@tldiagram/core-ui 1.95.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts +184 -3
- package/dist/components/ConnectorPanel.d.ts +5 -1
- package/dist/components/CrossBranchControls.d.ts +4 -3
- package/dist/components/ElementNode.d.ts +5 -0
- package/dist/components/ElementPanel.d.ts +6 -1
- package/dist/components/LayoutSection.d.ts +2 -1
- package/dist/components/MergeDialog.d.ts +16 -0
- package/dist/components/NodeContainer.d.ts +2 -0
- package/dist/components/ProxyConnectorPanel.d.ts +4 -1
- package/dist/components/ViewExplorer/index.d.ts +1 -1
- package/dist/components/ViewFloatingMenu-vscode.d.ts +5 -0
- package/dist/components/ViewFloatingMenu.d.ts +8 -1
- package/dist/components/ViewGridNode.d.ts +3 -0
- package/dist/components/ViewPanel.d.ts +2 -1
- package/dist/components/WorkspacePanel.d.ts +2 -0
- package/dist/components/ZUI/ZUICanvas.d.ts +4 -0
- package/dist/components/ZUI/focus.d.ts +32 -0
- package/dist/components/ZUI/focus.test.d.ts +1 -0
- package/dist/components/ZUI/layout.d.ts +2 -2
- package/dist/components/ZUI/proxy.d.ts +20 -4
- package/dist/components/ZUI/renderer.d.ts +35 -1
- package/dist/components/ZUI/types.d.ts +6 -0
- package/dist/components/ZUI/useZUIInteraction.d.ts +1 -0
- package/dist/context/WorkspaceVersionContext.d.ts +49 -0
- package/dist/crossBranch/resolve.d.ts +39 -2
- package/dist/crossBranch/resolve.test.d.ts +1 -0
- package/dist/crossBranch/settings.d.ts +6 -1
- package/dist/crossBranch/types.d.ts +8 -0
- package/dist/hooks/useElementSearch.d.ts +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +16529 -14030
- package/dist/pages/InfiniteZoom.d.ts +1 -0
- package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +6 -1
- package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +2 -0
- package/dist/pages/ViewEditor/hooks/useViewData.d.ts +4 -2
- package/dist/pages/ViewEditor/hooks/useViewEditHistory.d.ts +13 -0
- package/dist/pages/viewsJumpSearch.d.ts +22 -0
- package/dist/pages/viewsJumpSearch.test.d.ts +1 -0
- package/dist/store/useStore.d.ts +3 -0
- package/dist/types/index.d.ts +9 -0
- package/dist/utils/elementIcon.d.ts +2 -0
- package/dist/utils/elementIcon.test.d.ts +1 -0
- package/dist/utils/sourceEditor.d.ts +7 -0
- package/dist/utils/watchDiffSummary.d.ts +34 -0
- package/package.json +2 -2
- package/src/App.tsx +12 -8
- package/src/api/client.ts +488 -26
- package/src/components/CodePreviewPanel.tsx +90 -16
- package/src/components/ConnectorPanel.tsx +34 -3
- package/src/components/ContextNeighborElement.tsx +2 -5
- package/src/components/CrossBranchControls.tsx +46 -17
- package/src/components/ElementNode.tsx +98 -47
- package/src/components/ElementPanel.tsx +62 -25
- package/src/components/InlineElementAdder.tsx +8 -3
- package/src/components/LayoutSection.tsx +4 -1
- package/src/components/MergeDialog.tsx +269 -0
- package/src/components/NodeContainer.tsx +55 -17
- package/src/components/ProxyConnectorPanel.tsx +58 -16
- package/src/components/ViewBezierConnector.tsx +116 -21
- package/src/components/ViewExplorer/index.tsx +1 -1
- package/src/components/ViewFloatingMenu-vscode.tsx +5 -0
- package/src/components/ViewFloatingMenu.tsx +110 -1
- package/src/components/ViewGridNode.tsx +59 -8
- package/src/components/ViewPanel.tsx +3 -2
- package/src/components/WorkspacePanel.tsx +938 -0
- package/src/components/ZUI/ZUICanvas.tsx +216 -122
- package/src/components/ZUI/focus.test.ts +534 -0
- package/src/components/ZUI/focus.ts +293 -0
- package/src/components/ZUI/layout.ts +7 -11
- package/src/components/ZUI/proxy.ts +470 -114
- package/src/components/ZUI/renderer.ts +510 -134
- package/src/components/ZUI/types.ts +6 -0
- package/src/components/ZUI/useZUIInteraction.ts +66 -29
- package/src/context/WorkspaceVersionContext.tsx +126 -0
- package/src/crossBranch/resolve.test.ts +342 -0
- package/src/crossBranch/resolve.ts +368 -68
- package/src/crossBranch/settings.ts +49 -3
- package/src/crossBranch/types.ts +9 -0
- package/src/hooks/useElementSearch.ts +45 -0
- package/src/index.css +11 -0
- package/src/index.ts +7 -0
- package/src/pages/AppearanceSettings.tsx +24 -1
- package/src/pages/Dependencies.tsx +231 -65
- package/src/pages/InfiniteZoom.tsx +41 -19
- package/src/pages/Settings.tsx +1 -1
- package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +103 -24
- package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +102 -6
- package/src/pages/ViewEditor/hooks/useViewData.ts +42 -26
- package/src/pages/ViewEditor/hooks/useViewEditHistory.ts +62 -0
- package/src/pages/ViewEditor/index.tsx +549 -59
- package/src/pages/Views.tsx +112 -41
- package/src/pages/ViewsGrid.tsx +332 -113
- package/src/pages/viewsJumpSearch.test.ts +193 -0
- package/src/pages/viewsJumpSearch.ts +111 -0
- package/src/store/useStore.ts +58 -0
- package/src/types/index.ts +10 -0
- package/src/utils/elementIcon.test.ts +28 -0
- package/src/utils/elementIcon.ts +20 -0
- package/src/utils/sourceEditor.ts +46 -0
- 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
|
|
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
|
|
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
|
|
266
|
-
const sy = worldY
|
|
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
|
|
278
|
-
const sy = worldY
|
|
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
|
|
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
|
-
|
|
390
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
672
|
-
const vwR = (canvasW
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
1213
|
-
ctx.scale(
|
|
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
|
-
|
|
1220
|
-
|
|
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,
|
|
1635
|
+
drawGroupLabel(ctx, group, renderView, canvasW, canvasH, accent)
|
|
1225
1636
|
|
|
1226
1637
|
// ── Group box (diagram elements container) ──────────────────────────
|
|
1227
|
-
const borderAlpha = clamp(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 /
|
|
1643
|
+
ctx.lineWidth = 2 / renderView.zoom
|
|
1233
1644
|
ctx.setLineDash([2, 2])
|
|
1234
|
-
// Only draw the border around the diagram part
|
|
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,
|
|
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,
|
|
1655
|
+
if (!isVisible(node.worldX, node.worldY, node.worldW, node.worldH, renderView, canvasW, canvasH)) {
|
|
1280
1656
|
continue
|
|
1281
1657
|
}
|
|
1282
|
-
const screenW = node.worldW *
|
|
1283
|
-
drawNode(ctx, node, screenW, thresholds, 1,
|
|
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
|
|