@ankhorage/zora 22.1.2 → 22.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.d.ts +3 -3
  4. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js +3 -3
  5. package/dist/features/graph-view/adapters/inbound/web-artifact/GraphView.js.map +1 -1
  6. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.d.ts +6 -5
  7. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.d.ts.map +1 -1
  8. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.js +107 -171
  9. package/dist/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.js.map +1 -1
  10. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts +4 -0
  11. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.d.ts.map +1 -1
  12. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js +8 -0
  13. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphController.js.map +1 -1
  14. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js +5 -1
  15. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.js.map +1 -1
  16. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.d.ts +9 -0
  17. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.d.ts.map +1 -0
  18. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.js +234 -0
  19. package/dist/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.js.map +1 -0
  20. package/dist/types/graph-view-spacing.d.ts +15 -0
  21. package/dist/types/graph-view-spacing.d.ts.map +1 -0
  22. package/dist/types/graph-view-spacing.js +2 -0
  23. package/dist/types/graph-view-spacing.js.map +1 -0
  24. package/dist/types/graph-view.d.ts +4 -1
  25. package/dist/types/graph-view.d.ts.map +1 -1
  26. package/dist/types/graph-view.js.map +1 -1
  27. package/package.json +2 -2
  28. package/src/features/graph-view/adapters/inbound/web-artifact/GraphView.tsx +3 -3
  29. package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.test.ts +195 -69
  30. package/src/features/graph-view/adapters/inbound/web-artifact/compactGraphSpacing.ts +162 -241
  31. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.test.ts +2 -0
  32. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphController.ts +8 -0
  33. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphRuntime.ts +5 -1
  34. package/src/features/graph-view/adapters/inbound/web-artifact/createGraphSpacingEvaluator.ts +338 -0
  35. package/src/types/graph-view-spacing.ts +16 -0
  36. package/src/types/graph-view.ts +4 -1
  37. package/web-dist/components/graph-view/GraphView.d.ts +3 -3
  38. package/web-dist/components/graph-view/GraphView.js +237 -126
  39. package/web-dist/components/graph-view/GraphView.types-0.d.ts +4 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/zora",
3
3
  "type": "module",
4
- "version": "22.1.2",
4
+ "version": "22.1.3",
5
5
  "description": "Opinionated React Native and React Native Web UI kit built on @ankhorage/surface.",
6
6
  "homepage": "https://github.com/ankhorage/zora#readme",
7
7
  "bugs": {
@@ -124,7 +124,7 @@
124
124
  "version-packages": "ankhorage-changeset version"
125
125
  },
126
126
  "devDependencies": {
127
- "@ankhorage/devtools": "^2.1.7",
127
+ "@ankhorage/devtools": "^2.1.8",
128
128
  "@ankhorage/paradox": "^0.2.27",
129
129
  "@react-native-vector-icons/fontawesome": "^13.1.4",
130
130
  "@react-native-vector-icons/fontawesome5": "^13.1.4",
@@ -21,9 +21,9 @@ type GraphContainer = NonNullable<CytoscapeOptions['container']>;
21
21
  * minReadableLabelSize Expand the manual zoom ceiling until plain labels reach this CSS-pixel size.
22
22
  * maxFitLabelSize Cap automatic fitting at this plain-label CSS-pixel size, leaving whitespace for tiny graphs.
23
23
  * onSpacingFactorChange Receive the spacing accepted by explicit fit({ optimizeSpacing: true }).
24
- * Ordinary zoom never adjusts spacing or relayouts. Explicit optimization preserves layout ordering
25
- * and uses bounded collision checks including labels and compound siblings; it can recover uniformly
26
- * cramped layouts but cannot repair non-uniform overlaps.
24
+ * Ordinary zoom never adjusts spacing or relayouts. Explicit optimization preserves layout ordering,
25
+ * stops once effective fit reaches maxFitLabelSize, keeps labels and unrelated compound boundaries
26
+ * collision-safe, and permits only a small rendered background-overlap fallback for dense graphs.
27
27
  */
28
28
  export function GraphView(props: GraphViewProps) {
29
29
  const { containerRef, renderedNodes, runtimeRef } = useGraphViewRuntime(props);
@@ -1,113 +1,239 @@
1
1
  import { expect, test } from 'bun:test';
2
- import cytoscape, { type NodeSingular } from 'cytoscape';
2
+ import cytoscape, { type Core, type CytoscapeOptions, type NodeSingular } from 'cytoscape';
3
3
 
4
4
  import { compactGraphSpacing } from './compactGraphSpacing';
5
5
 
6
- test('compacts around measured labels while allowing node backgrounds to overlap', () => {
7
- const cy = cytoscape({
8
- headless: true,
9
- styleEnabled: true,
10
- layout: { name: 'preset' },
11
- elements: [
6
+ const FIT_OPTIONS = { fitPadding: 50, maxFitZoom: 1, minReadableZoom: 1 } as const;
7
+
8
+ test('keeps an already readable graph unchanged once the fit-size target is reached', () => {
9
+ const cy = createGraph(
10
+ [
11
+ { data: { id: 'a' }, position: { x: 0, y: 0 } },
12
+ { data: { id: 'b' }, position: { x: 300, y: 0 } },
13
+ ],
14
+ 800,
15
+ 600,
16
+ );
17
+ try {
18
+ const before = cy.getElementById('b').position();
19
+ const spacing = compactGraphSpacing(cy, 1, { ...FIT_OPTIONS, maxFitZoom: 1.5 });
20
+ expect(spacing).toBe(1);
21
+ expect(cy.getElementById('b').position()).toEqual(before);
22
+ } finally {
23
+ cy.destroy();
24
+ }
25
+ });
26
+
27
+ test('compacts only until the effective fit reaches its cap while preserving node separation', () => {
28
+ const cy = createGraph(
29
+ [
12
30
  { data: { id: 'a' }, position: { x: 0, y: 0 } },
13
- { data: { id: 'b' }, position: { x: 200, y: 0 } },
31
+ { data: { id: 'b' }, position: { x: 1000, y: 0 } },
14
32
  { data: { id: 'e', source: 'a', target: 'b', weight: 7 } },
15
33
  ],
16
- style: [{ selector: 'node', style: { width: 100, height: 40, padding: '0px' } }],
17
- });
34
+ 800,
35
+ 600,
36
+ );
18
37
  try {
19
- const a = cy.getElementById('a');
20
- const b = cy.getElementById('b');
21
- installMeasuredLabelBox(a, 40, 20);
22
- installMeasuredLabelBox(b, 40, 20);
23
-
24
- const factor = compactGraphSpacing(cy, 1);
25
- expect(factor).toBeGreaterThan(0.17);
26
- expect(factor).toBeLessThan(0.19);
27
- expect(b.boundingBox().x1 - a.boundingBox().x2).toBeLessThan(0);
28
-
29
- const labelA = readLabelBox(a);
30
- const labelB = readLabelBox(b);
31
- const overlap = Math.min(labelA.x2, labelB.x2) - Math.max(labelA.x1, labelB.x1);
32
- expect(overlap).toBeGreaterThan(0);
33
- expect(overlap).toBeLessThanOrEqual(40 * 0.12 + 0.2);
38
+ const spacing = compactGraphSpacing(cy, 1, FIT_OPTIONS);
39
+ const first = cy.getElementById('a').boundingBox();
40
+ const second = cy.getElementById('b').boundingBox();
41
+ expect(spacing).toBeGreaterThan(0.59);
42
+ expect(spacing).toBeLessThan(0.61);
43
+ expect(second.x1 - first.x2).toBeGreaterThan(0);
34
44
  expect(cy.edges()[0].data('weight')).toBe(7);
35
- expect(a.width()).toBe(100);
45
+ expect(cy.getElementById('a').width()).toBe(100);
36
46
  } finally {
37
47
  cy.destroy();
38
48
  }
39
49
  });
40
50
 
41
- test('expands a uniformly cramped layout only until measured labels are readable', () => {
42
- const cy = cytoscape({
43
- headless: true,
44
- styleEnabled: true,
45
- layout: { name: 'preset' },
46
- elements: [
51
+ test('keeps strict separation when it can preserve the readable-label threshold', () => {
52
+ const cy = createGraph(
53
+ [
54
+ { data: { id: 'a' }, position: { x: 0, y: 0 } },
55
+ { data: { id: 'b' }, position: { x: 120, y: 0 } },
56
+ ],
57
+ 340,
58
+ 400,
59
+ );
60
+ installMeasuredLabelBox(cy.getElementById('a'), 40, 20);
61
+ installMeasuredLabelBox(cy.getElementById('b'), 40, 20);
62
+
63
+ try {
64
+ const spacing = compactGraphSpacing(cy, 1, {
65
+ fitPadding: 50,
66
+ maxFitZoom: 1.5,
67
+ minReadableZoom: 1.1,
68
+ });
69
+ const first = cy.getElementById('a').boundingBox({ includeLabels: false });
70
+ const second = cy.getElementById('b').boundingBox({ includeLabels: false });
71
+ expect(spacing).toBeGreaterThan(0.8);
72
+ expect(spacing).toBeLessThan(0.9);
73
+ expect(Math.min(first.x2, second.x2) - Math.max(first.x1, second.x1)).toBeLessThanOrEqual(0);
74
+ } finally {
75
+ cy.destroy();
76
+ }
77
+ });
78
+
79
+ test('uses a small rendered background-overlap budget only when strict separation cannot reach the fit target', () => {
80
+ const cy = createGraph(
81
+ [
82
+ { data: { id: 'a' }, position: { x: 0, y: 0 } },
83
+ { data: { id: 'b' }, position: { x: 120, y: 0 } },
84
+ ],
85
+ 296,
86
+ 400,
87
+ );
88
+ installMeasuredLabelBox(cy.getElementById('a'), 40, 20);
89
+ installMeasuredLabelBox(cy.getElementById('b'), 40, 20);
90
+ try {
91
+ const spacing = compactGraphSpacing(cy, 1, FIT_OPTIONS);
92
+ const first = cy.getElementById('a').boundingBox({ includeLabels: false });
93
+ const second = cy.getElementById('b').boundingBox({ includeLabels: false });
94
+ const visualOverlap = Math.min(first.x2, second.x2) - Math.max(first.x1, second.x1);
95
+ const firstLabel = readLabelBox(cy.getElementById('a'));
96
+ const secondLabel = readLabelBox(cy.getElementById('b'));
97
+ const labelOverlap =
98
+ Math.min(firstLabel.x2, secondLabel.x2) - Math.max(firstLabel.x1, secondLabel.x1);
99
+
100
+ expect(spacing).toBeGreaterThan(0.79);
101
+ expect(spacing).toBeLessThan(0.84);
102
+ expect(visualOverlap).toBeGreaterThan(0);
103
+ expect(visualOverlap).toBeLessThanOrEqual(4.2);
104
+ expect(labelOverlap).toBeLessThanOrEqual(0);
105
+ } finally {
106
+ cy.destroy();
107
+ }
108
+ });
109
+
110
+ test('expands cramped labels and nodes when the viewport can keep them separated at the fit target', () => {
111
+ const cy = createGraph(
112
+ [
47
113
  { data: { id: 'a' }, position: { x: 0, y: 0 } },
48
114
  { data: { id: 'b' }, position: { x: 20, y: 0 } },
49
115
  ],
50
- style: [{ selector: 'node', style: { width: 100, height: 40, padding: '0px' } }],
51
- });
116
+ 800,
117
+ 600,
118
+ );
119
+ installMeasuredLabelBox(cy.getElementById('a'), 40, 20);
120
+ installMeasuredLabelBox(cy.getElementById('b'), 40, 20);
52
121
  try {
53
- installMeasuredLabelBox(cy.getElementById('a'), 40, 20);
54
- installMeasuredLabelBox(cy.getElementById('b'), 40, 20);
55
-
56
- const factor = compactGraphSpacing(cy, 0.1);
57
- expect(factor).toBeGreaterThan(0.17);
58
- expect(factor).toBeLessThan(0.19);
59
- const labelA = readLabelBox(cy.getElementById('a'));
60
- const labelB = readLabelBox(cy.getElementById('b'));
61
- expect(Math.min(labelA.x2, labelB.x2) - Math.max(labelA.x1, labelB.x1)).toBeLessThanOrEqual(
62
- 40 * 0.12 + 0.2,
63
- );
122
+ const spacing = compactGraphSpacing(cy, 0.1, FIT_OPTIONS);
123
+ const first = cy.getElementById('a').boundingBox({ includeLabels: false });
124
+ const second = cy.getElementById('b').boundingBox({ includeLabels: false });
125
+ expect(spacing).toBeGreaterThanOrEqual(0.49);
126
+ expect(second.x1 - first.x2).toBeGreaterThanOrEqual(-0.2);
127
+ expect(
128
+ Math.min(readLabelBox(cy.getElementById('a')).x2, readLabelBox(cy.getElementById('b')).x2) -
129
+ Math.max(readLabelBox(cy.getElementById('a')).x1, readLabelBox(cy.getElementById('b')).x1),
130
+ ).toBeLessThanOrEqual(0);
64
131
  } finally {
65
132
  cy.destroy();
66
133
  }
67
134
  });
68
135
 
69
- test('preserves locked or non-uniformly overlapping views', () => {
70
- for (const locked of [false, true]) {
71
- const cy = cytoscape({
72
- headless: true,
73
- styleEnabled: true,
74
- layout: { name: 'preset' },
75
- elements: [
76
- { data: { id: 'a' }, position: { x: 0, y: 0 }, locked },
77
- { data: { id: 'b' }, position: { x: locked ? 500 : 0, y: 0 } },
78
- ],
79
- });
80
- try {
81
- expect(compactGraphSpacing(cy, 1)).toBe(1);
82
- } finally {
83
- cy.destroy();
136
+ test('keeps mixed-width Atlas-like compound geometry readable while using available viewport', () => {
137
+ const cy = createGraph(
138
+ [
139
+ { data: { id: 'group' } },
140
+ { data: { id: 'theme', parent: 'group' }, position: { x: 0, y: 0 } },
141
+ { data: { id: 'workspace', parent: 'group' }, position: { x: 360, y: 0 } },
142
+ { data: { id: 'project-tree' }, position: { x: 760, y: 0 } },
143
+ { data: { id: 'project-source' }, position: { x: 1180, y: 0 } },
144
+ ],
145
+ 1600,
146
+ 700,
147
+ );
148
+ const widths = new Map([
149
+ ['theme', 180],
150
+ ['workspace', 220],
151
+ ['project-tree', 260],
152
+ ['project-source', 300],
153
+ ]);
154
+ for (const [id, width] of widths) {
155
+ cy.getElementById(id).style({ width, height: 64 });
156
+ installMeasuredLabelBox(cy.getElementById(id), width - 32, 24);
157
+ }
158
+
159
+ try {
160
+ const spacing = compactGraphSpacing(cy, 1, { fitPadding: 50, maxFitZoom: 1.4 });
161
+ expect(spacing).toBeLessThan(1);
162
+ expect(spacing).toBeGreaterThan(0.6);
163
+
164
+ const leaves = ['theme', 'workspace', 'project-tree', 'project-source'].map((id) =>
165
+ cy.getElementById(id).boundingBox({ includeLabels: false }),
166
+ );
167
+ for (const [index, first] of leaves.entries()) {
168
+ for (const second of leaves.slice(index + 1)) {
169
+ const overlapWidth = Math.min(first.x2, second.x2) - Math.max(first.x1, second.x1);
170
+ const overlapHeight = Math.min(first.y2, second.y2) - Math.max(first.y1, second.y1);
171
+ expect(overlapWidth > 0 && overlapHeight > 0).toBe(false);
172
+ }
84
173
  }
174
+ expect(cy.getElementById('theme').data('parent')).toBe('group');
175
+ expect(cy.getElementById('workspace').data('parent')).toBe('group');
176
+ } finally {
177
+ cy.destroy();
85
178
  }
86
179
  });
87
180
 
88
- test('allows parent-child containment but keeps compound peer groups separated', () => {
89
- const cy = cytoscape({
90
- headless: true,
91
- styleEnabled: true,
92
- layout: { name: 'preset' },
93
- elements: [
181
+ test('preserves compound containment while keeping peer groups separated', () => {
182
+ const cy = createGraph(
183
+ [
94
184
  { data: { id: 'p' } },
95
185
  { data: { id: 'p.a', parent: 'p' }, position: { x: 0, y: 0 } },
96
186
  { data: { id: 'q' } },
97
187
  { data: { id: 'q.a', parent: 'q' }, position: { x: 800, y: 0 } },
98
188
  ],
99
- });
189
+ 1000,
190
+ 600,
191
+ );
100
192
  try {
101
- expect(compactGraphSpacing(cy, 1)).toBeLessThan(1);
193
+ expect(compactGraphSpacing(cy, 1, { fitPadding: 50 })).toBeLessThan(1);
102
194
  expect(
103
195
  cy.getElementById('q').boundingBox().x1 - cy.getElementById('p').boundingBox().x2,
104
- ).toBeGreaterThanOrEqual(0);
196
+ ).toBeGreaterThanOrEqual(-0.2);
105
197
  expect(cy.getElementById('p.a').data('parent')).toBe('p');
106
198
  } finally {
107
199
  cy.destroy();
108
200
  }
109
201
  });
110
202
 
203
+ test('is idempotent after a capped optimized fit', () => {
204
+ const cy = createGraph(
205
+ [
206
+ { data: { id: 'a' }, position: { x: 0, y: 0 } },
207
+ { data: { id: 'b' }, position: { x: 1000, y: 0 } },
208
+ ],
209
+ 800,
210
+ 600,
211
+ );
212
+ try {
213
+ const firstSpacing = compactGraphSpacing(cy, 1, FIT_OPTIONS);
214
+ const firstPosition = { ...cy.getElementById('b').position() };
215
+ const secondSpacing = compactGraphSpacing(cy, firstSpacing, FIT_OPTIONS);
216
+ expect(secondSpacing).toBe(firstSpacing);
217
+ expect(cy.getElementById('b').position()).toEqual(firstPosition);
218
+ } finally {
219
+ cy.destroy();
220
+ }
221
+ });
222
+
223
+ /*** Create one measured headless graph with stable viewport and visual node dimensions. */
224
+ function createGraph(elements: CytoscapeOptions['elements'], width: number, height: number): Core {
225
+ const cy = cytoscape({
226
+ headless: true,
227
+ styleEnabled: true,
228
+ layout: { name: 'preset' },
229
+ elements,
230
+ style: [{ selector: 'node', style: { width: 100, height: 40, padding: '0px' } }],
231
+ });
232
+ cy.width = () => width;
233
+ cy.height = () => height;
234
+ return cy;
235
+ }
236
+
111
237
  /*** Install a dynamic renderer-style label box for headless Cytoscape tests. */
112
238
  function installMeasuredLabelBox(node: NodeSingular, width: number, height: number): void {
113
239
  const originalBoundingBox = node.boundingBox.bind(node);