From 9f893eb482e7ba16b45a14453ff2eb61e35d03e6 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Wed, 13 Feb 2019 22:53:19 -0500 Subject: [PATCH 1/6] Function to calculate required height. And options to set label orientation. --- widgets/linechart/axes/axes.go | 67 ++++++++++++++++++++++++----- widgets/linechart/axes/axes_test.go | 50 +++++++++++++++++++++ widgets/linechart/options.go | 25 +++++++++-- 3 files changed, 128 insertions(+), 14 deletions(-) diff --git a/widgets/linechart/axes/axes.go b/widgets/linechart/axes/axes.go index ddeceeb..bb947a4 100644 --- a/widgets/linechart/axes/axes.go +++ b/widgets/linechart/axes/axes.go @@ -26,8 +26,8 @@ const ( // rounded up to. nonZeroDecimals = 2 - // yAxisWidth is width of the Y axis. - yAxisWidth = 1 + // axisWidth is width of an axis. + axisWidth = 1 ) // YDetails contain information about the Y axis that will be drawn onto the @@ -75,15 +75,16 @@ func (y *Y) Update(minVal, maxVal float64) { y.min, y.max = NewValue(minVal, nonZeroDecimals), NewValue(maxVal, nonZeroDecimals) } -// RequiredWidth calculates the minimum width required in order to draw the Y axis. +// RequiredWidth calculates the minimum width required in order to draw the Y +// axis and its labels. func (y *Y) RequiredWidth() int { // This is an estimation only, it is possible that more labels in the // middle will be generated and might be wider than this. Such cases are // handled on the call to Details when the size of canvas is known. - return widestLabel([]*Label{ + return longestLabel([]*Label{ {Value: y.min}, {Value: y.max}, - }) + yAxisWidth + }) + axisWidth } // Details retrieves details about the Y axis required to draw it on a canvas @@ -103,7 +104,7 @@ func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) { } // See how the labels would look like on the entire maxWidth. - maxLabelWidth := maxWidth - yAxisWidth + maxLabelWidth := maxWidth - axisWidth labels, err := yLabels(scale, maxLabelWidth) if err != nil { return nil, err @@ -112,7 +113,7 @@ func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) { var width int // Determine the largest label, which might be less than maxWidth. // Such case would allow us to save more space for the line chart itself. - widest := widestLabel(labels) + widest := longestLabel(labels) if widest < maxLabelWidth { // Save the space and recalculate the labels, since they need to be realigned. l, err := yLabels(scale, widest) @@ -120,7 +121,7 @@ func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) { return nil, err } labels = l - width = widest + yAxisWidth // One for the axis itself. + width = widest + axisWidth // One for the axis itself. } else { width = maxWidth } @@ -134,8 +135,8 @@ func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) { }, nil } -// widestLabel returns the width of the widest label. -func widestLabel(labels []*Label) int { +// longestLabel returns the width of the widest label. +func longestLabel(labels []*Label) int { var widest int for _, label := range labels { if l := len(label.Value.Text()); l > widest { @@ -193,3 +194,49 @@ func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, custo Labels: labels, }, nil } + +// LabelOrientation represents the orientation of text labels. +type LabelOrientation int + +// String implements fmt.Stringer() +func (lo LabelOrientation) String() string { + if n, ok := labelOrientationNames[lo]; ok { + return n + } + return "LabelOrientationUnknown" +} + +// labelOrientationNames maps LabelOrientation values to human readable names. +var labelOrientationNames = map[LabelOrientation]string{ + LabelOrientationHorizontal: "LabelOrientationHorizontal", + LabelOrientationVertical: "LabelOrientationVertical", +} + +const ( + // LabelOrientationHorizontal is the default label orientation where text + // flows horizontally. + LabelOrientationHorizontal LabelOrientation = iota + + // LabelOrientationvertical is an orientation where text flows vertically. + LabelOrientationVertical +) + +// RequiredHeight calculates the minimum height required in order to draw the X +// axis and its labels. +func RequiredHeight(numPoints int, customLabels map[int]string, lo LabelOrientation) int { + if lo == LabelOrientationHorizontal { + // One row for the X axis and one row for its labels flowing + // horizontally. + return axisWidth + 1 + } + + labels := []*Label{ + {Value: NewValue(float64(numPoints), nonZeroDecimals)}, + } + for _, cl := range customLabels { + labels = append(labels, &Label{ + Value: NewTextValue(cl), + }) + } + return longestLabel(labels) + axisWidth +} diff --git a/widgets/linechart/axes/axes_test.go b/widgets/linechart/axes/axes_test.go index e79b47c..41164be 100644 --- a/widgets/linechart/axes/axes_test.go +++ b/widgets/linechart/axes/axes_test.go @@ -261,3 +261,53 @@ func TestNewXDetails(t *testing.T) { }) } } + +func TestRequiredHeight(t *testing.T) { + tests := []struct { + desc string + numPoints int + customLabels map[int]string + labelOrientation LabelOrientation + want int + }{ + { + desc: "horizontal orientation", + want: 2, + }, + { + desc: "vertical orientation, no custom labels, need single row for max label", + numPoints: 9, + labelOrientation: LabelOrientationVertical, + want: 2, + }, + { + desc: "vertical orientation, no custom labels, need multiple row for max label", + numPoints: 100, + labelOrientation: LabelOrientationVertical, + want: 4, + }, + { + desc: "vertical orientation, custom labels but all shorter than max label", + numPoints: 100, + customLabels: map[int]string{1: "a", 2: "b"}, + labelOrientation: LabelOrientationVertical, + want: 4, + }, + { + desc: "vertical orientation, custom labels and some longer than max label", + numPoints: 100, + customLabels: map[int]string{1: "a", 2: "bbbbb"}, + labelOrientation: LabelOrientationVertical, + want: 6, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := RequiredHeight(tc.numPoints, tc.customLabels, tc.labelOrientation) + if got != tc.want { + t.Errorf("RequiredHeight => %d, want %d", got, tc.want) + } + }) + } +} diff --git a/widgets/linechart/options.go b/widgets/linechart/options.go index 326cb80..5e77f68 100644 --- a/widgets/linechart/options.go +++ b/widgets/linechart/options.go @@ -29,10 +29,11 @@ type Option interface { // options stores the provided options. type options struct { - axesCellOpts []cell.Option - xLabelCellOpts []cell.Option - yLabelCellOpts []cell.Option - yAxisMode axes.YScaleMode + axesCellOpts []cell.Option + xLabelCellOpts []cell.Option + xLabelOrientation axes.LabelOrientation + yLabelCellOpts []cell.Option + yAxisMode axes.YScaleMode } // newOptions returns a new options instance. @@ -66,6 +67,22 @@ func XLabelCellOpts(co ...cell.Option) Option { }) } +// XLabelsVertical makes the labels under the X axis flow vertically. +// Defaults to labels that flow horizontally. +func XLabelsVertical() Option { + return option(func(opts *options) { + opts.xLabelOrientation = axes.LabelOrientationVertical + }) +} + +// XLabelsHorizontal makes the labels under the X axis flow horizontally. +// This is the default option. +func XLabelsHorizontal() Option { + return option(func(opts *options) { + opts.xLabelOrientation = axes.LabelOrientationHorizontal + }) +} + // YLabelCellOpts set the cell options for the labels on the Y axis. func YLabelCellOpts(co ...cell.Option) Option { return option(func(opts *options) { From 7ef79393df940db3c6a9063339c08481f02fc4ca Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Wed, 13 Feb 2019 23:16:05 -0500 Subject: [PATCH 2/6] xLabels now supports vertical labels. --- widgets/linechart/axes/axes.go | 30 +-------- widgets/linechart/axes/axes_test.go | 19 +++--- widgets/linechart/axes/label.go | 40 ++++++++++-- widgets/linechart/axes/label_test.go | 91 +++++++++++++++++++++++++--- widgets/linechart/linechart.go | 2 +- 5 files changed, 132 insertions(+), 50 deletions(-) diff --git a/widgets/linechart/axes/axes.go b/widgets/linechart/axes/axes.go index bb947a4..8a78f58 100644 --- a/widgets/linechart/axes/axes.go +++ b/widgets/linechart/axes/axes.go @@ -168,7 +168,7 @@ type XDetails struct { // plotted. // customLabels are the desired labels for the X axis, these are preferred if // provided. -func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, customLabels map[int]string) (*XDetails, error) { +func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, customLabels map[int]string, lo LabelOrientation) (*XDetails, error) { if min := 3; cvsAr.Dy() < min { return nil, fmt.Errorf("the canvas isn't tall enough to accommodate the X axis, its labels and the line chart, got height %d, minimum is %d", cvsAr.Dy(), min) } @@ -183,7 +183,7 @@ func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, custo // One point horizontally for the Y axis. // Two points vertically, one for the X axis and one for its labels. graphZero := image.Point{yStart.X + 1, cvsAr.Dy() - 3} - labels, err := xLabels(scale, graphZero, customLabels) + labels, err := xLabels(scale, graphZero, customLabels, lo) if err != nil { return nil, err } @@ -195,32 +195,6 @@ func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, custo }, nil } -// LabelOrientation represents the orientation of text labels. -type LabelOrientation int - -// String implements fmt.Stringer() -func (lo LabelOrientation) String() string { - if n, ok := labelOrientationNames[lo]; ok { - return n - } - return "LabelOrientationUnknown" -} - -// labelOrientationNames maps LabelOrientation values to human readable names. -var labelOrientationNames = map[LabelOrientation]string{ - LabelOrientationHorizontal: "LabelOrientationHorizontal", - LabelOrientationVertical: "LabelOrientationVertical", -} - -const ( - // LabelOrientationHorizontal is the default label orientation where text - // flows horizontally. - LabelOrientationHorizontal LabelOrientation = iota - - // LabelOrientationvertical is an orientation where text flows vertically. - LabelOrientationVertical -) - // RequiredHeight calculates the minimum height required in order to draw the X // axis and its labels. func RequiredHeight(numPoints int, customLabels map[int]string, lo LabelOrientation) int { diff --git a/widgets/linechart/axes/axes_test.go b/widgets/linechart/axes/axes_test.go index 41164be..27b2af7 100644 --- a/widgets/linechart/axes/axes_test.go +++ b/widgets/linechart/axes/axes_test.go @@ -179,14 +179,15 @@ func TestY(t *testing.T) { func TestNewXDetails(t *testing.T) { tests := []struct { - desc string - numPoints int - yStart image.Point - cvsWidth int - cvsAr image.Rectangle - customLabels map[int]string - want *XDetails - wantErr bool + desc string + numPoints int + yStart image.Point + cvsWidth int + cvsAr image.Rectangle + customLabels map[int]string + labelOrientation LabelOrientation + want *XDetails + wantErr bool }{ { desc: "fails when numPoints is negative", @@ -247,7 +248,7 @@ func TestNewXDetails(t *testing.T) { for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - got, err := NewXDetails(tc.numPoints, tc.yStart, tc.cvsAr, tc.customLabels) + got, err := NewXDetails(tc.numPoints, tc.yStart, tc.cvsAr, tc.customLabels, tc.labelOrientation) if (err != nil) != tc.wantErr { t.Errorf("NewXDetails => unexpected error: %v, wantErr: %v", err, tc.wantErr) } diff --git a/widgets/linechart/axes/label.go b/widgets/linechart/axes/label.go index ce2a22b..fef779f 100644 --- a/widgets/linechart/axes/label.go +++ b/widgets/linechart/axes/label.go @@ -23,6 +23,32 @@ import ( "github.com/mum4k/termdash/align" ) +// LabelOrientation represents the orientation of text labels. +type LabelOrientation int + +// String implements fmt.Stringer() +func (lo LabelOrientation) String() string { + if n, ok := labelOrientationNames[lo]; ok { + return n + } + return "LabelOrientationUnknown" +} + +// labelOrientationNames maps LabelOrientation values to human readable names. +var labelOrientationNames = map[LabelOrientation]string{ + LabelOrientationHorizontal: "LabelOrientationHorizontal", + LabelOrientationVertical: "LabelOrientationVertical", +} + +const ( + // LabelOrientationHorizontal is the default label orientation where text + // flows horizontally. + LabelOrientationHorizontal LabelOrientation = iota + + // LabelOrientationvertical is an orientation where text flows vertically. + LabelOrientationVertical +) + // Label is one value label on an axis. type Label struct { // Value if the value to be displayed. @@ -162,14 +188,14 @@ func (xs *xSpace) Sub(size int) error { // fit under the width of the axis. // The customLabels map value positions in the series to the desired custom // label. These are preferred if present. -func xLabels(scale *XScale, graphZero image.Point, customLabels map[int]string) ([]*Label, error) { +func xLabels(scale *XScale, graphZero image.Point, customLabels map[int]string, lo LabelOrientation) ([]*Label, error) { space := newXSpace(graphZero, scale.GraphWidth) const minSpacing = 3 var res []*Label next := 0 for haveLabels := 0; haveLabels <= int(scale.Max.Value); haveLabels = len(res) { - label, err := colLabel(scale, space, customLabels) + label, err := colLabel(scale, space, customLabels, lo) if err != nil { return nil, err } @@ -205,7 +231,7 @@ func xLabels(scale *XScale, graphZero image.Point, customLabels map[int]string) // colLabel returns a label placed at the beginning of the space. // The space is adjusted according to how much space was taken by the label. // Returns nil, nil if the label doesn't fit in the space. -func colLabel(scale *XScale, space *xSpace, customLabels map[int]string) (*Label, error) { +func colLabel(scale *XScale, space *xSpace, customLabels map[int]string, lo LabelOrientation) (*Label, error) { pos := space.Relative() label, err := scale.CellLabel(pos.X) if err != nil { @@ -216,7 +242,13 @@ func colLabel(scale *XScale, space *xSpace, customLabels map[int]string) (*Label label = NewTextValue(custom) } - labelLen := len(label.Text()) + var labelLen int + switch lo { + case LabelOrientationHorizontal: + labelLen = len(label.Text()) + case LabelOrientationVertical: + labelLen = 1 + } if labelLen > space.Remaining() { return nil, nil } diff --git a/widgets/linechart/axes/label_test.go b/widgets/linechart/axes/label_test.go index d1fc746..334985b 100644 --- a/widgets/linechart/axes/label_test.go +++ b/widgets/linechart/axes/label_test.go @@ -151,13 +151,14 @@ func TestYLabels(t *testing.T) { func TestXLabels(t *testing.T) { const nonZeroDecimals = 2 tests := []struct { - desc string - numPoints int - graphWidth int - graphZero image.Point - customLabels map[int]string - want []*Label - wantErr bool + desc string + numPoints int + graphWidth int + graphZero image.Point + customLabels map[int]string + labelOrientation LabelOrientation + want []*Label + wantErr bool }{ { desc: "only one point", @@ -168,6 +169,16 @@ func TestXLabels(t *testing.T) { {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, }, }, + { + desc: "only one point, vertical", + numPoints: 1, + graphWidth: 1, + graphZero: image.Point{0, 1}, + labelOrientation: LabelOrientationVertical, + want: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + }, + }, { desc: "two points, only one label fits", numPoints: 2, @@ -187,6 +198,17 @@ func TestXLabels(t *testing.T) { {NewValue(1, nonZeroDecimals), image.Point{4, 3}}, }, }, + { + desc: "two points, two labels fit exactly, vertical", + numPoints: 2, + graphWidth: 5, + graphZero: image.Point{0, 1}, + labelOrientation: LabelOrientationVertical, + want: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(1, nonZeroDecimals), image.Point{4, 3}}, + }, + }, { desc: "labels are placed according to graphZero", numPoints: 2, @@ -321,6 +343,59 @@ func TestXLabels(t *testing.T) { {NewValue(72, nonZeroDecimals), image.Point{4, 3}}, }, }, + { + desc: "longer labels, only two fit in horizontal", + numPoints: 1000, + graphWidth: 10, + graphZero: image.Point{0, 1}, + want: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(421, nonZeroDecimals), image.Point{4, 3}}, + }, + }, + { + desc: "longer labels, multiple fit in vertical", + numPoints: 1000, + graphWidth: 10, + graphZero: image.Point{0, 1}, + labelOrientation: LabelOrientationVertical, + want: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(421, nonZeroDecimals), image.Point{4, 3}}, + {NewValue(841, nonZeroDecimals), image.Point{8, 3}}, + }, + }, + { + desc: "longer custom labels, only one fits in horizontal", + numPoints: 1000, + graphWidth: 10, + graphZero: image.Point{0, 1}, + customLabels: map[int]string{ + 0: "zero label", + 421: "this one is even longer", + 841: "this label just keeps on going", + }, + want: []*Label{ + {NewTextValue("zero label"), image.Point{0, 3}}, + }, + }, + { + desc: "longer custom labels, all fit in vertical", + numPoints: 1000, + graphWidth: 10, + graphZero: image.Point{0, 1}, + customLabels: map[int]string{ + 0: "zero label", + 421: "this one is even longer", + 841: "this label just keeps on going", + }, + labelOrientation: LabelOrientationVertical, + want: []*Label{ + {NewTextValue("zero label"), image.Point{0, 3}}, + {NewTextValue("this one is even longer"), image.Point{4, 3}}, + {NewTextValue("this label just keeps on going"), image.Point{8, 3}}, + }, + }, } for _, tc := range tests { @@ -330,7 +405,7 @@ func TestXLabels(t *testing.T) { t.Fatalf("NewXScale => unexpected error: %v", err) } t.Logf("scale step: %v", scale.Step.Rounded) - got, err := xLabels(scale, tc.graphZero, tc.customLabels) + got, err := xLabels(scale, tc.graphZero, tc.customLabels, tc.labelOrientation) if (err != nil) != tc.wantErr { t.Errorf("xLabels => unexpected error: %v, wantErr: %v", err, tc.wantErr) } diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go index c800fa9..e258dbf 100644 --- a/widgets/linechart/linechart.go +++ b/widgets/linechart/linechart.go @@ -187,7 +187,7 @@ func (lc *LineChart) Draw(cvs *canvas.Canvas) error { return fmt.Errorf("lc.yAxis.Details => %v", err) } - xd, err := axes.NewXDetails(lc.maxPoints(), yd.Start, cvs.Area(), lc.xLabels) + xd, err := axes.NewXDetails(lc.maxPoints(), yd.Start, cvs.Area(), lc.xLabels, lc.opts.xLabelOrientation) if err != nil { return fmt.Errorf("NewXDetails => %v", err) } From 3dba76c26ffcdb54fe46ac7c4e2041a12a6b53fa Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Thu, 14 Feb 2019 00:37:35 -0500 Subject: [PATCH 3/6] Linechart can now draw labels under X vertically. --- draw/testdraw/testdraw.go | 7 + widgets/linechart/axes/axes.go | 30 ++-- widgets/linechart/axes/axes_test.go | 226 +++++++++++++++++++++------- widgets/linechart/linechart.go | 25 ++- widgets/linechart/linechart_test.go | 112 +++++++++++++- 5 files changed, 324 insertions(+), 76 deletions(-) diff --git a/draw/testdraw/testdraw.go b/draw/testdraw/testdraw.go index 22b7cb2..01448a7 100644 --- a/draw/testdraw/testdraw.go +++ b/draw/testdraw/testdraw.go @@ -38,6 +38,13 @@ func MustText(c *canvas.Canvas, text string, start image.Point, opts ...draw.Tex } } +// MustVerticalText draws the vertical text on the canvas or panics. +func MustVerticalText(c *canvas.Canvas, text string, start image.Point, opts ...draw.VerticalTextOption) { + if err := draw.VerticalText(c, text, start, opts...); err != nil { + panic(fmt.Sprintf("draw.VerticalText => unexpected error: %v", err)) + } +} + // MustRectangle draws the rectangle on the canvas or panics. func MustRectangle(c *canvas.Canvas, r image.Rectangle, opts ...draw.RectangleOption) { if err := draw.Rectangle(c, r, opts...); err != nil { diff --git a/widgets/linechart/axes/axes.go b/widgets/linechart/axes/axes.go index 8a78f58..89554c1 100644 --- a/widgets/linechart/axes/axes.go +++ b/widgets/linechart/axes/axes.go @@ -89,15 +89,16 @@ func (y *Y) RequiredWidth() int { // Details retrieves details about the Y axis required to draw it on a canvas // of the provided area. -func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) { +// The argument reqXHeight is the height required for the X axis and its labels. +func (y *Y) Details(cvsAr image.Rectangle, reqXHeight int, mode YScaleMode) (*YDetails, error) { cvsWidth := cvsAr.Dx() cvsHeight := cvsAr.Dy() - maxWidth := cvsWidth - 1 // Reserve one row for the line chart itself. + maxWidth := cvsWidth - 1 // Reserve one column for the line chart itself. if req := y.RequiredWidth(); maxWidth < req { - return nil, fmt.Errorf("the received maxWidth %d is smaller than the reported required width %d", maxWidth, req) + return nil, fmt.Errorf("the available maxWidth %d is smaller than the reported required width %d", maxWidth, req) } - graphHeight := cvsHeight - 2 // One row for the X axis and one for its labels. + graphHeight := cvsHeight - reqXHeight scale, err := NewYScale(y.min.Value, y.max.Value, graphHeight, nonZeroDecimals, mode) if err != nil { return nil, err @@ -169,8 +170,11 @@ type XDetails struct { // customLabels are the desired labels for the X axis, these are preferred if // provided. func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, customLabels map[int]string, lo LabelOrientation) (*XDetails, error) { - if min := 3; cvsAr.Dy() < min { - return nil, fmt.Errorf("the canvas isn't tall enough to accommodate the X axis, its labels and the line chart, got height %d, minimum is %d", cvsAr.Dy(), min) + cvsHeight := cvsAr.Dy() + maxHeight := cvsHeight - 1 // Reserve one row for the line chart itself. + reqHeight := RequiredHeight(numPoints, customLabels, lo) + if maxHeight < reqHeight { + return nil, fmt.Errorf("the available maxHeight %d is smaller than the reported required height %d", maxHeight, reqHeight) } // The space between the start of the axis and the end of the canvas. @@ -180,16 +184,20 @@ func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, custo return nil, err } - // One point horizontally for the Y axis. - // Two points vertically, one for the X axis and one for its labels. - graphZero := image.Point{yStart.X + 1, cvsAr.Dy() - 3} + // See how the labels would look like on the entire reqHeight. + graphZero := image.Point{ + // Reserve one point horizontally for the Y axis. + yStart.X + 1, + cvsAr.Dy() - reqHeight - 1, + } labels, err := xLabels(scale, graphZero, customLabels, lo) if err != nil { return nil, err } + return &XDetails{ - Start: image.Point{yStart.X, cvsAr.Dy() - 2}, // One row for the labels. - End: image.Point{yStart.X + graphWidth, cvsAr.Dy() - 2}, + Start: image.Point{yStart.X, cvsAr.Dy() - reqHeight}, // Space for the labels. + End: image.Point{yStart.X + graphWidth, cvsAr.Dy() - reqHeight}, Scale: scale, Labels: labels, }, nil diff --git a/widgets/linechart/axes/axes_test.go b/widgets/linechart/axes/axes_test.go index 27b2af7..91c5a08 100644 --- a/widgets/linechart/axes/axes_test.go +++ b/widgets/linechart/axes/axes_test.go @@ -28,46 +28,51 @@ type updateY struct { func TestY(t *testing.T) { tests := []struct { - desc string - minVal float64 - maxVal float64 - update *updateY - mode YScaleMode - cvsAr image.Rectangle - wantWidth int - want *YDetails - wantErr bool + desc string + minVal float64 + maxVal float64 + update *updateY + reqXHeight int + mode YScaleMode + cvsAr image.Rectangle + wantWidth int + want *YDetails + wantErr bool }{ { - desc: "fails on canvas too small", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 3, 2), - wantWidth: 2, - wantErr: true, + desc: "fails on canvas too small", + minVal: 0, + maxVal: 3, + cvsAr: image.Rect(0, 0, 3, 2), + reqXHeight: 2, + wantWidth: 2, + wantErr: true, }, { - desc: "fails on cvsWidth less than required width", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 2, 4), - wantWidth: 2, - wantErr: true, + desc: "fails on cvsWidth less than required width", + minVal: 0, + maxVal: 3, + cvsAr: image.Rect(0, 0, 2, 4), + reqXHeight: 2, + wantWidth: 2, + wantErr: true, }, { - desc: "fails when max is less than min", - minVal: 0, - maxVal: -1, - cvsAr: image.Rect(0, 0, 4, 4), - wantWidth: 3, - wantErr: true, + desc: "fails when max is less than min", + minVal: 0, + maxVal: -1, + cvsAr: image.Rect(0, 0, 4, 4), + reqXHeight: 2, + wantWidth: 3, + wantErr: true, }, { - desc: "cvsWidth equals required width", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 3, 4), - wantWidth: 2, + desc: "cvsWidth equals required width", + minVal: 0, + maxVal: 3, + cvsAr: image.Rect(0, 0, 3, 4), + wantWidth: 2, + reqXHeight: 2, want: &YDetails{ Width: 2, Start: image.Point{1, 0}, @@ -80,12 +85,13 @@ func TestY(t *testing.T) { }, }, { - desc: "success for anchored scale", - minVal: 1, - maxVal: 3, - mode: YScaleModeAnchored, - cvsAr: image.Rect(0, 0, 3, 4), - wantWidth: 2, + desc: "success for anchored scale", + minVal: 1, + maxVal: 3, + mode: YScaleModeAnchored, + cvsAr: image.Rect(0, 0, 3, 4), + reqXHeight: 2, + wantWidth: 2, want: &YDetails{ Width: 2, Start: image.Point{1, 0}, @@ -98,12 +104,32 @@ func TestY(t *testing.T) { }, }, { - desc: "success for adaptive scale", - minVal: 1, - maxVal: 6, - mode: YScaleModeAdaptive, - cvsAr: image.Rect(0, 0, 3, 4), - wantWidth: 2, + desc: "accommodates X scale that needs more height", + minVal: 1, + maxVal: 3, + mode: YScaleModeAnchored, + cvsAr: image.Rect(0, 0, 3, 6), + reqXHeight: 4, + wantWidth: 2, + want: &YDetails{ + Width: 2, + Start: image.Point{1, 0}, + End: image.Point{1, 2}, + Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored), + Labels: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, + {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}}, + }, + }, + }, + { + desc: "success for adaptive scale", + minVal: 1, + maxVal: 6, + mode: YScaleModeAdaptive, + cvsAr: image.Rect(0, 0, 3, 4), + reqXHeight: 2, + wantWidth: 2, want: &YDetails{ Width: 2, Start: image.Point{1, 0}, @@ -116,11 +142,12 @@ func TestY(t *testing.T) { }, }, { - desc: "cvsWidth just accommodates the longest label", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 6, 4), - wantWidth: 2, + desc: "cvsWidth just accommodates the longest label", + minVal: 0, + maxVal: 3, + cvsAr: image.Rect(0, 0, 6, 4), + reqXHeight: 2, + wantWidth: 2, want: &YDetails{ Width: 5, Start: image.Point{4, 0}, @@ -133,11 +160,12 @@ func TestY(t *testing.T) { }, }, { - desc: "cvsWidth is more than we need", - minVal: 0, - maxVal: 3, - cvsAr: image.Rect(0, 0, 7, 4), - wantWidth: 2, + desc: "cvsWidth is more than we need", + minVal: 0, + maxVal: 3, + cvsAr: image.Rect(0, 0, 7, 4), + reqXHeight: 2, + wantWidth: 2, want: &YDetails{ Width: 5, Start: image.Point{4, 0}, @@ -163,7 +191,7 @@ func TestY(t *testing.T) { t.Errorf("RequiredWidth => got %v, want %v", gotWidth, tc.wantWidth) } - got, err := y.Details(tc.cvsAr, tc.mode) + got, err := y.Details(tc.cvsAr, tc.reqXHeight, tc.mode) if (err != nil) != tc.wantErr { t.Errorf("Details => unexpected error: %v, wantErr: %v", err, tc.wantErr) } @@ -227,6 +255,24 @@ func TestNewXDetails(t *testing.T) { }, }, }, + { + desc: "works with no data points, vertical", + numPoints: 0, + yStart: image.Point{0, 0}, + cvsAr: image.Rect(0, 0, 2, 3), + labelOrientation: LabelOrientationVertical, + want: &XDetails{ + Start: image.Point{0, 1}, + End: image.Point{1, 1}, + Scale: mustNewXScale(0, 1, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{1, 2}, + }, + }, + }, + }, { desc: "accounts for non-zero yStart", numPoints: 0, @@ -244,6 +290,76 @@ func TestNewXDetails(t *testing.T) { }, }, }, + { + desc: "accounts for longer vertical labels, the tallest didn't fit", + numPoints: 1000, + yStart: image.Point{2, 0}, + cvsAr: image.Rect(0, 0, 10, 10), + labelOrientation: LabelOrientationVertical, + want: &XDetails{ + Start: image.Point{2, 5}, + End: image.Point{9, 5}, + Scale: mustNewXScale(1000, 7, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{3, 6}, + }, + { + Value: NewValue(615, nonZeroDecimals), + Pos: image.Point{7, 6}, + }, + }, + }, + }, + { + desc: "accounts for longer vertical labels, the tallest label fits", + numPoints: 999, + yStart: image.Point{2, 0}, + cvsAr: image.Rect(0, 0, 10, 10), + labelOrientation: LabelOrientationVertical, + want: &XDetails{ + Start: image.Point{2, 6}, + End: image.Point{9, 6}, + Scale: mustNewXScale(999, 7, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{3, 7}, + }, + { + Value: NewValue(614, nonZeroDecimals), + Pos: image.Point{7, 7}, + }, + }, + }, + }, + { + desc: "accounts for longer custom labels, vertical", + numPoints: 2, + yStart: image.Point{5, 0}, + cvsAr: image.Rect(0, 0, 20, 10), + customLabels: map[int]string{ + 0: "start", + 1: "end", + }, + labelOrientation: LabelOrientationVertical, + want: &XDetails{ + Start: image.Point{5, 4}, + End: image.Point{19, 4}, + Scale: mustNewXScale(2, 14, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewTextValue("start"), + Pos: image.Point{6, 5}, + }, + { + Value: NewTextValue("end"), + Pos: image.Point{19, 5}, + }, + }, + }, + }, } for _, tc := range tests { diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go index e258dbf..87fd66d 100644 --- a/widgets/linechart/linechart.go +++ b/widgets/linechart/linechart.go @@ -182,7 +182,8 @@ func (lc *LineChart) Draw(cvs *canvas.Canvas) error { return draw.ResizeNeeded(cvs) } - yd, err := lc.yAxis.Details(cvs.Area(), lc.opts.yAxisMode) + reqXHeight := axes.RequiredHeight(lc.maxPoints(), lc.xLabels, lc.opts.xLabelOrientation) + yd, err := lc.yAxis.Details(cvs.Area(), reqXHeight, lc.opts.yAxisMode) if err != nil { return fmt.Errorf("lc.yAxis.Details => %v", err) } @@ -219,8 +220,19 @@ func (lc *LineChart) drawAxes(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YD } for _, l := range xd.Labels { - if err := draw.Text(cvs, l.Value.Text(), l.Pos, draw.TextCellOpts(lc.opts.xLabelCellOpts...)); err != nil { - return fmt.Errorf("failed to draw the X labels: %v", err) + switch lc.opts.xLabelOrientation { + case axes.LabelOrientationHorizontal: + if err := draw.Text(cvs, l.Value.Text(), l.Pos, draw.TextCellOpts(lc.opts.xLabelCellOpts...)); err != nil { + return fmt.Errorf("failed to draw the X horizontal labels: %v", err) + } + + case axes.LabelOrientationVertical: + if err := draw.VerticalText(cvs, l.Value.Text(), l.Pos, + draw.VerticalTextCellOpts(lc.opts.xLabelCellOpts...), + draw.VerticalTextOverrunMode(draw.OverrunModeThreeDot), + ); err != nil { + return fmt.Errorf("failed to draw the vertical X labels: %v", err) + } } } return nil @@ -300,8 +312,11 @@ func (lc *LineChart) minSize() image.Point { // - n cells width for the Y axis and its labels as reported by it. // - at least 1 cell width for the graph. reqWidth := lc.yAxis.RequiredWidth() + 1 - // - 2 cells height the X axis and its values and 2 for min and max labels on Y. - const reqHeight = 4 + + // And for the height: + // - n cells width for the X axis and its labels as reported by it. + // - at least 1 cell height for the graph. + reqHeight := axes.RequiredHeight(lc.maxPoints(), lc.xLabels, lc.opts.xLabelOrientation) + 1 return image.Point{reqWidth, reqHeight} } diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go index bdc6b29..bb73257 100644 --- a/widgets/linechart/linechart_test.go +++ b/widgets/linechart/linechart_test.go @@ -260,7 +260,7 @@ func TestLineChartDraws(t *testing.T) { }, }, { - desc: "custom X labels", + desc: "custom X labels, horizontal by default", canvas: image.Rect(0, 0, 20, 10), writes: func(lc *LineChart) error { return lc.Series("first", []float64{0, 100}, SeriesXLabels(map[int]string{ @@ -294,6 +294,83 @@ func TestLineChartDraws(t *testing.T) { return ft }, }, + { + desc: "custom X labels, horizontal with option", + opts: []Option{ + XLabelsHorizontal(), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}, SeriesXLabels(map[int]string{ + 0: "start", + 1: "end", + })) + }, + 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, "start", image.Point{6, 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: "custom X labels, vertical", + opts: []Option{ + XLabelsVertical(), + }, + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}, SeriesXLabels(map[int]string{ + 0: "start", + 1: "end", + })) + }, + 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, 4}}, + {Start: image.Point{6, 4}, End: image.Point{19, 4}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{5, 3}) + testdraw.MustText(c, "80.040", image.Point{0, 0}) + testdraw.MustVerticalText(c, "start", image.Point{7, 5}) + testdraw.MustVerticalText(c, "end", image.Point{19, 5}) + + // Braille line. + graphAr := image.Rect(7, 0, 20, 4) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 15}, image.Point{25, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, { desc: "sets series cell options", canvas: image.Rect(0, 0, 20, 10), @@ -524,6 +601,7 @@ func TestLineChartDraws(t *testing.T) { func TestOptions(t *testing.T) { tests := []struct { desc string + opts []Option // if not nil, executed before obtaining the options. addSeries func(*LineChart) error want widgetapi.Options @@ -531,7 +609,7 @@ func TestOptions(t *testing.T) { { desc: "reserves space for axis without series", want: widgetapi.Options{ - MinimumSize: image.Point{3, 4}, + MinimumSize: image.Point{3, 3}, }, }, { @@ -540,7 +618,7 @@ func TestOptions(t *testing.T) { return lc.Series("series", []float64{0, 100}) }, want: widgetapi.Options{ - MinimumSize: image.Point{5, 4}, + MinimumSize: image.Point{5, 3}, }, }, { @@ -549,14 +627,38 @@ func TestOptions(t *testing.T) { return lc.Series("series", []float64{-100, 100}) }, want: widgetapi.Options{ - MinimumSize: image.Point{6, 4}, + MinimumSize: image.Point{6, 3}, + }, + }, + { + desc: "reserves space for longer vertical X labels", + opts: []Option{ + XLabelsVertical(), + }, + addSeries: func(lc *LineChart) error { + return lc.Series("series", []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) + }, + want: widgetapi.Options{ + MinimumSize: image.Point{4, 4}, + }, + }, + { + desc: "reserves space for longer custom vertical X labels", + opts: []Option{ + XLabelsVertical(), + }, + addSeries: func(lc *LineChart) error { + return lc.Series("series", []float64{0, 100}, SeriesXLabels(map[int]string{0: "text"})) + }, + want: widgetapi.Options{ + MinimumSize: image.Point{5, 6}, }, }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - lc := New() + lc := New(tc.opts...) if tc.addSeries != nil { if err := tc.addSeries(lc); err != nil { From 5f6a84314cd949b2bdd2655bfca4c3325e741c05 Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Thu, 14 Feb 2019 00:43:27 -0500 Subject: [PATCH 4/6] Account for two labels on the Y axis. --- widgets/linechart/linechart.go | 4 ++-- widgets/linechart/linechart_test.go | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go index 87fd66d..a64e83a 100644 --- a/widgets/linechart/linechart.go +++ b/widgets/linechart/linechart.go @@ -315,8 +315,8 @@ func (lc *LineChart) minSize() image.Point { // And for the height: // - n cells width for the X axis and its labels as reported by it. - // - at least 1 cell height for the graph. - reqHeight := axes.RequiredHeight(lc.maxPoints(), lc.xLabels, lc.opts.xLabelOrientation) + 1 + // - at least 2 cell height for the graph. + reqHeight := axes.RequiredHeight(lc.maxPoints(), lc.xLabels, lc.opts.xLabelOrientation) + 2 return image.Point{reqWidth, reqHeight} } diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go index bb73257..b0bf7a2 100644 --- a/widgets/linechart/linechart_test.go +++ b/widgets/linechart/linechart_test.go @@ -609,7 +609,7 @@ func TestOptions(t *testing.T) { { desc: "reserves space for axis without series", want: widgetapi.Options{ - MinimumSize: image.Point{3, 3}, + MinimumSize: image.Point{3, 4}, }, }, { @@ -618,7 +618,7 @@ func TestOptions(t *testing.T) { return lc.Series("series", []float64{0, 100}) }, want: widgetapi.Options{ - MinimumSize: image.Point{5, 3}, + MinimumSize: image.Point{5, 4}, }, }, { @@ -627,7 +627,7 @@ func TestOptions(t *testing.T) { return lc.Series("series", []float64{-100, 100}) }, want: widgetapi.Options{ - MinimumSize: image.Point{6, 3}, + MinimumSize: image.Point{6, 4}, }, }, { @@ -639,7 +639,7 @@ func TestOptions(t *testing.T) { return lc.Series("series", []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) }, want: widgetapi.Options{ - MinimumSize: image.Point{4, 4}, + MinimumSize: image.Point{4, 5}, }, }, { @@ -651,7 +651,7 @@ func TestOptions(t *testing.T) { return lc.Series("series", []float64{0, 100}, SeriesXLabels(map[int]string{0: "text"})) }, want: widgetapi.Options{ - MinimumSize: image.Point{5, 6}, + MinimumSize: image.Point{5, 7}, }, }, } From 369b86604c17a5a989ec1f00d44b954f8ab596be Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Thu, 14 Feb 2019 00:46:14 -0500 Subject: [PATCH 5/6] Updating CHANGELOG. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eff000..626f866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A function that draws text in vertically. +- The LineChart widget can display X axis labels in vertical orientation. ### Changed From 11286f6ff6ce4fbb9b77f8393a79607de8a9bcec Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Thu, 14 Feb 2019 01:06:48 -0500 Subject: [PATCH 6/6] Improving test coverage of label.go. --- widgets/linechart/axes/label_test.go | 61 +++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/widgets/linechart/axes/label_test.go b/widgets/linechart/axes/label_test.go index 334985b..c59ff7a 100644 --- a/widgets/linechart/axes/label_test.go +++ b/widgets/linechart/axes/label_test.go @@ -404,7 +404,7 @@ func TestXLabels(t *testing.T) { if err != nil { t.Fatalf("NewXScale => unexpected error: %v", err) } - t.Logf("scale step: %v", scale.Step.Rounded) + t.Logf("scale step: %v, label orientation: %v", scale.Step.Rounded, tc.labelOrientation) got, err := xLabels(scale, tc.graphZero, tc.customLabels, tc.labelOrientation) if (err != nil) != tc.wantErr { t.Errorf("xLabels => unexpected error: %v, wantErr: %v", err, tc.wantErr) @@ -418,3 +418,62 @@ func TestXLabels(t *testing.T) { }) } } + +func TestXSpace(t *testing.T) { + tests := []struct { + desc string + graphZero image.Point + graphWidth int + sub int + wantRemaining int + wantRelative image.Point + wantErr bool + }{ + { + desc: "fails to subtract when we run out of space", + graphWidth: 1, + sub: 2, + wantErr: true, + }, + { + desc: "subtracts, graph is zero based", + graphWidth: 2, + sub: 1, + wantRemaining: 1, + wantRelative: image.Point{1, 1}, + }, + { + desc: "subtracts, graph isn't zero based", + graphZero: image.Point{10, 10}, + graphWidth: 2, + sub: 1, + wantRemaining: 1, + wantRelative: image.Point{1, 11}, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + xs := newXSpace(tc.graphZero, tc.graphWidth) + t.Logf("xSpace: %v", xs) + + err := xs.Sub(tc.sub) + if (err != nil) != tc.wantErr { + t.Errorf("xSpace.Sub => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + gotRemaining := xs.Remaining() + if gotRemaining != tc.wantRemaining { + t.Errorf("xSpace.Remaining => %v, want %v", gotRemaining, tc.wantRemaining) + } + + gotRelative := xs.Relative() + if diff := pretty.Compare(tc.wantRelative, gotRelative); diff != "" { + t.Errorf("xSpace.Relative => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +}