diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f14fa..2e95c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 executed. - The SegmentDisplay widget now has a method that returns the observed character capacity the last time Draw was called. +- The grid.Builder API now allows users to specify options for intermediate + containers, i.e. containers that don't have widgets, but represent rows and + columns. +- Line chart widget now allows `math.NaN` values to represent "no value" (values + that will not be rendered) in the values slice. #### Breaking API changes @@ -82,10 +87,10 @@ identifiers shouldn't be used externally. - The draw.LineStyle enum was refactored into its own package linestyle.LineStyle. Users will have to replace: - - draw.LineStyleNone -> linestyle.None - - draw.LineStyleLight -> linestyle.Light - - draw.LineStyleDouble -> linestyle.Double - - draw.LineStyleRound -> linestyle.Round + - draw.LineStyleNone -> linestyle.None + - draw.LineStyleLight -> linestyle.Light + - draw.LineStyleDouble -> linestyle.Double + - draw.LineStyleRound -> linestyle.Round ## [0.7.0] - 24-Feb-2019 @@ -119,7 +124,6 @@ identifiers shouldn't be used externally. - The Text widget now has a Write option that atomically replaces the entire text content. - #### Improvements to the infrastructure - A function that draws text vertically. @@ -248,7 +252,7 @@ identifiers shouldn't be used externally. - The Gauge widget. - The Text widget. -[Unreleased]: https://github.com/mum4k/termdash/compare/v0.8.0...devel +[unreleased]: https://github.com/mum4k/termdash/compare/v0.8.0...devel [0.8.0]: https://github.com/mum4k/termdash/compare/v0.7.2...v0.8.0 [0.7.2]: https://github.com/mum4k/termdash/compare/v0.7.1...v0.7.2 [0.7.1]: https://github.com/mum4k/termdash/compare/v0.7.0...v0.7.1 diff --git a/container/grid/grid.go b/container/grid/grid.go index 9a59c0c..633de15 100644 --- a/container/grid/grid.go +++ b/container/grid/grid.go @@ -109,18 +109,19 @@ func build(elems []Element, parentHeightPerc, parentWidthPerc int) []container.O switch e := elem.(type) { case *row: + if len(elems) > 0 { perc := innerPerc(e.heightPerc, parentHeightPerc) childHeightPerc := parentHeightPerc - e.heightPerc return []container.Option{ container.SplitHorizontal( - container.Top(build(e.subElem, 100, parentWidthPerc)...), + container.Top(append(e.cOpts, build(e.subElem, 100, parentWidthPerc)...)...), container.Bottom(build(elems, childHeightPerc, parentWidthPerc)...), container.SplitPercent(perc), ), } } - return build(e.subElem, 100, parentWidthPerc) + return append(e.cOpts, build(e.subElem, 100, parentWidthPerc)...) case *col: if len(elems) > 0 { @@ -128,13 +129,13 @@ func build(elems []Element, parentHeightPerc, parentWidthPerc int) []container.O childWidthPerc := parentWidthPerc - e.widthPerc return []container.Option{ container.SplitVertical( - container.Left(build(e.subElem, parentHeightPerc, 100)...), + container.Left(append(e.cOpts, build(e.subElem, parentHeightPerc, 100)...)...), container.Right(build(elems, parentHeightPerc, childWidthPerc)...), container.SplitPercent(perc), ), } } - return build(e.subElem, parentHeightPerc, 100) + return append(e.cOpts, build(e.subElem, parentHeightPerc, 100)...) case *widget: opts := e.cOpts @@ -186,6 +187,9 @@ type row struct { // subElem are the sub Rows or Columns or a single widget. subElem []Element + + // cOpts are the options for the row's container. + cOpts []container.Option } // isElement implements Element.isElement. @@ -204,6 +208,9 @@ type col struct { // subElem are the sub Rows or Columns or a single widget. subElem []Element + + // cOpts are the options for the column's container. + cOpts []container.Option } // isElement implements Element.isElement. @@ -244,6 +251,16 @@ func RowHeightPerc(heightPerc int, subElements ...Element) Element { } } +// RowHeightPercWithOpts is like RowHeightPerc, but also allows to apply +// additional options to the container that represents the row. +func RowHeightPercWithOpts(heightPerc int, cOpts []container.Option, subElements ...Element) Element { + return &row{ + heightPerc: heightPerc, + subElem: subElements, + cOpts: cOpts, + } +} + // ColWidthPerc creates a column of the specified width. // The width is supplied as width percentage of the parent element. // The sum of all widths at the same level cannot be larger than 100%. If it @@ -257,6 +274,16 @@ func ColWidthPerc(widthPerc int, subElements ...Element) Element { } } +// ColWidthPercWithOpts is like ColWidthPerc, but also allows to apply +// additional options to the container that represents the column. +func ColWidthPercWithOpts(widthPerc int, cOpts []container.Option, subElements ...Element) Element { + return &col{ + widthPerc: widthPerc, + subElem: subElements, + cOpts: cOpts, + } +} + // Widget adds a widget into the Row or Column. // The options will be applied to the container that directly holds this // widget. diff --git a/container/grid/grid_test.go b/container/grid/grid_test.go index 9ab155a..01f6b29 100644 --- a/container/grid/grid_test.go +++ b/container/grid/grid_test.go @@ -389,6 +389,45 @@ func TestBuilder(t *testing.T) { return ft }, }, + { + desc: "two equal rows with options", + termSize: image.Point{10, 10}, + builder: func() *Builder { + b := New() + b.Add(RowHeightPercWithOpts( + 50, + []container.Option{ + container.Border(linestyle.Double), + }, + Widget(mirror()), + )) + b.Add(RowHeightPercWithOpts( + 50, + []container.Option{ + container.Border(linestyle.Double), + }, + Widget(mirror()), + )) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + + top, bot := mustHSplit(ft.Area(), 50) + topCvs := testcanvas.MustNew(top) + botCvs := testcanvas.MustNew(bot) + testdraw.MustBorder(topCvs, topCvs.Area(), draw.BorderLineStyle(linestyle.Double)) + testdraw.MustBorder(botCvs, botCvs.Area(), draw.BorderLineStyle(linestyle.Double)) + testcanvas.MustApply(topCvs, ft) + testcanvas.MustApply(botCvs, ft) + + topWidget := testcanvas.MustNew(area.ExcludeBorder(top)) + botWidget := testcanvas.MustNew(area.ExcludeBorder(bot)) + fakewidget.MustDraw(ft, topWidget, &widgetapi.Meta{}, widgetapi.Options{}) + fakewidget.MustDraw(ft, botWidget, &widgetapi.Meta{}, widgetapi.Options{}) + return ft + }, + }, { desc: "two unequal rows", termSize: image.Point{10, 10}, @@ -406,6 +445,45 @@ func TestBuilder(t *testing.T) { return ft }, }, + { + desc: "two equal columns with options", + termSize: image.Point{20, 10}, + builder: func() *Builder { + b := New() + b.Add(ColWidthPercWithOpts( + 50, + []container.Option{ + container.Border(linestyle.Double), + }, + Widget(mirror()), + )) + b.Add(ColWidthPercWithOpts( + 50, + []container.Option{ + container.Border(linestyle.Double), + }, + Widget(mirror()), + )) + return b + }(), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + + left, right := mustVSplit(ft.Area(), 50) + leftCvs := testcanvas.MustNew(left) + rightCvs := testcanvas.MustNew(right) + testdraw.MustBorder(leftCvs, leftCvs.Area(), draw.BorderLineStyle(linestyle.Double)) + testdraw.MustBorder(rightCvs, rightCvs.Area(), draw.BorderLineStyle(linestyle.Double)) + testcanvas.MustApply(leftCvs, ft) + testcanvas.MustApply(rightCvs, ft) + + leftWidget := testcanvas.MustNew(area.ExcludeBorder(left)) + rightWidget := testcanvas.MustNew(area.ExcludeBorder(right)) + fakewidget.MustDraw(ft, leftWidget, &widgetapi.Meta{}, widgetapi.Options{}) + fakewidget.MustDraw(ft, rightWidget, &widgetapi.Meta{}, widgetapi.Options{}) + return ft + }, + }, { desc: "two equal columns", termSize: image.Point{20, 10}, diff --git a/internal/numbers/numbers.go b/internal/numbers/numbers.go index f88a6b3..e62f346 100644 --- a/internal/numbers/numbers.go +++ b/internal/numbers/numbers.go @@ -109,14 +109,21 @@ func Round(x float64) float64 { // MinMax returns the smallest and the largest value among the provided values. // Returns (0, 0) if there are no values. +// Ignores NaN values. Allowing NaN values could lead to a corner case where all +// values can be NaN, in this case the function will return NaN as min and max. func MinMax(values []float64) (min, max float64) { if len(values) == 0 { return 0, 0 } min = math.MaxFloat64 max = -1 * math.MaxFloat64 - + allNaN := true for _, v := range values { + if math.IsNaN(v) { + continue + } + allNaN = false + if v < min { min = v } @@ -124,6 +131,11 @@ func MinMax(values []float64) (min, max float64) { max = v } } + + if allNaN { + return math.NaN(), math.NaN() + } + return min, max } diff --git a/internal/numbers/numbers_test.go b/internal/numbers/numbers_test.go index b47cbbf..f5955b5 100644 --- a/internal/numbers/numbers_test.go +++ b/internal/numbers/numbers_test.go @@ -215,13 +215,28 @@ func TestMinMax(t *testing.T) { wantMin: -11.3, wantMax: 22.5, }, + { + desc: "min and max among negative, positive, zero and NaN values", + values: []float64{1.1, 0, 1.3, math.NaN(), -11.3, 22.5}, + wantMin: -11.3, + wantMax: 22.5, + }, + { + desc: "all NaN values", + values: []float64{math.NaN(), math.NaN(), math.NaN(), math.NaN()}, + wantMin: math.NaN(), + wantMax: math.NaN(), + }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { gotMin, gotMax := MinMax(tc.values) - if gotMin != tc.wantMin || gotMax != tc.wantMax { - t.Errorf("MinMax => (%v, %v), want (%v, %v)", gotMin, gotMax, tc.wantMin, tc.wantMax) + if diff := pretty.Compare(tc.wantMin, gotMin); diff != "" { + t.Errorf("MinMax => unexpected min, diff (-want, +got):\n %s", diff) + } + if diff := pretty.Compare(tc.wantMax, gotMax); diff != "" { + t.Errorf("MinMax => unexpected max, diff (-want, +got):\n %s", diff) } }) } diff --git a/termdashdemo/termdashdemo.go b/termdashdemo/termdashdemo.go index 6601b59..768057f 100644 --- a/termdashdemo/termdashdemo.go +++ b/termdashdemo/termdashdemo.go @@ -166,16 +166,18 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) { container.BorderTitle("A rolling text"), ), ), - grid.RowHeightPerc(50, - grid.Widget(w.spGreen, - container.Border(linestyle.Light), - container.BorderTitle("Green SparkLine"), + grid.ColWidthPerc(50, + grid.RowHeightPerc(50, + grid.Widget(w.spGreen, + container.Border(linestyle.Light), + container.BorderTitle("Green SparkLine"), + ), ), - ), - grid.RowHeightPerc(50, - grid.Widget(w.spRed, - container.Border(linestyle.Light), - container.BorderTitle("Red SparkLine"), + grid.RowHeightPerc(50, + grid.Widget(w.spRed, + container.Border(linestyle.Light), + container.BorderTitle("Red SparkLine"), + ), ), ), ), diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go index fbeeeb5..cb88185 100644 --- a/widgets/linechart/linechart.go +++ b/widgets/linechart/linechart.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "image" + "math" "sort" "sync" @@ -56,7 +57,7 @@ func newSeriesValues(values []float64) *seriesValues { v := make([]float64, len(values)) copy(v, values) - min, max := numbers.MinMax(v) + min, max := minMax(v) return &seriesValues{ values: v, min: min, @@ -175,8 +176,9 @@ func (lc *LineChart) yMinMax() (float64, float64) { maximums = append(maximums, lc.opts.yAxisCustomScale.max) } - min, _ := numbers.MinMax(minimums) - _, max := numbers.MinMax(maximums) + min, _ := minMax(minimums) + _, max := minMax(maximums) + return min, max } @@ -196,6 +198,8 @@ func (lc *LineChart) ValueCapacity() int { // Series sets the values that should be displayed as the line chart with the // provided label. +// The values that should not be displayed on the line chart should be represented +// as math.NaN values on the values slice. // Subsequent calls with the same label replace any previously provided values. func (lc *LineChart) Series(label string, values []float64, opts ...SeriesOption) error { if label == "" { @@ -364,6 +368,7 @@ func (lc *LineChart) graphAr(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDe // drawSeries draws the graph representing the stored series. // Returns XDetails that might be adjusted to not start at zero value if some // of the series didn't fit the graphs and XAxisUnscaled was provided. +// If the series has NaN values they will be ignored and not draw on the graph. func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) (*axes.XDetails, error) { graphAr := lc.graphAr(cvs, xd, yd) bc, err := braille.New(graphAr) @@ -406,7 +411,14 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes. var prev float64 for i := 1; i < len(sv.values); i++ { + v := sv.values[i] prev = sv.values[i-1] + + // Skip the values that are missing. + if math.IsNaN(v) || math.IsNaN(prev) { + continue + } + if i < int(xdZoomed.Scale.Min.Value)+1 || i > int(xdZoomed.Scale.Max.Value) { // Don't draw lines for values that aren't supposed to be visible. // These are either values outside of the current zoom or @@ -429,7 +441,7 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes. if err != nil { return nil, fmt.Errorf("failure for series %v[%d] on scale %v, yd.Scale.ValueToPixel(%v) => %v", name, i-1, yd.Scale, prev, err) } - v := sv.values[i] + endY, err := yd.Scale.ValueToPixel(v) if err != nil { return nil, fmt.Errorf("failure for series %v[%d] on scale %v, yd.Scale.ValueToPixel(%v) => %v", name, i, yd.Scale, v, err) @@ -519,3 +531,17 @@ func (lc *LineChart) maxXValue() int { } return maxLen - 1 } + +// minMax is a wrapper around numbers.MinMax that controls +// the output if the values are NaN and sets defaults if it's +// the case. +func minMax(values []float64) (x, y float64) { + min, max := numbers.MinMax(values) + if math.IsNaN(min) { + min = 0 + } + if math.IsNaN(max) { + max = 0 + } + return min, max +} diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go index 378edef..d3ddbb5 100644 --- a/widgets/linechart/linechart_test.go +++ b/widgets/linechart/linechart_test.go @@ -1523,6 +1523,119 @@ func TestLineChartDraws(t *testing.T) { testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}) testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "all NaN values", + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{math.NaN(), math.NaN(), math.NaN(), math.NaN(), math.NaN()}) + }, + wantCapacity: 36, + 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{1, 0}, End: image.Point{1, 8}}, + {Start: image.Point{1, 8}, End: image.Point{19, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{0, 7}) + testdraw.MustText(c, "0", image.Point{2, 9}) + testdraw.MustText(c, "1", image.Point{6, 9}) + testdraw.MustText(c, "2", image.Point{10, 9}) + testdraw.MustText(c, "3", image.Point{14, 9}) + testdraw.MustText(c, "4", image.Point{18, 9}) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "first and last NaN values", + canvas: image.Rect(0, 0, 28, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{math.NaN(), math.NaN(), 100, 150, math.NaN()}) + }, + wantCapacity: 44, + 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{27, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 7}) + testdraw.MustText(c, "77.44", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{11, 9}) + testdraw.MustText(c, "2", image.Point{16, 9}) + testdraw.MustText(c, "3", image.Point{22, 9}) + testdraw.MustText(c, "4", image.Point{27, 9}) + + graphAr := image.Rect(6, 0, 25, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{21, 10}, image.Point{32, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "more values than capacity, X rescales with NaN values ignored", + canvas: image.Rect(0, 0, 11, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 1, 2, 3, 4, 5, 6, 7, math.NaN(), math.NaN(), math.NaN(), math.NaN(), 12, 13, 14, 15, 16, 17, 18, 19}) + }, + wantCapacity: 12, + 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{10, 8}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{3, 7}) + testdraw.MustText(c, "9.92", image.Point{0, 3}) + testdraw.MustText(c, "0", image.Point{5, 9}) + testdraw.MustText(c, "14", image.Point{9, 9}) + + // Braille line. + graphAr := image.Rect(5, 0, 11, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{1, 29}) + testdraw.MustBrailleLine(bc, image.Point{1, 29}, image.Point{1, 28}) + testdraw.MustBrailleLine(bc, image.Point{1, 28}, image.Point{2, 26}) + testdraw.MustBrailleLine(bc, image.Point{2, 26}, image.Point{2, 25}) + testdraw.MustBrailleLine(bc, image.Point{2, 25}, image.Point{3, 23}) + testdraw.MustBrailleLine(bc, image.Point{3, 23}, image.Point{3, 21}) + testdraw.MustBrailleLine(bc, image.Point{3, 21}, image.Point{4, 20}) + testdraw.MustBrailleLine(bc, image.Point{7, 12}, image.Point{8, 10}) + testdraw.MustBrailleLine(bc, image.Point{8, 10}, image.Point{8, 8}) + testdraw.MustBrailleLine(bc, image.Point{8, 8}, image.Point{9, 7}) + testdraw.MustBrailleLine(bc, image.Point{9, 7}, image.Point{9, 5}) + testdraw.MustBrailleLine(bc, image.Point{9, 5}, image.Point{10, 4}) + testdraw.MustBrailleLine(bc, image.Point{10, 4}, image.Point{10, 2}) + testdraw.MustBrailleLine(bc, image.Point{10, 2}, image.Point{11, 0}) + testbraille.MustCopyTo(bc, c) + testcanvas.MustApply(c, ft) return ft },