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.
Files changed (23) hide show
  1. package/LICENSE +21 -0
  2. package/example_application/css/flowexample.css +65 -0
  3. package/example_application/html/index.html +32 -0
  4. package/example_application/package.json +41 -0
  5. package/example_application/source/Pict-Application-FlowExample-Configuration.json +15 -0
  6. package/example_application/source/Pict-Application-FlowExample.js +241 -0
  7. package/example_application/source/providers/PictRouter-FlowExample-Configuration.json +22 -0
  8. package/example_application/source/views/PictView-FlowExample-About.js +184 -0
  9. package/example_application/source/views/PictView-FlowExample-BottomBar.js +77 -0
  10. package/example_application/source/views/PictView-FlowExample-Documentation.js +325 -0
  11. package/example_application/source/views/PictView-FlowExample-Layout.js +86 -0
  12. package/example_application/source/views/PictView-FlowExample-MainWorkspace.js +191 -0
  13. package/example_application/source/views/PictView-FlowExample-TopBar.js +95 -0
  14. package/package.json +22 -0
  15. package/source/Pict-Section-Flow.js +19 -0
  16. package/source/providers/PictProvider-Flow-EventHandler.js +158 -0
  17. package/source/providers/PictProvider-Flow-NodeTypes.js +174 -0
  18. package/source/services/PictService-Flow-ConnectionRenderer.js +251 -0
  19. package/source/services/PictService-Flow-InteractionManager.js +567 -0
  20. package/source/services/PictService-Flow-Layout.js +207 -0
  21. package/source/views/PictView-Flow-Node.js +267 -0
  22. package/source/views/PictView-Flow-Toolbar.js +223 -0
  23. package/source/views/PictView-Flow.js +1116 -0
@@ -0,0 +1,1116 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ const libPictServiceFlowInteractionManager = require('../services/PictService-Flow-InteractionManager.js');
4
+ const libPictServiceFlowConnectionRenderer = require('../services/PictService-Flow-ConnectionRenderer.js');
5
+ const libPictServiceFlowLayout = require('../services/PictService-Flow-Layout.js');
6
+
7
+ const libPictProviderFlowNodeTypes = require('../providers/PictProvider-Flow-NodeTypes.js');
8
+ const libPictProviderFlowEventHandler = require('../providers/PictProvider-Flow-EventHandler.js');
9
+
10
+ const libPictViewFlowNode = require('./PictView-Flow-Node.js');
11
+ const libPictViewFlowToolbar = require('./PictView-Flow-Toolbar.js');
12
+
13
+ const _DefaultConfiguration =
14
+ {
15
+ ViewIdentifier: 'Pict-Flow',
16
+
17
+ DefaultRenderable: 'Flow-Container',
18
+ DefaultDestinationAddress: '#Flow-Container',
19
+
20
+ AutoRender: false,
21
+
22
+ FlowDataAddress: false,
23
+
24
+ TargetElementAddress: '#Flow-SVG-Container',
25
+
26
+ EnableToolbar: true,
27
+ EnablePanning: true,
28
+ EnableZooming: true,
29
+ EnableNodeDragging: true,
30
+ EnableConnectionCreation: true,
31
+ EnableGridSnap: false,
32
+ GridSnapSize: 20,
33
+
34
+ MinZoom: 0.1,
35
+ MaxZoom: 5.0,
36
+ ZoomStep: 0.1,
37
+
38
+ DefaultNodeType: 'default',
39
+ DefaultNodeWidth: 180,
40
+ DefaultNodeHeight: 80,
41
+
42
+ CSS: /*css*/`
43
+ .pict-flow-container {
44
+ position: relative;
45
+ width: 100%;
46
+ height: 100%;
47
+ min-height: 400px;
48
+ overflow: hidden;
49
+ background-color: #fafafa;
50
+ border: 1px solid #e0e0e0;
51
+ border-radius: 4px;
52
+ }
53
+ .pict-flow-svg {
54
+ width: 100%;
55
+ height: 100%;
56
+ min-height: 400px;
57
+ cursor: grab;
58
+ user-select: none;
59
+ -webkit-user-select: none;
60
+ }
61
+ .pict-flow-svg.panning {
62
+ cursor: grabbing;
63
+ }
64
+ .pict-flow-svg.connecting {
65
+ cursor: crosshair;
66
+ }
67
+ .pict-flow-grid-pattern line {
68
+ stroke: #e8e8e8;
69
+ stroke-width: 0.5;
70
+ }
71
+ .pict-flow-node {
72
+ cursor: pointer;
73
+ }
74
+ .pict-flow-node:hover .pict-flow-node-body {
75
+ filter: brightness(0.97);
76
+ }
77
+ .pict-flow-node.selected .pict-flow-node-body {
78
+ stroke: #3498db;
79
+ stroke-width: 2.5;
80
+ }
81
+ .pict-flow-node.dragging {
82
+ opacity: 0.85;
83
+ cursor: grabbing;
84
+ }
85
+ .pict-flow-node-body {
86
+ fill: #ffffff;
87
+ stroke: #bdc3c7;
88
+ stroke-width: 1.5;
89
+ rx: 6;
90
+ ry: 6;
91
+ transition: filter 0.15s;
92
+ }
93
+ .pict-flow-node-title-bar {
94
+ fill: #2c3e50;
95
+ rx: 6;
96
+ ry: 6;
97
+ }
98
+ .pict-flow-node-title-bar-bottom {
99
+ fill: #2c3e50;
100
+ }
101
+ .pict-flow-node-title {
102
+ fill: #ffffff;
103
+ font-size: 12px;
104
+ font-weight: 700;
105
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
106
+ pointer-events: none;
107
+ }
108
+ .pict-flow-node-type-label {
109
+ fill: #95a5a6;
110
+ font-size: 10px;
111
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
112
+ pointer-events: none;
113
+ }
114
+ .pict-flow-port {
115
+ cursor: crosshair;
116
+ transition: r 0.15s;
117
+ }
118
+ .pict-flow-port.input {
119
+ fill: #3498db;
120
+ stroke: #2980b9;
121
+ stroke-width: 1.5;
122
+ }
123
+ .pict-flow-port.output {
124
+ fill: #2ecc71;
125
+ stroke: #27ae60;
126
+ stroke-width: 1.5;
127
+ }
128
+ .pict-flow-port:hover {
129
+ r: 7;
130
+ }
131
+ .pict-flow-port-label {
132
+ fill: #7f8c8d;
133
+ font-size: 9px;
134
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
135
+ pointer-events: none;
136
+ }
137
+ .pict-flow-connection {
138
+ fill: none;
139
+ stroke: #95a5a6;
140
+ stroke-width: 2;
141
+ cursor: pointer;
142
+ transition: stroke 0.15s;
143
+ }
144
+ .pict-flow-connection:hover {
145
+ stroke: #7f8c8d;
146
+ stroke-width: 3;
147
+ }
148
+ .pict-flow-connection.selected {
149
+ stroke: #3498db;
150
+ stroke-width: 3;
151
+ }
152
+ .pict-flow-connection-hitarea {
153
+ fill: none;
154
+ stroke: transparent;
155
+ stroke-width: 12;
156
+ cursor: pointer;
157
+ }
158
+ .pict-flow-drag-connection {
159
+ fill: none;
160
+ stroke: #3498db;
161
+ stroke-width: 2;
162
+ stroke-dasharray: 6 3;
163
+ pointer-events: none;
164
+ }
165
+ .pict-flow-node-decision .pict-flow-node-body {
166
+ fill: #fff9e6;
167
+ stroke: #f39c12;
168
+ }
169
+ .pict-flow-node-start .pict-flow-node-body {
170
+ fill: #eafaf1;
171
+ stroke: #27ae60;
172
+ rx: 25;
173
+ ry: 25;
174
+ }
175
+ .pict-flow-node-end .pict-flow-node-body {
176
+ fill: #fdedec;
177
+ stroke: #e74c3c;
178
+ rx: 25;
179
+ ry: 25;
180
+ }
181
+ `,
182
+
183
+ Templates:
184
+ [
185
+ {
186
+ Hash: 'Flow-Container-Template',
187
+ Template: /*html*/`
188
+ <div class="pict-flow-container" id="Flow-Wrapper-{~D:Record.ViewIdentifier~}">
189
+ <div id="Flow-Toolbar-{~D:Record.ViewIdentifier~}"></div>
190
+ <div id="Flow-SVG-Container-{~D:Record.ViewIdentifier~}">
191
+ <svg class="pict-flow-svg"
192
+ id="Flow-SVG-{~D:Record.ViewIdentifier~}"
193
+ xmlns="http://www.w3.org/2000/svg">
194
+ <defs>
195
+ <marker id="flow-arrowhead-{~D:Record.ViewIdentifier~}"
196
+ markerWidth="5" markerHeight="7"
197
+ refX="7.5" refY="3.5"
198
+ orient="auto" markerUnits="strokeWidth">
199
+ <polygon points="0 0, 5 3.5, 0 7" fill="#95a5a6" />
200
+ </marker>
201
+ <marker id="flow-arrowhead-selected-{~D:Record.ViewIdentifier~}"
202
+ markerWidth="5" markerHeight="7"
203
+ refX="7.5" refY="3.5"
204
+ orient="auto" markerUnits="strokeWidth">
205
+ <polygon points="0 0, 5 3.5, 0 7" fill="#3498db" />
206
+ </marker>
207
+ <pattern id="flow-grid-{~D:Record.ViewIdentifier~}"
208
+ width="20" height="20" patternUnits="userSpaceOnUse">
209
+ <line x1="20" y1="0" x2="20" y2="20" class="pict-flow-grid-pattern" />
210
+ <line x1="0" y1="20" x2="20" y2="20" class="pict-flow-grid-pattern" />
211
+ </pattern>
212
+ </defs>
213
+ <rect width="10000" height="10000" x="-5000" y="-5000"
214
+ fill="url(#flow-grid-{~D:Record.ViewIdentifier~})"
215
+ class="pict-flow-grid-background" />
216
+ <g class="pict-flow-viewport" id="Flow-Viewport-{~D:Record.ViewIdentifier~}">
217
+ <g class="pict-flow-connections-layer" id="Flow-Connections-{~D:Record.ViewIdentifier~}"></g>
218
+ <g class="pict-flow-nodes-layer" id="Flow-Nodes-{~D:Record.ViewIdentifier~}"></g>
219
+ </g>
220
+ </svg>
221
+ </div>
222
+ </div>
223
+ `
224
+ }
225
+ ],
226
+
227
+ Renderables:
228
+ [
229
+ {
230
+ RenderableHash: 'Flow-Container',
231
+ TemplateHash: 'Flow-Container-Template',
232
+ DestinationAddress: '#Flow-Container',
233
+ RenderMethod: 'replace'
234
+ }
235
+ ]
236
+ };
237
+
238
+ class PictViewFlow extends libPictView
239
+ {
240
+ constructor(pFable, pOptions, pServiceHash)
241
+ {
242
+ let tmpOptions = Object.assign({}, JSON.parse(JSON.stringify(_DefaultConfiguration)), pOptions);
243
+ super(pFable, tmpOptions, pServiceHash);
244
+
245
+ this.serviceType = 'PictSectionFlow';
246
+
247
+ // Register service types with fable so they can be instantiated
248
+ if (!this.fable.servicesMap.hasOwnProperty('PictServiceFlowInteractionManager'))
249
+ {
250
+ this.fable.addServiceType('PictServiceFlowInteractionManager', libPictServiceFlowInteractionManager);
251
+ }
252
+ if (!this.fable.servicesMap.hasOwnProperty('PictServiceFlowConnectionRenderer'))
253
+ {
254
+ this.fable.addServiceType('PictServiceFlowConnectionRenderer', libPictServiceFlowConnectionRenderer);
255
+ }
256
+ if (!this.fable.servicesMap.hasOwnProperty('PictServiceFlowLayout'))
257
+ {
258
+ this.fable.addServiceType('PictServiceFlowLayout', libPictServiceFlowLayout);
259
+ }
260
+ if (!this.fable.servicesMap.hasOwnProperty('PictProviderFlowNodeTypes'))
261
+ {
262
+ this.fable.addServiceType('PictProviderFlowNodeTypes', libPictProviderFlowNodeTypes);
263
+ }
264
+ if (!this.fable.servicesMap.hasOwnProperty('PictProviderFlowEventHandler'))
265
+ {
266
+ this.fable.addServiceType('PictProviderFlowEventHandler', libPictProviderFlowEventHandler);
267
+ }
268
+ if (!this.fable.servicesMap.hasOwnProperty('PictViewFlowNode'))
269
+ {
270
+ this.fable.addServiceType('PictViewFlowNode', libPictViewFlowNode);
271
+ }
272
+ if (!this.fable.servicesMap.hasOwnProperty('PictViewFlowToolbar'))
273
+ {
274
+ this.fable.addServiceType('PictViewFlowToolbar', libPictViewFlowToolbar);
275
+ }
276
+
277
+ // Internal state
278
+ this._FlowData = {
279
+ Nodes: [],
280
+ Connections: [],
281
+ ViewState: {
282
+ PanX: 0,
283
+ PanY: 0,
284
+ Zoom: 1,
285
+ SelectedNodeHash: null,
286
+ SelectedConnectionHash: null
287
+ }
288
+ };
289
+
290
+ this._SVGElement = null;
291
+ this._ViewportElement = null;
292
+ this._NodesLayer = null;
293
+ this._ConnectionsLayer = null;
294
+
295
+ this._InteractionManager = null;
296
+ this._ConnectionRenderer = null;
297
+ this._LayoutService = null;
298
+ this._NodeTypeProvider = null;
299
+ this._EventHandlerProvider = null;
300
+ this._NodeView = null;
301
+ this._ToolbarView = null;
302
+
303
+ this.initialRenderComplete = false;
304
+ }
305
+
306
+ get flowData()
307
+ {
308
+ return this._FlowData;
309
+ }
310
+
311
+ get viewState()
312
+ {
313
+ return this._FlowData.ViewState;
314
+ }
315
+
316
+ /**
317
+ * Override render to pass view options as the template record,
318
+ * so template expressions like {~D:Record.ViewIdentifier~} resolve correctly.
319
+ */
320
+ render(pRenderableHash, pRenderDestinationAddress)
321
+ {
322
+ return super.render(pRenderableHash, pRenderDestinationAddress, this.options);
323
+ }
324
+
325
+ renderAsync(pRenderableHash, pRenderDestinationAddress, pTemplateRecordAddress, pRootRenderable, fCallback)
326
+ {
327
+ // If no record address is explicitly provided, use this.options as the record
328
+ if (typeof pTemplateRecordAddress === 'function' || typeof pTemplateRecordAddress === 'undefined')
329
+ {
330
+ return super.renderAsync(pRenderableHash, pRenderDestinationAddress, this.options, pRootRenderable, pTemplateRecordAddress || fCallback);
331
+ }
332
+ return super.renderAsync(pRenderableHash, pRenderDestinationAddress, pTemplateRecordAddress, pRootRenderable, fCallback);
333
+ }
334
+
335
+ onBeforeInitialize()
336
+ {
337
+ super.onBeforeInitialize();
338
+
339
+ // Register services
340
+ this._InteractionManager = this.fable.instantiateServiceProviderWithoutRegistration('PictServiceFlowInteractionManager', { FlowView: this });
341
+ this._ConnectionRenderer = this.fable.instantiateServiceProviderWithoutRegistration('PictServiceFlowConnectionRenderer', { FlowView: this });
342
+ this._LayoutService = this.fable.instantiateServiceProviderWithoutRegistration('PictServiceFlowLayout', { FlowView: this });
343
+
344
+ // Register providers
345
+ this._NodeTypeProvider = this.fable.instantiateServiceProviderWithoutRegistration('PictProviderFlowNodeTypes', { FlowView: this });
346
+ this._EventHandlerProvider = this.fable.instantiateServiceProviderWithoutRegistration('PictProviderFlowEventHandler', { FlowView: this });
347
+
348
+ return super.onBeforeInitialize();
349
+ }
350
+
351
+ onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent)
352
+ {
353
+ if (!this.initialRenderComplete)
354
+ {
355
+ this.onAfterInitialRender();
356
+ this.initialRenderComplete = true;
357
+ }
358
+ return super.onAfterRender(pRenderable, pRenderDestinationAddress, pRecord, pContent);
359
+ }
360
+
361
+ onAfterInitialRender()
362
+ {
363
+ let tmpViewIdentifier = this.options.ViewIdentifier;
364
+
365
+ // Grab SVG DOM elements
366
+ let tmpSVGElements = this.pict.ContentAssignment.getElement(`#Flow-SVG-${tmpViewIdentifier}`);
367
+ if (tmpSVGElements.length < 1)
368
+ {
369
+ this.log.error(`PictSectionFlow could not find SVG element #Flow-SVG-${tmpViewIdentifier}`);
370
+ return false;
371
+ }
372
+ this._SVGElement = tmpSVGElements[0];
373
+
374
+ let tmpViewportElements = this.pict.ContentAssignment.getElement(`#Flow-Viewport-${tmpViewIdentifier}`);
375
+ if (tmpViewportElements.length > 0)
376
+ {
377
+ this._ViewportElement = tmpViewportElements[0];
378
+ }
379
+
380
+ let tmpNodesElements = this.pict.ContentAssignment.getElement(`#Flow-Nodes-${tmpViewIdentifier}`);
381
+ if (tmpNodesElements.length > 0)
382
+ {
383
+ this._NodesLayer = tmpNodesElements[0];
384
+ }
385
+
386
+ let tmpConnectionsElements = this.pict.ContentAssignment.getElement(`#Flow-Connections-${tmpViewIdentifier}`);
387
+ if (tmpConnectionsElements.length > 0)
388
+ {
389
+ this._ConnectionsLayer = tmpConnectionsElements[0];
390
+ }
391
+
392
+ // Initialize services with references
393
+ if (!this._InteractionManager)
394
+ {
395
+ this._InteractionManager = this.fable.instantiateServiceProviderWithoutRegistration('PictServiceFlowInteractionManager', { FlowView: this });
396
+ }
397
+ if (!this._ConnectionRenderer)
398
+ {
399
+ this._ConnectionRenderer = this.fable.instantiateServiceProviderWithoutRegistration('PictServiceFlowConnectionRenderer', { FlowView: this });
400
+ }
401
+ if (!this._LayoutService)
402
+ {
403
+ this._LayoutService = this.fable.instantiateServiceProviderWithoutRegistration('PictServiceFlowLayout', { FlowView: this });
404
+ }
405
+ if (!this._NodeTypeProvider)
406
+ {
407
+ this._NodeTypeProvider = this.fable.instantiateServiceProviderWithoutRegistration('PictProviderFlowNodeTypes', { FlowView: this });
408
+ }
409
+ if (!this._EventHandlerProvider)
410
+ {
411
+ this._EventHandlerProvider = this.fable.instantiateServiceProviderWithoutRegistration('PictProviderFlowEventHandler', { FlowView: this });
412
+ }
413
+
414
+ // Setup the toolbar if enabled
415
+ if (this.options.EnableToolbar)
416
+ {
417
+ this._ToolbarView = this.fable.instantiateServiceProviderWithoutRegistration('PictViewFlowToolbar',
418
+ Object.assign({},
419
+ libPictViewFlowToolbar.default_configuration,
420
+ {
421
+ ViewIdentifier: `Flow-Toolbar-${tmpViewIdentifier}`,
422
+ DefaultDestinationAddress: `#Flow-Toolbar-${tmpViewIdentifier}`,
423
+ FlowViewIdentifier: tmpViewIdentifier
424
+ }
425
+ ));
426
+ // Use the toolbar's render method after it's set up
427
+ if (this._ToolbarView && typeof this._ToolbarView.render === 'function')
428
+ {
429
+ this._ToolbarView._FlowView = this;
430
+ this._ToolbarView.render();
431
+ }
432
+ }
433
+
434
+ // Setup the node renderer
435
+ this._NodeView = this.fable.instantiateServiceProviderWithoutRegistration('PictViewFlowNode',
436
+ Object.assign({},
437
+ libPictViewFlowNode.default_configuration,
438
+ {
439
+ ViewIdentifier: `Flow-NodeRenderer-${tmpViewIdentifier}`,
440
+ AutoRender: false
441
+ }
442
+ ));
443
+ this._NodeView._FlowView = this;
444
+
445
+ // Bind interaction events
446
+ this._InteractionManager.initialize(this._SVGElement, this._ViewportElement);
447
+
448
+ // Load initial flow data if an address is configured
449
+ if (this.options.FlowDataAddress)
450
+ {
451
+ this.marshalToView();
452
+ }
453
+
454
+ // Render the initial flow
455
+ this.renderFlow();
456
+ }
457
+
458
+ /**
459
+ * Marshal data from AppData into the flow view
460
+ */
461
+ marshalToView()
462
+ {
463
+ if (this.options.FlowDataAddress)
464
+ {
465
+ const tmpAddressSpace =
466
+ {
467
+ Fable: this.fable,
468
+ Pict: this.pict || this.fable,
469
+ AppData: this.pict ? this.pict.AppData : this.fable.AppData,
470
+ Bundle: this.Bundle,
471
+ Options: this.options
472
+ };
473
+ let tmpData = this.fable.manifest.getValueByHash(tmpAddressSpace, this.options.FlowDataAddress);
474
+ if (typeof tmpData === 'object' && tmpData !== null)
475
+ {
476
+ this.setFlowData(tmpData);
477
+ }
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Marshal data from the flow view back to AppData
483
+ */
484
+ marshalFromView()
485
+ {
486
+ if (this.options.FlowDataAddress)
487
+ {
488
+ const tmpAddressSpace =
489
+ {
490
+ Fable: this.fable,
491
+ Pict: this.pict || this.fable,
492
+ AppData: this.pict ? this.pict.AppData : this.fable.AppData,
493
+ Bundle: this.Bundle,
494
+ Options: this.options
495
+ };
496
+ this.fable.manifest.setValueByHash(tmpAddressSpace, this.options.FlowDataAddress, JSON.parse(JSON.stringify(this._FlowData)));
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Get the complete flow data object
502
+ * @returns {Object} The flow data including nodes, connections, and view state
503
+ */
504
+ getFlowData()
505
+ {
506
+ return JSON.parse(JSON.stringify(this._FlowData));
507
+ }
508
+
509
+ /**
510
+ * Set the complete flow data object and re-render
511
+ * @param {Object} pFlowData - The flow data to set
512
+ */
513
+ setFlowData(pFlowData)
514
+ {
515
+ if (typeof pFlowData !== 'object' || pFlowData === null)
516
+ {
517
+ this.log.warn('PictSectionFlow setFlowData received invalid data');
518
+ return;
519
+ }
520
+
521
+ this._FlowData = {
522
+ Nodes: Array.isArray(pFlowData.Nodes) ? pFlowData.Nodes : [],
523
+ Connections: Array.isArray(pFlowData.Connections) ? pFlowData.Connections : [],
524
+ ViewState: Object.assign(
525
+ { PanX: 0, PanY: 0, Zoom: 1, SelectedNodeHash: null, SelectedConnectionHash: null },
526
+ pFlowData.ViewState || {}
527
+ )
528
+ };
529
+
530
+ if (this.initialRenderComplete)
531
+ {
532
+ this.renderFlow();
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Add a new node to the flow
538
+ * @param {string} pType - The node type hash
539
+ * @param {number} pX - X position
540
+ * @param {number} pY - Y position
541
+ * @param {string} [pTitle] - Optional title
542
+ * @param {Object} [pData] - Optional additional data
543
+ * @returns {Object} The created node
544
+ */
545
+ addNode(pType, pX, pY, pTitle, pData)
546
+ {
547
+ let tmpType = pType || this.options.DefaultNodeType;
548
+ let tmpNodeTypeConfig = this._NodeTypeProvider.getNodeType(tmpType);
549
+
550
+ let tmpNodeHash = `node-${this.fable.getUUID()}`;
551
+ let tmpNode =
552
+ {
553
+ Hash: tmpNodeHash,
554
+ Type: tmpType,
555
+ X: pX || 100,
556
+ Y: pY || 100,
557
+ Width: (tmpNodeTypeConfig && tmpNodeTypeConfig.DefaultWidth) || this.options.DefaultNodeWidth,
558
+ Height: (tmpNodeTypeConfig && tmpNodeTypeConfig.DefaultHeight) || this.options.DefaultNodeHeight,
559
+ Title: pTitle || (tmpNodeTypeConfig && tmpNodeTypeConfig.Label) || 'New Node',
560
+ Ports: (tmpNodeTypeConfig && tmpNodeTypeConfig.DefaultPorts)
561
+ ? JSON.parse(JSON.stringify(tmpNodeTypeConfig.DefaultPorts))
562
+ : [
563
+ { Hash: `port-in-${this.fable.getUUID()}`, Direction: 'input', Side: 'left', Label: 'In' },
564
+ { Hash: `port-out-${this.fable.getUUID()}`, Direction: 'output', Side: 'right', Label: 'Out' }
565
+ ],
566
+ Data: pData || {}
567
+ };
568
+
569
+ // Ensure each port has a unique hash
570
+ for (let i = 0; i < tmpNode.Ports.length; i++)
571
+ {
572
+ if (!tmpNode.Ports[i].Hash)
573
+ {
574
+ tmpNode.Ports[i].Hash = `port-${tmpNode.Ports[i].Direction}-${this.fable.getUUID()}`;
575
+ }
576
+ }
577
+
578
+ this._FlowData.Nodes.push(tmpNode);
579
+ this.renderFlow();
580
+ this.marshalFromView();
581
+
582
+ if (this._EventHandlerProvider)
583
+ {
584
+ this._EventHandlerProvider.fireEvent('onNodeAdded', tmpNode);
585
+ this._EventHandlerProvider.fireEvent('onFlowChanged', this._FlowData);
586
+ }
587
+
588
+ return tmpNode;
589
+ }
590
+
591
+ /**
592
+ * Remove a node and all its connections
593
+ * @param {string} pNodeHash - The hash of the node to remove
594
+ * @returns {boolean} Whether the node was removed
595
+ */
596
+ removeNode(pNodeHash)
597
+ {
598
+ let tmpNodeIndex = this._FlowData.Nodes.findIndex((pNode) => pNode.Hash === pNodeHash);
599
+ if (tmpNodeIndex < 0)
600
+ {
601
+ this.log.warn(`PictSectionFlow removeNode: node ${pNodeHash} not found`);
602
+ return false;
603
+ }
604
+
605
+ let tmpRemovedNode = this._FlowData.Nodes.splice(tmpNodeIndex, 1)[0];
606
+
607
+ // Remove all connections involving this node
608
+ this._FlowData.Connections = this._FlowData.Connections.filter((pConnection) =>
609
+ {
610
+ return pConnection.SourceNodeHash !== pNodeHash && pConnection.TargetNodeHash !== pNodeHash;
611
+ });
612
+
613
+ // Clear selection if this node was selected
614
+ if (this._FlowData.ViewState.SelectedNodeHash === pNodeHash)
615
+ {
616
+ this._FlowData.ViewState.SelectedNodeHash = null;
617
+ }
618
+
619
+ this.renderFlow();
620
+ this.marshalFromView();
621
+
622
+ if (this._EventHandlerProvider)
623
+ {
624
+ this._EventHandlerProvider.fireEvent('onNodeRemoved', tmpRemovedNode);
625
+ this._EventHandlerProvider.fireEvent('onFlowChanged', this._FlowData);
626
+ }
627
+
628
+ return true;
629
+ }
630
+
631
+ /**
632
+ * Add a connection between two ports
633
+ * @param {string} pSourceNodeHash
634
+ * @param {string} pSourcePortHash
635
+ * @param {string} pTargetNodeHash
636
+ * @param {string} pTargetPortHash
637
+ * @param {Object} [pData] - Optional additional data
638
+ * @returns {Object|false} The created connection, or false if invalid
639
+ */
640
+ addConnection(pSourceNodeHash, pSourcePortHash, pTargetNodeHash, pTargetPortHash, pData)
641
+ {
642
+ // Validate that both nodes and ports exist
643
+ let tmpSourceNode = this._FlowData.Nodes.find((pNode) => pNode.Hash === pSourceNodeHash);
644
+ let tmpTargetNode = this._FlowData.Nodes.find((pNode) => pNode.Hash === pTargetNodeHash);
645
+
646
+ if (!tmpSourceNode || !tmpTargetNode)
647
+ {
648
+ this.log.warn('PictSectionFlow addConnection: source or target node not found');
649
+ return false;
650
+ }
651
+
652
+ let tmpSourcePort = tmpSourceNode.Ports.find((pPort) => pPort.Hash === pSourcePortHash);
653
+ let tmpTargetPort = tmpTargetNode.Ports.find((pPort) => pPort.Hash === pTargetPortHash);
654
+
655
+ if (!tmpSourcePort || !tmpTargetPort)
656
+ {
657
+ this.log.warn('PictSectionFlow addConnection: source or target port not found');
658
+ return false;
659
+ }
660
+
661
+ // Prevent self-connections
662
+ if (pSourceNodeHash === pTargetNodeHash)
663
+ {
664
+ this.log.warn('PictSectionFlow addConnection: cannot connect a node to itself');
665
+ return false;
666
+ }
667
+
668
+ // Check for duplicate connections
669
+ let tmpDuplicate = this._FlowData.Connections.find((pConn) =>
670
+ {
671
+ return pConn.SourceNodeHash === pSourceNodeHash
672
+ && pConn.SourcePortHash === pSourcePortHash
673
+ && pConn.TargetNodeHash === pTargetNodeHash
674
+ && pConn.TargetPortHash === pTargetPortHash;
675
+ });
676
+ if (tmpDuplicate)
677
+ {
678
+ this.log.warn('PictSectionFlow addConnection: duplicate connection');
679
+ return false;
680
+ }
681
+
682
+ let tmpConnection =
683
+ {
684
+ Hash: `conn-${this.fable.getUUID()}`,
685
+ SourceNodeHash: pSourceNodeHash,
686
+ SourcePortHash: pSourcePortHash,
687
+ TargetNodeHash: pTargetNodeHash,
688
+ TargetPortHash: pTargetPortHash,
689
+ Data: pData || {}
690
+ };
691
+
692
+ this._FlowData.Connections.push(tmpConnection);
693
+ this.renderFlow();
694
+ this.marshalFromView();
695
+
696
+ if (this._EventHandlerProvider)
697
+ {
698
+ this._EventHandlerProvider.fireEvent('onConnectionCreated', tmpConnection);
699
+ this._EventHandlerProvider.fireEvent('onFlowChanged', this._FlowData);
700
+ }
701
+
702
+ return tmpConnection;
703
+ }
704
+
705
+ /**
706
+ * Remove a connection
707
+ * @param {string} pConnectionHash - The hash of the connection to remove
708
+ * @returns {boolean} Whether the connection was removed
709
+ */
710
+ removeConnection(pConnectionHash)
711
+ {
712
+ let tmpConnectionIndex = this._FlowData.Connections.findIndex((pConn) => pConn.Hash === pConnectionHash);
713
+ if (tmpConnectionIndex < 0)
714
+ {
715
+ this.log.warn(`PictSectionFlow removeConnection: connection ${pConnectionHash} not found`);
716
+ return false;
717
+ }
718
+
719
+ let tmpRemovedConnection = this._FlowData.Connections.splice(tmpConnectionIndex, 1)[0];
720
+
721
+ if (this._FlowData.ViewState.SelectedConnectionHash === pConnectionHash)
722
+ {
723
+ this._FlowData.ViewState.SelectedConnectionHash = null;
724
+ }
725
+
726
+ this.renderFlow();
727
+ this.marshalFromView();
728
+
729
+ if (this._EventHandlerProvider)
730
+ {
731
+ this._EventHandlerProvider.fireEvent('onConnectionRemoved', tmpRemovedConnection);
732
+ this._EventHandlerProvider.fireEvent('onFlowChanged', this._FlowData);
733
+ }
734
+
735
+ return true;
736
+ }
737
+
738
+ /**
739
+ * Select a node
740
+ * @param {string|null} pNodeHash - Hash of the node to select, or null to deselect
741
+ */
742
+ selectNode(pNodeHash)
743
+ {
744
+ let tmpPreviousSelection = this._FlowData.ViewState.SelectedNodeHash;
745
+ this._FlowData.ViewState.SelectedNodeHash = pNodeHash;
746
+ this._FlowData.ViewState.SelectedConnectionHash = null;
747
+
748
+ this.renderFlow();
749
+
750
+ if (this._EventHandlerProvider && pNodeHash !== tmpPreviousSelection)
751
+ {
752
+ let tmpNode = pNodeHash ? this._FlowData.Nodes.find((pNode) => pNode.Hash === pNodeHash) : null;
753
+ this._EventHandlerProvider.fireEvent('onNodeSelected', tmpNode);
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Select a connection
759
+ * @param {string|null} pConnectionHash - Hash of the connection to select, or null to deselect
760
+ */
761
+ selectConnection(pConnectionHash)
762
+ {
763
+ let tmpPreviousSelection = this._FlowData.ViewState.SelectedConnectionHash;
764
+ this._FlowData.ViewState.SelectedConnectionHash = pConnectionHash;
765
+ this._FlowData.ViewState.SelectedNodeHash = null;
766
+
767
+ this.renderFlow();
768
+
769
+ if (this._EventHandlerProvider && pConnectionHash !== tmpPreviousSelection)
770
+ {
771
+ let tmpConnection = pConnectionHash ? this._FlowData.Connections.find((pConn) => pConn.Hash === pConnectionHash) : null;
772
+ this._EventHandlerProvider.fireEvent('onConnectionSelected', tmpConnection);
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Deselect all nodes and connections
778
+ */
779
+ deselectAll()
780
+ {
781
+ this._FlowData.ViewState.SelectedNodeHash = null;
782
+ this._FlowData.ViewState.SelectedConnectionHash = null;
783
+ this.renderFlow();
784
+ }
785
+
786
+ /**
787
+ * Delete the currently selected node or connection
788
+ * @returns {boolean}
789
+ */
790
+ deleteSelected()
791
+ {
792
+ if (this._FlowData.ViewState.SelectedNodeHash)
793
+ {
794
+ return this.removeNode(this._FlowData.ViewState.SelectedNodeHash);
795
+ }
796
+ if (this._FlowData.ViewState.SelectedConnectionHash)
797
+ {
798
+ return this.removeConnection(this._FlowData.ViewState.SelectedConnectionHash);
799
+ }
800
+ return false;
801
+ }
802
+
803
+ /**
804
+ * Update the viewport transform (pan and zoom)
805
+ */
806
+ updateViewportTransform()
807
+ {
808
+ if (!this._ViewportElement) return;
809
+ let tmpVS = this._FlowData.ViewState;
810
+ this._ViewportElement.setAttribute('transform',
811
+ `translate(${tmpVS.PanX}, ${tmpVS.PanY}) scale(${tmpVS.Zoom})`
812
+ );
813
+ }
814
+
815
+ /**
816
+ * Set zoom level
817
+ * @param {number} pZoom - The zoom level
818
+ * @param {number} [pFocusX] - X coordinate to zoom toward (SVG space)
819
+ * @param {number} [pFocusY] - Y coordinate to zoom toward (SVG space)
820
+ */
821
+ setZoom(pZoom, pFocusX, pFocusY)
822
+ {
823
+ let tmpNewZoom = Math.max(this.options.MinZoom, Math.min(this.options.MaxZoom, pZoom));
824
+ let tmpOldZoom = this._FlowData.ViewState.Zoom;
825
+
826
+ if (typeof pFocusX === 'number' && typeof pFocusY === 'number')
827
+ {
828
+ // Zoom toward focus point
829
+ let tmpVS = this._FlowData.ViewState;
830
+ tmpVS.PanX = pFocusX - (pFocusX - tmpVS.PanX) * (tmpNewZoom / tmpOldZoom);
831
+ tmpVS.PanY = pFocusY - (pFocusY - tmpVS.PanY) * (tmpNewZoom / tmpOldZoom);
832
+ }
833
+
834
+ this._FlowData.ViewState.Zoom = tmpNewZoom;
835
+ this.updateViewportTransform();
836
+ }
837
+
838
+ /**
839
+ * Zoom to fit all nodes in the viewport
840
+ */
841
+ zoomToFit()
842
+ {
843
+ if (this._FlowData.Nodes.length === 0) return;
844
+ if (!this._SVGElement) return;
845
+
846
+ let tmpMinX = Infinity, tmpMinY = Infinity;
847
+ let tmpMaxX = -Infinity, tmpMaxY = -Infinity;
848
+
849
+ for (let i = 0; i < this._FlowData.Nodes.length; i++)
850
+ {
851
+ let tmpNode = this._FlowData.Nodes[i];
852
+ tmpMinX = Math.min(tmpMinX, tmpNode.X);
853
+ tmpMinY = Math.min(tmpMinY, tmpNode.Y);
854
+ tmpMaxX = Math.max(tmpMaxX, tmpNode.X + tmpNode.Width);
855
+ tmpMaxY = Math.max(tmpMaxY, tmpNode.Y + tmpNode.Height);
856
+ }
857
+
858
+ let tmpPadding = 50;
859
+ let tmpFlowWidth = tmpMaxX - tmpMinX + tmpPadding * 2;
860
+ let tmpFlowHeight = tmpMaxY - tmpMinY + tmpPadding * 2;
861
+
862
+ let tmpSVGRect = this._SVGElement.getBoundingClientRect();
863
+ let tmpScaleX = tmpSVGRect.width / tmpFlowWidth;
864
+ let tmpScaleY = tmpSVGRect.height / tmpFlowHeight;
865
+ let tmpZoom = Math.min(tmpScaleX, tmpScaleY, 1.0); // Don't zoom in past 1.0
866
+ tmpZoom = Math.max(this.options.MinZoom, Math.min(this.options.MaxZoom, tmpZoom));
867
+
868
+ let tmpCenterX = (tmpMinX + tmpMaxX) / 2;
869
+ let tmpCenterY = (tmpMinY + tmpMaxY) / 2;
870
+
871
+ this._FlowData.ViewState.Zoom = tmpZoom;
872
+ this._FlowData.ViewState.PanX = (tmpSVGRect.width / 2) - (tmpCenterX * tmpZoom);
873
+ this._FlowData.ViewState.PanY = (tmpSVGRect.height / 2) - (tmpCenterY * tmpZoom);
874
+
875
+ this.updateViewportTransform();
876
+ }
877
+
878
+ /**
879
+ * Apply auto-layout to all nodes
880
+ */
881
+ autoLayout()
882
+ {
883
+ if (this._LayoutService)
884
+ {
885
+ this._LayoutService.autoLayout(this._FlowData.Nodes, this._FlowData.Connections);
886
+ this.renderFlow();
887
+ this.marshalFromView();
888
+
889
+ if (this._EventHandlerProvider)
890
+ {
891
+ this._EventHandlerProvider.fireEvent('onFlowChanged', this._FlowData);
892
+ }
893
+ }
894
+ }
895
+
896
+ /**
897
+ * Get a node by hash
898
+ * @param {string} pNodeHash
899
+ * @returns {Object|null}
900
+ */
901
+ getNode(pNodeHash)
902
+ {
903
+ return this._FlowData.Nodes.find((pNode) => pNode.Hash === pNodeHash) || null;
904
+ }
905
+
906
+ /**
907
+ * Get a connection by hash
908
+ * @param {string} pConnectionHash
909
+ * @returns {Object|null}
910
+ */
911
+ getConnection(pConnectionHash)
912
+ {
913
+ return this._FlowData.Connections.find((pConn) => pConn.Hash === pConnectionHash) || null;
914
+ }
915
+
916
+ /**
917
+ * Get a port's absolute position in SVG coordinates.
918
+ *
919
+ * For left and right side ports, positioning is offset below the title bar
920
+ * so that connection endpoints match the rendered port circles.
921
+ *
922
+ * @param {string} pNodeHash
923
+ * @param {string} pPortHash
924
+ * @returns {{x: number, y: number, side: string}|null}
925
+ */
926
+ getPortPosition(pNodeHash, pPortHash)
927
+ {
928
+ let tmpNode = this.getNode(pNodeHash);
929
+ if (!tmpNode) return null;
930
+
931
+ let tmpPort = tmpNode.Ports.find((p) => p.Hash === pPortHash);
932
+ if (!tmpPort) return null;
933
+
934
+ // Count ports on the same side (matching both direction and side)
935
+ let tmpSameSidePorts = tmpNode.Ports.filter((p) => p.Side === tmpPort.Side);
936
+ let tmpPortIndex = tmpSameSidePorts.indexOf(tmpPort);
937
+ let tmpPortCount = tmpSameSidePorts.length;
938
+
939
+ let tmpTitleBarHeight = (this._NodeView && this._NodeView.options.NodeTitleBarHeight) || 28;
940
+
941
+ let tmpX, tmpY;
942
+
943
+ switch (tmpPort.Side)
944
+ {
945
+ case 'left':
946
+ {
947
+ // Distribute ports in the body area below the title bar
948
+ let tmpBodyHeight = tmpNode.Height - tmpTitleBarHeight;
949
+ tmpX = tmpNode.X;
950
+ tmpY = tmpNode.Y + tmpTitleBarHeight + ((tmpPortIndex + 1) / (tmpPortCount + 1)) * tmpBodyHeight;
951
+ break;
952
+ }
953
+ case 'right':
954
+ {
955
+ let tmpBodyHeight = tmpNode.Height - tmpTitleBarHeight;
956
+ tmpX = tmpNode.X + tmpNode.Width;
957
+ tmpY = tmpNode.Y + tmpTitleBarHeight + ((tmpPortIndex + 1) / (tmpPortCount + 1)) * tmpBodyHeight;
958
+ break;
959
+ }
960
+ case 'top':
961
+ tmpX = tmpNode.X + ((tmpPortIndex + 1) / (tmpPortCount + 1)) * tmpNode.Width;
962
+ tmpY = tmpNode.Y;
963
+ break;
964
+ case 'bottom':
965
+ tmpX = tmpNode.X + ((tmpPortIndex + 1) / (tmpPortCount + 1)) * tmpNode.Width;
966
+ tmpY = tmpNode.Y + tmpNode.Height;
967
+ break;
968
+ default:
969
+ tmpX = tmpNode.X + tmpNode.Width;
970
+ tmpY = tmpNode.Y + tmpNode.Height / 2;
971
+ break;
972
+ }
973
+
974
+ return { x: tmpX, y: tmpY, side: tmpPort.Side || 'right' };
975
+ }
976
+
977
+ /**
978
+ * Convert screen coordinates to SVG viewport coordinates
979
+ * @param {number} pScreenX
980
+ * @param {number} pScreenY
981
+ * @returns {{x: number, y: number}}
982
+ */
983
+ screenToSVGCoords(pScreenX, pScreenY)
984
+ {
985
+ if (!this._SVGElement)
986
+ {
987
+ return { x: pScreenX, y: pScreenY };
988
+ }
989
+
990
+ let tmpPoint = this._SVGElement.createSVGPoint();
991
+ tmpPoint.x = pScreenX;
992
+ tmpPoint.y = pScreenY;
993
+
994
+ let tmpCTM = this._SVGElement.getScreenCTM();
995
+ if (tmpCTM)
996
+ {
997
+ let tmpInverse = tmpCTM.inverse();
998
+ let tmpTransformed = tmpPoint.matrixTransform(tmpInverse);
999
+ // Account for viewport pan/zoom
1000
+ let tmpVS = this._FlowData.ViewState;
1001
+ return {
1002
+ x: (tmpTransformed.x - tmpVS.PanX) / tmpVS.Zoom,
1003
+ y: (tmpTransformed.y - tmpVS.PanY) / tmpVS.Zoom
1004
+ };
1005
+ }
1006
+
1007
+ return { x: pScreenX, y: pScreenY };
1008
+ }
1009
+
1010
+ /**
1011
+ * Render the complete flow diagram
1012
+ */
1013
+ renderFlow()
1014
+ {
1015
+ if (!this._NodesLayer || !this._ConnectionsLayer) return;
1016
+
1017
+ // Clear existing SVG content
1018
+ while (this._NodesLayer.firstChild)
1019
+ {
1020
+ this._NodesLayer.removeChild(this._NodesLayer.firstChild);
1021
+ }
1022
+ while (this._ConnectionsLayer.firstChild)
1023
+ {
1024
+ this._ConnectionsLayer.removeChild(this._ConnectionsLayer.firstChild);
1025
+ }
1026
+
1027
+ // Render connections first (behind nodes)
1028
+ for (let i = 0; i < this._FlowData.Connections.length; i++)
1029
+ {
1030
+ let tmpConnection = this._FlowData.Connections[i];
1031
+ let tmpIsSelected = (this._FlowData.ViewState.SelectedConnectionHash === tmpConnection.Hash);
1032
+
1033
+ this._ConnectionRenderer.renderConnection(
1034
+ tmpConnection,
1035
+ this._ConnectionsLayer,
1036
+ tmpIsSelected
1037
+ );
1038
+ }
1039
+
1040
+ // Render nodes
1041
+ for (let i = 0; i < this._FlowData.Nodes.length; i++)
1042
+ {
1043
+ let tmpNode = this._FlowData.Nodes[i];
1044
+ let tmpIsSelected = (this._FlowData.ViewState.SelectedNodeHash === tmpNode.Hash);
1045
+ let tmpNodeTypeConfig = this._NodeTypeProvider.getNodeType(tmpNode.Type);
1046
+
1047
+ this._NodeView.renderNode(tmpNode, this._NodesLayer, tmpIsSelected, tmpNodeTypeConfig);
1048
+ }
1049
+
1050
+ // Update viewport transform
1051
+ this.updateViewportTransform();
1052
+ }
1053
+
1054
+ /**
1055
+ * Update a single node's position in the SVG without full re-render (for drag performance)
1056
+ * @param {string} pNodeHash
1057
+ * @param {number} pX
1058
+ * @param {number} pY
1059
+ */
1060
+ updateNodePosition(pNodeHash, pX, pY)
1061
+ {
1062
+ let tmpNode = this.getNode(pNodeHash);
1063
+ if (!tmpNode) return;
1064
+
1065
+ if (this.options.EnableGridSnap)
1066
+ {
1067
+ pX = this._LayoutService.snapToGrid(pX, this.options.GridSnapSize);
1068
+ pY = this._LayoutService.snapToGrid(pY, this.options.GridSnapSize);
1069
+ }
1070
+
1071
+ tmpNode.X = pX;
1072
+ tmpNode.Y = pY;
1073
+
1074
+ // Update the node's SVG group transform for smooth dragging
1075
+ let tmpNodeGroup = this._NodesLayer.querySelector(`[data-node-hash="${pNodeHash}"]`);
1076
+ if (tmpNodeGroup)
1077
+ {
1078
+ tmpNodeGroup.setAttribute('transform', `translate(${pX}, ${pY})`);
1079
+ }
1080
+
1081
+ // Re-render connections that involve this node
1082
+ this._renderConnectionsForNode(pNodeHash);
1083
+ }
1084
+
1085
+ /**
1086
+ * Re-render only connections that involve a specific node (for drag performance)
1087
+ * @param {string} pNodeHash
1088
+ */
1089
+ _renderConnectionsForNode(pNodeHash)
1090
+ {
1091
+ let tmpAffectedConnections = this._FlowData.Connections.filter((pConn) =>
1092
+ {
1093
+ return pConn.SourceNodeHash === pNodeHash || pConn.TargetNodeHash === pNodeHash;
1094
+ });
1095
+
1096
+ for (let i = 0; i < tmpAffectedConnections.length; i++)
1097
+ {
1098
+ let tmpConn = tmpAffectedConnections[i];
1099
+ let tmpIsSelected = (this._FlowData.ViewState.SelectedConnectionHash === tmpConn.Hash);
1100
+
1101
+ // Remove existing connection SVG elements
1102
+ let tmpExisting = this._ConnectionsLayer.querySelectorAll(`[data-connection-hash="${tmpConn.Hash}"]`);
1103
+ for (let j = 0; j < tmpExisting.length; j++)
1104
+ {
1105
+ tmpExisting[j].remove();
1106
+ }
1107
+
1108
+ // Re-render this connection
1109
+ this._ConnectionRenderer.renderConnection(tmpConn, this._ConnectionsLayer, tmpIsSelected);
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ module.exports = PictViewFlow;
1115
+
1116
+ module.exports.default_configuration = _DefaultConfiguration;