@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
@@ -1,8 +1,15 @@
1
- import { resolveZUIProxyConnectors, type ZUIResolvedConnector } from '../../crossBranch/resolve'
1
+ import {
2
+ resolveZUIProxyConnectors,
3
+ type ZUIHiddenProxyBadge,
4
+ type ZUIViewportBounds,
5
+ type ZUIProxyResolution,
6
+ type ZUIResolvedConnector,
7
+ } from '../../crossBranch/resolve'
2
8
  import type { WorkspaceGraphSnapshot } from '../../crossBranch/types'
3
9
  import type { LayoutNode, ZUIViewState, HoveredItem } from './types'
4
10
  import { getExpandThresholds, pickEdgeLabelPosition, type ScreenRect } from './renderer'
5
11
  import type { CrossBranchContextSettings } from '../../crossBranch/types'
12
+ import { DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA } from '../../crossBranch/settings'
6
13
 
7
14
  export interface VisibleNodeAnchor {
8
15
  nodeId: string
@@ -20,12 +27,58 @@ function clamp(value: number, min: number, max: number) {
20
27
  return value < min ? min : value > max ? max : value
21
28
  }
22
29
 
30
+ function connectorAlpha(alpha: number): number {
31
+ return clamp(alpha * 1.1, 0.35, 0.95)
32
+ }
33
+
23
34
  function transitionT(screenW: number, start: number, end: number): number {
24
35
  return clamp((screenW - start) / (end - start), 0, 1)
25
36
  }
26
37
 
27
- function collectVisibleAnchorsInNodes(
28
- nodes: LayoutNode[],
38
+ function visualRectForNode(
39
+ absX: number,
40
+ absY: number,
41
+ absW: number,
42
+ absH: number,
43
+ hasChildren: boolean,
44
+ screenW: number,
45
+ thresholds: { start: number; end: number },
46
+ ) {
47
+ if (!hasChildren && screenW > thresholds.end) {
48
+ const scale = thresholds.end / screenW
49
+ const visualW = absW * scale
50
+ const visualH = absH * scale
51
+ return {
52
+ worldX: absX + (absW - visualW) / 2,
53
+ worldY: absY + (absH - visualH) / 2,
54
+ worldW: visualW,
55
+ worldH: visualH,
56
+ }
57
+ }
58
+
59
+ return {
60
+ worldX: absX,
61
+ worldY: absY,
62
+ worldW: absW,
63
+ worldH: absH,
64
+ }
65
+ }
66
+
67
+ function registerVisibleAnchor(
68
+ node: LayoutNode,
69
+ visibleAnchors: Map<number, VisibleNodeAnchor>,
70
+ byNodeId: Map<string, VisibleNodeAnchor>,
71
+ anchor: VisibleNodeAnchor,
72
+ ) {
73
+ const existing = visibleAnchors.get(node.elementId)
74
+ if (!existing || existing.pathDepth < anchor.pathDepth || existing.renderAlpha < anchor.renderAlpha) {
75
+ visibleAnchors.set(node.elementId, anchor)
76
+ }
77
+ byNodeId.set(node.id, anchor)
78
+ }
79
+
80
+ function collectVisibleAnchorForNode(
81
+ node: LayoutNode,
29
82
  view: ZUIViewState,
30
83
  thresholds: { start: number; end: number },
31
84
  hiddenTags: Set<string>,
@@ -38,42 +91,42 @@ function collectVisibleAnchorsInNodes(
38
91
  parentChildOffsetX: number,
39
92
  parentChildOffsetY: number,
40
93
  ) {
41
- for (const node of nodes) {
42
- if (hiddenTags.size > 0 && node.tags.some((tag) => hiddenTags.has(tag))) continue
43
-
44
- const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
45
- const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
46
- const absScale = parentAbsScale
47
- const absW = node.worldW * absScale
48
- const absH = node.worldH * absScale
49
- const screenW = absW * view.zoom
50
- if (screenW < 2) continue
51
-
52
- const hasChildren = node.children && node.children.length > 0
53
- const t = hasChildren ? transitionT(screenW, thresholds.start, thresholds.end) : 0
54
- const parentAlpha = inheritedAlpha * (1 - t)
55
- const childAlpha = inheritedAlpha * t
56
-
57
- if (!hasChildren || t <= 0.95) {
58
- const anchor: VisibleNodeAnchor = {
59
- nodeId: node.id,
60
- elementId: node.elementId,
61
- label: node.label,
62
- worldX: absX,
63
- worldY: absY,
64
- worldW: absW,
65
- worldH: absH,
66
- pathDepth: node.pathElementIds.length,
67
- renderAlpha: hasChildren ? parentAlpha : inheritedAlpha,
68
- }
69
- const existing = visibleAnchors.get(node.elementId)
70
- if (!existing || existing.pathDepth < anchor.pathDepth) visibleAnchors.set(node.elementId, anchor)
71
- byNodeId.set(node.id, anchor)
72
- }
94
+ if (hiddenTags.size > 0 && node.tags.some((tag) => hiddenTags.has(tag))) return { selfDrawn: false }
95
+
96
+ const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
97
+ const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
98
+ const absScale = parentAbsScale
99
+ const absW = node.worldW * absScale
100
+ const absH = node.worldH * absScale
101
+ const screenW = absW * view.zoom
102
+ if (screenW < 2) return { selfDrawn: false }
103
+
104
+ const hasChildren = node.children && node.children.length > 0
105
+ const t = hasChildren ? transitionT(screenW, thresholds.start, thresholds.end) : 0
106
+ const parentAlpha = inheritedAlpha * (1 - t)
107
+ const childAlpha = inheritedAlpha * t
108
+ const selfDrawn = !hasChildren || t <= 0.95
109
+ const visualRect = visualRectForNode(absX, absY, absW, absH, hasChildren, screenW, thresholds)
110
+
111
+ if (selfDrawn) {
112
+ registerVisibleAnchor(node, visibleAnchors, byNodeId, {
113
+ nodeId: node.id,
114
+ elementId: node.elementId,
115
+ label: node.label,
116
+ worldX: visualRect.worldX,
117
+ worldY: visualRect.worldY,
118
+ worldW: visualRect.worldW,
119
+ worldH: visualRect.worldH,
120
+ pathDepth: node.pathElementIds.length,
121
+ renderAlpha: hasChildren ? parentAlpha : inheritedAlpha,
122
+ })
123
+ }
73
124
 
74
- if (hasChildren && t > 0.05) {
75
- collectVisibleAnchorsInNodes(
76
- node.children,
125
+ let hasDirectChildDrawn = false
126
+ if (hasChildren && t > 0.05) {
127
+ for (const child of node.children) {
128
+ const childResult = collectVisibleAnchorForNode(
129
+ child,
77
130
  view,
78
131
  thresholds,
79
132
  hiddenTags,
@@ -86,8 +139,57 @@ function collectVisibleAnchorsInNodes(
86
139
  node.childOffsetX,
87
140
  node.childOffsetY,
88
141
  )
142
+ hasDirectChildDrawn = hasDirectChildDrawn || childResult.selfDrawn
89
143
  }
90
144
  }
145
+
146
+ if (!selfDrawn && hasDirectChildDrawn) {
147
+ registerVisibleAnchor(node, visibleAnchors, byNodeId, {
148
+ nodeId: node.id,
149
+ elementId: node.elementId,
150
+ label: node.label,
151
+ worldX: visualRect.worldX,
152
+ worldY: visualRect.worldY,
153
+ worldW: visualRect.worldW,
154
+ worldH: visualRect.worldH,
155
+ pathDepth: node.pathElementIds.length,
156
+ renderAlpha: Math.max(0.12, inheritedAlpha * 0.28),
157
+ })
158
+ }
159
+
160
+ return { selfDrawn }
161
+ }
162
+
163
+ function collectVisibleAnchorsInNodes(
164
+ nodes: LayoutNode[],
165
+ view: ZUIViewState,
166
+ thresholds: { start: number; end: number },
167
+ hiddenTags: Set<string>,
168
+ visibleAnchors: Map<number, VisibleNodeAnchor>,
169
+ byNodeId: Map<string, VisibleNodeAnchor>,
170
+ inheritedAlpha: number,
171
+ parentAbsX: number,
172
+ parentAbsY: number,
173
+ parentAbsScale: number,
174
+ parentChildOffsetX: number,
175
+ parentChildOffsetY: number,
176
+ ) {
177
+ for (const node of nodes) {
178
+ collectVisibleAnchorForNode(
179
+ node,
180
+ view,
181
+ thresholds,
182
+ hiddenTags,
183
+ visibleAnchors,
184
+ byNodeId,
185
+ inheritedAlpha,
186
+ parentAbsX,
187
+ parentAbsY,
188
+ parentAbsScale,
189
+ parentChildOffsetX,
190
+ parentChildOffsetY,
191
+ )
192
+ }
91
193
  }
92
194
 
93
195
  export function collectVisibleNodeAnchors(
@@ -121,13 +223,23 @@ export function collectVisibleNodeAnchors(
121
223
  return { visibleAnchors, byNodeId }
122
224
  }
123
225
 
124
- function getDirectAnchorPoint(anchor: VisibleNodeAnchor, towards: VisibleNodeAnchor) {
226
+ function getAnchorCenter(anchor: VisibleNodeAnchor) {
227
+ return {
228
+ x: anchor.worldX + anchor.worldW / 2,
229
+ y: anchor.worldY + anchor.worldH / 2,
230
+ }
231
+ }
232
+
233
+ function containsPoint(anchor: VisibleNodeAnchor, x: number, y: number) {
234
+ return x >= anchor.worldX &&
235
+ x <= anchor.worldX + anchor.worldW &&
236
+ y >= anchor.worldY &&
237
+ y <= anchor.worldY + anchor.worldH
238
+ }
239
+
240
+ function getRectBoundaryPoint(anchor: VisibleNodeAnchor, dx: number, dy: number) {
125
241
  const cx = anchor.worldX + anchor.worldW / 2
126
242
  const cy = anchor.worldY + anchor.worldH / 2
127
- const tx = towards.worldX + towards.worldW / 2
128
- const ty = towards.worldY + towards.worldH / 2
129
- const dx = tx - cx
130
- const dy = ty - cy
131
243
  const hw = anchor.worldW / 2
132
244
  const hh = anchor.worldH / 2
133
245
 
@@ -148,15 +260,191 @@ function getDirectAnchorPoint(anchor: VisibleNodeAnchor, towards: VisibleNodeAnc
148
260
  }
149
261
  }
150
262
 
263
+ function getDirectAnchorPoint(anchor: VisibleNodeAnchor, towards: VisibleNodeAnchor) {
264
+ const anchorCenter = getAnchorCenter(anchor)
265
+ const towardsCenter = getAnchorCenter(towards)
266
+
267
+ // Nested anchors represent parent/child nodes. Aim the child endpoint away
268
+ // from the parent center so proxy lines attach to the nearer child edge.
269
+ if (containsPoint(towards, anchorCenter.x, anchorCenter.y)) {
270
+ return getRectBoundaryPoint(
271
+ anchor,
272
+ anchorCenter.x - towardsCenter.x,
273
+ anchorCenter.y - towardsCenter.y,
274
+ )
275
+ }
276
+
277
+ return getRectBoundaryPoint(
278
+ anchor,
279
+ towardsCenter.x - anchorCenter.x,
280
+ towardsCenter.y - anchorCenter.y,
281
+ )
282
+ }
283
+
284
+ function getDirectAnchorPoints(source: VisibleNodeAnchor, target: VisibleNodeAnchor) {
285
+ const sourcePoint = getDirectAnchorPoint(source, target)
286
+ const targetPoint = getDirectAnchorPoint(target, source)
287
+ return { sourcePoint, targetPoint }
288
+ }
289
+
290
+ function getDevicePixelRatio(): number {
291
+ return typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
292
+ }
293
+
294
+ function roundRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
295
+ ctx.beginPath()
296
+ ctx.roundRect(x, y, w, h, r)
297
+ }
298
+
299
+ function drawFixedScreenProxyBadge(
300
+ ctx: CanvasRenderingContext2D,
301
+ label: string,
302
+ labelPos: { x: number; y: number },
303
+ badgeCssW: number,
304
+ badgeCssH: number,
305
+ labelBg: string,
306
+ strokeStyle: string,
307
+ lineDashCss: number[],
308
+ fontWeight = 600,
309
+ ) {
310
+ const matrix = ctx.getTransform()
311
+ const dpr = getDevicePixelRatio()
312
+ const centerX = matrix.a * labelPos.x + matrix.c * labelPos.y + matrix.e
313
+ const centerY = matrix.b * labelPos.x + matrix.d * labelPos.y + matrix.f
314
+ const badgeW = badgeCssW * dpr
315
+ const badgeH = badgeCssH * dpr
316
+ const radius = badgeH / 2
317
+
318
+ ctx.save()
319
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
320
+ ctx.fillStyle = labelBg
321
+ roundRectPath(ctx, centerX - badgeW / 2, centerY - badgeH / 2, badgeW, badgeH, radius)
322
+ ctx.fill()
323
+ ctx.strokeStyle = strokeStyle
324
+ ctx.lineWidth = dpr
325
+ ctx.setLineDash(lineDashCss.map((value) => value * dpr))
326
+ ctx.stroke()
327
+ ctx.setLineDash([])
328
+ ctx.fillStyle = 'white'
329
+ ctx.font = `${fontWeight} ${11 * dpr}px Inter, system-ui, sans-serif`
330
+ ctx.textAlign = 'center'
331
+ ctx.textBaseline = 'middle'
332
+ ctx.fillText(label, centerX, centerY)
333
+ ctx.restore()
334
+ }
335
+
336
+ function measureProxyBadge(ctx: CanvasRenderingContext2D, label: string, zoom: number, fontWeight = 600) {
337
+ ctx.save()
338
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
339
+ ctx.font = `${fontWeight} 11px Inter, system-ui, sans-serif`
340
+ const textW = ctx.measureText(label).width
341
+ ctx.restore()
342
+
343
+ const badgeCssW = Math.max(24, textW + 14)
344
+ const badgeCssH = 24
345
+ return {
346
+ badgeCssW,
347
+ badgeCssH,
348
+ worldW: badgeCssW / zoom,
349
+ worldH: badgeCssH / zoom,
350
+ }
351
+ }
352
+
353
+ interface IndexedProxyConnector {
354
+ connector: ZUIResolvedConnector
355
+ x1: number
356
+ y1: number
357
+ x2: number
358
+ y2: number
359
+ midX: number
360
+ midY: number
361
+ }
362
+
363
+ export interface ProxyConnectorSpatialIndex {
364
+ cellSize: number
365
+ cells: Map<string, IndexedProxyConnector[]>
366
+ }
367
+
368
+ const PROXY_CONNECTOR_INDEX_CELL_SIZE = 360
369
+
370
+ function proxyCellKey(cx: number, cy: number): string {
371
+ return `${cx},${cy}`
372
+ }
373
+
374
+ function addProxyConnectorToSpatialIndex(index: ProxyConnectorSpatialIndex, connector: IndexedProxyConnector): void {
375
+ const minX = Math.min(connector.x1, connector.x2)
376
+ const maxX = Math.max(connector.x1, connector.x2)
377
+ const minY = Math.min(connector.y1, connector.y2)
378
+ const maxY = Math.max(connector.y1, connector.y2)
379
+ const startX = Math.floor(minX / index.cellSize)
380
+ const endX = Math.floor(maxX / index.cellSize)
381
+ const startY = Math.floor(minY / index.cellSize)
382
+ const endY = Math.floor(maxY / index.cellSize)
383
+
384
+ for (let cx = startX; cx <= endX; cx++) {
385
+ for (let cy = startY; cy <= endY; cy++) {
386
+ const key = proxyCellKey(cx, cy)
387
+ let bucket = index.cells.get(key)
388
+ if (!bucket) {
389
+ bucket = []
390
+ index.cells.set(key, bucket)
391
+ }
392
+ bucket.push(connector)
393
+ }
394
+ }
395
+ }
396
+
397
+ export function buildProxyConnectorSpatialIndex(
398
+ connectors: ZUIResolvedConnector[],
399
+ visibleAnchorsByNodeId: Map<string, VisibleNodeAnchor>,
400
+ ): ProxyConnectorSpatialIndex {
401
+ const index: ProxyConnectorSpatialIndex = {
402
+ cellSize: PROXY_CONNECTOR_INDEX_CELL_SIZE,
403
+ cells: new Map(),
404
+ }
405
+
406
+ for (const connector of connectors) {
407
+ const source = visibleAnchorsByNodeId.get(connector.sourceNodeId)
408
+ const target = visibleAnchorsByNodeId.get(connector.targetNodeId)
409
+ if (!source || !target) continue
410
+
411
+ const { sourcePoint, targetPoint } = getDirectAnchorPoints(source, target)
412
+ addProxyConnectorToSpatialIndex(index, {
413
+ connector,
414
+ x1: sourcePoint.x,
415
+ y1: sourcePoint.y,
416
+ x2: targetPoint.x,
417
+ y2: targetPoint.y,
418
+ midX: (sourcePoint.x + targetPoint.x) / 2,
419
+ midY: (sourcePoint.y + targetPoint.y) / 2,
420
+ })
421
+ }
422
+
423
+ return index
424
+ }
425
+
151
426
  export function buildVisibleProxyConnectors(
152
427
  snapshot: WorkspaceGraphSnapshot | null,
153
428
  visibleAnchors: Map<number, VisibleNodeAnchor>,
154
429
  settings: CrossBranchContextSettings,
155
- ): ZUIResolvedConnector[] {
430
+ viewport?: ZUIViewportBounds | null,
431
+ ): ZUIProxyResolution {
432
+ const minAlpha = settings.minConnectorAnchorAlpha ?? DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA
433
+ const eligibleAnchors = Array.from(visibleAnchors.entries())
434
+ .filter(([, anchor]) => anchor.renderAlpha >= minAlpha)
435
+ const connectorAnchors = new Map(eligibleAnchors.map(([elementId, anchor]) => [elementId, anchor.nodeId]))
436
+ const anchorsByElementId = new Map(eligibleAnchors.map(([elementId, anchor]) => [elementId, {
437
+ nodeId: anchor.nodeId,
438
+ worldX: anchor.worldX,
439
+ worldY: anchor.worldY,
440
+ worldW: anchor.worldW,
441
+ worldH: anchor.worldH,
442
+ }]))
156
443
  return resolveZUIProxyConnectors(
157
444
  snapshot,
158
- new Map(Array.from(visibleAnchors.entries()).map(([elementId, anchor]) => [elementId, anchor.nodeId])),
445
+ connectorAnchors,
159
446
  settings,
447
+ { viewport, anchorsByElementId },
160
448
  )
161
449
  }
162
450
 
@@ -166,8 +454,27 @@ export function drawVisibleProxyConnectors(
166
454
  visibleAnchorsByNodeId: Map<string, VisibleNodeAnchor>,
167
455
  zoom: number,
168
456
  labelBg: string,
457
+ accent: string,
169
458
  occupiedLabelRects: ScreenRect[],
170
459
  ) {
460
+ const connectorsByActualPair = new Map<string, ZUIResolvedConnector[]>()
461
+ for (const connector of connectors) {
462
+ const pairKey = `${Math.min(connector.sourceElementId, connector.targetElementId)}::${Math.max(connector.sourceElementId, connector.targetElementId)}`
463
+ const family = connectorsByActualPair.get(pairKey)
464
+ if (family) family.push(connector)
465
+ else connectorsByActualPair.set(pairKey, [connector])
466
+ }
467
+
468
+ const provenanceKeys = new Set<string>()
469
+ for (const family of connectorsByActualPair.values()) {
470
+ if (family.length < 2) continue
471
+ const sorted = [...family].sort((left, right) => {
472
+ if (left.maxDepth !== right.maxDepth) return left.maxDepth - right.maxDepth
473
+ return (left.sourceDepth + left.targetDepth) - (right.sourceDepth + right.targetDepth)
474
+ })
475
+ for (const connector of sorted.slice(1)) provenanceKeys.add(connector.key)
476
+ }
477
+
171
478
  for (const connector of connectors) {
172
479
  const source = visibleAnchorsByNodeId.get(connector.sourceNodeId)
173
480
  const target = visibleAnchorsByNodeId.get(connector.targetNodeId)
@@ -175,104 +482,153 @@ export function drawVisibleProxyConnectors(
175
482
  const alpha = Math.min(source.renderAlpha, target.renderAlpha)
176
483
  if (alpha < 0.01) continue
177
484
 
178
- const sourcePoint = getDirectAnchorPoint(source, target)
179
- const targetPoint = getDirectAnchorPoint(target, source)
485
+ const { sourcePoint, targetPoint } = getDirectAnchorPoints(source, target)
180
486
  const midX = (sourcePoint.x + targetPoint.x) / 2
181
487
  const midY = (sourcePoint.y + targetPoint.y) / 2
182
488
  const label = String(connector.details.count)
183
489
 
184
490
  ctx.save()
185
- ctx.globalAlpha = alpha
186
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'
187
- ctx.lineWidth = 2 / zoom
491
+ const isProvenanceStub = provenanceKeys.has(connector.key)
492
+ if (isProvenanceStub) {
493
+ ctx.restore()
494
+ continue
495
+ }
496
+
497
+ ctx.globalAlpha = connectorAlpha(alpha) * 0.8
498
+ ctx.strokeStyle = accent
499
+ ctx.lineWidth = 1 / zoom
500
+ ctx.lineCap = 'round'
501
+ ctx.setLineDash([1 / zoom, 4 / zoom])
188
502
  ctx.beginPath()
189
503
  ctx.moveTo(sourcePoint.x, sourcePoint.y)
190
504
  ctx.lineTo(targetPoint.x, targetPoint.y)
191
505
  ctx.stroke()
192
- const fontSize = 11 / zoom
193
- ctx.font = `${fontSize}px Inter, system-ui, sans-serif`
194
- const textMetrics = ctx.measureText(label)
195
- const textW = textMetrics.width
196
- const textH = fontSize
506
+ ctx.setLineDash([])
507
+ const badge = measureProxyBadge(ctx, label, zoom)
197
508
  const labelPos = pickEdgeLabelPosition(
198
509
  ctx.getTransform(),
199
510
  midX,
200
511
  midY,
201
- textW,
202
- textH,
512
+ badge.worldW,
513
+ badge.worldH,
203
514
  targetPoint.x - sourcePoint.x,
204
515
  targetPoint.y - sourcePoint.y,
205
516
  occupiedLabelRects,
206
517
  )
207
- const px = 6 / zoom
208
- const py = 4 / zoom
209
- const badgeW = textW + px * 2
210
- const badgeH = textH + py * 2
211
- const badgeRadius = badgeH / 2
212
- ctx.fillStyle = labelBg
213
- ctx.beginPath()
214
- ctx.roundRect(
215
- labelPos.x - badgeW / 2,
216
- labelPos.y - badgeH / 2,
217
- badgeW,
218
- badgeH,
219
- badgeRadius,
220
- )
221
- ctx.fill()
222
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'
223
- ctx.lineWidth = 1 / zoom
224
- ctx.stroke()
225
- ctx.fillStyle = 'white'
226
- ctx.textAlign = 'center'
227
- ctx.textBaseline = 'middle'
228
- ctx.fillText(label, labelPos.x, labelPos.y)
518
+ drawFixedScreenProxyBadge(ctx, label, labelPos, badge.badgeCssW, badge.badgeCssH, labelBg, accent, [1, 4])
229
519
 
230
520
  ctx.restore()
231
521
  }
232
522
  }
233
523
 
524
+ export function drawVisibleDirectProxyBadges(
525
+ ctx: CanvasRenderingContext2D,
526
+ badges: ZUIHiddenProxyBadge[],
527
+ visibleAnchorsByNodeId: Map<string, VisibleNodeAnchor>,
528
+ zoom: number,
529
+ labelBg: string,
530
+ occupiedLabelRects: ScreenRect[],
531
+ ) {
532
+ for (const badge of badges) {
533
+ const source = visibleAnchorsByNodeId.get(badge.sourceNodeId)
534
+ const target = visibleAnchorsByNodeId.get(badge.targetNodeId)
535
+ if (!source || !target) continue
536
+ const alpha = Math.min(source.renderAlpha, target.renderAlpha)
537
+ if (alpha < 0.01) continue
538
+
539
+ const { sourcePoint, targetPoint } = getDirectAnchorPoints(source, target)
540
+ const midX = (sourcePoint.x + targetPoint.x) / 2
541
+ const midY = (sourcePoint.y + targetPoint.y) / 2
542
+ const label = `+${badge.count}`
543
+
544
+ ctx.save()
545
+ ctx.globalAlpha = alpha
546
+ const badgeMetrics = measureProxyBadge(ctx, label, zoom)
547
+ const labelPos = pickEdgeLabelPosition(
548
+ ctx.getTransform(),
549
+ midX,
550
+ midY,
551
+ badgeMetrics.worldW,
552
+ badgeMetrics.worldH,
553
+ targetPoint.x - sourcePoint.x,
554
+ targetPoint.y - sourcePoint.y,
555
+ occupiedLabelRects,
556
+ )
557
+ drawFixedScreenProxyBadge(
558
+ ctx,
559
+ label,
560
+ labelPos,
561
+ badgeMetrics.badgeCssW,
562
+ badgeMetrics.badgeCssH,
563
+ labelBg,
564
+ 'rgba(255, 255, 255, 0.5)',
565
+ [4, 3],
566
+ )
567
+ ctx.restore()
568
+ }
569
+ }
570
+
234
571
  export function findHoveredProxyConnector(
235
572
  worldX: number,
236
573
  worldY: number,
237
- connectors: ZUIResolvedConnector[],
238
- visibleAnchorsByNodeId: Map<string, VisibleNodeAnchor>,
574
+ index: ProxyConnectorSpatialIndex,
239
575
  view: ZUIViewState,
240
576
  ): HoveredItem | null {
241
577
  const threshold = 18 / view.zoom
242
- for (const connector of connectors) {
243
- const source = visibleAnchorsByNodeId.get(connector.sourceNodeId)
244
- const target = visibleAnchorsByNodeId.get(connector.targetNodeId)
245
- if (!source || !target) continue
246
- const x1 = source.worldX + source.worldW / 2
247
- const y1 = source.worldY + source.worldH / 2
248
- const x2 = target.worldX + target.worldW / 2
249
- const y2 = target.worldY + target.worldH / 2
250
- const dx = x2 - x1
251
- const dy = y2 - y1
252
- const l2 = dx * dx + dy * dy
253
- if (l2 === 0) continue
254
- let t = ((worldX - x1) * dx + (worldY - y1) * dy) / l2
255
- t = Math.max(0, Math.min(1, t))
256
- const nearestX = x1 + t * dx
257
- const nearestY = y1 + t * dy
258
- const dist = Math.sqrt((worldX - nearestX) ** 2 + (worldY - nearestY) ** 2)
259
- if (dist > threshold) continue
578
+ const startX = Math.floor((worldX - threshold) / index.cellSize)
579
+ const endX = Math.floor((worldX + threshold) / index.cellSize)
580
+ const startY = Math.floor((worldY - threshold) / index.cellSize)
581
+ const endY = Math.floor((worldY + threshold) / index.cellSize)
582
+ const thresholdSquared = threshold * threshold
583
+ const seen = new Set<string>()
584
+ let bestConnector: IndexedProxyConnector | null = null
585
+ let bestDistSquared = thresholdSquared
260
586
 
261
- return {
262
- type: 'edge',
263
- data: {
264
- sourceId: connector.details.sourceAnchorName,
265
- targetId: connector.details.targetAnchorName,
266
- label: connector.details.label || 'Cross-branch connector',
267
- diagramId: connector.details.ownerViewIds[0] ?? 0,
268
- sourceObjId: connector.sourceAnchorElementId,
269
- targetObjId: connector.targetAnchorElementId,
270
- isProxy: true,
271
- details: connector.details,
272
- },
273
- absX: (x1 + x2) / 2,
274
- absY: (y1 + y2) / 2,
587
+ for (let cx = startX; cx <= endX; cx++) {
588
+ for (let cy = startY; cy <= endY; cy++) {
589
+ const bucket = index.cells.get(proxyCellKey(cx, cy))
590
+ if (!bucket) continue
591
+
592
+ for (const indexed of bucket) {
593
+ const connector = indexed.connector
594
+ if (seen.has(connector.key)) continue
595
+ seen.add(connector.key)
596
+ const x1 = indexed.x1
597
+ const y1 = indexed.y1
598
+ const x2 = indexed.x2
599
+ const y2 = indexed.y2
600
+ const dx = x2 - x1
601
+ const dy = y2 - y1
602
+ const l2 = dx * dx + dy * dy
603
+ if (l2 === 0) continue
604
+ let t = ((worldX - x1) * dx + (worldY - y1) * dy) / l2
605
+ t = Math.max(0, Math.min(1, t))
606
+ const nearestX = x1 + t * dx
607
+ const nearestY = y1 + t * dy
608
+ const distSquared = (worldX - nearestX) ** 2 + (worldY - nearestY) ** 2
609
+ if (distSquared > bestDistSquared) continue
610
+ bestDistSquared = distSquared
611
+ bestConnector = indexed
612
+ }
275
613
  }
276
614
  }
277
- return null
615
+
616
+ if (!bestConnector) return null
617
+
618
+ const connector = bestConnector.connector
619
+ return {
620
+ type: 'edge',
621
+ data: {
622
+ sourceId: connector.details.sourceAnchorName,
623
+ targetId: connector.details.targetAnchorName,
624
+ label: connector.details.label || 'Cross-branch connector',
625
+ diagramId: connector.details.ownerViewIds[0] ?? 0,
626
+ sourceObjId: connector.sourceAnchorElementId,
627
+ targetObjId: connector.targetAnchorElementId,
628
+ isProxy: true,
629
+ details: connector.details,
630
+ },
631
+ absX: bestConnector.midX,
632
+ absY: bestConnector.midY,
633
+ }
278
634
  }