From 01957f0d15ab67ae13923221af50fb40e044c5ef Mon Sep 17 00:00:00 2001 From: Jakub Sobon Date: Sat, 12 Jan 2019 15:55:49 -0500 Subject: [PATCH] Finalizing the axes package. It can determine details for both of the axis. --- widgets/linechart/axes/axes.go | 37 ++++++++++++- widgets/linechart/axes/axes_test.go | 81 ++++++++++++++++++++++++++++ widgets/linechart/axes/label.go | 61 ++++++++++----------- widgets/linechart/axes/label_test.go | 40 +++++++------- widgets/linechart/axes/scale.go | 6 --- 5 files changed, 168 insertions(+), 57 deletions(-) diff --git a/widgets/linechart/axes/axes.go b/widgets/linechart/axes/axes.go index e0fd212..190120b 100644 --- a/widgets/linechart/axes/axes.go +++ b/widgets/linechart/axes/axes.go @@ -15,7 +15,10 @@ // Package axes calculates the required layout and draws the X and Y axes of a line chart. package axes -import "fmt" +import ( + "fmt" + "image" +) const ( // nonZeroDecimals determines the overall precision of values displayed on the @@ -77,7 +80,7 @@ func (y *Y) RequiredWidth() int { }) + yAxisWidth } -// Details retrieves details about the Y axis required to draw it on the provided canvas. +// Details retrieves details about the Y axis required to draw it on a canvas // of the provided height. The maxWidth indicates the maximum width available // for the Y axis and its labels. This is guaranteed to be at least what // RequiredWidth returned. @@ -130,3 +133,33 @@ func widestLabel(labels []*Label) int { } return widest } + +// XDetails contain information about the X axis that will be drawn onto the +// canvas. +type XDetails struct { + // Scale is the scale of the X axis. + Scale *XScale + + // Labels are the labels for values on the X axis in an increasing order. + Labels []*Label +} + +// NewXDetails retrieves details about the X axis required to draw it on a canvas +// of the provided height. The axisStart is the zero point of the X axis on the +// canvas and the axisWidth is its width in cells. The numPoints is the number +// of points in the largest series that will be plotted. +func NewXDetails(numPoints int, axisStart image.Point, axisWidth int) (*XDetails, error) { + scale, err := NewXScale(numPoints, axisWidth, nonZeroDecimals) + if err != nil { + return nil, err + } + + labels, err := xLabels(scale, axisStart) + if err != nil { + return nil, err + } + return &XDetails{ + Scale: scale, + Labels: labels, + }, nil +} diff --git a/widgets/linechart/axes/axes_test.go b/widgets/linechart/axes/axes_test.go index ad4b6f8..5d35042 100644 --- a/widgets/linechart/axes/axes_test.go +++ b/widgets/linechart/axes/axes_test.go @@ -56,6 +56,15 @@ func TestY(t *testing.T) { wantWidth: 2, wantErr: true, }, + { + desc: "fails when max is less than min", + minVal: 0, + maxVal: -1, + cvsHeight: 2, + maxWidth: 3, + wantWidth: 3, + wantErr: true, + }, { desc: "maxWidth equals required width", minVal: 0, @@ -131,3 +140,75 @@ func TestY(t *testing.T) { }) } } + +func TestNewXDetails(t *testing.T) { + tests := []struct { + desc string + numPoints int + axisStart image.Point + axisWidth int + want *XDetails + wantErr bool + }{ + { + desc: "fails when numPoints is negative", + numPoints: -1, + axisStart: image.Point{0, 1}, + axisWidth: 1, + wantErr: true, + }, + { + desc: "fails when axisWidth is too small", + numPoints: 1, + axisStart: image.Point{0, 1}, + axisWidth: 0, + wantErr: true, + }, + { + desc: "works with no data points", + numPoints: 0, + axisStart: image.Point{0, 1}, + axisWidth: 1, + want: &XDetails{ + Scale: mustNewXScale(0, 1, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{0, 1}, + }, + }, + }, + }, + { + desc: "axis doesn't start at point zero", + numPoints: 0, + axisStart: image.Point{11, 2}, + axisWidth: 1, + want: &XDetails{ + Scale: mustNewXScale(0, 1, nonZeroDecimals), + Labels: []*Label{ + { + Value: NewValue(0, nonZeroDecimals), + Pos: image.Point{11, 2}, + }, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, err := NewXDetails(tc.numPoints, tc.axisStart, tc.axisWidth) + if (err != nil) != tc.wantErr { + t.Errorf("NewXDetails => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("NewXDetails => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} diff --git a/widgets/linechart/axes/label.go b/widgets/linechart/axes/label.go index 3695f2b..1c96789 100644 --- a/widgets/linechart/axes/label.go +++ b/widgets/linechart/axes/label.go @@ -94,32 +94,30 @@ func rowLabel(scale *YScale, y int, labelWidth int) (*Label, error) { // xSpace represents an available space among the X axis. type xSpace struct { - // min is the current coordinate. + // min is the current relative coordinate. + // These are zero based, i.e. not adjusted to axisStart. cur int - // max is the maximum coordinate. + // max is the maximum relative coordinate. + // These are zero based, i.e. not adjusted to axisStart. // The xSpace instance contains points 0 <= x < max max int - // the y coordinate of this space. - y int + // axisStart is the actual position of the X axis zero point on the canvas. + axisStart image.Point } // newXSpace returns a new xSpace instance initialized for the provided width. -func newXSpace(axisWidth, cvsHeight int) (*xSpace, error) { - y, err := positionToY(0, cvsHeight) - if err != nil { - return nil, err - } +func newXSpace(axisStart image.Point, axisWidth int) *xSpace { return &xSpace{ - cur: 0, - max: axisWidth, - y: y, - }, nil + cur: 0, + max: axisWidth, + axisStart: axisStart, + } } // Implements fmt.Stringer. func (xs *xSpace) String() string { - return fmt.Sprintf("xSpace(size:%d)-cur:%v-max:%v", xs.Remaining(), image.Point{xs.cur, xs.y}, image.Point{xs.max, xs.y}) + return fmt.Sprintf("xSpace(size:%d)-cur:%v-max:%v", xs.Remaining(), image.Point{xs.cur, xs.axisStart.Y}, image.Point{xs.max, xs.axisStart.Y}) } // Remaining returns the remaining size on the X axis. @@ -127,9 +125,17 @@ func (xs *xSpace) Remaining() int { return xs.max - xs.cur } -// Current returns the current point. -func (xs *xSpace) Current() image.Point { - return image.Point{xs.cur, xs.y} +// Relative returns the relative coordinate within the space, these are zero +// based. +func (xs *xSpace) Relative() image.Point { + return image.Point{xs.cur, xs.axisStart.Y} +} + +// Absolute returns the absolute coordinate on the canvas where a label should +// be placed. The is the coordinate that represents the current relative +// coordinate of the space. +func (xs *xSpace) Absolute() image.Point { + return image.Point{xs.cur + xs.axisStart.X, xs.axisStart.Y} } // Sub subtracts the specified size from the beginning of the available @@ -143,19 +149,13 @@ func (xs *xSpace) Sub(size int) error { } // xLabels returns labels that should be placed under the X axis. +// The axisStart is the point where the X axis starts (its zero value) and is +// used to determine label placement. // Labels are returned in an increasing value order. // Returned labels shouldn't be trimmed, their count is adjusted so that they // fit under the width of the axis. -func xLabels(scale *XScale, cvsHeight int) ([]*Label, error) { - if min := 2; cvsHeight < min { - return nil, fmt.Errorf("cannot place labels on a canvas with height %d, minimum is %d", cvsHeight, min) - } - - space, err := newXSpace(scale.AxisWidth, cvsHeight) - if err != nil { - return nil, fmt.Errorf("newXSpace => %v", err) - } - +func xLabels(scale *XScale, axisStart image.Point) ([]*Label, error) { + space := newXSpace(axisStart, scale.AxisWidth) const minSpacing = 3 var res []*Label @@ -179,7 +179,7 @@ func xLabels(scale *XScale, cvsHeight int) ([]*Label, error) { return nil, err } - skip := nextCell - space.Current().X + skip := nextCell - space.Relative().X if skip < minSpacing { skip = minSpacing } @@ -198,7 +198,7 @@ func xLabels(scale *XScale, cvsHeight int) ([]*Label, error) { // 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) (*Label, error) { - pos := space.Current() + pos := space.Relative() v, err := scale.CellLabel(pos.X) if err != nil { return nil, fmt.Errorf("unable to determine label value for column %d: %v", pos.X, err) @@ -209,12 +209,13 @@ func colLabel(scale *XScale, space *xSpace) (*Label, error) { return nil, nil } + abs := space.Absolute() if err := space.Sub(labelLen); err != nil { return nil, err } return &Label{ Value: v, - Pos: pos, + Pos: abs, }, nil } diff --git a/widgets/linechart/axes/label_test.go b/widgets/linechart/axes/label_test.go index d792f96..8c956bd 100644 --- a/widgets/linechart/axes/label_test.go +++ b/widgets/linechart/axes/label_test.go @@ -144,23 +144,15 @@ func TestXLabels(t *testing.T) { desc string numPoints int axisWidth int - cvsHeight int + axisStart image.Point want []*Label wantErr bool }{ - - { - desc: "fails when canvas height is too small", - numPoints: 1, - axisWidth: 1, - cvsHeight: 1, - wantErr: true, - }, { desc: "only one point", numPoints: 1, axisWidth: 1, - cvsHeight: 2, + axisStart: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, }, @@ -169,7 +161,7 @@ func TestXLabels(t *testing.T) { desc: "two points, only one label fits", numPoints: 2, axisWidth: 1, - cvsHeight: 2, + axisStart: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, }, @@ -178,17 +170,27 @@ func TestXLabels(t *testing.T) { desc: "two points, two labels fit exactly", numPoints: 2, axisWidth: 5, - cvsHeight: 2, + axisStart: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, {NewValue(1, nonZeroDecimals), image.Point{4, 1}}, }, }, + { + desc: "labels are placed according to axisStart", + numPoints: 2, + axisWidth: 5, + axisStart: image.Point{3, 5}, + want: []*Label{ + {NewValue(0, nonZeroDecimals), image.Point{3, 5}}, + {NewValue(1, nonZeroDecimals), image.Point{7, 5}}, + }, + }, { desc: "skip to next value exhausts the space completely", numPoints: 11, axisWidth: 4, - cvsHeight: 2, + axisStart: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, }, @@ -197,7 +199,7 @@ func TestXLabels(t *testing.T) { desc: "second label doesn't fit due to its length", numPoints: 100, axisWidth: 5, - cvsHeight: 2, + axisStart: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, }, @@ -206,7 +208,7 @@ func TestXLabels(t *testing.T) { desc: "two points, two labels, more space than minSpacing so end label adjusted", numPoints: 2, axisWidth: 6, - cvsHeight: 2, + axisStart: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, {NewValue(1, nonZeroDecimals), image.Point{5, 1}}, @@ -216,7 +218,7 @@ func TestXLabels(t *testing.T) { desc: "at most as many labels as there are points", numPoints: 2, axisWidth: 100, - cvsHeight: 2, + axisStart: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, {NewValue(1, nonZeroDecimals), image.Point{98, 1}}, @@ -226,7 +228,7 @@ func TestXLabels(t *testing.T) { desc: "some labels in the middle", numPoints: 4, axisWidth: 100, - cvsHeight: 2, + axisStart: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, {NewValue(1, nonZeroDecimals), image.Point{31, 1}}, @@ -238,7 +240,7 @@ func TestXLabels(t *testing.T) { desc: "more points than pixels", numPoints: 100, axisWidth: 6, - cvsHeight: 2, + axisStart: image.Point{0, 1}, want: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, {NewValue(72, nonZeroDecimals), image.Point{4, 1}}, @@ -253,7 +255,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.cvsHeight) + got, err := xLabels(scale, tc.axisStart) if (err != nil) != tc.wantErr { t.Errorf("xLabels => unexpected error: %v, wantErr: %v", err, tc.wantErr) } diff --git a/widgets/linechart/axes/scale.go b/widgets/linechart/axes/scale.go index b6b4721..f969476 100644 --- a/widgets/linechart/axes/scale.go +++ b/widgets/linechart/axes/scale.go @@ -100,9 +100,6 @@ func (ys *YScale) PixelToValue(y int) (float64, error) { // The value must be within the bounds provided to NewYScale. Y coordinates // grow down. func (ys *YScale) ValueToPixel(v float64) (int, error) { - if min, max := ys.Min.Value, ys.Max.Rounded; v < min || v > max { - return 0, fmt.Errorf("invalid value %v, must be in range %v <= v <= %v", v, min, max) - } if ys.Step.Rounded == 0 { return 0, nil } @@ -234,9 +231,6 @@ func (xs *XScale) ValueToCell(v int) (int, error) { // axisWidth provided to NewXScale. X coordinates grow right. // The returned value is rounded to the nearest int, rounding half away from zero. func (xs *XScale) CellLabel(x int) (*Value, error) { - if min, max := 0, xs.AxisWidth; x < min || x >= max { - return nil, fmt.Errorf("invalid cell coordinate %d, must be in range %v <= x < %v", x, min, max) - } v, err := xs.PixelToValue(x * braille.ColMult) if err != nil { return nil, err