From 92a301a2470bcc72432750586c1deb29ab876479 Mon Sep 17 00:00:00 2001 From: gizak Date: Sat, 7 Feb 2015 20:19:16 -0500 Subject: [PATCH] Add LineChart and BarChart --- bar.go | 96 +++++++++++++++ chart.go | 279 +++++++++++++++++++++++++++++++++++++++++++ example/dashboard.go | 88 ++++++++++++-- 3 files changed, 454 insertions(+), 9 deletions(-) create mode 100644 bar.go create mode 100644 chart.go diff --git a/bar.go b/bar.go new file mode 100644 index 0000000..7633f7a --- /dev/null +++ b/bar.go @@ -0,0 +1,96 @@ +package termui + +import "fmt" + +type BarChart struct { + Block + BarColor Attribute + TextColor Attribute + NumColor Attribute + Data []int + DataLabels []string + BarWidth int + BarGap int + labels [][]rune + dataNum [][]rune + numBar int + scale float64 + max int +} + +func NewBarChart() *BarChart { + bc := &BarChart{Block: *NewBlock()} + bc.BarColor = ColorCyan + bc.NumColor = ColorWhite + bc.TextColor = ColorWhite + bc.BarGap = 1 + bc.BarWidth = 3 + return bc +} + +func (bc *BarChart) layout() { + bc.numBar = bc.innerWidth / (bc.BarGap + bc.BarWidth) + bc.labels = make([][]rune, bc.numBar) + bc.dataNum = make([][]rune, len(bc.Data)) + + for i := 0; i < bc.numBar && i < len(bc.DataLabels) && i < len(bc.Data); i++ { + bc.labels[i] = trimStr2Runes(bc.DataLabels[i], bc.BarWidth) + n := bc.Data[i] + s := fmt.Sprint(n) + bc.dataNum[i] = trimStr2Runes(s, bc.BarWidth) + } + + bc.max = bc.Data[0] // what if Data is nil? + for i := 0; i < len(bc.Data); i++ { + if bc.max < bc.Data[i] { + bc.max = bc.Data[i] + } + } + bc.scale = float64(bc.max) / float64(bc.innerHeight-1) +} + +func (bc *BarChart) Buffer() []Point { + ps := bc.Block.Buffer() + bc.layout() + + for i := 0; i < bc.numBar && i < len(bc.Data) && i < len(bc.DataLabels); i++ { + h := int(float64(bc.Data[i]) / bc.scale) + oftX := i * (bc.BarWidth + bc.BarGap) + // plot bar + for j := 0; j < bc.BarWidth; j++ { + for k := 0; k < h; k++ { + p := Point{} + p.Code.Ch = ' ' + p.Code.Bg = toTmAttr(bc.BarColor) + p.X = bc.innerX + i*(bc.BarWidth+bc.BarGap) + j + p.Y = bc.innerY + bc.innerHeight - 2 - k + ps = append(ps, p) + } + } + // plot text + for j := 0; j < len(bc.labels[i]); j++ { + p := Point{} + p.Code.Ch = bc.labels[i][j] + p.Code.Bg = toTmAttr(bc.BgColor) + p.Code.Fg = toTmAttr(bc.TextColor) + p.Y = bc.innerY + bc.innerHeight - 1 + p.X = bc.innerX + oftX + j + ps = append(ps, p) + } + // plot num + for j := 0; j < len(bc.dataNum[i]); j++ { + p := Point{} + p.Code.Ch = bc.dataNum[i][j] + p.Code.Fg = toTmAttr(bc.NumColor) + p.Code.Bg = toTmAttr(bc.BarColor) + if h == 0 { + p.Code.Bg = toTmAttr(bc.BgColor) + } + p.X = bc.innerX + oftX + (bc.BarWidth-len(bc.dataNum[i]))/2 + j + p.Y = bc.innerY + bc.innerHeight - 2 + ps = append(ps, p) + } + } + + return ps +} diff --git a/chart.go b/chart.go new file mode 100644 index 0000000..35d4a41 --- /dev/null +++ b/chart.go @@ -0,0 +1,279 @@ +package termui + +import "fmt" +import tm "github.com/nsf/termbox-go" + +const VDASH = '┊' +const HDASH = '┈' +const ORIGIN = '└' + +// only 16 possible combinations, why bother +var braillePatterns = map[[2]int]rune{ + [2]int{0, 0}: '⣀', + [2]int{0, 1}: '⡠', + [2]int{0, 2}: '⡐', + [2]int{0, 3}: '⡈', + + [2]int{1, 0}: '⢄', + [2]int{1, 1}: '⠤', + [2]int{1, 2}: '⠔', + [2]int{1, 3}: '⠌', + + [2]int{2, 0}: '⢂', + [2]int{2, 1}: '⠢', + [2]int{2, 2}: '⠒', + [2]int{2, 3}: '⠊', + + [2]int{3, 0}: '⢁', + [2]int{3, 1}: '⠡', + [2]int{3, 2}: '⠑', + [2]int{3, 3}: '⠉', +} + +type LineChart struct { + Block + Data []float64 + DataLabels []string + Mode string // braille | dot + DotStyle rune + LineColor Attribute + scale float64 + 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 +} + +func NewLineChart() *LineChart { + lc := &LineChart{Block: *NewBlock()} + lc.Mode = "braille" + lc.DotStyle = '•' + lc.axisXLebelGap = 2 + lc.axisYLebelGap = 1 + return lc +} + +// one cell contains two data points +func (lc *LineChart) renderBraille() []Point { + ps := []Point{} + getBaseMod := func(d float64) (b, m int) { + b = int((d - lc.minY) / lc.scale) + m = int(((d-lc.minY)-float64(b)*lc.scale)/0.25 + 0.5) + return + } + for i := 0; i+1 < len(lc.Data) && i/2 < lc.axisXWidth; i += 2 { + b0, m0 := getBaseMod(lc.Data[i]) + b1, m1 := getBaseMod(lc.Data[i+1]) + + if b0 > b1 { + m1 = 0 + } + if b0 < b1 { + m1 = 3 + } + + p := Point{} + p.Code.Ch = braillePatterns[[2]int{m0, m1}] + p.Code.Bg = toTmAttr(lc.BgColor) + p.Code.Fg = toTmAttr(lc.LineColor) + p.Y = lc.innerY + lc.innerHeight - 3 - b0 + p.X = lc.innerX + lc.labelYSpace + 1 + i/2 + ps = append(ps, p) + } + return ps +} + +func (lc *LineChart) renderDot() []Point { + ps := []Point{} + for i := 0; i < len(lc.Data) && i < lc.axisXWidth; i++ { + p := Point{} + p.Code.Ch = lc.DotStyle + p.Code.Fg = toTmAttr(lc.LineColor) + p.Code.Bg = toTmAttr(lc.BgColor) + p.X = lc.innerX + lc.labelYSpace + 1 + i + p.Y = lc.innerY + lc.innerHeight - 3 - int((lc.Data[i]-lc.minY)/lc.scale+0.5) + ps = append(ps, p) + } + + return ps +} + +func (lc *LineChart) calcLabelX() { + lc.labelX = [][]rune{} + + for i, l := 0, 0; i < len(lc.DataLabels) && l < lc.axisXWidth; i++ { + if lc.Mode == "dot" { + if l >= len(lc.DataLabels) { + break + } + + s := str2runes(lc.DataLabels[l]) + if l+len(s) <= lc.axisXWidth { + lc.labelX = append(lc.labelX, s) + } + l += (len(s) + lc.axisXLebelGap) // -1 needed + } else { + if 2*l >= len(lc.DataLabels) { + break + } + + s := str2runes(lc.DataLabels[2*l]) + if l+len(s) <= lc.axisXWidth { + lc.labelX = append(lc.labelX, s) + } + l += (len(s) + lc.axisXLebelGap) // -1 needed + + } + } +} + +func shortenFloatVal(x float64) string { + s := fmt.Sprintf("%.2f", x) + if len(s)-3 > 3 { + s = fmt.Sprintf("%.2e", x) + } + + if x < 0 { + s = fmt.Sprintf("%.2f", x) + } + return s +} + +func (lc *LineChart) calcLabelY() { + span := lc.topValue - lc.bottomValue + lc.scale = span / float64(lc.axisYHeight) + + n := (1 + lc.axisYHeight) / (lc.axisYLebelGap + 1) + lc.labelY = make([][]rune, n) + maxLen := 0 + for i := 0; i < n; i++ { + s := str2runes(shortenFloatVal(lc.bottomValue + float64(i)*span/float64(n))) + if len(s) > maxLen { + maxLen = len(s) + } + lc.labelY[i] = s + } + + lc.labelYSpace = maxLen +} + +func (lc *LineChart) calcLayout() { + 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) + } + } + + lc.minY = lc.Data[0] + lc.maxY = lc.Data[0] + for _, v := range lc.Data { + if v > lc.maxY { + lc.maxY = v + } + if v < lc.minY { + lc.minY = v + } + } + + lc.topValue = lc.maxY * 1.2 + lc.bottomValue = lc.minY * 0.8 //- 0.05*(lc.maxY-lc.minY) + if lc.minY < 0 { + lc.bottomValue = lc.minY * 1.2 + } + + lc.axisYHeight = lc.innerHeight - 2 + lc.calcLabelY() + + lc.axisXWidth = lc.innerWidth - 1 - lc.labelYSpace + lc.calcLabelX() + + lc.drawingX = lc.innerX + 1 + lc.labelYSpace + lc.drawingY = lc.innerY +} + +func (lc *LineChart) plotAxes() []Point { + origY := lc.innerY + lc.innerHeight - 2 + origX := lc.innerX + lc.labelYSpace + + ps := []Point{Point{Code: tm.Cell{Ch: ORIGIN, Bg: toTmAttr(lc.BgColor), Fg: toTmAttr(lc.AxesColor)}, + X: origX, + Y: origY}} + + for x := origX + 1; x < origX+lc.axisXWidth; x++ { + p := Point{} + p.X = x + p.Y = origY + p.Code.Bg = toTmAttr(lc.BgColor) + p.Code.Fg = toTmAttr(lc.AxesColor) + p.Code.Ch = HDASH + ps = append(ps, p) + } + + for dy := 1; dy <= lc.axisYHeight; dy++ { + p := Point{} + p.X = origX + p.Y = origY - dy + p.Code.Bg = toTmAttr(lc.BgColor) + p.Code.Fg = toTmAttr(lc.AxesColor) + p.Code.Ch = VDASH + ps = append(ps, p) + } + + // x label + oft := 0 + for _, rs := range lc.labelX { + if oft+len(rs) > lc.axisXWidth { + break + } + for j, r := range rs { + p := Point{} + p.Code.Ch = r + p.Code.Fg = toTmAttr(lc.AxesColor) + p.Code.Bg = toTmAttr(lc.BgColor) + p.X = origX + oft + j + p.Y = lc.innerY + lc.innerHeight - 1 + ps = append(ps, p) + } + oft += len(rs) + lc.axisXLebelGap + } + + // y labels + for i, rs := range lc.labelY { + for j, r := range rs { + p := Point{} + p.Code.Ch = r + p.Code.Fg = toTmAttr(lc.AxesColor) + p.Code.Bg = toTmAttr(lc.BgColor) + p.X = lc.innerX + j + p.Y = origY - i*(lc.axisYLebelGap+1) + ps = append(ps, p) + } + } + + return ps +} + +func (lc *LineChart) Buffer() []Point { + ps := lc.Block.Buffer() + lc.calcLayout() + ps = append(ps, lc.plotAxes()...) + + if lc.Mode == "dot" { + ps = append(ps, lc.renderDot()...) + } else { + ps = append(ps, lc.renderBraille()...) + } + + return ps +} diff --git a/example/dashboard.go b/example/dashboard.go index 3c8a6c7..73b54ca 100644 --- a/example/dashboard.go +++ b/example/dashboard.go @@ -2,6 +2,8 @@ package main import ui "github.com/gizak/termui" import tm "github.com/nsf/termbox-go" +import "math" + import "time" func main() { @@ -11,11 +13,11 @@ func main() { } defer ui.Close() - p := ui.NewP(":PRESS q TO QUIT DEMO\nThis is an example of termui package rendering.") - p.Height = 4 - p.Width = 59 + p := ui.NewP(":PRESS q TO QUIT DEMO") + p.Height = 3 + p.Width = 50 p.TextFgColor = ui.ColorWhite - p.Border.Label = "Text" + p.Border.Label = "Text Box" p.Border.FgColor = ui.ColorCyan strs := []string{"[0] gizak/termui", "[1] editbox.go", "[2] iterrupt.go", "[3] keyboard.go", "[4] output.go", "[5] random_out.go", "[6] dashboard.go", "[7] nsf/termbox-go"} @@ -29,7 +31,7 @@ func main() { g := ui.NewGauge() g.Percent = 50 - g.Width = 52 + g.Width = 50 g.Height = 3 g.Y = 11 g.Border.Label = "Gauge" @@ -53,16 +55,84 @@ func main() { spark1.LineColor = ui.ColorRed sp := ui.NewSparklines(spark, spark1) - sp.Width = 20 - sp.Height = 6 + sp.Width = 25 + sp.Height = 7 sp.Border.Label = "Sparkline" - sp.Y = 14 + sp.Y = 4 + sp.X = 25 + + lc := ui.NewLineChart() + sinps := (func() []float64 { + n := 100 + ps := make([]float64, n) + for i := range ps { + ps[i] = 1 + math.Sin(float64(i)/4) + } + return ps + })() + + lc.Border.Label = "Line Chart" + lc.Data = sinps + lc.Width = 50 + lc.Height = 11 + lc.X = 0 + lc.Y = 14 + lc.AxesColor = ui.ColorWhite + lc.LineColor = ui.ColorRed | ui.AttrBold + lc.Mode = "dot" + + bc := ui.NewBarChart() + bcdata := []int{3, 2, 5, 3, 9, 5, 3, 2, 5, 8, 3, 2, 4, 5, 3, 2, 5, 7, 5, 3, 2, 6, 7, 4, 6, 3, 6, 7, 8, 3, 6, 4, 5, 3, 2, 4, 6, 4, 8, 5, 9, 4, 3, 6, 5, 3, 6} + bclabels := []string{"S0", "S1", "S2", "S3", "S4", "S5"} + bc.Border.Label = "Bar Chart" + bc.Width = 26 + bc.Height = 10 + bc.X = 51 + bc.Y = 0 + bc.DataLabels = bclabels + bc.BarColor = ui.ColorGreen + bc.NumColor = ui.ColorBlack + + lc1 := ui.NewLineChart() + lc1.Border.Label = "Line Chart" + rndwalk := (func() []float64 { + n := 100 + d := make([]float64, n) + for i := 1; i < n; i++ { + if i < 20 { + d[i] = d[i-1] + 0.01 + } + if i > 20 { + d[i] = d[i-1] - 0.05 + } + } + return d + })() + lc1.Data = rndwalk + lc1.Width = 26 + lc1.Height = 11 + lc1.X = 51 + lc1.Y = 14 + lc1.AxesColor = ui.ColorWhite + lc1.LineColor = ui.ColorYellow | ui.AttrBold + + p1 := ui.NewP("Hey!\nI am a borderless block!") + p1.HasBorder = false + p1.Width = 26 + p1.Height = 2 + p1.TextFgColor = ui.ColorMagenta + p1.X = 52 + p1.Y = 11 draw := func(t int) { g.Percent = t % 101 list.Items = strs[t%9:] sp.Lines[0].Data = spdata[t%10:] - ui.Render(p, list, g, sp) + sp.Lines[1].Data = spdata[t/2%10:] + lc.Data = sinps[t/2:] + lc1.Data = rndwalk[t:] + bc.Data = bcdata[t/2%10:] + ui.Render(p, list, g, sp, lc, bc, lc1, p1) } evt := make(chan tm.Event)