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.
Files changed (63) hide show
  1. package/README.md +44 -13
  2. package/docs/Architecture.md +8 -148
  3. package/docs/Data_Model.md +2 -11
  4. package/docs/README.md +8 -38
  5. package/docs/Theme_Integration.md +11 -11
  6. package/docs/_cover.md +7 -1
  7. package/docs/_playground.json +24 -0
  8. package/docs/_sidebar.md +4 -0
  9. package/docs/_topbar.md +1 -1
  10. package/docs/_version.json +3 -3
  11. package/docs/card-help/FREAD.md +1 -1
  12. package/docs/diagrams/architecture-at-a-glance.excalidraw +4270 -0
  13. package/docs/diagrams/architecture-at-a-glance.mmd +30 -0
  14. package/docs/diagrams/architecture-at-a-glance.svg +2 -0
  15. package/docs/diagrams/data-flow.excalidraw +1451 -0
  16. package/docs/diagrams/data-flow.mmd +17 -0
  17. package/docs/diagrams/data-flow.svg +2 -0
  18. package/docs/diagrams/high-level-design.excalidraw +5767 -0
  19. package/docs/diagrams/high-level-design.mmd +86 -0
  20. package/docs/diagrams/high-level-design.svg +2 -0
  21. package/docs/diagrams/relationships.excalidraw +3852 -0
  22. package/docs/diagrams/relationships.mmd +9 -0
  23. package/docs/diagrams/relationships.svg +2 -0
  24. package/docs/diagrams/service-initialization-sequence.excalidraw +1466 -0
  25. package/docs/diagrams/service-initialization-sequence.mmd +19 -0
  26. package/docs/diagrams/service-initialization-sequence.svg +2 -0
  27. package/docs/diagrams/svg-layer-structure.excalidraw +1060 -0
  28. package/docs/diagrams/svg-layer-structure.mmd +18 -0
  29. package/docs/diagrams/svg-layer-structure.svg +2 -0
  30. package/docs/examples/README.md +9 -0
  31. package/docs/examples/simple_cards/README.md +677 -0
  32. package/docs/examples/simple_cards/css/flowexample.css +65 -0
  33. package/docs/examples/simple_cards/index.html +32 -0
  34. package/docs/examples/simple_cards/js/pict.min.js +12 -0
  35. package/docs/examples/simple_cards/pict-section-flow-example-simple-cards.compatible.min.js +1 -0
  36. package/docs/index.html +6 -5
  37. package/docs/playground/app.json +6 -0
  38. package/docs/playground/appdata.json +85 -0
  39. package/docs/playground/application.js +23 -0
  40. package/docs/playground/pict.json +17 -0
  41. package/docs/playground/runtime/pict-application.min.js +2 -0
  42. package/docs/playground/runtime/pict-section-flow.min.js +2 -0
  43. package/docs/playground/runtime/pict-section-modal.min.js +2 -0
  44. package/docs/playground/runtime/pict.min.js +12 -0
  45. package/docs/retold-catalog.json +241 -166
  46. package/docs/retold-keyword-index.json +19312 -7226
  47. package/example_applications/simple_cards/package.json +9 -1
  48. package/example_applications/simple_cards/source/views/PictView-FlowExample-BottomBar.js +2 -2
  49. package/package.json +5 -5
  50. package/source/providers/PictProvider-Flow-PanelChrome.js +2 -1
  51. package/source/providers/layouts/Layout-Layered.js +25 -79
  52. package/source/providers/layouts/Layout-Rank.js +141 -0
  53. package/source/providers/layouts/Layout-Staggered.js +131 -0
  54. package/source/services/PictService-Flow-DataManager.js +6 -0
  55. package/source/services/PictService-Flow-InteractionManager.js +10 -1
  56. package/source/services/PictService-Flow-Layout.js +2 -0
  57. package/source/services/PictService-Flow-PanelManager.js +106 -2
  58. package/source/views/PictView-Flow-Node.js +41 -4
  59. package/source/views/PictView-Flow-PropertiesPanel.js +70 -3
  60. package/source/views/PictView-Flow.js +53 -0
  61. package/test/Layout_tests.js +208 -4
  62. package/test/NodeView_tests.js +49 -0
  63. 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 (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
 
@@ -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
- let tmpNodeData = this._FlowView.getNode(pPanelData.NodeHash);
720
- if (!tmpNodeData) return;
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, tmpNodeData, pTethersLayer, pIsSelected, tmpViewIdentifier);
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
@@ -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
+ });
@@ -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
+ );