cat / 012
clone

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

cat/module-path
012 / internal / chart / pie.go
pie.go 3.9K
1package chart23import (4 "image"5 "math"6 "sort"7 "strconv"89 "github.com/charmbracelet/x/ansi"1011 "src.rest/cat/012/internal/sheet"12)1314// piePlan lays out a pie of the first series, clockwise from twelve15// o'clock as in Sheets, with a legend of labels and shares to its right16// unless the legend is off: a pie's legend is always on the right.17// As text, the disc is half blocks: two square pixels per cell, each18// colored by its slice.19type piePlan struct {20 d sheet.ChartData21 msg string22 slices []slice23 disc image.Rectangle // in cells24 legend image.Point // top-left of the legend25 // noLegend leaves the legend out, for Legend: none.26 noLegend bool27 nameW int28}2930type slice struct {31 name string32 value float6433 from, to float64 // fractions of the whole, clockwise34 colorIndex int35}3637func newPiePlan(d sheet.ChartData, w, h int, o Options) *piePlan {38 p := &piePlan{d: d}39 if len(d.Series) == 0 {40 p.msg = "No numbers to chart"41 return p42 }43 total := 0.044 for i, v := range d.Series[0].Values {45 if v > 0 && !math.IsInf(v, 0) && i < len(d.Categories) {46 p.slices = append(p.slices, slice{name: d.Categories[i], value: v, colorIndex: len(p.slices)})47 total += v48 }49 }50 if len(p.slices) == 0 {51 p.msg = "A pie needs positive numbers"52 return p53 }54 acc := 0.055 for i := range p.slices {56 p.slices[i].from = acc / total57 acc += p.slices[i].value58 p.slices[i].to = acc / total59 }60 for _, s := range p.slices {61 p.nameW = max(p.nameW, ansi.StringWidth(s.name))62 }63 // "■ " name " " share: shares are at most "100%".64 p.nameW = max(min(p.nameW, w/2-8), 3)65 legendW, gap := 2+p.nameW+2+4, 366 if o.Chart.Legend == sheet.LegendNone {67 legendW, gap, p.noLegend = 0, 0, true68 }69 a := o.aspect()70 rows := min(h, int(float64(w-legendW-gap)/a))71 if rows < 2 {72 p.msg = "Too small to chart"73 return p74 }75 cols := int(math.Round(float64(rows) * a))76 x0 := max((w-(cols+gap+legendW))/2, 0)77 y0 := (h - rows) / 278 p.disc = image.Rect(x0, y0, x0+cols, y0+rows)79 n := min(len(p.slices), h)80 p.legend = image.Pt(p.disc.Max.X+3, max((h-n)/2, 0))81 return p82}8384// sliceAt returns the slice under a point of the disc, given as offsets85// from its center scaled so the rim is at distance 1, or -1 outside.86func (p *piePlan) sliceAt(dx, dy float64) int {87 if dx*dx+dy*dy > 1 {88 return -189 }90 f := math.Atan2(dx, -dy) / (2 * math.Pi)91 if f < 0 {92 f++93 }94 // The first slice ending past f; slices run clockwise in order.95 i := sort.Search(len(p.slices), func(i int) bool { return f < p.slices[i].to })96 return min(i, len(p.slices)-1)97}9899func (p *piePlan) draw(g *Grid, o Options) {100 p.drawLegend(g)101 if o.Image {102 return103 }104 cols, rows := float64(p.disc.Dx()), float64(p.disc.Dy())105 at := func(x, y float64) int { // x in columns, y in rows, from the box106 return p.sliceAt((x-cols/2)/(cols/2), (y-rows/2)/(rows/2))107 }108 for y := range p.disc.Dy() {109 for x := range p.disc.Dx() {110 top := at(float64(x)+0.5, float64(y)+0.25)111 bottom := at(float64(x)+0.5, float64(y)+0.75)112 c := g.At(p.disc.Min.X+x, p.disc.Min.Y+y)113 if c == nil {114 continue115 }116 switch {117 case top < 0 && bottom < 0:118 continue119 case top == bottom:120 c.Text, c.Fg = "█", SeriesRole(top)121 case bottom < 0:122 c.Text, c.Fg = "▀", SeriesRole(top)123 case top < 0:124 c.Text, c.Fg = "▄", SeriesRole(bottom)125 default:126 c.Text, c.Fg, c.Bg = "▀", SeriesRole(top), SeriesRole(bottom)127 }128 }129 }130}131132func (p *piePlan) drawLegend(g *Grid) {133 if p.noLegend {134 return135 }136 x, y := p.legend.X, p.legend.Y137 for i, s := range p.slices {138 if y >= g.H {139 return140 }141 if y == g.H-1 && i < len(p.slices)-1 {142 g.text(x, y, "… "+strconv.Itoa(len(p.slices)-i)+" more", Label)143 return144 }145 g.set(x, y, "■", SeriesRole(i))146 g.text(x+2, y, ansi.Truncate(s.name, p.nameW, "…"), Label)147 share := strconv.Itoa(int(math.Round((s.to-s.from)*100))) + "%"148 g.text(x+2+p.nameW+2+4-ansi.StringWidth(share), y, share, Label)149 y++150 }151}