pict-section-flow 0.0.1
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/LICENSE +21 -0
- package/example_application/css/flowexample.css +65 -0
- package/example_application/html/index.html +32 -0
- package/example_application/package.json +41 -0
- package/example_application/source/Pict-Application-FlowExample-Configuration.json +15 -0
- package/example_application/source/Pict-Application-FlowExample.js +241 -0
- package/example_application/source/providers/PictRouter-FlowExample-Configuration.json +22 -0
- package/example_application/source/views/PictView-FlowExample-About.js +184 -0
- package/example_application/source/views/PictView-FlowExample-BottomBar.js +77 -0
- package/example_application/source/views/PictView-FlowExample-Documentation.js +325 -0
- package/example_application/source/views/PictView-FlowExample-Layout.js +86 -0
- package/example_application/source/views/PictView-FlowExample-MainWorkspace.js +191 -0
- package/example_application/source/views/PictView-FlowExample-TopBar.js +95 -0
- package/package.json +22 -0
- package/source/Pict-Section-Flow.js +19 -0
- package/source/providers/PictProvider-Flow-EventHandler.js +158 -0
- package/source/providers/PictProvider-Flow-NodeTypes.js +174 -0
- package/source/services/PictService-Flow-ConnectionRenderer.js +251 -0
- package/source/services/PictService-Flow-InteractionManager.js +567 -0
- package/source/services/PictService-Flow-Layout.js +207 -0
- package/source/views/PictView-Flow-Node.js +267 -0
- package/source/views/PictView-Flow-Toolbar.js +223 -0
- package/source/views/PictView-Flow.js +1116 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const libFableServiceProviderBase = require('fable-serviceproviderbase');
|
|
2
|
+
|
|
3
|
+
class PictServiceFlowLayout extends libFableServiceProviderBase
|
|
4
|
+
{
|
|
5
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
6
|
+
{
|
|
7
|
+
super(pFable, pOptions, pServiceHash);
|
|
8
|
+
|
|
9
|
+
this.serviceType = 'PictServiceFlowLayout';
|
|
10
|
+
|
|
11
|
+
this._FlowView = (pOptions && pOptions.FlowView) ? pOptions.FlowView : null;
|
|
12
|
+
|
|
13
|
+
// Layout configuration
|
|
14
|
+
this._HorizontalSpacing = 250;
|
|
15
|
+
this._VerticalSpacing = 120;
|
|
16
|
+
this._StartX = 100;
|
|
17
|
+
this._StartY = 100;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Snap a coordinate to the nearest grid point
|
|
22
|
+
* @param {number} pValue - The coordinate value
|
|
23
|
+
* @param {number} pGridSize - The grid size
|
|
24
|
+
* @returns {number}
|
|
25
|
+
*/
|
|
26
|
+
snapToGrid(pValue, pGridSize)
|
|
27
|
+
{
|
|
28
|
+
if (!pGridSize || pGridSize <= 0) return pValue;
|
|
29
|
+
return Math.round(pValue / pGridSize) * pGridSize;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Auto-layout nodes using a simple left-to-right topological approach
|
|
34
|
+
* @param {Array} pNodes - Array of node data objects
|
|
35
|
+
* @param {Array} pConnections - Array of connection data objects
|
|
36
|
+
*/
|
|
37
|
+
autoLayout(pNodes, pConnections)
|
|
38
|
+
{
|
|
39
|
+
if (!pNodes || pNodes.length === 0) return;
|
|
40
|
+
|
|
41
|
+
// Build adjacency information
|
|
42
|
+
let tmpNodeMap = {};
|
|
43
|
+
let tmpInDegree = {};
|
|
44
|
+
let tmpOutEdges = {};
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < pNodes.length; i++)
|
|
47
|
+
{
|
|
48
|
+
let tmpNode = pNodes[i];
|
|
49
|
+
tmpNodeMap[tmpNode.Hash] = tmpNode;
|
|
50
|
+
tmpInDegree[tmpNode.Hash] = 0;
|
|
51
|
+
tmpOutEdges[tmpNode.Hash] = [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < pConnections.length; i++)
|
|
55
|
+
{
|
|
56
|
+
let tmpConn = pConnections[i];
|
|
57
|
+
if (tmpInDegree.hasOwnProperty(tmpConn.TargetNodeHash))
|
|
58
|
+
{
|
|
59
|
+
tmpInDegree[tmpConn.TargetNodeHash]++;
|
|
60
|
+
}
|
|
61
|
+
if (tmpOutEdges.hasOwnProperty(tmpConn.SourceNodeHash))
|
|
62
|
+
{
|
|
63
|
+
tmpOutEdges[tmpConn.SourceNodeHash].push(tmpConn.TargetNodeHash);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Topological sort (Kahn's algorithm)
|
|
68
|
+
let tmpLayers = [];
|
|
69
|
+
let tmpQueue = [];
|
|
70
|
+
let tmpAssigned = {};
|
|
71
|
+
|
|
72
|
+
// Start with nodes that have no incoming edges
|
|
73
|
+
for (let tmpHash in tmpInDegree)
|
|
74
|
+
{
|
|
75
|
+
if (tmpInDegree[tmpHash] === 0)
|
|
76
|
+
{
|
|
77
|
+
tmpQueue.push(tmpHash);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
while (tmpQueue.length > 0)
|
|
82
|
+
{
|
|
83
|
+
let tmpCurrentLayer = [];
|
|
84
|
+
|
|
85
|
+
let tmpNextQueue = [];
|
|
86
|
+
for (let i = 0; i < tmpQueue.length; i++)
|
|
87
|
+
{
|
|
88
|
+
let tmpNodeHash = tmpQueue[i];
|
|
89
|
+
if (tmpAssigned[tmpNodeHash]) continue;
|
|
90
|
+
|
|
91
|
+
tmpAssigned[tmpNodeHash] = true;
|
|
92
|
+
tmpCurrentLayer.push(tmpNodeHash);
|
|
93
|
+
|
|
94
|
+
// Process outgoing edges
|
|
95
|
+
let tmpEdges = tmpOutEdges[tmpNodeHash] || [];
|
|
96
|
+
for (let j = 0; j < tmpEdges.length; j++)
|
|
97
|
+
{
|
|
98
|
+
let tmpTargetHash = tmpEdges[j];
|
|
99
|
+
tmpInDegree[tmpTargetHash]--;
|
|
100
|
+
if (tmpInDegree[tmpTargetHash] <= 0 && !tmpAssigned[tmpTargetHash])
|
|
101
|
+
{
|
|
102
|
+
tmpNextQueue.push(tmpTargetHash);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (tmpCurrentLayer.length > 0)
|
|
108
|
+
{
|
|
109
|
+
tmpLayers.push(tmpCurrentLayer);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
tmpQueue = tmpNextQueue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Handle any remaining unassigned nodes (cycles or disconnected)
|
|
116
|
+
let tmpRemainingNodes = [];
|
|
117
|
+
for (let i = 0; i < pNodes.length; i++)
|
|
118
|
+
{
|
|
119
|
+
if (!tmpAssigned[pNodes[i].Hash])
|
|
120
|
+
{
|
|
121
|
+
tmpRemainingNodes.push(pNodes[i].Hash);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (tmpRemainingNodes.length > 0)
|
|
125
|
+
{
|
|
126
|
+
tmpLayers.push(tmpRemainingNodes);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Assign positions based on layers
|
|
130
|
+
let tmpCurrentX = this._StartX;
|
|
131
|
+
|
|
132
|
+
for (let tmpLayerIndex = 0; tmpLayerIndex < tmpLayers.length; tmpLayerIndex++)
|
|
133
|
+
{
|
|
134
|
+
let tmpLayer = tmpLayers[tmpLayerIndex];
|
|
135
|
+
let tmpMaxWidth = 0;
|
|
136
|
+
|
|
137
|
+
// Calculate the total height for this layer to center vertically
|
|
138
|
+
let tmpTotalHeight = 0;
|
|
139
|
+
for (let i = 0; i < tmpLayer.length; i++)
|
|
140
|
+
{
|
|
141
|
+
let tmpNode = tmpNodeMap[tmpLayer[i]];
|
|
142
|
+
if (tmpNode)
|
|
143
|
+
{
|
|
144
|
+
tmpTotalHeight += tmpNode.Height || 80;
|
|
145
|
+
if (i < tmpLayer.length - 1)
|
|
146
|
+
{
|
|
147
|
+
tmpTotalHeight += this._VerticalSpacing;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let tmpCurrentY = this._StartY;
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < tmpLayer.length; i++)
|
|
155
|
+
{
|
|
156
|
+
let tmpNode = tmpNodeMap[tmpLayer[i]];
|
|
157
|
+
if (!tmpNode) continue;
|
|
158
|
+
|
|
159
|
+
tmpNode.X = tmpCurrentX;
|
|
160
|
+
tmpNode.Y = tmpCurrentY;
|
|
161
|
+
|
|
162
|
+
let tmpWidth = tmpNode.Width || 180;
|
|
163
|
+
let tmpHeight = tmpNode.Height || 80;
|
|
164
|
+
|
|
165
|
+
tmpMaxWidth = Math.max(tmpMaxWidth, tmpWidth);
|
|
166
|
+
tmpCurrentY += tmpHeight + this._VerticalSpacing;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
tmpCurrentX += tmpMaxWidth + this._HorizontalSpacing;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Center all nodes around a given point
|
|
175
|
+
* @param {Array} pNodes
|
|
176
|
+
* @param {number} pCenterX
|
|
177
|
+
* @param {number} pCenterY
|
|
178
|
+
*/
|
|
179
|
+
centerNodes(pNodes, pCenterX, pCenterY)
|
|
180
|
+
{
|
|
181
|
+
if (!pNodes || pNodes.length === 0) return;
|
|
182
|
+
|
|
183
|
+
let tmpMinX = Infinity, tmpMinY = Infinity;
|
|
184
|
+
let tmpMaxX = -Infinity, tmpMaxY = -Infinity;
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < pNodes.length; i++)
|
|
187
|
+
{
|
|
188
|
+
tmpMinX = Math.min(tmpMinX, pNodes[i].X);
|
|
189
|
+
tmpMinY = Math.min(tmpMinY, pNodes[i].Y);
|
|
190
|
+
tmpMaxX = Math.max(tmpMaxX, pNodes[i].X + (pNodes[i].Width || 180));
|
|
191
|
+
tmpMaxY = Math.max(tmpMaxY, pNodes[i].Y + (pNodes[i].Height || 80));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let tmpCurrentCenterX = (tmpMinX + tmpMaxX) / 2;
|
|
195
|
+
let tmpCurrentCenterY = (tmpMinY + tmpMaxY) / 2;
|
|
196
|
+
let tmpOffsetX = pCenterX - tmpCurrentCenterX;
|
|
197
|
+
let tmpOffsetY = pCenterY - tmpCurrentCenterY;
|
|
198
|
+
|
|
199
|
+
for (let i = 0; i < pNodes.length; i++)
|
|
200
|
+
{
|
|
201
|
+
pNodes[i].X += tmpOffsetX;
|
|
202
|
+
pNodes[i].Y += tmpOffsetY;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = PictServiceFlowLayout;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
const libPictView = require('pict-view');
|
|
2
|
+
|
|
3
|
+
const _DefaultConfiguration =
|
|
4
|
+
{
|
|
5
|
+
ViewIdentifier: 'Flow-NodeRenderer',
|
|
6
|
+
|
|
7
|
+
AutoRender: false,
|
|
8
|
+
|
|
9
|
+
// Title bar height for nodes
|
|
10
|
+
NodeTitleBarHeight: 22
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class PictViewFlowNode extends libPictView
|
|
14
|
+
{
|
|
15
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
16
|
+
{
|
|
17
|
+
let tmpOptions = Object.assign({}, JSON.parse(JSON.stringify(_DefaultConfiguration)), pOptions);
|
|
18
|
+
super(pFable, tmpOptions, pServiceHash);
|
|
19
|
+
|
|
20
|
+
this.serviceType = 'PictViewFlowNode';
|
|
21
|
+
|
|
22
|
+
this._FlowView = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create the SVG namespace element helper
|
|
27
|
+
* @param {string} pTagName
|
|
28
|
+
* @returns {SVGElement}
|
|
29
|
+
*/
|
|
30
|
+
_createSVGElement(pTagName)
|
|
31
|
+
{
|
|
32
|
+
return document.createElementNS('http://www.w3.org/2000/svg', pTagName);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Render a node into the nodes SVG layer
|
|
37
|
+
* @param {Object} pNodeData - The node data object
|
|
38
|
+
* @param {SVGGElement} pNodesLayer - The SVG <g> element to append to
|
|
39
|
+
* @param {boolean} pIsSelected - Whether this node is selected
|
|
40
|
+
* @param {Object} pNodeTypeConfig - The node type configuration
|
|
41
|
+
*/
|
|
42
|
+
renderNode(pNodeData, pNodesLayer, pIsSelected, pNodeTypeConfig)
|
|
43
|
+
{
|
|
44
|
+
let tmpGroup = this._createSVGElement('g');
|
|
45
|
+
tmpGroup.setAttribute('class', `pict-flow-node ${pIsSelected ? 'selected' : ''} pict-flow-node-${pNodeData.Type || 'default'}`);
|
|
46
|
+
tmpGroup.setAttribute('transform', `translate(${pNodeData.X}, ${pNodeData.Y})`);
|
|
47
|
+
tmpGroup.setAttribute('data-node-hash', pNodeData.Hash);
|
|
48
|
+
tmpGroup.setAttribute('data-element-type', 'node');
|
|
49
|
+
|
|
50
|
+
let tmpWidth = pNodeData.Width || 180;
|
|
51
|
+
let tmpHeight = pNodeData.Height || 80;
|
|
52
|
+
let tmpTitleBarHeight = this.options.NodeTitleBarHeight;
|
|
53
|
+
|
|
54
|
+
// Node body (main rectangle)
|
|
55
|
+
let tmpBody = this._createSVGElement('rect');
|
|
56
|
+
tmpBody.setAttribute('class', 'pict-flow-node-body');
|
|
57
|
+
tmpBody.setAttribute('x', '0');
|
|
58
|
+
tmpBody.setAttribute('y', '0');
|
|
59
|
+
tmpBody.setAttribute('width', String(tmpWidth));
|
|
60
|
+
tmpBody.setAttribute('height', String(tmpHeight));
|
|
61
|
+
tmpBody.setAttribute('data-node-hash', pNodeData.Hash);
|
|
62
|
+
tmpBody.setAttribute('data-element-type', 'node-body');
|
|
63
|
+
|
|
64
|
+
// Apply custom styles from node type
|
|
65
|
+
if (pNodeTypeConfig && pNodeTypeConfig.BodyStyle)
|
|
66
|
+
{
|
|
67
|
+
for (let tmpStyleKey in pNodeTypeConfig.BodyStyle)
|
|
68
|
+
{
|
|
69
|
+
tmpBody.setAttribute(tmpStyleKey, pNodeTypeConfig.BodyStyle[tmpStyleKey]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
tmpGroup.appendChild(tmpBody);
|
|
74
|
+
|
|
75
|
+
// Title bar background (top portion)
|
|
76
|
+
let tmpTitleBar = this._createSVGElement('rect');
|
|
77
|
+
tmpTitleBar.setAttribute('class', 'pict-flow-node-title-bar');
|
|
78
|
+
tmpTitleBar.setAttribute('x', '0');
|
|
79
|
+
tmpTitleBar.setAttribute('y', '0');
|
|
80
|
+
tmpTitleBar.setAttribute('width', String(tmpWidth));
|
|
81
|
+
tmpTitleBar.setAttribute('height', String(tmpTitleBarHeight));
|
|
82
|
+
tmpTitleBar.setAttribute('data-node-hash', pNodeData.Hash);
|
|
83
|
+
tmpTitleBar.setAttribute('data-element-type', 'node-body');
|
|
84
|
+
|
|
85
|
+
// Apply custom title bar color
|
|
86
|
+
if (pNodeTypeConfig && pNodeTypeConfig.TitleBarColor)
|
|
87
|
+
{
|
|
88
|
+
tmpTitleBar.setAttribute('fill', pNodeTypeConfig.TitleBarColor);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
tmpGroup.appendChild(tmpTitleBar);
|
|
92
|
+
|
|
93
|
+
// Title bar bottom fill (to square off the rounded corners at the bottom of the title bar)
|
|
94
|
+
let tmpTitleBarBottom = this._createSVGElement('rect');
|
|
95
|
+
tmpTitleBarBottom.setAttribute('class', 'pict-flow-node-title-bar-bottom');
|
|
96
|
+
tmpTitleBarBottom.setAttribute('x', '0');
|
|
97
|
+
tmpTitleBarBottom.setAttribute('y', String(tmpTitleBarHeight - 6));
|
|
98
|
+
tmpTitleBarBottom.setAttribute('width', String(tmpWidth));
|
|
99
|
+
tmpTitleBarBottom.setAttribute('height', '6');
|
|
100
|
+
tmpTitleBarBottom.setAttribute('data-node-hash', pNodeData.Hash);
|
|
101
|
+
tmpTitleBarBottom.setAttribute('data-element-type', 'node-body');
|
|
102
|
+
|
|
103
|
+
if (pNodeTypeConfig && pNodeTypeConfig.TitleBarColor)
|
|
104
|
+
{
|
|
105
|
+
tmpTitleBarBottom.setAttribute('fill', pNodeTypeConfig.TitleBarColor);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
tmpGroup.appendChild(tmpTitleBarBottom);
|
|
109
|
+
|
|
110
|
+
// Title text
|
|
111
|
+
let tmpTitle = this._createSVGElement('text');
|
|
112
|
+
tmpTitle.setAttribute('class', 'pict-flow-node-title');
|
|
113
|
+
tmpTitle.setAttribute('x', String(tmpWidth / 2));
|
|
114
|
+
tmpTitle.setAttribute('y', String(tmpTitleBarHeight / 2 + 1));
|
|
115
|
+
tmpTitle.setAttribute('text-anchor', 'middle');
|
|
116
|
+
tmpTitle.setAttribute('dominant-baseline', 'central');
|
|
117
|
+
tmpTitle.textContent = pNodeData.Title || 'Untitled';
|
|
118
|
+
tmpGroup.appendChild(tmpTitle);
|
|
119
|
+
|
|
120
|
+
// Type label (below title bar)
|
|
121
|
+
if (pNodeTypeConfig && pNodeTypeConfig.Label && pNodeTypeConfig.Label !== pNodeData.Title)
|
|
122
|
+
{
|
|
123
|
+
let tmpTypeLabel = this._createSVGElement('text');
|
|
124
|
+
tmpTypeLabel.setAttribute('class', 'pict-flow-node-type-label');
|
|
125
|
+
tmpTypeLabel.setAttribute('x', String(tmpWidth / 2));
|
|
126
|
+
tmpTypeLabel.setAttribute('y', String(tmpTitleBarHeight + 18));
|
|
127
|
+
tmpTypeLabel.setAttribute('text-anchor', 'middle');
|
|
128
|
+
tmpTypeLabel.setAttribute('dominant-baseline', 'central');
|
|
129
|
+
tmpTypeLabel.textContent = pNodeTypeConfig.Label;
|
|
130
|
+
tmpGroup.appendChild(tmpTypeLabel);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Render ports
|
|
134
|
+
this._renderPorts(pNodeData, tmpGroup, tmpWidth, tmpHeight);
|
|
135
|
+
|
|
136
|
+
pNodesLayer.appendChild(tmpGroup);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Render ports for a node
|
|
141
|
+
* @param {Object} pNodeData
|
|
142
|
+
* @param {SVGGElement} pGroup - The node's SVG group
|
|
143
|
+
* @param {number} pWidth
|
|
144
|
+
* @param {number} pHeight
|
|
145
|
+
*/
|
|
146
|
+
_renderPorts(pNodeData, pGroup, pWidth, pHeight)
|
|
147
|
+
{
|
|
148
|
+
if (!pNodeData.Ports || !Array.isArray(pNodeData.Ports)) return;
|
|
149
|
+
|
|
150
|
+
// Group ports by side and direction for positioning
|
|
151
|
+
let tmpPortsBySide = { left: [], right: [], top: [], bottom: [] };
|
|
152
|
+
for (let i = 0; i < pNodeData.Ports.length; i++)
|
|
153
|
+
{
|
|
154
|
+
let tmpPort = pNodeData.Ports[i];
|
|
155
|
+
let tmpSide = tmpPort.Side || (tmpPort.Direction === 'input' ? 'left' : 'right');
|
|
156
|
+
if (tmpPortsBySide[tmpSide])
|
|
157
|
+
{
|
|
158
|
+
tmpPortsBySide[tmpSide].push(tmpPort);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (let tmpSide in tmpPortsBySide)
|
|
163
|
+
{
|
|
164
|
+
let tmpPorts = tmpPortsBySide[tmpSide];
|
|
165
|
+
for (let i = 0; i < tmpPorts.length; i++)
|
|
166
|
+
{
|
|
167
|
+
let tmpPort = tmpPorts[i];
|
|
168
|
+
let tmpPosition = this._getPortLocalPosition(tmpSide, i, tmpPorts.length, pWidth, pHeight);
|
|
169
|
+
|
|
170
|
+
// Port circle
|
|
171
|
+
let tmpCircle = this._createSVGElement('circle');
|
|
172
|
+
tmpCircle.setAttribute('class', `pict-flow-port ${tmpPort.Direction}`);
|
|
173
|
+
tmpCircle.setAttribute('cx', String(tmpPosition.x));
|
|
174
|
+
tmpCircle.setAttribute('cy', String(tmpPosition.y));
|
|
175
|
+
tmpCircle.setAttribute('r', '5');
|
|
176
|
+
tmpCircle.setAttribute('data-port-hash', tmpPort.Hash);
|
|
177
|
+
tmpCircle.setAttribute('data-node-hash', pNodeData.Hash);
|
|
178
|
+
tmpCircle.setAttribute('data-port-direction', tmpPort.Direction);
|
|
179
|
+
tmpCircle.setAttribute('data-element-type', 'port');
|
|
180
|
+
pGroup.appendChild(tmpCircle);
|
|
181
|
+
|
|
182
|
+
// Port label
|
|
183
|
+
if (tmpPort.Label)
|
|
184
|
+
{
|
|
185
|
+
let tmpLabel = this._createSVGElement('text');
|
|
186
|
+
tmpLabel.setAttribute('class', 'pict-flow-port-label');
|
|
187
|
+
tmpLabel.textContent = tmpPort.Label;
|
|
188
|
+
|
|
189
|
+
let tmpLabelOffset = 12;
|
|
190
|
+
switch (tmpSide)
|
|
191
|
+
{
|
|
192
|
+
case 'left':
|
|
193
|
+
tmpLabel.setAttribute('x', String(tmpPosition.x + tmpLabelOffset));
|
|
194
|
+
tmpLabel.setAttribute('y', String(tmpPosition.y));
|
|
195
|
+
tmpLabel.setAttribute('text-anchor', 'start');
|
|
196
|
+
break;
|
|
197
|
+
case 'right':
|
|
198
|
+
tmpLabel.setAttribute('x', String(tmpPosition.x - tmpLabelOffset));
|
|
199
|
+
tmpLabel.setAttribute('y', String(tmpPosition.y));
|
|
200
|
+
tmpLabel.setAttribute('text-anchor', 'end');
|
|
201
|
+
break;
|
|
202
|
+
case 'top':
|
|
203
|
+
tmpLabel.setAttribute('x', String(tmpPosition.x));
|
|
204
|
+
tmpLabel.setAttribute('y', String(tmpPosition.y + tmpLabelOffset));
|
|
205
|
+
tmpLabel.setAttribute('text-anchor', 'middle');
|
|
206
|
+
break;
|
|
207
|
+
case 'bottom':
|
|
208
|
+
tmpLabel.setAttribute('x', String(tmpPosition.x));
|
|
209
|
+
tmpLabel.setAttribute('y', String(tmpPosition.y - tmpLabelOffset));
|
|
210
|
+
tmpLabel.setAttribute('text-anchor', 'middle');
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
tmpLabel.setAttribute('dominant-baseline', 'central');
|
|
214
|
+
pGroup.appendChild(tmpLabel);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Calculate port position relative to node origin.
|
|
222
|
+
*
|
|
223
|
+
* For left and right side ports, positioning is offset below the title bar
|
|
224
|
+
* so that ports never overlap the header area.
|
|
225
|
+
*
|
|
226
|
+
* @param {string} pSide - 'left', 'right', 'top', 'bottom'
|
|
227
|
+
* @param {number} pIndex - Index of this port on its side
|
|
228
|
+
* @param {number} pTotal - Total ports on this side
|
|
229
|
+
* @param {number} pWidth - Node width
|
|
230
|
+
* @param {number} pHeight - Node height
|
|
231
|
+
* @returns {{x: number, y: number}}
|
|
232
|
+
*/
|
|
233
|
+
_getPortLocalPosition(pSide, pIndex, pTotal, pWidth, pHeight)
|
|
234
|
+
{
|
|
235
|
+
let tmpSpacing;
|
|
236
|
+
let tmpTitleBarHeight = this.options.NodeTitleBarHeight;
|
|
237
|
+
|
|
238
|
+
switch (pSide)
|
|
239
|
+
{
|
|
240
|
+
case 'left':
|
|
241
|
+
{
|
|
242
|
+
// Distribute ports in the body area below the title bar
|
|
243
|
+
let tmpBodyHeight = pHeight - tmpTitleBarHeight;
|
|
244
|
+
tmpSpacing = tmpBodyHeight / (pTotal + 1);
|
|
245
|
+
return { x: 0, y: tmpTitleBarHeight + tmpSpacing * (pIndex + 1) };
|
|
246
|
+
}
|
|
247
|
+
case 'right':
|
|
248
|
+
{
|
|
249
|
+
let tmpBodyHeight = pHeight - tmpTitleBarHeight;
|
|
250
|
+
tmpSpacing = tmpBodyHeight / (pTotal + 1);
|
|
251
|
+
return { x: pWidth, y: tmpTitleBarHeight + tmpSpacing * (pIndex + 1) };
|
|
252
|
+
}
|
|
253
|
+
case 'top':
|
|
254
|
+
tmpSpacing = pWidth / (pTotal + 1);
|
|
255
|
+
return { x: tmpSpacing * (pIndex + 1), y: 0 };
|
|
256
|
+
case 'bottom':
|
|
257
|
+
tmpSpacing = pWidth / (pTotal + 1);
|
|
258
|
+
return { x: tmpSpacing * (pIndex + 1), y: pHeight };
|
|
259
|
+
default:
|
|
260
|
+
return { x: pWidth, y: pHeight / 2 };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = PictViewFlowNode;
|
|
266
|
+
|
|
267
|
+
module.exports.default_configuration = _DefaultConfiguration;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const libPictView = require('pict-view');
|
|
2
|
+
|
|
3
|
+
const _DefaultConfiguration =
|
|
4
|
+
{
|
|
5
|
+
ViewIdentifier: 'Flow-Toolbar',
|
|
6
|
+
|
|
7
|
+
DefaultRenderable: 'Flow-Toolbar-Content',
|
|
8
|
+
DefaultDestinationAddress: '#Flow-Toolbar-Container',
|
|
9
|
+
|
|
10
|
+
AutoRender: false,
|
|
11
|
+
|
|
12
|
+
FlowViewIdentifier: 'Pict-Flow',
|
|
13
|
+
|
|
14
|
+
CSS: /*css*/`
|
|
15
|
+
.pict-flow-toolbar {
|
|
16
|
+
display: flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: 0.5em;
|
|
19
|
+
padding: 0.5em 0.75em;
|
|
20
|
+
background-color: #ffffff;
|
|
21
|
+
border-bottom: 1px solid #e0e0e0;
|
|
22
|
+
flex-wrap: wrap;
|
|
23
|
+
}
|
|
24
|
+
.pict-flow-toolbar-group {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
gap: 0.25em;
|
|
28
|
+
padding-right: 0.75em;
|
|
29
|
+
border-right: 1px solid #e0e0e0;
|
|
30
|
+
}
|
|
31
|
+
.pict-flow-toolbar-group:last-child {
|
|
32
|
+
border-right: none;
|
|
33
|
+
padding-right: 0;
|
|
34
|
+
}
|
|
35
|
+
.pict-flow-toolbar-btn {
|
|
36
|
+
display: inline-flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
justify-content: center;
|
|
39
|
+
padding: 0.35em 0.65em;
|
|
40
|
+
border: 1px solid #bdc3c7;
|
|
41
|
+
border-radius: 4px;
|
|
42
|
+
background-color: #fff;
|
|
43
|
+
color: #2c3e50;
|
|
44
|
+
font-size: 0.85em;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
transition: background-color 0.15s, border-color 0.15s;
|
|
47
|
+
user-select: none;
|
|
48
|
+
-webkit-user-select: none;
|
|
49
|
+
}
|
|
50
|
+
.pict-flow-toolbar-btn:hover {
|
|
51
|
+
background-color: #ecf0f1;
|
|
52
|
+
border-color: #95a5a6;
|
|
53
|
+
}
|
|
54
|
+
.pict-flow-toolbar-btn:active {
|
|
55
|
+
background-color: #d5dbdb;
|
|
56
|
+
}
|
|
57
|
+
.pict-flow-toolbar-btn.danger {
|
|
58
|
+
color: #e74c3c;
|
|
59
|
+
border-color: #e74c3c;
|
|
60
|
+
}
|
|
61
|
+
.pict-flow-toolbar-btn.danger:hover {
|
|
62
|
+
background-color: #fdedec;
|
|
63
|
+
}
|
|
64
|
+
.pict-flow-toolbar-label {
|
|
65
|
+
font-size: 0.8em;
|
|
66
|
+
color: #7f8c8d;
|
|
67
|
+
margin-right: 0.25em;
|
|
68
|
+
}
|
|
69
|
+
.pict-flow-toolbar-select {
|
|
70
|
+
padding: 0.3em 0.5em;
|
|
71
|
+
border: 1px solid #bdc3c7;
|
|
72
|
+
border-radius: 4px;
|
|
73
|
+
font-size: 0.85em;
|
|
74
|
+
background-color: #fff;
|
|
75
|
+
color: #2c3e50;
|
|
76
|
+
}
|
|
77
|
+
`,
|
|
78
|
+
|
|
79
|
+
Templates:
|
|
80
|
+
[
|
|
81
|
+
{
|
|
82
|
+
Hash: 'Flow-Toolbar-Template',
|
|
83
|
+
Template: /*html*/`
|
|
84
|
+
<div class="pict-flow-toolbar">
|
|
85
|
+
<div class="pict-flow-toolbar-group">
|
|
86
|
+
<span class="pict-flow-toolbar-label">Node:</span>
|
|
87
|
+
<select class="pict-flow-toolbar-select" id="Flow-Toolbar-NodeType-{~D:Record.FlowViewIdentifier~}">
|
|
88
|
+
<option value="default">Default</option>
|
|
89
|
+
<option value="start">Start</option>
|
|
90
|
+
<option value="end">End</option>
|
|
91
|
+
<option value="decision">Decision</option>
|
|
92
|
+
</select>
|
|
93
|
+
<button class="pict-flow-toolbar-btn" data-flow-action="add-node">+ Add Node</button>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="pict-flow-toolbar-group">
|
|
96
|
+
<button class="pict-flow-toolbar-btn danger" data-flow-action="delete-selected">Delete</button>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="pict-flow-toolbar-group">
|
|
99
|
+
<button class="pict-flow-toolbar-btn" data-flow-action="zoom-in">Zoom +</button>
|
|
100
|
+
<button class="pict-flow-toolbar-btn" data-flow-action="zoom-out">Zoom −</button>
|
|
101
|
+
<button class="pict-flow-toolbar-btn" data-flow-action="zoom-fit">Fit</button>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="pict-flow-toolbar-group">
|
|
104
|
+
<button class="pict-flow-toolbar-btn" data-flow-action="auto-layout">Auto Layout</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
`
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
|
|
111
|
+
Renderables:
|
|
112
|
+
[
|
|
113
|
+
{
|
|
114
|
+
RenderableHash: 'Flow-Toolbar-Content',
|
|
115
|
+
TemplateHash: 'Flow-Toolbar-Template',
|
|
116
|
+
DestinationAddress: '#Flow-Toolbar-Container',
|
|
117
|
+
RenderMethod: 'replace'
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
class PictViewFlowToolbar extends libPictView
|
|
123
|
+
{
|
|
124
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
125
|
+
{
|
|
126
|
+
let tmpOptions = Object.assign({}, JSON.parse(JSON.stringify(_DefaultConfiguration)), pOptions);
|
|
127
|
+
super(pFable, tmpOptions, pServiceHash);
|
|
128
|
+
|
|
129
|
+
this.serviceType = 'PictViewFlowToolbar';
|
|
130
|
+
|
|
131
|
+
this._FlowView = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent)
|
|
135
|
+
{
|
|
136
|
+
// Bind toolbar button events via event delegation
|
|
137
|
+
let tmpToolbarElements = this.pict.ContentAssignment.getElement(`.pict-flow-toolbar`);
|
|
138
|
+
if (tmpToolbarElements.length > 0)
|
|
139
|
+
{
|
|
140
|
+
let tmpToolbar = tmpToolbarElements[0];
|
|
141
|
+
tmpToolbar.addEventListener('click', (pEvent) =>
|
|
142
|
+
{
|
|
143
|
+
let tmpTarget = pEvent.target;
|
|
144
|
+
if (!tmpTarget) return;
|
|
145
|
+
|
|
146
|
+
// Walk up to find the button with the action
|
|
147
|
+
let tmpButton = tmpTarget.closest('[data-flow-action]');
|
|
148
|
+
if (!tmpButton) return;
|
|
149
|
+
|
|
150
|
+
let tmpAction = tmpButton.getAttribute('data-flow-action');
|
|
151
|
+
this._handleToolbarAction(tmpAction);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return super.onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Handle a toolbar action
|
|
160
|
+
* @param {string} pAction
|
|
161
|
+
*/
|
|
162
|
+
_handleToolbarAction(pAction)
|
|
163
|
+
{
|
|
164
|
+
if (!this._FlowView) return;
|
|
165
|
+
|
|
166
|
+
let tmpFlowViewIdentifier = this.options.FlowViewIdentifier;
|
|
167
|
+
|
|
168
|
+
switch (pAction)
|
|
169
|
+
{
|
|
170
|
+
case 'add-node':
|
|
171
|
+
{
|
|
172
|
+
// Get selected node type from dropdown
|
|
173
|
+
let tmpSelectElements = this.pict.ContentAssignment.getElement(`#Flow-Toolbar-NodeType-${tmpFlowViewIdentifier}`);
|
|
174
|
+
let tmpNodeType = 'default';
|
|
175
|
+
if (tmpSelectElements.length > 0)
|
|
176
|
+
{
|
|
177
|
+
tmpNodeType = tmpSelectElements[0].value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Place the new node at a reasonable position
|
|
181
|
+
let tmpVS = this._FlowView.viewState;
|
|
182
|
+
let tmpX = (-tmpVS.PanX + 200) / tmpVS.Zoom;
|
|
183
|
+
let tmpY = (-tmpVS.PanY + 200) / tmpVS.Zoom;
|
|
184
|
+
|
|
185
|
+
// Offset if there are existing nodes to avoid overlap
|
|
186
|
+
let tmpNodeCount = this._FlowView.flowData.Nodes.length;
|
|
187
|
+
tmpX += (tmpNodeCount % 5) * 40;
|
|
188
|
+
tmpY += (tmpNodeCount % 5) * 40;
|
|
189
|
+
|
|
190
|
+
this._FlowView.addNode(tmpNodeType, tmpX, tmpY);
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
|
|
194
|
+
case 'delete-selected':
|
|
195
|
+
this._FlowView.deleteSelected();
|
|
196
|
+
break;
|
|
197
|
+
|
|
198
|
+
case 'zoom-in':
|
|
199
|
+
this._FlowView.setZoom(this._FlowView.viewState.Zoom + this._FlowView.options.ZoomStep);
|
|
200
|
+
break;
|
|
201
|
+
|
|
202
|
+
case 'zoom-out':
|
|
203
|
+
this._FlowView.setZoom(this._FlowView.viewState.Zoom - this._FlowView.options.ZoomStep);
|
|
204
|
+
break;
|
|
205
|
+
|
|
206
|
+
case 'zoom-fit':
|
|
207
|
+
this._FlowView.zoomToFit();
|
|
208
|
+
break;
|
|
209
|
+
|
|
210
|
+
case 'auto-layout':
|
|
211
|
+
this._FlowView.autoLayout();
|
|
212
|
+
break;
|
|
213
|
+
|
|
214
|
+
default:
|
|
215
|
+
this.log.warn(`PictViewFlowToolbar: unknown action '${pAction}'`);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = PictViewFlowToolbar;
|
|
222
|
+
|
|
223
|
+
module.exports.default_configuration = _DefaultConfiguration;
|