cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / chart / chart.go
chart.go 7.6K
1// Package chart draws a sheet's charts: as text for any terminal (block2// elements for bars, braille for lines, half blocks for pies), and the plot3// area as an image for terminals with kitty graphics. Both share one4// layout, so axis labels, category labels and the legend stay terminal5// text around the image, in the terminal's own font.6package chart78import (9 "image"10 "math"11 "strconv"12 "strings"1314 "github.com/charmbracelet/x/ansi"1516 "src.rest/cat/012/internal/sheet"17)1819// Role is what a cell of a drawn chart shows, so the UI can style it with20// its theme.21type Role uint82223const (24 None Role = iota // background25 Axis // axis lines and tick marks26 Label // tick values, category labels and legend text27 Muted // messages such as "No numbers to chart"28 Gridline // gridlines29 Series // Series+i is the color of series (or slice) i30)3132// Colors is how many series colors there are; later series reuse them.33const Colors = 63435// SeriesRole is the role of series i.36func SeriesRole(i int) Role { return Series + Role(i%Colors) }3738// Cell is one terminal cell of a drawn chart.39type Cell struct {40 Text string // one grapheme; "" continues a wide one to the left41 Fg Role42 Bg Role // only the pie's half blocks set a background43}4445// Grid is a drawn chart, W by H cells.46type Grid struct {47 W, H int48 Cells []Cell49 // Plot is the area an image covers, in cells. It is empty when the50 // chart has nothing to plot.51 Plot image.Rectangle52}5354// Options control drawing.55type Options struct {56 // Image leaves the plot area blank, for an image drawn by Image.57 Image bool58 // CellW and CellH are the size of a terminal cell in pixels; they set59 // a pie's proportions. Zero means the usual 1:2.60 CellW, CellH int61 // Chart is the chart's own settings: stacking, the value axis, the62 // gridlines and the legend.63 Chart sheet.ChartOptions64}6566func (o Options) aspect() float64 {67 if o.CellW <= 0 || o.CellH <= 0 {68 return 269 }70 return float64(o.CellH) / float64(o.CellW)71}7273func newGrid(w, h int) *Grid {74 g := &Grid{W: w, H: h, Cells: make([]Cell, w*h)}75 for i := range g.Cells {76 g.Cells[i].Text = " "77 }78 return g79}8081// At returns the cell at x, y, or nil outside the grid.82func (g *Grid) At(x, y int) *Cell {83 if x < 0 || y < 0 || x >= g.W || y >= g.H {84 return nil85 }86 return &g.Cells[y*g.W+x]87}8889func (g *Grid) set(x, y int, s string, fg Role) {90 if c := g.At(x, y); c != nil {91 c.Text, c.Fg, c.Bg = s, fg, None92 }93}9495// text writes s from x, clipped to the grid.96func (g *Grid) text(x, y int, s string, fg Role) {97 for _, r := range s {98 w := ansi.StringWidth(string(r))99 if w == 0 {100 continue101 }102 if x+w > g.W {103 return104 }105 g.set(x, y, string(r), fg)106 for k := 1; k < w; k++ {107 g.set(x+k, y, "", fg)108 }109 x += w110 }111}112113// String returns the grid as plain text, for tests.114func (g *Grid) String() string {115 var b strings.Builder116 for y := range g.H {117 var line strings.Builder118 for x := range g.W {119 line.WriteString(g.At(x, y).Text)120 }121 b.WriteString(strings.TrimRight(line.String(), " "))122 if y < g.H-1 {123 b.WriteByte('\n')124 }125 }126 return b.String()127}128129// Draw draws d as a chart of type t in w by h cells.130func Draw(t sheet.ChartType, d sheet.ChartData, w, h int, o Options) *Grid {131 g := newGrid(max(w, 0), max(h, 0))132 if w < 8 || h < 3 {133 return g134 }135 p, lg := planFor(t, d, w, h, o)136 if msg := p.note(); msg != "" {137 g.message(msg)138 return g139 }140 g.Plot = p.plotArea()141 p.draw(g, o)142 lg.draw(g)143 return g144}145146// message centers a note in the grid, e.g. when there's nothing to plot.147func (g *Grid) message(s string) {148 s = ansi.Truncate(s, g.W-2, "…")149 g.text((g.W-ansi.StringWidth(s))/2, (g.H-1)/2, s, Muted)150}151152// finite returns the range of the finite values in d's series, and false153// if there are none.154func finite(series []sheet.ChartSeries) (lo, hi float64, ok bool) {155 lo, hi = math.Inf(1), math.Inf(-1)156 for _, s := range series {157 for _, v := range s.Values {158 if !math.IsNaN(v) && !math.IsInf(v, 0) {159 lo, hi, ok = min(lo, v), max(hi, v), true160 }161 }162 }163 return lo, hi, ok164}165166// scale is a value axis: lo..hi divided into n intervals of step, each k167// cells long, so every tick lands on a cell boundary. On a log scale lo,168// hi and step are powers of ten (their logarithms), see axis.go.169type scale struct {170 lo, hi, step float64171 n, k int172 log bool173 label func(float64) string174}175176// cells is the axis length in cells.177func (s scale) cells() int { return s.n * s.k }178179// pos maps a value to a distance along the axis in cells.180func (s scale) pos(v float64) float64 {181 if s.log {182 if v <= 0 {183 return math.Inf(-1)184 }185 v = math.Log10(v)186 }187 return (v - s.lo) / (s.hi - s.lo) * float64(s.cells())188}189190// tick returns the value of tick j.191func (s scale) tick(j int) float64 {192 if s.log {193 return math.Pow(10, s.lo+float64(j)*s.step)194 }195 return s.lo + float64(j)*s.step196}197198// newScale fits dlo..dhi onto length cells, with ticks at least minK199// cells apart and at most maxN intervals.200func newScale(dlo, dhi float64, length, minK, maxN int, f sheet.Format) scale {201 if dlo == dhi {202 if dlo == 0 {203 dhi = 1204 } else if dlo > 0 {205 dlo = 0206 } else {207 dhi = 0208 }209 }210 best := scale{lo: dlo, hi: dhi, step: dhi - dlo, n: 1, k: max(length, 1)}211 for target := min(maxN, max(length/minK, 1)); target >= 1; target-- {212 step := niceStep((dhi - dlo) / float64(target))213 lo := math.Floor(dlo/step+1e-9) * step214 hi := math.Ceil(dhi/step-1e-9) * step215 n := int(math.Round((hi - lo) / step))216 if n < 1 {217 continue218 }219 if k := length / n; k >= minK || target == 1 && k >= 1 {220 best = scale{lo: lo, hi: hi, step: step, n: n, k: k}221 break222 }223 }224 best.label = tickFormat(best, f)225 return best226}227228// niceStep rounds x up to 1, 2 or 5 times a power of ten.229func niceStep(x float64) float64 {230 if x <= 0 || math.IsNaN(x) || math.IsInf(x, 0) {231 return 1232 }233 p := math.Pow(10, math.Floor(math.Log10(x)))234 for _, m := range []float64{1, 2, 2.5, 5, 10} {235 if x <= m*p*(1+1e-9) {236 return m * p237 }238 }239 return 10 * p240}241242// tickFormat labels ticks compactly in the data's format: "$1,500",243// "25%", "12K".244func tickFormat(s scale, f sheet.Format) func(float64) string {245 mult, prefix, suffix := 1.0, "", ""246 switch f.Kind {247 case sheet.FmtPercent:248 mult, suffix = 100, "%"249 case sheet.FmtCurrency, sheet.FmtAccounting:250 prefix = "$"251 }252 step, top := s.step*mult, math.Max(math.Abs(s.lo), math.Abs(s.hi))*mult253 div, unit := 1.0, ""254 switch {255 case top >= 1e9 && step >= 1e8:256 div, unit = 1e9, "B"257 case top >= 1e6 && step >= 1e5:258 div, unit = 1e6, "M"259 case top >= 1e4 && step >= 1e3:260 div, unit = 1e3, "K"261 }262 decimals := 0263 if st := step / div; st < 1 {264 decimals = int(math.Ceil(-math.Log10(st) - 1e-9))265 if r := st * math.Pow(10, float64(decimals)); math.Abs(r-math.Round(r)) > 1e-6 {266 decimals++ // 0.25267 }268 }269 return func(v float64) string {270 v = v * mult / div271 sign := ""272 if v < 0 && math.Abs(v) > 1e-12 {273 sign, v = "-", -v274 }275 return sign + prefix + groupDigits(strconv.FormatFloat(v, 'f', decimals, 64)) + unit + suffix276 }277}278279// groupDigits adds thousands separators to a formatted number.280func groupDigits(s string) string {281 intPart, frac, _ := strings.Cut(s, ".")282 var b strings.Builder283 for i, r := range intPart {284 if i > 0 && (len(intPart)-i)%3 == 0 {285 b.WriteByte(',')286 }287 b.WriteRune(r)288 }289 if frac != "" {290 b.WriteString("." + frac)291 }292 return b.String()293}294295// eighths are the block elements filling 0/8 to 8/8 of a cell from the296// bottom, and from the left.297var (298 lowerEighths = []string{" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}299 leftEighths = []string{" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"}300)