@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,61 @@
1
+ import { checkExhaustive } from "./assert.ts";
2
+
3
+ import { CircleGeometry, type CircleParams } from "./geometry/circle.ts";
4
+ import {
5
+ RectangleGeometry,
6
+ type RectangleParams,
7
+ SquareGeometry,
8
+ type SquareParams,
9
+ } from "./geometry/rectangle.ts";
10
+ import { RNGon, type RNGonParams } from "./geometry/rngon.ts";
11
+ import type { Geometry } from "./geometry/types.ts";
12
+
13
+ export { CircleGeometry } from "./geometry/circle.ts";
14
+ export { origin } from "./geometry/geometry.ts";
15
+ export { FULL_BOX, straightLinePath } from "./geometry/path.ts";
16
+ export { RectangleGeometry, SquareGeometry } from "./geometry/rectangle.ts";
17
+ export { RNGon } from "./geometry/rngon.ts";
18
+
19
+ const GeometryType = {
20
+ RECTANGLE: "rectangle",
21
+ SQUARE: "square",
22
+ CIRCLE: "circle",
23
+ RNGON: "rngon",
24
+ } as const;
25
+
26
+ interface ParseCircleParams extends CircleParams {
27
+ type: typeof GeometryType.CIRCLE;
28
+ }
29
+ interface ParseRectangleParams extends RectangleParams {
30
+ type: typeof GeometryType.RECTANGLE;
31
+ }
32
+ interface ParseRNGonParams extends RNGonParams {
33
+ type: typeof GeometryType.RNGON;
34
+ }
35
+ interface ParseSquareParams extends SquareParams {
36
+ type: typeof GeometryType.SQUARE;
37
+ }
38
+
39
+ /** JSON parameters to parse a geometry */
40
+ export type GeometryParams =
41
+ | ParseCircleParams
42
+ | ParseRectangleParams
43
+ | ParseRNGonParams
44
+ | ParseSquareParams;
45
+
46
+ /** Parse an object describing a geometry into the full object instance. */
47
+ export function parseGeometry(params: GeometryParams): Geometry {
48
+ const { type } = params;
49
+ switch (type) {
50
+ case GeometryType.CIRCLE:
51
+ return new CircleGeometry(params);
52
+ case GeometryType.SQUARE:
53
+ return new SquareGeometry(params);
54
+ case GeometryType.RECTANGLE:
55
+ return new RectangleGeometry(params);
56
+ case GeometryType.RNGON:
57
+ return new RNGon(params);
58
+ default:
59
+ return checkExhaustive(type);
60
+ }
61
+ }
@@ -0,0 +1,85 @@
1
+ import { segmentId } from "../tsabre/graph.ts";
2
+ import type { Edge, FullGraph, NodeId, Segment } from "../types.ts";
3
+
4
+ /**
5
+ * Class to track which nodes and edges should be considered highlighted.
6
+ */
7
+ export abstract class HighlightManager {
8
+ protected readonly highlighted = new Set<string>();
9
+
10
+ protected graph?: FullGraph;
11
+ constructor(graph?: FullGraph) {
12
+ this.graph = graph;
13
+ }
14
+
15
+ setGraph(graph: FullGraph): void {
16
+ this.clear();
17
+ this.graph = graph;
18
+ }
19
+
20
+ clear(): void {
21
+ this.highlighted.clear();
22
+ }
23
+
24
+ isNodeHighlighted(id: NodeId): boolean {
25
+ return this.highlighted.has(id);
26
+ }
27
+
28
+ isSegmentHighlighted(segment: Segment): boolean {
29
+ return this.highlighted.has(segmentId(segment));
30
+ }
31
+
32
+ abstract markNode(id?: NodeId): void;
33
+ abstract unmarkNode(id?: NodeId): void;
34
+
35
+ abstract markSegment(segment?: Segment<Edge>): void;
36
+ abstract unmarkSegment(segment?: Segment<Edge>): void;
37
+ }
38
+
39
+ /**
40
+ * A highlight manager which tracks a single highlighted node and its one-hop
41
+ * neighborhood at a time. Marking a second node will clear the first, and only
42
+ * add the second node's neighborhood.
43
+ */
44
+ export class SingleHighlightManager extends HighlightManager {
45
+ markNode(id?: NodeId): void {
46
+ this.clear();
47
+
48
+ if (!id) return;
49
+ if (!this.graph) return;
50
+
51
+ this.highlighted.add(id);
52
+ for (const segment of this.graph.nodeSegments(id)) {
53
+ for (const edge of segment.edges) {
54
+ for (const s of this.graph.edgeSegments(edge)) {
55
+ this.highlighted.add(s.source);
56
+ this.highlighted.add(s.target);
57
+ this.highlighted.add(segmentId(s));
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ markSegment(segment?: Segment<Edge>): void {
64
+ this.clear();
65
+
66
+ if (!segment) return;
67
+ if (!this.graph) return;
68
+
69
+ for (const edge of segment.edges) {
70
+ for (const s of this.graph.edgeSegments(edge)) {
71
+ this.highlighted.add(s.source);
72
+ this.highlighted.add(s.target);
73
+ this.highlighted.add(segmentId(s));
74
+ }
75
+ }
76
+ }
77
+
78
+ override unmarkNode(id?: NodeId): void {
79
+ this.clear();
80
+ }
81
+
82
+ override unmarkSegment(s?: Segment): void {
83
+ this.clear();
84
+ }
85
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * as assert from "./assert.ts";
2
+ export * as geometry from "./geometry.ts";
3
+ export * as layout from "./layout.ts";
4
+ export * as tsabre from "./tsabre.ts";
5
+ export * as types from "./types.ts";
@@ -0,0 +1,55 @@
1
+ import type { Geometry } from "../../geometry/types.ts";
2
+ import type { NodeId } from "../../types.ts";
3
+ import { layout as hullLayout } from "../hulls/hulls.ts";
4
+ import { assignSegmentPoints } from "../hulls/segments.ts";
5
+ import { AbstractLayoutManager } from "../manager/manager.ts";
6
+ import type { LayoutNode } from "../types.ts";
7
+ import { getNodeProps } from "../util.ts";
8
+
9
+ const SEPARATION = 50;
10
+ const RADIUS = 10;
11
+
12
+ /**
13
+ * A linear layout manager, for ease of testing. Puts nodes into a square, as
14
+ * best it is able.
15
+ */
16
+ export class GridLayoutManager extends AbstractLayoutManager {
17
+ /**
18
+ * Rerun the layout.
19
+ * @param triggeredNodeId - See manager/manager.ts::run(). ID of the
20
+ * expanded/collapsed node. Each such event triggers a partial layout
21
+ * recalculation. If no event fired and/or we need the complete layout
22
+ * recalculation, no triggeredNodeId is passed.
23
+ */
24
+ protected doLayout(triggeredNodeId?: NodeId): void {
25
+ const nodes = this.graph.children();
26
+ const nodesPerRow = Math.ceil(Math.sqrt(nodes.size));
27
+ let col = 0;
28
+ let row = 0;
29
+
30
+ hullLayout(this.graph, triggeredNodeId);
31
+
32
+ for (const id of nodes) {
33
+ if (col >= nodesPerRow) {
34
+ col = 0;
35
+ row += 1;
36
+ }
37
+
38
+ const node = this.getNode(id);
39
+ const { geometry } = getNodeProps(node);
40
+ geometry.moveTo({ x: col * SEPARATION, y: row * SEPARATION });
41
+ geometry.resize(node.expanded ? geometry.height / 2 : RADIUS);
42
+
43
+ col += 1;
44
+ }
45
+
46
+ assignSegmentPoints(this.graph);
47
+ }
48
+
49
+ protected override assignNode(node: LayoutNode, position: Geometry): void {
50
+ const { geometry } = getNodeProps(node);
51
+ geometry.moveTo(position);
52
+
53
+ super.assignNode(node, position);
54
+ }
55
+ }
@@ -0,0 +1 @@
1
+ export * as grid from "./grid/manager.ts";
@@ -0,0 +1,21 @@
1
+ # Graph Layout
2
+
3
+ Given a graph G, assign <x, y> to each node to result in an efficient and
4
+ aesthetically pleasing layout.
5
+
6
+ "Aesthetically Pleasing" is not a subjective concept, but rather a collection of
7
+ mathematical principles driven in the goal of an accurate layout which serves to
8
+ bring forth information present in the graph proper. From Gansner, these
9
+ attributes to maintain are:
10
+
11
+ - A1) Expose hierarchical structure in the graph.
12
+ - A2) Avoid visual anomolies which do not convey information.
13
+ - A3) Keep edges short.
14
+ - A4) Favor symmetry and balance.
15
+
16
+ This happens in 3 phases - ranking (vertical positioning), ordering (horizontal
17
+ positioning), and positioning (converting rank and order to a compact <x, y>).
18
+ See layout.ts for the high-level orchestration, and links to other files in
19
+ order for implementation & notes.
20
+
21
+ See docs/tsabre for full details and references.
@@ -0,0 +1,249 @@
1
+ import { assertExists } from "../../assert.ts";
2
+ import { EDGE_KEY_DELIM, edgeId, TsabreGraph } from "../../tsabre/graph.ts";
3
+ import type { Edge, FullGraph, Node, SmartGraph } from "../../types.ts";
4
+ import { getEdgeProps, getGraphProps } from "../util.ts";
5
+
6
+ import { debugFas } from "./debug.ts";
7
+
8
+ /**
9
+ * Given an arbitrary graph find a small set of edges such that removing them
10
+ * results in an acyclic graph, and then flip those edges. Leave the edges
11
+ * marked so that it can be undone in the future.
12
+ */
13
+ export function decycle(g: FullGraph): void {
14
+ const graphProps = getGraphProps(g);
15
+ // Cache the FAS. The cached FAS will be used on further iterations of layout
16
+ // calculation, as a part of simplified pipeline.
17
+ // greedyFas is a good (in size of sets), polynomial time algorithm to find
18
+ // the set of edges which cause cycles.
19
+ graphProps.fas = graphProps.fas === undefined ? greedyFas(g) : graphProps.fas;
20
+
21
+ for (const e of graphProps.fas) {
22
+ const edge = g.edge(e)!;
23
+ const layout = getEdgeProps(edge);
24
+ layout.reversed = true;
25
+ layout.original = edge;
26
+ g.removeEdge(e);
27
+ // An almost certainly unique name.
28
+ const name = `(reversed${EDGE_KEY_DELIM}${edgeId(edge)})`;
29
+ const reversed = { source: e.target, target: e.source, name, layout };
30
+ g.setEdge(reversed, reversed);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Given a DAG with edges possibly flipped by decycle, restore the flipped
36
+ * edges.
37
+ */
38
+ export function recycle(g: FullGraph): void {
39
+ for (const e of g.edges()) {
40
+ const layout = getEdgeProps(e);
41
+ if (layout.reversed) {
42
+ const original = assertExists(
43
+ layout.original,
44
+ `Reversing edge ${edgeId(e)} with no original edge`,
45
+ );
46
+ g.removeEdge(e);
47
+ layout.reversed = false;
48
+ delete layout.original;
49
+ g.setEdge(original, original);
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Heuristic Feedback Arc Set implementation, returning a minimal list of
56
+ * cycle-causing edges. This will be used as the set of edges to reverse to
57
+ * remove cycles in the graph.
58
+ *
59
+ * See Eades et al, "Effective Heuristic for the Feedback Arc Set Problem".
60
+ *
61
+ * All nodes will have some number of "out" edges (sources) and some number of
62
+ * "in" edges (sinks). The heuristic greedily removes source, sink, and nodes
63
+ * which have the highest D(n), where D(n) = (number of out edges - number of in
64
+ * edges). In practice, this means keeping a mapping of D(n) to lists of nodes
65
+ * with that size. (For convenience in iteration, this is an array normalized so
66
+ * that 0 = no inedges (aka all sources). Iteration then proceeds by "removing"
67
+ * the node with max(D(n)), updating nodes in its neighborhood with their new
68
+ * inedge & outedge count.
69
+ *
70
+ * This algorithm works because when removing max(D(n)) nodes, it's likely that
71
+ * new source and sink nodes are revealed. Additionally, other nodes within the
72
+ * current max set will likely get moved out of that set as their D(n) counts
73
+ * change. See the paper for proof, etc.
74
+ *
75
+ * During this iteration, the final FAS is the set of all edges removed when
76
+ * removing max(D(n)) nodes.
77
+ */
78
+ function greedyFas(g: FullGraph): Edge[] {
79
+ const { printDebug } = getGraphProps(g);
80
+
81
+ interface FasNode extends Node {
82
+ in: number;
83
+ out: number;
84
+ }
85
+ interface FasEdge extends Edge {
86
+ weight: number;
87
+ }
88
+
89
+ type FasGraph = SmartGraph<FasNode, FasEdge>;
90
+ interface FasState {
91
+ graph: FasGraph;
92
+ // A list of FasNodes, sorted by Max(outEdges - inEdges)), with [len-1]
93
+ // being sources & sinks.
94
+ buckets: FasNode[][];
95
+ }
96
+
97
+ if (g.nodeCount <= 1) return [];
98
+ const state = buildState();
99
+
100
+ if (printDebug) {
101
+ console.group("FAS");
102
+ debugFas(state);
103
+ }
104
+
105
+ const results = doGreedyFas(state);
106
+
107
+ if (printDebug) {
108
+ console.groupEnd();
109
+ }
110
+
111
+ return results;
112
+
113
+ /** Build the initial list of FAS buckets, ordered by (outedges - inedges). */
114
+ function buildState(): FasState {
115
+ const fasGraph = new TsabreGraph<FasNode, FasEdge>();
116
+ let maxIn = 0;
117
+ let maxOut = 0;
118
+
119
+ for (const id of g.nodes) {
120
+ fasGraph.setNode(id, { id, in: 0, out: 0 });
121
+ }
122
+
123
+ for (const edge of g.edges()) {
124
+ const { weight } = getEdgeProps(g.edge(edge)!);
125
+ fasGraph.setEdge(edge, { ...edge, weight });
126
+ maxOut = Math.max(maxOut, (fasGraph.node(edge.source)!.out += weight));
127
+ maxIn = Math.max(maxIn, (fasGraph.node(edge.target)!.in += weight));
128
+ }
129
+
130
+ const buckets: FasNode[][] = [];
131
+ for (let i = 0; i < Math.max(maxOut, maxIn) + 2; i++) {
132
+ buckets[i] = [];
133
+ }
134
+
135
+ for (const id of fasGraph.nodes) {
136
+ const node = fasGraph.node(id)!;
137
+ assignBucket(buckets, node);
138
+ }
139
+
140
+ return { graph: fasGraph, buckets };
141
+ }
142
+
143
+ /**
144
+ * Greedily work throuth the buckets, removing sources, sinks, and max nodes.
145
+ */
146
+ function doGreedyFas({ graph: g, buckets }: FasState): Edge[] {
147
+ const results: Edge[] = [];
148
+
149
+ const ends = buckets[buckets.length - 1];
150
+
151
+ let i = 0;
152
+ while (g.nodeCount > 0) {
153
+ while (ends.length > 0) {
154
+ removeNode(g, buckets, ends.shift()!, false);
155
+ }
156
+ if (g.nodeCount) {
157
+ for (let i = buckets.length - 2; i >= 0; --i) {
158
+ if (buckets[i].length > 0) {
159
+ const bucket = buckets[i].shift()!;
160
+ const reverse = removeNode(g, buckets, bucket, true);
161
+ results.push(...reverse);
162
+ break; // Conservatively remove one node at a time; jump back to
163
+ // removeing sources and sinks.
164
+ }
165
+ }
166
+ }
167
+ if (printDebug) {
168
+ console.group(`FAS iteration ${i++}`);
169
+ debugFas({ graph: g, buckets }, results);
170
+ console.groupEnd();
171
+ }
172
+ }
173
+
174
+ return results;
175
+ }
176
+
177
+ function removeNode(g: FasGraph, buckets: FasNode[][], entry: FasNode, collect: boolean): Edge[] {
178
+ const results: FasEdge[] = [];
179
+
180
+ const inEdges = g.inEdges(entry.id);
181
+ const outEdges = g.outEdges(entry.id);
182
+
183
+ const collectIn = collect && entry.in <= entry.out;
184
+ const collectOut = collect && !collectIn;
185
+
186
+ for (const e of inEdges) {
187
+ if (e.source === entry.id) continue;
188
+ const edge = g.edge(e)!;
189
+ const { weight } = edge;
190
+ const source = g.node(edge.source)!;
191
+
192
+ if (collectIn) {
193
+ results.push({
194
+ source: e.source,
195
+ target: e.target,
196
+ name: e.name,
197
+ weight,
198
+ });
199
+ }
200
+
201
+ removeBucket(buckets, source);
202
+ source.out -= weight;
203
+ assignBucket(buckets, source);
204
+ }
205
+
206
+ for (const e of outEdges) {
207
+ if (e.target === entry.id) continue;
208
+ const edge = g.edge(e)!;
209
+ const { weight } = edge;
210
+ const target = g.node(edge.target)!;
211
+
212
+ if (collectOut) {
213
+ results.push({
214
+ source: e.source,
215
+ target: e.target,
216
+ name: e.name,
217
+ weight,
218
+ });
219
+ }
220
+
221
+ removeBucket(buckets, target);
222
+ target.in -= weight;
223
+ assignBucket(buckets, target);
224
+ }
225
+
226
+ g.removeNode(entry.id);
227
+ return results;
228
+ }
229
+
230
+ function assignBucket(buckets: FasNode[][], node: FasNode) {
231
+ const i = idx(node, buckets.length);
232
+ buckets[i].push(node);
233
+ }
234
+
235
+ function removeBucket(buckets: FasNode[][], node: FasNode) {
236
+ const i = idx(node, buckets.length);
237
+ const idn = buckets[i].indexOf(node) || buckets[i].length;
238
+ buckets[i].splice(idn, 1);
239
+ }
240
+
241
+ function idx({ in: en, out }: FasNode, end: number): number {
242
+ return out === 0 || en === 0 ? end - 1 : Math.abs(out - en);
243
+ }
244
+ }
245
+
246
+ /** Functions for testing. */
247
+ export const TEST_ONLY = {
248
+ greedyFas,
249
+ };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @fileoverview When running layouts, children of nodes are handled using Hulls
3
+ * & Segments, as opposed to subgraphs. To not include children during the
4
+ * layout, these helpers will keep track of all the children its found, their
5
+ * edges, and their parent associations, removing them from the graph safely
6
+ * until they can be added back after layout of top-level nodes is complete.
7
+ */
8
+
9
+ import { assertExists } from "../../assert.ts";
10
+ import {
11
+ getPositionOnCircle,
12
+ getSmallestEnclosingCircle,
13
+ smallestIntersectingRadius,
14
+ } from "../../geometry/circle.ts";
15
+ import type { Circle, Edge, Node, NodeId, PointTuple } from "../../types.ts";
16
+ import type { LayoutGraph } from "../types.ts";
17
+ import { getGraphProps, getNodeProps } from "../util.ts";
18
+
19
+ /**
20
+ * A childrenHandler returns a pair of functions bound to a known set of
21
+ * children.
22
+ */
23
+ export function handler(): {
24
+ remove: (input: LayoutGraph) => LayoutGraph;
25
+ replace: (input: LayoutGraph) => LayoutGraph;
26
+ } {
27
+ const children: Array<[NodeId, Node]> = [];
28
+ const childEdges = new Map<Edge, Edge>();
29
+ return {
30
+ /**
31
+ * Stash child nodes, their parent links, and their edges.
32
+ */
33
+ remove(g: LayoutGraph) {
34
+ const visited = new Set<NodeId>();
35
+ function dfs(n: NodeId) {
36
+ if (visited.has(n)) return;
37
+ visited.add(n);
38
+ for (const child of g.children(n)) {
39
+ dfs(child);
40
+ }
41
+ const pid = g.parent(n);
42
+ if (g.hasNode(pid)) {
43
+ children.push([pid, g.node(n)!] as [NodeId, Node]);
44
+ for (const edge of g.edges(n)) {
45
+ childEdges.set(edge, g.edge(edge)!);
46
+ }
47
+ g.removeNode(n);
48
+ }
49
+ }
50
+ for (const id of g.nodes) {
51
+ dfs(id);
52
+ }
53
+ return g;
54
+ },
55
+
56
+ /**
57
+ * Put the stashed children back in the graph.
58
+ */
59
+ replace(g: LayoutGraph) {
60
+ while (children.length > 0) {
61
+ // Nodes were pushed from each tree bottom to top, so popping them
62
+ // works as a stack.
63
+ const [pid, node] = children.pop() as [NodeId, Node];
64
+ g.setNode(node.id, node);
65
+ g.setParent(node.id, pid);
66
+ }
67
+ for (const [edge, value] of childEdges.entries()) {
68
+ g.setEdge(edge, value);
69
+ }
70
+
71
+ // Update position offsets based on one root node being moved
72
+ function positionDFS(id: NodeId, translate: PointTuple) {
73
+ const node = assertExists(g.node(id));
74
+ const { geometry } = getNodeProps(node);
75
+ geometry.move({ x: translate[0] | 0, y: translate[1] | 0 });
76
+ if (!node.expanded) return;
77
+ for (const cid of g.children(id)) {
78
+ positionDFS(cid, translate);
79
+ }
80
+ }
81
+
82
+ // Going through each top-level node, recenter all its children by the
83
+ // top-most hull's <x, y>.
84
+ for (const id of g.children()) {
85
+ const {
86
+ geometry: { x, y },
87
+ fx,
88
+ fy,
89
+ } = getNodeProps(g.node(id)!);
90
+ for (const cid of g.children(id)) {
91
+ positionDFS(cid, [fx ?? x, fy ?? y]);
92
+ }
93
+ }
94
+
95
+ return g;
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Layout all nodes which are children, inside their parent.
102
+ */
103
+ export function layout(g: LayoutGraph): void {
104
+ function translateDFS(id: NodeId, translate: PointTuple) {
105
+ const node = assertExists(g.node(id), `children::layout::translateDFS: ${id} missing`);
106
+ getNodeProps(node).geometry.move({
107
+ x: translate[0] | 0,
108
+ y: translate[1] | 0,
109
+ });
110
+ if (!node.expanded) return;
111
+ for (const cid of g.children(id)) {
112
+ translateDFS(cid, translate);
113
+ }
114
+ }
115
+
116
+ function layoutDFS(id: NodeId): Circle & { id: NodeId } {
117
+ const node = assertExists(g.node(id), `children::layout::layoutDFS: ${id} missing`);
118
+ const { expanded } = node;
119
+ const { geometry } = getNodeProps(node);
120
+ const { baseRadius, hullRotation } = getGraphProps(g);
121
+ geometry.moveTo({ x: 0, y: 0 });
122
+
123
+ const children = g.children(id);
124
+ const childrenCount = children.size;
125
+
126
+ if (childrenCount === 0 || !expanded) {
127
+ // No children to lay out
128
+ return { id, x: 0, y: 0, r: geometry.height / 2 };
129
+ }
130
+
131
+ // Recursively layout all children first
132
+ const circles: Array<Circle & { id: NodeId }> = [];
133
+ for (const cid of children) {
134
+ circles.push(layoutDFS(cid));
135
+ }
136
+
137
+ // Arrange all children around the hull
138
+ const boundsRadius = smallestIntersectingRadius(
139
+ circles.map((a) => a.r),
140
+ 2 * baseRadius,
141
+ );
142
+ let i = 0;
143
+ for (const circle of circles.sort((a, b) => a.id.localeCompare(b.id))) {
144
+ // Lay children out circularly, starting with a slight rotational offset
145
+ const position = getPositionOnCircle(boundsRadius, childrenCount, i++, -hullRotation);
146
+
147
+ circle.x = position.x;
148
+ circle.y = position.y;
149
+ }
150
+
151
+ // Find the smallest bounding circle
152
+ const bounds = { ...getSmallestEnclosingCircle(circles, baseRadius), id };
153
+
154
+ // Readjust children to center of smallest circle
155
+ for (const circle of circles) {
156
+ circle.x -= bounds.x;
157
+ circle.y -= bounds.y;
158
+ translateDFS(circle.id, [circle.x, circle.y]);
159
+ }
160
+
161
+ // Adjust local size
162
+ geometry.resize(bounds.r | 0);
163
+ return bounds;
164
+ }
165
+
166
+ for (const id of g.children()) {
167
+ layoutDFS(id);
168
+ }
169
+ }