mirror of https://github.com/gizak/termui.git
add support for multiple series to linechart
This commit is contained in:
parent
08a5d3f67b
commit
e74935dded
|
@ -20,7 +20,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gizak/termui"
|
||||
"github.com/gizak/termui/extra"
|
||||
"github.com/gizak/termui/_extra"
|
||||
)
|
||||
|
||||
const statFilePath = "/proc/stat"
|
||||
|
|
300
linechart.go
300
linechart.go
|
@ -7,6 +7,9 @@ package termui
|
|||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// only 16 possible combinations, why bother
|
||||
|
@ -35,12 +38,43 @@ var braillePatterns = map[[2]int]rune{
|
|||
var lSingleBraille = [4]rune{'\u2840', '⠄', '⠂', '⠁'}
|
||||
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,
|
||||
// because one braille char can represent two data points.
|
||||
/*
|
||||
lc := termui.NewLineChart()
|
||||
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.Height = 12
|
||||
lc.AxesColor = termui.ColorWhite
|
||||
|
@ -49,44 +83,52 @@ var rSingleBraille = [4]rune{'\u2880', '⠠', '⠐', '⠈'}
|
|||
*/
|
||||
type LineChart struct {
|
||||
Block
|
||||
Data []float64
|
||||
DataLabels []string // if unset, the data indices will be used
|
||||
Mode string // braille | dot
|
||||
DotStyle rune
|
||||
LineColor Attribute
|
||||
scale float64 // data span per cell on y-axis
|
||||
AxesColor Attribute
|
||||
drawingX int
|
||||
drawingY int
|
||||
axisYHeight int
|
||||
axisXWidth int
|
||||
axisYLebelGap int
|
||||
axisXLebelGap int
|
||||
topValue float64
|
||||
bottomValue float64
|
||||
labelX [][]rune
|
||||
labelY [][]rune
|
||||
labelYSpace int
|
||||
maxY float64
|
||||
minY float64
|
||||
Data map[string][]float64
|
||||
DataLabels []string // if unset, the data indices will be used
|
||||
Mode string // braille | dot
|
||||
DotStyle rune
|
||||
LineColor map[string]Attribute
|
||||
defaultLineColor Attribute
|
||||
scale float64 // data span per cell on y-axis
|
||||
AxesColor Attribute
|
||||
drawingX int
|
||||
drawingY int
|
||||
axisYHeight int
|
||||
axisXWidth int
|
||||
axisYLebelGap int
|
||||
axisXLebelGap int
|
||||
topValue float64
|
||||
bottomValue float64
|
||||
labelX [][]rune
|
||||
labelY [][]rune
|
||||
labelYSpace int
|
||||
maxY float64
|
||||
minY float64
|
||||
YPadding float64
|
||||
YFloor float64
|
||||
YCeil float64
|
||||
}
|
||||
|
||||
// NewLineChart returns a new LineChart with current theme.
|
||||
func NewLineChart() *LineChart {
|
||||
lc := &LineChart{Block: *NewBlock()}
|
||||
lc.AxesColor = ThemeAttr("linechart.axes.fg")
|
||||
lc.LineColor = ThemeAttr("linechart.line.fg")
|
||||
lc.defaultLineColor = ThemeAttr("linechart.line.fg")
|
||||
lc.Mode = "braille"
|
||||
lc.DotStyle = '•'
|
||||
lc.Data = make(map[string][]float64)
|
||||
lc.LineColor = make(map[string]Attribute)
|
||||
lc.axisXLebelGap = 2
|
||||
lc.axisYLebelGap = 1
|
||||
lc.bottomValue = math.Inf(1)
|
||||
lc.topValue = math.Inf(-1)
|
||||
lc.YPadding = 0.2
|
||||
lc.YFloor = math.Inf(-1)
|
||||
lc.YCeil = math.Inf(1)
|
||||
return lc
|
||||
}
|
||||
|
||||
// one cell contains two data points
|
||||
// so the capicity is 2x as dot-mode
|
||||
// one cell contains two data points, so capicity is 2x dot mode
|
||||
func (lc *LineChart) renderBraille() Buffer {
|
||||
buf := NewBuffer()
|
||||
|
||||
|
@ -98,51 +140,101 @@ func (lc *LineChart) renderBraille() Buffer {
|
|||
m = cnt4 % 4
|
||||
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
|
||||
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 {
|
||||
c := Cell{
|
||||
Ch: braillePatterns[[2]int{m0, m1}],
|
||||
Bg: lc.Bg,
|
||||
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)
|
||||
for _, seriesName := range seriesList {
|
||||
seriesData := lc.Data[seriesName]
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
func (lc *LineChart) renderDot() Buffer {
|
||||
buf := NewBuffer()
|
||||
for i := 0; i < len(lc.Data) && i < lc.axisXWidth; i++ {
|
||||
c := Cell{
|
||||
Ch: lc.DotStyle,
|
||||
Fg: lc.LineColor,
|
||||
Bg: lc.Bg,
|
||||
for seriesName, seriesData := range lc.Data {
|
||||
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; {
|
||||
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
|
||||
|
@ -210,45 +302,56 @@ func (lc *LineChart) calcLabelY() {
|
|||
}
|
||||
|
||||
func (lc *LineChart) calcLayout() {
|
||||
// set datalabels if it is not provided
|
||||
if lc.DataLabels == nil || len(lc.DataLabels) == 0 {
|
||||
lc.DataLabels = make([]string, len(lc.Data))
|
||||
for i := range lc.Data {
|
||||
lc.DataLabels[i] = fmt.Sprint(i)
|
||||
for _, seriesData := range lc.Data {
|
||||
if seriesData == nil || len(seriesData) == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// lazy increase, to avoid y shaking frequently
|
||||
// update bound Y when drawing is gonna overflow
|
||||
lc.minY = lc.Data[0]
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
lc.bottomValue = lc.minY - 0.2*span
|
||||
}
|
||||
span := lc.maxY - lc.minY
|
||||
|
||||
if lc.maxY > lc.topValue {
|
||||
lc.topValue = lc.maxY + 0.2*span
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lc.axisYHeight = lc.innerArea.Dy() - 2
|
||||
|
@ -259,6 +362,8 @@ func (lc *LineChart) calcLayout() {
|
|||
|
||||
lc.drawingX = lc.innerArea.Min.X + 1 + lc.labelYSpace
|
||||
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 {
|
||||
|
@ -313,15 +418,24 @@ func (lc *LineChart) plotAxes() Buffer {
|
|||
func (lc *LineChart) Buffer() 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
|
||||
}
|
||||
lc.calcLayout()
|
||||
buf.Merge(lc.plotAxes())
|
||||
|
||||
if lc.Mode == "dot" {
|
||||
Debug("lc render start dot")
|
||||
buf.Merge(lc.renderDot())
|
||||
} else {
|
||||
Debug("lc render start braille")
|
||||
buf.Merge(lc.renderBraille())
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue