mirror of
https://github.com/mum4k/termdash.git
synced 2025-04-27 13:48:49 +08:00
Merge pull request #133 from mum4k/linechart-zoom
The LineChart widget now supports zooming the content
This commit is contained in:
commit
2ab7083a53
@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
"roll" through the linechart.
|
||||
- The LineChart widget now has a method that returns the observed capacity of
|
||||
the LineChart the last time Draw was called.
|
||||
- The LineChart widget now supports zoom of the content triggered by mouse
|
||||
events.
|
||||
- The Text widget now has a Write option that atomically replaces the entire
|
||||
text content.
|
||||
|
||||
|
@ -119,7 +119,8 @@ go run github.com/mum4k/termdash/widgets/barchart/barchartdemo/barchartdemo.go
|
||||
|
||||
### The LineChart
|
||||
|
||||
Displays series of values on a line chart. Run the
|
||||
Displays series of values on a line chart, supports zoom triggered by mouse
|
||||
events. Run the
|
||||
[linechartdemo](widgets/linechart/linechartdemo/linechartdemo.go).
|
||||
|
||||
```go
|
||||
|
33
area/area.go
33
area/area.go
@ -96,42 +96,11 @@ func ExcludeBorder(area image.Rectangle) image.Rectangle {
|
||||
)
|
||||
}
|
||||
|
||||
// findGCF finds the greatest common factor of two integers.
|
||||
func findGCF(a, b int) int {
|
||||
if a == 0 || b == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/Euclidean_algorithm
|
||||
for {
|
||||
rem := a % b
|
||||
a = b
|
||||
b = rem
|
||||
|
||||
if b == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// simplifyRatio simplifies the given ratio.
|
||||
func simplifyRatio(ratio image.Point) image.Point {
|
||||
gcf := findGCF(ratio.X, ratio.Y)
|
||||
if gcf == 0 {
|
||||
return image.ZP
|
||||
}
|
||||
return image.Point{
|
||||
X: ratio.X / gcf,
|
||||
Y: ratio.Y / gcf,
|
||||
}
|
||||
}
|
||||
|
||||
// WithRatio returns the largest area that has the requested ratio but is
|
||||
// either equal or smaller than the provided area. Returns zero area if the
|
||||
// area or the ratio are zero, or if there is no such area.
|
||||
func WithRatio(area image.Rectangle, ratio image.Point) image.Rectangle {
|
||||
ratio = simplifyRatio(ratio)
|
||||
ratio = numbers.SimplifyRatio(ratio)
|
||||
if area == image.ZR || ratio == image.ZP {
|
||||
return image.ZR
|
||||
}
|
||||
|
@ -15,7 +15,6 @@
|
||||
package area
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"testing"
|
||||
|
||||
@ -321,30 +320,6 @@ func TestExcludeBorder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGCF(t *testing.T) {
|
||||
tests := []struct {
|
||||
a int
|
||||
b int
|
||||
want int
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{0, 1, 0},
|
||||
{1, 0, 0},
|
||||
{1, 1, 1},
|
||||
{2, 2, 2},
|
||||
{50, 35, 5},
|
||||
{16, 88, 8},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(fmt.Sprintf("findGCF(%d,%d)", tc.a, tc.b), func(t *testing.T) {
|
||||
if got := findGCF(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("findGCF(%d,%d) => got %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRatio(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
|
@ -110,6 +110,11 @@ func (c *Canvas) Size() image.Point {
|
||||
return image.Point{s.X * ColMult, s.Y * RowMult}
|
||||
}
|
||||
|
||||
// CellArea returns the area of the underlying cell canvas in cells.
|
||||
func (c *Canvas) CellArea() image.Rectangle {
|
||||
return c.regular.Area()
|
||||
}
|
||||
|
||||
// Area returns the area of the braille canvas in pixels.
|
||||
// This will be zero-based area that is two times wider and four times taller
|
||||
// than the area used to create the braille canvas.
|
||||
@ -186,17 +191,57 @@ func (c *Canvas) TogglePixel(p image.Point, opts ...cell.Option) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cell, err := c.regular.Cell(cp)
|
||||
curCell, err := c.regular.Cell(cp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isBraille(cell.Rune) && pixelSet(cell.Rune, p) {
|
||||
if isBraille(curCell.Rune) && pixelSet(curCell.Rune, p) {
|
||||
return c.ClearPixel(p, opts...)
|
||||
}
|
||||
return c.SetPixel(p, opts...)
|
||||
}
|
||||
|
||||
// SetCellOpts sets options on the specified cell of the braille canvas without
|
||||
// modifying the content of the cell.
|
||||
// Sets the default cell options if no options are provided.
|
||||
// This method is idempotent.
|
||||
func (c *Canvas) SetCellOpts(cellPoint image.Point, opts ...cell.Option) error {
|
||||
curCell, err := c.regular.Cell(cellPoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(opts) == 0 {
|
||||
// Set the default options.
|
||||
opts = []cell.Option{
|
||||
cell.FgColor(cell.ColorDefault),
|
||||
cell.BgColor(cell.ColorDefault),
|
||||
}
|
||||
}
|
||||
if _, err := c.regular.SetCell(cellPoint, curCell.Rune, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAreaCellOpts is like SetCellOpts, but sets the specified options on all
|
||||
// the cells within the provided area.
|
||||
func (c *Canvas) SetAreaCellOpts(cellArea image.Rectangle, opts ...cell.Option) error {
|
||||
haveArea := c.regular.Area()
|
||||
if !cellArea.In(haveArea) {
|
||||
return fmt.Errorf("unable to set cell options in area %v, it must fit inside the available cell area is %v", cellArea, haveArea)
|
||||
}
|
||||
for col := cellArea.Min.X; col < cellArea.Max.X; col++ {
|
||||
for row := cellArea.Min.Y; row < cellArea.Max.Y; row++ {
|
||||
if err := c.SetCellOpts(image.Point{col, row}, opts...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply applies the canvas to the corresponding area of the terminal.
|
||||
// Guarantees to stay within limits of the area the canvas was created with.
|
||||
func (c *Canvas) Apply(t terminalapi.Terminal) error {
|
||||
|
@ -74,11 +74,12 @@ func Example_appliedToTerminal() {
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
ar image.Rectangle
|
||||
wantSize image.Point
|
||||
wantArea image.Rectangle
|
||||
wantErr bool
|
||||
desc string
|
||||
ar image.Rectangle
|
||||
wantSize image.Point
|
||||
wantArea image.Rectangle
|
||||
wantCellArea image.Rectangle
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "fails on a negative area",
|
||||
@ -86,34 +87,39 @@ func TestNew(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "braille from zero-based single-cell area",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
wantSize: image.Point{2, 4},
|
||||
wantArea: image.Rect(0, 0, 2, 4),
|
||||
desc: "braille from zero-based single-cell area",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
wantSize: image.Point{2, 4},
|
||||
wantArea: image.Rect(0, 0, 2, 4),
|
||||
wantCellArea: image.Rect(0, 0, 1, 1),
|
||||
},
|
||||
{
|
||||
desc: "braille from non-zero-based single-cell area",
|
||||
ar: image.Rect(3, 3, 4, 4),
|
||||
wantSize: image.Point{2, 4},
|
||||
wantArea: image.Rect(0, 0, 2, 4),
|
||||
desc: "braille from non-zero-based single-cell area",
|
||||
ar: image.Rect(3, 3, 4, 4),
|
||||
wantSize: image.Point{2, 4},
|
||||
wantArea: image.Rect(0, 0, 2, 4),
|
||||
wantCellArea: image.Rect(0, 0, 1, 1),
|
||||
},
|
||||
{
|
||||
desc: "braille from zero-based multi-cell area",
|
||||
ar: image.Rect(0, 0, 3, 3),
|
||||
wantSize: image.Point{6, 12},
|
||||
wantArea: image.Rect(0, 0, 6, 12),
|
||||
desc: "braille from zero-based multi-cell area",
|
||||
ar: image.Rect(0, 0, 3, 3),
|
||||
wantSize: image.Point{6, 12},
|
||||
wantArea: image.Rect(0, 0, 6, 12),
|
||||
wantCellArea: image.Rect(0, 0, 3, 3),
|
||||
},
|
||||
{
|
||||
desc: "braille from non-zero-based multi-cell area",
|
||||
ar: image.Rect(6, 6, 9, 9),
|
||||
wantSize: image.Point{6, 12},
|
||||
wantArea: image.Rect(0, 0, 6, 12),
|
||||
desc: "braille from non-zero-based multi-cell area",
|
||||
ar: image.Rect(6, 6, 9, 9),
|
||||
wantSize: image.Point{6, 12},
|
||||
wantArea: image.Rect(0, 0, 6, 12),
|
||||
wantCellArea: image.Rect(0, 0, 3, 3),
|
||||
},
|
||||
{
|
||||
desc: "braille from non-zero-based multi-cell rectangular area",
|
||||
ar: image.Rect(6, 6, 9, 10),
|
||||
wantSize: image.Point{6, 16},
|
||||
wantArea: image.Rect(0, 0, 6, 16),
|
||||
desc: "braille from non-zero-based multi-cell rectangular area",
|
||||
ar: image.Rect(6, 6, 9, 10),
|
||||
wantSize: image.Point{6, 16},
|
||||
wantArea: image.Rect(0, 0, 6, 16),
|
||||
wantCellArea: image.Rect(0, 0, 3, 4),
|
||||
},
|
||||
}
|
||||
|
||||
@ -136,6 +142,11 @@ func TestNew(t *testing.T) {
|
||||
if diff := pretty.Compare(tc.wantArea, gotArea); diff != "" {
|
||||
t.Errorf("Area => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
|
||||
gotCellArea := got.CellArea()
|
||||
if diff := pretty.Compare(tc.wantCellArea, gotCellArea); diff != "" {
|
||||
t.Errorf("CellArea => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -170,6 +181,162 @@ func TestBraille(t *testing.T) {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions fails on a cell outside of the braille canvas",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetCellOpts(image.Point{0, -1})
|
||||
},
|
||||
wantErr: true,
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
return faketerm.MustNew(size)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions sets options on cell with no options",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
c := testcanvas.MustCell(cvs, image.Point{0, 0})
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions preserves the cell rune",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
if err := c.SetPixel(image.Point{0, 0}); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⠁', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions overwrites options set previously",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
if err := c.SetPixel(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⠁', cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow))
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions sets default options when no options provided",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
if err := c.SetPixel(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetCellOpts(image.Point{0, 0})
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, '⠁')
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions is idempotent",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
if err := c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.SetCellOpts(image.Point{0, 0}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
c := testcanvas.MustCell(cvs, image.Point{0, 0})
|
||||
testcanvas.MustSetCell(cvs, image.Point{0, 0}, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetAreaCellOptions fails on area too large",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions sets the cell options in full area",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetAreaCellOpts(image.Rect(0, 0, 1, 1), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
for _, p := range []image.Point{
|
||||
{0, 0},
|
||||
} {
|
||||
c := testcanvas.MustCell(cvs, p)
|
||||
testcanvas.MustSetCell(cvs, p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
}
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "SetCellOptions sets the cell options in a sub-area",
|
||||
ar: image.Rect(0, 0, 3, 3),
|
||||
pixelOps: func(c *Canvas) error {
|
||||
return c.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
},
|
||||
want: func(size image.Point) *faketerm.Terminal {
|
||||
ft := faketerm.MustNew(size)
|
||||
cvs := testcanvas.MustNew(ft.Area())
|
||||
|
||||
for _, p := range []image.Point{
|
||||
{0, 0},
|
||||
{0, 1},
|
||||
{1, 0},
|
||||
{1, 1},
|
||||
} {
|
||||
c := testcanvas.MustCell(cvs, p)
|
||||
testcanvas.MustSetCell(cvs, p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue))
|
||||
}
|
||||
testcanvas.MustApply(cvs, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "set pixel 0,0",
|
||||
ar: image.Rect(0, 0, 1, 1),
|
||||
|
@ -61,3 +61,17 @@ func MustCopyTo(bc *braille.Canvas, dst *canvas.Canvas) {
|
||||
panic(fmt.Sprintf("bc.CopyTo => unexpected error: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// MustSetCellOpts sets the cell options or panics.
|
||||
func MustSetCellOpts(bc *braille.Canvas, cellPoint image.Point, opts ...cell.Option) {
|
||||
if err := bc.SetCellOpts(cellPoint, opts...); err != nil {
|
||||
panic(fmt.Sprintf("bc.SetCellOpts => unexpected error: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// MustSetAreaCellOpts sets the cell options in the area or panics.
|
||||
func MustSetAreaCellOpts(bc *braille.Canvas, cellArea image.Rectangle, opts ...cell.Option) {
|
||||
if err := bc.SetAreaCellOpts(cellArea, opts...); err != nil {
|
||||
panic(fmt.Sprintf("bc.SetAreaCellOpts => unexpected error: %v", err))
|
||||
}
|
||||
}
|
||||
|
@ -51,6 +51,15 @@ func MustSetCell(c *canvas.Canvas, p image.Point, r rune, opts ...cell.Option) i
|
||||
return cells
|
||||
}
|
||||
|
||||
// MustCell returns the cell or panics.
|
||||
func MustCell(c *canvas.Canvas, p image.Point) *cell.Cell {
|
||||
cell, err := c.Cell(p)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("canvas.Cell => unexpected error: %v", err))
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
// MustCopyTo copies the content of the source canvas onto the destination
|
||||
// canvas or panics.
|
||||
func MustCopyTo(src, dst *canvas.Canvas) {
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 2.4 MiB |
@ -16,6 +16,7 @@
|
||||
package numbers
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
)
|
||||
|
||||
@ -170,3 +171,51 @@ func Abs(x int) int {
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// findGCF finds the greatest common factor of two integers.
|
||||
func findGCF(a, b int) int {
|
||||
if a == 0 || b == 0 {
|
||||
return 0
|
||||
}
|
||||
a = Abs(a)
|
||||
b = Abs(b)
|
||||
|
||||
// https://en.wikipedia.org/wiki/Euclidean_algorithm
|
||||
for {
|
||||
rem := a % b
|
||||
a = b
|
||||
b = rem
|
||||
|
||||
if b == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// SimplifyRatio simplifies the given ratio.
|
||||
func SimplifyRatio(ratio image.Point) image.Point {
|
||||
gcf := findGCF(ratio.X, ratio.Y)
|
||||
if gcf == 0 {
|
||||
return image.ZP
|
||||
}
|
||||
return image.Point{
|
||||
X: ratio.X / gcf,
|
||||
Y: ratio.Y / gcf,
|
||||
}
|
||||
}
|
||||
|
||||
// SplitByRatio splits the provided number by the specified ratio.
|
||||
func SplitByRatio(n int, ratio image.Point) image.Point {
|
||||
sr := SimplifyRatio(ratio)
|
||||
if sr.Eq(image.ZP) {
|
||||
return image.ZP
|
||||
}
|
||||
fn := float64(n)
|
||||
sum := float64(sr.X + sr.Y)
|
||||
fact := fn / sum
|
||||
return image.Point{
|
||||
int(Round(fact * float64(sr.X))),
|
||||
int(Round(fact * float64(sr.Y))),
|
||||
}
|
||||
}
|
||||
|
@ -16,8 +16,11 @@ package numbers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
)
|
||||
|
||||
func TestRoundToNonZeroPlaces(t *testing.T) {
|
||||
@ -349,3 +352,138 @@ func TestAbs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGCF(t *testing.T) {
|
||||
tests := []struct {
|
||||
a int
|
||||
b int
|
||||
want int
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{0, 1, 0},
|
||||
{1, 0, 0},
|
||||
{1, 1, 1},
|
||||
{2, 2, 2},
|
||||
{50, 35, 5},
|
||||
{16, 88, 8},
|
||||
{-16, 88, 8},
|
||||
{16, -88, 8},
|
||||
{-16, -88, 8},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(fmt.Sprintf("findGCF(%d,%d)", tc.a, tc.b), func(t *testing.T) {
|
||||
if got := findGCF(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("findGCF(%d,%d) => got %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimplifyRatio(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
ratio image.Point
|
||||
want image.Point
|
||||
}{
|
||||
{
|
||||
desc: "zero ratio",
|
||||
ratio: image.Point{0, 0},
|
||||
want: image.Point{0, 0},
|
||||
},
|
||||
{
|
||||
desc: "already simplified",
|
||||
ratio: image.Point{1, 3},
|
||||
want: image.Point{1, 3},
|
||||
},
|
||||
{
|
||||
desc: "already simplified and X is negative",
|
||||
ratio: image.Point{-1, 3},
|
||||
want: image.Point{-1, 3},
|
||||
},
|
||||
{
|
||||
desc: "already simplified and Y is negative",
|
||||
ratio: image.Point{1, -3},
|
||||
want: image.Point{1, -3},
|
||||
},
|
||||
{
|
||||
desc: "already simplified and both are negative",
|
||||
ratio: image.Point{-1, -3},
|
||||
want: image.Point{-1, -3},
|
||||
},
|
||||
{
|
||||
desc: "simplifies positive ratio",
|
||||
ratio: image.Point{27, 42},
|
||||
want: image.Point{9, 14},
|
||||
},
|
||||
{
|
||||
desc: "simplifies negative ratio",
|
||||
ratio: image.Point{-30, 50},
|
||||
want: image.Point{-3, 5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := SimplifyRatio(tc.ratio)
|
||||
if diff := pretty.Compare(tc.want, got); diff != "" {
|
||||
t.Errorf("SimplifyRatio => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitByRatio(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
number int
|
||||
ratio image.Point
|
||||
want image.Point
|
||||
}{
|
||||
{
|
||||
desc: "zero numerator",
|
||||
number: 10,
|
||||
ratio: image.Point{0, 2},
|
||||
want: image.ZP,
|
||||
},
|
||||
{
|
||||
desc: "zero denominator",
|
||||
number: 10,
|
||||
ratio: image.Point{2, 0},
|
||||
want: image.ZP,
|
||||
},
|
||||
{
|
||||
desc: "zero number",
|
||||
number: 0,
|
||||
ratio: image.Point{1, 2},
|
||||
want: image.ZP,
|
||||
},
|
||||
{
|
||||
desc: "equal ratio",
|
||||
number: 2,
|
||||
ratio: image.Point{2, 2},
|
||||
want: image.Point{1, 1},
|
||||
},
|
||||
{
|
||||
desc: "unequal ratio",
|
||||
number: 15,
|
||||
ratio: image.Point{1, 2},
|
||||
want: image.Point{5, 10},
|
||||
},
|
||||
{
|
||||
desc: "large ratio",
|
||||
number: 19,
|
||||
ratio: image.Point{78, 121},
|
||||
want: image.Point{7, 12},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := SplitByRatio(tc.number, tc.ratio)
|
||||
if diff := pretty.Compare(tc.want, got); diff != "" {
|
||||
t.Errorf("SplitByRatio => unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -147,6 +147,9 @@ type XDetails struct {
|
||||
|
||||
// Labels are the labels for values on the X axis in an increasing order.
|
||||
Labels []*Label
|
||||
|
||||
// Properties are the properties that were used on the call to NewXDetails.
|
||||
Properties *XProperties
|
||||
}
|
||||
|
||||
// XProperties are the properties of the X axis.
|
||||
@ -199,10 +202,11 @@ func NewXDetails(cvsAr image.Rectangle, xp *XProperties) (*XDetails, error) {
|
||||
}
|
||||
|
||||
return &XDetails{
|
||||
Start: image.Point{xp.ReqYWidth, cvsAr.Dy() - reqHeight}, // Space for the labels.
|
||||
End: image.Point{xp.ReqYWidth + graphWidth, cvsAr.Dy() - reqHeight},
|
||||
Scale: scale,
|
||||
Labels: labels,
|
||||
Start: image.Point{xp.ReqYWidth, cvsAr.Dy() - reqHeight}, // Space for the labels.
|
||||
End: image.Point{xp.ReqYWidth + graphWidth, cvsAr.Dy() - reqHeight},
|
||||
Scale: scale,
|
||||
Labels: labels,
|
||||
Properties: xp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -270,6 +270,11 @@ func TestNewXDetails(t *testing.T) {
|
||||
Pos: image.Point{1, 2},
|
||||
},
|
||||
},
|
||||
Properties: &XProperties{
|
||||
Min: 0,
|
||||
Max: 0,
|
||||
ReqYWidth: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -291,6 +296,12 @@ func TestNewXDetails(t *testing.T) {
|
||||
Pos: image.Point{1, 2},
|
||||
},
|
||||
},
|
||||
Properties: &XProperties{
|
||||
Min: 0,
|
||||
Max: 0,
|
||||
ReqYWidth: 0,
|
||||
LO: LabelOrientationVertical,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -311,6 +322,11 @@ func TestNewXDetails(t *testing.T) {
|
||||
Pos: image.Point{3, 4},
|
||||
},
|
||||
},
|
||||
Properties: &XProperties{
|
||||
Min: 0,
|
||||
Max: 0,
|
||||
ReqYWidth: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -336,6 +352,12 @@ func TestNewXDetails(t *testing.T) {
|
||||
Pos: image.Point{7, 6},
|
||||
},
|
||||
},
|
||||
Properties: &XProperties{
|
||||
Min: 0,
|
||||
Max: 1000,
|
||||
ReqYWidth: 2,
|
||||
LO: LabelOrientationVertical,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -361,6 +383,12 @@ func TestNewXDetails(t *testing.T) {
|
||||
Pos: image.Point{7, 7},
|
||||
},
|
||||
},
|
||||
Properties: &XProperties{
|
||||
Min: 0,
|
||||
Max: 999,
|
||||
ReqYWidth: 2,
|
||||
LO: LabelOrientationVertical,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -390,6 +418,16 @@ func TestNewXDetails(t *testing.T) {
|
||||
Pos: image.Point{19, 5},
|
||||
},
|
||||
},
|
||||
Properties: &XProperties{
|
||||
Min: 0,
|
||||
Max: 1,
|
||||
ReqYWidth: 5,
|
||||
CustomLabels: map[int]string{
|
||||
0: "start",
|
||||
1: "end",
|
||||
},
|
||||
LO: LabelOrientationVertical,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ import (
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/widgetapi"
|
||||
"github.com/mum4k/termdash/widgets/linechart/axes"
|
||||
"github.com/mum4k/termdash/widgets/linechart/zoom"
|
||||
)
|
||||
|
||||
// seriesValues represent values stored in the series.
|
||||
@ -70,6 +71,10 @@ func newSeriesValues(values []float64) *seriesValues {
|
||||
// The Y axis will be sized so that it can conveniently accommodate the largest
|
||||
// value among all the labeled line charts. This determines the used scale.
|
||||
//
|
||||
// LineChart supports mouse based zoom, zooming is achieved by either
|
||||
// highlighting an area on the graph (left mouse clicking and dragging) or by
|
||||
// using the mouse scroll button.
|
||||
//
|
||||
// Implements widgetapi.Widget. This object is thread-safe.
|
||||
type LineChart struct {
|
||||
// mu protects the LineChart widget.
|
||||
@ -91,6 +96,9 @@ type LineChart struct {
|
||||
|
||||
// xLabels that were provided on a call to Series.
|
||||
xLabels map[int]string
|
||||
|
||||
// zoom tracks the zooming of the X axis.
|
||||
zoom *zoom.Tracker
|
||||
}
|
||||
|
||||
// New returns a new line chart widget.
|
||||
@ -339,23 +347,18 @@ func (lc *LineChart) drawAxes(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YD
|
||||
return nil
|
||||
}
|
||||
|
||||
// brailleCvs returns a braille canvas sized so that it fits between the axes
|
||||
// and the canvas borders.
|
||||
func (lc *LineChart) brailleCvs(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) (*braille.Canvas, error) {
|
||||
// The area available to the graph.
|
||||
graphAr := image.Rect(yd.Start.X+1, yd.Start.Y, cvs.Area().Max.X, xd.End.Y)
|
||||
bc, err := braille.New(graphAr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("braille.New => %v", err)
|
||||
}
|
||||
return bc, nil
|
||||
// graphAr returns the area available for the graph itself sized so that it
|
||||
// fits between the axes and the canvas borders.
|
||||
func (lc *LineChart) graphAr(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) image.Rectangle {
|
||||
return image.Rect(yd.Start.X+1, yd.Start.Y, cvs.Area().Max.X, xd.End.Y)
|
||||
}
|
||||
|
||||
// drawSeries draws the graph representing the stored series.
|
||||
// Returns XDetails that might be adjusted to not start at zero value if some
|
||||
// of the series didn't fit the graphs and XAxisUnscaled was provided.
|
||||
func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) (*axes.XDetails, error) {
|
||||
bc, err := lc.brailleCvs(cvs, xd, yd)
|
||||
graphAr := lc.graphAr(cvs, xd, yd)
|
||||
bc, err := braille.New(graphAr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -365,6 +368,19 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if lc.zoom == nil {
|
||||
z, err := zoom.New(xdForCap, cvs.Area(), graphAr, zoom.ScrollStep(lc.opts.zoomStepPercent))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lc.zoom = z
|
||||
} else {
|
||||
if err := lc.zoom.Update(xdForCap, cvs.Area(), graphAr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
xdZoomed := lc.zoom.Zoom()
|
||||
var names []string
|
||||
for name := range lc.series {
|
||||
names = append(names, name)
|
||||
@ -383,7 +399,7 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.
|
||||
var prev float64
|
||||
for i := 1; i < len(sv.values); i++ {
|
||||
prev = sv.values[i-1]
|
||||
if i < int(xdForCap.Scale.Min.Value)+1 || i > int(xdForCap.Scale.Max.Value) {
|
||||
if i < int(xdZoomed.Scale.Min.Value)+1 || i > int(xdZoomed.Scale.Max.Value) {
|
||||
// Don't draw lines for values that aren't supposed to be visible.
|
||||
// These are either values outside of the current zoom or
|
||||
// values at the beginning of a series that falls before athe
|
||||
@ -392,13 +408,13 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.
|
||||
continue
|
||||
}
|
||||
|
||||
startX, err := xdForCap.Scale.ValueToPixel(i - 1)
|
||||
startX, err := xdZoomed.Scale.ValueToPixel(i - 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failure for series %v[%d], xdForCap.Scale.ValueToPixel => %v", name, i-1, err)
|
||||
return nil, fmt.Errorf("failure for series %v[%d], xdZoomed.Scale.ValueToPixel => %v", name, i-1, err)
|
||||
}
|
||||
endX, err := xdForCap.Scale.ValueToPixel(i)
|
||||
endX, err := xdZoomed.Scale.ValueToPixel(i)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failure for series %v[%d], xdForCap.Scale.ValueToPixel => %v", name, i, err)
|
||||
return nil, fmt.Errorf("failure for series %v[%d], xdZoomed.Scale.ValueToPixel => %v", name, i, err)
|
||||
}
|
||||
|
||||
startY, err := yd.Scale.ValueToPixel(prev)
|
||||
@ -420,10 +436,24 @@ func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if highlight, hRange := lc.zoom.Highlight(); highlight {
|
||||
if err := lc.highlightRange(bc, hRange); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := bc.CopyTo(cvs); err != nil {
|
||||
return nil, fmt.Errorf("bc.Apply => %v", err)
|
||||
}
|
||||
return xdForCap, nil
|
||||
return xdZoomed, nil
|
||||
}
|
||||
|
||||
// highlightRange highlights the range of X columns on the braille canvas.
|
||||
func (lc *LineChart) highlightRange(bc *braille.Canvas, hRange *zoom.Range) error {
|
||||
cellAr := bc.CellArea()
|
||||
ar := image.Rect(hRange.Start, cellAr.Min.Y, hRange.End, cellAr.Max.Y)
|
||||
return bc.SetAreaCellOpts(ar, cell.BgColor(lc.opts.zoomHightlightColor))
|
||||
}
|
||||
|
||||
// Keyboard implements widgetapi.Widget.Keyboard.
|
||||
@ -433,7 +463,10 @@ func (lc *LineChart) Keyboard(k *terminalapi.Keyboard) error {
|
||||
|
||||
// Mouse implements widgetapi.Widget.Mouse.
|
||||
func (lc *LineChart) Mouse(m *terminalapi.Mouse) error {
|
||||
return errors.New("the LineChart widget doesn't support mouse events")
|
||||
if lc.zoom == nil {
|
||||
return nil
|
||||
}
|
||||
return lc.zoom.Mouse(m)
|
||||
}
|
||||
|
||||
// minSize determines the minimum required size to draw the line chart.
|
||||
@ -457,6 +490,7 @@ func (lc *LineChart) Options() widgetapi.Options {
|
||||
|
||||
return widgetapi.Options{
|
||||
MinimumSize: lc.minSize(),
|
||||
WantMouse: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -26,7 +26,9 @@ import (
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/draw/testdraw"
|
||||
"github.com/mum4k/termdash/mouse"
|
||||
"github.com/mum4k/termdash/terminal/faketerm"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/widgetapi"
|
||||
)
|
||||
|
||||
@ -42,6 +44,22 @@ func TestLineChartDraws(t *testing.T) {
|
||||
wantWriteErr bool
|
||||
wantDrawErr bool
|
||||
}{
|
||||
{
|
||||
desc: "fails with scroll step too low",
|
||||
canvas: image.Rect(0, 0, 3, 4),
|
||||
opts: []Option{
|
||||
ZoomStepPercent(0),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "fails with scroll step too high",
|
||||
canvas: image.Rect(0, 0, 3, 4),
|
||||
opts: []Option{
|
||||
ZoomStepPercent(101),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "fails with custom scale where min is NaN",
|
||||
canvas: image.Rect(0, 0, 3, 4),
|
||||
@ -1136,6 +1154,155 @@ func TestLineChartDraws(t *testing.T) {
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "highlights area for zoom",
|
||||
canvas: image.Rect(0, 0, 20, 10),
|
||||
writes: func(lc *LineChart) error {
|
||||
if err := lc.Series("first", []float64{0, 100}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Draw once so zoom tracker is initialized.
|
||||
cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10))
|
||||
if err := lc.Draw(cvs); err != nil {
|
||||
return err
|
||||
}
|
||||
return lc.Mouse(&terminalapi.Mouse{
|
||||
Position: image.Point{6, 5},
|
||||
Button: mouse.ButtonLeft,
|
||||
})
|
||||
},
|
||||
wantCapacity: 28,
|
||||
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})
|
||||
|
||||
// Highlighted area for zoom.
|
||||
testbraille.MustSetAreaCellOpts(bc, image.Rect(0, 0, 1, 8), cell.BgColor(cell.ColorNumber(235)))
|
||||
|
||||
testbraille.MustCopyTo(bc, c)
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "highlights area for zoom to a custom color",
|
||||
opts: []Option{
|
||||
ZoomHightlightColor(cell.ColorNumber(13)),
|
||||
},
|
||||
canvas: image.Rect(0, 0, 20, 10),
|
||||
writes: func(lc *LineChart) error {
|
||||
if err := lc.Series("first", []float64{0, 100}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Draw once so zoom tracker is initialized.
|
||||
cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10))
|
||||
if err := lc.Draw(cvs); err != nil {
|
||||
return err
|
||||
}
|
||||
return lc.Mouse(&terminalapi.Mouse{
|
||||
Position: image.Point{6, 5},
|
||||
Button: mouse.ButtonLeft,
|
||||
})
|
||||
},
|
||||
wantCapacity: 28,
|
||||
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})
|
||||
|
||||
// Highlighted area for zoom.
|
||||
testbraille.MustSetAreaCellOpts(bc, image.Rect(0, 0, 1, 8), cell.BgColor(cell.ColorNumber(13)))
|
||||
|
||||
testbraille.MustCopyTo(bc, c)
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "zooms in on scroll up",
|
||||
opts: []Option{
|
||||
ZoomStepPercent(50),
|
||||
},
|
||||
canvas: image.Rect(0, 0, 20, 10),
|
||||
writes: func(lc *LineChart) error {
|
||||
if err := lc.Series("first", []float64{0, 25, 75, 100}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Draw once so zoom tracker is initialized.
|
||||
cvs := testcanvas.MustNew(image.Rect(0, 0, 20, 10))
|
||||
if err := lc.Draw(cvs); err != nil {
|
||||
return err
|
||||
}
|
||||
return lc.Mouse(&terminalapi.Mouse{
|
||||
Position: image.Point{8, 5},
|
||||
Button: mouse.ButtonWheelUp,
|
||||
})
|
||||
},
|
||||
wantCapacity: 28,
|
||||
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{13, 23})
|
||||
testdraw.MustBrailleLine(bc, image.Point{13, 23}, image.Point{27, 8})
|
||||
|
||||
testbraille.MustCopyTo(bc, c)
|
||||
testcanvas.MustApply(c, ft)
|
||||
return ft
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@ -1198,6 +1365,26 @@ func TestLineChartDraws(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyboard(t *testing.T) {
|
||||
lc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New => unexpected error: %v", err)
|
||||
}
|
||||
if err := lc.Keyboard(&terminalapi.Keyboard{}); err == nil {
|
||||
t.Errorf("Keyboard => got nil err, wanted one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMouseDoesNothingWithoutZoomTracker(t *testing.T) {
|
||||
lc, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New => unexpected error: %v", err)
|
||||
}
|
||||
if err := lc.Mouse(&terminalapi.Mouse{}); err != nil {
|
||||
t.Errorf("Mouse => unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
@ -1210,6 +1397,7 @@ func TestOptions(t *testing.T) {
|
||||
desc: "reserves space for axis without series",
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{3, 4},
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -1219,6 +1407,7 @@ func TestOptions(t *testing.T) {
|
||||
},
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{5, 4},
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -1228,6 +1417,7 @@ func TestOptions(t *testing.T) {
|
||||
},
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{6, 4},
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -1240,6 +1430,7 @@ func TestOptions(t *testing.T) {
|
||||
},
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{4, 5},
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -1252,6 +1443,7 @@ func TestOptions(t *testing.T) {
|
||||
},
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{5, 7},
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ import (
|
||||
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/widgets/linechart/axes"
|
||||
"github.com/mum4k/termdash/widgets/linechart/zoom"
|
||||
)
|
||||
|
||||
// options.go contains configurable options for LineChart.
|
||||
@ -32,33 +33,39 @@ type Option interface {
|
||||
|
||||
// options stores the provided options.
|
||||
type options struct {
|
||||
axesCellOpts []cell.Option
|
||||
xLabelCellOpts []cell.Option
|
||||
xLabelOrientation axes.LabelOrientation
|
||||
yLabelCellOpts []cell.Option
|
||||
xAxisUnscaled bool
|
||||
yAxisMode axes.YScaleMode
|
||||
yAxisCustomScale *customScale
|
||||
axesCellOpts []cell.Option
|
||||
xLabelCellOpts []cell.Option
|
||||
xLabelOrientation axes.LabelOrientation
|
||||
yLabelCellOpts []cell.Option
|
||||
xAxisUnscaled bool
|
||||
yAxisMode axes.YScaleMode
|
||||
yAxisCustomScale *customScale
|
||||
zoomHightlightColor cell.Color
|
||||
zoomStepPercent int
|
||||
}
|
||||
|
||||
// validate validates the provided options.
|
||||
func (o *options) validate() error {
|
||||
if o.yAxisCustomScale == nil {
|
||||
return nil
|
||||
if o.yAxisCustomScale != nil {
|
||||
if math.IsNaN(o.yAxisCustomScale.min) || math.IsNaN(o.yAxisCustomScale.max) {
|
||||
return fmt.Errorf("both the min(%v) and the max(%v) provided as custom Y scale must be valid numbers", o.yAxisCustomScale.min, o.yAxisCustomScale.max)
|
||||
}
|
||||
if o.yAxisCustomScale.min >= o.yAxisCustomScale.max {
|
||||
return fmt.Errorf("the min(%v) must be less than the max(%v) provided as custom Y scale", o.yAxisCustomScale.min, o.yAxisCustomScale.max)
|
||||
}
|
||||
}
|
||||
|
||||
if math.IsNaN(o.yAxisCustomScale.min) || math.IsNaN(o.yAxisCustomScale.max) {
|
||||
return fmt.Errorf("both the min(%v) and the max(%v) provided as custom Y scale must be valid numbers", o.yAxisCustomScale.min, o.yAxisCustomScale.max)
|
||||
}
|
||||
if o.yAxisCustomScale.min >= o.yAxisCustomScale.max {
|
||||
return fmt.Errorf("the min(%v) must be less than the max(%v) provided as custom Y scale", o.yAxisCustomScale.min, o.yAxisCustomScale.max)
|
||||
if got, min, max := o.zoomStepPercent, 1, 100; got < min || got > max {
|
||||
return fmt.Errorf("invalid ZoomStepPercent %d, must be in range %d <= value <= %d", got, min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newOptions returns a new options instance.
|
||||
func newOptions(opts ...Option) *options {
|
||||
opt := &options{}
|
||||
opt := &options{
|
||||
zoomHightlightColor: cell.ColorNumber(235),
|
||||
zoomStepPercent: zoom.DefaultScrollStep,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o.set(opt)
|
||||
}
|
||||
@ -168,3 +175,22 @@ func XAxisUnscaled() Option {
|
||||
opts.xAxisUnscaled = true
|
||||
})
|
||||
}
|
||||
|
||||
// ZoomHightlightColor sets the background color of the area that is selected
|
||||
// with mouse in order to zoom the linechart.
|
||||
// Defaults to color number 235.
|
||||
func ZoomHightlightColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.zoomHightlightColor = c
|
||||
})
|
||||
}
|
||||
|
||||
// ZoomStepPercent sets the zooming step on each mouse scroll event as the
|
||||
// percentage of the size of the X axis.
|
||||
// The value must be in range 0 < value <= 100.
|
||||
// Defaults to zoom.DefaultScrollStep.
|
||||
func ZoomStepPercent(perc int) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.zoomStepPercent = perc
|
||||
})
|
||||
}
|
||||
|
487
widgets/linechart/zoom/zoom.go
Normal file
487
widgets/linechart/zoom/zoom.go
Normal file
@ -0,0 +1,487 @@
|
||||
// Copyright 2019 Google Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package zoom contains code that tracks the current zoom level.
|
||||
package zoom
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"reflect"
|
||||
|
||||
"github.com/mum4k/termdash/mouse"
|
||||
"github.com/mum4k/termdash/mouse/button"
|
||||
"github.com/mum4k/termdash/numbers"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/widgets/linechart/axes"
|
||||
)
|
||||
|
||||
// Option is used to provide options.
|
||||
type Option interface {
|
||||
// set sets the provided option.
|
||||
set(*options)
|
||||
}
|
||||
|
||||
// options stores the provided options.
|
||||
type options struct {
|
||||
scrollStepPerc int
|
||||
}
|
||||
|
||||
// newOptions creates new options instance and applies the provided options.
|
||||
func newOptions(opts ...Option) *options {
|
||||
o := &options{
|
||||
scrollStepPerc: DefaultScrollStep,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt.set(o)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// validate validates the provided options.
|
||||
func (o *options) validate() error {
|
||||
if min, max := 1, 100; o.scrollStepPerc < min || o.scrollStepPerc > max {
|
||||
return fmt.Errorf("invalid ScrollStep %d, must be a value in the range %d <= value <= %d", o.scrollStepPerc, min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// option implements Option.
|
||||
type option func(*options)
|
||||
|
||||
// set implements Option.set.
|
||||
func (o option) set(opts *options) {
|
||||
o(opts)
|
||||
}
|
||||
|
||||
// DefaultScrollStep is the default value for the ScrollStep option.
|
||||
const DefaultScrollStep = 10
|
||||
|
||||
// ScrollStep sets the amount of zoom in or out on a single mouse scroll event.
|
||||
// This is set as a percentage of the current value size of the X axis.
|
||||
// Must be a value in range 0 < value <= 100.
|
||||
// Defaults to DefaultScrollStep.
|
||||
func ScrollStep(perc int) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.scrollStepPerc = perc
|
||||
})
|
||||
}
|
||||
|
||||
// Tracker tracks the state of mouse selection on the linechart and stores
|
||||
// requests for zoom.
|
||||
// This object is not thread-safe.
|
||||
type Tracker struct {
|
||||
// baseX is the base X axis without any zoom applied.
|
||||
baseX *axes.XDetails
|
||||
// zoomX is the zoomed X axis or nil if zoom isn't applied.
|
||||
zoomX *axes.XDetails
|
||||
|
||||
// cvsAr is the entire canvas available to the linechart widget.
|
||||
cvsAr image.Rectangle
|
||||
|
||||
// graphAr is a smaller part of the cvsAr that contains the linechart
|
||||
// itself. I.e. an area between the axis and the borders of cvsAr.
|
||||
graphAr image.Rectangle
|
||||
|
||||
// fsm is the state machine tracking the state of mouse left button.
|
||||
fsm *button.FSM
|
||||
|
||||
// highlight is the currently highlighted area.
|
||||
highlight *Range
|
||||
|
||||
// opts are the provided options.
|
||||
opts *options
|
||||
}
|
||||
|
||||
// New returns a new zoom tracker that tracks zoom requests within
|
||||
// the provided graph area. The cvsAr argument indicates size of the entire
|
||||
// canvas available to the widget.
|
||||
func New(baseX *axes.XDetails, cvsAr, graphAr image.Rectangle, opts ...Option) (*Tracker, error) {
|
||||
o := newOptions(opts...)
|
||||
if err := o.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &Tracker{
|
||||
fsm: button.NewFSM(mouse.ButtonLeft, graphAr),
|
||||
highlight: &Range{},
|
||||
opts: o,
|
||||
}
|
||||
if err := t.Update(baseX, cvsAr, graphAr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Update is used to inform the zoom tracker about the base X axis and the
|
||||
// graph area.
|
||||
// Should be called each time the widget redraws.
|
||||
func (t *Tracker) Update(baseX *axes.XDetails, cvsAr, graphAr image.Rectangle) error {
|
||||
if !graphAr.In(cvsAr) {
|
||||
return fmt.Errorf("the graphAr %v doesn't fit inside the cvsAr %v", graphAr, cvsAr)
|
||||
}
|
||||
// If any of these parameters changed, we need to reset the FSM and ensure
|
||||
// the current zoom is still within the range of the new X axis.
|
||||
ac, sc := t.axisChanged(baseX), t.sizeChanged(cvsAr, graphAr)
|
||||
|
||||
if sc {
|
||||
t.highlight.reset()
|
||||
}
|
||||
if ac || sc {
|
||||
if t.zoomX != nil {
|
||||
// Input data changed and we have an existing zoom in place.
|
||||
// We need to normalize it again, since it might be outside of the
|
||||
// currently visible values (e.g. if the terminal size decreased).
|
||||
zoomMin := int(t.zoomX.Scale.Min.Value)
|
||||
zoomMax := int(t.zoomX.Scale.Max.Value)
|
||||
min, max := normalize(baseX.Scale.Min, baseX.Scale.Max, zoomMin, zoomMax)
|
||||
zoom, err := newZoomedFromBase(min, max, baseX, cvsAr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.zoomX = zoom
|
||||
}
|
||||
}
|
||||
|
||||
t.baseX = baseX
|
||||
t.cvsAr = cvsAr
|
||||
t.graphAr = graphAr
|
||||
return nil
|
||||
}
|
||||
|
||||
// sizeChanged asserts whether the physical layout of the terminal changed.
|
||||
func (t *Tracker) sizeChanged(cvsAr, graphAr image.Rectangle) bool {
|
||||
return !cvsAr.Eq(t.cvsAr) || !graphAr.Eq(t.graphAr)
|
||||
}
|
||||
|
||||
// axisChanged asserts whether the axis scale changed.
|
||||
func (t *Tracker) axisChanged(baseX *axes.XDetails) bool {
|
||||
return !reflect.DeepEqual(baseX, t.baseX)
|
||||
}
|
||||
|
||||
// baseForZoom returns the base axis before zooming.
|
||||
// This is either the base provided to New or Update if no zoom was performed
|
||||
// yet, or the previously zoomed axis.
|
||||
func (t *Tracker) baseForZoom() *axes.XDetails {
|
||||
if t.zoomX == nil {
|
||||
return t.baseX
|
||||
}
|
||||
return t.zoomX
|
||||
}
|
||||
|
||||
// Mouse is used to forward mouse events to the zoom tracker.
|
||||
func (t *Tracker) Mouse(m *terminalapi.Mouse) error {
|
||||
zoom, err := zoomToScroll(m, t.cvsAr, t.graphAr, t.baseForZoom(), t.baseX, t.opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if zoom != nil {
|
||||
t.zoomX = zoom
|
||||
}
|
||||
|
||||
clicked, bs := t.fsm.Event(m)
|
||||
switch {
|
||||
case bs == button.Down:
|
||||
cellX := m.Position.X - t.graphAr.Min.X
|
||||
t.highlight.addX(cellX)
|
||||
|
||||
case clicked && bs == button.Up:
|
||||
zoom, err := zoomToHighlight(t.baseForZoom(), t.highlight, t.cvsAr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.zoomX = zoom
|
||||
t.highlight.reset()
|
||||
|
||||
default:
|
||||
t.highlight.reset()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Range represents a range of values.
|
||||
// The range includes all values x such that Start <= x < End.
|
||||
type Range struct {
|
||||
// Start is the start of the range.
|
||||
Start int
|
||||
// End is the end of the range.
|
||||
End int
|
||||
|
||||
// last is the last coordinate that was added to the range.
|
||||
last int
|
||||
}
|
||||
|
||||
// empty asserts if the range is empty.
|
||||
func (r *Range) empty() bool {
|
||||
return r.Start == r.End
|
||||
}
|
||||
|
||||
// reset resets the range back to zero.
|
||||
func (r *Range) reset() {
|
||||
r.Start, r.End, r.last = 0, 0, 0
|
||||
}
|
||||
|
||||
// addX adds the provided X coordinate to the range.
|
||||
func (r *Range) addX(x int) {
|
||||
switch {
|
||||
case r.empty():
|
||||
r.Start = x
|
||||
r.End = x + 1
|
||||
|
||||
case x < r.Start:
|
||||
if r.last == r.End-1 {
|
||||
// Handles fast mouse move to the left across Start.
|
||||
// If we don't adjust the end, we would extend both ends of the
|
||||
// range.
|
||||
r.End = r.Start + 1
|
||||
}
|
||||
r.Start = x
|
||||
|
||||
case x >= r.End:
|
||||
if r.last == r.Start {
|
||||
// Handles fast mouse move to the right across End.
|
||||
// If we don't adjust the start, we would extend both ends of the
|
||||
// range.
|
||||
r.Start = r.End - 1
|
||||
}
|
||||
r.End = x + 1
|
||||
|
||||
case x > r.last:
|
||||
// Handles change of direction from left to right.
|
||||
r.Start = x
|
||||
|
||||
case x < r.last:
|
||||
// Handles change of direction from right to left.
|
||||
r.End = x + 1
|
||||
}
|
||||
r.last = x
|
||||
}
|
||||
|
||||
// Highlight returns true if a range on the graph area should be highlighted
|
||||
// because the user is holding down the left mouse button and dragging mouse
|
||||
// across the graph area. The returned range indicates the range of X cell
|
||||
// coordinates within the graph area provided to New or Update. These are the
|
||||
// columns that should be highlighted.
|
||||
// Returns false of no area should be highlighted, in which case the state of
|
||||
// the Range return value is undefined.
|
||||
func (t *Tracker) Highlight() (bool, *Range) {
|
||||
if t.highlight.empty() {
|
||||
return false, nil
|
||||
}
|
||||
return true, t.highlight
|
||||
}
|
||||
|
||||
// Zoom returns an adjusted X axis if zoom is applied, or the same axis as was
|
||||
// provided to New or Update.
|
||||
func (t *Tracker) Zoom() *axes.XDetails {
|
||||
if t.zoomX == nil {
|
||||
return t.baseX
|
||||
}
|
||||
return t.zoomX
|
||||
}
|
||||
|
||||
// normalize normalizes the zoom range.
|
||||
// This handles cases where zoom out would happen above the base axis or
|
||||
// when the base axis itself changes (user provided new values) or when the
|
||||
// graph areas change (terminal size changed).
|
||||
func normalize(baseMin, baseMax *axes.Value, min, max int) (int, int) {
|
||||
bMin := int(baseMin.Value)
|
||||
bMax := int(baseMax.Value)
|
||||
var newMin, newMax int
|
||||
// Don't zoom-out above the base axis.
|
||||
if min < bMin {
|
||||
newMin = bMin
|
||||
} else {
|
||||
newMin = min
|
||||
}
|
||||
|
||||
if max > bMax {
|
||||
newMax = bMax
|
||||
} else {
|
||||
newMax = max
|
||||
}
|
||||
return newMin, newMax
|
||||
}
|
||||
|
||||
// newZoomedFromBase returns a new X axis zoomed to the provided min and max.
|
||||
func newZoomedFromBase(min, max int, base *axes.XDetails, cvsAr image.Rectangle) (*axes.XDetails, error) {
|
||||
zp := *base.Properties // Shallow copy.
|
||||
zp.Min = min
|
||||
zp.Max = max
|
||||
|
||||
zoom, err := axes.NewXDetails(cvsAr, &zp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create zoomed X axis: %v", err)
|
||||
}
|
||||
return zoom, nil
|
||||
}
|
||||
|
||||
// findCellPair given two cells on the base X axis returns the values of the
|
||||
// closest or the same cells such that the values are distinct.
|
||||
// Useful while zooming, if the zoom targets a view that would only have one
|
||||
// value, this function adjusts the view to the closest two cells with distinct
|
||||
// values.
|
||||
func findCellPair(base *axes.XDetails, minCell, maxCell int) (*axes.Value, *axes.Value, error) {
|
||||
minL, err := base.Scale.CellLabel(minCell)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("unable to determine min label for cell %d: %v", minCell, err)
|
||||
}
|
||||
maxL, err := base.Scale.CellLabel(maxCell)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("unable to determine max label for cell %d: %v", maxCell, err)
|
||||
}
|
||||
|
||||
diff := maxL.Value - minL.Value
|
||||
if diff > 1 {
|
||||
return minL, maxL, nil
|
||||
}
|
||||
|
||||
// Try above the max.
|
||||
for cellNum := maxCell; cellNum < base.Scale.GraphWidth; cellNum++ {
|
||||
l, err := base.Scale.CellLabel(cellNum)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if l.Value > minL.Value {
|
||||
return minL, l, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try below the min.
|
||||
for cellNum := minCell; cellNum >= 0; cellNum-- {
|
||||
l, err := base.Scale.CellLabel(cellNum)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if l.Value < maxL.Value {
|
||||
return l, maxL, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Give up and use the first and the last cells.
|
||||
firstL, err := base.Scale.CellLabel(0)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("unable to determine label for the first cell: %v", err)
|
||||
}
|
||||
lastL, err := base.Scale.CellLabel(base.Scale.GraphWidth - 1)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("unable to determine label for the last cell: %v", err)
|
||||
}
|
||||
return firstL, lastL, nil
|
||||
}
|
||||
|
||||
// zoomToHighlight zooms the base X axis according to the highlighted range.
|
||||
func zoomToHighlight(base *axes.XDetails, hr *Range, cvsAr image.Rectangle) (*axes.XDetails, error) {
|
||||
minL, maxL, err := findCellPair(base, hr.Start, hr.End-1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zoom, err := newZoomedFromBase(int(minL.Value), int(maxL.Value), base, cvsAr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return zoom, nil
|
||||
}
|
||||
|
||||
// zoomToScroll zooms the current X axis in or out depending on the direction of
|
||||
// the scroll. Doesn't zoom out above the base X axis view.
|
||||
// Doesn't zoom if the scroll button isn't recognized or the event falls
|
||||
// outside of the graph area.
|
||||
// Can return nil axis if the mouse event didn't result in zooming.
|
||||
func zoomToScroll(m *terminalapi.Mouse, cvsAr, graphAr image.Rectangle, curr, base *axes.XDetails, opts *options) (*axes.XDetails, error) {
|
||||
if !m.Position.In(graphAr) {
|
||||
// Ignore scroll events outside of the graph area.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var direction int // Positive on zoom in, negative on zoom out.
|
||||
switch m.Button {
|
||||
case mouse.ButtonWheelUp:
|
||||
direction = 1
|
||||
|
||||
case mouse.ButtonWheelDown:
|
||||
direction = -1
|
||||
|
||||
default:
|
||||
// Nothing to do for other buttons.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cellX := m.Position.X - graphAr.Min.X
|
||||
tgtVal, err := curr.Scale.CellLabel(cellX)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to determine value at the point where scrolling occurred: %v", err)
|
||||
}
|
||||
|
||||
currMin := int(curr.Scale.Min.Value)
|
||||
currMax := int(curr.Scale.Max.Value)
|
||||
baseMin := int(base.Scale.Min.Value)
|
||||
baseMax := int(base.Scale.Max.Value)
|
||||
size := baseMax - baseMin
|
||||
step := size * opts.scrollStepPerc / 100
|
||||
_, left := numbers.MinMaxInts([]int{
|
||||
1,
|
||||
int(tgtVal.Value) - currMin,
|
||||
})
|
||||
_, right := numbers.MinMaxInts([]int{
|
||||
1,
|
||||
currMax - int(tgtVal.Value),
|
||||
})
|
||||
|
||||
splitStep := numbers.SplitByRatio(step, image.Point{left, right})
|
||||
newMin := currMin + (direction * splitStep.X)
|
||||
newMax := currMax - (direction * splitStep.Y)
|
||||
|
||||
var limits *axes.XDetails
|
||||
switch m.Button {
|
||||
case mouse.ButtonWheelUp:
|
||||
if newMin > currMax {
|
||||
newMin = currMax
|
||||
}
|
||||
if newMax < currMin {
|
||||
newMax = currMin
|
||||
}
|
||||
limits = curr
|
||||
|
||||
case mouse.ButtonWheelDown:
|
||||
if newMin < baseMin {
|
||||
newMin = baseMin
|
||||
}
|
||||
if newMax > baseMax {
|
||||
newMax = baseMax
|
||||
}
|
||||
limits = base
|
||||
}
|
||||
|
||||
minCell, err := limits.Scale.ValueToCell(newMin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxCell, err := limits.Scale.ValueToCell(newMax)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
minL, maxL, err := findCellPair(limits, minCell, maxCell)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
min, max := normalize(limits.Scale.Min, limits.Scale.Max, int(minL.Value), int(maxL.Value))
|
||||
zoom, err := newZoomedFromBase(min, max, curr, cvsAr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return zoom, nil
|
||||
}
|
1696
widgets/linechart/zoom/zoom_test.go
Normal file
1696
widgets/linechart/zoom/zoom_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user