Projects STRLCPY termdash Commits 64819efa
🤬
Revision indexing in progress... (symbol navigation in revisions will be accurate after indexed)
  • ■ ■ ■ ■ ■ ■
    README.md
    skipped 97 lines
    98 98   
    99 99  [<img src="./images/barchartdemo.gif" alt="barchartdemo" type="image/gif">](widgets/barchart/barchartdemo/barchartdemo.go)
    100 100   
     101 +### The LineChart
     102 + 
     103 +Displays series of values as line charts. Run the
     104 +[linechartdemo](widgets/linechart/linechartdemo/linechartdemo.go).
     105 + 
     106 +[<img src="./images/linechartdemo.gif" alt="linechartdemo" type="image/gif">](widgets/linechart/linechartdemo/linechartdemo.go)
     107 + 
    101 108   
    102 109  ## Disclaimer
    103 110   
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    canvas/braille/braille.go
    skipped 50 lines
    51 51  )
    52 52   
    53 53  const (
    54  - // colMult is the resolution multiplier for the width, i.e. two pixels per cell.
    55  - colMult = 2
     54 + // ColMult is the resolution multiplier for the width, i.e. two pixels per cell.
     55 + ColMult = 2
    56 56   
    57  - // rowMult is the resolution multiplier for the height, i.e. four pixels per cell.
    58  - rowMult = 4
     57 + // RowMult is the resolution multiplier for the height, i.e. four pixels per cell.
     58 + RowMult = 4
    59 59   
    60 60   // brailleCharOffset is the offset of the braille pattern unicode characters.
    61 61   // From: http://www.alanwood.net/unicode/braille_patterns.html
    skipped 45 lines
    107 107  // Size returns the size of the braille canvas in pixels.
    108 108  func (c *Canvas) Size() image.Point {
    109 109   s := c.regular.Size()
    110  - return image.Point{s.X * colMult, s.Y * rowMult}
     110 + return image.Point{s.X * ColMult, s.Y * RowMult}
    111 111  }
    112 112   
    113 113  // Area returns the area of the braille canvas in pixels.
    skipped 1 lines
    115 115  // than the area used to create the braille canvas.
    116 116  func (c *Canvas) Area() image.Rectangle {
    117 117   ar := c.regular.Area()
    118  - return image.Rect(0, 0, ar.Dx()*colMult, ar.Dx()*rowMult)
     118 + return image.Rect(0, 0, ar.Dx()*ColMult, ar.Dx()*RowMult)
    119 119  }
    120 120   
    121 121  // Clear clears all the content on the canvas.
    skipped 91 lines
    213 213  // cellPoint determines the point (coordinate) of the character cell given
    214 214  // coordinates in pixels.
    215 215  func (c *Canvas) cellPoint(p image.Point) (image.Point, error) {
    216  - cp := image.Point{p.X / colMult, p.Y / rowMult}
     216 + cp := image.Point{p.X / ColMult, p.Y / RowMult}
    217 217   if ar := c.regular.Area(); !cp.In(ar) {
    218 218   return image.ZP, fmt.Errorf("pixel at%v would be in a character cell at%v which falls outside of the canvas area %v", p, cp, ar)
    219 219   }
    skipped 12 lines
    232 232   
    233 233  // pixelPoint translates point within canvas to point within the target cell.
    234 234  func pixelPoint(p image.Point) image.Point {
    235  - return image.Point{p.X % colMult, p.Y % rowMult}
     235 + return image.Point{p.X % ColMult, p.Y % RowMult}
    236 236  }
    237 237   
  • ■ ■ ■ ■ ■ ■
    canvas/braille/testbraille/testbraille.go
    skipped 18 lines
    19 19   "fmt"
    20 20   "image"
    21 21   
     22 + "github.com/mum4k/termdash/canvas"
    22 23   "github.com/mum4k/termdash/canvas/braille"
    23 24   "github.com/mum4k/termdash/cell"
    24 25   "github.com/mum4k/termdash/terminal/faketerm"
    skipped 22 lines
    47 48   }
    48 49  }
    49 50   
     51 +// MustCopyTo copies the braille canvas onto the provided canvas or panics.
     52 +func MustCopyTo(bc *braille.Canvas, dst *canvas.Canvas) {
     53 + if err := bc.CopyTo(dst); err != nil {
     54 + panic(fmt.Sprintf("bc.CopyTo => unexpected error: %v", err))
     55 + }
     56 +}
     57 + 
  • ■ ■ ■ ■ ■ ■
    draw/hv_line.go
    skipped 70 lines
    71 71   
    72 72  // HVLine represents one horizontal or vertical line.
    73 73  type HVLine struct {
    74  - // start is the cell where the line starts.
    75  - start image.Point
    76  - // end is the cell where the line ends.
    77  - end image.Point
     74 + // Start is the cell where the line starts.
     75 + Start image.Point
     76 + // End is the cell where the line ends.
     77 + End image.Point
    78 78  }
    79 79   
    80 80  // HVLines draws horizontal or vertical lines. Handles drawing of the correct
    skipped 9 lines
    90 90   
    91 91   g := newHVLineGraph()
    92 92   for _, l := range lines {
    93  - line, err := newHVLine(c, l.start, l.end, opt)
     93 + line, err := newHVLine(c, l.Start, l.End, opt)
    94 94   if err != nil {
    95 95   return err
    96 96   }
    skipped 111 lines
  • ■ ■ ■ ■ ■ ■
    draw/hv_line_graph_test.go
    skipped 35 lines
    36 36   desc: "single-edge nodes only",
    37 37   lines: []HVLine{
    38 38   {
    39  - start: image.Point{0, 0},
    40  - end: image.Point{0, 1},
     39 + Start: image.Point{0, 0},
     40 + End: image.Point{0, 1},
    41 41   },
    42 42   {
    43  - start: image.Point{1, 0},
    44  - end: image.Point{1, 1},
     43 + Start: image.Point{1, 0},
     44 + End: image.Point{1, 1},
    45 45   },
    46 46   },
    47 47   },
    skipped 1 lines
    49 49   desc: "lines don't cross",
    50 50   lines: []HVLine{
    51 51   {
    52  - start: image.Point{0, 0},
    53  - end: image.Point{0, 2},
     52 + Start: image.Point{0, 0},
     53 + End: image.Point{0, 2},
    54 54   },
    55 55   {
    56  - start: image.Point{1, 0},
    57  - end: image.Point{1, 2},
     56 + Start: image.Point{1, 0},
     57 + End: image.Point{1, 2},
    58 58   },
    59 59   },
    60 60   want: []*hVLineNode{
    skipped 17 lines
    78 78   desc: "lines cross, node has two edges",
    79 79   lines: []HVLine{
    80 80   {
    81  - start: image.Point{0, 0},
    82  - end: image.Point{0, 1},
     81 + Start: image.Point{0, 0},
     82 + End: image.Point{0, 1},
    83 83   },
    84 84   {
    85  - start: image.Point{0, 0},
    86  - end: image.Point{1, 0},
     85 + Start: image.Point{0, 0},
     86 + End: image.Point{1, 0},
    87 87   },
    88 88   },
    89 89   want: []*hVLineNode{
    skipped 10 lines
    100 100   desc: "lines cross, node has three edges",
    101 101   lines: []HVLine{
    102 102   {
    103  - start: image.Point{0, 0},
    104  - end: image.Point{0, 2},
     103 + Start: image.Point{0, 0},
     104 + End: image.Point{0, 2},
    105 105   },
    106 106   {
    107  - start: image.Point{0, 1},
    108  - end: image.Point{1, 1},
     107 + Start: image.Point{0, 1},
     108 + End: image.Point{1, 1},
    109 109   },
    110 110   },
    111 111   want: []*hVLineNode{
    skipped 11 lines
    123 123   desc: "lines cross, node has four edges",
    124 124   lines: []HVLine{
    125 125   {
    126  - start: image.Point{1, 0},
    127  - end: image.Point{1, 2},
     126 + Start: image.Point{1, 0},
     127 + End: image.Point{1, 2},
    128 128   },
    129 129   {
    130  - start: image.Point{0, 1},
    131  - end: image.Point{2, 1},
     130 + Start: image.Point{0, 1},
     131 + End: image.Point{2, 1},
    132 132   },
    133 133   },
    134 134   want: []*hVLineNode{
    skipped 19 lines
    154 154   
    155 155   g := newHVLineGraph()
    156 156   for i, l := range tc.lines {
    157  - line, err := newHVLine(c, l.start, l.end, newHVLineOptions())
     157 + line, err := newHVLine(c, l.Start, l.End, newHVLineOptions())
    158 158   if err != nil {
    159 159   t.Fatalf("newHVLine[%d] => unexpected error: %v", i, err)
    160 160   }
    skipped 216 lines
  • ■ ■ ■ ■ ■ ■
    draw/hv_line_test.go
    skipped 37 lines
    38 38   canvas: image.Rect(0, 0, 2, 2),
    39 39   lines: []HVLine{
    40 40   {
    41  - start: image.Point{0, 0},
    42  - end: image.Point{1, 1},
     41 + Start: image.Point{0, 0},
     42 + End: image.Point{1, 1},
    43 43   },
    44 44   },
    45 45   want: func(size image.Point) *faketerm.Terminal {
    skipped 6 lines
    52 52   canvas: image.Rect(0, 0, 1, 1),
    53 53   lines: []HVLine{
    54 54   {
    55  - start: image.Point{2, 0},
    56  - end: image.Point{0, 0},
     55 + Start: image.Point{2, 0},
     56 + End: image.Point{0, 0},
    57 57   },
    58 58   },
    59 59   want: func(size image.Point) *faketerm.Terminal {
    skipped 6 lines
    66 66   canvas: image.Rect(0, 0, 1, 1),
    67 67   lines: []HVLine{
    68 68   {
    69  - start: image.Point{0, 0},
    70  - end: image.Point{0, 2},
     69 + Start: image.Point{0, 0},
     70 + End: image.Point{0, 2},
    71 71   },
    72 72   },
    73 73   want: func(size image.Point) *faketerm.Terminal {
    skipped 6 lines
    80 80   canvas: image.Rect(0, 0, 1, 1),
    81 81   lines: []HVLine{
    82 82   {
    83  - start: image.Point{0, 0},
    84  - end: image.Point{0, 0},
     83 + Start: image.Point{0, 0},
     84 + End: image.Point{0, 0},
    85 85   },
    86 86   },
    87 87   want: func(size image.Point) *faketerm.Terminal {
    skipped 6 lines
    94 94   canvas: image.Rect(0, 0, 3, 1),
    95 95   lines: []HVLine{
    96 96   {
    97  - start: image.Point{0, 0},
    98  - end: image.Point{2, 0},
     97 + Start: image.Point{0, 0},
     98 + End: image.Point{2, 0},
    99 99   },
    100 100   },
    101 101   want: func(size image.Point) *faketerm.Terminal {
    skipped 14 lines
    116 116   canvas: image.Rect(0, 0, 3, 1),
    117 117   lines: []HVLine{
    118 118   {
    119  - start: image.Point{0, 0},
    120  - end: image.Point{2, 0},
     119 + Start: image.Point{0, 0},
     120 + End: image.Point{2, 0},
    121 121   },
    122 122   },
    123 123   opts: []HVLineOption{
    skipped 17 lines
    141 141   canvas: image.Rect(0, 0, 3, 1),
    142 142   lines: []HVLine{
    143 143   {
    144  - start: image.Point{0, 0},
    145  - end: image.Point{2, 0},
     144 + Start: image.Point{0, 0},
     145 + End: image.Point{2, 0},
    146 146   },
    147 147   },
    148 148   opts: []HVLineOption{
    skipped 29 lines
    178 178   canvas: image.Rect(0, 0, 3, 1),
    179 179   lines: []HVLine{
    180 180   {
    181  - start: image.Point{1, 0},
    182  - end: image.Point{0, 0},
     181 + Start: image.Point{1, 0},
     182 + End: image.Point{0, 0},
    183 183   },
    184 184   },
    185 185   want: func(size image.Point) *faketerm.Terminal {
    skipped 13 lines
    199 199   canvas: image.Rect(0, 0, 3, 3),
    200 200   lines: []HVLine{
    201 201   {
    202  - start: image.Point{1, 0},
    203  - end: image.Point{1, 2},
     202 + Start: image.Point{1, 0},
     203 + End: image.Point{1, 2},
    204 204   },
    205 205   },
    206 206   want: func(size image.Point) *faketerm.Terminal {
    skipped 14 lines
    221 221   canvas: image.Rect(0, 0, 3, 3),
    222 222   lines: []HVLine{
    223 223   {
    224  - start: image.Point{1, 1},
    225  - end: image.Point{1, 0},
     224 + Start: image.Point{1, 1},
     225 + End: image.Point{1, 0},
    226 226   },
    227 227   },
    228 228   want: func(size image.Point) *faketerm.Terminal {
    skipped 13 lines
    242 242   canvas: image.Rect(0, 0, 3, 3),
    243 243   lines: []HVLine{
    244 244   {
    245  - start: image.Point{0, 0},
    246  - end: image.Point{2, 0},
     245 + Start: image.Point{0, 0},
     246 + End: image.Point{2, 0},
    247 247   },
    248 248   {
    249  - start: image.Point{0, 1},
    250  - end: image.Point{2, 1},
     249 + Start: image.Point{0, 1},
     250 + End: image.Point{2, 1},
    251 251   },
    252 252   },
    253 253   want: func(size image.Point) *faketerm.Terminal {
    skipped 18 lines
    272 272   canvas: image.Rect(0, 0, 3, 3),
    273 273   lines: []HVLine{
    274 274   {
    275  - start: image.Point{0, 0},
    276  - end: image.Point{0, 2},
     275 + Start: image.Point{0, 0},
     276 + End: image.Point{0, 2},
    277 277   },
    278 278   {
    279  - start: image.Point{1, 0},
    280  - end: image.Point{1, 2},
     279 + Start: image.Point{1, 0},
     280 + End: image.Point{1, 2},
    281 281   },
    282 282   },
    283 283   want: func(size image.Point) *faketerm.Terminal {
    skipped 18 lines
    302 302   canvas: image.Rect(0, 0, 3, 3),
    303 303   lines: []HVLine{
    304 304   {
    305  - start: image.Point{0, 0},
    306  - end: image.Point{0, 2},
     305 + Start: image.Point{0, 0},
     306 + End: image.Point{0, 2},
    307 307   },
    308 308   {
    309  - start: image.Point{1, 1},
    310  - end: image.Point{2, 1},
     309 + Start: image.Point{1, 1},
     310 + End: image.Point{2, 1},
    311 311   },
    312 312   },
    313 313   want: func(size image.Point) *faketerm.Terminal {
    skipped 17 lines
    331 331   canvas: image.Rect(0, 0, 3, 3),
    332 332   lines: []HVLine{
    333 333   {
    334  - start: image.Point{0, 0},
    335  - end: image.Point{0, 2},
     334 + Start: image.Point{0, 0},
     335 + End: image.Point{0, 2},
    336 336   },
    337 337   {
    338  - start: image.Point{0, 0},
    339  - end: image.Point{2, 0},
     338 + Start: image.Point{0, 0},
     339 + End: image.Point{2, 0},
    340 340   },
    341 341   },
    342 342   want: func(size image.Point) *faketerm.Terminal {
    skipped 17 lines
    360 360   canvas: image.Rect(0, 0, 3, 3),
    361 361   lines: []HVLine{
    362 362   {
    363  - start: image.Point{2, 0},
    364  - end: image.Point{2, 2},
     363 + Start: image.Point{2, 0},
     364 + End: image.Point{2, 2},
    365 365   },
    366 366   {
    367  - start: image.Point{0, 0},
    368  - end: image.Point{2, 0},
     367 + Start: image.Point{0, 0},
     368 + End: image.Point{2, 0},
    369 369   },
    370 370   },
    371 371   want: func(size image.Point) *faketerm.Terminal {
    skipped 17 lines
    389 389   canvas: image.Rect(0, 0, 3, 3),
    390 390   lines: []HVLine{
    391 391   {
    392  - start: image.Point{0, 0},
    393  - end: image.Point{0, 2},
     392 + Start: image.Point{0, 0},
     393 + End: image.Point{0, 2},
    394 394   },
    395 395   {
    396  - start: image.Point{0, 2},
    397  - end: image.Point{2, 2},
     396 + Start: image.Point{0, 2},
     397 + End: image.Point{2, 2},
    398 398   },
    399 399   },
    400 400   want: func(size image.Point) *faketerm.Terminal {
    skipped 17 lines
    418 418   canvas: image.Rect(0, 0, 3, 3),
    419 419   lines: []HVLine{
    420 420   {
    421  - start: image.Point{2, 0},
    422  - end: image.Point{2, 2},
     421 + Start: image.Point{2, 0},
     422 + End: image.Point{2, 2},
    423 423   },
    424 424   {
    425  - start: image.Point{0, 2},
    426  - end: image.Point{2, 2},
     425 + Start: image.Point{0, 2},
     426 + End: image.Point{2, 2},
    427 427   },
    428 428   },
    429 429   want: func(size image.Point) *faketerm.Terminal {
    skipped 17 lines
    447 447   canvas: image.Rect(0, 0, 3, 3),
    448 448   lines: []HVLine{
    449 449   {
    450  - start: image.Point{0, 2},
    451  - end: image.Point{2, 2},
     450 + Start: image.Point{0, 2},
     451 + End: image.Point{2, 2},
    452 452   },
    453 453   {
    454  - start: image.Point{1, 0},
    455  - end: image.Point{1, 2},
     454 + Start: image.Point{1, 0},
     455 + End: image.Point{1, 2},
    456 456   },
    457 457   },
    458 458   want: func(size image.Point) *faketerm.Terminal {
    skipped 17 lines
    476 476   canvas: image.Rect(0, 0, 3, 3),
    477 477   lines: []HVLine{
    478 478   {
    479  - start: image.Point{0, 0},
    480  - end: image.Point{2, 0},
     479 + Start: image.Point{0, 0},
     480 + End: image.Point{2, 0},
    481 481   },
    482 482   {
    483  - start: image.Point{1, 0},
    484  - end: image.Point{1, 2},
     483 + Start: image.Point{1, 0},
     484 + End: image.Point{1, 2},
    485 485   },
    486 486   },
    487 487   want: func(size image.Point) *faketerm.Terminal {
    skipped 17 lines
    505 505   canvas: image.Rect(0, 0, 3, 3),
    506 506   lines: []HVLine{
    507 507   {
    508  - start: image.Point{0, 1},
    509  - end: image.Point{2, 1},
     508 + Start: image.Point{0, 1},
     509 + End: image.Point{2, 1},
    510 510   },
    511 511   {
    512  - start: image.Point{2, 0},
    513  - end: image.Point{2, 2},
     512 + Start: image.Point{2, 0},
     513 + End: image.Point{2, 2},
    514 514   },
    515 515   },
    516 516   want: func(size image.Point) *faketerm.Terminal {
    skipped 17 lines
    534 534   canvas: image.Rect(0, 0, 3, 3),
    535 535   lines: []HVLine{
    536 536   {
    537  - start: image.Point{0, 1},
    538  - end: image.Point{2, 1},
     537 + Start: image.Point{0, 1},
     538 + End: image.Point{2, 1},
    539 539   },
    540 540   {
    541  - start: image.Point{0, 0},
    542  - end: image.Point{0, 2},
     541 + Start: image.Point{0, 0},
     542 + End: image.Point{0, 2},
    543 543   },
    544 544   },
    545 545   want: func(size image.Point) *faketerm.Terminal {
    skipped 17 lines
    563 563   canvas: image.Rect(0, 0, 3, 3),
    564 564   lines: []HVLine{
    565 565   {
    566  - start: image.Point{0, 1},
    567  - end: image.Point{2, 1},
     566 + Start: image.Point{0, 1},
     567 + End: image.Point{2, 1},
    568 568   },
    569 569   {
    570  - start: image.Point{1, 0},
    571  - end: image.Point{1, 2},
     570 + Start: image.Point{1, 0},
     571 + End: image.Point{1, 2},
    572 572   },
    573 573   },
    574 574   want: func(size image.Point) *faketerm.Terminal {
    skipped 18 lines
    593 593   lines: []HVLine{
    594 594   // Three horizontal lines.
    595 595   {
    596  - start: image.Point{0, 0},
    597  - end: image.Point{2, 0},
     596 + Start: image.Point{0, 0},
     597 + End: image.Point{2, 0},
    598 598   },
    599 599   {
    600  - start: image.Point{0, 1},
    601  - end: image.Point{2, 1},
     600 + Start: image.Point{0, 1},
     601 + End: image.Point{2, 1},
    602 602   },
    603 603   {
    604  - start: image.Point{0, 2},
    605  - end: image.Point{2, 2},
     604 + Start: image.Point{0, 2},
     605 + End: image.Point{2, 2},
    606 606   },
    607 607   // Three vertical lines.
    608 608   {
    609  - start: image.Point{0, 0},
    610  - end: image.Point{0, 2},
     609 + Start: image.Point{0, 0},
     610 + End: image.Point{0, 2},
    611 611   },
    612 612   {
    613  - start: image.Point{1, 0},
    614  - end: image.Point{1, 2},
     613 + Start: image.Point{1, 0},
     614 + End: image.Point{1, 2},
    615 615   },
    616 616   {
    617  - start: image.Point{2, 0},
    618  - end: image.Point{2, 2},
     617 + Start: image.Point{2, 0},
     618 + End: image.Point{2, 2},
    619 619   },
    620 620   },
    621 621   want: func(size image.Point) *faketerm.Terminal {
    skipped 53 lines
  • ■ ■ ■ ■ ■ ■
    draw/testdraw/testdraw.go
    skipped 19 lines
    20 20   "image"
    21 21   
    22 22   "github.com/mum4k/termdash/canvas"
     23 + "github.com/mum4k/termdash/canvas/braille"
    23 24   "github.com/mum4k/termdash/draw"
    24 25  )
    25 26   
    skipped 25 lines
    51 52   }
    52 53  }
    53 54   
     55 +// MustBrailleLine draws the braille line or panics.
     56 +func MustBrailleLine(bc *braille.Canvas, start, end image.Point, opts ...draw.BrailleLineOption) {
     57 + if err := draw.BrailleLine(bc, start, end, opts...); err != nil {
     58 + panic(fmt.Sprintf("draw.BrailleLine => unexpected error: %v", err))
     59 + }
     60 +}
     61 + 
  • images/linechartdemo.gif
  • ■ ■ ■ ■ ■ ■
    numbers/numbers.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +// Package numbers implements various numerical functions.
     16 +package numbers
     17 + 
     18 +import (
     19 + "math"
     20 +)
     21 + 
     22 +// RoundToNonZeroPlaces rounds the float up, so that it has at least the provided
     23 +// number of non-zero decimal places.
     24 +// Returns the rounded float and the number of leading decimal places that
     25 +// are zero. Returns the original float when places is zero. Negative places
     26 +// are treated as positive, so that -2 == 2.
     27 +func RoundToNonZeroPlaces(f float64, places int) (float64, int) {
     28 + if f == 0 {
     29 + return 0, 0
     30 + }
     31 + 
     32 + decOnly := zeroBeforeDecimal(f)
     33 + if decOnly == 0 {
     34 + return f, 0
     35 + }
     36 + nzMult := multToNonZero(decOnly)
     37 + if places == 0 {
     38 + return f, multToPlaces(nzMult)
     39 + }
     40 + plMult := placesToMult(places)
     41 + 
     42 + m := float64(nzMult * plMult)
     43 + return math.Ceil(f*m) / m, multToPlaces(nzMult)
     44 +}
     45 + 
     46 +// multToNonZero returns multiplier for the float, so that the first decimal
     47 +// place is non-zero. The float must not be zero.
     48 +func multToNonZero(f float64) int {
     49 + v := f
     50 + if v < 0 {
     51 + v *= -1
     52 + }
     53 + 
     54 + mult := 1
     55 + for v < 0.1 {
     56 + v *= 10
     57 + mult *= 10
     58 + }
     59 + return mult
     60 +}
     61 + 
     62 +// placesToMult translates the number of decimal places to a multiple of 10.
     63 +func placesToMult(places int) int {
     64 + if places < 0 {
     65 + places *= -1
     66 + }
     67 + 
     68 + mult := 1
     69 + for i := 0; i < places; i++ {
     70 + mult *= 10
     71 + }
     72 + return mult
     73 +}
     74 + 
     75 +// multToPlaces translates the multiple of 10 to a number of decimal places.
     76 +func multToPlaces(mult int) int {
     77 + places := 0
     78 + for mult > 1 {
     79 + mult /= 10
     80 + places++
     81 + }
     82 + return places
     83 +}
     84 + 
     85 +// zeroBeforeDecimal modifies the float so that it only has zero value before
     86 +// the decimal point.
     87 +func zeroBeforeDecimal(f float64) float64 {
     88 + var sign float64 = 1
     89 + if f < 0 {
     90 + f *= -1
     91 + sign = -1
     92 + }
     93 + 
     94 + floor := math.Floor(f)
     95 + return (f - floor) * sign
     96 +}
     97 + 
     98 +// Round returns the nearest integer, rounding half away from zero.
     99 +// Copied from the math package of Go 1.10 for backwards compatibility with Go
     100 +// 1.8 where the math.Round function doesn't exist yet.
     101 +func Round(x float64) float64 {
     102 + t := math.Trunc(x)
     103 + if math.Abs(x-t) >= 0.5 {
     104 + return t + math.Copysign(1, x)
     105 + }
     106 + return t
     107 +}
     108 + 
     109 +// MinMax returns the smallest and the largest value among the provided values.
     110 +// Returns (0, 0) if there are no values.
     111 +func MinMax(values []float64) (min, max float64) {
     112 + if len(values) == 0 {
     113 + return 0, 0
     114 + }
     115 + min = math.MaxFloat64
     116 + max = -1 * math.MaxFloat64
     117 + 
     118 + for _, v := range values {
     119 + if v < min {
     120 + min = v
     121 + }
     122 + if v > max {
     123 + max = v
     124 + }
     125 + }
     126 + return min, max
     127 +}
     128 + 
  • ■ ■ ■ ■ ■ ■
    numbers/numbers_test.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package numbers
     16 + 
     17 +import (
     18 + "fmt"
     19 + "math"
     20 + "testing"
     21 +)
     22 + 
     23 +func TestRoundToNonZeroPlaces(t *testing.T) {
     24 + tests := []struct {
     25 + float float64
     26 + places int
     27 + wantFloat float64
     28 + wantPlaces int
     29 + }{
     30 + {0, 0, 0, 0},
     31 + {1.1, 0, 1.1, 0},
     32 + {-1, 1, -1, 0},
     33 + {1, 1, 1, 0},
     34 + {1, 10, 1, 0},
     35 + {1, -1, 1, 0},
     36 + {0.12345, 2, 0.13, 0},
     37 + {0.12345, -2, 0.13, 0},
     38 + {0.12345, 10, 0.12345, 0},
     39 + {0.00012345, 2, 0.00013, 3},
     40 + {0.00012345, 3, 0.000124, 3},
     41 + {0.00012345, 10, 0.00012345, 3},
     42 + {-0.00012345, 10, -0.00012345, 3},
     43 + {1.234567, 2, 1.24, 0},
     44 + {-1.234567, 2, -1.23, 0},
     45 + {1099.0000234567, 3, 1099.0000235, 4},
     46 + {-1099.0000234567, 3, -1099.0000234, 4},
     47 + }
     48 + 
     49 + for _, tc := range tests {
     50 + t.Run(fmt.Sprintf("%v_%v", tc.float, tc.places), func(t *testing.T) {
     51 + gotFloat, gotPlaces := RoundToNonZeroPlaces(tc.float, tc.places)
     52 + if gotFloat != tc.wantFloat || gotPlaces != tc.wantPlaces {
     53 + t.Errorf("RoundToNonZeroPlaces(%v, %d) => (%v, %v), want (%v, %v)", tc.float, tc.places, gotFloat, gotPlaces, tc.wantFloat, tc.wantPlaces)
     54 + }
     55 + })
     56 + }
     57 +}
     58 + 
     59 +func TestZeroBeforeDecimal(t *testing.T) {
     60 + tests := []struct {
     61 + float float64
     62 + want float64
     63 + }{
     64 + {0, 0},
     65 + {-1, 0},
     66 + {1, 0},
     67 + {1.0, 0},
     68 + {1.123, 0.123},
     69 + {-1.123, -0.123},
     70 + }
     71 + 
     72 + for _, tc := range tests {
     73 + t.Run(fmt.Sprint(tc.float), func(t *testing.T) {
     74 + got := zeroBeforeDecimal(tc.float)
     75 + if got != tc.want {
     76 + t.Errorf("zeroBeforeDecimal(%v) => %v, want %v", tc.float, got, tc.want)
     77 + 
     78 + }
     79 + })
     80 + }
     81 +}
     82 + 
     83 +// Copied from the math package of Go 1.10 for backwards compatibility with Go
     84 +// 1.8 where the math.Round function doesn't exist yet.
     85 + 
     86 +func alike(a, b float64) bool {
     87 + switch {
     88 + case math.IsNaN(a) && math.IsNaN(b):
     89 + return true
     90 + case a == b:
     91 + return math.Signbit(a) == math.Signbit(b)
     92 + }
     93 + return false
     94 +}
     95 + 
     96 +var round = []float64{
     97 + 5,
     98 + 8,
     99 + math.Copysign(0, -1),
     100 + -5,
     101 + 10,
     102 + 3,
     103 + 5,
     104 + 3,
     105 + 2,
     106 + -9,
     107 +}
     108 + 
     109 +var vf = []float64{
     110 + 4.9790119248836735e+00,
     111 + 7.7388724745781045e+00,
     112 + -2.7688005719200159e-01,
     113 + -5.0106036182710749e+00,
     114 + 9.6362937071984173e+00,
     115 + 2.9263772392439646e+00,
     116 + 5.2290834314593066e+00,
     117 + 2.7279399104360102e+00,
     118 + 1.8253080916808550e+00,
     119 + -8.6859247685756013e+00,
     120 +}
     121 + 
     122 +var vfroundSC = [][2]float64{
     123 + {0, 0},
     124 + {1.390671161567e-309, 0}, // denormal
     125 + {0.49999999999999994, 0}, // 0.5-epsilon
     126 + {0.5, 1},
     127 + {0.5000000000000001, 1}, // 0.5+epsilon
     128 + {-1.5, -2},
     129 + {-2.5, -3},
     130 + {math.NaN(), math.NaN()},
     131 + {math.Inf(1), math.Inf(1)},
     132 + {2251799813685249.5, 2251799813685250}, // 1 bit fraction
     133 + {2251799813685250.5, 2251799813685251},
     134 + {4503599627370495.5, 4503599627370496}, // 1 bit fraction, rounding to 0 bit fraction
     135 + {4503599627370497, 4503599627370497}, // large integer
     136 +}
     137 + 
     138 +func TestRound(t *testing.T) {
     139 + for i := 0; i < len(vf); i++ {
     140 + if f := Round(vf[i]); !alike(round[i], f) {
     141 + t.Errorf("Round(%g) = %g, want %g", vf[i], f, round[i])
     142 + }
     143 + }
     144 + for i := 0; i < len(vfroundSC); i++ {
     145 + if f := Round(vfroundSC[i][0]); !alike(vfroundSC[i][1], f) {
     146 + t.Errorf("Round(%g) = %g, want %g", vfroundSC[i][0], f, vfroundSC[i][1])
     147 + }
     148 + }
     149 +}
     150 + 
     151 +func TestMinMax(t *testing.T) {
     152 + tests := []struct {
     153 + desc string
     154 + values []float64
     155 + wantMin float64
     156 + wantMax float64
     157 + }{
     158 + {
     159 + desc: "no values",
     160 + },
     161 + {
     162 + desc: "all values the same",
     163 + values: []float64{1.1, 1.1},
     164 + wantMin: 1.1,
     165 + wantMax: 1.1,
     166 + },
     167 + {
     168 + desc: "all values the same and negative",
     169 + values: []float64{-1.1, -1.1},
     170 + wantMin: -1.1,
     171 + wantMax: -1.1,
     172 + },
     173 + {
     174 + desc: "min and max among positive values",
     175 + values: []float64{1.1, 1.2, 1.3},
     176 + wantMin: 1.1,
     177 + wantMax: 1.3,
     178 + },
     179 + {
     180 + desc: "min and max among positive and zero values",
     181 + values: []float64{1.1, 0, 1.3},
     182 + wantMin: 0,
     183 + wantMax: 1.3,
     184 + },
     185 + {
     186 + desc: "min and max among negative, positive and zero values",
     187 + values: []float64{1.1, 0, 1.3, -11.3, 22.5},
     188 + wantMin: -11.3,
     189 + wantMax: 22.5,
     190 + },
     191 + }
     192 + 
     193 + for _, tc := range tests {
     194 + t.Run(tc.desc, func(t *testing.T) {
     195 + gotMin, gotMax := MinMax(tc.values)
     196 + if gotMin != tc.wantMin || gotMax != tc.wantMax {
     197 + t.Errorf("MinMax => (%v, %v), want (%v, %v)", gotMin, gotMax, tc.wantMin, tc.wantMax)
     198 + }
     199 + })
     200 + }
     201 +}
     202 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/axes/axes.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +// Package axes calculates the required layout and draws the X and Y axes of a line chart.
     16 +package axes
     17 + 
     18 +import (
     19 + "fmt"
     20 + "image"
     21 +)
     22 + 
     23 +const (
     24 + // nonZeroDecimals determines the overall precision of values displayed on the
     25 + // graph, it indicates the number of non-zero decimal places the values will be
     26 + // rounded up to.
     27 + nonZeroDecimals = 2
     28 + 
     29 + // yAxisWidth is width of the Y axis.
     30 + yAxisWidth = 1
     31 +)
     32 + 
     33 +// YDetails contain information about the Y axis that will be drawn onto the
     34 +// canvas.
     35 +type YDetails struct {
     36 + // Width in character cells of the Y axis and its character labels.
     37 + Width int
     38 + 
     39 + // Start is the point where the Y axis starts.
     40 + // Both coordinates of Start are less than End.
     41 + Start image.Point
     42 + // End is the point where the Y axis ends.
     43 + End image.Point
     44 + 
     45 + // Scale is the scale of the Y axis.
     46 + Scale *YScale
     47 + 
     48 + // Labels are the labels for values on the Y axis in an increasing order.
     49 + Labels []*Label
     50 +}
     51 + 
     52 +// Y tracks the state of the Y axis throughout the lifetime of a line chart.
     53 +// Implements lazy resize of the axis to decrease visual "jumping".
     54 +// This object is not thread-safe.
     55 +type Y struct {
     56 + // min is the smallest value on the Y axis.
     57 + min *Value
     58 + // max is the largest value on the Y axis.
     59 + max *Value
     60 + // details about the Y axis as it will be drawn.
     61 + details *YDetails
     62 +}
     63 + 
     64 +// NewY returns a new Y instance.
     65 +// The minVal and maxVal represent the minimum and maximum value that will be
     66 +// displayed on the line chart among all of the series.
     67 +func NewY(minVal, maxVal float64) *Y {
     68 + y := &Y{}
     69 + y.Update(minVal, maxVal)
     70 + return y
     71 +}
     72 + 
     73 +// Update updates the stored minVal and maxVal.
     74 +func (y *Y) Update(minVal, maxVal float64) {
     75 + y.min, y.max = NewValue(minVal, nonZeroDecimals), NewValue(maxVal, nonZeroDecimals)
     76 +}
     77 + 
     78 +// RequiredWidth calculates the minimum width required in order to draw the Y axis.
     79 +func (y *Y) RequiredWidth() int {
     80 + // This is an estimation only, it is possible that more labels in the
     81 + // middle will be generated and might be wider than this. Such cases are
     82 + // handled on the call to Details when the size of canvas is known.
     83 + return widestLabel([]*Label{
     84 + {Value: y.min},
     85 + {Value: y.max},
     86 + }) + yAxisWidth
     87 +}
     88 + 
     89 +// Details retrieves details about the Y axis required to draw it on a canvas
     90 +// of the provided area.
     91 +func (y *Y) Details(cvsAr image.Rectangle) (*YDetails, error) {
     92 + cvsWidth := cvsAr.Dx()
     93 + cvsHeight := cvsAr.Dy()
     94 + maxWidth := cvsWidth - 1 // Reserve one row for the line chart itself.
     95 + if req := y.RequiredWidth(); maxWidth < req {
     96 + return nil, fmt.Errorf("the received maxWidth %d is smaller than the reported required width %d", maxWidth, req)
     97 + }
     98 + 
     99 + graphHeight := cvsHeight - 2 // One row for the X axis and one for its labels.
     100 + scale, err := NewYScale(y.min.Value, y.max.Value, graphHeight, nonZeroDecimals)
     101 + if err != nil {
     102 + return nil, err
     103 + }
     104 + 
     105 + // See how the labels would look like on the entire maxWidth.
     106 + maxLabelWidth := maxWidth - yAxisWidth
     107 + labels, err := yLabels(scale, maxLabelWidth)
     108 + if err != nil {
     109 + return nil, err
     110 + }
     111 + 
     112 + var width int
     113 + // Determine the largest label, which might be less than maxWidth.
     114 + // Such case would allow us to save more space for the line chart itself.
     115 + widest := widestLabel(labels)
     116 + if widest < maxLabelWidth {
     117 + // Save the space and recalculate the labels, since they need to be realigned.
     118 + l, err := yLabels(scale, widest)
     119 + if err != nil {
     120 + return nil, err
     121 + }
     122 + labels = l
     123 + width = widest + yAxisWidth // One for the axis itself.
     124 + } else {
     125 + width = maxWidth
     126 + }
     127 + 
     128 + return &YDetails{
     129 + Width: width,
     130 + Start: image.Point{width - 1, 0},
     131 + End: image.Point{width - 1, graphHeight},
     132 + Scale: scale,
     133 + Labels: labels,
     134 + }, nil
     135 +}
     136 + 
     137 +// widestLabel returns the width of the widest label.
     138 +func widestLabel(labels []*Label) int {
     139 + var widest int
     140 + for _, label := range labels {
     141 + if l := len(label.Value.Text()); l > widest {
     142 + widest = l
     143 + }
     144 + }
     145 + return widest
     146 +}
     147 + 
     148 +// XDetails contain information about the X axis that will be drawn onto the
     149 +// canvas.
     150 +type XDetails struct {
     151 + // Start is the point where the X axis starts.
     152 + // Both coordinates of Start are less than End.
     153 + Start image.Point
     154 + // End is the point where the X axis ends.
     155 + End image.Point
     156 + 
     157 + // Scale is the scale of the X axis.
     158 + Scale *XScale
     159 + 
     160 + // Labels are the labels for values on the X axis in an increasing order.
     161 + Labels []*Label
     162 +}
     163 + 
     164 +// NewXDetails retrieves details about the X axis required to draw it on a canvas
     165 +// of the provided area. The yStart is the point where the Y axis starts.
     166 +// The numPoints is the number of points in the largest series that will be
     167 +// plotted.
     168 +// customLabels are the desired labels for the X axis, these are preferred if
     169 +// provided.
     170 +func NewXDetails(numPoints int, yStart image.Point, cvsAr image.Rectangle, customLabels map[int]string) (*XDetails, error) {
     171 + if min := 3; cvsAr.Dy() < min {
     172 + 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)
     173 + }
     174 + 
     175 + // The space between the start of the axis and the end of the canvas.
     176 + graphWidth := cvsAr.Dx() - yStart.X - 1
     177 + scale, err := NewXScale(numPoints, graphWidth, nonZeroDecimals)
     178 + if err != nil {
     179 + return nil, err
     180 + }
     181 + 
     182 + // One point horizontally for the Y axis.
     183 + // Two points vertically, one for the X axis and one for its labels.
     184 + graphZero := image.Point{yStart.X + 1, cvsAr.Dy() - 3}
     185 + labels, err := xLabels(scale, graphZero, customLabels)
     186 + if err != nil {
     187 + return nil, err
     188 + }
     189 + return &XDetails{
     190 + Start: image.Point{yStart.X, cvsAr.Dy() - 2}, // One row for the labels.
     191 + End: image.Point{yStart.X + graphWidth, cvsAr.Dy() - 2},
     192 + Scale: scale,
     193 + Labels: labels,
     194 + }, nil
     195 +}
     196 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/axes/axes_test.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package axes
     16 + 
     17 +import (
     18 + "image"
     19 + "testing"
     20 + 
     21 + "github.com/kylelemons/godebug/pretty"
     22 +)
     23 + 
     24 +type updateY struct {
     25 + minVal float64
     26 + maxVal float64
     27 +}
     28 + 
     29 +func TestY(t *testing.T) {
     30 + tests := []struct {
     31 + desc string
     32 + minVal float64
     33 + maxVal float64
     34 + update *updateY
     35 + cvsAr image.Rectangle
     36 + wantWidth int
     37 + want *YDetails
     38 + wantErr bool
     39 + }{
     40 + {
     41 + desc: "fails on canvas too small",
     42 + minVal: 0,
     43 + maxVal: 3,
     44 + cvsAr: image.Rect(0, 0, 3, 2),
     45 + wantWidth: 2,
     46 + wantErr: true,
     47 + },
     48 + {
     49 + desc: "fails on cvsWidth less than required width",
     50 + minVal: 0,
     51 + maxVal: 3,
     52 + cvsAr: image.Rect(0, 0, 2, 4),
     53 + wantWidth: 2,
     54 + wantErr: true,
     55 + },
     56 + {
     57 + desc: "fails when max is less than min",
     58 + minVal: 0,
     59 + maxVal: -1,
     60 + cvsAr: image.Rect(0, 0, 4, 4),
     61 + wantWidth: 3,
     62 + wantErr: true,
     63 + },
     64 + {
     65 + desc: "cvsWidth equals required width",
     66 + minVal: 0,
     67 + maxVal: 3,
     68 + cvsAr: image.Rect(0, 0, 3, 4),
     69 + wantWidth: 2,
     70 + want: &YDetails{
     71 + Width: 2,
     72 + Start: image.Point{1, 0},
     73 + End: image.Point{1, 2},
     74 + Scale: mustNewYScale(0, 3, 2, nonZeroDecimals),
     75 + Labels: []*Label{
     76 + {NewValue(0, nonZeroDecimals), image.Point{0, 1}},
     77 + {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}},
     78 + },
     79 + },
     80 + },
     81 + {
     82 + desc: "cvsWidth just accommodates the longest label",
     83 + minVal: 0,
     84 + maxVal: 3,
     85 + cvsAr: image.Rect(0, 0, 6, 4),
     86 + wantWidth: 2,
     87 + want: &YDetails{
     88 + Width: 5,
     89 + Start: image.Point{4, 0},
     90 + End: image.Point{4, 2},
     91 + Scale: mustNewYScale(0, 3, 2, nonZeroDecimals),
     92 + Labels: []*Label{
     93 + {NewValue(0, nonZeroDecimals), image.Point{3, 1}},
     94 + {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}},
     95 + },
     96 + },
     97 + },
     98 + {
     99 + desc: "cvsWidth is more than we need",
     100 + minVal: 0,
     101 + maxVal: 3,
     102 + cvsAr: image.Rect(0, 0, 7, 4),
     103 + wantWidth: 2,
     104 + want: &YDetails{
     105 + Width: 5,
     106 + Start: image.Point{4, 0},
     107 + End: image.Point{4, 2},
     108 + Scale: mustNewYScale(0, 3, 2, nonZeroDecimals),
     109 + Labels: []*Label{
     110 + {NewValue(0, nonZeroDecimals), image.Point{3, 1}},
     111 + {NewValue(1.72, nonZeroDecimals), image.Point{0, 0}},
     112 + },
     113 + },
     114 + },
     115 + }
     116 + 
     117 + for _, tc := range tests {
     118 + t.Run(tc.desc, func(t *testing.T) {
     119 + y := NewY(tc.minVal, tc.maxVal)
     120 + if tc.update != nil {
     121 + y.Update(tc.update.minVal, tc.update.maxVal)
     122 + }
     123 + 
     124 + gotWidth := y.RequiredWidth()
     125 + if gotWidth != tc.wantWidth {
     126 + t.Errorf("RequiredWidth => got %v, want %v", gotWidth, tc.wantWidth)
     127 + }
     128 + 
     129 + got, err := y.Details(tc.cvsAr)
     130 + if (err != nil) != tc.wantErr {
     131 + t.Errorf("Details => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     132 + }
     133 + if err != nil {
     134 + return
     135 + }
     136 + if diff := pretty.Compare(tc.want, got); diff != "" {
     137 + t.Errorf("Details => unexpected diff (-want, +got):\n%s", diff)
     138 + }
     139 + })
     140 + }
     141 +}
     142 + 
     143 +func TestNewXDetails(t *testing.T) {
     144 + tests := []struct {
     145 + desc string
     146 + numPoints int
     147 + yStart image.Point
     148 + cvsWidth int
     149 + cvsAr image.Rectangle
     150 + customLabels map[int]string
     151 + want *XDetails
     152 + wantErr bool
     153 + }{
     154 + {
     155 + desc: "fails when numPoints is negative",
     156 + numPoints: -1,
     157 + yStart: image.Point{0, 0},
     158 + cvsAr: image.Rect(0, 0, 2, 3),
     159 + wantErr: true,
     160 + },
     161 + {
     162 + desc: "fails when cvsAr isn't wide enough",
     163 + numPoints: 1,
     164 + yStart: image.Point{0, 0},
     165 + cvsAr: image.Rect(0, 0, 1, 3),
     166 + wantErr: true,
     167 + },
     168 + {
     169 + desc: "fails when cvsAr isn't tall enough",
     170 + numPoints: 1,
     171 + yStart: image.Point{0, 0},
     172 + cvsAr: image.Rect(0, 0, 3, 2),
     173 + wantErr: true,
     174 + },
     175 + {
     176 + desc: "works with no data points",
     177 + numPoints: 0,
     178 + yStart: image.Point{0, 0},
     179 + cvsAr: image.Rect(0, 0, 2, 3),
     180 + want: &XDetails{
     181 + Start: image.Point{0, 1},
     182 + End: image.Point{1, 1},
     183 + Scale: mustNewXScale(0, 1, nonZeroDecimals),
     184 + Labels: []*Label{
     185 + {
     186 + Value: NewValue(0, nonZeroDecimals),
     187 + Pos: image.Point{1, 2},
     188 + },
     189 + },
     190 + },
     191 + },
     192 + {
     193 + desc: "accounts for non-zero yStart",
     194 + numPoints: 0,
     195 + yStart: image.Point{2, 0},
     196 + cvsAr: image.Rect(0, 0, 4, 5),
     197 + want: &XDetails{
     198 + Start: image.Point{2, 3},
     199 + End: image.Point{3, 3},
     200 + Scale: mustNewXScale(0, 1, nonZeroDecimals),
     201 + Labels: []*Label{
     202 + {
     203 + Value: NewValue(0, nonZeroDecimals),
     204 + Pos: image.Point{3, 4},
     205 + },
     206 + },
     207 + },
     208 + },
     209 + }
     210 + 
     211 + for _, tc := range tests {
     212 + t.Run(tc.desc, func(t *testing.T) {
     213 + got, err := NewXDetails(tc.numPoints, tc.yStart, tc.cvsAr, tc.customLabels)
     214 + if (err != nil) != tc.wantErr {
     215 + t.Errorf("NewXDetails => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     216 + }
     217 + if err != nil {
     218 + return
     219 + }
     220 + 
     221 + if diff := pretty.Compare(tc.want, got); diff != "" {
     222 + t.Errorf("NewXDetails => unexpected diff (-want, +got):\n%s", diff)
     223 + }
     224 + })
     225 + }
     226 +}
     227 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/axes/label.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package axes
     16 + 
     17 +// label.go contains code that calculates the positions of labels on the axes.
     18 + 
     19 +import (
     20 + "fmt"
     21 + "image"
     22 + 
     23 + "github.com/mum4k/termdash/align"
     24 +)
     25 + 
     26 +// Label is one value label on an axis.
     27 +type Label struct {
     28 + // Value if the value to be displayed.
     29 + Value *Value
     30 + 
     31 + // Position of the label within the canvas.
     32 + Pos image.Point
     33 +}
     34 + 
     35 +// yLabels returns labels that should be placed next to the Y axis.
     36 +// The labelWidth is the width of the area from the left-most side of the
     37 +// canvas until the Y axis (not including the Y axis). This is the area where
     38 +// the labels will be placed and aligned.
     39 +// Labels are returned in an increasing value order.
     40 +// Label value is not trimmed to the provided labelWidth, the label width is
     41 +// only used to align the labels. Alignment is done with the assumption that
     42 +// longer labels will be trimmed.
     43 +func yLabels(scale *YScale, labelWidth int) ([]*Label, error) {
     44 + if min := 2; scale.GraphHeight < min {
     45 + return nil, fmt.Errorf("cannot place labels on a canvas with height %d, minimum is %d", scale.GraphHeight, min)
     46 + }
     47 + if min := 1; labelWidth < min {
     48 + return nil, fmt.Errorf("cannot place labels in label area width %d, minimum is %d", labelWidth, min)
     49 + }
     50 + 
     51 + var labels []*Label
     52 + const labelSpacing = 4
     53 + seen := map[string]bool{}
     54 + for y := scale.GraphHeight - 1; y >= 0; y -= labelSpacing {
     55 + label, err := rowLabel(scale, y, labelWidth)
     56 + if err != nil {
     57 + return nil, err
     58 + }
     59 + if !seen[label.Value.Text()] {
     60 + labels = append(labels, label)
     61 + seen[label.Value.Text()] = true
     62 + }
     63 + }
     64 + 
     65 + // If we have data, place at least two labels, first and last.
     66 + haveData := scale.Min.Rounded != 0 || scale.Max.Rounded != 0
     67 + if len(labels) < 2 && haveData {
     68 + const maxRow = 0
     69 + label, err := rowLabel(scale, maxRow, labelWidth)
     70 + if err != nil {
     71 + return nil, err
     72 + }
     73 + labels = append(labels, label)
     74 + }
     75 + return labels, nil
     76 +}
     77 + 
     78 +// rowLabelArea determines the area available for labels on the specified row.
     79 +// The row is the Y coordinate of the row, Y coordinates grow down.
     80 +func rowLabelArea(row int, labelWidth int) image.Rectangle {
     81 + return image.Rect(0, row, labelWidth, row+1)
     82 +}
     83 + 
     84 +// rowLabel returns label for the specified row.
     85 +func rowLabel(scale *YScale, y int, labelWidth int) (*Label, error) {
     86 + v, err := scale.CellLabel(y)
     87 + if err != nil {
     88 + return nil, fmt.Errorf("unable to determine label value for row %d: %v", y, err)
     89 + }
     90 + 
     91 + ar := rowLabelArea(y, labelWidth)
     92 + pos, err := align.Text(ar, v.Text(), align.HorizontalRight, align.VerticalMiddle)
     93 + return &Label{
     94 + Value: v,
     95 + Pos: pos,
     96 + }, nil
     97 +}
     98 + 
     99 +// xSpace represents an available space among the X axis.
     100 +type xSpace struct {
     101 + // min is the current relative coordinate.
     102 + // These are zero based, i.e. not adjusted to axisStart.
     103 + cur int
     104 + // max is the maximum relative coordinate.
     105 + // These are zero based, i.e. not adjusted to axisStart.
     106 + // The xSpace instance contains points 0 <= x < max
     107 + max int
     108 + 
     109 + // graphZero is the (0, 0) point on the graph.
     110 + graphZero image.Point
     111 +}
     112 + 
     113 +// newXSpace returns a new xSpace instance initialized for the provided width.
     114 +func newXSpace(graphZero image.Point, graphWidth int) *xSpace {
     115 + return &xSpace{
     116 + cur: 0,
     117 + max: graphWidth,
     118 + graphZero: graphZero,
     119 + }
     120 +}
     121 + 
     122 +// Implements fmt.Stringer.
     123 +func (xs *xSpace) String() string {
     124 + 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})
     125 +}
     126 + 
     127 +// Remaining returns the remaining size on the X axis.
     128 +func (xs *xSpace) Remaining() int {
     129 + return xs.max - xs.cur
     130 +}
     131 + 
     132 +// Relative returns the relative coordinate within the space, these are zero
     133 +// based.
     134 +func (xs *xSpace) Relative() image.Point {
     135 + return image.Point{xs.cur, xs.graphZero.Y + 1}
     136 +}
     137 + 
     138 +// LabelPos returns the absolute coordinate on the canvas where a label should
     139 +// be placed. The is the coordinate that represents the current relative
     140 +// coordinate of the space.
     141 +func (xs *xSpace) LabelPos() image.Point {
     142 + return image.Point{xs.cur + xs.graphZero.X, xs.graphZero.Y + 2} // First down is the axis, second the label.
     143 +}
     144 + 
     145 +// Sub subtracts the specified size from the beginning of the available
     146 +// space.
     147 +func (xs *xSpace) Sub(size int) error {
     148 + if xs.Remaining() < size {
     149 + return fmt.Errorf("unable to subtract %d from the start, not enough size in %v", size, xs)
     150 + }
     151 + xs.cur += size
     152 + return nil
     153 +}
     154 + 
     155 +// xLabels returns labels that should be placed under the X axis.
     156 +// The graphZero is the (0, 0) point of the graph area on the canvas.
     157 +// Labels are returned in an increasing value order.
     158 +// Returned labels shouldn't be trimmed, their count is adjusted so that they
     159 +// fit under the width of the axis.
     160 +// The customLabels map value positions in the series to the desired custom
     161 +// label. These are preferred if present.
     162 +func xLabels(scale *XScale, graphZero image.Point, customLabels map[int]string) ([]*Label, error) {
     163 + space := newXSpace(graphZero, scale.GraphWidth)
     164 + const minSpacing = 3
     165 + var res []*Label
     166 + 
     167 + next := 0
     168 + for haveLabels := 0; haveLabels <= int(scale.Max.Value); haveLabels = len(res) {
     169 + label, err := colLabel(scale, space, next, customLabels)
     170 + if err != nil {
     171 + return nil, err
     172 + }
     173 + if label == nil {
     174 + break
     175 + }
     176 + res = append(res, label)
     177 + 
     178 + next++
     179 + if next > int(scale.Max.Value) {
     180 + break
     181 + }
     182 + nextCell, err := scale.ValueToCell(next)
     183 + if err != nil {
     184 + return nil, err
     185 + }
     186 + 
     187 + skip := nextCell - space.Relative().X
     188 + if skip < minSpacing {
     189 + skip = minSpacing
     190 + }
     191 + 
     192 + if space.Remaining() <= skip {
     193 + break
     194 + }
     195 + if err := space.Sub(skip); err != nil {
     196 + return nil, err
     197 + }
     198 + }
     199 + return res, nil
     200 +}
     201 + 
     202 +// colLabel returns a label placed either at the beginning of the space.
     203 +// The space is adjusted according to how much space was taken by the label.
     204 +// Returns nil, nil if the label doesn't fit in the space.
     205 +func colLabel(scale *XScale, space *xSpace, labelNum int, customLabels map[int]string) (*Label, error) {
     206 + var val *Value
     207 + if custom, ok := customLabels[labelNum]; ok {
     208 + val = NewTextValue(custom)
     209 + } else {
     210 + pos := space.Relative()
     211 + v, err := scale.CellLabel(pos.X)
     212 + if err != nil {
     213 + return nil, fmt.Errorf("unable to determine label value for column %d: %v", pos.X, err)
     214 + }
     215 + val = v
     216 + }
     217 + 
     218 + labelLen := len(val.Text())
     219 + if labelLen > space.Remaining() {
     220 + return nil, nil
     221 + }
     222 + 
     223 + abs := space.LabelPos()
     224 + if err := space.Sub(labelLen); err != nil {
     225 + return nil, err
     226 + }
     227 + 
     228 + return &Label{
     229 + Value: val,
     230 + Pos: abs,
     231 + }, nil
     232 +}
     233 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/axes/label_test.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package axes
     16 + 
     17 +import (
     18 + "image"
     19 + "testing"
     20 + 
     21 + "github.com/kylelemons/godebug/pretty"
     22 +)
     23 + 
     24 +func TestYLabels(t *testing.T) {
     25 + const nonZeroDecimals = 2
     26 + tests := []struct {
     27 + desc string
     28 + min float64
     29 + max float64
     30 + graphHeight int
     31 + labelWidth int
     32 + want []*Label
     33 + wantErr bool
     34 + }{
     35 + {
     36 + desc: "fails when canvas is too small",
     37 + min: 0,
     38 + max: 1,
     39 + graphHeight: 1,
     40 + labelWidth: 4,
     41 + wantErr: true,
     42 + },
     43 + {
     44 + desc: "fails when labelWidth is too small",
     45 + min: 0,
     46 + max: 1,
     47 + graphHeight: 2,
     48 + labelWidth: 0,
     49 + wantErr: true,
     50 + },
     51 + {
     52 + desc: "works when there are no data points",
     53 + min: 0,
     54 + max: 0,
     55 + graphHeight: 2,
     56 + labelWidth: 1,
     57 + want: []*Label{
     58 + {NewValue(0, nonZeroDecimals), image.Point{0, 1}},
     59 + },
     60 + },
     61 + {
     62 + desc: "only one label on tall canvas without data points",
     63 + min: 0,
     64 + max: 0,
     65 + graphHeight: 25,
     66 + labelWidth: 1,
     67 + want: []*Label{
     68 + {NewValue(0, nonZeroDecimals), image.Point{0, 24}},
     69 + },
     70 + },
     71 + {
     72 + desc: "works when min equals max",
     73 + min: 5,
     74 + max: 5,
     75 + graphHeight: 2,
     76 + labelWidth: 1,
     77 + want: []*Label{
     78 + {NewValue(0, nonZeroDecimals), image.Point{0, 1}},
     79 + {NewValue(2.88, nonZeroDecimals), image.Point{0, 0}},
     80 + },
     81 + },
     82 + {
     83 + desc: "only two rows on the canvas, labels min and max",
     84 + min: 0,
     85 + max: 5,
     86 + graphHeight: 2,
     87 + labelWidth: 1,
     88 + want: []*Label{
     89 + {NewValue(0, nonZeroDecimals), image.Point{0, 1}},
     90 + {NewValue(2.88, nonZeroDecimals), image.Point{0, 0}},
     91 + },
     92 + },
     93 + {
     94 + desc: "aligns labels to the right",
     95 + min: 0,
     96 + max: 5,
     97 + graphHeight: 2,
     98 + labelWidth: 5,
     99 + want: []*Label{
     100 + {NewValue(0, nonZeroDecimals), image.Point{4, 1}},
     101 + {NewValue(2.88, nonZeroDecimals), image.Point{1, 0}},
     102 + },
     103 + },
     104 + {
     105 + desc: "multiple labels, last on the top",
     106 + min: 0,
     107 + max: 5,
     108 + graphHeight: 9,
     109 + labelWidth: 1,
     110 + want: []*Label{
     111 + {NewValue(0, nonZeroDecimals), image.Point{0, 8}},
     112 + {NewValue(2.4, nonZeroDecimals), image.Point{0, 4}},
     113 + {NewValue(4.8, nonZeroDecimals), image.Point{0, 0}},
     114 + },
     115 + },
     116 + {
     117 + desc: "multiple labels, last on top-1",
     118 + min: 0,
     119 + max: 5,
     120 + graphHeight: 10,
     121 + labelWidth: 1,
     122 + want: []*Label{
     123 + {NewValue(0, nonZeroDecimals), image.Point{0, 9}},
     124 + {NewValue(2.08, nonZeroDecimals), image.Point{0, 5}},
     125 + {NewValue(4.16, nonZeroDecimals), image.Point{0, 1}},
     126 + },
     127 + },
     128 + }
     129 + 
     130 + for _, tc := range tests {
     131 + t.Run(tc.desc, func(t *testing.T) {
     132 + scale, err := NewYScale(tc.min, tc.max, tc.graphHeight, nonZeroDecimals)
     133 + if err != nil {
     134 + t.Fatalf("NewYScale => unexpected error: %v", err)
     135 + }
     136 + t.Logf("scale step: %v", scale.Step.Rounded)
     137 + got, err := yLabels(scale, tc.labelWidth)
     138 + if (err != nil) != tc.wantErr {
     139 + t.Errorf("yLabels => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     140 + }
     141 + if err != nil {
     142 + return
     143 + }
     144 + if diff := pretty.Compare(tc.want, got); diff != "" {
     145 + t.Errorf("yLabels => unexpected diff (-want, +got):\n%s", diff)
     146 + }
     147 + })
     148 + }
     149 +}
     150 + 
     151 +func TestXLabels(t *testing.T) {
     152 + const nonZeroDecimals = 2
     153 + tests := []struct {
     154 + desc string
     155 + numPoints int
     156 + graphWidth int
     157 + graphZero image.Point
     158 + customLabels map[int]string
     159 + want []*Label
     160 + wantErr bool
     161 + }{
     162 + {
     163 + desc: "only one point",
     164 + numPoints: 1,
     165 + graphWidth: 1,
     166 + graphZero: image.Point{0, 1},
     167 + want: []*Label{
     168 + {NewValue(0, nonZeroDecimals), image.Point{0, 3}},
     169 + },
     170 + },
     171 + {
     172 + desc: "two points, only one label fits",
     173 + numPoints: 2,
     174 + graphWidth: 1,
     175 + graphZero: image.Point{0, 1},
     176 + want: []*Label{
     177 + {NewValue(0, nonZeroDecimals), image.Point{0, 3}},
     178 + },
     179 + },
     180 + {
     181 + desc: "two points, two labels fit exactly",
     182 + numPoints: 2,
     183 + graphWidth: 5,
     184 + graphZero: image.Point{0, 1},
     185 + want: []*Label{
     186 + {NewValue(0, nonZeroDecimals), image.Point{0, 3}},
     187 + {NewValue(1, nonZeroDecimals), image.Point{4, 3}},
     188 + },
     189 + },
     190 + {
     191 + desc: "labels are placed according to graphZero",
     192 + numPoints: 2,
     193 + graphWidth: 5,
     194 + graphZero: image.Point{3, 5},
     195 + want: []*Label{
     196 + {NewValue(0, nonZeroDecimals), image.Point{3, 7}},
     197 + {NewValue(1, nonZeroDecimals), image.Point{7, 7}},
     198 + },
     199 + },
     200 + {
     201 + desc: "skip to next value exhausts the space completely",
     202 + numPoints: 11,
     203 + graphWidth: 4,
     204 + graphZero: image.Point{0, 1},
     205 + want: []*Label{
     206 + {NewValue(0, nonZeroDecimals), image.Point{0, 3}},
     207 + },
     208 + },
     209 + {
     210 + desc: "second label doesn't fit due to its length",
     211 + numPoints: 100,
     212 + graphWidth: 5,
     213 + graphZero: image.Point{0, 1},
     214 + want: []*Label{
     215 + {NewValue(0, nonZeroDecimals), image.Point{0, 3}},
     216 + },
     217 + },
     218 + {
     219 + desc: "two points, two labels, more space than minSpacing so end label adjusted",
     220 + numPoints: 2,
     221 + graphWidth: 6,
     222 + graphZero: image.Point{0, 1},
     223 + want: []*Label{
     224 + {NewValue(0, nonZeroDecimals), image.Point{0, 3}},
     225 + {NewValue(1, nonZeroDecimals), image.Point{5, 3}},
     226 + },
     227 + },
     228 + {
     229 + desc: "at most as many labels as there are points",
     230 + numPoints: 2,
     231 + graphWidth: 100,
     232 + graphZero: image.Point{0, 1},
     233 + want: []*Label{
     234 + {NewValue(0, nonZeroDecimals), image.Point{0, 3}},
     235 + {NewValue(1, nonZeroDecimals), image.Point{98, 3}},
     236 + },
     237 + },
     238 + {
     239 + desc: "some labels in the middle",
     240 + numPoints: 4,
     241 + graphWidth: 100,
     242 + graphZero: image.Point{0, 1},
     243 + want: []*Label{
     244 + {NewValue(0, nonZeroDecimals), image.Point{0, 3}},
     245 + {NewValue(1, nonZeroDecimals), image.Point{31, 3}},
     246 + {NewValue(2, nonZeroDecimals), image.Point{62, 3}},
     247 + {NewValue(3, nonZeroDecimals), image.Point{94, 3}},
     248 + },
     249 + },
     250 + {
     251 + desc: "custom labels provided",
     252 + numPoints: 4,
     253 + graphWidth: 100,
     254 + graphZero: image.Point{0, 1},
     255 + customLabels: map[int]string{
     256 + 0: "a",
     257 + 1: "b",
     258 + 2: "c",
     259 + 3: "d",
     260 + },
     261 + want: []*Label{
     262 + {NewTextValue("a"), image.Point{0, 3}},
     263 + {NewTextValue("b"), image.Point{31, 3}},
     264 + {NewTextValue("c"), image.Point{62, 3}},
     265 + {NewTextValue("d"), image.Point{94, 3}},
     266 + },
     267 + },
     268 + {
     269 + desc: "only some custom labels provided",
     270 + numPoints: 4,
     271 + graphWidth: 100,
     272 + graphZero: image.Point{0, 1},
     273 + customLabels: map[int]string{
     274 + 0: "a",
     275 + 3: "d",
     276 + },
     277 + want: []*Label{
     278 + {NewTextValue("a"), image.Point{0, 3}},
     279 + {NewValue(1, nonZeroDecimals), image.Point{31, 3}},
     280 + {NewValue(2, nonZeroDecimals), image.Point{62, 3}},
     281 + {NewTextValue("d"), image.Point{94, 3}},
     282 + },
     283 + },
     284 + {
     285 + desc: "not displayed if custom labels don't fit",
     286 + numPoints: 2,
     287 + graphWidth: 6,
     288 + graphZero: image.Point{0, 1},
     289 + customLabels: map[int]string{
     290 + 0: "a very very long custom label",
     291 + },
     292 + want: []*Label{},
     293 + },
     294 + {
     295 + desc: "more points than pixels",
     296 + numPoints: 100,
     297 + graphWidth: 6,
     298 + graphZero: image.Point{0, 1},
     299 + want: []*Label{
     300 + {NewValue(0, nonZeroDecimals), image.Point{0, 3}},
     301 + {NewValue(72, nonZeroDecimals), image.Point{4, 3}},
     302 + },
     303 + },
     304 + }
     305 + 
     306 + for _, tc := range tests {
     307 + t.Run(tc.desc, func(t *testing.T) {
     308 + scale, err := NewXScale(tc.numPoints, tc.graphWidth, nonZeroDecimals)
     309 + if err != nil {
     310 + t.Fatalf("NewXScale => unexpected error: %v", err)
     311 + }
     312 + t.Logf("scale step: %v", scale.Step.Rounded)
     313 + got, err := xLabels(scale, tc.graphZero, tc.customLabels)
     314 + if (err != nil) != tc.wantErr {
     315 + t.Errorf("xLabels => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     316 + }
     317 + if err != nil {
     318 + return
     319 + }
     320 + if diff := pretty.Compare(tc.want, got); diff != "" {
     321 + t.Errorf("xLabels => unexpected diff (-want, +got):\n%s", diff)
     322 + }
     323 + })
     324 + }
     325 +}
     326 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/axes/scale.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package axes
     16 + 
     17 +// scale.go calculates the scale of the Y axis.
     18 + 
     19 +import (
     20 + "fmt"
     21 + 
     22 + "github.com/mum4k/termdash/canvas/braille"
     23 + "github.com/mum4k/termdash/numbers"
     24 +)
     25 + 
     26 +// YScale is the scale of the Y axis.
     27 +type YScale struct {
     28 + // Min is the minimum value on the axis.
     29 + Min *Value
     30 + // Max is the maximum value on the axis.
     31 + Max *Value
     32 + // Step is the step in the value between pixels.
     33 + Step *Value
     34 + 
     35 + // GraphHeight is the height in cells of the area on the canvas that is
     36 + // dedicated to the graph itself.
     37 + GraphHeight int
     38 + // brailleHeight is the height of the braille canvas based on the GraphHeight.
     39 + brailleHeight int
     40 +}
     41 + 
     42 +// NewYScale calculates the scale of the Y axis, given the boundary values and
     43 +// the height of the graph. The nonZeroDecimals dictates rounding of the
     44 +// calculated scale, see NewValue for details.
     45 +// Max must be greater or equal to min. The graphHeight must be a positive
     46 +// number.
     47 +func NewYScale(min, max float64, graphHeight, nonZeroDecimals int) (*YScale, error) {
     48 + if max < min {
     49 + return nil, fmt.Errorf("max(%v) cannot be less than min(%v)", max, min)
     50 + }
     51 + if min := 1; graphHeight < min {
     52 + return nil, fmt.Errorf("graphHeight cannot be less than %d, got %d", min, graphHeight)
     53 + }
     54 + 
     55 + brailleHeight := graphHeight * braille.RowMult
     56 + usablePixels := brailleHeight - 1 // One pixel reserved for value zero.
     57 + 
     58 + if min > 0 { // If we only have positive data points, make the scale zero based (min).
     59 + min = 0
     60 + }
     61 + if max < 0 { // If we only have negative data points, make the scale zero based (max).
     62 + max = 0
     63 + }
     64 + diff := max - min
     65 + step := NewValue(diff/float64(usablePixels), nonZeroDecimals)
     66 + return &YScale{
     67 + Min: NewValue(min, nonZeroDecimals),
     68 + Max: NewValue(max, nonZeroDecimals),
     69 + Step: step,
     70 + GraphHeight: graphHeight,
     71 + brailleHeight: brailleHeight,
     72 + }, nil
     73 +}
     74 + 
     75 +// PixelToValue given a Y coordinate of the pixel, returns its value according
     76 +// to the scale. The coordinate must be within bounds of the graph height
     77 +// provided to NewYScale. Y coordinates grow down.
     78 +func (ys *YScale) PixelToValue(y int) (float64, error) {
     79 + pos, err := yToPosition(y, ys.brailleHeight)
     80 + if err != nil {
     81 + return 0, err
     82 + }
     83 + 
     84 + switch {
     85 + case pos == 0:
     86 + return ys.Min.Rounded, nil
     87 + case pos == ys.brailleHeight-1:
     88 + return ys.Max.Rounded, nil
     89 + default:
     90 + v := float64(pos) * ys.Step.Rounded
     91 + if ys.Min.Value < 0 {
     92 + diff := -1 * ys.Min.Value
     93 + v -= diff
     94 + }
     95 + return v, nil
     96 + }
     97 +}
     98 + 
     99 +// ValueToPixel given a value, determines the Y coordinate of the pixel that
     100 +// most closely represents the value on the line chart according to the scale.
     101 +// The value must be within the bounds provided to NewYScale. Y coordinates
     102 +// grow down.
     103 +func (ys *YScale) ValueToPixel(v float64) (int, error) {
     104 + if ys.Step.Rounded == 0 {
     105 + return 0, nil
     106 + }
     107 + 
     108 + if ys.Min.Value < 0 {
     109 + diff := -1 * ys.Min.Value
     110 + v += diff
     111 + }
     112 + pos := int(numbers.Round(v / ys.Step.Rounded))
     113 + return positionToY(pos, ys.brailleHeight)
     114 +}
     115 + 
     116 +// CellLabel given a Y coordinate of a cell on the canvas, determines value of
     117 +// the label that should be next to it. The Y coordinate must be within the
     118 +// graphHeight provided to NewYScale. Y coordinates grow down.
     119 +func (ys *YScale) CellLabel(y int) (*Value, error) {
     120 + pos, err := yToPosition(y, ys.GraphHeight)
     121 + if err != nil {
     122 + return nil, err
     123 + }
     124 + 
     125 + pixelY, err := positionToY(pos*braille.RowMult, ys.brailleHeight)
     126 + if err != nil {
     127 + return nil, err
     128 + }
     129 + 
     130 + v, err := ys.PixelToValue(pixelY)
     131 + if err != nil {
     132 + return nil, err
     133 + }
     134 + return NewValue(v, ys.Min.NonZeroDecimals), nil
     135 +}
     136 + 
     137 +// XScale is the scale of the X axis.
     138 +type XScale struct {
     139 + // Min is the minimum value on the axis.
     140 + Min *Value
     141 + // Max is the maximum value on the axis.
     142 + Max *Value
     143 + // Step is the step in the value between pixels.
     144 + Step *Value
     145 + 
     146 + // GraphWidth is the width in cells of the area on the canvas that is
     147 + // dedicated to the graph.
     148 + GraphWidth int
     149 + // brailleWidth is the width of the braille canvas based on the GraphWidth.
     150 + brailleWidth int
     151 +}
     152 + 
     153 +// NewXScale calculates the scale of the X axis, given the number of data
     154 +// points in the series and the width on the canvas that is available to the X
     155 +// axis. The nonZeroDecimals dictates rounding of the calculated scale, see
     156 +// NewValue for details.
     157 +// The numPoints must be zero or positive number. The graphWidth must be a
     158 +// positive number.
     159 +func NewXScale(numPoints int, graphWidth, nonZeroDecimals int) (*XScale, error) {
     160 + if numPoints < 0 {
     161 + return nil, fmt.Errorf("numPoints cannot be negative, got %d", numPoints)
     162 + }
     163 + if min := 1; graphWidth < min {
     164 + return nil, fmt.Errorf("graphWidth must be at least %d, got %d", min, graphWidth)
     165 + }
     166 + 
     167 + brailleWidth := graphWidth * braille.ColMult
     168 + usablePixels := brailleWidth - 1 // One pixel reserved for value zero.
     169 + 
     170 + const min float64 = 0
     171 + max := float64(numPoints - 1)
     172 + if max < 0 {
     173 + max = 0
     174 + }
     175 + diff := max - min
     176 + step := NewValue(diff/float64(usablePixels), nonZeroDecimals)
     177 + return &XScale{
     178 + Min: NewValue(min, nonZeroDecimals),
     179 + Max: NewValue(max, nonZeroDecimals),
     180 + Step: step,
     181 + GraphWidth: graphWidth,
     182 + brailleWidth: brailleWidth,
     183 + }, nil
     184 +}
     185 + 
     186 +// PixelToValue given a X coordinate of the pixel, returns its value according
     187 +// to the scale. The coordinate must be within bounds of the canvas width
     188 +// provided to NewXScale. X coordinates grow right.
     189 +func (xs *XScale) PixelToValue(x int) (float64, error) {
     190 + if min, max := 0, xs.brailleWidth; x < min || x >= max {
     191 + return 0, fmt.Errorf("invalid x coordinate %d, must be in range %v < x < %v", x, min, max)
     192 + }
     193 + 
     194 + switch {
     195 + case x == 0:
     196 + return xs.Min.Rounded, nil
     197 + case x == xs.brailleWidth-1:
     198 + return xs.Max.Rounded, nil
     199 + default:
     200 + return float64(x) * xs.Step.Rounded, nil
     201 + }
     202 +}
     203 + 
     204 +// ValueToPixel given a value, determines the X coordinate of the pixel that
     205 +// most closely represents the value on the line chart according to the scale.
     206 +// The value must be within the bounds provided to NewXScale. X coordinates
     207 +// grow right.
     208 +func (xs *XScale) ValueToPixel(v int) (int, error) {
     209 + fv := float64(v)
     210 + if min, max := xs.Min.Value, xs.Max.Rounded; fv < min || fv > max {
     211 + return 0, fmt.Errorf("invalid value %v, must be in range %v <= v <= %v", v, min, max)
     212 + }
     213 + if xs.Step.Rounded == 0 {
     214 + return 0, nil
     215 + }
     216 + return int(numbers.Round(fv / xs.Step.Rounded)), nil
     217 +}
     218 + 
     219 +// ValueToCell given a value, determines the X coordinate of the cell that
     220 +// most closely represents the value on the line chart according to the scale.
     221 +// The value must be within the bounds provided to NewXScale. X coordinates
     222 +// grow right.
     223 +func (xs *XScale) ValueToCell(v int) (int, error) {
     224 + p, err := xs.ValueToPixel(v)
     225 + if err != nil {
     226 + return 0, err
     227 + }
     228 + return p / braille.ColMult, nil
     229 +}
     230 + 
     231 +// CellLabel given an X coordinate of a cell on the canvas, determines value of the
     232 +// label that should be next to it. The X coordinate must be within the
     233 +// graphWidth provided to NewXScale. X coordinates grow right.
     234 +// The returned value is rounded to the nearest int, rounding half away from zero.
     235 +func (xs *XScale) CellLabel(x int) (*Value, error) {
     236 + v, err := xs.PixelToValue(x * braille.ColMult)
     237 + if err != nil {
     238 + return nil, err
     239 + }
     240 + return NewValue(numbers.Round(v), xs.Min.NonZeroDecimals), nil
     241 +}
     242 + 
     243 +// positionToY, given a position within the height, returns the Y coordinate of
     244 +// the position. Positions grow up, coordinates grow down.
     245 +//
     246 +// Positions Y Coordinates
     247 +// 2 | 0
     248 +// 1 | 1
     249 +// 0 | 2
     250 +func positionToY(pos int, height int) (int, error) {
     251 + max := height - 1
     252 + if min := 0; pos < min || pos > max {
     253 + return 0, fmt.Errorf("position %d out of bounds %d <= pos <= %d", pos, min, max)
     254 + }
     255 + return max - pos, nil
     256 +}
     257 + 
     258 +// yToPosition is the reverse of positionToY.
     259 +func yToPosition(y int, height int) (int, error) {
     260 + max := height - 1
     261 + if min := 0; y < min || y > max {
     262 + return 0, fmt.Errorf("Y coordinate %d out of bounds %d <= Y <= %d", y, min, max)
     263 + }
     264 + return -1*y + max, nil
     265 +}
     266 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/axes/scale_test.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package axes
     16 + 
     17 +import (
     18 + "fmt"
     19 + "testing"
     20 + 
     21 + "github.com/kylelemons/godebug/pretty"
     22 +)
     23 + 
     24 +// mustNewYScale returns a new YScale or panics.
     25 +func mustNewYScale(min, max float64, graphHeight, nonZeroDecimals int) *YScale {
     26 + s, err := NewYScale(min, max, graphHeight, nonZeroDecimals)
     27 + if err != nil {
     28 + panic(err)
     29 + }
     30 + return s
     31 +}
     32 + 
     33 +// mustNewXScale returns a new XScale or panics.
     34 +func mustNewXScale(numPoints int, graphWidth, nonZeroDecimals int) *XScale {
     35 + s, err := NewXScale(numPoints, graphWidth, nonZeroDecimals)
     36 + if err != nil {
     37 + panic(err)
     38 + }
     39 + return s
     40 +}
     41 + 
     42 +// pixelToValueTest is a test case for PixelToValue.
     43 +type pixelToValueTest struct {
     44 + pixel int
     45 + want float64
     46 + wantErr bool
     47 +}
     48 + 
     49 +// valueToPixelTest is a test case for ValueToPixel.
     50 +type valueToPixelTest struct {
     51 + value float64
     52 + want int
     53 + wantErr bool
     54 +}
     55 + 
     56 +// valueToCellTest is a test case for ValueToCell.
     57 +type valueToCellTest struct {
     58 + value int
     59 + want int
     60 + wantErr bool
     61 +}
     62 + 
     63 +// cellLabelTest is a test case for CellLabel.
     64 +type cellLabelTest struct {
     65 + cell int
     66 + want *Value
     67 + wantErr bool
     68 +}
     69 + 
     70 +func TestYScale(t *testing.T) {
     71 + tests := []struct {
     72 + desc string
     73 + min float64
     74 + max float64
     75 + graphHeight int
     76 + nonZeroDecimals int
     77 + pixelToValueTests []pixelToValueTest
     78 + valueToPixelTests []valueToPixelTest
     79 + cellLabelTests []cellLabelTest
     80 + wantErr bool
     81 + }{
     82 + {
     83 + desc: "fails when max is less than min",
     84 + min: 0,
     85 + max: -1,
     86 + graphHeight: 4,
     87 + nonZeroDecimals: 2,
     88 + wantErr: true,
     89 + },
     90 + {
     91 + desc: "fails when canvas height too small",
     92 + min: 0,
     93 + max: 1,
     94 + graphHeight: 0,
     95 + nonZeroDecimals: 2,
     96 + wantErr: true,
     97 + },
     98 + {
     99 + desc: "fails on negative pixel",
     100 + min: 0,
     101 + max: 10,
     102 + graphHeight: 4,
     103 + nonZeroDecimals: 2,
     104 + pixelToValueTests: []pixelToValueTest{
     105 + {-1, 0, true},
     106 + },
     107 + },
     108 + {
     109 + desc: "fails on pixel out of range",
     110 + min: 0,
     111 + max: 10,
     112 + graphHeight: 4,
     113 + nonZeroDecimals: 2,
     114 + pixelToValueTests: []pixelToValueTest{
     115 + {16, 0, true},
     116 + },
     117 + },
     118 + {
     119 + desc: "fails on value or cell too small",
     120 + min: -1,
     121 + max: 0,
     122 + graphHeight: 4,
     123 + nonZeroDecimals: 2,
     124 + valueToPixelTests: []valueToPixelTest{
     125 + {-2, 0, true},
     126 + },
     127 + cellLabelTests: []cellLabelTest{
     128 + {-1, nil, true},
     129 + },
     130 + },
     131 + {
     132 + desc: "fails on value or cell too large",
     133 + min: -1,
     134 + max: 0,
     135 + graphHeight: 4,
     136 + nonZeroDecimals: 2,
     137 + valueToPixelTests: []valueToPixelTest{
     138 + {1, 0, true},
     139 + },
     140 + cellLabelTests: []cellLabelTest{
     141 + {4, nil, true},
     142 + },
     143 + },
     144 + {
     145 + desc: "works without data points",
     146 + min: 0,
     147 + max: 0,
     148 + graphHeight: 1,
     149 + nonZeroDecimals: 2,
     150 + pixelToValueTests: []pixelToValueTest{
     151 + {0, 0, false},
     152 + },
     153 + valueToPixelTests: []valueToPixelTest{
     154 + {0, 0, false},
     155 + },
     156 + cellLabelTests: []cellLabelTest{
     157 + {0, NewValue(0, 2), false},
     158 + },
     159 + },
     160 + {
     161 + desc: "min and max are non-zero positive and equal, scale is zero based",
     162 + min: 6,
     163 + max: 6,
     164 + graphHeight: 1,
     165 + nonZeroDecimals: 2,
     166 + pixelToValueTests: []pixelToValueTest{
     167 + {3, 0, false},
     168 + {2, 2, false},
     169 + {1, 4, false},
     170 + {0, 6, false},
     171 + },
     172 + valueToPixelTests: []valueToPixelTest{
     173 + {0, 3, false},
     174 + {0.5, 3, false},
     175 + {1, 2, false},
     176 + {1.5, 2, false},
     177 + {2, 2, false},
     178 + {4, 1, false},
     179 + {6, 0, false},
     180 + },
     181 + cellLabelTests: []cellLabelTest{
     182 + {0, NewValue(0, 2), false},
     183 + },
     184 + },
     185 + {
     186 + desc: "min is non-zero positive, not equal to max, scale is zero based",
     187 + min: 1,
     188 + max: 6,
     189 + graphHeight: 1,
     190 + nonZeroDecimals: 2,
     191 + pixelToValueTests: []pixelToValueTest{
     192 + {3, 0, false},
     193 + {2, 2, false},
     194 + {1, 4, false},
     195 + {0, 6, false},
     196 + },
     197 + valueToPixelTests: []valueToPixelTest{
     198 + {0, 3, false},
     199 + {0.5, 3, false},
     200 + {1, 2, false},
     201 + {1.5, 2, false},
     202 + {2, 2, false},
     203 + {4, 1, false},
     204 + {6, 0, false},
     205 + },
     206 + cellLabelTests: []cellLabelTest{
     207 + {0, NewValue(0, 2), false},
     208 + },
     209 + },
     210 + 
     211 + {
     212 + desc: "integer scale",
     213 + min: 0,
     214 + max: 6,
     215 + graphHeight: 1,
     216 + nonZeroDecimals: 2,
     217 + pixelToValueTests: []pixelToValueTest{
     218 + {3, 0, false},
     219 + {2, 2, false},
     220 + {1, 4, false},
     221 + {0, 6, false},
     222 + },
     223 + valueToPixelTests: []valueToPixelTest{
     224 + {0, 3, false},
     225 + {0.5, 3, false},
     226 + {1, 2, false},
     227 + {1.5, 2, false},
     228 + {2, 2, false},
     229 + {4, 1, false},
     230 + {6, 0, false},
     231 + },
     232 + cellLabelTests: []cellLabelTest{
     233 + {0, NewValue(0, 2), false},
     234 + },
     235 + },
     236 + {
     237 + desc: "integer scale, multi-row canvas",
     238 + min: 0,
     239 + max: 14,
     240 + graphHeight: 2,
     241 + nonZeroDecimals: 2,
     242 + pixelToValueTests: []pixelToValueTest{
     243 + {7, 0, false},
     244 + {6, 2, false},
     245 + {5, 4, false},
     246 + {4, 6, false},
     247 + {3, 8, false},
     248 + {2, 10, false},
     249 + {1, 12, false},
     250 + {0, 14, false},
     251 + },
     252 + valueToPixelTests: []valueToPixelTest{
     253 + {0, 7, false},
     254 + {1, 6, false},
     255 + {4, 5, false},
     256 + {6, 4, false},
     257 + {14, 0, false},
     258 + },
     259 + cellLabelTests: []cellLabelTest{
     260 + {0, NewValue(8, 2), false},
     261 + {1, NewValue(0, 2), false},
     262 + },
     263 + },
     264 + {
     265 + desc: "negative integer scale",
     266 + min: -3,
     267 + max: 3,
     268 + graphHeight: 1,
     269 + nonZeroDecimals: 2,
     270 + pixelToValueTests: []pixelToValueTest{
     271 + {3, -3, false},
     272 + {2, -1, false},
     273 + {1, 1, false},
     274 + {0, 3, false},
     275 + },
     276 + valueToPixelTests: []valueToPixelTest{
     277 + {-3, 3, false},
     278 + {-2.5, 3, false},
     279 + {-2, 2, false},
     280 + {-1.5, 2, false},
     281 + {-1, 2, false},
     282 + {0, 1, false},
     283 + {1, 1, false},
     284 + {3, 0, false},
     285 + },
     286 + cellLabelTests: []cellLabelTest{
     287 + {0, NewValue(-3, 2), false},
     288 + },
     289 + },
     290 + {
     291 + desc: "negative integer scale, max is zero",
     292 + min: -6,
     293 + max: 0,
     294 + graphHeight: 1,
     295 + nonZeroDecimals: 2,
     296 + pixelToValueTests: []pixelToValueTest{
     297 + {3, -6, false},
     298 + {2, -4, false},
     299 + {1, -2, false},
     300 + {0, 0, false},
     301 + },
     302 + valueToPixelTests: []valueToPixelTest{
     303 + {-6, 3, false},
     304 + {-4, 2, false},
     305 + {-2, 1, false},
     306 + {0, 0, false},
     307 + },
     308 + cellLabelTests: []cellLabelTest{
     309 + {0, NewValue(-6, 2), false},
     310 + },
     311 + },
     312 + {
     313 + desc: "negative integer scale, max is also negative, scale has max of zero",
     314 + min: -6,
     315 + max: -1,
     316 + graphHeight: 1,
     317 + nonZeroDecimals: 2,
     318 + pixelToValueTests: []pixelToValueTest{
     319 + {3, -6, false},
     320 + {2, -4, false},
     321 + {1, -2, false},
     322 + {0, 0, false},
     323 + },
     324 + valueToPixelTests: []valueToPixelTest{
     325 + {-6, 3, false},
     326 + {-4, 2, false},
     327 + {-2, 1, false},
     328 + {0, 0, false},
     329 + },
     330 + cellLabelTests: []cellLabelTest{
     331 + {0, NewValue(-6, 2), false},
     332 + },
     333 + },
     334 + {
     335 + desc: "zero based float scale",
     336 + min: 0,
     337 + max: 0.3,
     338 + graphHeight: 1,
     339 + nonZeroDecimals: 2,
     340 + pixelToValueTests: []pixelToValueTest{
     341 + {3, 0, false},
     342 + {2, 0.1, false},
     343 + {1, 0.2, false},
     344 + {0, 0.3, false},
     345 + },
     346 + valueToPixelTests: []valueToPixelTest{
     347 + {0, 3, false},
     348 + {0.1, 2, false},
     349 + {0.2, 1, false},
     350 + {0.3, 0, false},
     351 + },
     352 + cellLabelTests: []cellLabelTest{
     353 + {0, NewValue(0, 2), false},
     354 + },
     355 + },
     356 + {
     357 + desc: "requested value is negative, rounded isn't",
     358 + min: -0.19866933079506122,
     359 + max: 0.19866933079506122,
     360 + graphHeight: 28,
     361 + nonZeroDecimals: 2,
     362 + valueToPixelTests: []valueToPixelTest{
     363 + {-0.19866933079506122, 111, false},
     364 + },
     365 + pixelToValueTests: []pixelToValueTest{
     366 + {111, -0.19, false},
     367 + },
     368 + },
     369 + }
     370 + 
     371 + for _, test := range tests {
     372 + scale, err := NewYScale(test.min, test.max, test.graphHeight, test.nonZeroDecimals)
     373 + if (err != nil) != test.wantErr {
     374 + t.Errorf("NewYScale => unexpected error: %v, wantErr: %v", err, test.wantErr)
     375 + }
     376 + if err != nil {
     377 + continue
     378 + }
     379 + 
     380 + t.Run(fmt.Sprintf("PixelToValue:%s", test.desc), func(t *testing.T) {
     381 + for _, tc := range test.pixelToValueTests {
     382 + got, err := scale.PixelToValue(tc.pixel)
     383 + if (err != nil) != tc.wantErr {
     384 + t.Errorf("PixelToValue => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     385 + }
     386 + if err != nil {
     387 + continue
     388 + }
     389 + if got != tc.want {
     390 + t.Errorf("PixelToValue(%v) => %v, want %v", tc.pixel, got, tc.want)
     391 + }
     392 + }
     393 + })
     394 + 
     395 + t.Run(fmt.Sprintf("ValueToPixel:%s", test.desc), func(t *testing.T) {
     396 + for _, tc := range test.valueToPixelTests {
     397 + got, err := scale.ValueToPixel(tc.value)
     398 + if (err != nil) != tc.wantErr {
     399 + t.Errorf("ValueToPixel => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     400 + }
     401 + if err != nil {
     402 + continue
     403 + }
     404 + if got != tc.want {
     405 + t.Errorf("ValueToPixel(%v) => %v, want %v", tc.value, got, tc.want)
     406 + }
     407 + }
     408 + })
     409 + 
     410 + t.Run(fmt.Sprintf("CellLabel:%s", test.desc), func(t *testing.T) {
     411 + for _, tc := range test.cellLabelTests {
     412 + got, err := scale.CellLabel(tc.cell)
     413 + if (err != nil) != tc.wantErr {
     414 + t.Errorf("CellLabel => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     415 + }
     416 + if err != nil {
     417 + continue
     418 + }
     419 + if diff := pretty.Compare(tc.want, got); diff != "" {
     420 + t.Errorf("CellLabel(%v) => unexpected diff (-want, +got):\n%s", tc.cell, diff)
     421 + }
     422 + }
     423 + })
     424 + }
     425 +}
     426 + 
     427 +func TestXScale(t *testing.T) {
     428 + tests := []struct {
     429 + desc string
     430 + numPoints int
     431 + graphWidth int
     432 + nonZeroDecimals int
     433 + pixelToValueTests []pixelToValueTest
     434 + valueToPixelTests []valueToPixelTest
     435 + valueToCellTests []valueToCellTest
     436 + cellLabelTests []cellLabelTest
     437 + wantErr bool
     438 + }{
     439 + {
     440 + desc: "fails when numPoints negative",
     441 + numPoints: -1,
     442 + graphWidth: 1,
     443 + wantErr: true,
     444 + },
     445 + {
     446 + desc: "fails when graphWidth zero",
     447 + numPoints: 1,
     448 + graphWidth: 0,
     449 + wantErr: true,
     450 + },
     451 + {
     452 + desc: "fails on negative pixel",
     453 + numPoints: 1,
     454 + graphWidth: 1,
     455 + nonZeroDecimals: 2,
     456 + pixelToValueTests: []pixelToValueTest{
     457 + {-1, 0, true},
     458 + },
     459 + },
     460 + {
     461 + desc: "fails on pixel out of range",
     462 + numPoints: 1,
     463 + graphWidth: 1,
     464 + nonZeroDecimals: 2,
     465 + pixelToValueTests: []pixelToValueTest{
     466 + {2, 0, true},
     467 + },
     468 + },
     469 + {
     470 + desc: "fails on value or cell too small",
     471 + numPoints: 1,
     472 + graphWidth: 1,
     473 + nonZeroDecimals: 2,
     474 + valueToPixelTests: []valueToPixelTest{
     475 + {-1, 0, true},
     476 + },
     477 + valueToCellTests: []valueToCellTest{
     478 + {-1, 0, true},
     479 + },
     480 + cellLabelTests: []cellLabelTest{
     481 + {-1, nil, true},
     482 + },
     483 + },
     484 + {
     485 + desc: "fails on value or cell too large",
     486 + numPoints: 1,
     487 + graphWidth: 1,
     488 + nonZeroDecimals: 2,
     489 + valueToPixelTests: []valueToPixelTest{
     490 + {1, 0, true},
     491 + },
     492 + valueToCellTests: []valueToCellTest{
     493 + {1, 0, true},
     494 + },
     495 + cellLabelTests: []cellLabelTest{
     496 + {2, nil, true},
     497 + },
     498 + },
     499 + {
     500 + desc: "works without data points",
     501 + numPoints: 0,
     502 + graphWidth: 1,
     503 + nonZeroDecimals: 2,
     504 + pixelToValueTests: []pixelToValueTest{
     505 + {0, 0, false},
     506 + },
     507 + valueToPixelTests: []valueToPixelTest{
     508 + {0, 0, false},
     509 + },
     510 + cellLabelTests: []cellLabelTest{
     511 + {0, NewValue(0, 2), false},
     512 + },
     513 + },
     514 + {
     515 + desc: "integer scale, all points fit",
     516 + numPoints: 6,
     517 + graphWidth: 3,
     518 + nonZeroDecimals: 2,
     519 + pixelToValueTests: []pixelToValueTest{
     520 + {0, 0, false},
     521 + {1, 1, false},
     522 + {2, 2, false},
     523 + {3, 3, false},
     524 + {4, 4, false},
     525 + {5, 5, false},
     526 + },
     527 + valueToPixelTests: []valueToPixelTest{
     528 + {0, 0, false},
     529 + {1, 1, false},
     530 + {2, 2, false},
     531 + {3, 3, false},
     532 + {4, 4, false},
     533 + {5, 5, false},
     534 + },
     535 + valueToCellTests: []valueToCellTest{
     536 + {0, 0, false},
     537 + {1, 0, false},
     538 + {2, 1, false},
     539 + {3, 1, false},
     540 + {4, 2, false},
     541 + {5, 2, false},
     542 + },
     543 + cellLabelTests: []cellLabelTest{
     544 + {0, NewValue(0, 2), false},
     545 + {1, NewValue(2, 2), false},
     546 + {2, NewValue(4, 2), false},
     547 + },
     548 + },
     549 + {
     550 + desc: "float scale, multiple points per pixel",
     551 + numPoints: 12,
     552 + graphWidth: 3,
     553 + nonZeroDecimals: 2,
     554 + pixelToValueTests: []pixelToValueTest{
     555 + {0, 0, false},
     556 + {1, 2.21, false},
     557 + {2, 4.42, false},
     558 + {3, 6.63, false},
     559 + {4, 8.84, false},
     560 + {5, 11, false},
     561 + },
     562 + valueToPixelTests: []valueToPixelTest{
     563 + {0, 0, false},
     564 + {1, 0, false},
     565 + {2, 1, false},
     566 + {3, 1, false},
     567 + {4, 2, false},
     568 + {5, 2, false},
     569 + {6, 3, false},
     570 + {7, 3, false},
     571 + {8, 4, false},
     572 + {9, 4, false},
     573 + {10, 5, false},
     574 + {11, 5, false},
     575 + },
     576 + valueToCellTests: []valueToCellTest{
     577 + {0, 0, false},
     578 + {1, 0, false},
     579 + {2, 0, false},
     580 + {3, 0, false},
     581 + {4, 1, false},
     582 + {5, 1, false},
     583 + {6, 1, false},
     584 + {7, 1, false},
     585 + {8, 2, false},
     586 + {9, 2, false},
     587 + {10, 2, false},
     588 + {11, 2, false},
     589 + },
     590 + cellLabelTests: []cellLabelTest{
     591 + {0, NewValue(0, 2), false},
     592 + {1, NewValue(4, 2), false},
     593 + {2, NewValue(9, 2), false},
     594 + },
     595 + },
     596 + {
     597 + desc: "float scale, multiple pixels per point",
     598 + numPoints: 2,
     599 + graphWidth: 5,
     600 + nonZeroDecimals: 2,
     601 + pixelToValueTests: []pixelToValueTest{
     602 + {0, 0, false},
     603 + {1, 0.12, false},
     604 + {2, 0.24, false},
     605 + {3, 0.36, false},
     606 + {4, 0.48, false},
     607 + {5, 0.6, false},
     608 + {6, 0.72, false},
     609 + {7, 0.84, false},
     610 + {8, 0.96, false},
     611 + {9, 1, false},
     612 + },
     613 + valueToPixelTests: []valueToPixelTest{
     614 + {0, 0, false},
     615 + {1, 8, false},
     616 + },
     617 + valueToCellTests: []valueToCellTest{
     618 + {0, 0, false},
     619 + {1, 4, false},
     620 + },
     621 + cellLabelTests: []cellLabelTest{
     622 + {0, NewValue(0, 2), false},
     623 + {1, NewValue(0, 2), false},
     624 + {2, NewValue(0, 2), false},
     625 + {3, NewValue(1, 2), false},
     626 + {4, NewValue(1, 2), false},
     627 + },
     628 + },
     629 + }
     630 + 
     631 + for _, test := range tests {
     632 + scale, err := NewXScale(test.numPoints, test.graphWidth, test.nonZeroDecimals)
     633 + if (err != nil) != test.wantErr {
     634 + t.Errorf("NewXScale => unexpected error: %v, wantErr: %v", err, test.wantErr)
     635 + }
     636 + if err != nil {
     637 + continue
     638 + }
     639 + 
     640 + t.Run(fmt.Sprintf("PixelToValue:%s", test.desc), func(t *testing.T) {
     641 + for _, tc := range test.pixelToValueTests {
     642 + got, err := scale.PixelToValue(tc.pixel)
     643 + if (err != nil) != tc.wantErr {
     644 + t.Errorf("PixelToValue => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     645 + }
     646 + if err != nil {
     647 + continue
     648 + }
     649 + if got != tc.want {
     650 + t.Errorf("PixelToValue(%v) => %v, want %v", tc.pixel, got, tc.want)
     651 + }
     652 + }
     653 + })
     654 + 
     655 + t.Run(fmt.Sprintf("ValueToPixel:%s", test.desc), func(t *testing.T) {
     656 + for _, tc := range test.valueToPixelTests {
     657 + got, err := scale.ValueToPixel(int(tc.value))
     658 + if (err != nil) != tc.wantErr {
     659 + t.Errorf("ValueToPixel => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     660 + }
     661 + if err != nil {
     662 + continue
     663 + }
     664 + if got != tc.want {
     665 + t.Errorf("ValueToPixel(%v) => %v, want %v", tc.value, got, tc.want)
     666 + }
     667 + }
     668 + })
     669 + 
     670 + t.Run(fmt.Sprintf("ValueToCell:%s", test.desc), func(t *testing.T) {
     671 + for _, tc := range test.valueToCellTests {
     672 + got, err := scale.ValueToCell(tc.value)
     673 + if (err != nil) != tc.wantErr {
     674 + t.Errorf("ValueToCell => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     675 + }
     676 + if err != nil {
     677 + continue
     678 + }
     679 + if got != tc.want {
     680 + t.Errorf("ValueToCell(%v) => %v, want %v", tc.value, got, tc.want)
     681 + }
     682 + }
     683 + })
     684 + 
     685 + t.Run(fmt.Sprintf("CellLabel:%s", test.desc), func(t *testing.T) {
     686 + for _, tc := range test.cellLabelTests {
     687 + got, err := scale.CellLabel(tc.cell)
     688 + if (err != nil) != tc.wantErr {
     689 + t.Errorf("CellLabel => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     690 + }
     691 + if err != nil {
     692 + continue
     693 + }
     694 + if diff := pretty.Compare(tc.want, got); diff != "" {
     695 + t.Errorf("CellLabel(%v) => unexpected diff (-want, +got):\n%s", tc.cell, diff)
     696 + }
     697 + }
     698 + })
     699 + }
     700 +}
     701 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/axes/value.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package axes
     16 + 
     17 +// value.go contains code dealing with values on the line chart.
     18 + 
     19 +import (
     20 + "fmt"
     21 + "math"
     22 + 
     23 + "github.com/mum4k/termdash/numbers"
     24 +)
     25 + 
     26 +// Value represents one value.
     27 +type Value struct {
     28 + // Value is the original unmodified value.
     29 + Value float64
     30 + // Rounded is the value rounded up to the nonZeroPlaces number of non-zero
     31 + // decimal places.
     32 + Rounded float64
     33 + // ZeroDecimals indicates how many decimal places in Rounded have a value
     34 + // of zero.
     35 + ZeroDecimals int
     36 + // NonZeroDecimals indicates the rounding precision used, it is provided on
     37 + // a call to newValue.
     38 + NonZeroDecimals int
     39 + 
     40 + // text value if this value was constructed using NewTextValue.
     41 + text string
     42 +}
     43 + 
     44 +// String implements fmt.Stringer.
     45 +func (v *Value) String() string {
     46 + return fmt.Sprintf("Value{%v, %v}", v.Value, v.Rounded)
     47 +}
     48 + 
     49 +// NewValue returns a new instance representing the provided value, rounding
     50 +// the value up to the specified number of non-zero decimal places.
     51 +func NewValue(v float64, nonZeroDecimals int) *Value {
     52 + r, zd := numbers.RoundToNonZeroPlaces(v, nonZeroDecimals)
     53 + return &Value{
     54 + Value: v,
     55 + Rounded: r,
     56 + ZeroDecimals: zd,
     57 + NonZeroDecimals: nonZeroDecimals,
     58 + }
     59 +}
     60 + 
     61 +// NewTextValue constructs a value out of the provided text.
     62 +func NewTextValue(text string) *Value {
     63 + return &Value{
     64 + Value: math.NaN(),
     65 + Rounded: math.NaN(),
     66 + text: text,
     67 + }
     68 +}
     69 + 
     70 +// Text returns textual representation of the value.
     71 +func (v *Value) Text() string {
     72 + if v.text != "" {
     73 + return v.text
     74 + }
     75 + if math.Ceil(v.Rounded) == v.Rounded {
     76 + return fmt.Sprintf("%.0f", v.Rounded)
     77 + }
     78 + 
     79 + format := fmt.Sprintf("%%.%df", v.NonZeroDecimals+v.ZeroDecimals)
     80 + t := fmt.Sprintf(format, v.Rounded)
     81 + if len(t) > 10 {
     82 + t = fmt.Sprintf("%.2e", v.Rounded)
     83 + }
     84 + return t
     85 +}
     86 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/axes/value_test.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package axes
     16 + 
     17 +import (
     18 + "fmt"
     19 + "testing"
     20 + 
     21 + "github.com/kylelemons/godebug/pretty"
     22 +)
     23 + 
     24 +func TestValue(t *testing.T) {
     25 + tests := []struct {
     26 + desc string
     27 + float float64
     28 + nonZeroDecimals int
     29 + want *Value
     30 + }{
     31 + {
     32 + desc: "handles zeroes",
     33 + float: 0,
     34 + nonZeroDecimals: 0,
     35 + want: &Value{
     36 + Value: 0,
     37 + Rounded: 0,
     38 + ZeroDecimals: 0,
     39 + NonZeroDecimals: 0,
     40 + },
     41 + },
     42 + {
     43 + desc: "rounds to requested precision",
     44 + float: 1.01234,
     45 + nonZeroDecimals: 2,
     46 + want: &Value{
     47 + Value: 1.01234,
     48 + Rounded: 1.013,
     49 + ZeroDecimals: 1,
     50 + NonZeroDecimals: 2,
     51 + },
     52 + },
     53 + {
     54 + desc: "no rounding when not requested",
     55 + float: 1.01234,
     56 + nonZeroDecimals: 0,
     57 + want: &Value{
     58 + Value: 1.01234,
     59 + Rounded: 1.01234,
     60 + ZeroDecimals: 1,
     61 + NonZeroDecimals: 0,
     62 + },
     63 + },
     64 + }
     65 + 
     66 + for _, tc := range tests {
     67 + t.Run(tc.desc, func(t *testing.T) {
     68 + got := NewValue(tc.float, tc.nonZeroDecimals)
     69 + if diff := pretty.Compare(tc.want, got); diff != "" {
     70 + t.Errorf("NewValue => unexpected diff (-want, +got):\n%s", diff)
     71 + }
     72 + })
     73 + }
     74 +}
     75 + 
     76 +func TestText(t *testing.T) {
     77 + tests := []struct {
     78 + value float64
     79 + nonZeroDecimals int
     80 + wantRounded float64
     81 + wantText string
     82 + }{
     83 + {0, 2, 0, "0"},
     84 + {10, 2, 10, "10"},
     85 + {-10, 2, -10, "-10"},
     86 + {0.5, 2, 0.5, "0.50"},
     87 + {-0.5, 2, -0.5, "-0.50"},
     88 + {100.5, 2, 100.5, "100.50"},
     89 + {-100.5, 2, -100.5, "-100.50"},
     90 + {0.12345, 1, 0.2, "0.2"},
     91 + {0.12345, 2, 0.13, "0.13"},
     92 + {0.123, 4, 0.123, "0.1230"},
     93 + {-0.12345, 2, -0.12, "-0.12"},
     94 + {999.12345, 2, 999.13, "999.13"},
     95 + {-999.12345, 2, -999.12, "-999.12"},
     96 + {999.00012345, 2, 999.00013, "999.00013"},
     97 + {-999.00012345, 2, -999.00012, "-999.00012"},
     98 + {100000.1, 2, 100000.1, "100000.10"},
     99 + {1000000.1, 2, 1000000.1, "1.00e+06"},
     100 + }
     101 + 
     102 + for _, tc := range tests {
     103 + t.Run(fmt.Sprintf("%v_%v", tc.value, tc.nonZeroDecimals), func(t *testing.T) {
     104 + v := NewValue(tc.value, tc.nonZeroDecimals)
     105 + gotRounded := v.Rounded
     106 + if gotRounded != tc.wantRounded {
     107 + t.Errorf("newValue(%v, %v).Rounded => got %v, want %v", tc.value, tc.nonZeroDecimals, gotRounded, tc.wantRounded)
     108 + }
     109 + 
     110 + gotText := v.Text()
     111 + if gotText != tc.wantText {
     112 + t.Errorf("newValue(%v, %v).Text => got %q, want %q", tc.value, tc.nonZeroDecimals, gotText, tc.wantText)
     113 + }
     114 + 
     115 + })
     116 + }
     117 +}
     118 + 
     119 +func TestNewTextValue(t *testing.T) {
     120 + const want = "foo"
     121 + v := NewTextValue(want)
     122 + got := v.Text()
     123 + if got != want {
     124 + t.Errorf("v.Text => got %q, want %q", got, want)
     125 + }
     126 +}
     127 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/linechart.go
     1 +// Copyright 2018 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +// Package linechart contains a widget that displays line charts.
     16 +package linechart
     17 + 
     18 +import (
     19 + "errors"
     20 + "fmt"
     21 + "image"
     22 + "sort"
     23 + "sync"
     24 + 
     25 + "github.com/mum4k/termdash/canvas"
     26 + "github.com/mum4k/termdash/canvas/braille"
     27 + "github.com/mum4k/termdash/cell"
     28 + "github.com/mum4k/termdash/draw"
     29 + "github.com/mum4k/termdash/numbers"
     30 + "github.com/mum4k/termdash/terminalapi"
     31 + "github.com/mum4k/termdash/widgetapi"
     32 + "github.com/mum4k/termdash/widgets/linechart/axes"
     33 +)
     34 + 
     35 +// seriesValues represent values stored in the series.
     36 +type seriesValues struct {
     37 + // values are the values in the series.
     38 + values []float64
     39 + // min is the smallest value, zero if values is empty.
     40 + min float64
     41 + // max is the largest value, zero if values is empty.
     42 + max float64
     43 + 
     44 + seriesCellOpts []cell.Option
     45 + // The custom labels provided on a call to Series and a bool indicating if
     46 + // the labels were provided. This allows resetting them to nil.
     47 + xLabelsSet bool
     48 + xLabels map[int]string
     49 +}
     50 + 
     51 +// newSeriesValues returns a new seriesValues instance.
     52 +func newSeriesValues(values []float64) *seriesValues {
     53 + min, max := numbers.MinMax(values)
     54 + return &seriesValues{
     55 + values: values,
     56 + min: min,
     57 + max: max,
     58 + }
     59 +}
     60 + 
     61 +// LineChart draws line charts.
     62 +//
     63 +// Each line chart has an identifying label and a set of values that are
     64 +// plotted.
     65 +//
     66 +// The size of the two axes is determined from the values.
     67 +// The X axis will have a number of evenly distributed data points equal to the
     68 +// largest count of values among all the labeled line charts.
     69 +// The Y axis will be sized so that it can conveniently accommodate the largest
     70 +// value among all the labeled line charts. This determines the used scale.
     71 +//
     72 +// Implements widgetapi.Widget. This object is thread-safe.
     73 +type LineChart struct {
     74 + // mu protects the LineChart widget.
     75 + mu sync.Mutex
     76 + 
     77 + // series are the series that will be plotted.
     78 + // Keyed by the name of the series and updated by calling Series.
     79 + series map[string]*seriesValues
     80 + 
     81 + // yAxis is the Y axis of the line chart.
     82 + yAxis *axes.Y
     83 + 
     84 + // opts are the provided options.
     85 + opts *options
     86 + 
     87 + // xLabels that were provided on a call to Series.
     88 + xLabels map[int]string
     89 +}
     90 + 
     91 +// New returns a new line chart widget.
     92 +func New(opts ...Option) *LineChart {
     93 + opt := newOptions(opts...)
     94 + return &LineChart{
     95 + series: map[string]*seriesValues{},
     96 + yAxis: axes.NewY(0, 0),
     97 + opts: opt,
     98 + }
     99 +}
     100 + 
     101 +// SeriesOption is used to provide options to Series.
     102 +type SeriesOption interface {
     103 + // set sets the provided option.
     104 + set(*seriesValues)
     105 +}
     106 + 
     107 +// seriesOption implements SeriesOption.
     108 +type seriesOption func(*seriesValues)
     109 + 
     110 +// set implements SeriesOption.set.
     111 +func (so seriesOption) set(sv *seriesValues) {
     112 + so(sv)
     113 +}
     114 + 
     115 +// SeriesCellOpts sets the cell options for this series.
     116 +// Note that the braille canvas has resolution of 2x4 pixels per cell, but each
     117 +// cell can only have one set of cell options set. Meaning that where series
     118 +// share a cell, the last drawn series sets the cell options. Series are drawn
     119 +// in alphabetical order based on their name.
     120 +func SeriesCellOpts(co ...cell.Option) SeriesOption {
     121 + return seriesOption(func(opts *seriesValues) {
     122 + opts.seriesCellOpts = co
     123 + })
     124 +}
     125 + 
     126 +// SeriesXLabels is used to provide custom labels for the X axis.
     127 +// The argument maps the positions in the provided series to the desired label.
     128 +// The labels are only used if they fit under the axis.
     129 +// Custom labels are property of the line chart, since there is only one X axis,
     130 +// providing multiple custom labels overwrites the previous value.
     131 +func SeriesXLabels(labels map[int]string) SeriesOption {
     132 + return seriesOption(func(opts *seriesValues) {
     133 + opts.xLabelsSet = true
     134 + opts.xLabels = labels
     135 + })
     136 +}
     137 + 
     138 +// Series sets the values that should be displayed as the line chart with the
     139 +// provided label.
     140 +// Subsequent calls with the same label replace any previously provided values.
     141 +func (lc *LineChart) Series(label string, values []float64, opts ...SeriesOption) error {
     142 + if label == "" {
     143 + return errors.New("the label cannot be empty")
     144 + }
     145 + 
     146 + lc.mu.Lock()
     147 + defer lc.mu.Unlock()
     148 + 
     149 + series := newSeriesValues(values)
     150 + for _, opt := range opts {
     151 + opt.set(series)
     152 + }
     153 + if series.xLabelsSet {
     154 + for i, t := range series.xLabels {
     155 + if i < 0 {
     156 + return fmt.Errorf("invalid key %d -> %q provided in SeriesXLabels, keys must be positive", i, t)
     157 + }
     158 + if t == "" {
     159 + return fmt.Errorf("invalid label %d -> %q provided in SeriesXLabels, values cannot be empty", i, t)
     160 + }
     161 + }
     162 + lc.xLabels = series.xLabels
     163 + }
     164 + 
     165 + lc.series[label] = series
     166 + lc.yAxis = axes.NewY(series.min, series.max)
     167 + return nil
     168 +}
     169 + 
     170 +// Draw draws the values as line charts.
     171 +// Implements widgetapi.Widget.Draw.
     172 +func (lc *LineChart) Draw(cvs *canvas.Canvas) error {
     173 + lc.mu.Lock()
     174 + defer lc.mu.Unlock()
     175 + 
     176 + yd, err := lc.yAxis.Details(cvs.Area())
     177 + if err != nil {
     178 + return fmt.Errorf("lc.yAxis.Details => %v", err)
     179 + }
     180 + 
     181 + xd, err := axes.NewXDetails(lc.maxPoints(), yd.Start, cvs.Area(), lc.xLabels)
     182 + if err != nil {
     183 + return fmt.Errorf("NewXDetails => %v", err)
     184 + }
     185 + 
     186 + if err := lc.drawAxes(cvs, xd, yd); err != nil {
     187 + return err
     188 + }
     189 + return lc.drawSeries(cvs, xd, yd)
     190 +}
     191 + 
     192 +// drawAxes draws the X,Y axes and their labels.
     193 +func (lc *LineChart) drawAxes(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error {
     194 + lines := []draw.HVLine{
     195 + {Start: yd.Start, End: yd.End},
     196 + {Start: xd.Start, End: xd.End},
     197 + }
     198 + if err := draw.HVLines(cvs, lines, draw.HVLineCellOpts(lc.opts.axesCellOpts...)); err != nil {
     199 + return fmt.Errorf("failed to draw the axes: %v", err)
     200 + }
     201 + 
     202 + for _, l := range yd.Labels {
     203 + if err := draw.Text(cvs, l.Value.Text(), l.Pos,
     204 + draw.TextMaxX(yd.Start.X),
     205 + draw.TextOverrunMode(draw.OverrunModeThreeDot),
     206 + draw.TextCellOpts(lc.opts.yLabelCellOpts...),
     207 + ); err != nil {
     208 + return fmt.Errorf("failed to draw the Y labels: %v", err)
     209 + }
     210 + }
     211 + 
     212 + for _, l := range xd.Labels {
     213 + if err := draw.Text(cvs, l.Value.Text(), l.Pos, draw.TextCellOpts(lc.opts.xLabelCellOpts...)); err != nil {
     214 + return fmt.Errorf("failed to draw the X labels: %v", err)
     215 + }
     216 + }
     217 + return nil
     218 +}
     219 + 
     220 +// drawSeries draws the graph representing the stored series.
     221 +func (lc *LineChart) drawSeries(cvs *canvas.Canvas, xd *axes.XDetails, yd *axes.YDetails) error {
     222 + // The area available to the graph.
     223 + graphAr := image.Rect(yd.Start.X+1, yd.Start.Y, cvs.Area().Max.X, xd.End.Y)
     224 + bc, err := braille.New(graphAr)
     225 + if err != nil {
     226 + return fmt.Errorf("braille.New => %v", err)
     227 + }
     228 + 
     229 + var names []string
     230 + for name := range lc.series {
     231 + names = append(names, name)
     232 + }
     233 + sort.Strings(names)
     234 + 
     235 + for _, name := range names {
     236 + sv := lc.series[name]
     237 + if len(sv.values) <= 1 {
     238 + continue
     239 + }
     240 + 
     241 + prev := sv.values[0]
     242 + for i := 1; i < len(sv.values); i++ {
     243 + startX, err := xd.Scale.ValueToPixel(i - 1)
     244 + if err != nil {
     245 + return fmt.Errorf("failure for series %v[%d], xd.Scale.ValueToPixel => %v", name, i-1, err)
     246 + }
     247 + endX, err := xd.Scale.ValueToPixel(i)
     248 + if err != nil {
     249 + return fmt.Errorf("failure for series %v[%d], xd.Scale.ValueToPixel => %v", name, i, err)
     250 + }
     251 + 
     252 + startY, err := yd.Scale.ValueToPixel(prev)
     253 + if err != nil {
     254 + return fmt.Errorf("failure for series %v[%d], yd.Scale.ValueToPixel => %v", name, i-1, err)
     255 + }
     256 + v := sv.values[i]
     257 + endY, err := yd.Scale.ValueToPixel(v)
     258 + if err != nil {
     259 + return fmt.Errorf("failure for series %v[%d], yd.Scale.ValueToPixel => %v", name, i, err)
     260 + }
     261 + 
     262 + if err := draw.BrailleLine(bc,
     263 + image.Point{startX, startY},
     264 + image.Point{endX, endY},
     265 + draw.BrailleLineCellOpts(sv.seriesCellOpts...),
     266 + ); err != nil {
     267 + return fmt.Errorf("draw.BrailleLine => %v", err)
     268 + }
     269 + prev = v
     270 + }
     271 + }
     272 + if err := bc.CopyTo(cvs); err != nil {
     273 + return fmt.Errorf("bc.Apply => %v", err)
     274 + }
     275 + return nil
     276 +}
     277 + 
     278 +// Implements widgetapi.Widget.Keyboard.
     279 +func (lc *LineChart) Keyboard(k *terminalapi.Keyboard) error {
     280 + return errors.New("the LineChart widget doesn't support keyboard events")
     281 +}
     282 + 
     283 +// Implements widgetapi.Widget.Mouse.
     284 +func (lc *LineChart) Mouse(m *terminalapi.Mouse) error {
     285 + return errors.New("the LineChart widget doesn't support mouse events")
     286 +}
     287 + 
     288 +// Options implements widgetapi.Widget.Options.
     289 +func (lc *LineChart) Options() widgetapi.Options {
     290 + lc.mu.Lock()
     291 + defer lc.mu.Unlock()
     292 + 
     293 + // At the very least we need:
     294 + // - n cells width for the Y axis and its labels as reported by it.
     295 + // - at least 1 cell width for the graph.
     296 + reqWidth := lc.yAxis.RequiredWidth() + 1
     297 + // - 2 cells height the X axis and its values and 2 for min and max labels on Y.
     298 + const reqHeight = 4
     299 + return widgetapi.Options{
     300 + MinimumSize: image.Point{reqWidth, reqHeight},
     301 + }
     302 +}
     303 + 
     304 +// maxPoints returns the largest number of points among all the series.
     305 +// lc.mu must be held when calling this method.
     306 +func (lc *LineChart) maxPoints() int {
     307 + max := 0
     308 + for _, sv := range lc.series {
     309 + if num := len(sv.values); num > max {
     310 + max = num
     311 + }
     312 + }
     313 + return max
     314 +}
     315 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/linechart_test.go
     1 +// Copyright 2018 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package linechart
     16 + 
     17 +import (
     18 + "image"
     19 + "testing"
     20 + 
     21 + "github.com/kylelemons/godebug/pretty"
     22 + "github.com/mum4k/termdash/canvas"
     23 + "github.com/mum4k/termdash/canvas/braille/testbraille"
     24 + "github.com/mum4k/termdash/canvas/testcanvas"
     25 + "github.com/mum4k/termdash/cell"
     26 + "github.com/mum4k/termdash/draw"
     27 + "github.com/mum4k/termdash/draw/testdraw"
     28 + "github.com/mum4k/termdash/terminal/faketerm"
     29 + "github.com/mum4k/termdash/widgetapi"
     30 +)
     31 + 
     32 +func TestLineChartDraws(t *testing.T) {
     33 + tests := []struct {
     34 + desc string
     35 + canvas image.Rectangle
     36 + opts []Option
     37 + writes func(*LineChart) error
     38 + want func(size image.Point) *faketerm.Terminal
     39 + wantWriteErr bool
     40 + wantErr bool
     41 + }{
     42 + {
     43 + desc: "series fails without name for the series",
     44 + canvas: image.Rect(0, 0, 3, 4),
     45 + writes: func(lc *LineChart) error {
     46 + return lc.Series("", nil)
     47 + },
     48 + wantWriteErr: true,
     49 + },
     50 + {
     51 + desc: "series fails when custom label has negative key",
     52 + canvas: image.Rect(0, 0, 3, 4),
     53 + writes: func(lc *LineChart) error {
     54 + return lc.Series("series", nil, SeriesXLabels(map[int]string{-1: "text"}))
     55 + },
     56 + wantWriteErr: true,
     57 + },
     58 + {
     59 + desc: "series fails when custom label has empty value",
     60 + canvas: image.Rect(0, 0, 3, 4),
     61 + writes: func(lc *LineChart) error {
     62 + return lc.Series("series", nil, SeriesXLabels(map[int]string{1: ""}))
     63 + },
     64 + wantWriteErr: true,
     65 + },
     66 + {
     67 + desc: "draw fails when canvas not wide enough",
     68 + canvas: image.Rect(0, 0, 2, 4),
     69 + wantErr: true,
     70 + },
     71 + {
     72 + desc: "draw fails when canvas not tall enough",
     73 + canvas: image.Rect(0, 0, 3, 3),
     74 + wantErr: true,
     75 + },
     76 + {
     77 + desc: "empty without series",
     78 + canvas: image.Rect(0, 0, 3, 4),
     79 + want: func(size image.Point) *faketerm.Terminal {
     80 + ft := faketerm.MustNew(size)
     81 + c := testcanvas.MustNew(ft.Area())
     82 + 
     83 + // Y and X axis.
     84 + lines := []draw.HVLine{
     85 + {Start: image.Point{1, 0}, End: image.Point{1, 2}},
     86 + {Start: image.Point{1, 2}, End: image.Point{2, 2}},
     87 + }
     88 + testdraw.MustHVLines(c, lines)
     89 + 
     90 + // Zero value labels.
     91 + testdraw.MustText(c, "0", image.Point{0, 1})
     92 + testdraw.MustText(c, "0", image.Point{2, 3})
     93 + 
     94 + testcanvas.MustApply(c, ft)
     95 + return ft
     96 + },
     97 + },
     98 + {
     99 + desc: "sets axes cell options",
     100 + canvas: image.Rect(0, 0, 3, 4),
     101 + opts: []Option{
     102 + AxesCellOpts(
     103 + cell.BgColor(cell.ColorRed),
     104 + cell.FgColor(cell.ColorGreen),
     105 + ),
     106 + },
     107 + want: func(size image.Point) *faketerm.Terminal {
     108 + ft := faketerm.MustNew(size)
     109 + c := testcanvas.MustNew(ft.Area())
     110 + 
     111 + // Y and X axis.
     112 + lines := []draw.HVLine{
     113 + {Start: image.Point{1, 0}, End: image.Point{1, 2}},
     114 + {Start: image.Point{1, 2}, End: image.Point{2, 2}},
     115 + }
     116 + testdraw.MustHVLines(c, lines, draw.HVLineCellOpts(cell.BgColor(cell.ColorRed), cell.FgColor(cell.ColorGreen)))
     117 + 
     118 + // Zero value labels.
     119 + testdraw.MustText(c, "0", image.Point{0, 1})
     120 + testdraw.MustText(c, "0", image.Point{2, 3})
     121 + 
     122 + testcanvas.MustApply(c, ft)
     123 + return ft
     124 + },
     125 + },
     126 + {
     127 + desc: "sets label cell options",
     128 + canvas: image.Rect(0, 0, 3, 4),
     129 + opts: []Option{
     130 + XLabelCellOpts(
     131 + cell.BgColor(cell.ColorYellow),
     132 + cell.FgColor(cell.ColorBlue),
     133 + ),
     134 + YLabelCellOpts(
     135 + cell.BgColor(cell.ColorRed),
     136 + cell.FgColor(cell.ColorGreen),
     137 + ),
     138 + },
     139 + want: func(size image.Point) *faketerm.Terminal {
     140 + ft := faketerm.MustNew(size)
     141 + c := testcanvas.MustNew(ft.Area())
     142 + 
     143 + // Y and X axis.
     144 + lines := []draw.HVLine{
     145 + {Start: image.Point{1, 0}, End: image.Point{1, 2}},
     146 + {Start: image.Point{1, 2}, End: image.Point{2, 2}},
     147 + }
     148 + testdraw.MustHVLines(c, lines)
     149 + 
     150 + // Zero value labels.
     151 + testdraw.MustText(c, "0", image.Point{0, 1}, draw.TextCellOpts(cell.BgColor(cell.ColorRed), cell.FgColor(cell.ColorGreen)))
     152 + testdraw.MustText(c, "0", image.Point{2, 3}, draw.TextCellOpts(cell.BgColor(cell.ColorYellow), cell.FgColor(cell.ColorBlue)))
     153 + 
     154 + testcanvas.MustApply(c, ft)
     155 + return ft
     156 + },
     157 + },
     158 + {
     159 + desc: "two Y and X labels",
     160 + canvas: image.Rect(0, 0, 20, 10),
     161 + writes: func(lc *LineChart) error {
     162 + return lc.Series("first", []float64{0, 100})
     163 + },
     164 + want: func(size image.Point) *faketerm.Terminal {
     165 + ft := faketerm.MustNew(size)
     166 + c := testcanvas.MustNew(ft.Area())
     167 + 
     168 + // Y and X axis.
     169 + lines := []draw.HVLine{
     170 + {Start: image.Point{5, 0}, End: image.Point{5, 8}},
     171 + {Start: image.Point{5, 8}, End: image.Point{19, 8}},
     172 + }
     173 + testdraw.MustHVLines(c, lines)
     174 + 
     175 + // Value labels.
     176 + testdraw.MustText(c, "0", image.Point{4, 7})
     177 + testdraw.MustText(c, "51.68", image.Point{0, 3})
     178 + testdraw.MustText(c, "0", image.Point{6, 9})
     179 + testdraw.MustText(c, "1", image.Point{19, 9})
     180 + 
     181 + // Braille line.
     182 + graphAr := image.Rect(6, 0, 20, 8)
     183 + bc := testbraille.MustNew(graphAr)
     184 + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0})
     185 + testbraille.MustCopyTo(bc, c)
     186 + 
     187 + testcanvas.MustApply(c, ft)
     188 + return ft
     189 + },
     190 + },
     191 + {
     192 + desc: "custom X labels",
     193 + canvas: image.Rect(0, 0, 20, 10),
     194 + writes: func(lc *LineChart) error {
     195 + return lc.Series("first", []float64{0, 100}, SeriesXLabels(map[int]string{
     196 + 0: "start",
     197 + 1: "end",
     198 + }))
     199 + },
     200 + want: func(size image.Point) *faketerm.Terminal {
     201 + ft := faketerm.MustNew(size)
     202 + c := testcanvas.MustNew(ft.Area())
     203 + 
     204 + // Y and X axis.
     205 + lines := []draw.HVLine{
     206 + {Start: image.Point{5, 0}, End: image.Point{5, 8}},
     207 + {Start: image.Point{5, 8}, End: image.Point{19, 8}},
     208 + }
     209 + testdraw.MustHVLines(c, lines)
     210 + 
     211 + // Value labels.
     212 + testdraw.MustText(c, "0", image.Point{4, 7})
     213 + testdraw.MustText(c, "51.68", image.Point{0, 3})
     214 + testdraw.MustText(c, "start", image.Point{6, 9})
     215 + 
     216 + // Braille line.
     217 + graphAr := image.Rect(6, 0, 20, 8)
     218 + bc := testbraille.MustNew(graphAr)
     219 + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0})
     220 + testbraille.MustCopyTo(bc, c)
     221 + 
     222 + testcanvas.MustApply(c, ft)
     223 + return ft
     224 + },
     225 + },
     226 + {
     227 + desc: "sets series cell options",
     228 + canvas: image.Rect(0, 0, 20, 10),
     229 + writes: func(lc *LineChart) error {
     230 + return lc.Series("first", []float64{0, 100}, SeriesCellOpts(cell.BgColor(cell.ColorRed), cell.FgColor(cell.ColorGreen)))
     231 + },
     232 + want: func(size image.Point) *faketerm.Terminal {
     233 + ft := faketerm.MustNew(size)
     234 + c := testcanvas.MustNew(ft.Area())
     235 + 
     236 + // Y and X axis.
     237 + lines := []draw.HVLine{
     238 + {Start: image.Point{5, 0}, End: image.Point{5, 8}},
     239 + {Start: image.Point{5, 8}, End: image.Point{19, 8}},
     240 + }
     241 + testdraw.MustHVLines(c, lines)
     242 + 
     243 + // Value labels.
     244 + testdraw.MustText(c, "0", image.Point{4, 7})
     245 + testdraw.MustText(c, "51.68", image.Point{0, 3})
     246 + testdraw.MustText(c, "0", image.Point{6, 9})
     247 + testdraw.MustText(c, "1", image.Point{19, 9})
     248 + 
     249 + // Braille line.
     250 + graphAr := image.Rect(6, 0, 20, 8)
     251 + bc := testbraille.MustNew(graphAr)
     252 + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{26, 0}, draw.BrailleLineCellOpts(cell.BgColor(cell.ColorRed), cell.FgColor(cell.ColorGreen)))
     253 + testbraille.MustCopyTo(bc, c)
     254 + 
     255 + testcanvas.MustApply(c, ft)
     256 + return ft
     257 + },
     258 + },
     259 + {
     260 + desc: "multiple Y and X labels",
     261 + canvas: image.Rect(0, 0, 20, 11),
     262 + writes: func(lc *LineChart) error {
     263 + return lc.Series("first", []float64{0, 50, 100})
     264 + },
     265 + want: func(size image.Point) *faketerm.Terminal {
     266 + ft := faketerm.MustNew(size)
     267 + c := testcanvas.MustNew(ft.Area())
     268 + 
     269 + // Y and X axis.
     270 + lines := []draw.HVLine{
     271 + {Start: image.Point{5, 0}, End: image.Point{5, 9}},
     272 + {Start: image.Point{5, 9}, End: image.Point{19, 9}},
     273 + }
     274 + testdraw.MustHVLines(c, lines)
     275 + 
     276 + // Value labels.
     277 + testdraw.MustText(c, "0", image.Point{4, 8})
     278 + testdraw.MustText(c, "45.76", image.Point{0, 4})
     279 + testdraw.MustText(c, "91.52", image.Point{0, 0})
     280 + testdraw.MustText(c, "0", image.Point{6, 10})
     281 + testdraw.MustText(c, "1", image.Point{12, 10})
     282 + testdraw.MustText(c, "2", image.Point{19, 10})
     283 + 
     284 + // Braille line.
     285 + graphAr := image.Rect(6, 0, 20, 9)
     286 + bc := testbraille.MustNew(graphAr)
     287 + testdraw.MustBrailleLine(bc, image.Point{0, 35}, image.Point{13, 18})
     288 + testdraw.MustBrailleLine(bc, image.Point{13, 18}, image.Point{27, 0})
     289 + testbraille.MustCopyTo(bc, c)
     290 + 
     291 + testcanvas.MustApply(c, ft)
     292 + return ft
     293 + },
     294 + },
     295 + {
     296 + desc: "Y labels are trimmed",
     297 + canvas: image.Rect(0, 0, 5, 4),
     298 + writes: func(lc *LineChart) error {
     299 + return lc.Series("first", []float64{0, 100})
     300 + },
     301 + want: func(size image.Point) *faketerm.Terminal {
     302 + ft := faketerm.MustNew(size)
     303 + c := testcanvas.MustNew(ft.Area())
     304 + 
     305 + // Y and X axis.
     306 + lines := []draw.HVLine{
     307 + {Start: image.Point{3, 0}, End: image.Point{3, 2}},
     308 + {Start: image.Point{3, 2}, End: image.Point{4, 2}},
     309 + }
     310 + testdraw.MustHVLines(c, lines)
     311 + 
     312 + // Value labels.
     313 + testdraw.MustText(c, "0", image.Point{2, 1})
     314 + testdraw.MustText(c, "57…", image.Point{0, 0})
     315 + testdraw.MustText(c, "0", image.Point{4, 3})
     316 + 
     317 + // Braille line.
     318 + graphAr := image.Rect(4, 0, 5, 2)
     319 + bc := testbraille.MustNew(graphAr)
     320 + testdraw.MustBrailleLine(bc, image.Point{0, 7}, image.Point{1, 0})
     321 + testbraille.MustCopyTo(bc, c)
     322 + 
     323 + testcanvas.MustApply(c, ft)
     324 + return ft
     325 + },
     326 + },
     327 + {
     328 + desc: "draw multiple series",
     329 + canvas: image.Rect(0, 0, 20, 10),
     330 + writes: func(lc *LineChart) error {
     331 + if err := lc.Series("first", []float64{0, 50, 100}); err != nil {
     332 + return err
     333 + }
     334 + return lc.Series("second", []float64{100, 0})
     335 + },
     336 + want: func(size image.Point) *faketerm.Terminal {
     337 + ft := faketerm.MustNew(size)
     338 + c := testcanvas.MustNew(ft.Area())
     339 + 
     340 + // Y and X axis.
     341 + lines := []draw.HVLine{
     342 + {Start: image.Point{5, 0}, End: image.Point{5, 8}},
     343 + {Start: image.Point{5, 8}, End: image.Point{19, 8}},
     344 + }
     345 + testdraw.MustHVLines(c, lines)
     346 + 
     347 + // Value labels.
     348 + testdraw.MustText(c, "0", image.Point{4, 7})
     349 + testdraw.MustText(c, "51.68", image.Point{0, 3})
     350 + testdraw.MustText(c, "0", image.Point{6, 9})
     351 + testdraw.MustText(c, "1", image.Point{12, 9})
     352 + testdraw.MustText(c, "2", image.Point{19, 9})
     353 + 
     354 + // Braille line.
     355 + graphAr := image.Rect(6, 0, 20, 8)
     356 + bc := testbraille.MustNew(graphAr)
     357 + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{27, 0})
     358 + testdraw.MustBrailleLine(bc, image.Point{0, 0}, image.Point{13, 31})
     359 + testbraille.MustCopyTo(bc, c)
     360 + 
     361 + testcanvas.MustApply(c, ft)
     362 + return ft
     363 + },
     364 + },
     365 + {
     366 + desc: "draw multiple series with different cell options, last series wins where they cross",
     367 + canvas: image.Rect(0, 0, 20, 10),
     368 + writes: func(lc *LineChart) error {
     369 + if err := lc.Series("first", []float64{0, 50, 100}, SeriesCellOpts(cell.FgColor(cell.ColorRed))); err != nil {
     370 + return err
     371 + }
     372 + return lc.Series("second", []float64{100, 0}, SeriesCellOpts(cell.FgColor(cell.ColorBlue)))
     373 + },
     374 + want: func(size image.Point) *faketerm.Terminal {
     375 + ft := faketerm.MustNew(size)
     376 + c := testcanvas.MustNew(ft.Area())
     377 + 
     378 + // Y and X axis.
     379 + lines := []draw.HVLine{
     380 + {Start: image.Point{5, 0}, End: image.Point{5, 8}},
     381 + {Start: image.Point{5, 8}, End: image.Point{19, 8}},
     382 + }
     383 + testdraw.MustHVLines(c, lines)
     384 + 
     385 + // Value labels.
     386 + testdraw.MustText(c, "0", image.Point{4, 7})
     387 + testdraw.MustText(c, "51.68", image.Point{0, 3})
     388 + testdraw.MustText(c, "0", image.Point{6, 9})
     389 + testdraw.MustText(c, "1", image.Point{12, 9})
     390 + testdraw.MustText(c, "2", image.Point{19, 9})
     391 + 
     392 + // Braille line.
     393 + graphAr := image.Rect(6, 0, 20, 8)
     394 + bc := testbraille.MustNew(graphAr)
     395 + testdraw.MustBrailleLine(bc, image.Point{0, 31}, image.Point{27, 0}, draw.BrailleLineCellOpts(cell.FgColor(cell.ColorRed)))
     396 + testdraw.MustBrailleLine(bc, image.Point{0, 0}, image.Point{13, 31}, draw.BrailleLineCellOpts(cell.FgColor(cell.ColorBlue)))
     397 + testbraille.MustCopyTo(bc, c)
     398 + 
     399 + testcanvas.MustApply(c, ft)
     400 + return ft
     401 + },
     402 + },
     403 + }
     404 + 
     405 + for _, tc := range tests {
     406 + t.Run(tc.desc, func(t *testing.T) {
     407 + c, err := canvas.New(tc.canvas)
     408 + if err != nil {
     409 + t.Fatalf("canvas.New => unexpected error: %v", err)
     410 + }
     411 + 
     412 + widget := New(tc.opts...)
     413 + if tc.writes != nil {
     414 + err := tc.writes(widget)
     415 + if (err != nil) != tc.wantWriteErr {
     416 + t.Errorf("Series => unexpected error: %v, wantWriteErr: %v", err, tc.wantWriteErr)
     417 + }
     418 + if err != nil {
     419 + return
     420 + }
     421 + }
     422 + 
     423 + {
     424 + err := widget.Draw(c)
     425 + if (err != nil) != tc.wantErr {
     426 + t.Fatalf("Draw => unexpected error: %v, wantErr: %v", err, tc.wantErr)
     427 + }
     428 + if err != nil {
     429 + return
     430 + }
     431 + }
     432 + 
     433 + got, err := faketerm.New(c.Size())
     434 + if err != nil {
     435 + t.Fatalf("faketerm.New => unexpected error: %v", err)
     436 + }
     437 + 
     438 + if err := c.Apply(got); err != nil {
     439 + t.Fatalf("Apply => unexpected error: %v", err)
     440 + }
     441 + 
     442 + want := faketerm.MustNew(c.Size())
     443 + if tc.want != nil {
     444 + want = tc.want(c.Size())
     445 + }
     446 + if diff := faketerm.Diff(want, got); diff != "" {
     447 + t.Errorf("Draw => %v", diff)
     448 + }
     449 + })
     450 + }
     451 +}
     452 + 
     453 +func TestOptions(t *testing.T) {
     454 + tests := []struct {
     455 + desc string
     456 + // if not nil, executed before obtaining the options.
     457 + addSeries func(*LineChart) error
     458 + want widgetapi.Options
     459 + }{
     460 + {
     461 + desc: "reserves space for axis without series",
     462 + want: widgetapi.Options{
     463 + MinimumSize: image.Point{3, 4},
     464 + },
     465 + },
     466 + {
     467 + desc: "reserves space for longer Y labels",
     468 + addSeries: func(lc *LineChart) error {
     469 + return lc.Series("series", []float64{0, 100})
     470 + },
     471 + want: widgetapi.Options{
     472 + MinimumSize: image.Point{5, 4},
     473 + },
     474 + },
     475 + {
     476 + desc: "reserves space for negative Y labels",
     477 + addSeries: func(lc *LineChart) error {
     478 + return lc.Series("series", []float64{-100, 100})
     479 + },
     480 + want: widgetapi.Options{
     481 + MinimumSize: image.Point{6, 4},
     482 + },
     483 + },
     484 + }
     485 + 
     486 + for _, tc := range tests {
     487 + t.Run(tc.desc, func(t *testing.T) {
     488 + lc := New()
     489 + 
     490 + if tc.addSeries != nil {
     491 + if err := tc.addSeries(lc); err != nil {
     492 + t.Fatalf("tc.addSeries => %v", err)
     493 + }
     494 + }
     495 + got := lc.Options()
     496 + if diff := pretty.Compare(tc.want, got); diff != "" {
     497 + t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff)
     498 + }
     499 + })
     500 + }
     501 +}
     502 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/linechartdemo/linechartdemo.go
     1 +// Copyright 2019 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +// Binary linechartdemo displays a linechart widget.
     16 +// Exist when 'q' is pressed.
     17 +package main
     18 + 
     19 +import (
     20 + "context"
     21 + "math"
     22 + "time"
     23 + 
     24 + "github.com/mum4k/termdash"
     25 + "github.com/mum4k/termdash/cell"
     26 + "github.com/mum4k/termdash/container"
     27 + "github.com/mum4k/termdash/draw"
     28 + "github.com/mum4k/termdash/terminal/termbox"
     29 + "github.com/mum4k/termdash/terminalapi"
     30 + "github.com/mum4k/termdash/widgets/linechart"
     31 +)
     32 + 
     33 +// sineInputs generates values from -1 to 1 for display on the line chart.
     34 +func sineInputs() []float64 {
     35 + var res []float64
     36 + 
     37 + for i := 0; i < 200; i++ {
     38 + v := math.Sin(float64(i) / 100 * math.Pi)
     39 + res = append(res, v)
     40 + }
     41 + return res
     42 +}
     43 + 
     44 +// playLineChart continuously adds values to the LineChart, once every delay.
     45 +// Exits when the context expires.
     46 +func playLineChart(ctx context.Context, lc *linechart.LineChart, delay time.Duration) {
     47 + inputs := sineInputs()
     48 + ticker := time.NewTicker(delay)
     49 + defer ticker.Stop()
     50 + for i := 0; ; {
     51 + select {
     52 + case <-ticker.C:
     53 + i = (i + 1) % len(inputs)
     54 + rotated := append(inputs[i:], inputs[:i]...)
     55 + if err := lc.Series("first", rotated,
     56 + linechart.SeriesCellOpts(cell.FgColor(cell.ColorBlue)),
     57 + linechart.SeriesXLabels(map[int]string{
     58 + 0: "zero",
     59 + }),
     60 + ); err != nil {
     61 + panic(err)
     62 + }
     63 + 
     64 + i2 := (i + 100) % len(inputs)
     65 + rotated2 := append(inputs[i2:], inputs[:i2]...)
     66 + if err := lc.Series("second", rotated2, linechart.SeriesCellOpts(cell.FgColor(cell.ColorWhite))); err != nil {
     67 + panic(err)
     68 + }
     69 + 
     70 + case <-ctx.Done():
     71 + return
     72 + }
     73 + }
     74 +}
     75 + 
     76 +func main() {
     77 + t, err := termbox.New()
     78 + if err != nil {
     79 + panic(err)
     80 + }
     81 + defer t.Close()
     82 + 
     83 + const redrawInterval = 25 * time.Millisecond
     84 + ctx, cancel := context.WithCancel(context.Background())
     85 + lc := linechart.New(
     86 + linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)),
     87 + linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)),
     88 + linechart.XLabelCellOpts(cell.FgColor(cell.ColorCyan)),
     89 + )
     90 + go playLineChart(ctx, lc, redrawInterval/3)
     91 + c := container.New(
     92 + t,
     93 + container.Border(draw.LineStyleLight),
     94 + container.BorderTitle("PRESS Q TO QUIT"),
     95 + container.PlaceWidget(lc),
     96 + )
     97 + 
     98 + quitter := func(k *terminalapi.Keyboard) {
     99 + if k.Key == 'q' || k.Key == 'Q' {
     100 + cancel()
     101 + }
     102 + }
     103 + 
     104 + if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(redrawInterval)); err != nil {
     105 + panic(err)
     106 + }
     107 +}
     108 + 
  • ■ ■ ■ ■ ■ ■
    widgets/linechart/options.go
     1 +// Copyright 2018 Google Inc.
     2 +//
     3 +// Licensed under the Apache License, Version 2.0 (the "License");
     4 +// you may not use this file except in compliance with the License.
     5 +// You may obtain a copy of the License at
     6 +//
     7 +// http://www.apache.org/licenses/LICENSE-2.0
     8 +//
     9 +// Unless required by applicable law or agreed to in writing, software
     10 +// distributed under the License is distributed on an "AS IS" BASIS,
     11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 +// See the License for the specific language governing permissions and
     13 +// limitations under the License.
     14 + 
     15 +package linechart
     16 + 
     17 +import "github.com/mum4k/termdash/cell"
     18 + 
     19 +// options.go contains configurable options for LineChart.
     20 + 
     21 +// Option is used to provide options to New().
     22 +type Option interface {
     23 + // set sets the provided option.
     24 + set(*options)
     25 +}
     26 + 
     27 +// options stores the provided options.
     28 +type options struct {
     29 + axesCellOpts []cell.Option
     30 + xLabelCellOpts []cell.Option
     31 + yLabelCellOpts []cell.Option
     32 +}
     33 + 
     34 +// newOptions returns a new options instance.
     35 +func newOptions(opts ...Option) *options {
     36 + opt := &options{}
     37 + for _, o := range opts {
     38 + o.set(opt)
     39 + }
     40 + return opt
     41 +}
     42 + 
     43 +// option implements Option.
     44 +type option func(*options)
     45 + 
     46 +// set implements Option.set.
     47 +func (o option) set(opts *options) {
     48 + o(opts)
     49 +}
     50 + 
     51 +// AxesCellOpts set the cell options for the X and Y axes.
     52 +func AxesCellOpts(co ...cell.Option) Option {
     53 + return option(func(opts *options) {
     54 + opts.axesCellOpts = co
     55 + })
     56 +}
     57 + 
     58 +// XLabelCellOpts set the cell options for the labels on the X axis.
     59 +func XLabelCellOpts(co ...cell.Option) Option {
     60 + return option(func(opts *options) {
     61 + opts.xLabelCellOpts = co
     62 + })
     63 +}
     64 + 
     65 +// YLabelCellOpts set the cell options for the labels on the Y axis.
     66 +func YLabelCellOpts(co ...cell.Option) Option {
     67 + return option(func(opts *options) {
     68 + opts.yLabelCellOpts = co
     69 + })
     70 +}
     71 + 
  • ■ ■ ■ ■ ■
    widgets/sparkline/sparks.go
    skipped 18 lines
    19 19   
    20 20  import (
    21 21   "fmt"
    22  - "math"
    23 22   
    24 23   runewidth "github.com/mattn/go-runewidth"
     24 + "github.com/mum4k/termdash/numbers"
    25 25  )
    26 26   
    27 27  // sparks are the characters used to draw the SparkLine.
    skipped 47 lines
    75 75   scale := float64(cellSparks) * float64(vertCells) / float64(max)
    76 76   
    77 77   // How many smallest spark elements are needed to represent the value.
    78  - elements := int(round(float64(value) * scale))
     78 + elements := int(numbers.Round(float64(value) * scale))
    79 79   
    80 80   b := blocks{
    81 81   full: elements / cellSparks,
    skipped 4 lines
    86 86   b.partSpark = sparks[part-1]
    87 87   }
    88 88   return b
    89  -}
    90  - 
    91  -// round returns the nearest integer, rounding half away from zero.
    92  -// Copied from the math package of Go 1.10 for backwards compatibility with Go
    93  -// 1.8 where the math.Round function doesn't exist yet.
    94  -func round(x float64) float64 {
    95  - t := math.Trunc(x)
    96  - if math.Abs(x-t) >= 0.5 {
    97  - return t + math.Copysign(1, x)
    98  - }
    99  - return t
    100 89  }
    101 90   
    102 91  // init ensures that all spark characters are half-width runes.
    skipped 10 lines
Please wait...
Page is in error, reload to recover