Skip to content

Commit 8356eda

Browse files
authored
Merge pull request #22 from nulab/dev-21/disconnected-graph
Support disconnected graphs (fixes #21)
2 parents f8dda04 + 4b1c849 commit 8356eda

File tree

3 files changed

+169
-29
lines changed

3 files changed

+169
-29
lines changed

autolayout.go

+59-29
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/nulab/autog/graph"
77
ig "github.com/nulab/autog/internal/graph"
8+
"github.com/nulab/autog/internal/graph/connected"
89
imonitor "github.com/nulab/autog/internal/monitor"
910
"github.com/nulab/autog/internal/processor"
1011
)
@@ -20,7 +21,7 @@ func Layout(source graph.Source, opts ...Option) graph.Layout {
2021
imonitor.Set(layoutOpts.monitor)
2122
defer imonitor.Reset()
2223

23-
pipeline := [...]processor.P{
24+
pipeline := []processor.P{
2425
layoutOpts.p1, // cycle breaking
2526
layoutOpts.p2, // layering
2627
layoutOpts.p3, // ordering
@@ -29,46 +30,75 @@ func Layout(source graph.Source, opts ...Option) graph.Layout {
2930
}
3031

3132
// populate the graph struct from the graph source
32-
g := from(source)
33+
G := from(source)
3334

3435
if layoutOpts.params.NodeFixedSizeFunc != nil {
35-
for _, n := range g.Nodes {
36+
for _, n := range G.Nodes {
3637
layoutOpts.params.NodeFixedSizeFunc(n)
3738
}
3839
}
3940

40-
// run it through the pipeline
41-
for _, phase := range pipeline {
42-
phase.Process(g, layoutOpts.params)
43-
}
44-
4541
// return only relevant data to the caller
46-
out := graph.Layout{
47-
Nodes: make([]graph.Node, 0, len(g.Nodes)),
48-
Edges: make([]graph.Edge, 0, len(g.Edges)),
49-
}
50-
for _, n := range g.Nodes {
51-
if n.IsVirtual && !layoutOpts.output.keepVirtualNodes {
52-
continue
42+
out := graph.Layout{}
43+
44+
// shift disconnected sub-graphs to the right
45+
shift := 0.0
46+
47+
// process each connected components and collect results into the same layout output
48+
for _, g := range connected.Components(G) {
49+
out.Nodes = slices.Grow(out.Nodes, len(g.Nodes))
50+
out.Edges = slices.Grow(out.Edges, len(g.Edges))
51+
52+
// run subgraph through the pipeline
53+
for _, phase := range pipeline {
54+
phase.Process(g, layoutOpts.params)
55+
}
56+
57+
// collect nodes
58+
for _, n := range g.Nodes {
59+
if n.IsVirtual && !layoutOpts.output.keepVirtualNodes {
60+
continue
61+
}
62+
63+
m := graph.Node{
64+
ID: n.ID,
65+
Size: n.Size,
66+
}
67+
// apply subgraph's left shift
68+
m.X += shift
69+
70+
out.Nodes = append(out.Nodes, m)
71+
// todo: clients can't reliably tell virtual nodes from concrete nodes
72+
}
73+
74+
// collect edges
75+
for _, e := range g.Edges {
76+
f := graph.Edge{
77+
FromID: e.From.ID,
78+
ToID: e.To.ID,
79+
Points: slices.Clone(e.Points),
80+
ArrowHeadStart: e.ArrowHeadStart,
81+
}
82+
// apply subgraph's left shift
83+
for i := range f.Points {
84+
f.Points[i][0] += shift
85+
}
86+
87+
out.Edges = append(out.Edges, f)
5388
}
54-
out.Nodes = append(out.Nodes, graph.Node{
55-
ID: n.ID,
56-
Size: n.Size,
57-
})
58-
// todo: clients can't reliably tell virtual nodes from concrete nodes
89+
90+
// compute shift for subsequent subgraphs
91+
rightmostX := 0.0
92+
for _, l := range g.Layers {
93+
n := l.Nodes[len(l.Nodes)-1]
94+
rightmostX = max(rightmostX, n.X+n.W)
95+
}
96+
shift += rightmostX + layoutOpts.params.NodeSpacing
5997
}
98+
6099
if !layoutOpts.output.keepVirtualNodes {
61100
out.Nodes = slices.Clip(out.Nodes)
62101
}
63-
64-
for _, e := range g.Edges {
65-
out.Edges = append(out.Edges, graph.Edge{
66-
FromID: e.From.ID,
67-
ToID: e.To.ID,
68-
Points: e.Points,
69-
ArrowHeadStart: e.ArrowHeadStart,
70-
})
71-
}
72102
return out
73103
}
74104

internal/graph/connected/connected.go

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package connected
2+
3+
import (
4+
"maps"
5+
6+
ig "github.com/nulab/autog/internal/graph"
7+
)
8+
9+
func Components(g *ig.DGraph) []*ig.DGraph {
10+
visitedN := make(ig.NodeSet)
11+
visitedE := make(ig.EdgeSet)
12+
walkDfs(g.Nodes[0], visitedN, visitedE)
13+
14+
// if all nodes were visited at the first dfs
15+
// then there is only one connected component and that is G itself
16+
if len(visitedN) == len(g.Nodes) {
17+
return []*ig.DGraph{g}
18+
}
19+
20+
cnncmp := make([]*ig.DGraph, 0, 2) // this has at least 2 connected components
21+
cnncmp = append(cnncmp, &ig.DGraph{Nodes: visitedN.Keys(), Edges: visitedE.Keys()})
22+
23+
for _, n := range g.Nodes {
24+
if !visitedN[n] {
25+
ns := make(ig.NodeSet)
26+
es := make(ig.EdgeSet)
27+
walkDfs(n, ns, es)
28+
cnncmp = append(cnncmp, &ig.DGraph{Nodes: ns.Keys(), Edges: es.Keys()})
29+
maps.Copy(visitedN, ns)
30+
}
31+
}
32+
return cnncmp
33+
}
34+
35+
func walkDfs(n *ig.Node, visitedN ig.NodeSet, visitedE ig.EdgeSet) {
36+
visitedN[n] = true
37+
n.VisitEdges(func(e *ig.Edge) {
38+
if !visitedE[e] {
39+
visitedE[e] = true
40+
walkDfs(e.ConnectedNode(n), visitedN, visitedE)
41+
}
42+
})
43+
}
+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package connected
2+
3+
import (
4+
"testing"
5+
6+
"github.com/nulab/autog/graph"
7+
ig "github.com/nulab/autog/internal/graph"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestComponents(t *testing.T) {
12+
t.Run("one component", func(t *testing.T) {
13+
es := [][]string{
14+
{"a", "b"},
15+
{"b", "c"},
16+
}
17+
g := &ig.DGraph{}
18+
graph.EdgeSlice(es).Populate(g)
19+
20+
comp := Components(g)
21+
assert.Len(t, comp, 1)
22+
assert.True(t, comp[0] == g)
23+
})
24+
25+
t.Run("multiple components", func(t *testing.T) {
26+
es := [][]string{
27+
{"a", "b"},
28+
{"b", "c"},
29+
{"f", "g"},
30+
}
31+
g := &ig.DGraph{}
32+
graph.EdgeSlice(es).Populate(g)
33+
34+
comp := Components(g)
35+
assert.Len(t, comp, 2)
36+
assert.ElementsMatch(t, []string{"a", "b", "c"}, ids(comp[0].Nodes))
37+
assert.ElementsMatch(t, []string{"f", "g"}, ids(comp[1].Nodes))
38+
})
39+
40+
t.Run("self-loop", func(t *testing.T) {
41+
es := [][]string{
42+
{"a", "b"}, {"b", "c"},
43+
{"f", "g"}, {"g", "h"}, {"h", "i"},
44+
{"u", "u"},
45+
{"j", "k"},
46+
{"l", "j"},
47+
{"z", "f"},
48+
}
49+
g := &ig.DGraph{}
50+
graph.EdgeSlice(es).Populate(g)
51+
52+
comp := Components(g)
53+
assert.Len(t, comp, 4)
54+
assert.ElementsMatch(t, []string{"a", "b", "c"}, ids(comp[0].Nodes))
55+
assert.ElementsMatch(t, []string{"f", "g", "h", "i", "z"}, ids(comp[1].Nodes))
56+
assert.ElementsMatch(t, []string{"u"}, ids(comp[2].Nodes))
57+
assert.ElementsMatch(t, []string{"j", "k", "l"}, ids(comp[3].Nodes))
58+
})
59+
60+
}
61+
62+
func ids(ns []*ig.Node) (ids []string) {
63+
for _, n := range ns {
64+
ids = append(ids, n.ID)
65+
}
66+
return
67+
}

0 commit comments

Comments
 (0)