cat / 012
clone

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

Draw charts as text and as images for kitty graphics

Dami committed 897b4ce
commit
897b4ce918cc94ae90416a395c533e6fa858ba40
parent
0be2a2814b4a478219a9e4e98c43fc083b90207d
author
Dami <dami.etoile@gmail.com>
18 files changed, +1628 -0
Ainternal/chart/bar.go151++++++++++++++
Ainternal/chart/chart.go322++++++++++++++++++++++++++++++
Ainternal/chart/chart_test.go257++++++++++++++++++++++++
Ainternal/chart/column.go284++++++++++++++++++++++++++
Ainternal/chart/image.go268+++++++++++++++++++++++++
Ainternal/chart/kitty.go89++++++++
Ainternal/chart/pie.go149++++++++++++++
Ainternal/chart/testdata/bar-single.txt8+
Ainternal/chart/testdata/bar.txt12+
Ainternal/chart/testdata/column-narrow.txt8+
Ainternal/chart/testdata/column-negative.txt10+
Ainternal/chart/testdata/column-single.txt10+
Ainternal/chart/testdata/column.txt12+
Ainternal/chart/testdata/empty.txt8+
Ainternal/chart/testdata/line-single.txt10+
Ainternal/chart/testdata/line.txt12+
Ainternal/chart/testdata/pie-negative.txt8+
Ainternal/chart/testdata/pie.txt10+
A internal/chart/bar.go +151 -0
@@ -0,0 +1,151 @@
1+package chart2+3+import (4+ "image"5+ "math"6+7+ "github.com/charmbracelet/x/ansi"8+9+ "012/internal/sheet"10+)11+12+// barPlan lays out horizontal bar charts: category labels on the left,13+// bars growing right, and the value axis along the bottom.14+//15+// Jan │██████████▌16+// Feb │███████▎17+// └─────┬─────┬18+// 0 1,000 2,00019+type barPlan struct {20+ d sheet.ChartData21+ msg string22+ sc scale23+ catW int // width of the category labels24+ axisX int25+ axisRow int26+ tickRow int27+ legend int28+ plot image.Rectangle29+ shown int30+ rows int // rows per category, including a gap row31+}32+33+func newBarPlan(d sheet.ChartData, w, h int) *barPlan {34+ p := &barPlan{d: d, legend: -1}35+ lo, hi, ok := finite(d.Series)36+ if !ok || len(d.Categories) == 0 {37+ p.msg = "No numbers to chart"38+ return p39+ }40+ lo, hi = min(lo, 0), max(hi, 0)41+ p.tickRow = h - 142+ ns := len(d.Series)43+ if ns > 1 && h >= 7 {44+ p.legend = h - 145+ p.tickRow = h - 246+ }47+ p.axisRow = p.tickRow - 148+ if p.axisRow < 1 {49+ p.msg = "Too small to chart"50+ return p51+ }52+ for _, c := range d.Categories {53+ p.catW = max(p.catW, ansi.StringWidth(c))54+ }55+ p.catW = max(min(p.catW, w/3), 1)56+ p.axisX = p.catW + 157+ x0 := p.axisX + 158+ // Tick labels are centered on their ticks, so the last one needs half59+ // its width to the right of the plot.60+ labelW := 161+ for range 2 {62+ plotW := w - x0 - (labelW+1)/263+ if plotW < 2 {64+ p.msg = "Too small to chart"65+ return p66+ }67+ p.sc = newScale(lo, hi, plotW, labelW+2, 6, d.Format)68+ for j := 0; j <= p.sc.n; j++ {69+ labelW = max(labelW, ansi.StringWidth(p.sc.label(p.sc.tick(j))))70+ }71+ }72+ p.plot = image.Rect(x0, 0, x0+p.sc.cells(), p.axisRow)73+ p.rows = ns + 174+ if len(d.Categories)*p.rows-1 > p.axisRow {75+ p.rows = ns76+ }77+ p.shown = max(min(len(d.Categories), (p.axisRow+p.rows-ns)/p.rows), 1)78+ return p79+}80+81+// barRow returns the row of series j's bar in category i.82+func (p *barPlan) barRow(i, j int) int { return i*p.rows + j }83+84+func (p *barPlan) draw(g *Grid, o Options) {85+ if p.msg != "" {86+ g.message(p.msg)87+ return88+ }89+ g.Plot = p.plot90+ ns := len(p.d.Series)91+ for y := 0; y < p.axisRow; y++ {92+ g.set(p.axisX, y, "│", Axis)93+ }94+ g.set(p.axisX, p.axisRow, "└", Axis)95+ for x := p.axisX + 1; x < p.plot.Max.X; x++ {96+ g.set(x, p.axisRow, "─", Axis)97+ }98+ next := 099+ for j := 0; j <= p.sc.n; j++ {100+ x := p.axisX101+ if j > 0 {102+ x = p.plot.Min.X + j*p.sc.k - 1103+ g.set(x, p.axisRow, "┬", Axis)104+ }105+ label := p.sc.label(p.sc.tick(j))106+ lw := ansi.StringWidth(label)107+ lx := min(max(x-(lw-1)/2, 0), g.W-lw)108+ if lx >= next {109+ g.text(lx, p.tickRow, label, Label)110+ next = lx + lw + 1111+ }112+ }113+ for i := 0; i < p.shown; i++ {114+ label := ansi.Truncate(p.d.Categories[i], p.catW, "…")115+ g.text(p.catW-ansi.StringWidth(label), p.barRow(i, (ns-1)/2), label, Label)116+ }117+ if p.legend >= 0 {118+ g.legend(p.legend, seriesNames(p.d))119+ }120+ if o.Image {121+ return122+ }123+ zero := p.sc.pos(0)124+ for j, s := range p.d.Series {125+ for i := 0; i < p.shown && i < len(s.Values); i++ {126+ v := s.Values[i]127+ if math.IsNaN(v) {128+ continue129+ }130+ end := p.sc.pos(v)131+ lo, hi := min(zero, end), max(zero, end)132+ for c := int(math.Floor(lo)); float64(c) < hi; c++ {133+ cover := min(hi, float64(c+1)) - max(lo, float64(c))134+ glyph := ""135+ if v >= 0 {136+ glyph = leftEighths[int(math.Round(cover*8))]137+ } else {138+ switch {139+ case cover >= 0.75:140+ glyph = "█"141+ case cover >= 0.25:142+ glyph = "▐" // left of the axis, bars fill from the right143+ }144+ }145+ if glyph != "" && glyph != " " {146+ g.set(p.plot.Min.X+c, p.barRow(i, j), glyph, SeriesRole(j))147+ }148+ }149+ }150+ }151+}
A internal/chart/chart.go +322 -0
@@ -0,0 +1,322 @@
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.6+package chart7+8+import (9+ "image"10+ "math"11+ "strconv"12+ "strings"13+14+ "github.com/charmbracelet/x/ansi"15+16+ "012/internal/sheet"17+)18+19+// Role is what a cell of a drawn chart shows, so the UI can style it with20+// its theme.21+type Role uint822+23+const (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+ Series // Series+i is the color of series (or slice) i29+)30+31+// Colors is how many series colors there are; later series reuse them.32+const Colors = 633+34+// SeriesRole is the role of series i.35+func SeriesRole(i int) Role { return Series + Role(i%Colors) }36+37+// Cell is one terminal cell of a drawn chart.38+type Cell struct {39+ Text string // one grapheme; "" continues a wide one to the left40+ Fg Role41+ Bg Role // only the pie's half blocks set a background42+}43+44+// Grid is a drawn chart, W by H cells.45+type Grid struct {46+ W, H int47+ Cells []Cell48+ // Plot is the area an image covers, in cells. It is empty when the49+ // chart has nothing to plot.50+ Plot image.Rectangle51+}52+53+// Options control drawing.54+type Options struct {55+ // Image leaves the plot area blank, for an image drawn by Image.56+ Image bool57+ // CellW and CellH are the size of a terminal cell in pixels; they set58+ // a pie's proportions. Zero means the usual 1:2.59+ CellW, CellH int60+}61+62+func (o Options) aspect() float64 {63+ if o.CellW <= 0 || o.CellH <= 0 {64+ return 265+ }66+ return float64(o.CellH) / float64(o.CellW)67+}68+69+func newGrid(w, h int) *Grid {70+ g := &Grid{W: w, H: h, Cells: make([]Cell, w*h)}71+ for i := range g.Cells {72+ g.Cells[i].Text = " "73+ }74+ return g75+}76+77+// At returns the cell at x, y, or nil outside the grid.78+func (g *Grid) At(x, y int) *Cell {79+ if x < 0 || y < 0 || x >= g.W || y >= g.H {80+ return nil81+ }82+ return &g.Cells[y*g.W+x]83+}84+85+func (g *Grid) set(x, y int, s string, fg Role) {86+ if c := g.At(x, y); c != nil {87+ c.Text, c.Fg, c.Bg = s, fg, None88+ }89+}90+91+// text writes s from x, clipped to the grid.92+func (g *Grid) text(x, y int, s string, fg Role) {93+ for _, r := range s {94+ w := ansi.StringWidth(string(r))95+ if w == 0 {96+ continue97+ }98+ if x+w > g.W {99+ return100+ }101+ g.set(x, y, string(r), fg)102+ for k := 1; k < w; k++ {103+ g.set(x+k, y, "", fg)104+ }105+ x += w106+ }107+}108+109+// String returns the grid as plain text, for tests.110+func (g *Grid) String() string {111+ var b strings.Builder112+ for y := range g.H {113+ var line strings.Builder114+ for x := range g.W {115+ line.WriteString(g.At(x, y).Text)116+ }117+ b.WriteString(strings.TrimRight(line.String(), " "))118+ if y < g.H-1 {119+ b.WriteByte('\n')120+ }121+ }122+ return b.String()123+}124+125+// Draw draws d as a chart of type t in w by h cells.126+func Draw(t sheet.ChartType, d sheet.ChartData, w, h int, o Options) *Grid {127+ g := newGrid(max(w, 0), max(h, 0))128+ if w < 8 || h < 3 {129+ return g130+ }131+ switch t {132+ case sheet.ChartBar:133+ newBarPlan(d, w, h).draw(g, o)134+ case sheet.ChartLine:135+ newColumnPlan(d, w, h, true).draw(g, o)136+ case sheet.ChartPie:137+ newPiePlan(d, w, h, o).draw(g, o)138+ default:139+ newColumnPlan(d, w, h, false).draw(g, o)140+ }141+ return g142+}143+144+// message centers a note in the grid, e.g. when there's nothing to plot.145+func (g *Grid) message(s string) {146+ s = ansi.Truncate(s, g.W-2, "…")147+ g.text((g.W-ansi.StringWidth(s))/2, (g.H-1)/2, s, Muted)148+}149+150+// finite returns the range of the finite values in d's series, and false151+// if there are none.152+func finite(series []sheet.ChartSeries) (lo, hi float64, ok bool) {153+ lo, hi = math.Inf(1), math.Inf(-1)154+ for _, s := range series {155+ for _, v := range s.Values {156+ if !math.IsNaN(v) && !math.IsInf(v, 0) {157+ lo, hi, ok = min(lo, v), max(hi, v), true158+ }159+ }160+ }161+ return lo, hi, ok162+}163+164+// scale is a value axis: lo..hi divided into n intervals of step, each k165+// cells long, so every tick lands on a cell boundary.166+type scale struct {167+ lo, hi, step float64168+ n, k int169+ label func(float64) string170+}171+172+// cells is the axis length in cells.173+func (s scale) cells() int { return s.n * s.k }174+175+// pos maps a value to a distance along the axis in cells.176+func (s scale) pos(v float64) float64 {177+ return (v - s.lo) / (s.hi - s.lo) * float64(s.cells())178+}179+180+// tick returns the value of tick j.181+func (s scale) tick(j int) float64 { return s.lo + float64(j)*s.step }182+183+// newScale fits dlo..dhi onto length cells, with ticks at least minK184+// cells apart and at most maxN intervals.185+func newScale(dlo, dhi float64, length, minK, maxN int, f sheet.Format) scale {186+ if dlo == dhi {187+ if dlo == 0 {188+ dhi = 1189+ } else if dlo > 0 {190+ dlo = 0191+ } else {192+ dhi = 0193+ }194+ }195+ best := scale{lo: dlo, hi: dhi, step: dhi - dlo, n: 1, k: max(length, 1)}196+ for target := min(maxN, max(length/minK, 1)); target >= 1; target-- {197+ step := niceStep((dhi - dlo) / float64(target))198+ lo := math.Floor(dlo/step+1e-9) * step199+ hi := math.Ceil(dhi/step-1e-9) * step200+ n := int(math.Round((hi - lo) / step))201+ if n < 1 {202+ continue203+ }204+ if k := length / n; k >= minK || target == 1 && k >= 1 {205+ best = scale{lo: lo, hi: hi, step: step, n: n, k: k}206+ break207+ }208+ }209+ best.label = tickFormat(best, f)210+ return best211+}212+213+// niceStep rounds x up to 1, 2 or 5 times a power of ten.214+func niceStep(x float64) float64 {215+ if x <= 0 || math.IsNaN(x) || math.IsInf(x, 0) {216+ return 1217+ }218+ p := math.Pow(10, math.Floor(math.Log10(x)))219+ for _, m := range []float64{1, 2, 2.5, 5, 10} {220+ if x <= m*p*(1+1e-9) {221+ return m * p222+ }223+ }224+ return 10 * p225+}226+227+// tickFormat labels ticks compactly in the data's format: "$1,500",228+// "25%", "12K".229+func tickFormat(s scale, f sheet.Format) func(float64) string {230+ mult, prefix, suffix := 1.0, "", ""231+ switch f.Kind {232+ case sheet.FmtPercent:233+ mult, suffix = 100, "%"234+ case sheet.FmtCurrency, sheet.FmtAccounting:235+ prefix = "$"236+ }237+ step, top := s.step*mult, math.Max(math.Abs(s.lo), math.Abs(s.hi))*mult238+ div, unit := 1.0, ""239+ switch {240+ case top >= 1e9 && step >= 1e8:241+ div, unit = 1e9, "B"242+ case top >= 1e6 && step >= 1e5:243+ div, unit = 1e6, "M"244+ case top >= 1e4 && step >= 1e3:245+ div, unit = 1e3, "K"246+ }247+ decimals := 0248+ if st := step / div; st < 1 {249+ decimals = int(math.Ceil(-math.Log10(st) - 1e-9))250+ if r := st * math.Pow(10, float64(decimals)); math.Abs(r-math.Round(r)) > 1e-6 {251+ decimals++ // 0.25252+ }253+ }254+ return func(v float64) string {255+ v = v * mult / div256+ sign := ""257+ if v < 0 && math.Abs(v) > 1e-12 {258+ sign, v = "-", -v259+ }260+ return sign + prefix + groupDigits(strconv.FormatFloat(v, 'f', decimals, 64)) + unit + suffix261+ }262+}263+264+// groupDigits adds thousands separators to a formatted number.265+func groupDigits(s string) string {266+ intPart, frac, _ := strings.Cut(s, ".")267+ var b strings.Builder268+ for i, r := range intPart {269+ if i > 0 && (len(intPart)-i)%3 == 0 {270+ b.WriteByte(',')271+ }272+ b.WriteRune(r)273+ }274+ if frac != "" {275+ b.WriteString("." + frac)276+ }277+ return b.String()278+}279+280+// legend draws "■ Rent ■ Food" centered on row y, shortening names to281+// fit.282+func (g *Grid) legend(y int, names []string) {283+ const gap = " "284+ for limit := 24; limit >= 1; limit-- {285+ items := make([]string, len(names))286+ width := 0287+ for i, n := range names {288+ items[i] = "■ " + ansi.Truncate(n, limit, "…")289+ width += ansi.StringWidth(items[i])290+ }291+ width += len(gap) * (len(items) - 1)292+ if width > g.W && limit > 1 {293+ continue294+ }295+ x := max((g.W-width)/2, 0)296+ for i, it := range items {297+ if x+ansi.StringWidth(it) > g.W {298+ g.text(max(x, 0), y, "…", Label)299+ return300+ }301+ g.set(x, y, "■", SeriesRole(i))302+ g.text(x+2, y, it[len("■ "):], Label)303+ x += ansi.StringWidth(it) + len(gap)304+ }305+ return306+ }307+}308+309+func seriesNames(d sheet.ChartData) []string {310+ names := make([]string, len(d.Series))311+ for i, s := range d.Series {312+ names[i] = s.Name313+ }314+ return names315+}316+317+// eighths are the block elements filling 0/8 to 8/8 of a cell from the318+// bottom, and from the left.319+var (320+ lowerEighths = []string{" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}321+ leftEighths = []string{" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"}322+)
A internal/chart/chart_test.go +257 -0
@@ -0,0 +1,257 @@
1+package chart2+3+import (4+ "bytes"5+ "compress/zlib"6+ "encoding/base64"7+ "flag"8+ "image"9+ "image/color"10+ "io"11+ "math"12+ "math/rand/v2"13+ "os"14+ "path/filepath"15+ "regexp"16+ "strings"17+ "testing"18+19+ "012/internal/sheet"20+)21+22+var update = flag.Bool("update", false, "rewrite text chart goldens in testdata")23+24+var nan = math.NaN()25+26+var budget = sheet.ChartData{27+ Categories: []string{"Jan", "Feb", "Mar", "Apr"},28+ Series: []sheet.ChartSeries{29+ {Name: "Rent", Values: []float64{1450, 1450, 1500, 1500}},30+ {Name: "Food", Values: []float64{600, nan, 612, 700}},31+ },32+ Format: sheet.Format{Kind: sheet.FmtCurrency},33+}34+35+var single = sheet.ChartData{36+ Categories: []string{"North", "South", "East", "West"},37+ Series: []sheet.ChartSeries{{Name: "Sales", Values: []float64{120, 80, 45, 30}}},38+}39+40+var mixed = sheet.ChartData{41+ Categories: []string{"Q1", "Q2", "Q3", "Q4"},42+ Series: []sheet.ChartSeries{{Name: "Profit", Values: []float64{0.12, -0.05, 0.08, 0.2}}},43+ Format: sheet.Format{Kind: sheet.FmtPercent},44+}45+46+// TestDrawGoldens renders every chart type as text; the goldens in47+// testdata are the review surface for how charts look.48+func TestDrawGoldens(t *testing.T) {49+ cases := []struct {50+ name string51+ t sheet.ChartType52+ d sheet.ChartData53+ w, h int54+ }{55+ {"column", sheet.ChartColumn, budget, 44, 12},56+ {"column-single", sheet.ChartColumn, single, 40, 10},57+ {"column-negative", sheet.ChartColumn, mixed, 36, 10},58+ {"column-narrow", sheet.ChartColumn, budget, 22, 8},59+ {"bar", sheet.ChartBar, budget, 44, 12},60+ {"bar-single", sheet.ChartBar, single, 40, 8},61+ {"line", sheet.ChartLine, budget, 44, 12},62+ {"line-single", sheet.ChartLine, single, 40, 10},63+ {"pie", sheet.ChartPie, single, 40, 10},64+ {"empty", sheet.ChartColumn, sheet.ChartData{Categories: []string{"a"}, Series: []sheet.ChartSeries{{Values: []float64{nan}}}}, 30, 8},65+ {"pie-negative", sheet.ChartPie, sheet.ChartData{Categories: []string{"a"}, Series: []sheet.ChartSeries{{Values: []float64{-1}}}}, 30, 8},66+ }67+ for _, tc := range cases {68+ t.Run(tc.name, func(t *testing.T) {69+ got := Draw(tc.t, tc.d, tc.w, tc.h, Options{}).String() + "\n"70+ path := filepath.Join("testdata", tc.name+".txt")71+ if *update {72+ os.MkdirAll("testdata", 0o755)73+ os.WriteFile(path, []byte(got), 0o644)74+ return75+ }76+ want, err := os.ReadFile(path)77+ if err != nil {78+ t.Fatalf("missing golden (go test ./internal/chart -update): %v", err)79+ }80+ if got != string(want) {81+ t.Errorf("chart changed:\n%s\nwant:\n%s", got, want)82+ }83+ })84+ }85+}86+87+func TestScale(t *testing.T) {88+ tests := []struct {89+ lo, hi float6490+ length int91+ f sheet.Format92+ wantLo, wantHi float6493+ labels []string94+ }{95+ {0, 1500, 9, sheet.Format{Kind: sheet.FmtCurrency}, 0, 1500, []string{"$0", "$500", "$1,000", "$1,500"}},96+ {0, 1, 10, sheet.Format{}, 0, 1, []string{"0.0", "0.2", "0.4", "0.6", "0.8", "1.0"}},97+ {-0.05, 0.2, 7, sheet.Format{Kind: sheet.FmtPercent}, -0.1, 0.2, []string{"-10%", "0%", "10%", "20%"}},98+ {0, 48000, 8, sheet.Format{}, 0, 60000, []string{"0K", "20K", "40K", "60K"}},99+ {0, 0.75, 6, sheet.Format{}, 0, 0.75, []string{"0.00", "0.25", "0.50", "0.75"}},100+ {5, 5, 4, sheet.Format{}, 0, 5, nil},101+ }102+ for _, tt := range tests {103+ s := newScale(tt.lo, tt.hi, tt.length, 2, 6, tt.f)104+ if s.lo != tt.wantLo || s.hi != tt.wantHi {105+ t.Errorf("%v..%v: scale %v..%v, want %v..%v", tt.lo, tt.hi, s.lo, s.hi, tt.wantLo, tt.wantHi)106+ }107+ if s.cells() > tt.length || s.k < 2 {108+ t.Errorf("%v..%v: %d intervals of %d cells in %d", tt.lo, tt.hi, s.n, s.k, tt.length)109+ }110+ if tt.labels == nil {111+ continue112+ }113+ var got []string114+ for j := 0; j <= s.n; j++ {115+ got = append(got, s.label(s.tick(j)))116+ }117+ if strings.Join(got, " ") != strings.Join(tt.labels, " ") {118+ t.Errorf("%v..%v: labels %q, want %q", tt.lo, tt.hi, got, tt.labels)119+ }120+ }121+}122+123+func TestLayout(t *testing.T) {124+ // Columns: every bar lies inside the plot and bars don't overlap.125+ p := newColumnPlan(budget, 44, 12, false)126+ prev := 0127+ for i := range 4 {128+ for j := range 2 {129+ x0, x1 := p.bar(i, j)130+ if x0 < p.plot.Min.X || x1 > p.plot.Max.X || x0 < prev {131+ t.Errorf("bar %d,%d at %d..%d overlaps or leaves %v", i, j, x0, x1, p.plot)132+ }133+ prev = x1134+ }135+ }136+ // Ticks land on cell boundaries, the top one inside the plot.137+ if top := p.sc.cells(); top > p.plot.Dy() || p.sc.pos(p.sc.hi) != float64(top) {138+ t.Errorf("value axis %d cells in a %d row plot", top, p.plot.Dy())139+ }140+ // Too many categories: only what fits is drawn.141+ many := sheet.ChartData{Series: []sheet.ChartSeries{{Name: "x"}}}142+ for i := range 100 {143+ many.Categories = append(many.Categories, "c")144+ many.Series[0].Values = append(many.Series[0].Values, float64(i))145+ }146+ if p := newColumnPlan(many, 30, 10, false); p.shown != p.plot.Dx() {147+ t.Errorf("shown %d categories in %d columns", p.shown, p.plot.Dx())148+ }149+ // The plot rectangle is what Image fills.150+ for _, ty := range sheet.ChartTypes {151+ o := Options{CellW: 8, CellH: 16}152+ g := Draw(ty, budget, 44, 12, o)153+ img := Image(ty, budget, 44, 12, o, testPalette)154+ if img == nil || img.Bounds().Dx() != g.Plot.Dx()*8 || img.Bounds().Dy() != g.Plot.Dy()*16 {155+ t.Errorf("%v: image for plot %v", ty, g.Plot)156+ }157+ }158+}159+160+func TestImageLeavesPlotToImage(t *testing.T) {161+ g := Draw(sheet.ChartColumn, budget, 44, 12, Options{Image: true})162+ for y := g.Plot.Min.Y; y < g.Plot.Max.Y; y++ {163+ for x := g.Plot.Min.X; x < g.Plot.Max.X; x++ {164+ if c := g.At(x, y); c.Text != " " {165+ t.Fatalf("plot cell %d,%d = %q with an image", x, y, c.Text)166+ }167+ }168+ }169+ if !strings.Contains(g.String(), "$1,500 ┤") || !strings.Contains(g.String(), "■ Rent") {170+ t.Errorf("labels missing:\n%s", g)171+ }172+}173+174+var testPalette = Palette{175+ Series: [Colors]color.RGBA{{255, 0, 0, 255}, {0, 0, 255, 255}, {0, 255, 0, 255}, {9, 9, 9, 255}, {8, 8, 8, 255}, {7, 7, 7, 255}},176+ Grid: color.RGBA{128, 128, 128, 80},177+}178+179+func TestImagePixels(t *testing.T) {180+ o := Options{CellW: 8, CellH: 16}181+ p := newColumnPlan(single, 40, 10, false)182+ img := Image(sheet.ChartColumn, single, 40, 10, o, testPalette)183+ x0, x1 := p.bar(0, 0)184+ mid := ((x0+x1)/2 - p.plot.Min.X) * 8185+ bottom := img.Bounds().Dy() - 2186+ if c := img.RGBAAt(mid, bottom); c != (color.RGBA{255, 0, 0, 255}) {187+ t.Errorf("bar pixel %v", c)188+ }189+ if c := img.RGBAAt(mid, 1); c.A != 0 {190+ t.Errorf("above the bar %v, want transparent", c)191+ }192+ // Pie: inside the first slice (top right) is its color.193+ pie := Image(sheet.ChartPie, single, 40, 10, o, testPalette)194+ b := pie.Bounds()195+ if c := pie.RGBAAt(b.Dx()/2+b.Dx()/8, b.Dy()/2-b.Dy()/4); c != testPalette.Series[0] {196+ t.Errorf("pie slice pixel %v", c)197+ }198+ if c := pie.RGBAAt(0, 0); c.A != 0 {199+ t.Errorf("pie corner %v, want transparent", c)200+ }201+}202+203+func TestTransmit(t *testing.T) {204+ img := image.NewRGBA(image.Rect(0, 0, 2, 1))205+ img.Pix = []uint8{255, 0, 0, 255, 0, 0, 64, 128} // opaque red, half blue (premultiplied)206+ got := Transmit(7, img, 3, 2, nil)207+ re := regexp.MustCompile("^\x1b_Ga=T,q=2,f=32,o=z,s=2,v=1,i=7,U=1,c=3,r=2,m=0;([A-Za-z0-9+/=]+)\x1b\\\\$")208+ m := re.FindStringSubmatch(got)209+ if m == nil {210+ t.Fatalf("transmit = %q", got)211+ }212+ z, _ := base64.StdEncoding.DecodeString(m[1])213+ zr, err := zlib.NewReader(bytes.NewReader(z))214+ if err != nil {215+ t.Fatal(err)216+ }217+ raw, _ := io.ReadAll(zr)218+ if want := []byte{255, 0, 0, 255, 0, 0, 127, 128}; !bytes.Equal(raw, want) {219+ t.Errorf("pixels %v, want straight alpha %v", raw, want)220+ }221+222+ // Big images go in chunks of at most 4096 bytes, each wrapped.223+ noise := image.NewRGBA(image.Rect(0, 0, 200, 200))224+ rng := rand.New(rand.NewPCG(1, 2))225+ for i := range noise.Pix {226+ noise.Pix[i] = uint8(rng.IntN(256))227+ }228+ wrapped := Transmit(9, noise, 20, 10, func(s string) string { return "<" + s + ">" })229+ chunks := strings.Split(strings.TrimSuffix(strings.TrimPrefix(wrapped, "<"), ">"), "><")230+ if len(chunks) < 2 {231+ t.Fatalf("%d chunks", len(chunks))232+ }233+ for i, c := range chunks {234+ head, body, _ := strings.Cut(strings.TrimPrefix(c, "\x1b_G"), ";")235+ last := i == len(chunks)-1236+ switch {237+ case i == 0 && !strings.HasPrefix(head, "a=T,q=2,") || i > 0 && !strings.HasPrefix(head, "q=2,"):238+ t.Errorf("chunk %d options %q", i, head)239+ case last != strings.HasSuffix(head, "m=0"):240+ t.Errorf("chunk %d of %d: %q", i, len(chunks), head)241+ case len(strings.TrimSuffix(body, "\x1b\\")) > 4096:242+ t.Errorf("chunk %d is %d bytes", i, len(body))243+ }244+ }245+}246+247+func TestPlaceholderAndDelete(t *testing.T) {248+ if got := Placeholder(0, 2); got != "\U0010EEEE̅̎" {249+ t.Errorf("placeholder %q", got)250+ }251+ if got := Delete(7, nil); got != "\x1b_Ga=d,d=I,i=7,q=2\x1b\\" {252+ t.Errorf("delete %q", got)253+ }254+ if got := Query(); got != "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" {255+ t.Errorf("query %q", got)256+ }257+}
A internal/chart/column.go +284 -0
@@ -0,0 +1,284 @@
1+package chart2+3+import (4+ "image"5+ "math"6+7+ "github.com/charmbracelet/x/ansi"8+9+ "012/internal/sheet"10+)11+12+// columnPlan lays out column and line charts: a value axis on the left13+// with labeled ticks, the plot, a category axis and labels below it, and a14+// legend when there are several series.15+//16+// 1,500 ┤ █▆17+// 1,000 ┤ ██ ▆18+// 500 ┤ ██ █19+// 0 ┼─────────20+// Jan Feb21+// ■ Rent ■ Food22+type columnPlan struct {23+ d sheet.ChartData24+ line bool25+ msg string26+ sc scale27+ labelW int // width of the tick labels28+ axisX int // column of the value axis29+ axisRow int // row of the category axis30+ catRow int31+ legend int // row of the legend, -1 for none32+ plot image.Rectangle33+ shown int // categories drawn34+ // Columns: each category is a group gw cells wide, starting off cells35+ // into the plot; each series' bar in it is bw wide.36+ gw, off, bw, inset int37+}38+39+func newColumnPlan(d sheet.ChartData, w, h int, line bool) *columnPlan {40+ p := &columnPlan{d: d, line: line, legend: -1}41+ lo, hi, ok := finite(d.Series)42+ if !ok || len(d.Categories) == 0 {43+ p.msg = "No numbers to chart"44+ return p45+ }46+ if !line {47+ lo, hi = min(lo, 0), max(hi, 0)48+ }49+ p.catRow = h - 150+ if len(d.Series) > 1 && h >= 7 {51+ p.legend = h - 152+ p.catRow = h - 253+ }54+ p.axisRow = p.catRow - 155+ if p.axisRow < 2 {56+ p.msg = "Too small to chart"57+ return p58+ }59+ p.sc = newScale(lo, hi, p.axisRow, 2, 6, d.Format)60+ for j := 0; j <= p.sc.n; j++ {61+ p.labelW = max(p.labelW, ansi.StringWidth(p.sc.label(p.sc.tick(j))))62+ }63+ p.axisX = p.labelW + 164+ p.plot = image.Rect(p.axisX+1, 0, w-1, p.axisRow)65+ plotW := p.plot.Dx()66+ if plotW < 2 {67+ p.msg = "Too small to chart"68+ return p69+ }70+ ns := len(d.Series)71+ if line {72+ p.shown = min(len(d.Categories), plotW*2)73+ return p74+ }75+ p.shown = max(min(len(d.Categories), plotW/ns), 1)76+ p.gw = plotW / p.shown77+ gap := 078+ if p.gw > ns {79+ gap = max(1, p.gw/4)80+ }81+ p.bw = max((p.gw-gap)/ns, 1)82+ p.inset = (p.gw - p.bw*ns) / 283+ p.off = (plotW - p.shown*p.gw) / 284+ return p85+}86+87+// bar returns the columns of series j's bar in category i.88+func (p *columnPlan) bar(i, j int) (x0, x1 int) {89+ x0 = p.plot.Min.X + p.off + i*p.gw + p.inset + j*p.bw90+ return x0, x0 + p.bw91+}92+93+// dotX returns the x of category i's point in braille dots (two per cell).94+func (p *columnPlan) dotX(i int) int {95+ dots := p.plot.Dx() * 296+ if p.shown <= 1 {97+ return dots / 298+ }99+ return int(math.Round(float64(i) * float64(dots-1) / float64(p.shown-1)))100+}101+102+// catCenter is the column under which category i's label is centered.103+func (p *columnPlan) catCenter(i int) int {104+ if p.line {105+ return p.plot.Min.X + p.dotX(i)/2106+ }107+ return p.plot.Min.X + p.off + i*p.gw + p.gw/2108+}109+110+func (p *columnPlan) draw(g *Grid, o Options) {111+ if p.msg != "" {112+ g.message(p.msg)113+ return114+ }115+ g.Plot = p.plot116+ // Value axis with a label at each tick, up to the top one.117+ for y := p.axisRow - p.sc.cells(); y < p.axisRow; y++ {118+ g.set(p.axisX, y, "│", Axis)119+ }120+ for x := p.plot.Min.X; x < g.W; x++ {121+ g.set(x, p.axisRow, "─", Axis)122+ }123+ for j := 0; j <= p.sc.n; j++ {124+ y := p.axisRow - j*p.sc.k125+ label := p.sc.label(p.sc.tick(j))126+ g.text(p.labelW-ansi.StringWidth(label), y, label, Label)127+ mark := "┤"128+ if j == 0 {129+ mark = "┼"130+ }131+ g.set(p.axisX, y, mark, Axis)132+ }133+ p.drawCategories(g)134+ if p.legend >= 0 {135+ g.legend(p.legend, seriesNames(p.d))136+ }137+ if o.Image {138+ return139+ }140+ if p.line {141+ p.drawLines(g)142+ } else {143+ p.drawBars(g)144+ }145+}146+147+// drawCategories labels as many categories as fit without touching.148+func (p *columnPlan) drawCategories(g *Grid) {149+ room := p.gw - 1150+ if p.line {151+ room = p.plot.Dx()/max(p.shown-1, 1) - 1152+ }153+ room = max(room, 3)154+ next := 0 // first free column155+ for i := 0; i < p.shown; i++ {156+ label := ansi.Truncate(p.d.Categories[i], room, "…")157+ w := ansi.StringWidth(label)158+ x := min(max(p.catCenter(i)-(w-1)/2, 0), g.W-w)159+ if x < next {160+ continue161+ }162+ g.text(x, p.catRow, label, Label)163+ next = x + w + 1164+ }165+}166+167+func (p *columnPlan) drawBars(g *Grid) {168+ zero := p.sc.pos(0)169+ for j, s := range p.d.Series {170+ for i := 0; i < p.shown && i < len(s.Values); i++ {171+ v := s.Values[i]172+ if math.IsNaN(v) {173+ continue174+ }175+ x0, x1 := p.bar(i, j)176+ top := p.sc.pos(v)177+ lo, hi := min(zero, top), max(zero, top)178+ for r := int(math.Floor(lo)); float64(r) < hi; r++ {179+ cover := min(hi, float64(r+1)) - max(lo, float64(r))180+ glyph := ""181+ if v >= 0 {182+ glyph = lowerEighths[int(math.Round(cover*8))]183+ } else {184+ switch {185+ case cover >= 0.75:186+ glyph = "█"187+ case cover >= 0.25:188+ glyph = "▀" // below the axis, bars fill from the top189+ }190+ }191+ if glyph == "" || glyph == " " {192+ continue193+ }194+ for x := x0; x < x1; x++ {195+ g.set(x, p.axisRow-1-r, glyph, SeriesRole(j))196+ }197+ }198+ }199+ }200+}201+202+// dotY maps a value to a braille dot row, four per cell, from the top.203+func (p *columnPlan) dotY(v float64) int {204+ rows := p.axisRow * 4205+ full := p.sc.cells()*4 - 1206+ y := rows - 1 - int(math.Round(p.sc.pos(v)/float64(p.sc.cells())*float64(full)))207+ return min(max(y, 0), rows-1)208+}209+210+// drawLines draws each series as a braille line; where lines cross a211+// cell, the later series' color wins.212+func (p *columnPlan) drawLines(g *Grid) {213+ w, h := p.plot.Dx(), p.axisRow214+ bits := make([]uint8, w*h)215+ owner := make([]int, w*h)216+ dot := func(x, y, series int) {217+ cx, cy := x/2, y/4218+ if cx < 0 || cx >= w || cy < 0 || cy >= h {219+ return220+ }221+ bits[cy*w+cx] |= brailleBit[x%2][y%4]222+ owner[cy*w+cx] = series223+ }224+ for j, s := range p.d.Series {225+ px, py, have := 0, 0, false226+ for i := 0; i < p.shown && i < len(s.Values); i++ {227+ v := s.Values[i]228+ if math.IsNaN(v) {229+ have = false230+ continue231+ }232+ x, y := p.dotX(i), p.dotY(v)233+ if have {234+ bresenham(px, py, x, y, func(x, y int) { dot(x, y, j) })235+ } else {236+ dot(x, y, j)237+ }238+ px, py, have = x, y, true239+ }240+ }241+ for cy := range h {242+ for cx := range w {243+ if b := bits[cy*w+cx]; b != 0 {244+ g.set(p.plot.Min.X+cx, cy, string(rune(0x2800+int(b))), SeriesRole(owner[cy*w+cx]))245+ }246+ }247+ }248+}249+250+// brailleBit is the bit of the braille dot at column x, row y of a cell.251+var brailleBit = [2][4]uint8{{0x01, 0x02, 0x04, 0x40}, {0x08, 0x10, 0x20, 0x80}}252+253+func bresenham(x0, y0, x1, y1 int, plot func(x, y int)) {254+ dx, dy := abs(x1-x0), -abs(y1-y0)255+ sx, sy := sign(x1-x0), sign(y1-y0)256+ e := dx + dy257+ for {258+ plot(x0, y0)259+ if x0 == x1 && y0 == y1 {260+ return261+ }262+ e2 := 2 * e263+ if e2 >= dy {264+ e += dy265+ x0 += sx266+ }267+ if e2 <= dx {268+ e += dx269+ y0 += sy270+ }271+ }272+}273+274+func abs(n int) int { return max(n, -n) }275+276+func sign(n int) int {277+ switch {278+ case n > 0:279+ return 1280+ case n < 0:281+ return -1282+ }283+ return 0284+}
A internal/chart/image.go +268 -0
@@ -0,0 +1,268 @@
1+package chart2+3+import (4+ "image"5+ "image/color"6+ "math"7+8+ "012/internal/sheet"9+)10+11+// Palette holds the colors an image draws with: the terminal's colors for12+// the series roles, so the image matches the text legend, and a faint13+// color for gridlines.14+type Palette struct {15+ Series [Colors]color.RGBA16+ Grid color.RGBA17+}18+19+// Image draws the plot area of the chart Draw lays out with the same20+// arguments, cw by ch pixels per cell, on a transparent background. It21+// returns nil when there's nothing to plot.22+func Image(t sheet.ChartType, d sheet.ChartData, w, h int, o Options, pal Palette) *image.RGBA {23+ cw, ch := o.CellW, o.CellH24+ if cw <= 0 || ch <= 0 {25+ cw, ch = 10, 2026+ o.CellW, o.CellH = cw, ch27+ }28+ if w < 8 || h < 3 {29+ return nil30+ }31+ var plot image.Rectangle32+ var draw func(c *canvas)33+ switch t {34+ case sheet.ChartBar:35+ p := newBarPlan(d, w, h)36+ plot, draw = p.plot, p.image37+ if p.msg != "" {38+ return nil39+ }40+ case sheet.ChartPie:41+ p := newPiePlan(d, w, h, o)42+ plot, draw = p.disc, p.image43+ if p.msg != "" {44+ return nil45+ }46+ default:47+ p := newColumnPlan(d, w, h, t == sheet.ChartLine)48+ plot, draw = p.plot, p.image49+ if p.msg != "" {50+ return nil51+ }52+ }53+ if plot.Empty() {54+ return nil55+ }56+ c := &canvas{57+ RGBA: image.NewRGBA(image.Rect(0, 0, plot.Dx()*cw, plot.Dy()*ch)),58+ cw: float64(cw), ch: float64(ch), pal: pal,59+ }60+ draw(c)61+ return c.RGBA62+}63+64+// canvas is an image with blending and the size of a cell.65+type canvas struct {66+ *image.RGBA67+ cw, ch float6468+ pal Palette69+}70+71+// blend paints c over the pixel at x, y with coverage a.72+func (cv *canvas) blend(x, y int, c color.RGBA, a float64) {73+ if !(image.Point{x, y}.In(cv.Rect)) || a <= 0 {74+ return75+ }76+ a = min(a, 1) * float64(c.A) / 25577+ i := cv.PixOffset(x, y)78+ p := cv.Pix[i : i+4 : i+4]79+ // Premultiplied source over.80+ for k, v := range []uint8{c.R, c.G, c.B} {81+ p[k] = uint8(math.Round(float64(v)*a + float64(p[k])*(1-a)))82+ }83+ p[3] = uint8(math.Round(255*a + float64(p[3])*(1-a)))84+}85+86+// rect fills x0..x1, y0..y1 in pixels, antialiasing fractional edges.87+func (cv *canvas) rect(x0, y0, x1, y1 float64, c color.RGBA) {88+ for y := int(math.Floor(y0)); float64(y) < y1; y++ {89+ cy := min(y1, float64(y+1)) - max(y0, float64(y))90+ for x := int(math.Floor(x0)); float64(x) < x1; x++ {91+ cx := min(x1, float64(x+1)) - max(x0, float64(x))92+ cv.blend(x, y, c, cx*cy)93+ }94+ }95+}96+97+// hline draws a one pixel horizontal line at y.98+func (cv *canvas) hline(y int, c color.RGBA) {99+ for x := cv.Rect.Min.X; x < cv.Rect.Max.X; x++ {100+ cv.blend(x, y, c, 1)101+ }102+}103+104+// vline draws a one pixel vertical line at x.105+func (cv *canvas) vline(x int, c color.RGBA) {106+ for y := cv.Rect.Min.Y; y < cv.Rect.Max.Y; y++ {107+ cv.blend(x, y, c, 1)108+ }109+}110+111+// polyline strokes the points with a round-capped line width px wide.112+// Coverage is kept per pixel, so joints aren't painted twice.113+func (cv *canvas) polyline(pts [][2]float64, width float64, c color.RGBA) {114+ b := cv.Rect115+ cover := make([]float64, b.Dx()*b.Dy())116+ r := width / 2117+ seg := func(ax, ay, bx, by float64) {118+ x0, x1 := int(math.Floor(min(ax, bx)-r-1)), int(math.Ceil(max(ax, bx)+r+1))119+ y0, y1 := int(math.Floor(min(ay, by)-r-1)), int(math.Ceil(max(ay, by)+r+1))120+ for y := max(y0, b.Min.Y); y < min(y1, b.Max.Y); y++ {121+ for x := max(x0, b.Min.X); x < min(x1, b.Max.X); x++ {122+ d := segDist(float64(x)+0.5, float64(y)+0.5, ax, ay, bx, by)123+ if a := r + 0.5 - d; a > 0 {124+ i := (y-b.Min.Y)*b.Dx() + (x - b.Min.X)125+ cover[i] = max(cover[i], min(a, 1))126+ }127+ }128+ }129+ }130+ for i := range pts {131+ if i == 0 {132+ seg(pts[0][0], pts[0][1], pts[0][0], pts[0][1])133+ continue134+ }135+ seg(pts[i-1][0], pts[i-1][1], pts[i][0], pts[i][1])136+ }137+ for i, a := range cover {138+ if a > 0 {139+ cv.blend(b.Min.X+i%b.Dx(), b.Min.Y+i/b.Dx(), c, a)140+ }141+ }142+}143+144+// segDist is the distance from p to the segment a-b.145+func segDist(px, py, ax, ay, bx, by float64) float64 {146+ dx, dy := bx-ax, by-ay147+ t := 0.0148+ if l := dx*dx + dy*dy; l > 0 {149+ t = min(max(((px-ax)*dx+(py-ay)*dy)/l, 0), 1)150+ }151+ return math.Hypot(px-(ax+t*dx), py-(ay+t*dy))152+}153+154+// image draws columns or lines. Values map exactly, while gridlines sit155+// on the cell boundaries the text ticks label.156+func (p *columnPlan) image(cv *canvas) {157+ bottom := float64(p.plot.Dy()) * cv.ch158+ y := func(v float64) float64 { return bottom - p.sc.pos(v)*cv.ch }159+ for j := 1; j <= p.sc.n; j++ {160+ cv.hline(int(math.Round(y(p.sc.tick(j)))), cv.pal.Grid)161+ }162+ if p.sc.lo < 0 && !p.line {163+ cv.hline(int(math.Round(y(0))), cv.pal.Grid)164+ }165+ if p.line {166+ width := max(cv.ch/8, 2)167+ for j, s := range p.d.Series {168+ var run [][2]float64169+ flush := func() {170+ if len(run) > 0 {171+ cv.polyline(run, width, cv.pal.Series[j%Colors])172+ }173+ run = run[:0]174+ }175+ for i := 0; i < p.shown && i < len(s.Values); i++ {176+ if math.IsNaN(s.Values[i]) {177+ flush()178+ continue179+ }180+ x := (float64(p.dotX(i)) + 0.5) * cv.cw / 2181+ run = append(run, [2]float64{x, y(s.Values[i])})182+ }183+ flush()184+ }185+ return186+ }187+ inset := min(cv.cw/6, 2)188+ for j, s := range p.d.Series {189+ for i := 0; i < p.shown && i < len(s.Values); i++ {190+ v := s.Values[i]191+ if math.IsNaN(v) {192+ continue193+ }194+ x0, x1 := p.bar(i, j)195+ px0 := float64(x0-p.plot.Min.X)*cv.cw + inset196+ px1 := float64(x1-p.plot.Min.X)*cv.cw - inset197+ cv.rect(px0, min(y(v), y(0)), px1, max(y(v), y(0)), cv.pal.Series[j%Colors])198+ }199+ }200+}201+202+// image draws horizontal bars.203+func (p *barPlan) image(cv *canvas) {204+ x := func(v float64) float64 { return p.sc.pos(v) * cv.cw }205+ for j := 1; j <= p.sc.n; j++ {206+ cv.vline(int(math.Round(x(p.sc.tick(j))))-1, cv.pal.Grid)207+ }208+ if p.sc.lo < 0 {209+ cv.vline(int(math.Round(x(0))), cv.pal.Grid)210+ }211+ inset := min(cv.ch/8, 2)212+ for j, s := range p.d.Series {213+ for i := 0; i < p.shown && i < len(s.Values); i++ {214+ v := s.Values[i]215+ if math.IsNaN(v) {216+ continue217+ }218+ row := float64(p.barRow(i, j))219+ cv.rect(min(x(v), x(0)), row*cv.ch+inset, max(x(v), x(0)), (row+1)*cv.ch-inset, cv.pal.Series[j%Colors])220+ }221+ }222+}223+224+// image draws the pie, supersampled, with a thin gap between slices.225+func (p *piePlan) image(cv *canvas) {226+ b := cv.Rect227+ cx, cy := float64(b.Dx())/2, float64(b.Dy())/2228+ r := min(cx, cy) - 1229+ const n = 4 // samples per pixel along each axis230+ gap := 1.0 // pixels between slices231+ counts := make([]float64, len(p.slices))232+ for y := range b.Dy() {233+ for x := range b.Dx() {234+ clear(counts)235+ for sy := range n {236+ for sx := range n {237+ dx := (float64(x) + (float64(sx)+0.5)/n - cx) / r238+ dy := (float64(y) + (float64(sy)+0.5)/n - cy) / r239+ i := p.sliceAt(dx, dy)240+ if i < 0 || len(p.slices) > 1 && p.nearEdge(i, dx, dy, gap/r) {241+ continue242+ }243+ counts[i]++244+ }245+ }246+ for i, k := range counts {247+ if k > 0 {248+ cv.blend(x, y, cv.pal.Series[i%Colors], k/(n*n))249+ }250+ }251+ }252+ }253+}254+255+// nearEdge reports whether a point of slice i lies within half of gap256+// (as a fraction of the radius) of the slice's straight edges.257+func (p *piePlan) nearEdge(i int, dx, dy, gap float64) bool {258+ for _, f := range []float64{p.slices[i].from, p.slices[i].to} {259+ a := f * 2 * math.Pi260+ // The edge runs from the center toward (sin a, -cos a).261+ ex, ey := math.Sin(a), -math.Cos(a)262+ along := dx*ex + dy*ey263+ if along > 0 && math.Abs(dx*ey-dy*ex) < gap/2 {264+ return true265+ }266+ }267+ return false268+}
A internal/chart/kitty.go +89 -0
@@ -0,0 +1,89 @@
1+package chart2+3+import (4+ "bytes"5+ "compress/zlib"6+ "encoding/base64"7+ "fmt"8+ "image"9+ "strings"10+11+ "github.com/charmbracelet/x/ansi"12+ "github.com/charmbracelet/x/ansi/kitty"13+)14+15+// Images reach the terminal through the kitty graphics protocol with16+// Unicode placeholders: the image is sent once with a virtual placement,17+// and the view draws placeholder characters where it should appear. The18+// placeholders are ordinary text to Bubble Tea's renderer, so redraws never19+// erase the image, and they survive tmux.20+21+// Transmit returns the sequences that send img as image id, with a virtual22+// placement of cols by rows cells for placeholders to show. Sending an id23+// again replaces the image. wrap, if not nil, wraps each chunk, e.g. for24+// tmux passthrough.25+func Transmit(id int, img *image.RGBA, cols, rows int, wrap func(string) string) string {26+ b := img.Bounds()27+ // Straight (not premultiplied) RGBA, as the protocol expects.28+ raw := make([]byte, 0, b.Dx()*b.Dy()*4)29+ for y := b.Min.Y; y < b.Max.Y; y++ {30+ for x := b.Min.X; x < b.Max.X; x++ {31+ i := img.PixOffset(x, y)32+ r, g, bl, a := img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3]33+ if a > 0 && a < 255 {34+ un := func(c uint8) uint8 { return uint8(min(int(c)*255/int(a), 255)) }35+ r, g, bl = un(r), un(g), un(bl)36+ }37+ raw = append(raw, r, g, bl, a)38+ }39+ }40+ var z bytes.Buffer41+ zw := zlib.NewWriter(&z)42+ zw.Write(raw)43+ zw.Close()44+ payload := base64.StdEncoding.EncodeToString(z.Bytes())45+46+ if wrap == nil {47+ wrap = func(s string) string { return s }48+ }49+ first := fmt.Sprintf("a=T,q=2,f=%d,o=z,s=%d,v=%d,i=%d,U=1,c=%d,r=%d", kitty.RGBA, b.Dx(), b.Dy(), id, cols, rows)50+ var out strings.Builder51+ for i := 0; i < len(payload) || i == 0; i += kitty.MaxChunkSize {52+ chunk := payload[i:min(i+kitty.MaxChunkSize, len(payload))]53+ more := "m=0"54+ if i+kitty.MaxChunkSize < len(payload) {55+ more = "m=1"56+ }57+ opts := "q=2," + more58+ if i == 0 {59+ opts = first + "," + more60+ }61+ out.WriteString(wrap(ansi.KittyGraphics([]byte(chunk), opts)))62+ }63+ return out.String()64+}65+66+// Delete returns the sequence that frees image id and its placements.67+func Delete(id int, wrap func(string) string) string {68+ s := ansi.KittyGraphics(nil, fmt.Sprintf("a=d,d=I,i=%d,q=2", id))69+ if wrap != nil {70+ return wrap(s)71+ }72+ return s73+}74+75+// Query is the probe for kitty graphics support: terminals that have it76+// answer with an OK for image QueryID; others stay silent.77+const QueryID = 3178+79+// Query returns the probe.80+func Query() string {81+ return ansi.KittyGraphics([]byte("AAAA"), fmt.Sprintf("i=%d,s=1,v=1,a=q,t=d,f=24", QueryID))82+}83+84+// Placeholder returns the text of the cell at row, col of an image's85+// placement. The foreground color must name the image: the 256-color86+// index of its id.87+func Placeholder(row, col int) string {88+ return string([]rune{kitty.Placeholder, kitty.Diacritic(row), kitty.Diacritic(col)})89+}
A internal/chart/pie.go +149 -0
@@ -0,0 +1,149 @@
1+package chart2+3+import (4+ "image"5+ "math"6+ "strconv"7+8+ "github.com/charmbracelet/x/ansi"9+10+ "012/internal/sheet"11+)12+13+// piePlan lays out a pie of the first series, clockwise from twelve14+// o'clock as in Sheets, with a legend of labels and shares to its right.15+// As text, the disc is half blocks: two square pixels per cell, each16+// colored by its slice.17+type piePlan struct {18+ d sheet.ChartData19+ msg string20+ slices []slice21+ disc image.Rectangle // in cells22+ legend image.Point // top-left of the legend23+ nameW int24+}25+26+type slice struct {27+ name string28+ value float6429+ from, to float64 // fractions of the whole, clockwise30+ colorIndex int31+}32+33+func newPiePlan(d sheet.ChartData, w, h int, o Options) *piePlan {34+ p := &piePlan{d: d}35+ if len(d.Series) == 0 {36+ p.msg = "No numbers to chart"37+ return p38+ }39+ total := 0.040+ for i, v := range d.Series[0].Values {41+ if v > 0 && !math.IsInf(v, 0) && i < len(d.Categories) {42+ p.slices = append(p.slices, slice{name: d.Categories[i], value: v, colorIndex: len(p.slices)})43+ total += v44+ }45+ }46+ if len(p.slices) == 0 {47+ p.msg = "A pie needs positive numbers"48+ return p49+ }50+ acc := 0.051+ for i := range p.slices {52+ p.slices[i].from = acc / total53+ acc += p.slices[i].value54+ p.slices[i].to = acc / total55+ }56+ for _, s := range p.slices {57+ p.nameW = max(p.nameW, ansi.StringWidth(s.name))58+ }59+ // "■ " name " " share: shares are at most "100%".60+ p.nameW = max(min(p.nameW, w/2-8), 3)61+ legendW := 2 + p.nameW + 2 + 462+ a := o.aspect()63+ rows := min(h, int(float64(w-legendW-3)/a))64+ if rows < 2 {65+ p.msg = "Too small to chart"66+ return p67+ }68+ cols := int(math.Round(float64(rows) * a))69+ x0 := max((w-(cols+3+legendW))/2, 0)70+ y0 := (h - rows) / 271+ p.disc = image.Rect(x0, y0, x0+cols, y0+rows)72+ n := min(len(p.slices), h)73+ p.legend = image.Pt(p.disc.Max.X+3, max((h-n)/2, 0))74+ return p75+}76+77+// sliceAt returns the slice under a point of the disc, given as offsets78+// from its center scaled so the rim is at distance 1, or -1 outside.79+func (p *piePlan) sliceAt(dx, dy float64) int {80+ if dx*dx+dy*dy > 1 {81+ return -182+ }83+ f := math.Atan2(dx, -dy) / (2 * math.Pi)84+ if f < 0 {85+ f++86+ }87+ for i, s := range p.slices {88+ if f < s.to {89+ return i90+ }91+ }92+ return len(p.slices) - 193+}94+95+func (p *piePlan) draw(g *Grid, o Options) {96+ if p.msg != "" {97+ g.message(p.msg)98+ return99+ }100+ g.Plot = p.disc101+ p.drawLegend(g)102+ if o.Image {103+ return104+ }105+ cols, rows := float64(p.disc.Dx()), float64(p.disc.Dy())106+ at := func(x, y float64) int { // x in columns, y in rows, from the box107+ return p.sliceAt((x-cols/2)/(cols/2), (y-rows/2)/(rows/2))108+ }109+ for y := range p.disc.Dy() {110+ for x := range p.disc.Dx() {111+ top := at(float64(x)+0.5, float64(y)+0.25)112+ bottom := at(float64(x)+0.5, float64(y)+0.75)113+ c := g.At(p.disc.Min.X+x, p.disc.Min.Y+y)114+ if c == nil {115+ continue116+ }117+ switch {118+ case top < 0 && bottom < 0:119+ continue120+ case top == bottom:121+ c.Text, c.Fg = "█", SeriesRole(top)122+ case bottom < 0:123+ c.Text, c.Fg = "▀", SeriesRole(top)124+ case top < 0:125+ c.Text, c.Fg = "▄", SeriesRole(bottom)126+ default:127+ c.Text, c.Fg, c.Bg = "▀", SeriesRole(top), SeriesRole(bottom)128+ }129+ }130+ }131+}132+133+func (p *piePlan) drawLegend(g *Grid) {134+ x, y := p.legend.X, p.legend.Y135+ for i, s := range p.slices {136+ if y >= g.H {137+ return138+ }139+ if y == g.H-1 && i < len(p.slices)-1 {140+ g.text(x, y, "… "+strconv.Itoa(len(p.slices)-i)+" more", Label)141+ return142+ }143+ g.set(x, y, "■", SeriesRole(i))144+ g.text(x+2, y, ansi.Truncate(s.name, p.nameW, "…"), Label)145+ share := strconv.Itoa(int(math.Round((s.to-s.from)*100))) + "%"146+ g.text(x+2+p.nameW+2+4-ansi.StringWidth(share), y, share, Label)147+ y++148+ }149+}
A internal/chart/testdata/bar-single.txt +8 -0
A internal/chart/testdata/bar.txt +12 -0
A internal/chart/testdata/column-narrow.txt +8 -0
A internal/chart/testdata/column-negative.txt +10 -0
A internal/chart/testdata/column-single.txt +10 -0
A internal/chart/testdata/column.txt +12 -0
A internal/chart/testdata/empty.txt +8 -0
A internal/chart/testdata/line-single.txt +10 -0
A internal/chart/testdata/line.txt +12 -0
A internal/chart/testdata/pie-negative.txt +8 -0
A internal/chart/testdata/pie.txt +10 -0