@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,127 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { DrawingPath } from '../../../components/DrawingCanvas'
3
+ import { DRAWING_COLORS } from '../../../constants/colors'
4
+
5
+ export function useDrawingEngine(viewId: number | null) {
6
+ const [drawingMode, setDrawingMode] = useState(false)
7
+ const [drawingVisible, setDrawingVisible] = useState(true)
8
+ const [drawingPaths, setDrawingPaths] = useState<DrawingPath[]>([])
9
+ const [drawingTool, setDrawingTool] = useState<'pencil' | 'eraser' | 'text' | 'select'>('pencil')
10
+ const [drawingColor, setDrawingColor] = useState(DRAWING_COLORS[0])
11
+ const [drawingWidth, setDrawingWidth] = useState(3)
12
+ const [textEditorState, setTextEditorState] = useState<{
13
+ canvasX: number; canvasY: number; flowX: number; flowY: number
14
+ } | null>(null)
15
+
16
+ const drawingHistoryRef = useRef<DrawingPath[][]>([])
17
+ const drawingRedoStackRef = useRef<DrawingPath[][]>([])
18
+
19
+ const lastViewIdRef = useRef<number | null>(null)
20
+
21
+ // Reset drawing state only when viewId actually changes to a new value
22
+ useEffect(() => {
23
+ if (lastViewIdRef.current !== viewId) {
24
+ setDrawingPaths([])
25
+ setDrawingMode(false)
26
+ setTextEditorState(null)
27
+ lastViewIdRef.current = viewId
28
+ }
29
+ }, [viewId])
30
+
31
+ const handleUndo = useCallback(() => {
32
+ if (drawingHistoryRef.current.length === 0) return
33
+ const prevState = drawingHistoryRef.current.pop()!
34
+ setDrawingPaths((current) => {
35
+ drawingRedoStackRef.current.push([...current])
36
+ return prevState
37
+ })
38
+ }, [])
39
+
40
+ const handleRedo = useCallback(() => {
41
+ if (drawingRedoStackRef.current.length === 0) return
42
+ const nextState = drawingRedoStackRef.current.pop()!
43
+ setDrawingPaths((current) => {
44
+ drawingHistoryRef.current.push([...current])
45
+ return nextState
46
+ })
47
+ }, [])
48
+
49
+ const commitDrawingText = useCallback(
50
+ (value: string, state: { canvasX: number; canvasY: number; flowX: number; flowY: number }) => {
51
+ setTextEditorState(null)
52
+ if (!value.trim()) return
53
+ const path: DrawingPath = {
54
+ id: `text-${Date.now()}-${Math.random().toString(36).slice(2)}`,
55
+ points: [{ x: state.flowX, y: state.flowY }],
56
+ color: drawingColor,
57
+ width: drawingWidth,
58
+ text: value,
59
+ fontSize: Math.max(14, drawingWidth * 5),
60
+ }
61
+ setDrawingPaths((prev) => {
62
+ drawingHistoryRef.current.push([...prev])
63
+ drawingRedoStackRef.current = []
64
+ return [...prev, path]
65
+ })
66
+ },
67
+ [drawingColor, drawingWidth],
68
+ )
69
+
70
+ const onPathComplete = useCallback(
71
+ (path: DrawingPath) => {
72
+ setDrawingPaths((prev) => {
73
+ drawingHistoryRef.current.push([...prev])
74
+ drawingRedoStackRef.current = []
75
+ return [...prev, path]
76
+ })
77
+ },
78
+ [],
79
+ )
80
+
81
+ const onPathDelete = useCallback(
82
+ (pathId: string) => {
83
+ setDrawingPaths((prev) => {
84
+ drawingHistoryRef.current.push([...prev])
85
+ drawingRedoStackRef.current = []
86
+ return prev.filter((p) => p.id !== pathId)
87
+ })
88
+ },
89
+ [],
90
+ )
91
+
92
+ const onPathUpdate = useCallback(
93
+ (path: DrawingPath) => {
94
+ setDrawingPaths((prev) => {
95
+ drawingHistoryRef.current.push([...prev])
96
+ drawingRedoStackRef.current = []
97
+ return prev.map(p => p.id === path.id ? path : p)
98
+ })
99
+ },
100
+ [],
101
+ )
102
+
103
+ return {
104
+ drawingMode,
105
+ setDrawingMode,
106
+ drawingVisible,
107
+ setDrawingVisible,
108
+ drawingPaths,
109
+ setDrawingPaths,
110
+ drawingTool,
111
+ setDrawingTool,
112
+ drawingColor,
113
+ setDrawingColor,
114
+ drawingWidth,
115
+ setDrawingWidth,
116
+ textEditorState,
117
+ setTextEditorState,
118
+ drawingHistoryRef,
119
+ drawingRedoStackRef,
120
+ handleUndo,
121
+ handleRedo,
122
+ commitDrawingText,
123
+ onPathComplete,
124
+ onPathDelete,
125
+ onPathUpdate,
126
+ }
127
+ }
@@ -0,0 +1,501 @@
1
+ import { useMemo } from 'react'
2
+ import { type Edge as RFEdge, type Node as RFNode } from 'reactflow'
3
+ import type { PlacedElement } from '../../../types'
4
+ import type { CrossBranchContextSettings, ProxyConnectorDetails, WorkspaceGraphSnapshot } from '../../../crossBranch/types'
5
+ import { resolveViewProxyGraph } from '../../../crossBranch/resolve'
6
+
7
+ interface Props {
8
+ snapshot: WorkspaceGraphSnapshot | null
9
+ settings: CrossBranchContextSettings
10
+ viewId: number | null
11
+ viewElements: PlacedElement[]
12
+ rfNodes: RFNode[]
13
+ stableOnNavigateToView: (id: number) => void
14
+ onSelectProxyDetails: (details: ProxyConnectorDetails) => void
15
+ expandedAncestorGroups: Set<string>
16
+ onToggleAncestorGroup: (anchorId: string) => void
17
+ }
18
+
19
+ type ContextSide = 'top' | 'bottom' | 'left' | 'right'
20
+
21
+ const CONTEXT_NODE_W = 200
22
+ const CONTEXT_NODE_H = 100
23
+ const CONTEXT_NODE_HALF_W = CONTEXT_NODE_W / 2
24
+ const CONTEXT_NODE_HALF_H = CONTEXT_NODE_H / 2
25
+ const HORIZONTAL_STACK_SPACING = 128
26
+ const VERTICAL_STACK_SPACING = 74
27
+ const HORIZONTAL_BOUNDARY_CLEARANCE = 72
28
+ const VERTICAL_BOUNDARY_CLEARANCE = 44
29
+ // Extra space to clear the chevron button when a cluster is expanded.
30
+ // The chevron sits at 75% of the node's layout dimension (scale-0.5 visual edge) + 6px offset.
31
+ // For right side: visual right ≈ 150px, chevron ~36px wide → children need ~80px extra.
32
+ // For bottom side: visual bottom ≈ 75px, chevron ~14px tall → children need ~30px extra.
33
+ const CHEVRON_H_CLEARANCE = 30
34
+ const CHEVRON_V_CLEARANCE = 10
35
+ const SIDE_CLUSTER_THRESHOLD = 90
36
+ const TOP_BOTTOM_CLUSTER_THRESHOLD = 140
37
+
38
+ function stableAngleFromId(id: string) {
39
+ let hash = 0
40
+ for (let i = 0; i < id.length; i += 1) hash = ((hash << 5) - hash) + id.charCodeAt(i)
41
+ return ((hash % 360) * Math.PI) / 180
42
+ }
43
+
44
+ function averageAngles(angles: number[]): number {
45
+ if (angles.length === 0) return 0
46
+ const sum = angles.reduce((acc, angle) => ({
47
+ x: acc.x + Math.cos(angle),
48
+ y: acc.y + Math.sin(angle),
49
+ }), { x: 0, y: 0 })
50
+ if (sum.x === 0 && sum.y === 0) return angles[0]
51
+ return Math.atan2(sum.y, sum.x)
52
+ }
53
+
54
+ function classifySide(angle: number): ContextSide {
55
+ const dx = Math.cos(angle)
56
+ const dy = Math.sin(angle)
57
+ if (Math.abs(dx) > Math.abs(dy)) return dx < 0 ? 'left' : 'right'
58
+ return dy < 0 ? 'top' : 'bottom'
59
+ }
60
+
61
+ function isAncestorContextNode(
62
+ snapshot: WorkspaceGraphSnapshot,
63
+ ancestor: { anchorElementId: number },
64
+ descendant: { placementViewId: number | null },
65
+ ): boolean {
66
+ const ownedViewId = snapshot.childViewIdByOwnerElementId[ancestor.anchorElementId]
67
+ if (ownedViewId == null || descendant.placementViewId == null) return false
68
+ return descendant.placementViewId === ownedViewId ||
69
+ (snapshot.descendantsByViewId[ownedViewId]?.includes(descendant.placementViewId) ?? false)
70
+ }
71
+
72
+ export function useViewContextNeighbours({
73
+ snapshot,
74
+ settings,
75
+ viewId,
76
+ viewElements,
77
+ rfNodes,
78
+ stableOnNavigateToView,
79
+ onSelectProxyDetails,
80
+ expandedAncestorGroups,
81
+ onToggleAncestorGroup,
82
+ }: Props) {
83
+ return useMemo(() => {
84
+ if (!snapshot || viewId == null || !settings.enabled) {
85
+ return { contextNodes: [] as RFNode[], contextConnectors: [] as RFEdge[], proxyConnectorDetailsByKey: {} as Record<string, ProxyConnectorDetails> }
86
+ }
87
+
88
+ const { proxyNodes, proxyConnectors, proxyConnectorDetailsByKey } = resolveViewProxyGraph(snapshot, viewId, viewElements, settings)
89
+ if (proxyNodes.length === 0 && proxyConnectors.length === 0) {
90
+ return { contextNodes: [] as RFNode[], contextConnectors: [] as RFEdge[], proxyConnectorDetailsByKey }
91
+ }
92
+
93
+ const mainNodes = rfNodes.filter((node) => node.type === 'elementNode')
94
+ if (mainNodes.length === 0) {
95
+ return { contextNodes: [] as RFNode[], contextConnectors: [] as RFEdge[], proxyConnectorDetailsByKey }
96
+ }
97
+
98
+ let minX = Infinity
99
+ let minY = Infinity
100
+ let maxX = -Infinity
101
+ let maxY = -Infinity
102
+ for (const node of mainNodes) {
103
+ const width = node.width ?? 200
104
+ const height = node.height ?? 90
105
+ minX = Math.min(minX, node.position.x)
106
+ minY = Math.min(minY, node.position.y)
107
+ maxX = Math.max(maxX, node.position.x + width)
108
+ maxY = Math.max(maxY, node.position.y + height)
109
+ }
110
+
111
+ const centerX = (minX + maxX) / 2
112
+ const centerY = (minY + maxY) / 2
113
+ const boundaryW = maxX - minX
114
+ const boundaryH = maxY - minY
115
+ const totalInset = 100
116
+ const padding = 180
117
+ const radiusX = boundaryW / 2 + padding
118
+ const radiusY = boundaryH / 2 + padding
119
+ const boundaryLeft = minX - totalInset
120
+ const boundaryRight = maxX + totalInset
121
+ const boundaryTop = minY - totalInset
122
+ const boundaryBottom = maxY + totalInset
123
+
124
+ const livePositions = new Map(rfNodes.map((node) => [node.id, node.position] as const))
125
+ const currentViewPositions = new Map(viewElements.map((element) => {
126
+ const live = livePositions.get(String(element.element_id))
127
+ return [
128
+ element.element_id,
129
+ live ? { ...element, position_x: live.x, position_y: live.y } : element,
130
+ ] as const
131
+ }))
132
+ const proxyNodeDetailsById = new Map(
133
+ proxyNodes.map((proxyNode) => {
134
+ const connectors = proxyConnectors
135
+ .filter((connector) => connector.sourceAnchorId === proxyNode.id || connector.targetAnchorId === proxyNode.id)
136
+ .flatMap((connector) => connector.details.connectors)
137
+
138
+ const ownerViews = new Map<number, string>()
139
+ connectors.forEach((leaf) => ownerViews.set(leaf.ownerViewId, leaf.ownerViewName))
140
+
141
+ const details: ProxyConnectorDetails = {
142
+ key: `node:${proxyNode.id}`,
143
+ label: 'Merged branch context',
144
+ count: connectors.length,
145
+ sourceAnchorId: proxyNode.id,
146
+ targetAnchorId: proxyNode.id,
147
+ sourceAnchorName: proxyNode.name,
148
+ targetAnchorName: 'Multiple connections',
149
+ ownerViewIds: Array.from(ownerViews.keys()),
150
+ ownerViewNames: Array.from(ownerViews.values()),
151
+ connectors,
152
+ }
153
+
154
+ return [proxyNode.id, details] as const
155
+ }),
156
+ )
157
+
158
+ const ContextBoundaryElement: RFNode = {
159
+ id: 'context-boundary',
160
+ type: 'ContextBoundaryElement',
161
+ position: { x: minX - totalInset, y: minY - totalInset },
162
+ selectable: false,
163
+ draggable: false,
164
+ connectable: false,
165
+ zIndex: 1,
166
+ data: {
167
+ width: boundaryW + totalInset * 2,
168
+ height: boundaryH + totalInset * 2,
169
+ parentName: snapshot.viewById[viewId]?.name ?? 'Current view',
170
+ onNavigateToDiagram: () => { /* intentionally read-only */ },
171
+ },
172
+ style: {
173
+ pointerEvents: 'none',
174
+ },
175
+ }
176
+
177
+ const provisionalContextLayouts = proxyNodes.map((contextNode) => {
178
+ const relatedAngles = proxyConnectors
179
+ .filter((connector) => connector.sourceAnchorId === contextNode.id || connector.targetAnchorId === contextNode.id)
180
+ .flatMap((connector) => {
181
+ const leafAngles = connector.details.connectors.map((leaf) => {
182
+ const external = leaf.source.externalToView ? leaf.source : leaf.target
183
+ if (external.anchorElementId !== contextNode.anchorElementId) return null
184
+ const internal = leaf.source.externalToView ? leaf.target : leaf.source
185
+
186
+ if (external.anchorViewId === viewId) {
187
+ const externalPos = currentViewPositions.get(external.anchorElementId)
188
+ const internalPos = currentViewPositions.get(internal.anchorElementId)
189
+ if (externalPos && internalPos) {
190
+ return Math.atan2(externalPos.position_y - internalPos.position_y, externalPos.position_x - internalPos.position_x)
191
+ }
192
+ }
193
+
194
+ if (external.commonAncestorViewId != null && external.currentBranchElementId != null) {
195
+ const commonAncestorPositions = snapshot.placementsByViewId[external.commonAncestorViewId] ?? []
196
+ const currentBranchPlacement = commonAncestorPositions.find((placement) => placement.element_id === external.currentBranchElementId)
197
+ const externalPlacement = commonAncestorPositions.find((placement) => placement.element_id === external.anchorElementId)
198
+ if (currentBranchPlacement && externalPlacement) {
199
+ return Math.atan2(
200
+ externalPlacement.position_y - currentBranchPlacement.position_y,
201
+ externalPlacement.position_x - currentBranchPlacement.position_x,
202
+ )
203
+ }
204
+ }
205
+
206
+ const internalPos = currentViewPositions.get(internal.anchorElementId)
207
+ if (internalPos) {
208
+ return Math.atan2(internalPos.position_y - centerY, internalPos.position_x - centerX) + Math.PI
209
+ }
210
+
211
+ return null
212
+ })
213
+
214
+ return leafAngles.filter((angle): angle is number => angle != null)
215
+ })
216
+
217
+ let angle = relatedAngles.length > 0 ? averageAngles(relatedAngles) : stableAngleFromId(contextNode.id)
218
+ const isDescendant = contextNode.placementViewId != null && (snapshot.descendantsByViewId[viewId]?.includes(contextNode.placementViewId) ?? false)
219
+
220
+ if (isDescendant) {
221
+ angle = -Math.PI / 2
222
+ }
223
+
224
+ const side = isDescendant ? 'top' : classifySide(angle)
225
+
226
+ const centerPosX = centerX + Math.cos(angle) * radiusX
227
+ const centerPosY = centerY + Math.sin(angle) * radiusY
228
+
229
+ return {
230
+ contextNode,
231
+ angle,
232
+ side,
233
+ centerX: centerPosX,
234
+ centerY: centerPosY,
235
+ position: {
236
+ x: centerPosX - CONTEXT_NODE_HALF_W,
237
+ y: centerPosY - CONTEXT_NODE_HALF_H,
238
+ },
239
+ }
240
+ })
241
+
242
+ const layoutsBySide: Record<ContextSide, typeof provisionalContextLayouts> = {
243
+ top: [],
244
+ bottom: [],
245
+ left: [],
246
+ right: [],
247
+ }
248
+
249
+ provisionalContextLayouts.forEach((layout) => {
250
+ layoutsBySide[layout.side].push(layout)
251
+ })
252
+
253
+ let clusterCounter = 0
254
+ const stackedLayouts = (['top', 'bottom', 'left', 'right'] as const).flatMap((side) => {
255
+ const sideLayouts = [...layoutsBySide[side]].sort((left, right) => {
256
+ const leftCoord = side === 'left' || side === 'right' ? left.centerY : left.centerX
257
+ const rightCoord = side === 'left' || side === 'right' ? right.centerY : right.centerX
258
+ return leftCoord - rightCoord
259
+ })
260
+ if (sideLayouts.length === 0) return []
261
+
262
+ const threshold = side === 'left' || side === 'right' ? SIDE_CLUSTER_THRESHOLD : TOP_BOTTOM_CLUSTER_THRESHOLD
263
+ const clusters: typeof sideLayouts[] = []
264
+
265
+ for (const layout of sideLayouts) {
266
+ const coord = side === 'left' || side === 'right' ? layout.centerY : layout.centerX
267
+ const cluster = clusters[clusters.length - 1]
268
+ if (!cluster) {
269
+ clusters.push([layout])
270
+ continue
271
+ }
272
+ const last = cluster[cluster.length - 1]
273
+ const lastCoord = side === 'left' || side === 'right' ? last.centerY : last.centerX
274
+ if (Math.abs(coord - lastCoord) <= threshold) cluster.push(layout)
275
+ else clusters.push([layout])
276
+ }
277
+
278
+ return clusters.flatMap((cluster) => {
279
+ const clusterId = `${side}-${clusterCounter++}`
280
+ const ordered = [...cluster].sort((left, right) => {
281
+ if (left.contextNode.sortLevel !== right.contextNode.sortLevel) {
282
+ return left.contextNode.sortLevel - right.contextNode.sortLevel
283
+ }
284
+ return left.contextNode.name.localeCompare(right.contextNode.name)
285
+ })
286
+
287
+ const anchorX = cluster.reduce((sum, layout) => sum + layout.centerX, 0) / cluster.length
288
+ const anchorY = cluster.reduce((sum, layout) => sum + layout.centerY, 0) / cluster.length
289
+ const clusterExpanded = ordered.length > 1 && expandedAncestorGroups.has(ordered[0].contextNode.id)
290
+ // A cluster is collapsible only when the lowest-sortLevel node genuinely owns a view
291
+ // that contains at least one other node's placement view. Siblings that merely happen
292
+ // to be close in angle but are NOT in an ancestor-descendant view relationship
293
+ // are laid out along the boundary edge instead of stacking away from it.
294
+ const hasAncestorDescendant = ordered.length > 1 &&
295
+ ordered.slice(1).some((layout) => isAncestorContextNode(snapshot, ordered[0].contextNode, layout.contextNode))
296
+
297
+ if (side === 'top') {
298
+ if (!hasAncestorDescendant) {
299
+ const startX = anchorX - ((ordered.length - 1) * HORIZONTAL_STACK_SPACING) / 2
300
+ return ordered.map((layout, index) => ({
301
+ ...layout,
302
+ clusterId,
303
+ position: {
304
+ x: startX + index * HORIZONTAL_STACK_SPACING - CONTEXT_NODE_HALF_W,
305
+ y: boundaryTop - VERTICAL_BOUNDARY_CLEARANCE - CONTEXT_NODE_HALF_H,
306
+ },
307
+ }))
308
+ }
309
+ const startY = boundaryTop - VERTICAL_BOUNDARY_CLEARANCE - (ordered.length - 1) * VERTICAL_STACK_SPACING
310
+ return ordered.map((layout, index) => ({
311
+ ...layout,
312
+ clusterId,
313
+ position: {
314
+ x: anchorX - CONTEXT_NODE_HALF_W,
315
+ y: startY + index * VERTICAL_STACK_SPACING - CONTEXT_NODE_HALF_H,
316
+ },
317
+ }))
318
+ }
319
+
320
+ if (side === 'bottom') {
321
+ if (!hasAncestorDescendant) {
322
+ const startX = anchorX - ((ordered.length - 1) * HORIZONTAL_STACK_SPACING) / 2
323
+ return ordered.map((layout, index) => ({
324
+ ...layout,
325
+ clusterId,
326
+ position: {
327
+ x: startX + index * HORIZONTAL_STACK_SPACING - CONTEXT_NODE_HALF_W,
328
+ y: boundaryBottom + VERTICAL_BOUNDARY_CLEARANCE - CONTEXT_NODE_HALF_H,
329
+ },
330
+ }))
331
+ }
332
+ const startY = boundaryBottom + VERTICAL_BOUNDARY_CLEARANCE
333
+ return ordered.map((layout, index) => ({
334
+ ...layout,
335
+ clusterId,
336
+ position: {
337
+ x: anchorX - CONTEXT_NODE_HALF_W,
338
+ y: startY + index * VERTICAL_STACK_SPACING + (index > 0 && clusterExpanded ? CHEVRON_V_CLEARANCE : 0) - CONTEXT_NODE_HALF_H,
339
+ },
340
+ }))
341
+ }
342
+
343
+ if (side === 'left') {
344
+ if (!hasAncestorDescendant) {
345
+ const startY = anchorY - ((ordered.length - 1) * VERTICAL_STACK_SPACING) / 2
346
+ return ordered.map((layout, index) => ({
347
+ ...layout,
348
+ clusterId,
349
+ position: {
350
+ x: boundaryLeft - HORIZONTAL_BOUNDARY_CLEARANCE - CONTEXT_NODE_HALF_W,
351
+ y: startY + index * VERTICAL_STACK_SPACING - CONTEXT_NODE_HALF_H,
352
+ },
353
+ }))
354
+ }
355
+ const startX = boundaryLeft - HORIZONTAL_BOUNDARY_CLEARANCE - (ordered.length - 1) * HORIZONTAL_STACK_SPACING
356
+ return ordered.map((layout, index) => ({
357
+ ...layout,
358
+ clusterId,
359
+ position: {
360
+ x: startX + index * HORIZONTAL_STACK_SPACING - CONTEXT_NODE_HALF_W,
361
+ y: anchorY - CONTEXT_NODE_HALF_H,
362
+ },
363
+ }))
364
+ }
365
+
366
+ // right side
367
+ if (!hasAncestorDescendant) {
368
+ const startY = anchorY - ((ordered.length - 1) * VERTICAL_STACK_SPACING) / 2
369
+ return ordered.map((layout, index) => ({
370
+ ...layout,
371
+ clusterId,
372
+ position: {
373
+ x: boundaryRight + HORIZONTAL_BOUNDARY_CLEARANCE - CONTEXT_NODE_HALF_W,
374
+ y: startY + index * VERTICAL_STACK_SPACING - CONTEXT_NODE_HALF_H,
375
+ },
376
+ }))
377
+ }
378
+ const startX = boundaryRight + HORIZONTAL_BOUNDARY_CLEARANCE
379
+ return ordered.map((layout, index) => ({
380
+ ...layout,
381
+ clusterId,
382
+ position: {
383
+ x: startX + index * HORIZONTAL_STACK_SPACING + (index > 0 && clusterExpanded ? CHEVRON_H_CLEARANCE : 0) - CONTEXT_NODE_HALF_W,
384
+ y: anchorY - CONTEXT_NODE_HALF_H,
385
+ },
386
+ }))
387
+ })
388
+ })
389
+
390
+ // Group nodes that the layout already places in the same spatial cluster.
391
+ // Within each cluster, the node with the lowest sortLevel (closest ancestor) is the anchor;
392
+ // all others are children that collapse behind it.
393
+ const clusterGroups = new Map<string, typeof stackedLayouts>()
394
+ for (const layout of stackedLayouts) {
395
+ const group = clusterGroups.get(layout.clusterId) ?? []
396
+ group.push(layout)
397
+ clusterGroups.set(layout.clusterId, group)
398
+ }
399
+
400
+ const childToAnchorId = new Map<string, string>()
401
+ const anchorGroupChildCount = new Map<string, number>()
402
+
403
+ for (const [, clusterLayouts] of clusterGroups) {
404
+ if (clusterLayouts.length < 2) continue
405
+ const [anchor, ...rest] = clusterLayouts
406
+ // Only collapse nodes whose placement view is actually inside the view tree rooted
407
+ // at the anchor element's child view. Siblings that merely cluster spatially are not collapsed.
408
+ const children = rest.filter((layout) =>
409
+ isAncestorContextNode(snapshot, anchor.contextNode, layout.contextNode),
410
+ )
411
+ if (children.length === 0) continue
412
+ anchorGroupChildCount.set(anchor.contextNode.id, children.length)
413
+ for (const child of children) {
414
+ childToAnchorId.set(child.contextNode.id, anchor.contextNode.id)
415
+ }
416
+ }
417
+
418
+ const contextNodes: RFNode[] = stackedLayouts
419
+ .filter(({ contextNode }) => {
420
+ const anchorId = childToAnchorId.get(contextNode.id)
421
+ if (anchorId == null) return true
422
+ return expandedAncestorGroups.has(anchorId)
423
+ })
424
+ .map(({ contextNode, position, side }) => {
425
+ const isGroupAnchor = anchorGroupChildCount.has(contextNode.id)
426
+ const groupChildCount = anchorGroupChildCount.get(contextNode.id) ?? 0
427
+ const isGroupExpanded = expandedAncestorGroups.has(contextNode.id)
428
+ return {
429
+ id: contextNode.id,
430
+ type: 'contextNeighborNode',
431
+ position,
432
+ selectable: false,
433
+ draggable: false,
434
+ connectable: false,
435
+ zIndex: isGroupAnchor && isGroupExpanded ? 8 : 6,
436
+ data: {
437
+ element_id: contextNode.anchorElementId,
438
+ name: contextNode.name,
439
+ kind: contextNode.kind,
440
+ description: contextNode.description,
441
+ technology: contextNode.technology,
442
+ logo_url: contextNode.logoUrl,
443
+ technology_connectors: contextNode.technologyConnectors,
444
+ ownerViewIds: contextNode.ownerViewIds,
445
+ ownerViewNames: contextNode.ownerViewNames,
446
+ commonAncestorViewId: contextNode.commonAncestorViewId,
447
+ commonAncestorViewName: contextNode.commonAncestorViewName,
448
+ connectorCount: contextNode.connectorCount,
449
+ onNavigateToView: stableOnNavigateToView,
450
+ onSelectDetails: proxyNodeDetailsById.get(contextNode.id)
451
+ ? () => onSelectProxyDetails(proxyNodeDetailsById.get(contextNode.id) as ProxyConnectorDetails)
452
+ : undefined,
453
+ isGroupAnchor,
454
+ groupChildCount,
455
+ isGroupExpanded,
456
+ onToggleGroup: isGroupAnchor ? () => onToggleAncestorGroup(contextNode.id) : undefined,
457
+ side,
458
+ },
459
+ }
460
+ })
461
+
462
+ const seenCollapsedPairs = new Set<string>()
463
+ const contextConnectors: RFEdge[] = proxyConnectors.flatMap((connector) => {
464
+ let sourceId = connector.sourceAnchorId
465
+ let targetId = connector.targetAnchorId
466
+
467
+ const sourceAnchor = childToAnchorId.get(sourceId)
468
+ if (sourceAnchor != null && !expandedAncestorGroups.has(sourceAnchor)) sourceId = sourceAnchor
469
+
470
+ const targetAnchor = childToAnchorId.get(targetId)
471
+ if (targetAnchor != null && !expandedAncestorGroups.has(targetAnchor)) targetId = targetAnchor
472
+
473
+ if (sourceId === targetId) return []
474
+
475
+ const pairKey = `${sourceId}::${targetId}`
476
+ if (seenCollapsedPairs.has(pairKey)) return []
477
+ seenCollapsedPairs.add(pairKey)
478
+
479
+ return [{
480
+ id: `proxy:${connector.key}`,
481
+ source: sourceId,
482
+ target: targetId,
483
+ type: 'proxyConnectorEdge',
484
+ animated: false,
485
+ selectable: true,
486
+ updatable: false,
487
+ data: {
488
+ isProxy: true,
489
+ proxyKey: connector.key,
490
+ details: connector.details,
491
+ },
492
+ style: {
493
+ stroke: 'rgba(255, 255, 255, 0.2)',
494
+ strokeWidth: 2,
495
+ },
496
+ }]
497
+ })
498
+
499
+ return { contextNodes: [ContextBoundaryElement, ...contextNodes], contextConnectors, proxyConnectorDetailsByKey }
500
+ }, [snapshot, settings, viewId, viewElements, rfNodes, stableOnNavigateToView, onSelectProxyDetails, expandedAncestorGroups, onToggleAncestorGroup])
501
+ }