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
@@ -3,6 +3,14 @@
3
3
  "version": "0.0.1",
4
4
  "description": "Pict Section Flow - Simple Cards Example Application",
5
5
  "main": "source/Pict-Application-FlowExample.js",
6
+ "retold": {
7
+ "ExampleApplication": {
8
+ "Stage": true,
9
+ "Title": "Simple Cards",
10
+ "Summary": "Twelve custom PictFlowCard subclasses across six categories — every panel type (Markdown / Template / Form / View), every BodyContent renderer (SVG / HTML / Canvas), wrapped in a multi-page Pict shell with router-driven navigation and a curated sample-graph catalog.",
11
+ "Complexity": "Basic"
12
+ }
13
+ },
6
14
  "scripts": {
7
15
  "start": "node source/Pict-Application-FlowExample.js",
8
16
  "prebuild": "node ../../scripts/generate-card-help.js",
@@ -19,7 +27,7 @@
19
27
  "pict-provider": "^1.0.3",
20
28
  "pict-section-content": "^0.0.6",
21
29
  "pict-section-form": "^1.0.192",
22
- "pict-section-flow": "file:../../"
30
+ "pict-section-flow": "^1.0.1"
23
31
  },
24
32
  "devDependencies": {
25
33
  "quackage": "^1.0.58"
@@ -44,8 +44,8 @@ const _ViewConfiguration =
44
44
  <div class="flowexample-bottombar">
45
45
  <span>Pict Section Flow Example &copy; 2025</span>
46
46
  <div class="flowexample-bottombar-links">
47
- <a href="https://github.com/stevenvelozo/pict" target="_blank">Pict</a>
48
- <a href="https://github.com/stevenvelozo/fable" target="_blank">Fable</a>
47
+ <a href="https://github.com/fable-retold/pict" target="_blank">Pict</a>
48
+ <a href="https://github.com/fable-retold/fable" target="_blank">Fable</a>
49
49
  <a onclick="{~P~}.PictApplication.navigateTo('/About')">About</a>
50
50
  </div>
51
51
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pict-section-flow",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Pict Section Flow Diagram",
5
5
  "main": "source/Pict-Section-Flow.js",
6
6
  "scripts": {
@@ -15,15 +15,15 @@
15
15
  "dependencies": {
16
16
  "fable-serviceproviderbase": "^3.0.19",
17
17
  "pict-provider": "^1.0.13",
18
- "pict-section-form": "^1.0.196",
18
+ "pict-section-form": "^1.0.199",
19
19
  "pict-view": "^1.0.68"
20
20
  },
21
21
  "devDependencies": {
22
22
  "chai": "^6.2.2",
23
23
  "mocha": "^11.7.5",
24
- "pict": "^1.0.369",
25
- "pict-docuserve": "^1.3.2",
24
+ "pict": "^1.0.372",
25
+ "pict-docuserve": "^1.4.4",
26
26
  "pict-router": "^1.0.10",
27
- "quackage": "^1.2.3"
27
+ "quackage": "^1.3.0"
28
28
  }
29
29
  }
@@ -40,7 +40,8 @@ class PictProviderFlowPanelChrome extends libFableServiceProviderBase
40
40
  let tmpFO = tmpSVGHelper.createSVGElement('foreignObject');
41
41
  tmpFO.setAttribute('class', 'pict-flow-panel-foreign-object');
42
42
  tmpFO.setAttribute('data-panel-hash', pPanelData.Hash);
43
- tmpFO.setAttribute('data-node-hash', pPanelData.NodeHash);
43
+ tmpFO.setAttribute('data-node-hash', pPanelData.NodeHash || '');
44
+ if (pPanelData.ConnectionHash) { tmpFO.setAttribute('data-connection-hash', pPanelData.ConnectionHash); }
44
45
  tmpFO.setAttribute('x', String(pPanelData.X));
45
46
  tmpFO.setAttribute('y', String(pPanelData.Y));
46
47
  tmpFO.setAttribute('width', String(pPanelData.Width));
@@ -1,9 +1,10 @@
1
1
  const libCoerce = require('./Layout-Coerce.js');
2
+ const libRank = require('./Layout-Rank.js');
2
3
 
3
4
  /**
4
5
  * Layout-Layered
5
6
  *
6
- * Topological-sort (Kahn's algorithm) left-to-right layered layout.
7
+ * Cycle-tolerant left-to-right layered layout.
7
8
  *
8
9
  * This is the original `autoLayout` behavior of pict-section-flow,
9
10
  * extracted into a layout-algorithm descriptor. Calling
@@ -34,100 +35,45 @@ module.exports =
34
35
 
35
36
  let tmpConnections = Array.isArray(pConnections) ? pConnections : [];
36
37
 
37
- // Build adjacency information
38
38
  let tmpNodeMap = {};
39
- let tmpInDegree = {};
40
- let tmpOutEdges = {};
41
-
42
39
  for (let i = 0; i < pNodes.length; i++)
43
40
  {
44
- let tmpNode = pNodes[i];
45
- tmpNodeMap[tmpNode.Hash] = tmpNode;
46
- tmpInDegree[tmpNode.Hash] = 0;
47
- tmpOutEdges[tmpNode.Hash] = [];
48
- }
49
-
50
- for (let i = 0; i < tmpConnections.length; i++)
51
- {
52
- let tmpConn = tmpConnections[i];
53
- if (tmpInDegree.hasOwnProperty(tmpConn.TargetNodeHash))
54
- {
55
- tmpInDegree[tmpConn.TargetNodeHash]++;
56
- }
57
- if (tmpOutEdges.hasOwnProperty(tmpConn.SourceNodeHash))
58
- {
59
- tmpOutEdges[tmpConn.SourceNodeHash].push(tmpConn.TargetNodeHash);
60
- }
41
+ tmpNodeMap[pNodes[i].Hash] = pNodes[i];
61
42
  }
62
43
 
63
- // Topological sort (Kahn's algorithm)
64
- let tmpLayers = [];
65
- let tmpQueue = [];
66
- let tmpAssigned = {};
67
-
68
- for (let tmpHash in tmpInDegree)
44
+ // Rank the nodes into layers, left to right, with cycle breaking. The
45
+ // shared ranker keeps back-edged graphs (workflows, state machines) from
46
+ // collapsing into one tall column the way plain Kahn's topological sort
47
+ // would. See Layout-Rank.js.
48
+ let tmpLayers = libRank.toRanks(pNodes, tmpConnections);
49
+
50
+ // Measure each layer's stacked height so layers can be centered on one
51
+ // shared horizontal axis. Without centering every layer top-aligns at
52
+ // StartY, so a one-node layer beside a five-node layer reads as a
53
+ // diagonal drift down the page instead of a balanced band.
54
+ let tmpLayerHeights = [];
55
+ let tmpMaxLayerHeight = 0;
56
+ for (let l = 0; l < tmpLayers.length; l++)
69
57
  {
70
- if (tmpInDegree[tmpHash] === 0)
58
+ let tmpHeight = 0;
59
+ for (let i = 0; i < tmpLayers[l].length; i++)
71
60
  {
72
- tmpQueue.push(tmpHash);
61
+ let tmpNode = tmpNodeMap[tmpLayers[l][i]];
62
+ tmpHeight += (tmpNode && tmpNode.Height) ? tmpNode.Height : 80;
63
+ if (i > 0) tmpHeight += tmpVerticalSpacing;
73
64
  }
65
+ tmpLayerHeights.push(tmpHeight);
66
+ if (tmpHeight > tmpMaxLayerHeight) tmpMaxLayerHeight = tmpHeight;
74
67
  }
75
68
 
76
- while (tmpQueue.length > 0)
77
- {
78
- let tmpCurrentLayer = [];
79
- let tmpNextQueue = [];
80
-
81
- for (let i = 0; i < tmpQueue.length; i++)
82
- {
83
- let tmpNodeHash = tmpQueue[i];
84
- if (tmpAssigned[tmpNodeHash]) continue;
85
-
86
- tmpAssigned[tmpNodeHash] = true;
87
- tmpCurrentLayer.push(tmpNodeHash);
88
-
89
- let tmpEdges = tmpOutEdges[tmpNodeHash] || [];
90
- for (let j = 0; j < tmpEdges.length; j++)
91
- {
92
- let tmpTargetHash = tmpEdges[j];
93
- tmpInDegree[tmpTargetHash]--;
94
- if (tmpInDegree[tmpTargetHash] <= 0 && !tmpAssigned[tmpTargetHash])
95
- {
96
- tmpNextQueue.push(tmpTargetHash);
97
- }
98
- }
99
- }
100
-
101
- if (tmpCurrentLayer.length > 0)
102
- {
103
- tmpLayers.push(tmpCurrentLayer);
104
- }
105
-
106
- tmpQueue = tmpNextQueue;
107
- }
108
-
109
- // Handle cycles or disconnected nodes
110
- let tmpRemainingNodes = [];
111
- for (let i = 0; i < pNodes.length; i++)
112
- {
113
- if (!tmpAssigned[pNodes[i].Hash])
114
- {
115
- tmpRemainingNodes.push(pNodes[i].Hash);
116
- }
117
- }
118
- if (tmpRemainingNodes.length > 0)
119
- {
120
- tmpLayers.push(tmpRemainingNodes);
121
- }
122
-
123
- // Assign positions based on layers
69
+ // Assign positions: one column per layer, each layer vertically centered.
124
70
  let tmpCurrentX = tmpStartX;
125
71
 
126
72
  for (let tmpLayerIndex = 0; tmpLayerIndex < tmpLayers.length; tmpLayerIndex++)
127
73
  {
128
74
  let tmpLayer = tmpLayers[tmpLayerIndex];
129
75
  let tmpMaxWidth = 0;
130
- let tmpCurrentY = tmpStartY;
76
+ let tmpCurrentY = tmpStartY + ((tmpMaxLayerHeight - tmpLayerHeights[tmpLayerIndex]) / 2);
131
77
 
132
78
  for (let i = 0; i < tmpLayer.length; i++)
133
79
  {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Layout-Rank
3
+ *
4
+ * Shared cycle-tolerant topological ranking for the directed layouts
5
+ * (Layered, Staggered).
6
+ *
7
+ * Plain Kahn's topological sort drops every node that participates in a cycle
8
+ * into a single trailing rank. Any graph with back-edges (workflows, state
9
+ * machines: rejections, retries, "send back") therefore collapses into one
10
+ * tall stripe of unranked nodes, which is the historical "auto-layout bunches
11
+ * everything together and is useless" behavior.
12
+ *
13
+ * `toRanks` instead breaks each cycle at its most-resolved node: when a round
14
+ * finds nothing dependency-free but nodes remain, it forces the unassigned
15
+ * node with the fewest unmet predecessors into the next rank and continues.
16
+ * Cyclic graphs then rank left to right the same way a DAG does.
17
+ *
18
+ * Returns an array of ranks; each rank is an array of node Hashes in stable
19
+ * source order. Self-loops are ignored for ranking (they cannot define an
20
+ * order). Callers map Hashes back to node objects themselves.
21
+ */
22
+ function toRanks(pNodes, pConnections)
23
+ {
24
+ let tmpRanks = [];
25
+ if (!pNodes || pNodes.length === 0)
26
+ {
27
+ return tmpRanks;
28
+ }
29
+
30
+ let tmpConnections = Array.isArray(pConnections) ? pConnections : [];
31
+
32
+ let tmpInDegree = {};
33
+ let tmpOutEdges = {};
34
+ for (let i = 0; i < pNodes.length; i++)
35
+ {
36
+ tmpInDegree[pNodes[i].Hash] = 0;
37
+ tmpOutEdges[pNodes[i].Hash] = [];
38
+ }
39
+
40
+ for (let i = 0; i < tmpConnections.length; i++)
41
+ {
42
+ let tmpConn = tmpConnections[i];
43
+ // A self-loop cannot define a rank order; skip it.
44
+ if (tmpConn.SourceNodeHash === tmpConn.TargetNodeHash)
45
+ {
46
+ continue;
47
+ }
48
+ if (tmpInDegree.hasOwnProperty(tmpConn.TargetNodeHash))
49
+ {
50
+ tmpInDegree[tmpConn.TargetNodeHash]++;
51
+ }
52
+ if (tmpOutEdges.hasOwnProperty(tmpConn.SourceNodeHash))
53
+ {
54
+ tmpOutEdges[tmpConn.SourceNodeHash].push(tmpConn.TargetNodeHash);
55
+ }
56
+ }
57
+
58
+ let tmpAssigned = {};
59
+ let tmpAssignedCount = 0;
60
+
61
+ while (tmpAssignedCount < pNodes.length)
62
+ {
63
+ let tmpRank = [];
64
+
65
+ for (let i = 0; i < pNodes.length; i++)
66
+ {
67
+ let tmpHash = pNodes[i].Hash;
68
+ if (!tmpAssigned[tmpHash] && tmpInDegree[tmpHash] <= 0)
69
+ {
70
+ tmpRank.push(tmpHash);
71
+ }
72
+ }
73
+
74
+ if (tmpRank.length === 0)
75
+ {
76
+ // Cycle break: nothing is dependency-free, so force the unassigned
77
+ // node with the fewest remaining predecessors (ties keep source
78
+ // order, which keeps the result stable across runs).
79
+ let tmpMinDegree = Infinity;
80
+ let tmpPick = null;
81
+ for (let i = 0; i < pNodes.length; i++)
82
+ {
83
+ let tmpHash = pNodes[i].Hash;
84
+ if (!tmpAssigned[tmpHash] && tmpInDegree[tmpHash] < tmpMinDegree)
85
+ {
86
+ tmpMinDegree = tmpInDegree[tmpHash];
87
+ tmpPick = tmpHash;
88
+ }
89
+ }
90
+ if (tmpPick === null)
91
+ {
92
+ break; // safety; every node is already assigned
93
+ }
94
+ tmpRank.push(tmpPick);
95
+ }
96
+
97
+ // Commit the whole rank, then relax its out-edges so the next round
98
+ // sees the successors it just freed.
99
+ for (let i = 0; i < tmpRank.length; i++)
100
+ {
101
+ tmpAssigned[tmpRank[i]] = true;
102
+ tmpAssignedCount++;
103
+ }
104
+ for (let i = 0; i < tmpRank.length; i++)
105
+ {
106
+ let tmpEdges = tmpOutEdges[tmpRank[i]] || [];
107
+ for (let j = 0; j < tmpEdges.length; j++)
108
+ {
109
+ tmpInDegree[tmpEdges[j]]--;
110
+ }
111
+ }
112
+
113
+ tmpRanks.push(tmpRank);
114
+ }
115
+
116
+ return tmpRanks;
117
+ }
118
+
119
+ /**
120
+ * Flatten ranks to a single ordered list of node Hashes (rank by rank, source
121
+ * order within a rank). Convenience for layouts that walk one sequence.
122
+ */
123
+ function toOrder(pNodes, pConnections)
124
+ {
125
+ let tmpRanks = toRanks(pNodes, pConnections);
126
+ let tmpOrder = [];
127
+ for (let i = 0; i < tmpRanks.length; i++)
128
+ {
129
+ for (let j = 0; j < tmpRanks[i].length; j++)
130
+ {
131
+ tmpOrder.push(tmpRanks[i][j]);
132
+ }
133
+ }
134
+ return tmpOrder;
135
+ }
136
+
137
+ module.exports =
138
+ {
139
+ toRanks: toRanks,
140
+ toOrder: toOrder
141
+ };
@@ -0,0 +1,131 @@
1
+ const libCoerce = require('./Layout-Coerce.js');
2
+ const libRank = require('./Layout-Rank.js');
3
+
4
+ /**
5
+ * Layout-Staggered
6
+ *
7
+ * Serpentine "stairstep" layout for directed graphs. It ranks the nodes left
8
+ * to right by connection topology (the same cycle-tolerant ranking the Layered
9
+ * layout uses), then walks that ordered sequence along a band that steps down a
10
+ * few rows and back up, advancing horizontally at every node.
11
+ *
12
+ * The point is vertical-space efficiency: a long directed chain (a workflow, a
13
+ * delivery pipeline, a state machine) laid out purely left to right runs off
14
+ * the right edge of the canvas. Folding it into a stairstep band keeps the
15
+ * left-to-right reading order while using the height of the viewport, so the
16
+ * whole graph frames at a usable zoom.
17
+ *
18
+ * `Rows` sets how deep the band steps before folding back: 2 is a simple
19
+ * zigzag (odd nodes high, even nodes low); 3+ is a deeper stairstep. The row
20
+ * index follows a triangle wave so the band descends and then climbs rather
21
+ * than snapping back to the top between runs.
22
+ *
23
+ * Numeric parameters are typed `PreciseNumber` so they survive ExpressionParser
24
+ * solver chains; Layout-Coerce converts them back to JS floats at entry.
25
+ */
26
+ module.exports =
27
+ {
28
+ Name: 'Staggered',
29
+ Label: 'Staggered (Stairstep)',
30
+ Description: 'Topological order folded along a serpentine stairstep band.',
31
+ DefaultEdgeTheme: 'Perimeter',
32
+
33
+ Apply: function (pNodes, pConnections, pParameters)
34
+ {
35
+ if (!pNodes || pNodes.length === 0) return;
36
+
37
+ let tmpParams = pParameters || {};
38
+ let tmpSpacing = libCoerce.toFloat(tmpParams.Spacing, 1.0);
39
+ let tmpRows = Math.max(1, libCoerce.toInt(tmpParams.Rows, 2));
40
+ let tmpColumnSpacing = libCoerce.toFloat(tmpParams.ColumnSpacing, 80) * tmpSpacing;
41
+ let tmpRowOffset = libCoerce.toFloat(tmpParams.RowOffset, 150) * tmpSpacing;
42
+ let tmpStartX = libCoerce.toFloat(tmpParams.StartX, 80);
43
+ let tmpStartY = libCoerce.toFloat(tmpParams.StartY, 80);
44
+
45
+ let tmpConnections = Array.isArray(pConnections) ? pConnections : [];
46
+
47
+ let tmpNodeMap = {};
48
+ for (let i = 0; i < pNodes.length; i++)
49
+ {
50
+ tmpNodeMap[pNodes[i].Hash] = pNodes[i];
51
+ }
52
+
53
+ let tmpOrder = libRank.toOrder(pNodes, tmpConnections);
54
+
55
+ // A uniform column pitch (the widest node plus the spacing) keeps the
56
+ // stairstep diagonal even regardless of individual node widths.
57
+ let tmpMaxWidth = 0;
58
+ for (let i = 0; i < pNodes.length; i++)
59
+ {
60
+ tmpMaxWidth = Math.max(tmpMaxWidth, pNodes[i].Width || 180);
61
+ }
62
+ let tmpColumnPitch = tmpMaxWidth + tmpColumnSpacing;
63
+
64
+ // Triangle wave: descend (Rows - 1) steps, then climb (Rows - 1) steps.
65
+ let tmpPeriod = (tmpRows > 1) ? (2 * (tmpRows - 1)) : 1;
66
+
67
+ for (let i = 0; i < tmpOrder.length; i++)
68
+ {
69
+ let tmpNode = tmpNodeMap[tmpOrder[i]];
70
+ if (!tmpNode) continue;
71
+
72
+ let tmpRow;
73
+ if (tmpRows <= 1)
74
+ {
75
+ tmpRow = 0;
76
+ }
77
+ else
78
+ {
79
+ let tmpPhase = i % tmpPeriod;
80
+ tmpRow = (tmpPhase < tmpRows) ? tmpPhase : (tmpPeriod - tmpPhase);
81
+ }
82
+
83
+ tmpNode.X = tmpStartX + (i * tmpColumnPitch);
84
+ tmpNode.Y = tmpStartY + (tmpRow * tmpRowOffset);
85
+ }
86
+ },
87
+
88
+ DefaultParameters:
89
+ {
90
+ Spacing: 1.0,
91
+ Rows: 2,
92
+ ColumnSpacing: 80,
93
+ RowOffset: 150,
94
+ StartX: 80,
95
+ StartY: 80
96
+ },
97
+
98
+ ParameterSchema:
99
+ {
100
+ Spacing: { Type: 'PreciseNumber', Label: 'Spacing (multiplier)', Default: 1.0, Min: 0.1, Max: 5 },
101
+ Rows: { Type: 'Number', Label: 'Rows', Default: 2, Min: 1, Max: 12 },
102
+ ColumnSpacing: { Type: 'PreciseNumber', Label: 'Column spacing', Default: 80, Min: 0, Max: 1000 },
103
+ RowOffset: { Type: 'PreciseNumber', Label: 'Row offset', Default: 150, Min: 0, Max: 1000 },
104
+ StartX: { Type: 'PreciseNumber', Label: 'Start X', Default: 80, Min: -10000, Max: 10000 },
105
+ StartY: { Type: 'PreciseNumber', Label: 'Start Y', Default: 80, Min: -10000, Max: 10000 }
106
+ },
107
+
108
+ ParameterManifest:
109
+ {
110
+ Scope: 'PictFlowLayout-Staggered',
111
+ Sections:
112
+ [
113
+ { Name: 'Staggered Parameters', Hash: 'PFLStaggeredSection', Groups: [{ Name: 'Defaults', Hash: 'PFLStaggeredGroup' }] }
114
+ ],
115
+ Descriptors:
116
+ {
117
+ 'PictFlowLayoutEditor.Parameters.Spacing':
118
+ { Name: 'Spacing (multiplier)', Hash: 'Spacing', DataType: 'PreciseNumber', Default: 1.0, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 0, Width: 6, Min: 0.1, Max: 5 } },
119
+ 'PictFlowLayoutEditor.Parameters.Rows':
120
+ { Name: 'Rows', Hash: 'Rows', DataType: 'Number', Default: 2, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 0, Width: 6, Min: 1, Max: 12 } },
121
+ 'PictFlowLayoutEditor.Parameters.ColumnSpacing':
122
+ { Name: 'Column spacing', Hash: 'ColumnSpacing', DataType: 'PreciseNumber', Default: 80, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 1, Width: 6, Min: 0, Max: 1000 } },
123
+ 'PictFlowLayoutEditor.Parameters.RowOffset':
124
+ { Name: 'Row offset', Hash: 'RowOffset', DataType: 'PreciseNumber', Default: 150, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 1, Width: 6, Min: 0, Max: 1000 } },
125
+ 'PictFlowLayoutEditor.Parameters.StartX':
126
+ { Name: 'Start X', Hash: 'StartX', DataType: 'PreciseNumber', Default: 80, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 2, Width: 6, Min: -10000, Max: 10000 } },
127
+ 'PictFlowLayoutEditor.Parameters.StartY':
128
+ { Name: 'Start Y', Hash: 'StartY', DataType: 'PreciseNumber', Default: 80, PictForm: { Section: 'PFLStaggeredSection', Group: 'PFLStaggeredGroup', Row: 2, Width: 6, Min: -10000, Max: 10000 } }
129
+ }
130
+ }
131
+ };
@@ -328,6 +328,12 @@ class PictServiceFlowDataManager extends libFableServiceProviderBase
328
328
 
329
329
  let tmpRemovedConnection = this._FlowView._FlowData.Connections.splice(tmpConnectionIndex, 1)[0];
330
330
 
331
+ // Close any properties panel open for this connection.
332
+ if (typeof this._FlowView.closePanelForConnection === 'function')
333
+ {
334
+ this._FlowView.closePanelForConnection(pConnectionHash);
335
+ }
336
+
331
337
  if (this._FlowView._FlowData.ViewState.SelectedConnectionHash === pConnectionHash)
332
338
  {
333
339
  this._FlowView._FlowData.ViewState.SelectedConnectionHash = null;
@@ -322,7 +322,16 @@ class PictServiceFlowInteractionManager extends libFableServiceProviderBase
322
322
  {
323
323
  this._LastConnectionClickTime = 0;
324
324
  this._LastConnectionClickHash = null;
325
- this._addBezierHandle(tmpTarget, pEvent);
325
+ // When the host configured a connection properties panel, double-click opens it;
326
+ // otherwise keep the default behavior of adding a bezier handle.
327
+ if (this._FlowView.options.ConnectionPropertiesPanel && tmpConnectionHash)
328
+ {
329
+ this._FlowView.toggleConnectionPanel(tmpConnectionHash);
330
+ }
331
+ else
332
+ {
333
+ this._addBezierHandle(tmpTarget, pEvent);
334
+ }
326
335
  }
327
336
  else
328
337
  {
@@ -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,
@@ -152,6 +152,98 @@ class PictServiceFlowPanelManager extends libFableServiceProviderBase
152
152
  return this.openPanel(pNodeHash);
153
153
  }
154
154
 
155
+ /**
156
+ * Open a properties panel for a connection (edge). The panel config comes from the FlowView's
157
+ * ConnectionPropertiesPanel option (connections are not typed, so one config serves them all);
158
+ * the panel is placed near the connection's midpoint and tethers to it. Returns false when no
159
+ * ConnectionPropertiesPanel is configured, so a host that has not opted in keeps the default
160
+ * edge behavior (double-click adds a bezier handle).
161
+ * @param {string} pConnectionHash
162
+ * @returns {Object|false} The panel data, or false
163
+ */
164
+ openConnectionPanel(pConnectionHash)
165
+ {
166
+ let tmpConnection = this._FlowView.getConnection(pConnectionHash);
167
+ if (!tmpConnection) return false;
168
+
169
+ let tmpPanelConfig = this._FlowView.options.ConnectionPropertiesPanel;
170
+ if (!tmpPanelConfig) return false;
171
+
172
+ let tmpExisting = this._FlowView._FlowData.OpenPanels.find((pPanel) => pPanel.ConnectionHash === pConnectionHash);
173
+ if (tmpExisting) return tmpExisting;
174
+
175
+ let tmpMidpoint = this._FlowView.getConnectionMidpoint(pConnectionHash) || { x: 0, y: 0 };
176
+ let tmpWidth = tmpPanelConfig.DefaultWidth || 300;
177
+ let tmpHeight = tmpPanelConfig.DefaultHeight || 200;
178
+
179
+ let tmpPanelData =
180
+ {
181
+ Hash: `panel-${this.fable.getUUID()}`,
182
+ ConnectionHash: pConnectionHash,
183
+ NodeHash: null,
184
+ PanelType: tmpPanelConfig.PanelType || 'Base',
185
+ Title: tmpPanelConfig.Title || 'Connection',
186
+ X: tmpMidpoint.x + 40,
187
+ Y: tmpMidpoint.y + 20,
188
+ Width: tmpWidth,
189
+ Height: tmpHeight
190
+ };
191
+
192
+ this._FlowView._FlowData.OpenPanels.push(tmpPanelData);
193
+ this._FlowView.renderFlow();
194
+ this._FlowView.marshalFromView();
195
+
196
+ if (this._FlowView._EventHandlerProvider)
197
+ {
198
+ this._FlowView._EventHandlerProvider.fireEvent('onPanelOpened', tmpPanelData);
199
+ this._FlowView._EventHandlerProvider.fireEvent('onFlowChanged', this._FlowView._FlowData);
200
+ }
201
+
202
+ return tmpPanelData;
203
+ }
204
+
205
+ /**
206
+ * Toggle a properties panel for a connection (open if closed, close if open).
207
+ * @param {string} pConnectionHash
208
+ * @returns {Object|false}
209
+ */
210
+ toggleConnectionPanel(pConnectionHash)
211
+ {
212
+ let tmpExisting = this._FlowView._FlowData.OpenPanels.find((pPanel) => pPanel.ConnectionHash === pConnectionHash);
213
+ if (tmpExisting)
214
+ {
215
+ this.closePanel(tmpExisting.Hash);
216
+ return false;
217
+ }
218
+ return this.openConnectionPanel(pConnectionHash);
219
+ }
220
+
221
+ /**
222
+ * Close all panels for a given connection.
223
+ * @param {string} pConnectionHash
224
+ * @returns {boolean}
225
+ */
226
+ closePanelForConnection(pConnectionHash)
227
+ {
228
+ let tmpPanelsToClose = this._FlowView._FlowData.OpenPanels.filter((pPanel) => pPanel.ConnectionHash === pConnectionHash);
229
+ if (tmpPanelsToClose.length === 0) return false;
230
+
231
+ for (let i = 0; i < tmpPanelsToClose.length; i++)
232
+ {
233
+ let tmpIndex = this._FlowView._FlowData.OpenPanels.indexOf(tmpPanelsToClose[i]);
234
+ if (tmpIndex >= 0)
235
+ {
236
+ this._FlowView._FlowData.OpenPanels.splice(tmpIndex, 1);
237
+ }
238
+ if (this._FlowView._PropertiesPanelView)
239
+ {
240
+ this._FlowView._PropertiesPanelView.destroyPanel(tmpPanelsToClose[i].Hash);
241
+ }
242
+ }
243
+
244
+ return true;
245
+ }
246
+
155
247
  /**
156
248
  * Update a panel's position (for drag).
157
249
  * @param {string} pPanelHash
@@ -180,8 +272,20 @@ class PictServiceFlowPanelManager extends libFableServiceProviderBase
180
272
  }
181
273
  }
182
274
 
183
- // Update the tether for this panel
184
- this._FlowView._renderTethersForNode(tmpPanel.NodeHash);
275
+ // Update the tether for this panel. A node panel refreshes just its node's tethers; a
276
+ // connection panel has no node, so reconcile the panels layer (redraws all tethers, which
277
+ // is where the connection-midpoint tether is recomputed).
278
+ if (tmpPanel.ConnectionHash)
279
+ {
280
+ if (this._FlowView._PropertiesPanelView && this._FlowView._PanelsLayer && this._FlowView._TethersLayer)
281
+ {
282
+ this._FlowView._PropertiesPanelView.renderPanels(this._FlowView._FlowData.OpenPanels, this._FlowView._PanelsLayer, this._FlowView._TethersLayer, this._FlowView._FlowData.ViewState.SelectedTetherHash);
283
+ }
284
+ }
285
+ else
286
+ {
287
+ this._FlowView._renderTethersForNode(tmpPanel.NodeHash);
288
+ }
185
289
  }
186
290
  }
187
291