@yalehwang/plantuml-js 0.2.2 → 0.3.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 (65) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +106 -13
  3. package/dist/core.js +92 -52
  4. package/dist/foundation/labels.d.ts +44 -0
  5. package/dist/foundation/labels.js +231 -0
  6. package/dist/foundation/layers.d.ts +25 -0
  7. package/dist/foundation/layers.js +101 -0
  8. package/dist/foundation/layout.d.ts +116 -0
  9. package/dist/foundation/layout.js +298 -0
  10. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  11. package/dist/foundation/relation-semantics.d.ts +49 -0
  12. package/dist/foundation/relation-semantics.js +112 -0
  13. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  14. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/layout/compound.d.ts +74 -0
  18. package/dist/layout/compound.js +1204 -0
  19. package/dist/layout/elk-graph.d.ts +36 -0
  20. package/dist/layout/elk-graph.js +220 -0
  21. package/dist/layout/elk-result.d.ts +18 -0
  22. package/dist/layout/elk-result.js +127 -0
  23. package/dist/layout/elk.d.ts +80 -33
  24. package/dist/layout/elk.js +229 -320
  25. package/dist/layout/fanout-trunk.d.ts +18 -0
  26. package/dist/layout/fanout-trunk.js +380 -0
  27. package/dist/layout/grid-router.d.ts +69 -0
  28. package/dist/layout/grid-router.js +615 -0
  29. package/dist/layout/lane-spread.d.ts +11 -0
  30. package/dist/layout/lane-spread.js +302 -0
  31. package/dist/layout/package-endpoints.d.ts +10 -0
  32. package/dist/layout/package-endpoints.js +54 -0
  33. package/dist/layout/placement-strategy.d.ts +98 -0
  34. package/dist/layout/placement-strategy.js +26 -0
  35. package/dist/layout/strategy.d.ts +27 -0
  36. package/dist/layout/strategy.js +19 -0
  37. package/dist/layout/trunk-lanes.d.ts +29 -0
  38. package/dist/layout/trunk-lanes.js +308 -0
  39. package/dist/layout/viz.d.ts +3 -3
  40. package/dist/layout/viz.js +13 -17
  41. package/dist/metrics/layout-metrics.d.ts +183 -0
  42. package/dist/metrics/layout-metrics.js +766 -0
  43. package/dist/parse/parser.d.ts +43 -0
  44. package/dist/{parser.js → parse/parser.js} +111 -5
  45. package/dist/parse/serialize.d.ts +32 -0
  46. package/dist/parse/serialize.js +218 -0
  47. package/dist/render/legend.d.ts +15 -0
  48. package/dist/render/legend.js +42 -0
  49. package/dist/render/svg.d.ts +60 -3
  50. package/dist/render/svg.js +326 -98
  51. package/dist/render/theme.d.ts +109 -0
  52. package/dist/render/theme.js +99 -0
  53. package/dist/{transform.d.ts → transform/transform.d.ts} +55 -1
  54. package/dist/{transform.js → transform/transform.js} +277 -29
  55. package/package.json +4 -3
  56. package/dist/layout-metrics.d.ts +0 -42
  57. package/dist/layout-metrics.js +0 -241
  58. package/dist/layout.d.ts +0 -75
  59. package/dist/layout.js +0 -51
  60. package/dist/parser.d.ts +0 -17
  61. /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  62. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  63. /package/dist/{model.js → foundation/model.js} +0 -0
  64. /package/dist/{render-dot.d.ts → layout/render-dot.d.ts} +0 -0
  65. /package/dist/{render-dot.js → layout/render-dot.js} +0 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Fan-out trunks. A node that sends (or receives) many edges the same way gets
3
+ * a "comb" from the routers: every edge takes its own port and its own bend
4
+ * level, so a dozen nested right-angle staircases run side by side. This pass
5
+ * redraws such a group as a tree: one shared trunk leaves the node, runs to a
6
+ * common branch level, and a shared bus there fans out to short drops that end
7
+ * at each target. Edges stay orthogonal and keep their endpoints' sides; a
8
+ * group is only rewritten when the new geometry stays clear of every other node.
9
+ *
10
+ * Only edges shaped "out, across, onward" (first and third segments along the
11
+ * same direction, the second across) take part — the shape both ELK and the
12
+ * grid router give a plain fan-out. Anything else in the group is left alone.
13
+ */
14
+ import { findParallelBundles } from '../metrics/layout-metrics.js';
15
+ import { routeOrthogonal } from './grid-router.js';
16
+ /** Edges leaving (or entering) one side that make a group worth merging. */
17
+ export const TRUNK_MIN_EDGES = 5;
18
+ /** Shortest trunk worth drawing (px), measured from the node border. */
19
+ const MIN_TRUNK = 16;
20
+ /** Preferred distance between the bus and the nearest drop's far end. */
21
+ const BRANCH_GAP = 24;
22
+ /** Tightest bus-to-corner distance accepted when the preferred gap does not fit. */
23
+ const MIN_BRANCH_GAP = 12;
24
+ /** Clearance between a new segment and an unrelated node box. */
25
+ /** Extra crossings a re-routed comb may add (the tree trades a few crossings for one clean trunk). */
26
+ const REROUTE_CROSSING_BUDGET = 6;
27
+ /** Lattice cost of crossing another wire while re-routing a comb (default routing: 1.5). */
28
+ const REROUTE_CROSSING_COST = 12;
29
+ /** Lattice cost of running along another wire's lane while re-routing a comb (default routing: 2). */
30
+ const REROUTE_SHARED_LANE_COST = 8;
31
+ const NODE_CLEARANCE = 4;
32
+ /** A drop lands this far inside its target's side, away from the corners. */
33
+ const DROP_INSET = 10;
34
+ /** Spacing of the candidate drop columns tried along a target's face. */
35
+ const DROP_STEP = 8;
36
+ /** Parallel segments of different edges closer than this (and not on top of each other) read as one wall. */
37
+ const CROWD_DISTANCE = 10;
38
+ export function mergeFanoutTrunks(model, layout) {
39
+ const polylines = layout.edges.map((e) => dropRepeats(e.points));
40
+ const typeOf = (id) => {
41
+ const index = Number(id.startsWith('rel-') ? id.slice(4) : NaN);
42
+ return model.relations[index]?.type ?? '';
43
+ };
44
+ const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
45
+ let changed = false;
46
+ for (const end of ['source', 'target']) {
47
+ // Family = the edges of one kind at one node; each may leave through several
48
+ // sides (every side its far node lies beyond). Sides are taken greedily, the
49
+ // one that most unassigned edges can use first, while a group of enough remains.
50
+ const families = new Map();
51
+ layout.edges.forEach((e, i) => {
52
+ if (polylines[i].length < 2)
53
+ return;
54
+ const here = nodeById.get(e[end]);
55
+ const far = nodeById.get(end === 'source' ? e.target : e.source);
56
+ const dirs = here && far ? facing(here, far) : [];
57
+ if (dirs.length === 0)
58
+ return;
59
+ const key = `${e[end]}\u0000${typeOf(e.id)}`;
60
+ const list = families.get(key);
61
+ if (list)
62
+ list.push({ edge: i, dirs });
63
+ else
64
+ families.set(key, [{ edge: i, dirs }]);
65
+ });
66
+ for (const family of families.values()) {
67
+ let rest = family;
68
+ while (rest.length >= TRUNK_MIN_EDGES) {
69
+ const counts = new Map();
70
+ for (const m of rest)
71
+ for (const d of m.dirs)
72
+ counts.set(d, (counts.get(d) ?? 0) + 1);
73
+ const [dir, count] = [...counts].sort((x, y) => y[1] - x[1] || DIRS.indexOf(x[0]) - DIRS.indexOf(y[0]))[0];
74
+ if (count < TRUNK_MIN_EDGES)
75
+ break;
76
+ const picked = rest.filter((m) => m.dirs.includes(dir));
77
+ rest = rest.filter((m) => !m.dirs.includes(dir));
78
+ const members = picked.map((m) => ({
79
+ edge: m.edge,
80
+ points: end === 'source' ? polylines[m.edge] : polylines[m.edge].slice().reverse(),
81
+ }));
82
+ const rewritten = mergeGroup(members, dir, layout, polylines, end);
83
+ if (!rewritten)
84
+ continue;
85
+ for (const m of rewritten)
86
+ polylines[m.edge] = end === 'source' ? m.points : m.points.slice().reverse();
87
+ changed = true;
88
+ }
89
+ }
90
+ }
91
+ if (rerouteCombs(model, layout, polylines))
92
+ changed = true;
93
+ if (!changed)
94
+ return layout;
95
+ return { ...layout, edges: layout.edges.map((e, i) => ({ ...e, points: polylines[i] })) };
96
+ }
97
+ /**
98
+ * Combs the direct tree could not replace (their targets sit behind other
99
+ * nodes) are re-routed together on the lattice router, which grows a shared
100
+ * trunk wherever wires of one source (or target) run alike. A comb is kept
101
+ * re-routed only when that really removes it without piling up crossings.
102
+ */
103
+ function rerouteCombs(model, layout, polylines) {
104
+ const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
105
+ const indexOfEdge = new Map(layout.edges.map((e, i) => [e.id, i]));
106
+ const snapshot = () => layout.edges.map((e, i) => ({ ...e, points: polylines[i] }));
107
+ let changed = false;
108
+ for (const comb of findParallelBundles(snapshot())) {
109
+ // The whole family (same node, same end, same kind) is re-routed so the tree is complete.
110
+ const typeOf = (i) => model.relations[Number(layout.edges[i].id.slice(4))]?.type ?? '';
111
+ const seed = indexOfEdge.get(comb.edges[0]);
112
+ const members = layout.edges
113
+ .map((e, i) => i)
114
+ .filter((i) => layout.edges[i][comb.end] === comb.node && typeOf(i) === typeOf(seed) && polylines[i].length >= 2);
115
+ const requests = [];
116
+ for (const i of members) {
117
+ const source = nodeById.get(layout.edges[i].source);
118
+ const target = nodeById.get(layout.edges[i].target);
119
+ if (!source || !target)
120
+ break;
121
+ requests.push({ id: String(i), source, target });
122
+ }
123
+ if (requests.length !== members.length)
124
+ continue;
125
+ const taken = new Set(members);
126
+ const others = layout.edges
127
+ .map((e, i) => ({ source: e.source, target: e.target, points: polylines[i] }))
128
+ .filter((_, i) => !taken.has(i));
129
+ const routed = routeOrthogonal(requests, layout.nodes, {
130
+ width: layout.width,
131
+ height: layout.height,
132
+ frames: layout.packages,
133
+ existing: others,
134
+ crossingCost: REROUTE_CROSSING_COST,
135
+ sharedLaneCost: REROUTE_SHARED_LANE_COST,
136
+ });
137
+ const group = (pts) => members.map(pts);
138
+ const otherLines = others.map((o) => o.points);
139
+ const before = crossingTotal(group((i) => polylines[i]), otherLines);
140
+ const next = (i) => routed.get(String(i)) ?? polylines[i];
141
+ const after = crossingTotal(group(next), otherLines);
142
+ if (after > before + REROUTE_CROSSING_BUDGET)
143
+ continue;
144
+ const crowdBefore = group((i) => polylines[i]).reduce((n, g) => n + otherLines.reduce((m, o) => m + crowdedSegments(g, o), 0), 0);
145
+ const crowdAfter = group(next).reduce((n, g) => n + otherLines.reduce((m, o) => m + crowdedSegments(g, o), 0), 0);
146
+ if (crowdAfter > crowdBefore * 1.25 + 2)
147
+ continue;
148
+ const saved = members.map((i) => polylines[i]);
149
+ members.forEach((i) => (polylines[i] = next(i)));
150
+ const stillCombed = findParallelBundles(snapshot()).some((b) => b.node === comb.node && b.end === comb.end);
151
+ if (stillCombed) {
152
+ members.forEach((i, k) => (polylines[i] = saved[k]));
153
+ continue;
154
+ }
155
+ changed = true;
156
+ }
157
+ return changed;
158
+ }
159
+ function crossingTotal(group, others) {
160
+ let n = 0;
161
+ for (let i = 0; i < group.length; i++) {
162
+ for (let j = i + 1; j < group.length; j++)
163
+ n += polylineCrossings(group[i], group[j]);
164
+ for (const o of others)
165
+ n += polylineCrossings(group[i], o);
166
+ }
167
+ return n;
168
+ }
169
+ /** Tie-break order between sides (vertical trunks first). */
170
+ const DIRS = ['S', 'N', 'E', 'W'];
171
+ /** Gap (px) that must separate two nodes for one to count as lying "beyond" the other. */
172
+ const FACING_GAP = MIN_TRUNK + MIN_BRANCH_GAP;
173
+ /** Sides of `here` that `far` lies beyond by a clear gap (none when they overlap in both axes). */
174
+ function facing(here, far) {
175
+ const out = [];
176
+ if (far.y >= here.y + here.height + FACING_GAP)
177
+ out.push('S');
178
+ if (far.y + far.height <= here.y - FACING_GAP)
179
+ out.push('N');
180
+ if (far.x >= here.x + here.width + FACING_GAP)
181
+ out.push('E');
182
+ if (far.x + far.width <= here.x - FACING_GAP)
183
+ out.push('W');
184
+ return out;
185
+ }
186
+ /** Rewritten members of one group (the ones a clear tree route exists for), or undefined. */
187
+ function mergeGroup(members, dir, layout, polylines, end) {
188
+ const edges = layout.edges;
189
+ const nodes = layout.nodes;
190
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
191
+ const vertical = dir === 'N' || dir === 'S';
192
+ // Work in (along, cross) coordinates so one code path serves all four directions.
193
+ const along = (p) => (vertical ? p.y : p.x);
194
+ const cross = (p) => (vertical ? p.x : p.y);
195
+ const make = (a, c) => (vertical ? { x: c, y: a } : { x: a, y: c });
196
+ const sign = dir === 'S' || dir === 'E' ? 1 : -1;
197
+ const here = nodeById.get(edges[members[0].edge][end]);
198
+ const border = dir === 'S' ? here.y + here.height : dir === 'N' ? here.y : dir === 'E' ? here.x + here.width : here.x;
199
+ const orient = (pts) => (end === 'source' ? pts : pts.slice().reverse());
200
+ const fars = [];
201
+ const startAlong = sign * border;
202
+ for (const m of members) {
203
+ const e = edges[m.edge];
204
+ const node = nodeById.get(end === 'source' ? e.target : e.source);
205
+ if (!node)
206
+ continue;
207
+ const near = vertical ? (sign > 0 ? node.y : node.y + node.height) : sign > 0 ? node.x : node.x + node.width;
208
+ const lo = vertical ? node.x : node.y;
209
+ const hi = vertical ? node.x + node.width : node.y + node.height;
210
+ const last = m.points[m.points.length - 1];
211
+ fars.push({ m, face: sign * near, lo: lo + DROP_INSET, hi: hi - DROP_INSET, origin: cross(last) });
212
+ }
213
+ const ahead = fars.filter((f) => f.face - startAlong >= MIN_TRUNK + MIN_BRANCH_GAP && f.hi >= f.lo);
214
+ if (ahead.length < TRUNK_MIN_EDGES)
215
+ return undefined;
216
+ // The trunk leaves the node level with where most of its targets lie.
217
+ const centres = ahead.map((f) => (f.lo + f.hi) / 2).sort((a, b) => a - b);
218
+ const hereLo = (vertical ? here.x : here.y) + DROP_INSET;
219
+ const hereHi = (vertical ? here.x + here.width : here.y + here.height) - DROP_INSET;
220
+ const trunk = hereLo <= hereHi ? clamp(centres[(centres.length - 1) >> 1], hereLo, hereHi) : (hereLo + hereHi) / 2;
221
+ const nearest = Math.min(...ahead.map((f) => f.face));
222
+ const levels = [BRANCH_GAP, MIN_BRANCH_GAP, BRANCH_GAP + 12, BRANCH_GAP + 24, BRANCH_GAP + 36, BRANCH_GAP + 48]
223
+ .map((gap) => nearest - gap)
224
+ .filter((l) => l >= startAlong + MIN_TRUNK);
225
+ const before = (picked) => quality(picked.map((m) => orient(m.points)), others(picked), edges, picked, layout);
226
+ const others = (picked) => {
227
+ const taken = new Set(picked.map((m) => m.edge));
228
+ return polylines.filter((_, i) => !taken.has(i));
229
+ };
230
+ for (const level of levels) {
231
+ const L = sign * level;
232
+ const out = [];
233
+ for (const f of ahead) {
234
+ if (f.face <= level + 1)
235
+ continue;
236
+ const startPoint = make(border, trunk);
237
+ const faceAlong = sign * f.face;
238
+ // Candidate drop columns: where the old route arrived, the middle, then a sweep.
239
+ const mid = (f.lo + f.hi) / 2;
240
+ const columns = [clamp(f.origin, f.lo, f.hi), clamp(trunk, f.lo, f.hi), mid];
241
+ for (let c = f.lo; c <= f.hi; c += DROP_STEP)
242
+ columns.push(c);
243
+ columns.sort((a, b) => Math.abs(a - f.origin) - Math.abs(b - f.origin));
244
+ for (const column of new Set(columns)) {
245
+ const points = dropCollinear(dropRepeats([startPoint, make(L, trunk), make(L, column), make(faceAlong, column)]));
246
+ if (clearOfNodes(points, edges[f.m.edge], nodes)) {
247
+ out.push({ edge: f.m.edge, points });
248
+ break;
249
+ }
250
+ }
251
+ }
252
+ if (out.length < TRUNK_MIN_EDGES)
253
+ continue;
254
+ const original = out.map((o) => members.find((m) => m.edge === o.edge));
255
+ const prior = before(original);
256
+ const after = quality(out.map((o) => orient(o.points)), others(out), edges, out, layout);
257
+ if (after.crossings <= prior.crossings && after.frames <= prior.frames && after.crowding <= prior.crowding)
258
+ return out;
259
+ }
260
+ return undefined;
261
+ }
262
+ function clamp(v, lo, hi) {
263
+ return Math.min(hi, Math.max(lo, v));
264
+ }
265
+ function quality(group, others, edges, members, layout) {
266
+ let crossings = 0;
267
+ for (let i = 0; i < group.length; i++) {
268
+ for (let j = i + 1; j < group.length; j++)
269
+ crossings += polylineCrossings(group[i], group[j]);
270
+ for (const o of others)
271
+ crossings += polylineCrossings(group[i], o);
272
+ }
273
+ let frames = 0;
274
+ const frameRects = layout.packages ?? [];
275
+ members.forEach((m, k) => {
276
+ const edge = edges[m.edge];
277
+ const ends = [layout.nodes.find((n) => n.id === edge.source), layout.nodes.find((n) => n.id === edge.target)];
278
+ for (const f of frameRects) {
279
+ const own = ends.some((n) => n && n.x + n.width / 2 > f.x && n.x + n.width / 2 < f.x + f.width && n.y + n.height / 2 > f.y && n.y + n.height / 2 < f.y + f.height);
280
+ if (!own && polylineRunsInsideRect(group[k], f))
281
+ frames++;
282
+ }
283
+ });
284
+ let crowding = 0;
285
+ for (const g of group)
286
+ for (const o of others)
287
+ crowding += crowdedSegments(g, o);
288
+ return { crossings, frames, crowding };
289
+ }
290
+ function segs(points) {
291
+ const out = [];
292
+ for (let i = 0; i + 1 < points.length; i++)
293
+ out.push([points[i], points[i + 1]]);
294
+ return out;
295
+ }
296
+ function polylineCrossings(a, b) {
297
+ let n = 0;
298
+ for (const [p, q] of segs(a)) {
299
+ for (const [r, t] of segs(b)) {
300
+ const pv = Math.abs(p.x - q.x) < 1e-6;
301
+ const rv = Math.abs(r.x - t.x) < 1e-6;
302
+ if (pv === rv)
303
+ continue;
304
+ const [v0, v1, h0, h1] = pv ? [p, q, r, t] : [r, t, p, q];
305
+ if (v0.x > Math.min(h0.x, h1.x) + 1e-6 && v0.x < Math.max(h0.x, h1.x) - 1e-6 &&
306
+ h0.y > Math.min(v0.y, v1.y) + 1e-6 && h0.y < Math.max(v0.y, v1.y) - 1e-6)
307
+ n++;
308
+ }
309
+ }
310
+ return n;
311
+ }
312
+ /** Pairs of parallel segments up to 10 px apart (or on top of each other) that share a run of at least 20 px. */
313
+ function crowdedSegments(a, b) {
314
+ let n = 0;
315
+ for (const [p, q] of segs(a)) {
316
+ for (const [r, t] of segs(b)) {
317
+ const horizontal = Math.abs(p.y - q.y) < 1e-6;
318
+ if (horizontal !== (Math.abs(r.y - t.y) < 1e-6))
319
+ continue;
320
+ if (!horizontal && (Math.abs(p.x - q.x) > 1e-6 || Math.abs(r.x - t.x) > 1e-6))
321
+ continue;
322
+ const lateral = horizontal ? Math.abs(p.y - r.y) : Math.abs(p.x - r.x);
323
+ if (lateral > CROWD_DISTANCE)
324
+ continue;
325
+ const [a0, a1] = horizontal ? [p.x, q.x] : [p.y, q.y];
326
+ const [b0, b1] = horizontal ? [r.x, t.x] : [r.y, t.y];
327
+ if (Math.min(Math.max(a0, a1), Math.max(b0, b1)) - Math.max(Math.min(a0, a1), Math.min(b0, b1)) >= 20)
328
+ n++;
329
+ }
330
+ }
331
+ return n;
332
+ }
333
+ function polylineRunsInsideRect(points, r) {
334
+ for (const [a, b] of segs(points)) {
335
+ if (Math.abs(a.y - b.y) < 1e-6) {
336
+ if (a.y > r.y && a.y < r.y + r.height && Math.min(Math.max(a.x, b.x), r.x + r.width) - Math.max(Math.min(a.x, b.x), r.x) >= 4)
337
+ return true;
338
+ }
339
+ else if (Math.abs(a.x - b.x) < 1e-6) {
340
+ if (a.x > r.x && a.x < r.x + r.width && Math.min(Math.max(a.y, b.y), r.y + r.height) - Math.max(Math.min(a.y, b.y), r.y) >= 4)
341
+ return true;
342
+ }
343
+ }
344
+ return false;
345
+ }
346
+ /** True when no segment of `points` passes through a node other than the edge's own endpoints. */
347
+ function clearOfNodes(points, edge, nodes) {
348
+ for (let i = 0; i + 1 < points.length; i++) {
349
+ const x0 = Math.min(points[i].x, points[i + 1].x);
350
+ const x1 = Math.max(points[i].x, points[i + 1].x);
351
+ const y0 = Math.min(points[i].y, points[i + 1].y);
352
+ const y1 = Math.max(points[i].y, points[i + 1].y);
353
+ for (const n of nodes) {
354
+ if (n.id === edge.source || n.id === edge.target)
355
+ continue;
356
+ if (x1 > n.x - NODE_CLEARANCE &&
357
+ x0 < n.x + n.width + NODE_CLEARANCE &&
358
+ y1 > n.y - NODE_CLEARANCE &&
359
+ y0 < n.y + n.height + NODE_CLEARANCE) {
360
+ return false;
361
+ }
362
+ }
363
+ }
364
+ return true;
365
+ }
366
+ function dropRepeats(points) {
367
+ return points.filter((p, i) => i === 0 || Math.abs(p.x - points[i - 1].x) > 1e-6 || Math.abs(p.y - points[i - 1].y) > 1e-6);
368
+ }
369
+ function dropCollinear(points) {
370
+ const out = points.slice();
371
+ for (let i = out.length - 2; i >= 1; i--) {
372
+ const a = out[i - 1];
373
+ const b = out[i];
374
+ const c = out[i + 1];
375
+ if ((Math.abs(a.x - b.x) < 1e-6 && Math.abs(b.x - c.x) < 1e-6) || (Math.abs(a.y - b.y) < 1e-6 && Math.abs(b.y - c.y) < 1e-6)) {
376
+ out.splice(i, 1);
377
+ }
378
+ }
379
+ return out;
380
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Grid-based orthogonal edge router.
3
+ *
4
+ * ELK's hierarchical routing only resolves edges inside one layered run, and a
5
+ * layered run cannot keep a large compound graph at a sensible aspect ratio.
6
+ * The compound layout therefore lays blocks out first and hands the edges that
7
+ * cross block boundaries to this router: an A* search on a lattice whose
8
+ * vertices are blocked inside every node rectangle (plus a clearance margin),
9
+ * with a bend penalty and a soft penalty for sharing a lane with an already
10
+ * routed edge so parallel wires spread out instead of stacking.
11
+ *
12
+ * Every returned polyline is axis-aligned, starts and ends on the border of the
13
+ * source/target node, and avoids every other node rectangle unless no path
14
+ * exists (then a plain dog-leg is returned).
15
+ */
16
+ import type { NodeLayout, Point } from '../foundation/layout.js';
17
+ export interface RouteRequest {
18
+ id: string;
19
+ source: NodeLayout;
20
+ target: NodeLayout;
21
+ }
22
+ export interface GridRouterOptions {
23
+ width: number;
24
+ height: number;
25
+ /** Clearance kept between a wire and any node rectangle, in pixels. */
26
+ margin?: number;
27
+ /** Lattice pitch in pixels; chosen from the canvas area when omitted. */
28
+ pitch?: number;
29
+ /**
30
+ * Package frames. A wire running parallel to a frame border within
31
+ * [[FRAME_CLEARANCE]] px pays [[FRAME_FLUSH_COST]] per step, so lanes keep off
32
+ * the box outline instead of reading as part of it.
33
+ */
34
+ frames?: {
35
+ x: number;
36
+ y: number;
37
+ width: number;
38
+ height: number;
39
+ }[];
40
+ /**
41
+ * Wires already drawn (not re-routed). Their lanes count as used, so new wires
42
+ * keep off them — except wires with the same source or target, which they may
43
+ * share.
44
+ */
45
+ /** Cost of running along a lane another wire uses; default [[SHARED_LANE_COST]]. */
46
+ sharedLaneCost?: number;
47
+ /** Cost of crossing a used lane at right angles; default [[CROSSING_COST]]. */
48
+ crossingCost?: number;
49
+ /** Rip-up-and-re-route passes after the first routing; default [[RIPUP_PASSES]]. */
50
+ ripupPasses?: number;
51
+ existing?: {
52
+ source: string;
53
+ target: string;
54
+ points: Point[];
55
+ }[];
56
+ }
57
+ /** Distance (px) a lane keeps from a package frame border when it can. */
58
+ export declare const FRAME_CLEARANCE = 12;
59
+ /**
60
+ * Extra cost per lattice step through the interior of a package frame that
61
+ * neither endpoint of the wire lives in, so corridors prefer the gutters
62
+ * between frames over cutting through unrelated packages.
63
+ */
64
+ export declare const FOREIGN_FRAME_COST = 7;
65
+ /**
66
+ * Route every request. Returns a polyline per request id; requests whose
67
+ * endpoints are the same node get an empty polyline.
68
+ */
69
+ export declare function routeOrthogonal(requests: RouteRequest[], obstacles: NodeLayout[], options: GridRouterOptions): Map<string, Point[]>;