@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
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { computeLayout } from './layout'
|
|
3
|
+
import { findDiagramFocusTarget, findElementFocusTarget, viewportForDiagramFocusTarget, viewportForElementFocusTarget, viewportForFocusTarget, type ZUIFocusTarget } from './focus'
|
|
4
|
+
import { calculateMaxZoom, constrainViewState } from './useZUIInteraction'
|
|
5
|
+
import { buildCameraTransitionRebase, findFocusedFlattenedLayerForTest, getCameraRebase, getExpandThresholds, rawCameraView, worldToScreenX, worldToScreenY } from './renderer'
|
|
6
|
+
import type { ExploreData, PlacedElement, ViewConnector, ViewTreeNode } from '../../types'
|
|
7
|
+
import type { DiagramGroupLayout, LayoutNode, ZUIViewState } from './types'
|
|
8
|
+
|
|
9
|
+
function treeNode(id: number, name: string, ownerElementId: number | null, parentViewId: number | null, children: ViewTreeNode[] = []): ViewTreeNode {
|
|
10
|
+
return {
|
|
11
|
+
id,
|
|
12
|
+
owner_element_id: ownerElementId,
|
|
13
|
+
name,
|
|
14
|
+
description: null,
|
|
15
|
+
level_label: null,
|
|
16
|
+
level: 0,
|
|
17
|
+
depth: parentViewId == null ? 0 : 1,
|
|
18
|
+
created_at: '2024-01-01',
|
|
19
|
+
updated_at: '2024-01-01',
|
|
20
|
+
parent_view_id: parentViewId,
|
|
21
|
+
children,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function placed(viewId: number, elementId: number, x: number, y: number, hasView = false): PlacedElement {
|
|
26
|
+
return {
|
|
27
|
+
id: viewId * 1000 + elementId,
|
|
28
|
+
view_id: viewId,
|
|
29
|
+
element_id: elementId,
|
|
30
|
+
position_x: x,
|
|
31
|
+
position_y: y,
|
|
32
|
+
name: `Element ${elementId}`,
|
|
33
|
+
description: null,
|
|
34
|
+
kind: 'service',
|
|
35
|
+
technology: null,
|
|
36
|
+
url: null,
|
|
37
|
+
logo_url: null,
|
|
38
|
+
technology_connectors: [],
|
|
39
|
+
tags: [],
|
|
40
|
+
has_view: hasView,
|
|
41
|
+
view_label: null,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function testNode(id: string, children: LayoutNode[] = [], childScale = 0.8): LayoutNode {
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
elementId: Number(id.replace(/\D/g, '')) || 1,
|
|
49
|
+
diagramId: 1,
|
|
50
|
+
worldX: 0,
|
|
51
|
+
worldY: 0,
|
|
52
|
+
worldW: 180,
|
|
53
|
+
worldH: 85,
|
|
54
|
+
label: id,
|
|
55
|
+
type: 'service',
|
|
56
|
+
logoUrl: null,
|
|
57
|
+
description: null,
|
|
58
|
+
technology: null,
|
|
59
|
+
tags: [],
|
|
60
|
+
ancestorElementIds: [],
|
|
61
|
+
pathElementIds: [],
|
|
62
|
+
children,
|
|
63
|
+
childScale,
|
|
64
|
+
childOffsetX: 0,
|
|
65
|
+
childOffsetY: 0,
|
|
66
|
+
edgesOut: [],
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function testGroup(nodes: LayoutNode[]): DiagramGroupLayout {
|
|
71
|
+
return {
|
|
72
|
+
diagramId: 1,
|
|
73
|
+
label: 'Root',
|
|
74
|
+
description: null,
|
|
75
|
+
level: 0,
|
|
76
|
+
levelLabel: null,
|
|
77
|
+
worldX: 0,
|
|
78
|
+
worldY: 0,
|
|
79
|
+
worldW: 180,
|
|
80
|
+
worldH: 85,
|
|
81
|
+
diagramW: 180,
|
|
82
|
+
diagramH: 85,
|
|
83
|
+
diagramX: 0,
|
|
84
|
+
diagramY: 0,
|
|
85
|
+
nodes,
|
|
86
|
+
edges: [],
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function navigation(fromViewId: number, elementId: number, toViewId: number): ViewConnector {
|
|
91
|
+
return {
|
|
92
|
+
id: toViewId,
|
|
93
|
+
element_id: elementId,
|
|
94
|
+
from_view_id: fromViewId,
|
|
95
|
+
to_view_id: toViewId,
|
|
96
|
+
to_view_name: `View ${toViewId}`,
|
|
97
|
+
relation_type: 'child',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function nestedExploreData(): ExploreData {
|
|
102
|
+
return {
|
|
103
|
+
tree: [
|
|
104
|
+
treeNode(1, 'Root', null, null, [
|
|
105
|
+
treeNode(2, 'Second', 101, 1, [
|
|
106
|
+
treeNode(3, 'Third', 201, 2, [
|
|
107
|
+
treeNode(4, 'Fourth', 301, 3),
|
|
108
|
+
]),
|
|
109
|
+
]),
|
|
110
|
+
]),
|
|
111
|
+
],
|
|
112
|
+
views: {
|
|
113
|
+
1: { placements: [placed(1, 101, 120, 100, true)], connectors: [] },
|
|
114
|
+
2: { placements: [placed(2, 201, 200, 160, true)], connectors: [] },
|
|
115
|
+
3: { placements: [placed(3, 301, 300, 220, true)], connectors: [] },
|
|
116
|
+
4: {
|
|
117
|
+
placements: [
|
|
118
|
+
placed(4, 401, 40, 60),
|
|
119
|
+
placed(4, 499, 10_000, 8_000),
|
|
120
|
+
],
|
|
121
|
+
connectors: [],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
navigations: [
|
|
125
|
+
navigation(1, 101, 2),
|
|
126
|
+
navigation(2, 201, 3),
|
|
127
|
+
navigation(3, 301, 4),
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function deepSingleChainExploreData(depth: number): ExploreData {
|
|
133
|
+
const treeById = new Map<number, ViewTreeNode>()
|
|
134
|
+
for (let viewId = depth; viewId >= 1; viewId -= 1) {
|
|
135
|
+
treeById.set(
|
|
136
|
+
viewId,
|
|
137
|
+
treeNode(
|
|
138
|
+
viewId,
|
|
139
|
+
`View ${viewId}`,
|
|
140
|
+
viewId === 1 ? null : 1000 + viewId - 1,
|
|
141
|
+
viewId === 1 ? null : viewId - 1,
|
|
142
|
+
viewId < depth ? [treeById.get(viewId + 1)!] : [],
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const views: ExploreData['views'] = {}
|
|
148
|
+
const navigations: ViewConnector[] = []
|
|
149
|
+
for (let viewId = 1; viewId <= depth; viewId += 1) {
|
|
150
|
+
const elementId = viewId === depth ? 9001 : 1000 + viewId
|
|
151
|
+
views[viewId] = {
|
|
152
|
+
placements: [placed(viewId, elementId, viewId * 15, viewId * 10, viewId < depth)],
|
|
153
|
+
connectors: [],
|
|
154
|
+
}
|
|
155
|
+
if (viewId < depth) {
|
|
156
|
+
navigations.push(navigation(viewId, elementId, viewId + 1))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
tree: [treeById.get(1)!],
|
|
162
|
+
views,
|
|
163
|
+
navigations,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function focusMatrixExploreData(depth: number): ExploreData {
|
|
168
|
+
const treeById = new Map<number, ViewTreeNode>()
|
|
169
|
+
for (let viewId = depth; viewId >= 1; viewId -= 1) {
|
|
170
|
+
treeById.set(
|
|
171
|
+
viewId,
|
|
172
|
+
treeNode(
|
|
173
|
+
viewId,
|
|
174
|
+
`Matrix View ${viewId}`,
|
|
175
|
+
viewId === 1 ? null : 10_000 + viewId - 1,
|
|
176
|
+
viewId === 1 ? null : viewId - 1,
|
|
177
|
+
viewId < depth ? [treeById.get(viewId + 1)!] : [],
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const views: ExploreData['views'] = {}
|
|
183
|
+
const navigations: ViewConnector[] = []
|
|
184
|
+
for (let viewId = 1; viewId <= depth; viewId += 1) {
|
|
185
|
+
const childOwnerId = 10_000 + viewId
|
|
186
|
+
const childBearing = viewId < depth
|
|
187
|
+
? [placed(viewId, childOwnerId, viewId % 2 === 0 ? 1600 : -1500, viewId % 3 === 0 ? -1250 : 1350, true)]
|
|
188
|
+
: []
|
|
189
|
+
views[viewId] = {
|
|
190
|
+
placements: [
|
|
191
|
+
...childBearing,
|
|
192
|
+
placed(viewId, viewId * 100 + 1, -2600 + viewId * 37, 1800 - viewId * 53),
|
|
193
|
+
placed(viewId, viewId * 100 + 2, 2400 - viewId * 41, -2100 + viewId * 47),
|
|
194
|
+
placed(viewId, viewId * 100 + 3, viewId * 180, -viewId * 140),
|
|
195
|
+
],
|
|
196
|
+
connectors: [],
|
|
197
|
+
}
|
|
198
|
+
if (viewId < depth) {
|
|
199
|
+
navigations.push(navigation(viewId, childOwnerId, viewId + 1))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
tree: [treeById.get(1)!],
|
|
205
|
+
views,
|
|
206
|
+
navigations,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function placementsIn(data: ExploreData): Array<{ viewId: number; elementId: number }> {
|
|
211
|
+
return Object.entries(data.views).flatMap(([viewIdText, content]) => {
|
|
212
|
+
const viewId = Number(viewIdText)
|
|
213
|
+
return (content.placements ?? []).map((placement) => ({ viewId, elementId: placement.element_id }))
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function viewsIn(data: ExploreData): number[] {
|
|
218
|
+
return Object.keys(data.views).map(Number).filter(Number.isFinite)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function screenRect(target: ZUIFocusTarget, viewport: ZUIViewState) {
|
|
222
|
+
return {
|
|
223
|
+
left: worldToScreenX(target.absX, viewport),
|
|
224
|
+
top: worldToScreenY(target.absY, viewport),
|
|
225
|
+
right: worldToScreenX(target.absX + target.absW, viewport),
|
|
226
|
+
bottom: worldToScreenY(target.absY + target.absH, viewport),
|
|
227
|
+
width: target.absW * viewport.zoom,
|
|
228
|
+
height: target.absH * viewport.zoom,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function worldScreenRect(rect: { x: number; y: number; w: number; h: number }, viewport: ZUIViewState) {
|
|
233
|
+
return {
|
|
234
|
+
left: worldToScreenX(rect.x, viewport),
|
|
235
|
+
top: worldToScreenY(rect.y, viewport),
|
|
236
|
+
right: worldToScreenX(rect.x + rect.w, viewport),
|
|
237
|
+
bottom: worldToScreenY(rect.y + rect.h, viewport),
|
|
238
|
+
width: rect.w * viewport.zoom,
|
|
239
|
+
height: rect.h * viewport.zoom,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function interpolateViewState(from: ZUIViewState, to: ZUIViewState, t: number): ZUIViewState {
|
|
244
|
+
return {
|
|
245
|
+
x: from.x + (to.x - from.x) * t,
|
|
246
|
+
y: from.y + (to.y - from.y) * t,
|
|
247
|
+
zoom: from.zoom + (to.zoom - from.zoom) * t,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function completeFocusNavigationFromCurrent(
|
|
252
|
+
current: ZUIViewState,
|
|
253
|
+
destination: ZUIViewState,
|
|
254
|
+
canvasW: number,
|
|
255
|
+
canvasH: number,
|
|
256
|
+
bbox: { minX: number; minY: number; maxX: number; maxY: number },
|
|
257
|
+
): ZUIViewState {
|
|
258
|
+
;[0.15, 0.5, 0.85].forEach((t) => {
|
|
259
|
+
constrainViewState(interpolateViewState(current, destination, t), canvasW, canvasH, bbox)
|
|
260
|
+
})
|
|
261
|
+
return constrainViewState(destination, canvasW, canvasH, bbox)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function expectFiniteViewport(viewport: ZUIViewState, context: string) {
|
|
265
|
+
expect(Number.isFinite(viewport.x), `${context} x`).toBe(true)
|
|
266
|
+
expect(Number.isFinite(viewport.y), `${context} y`).toBe(true)
|
|
267
|
+
expect(Number.isFinite(viewport.zoom), `${context} zoom`).toBe(true)
|
|
268
|
+
expect(viewport.zoom, `${context} zoom`).toBeGreaterThan(0)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function expectScreenRectVisible(
|
|
272
|
+
rect: ReturnType<typeof screenRect>,
|
|
273
|
+
canvasW: number,
|
|
274
|
+
canvasH: number,
|
|
275
|
+
context: string,
|
|
276
|
+
) {
|
|
277
|
+
const epsilon = 0.75
|
|
278
|
+
expect(rect.left, `${context} left`).toBeGreaterThanOrEqual(-epsilon)
|
|
279
|
+
expect(rect.top, `${context} top`).toBeGreaterThanOrEqual(-epsilon)
|
|
280
|
+
expect(rect.right, `${context} right`).toBeLessThanOrEqual(canvasW + epsilon)
|
|
281
|
+
expect(rect.bottom, `${context} bottom`).toBeLessThanOrEqual(canvasH + epsilon)
|
|
282
|
+
expect(rect.width, `${context} width`).toBeGreaterThan(0)
|
|
283
|
+
expect(rect.height, `${context} height`).toBeGreaterThan(0)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
describe('ZUI focus targets', () => {
|
|
287
|
+
it('rebases a high-zoom camera to a small centered render transform', () => {
|
|
288
|
+
const rebase = getCameraRebase(
|
|
289
|
+
{ x: -147_317_059.10654327, y: -184_315_493.52577353, zoom: 906_732.1382976775 },
|
|
290
|
+
997,
|
|
291
|
+
975,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
expect(rebase.originX).toBeCloseTo(162.47086805935993, 10)
|
|
295
|
+
expect(rebase.originY).toBeCloseTo(203.27500619070713, 10)
|
|
296
|
+
expect(rebase.view).toEqual({
|
|
297
|
+
x: 498.5,
|
|
298
|
+
y: 487.5,
|
|
299
|
+
zoom: 906_732.1382976775,
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('flattens the focused deepest layer at extreme zoom', () => {
|
|
304
|
+
const layout = computeLayout(deepSingleChainExploreData(8))
|
|
305
|
+
const elementTarget = findElementFocusTarget(layout.groups, 8, 9001)
|
|
306
|
+
expect(elementTarget).not.toBeNull()
|
|
307
|
+
const constrained = {
|
|
308
|
+
x: 498.5,
|
|
309
|
+
y: 487.5,
|
|
310
|
+
zoom: 13_610_091,
|
|
311
|
+
originX: elementTarget!.absX + elementTarget!.absW / 2,
|
|
312
|
+
originY: elementTarget!.absY + elementTarget!.absH / 2,
|
|
313
|
+
}
|
|
314
|
+
const rebase = getCameraRebase(constrained, 997, 975)
|
|
315
|
+
const layer = findFocusedFlattenedLayerForTest(
|
|
316
|
+
layout.groups,
|
|
317
|
+
constrained,
|
|
318
|
+
997,
|
|
319
|
+
975,
|
|
320
|
+
getExpandThresholds(997),
|
|
321
|
+
rebase,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
expect(layer?.nodes.length).toBeGreaterThan(0)
|
|
325
|
+
const target = layer!.nodes.find((node) => node.elementId === 9001)
|
|
326
|
+
expect(target).toBeTruthy()
|
|
327
|
+
const left = worldToScreenX(target!.worldX, layer!.view)
|
|
328
|
+
const right = worldToScreenX(target!.worldX + target!.worldW, layer!.view)
|
|
329
|
+
expect(Number.isFinite(left)).toBe(true)
|
|
330
|
+
expect(Number.isFinite(right)).toBe(true)
|
|
331
|
+
expect(right - left).toBeGreaterThan(0)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('rebases stacked camera-center transitions without forcing ancestor expansion', () => {
|
|
335
|
+
const grandchild = testNode('node-3', [])
|
|
336
|
+
const child = testNode('node-2', [grandchild])
|
|
337
|
+
const parent = testNode('node-1', [child])
|
|
338
|
+
const groups = [testGroup([parent])]
|
|
339
|
+
const thresholds = getExpandThresholds(1200)
|
|
340
|
+
|
|
341
|
+
const rebase = buildCameraTransitionRebase(
|
|
342
|
+
groups,
|
|
343
|
+
{ x: 420, y: 315, zoom: 2.2 },
|
|
344
|
+
1200,
|
|
345
|
+
800,
|
|
346
|
+
thresholds,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
expect(rebase.preserveChildAlphaNodeIds.has('node-1')).toBe(true)
|
|
350
|
+
expect(rebase.preserveChildAlphaNodeIds.has('node-2')).toBe(false)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('does not rebase when only one camera-center transition is active', () => {
|
|
354
|
+
const child = testNode('node-2', [])
|
|
355
|
+
const parent = testNode('node-1', [child])
|
|
356
|
+
const groups = [testGroup([parent])]
|
|
357
|
+
const thresholds = getExpandThresholds(1200)
|
|
358
|
+
|
|
359
|
+
const rebase = buildCameraTransitionRebase(
|
|
360
|
+
groups,
|
|
361
|
+
{ x: 420, y: 315, zoom: 1.9 },
|
|
362
|
+
1200,
|
|
363
|
+
800,
|
|
364
|
+
thresholds,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
expect(rebase.preserveChildAlphaNodeIds.size).toBe(0)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('finds and centers an element inside a deeply nested view', () => {
|
|
371
|
+
const layout = computeLayout(nestedExploreData())
|
|
372
|
+
const target = findElementFocusTarget(layout.groups, 4, 401)
|
|
373
|
+
expect(target).not.toBeNull()
|
|
374
|
+
|
|
375
|
+
const viewport = viewportForFocusTarget(target!, 1200, 800, 100_000, 0.18, {
|
|
376
|
+
minTargetScreenW: 320,
|
|
377
|
+
keepParentVisible: true,
|
|
378
|
+
})
|
|
379
|
+
expect(viewport).not.toBeNull()
|
|
380
|
+
|
|
381
|
+
const constrained = constrainViewState(viewport!, 1200, 800, layout.bbox)
|
|
382
|
+
const rect = screenRect(target!, constrained)
|
|
383
|
+
expect(rect.left).toBeGreaterThanOrEqual(0)
|
|
384
|
+
expect(rect.top).toBeGreaterThanOrEqual(0)
|
|
385
|
+
expect(rect.right).toBeLessThanOrEqual(1200)
|
|
386
|
+
expect(rect.bottom).toBeLessThanOrEqual(800)
|
|
387
|
+
expect(rect.width).toBeGreaterThanOrEqual(320)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('zooms nested view navigation far enough for the selected view contents to render', () => {
|
|
391
|
+
const layout = computeLayout(nestedExploreData())
|
|
392
|
+
const viewTarget = findDiagramFocusTarget(layout.groups, 4)
|
|
393
|
+
const elementTarget = findElementFocusTarget(layout.groups, 4, 401)
|
|
394
|
+
expect(viewTarget?.contentRect).toBeTruthy()
|
|
395
|
+
expect(elementTarget).not.toBeNull()
|
|
396
|
+
|
|
397
|
+
const viewport = viewportForFocusTarget(viewTarget!, 1200, 800, 100_000, 0.16, {
|
|
398
|
+
preferContent: true,
|
|
399
|
+
minTargetScreenW: 260,
|
|
400
|
+
minChildScreenW: 104,
|
|
401
|
+
})
|
|
402
|
+
expect(viewport).not.toBeNull()
|
|
403
|
+
|
|
404
|
+
const constrained = constrainViewState(viewport!, 1200, 800, layout.bbox)
|
|
405
|
+
const rect = screenRect(elementTarget!, constrained)
|
|
406
|
+
expect(rect.width).toBeGreaterThanOrEqual(104)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('does not inflate sub-pixel nested content bounds when centering a deep view', () => {
|
|
410
|
+
const layout = computeLayout(deepSingleChainExploreData(8))
|
|
411
|
+
const viewTarget = findDiagramFocusTarget(layout.groups, 8)
|
|
412
|
+
const elementTarget = findElementFocusTarget(layout.groups, 8, 9001)
|
|
413
|
+
expect(viewTarget?.contentRect).toBeTruthy()
|
|
414
|
+
expect(elementTarget).not.toBeNull()
|
|
415
|
+
|
|
416
|
+
const viewport = viewportForFocusTarget(viewTarget!, 1200, 800, 1_000_000, 0.16, {
|
|
417
|
+
preferContent: true,
|
|
418
|
+
minTargetScreenW: 260,
|
|
419
|
+
minChildScreenW: 104,
|
|
420
|
+
})
|
|
421
|
+
expect(viewport).not.toBeNull()
|
|
422
|
+
|
|
423
|
+
const rect = screenRect(elementTarget!, constrainViewState(viewport!, 1200, 800, layout.bbox))
|
|
424
|
+
expect(rect.left).toBeGreaterThanOrEqual(0)
|
|
425
|
+
expect(rect.top).toBeGreaterThanOrEqual(0)
|
|
426
|
+
expect(rect.right).toBeLessThanOrEqual(1200)
|
|
427
|
+
expect(rect.bottom).toBeLessThanOrEqual(800)
|
|
428
|
+
expect(rect.width).toBeGreaterThanOrEqual(104)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('keeps a sub-pixel expandable element visible when capping child zoom', () => {
|
|
432
|
+
const layout = computeLayout(deepSingleChainExploreData(8))
|
|
433
|
+
const target = findElementFocusTarget(layout.groups, 7, 1007)
|
|
434
|
+
expect(target?.node?.children.length).toBe(1)
|
|
435
|
+
|
|
436
|
+
const viewport = viewportForFocusTarget(target!, 1200, 800, 1_000_000, 0.18, {
|
|
437
|
+
minTargetScreenW: 320,
|
|
438
|
+
keepParentVisible: true,
|
|
439
|
+
})
|
|
440
|
+
expect(viewport).not.toBeNull()
|
|
441
|
+
|
|
442
|
+
const rect = screenRect(target!, constrainViewState(viewport!, 1200, 800, layout.bbox))
|
|
443
|
+
expect(rect.left).toBeGreaterThanOrEqual(0)
|
|
444
|
+
expect(rect.top).toBeGreaterThanOrEqual(0)
|
|
445
|
+
expect(rect.right).toBeLessThanOrEqual(1200)
|
|
446
|
+
expect(rect.bottom).toBeLessThanOrEqual(800)
|
|
447
|
+
expect(rect.width).toBeGreaterThanOrEqual(320)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('can navigate and zoom to every placed element across viewport sizes, levels, and current cameras', () => {
|
|
451
|
+
const data = focusMatrixExploreData(6)
|
|
452
|
+
const layout = computeLayout(data)
|
|
453
|
+
const canvasCases = [
|
|
454
|
+
{ name: 'desktop', w: 1200, h: 800, isMobile: false, leafMinWidth: 320 },
|
|
455
|
+
{ name: 'mobile', w: 390, h: 720, isMobile: true, leafMinWidth: 220 },
|
|
456
|
+
{ name: 'ultrawide', w: 1800, h: 900, isMobile: false, leafMinWidth: 320 },
|
|
457
|
+
]
|
|
458
|
+
const currentViewports: ZUIViewState[] = [
|
|
459
|
+
{ x: 0, y: 0, zoom: 0.4 },
|
|
460
|
+
{ x: -25_000, y: 18_000, zoom: 0.8 },
|
|
461
|
+
{ x: 40_000, y: -35_000, zoom: 24 },
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
for (const { name, w, h, isMobile, leafMinWidth } of canvasCases) {
|
|
465
|
+
const maxZoom = calculateMaxZoom(layout.groups, w)
|
|
466
|
+
const thresholds = getExpandThresholds(w)
|
|
467
|
+
for (const { viewId, elementId } of placementsIn(data)) {
|
|
468
|
+
const target = findElementFocusTarget(layout.groups, viewId, elementId)
|
|
469
|
+
expect(target, `${name} view ${viewId} element ${elementId} target`).not.toBeNull()
|
|
470
|
+
const viewport = viewportForElementFocusTarget(target!, w, h, maxZoom, isMobile)
|
|
471
|
+
expect(viewport, `${name} view ${viewId} element ${elementId} viewport`).not.toBeNull()
|
|
472
|
+
expectFiniteViewport(viewport!, `${name} view ${viewId} element ${elementId}`)
|
|
473
|
+
|
|
474
|
+
for (const current of currentViewports) {
|
|
475
|
+
const finalViewport = completeFocusNavigationFromCurrent(current, viewport!, w, h, layout.bbox)
|
|
476
|
+
const rect = screenRect(target!, finalViewport)
|
|
477
|
+
const context = `${name} from ${current.x}/${current.y}/${current.zoom} to view ${viewId} element ${elementId}`
|
|
478
|
+
expectScreenRectVisible(rect, w, h, context)
|
|
479
|
+
|
|
480
|
+
const expectedMinWidth = target!.node?.children.length ? Math.min(leafMinWidth, thresholds.start) : leafMinWidth
|
|
481
|
+
expect(rect.width, `${context} usable width`).toBeGreaterThanOrEqual(expectedMinWidth - 0.75)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('can navigate and zoom to every linked view target without losing the content center', () => {
|
|
488
|
+
const data = focusMatrixExploreData(6)
|
|
489
|
+
const layout = computeLayout(data)
|
|
490
|
+
const canvasW = 1200
|
|
491
|
+
const canvasH = 800
|
|
492
|
+
const maxZoom = calculateMaxZoom(layout.groups, canvasW)
|
|
493
|
+
|
|
494
|
+
for (const viewId of viewsIn(data)) {
|
|
495
|
+
const target = findDiagramFocusTarget(layout.groups, viewId)
|
|
496
|
+
expect(target, `view ${viewId} target`).not.toBeNull()
|
|
497
|
+
const viewport = viewportForDiagramFocusTarget(target!, canvasW, canvasH, maxZoom, false)
|
|
498
|
+
expect(viewport, `view ${viewId} viewport`).not.toBeNull()
|
|
499
|
+
expectFiniteViewport(viewport!, `view ${viewId}`)
|
|
500
|
+
|
|
501
|
+
const finalViewport = constrainViewState(viewport!, canvasW, canvasH, layout.bbox)
|
|
502
|
+
const rect = screenRect(target!, finalViewport)
|
|
503
|
+
expect(rect.width, `view ${viewId} target width`).toBeGreaterThan(0)
|
|
504
|
+
expect(rect.height, `view ${viewId} target height`).toBeGreaterThan(0)
|
|
505
|
+
expect((rect.left + rect.right) / 2, `view ${viewId} target center x`).toBeGreaterThanOrEqual(0)
|
|
506
|
+
expect((rect.left + rect.right) / 2, `view ${viewId} target center x`).toBeLessThanOrEqual(canvasW)
|
|
507
|
+
expect((rect.top + rect.bottom) / 2, `view ${viewId} target center y`).toBeGreaterThanOrEqual(0)
|
|
508
|
+
expect((rect.top + rect.bottom) / 2, `view ${viewId} target center y`).toBeLessThanOrEqual(canvasH)
|
|
509
|
+
|
|
510
|
+
if (target!.contentRect) {
|
|
511
|
+
const contentRect = worldScreenRect(target!.contentRect, finalViewport)
|
|
512
|
+
expect(contentRect.width, `view ${viewId} content width`).toBeGreaterThan(0)
|
|
513
|
+
expect(contentRect.height, `view ${viewId} content height`).toBeGreaterThan(0)
|
|
514
|
+
expect((contentRect.left + contentRect.right) / 2, `view ${viewId} content center x`).toBeGreaterThanOrEqual(0)
|
|
515
|
+
expect((contentRect.left + contentRect.right) / 2, `view ${viewId} content center x`).toBeLessThanOrEqual(canvasW)
|
|
516
|
+
expect((contentRect.top + contentRect.bottom) / 2, `view ${viewId} content center y`).toBeGreaterThanOrEqual(0)
|
|
517
|
+
expect((contentRect.top + contentRect.bottom) / 2, `view ${viewId} content center y`).toBeLessThanOrEqual(canvasH)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('keeps focus centering available when the canvas is smaller than the old fixed padding', () => {
|
|
523
|
+
const targetView = { x: 400, y: 300, zoom: 1 }
|
|
524
|
+
const constrained = constrainViewState(targetView, 1000, 800, {
|
|
525
|
+
minX: 0,
|
|
526
|
+
minY: 0,
|
|
527
|
+
maxX: 1600,
|
|
528
|
+
maxY: 1200,
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
expect(rawCameraView(constrained).x).toBeCloseTo(targetView.x)
|
|
532
|
+
expect(rawCameraView(constrained).y).toBeCloseTo(targetView.y)
|
|
533
|
+
})
|
|
534
|
+
})
|