graph/layout: new package for calculating graph layouts
1
go.mod
@@ -6,4 +6,5 @@ require (
|
||||
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2
|
||||
golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e
|
||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0
|
||||
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b
|
||||
)
|
||||
|
18
go.sum
@@ -1,6 +1,24 @@
|
||||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af h1:wVe6/Ea46ZMeNkQjjBW6xcqyQA/j5e0D6GytH95g0gQ=
|
||||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
||||
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90 h1:WXb3TSNmHp2vHoCroCIB1foO/yQ36swABL8aOVeDpgg=
|
||||
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5 h1:PJr+ZMXIecYc1Ey2zucXdR73SMBtgjPgwa31099IMv0=
|
||||
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2 h1:y102fOLFqhV41b+4GPiJoa0k/x+pJcEi2/HB1Y5T6fU=
|
||||
golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81 h1:00VmoueYNlNz/aHIilyyQz/MHSqGoWJzpFv/HW8xpzI=
|
||||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
||||
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e h1:Io7mpb+aUAGF0MKxbyQ7HQl1VgB+cL6ZJZUFaFNqVV4=
|
||||
golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
|
||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc=
|
||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b h1:Qh4dB5D/WpoUUp3lSod7qgoyEHbDGPUWjIbnqdqqe1k=
|
||||
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
|
||||
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
6
graph/layout/doc.go
Normal file
@@ -0,0 +1,6 @@
|
||||
// Copyright ©2019 The Gonum Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package layout defines functions for performing graph layout.
|
||||
package layout // import "gonum.org/v1/gonum/graph/layout"
|
171
graph/layout/eades.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// Copyright ©2019 The Gonum Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package layout
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"golang.org/x/exp/rand"
|
||||
|
||||
"gonum.org/v1/gonum/graph"
|
||||
"gonum.org/v1/gonum/spatial/barneshut"
|
||||
"gonum.org/v1/gonum/spatial/r2"
|
||||
)
|
||||
|
||||
// EadesR2 implements the graph layout algorithm essentially as
|
||||
// described in "A heuristic for graph drawing", Congressus
|
||||
// numerantium 42:149-160.
|
||||
// The implementation here uses the Barnes-Hut approximation for
|
||||
// global repulsion calculation, and edge weights are considered
|
||||
// when calculating adjacent node attraction.
|
||||
type EadesR2 struct {
|
||||
// Updates is the number of updates to perform.
|
||||
Updates int
|
||||
|
||||
// Repulsion is the strength of the global
|
||||
// repulsive force between nodes in the
|
||||
// layout. It corresponds to C3 in the paper.
|
||||
Repulsion float64
|
||||
|
||||
// Rate is the gradient descent rate. It
|
||||
// corresponds to C4 in the paper.
|
||||
Rate float64
|
||||
|
||||
// Theta is the Barnes-Hut theta constant.
|
||||
Theta float64
|
||||
|
||||
// Src is the source of randomness used
|
||||
// to initialize the nodes' locations. If
|
||||
// Src is nil, the global random number
|
||||
// generator is used.
|
||||
Src rand.Source
|
||||
|
||||
nodes graph.Nodes
|
||||
indexOf map[int64]int
|
||||
|
||||
particles []barneshut.Particle2
|
||||
forces []r2.Vec
|
||||
}
|
||||
|
||||
// Update is the EadesR2 spatial graph update function.
|
||||
func (u *EadesR2) Update(g graph.Graph, layout LayoutR2) bool {
|
||||
if u.Updates <= 0 {
|
||||
return false
|
||||
}
|
||||
u.Updates--
|
||||
|
||||
if !layout.IsInitialized() {
|
||||
var rnd func() float64
|
||||
if u.Src == nil {
|
||||
rnd = rand.Float64
|
||||
} else {
|
||||
rnd = rand.New(u.Src).Float64
|
||||
}
|
||||
u.nodes = g.Nodes()
|
||||
u.indexOf = make(map[int64]int, u.nodes.Len())
|
||||
u.particles = make([]barneshut.Particle2, 0, u.nodes.Len())
|
||||
u.forces = make([]r2.Vec, u.nodes.Len())
|
||||
for u.nodes.Next() {
|
||||
id := u.nodes.Node().ID()
|
||||
u.indexOf[id] = len(u.particles)
|
||||
u.particles = append(u.particles, eadesR2Node{id: id, pos: r2.Vec{X: rnd(), Y: rnd()}})
|
||||
}
|
||||
}
|
||||
u.nodes.Reset()
|
||||
|
||||
// Apply global repulsion.
|
||||
plane, err := barneshut.NewPlane(u.particles)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var updated bool
|
||||
for i, p := range u.particles {
|
||||
f := plane.ForceOn(p, u.Theta, barneshut.Gravity2).Scale(-u.Repulsion)
|
||||
// Prevent marginal updates that can be caused by
|
||||
// floating point error when nodes are very far apart.
|
||||
if math.Hypot(f.X, f.Y) > 1e-12 {
|
||||
updated = true
|
||||
}
|
||||
u.forces[i] = f
|
||||
}
|
||||
|
||||
// Handle edge weighting for attraction.
|
||||
var weight func(uid, vid int64) float64
|
||||
if wg, ok := g.(graph.Weighted); ok {
|
||||
if _, ok := g.(graph.Directed); ok {
|
||||
weight = func(xid, yid int64) float64 {
|
||||
var w float64
|
||||
f, ok := wg.Weight(xid, yid)
|
||||
if ok {
|
||||
w += f
|
||||
}
|
||||
r, ok := wg.Weight(yid, xid)
|
||||
if ok {
|
||||
w += r
|
||||
}
|
||||
return w
|
||||
}
|
||||
} else {
|
||||
weight = func(xid, yid int64) float64 {
|
||||
w, ok := wg.Weight(xid, yid)
|
||||
if ok {
|
||||
return w
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This is only called when the adjacency is known so just return unit.
|
||||
weight = func(_, _ int64) float64 { return 1 }
|
||||
}
|
||||
|
||||
seen := make(map[[2]int64]bool)
|
||||
for u.nodes.Next() {
|
||||
xid := u.nodes.Node().ID()
|
||||
xidx := u.indexOf[xid]
|
||||
to := g.From(xid)
|
||||
for to.Next() {
|
||||
yid := to.Node().ID()
|
||||
if seen[[2]int64{xid, yid}] {
|
||||
continue
|
||||
}
|
||||
seen[[2]int64{yid, xid}] = true
|
||||
yidx := u.indexOf[yid]
|
||||
|
||||
// Apply adjacent node attraction.
|
||||
v := u.particles[yidx].Coord2().Sub(u.particles[xidx].Coord2())
|
||||
f := v.Scale(weight(xid, yid) * math.Log(math.Hypot(v.X, v.Y)))
|
||||
if math.Hypot(f.X, f.Y) > 1e-12 {
|
||||
updated = true
|
||||
}
|
||||
u.forces[xidx] = u.forces[xidx].Add(f)
|
||||
u.forces[yidx] = u.forces[yidx].Sub(f)
|
||||
}
|
||||
}
|
||||
|
||||
if !updated {
|
||||
return false
|
||||
}
|
||||
|
||||
rate := u.Rate
|
||||
if rate == 0 {
|
||||
rate = 0.1
|
||||
}
|
||||
for i, f := range u.forces {
|
||||
n := u.particles[i].(eadesR2Node)
|
||||
n.pos = n.pos.Add(f.Scale(rate))
|
||||
u.particles[i] = n
|
||||
layout.SetCoord2(n.id, n.pos)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type eadesR2Node struct {
|
||||
id int64
|
||||
pos r2.Vec
|
||||
}
|
||||
|
||||
func (p eadesR2Node) Coord2() r2.Vec { return p.pos }
|
||||
func (p eadesR2Node) Mass() float64 { return 1 }
|
233
graph/layout/eades_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright ©2019 The Gonum Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package layout
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/exp/rand"
|
||||
|
||||
"gonum.org/v1/gonum/graph"
|
||||
"gonum.org/v1/gonum/graph/simple"
|
||||
"gonum.org/v1/gonum/spatial/r2"
|
||||
"gonum.org/v1/plot"
|
||||
"gonum.org/v1/plot/vg"
|
||||
)
|
||||
|
||||
var eadesR2Tests = []struct {
|
||||
name string
|
||||
g graph.Graph
|
||||
param EadesR2
|
||||
wantIters int
|
||||
}{
|
||||
{
|
||||
name: "line",
|
||||
g: func() graph.Graph {
|
||||
edges := []simple.Edge{
|
||||
{simple.Node(0), simple.Node(1)},
|
||||
}
|
||||
g := simple.NewUndirectedGraph()
|
||||
for _, e := range edges {
|
||||
g.SetEdge(e)
|
||||
}
|
||||
return orderedGraph{g}
|
||||
}(),
|
||||
param: EadesR2{Repulsion: 1, Rate: 0.1, Updates: 100, Theta: 0.1, Src: rand.NewSource(1)},
|
||||
wantIters: 100,
|
||||
},
|
||||
{
|
||||
name: "square",
|
||||
g: func() graph.Graph {
|
||||
edges := []simple.Edge{
|
||||
{simple.Node(0), simple.Node(1)},
|
||||
{simple.Node(0), simple.Node(2)},
|
||||
{simple.Node(1), simple.Node(3)},
|
||||
{simple.Node(2), simple.Node(3)},
|
||||
}
|
||||
g := simple.NewUndirectedGraph()
|
||||
for _, e := range edges {
|
||||
g.SetEdge(e)
|
||||
}
|
||||
return orderedGraph{g}
|
||||
}(),
|
||||
param: EadesR2{Repulsion: 1, Rate: 0.1, Updates: 100, Theta: 0.1, Src: rand.NewSource(1)},
|
||||
wantIters: 100,
|
||||
},
|
||||
{
|
||||
name: "tetrahedron",
|
||||
g: func() graph.Graph {
|
||||
edges := []simple.Edge{
|
||||
{simple.Node(0), simple.Node(1)},
|
||||
{simple.Node(0), simple.Node(2)},
|
||||
{simple.Node(0), simple.Node(3)},
|
||||
{simple.Node(1), simple.Node(2)},
|
||||
{simple.Node(1), simple.Node(3)},
|
||||
{simple.Node(2), simple.Node(3)},
|
||||
}
|
||||
g := simple.NewUndirectedGraph()
|
||||
for _, e := range edges {
|
||||
g.SetEdge(e)
|
||||
}
|
||||
return orderedGraph{g}
|
||||
}(),
|
||||
param: EadesR2{Repulsion: 1, Rate: 0.1, Updates: 100, Theta: 0.1, Src: rand.NewSource(1)},
|
||||
wantIters: 100,
|
||||
},
|
||||
{
|
||||
name: "sheet",
|
||||
g: func() graph.Graph {
|
||||
edges := []simple.Edge{
|
||||
{simple.Node(0), simple.Node(1)},
|
||||
{simple.Node(0), simple.Node(3)},
|
||||
{simple.Node(1), simple.Node(2)},
|
||||
{simple.Node(1), simple.Node(4)},
|
||||
{simple.Node(2), simple.Node(5)},
|
||||
{simple.Node(3), simple.Node(4)},
|
||||
{simple.Node(3), simple.Node(6)},
|
||||
{simple.Node(4), simple.Node(5)},
|
||||
{simple.Node(4), simple.Node(7)},
|
||||
{simple.Node(5), simple.Node(8)},
|
||||
{simple.Node(6), simple.Node(7)},
|
||||
{simple.Node(7), simple.Node(8)},
|
||||
}
|
||||
g := simple.NewUndirectedGraph()
|
||||
for _, e := range edges {
|
||||
g.SetEdge(e)
|
||||
}
|
||||
return orderedGraph{g}
|
||||
}(),
|
||||
param: EadesR2{Repulsion: 1, Rate: 0.1, Updates: 100, Theta: 0.1, Src: rand.NewSource(1)},
|
||||
wantIters: 100,
|
||||
},
|
||||
{
|
||||
name: "tube",
|
||||
g: func() graph.Graph {
|
||||
edges := []simple.Edge{
|
||||
{simple.Node(0), simple.Node(1)},
|
||||
{simple.Node(0), simple.Node(2)},
|
||||
{simple.Node(0), simple.Node(3)},
|
||||
{simple.Node(1), simple.Node(2)},
|
||||
{simple.Node(1), simple.Node(4)},
|
||||
{simple.Node(2), simple.Node(5)},
|
||||
{simple.Node(3), simple.Node(4)},
|
||||
{simple.Node(3), simple.Node(5)},
|
||||
{simple.Node(3), simple.Node(6)},
|
||||
{simple.Node(4), simple.Node(5)},
|
||||
{simple.Node(4), simple.Node(7)},
|
||||
{simple.Node(5), simple.Node(8)},
|
||||
{simple.Node(6), simple.Node(7)},
|
||||
{simple.Node(6), simple.Node(8)},
|
||||
{simple.Node(7), simple.Node(8)},
|
||||
}
|
||||
g := simple.NewUndirectedGraph()
|
||||
for _, e := range edges {
|
||||
g.SetEdge(e)
|
||||
}
|
||||
return orderedGraph{g}
|
||||
}(),
|
||||
param: EadesR2{Repulsion: 1, Rate: 0.1, Updates: 100, Theta: 0.1, Src: rand.NewSource(1)},
|
||||
wantIters: 100,
|
||||
},
|
||||
{
|
||||
// This test does not produce a good layout, but is here to
|
||||
// ensure that Update does not panic with steep decent rates.
|
||||
name: "tube-steep",
|
||||
g: func() graph.Graph {
|
||||
edges := []simple.Edge{
|
||||
{simple.Node(0), simple.Node(1)},
|
||||
{simple.Node(0), simple.Node(2)},
|
||||
{simple.Node(0), simple.Node(3)},
|
||||
{simple.Node(1), simple.Node(2)},
|
||||
{simple.Node(1), simple.Node(4)},
|
||||
{simple.Node(2), simple.Node(5)},
|
||||
{simple.Node(3), simple.Node(4)},
|
||||
{simple.Node(3), simple.Node(5)},
|
||||
{simple.Node(3), simple.Node(6)},
|
||||
{simple.Node(4), simple.Node(5)},
|
||||
{simple.Node(4), simple.Node(7)},
|
||||
{simple.Node(5), simple.Node(8)},
|
||||
{simple.Node(6), simple.Node(7)},
|
||||
{simple.Node(6), simple.Node(8)},
|
||||
{simple.Node(7), simple.Node(8)},
|
||||
}
|
||||
g := simple.NewUndirectedGraph()
|
||||
for _, e := range edges {
|
||||
g.SetEdge(e)
|
||||
}
|
||||
return orderedGraph{g}
|
||||
}(),
|
||||
param: EadesR2{Repulsion: 1, Rate: 1, Updates: 100, Theta: 0.1, Src: rand.NewSource(1)},
|
||||
wantIters: 99,
|
||||
},
|
||||
|
||||
{
|
||||
name: "wp_page", // https://en.wikipedia.org/wiki/PageRank#/media/File:PageRanks-Example.jpg
|
||||
g: func() graph.Graph {
|
||||
edges := []simple.Edge{
|
||||
{simple.Node(0), simple.Node(3)},
|
||||
{simple.Node(1), simple.Node(2)},
|
||||
{simple.Node(1), simple.Node(3)},
|
||||
{simple.Node(1), simple.Node(4)},
|
||||
{simple.Node(1), simple.Node(5)},
|
||||
{simple.Node(1), simple.Node(6)},
|
||||
{simple.Node(1), simple.Node(7)},
|
||||
{simple.Node(1), simple.Node(8)},
|
||||
{simple.Node(3), simple.Node(4)},
|
||||
{simple.Node(4), simple.Node(5)},
|
||||
{simple.Node(4), simple.Node(6)},
|
||||
{simple.Node(4), simple.Node(7)},
|
||||
{simple.Node(4), simple.Node(8)},
|
||||
{simple.Node(4), simple.Node(9)},
|
||||
{simple.Node(4), simple.Node(10)},
|
||||
}
|
||||
g := simple.NewUndirectedGraph()
|
||||
for _, e := range edges {
|
||||
g.SetEdge(e)
|
||||
}
|
||||
return orderedGraph{g}
|
||||
}(),
|
||||
param: EadesR2{Repulsion: 1, Rate: 0.1, Updates: 100, Theta: 0.1, Src: rand.NewSource(1)},
|
||||
wantIters: 100,
|
||||
},
|
||||
}
|
||||
|
||||
func TestEadesR2(t *testing.T) {
|
||||
for _, test := range eadesR2Tests {
|
||||
eades := test.param
|
||||
o := NewOptimizerR2(test.g, eades.Update)
|
||||
var n int
|
||||
for o.Update() {
|
||||
n++
|
||||
}
|
||||
if n != test.wantIters {
|
||||
t.Errorf("unexpected number of iterations for %q: got:%d want:%d", test.name, n, test.wantIters)
|
||||
}
|
||||
|
||||
p, err := plot.New()
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
continue
|
||||
}
|
||||
p.Add(render{o})
|
||||
p.HideAxes()
|
||||
path := filepath.Join("testdata", test.name+".png")
|
||||
err = p.Save(10*vg.Centimeter, 10*vg.Centimeter, path)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
continue
|
||||
}
|
||||
ok := checkRenderedLayout(t, path)
|
||||
if !ok {
|
||||
got := make(map[int64]r2.Vec)
|
||||
nodes := test.g.Nodes()
|
||||
for nodes.Next() {
|
||||
id := nodes.Node().ID()
|
||||
got[id] = o.Coord2(id)
|
||||
}
|
||||
t.Logf("got node positions: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
23
graph/layout/layout.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright ©2019 The Gonum Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package layout
|
||||
|
||||
import (
|
||||
"gonum.org/v1/gonum/graph"
|
||||
"gonum.org/v1/gonum/spatial/r2"
|
||||
)
|
||||
|
||||
// GraphR2 is a graph with planar spatial representation of node positions.
|
||||
type GraphR2 interface {
|
||||
graph.Graph
|
||||
LayoutNodeR2(id int64) NodeR2
|
||||
}
|
||||
|
||||
// NodeR2 is a graph node with planar spatial representation of its position.
|
||||
// A NodeR2 is only valid when the graph.Node is not nil.
|
||||
type NodeR2 struct {
|
||||
graph.Node
|
||||
Coord2 r2.Vec
|
||||
}
|
120
graph/layout/layout_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright ©2019 The Gonum Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package layout
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gonum.org/v1/gonum/graph"
|
||||
"gonum.org/v1/gonum/graph/encoding"
|
||||
"gonum.org/v1/gonum/graph/internal/ordered"
|
||||
"gonum.org/v1/gonum/graph/iterator"
|
||||
"gonum.org/v1/gonum/spatial/r2"
|
||||
"gonum.org/v1/plot/cmpimg"
|
||||
)
|
||||
|
||||
// orderedGraph wraps a graph.Graph ensuring consistent ordering of nodes
|
||||
// in graph queries. Removal of this causes to tests to fail due to changes
|
||||
// in node iteration order, but the produced graph layouts are still good.
|
||||
type orderedGraph struct {
|
||||
graph.Graph
|
||||
}
|
||||
|
||||
func (g orderedGraph) Nodes() graph.Nodes {
|
||||
n := graph.NodesOf(g.Graph.Nodes())
|
||||
sort.Sort(ordered.ByID(n))
|
||||
return iterator.NewOrderedNodes(n)
|
||||
}
|
||||
|
||||
func (g orderedGraph) From(id int64) graph.Nodes {
|
||||
n := graph.NodesOf(g.Graph.From(id))
|
||||
sort.Sort(ordered.ByID(n))
|
||||
return iterator.NewOrderedNodes(n)
|
||||
}
|
||||
|
||||
// positionNode is a graph.Node with an XY position.
|
||||
type positionNode struct {
|
||||
id int64
|
||||
pos r2.Vec
|
||||
}
|
||||
|
||||
func (n positionNode) ID() int64 { return n.id }
|
||||
func (n positionNode) Attributes() []encoding.Attribute {
|
||||
return []encoding.Attribute{{Key: "pos", Value: fmt.Sprintf(`"%f,%f"`, n.pos.X, n.pos.Y)}}
|
||||
}
|
||||
|
||||
func goldenPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
noext := strings.TrimSuffix(path, ext)
|
||||
return noext + "_golden" + ext
|
||||
}
|
||||
|
||||
func checkRenderedLayout(t *testing.T, path string) (ok bool) {
|
||||
if *cmpimg.GenerateTestData {
|
||||
// Recreate Golden image and exit.
|
||||
golden := goldenPath(path)
|
||||
_ = os.Remove(golden)
|
||||
if err := os.Rename(path, golden); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Read the images we've just generated and check them against the
|
||||
// Golden Images.
|
||||
got, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read %s: %v", path, err)
|
||||
return true
|
||||
}
|
||||
golden := goldenPath(path)
|
||||
want, err := ioutil.ReadFile(golden)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read golden file %s: %v", golden, err)
|
||||
return true
|
||||
}
|
||||
typ := filepath.Ext(path)[1:] // remove the dot in ".png"
|
||||
ok, err = cmpimg.Equal(typ, got, want)
|
||||
if err != nil {
|
||||
t.Errorf("failed to compare image for %s: %v", path, err)
|
||||
return true
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("image mismatch for %s\n", path)
|
||||
v1, _, err := image.Decode(bytes.NewReader(got))
|
||||
if err != nil {
|
||||
t.Errorf("failed to decode %s: %v", path, err)
|
||||
return false
|
||||
}
|
||||
v2, _, err := image.Decode(bytes.NewReader(want))
|
||||
if err != nil {
|
||||
t.Errorf("failed to decode %s: %v", golden, err)
|
||||
return false
|
||||
}
|
||||
|
||||
dst := image.NewRGBA64(v1.Bounds().Union(v2.Bounds()))
|
||||
rect := cmpimg.Diff(dst, v1, v2)
|
||||
t.Logf("image bounds union:%+v diff bounds intersection:%+v", dst.Bounds(), rect)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = png.Encode(&buf, dst)
|
||||
if err != nil {
|
||||
t.Errorf("failed to encode difference png: %v", err)
|
||||
return false
|
||||
}
|
||||
t.Log("IMAGE:" + base64.StdEncoding.EncodeToString(buf.Bytes()))
|
||||
}
|
||||
return ok
|
||||
}
|
106
graph/layout/optimizer.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Copyright ©2019 The Gonum Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package layout
|
||||
|
||||
import (
|
||||
"gonum.org/v1/gonum/graph"
|
||||
"gonum.org/v1/gonum/spatial/r2"
|
||||
)
|
||||
|
||||
// LayoutR2 implements graph layout updates and representations.
|
||||
type LayoutR2 interface {
|
||||
// IsInitialized returns whether the Layout is initialized.
|
||||
IsInitialized() bool
|
||||
|
||||
// SetCoord2 sets the coordinates of the node with the given
|
||||
// id to coords.
|
||||
SetCoord2(id int64, coords r2.Vec)
|
||||
|
||||
// Coord2 returns the coordinated of the node with the given
|
||||
// id in the graph layout.
|
||||
Coord2(id int64) r2.Vec
|
||||
}
|
||||
|
||||
// NewOptimizerR2 returns a new layout optimizer. If g implements LayoutR2 the layout
|
||||
// will be updated into g, otherwise the OptimizerR2 will hold the graph layout. A nil
|
||||
// value for update is a valid no-op layout update function.
|
||||
func NewOptimizerR2(g graph.Graph, update func(graph.Graph, LayoutR2) bool) OptimizerR2 {
|
||||
l, ok := g.(LayoutR2)
|
||||
if !ok {
|
||||
l = make(coordinatesR2)
|
||||
}
|
||||
return OptimizerR2{
|
||||
g: g,
|
||||
layout: l,
|
||||
Updater: update,
|
||||
}
|
||||
}
|
||||
|
||||
// coordinatesR2 is the default layout store for R2.
|
||||
type coordinatesR2 map[int64]r2.Vec
|
||||
|
||||
func (c coordinatesR2) IsInitialized() bool { return len(c) != 0 }
|
||||
func (c coordinatesR2) SetCoord2(id int64, pos r2.Vec) { c[id] = pos }
|
||||
func (c coordinatesR2) Coord2(id int64) r2.Vec { return c[id] }
|
||||
|
||||
// OptimizerR2 is a helper type that holds a graph and layout
|
||||
// optimization state.
|
||||
type OptimizerR2 struct {
|
||||
g graph.Graph
|
||||
layout LayoutR2
|
||||
|
||||
// Updater is the function called for each call to Update.
|
||||
// It updates the OptimizerR2's spatial distribution of the
|
||||
// nodes in the backing graph.
|
||||
Updater func(graph.Graph, LayoutR2) bool
|
||||
}
|
||||
|
||||
// Coord2 returns the location of the node with the given
|
||||
// ID. The returned value is only valid if the node exists
|
||||
// in the graph.
|
||||
func (g OptimizerR2) Coord2(id int64) r2.Vec {
|
||||
return g.layout.Coord2(id)
|
||||
}
|
||||
|
||||
// Update updates the locations of the nodes in the graph
|
||||
// according to the provided update function. It returns whether
|
||||
// the update function is able to further refine the graph's
|
||||
// node locations.
|
||||
func (g OptimizerR2) Update() bool {
|
||||
if g.Updater == nil {
|
||||
return false
|
||||
}
|
||||
return g.Updater(g.g, g.layout)
|
||||
}
|
||||
|
||||
// LayoutNodeR2 implements the GraphR2 interface.
|
||||
func (g OptimizerR2) LayoutNodeR2(id int64) NodeR2 {
|
||||
n := g.g.Node(id)
|
||||
if n == nil {
|
||||
return NodeR2{}
|
||||
}
|
||||
return NodeR2{Node: n, Coord2: g.Coord2(id)}
|
||||
}
|
||||
|
||||
// Node returns the node with the given ID if it exists
|
||||
// in the graph, and nil otherwise.
|
||||
func (g OptimizerR2) Node(id int64) graph.Node { return g.g.Node(id) }
|
||||
|
||||
// Nodes returns all the nodes in the graph.
|
||||
func (g OptimizerR2) Nodes() graph.Nodes { return g.g.Nodes() }
|
||||
|
||||
// From returns all nodes that can be reached directly
|
||||
// from the node with the given ID.
|
||||
func (g OptimizerR2) From(id int64) graph.Nodes { return g.g.From(id) }
|
||||
|
||||
// HasEdgeBetween returns whether an edge exists between
|
||||
// nodes with IDs xid and yid without considering direction.
|
||||
func (g OptimizerR2) HasEdgeBetween(xid, yid int64) bool { return g.g.HasEdgeBetween(xid, yid) }
|
||||
|
||||
// Edge returns the edge from u to v, with IDs uid and vid,
|
||||
// if such an edge exists and nil otherwise. The node v
|
||||
// must be directly reachable from u as defined by the
|
||||
// From method.
|
||||
func (g OptimizerR2) Edge(uid, vid int64) graph.Edge { return g.g.Edge(uid, vid) }
|
132
graph/layout/plotter_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright ©2019 The Gonum Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package layout
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"gonum.org/v1/plot"
|
||||
"gonum.org/v1/plot/plotter"
|
||||
"gonum.org/v1/plot/vg"
|
||||
"gonum.org/v1/plot/vg/draw"
|
||||
)
|
||||
|
||||
const radius = vg.Length(15)
|
||||
|
||||
// render implements the plot.Plotter interface for graphs.
|
||||
type render struct {
|
||||
GraphR2
|
||||
}
|
||||
|
||||
func (p render) Plot(c draw.Canvas, plt *plot.Plot) {
|
||||
nodes := p.GraphR2.Nodes()
|
||||
if nodes.Len() == 0 {
|
||||
return
|
||||
}
|
||||
xys := make(plotter.XYs, 0, nodes.Len())
|
||||
ids := make([]string, 0, nodes.Len())
|
||||
for nodes.Next() {
|
||||
u := nodes.Node()
|
||||
uid := u.ID()
|
||||
ur2 := p.GraphR2.LayoutNodeR2(uid)
|
||||
xys = append(xys, plotter.XY(ur2.Coord2))
|
||||
ids = append(ids, fmt.Sprint(uid))
|
||||
to := p.GraphR2.From(uid)
|
||||
for to.Next() {
|
||||
v := to.Node()
|
||||
vid := v.ID()
|
||||
vr2 := p.GraphR2.LayoutNodeR2(vid)
|
||||
|
||||
l, err := plotter.NewLine(plotter.XYs{plotter.XY(ur2.Coord2), plotter.XY(vr2.Coord2)})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
l.Plot(c, plt)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n, err := plotter.NewScatter(xys)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
n.GlyphStyle.Shape = nodeGlyph{}
|
||||
n.GlyphStyle.Radius = radius
|
||||
n.Plot(c, plt)
|
||||
|
||||
l, err := plotter.NewLabels(plotter.XYLabels{XYs: xys, Labels: ids})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fnt, err := vg.MakeFont(plot.DefaultFont, vg.Points(18))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for i := range l.TextStyle {
|
||||
l.TextStyle[i] = draw.TextStyle{Font: fnt, XAlign: draw.XCenter, YAlign: -0.33}
|
||||
}
|
||||
|
||||
l.Plot(c, plt)
|
||||
}
|
||||
|
||||
// DataRange returns the minimum and maximum X and Y values
|
||||
func (p render) DataRange() (xmin, xmax, ymin, ymax float64) {
|
||||
nodes := p.GraphR2.Nodes()
|
||||
if nodes.Len() == 0 {
|
||||
return
|
||||
}
|
||||
xys := make(plotter.XYs, 0, nodes.Len())
|
||||
for nodes.Next() {
|
||||
u := nodes.Node()
|
||||
uid := u.ID()
|
||||
ur2 := p.GraphR2.LayoutNodeR2(uid)
|
||||
xys = append(xys, plotter.XY(ur2.Coord2))
|
||||
}
|
||||
return plotter.XYRange(xys)
|
||||
}
|
||||
|
||||
// GlyphBoxes returns a slice of plot.GlyphBoxes, implementing the
|
||||
// plot.GlyphBoxer interface.
|
||||
func (p render) GlyphBoxes(plt *plot.Plot) []plot.GlyphBox {
|
||||
nodes := p.GraphR2.Nodes()
|
||||
if nodes.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
b := make([]plot.GlyphBox, nodes.Len())
|
||||
for i := 0; nodes.Next(); i++ {
|
||||
u := nodes.Node()
|
||||
uid := u.ID()
|
||||
ur2 := p.GraphR2.LayoutNodeR2(uid)
|
||||
|
||||
b[i].X = plt.X.Norm(ur2.Coord2.X)
|
||||
b[i].Y = plt.Y.Norm(ur2.Coord2.Y)
|
||||
r := radius
|
||||
b[i].Rectangle = vg.Rectangle{
|
||||
Min: vg.Point{X: -r, Y: -r},
|
||||
Max: vg.Point{X: +r, Y: +r},
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// nodeGlyph is a glyph that draws a filled circle.
|
||||
type nodeGlyph struct{}
|
||||
|
||||
// DrawGlyph implements the GlyphDrawer interface.
|
||||
func (nodeGlyph) DrawGlyph(c *draw.Canvas, sty draw.GlyphStyle, pt vg.Point) {
|
||||
var p vg.Path
|
||||
c.Push()
|
||||
c.SetColor(color.White)
|
||||
p.Move(vg.Point{X: pt.X + sty.Radius, Y: pt.Y})
|
||||
p.Arc(pt, sty.Radius, 0, 2*math.Pi)
|
||||
p.Close()
|
||||
c.Fill(p)
|
||||
c.Pop()
|
||||
c.Stroke(p)
|
||||
}
|
2
graph/layout/testdata/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.png
|
||||
!*_golden.png
|
BIN
graph/layout/testdata/line_golden.png
vendored
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
graph/layout/testdata/sheet_golden.png
vendored
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
graph/layout/testdata/square_golden.png
vendored
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
graph/layout/testdata/tetrahedron_golden.png
vendored
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
graph/layout/testdata/tube-steep_golden.png
vendored
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
graph/layout/testdata/tube_golden.png
vendored
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
graph/layout/testdata/wp_page_golden.png
vendored
Normal file
After Width: | Height: | Size: 26 KiB |
@@ -97,8 +97,8 @@ func (q *Plane) Reset() (err error) {
|
||||
defer func() {
|
||||
switch r := recover(); r {
|
||||
case nil:
|
||||
case volumeTooBig:
|
||||
err = volumeTooBig
|
||||
case planeTooBig:
|
||||
err = planeTooBig
|
||||
default:
|
||||
panic(r)
|
||||
}
|
||||
|