@tldiagram/core-ui 1.91.0 → 1.93.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 (37) hide show
  1. package/dist/api/client.d.ts +13 -1
  2. package/dist/components/ElementNode.d.ts +9 -0
  3. package/dist/config/runtime-vscode.d.ts +1 -0
  4. package/dist/config/runtime.d.ts +1 -0
  5. package/dist/index.d.ts +0 -1
  6. package/dist/index.js +10063 -9512
  7. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +2 -1
  8. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +20 -21
  9. package/dist/shims/empty-node-module.d.ts +2 -0
  10. package/dist/store/useStore.d.ts +78 -0
  11. package/dist/store/useStore.test.d.ts +1 -0
  12. package/package.json +7 -4
  13. package/src/App.tsx +0 -4
  14. package/src/api/client.ts +39 -1
  15. package/src/components/ElementNode.tsx +11 -58
  16. package/src/components/ElementPanel.tsx +2 -2
  17. package/src/components/LayoutSection.tsx +68 -93
  18. package/src/components/ViewGridNode.tsx +1 -4
  19. package/src/components/ZUI/renderer.ts +166 -66
  20. package/src/components/ZUI/useZUIInteraction.ts +235 -81
  21. package/src/config/runtime-vscode.ts +6 -0
  22. package/src/config/runtime.ts +4 -0
  23. package/src/index.ts +0 -1
  24. package/src/main.tsx +26 -14
  25. package/src/pages/ViewEditor/context.tsx +12 -4
  26. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +172 -121
  27. package/src/pages/ViewEditor/hooks/useViewData.ts +455 -253
  28. package/src/pages/ViewEditor/index.tsx +45 -32
  29. package/src/shims/empty-node-module.ts +1 -0
  30. package/src/store/useStore.test.ts +272 -0
  31. package/src/store/useStore.ts +285 -0
  32. package/dist/demo/DemoPage.d.ts +0 -9
  33. package/dist/demo/seed.d.ts +0 -9
  34. package/dist/demo/store.d.ts +0 -137
  35. package/src/demo/DemoPage.tsx +0 -184
  36. package/src/demo/seed.ts +0 -67
  37. package/src/demo/store.ts +0 -536
@@ -21,11 +21,10 @@ import { ChevronDownIcon, ChevronRightIcon } from './Icons'
21
21
  import { api } from '../api/client'
22
22
  import type { ViewTreeNode } from '../types'
23
23
 
24
- type Algorithm = 'elk' | 'force'
24
+ type Algorithm = 'dagre' | 'force'
25
25
 
26
- interface ElkConfig {
27
- algorithm: 'layered' | 'force' | 'mrtree' | 'box'
28
- direction: 'RIGHT' | 'LEFT' | 'DOWN' | 'UP'
26
+ interface DagreConfig {
27
+ direction: 'TB' | 'BT' | 'LR' | 'RL'
29
28
  nodeSpacing: number
30
29
  layerSpacing: number
31
30
  }
@@ -41,7 +40,7 @@ const NODE_W = 200
41
40
  const NODE_H = 120
42
41
 
43
42
  const ALGO_META: Record<Algorithm, { label: string }> = {
44
- elk: { label: 'Layered' },
43
+ dagre: { label: 'Layered' },
45
44
  force: { label: 'Organic' },
46
45
  }
47
46
 
@@ -52,13 +51,12 @@ interface Props {
52
51
 
53
52
  export default function LayoutSection({ view, canEdit }: Props) {
54
53
  const [open, setOpen] = useState(false)
55
- const [algo, setAlgo] = useState<Algorithm>('elk')
54
+ const [algo, setAlgo] = useState<Algorithm>('dagre')
56
55
  const [running, setRunning] = useState(false)
57
56
  const [collisionRunning, setCollisionRunning] = useState(false)
58
57
 
59
- const [elkConfig, setElkConfig] = useState<ElkConfig>({
60
- algorithm: 'layered',
61
- direction: 'DOWN',
58
+ const [dagreConfig, setDagreConfig] = useState<DagreConfig>({
59
+ direction: 'TB',
62
60
  nodeSpacing: 75,
63
61
  layerSpacing: 75,
64
62
  })
@@ -199,8 +197,8 @@ export default function LayoutSection({ view, canEdit }: Props) {
199
197
  ])
200
198
 
201
199
  let positions: Map<number, { x: number; y: number }>
202
- if (algo === 'elk') {
203
- positions = await runElk(objs, edgeList)
200
+ if (algo === 'dagre') {
201
+ positions = await runDagre(objs, edgeList)
204
202
  } else {
205
203
  positions = await runForce(objs, edgeList)
206
204
  }
@@ -271,46 +269,45 @@ export default function LayoutSection({ view, canEdit }: Props) {
271
269
  }
272
270
 
273
271
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
274
- const runElk = async (objs: any[], edgeList: any[]) => {
272
+ const runDagre = async (objs: any[], edgeList: any[]) => {
275
273
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
276
- const ELKModule = await import('elkjs/lib/elk.bundled.js') as any
277
- const elk = new ELKModule.default()
278
-
279
- const layoutOptions: Record<string, string> = {
280
- 'elk.algorithm': elkConfig.algorithm,
281
- 'elk.spacing.nodeNode': String(elkConfig.nodeSpacing),
282
- }
283
- if (elkConfig.algorithm === 'layered') {
284
- layoutOptions['elk.direction'] = elkConfig.direction
285
- layoutOptions['elk.layered.spacing.nodeNodeBetweenLayers'] = String(elkConfig.layerSpacing)
286
- }
274
+ const dagreModule = await import('dagre') as any
275
+ const dagre = dagreModule.default ?? dagreModule
287
276
 
288
277
  const objSet = new Set<number>(objs.map((o: { element_id?: number }) => Number(o.element_id)))
289
- const graph = {
290
- id: 'root',
291
- layoutOptions,
292
- children: objs.map((obj: { element_id: number }) => ({
293
- id: String(obj.element_id),
294
- width: NODE_W,
295
- height: NODE_H,
296
- })),
297
- edges: edgeList
298
- .filter((e: { source_element_id: number; target_element_id: number }) =>
299
- objSet.has(e.source_element_id) && objSet.has(e.target_element_id)
300
- )
301
- .map((e: { id: number; source_element_id: number; target_element_id: number }) => ({
302
- id: String(e.id),
303
- sources: [String(e.source_element_id)],
304
- targets: [String(e.target_element_id)],
305
- })),
306
- }
278
+ const graph = new dagre.graphlib.Graph({ multigraph: true })
279
+ graph.setGraph({
280
+ rankdir: dagreConfig.direction,
281
+ nodesep: dagreConfig.nodeSpacing,
282
+ ranksep: dagreConfig.layerSpacing,
283
+ marginx: 0,
284
+ marginy: 0,
285
+ })
286
+ graph.setDefaultEdgeLabel(() => ({}))
287
+
288
+ objs.forEach((obj: { element_id: number }) => {
289
+ graph.setNode(String(obj.element_id), { width: NODE_W, height: NODE_H })
290
+ })
291
+
292
+ edgeList
293
+ .filter((e: { source_element_id: number; target_element_id: number }) =>
294
+ objSet.has(e.source_element_id) && objSet.has(e.target_element_id)
295
+ )
296
+ .forEach((e: { id: number; source_element_id: number; target_element_id: number }) => {
297
+ graph.setEdge(String(e.source_element_id), String(e.target_element_id), {}, String(e.id))
298
+ })
299
+
300
+ dagre.layout(graph)
307
301
 
308
- const result = await elk.layout(graph)
309
302
  const positions = new Map<number, { x: number; y: number }>()
310
- result.children?.forEach((child: { id: string; x?: number; y?: number }) => {
311
- const id = Number(child.id)
303
+ graph.nodes().forEach((nodeId: string) => {
304
+ const id = Number(nodeId)
312
305
  if (!Number.isFinite(id)) return
313
- positions.set(id, { x: child.x ?? 0, y: child.y ?? 0 })
306
+ const node = graph.node(nodeId) as { x?: number; y?: number }
307
+ positions.set(id, {
308
+ x: (node.x ?? 0) - NODE_W / 2,
309
+ y: (node.y ?? 0) - NODE_H / 2,
310
+ })
314
311
  })
315
312
  return positions
316
313
  }
@@ -428,54 +425,34 @@ export default function LayoutSection({ view, canEdit }: Props) {
428
425
  border="1px solid"
429
426
  borderColor="whiteAlpha.100"
430
427
  >
431
- {algo === 'elk' ? (
428
+ {algo === 'dagre' ? (
432
429
  <Grid templateColumns="1fr 1fr" gap={4}>
433
430
  <FormControl gridColumn="span 2">
434
- <FormLabel {...LabelStyle}>Algorithm</FormLabel>
431
+ <FormLabel {...LabelStyle}>Direction</FormLabel>
435
432
  <Select
436
433
  size="xs"
437
434
  variant="filled"
438
435
  bg="whiteAlpha.100"
439
436
  border="none"
440
437
  _hover={{ bg: 'whiteAlpha.200' }}
441
- value={elkConfig.algorithm}
442
- onChange={e => setElkConfig(c => ({ ...c, algorithm: e.target.value as ElkConfig['algorithm'] }))}
438
+ value={dagreConfig.direction}
439
+ onChange={e => setDagreConfig(c => ({ ...c, direction: e.target.value as DagreConfig['direction'] }))}
443
440
  >
444
- <option value="layered">Layered</option>
445
- <option value="force">Force</option>
446
- <option value="mrtree">Mr. Tree</option>
447
- <option value="box">Box</option>
441
+ <option value="TB">Top → Bottom</option>
442
+ <option value="BT">Bottom → Top</option>
443
+ <option value="LR">Left → Right</option>
444
+ <option value="RL">Right → Left</option>
448
445
  </Select>
449
446
  </FormControl>
450
447
 
451
- {elkConfig.algorithm === 'layered' && (
452
- <FormControl gridColumn="span 2">
453
- <FormLabel {...LabelStyle}>Direction</FormLabel>
454
- <Select
455
- size="xs"
456
- variant="filled"
457
- bg="whiteAlpha.100"
458
- border="none"
459
- _hover={{ bg: 'whiteAlpha.200' }}
460
- value={elkConfig.direction}
461
- onChange={e => setElkConfig(c => ({ ...c, direction: e.target.value as ElkConfig['direction'] }))}
462
- >
463
- <option value="DOWN">Top → Bottom</option>
464
- <option value="UP">Bottom → Top</option>
465
- <option value="RIGHT">Left → Right</option>
466
- <option value="LEFT">Right → Left</option>
467
- </Select>
468
- </FormControl>
469
- )}
470
-
471
448
  <FormControl>
472
449
  <FormLabel {...LabelStyle}>Element Gap</FormLabel>
473
450
  <NumberInput
474
451
  size="xs"
475
452
  variant="filled"
476
- value={elkConfig.nodeSpacing}
453
+ value={dagreConfig.nodeSpacing}
477
454
  min={10} max={400} step={10}
478
- onChange={(_, v) => !isNaN(v) && setElkConfig(c => ({ ...c, nodeSpacing: v }))}
455
+ onChange={(_, v) => !isNaN(v) && setDagreConfig(c => ({ ...c, nodeSpacing: v }))}
479
456
  >
480
457
  <NumberInputField bg="whiteAlpha.100" border="none" />
481
458
  <NumberInputStepper>
@@ -485,24 +462,22 @@ export default function LayoutSection({ view, canEdit }: Props) {
485
462
  </NumberInput>
486
463
  </FormControl>
487
464
 
488
- {elkConfig.algorithm === 'layered' && (
489
- <FormControl>
490
- <FormLabel {...LabelStyle}>Layer Gap</FormLabel>
491
- <NumberInput
492
- size="xs"
493
- variant="filled"
494
- value={elkConfig.layerSpacing}
495
- min={10} max={400} step={10}
496
- onChange={(_, v) => !isNaN(v) && setElkConfig(c => ({ ...c, layerSpacing: v }))}
497
- >
498
- <NumberInputField bg="whiteAlpha.100" border="none" />
499
- <NumberInputStepper>
500
- <NumberIncrementStepper border="none" />
501
- <NumberDecrementStepper border="none" />
502
- </NumberInputStepper>
503
- </NumberInput>
504
- </FormControl>
505
- )}
465
+ <FormControl>
466
+ <FormLabel {...LabelStyle}>Layer Gap</FormLabel>
467
+ <NumberInput
468
+ size="xs"
469
+ variant="filled"
470
+ value={dagreConfig.layerSpacing}
471
+ min={10} max={400} step={10}
472
+ onChange={(_, v) => !isNaN(v) && setDagreConfig(c => ({ ...c, layerSpacing: v }))}
473
+ >
474
+ <NumberInputField bg="whiteAlpha.100" border="none" />
475
+ <NumberInputStepper>
476
+ <NumberIncrementStepper border="none" />
477
+ <NumberDecrementStepper border="none" />
478
+ </NumberInputStepper>
479
+ </NumberInput>
480
+ </FormControl>
506
481
  </Grid>
507
482
  ) : (
508
483
  <Grid templateColumns="1fr 1fr" gap={4}>
@@ -137,7 +137,7 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
137
137
 
138
138
  return () => {
139
139
  active = false
140
- if (url) {
140
+ if (url?.startsWith('blob:')) {
141
141
  URL.revokeObjectURL(url)
142
142
  }
143
143
  }
@@ -224,9 +224,6 @@ export default function ViewGridNode({ data }: { data: ViewGridNodeData }) {
224
224
  display="block"
225
225
  p={2}
226
226
  bg="var(--bg-card-solid)"
227
- style={{
228
- filter: 'brightness(0) saturate(100%) invert(35%) sepia(26%) forum-blue(82%) hue-rotate(180deg) brightness(95%) contrast(90%)',
229
- }}
230
227
  />
231
228
  ) : (
232
229
  <Flex
@@ -37,6 +37,7 @@ export interface ScreenRect {
37
37
  bottom: number
38
38
  }
39
39
 
40
+ const frameLabelRects: ScreenRect[] = []
40
41
 
41
42
  /**
42
43
  * Returns a world-space font size that, when multiplied by zoom,
@@ -62,19 +63,61 @@ const TYPE_COLOR_400: Record<string, string> = {
62
63
  }
63
64
 
64
65
  /** Border color: type .400 at 50% alpha - bold branded tint */
66
+ const typeBorderColorCache = new Map<string, string>()
65
67
  function typeBorderColor(type: string, alpha = 0.5): string {
68
+ const cacheKey = `${type}:${alpha}`
69
+ const cached = typeBorderColorCache.get(cacheKey)
70
+ if (cached) return cached
71
+
66
72
  const color = TYPE_COLOR_400[type]
67
73
  const hex = typeof color === 'string' ? color : '#a0aec0'
68
74
  const r = parseInt(hex.slice(1, 3), 16)
69
75
  const g = parseInt(hex.slice(3, 5), 16)
70
76
  const b = parseInt(hex.slice(5, 7), 16)
71
- return `rgba(${r},${g},${b},${alpha})`
77
+ const rgba = `rgba(${r},${g},${b},${alpha})`
78
+ typeBorderColorCache.set(cacheKey, rgba)
79
+ return rgba
80
+ }
81
+
82
+ interface RendererThemeVars {
83
+ canvasBg: string
84
+ nodeBg: string
85
+ accent: string
86
+ labelBg: string
87
+ }
88
+
89
+ const themeFallbacks: RendererThemeVars = {
90
+ canvasBg: '#0d121e',
91
+ nodeBg: '#2d3748',
92
+ accent: '#63b3ed',
93
+ labelBg: '#171923',
72
94
  }
73
95
 
74
- /** Read a CSS custom property value from :root (resolves color-mix, etc.). */
75
- function readCSSVar(name: string, fallback: string): string {
76
- const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
77
- return v || fallback
96
+ let cachedThemeVars: RendererThemeVars = themeFallbacks
97
+ let themeObserverStarted = false
98
+
99
+ function refreshThemeVars(): void {
100
+ if (typeof document === 'undefined') return
101
+ const styles = getComputedStyle(document.documentElement)
102
+ cachedThemeVars = {
103
+ canvasBg: styles.getPropertyValue('--bg-main').trim() || themeFallbacks.canvasBg,
104
+ nodeBg: styles.getPropertyValue('--bg-element').trim() || themeFallbacks.nodeBg,
105
+ accent: styles.getPropertyValue('--accent').trim() || themeFallbacks.accent,
106
+ labelBg: styles.getPropertyValue('--chakra-colors-gray-900').trim() || themeFallbacks.labelBg,
107
+ }
108
+ }
109
+
110
+ function getThemeVars(): RendererThemeVars {
111
+ if (!themeObserverStarted && typeof document !== 'undefined') {
112
+ themeObserverStarted = true
113
+ refreshThemeVars()
114
+ const update = () => refreshThemeVars()
115
+ const mo = new MutationObserver(update)
116
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] })
117
+ window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener?.('change', update)
118
+ window.matchMedia?.('(prefers-color-scheme: light)').addEventListener?.('change', update)
119
+ }
120
+ return cachedThemeVars
78
121
  }
79
122
 
80
123
  // ── Geometry helpers ───────────────────────────────────────────────
@@ -133,14 +176,6 @@ function rectsOverlap(a: ScreenRect, b: ScreenRect): boolean {
133
176
  return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
134
177
  }
135
178
 
136
- function worldToScreen(matrix: DOMMatrix, x: number, y: number) {
137
- return new DOMPoint(x, y).matrixTransform(matrix)
138
- }
139
-
140
- function screenToWorld(matrix: DOMMatrix, x: number, y: number) {
141
- return new DOMPoint(x, y).matrixTransform(matrix.inverse())
142
- }
143
-
144
179
  export function pickEdgeLabelPosition(
145
180
  matrix: DOMMatrix,
146
181
  midX: number,
@@ -151,7 +186,8 @@ export function pickEdgeLabelPosition(
151
186
  dy: number,
152
187
  occupiedLabelRects: ScreenRect[],
153
188
  ) {
154
- const screenMid = worldToScreen(matrix, midX, midY)
189
+ const screenMidX = matrix.a * midX + matrix.c * midY + matrix.e
190
+ const screenMidY = matrix.b * midX + matrix.d * midY + matrix.f
155
191
  const screenTextW = Math.max(1, textW * matrix.a)
156
192
  const screenTextH = Math.max(1, textH * matrix.d)
157
193
  const gap = 6
@@ -161,21 +197,38 @@ export function pickEdgeLabelPosition(
161
197
  const normalY = dx / length
162
198
  const tangentX = dx / length
163
199
  const tangentY = dy / length
164
- const candidateOffsets = [
165
- { x: 0, y: 0 },
166
- { x: normalX * step, y: normalY * step },
167
- { x: -normalX * step, y: -normalY * step },
168
- { x: normalX * step * 2, y: normalY * step * 2 },
169
- { x: -normalX * step * 2, y: -normalY * step * 2 },
170
- { x: tangentX * step, y: tangentY * step },
171
- { x: -tangentX * step, y: -tangentY * step },
172
- { x: tangentX * step + normalX * step, y: tangentY * step + normalY * step },
173
- { x: -tangentX * step - normalX * step, y: -tangentY * step - normalY * step },
174
- ]
175
-
176
- for (const offset of candidateOffsets) {
177
- const centerX = screenMid.x + offset.x
178
- const centerY = screenMid.y + offset.y
200
+
201
+ for (let i = 0; i < 9; i++) {
202
+ let offsetX = 0
203
+ let offsetY = 0
204
+ if (i === 1) {
205
+ offsetX = normalX * step
206
+ offsetY = normalY * step
207
+ } else if (i === 2) {
208
+ offsetX = -normalX * step
209
+ offsetY = -normalY * step
210
+ } else if (i === 3) {
211
+ offsetX = normalX * step * 2
212
+ offsetY = normalY * step * 2
213
+ } else if (i === 4) {
214
+ offsetX = -normalX * step * 2
215
+ offsetY = -normalY * step * 2
216
+ } else if (i === 5) {
217
+ offsetX = tangentX * step
218
+ offsetY = tangentY * step
219
+ } else if (i === 6) {
220
+ offsetX = -tangentX * step
221
+ offsetY = -tangentY * step
222
+ } else if (i === 7) {
223
+ offsetX = tangentX * step + normalX * step
224
+ offsetY = tangentY * step + normalY * step
225
+ } else if (i === 8) {
226
+ offsetX = -tangentX * step - normalX * step
227
+ offsetY = -tangentY * step - normalY * step
228
+ }
229
+
230
+ const centerX = screenMidX + offsetX
231
+ const centerY = screenMidY + offsetY
179
232
  const rect: ScreenRect = {
180
233
  left: centerX - screenTextW / 2 - gap,
181
234
  top: centerY - screenTextH / 2 - gap / 2,
@@ -184,15 +237,21 @@ export function pickEdgeLabelPosition(
184
237
  }
185
238
  if (occupiedLabelRects.some((existing) => rectsOverlap(rect, existing))) continue
186
239
  occupiedLabelRects.push(rect)
187
- const worldPoint = screenToWorld(matrix, centerX, centerY)
188
- return { x: worldPoint.x, y: worldPoint.y }
240
+ const det = matrix.a * matrix.d - matrix.b * matrix.c
241
+ if (det === 0) return { x: midX, y: midY }
242
+ const translatedX = centerX - matrix.e
243
+ const translatedY = centerY - matrix.f
244
+ return {
245
+ x: (matrix.d * translatedX - matrix.c * translatedY) / det,
246
+ y: (-matrix.b * translatedX + matrix.a * translatedY) / det,
247
+ }
189
248
  }
190
249
 
191
250
  const fallbackRect: ScreenRect = {
192
- left: screenMid.x - screenTextW / 2 - gap,
193
- top: screenMid.y - screenTextH / 2 - gap / 2,
194
- right: screenMid.x + screenTextW / 2 + gap,
195
- bottom: screenMid.y + screenTextH / 2 + gap / 2,
251
+ left: screenMidX - screenTextW / 2 - gap,
252
+ top: screenMidY - screenTextH / 2 - gap / 2,
253
+ right: screenMidX + screenTextW / 2 + gap,
254
+ bottom: screenMidY + screenTextH / 2 + gap / 2,
196
255
  }
197
256
  occupiedLabelRects.push(fallbackRect)
198
257
  return { x: midX, y: midY }
@@ -301,9 +360,15 @@ function parseHex(hex: string): { r: number; g: number; b: number } {
301
360
  }
302
361
 
303
362
  /** Derive a portal tint color from the accent: same hue, very low alpha. */
363
+ const portalTintColorCache = new Map<string, string>()
304
364
  function portalTintColor(accent: string, alpha: number): string {
365
+ const cacheKey = `${accent}:${alpha}`
366
+ const cached = portalTintColorCache.get(cacheKey)
367
+ if (cached) return cached
305
368
  const { r, g, b } = parseHex(accent)
306
- return `rgba(${r},${g},${b},${alpha})`
369
+ const rgba = `rgba(${r},${g},${b},${alpha})`
370
+ portalTintColorCache.set(cacheKey, rgba)
371
+ return rgba
307
372
  }
308
373
 
309
374
  /** Draw a squiggly line from (x1, y1) to (x2, y2). */
@@ -719,24 +784,38 @@ function drawNode(
719
784
 
720
785
  // ── Edge drawing ───────────────────────────────────────────────────
721
786
 
722
- function drawEdges(
723
- ctx: CanvasRenderingContext2D,
724
- nodes: LayoutNode[],
725
- alpha: number,
726
- zoom: number,
727
- thresholds: { start: number; end: number },
728
- accent: string,
729
- labelBg: string,
730
- occupiedLabelRects: ScreenRect[],
731
- ): void {
732
- if (alpha < 0.05) return
733
- const nodeMap = new Map(nodes.map((n) => [n.id, n]))
734
- const handleUsage: Record<string, { edgeKey: string; type: 'source' | 'target'; otherNodeCoord: number }[]> = {}
787
+ interface HandleUsage {
788
+ edgeKey: string
789
+ type: 'source' | 'target'
790
+ otherNodeCoord: number
791
+ }
735
792
 
736
- nodes.forEach((node) => {
737
- node.edgesOut.forEach((edge, edgeIndex) => {
793
+ interface DrawEdgesLayoutMetadata {
794
+ nodeMap: Map<string, LayoutNode>
795
+ handleUsage: Record<string, HandleUsage[]>
796
+ handleUsageIndex: Record<string, number>
797
+ }
798
+
799
+ const drawEdgesMetadataCache = new WeakMap<LayoutNode[], DrawEdgesLayoutMetadata>()
800
+ const emptyHandleUsage: HandleUsage[] = []
801
+
802
+ function getDrawEdgesLayoutMetadata(nodes: LayoutNode[]): DrawEdgesLayoutMetadata {
803
+ const cached = drawEdgesMetadataCache.get(nodes)
804
+ if (cached) return cached
805
+
806
+ const nodeMap = new Map<string, LayoutNode>()
807
+ const handleUsage: Record<string, HandleUsage[]> = {}
808
+ const handleUsageIndex: Record<string, number> = {}
809
+
810
+ for (const node of nodes) {
811
+ nodeMap.set(node.id, node)
812
+ }
813
+
814
+ for (const node of nodes) {
815
+ for (let edgeIndex = 0; edgeIndex < node.edgesOut.length; edgeIndex++) {
816
+ const edge = node.edgesOut[edgeIndex]
738
817
  const target = nodeMap.get(edge.targetId)
739
- if (!target) return
818
+ if (!target) continue
740
819
 
741
820
  const edgeKey = `${node.id}:${edgeIndex}`
742
821
  const sourceSide = getLogicalHandleId(edge.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
@@ -761,12 +840,34 @@ function drawEdges(
761
840
  ? node.worldY + node.worldH / 2
762
841
  : node.worldX + node.worldW / 2,
763
842
  })
764
- })
765
- })
843
+ }
844
+ }
766
845
 
767
- Object.values(handleUsage).forEach((usages) => {
846
+ for (const [usageKey, usages] of Object.entries(handleUsage)) {
768
847
  usages.sort((a, b) => a.otherNodeCoord - b.otherNodeCoord)
769
- })
848
+ for (let i = 0; i < usages.length; i++) {
849
+ const usage = usages[i]
850
+ handleUsageIndex[`${usageKey}:${usage.edgeKey}:${usage.type}`] = i
851
+ }
852
+ }
853
+
854
+ const metadata = { nodeMap, handleUsage, handleUsageIndex }
855
+ drawEdgesMetadataCache.set(nodes, metadata)
856
+ return metadata
857
+ }
858
+
859
+ function drawEdges(
860
+ ctx: CanvasRenderingContext2D,
861
+ nodes: LayoutNode[],
862
+ alpha: number,
863
+ zoom: number,
864
+ thresholds: { start: number; end: number },
865
+ accent: string,
866
+ labelBg: string,
867
+ occupiedLabelRects: ScreenRect[],
868
+ ): void {
869
+ if (alpha < 0.05) return
870
+ const { nodeMap, handleUsage, handleUsageIndex } = getDrawEdgesLayoutMetadata(nodes)
770
871
 
771
872
  for (const node of nodes) {
772
873
  for (const [edgeIndex, edge] of node.edgesOut.entries()) {
@@ -807,10 +908,12 @@ function drawEdges(
807
908
  const edgeKey = `${node.id}:${edgeIndex}`
808
909
  const sourceSide = getLogicalHandleId(edge.sourceHandle, DEFAULT_SOURCE_HANDLE_SIDE) ?? DEFAULT_SOURCE_HANDLE_SIDE
809
910
  const targetSide = getLogicalHandleId(edge.targetHandle, DEFAULT_TARGET_HANDLE_SIDE) ?? DEFAULT_TARGET_HANDLE_SIDE
810
- const srcGroup = handleUsage[`${node.id}-${sourceSide}`] ?? []
811
- const tgtGroup = handleUsage[`${target.id}-${targetSide}`] ?? []
812
- const sourceGroupIndex = srcGroup.findIndex((usage) => usage.edgeKey === edgeKey && usage.type === 'source')
813
- const targetGroupIndex = tgtGroup.findIndex((usage) => usage.edgeKey === edgeKey && usage.type === 'target')
911
+ const srcKey = `${node.id}-${sourceSide}`
912
+ const tgtKey = `${target.id}-${targetSide}`
913
+ const srcGroup = handleUsage[srcKey] ?? emptyHandleUsage
914
+ const tgtGroup = handleUsage[tgtKey] ?? emptyHandleUsage
915
+ const sourceGroupIndex = handleUsageIndex[`${srcKey}:${edgeKey}:source`] ?? -1
916
+ const targetGroupIndex = handleUsageIndex[`${tgtKey}:${edgeKey}:target`] ?? -1
814
917
 
815
918
  const sH = getHandlePos(
816
919
  effXSource,
@@ -1095,11 +1198,7 @@ export function renderFrame(
1095
1198
  canvasW: number,
1096
1199
  canvasH: number,
1097
1200
  ): ScreenRect[] {
1098
- // Read user-customisable CSS vars once per frame
1099
- const canvasBg = readCSSVar('--bg-main', '#0d121e')
1100
- const nodeBg = readCSSVar('--bg-element', '#2d3748')
1101
- const accent = readCSSVar('--accent', '#63b3ed')
1102
- const labelBg = readCSSVar('--chakra-colors-gray-900', '#171923')
1201
+ const { canvasBg, nodeBg, accent, labelBg } = getThemeVars()
1103
1202
 
1104
1203
  ctx.clearRect(0, 0, canvasW, canvasH)
1105
1204
 
@@ -1114,7 +1213,8 @@ export function renderFrame(
1114
1213
  ctx.scale(view.zoom, view.zoom)
1115
1214
 
1116
1215
  const thresholds = getExpandThresholds(canvasW)
1117
- const occupiedLabelRects: ScreenRect[] = []
1216
+ const occupiedLabelRects = frameLabelRects
1217
+ occupiedLabelRects.length = 0
1118
1218
 
1119
1219
  for (const group of groups) {
1120
1220
  if (!isVisible(group.worldX, group.worldY, group.worldW, group.worldH, view, canvasW, canvasH)) {