mirror of
https://github.com/mum4k/termdash.git
synced 2025-04-30 13:48:54 +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
|
### Added
|
||||||
|
|
||||||
- A function that draws text in vertically.
|
- A function that draws text in vertically.
|
||||||
|
- The LineChart widget can display X axis labels in vertical orientation.
|
||||||
|
|
||||||
### Changed
|
### 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.
|
// MustRectangle draws the rectangle on the canvas or panics.
|
||||||
func MustRectangle(c *canvas.Canvas, r image.Rectangle, opts ...draw.RectangleOption) {
|
func MustRectangle(c *canvas.Canvas, r image.Rectangle, opts ...draw.RectangleOption) {
|
||||||
if err := draw.Rectangle(c, r, opts...); err != nil {
|
if err := draw.Rectangle(c, r, opts...); err != nil {
|
||||||
|
@ -26,8 +26,8 @@ const (
|
|||||||
// rounded up to.
|
// rounded up to.
|
||||||
nonZeroDecimals = 2
|
nonZeroDecimals = 2
|
||||||
|
|
||||||
// yAxisWidth is width of the Y axis.
|
// axisWidth is width of an axis.
|
||||||
yAxisWidth = 1
|
axisWidth = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
// YDetails contain information about the Y axis that will be drawn onto the
|
// 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)
|
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 {
|
func (y *Y) RequiredWidth() int {
|
||||||
// This is an estimation only, it is possible that more labels in the
|
// 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
|
// 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.
|
// handled on the call to Details when the size of canvas is known.
|
||||||
return widestLabel([]*Label{
|
return longestLabel([]*Label{
|
||||||
{Value: y.min},
|
{Value: y.min},
|
||||||
{Value: y.max},
|
{Value: y.max},
|
||||||
}) + yAxisWidth
|
}) + axisWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
// Details retrieves details about the Y axis required to draw it on a canvas
|
// Details retrieves details about the Y axis required to draw it on a canvas
|
||||||
// of the provided area.
|
// 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()
|
cvsWidth := cvsAr.Dx()
|
||||||
cvsHeight := cvsAr.Dy()
|
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 {
|
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)
|
scale, err := NewYScale(y.min.Value, y.max.Value, graphHeight, nonZeroDecimals, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// See how the labels would look like on the entire maxWidth.
|
// See how the labels would look like on the entire maxWidth.
|
||||||
maxLabelWidth := maxWidth - yAxisWidth
|
maxLabelWidth := maxWidth - axisWidth
|
||||||
labels, err := yLabels(scale, maxLabelWidth)
|
labels, err := yLabels(scale, maxLabelWidth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -112,7 +114,7 @@ func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) {
|
|||||||
var width int
|
var width int
|
||||||
// Determine the largest label, which might be less than maxWidth.
|
// Determine the largest label, which might be less than maxWidth.
|
||||||
// Such case would allow us to save more space for the line chart itself.
|
// Such case would allow us to save more space for the line chart itself.
|
||||||
widest := widestLabel(labels)
|
widest := longestLabel(labels)
|
||||||
if widest < maxLabelWidth {
|
if widest < maxLabelWidth {
|
||||||
// Save the space and recalculate the labels, since they need to be realigned.
|
// Save the space and recalculate the labels, since they need to be realigned.
|
||||||
l, err := yLabels(scale, widest)
|
l, err := yLabels(scale, widest)
|
||||||
@ -120,7 +122,7 @@ func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
labels = l
|
labels = l
|
||||||
width = widest + yAxisWidth // One for the axis itself.
|
width = widest + axisWidth // One for the axis itself.
|
||||||
} else {
|
} else {
|
||||||
width = maxWidth
|
width = maxWidth
|
||||||
}
|
}
|
||||||
@ -134,8 +136,8 @@ func (y *Y) Details(cvsAr image.Rectangle, mode YScaleMode) (*YDetails, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// widestLabel returns the width of the widest label.
|
// longestLabel returns the width of the widest label.
|
||||||
func widestLabel(labels []*Label) int {
|
func longestLabel(labels []*Label) int {
|
||||||
var widest int
|
var widest int
|
||||||
for _, label := range labels {
|
for _, label := range labels {
|
||||||
if l := len(label.Value.Text()); l > widest {
|
if l := len(label.Value.Text()); l > widest {
|
||||||
@ -167,9 +169,12 @@ type XDetails struct {
|
|||||||
// plotted.
|
// plotted.
|
||||||
// customLabels are the desired labels for the X axis, these are preferred if
|
// customLabels are the desired labels for the X axis, these are preferred if
|
||||||
// provided.
|
// 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 {
|
cvsHeight := cvsAr.Dy()
|
||||||
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)
|
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.
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// One point horizontally for the Y axis.
|
// See how the labels would look like on the entire reqHeight.
|
||||||
// Two points vertically, one for the X axis and one for its labels.
|
graphZero := image.Point{
|
||||||
graphZero := image.Point{yStart.X + 1, cvsAr.Dy() - 3}
|
// Reserve one point horizontally for the Y axis.
|
||||||
labels, err := xLabels(scale, graphZero, customLabels)
|
yStart.X + 1,
|
||||||
|
cvsAr.Dy() - reqHeight - 1,
|
||||||
|
}
|
||||||
|
labels, err := xLabels(scale, graphZero, customLabels, lo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &XDetails{
|
return &XDetails{
|
||||||
Start: image.Point{yStart.X, cvsAr.Dy() - 2}, // One row for the labels.
|
Start: image.Point{yStart.X, cvsAr.Dy() - reqHeight}, // Space for the labels.
|
||||||
End: image.Point{yStart.X + graphWidth, cvsAr.Dy() - 2},
|
End: image.Point{yStart.X + graphWidth, cvsAr.Dy() - reqHeight},
|
||||||
Scale: scale,
|
Scale: scale,
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
}, nil
|
}, 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
|
||||||
|
}
|
||||||
|
@ -32,6 +32,7 @@ func TestY(t *testing.T) {
|
|||||||
minVal float64
|
minVal float64
|
||||||
maxVal float64
|
maxVal float64
|
||||||
update *updateY
|
update *updateY
|
||||||
|
reqXHeight int
|
||||||
mode YScaleMode
|
mode YScaleMode
|
||||||
cvsAr image.Rectangle
|
cvsAr image.Rectangle
|
||||||
wantWidth int
|
wantWidth int
|
||||||
@ -43,6 +44,7 @@ func TestY(t *testing.T) {
|
|||||||
minVal: 0,
|
minVal: 0,
|
||||||
maxVal: 3,
|
maxVal: 3,
|
||||||
cvsAr: image.Rect(0, 0, 3, 2),
|
cvsAr: image.Rect(0, 0, 3, 2),
|
||||||
|
reqXHeight: 2,
|
||||||
wantWidth: 2,
|
wantWidth: 2,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
@ -51,6 +53,7 @@ func TestY(t *testing.T) {
|
|||||||
minVal: 0,
|
minVal: 0,
|
||||||
maxVal: 3,
|
maxVal: 3,
|
||||||
cvsAr: image.Rect(0, 0, 2, 4),
|
cvsAr: image.Rect(0, 0, 2, 4),
|
||||||
|
reqXHeight: 2,
|
||||||
wantWidth: 2,
|
wantWidth: 2,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
@ -59,6 +62,7 @@ func TestY(t *testing.T) {
|
|||||||
minVal: 0,
|
minVal: 0,
|
||||||
maxVal: -1,
|
maxVal: -1,
|
||||||
cvsAr: image.Rect(0, 0, 4, 4),
|
cvsAr: image.Rect(0, 0, 4, 4),
|
||||||
|
reqXHeight: 2,
|
||||||
wantWidth: 3,
|
wantWidth: 3,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
@ -68,6 +72,7 @@ func TestY(t *testing.T) {
|
|||||||
maxVal: 3,
|
maxVal: 3,
|
||||||
cvsAr: image.Rect(0, 0, 3, 4),
|
cvsAr: image.Rect(0, 0, 3, 4),
|
||||||
wantWidth: 2,
|
wantWidth: 2,
|
||||||
|
reqXHeight: 2,
|
||||||
want: &YDetails{
|
want: &YDetails{
|
||||||
Width: 2,
|
Width: 2,
|
||||||
Start: image.Point{1, 0},
|
Start: image.Point{1, 0},
|
||||||
@ -85,6 +90,26 @@ func TestY(t *testing.T) {
|
|||||||
maxVal: 3,
|
maxVal: 3,
|
||||||
mode: YScaleModeAnchored,
|
mode: YScaleModeAnchored,
|
||||||
cvsAr: image.Rect(0, 0, 3, 4),
|
cvsAr: image.Rect(0, 0, 3, 4),
|
||||||
|
reqXHeight: 2,
|
||||||
|
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: "accommodates X scale that needs more height",
|
||||||
|
minVal: 1,
|
||||||
|
maxVal: 3,
|
||||||
|
mode: YScaleModeAnchored,
|
||||||
|
cvsAr: image.Rect(0, 0, 3, 6),
|
||||||
|
reqXHeight: 4,
|
||||||
wantWidth: 2,
|
wantWidth: 2,
|
||||||
want: &YDetails{
|
want: &YDetails{
|
||||||
Width: 2,
|
Width: 2,
|
||||||
@ -103,6 +128,7 @@ func TestY(t *testing.T) {
|
|||||||
maxVal: 6,
|
maxVal: 6,
|
||||||
mode: YScaleModeAdaptive,
|
mode: YScaleModeAdaptive,
|
||||||
cvsAr: image.Rect(0, 0, 3, 4),
|
cvsAr: image.Rect(0, 0, 3, 4),
|
||||||
|
reqXHeight: 2,
|
||||||
wantWidth: 2,
|
wantWidth: 2,
|
||||||
want: &YDetails{
|
want: &YDetails{
|
||||||
Width: 2,
|
Width: 2,
|
||||||
@ -120,6 +146,7 @@ func TestY(t *testing.T) {
|
|||||||
minVal: 0,
|
minVal: 0,
|
||||||
maxVal: 3,
|
maxVal: 3,
|
||||||
cvsAr: image.Rect(0, 0, 6, 4),
|
cvsAr: image.Rect(0, 0, 6, 4),
|
||||||
|
reqXHeight: 2,
|
||||||
wantWidth: 2,
|
wantWidth: 2,
|
||||||
want: &YDetails{
|
want: &YDetails{
|
||||||
Width: 5,
|
Width: 5,
|
||||||
@ -137,6 +164,7 @@ func TestY(t *testing.T) {
|
|||||||
minVal: 0,
|
minVal: 0,
|
||||||
maxVal: 3,
|
maxVal: 3,
|
||||||
cvsAr: image.Rect(0, 0, 7, 4),
|
cvsAr: image.Rect(0, 0, 7, 4),
|
||||||
|
reqXHeight: 2,
|
||||||
wantWidth: 2,
|
wantWidth: 2,
|
||||||
want: &YDetails{
|
want: &YDetails{
|
||||||
Width: 5,
|
Width: 5,
|
||||||
@ -163,7 +191,7 @@ func TestY(t *testing.T) {
|
|||||||
t.Errorf("RequiredWidth => got %v, want %v", gotWidth, tc.wantWidth)
|
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 {
|
if (err != nil) != tc.wantErr {
|
||||||
t.Errorf("Details => unexpected error: %v, wantErr: %v", err, tc.wantErr)
|
t.Errorf("Details => unexpected error: %v, wantErr: %v", err, tc.wantErr)
|
||||||
}
|
}
|
||||||
@ -185,6 +213,7 @@ func TestNewXDetails(t *testing.T) {
|
|||||||
cvsWidth int
|
cvsWidth int
|
||||||
cvsAr image.Rectangle
|
cvsAr image.Rectangle
|
||||||
customLabels map[int]string
|
customLabels map[int]string
|
||||||
|
labelOrientation LabelOrientation
|
||||||
want *XDetails
|
want *XDetails
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
@ -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",
|
desc: "accounts for non-zero yStart",
|
||||||
numPoints: 0,
|
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 {
|
for _, tc := range tests {
|
||||||
t.Run(tc.desc, func(t *testing.T) {
|
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 {
|
if (err != nil) != tc.wantErr {
|
||||||
t.Errorf("NewXDetails => unexpected error: %v, wantErr: %v", err, 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"
|
"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.
|
// Label is one value label on an axis.
|
||||||
type Label struct {
|
type Label struct {
|
||||||
// Value if the value to be displayed.
|
// 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.
|
// fit under the width of the axis.
|
||||||
// The customLabels map value positions in the series to the desired custom
|
// The customLabels map value positions in the series to the desired custom
|
||||||
// label. These are preferred if present.
|
// 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)
|
space := newXSpace(graphZero, scale.GraphWidth)
|
||||||
const minSpacing = 3
|
const minSpacing = 3
|
||||||
var res []*Label
|
var res []*Label
|
||||||
|
|
||||||
next := 0
|
next := 0
|
||||||
for haveLabels := 0; haveLabels <= int(scale.Max.Value); haveLabels = len(res) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// 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.
|
// 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.
|
// 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()
|
pos := space.Relative()
|
||||||
label, err := scale.CellLabel(pos.X)
|
label, err := scale.CellLabel(pos.X)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -216,7 +242,13 @@ func colLabel(scale *XScale, space *xSpace, customLabels map[int]string) (*Label
|
|||||||
label = NewTextValue(custom)
|
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() {
|
if labelLen > space.Remaining() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
@ -156,6 +156,7 @@ func TestXLabels(t *testing.T) {
|
|||||||
graphWidth int
|
graphWidth int
|
||||||
graphZero image.Point
|
graphZero image.Point
|
||||||
customLabels map[int]string
|
customLabels map[int]string
|
||||||
|
labelOrientation LabelOrientation
|
||||||
want []*Label
|
want []*Label
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
@ -168,6 +169,16 @@ func TestXLabels(t *testing.T) {
|
|||||||
{NewValue(0, nonZeroDecimals), image.Point{0, 3}},
|
{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",
|
desc: "two points, only one label fits",
|
||||||
numPoints: 2,
|
numPoints: 2,
|
||||||
@ -187,6 +198,17 @@ func TestXLabels(t *testing.T) {
|
|||||||
{NewValue(1, nonZeroDecimals), image.Point{4, 3}},
|
{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",
|
desc: "labels are placed according to graphZero",
|
||||||
numPoints: 2,
|
numPoints: 2,
|
||||||
@ -321,6 +343,59 @@ func TestXLabels(t *testing.T) {
|
|||||||
{NewValue(72, nonZeroDecimals), image.Point{4, 3}},
|
{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 {
|
for _, tc := range tests {
|
||||||
@ -329,8 +404,8 @@ func TestXLabels(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewXScale => unexpected error: %v", err)
|
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)
|
got, err := xLabels(scale, tc.graphZero, tc.customLabels, tc.labelOrientation)
|
||||||
if (err != nil) != tc.wantErr {
|
if (err != nil) != tc.wantErr {
|
||||||
t.Errorf("xLabels => unexpected error: %v, wantErr: %v", err, 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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("lc.yAxis.Details => %v", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("NewXDetails => %v", err)
|
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 {
|
for _, l := range xd.Labels {
|
||||||
|
switch lc.opts.xLabelOrientation {
|
||||||
|
case axes.LabelOrientationHorizontal:
|
||||||
if err := draw.Text(cvs, l.Value.Text(), l.Pos, draw.TextCellOpts(lc.opts.xLabelCellOpts...)); err != nil {
|
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)
|
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
|
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.
|
// - n cells width for the Y axis and its labels as reported by it.
|
||||||
// - at least 1 cell width for the graph.
|
// - at least 1 cell width for the graph.
|
||||||
reqWidth := lc.yAxis.RequiredWidth() + 1
|
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}
|
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),
|
canvas: image.Rect(0, 0, 20, 10),
|
||||||
writes: func(lc *LineChart) error {
|
writes: func(lc *LineChart) error {
|
||||||
return lc.Series("first", []float64{0, 100}, SeriesXLabels(map[int]string{
|
return lc.Series("first", []float64{0, 100}, SeriesXLabels(map[int]string{
|
||||||
@ -294,6 +294,83 @@ func TestLineChartDraws(t *testing.T) {
|
|||||||
return ft
|
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",
|
desc: "sets series cell options",
|
||||||
canvas: image.Rect(0, 0, 20, 10),
|
canvas: image.Rect(0, 0, 20, 10),
|
||||||
@ -524,6 +601,7 @@ func TestLineChartDraws(t *testing.T) {
|
|||||||
func TestOptions(t *testing.T) {
|
func TestOptions(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
desc string
|
desc string
|
||||||
|
opts []Option
|
||||||
// if not nil, executed before obtaining the options.
|
// if not nil, executed before obtaining the options.
|
||||||
addSeries func(*LineChart) error
|
addSeries func(*LineChart) error
|
||||||
want widgetapi.Options
|
want widgetapi.Options
|
||||||
@ -552,11 +630,35 @@ func TestOptions(t *testing.T) {
|
|||||||
MinimumSize: image.Point{6, 4},
|
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 {
|
for _, tc := range tests {
|
||||||
t.Run(tc.desc, func(t *testing.T) {
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
lc := New()
|
lc := New(tc.opts...)
|
||||||
|
|
||||||
if tc.addSeries != nil {
|
if tc.addSeries != nil {
|
||||||
if err := tc.addSeries(lc); err != nil {
|
if err := tc.addSeries(lc); err != nil {
|
||||||
|
@ -31,6 +31,7 @@ type Option interface {
|
|||||||
type options struct {
|
type options struct {
|
||||||
axesCellOpts []cell.Option
|
axesCellOpts []cell.Option
|
||||||
xLabelCellOpts []cell.Option
|
xLabelCellOpts []cell.Option
|
||||||
|
xLabelOrientation axes.LabelOrientation
|
||||||
yLabelCellOpts []cell.Option
|
yLabelCellOpts []cell.Option
|
||||||
yAxisMode axes.YScaleMode
|
yAxisMode axes.YScaleMode
|
||||||
}
|
}
|
||||||
@ -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.
|
// YLabelCellOpts set the cell options for the labels on the Y axis.
|
||||||
func YLabelCellOpts(co ...cell.Option) Option {
|
func YLabelCellOpts(co ...cell.Option) Option {
|
||||||
return option(func(opts *options) {
|
return option(func(opts *options) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user