add support for multiple series to linechart

This commit is contained in:
Matt Ranney 2016-02-02 21:51:37 -08:00
parent 08a5d3f67b
commit e74935dded
3 changed files with 211 additions and 94 deletions

View File

@ -20,7 +20,7 @@ import (
"time" "time"
"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,9 @@ package termui
import ( import (
"fmt" "fmt"
"math" "math"
"os"
"sort"
"time"
) )
// only 16 possible combinations, why bother // only 16 possible combinations, why bother
@ -35,12 +38,43 @@ 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', '⠠', '⠐', '⠈'}
// set this filename to have debug logging written here
var DebugFilename string
var debugFile *os.File
func debugLog(str string) {
if DebugFilename == "" {
return
}
var err error
if debugFile == nil {
debugFile, err = os.OpenFile(DebugFilename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666)
if err != nil {
panic(err)
}
}
stamp := time.Now().Format(time.StampMilli)
_, err = fmt.Fprintln(debugFile, stamp, str)
if err != nil {
panic(err)
}
}
func Debug(a ...interface{}) {
debugLog(fmt.Sprint(a))
}
func Debugf(format string, a ...interface{}) {
debugLog(fmt.Sprintf(format, a...))
}
// LineChart has two modes: braille(default) and dot. Using braille gives 2x capicity as dot mode, // LineChart has two modes: braille(default) and dot. Using braille gives 2x capicity as dot mode,
// because one braille char can represent two data points. // because one braille char can represent two data points.
/* /*
lc := termui.NewLineChart() lc := termui.NewLineChart()
lc.Border.Label = "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,44 +83,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
axisYLebelGap int axisXWidth int
axisXLebelGap int axisYLebelGap int
topValue float64 axisXLebelGap 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
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.axisXLebelGap = 2 lc.axisXLebelGap = 2
lc.axisYLebelGap = 1 lc.axisYLebelGap = 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 capicity is 2x as dot-mode
func (lc *LineChart) renderBraille() Buffer { func (lc *LineChart) renderBraille() Buffer {
buf := NewBuffer() buf := NewBuffer()
@ -98,51 +140,101 @@ 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()
for i := 0; i < len(lc.Data) && i < lc.axisXWidth; i++ { for seriesName, seriesData := range lc.Data {
c := Cell{ thisLineColor, ok := lc.LineColor[seriesName]
Ch: lc.DotStyle, if !ok {
Fg: lc.LineColor, thisLineColor = lc.defaultLineColor
Bg: lc.Bg, }
minCell := lc.innerArea.Min.X + lc.labelYSpace
cellPos := lc.innerArea.Max.X - 1
for dataPos := len(seriesData) - 1; dataPos >= 0 && cellPos > minCell; {
Debug(seriesName, " ", dataPos, cellPos, seriesData[dataPos])
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)
cellPos--
dataPos--
} }
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)
buf.Set(x, y, c)
} }
return buf return buf
@ -210,45 +302,56 @@ 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 { if seriesData == nil || len(seriesData) == 0 {
lc.DataLabels = make([]string, len(lc.Data)) continue
for i := range lc.Data {
lc.DataLabels[i] = fmt.Sprint(i)
} }
} // set datalabels if not provided
if lc.DataLabels == nil || len(lc.DataLabels) == 0 {
// lazy increase, to avoid y shaking frequently lc.DataLabels = make([]string, len(seriesData))
// update bound Y when drawing is gonna overflow for i := range seriesData {
lc.minY = lc.Data[0] lc.DataLabels[i] = fmt.Sprint(i)
lc.maxY = lc.Data[0] }
// 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 // 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)
} }
}
span := lc.maxY - lc.minY for _, v := range seriesData[:vrange] {
if v > lc.maxY {
lc.maxY = v
}
if v < lc.minY {
lc.minY = v
}
}
if lc.minY < lc.bottomValue { span := lc.maxY - lc.minY
lc.bottomValue = lc.minY - 0.2*span
}
if lc.maxY > lc.topValue { // allow some padding unless we are beyond the flor/ceil
lc.topValue = lc.maxY + 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 {
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() - 2
@ -259,6 +362,8 @@ func (lc *LineChart) calcLayout() {
lc.drawingX = lc.innerArea.Min.X + 1 + lc.labelYSpace lc.drawingX = lc.innerArea.Min.X + 1 + lc.labelYSpace
lc.drawingY = lc.innerArea.Min.Y lc.drawingY = lc.innerArea.Min.Y
Debugf("calcLayout bottom=%f top=%f min=%f max=%f axisYHeight=%d", lc.bottomValue, lc.topValue, lc.minY, lc.maxY, lc.axisYHeight)
} }
func (lc *LineChart) plotAxes() Buffer { func (lc *LineChart) plotAxes() Buffer {
@ -313,15 +418,24 @@ 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 {
Debug("lc render no data")
return buf return buf
} }
lc.calcLayout() lc.calcLayout()
buf.Merge(lc.plotAxes()) buf.Merge(lc.plotAxes())
if lc.Mode == "dot" { if lc.Mode == "dot" {
Debug("lc render start dot")
buf.Merge(lc.renderDot()) buf.Merge(lc.renderDot())
} else { } else {
Debug("lc render start braille")
buf.Merge(lc.renderBraille()) buf.Merge(lc.renderBraille())
} }

View File

@ -63,6 +63,9 @@ func Init() error {
// should be called after successful initialization when termui's functionality isn't required anymore. // should be called after successful initialization when termui's functionality isn't required anymore.
func Close() { func Close() {
tm.Close() tm.Close()
if debugFile != nil {
debugFile.Close()
}
} }
var renderLock sync.Mutex var renderLock sync.Mutex