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