@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.
- package/dist/api/client.d.ts +13 -1
- package/dist/components/ElementNode.d.ts +9 -0
- package/dist/config/runtime-vscode.d.ts +1 -0
- package/dist/config/runtime.d.ts +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +10063 -9512
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +2 -1
- package/dist/pages/ViewEditor/hooks/useViewData.d.ts +20 -21
- package/dist/shims/empty-node-module.d.ts +2 -0
- package/dist/store/useStore.d.ts +78 -0
- package/dist/store/useStore.test.d.ts +1 -0
- package/package.json +7 -4
- package/src/App.tsx +0 -4
- package/src/api/client.ts +39 -1
- package/src/components/ElementNode.tsx +11 -58
- package/src/components/ElementPanel.tsx +2 -2
- package/src/components/LayoutSection.tsx +68 -93
- package/src/components/ViewGridNode.tsx +1 -4
- package/src/components/ZUI/renderer.ts +166 -66
- package/src/components/ZUI/useZUIInteraction.ts +235 -81
- package/src/config/runtime-vscode.ts +6 -0
- package/src/config/runtime.ts +4 -0
- package/src/index.ts +0 -1
- package/src/main.tsx +26 -14
- package/src/pages/ViewEditor/context.tsx +12 -4
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +172 -121
- package/src/pages/ViewEditor/hooks/useViewData.ts +455 -253
- package/src/pages/ViewEditor/index.tsx +45 -32
- package/src/shims/empty-node-module.ts +1 -0
- package/src/store/useStore.test.ts +272 -0
- package/src/store/useStore.ts +285 -0
- package/dist/demo/DemoPage.d.ts +0 -9
- package/dist/demo/seed.d.ts +0 -9
- package/dist/demo/store.d.ts +0 -137
- package/src/demo/DemoPage.tsx +0 -184
- package/src/demo/seed.ts +0 -67
- 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 = '
|
|
24
|
+
type Algorithm = 'dagre' | 'force'
|
|
25
25
|
|
|
26
|
-
interface
|
|
27
|
-
|
|
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
|
-
|
|
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>('
|
|
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 [
|
|
60
|
-
|
|
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 === '
|
|
203
|
-
positions = await
|
|
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
|
|
272
|
+
const runDagre = async (objs: any[], edgeList: any[]) => {
|
|
275
273
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
276
|
-
const
|
|
277
|
-
const
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
311
|
-
const id = Number(
|
|
303
|
+
graph.nodes().forEach((nodeId: string) => {
|
|
304
|
+
const id = Number(nodeId)
|
|
312
305
|
if (!Number.isFinite(id)) return
|
|
313
|
-
|
|
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 === '
|
|
428
|
+
{algo === 'dagre' ? (
|
|
432
429
|
<Grid templateColumns="1fr 1fr" gap={4}>
|
|
433
430
|
<FormControl gridColumn="span 2">
|
|
434
|
-
<FormLabel {...LabelStyle}>
|
|
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={
|
|
442
|
-
onChange={e =>
|
|
438
|
+
value={dagreConfig.direction}
|
|
439
|
+
onChange={e => setDagreConfig(c => ({ ...c, direction: e.target.value as DagreConfig['direction'] }))}
|
|
443
440
|
>
|
|
444
|
-
<option value="
|
|
445
|
-
<option value="
|
|
446
|
-
<option value="
|
|
447
|
-
<option value="
|
|
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={
|
|
453
|
+
value={dagreConfig.nodeSpacing}
|
|
477
454
|
min={10} max={400} step={10}
|
|
478
|
-
onChange={(_, v) => !isNaN(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
|
-
|
|
489
|
-
<
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
<
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
188
|
-
return { x:
|
|
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:
|
|
193
|
-
top:
|
|
194
|
-
right:
|
|
195
|
-
bottom:
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
737
|
-
|
|
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)
|
|
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.
|
|
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
|
|
811
|
-
const
|
|
812
|
-
const
|
|
813
|
-
const
|
|
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
|
-
|
|
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
|
|
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)) {
|