1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-28 13:48:51 +08:00

Merge pull request #122 from mum4k/vertical-labels

Linechart now supports vertical label orientation under the X axis.
This commit is contained in:
Jakub Sobon 2019-02-14 01:15:05 -05:00 committed by GitHub
commit 6969da653e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 616 additions and 112 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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,35 +75,37 @@ 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
// 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
}
// 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 +114,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 +122,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 +136,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 {
@ -167,9 +169,12 @@ 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) {
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)
func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, customLabels map[int]string, lo LabelOrientation) (*XDetails, error) {
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.
@ -179,17 +184,41 @@ 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}
labels, err := xLabels(scale, graphZero, customLabels)
// 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
}
// 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
}

View File

@ -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)
}
@ -179,14 +207,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",
@ -226,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,
@ -243,11 +290,81 @@ 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 {
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)
}
@ -261,3 +378,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)
}
})
}
}

View File

@ -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
}

View File

@ -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 {
@ -329,8 +404,8 @@ func TestXLabels(t *testing.T) {
if err != nil {
t.Fatalf("NewXScale => unexpected error: %v", err)
}
t.Logf("scale step: %v", scale.Step.Rounded)
got, err := xLabels(scale, tc.graphZero, tc.customLabels)
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)
}
@ -343,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)
}
})
}
}

View File

@ -182,12 +182,13 @@ 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)
}
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)
}
@ -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 2 cell height for the graph.
reqHeight := axes.RequiredHeight(lc.maxPoints(), lc.xLabels, lc.opts.xLabelOrientation) + 2
return image.Point{reqWidth, reqHeight}
}

View File

@ -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
@ -552,11 +630,35 @@ func TestOptions(t *testing.T) {
MinimumSize: image.Point{6, 4},
},
},
{
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, 5},
},
},
{
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, 7},
},
},
}
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 {

View File

@ -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) {