@knowvah/dot-engine 1.9.0 → 2.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 (184) hide show
  1. package/README.md +232 -30
  2. package/dist/api/builder.d.ts +3 -0
  3. package/dist/api/builder.d.ts.map +1 -1
  4. package/dist/api/edge-ops.d.ts +7 -0
  5. package/dist/api/edge-ops.d.ts.map +1 -1
  6. package/dist/api/geometry.d.ts +5 -2
  7. package/dist/api/geometry.d.ts.map +1 -1
  8. package/dist/api.js +159 -31
  9. package/dist/api.js.map +3 -3
  10. package/dist/async/collect.d.ts +49 -0
  11. package/dist/async/collect.d.ts.map +1 -0
  12. package/dist/async/fonts.d.ts +20 -0
  13. package/dist/async/fonts.d.ts.map +1 -0
  14. package/dist/async/render-async.d.ts +91 -0
  15. package/dist/async/render-async.d.ts.map +1 -0
  16. package/dist/async/render-into.d.ts +38 -0
  17. package/dist/async/render-into.d.ts.map +1 -0
  18. package/dist/async/sanitize.d.ts +28 -0
  19. package/dist/async/sanitize.d.ts.map +1 -0
  20. package/dist/common/htmltable-types.d.ts +3 -3
  21. package/dist/common/htmltable-types.d.ts.map +1 -1
  22. package/dist/common/make-label.d.ts.map +1 -1
  23. package/dist/common/poly-shapes.d.ts.map +1 -1
  24. package/dist/common/textmeasure-factory.d.ts +2 -0
  25. package/dist/common/textmeasure-factory.d.ts.map +1 -1
  26. package/dist/common/utils-inputscale.d.ts +19 -0
  27. package/dist/common/utils-inputscale.d.ts.map +1 -0
  28. package/dist/errors.d.ts +61 -5
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/gvc/context.d.ts +36 -4
  31. package/dist/gvc/context.d.ts.map +1 -1
  32. package/dist/gvc/device.d.ts +5 -2
  33. package/dist/gvc/device.d.ts.map +1 -1
  34. package/dist/gvc/image-resolver.d.ts +16 -16
  35. package/dist/gvc/image-resolver.d.ts.map +1 -1
  36. package/dist/gvc/job.d.ts +1 -9
  37. package/dist/gvc/job.d.ts.map +1 -1
  38. package/dist/gvc/usershape.d.ts +2 -12
  39. package/dist/gvc/usershape.d.ts.map +1 -1
  40. package/dist/index.d.ts +27 -8
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +7682 -5535
  43. package/dist/index.js.map +4 -4
  44. package/dist/label/index.d.ts.map +1 -1
  45. package/dist/label/node.d.ts +0 -6
  46. package/dist/label/node.d.ts.map +1 -1
  47. package/dist/label/rectangle.d.ts +1 -7
  48. package/dist/label/rectangle.d.ts.map +1 -1
  49. package/dist/layout/circo/circular.d.ts +8 -5
  50. package/dist/layout/circo/circular.d.ts.map +1 -1
  51. package/dist/layout/dot/pack-components.d.ts +0 -19
  52. package/dist/layout/dot/pack-components.d.ts.map +1 -1
  53. package/dist/layout/dot/position.d.ts +7 -2
  54. package/dist/layout/dot/position.d.ts.map +1 -1
  55. package/dist/layout/fdp/derive.d.ts.map +1 -1
  56. package/dist/layout/fdp/index.d.ts.map +1 -1
  57. package/dist/layout/fdp/init.d.ts.map +1 -1
  58. package/dist/layout/fdp/layout.d.ts.map +1 -1
  59. package/dist/layout/fdp/normalize.d.ts +2 -1
  60. package/dist/layout/fdp/normalize.d.ts.map +1 -1
  61. package/dist/layout/fdp/ports.d.ts +0 -10
  62. package/dist/layout/fdp/ports.d.ts.map +1 -1
  63. package/dist/layout/fdp/xlayout.d.ts +0 -16
  64. package/dist/layout/fdp/xlayout.d.ts.map +1 -1
  65. package/dist/layout/neato/adjust-info.d.ts +117 -0
  66. package/dist/layout/neato/adjust-info.d.ts.map +1 -0
  67. package/dist/layout/neato/cdt-surface.d.ts.map +1 -1
  68. package/dist/layout/neato/constraint-adjust.d.ts +40 -0
  69. package/dist/layout/neato/constraint-adjust.d.ts.map +1 -0
  70. package/dist/layout/neato/edge-len.d.ts +20 -0
  71. package/dist/layout/neato/edge-len.d.ts.map +1 -0
  72. package/dist/layout/neato/fdp-adjust.d.ts +32 -4
  73. package/dist/layout/neato/fdp-adjust.d.ts.map +1 -1
  74. package/dist/layout/neato/index.d.ts +9 -6
  75. package/dist/layout/neato/index.d.ts.map +1 -1
  76. package/dist/layout/neato/init.d.ts +4 -11
  77. package/dist/layout/neato/init.d.ts.map +1 -1
  78. package/dist/layout/neato/kk-paths.d.ts +50 -0
  79. package/dist/layout/neato/kk-paths.d.ts.map +1 -0
  80. package/dist/layout/neato/kk-solve.d.ts +14 -0
  81. package/dist/layout/neato/kk-solve.d.ts.map +1 -0
  82. package/dist/layout/neato/kk.d.ts +44 -0
  83. package/dist/layout/neato/kk.d.ts.map +1 -0
  84. package/dist/layout/neato/multispline-router.d.ts.map +1 -1
  85. package/dist/layout/neato/poly.d.ts +50 -0
  86. package/dist/layout/neato/poly.d.ts.map +1 -0
  87. package/dist/layout/neato/sc-adjust.d.ts +2 -9
  88. package/dist/layout/neato/sc-adjust.d.ts.map +1 -1
  89. package/dist/layout/neato/sgd-dijkstra.d.ts +20 -0
  90. package/dist/layout/neato/sgd-dijkstra.d.ts.map +1 -0
  91. package/dist/layout/neato/sgd.d.ts +14 -9
  92. package/dist/layout/neato/sgd.d.ts.map +1 -1
  93. package/dist/layout/neato/simple-scale.d.ts +15 -0
  94. package/dist/layout/neato/simple-scale.d.ts.map +1 -0
  95. package/dist/layout/neato/start.d.ts +51 -0
  96. package/dist/layout/neato/start.d.ts.map +1 -0
  97. package/dist/layout/neato/vpsc-adjust.d.ts +21 -0
  98. package/dist/layout/neato/vpsc-adjust.d.ts.map +1 -0
  99. package/dist/layout/sfdp/index.d.ts.map +1 -1
  100. package/dist/layout/sfdp/init.d.ts +0 -9
  101. package/dist/layout/sfdp/init.d.ts.map +1 -1
  102. package/dist/layout/sfdp/spring-driver.d.ts.map +1 -1
  103. package/dist/layout/twopi/circle.d.ts +0 -7
  104. package/dist/layout/twopi/circle.d.ts.map +1 -1
  105. package/dist/ortho/ortho-parallel.d.ts.map +1 -1
  106. package/dist/ortho/trap-query.d.ts.map +1 -1
  107. package/dist/parser/index.d.ts +9 -5
  108. package/dist/parser/index.d.ts.map +1 -1
  109. package/dist/render/index.d.ts +2 -0
  110. package/dist/render/index.d.ts.map +1 -1
  111. package/dist/render/public.d.ts +9 -3
  112. package/dist/render/public.d.ts.map +1 -1
  113. package/dist/render/xdot-public.d.ts +7 -2
  114. package/dist/render/xdot-public.d.ts.map +1 -1
  115. package/dist/render.js +7468 -5556
  116. package/dist/render.js.map +4 -4
  117. package/dist/util/xml.d.ts.map +1 -1
  118. package/dist/vpsc/Solver.d.ts +1 -0
  119. package/dist/vpsc/Solver.d.ts.map +1 -1
  120. package/package.json +1 -1
  121. package/src/api/builder.ts +73 -5
  122. package/src/api/edge-ops.ts +19 -0
  123. package/src/api/geometry.ts +22 -5
  124. package/src/async/collect.ts +204 -0
  125. package/src/async/fonts.ts +61 -0
  126. package/src/async/render-async.ts +205 -0
  127. package/src/async/render-into.ts +115 -0
  128. package/src/async/sanitize.ts +150 -0
  129. package/src/common/htmltable-types.ts +4 -5
  130. package/src/common/make-label.ts +10 -1
  131. package/src/common/poly-shapes.ts +4 -1
  132. package/src/common/textmeasure-factory.ts +11 -0
  133. package/src/common/utils-inputscale.ts +31 -0
  134. package/src/errors.ts +188 -5
  135. package/src/gvc/context.ts +96 -17
  136. package/src/gvc/device.ts +11 -6
  137. package/src/gvc/image-resolver.ts +25 -2
  138. package/src/gvc/job.ts +3 -1
  139. package/src/gvc/usershape.ts +6 -0
  140. package/src/index.ts +59 -43
  141. package/src/label/index.ts +6 -2
  142. package/src/label/node.ts +2 -1
  143. package/src/label/rectangle.ts +4 -2
  144. package/src/layout/circo/circular.ts +9 -6
  145. package/src/layout/dot/pack-components.ts +6 -2
  146. package/src/layout/dot/position.ts +21 -3
  147. package/src/layout/fdp/derive.ts +7 -6
  148. package/src/layout/fdp/index.ts +45 -5
  149. package/src/layout/fdp/init.ts +7 -6
  150. package/src/layout/fdp/layout.ts +2 -1
  151. package/src/layout/fdp/normalize.ts +12 -8
  152. package/src/layout/fdp/ports.ts +3 -2
  153. package/src/layout/fdp/xlayout.ts +8 -5
  154. package/src/layout/neato/adjust-info.ts +332 -0
  155. package/src/layout/neato/cdt-surface.ts +50 -30
  156. package/src/layout/neato/constraint-adjust.ts +466 -0
  157. package/src/layout/neato/edge-len.ts +36 -0
  158. package/src/layout/neato/fdp-adjust.ts +120 -12
  159. package/src/layout/neato/index.ts +36 -54
  160. package/src/layout/neato/init.ts +21 -36
  161. package/src/layout/neato/kk-paths.ts +146 -0
  162. package/src/layout/neato/kk-solve.ts +64 -0
  163. package/src/layout/neato/kk.ts +322 -0
  164. package/src/layout/neato/multispline-router.ts +4 -3
  165. package/src/layout/neato/poly.ts +493 -0
  166. package/src/layout/neato/sc-adjust.ts +2 -16
  167. package/src/layout/neato/sgd-dijkstra.ts +125 -0
  168. package/src/layout/neato/sgd.ts +63 -40
  169. package/src/layout/neato/simple-scale.ts +54 -0
  170. package/src/layout/neato/start.ts +222 -0
  171. package/src/layout/neato/vpsc-adjust.ts +93 -0
  172. package/src/layout/sfdp/index.ts +4 -5
  173. package/src/layout/sfdp/init.ts +40 -4
  174. package/src/layout/sfdp/spring-driver.ts +30 -1
  175. package/src/layout/twopi/circle.ts +2 -1
  176. package/src/ortho/ortho-parallel.ts +2 -1
  177. package/src/ortho/trap-query.ts +2 -1
  178. package/src/parser/index.ts +11 -11
  179. package/src/render/index.ts +5 -0
  180. package/src/render/public.ts +24 -24
  181. package/src/render/svg.ts +1 -1
  182. package/src/render/xdot-public.ts +19 -18
  183. package/src/util/xml.ts +24 -30
  184. package/src/vpsc/Solver.ts +5 -3
@@ -8,10 +8,15 @@
8
8
  */
9
9
 
10
10
  import type { Graph } from '../../model/graph.js';
11
+ import type { Node } from '../../model/node.js';
11
12
  import type { TermSgd } from './dijkstra.js';
12
- import { dijkstraSgd } from './dijkstra.js';
13
+ import { dijkstraSgd } from './sgd-dijkstra.js';
14
+ import { setEdgeLen } from './edge-len.js';
13
15
  import { rkNewState, rkSeed, rkInterval } from '../../util/mt19937.js';
14
16
  import type { RkState } from '../../util/mt19937.js';
17
+ import { drand48 } from '../../common/random.js';
18
+ import { lateDouble } from '../../common/nodeinit.js';
19
+ import { checkStart, parseStart, INIT_RANDOM, INIT_REGULAR } from './start.js';
15
20
 
16
21
  // ---------------------------------------------------------------------------
17
22
  // Model constants — @see lib/neatogen/defs.h
@@ -26,11 +31,14 @@ export const MODEL_SUBSET = 2;
26
31
  /** MDS distance model (falls back to MODEL_SHORTPATH). */
27
32
  export const MODEL_MDS = 3;
28
33
 
29
- /** Default tolerance (Epsilon in neato). @see lib/neatogen/sgd.c */
30
- const DFLT_TOLERANCE = 1e-4;
34
+ /** Epsilon under MODE_SGD: a minimum step size, not the 1e-4 energy tolerance. @see lib/neatogen/stuff.c:scan_graph_mode */
35
+ const SGD_EPSILON = 0.01;
31
36
 
32
- /** Default maximum number of SGD iterations. @see lib/neatogen/neato.h */
33
- const MAX_ITER = 30;
37
+ /** MaxIter when `maxiter` is unset under MODE_SGD. @see lib/neatogen/neatoinit.c:1288 */
38
+ const SGD_DFLT_ITERATIONS = 30;
39
+
40
+ /** C's rk_seed(0, &rstate) in sgd(): the shuffle seed is fixed. @see lib/neatogen/sgd.c:215 */
41
+ const SHUFFLE_SEED = 0;
34
42
 
35
43
  // ---------------------------------------------------------------------------
36
44
  // GraphSgd — CSR sparse-graph for Dijkstra
@@ -78,27 +86,31 @@ export function assignNodeIds(g: Graph): { nNodes: number; nEdges: number } {
78
86
  }
79
87
 
80
88
  /**
81
- * Append CSR entries for all non-self-loop edges incident on node `np`.
89
+ * Append CSR entries for all non-self-loop edges incident on node `np`
90
+ * (out-edges first, then in-edges, as agfstedge/agnxtedge).
82
91
  * Returns the number of edges appended.
83
92
  * @internal
84
93
  */
85
94
  export function fillNodeEdges(
86
- np: import('../../model/node.js').Node,
95
+ np: Node,
87
96
  g: Graph,
88
97
  graph: GraphSgd,
89
98
  edgeOffset: number,
90
99
  ): number {
91
100
  let nEdges = edgeOffset;
92
- for (const ep of g.edges) {
93
- if (ep.tail === np && ep.head !== np) {
94
- graph.targets[nEdges] = ep.head.info.id ?? 0;
95
- graph.weights[nEdges] = ep.info.dist ?? 1;
96
- nEdges++;
97
- } else if (ep.head === np && ep.tail !== np) {
98
- graph.targets[nEdges] = ep.tail.info.id ?? 0;
99
- graph.weights[nEdges] = ep.info.dist ?? 1;
100
- nEdges++;
101
- }
101
+ // agfstedge/agnxtedge: every out-edge, then every in-edge, each dict ordered
102
+ // by (other endpoint's seq, edge seq). The order is load-bearing: dijkstra
103
+ // tie-breaking sets the term order that the shuffle then permutes.
104
+ // ND_id is assigned and setEdgeLen sets ED_dist on every edge before
105
+ // extractAdjacency runs (sgd() below), so both are defined here.
106
+ for (const ep of np.outEdges(g)) {
107
+ if (ep.head === np) continue;
108
+ graph.targets[nEdges] = ep.head.info.id!;
109
+ graph.weights[nEdges++] = ep.info.dist!;
110
+ }
111
+ for (const ep of np.inEdges(g)) {
112
+ graph.targets[nEdges] = ep.tail.info.id!;
113
+ graph.weights[nEdges++] = ep.info.dist!;
102
114
  }
103
115
  return nEdges;
104
116
  }
@@ -252,21 +264,25 @@ export function fisheryatesShuffle(terms: TermSgd[], state: RkState): void {
252
264
  // Initial positions
253
265
  // ---------------------------------------------------------------------------
254
266
 
267
+ /** C hasPos: ND_pinned > 0 (P_SET from `pos=`, or P_PIN). @see lib/neatogen/stress.c:hasPos */
268
+ function hasPos(np: Node): boolean {
269
+ return np.info.posSet === true || np.info.pinned === true;
270
+ }
271
+
255
272
  /**
256
- * Assign random initial positions to unpinned nodes with no position set.
257
- * Uses MT19937 for deterministic, seed-controlled output.
273
+ * Seed drand48 from `start` (checkStart) and give every node without a
274
+ * position a random one. start=regular keeps the regular placement that
275
+ * solveModel already made (checkStart's INIT_REGULAR early return).
258
276
  *
259
277
  * @see lib/neatogen/stuff.c:initial_positions
260
278
  * @see lib/neatogen/stuff.c:randompos
261
279
  */
262
- export function initialPositions(g: Graph, state: RkState): void {
263
- for (const [, np] of g.nodes) {
264
- if (np.info.pinned === true) continue;
265
- if (np.info.pos != null) continue;
266
- np.info.pos = [
267
- rkInterval(0xffff, state) / 0x10000,
268
- rkInterval(0xffff, state) / 0x10000,
269
- ];
280
+ export function initialPositions(g: Graph, nG: number = g.nodes.size): void {
281
+ if (parseStart(g.root.attrs.get('start'), INIT_RANDOM).init === INIT_REGULAR) return;
282
+ checkStart(g, nG, INIT_RANDOM);
283
+ for (const np of g.nodes.values()) {
284
+ if (hasPos(np)) continue;
285
+ np.info.pos = [drand48(), drand48()];
270
286
  }
271
287
  }
272
288
 
@@ -277,7 +293,7 @@ export function initialPositions(g: Graph, state: RkState): void {
277
293
  /**
278
294
  * Compute the annealing schedule parameters from the term weights.
279
295
  *
280
- * eta_max = 1/w_min; eta_min = DFLT_TOLERANCE/w_max
296
+ * eta_max = 1/w_min; eta_min = Epsilon/w_max
281
297
  * lambda = log(eta_max/eta_min) / (maxIter - 1)
282
298
  *
283
299
  * @see lib/neatogen/sgd.c:sgd (initialise annealing schedule)
@@ -285,6 +301,7 @@ export function initialPositions(g: Graph, state: RkState): void {
285
301
  export function computeSchedule(
286
302
  terms: TermSgd[],
287
303
  maxIter: number,
304
+ epsilon: number = SGD_EPSILON,
288
305
  ): { etaMax: number; lambda: number } {
289
306
  let wMin = terms[0].w;
290
307
  let wMax = terms[0].w;
@@ -293,7 +310,7 @@ export function computeSchedule(
293
310
  if (terms[ij].w > wMax) wMax = terms[ij].w;
294
311
  }
295
312
  const etaMax = 1 / wMin;
296
- const etaMin = DFLT_TOLERANCE / wMax;
313
+ const etaMin = epsilon / wMax;
297
314
  const lambda = Math.log(etaMax / etaMin) / (maxIter - 1);
298
315
  return { etaMax, lambda };
299
316
  }
@@ -404,15 +421,15 @@ export function resolveModel(model: number): number {
404
421
  return model;
405
422
  }
406
423
 
407
- /** Optimisation loop: shuffle and step for MAX_ITER iterations. @see lib/neatogen/sgd.c:sgd */
424
+ /** Optimisation loop: shuffle and step for `sched.maxIter` iterations. @see lib/neatogen/sgd.c:sgd */
408
425
  export function runSgdLoop(
409
426
  pos: Float64Array,
410
427
  terms: TermSgd[],
411
428
  unfixed: boolean[],
412
- sched: { etaMax: number; lambda: number },
429
+ sched: { etaMax: number; lambda: number; maxIter: number },
413
430
  state: RkState,
414
431
  ): void {
415
- for (let t = 0; t < MAX_ITER; t++) {
432
+ for (let t = 0; t < sched.maxIter; t++) {
416
433
  fisheryatesShuffle(terms, state);
417
434
  const eta = sched.etaMax * Math.exp(-sched.lambda * t);
418
435
  sgdIteration(pos, terms, unfixed, eta);
@@ -431,7 +448,8 @@ export function runSgdLoop(
431
448
  *
432
449
  * Node positions are written to `node.info.pos` ([x, y]).
433
450
  * Nodes with `node.info.pinned === true` are not moved.
434
- * `g.info.seed` controls the MT19937 seed (default 0).
451
+ * The shuffle RNG is seeded with 0 as in C; `start` seeds only the initial
452
+ * positions (drand48, via checkStart).
435
453
  *
436
454
  * @see lib/neatogen/sgd.c:sgd
437
455
  */
@@ -440,22 +458,27 @@ export function sgdLayout(g: Graph, model: number): void {
440
458
  const n = g.nodes.size;
441
459
  if (n === 0) return;
442
460
 
443
- const state = rkNewState();
444
- rkSeed(g.info.seed ?? 0, state);
445
-
446
461
  let idx = 0;
447
462
  for (const [, node] of g.nodes) { node.info.id = idx++; }
448
463
 
449
- initialPositions(g, state);
450
-
464
+ setEdgeLen(g);
451
465
  const graph = extractAdjacency(g, resolvedModel);
452
466
  const terms = sgdBuildTerms(graph);
453
467
  if (terms.length === 0) return;
454
468
 
455
- const { etaMax, lambda } = computeSchedule(terms, MAX_ITER);
469
+ // C: MaxIter = atoi(maxiter) else 30; Epsilon = late_double(epsilon, 1e-4)
470
+ // @see lib/neatogen/neatoinit.c:1283-1288
471
+ const maxIterAttr = g.attrs.get('maxiter');
472
+ const maxIter = maxIterAttr !== undefined ? parseInt(maxIterAttr, 10) || 0 : SGD_DFLT_ITERATIONS;
473
+ const epsilon = lateDouble(g.attrs.get('epsilon'), SGD_EPSILON, 0);
474
+ const { etaMax, lambda } = computeSchedule(terms, maxIter, epsilon);
475
+
476
+ initialPositions(g, n);
456
477
  const pos = posToArray(g, n);
457
478
  const unfixed = buildUnfixed(g, n);
458
479
 
459
- runSgdLoop(pos, terms, unfixed, { etaMax, lambda }, state);
480
+ const state = rkNewState();
481
+ rkSeed(SHUFFLE_SEED, state);
482
+ runSgdLoop(pos, terms, unfixed, { etaMax, lambda, maxIter }, state);
460
483
  posFromArray(g, pos);
461
484
  }
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * The `scale` graph attribute: multiply every node position by (sx, sy)
5
+ * before overlap removal. removeOverlapWith runs it right after normalize,
6
+ * whatever the overlap mode.
7
+ *
8
+ * @see lib/neatogen/adjust.c:simpleScale
9
+ */
10
+
11
+ import type { Graph } from '../../model/graph.js';
12
+ import { aggetGraph } from '../fdp/fdp-model.js';
13
+
14
+ /** @see lib/neatogen/adjust.c:ISZERO */
15
+ const ISZERO = (d: number): boolean => Math.abs(d) < 0.000000001;
16
+
17
+ /** One C `%lf` conversion: optional leading whitespace, then a strtod number. */
18
+ const LF = /^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/;
19
+
20
+ /**
21
+ * C `sscanf(p, "%lf,%lf", &x, &y)`: the values converted, in order (0, 1 or
22
+ * 2 of them). An empty string returns none — C's EOF (-1) there reads an
23
+ * uninitialised pointf, undefined behaviour the port defines as a no-op.
24
+ */
25
+ function scanPair(p: string): number[] {
26
+ const m1 = LF.exec(p);
27
+ if (m1 === null) return [];
28
+ const rest = p.slice(m1[0].length);
29
+ if (!rest.startsWith(',')) return [parseFloat(m1[1]!)];
30
+ const m2 = LF.exec(rest.slice(1));
31
+ return m2 === null ? [parseFloat(m1[1]!)] : [parseFloat(m1[1]!), parseFloat(m2[1]!)];
32
+ }
33
+
34
+ /**
35
+ * Scale node positions by the `scale` attribute ("s" or "sx,sy"). Returns 1
36
+ * if nodes moved, else 0 (unset, unparsable, a zero factor, or 1,1).
37
+ * @see lib/neatogen/adjust.c:simpleScale
38
+ */
39
+ export function simpleScale(g: Graph): number {
40
+ const p = aggetGraph(g, 'scale');
41
+ if (p === undefined) return 0;
42
+ const sc = scanPair(p);
43
+ if (sc.length === 0) return 0;
44
+ const x = sc[0]!;
45
+ if (ISZERO(x)) return 0;
46
+ const y = sc.length === 1 ? x : sc[1]!;
47
+ if (ISZERO(y)) return 0;
48
+ if (y === 1 && x === 1) return 0;
49
+ for (const n of g.nodes.values()) {
50
+ n.info.pos![0] *= x;
51
+ n.info.pos![1] *= y;
52
+ }
53
+ return 1;
54
+ }
@@ -0,0 +1,222 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+ /**
3
+ * Neato `start` handling (setSeed / initRegular / checkStart) and the
4
+ * fail-loud checks for the neato init features this port does not have
5
+ * (smart init, the subset/circuit distance models, hier/ipsep modes).
6
+ *
7
+ * Each loud check sits where neato's C branches into the unported code and
8
+ * fires only for the modes whose C path reaches it (ADR-3).
9
+ *
10
+ * @see lib/neatogen/neatoinit.c
11
+ */
12
+
13
+ import { RenderError } from '../../errors.js';
14
+ import type { Graph } from '../../model/graph.js';
15
+ import { srand48 } from '../../common/random.js';
16
+ import { mapbool, isACluster } from '../dot/rank.js';
17
+ import { getPack } from '../pack/index.js';
18
+ import {
19
+ MODE_KK, MODE_MAJOR, MODE_HIER, MODE_IPSEP, MODE_SGD,
20
+ MODEL_CIRCUIT, MODEL_SUBSET,
21
+ } from './init.js';
22
+
23
+ /** @see lib/neatogen/neato.h:INIT_SELF */
24
+ export const INIT_SELF = 0;
25
+ /** @see lib/neatogen/neato.h:INIT_REGULAR */
26
+ export const INIT_REGULAR = 1;
27
+ /** @see lib/neatogen/neato.h:INIT_RANDOM */
28
+ export const INIT_RANDOM = 2;
29
+
30
+ /** @see lib/common/const.h:Spring_coeff */
31
+ const SPRING_COEFF = 1.0;
32
+
33
+ /** `start` keyword prefixes. @see lib/neatogen/neatoinit.c:SMART,REGULAR,RANDOM */
34
+ const START_KEYWORDS: readonly (readonly [string, number])[] = [
35
+ ['self', INIT_SELF],
36
+ ['regular', INIT_REGULAR],
37
+ ['random', INIT_RANDOM],
38
+ ];
39
+
40
+ const WARN_POS_IGNORED = 'node positions are ignored unless start=random';
41
+ const WARN_SELF_IGNORED = 'start=0 not supported with mode=self - ignored';
42
+
43
+ /**
44
+ * Roots already warned about start=self under KK/SGD. C warns once per process
45
+ * (stuff.c:330 static atomic_flag); one render is one C process here, so the
46
+ * flag is per root graph rather than module state (multi-diagram safety).
47
+ */
48
+ const selfWarned = new WeakSet<object>();
49
+ /** Roots already warned about the KK circuit fallback (one warning per graph). */
50
+ const circuitWarned = new WeakSet<object>();
51
+
52
+ /**
53
+ * Classify the `start` attribute: keyword prefix, digit-led (random), else the
54
+ * default. A random seed comes from the digits following the keyword; with
55
+ * none, C seeds from time(), which this port keeps at the default seed 1.
56
+ * @see lib/neatogen/neatoinit.c:setSeed
57
+ */
58
+ export function parseStart(
59
+ p: string | undefined, dflt: number,
60
+ ): { init: number; seed: number } {
61
+ let init = dflt;
62
+ let rest = p ?? '';
63
+ if (rest === '') return { init, seed: 1 };
64
+ if (/^[A-Za-z]/.test(rest)) {
65
+ const kw = START_KEYWORDS.find(([k]) => rest.startsWith(k));
66
+ if (kw !== undefined) { init = kw[1]; rest = rest.slice(kw[0].length); }
67
+ } else if (/^\d/.test(rest)) {
68
+ init = INIT_RANDOM;
69
+ }
70
+ const seed = init === INIT_RANDOM && /^\d/.test(rest) ? parseInt(rest, 10) : 1;
71
+ return { init, seed };
72
+ }
73
+
74
+ /**
75
+ * Place the nodes on a regular polygon and mark them P_SET (positioned, not
76
+ * pinned), so the stress kernel's initLayout starts from them.
77
+ * @see lib/neatogen/neatoinit.c:initRegular
78
+ */
79
+ export function initRegular(g: Graph, nG: number): void {
80
+ let a = 0.0;
81
+ const da = (2 * Math.PI) / nG;
82
+ for (const np of g.nodes.values()) {
83
+ np.info.pos = [nG * SPRING_COEFF * Math.cos(a), nG * SPRING_COEFF * Math.sin(a)];
84
+ np.info.posSet = true;
85
+ np.info.pinned = false; // C: ND_pinned = P_SET overrides P_PIN
86
+ a += da;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Analyse `start`: place regular nodes, seed the RNG, return the init kind.
92
+ * @see lib/neatogen/neatoinit.c:checkStart
93
+ */
94
+ export function checkStart(
95
+ g: Graph, nG: number = g.nodes.size, dflt: number = INIT_RANDOM,
96
+ ): number {
97
+ const { init, seed } = parseStart(g.root.attrs.get('start'), dflt);
98
+ if (init !== INIT_RANDOM && [...g.nodes.values()].some((n) => n.attrs.has('pos'))) {
99
+ console.warn(WARN_POS_IGNORED);
100
+ }
101
+ if (init === INIT_REGULAR) initRegular(g, nG);
102
+ srand48(seed);
103
+ return init;
104
+ }
105
+
106
+ function unsupported(attr: string, value: string, what: string): never {
107
+ throw new RenderError(`${attr}=${value}: ${what} is not supported yet`, 'UNSUPPORTED_FEATURE');
108
+ }
109
+
110
+ /** Does C's ipsep branch build constraints? @see neatoinit.c:1132-1150 */
111
+ function ipsepHasConstraints(g: Graph): boolean {
112
+ const dir = g.root.attrs.get('diredgeconstraints') ?? '';
113
+ if (mapbool(dir) || /^hier/i.test(dir)) return true;
114
+ if ((g.root.attrs.get('overlap') ?? '').toLowerCase() === 'ipsep') return true;
115
+ return [...g.root.subgraphs.values()].some((s) => s !== g.root && isACluster(s));
116
+ }
117
+
118
+ /** @see lib/neatogen/neatoinit.c:1290 (MaxIter < 0 returns before any solver) */
119
+ function maxIterNegative(g: Graph): boolean {
120
+ const v = g.attrs.get('maxiter') ?? g.root.attrs.get('maxiter');
121
+ if (v === undefined) return false;
122
+ const n = parseInt(v, 10);
123
+ return !Number.isNaN(n) && n < 0;
124
+ }
125
+
126
+ /** @see lib/neatogen/neatoinit.c:neatoMode (DIGCOLA + IPSEPCOLA builds) */
127
+ // A function, not a constant: init.ts and start.ts import each other, so the
128
+ // MODE_* values are not yet initialised when this module body runs.
129
+ const modeByName = (): ReadonlyMap<string, number> => new Map([
130
+ ['KK', MODE_KK], ['major', MODE_MAJOR], ['sgd', MODE_SGD],
131
+ ['hier', MODE_HIER], ['ipsep', MODE_IPSEP],
132
+ ]);
133
+
134
+ /**
135
+ * Read the root `mode` attribute: empty/absent is MODE_MAJOR, an unknown value
136
+ * warns with C's text and is ignored. Case sensitive, as C's streq.
137
+ * @see lib/neatogen/neatoinit.c:neatoMode
138
+ */
139
+ export function neatoMode(g: Graph): number {
140
+ const str = g.root.attrs.get('mode');
141
+ if (str === undefined || str === '') return MODE_MAJOR;
142
+ const mode = modeByName().get(str);
143
+ if (mode !== undefined) return mode;
144
+ console.warn(`Illegal value ${str} for attribute "mode" in graph ${g.root.name} - ignored`);
145
+ return MODE_MAJOR;
146
+ }
147
+
148
+ function checkMode(g: Graph, mode: number): void {
149
+ const value = g.root.attrs.get('mode') || (mode === MODE_HIER ? 'hier' : 'ipsep');
150
+ // @see lib/neatogen/neatoinit.c:1117 (stress_majorization_with_hierarchy)
151
+ if (mode === MODE_HIER) unsupported('mode', value, 'hierarchical stress majorization');
152
+ // @see lib/neatogen/neatoinit.c:1121-1170 (stress_majorization_cola)
153
+ if (mode === MODE_IPSEP && ipsepHasConstraints(g)) {
154
+ unsupported('mode', value, 'constrained (ipsep) majorization');
155
+ }
156
+ }
157
+
158
+ function checkStartAttr(g: Graph, mode: number): void {
159
+ const value = g.root.attrs.get('start');
160
+ if (parseStart(value, INIT_RANDOM).init !== INIT_SELF) return;
161
+ // @see lib/neatogen/neatoinit.c:1095, lib/neatogen/stress.c:884 (smart_ini)
162
+ if (mode === MODE_MAJOR || mode === MODE_IPSEP) unsupported('start', value ?? '', 'smart initialisation');
163
+ // @see lib/neatogen/stuff.c:330 (KK and SGD warn once and ignore it)
164
+ if ((mode === MODE_KK || mode === MODE_SGD) && !selfWarned.has(g.root)) {
165
+ selfWarned.add(g.root);
166
+ console.warn(WARN_SELF_IGNORED);
167
+ }
168
+ }
169
+
170
+ function isConnected(g: Graph): boolean {
171
+ const nodes = [...g.nodes.values()];
172
+ if (nodes.length === 0) return true;
173
+ const adj = new Map<unknown, unknown[]>(nodes.map((n) => [n, []]));
174
+ for (const e of g.edges) { adj.get(e.tail)?.push(e.head); adj.get(e.head)?.push(e.tail); }
175
+ const seen = new Set<unknown>([nodes[0]]);
176
+ const stack: unknown[] = [nodes[0]];
177
+ for (let n = stack.pop(); n !== undefined; n = stack.pop()) {
178
+ for (const m of adj.get(n) ?? []) if (!seen.has(m)) { seen.add(m); stack.push(m); }
179
+ }
180
+ return seen.size === nodes.length;
181
+ }
182
+
183
+ /** KK runs unpacked unless `pack` or `packmode` is set. @see neatoinit.c:1380-1389 */
184
+ function kkCircuitFallsBack(g: Graph): boolean {
185
+ const packed = getPack(g.root, -1, 0) >= 0 || (g.root.attrs.get('packmode') ?? '') !== '';
186
+ return !packed && !isConnected(g.root);
187
+ }
188
+
189
+ function warnCircuitDisconnected(g: Graph): void {
190
+ if (circuitWarned.has(g.root)) return;
191
+ circuitWarned.add(g.root);
192
+ console.warn(
193
+ `graph ${g.root.name} is disconnected. Hence, the circuit model\n`
194
+ + 'is undefined. Reverting to the shortest path model.\n'
195
+ + 'Alternatively, consider running neato using -Gpack=true or decomposing\n'
196
+ + 'the graph into connected components.',
197
+ );
198
+ }
199
+
200
+ function checkModel(g: Graph, mode: number, model: number): void {
201
+ const value = g.root.attrs.get('model') ?? '';
202
+ const stressPath = mode === MODE_MAJOR || mode === MODE_KK;
203
+ // @see lib/neatogen/stress.c:846, lib/neatogen/neatoinit.c:1246
204
+ if (model === MODEL_SUBSET && stressPath) unsupported('model', value, 'the subset distance model');
205
+ if (model !== MODEL_CIRCUIT || !stressPath) return;
206
+ // @see lib/neatogen/neatoinit.c:1248-1257 (disconnected: warn + shortpath)
207
+ if (mode === MODE_KK && kkCircuitFallsBack(g)) { warnCircuitDisconnected(g); return; }
208
+ // @see lib/neatogen/stress.c:851, lib/neatogen/circuit.c:circuit_model
209
+ unsupported('model', value, 'the circuit distance model');
210
+ }
211
+
212
+ /**
213
+ * Throw UNSUPPORTED_FEATURE where neato's C would run unported init code for
214
+ * this mode/model/start. Call after the nG < 2 guard.
215
+ * @see lib/neatogen/neatoinit.c:neatoLayout
216
+ */
217
+ export function assertSupported(g: Graph, mode: number, model: number): void {
218
+ if (maxIterNegative(g)) return;
219
+ checkMode(g, mode);
220
+ checkStartAttr(g, mode);
221
+ checkModel(g, mode, model);
222
+ }
@@ -0,0 +1,93 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * `overlap=vpsc`: the VPSC adjuster of removeOverlapWith, moved out of
5
+ * neato/index.ts so every engine reaches it through the shared dispatch.
6
+ *
7
+ * C works on `float` coordinates (`float *coords[2]`), so positions are rounded
8
+ * to float on entry and after each pass: that decides exact-touch verdicts
9
+ * between the x and y passes.
10
+ *
11
+ * @see lib/neatogen/adjust.c:vpscAdjust
12
+ * @see lib/neatogen/quad_prog_vpsc.c:removeoverlaps
13
+ */
14
+
15
+ import type { Graph } from '../../model/graph.js';
16
+ import {
17
+ newVariable,
18
+ deleteVariable,
19
+ deleteVPSC,
20
+ deleteConstraints,
21
+ solveVPSC,
22
+ genXConstraints,
23
+ genYConstraints,
24
+ VPSC,
25
+ Rectangle,
26
+ type Variable,
27
+ } from '../../vpsc/index.js';
28
+ import { sepFactor, DFLT_MARGIN } from './sep-factor.js';
29
+
30
+ const POINTS_PER_INCH = 72;
31
+ /** Widen nodes in the x pass so a horizontally resolved pair does not count as
32
+ * vertically overlapping. @see quad_prog_vpsc.c:generateNonoverlapConstraints */
33
+ const X_PASS_SCALE = Math.fround(1.0001);
34
+
35
+ interface Gap { x: number; y: number }
36
+ interface VpscBox { w: number; h: number }
37
+ type Coords = readonly [Float32Array, Float32Array];
38
+
39
+ /** One Rectangle per node: size scaled by `scale`, plus half the gap per side.
40
+ * @see lib/neatogen/quad_prog_vpsc.c:generateNonoverlapConstraints */
41
+ function buildRects(c: Coords, size: VpscBox[], gap: Gap, scale: number): Rectangle[] {
42
+ return size.map((s, i) => {
43
+ const hw = (scale * s.w) / 2.0 + gap.x / 2.0;
44
+ const hh = (scale * s.h) / 2.0 + gap.y / 2.0;
45
+ return new Rectangle(c[0][i]! - hw, c[0][i]! + hw, c[1][i]! - hh, c[1][i]! + hh);
46
+ });
47
+ }
48
+
49
+ /** Solve one axis and store the result (rounded to float) into `c[axis]`.
50
+ * @see lib/neatogen/quad_prog_vpsc.c:removeoverlaps */
51
+ function solveAxis(c: Coords, size: VpscBox[], gap: Gap, axis: 0 | 1): void {
52
+ const rects = buildRects(c, size, gap, axis === 0 ? X_PASS_SCALE : 1);
53
+ const vars: Variable[] = size.map((_, i) => newVariable(i, 1.0, 1.0));
54
+ const cs = axis === 0 ? genXConstraints(rects, vars, true) : genYConstraints(rects, vars);
55
+ const vpsc = new VPSC(vars, cs);
56
+ solveVPSC(vpsc);
57
+ // solveVPSC places every variable in a block. @see quad_prog_vpsc.c:568 getVariablePos
58
+ vars.forEach((v, i) => {
59
+ c[axis][i] = v.position();
60
+ });
61
+ deleteVPSC(vpsc);
62
+ deleteConstraints(cs);
63
+ for (const v of vars) deleteVariable(v);
64
+ }
65
+
66
+ /**
67
+ * Remove overlaps with two VPSC passes (x then y). The gap handed to
68
+ * removeoverlaps is twice the separation margin (opt.gap is the gap size, not
69
+ * the margin): `2 * sep` when `sep` is additive, else twice DFLT_MARGIN.
70
+ * Returns 0 as C does.
71
+ * @see lib/neatogen/adjust.c:vpscAdjust
72
+ */
73
+ export function vpscAdjust(g: Graph): number {
74
+ const margin = sepFactor(g);
75
+ const gap: Gap = {
76
+ x: (2 * (margin.doAdd ? margin.x : DFLT_MARGIN)) / POINTS_PER_INCH,
77
+ y: (2 * (margin.doAdd ? margin.y : DFLT_MARGIN)) / POINTS_PER_INCH,
78
+ };
79
+ const nodes = Array.from(g.nodes.values());
80
+ const c: Coords = [new Float32Array(nodes.length), new Float32Array(nodes.length)];
81
+ // Layout has set ND_pos and node init ND_width/ND_height before any adjuster.
82
+ nodes.forEach((n, i) => {
83
+ c[0][i] = n.info.pos![0];
84
+ c[1][i] = n.info.pos![1];
85
+ });
86
+ const size = nodes.map((n) => ({ w: n.info.width!, h: n.info.height! }));
87
+ solveAxis(c, size, gap, 0);
88
+ solveAxis(c, size, gap, 1);
89
+ nodes.forEach((n, i) => {
90
+ n.info.pos = [c[0][i]!, c[1][i]!];
91
+ });
92
+ return 0;
93
+ }
@@ -31,8 +31,7 @@ import { CL_OFFSET } from '../twopi/pipeline.js';
31
31
  import { placeGraphLabel } from '../dot/position-bbox.js';
32
32
  import { gvPostprocess } from '../../common/postproc.js';
33
33
  import { aggetGraph } from '../fdp/fdp-model.js';
34
- import { overlapPrismTries } from '../neato/fdp-adjust.js';
35
- import { adjustNodesScale } from '../neato/sc-adjust.js';
34
+ import { overlapPrismTries, adjustNodesFull } from '../neato/fdp-adjust.js';
36
35
  import { lateDouble } from '../../common/nodeinit.js';
37
36
  import {
38
37
  type SpringElectricalControl,
@@ -85,7 +84,7 @@ export function sfdpLayoutComponent(
85
84
  * for prism/false), ctrl.initialScaling = overlap_scaling (default −4), and
86
85
  * doAdjust is false. Otherwise (scale family, AM_NONE, unported modes) sfdp's
87
86
  * in-layout removal is off (ctrl.overlap = −1) and doAdjust drives a
88
- * post-layout removeOverlapWith (adjustNodesScale).
87
+ * post-layout removeOverlapWith (adjustNodesFull).
89
88
  * @see lib/neatogen/adjust.c:graphAdjustMode / getAdjustMode (15.0.0)
90
89
  * @see lib/sfdpgen/sfdpinit.c:sfdp_layout (HAVE_GTS branch)
91
90
  */
@@ -139,7 +138,7 @@ function layoutComponents(
139
138
  for (const sg of comps) {
140
139
  // ccomps already node-induces edges (graphviz_node_induce).
141
140
  sfdpLayoutComponent(sg, ctrl, pad);
142
- if (doAdjust) adjustNodesScale(sg); // removeOverlapWith (non-PRISM modes)
141
+ if (doAdjust) adjustNodesFull(sg); // removeOverlapWith (non-PRISM modes)
143
142
  // @see lib/sfdpgen/sfdpinit.c:284 (setEdgeType FUNCTION, per component)
144
143
  setEdgeTypeFromAttr(sg, EDGETYPE_LINE);
145
144
  splineEdgesShifted(sg);
@@ -213,7 +212,7 @@ export function sfdpLayout(g: Graph): void {
213
212
  singleComponent = comps.length === 1;
214
213
  if (singleComponent) {
215
214
  sfdpLayoutComponent(g, ctrl, pad);
216
- if (doAdjust) adjustNodesScale(g); // removeOverlapWith (non-PRISM modes)
215
+ if (doAdjust) adjustNodesFull(g); // removeOverlapWith (non-PRISM modes)
217
216
  splineEdgesShifted(g); // C spline_edges: shift + coord sync + route
218
217
  } else {
219
218
  layoutComponents(g, comps, ctrl, pad, doAdjust);