@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,720 @@
1
+ // src/components/ZUI/useZUIInteraction.ts
2
+
3
+ import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
4
+ import type { BBox, DiagramGroupLayout, LayoutNode, ZUIViewState, HoveredItem } from './types'
5
+ import { getExpandThresholds } from './renderer'
6
+
7
+ function constrainViewState(view: ZUIViewState, canvasW: number, canvasH: number, bbox: BBox): ZUIViewState {
8
+ const padding = 600 // pixels
9
+ const minX = padding - bbox.maxX * view.zoom
10
+ const maxX = canvasW - padding - bbox.minX * view.zoom
11
+ const minY = padding - bbox.maxY * view.zoom
12
+ const maxY = canvasH - padding - bbox.minY * view.zoom
13
+
14
+ let { x, y } = view
15
+ if (maxX >= minX) x = Math.max(minX, Math.min(maxX, x))
16
+ else x = (minX + maxX) / 2
17
+
18
+ if (maxY >= minY) y = Math.max(minY, Math.min(maxY, y))
19
+ else y = (minY + maxY) / 2
20
+
21
+ return { ...view, x, y }
22
+ }
23
+
24
+ interface DeepestNodeResult {
25
+ node: LayoutNode
26
+ absX: number
27
+ absY: number
28
+ absW: number
29
+ absH: number
30
+ cumulativeScale: number
31
+ }
32
+
33
+ function findDeepestAt(worldX: number, worldY: number, groups: DiagramGroupLayout[], view: ZUIViewState, thresholds: { start: number, end: number }): DeepestNodeResult | null {
34
+ for (const group of groups) {
35
+ if (worldX >= group.worldX && worldX <= group.worldX + group.worldW &&
36
+ worldY >= group.worldY && worldY <= group.worldY + group.worldH) {
37
+ // Root nodes in the group have absolute world coordinates already
38
+ return findDeepestInNodes(worldX, worldY, group.nodes, 0, 0, 1, 0, 0, view, thresholds)
39
+ }
40
+ }
41
+ return null
42
+ }
43
+
44
+ function findDeepestInNodes(
45
+ worldX: number,
46
+ worldY: number,
47
+ nodes: LayoutNode[],
48
+ parentAbsX: number,
49
+ parentAbsY: number,
50
+ parentAbsScale: number,
51
+ parentChildOffsetX: number,
52
+ parentChildOffsetY: number,
53
+ view: ZUIViewState,
54
+ thresholds: { start: number, end: number }
55
+ ): DeepestNodeResult | null {
56
+ for (const node of nodes) {
57
+ if (worldX >= node.worldX && worldX <= node.worldX + node.worldW &&
58
+ worldY >= node.worldY && worldY <= node.worldY + node.worldH) {
59
+
60
+ // Screen width of this node at current zoom level
61
+ const worldW = node.worldW * parentAbsScale
62
+ const screenW = worldW * view.zoom
63
+
64
+ // Visibility check: if node is too small to be drawn, skip it
65
+ if (screenW < 2) continue
66
+
67
+ // Absolute world position of THIS node
68
+ const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
69
+ const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
70
+ const absW = worldW
71
+ const absH = node.worldH * parentAbsScale
72
+
73
+ const childX = (worldX - node.worldX) / node.childScale + node.childOffsetX
74
+ const childY = (worldY - node.worldY) / node.childScale + node.childOffsetY
75
+
76
+ const hasChildren = node.children && node.children.length > 0
77
+ const t = hasChildren ? Math.max(0, Math.min(1, (screenW - thresholds.start) / (thresholds.end - thresholds.start))) : 0
78
+
79
+ // If children are significantly visible, descend
80
+ if (t > 0.05) {
81
+ const deeper = findDeepestInNodes(
82
+ childX,
83
+ childY,
84
+ node.children,
85
+ absX,
86
+ absY,
87
+ parentAbsScale * node.childScale,
88
+ node.childOffsetX,
89
+ node.childOffsetY,
90
+ view,
91
+ thresholds
92
+ )
93
+ if (deeper) return deeper
94
+ }
95
+
96
+ // If the node has fully transitioned to its children, the parent itself is no longer hoverable
97
+ if (t > 0.95) return null
98
+
99
+ return { node, absX, absY, absW, absH, cumulativeScale: parentAbsScale }
100
+ }
101
+ }
102
+ return null
103
+ }
104
+
105
+ function findHoveredGroup(worldX: number, worldY: number, groups: DiagramGroupLayout[], view: ZUIViewState): DiagramGroupLayout | null {
106
+ for (const group of groups) {
107
+ // Check if mouse is near the diagram label (placed above the main diagram box)
108
+ const labelCenterX = group.worldX + group.diagramX + group.diagramW / 2
109
+ const labelTop = group.worldY + group.diagramY - 50 / view.zoom
110
+ const labelBot = group.worldY + group.diagramY
111
+
112
+ // Estimated width for the label hit-target
113
+ const labelHalfW = 100 / view.zoom
114
+
115
+ if (worldX >= labelCenterX - labelHalfW && worldX <= labelCenterX + labelHalfW &&
116
+ worldY >= labelTop && worldY <= labelBot) {
117
+ return group
118
+ }
119
+ }
120
+ return null
121
+ }
122
+
123
+ function findHoveredEdge(
124
+ worldX: number,
125
+ worldY: number,
126
+ groups: DiagramGroupLayout[],
127
+ view: ZUIViewState
128
+ ): HoveredItem | null {
129
+ const threshold = 18 / view.zoom // 18 screen pixels converted to world distance
130
+
131
+ for (const group of groups) {
132
+ const nodeMap = new Map<string, LayoutNode>()
133
+ for (const node of group.nodes) {
134
+ nodeMap.set(node.id, node)
135
+ }
136
+
137
+ for (const edge of group.edges) {
138
+ const source = nodeMap.get(edge.sourceId)
139
+ const target = nodeMap.get(edge.targetId)
140
+ if (!source || !target) continue
141
+
142
+ // Node centers
143
+ const x1 = source.worldX + source.worldW / 2
144
+ const y1 = source.worldY + source.worldH / 2
145
+ const x2 = target.worldX + target.worldW / 2
146
+ const y2 = target.worldY + target.worldH / 2
147
+
148
+ // Midpoint for popover placement
149
+ const midX = (x1 + x2) / 2
150
+ const midY = (y1 + y2) / 2
151
+
152
+ // Distance from point to line segment
153
+ const dx = x2 - x1
154
+ const dy = y2 - y1
155
+ const l2 = dx * dx + dy * dy
156
+ if (l2 === 0) continue
157
+
158
+ let t = ((worldX - x1) * dx + (worldY - y1) * dy) / l2
159
+ t = Math.max(0, Math.min(1, t))
160
+
161
+ const nearestX = x1 + t * dx
162
+ const nearestY = y1 + t * dy
163
+ const dist = Math.sqrt((worldX - nearestX) ** 2 + (worldY - nearestY) ** 2)
164
+
165
+ if (dist < threshold) {
166
+ return {
167
+ type: 'edge',
168
+ data: {
169
+ sourceId: source.label,
170
+ targetId: target.label,
171
+ label: edge.label || 'Connection',
172
+ diagramId: group.diagramId,
173
+ sourceObjId: source.elementId,
174
+ targetObjId: target.elementId
175
+ },
176
+ absX: midX,
177
+ absY: midY
178
+ }
179
+ }
180
+ }
181
+
182
+ // ── Squiggly lines to portal nodes ──
183
+ for (const node of group.nodes) {
184
+ if (node.isPortal) {
185
+ // Line from diagram bottom center to portal top center
186
+ const x1 = group.worldX + group.diagramX + group.diagramW / 2
187
+ const y1 = group.worldY + group.diagramY + group.diagramH
188
+ const x2 = node.worldX + node.worldW / 2
189
+ const y2 = node.worldY
190
+
191
+ const midX = (x1 + x2) / 2
192
+ const midY = (y1 + y2) / 2
193
+
194
+ const dx = x2 - x1
195
+ const dy = y2 - y1
196
+ const l2 = dx * dx + dy * dy
197
+ if (l2 === 0) continue
198
+
199
+ let t = ((worldX - x1) * dx + (worldY - y1) * dy) / l2
200
+ t = Math.max(0, Math.min(1, t))
201
+
202
+ const nearestX = x1 + t * dx
203
+ const nearestY = y1 + t * dy
204
+ const dist = Math.sqrt((worldX - nearestX) ** 2 + (worldY - nearestY) ** 2)
205
+
206
+ if (dist < threshold) {
207
+ return {
208
+ type: 'edge',
209
+ data: {
210
+ sourceId: group.label,
211
+ targetId: node.label,
212
+ label: '',
213
+ diagramId: group.diagramId,
214
+ targetDiagId: node.linkedDiagramId,
215
+ isPortalConn: true
216
+ },
217
+ absX: midX,
218
+ absY: midY
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+ return null
225
+ }
226
+
227
+ export function calculateMaxZoom(groups: DiagramGroupLayout[], canvasW: number): number {
228
+ if (canvasW <= 0) return 40
229
+ const thresholds = getExpandThresholds(canvasW)
230
+ let maxPossibleZoom = 40
231
+
232
+ function visitNodes(nodes: LayoutNode[], cumulativeScale: number) {
233
+ for (const node of nodes) {
234
+ if (!node.children || node.children.length === 0) {
235
+ // This is a leaf node. We want it to be able to fill 'thresholds.end' of the canvas.
236
+ const neededZoom = thresholds.end / (node.worldW * cumulativeScale)
237
+ if (neededZoom > maxPossibleZoom) {
238
+ maxPossibleZoom = neededZoom
239
+ }
240
+ } else {
241
+ visitNodes(node.children, cumulativeScale * node.childScale)
242
+ }
243
+ }
244
+ }
245
+
246
+ for (const group of groups) {
247
+ visitNodes(group.nodes, 1)
248
+ }
249
+
250
+ return maxPossibleZoom
251
+ }
252
+
253
+ const MIN_ZOOM = 0.4
254
+
255
+ function clampZoom(z: number, prevZ: number, maxZ: number): number {
256
+ if (z > prevZ) {
257
+ // Zooming IN: cap at maxZ (but don't force down if already above)
258
+ return Math.min(z, Math.max(prevZ, maxZ))
259
+ } else {
260
+ // Zooming OUT: ignore maxZ (only cap at global MIN_ZOOM)
261
+ return Math.max(z, MIN_ZOOM)
262
+ }
263
+ }
264
+
265
+ /** Zoom toward/away from a screen-space focal point. */
266
+ function zoomAround(
267
+ view: ZUIViewState,
268
+ focalX: number,
269
+ focalY: number,
270
+ factor: number,
271
+ maxZoom: number,
272
+ ): ZUIViewState {
273
+ const newZoom = clampZoom(view.zoom * factor, view.zoom, maxZoom)
274
+ const scale = newZoom / view.zoom
275
+ return {
276
+ zoom: newZoom,
277
+ x: focalX - (focalX - view.x) * scale,
278
+ y: focalY - (focalY - view.y) * scale,
279
+ }
280
+ }
281
+
282
+ export interface ZUIInteraction {
283
+ viewState: ZUIViewState
284
+ /** Ref that is updated synchronously on every input event use this in RAF loops to avoid waiting for React renders. */
285
+ viewStateRef: React.MutableRefObject<ZUIViewState>
286
+ setViewState: React.Dispatch<React.SetStateAction<ZUIViewState>>
287
+ /** Call with the canvas DOMRect + layout bbox to fit all content. */
288
+ fitView: (
289
+ canvasW: number,
290
+ canvasH: number,
291
+ bbox: { minX: number; minY: number; maxX: number; maxY: number },
292
+ padding?: number,
293
+ ) => void
294
+ maxZoom: number
295
+ hoveredItem: HoveredItem | null
296
+ setHoveredItem: (item: HoveredItem | null, force?: boolean) => void
297
+ /** Set to true to prevent clearing hoveredItem (e.g. when mouse is over a popover). */
298
+ setHoverLocked: (locked: boolean) => void
299
+ }
300
+
301
+ export function useZUIInteraction(
302
+ canvasRef: React.RefObject<HTMLCanvasElement | null>,
303
+ initialView: ZUIViewState = { x: 0, y: 0, zoom: 0.3 },
304
+ groups: DiagramGroupLayout[] = [],
305
+ bbox?: BBox,
306
+ onZoom?: () => void,
307
+ onPan?: () => void,
308
+ isMobile: boolean = false,
309
+ resolveHoveredProxyItem?: (worldX: number, worldY: number, view: ZUIViewState, canvasW: number) => HoveredItem | null,
310
+ ): ZUIInteraction {
311
+ const [viewState, setViewStateInternal] = useState<ZUIViewState>(initialView)
312
+ const [hoveredItem, setHoveredItemInternal] = useState<HoveredItem | null>(null)
313
+ const hoverLockedRef = useRef(false)
314
+ const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
315
+
316
+ const setHoveredItem = useCallback((item: HoveredItem | null, force = false) => {
317
+ if (hoverTimeoutRef.current) {
318
+ clearTimeout(hoverTimeoutRef.current)
319
+ hoverTimeoutRef.current = null
320
+ }
321
+
322
+ if (item === null) {
323
+ if (force) {
324
+ setHoveredItemInternal(null)
325
+ return
326
+ }
327
+ // Grace period before clearing hover to allow mouse to reach popover
328
+ hoverTimeoutRef.current = setTimeout(() => {
329
+ if (!hoverLockedRef.current) {
330
+ setHoveredItemInternal(null)
331
+ }
332
+ }, 100)
333
+ } else {
334
+ setHoveredItemInternal(item)
335
+ }
336
+ }, [])
337
+
338
+ const setHoverLocked = useCallback((locked: boolean) => {
339
+ hoverLockedRef.current = locked
340
+ if (locked && hoverTimeoutRef.current) {
341
+ clearTimeout(hoverTimeoutRef.current)
342
+ hoverTimeoutRef.current = null
343
+ }
344
+ if (!locked) {
345
+ // If we unlock and there is no item currently being "detected" by mouse,
346
+ // it should ideally clear soon. The next mouse move will handle it.
347
+ }
348
+ }, [])
349
+
350
+ // ── Refs for stable event handlers ──────────────────────────────
351
+ const viewStateRef = useRef<ZUIViewState>(initialView)
352
+ const groupsRef = useRef<DiagramGroupLayout[]>(groups)
353
+ const bboxRef = useRef<BBox | undefined>(bbox)
354
+ const onZoomRef = useRef(onZoom)
355
+ const onPanRef = useRef(onPan)
356
+
357
+ useEffect(() => {
358
+ groupsRef.current = groups
359
+ bboxRef.current = bbox
360
+ onZoomRef.current = onZoom
361
+ onPanRef.current = onPan
362
+ }, [groups, bbox, onZoom, onPan])
363
+
364
+ const [lastCanvasW, setLastCanvasW] = useState(0)
365
+
366
+ const dynamicMaxZoom = useMemo(() => {
367
+ return calculateMaxZoom(groups, lastCanvasW || 1200) // Fallback width for initial calc
368
+ }, [groups, lastCanvasW])
369
+
370
+ const maxZoomRef = useRef(40)
371
+ useEffect(() => {
372
+ maxZoomRef.current = dynamicMaxZoom
373
+ }, [dynamicMaxZoom])
374
+
375
+ const setViewState = useCallback(
376
+ (update: React.SetStateAction<ZUIViewState>) => {
377
+ setViewStateInternal((prev) => {
378
+ const next = typeof update === 'function' ? (update as (p: ZUIViewState) => ZUIViewState)(prev) : update
379
+ const box = bboxRef.current
380
+ if (!box || !canvasRef.current) {
381
+ viewStateRef.current = next
382
+ return next
383
+ }
384
+ const el = canvasRef.current
385
+ const w = el.clientWidth || el.width / (window.devicePixelRatio || 1)
386
+ const h = el.clientHeight || el.height / (window.devicePixelRatio || 1)
387
+
388
+ if (w !== lastCanvasW && w > 0) {
389
+ setLastCanvasW(w)
390
+ }
391
+
392
+ if (w === 0 || h === 0) {
393
+ viewStateRef.current = next
394
+ return next
395
+ }
396
+ const constrained = constrainViewState(next, w, h, box)
397
+ viewStateRef.current = constrained
398
+ return constrained
399
+ })
400
+ },
401
+ [canvasRef, lastCanvasW],
402
+ )
403
+
404
+ const dragging = useRef(false)
405
+ const lastMouse = useRef({ x: 0, y: 0 })
406
+ const lastPinchDist = useRef<number | null>(null)
407
+ const lastPinchMid = useRef({ x: 0, y: 0 })
408
+
409
+ const fitView = useCallback(
410
+ (
411
+ canvasW: number,
412
+ canvasH: number,
413
+ bbox: { minX: number; minY: number; maxX: number; maxY: number },
414
+ padding = 0.1,
415
+ ) => {
416
+ if (canvasW !== lastCanvasW && canvasW > 0) {
417
+ setLastCanvasW(canvasW)
418
+ }
419
+ const bboxW = bbox.maxX - bbox.minX
420
+ const bboxH = bbox.maxY - bbox.minY
421
+ if (bboxW <= 0 || bboxH <= 0) return
422
+
423
+ const currentMaxZ = calculateMaxZoom(groupsRef.current, canvasW)
424
+ const zoom = Math.max(MIN_ZOOM, Math.min(currentMaxZ,
425
+ Math.min(
426
+ (canvasW * (1 - padding * 2)) / bboxW,
427
+ (canvasH * (1 - padding * 2)) / bboxH,
428
+ ),
429
+ ))
430
+ const x = (canvasW - bboxW * zoom) / 2 - bbox.minX * zoom
431
+ const y = (canvasH - bboxH * zoom) / 2 - bbox.minY * zoom
432
+ setViewState({ x, y, zoom })
433
+ },
434
+ [setViewState, lastCanvasW],
435
+ )
436
+
437
+ const lastPanTimeRef = useRef(0)
438
+
439
+ useEffect(() => {
440
+ const el = canvasRef.current
441
+ if (!el) return
442
+
443
+ function onWheel(e: WheelEvent) {
444
+ // Heuristic to distinguish between trackpad and physical mouse wheel:
445
+ // 1. If ctrlKey is true, it's a pinch (trackpad) or Ctrl+Wheel. We always zoom.
446
+ // 2. If deltaMode !== 0, it's a physical mouse wheel (DOM_DELTA_LINE/PAGE). We zoom.
447
+ // 3. Only zoom on notched mouse wheel, not trackpad pan gestures.
448
+ const isPinch = e.ctrlKey
449
+
450
+ // We don't have isRecentMultiTouch yet, but we can check if it looks like a mouse wheel
451
+ const isMouseWheel = e.deltaMode !== 0 || (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 20)
452
+
453
+ // On mobile, Safari synthesizes wheel events for pinches.
454
+ // If it's not a pinch or a real mouse wheel, we ignore it to allow native gestures or prevent conflicts.
455
+ if (isMobile && !isPinch && !isMouseWheel) return
456
+
457
+ e.preventDefault()
458
+ setHoveredItem(null, true) // Clear popover immediately on zoom/pan
459
+
460
+ // Track multi-touch wheel events (deltaX !== 0 indicates two-finger contact on trackpad)
461
+ if (e.deltaX !== 0) {
462
+ lastPanTimeRef.current = Date.now()
463
+ }
464
+
465
+ // If we just finished a multi-touch gesture, suppress zoom for ~1000ms (trackpad momentum can last longer)
466
+ const isRecentMultiTouch = Date.now() - lastPanTimeRef.current < 1000
467
+
468
+ // Re-evaluate isMouseWheel with trackpad suppression for desktop
469
+ const isNotchedWheel = !isRecentMultiTouch && e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 20
470
+ const isRealMouseWheel = e.deltaMode !== 0 || isNotchedWheel
471
+
472
+ if (isPinch || isRealMouseWheel) {
473
+ const rect = el!.getBoundingClientRect()
474
+ const focalX = e.clientX - rect.left
475
+ const focalY = e.clientY - rect.top
476
+
477
+ // Use standard factors for zoom
478
+ let factor = 1 - e.deltaY * (isRealMouseWheel ? 0.002 : 0.01)
479
+ factor = Math.max(0.85, Math.min(1.15, factor))
480
+
481
+ setViewState((prev) => {
482
+ const worldX = (focalX - prev.x) / prev.zoom
483
+ const worldY = (focalY - prev.y) / prev.zoom
484
+ const thresholds = getExpandThresholds(rect.width)
485
+ const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
486
+
487
+ let currentMaxZ = maxZoomRef.current
488
+
489
+ if (deepest && (!deepest.node.children || deepest.node.children.length === 0)) {
490
+ currentMaxZ = thresholds.end / (deepest.node.worldW * deepest.cumulativeScale)
491
+ }
492
+
493
+ return zoomAround(prev, focalX, focalY, factor, currentMaxZ)
494
+ })
495
+ onZoomRef.current?.()
496
+ } else if (!isMobile) {
497
+ // Trackpad panning - disabled on mobile to avoid interference with pinch-to-zoom
498
+ setViewState((prev) => ({ ...prev, x: prev.x - e.deltaX, y: prev.y - e.deltaY }))
499
+ onPanRef.current?.()
500
+ }
501
+ }
502
+
503
+ function onMouseDown(e: MouseEvent) {
504
+ if (e.button !== 0) return
505
+ dragging.current = true
506
+ lastMouse.current = { x: e.clientX, y: e.clientY }
507
+ el!.style.cursor = 'grabbing'
508
+ setHoveredItem(null, true) // Hide popover immediately while dragging
509
+ }
510
+
511
+ function onMouseMove(e: MouseEvent) {
512
+ if (hoverLockedRef.current) return
513
+
514
+ const rect = el!.getBoundingClientRect()
515
+ const screenX = e.clientX - rect.left
516
+ const screenY = e.clientY - rect.top
517
+
518
+ if (dragging.current) {
519
+ const dx = e.clientX - lastMouse.current.x
520
+ const dy = e.clientY - lastMouse.current.y
521
+ lastMouse.current = { x: e.clientX, y: e.clientY }
522
+ setViewState((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }))
523
+ onPanRef.current?.()
524
+ return
525
+ }
526
+
527
+ // Hover detection
528
+ const view = viewStateRef.current
529
+ const worldX = (screenX - view.x) / view.zoom
530
+ const worldY = (screenY - view.y) / view.zoom
531
+ const thresholds = getExpandThresholds(rect.width)
532
+
533
+ const deepest = findDeepestAt(worldX, worldY, groupsRef.current, view, thresholds)
534
+ if (deepest) {
535
+ const { node, absX, absY, absW, absH } = deepest
536
+ setHoveredItem({
537
+ type: 'node',
538
+ data: node,
539
+ absX,
540
+ absY,
541
+ absW,
542
+ absH
543
+ })
544
+ } else {
545
+ const proxyEdge = resolveHoveredProxyItem?.(worldX, worldY, view, rect.width) ?? null
546
+ if (proxyEdge) {
547
+ setHoveredItem(proxyEdge)
548
+ return
549
+ }
550
+ const edge = findHoveredEdge(worldX, worldY, groupsRef.current, view)
551
+ if (edge) {
552
+ setHoveredItem(edge)
553
+ } else {
554
+ const group = findHoveredGroup(worldX, worldY, groupsRef.current, view)
555
+ if (group) {
556
+ setHoveredItem({
557
+ type: 'group',
558
+ data: group
559
+ })
560
+ } else {
561
+ setHoveredItem(null)
562
+ }
563
+ }
564
+ }
565
+ }
566
+
567
+ function onMouseUp() {
568
+ dragging.current = false
569
+ if (el) el.style.cursor = 'grab'
570
+ }
571
+
572
+ function onMouseOut() {
573
+ setHoveredItem(null)
574
+ }
575
+
576
+ function onDblClick(e: MouseEvent) {
577
+ const rect = el!.getBoundingClientRect()
578
+ const focalX = e.clientX - rect.left
579
+ const focalY = e.clientY - rect.top
580
+ setHoveredItem(null, true) // Clear popover immediately on double-click zoom
581
+
582
+ setViewState((prev) => {
583
+ const worldX = (focalX - prev.x) / prev.zoom
584
+ const worldY = (focalY - prev.y) / prev.zoom
585
+ const thresholds = getExpandThresholds(rect.width)
586
+ const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
587
+
588
+ let currentMaxZ = maxZoomRef.current
589
+
590
+ if (deepest && (!deepest.node.children || deepest.node.children.length === 0)) {
591
+ currentMaxZ = thresholds.end / (deepest.node.worldW * deepest.cumulativeScale)
592
+ }
593
+
594
+ return zoomAround(prev, focalX, focalY, 2, currentMaxZ)
595
+ })
596
+ onZoomRef.current?.()
597
+ }
598
+
599
+ // ── Touch pan + pinch ──────────────────────────────────────────
600
+ function pinchDist(touches: TouchList): number {
601
+ if (touches.length < 2) return 0
602
+ const dx = touches[0].clientX - touches[1].clientX
603
+ const dy = touches[0].clientY - touches[1].clientY
604
+ return Math.sqrt(dx * dx + dy * dy)
605
+ }
606
+
607
+ function pinchMid(touches: TouchList): { x: number; y: number } {
608
+ const rect = el!.getBoundingClientRect()
609
+ if (touches.length < 2) {
610
+ return { x: touches[0].clientX - rect.left, y: touches[0].clientY - rect.top }
611
+ }
612
+ return {
613
+ x: (touches[0].clientX + touches[1].clientX) / 2 - rect.left,
614
+ y: (touches[0].clientY + touches[1].clientY) / 2 - rect.top,
615
+ }
616
+ }
617
+
618
+ function onTouchStart(e: TouchEvent) {
619
+ e.preventDefault()
620
+ if (e.touches.length === 1) {
621
+ dragging.current = true
622
+ lastMouse.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
623
+ lastPinchDist.current = null
624
+ } else if (e.touches.length >= 2) {
625
+ dragging.current = false
626
+ const dist = pinchDist(e.touches)
627
+ lastPinchDist.current = dist > 0 ? dist : null
628
+ lastPinchMid.current = pinchMid(e.touches)
629
+ }
630
+ }
631
+
632
+ function onTouchMove(e: TouchEvent) {
633
+ e.preventDefault()
634
+ setHoveredItem(null, true) // Clear popover immediately on touch movement
635
+ if (e.touches.length === 1 && dragging.current) {
636
+ const dx = e.touches[0].clientX - lastMouse.current.x
637
+ const dy = e.touches[0].clientY - lastMouse.current.y
638
+ lastMouse.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
639
+ setViewState((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy }))
640
+ onPanRef.current?.()
641
+ } else if (e.touches.length >= 2) {
642
+ const dist = pinchDist(e.touches)
643
+ const mid = pinchMid(e.touches)
644
+ if (lastPinchDist.current !== null && lastPinchDist.current > 0) {
645
+ const factor = dist / lastPinchDist.current
646
+ const dx = mid.x - lastPinchMid.current.x
647
+ const dy = mid.y - lastPinchMid.current.y
648
+
649
+ if (isFinite(factor) && factor > 0) {
650
+ setViewState((prev) => {
651
+ const rect = el!.getBoundingClientRect()
652
+ const worldX = (mid.x - prev.x) / prev.zoom
653
+ const worldY = (mid.y - prev.y) / prev.zoom
654
+ const thresholds = getExpandThresholds(rect.width)
655
+ const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
656
+
657
+ let currentMaxZ = maxZoomRef.current
658
+
659
+ if (deepest && (!deepest.node.children || deepest.node.children.length === 0)) {
660
+ currentMaxZ = thresholds.end / (deepest.node.worldW * deepest.cumulativeScale)
661
+ }
662
+
663
+ const zoomed = zoomAround(prev, mid.x, mid.y, factor, currentMaxZ)
664
+ return { ...zoomed, x: zoomed.x + dx, y: zoomed.y + dy }
665
+ })
666
+ onZoomRef.current?.()
667
+ }
668
+ }
669
+ lastPinchDist.current = dist > 0 ? dist : lastPinchDist.current
670
+ lastPinchMid.current = mid
671
+ }
672
+ }
673
+ function onTouchEnd(e: TouchEvent) {
674
+ if (e.touches.length === 0) {
675
+ dragging.current = false
676
+ lastPinchDist.current = null
677
+ } else if (e.touches.length === 1) {
678
+ // Transition back to dragging with the single remaining finger
679
+ dragging.current = true
680
+ lastMouse.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }
681
+ lastPinchDist.current = null
682
+ } else {
683
+ // Still have multiple fingers, reset baseline to avoid jumps
684
+ const dist = pinchDist(e.touches)
685
+ lastPinchDist.current = dist > 0 ? dist : null
686
+ lastPinchMid.current = pinchMid(e.touches)
687
+ }
688
+ }
689
+
690
+ el.style.cursor = 'grab'
691
+
692
+ el.addEventListener('wheel', onWheel, { passive: false })
693
+ el.addEventListener('mousedown', onMouseDown)
694
+ el.addEventListener('mouseleave', onMouseOut)
695
+ el.addEventListener('mouseout', onMouseOut)
696
+ window.addEventListener('mousemove', onMouseMove)
697
+ window.addEventListener('mouseup', onMouseUp)
698
+ el.addEventListener('dblclick', onDblClick)
699
+ el.addEventListener('touchstart', onTouchStart, { passive: false })
700
+ el.addEventListener('touchmove', onTouchMove, { passive: false })
701
+ el.addEventListener('touchend', onTouchEnd)
702
+ el.addEventListener('touchcancel', onTouchEnd)
703
+
704
+ return () => {
705
+ el.removeEventListener('wheel', onWheel)
706
+ el.removeEventListener('mousedown', onMouseDown)
707
+ el.removeEventListener('mouseleave', onMouseOut)
708
+ el.removeEventListener('mouseout', onMouseOut)
709
+ window.removeEventListener('mousemove', onMouseMove)
710
+ window.removeEventListener('mouseup', onMouseUp)
711
+ el.removeEventListener('dblclick', onDblClick)
712
+ el.removeEventListener('touchstart', onTouchStart)
713
+ el.removeEventListener('touchmove', onTouchMove)
714
+ el.removeEventListener('touchend', onTouchEnd)
715
+ el.removeEventListener('touchcancel', onTouchEnd)
716
+ }
717
+ }, [canvasRef, setViewState, setHoveredItem, isMobile, resolveHoveredProxyItem]) // groupsRef handles groups updates without re-binding!
718
+
719
+ return { viewState, viewStateRef, setViewState, fitView, maxZoom: dynamicMaxZoom, hoveredItem, setHoveredItem, setHoverLocked }
720
+ }