pict-section-flow 1.0.1 → 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/README.md +44 -13
- package/docs/Architecture.md +8 -148
- package/docs/Data_Model.md +2 -11
- package/docs/README.md +8 -38
- package/docs/Theme_Integration.md +11 -11
- package/docs/_cover.md +7 -1
- package/docs/_playground.json +24 -0
- package/docs/_sidebar.md +4 -0
- package/docs/_topbar.md +1 -1
- package/docs/_version.json +3 -3
- package/docs/card-help/FREAD.md +1 -1
- package/docs/diagrams/architecture-at-a-glance.excalidraw +4270 -0
- package/docs/diagrams/architecture-at-a-glance.mmd +30 -0
- package/docs/diagrams/architecture-at-a-glance.svg +2 -0
- package/docs/diagrams/data-flow.excalidraw +1451 -0
- package/docs/diagrams/data-flow.mmd +17 -0
- package/docs/diagrams/data-flow.svg +2 -0
- package/docs/diagrams/high-level-design.excalidraw +5767 -0
- package/docs/diagrams/high-level-design.mmd +86 -0
- package/docs/diagrams/high-level-design.svg +2 -0
- package/docs/diagrams/relationships.excalidraw +3852 -0
- package/docs/diagrams/relationships.mmd +9 -0
- package/docs/diagrams/relationships.svg +2 -0
- package/docs/diagrams/service-initialization-sequence.excalidraw +1466 -0
- package/docs/diagrams/service-initialization-sequence.mmd +19 -0
- package/docs/diagrams/service-initialization-sequence.svg +2 -0
- package/docs/diagrams/svg-layer-structure.excalidraw +1060 -0
- package/docs/diagrams/svg-layer-structure.mmd +18 -0
- package/docs/diagrams/svg-layer-structure.svg +2 -0
- package/docs/examples/README.md +9 -0
- package/docs/examples/simple_cards/README.md +677 -0
- package/docs/examples/simple_cards/css/flowexample.css +65 -0
- package/docs/examples/simple_cards/index.html +32 -0
- package/docs/examples/simple_cards/js/pict.min.js +12 -0
- package/docs/examples/simple_cards/pict-section-flow-example-simple-cards.compatible.min.js +1 -0
- package/docs/index.html +6 -5
- package/docs/playground/app.json +6 -0
- package/docs/playground/appdata.json +85 -0
- package/docs/playground/application.js +23 -0
- package/docs/playground/pict.json +17 -0
- package/docs/playground/runtime/pict-application.min.js +2 -0
- package/docs/playground/runtime/pict-section-flow.min.js +2 -0
- package/docs/playground/runtime/pict-section-modal.min.js +2 -0
- package/docs/playground/runtime/pict.min.js +12 -0
- package/docs/retold-catalog.json +241 -166
- package/docs/retold-keyword-index.json +19312 -7226
- package/example_applications/simple_cards/package.json +9 -1
- package/example_applications/simple_cards/source/views/PictView-FlowExample-BottomBar.js +2 -2
- package/package.json +5 -5
- package/source/providers/PictProvider-Flow-PanelChrome.js +2 -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-DataManager.js +6 -0
- package/source/services/PictService-Flow-InteractionManager.js +10 -1
- package/source/services/PictService-Flow-Layout.js +2 -0
- package/source/services/PictService-Flow-PanelManager.js +106 -2
- package/source/views/PictView-Flow-Node.js +41 -4
- package/source/views/PictView-Flow-PropertiesPanel.js +70 -3
- package/source/views/PictView-Flow.js +53 -0
- package/test/Layout_tests.js +208 -4
- package/test/NodeView_tests.js +49 -0
- package/test/PanelManager_tests.js +172 -0
|
@@ -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
|
|
|
@@ -242,6 +242,13 @@ class PictViewFlowPropertiesPanel extends libPictView
|
|
|
242
242
|
*/
|
|
243
243
|
_renderPanelContent(pPanelData, pBodyContainer)
|
|
244
244
|
{
|
|
245
|
+
// Connection (edge) panels resolve their config and data differently from node panels.
|
|
246
|
+
if (pPanelData.ConnectionHash)
|
|
247
|
+
{
|
|
248
|
+
this._renderConnectionPanelContent(pPanelData, pBodyContainer);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
245
252
|
let tmpNodeData = this._FlowView.getNode(pPanelData.NodeHash);
|
|
246
253
|
if (!tmpNodeData) return;
|
|
247
254
|
|
|
@@ -298,6 +305,54 @@ class PictViewFlowPropertiesPanel extends libPictView
|
|
|
298
305
|
this._renderPortSummary(pBodyContainer, tmpNodeTypeConfig);
|
|
299
306
|
}
|
|
300
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Render the content of a connection (edge) panel. The config is the FlowView's single
|
|
310
|
+
* ConnectionPropertiesPanel (connections are not typed); the panel-type instance renders
|
|
311
|
+
* against the connection object, so a Form panel edits Connection.Data.* and a Template panel
|
|
312
|
+
* renders against the connection.
|
|
313
|
+
*
|
|
314
|
+
* @param {Object} pPanelData
|
|
315
|
+
* @param {HTMLDivElement} pBodyContainer
|
|
316
|
+
*/
|
|
317
|
+
_renderConnectionPanelContent(pPanelData, pBodyContainer)
|
|
318
|
+
{
|
|
319
|
+
let tmpConnectionData = this._FlowView.getConnection(pPanelData.ConnectionHash);
|
|
320
|
+
if (!tmpConnectionData) return;
|
|
321
|
+
|
|
322
|
+
let tmpPanelConfig = this._FlowView.options.ConnectionPropertiesPanel;
|
|
323
|
+
if (!tmpPanelConfig)
|
|
324
|
+
{
|
|
325
|
+
pBodyContainer.innerHTML = '<em>No connection properties panel configured.</em>';
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let tmpPanelType = tmpPanelConfig.PanelType || 'Base';
|
|
330
|
+
let tmpServiceName = `PictFlowCardPropertiesPanel-${tmpPanelType}`;
|
|
331
|
+
let tmpInstance = this._PanelInstances[pPanelData.Hash];
|
|
332
|
+
|
|
333
|
+
if (!tmpInstance)
|
|
334
|
+
{
|
|
335
|
+
if (this.fable.servicesMap.hasOwnProperty(tmpServiceName))
|
|
336
|
+
{
|
|
337
|
+
tmpInstance = this.fable.instantiateServiceProviderWithoutRegistration(tmpServiceName, tmpPanelConfig);
|
|
338
|
+
}
|
|
339
|
+
else if (this.fable.servicesMap.hasOwnProperty('PictFlowCardPropertiesPanel'))
|
|
340
|
+
{
|
|
341
|
+
tmpInstance = this.fable.instantiateServiceProviderWithoutRegistration('PictFlowCardPropertiesPanel', tmpPanelConfig);
|
|
342
|
+
}
|
|
343
|
+
if (tmpInstance)
|
|
344
|
+
{
|
|
345
|
+
tmpInstance._FlowView = this._FlowView;
|
|
346
|
+
this._PanelInstances[pPanelData.Hash] = tmpInstance;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (tmpInstance)
|
|
351
|
+
{
|
|
352
|
+
tmpInstance.render(pBodyContainer, tmpConnectionData);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
301
356
|
/**
|
|
302
357
|
* Render an auto-generated info panel for nodes without a configured PropertiesPanel.
|
|
303
358
|
* Shows the node type, description, and a summary of input/output ports with
|
|
@@ -716,11 +771,23 @@ class PictViewFlowPropertiesPanel extends libPictView
|
|
|
716
771
|
let tmpTetherService = this._FlowView._TetherService;
|
|
717
772
|
if (!tmpTetherService) return;
|
|
718
773
|
|
|
719
|
-
|
|
720
|
-
|
|
774
|
+
// A connection panel tethers to the edge midpoint; model it as a zero-size anchor at that
|
|
775
|
+
// point so the same tether geometry applies. A node panel tethers to its node.
|
|
776
|
+
let tmpAnchorData;
|
|
777
|
+
if (pPanelData.ConnectionHash)
|
|
778
|
+
{
|
|
779
|
+
let tmpMidpoint = this._FlowView.getConnectionMidpoint(pPanelData.ConnectionHash);
|
|
780
|
+
if (!tmpMidpoint) return;
|
|
781
|
+
tmpAnchorData = { X: tmpMidpoint.x, Y: tmpMidpoint.y, Width: 0, Height: 0 };
|
|
782
|
+
}
|
|
783
|
+
else
|
|
784
|
+
{
|
|
785
|
+
tmpAnchorData = this._FlowView.getNode(pPanelData.NodeHash);
|
|
786
|
+
if (!tmpAnchorData) return;
|
|
787
|
+
}
|
|
721
788
|
|
|
722
789
|
let tmpViewIdentifier = this._FlowView.options.ViewIdentifier;
|
|
723
|
-
tmpTetherService.renderTether(pPanelData,
|
|
790
|
+
tmpTetherService.renderTether(pPanelData, tmpAnchorData, pTethersLayer, pIsSelected, tmpViewIdentifier);
|
|
724
791
|
}
|
|
725
792
|
|
|
726
793
|
/**
|
|
@@ -71,6 +71,11 @@ const _DefaultConfiguration =
|
|
|
71
71
|
DefaultNodeWidth: 180,
|
|
72
72
|
DefaultNodeHeight: 80,
|
|
73
73
|
|
|
74
|
+
// Properties panel for connections (edges). Connections are not typed, so one config serves
|
|
75
|
+
// them all: { PanelType, DefaultWidth, DefaultHeight, Title, Configuration }. When set, a
|
|
76
|
+
// double-click on a connection opens this panel; when false, double-click adds a bezier handle.
|
|
77
|
+
ConnectionPropertiesPanel: false,
|
|
78
|
+
|
|
74
79
|
// Layout-algorithm subsystem defaults
|
|
75
80
|
DefaultLayoutAlgorithm: 'Custom',
|
|
76
81
|
DefaultLayoutParameters: {},
|
|
@@ -1328,6 +1333,54 @@ class PictViewFlow extends libPictView
|
|
|
1328
1333
|
return this._PanelManager.togglePanel(pNodeHash);
|
|
1329
1334
|
}
|
|
1330
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* Open a properties panel for a connection (edge). Requires the ConnectionPropertiesPanel
|
|
1338
|
+
* option; returns false otherwise.
|
|
1339
|
+
* @param {string} pConnectionHash
|
|
1340
|
+
* @returns {Object|false}
|
|
1341
|
+
*/
|
|
1342
|
+
openConnectionPanel(pConnectionHash)
|
|
1343
|
+
{
|
|
1344
|
+
return this._PanelManager.openConnectionPanel(pConnectionHash);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Toggle a properties panel for a connection.
|
|
1349
|
+
* @param {string} pConnectionHash
|
|
1350
|
+
* @returns {Object|false}
|
|
1351
|
+
*/
|
|
1352
|
+
toggleConnectionPanel(pConnectionHash)
|
|
1353
|
+
{
|
|
1354
|
+
return this._PanelManager.toggleConnectionPanel(pConnectionHash);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Close all panels for a given connection.
|
|
1359
|
+
* @param {string} pConnectionHash
|
|
1360
|
+
* @returns {boolean}
|
|
1361
|
+
*/
|
|
1362
|
+
closePanelForConnection(pConnectionHash)
|
|
1363
|
+
{
|
|
1364
|
+
return this._PanelManager.closePanelForConnection(pConnectionHash);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* The midpoint of a connection in SVG coordinates, averaged from its two endpoint ports. Used
|
|
1369
|
+
* to place and tether a connection's properties panel. Returns null if the connection or
|
|
1370
|
+
* either port can not be resolved.
|
|
1371
|
+
* @param {string} pConnectionHash
|
|
1372
|
+
* @returns {{x: number, y: number}|null}
|
|
1373
|
+
*/
|
|
1374
|
+
getConnectionMidpoint(pConnectionHash)
|
|
1375
|
+
{
|
|
1376
|
+
let tmpConnection = this.getConnection(pConnectionHash);
|
|
1377
|
+
if (!tmpConnection) return null;
|
|
1378
|
+
let tmpSource = this.getPortPosition(tmpConnection.SourceNodeHash, tmpConnection.SourcePortHash);
|
|
1379
|
+
let tmpTarget = this.getPortPosition(tmpConnection.TargetNodeHash, tmpConnection.TargetPortHash);
|
|
1380
|
+
if (!tmpSource || !tmpTarget) return null;
|
|
1381
|
+
return { x: (tmpSource.x + tmpTarget.x) / 2, y: (tmpSource.y + tmpTarget.y) / 2 };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1331
1384
|
/**
|
|
1332
1385
|
* Update a panel's position (for drag).
|
|
1333
1386
|
* @param {string} pPanelHash
|
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
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const libFable = require('fable');
|
|
2
|
+
const libChai = require('chai');
|
|
3
|
+
const libExpect = libChai.expect;
|
|
4
|
+
|
|
5
|
+
const libPanelManager = require('../source/services/PictService-Flow-PanelManager.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Connection (edge) properties panels. The node-panel path is well covered through the view; these
|
|
9
|
+
* focus on the connection additions: gating on ConnectionPropertiesPanel, placement near the edge
|
|
10
|
+
* midpoint, the open/toggle/close lifecycle, and that node panels are not disturbed.
|
|
11
|
+
*/
|
|
12
|
+
suite
|
|
13
|
+
(
|
|
14
|
+
'PictService-Flow-PanelManager (connection panels)',
|
|
15
|
+
function ()
|
|
16
|
+
{
|
|
17
|
+
let _Fable;
|
|
18
|
+
let _PanelManager;
|
|
19
|
+
let _MockFlowView;
|
|
20
|
+
|
|
21
|
+
setup
|
|
22
|
+
(
|
|
23
|
+
function ()
|
|
24
|
+
{
|
|
25
|
+
_Fable = new libFable({});
|
|
26
|
+
|
|
27
|
+
_MockFlowView =
|
|
28
|
+
{
|
|
29
|
+
fable: _Fable,
|
|
30
|
+
log: _Fable.log,
|
|
31
|
+
options:
|
|
32
|
+
{
|
|
33
|
+
ViewIdentifier: 'Test-Flow',
|
|
34
|
+
ConnectionPropertiesPanel: false
|
|
35
|
+
},
|
|
36
|
+
_FlowData:
|
|
37
|
+
{
|
|
38
|
+
Nodes:
|
|
39
|
+
[
|
|
40
|
+
{ Hash: 'n1', Type: 'state', X: 0, Y: 0, Width: 100, Height: 60, Ports: [ { Hash: 'n1-out', Direction: 'output' } ] },
|
|
41
|
+
{ Hash: 'n2', Type: 'state', X: 300, Y: 0, Width: 100, Height: 60, Ports: [ { Hash: 'n2-in', Direction: 'input' } ] }
|
|
42
|
+
],
|
|
43
|
+
Connections:
|
|
44
|
+
[
|
|
45
|
+
{ Hash: 'c1', SourceNodeHash: 'n1', SourcePortHash: 'n1-out', TargetNodeHash: 'n2', TargetPortHash: 'n2-in', Data: {} }
|
|
46
|
+
],
|
|
47
|
+
OpenPanels: [],
|
|
48
|
+
ViewState: { SelectedTetherHash: null }
|
|
49
|
+
},
|
|
50
|
+
getConnection: function (pHash) { return this._FlowData.Connections.find((pConn) => pConn.Hash === pHash) || null; },
|
|
51
|
+
getNode: function (pHash) { return this._FlowData.Nodes.find((pNode) => pNode.Hash === pHash) || null; },
|
|
52
|
+
getConnectionMidpoint: function (pHash) { return this.getConnection(pHash) ? { x: 200, y: 30 } : null; },
|
|
53
|
+
_NodeTypeProvider:
|
|
54
|
+
{
|
|
55
|
+
getNodeType: function () { return { Label: 'State', PropertiesPanel: { PanelType: 'Form', DefaultWidth: 300, DefaultHeight: 220, Title: 'State' } }; }
|
|
56
|
+
},
|
|
57
|
+
renderFlow: function () {},
|
|
58
|
+
marshalFromView: function () {},
|
|
59
|
+
_PropertiesPanelView: { destroyPanel: function () {} },
|
|
60
|
+
_EventHandlerProvider: { fireEvent: function () {} }
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
_PanelManager = new libPanelManager(_Fable, { FlowView: _MockFlowView }, 'PM-Test');
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
test
|
|
68
|
+
(
|
|
69
|
+
'openConnectionPanel returns false when no ConnectionPropertiesPanel is configured',
|
|
70
|
+
function ()
|
|
71
|
+
{
|
|
72
|
+
let tmpResult = _PanelManager.openConnectionPanel('c1');
|
|
73
|
+
libExpect(tmpResult).to.equal(false);
|
|
74
|
+
libExpect(_MockFlowView._FlowData.OpenPanels.length).to.equal(0);
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
test
|
|
79
|
+
(
|
|
80
|
+
'openConnectionPanel returns false for an unknown connection',
|
|
81
|
+
function ()
|
|
82
|
+
{
|
|
83
|
+
_MockFlowView.options.ConnectionPropertiesPanel = { PanelType: 'Form' };
|
|
84
|
+
let tmpResult = _PanelManager.openConnectionPanel('no-such-connection');
|
|
85
|
+
libExpect(tmpResult).to.equal(false);
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
test
|
|
90
|
+
(
|
|
91
|
+
'openConnectionPanel opens a panel carrying the ConnectionHash, placed near the midpoint',
|
|
92
|
+
function ()
|
|
93
|
+
{
|
|
94
|
+
_MockFlowView.options.ConnectionPropertiesPanel = { PanelType: 'Form', DefaultWidth: 320, DefaultHeight: 240, Title: 'Transition' };
|
|
95
|
+
let tmpPanel = _PanelManager.openConnectionPanel('c1');
|
|
96
|
+
|
|
97
|
+
libExpect(tmpPanel).to.be.an('object');
|
|
98
|
+
libExpect(tmpPanel.ConnectionHash).to.equal('c1');
|
|
99
|
+
libExpect(tmpPanel.NodeHash).to.equal(null);
|
|
100
|
+
libExpect(tmpPanel.Title).to.equal('Transition');
|
|
101
|
+
libExpect(tmpPanel.Width).to.equal(320);
|
|
102
|
+
libExpect(tmpPanel.Height).to.equal(240);
|
|
103
|
+
// Midpoint is (200, 30); the panel is offset from it.
|
|
104
|
+
libExpect(tmpPanel.X).to.equal(240);
|
|
105
|
+
libExpect(tmpPanel.Y).to.equal(50);
|
|
106
|
+
libExpect(_MockFlowView._FlowData.OpenPanels.length).to.equal(1);
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
test
|
|
111
|
+
(
|
|
112
|
+
'openConnectionPanel is idempotent: a second open returns the same panel',
|
|
113
|
+
function ()
|
|
114
|
+
{
|
|
115
|
+
_MockFlowView.options.ConnectionPropertiesPanel = { PanelType: 'Form' };
|
|
116
|
+
let tmpFirst = _PanelManager.openConnectionPanel('c1');
|
|
117
|
+
let tmpSecond = _PanelManager.openConnectionPanel('c1');
|
|
118
|
+
libExpect(tmpSecond.Hash).to.equal(tmpFirst.Hash);
|
|
119
|
+
libExpect(_MockFlowView._FlowData.OpenPanels.length).to.equal(1);
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
test
|
|
124
|
+
(
|
|
125
|
+
'toggleConnectionPanel opens then closes',
|
|
126
|
+
function ()
|
|
127
|
+
{
|
|
128
|
+
_MockFlowView.options.ConnectionPropertiesPanel = { PanelType: 'Form' };
|
|
129
|
+
let tmpOpened = _PanelManager.toggleConnectionPanel('c1');
|
|
130
|
+
libExpect(tmpOpened).to.be.an('object');
|
|
131
|
+
libExpect(_MockFlowView._FlowData.OpenPanels.length).to.equal(1);
|
|
132
|
+
|
|
133
|
+
let tmpClosed = _PanelManager.toggleConnectionPanel('c1');
|
|
134
|
+
libExpect(tmpClosed).to.equal(false);
|
|
135
|
+
libExpect(_MockFlowView._FlowData.OpenPanels.length).to.equal(0);
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
test
|
|
140
|
+
(
|
|
141
|
+
'closePanelForConnection removes the connection panel',
|
|
142
|
+
function ()
|
|
143
|
+
{
|
|
144
|
+
_MockFlowView.options.ConnectionPropertiesPanel = { PanelType: 'Form' };
|
|
145
|
+
_PanelManager.openConnectionPanel('c1');
|
|
146
|
+
let tmpRemoved = _PanelManager.closePanelForConnection('c1');
|
|
147
|
+
libExpect(tmpRemoved).to.equal(true);
|
|
148
|
+
libExpect(_MockFlowView._FlowData.OpenPanels.length).to.equal(0);
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
test
|
|
153
|
+
(
|
|
154
|
+
'node panels still open alongside connection panels, keyed separately',
|
|
155
|
+
function ()
|
|
156
|
+
{
|
|
157
|
+
_MockFlowView.options.ConnectionPropertiesPanel = { PanelType: 'Form' };
|
|
158
|
+
let tmpNodePanel = _PanelManager.openPanel('n1');
|
|
159
|
+
let tmpConnPanel = _PanelManager.openConnectionPanel('c1');
|
|
160
|
+
|
|
161
|
+
libExpect(tmpNodePanel.NodeHash).to.equal('n1');
|
|
162
|
+
libExpect(tmpConnPanel.ConnectionHash).to.equal('c1');
|
|
163
|
+
libExpect(_MockFlowView._FlowData.OpenPanels.length).to.equal(2);
|
|
164
|
+
|
|
165
|
+
// Closing the connection panel leaves the node panel intact.
|
|
166
|
+
_PanelManager.closePanelForConnection('c1');
|
|
167
|
+
libExpect(_MockFlowView._FlowData.OpenPanels.length).to.equal(1);
|
|
168
|
+
libExpect(_MockFlowView._FlowData.OpenPanels[0].NodeHash).to.equal('n1');
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
);
|