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 / stack.go
stack.go 5.9K
1package chart23import (4 "math"56 "src.rest/cat/012/internal/sheet"7)89// A span is a piece of a bar, or of an area at one point, from lo to hi10// along the value axis, in values, colored as series j.11type span struct {12 lo, hi float6413 j int14}1516// prepare readies d for a chart's options: values a log scale can't show17// left out, and with 100% stacking each value as its share of its18// category.19func prepare(d sheet.ChartData, o sheet.ChartOptions, stackable bool) sheet.ChartData {20 if o.Log {21 d = positive(d)22 }23 if stackable && o.Stack == sheet.StackPercent {24 d = shares(d)25 }26 return d27}2829// shares is d with each value divided by the sum of its category's sizes,30// formatted as percentages.31func shares(d sheet.ChartData) sheet.ChartData {32 out := sheet.ChartData{Categories: d.Categories, Format: sheet.Format{Kind: sheet.FmtPercent}}33 totals := make([]float64, len(d.Categories))34 for _, s := range d.Series {35 for i, v := range s.Values {36 if i < len(totals) && !math.IsNaN(v) && !math.IsInf(v, 0) {37 totals[i] += math.Abs(v)38 }39 }40 }41 for _, s := range d.Series {42 vals := make([]float64, len(s.Values))43 for i, v := range s.Values {44 vals[i] = math.NaN()45 if i < len(totals) && totals[i] > 0 && !math.IsInf(v, 0) {46 vals[i] = v / totals[i]47 }48 }49 out.Series = append(out.Series, sheet.ChartSeries{Name: s.Name, Values: vals})50 }51 return out52}5354// value returns series s's value i, NaN when missing or infinite.55func value(s sheet.ChartSeries, i int) float64 {56 if i >= len(s.Values) || math.IsInf(s.Values[i], 0) {57 return math.NaN()58 }59 return s.Values[i]60}6162// stackedRange is the range of the stacks' ends over the first n63// categories: positive values pile up from zero and negative ones down.64func stackedRange(d sheet.ChartData, n int) (lo, hi float64, ok bool) {65 for i := range n {66 up, down := 0.0, 0.067 for _, s := range d.Series {68 if v := value(s, i); v > 0 {69 up += v70 ok = true71 } else if v <= 0 {72 down += v73 ok = true74 }75 }76 lo, hi = min(lo, down), max(hi, up)77 }78 return lo, hi, ok79}8081// stack returns category i's spans, one per series with a value, piled82// up from zero (positive values) and down from it (negative ones).83func stack(d sheet.ChartData, i int, out []span) []span {84 return pileUp(len(d.Series), func(j int) float64 { return value(d.Series[j], i) }, out)85}8687// pileUp stacks n values, v(j) for series j, into out.88func pileUp(n int, v func(j int) float64, out []span) []span {89 out = out[:0]90 up, down := 0.0, 0.091 for j := range n {92 switch v := v(j); {93 case v > 0:94 out = append(out, span{up, up + v, j})95 up += v96 case v < 0:97 out = append(out, span{down + v, down, j})98 down += v99 }100 }101 return out102}103104// pileCell is cell c, counted in cells from the start of the axis, of a105// pile of spans given in cells along it, drawn with eighths growing from106// the start of the cell (lowerEighths or leftEighths) and half, the107// glyph for its far half. The span covering the near edge of the cell108// is the foreground, reaching as far as it goes; the one covering the far109// edge is the background. A cell only its far part of which is covered110// is a full block or the half block, as eighths can't grow the other111// way. ok is false for an empty cell.112func pileCell(spans []span, c int, eighths []string, half string) (Cell, bool) {113 const edge = 1.0 / 16114 near, far := -1, -1115 for k, s := range spans {116 if s.lo <= float64(c)+edge && s.hi > float64(c)+edge {117 near = k118 }119 if s.lo < float64(c+1)-edge && s.hi >= float64(c+1)-edge {120 far = k121 }122 }123 switch {124 case near >= 0 && near == far:125 return Cell{Text: "█", Fg: SeriesRole(spans[near].j)}, true126 case near >= 0:127 n := int(math.Round((spans[near].hi - float64(c)) * 8))128 if n >= 8 || far < 0 && n >= 1 {129 bg := None130 if far >= 0 {131 bg = SeriesRole(spans[far].j)132 }133 return Cell{Text: eighths[min(n, 8)], Fg: SeriesRole(spans[near].j), Bg: bg}, true134 }135 if n >= 1 {136 return Cell{Text: eighths[n], Fg: SeriesRole(spans[near].j), Bg: SeriesRole(spans[far].j)}, true137 }138 }139 if far < 0 {140 return Cell{}, false141 }142 switch cover := float64(c+1) - spans[far].lo; {143 case cover >= 0.75:144 return Cell{Text: "█", Fg: SeriesRole(spans[far].j)}, true145 case cover >= 0.25:146 return Cell{Text: half, Fg: SeriesRole(spans[far].j)}, true147 }148 return Cell{}, false149}150151// drawPiles draws the bars of the first shown categories cell by cell:152// piles gives category i's spans in values, one pile of them all when153// stacked, and put draws cell c along the axis of series j's bar (the154// stack's, when stacked) in category i.155func drawPiles(sc scale, shown int, stacked bool, piles func(int, []span) []span, eighths []string, half string, put func(i, j, c int, cell Cell)) {156 var vals, cells []span157 for i := 0; i < shown; i++ {158 vals = piles(i, vals)159 for k := range vals {160 pile := vals[k : k+1]161 if stacked {162 pile = vals163 }164 cells = inCells(sc, pile, cells)165 from, to := pileExtent(cells)166 for c := from; c <= to; c++ {167 if cell, ok := pileCell(cells, c, eighths, half); ok {168 put(i, vals[k].j, c, cell)169 }170 }171 if stacked {172 break173 }174 }175 }176}177178// inCells maps spans in values to spans in cells along sc, into out.179// Text ticks and the axis are lines through the middle of their cells,180// so values are placed half a cell back from where sc puts them: a181// bar starts at the axis line, in the middle of cell -1 (the axis row182// or column), and a value on a tick ends on the tick's line.183func inCells(sc scale, spans []span, out []span) []span {184 out = out[:0]185 for _, s := range spans {186 out = append(out, span{sc.at(s.lo) - 0.5, sc.at(s.hi) - 0.5, s.j})187 }188 return out189}190191// pileExtent is the first and last cell a pile in cells touches.192func pileExtent(spans []span) (from, to int) {193 lo, hi := math.Inf(1), math.Inf(-1)194 for _, s := range spans {195 lo, hi = min(lo, s.lo), max(hi, s.hi)196 }197 if lo > hi {198 return 0, -1199 }200 return int(math.Floor(lo)), int(math.Ceil(hi)) - 1201}