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 +1 -1
- package/source/providers/layouts/Layout-Layered.js +25 -79
- package/source/providers/layouts/Layout-Rank.js +141 -0
- package/source/providers/layouts/Layout-Staggered.js +131 -0
- package/source/services/PictService-Flow-Layout.js +2 -0
- package/source/views/PictView-Flow-Node.js +41 -4
- package/test/Layout_tests.js +208 -4
- package/test/NodeView_tests.js +49 -0
package/package.json
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
58
|
+
let tmpHeight = 0;
|
|
59
|
+
for (let i = 0; i < tmpLayers[l].length; i++)
|
|
71
60
|
{
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
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 -
|
|
645
|
+
tmpTitleBarBottom.setAttribute('y', String(pTitleBarHeight - tmpBottomStripHeight));
|
|
609
646
|
tmpTitleBarBottom.setAttribute('width', String(pWidth));
|
|
610
|
-
tmpTitleBarBottom.setAttribute('height',
|
|
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
|
|
package/test/Layout_tests.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
+
});
|