termui/linechart.go

352 lines
7.8 KiB
Go
Raw Normal View History

2017-01-13 23:07:43 -07:00
// Copyright 2017 Zack Guo <zack.y.guo@gmail.com>. All rights reserved.
2015-03-20 14:21:50 -06:00
// Use of this source code is governed by a MIT license that can
// be found in the LICENSE file.
2015-02-07 18:19:16 -07:00
package termui
import (
"fmt"
"math"
)
2015-02-07 18:19:16 -07:00
// only 16 possible combinations, why bother
var braillePatterns = map[[2]int]rune{
[2]int{0, 0}: '⣀',
[2]int{0, 1}: '⡠',
[2]int{0, 2}: '⡐',
[2]int{0, 3}: '⡈',
[2]int{1, 0}: '⢄',
[2]int{1, 1}: '⠤',
[2]int{1, 2}: '⠔',
[2]int{1, 3}: '⠌',
[2]int{2, 0}: '⢂',
[2]int{2, 1}: '⠢',
[2]int{2, 2}: '⠒',
[2]int{2, 3}: '⠊',
[2]int{3, 0}: '⢁',
[2]int{3, 1}: '⠡',
[2]int{3, 2}: '⠑',
[2]int{3, 3}: '⠉',
}
var lSingleBraille = [4]rune{'\u2840', '⠄', '⠂', '⠁'}
var rSingleBraille = [4]rune{'\u2880', '⠠', '⠐', '⠈'}
2016-03-13 23:45:45 -06:00
// LineChart has two modes: braille(default) and dot. Using braille gives 2x capacity as dot mode,
2015-03-24 15:16:43 -06:00
// because one braille char can represent two data points.
/*
lc := termui.NewLineChart()
2016-10-14 05:48:50 -06:00
lc.BorderLabel = "braille-mode Line Chart"
2015-03-24 15:16:43 -06:00
lc.Data = [1.2, 1.3, 1.5, 1.7, 1.5, 1.6, 1.8, 2.0]
lc.Width = 50
lc.Height = 12
lc.AxesColor = termui.ColorWhite
lc.LineColor = termui.ColorGreen | termui.AttrBold
// termui.Render(lc)...
*/
2015-02-07 18:19:16 -07:00
type LineChart struct {
Block
Data []float64
DataLabels []string // if unset, the data indices will be used
Mode string // braille | dot
2015-02-07 18:19:16 -07:00
DotStyle rune
LineColor Attribute
scale float64 // data span per cell on y-axis
2015-02-07 18:19:16 -07:00
AxesColor Attribute
drawingX int
drawingY int
axisYHeight int
axisXWidth int
axisYLabelGap int
axisXLabelGap int
2015-02-07 18:19:16 -07:00
topValue float64
bottomValue float64
labelX [][]rune
labelY [][]rune
labelYSpace int
maxY float64
minY float64
autoLabels bool
2015-02-07 18:19:16 -07:00
}
2015-03-24 15:16:43 -06:00
// NewLineChart returns a new LineChart with current theme.
2015-02-07 18:19:16 -07:00
func NewLineChart() *LineChart {
lc := &LineChart{Block: *NewBlock()}
2015-10-08 20:11:26 -06:00
lc.AxesColor = ThemeAttr("linechart.axes.fg")
lc.LineColor = ThemeAttr("linechart.line.fg")
2015-02-07 18:19:16 -07:00
lc.Mode = "braille"
lc.DotStyle = '•'
lc.axisXLabelGap = 2
lc.axisYLabelGap = 1
lc.bottomValue = math.Inf(1)
lc.topValue = math.Inf(-1)
2015-02-07 18:19:16 -07:00
return lc
}
// one cell contains two data points
2016-03-13 23:45:45 -06:00
// so the capacity is 2x as dot-mode
2015-10-07 12:25:59 -06:00
func (lc *LineChart) renderBraille() Buffer {
buf := NewBuffer()
// return: b -> which cell should the point be in
// m -> in the cell, divided into 4 equal height levels, which subcell?
getPos := func(d float64) (b, m int) {
cnt4 := int((d-lc.bottomValue)/(lc.scale/4) + 0.5)
b = cnt4 / 4
m = cnt4 % 4
2015-02-07 18:19:16 -07:00
return
}
// plot points
for i := 0; 2*i+1 < len(lc.Data) && i < lc.axisXWidth; i++ {
b0, m0 := getPos(lc.Data[2*i])
b1, m1 := getPos(lc.Data[2*i+1])
if b0 == b1 {
2015-10-07 12:25:59 -06:00
c := Cell{
Ch: braillePatterns[[2]int{m0, m1}],
2015-10-08 20:11:26 -06:00
Bg: lc.Bg,
2015-10-07 12:25:59 -06:00
Fg: lc.LineColor,
}
y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b0
x := lc.innerArea.Min.X + lc.labelYSpace + 1 + i
buf.Set(x, y, c)
} else {
2015-10-07 12:25:59 -06:00
c0 := Cell{Ch: lSingleBraille[m0],
Fg: lc.LineColor,
2015-10-08 20:11:26 -06:00
Bg: lc.Bg}
2015-10-07 12:25:59 -06:00
x0 := lc.innerArea.Min.X + lc.labelYSpace + 1 + i
y0 := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b0
buf.Set(x0, y0, c0)
c1 := Cell{Ch: rSingleBraille[m1],
Fg: lc.LineColor,
Bg: lc.Bg}
x1 := lc.innerArea.Min.X + lc.labelYSpace + 1 + i
y1 := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b1
buf.Set(x1, y1, c1)
2015-02-07 18:19:16 -07:00
}
}
2015-10-07 12:25:59 -06:00
return buf
2015-02-07 18:19:16 -07:00
}
2015-10-07 12:25:59 -06:00
func (lc *LineChart) renderDot() Buffer {
buf := NewBuffer()
2018-01-02 01:11:01 -07:00
lasty := -1 // previous y val
2015-02-07 18:19:16 -07:00
for i := 0; i < len(lc.Data) && i < lc.axisXWidth; i++ {
2015-10-07 12:25:59 -06:00
c := Cell{
Ch: lc.DotStyle,
Fg: lc.LineColor,
2015-10-08 20:11:26 -06:00
Bg: lc.Bg,
2015-10-07 12:25:59 -06:00
}
x := lc.innerArea.Min.X + lc.labelYSpace + 1 + i
y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - int((lc.Data[i]-lc.bottomValue)/lc.scale+0.5)
2018-01-02 01:11:01 -07:00
if lasty != -1 && lasty != y {
u := 1 // direction
if lasty > y {
u = -1 // put dot below
2017-01-05 08:45:31 -07:00
}
2018-01-02 01:11:01 -07:00
for fy := lasty + u; fy != y; fy += u { // fy: filling point's y val
dx := -1 // lastx := x-1 = x+dx
if u*(fy-lasty) >= u*(y-lasty)/2 {
dx = 0 // cancel the horizontal backspace when getting close to (x,y)
2017-01-05 10:13:08 -07:00
}
2018-01-02 01:11:01 -07:00
buf.Set(x+dx, fy, c)
2017-01-05 08:45:31 -07:00
}
}
2018-01-02 01:11:01 -07:00
lasty = y
2015-10-07 12:25:59 -06:00
buf.Set(x, y, c)
2015-02-07 18:19:16 -07:00
}
2015-10-07 12:25:59 -06:00
return buf
2015-02-07 18:19:16 -07:00
}
func (lc *LineChart) calcLabelX() {
lc.labelX = [][]rune{}
for i, l := 0, 0; i < len(lc.DataLabels) && l < lc.axisXWidth; i++ {
if lc.Mode == "dot" {
if l >= len(lc.DataLabels) {
break
}
s := str2runes(lc.DataLabels[l])
w := strWidth(lc.DataLabels[l])
if l+w <= lc.axisXWidth {
2015-02-07 18:19:16 -07:00
lc.labelX = append(lc.labelX, s)
}
l += w + lc.axisXLabelGap
} else { // braille
2015-02-07 18:19:16 -07:00
if 2*l >= len(lc.DataLabels) {
break
}
s := str2runes(lc.DataLabels[2*l])
w := strWidth(lc.DataLabels[2*l])
if l+w <= lc.axisXWidth {
2015-02-07 18:19:16 -07:00
lc.labelX = append(lc.labelX, s)
}
l += w + lc.axisXLabelGap
2015-02-07 18:19:16 -07:00
}
}
}
func shortenFloatVal(x float64) string {
s := fmt.Sprintf("%.2f", x)
if len(s)-3 > 3 {
s = fmt.Sprintf("%.2e", x)
}
if x < 0 {
s = fmt.Sprintf("%.2f", x)
}
return s
}
func (lc *LineChart) calcLabelY() {
span := lc.topValue - lc.bottomValue
lc.scale = span / float64(lc.axisYHeight)
n := (1 + lc.axisYHeight) / (lc.axisYLabelGap + 1)
2015-02-07 18:19:16 -07:00
lc.labelY = make([][]rune, n)
maxLen := 0
for i := 0; i < n; i++ {
s := str2runes(shortenFloatVal(lc.bottomValue + float64(i)*span/float64(n)))
if len(s) > maxLen {
maxLen = len(s)
}
lc.labelY[i] = s
}
lc.labelYSpace = maxLen
}
func (lc *LineChart) calcLayout() {
// set datalabels if it is not provided
if (lc.DataLabels == nil || len(lc.DataLabels) == 0) || lc.autoLabels {
lc.autoLabels = true
2015-02-07 18:19:16 -07:00
lc.DataLabels = make([]string, len(lc.Data))
for i := range lc.Data {
lc.DataLabels[i] = fmt.Sprint(i)
}
}
// lazy increase, to avoid y shaking frequently
// update bound Y when drawing is gonna overflow
2015-02-07 18:19:16 -07:00
lc.minY = lc.Data[0]
lc.maxY = lc.Data[0]
lc.bottomValue = lc.minY
lc.topValue = lc.maxY
// valid visible range
2015-10-07 12:25:59 -06:00
vrange := lc.innerArea.Dx()
if lc.Mode == "braille" {
2015-10-07 12:25:59 -06:00
vrange = 2 * lc.innerArea.Dx()
}
if vrange > len(lc.Data) {
vrange = len(lc.Data)
}
for _, v := range lc.Data[:vrange] {
2015-02-07 18:19:16 -07:00
if v > lc.maxY {
lc.maxY = v
}
if v < lc.minY {
lc.minY = v
}
}
span := lc.maxY - lc.minY
if lc.minY < lc.bottomValue {
lc.bottomValue = lc.minY - 0.2*span
}
if lc.maxY > lc.topValue {
lc.topValue = lc.maxY + 0.2*span
2015-02-07 18:19:16 -07:00
}
2015-10-07 12:25:59 -06:00
lc.axisYHeight = lc.innerArea.Dy() - 2
2015-02-07 18:19:16 -07:00
lc.calcLabelY()
2015-10-07 12:25:59 -06:00
lc.axisXWidth = lc.innerArea.Dx() - 1 - lc.labelYSpace
2015-02-07 18:19:16 -07:00
lc.calcLabelX()
2015-10-07 12:25:59 -06:00
lc.drawingX = lc.innerArea.Min.X + 1 + lc.labelYSpace
lc.drawingY = lc.innerArea.Min.Y
2015-02-07 18:19:16 -07:00
}
2015-10-07 12:25:59 -06:00
func (lc *LineChart) plotAxes() Buffer {
buf := NewBuffer()
origY := lc.innerArea.Min.Y + lc.innerArea.Dy() - 2
origX := lc.innerArea.Min.X + lc.labelYSpace
2015-02-07 18:19:16 -07:00
2015-10-07 12:25:59 -06:00
buf.Set(origX, origY, Cell{Ch: ORIGIN, Fg: lc.AxesColor, Bg: lc.Bg})
2015-02-07 18:19:16 -07:00
for x := origX + 1; x < origX+lc.axisXWidth; x++ {
2015-10-07 12:25:59 -06:00
buf.Set(x, origY, Cell{Ch: HDASH, Fg: lc.AxesColor, Bg: lc.Bg})
2015-02-07 18:19:16 -07:00
}
for dy := 1; dy <= lc.axisYHeight; dy++ {
2015-10-07 12:25:59 -06:00
buf.Set(origX, origY-dy, Cell{Ch: VDASH, Fg: lc.AxesColor, Bg: lc.Bg})
2015-02-07 18:19:16 -07:00
}
// x label
oft := 0
for _, rs := range lc.labelX {
if oft+len(rs) > lc.axisXWidth {
break
}
for j, r := range rs {
2015-10-07 12:25:59 -06:00
c := Cell{
Ch: r,
Fg: lc.AxesColor,
2015-10-08 20:11:26 -06:00
Bg: lc.Bg,
2015-10-07 12:25:59 -06:00
}
x := origX + oft + j
y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 1
buf.Set(x, y, c)
2015-02-07 18:19:16 -07:00
}
oft += len(rs) + lc.axisXLabelGap
2015-02-07 18:19:16 -07:00
}
// y labels
for i, rs := range lc.labelY {
for j, r := range rs {
2015-10-07 12:25:59 -06:00
buf.Set(
lc.innerArea.Min.X+j,
origY-i*(lc.axisYLabelGap+1),
2015-10-07 12:25:59 -06:00
Cell{Ch: r, Fg: lc.AxesColor, Bg: lc.Bg})
2015-02-07 18:19:16 -07:00
}
}
2015-10-07 12:25:59 -06:00
return buf
2015-02-07 18:19:16 -07:00
}
2015-03-24 15:16:43 -06:00
// Buffer implements Bufferer interface.
2015-10-08 20:11:26 -06:00
func (lc *LineChart) Buffer() Buffer {
2015-10-07 12:25:59 -06:00
buf := lc.Block.Buffer()
2015-02-08 10:00:00 -07:00
if lc.Data == nil || len(lc.Data) == 0 {
2015-10-08 20:11:26 -06:00
return buf
2015-02-08 10:00:00 -07:00
}
2015-02-07 18:19:16 -07:00
lc.calcLayout()
2015-10-07 12:25:59 -06:00
buf.Merge(lc.plotAxes())
2015-02-07 18:19:16 -07:00
if lc.Mode == "dot" {
2015-10-07 12:25:59 -06:00
buf.Merge(lc.renderDot())
2015-02-07 18:19:16 -07:00
} else {
2015-10-08 20:11:26 -06:00
buf.Merge(lc.renderBraille())
2015-02-07 18:19:16 -07:00
}
2015-10-07 12:25:59 -06:00
return buf
2015-02-07 18:19:16 -07:00
}