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,45 +51,52 @@ 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
scale float64 // data span per cell on y-axis defaultLineColor Attribute
AxesColor Attribute scale float64 // data span per cell on y-axis
drawingX int AxesColor Attribute
drawingY int drawingX int
axisYHeight int drawingY int
axisXWidth int axisYHeight int
axisYLabelGap int axisXWidth int
axisXLabelGap int axisYLabelGap int
topValue float64 axisXLabelGap int
bottomValue float64 topValue float64
labelX [][]rune bottomValue float64
labelY [][]rune labelX [][]rune
labelYSpace int labelY [][]rune
maxY float64 labelYSpace int
minY float64 maxY float64
autoLabels bool minY float64
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
if b0 == b1 { }
c := Cell{ thisLineColor, ok := lc.LineColor[seriesName]
Ch: braillePatterns[[2]int{m0, m1}], if !ok {
Bg: lc.Bg, thisLineColor = lc.defaultLineColor
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 {
c0 := Cell{Ch: lSingleBraille[m0],
Fg: lc.LineColor,
Bg: lc.Bg}
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)
} }
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 {
c := Cell{
Ch: braillePatterns[[2]int{m1, m0}],
Bg: lc.Bg,
Fg: thisLineColor,
}
y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - b0
buf.Set(cellPos, y, c)
} else {
c0 := Cell{
Ch: rSingleBraille[m0],
Fg: thisLineColor,
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
buf.Set(x0, y0, c0)
}
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]
c := Cell{ if !ok {
Ch: lc.DotStyle, thisLineColor = lc.defaultLineColor
Fg: lc.LineColor,
Bg: lc.Bg,
} }
x := lc.innerArea.Min.X + lc.labelYSpace + 1 + i minCell := lc.innerArea.Min.X + lc.labelYSpace
y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - int((lc.Data[i]-lc.bottomValue)/lc.scale+0.5) cellPos := lc.innerArea.Max.X - 1
for dataPos := len(seriesData) - 1; dataPos >= 0 && cellPos > minCell; {
c := Cell{
Ch: lc.DotStyle,
Fg: thisLineColor,
Bg: lc.Bg,
}
x := cellPos
y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 3 - int((seriesData[dataPos]-lc.bottomValue)/lc.scale+0.5)
buf.Set(x, y, c)
if lasty != -1 && lasty != y { cellPos--
u := 1 // direction dataPos--
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)
} }
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,52 +270,59 @@ 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
lc.DataLabels[i] = fmt.Sprint(i) if lc.DataLabels == nil || len(lc.DataLabels) == 0 {
lc.DataLabels = make([]string, len(seriesData))
for i := range seriesData {
lc.DataLabels[i] = fmt.Sprint(i)
}
}
// lazy increase, to avoid y shaking frequently
lc.minY = seriesData[0]
lc.maxY = seriesData[0]
// valid visible range
vrange := lc.innerArea.Dx()
if lc.Mode == "braille" {
vrange = 2 * lc.innerArea.Dx()
}
if vrange > len(seriesData) {
vrange = len(seriesData)
}
for _, v := range seriesData[:vrange] {
if v > lc.maxY {
lc.maxY = v
}
if v < lc.minY {
lc.minY = v
}
}
span := lc.maxY - lc.minY
// allow some padding unless we are beyond the flor/ceil
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 {
lc.topValue = lc.maxY + lc.YPadding*span
if lc.topValue > lc.YCeil {
lc.topValue = lc.YCeil
}
} }
} }
// lazy increase, to avoid y shaking frequently lc.axisYHeight = lc.innerArea.Dy() - 1
// update bound Y when drawing is gonna overflow
lc.minY = lc.Data[0]
lc.maxY = lc.Data[0]
lc.bottomValue = lc.minY
lc.topValue = lc.maxY
// valid visible range
vrange := lc.innerArea.Dx()
if lc.Mode == "braille" {
vrange = 2 * lc.innerArea.Dx()
}
if vrange > len(lc.Data) {
vrange = len(lc.Data)
}
for _, v := range lc.Data[:vrange] {
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
}
lc.axisYHeight = lc.innerArea.Dy() - 2
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()