From e74935dded25fb6c9150dceceae690524c4e48d3 Mon Sep 17 00:00:00 2001 From: Matt Ranney Date: Tue, 2 Feb 2016 21:51:37 -0800 Subject: [PATCH 1/2] add support for multiple series to linechart --- _example/ttop.go | 2 +- linechart.go | 300 ++++++++++++++++++++++++++++++++--------------- render.go | 3 + 3 files changed, 211 insertions(+), 94 deletions(-) diff --git a/_example/ttop.go b/_example/ttop.go index 73c9b85..56e58f5 100644 --- a/_example/ttop.go +++ b/_example/ttop.go @@ -20,7 +20,7 @@ import ( "time" "github.com/gizak/termui" - "github.com/gizak/termui/extra" + "github.com/gizak/termui/_extra" ) const statFilePath = "/proc/stat" diff --git a/linechart.go b/linechart.go index f282914..b19c233 100644 --- a/linechart.go +++ b/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()) } diff --git a/render.go b/render.go index 36544f0..4b234bd 100644 --- a/render.go +++ b/render.go @@ -63,6 +63,9 @@ func Init() error { // should be called after successful initialization when termui's functionality isn't required anymore. func Close() { tm.Close() + if debugFile != nil { + debugFile.Close() + } } var renderLock sync.Mutex From 562ca479963af87c4795e0af33128259bdea15ad Mon Sep 17 00:00:00 2001 From: Matt Ranney Date: Thu, 4 Feb 2016 10:56:53 -0800 Subject: [PATCH 2/2] Fix some off-by-one errors in Y axis calculations Y axis label printing stopped before the top line Sometimes large data values would land on the border or beyond the box fix typo --- linechart.go | 71 +++++++++++++--------------------------------------- render.go | 3 --- 2 files changed, 17 insertions(+), 57 deletions(-) diff --git a/linechart.go b/linechart.go index b19c233..d6e4161 100644 --- a/linechart.go +++ b/linechart.go @@ -7,9 +7,7 @@ package termui import ( "fmt" "math" - "os" "sort" - "time" ) // only 16 possible combinations, why bother @@ -38,39 +36,9 @@ 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. +// LineChart has two modes: braille(default) and dot. +// 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.Border.Label = "braille-mode Line Chart" @@ -95,8 +63,8 @@ type LineChart struct { drawingY int axisYHeight int axisXWidth int - axisYLebelGap int - axisXLebelGap int + axisYLabelGap int + axisXLabelGap int topValue float64 bottomValue float64 labelX [][]rune @@ -118,8 +86,8 @@ func NewLineChart() *LineChart { lc.DotStyle = '•' lc.Data = make(map[string][]float64) lc.LineColor = make(map[string]Attribute) - lc.axisXLebelGap = 2 - lc.axisYLebelGap = 1 + lc.axisXLabelGap = 2 + lc.axisYLabelGap = 1 lc.bottomValue = math.Inf(1) lc.topValue = math.Inf(-1) lc.YPadding = 0.2 @@ -222,7 +190,6 @@ func (lc *LineChart) renderDot() Buffer { 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, @@ -254,7 +221,7 @@ func (lc *LineChart) calcLabelX() { if l+w <= lc.axisXWidth { lc.labelX = append(lc.labelX, s) } - l += w + lc.axisXLebelGap + l += w + lc.axisXLabelGap } else { // braille if 2*l >= len(lc.DataLabels) { break @@ -265,7 +232,7 @@ func (lc *LineChart) calcLabelX() { if l+w <= lc.axisXWidth { lc.labelX = append(lc.labelX, s) } - l += w + lc.axisXLebelGap + l += w + lc.axisXLabelGap } } @@ -285,9 +252,10 @@ func shortenFloatVal(x float64) string { func (lc *LineChart) calcLabelY() { 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.axisYLebelGap + 1) + n := (1 + lc.axisYHeight) / (lc.axisYLabelGap + 1) lc.labelY = make([][]rune, n) maxLen := 0 for i := 0; i < n; i++ { @@ -354,7 +322,7 @@ func (lc *LineChart) calcLayout() { } } - lc.axisYHeight = lc.innerArea.Dy() - 2 + lc.axisYHeight = lc.innerArea.Dy() - 1 lc.calcLabelY() lc.axisXWidth = lc.innerArea.Dx() - 1 - lc.labelYSpace @@ -362,8 +330,6 @@ 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 { @@ -378,8 +344,8 @@ func (lc *LineChart) plotAxes() Buffer { buf.Set(x, origY, Cell{Ch: HDASH, Fg: lc.AxesColor, Bg: lc.Bg}) } - for dy := 1; dy <= lc.axisYHeight; dy++ { - buf.Set(origX, origY-dy, Cell{Ch: VDASH, Fg: lc.AxesColor, Bg: lc.Bg}) + for y := origY - 1; y > origY-lc.axisYHeight; y-- { + buf.Set(origX, y, Cell{Ch: VDASH, Fg: lc.AxesColor, Bg: lc.Bg}) } // x label @@ -398,7 +364,7 @@ func (lc *LineChart) plotAxes() Buffer { y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 1 buf.Set(x, y, c) } - oft += len(rs) + lc.axisXLebelGap + oft += len(rs) + lc.axisXLabelGap } // y labels @@ -406,7 +372,7 @@ func (lc *LineChart) plotAxes() Buffer { for j, r := range rs { buf.Set( lc.innerArea.Min.X+j, - origY-i*(lc.axisYLebelGap+1), + origY-i*(lc.axisYLabelGap+1), Cell{Ch: r, Fg: lc.AxesColor, Bg: lc.Bg}) } } @@ -425,17 +391,14 @@ func (lc *LineChart) Buffer() Buffer { } } 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()) } diff --git a/render.go b/render.go index 4b234bd..36544f0 100644 --- a/render.go +++ b/render.go @@ -63,9 +63,6 @@ func Init() error { // should be called after successful initialization when termui's functionality isn't required anymore. func Close() { tm.Close() - if debugFile != nil { - debugFile.Close() - } } var renderLock sync.Mutex