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:
commit
6969da653e
@ -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
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user