pict-section-flow 0.0.10 → 0.0.13

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 (88) hide show
  1. package/.claude/launch.json +1 -1
  2. package/README.md +176 -0
  3. package/docs/.nojekyll +0 -0
  4. package/docs/Architecture.md +303 -0
  5. package/docs/Custom-Styling.md +275 -0
  6. package/docs/Data_Model.md +158 -0
  7. package/docs/Event_System.md +156 -0
  8. package/docs/Getting_Started.md +237 -0
  9. package/docs/Implementation_Reference.md +528 -0
  10. package/docs/Layout_Persistence.md +117 -0
  11. package/docs/README.md +115 -52
  12. package/docs/_cover.md +11 -0
  13. package/docs/_sidebar.md +52 -0
  14. package/docs/_topbar.md +8 -0
  15. package/docs/api/PictFlowCard.md +216 -0
  16. package/docs/api/PictFlowCardPropertiesPanel.md +235 -0
  17. package/docs/api/addConnection.md +101 -0
  18. package/docs/api/addNode.md +137 -0
  19. package/docs/api/autoLayout.md +77 -0
  20. package/docs/api/getFlowData.md +112 -0
  21. package/docs/api/marshalToView.md +95 -0
  22. package/docs/api/openPanel.md +128 -0
  23. package/docs/api/registerHandler.md +174 -0
  24. package/docs/api/registerNodeType.md +142 -0
  25. package/docs/api/removeConnection.md +57 -0
  26. package/docs/api/removeNode.md +80 -0
  27. package/docs/api/saveLayout.md +152 -0
  28. package/docs/api/screenToSVGCoords.md +68 -0
  29. package/docs/api/selectNode.md +116 -0
  30. package/docs/api/setTheme.md +168 -0
  31. package/docs/api/setZoom.md +97 -0
  32. package/docs/api/toggleFullscreen.md +68 -0
  33. package/docs/card-help/EACH.md +19 -0
  34. package/docs/card-help/FREAD.md +24 -0
  35. package/docs/card-help/FWRITE.md +24 -0
  36. package/docs/card-help/GET.md +22 -0
  37. package/docs/card-help/ITE.md +23 -0
  38. package/docs/card-help/LOG.md +23 -0
  39. package/docs/card-help/NOTE.md +17 -0
  40. package/docs/card-help/PREV.md +18 -0
  41. package/docs/card-help/SET.md +27 -0
  42. package/docs/card-help/SPKL.md +22 -0
  43. package/docs/card-help/STAT.md +23 -0
  44. package/docs/card-help/SW.md +25 -0
  45. package/docs/css/docuserve.css +73 -0
  46. package/docs/index.html +39 -0
  47. package/docs/retold-catalog.json +169 -0
  48. package/docs/retold-keyword-index.json +13942 -0
  49. package/example_applications/simple_cards/package.json +1 -0
  50. package/example_applications/simple_cards/source/card-help-content.js +16 -0
  51. package/example_applications/simple_cards/source/cards/FlowCard-Comment.js +2 -0
  52. package/example_applications/simple_cards/source/cards/FlowCard-DataPreview.js +2 -0
  53. package/example_applications/simple_cards/source/cards/FlowCard-Each.js +2 -0
  54. package/example_applications/simple_cards/source/cards/FlowCard-FileRead.js +2 -0
  55. package/example_applications/simple_cards/source/cards/FlowCard-FileWrite.js +2 -0
  56. package/example_applications/simple_cards/source/cards/FlowCard-GetValue.js +2 -0
  57. package/example_applications/simple_cards/source/cards/FlowCard-IfThenElse.js +2 -0
  58. package/example_applications/simple_cards/source/cards/FlowCard-LogValues.js +2 -0
  59. package/example_applications/simple_cards/source/cards/FlowCard-SetValue.js +2 -0
  60. package/example_applications/simple_cards/source/cards/FlowCard-Sparkline.js +2 -0
  61. package/example_applications/simple_cards/source/cards/FlowCard-StatusMonitor.js +2 -0
  62. package/example_applications/simple_cards/source/cards/FlowCard-Switch.js +2 -0
  63. package/package.json +11 -7
  64. package/scripts/generate-card-help.js +214 -0
  65. package/source/Pict-Section-Flow.js +4 -0
  66. package/source/PictFlowCard.js +3 -1
  67. package/source/providers/PictProvider-Flow-CSS.js +245 -152
  68. package/source/providers/PictProvider-Flow-ConnectorShapes.js +24 -0
  69. package/source/providers/PictProvider-Flow-Geometry.js +195 -38
  70. package/source/providers/PictProvider-Flow-PanelChrome.js +14 -12
  71. package/source/services/PictService-Flow-ConnectionHandleManager.js +263 -0
  72. package/source/services/PictService-Flow-ConnectionRenderer.js +134 -183
  73. package/source/services/PictService-Flow-DataManager.js +338 -0
  74. package/source/services/PictService-Flow-InteractionManager.js +165 -7
  75. package/source/services/PictService-Flow-PathGenerator.js +282 -0
  76. package/source/services/PictService-Flow-PortRenderer.js +269 -0
  77. package/source/services/PictService-Flow-RenderManager.js +281 -0
  78. package/source/services/PictService-Flow-Tether.js +6 -42
  79. package/source/views/PictView-Flow-Node.js +2 -220
  80. package/source/views/PictView-Flow-PropertiesPanel.js +89 -44
  81. package/source/views/PictView-Flow.js +130 -882
  82. package/test/ConnectionHandleManager_tests.js +717 -0
  83. package/test/ConnectionRenderer_tests.js +591 -0
  84. package/test/DataManager_tests.js +859 -0
  85. package/test/Geometry_tests.js +767 -0
  86. package/test/PathGenerator_tests.js +978 -0
  87. package/test/PortRenderer_tests.js +367 -0
  88. package/test/RenderManager_tests.js +756 -0
@@ -0,0 +1,978 @@
1
+ const libFable = require('fable');
2
+ const libChai = require('chai');
3
+ const libExpect = libChai.expect;
4
+
5
+ const libPathGenerator = require('../source/services/PictService-Flow-PathGenerator.js');
6
+
7
+ suite
8
+ (
9
+ 'PictService-Flow-PathGenerator',
10
+ function ()
11
+ {
12
+ let _Fable;
13
+ let _PathGenerator;
14
+
15
+ // Minimal mock FlowView with a GeometryProvider
16
+ let _MockFlowView;
17
+
18
+ setup
19
+ (
20
+ function ()
21
+ {
22
+ _Fable = new libFable({});
23
+
24
+ _MockFlowView =
25
+ {
26
+ _GeometryProvider:
27
+ {
28
+ sideDirection: function (pSide)
29
+ {
30
+ switch (pSide)
31
+ {
32
+ case 'left': return { dx: -1, dy: 0 };
33
+ case 'right': return { dx: 1, dy: 0 };
34
+ case 'top': return { dx: 0, dy: -1 };
35
+ case 'bottom': return { dx: 0, dy: 1 };
36
+ default: return { dx: 1, dy: 0 };
37
+ }
38
+ }
39
+ }
40
+ };
41
+
42
+ _PathGenerator = new libPathGenerator(_Fable, { FlowView: _MockFlowView }, 'PathGen-Test');
43
+ }
44
+ );
45
+
46
+ // ---- Constructor ----
47
+
48
+ suite
49
+ (
50
+ 'Constructor',
51
+ function ()
52
+ {
53
+ test
54
+ (
55
+ 'should instantiate with correct serviceType',
56
+ function (fDone)
57
+ {
58
+ libExpect(_PathGenerator).to.be.an('object');
59
+ libExpect(_PathGenerator.serviceType).to.equal('PictServiceFlowPathGenerator');
60
+ fDone();
61
+ }
62
+ );
63
+
64
+ test
65
+ (
66
+ 'should store FlowView reference from options',
67
+ function (fDone)
68
+ {
69
+ libExpect(_PathGenerator._FlowView).to.equal(_MockFlowView);
70
+ fDone();
71
+ }
72
+ );
73
+
74
+ test
75
+ (
76
+ 'should handle missing FlowView in options',
77
+ function (fDone)
78
+ {
79
+ let tmpGen = new libPathGenerator(_Fable, {}, 'NoView');
80
+ libExpect(tmpGen._FlowView).to.be.null;
81
+ fDone();
82
+ }
83
+ );
84
+ }
85
+ );
86
+
87
+ // ---- computeDepartApproach ----
88
+
89
+ suite
90
+ (
91
+ 'computeDepartApproach',
92
+ function ()
93
+ {
94
+ test
95
+ (
96
+ 'should compute departure and approach for right-to-left',
97
+ function (fDone)
98
+ {
99
+ let tmpResult = _PathGenerator.computeDepartApproach(
100
+ { x: 100, y: 50, side: 'right' },
101
+ { x: 300, y: 50, side: 'left' },
102
+ 20
103
+ );
104
+
105
+ libExpect(tmpResult.departX).to.equal(120);
106
+ libExpect(tmpResult.departY).to.equal(50);
107
+ libExpect(tmpResult.approachX).to.equal(280);
108
+ libExpect(tmpResult.approachY).to.equal(50);
109
+ libExpect(tmpResult.fromDir).to.deep.equal({ dx: 1, dy: 0 });
110
+ libExpect(tmpResult.toDir).to.deep.equal({ dx: -1, dy: 0 });
111
+ fDone();
112
+ }
113
+ );
114
+
115
+ test
116
+ (
117
+ 'should compute departure for top-to-bottom',
118
+ function (fDone)
119
+ {
120
+ let tmpResult = _PathGenerator.computeDepartApproach(
121
+ { x: 100, y: 50, side: 'top' },
122
+ { x: 100, y: 200, side: 'bottom' },
123
+ 30
124
+ );
125
+
126
+ libExpect(tmpResult.departX).to.equal(100);
127
+ libExpect(tmpResult.departY).to.equal(20);
128
+ libExpect(tmpResult.approachX).to.equal(100);
129
+ libExpect(tmpResult.approachY).to.equal(230);
130
+ fDone();
131
+ }
132
+ );
133
+
134
+ test
135
+ (
136
+ 'should default to right/left when side is missing',
137
+ function (fDone)
138
+ {
139
+ let tmpResult = _PathGenerator.computeDepartApproach(
140
+ { x: 0, y: 0 },
141
+ { x: 100, y: 0 },
142
+ 10
143
+ );
144
+
145
+ // Default right → dx=1, default left → dx=-1
146
+ libExpect(tmpResult.departX).to.equal(10);
147
+ libExpect(tmpResult.approachX).to.equal(90);
148
+ fDone();
149
+ }
150
+ );
151
+ }
152
+ );
153
+
154
+ // ---- computeAutoOrthogonalCorners ----
155
+
156
+ suite
157
+ (
158
+ 'computeAutoOrthogonalCorners',
159
+ function ()
160
+ {
161
+ test
162
+ (
163
+ 'should compute Z-shaped corridor for both-horizontal',
164
+ function (fDone)
165
+ {
166
+ // Both horizontal: corridor is vertical at midpoint X
167
+ let tmpResult = _PathGenerator.computeAutoOrthogonalCorners(
168
+ 100, 50, // depart
169
+ 300, 150, // approach
170
+ { dx: 1, dy: 0 }, // from horizontal
171
+ { dx: -1, dy: 0 }, // to horizontal
172
+ 0
173
+ );
174
+
175
+ libExpect(tmpResult.corner1.x).to.equal(200); // midX
176
+ libExpect(tmpResult.corner1.y).to.equal(50); // departY
177
+ libExpect(tmpResult.corner2.x).to.equal(200); // midX
178
+ libExpect(tmpResult.corner2.y).to.equal(150); // approachY
179
+ libExpect(tmpResult.midpoint.x).to.equal(200);
180
+ libExpect(tmpResult.midpoint.y).to.equal(100); // avg Y
181
+ fDone();
182
+ }
183
+ );
184
+
185
+ test
186
+ (
187
+ 'should compute Z-shaped corridor for both-vertical',
188
+ function (fDone)
189
+ {
190
+ let tmpResult = _PathGenerator.computeAutoOrthogonalCorners(
191
+ 50, 100,
192
+ 150, 300,
193
+ { dx: 0, dy: 1 }, // from vertical (downward)
194
+ { dx: 0, dy: -1 }, // to vertical (upward)
195
+ 0
196
+ );
197
+
198
+ libExpect(tmpResult.corner1.x).to.equal(50);
199
+ libExpect(tmpResult.corner1.y).to.equal(200); // midY
200
+ libExpect(tmpResult.corner2.x).to.equal(150);
201
+ libExpect(tmpResult.corner2.y).to.equal(200); // midY
202
+ fDone();
203
+ }
204
+ );
205
+
206
+ test
207
+ (
208
+ 'should compute L-bend for horizontal-to-vertical',
209
+ function (fDone)
210
+ {
211
+ let tmpResult = _PathGenerator.computeAutoOrthogonalCorners(
212
+ 100, 50,
213
+ 300, 200,
214
+ { dx: 1, dy: 0 }, // from horizontal
215
+ { dx: 0, dy: -1 }, // to vertical
216
+ 0
217
+ );
218
+
219
+ // H→V: corner at (approachX, departY)
220
+ libExpect(tmpResult.corner1.x).to.equal(300);
221
+ libExpect(tmpResult.corner1.y).to.equal(50);
222
+ fDone();
223
+ }
224
+ );
225
+
226
+ test
227
+ (
228
+ 'should compute L-bend for vertical-to-horizontal',
229
+ function (fDone)
230
+ {
231
+ let tmpResult = _PathGenerator.computeAutoOrthogonalCorners(
232
+ 100, 50,
233
+ 300, 200,
234
+ { dx: 0, dy: 1 }, // from vertical
235
+ { dx: -1, dy: 0 }, // to horizontal
236
+ 0
237
+ );
238
+
239
+ // V→H: corner at (departX, approachY)
240
+ libExpect(tmpResult.corner1.x).to.equal(100);
241
+ libExpect(tmpResult.corner1.y).to.equal(200);
242
+ fDone();
243
+ }
244
+ );
245
+
246
+ test
247
+ (
248
+ 'should apply midOffset for both-horizontal',
249
+ function (fDone)
250
+ {
251
+ let tmpResult = _PathGenerator.computeAutoOrthogonalCorners(
252
+ 100, 50,
253
+ 300, 150,
254
+ { dx: 1, dy: 0 },
255
+ { dx: -1, dy: 0 },
256
+ 25 // offset
257
+ );
258
+
259
+ libExpect(tmpResult.corner1.x).to.equal(225); // 200 + 25
260
+ libExpect(tmpResult.corner2.x).to.equal(225);
261
+ fDone();
262
+ }
263
+ );
264
+ }
265
+ );
266
+
267
+ // ---- evaluateCubicBezier ----
268
+
269
+ suite
270
+ (
271
+ 'evaluateCubicBezier',
272
+ function ()
273
+ {
274
+ test
275
+ (
276
+ 'should return start point at t=0',
277
+ function (fDone)
278
+ {
279
+ let tmpResult = _PathGenerator.evaluateCubicBezier(
280
+ { x: 10, y: 20 },
281
+ { x: 40, y: 80 },
282
+ { x: 60, y: 80 },
283
+ { x: 90, y: 20 },
284
+ 0
285
+ );
286
+
287
+ libExpect(tmpResult.x).to.be.closeTo(10, 0.001);
288
+ libExpect(tmpResult.y).to.be.closeTo(20, 0.001);
289
+ fDone();
290
+ }
291
+ );
292
+
293
+ test
294
+ (
295
+ 'should return end point at t=1',
296
+ function (fDone)
297
+ {
298
+ let tmpResult = _PathGenerator.evaluateCubicBezier(
299
+ { x: 10, y: 20 },
300
+ { x: 40, y: 80 },
301
+ { x: 60, y: 80 },
302
+ { x: 90, y: 20 },
303
+ 1
304
+ );
305
+
306
+ libExpect(tmpResult.x).to.be.closeTo(90, 0.001);
307
+ libExpect(tmpResult.y).to.be.closeTo(20, 0.001);
308
+ fDone();
309
+ }
310
+ );
311
+
312
+ test
313
+ (
314
+ 'should return midpoint at t=0.5 for a straight-line bezier',
315
+ function (fDone)
316
+ {
317
+ // When all control points are collinear, the midpoint
318
+ // should be on the line
319
+ let tmpResult = _PathGenerator.evaluateCubicBezier(
320
+ { x: 0, y: 0 },
321
+ { x: 33.33, y: 0 },
322
+ { x: 66.67, y: 0 },
323
+ { x: 100, y: 0 },
324
+ 0.5
325
+ );
326
+
327
+ libExpect(tmpResult.x).to.be.closeTo(50, 0.1);
328
+ libExpect(tmpResult.y).to.be.closeTo(0, 0.1);
329
+ fDone();
330
+ }
331
+ );
332
+
333
+ test
334
+ (
335
+ 'should compute intermediate points for symmetric S-curve',
336
+ function (fDone)
337
+ {
338
+ let tmpResult = _PathGenerator.evaluateCubicBezier(
339
+ { x: 0, y: 0 },
340
+ { x: 0, y: 100 },
341
+ { x: 100, y: -100 },
342
+ { x: 100, y: 0 },
343
+ 0.5
344
+ );
345
+
346
+ // At t=0.5, x should be exactly 50 for this symmetric curve
347
+ libExpect(tmpResult.x).to.be.closeTo(50, 0.001);
348
+ // y should be 0 for this symmetric arrangement
349
+ libExpect(tmpResult.y).to.be.closeTo(0, 0.001);
350
+ fDone();
351
+ }
352
+ );
353
+ }
354
+ );
355
+
356
+ // ---- buildBezierPathString ----
357
+
358
+ suite
359
+ (
360
+ 'buildBezierPathString',
361
+ function ()
362
+ {
363
+ test
364
+ (
365
+ 'should produce valid SVG path string',
366
+ function (fDone)
367
+ {
368
+ let tmpResult = _PathGenerator.buildBezierPathString(
369
+ { x: 0, y: 0 },
370
+ { x: 20, y: 0 },
371
+ { x: 50, y: 0 },
372
+ { x: 80, y: 0 },
373
+ { x: 100, y: 0 },
374
+ { x: 120, y: 0 }
375
+ );
376
+
377
+ libExpect(tmpResult).to.be.a('string');
378
+ libExpect(tmpResult).to.match(/^M /);
379
+ libExpect(tmpResult).to.contain('L');
380
+ libExpect(tmpResult).to.contain('C');
381
+ fDone();
382
+ }
383
+ );
384
+
385
+ test
386
+ (
387
+ 'should include all coordinate values',
388
+ function (fDone)
389
+ {
390
+ let tmpResult = _PathGenerator.buildBezierPathString(
391
+ { x: 10, y: 20 },
392
+ { x: 30, y: 20 },
393
+ { x: 50, y: 40 },
394
+ { x: 70, y: 40 },
395
+ { x: 90, y: 20 },
396
+ { x: 110, y: 20 }
397
+ );
398
+
399
+ libExpect(tmpResult).to.contain('M 10 20');
400
+ libExpect(tmpResult).to.contain('L 30 20');
401
+ libExpect(tmpResult).to.contain('C 50 40');
402
+ libExpect(tmpResult).to.contain('L 110 20');
403
+ fDone();
404
+ }
405
+ );
406
+ }
407
+ );
408
+
409
+ // ---- buildSplitBezierPathString ----
410
+
411
+ suite
412
+ (
413
+ 'buildSplitBezierPathString',
414
+ function ()
415
+ {
416
+ test
417
+ (
418
+ 'should produce SVG path with two cubic segments',
419
+ function (fDone)
420
+ {
421
+ let tmpResult = _PathGenerator.buildSplitBezierPathString(
422
+ { x: 0, y: 0 }, // start
423
+ { x: 20, y: 0 }, // depart
424
+ { x: 40, y: 10 }, // cp1a
425
+ { x: 45, y: 20 }, // cp1b
426
+ { x: 50, y: 30 }, // handle
427
+ { x: 55, y: 20 }, // cp2a
428
+ { x: 60, y: 10 }, // cp2b
429
+ { x: 80, y: 0 }, // approach
430
+ { x: 100, y: 0 } // end
431
+ );
432
+
433
+ libExpect(tmpResult).to.be.a('string');
434
+ // Should have M, L, two C commands, and final L
435
+ let tmpCCount = (tmpResult.match(/C /g) || []).length;
436
+ libExpect(tmpCCount).to.equal(2);
437
+ fDone();
438
+ }
439
+ );
440
+ }
441
+ );
442
+
443
+ // ---- buildOrthogonalPathString ----
444
+
445
+ suite
446
+ (
447
+ 'buildOrthogonalPathString',
448
+ function ()
449
+ {
450
+ test
451
+ (
452
+ 'should produce SVG path with only L commands (no curves)',
453
+ function (fDone)
454
+ {
455
+ let tmpResult = _PathGenerator.buildOrthogonalPathString(
456
+ { x: 0, y: 50 },
457
+ { x: 20, y: 50 },
458
+ { x: 60, y: 50 },
459
+ { x: 60, y: 100 },
460
+ { x: 80, y: 100 },
461
+ { x: 100, y: 100 }
462
+ );
463
+
464
+ libExpect(tmpResult).to.match(/^M /);
465
+ libExpect(tmpResult).to.not.contain('C');
466
+ // Should have M + 5 L commands
467
+ let tmpLCount = (tmpResult.match(/L /g) || []).length;
468
+ libExpect(tmpLCount).to.equal(5);
469
+ fDone();
470
+ }
471
+ );
472
+ }
473
+ );
474
+
475
+ // ---- computeDirectionalGeometry ----
476
+
477
+ suite
478
+ (
479
+ 'computeDirectionalGeometry',
480
+ function ()
481
+ {
482
+ test
483
+ (
484
+ 'should compute departure and approach for right-to-left facing',
485
+ function (fDone)
486
+ {
487
+ let tmpGeo = _PathGenerator.computeDirectionalGeometry(
488
+ { x: 100, y: 50, side: 'right' },
489
+ { x: 400, y: 50, side: 'left' }
490
+ );
491
+
492
+ libExpect(tmpGeo.departX).to.equal(120);
493
+ libExpect(tmpGeo.departY).to.equal(50);
494
+ libExpect(tmpGeo.approachX).to.equal(380);
495
+ libExpect(tmpGeo.approachY).to.equal(50);
496
+ fDone();
497
+ }
498
+ );
499
+
500
+ test
501
+ (
502
+ 'should use facing-each-other offset when ports face each other',
503
+ function (fDone)
504
+ {
505
+ let tmpGeo = _PathGenerator.computeDirectionalGeometry(
506
+ { x: 100, y: 50, side: 'right' },
507
+ { x: 400, y: 50, side: 'left' }
508
+ );
509
+
510
+ // Facing: offset = max(inlineDist*0.35, 30)
511
+ // inlineDist = |380-120| = 260, offset = 91
512
+ let tmpOffset = tmpGeo.cp1X - tmpGeo.departX;
513
+ libExpect(tmpOffset).to.be.closeTo(91, 1);
514
+ fDone();
515
+ }
516
+ );
517
+
518
+ test
519
+ (
520
+ 'should handle perpendicular exits',
521
+ function (fDone)
522
+ {
523
+ let tmpGeo = _PathGenerator.computeDirectionalGeometry(
524
+ { x: 100, y: 50, side: 'right' },
525
+ { x: 300, y: 200, side: 'top' }
526
+ );
527
+
528
+ libExpect(tmpGeo.startDir).to.deep.equal({ dx: 1, dy: 0 });
529
+ libExpect(tmpGeo.endDir).to.deep.equal({ dx: 0, dy: -1 });
530
+ libExpect(tmpGeo.cp1X).to.be.greaterThan(tmpGeo.departX);
531
+ libExpect(tmpGeo.cp2Y).to.be.lessThan(tmpGeo.approachY);
532
+ fDone();
533
+ }
534
+ );
535
+
536
+ test
537
+ (
538
+ 'should handle vertical facing ports',
539
+ function (fDone)
540
+ {
541
+ let tmpGeo = _PathGenerator.computeDirectionalGeometry(
542
+ { x: 100, y: 50, side: 'bottom' },
543
+ { x: 100, y: 250, side: 'top' }
544
+ );
545
+
546
+ libExpect(tmpGeo.departY).to.equal(70);
547
+ libExpect(tmpGeo.approachY).to.equal(230);
548
+ fDone();
549
+ }
550
+ );
551
+
552
+ test
553
+ (
554
+ 'should return direction vectors',
555
+ function (fDone)
556
+ {
557
+ let tmpGeo = _PathGenerator.computeDirectionalGeometry(
558
+ { x: 0, y: 0, side: 'bottom' },
559
+ { x: 200, y: 200, side: 'left' }
560
+ );
561
+
562
+ libExpect(tmpGeo.startDir).to.deep.equal({ dx: 0, dy: 1 });
563
+ libExpect(tmpGeo.endDir).to.deep.equal({ dx: -1, dy: 0 });
564
+ fDone();
565
+ }
566
+ );
567
+
568
+ test
569
+ (
570
+ 'should use same-axis-not-facing offset when both face same direction',
571
+ function (fDone)
572
+ {
573
+ // Right port facing right, left port facing left — reversed so not facing
574
+ let tmpGeo = _PathGenerator.computeDirectionalGeometry(
575
+ { x: 400, y: 50, side: 'right' },
576
+ { x: 100, y: 50, side: 'left' }
577
+ );
578
+
579
+ // Same axis, not facing: max(baseOffset, 60)
580
+ let tmpOffset = tmpGeo.cp1X - tmpGeo.departX;
581
+ libExpect(tmpOffset).to.be.at.least(60);
582
+ fDone();
583
+ }
584
+ );
585
+ }
586
+ );
587
+
588
+ // ---- distanceToSegment ----
589
+
590
+ suite
591
+ (
592
+ 'distanceToSegment',
593
+ function ()
594
+ {
595
+ test
596
+ (
597
+ 'should return 0 for point on the segment',
598
+ function (fDone)
599
+ {
600
+ let tmpDist = _PathGenerator.distanceToSegment(50, 0, 0, 0, 100, 0);
601
+ libExpect(tmpDist).to.be.closeTo(0, 0.001);
602
+ fDone();
603
+ }
604
+ );
605
+
606
+ test
607
+ (
608
+ 'should compute perpendicular distance',
609
+ function (fDone)
610
+ {
611
+ let tmpDist = _PathGenerator.distanceToSegment(50, 30, 0, 0, 100, 0);
612
+ libExpect(tmpDist).to.be.closeTo(30, 0.001);
613
+ fDone();
614
+ }
615
+ );
616
+
617
+ test
618
+ (
619
+ 'should compute distance to endpoint when past segment',
620
+ function (fDone)
621
+ {
622
+ let tmpDist = _PathGenerator.distanceToSegment(110, 0, 0, 0, 100, 0);
623
+ libExpect(tmpDist).to.be.closeTo(10, 0.001);
624
+ fDone();
625
+ }
626
+ );
627
+
628
+ test
629
+ (
630
+ 'should compute distance to start when before segment',
631
+ function (fDone)
632
+ {
633
+ let tmpDist = _PathGenerator.distanceToSegment(-20, 0, 0, 0, 100, 0);
634
+ libExpect(tmpDist).to.be.closeTo(20, 0.001);
635
+ fDone();
636
+ }
637
+ );
638
+
639
+ test
640
+ (
641
+ 'should handle degenerate (zero-length) segment',
642
+ function (fDone)
643
+ {
644
+ let tmpDist = _PathGenerator.distanceToSegment(30, 40, 0, 0, 0, 0);
645
+ libExpect(tmpDist).to.be.closeTo(50, 0.001);
646
+ fDone();
647
+ }
648
+ );
649
+
650
+ test
651
+ (
652
+ 'should handle diagonal segments',
653
+ function (fDone)
654
+ {
655
+ let tmpDist = _PathGenerator.distanceToSegment(0, 100, 0, 0, 100, 100);
656
+ libExpect(tmpDist).to.be.closeTo(70.71, 0.1);
657
+ fDone();
658
+ }
659
+ );
660
+ }
661
+ );
662
+
663
+ // ---- getAutoMidpoint ----
664
+
665
+ suite
666
+ (
667
+ 'getAutoMidpoint',
668
+ function ()
669
+ {
670
+ test
671
+ (
672
+ 'should return a point between start and end',
673
+ function (fDone)
674
+ {
675
+ let tmpMid = _PathGenerator.getAutoMidpoint(
676
+ { x: 0, y: 50, side: 'right' },
677
+ { x: 400, y: 50, side: 'left' }
678
+ );
679
+
680
+ libExpect(tmpMid.x).to.be.greaterThan(0);
681
+ libExpect(tmpMid.x).to.be.lessThan(400);
682
+ fDone();
683
+ }
684
+ );
685
+
686
+ test
687
+ (
688
+ 'should be near horizontal midpoint for symmetric endpoints',
689
+ function (fDone)
690
+ {
691
+ let tmpMid = _PathGenerator.getAutoMidpoint(
692
+ { x: 0, y: 50, side: 'right' },
693
+ { x: 400, y: 50, side: 'left' }
694
+ );
695
+
696
+ libExpect(tmpMid.x).to.be.closeTo(200, 20);
697
+ libExpect(tmpMid.y).to.be.closeTo(50, 5);
698
+ fDone();
699
+ }
700
+ );
701
+
702
+ test
703
+ (
704
+ 'should handle vertical endpoints',
705
+ function (fDone)
706
+ {
707
+ let tmpMid = _PathGenerator.getAutoMidpoint(
708
+ { x: 100, y: 0, side: 'bottom' },
709
+ { x: 100, y: 400, side: 'top' }
710
+ );
711
+
712
+ libExpect(tmpMid.y).to.be.greaterThan(0);
713
+ libExpect(tmpMid.y).to.be.lessThan(400);
714
+ libExpect(tmpMid.x).to.be.closeTo(100, 5);
715
+ fDone();
716
+ }
717
+ );
718
+
719
+ test
720
+ (
721
+ 'should match manual evaluateCubicBezier at t=0.5',
722
+ function (fDone)
723
+ {
724
+ let tmpStart = { x: 0, y: 50, side: 'right' };
725
+ let tmpEnd = { x: 400, y: 150, side: 'left' };
726
+
727
+ let tmpMid = _PathGenerator.getAutoMidpoint(tmpStart, tmpEnd);
728
+ let tmpGeo = _PathGenerator.computeDirectionalGeometry(tmpStart, tmpEnd);
729
+
730
+ let tmpManual = _PathGenerator.evaluateCubicBezier(
731
+ { x: tmpGeo.departX, y: tmpGeo.departY },
732
+ { x: tmpGeo.cp1X, y: tmpGeo.cp1Y },
733
+ { x: tmpGeo.cp2X, y: tmpGeo.cp2Y },
734
+ { x: tmpGeo.approachX, y: tmpGeo.approachY },
735
+ 0.5
736
+ );
737
+
738
+ libExpect(tmpMid.x).to.be.closeTo(tmpManual.x, 0.001);
739
+ libExpect(tmpMid.y).to.be.closeTo(tmpManual.y, 0.001);
740
+ fDone();
741
+ }
742
+ );
743
+ }
744
+ );
745
+
746
+ // ---- getAutoMidpointSimple ----
747
+
748
+ suite
749
+ (
750
+ 'getAutoMidpointSimple',
751
+ function ()
752
+ {
753
+ test
754
+ (
755
+ 'should return a point between from and to',
756
+ function (fDone)
757
+ {
758
+ let tmpMid = _PathGenerator.getAutoMidpointSimple(
759
+ { x: 0, y: 50, side: 'right' },
760
+ { x: 400, y: 50, side: 'left' },
761
+ 20
762
+ );
763
+
764
+ libExpect(tmpMid.x).to.be.greaterThan(0);
765
+ libExpect(tmpMid.x).to.be.lessThan(400);
766
+ fDone();
767
+ }
768
+ );
769
+
770
+ test
771
+ (
772
+ 'should be near horizontal midpoint for symmetric endpoints',
773
+ function (fDone)
774
+ {
775
+ let tmpMid = _PathGenerator.getAutoMidpointSimple(
776
+ { x: 0, y: 50, side: 'right' },
777
+ { x: 400, y: 50, side: 'left' },
778
+ 20
779
+ );
780
+
781
+ libExpect(tmpMid.x).to.be.closeTo(200, 20);
782
+ libExpect(tmpMid.y).to.be.closeTo(50, 5);
783
+ fDone();
784
+ }
785
+ );
786
+
787
+ test
788
+ (
789
+ 'should handle vertical endpoints',
790
+ function (fDone)
791
+ {
792
+ let tmpMid = _PathGenerator.getAutoMidpointSimple(
793
+ { x: 100, y: 0, side: 'bottom' },
794
+ { x: 100, y: 400, side: 'top' },
795
+ 20
796
+ );
797
+
798
+ libExpect(tmpMid.y).to.be.greaterThan(0);
799
+ libExpect(tmpMid.y).to.be.lessThan(400);
800
+ libExpect(tmpMid.x).to.be.closeTo(100, 5);
801
+ fDone();
802
+ }
803
+ );
804
+
805
+ test
806
+ (
807
+ 'should use span-based control points (different strategy than getAutoMidpoint)',
808
+ function (fDone)
809
+ {
810
+ let tmpStart = { x: 0, y: 50, side: 'right' };
811
+ let tmpEnd = { x: 400, y: 200, side: 'left' };
812
+
813
+ let tmpSimple = _PathGenerator.getAutoMidpointSimple(tmpStart, tmpEnd, 20);
814
+ let tmpFull = _PathGenerator.getAutoMidpoint(tmpStart, tmpEnd);
815
+
816
+ // Both should be valid midpoints
817
+ libExpect(tmpSimple.x).to.be.a('number');
818
+ libExpect(tmpSimple.y).to.be.a('number');
819
+ libExpect(tmpFull.x).to.be.a('number');
820
+ libExpect(tmpFull.y).to.be.a('number');
821
+ fDone();
822
+ }
823
+ );
824
+ }
825
+ );
826
+
827
+ // ---- buildMultiBezierPathString ----
828
+
829
+ suite
830
+ (
831
+ 'buildMultiBezierPathString',
832
+ function ()
833
+ {
834
+ test
835
+ (
836
+ 'should produce path with N+1 cubic segments for N handles',
837
+ function (fDone)
838
+ {
839
+ let tmpHandles = [
840
+ { x: 150, y: 80 },
841
+ { x: 250, y: 120 }
842
+ ];
843
+
844
+ let tmpResult = _PathGenerator.buildMultiBezierPathString(
845
+ { x: 0, y: 50 }, // start
846
+ { x: 20, y: 50 }, // depart
847
+ tmpHandles,
848
+ { x: 380, y: 50 }, // approach
849
+ { x: 400, y: 50 }, // end
850
+ { dx: 1, dy: 0 }, // startDir
851
+ { dx: -1, dy: 0 } // endDir
852
+ );
853
+
854
+ libExpect(tmpResult).to.be.a('string');
855
+ // 2 handles → 3 segments → 3 C commands
856
+ let tmpCCount = (tmpResult.match(/C /g) || []).length;
857
+ libExpect(tmpCCount).to.equal(3);
858
+ fDone();
859
+ }
860
+ );
861
+
862
+ test
863
+ (
864
+ 'should produce path with 2 cubic segments for 1 handle',
865
+ function (fDone)
866
+ {
867
+ let tmpHandles = [{ x: 200, y: 100 }];
868
+
869
+ let tmpResult = _PathGenerator.buildMultiBezierPathString(
870
+ { x: 0, y: 50 },
871
+ { x: 20, y: 50 },
872
+ tmpHandles,
873
+ { x: 380, y: 50 },
874
+ { x: 400, y: 50 },
875
+ { dx: 1, dy: 0 },
876
+ { dx: -1, dy: 0 }
877
+ );
878
+
879
+ let tmpCCount = (tmpResult.match(/C /g) || []).length;
880
+ libExpect(tmpCCount).to.equal(2);
881
+ fDone();
882
+ }
883
+ );
884
+
885
+ test
886
+ (
887
+ 'should produce single segment for zero handles',
888
+ function (fDone)
889
+ {
890
+ let tmpResult = _PathGenerator.buildMultiBezierPathString(
891
+ { x: 0, y: 50 },
892
+ { x: 20, y: 50 },
893
+ [],
894
+ { x: 380, y: 50 },
895
+ { x: 400, y: 50 },
896
+ { dx: 1, dy: 0 },
897
+ { dx: -1, dy: 0 }
898
+ );
899
+
900
+ let tmpCCount = (tmpResult.match(/C /g) || []).length;
901
+ libExpect(tmpCCount).to.equal(1);
902
+ fDone();
903
+ }
904
+ );
905
+
906
+ test
907
+ (
908
+ 'should start with M and end with L',
909
+ function (fDone)
910
+ {
911
+ let tmpResult = _PathGenerator.buildMultiBezierPathString(
912
+ { x: 10, y: 20 },
913
+ { x: 30, y: 20 },
914
+ [{ x: 100, y: 80 }],
915
+ { x: 170, y: 20 },
916
+ { x: 190, y: 20 },
917
+ { dx: 1, dy: 0 },
918
+ { dx: -1, dy: 0 }
919
+ );
920
+
921
+ libExpect(tmpResult).to.match(/^M 10 20/);
922
+ libExpect(tmpResult).to.match(/L 190 20$/);
923
+ fDone();
924
+ }
925
+ );
926
+
927
+ test
928
+ (
929
+ 'should handle vertical start/end directions',
930
+ function (fDone)
931
+ {
932
+ let tmpResult = _PathGenerator.buildMultiBezierPathString(
933
+ { x: 100, y: 0 },
934
+ { x: 100, y: 20 },
935
+ [{ x: 200, y: 100 }],
936
+ { x: 100, y: 180 },
937
+ { x: 100, y: 200 },
938
+ { dx: 0, dy: 1 }, // downward departure
939
+ { dx: 0, dy: -1 } // upward approach
940
+ );
941
+
942
+ libExpect(tmpResult).to.be.a('string');
943
+ libExpect(tmpResult).to.contain('C ');
944
+ fDone();
945
+ }
946
+ );
947
+
948
+ test
949
+ (
950
+ 'should handle many handles (5+)',
951
+ function (fDone)
952
+ {
953
+ let tmpHandles = [];
954
+ for (let i = 0; i < 5; i++)
955
+ {
956
+ tmpHandles.push({ x: 50 + i * 60, y: 50 + (i % 2) * 80 });
957
+ }
958
+
959
+ let tmpResult = _PathGenerator.buildMultiBezierPathString(
960
+ { x: 0, y: 50 },
961
+ { x: 20, y: 50 },
962
+ tmpHandles,
963
+ { x: 380, y: 50 },
964
+ { x: 400, y: 50 },
965
+ { dx: 1, dy: 0 },
966
+ { dx: -1, dy: 0 }
967
+ );
968
+
969
+ // 5 handles → 6 segments → 6 C commands
970
+ let tmpCCount = (tmpResult.match(/C /g) || []).length;
971
+ libExpect(tmpCCount).to.equal(6);
972
+ fDone();
973
+ }
974
+ );
975
+ }
976
+ );
977
+ }
978
+ );