Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding a LineChart option that switches the Y axis from anchored to adaptive. #96

Merged
merged 3 commits into from
Jan 27, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- The LineChart now correctly displays positive and negative series that don't
contain zero value.
- The LineChart now has an option to change the behavior of the Y axis from
zero anchored to adaptive.
- Lint errors reported on the Go report card.

## [0.5.0] - 21-Jan-2019
Expand Down
4 changes: 2 additions & 2 deletions widgets/linechart/axes/axes.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ 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) (*YDetails, error) {
func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) {
cvsWidth := cvsAr.Dx()
cvsHeight := cvsAr.Dy()
maxWidth := cvsWidth - 1 // Reserve one row for the line chart itself.
Expand All @@ -97,7 +97,7 @@ func (y *Y) Details(cvsAr image.Rectangle) (*YDetails, error) {
}

graphHeight := cvsHeight - 2 // One row for the X axis and one for its labels.
scale, err := NewYScale(y.min.Value, y.max.Value, graphHeight, nonZeroDecimals)
scale, err := NewYScale(y.min.Value, y.max.Value, graphHeight, nonZeroDecimals, mode)
if err != nil {
return nil, err
}
Expand Down
45 changes: 41 additions & 4 deletions widgets/linechart/axes/axes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func TestY(t *testing.T) {
minVal float64
maxVal float64
update *updateY
mode YScaleMode
cvsAr image.Rectangle
wantWidth int
want *YDetails
Expand Down Expand Up @@ -71,13 +72,49 @@ func TestY(t *testing.T) {
Width: 2,
Start: image.Point{1, 0},
End: image.Point{1, 2},
Scale: mustNewYScale(0, 3, 2, nonZeroDecimals),
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 anchored scale",
minVal: 1,
maxVal: 3,
mode: YScaleModeAnchored,
cvsAr: image.Rect(0, 0, 3, 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),
wantWidth: 2,
want: &YDetails{
Width: 2,
Start: image.Point{1, 0},
End: image.Point{1, 2},
Scale: mustNewYScale(1, 6, 2, nonZeroDecimals, YScaleModeAdaptive),
Labels: []*Label{
{NewValue(1, nonZeroDecimals), image.Point{0, 1}},
{NewValue(3.88, nonZeroDecimals), image.Point{0, 0}},
},
},
},
{
desc: "cvsWidth just accommodates the longest label",
minVal: 0,
Expand All @@ -88,7 +125,7 @@ func TestY(t *testing.T) {
Width: 5,
Start: image.Point{4, 0},
End: image.Point{4, 2},
Scale: mustNewYScale(0, 3, 2, nonZeroDecimals),
Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored),
Labels: []*Label{
{NewValue(0, nonZeroDecimals), image.Point{3, 1}},
{NewValue(1.72, nonZeroDecimals), image.Point{0, 0}},
Expand All @@ -105,7 +142,7 @@ func TestY(t *testing.T) {
Width: 5,
Start: image.Point{4, 0},
End: image.Point{4, 2},
Scale: mustNewYScale(0, 3, 2, nonZeroDecimals),
Scale: mustNewYScale(0, 3, 2, nonZeroDecimals, YScaleModeAnchored),
Labels: []*Label{
{NewValue(0, nonZeroDecimals), image.Point{3, 1}},
{NewValue(1.72, nonZeroDecimals), image.Point{0, 0}},
Expand All @@ -126,7 +163,7 @@ func TestY(t *testing.T) {
t.Errorf("RequiredWidth => got %v, want %v", gotWidth, tc.wantWidth)
}

got, err := y.Details(tc.cvsAr)
got, err := y.Details(tc.cvsAr, tc.mode)
if (err != nil) != tc.wantErr {
t.Errorf("Details => unexpected error: %v, wantErr: %v", err, tc.wantErr)
}
Expand Down
2 changes: 1 addition & 1 deletion widgets/linechart/axes/label_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ func TestYLabels(t *testing.T) {

for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) {
scale, err := NewYScale(tc.min, tc.max, tc.graphHeight, nonZeroDecimals)
scale, err := NewYScale(tc.min, tc.max, tc.graphHeight, nonZeroDecimals, YScaleModeAnchored)
if err != nil {
t.Fatalf("NewYScale => unexpected error: %v", err)
}
Expand Down
57 changes: 51 additions & 6 deletions widgets/linechart/axes/scale.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,35 @@ import (
"github.com/mum4k/termdash/numbers"
)

// YScaleMode determines whether the Y scale is anchored to the zero value.
type YScaleMode int

// String implements fmt.Stringer()
func (ysm YScaleMode) String() string {
if n, ok := yScaleModeNames[ysm]; ok {
return n
}
return "YScaleModeUnknown"
}

// yScaleModeNames maps YScaleMode values to human readable names.
var yScaleModeNames = map[YScaleMode]string{
YScaleModeAnchored: "YScaleModeAnchored",
YScaleModeAdaptive: "YScaleModeAdaptive",
}

const (
// YScaleModeAnchored is a mode in which the Y scale always starts at value
// zero regardless of the min and max on the series.
YScaleModeAnchored YScaleMode = iota

// YScaleModeAdaptive is a mode where the Y scale adapts its base value
// according to the min and max on the series.
// I.e. it starts at min for all-positive series and at max for
// all-negative series.
YScaleModeAdaptive
)

// YScale is the scale of the Y axis.
type YScale struct {
// Min is the minimum value on the axis.
Expand All @@ -44,7 +73,7 @@ type YScale struct {
// calculated scale, see NewValue for details.
// Max must be greater or equal to min. The graphHeight must be a positive
// number.
func NewYScale(min, max float64, graphHeight, nonZeroDecimals int) (*YScale, error) {
func NewYScale(min, max float64, graphHeight, nonZeroDecimals int, mode YScaleMode) (*YScale, error) {
if max < min {
return nil, fmt.Errorf("max(%v) cannot be less than min(%v)", max, min)
}
Expand All @@ -55,11 +84,27 @@ func NewYScale(min, max float64, graphHeight, nonZeroDecimals int) (*YScale, err
brailleHeight := graphHeight * braille.RowMult
usablePixels := brailleHeight - 1 // One pixel reserved for value zero.

if min > 0 && min == max { // If all the data points are equal, make the scale zero based so we can draw something.
min = 0
}
if max < 0 && min == max { // If all the data points are equal, make the scale zero based so we can draw something.
max = 0
switch mode {
case YScaleModeAnchored:
// Anchor the axis at the zero value.
if min > 0 {
min = 0
}
if max < 0 {
max = 0
}

case YScaleModeAdaptive:
// Even in this mode, we still anchor the axis at the zero if all the
// data points are equal, so we can still draw something.
if min > 0 && min == max {
min = 0
}
if max < 0 && min == max {
max = 0
}
default:
return nil, fmt.Errorf("unsupported mode: %v(%d)", mode, mode)
}
diff := max - min
step := NewValue(diff/float64(usablePixels), nonZeroDecimals)
Expand Down
Loading