pict-section-flow 1.1.0 → 1.3.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.
@@ -6,6 +6,8 @@ const libLayoutService = require('../source/services/PictService-Flow-Layout.js'
6
6
 
7
7
  const libLayoutCustom = require('../source/providers/layouts/Layout-Custom.js');
8
8
  const libLayoutLayered = require('../source/providers/layouts/Layout-Layered.js');
9
+ const libLayoutStaggered = require('../source/providers/layouts/Layout-Staggered.js');
10
+ const libLayoutRank = require('../source/providers/layouts/Layout-Rank.js');
9
11
  const libLayoutForcedFromCenter = require('../source/providers/layouts/Layout-ForcedFromCenter.js');
10
12
  const libLayoutGrid = require('../source/providers/layouts/Layout-Grid.js');
11
13
  const libLayoutCircular = require('../source/providers/layouts/Layout-Circular.js');
@@ -86,15 +88,15 @@ suite
86
88
 
87
89
  test
88
90
  (
89
- 'should register all seven built-in algorithms by default',
91
+ 'should register all eight built-in algorithms by default',
90
92
  function (fDone)
91
93
  {
92
94
  let tmpNames = _LayoutService.getAlgorithmNames();
93
95
  libExpect(tmpNames).to.include.members([
94
- 'Custom', 'Layered', 'ForcedFromCenter',
96
+ 'Custom', 'Layered', 'Staggered', 'ForcedFromCenter',
95
97
  'Grid', 'Circular', 'Tabular', 'Columnar'
96
98
  ]);
97
- libExpect(tmpNames.length).to.equal(7);
99
+ libExpect(tmpNames.length).to.equal(8);
98
100
  fDone();
99
101
  }
100
102
  );
@@ -184,9 +186,10 @@ suite
184
186
  function (fDone)
185
187
  {
186
188
  let tmpAll = _LayoutService.listAlgorithms();
187
- libExpect(tmpAll.length).to.equal(7);
189
+ libExpect(tmpAll.length).to.equal(8);
188
190
  let tmpNames = tmpAll.map((pA) => pA.Name);
189
191
  libExpect(tmpNames).to.include('Custom');
192
+ libExpect(tmpNames).to.include('Staggered');
190
193
  libExpect(tmpNames).to.include('ForcedFromCenter');
191
194
  fDone();
192
195
  }
@@ -315,6 +318,207 @@ suite
315
318
  fDone();
316
319
  }
317
320
  );
321
+
322
+ test
323
+ (
324
+ 'a back-edge cycle does NOT collapse into one column (regression)',
325
+ function (fDone)
326
+ {
327
+ // n0 -> n1 -> n2 -> n3 -> n4 with a back-edge n4 -> n1.
328
+ // Plain Kahn's would place n0, then dump n1..n4 into a single
329
+ // trailing layer (one tall column). The cycle-tolerant ranker
330
+ // must spread them across columns instead.
331
+ let tmpNodes = makeNodes(5);
332
+ let tmpConns = makeChain(5);
333
+ tmpConns.push({ Hash: 'c-back', SourceNodeHash: 'n-4', TargetNodeHash: 'n-1' });
334
+
335
+ libLayoutLayered.Apply(tmpNodes, tmpConns, libLayoutLayered.DefaultParameters);
336
+
337
+ let tmpColumns = {};
338
+ let tmpMaxPerColumn = 0;
339
+ for (let i = 0; i < tmpNodes.length; i++)
340
+ {
341
+ let tmpX = tmpNodes[i].X;
342
+ tmpColumns[tmpX] = (tmpColumns[tmpX] || 0) + 1;
343
+ tmpMaxPerColumn = Math.max(tmpMaxPerColumn, tmpColumns[tmpX]);
344
+ }
345
+ // Five distinct columns, one node each — no tower.
346
+ libExpect(Object.keys(tmpColumns).length).to.equal(5);
347
+ libExpect(tmpMaxPerColumn).to.equal(1);
348
+ fDone();
349
+ }
350
+ );
351
+
352
+ test
353
+ (
354
+ 'a self-loop does not strand a node in a trailing column',
355
+ function (fDone)
356
+ {
357
+ // n0 -> n1 -> n2 with a self-loop on n1.
358
+ let tmpNodes = makeNodes(3);
359
+ let tmpConns = makeChain(3);
360
+ tmpConns.push({ Hash: 'c-self', SourceNodeHash: 'n-1', TargetNodeHash: 'n-1' });
361
+
362
+ libLayoutLayered.Apply(tmpNodes, tmpConns, libLayoutLayered.DefaultParameters);
363
+
364
+ // Clean chain: three columns left to right, one node each.
365
+ libExpect(tmpNodes[0].X).to.be.below(tmpNodes[1].X);
366
+ libExpect(tmpNodes[1].X).to.be.below(tmpNodes[2].X);
367
+ fDone();
368
+ }
369
+ );
370
+ }
371
+ );
372
+
373
+ // ── Rank (shared ranker) ──────────────────────────────────────
374
+
375
+ suite
376
+ (
377
+ 'Layout-Rank ranker',
378
+ function ()
379
+ {
380
+ test
381
+ (
382
+ 'a chain ranks one node per rank, in order',
383
+ function (fDone)
384
+ {
385
+ let tmpNodes = makeNodes(4);
386
+ let tmpRanks = libLayoutRank.toRanks(tmpNodes, makeChain(4));
387
+ libExpect(tmpRanks.length).to.equal(4);
388
+ libExpect(tmpRanks[0]).to.deep.equal(['n-0']);
389
+ libExpect(tmpRanks[3]).to.deep.equal(['n-3']);
390
+ fDone();
391
+ }
392
+ );
393
+
394
+ test
395
+ (
396
+ 'unconnected nodes share the first rank',
397
+ function (fDone)
398
+ {
399
+ let tmpNodes = makeNodes(3);
400
+ let tmpRanks = libLayoutRank.toRanks(tmpNodes, []);
401
+ libExpect(tmpRanks.length).to.equal(1);
402
+ libExpect(tmpRanks[0].length).to.equal(3);
403
+ fDone();
404
+ }
405
+ );
406
+
407
+ test
408
+ (
409
+ 'toOrder visits every node exactly once even with a cycle',
410
+ function (fDone)
411
+ {
412
+ let tmpNodes = makeNodes(5);
413
+ let tmpConns = makeChain(5);
414
+ tmpConns.push({ Hash: 'c-back', SourceNodeHash: 'n-4', TargetNodeHash: 'n-1' });
415
+ let tmpOrder = libLayoutRank.toOrder(tmpNodes, tmpConns);
416
+ libExpect(tmpOrder.length).to.equal(5);
417
+ let tmpSeen = {};
418
+ for (let i = 0; i < tmpOrder.length; i++) tmpSeen[tmpOrder[i]] = true;
419
+ libExpect(Object.keys(tmpSeen).length).to.equal(5);
420
+ fDone();
421
+ }
422
+ );
423
+
424
+ test
425
+ (
426
+ 'empty input returns an empty rank list',
427
+ function (fDone)
428
+ {
429
+ libExpect(libLayoutRank.toRanks([], [])).to.deep.equal([]);
430
+ libExpect(libLayoutRank.toOrder(null, null)).to.deep.equal([]);
431
+ fDone();
432
+ }
433
+ );
434
+ }
435
+ );
436
+
437
+ // ── Staggered ─────────────────────────────────────────────────
438
+
439
+ suite
440
+ (
441
+ 'Staggered algorithm',
442
+ function ()
443
+ {
444
+ test
445
+ (
446
+ 'two rows zigzag: X strictly increases, Y alternates',
447
+ function (fDone)
448
+ {
449
+ let tmpNodes = makeNodes(4);
450
+ let tmpConns = makeChain(4);
451
+ libLayoutStaggered.Apply(tmpNodes, tmpConns, { Rows: 2, ColumnSpacing: 80, RowOffset: 150, StartX: 0, StartY: 0 });
452
+
453
+ // Topological order is n0..n3; column pitch = 180 + 80 = 260.
454
+ libExpect(tmpNodes[0].X).to.equal(0);
455
+ libExpect(tmpNodes[1].X).to.equal(260);
456
+ libExpect(tmpNodes[2].X).to.equal(520);
457
+ libExpect(tmpNodes[3].X).to.equal(780);
458
+ // Rows=2 → row pattern 0,1,0,1 → Y 0,150,0,150.
459
+ libExpect(tmpNodes[0].Y).to.equal(0);
460
+ libExpect(tmpNodes[1].Y).to.equal(150);
461
+ libExpect(tmpNodes[2].Y).to.equal(0);
462
+ libExpect(tmpNodes[3].Y).to.equal(150);
463
+ fDone();
464
+ }
465
+ );
466
+
467
+ test
468
+ (
469
+ 'three rows make a triangle-wave stairstep (down then up)',
470
+ function (fDone)
471
+ {
472
+ let tmpNodes = makeNodes(6);
473
+ let tmpConns = makeChain(6);
474
+ libLayoutStaggered.Apply(tmpNodes, tmpConns, { Rows: 3, RowOffset: 100, StartX: 0, StartY: 0 });
475
+
476
+ // period = 4 → row phases 0,1,2,1,0,1 → Y 0,100,200,100,0,100.
477
+ let tmpRows = tmpNodes.map((pN) => pN.Y / 100);
478
+ libExpect(tmpRows).to.deep.equal([0, 1, 2, 1, 0, 1]);
479
+ fDone();
480
+ }
481
+ );
482
+
483
+ test
484
+ (
485
+ 'column pitch follows the widest node',
486
+ function (fDone)
487
+ {
488
+ let tmpNodes = makeNodes(3);
489
+ tmpNodes[1].Width = 400; // widest
490
+ libLayoutStaggered.Apply(tmpNodes, makeChain(3), { ColumnSpacing: 50, StartX: 0 });
491
+ // pitch = 400 + 50 = 450
492
+ libExpect(tmpNodes[1].X).to.equal(450);
493
+ libExpect(tmpNodes[2].X).to.equal(900);
494
+ fDone();
495
+ }
496
+ );
497
+
498
+ test
499
+ (
500
+ 'Rows=1 places every node on a single row',
501
+ function (fDone)
502
+ {
503
+ let tmpNodes = makeNodes(4);
504
+ libLayoutStaggered.Apply(tmpNodes, makeChain(4), { Rows: 1, StartY: 42 });
505
+ for (let i = 0; i < tmpNodes.length; i++)
506
+ {
507
+ libExpect(tmpNodes[i].Y).to.equal(42);
508
+ }
509
+ fDone();
510
+ }
511
+ );
512
+
513
+ test
514
+ (
515
+ 'empty node list does not throw',
516
+ function (fDone)
517
+ {
518
+ libExpect(function () { libLayoutStaggered.Apply([], [], {}); }).to.not.throw();
519
+ fDone();
520
+ }
521
+ );
318
522
  }
319
523
  );
320
524
 
@@ -0,0 +1,66 @@
1
+ const libChai = require('chai');
2
+ const libExpect = libChai.expect;
3
+
4
+ const libPictViewFlowNode = require('../source/views/PictView-Flow-Node.js');
5
+
6
+ suite('PictView-Flow-Node',
7
+ function ()
8
+ {
9
+ // The title-bar bottom strip squares off the title bar's lower corners. The regression it guards
10
+ // against: a corner radius larger than the title bar made the strip taller than the whole title
11
+ // bar, so it painted over the rounded TOP corners and the card read as square on top (only the
12
+ // bottom rounded). See titleBarBottomStripHeight.
13
+ suite('titleBarBottomStripHeight',
14
+ function ()
15
+ {
16
+ test('never exceeds half the title bar height, even for a capsule radius',
17
+ function ()
18
+ {
19
+ // radius 24 on a 22px title bar must not produce a 24px strip (which would cover the top)
20
+ libExpect(libPictViewFlowNode.titleBarBottomStripHeight(24, 22)).to.equal(11);
21
+ libExpect(libPictViewFlowNode.titleBarBottomStripHeight(100, 30)).to.equal(15);
22
+ });
23
+
24
+ test('covers small radii with the 8px floor',
25
+ function ()
26
+ {
27
+ libExpect(libPictViewFlowNode.titleBarBottomStripHeight(5, 22)).to.equal(8);
28
+ libExpect(libPictViewFlowNode.titleBarBottomStripHeight(0, 22)).to.equal(8);
29
+ });
30
+
31
+ test('treats a null/absent radius as no override (8px floor)',
32
+ function ()
33
+ {
34
+ libExpect(libPictViewFlowNode.titleBarBottomStripHeight(null, 22)).to.equal(8);
35
+ libExpect(libPictViewFlowNode.titleBarBottomStripHeight(undefined, 22)).to.equal(8);
36
+ });
37
+
38
+ test('the strip is always at most the title bar height for any radius',
39
+ function ()
40
+ {
41
+ let tmpTitleBarHeight = 22;
42
+ for (let tmpRadius = 0; tmpRadius <= 60; tmpRadius++)
43
+ {
44
+ let tmpStrip = libPictViewFlowNode.titleBarBottomStripHeight(tmpRadius, tmpTitleBarHeight);
45
+ libExpect(tmpStrip).to.be.at.most(Math.floor(tmpTitleBarHeight / 2));
46
+ }
47
+ });
48
+ });
49
+
50
+ suite('nodeTransform',
51
+ function ()
52
+ {
53
+ test('a zero / absent rotation is a plain position translate',
54
+ function ()
55
+ {
56
+ libExpect(libPictViewFlowNode.nodeTransform(40, 60, 0, 100, 80)).to.equal('translate(40, 60)');
57
+ libExpect(libPictViewFlowNode.nodeTransform(40, 60, null, 100, 80)).to.equal('translate(40, 60)');
58
+ });
59
+
60
+ test('a non-zero rotation rotates about the node center',
61
+ function ()
62
+ {
63
+ libExpect(libPictViewFlowNode.nodeTransform(40, 60, 15, 100, 80)).to.equal('translate(40, 60) rotate(15 50 40)');
64
+ });
65
+ });
66
+ });
@@ -0,0 +1,185 @@
1
+ const libFable = require('fable');
2
+ const libChai = require('chai');
3
+ const libExpect = libChai.expect;
4
+
5
+ const libSelectionManager = require('../source/services/PictService-Flow-SelectionManager.js');
6
+
7
+ // A minimal FlowView stand-in: just the surface the selection manager touches.
8
+ function makeMockFlowView(pNodes)
9
+ {
10
+ let tmpFired = [];
11
+ let tmpRemoved = [];
12
+ return {
13
+ _FlowData:
14
+ {
15
+ Nodes: pNodes.slice(),
16
+ Connections: [],
17
+ OpenPanels: [],
18
+ ViewState: { SelectedNodeHash: null, SelectedNodeHashes: [], SelectedConnectionHash: null, SelectedTetherHash: null }
19
+ },
20
+ renderFlow: function () { this._rendered = (this._rendered || 0) + 1; },
21
+ removeNode: function (pHash) { tmpRemoved.push(pHash); this._FlowData.Nodes = this._FlowData.Nodes.filter((n) => n.Hash !== pHash); return true; },
22
+ removeConnection: function (pHash) { tmpRemoved.push('conn:' + pHash); return true; },
23
+ _EventHandlerProvider: { fireEvent: function (pName, pPayload) { tmpFired.push({ Name: pName, Payload: pPayload }); } },
24
+ _firedEvents: tmpFired,
25
+ _removed: tmpRemoved
26
+ };
27
+ }
28
+
29
+ function makeManager(pFable, pNodes)
30
+ {
31
+ let tmpFV = makeMockFlowView(pNodes);
32
+ let tmpSM = new libSelectionManager(pFable, { FlowView: tmpFV }, 'SM-Test');
33
+ return { sm: tmpSM, fv: tmpFV, vs: tmpFV._FlowData.ViewState };
34
+ }
35
+
36
+ suite('PictService-Flow-SelectionManager',
37
+ function ()
38
+ {
39
+ let _Fable;
40
+ let _Nodes;
41
+ setup(function ()
42
+ {
43
+ _Fable = new libFable({});
44
+ _Nodes = [ { Hash: 'n1', X: 0, Y: 0, Width: 100, Height: 80 }, { Hash: 'n2', X: 200, Y: 0, Width: 100, Height: 80 }, { Hash: 'n3', X: 400, Y: 0, Width: 100, Height: 80 } ];
45
+ });
46
+
47
+ suite('single selection keeps the set in lockstep',
48
+ function ()
49
+ {
50
+ test('selectNode sets the primary and a one-element set',
51
+ function ()
52
+ {
53
+ let tmp = makeManager(_Fable, _Nodes);
54
+ tmp.sm.selectNode('n2');
55
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n2');
56
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n2']);
57
+ });
58
+
59
+ test('selectNode(null) clears both the primary and the set',
60
+ function ()
61
+ {
62
+ let tmp = makeManager(_Fable, _Nodes);
63
+ tmp.sm.selectNode('n2');
64
+ tmp.sm.selectNode(null);
65
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
66
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
67
+ });
68
+ });
69
+
70
+ suite('toggleNodeSelection',
71
+ function ()
72
+ {
73
+ test('adds a node, then a second, then removes the first',
74
+ function ()
75
+ {
76
+ let tmp = makeManager(_Fable, _Nodes);
77
+ tmp.sm.toggleNodeSelection('n1');
78
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n1']);
79
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n1');
80
+
81
+ tmp.sm.toggleNodeSelection('n3');
82
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n1', 'n3']);
83
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n3');
84
+
85
+ tmp.sm.toggleNodeSelection('n1');
86
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n3']);
87
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n3');
88
+ });
89
+
90
+ test('toggling the last member empties the set and nulls the primary',
91
+ function ()
92
+ {
93
+ let tmp = makeManager(_Fable, _Nodes);
94
+ tmp.sm.toggleNodeSelection('n1');
95
+ tmp.sm.toggleNodeSelection('n1');
96
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
97
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
98
+ });
99
+ });
100
+
101
+ suite('selectNodes',
102
+ function ()
103
+ {
104
+ test('replaces the set and sets the primary to the last hash',
105
+ function ()
106
+ {
107
+ let tmp = makeManager(_Fable, _Nodes);
108
+ tmp.sm.selectNode('n1');
109
+ tmp.sm.selectNodes(['n2', 'n3']);
110
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n2', 'n3']);
111
+ libExpect(tmp.vs.SelectedNodeHash).to.equal('n3');
112
+ });
113
+
114
+ test('an empty array clears the selection',
115
+ function ()
116
+ {
117
+ let tmp = makeManager(_Fable, _Nodes);
118
+ tmp.sm.selectNodes(['n1', 'n2']);
119
+ tmp.sm.selectNodes([]);
120
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
121
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
122
+ });
123
+
124
+ test('getSelectedNodeHashes returns a copy (mutating it does not change state)',
125
+ function ()
126
+ {
127
+ let tmp = makeManager(_Fable, _Nodes);
128
+ tmp.sm.selectNodes(['n1', 'n2']);
129
+ let tmpCopy = tmp.sm.getSelectedNodeHashes();
130
+ tmpCopy.push('n3');
131
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal(['n1', 'n2']);
132
+ });
133
+ });
134
+
135
+ suite('deleteSelected (multi)',
136
+ function ()
137
+ {
138
+ test('removes every node in the selection set',
139
+ function ()
140
+ {
141
+ let tmp = makeManager(_Fable, _Nodes);
142
+ tmp.sm.selectNodes(['n1', 'n3']);
143
+ let tmpResult = tmp.sm.deleteSelected();
144
+ libExpect(tmpResult).to.equal(true);
145
+ libExpect(tmp.fv._removed).to.deep.equal(['n1', 'n3']);
146
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
147
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
148
+ });
149
+
150
+ test('falls back to the single primary when the set is empty',
151
+ function ()
152
+ {
153
+ let tmp = makeManager(_Fable, _Nodes);
154
+ tmp.fv._FlowData.ViewState.SelectedNodeHash = 'n2';
155
+ tmp.fv._FlowData.ViewState.SelectedNodeHashes = [];
156
+ tmp.sm.deleteSelected();
157
+ libExpect(tmp.fv._removed).to.deep.equal(['n2']);
158
+ });
159
+
160
+ test('deletes the selected connection when no nodes are selected',
161
+ function ()
162
+ {
163
+ let tmp = makeManager(_Fable, _Nodes);
164
+ tmp.fv._FlowData.ViewState.SelectedConnectionHash = 'c1';
165
+ tmp.sm.deleteSelected();
166
+ libExpect(tmp.fv._removed).to.deep.equal(['conn:c1']);
167
+ });
168
+ });
169
+
170
+ suite('deselectAll',
171
+ function ()
172
+ {
173
+ test('clears the primary, the set, and the connection/tether selections',
174
+ function ()
175
+ {
176
+ let tmp = makeManager(_Fable, _Nodes);
177
+ tmp.sm.selectNodes(['n1', 'n2']);
178
+ tmp.fv._FlowData.ViewState.SelectedConnectionHash = 'c1';
179
+ tmp.sm.deselectAll();
180
+ libExpect(tmp.vs.SelectedNodeHash).to.equal(null);
181
+ libExpect(tmp.vs.SelectedNodeHashes).to.deep.equal([]);
182
+ libExpect(tmp.vs.SelectedConnectionHash).to.equal(null);
183
+ });
184
+ });
185
+ });