@tldiagram/core-ui 1.95.0 → 2.0.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 (102) hide show
  1. package/dist/api/client.d.ts +184 -3
  2. package/dist/components/ConnectorPanel.d.ts +5 -1
  3. package/dist/components/CrossBranchControls.d.ts +4 -3
  4. package/dist/components/ElementNode.d.ts +5 -0
  5. package/dist/components/ElementPanel.d.ts +6 -1
  6. package/dist/components/LayoutSection.d.ts +2 -1
  7. package/dist/components/MergeDialog.d.ts +16 -0
  8. package/dist/components/MiniZoomOnboarding.d.ts +2 -1
  9. package/dist/components/NodeContainer.d.ts +2 -0
  10. package/dist/components/ProxyConnectorPanel.d.ts +4 -1
  11. package/dist/components/ViewExplorer/index.d.ts +1 -1
  12. package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
  13. package/dist/components/ViewFloatingMenu.d.ts +8 -1
  14. package/dist/components/ViewGridNode.d.ts +3 -0
  15. package/dist/components/ViewPanel.d.ts +2 -1
  16. package/dist/components/WorkspacePanel.d.ts +2 -0
  17. package/dist/components/ZUI/ZUICanvas.d.ts +5 -0
  18. package/dist/components/ZUI/focus.d.ts +32 -0
  19. package/dist/components/ZUI/focus.test.d.ts +1 -0
  20. package/dist/components/ZUI/layout.d.ts +2 -2
  21. package/dist/components/ZUI/proxy.d.ts +20 -4
  22. package/dist/components/ZUI/renderer.d.ts +35 -1
  23. package/dist/components/ZUI/types.d.ts +6 -0
  24. package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
  25. package/dist/context/WorkspaceVersionContext.d.ts +49 -0
  26. package/dist/crossBranch/resolve.d.ts +39 -2
  27. package/dist/crossBranch/resolve.test.d.ts +1 -0
  28. package/dist/crossBranch/settings.d.ts +6 -1
  29. package/dist/crossBranch/types.d.ts +8 -0
  30. package/dist/hooks/useElementSearch.d.ts +8 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +14597 -12083
  33. package/dist/pages/InfiniteZoom.d.ts +1 -0
  34. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
  35. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
  36. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
  37. package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
  38. package/dist/pages/viewsJumpSearch.d.ts +22 -0
  39. package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
  40. package/dist/store/useStore.d.ts +3 -0
  41. package/dist/types/index.d.ts +9 -0
  42. package/dist/utils/elementIcon.d.ts +2 -0
  43. package/dist/utils/elementIcon.test.d.ts +1 -0
  44. package/dist/utils/sourceEditor.d.ts +7 -0
  45. package/dist/utils/watchDiffSummary.d.ts +34 -0
  46. package/package.json +2 -2
  47. package/src/App.tsx +12 -8
  48. package/src/api/client.ts +488 -26
  49. package/src/components/CodePreviewPanel.tsx +90 -16
  50. package/src/components/ConnectorPanel.tsx +34 -3
  51. package/src/components/ContextNeighborElement.tsx +2 -5
  52. package/src/components/CrossBranchControls.tsx +46 -17
  53. package/src/components/ElementNode.tsx +98 -47
  54. package/src/components/ElementPanel.tsx +62 -25
  55. package/src/components/InlineElementAdder.tsx +8 -3
  56. package/src/components/LayoutSection.tsx +4 -1
  57. package/src/components/MergeDialog.tsx +269 -0
  58. package/src/components/MiniZoomOnboarding.tsx +29 -22
  59. package/src/components/NodeContainer.tsx +55 -17
  60. package/src/components/ProxyConnectorPanel.tsx +58 -16
  61. package/src/components/ViewBezierConnector.tsx +116 -21
  62. package/src/components/ViewExplorer/index.tsx +1 -1
  63. package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
  64. package/src/components/ViewFloatingMenu.tsx +110 -1
  65. package/src/components/ViewGridNode.tsx +59 -8
  66. package/src/components/ViewPanel.tsx +3 -2
  67. package/src/components/WorkspacePanel.tsx +938 -0
  68. package/src/components/ZUI/ZUICanvas.tsx +226 -127
  69. package/src/components/ZUI/focus.test.ts +534 -0
  70. package/src/components/ZUI/focus.ts +293 -0
  71. package/src/components/ZUI/layout.ts +7 -11
  72. package/src/components/ZUI/proxy.ts +470 -114
  73. package/src/components/ZUI/renderer.ts +510 -134
  74. package/src/components/ZUI/types.ts +6 -0
  75. package/src/components/ZUI/useZUIInteraction.ts +66 -29
  76. package/src/context/WorkspaceVersionContext.tsx +126 -0
  77. package/src/crossBranch/resolve.test.ts +342 -0
  78. package/src/crossBranch/resolve.ts +368 -68
  79. package/src/crossBranch/settings.ts +49 -3
  80. package/src/crossBranch/types.ts +9 -0
  81. package/src/hooks/useElementSearch.ts +45 -0
  82. package/src/index.css +11 -0
  83. package/src/index.ts +7 -0
  84. package/src/pages/AppearanceSettings.tsx +24 -1
  85. package/src/pages/Dependencies.tsx +231 -65
  86. package/src/pages/InfiniteZoom.tsx +76 -27
  87. package/src/pages/Settings.tsx +1 -1
  88. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
  89. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
  90. package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
  91. package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
  92. package/src/pages/ViewEditor/index.tsx +549 -59
  93. package/src/pages/Views.tsx +112 -41
  94. package/src/pages/ViewsGrid.tsx +332 -113
  95. package/src/pages/viewsJumpSearch.test.ts +193 -0
  96. package/src/pages/viewsJumpSearch.ts +111 -0
  97. package/src/store/useStore.ts +58 -0
  98. package/src/types/index.ts +10 -0
  99. package/src/utils/elementIcon.test.ts +28 -0
  100. package/src/utils/elementIcon.ts +20 -0
  101. package/src/utils/sourceEditor.ts +46 -0
  102. package/src/utils/watchDiffSummary.ts +159 -0
@@ -10,6 +10,10 @@ export interface ZUIViewState {
10
10
  y: number
11
11
  /** Current zoom multiplier (1 = 1 world-pixel per screen-pixel). */
12
12
  zoom: number
13
+ /** World-space X rendered at the local camera origin. Keeps x screen-sized at deep zoom. */
14
+ originX?: number
15
+ /** World-space Y rendered at the local camera origin. Keeps y screen-sized at deep zoom. */
16
+ originY?: number
13
17
  }
14
18
 
15
19
  /**
@@ -71,6 +75,7 @@ export interface LayoutNode {
71
75
 
72
76
  // ── Edges within the same diagram ────────────────────────────────
73
77
  edgesOut: Array<{
78
+ id: number
74
79
  /** LayoutNode id of the target. */
75
80
  targetId: string
76
81
  label: string
@@ -103,6 +108,7 @@ export interface DiagramGroupLayout {
103
108
  nodes: LayoutNode[]
104
109
  /** Edges whose both endpoints are in this diagram. */
105
110
  edges: Array<{
111
+ id: number
106
112
  sourceId: string
107
113
  targetId: string
108
114
  label: string
@@ -2,23 +2,39 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
4
4
  import type { BBox, DiagramGroupLayout, LayoutNode, ZUIViewState, HoveredItem } from './types'
5
- import { getExpandThresholds } from './renderer'
5
+ import { getExpandThresholds, screenToWorldX, screenToWorldY, viewOriginX, viewOriginY } from './renderer'
6
+
7
+ export function constrainViewState(view: ZUIViewState, canvasW: number, canvasH: number, bbox: BBox): ZUIViewState {
8
+ const padding = Math.min(600, canvasW * 0.45, canvasH * 0.45)
9
+ const normalized = normalizeViewState(view, canvasW, canvasH)
10
+ const halfVisibleX = Math.max(0, canvasW / 2 - padding) / normalized.zoom
11
+ const halfVisibleY = Math.max(0, canvasH / 2 - padding) / normalized.zoom
12
+ const minOriginX = bbox.minX - halfVisibleX
13
+ const maxOriginX = bbox.maxX + halfVisibleX
14
+ const minOriginY = bbox.minY - halfVisibleY
15
+ const maxOriginY = bbox.maxY + halfVisibleY
6
16
 
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
17
+ return {
18
+ ...normalized,
19
+ originX: maxOriginX >= minOriginX
20
+ ? Math.max(minOriginX, Math.min(maxOriginX, viewOriginX(normalized)))
21
+ : (minOriginX + maxOriginX) / 2,
22
+ originY: maxOriginY >= minOriginY
23
+ ? Math.max(minOriginY, Math.min(maxOriginY, viewOriginY(normalized)))
24
+ : (minOriginY + maxOriginY) / 2,
25
+ }
26
+ }
20
27
 
21
- return { ...view, x, y }
28
+ function normalizeViewState(view: ZUIViewState, canvasW: number, canvasH: number): ZUIViewState {
29
+ const zoom = Math.max(0.0001, view.zoom)
30
+ return {
31
+ ...view,
32
+ x: canvasW / 2,
33
+ y: canvasH / 2,
34
+ zoom,
35
+ originX: screenToWorldX(canvasW / 2, { ...view, zoom }),
36
+ originY: screenToWorldY(canvasH / 2, { ...view, zoom }),
37
+ }
22
38
  }
23
39
 
24
40
  interface DeepestNodeResult {
@@ -392,6 +408,12 @@ export function calculateMaxZoom(groups: DiagramGroupLayout[], canvasW: number):
392
408
  }
393
409
 
394
410
  const MIN_ZOOM = 0.4
411
+ const ZUI_NATIVE_WHEEL_SELECTOR = '[data-zui-native-wheel="true"]'
412
+
413
+ function shouldIgnoreCapturedWheel(e: WheelEvent): boolean {
414
+ const target = e.target
415
+ return target instanceof Element && target.closest(ZUI_NATIVE_WHEEL_SELECTOR) !== null
416
+ }
395
417
 
396
418
  function clampZoom(z: number, prevZ: number, maxZ: number): number {
397
419
  if (z > prevZ) {
@@ -412,11 +434,16 @@ function zoomAround(
412
434
  maxZoom: number,
413
435
  ): ZUIViewState {
414
436
  const newZoom = clampZoom(view.zoom * factor, view.zoom, maxZoom)
415
- const scale = newZoom / view.zoom
437
+ const worldX = screenToWorldX(focalX, view)
438
+ const worldY = screenToWorldY(focalY, view)
439
+ const originX = viewOriginX(view)
440
+ const originY = viewOriginY(view)
416
441
  return {
442
+ originX,
443
+ originY,
417
444
  zoom: newZoom,
418
- x: focalX - (focalX - view.x) * scale,
419
- y: focalY - (focalY - view.y) * scale,
445
+ x: focalX - (worldX - originX) * newZoom,
446
+ y: focalY - (worldY - originY) * newZoom,
420
447
  }
421
448
  }
422
449
 
@@ -590,6 +617,17 @@ export function useZUIInteraction(
590
617
  if (!el) return
591
618
 
592
619
  function onWheel(e: WheelEvent) {
620
+ if (shouldIgnoreCapturedWheel(e)) return
621
+
622
+ const rect = el!.getBoundingClientRect()
623
+ const isInsideCanvas =
624
+ e.clientX >= rect.left &&
625
+ e.clientX <= rect.right &&
626
+ e.clientY >= rect.top &&
627
+ e.clientY <= rect.bottom
628
+
629
+ if (!isInsideCanvas) return
630
+
593
631
  // Heuristic to distinguish between trackpad and physical mouse wheel:
594
632
  // 1. If ctrlKey is true, it's a pinch (trackpad) or Ctrl+Wheel. We always zoom.
595
633
  // 2. If deltaMode !== 0, it's a physical mouse wheel (DOM_DELTA_LINE/PAGE). We zoom.
@@ -619,7 +657,6 @@ export function useZUIInteraction(
619
657
  const isRealMouseWheel = e.deltaMode !== 0 || isNotchedWheel
620
658
 
621
659
  if (isPinch || isRealMouseWheel) {
622
- const rect = el!.getBoundingClientRect()
623
660
  const focalX = e.clientX - rect.left
624
661
  const focalY = e.clientY - rect.top
625
662
 
@@ -628,8 +665,8 @@ export function useZUIInteraction(
628
665
  factor = Math.max(0.85, Math.min(1.15, factor))
629
666
 
630
667
  setViewState((prev) => {
631
- const worldX = (focalX - prev.x) / prev.zoom
632
- const worldY = (focalY - prev.y) / prev.zoom
668
+ const worldX = screenToWorldX(focalX, prev)
669
+ const worldY = screenToWorldY(focalY, prev)
633
670
  const thresholds = getExpandThresholds(rect.width)
634
671
  const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
635
672
 
@@ -677,8 +714,8 @@ export function useZUIInteraction(
677
714
 
678
715
  // Hover detection
679
716
  const view = viewStateRef.current
680
- const worldX = (screenX - view.x) / view.zoom
681
- const worldY = (screenY - view.y) / view.zoom
717
+ const worldX = screenToWorldX(screenX, view)
718
+ const worldY = screenToWorldY(screenY, view)
682
719
  const thresholds = getExpandThresholds(rect.width)
683
720
 
684
721
  const deepest = findDeepestAt(worldX, worldY, groupsRef.current, view, thresholds)
@@ -731,8 +768,8 @@ export function useZUIInteraction(
731
768
  setHoveredItem(null, true) // Clear popover immediately on double-click zoom
732
769
 
733
770
  setViewState((prev) => {
734
- const worldX = (focalX - prev.x) / prev.zoom
735
- const worldY = (focalY - prev.y) / prev.zoom
771
+ const worldX = screenToWorldX(focalX, prev)
772
+ const worldY = screenToWorldY(focalY, prev)
736
773
  const thresholds = getExpandThresholds(rect.width)
737
774
  const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
738
775
 
@@ -802,8 +839,8 @@ export function useZUIInteraction(
802
839
  if (isFinite(factor) && factor > 0) {
803
840
  setViewState((prev) => {
804
841
  const rect = el!.getBoundingClientRect()
805
- const worldX = (mid.x - prev.x) / prev.zoom
806
- const worldY = (mid.y - prev.y) / prev.zoom
842
+ const worldX = screenToWorldX(mid.x, prev)
843
+ const worldY = screenToWorldY(mid.y, prev)
807
844
  const thresholds = getExpandThresholds(rect.width)
808
845
  const deepest = findDeepestAt(worldX, worldY, groupsRef.current, prev, thresholds)
809
846
 
@@ -843,7 +880,7 @@ export function useZUIInteraction(
843
880
 
844
881
  el.style.cursor = 'grab'
845
882
 
846
- el.addEventListener('wheel', onWheel, { passive: false })
883
+ window.addEventListener('wheel', onWheel, { passive: false, capture: true })
847
884
  el.addEventListener('mousedown', onMouseDown)
848
885
  el.addEventListener('mouseleave', onMouseOut)
849
886
  el.addEventListener('mouseout', onMouseOut)
@@ -856,7 +893,7 @@ export function useZUIInteraction(
856
893
  el.addEventListener('touchcancel', onTouchEnd)
857
894
 
858
895
  return () => {
859
- el.removeEventListener('wheel', onWheel)
896
+ window.removeEventListener('wheel', onWheel, { capture: true })
860
897
  el.removeEventListener('mousedown', onMouseDown)
861
898
  el.removeEventListener('mouseleave', onMouseOut)
862
899
  el.removeEventListener('mouseout', onMouseOut)
@@ -0,0 +1,126 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
2
+ import type { WatchDiff, WatchRepository, WatchVersion, WorkspaceVersion } from '../api/client'
3
+ import { normalizeWatchChangeType } from '../utils/watchDiffSummary'
4
+
5
+ export type VersionChangeType = 'added' | 'updated' | 'deleted' | 'initialized'
6
+
7
+ export interface VersionLineDelta {
8
+ added: number
9
+ removed: number
10
+ }
11
+
12
+ export interface WorkspaceVersionPreview {
13
+ repository: WatchRepository | null
14
+ version: WatchVersion | null
15
+ workspaceVersions: WorkspaceVersion[]
16
+ diffs: WatchDiff[]
17
+ elementChanges: Map<number, VersionChangeType>
18
+ elementLineDeltas: Map<number, VersionLineDelta>
19
+ connectorChanges: Map<number, VersionChangeType>
20
+ summary: {
21
+ added: number
22
+ updated: number
23
+ deleted: number
24
+ initialized: number
25
+ elements: number
26
+ connectors: number
27
+ }
28
+ }
29
+
30
+ export interface WorkspaceVersionFollowTarget {
31
+ token: number
32
+ resourceType: string
33
+ resourceId?: number
34
+ viewId?: number
35
+ changeType?: VersionChangeType
36
+ }
37
+
38
+ interface WorkspaceVersionContextValue {
39
+ preview: WorkspaceVersionPreview | null
40
+ followToken: number
41
+ followTarget: WorkspaceVersionFollowTarget | null
42
+ setPreview: (preview: WorkspaceVersionPreview | null) => void
43
+ clearPreview: () => void
44
+ requestFollow: (target?: Omit<WorkspaceVersionFollowTarget, 'token'>) => void
45
+ }
46
+
47
+ const WorkspaceVersionContext = createContext<WorkspaceVersionContextValue | null>(null)
48
+
49
+ export function buildWorkspaceVersionPreview(args: {
50
+ repository: WatchRepository | null
51
+ version: WatchVersion | null
52
+ workspaceVersions: WorkspaceVersion[]
53
+ diffs: WatchDiff[] | null | undefined
54
+ }): WorkspaceVersionPreview {
55
+ const diffs = Array.isArray(args.diffs) ? args.diffs : []
56
+ const elementChanges = new Map<number, VersionChangeType>()
57
+ const elementLineDeltas = new Map<number, VersionLineDelta>()
58
+ const connectorChanges = new Map<number, VersionChangeType>()
59
+ const summary = { added: 0, updated: 0, deleted: 0, initialized: 0, elements: 0, connectors: 0 }
60
+
61
+ diffs.forEach((diff) => {
62
+ const change = normalizeWatchChangeType(diff.change_type)
63
+ summary[change] += 1
64
+ if (diff.resource_type === 'element' && diff.resource_id) {
65
+ elementChanges.set(diff.resource_id, change)
66
+ const added = Math.max(0, diff.added_lines ?? 0)
67
+ const removed = Math.max(0, diff.removed_lines ?? 0)
68
+ if (added > 0 || removed > 0) {
69
+ elementLineDeltas.set(diff.resource_id, { added, removed })
70
+ }
71
+ summary.elements += 1
72
+ }
73
+ if (diff.resource_type === 'connector' && diff.resource_id) {
74
+ connectorChanges.set(diff.resource_id, change)
75
+ summary.connectors += 1
76
+ }
77
+ })
78
+
79
+ return {
80
+ repository: args.repository,
81
+ version: args.version,
82
+ workspaceVersions: args.workspaceVersions,
83
+ diffs,
84
+ elementChanges,
85
+ elementLineDeltas,
86
+ connectorChanges,
87
+ summary,
88
+ }
89
+ }
90
+
91
+ export function WorkspaceVersionProvider({ children }: { children: React.ReactNode }) {
92
+ const [preview, setPreview] = useState<WorkspaceVersionPreview | null>(null)
93
+ const [followToken, setFollowToken] = useState(0)
94
+ const [followTarget, setFollowTarget] = useState<WorkspaceVersionFollowTarget | null>(null)
95
+ const followClearTimerRef = useRef<number | null>(null)
96
+ const clearPreview = useCallback(() => setPreview(null), [])
97
+ const requestFollow = useCallback((target?: Omit<WorkspaceVersionFollowTarget, 'token'>) => {
98
+ setFollowToken((value) => value + 1)
99
+ if (followClearTimerRef.current !== null) {
100
+ window.clearTimeout(followClearTimerRef.current)
101
+ followClearTimerRef.current = null
102
+ }
103
+ if (!target) {
104
+ setFollowTarget(null)
105
+ return
106
+ }
107
+ setFollowTarget({ ...target, token: Date.now() })
108
+ followClearTimerRef.current = window.setTimeout(() => {
109
+ setFollowTarget(null)
110
+ followClearTimerRef.current = null
111
+ }, 1600)
112
+ }, [])
113
+ useEffect(() => {
114
+ return () => {
115
+ if (followClearTimerRef.current !== null) window.clearTimeout(followClearTimerRef.current)
116
+ }
117
+ }, [])
118
+ const value = useMemo(() => ({ preview, followToken, followTarget, setPreview, clearPreview, requestFollow }), [preview, followToken, followTarget, clearPreview, requestFollow])
119
+ return <WorkspaceVersionContext.Provider value={value}>{children}</WorkspaceVersionContext.Provider>
120
+ }
121
+
122
+ export function useWorkspaceVersionPreview() {
123
+ const value = useContext(WorkspaceVersionContext)
124
+ if (!value) throw new Error('useWorkspaceVersionPreview must be used within WorkspaceVersionProvider')
125
+ return value
126
+ }
@@ -0,0 +1,342 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildWorkspaceGraphSnapshot } from './graph'
3
+ import { resolveZUIProxyConnectors } from './resolve'
4
+ import type { ResolveZUIProxyConnectorOptions, ZUIConnectorAnchorInfo } from './resolve'
5
+ import type { Connector, ExploreData, PlacedElement, ViewTreeNode } from '../types'
6
+ import type { CrossBranchContextSettings } from './types'
7
+
8
+ function placedElement(view_id: number, element_id: number, name: string): PlacedElement {
9
+ return {
10
+ id: view_id * 100 + element_id,
11
+ view_id,
12
+ element_id,
13
+ position_x: element_id * 10,
14
+ position_y: 0,
15
+ name,
16
+ description: null,
17
+ kind: 'service',
18
+ technology: null,
19
+ url: null,
20
+ logo_url: null,
21
+ technology_connectors: [],
22
+ tags: [],
23
+ has_view: element_id === 1 || element_id === 3,
24
+ view_label: null,
25
+ }
26
+ }
27
+
28
+ function connector(id: number, view_id: number, source_element_id: number, target_element_id: number, label: string): Connector {
29
+ return {
30
+ id,
31
+ view_id,
32
+ source_element_id,
33
+ target_element_id,
34
+ label,
35
+ description: null,
36
+ relationship: null,
37
+ direction: 'forward',
38
+ style: 'bezier',
39
+ url: null,
40
+ source_handle: null,
41
+ target_handle: null,
42
+ created_at: '2024-01-01',
43
+ updated_at: '2024-01-01',
44
+ }
45
+ }
46
+
47
+ function zuiSettings(overrides: Partial<CrossBranchContextSettings> = {}): CrossBranchContextSettings {
48
+ return {
49
+ enabled: true,
50
+ depth: 5,
51
+ connectorBudget: 50,
52
+ connectorPriority: 'external',
53
+ ...overrides,
54
+ }
55
+ }
56
+
57
+ function anchor(nodeId: string, x: number, y = 0): ZUIConnectorAnchorInfo {
58
+ return {
59
+ nodeId,
60
+ worldX: x,
61
+ worldY: y,
62
+ worldW: 10,
63
+ worldH: 10,
64
+ }
65
+ }
66
+
67
+ function viewportOptions(anchorsByElementId: Map<number, ZUIConnectorAnchorInfo>): ResolveZUIProxyConnectorOptions {
68
+ return {
69
+ anchorsByElementId,
70
+ viewport: {
71
+ minX: 0,
72
+ minY: 0,
73
+ maxX: 100,
74
+ maxY: 100,
75
+ centerX: 50,
76
+ centerY: 50,
77
+ },
78
+ }
79
+ }
80
+
81
+ const tree: ViewTreeNode[] = [{
82
+ id: 1,
83
+ owner_element_id: null,
84
+ name: 'Root',
85
+ description: null,
86
+ level_label: null,
87
+ level: 0,
88
+ depth: 0,
89
+ created_at: '2024-01-01',
90
+ updated_at: '2024-01-01',
91
+ parent_view_id: null,
92
+ children: [{
93
+ id: 2,
94
+ owner_element_id: 1,
95
+ name: 'A Child',
96
+ description: null,
97
+ level_label: null,
98
+ level: 1,
99
+ depth: 1,
100
+ created_at: '2024-01-01',
101
+ updated_at: '2024-01-01',
102
+ parent_view_id: 1,
103
+ children: [{
104
+ id: 3,
105
+ owner_element_id: 3,
106
+ name: 'AA Child',
107
+ description: null,
108
+ level_label: null,
109
+ level: 2,
110
+ depth: 2,
111
+ created_at: '2024-01-01',
112
+ updated_at: '2024-01-01',
113
+ parent_view_id: 2,
114
+ children: [],
115
+ }],
116
+ }],
117
+ }]
118
+
119
+ function baseData(connectors: Connector[]): ExploreData {
120
+ return {
121
+ tree,
122
+ navigations: [
123
+ { id: 1, element_id: 1, from_view_id: 1, to_view_id: 2, to_view_name: 'A Child', relation_type: 'child' },
124
+ { id: 2, element_id: 3, from_view_id: 2, to_view_id: 3, to_view_name: 'AA Child', relation_type: 'child' },
125
+ ],
126
+ views: {
127
+ '1': {
128
+ placements: [placedElement(1, 1, 'A'), placedElement(1, 2, 'B')],
129
+ connectors,
130
+ },
131
+ '2': {
132
+ placements: [placedElement(2, 3, 'AA')],
133
+ connectors: [],
134
+ },
135
+ '3': {
136
+ placements: [placedElement(3, 4, 'AAA')],
137
+ connectors: [],
138
+ },
139
+ },
140
+ }
141
+ }
142
+
143
+ describe('resolveZUIProxyConnectors', () => {
144
+ it('collapses direct-child cross-branch links into a native +N badge', () => {
145
+ const snapshot = buildWorkspaceGraphSnapshot(baseData([
146
+ connector(1, 1, 1, 2, 'A-B'),
147
+ connector(2, 1, 3, 2, 'AA-B'),
148
+ ]))
149
+
150
+ const resolved = resolveZUIProxyConnectors(
151
+ snapshot,
152
+ new Map([
153
+ [1, 'd1-o1'],
154
+ [2, 'd1-o2'],
155
+ ]),
156
+ zuiSettings(),
157
+ )
158
+
159
+ expect(resolved.connectors).toHaveLength(0)
160
+ expect(resolved.hiddenBadges).toHaveLength(1)
161
+ expect(resolved.hiddenBadges[0]).toMatchObject({
162
+ sourceAnchorElementId: 1,
163
+ targetAnchorElementId: 2,
164
+ count: 1,
165
+ })
166
+ })
167
+
168
+ it('fractures the badge into direct child and parent connectors when the child is visible', () => {
169
+ const snapshot = buildWorkspaceGraphSnapshot(baseData([
170
+ connector(1, 1, 1, 2, 'A-B'),
171
+ connector(2, 1, 3, 2, 'AA-B'),
172
+ ]))
173
+
174
+ const resolved = resolveZUIProxyConnectors(
175
+ snapshot,
176
+ new Map([
177
+ [1, 'd1-o1'],
178
+ [2, 'd1-o2'],
179
+ [3, 'd2-o3'],
180
+ ]),
181
+ zuiSettings(),
182
+ )
183
+
184
+ expect(resolved.hiddenBadges).toHaveLength(0)
185
+ expect(resolved.connectors.map((item) => [item.sourceAnchorElementId, item.targetAnchorElementId])).toEqual([[2, 3]])
186
+ })
187
+
188
+ it('keeps only the deepest visible connector and its parent once grandchildren are visible', () => {
189
+ const snapshot = buildWorkspaceGraphSnapshot(baseData([
190
+ connector(1, 1, 1, 2, 'A-B'),
191
+ connector(2, 1, 4, 2, 'AAA-B'),
192
+ ]))
193
+
194
+ const resolved = resolveZUIProxyConnectors(
195
+ snapshot,
196
+ new Map([
197
+ [1, 'd1-o1'],
198
+ [2, 'd1-o2'],
199
+ [3, 'd2-o3'],
200
+ [4, 'd3-o4'],
201
+ ]),
202
+ zuiSettings(),
203
+ )
204
+
205
+ expect(resolved.hiddenBadges).toHaveLength(0)
206
+ expect(resolved.connectors.map((item) => [item.sourceAnchorElementId, item.targetAnchorElementId]).sort()).toEqual([
207
+ [2, 3],
208
+ [2, 4],
209
+ ])
210
+ })
211
+
212
+ it('budgets visible connector groups and reports the omitted leaf count', () => {
213
+ const data = baseData([
214
+ connector(1, 1, 3, 2, 'one'),
215
+ connector(2, 1, 4, 2, 'two'),
216
+ connector(3, 1, 5, 2, 'three'),
217
+ ])
218
+ data.views['2'].placements = [
219
+ placedElement(2, 3, 'AA'),
220
+ placedElement(2, 4, 'AB'),
221
+ placedElement(2, 5, 'AC'),
222
+ ]
223
+ const snapshot = buildWorkspaceGraphSnapshot(data)
224
+
225
+ const resolved = resolveZUIProxyConnectors(
226
+ snapshot,
227
+ new Map([
228
+ [1, 'd1-o1'],
229
+ [2, 'd1-o2'],
230
+ [3, 'd2-o3'],
231
+ [4, 'd2-o4'],
232
+ [5, 'd2-o5'],
233
+ ]),
234
+ zuiSettings({ connectorBudget: 2 }),
235
+ )
236
+
237
+ expect(resolved.connectors).toHaveLength(2)
238
+ expect(resolved.omittedConnectorCount).toBe(3)
239
+ })
240
+
241
+ it('reducing the budget keeps a subset of the larger-budget result', () => {
242
+ const connectors = [
243
+ connector(1, 1, 4, 2, 'deep-one'),
244
+ connector(2, 1, 4, 2, 'deep-two'),
245
+ connector(3, 1, 3, 2, 'shallow'),
246
+ ]
247
+ const snapshot = buildWorkspaceGraphSnapshot(baseData(connectors))
248
+ const visibleNodes = new Map([
249
+ [1, 'd1-o1'],
250
+ [2, 'd1-o2'],
251
+ [3, 'd2-o3'],
252
+ [4, 'd3-o4'],
253
+ ])
254
+
255
+ const budgetTwo = resolveZUIProxyConnectors(
256
+ snapshot,
257
+ visibleNodes,
258
+ zuiSettings({ connectorBudget: 2 }),
259
+ )
260
+ const budgetOne = resolveZUIProxyConnectors(
261
+ snapshot,
262
+ visibleNodes,
263
+ zuiSettings({ connectorBudget: 1 }),
264
+ )
265
+
266
+ const budgetTwoKeys = new Set(budgetTwo.connectors.map((item) => item.key))
267
+ expect(budgetTwo.connectors).toHaveLength(2)
268
+ expect(budgetOne.connectors).toHaveLength(1)
269
+ expect(budgetOne.connectors.every((item) => budgetTwoKeys.has(item.key))).toBe(true)
270
+ })
271
+
272
+ it('prioritizes one-near one-far connector groups in external mode', () => {
273
+ const data = baseData([
274
+ connector(1, 1, 3, 2, 'near-far'),
275
+ connector(2, 1, 4, 2, 'near-near'),
276
+ ])
277
+ data.views['2'].placements = [
278
+ placedElement(2, 3, 'External Far'),
279
+ placedElement(2, 4, 'Internal Near'),
280
+ ]
281
+ const snapshot = buildWorkspaceGraphSnapshot(data)
282
+ const visibleNodes = new Map([
283
+ [2, 'd1-o2'],
284
+ [3, 'd2-o3'],
285
+ [4, 'd2-o4'],
286
+ ])
287
+ const options = viewportOptions(new Map([
288
+ [2, anchor('d1-o2', 45, 45)],
289
+ [3, anchor('d2-o3', 400, 45)],
290
+ [4, anchor('d2-o4', 60, 45)],
291
+ ]))
292
+
293
+ const resolved = resolveZUIProxyConnectors(
294
+ snapshot,
295
+ visibleNodes,
296
+ zuiSettings({ connectorBudget: 1, connectorPriority: 'external' }),
297
+ options,
298
+ )
299
+
300
+ expect(resolved.connectors).toHaveLength(1)
301
+ expect(resolved.connectors[0].details.connectors[0].connector.label).toBe('near-far')
302
+ })
303
+
304
+ it('prioritizes both-near connector groups in internal mode', () => {
305
+ const data = baseData([
306
+ connector(1, 1, 3, 2, 'near-far'),
307
+ connector(2, 1, 4, 2, 'near-near'),
308
+ ])
309
+ data.views['2'].placements = [
310
+ placedElement(2, 3, 'External Far'),
311
+ placedElement(2, 4, 'Internal Near'),
312
+ ]
313
+ const snapshot = buildWorkspaceGraphSnapshot(data)
314
+ const visibleNodes = new Map([
315
+ [2, 'd1-o2'],
316
+ [3, 'd2-o3'],
317
+ [4, 'd2-o4'],
318
+ ])
319
+ const options = viewportOptions(new Map([
320
+ [2, anchor('d1-o2', 45, 45)],
321
+ [3, anchor('d2-o3', 400, 45)],
322
+ [4, anchor('d2-o4', 60, 45)],
323
+ ]))
324
+
325
+ const resolved = resolveZUIProxyConnectors(
326
+ snapshot,
327
+ visibleNodes,
328
+ zuiSettings({ connectorBudget: 1, connectorPriority: 'internal' }),
329
+ options,
330
+ )
331
+
332
+ expect(resolved.connectors).toHaveLength(1)
333
+ expect(resolved.connectors[0].details.connectors[0].connector.label).toBe('near-near')
334
+ })
335
+
336
+ it('uses a default budget of 50 and external priority in test settings', () => {
337
+ expect(zuiSettings()).toMatchObject({
338
+ connectorBudget: 50,
339
+ connectorPriority: 'external',
340
+ })
341
+ })
342
+ })