@tldiagram/core-ui 1.95.1 → 2.0.1

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 (100) 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/NodeContainer.d.ts +2 -0
  9. package/dist/components/ProxyConnectorPanel.d.ts +4 -1
  10. package/dist/components/ViewExplorer/index.d.ts +1 -1
  11. package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
  12. package/dist/components/ViewFloatingMenu.d.ts +8 -1
  13. package/dist/components/ViewGridNode.d.ts +3 -0
  14. package/dist/components/ViewPanel.d.ts +2 -1
  15. package/dist/components/WorkspacePanel.d.ts +2 -0
  16. package/dist/components/ZUI/ZUICanvas.d.ts +4 -0
  17. package/dist/components/ZUI/focus.d.ts +32 -0
  18. package/dist/components/ZUI/focus.test.d.ts +1 -0
  19. package/dist/components/ZUI/layout.d.ts +2 -2
  20. package/dist/components/ZUI/proxy.d.ts +20 -4
  21. package/dist/components/ZUI/renderer.d.ts +35 -1
  22. package/dist/components/ZUI/types.d.ts +6 -0
  23. package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
  24. package/dist/context/WorkspaceVersionContext.d.ts +49 -0
  25. package/dist/crossBranch/resolve.d.ts +39 -2
  26. package/dist/crossBranch/resolve.test.d.ts +1 -0
  27. package/dist/crossBranch/settings.d.ts +6 -1
  28. package/dist/crossBranch/types.d.ts +8 -0
  29. package/dist/hooks/useElementSearch.d.ts +8 -0
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +16529 -14030
  32. package/dist/pages/InfiniteZoom.d.ts +1 -0
  33. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
  34. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
  35. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
  36. package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
  37. package/dist/pages/viewsJumpSearch.d.ts +22 -0
  38. package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
  39. package/dist/store/useStore.d.ts +3 -0
  40. package/dist/types/index.d.ts +9 -0
  41. package/dist/utils/elementIcon.d.ts +2 -0
  42. package/dist/utils/elementIcon.test.d.ts +1 -0
  43. package/dist/utils/sourceEditor.d.ts +7 -0
  44. package/dist/utils/watchDiffSummary.d.ts +34 -0
  45. package/package.json +2 -2
  46. package/src/App.tsx +12 -8
  47. package/src/api/client.ts +488 -26
  48. package/src/components/CodePreviewPanel.tsx +90 -16
  49. package/src/components/ConnectorPanel.tsx +34 -3
  50. package/src/components/ContextNeighborElement.tsx +2 -5
  51. package/src/components/CrossBranchControls.tsx +46 -17
  52. package/src/components/ElementNode.tsx +98 -47
  53. package/src/components/ElementPanel.tsx +62 -25
  54. package/src/components/InlineElementAdder.tsx +8 -3
  55. package/src/components/LayoutSection.tsx +4 -1
  56. package/src/components/MergeDialog.tsx +269 -0
  57. package/src/components/NodeContainer.tsx +55 -17
  58. package/src/components/ProxyConnectorPanel.tsx +58 -16
  59. package/src/components/ViewBezierConnector.tsx +116 -21
  60. package/src/components/ViewExplorer/index.tsx +1 -1
  61. package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
  62. package/src/components/ViewFloatingMenu.tsx +110 -1
  63. package/src/components/ViewGridNode.tsx +59 -8
  64. package/src/components/ViewPanel.tsx +3 -2
  65. package/src/components/WorkspacePanel.tsx +938 -0
  66. package/src/components/ZUI/ZUICanvas.tsx +216 -122
  67. package/src/components/ZUI/focus.test.ts +534 -0
  68. package/src/components/ZUI/focus.ts +293 -0
  69. package/src/components/ZUI/layout.ts +7 -11
  70. package/src/components/ZUI/proxy.ts +470 -114
  71. package/src/components/ZUI/renderer.ts +510 -134
  72. package/src/components/ZUI/types.ts +6 -0
  73. package/src/components/ZUI/useZUIInteraction.ts +66 -29
  74. package/src/context/WorkspaceVersionContext.tsx +126 -0
  75. package/src/crossBranch/resolve.test.ts +342 -0
  76. package/src/crossBranch/resolve.ts +368 -68
  77. package/src/crossBranch/settings.ts +49 -3
  78. package/src/crossBranch/types.ts +9 -0
  79. package/src/hooks/useElementSearch.ts +45 -0
  80. package/src/index.css +11 -0
  81. package/src/index.ts +7 -0
  82. package/src/pages/AppearanceSettings.tsx +24 -1
  83. package/src/pages/Dependencies.tsx +231 -65
  84. package/src/pages/InfiniteZoom.tsx +41 -19
  85. package/src/pages/Settings.tsx +1 -1
  86. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
  87. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
  88. package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
  89. package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
  90. package/src/pages/ViewEditor/index.tsx +549 -59
  91. package/src/pages/Views.tsx +112 -41
  92. package/src/pages/ViewsGrid.tsx +332 -113
  93. package/src/pages/viewsJumpSearch.test.ts +193 -0
  94. package/src/pages/viewsJumpSearch.ts +111 -0
  95. package/src/store/useStore.ts +58 -0
  96. package/src/types/index.ts +10 -0
  97. package/src/utils/elementIcon.test.ts +28 -0
  98. package/src/utils/elementIcon.ts +20 -0
  99. package/src/utils/sourceEditor.ts +46 -0
  100. package/src/utils/watchDiffSummary.ts +159 -0
@@ -5,6 +5,7 @@ import {
5
5
  DEFAULT_SOURCE_HANDLE_SIDE,
6
6
  DEFAULT_TARGET_HANDLE_SIDE,
7
7
  getHandleFlowPosition,
8
+ getHandleSlotOffsetFromId,
8
9
  getLogicalHandleId,
9
10
  getVisualHandleIdForGroup,
10
11
  } from '../../utils/edgeDistribution'
@@ -21,15 +22,21 @@ export function getExpandThresholds(canvasW: number) {
21
22
  const MIN_LABEL_PX = 12 // below this screen width, skip label text
22
23
  const MIN_DRAW_PX = 2 // below this screen width, skip node entirely
23
24
  const BADGE_THRESHOLD = 100 // node width in screen pixels below which we hide type badge and zoom icon
25
+ const CONNECTOR_MIN_ALPHA = 0.32
26
+ const CONNECTOR_MAX_ALPHA = 0.95
27
+ const CONNECTOR_LINE_PX = 2
24
28
 
25
29
  // ── Screen-space font limits (px) ──────────────────────────────────
26
- const MIN_FONT_NAME = 10
27
- const MAX_FONT_NAME = 50
28
- const MIN_FONT_BADGE = 12
29
- const MAX_FONT_BADGE = 30
30
30
  const MIN_FONT_HINT = 12
31
31
  const MAX_FONT_HINT = 24
32
32
 
33
+ // Match ViewEditor ElementNode: nameSize="xl" (20px) and typeSize="2xs"
34
+ // (10px), rounded="lg" (8px), on the default 85px-high node.
35
+ const VIEW_EDITOR_NODE_H = 85
36
+ const NAME_FONT_TO_NODE_H = 20 / VIEW_EDITOR_NODE_H
37
+ const TYPE_FONT_TO_NODE_H = 10 / VIEW_EDITOR_NODE_H
38
+ const RADIUS_TO_NODE_H = 8 / VIEW_EDITOR_NODE_H
39
+
33
40
  export interface ScreenRect {
34
41
  left: number
35
42
  top: number
@@ -48,20 +55,6 @@ function getClampedFontSize(baseWorldSize: number, minScreenSize: number, maxScr
48
55
  return clamp(baseWorldSize, minScreenSize / zoom, maxScreenSize / zoom)
49
56
  }
50
57
 
51
- // ── Chakra v2 type palette - mirrors TYPE_COLORS in src/types/index.ts ─
52
- // .400 variants: used for type badge text and border tint
53
- const TYPE_COLOR_400: Record<string, string> = {
54
- person: '#38b2ac', // teal.400
55
- system: '#63b3ed', // blue.400
56
- container: '#9f7aea', // purple.400
57
- component: '#f6ad55', // orange.400
58
- database: '#4fd1c5', // cyan.400
59
- queue: '#f6e05e', // yellow.400
60
- api: '#68d391', // green.400
61
- service: '#f687b3', // pink.400
62
- external: '#a0aec0', // gray.400
63
- }
64
-
65
58
  /** Border color: type .400 at 50% alpha - bold branded tint */
66
59
  const typeBorderColorCache = new Map<string, string>()
67
60
  function typeBorderColor(type: string, alpha = 0.5): string {
@@ -69,8 +62,7 @@ function typeBorderColor(type: string, alpha = 0.5): string {
69
62
  const cached = typeBorderColorCache.get(cacheKey)
70
63
  if (cached) return cached
71
64
 
72
- const color = TYPE_COLOR_400[type]
73
- const hex = typeof color === 'string' ? color : '#a0aec0'
65
+ const hex = '#a0aec0'
74
66
  const r = parseInt(hex.slice(1, 3), 16)
75
67
  const g = parseInt(hex.slice(3, 5), 16)
76
68
  const b = parseInt(hex.slice(5, 7), 16)
@@ -144,6 +136,19 @@ export function setHiddenTags(tags: Set<string>): void {
144
136
  currentHiddenTags = tags
145
137
  }
146
138
 
139
+ let currentVersionElementChanges: Map<number, string> = new Map()
140
+ let currentVersionConnectorChanges: Map<number, string> = new Map()
141
+ let currentVersionElementLineDeltas: Map<number, { added: number; removed: number }> = new Map()
142
+ export function setVersionDiff(
143
+ elementChanges: Map<number, string>,
144
+ connectorChanges: Map<number, string>,
145
+ elementLineDeltas: Map<number, { added: number; removed: number }> = new Map(),
146
+ ): void {
147
+ currentVersionElementChanges = elementChanges
148
+ currentVersionConnectorChanges = connectorChanges
149
+ currentVersionElementLineDeltas = elementLineDeltas
150
+ }
151
+
147
152
  /**
148
153
  * Get image from cache or start loading it.
149
154
  * Returns the image if already loaded, null otherwise.
@@ -168,10 +173,125 @@ function clamp(v: number, min: number, max: number): number {
168
173
  return v < min ? min : v > max ? max : v
169
174
  }
170
175
 
171
- function transitionT(screenW: number, start: number, end: number): number {
176
+ export function viewOriginX(view: ZUIViewState): number {
177
+ return view.originX ?? 0
178
+ }
179
+
180
+ export function viewOriginY(view: ZUIViewState): number {
181
+ return view.originY ?? 0
182
+ }
183
+
184
+ export function worldToScreenX(worldX: number, view: ZUIViewState): number {
185
+ return (worldX - viewOriginX(view)) * view.zoom + view.x
186
+ }
187
+
188
+ export function worldToScreenY(worldY: number, view: ZUIViewState): number {
189
+ return (worldY - viewOriginY(view)) * view.zoom + view.y
190
+ }
191
+
192
+ export function screenToWorldX(screenX: number, view: ZUIViewState): number {
193
+ return viewOriginX(view) + (screenX - view.x) / view.zoom
194
+ }
195
+
196
+ export function screenToWorldY(screenY: number, view: ZUIViewState): number {
197
+ return viewOriginY(view) + (screenY - view.y) / view.zoom
198
+ }
199
+
200
+ export function rawCameraView(view: ZUIViewState): ZUIViewState {
201
+ return {
202
+ x: view.x - viewOriginX(view) * view.zoom,
203
+ y: view.y - viewOriginY(view) * view.zoom,
204
+ zoom: view.zoom,
205
+ }
206
+ }
207
+
208
+ function connectorAlpha(alpha: number): number {
209
+ return clamp(alpha * 1.15, CONNECTOR_MIN_ALPHA, CONNECTOR_MAX_ALPHA)
210
+ }
211
+
212
+ function normalizeEdgeRouteType(type: string | null | undefined): 'bezier' | 'straight' | 'step' | 'smoothstep' {
213
+ if (type === 'straight' || type === 'step' || type === 'smoothstep') return type
214
+ return 'bezier'
215
+ }
216
+
217
+ export interface ZUITransitionRebase {
218
+ preserveChildAlphaNodeIds: Set<string>
219
+ }
220
+
221
+ export function transitionT(screenW: number, start: number, end: number): number {
172
222
  return clamp((screenW - start) / (end - start), 0, 1)
173
223
  }
174
224
 
225
+ export function buildCameraTransitionRebase(
226
+ groups: DiagramGroupLayout[],
227
+ view: ZUIViewState,
228
+ canvasW: number,
229
+ canvasH: number,
230
+ thresholds: { start: number; end: number },
231
+ ): ZUITransitionRebase {
232
+ if (canvasW <= 0 || canvasH <= 0 || view.zoom <= 0) {
233
+ return { preserveChildAlphaNodeIds: new Set() }
234
+ }
235
+
236
+ const worldCenterX = screenToWorldX(canvasW / 2, view)
237
+ const worldCenterY = screenToWorldY(canvasH / 2, view)
238
+ const path: Array<{ id: string; t: number }> = []
239
+
240
+ for (const group of groups) {
241
+ if (
242
+ worldCenterX < group.worldX ||
243
+ worldCenterX > group.worldX + group.worldW ||
244
+ worldCenterY < group.worldY ||
245
+ worldCenterY > group.worldY + group.worldH
246
+ ) {
247
+ continue
248
+ }
249
+
250
+ let currentX = worldCenterX
251
+ let currentY = worldCenterY
252
+ let currentNodes = group.nodes
253
+ let cumulativeScale = 1
254
+
255
+ while (true) {
256
+ const node = currentNodes.find((candidate) =>
257
+ currentX >= candidate.worldX &&
258
+ currentX <= candidate.worldX + candidate.worldW &&
259
+ currentY >= candidate.worldY &&
260
+ currentY <= candidate.worldY + candidate.worldH
261
+ )
262
+
263
+ if (!node) break
264
+
265
+ const hasChildren = node.children && node.children.length > 0
266
+ const screenW = node.worldW * view.zoom * cumulativeScale
267
+ const t = hasChildren ? transitionT(screenW, thresholds.start, thresholds.end) : 0
268
+ path.push({ id: node.id, t })
269
+
270
+ if (!hasChildren || t <= 0.05 || node.childScale <= 0) break
271
+
272
+ currentX = (currentX - node.worldX) / node.childScale + node.childOffsetX
273
+ currentY = (currentY - node.worldY) / node.childScale + node.childOffsetY
274
+ currentNodes = node.children
275
+ cumulativeScale *= node.childScale
276
+ }
277
+
278
+ break
279
+ }
280
+
281
+ const activeTransitionIndexes = path
282
+ .map((entry, index) => ({ ...entry, index }))
283
+ .filter((entry) => entry.t > 0.05 && entry.t < 0.95)
284
+
285
+ if (activeTransitionIndexes.length <= 1) {
286
+ return { preserveChildAlphaNodeIds: new Set() }
287
+ }
288
+
289
+ const deepestActiveIndex = activeTransitionIndexes[activeTransitionIndexes.length - 1].index
290
+ return {
291
+ preserveChildAlphaNodeIds: new Set(path.slice(0, deepestActiveIndex).map((entry) => entry.id)),
292
+ }
293
+ }
294
+
175
295
  function rectsOverlap(a: ScreenRect, b: ScreenRect): boolean {
176
296
  return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
177
297
  }
@@ -262,8 +382,8 @@ export function isVisible(
262
382
  worldX: number, worldY: number, worldW: number, worldH: number,
263
383
  view: ZUIViewState, canvasW: number, canvasH: number,
264
384
  ): boolean {
265
- const sx = worldX * view.zoom + view.x
266
- const sy = worldY * view.zoom + view.y
385
+ const sx = worldToScreenX(worldX, view)
386
+ const sy = worldToScreenY(worldY, view)
267
387
  const sw = worldW * view.zoom
268
388
  const sh = worldH * view.zoom
269
389
  return sx + sw > 0 && sy + sh > 0 && sx < canvasW && sy < canvasH
@@ -274,13 +394,204 @@ export function isFullyVisible(
274
394
  worldX: number, worldY: number, worldW: number, worldH: number,
275
395
  view: ZUIViewState, canvasW: number, canvasH: number,
276
396
  ): boolean {
277
- const sx = worldX * view.zoom + view.x
278
- const sy = worldY * view.zoom + view.y
397
+ const sx = worldToScreenX(worldX, view)
398
+ const sy = worldToScreenY(worldY, view)
279
399
  const sw = worldW * view.zoom
280
400
  const sh = worldH * view.zoom
281
401
  return sx >= 0 && sy >= 0 && sx + sw <= canvasW && sy + sh <= canvasH
282
402
  }
283
403
 
404
+ export interface ZUICameraRebase {
405
+ originX: number
406
+ originY: number
407
+ view: ZUIViewState
408
+ }
409
+
410
+ export function getCameraRebase(view: ZUIViewState, canvasW: number, canvasH: number): ZUICameraRebase {
411
+ const zoom = Math.max(0.0001, view.zoom)
412
+ return {
413
+ originX: screenToWorldX(canvasW / 2, { ...view, zoom }),
414
+ originY: screenToWorldY(canvasH / 2, { ...view, zoom }),
415
+ view: {
416
+ x: canvasW / 2,
417
+ y: canvasH / 2,
418
+ zoom: view.zoom,
419
+ },
420
+ }
421
+ }
422
+
423
+ function rebaseRootNodeForRender(node: LayoutNode, rebase: ZUICameraRebase): LayoutNode {
424
+ return {
425
+ ...node,
426
+ worldX: node.worldX - rebase.originX,
427
+ worldY: node.worldY - rebase.originY,
428
+ }
429
+ }
430
+
431
+ interface RebasedRenderGroup {
432
+ sourceNodes: LayoutNode[]
433
+ group: DiagramGroupLayout
434
+ }
435
+
436
+ const rebasedRenderGroupCache = new WeakMap<DiagramGroupLayout, RebasedRenderGroup>()
437
+
438
+ function rebaseGroupForRender(group: DiagramGroupLayout, rebase: ZUICameraRebase): DiagramGroupLayout {
439
+ let cached = rebasedRenderGroupCache.get(group)
440
+ if (!cached || cached.sourceNodes !== group.nodes) {
441
+ cached = {
442
+ sourceNodes: group.nodes,
443
+ group: {
444
+ ...group,
445
+ nodes: group.nodes.map((node) => rebaseRootNodeForRender(node, rebase)),
446
+ },
447
+ }
448
+ rebasedRenderGroupCache.set(group, cached)
449
+ }
450
+
451
+ cached.group.worldX = group.worldX - rebase.originX
452
+ cached.group.worldY = group.worldY - rebase.originY
453
+ cached.group.worldW = group.worldW
454
+ cached.group.worldH = group.worldH
455
+ cached.group.diagramW = group.diagramW
456
+ cached.group.diagramH = group.diagramH
457
+ cached.group.diagramX = group.diagramX
458
+ cached.group.diagramY = group.diagramY
459
+ cached.group.edges = group.edges
460
+
461
+ for (let index = 0; index < group.nodes.length; index += 1) {
462
+ const source = group.nodes[index]
463
+ const target = cached.group.nodes[index]
464
+ Object.assign(target, source, {
465
+ worldX: source.worldX - rebase.originX,
466
+ worldY: source.worldY - rebase.originY,
467
+ })
468
+ }
469
+
470
+ return cached.group
471
+ }
472
+
473
+ interface FocusedFlattenedLayer {
474
+ nodes: LayoutNode[]
475
+ view: ZUIViewState
476
+ }
477
+
478
+ function flattenNodeForRender(
479
+ node: LayoutNode,
480
+ absX: number,
481
+ absY: number,
482
+ layerScale: number,
483
+ rebase: ZUICameraRebase,
484
+ ): LayoutNode {
485
+ return {
486
+ ...node,
487
+ worldX: (absX - rebase.originX) / layerScale,
488
+ worldY: (absY - rebase.originY) / layerScale,
489
+ worldW: node.worldW,
490
+ worldH: node.worldH,
491
+ children: [],
492
+ }
493
+ }
494
+
495
+ function flattenSiblingLayerForRender(
496
+ nodes: LayoutNode[],
497
+ parentAbsX: number,
498
+ parentAbsY: number,
499
+ parentAbsScale: number,
500
+ parentChildOffsetX: number,
501
+ parentChildOffsetY: number,
502
+ rebase: ZUICameraRebase,
503
+ ): LayoutNode[] {
504
+ return nodes.map((node) => {
505
+ const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
506
+ const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
507
+ return flattenNodeForRender(node, absX, absY, parentAbsScale, rebase)
508
+ })
509
+ }
510
+
511
+ export function findFocusedFlattenedLayerForTest(
512
+ groups: DiagramGroupLayout[],
513
+ view: ZUIViewState,
514
+ canvasW: number,
515
+ canvasH: number,
516
+ thresholds: { start: number; end: number },
517
+ rebase: ZUICameraRebase,
518
+ ): FocusedFlattenedLayer | null {
519
+ if (canvasW <= 0 || canvasH <= 0 || view.zoom < 1_000_000) return null
520
+
521
+ const worldCenterX = screenToWorldX(canvasW / 2, view)
522
+ const worldCenterY = screenToWorldY(canvasH / 2, view)
523
+
524
+ for (const group of groups) {
525
+ if (
526
+ worldCenterX < group.worldX ||
527
+ worldCenterX > group.worldX + group.worldW ||
528
+ worldCenterY < group.worldY ||
529
+ worldCenterY > group.worldY + group.worldH
530
+ ) {
531
+ continue
532
+ }
533
+
534
+ let currentX = worldCenterX
535
+ let currentY = worldCenterY
536
+ let currentNodes = group.nodes
537
+ let parentAbsX = 0
538
+ let parentAbsY = 0
539
+ let parentAbsScale = 1
540
+ let parentChildOffsetX = 0
541
+ let parentChildOffsetY = 0
542
+ let focusedLayer: FocusedFlattenedLayer | null = null
543
+
544
+ while (true) {
545
+ const node = currentNodes.find((candidate) =>
546
+ currentX >= candidate.worldX &&
547
+ currentX <= candidate.worldX + candidate.worldW &&
548
+ currentY >= candidate.worldY &&
549
+ currentY <= candidate.worldY + candidate.worldH
550
+ )
551
+ if (!node) break
552
+
553
+ const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
554
+ const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
555
+ const hasChildren = node.children && node.children.length > 0
556
+ const screenW = node.worldW * parentAbsScale * view.zoom
557
+ const t = hasChildren ? transitionT(screenW, thresholds.start, thresholds.end) : 0
558
+
559
+ if (!hasChildren || t < 0.95 || node.childScale <= 0) break
560
+
561
+ const childAbsScale = parentAbsScale * node.childScale
562
+ focusedLayer = {
563
+ nodes: flattenSiblingLayerForRender(
564
+ node.children,
565
+ absX,
566
+ absY,
567
+ childAbsScale,
568
+ node.childOffsetX,
569
+ node.childOffsetY,
570
+ rebase,
571
+ ),
572
+ view: {
573
+ x: canvasW / 2,
574
+ y: canvasH / 2,
575
+ zoom: view.zoom * childAbsScale,
576
+ },
577
+ }
578
+
579
+ currentX = (currentX - node.worldX) / node.childScale + node.childOffsetX
580
+ currentY = (currentY - node.worldY) / node.childScale + node.childOffsetY
581
+ currentNodes = node.children
582
+ parentAbsX = absX
583
+ parentAbsY = absY
584
+ parentAbsScale = childAbsScale
585
+ parentChildOffsetX = node.childOffsetX
586
+ parentChildOffsetY = node.childOffsetY
587
+ }
588
+
589
+ return focusedLayer
590
+ }
591
+
592
+ return null
593
+ }
594
+
284
595
  /** Draw the ZoomIn magnifying glass icon. */
285
596
  function drawZoomInIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, strokeWidth: number): void {
286
597
  ctx.save()
@@ -306,29 +617,7 @@ function drawZoomInIcon(ctx: CanvasRenderingContext2D, x: number, y: number, siz
306
617
  ctx.restore()
307
618
  }
308
619
 
309
- /** Draw a portal arrow icon () for portal nodes. */
310
- function drawPortalIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, strokeWidth: number, color: string): void {
311
- ctx.save()
312
- ctx.strokeStyle = color
313
- ctx.lineWidth = strokeWidth
314
- ctx.lineCap = 'round'
315
- ctx.lineJoin = 'round'
316
- ctx.translate(x, y)
317
- const s = size / 16
318
- ctx.scale(s, s)
319
- ctx.beginPath()
320
- // Arrow shaft: (2,14) → (13,3)
321
- ctx.moveTo(2, 14)
322
- ctx.lineTo(13, 3)
323
- // Arrow head
324
- ctx.moveTo(5, 3)
325
- ctx.lineTo(13, 3)
326
- ctx.lineTo(13, 11)
327
- ctx.stroke()
328
- ctx.restore()
329
- }
330
-
331
- /** Draw a cycle icon (↺) for circular nodes. */
620
+ /** Draw a cycle icon () for circular nodes. NOT USED CURRENTLY */
332
621
  function drawCycleIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, strokeWidth: number, color: string): void {
333
622
  ctx.save()
334
623
  ctx.strokeStyle = color
@@ -371,23 +660,26 @@ function portalTintColor(accent: string, alpha: number): string {
371
660
  return rgba
372
661
  }
373
662
 
374
- /** Draw a squiggly line from (x1, y1) to (x2, y2). */
375
- function drawSquigglyLine(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, zoom: number): void {
376
- ctx.save()
377
- ctx.beginPath()
378
- ctx.moveTo(x1, y1)
379
- ctx.lineTo(x2, y2)
380
- const dashLen = 6 / zoom
381
- ctx.setLineDash([dashLen, dashLen * 1.5])
382
- ctx.stroke()
383
- ctx.restore()
384
- }
385
-
386
663
  /** Calculate coordinate for a named handle on a node. */
387
- function getHandlePos(nodeX: number, nodeY: number, nodeW: number, nodeH: number, handleId: string | null, isSource: boolean): { x: number, y: number, pos: 'top' | 'bottom' | 'left' | 'right' } {
664
+ function getHandlePos(nodeX: number, nodeY: number, nodeW: number, nodeH: number, handleId: string | null, isSource: boolean, slotScale = 1): { x: number, y: number, pos: 'top' | 'bottom' | 'left' | 'right' } {
388
665
  const fallback = isSource ? DEFAULT_SOURCE_HANDLE_SIDE : DEFAULT_TARGET_HANDLE_SIDE
389
- const { x, y, side } = getHandleFlowPosition(nodeX, nodeY, nodeW, nodeH, handleId, fallback)
390
- return { x, y, pos: side }
666
+ if (slotScale === 1) {
667
+ const { x, y, side } = getHandleFlowPosition(nodeX, nodeY, nodeW, nodeH, handleId, fallback)
668
+ return { x, y, pos: side }
669
+ }
670
+
671
+ const side = getLogicalHandleId(handleId, fallback) ?? fallback
672
+ const offset = getHandleSlotOffsetFromId(handleId) * slotScale
673
+ switch (side) {
674
+ case 'top':
675
+ return { x: nodeX + nodeW / 2 + offset, y: nodeY, pos: side }
676
+ case 'bottom':
677
+ return { x: nodeX + nodeW / 2 + offset, y: nodeY + nodeH, pos: side }
678
+ case 'left':
679
+ return { x: nodeX, y: nodeY + nodeH / 2 + offset, pos: side }
680
+ case 'right':
681
+ return { x: nodeX + nodeW, y: nodeY + nodeH / 2 + offset, pos: side }
682
+ }
391
683
  }
392
684
 
393
685
  /** Draw a closed arrow head matching React Flow MarkerType.ArrowClosed. */
@@ -443,6 +735,7 @@ function drawNode(
443
735
  absY: number,
444
736
  absScale: number,
445
737
  occupiedLabelRects: ScreenRect[],
738
+ transitionRebase: ZUITransitionRebase,
446
739
  ): void {
447
740
  if (screenW < MIN_DRAW_PX || alpha < 0.01) return
448
741
 
@@ -474,8 +767,8 @@ function drawNode(
474
767
  }
475
768
 
476
769
  const parentAlpha = alpha * (1 - t)
477
- const childAlpha = alpha * t
478
- const r = 8 / drawZoom // matches Chakra rounded="lg" (8px)
770
+ const childAlpha = transitionRebase.preserveChildAlphaNodeIds.has(node.id) ? alpha : alpha * t
771
+ const r = h * RADIUS_TO_NODE_H
479
772
 
480
773
  const borderColor = typeBorderColor(node.type)
481
774
 
@@ -519,6 +812,19 @@ function drawNode(
519
812
  ctx.restore()
520
813
  }
521
814
 
815
+ // ── Shadow ───────────────────────────────────────────────────────
816
+ // Subtler shadow for Canvas performance
817
+ if (parentAlpha > 0.5 && screenW > 40) {
818
+ ctx.save()
819
+ ctx.globalAlpha = parentAlpha * 0.4
820
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
821
+ ctx.shadowBlur = 12 / drawZoom
822
+ ctx.shadowOffsetY = 4 / drawZoom
823
+ traceShape()
824
+ ctx.fill()
825
+ ctx.restore()
826
+ }
827
+
522
828
  // ── Background ───────────────────────────────────────────────────
523
829
  // We draw two backgrounds:
524
830
  // 1. A base background (canvasBg) that remains opaque (total 'alpha').
@@ -605,10 +911,7 @@ function drawNode(
605
911
 
606
912
  // ── Label - portal shows "PORTAL" badge in accent; otherwise type badge ─
607
913
  if (screenW >= MIN_LABEL_PX && parentAlpha > 0.1) {
608
- // Dynamic minimum: don't let font be larger than a fraction of node height on screen
609
- const minName = Math.min(MIN_FONT_NAME, screenW * 0.35)
610
- // w=200, so 0.10w = 20px (Chakra 'xl')
611
- const nameFontSize = getClampedFontSize(w * 0.10, minName, MAX_FONT_NAME, drawZoom)
914
+ const nameFontSize = h * NAME_FONT_TO_NODE_H
612
915
  const screenFontSize = nameFontSize * drawZoom
613
916
 
614
917
  if (screenFontSize >= 6) {
@@ -637,13 +940,10 @@ function drawNode(
637
940
 
638
941
  // Type badge - using regular element type display
639
942
  if (drawScreenW > BADGE_THRESHOLD) {
640
- const minBadge = Math.min(MIN_FONT_BADGE, screenW * 0.20)
641
- // 0.05w = 10px (Chakra '2xs')
642
- const badgeFontSize = getClampedFontSize(w * 0.05, minBadge, MAX_FONT_BADGE, drawZoom)
943
+ const badgeFontSize = h * TYPE_FONT_TO_NODE_H
643
944
  if (badgeFontSize * drawZoom >= 5) {
644
945
  ctx.font = `${badgeFontSize}px Inter, system-ui, sans-serif`
645
- const badgeColor = TYPE_COLOR_400[node.type]
646
- ctx.fillStyle = typeof badgeColor === 'string' ? badgeColor : '#a0aec0'
946
+ ctx.fillStyle = '#a0aec0'
647
947
  const displayType = typeof node.type === 'string' ? node.type.toUpperCase() : 'UNKNOWN'
648
948
  ctx.fillText(displayType, x + w / 2, y + h * (0.62 + baseOffset))
649
949
  }
@@ -663,13 +963,13 @@ function drawNode(
663
963
 
664
964
  if (t > 0.8) {
665
965
  // Sticky hint Y: stick to viewport bottom
666
- const viewportBottomWorld = (canvasH - screenFontSize - view.y) / view.zoom
966
+ const viewportBottomWorld = screenToWorldY(canvasH - screenFontSize, view)
667
967
  hintY = Math.min(hintY, viewportBottomWorld)
668
968
  hintY = Math.max(hintY, y + h / 2) // avoid overlapping center
669
969
 
670
970
  // Sticky hint X: stick to viewport sides
671
- const vwL = -view.x / view.zoom
672
- const vwR = (canvasW - view.x) / view.zoom
971
+ const vwL = screenToWorldX(0, view)
972
+ const vwR = screenToWorldX(canvasW, view)
673
973
 
674
974
  ctx.save()
675
975
  ctx.font = `${hintFontSize}px Inter, system-ui, sans-serif`
@@ -713,7 +1013,7 @@ function drawNode(
713
1013
 
714
1014
  // Recursive children's edges DRAWN FIRST (below nodes)
715
1015
  if (childAlpha > 0.2) {
716
- drawEdges(ctx, node.children, childAlpha * 0.5, edgeZoom, thresholds, accent, labelBg, occupiedLabelRects)
1016
+ drawEdges(ctx, node.children, childAlpha * 0.8, edgeZoom, thresholds, accent, labelBg, occupiedLabelRects)
717
1017
  }
718
1018
 
719
1019
  const nextAbsScale = absScale * node.childScale
@@ -725,7 +1025,7 @@ function drawNode(
725
1025
  if (!isVisible(childAbsX, childAbsY, childAbsW, childAbsH, view, canvasW, canvasH)) continue
726
1026
 
727
1027
  const childScreenW = child.worldW * childZoom
728
- drawNode(ctx, child, childScreenW, thresholds, childAlpha, childZoom, nodeBg, canvasBg, view, canvasW, canvasH, accent, labelBg, childAbsX, childAbsY, nextAbsScale, occupiedLabelRects)
1028
+ drawNode(ctx, child, childScreenW, thresholds, childAlpha, childZoom, nodeBg, canvasBg, view, canvasW, canvasH, accent, labelBg, childAbsX, childAbsY, nextAbsScale, occupiedLabelRects, transitionRebase)
729
1029
  }
730
1030
 
731
1031
  ctx.restore()
@@ -742,11 +1042,8 @@ function drawNode(
742
1042
  ctx.strokeStyle = accent
743
1043
  if (node.isCircular) {
744
1044
  drawCycleIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 3.5, accent)
745
- } else if (node.isPortal) {
746
- // Portal: use arrow icon instead of magnifying glass
747
- drawPortalIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 3.5, accent)
748
1045
  } else {
749
- drawZoomInIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 3.5)
1046
+ drawZoomInIcon(ctx, x + w - iconSize - padding, y + padding, iconSize, 2.5)
750
1047
  }
751
1048
  ctx.restore()
752
1049
  }
@@ -777,6 +1074,58 @@ function drawNode(
777
1074
  }
778
1075
  }
779
1076
 
1077
+ if ((currentVersionElementChanges.size > 0 || currentVersionConnectorChanges.size > 0) && parentAlpha > 0.05) {
1078
+ const change = currentVersionElementChanges.get(node.elementId)
1079
+ if (!change) {
1080
+ ctx.save()
1081
+ ctx.globalAlpha = parentAlpha * 0.9
1082
+ ctx.fillStyle = canvasBg
1083
+ traceShape()
1084
+ ctx.fill()
1085
+ ctx.restore()
1086
+ } else {
1087
+ const color = change === 'added' ? '#68d391' : change === 'deleted' ? '#fc8181' : '#f6e05e'
1088
+ ctx.save()
1089
+ ctx.globalAlpha = parentAlpha
1090
+ ctx.shadowColor = color
1091
+ ctx.shadowBlur = 8 / drawZoom
1092
+ ctx.strokeStyle = color
1093
+ ctx.lineWidth = 2.5 / drawZoom
1094
+ traceShape()
1095
+ ctx.stroke()
1096
+ ctx.restore()
1097
+
1098
+ }
1099
+ }
1100
+
1101
+ const delta = currentVersionElementLineDeltas.get(node.elementId)
1102
+ if (delta && (delta.added > 0 || delta.removed > 0) && drawScreenW > 52 && parentAlpha > 0.05) {
1103
+ const addText = delta.added > 0 ? `+${delta.added}` : ''
1104
+ const removeText = delta.removed > 0 ? `-${delta.removed}` : ''
1105
+ const badgeText = [addText, removeText].filter(Boolean).join(' ')
1106
+ const fontSize = getClampedFontSize(12, 8, 13, drawZoom)
1107
+ ctx.save()
1108
+ ctx.globalAlpha = parentAlpha
1109
+ ctx.font = `800 ${fontSize}px Inter, system-ui, sans-serif`
1110
+ const textWidth = ctx.measureText(badgeText).width
1111
+ const badgeW = textWidth + 12 / drawZoom
1112
+ const badgeH = 20 / drawZoom
1113
+ const badgeX = x + w - badgeW - 6 / drawZoom
1114
+ const badgeY = y + h - badgeH - 6 / drawZoom
1115
+ ctx.fillStyle = 'rgba(17, 24, 39, 0.9)'
1116
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)'
1117
+ ctx.lineWidth = 1 / drawZoom
1118
+ ctx.beginPath()
1119
+ ctx.roundRect(badgeX, badgeY, badgeW, badgeH, 5 / drawZoom)
1120
+ ctx.fill()
1121
+ ctx.stroke()
1122
+ ctx.textAlign = 'center'
1123
+ ctx.textBaseline = 'middle'
1124
+ ctx.fillStyle = delta.added > 0 && delta.removed === 0 ? '#68d391' : delta.removed > 0 && delta.added === 0 ? '#fc8181' : '#e2e8f0'
1125
+ ctx.fillText(badgeText, badgeX + badgeW / 2, badgeY + badgeH / 2)
1126
+ ctx.restore()
1127
+ }
1128
+
780
1129
  if (!hasChildren && screenW > thresholds.end) {
781
1130
  ctx.restore()
782
1131
  }
@@ -882,7 +1231,7 @@ function drawEdges(
882
1231
  }
883
1232
 
884
1233
  const dir = edge.direction ?? 'forward'
885
- const type = edge.type || 'bezier'
1234
+ const type = normalizeEdgeRouteType(edge.type)
886
1235
 
887
1236
  // ── Effective visual dimensions (handles capping) ─────────────
888
1237
  const hasSourceChildren = node.children && node.children.length > 0
@@ -922,6 +1271,7 @@ function drawEdges(
922
1271
  effHSource,
923
1272
  getVisualHandleIdForGroup(sourceSide, sourceGroupIndex, Math.max(srcGroup.length, 1)),
924
1273
  true,
1274
+ sSource,
925
1275
  )
926
1276
  const tH = getHandlePos(
927
1277
  effXTarget,
@@ -930,12 +1280,15 @@ function drawEdges(
930
1280
  effHTarget,
931
1281
  getVisualHandleIdForGroup(targetSide, targetGroupIndex, Math.max(tgtGroup.length, 1)),
932
1282
  false,
1283
+ sTarget,
933
1284
  )
934
1285
 
935
1286
  ctx.save()
936
- ctx.globalAlpha = alpha * 0.8
1287
+ const edgeChange = currentVersionConnectorChanges.get(edge.id)
1288
+ const versionPreviewActive = currentVersionElementChanges.size > 0 || currentVersionConnectorChanges.size > 0
1289
+ ctx.globalAlpha = versionPreviewActive && !edgeChange ? Math.max(alpha * 0.18, 0.08) : connectorAlpha(alpha)
937
1290
  ctx.strokeStyle = accent
938
- ctx.lineWidth = 2 / zoom
1291
+ ctx.lineWidth = CONNECTOR_LINE_PX / zoom
939
1292
 
940
1293
  let midX = (sH.x + tH.x) / 2
941
1294
  let midY = (sH.y + tH.y) / 2
@@ -1148,7 +1501,7 @@ function drawGroupLabel(
1148
1501
  const labelY = group.worldY + group.diagramY - 22 / view.zoom
1149
1502
 
1150
1503
  // Ensure label is within viewport
1151
- const screenY = labelY * view.zoom + view.y
1504
+ const screenY = worldToScreenY(labelY, view)
1152
1505
  if (screenY < -20 || screenY > canvasH + 20) return
1153
1506
 
1154
1507
  ctx.save()
@@ -1183,6 +1536,40 @@ function drawGroupLabel(
1183
1536
  }
1184
1537
 
1185
1538
 
1539
+ /** Draw a dot grid matching React Flow default style. */
1540
+ function drawGrid(ctx: CanvasRenderingContext2D, view: ZUIViewState, canvasW: number, canvasH: number): void {
1541
+ const gridSize = 20
1542
+ const dotSize = 1.0
1543
+ const color = 'rgba(255, 255, 255, 0.1)' // subtle white dots on dark background
1544
+ const rebase = getCameraRebase(view, canvasW, canvasH)
1545
+
1546
+ const left = screenToWorldX(0, view)
1547
+ const top = screenToWorldY(0, view)
1548
+ const right = screenToWorldX(canvasW, view)
1549
+ const bottom = screenToWorldY(canvasH, view)
1550
+
1551
+ const startX = Math.floor(left / gridSize) * gridSize
1552
+ const startY = Math.floor(top / gridSize) * gridSize
1553
+
1554
+ ctx.save()
1555
+ ctx.fillStyle = color
1556
+
1557
+ // Dot grid rendering: only show if zoom is not too small
1558
+ if (view.zoom > 0.2) {
1559
+ for (let wx = startX; wx < right; wx += gridSize) {
1560
+ for (let wy = startY; wy < bottom; wy += gridSize) {
1561
+ const sx = (wx - rebase.originX) * rebase.view.zoom + rebase.view.x
1562
+ const sy = (wy - rebase.originY) * rebase.view.zoom + rebase.view.y
1563
+
1564
+ ctx.beginPath()
1565
+ ctx.arc(sx, sy, dotSize, 0, Math.PI * 2)
1566
+ ctx.fill()
1567
+ }
1568
+ }
1569
+ }
1570
+ ctx.restore()
1571
+ }
1572
+
1186
1573
  // ── Public: render one frame ───────────────────────────────────────
1187
1574
 
1188
1575
  /**
@@ -1206,81 +1593,70 @@ export function renderFrame(
1206
1593
  ctx.fillStyle = canvasBg
1207
1594
  ctx.fillRect(0, 0, canvasW, canvasH)
1208
1595
 
1596
+ drawGrid(ctx, view, canvasW, canvasH)
1597
+
1598
+ const rebase = getCameraRebase(view, canvasW, canvasH)
1599
+ const renderView = rebase.view
1600
+ const renderGroups = groups.map((group) => rebaseGroupForRender(group, rebase))
1209
1601
 
1210
1602
  // Apply world transform
1211
1603
  ctx.save()
1212
- ctx.translate(view.x, view.y)
1213
- ctx.scale(view.zoom, view.zoom)
1604
+ ctx.translate(renderView.x, renderView.y)
1605
+ ctx.scale(renderView.zoom, renderView.zoom)
1214
1606
 
1215
1607
  const thresholds = getExpandThresholds(canvasW)
1608
+ const transitionRebase = buildCameraTransitionRebase(renderGroups, renderView, canvasW, canvasH, thresholds)
1216
1609
  const occupiedLabelRects = frameLabelRects
1217
1610
  occupiedLabelRects.length = 0
1611
+ const focusedLayer = findFocusedFlattenedLayerForTest(groups, view, canvasW, canvasH, thresholds, rebase)
1218
1612
 
1219
- for (const group of groups) {
1220
- if (!isVisible(group.worldX, group.worldY, group.worldW, group.worldH, view, canvasW, canvasH)) {
1613
+ if (focusedLayer) {
1614
+ ctx.restore()
1615
+ ctx.save()
1616
+ ctx.translate(focusedLayer.view.x, focusedLayer.view.y)
1617
+ ctx.scale(focusedLayer.view.zoom, focusedLayer.view.zoom)
1618
+ drawEdges(ctx, focusedLayer.nodes, 0.7, focusedLayer.view.zoom, thresholds, accent, labelBg, occupiedLabelRects)
1619
+ for (const node of focusedLayer.nodes) {
1620
+ if (!isVisible(node.worldX, node.worldY, node.worldW, node.worldH, focusedLayer.view, canvasW, canvasH)) {
1621
+ continue
1622
+ }
1623
+ const screenW = node.worldW * focusedLayer.view.zoom
1624
+ drawNode(ctx, node, screenW, thresholds, 1, focusedLayer.view.zoom, nodeBg, canvasBg, focusedLayer.view, canvasW, canvasH, accent, labelBg, node.worldX, node.worldY, 1, occupiedLabelRects, transitionRebase)
1625
+ }
1626
+ ctx.restore()
1627
+ return occupiedLabelRects
1628
+ }
1629
+
1630
+ for (const group of renderGroups) {
1631
+ if (!isVisible(group.worldX, group.worldY, group.worldW, group.worldH, renderView, canvasW, canvasH)) {
1221
1632
  continue
1222
1633
  }
1223
1634
 
1224
- drawGroupLabel(ctx, group, view, canvasW, canvasH, accent)
1635
+ drawGroupLabel(ctx, group, renderView, canvasW, canvasH, accent)
1225
1636
 
1226
1637
  // ── Group box (diagram elements container) ──────────────────────────
1227
- const borderAlpha = clamp(0.5 - view.zoom * 0.05, 0.15, 0.5)
1638
+ const borderAlpha = clamp(0.5 - renderView.zoom * 0.05, 0.15, 0.5)
1228
1639
 
1229
1640
  ctx.save()
1230
1641
  ctx.globalAlpha = borderAlpha
1231
1642
  ctx.strokeStyle = accent
1232
- ctx.lineWidth = 2 / view.zoom
1643
+ ctx.lineWidth = 2 / renderView.zoom
1233
1644
  ctx.setLineDash([2, 2])
1234
- // Only draw the border around the diagram part (not portals)
1645
+ // Only draw the border around the diagram part
1235
1646
  ctx.strokeRect(group.worldX + group.diagramX, group.worldY + group.diagramY, group.diagramW, group.diagramH)
1236
1647
  ctx.setLineDash([])
1237
1648
  ctx.restore()
1238
1649
 
1239
- // ── Squiggly edges to portal nodes ────────────────────────────────
1240
- ctx.save()
1241
- ctx.strokeStyle = accent
1242
- ctx.setLineDash([])
1243
- ctx.lineWidth = 2 / view.zoom
1244
- ctx.globalAlpha = 0.6
1245
- for (const node of group.nodes) {
1246
- if (node.isPortal) {
1247
- // Draw squiggle/dash from diagram box boundary to portal box boundary
1248
- const cx = group.worldX + group.diagramX + group.diagramW / 2
1249
- const cy = group.worldY + group.diagramY + group.diagramH / 2
1250
- const px = node.worldX + node.worldW / 2
1251
- const py = node.worldY + node.worldH / 2
1252
-
1253
- const dx = px - cx
1254
- const dy = py - cy
1255
-
1256
- const getBBoxIntersection = (boxW: number, boxH: number, targetDX: number, targetDY: number) => {
1257
- const hw = boxW / 2 + 10 // pad
1258
- const hh = boxH / 2 + 10 // pad
1259
- if (Math.abs(targetDX * hh) > Math.abs(targetDY * hw)) {
1260
- return { x: Math.sign(targetDX) * hw, y: targetDY * (hw / Math.abs(targetDX)) }
1261
- } else {
1262
- return { x: targetDX * (hh / Math.abs(targetDY)), y: Math.sign(targetDY) * hh }
1263
- }
1264
- }
1265
-
1266
- const start = getBBoxIntersection(group.diagramW, group.diagramH, dx, dy)
1267
- const end = getBBoxIntersection(node.worldW, node.worldH, -dx, -dy)
1268
-
1269
- drawSquigglyLine(ctx, cx + start.x, cy + start.y, px + end.x, py + end.y, view.zoom)
1270
- }
1271
- }
1272
- ctx.restore()
1273
-
1274
1650
  // Edges in this group
1275
- drawEdges(ctx, group.nodes, 0.7, view.zoom, thresholds, accent, labelBg, occupiedLabelRects)
1651
+ drawEdges(ctx, group.nodes, 0.7, renderView.zoom, thresholds, accent, labelBg, occupiedLabelRects)
1276
1652
 
1277
1653
  // Nodes in this group
1278
1654
  for (const node of group.nodes) {
1279
- if (!isVisible(node.worldX, node.worldY, node.worldW, node.worldH, view, canvasW, canvasH)) {
1655
+ if (!isVisible(node.worldX, node.worldY, node.worldW, node.worldH, renderView, canvasW, canvasH)) {
1280
1656
  continue
1281
1657
  }
1282
- const screenW = node.worldW * view.zoom
1283
- drawNode(ctx, node, screenW, thresholds, 1, view.zoom, nodeBg, canvasBg, view, canvasW, canvasH, accent, labelBg, node.worldX, node.worldY, 1, occupiedLabelRects)
1658
+ const screenW = node.worldW * renderView.zoom
1659
+ drawNode(ctx, node, screenW, thresholds, 1, renderView.zoom, nodeBg, canvasBg, renderView, canvasW, canvasH, accent, labelBg, node.worldX, node.worldY, 1, occupiedLabelRects, transitionRebase)
1284
1660
  }
1285
1661
  }
1286
1662