diff --git a/canvas/braille/testbraille/testbraille.go b/canvas/braille/testbraille/testbraille.go index 1ceabed..9685e6a 100644 --- a/canvas/braille/testbraille/testbraille.go +++ b/canvas/braille/testbraille/testbraille.go @@ -19,6 +19,7 @@ import ( "fmt" "image" + "github.com/mum4k/termdash/canvas" "github.com/mum4k/termdash/canvas/braille" "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/terminal/faketerm" @@ -46,3 +47,10 @@ func MustSetPixel(bc *braille.Canvas, p image.Point, opts ...cell.Option) { panic(fmt.Sprintf("braille.SetPixel => unexpected error: %v", err)) } } + +// MustCopyTo copies the braille canvas onto the provided canvas or panics. +func MustCopyTo(bc *braille.Canvas, dst *canvas.Canvas) { + if err := bc.CopyTo(dst); err != nil { + panic(fmt.Sprintf("bc.CopyTo => unexpected error: %v", err)) + } +} diff --git a/draw/testdraw/testdraw.go b/draw/testdraw/testdraw.go index 674c20a..4f057e7 100644 --- a/draw/testdraw/testdraw.go +++ b/draw/testdraw/testdraw.go @@ -20,6 +20,7 @@ import ( "image" "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/canvas/braille" "github.com/mum4k/termdash/draw" ) @@ -50,3 +51,10 @@ func MustHVLines(c *canvas.Canvas, lines []draw.HVLine, opts ...draw.HVLineOptio panic(fmt.Sprintf("draw.HVLines => unexpected error: %v", err)) } } + +// MustBrailleLine draws the braille line or panics. +func MustBrailleLine(bc *braille.Canvas, start, end image.Point, opts ...draw.BrailleLineOption) { + if err := draw.BrailleLine(bc, start, end, opts...); err != nil { + panic(fmt.Sprintf("draw.BrailleLine => unexpected error: %v", err)) + } +} diff --git a/widgets/linechart/axes/axes.go b/widgets/linechart/axes/axes.go index 07de7e6..4ccf2b0 100644 --- a/widgets/linechart/axes/axes.go +++ b/widgets/linechart/axes/axes.go @@ -36,6 +36,12 @@ type YDetails struct { // Width in character cells of the Y axis and its character labels. Width int + // Start is the point where the Y axis starts. + // Both coordinates of Start are less than End. + Start image.Point + // End is the point where the Y axis ends. + End image.Point + // Scale is the scale of the Y axis. Scale *YScale @@ -81,15 +87,17 @@ func (y *Y) RequiredWidth() int { } // Details retrieves details about the Y axis required to draw it on a canvas -// of the provided height. The cvsHeight should be the height of the area with -// the line chart. The maxWidth indicates the maximum width available -// for the Y axis and its labels. This is guaranteed to be at least what -// RequiredWidth returned. -func (y *Y) Details(cvsHeight int, maxWidth int) (*YDetails, error) { +// of the provided area. +func (y *Y) Details(cvsAr image.Rectangle) (*YDetails, error) { + cvsWidth := cvsAr.Dx() + cvsHeight := cvsAr.Dy() + maxWidth := cvsWidth - 1 // Reserve one row 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) } - scale, err := NewYScale(y.min.Value, y.max.Value, cvsHeight, nonZeroDecimals) + + graphHeight := cvsHeight - 2 // One row for the X axis and one for its labels. + scale, err := NewYScale(y.min.Value, y.max.Value, graphHeight, nonZeroDecimals) if err != nil { return nil, err } @@ -119,6 +127,8 @@ func (y *Y) Details(cvsHeight int, maxWidth int) (*YDetails, error) { return &YDetails{ Width: width, + Start: image.Point{width - 1, 0}, + End: image.Point{width - 1, graphHeight}, Scale: scale, Labels: labels, }, nil @@ -138,6 +148,12 @@ func widestLabel(labels []*Label) int { // XDetails contain information about the X axis that will be drawn onto the // canvas. type XDetails struct { + // Start is the point where the X axis starts. + // Both coordinates of Start are less than End. + Start image.Point + // End is the point where the X axis ends. + End image.Point + // Scale is the scale of the X axis. Scale *XScale @@ -146,20 +162,31 @@ type XDetails struct { } // NewXDetails retrieves details about the X axis required to draw it on a canvas -// of the provided height. The axisStart is the zero point of the X axis on the -// canvas and the axisWidth is its width in cells. The numPoints is the number -// of points in the largest series that will be plotted. -func NewXDetails(numPoints int, axisStart image.Point, axisWidth int) (*XDetails, error) { - scale, err := NewXScale(numPoints, axisWidth, nonZeroDecimals) +// of the provided area. The yStart is the point where the Y axis starts. +// The numPoints is the number of points in the largest series that will be +// plotted. +func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle) (*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) + } + + // The space between the start of the axis and the end of the canvas. + graphWidth := cvsAr.Dx() - yStart.X - 1 + scale, err := NewXScale(numPoints, graphWidth, nonZeroDecimals) if err != nil { return nil, err } - labels, err := xLabels(scale, axisStart) + // 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) 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}, Scale: scale, Labels: labels, }, nil diff --git a/widgets/linechart/axes/axes_test.go b/widgets/linechart/axes/axes_test.go index 1ab3686..0b75c4e 100644 --- a/widgets/linechart/axes/axes_test.go +++ b/widgets/linechart/axes/axes_test.go @@ -32,8 +32,7 @@ func TestY(t *testing.T) { minVal float64 maxVal float64 update *updateY - cvsHeight int - maxWidth int + cvsAr image.Rectangle wantWidth int want *YDetails wantErr bool @@ -42,17 +41,15 @@ func TestY(t *testing.T) { desc: "fails on canvas too small", minVal: 0, maxVal: 3, - cvsHeight: 1, - maxWidth: 2, + cvsAr: image.Rect(0, 0, 3, 2), wantWidth: 2, wantErr: true, }, { - desc: "fails on maxWidth less than required width", + desc: "fails on cvsWidth less than required width", minVal: 0, maxVal: 3, - cvsHeight: 2, - maxWidth: 1, + cvsAr: image.Rect(0, 0, 2, 4), wantWidth: 2, wantErr: true, }, @@ -60,20 +57,20 @@ func TestY(t *testing.T) { desc: "fails when max is less than min", minVal: 0, maxVal: -1, - cvsHeight: 2, - maxWidth: 3, + cvsAr: image.Rect(0, 0, 4, 4), wantWidth: 3, wantErr: true, }, { - desc: "maxWidth equals required width", + desc: "cvsWidth equals required width", minVal: 0, maxVal: 3, - cvsHeight: 2, + cvsAr: image.Rect(0, 0, 3, 4), wantWidth: 2, - maxWidth: 2, want: &YDetails{ Width: 2, + Start: image.Point{1, 0}, + End: image.Point{1, 2}, Scale: mustNewYScale(0, 3, 2, nonZeroDecimals), Labels: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{0, 1}}, @@ -82,14 +79,15 @@ func TestY(t *testing.T) { }, }, { - desc: "maxWidth just accommodates the longest label", + desc: "cvsWidth just accommodates the longest label", minVal: 0, maxVal: 3, - cvsHeight: 2, + cvsAr: image.Rect(0, 0, 6, 4), wantWidth: 2, - maxWidth: 5, want: &YDetails{ Width: 5, + Start: image.Point{4, 0}, + End: image.Point{4, 2}, Scale: mustNewYScale(0, 3, 2, nonZeroDecimals), Labels: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{3, 1}}, @@ -98,14 +96,15 @@ func TestY(t *testing.T) { }, }, { - desc: "maxWidth is more than we need", + desc: "cvsWidth is more than we need", minVal: 0, maxVal: 3, - cvsHeight: 2, + cvsAr: image.Rect(0, 0, 7, 4), wantWidth: 2, - maxWidth: 6, want: &YDetails{ Width: 5, + Start: image.Point{4, 0}, + End: image.Point{4, 2}, Scale: mustNewYScale(0, 3, 2, nonZeroDecimals), Labels: []*Label{ {NewValue(0, nonZeroDecimals), image.Point{3, 1}}, @@ -127,7 +126,7 @@ func TestY(t *testing.T) { t.Errorf("RequiredWidth => got %v, want %v", gotWidth, tc.wantWidth) } - got, err := y.Details(tc.cvsHeight, tc.maxWidth) + got, err := y.Details(tc.cvsAr) if (err != nil) != tc.wantErr { t.Errorf("Details => unexpected error: %v, wantErr: %v", err, tc.wantErr) } @@ -145,51 +144,63 @@ func TestNewXDetails(t *testing.T) { tests := []struct { desc string numPoints int - axisStart image.Point - axisWidth int + yStart image.Point + cvsWidth int + cvsAr image.Rectangle want *XDetails wantErr bool }{ { desc: "fails when numPoints is negative", numPoints: -1, - axisStart: image.Point{0, 1}, - axisWidth: 1, + yStart: image.Point{0, 0}, + cvsAr: image.Rect(0, 0, 2, 3), wantErr: true, }, { - desc: "fails when axisWidth is too small", + desc: "fails when cvsAr isn't wide enough", numPoints: 1, - axisStart: image.Point{0, 1}, - axisWidth: 0, + yStart: image.Point{0, 0}, + cvsAr: image.Rect(0, 0, 1, 3), + wantErr: true, + }, + { + desc: "fails when cvsAr isn't tall enough", + numPoints: 1, + yStart: image.Point{0, 0}, + cvsAr: image.Rect(0, 0, 3, 2), wantErr: true, }, { desc: "works with no data points", numPoints: 0, - axisStart: image.Point{0, 1}, - axisWidth: 1, + yStart: image.Point{0, 0}, + cvsAr: image.Rect(0, 0, 2, 3), 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{0, 2}, + Pos: image.Point{1, 2}, }, }, }, }, { - desc: "axis doesn't start at point zero", + desc: "accounts for non-zero yStart", numPoints: 0, - axisStart: image.Point{11, 2}, - axisWidth: 1, + yStart: image.Point{2, 0}, + cvsAr: image.Rect(0, 0, 4, 5), want: &XDetails{ + Start: image.Point{2, 3}, + End: image.Point{3, 3}, Scale: mustNewXScale(0, 1, nonZeroDecimals), Labels: []*Label{ { Value: NewValue(0, nonZeroDecimals), - Pos: image.Point{11, 3}, + Pos: image.Point{3, 4}, }, }, }, @@ -198,7 +209,7 @@ func TestNewXDetails(t *testing.T) { for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { - got, err := NewXDetails(tc.numPoints, tc.axisStart, tc.axisWidth) + got, err := NewXDetails(tc.numPoints, tc.yStart, tc.cvsAr) if (err != nil) != tc.wantErr { t.Errorf("NewXDetails => unexpected error: %v, wantErr: %v", err, tc.wantErr) } diff --git a/widgets/linechart/axes/label.go b/widgets/linechart/axes/label.go index f9c3003..2692baa 100644 --- a/widgets/linechart/axes/label.go +++ b/widgets/linechart/axes/label.go @@ -106,22 +106,22 @@ type xSpace struct { // The xSpace instance contains points 0 <= x < max max int - // axisStart is the actual position of the X axis zero point on the canvas. - axisStart image.Point + // graphZero is the (0, 0) point on the graph. + graphZero image.Point } // newXSpace returns a new xSpace instance initialized for the provided width. -func newXSpace(axisStart image.Point, graphWidth int) *xSpace { +func newXSpace(graphZero image.Point, graphWidth int) *xSpace { return &xSpace{ cur: 0, max: graphWidth, - axisStart: axisStart, + graphZero: graphZero, } } // Implements fmt.Stringer. func (xs *xSpace) String() string { - return fmt.Sprintf("xSpace(size:%d)-cur:%v-max:%v", xs.Remaining(), image.Point{xs.cur, xs.axisStart.Y}, image.Point{xs.max, xs.axisStart.Y}) + return fmt.Sprintf("xSpace(size:%d)-cur:%v-max:%v", xs.Remaining(), image.Point{xs.cur, xs.graphZero.Y}, image.Point{xs.max, xs.graphZero.Y}) } // Remaining returns the remaining size on the X axis. @@ -132,14 +132,14 @@ func (xs *xSpace) Remaining() int { // Relative returns the relative coordinate within the space, these are zero // based. func (xs *xSpace) Relative() image.Point { - return image.Point{xs.cur, xs.axisStart.Y + 1} + return image.Point{xs.cur, xs.graphZero.Y + 1} } -// Absolute returns the absolute coordinate on the canvas where a label should +// LabelPos returns the absolute coordinate on the canvas where a label should // be placed. The is the coordinate that represents the current relative // coordinate of the space. -func (xs *xSpace) Absolute() image.Point { - return image.Point{xs.cur + xs.axisStart.X, xs.axisStart.Y + 1} +func (xs *xSpace) LabelPos() image.Point { + return image.Point{xs.cur + xs.graphZero.X, xs.graphZero.Y + 2} // First down is the axis, second the label. } // Sub subtracts the specified size from the beginning of the available @@ -153,13 +153,12 @@ func (xs *xSpace) Sub(size int) error { } // xLabels returns labels that should be placed under the X axis. -// The axisStart is the point where the X axis starts (its zero value) and is -// used to determine label placement. +// The graphZero is the (0, 0) point of the graph area on the canvas. // Labels are returned in an increasing value order. // Returned labels shouldn't be trimmed, their count is adjusted so that they // fit under the width of the axis. -func xLabels(scale *XScale, axisStart image.Point) ([]*Label, error) { - space := newXSpace(axisStart, scale.GraphWidth) +func xLabels(scale *XScale, graphZero image.Point) ([]*Label, error) { + space := newXSpace(graphZero, scale.GraphWidth) const minSpacing = 3 var res []*Label @@ -213,7 +212,7 @@ func colLabel(scale *XScale, space *xSpace) (*Label, error) { return nil, nil } - abs := space.Absolute() + abs := space.LabelPos() if err := space.Sub(labelLen); err != nil { return nil, err } diff --git a/widgets/linechart/axes/label_test.go b/widgets/linechart/axes/label_test.go index e7a38ac..d5aa62f 100644 --- a/widgets/linechart/axes/label_test.go +++ b/widgets/linechart/axes/label_test.go @@ -154,7 +154,7 @@ func TestXLabels(t *testing.T) { desc string numPoints int graphWidth int - axisStart image.Point + graphZero image.Point want []*Label wantErr bool }{ @@ -162,98 +162,98 @@ func TestXLabels(t *testing.T) { desc: "only one point", numPoints: 1, graphWidth: 1, - axisStart: image.Point{0, 1}, + graphZero: image.Point{0, 1}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 2}}, + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, }, }, { desc: "two points, only one label fits", numPoints: 2, graphWidth: 1, - axisStart: image.Point{0, 1}, + graphZero: image.Point{0, 1}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 2}}, + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, }, }, { desc: "two points, two labels fit exactly", numPoints: 2, graphWidth: 5, - axisStart: image.Point{0, 1}, + graphZero: image.Point{0, 1}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 2}}, - {NewValue(1, nonZeroDecimals), image.Point{4, 2}}, + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(1, nonZeroDecimals), image.Point{4, 3}}, }, }, { - desc: "labels are placed according to axisStart", + desc: "labels are placed according to graphZero", numPoints: 2, graphWidth: 5, - axisStart: image.Point{3, 5}, + graphZero: image.Point{3, 5}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{3, 6}}, - {NewValue(1, nonZeroDecimals), image.Point{7, 6}}, + {NewValue(0, nonZeroDecimals), image.Point{3, 7}}, + {NewValue(1, nonZeroDecimals), image.Point{7, 7}}, }, }, { desc: "skip to next value exhausts the space completely", numPoints: 11, graphWidth: 4, - axisStart: image.Point{0, 1}, + graphZero: image.Point{0, 1}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 2}}, + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, }, }, { desc: "second label doesn't fit due to its length", numPoints: 100, graphWidth: 5, - axisStart: image.Point{0, 1}, + graphZero: image.Point{0, 1}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 2}}, + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, }, }, { desc: "two points, two labels, more space than minSpacing so end label adjusted", numPoints: 2, graphWidth: 6, - axisStart: image.Point{0, 1}, + graphZero: image.Point{0, 1}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 2}}, - {NewValue(1, nonZeroDecimals), image.Point{5, 2}}, + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(1, nonZeroDecimals), image.Point{5, 3}}, }, }, { desc: "at most as many labels as there are points", numPoints: 2, graphWidth: 100, - axisStart: image.Point{0, 1}, + graphZero: image.Point{0, 1}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 2}}, - {NewValue(1, nonZeroDecimals), image.Point{98, 2}}, + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(1, nonZeroDecimals), image.Point{98, 3}}, }, }, { desc: "some labels in the middle", numPoints: 4, graphWidth: 100, - axisStart: image.Point{0, 1}, + graphZero: image.Point{0, 1}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 2}}, - {NewValue(1, nonZeroDecimals), image.Point{31, 2}}, - {NewValue(2, nonZeroDecimals), image.Point{62, 2}}, - {NewValue(3, nonZeroDecimals), image.Point{94, 2}}, + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(1, nonZeroDecimals), image.Point{31, 3}}, + {NewValue(2, nonZeroDecimals), image.Point{62, 3}}, + {NewValue(3, nonZeroDecimals), image.Point{94, 3}}, }, }, { desc: "more points than pixels", numPoints: 100, graphWidth: 6, - axisStart: image.Point{0, 1}, + graphZero: image.Point{0, 1}, want: []*Label{ - {NewValue(0, nonZeroDecimals), image.Point{0, 2}}, - {NewValue(72, nonZeroDecimals), image.Point{4, 2}}, + {NewValue(0, nonZeroDecimals), image.Point{0, 3}}, + {NewValue(72, nonZeroDecimals), image.Point{4, 3}}, }, }, } @@ -265,7 +265,7 @@ func TestXLabels(t *testing.T) { t.Fatalf("NewXScale => unexpected error: %v", err) } t.Logf("scale step: %v", scale.Step.Rounded) - got, err := xLabels(scale, tc.axisStart) + got, err := xLabels(scale, tc.graphZero) if (err != nil) != tc.wantErr { t.Errorf("xLabels => unexpected error: %v, wantErr: %v", err, tc.wantErr) } diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go index 3639960..c7c670a 100644 --- a/widgets/linechart/linechart.go +++ b/widgets/linechart/linechart.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "image" + "log" "sync" "github.com/mum4k/termdash/canvas" @@ -73,9 +74,6 @@ type LineChart struct { // yAxis is the Y axis of the line chart. yAxis *axes.Y - // reqWidth is the last reported required with on a call to Options. - reqWidth int - // opts are the provided options. opts *options } @@ -113,27 +111,27 @@ func (lc *LineChart) Draw(cvs *canvas.Canvas) error { lc.mu.Lock() defer lc.mu.Unlock() - // The Y axis and its labels cannot take the entire canvas width, reserve one column for the line chart. - maxWidth := cvs.Area().Dx() - 1 - yd, err := lc.yAxis.Details(cvs.Area().Dy()-2, maxWidth) + yd, err := lc.yAxis.Details(cvs.Area()) if err != nil { return fmt.Errorf("lc.yAxis.Details => %v", err) } - yStart := image.Point{yd.Width - 1, 0} - xStart := image.Point{yStart.X, cvs.Area().Max.Y - 2} // One for X labels. - yEnd := image.Point{yStart.X, xStart.Y} - - xWidth := cvs.Area().Dx() - yStart.X - 1 - xEnd := image.Point{xStart.X + xWidth - 1, xStart.Y} - xd, err := axes.NewXDetails(lc.maxPoints(), xStart, xWidth) + xd, err := axes.NewXDetails(lc.maxPoints(), yd.Start, cvs.Area()) if err != nil { return fmt.Errorf("NewXDetails => %v", err) } + if err := lc.drawAxes(cvs, xd, yd); err != nil { + return err + } + return lc.drawSeries(cvs, xd, yd) +} + +// drawAxes draws the X,Y axes and their labels. +func (lc *LineChart) drawAxes(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error { lines := []draw.HVLine{ - {Start: yStart, End: yEnd}, - {Start: xStart, End: xEnd}, + {Start: yd.Start, End: yd.End}, + {Start: xd.Start, End: xd.End}, } if err := draw.HVLines(cvs, lines); err != nil { return fmt.Errorf("failed to draw the axes: %v", err) @@ -141,7 +139,7 @@ func (lc *LineChart) Draw(cvs *canvas.Canvas) error { for _, l := range yd.Labels { if err := draw.Text(cvs, l.Value.Text(), l.Pos, - draw.TextMaxX(yStart.X), + draw.TextMaxX(yd.Start.X), draw.TextOverrunMode(draw.OverrunModeThreeDot), ); err != nil { return fmt.Errorf("failed to draw the Y labels: %v", err) @@ -153,9 +151,15 @@ func (lc *LineChart) Draw(cvs *canvas.Canvas) error { return fmt.Errorf("failed to draw the X labels: %v", err) } } + return nil +} - ba := image.Rect(yStart.X+1, yStart.Y, cvs.Area().Max.X, xEnd.Y) - bc, err := braille.New(ba) +// drawSeries draws the graph representing the stored series. +func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error { + // The area available to the graph. + graphAr := image.Rect(yd.Start.X+1, yd.Start.Y, cvs.Area().Max.X, xd.End.Y) + log.Printf("graphAr:%v", graphAr) + bc, err := braille.New(graphAr) if err != nil { return fmt.Errorf("braille.New => %v", err) } @@ -185,6 +189,9 @@ func (lc *LineChart) Draw(cvs *canvas.Canvas) error { return fmt.Errorf("failure for series %v[%d], yd.Scale.ValueToPixel => %v", name, i, err) } + start := image.Point{startX, startY} + end := image.Point{endX, endY} + log.Printf("start:%v, end:%v", start, end) if err := draw.BrailleLine(bc, image.Point{startX, startY}, image.Point{endX, endY}); err != nil { return fmt.Errorf("draw.BrailleLine => %v", err) } @@ -213,13 +220,13 @@ func (lc *LineChart) Options() widgetapi.Options { defer lc.mu.Unlock() // At the very least we need: - // - n columns for the Y axis and its values as reported by it. - // - 2 rows for the X axis and its values. - lc.reqWidth = lc.yAxis.RequiredWidth() + 1 - const reqHeight = 3 + // - 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 return widgetapi.Options{ - // - 1 row and column for the line chart. - MinimumSize: image.Point{lc.reqWidth, reqHeight}, + MinimumSize: image.Point{reqWidth, reqHeight}, } } diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go index 7488308..5d1f155 100644 --- a/widgets/linechart/linechart_test.go +++ b/widgets/linechart/linechart_test.go @@ -20,12 +20,15 @@ import ( "github.com/kylelemons/godebug/pretty" "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/canvas/braille/testbraille" + "github.com/mum4k/termdash/canvas/testcanvas" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/draw/testdraw" "github.com/mum4k/termdash/terminal/faketerm" "github.com/mum4k/termdash/widgetapi" ) func TestLineChartDraws(t *testing.T) { - t.Skip() // Unimplemented. tests := []struct { desc string canvas image.Rectangle @@ -33,11 +36,194 @@ func TestLineChartDraws(t *testing.T) { writes func(*LineChart) error want func(size image.Point) *faketerm.Terminal wantWriteErr bool + wantErr bool }{ { - desc: "empty without series", - canvas: image.Rect(0, 0, 1, 1), + desc: "write fails without name for the series", + canvas: image.Rect(0, 0, 3, 4), + writes: func(lc *LineChart) error { + return lc.Series("", nil) + }, + wantWriteErr: true, }, + { + desc: "draw fails when canvas not wide enough", + canvas: image.Rect(0, 0, 2, 4), + wantErr: true, + }, + { + desc: "draw fails when canvas not tall enough", + canvas: image.Rect(0, 0, 3, 3), + wantErr: true, + }, + { + desc: "empty without series", + canvas: image.Rect(0, 0, 3, 4), + 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{1, 0}, End: image.Point{1, 2}}, + {Start: image.Point{1, 2}, End: image.Point{2, 2}}, + } + testdraw.MustHVLines(c, lines) + + // Zero value labels. + testdraw.MustText(c, "0", image.Point{0, 1}) + testdraw.MustText(c, "0", image.Point{2, 3}) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "two Y and X labels", + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}) + }, + 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, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{19, 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: "multiple Y and X labels", + canvas: image.Rect(0, 0, 20, 11), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 50, 100}) + }, + 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, 9}}, + {Start: image.Point{5, 9}, End: image.Point{19, 9}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{4, 8}) + testdraw.MustText(c, "45.76", image.Point{0, 4}) + testdraw.MustText(c, "91.52", image.Point{0, 0}) + testdraw.MustText(c, "0", image.Point{6, 10}) + testdraw.MustText(c, "1", image.Point{12, 10}) + testdraw.MustText(c, "2", image.Point{19, 10}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 9) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 35}, image.Point{13, 18}) + testdraw.MustBrailleLine(bc, image.Point{13, 18}, image.Point{27, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "Y labels are trimmed", + canvas: image.Rect(0, 0, 5, 4), + writes: func(lc *LineChart) error { + return lc.Series("first", []float64{0, 100}) + }, + 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{3, 0}, End: image.Point{3, 2}}, + {Start: image.Point{3, 2}, End: image.Point{4, 2}}, + } + testdraw.MustHVLines(c, lines) + + // Value labels. + testdraw.MustText(c, "0", image.Point{2, 1}) + testdraw.MustText(c, "57…", image.Point{0, 0}) + testdraw.MustText(c, "0", image.Point{4, 3}) + + // Braille line. + graphAr := image.Rect(4, 0, 5, 2) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 7}, image.Point{1, 0}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + { + desc: "draw multiple series", + canvas: image.Rect(0, 0, 20, 10), + writes: func(lc *LineChart) error { + if err := lc.Series("first", []float64{0, 50, 100}); err != nil { + return err + } + return lc.Series("second", []float64{100, 0}) + }, + 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, "0", image.Point{6, 9}) + testdraw.MustText(c, "1", image.Point{12, 9}) + testdraw.MustText(c, "2", image.Point{19, 9}) + + // Braille line. + graphAr := image.Rect(6, 0, 20, 8) + bc := testbraille.MustNew(graphAr) + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{27, 0}) + testdraw.MustBrailleLine(bc, image.Point{0, 0}, image.Point{13, 31}) + testbraille.MustCopyTo(bc, c) + + testcanvas.MustApply(c, ft) + return ft + }, + }, + + // Sets axis colors. + // Sets label colors on Y axis. + // Sets label colors on X axis. + // Sets series color. + // Multiple series, same color. + // Multiple series, different color. } for _, tc := range tests { @@ -58,8 +244,14 @@ func TestLineChartDraws(t *testing.T) { } } - if err := widget.Draw(c); err != nil { - t.Fatalf("Draw => unexpected error: %v", err) + { + err := widget.Draw(c) + if (err != nil) != tc.wantErr { + t.Fatalf("Draw => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } } got, err := faketerm.New(c.Size()) @@ -92,7 +284,7 @@ func TestOptions(t *testing.T) { { desc: "reserves space for axis without series", want: widgetapi.Options{ - MinimumSize: image.Point{3, 3}, + MinimumSize: image.Point{3, 4}, }, }, { @@ -101,7 +293,7 @@ func TestOptions(t *testing.T) { return lc.Series("series", []float64{0, 100}) }, want: widgetapi.Options{ - MinimumSize: image.Point{5, 3}, + MinimumSize: image.Point{5, 4}, }, }, { @@ -110,7 +302,7 @@ func TestOptions(t *testing.T) { return lc.Series("series", []float64{-100, 100}) }, want: widgetapi.Options{ - MinimumSize: image.Point{6, 3}, + MinimumSize: image.Point{6, 4}, }, }, }