@davidsouther/agathon 0.0.0-stage → 0.0.0

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 (62) hide show
  1. package/package.json +19 -4
  2. package/src/assert.ts +67 -0
  3. package/src/geometry/box.ts +76 -0
  4. package/src/geometry/circle.ts +296 -0
  5. package/src/geometry/geometry.ts +318 -0
  6. package/src/geometry/path.ts +315 -0
  7. package/src/geometry/quadtree.ts +321 -0
  8. package/src/geometry/rectangle.ts +129 -0
  9. package/src/geometry/rngon.ts +132 -0
  10. package/src/geometry/types.ts +102 -0
  11. package/src/geometry.ts +61 -0
  12. package/src/highlight/highlight.ts +85 -0
  13. package/src/index.ts +5 -0
  14. package/src/layout/grid/manager.ts +55 -0
  15. package/src/layout/grid.ts +1 -0
  16. package/src/layout/hierarchic/README.md +21 -0
  17. package/src/layout/hierarchic/acyclic.ts +249 -0
  18. package/src/layout/hierarchic/children.ts +169 -0
  19. package/src/layout/hierarchic/debug.ts +186 -0
  20. package/src/layout/hierarchic/layout.ts +266 -0
  21. package/src/layout/hierarchic/manager.ts +65 -0
  22. package/src/layout/hierarchic/normalize.ts +177 -0
  23. package/src/layout/hierarchic/order.ts +321 -0
  24. package/src/layout/hierarchic/position.ts +514 -0
  25. package/src/layout/hierarchic/rank.ts +629 -0
  26. package/src/layout/hierarchic.ts +2 -0
  27. package/src/layout/hulls/arrange.ts +94 -0
  28. package/src/layout/hulls/hulls.ts +243 -0
  29. package/src/layout/hulls/segments.ts +174 -0
  30. package/src/layout/hulls.ts +3 -0
  31. package/src/layout/manager/child_manager.ts +42 -0
  32. package/src/layout/manager/drag_manager.ts +112 -0
  33. package/src/layout/manager/manager.ts +425 -0
  34. package/src/layout/manager/pan_manager.ts +146 -0
  35. package/src/layout/manager/visibility_manager.ts +84 -0
  36. package/src/layout/manager/zoom_manager.ts +263 -0
  37. package/src/layout/manager.ts +6 -0
  38. package/src/layout/properties/defaults.ts +99 -0
  39. package/src/layout/properties/symbol.ts +99 -0
  40. package/src/layout/properties/weak_map.ts +73 -0
  41. package/src/layout/properties.ts +111 -0
  42. package/src/layout/types.ts +470 -0
  43. package/src/layout/util.ts +444 -0
  44. package/src/layout.ts +6 -0
  45. package/src/samples/diff_graph.ts +15 -0
  46. package/src/samples/edge_cases.ts +168 -0
  47. package/src/samples/gansner_with_states.ts +67 -0
  48. package/src/samples/load_generator.ts +213 -0
  49. package/src/samples/search_graph.ts +35 -0
  50. package/src/tsabre/algo.ts +126 -0
  51. package/src/tsabre/builder.ts +97 -0
  52. package/src/tsabre/compress.ts +15 -0
  53. package/src/tsabre/dijkstra/dijkstra.ts +76 -0
  54. package/src/tsabre/dijkstra/heap.ts +83 -0
  55. package/src/tsabre/graph.ts +808 -0
  56. package/src/tsabre/parse.ts +634 -0
  57. package/src/tsabre/print.ts +188 -0
  58. package/src/tsabre/sourcebuilder/code_content_buffer.ts +275 -0
  59. package/src/tsabre/sourcebuilder/printable.ts +33 -0
  60. package/src/tsabre.ts +6 -0
  61. package/src/types.ts +424 -0
  62. package/README.md +0 -3
@@ -0,0 +1,514 @@
1
+ import { assert, assertExists } from "../../assert.ts";
2
+ import { RNGon } from "../../geometry.ts";
3
+ import { TsabreGraph } from "../../tsabre/graph.ts";
4
+ import type { FullGraph, NodeId } from "../../types.ts";
5
+ import * as util from "../util.ts";
6
+
7
+ /**
8
+ * An alignment marks a set of blocks (vertical chains of edges) and classes
9
+ * (horizontal groupings of blocks).
10
+ */
11
+ export interface Alignment {
12
+ root: Record<NodeId, NodeId>;
13
+ align: Record<NodeId, NodeId>;
14
+ }
15
+
16
+ /** A shorthand for tracking id to number. */
17
+ export type Coordinates = Record<NodeId, number>;
18
+
19
+ /**
20
+ * The coordinates of each node, from each of the diaganols.
21
+ */
22
+ export interface CoordinateBounds {
23
+ ul: Coordinates;
24
+ ur: Coordinates;
25
+ dl: Coordinates;
26
+ dr: Coordinates;
27
+ }
28
+
29
+ /**
30
+ * Given a graph with nodes' rank and order filled in, determine their relative
31
+ * position, obeying size & label constraints. Y positions are trivially easy-
32
+ * all nodes with the same rank have the same Y value, and the next Y value is
33
+ * the prior Y value plus half the max node height of the current row plus half
34
+ * the max node height of the next row plus the graph's rankSep.
35
+ *
36
+ * X coordinates are much more complex, and lean heavily on the "balance and
37
+ * symmetry" aesthetic. Constraints to consider are balancing whitespace, length
38
+ * and straightness of edges, and balancing edges incident on one node. Given
39
+ * that vertical placement is fixed by ranks, above, X positions indeed only
40
+ * lay out in the horizontal direction.
41
+ *
42
+ * See [Brandes and Koepf] in docs/tsabre for more details.
43
+ */
44
+ export function position(g: FullGraph): void {
45
+ const layerMatrix = util.buildLayerMatrix(g);
46
+
47
+ const ys = positionY(g, layerMatrix);
48
+ const xs = positionX(g, layerMatrix);
49
+
50
+ for (const node of g.allNodes) {
51
+ const x = xs[node.id] ?? 0;
52
+ const y = ys[node.id] ?? 0;
53
+ util.getNodeProps(node).geometry.moveTo({ x, y });
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Calculate aesthetically pleasing X coordinates for nodes, given a SmartGraph
59
+ * with nodes having `order` and `rank` on their layout, as well as having
60
+ * virtual nodes indicating segments of multi-rank edge spans. Node layout
61
+ * should keep conceptual "columns" grouped, that is, keeping edges as vertical
62
+ * and as short as possible.
63
+ */
64
+ function positionX(g: FullGraph, layers: NodeId[][]): Coordinates {
65
+ const conflicts = findType1Conflicts(g, layers);
66
+
67
+ // Positioning layouts have strong directional affinities. To mitigate this,
68
+ // we calculate all 4 layouts, and then take the median positions for the
69
+ // final balanced position. See [Brandes and Kopf 4.3].
70
+ const direction = {
71
+ ul: [...layers],
72
+ ur: [...layers].map((l) => [...l].reverse()),
73
+ dl: [...layers].reverse(),
74
+ dr: [...layers].map((l) => [...l].reverse()).reverse(),
75
+ };
76
+
77
+ const xss: CoordinateBounds = {
78
+ ul: makeBounds(direction.ul, (id: NodeId) => g.predecessors(id), false),
79
+ ur: makeBounds(direction.ur, (id: NodeId) => g.predecessors(id), true),
80
+ dl: makeBounds(direction.dl, (id: NodeId) => g.successors(id), false),
81
+ dr: makeBounds(direction.dr, (id: NodeId) => g.successors(id), true),
82
+ };
83
+
84
+ const smallestWidth = findSmallestWidthAlignment(g, xss);
85
+ alignCoordinates(xss, smallestWidth);
86
+ return balance(xss);
87
+
88
+ function makeBounds(
89
+ layers: NodeId[][],
90
+ neighborFn: (id: NodeId) => Set<NodeId>,
91
+ correction: boolean,
92
+ ) {
93
+ const align = verticalAlignment(layers, conflicts, neighborFn);
94
+ const xs = horizontalCompaction(g, layers, align);
95
+
96
+ if (correction) {
97
+ for (const [id, x] of Object.entries(xs)) {
98
+ xs[id] = -x;
99
+ }
100
+ }
101
+ return xs;
102
+ }
103
+ }
104
+
105
+ function positionY(g: FullGraph, layers: NodeId[][]): Coordinates {
106
+ const coordinates: Coordinates = {};
107
+ const { rankSep, staggerRows } = util.getGraphProps(g);
108
+ assert(staggerRows > 0);
109
+ // It's easiest to assign y position as the middle of the row + row sep + the
110
+ // middle of the next row. To normalize back to y=0 for the first row,
111
+ // subtract that first middle row height.
112
+ // All empty/undefined (virtual) nodes in layers are filtered out in
113
+ // buildLayerMatrix.
114
+ let prevY =
115
+ -0.5 * Math.max(...layers[0].map((id) => util.getNodeProps(g.node(id)!).geometry.height));
116
+ for (const layer of layers) {
117
+ const height = util.staggerRow(g, layer, coordinates, staggerRows);
118
+ for (const id of layer) {
119
+ coordinates[id] += prevY;
120
+ }
121
+ prevY += height + rankSep;
122
+ }
123
+ return coordinates;
124
+ }
125
+
126
+ /**
127
+ * Conflicts track which other nodes a node has conflicting edges with.
128
+ * Conflicts occur when two edge segments cross or share a vertex.
129
+ *
130
+ * a b
131
+ * X
132
+ * c d
133
+ *
134
+ * a conflicts with c and d, b conflicts with c and d,
135
+ */
136
+ type Conflicts = Map<NodeId, Set<NodeId>>;
137
+
138
+ /**
139
+ * Type 1 conflicts are when a non-inner segment crosses an inner segment. An
140
+ * inner segment is one on which both incident nodes are marked `virtual`, and
141
+ * represents a portion of a longer edge which crosses multiple ranks. Inner
142
+ * segments should be kept vertical; otherwise, long edges will be "jagged".
143
+ *
144
+ * We scan layer by layer, starting at the second, looking for type-1 conflicts
145
+ * between the current and prior layer.
146
+ */
147
+ function findType1Conflicts(g: FullGraph, layers: NodeId[][]): Conflicts {
148
+ const conflicts = new Map<NodeId, Set<NodeId>>();
149
+
150
+ for (let i = 1; i < layers.length; i++) {
151
+ const layer = layers[i];
152
+ const prev = layers[i - 1];
153
+
154
+ let k0 = 0;
155
+ let scanPos = 0;
156
+ const prevLength = prev.length;
157
+
158
+ for (let j = 0; j < layer.length; j++) {
159
+ const id = layer[j];
160
+
161
+ const target = findOtherInnerSegmentNode(g, id);
162
+ const k1 = target ? util.getNodeProps(g.node(target)!).order : prevLength;
163
+
164
+ if (target || id === layer[layer.length - 1]) {
165
+ for (const scanNodeId of layer.slice(scanPos, j + 1)) {
166
+ const scanNode = g.node(scanNodeId);
167
+
168
+ if (!scanNode) continue;
169
+
170
+ const vScanNode = util.getNodeProps(scanNode).virtual;
171
+ for (const pred of g.predecessors(scanNodeId)) {
172
+ const { order, virtual } = util.getNodeProps(g.node(pred)!);
173
+
174
+ const hasVScanNode = vScanNode !== false;
175
+ const hasVirtual = virtual !== false;
176
+
177
+ if ((order < k0 || k1 < order) && !(hasVirtual && hasVScanNode)) {
178
+ addConflict(conflicts, pred, scanNodeId);
179
+ }
180
+ }
181
+ }
182
+ scanPos = j + 1;
183
+ k0 = k1;
184
+ }
185
+ }
186
+ }
187
+
188
+ return conflicts;
189
+ }
190
+
191
+ function findOtherInnerSegmentNode(g: FullGraph, id: NodeId): NodeId | undefined {
192
+ const node = g.node(id);
193
+ if (node && util.getNodeProps(node).virtual !== false) {
194
+ return Array.from(g.predecessors(id)).find(
195
+ (id) => util.getNodeProps(g.node(id)!).virtual !== false,
196
+ );
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ function sortIds(a: NodeId, b: NodeId): [NodeId, NodeId] {
202
+ // Normalize source and target alphabetically
203
+ if (a > b) {
204
+ [b, a] = [a, b];
205
+ }
206
+ return [a, b];
207
+ }
208
+
209
+ /** Mark a conflict between any source/target pair. */
210
+ function addConflict(conflicts: Conflicts, a: NodeId, b: NodeId): void {
211
+ const [source, target] = sortIds(a, b);
212
+ const conflictSet = conflicts.get(source) || new Set<NodeId>();
213
+ conflicts.set(source, conflictSet);
214
+ conflictSet.add(target);
215
+ }
216
+
217
+ /** Check if there is a conflict between any source/target pair. */
218
+ function hasConflict(conflicts: Conflicts, a: NodeId, b: NodeId): boolean {
219
+ const [source, target] = sortIds(a, b);
220
+ if (!conflicts.has(source)) return false;
221
+ return conflicts.get(source)!.has(target);
222
+ }
223
+
224
+ /**
225
+ * Determine vertical alignment among nodes by finding the median positions of a
226
+ *
227
+ * See [Brandes and Kopf 4.1].
228
+ */
229
+ function verticalAlignment(
230
+ layers: NodeId[][],
231
+ conflicts: Conflicts,
232
+ neighbors: (id: NodeId) => Set<NodeId>,
233
+ ): Alignment {
234
+ // The root mapping will point to the node's neighbor to the left.
235
+ const root: Record<NodeId, NodeId> = {};
236
+ // The align mapping will point to the node's neighbor to the right.
237
+ const align: Record<NodeId, NodeId> = {};
238
+ const pos: Coordinates = {};
239
+
240
+ for (const layer of layers) {
241
+ for (let i = 0; i < layer.length; i++) {
242
+ const id = layer[i];
243
+ root[id] = id;
244
+ align[id] = id;
245
+ pos[id] = i;
246
+ }
247
+ }
248
+
249
+ for (const layer of layers) {
250
+ let prevIdx = -1;
251
+ for (const id of layer) {
252
+ const ns = neighbors(id);
253
+ if (ns.size > 0) {
254
+ const ids = Array.from(ns).sort((a, b) => pos[a] - pos[b]);
255
+ // Update the median point
256
+ const mp = (ids.length - 1) / 2;
257
+ for (let i = Math.floor(mp), j = Math.ceil(mp); i <= j; i++) {
258
+ const targetId = ids[i];
259
+ if (
260
+ align[id] === id &&
261
+ prevIdx < pos[targetId] &&
262
+ !hasConflict(conflicts, id, targetId)
263
+ ) {
264
+ align[targetId] = id;
265
+ align[id] = root[id] = root[targetId];
266
+ prevIdx = pos[targetId];
267
+ }
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ return { root, align };
274
+ }
275
+
276
+ /**
277
+ * Given a vertical alignment, shrink it as tight as possible. This shrinking
278
+ * is facilitated by a block graph, that is, a graph that points each node at
279
+ * its predecessor on a rank. See [Brandes and Kopf 4.2].
280
+ */
281
+ function horizontalCompaction(
282
+ graph: FullGraph,
283
+ layers: NodeId[][],
284
+ alignment: Alignment,
285
+ ): Coordinates {
286
+ const xs: Coordinates = {};
287
+ const { root, align } = alignment;
288
+
289
+ const blockG = buildBlockGraph();
290
+
291
+ iterate(pass1, (id) => blockG.predecessors(id));
292
+ iterate(pass2, (id) => blockG.successors(id));
293
+
294
+ for (const id of Object.keys(align)) {
295
+ xs[id] = xs[root[id]];
296
+ }
297
+
298
+ return xs;
299
+
300
+ /* Set x values to the maximum incoming edge weights, pushing the node to the
301
+ * far right of the vertical block. */
302
+ function pass1(id: NodeId) {
303
+ const edgeWeights = Array.from(blockG.inEdges(id)).map(
304
+ (e) => (xs[e.source] || 0) + blockG.edge(e)!.separation,
305
+ );
306
+ xs[id] = edgeWeights.reduce((max, e) => Math.max(max, e), 0);
307
+ if (isNaN(xs[id])) {
308
+ // This is a serious error that is unclear how best to handle. In lieu of
309
+ // a robust understanding of the problem, just shunt the node far-left.
310
+ // If this is happening, drop a debugger here and take a look.
311
+ xs[id] = -1;
312
+ }
313
+ }
314
+
315
+ /* Set x values to the minimum outgoing edge weights, pulling the node to the
316
+ * far left of the vertical block. */
317
+ function pass2(id: NodeId) {
318
+ const MAX = Number.POSITIVE_INFINITY;
319
+ const edgeWeights = Array.from(blockG.outEdges(id)).map(
320
+ (e) => xs[e.target] - blockG.edge(e)!.separation,
321
+ );
322
+ const min = edgeWeights.reduce((min, e) => Math.min(min, e), MAX);
323
+ if (min !== MAX) {
324
+ xs[id] = Math.max(xs[id], min);
325
+ }
326
+ }
327
+
328
+ // Utility to apply a function across the blockG nodes
329
+ function iterate(setXs: (id: NodeId) => void, nextNodes: (id: NodeId) => Set<NodeId>) {
330
+ const stack = Array.from(blockG.nodes);
331
+ const visited = new Set<NodeId>();
332
+ while (stack.length > 0) {
333
+ const id = stack.pop()!;
334
+ if (visited.has(id)) {
335
+ setXs(id);
336
+ } else {
337
+ visited.add(id);
338
+ stack.push(id, ...nextNodes(id));
339
+ }
340
+ }
341
+ }
342
+
343
+ function buildBlockGraph() {
344
+ const blockGraph = new TsabreGraph<
345
+ {},
346
+ { source: NodeId; target: NodeId; separation: number }
347
+ >();
348
+ const { nodeSep, edgeSep } = util.getGraphProps(graph);
349
+
350
+ for (const layer of layers) {
351
+ let tId: NodeId | undefined;
352
+ for (const sId of layer) {
353
+ const sRoot = root[sId];
354
+ blockGraph.setNode(sRoot, {});
355
+ if (tId) {
356
+ const tRoot = root[tId];
357
+ const ts = {
358
+ source: tRoot,
359
+ target: sRoot,
360
+ separation: separation(tId, sId),
361
+ };
362
+ const tsEdge = blockGraph.edge(ts);
363
+ if (tsEdge) {
364
+ ts.separation = Math.max(ts.separation, tsEdge.separation);
365
+ }
366
+ blockGraph.setEdge(ts, ts);
367
+ }
368
+ tId = sId;
369
+ }
370
+ }
371
+
372
+ return blockGraph;
373
+
374
+ /**
375
+ * calculate the horizontal separation between two nodes in a graph.
376
+ * Horizontal separation is half of the width of each node, plus nodeSep,
377
+ * plus edgeSep for any virtual nodes created to break up long edges.
378
+ */
379
+ function separation(sid: NodeId, tid: NodeId): number {
380
+ const sNode = graph.node(sid);
381
+ const tNode = graph.node(tid);
382
+
383
+ // Occasionally, there are virtual nodes in the layers, that are used to
384
+ // build node-connecting lines, but don't take any space (0 width &
385
+ // height).
386
+ const virtualNodePlaceholder = {
387
+ width: 0,
388
+ virtual: true,
389
+ geometry: RNGon.point(),
390
+ };
391
+
392
+ const sLayout = sNode ? util.getNodeProps(sNode) : virtualNodePlaceholder;
393
+ const tLayout = tNode ? util.getNodeProps(tNode) : virtualNodePlaceholder;
394
+
395
+ let sum = 0;
396
+ sum += sLayout.geometry.width;
397
+ sum += sLayout.virtual ? edgeSep : nodeSep;
398
+ sum += tLayout.virtual ? edgeSep : nodeSep;
399
+ sum += tLayout.geometry.width;
400
+
401
+ return sum / 2;
402
+ }
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Arrange the coordinates of each of the layout alignment bounds such that
408
+ * left-biased alignments have their minimum coordinate at the same point as
409
+ * the minimum coordinate of the smallest width alignment and right-biased
410
+ * alignments have their maximum coordinate at the same point as the maximum
411
+ * coordinate of the smallest width alignment.
412
+ */
413
+ function alignCoordinates(xss: CoordinateBounds, alignTo: Coordinates): void {
414
+ const alignToVals = Object.values(alignTo);
415
+ const alignToMin = Math.min(...alignToVals);
416
+ const alignToMax = Math.max(...alignToVals);
417
+
418
+ function updateCoords(xs: Coordinates, left: boolean) {
419
+ if (xs === alignTo) return;
420
+ const vals = Object.values(xs) || {};
421
+ const delta = left ? alignToMin - Math.min(...vals) : alignToMax - Math.max(...vals);
422
+ for (const id of Object.keys(xs)) {
423
+ xs[id] += delta;
424
+ }
425
+ }
426
+
427
+ updateCoords(xss.ur, false);
428
+ updateCoords(xss.ul, true);
429
+ updateCoords(xss.dr, false);
430
+ updateCoords(xss.dl, true);
431
+ }
432
+
433
+ function getAlignment(bounds: CoordinateBounds, v: "u" | "d", h: "l" | "r") {
434
+ if (v === "u" && h === "l") return bounds.ul;
435
+ if (v === "u" && h === "r") return bounds.ur;
436
+ if (v === "d" && h === "l") return bounds.dl;
437
+ if (v === "d" && h === "r") return bounds.dr;
438
+ throw new Error(`Invalid alignment: ${v}${h}`);
439
+ }
440
+
441
+ /**
442
+ * Given a set of bounds, find the one with the smallest layout width.
443
+ */
444
+ function findSmallestWidthAlignment(g: FullGraph, bounds: CoordinateBounds): Coordinates {
445
+ let best = Number.POSITIVE_INFINITY;
446
+ let bestCoords: Coordinates | undefined;
447
+
448
+ for (const coords of Object.values(bounds)) {
449
+ let max = Number.NEGATIVE_INFINITY;
450
+ let min = Number.POSITIVE_INFINITY;
451
+
452
+ const entries: Array<[NodeId, number]> = Object.entries(coords);
453
+ for (const [id, x] of entries) {
454
+ const node = g.node(id);
455
+ if (!node) continue;
456
+
457
+ const halfWidth = util.getNodeProps(node).geometry.width / 2;
458
+
459
+ max = Math.max(x + halfWidth, max);
460
+ min = Math.min(x - halfWidth, min);
461
+ }
462
+
463
+ const nextBest = max - min;
464
+ if (nextBest < best) {
465
+ best = nextBest;
466
+ bestCoords = coords;
467
+ }
468
+ }
469
+
470
+ return assertExists(bestCoords, "No best coordinates possible");
471
+ }
472
+
473
+ /**
474
+ * Layouts are created in 4 diagonal directions. Each biases sources to the
475
+ * corresponding corners. To even this out, a final balancing placed each node
476
+ * at the median of its 4 computed positions.
477
+ */
478
+ function balance(xss: CoordinateBounds, align?: ["u" | "d", "l" | "r"]): Coordinates {
479
+ const balanced: Coordinates = {};
480
+ let minX = Number.MAX_VALUE;
481
+ for (const id of Object.keys(xss.ul)) {
482
+ if (align) {
483
+ balanced[id] = getAlignment(xss, align[0], align[1])[id];
484
+ minX = Math.min(minX, balanced[id]);
485
+ } else {
486
+ const xs = Object.values(xss)
487
+ .map((c) => c[id])
488
+ .sort((a, b) => a - b) as [number, number, number, number];
489
+ // Get the median value of the 4 entries
490
+ balanced[id] = (xs[1] + xs[2]) / 2;
491
+ minX = Math.min(minX, balanced[id]);
492
+ }
493
+ }
494
+
495
+ // Normalize all X values, so that minimum value is set to 0. Prevents the
496
+ // graph from shifting around during positioning, by fixing the left side
497
+ for (const id of Object.keys(balanced)) {
498
+ balanced[id] -= minX;
499
+ }
500
+ return balanced;
501
+ }
502
+
503
+ export const TEST_ONLY = {
504
+ addConflict,
505
+ alignCoordinates,
506
+ balance,
507
+ findSmallestWidthAlignment,
508
+ findType1Conflicts,
509
+ hasConflict,
510
+ horizontalCompaction,
511
+ positionX,
512
+ positionY,
513
+ verticalAlignment,
514
+ };