From 4851cefb3696b9761f208048c19914b8cfd0870b Mon Sep 17 00:00:00 2001 From: Dominik Menke Date: Tue, 26 Jun 2018 20:25:46 +0200 Subject: [PATCH 1/5] Implement functional options As mentioned on Reddit [1], handling options is not ideal. This implements the functional option pattern, as described here: [2]. [1]: https://www.reddit.com/r/golang/comments/8toot9/-/e19szsq/ [2]: https://dave.cheney.net/2014/10/17/functional-options --- README.rst | 5 +-- asciigraph.go | 69 +++++++++++++++-------------------------- asciigraph_test.go | 72 ++++++++++++++++--------------------------- examples/SineCurve.go | 9 ++---- options.go | 67 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 99 deletions(-) create mode 100644 options.go diff --git a/README.rst b/README.rst index 4cf7fc8..e27b42e 100644 --- a/README.rst +++ b/README.rst @@ -55,10 +55,7 @@ Basic graph func main() { data := []float64{3, 4, 9, 6, 2, 4, 5, 8, 5, 10, 2, 7, 2, 5, 6} - - conf := map[string]interface{}{} - - graph := asciigraph.Plot(data, conf) + graph := asciigraph.Plot(data) fmt.Println(graph) } diff --git a/asciigraph.go b/asciigraph.go index 749a496..cfa803c 100644 --- a/asciigraph.go +++ b/asciigraph.go @@ -7,43 +7,31 @@ import ( ) // Plot returns ascii graph for a series. -func Plot(series []float64, config map[string]interface{}) string { +func Plot(series []float64, options ...Option) string { + config := configure(config{ + Offset: 3, + }, options) - var height, offset int - var caption string - - if val, ok := config["width"].(int); ok { - series = interpolateArray(series, val) + if config.Width > 0 { + series = interpolateArray(series, config.Width) } minimum, maximum := minMaxFloat64Slice(series) - interval := math.Abs(maximum - minimum) - if val, ok := config["height"].(int); ok { - height = val - } else { + if config.Height <= 0 { if int(interval) <= 0 { - height = int(interval * math.Pow10(int(math.Ceil(-math.Log10(interval))))) + config.Height = int(interval * math.Pow10(int(math.Ceil(-math.Log10(interval))))) } else { - height = int(interval) + config.Height = int(interval) } } - if val, ok := config["offset"].(int); ok { - offset = val - } else { - offset = 3 - } - - if val, ok := config["caption"].(string); ok { - caption = val - } else { - caption = "" + if config.Offset <= 0 { + config.Offset = 3 } - ratio := float64(height) / interval - + ratio := float64(config.Height) / interval min2 := math.Floor(minimum * ratio) max2 := math.Ceil(maximum * ratio) @@ -51,7 +39,7 @@ func Plot(series []float64, config map[string]interface{}) string { intmax2 := int(max2) rows := int(math.Abs(float64(intmax2 - intmin2))) - width := len(series) + offset + width := len(series) + config.Offset var plot [][]string @@ -75,73 +63,66 @@ func Plot(series []float64, config map[string]interface{}) string { } else { precision = precision + int(math.Abs(logMaximum)-1.0) } - } else if logMaximum > 2 { precision = 0 } maxNumLength := len(fmt.Sprintf("%0.*f", precision, maximum)) minNumLength := len(fmt.Sprintf("%0.*f", precision, minimum)) - maxWidth := int(math.Max(float64(maxNumLength), float64(minNumLength))) // axis and labels for y := intmin2; y < intmax2+1; y++ { label := fmt.Sprintf("%*.*f", maxWidth+1, precision, maximum-(float64(y-intmin2)*interval/float64(rows))) w := y - intmin2 - h := int(math.Max(float64(offset)-float64(len(label)), 0)) + h := int(math.Max(float64(config.Offset)-float64(len(label)), 0)) plot[w][h] = label if y == 0 { - plot[w][offset-1] = "┼" + plot[w][config.Offset-1] = "┼" } else { - plot[w][offset-1] = "┤" + plot[w][config.Offset-1] = "┤" } } y0 := int(series[0]*ratio - min2) - var y1 int - plot[rows-y0][offset-1] = "┼" // first value + plot[rows-y0][config.Offset-1] = "┼" // first value for x := 0; x < len(series)-1; x++ { // plot the line y0 = int(round(series[x+0]*ratio) - float64(intmin2)) y1 = int(round(series[x+1]*ratio) - float64(intmin2)) if y0 == y1 { - plot[rows-y0][x+offset] = "─" + plot[rows-y0][x+config.Offset] = "─" } else { if y0 > y1 { - plot[rows-y1][x+offset] = "╰" + plot[rows-y1][x+config.Offset] = "╰" } else { - plot[rows-y1][x+offset] = "╭" + plot[rows-y1][x+config.Offset] = "╭" } if y0 > y1 { - plot[rows-y0][x+offset] = "╮" + plot[rows-y0][x+config.Offset] = "╮" } else { - plot[rows-y0][x+offset] = "╯" + plot[rows-y0][x+config.Offset] = "╯" } start := int(math.Min(float64(y0), float64(y1))) + 1 end := int(math.Max(float64(y0), float64(y1))) for y := start; y < end; y++ { - plot[rows-y][x+offset] = "│" + plot[rows-y][x+config.Offset] = "│" } - } - } - var lines []string - // join columns for _, v := range plot { lines = append(lines, strings.Join(v, "")) } // add caption if not empty - if caption != "" { - lines = append(lines, fmt.Sprintf("%s", strings.Repeat(" ", offset+maxWidth+2)+caption)) + if config.Caption != "" { + lines = append(lines, fmt.Sprintf("%s", strings.Repeat(" ", config.Offset+maxWidth+2)+config.Caption)) } return strings.Join(lines, "\n") // join rows diff --git a/asciigraph_test.go b/asciigraph_test.go index f1d2686..5a0175a 100644 --- a/asciigraph_test.go +++ b/asciigraph_test.go @@ -1,23 +1,16 @@ package asciigraph -import ( - "fmt" - "testing" -) +import "testing" func TestPlot(t *testing.T) { - type input struct { - data []float64 - conf map[string]interface{} - } - cases := []struct { - in input - want string + data []float64 + opts []Option + expected string }{ { - input{[]float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1}, map[string]interface{}{}}, - + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1}, + nil, ` 11.00 ┤ ╭╮ 10.00 ┤ ││ 9.00 ┼ ││ @@ -33,10 +26,8 @@ func TestPlot(t *testing.T) { -1.00 ┤ ││ -2.00 ┤ ╰╯ `}, { - input{ - []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 4, 5, 6, 9, 4, 0, 6, 1, 5, 3, 6, 2}, - map[string]interface{}{"caption": "Plot using asciigraph."}}, - + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 4, 5, 6, 9, 4, 0, 6, 1, 5, 3, 6, 2}, + []Option{Caption("Plot using asciigraph.")}, ` 11.00 ┤ ╭╮ 10.00 ┤ ││ 9.00 ┼ ││ ╭╮ @@ -53,20 +44,16 @@ func TestPlot(t *testing.T) { -2.00 ┤ ╰╯ Plot using asciigraph.`}, { - input{ - []float64{.2, .1, .2, 2, -.9, .7, .91, .3, .7, .4, .5}, - map[string]interface{}{"caption": "Plot using asciigraph."}}, - + []float64{.2, .1, .2, 2, -.9, .7, .91, .3, .7, .4, .5}, + []Option{Caption("Plot using asciigraph.")}, ` 2.00 ┤ 1.03 ┼ ╭╮ ╭╮ 0.07 ┼──╯│╭╯╰─── -0.90 ┤ ╰╯ Plot using asciigraph.`}, { - input{ - []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1}, - map[string]interface{}{"height": 4, "offset": 3}}, - + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1}, + []Option{Height(4), Offset(3)}, ` 11.00 ┤ 8.40 ┼ ╭╮ 5.80 ┤ ╭─╯│╭╮ @@ -74,10 +61,8 @@ func TestPlot(t *testing.T) { 0.60 ┼╰─╯││ ╰ -2.00 ┤ ╰╯ `}, { - input{ - []float64{.453, .141, .951, .251, .223, .581, .771, .191, .393, .617, .478}, - map[string]interface{}{}}, - + []float64{.453, .141, .951, .251, .223, .581, .771, .191, .393, .617, .478}, + nil, ` 0.95 ┤ 0.86 ┤ ╭╮ 0.77 ┤ ││ ╭╮ @@ -90,9 +75,8 @@ func TestPlot(t *testing.T) { 0.14 ┤╰╯ `}, { - input{[]float64{.01, .004, .003, .0042, .0083, .0033, 0.0079}, - map[string]interface{}{}}, - + []float64{.01, .004, .003, .0042, .0083, .0033, 0.0079}, + nil, ` 0.010 ┼╮ 0.009 ┤│ 0.008 ┤│ ╭╮╭ @@ -103,10 +87,8 @@ func TestPlot(t *testing.T) { 0.003 ┤ ╰╯ ╰╯ `}, { - input{ - []float64{192, 431, 112, 449, -122, 375, 782, 123, 911, 1711, 172}, - map[string]interface{}{"height": 10}}, - + []float64{192, 431, 112, 449, -122, 375, 782, 123, 911, 1711, 172}, + []Option{Height(10)}, ` 1711 ┤ 1544 ┼ ╭╮ 1378 ┤ ││ @@ -120,10 +102,8 @@ func TestPlot(t *testing.T) { 45 ┤ ││ -122 ┤ ╰╯ `}, { - input{ - []float64{0.3189989805, 0.149949026, 0.30142492354, 0.195129182935, 0.3142492354, 0.1674974513, 0.3142492354, 0.1474974513, 0.3047974513}, - map[string]interface{}{"width": 30, "height": 5, "caption": "Plot with custom height & width."}}, - + []float64{0.3189989805, 0.149949026, 0.30142492354, 0.195129182935, 0.3142492354, 0.1674974513, 0.3142492354, 0.1474974513, 0.3047974513}, + []Option{Width(30), Height(5), Caption("Plot with custom height & width.")}, ` 0.32 ┤ 0.29 ┼╮ ╭─╮ ╭╮ ╭ 0.27 ┤╰╮ ╭─╮ ╭╯ │ ╭╯│ │ @@ -134,11 +114,13 @@ func TestPlot(t *testing.T) { Plot with custom height & width.`}, } - for _, c := range cases { - got := Plot(c.in.data, c.in.conf) - fmt.Println(got + "\n") - if got != c.want { - t.Errorf("Plot(%f, %q) == %q, want %q", c.in.data, c.in.conf, got, c.want) + for i, c := range cases { + actual := Plot(c.data, c.opts...) + t.Logf("test case %d:\n%s\n", i, actual) + + if actual != c.expected { + conf := configure(config{}, c.opts) + t.Errorf("Plot(%f, %+v) == %q, want %q", c.data, conf, actual, c.expected) } } } diff --git a/examples/SineCurve.go b/examples/SineCurve.go index 3ddc0e1..fc66fc2 100644 --- a/examples/SineCurve.go +++ b/examples/SineCurve.go @@ -2,8 +2,9 @@ package main import ( "fmt" - "github.com/guptarohit/asciigraph" "math" + + "github.com/guptarohit/asciigraph" ) func main() { @@ -13,10 +14,7 @@ func main() { for i := 0; i < 105; i++ { data = append(data, 15*math.Sin(float64(i)*((math.Pi*4)/120.0))) } - - conf := map[string]interface{}{"height": 10} - - graph := asciigraph.Plot(data, conf) + graph := asciigraph.Plot(data, asciigraph.Height(10)) fmt.Println(graph) // Output: @@ -31,5 +29,4 @@ func main() { // -9.00 ┤ ╰──╮ ╭─╯ ╰──╮ // -12.00 ┤ ╰──╮ ╭──╯ ╰──╮ // -15.00 ┤ ╰────────╯ ╰─── - } diff --git a/options.go b/options.go new file mode 100644 index 0000000..7ecf379 --- /dev/null +++ b/options.go @@ -0,0 +1,67 @@ +package asciigraph + +import ( + "strings" +) + +// Option represents a configuration setting. +type Option interface { + apply(c *config) +} + +// config holds various graph options +type config struct { + Width, Height int + Offset int + Caption string +} + +// An optionFunc applies an option. +type optionFunc func(*config) + +// apply implements the Option interface. +func (of optionFunc) apply(c *config) { of(c) } + +func configure(defaults config, options []Option) *config { + for _, o := range options { + o.apply(&defaults) + } + return &defaults +} + +// Width sets the graphs width. By default, the width of the graph is +// determined by the number of data points. If the value given is a +// positive number, the data points are interpolated on the x axis. +// Values <= 0 reset the width to the default value. +func Width(w int) Option { + return optionFunc(func(c *config) { + if w > 0 { + c.Width = w + } else { + c.Width = 0 + } + }) +} + +// Height sets the graphs height. +func Height(h int) Option { + return optionFunc(func(c *config) { + if h > 0 { + c.Height = h + } else { + c.Height = 0 + } + }) +} + +// Offset sets the graphs offset. +func Offset(o int) Option { + return optionFunc(func(c *config) { c.Offset = o }) +} + +// Caption sets the graphs caption. +func Caption(caption string) Option { + return optionFunc(func(c *config) { + c.Caption = strings.TrimSpace(caption) + }) +} From 04d54fe258ec09988e6c69f780f1fe8cf1cb829c Mon Sep 17 00:00:00 2001 From: Dominik Menke Date: Tue, 26 Jun 2018 20:30:44 +0200 Subject: [PATCH 2/5] Improve result generation. Instead of building another string slice, just to join it at the end, write directly to a buffer. --- asciigraph.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/asciigraph.go b/asciigraph.go index cfa803c..a6ac540 100644 --- a/asciigraph.go +++ b/asciigraph.go @@ -1,6 +1,7 @@ package asciigraph import ( + "bytes" "fmt" "math" "strings" @@ -116,14 +117,22 @@ func Plot(series []float64, options ...Option) string { } // join columns - for _, v := range plot { - lines = append(lines, strings.Join(v, "")) + var lines bytes.Buffer + for h, horizontal := range plot { + if h != 0 { + lines.WriteRune('\n') + } + for _, v := range horizontal { + lines.WriteString(v) + } } // add caption if not empty if config.Caption != "" { - lines = append(lines, fmt.Sprintf("%s", strings.Repeat(" ", config.Offset+maxWidth+2)+config.Caption)) + lines.WriteRune('\n') + lines.WriteString(strings.Repeat(" ", config.Offset+maxWidth+2)) + lines.WriteString(config.Caption) } - return strings.Join(lines, "\n") // join rows + return lines.String() } From 2e8eb6f15be5f0088a0f7aaebc56838b822d9364 Mon Sep 17 00:00:00 2001 From: Dominik Menke Date: Tue, 26 Jun 2018 21:06:37 +0200 Subject: [PATCH 3/5] Tests: Address individual test cases Example: go test -v -run TestPlot/1 --- asciigraph_test.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/asciigraph_test.go b/asciigraph_test.go index 5a0175a..263a02b 100644 --- a/asciigraph_test.go +++ b/asciigraph_test.go @@ -114,13 +114,17 @@ func TestPlot(t *testing.T) { Plot with custom height & width.`}, } - for i, c := range cases { + for i := range cases { + name := fmt.Sprintf("%d", i) + t.Run(name, func(t *testing.T) { + c := cases[i] actual := Plot(c.data, c.opts...) - t.Logf("test case %d:\n%s\n", i, actual) - if actual != c.expected { conf := configure(config{}, c.opts) - t.Errorf("Plot(%f, %+v) == %q, want %q", c.data, conf, actual, c.expected) + t.Errorf("Plot(%f, %#v)", c.data, conf) + t.Logf("expected:\n%s\n", c.expected) } + t.Logf("actual:\n%s\n", actual) + }) } } From 655e935e6054a7c42da4de4cc3d35f6ec29fa4c6 Mon Sep 17 00:00:00 2001 From: Dominik Menke Date: Tue, 26 Jun 2018 21:10:09 +0200 Subject: [PATCH 4/5] Test: Add at least one test case using an Offset --- asciigraph_test.go | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/asciigraph_test.go b/asciigraph_test.go index 263a02b..ef567f7 100644 --- a/asciigraph_test.go +++ b/asciigraph_test.go @@ -1,6 +1,9 @@ package asciigraph -import "testing" +import ( + "fmt" + "testing" +) func TestPlot(t *testing.T) { cases := []struct { @@ -112,18 +115,38 @@ func TestPlot(t *testing.T) { 0.19 ┤ ╰╮│ ╰╯ ╰╯ │╭╯ 0.16 ┤ ╰╯ ╰╯ Plot with custom height & width.`}, + { + []float64{ + 0, 0, 0, 0, 1.5, 0, 0, -0.5, 9, -3, 0, 0, 1, 2, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 1.5, 0, 0, -0.5, 8, -3, 0, 0, 1, 2, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 1.5, 0, 0, -0.5, 10, -3, 0, 0, 1, 2, 1, 0, 0, 0, 0, + }, + []Option{Offset(10), Height(10), Caption("I'm a doctor, not an engineer.")}, + ` 10.00 ┤ ╭╮ + 8.82 ┤ ╭╮ ││ + 7.64 ┤ ││ ╭╮ ││ + 6.45 ┼ ││ ││ ││ + 5.27 ┤ ││ ││ ││ + 4.09 ┤ ││ ││ ││ + 2.91 ┤ ││ ╭╮ ││ ╭╮ ││ ╭╮ + 1.73 ┤ ╭╮ ││ ╭╯╰╮ ╭╮ ││ ╭╯╰╮ ╭╮ ││ ╭╯╰╮ + 0.55 ┼───╯╰──╯│╭─╯ ╰───────╯╰──╯│╭─╯ ╰───────╯╰──╯│╭─╯ ╰─── + -0.64 ┤ ││ ││ ││ + -1.82 ┤ ╰╯ ╰╯ ╰╯ + -3.00 ┤ + I'm a doctor, not an engineer.`}, } for i := range cases { name := fmt.Sprintf("%d", i) t.Run(name, func(t *testing.T) { c := cases[i] - actual := Plot(c.data, c.opts...) - if actual != c.expected { - conf := configure(config{}, c.opts) + actual := Plot(c.data, c.opts...) + if actual != c.expected { + conf := configure(config{}, c.opts) t.Errorf("Plot(%f, %#v)", c.data, conf) t.Logf("expected:\n%s\n", c.expected) - } + } t.Logf("actual:\n%s\n", actual) }) } From 9537a7c667f069665a6a95c603675ba1a551afad Mon Sep 17 00:00:00 2001 From: Dominik Menke Date: Tue, 26 Jun 2018 22:18:17 +0200 Subject: [PATCH 5/5] Add simple command line utility --- README.rst | 27 +++++++++++++++++++ cmd/asciigraph/main.go | 61 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 cmd/asciigraph/main.go diff --git a/README.rst b/README.rst index e27b42e..0d4059e 100644 --- a/README.rst +++ b/README.rst @@ -75,6 +75,33 @@ Running this example would render the following graph: 2.00 ┤ ╰╯ ╰╯╰╯ .. +Command line interface +---------------------- + +This package also brings a small utility for command line usage. Assuming +`$GOPATH/bin` is in yout `$PATH`, simply `go get` it, and feed it data +points via stdin: + +:: + +$ go install github.com/guptarohit/asciigraph/cmd/asciigraph +$ seq 1 72 | asciigraph -h 10 -c "plot data from stdin" + 72.00 ┼ + 65.55 ┤ ╭──── + 59.09 ┤ ╭──────╯ + 52.64 ┤ ╭──────╯ + 46.18 ┤ ╭──────╯ + 39.73 ┤ ╭──────╯ + 33.27 ┤ ╭───────╯ + 26.82 ┤ ╭──────╯ + 20.36 ┤ ╭──────╯ + 13.91 ┤ ╭──────╯ + 7.45 ┤ ╭──────╯ + 1.00 ┼──╯ + plot data from stdin +.. + + Acknowledgement ---------------- This package is golang port of library `asciichart `_ written by `@kroitor `_. diff --git a/cmd/asciigraph/main.go b/cmd/asciigraph/main.go new file mode 100644 index 0000000..2d76e92 --- /dev/null +++ b/cmd/asciigraph/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "log" + "os" + "strconv" + + "github.com/guptarohit/asciigraph" +) + +var ( + height uint + width uint + offset uint = 3 + caption string +) + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s [options]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "%s expects data points from stdin. Invalid values are logged to stderr.", os.Args[0]) + } + flag.UintVar(&height, "h", height, "`height` in text rows, 0 for auto-scaling") + flag.UintVar(&width, "w", width, "`width` in columns, 0 for auto-scaling") + flag.UintVar(&offset, "o", offset, "`offset` in columns, for the label") + flag.StringVar(&caption, "c", caption, "`caption` for the graph") + flag.Parse() + + data := make([]float64, 0, 64) + + s := bufio.NewScanner(os.Stdin) + s.Split(bufio.ScanWords) + for s.Scan() { + p, err := strconv.ParseFloat(s.Text(), 64) + if err != nil { + continue + } + data = append(data, p) + } + if err := s.Err(); err != nil { + log.Fatal(err) + } + + if len(data) == 0 { + log.Fatal("no data") + } + + plot := asciigraph.Plot(data, + asciigraph.Height(int(height)), + asciigraph.Width(int(width)), + asciigraph.Offset(int(offset)), + asciigraph.Caption(caption)) + + fmt.Println(plot) +}