├── .github
└── CODEOWNERS
├── .gitignore
├── LICENSE
├── README.md
├── go.mod
├── go.sum
├── layout
├── brandeskopf.go
├── cycle_remover.go
├── edge_layout.go
├── force_layout.go
├── gonum_layout.go
├── graph.go
├── layered_graph.go
├── layers_edgepath_assigner.go
├── layers_layers_assigner.go
├── layers_layout.go
├── layers_nodes_vertical_assigner.go
├── layers_ordering_assigner.go
├── layout.go
├── layout_test.go
├── scaler_layout.go
└── testdata
│ ├── brandeskopf.jsonl
│ ├── brandeskopf_eades.svg
│ ├── brandeskopf_forces.svg
│ ├── brandeskopf_isomap.svg
│ ├── brandeskopf_layers.svg
│ ├── gin.jsonl
│ ├── gin_eades.svg
│ ├── gin_forces.svg
│ ├── gin_isomap.svg
│ ├── gin_layers.svg
│ ├── small.jsonl
│ ├── small_eades.svg
│ ├── small_forces.svg
│ ├── small_isomap.svg
│ └── small_layers.svg
└── svg
├── components.go
├── edge.go
├── graph.go
└── node.go
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @gverger
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Nikolay Dubina
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Graph Layout Algorithms in Go
2 |
3 | This module provides algorithms for graph visualization in native Go.
4 | As of 2021-11-20, virtually all graph visualization algorithms are bindings to Graphviz dot code which is in C.
5 | This module attempts to provide implementation of latest and best graph visualization algorithms from scratch in Go.
6 | However, given this is very complex task this is work in progress.
7 |
8 | ## Features
9 |
10 | - [x] gonum Isomap
11 | - [x] gonum Eades
12 | - [x] Kozo Sugiyama layers strategy
13 | - [ ] Brandes-Köpf horizontal layers assignment [80% done]
14 | - [ ] Graphviz dot layers algorithm [80% done]
15 | - [x] Gravity force
16 | - [x] Spring force
17 | - [ ] Kozo Sugiyama Magnetic Force
18 | - [ ] Metro Style edges
19 | - [ ] Ports for edges
20 | - [ ] Spline edges
21 | - [ ] Collision avoidance (dot) edge path algorithm
22 |
23 | ## Contributions
24 |
25 | Yes please. These algorithms are hard. If you can, help to finish implementing any of above!
26 |
27 | If lots of contributions, I am ok to merge this into some org!
28 |
29 | ## References
30 |
31 | - [Wiki Layered Graph Drawing](https://en.wikipedia.org/wiki/Layered_graph_drawing)
32 | - ["Handbook of Graph Drawing and Visualization"](https://cs.brown.edu/people/rtamassi/gdhandbook/), Roberto Tamassia, Brown, Ch.13, 2013
33 | - ["A Technique for Drawing Directed Graphs"](https://ieeexplore.ieee.org/document/221135), Emden R. Gansner Eleftherios Koutsofios Stephen C. North Kiem-Phong Vo, AT&T Bell Laboratories, 1993
34 | - ["Fast and Simple Horizontal Coordinate Assignment"](https://link.springer.com/content/pdf/10.1007/3-540-45848-4_3.pdf), U. Brandes, Boris Köpf, 2002
35 | - "Methods for visual understanding of hierarchical system structures", Sugiyama, Kozo; Tagawa, Shôjirô; Toda, Mitsuhiko, 1981
36 | - "Graph Drawing by the Magnetic Spring Model", Kozo Sugiyama, 1995
37 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/gverger/go-graph-layout
2 |
3 | go 1.22
4 |
5 | toolchain go1.23.1
6 |
7 | require (
8 | github.com/nikolaydubina/multiline-jsonl v1.0.3 // indirect
9 | golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
10 | gonum.org/v1/gonum v0.15.1
11 | )
12 |
13 | require github.com/nikolaydubina/jsonl-graph v1.1.0
14 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | git.sr.ht/~sbinet/gg v0.5.0 h1:6V43j30HM623V329xA9Ntq+WJrMjDxRjuAB1LFWF5m8=
2 | git.sr.ht/~sbinet/gg v0.5.0/go.mod h1:G2C0eRESqlKhS7ErsNey6HHrqU1PwsnCQlekFi9Q2Oo=
3 | github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
4 | github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
5 | github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY=
6 | github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8=
7 | github.com/go-fonts/liberation v0.3.2 h1:XuwG0vGHFBPRRI8Qwbi5tIvR3cku9LUfZGq/Ar16wlQ=
8 | github.com/go-fonts/liberation v0.3.2/go.mod h1:N0QsDLVUQPy3UYg9XAc3Uh3UDMp2Z7M1o4+X98dXkmI=
9 | github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea h1:DfZQkvEbdmOe+JK2TMtBM+0I9GSdzE2y/L1/AmD8xKc=
10 | github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea/go.mod h1:Y7Vld91/HRbTBm7JwoI7HejdDB0u+e9AUBO9MB7yuZk=
11 | github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
12 | github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
13 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
14 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
15 | github.com/nikolaydubina/jsonl-graph v1.1.0 h1:hxU2EFe0keB47tekjkDMGO2+DDbZvcfz21CvlV5Uv9A=
16 | github.com/nikolaydubina/jsonl-graph v1.1.0/go.mod h1:wQZL4BSABkCzcN3ual9qJ1AFE6wQSDJAbIZEmuYL1eY=
17 | github.com/nikolaydubina/multiline-jsonl v1.0.3 h1:rzfukoRroxvBorDgrtAjIcBasBpKQstqaOk9VfBPz70=
18 | github.com/nikolaydubina/multiline-jsonl v1.0.3/go.mod h1:/CONohg68Ltqm6QImYFWK2hC4025Sm/b53pqWQDVJ2A=
19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
21 | golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
22 | golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
23 | golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
24 | golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
25 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
26 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
27 | gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
28 | gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
29 | gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE=
30 | gonum.org/v1/plot v0.14.0/go.mod h1:MLdR9424SJed+5VqC6MsouEpig9pZX2VZ57H9ko2bXU=
31 | rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
32 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
33 |
--------------------------------------------------------------------------------
/layout/cycle_remover.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | import (
4 | "math/rand"
5 | )
6 |
7 | // SimpleCycleRemover will keep testing for cycles, if cycle found will randomly reverse one edge in cycle.
8 | // When restoring, will reverse previously reversed edges.
9 | type SimpleCycleRemover struct {
10 | Reversed map[[2]uint64]bool
11 | }
12 |
13 | func NewSimpleCycleRemover() SimpleCycleRemover {
14 | return SimpleCycleRemover{
15 | Reversed: map[[2]uint64]bool{},
16 | }
17 | }
18 |
19 | func getCycleDFS(neighbors map[uint64][]uint64, que []uint64) []uint64 {
20 | if len(que) == 0 {
21 | return que
22 | }
23 |
24 | p := que[len(que)-1]
25 | for _, d := range neighbors[p] {
26 | // check if cycle
27 | for i, t := range que {
28 | if d == t {
29 | return que[i:]
30 | }
31 | }
32 |
33 | // DFS deep call
34 | if t := getCycleDFS(neighbors, append(que, d)); len(t) > 0 {
35 | return t
36 | }
37 | }
38 |
39 | return nil
40 | }
41 |
42 | func getCycle(roots []uint64, neighbors map[uint64][]uint64) []uint64 {
43 | for _, root := range roots {
44 | if t := getCycleDFS(neighbors, []uint64{root}); len(t) > 0 {
45 | return t
46 | }
47 | }
48 | return nil
49 | }
50 |
51 | func reverseEdge(g Graph, e [2]uint64) {
52 | delete(g.Edges, e)
53 | g.Edges[[2]uint64{e[1], e[0]}] = Edge{}
54 | }
55 |
56 | func (s SimpleCycleRemover) RemoveCycles(g Graph) {
57 | neighbors := make(map[uint64][]uint64)
58 | for e := range g.Edges {
59 | neighbors[e[0]] = append(neighbors[e[0]], e[1])
60 | }
61 | for cycle := getCycle(g.Roots(), neighbors); len(cycle) > 0; cycle = getCycle(g.Roots(), neighbors) {
62 | // pick edge randomly
63 | i := rand.Intn(len(cycle) - 1)
64 | e := [2]uint64{cycle[i], cycle[i+1]}
65 |
66 | reverseEdge(g, e)
67 | s.Reversed[e] = true
68 |
69 | neighbors[e[0]] = deleteValue(neighbors[e[0]], e[1])
70 | neighbors[e[1]] = append(neighbors[e[1]], e[0])
71 | }
72 | }
73 |
74 | func deleteValue(slice []uint64, value uint64) []uint64 {
75 | for i, n := range slice {
76 | if n == value {
77 | slice[i] = slice[len(slice)-1]
78 | return slice[:len(slice)-1]
79 | }
80 | }
81 | return slice
82 | }
83 |
84 | func (s SimpleCycleRemover) Restore(g Graph) {
85 | for e := range s.Reversed {
86 | reverseEdge(g, e)
87 | delete(s.Reversed, e)
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/layout/edge_layout.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | // DirectEdge is straight line from center of one node to another.
4 | func DirectEdge(from, to Node) Edge {
5 | return Edge{
6 | Path: []Position{
7 | {
8 | X: from.X + (from.W / 2),
9 | Y: from.Y + (from.H / 2),
10 | },
11 | {
12 | X: to.X + (to.W / 2),
13 | Y: to.Y + (to.H / 2),
14 | },
15 | },
16 | }
17 | }
18 |
19 | // DirectEdgesLayout are straight single line edges.
20 | type DirectEdgesLayout struct{}
21 |
22 | func (l DirectEdgesLayout) UpdateGraphLayout(g Graph) {
23 | for e := range g.Edges {
24 | g.Edges[e] = DirectEdge(g.Nodes[e[0]], g.Nodes[e[1]])
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/layout/force_layout.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | import "math"
4 |
5 | // Force computes forces for Nodes.
6 | type Force interface {
7 | UpdateForce(g Graph, f map[uint64][2]float64)
8 | }
9 |
10 | // ForceGraphLayout will simulate node movement due to forces.
11 | type ForceGraphLayout struct {
12 | Delta float64 // how much move each step
13 | MaxSteps int // limit of iterations
14 | Epsilon float64 // minimal force
15 | Forces []Force
16 | }
17 |
18 | func (l ForceGraphLayout) UpdateGraphLayout(g Graph) {
19 | for step := 0; step < l.MaxSteps; step++ {
20 | f := make(map[uint64][2]float64, len(g.Nodes))
21 |
22 | // accumulate all forces
23 | for i := range l.Forces {
24 | l.Forces[i].UpdateForce(g, f)
25 | }
26 |
27 | // delete tiny forces
28 | for i := range g.Nodes {
29 | if math.Hypot(f[i][0], f[i][1]) < l.Epsilon {
30 | delete(f, i)
31 | }
32 | }
33 |
34 | // early stop if no forces
35 | if len(f) == 0 {
36 | break
37 | }
38 |
39 | // move by delta
40 | for i := range g.Nodes {
41 | x := g.Nodes[i].X + int((f[i][0] * l.Delta))
42 | y := g.Nodes[i].Y + int((f[i][1] * l.Delta))
43 | g.Nodes[i] = Node{
44 | Position: Position{X: x, Y: y},
45 | W: g.Nodes[i].W,
46 | H: g.Nodes[i].H,
47 | }
48 | }
49 | }
50 | }
51 |
52 | // SpringForce is linear by distance.
53 | type SpringForce struct {
54 | K float64 // has to be positive
55 | L float64 // distance at rest
56 | EdgesOnly bool // true = only edges, false = all nodes
57 | }
58 |
59 | func (l SpringForce) UpdateForce(g Graph, f map[uint64][2]float64) {
60 | for i := range g.Nodes {
61 | var js []uint64
62 |
63 | if l.EdgesOnly {
64 | for e := range g.Edges {
65 | if e[0] == i {
66 | js = append(js, e[1])
67 | }
68 | }
69 | } else {
70 | for j := range g.Nodes {
71 | if i != j {
72 | js = append(js, j)
73 | }
74 | }
75 | }
76 |
77 | xi := float64(g.Nodes[i].X)
78 | yi := float64(g.Nodes[i].Y)
79 |
80 | for _, j := range js {
81 | xj := float64(g.Nodes[j].X)
82 | yj := float64(g.Nodes[j].Y)
83 |
84 | d := math.Hypot(xi-xj, yi-yj)
85 |
86 | if d > 1 {
87 | // if stretch, then attraction
88 | // if shrink, then repulsion
89 | af := (d - l.L) * l.K
90 | f[i] = [2]float64{
91 | f[i][0] + (af * (xj - xi) / d),
92 | f[i][1] + (af * (yj - yi) / d),
93 | }
94 | }
95 | }
96 | }
97 | }
98 |
99 | // GravityForce is gravity-like repulsive (or attractive) force.
100 | type GravityForce struct {
101 | K float64 // positive K for attraction
102 | EdgesOnly bool // true = only edges, false = all nodes
103 | }
104 |
105 | func (l GravityForce) UpdateForce(g Graph, f map[uint64][2]float64) {
106 | for i := range g.Nodes {
107 | var js []uint64
108 | if l.EdgesOnly {
109 | for e := range g.Edges {
110 | if e[0] == i {
111 | js = append(js, e[1])
112 | }
113 | }
114 | } else {
115 | for j := range g.Nodes {
116 | if i != j {
117 | js = append(js, j)
118 | }
119 | }
120 | }
121 |
122 | xi := float64(g.Nodes[i].X)
123 | yi := float64(g.Nodes[i].Y)
124 |
125 | for _, j := range js {
126 | xj := float64(g.Nodes[j].X)
127 | yj := float64(g.Nodes[j].Y)
128 |
129 | d := math.Hypot(xi-xj, yi-yj)
130 |
131 | if d > 1 {
132 | af := l.K / d
133 | f[i] = [2]float64{
134 | f[i][0] + (af * (xj - xi) / d),
135 | f[i][1] + (af * (yj - yi) / d),
136 | }
137 | }
138 | }
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/layout/gonum_layout.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | import (
4 | "log"
5 | "math"
6 |
7 | gnlayout "gonum.org/v1/gonum/graph/layout"
8 | gnsimple "gonum.org/v1/gonum/graph/simple"
9 | gnr2 "gonum.org/v1/gonum/spatial/r2"
10 | )
11 |
12 | func gonumNodeID(id uint64) int64 {
13 | return int64(float64(id))
14 | }
15 |
16 | func toGonumGraph(g Graph) *gnsimple.UndirectedGraph {
17 | gn := gnsimple.NewUndirectedGraph()
18 | for e := range g.Edges {
19 | gn.SetEdge(gn.NewEdge(gnsimple.Node(gonumNodeID(e[0])), gnsimple.Node(gonumNodeID(e[1]))))
20 | }
21 | return gn
22 | }
23 |
24 | type gnLayoutGetter interface {
25 | Coord2(id int64) gnr2.Vec
26 | }
27 |
28 | // will make dimension such that all nodes data fits into square of the same area
29 | // this is for pretty layouts.
30 | func getSquareLayoutSize(g Graph) float64 {
31 | s := g.TotalNodesWidth() * g.TotalNodesHeight()
32 | return math.Sqrt(float64(s))
33 | }
34 |
35 | func updateGraphByGonumLayout(g Graph, gnLayout gnLayoutGetter, scaleX float64, scaleY float64) {
36 | // get width and height of gonum layout
37 | gnw := 0.0
38 | gnh := 0.0
39 | for i := range g.Nodes {
40 | n := gnLayout.Coord2(gonumNodeID(i))
41 |
42 | if n.X > gnw {
43 | gnw = n.X
44 | }
45 | if n.Y > gnh {
46 | gnh = n.Y
47 | }
48 | }
49 |
50 | // get width and height of our expected layout
51 | w := getSquareLayoutSize(g) * scaleX
52 | h := w * scaleY
53 | log.Printf("update gonum layout: gonum layout(%f x %f) our layout (%f x %f)", gnw, gnh, w, h)
54 |
55 | // update our coodinates and scale
56 | for nodeID := range g.Nodes {
57 | gnNode := gnLayout.Coord2(gonumNodeID(nodeID))
58 |
59 | g.Nodes[nodeID] = Node{
60 | Position: Position{
61 | X: int(gnNode.X * w / gnw),
62 | Y: int(gnNode.Y * h / gnh),
63 | },
64 | W: g.Nodes[nodeID].W,
65 | H: g.Nodes[nodeID].H,
66 | }
67 | }
68 | }
69 |
70 | // This works, but not as pretty.
71 | type EadesGonumLayout struct {
72 | Updates int
73 | Repulsion float64
74 | Rate float64
75 | Theta float64
76 | ScaleX float64
77 | ScaleY float64
78 | }
79 |
80 | func (l EadesGonumLayout) UpdateGraphLayout(g Graph) {
81 | gn := toGonumGraph(g)
82 |
83 | eades := gnlayout.EadesR2{
84 | Updates: l.Updates,
85 | Repulsion: l.Repulsion,
86 | Rate: l.Rate,
87 | Theta: l.Theta,
88 | }
89 | optimizer := gnlayout.NewOptimizerR2(gn, eades.Update)
90 | for optimizer.Update() {
91 | }
92 |
93 | updateGraphByGonumLayout(g, optimizer, l.ScaleX, l.ScaleY)
94 | }
95 |
96 | type IsomapR2GonumLayout struct {
97 | Scale float64
98 | ScaleX float64
99 | ScaleY float64
100 | }
101 |
102 | func (l IsomapR2GonumLayout) UpdateGraphLayout(g Graph) {
103 | gn := toGonumGraph(g)
104 | optimizer := gnlayout.NewOptimizerR2(gn, gnlayout.IsomapR2{}.Update)
105 | for optimizer.Update() {
106 | }
107 | updateGraphByGonumLayout(g, optimizer, l.ScaleX, l.ScaleY)
108 | }
109 |
--------------------------------------------------------------------------------
/layout/graph.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | type NodeID = uint64
4 |
5 | type Position struct {
6 | X int
7 | Y int
8 | }
9 |
10 | // Graph tells how to position nodes and paths for edges
11 | type Graph struct {
12 | Edges map[[2]NodeID]Edge
13 | Nodes map[NodeID]Node
14 | }
15 |
16 | // Node is how to position node and its dimensions
17 | type Node struct {
18 | Position
19 | W int
20 | H int
21 | }
22 |
23 | func (n Node) CenterXY() Position {
24 | x := n.X + n.W/2
25 | y := n.Y + n.H/2
26 | return Position{x, y}
27 | }
28 |
29 | // Edge is path of points that edge goes through
30 | type Edge struct {
31 | Path []Position // [start: {x,y}, ... finish: {x,y}]
32 | }
33 |
34 | func (g Graph) Copy() Graph {
35 | ng := Graph{
36 | Nodes: make(map[NodeID]Node, len(g.Nodes)),
37 | Edges: make(map[[2]NodeID]Edge, len(g.Edges)),
38 | }
39 | for id, n := range g.Nodes {
40 | ng.Nodes[id] = n
41 | }
42 | for id, e := range g.Edges {
43 | ng.Edges[id] = Edge{Path: make([]Position, len(e.Path))}
44 | copy(ng.Edges[id].Path, e.Path)
45 | }
46 | return ng
47 | }
48 |
49 | func (g Graph) Roots() []NodeID {
50 | hasParent := make(map[NodeID]bool, len(g.Nodes))
51 | for e := range g.Edges {
52 | hasParent[e[1]] = true
53 | }
54 |
55 | var roots []NodeID
56 | for n := range g.Nodes {
57 | if !hasParent[n] {
58 | roots = append(roots, n)
59 | }
60 | }
61 | return roots
62 | }
63 |
64 | func (g Graph) TotalNodesWidth() int {
65 | w := 0
66 | for _, node := range g.Nodes {
67 | w += node.W
68 | }
69 | return w
70 | }
71 |
72 | func (g Graph) TotalNodesHeight() int {
73 | h := 0
74 | for _, node := range g.Nodes {
75 | h += node.H
76 | }
77 | return h
78 | }
79 |
80 | // BoundingBox coordinates that should fit whole graph.
81 | // Does not consider edges.
82 | func (g Graph) BoundingBox() (minx, miny, maxx, maxy int) {
83 | for _, node := range g.Nodes {
84 | nx := node.X
85 | ny := node.Y
86 |
87 | if nx < minx {
88 | minx = nx
89 | }
90 | if x := nx + node.W; x > maxx {
91 | maxx = x
92 | }
93 | if ny < miny {
94 | miny = ny
95 | }
96 | if y := ny + node.H; y > maxy {
97 | maxy = y
98 | }
99 | }
100 | return minx, miny, maxx, maxy
101 | }
102 |
--------------------------------------------------------------------------------
/layout/layered_graph.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 | "strings"
7 | )
8 |
9 | type LayerPosition struct {
10 | Layer int // Layer index: is the root
11 | Order int // Order in the layer
12 | }
13 |
14 | func (p LayerPosition) IsLeftOf(other LayerPosition) bool {
15 | if p.Layer != other.Layer {
16 | panic(fmt.Sprintf("positions not on same layer: %+v < %+v", p, other))
17 | }
18 |
19 | return p.Order < other.Order
20 | }
21 |
22 | // LayeredGraph is graph with dummy nodes such that there is no long edges.
23 | // Short edge is between nodes in Layers next to each other.
24 | // Long edge is between nodes in 1+ Layers between each other.
25 | // Segment is either a short edge or a long edge.
26 | // Top layer has lowest layer number.
27 | type LayeredGraph struct {
28 | Segments map[[2]uint64]bool // segment is an edge in layered graph, can be real edge or piece of fake edge
29 | Dummy map[uint64]bool // fake nodes
30 | NodePosition map[uint64]LayerPosition // node -> {layer, ordering in layer}
31 | Edges map[[2]uint64][]uint64 // real long/short edge -> {real, fake, fake, fake, real} nodes
32 | }
33 |
34 | func (g LayeredGraph) Layers() [][]uint64 {
35 | maxLayer := 0
36 | for _, position := range g.NodePosition {
37 | if position.Layer > maxLayer {
38 | maxLayer = position.Layer
39 | }
40 | }
41 |
42 | layers := make([][]uint64, maxLayer+1)
43 | for node, position := range g.NodePosition {
44 | layers[position.Layer] = append(layers[position.Layer], node)
45 | }
46 |
47 | for layerIdx := 0; layerIdx < len(layers); layerIdx++ {
48 | sort.Slice(layers[layerIdx], func(i, j int) bool {
49 | return g.NodePosition[layers[layerIdx][i]].IsLeftOf(g.NodePosition[layers[layerIdx][j]])
50 | })
51 | }
52 |
53 | return layers
54 | }
55 |
56 | func (g LayeredGraph) Validate() error {
57 | for e := range g.Segments {
58 | from := g.NodePosition[e[0]].Layer
59 | to := g.NodePosition[e[1]].Layer
60 | if from >= to {
61 | return fmt.Errorf("edge(%v) is wrong direction, got from level(%d) to level(%d)", e, from, to)
62 | }
63 | }
64 | return nil
65 | }
66 |
67 | func (g LayeredGraph) String() string {
68 | out := ""
69 |
70 | out += fmt.Sprintf("fake nodes: %+v\n", g.Dummy)
71 |
72 | segments := []string{}
73 | for e := range g.Segments {
74 | segments = append(segments, fmt.Sprintf("%d->%d", e[0], e[1]))
75 | }
76 | sort.Strings(segments)
77 | out += fmt.Sprintf("segments: %s\n", strings.Join(segments, " "))
78 |
79 | layers := g.Layers()
80 | for l, nodes := range layers {
81 | vs := ""
82 | for _, node := range nodes {
83 | vs += fmt.Sprintf(" %d", node)
84 | }
85 | out += fmt.Sprintf("%d: %s\n", l, vs)
86 | }
87 | return out
88 | }
89 |
90 | // IsInnerSegment tells when edge is between two Dummy nodes.
91 | func (g LayeredGraph) IsInnerSegment(segment [2]uint64) bool {
92 | return g.Dummy[segment[0]] && g.Dummy[segment[1]]
93 | }
94 |
95 | // UpperNeighbors are nodes in upper layer that are connected to given node.
96 | func (g LayeredGraph) UpperNeighbors(node uint64) []uint64 {
97 | var nodes []uint64
98 | for e := range g.Segments {
99 | if e[1] == node {
100 | if g.NodePosition[e[0]].Layer == (g.NodePosition[e[1]].Layer - 1) {
101 | nodes = append(nodes, e[0])
102 | }
103 | }
104 | }
105 | return nodes
106 | }
107 |
108 | // LowerNeighbors are nodes in lower layer that are connected to given node.
109 | func (g LayeredGraph) LowerNeighbors(node uint64) []uint64 {
110 | var nodes []uint64
111 | for e := range g.Segments {
112 | if e[0] == node {
113 | if g.NodePosition[e[0]].Layer == (g.NodePosition[e[1]].Layer - 1) {
114 | nodes = append(nodes, e[0])
115 | }
116 | }
117 | }
118 | return nodes
119 | }
120 |
121 | // newLayersFrom makes new layers with content identical to source.
122 | func newLayersFrom(src [][]uint64) (dst [][]uint64) {
123 | dst = make([][]uint64, len(src))
124 | for i, l := range src {
125 | dst[i] = make([]uint64, len(l))
126 | copy(dst[i], l)
127 | }
128 | return dst
129 | }
130 |
131 | // copyLayers copies from src to destination
132 | func copyLayers(dst, src [][]uint64) {
133 | for i := range src {
134 | copy(dst[i], src[i])
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/layout/layers_edgepath_assigner.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | import (
4 | "fmt"
5 | )
6 |
7 | // StraightEdgePathAssigner will check node locations for each fake/real node in path and set edge path to go through middle of it.
8 | type StraightEdgePathAssigner struct{}
9 |
10 | func (l StraightEdgePathAssigner) UpdateGraphLayout(g Graph, lg LayeredGraph, allNodesXY map[uint64]Position) {
11 | numAssignedEdges := 0
12 | for e, nodes := range lg.Edges {
13 | if _, ok := g.Edges[e]; !ok {
14 | panic(fmt.Errorf("layered graph edge(%v) is not found in the original graph", e))
15 | }
16 |
17 | path := make([]Position, len(nodes))
18 | for i, n := range nodes {
19 | path[i] = allNodesXY[n]
20 | }
21 |
22 | g.Edges[e] = Edge{Path: path}
23 | numAssignedEdges++
24 | }
25 |
26 | if numAssignedEdges != len(g.Edges) {
27 | panic(fmt.Errorf("layered graph has wrong number of edges(%d) vs graph num edges (%d)", numAssignedEdges, len(g.Edges)))
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/layout/layers_layers_assigner.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | import "fmt"
4 |
5 | // Expects that graph g does not have cycles.
6 | // This step creates fake nodes and splits long edges into segments.
7 | func NewLayeredGraph(g Graph) LayeredGraph {
8 | positions := assignLevels(g)
9 | edges := makeEdges(g, positions)
10 | return LayeredGraph{
11 | NodePosition: positions,
12 | Segments: makeSegments(edges),
13 | Dummy: makeDummy(edges),
14 | Edges: edges,
15 | }
16 | }
17 |
18 | func maxNodeID(g Graph) uint64 {
19 | var maxNodeID uint64
20 | for e := range g.Edges {
21 | if e[0] > maxNodeID {
22 | maxNodeID = e[0]
23 | }
24 | if e[1] > maxNodeID {
25 | maxNodeID = e[1]
26 | }
27 | }
28 | return maxNodeID
29 | }
30 |
31 | func assignLevels(g Graph) map[uint64]LayerPosition {
32 | nodeYX := make(map[uint64]LayerPosition, len(g.Nodes))
33 | neighbors := make(map[uint64][]uint64)
34 | for e := range g.Edges {
35 | neighbors[e[0]] = append(neighbors[e[0]], e[1])
36 | }
37 | for _, root := range g.Roots() {
38 | nodeYX[root] = LayerPosition{}
39 | for que := []uint64{root}; len(que) > 0; {
40 | // pop
41 | p := que[0]
42 | if len(que) > 1 {
43 | que = que[1:]
44 | } else {
45 | que = nil
46 | }
47 |
48 | // set max depth for each child
49 | for _, child := range neighbors[p] {
50 | if l := nodeYX[p].Layer + 1; l > nodeYX[child].Layer {
51 | nodeYX[child] = LayerPosition{Layer: l, Order: 0}
52 | }
53 | que = append(que, child)
54 | }
55 | }
56 | }
57 | return nodeYX
58 | }
59 |
60 | // for each long edge breaks it down to multiple segments, for short edge just adds it
61 | func makeSegments(edges map[[2]uint64][]uint64) map[[2]uint64]bool {
62 | segments := map[[2]uint64]bool{}
63 | for e, nodes := range edges {
64 | switch {
65 | case len(nodes) == 2:
66 | segments[e] = true
67 | case len(nodes) > 2:
68 | for i := range nodes {
69 | if i == 0 {
70 | continue
71 | }
72 | segments[[2]uint64{nodes[i-1], nodes[i]}] = true
73 | }
74 | default:
75 | panic(fmt.Errorf("edge(%v) has only one node(%v) but at least 2 expected", e, nodes))
76 | }
77 | }
78 | return segments
79 | }
80 |
81 | // extracts all fake nodes for edges that are long into separate map
82 | func makeDummy(edges map[[2]uint64][]uint64) map[uint64]bool {
83 | dummy := map[uint64]bool{}
84 | for _, nodes := range edges {
85 | if len(nodes) > 2 {
86 | for i, n := range nodes {
87 | if i == 0 || i == (len(nodes)-1) {
88 | continue
89 | }
90 | dummy[n] = true
91 | }
92 | }
93 | }
94 | return dummy
95 | }
96 |
97 | // makeEdges split long edges into segments and add fake nodes
98 | // adds new fake nodes to nodeYX
99 | func makeEdges(g Graph, nodeYX map[uint64]LayerPosition) map[[2]uint64][]uint64 {
100 | edges := make(map[[2]uint64][]uint64, len(g.Edges))
101 |
102 | nextFakeNodeID := maxNodeID(g) + 1
103 | for e := range g.Edges {
104 | fromLayer := nodeYX[e[0]].Layer
105 | toLayer := nodeYX[e[1]].Layer
106 |
107 | newEdge := []uint64{}
108 | newEdge = append(newEdge, e[0])
109 |
110 | if (toLayer - fromLayer) > 1 {
111 | for layer := fromLayer + 1; layer < toLayer; layer++ {
112 | nodeYX[nextFakeNodeID] = LayerPosition{Layer: layer, Order: 0}
113 | newEdge = append(newEdge, nextFakeNodeID)
114 | nextFakeNodeID++
115 | }
116 | }
117 |
118 | newEdge = append(newEdge, e[1])
119 |
120 | edges[e] = newEdge
121 | }
122 |
123 | return edges
124 | }
125 |
--------------------------------------------------------------------------------
/layout/layers_layout.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | type CycleRemover interface {
4 | RemoveCycles(g Graph)
5 | Restore(g Graph)
6 | }
7 |
8 | type NodesHorizontalCoordinatesAssigner interface {
9 | NodesHorizontalCoordinates(g Graph, lg LayeredGraph) map[uint64]int
10 | }
11 |
12 | type NodesVerticalCoordinatesAssigner interface {
13 | NodesVerticalCoordinates(g Graph, lg LayeredGraph) map[uint64]int
14 | }
15 |
16 | // Kozo Sugiyama algorithm breaks down layered graph construction in phases.
17 | type SugiyamaLayersStrategyGraphLayout struct {
18 | CycleRemover CycleRemover
19 | LevelsAssigner func(g Graph) LayeredGraph
20 | OrderingAssigner func(g Graph, lg LayeredGraph)
21 | NodesHorizontalCoordinatesAssigner NodesHorizontalCoordinatesAssigner
22 | NodesVerticalCoordinatesAssigner NodesVerticalCoordinatesAssigner
23 | EdgePathAssigner func(g Graph, lg LayeredGraph, allNodesXY map[uint64]Position)
24 | }
25 |
26 | // UpdateGraphLayout breaks down layered graph construction in phases.
27 | func (l SugiyamaLayersStrategyGraphLayout) UpdateGraphLayout(g Graph) {
28 | l.CycleRemover.RemoveCycles(g)
29 |
30 | lg := l.LevelsAssigner(g)
31 | if err := lg.Validate(); err != nil {
32 | panic(err)
33 | }
34 |
35 | l.OrderingAssigner(g, lg)
36 |
37 | nodeX := l.NodesHorizontalCoordinatesAssigner.NodesHorizontalCoordinates(g, lg)
38 | nodeY := l.NodesVerticalCoordinatesAssigner.NodesVerticalCoordinates(g, lg)
39 |
40 | // real and fake node coordinates
41 | allNodesXY := make(map[uint64]Position, len(g.Nodes))
42 | for n := range lg.NodePosition {
43 | allNodesXY[n] = Position{X: nodeX[n], Y: nodeY[n]}
44 | }
45 |
46 | // export coordinates for edges
47 | l.EdgePathAssigner(g, lg, allNodesXY)
48 |
49 | // export coordinates to real nodes
50 | for n, node := range g.Nodes {
51 | g.Nodes[n] = Node{
52 | Position: Position{
53 | X: nodeX[n] - node.W/2,
54 | Y: nodeY[n] - node.H/2,
55 | },
56 | W: node.W,
57 | H: node.H,
58 | }
59 | }
60 |
61 | l.CycleRemover.Restore(g)
62 | }
63 |
--------------------------------------------------------------------------------
/layout/layers_nodes_vertical_assigner.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | // BasicNodesVerticalCoordinatesAssigner will check maximum height in each layer.
4 | // It will keep each node vertically in the middle within each layer.
5 | type BasicNodesVerticalCoordinatesAssigner struct {
6 | MarginLayers int // distance between layers
7 | FakeNodeHeight int
8 | }
9 |
10 | func layersMaxHeights(g Graph, layers [][]uint64) []int {
11 | hmax := make([]int, len(layers))
12 | for i, nodes := range layers {
13 | for _, node := range nodes {
14 | if hmax[i] < g.Nodes[node].H {
15 | hmax[i] = g.Nodes[node].H
16 | }
17 | }
18 | }
19 | return hmax
20 | }
21 |
22 | func (s BasicNodesVerticalCoordinatesAssigner) NodesVerticalCoordinates(g Graph, lg LayeredGraph) map[uint64]int {
23 | nodeY := make(map[uint64]int, len(lg.NodePosition))
24 |
25 | layers := lg.Layers()
26 | layersHMax := layersMaxHeights(g, layers)
27 |
28 | yOffset := 0
29 | for i, nodes := range layers {
30 | for _, node := range nodes {
31 | // put in the middle vertically
32 | nodeY[node] = yOffset + (layersHMax[i]) / 2
33 | }
34 |
35 | // move to next layer
36 | yOffset += layersHMax[i] + s.MarginLayers
37 | }
38 |
39 | return nodeY
40 | }
41 |
--------------------------------------------------------------------------------
/layout/layers_ordering_assigner.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | import (
4 | "log"
5 | "math/rand"
6 | "sort"
7 | )
8 |
9 | type LayerOrderingInitializer interface {
10 | Init(segments map[[2]uint64]bool, layers [][]uint64)
11 | }
12 |
13 | type LayerOrderingOptimizer interface {
14 | Optimize(segments map[[2]uint64]bool, layers [][]uint64, idx int, downUp bool)
15 | }
16 |
17 | type CompositeLayerOrderingOptimizer struct {
18 | Optimizers []LayerOrderingOptimizer
19 | }
20 |
21 | func (o CompositeLayerOrderingOptimizer) Optimize(segments map[[2]uint64]bool, layers [][]uint64, idx int, downUp bool) {
22 | for _, q := range o.Optimizers {
23 | q.Optimize(segments, layers, idx, downUp)
24 | }
25 | }
26 |
27 | // WarfieldOrderingOptimizer is heuristic based strategy for ordering optimization.
28 | // Goes up and down number of iterations across all layers.
29 | // Considers upper and lower fixed and permutes ordering in layer.
30 | // Used in Graphviz/dot.
31 | type WarfieldOrderingOptimizer struct {
32 | Epochs int
33 | LayerOrderingInitializer LayerOrderingInitializer
34 | LayerOrderingOptimizer LayerOrderingOptimizer
35 | }
36 |
37 | func (o WarfieldOrderingOptimizer) Optimize(g Graph, lg LayeredGraph) {
38 | // layers is temporary layers
39 | layers := lg.Layers()
40 | o.LayerOrderingInitializer.Init(lg.Segments, layers)
41 |
42 | bestN := -1
43 | bestLayers := newLayersFrom(layers)
44 |
45 | for t := 0; t < o.Epochs; t++ {
46 | downUp := (t % 2) == 0
47 | for i := range layers {
48 | j := i
49 | if downUp {
50 | j = len(layers) - 1 - i
51 | }
52 | o.LayerOrderingOptimizer.Optimize(lg.Segments, layers, j, downUp)
53 | }
54 |
55 | N := numCrossings(lg.Segments, layers)
56 | if bestN < 0 || N < bestN {
57 | bestN = N
58 | copyLayers(bestLayers, layers)
59 | }
60 | log.Printf("warfield ordering optimizer:\t epoch(%d)\t best(%d)\t current(%d)\n", t, bestN, N)
61 | if N == 0 {
62 | break
63 | }
64 | }
65 |
66 | // store to graph
67 | for y, layer := range bestLayers {
68 | for x, node := range layer {
69 | lg.NodePosition[node] = LayerPosition{Layer: y, Order: x}
70 | }
71 | }
72 | }
73 |
74 | // BFSOrderingInitializer will set order in each layer by traversing BFS from roots.
75 | type BFSOrderingInitializer struct{}
76 |
77 | func (o BFSOrderingInitializer) Init(segments map[[2]uint64]bool, layers [][]uint64) {
78 | // accumulate where node can lead to
79 | fromNodeToNodes := map[uint64][]uint64{}
80 | for e := range segments {
81 | if _, ok := fromNodeToNodes[e[0]]; !ok {
82 | fromNodeToNodes[e[0]] = []uint64{}
83 | }
84 | fromNodeToNodes[e[0]] = append(fromNodeToNodes[e[0]], e[1])
85 | }
86 |
87 | // get roots
88 | hasParent := map[uint64]bool{}
89 | for e := range segments {
90 | hasParent[e[1]] = true
91 | }
92 | var roots []uint64
93 | for e := range segments {
94 | if _, ok := hasParent[e[1]]; !ok {
95 | roots = append(roots, e[1])
96 | }
97 | }
98 |
99 | // BFS starting with roots, keeping order when node is visited
100 | cnt := 0
101 | que := roots
102 | ord := map[uint64]int{}
103 | for len(que) > 0 {
104 | p := que[0]
105 | if len(que) > 1 {
106 | que = que[1:]
107 | } else {
108 | que = nil
109 | }
110 |
111 | if _, ok := ord[p]; ok {
112 | continue
113 | }
114 |
115 | ord[p] = cnt
116 | cnt++
117 |
118 | que = append(que, fromNodeToNodes[p]...)
119 | }
120 |
121 | for l := range layers {
122 | sort.Slice(layers[l], func(i, j int) bool { return ord[layers[l][i]] < ord[layers[l][j]] })
123 | }
124 | }
125 |
126 | // RandomLayerOrderingInitializer assigns random ordering in each layer
127 | type RandomLayerOrderingInitializer struct{}
128 |
129 | func (o RandomLayerOrderingInitializer) Init(_ map[[2]uint64]bool, layers [][]uint64) {
130 | for i := range layers {
131 | l := layers[i]
132 | rand.Shuffle(len(l), func(a, b int) { l[a], l[b] = l[b], l[a] })
133 | }
134 | }
135 |
136 | // WMedianOrderingOptimizer takes median of upper (or lower) level neighbors for each node in layer.
137 | // Median has property of stable vertical edges which is especially useful for "long" edges (fake nodes).
138 | // Eades and Wormald, 1994
139 | // This is used in dot/Graphviz, Figure 3-2 in Graphviz dot paper TSE93.
140 | type WMedianOrderingOptimizer struct{}
141 |
142 | func (o WMedianOrderingOptimizer) Optimize(segments map[[2]uint64]bool, layers [][]uint64, y int, downUp bool) {
143 | w := map[uint64]float64{}
144 |
145 | for i, node := range layers[y] {
146 | var xs []int
147 | if downUp {
148 | xs = lowerNeighborsX(segments, layers, i, y)
149 | } else {
150 | xs = upperNeighborsX(segments, layers, i, y)
151 | }
152 |
153 | P := make([]float64, len(xs))
154 | for i, v := range xs {
155 | P[i] = float64(v)
156 | }
157 | w[node] = median(P)
158 | }
159 |
160 | sort.Slice(layers[y], func(i, j int) bool { return w[layers[y][i]] < w[layers[y][j]] })
161 | }
162 |
163 | // time: O(len(layer))
164 | // space: O(len(layer))
165 | func lowerNeighborsX(segments map[[2]uint64]bool, layers [][]uint64, x int, y int) []int {
166 | if y == (len(layers) - 1) {
167 | return nil
168 | }
169 |
170 | t := layers[y][x]
171 |
172 | var nx []int
173 | for i, n := range layers[y+1] {
174 | if segments[[2]uint64{t, n}] {
175 | nx = append(nx, i)
176 | }
177 | }
178 |
179 | return nx
180 | }
181 |
182 | // time: O(len(layer))
183 | // space: O(len(layer))
184 | func upperNeighborsX(segments map[[2]uint64]bool, layers [][]uint64, x int, y int) []int {
185 | if y == 0 {
186 | return nil
187 | }
188 |
189 | t := layers[y][x]
190 |
191 | var nx []int
192 | for i, n := range layers[y-1] {
193 | if segments[[2]uint64{n, t}] {
194 | nx = append(nx, i)
195 | }
196 | }
197 |
198 | return nx
199 | }
200 |
201 | func median(P []float64) float64 {
202 | m := len(P) / 2
203 | switch {
204 | case len(P) == 0:
205 | return -1
206 | case len(P)%2 == 1:
207 | return P[m]
208 | case len(P) == 2:
209 | return (P[0] + P[1]) / 2
210 | default:
211 | left := P[m-1] - P[0]
212 | right := P[len(P)-1] - P[m]
213 | return (P[m-1]*right + P[m]*left) / (left + right)
214 | }
215 | }
216 |
217 | // SwitchAdjacentOrderingOptimizer will try swapping two adjacent nodes in a layer will improve crossings.
218 | // This is used in dot/Graphviz, Figure 3-3 in Graphviz dot paper TSE93 and called "transpose".
219 | type SwitchAdjacentOrderingOptimizer struct{}
220 |
221 | func (o SwitchAdjacentOrderingOptimizer) Optimize(segments map[[2]uint64]bool, layers [][]uint64, y int, downUp bool) {
222 | if len(layers[y]) < 2 {
223 | return
224 | }
225 |
226 | // does not have bellow
227 | if downUp && y == (len(layers)-1) {
228 | return
229 | }
230 |
231 | // does not have above
232 | if !downUp && y == 0 {
233 | return
234 | }
235 |
236 | for i := 0; i < (len(layers[y]) - 1); i++ {
237 | j := i + 1
238 |
239 | current := []uint64{layers[y][i], layers[y][j]}
240 | swapped := []uint64{layers[y][j], layers[y][i]}
241 | var currCrossings, swappedCrossings int
242 | if downUp {
243 | currCrossings = numCrossingsBetweenLayers(segments, current, layers[y+1])
244 | swappedCrossings = numCrossingsBetweenLayers(segments, swapped, layers[y+1])
245 | } else {
246 | currCrossings = numCrossingsBetweenLayers(segments, layers[y-1], current)
247 | swappedCrossings = numCrossingsBetweenLayers(segments, layers[y-1], swapped)
248 | }
249 |
250 | if swappedCrossings < currCrossings {
251 | layers[y][i], layers[y][j] = layers[y][j], layers[y][i]
252 | }
253 | }
254 | }
255 |
256 | // RandomLayerOrderingOptimizer picks best out of epochs random orderings.
257 | type RandomLayerOrderingOptimizer struct {
258 | Epochs int
259 | }
260 |
261 | func (o RandomLayerOrderingOptimizer) Optimize(segments map[[2]uint64]bool, layers [][]uint64, idx int, downUp bool) {
262 | bestN := -1
263 | layer := make([]uint64, len(layers[idx]))
264 | copy(layer, layers[idx])
265 |
266 | for i := 0; i < o.Epochs; i++ {
267 | rand.Shuffle(len(layer), func(a, b int) { layer[a], layer[b] = layer[b], layer[a] })
268 |
269 | N := 0
270 | if idx > 0 {
271 | N += numCrossingsBetweenLayers(segments, layers[idx-1], layers[idx])
272 | }
273 | if (idx + 1) < len(layers) {
274 | N += numCrossingsBetweenLayers(segments, layers[idx], layers[idx+1])
275 | }
276 |
277 | if bestN < 0 || N < bestN {
278 | bestN = N
279 | copy(layers[idx], layer)
280 | }
281 | }
282 | }
283 |
284 | // time: O(ntop * nbot * log(ntop))
285 | // memory: O(ntop)
286 | func numCrossingsBetweenLayers(segments map[[2]uint64]bool, ltop, lbottom []uint64) int {
287 | sum := 0
288 | bit := NewFenwickTree(len(ltop))
289 | for i := len(lbottom) - 1; i >= 0; i-- {
290 | node := lbottom[i]
291 | for j := len(ltop) - 1; j >= 0; j-- {
292 | neighbor := ltop[j]
293 | if segments[[2]uint64{neighbor, node}] {
294 | bit.Update(j+1, 1)
295 | sum += bit.Query(j)
296 | }
297 | }
298 | }
299 | return sum
300 | }
301 |
302 | type FenwickTree []int
303 |
304 | func NewFenwickTree(nbElements int) FenwickTree {
305 | return make(FenwickTree, nbElements+1)
306 | }
307 |
308 | func (bit FenwickTree) Update(idx int, value int) {
309 | for ; idx < len(bit); idx += idx & (-idx) {
310 | bit[idx] += value
311 | }
312 | }
313 |
314 | func (bit FenwickTree) Query(idx int) int {
315 | sum := 0
316 | for ; idx > 0; idx -= idx & (-idx) {
317 | sum += bit[idx]
318 | }
319 | return sum
320 | }
321 |
322 | // time: O(?)
323 | // memory: O(1)
324 | func numCrossings(segments map[[2]uint64]bool, layers [][]uint64) int {
325 | count := 0
326 | for i := range layers {
327 | if i == 0 {
328 | continue
329 | }
330 | count += numCrossingsBetweenLayers(segments, layers[i-1], layers[i])
331 | }
332 | return count
333 | }
334 |
--------------------------------------------------------------------------------
/layout/layout.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | // Layout is something that can update graph layout
4 | type Layout interface {
5 | UpdateGraphLayout(g Graph)
6 | }
7 |
8 | // SequenceLayout applies sequence of layouts
9 | type SequenceLayout struct {
10 | Layouts []Layout
11 | }
12 |
13 | func (s SequenceLayout) UpdateGraphLayout(g Graph) {
14 | for _, l := range s.Layouts {
15 | l.UpdateGraphLayout(g)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/layout/layout_test.go:
--------------------------------------------------------------------------------
1 | package layout_test
2 |
3 | import (
4 | _ "embed"
5 | "fmt"
6 | "os"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/nikolaydubina/jsonl-graph/graph"
11 |
12 | "github.com/gverger/go-graph-layout/layout"
13 | "github.com/gverger/go-graph-layout/svg"
14 | )
15 |
16 | func parseJSONLGraph(in string) (*graph.Graph, *layout.Graph, error) {
17 | gd, err := graph.NewGraphFromJSONL(strings.NewReader(in))
18 | if err != nil {
19 | return nil, nil, err
20 | }
21 |
22 | gl := layout.Graph{
23 | Nodes: make(map[uint64]layout.Node),
24 | Edges: make(map[[2]uint64]layout.Edge),
25 | }
26 |
27 | for id, node := range gd.Nodes {
28 | // compute w and h for nodes, since width and height of node depends on content
29 | rnodeData := node
30 | rnode := svg.Node{
31 | Title: node.ID(),
32 | NodeData: rnodeData,
33 | }
34 | gl.Nodes[id] = layout.Node{W: rnode.Width(), H: rnode.Height()}
35 | }
36 |
37 | for e := range gd.Edges {
38 | gl.Edges[e] = layout.Edge{}
39 | }
40 |
41 | return &gd, &gl, nil
42 | }
43 |
44 | func writeSVG(gd graph.Graph, gl layout.Graph) string {
45 | graph := svg.Graph{
46 | ID: "graph-root",
47 | Nodes: map[uint64]svg.Node{},
48 | Edges: map[[2]uint64]svg.Edge{},
49 | }
50 |
51 | for id, node := range gd.Nodes {
52 | graph.Nodes[id] = svg.Node{
53 | ID: fmt.Sprintf("%d", id),
54 | X: gl.Nodes[id].X,
55 | Y: gl.Nodes[id].Y,
56 | Title: node.ID(),
57 | NodeData: node,
58 | }
59 | }
60 |
61 | for e, edata := range gl.Edges {
62 | path := make([][2]int, 0, len(edata.Path))
63 | for _, p := range edata.Path {
64 | path = append(path, [2]int{p.X, p.Y})
65 | }
66 | graph.Edges[e] = svg.Edge{
67 | Path: path,
68 | }
69 | }
70 |
71 | svgContainer := svg.SVG{
72 | ID: "svg-root",
73 | Definitions: []svg.Renderable{},
74 | Body: graph,
75 | }
76 | return svgContainer.Render()
77 | }
78 |
79 | //go:embed testdata/gin.jsonl
80 | var ginJSONL string
81 |
82 | //go:embed testdata/small.jsonl
83 | var smallJSONL string
84 |
85 | //go:embed testdata/brandeskopf.jsonl
86 | var brandeskopfJSONL string
87 |
88 | func TestE2E(t *testing.T) {
89 | inputJSONLGraphs := []struct {
90 | name string
91 | inputJSONLGraph string
92 | }{
93 | {
94 | name: "gin",
95 | inputJSONLGraph: ginJSONL,
96 | },
97 | {
98 | name: "small",
99 | inputJSONLGraph: smallJSONL,
100 | },
101 | {
102 | name: "brandeskopf",
103 | inputJSONLGraph: brandeskopfJSONL,
104 | },
105 | }
106 |
107 | layouts := []struct {
108 | name string
109 | l layout.Layout
110 | }{
111 | {
112 | name: "forces",
113 | l: layout.SequenceLayout{
114 | Layouts: []layout.Layout{
115 | layout.ForceGraphLayout{
116 | Delta: 1,
117 | MaxSteps: 5000,
118 | Epsilon: 1.5,
119 | Forces: []layout.Force{
120 | layout.GravityForce{
121 | K: -50,
122 | EdgesOnly: false,
123 | },
124 | layout.SpringForce{
125 | K: 0.2,
126 | L: 200,
127 | EdgesOnly: true,
128 | },
129 | },
130 | },
131 | layout.DirectEdgesLayout{},
132 | },
133 | },
134 | },
135 | {
136 | name: "eades",
137 | l: layout.SequenceLayout{
138 | Layouts: []layout.Layout{
139 | layout.EadesGonumLayout{
140 | Repulsion: 1,
141 | Rate: 0.05,
142 | Updates: 30,
143 | Theta: 0.2,
144 | ScaleX: 0.5,
145 | ScaleY: 0.5,
146 | },
147 | layout.DirectEdgesLayout{},
148 | },
149 | },
150 | },
151 | {
152 | name: "isomap",
153 | l: layout.SequenceLayout{
154 | Layouts: []layout.Layout{
155 | layout.IsomapR2GonumLayout{
156 | ScaleX: 0.5,
157 | ScaleY: 0.5,
158 | },
159 | layout.DirectEdgesLayout{},
160 | },
161 | },
162 | },
163 | {
164 | name: "layers",
165 | l: layout.SugiyamaLayersStrategyGraphLayout{
166 | CycleRemover: layout.NewSimpleCycleRemover(),
167 | LevelsAssigner: layout.NewLayeredGraph,
168 | OrderingAssigner: layout.WarfieldOrderingOptimizer{
169 | Epochs: 100,
170 | LayerOrderingInitializer: layout.BFSOrderingInitializer{},
171 | LayerOrderingOptimizer: layout.CompositeLayerOrderingOptimizer{
172 | Optimizers: []layout.LayerOrderingOptimizer{
173 | layout.WMedianOrderingOptimizer{},
174 | layout.SwitchAdjacentOrderingOptimizer{},
175 | },
176 | },
177 | }.Optimize,
178 | NodesHorizontalCoordinatesAssigner: layout.BrandesKopfLayersNodesHorizontalAssigner{
179 | Delta: 25,
180 | },
181 | NodesVerticalCoordinatesAssigner: layout.BasicNodesVerticalCoordinatesAssigner{
182 | MarginLayers: 25,
183 | FakeNodeHeight: 25,
184 | },
185 | EdgePathAssigner: layout.StraightEdgePathAssigner{}.UpdateGraphLayout,
186 | },
187 | },
188 | }
189 | for _, inputJSONLGraph := range inputJSONLGraphs {
190 | for _, l := range layouts {
191 | name := fmt.Sprintf("testdata/%s_%s.svg", inputJSONLGraph.name, l.name)
192 | t.Run(name, func(t *testing.T) {
193 | gd, gl, err := parseJSONLGraph(inputJSONLGraph.inputJSONLGraph)
194 | if err != nil {
195 | t.Error(err)
196 | }
197 |
198 | l.l.UpdateGraphLayout(*gl)
199 | svgResult := writeSVG(*gd, *gl)
200 |
201 | outputfile, err := os.Create(name)
202 | if err != nil {
203 | t.Error(err)
204 | }
205 |
206 | if _, err := outputfile.WriteString(svgResult); err != nil {
207 | t.Error(err)
208 | }
209 | if err := outputfile.Close(); err != nil {
210 | t.Error(err)
211 | }
212 | })
213 | }
214 | }
215 | }
216 |
--------------------------------------------------------------------------------
/layout/scaler_layout.go:
--------------------------------------------------------------------------------
1 | package layout
2 |
3 | // ScalerLayout will scale existing layout by constant factor.
4 | type ScalerLayout struct {
5 | Scale float64
6 | }
7 |
8 | func (l *ScalerLayout) UpdateGraphLayout(g Graph) {
9 | for i := range g.Nodes {
10 | x := float64(g.Nodes[i].X)
11 | y := float64(g.Nodes[i].Y)
12 |
13 | g.Nodes[i] = Node{
14 | Position: Position{
15 | X: int(x * l.Scale),
16 | Y: int(y * l.Scale),
17 | },
18 | W: g.Nodes[i].W,
19 | H: g.Nodes[i].H,
20 | }
21 | }
22 |
23 | // can not recompute edge layout as some paths are complex and not direct
24 | for e := range g.Edges {
25 | for p, pos := range g.Edges[e].Path {
26 | g.Edges[e].Path[p] = Position{
27 | X: int(float64(pos.X) * l.Scale),
28 | Y: int(float64(pos.Y) * l.Scale),
29 | }
30 | }
31 |
32 | // if edge was not previously set adding at least two nodes for start and end
33 | if len(g.Edges[e].Path) == 0 {
34 | g.Edges[e] = Edge{Path: make([]Position, 2)}
35 | }
36 |
37 | // end and start should use center coordinates of nodes
38 | // note, this overrites ports for edges
39 | g.Edges[e].Path[0] = g.Nodes[e[0]].CenterXY()
40 | g.Edges[e].Path[len(g.Edges[e].Path)-1] = g.Nodes[e[1]].CenterXY()
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/layout/testdata/brandeskopf.jsonl:
--------------------------------------------------------------------------------
1 | {"from": "1", "to": "13"}
2 | {"from": "1", "to": "21"}
3 | {"from": "1", "to": "4"}
4 | {"from": "1", "to": "3"}
5 | {"from": "2", "to": "3"}
6 | {"from": "2", "to": "20"}
7 | {"from": "3", "to": "4"}
8 | {"from": "3", "to": "5"}
9 | {"from": "3", "to": "23"}
10 | {"from": "4", "to": "6"}
11 | {"from": "5", "to": "7"}
12 | {"from": "6", "to": "8"}
13 | {"from": "6", "to": "16"}
14 | {"from": "6", "to": "23"}
15 | {"from": "7", "to": "9"}
16 | {"from": "8", "to": "10"}
17 | {"from": "8", "to": "11"}
18 | {"from": "9", "to": "12"}
19 | {"from": "10", "to": "13"}
20 | {"from": "10", "to": "14"}
21 | {"from": "10", "to": "15"}
22 | {"from": "11", "to": "15"}
23 | {"from": "11", "to": "16"}
24 | {"from": "12", "to": "20"}
25 | {"from": "13", "to": "17"}
26 | {"from": "14", "to": "17"}
27 | {"from": "14", "to": "18"}
28 | {"from": "16", "to": "18"}
29 | {"from": "16", "to": "19"}
30 | {"from": "16", "to": "20"}
31 | {"from": "18", "to": "21"}
32 | {"from": "19", "to": "22"}
33 | {"from": "21", "to": "23"}
34 | {"from": "22", "to": "23"}
35 |
--------------------------------------------------------------------------------
/layout/testdata/brandeskopf_eades.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/layout/testdata/brandeskopf_forces.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/layout/testdata/brandeskopf_isomap.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/layout/testdata/brandeskopf_layers.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/layout/testdata/gin.jsonl:
--------------------------------------------------------------------------------
1 | {"id":"github.com/gin-gonic/gin","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/gin-gonic/gin","git_url":"https://github.com/gin-gonic/gin","git_last_commit":"2021-04-21","git_last_commit_days_since":4,"git_num_contributors":321,"codecov_url":"https://app.codecov.io/gh/gin-gonic/gin","codecov_files":41,"codecov_lines":2036,"codecov_coverage":98.67,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":6,"gotest_num_packages_with_tests":4,"gotest_num_packages_tests_passed":4,"gotest_package_coverage_avg":98.9,"goreportcard_average":0.99,"goreportcard_grade":"A+","goreportcard_files":83,"goreportcard_issues":6,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true,"github_repo_stars":47520}
2 | {"id":"github.com/gin-contrib/sse","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/gin-contrib/sse","git_url":"https://github.com/gin-contrib/sse","git_last_commit":"2020-08-15","git_last_commit_days_since":252,"git_num_contributors":3,"codecov_url":"https://app.codecov.io/gh/gin-contrib/sse","codecov_files":3,"codecov_lines":107,"codecov_coverage":90.65,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":1,"gotest_num_packages_with_tests":1,"gotest_num_packages_tests_passed":1,"gotest_package_coverage_avg":94.1,"goreportcard_average":0.93,"goreportcard_grade":"A+","goreportcard_files":5,"goreportcard_issues":3,"files_has_benchmarks":true,"files_has_tests":true,"github_repo_stars":56}
3 | {"id":"github.com/go-playground/validator/v10","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/go-playground/validator","git_url":"https://github.com/go-playground/validator","git_last_commit":"2021-04-07","git_last_commit_days_since":18,"git_num_contributors":116,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":14,"gotest_num_packages_with_tests":14,"gotest_num_packages_tests_passed":14,"gotest_package_coverage_avg":73.70714285714287,"goreportcard_average":0.87,"goreportcard_grade":"A","goreportcard_files":48,"goreportcard_issues":28,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true,"github_repo_stars":7569}
4 | {"id":"github.com/golang/protobuf","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/golang/protobuf","git_url":"https://github.com/golang/protobuf","git_last_commit":"2021-03-30","git_last_commit_days_since":26,"git_num_contributors":96,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":21,"gotest_num_packages_with_tests":5,"gotest_num_packages_tests_passed":5,"gotest_package_coverage_avg":79.8,"goreportcard_average":0.92,"goreportcard_grade":"A+","goreportcard_files":39,"goreportcard_issues":16,"files_has_benchmarks":false,"files_has_tests":true,"readme_deprecated":true,"awesomelists_is_mentioned":true,"github_repo_stars":7608}
5 | {"id":"github.com/json-iterator/go","can_get_git":true,"can_run_tests":false,"can_get_github":true,"github_url":"https://github.com/json-iterator/go","git_url":"https://github.com/json-iterator/go","git_last_commit":"2020-11-18","git_last_commit_days_since":158,"git_num_contributors":55,"codecov_url":"https://app.codecov.io/gh/json-iterator/go","codecov_files":41,"codecov_lines":5115,"codecov_coverage":86.37,"goreportcard_average":0.95,"goreportcard_grade":"A+","goreportcard_files":127,"goreportcard_issues":47,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true,"github_repo_stars":9204}
6 | {"id":"github.com/mattn/go-isatty","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/mattn/go-isatty","git_url":"https://github.com/mattn/go-isatty","git_last_commit":"2021-01-18","git_last_commit_days_since":96,"git_num_contributors":23,"codecov_url":"https://app.codecov.io/gh/mattn/go-isatty","codecov_files":1,"codecov_lines":3,"codecov_coverage":100,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":1,"gotest_num_packages_with_tests":1,"gotest_num_packages_tests_passed":1,"gotest_package_coverage_avg":100,"goreportcard_average":1.00,"goreportcard_grade":"A+","goreportcard_files":10,"goreportcard_issues":1,"files_has_benchmarks":false,"files_has_tests":true,"awesomelists_is_mentioned":true,"github_repo_stars":511}
7 | {"id":"github.com/stretchr/testify","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/stretchr/testify","git_url":"https://github.com/stretchr/testify","git_last_commit":"2020-11-09","git_last_commit_days_since":167,"git_num_contributors":217,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":6,"gotest_num_packages_with_tests":5,"gotest_num_packages_tests_passed":5,"gotest_package_coverage_avg":54.260000000000005,"goreportcard_average":0.98,"goreportcard_grade":"A+","goreportcard_files":36,"goreportcard_issues":6,"files_has_benchmarks":true,"files_has_tests":true,"readme_deprecated":true,"awesomelists_is_mentioned":true,"github_repo_stars":13106}
8 | {"id":"github.com/ugorji/go/codec","can_get_git":true,"can_run_tests":false,"can_get_github":true,"github_url":"https://github.com/ugorji/go","git_url":"https://github.com/ugorji/go","git_last_commit":"2021-04-19","git_last_commit_days_since":5,"git_num_contributors":13,"codecov_url":"https://app.codecov.io/gh/ugorji/go","codecov_files":29,"codecov_lines":11645,"codecov_coverage":90.06,"goreportcard_average":0.93,"goreportcard_grade":"A+","goreportcard_files":69,"goreportcard_issues":24,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true,"github_repo_stars":1532}
9 | {"id":"gopkg.in/yaml.v2","can_get_git":true,"can_run_tests":false,"can_get_github":false,"git_url":"https://gopkg.in/yaml.v2","git_last_commit":"2020-11-17","git_last_commit_days_since":158,"git_num_contributors":47,"goreportcard_average":0.75,"goreportcard_grade":"B","goreportcard_files":18,"goreportcard_issues":15,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true}
10 | {"id":"github.com/go-playground/assert/v2","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/go-playground/assert","git_url":"https://github.com/go-playground/assert","git_last_commit":"2019-10-18","git_last_commit_days_since":555,"git_num_contributors":4,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":1,"gotest_num_packages_with_tests":1,"gotest_num_packages_tests_passed":1,"gotest_package_coverage_avg":70.4,"goreportcard_average":1.00,"goreportcard_grade":"A+","goreportcard_files":3,"goreportcard_issues":0,"files_has_benchmarks":false,"files_has_tests":true,"awesomelists_is_mentioned":true,"github_repo_stars":32}
11 | {"id":"github.com/go-playground/locales","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/go-playground/locales","git_url":"https://github.com/go-playground/locales","git_last_commit":"2020-09-28","git_last_commit_days_since":209,"git_num_contributors":10,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":739,"gotest_num_packages_with_tests":736,"gotest_num_packages_tests_passed":736,"gotest_package_coverage_avg":8.753668478260886,"goreportcard_average":0.46,"goreportcard_grade":"E","goreportcard_files":1477,"goreportcard_issues":924,"files_has_benchmarks":true,"files_has_tests":true,"github_repo_stars":174}
12 | {"id":"github.com/go-playground/universal-translator","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/go-playground/universal-translator","git_url":"https://github.com/go-playground/universal-translator","git_last_commit":"2019-11-12","git_last_commit_days_since":530,"git_num_contributors":7,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":1,"gotest_num_packages_with_tests":1,"gotest_num_packages_tests_passed":1,"gotest_package_coverage_avg":99,"goreportcard_average":0.95,"goreportcard_grade":"A+","goreportcard_files":10,"goreportcard_issues":4,"files_has_benchmarks":true,"files_has_tests":true,"github_repo_stars":205}
13 | {"id":"github.com/leodido/go-urn","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/leodido/go-urn","git_url":"https://github.com/leodido/go-urn","git_last_commit":"2020-12-14","git_last_commit_days_since":132,"git_num_contributors":3,"codecov_url":"https://app.codecov.io/gh/leodido/go-urn","codecov_files":1,"codecov_lines":34,"codecov_coverage":100,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":1,"gotest_num_packages_with_tests":1,"gotest_num_packages_tests_passed":1,"gotest_package_coverage_avg":45.5,"goreportcard_average":0.84,"goreportcard_grade":"A","goreportcard_files":6,"goreportcard_issues":3,"files_has_benchmarks":true,"files_has_tests":true,"github_repo_stars":32}
14 | {"id":"golang.org/x/crypto","can_get_git":true,"can_run_tests":true,"can_get_github":false,"git_url":"https://go.googlesource.com/crypto","git_last_commit":"2020-11-25","git_last_commit_days_since":151,"git_num_contributors":255,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":55,"gotest_num_packages_with_tests":48,"gotest_num_packages_tests_passed":48,"gotest_package_coverage_avg":82.68125,"goreportcard_average":0.96,"goreportcard_grade":"A+","goreportcard_files":324,"goreportcard_issues":84,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true}
15 | {"id":"github.com/davecgh/go-spew","can_get_git":true,"can_run_tests":false,"can_get_github":true,"github_url":"https://github.com/davecgh/go-spew","git_url":"https://github.com/davecgh/go-spew","git_last_commit":"2018-08-31","git_last_commit_days_since":968,"git_num_contributors":20,"goreportcard_average":0.98,"goreportcard_grade":"A+","goreportcard_files":17,"goreportcard_issues":4,"files_has_benchmarks":false,"files_has_tests":true,"awesomelists_is_mentioned":true,"github_repo_stars":4389}
16 | {"id":"github.com/google/gofuzz","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/google/gofuzz","git_url":"https://github.com/google/gofuzz","git_last_commit":"2021-01-06","git_last_commit_days_since":109,"git_num_contributors":19,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":2,"gotest_num_packages_with_tests":2,"gotest_num_packages_tests_passed":2,"gotest_package_coverage_avg":86.7,"goreportcard_average":0.94,"goreportcard_grade":"A+","goreportcard_files":6,"goreportcard_issues":2,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true,"github_repo_stars":1036}
17 | {"id":"github.com/modern-go/concurrent","can_get_git":true,"can_run_tests":false,"can_get_github":true,"github_url":"https://github.com/modern-go/concurrent","git_url":"https://github.com/modern-go/concurrent","git_last_commit":"2018-03-06","git_last_commit_days_since":1146,"git_num_contributors":2,"codecov_url":"https://app.codecov.io/gh/modern-go/concurrent","codecov_files":3,"codecov_lines":69,"codecov_coverage":79.71,"goreportcard_average":0.85,"goreportcard_grade":"A","goreportcard_files":7,"goreportcard_issues":3,"files_has_benchmarks":false,"files_has_tests":true,"github_repo_stars":187}
18 | {"id":"github.com/modern-go/reflect2","can_get_git":true,"can_run_tests":false,"can_get_github":true,"github_url":"https://github.com/modern-go/reflect2","git_url":"https://github.com/modern-go/reflect2","git_last_commit":"2021-01-09","git_last_commit_days_since":106,"git_num_contributors":7,"codecov_url":"https://app.codecov.io/gh/modern-go/reflect2","codecov_files":19,"codecov_lines":533,"codecov_coverage":65.29,"goreportcard_average":0.93,"goreportcard_grade":"A+","goreportcard_files":22,"goreportcard_issues":10,"files_has_benchmarks":false,"files_has_tests":false,"github_repo_stars":413}
19 | {"id":"golang.org/x/sys","can_get_git":true,"can_run_tests":true,"can_get_github":false,"git_url":"https://go.googlesource.com/sys","git_last_commit":"2021-04-20","git_last_commit_days_since":5,"git_num_contributors":212,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":5,"gotest_num_packages_with_tests":4,"gotest_num_packages_tests_passed":4,"gotest_package_coverage_avg":40.325,"goreportcard_average":0.99,"goreportcard_grade":"A+","goreportcard_files":381,"goreportcard_issues":23,"files_has_benchmarks":false,"files_has_tests":true,"awesomelists_is_mentioned":true}
20 | {"id":"github.com/pmezard/go-difflib","can_get_git":true,"can_run_tests":false,"can_get_github":true,"github_url":"https://github.com/pmezard/go-difflib","git_url":"https://github.com/pmezard/go-difflib","git_last_commit":"2018-12-26","git_last_commit_days_since":850,"git_num_contributors":6,"goreportcard_average":0.53,"goreportcard_grade":"D","goreportcard_files":2,"goreportcard_issues":2,"files_has_benchmarks":true,"files_has_tests":true,"readme_deprecated":true,"github_repo_stars":263}
21 | {"id":"github.com/stretchr/objx","can_get_git":true,"can_run_tests":true,"can_get_github":true,"github_url":"https://github.com/stretchr/objx","git_url":"https://github.com/stretchr/objx","git_last_commit":"2021-02-08","git_last_commit_days_since":75,"git_num_contributors":12,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":1,"gotest_num_packages_with_tests":1,"gotest_num_packages_tests_passed":1,"gotest_package_coverage_avg":99.1,"goreportcard_average":0.97,"goreportcard_grade":"A+","goreportcard_files":21,"goreportcard_issues":4,"files_has_benchmarks":false,"files_has_tests":true,"github_repo_stars":364}
22 | {"id":"github.com/ugorji/go","can_get_git":true,"can_run_tests":false,"can_get_github":true,"github_url":"https://github.com/ugorji/go","git_url":"https://github.com/ugorji/go","git_last_commit":"2021-04-19","git_last_commit_days_since":5,"git_num_contributors":13,"codecov_url":"https://app.codecov.io/gh/ugorji/go","codecov_files":29,"codecov_lines":11645,"codecov_coverage":90.06,"goreportcard_average":0.93,"goreportcard_grade":"A+","goreportcard_files":69,"goreportcard_issues":24,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true,"github_repo_stars":1532}
23 | {"id":"gopkg.in/check.v1","can_get_git":false,"can_run_tests":true,"can_get_github":false,"git_url":"https://gopkg.in/check.v1","gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":1,"gotest_num_packages_with_tests":1,"gotest_num_packages_tests_passed":1,"gotest_package_coverage_avg":92.6,"goreportcard_average":0.89,"goreportcard_grade":"A","goreportcard_files":19,"goreportcard_issues":8,"files_has_benchmarks":false,"files_has_tests":true,"awesomelists_is_mentioned":true}
24 | {"id":"golang.org/x/text","can_get_git":true,"can_run_tests":true,"can_get_github":false,"git_url":"https://go.googlesource.com/text","git_last_commit":"2021-04-11","git_last_commit_days_since":14,"git_num_contributors":56,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":63,"gotest_num_packages_with_tests":48,"gotest_num_packages_tests_passed":48,"gotest_package_coverage_avg":81.4833333333333,"goreportcard_average":0.94,"goreportcard_grade":"A+","goreportcard_files":375,"goreportcard_issues":125,"files_has_benchmarks":true,"files_has_tests":true,"readme_deprecated":true,"awesomelists_is_mentioned":true}
25 | {"id":"golang.org/x/net","can_get_git":true,"can_run_tests":true,"can_get_github":false,"git_url":"https://go.googlesource.com/net","git_last_commit":"2021-04-20","git_last_commit_days_since":4,"git_num_contributors":196,"gotest_has_tests":true,"gotest_all_tests_passed":true,"gotest_num_packages":33,"gotest_num_packages_with_tests":30,"gotest_num_packages_tests_passed":30,"gotest_package_coverage_avg":71.35666666666665,"goreportcard_average":0.98,"goreportcard_grade":"A+","goreportcard_files":432,"goreportcard_issues":73,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true}
26 | {"id":"golang.org/x/tools","can_get_git":true,"can_run_tests":false,"can_get_github":false,"git_url":"https://go.googlesource.com/tools","git_last_commit":"2021-04-20","git_last_commit_days_since":4,"git_num_contributors":389,"goreportcard_average":0.94,"goreportcard_grade":"A+","goreportcard_files":832,"goreportcard_issues":346,"files_has_benchmarks":true,"files_has_tests":true,"awesomelists_is_mentioned":true}
27 | {"from":"github.com/gin-gonic/gin","to":"github.com/gin-contrib/sse"}
28 | {"from":"github.com/gin-gonic/gin","to":"github.com/go-playground/validator/v10"}
29 | {"from":"github.com/gin-gonic/gin","to":"github.com/golang/protobuf"}
30 | {"from":"github.com/gin-gonic/gin","to":"github.com/json-iterator/go"}
31 | {"from":"github.com/gin-gonic/gin","to":"github.com/mattn/go-isatty"}
32 | {"from":"github.com/gin-gonic/gin","to":"github.com/stretchr/testify"}
33 | {"from":"github.com/gin-gonic/gin","to":"github.com/ugorji/go/codec"}
34 | {"from":"github.com/gin-gonic/gin","to":"gopkg.in/yaml.v2"}
35 | {"from":"github.com/gin-contrib/sse","to":"github.com/stretchr/testify"}
36 | {"from":"github.com/go-playground/validator/v10","to":"github.com/go-playground/assert/v2"}
37 | {"from":"github.com/go-playground/validator/v10","to":"github.com/go-playground/locales"}
38 | {"from":"github.com/go-playground/validator/v10","to":"github.com/go-playground/universal-translator"}
39 | {"from":"github.com/go-playground/validator/v10","to":"github.com/leodido/go-urn"}
40 | {"from":"github.com/go-playground/validator/v10","to":"golang.org/x/crypto"}
41 | {"from":"github.com/json-iterator/go","to":"github.com/davecgh/go-spew"}
42 | {"from":"github.com/json-iterator/go","to":"github.com/google/gofuzz"}
43 | {"from":"github.com/json-iterator/go","to":"github.com/modern-go/concurrent"}
44 | {"from":"github.com/json-iterator/go","to":"github.com/modern-go/reflect2"}
45 | {"from":"github.com/json-iterator/go","to":"github.com/stretchr/testify"}
46 | {"from":"github.com/mattn/go-isatty","to":"golang.org/x/sys"}
47 | {"from":"github.com/stretchr/testify","to":"github.com/davecgh/go-spew"}
48 | {"from":"github.com/stretchr/testify","to":"github.com/pmezard/go-difflib"}
49 | {"from":"github.com/stretchr/testify","to":"github.com/stretchr/objx"}
50 | {"from":"github.com/stretchr/testify","to":"gopkg.in/yaml.v2"}
51 | {"from":"github.com/ugorji/go/codec","to":"github.com/ugorji/go"}
52 | {"from":"gopkg.in/yaml.v2","to":"gopkg.in/check.v1"}
53 | {"from":"github.com/go-playground/locales","to":"golang.org/x/text"}
54 | {"from":"github.com/go-playground/universal-translator","to":"github.com/go-playground/locales"}
55 | {"from":"github.com/leodido/go-urn","to":"github.com/stretchr/testify"}
56 | {"from":"golang.org/x/crypto","to":"golang.org/x/net"}
57 | {"from":"golang.org/x/crypto","to":"golang.org/x/sys"}
58 | {"from":"github.com/ugorji/go","to":"github.com/ugorji/go/codec"}
59 | {"from":"golang.org/x/text","to":"golang.org/x/tools"}
60 | {"from":"golang.org/x/net","to":"golang.org/x/crypto"}
61 | {"from":"golang.org/x/net","to":"golang.org/x/text"}
62 |
--------------------------------------------------------------------------------
/layout/testdata/small.jsonl:
--------------------------------------------------------------------------------
1 | {"from":"github.com/nikolaydubina/jsonl-graph/graph","to":"bufio"}
2 | {"from":"github.com/nikolaydubina/jsonl-graph/graph","to":"bytes"}
3 | {"from":"github.com/nikolaydubina/jsonl-graph/graph","to":"encoding/json"}
4 | {"from":"github.com/nikolaydubina/jsonl-graph/graph","to":"errors"}
5 | {"from":"github.com/nikolaydubina/jsonl-graph/graph","to":"fmt"}
6 | {"from":"github.com/nikolaydubina/jsonl-graph/graph","to":"io"}
7 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"embed"}
8 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"encoding/json"}
9 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"errors"}
10 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"fmt"}
11 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"github.com/nikolaydubina/jsonl-graph/graph"}
12 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"image/color"}
13 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"io"}
14 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"io/ioutil"}
15 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"net/http"}
16 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"sort"}
17 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"strconv"}
18 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"strings"}
19 | {"from":"github.com/nikolaydubina/jsonl-graph/dot","to":"text/template"}
20 | {"from":"github.com/nikolaydubina/jsonl-graph","to":"flag"}
21 | {"from":"github.com/nikolaydubina/jsonl-graph","to":"github.com/nikolaydubina/jsonl-graph/dot"}
22 | {"from":"github.com/nikolaydubina/jsonl-graph","to":"github.com/nikolaydubina/jsonl-graph/graph"}
23 | {"from":"github.com/nikolaydubina/jsonl-graph","to":"io"}
24 | {"from":"github.com/nikolaydubina/jsonl-graph","to":"log"}
25 | {"from":"github.com/nikolaydubina/jsonl-graph","to":"os"}
26 |
--------------------------------------------------------------------------------
/layout/testdata/small_eades.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/layout/testdata/small_forces.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/layout/testdata/small_isomap.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/layout/testdata/small_layers.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/svg/components.go:
--------------------------------------------------------------------------------
1 | package svg
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | )
7 |
8 | type Renderable interface {
9 | Render() string
10 | }
11 |
12 | type SVG struct {
13 | ID string
14 | Definitions []Renderable
15 | Body Renderable
16 | }
17 |
18 | func (s SVG) Render() string {
19 | defs := make([]string, 0, len(s.Definitions))
20 | for _, d := range s.Definitions {
21 | defs = append(defs, d.Render())
22 | }
23 | return strings.Join(
24 | []string{
25 | fmt.Sprintf(``,
31 | },
32 | "\n",
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/svg/edge.go:
--------------------------------------------------------------------------------
1 | package svg
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | )
7 |
8 | // Edge is polylines of straight lines going through all points.
9 | type Edge struct {
10 | Path [][2]int
11 | }
12 |
13 | func (e Edge) Render() string {
14 | var points []string
15 | for _, point := range e.Path {
16 | points = append(points, fmt.Sprintf("%d,%d", point[0], point[1]))
17 | }
18 | return fmt.Sprintf(`