Skip to content

Commit

Permalink
Merge pull request #22 from nulab/dev-21/disconnected-graph
Browse files Browse the repository at this point in the history
Support disconnected graphs (fixes #21)
  • Loading branch information
vibridi authored Jul 10, 2024
2 parents f8dda04 + 4b1c849 commit 8356eda
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 29 deletions.
88 changes: 59 additions & 29 deletions autolayout.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/nulab/autog/graph"
ig "github.com/nulab/autog/internal/graph"
"github.com/nulab/autog/internal/graph/connected"
imonitor "github.com/nulab/autog/internal/monitor"
"github.com/nulab/autog/internal/processor"
)
Expand All @@ -20,7 +21,7 @@ func Layout(source graph.Source, opts ...Option) graph.Layout {
imonitor.Set(layoutOpts.monitor)
defer imonitor.Reset()

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

// populate the graph struct from the graph source
g := from(source)
G := from(source)

if layoutOpts.params.NodeFixedSizeFunc != nil {
for _, n := range g.Nodes {
for _, n := range G.Nodes {
layoutOpts.params.NodeFixedSizeFunc(n)
}
}

// run it through the pipeline
for _, phase := range pipeline {
phase.Process(g, layoutOpts.params)
}

// return only relevant data to the caller
out := graph.Layout{
Nodes: make([]graph.Node, 0, len(g.Nodes)),
Edges: make([]graph.Edge, 0, len(g.Edges)),
}
for _, n := range g.Nodes {
if n.IsVirtual && !layoutOpts.output.keepVirtualNodes {
continue
out := graph.Layout{}

// shift disconnected sub-graphs to the right
shift := 0.0

// process each connected components and collect results into the same layout output
for _, g := range connected.Components(G) {
out.Nodes = slices.Grow(out.Nodes, len(g.Nodes))
out.Edges = slices.Grow(out.Edges, len(g.Edges))

// run subgraph through the pipeline
for _, phase := range pipeline {
phase.Process(g, layoutOpts.params)
}

// collect nodes
for _, n := range g.Nodes {
if n.IsVirtual && !layoutOpts.output.keepVirtualNodes {
continue
}

m := graph.Node{
ID: n.ID,
Size: n.Size,
}
// apply subgraph's left shift
m.X += shift

out.Nodes = append(out.Nodes, m)
// todo: clients can't reliably tell virtual nodes from concrete nodes
}

// collect edges
for _, e := range g.Edges {
f := graph.Edge{
FromID: e.From.ID,
ToID: e.To.ID,
Points: slices.Clone(e.Points),
ArrowHeadStart: e.ArrowHeadStart,
}
// apply subgraph's left shift
for i := range f.Points {
f.Points[i][0] += shift
}

out.Edges = append(out.Edges, f)
}
out.Nodes = append(out.Nodes, graph.Node{
ID: n.ID,
Size: n.Size,
})
// todo: clients can't reliably tell virtual nodes from concrete nodes

// compute shift for subsequent subgraphs
rightmostX := 0.0
for _, l := range g.Layers {
n := l.Nodes[len(l.Nodes)-1]
rightmostX = max(rightmostX, n.X+n.W)
}
shift += rightmostX + layoutOpts.params.NodeSpacing
}

if !layoutOpts.output.keepVirtualNodes {
out.Nodes = slices.Clip(out.Nodes)
}

for _, e := range g.Edges {
out.Edges = append(out.Edges, graph.Edge{
FromID: e.From.ID,
ToID: e.To.ID,
Points: e.Points,
ArrowHeadStart: e.ArrowHeadStart,
})
}
return out
}

Expand Down
43 changes: 43 additions & 0 deletions internal/graph/connected/connected.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package connected

import (
"maps"

ig "github.com/nulab/autog/internal/graph"
)

func Components(g *ig.DGraph) []*ig.DGraph {
visitedN := make(ig.NodeSet)
visitedE := make(ig.EdgeSet)
walkDfs(g.Nodes[0], visitedN, visitedE)

// if all nodes were visited at the first dfs
// then there is only one connected component and that is G itself
if len(visitedN) == len(g.Nodes) {
return []*ig.DGraph{g}
}

cnncmp := make([]*ig.DGraph, 0, 2) // this has at least 2 connected components
cnncmp = append(cnncmp, &ig.DGraph{Nodes: visitedN.Keys(), Edges: visitedE.Keys()})

for _, n := range g.Nodes {
if !visitedN[n] {
ns := make(ig.NodeSet)
es := make(ig.EdgeSet)
walkDfs(n, ns, es)
cnncmp = append(cnncmp, &ig.DGraph{Nodes: ns.Keys(), Edges: es.Keys()})
maps.Copy(visitedN, ns)
}
}
return cnncmp
}

func walkDfs(n *ig.Node, visitedN ig.NodeSet, visitedE ig.EdgeSet) {
visitedN[n] = true
n.VisitEdges(func(e *ig.Edge) {
if !visitedE[e] {
visitedE[e] = true
walkDfs(e.ConnectedNode(n), visitedN, visitedE)
}
})
}
67 changes: 67 additions & 0 deletions internal/graph/connected/connected_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package connected

import (
"testing"

"github.com/nulab/autog/graph"
ig "github.com/nulab/autog/internal/graph"
"github.com/stretchr/testify/assert"
)

func TestComponents(t *testing.T) {
t.Run("one component", func(t *testing.T) {
es := [][]string{
{"a", "b"},
{"b", "c"},
}
g := &ig.DGraph{}
graph.EdgeSlice(es).Populate(g)

comp := Components(g)
assert.Len(t, comp, 1)
assert.True(t, comp[0] == g)
})

t.Run("multiple components", func(t *testing.T) {
es := [][]string{
{"a", "b"},
{"b", "c"},
{"f", "g"},
}
g := &ig.DGraph{}
graph.EdgeSlice(es).Populate(g)

comp := Components(g)
assert.Len(t, comp, 2)
assert.ElementsMatch(t, []string{"a", "b", "c"}, ids(comp[0].Nodes))
assert.ElementsMatch(t, []string{"f", "g"}, ids(comp[1].Nodes))
})

t.Run("self-loop", func(t *testing.T) {
es := [][]string{
{"a", "b"}, {"b", "c"},
{"f", "g"}, {"g", "h"}, {"h", "i"},
{"u", "u"},
{"j", "k"},
{"l", "j"},
{"z", "f"},
}
g := &ig.DGraph{}
graph.EdgeSlice(es).Populate(g)

comp := Components(g)
assert.Len(t, comp, 4)
assert.ElementsMatch(t, []string{"a", "b", "c"}, ids(comp[0].Nodes))
assert.ElementsMatch(t, []string{"f", "g", "h", "i", "z"}, ids(comp[1].Nodes))
assert.ElementsMatch(t, []string{"u"}, ids(comp[2].Nodes))
assert.ElementsMatch(t, []string{"j", "k", "l"}, ids(comp[3].Nodes))
})

}

func ids(ns []*ig.Node) (ids []string) {
for _, n := range ns {
ids = append(ids, n.ID)
}
return
}

0 comments on commit 8356eda

Please sign in to comment.