Merge pull request #81 from uber-archive/mjr-multi-series

add support for multiple data series in linechart
This commit is contained in:
Caleb Bassi 2018-08-16 17:31:03 -07:00
commit e4bc917824
2 changed files with 181 additions and 126 deletions

View File

@ -19,7 +19,7 @@ import (
"strings" "strings"
"github.com/gizak/termui" "github.com/gizak/termui"
"github.com/gizak/termui/extra" "github.com/gizak/termui/_extra"
) )
const statFilePath = "/proc/stat" const statFilePath = "/proc/stat"

View File

@ -7,6 +7,7 @@ package termui
import ( import (
"fmt" "fmt"
"math" "math"
"sort"
) )
// only 16 possible combinations, why bother // only 16 possible combinations, why bother
@ -35,12 +36,13 @@ var braillePatterns = map[[2]int]rune{
var lSingleBraille = [4]rune{'\u2840', '⠄', '⠂', '⠁'} var lSingleBraille = [4]rune{'\u2840', '⠄', '⠂', '⠁'}
var rSingleBraille = [4]rune{'\u2880', '⠠', '⠐', '⠈'} var rSingleBraille = [4]rune{'\u2880', '⠠', '⠐', '⠈'}
// LineChart has two modes: braille(default) and dot. Using braille gives 2x capacity as dot mode, // LineChart has two modes: braille(default) and dot.
// because one braille char can represent two data points. // A single braille character is a 2x4 grid of dots, so Using braille
// gives 2x X resolution and 4x Y resolution over dot mode.
/* /*
lc := termui.NewLineChart() lc := termui.NewLineChart()
lc.BorderLabel = "braille-mode Line Chart" lc.Border.Label = "braille-mode Line Chart"
lc.Data = [1.2, 1.3, 1.5, 1.7, 1.5, 1.6, 1.8, 2.0] lc.Data["name'] = [1.2, 1.3, 1.5, 1.7, 1.5, 1.6, 1.8, 2.0]
lc.Width = 50 lc.Width = 50
lc.Height = 12 lc.Height = 12
lc.AxesColor = termui.ColorWhite lc.AxesColor = termui.ColorWhite
@ -49,11 +51,12 @@ var rSingleBraille = [4]rune{'\u2880', '⠠', '⠐', '⠈'}
*/ */
type LineChart struct { type LineChart struct {
Block Block
Data []float64 Data map[string][]float64
DataLabels []string // if unset, the data indices will be used DataLabels []string // if unset, the data indices will be used
Mode string // braille | dot Mode string // braille | dot
DotStyle rune DotStyle rune
LineColor Attribute LineColor map[string]Attribute
defaultLineColor Attribute
scale float64 // data span per cell on y-axis scale float64 // data span per cell on y-axis
AxesColor Attribute AxesColor Attribute
drawingX int drawingX int
@ -69,25 +72,31 @@ type LineChart struct {
labelYSpace int labelYSpace int
maxY float64 maxY float64
minY float64 minY float64
autoLabels bool YPadding float64
YFloor float64
YCeil float64
} }
// NewLineChart returns a new LineChart with current theme. // NewLineChart returns a new LineChart with current theme.
func NewLineChart() *LineChart { func NewLineChart() *LineChart {
lc := &LineChart{Block: *NewBlock()} lc := &LineChart{Block: *NewBlock()}
lc.AxesColor = ThemeAttr("linechart.axes.fg") lc.AxesColor = ThemeAttr("linechart.axes.fg")
lc.LineColor = ThemeAttr("linechart.line.fg") lc.defaultLineColor = ThemeAttr("linechart.line.fg")
lc.Mode = "braille" lc.Mode = "braille"
lc.DotStyle = '•' lc.DotStyle = '•'
lc.Data = make(map[string][]float64)
lc.LineColor = make(map[string]Attribute)
lc.axisXLabelGap = 2 lc.axisXLabelGap = 2
lc.axisYLabelGap = 1 lc.axisYLabelGap = 1
lc.bottomValue = math.Inf(1) lc.bottomValue = math.Inf(1)
lc.topValue = math.Inf(-1) lc.topValue = math.Inf(-1)
lc.YPadding = 0.2
lc.YFloor = math.Inf(-1)
lc.YCeil = math.Inf(1)
return lc return lc
} }
// one cell contains two data points // one cell contains two data points, so capicity is 2x dot mode
// so the capacity is 2x as dot-mode
func (lc *LineChart) renderBraille() Buffer { func (lc *LineChart) renderBraille() Buffer {
buf := NewBuffer() buf := NewBuffer()
@ -99,68 +108,100 @@ func (lc *LineChart) renderBraille() Buffer {
m = cnt4 % 4 m = cnt4 % 4
return return
} }
// Sort the series so that overlapping data will overlap the same way each time
seriesList := make([]string, len(lc.Data))
i := 0
for seriesName := range lc.Data {
seriesList[i] = seriesName
i++
}
sort.Strings(seriesList)
// plot points // plot points
for i := 0; 2*i+1 < len(lc.Data) && i < lc.axisXWidth; i++ { for _, seriesName := range seriesList {
b0, m0 := getPos(lc.Data[2*i]) seriesData := lc.Data[seriesName]
b1, m1 := getPos(lc.Data[2*i+1]) if len(seriesData) == 0 {
continue
}
thisLineColor, ok := lc.LineColor[seriesName]
if !ok {
thisLineColor = lc.defaultLineColor
}
minCell := lc.innerArea.Min.X + lc.labelYSpace
cellPos := lc.innerArea.Max.X - 1
for dataPos := len(seriesData) - 1; dataPos >= 0 && cellPos > minCell; {
b0, m0 := getPos(seriesData[dataPos])
var b1, m1 int
if dataPos > 0 {
b1, m1 = getPos(seriesData[dataPos-1])
if b0 == b1 { if b0 == b1 {
c := Cell{ c := Cell{
Ch: braillePatterns[[2]int{m0, m1}], Ch: braillePatterns[[2]int{m1, m0}],
Bg: lc.Bg, Bg: lc.Bg,
Fg: lc.LineColor, Fg: thisLineColor,
} }
y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b0 y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b0
x := lc.innerArea.Min.X + lc.labelYSpace + 1 + i buf.Set(cellPos, y, c)
buf.Set(x, y, c)
} else { } else {
c0 := Cell{Ch: lSingleBraille[m0], c0 := Cell{
Fg: lc.LineColor, Ch: rSingleBraille[m0],
Bg: lc.Bg} Fg: thisLineColor,
x0 := lc.innerArea.Min.X + lc.labelYSpace + 1 + i Bg: lc.Bg,
}
y0 := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b0
buf.Set(cellPos, y0, c0)
c1 := Cell{
Ch: lSingleBraille[m1],
Fg: thisLineColor,
Bg: lc.Bg,
}
y1 := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b1
buf.Set(cellPos, y1, c1)
}
} else {
c0 := Cell{
Ch: rSingleBraille[m0],
Fg: thisLineColor,
Bg: lc.Bg,
}
x0 := cellPos
y0 := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b0 y0 := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b0
buf.Set(x0, y0, c0) 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)
} }
dataPos -= 2
cellPos--
}
} }
return buf return buf
} }
func (lc *LineChart) renderDot() Buffer { func (lc *LineChart) renderDot() Buffer {
buf := NewBuffer() buf := NewBuffer()
lasty := -1 // previous y val for seriesName, seriesData := range lc.Data {
for i := 0; i < len(lc.Data) && i < lc.axisXWidth; i++ { thisLineColor, ok := lc.LineColor[seriesName]
if !ok {
thisLineColor = lc.defaultLineColor
}
minCell := lc.innerArea.Min.X + lc.labelYSpace
cellPos := lc.innerArea.Max.X - 1
for dataPos := len(seriesData) - 1; dataPos >= 0 && cellPos > minCell; {
c := Cell{ c := Cell{
Ch: lc.DotStyle, Ch: lc.DotStyle,
Fg: lc.LineColor, Fg: thisLineColor,
Bg: lc.Bg, Bg: lc.Bg,
} }
x := lc.innerArea.Min.X + lc.labelYSpace + 1 + i x := cellPos
y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - int((lc.Data[i]-lc.bottomValue)/lc.scale+0.5) y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - int((seriesData[dataPos]-lc.bottomValue)/lc.scale+0.5)
if lasty != -1 && lasty != y {
u := 1 // direction
if lasty > y {
u = -1 // put dot below
}
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)
}
buf.Set(x+dx, fy, c)
}
}
lasty = y
buf.Set(x, y, c) buf.Set(x, y, c)
cellPos--
dataPos--
}
} }
return buf return buf
@ -211,7 +252,8 @@ func shortenFloatVal(x float64) string {
func (lc *LineChart) calcLabelY() { func (lc *LineChart) calcLabelY() {
span := lc.topValue - lc.bottomValue span := lc.topValue - lc.bottomValue
lc.scale = span / float64(lc.axisYHeight) // where does -2 come from? Without it, we might draw on the top border or past the block
lc.scale = span / float64(lc.axisYHeight-2)
n := (1 + lc.axisYHeight) / (lc.axisYLabelGap + 1) n := (1 + lc.axisYHeight) / (lc.axisYLabelGap + 1)
lc.labelY = make([][]rune, n) lc.labelY = make([][]rune, n)
@ -228,33 +270,32 @@ func (lc *LineChart) calcLabelY() {
} }
func (lc *LineChart) calcLayout() { func (lc *LineChart) calcLayout() {
// set datalabels if it is not provided for _, seriesData := range lc.Data {
if (lc.DataLabels == nil || len(lc.DataLabels) == 0) || lc.autoLabels { if seriesData == nil || len(seriesData) == 0 {
lc.autoLabels = true continue
lc.DataLabels = make([]string, len(lc.Data)) }
for i := range lc.Data { // set datalabels if not provided
if lc.DataLabels == nil || len(lc.DataLabels) == 0 {
lc.DataLabels = make([]string, len(seriesData))
for i := range seriesData {
lc.DataLabels[i] = fmt.Sprint(i) lc.DataLabels[i] = fmt.Sprint(i)
} }
} }
// lazy increase, to avoid y shaking frequently // lazy increase, to avoid y shaking frequently
// update bound Y when drawing is gonna overflow lc.minY = seriesData[0]
lc.minY = lc.Data[0] lc.maxY = seriesData[0]
lc.maxY = lc.Data[0]
lc.bottomValue = lc.minY
lc.topValue = lc.maxY
// valid visible range // valid visible range
vrange := lc.innerArea.Dx() vrange := lc.innerArea.Dx()
if lc.Mode == "braille" { if lc.Mode == "braille" {
vrange = 2 * lc.innerArea.Dx() vrange = 2 * lc.innerArea.Dx()
} }
if vrange > len(lc.Data) { if vrange > len(seriesData) {
vrange = len(lc.Data) vrange = len(seriesData)
} }
for _, v := range lc.Data[:vrange] { for _, v := range seriesData[:vrange] {
if v > lc.maxY { if v > lc.maxY {
lc.maxY = v lc.maxY = v
} }
@ -265,15 +306,23 @@ func (lc *LineChart) calcLayout() {
span := lc.maxY - lc.minY span := lc.maxY - lc.minY
if lc.minY < lc.bottomValue { // allow some padding unless we are beyond the flor/ceil
lc.bottomValue = lc.minY - 0.2*span if lc.minY <= lc.bottomValue {
lc.bottomValue = lc.minY - lc.YPadding*span
if lc.bottomValue < lc.YFloor {
lc.bottomValue = lc.YFloor
}
} }
if lc.maxY > lc.topValue { if lc.maxY >= lc.topValue {
lc.topValue = lc.maxY + 0.2*span lc.topValue = lc.maxY + lc.YPadding*span
if lc.topValue > lc.YCeil {
lc.topValue = lc.YCeil
}
}
} }
lc.axisYHeight = lc.innerArea.Dy() - 2 lc.axisYHeight = lc.innerArea.Dy() - 1
lc.calcLabelY() lc.calcLabelY()
lc.axisXWidth = lc.innerArea.Dx() - 1 - lc.labelYSpace lc.axisXWidth = lc.innerArea.Dx() - 1 - lc.labelYSpace
@ -295,8 +344,8 @@ func (lc *LineChart) plotAxes() Buffer {
buf.Set(x, origY, Cell{Ch: HDASH, Fg: lc.AxesColor, Bg: lc.Bg}) buf.Set(x, origY, Cell{Ch: HDASH, Fg: lc.AxesColor, Bg: lc.Bg})
} }
for dy := 1; dy <= lc.axisYHeight; dy++ { for y := origY - 1; y > origY-lc.axisYHeight; y-- {
buf.Set(origX, origY-dy, Cell{Ch: VDASH, Fg: lc.AxesColor, Bg: lc.Bg}) buf.Set(origX, y, Cell{Ch: VDASH, Fg: lc.AxesColor, Bg: lc.Bg})
} }
// x label // x label
@ -335,7 +384,13 @@ func (lc *LineChart) plotAxes() Buffer {
func (lc *LineChart) Buffer() Buffer { func (lc *LineChart) Buffer() Buffer {
buf := lc.Block.Buffer() buf := lc.Block.Buffer()
if lc.Data == nil || len(lc.Data) == 0 { seriesCount := 0
for _, data := range lc.Data {
if len(data) > 0 {
seriesCount++
}
}
if seriesCount == 0 {
return buf return buf
} }
lc.calcLayout() lc.calcLayout()