@tldiagram/core-ui 2.0.0 → 2.0.2

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.
@@ -68,6 +68,7 @@ export declare function updatePlacedElementPosition(elements: PlacedElement[], e
68
68
  export declare function removePlacedElement(elements: PlacedElement[], elementId: number): PlacedElement[];
69
69
  export declare function placedElementToLibraryElement(element: PlacedElement): LibraryElement;
70
70
  export declare function buildElementLibraryItems(allElements: LibraryElement[], viewElements: PlacedElement[]): LibraryElement[];
71
+ export declare function resolveElementForUpdate(elementId: number, selectedElement: LibraryElement | null, allElements: LibraryElement[], viewElements: PlacedElement[]): LibraryElement | null;
71
72
  export declare function mergeSavedElementIntoPlacements(elements: PlacedElement[], saved: LibraryElement): PlacedElement[];
72
73
  export declare function upsertConnectorInList(connectors: Connector[], connector: Connector): Connector[];
73
74
  export declare function removeConnectorFromList(connectors: Connector[], connectorId: number): Connector[];
@@ -58,3 +58,15 @@ export type WebviewToExtensionMessage = {
58
58
  filePath: string;
59
59
  startLine: number;
60
60
  };
61
+ export interface WatchEventDetail {
62
+ type: string;
63
+ repository_id?: number;
64
+ message?: string;
65
+ at: string;
66
+ data?: any;
67
+ }
68
+ export interface SyncStatusPayload {
69
+ localChanges: number;
70
+ needsPush: boolean;
71
+ needsPull: boolean;
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tldiagram/core-ui",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,7 +36,7 @@
36
36
  "test": "vitest run"
37
37
  },
38
38
  "dependencies": {
39
- "@buf/tldiagramcom_diagram.bufbuild_es": "^2.12.0-20260503002426-45e3166b5ec1.1",
39
+ "@buf/tldiagramcom_diagram.bufbuild_es": "^2.12.0-20260510134954-1dd1981cf3f6.1",
40
40
  "@bufbuild/protobuf": "^2.11.0",
41
41
  "esbuild": "^0.25.12",
42
42
  "zustand": "^5.0.12"
@@ -126,8 +126,8 @@
126
126
  "@connectrpc/connect-web": "^2.1.1",
127
127
  "@emotion/react": "^11.14.0",
128
128
  "@emotion/styled": "^11.14.0",
129
- "@tanstack/react-query": "^5.100.1",
130
129
  "@eslint/js": "^9.0.0",
130
+ "@tanstack/react-query": "^5.100.1",
131
131
  "@types/dagre": "^0.7.54",
132
132
  "@types/react": "^18.3.12",
133
133
  "@types/react-dom": "^18.3.1",
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { normalizeConnectorRouteStyle } from './client'
3
+
4
+ describe('normalizeConnectorRouteStyle', () => {
5
+ it('keeps valid route styles', () => {
6
+ expect(normalizeConnectorRouteStyle('bezier')).toBe('bezier')
7
+ expect(normalizeConnectorRouteStyle('straight')).toBe('straight')
8
+ expect(normalizeConnectorRouteStyle('step')).toBe('step')
9
+ expect(normalizeConnectorRouteStyle('smoothstep')).toBe('smoothstep')
10
+ })
11
+
12
+ it('maps legacy line styles to bezier', () => {
13
+ expect(normalizeConnectorRouteStyle('solid')).toBe('bezier')
14
+ expect(normalizeConnectorRouteStyle('dashed')).toBe('bezier')
15
+ expect(normalizeConnectorRouteStyle(undefined)).toBe('bezier')
16
+ })
17
+ })
package/src/api/client.ts CHANGED
@@ -59,6 +59,12 @@ import {
59
59
  import { transport } from './transport'
60
60
  import { apiUrl, fetchApiAsset } from '../config/runtime'
61
61
 
62
+ const CONNECTOR_ROUTE_STYLES = new Set(['bezier', 'straight', 'step', 'smoothstep'])
63
+
64
+ export function normalizeConnectorRouteStyle(style: unknown): string {
65
+ return typeof style === 'string' && CONNECTOR_ROUTE_STYLES.has(style) ? style : 'bezier'
66
+ }
67
+
62
68
  async function responseError(res: Response, fallback: string): Promise<Error> {
63
69
  const body = await res.json().catch(() => null) as { error?: string } | null
64
70
  return new Error(body?.error || `${fallback}: ${res.statusText}`)
@@ -403,7 +409,7 @@ function protoConnector(e: Record<string, unknown>): Connector {
403
409
  description: (e.description ?? null) as string | null,
404
410
  relationship: (e.relationship ?? null) as string | null,
405
411
  direction: String(e.direction ?? 'forward'),
406
- style: String(e.style ?? 'bezier'),
412
+ style: normalizeConnectorRouteStyle(e.style),
407
413
  url: (e.url ?? null) as string | null,
408
414
  source_handle: (e.source_handle ?? null) as string | null,
409
415
  target_handle: (e.target_handle ?? null) as string | null,
@@ -936,7 +942,7 @@ export const api = {
936
942
  description: data.description ?? undefined,
937
943
  relationship: data.relationship ?? undefined,
938
944
  direction: data.direction ?? undefined,
939
- style: data.style ?? undefined,
945
+ style: normalizeConnectorRouteStyle(data.style),
940
946
  url: data.url ?? undefined,
941
947
  sourceHandle: data.source_handle ?? undefined,
942
948
  targetHandle: data.target_handle ?? undefined,
@@ -970,7 +976,7 @@ export const api = {
970
976
  description: data.description ?? undefined,
971
977
  relationship: data.relationship ?? undefined,
972
978
  direction: data.direction ?? undefined,
973
- style: data.style ?? undefined,
979
+ style: data.style === undefined ? undefined : normalizeConnectorRouteStyle(data.style),
974
980
  url: data.url ?? undefined,
975
981
  sourceHandle: data.source_handle ?? undefined,
976
982
  targetHandle: data.target_handle ?? undefined,
@@ -66,7 +66,7 @@ export default function LayoutSection({ view, canEdit, onUnsupportedMutation }:
66
66
  })
67
67
 
68
68
  const [forceConfig, setForceConfig] = useState<ForceConfig>({
69
- linkDistance: 180,
69
+ linkDistance: 300,
70
70
  chargeStrength: -150,
71
71
  collideRadius: 130,
72
72
  iterations: 300,
@@ -576,17 +576,17 @@ export default function LayoutSection({ view, canEdit, onUnsupportedMutation }:
576
576
  </Button>
577
577
  {/* Apply button */}
578
578
  <VStack spacing={2} w="full">
579
- <Button
580
- size="sm"
581
- w="full"
582
- variant="outline"
583
- colorScheme="blue"
584
- onClick={adjustConnectorsConfirm.onOpen}
585
- isLoading={collisionRunning}
586
- isDisabled={!canEdit || !view}
587
- loadingText="Removing Connector Collisions..."
588
- fontWeight="bold"
589
- fontSize="xs"
579
+ <Button
580
+ size="sm"
581
+ w="full"
582
+ variant="outline"
583
+ colorScheme="blue"
584
+ onClick={adjustConnectorsConfirm.onOpen}
585
+ isLoading={collisionRunning}
586
+ isDisabled={!canEdit || !view}
587
+ loadingText="Removing Connector Collisions..."
588
+ fontWeight="bold"
589
+ fontSize="xs"
590
590
  letterSpacing="0.05em"
591
591
  textTransform="uppercase"
592
592
  h="32px"
@@ -104,6 +104,8 @@ export const TagItem: React.FC<Props> = ({
104
104
  onDragOver={handleDragOver}
105
105
  onDragLeave={handleDragLeave}
106
106
  onDrop={handleDrop}
107
+ draggable
108
+ onDragStart={handleDragStart}
107
109
  cursor="grab"
108
110
  _active={{ cursor: 'grabbing' }}
109
111
  role="group"
@@ -46,6 +46,8 @@ import {
46
46
  type VisibleNodeAnchor,
47
47
  } from './proxy'
48
48
 
49
+ const MAX_PROXY_HOVER_VIEW_LINKS = 5
50
+
49
51
  export interface ZUICanvasHandle {
50
52
  fitView(): void
51
53
  focusDiagram(viewId: number): boolean
@@ -1053,7 +1055,7 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
1053
1055
  </VStack>
1054
1056
  <Divider borderColor="whiteAlpha.200" />
1055
1057
  <VStack align="stretch" spacing={2} width="full">
1056
- {hoveredItem.data.details.ownerViewIds.map((ownerViewId, index) => (
1058
+ {hoveredItem.data.details.ownerViewIds.slice(0, MAX_PROXY_HOVER_VIEW_LINKS).map((ownerViewId, index) => (
1057
1059
  <Button
1058
1060
  key={`${ownerViewId}-${index}`}
1059
1061
  as={RouterLink}
@@ -1069,6 +1071,11 @@ export const ZUICanvas = forwardRef<ZUICanvasHandle, Props>(function ZUICanvas({
1069
1071
  {hoveredItem.data.details!.ownerViewNames[index] ?? `Open View ${ownerViewId}`}
1070
1072
  </Button>
1071
1073
  ))}
1074
+ {hoveredItem.data.details.ownerViewIds.length > MAX_PROXY_HOVER_VIEW_LINKS && (
1075
+ <Text fontSize="xs" color="gray.500" textAlign="center">
1076
+ +{hoveredItem.data.details.ownerViewIds.length - MAX_PROXY_HOVER_VIEW_LINKS} more view{hoveredItem.data.details.ownerViewIds.length - MAX_PROXY_HOVER_VIEW_LINKS === 1 ? '' : 's'}
1077
+ </Text>
1078
+ )}
1072
1079
  </VStack>
1073
1080
  <Divider borderColor="whiteAlpha.200" />
1074
1081
  <HStack width="full" spacing={2}>
@@ -284,6 +284,26 @@ function expectScreenRectVisible(
284
284
  }
285
285
 
286
286
  describe('ZUI focus targets', () => {
287
+ it('derives ZUI node icons from assigned catalog technology connectors', () => {
288
+ const data: ExploreData = {
289
+ tree: [treeNode(1, 'Root', null, null)],
290
+ navigations: [],
291
+ views: {
292
+ 1: {
293
+ placements: [{
294
+ ...placed(1, 1, 0, 0),
295
+ technology_connectors: [{ type: 'catalog', slug: 'golang', label: 'Go', is_primary_icon: true }],
296
+ }],
297
+ connectors: [],
298
+ },
299
+ },
300
+ }
301
+
302
+ const layout = computeLayout(data)
303
+
304
+ expect(layout.groups[0]?.nodes[0]?.logoUrl).toBe('/icons/golang.png')
305
+ })
306
+
287
307
  it('rebases a high-zoom camera to a small centered render transform', () => {
288
308
  const rebase = getCameraRebase(
289
309
  { x: -147_317_059.10654327, y: -184_315_493.52577353, zoom: 906_732.1382976775 },
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { collectVisibleNodeAnchors, getProxyBezierBadgeGeometry, type VisibleNodeAnchor } from './proxy'
3
+ import { DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA } from '../../crossBranch/settings'
4
+ import type { LayoutNode } from './types'
5
+
6
+ function node(id: string, elementId: number, children: LayoutNode[] = []): LayoutNode {
7
+ return {
8
+ id,
9
+ elementId,
10
+ diagramId: elementId,
11
+ worldX: 0,
12
+ worldY: 0,
13
+ worldW: 100,
14
+ worldH: 100,
15
+ label: id,
16
+ type: 'service',
17
+ logoUrl: null,
18
+ description: null,
19
+ technology: null,
20
+ tags: [],
21
+ ancestorElementIds: [],
22
+ pathElementIds: [elementId],
23
+ children,
24
+ childScale: 1,
25
+ childOffsetX: 0,
26
+ childOffsetY: 0,
27
+ edgesOut: [],
28
+ }
29
+ }
30
+
31
+ describe('collectVisibleNodeAnchors', () => {
32
+ it('keeps fallback anchors for ancestors when only deeper descendants are self drawn', () => {
33
+ const grandchild = node('grandchild', 3)
34
+ const child = node('child', 2, [grandchild])
35
+ const parent = node('parent', 1, [child])
36
+
37
+ const anchors = collectVisibleNodeAnchors(
38
+ [{ nodes: [parent] }],
39
+ { x: 0, y: 0, zoom: 5 },
40
+ 1000,
41
+ )
42
+
43
+ expect(anchors.visibleAnchors.get(3)?.renderAlpha).toBe(1)
44
+ expect(anchors.visibleAnchors.get(2)?.renderAlpha).toBeGreaterThan(0)
45
+ expect(anchors.visibleAnchors.get(1)?.renderAlpha).toBeGreaterThan(0)
46
+ })
47
+
48
+ it('keeps fully expanded ancestor fallback anchors eligible for connectors', () => {
49
+ const grandchild = node('grandchild', 3)
50
+ const child = node('child', 2, [grandchild])
51
+ const parent = node('parent', 1, [child])
52
+
53
+ const anchors = collectVisibleNodeAnchors(
54
+ [{ nodes: [parent] }],
55
+ { x: 0, y: 0, zoom: 5 },
56
+ 1000,
57
+ )
58
+
59
+ expect(anchors.visibleAnchors.get(1)?.renderAlpha).toBeGreaterThanOrEqual(DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA)
60
+ expect(anchors.visibleAnchors.get(2)?.renderAlpha).toBeGreaterThanOrEqual(DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA)
61
+ })
62
+
63
+ it('keeps ancestor anchors eligible while the body fades into the dashed border', () => {
64
+ const child = node('child', 2)
65
+ const parent = node('parent', 1, [child])
66
+
67
+ const anchors = collectVisibleNodeAnchors(
68
+ [{ nodes: [parent] }],
69
+ { x: 0, y: 0, zoom: 3.7 },
70
+ 1000,
71
+ )
72
+
73
+ expect(anchors.visibleAnchors.get(1)?.renderAlpha).toBeGreaterThanOrEqual(DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA)
74
+ })
75
+ })
76
+
77
+ function anchor(partial: Partial<VisibleNodeAnchor>): VisibleNodeAnchor {
78
+ return {
79
+ nodeId: partial.nodeId ?? 'node',
80
+ elementId: partial.elementId ?? 1,
81
+ label: partial.label ?? 'node',
82
+ worldX: partial.worldX ?? 0,
83
+ worldY: partial.worldY ?? 0,
84
+ worldW: partial.worldW ?? 100,
85
+ worldH: partial.worldH ?? 100,
86
+ pathDepth: partial.pathDepth ?? 1,
87
+ renderAlpha: partial.renderAlpha ?? 1,
88
+ }
89
+ }
90
+
91
+ describe('getProxyBezierBadgeGeometry', () => {
92
+ it('positions badges on the bezier curve instead of the straight connector chord', () => {
93
+ const source = anchor({ worldX: 0, worldY: 0, worldW: 200, worldH: 100 })
94
+ const target = anchor({ elementId: 2, worldX: 300, worldY: 180, worldW: 80, worldH: 100 })
95
+
96
+ const geometry = getProxyBezierBadgeGeometry(source, target)
97
+
98
+ expect(geometry.midX).not.toBe((200 + 300) / 2)
99
+ expect(Math.hypot(geometry.tangentX, geometry.tangentY)).toBeGreaterThan(0)
100
+ })
101
+ })
@@ -10,6 +10,12 @@ import type { LayoutNode, ZUIViewState, HoveredItem } from './types'
10
10
  import { getExpandThresholds, pickEdgeLabelPosition, type ScreenRect } from './renderer'
11
11
  import type { CrossBranchContextSettings } from '../../crossBranch/types'
12
12
  import { DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA } from '../../crossBranch/settings'
13
+ import {
14
+ DEFAULT_SOURCE_HANDLE_SIDE,
15
+ DEFAULT_TARGET_HANDLE_SIDE,
16
+ getHandleFlowPosition,
17
+ type LogicalHandleSide,
18
+ } from '../../utils/edgeDistribution'
13
19
 
14
20
  export interface VisibleNodeAnchor {
15
21
  nodeId: string
@@ -35,6 +41,10 @@ function transitionT(screenW: number, start: number, end: number): number {
35
41
  return clamp((screenW - start) / (end - start), 0, 1)
36
42
  }
37
43
 
44
+ function zoomableNodeConnectorAlpha(inheritedAlpha: number, bodyAlpha: number): number {
45
+ return Math.max(bodyAlpha, Math.min(inheritedAlpha, DEFAULT_MIN_CONNECTOR_ANCHOR_ALPHA))
46
+ }
47
+
38
48
  function visualRectForNode(
39
49
  absX: number,
40
50
  absY: number,
@@ -90,8 +100,10 @@ function collectVisibleAnchorForNode(
90
100
  parentAbsScale: number,
91
101
  parentChildOffsetX: number,
92
102
  parentChildOffsetY: number,
93
- ) {
94
- if (hiddenTags.size > 0 && node.tags.some((tag) => hiddenTags.has(tag))) return { selfDrawn: false }
103
+ ): { selfDrawn: boolean; descendantDrawn: boolean } {
104
+ if (hiddenTags.size > 0 && node.tags.some((tag) => hiddenTags.has(tag))) {
105
+ return { selfDrawn: false, descendantDrawn: false }
106
+ }
95
107
 
96
108
  const absX = parentAbsX + (node.worldX - parentChildOffsetX) * parentAbsScale
97
109
  const absY = parentAbsY + (node.worldY - parentChildOffsetY) * parentAbsScale
@@ -99,7 +111,7 @@ function collectVisibleAnchorForNode(
99
111
  const absW = node.worldW * absScale
100
112
  const absH = node.worldH * absScale
101
113
  const screenW = absW * view.zoom
102
- if (screenW < 2) return { selfDrawn: false }
114
+ if (screenW < 2) return { selfDrawn: false, descendantDrawn: false }
103
115
 
104
116
  const hasChildren = node.children && node.children.length > 0
105
117
  const t = hasChildren ? transitionT(screenW, thresholds.start, thresholds.end) : 0
@@ -118,11 +130,11 @@ function collectVisibleAnchorForNode(
118
130
  worldW: visualRect.worldW,
119
131
  worldH: visualRect.worldH,
120
132
  pathDepth: node.pathElementIds.length,
121
- renderAlpha: hasChildren ? parentAlpha : inheritedAlpha,
133
+ renderAlpha: hasChildren ? zoomableNodeConnectorAlpha(inheritedAlpha, parentAlpha) : inheritedAlpha,
122
134
  })
123
135
  }
124
136
 
125
- let hasDirectChildDrawn = false
137
+ let hasVisibleDescendant = false
126
138
  if (hasChildren && t > 0.05) {
127
139
  for (const child of node.children) {
128
140
  const childResult = collectVisibleAnchorForNode(
@@ -139,11 +151,11 @@ function collectVisibleAnchorForNode(
139
151
  node.childOffsetX,
140
152
  node.childOffsetY,
141
153
  )
142
- hasDirectChildDrawn = hasDirectChildDrawn || childResult.selfDrawn
154
+ hasVisibleDescendant = hasVisibleDescendant || childResult.selfDrawn || childResult.descendantDrawn
143
155
  }
144
156
  }
145
157
 
146
- if (!selfDrawn && hasDirectChildDrawn) {
158
+ if (!selfDrawn && hasVisibleDescendant) {
147
159
  registerVisibleAnchor(node, visibleAnchors, byNodeId, {
148
160
  nodeId: node.id,
149
161
  elementId: node.elementId,
@@ -153,11 +165,11 @@ function collectVisibleAnchorForNode(
153
165
  worldW: visualRect.worldW,
154
166
  worldH: visualRect.worldH,
155
167
  pathDepth: node.pathElementIds.length,
156
- renderAlpha: Math.max(0.12, inheritedAlpha * 0.28),
168
+ renderAlpha: zoomableNodeConnectorAlpha(inheritedAlpha, inheritedAlpha * 0.28),
157
169
  })
158
170
  }
159
171
 
160
- return { selfDrawn }
172
+ return { selfDrawn, descendantDrawn: selfDrawn || hasVisibleDescendant }
161
173
  }
162
174
 
163
175
  function collectVisibleAnchorsInNodes(
@@ -287,6 +299,84 @@ function getDirectAnchorPoints(source: VisibleNodeAnchor, target: VisibleNodeAnc
287
299
  return { sourcePoint, targetPoint }
288
300
  }
289
301
 
302
+ type AnchorSide = 'top' | 'bottom' | 'left' | 'right'
303
+
304
+ interface AnchorPoint {
305
+ x: number
306
+ y: number
307
+ side: AnchorSide
308
+ }
309
+
310
+ interface ProxyBezierBadgeGeometry {
311
+ midX: number
312
+ midY: number
313
+ tangentX: number
314
+ tangentY: number
315
+ }
316
+
317
+ function bezierControlPoint(
318
+ point: AnchorPoint,
319
+ stem: number,
320
+ ): AnchorPoint {
321
+ switch (point.side) {
322
+ case 'left':
323
+ return { x: point.x - stem, y: point.y, side: point.side }
324
+ case 'right':
325
+ return { x: point.x + stem, y: point.y, side: point.side }
326
+ case 'top':
327
+ return { x: point.x, y: point.y - stem, side: point.side }
328
+ case 'bottom':
329
+ return { x: point.x, y: point.y + stem, side: point.side }
330
+ }
331
+ }
332
+
333
+ export function getProxyBezierBadgeGeometry(
334
+ source: VisibleNodeAnchor,
335
+ target: VisibleNodeAnchor,
336
+ options: {
337
+ sourceHandle?: string | null
338
+ targetHandle?: string | null
339
+ sourceFallback?: LogicalHandleSide
340
+ targetFallback?: LogicalHandleSide
341
+ } = {},
342
+ ): ProxyBezierBadgeGeometry {
343
+ const sourcePoint = getHandleFlowPosition(
344
+ source.worldX,
345
+ source.worldY,
346
+ source.worldW,
347
+ source.worldH,
348
+ options.sourceHandle,
349
+ options.sourceFallback ?? DEFAULT_SOURCE_HANDLE_SIDE,
350
+ )
351
+ const targetPoint = getHandleFlowPosition(
352
+ target.worldX,
353
+ target.worldY,
354
+ target.worldW,
355
+ target.worldH,
356
+ options.targetHandle,
357
+ options.targetFallback ?? DEFAULT_TARGET_HANDLE_SIDE,
358
+ )
359
+ const dx = Math.abs(targetPoint.x - sourcePoint.x)
360
+ const dy = Math.abs(targetPoint.y - sourcePoint.y)
361
+ const sourceStem = Math.max(
362
+ (sourcePoint.side === 'left' || sourcePoint.side === 'right' ? dx : dy) * 0.5,
363
+ (sourcePoint.side === 'left' || sourcePoint.side === 'right' ? source.worldW : source.worldH) * 0.5,
364
+ )
365
+ const targetStem = Math.max(
366
+ (targetPoint.side === 'left' || targetPoint.side === 'right' ? dx : dy) * 0.5,
367
+ (targetPoint.side === 'left' || targetPoint.side === 'right' ? target.worldW : target.worldH) * 0.5,
368
+ )
369
+ const cp1 = bezierControlPoint(sourcePoint, sourceStem)
370
+ const cp2 = bezierControlPoint(targetPoint, targetStem)
371
+
372
+ return {
373
+ midX: 0.125 * sourcePoint.x + 0.375 * cp1.x + 0.375 * cp2.x + 0.125 * targetPoint.x,
374
+ midY: 0.125 * sourcePoint.y + 0.375 * cp1.y + 0.375 * cp2.y + 0.125 * targetPoint.y,
375
+ tangentX: -0.75 * sourcePoint.x - 0.75 * cp1.x + 0.75 * cp2.x + 0.75 * targetPoint.x,
376
+ tangentY: -0.75 * sourcePoint.y - 0.75 * cp1.y + 0.75 * cp2.y + 0.75 * targetPoint.y,
377
+ }
378
+ }
379
+
290
380
  function getDevicePixelRatio(): number {
291
381
  return typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1
292
382
  }
@@ -536,9 +626,18 @@ export function drawVisibleDirectProxyBadges(
536
626
  const alpha = Math.min(source.renderAlpha, target.renderAlpha)
537
627
  if (alpha < 0.01) continue
538
628
 
539
- const { sourcePoint, targetPoint } = getDirectAnchorPoints(source, target)
540
- const midX = (sourcePoint.x + targetPoint.x) / 2
541
- const midY = (sourcePoint.y + targetPoint.y) / 2
629
+ const sourceFallback = badge.details.connectors[0]?.connector.source_element_id === badge.sourceAnchorElementId
630
+ ? DEFAULT_SOURCE_HANDLE_SIDE
631
+ : DEFAULT_TARGET_HANDLE_SIDE
632
+ const targetFallback = badge.details.connectors[0]?.connector.target_element_id === badge.targetAnchorElementId
633
+ ? DEFAULT_TARGET_HANDLE_SIDE
634
+ : DEFAULT_SOURCE_HANDLE_SIDE
635
+ const { midX, midY, tangentX, tangentY } = getProxyBezierBadgeGeometry(source, target, {
636
+ sourceHandle: badge.sourceHandle,
637
+ targetHandle: badge.targetHandle,
638
+ sourceFallback,
639
+ targetFallback,
640
+ })
542
641
  const label = `+${badge.count}`
543
642
 
544
643
  ctx.save()
@@ -550,8 +649,8 @@ export function drawVisibleDirectProxyBadges(
550
649
  midY,
551
650
  badgeMetrics.worldW,
552
651
  badgeMetrics.worldH,
553
- targetPoint.x - sourcePoint.x,
554
- targetPoint.y - sourcePoint.y,
652
+ tangentX,
653
+ tangentY,
555
654
  occupiedLabelRects,
556
655
  )
557
656
  drawFixedScreenProxyBadge(