From a56369e9e25bbea796a27cddd3f9b966f5b5f075 Mon Sep 17 00:00:00 2001 From: Pieter Loubser Date: Tue, 12 Nov 2024 11:18:34 +0000 Subject: [PATCH] Fork and update guptarohit/asciigraph Here we fork guptarohit/asciigraph and add it to our internal packages. We have added the ability to specify a `ValueFormatter` option for a graph, allowing us to prettify the lables shown on the y-axis of a graph. With that we have added the ability to specify `AlwaysY`, which will force the graph to draw the y-axis even in cases where the input data is 0, allowing us to draw multiple graphs in the terminal with consistent size and clariy. --- .github/workflows/test.yaml | 6 +- ABTaskFile | 6 +- cli/columns.go | 8 + cli/consumer_command.go | 6 +- cli/server_graph_command.go | 47 ++- cli/server_ping_command.go | 2 +- cli/stream_command.go | 5 +- cli/sub_command.go | 3 +- go.mod | 1 - go.sum | 2 - internal/asciigraph/LICENSE | 29 ++ internal/asciigraph/asciigraph.go | 308 +++++++++++++++++ internal/asciigraph/asciigraph_test.go | 451 +++++++++++++++++++++++++ internal/asciigraph/color.go | 312 +++++++++++++++++ internal/asciigraph/legend.go | 45 +++ internal/asciigraph/options.go | 144 ++++++++ internal/asciigraph/utils.go | 104 ++++++ internal/util/util.go | 2 +- 18 files changed, 1450 insertions(+), 31 deletions(-) create mode 100644 internal/asciigraph/LICENSE create mode 100644 internal/asciigraph/asciigraph.go create mode 100644 internal/asciigraph/asciigraph_test.go create mode 100644 internal/asciigraph/color.go create mode 100644 internal/asciigraph/legend.go create mode 100644 internal/asciigraph/options.go create mode 100644 internal/asciigraph/utils.go diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c06bb3b1..acd1f839 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -36,15 +36,15 @@ jobs: shell: bash --noprofile --norc -x -eo pipefail {0} run: | PATH=$PATH:$GOPATH/bin - GO_LIST=$(go list ./...) + GO_LIST=$(go list ./... | grep -F -e asciigraph -v) $(exit $(go fmt $GO_LIST | wc -l)) go vet -composites=false $GO_LIST - find . -type f -name "*.go" | xargs misspell -error -locale US + find . -type f -name "*.go" | grep -F -e asciigraph -v | xargs misspell -error -locale US staticcheck -f stylish $GO_LIST - name: Run tests shell: bash --noprofile --norc -x -eo pipefail {0} run: | set -e - go test -v --failfast -p=1 ./... + go list ./... | grep -F -e asciigraph -v | xargs go test -v --failfast -p=1 set +e diff --git a/ABTaskFile b/ABTaskFile index 3c5e0c08..3a456338 100644 --- a/ABTaskFile +++ b/ABTaskFile @@ -60,17 +60,17 @@ commands: {{ if .Flags.spell }} ab_say Checking spelling - find . -type f -name "*.go" | xargs misspell -error -locale US -i flavour + find . -type f -name "*.go" | grep -F -e asciigraph -v | xargs misspell -error -locale US -i flavour {{ end }} {{ if .Flags.vet }} ab_say Performing go vet - go vet ./... + go list ./... | grep -F -e asciigraph -v |xargs go vet {{ end }} {{ if .Flags.staticcheck }} ab_say Running staticcheck - staticcheck ./... + go list ./... | grep -F -e asciigraph -v |xargs staticcheck {{ end }} - name: dependencies diff --git a/cli/columns.go b/cli/columns.go index 6be6369c..67d0dbf7 100644 --- a/cli/columns.go +++ b/cli/columns.go @@ -33,3 +33,11 @@ func fiBytes(v uint64) string { func f(v any) string { return columns.F(v) } + +func fFloat2Int(v any) string { + return columns.F(uint64(v.(float64))) +} + +func fiBytesFloat2Int(v any) string { + return fiBytes(uint64(v.(float64))) +} diff --git a/cli/consumer_command.go b/cli/consumer_command.go index dd6d9173..49852a28 100644 --- a/cli/consumer_command.go +++ b/cli/consumer_command.go @@ -29,8 +29,8 @@ import ( "strings" "time" - "github.com/guptarohit/asciigraph" "github.com/nats-io/natscli/columns" + "github.com/nats-io/natscli/internal/asciigraph" iu "github.com/nats-io/natscli/internal/util" terminal "golang.org/x/term" "gopkg.in/yaml.v3" @@ -515,6 +515,7 @@ func (c *consumerCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/4-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) ackedPlot := asciigraph.Plot(ackedRates, @@ -523,6 +524,7 @@ func (c *consumerCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/4-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) unprocessedPlot := asciigraph.Plot(unprocessedMessages, @@ -531,6 +533,7 @@ func (c *consumerCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/4-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int), ) outstandingPlot := asciigraph.Plot(outstandingMessages, @@ -539,6 +542,7 @@ func (c *consumerCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/4-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int), ) iu.ClearScreen() diff --git a/cli/server_graph_command.go b/cli/server_graph_command.go index 8a42db7e..52981809 100644 --- a/cli/server_graph_command.go +++ b/cli/server_graph_command.go @@ -16,16 +16,17 @@ package cli import ( "encoding/json" "fmt" + "os" + "os/signal" + "strings" + "time" + "github.com/choria-io/fisk" - "github.com/guptarohit/asciigraph" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" + "github.com/nats-io/natscli/internal/asciigraph" iu "github.com/nats-io/natscli/internal/util" terminal "golang.org/x/term" - "os" - "os/signal" - "strings" - "time" ) type SrvGraphCmd struct { @@ -183,35 +184,41 @@ func (c *SrvGraphCmd) graphJetStream() error { asciigraph.Caption(fmt.Sprintf("CPU %% Used (normalized for %d cores)", vz.Cores)), asciigraph.Height(height/6-2), asciigraph.Width(width), - asciigraph.Precision(0)) + asciigraph.Precision(0), + asciigraph.ValueFormatter(f)) memPlot := asciigraph.Plot(memUsed, asciigraph.Caption("Memory Storage in GB"), asciigraph.Height(height/6-2), - asciigraph.Width(width)) + asciigraph.Width(width), + asciigraph.ValueFormatter(fiBytesFloat2Int)) filePlot := asciigraph.Plot(fileUsed, asciigraph.Caption("File Storage in GB"), asciigraph.Height(height/6-2), - asciigraph.Width(width)) + asciigraph.Width(width), + asciigraph.ValueFormatter(fiBytesFloat2Int)) assetsPlot := asciigraph.Plot(haAssets, asciigraph.Caption("HA Assets"), asciigraph.Height(height/6-2), asciigraph.Width(width), - asciigraph.Precision(0)) + asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int)) apiRatesPlot := asciigraph.Plot(apiRates, asciigraph.Caption("API Requests / second"), asciigraph.Height(height/6-2), asciigraph.Width(width), - asciigraph.Precision(0)) + asciigraph.Precision(0), + asciigraph.ValueFormatter(f)) pendingPlot := asciigraph.Plot(pending, asciigraph.Caption("Pending API Requests"), asciigraph.Height(height/6-2), asciigraph.Width(width), - asciigraph.Precision(0)) + asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int)) return []string{cpuPlot, assetsPlot, apiRatesPlot, pendingPlot, filePlot, memPlot}, nil }) @@ -257,36 +264,42 @@ func (c *SrvGraphCmd) graphServer() error { asciigraph.Caption(fmt.Sprintf("CPU %% Used (normalized for %d cores)", vz.Cores)), asciigraph.Height(height/6-2), asciigraph.Width(width), - asciigraph.Precision(0)) + asciigraph.Precision(0), + asciigraph.ValueFormatter(f)) memPlot := asciigraph.Plot(memUsed, asciigraph.Caption("Memory Used in MB"), asciigraph.Height(height/6-2), - asciigraph.Width(width)) + asciigraph.Width(width), + asciigraph.ValueFormatter(fiBytesFloat2Int)) connectionsPlot := asciigraph.Plot(connections, asciigraph.Caption("Connections"), asciigraph.Height(height/6-2), asciigraph.Width(width), - asciigraph.Precision(0)) + asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int)) subscriptionsPlot := asciigraph.Plot(subscriptions, asciigraph.Caption("Subscriptions"), asciigraph.Height(height/6-2), asciigraph.Width(width), - asciigraph.Precision(0)) + asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int)) messagesPlot := asciigraph.Plot(messagesRate, asciigraph.Caption("Messages In+Out / second"), asciigraph.Height(height/6-2), asciigraph.Width(width), - asciigraph.Precision(0)) + asciigraph.Precision(0), + asciigraph.ValueFormatter(f)) bytesPlot := asciigraph.Plot(bytesRate, asciigraph.Caption("Bytes In+Out / second"), asciigraph.Height(height/6-2), asciigraph.Width(width), - asciigraph.Precision(0)) + asciigraph.Precision(0), + asciigraph.ValueFormatter(fiBytesFloat2Int)) return []string{cpuPlot, memPlot, connectionsPlot, subscriptionsPlot, messagesPlot, bytesPlot}, nil }) diff --git a/cli/server_ping_command.go b/cli/server_ping_command.go index af7e9244..2cef4d65 100644 --- a/cli/server_ping_command.go +++ b/cli/server_ping_command.go @@ -25,9 +25,9 @@ import ( "time" "github.com/choria-io/fisk" - "github.com/guptarohit/asciigraph" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" + "github.com/nats-io/natscli/internal/asciigraph" ) type SrvPingCmd struct { diff --git a/cli/stream_command.go b/cli/stream_command.go index b25becdd..de06aad8 100644 --- a/cli/stream_command.go +++ b/cli/stream_command.go @@ -30,7 +30,7 @@ import ( "syscall" "time" - "github.com/guptarohit/asciigraph" + "github.com/nats-io/natscli/internal/asciigraph" iu "github.com/nats-io/natscli/internal/util" terminal "golang.org/x/term" @@ -503,6 +503,7 @@ func (c *streamCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/3-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(fFloat2Int), ) limitedRatePlot := asciigraph.Plot(limitedRates, @@ -511,6 +512,7 @@ func (c *streamCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/3-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) msgRatePlot := asciigraph.Plot(messageRates, @@ -519,6 +521,7 @@ func (c *streamCmd) graphAction(_ *fisk.ParseContext) error { asciigraph.Height(height/3-2), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) iu.ClearScreen() diff --git a/cli/sub_command.go b/cli/sub_command.go index 64b9a61f..cd070581 100644 --- a/cli/sub_command.go +++ b/cli/sub_command.go @@ -28,10 +28,10 @@ import ( "github.com/choria-io/fisk" "github.com/dustin/go-humanize" - "github.com/guptarohit/asciigraph" "github.com/nats-io/jsm.go" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" + "github.com/nats-io/natscli/internal/asciigraph" iu "github.com/nats-io/natscli/internal/util" terminal "golang.org/x/term" ) @@ -191,6 +191,7 @@ func (c *subCmd) startGraph(ctx context.Context, mu *sync.Mutex) { asciigraph.Height((c.height/(len(c.subjects)+1))-1), asciigraph.LowerBound(0), asciigraph.Precision(0), + asciigraph.ValueFormatter(f), ) fmt.Println(msgRatePlot) fmt.Println() diff --git a/go.mod b/go.mod index 0af99f73..287af106 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( github.com/google/go-cmp v0.6.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gosuri/uiprogress v0.0.1 - github.com/guptarohit/asciigraph v0.7.3 github.com/jedib0t/go-pretty/v6 v6.6.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/klauspost/compress v1.17.11 diff --git a/go.sum b/go.sum index 12b71767..9388d81f 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,6 @@ github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY= github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI= github.com/gosuri/uiprogress v0.0.1 h1:0kpv/XY/qTmFWl/SkaJykZXrBBzwwadmW8fRb7RJSxw= github.com/gosuri/uiprogress v0.0.1/go.mod h1:C1RTYn4Sc7iEyf6j8ft5dyoZ4212h8G1ol9QQluh5+0= -github.com/guptarohit/asciigraph v0.7.3 h1:p05XDDn7cBTWiBqWb30mrwxd6oU0claAjqeytllnsPY= -github.com/guptarohit/asciigraph v0.7.3/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= diff --git a/internal/asciigraph/LICENSE b/internal/asciigraph/LICENSE new file mode 100644 index 00000000..7918b971 --- /dev/null +++ b/internal/asciigraph/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2018, Rohit Gupta +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/internal/asciigraph/asciigraph.go b/internal/asciigraph/asciigraph.go new file mode 100644 index 00000000..59de800a --- /dev/null +++ b/internal/asciigraph/asciigraph.go @@ -0,0 +1,308 @@ +package asciigraph + +import ( + "bytes" + "fmt" + "math" + "strings" +) + +// Plot returns ascii graph for a series. +func Plot(series []float64, options ...Option) string { + return PlotMany([][]float64{series}, options...) +} + +// PlotMany returns ascii graph for multiple series. +func PlotMany(data [][]float64, options ...Option) string { + var logMaximum float64 + config := configure(config{ + Offset: 3, + Precision: 2, + AlwaysY: true, + }, options) + + // Create a deep copy of the input data + dataCopy := make([][]float64, len(data)) + for i, series := range data { + dataCopy[i] = make([]float64, len(series)) + copy(dataCopy[i], series) + } + data = dataCopy + + lenMax := 0 + for i := range data { + if l := len(data[i]); l > lenMax { + lenMax = l + } + } + + if config.Width > 0 { + for i := range data { + for j := len(data[i]); j < lenMax; j++ { + data[i] = append(data[i], math.NaN()) + } + data[i] = interpolateArray(data[i], config.Width) + } + + lenMax = config.Width + } + + minimum, maximum := math.Inf(1), math.Inf(-1) + for i := range data { + minVal, maxVal := minMaxFloat64Slice(data[i]) + if minVal < minimum { + minimum = minVal + } + if maxVal > maximum { + maximum = maxVal + } + } + + if config.LowerBound != nil && *config.LowerBound < minimum { + minimum = *config.LowerBound + } + if config.UpperBound != nil && *config.UpperBound > maximum { + maximum = *config.UpperBound + } + interval := math.Abs(maximum - minimum) + + if config.Height <= 0 { + config.Height = calculateHeight(interval) + } + + if config.Offset <= 0 { + config.Offset = 3 + } + + var ratio float64 + if interval != 0 { + ratio = float64(config.Height) / interval + } else { + ratio = 1 + } + min2 := round(minimum * ratio) + max2 := round(maximum * ratio) + + intmin2 := int(min2) + intmax2 := int(max2) + + rows := int(math.Abs(float64(intmax2 - intmin2))) + width := lenMax + config.Offset + + type cell struct { + Text string + Color AnsiColor + } + + if rows == 0 && config.AlwaysY { + rows = config.Height + intmax2 = config.Height + } + + // This guards against the extreme case where the window is very small + if rows < 0 { + rows = 0 + } + + plot := make([][]cell, rows+1) + + // initialise empty 2D grid + for i := 0; i < rows+1; i++ { + line := make([]cell, width) + for j := 0; j < width; j++ { + line[j].Text = " " + line[j].Color = Default + } + plot[i] = line + } + + precision := config.Precision + logMaximum = math.Log10(math.Max(math.Abs(maximum), math.Abs(minimum))) //to find number of zeros after decimal + if minimum == float64(0) && maximum == float64(0) { + logMaximum = float64(-1) + } + + if logMaximum < 0 { + // negative log + if math.Mod(logMaximum, 1) != 0 { + // non-zero digits after decimal + precision += uint(math.Abs(logMaximum)) + } else { + precision += uint(math.Abs(logMaximum) - 1.0) + } + } else if logMaximum > 2 { + precision = 0 + } + + maxNumLength, minNumLength := 0, math.MaxInt64 + var magnitudes []float64 + + if config.ValueFormatter == nil { + maxNumLength = len(fmt.Sprintf("%0.*f", precision, maximum)) + minNumLength = len(fmt.Sprintf("%0.*f", precision, minimum)) + } + + // calculate label magnitudes and the length when formatted using the ValueFormatter + for y := intmin2; y < intmax2+1; y++ { + var magnitude float64 + if rows > 0 { + magnitude = maximum - (float64(y-intmin2) * interval / float64(rows)) + } else { + magnitude = float64(y) + } + magnitudes = append(magnitudes, magnitude) + + if config.ValueFormatter != nil { + l := len(config.ValueFormatter(magnitude)) + if l > maxNumLength { + maxNumLength = l + } + if l < minNumLength { + minNumLength = l + } + } + } + maxWidth := int(math.Max(float64(maxNumLength), float64(minNumLength))) + + // Protect us from infinity... + if maxWidth < 0 { + maxWidth = 0 + } + + // axis and labels reusing the previously calculated magnitudes + for w, magnitude := range magnitudes { + var label string + if config.ValueFormatter == nil { + label = fmt.Sprintf("%*.*f", maxWidth+1, precision, magnitude) + } else { + val := config.ValueFormatter(magnitude) + label = strings.Repeat(" ", maxWidth+1-len(val)) + val + } + + h := int(math.Max(float64(config.Offset)-float64(len(label)), 0)) + + plot[w][h].Text = label + plot[w][h].Color = config.LabelColor + plot[w][config.Offset-1].Text = "┤" + plot[w][config.Offset-1].Color = config.AxisColor + } + + for i := range data { + series := data[i] + + color := Default + if i < len(config.SeriesColors) { + color = config.SeriesColors[i] + } + + var y0, y1 int + + if !math.IsNaN(series[0]) { + y0 = int(round(series[0]*ratio) - min2) + plot[rows-y0][config.Offset-1].Text = "┼" // first value + plot[rows-y0][config.Offset-1].Color = config.AxisColor + } + + for x := 0; x < len(series)-1; x++ { // plot the line + d0 := series[x] + d1 := series[x+1] + + if math.IsNaN(d0) && math.IsNaN(d1) { + continue + } + + if math.IsNaN(d1) && !math.IsNaN(d0) { + y0 = int(round(d0*ratio) - float64(intmin2)) + plot[rows-y0][x+config.Offset].Text = "╴" + plot[rows-y0][x+config.Offset].Color = color + continue + } + + if math.IsNaN(d0) && !math.IsNaN(d1) { + y1 = int(round(d1*ratio) - float64(intmin2)) + plot[rows-y1][x+config.Offset].Text = "╶" + plot[rows-y1][x+config.Offset].Color = color + continue + } + + y0 = int(round(d0*ratio) - float64(intmin2)) + y1 = int(round(d1*ratio) - float64(intmin2)) + + if y0 == y1 { + plot[rows-y0][x+config.Offset].Text = "─" + } else { + if y0 > y1 { + plot[rows-y1][x+config.Offset].Text = "╰" + plot[rows-y0][x+config.Offset].Text = "╮" + } else { + plot[rows-y1][x+config.Offset].Text = "╭" + plot[rows-y0][x+config.Offset].Text = "╯" + } + + 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+config.Offset].Text = "│" + } + } + + start := int(math.Min(float64(y0), float64(y1))) + end := int(math.Max(float64(y0), float64(y1))) + for y := start; y <= end; y++ { + plot[rows-y][x+config.Offset].Color = color + } + } + } + + // join columns + var lines bytes.Buffer + for h, horizontal := range plot { + if h != 0 { + lines.WriteRune('\n') + } + + // remove trailing spaces + lastCharIndex := 0 + for i := width - 1; i >= 0; i-- { + if horizontal[i].Text != " " { + lastCharIndex = i + break + } + } + + c := Default + for _, v := range horizontal[:lastCharIndex+1] { + if v.Color != c { + c = v.Color + lines.WriteString(c.String()) + } + + lines.WriteString(v.Text) + } + if c != Default { + lines.WriteString(Default.String()) + } + } + + // add caption if not empty + if config.Caption != "" { + lines.WriteRune('\n') + lines.WriteString(strings.Repeat(" ", config.Offset+maxWidth)) + if len(config.Caption) < lenMax { + lines.WriteString(strings.Repeat(" ", (lenMax-len(config.Caption))/2)) + } + if config.CaptionColor != Default { + lines.WriteString(config.CaptionColor.String()) + } + lines.WriteString(config.Caption) + if config.CaptionColor != Default { + lines.WriteString(Default.String()) + } + } + + if len(config.SeriesLegends) > 0 { + addLegends(&lines, config, lenMax, config.Offset+maxWidth) + } + + return lines.String() +} diff --git a/internal/asciigraph/asciigraph_test.go b/internal/asciigraph/asciigraph_test.go new file mode 100644 index 00000000..db0a0203 --- /dev/null +++ b/internal/asciigraph/asciigraph_test.go @@ -0,0 +1,451 @@ +package asciigraph + +import ( + "fmt" + "math" + "strings" + "testing" +) + +func TestPlot(t *testing.T) { + cases := []struct { + data []float64 + opts []Option + expected string + }{ + + { + []float64{1, 1, 1, 1, 1}, + []Option{AlwaysY(false)}, + ` 1.00 ┼────`}, + { + []float64{0, 0, 0, 0, 0}, + []Option{AlwaysY(false)}, + ` 0.00 ┼────`}, + { + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1}, + []Option{AlwaysY(false)}, + ` + 11.00 ┤ ╭╮ + 10.00 ┤ ││ + 9.00 ┤ ││ + 8.00 ┤ ││ + 7.00 ┤ ╭╯│╭╮ + 6.00 ┤ │ │││ + 5.00 ┤ ╭╯ │││ + 4.00 ┤ │ │││ + 3.00 ┤ │ ╰╯│ + 2.00 ┼╮ ╭╮│ │ + 1.00 ┤╰─╯││ ╰ + 0.00 ┤ ││ + -1.00 ┤ ││ + -2.00 ┤ ╰╯`}, + { + []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."), AlwaysY(false)}, + ` + 11.00 ┤ ╭╮ + 10.00 ┤ ││ + 9.00 ┤ ││ ╭╮ + 8.00 ┤ ││ ││ + 7.00 ┤ ╭╯│╭╮ ││ + 6.00 ┤ │ │││ ╭╯│ ╭╮ ╭╮ + 5.00 ┤ ╭╯ │││╭╯ │ ││╭╮││ + 4.00 ┤ │ ││╰╯ ╰╮││││││ + 3.00 ┤ │ ╰╯ ││││╰╯│ + 2.00 ┼╮ ╭╮│ ││││ ╰ + 1.00 ┤╰─╯││ ││╰╯ + 0.00 ┤ ││ ╰╯ + -1.00 ┤ ││ + -2.00 ┤ ╰╯ + Plot using asciigraph.`}, + { + []float64{.2, .1, .2, 2, -.9, .7, .91, .3, .7, .4, .5}, + []Option{Caption("Plot using asciigraph."), AlwaysY(false)}, + ` + 2.00 ┤ ╭╮ ╭╮ + 0.55 ┼──╯│╭╯╰─── + -0.90 ┤ ╰╯ + Plot using asciigraph.`}, + { + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1}, + []Option{Height(4), Offset(3), AlwaysY(false)}, + ` + 11.00 ┤ ╭╮ + 7.75 ┤ ╭─╯│╭╮ + 4.50 ┼╮ ╭╮│ ╰╯│ + 1.25 ┤╰─╯││ ╰ + -2.00 ┤ ╰╯`}, + { + []float64{.453, .141, .951, .251, .223, .581, .771, .191, .393, .617, .478}, + []Option{AlwaysY(false)}, + ` + 0.95 ┤ ╭╮ + 0.85 ┤ ││ ╭╮ + 0.75 ┤ ││ ││ + 0.65 ┤ ││ ╭╯│ ╭╮ + 0.55 ┤ ││ │ │ │╰ + 0.44 ┼╮││ │ │╭╯ + 0.34 ┤│││ │ ││ + 0.24 ┤││╰─╯ ╰╯ + 0.14 ┤╰╯`}, + + { + []float64{.01, .004, .003, .0042, .0083, .0033, 0.0079}, + []Option{AlwaysY(false)}, + ` + 0.010 ┼╮ + 0.009 ┤│ + 0.008 ┤│ ╭╮╭ + 0.007 ┤│ │││ + 0.006 ┤│ │││ + 0.005 ┤│ │││ + 0.004 ┤╰╮╭╯││ + 0.003 ┤ ╰╯ ╰╯`}, + + { + []float64{192, 431, 112, 449, -122, 375, 782, 123, 911, 1711, 172}, + []Option{Height(10), AlwaysY(false)}, + ` + 1711 ┤ ╭╮ + 1528 ┤ ││ + 1344 ┤ ││ + 1161 ┤ ││ + 978 ┤ ╭╯│ + 794 ┤ ╭╮│ │ + 611 ┤ │││ │ + 428 ┤╭╮╭╮╭╯││ │ + 245 ┼╯╰╯││ ╰╯ ╰ + 61 ┤ ││ + -122 ┤ ╰╯`}, + { + []float64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + []Option{Height(10), AlwaysY(true)}, + ` + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┤ + 0.00 ┼──────────`}, + { + []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."), AlwaysY(false)}, + ` + 0.32 ┼╮ ╭─╮ ╭╮ ╭ + 0.29 ┤╰╮ ╭─╮ ╭╯ │ ╭╯│ │ + 0.26 ┤ │ ╭╯ ╰╮ ╭╯ ╰╮ ╭╯ ╰╮ ╭╯ + 0.23 ┤ ╰╮ ╭╯ ╰╮│ ╰╮╭╯ ╰╮ ╭╯ + 0.20 ┤ ╰╮│ ╰╯ ╰╯ │╭╯ + 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."), AlwaysY(false)}, + ` + 10.00 ┤ ╭╮ + 8.70 ┤ ╭╮ ││ + 7.40 ┤ ││ ╭╮ ││ + 6.10 ┤ ││ ││ ││ + 4.80 ┤ ││ ││ ││ + 3.50 ┤ ││ ││ ││ + 2.20 ┤ ││ ╭╮ ││ ╭╮ ││ ╭╮ + 0.90 ┤ ╭╮ ││ ╭╯╰╮ ╭╮ ││ ╭╯╰╮ ╭╮ ││ ╭╯╰╮ + -0.40 ┼───╯╰──╯│╭─╯ ╰───────╯╰──╯│╭─╯ ╰───────╯╰──╯│╭─╯ ╰─── + -1.70 ┤ ││ ││ ││ + -3.00 ┤ ╰╯ ╰╯ ╰╯ + I'm a doctor, not an engineer.`}, + { + []float64{-5, -2, -3, -4, 0, -5, -6, -7, -8, 0, -9, -3, -5, -2, -9, -3, -1}, + []Option{AlwaysY(false)}, + ` + 0.00 ┤ ╭╮ ╭╮ + -1.00 ┤ ││ ││ ╭ + -2.00 ┤╭╮ ││ ││ ╭╮ │ + -3.00 ┤│╰╮││ ││╭╮││╭╯ + -4.00 ┤│ ╰╯│ │││││││ + -5.00 ┼╯ ╰╮ │││╰╯││ + -6.00 ┤ ╰╮ │││ ││ + -7.00 ┤ ╰╮│││ ││ + -8.00 ┤ ╰╯││ ││ + -9.00 ┤ ╰╯ ╰╯`}, + { + []float64{-0.000018527, -0.021, -.00123, .00000021312, -.0434321234, -.032413241234, .0000234234}, + []Option{Height(5), Width(45), AlwaysY(false)}, + ` + 0.000 ┼─╮ ╭────────╮ ╭ + -0.008 ┤ ╰──╮ ╭──╯ ╰─╮ ╭─╯ + -0.017 ┤ ╰─────╯ ╰╮ ╭─╯ + -0.025 ┤ ╰─╮ ╭─╯ + -0.034 ┤ ╰╮ ╭────╯ + -0.042 ┤ ╰───╯`}, + { + []float64{57.76, 54.04, 56.31, 57.02, 59.5, 52.63, 52.97, 56.44, 56.75, 52.96, 55.54, 55.09, 58.22, 56.85, 60.61, 59.62, 59.73, 59.93, 56.3, 54.69, 55.32, 54.03, 50.98, 50.48, 54.55, 47.49, 55.3, 46.74, 46, 45.8, 49.6, 48.83, 47.64, 46.61, 54.72, 42.77, 50.3, 42.79, 41.84, 44.19, 43.36, 45.62, 45.09, 44.95, 50.36, 47.21, 47.77, 52.04, 47.46, 44.19, 47.22, 45.55, 40.65, 39.64, 37.26, 40.71, 42.15, 36.45, 39.14, 36.62}, + []Option{Width(-10), Height(-10), Offset(-1), AlwaysY(false)}, + ` + 60.61 ┤ ╭╮ ╭╮ + 59.60 ┤ ╭╮ │╰─╯│ + 58.60 ┤ ││ ╭╮│ │ + 57.59 ┼╮ ╭╯│ │││ │ + 56.58 ┤│╭╯ │ ╭─╮ │╰╯ ╰╮ + 55.58 ┤││ │ │ │╭─╯ │╭╮ ╭╮ + 54.57 ┤╰╯ │ │ ││ ╰╯╰╮ ╭╮││ ╭╮ + 53.56 ┤ │╭╯ ╰╯ │ ││││ ││ + 52.56 ┤ ╰╯ │ ││││ ││ ╭╮ + 51.55 ┤ ╰╮││││ ││ ││ + 50.54 ┤ ╰╯│││ ││╭╮ ╭╮ ││ + 49.54 ┤ │││ ╭─╮ ││││ ││ ││ + 48.53 ┤ │││ │ │ ││││ ││ ││ + 47.52 ┤ ╰╯│ │ ╰╮││││ │╰─╯╰╮╭╮ + 46.52 ┤ ╰─╮│ ╰╯│││ │ │││ + 45.51 ┤ ╰╯ │││ ╭──╯ ││╰╮ + 44.50 ┤ │││ ╭╮│ ╰╯ │ + 43.50 ┤ ││╰╮│╰╯ │ + 42.49 ┤ ╰╯ ╰╯ │ ╭╮ + 41.48 ┤ │ ││ + 40.48 ┤ ╰╮ ╭╯│ + 39.47 ┤ ╰╮│ │╭╮ + 38.46 ┤ ││ │││ + 37.46 ┤ ╰╯ │││ + 36.45 ┤ ╰╯╰`}, + { + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 4, 5, 6, 9, 4, 0, 6, 1, 5, 3, 6, 2}, + []Option{LowerBound(-3), UpperBound(13), AlwaysY(false)}, + ` 13.00 ┤ + 12.00 ┤ + 11.00 ┤ ╭╮ + 10.00 ┤ ││ + 9.00 ┤ ││ ╭╮ + 8.00 ┤ ││ ││ + 7.00 ┤ ╭╯│╭╮ ││ + 6.00 ┤ │ │││ ╭╯│ ╭╮ ╭╮ + 5.00 ┤ ╭╯ │││╭╯ │ ││╭╮││ + 4.00 ┤ │ ││╰╯ ╰╮││││││ + 3.00 ┤ │ ╰╯ ││││╰╯│ + 2.00 ┼╮ ╭╮│ ││││ ╰ + 1.00 ┤╰─╯││ ││╰╯ + 0.00 ┤ ││ ╰╯ + -1.00 ┤ ││ + -2.00 ┤ ╰╯ + -3.00 ┤`}, + { + []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 4, 5, 6, 9, 4, 0, 6, 1, 5, 3, 6, 2}, + []Option{LowerBound(0), UpperBound(3), AlwaysY(false)}, + ` 11.00 ┤ ╭╮ + 10.00 ┤ ││ + 9.00 ┤ ││ ╭╮ + 8.00 ┤ ││ ││ + 7.00 ┤ ╭╯│╭╮ ││ + 6.00 ┤ │ │││ ╭╯│ ╭╮ ╭╮ + 5.00 ┤ ╭╯ │││╭╯ │ ││╭╮││ + 4.00 ┤ │ ││╰╯ ╰╮││││││ + 3.00 ┤ │ ╰╯ ││││╰╯│ + 2.00 ┼╮ ╭╮│ ││││ ╰ + 1.00 ┤╰─╯││ ││╰╯ + 0.00 ┤ ││ ╰╯ + -1.00 ┤ ││ + -2.00 ┤ ╰╯`}, + + { + []float64{1, 1, math.NaN(), 1, 1}, + []Option{AlwaysY(false)}, + ` 1.00 ┼─╴╶─`}, + { + []float64{math.NaN(), 1}, + []Option{AlwaysY(false)}, + ` 1.00 ┤╶`}, + { + []float64{0, 0, 1, 1, math.NaN(), math.NaN(), 3, 3, 4}, + []Option{AlwaysY(false)}, + ` + 4.00 ┤ ╭ + 3.00 ┤ ╶─╯ + 2.00 ┤ + 1.00 ┤ ╭─╴ + 0.00 ┼─╯`}, + { + []float64{.1, .2, .3, math.NaN(), .5, .6, .7, math.NaN(), math.NaN(), .9, 1}, + []Option{AlwaysY(false)}, + ` + 1.00 ┤ ╭ + 0.90 ┤ ╶╯ + 0.80 ┤ + 0.70 ┤ ╭╴ + 0.60 ┤ ╭╯ + 0.50 ┤ ╶╯ + 0.40 ┤ + 0.30 ┤ ╭╴ + 0.20 ┤╭╯ + 0.10 ┼╯`}, + { + []float64{-0.000018527, -0.021, -.00123, .00000021312, -.0434321234, -.032413241234, .0000234234}, + []Option{Height(5), Width(45), Precision(5), AlwaysY(false)}, + ` + 0.000023 ┼─╮ ╭────────╮ ╭ + -0.008467 ┤ ╰──╮ ╭──╯ ╰─╮ ╭─╯ + -0.016958 ┤ ╰─────╯ ╰╮ ╭─╯ + -0.025449 ┤ ╰─╮ ╭─╯ + -0.033940 ┤ ╰╮ ╭────╯ + -0.042430 ┤ ╰───╯`}, + + { + []float64{math.NaN(), 1}, + []Option{Caption("color test"), CaptionColor(Red), AxisColor(Green), LabelColor(Blue), AlwaysY(false)}, + ` +\x1b[94m 1.00\x1b[0m \x1b[32m┤\x1b[0m╶ + \x1b[91mcolor test\x1b[0m`}, + { + []float64{.02, .03, .02}, + []Option{AlwaysY(false)}, + ` + 0.030 ┤╭╮ + 0.020 ┼╯╰`}, + { + []float64{.2, .3, .1, .3}, + []Option{AlwaysY(false)}, + ` + 0.30 ┤╭╮╭ + 0.20 ┼╯││ + 0.10 ┤ ╰╯`}, + { + []float64{70 * 1024 * 1024 * 1024, 90 * 1024 * 1024 * 1024, 80 * 1024 * 1024 * 1024, 2 * 1024 * 1024 * 1024}, + []Option{Height(5), Width(45), ValueFormatter(func(v any) string { + return fmt.Sprintf("%.2f Foo", v.(float64)/1024/1024/1024) + }), AlwaysY(false)}, + ` 89.77 Foo ┤ ╭──────────────────────╮ + 72.22 Foo ┼──────╯ ╰──╮ + 54.66 Foo ┤ ╰───╮ + 37.11 Foo ┤ ╰──╮ + 19.55 Foo ┤ ╰──╮ + 2.00 Foo ┤ ╰─`, + }, + } + + for i := range cases { + name := fmt.Sprintf("%d", i) + t.Run(name, func(t *testing.T) { + c := cases[i] + expected := strings.Replace(strings.TrimPrefix(c.expected, "\n"), `\x1b`, "\x1b", -1) + actual := Plot(c.data, c.opts...) + if actual != expected { + conf := configure(config{AlwaysY: false}, c.opts) + t.Errorf("Plot(%f, %#v)", c.data, conf) + t.Logf("expected:\n%s\n", expected) + } + t.Logf("actual:\n%s\n", actual) + }) + } +} + +func TestPlotMany(t *testing.T) { + cases := []struct { + data [][]float64 + opts []Option + expected string + }{ + { + [][]float64{{0}, {1}, {2}}, + nil, + ` + 2.00 ┼ + 1.00 ┼ + 0.00 ┼`}, + { + [][]float64{{0, 0, 2, 2, math.NaN()}, {1, 1, 1, 1, 1, 1, 1}, {math.NaN(), math.NaN(), math.NaN(), 0, 0, 2, 2}}, + nil, + ` + 2.00 ┤ ╭─╴╭─ + 1.00 ┼────│─ + 0.00 ┼─╯╶─╯`}, + { + [][]float64{{0, 0, 0}, {math.NaN(), 0, 0}, {math.NaN(), math.NaN(), 0}}, + nil, + ` 0.00 ┼╶╶`}, + { + [][]float64{{0, 1, 0}, {2, 3, 4, 3, 2}, {4, 5, 6, 7, 6, 5, 4}}, + []Option{Width(21), Caption("interpolation test"), AlwaysY(false)}, + ` + 7.00 ┤ ╭──╮ + 6.00 ┤ ╭───╯ ╰───╮ + 5.00 ┤ ╭──╯ ╰──╮ + 4.00 ┼─╯ ╭───╮ ╰─ + 3.00 ┤ ╭──╯ ╰──╮ + 2.00 ┼─╯ ╰─╴ + 1.00 ┤ ╭───╮ + 0.00 ┼─╯ ╰╴ + interpolation test`}, + + { + [][]float64{{0, 0}, {math.NaN(), 0}}, + []Option{SeriesColors(Red), AlwaysY(false)}, + " 0.00 ┼╶"}, + { + [][]float64{{0, 0}, {math.NaN(), 0}}, + []Option{SeriesColors(Default, Red), AlwaysY(false)}, + " 0.00 ┼\x1b[91m╶\x1b[0m"}, + { + [][]float64{{math.NaN(), 0, 2}, {0, 2}}, + []Option{SeriesColors(Red, Red), AlwaysY(false)}, + ` + 2.00 ┤\x1b[91m╭╭\x1b[0m + 1.00 ┤\x1b[91m││\x1b[0m + 0.00 ┼\x1b[91m╯╯\x1b[0m`}, + { + [][]float64{{0, 1, 0}, {2, 3, 4, 3, 2}}, + []Option{SeriesColors(Red, Blue), SeriesLegends("Red", "Blue"), + Caption("legends with caption test"), AlwaysY(false)}, + ` + 4.00 ┤ ╭╮ + 3.00 ┤╭╯╰╮ + 2.00 ┼╯ ╰ + 1.00 ┤╭╮ + 0.00 ┼╯╰ + legends with caption test + + ■ Red ■ Blue`}, + } + + for i := range cases { + name := fmt.Sprintf("%d", i) + t.Run(name, func(t *testing.T) { + c := cases[i] + expected := strings.Replace(strings.TrimPrefix(c.expected, "\n"), `\x1b`, "\x1b", -1) + actual := PlotMany(c.data, c.opts...) + if actual != expected { + conf := configure(config{}, c.opts) + t.Errorf("Plot(%f, %#v)", c.data, conf) + t.Logf("expected:\n%s\n", expected) + } + t.Logf("actual:\n%s\n", actual) + }) + } +} + +func BenchmarkPlot(b *testing.B) { + data := []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1} + opts := []Option{Height(4), Offset(3)} + + for i := 0; i < b.N; i++ { + Plot(data, opts...) + } +} + +func BenchmarkPlotMany(b *testing.B) { + data1 := []float64{2, 1, 1, 2, -2, 5, 7, 11, 3, 7, 1} + data2 := []float64{5, 3, 2, 7, 1, -2, 9, 4, 3, 2, 1} + opts := []Option{Height(4), Offset(3)} + datasets := [][]float64{data1, data2} + + for i := 0; i < b.N; i++ { + PlotMany(datasets, opts...) + } +} diff --git a/internal/asciigraph/color.go b/internal/asciigraph/color.go new file mode 100644 index 00000000..1c828e52 --- /dev/null +++ b/internal/asciigraph/color.go @@ -0,0 +1,312 @@ +package asciigraph + +import "fmt" + +type AnsiColor byte + +var ( + Default AnsiColor = 0 + AliceBlue AnsiColor = 255 + AntiqueWhite AnsiColor = 255 + Aqua AnsiColor = 14 + Aquamarine AnsiColor = 122 + Azure AnsiColor = 15 + Beige AnsiColor = 230 + Bisque AnsiColor = 224 + Black AnsiColor = 188 // dummy value + BlanchedAlmond AnsiColor = 230 + Blue AnsiColor = 12 + BlueViolet AnsiColor = 92 + Brown AnsiColor = 88 + BurlyWood AnsiColor = 180 + CadetBlue AnsiColor = 73 + Chartreuse AnsiColor = 118 + Chocolate AnsiColor = 166 + Coral AnsiColor = 209 + CornflowerBlue AnsiColor = 68 + Cornsilk AnsiColor = 230 + Crimson AnsiColor = 161 + Cyan AnsiColor = 14 + DarkBlue AnsiColor = 18 + DarkCyan AnsiColor = 30 + DarkGoldenrod AnsiColor = 136 + DarkGray AnsiColor = 248 + DarkGreen AnsiColor = 22 + DarkKhaki AnsiColor = 143 + DarkMagenta AnsiColor = 90 + DarkOliveGreen AnsiColor = 59 + DarkOrange AnsiColor = 208 + DarkOrchid AnsiColor = 134 + DarkRed AnsiColor = 88 + DarkSalmon AnsiColor = 173 + DarkSeaGreen AnsiColor = 108 + DarkSlateBlue AnsiColor = 60 + DarkSlateGray AnsiColor = 238 + DarkTurquoise AnsiColor = 44 + DarkViolet AnsiColor = 92 + DeepPink AnsiColor = 198 + DeepSkyBlue AnsiColor = 39 + DimGray AnsiColor = 242 + DodgerBlue AnsiColor = 33 + Firebrick AnsiColor = 124 + FloralWhite AnsiColor = 15 + ForestGreen AnsiColor = 28 + Fuchsia AnsiColor = 13 + Gainsboro AnsiColor = 253 + GhostWhite AnsiColor = 15 + Gold AnsiColor = 220 + Goldenrod AnsiColor = 178 + Gray AnsiColor = 8 + Green AnsiColor = 2 + GreenYellow AnsiColor = 155 + Honeydew AnsiColor = 15 + HotPink AnsiColor = 205 + IndianRed AnsiColor = 167 + Indigo AnsiColor = 54 + Ivory AnsiColor = 15 + Khaki AnsiColor = 222 + Lavender AnsiColor = 254 + LavenderBlush AnsiColor = 255 + LawnGreen AnsiColor = 118 + LemonChiffon AnsiColor = 230 + LightBlue AnsiColor = 152 + LightCoral AnsiColor = 210 + LightCyan AnsiColor = 195 + LightGoldenrodYellow AnsiColor = 230 + LightGray AnsiColor = 252 + LightGreen AnsiColor = 120 + LightPink AnsiColor = 217 + LightSalmon AnsiColor = 216 + LightSeaGreen AnsiColor = 37 + LightSkyBlue AnsiColor = 117 + LightSlateGray AnsiColor = 103 + LightSteelBlue AnsiColor = 152 + LightYellow AnsiColor = 230 + Lime AnsiColor = 10 + LimeGreen AnsiColor = 77 + Linen AnsiColor = 255 + Magenta AnsiColor = 13 + Maroon AnsiColor = 1 + MediumAquamarine AnsiColor = 79 + MediumBlue AnsiColor = 20 + MediumOrchid AnsiColor = 134 + MediumPurple AnsiColor = 98 + MediumSeaGreen AnsiColor = 72 + MediumSlateBlue AnsiColor = 99 + MediumSpringGreen AnsiColor = 48 + MediumTurquoise AnsiColor = 80 + MediumVioletRed AnsiColor = 162 + MidnightBlue AnsiColor = 17 + MintCream AnsiColor = 15 + MistyRose AnsiColor = 224 + Moccasin AnsiColor = 223 + NavajoWhite AnsiColor = 223 + Navy AnsiColor = 4 + OldLace AnsiColor = 230 + Olive AnsiColor = 3 + OliveDrab AnsiColor = 64 + Orange AnsiColor = 214 + OrangeRed AnsiColor = 202 + Orchid AnsiColor = 170 + PaleGoldenrod AnsiColor = 223 + PaleGreen AnsiColor = 120 + PaleTurquoise AnsiColor = 159 + PaleVioletRed AnsiColor = 168 + PapayaWhip AnsiColor = 230 + PeachPuff AnsiColor = 223 + Peru AnsiColor = 173 + Pink AnsiColor = 218 + Plum AnsiColor = 182 + PowderBlue AnsiColor = 152 + Purple AnsiColor = 5 + Red AnsiColor = 9 + RosyBrown AnsiColor = 138 + RoyalBlue AnsiColor = 63 + SaddleBrown AnsiColor = 94 + Salmon AnsiColor = 210 + SandyBrown AnsiColor = 215 + SeaGreen AnsiColor = 29 + SeaShell AnsiColor = 15 + Sienna AnsiColor = 131 + Silver AnsiColor = 7 + SkyBlue AnsiColor = 117 + SlateBlue AnsiColor = 62 + SlateGray AnsiColor = 66 + Snow AnsiColor = 15 + SpringGreen AnsiColor = 48 + SteelBlue AnsiColor = 67 + Tan AnsiColor = 180 + Teal AnsiColor = 6 + Thistle AnsiColor = 182 + Tomato AnsiColor = 203 + Turquoise AnsiColor = 80 + Violet AnsiColor = 213 + Wheat AnsiColor = 223 + White AnsiColor = 15 + WhiteSmoke AnsiColor = 255 + Yellow AnsiColor = 11 + YellowGreen AnsiColor = 149 +) + +var ColorNames = map[string]AnsiColor{ + "default": Default, + "aliceblue": AliceBlue, + "antiquewhite": AntiqueWhite, + "aqua": Aqua, + "aquamarine": Aquamarine, + "azure": Azure, + "beige": Beige, + "bisque": Bisque, + "black": Black, + "blanchedalmond": BlanchedAlmond, + "blue": Blue, + "blueviolet": BlueViolet, + "brown": Brown, + "burlywood": BurlyWood, + "cadetblue": CadetBlue, + "chartreuse": Chartreuse, + "chocolate": Chocolate, + "coral": Coral, + "cornflowerblue": CornflowerBlue, + "cornsilk": Cornsilk, + "crimson": Crimson, + "cyan": Cyan, + "darkblue": DarkBlue, + "darkcyan": DarkCyan, + "darkgoldenrod": DarkGoldenrod, + "darkgray": DarkGray, + "darkgreen": DarkGreen, + "darkkhaki": DarkKhaki, + "darkmagenta": DarkMagenta, + "darkolivegreen": DarkOliveGreen, + "darkorange": DarkOrange, + "darkorchid": DarkOrchid, + "darkred": DarkRed, + "darksalmon": DarkSalmon, + "darkseagreen": DarkSeaGreen, + "darkslateblue": DarkSlateBlue, + "darkslategray": DarkSlateGray, + "darkturquoise": DarkTurquoise, + "darkviolet": DarkViolet, + "deeppink": DeepPink, + "deepskyblue": DeepSkyBlue, + "dimgray": DimGray, + "dodgerblue": DodgerBlue, + "firebrick": Firebrick, + "floralwhite": FloralWhite, + "forestgreen": ForestGreen, + "fuchsia": Fuchsia, + "gainsboro": Gainsboro, + "ghostwhite": GhostWhite, + "gold": Gold, + "goldenrod": Goldenrod, + "gray": Gray, + "green": Green, + "greenyellow": GreenYellow, + "honeydew": Honeydew, + "hotpink": HotPink, + "indianred": IndianRed, + "indigo": Indigo, + "ivory": Ivory, + "khaki": Khaki, + "lavender": Lavender, + "lavenderblush": LavenderBlush, + "lawngreen": LawnGreen, + "lemonchiffon": LemonChiffon, + "lightblue": LightBlue, + "lightcoral": LightCoral, + "lightcyan": LightCyan, + "lightgoldenrodyellow": LightGoldenrodYellow, + "lightgray": LightGray, + "lightgreen": LightGreen, + "lightpink": LightPink, + "lightsalmon": LightSalmon, + "lightseagreen": LightSeaGreen, + "lightskyblue": LightSkyBlue, + "lightslategray": LightSlateGray, + "lightsteelblue": LightSteelBlue, + "lightyellow": LightYellow, + "lime": Lime, + "limegreen": LimeGreen, + "linen": Linen, + "magenta": Magenta, + "maroon": Maroon, + "mediumaquamarine": MediumAquamarine, + "mediumblue": MediumBlue, + "mediumorchid": MediumOrchid, + "mediumpurple": MediumPurple, + "mediumseagreen": MediumSeaGreen, + "mediumslateblue": MediumSlateBlue, + "mediumspringgreen": MediumSpringGreen, + "mediumturquoise": MediumTurquoise, + "mediumvioletred": MediumVioletRed, + "midnightblue": MidnightBlue, + "mintcream": MintCream, + "mistyrose": MistyRose, + "moccasin": Moccasin, + "navajowhite": NavajoWhite, + "navy": Navy, + "oldlace": OldLace, + "olive": Olive, + "olivedrab": OliveDrab, + "orange": Orange, + "orangered": OrangeRed, + "orchid": Orchid, + "palegoldenrod": PaleGoldenrod, + "palegreen": PaleGreen, + "paleturquoise": PaleTurquoise, + "palevioletred": PaleVioletRed, + "papayawhip": PapayaWhip, + "peachpuff": PeachPuff, + "peru": Peru, + "pink": Pink, + "plum": Plum, + "powderblue": PowderBlue, + "purple": Purple, + "red": Red, + "rosybrown": RosyBrown, + "royalblue": RoyalBlue, + "saddlebrown": SaddleBrown, + "salmon": Salmon, + "sandybrown": SandyBrown, + "seagreen": SeaGreen, + "seashell": SeaShell, + "sienna": Sienna, + "silver": Silver, + "skyblue": SkyBlue, + "slateblue": SlateBlue, + "slategray": SlateGray, + "snow": Snow, + "springgreen": SpringGreen, + "steelblue": SteelBlue, + "tan": Tan, + "teal": Teal, + "thistle": Thistle, + "tomato": Tomato, + "turquoise": Turquoise, + "violet": Violet, + "wheat": Wheat, + "white": White, + "whitesmoke": WhiteSmoke, + "yellow": Yellow, + "yellowgreen": YellowGreen, +} + +func (c AnsiColor) String() string { + if c == Default { + return "\x1b[0m" + } + if c == Black { + c = 0 + } + if c <= Silver { + // 3-bit color + return fmt.Sprintf("\x1b[%dm", 30+byte(c)) + } + if c <= White { + // 4-bit color + return fmt.Sprintf("\x1b[%dm", 82+byte(c)) + } + // 8-bit color + return fmt.Sprintf("\x1b[38;5;%dm", byte(c)) +} diff --git a/internal/asciigraph/legend.go b/internal/asciigraph/legend.go new file mode 100644 index 00000000..42d98982 --- /dev/null +++ b/internal/asciigraph/legend.go @@ -0,0 +1,45 @@ +package asciigraph + +import ( + "bytes" + "fmt" + "strings" + "unicode/utf8" +) + +// Create legend item as a colored box and text +func createLegendItem(text string, color AnsiColor) (string, int) { + return fmt.Sprintf( + "%s■%s %s", + color.String(), + Default.String(), + text, + ), + // Can't use len() because of AnsiColor, add 2 for box and space + utf8.RuneCountInString(text) + 2 +} + +// Add legend for each series added to the graph +func addLegends(lines *bytes.Buffer, config *config, lenMax int, leftPad int) { + lines.WriteString("\n\n") + lines.WriteString(strings.Repeat(" ", leftPad)) + + var legendsText string + var legendsTextLen int + rightPad := 3 + for i, text := range config.SeriesLegends { + item, itemLen := createLegendItem(text, config.SeriesColors[i]) + legendsText += item + legendsTextLen += itemLen + + if i < len(config.SeriesLegends)-1 { + legendsText += strings.Repeat(" ", rightPad) + legendsTextLen += rightPad + } + } + + if legendsTextLen < lenMax { + lines.WriteString(strings.Repeat(" ", (lenMax-legendsTextLen)/2)) + } + lines.WriteString(legendsText) +} diff --git a/internal/asciigraph/options.go b/internal/asciigraph/options.go new file mode 100644 index 00000000..18d723f4 --- /dev/null +++ b/internal/asciigraph/options.go @@ -0,0 +1,144 @@ +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 + LowerBound, UpperBound *float64 + Offset int + Caption string + Precision uint + CaptionColor AnsiColor + AxisColor AnsiColor + LabelColor AnsiColor + SeriesColors []AnsiColor + SeriesLegends []string + ValueFormatter NumberFormatter + AlwaysY bool +} + +type NumberFormatter func(any) 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 + } + }) +} + +// LowerBound sets the graph's minimum value for the vertical axis. It will be ignored +// if the series contains a lower value. +func LowerBound(min float64) Option { + return optionFunc(func(c *config) { c.LowerBound = &min }) +} + +// UpperBound sets the graph's maximum value for the vertical axis. It will be ignored +// if the series contains a bigger value. +func UpperBound(max float64) Option { + return optionFunc(func(c *config) { c.UpperBound = &max }) +} + +// Offset sets the graphs offset. +func Offset(o int) Option { + return optionFunc(func(c *config) { c.Offset = o }) +} + +// Precision sets the graphs precision. +func Precision(p uint) Option { + return optionFunc(func(c *config) { c.Precision = p }) +} + +// Caption sets the graphs caption. +func Caption(caption string) Option { + return optionFunc(func(c *config) { + c.Caption = strings.TrimSpace(caption) + }) +} + +// CaptionColor sets the caption color. +func CaptionColor(ac AnsiColor) Option { + return optionFunc(func(c *config) { + c.CaptionColor = ac + }) +} + +// AxisColor sets the axis color. +func AxisColor(ac AnsiColor) Option { + return optionFunc(func(c *config) { + c.AxisColor = ac + }) +} + +// LabelColor sets the axis label color. +func LabelColor(ac AnsiColor) Option { + return optionFunc(func(c *config) { + c.LabelColor = ac + }) +} + +// SeriesColors sets the series colors. +func SeriesColors(ac ...AnsiColor) Option { + return optionFunc(func(c *config) { + c.SeriesColors = ac + }) +} + +// SeriesLegends sets the legend text for the corresponding series. +func SeriesLegends(text ...string) Option { + return optionFunc(func(c *config) { + c.SeriesLegends = text + }) +} + +// ValueFormatter formats values printed to the side of graphs +func ValueFormatter(f NumberFormatter) Option { + return optionFunc(func(c *config) { + c.ValueFormatter = f + }) +} + +// AxisColor sets the axis color. +func AlwaysY(ay bool) Option { + return optionFunc(func(c *config) { + c.AlwaysY = ay + }) +} diff --git a/internal/asciigraph/utils.go b/internal/asciigraph/utils.go new file mode 100644 index 00000000..fdade455 --- /dev/null +++ b/internal/asciigraph/utils.go @@ -0,0 +1,104 @@ +package asciigraph + +import ( + "fmt" + "log" + "math" + "os" + "os/exec" + "runtime" +) + +func minMaxFloat64Slice(v []float64) (min, max float64) { + min = math.Inf(1) + max = math.Inf(-1) + + if len(v) == 0 { + panic("Empty slice") + } + + for _, e := range v { + if e < min { + min = e + } + if e > max { + max = e + } + } + return +} + +func round(input float64) float64 { + if math.IsNaN(input) { + return math.NaN() + } + sign := 1.0 + if input < 0 { + sign = -1 + input *= -1 + } + _, decimal := math.Modf(input) + var rounded float64 + if decimal >= 0.5 { + rounded = math.Ceil(input) + } else { + rounded = math.Floor(input) + } + return rounded * sign +} + +func linearInterpolate(before, after, atPoint float64) float64 { + return before + (after-before)*atPoint +} + +func interpolateArray(data []float64, fitCount int) []float64 { + var interpolatedData []float64 + + springFactor := float64(len(data)-1) / float64(fitCount-1) + interpolatedData = append(interpolatedData, data[0]) + + for i := 1; i < fitCount-1; i++ { + spring := float64(i) * springFactor + before := math.Floor(spring) + after := math.Ceil(spring) + atPoint := spring - before + interpolatedData = append(interpolatedData, linearInterpolate(data[int(before)], data[int(after)], atPoint)) + } + interpolatedData = append(interpolatedData, data[len(data)-1]) + return interpolatedData +} + +// clear terminal screen +var Clear func() + +func init() { + platform := runtime.GOOS + + if platform == "windows" { + Clear = func() { + cmd := exec.Command("cmd", "/c", "cls") + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + log.Fatal(err) + } + } + } else { + Clear = func() { + fmt.Print("\033[2J\033[H") + } + } +} + +func calculateHeight(interval float64) int { + if interval >= 1 { + return int(interval) + } + + scaleFactor := math.Pow(10, math.Floor(math.Log10(interval))) + scaledDelta := interval / scaleFactor + + if scaledDelta < 2 { + return int(math.Ceil(scaledDelta)) + } + return int(math.Floor(scaledDelta)) +} diff --git a/internal/util/util.go b/internal/util/util.go index 2de121ce..603b561d 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -31,9 +31,9 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/dustin/go-humanize" "github.com/google/shlex" - "github.com/guptarohit/asciigraph" "github.com/nats-io/jsm.go" "github.com/nats-io/nats.go" + "github.com/nats-io/natscli/internal/asciigraph" "github.com/nats-io/natscli/options" "golang.org/x/exp/constraints"