@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
@@ -27,17 +27,29 @@ import { Link as RouterLink } from 'react-router-dom'
27
27
  import { ExternalLinkIcon } from '@chakra-ui/icons'
28
28
  import type { ExploreData } from '../../types'
29
29
  import { computeLayout } from './layout'
30
- import { renderFrame, getExpandThresholds, setOnImageLoadCallback, setHighlightedTags as setRendererHighlightedTags, setHiddenTags as setRendererHiddenTags, setHighlightColor as setRendererHighlightColor } from './renderer'
30
+ import { renderFrame, getExpandThresholds, getCameraRebase, rawCameraView, screenToWorldX, screenToWorldY, worldToScreenX, worldToScreenY, setOnImageLoadCallback, setHighlightedTags as setRendererHighlightedTags, setHiddenTags as setRendererHiddenTags, setHighlightColor as setRendererHighlightColor, setVersionDiff as setRendererVersionDiff } from './renderer'
31
31
  import { useZUIInteraction } from './useZUIInteraction'
32
32
  import type { DiagramGroupLayout, ZUIViewState } from './types'
33
+ import { findDiagramFocusTarget, findElementFocusTarget, viewportForDiagramFocusTarget, viewportForElementFocusTarget } from './focus'
33
34
  import { buildWorkspaceGraphSnapshot } from '../../crossBranch/graph'
34
35
  import type { CrossBranchContextSettings } from '../../crossBranch/types'
35
- import type { ZUIResolvedConnector } from '../../crossBranch/resolve'
36
- import { buildVisibleProxyConnectors, collectVisibleNodeAnchors, drawVisibleProxyConnectors, findHoveredProxyConnector } from './proxy'
36
+ import { DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA } from '../../crossBranch/settings'
37
+ import type { WorkspaceVersionFollowTarget, WorkspaceVersionPreview } from '../../context/WorkspaceVersionContext'
38
+ import {
39
+ buildProxyConnectorSpatialIndex,
40
+ buildVisibleProxyConnectors,
41
+ collectVisibleNodeAnchors,
42
+ drawVisibleDirectProxyBadges,
43
+ drawVisibleProxyConnectors,
44
+ findHoveredProxyConnector,
45
+ type ProxyConnectorSpatialIndex,
46
+ type VisibleNodeAnchor,
47
+ } from './proxy'
37
48
 
38
49
  export interface ZUICanvasHandle {
39
50
  fitView(): void
40
51
  focusDiagram(viewId: number): boolean
52
+ focusElement(viewId: number, elementId: number): boolean
41
53
  setCameraFrame(frame: ZUICameraFrame): boolean
42
54
  }
43
55
 
@@ -51,9 +63,12 @@ interface Props {
51
63
  onReady?: () => void
52
64
  onZoom?: () => void
53
65
  onPan?: () => void
66
+ initialCameraFrame?: ZUICameraFrame
54
67
  highlightedTags?: string[]
55
68
  highlightColor?: string
56
69
  hiddenTags?: string[]
70
+ versionPreview?: WorkspaceVersionPreview | null
71
+ versionFollowTarget?: WorkspaceVersionFollowTarget | null
57
72
  crossBranchSettings: CrossBranchContextSettings
58
73
  hoverLocked?: boolean
59
74
  }
@@ -70,6 +85,26 @@ interface PathItem {
70
85
  absH: number
71
86
  }
72
87
 
88
+ function rebaseVisibleNodeAnchors(
89
+ anchors: Map<string, VisibleNodeAnchor>,
90
+ originX: number,
91
+ originY: number,
92
+ ): Map<string, VisibleNodeAnchor> {
93
+ const rebased = new Map<string, VisibleNodeAnchor>()
94
+ for (const [nodeId, anchor] of anchors) {
95
+ rebased.set(nodeId, {
96
+ ...anchor,
97
+ worldX: anchor.worldX - originX,
98
+ worldY: anchor.worldY - originY,
99
+ })
100
+ }
101
+ return rebased
102
+ }
103
+
104
+ function anchorViewForZoom(zoom: number): ZUIViewState {
105
+ return { x: 0, y: 0, zoom: Math.max(0.0001, zoom) }
106
+ }
107
+
73
108
  function getPathAt(
74
109
  view: ZUIViewState,
75
110
  groups: DiagramGroupLayout[],
@@ -79,8 +114,8 @@ function getPathAt(
79
114
  if (canvasW === 0 || canvasH === 0) return []
80
115
 
81
116
  // World center of the screen
82
- const worldCenterX = (canvasW / 2 - view.x) / view.zoom
83
- const worldCenterY = (canvasH / 2 - view.y) / view.zoom
117
+ const worldCenterX = screenToWorldX(canvasW / 2, view)
118
+ const worldCenterY = screenToWorldY(canvasH / 2, view)
84
119
  const thresholds = getExpandThresholds(canvasW)
85
120
 
86
121
  for (const group of groups) {
@@ -176,70 +211,6 @@ function getPathAt(
176
211
  return []
177
212
  }
178
213
 
179
- function findDiagramFocusTarget(groups: DiagramGroupLayout[], viewId: number): PathItem | null {
180
- for (const group of groups) {
181
- if (group.diagramId === viewId) {
182
- return {
183
- id: `g-${group.diagramId}`,
184
- label: group.label,
185
- type: 'group',
186
- absX: group.worldX,
187
- absY: group.worldY,
188
- absW: group.worldW,
189
- absH: group.worldH,
190
- }
191
- }
192
-
193
- const found = findLinkedDiagramInNodes(viewId, group.nodes, 0, 0, 1, 0, 0)
194
- if (found) return found
195
- }
196
- return null
197
- }
198
-
199
- function findLinkedDiagramInNodes(
200
- viewId: number,
201
- nodes: DiagramGroupLayout['nodes'],
202
- parentAbsX: number,
203
- parentAbsY: number,
204
- parentAbsScale: number,
205
- parentChildOffsetX: number,
206
- parentChildOffsetY: number,
207
- ): PathItem | null {
208
- for (const node of nodes) {
209
- const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
210
- const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
211
- const absW = node.worldW * parentAbsScale
212
- const absH = node.worldH * parentAbsScale
213
-
214
- if (node.linkedDiagramId === viewId) {
215
- return {
216
- id: node.id,
217
- label: node.linkedDiagramLabel || node.label,
218
- type: 'node',
219
- isCircular: node.isCircular,
220
- absX,
221
- absY,
222
- absW,
223
- absH,
224
- }
225
- }
226
-
227
- if (node.children.length > 0) {
228
- const found = findLinkedDiagramInNodes(
229
- viewId,
230
- node.children,
231
- absX,
232
- absY,
233
- parentAbsScale * node.childScale,
234
- node.childOffsetX,
235
- node.childOffsetY,
236
- )
237
- if (found) return found
238
- }
239
- }
240
- return null
241
- }
242
-
243
214
  function easeOutQuart(t: number): number {
244
215
  return 1 - Math.pow(1 - t, 4)
245
216
  }
@@ -319,25 +290,27 @@ function findFirstExpandableNodeInTree(
319
290
  return null
320
291
  }
321
292
 
322
- export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({ data, onReady, onZoom, onPan, highlightedTags, highlightColor, hiddenTags, crossBranchSettings, hoverLocked = false }, ref) {
293
+ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({ data, onReady, onZoom, onPan, initialCameraFrame, highlightedTags, highlightColor, hiddenTags, versionPreview, versionFollowTarget, crossBranchSettings, hoverLocked = false }, ref) {
323
294
  const canvasRef = useRef<HTMLCanvasElement>(null)
324
295
  const containerRef = useRef<HTMLDivElement>(null)
325
296
  const cameraTransitionRef = useRef<number | null>(null)
326
297
  const [initialized, setInitialized] = useState(false)
327
298
  const [containerSize, setContainerSize] = useState({ w: 0, h: 0 })
328
299
  const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
300
+ const debugViewport = useMemo(() => typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugZuiCamera'), [])
329
301
 
330
302
  // ── Layout ──────────────────────────────────────────────────────
331
303
  const layout = useMemo(() => computeLayout(data), [data])
332
304
  const workspaceSnapshot = useMemo(() => buildWorkspaceGraphSnapshot(data), [data])
333
- // Holds the most-recently resolved connector topology so hover detection can
334
- // use it without re-running the expensive O(connectors) resolution on every mousemove.
335
- const proxyConnectorsRef = useRef<ZUIResolvedConnector[]>([])
336
-
337
- const resolveHoveredProxyItem = useCallback((worldX: number, worldY: number, view: ZUIViewState, canvasW: number) => {
338
- const freshAnchors = collectVisibleNodeAnchors(layout.groups, view, canvasW, hiddenTags)
339
- return findHoveredProxyConnector(worldX, worldY, proxyConnectorsRef.current, freshAnchors.byNodeId, view)
340
- }, [hiddenTags, layout.groups])
305
+ // Holds the latest proxy hover index so mousemove can query it without
306
+ // rebuilding anchors or connector geometry.
307
+ const proxyHoverIndexRef = useRef<ProxyConnectorSpatialIndex | null>(null)
308
+
309
+ const resolveHoveredProxyItem = useCallback((worldX: number, worldY: number, view: ZUIViewState) => {
310
+ const index = proxyHoverIndexRef.current
311
+ if (!index) return null
312
+ return findHoveredProxyConnector(worldX, worldY, index, view)
313
+ }, [])
341
314
 
342
315
  // ── Interaction ─────────────────────────────────────────────────
343
316
  const { viewState, viewStateRef, setViewState, fitView, maxZoom, hoveredItem, setHoveredItem, setHoverLocked } = useZUIInteraction(
@@ -351,42 +324,100 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
351
324
  resolveHoveredProxyItem,
352
325
  )
353
326
 
354
- // Anchor positions recompute every render (fast tree traversal, view-dependent).
327
+ // Anchor positions are zoom-dependent, but not pan-dependent. Keeping pan out
328
+ // of this memo avoids re-walking the ZUI tree during drag/trackpad movement.
355
329
  const anchors = useMemo(() =>
356
- collectVisibleNodeAnchors(layout.groups, viewState, containerSize.w || 1, hiddenTags),
357
- [layout.groups, viewState, containerSize.w, hiddenTags],
330
+ collectVisibleNodeAnchors(layout.groups, anchorViewForZoom(viewState.zoom), containerSize.w || 1, hiddenTags),
331
+ [layout.groups, viewState.zoom, containerSize.w, hiddenTags],
358
332
  )
359
333
 
334
+ const viewportBounds = useMemo(() => {
335
+ const zoom = Math.max(0.0001, viewState.zoom)
336
+ const stableView = { ...viewState, zoom }
337
+ const minX = screenToWorldX(0, stableView)
338
+ const minY = screenToWorldY(0, stableView)
339
+ const maxX = screenToWorldX(containerSize.w, stableView)
340
+ const maxY = screenToWorldY(containerSize.h, stableView)
341
+ return {
342
+ minX,
343
+ minY,
344
+ maxX,
345
+ maxY,
346
+ centerX: (minX + maxX) / 2,
347
+ centerY: (minY + maxY) / 2,
348
+ }
349
+ }, [containerSize.h, containerSize.w, viewState])
350
+
351
+ useEffect(() => {
352
+ if (!debugViewport) return
353
+ const cameraRebase = getCameraRebase(viewState, containerSize.w, containerSize.h)
354
+ console.debug('[ZUICanvas] viewport', {
355
+ x: viewState.x,
356
+ y: viewState.y,
357
+ zoom: viewState.zoom,
358
+ width: containerSize.w,
359
+ height: containerSize.h,
360
+ minX: viewportBounds.minX,
361
+ minY: viewportBounds.minY,
362
+ maxX: viewportBounds.maxX,
363
+ maxY: viewportBounds.maxY,
364
+ centerX: viewportBounds.centerX,
365
+ centerY: viewportBounds.centerY,
366
+ renderX: cameraRebase.view.x,
367
+ renderY: cameraRebase.view.y,
368
+ renderOriginX: cameraRebase.originX,
369
+ renderOriginY: cameraRebase.originY,
370
+ })
371
+ }, [containerSize.h, containerSize.w, debugViewport, viewState, viewportBounds])
372
+
360
373
  // A stable string key encoding which element→nodeId pairs are currently visible.
361
374
  // This only changes when nodes cross zoom-expansion thresholds not on every pan pixel.
362
375
  const visibleElementSig = useMemo(() =>
363
376
  Array.from(anchors.visibleAnchors.entries())
364
377
  .sort(([a], [b]) => a - b)
365
- .map(([id, anchor]) => `${id}:${anchor.nodeId}`)
378
+ .map(([id, anchor]) => `${id}:${anchor.nodeId}:${anchor.renderAlpha >= (crossBranchSettings.minConnectorAnchorAlpha ?? DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA) ? 1 : 0}`)
366
379
  .join(','),
367
- [anchors.visibleAnchors],
380
+ [anchors.visibleAnchors, crossBranchSettings.minConnectorAnchorAlpha],
368
381
  )
369
-
370
- // Connector topology: expensive O(connectors) resolution only when visibility set changes.
382
+ const proxySettingsSig = [
383
+ crossBranchSettings.enabled,
384
+ crossBranchSettings.depth,
385
+ crossBranchSettings.connectorBudget,
386
+ crossBranchSettings.connectorPriority,
387
+ crossBranchSettings.minConnectorAnchorAlpha ?? '',
388
+ crossBranchSettings.maxProxyConnectorGroups ?? '',
389
+ ].join(':')
390
+
391
+ // Connector topology follows visible anchor identity, not camera position.
392
+ // Continuous pan/zoom renders reuse the previous topology until zoom changes
393
+ // which elements have visible/eligible anchors.
371
394
  const proxyConnectors = useMemo(() => {
372
395
  const resolved = buildVisibleProxyConnectors(workspaceSnapshot, anchors.visibleAnchors, crossBranchSettings)
373
- proxyConnectorsRef.current = resolved
374
396
  return resolved
375
397
  // eslint-disable-next-line react-hooks/exhaustive-deps
376
- }, [workspaceSnapshot, visibleElementSig, crossBranchSettings])
398
+ }, [workspaceSnapshot, visibleElementSig, proxySettingsSig])
399
+
400
+ const proxyHoverIndex = useMemo(() => (
401
+ buildProxyConnectorSpatialIndex(proxyConnectors.connectors, anchors.byNodeId)
402
+ ), [proxyConnectors.connectors, anchors.byNodeId])
403
+ proxyHoverIndexRef.current = proxyHoverIndex
377
404
 
378
405
  const visibleProxyState = useMemo(() => ({
379
406
  ...anchors,
380
- proxyConnectors,
407
+ proxyConnectors: proxyConnectors.connectors,
408
+ hiddenProxyBadges: proxyConnectors.hiddenBadges,
381
409
  }), [anchors, proxyConnectors])
382
410
 
383
411
  const visibleProxyStateRef = useRef(visibleProxyState)
384
412
  visibleProxyStateRef.current = visibleProxyState
385
413
 
386
414
  const labelBgRef = useRef('#171923')
415
+ const accentRef = useRef('#63b3ed')
387
416
  useEffect(() => {
388
417
  const update = () => {
389
- labelBgRef.current = getComputedStyle(document.documentElement).getPropertyValue('--chakra-colors-gray-900').trim() || '#171923'
418
+ const styles = getComputedStyle(document.documentElement)
419
+ labelBgRef.current = styles.getPropertyValue('--chakra-colors-gray-900').trim() || '#171923'
420
+ accentRef.current = styles.getPropertyValue('--accent').trim() || '#63b3ed'
390
421
  needsRedrawRef.current = true
391
422
  }
392
423
  update()
@@ -414,8 +445,8 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
414
445
  absY = g.worldY + g.diagramY - absH
415
446
  }
416
447
 
417
- const sx = absX * viewState.zoom + viewState.x
418
- const sy = absY * viewState.zoom + viewState.y
448
+ const sx = worldToScreenX(absX, viewState)
449
+ const sy = worldToScreenY(absY, viewState)
419
450
  const sw = absW * viewState.zoom
420
451
  const sh = absH * viewState.zoom
421
452
 
@@ -472,36 +503,13 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
472
503
  setViewState({ x, y, zoom })
473
504
  }, [containerSize, maxZoom, setViewState, setHoveredItem])
474
505
 
475
- const focusDiagram = useCallback((viewId: number) => {
476
- const el = containerRef.current
477
- const target = findDiagramFocusTarget(layout.groups, viewId)
478
- if (!el || !target) return false
479
-
480
- const canvasW = el.offsetWidth
481
- const canvasH = el.offsetHeight
482
- if (canvasW === 0 || canvasH === 0) return false
483
-
484
- setHoveredItem(null, true)
485
-
486
- const padding = isMobileLayout ? 0.18 : 0.16
487
- const bboxW = Math.max(1, target.absW)
488
- const bboxH = Math.max(1, target.absH)
489
- const zoom = Math.min(
490
- (canvasW * (1 - padding * 2)) / bboxW,
491
- (canvasH * (1 - padding * 2)) / bboxH,
492
- maxZoom,
493
- )
494
-
495
- const x = (canvasW - bboxW * zoom) / 2 - target.absX * zoom
496
- const y = (canvasH - bboxH * zoom) / 2 - target.absY * zoom
497
-
506
+ const animateToViewport = useCallback((to: ZUIViewState) => {
498
507
  if (cameraTransitionRef.current !== null) {
499
508
  cancelAnimationFrame(cameraTransitionRef.current)
500
509
  cameraTransitionRef.current = null
501
510
  }
502
511
 
503
- const from = viewStateRef.current
504
- const to = { x, y, zoom }
512
+ const from = rawCameraView(viewStateRef.current)
505
513
  const duration = 520
506
514
  const startedAt = performance.now()
507
515
 
@@ -523,8 +531,41 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
523
531
  }
524
532
 
525
533
  cameraTransitionRef.current = requestAnimationFrame(step)
534
+ }, [setViewState, viewStateRef])
535
+
536
+ const focusDiagram = useCallback((viewId: number) => {
537
+ const el = containerRef.current
538
+ const target = findDiagramFocusTarget(layout.groups, viewId)
539
+ if (!el || !target) return false
540
+
541
+ const canvasW = el.offsetWidth
542
+ const canvasH = el.offsetHeight
543
+ if (canvasW === 0 || canvasH === 0) return false
544
+
545
+ const to = viewportForDiagramFocusTarget(target, canvasW, canvasH, maxZoom, isMobileLayout)
546
+ if (!to) return false
547
+
548
+ setHoveredItem(null, true)
549
+ animateToViewport(to)
526
550
  return true
527
- }, [isMobileLayout, layout.groups, maxZoom, setHoveredItem, setViewState, viewStateRef])
551
+ }, [animateToViewport, isMobileLayout, layout.groups, maxZoom, setHoveredItem])
552
+
553
+ const focusElement = useCallback((viewId: number, elementId: number) => {
554
+ const el = containerRef.current
555
+ const target = findElementFocusTarget(layout.groups, viewId, elementId)
556
+ if (!el || !target) return false
557
+
558
+ const canvasW = el.offsetWidth
559
+ const canvasH = el.offsetHeight
560
+ if (canvasW === 0 || canvasH === 0) return false
561
+
562
+ const to = viewportForElementFocusTarget(target, canvasW, canvasH, maxZoom, isMobileLayout)
563
+ if (!to) return false
564
+
565
+ setHoveredItem(null, true)
566
+ animateToViewport(to)
567
+ return true
568
+ }, [animateToViewport, isMobileLayout, layout.groups, maxZoom, setHoveredItem])
528
569
 
529
570
  const setCameraFrame = useCallback((frame: ZUICameraFrame) => {
530
571
  if (frame.profile !== 'detail-to-overview') return false
@@ -583,6 +624,11 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
583
624
  return true
584
625
  }, [layout.groups, maxZoom, setHoveredItem, setViewState])
585
626
 
627
+ const fitInitialView = useCallback((w: number, h: number) => {
628
+ if (initialCameraFrame && setCameraFrame(initialCameraFrame)) return
629
+ fitView(w, h, layout.bbox)
630
+ }, [fitView, initialCameraFrame, layout.bbox, setCameraFrame])
631
+
586
632
  useEffect(() => {
587
633
  return () => {
588
634
  if (cameraTransitionRef.current !== null) {
@@ -600,14 +646,13 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
600
646
  // Only set as initialized if we have valid dimensions
601
647
  if (w > 0 && h > 0) {
602
648
  setContainerSize({ w, h })
603
- fitView(w, h, layout.bbox)
649
+ fitInitialView(w, h)
604
650
  if (!initialized) {
605
651
  setInitialized(true)
606
652
  onReady?.()
607
653
  }
608
654
  }
609
- // eslint-disable-next-line react-hooks/exhaustive-deps
610
- }, [layout, initialized, onReady])
655
+ }, [initialized, onReady, fitInitialView])
611
656
 
612
657
  // ── Expose fitView to parent ─────────────────────────────────────
613
658
  useImperativeHandle(
@@ -620,9 +665,10 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
620
665
  fitView(el.offsetWidth, el.offsetHeight, layout.bbox)
621
666
  },
622
667
  focusDiagram,
668
+ focusElement,
623
669
  setCameraFrame,
624
670
  }),
625
- [fitView, focusDiagram, layout.bbox, setCameraFrame, setHoveredItem],
671
+ [fitView, focusDiagram, focusElement, layout.bbox, setCameraFrame, setHoveredItem],
626
672
  )
627
673
 
628
674
  // ── RAF render loop ──────────────────────────────────────────────
@@ -670,7 +716,7 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
670
716
 
671
717
  // Trigger initialization if it hasn't happened yet
672
718
  if (!initialized && w > 0 && h > 0) {
673
- fitView(w, h, layout.bbox)
719
+ fitInitialView(w, h)
674
720
  setInitialized(true)
675
721
  onReady?.()
676
722
  }
@@ -680,7 +726,7 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
680
726
  ro.observe(container)
681
727
  resize()
682
728
  return () => ro.disconnect()
683
- }, [initialized, layout, fitView, onReady])
729
+ }, [initialized, fitInitialView, onReady])
684
730
 
685
731
  useEffect(() => {
686
732
  if (!initialized) return // Don't start loop until initialized
@@ -714,14 +760,29 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
714
760
  ctx.save()
715
761
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
716
762
  const occupiedLabelRects = renderFrame(ctx, layout.groups, currentView, w, h)
763
+ const cameraRebase = getCameraRebase(currentView, w, h)
764
+ const rebasedProxyAnchors = rebaseVisibleNodeAnchors(
765
+ visibleProxyStateRef.current.byNodeId,
766
+ cameraRebase.originX,
767
+ cameraRebase.originY,
768
+ )
717
769
  ctx.save()
718
- ctx.translate(currentView.x, currentView.y)
719
- ctx.scale(currentView.zoom, currentView.zoom)
770
+ ctx.translate(cameraRebase.view.x, cameraRebase.view.y)
771
+ ctx.scale(cameraRebase.view.zoom, cameraRebase.view.zoom)
720
772
  drawVisibleProxyConnectors(
721
773
  ctx,
722
774
  visibleProxyStateRef.current.proxyConnectors,
723
- visibleProxyStateRef.current.byNodeId,
724
- currentView.zoom,
775
+ rebasedProxyAnchors,
776
+ cameraRebase.view.zoom,
777
+ labelBgRef.current,
778
+ accentRef.current,
779
+ occupiedLabelRects,
780
+ )
781
+ drawVisibleDirectProxyBadges(
782
+ ctx,
783
+ visibleProxyStateRef.current.hiddenProxyBadges,
784
+ rebasedProxyAnchors,
785
+ cameraRebase.view.zoom,
725
786
  labelBgRef.current,
726
787
  occupiedLabelRects,
727
788
  )
@@ -756,6 +817,30 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
756
817
  needsRedrawRef.current = true
757
818
  }, [hiddenTags])
758
819
 
820
+ useEffect(() => {
821
+ const pulsedElementChanges = new Map<number, string>()
822
+ const pulsedElementLineDeltas = new Map<number, { added: number; removed: number }>()
823
+ if (versionFollowTarget?.resourceType === 'element' && versionFollowTarget.resourceId) {
824
+ const change = versionFollowTarget.changeType ?? versionPreview?.elementChanges.get(versionFollowTarget.resourceId)
825
+ if (change) pulsedElementChanges.set(versionFollowTarget.resourceId, change)
826
+ }
827
+ setRendererVersionDiff(
828
+ pulsedElementChanges,
829
+ versionPreview?.connectorChanges ?? new Map(),
830
+ versionPreview?.elementLineDeltas ?? pulsedElementLineDeltas,
831
+ )
832
+ needsRedrawRef.current = true
833
+ }, [versionPreview, versionFollowTarget])
834
+
835
+ useEffect(() => {
836
+ if (!initialized || !versionFollowTarget?.viewId) return
837
+ if (versionFollowTarget.resourceType === 'element' && versionFollowTarget.resourceId) {
838
+ focusElement(versionFollowTarget.viewId, versionFollowTarget.resourceId)
839
+ return
840
+ }
841
+ focusDiagram(versionFollowTarget.viewId)
842
+ }, [focusDiagram, focusElement, initialized, versionFollowTarget?.resourceId, versionFollowTarget?.resourceType, versionFollowTarget?.token, versionFollowTarget?.viewId])
843
+
759
844
  useEffect(() => {
760
845
  setHoverLocked(hoverLocked)
761
846
  }, [hoverLocked, setHoverLocked])
@@ -766,6 +851,7 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
766
851
  setRendererHighlightedTags(new Set())
767
852
  setRendererHighlightColor('')
768
853
  setRendererHiddenTags(new Set())
854
+ setRendererVersionDiff(new Map(), new Map())
769
855
  }
770
856
  }, [])
771
857
 
@@ -952,6 +1038,19 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
952
1038
  </Text>
953
1039
  <Text fontSize="xs" color="gray.400">{hoveredItem.data.details.label}</Text>
954
1040
  </VStack>
1041
+ <VStack align="start" spacing={1} width="full">
1042
+ <Text color="gray.400" fontSize="2xs" fontWeight="600" letterSpacing="wider">UNDERLYING PATHS</Text>
1043
+ {hoveredItem.data.details.connectors.slice(0, 4).map((leaf, index) => (
1044
+ <Text key={`${leaf.connector.id}-${index}`} fontSize="xs" color="gray.200">
1045
+ {leaf.source.actualElementName} &rarr; {leaf.target.actualElementName}
1046
+ </Text>
1047
+ ))}
1048
+ {hoveredItem.data.details.connectors.length > 4 && (
1049
+ <Text fontSize="xs" color="gray.500">
1050
+ +{hoveredItem.data.details.connectors.length - 4} more
1051
+ </Text>
1052
+ )}
1053
+ </VStack>
955
1054
  <Divider borderColor="whiteAlpha.200" />
956
1055
  <VStack align="stretch" spacing={2} width="full">
957
1056
  {hoveredItem.data.details.ownerViewIds.map((ownerViewId, index) => (