// Copyright 2017 Zack Guo . All rights reserved. // Use of this source code is governed by a MIT license that can // be found in the LICENSE file. package termui import ( "fmt" "math" ) // 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}: '⠉', } var lSingleBraille = [4]rune{'\u2840', '⠄', '⠂', '⠁'} var rSingleBraille = [4]rune{'\u2880', '⠠', '⠐', '⠈'} // LineChart has two modes: braille(default) and dot. Using braille gives 2x capacity as dot mode, // because one braille char can represent two data points. /* lc := termui.NewLineChart() lc.BorderLabel = "braille-mode Line Chart" lc.Data = [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 lc.LineColor = termui.ColorGreen | termui.AttrBold // termui.Render(lc)... */ 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 axisYLabelGap int axisXLabelGap int topValue float64 bottomValue float64 labelX [][]rune labelY [][]rune labelYSpace int maxY float64 minY float64 autoLabels bool } // 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.Mode = "braille" lc.DotStyle = '•' lc.axisXLabelGap = 2 lc.axisYLabelGap = 1 lc.bottomValue = math.Inf(1) lc.topValue = math.Inf(-1) return lc } // one cell contains two data points // so the capacity is 2x as dot-mode func (lc *LineChart) renderBraille() Buffer { buf := NewBuffer() // return: b -> which cell should the point be in // m -> in the cell, divided into 4 equal height levels, which subcell? getPos := func(d float64) (b, m int) { cnt4 := int((d-lc.bottomValue)/(lc.scale/4) + 0.5) b = cnt4 / 4 m = cnt4 % 4 return } // 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) } } return buf } func (lc *LineChart) renderDot() Buffer { buf := NewBuffer() lasty := -1 // previous y val for i := 0; i < len(lc.Data) && i < lc.axisXWidth; i++ { c := Cell{ Ch: lc.DotStyle, Fg: lc.LineColor, Bg: lc.Bg, } 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) if lasty != -1 && lasty != y { u := 1 // direction if lasty > y { u = -1 // put dot below } for fy := lasty + u; fy != y; fy += u { // fy: filling point's y val dx := -1 // lastx := x-1 = x+dx if u*(fy-lasty) >= u*(y-lasty)/2 { dx = 0 // cancel the horizontal backspace when getting close to (x,y) } buf.Set(x+dx, fy, c) } } lasty = y buf.Set(x, y, c) } return buf } 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]) w := strWidth(lc.DataLabels[l]) if l+w <= lc.axisXWidth { lc.labelX = append(lc.labelX, s) } l += w + lc.axisXLabelGap } else { // braille if 2*l >= len(lc.DataLabels) { break } s := str2runes(lc.DataLabels[2*l]) w := strWidth(lc.DataLabels[2*l]) if l+w <= lc.axisXWidth { lc.labelX = append(lc.labelX, s) } l += w + lc.axisXLabelGap } } } 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.axisYLabelGap + 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() { // set datalabels if it is not provided if (lc.DataLabels == nil || len(lc.DataLabels) == 0) || lc.autoLabels { lc.autoLabels = true lc.DataLabels = make([]string, len(lc.Data)) for i := range lc.Data { lc.DataLabels[i] = fmt.Sprint(i) } } // 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] lc.bottomValue = lc.minY lc.topValue = lc.maxY // 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 } } span := lc.maxY - lc.minY if lc.minY < lc.bottomValue { lc.bottomValue = lc.minY - 0.2*span } if lc.maxY > lc.topValue { lc.topValue = lc.maxY + 0.2*span } lc.axisYHeight = lc.innerArea.Dy() - 2 lc.calcLabelY() lc.axisXWidth = lc.innerArea.Dx() - 1 - lc.labelYSpace lc.calcLabelX() lc.drawingX = lc.innerArea.Min.X + 1 + lc.labelYSpace lc.drawingY = lc.innerArea.Min.Y } func (lc *LineChart) plotAxes() Buffer { buf := NewBuffer() origY := lc.innerArea.Min.Y + lc.innerArea.Dy() - 2 origX := lc.innerArea.Min.X + lc.labelYSpace buf.Set(origX, origY, Cell{Ch: ORIGIN, Fg: lc.AxesColor, Bg: lc.Bg}) for x := origX + 1; x < origX+lc.axisXWidth; x++ { 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}) } // x label oft := 0 for _, rs := range lc.labelX { if oft+len(rs) > lc.axisXWidth { break } for j, r := range rs { c := Cell{ Ch: r, Fg: lc.AxesColor, Bg: lc.Bg, } x := origX + oft + j y := lc.innerArea.Min.Y + lc.innerArea.Dy() - 1 buf.Set(x, y, c) } oft += len(rs) + lc.axisXLabelGap } // y labels for i, rs := range lc.labelY { for j, r := range rs { buf.Set( lc.innerArea.Min.X+j, origY-i*(lc.axisYLabelGap+1), Cell{Ch: r, Fg: lc.AxesColor, Bg: lc.Bg}) } } return buf } // Buffer implements Bufferer interface. func (lc *LineChart) Buffer() Buffer { buf := lc.Block.Buffer() if lc.Data == nil || len(lc.Data) == 0 { return buf } lc.calcLayout() buf.Merge(lc.plotAxes()) if lc.Mode == "dot" { buf.Merge(lc.renderDot()) } else { buf.Merge(lc.renderBraille()) } return buf }