Repository: dominikbraun/graph
Pull request #93 (2023-04-02 09:21:57)

Introduce `draw.GraphAttributes` for rendering graph attributes


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 94c07c9..8a3f842 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),  and this project
 adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.17.0] - 2023-04-04
+
+### Added
+* Added the `draw.GraphAttributes` functional option for `draw.DOT` for rendering graph attributes.
+
 ## [0.16.2] - 2023-03-27
 
 ### Fixed
diff --git a/README.md b/README.md
index e736935..b23fa58 100644
--- a/README.md
+++ b/README.md
@@ -247,6 +247,12 @@ To generate an SVG from the created file using Graphviz, use a command such as t
 dot -Tsvg -O mygraph.gv
 ```
 
+The `DOT` function also supports rendering graph attributes:
+
+```go
+_ = draw.DOT(g, file, draw.GraphAttribute("label", "my-graph"))
+```
+
 ### Draw a graph as in this documentation
 
 ![simple graph](img/simple.svg)
diff --git a/draw/draw.go b/draw/draw.go
index ce3b2f0..fcc3b6f 100644
--- a/draw/draw.go
+++ b/draw/draw.go
@@ -1,5 +1,6 @@
-// Package draw provides functions for visualizing graph structures. At this time, draw supports
-// the DOT language which can be interpreted by Graphviz, Grappa, and others.
+// Package draw provides functions for visualizing graph structures. At this
+// time, draw supports the DOT language which can be interpreted by Graphviz,
+// Grappa, and others.
 package draw
 
 import (
@@ -12,6 +13,9 @@ import (
 
 // ToDo: This template should be simplified and split into multiple templates.
 const dotTemplate = `strict {{.GraphType}} {
+{{range $k, $v := .Attributes}}
+	{{$k}}="{{$v}}";
+{{end}}
 {{range $s := .Statements}}
 	"{{.Source}}" {{if .Target}}{{$.EdgeOperator}} "{{.Target}}" [ {{range $k, $v := .EdgeAttributes}}{{$k}}="{{$v}}", {{end}} weight={{.EdgeWeight}} ]{{else}}[ {{range $k, $v := .SourceAttributes}}{{$k}}="{{$v}}", {{end}} weight={{.SourceWeight}} ]{{end}};
 {{end}}
@@ -20,6 +24,7 @@ const dotTemplate = `strict {{.GraphType}} {
 
 type description struct {
 	GraphType    string
+	Attributes   map[string]string
 	EdgeOperator string
 	Statements   []statement
 }
@@ -33,8 +38,9 @@ type statement struct {
 	EdgeAttributes   map[string]string
 }
 
-// DOT renders the given graph structure in DOT language into an io.Writer, for example a file. The
-// generated output can be passed to Graphviz or other visualization tools supporting DOT.
+// DOT renders the given graph structure in DOT language into an io.Writer, for
+// example a file. The generated output can be passed to Graphviz or other
+// visualization tools supporting DOT.
 //
 // The following example renders a directed graph into a file my-graph.gv:
 //
@@ -50,16 +56,22 @@ type statement struct {
 //	file, _ := os.Create("./my-graph.gv")
 //	_ = draw.DOT(g, file)
 //
-// To generate an SVG from the created file using Graphviz, use a command such as the following:
+// To generate an SVG from the created file using Graphviz, use a command such
+// as the following:
 //
 //	dot -Tsvg -O my-graph.gv
 //
-// Another possibility is to use os.Stdout as an io.Writer, print the DOT output to stdout, and
-// pipe it as follows:
+// Another possibility is to use os.Stdout as an io.Writer, print the DOT output
+// to stdout, and pipe it as follows:
 //
 //	go run main.go | dot -Tsvg > output.svg
-func DOT[K comparable, T any](g graph.Graph[K, T], w io.Writer) error {
-	desc, err := generateDOT(g)
+//
+// DOT also accepts the [draw.GraphAttribute] functional option, which can be
+// used to add global attributes when rendering the graph:
+//
+//	_ = draw.DOT(g, file, draw.GraphAttribute("label", "my-graph"))
+func DOT[K comparable, T any](g graph.Graph[K, T], w io.Writer, options ...func(*description)) error {
+	desc, err := generateDOT(g, options...)
 	if err != nil {
 		return fmt.Errorf("failed to generate DOT description: %w", err)
 	}
@@ -67,13 +79,25 @@ func DOT[K comparable, T any](g graph.Graph[K, T], w io.Writer) error {
 	return renderDOT(w, desc)
 }
 
-func generateDOT[K comparable, T any](g graph.Graph[K, T]) (description, error) {
+// GraphAttribute is a functional option for the [draw.DOT] method.
+func GraphAttribute(key, value string) func(*description) {
+	return func(d *description) {
+		d.Attributes[key] = value
+	}
+}
+
+func generateDOT[K comparable, T any](g graph.Graph[K, T], options ...func(*description)) (description, error) {
 	desc := description{
 		GraphType:    "graph",
+		Attributes:   make(map[string]string),
 		EdgeOperator: "--",
 		Statements:   make([]statement, 0),
 	}
 
+	for _, option := range options {
+		option(&desc)
+	}
+
 	if g.Traits().IsDirected {
 		desc.GraphType = "digraph"
 		desc.EdgeOperator = "->"


Test patch:
diff --git a/draw/draw_test.go b/draw/draw_test.go
index d1e7437..6a6e44a 100644
--- a/draw/draw_test.go
+++ b/draw/draw_test.go
@@ -11,20 +11,23 @@ import (
 func TestGenerateDOT(t *testing.T) {
 	tests := map[string]struct {
 		graph            graph.Graph[string, string]
+		attributes       map[string]string
 		vertices         []string
 		vertexProperties map[string]graph.VertexProperties
 		edges            []graph.Edge[string]
 		expected         description
 	}{
 		"3-vertex directed graph": {
-			graph:    graph.New(graph.StringHash, graph.Directed()),
-			vertices: []string{"1", "2", "3"},
+			graph:      graph.New(graph.StringHash, graph.Directed()),
+			attributes: map[string]string{},
+			vertices:   []string{"1", "2", "3"},
 			edges: []graph.Edge[string]{
 				{Source: "1", Target: "2"},
 				{Source: "1", Target: "3"},
 			},
 			expected: description{
 				GraphType:    "digraph",
+				Attributes:   map[string]string{},
 				EdgeOperator: "->",
 				Statements: []statement{
 					{Source: "1", Target: "2"},
@@ -36,8 +39,9 @@ func TestGenerateDOT(t *testing.T) {
 			},
 		},
 		"3-vertex directed, weighted graph with weights and attributes": {
-			graph:    graph.New(graph.StringHash, graph.Directed(), graph.Weighted()),
-			vertices: []string{"1", "2", "3"},
+			graph:      graph.New(graph.StringHash, graph.Directed(), graph.Weighted()),
+			attributes: map[string]string{},
+			vertices:   []string{"1", "2", "3"},
 			edges: []graph.Edge[string]{
 				{
 					Source: "1",
@@ -53,6 +57,7 @@ func TestGenerateDOT(t *testing.T) {
 			},
 			expected: description{
 				GraphType:    "digraph",
+				Attributes:   map[string]string{},
 				EdgeOperator: "->",
 				Statements: []statement{
 					{
@@ -71,8 +76,9 @@ func TestGenerateDOT(t *testing.T) {
 			},
 		},
 		"vertices with attributes": {
-			graph:    graph.New(graph.StringHash, graph.Directed(), graph.Weighted()),
-			vertices: []string{"1", "2"},
+			graph:      graph.New(graph.StringHash, graph.Directed(), graph.Weighted()),
+			attributes: map[string]string{},
+			vertices:   []string{"1", "2"},
 			vertexProperties: map[string]graph.VertexProperties{
 				"1": {
 					Attributes: map[string]string{
@@ -92,6 +98,7 @@ func TestGenerateDOT(t *testing.T) {
 			},
 			expected: description{
 				GraphType:    "digraph",
+				Attributes:   map[string]string{},
 				EdgeOperator: "->",
 				Statements: []statement{
 					{
@@ -115,6 +122,39 @@ func TestGenerateDOT(t *testing.T) {
 				},
 			},
 		},
+		"directed graph with attributes": {
+			graph: graph.New(graph.StringHash, graph.Directed()),
+			attributes: map[string]string{
+				"label":     "my-graph",
+				"normalize": "true",
+				"compound":  "false",
+			},
+			vertices: []string{"1", "2"},
+			edges: []graph.Edge[string]{
+				{Source: "1", Target: "2"},
+			},
+			expected: description{
+				GraphType: "digraph",
+				Attributes: map[string]string{
+					"label":     "my-graph",
+					"normalize": "true",
+					"compound":  "false",
+				},
+				EdgeOperator: "->",
+				Statements: []statement{
+					{
+						Source: "1",
+					},
+					{
+						Source: "2",
+					},
+					{
+						Source: "1",
+						Target: "2",
+					},
+				},
+			},
+		},
 	}
 
 	for name, test := range tests {
@@ -123,8 +163,9 @@ func TestGenerateDOT(t *testing.T) {
 				_ = test.graph.AddVertex(vertex)
 				continue
 			}
-			// If there are vertex attributes, iterate over them and call VertexAttribute for each
-			// entry. A vertex should only have one attribute so that AddVertex is invoked once.
+			// If there are vertex attributes, iterate over them and call
+			// VertexAttribute for each entry. A vertex should only have one
+			// attribute so that AddVertex is invoked once.
 			for key, value := range test.vertexProperties[vertex].Attributes {
 				weight := test.vertexProperties[vertex].Weight
 				// ToDo: Clarify how multiple functional options and attributes can be tested.
@@ -137,8 +178,9 @@ func TestGenerateDOT(t *testing.T) {
 			if len(edge.Properties.Attributes) == 0 {
 				err = test.graph.AddEdge(edge.Source, edge.Target, graph.EdgeWeight(edge.Properties.Weight))
 			}
-			// If there are edge attributes, iterate over them and call EdgeAttribute for each
-			// entry. An edge should only have one attribute so that AddEdge is invoked once.
+			// If there are edge attributes, iterate over them and call
+			// EdgeAttribute for each entry. An edge should only have one
+			// attribute so that AddEdge is invoked once.
 			for key, value := range edge.Properties.Attributes {
 				err = test.graph.AddEdge(edge.Source, edge.Target, graph.EdgeWeight(edge.Properties.Weight), graph.EdgeAttribute(key, value))
 			}
@@ -149,6 +191,10 @@ func TestGenerateDOT(t *testing.T) {
 
 		desc, _ := generateDOT(test.graph)
 
+		// Add the graph attributes manually instead of using the functional
+		// option. This is the reason why I dislike them more and more.
+		desc.Attributes = test.attributes
+
 		if desc.GraphType != test.expected.GraphType {
 			t.Errorf("%s: graph type expectancy doesn't match: expected %v, got %v", name, test.expected.GraphType, desc.GraphType)
 		}
@@ -160,6 +206,14 @@ func TestGenerateDOT(t *testing.T) {
 		if !slicesAreEqual(desc.Statements, test.expected.Statements, statementsAreEqual) {
 			t.Errorf("%s: statements expectancy doesn't match: expected %v, got %v", name, test.expected.Statements, desc.Statements)
 		}
+
+		stringsAreEqual := func(a, b string) bool {
+			return a == b
+		}
+
+		if !mapsAreEqual(desc.Attributes, test.expected.Attributes, stringsAreEqual) {
+			t.Errorf("%s: attributes expectancy doesn't match: expected %v, got %v", name, test.expected.Attributes, desc.Attributes)
+		}
 	}
 }
 
@@ -171,6 +225,7 @@ func TestRenderDOT(t *testing.T) {
 		"3-vertex directed graph": {
 			description: description{
 				GraphType:    "digraph",
+				Attributes:   map[string]string{},
 				EdgeOperator: "->",
 				Statements: []statement{
 					{Source: 1, Target: 2},
@@ -191,6 +246,7 @@ func TestRenderDOT(t *testing.T) {
 		"custom edge attributes": {
 			description: description{
 				GraphType:    "digraph",
+				Attributes:   map[string]string{},
 				EdgeOperator: "->",
 				Statements: []statement{
 					{
@@ -223,6 +279,7 @@ func TestRenderDOT(t *testing.T) {
 		"vertices containing special characters": {
 			description: description{
 				GraphType:    "digraph",
+				Attributes:   map[string]string{},
 				EdgeOperator: "->",
 				Statements: []statement{
 					{Source: "/home", Target: "projects/graph"},
@@ -239,6 +296,7 @@ func TestRenderDOT(t *testing.T) {
 		"vertices with attributes": {
 			description: description{
 				GraphType:    "digraph",
+				Attributes:   map[string]string{},
 				EdgeOperator: "->",
 				Statements: []statement{
 					{
@@ -267,6 +325,30 @@ func TestRenderDOT(t *testing.T) {
 				"1" -> "2" [ weight=0 ];
 			}`,
 		},
+		"3-vertex directed graph with attributes": {
+			description: description{
+				GraphType: "digraph",
+				Attributes: map[string]string{
+					"label": "my-graph",
+				},
+				EdgeOperator: "->",
+				Statements: []statement{
+					{Source: 1, Target: 2},
+					{Source: 1, Target: 3},
+					{Source: 1},
+					{Source: 2},
+					{Source: 3},
+				},
+			},
+			expected: `strict digraph {
+				label="my-graph";
+				"1" -> "2" [ weight=0 ];
+				"1" -> "3" [ weight=0 ];
+				"1" [ weight=0 ];
+				"2" [ weight=0 ];
+				"3" [ weight=0 ];
+			}`,
+		},
 	}
 
 	for name, test := range tests {
@@ -302,6 +384,24 @@ func slicesAreEqual[T any](a, b []T, equals func(a, b T) bool) bool {
 	return true
 }
 
+func mapsAreEqual[K comparable, V any](a, b map[K]V, equals func(a, b V) bool) bool {
+	if len(a) != len(b) {
+		return false
+	}
+
+	for aKey, aValue := range a {
+		bValue, ok := b[aKey]
+		if !ok {
+			return false
+		}
+		if !equals(aValue, bValue) {
+			return false
+		}
+	}
+
+	return true
+}
+
 func normalizeOutput(output string) string {
 	replacer := strings.NewReplacer(" ", "", "\n", "", "\t", "")