@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.
- package/package.json +19 -4
- package/src/assert.ts +67 -0
- package/src/geometry/box.ts +76 -0
- package/src/geometry/circle.ts +296 -0
- package/src/geometry/geometry.ts +318 -0
- package/src/geometry/path.ts +315 -0
- package/src/geometry/quadtree.ts +321 -0
- package/src/geometry/rectangle.ts +129 -0
- package/src/geometry/rngon.ts +132 -0
- package/src/geometry/types.ts +102 -0
- package/src/geometry.ts +61 -0
- package/src/highlight/highlight.ts +85 -0
- package/src/index.ts +5 -0
- package/src/layout/grid/manager.ts +55 -0
- package/src/layout/grid.ts +1 -0
- package/src/layout/hierarchic/README.md +21 -0
- package/src/layout/hierarchic/acyclic.ts +249 -0
- package/src/layout/hierarchic/children.ts +169 -0
- package/src/layout/hierarchic/debug.ts +186 -0
- package/src/layout/hierarchic/layout.ts +266 -0
- package/src/layout/hierarchic/manager.ts +65 -0
- package/src/layout/hierarchic/normalize.ts +177 -0
- package/src/layout/hierarchic/order.ts +321 -0
- package/src/layout/hierarchic/position.ts +514 -0
- package/src/layout/hierarchic/rank.ts +629 -0
- package/src/layout/hierarchic.ts +2 -0
- package/src/layout/hulls/arrange.ts +94 -0
- package/src/layout/hulls/hulls.ts +243 -0
- package/src/layout/hulls/segments.ts +174 -0
- package/src/layout/hulls.ts +3 -0
- package/src/layout/manager/child_manager.ts +42 -0
- package/src/layout/manager/drag_manager.ts +112 -0
- package/src/layout/manager/manager.ts +425 -0
- package/src/layout/manager/pan_manager.ts +146 -0
- package/src/layout/manager/visibility_manager.ts +84 -0
- package/src/layout/manager/zoom_manager.ts +263 -0
- package/src/layout/manager.ts +6 -0
- package/src/layout/properties/defaults.ts +99 -0
- package/src/layout/properties/symbol.ts +99 -0
- package/src/layout/properties/weak_map.ts +73 -0
- package/src/layout/properties.ts +111 -0
- package/src/layout/types.ts +470 -0
- package/src/layout/util.ts +444 -0
- package/src/layout.ts +6 -0
- package/src/samples/diff_graph.ts +15 -0
- package/src/samples/edge_cases.ts +168 -0
- package/src/samples/gansner_with_states.ts +67 -0
- package/src/samples/load_generator.ts +213 -0
- package/src/samples/search_graph.ts +35 -0
- package/src/tsabre/algo.ts +126 -0
- package/src/tsabre/builder.ts +97 -0
- package/src/tsabre/compress.ts +15 -0
- package/src/tsabre/dijkstra/dijkstra.ts +76 -0
- package/src/tsabre/dijkstra/heap.ts +83 -0
- package/src/tsabre/graph.ts +808 -0
- package/src/tsabre/parse.ts +634 -0
- package/src/tsabre/print.ts +188 -0
- package/src/tsabre/sourcebuilder/code_content_buffer.ts +275 -0
- package/src/tsabre/sourcebuilder/printable.ts +33 -0
- package/src/tsabre.ts +6 -0
- package/src/types.ts +424 -0
- package/README.md +0 -3
package/src/geometry.ts
ADDED
|
@@ -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,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
|
+
}
|