pict-section-flow 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-section-flow",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Pict Section Flow Diagram",
5
5
  "main": "source/Pict-Section-Flow.js",
6
6
  "scripts": {
@@ -1,9 +1,10 @@
1
1
  const libCoerce = require('./Layout-Coerce.js');
2
+ const libRank = require('./Layout-Rank.js');
2
3
 
3
4
  /**
4
5
  * Layout-Layered
5
6
  *
6
- * Topological-sort (Kahn's algorithm) left-to-right layered layout.
7
+ * Cycle-tolerant left-to-right layered layout.
7
8
  *
8
9
  * This is the original `autoLayout` behavior of pict-section-flow,
9
10
  * extracted into a layout-algorithm descriptor. Calling
@@ -34,100 +35,45 @@ module.exports =
34
35
 
35
36
  let tmpConnections = Array.isArray(pConnections) ? pConnections : [];
36
37
 
37
- // Build adjacency information
38
38
  let tmpNodeMap = {};
39
- let tmpInDegree = {};
40
- let tmpOutEdges = {};
41
-
42
39
  for (let i = 0; i < pNodes.length; i++)
43
40
  {
44
- let tmpNode = pNodes[i];
45
- tmpNodeMap[tmpNode.Hash] = tmpNode;
46
- tmpInDegree[tmpNode.Hash] = 0;
47
- tmpOutEdges[tmpNode.Hash] = [];
48
- }
49
-
50
- for (let i = 0; i < tmpConnections.length; i++)
51
- {
52
- let tmpConn = tmpConnections[i];
53
- if (tmpInDegree.hasOwnProperty(tmpConn.TargetNodeHash))
54
- {
55
- tmpInDegree[tmpConn.TargetNodeHash]++;
56
- }
57
- if (tmpOutEdges.hasOwnProperty(tmpConn.SourceNodeHash))
58
- {
59
- tmpOutEdges[tmpConn.SourceNodeHash].push(tmpConn.TargetNodeHash);
60
- }
41
+ tmpNodeMap[pNodes[i].Hash] = pNodes[i];
61
42
  }
62
43
 
63
- // Topological sort (Kahn's algorithm)
64
- let tmpLayers = [];
65
- let tmpQueue = [];
66
- let tmpAssigned = {};
67
-
68
- for (let tmpHash in tmpInDegree)
44
+ // Rank the nodes into layers, left to right, with cycle breaking. The
45
+ // shared ranker keeps back-edged graphs (workflows, state machines) from
46
+ // collapsing into one tall column the way plain Kahn's topological sort
47
+ // would. See Layout-Rank.js.
48
+ let tmpLayers = libRank.toRanks(pNodes, tmpConnections);
49
+
50
+ // Measure each layer's stacked height so layers can be centered on one
51
+ // shared horizontal axis. Without centering every layer top-aligns at
52
+ // StartY, so a one-node layer beside a five-node layer reads as a
53
+ // diagonal drift down the page instead of a balanced band.
54
+ let tmpLayerHeights = [];
55
+ let tmpMaxLayerHeight = 0;
56
+ for (let l = 0; l < tmpLayers.length; l++)
69
57
  {
70
- if (tmpInDegree[tmpHash] === 0)
58
+ let tmpHeight = 0;
59
+ for (let i = 0; i < tmpLayers[l].length; i++)
71
60
  {
72
- tmpQueue.push(tmpHash);
61
+ let tmpNode = tmpNodeMap[tmpLayers[l][i]];
62
+ tmpHeight += (tmpNode && tmpNode.Height) ? tmpNode.Height : 80;
63
+ if (i > 0) tmpHeight += tmpVerticalSpacing;
73
64
  }
65
+ tmpLayerHeights.push(tmpHeight);
66
+ if (tmpHeight > tmpMaxLayerHeight) tmpMaxLayerHeight = tmpHeight;
74
67
  }
75
68
 
76
- while (tmpQueue.length > 0)
77
- {
78
- let tmpCurrentLayer = [];
79
- let tmpNextQueue = [];
80
-
81
- for (let i = 0; i < tmpQueue.length; i++)
82
- {
83
- let tmpNodeHash = tmpQueue[i];
84
- if (tmpAssigned[tmpNodeHash]) continue;
85
-
86
- tmpAssigned[tmpNodeHash] = true;
87
- tmpCurrentLayer.push(tmpNodeHash);
88
-
89
- let tmpEdges = tmpOutEdges[tmpNodeHash] || [];
90
- for (let j = 0; j < tmpEdges.length; j++)
91
- {
92
- let tmpTargetHash = tmpEdges[j];
93
- tmpInDegree[tmpTargetHash]--;
94
- if (tmpInDegree[tmpTargetHash] <= 0 && !tmpAssigned[tmpTargetHash])
95
- {
96
- tmpNextQueue.push(tmpTargetHash);
97
- }
98
- }
99
- }
100
-
101
- if (tmpCurrentLayer.length > 0)
102
- {
103
- tmpLayers.push(tmpCurrentLayer);
104
- }
105
-
106
- tmpQueue = tmpNextQueue;
107
- }
108
-
109
- // Handle cycles or disconnected nodes
110
- let tmpRemainingNodes = [];
111
- for (let i = 0; i < pNodes.length; i++)
112
- {
113
- if (!tmpAssigned[pNodes[i].Hash])
114
- {
115
- tmpRemainingNodes.push(pNodes[i].Hash);
116
- }
117
- }
118
- if (tmpRemainingNodes.length > 0)
119
- {
120
- tmpLayers.push(tmpRemainingNodes);
121
- }
122
-
123
- // Assign positions based on layers
69
+ // Assign positions: one column per layer, each layer vertically centered.
124
70
  let tmpCurrentX = tmpStartX;
125
71
 
126
72
  for (let tmpLayerIndex = 0; tmpLayerIndex < tmpLayers.length; tmpLayerIndex++)
127
73
  {
128
74
  let tmpLayer = tmpLayers[tmpLayerIndex];
129
75
  let tmpMaxWidth = 0;
130
- let tmpCurrentY = tmpStartY;
76
+ let tmpCurrentY = tmpStartY + ((tmpMaxLayerHeight - tmpLayerHeights[tmpLayerIndex]) / 2);
131
77
 
132
78
  for (let i = 0; i < tmpLayer.length; i++)
133
79
  {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Layout-Rank
3
+ *
4
+ * Shared cycle-tolerant topological ranking for the directed layouts
5
+ * (Layered, Staggered).
6
+ *
7
+ * Plain Kahn's topological sort drops every node that participates in a cycle
8
+ * into a single trailing rank. Any graph with back-edges (workflows, state
9
+ * machines: rejections, retries, "send back") therefore collapses into one
10
+ * tall stripe of unranked nodes, which is the historical "auto-layout bunches
11
+ * everything together and is useless" behavior.
12
+ *
13
+ * `toRanks` instead breaks each cycle at its most-resolved node: when a round
14
+ * finds nothing dependency-free but nodes remain, it forces the unassigned
15
+ * node with the fewest unmet predecessors into the next rank and continues.
16
+ * Cyclic graphs then rank left to right the same way a DAG does.
17
+ *
18
+ * Returns an array of ranks; each rank is an array of node Hashes in stable
19
+ * source order. Self-loops are ignored for ranking (they cannot define an
20
+ * order). Callers map Hashes back to node objects themselves.
21
+ */
22
+ function toRanks(pNodes, pConnections)
23
+ {
24
+ let tmpRanks = [];
25
+ if (!pNodes || pNodes.length === 0)
26
+ {
27
+ return tmpRanks;
28
+ }
29
+
30
+ let tmpConnections = Array.isArray(pConnections) ? pConnections : [];
31
+
32
+ let tmpInDegree = {};
33
+ let tmpOutEdges = {};
34
+ for (let i = 0; i < pNodes.length; i++)
35
+ {
36
+ tmpInDegree[pNodes[i].Hash] = 0;
37
+ tmpOutEdges[pNodes[i].Hash] = [];
38
+ }
39
+
40
+ for (let i = 0; i < tmpConnections.length; i++)
41
+ {
42
+ let tmpConn = tmpConnections[i];
43
+ // A self-loop cannot define a rank order; skip it.
44
+ if (tmpConn.SourceNodeHash === tmpConn.TargetNodeHash)
45
+ {
46
+ continue;
47
+ }
48
+ if (tmpInDegree.hasOwnProperty(tmpConn.TargetNodeHash))
49
+ {
50
+ tmpInDegree[tmpConn.TargetNodeHash]++;
51
+ }
52
+ if (tmpOutEdges.hasOwnProperty(tmpConn.SourceNodeHash))
53
+ {
54
+ tmpOutEdges[tmpConn.SourceNodeHash].push(tmpConn.TargetNodeHash);
55
+ }
56
+ }
57
+
58
+ let tmpAssigned = {};
59
+ let tmpAssignedCount = 0;
60
+
61
+ while (tmpAssignedCount < pNodes.length)
62
+ {
63
+ let tmpRank = [];
64
+
65
+ for (let i = 0; i < pNodes.length; i++)
66
+ {
67
+ let tmpHash = pNodes[i].Hash;
68
+ if (!tmpAssigned[tmpHash] && tmpInDegree[tmpHash] <= 0)
69
+ {
70
+ tmpRank.push(tmpHash);
71
+ }
72
+ }
73
+
74
+ if (tmpRank.length === 0)
75
+ {
76
+ // Cycle break: nothing is dependency-free, so force the unassigned
77
+ // node with the fewest remaining predecessors (ties keep source
78
+ // order, which keeps the result stable across runs).
79
+ let tmpMinDegree = Infinity;
80
+ let tmpPick = null;
81
+ for (let i = 0; i < pNodes.length; i++)
82
+ {
83
+ let tmpHash = pNodes[i].Hash;
84
+ if (!tmpAssigned[tmpHash] && tmpInDegree[tmpHash] < tmpMinDegree)
85
+ {
86
+ tmpMinDegree = tmpInDegree[tmpHash];
87
+ tmpPick = tmpHash;
88
+ }
89
+ }
90
+ if (tmpPick === null)
91
+ {
92
+ break; // safety; every node is already assigned
93
+ }
94
+ tmpRank.push(tmpPick);
95
+ }
96
+
97
+ // Commit the whole rank, then relax its out-edges so the next round
98
+ // sees the successors it just freed.
99
+ for (let i = 0; i < tmpRank.length; i++)
100
+ {
101
+ tmpAssigned[tmpRank[i]] = true;
102
+ tmpAssignedCount++;
103
+ }
104
+ for (let i = 0; i < tmpRank.length; i++)
105
+ {
106
+ let tmpEdges = tmpOutEdges[tmpRank[i]] || [];
107
+ for (let j = 0; j < tmpEdges.length; j++)
108
+ {
109
+ tmpInDegree[tmpEdges[j]]--;
110
+ }
111
+ }
112
+
113
+ tmpRanks.push(tmpRank);
114
+ }
115
+
116
+ return tmpRanks;
117
+ }
118
+
119
+ /**
120
+ * Flatten ranks to a single ordered list of node Hashes (rank by rank, source
121
+ * order within a rank). Convenience for layouts that walk one sequence.
122
+ */
123
+ function toOrder(pNodes, pConnections)
124
+ {
125
+ let tmpRanks = toRanks(pNodes, pConnections);
126
+ let tmpOrder = [];
127
+ for (let i = 0; i < tmpRanks.length; i++)
128
+ {
129
+ for (let j = 0; j < tmpRanks[i].length; j++)
130
+ {
131
+ tmpOrder.push(tmpRanks[i][j]);
132
+ }
133
+ }
134
+ return tmpOrder;
135
+ }
136
+
137
+ module.exports =
138
+ {
139
+ toRanks: toRanks,
140
+ toOrder: toOrder
141
+ };
@@ -0,0 +1,131 @@
1
+ const libCoerce = require('./Layout-Coerce.js');
2
+ const libRank = require('./Layout-Rank.js');
3
+
4
+ /**
5
+ * Layout-Staggered
6
+ *
7
+ * Serpentine "stairstep" layout for directed graphs. It ranks the nodes left
8
+ * to right by connection topology (the same cycle-tolerant ranking the Layered
9
+ * layout uses), then walks that ordered sequence along a band that steps down a
10
+ * few rows and back up, advancing horizontally at every node.
11
+ *
12
+ * The point is vertical-space efficiency: a long directed chain (a workflow, a
13
+ * delivery pipeline, a state machine) laid out purely left to right runs off
14
+ * the right edge of the canvas. Folding it into a stairstep band keeps the
15
+ * left-to-right reading order while using the height of the viewport, so the
16
+ * whole graph frames at a usable zoom.
17
+ *
18
+ * `Rows` sets how deep the band steps before folding back: 2 is a simple
19
+ * zigzag (odd nodes high, even nodes low); 3+ is a deeper stairstep. The row
20
+ * index follows a triangle wave so the band descends and then climbs rather
21
+ * than snapping back to the top between runs.
22
+ *
23
+ * Numeric parameters are typed `PreciseNumber` so they survive ExpressionParser
24
+ * solver chains; Layout-Coerce converts them back to JS floats at entry.
25
+ */
26
+ module.exports =
27
+ {
28
+ Name: 'Staggered',
29
+ Label: 'Staggered (Stairstep)',
30
+ Description: 'Topological order folded along a serpentine stairstep band.',
31
+ DefaultEdgeTheme: 'Perimeter',
32
+
33
+ Apply: function (pNodes, pConnections, pParameters)
34
+ {
35
+ if (!pNodes || pNodes.length === 0) return;
36
+
37
+ let tmpParams = pParameters || {};
38
+ let tmpSpacing = libCoerce.toFloat(tmpParams.Spacing, 1.0);
39
+ let tmpRows = Math.max(1, libCoerce.toInt(tmpParams.Rows, 2));
40
+ let tmpColumnSpacing = libCoerce.toFloat(tmpParams.ColumnSpacing, 80) * tmpSpacing;
41
+ let tmpRowOffset = libCoerce.toFloat(tmpParams.RowOffset, 150) * tmpSpacing;
42
+ let tmpStartX = libCoerce.toFloat(tmpParams.StartX, 80);
43
+ let tmpStartY = libCoerce.toFloat(tmpParams.StartY, 80);
44
+
45
+ let tmpConnections = Array.isArray(pConnections) ? pConnections : [];
46
+
47
+ let tmpNodeMap = {};
48
+ for (let i = 0; i < pNodes.length; i++)
49
+ {
50
+ tmpNodeMap[pNodes[i].Hash] = pNodes[i];
51
+ }
52
+
53
+ let tmpOrder = libRank.toOrder(pNodes, tmpConnections);
54
+
55
+ // A uniform column pitch (the widest node plus the spacing) keeps the
56
+ // stairstep diagonal even regardless of individual node widths.
57
+ let tmpMaxWidth = 0;
58
+ for (let i = 0; i < pNodes.length; i++)
59
+ {
60
+ tmpMaxWidth = Math.max(tmpMaxWidth, pNodes[i].Width || 180);
61
+ }
62
+ let tmpColumnPitch = tmpMaxWidth + tmpColumnSpacing;
63
+
64
+ // Triangle wave: descend (Rows - 1) steps, then climb (Rows - 1) steps.
65
+ let tmpPeriod = (tmpRows > 1) ? (2 * (tmpRows - 1)) : 1;
66
+
67
+ for (let i = 0; i < tmpOrder.length; i++)
68
+ {
69
+ let tmpNode = tmpNodeMap[tmpOrder[i]];
70
+ if (!tmpNode) continue;
71
+
72
+ let tmpRow;
73
+ if (tmpRows <= 1)
74
+ {
75
+ tmpRow = 0;
76
+ }
77
+ else
78
+ {
79
+ let tmpPhase = i % tmpPeriod;
80
+ tmpRow = (tmpPhase < tmpRows) ? tmpPhase : (tmpPeriod - tmpPhase);
81
+ }
82
+
83
+ tmpNode.X = tmpStartX + (i * tmpColumnPitch);
84
+ tmpNode.Y = tmpStartY + (tmpRow * tmpRowOffset);
85
+ }
86
+ },
87
+
88
+ DefaultParameters:
89
+ {
90
+ Spacing: 1.0,
91
+ Rows: 2,
92
+ ColumnSpacing: 80,
93
+ RowOffset: 150,
94
+ StartX: 80,
95
+ StartY: 80
96
+ },
97
+
98
+ ParameterSchema:
99
+ {
100
+ Spacing: { Type: 'PreciseNumber', Label: 'Spacing (multiplier)', Default: 1.0, Min: 0.1, Max: 5 },
101
+ Rows: { Type: 'Number', Label: 'Rows', Default: 2, Min: 1, Max: 12 },
102
+ ColumnSpacing: { Type: 'PreciseNumber', Label: 'Column spacing', Default: 80, Min: 0, Max: 1000 },
103
+ RowOffset: { Type: 'PreciseNumber', Label: 'Row offset', Default: 150, Min: 0, Max: 1000 },
104
+ StartX: { Type: 'PreciseNumber', Label: 'Start X', Default: 80, Min: -10000, Max: 10000 },
105
+ StartY: { Type: 'PreciseNumber', Label: 'Start Y', Default: 80, Min: -10000, Max: 10000 }
106
+ },
107
+
108
+ ParameterManifest:
109
+ {
110
+ Scope: 'PictFlowLayout-Staggered',
111
+ Sections:
112
+ [
113
+ { Name: 'Staggered Parameters', Hash: 'PFLStaggeredSection', Groups: [{ Name: 'Defaults', Hash: 'PFLStaggeredGroup' }] }
114
+ ],
115
+ Descriptors:
116
+ {
117
+ 'PictFlowLayoutEditor.Parameters.Spacing':
118
+ { Name: 'Spacing (multiplier)', Hash: 'Spacing', DataType: 'PreciseNumber', Default: 1.0, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 0, Width: 6, Min: 0.1, Max: 5 } },
119
+ 'PictFlowLayoutEditor.Parameters.Rows':
120
+ { Name: 'Rows', Hash: 'Rows', DataType: 'Number', Default: 2, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 0, Width: 6, Min: 1, Max: 12 } },
121
+ 'PictFlowLayoutEditor.Parameters.ColumnSpacing':
122
+ { Name: 'Column spacing', Hash: 'ColumnSpacing', DataType: 'PreciseNumber', Default: 80, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 1, Width: 6, Min: 0, Max: 1000 } },
123
+ 'PictFlowLayoutEditor.Parameters.RowOffset':
124
+ { Name: 'Row offset', Hash: 'RowOffset', DataType: 'PreciseNumber', Default: 150, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 1, Width: 6, Min: 0, Max: 1000 } },
125
+ 'PictFlowLayoutEditor.Parameters.StartX':
126
+ { Name: 'Start X', Hash: 'StartX', DataType: 'PreciseNumber', Default: 80, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 2, Width: 6, Min: -10000, Max: 10000 } },
127
+ 'PictFlowLayoutEditor.Parameters.StartY':
128
+ { Name: 'Start Y', Hash: 'StartY', DataType: 'PreciseNumber', Default: 80, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 2, Width: 6, Min: -10000, Max: 10000 } }
129
+ }
130
+ }
131
+ };
@@ -2,6 +2,7 @@ const libFableServiceProviderBase = require('fable-serviceproviderbase');
2
2
 
3
3
  const libLayoutCustom = require('../providers/layouts/Layout-Custom.js');
4
4
  const libLayoutLayered = require('../providers/layouts/Layout-Layered.js');
5
+ const libLayoutStaggered = require('../providers/layouts/Layout-Staggered.js');
5
6
  const libLayoutForcedFromCenter = require('../providers/layouts/Layout-ForcedFromCenter.js');
6
7
  const libLayoutGrid = require('../providers/layouts/Layout-Grid.js');
7
8
  const libLayoutCircular = require('../providers/layouts/Layout-Circular.js');
@@ -20,6 +21,7 @@ const _BUILTIN_ALGORITHMS =
20
21
  [
21
22
  libLayoutCustom,
22
23
  libLayoutLayered,
24
+ libLayoutStaggered,
23
25
  libLayoutForcedFromCenter,
24
26
  libLayoutGrid,
25
27
  libLayoutCircular,
@@ -529,8 +529,44 @@ class PictViewFlowNode extends libPictView
529
529
  * @param {number} pTitleBarHeight
530
530
  * @param {Object} pNodeTypeConfig
531
531
  */
532
+ // Append a CSS fragment to an SVG element's inline style (which wins over the stylesheet), keeping
533
+ // any existing inline style.
534
+ _appendElementStyle(pElement, pStyleFragment)
535
+ {
536
+ let tmpExisting = pElement.getAttribute('style') || '';
537
+ if (tmpExisting && tmpExisting.charAt(tmpExisting.length - 1) !== ';') { tmpExisting += ';'; }
538
+ pElement.setAttribute('style', tmpExisting + pStyleFragment);
539
+ }
540
+
541
+ /**
542
+ * Height of the strip that squares off the bottom corners of the title bar. It must cover the
543
+ * bottom rounded corners (so the title bar meets the body in a straight seam) but must never reach
544
+ * the top ones, so it is capped at half the title bar height. Without the cap a corner radius
545
+ * larger than the title bar (a capsule card: radius 24 on a 22px bar) yields a strip taller than
546
+ * the whole title bar, which paints over the rounded TOP corners and makes the card read as square
547
+ * on top and rounded only on the bottom.
548
+ *
549
+ * @param {number|null} pCornerRadius - the card corner radius, or null for the theme default
550
+ * @param {number} pTitleBarHeight - the title bar height in user units
551
+ * @returns {number}
552
+ */
553
+ static titleBarBottomStripHeight(pCornerRadius, pTitleBarHeight)
554
+ {
555
+ let tmpRadius = (typeof pCornerRadius === 'number') ? pCornerRadius : 0;
556
+ return Math.min(Math.max(8, tmpRadius), Math.floor(pTitleBarHeight / 2));
557
+ }
558
+
532
559
  _renderRectNodeBody(pGroup, pNodeData, pWidth, pHeight, pTitleBarHeight, pNodeTypeConfig)
533
560
  {
561
+ // Per-card corner radius (a node-data or node-type override of the theme default), so a card
562
+ // type can read as a rounded rectangle, a capsule, or a sharp box. null leaves the
563
+ // theme/CSS default in place. Set as the --pf-node-body-radius custom property on the node
564
+ // group; the body and title-bar both read it through their `rx: var(--pf-node-body-radius)`,
565
+ // and it inherits to both, so one assignment rounds the whole card.
566
+ let tmpCornerRadius = (typeof pNodeData.CornerRadius === 'number') ? pNodeData.CornerRadius
567
+ : ((pNodeTypeConfig && typeof pNodeTypeConfig.CornerRadius === 'number') ? pNodeTypeConfig.CornerRadius : null);
568
+ if (tmpCornerRadius != null) { this._appendElementStyle(pGroup, '--pf-node-body-radius:' + tmpCornerRadius + 'px'); }
569
+
534
570
  // Node body (main rectangle)
535
571
  let tmpBody = this._FlowView._SVGHelperProvider.createSVGElement('rect');
536
572
  tmpBody.setAttribute('class', 'pict-flow-node-body');
@@ -598,16 +634,17 @@ class PictViewFlowNode extends libPictView
598
634
  {
599
635
  tmpTitleBar.setAttribute('fill', pNodeData.TitleBarColor);
600
636
  }
601
-
602
637
  pGroup.appendChild(tmpTitleBar);
603
638
 
604
- // Title bar bottom fill (to square off the rounded corners at the bottom of the title bar)
639
+ // Title bar bottom fill: squares off the rounded corners at the bottom of the title bar so it
640
+ // meets the body in a straight seam. See titleBarBottomStripHeight for why it is capped.
641
+ let tmpBottomStripHeight = PictViewFlowNode.titleBarBottomStripHeight(tmpCornerRadius, pTitleBarHeight);
605
642
  let tmpTitleBarBottom = this._FlowView._SVGHelperProvider.createSVGElement('rect');
606
643
  tmpTitleBarBottom.setAttribute('class', 'pict-flow-node-title-bar-bottom');
607
644
  tmpTitleBarBottom.setAttribute('x', '0');
608
- tmpTitleBarBottom.setAttribute('y', String(pTitleBarHeight - 8));
645
+ tmpTitleBarBottom.setAttribute('y', String(pTitleBarHeight - tmpBottomStripHeight));
609
646
  tmpTitleBarBottom.setAttribute('width', String(pWidth));
610
- tmpTitleBarBottom.setAttribute('height', '8');
647
+ tmpTitleBarBottom.setAttribute('height', String(tmpBottomStripHeight));
611
648
  tmpTitleBarBottom.setAttribute('data-node-hash', pNodeData.Hash);
612
649
  tmpTitleBarBottom.setAttribute('data-element-type', 'node-body');
613
650
 
@@ -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,49 @@
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
+ });