Skip to content

Commit

Permalink
Merge pull request #34 from dnnrly/feature/random-sqare-layout
Browse files Browse the repository at this point in the history
Random sqare layout
  • Loading branch information
dnnrly committed Nov 18, 2023
2 parents ac48664 + df28d2d commit 9ce89a2
Show file tree
Hide file tree
Showing 11 changed files with 233 additions and 11 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ acceptance-test: build ## run acceptance tests
rm -rf ./test/tmp
go build -cover -o layli ./cmd/layli
mkdir -p ./test/tmp/coverage
cd test && GOCOVERDIR=tmp/coverage go test -timeout 5s -tags acceptance
cd test && GOCOVERDIR=tmp/coverage go test -timeout 20s -tags acceptance

.PHONY: coverage-report
coverage-report: ## collate the coverage data
Expand Down
27 changes: 27 additions & 0 deletions arrangements.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package layli
import (
"errors"
"math"
"math/rand"

"github.com/barkimedes/go-deepcopy"
"github.com/dnnrly/layli/algorithms/tarjan"
"github.com/dnnrly/layli/algorithms/topological"
)
Expand All @@ -24,6 +26,9 @@ func selectArrangement(c *Config) (LayoutArrangementFunc, error) {

case "topo-sort":
return LayoutTopologicalSort, nil

case "random-shortest-square":
return LayoutRandomShortestSquare, nil
}

return nil, errors.New("do not understand layout " + c.Layout)
Expand Down Expand Up @@ -130,3 +135,25 @@ func LayoutTarjan(config *Config) LayoutNodes {

return layoutNodes
}

func LayoutRandomShortestSquare(config *Config) LayoutNodes {
return shuffleNodes(config, LayoutFlowSquare)
}

func shuffleNodes(config *Config, arrange func(config *Config) LayoutNodes) LayoutNodes {
c := deepcopy.MustAnything(config).(*Config)
var shortest LayoutNodes
shortestDist := math.MaxFloat64

for i := 0; i < config.LayoutAttempts; i++ {
rand.Shuffle(len(c.Nodes), func(i, j int) { c.Nodes[i], c.Nodes[j] = c.Nodes[j], c.Nodes[i] })
nodes := arrange(c)
dist, _ := nodes.ConnectionDistances(c.Edges)
if dist < shortestDist {
shortest = nodes
shortestDist = dist
}
}

return shortest
}
59 changes: 59 additions & 0 deletions arrangements_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func TestSelectArrangement(t *testing.T) {
a(LayoutFlowSquare, Config{})
a(LayoutFlowSquare, Config{Layout: "flow-square"})
a(LayoutTopologicalSort, Config{Layout: "topo-sort"})
a(LayoutRandomShortestSquare, Config{Layout: "random-shortest-square"})

actual, err := selectArrangement(&Config{Layout: "unknown"})
assert.Error(t, err)
Expand Down Expand Up @@ -147,3 +148,61 @@ func TestLayoutTarjan(t *testing.T) {

assertSameColumn(t, *nodes.ByID("4"), *nodes.ByID("5"))
}

func shuffleConfig() *Config {
return &Config{
Nodes: ConfigNodes{
ConfigNode{Id: "1"}, ConfigNode{Id: "2"}, ConfigNode{Id: "3"}, ConfigNode{Id: "4"},
ConfigNode{Id: "5"}, ConfigNode{Id: "6"}, ConfigNode{Id: "7"}, ConfigNode{Id: "8"},
ConfigNode{Id: "9"}, ConfigNode{Id: "A"}, ConfigNode{Id: "B"}, ConfigNode{Id: "C"},
ConfigNode{Id: "D"}, ConfigNode{Id: "9"}, ConfigNode{Id: "E"}, ConfigNode{Id: "F"},
ConfigNode{Id: "G"}, ConfigNode{Id: "H"}, ConfigNode{Id: "I"}, ConfigNode{Id: "J"},
ConfigNode{Id: "K"}, ConfigNode{Id: "L"}, ConfigNode{Id: "M"}, ConfigNode{Id: "N"},
},
LayoutAttempts: 10,
Edges: ConfigEdges{
ConfigEdge{From: "1", To: "9"},
},

Border: 1, Spacing: 1, NodeWidth: 1, NodeHeight: 1, Margin: 1,
}
}

func TestLayoutRandomShortestSquare(t *testing.T) {
result := LayoutRandomShortestSquare(shuffleConfig())
expected := LayoutFlowSquare(shuffleConfig())

assert.NotNil(t, result)
assert.NotEqual(t, expected.String(), result.String(), "but got "+result.String())
}

func TestShuffleNodes_shufflesNumTimes(t *testing.T) {
var count int
lastConfig := shuffleConfig()

_ = shuffleNodes(shuffleConfig(), func(config *Config) LayoutNodes {
assert.NotEqual(t, lastConfig, config)
count++
return LayoutNodes{NewLayoutNode("A", "c", 0, 0, 1, 1)}
})

assert.Equal(t, 10, count)
}

func TestShuffleNodes_selectsShortsConnectionDistances(t *testing.T) {
var count int
options := []LayoutNodes{
{NewLayoutNode("1", "", 0, 0, 1, 1), NewLayoutNode("9", "", 0, 25, 1, 29)},
{NewLayoutNode("1", "", 0, 0, 1, 1), NewLayoutNode("9", "", 0, 15, 1, 20)},
{NewLayoutNode("1", "", 0, 0, 1, 1), NewLayoutNode("9", "", 0, 45, 1, 50)},
}

c := shuffleConfig()
c.LayoutAttempts = 3
result := shuffleNodes(c, func(config *Config) LayoutNodes {
count++
return options[count-1]
})

assert.Equal(t, options[1], result)
}
14 changes: 9 additions & 5 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ type ConfigPath struct {
}

type Config struct {
Layout string `yaml:"layout"`
Path ConfigPath `yaml:"path"`
Nodes ConfigNodes `yaml:"nodes"`
Edges ConfigEdges `yaml:"edges"`
Spacing int `yaml:"-"`
Layout string `yaml:"layout"`
LayoutAttempts int `yaml:"layout-attempts"`
Path ConfigPath `yaml:"path"`
Nodes ConfigNodes `yaml:"nodes"`
Edges ConfigEdges `yaml:"edges"`
Spacing int `yaml:"-"`

NodeWidth int `yaml:"width"`
NodeHeight int `yaml:"height"`
Expand Down Expand Up @@ -72,6 +73,9 @@ func NewConfigFromFile(r io.Reader) (*Config, error) {
if config.Border == 0 {
config.Border = 1
}
if config.LayoutAttempts == 0 {
config.LayoutAttempts = 10
}

return &config, nil
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (

require (
github.com/antchfx/xpath v1.2.4 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
github.com/cucumber/gherkin-go/v11 v11.0.0 // indirect
github.com/cucumber/messages-go/v10 v10.0.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aslakhellesoy/gox v1.0.100/go.mod h1:AJl542QsKKG96COVsv0N74HHzVQgDIQPceVUh1aeU2M=
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0=
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
Expand Down
11 changes: 6 additions & 5 deletions layli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ nodes:
Contents: "C2",
},
},
Spacing: 20,
Border: 1,
Margin: 2,
NodeWidth: 5,
NodeHeight: 3,
LayoutAttempts: 10,
Spacing: 20,
Border: 1,
Margin: 2,
NodeWidth: 5,
NodeHeight: 3,
}, *config)
}

Expand Down
30 changes: 30 additions & 0 deletions layout.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package layli

import (
"errors"
"fmt"
"strings"
)

type LayoutDrawer interface {
Expand Down Expand Up @@ -131,6 +133,14 @@ type LayoutNode struct {

type LayoutNodes []LayoutNode

func (nodes LayoutNodes) String() string {
var buf []string
for _, n := range nodes {
buf = append(buf, n.Id)
}
return fmt.Sprintf("[%s]", strings.Join(buf, ", "))
}

func (nodes LayoutNodes) ByID(id string) *LayoutNode {
for _, n := range nodes {
if n.Id == id {
Expand All @@ -140,6 +150,26 @@ func (nodes LayoutNodes) ByID(id string) *LayoutNode {
return nil
}

func (n LayoutNodes) ConnectionDistances(connections ConfigEdges) (float64, error) {
dist := 0.0

for _, c := range connections {
f := n.ByID(c.From)
if f == nil {
return 0, errors.New("cannot find node " + c.From)
}

to := n.ByID(c.To)
if to == nil {
return 0, errors.New("cannot find node " + c.To)
}

dist += f.GetCentre().Distance(to.GetCentre())
}

return dist, nil
}

func NewLayoutNode(id, contents string, left, top, width, height int) LayoutNode {
return LayoutNode{
Id: id,
Expand Down
33 changes: 33 additions & 0 deletions layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,39 @@ func TestLayoutNodes_ByID(t *testing.T) {
assert.Nil(t, nodes.ByID("unknown"))
}

func TestLayoutNodes_ConnectionDistances_simple(t *testing.T) {
n := LayoutNodes{
NewLayoutNode("1", "contents", 1, 1, 3, 3),
NewLayoutNode("2", "contents", 1, 5, 3, 7),
NewLayoutNode("3", "contents", 1, 9, 3, 11),
}
e := func(f, t string) ConfigEdge {
return ConfigEdge{From: f, To: t}
}

dist, err := n.ConnectionDistances(ConfigEdges{e("1", "2"), e("2", "3"), e("3", "1")})

assert.NoError(t, err)
assert.Equal(t, 24.0, dist)
}

func TestLayoutNodes_ConnectionDistances_notFound(t *testing.T) {
n := LayoutNodes{
NewLayoutNode("1", "contents", 1, 1, 3, 3),
NewLayoutNode("2", "contents", 1, 5, 3, 7),
NewLayoutNode("3", "contents", 1, 9, 3, 11),
}
e := func(f, t string) ConfigEdge {
return ConfigEdge{From: f, To: t}
}

_, err := n.ConnectionDistances(ConfigEdges{e("1", "X"), e("2", "3"), e("3", "1")})
assert.Error(t, err)

_, err = n.ConnectionDistances(ConfigEdges{e("1", "2"), e("2", "3"), e("5", "1")})
assert.Error(t, err)
}

func TestLayout_ErrorsOnBadLayoutName(t *testing.T) {
_, err := NewLayoutFromConfig(func(start, end dijkstra.Point) PathFinder { return nil }, &Config{Layout: "bad name"})
require.Error(t, err)
Expand Down
9 changes: 9 additions & 0 deletions test/features/cli.feature
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ Feature: Simple CLI commands
And in the SVG file, nodes do not overlap
And in the SVG file, all nodes fit on the image

@Acceptance
Scenario: Generates an image with random shortest square nodes
When the app runs with parameters "tmp/fixtures/inputs/random-shortest-square.layli"
Then the app exits without error
And a file "tmp/fixtures/inputs/random-shortest-square.svg" exists
And in the SVG file, all node text fits inside the node boundaries
And in the SVG file, nodes do not overlap
And in the SVG file, all nodes fit on the image

@Acceptance
Scenario: Arranges paths to prevent blockages
When the app runs with parameters "tmp/fixtures/inputs/blocked.layli"
Expand Down
56 changes: 56 additions & 0 deletions test/fixtures/inputs/random-shortest-square.layli
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
path:
strategy: random
attempts: 1000

layout: random-shortest-square
layout-attempts: 1000

nodes:
- id: node1
contents: "Node 1"
- id: node2
contents: "Node 2"
- id: node3
contents: "Node 3"
- id: node4
contents: "Node 4"
- id: node5
contents: "Node 5"
- id: node6
contents: "Node 6"
- id: node7
contents: "Node 7"
- id: node8
contents: "Node 8"
- id: node9
contents: "Node 9"
- id: node10
contents: "Node 10"
- id: node11
contents: "Node 11"
- id: node12
contents: "Node 12"
- id: node13
contents: "Node 13"
- id: node14
contents: "Node 14"

edges:
- from: node1
to: node2
- from: node2
to: node3
- from: node3
to: node7
- from: node7
to: node11
- from: node11
to: node10
- from: node10
to: node9
- from: node9
to: node5
- from: node5
to: node1
- from: node6
to: node12

0 comments on commit 9ce89a2

Please sign in to comment.