From 6379b1d28e6b38e13810cba20742da9d6034428f Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Thu, 14 Feb 2019 23:13:21 -0500 Subject: [PATCH] Allow users to provide custom Y axis scale for the LineChart. --- CHANGELOG.md | 4 + termdashdemo/termdashdemo.go | 30 +- widgets/linechart/linechart.go | 30 +- widgets/linechart/linechart_test.go | 313 +++++++++++++++++- .../linechart/linechartdemo/linechartdemo.go | 5 +- widgets/linechart/options.go | 44 +++ 6 files changed, 409 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 626f866..37ab329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A function that draws text in vertically. - The LineChart widget can display X axis labels in vertical orientation. +- The LineChart widget allows the user to specify a custom scale for the Y + axis. ### Changed - Termbox is now initialized in 256 color mode by default. - Generalized mouse button FSM for use in widgets that need to track mouse button clicks. +- The constructor of the LineChart widget now also returns an error so that it + can validate its options. This is a breaking change on the LineChart API. ## [0.6.1] - 12-Feb-2019 diff --git a/termdashdemo/termdashdemo.go b/termdashdemo/termdashdemo.go index 60820d1..1246d1a 100644 --- a/termdashdemo/termdashdemo.go +++ b/termdashdemo/termdashdemo.go @@ -77,6 +77,10 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, ), } + heartLC, err := newHeartbeat(ctx) + if err != nil { + return nil, err + } gaugeAndHeartbeat := []container.Option{ container.SplitHorizontal( container.Top( @@ -88,7 +92,7 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, container.Bottom( container.Border(draw.LineStyleLight), container.BorderTitle("A LineChart"), - container.PlaceWidget(newHeartbeat(ctx)), + container.PlaceWidget(heartLC), ), container.SplitPercent(20), ), @@ -107,6 +111,10 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, return nil, err } + sineLC, err := newSines(ctx) + if err != nil { + return nil, err + } rightSide := []container.Option{ container.SplitHorizontal( container.Top( @@ -127,7 +135,7 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, container.Border(draw.LineStyleLight), container.BorderTitle("Multiple series"), container.BorderTitleAlignRight(), - container.PlaceWidget(newSines(ctx)), + container.PlaceWidget(sineLC), ), container.SplitPercent(30), ), @@ -317,18 +325,21 @@ func newDonut(ctx context.Context) (*donut.Donut, error) { } // newHeartbeat returns a line chart that displays a heartbeat-like progression. -func newHeartbeat(ctx context.Context) *linechart.LineChart { +func newHeartbeat(ctx context.Context) (*linechart.LineChart, error) { var inputs []float64 for i := 0; i < 100; i++ { v := math.Pow(math.Sin(float64(i)), 63) * math.Sin(float64(i)+1.5) * 8 inputs = append(inputs, v) } - lc := linechart.New( + lc, err := linechart.New( linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)), ) + if err != nil { + return nil, err + } step := 0 go periodic(ctx, redrawInterval/3, func() error { step = (step + 1) % len(inputs) @@ -339,7 +350,7 @@ func newHeartbeat(ctx context.Context) *linechart.LineChart { }), ) }) - return lc + return lc, nil } // newBarChart returns a BarcChart that displays random values on multiple bars. @@ -380,18 +391,21 @@ func newBarChart(ctx context.Context) *barchart.BarChart { } // newSines returns a line chart that displays multiple sine series. -func newSines(ctx context.Context) *linechart.LineChart { +func newSines(ctx context.Context) (*linechart.LineChart, error) { var inputs []float64 for i := 0; i < 200; i++ { v := math.Sin(float64(i) / 100 * math.Pi) inputs = append(inputs, v) } - lc := linechart.New( + lc, err := linechart.New( linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)), ) + if err != nil { + return nil, err + } step1 := 0 go periodic(ctx, redrawInterval/3, func() error { step1 = (step1 + 1) % len(inputs) @@ -404,7 +418,7 @@ func newSines(ctx context.Context) *linechart.LineChart { step2 := (step1 + 100) % len(inputs) return lc.Series("second", rotateFloats(inputs, step2), linechart.SeriesCellOpts(cell.FgColor(cell.ColorWhite))) }) - return lc + return lc, nil } // rotateFloats returns a new slice with inputs rotated by step. diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go index a64e83a..d596c7d 100644 --- a/widgets/linechart/linechart.go +++ b/widgets/linechart/linechart.go @@ -90,13 +90,16 @@ type LineChart struct { } // New returns a new line chart widget. -func New(opts ...Option) *LineChart { +func New(opts ...Option) (*LineChart, error) { opt := newOptions(opts...) + if err := opt.validate(); err != nil { + return nil, err + } return &LineChart{ series: map[string]*seriesValues{}, yAxis: axes.NewY(0, 0), opts: opt, - } + }, nil } // SeriesOption is used to provide options to Series. @@ -136,6 +139,27 @@ func SeriesXLabels(labels map[int]string) SeriesOption { }) } +// yMinMax determines the min and max values for the Y axis. +func (lc *LineChart) yMinMax() (float64, float64) { + var ( + minimums []float64 + maximums []float64 + ) + for _, sv := range lc.series { + minimums = append(minimums, sv.min) + maximums = append(maximums, sv.max) + } + + if lc.opts.yAxisCustomScale != nil { + minimums = append(minimums, lc.opts.yAxisCustomScale.min) + maximums = append(maximums, lc.opts.yAxisCustomScale.max) + } + + min, _ := numbers.MinMax(minimums) + _, max := numbers.MinMax(maximums) + return min, max +} + // Series sets the values that should be displayed as the line chart with the // provided label. // Subsequent calls with the same label replace any previously provided values. @@ -164,7 +188,7 @@ func (lc *LineChart) Series(label string, values []float64, opts ...SeriesOption } lc.series[label] = series - lc.yAxis = axes.NewY(series.min, series.max) + lc.yAxis = axes.NewY(lc.yMinMax()) return nil } diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go index b0bf7a2..6cce4a4 100644 --- a/widgets/linechart/linechart_test.go +++ b/widgets/linechart/linechart_test.go @@ -16,6 +16,7 @@ package linechart import ( "image" + "math" "testing" "github.com/kylelemons/godebug/pretty" @@ -36,9 +37,42 @@ func TestLineChartDraws(t *testing.T) { opts []Option writes func(*LineChart) error want func(size image.Point) *faketerm.Terminal - wantWriteErr bool wantErr bool + wantWriteErr bool + wantDrawErr bool }{ + { + desc: "fails with custom scale where min is NaN", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + YAxisCustomScale(math.NaN(), 1), + }, + wantErr: true, + }, + { + desc: "fails with custom scale where max is NaN", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + YAxisCustomScale(0, math.NaN()), + }, + wantErr: true, + }, + { + desc: "fails with custom scale where min > max", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + YAxisCustomScale(1, 0), + }, + wantErr: true, + }, + { + desc: "fails with custom scale where min == max", + canvas: image.Rect(0, 0, 3, 4), + opts: []Option{ + YAxisCustomScale(1, 1), + }, + wantErr: true, + }, { desc: "series fails without name for the series", canvas: image.Rect(0, 0, 3, 4), @@ -190,6 +224,187 @@ func TestLineChartDraws(t *testing.T) { return ft }, }, + { + desc: "custom Y scale, zero based positive, values fit", + opts: []Option{ + YAxisCustomScale(0, 200), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{6, 0}, End: image.Point{6, 8}}, + {Start: image.Point{6, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{5, 7}) + testdraw.MustText(c, "103.36", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{7, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(7, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{25, 16}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "custom Y scale, zero based negative, values fit", + opts: []Option{ + YAxisCustomScale(-200, 0), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, -200}) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{6, 0}, End: image.Point{6, 8}}, + {Start: image.Point{6, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-200", image.Point{2, 7}) + testdraw.MustText(c, "-96.64", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{7, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(7, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 0}, image.Point{25, 31}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "custom Y scale, negative and positive, values fit", + opts: []Option{ + YAxisCustomScale(-200, 200), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{4, 0}, End: image.Point{4, 8}}, + {Start: image.Point{4, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-200", image.Point{0, 7}) + testdraw.MustText(c, "6.57", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{5, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 16}, image.Point{29, 8}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "custom Y scale, negative only, values fit", + opts: []Option{ + YAxisCustomScale(-200, -100), + YAxisAdaptive(), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{-200, -100}) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{7, 0}, End: image.Point{7, 8}}, + {Start: image.Point{7, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-200", image.Point{3, 7}) + testdraw.MustText(c, "-148.32", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{8, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(8, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{23, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "custom Y scale, negative and positive, values don't fit so adjusted", + opts: []Option{ + YAxisCustomScale(-200, 200), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{-400, 400}) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-400", image.Point{1, 7}) + testdraw.MustText(c, "12.96", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, { desc: "draws anchored Y axis", canvas: image.Rect(0, 0, 20, 10), @@ -544,6 +759,84 @@ func TestLineChartDraws(t *testing.T) { testdraw.MustBrailleLine(bc, image.Point{0, 0}, image.Point{13, 31}, draw.BrailleLineCellOpts(cell.FgColor(cell.ColorBlue))) testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draw multiple series, the second has smaller scale than the first", + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 50, 100}); err != nil { + return err + } + return lc.Series("second", []float64{10, 20}) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "51.68", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{12, 9}) + testdraw.MustText(c, "2", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{13, 16}) + testdraw.MustBrailleLine(bc, image.Point{13, 16}, image.Point{27, 0}) + testdraw.MustBrailleLine(bc, image.Point{0, 28}, image.Point{13, 25}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draw multiple series, the second has larger scale than the first", + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 50, 100}); err != nil { + return err + } + return lc.Series("second", []float64{-10, 200}) + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + c := testcanvas.MustNew(ft.Area()) + + // Y and X axis. + lines := []draw.HVLine{ + {Start: image.Point{5, 0}, End: image.Point{5, 8}}, + {Start: image.Point{5, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "-10", image.Point{2, 7}) + testdraw.MustText(c, "98.48", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{12, 9}) + testdraw.MustText(c, "2", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 30}, image.Point{13, 22}) + testdraw.MustBrailleLine(bc, image.Point{13, 22}, image.Point{27, 15}) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{13, 0}) + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) return ft }, @@ -557,7 +850,14 @@ func TestLineChartDraws(t *testing.T) { t.Fatalf("canvas.New => unexpected error: %v", err) } - widget := New(tc.opts...) + widget, err := New(tc.opts...) + if (err != nil) != tc.wantErr { + t.Errorf("New => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + if tc.writes != nil { err := tc.writes(widget) if (err != nil) != tc.wantWriteErr { @@ -570,8 +870,8 @@ func TestLineChartDraws(t *testing.T) { { err := widget.Draw(c) - if (err != nil) != tc.wantErr { - t.Fatalf("Draw => unexpected error: %v, wantErr: %v", err, tc.wantErr) + if (err != nil) != tc.wantDrawErr { + t.Fatalf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) } if err != nil { return @@ -658,7 +958,10 @@ func TestOptions(t *testing.T) { for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - lc := New(tc.opts...) + lc, err := New(tc.opts...) + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } if tc.addSeries != nil { if err := tc.addSeries(lc); err != nil { diff --git a/widgets/linechart/linechartdemo/linechartdemo.go b/widgets/linechart/linechartdemo/linechartdemo.go index 063b894..2e84259 100644 --- a/widgets/linechart/linechartdemo/linechartdemo.go +++ b/widgets/linechart/linechartdemo/linechartdemo.go @@ -82,11 +82,14 @@ func main() { const redrawInterval = 250 * time.Millisecond ctx, cancel := context.WithCancel(context.Background()) - lc := linechart.New( + lc, err := linechart.New( linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), linechart.XLabelCellOpts(cell.FgColor(cell.ColorCyan)), ) + if err != nil { + panic(err) + } go playLineChart(ctx, lc, redrawInterval/3) c, err := container.New( t, diff --git a/widgets/linechart/options.go b/widgets/linechart/options.go index 5e77f68..fed3bdf 100644 --- a/widgets/linechart/options.go +++ b/widgets/linechart/options.go @@ -15,6 +15,9 @@ package linechart import ( + "fmt" + "math" + "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/widgets/linechart/axes" ) @@ -34,6 +37,22 @@ type options struct { xLabelOrientation axes.LabelOrientation yLabelCellOpts []cell.Option yAxisMode axes.YScaleMode + yAxisCustomScale *customScale +} + +// validate validates the provided options. +func (o *options) validate() error { + if o.yAxisCustomScale == nil { + return nil + } + + if math.IsNaN(o.yAxisCustomScale.min) || math.IsNaN(o.yAxisCustomScale.max) { + return fmt.Errorf("both the min(%v) and the max(%v) provided as custom Y scale must be valid numbers", o.yAxisCustomScale.min, o.yAxisCustomScale.max) + } + if o.yAxisCustomScale.min >= o.yAxisCustomScale.max { + return fmt.Errorf("the min(%v) must be less than the max(%v) provided as custom Y scale", o.yAxisCustomScale.min, o.yAxisCustomScale.max) + } + return nil } // newOptions returns a new options instance. @@ -103,3 +122,28 @@ func YAxisAdaptive() Option { opts.yAxisMode = axes.YScaleModeAdaptive }) } + +// customScale is the custom scale provided via the YAxisCustomScale option. +type customScale struct { + min, max float64 +} + +// YAxisCustomScale when provided, the scale of the Y axis will be based on the +// specified minimum and maximum value instead of determining those from the +// LineChart series. Useful to visually stabilize the Y axis for LineChart +// applications that continuously feed values. +// The default behavior is to continuously determine the minimum and maximum +// value from the series before drawing the LineChart. +// Even when this option is provided, the LineChart would still rescale the Y +// axis if a value is encountered that is outside of the range specified here, +// i.e. smaller than the minimum or larger than the maximum. +// Both the minimum and the maximum must be valid numbers and the minimum must +// be smaller than the maximum. +func YAxisCustomScale(min, max float64) Option { + return option(func(opts *options) { + opts.yAxisCustomScale = &customScale{ + min: min, + max: max, + } + }) +}