cat / 012
clone

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

Draw area, scatter, stacked charts and axis options

Dami committed c3be4a3
Area charts fill under the lines (translucent in images, front to back
by height as text), scatter charts plot the first series as X with an
optional least squares trend line, and column, bar and area charts
stack, also as 100%. The value axis takes a fixed minimum or maximum
and a log scale; gridlines show as text too and can be turned off; the
legend goes at the bottom, on the right or nowhere, and Draw gives the
layout the room it leaves. Stacked bars draw two series in a cell with
a background color. Images are pinned by hashes of their pixels.
commit
c3be4a34aa4dd2ade6b31e32af239e5288d5c5d9
parent
886c08c6099566618a7cb91eb7dcb1f24dbd7771
author
Dami <dami.etoile@gmail.com>
42 files changed, +1675 -285
Ainternal/chart/area.go161++++++++++++++++++++
Ainternal/chart/axis.go150+++++++++++++++++++
Minternal/chart/bar.go127++++++++--------
Minternal/chart/chart.go71+++------
Minternal/chart/chart_test.go78++++++++--
Minternal/chart/column.go222++++++++++++++++++----------
Minternal/chart/image.go64++++----
Ainternal/chart/legend.go114++++++++++++++
Minternal/chart/pie.go19++-
Ainternal/chart/scatter.go239++++++++++++++++++++++++++++++
Ainternal/chart/stack.go197+++++++++++++++++++++++++
Minternal/chart/stress_test.go2+
Ainternal/chart/testdata/area-negative.txt10+
Ainternal/chart/testdata/area-stacked.txt12++
Ainternal/chart/testdata/area.txt12++
Ainternal/chart/testdata/axis-log.txt12++
Ainternal/chart/testdata/axis-min-max.txt12++
Ainternal/chart/testdata/axis-min.txt12++
Ainternal/chart/testdata/bar-log.txt10+
Ainternal/chart/testdata/bar-percent.txt12++
Minternal/chart/testdata/bar-single.txt10+-
Ainternal/chart/testdata/bar-stacked.txt12++
Minternal/chart/testdata/bar.txt14+-
Minternal/chart/testdata/column-narrow.txt4+-
Minternal/chart/testdata/column-negative.txt6+-
Ainternal/chart/testdata/column-percent.txt12++
Minternal/chart/testdata/column-single.txt6+-
Ainternal/chart/testdata/column-stacked-negative.txt12++
Ainternal/chart/testdata/column-stacked.txt12++
Minternal/chart/testdata/column.txt6+-
Ainternal/chart/testdata/images.txt17++
Ainternal/chart/testdata/legend-none.txt12++
Ainternal/chart/testdata/legend-right.txt12++
Minternal/chart/testdata/line-single.txt8+-
Minternal/chart/testdata/line.txt6+-
Ainternal/chart/testdata/no-gridlines.txt10+
Ainternal/chart/testdata/pie-no-legend.txt10+
Ainternal/chart/testdata/scatter-single.txt10+
Ainternal/chart/testdata/scatter-trend.txt12++
Ainternal/chart/testdata/scatter.txt12++
Minternal/chart/types.go66++++++--
Ainternal/chart/types_test.go155+++++++++++++++++++
A internal/chart/area.go +161 -0
@@ -0,0 +1,161 @@
1+package chart2+3+import (4+ "math"5+ "slices"6+)7+8+// Area charts are line charts filled down to the axis. Each column of9+// the plot (each pixel column of the image) samples the series between10+// the two categories around it, so drawing costs the plot's width times11+// the series, whatever the data.12+13+// pointX is category i's point in cells from the plot's left edge: the14+// center of its braille dot, so fills meet the lines.15+func (p *columnPlan) pointX(i int) float64 { return (float64(p.dotX(i)) + 0.5) / 2 }16+17+// areaCursor finds the categories around points along the plot, left to18+// right.19+type areaCursor struct {20+ p *columnPlan21+ k int22+}23+24+// at returns category i and how far x (cells into the plot) lies from its25+// point toward the next one's, or false outside the first and last points.26+func (c *areaCursor) at(x float64) (int, float64, bool) {27+ p := c.p28+ if p.shown == 1 {29+ return 0, 0, math.Abs(x-p.pointX(0)) <= 0.530+ }31+ if x < p.pointX(0) || x > p.pointX(p.shown-1) {32+ return 0, 0, false33+ }34+ for c.k < p.shown-2 && p.pointX(c.k+1) < x {35+ c.k++36+ }37+ x0, x1 := p.pointX(c.k), p.pointX(c.k+1)38+ if x1 <= x0 {39+ return c.k, 0, true40+ }41+ return c.k, (x - x0) / (x1 - x0), true42+}43+44+// valueAt is series j between categories i and i+1, t of the way, or NaN45+// where either is missing.46+func (p *columnPlan) valueAt(j, i int, t float64) float64 {47+ s := p.d.Series[j]48+ a := value(s, i)49+ if t == 0 || i+1 >= p.shown {50+ return a51+ }52+ b := value(s, i+1)53+ return a + (b-a)*t54+}55+56+// areaSpans returns the area's spans at one point: stacked, the series'57+// piles; otherwise each series from the axis to its value, the smaller58+// in front, so each shows the part no smaller one covers.59+func (p *columnPlan) areaSpans(i int, t float64, vals []float64, out []span) []span {60+ vals = vals[:0]61+ for j := range p.d.Series {62+ vals = append(vals, p.valueAt(j, i, t))63+ }64+ if p.stacked {65+ return pileUp(len(vals), func(j int) float64 { return vals[j] }, out)66+ }67+ return overlap(vals, p.sc.base(), out)68+}69+70+// overlap turns values drawn from base, each in front of the larger ones,71+// into the visible spans: from base up through the positive values in72+// order, and down through the negative ones.73+func overlap(vals []float64, base float64, out []span) []span {74+ out = out[:0]75+ order := make([]int, 0, len(vals))76+ for j, v := range vals {77+ if !math.IsNaN(v) {78+ order = append(order, j)79+ }80+ }81+ slices.SortStableFunc(order, func(a, b int) int { return cmpDistance(vals[a]-base, vals[b]-base) })82+ up, down := base, base83+ for _, j := range order {84+ switch v := vals[j]; {85+ case v > up:86+ out = append(out, span{up, v, j})87+ up = v88+ case v < down:89+ out = append(out, span{v, down, j})90+ down = v91+ }92+ }93+ return out94+}95+96+// cmpDistance orders offsets by their distance from zero.97+func cmpDistance(a, b float64) int {98+ switch a, b = math.Abs(a), math.Abs(b); {99+ case a < b:100+ return -1101+ case a > b:102+ return 1103+ }104+ return 0105+}106+107+// drawArea fills each column of the plot under the series with eighths.108+func (p *columnPlan) drawArea(g *Grid) {109+ cur := areaCursor{p: p}110+ vals := make([]float64, 0, len(p.d.Series))111+ var spans, cells []span112+ for x := range p.plot.Dx() {113+ i, t, ok := cur.at(float64(x) + 0.5)114+ if !ok {115+ continue116+ }117+ spans = p.areaSpans(i, t, vals, spans)118+ cells = inCells(p.sc, spans, cells)119+ from, to := pileExtent(cells)120+ for r := from; r <= to; r++ {121+ if c, ok := pileCell(cells, r, lowerEighths, "▀"); ok {122+ if at := g.At(p.plot.Min.X+x, p.axisRow-1-r); at != nil {123+ *at = c124+ }125+ }126+ }127+ }128+}129+130+// areaImage fills the area a pixel column at a time: stacked piles131+// opaque, otherwise each series translucent over the ones before, then132+// strokes the lines on top.133+func (p *columnPlan) areaImage(cv *canvas, y func(float64) float64) {134+ cur := areaCursor{p: p}135+ vals := make([]float64, 0, len(p.d.Series))136+ var spans []span137+ for px := range cv.Rect.Dx() {138+ i, t, ok := cur.at((float64(px) + 0.5) / cv.cw)139+ if !ok {140+ continue141+ }142+ if p.stacked {143+ spans = p.areaSpans(i, t, vals, spans)144+ for _, s := range spans {145+ cv.rect(float64(px), y(s.hi), float64(px+1), y(s.lo), cv.pal.Series[s.j%Colors])146+ }147+ continue148+ }149+ base := y(p.sc.base())150+ for j := range p.d.Series {151+ if v := p.valueAt(j, i, t); !math.IsNaN(v) {152+ c := cv.pal.Series[j%Colors]153+ c.A = 150154+ cv.rect(float64(px), min(y(v), base), float64(px+1), max(y(v), base), c)155+ }156+ }157+ }158+ if !p.stacked {159+ p.lineImage(cv, y)160+ }161+}
A internal/chart/axis.go +150 -0
@@ -0,0 +1,150 @@
1+package chart2+3+import (4+ "math"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+)8+9+// newAxis fits dlo..dhi onto a value axis of length cells as newScale10+// does, with the chart's axis options: a fixed minimum or maximum, and a11+// log scale.12+func newAxis(dlo, dhi float64, length, minK, maxN int, f sheet.Format, o sheet.ChartOptions) scale {13+ if o.HasMin {14+ dlo = o.Min15+ }16+ if o.HasMax {17+ dhi = o.Max18+ }19+ if o.HasMin && !o.HasMax && dhi <= dlo {20+ dhi = dlo + max(math.Abs(dlo), 1)21+ }22+ if o.HasMax && !o.HasMin && dlo >= dhi {23+ dlo = dhi - max(math.Abs(dhi), 1)24+ }25+ if dlo > dhi {26+ dlo, dhi = dhi, dlo27+ }28+ switch {29+ case o.Log:30+ return newLogScale(dlo, dhi, length, minK, f)31+ case (o.HasMin || o.HasMax) && dlo < dhi:32+ return newFixedScale(dlo, dhi, o.HasMin, o.HasMax, length, minK, maxN, f)33+ }34+ return newScale(dlo, dhi, length, minK, maxN, f)35+}36+37+// newFixedScale is newScale keeping the ends that are fixed where they38+// are: the other end rounds out to a whole step, and with both fixed the39+// step divides the range, a round one where it can.40+func newFixedScale(dlo, dhi float64, fixLo, fixHi bool, length, minK, maxN int, f sheet.Format) scale {41+ best := scale{lo: dlo, hi: dhi, step: dhi - dlo, n: 1, k: max(length, 1)}42+ fallback := false43+ for target := min(maxN, max(length/minK, 1)); target >= 1; target-- {44+ s, ok := fixedTicks(dlo, dhi, fixLo, fixHi, target)45+ if !ok {46+ continue47+ }48+ s.k = length / s.n49+ if s.k < minK && (target > 1 || s.k < 1) {50+ continue51+ }52+ if fixLo && fixHi && niceStep(s.step) != s.step {53+ if !fallback { // the finest division, if no round step fits54+ best, fallback = s, true55+ }56+ continue57+ }58+ best = s59+ break60+ }61+ best.label = tickFormat(best, f)62+ return best63+}64+65+// fixedTicks divides dlo..dhi into about target steps, moving only the66+// ends that aren't fixed.67+func fixedTicks(dlo, dhi float64, fixLo, fixHi bool, target int) (scale, bool) {68+ if fixLo && fixHi {69+ return scale{lo: dlo, hi: dhi, step: (dhi - dlo) / float64(target), n: target}, true70+ }71+ step := niceStep((dhi - dlo) / float64(target))72+ n := int(math.Ceil((dhi-dlo)/step - 1e-9))73+ if n < 1 {74+ return scale{}, false75+ }76+ if fixLo {77+ return scale{lo: dlo, hi: dlo + float64(n)*step, step: step, n: n}, true78+ }79+ return scale{lo: dhi - float64(n)*step, hi: dhi, step: step, n: n}, true80+}81+82+// newLogScale is a log axis over positive dlo..dhi: its ends round out to83+// powers of ten, and ticks are one decade apart, or more when decades84+// would be closer than minK cells.85+func newLogScale(dlo, dhi float64, length, minK int, f sheet.Format) scale {86+ if dhi <= 0 {87+ dlo, dhi = 1, 1088+ }89+ if dlo <= 0 {90+ dlo = dhi / 1091+ }92+ lo, hi := math.Floor(math.Log10(dlo)+1e-9), math.Ceil(math.Log10(dhi)-1e-9)93+ if hi <= lo {94+ hi = lo + 195+ }96+ decades := int(hi - lo)97+ s := scale{lo: lo, log: true}98+ for _, step := range []int{1, 2, 3, 5, 10, 20, 50, 100} {99+ n := (decades + step - 1) / step100+ s.step, s.n, s.k = float64(step), n, length/n101+ if s.k >= minK || n == 1 {102+ break103+ }104+ }105+ s.k = max(s.k, 1)106+ s.hi = lo + float64(s.n)*s.step107+ s.label = func(v float64) string {108+ return tickFormat(scale{lo: v, hi: v, step: v}, f)(v)109+ }110+ return s111+}112+113+// base is where bars start: zero, or the end of the axis nearest it.114+func (s scale) base() float64 {115+ if s.log {116+ return s.tick(0)117+ }118+ return min(max(0, s.lo), s.hi)119+}120+121+// at is pos kept on the axis, for values beyond a fixed end.122+func (s scale) at(v float64) float64 {123+ p := s.pos(v)124+ if math.IsNaN(p) {125+ return 0126+ }127+ return min(max(p, 0), float64(s.cells()))128+}129+130+// crossesZero reports whether zero lies inside the axis, where images131+// draw a line.132+func (s scale) crossesZero() bool { return !s.log && s.lo < 0 && s.hi > 0 }133+134+// positive is d with every value that isn't positive left out, as a log135+// scale can't show them.136+func positive(d sheet.ChartData) sheet.ChartData {137+ out := d138+ out.Series = make([]sheet.ChartSeries, len(d.Series))139+ for j, s := range d.Series {140+ vals := make([]float64, len(s.Values))141+ for i, v := range s.Values {142+ if v <= 0 {143+ v = math.NaN()144+ }145+ vals[i] = v146+ }147+ out.Series[j] = sheet.ChartSeries{Name: s.Name, Values: vals}148+ }149+ return out150+}
M internal/chart/bar.go +66 -61
@@ -10,7 +10,8 @@ import (
1010 )1111 1212 // barPlan lays out horizontal bar charts: category labels on the left,13-// bars growing right, and the value axis along the bottom.13+// bars growing right, and the value axis along the bottom. Draw adds the14+// legend.1415 //1516 // Jan │██████████▌1617 // Feb │███████▎
@@ -18,32 +19,36 @@ import (
1819 // 0 1,000 2,0001920 type barPlan struct {2021 d sheet.ChartData22+ stacked bool23+ grid bool2124 msg string2225 sc scale2326 catW int // width of the category labels2427 axisX int2528 axisRow int2629 tickRow int27- legend int2830 plot image.Rectangle2931 shown int32+ slots int // bars per category: the series, or one stack3033 rows int // rows per category, including a gap row3134 }3235 33-func newBarPlan(d sheet.ChartData, w, h int) *barPlan {34- p := &barPlan{d: d, legend: -1}36+func newBarPlan(d sheet.ChartData, w, h int, o sheet.ChartOptions) *barPlan {37+ d = prepare(d, o, true)38+ p := &barPlan{d: d, stacked: o.Stack != sheet.StackNone, grid: !o.NoGrid, slots: len(d.Series)}3539 lo, hi, ok := finite(d.Series)40+ if p.stacked {41+ lo, hi, ok = stackedRange(d, len(d.Categories))42+ p.slots = 143+ }3644 if !ok || len(d.Categories) == 0 {3745 p.msg = "No numbers to chart"3846 return p3947 }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 - 248+ if !o.Log {49+ lo, hi = min(lo, 0), max(hi, 0)4650 }51+ p.tickRow = h - 14752 p.axisRow = p.tickRow - 14853 if p.axisRow < 1 {4954 p.msg = "Too small to chart"
@@ -64,56 +69,69 @@ func newBarPlan(d sheet.ChartData, w, h int) *barPlan {
6469 p.msg = "Too small to chart"6570 return p6671 }67- p.sc = newScale(lo, hi, plotW, labelW+2, 6, d.Format)72+ p.sc = newAxis(lo, hi, plotW, labelW+2, 6, d.Format, o)6873 for j := 0; j <= p.sc.n; j++ {6974 labelW = max(labelW, ansi.StringWidth(p.sc.label(p.sc.tick(j))))7075 }7176 }7277 p.plot = image.Rect(x0, 0, x0+p.sc.cells(), p.axisRow)73- p.rows = ns + 178+ p.rows = p.slots + 17479 if len(d.Categories)*p.rows-1 > p.axisRow {75- p.rows = ns80+ p.rows = p.slots7681 }77- p.shown = max(min(len(d.Categories), (p.axisRow+p.rows-ns)/p.rows), 1)82+ p.shown = max(min(len(d.Categories), (p.axisRow+p.rows-p.slots)/p.rows), 1)7883 return p7984 }8085 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 }86+// barRow returns the row of series j's bar in category i; stacked, all87+// series share one.88+func (p *barPlan) barRow(i, j int) int {89+ if p.stacked {90+ j = 091+ }92+ return i*p.rows + j93+}94+95+// piles returns category i's bars as spans, as columnPlan.piles does.96+func (p *barPlan) piles(i int, buf []span) []span {97+ if p.stacked {98+ return stack(p.d, i, buf)99+ }100+ buf = buf[:0]101+ base := p.sc.base()102+ for j, s := range p.d.Series {103+ if v := value(s, i); !math.IsNaN(v) {104+ buf = append(buf, span{min(v, base), max(v, base), j})105+ }106+ }107+ return buf108+}83109 84110 func (p *barPlan) draw(g *Grid, o Options) {85111 p.drawAxis(g)86- ns := len(p.d.Series)87112 for i := 0; i < p.shown; i++ {88113 label := ansi.Truncate(p.d.Categories[i], p.catW, "…")89- g.text(p.catW-ansi.StringWidth(label), p.barRow(i, (ns-1)/2), label, Label)90- }91- if p.legend >= 0 {92- g.legend(p.legend, seriesNames(p.d))114+ g.text(p.catW-ansi.StringWidth(label), p.barRow(i, (p.slots-1)/2), label, Label)93115 }94116 if o.Image {95117 return96118 }97- zero := p.sc.pos(0)98- for j, s := range p.d.Series {99- for i := 0; i < p.shown && i < len(s.Values); i++ {100- v := s.Values[i]101- if math.IsNaN(v) {102- continue103- }104- lo, hi := min(zero, p.sc.pos(v)), max(zero, p.sc.pos(v))105- for c := int(math.Floor(lo)); float64(c) < hi; c++ {106- // Left of the axis, bars fill from the right.107- if glyph := barGlyph(lo, hi, c, v >= 0, leftEighths, "▐"); glyph != "" {108- g.set(p.plot.Min.X+c, p.barRow(i, j), glyph, SeriesRole(j))109- }119+ if p.grid {120+ for j := 1; j <= p.sc.n; j++ {121+ for y := 0; y < p.axisRow; y++ {122+ g.set(p.plot.Min.X+j*p.sc.k-1, y, "┊", Gridline)110123 }111124 }112125 }126+ drawPiles(p.sc, p.shown, p.stacked, p.piles, leftEighths, "▐", func(i, j, c int, cell Cell) {127+ if at := g.At(p.plot.Min.X+c, p.barRow(i, j)); at != nil {128+ *at = cell129+ }130+ })113131 }114132 115133 // drawAxis draws the category axis on the left and the value axis along116-// the bottom, labeling as many ticks as fit without touching.134+// the bottom.117135 func (p *barPlan) drawAxis(g *Grid) {118136 for y := 0; y < p.axisRow; y++ {119137 g.set(p.axisX, y, "│", Axis)
@@ -122,39 +140,26 @@ func (p *barPlan) drawAxis(g *Grid) {
122140 for x := p.axisX + 1; x < p.plot.Max.X; x++ {123141 g.set(x, p.axisRow, "─", Axis)124142 }143+ drawXTicks(g, p.sc, p.axisX, p.plot.Min.X, p.axisRow, p.tickRow)144+}145+146+// drawXTicks marks the ticks of a horizontal axis on row axisRow, the147+// first at column axisX and the rest ending intervals from x0, and labels148+// as many on row tickRow as fit without touching.149+func drawXTicks(g *Grid, sc scale, axisX, x0, axisRow, tickRow int) {125150 next := 0 // first free column126- for j := 0; j <= p.sc.n; j++ {127- x := p.axisX151+ for j := 0; j <= sc.n; j++ {152+ x := axisX128153 if j > 0 {129- x = p.plot.Min.X + j*p.sc.k - 1130- g.set(x, p.axisRow, "┬", Axis)154+ x = x0 + j*sc.k - 1155+ g.set(x, axisRow, "┬", Axis)131156 }132- label := p.sc.label(p.sc.tick(j))157+ label := sc.label(sc.tick(j))133158 lw := ansi.StringWidth(label)134159 lx := min(max(x-(lw-1)/2, 0), g.W-lw)135160 if lx >= next {136- g.text(lx, p.tickRow, label, Label)161+ g.text(lx, tickRow, label, Label)137162 next = lx + lw + 1138163 }139164 }140165 }141-142-// barGlyph is the glyph of cell c of a bar from lo to hi, distances143-// along its axis in cells, or "" for none. A positive bar grows away144-// from the axis in eighths; a negative one has no eighths growing the145-// other way, so its partly covered cells are a full block or the half146-// block half.147-func barGlyph(lo, hi float64, c int, positive bool, eighths []string, half string) string {148- cover := min(hi, float64(c+1)) - max(lo, float64(c))149- switch {150- case positive:151- if g := eighths[int(math.Round(cover*8))]; g != " " {152- return g153- }154- case cover >= 0.75:155- return "█"156- case cover >= 0.25:157- return half158- }159- return ""160-}
M internal/chart/chart.go +26 -45
@@ -21,11 +21,12 @@ import (
2121 type Role uint82222 2323 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) i24+ 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) i2930 )3031 3132 // Colors is how many series colors there are; later series reuse them.
@@ -57,6 +58,9 @@ type Options struct {
5758 // CellW and CellH are the size of a terminal cell in pixels; they set5859 // a pie's proportions. Zero means the usual 1:2.5960 CellW, CellH int61+ // Chart is the chart's own settings: stacking, the value axis, the62+ // gridlines and the legend.63+ Chart sheet.ChartOptions6064 }6165 6266 func (o Options) aspect() float64 {
@@ -128,13 +132,14 @@ func Draw(t sheet.ChartType, d sheet.ChartData, w, h int, o Options) *Grid {
128132 if w < 8 || h < 3 {129133 return g130134 }131- p := planFor(t, d, w, h, o)135+ p, lg := planFor(t, d, w, h, o)132136 if msg := p.note(); msg != "" {133137 g.message(msg)134138 return g135139 }136140 g.Plot = p.plotArea()137141 p.draw(g, o)142+ lg.draw(g)138143 return g139144 }140145
@@ -159,10 +164,12 @@ func finite(series []sheet.ChartSeries) (lo, hi float64, ok bool) {
159164 }160165 161166 // scale is a value axis: lo..hi divided into n intervals of step, each k162-// cells long, so every tick lands on a cell boundary.167+// 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.163169 type scale struct {164170 lo, hi, step float64165171 n, k int172+ log bool166173 label func(float64) string167174 }168175
@@ -171,11 +178,22 @@ func (s scale) cells() int { return s.n * s.k }
171178 172179 // pos maps a value to a distance along the axis in cells.173180 func (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+ }174187 return (v - s.lo) / (s.hi - s.lo) * float64(s.cells())175188 }176189 177190 // tick returns the value of tick j.178-func (s scale) tick(j int) float64 { return s.lo + float64(j)*s.step }191+func (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+}179197 180198 // newScale fits dlo..dhi onto length cells, with ticks at least minK181199 // cells apart and at most maxN intervals.
@@ -274,43 +292,6 @@ func groupDigits(s string) string {
274292 return b.String()275293 }276294 277-// legend draws "■ Rent ■ Food" centered on row y, shortening names to278-// fit.279-func (g *Grid) legend(y int, names []string) {280- const gap = " "281- for limit := 24; limit >= 1; limit-- {282- items := make([]string, len(names))283- width := 0284- for i, n := range names {285- items[i] = "■ " + ansi.Truncate(n, limit, "…")286- width += ansi.StringWidth(items[i])287- }288- width += len(gap) * (len(items) - 1)289- if width > g.W && limit > 1 {290- continue291- }292- x := max((g.W-width)/2, 0)293- for i, it := range items {294- if x+ansi.StringWidth(it) > g.W {295- g.text(max(x, 0), y, "…", Label)296- return297- }298- g.set(x, y, "■", SeriesRole(i))299- g.text(x+2, y, it[len("■ "):], Label)300- x += ansi.StringWidth(it) + len(gap)301- }302- return303- }304-}305-306-func seriesNames(d sheet.ChartData) []string {307- names := make([]string, len(d.Series))308- for i, s := range d.Series {309- names[i] = s.Name310- }311- return names312-}313-314295 // eighths are the block elements filling 0/8 to 8/8 of a cell from the315296 // bottom, and from the left.316297 var (
M internal/chart/chart_test.go +62 -16
@@ -43,6 +43,32 @@ var mixed = sheet.ChartData{
4343 Format: sheet.Format{Kind: sheet.FmtPercent},4444 }4545 46+// signs has positive and negative values in the same category.47+var signs = sheet.ChartData{48+ Categories: []string{"Q1", "Q2", "Q3"},49+ Series: []sheet.ChartSeries{50+ {Name: "Sales", Values: []float64{30, 40, 20}},51+ {Name: "Costs", Values: []float64{-20, -10, -30}},52+ {Name: "Other", Values: []float64{10, -5, 15}},53+ },54+}55+56+// xy is a scatter: X in the first series, two series of Y against it.57+var xy = sheet.ChartData{58+ Categories: []string{"1", "2", "3", "4", "5", "6"},59+ Series: []sheet.ChartSeries{60+ {Name: "Hours", Values: []float64{1, 2, 3, 5, 6, 8}},61+ {Name: "Score", Values: []float64{52, 55, 61, 70, 71, 80}},62+ {Name: "Retake", Values: []float64{40, nan, 50, 58, 66, 69}},63+ },64+}65+66+// growth spans several powers of ten, for log scales.67+var growth = sheet.ChartData{68+ Categories: []string{"Y1", "Y2", "Y3", "Y4", "Y5"},69+ Series: []sheet.ChartSeries{{Name: "Users", Values: []float64{3, 40, 700, 9000, 0}}},70+}71+4672 // TestDrawGoldens renders every chart type as text; the goldens in4773 // testdata are the review surface for how charts look.4874 func TestDrawGoldens(t *testing.T) {
@@ -51,22 +77,42 @@ func TestDrawGoldens(t *testing.T) {
5177 t sheet.ChartType5278 d sheet.ChartData5379 w, h int80+ o sheet.ChartOptions5481 }{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},82+ {"column", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{}},83+ {"column-single", sheet.ChartColumn, single, 40, 10, sheet.ChartOptions{}},84+ {"column-negative", sheet.ChartColumn, mixed, 36, 10, sheet.ChartOptions{}},85+ {"column-narrow", sheet.ChartColumn, budget, 22, 8, sheet.ChartOptions{}},86+ {"bar", sheet.ChartBar, budget, 44, 12, sheet.ChartOptions{}},87+ {"bar-single", sheet.ChartBar, single, 40, 8, sheet.ChartOptions{}},88+ {"line", sheet.ChartLine, budget, 44, 12, sheet.ChartOptions{}},89+ {"line-single", sheet.ChartLine, single, 40, 10, sheet.ChartOptions{}},90+ {"pie", sheet.ChartPie, single, 40, 10, sheet.ChartOptions{}},91+ {"empty", sheet.ChartColumn, sheet.ChartData{Categories: []string{"a"}, Series: []sheet.ChartSeries{{Values: []float64{nan}}}}, 30, 8, sheet.ChartOptions{}},92+ {"pie-negative", sheet.ChartPie, sheet.ChartData{Categories: []string{"a"}, Series: []sheet.ChartSeries{{Values: []float64{-1}}}}, 30, 8, sheet.ChartOptions{}},93+ {"pie-no-legend", sheet.ChartPie, single, 40, 10, sheet.ChartOptions{Legend: sheet.LegendNone}},94+ {"area", sheet.ChartArea, budget, 44, 12, sheet.ChartOptions{}},95+ {"area-stacked", sheet.ChartArea, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackNormal}},96+ {"area-negative", sheet.ChartArea, mixed, 36, 10, sheet.ChartOptions{}},97+ {"column-stacked", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackNormal}},98+ {"column-percent", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackPercent}},99+ {"column-stacked-negative", sheet.ChartColumn, signs, 40, 12, sheet.ChartOptions{Stack: sheet.StackNormal}},100+ {"bar-stacked", sheet.ChartBar, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackNormal}},101+ {"bar-percent", sheet.ChartBar, budget, 44, 12, sheet.ChartOptions{Stack: sheet.StackPercent}},102+ {"scatter", sheet.ChartScatter, xy, 44, 12, sheet.ChartOptions{}},103+ {"scatter-trend", sheet.ChartScatter, xy, 44, 12, sheet.ChartOptions{Trend: true}},104+ {"scatter-single", sheet.ChartScatter, single, 40, 10, sheet.ChartOptions{}},105+ {"legend-right", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{Legend: sheet.LegendRight}},106+ {"legend-none", sheet.ChartLine, budget, 44, 12, sheet.ChartOptions{Legend: sheet.LegendNone}},107+ {"no-gridlines", sheet.ChartColumn, single, 40, 10, sheet.ChartOptions{NoGrid: true}},108+ {"axis-min-max", sheet.ChartColumn, budget, 44, 12, sheet.ChartOptions{Min: 500, HasMin: true, Max: 1600, HasMax: true}},109+ {"axis-min", sheet.ChartLine, budget, 44, 12, sheet.ChartOptions{Min: 400, HasMin: true}},110+ {"axis-log", sheet.ChartColumn, growth, 40, 12, sheet.ChartOptions{Log: true}},111+ {"bar-log", sheet.ChartBar, growth, 40, 10, sheet.ChartOptions{Log: true}},66112 }67113 for _, tc := range cases {68114 t.Run(tc.name, func(t *testing.T) {69- got := Draw(tc.t, tc.d, tc.w, tc.h, Options{}).String() + "\n"115+ got := Draw(tc.t, tc.d, tc.w, tc.h, Options{Chart: tc.o}).String() + "\n"70116 path := filepath.Join("testdata", tc.name+".txt")71117 if *update {72118 os.MkdirAll("testdata", 0o755)
@@ -122,7 +168,7 @@ func TestScale(t *testing.T) {
122168 123169 func TestLayout(t *testing.T) {124170 // Columns: every bar lies inside the plot and bars don't overlap.125- p := newColumnPlan(budget, 44, 12, false)171+ p := newColumnPlan(budget, 44, 12, columnBars, sheet.ChartOptions{})126172 prev := 0127173 for i := range 4 {128174 for j := range 2 {
@@ -143,7 +189,7 @@ func TestLayout(t *testing.T) {
143189 many.Categories = append(many.Categories, "c")144190 many.Series[0].Values = append(many.Series[0].Values, float64(i))145191 }146- if p := newColumnPlan(many, 30, 10, false); p.shown != p.plot.Dx() {192+ if p := newColumnPlan(many, 30, 10, columnBars, sheet.ChartOptions{}); p.shown != p.plot.Dx() {147193 t.Errorf("shown %d categories in %d columns", p.shown, p.plot.Dx())148194 }149195 // The plot rectangle is what Image fills.
@@ -160,7 +206,7 @@ func TestLayout(t *testing.T) {
160206 // TestTypes checks every chart type the editor offers has a layout.161207 func TestTypes(t *testing.T) {162208 for _, ty := range sheet.ChartTypes {163- if types[ty] == nil {209+ if types[ty].layout == nil {164210 t.Errorf("%v has no layout", ty)165211 }166212 }
@@ -190,7 +236,7 @@ var testPalette = Palette{
190236 191237 func TestImagePixels(t *testing.T) {192238 o := Options{CellW: 8, CellH: 16}193- p := newColumnPlan(single, 40, 10, false)239+ p := newColumnPlan(single, 40, 10, columnBars, sheet.ChartOptions{})194240 img := Image(sheet.ChartColumn, single, 40, 10, o, testPalette)195241 x0, x1 := p.bar(0, 0)196242 mid := ((x0+x1)/2 - p.plot.Min.X) * 8
M internal/chart/column.go +143 -79
@@ -9,54 +9,65 @@ import (
99 "github.com/FelineStateMachine/012/internal/sheet"1010 )1111 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.12+// columnPlan lays out column, line and area charts: a value axis on the13+// left with labeled ticks, the plot, and a category axis and labels14+// below it. Draw adds the legend.1515 //1616 // 1,500 ┤ █▆1717 // 1,000 ┤ ██ ▆1818 // 500 ┤ ██ █1919 // 0 ┼─────────2020 // Jan Feb21-// ■ Rent ■ Food2221 type columnPlan struct {2322 d sheet.ChartData24- line bool23+ kind columnKind24+ stacked bool25+ grid bool2526 msg string2627 sc scale2728 labelW int // width of the tick labels2829 axisX int // column of the value axis2930 axisRow int // row of the category axis3031 catRow int31- legend int // row of the legend, -1 for none3232 plot image.Rectangle3333 shown int // categories drawn3434 // Columns: each category is a group gw cells wide, starting off cells35- // into the plot; each series' bar in it is bw wide.35+ // into the plot; each series' bar in it (the stack, when stacked) is36+ // bw wide.3637 gw, off, bw, inset int3738 }3839 39-func newColumnPlan(d sheet.ChartData, w, h int, line bool) *columnPlan {40- p := &columnPlan{d: d, line: line, legend: -1}40+// columnKind is what a columnPlan draws at each category.41+type columnKind uint842+43+const (44+ columnBars columnKind = iota45+ columnLine46+ columnArea47+)48+49+func newColumnPlan(d sheet.ChartData, w, h int, kind columnKind, o sheet.ChartOptions) *columnPlan {50+ stacked := o.Stack != sheet.StackNone && kind != columnLine51+ d = prepare(d, o, kind != columnLine)52+ p := &columnPlan{d: d, kind: kind, stacked: stacked, grid: !o.NoGrid}4153 lo, hi, ok := finite(d.Series)54+ if stacked {55+ lo, hi, ok = stackedRange(d, len(d.Categories))56+ }4257 if !ok || len(d.Categories) == 0 {4358 p.msg = "No numbers to chart"4459 return p4560 }46- if !line {61+ if kind != columnLine && !o.Log {4762 lo, hi = min(lo, 0), max(hi, 0)4863 }4964 p.catRow = h - 150- if len(d.Series) > 1 && h >= 7 {51- p.legend = h - 152- p.catRow = h - 253- }5465 p.axisRow = p.catRow - 15566 if p.axisRow < 2 {5667 p.msg = "Too small to chart"5768 return p5869 }59- p.sc = newScale(lo, hi, p.axisRow, 2, 6, d.Format)70+ p.sc = newAxis(lo, hi, p.axisRow, 2, 6, d.Format, o)6071 for j := 0; j <= p.sc.n; j++ {6172 p.labelW = max(p.labelW, ansi.StringWidth(p.sc.label(p.sc.tick(j))))6273 }
@@ -67,29 +78,53 @@ func newColumnPlan(d sheet.ChartData, w, h int, line bool) *columnPlan {
6778 p.msg = "Too small to chart"6879 return p6980 }70- ns := len(d.Series)71- if line {81+ if kind != columnBars {7282 p.shown = min(len(d.Categories), plotW*2)7383 return p7484 }75- p.shown = max(min(len(d.Categories), plotW/ns), 1)85+ slots := len(d.Series)86+ if stacked {87+ slots = 188+ }89+ p.shown = max(min(len(d.Categories), plotW/slots), 1)7690 p.gw = plotW / p.shown7791 gap := 078- if p.gw > ns {92+ if p.gw > slots {7993 gap = max(1, p.gw/4)8094 }81- p.bw = max((p.gw-gap)/ns, 1)82- p.inset = (p.gw - p.bw*ns) / 295+ p.bw = max((p.gw-gap)/slots, 1)96+ p.inset = (p.gw - p.bw*slots) / 28397 p.off = (plotW - p.shown*p.gw) / 28498 return p8599 }86100 87-// bar returns the columns of series j's bar in category i.101+// bar returns the columns of series j's bar in category i; stacked, all102+// series share the first.88103 func (p *columnPlan) bar(i, j int) (x0, x1 int) {104+ if p.stacked {105+ j = 0106+ }89107 x0 = p.plot.Min.X + p.off + i*p.gw + p.inset + j*p.bw90108 return x0, x0 + p.bw91109 }92110 111+// piles returns category i's bars as piles of spans in values, each with112+// the series whose slot it takes: one pile when stacked, one per series113+// otherwise.114+func (p *columnPlan) piles(i int, buf []span) []span {115+ if p.stacked {116+ return stack(p.d, i, buf)117+ }118+ buf = buf[:0]119+ base := p.sc.base()120+ for j, s := range p.d.Series {121+ if v := value(s, i); !math.IsNaN(v) {122+ buf = append(buf, span{min(v, base), max(v, base), j})123+ }124+ }125+ return buf126+}127+93128 // dotX returns the x of category i's point in braille dots (two per cell).94129 func (p *columnPlan) dotX(i int) int {95130 dots := p.plot.Dx() * 2
@@ -101,48 +136,67 @@ func (p *columnPlan) dotX(i int) int {
101136 102137 // catCenter is the column under which category i's label is centered.103138 func (p *columnPlan) catCenter(i int) int {104- if p.line {139+ if p.kind != columnBars {105140 return p.plot.Min.X + p.dotX(i)/2106141 }107142 return p.plot.Min.X + p.off + i*p.gw + p.gw/2108143 }109144 110145 func (p *columnPlan) draw(g *Grid, o Options) {111- // Value axis with a label at each tick, up to the top one.112- for y := p.axisRow - p.sc.cells(); y < p.axisRow; y++ {113- g.set(p.axisX, y, "│", Axis)146+ drawValueAxis(g, p.sc, p.labelW, p.axisX, p.axisRow, p.plot.Min.X, p.plot.Max.X+1)147+ p.drawCategories(g)148+ if o.Image {149+ return114150 }115- for x := p.plot.Min.X; x < g.W; x++ {116- g.set(x, p.axisRow, "─", Axis)151+ if p.grid {152+ hGridlines(g, p.sc, p.axisRow, p.plot)117153 }118- for j := 0; j <= p.sc.n; j++ {119- y := p.axisRow - j*p.sc.k120- label := p.sc.label(p.sc.tick(j))121- g.text(p.labelW-ansi.StringWidth(label), y, label, Label)154+ switch p.kind {155+ case columnLine:156+ p.drawLines(g)157+ case columnArea:158+ p.drawArea(g)159+ default:160+ p.drawBars(g)161+ }162+}163+164+// drawValueAxis draws a vertical value axis at column axisX with a label165+// at each tick, and the category axis along row axisRow from x0 to x1.166+func drawValueAxis(g *Grid, sc scale, labelW, axisX, axisRow, x0, x1 int) {167+ for y := axisRow - sc.cells(); y < axisRow; y++ {168+ g.set(axisX, y, "│", Axis)169+ }170+ for x := x0; x < min(x1, g.W); x++ {171+ g.set(x, axisRow, "─", Axis)172+ }173+ for j := 0; j <= sc.n; j++ {174+ y := axisRow - j*sc.k175+ label := sc.label(sc.tick(j))176+ g.text(labelW-ansi.StringWidth(label), y, label, Label)122177 mark := "┤"123178 if j == 0 {124179 mark = "┼"125180 }126- g.set(p.axisX, y, mark, Axis)127- }128- p.drawCategories(g)129- if p.legend >= 0 {130- g.legend(p.legend, seriesNames(p.d))131- }132- if o.Image {133- return181+ g.set(axisX, y, mark, Axis)134182 }135- if p.line {136- p.drawLines(g)137- } else {138- p.drawBars(g)183+}184+185+// hGridlines draws a dashed line across the plot at each tick above the186+// axis.187+func hGridlines(g *Grid, sc scale, axisRow int, plot image.Rectangle) {188+ for j := 1; j <= sc.n; j++ {189+ y := axisRow - j*sc.k190+ for x := plot.Min.X; x < plot.Max.X; x++ {191+ g.set(x, y, "┈", Gridline)192+ }139193 }140194 }141195 142196 // drawCategories labels as many categories as fit without touching.143197 func (p *columnPlan) drawCategories(g *Grid) {144198 room := p.gw - 1145- if p.line {199+ if p.kind != columnBars {146200 room = p.plot.Dx()/max(p.shown-1, 1) - 1147201 }148202 room = max(room, 3)
@@ -160,48 +214,28 @@ func (p *columnPlan) drawCategories(g *Grid) {
160214 }161215 162216 func (p *columnPlan) drawBars(g *Grid) {163- zero := p.sc.pos(0)164- for j, s := range p.d.Series {165- for i := 0; i < p.shown && i < len(s.Values); i++ {166- v := s.Values[i]167- if math.IsNaN(v) {168- continue169- }170- x0, x1 := p.bar(i, j)171- lo, hi := min(zero, p.sc.pos(v)), max(zero, p.sc.pos(v))172- for r := int(math.Floor(lo)); float64(r) < hi; r++ {173- // Below the axis, bars fill from the top.174- glyph := barGlyph(lo, hi, r, v >= 0, lowerEighths, "▀")175- for x := x0; glyph != "" && x < x1; x++ {176- g.set(x, p.axisRow-1-r, glyph, SeriesRole(j))177- }217+ drawPiles(p.sc, p.shown, p.stacked, p.piles, lowerEighths, "▀", func(i, j, r int, cell Cell) {218+ x0, x1 := p.bar(i, j)219+ for x := x0; x < x1; x++ {220+ if at := g.At(x, p.axisRow-1-r); at != nil {221+ *at = cell178222 }179223 }180- }224+ })181225 }182226 183227 // dotY maps a value to a braille dot row, four per cell, from the top.184228 func (p *columnPlan) dotY(v float64) int {185229 rows := p.axisRow * 4186230 full := p.sc.cells()*4 - 1187- y := rows - 1 - int(math.Round(p.sc.pos(v)/float64(p.sc.cells())*float64(full)))231+ y := rows - 1 - int(math.Round(p.sc.at(v)/float64(p.sc.cells())*float64(full)))188232 return min(max(y, 0), rows-1)189233 }190234 191235 // drawLines draws each series as a braille line; where lines cross a192236 // cell, the later series' color wins.193237 func (p *columnPlan) drawLines(g *Grid) {194- w, h := p.plot.Dx(), p.axisRow195- bits := make([]uint8, w*h)196- owner := make([]int, w*h)197- dot := func(x, y, series int) {198- cx, cy := x/2, y/4199- if cx < 0 || cx >= w || cy < 0 || cy >= h {200- return201- }202- bits[cy*w+cx] |= brailleBit[x%2][y%4]203- owner[cy*w+cx] = series204- }238+ b := newBraille(p.plot.Dx(), p.axisRow)205239 for j, s := range p.d.Series {206240 px, py, have := 0, 0, false207241 for i := 0; i < p.shown && i < len(s.Values); i++ {
@@ -212,17 +246,47 @@ func (p *columnPlan) drawLines(g *Grid) {
212246 }213247 x, y := p.dotX(i), p.dotY(v)214248 if have {215- bresenham(px, py, x, y, func(x, y int) { dot(x, y, j) })249+ b.line(px, py, x, y, j)216250 } else {217- dot(x, y, j)251+ b.dot(x, y, j)218252 }219253 px, py, have = x, y, true220254 }221255 }222- for cy := range h {223- for cx := range w {224- if b := bits[cy*w+cx]; b != 0 {225- g.set(p.plot.Min.X+cx, cy, string(rune(0x2800+int(b))), SeriesRole(owner[cy*w+cx]))256+ b.draw(g, p.plot.Min.X, 0)257+}258+259+// braille is a canvas of braille dots, two by four per cell, each cell260+// colored by the series that drew in it last.261+type braille struct {262+ w, h int263+ bits []uint8264+ owner []int265+}266+267+func newBraille(w, h int) *braille {268+ return &braille{w: w, h: h, bits: make([]uint8, w*h), owner: make([]int, w*h)}269+}270+271+func (b *braille) dot(x, y, series int) {272+ cx, cy := x/2, y/4273+ if x < 0 || y < 0 || cx >= b.w || cy >= b.h {274+ return275+ }276+ b.bits[cy*b.w+cx] |= brailleBit[x%2][y%4]277+ b.owner[cy*b.w+cx] = series278+}279+280+func (b *braille) line(x0, y0, x1, y1, series int) {281+ bresenham(x0, y0, x1, y1, func(x, y int) { b.dot(x, y, series) })282+}283+284+// draw puts the dots on g with the top-left cell at x, y.285+func (b *braille) draw(g *Grid, x, y int) {286+ for cy := range b.h {287+ for cx := range b.w {288+ if bits := b.bits[cy*b.w+cx]; bits != 0 {289+ g.set(x+cx, y+cy, string(rune(0x2800+int(bits))), SeriesRole(b.owner[cy*b.w+cx]))226290 }227291 }228292 }
M internal/chart/image.go +34 -30
@@ -28,7 +28,7 @@ func Image(t sheet.ChartType, d sheet.ChartData, w, h int, o Options, pal Palett
2828 if w < 8 || h < 3 {2929 return nil3030 }31- p := planFor(t, d, w, h, o)31+ p, _ := planFor(t, d, w, h, o)3232 plot := p.plotArea()3333 if p.note() != "" || plot.Empty() {3434 return nil
@@ -131,32 +131,36 @@ func segDist(px, py, ax, ay, bx, by float64) float64 {
131131 return math.Hypot(px-(ax+t*dx), py-(ay+t*dy))132132 }133133 134-// image draws columns or lines. Values map exactly, while gridlines sit135-// on the cell boundaries the text ticks label.134+// image draws columns, lines or areas. Values map exactly, while135+// gridlines sit on the cell boundaries the text ticks label.136136 func (p *columnPlan) image(cv *canvas) {137137 bottom := float64(p.plot.Dy()) * cv.ch138- y := func(v float64) float64 { return bottom - p.sc.pos(v)*cv.ch }139- for j := 1; j <= p.sc.n; j++ {140- cv.hline(int(math.Round(y(p.sc.tick(j)))), cv.pal.Grid)138+ y := func(v float64) float64 { return bottom - p.sc.at(v)*cv.ch }139+ if p.grid {140+ for j := 1; j <= p.sc.n; j++ {141+ cv.hline(int(math.Round(y(p.sc.tick(j)))), cv.pal.Grid)142+ }143+ }144+ if p.kind != columnLine && p.sc.crossesZero() {145+ cv.hline(int(math.Round(y(0))), cv.pal.Grid)141146 }142- if p.line {147+ switch p.kind {148+ case columnLine:143149 p.lineImage(cv, y)144150 return145- }146- if p.sc.lo < 0 {147- cv.hline(int(math.Round(y(0))), cv.pal.Grid)151+ case columnArea:152+ p.areaImage(cv, y)153+ return148154 }149155 inset := min(cv.cw/6, 2)150- for j, s := range p.d.Series {151- for i := 0; i < p.shown && i < len(s.Values); i++ {152- v := s.Values[i]153- if math.IsNaN(v) {154- continue155- }156- x0, x1 := p.bar(i, j)156+ var piles []span157+ for i := 0; i < p.shown; i++ {158+ piles = p.piles(i, piles)159+ for _, s := range piles {160+ x0, x1 := p.bar(i, s.j)157161 px0 := float64(x0-p.plot.Min.X)*cv.cw + inset158162 px1 := float64(x1-p.plot.Min.X)*cv.cw - inset159- cv.rect(px0, min(y(v), y(0)), px1, max(y(v), y(0)), cv.pal.Series[j%Colors])163+ cv.rect(px0, y(s.hi), px1, y(s.lo), cv.pal.Series[s.j%Colors])160164 }161165 }162166 }
@@ -187,22 +191,22 @@ func (p *columnPlan) lineImage(cv *canvas, y func(float64) float64) {
187191 188192 // image draws horizontal bars.189193 func (p *barPlan) image(cv *canvas) {190- x := func(v float64) float64 { return p.sc.pos(v) * cv.cw }191- for j := 1; j <= p.sc.n; j++ {192- cv.vline(int(math.Round(x(p.sc.tick(j))))-1, cv.pal.Grid)194+ x := func(v float64) float64 { return p.sc.at(v) * cv.cw }195+ if p.grid {196+ for j := 1; j <= p.sc.n; j++ {197+ cv.vline(int(math.Round(x(p.sc.tick(j))))-1, cv.pal.Grid)198+ }193199 }194- if p.sc.lo < 0 {200+ if p.sc.crossesZero() {195201 cv.vline(int(math.Round(x(0))), cv.pal.Grid)196202 }197203 inset := min(cv.ch/8, 2)198- for j, s := range p.d.Series {199- for i := 0; i < p.shown && i < len(s.Values); i++ {200- v := s.Values[i]201- if math.IsNaN(v) {202- continue203- }204- row := float64(p.barRow(i, j))205- 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])204+ var piles []span205+ for i := 0; i < p.shown; i++ {206+ piles = p.piles(i, piles)207+ for _, s := range piles {208+ row := float64(p.barRow(i, s.j))209+ cv.rect(x(s.lo), row*cv.ch+inset, x(s.hi), (row+1)*cv.ch-inset, cv.pal.Series[s.j%Colors])206210 }207211 }208212 }
A internal/chart/legend.go +114 -0
@@ -0,0 +1,114 @@
1+package chart2+3+import (4+ "strconv"5+6+ "github.com/charmbracelet/x/ansi"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// legendBox is where a chart's legend of series goes: a row under the12+// plot, a column right of it, or nowhere. Draw lays the chart out in the13+// room the legend leaves.14+type legendBox struct {15+ names []string16+ pos sheet.ChartLegend17+ row int // the bottom legend's row18+ x, w int // the right legend's first column and width19+}20+21+// Legend sizes.22+const (23+ legendNameW = 16 // longest name in a right legend24+ minRightPlot = 16 // narrowest chart a right legend leaves25+)26+27+// placeLegend makes room for a legend of names in a w by h chart and28+// returns the room left for the chart. A legend needs two series or more,29+// and space: a bottom one seven rows, a right one a chart still30+// minRightPlot wide beside it.31+func placeLegend(names []string, pos sheet.ChartLegend, w, h int) (legendBox, int, int) {32+ lg := legendBox{pos: sheet.LegendNone}33+ if len(names) < 2 {34+ return lg, w, h35+ }36+ switch pos {37+ case sheet.LegendBottom:38+ if h >= 7 {39+ return legendBox{names: names, pos: pos, row: h - 1}, w, h - 140+ }41+ case sheet.LegendRight:42+ nameW := 143+ for _, n := range names {44+ nameW = max(nameW, ansi.StringWidth(n))45+ }46+ lw := 2 + min(nameW, legendNameW)47+ if w-lw-1 >= minRightPlot {48+ return legendBox{names: names, pos: pos, x: w - lw, w: lw}, w - lw - 1, h49+ }50+ }51+ return lg, w, h52+}53+54+func (lg legendBox) draw(g *Grid) {55+ switch lg.pos {56+ case sheet.LegendBottom:57+ g.legend(lg.row, lg.names)58+ case sheet.LegendRight:59+ g.legendColumn(lg.x, lg.w, lg.names)60+ }61+}62+63+// legend draws "■ Rent ■ Food" centered on row y, shortening names to64+// fit.65+func (g *Grid) legend(y int, names []string) {66+ const gap = " "67+ for limit := 24; limit >= 1; limit-- {68+ items := make([]string, len(names))69+ width := 070+ for i, n := range names {71+ items[i] = "■ " + ansi.Truncate(n, limit, "…")72+ width += ansi.StringWidth(items[i])73+ }74+ width += len(gap) * (len(items) - 1)75+ if width > g.W && limit > 1 {76+ continue77+ }78+ x := max((g.W-width)/2, 0)79+ for i, it := range items {80+ if x+ansi.StringWidth(it) > g.W {81+ g.text(max(x, 0), y, "…", Label)82+ return83+ }84+ g.set(x, y, "■", SeriesRole(i))85+ g.text(x+2, y, it[len("■ "):], Label)86+ x += ansi.StringWidth(it) + len(gap)87+ }88+ return89+ }90+}91+92+// legendColumn draws the legend one series a row from column x, w wide,93+// centered down the chart, ending in "… 3 more" when they don't all fit.94+func (g *Grid) legendColumn(x, w int, names []string) {95+ n := min(len(names), g.H)96+ y := (g.H - n) / 297+ for i, name := range names {98+ if y == g.H-1 && i < len(names)-1 {99+ g.text(x, y, ansi.Truncate("… "+strconv.Itoa(len(names)-i)+" more", w, "…"), Label)100+ return101+ }102+ g.set(x, y, "■", SeriesRole(i))103+ g.text(x+2, y, ansi.Truncate(name, w-2, "…"), Label)104+ y++105+ }106+}107+108+func seriesNames(series []sheet.ChartSeries) []string {109+ names := make([]string, len(series))110+ for i, s := range series {111+ names[i] = s.Name112+ }113+ return names114+}
M internal/chart/pie.go +14 -5
@@ -12,7 +12,8 @@ import (
1212 )1313 1414 // 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 right.15+// 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.1617 // As text, the disc is half blocks: two square pixels per cell, each1718 // colored by its slice.1819 type piePlan struct {
@@ -21,7 +22,9 @@ type piePlan struct {
2122 slices []slice2223 disc image.Rectangle // in cells2324 legend image.Point // top-left of the legend24- nameW int25+ // noLegend leaves the legend out, for Legend: none.26+ noLegend bool27+ nameW int2528 }2629 2730 type slice struct {
@@ -59,15 +62,18 @@ func newPiePlan(d sheet.ChartData, w, h int, o Options) *piePlan {
5962 }6063 // "■ " name " " share: shares are at most "100%".6164 p.nameW = max(min(p.nameW, w/2-8), 3)62- legendW := 2 + p.nameW + 2 + 465+ legendW, gap := 2+p.nameW+2+4, 366+ if o.Chart.Legend == sheet.LegendNone {67+ legendW, gap, p.noLegend = 0, 0, true68+ }6369 a := o.aspect()64- rows := min(h, int(float64(w-legendW-3)/a))70+ rows := min(h, int(float64(w-legendW-gap)/a))6571 if rows < 2 {6672 p.msg = "Too small to chart"6773 return p6874 }6975 cols := int(math.Round(float64(rows) * a))70- x0 := max((w-(cols+3+legendW))/2, 0)76+ x0 := max((w-(cols+gap+legendW))/2, 0)7177 y0 := (h - rows) / 27278 p.disc = image.Rect(x0, y0, x0+cols, y0+rows)7379 n := min(len(p.slices), h)
@@ -124,6 +130,9 @@ func (p *piePlan) draw(g *Grid, o Options) {
124130 }125131 126132 func (p *piePlan) drawLegend(g *Grid) {133+ if p.noLegend {134+ return135+ }127136 x, y := p.legend.X, p.legend.Y128137 for i, s := range p.slices {129138 if y >= g.H {
A internal/chart/scatter.go +239 -0
@@ -0,0 +1,239 @@
1+package chart2+3+import (4+ "image"5+ "image/color"6+ "math"7+ "strconv"8+9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// scatterPlan lays out a scatter chart as Sheets does: the first series15+// is the X values and every other one a series of Y values plotted16+// against them, each point a dot, with an optional linear trend line per17+// series. The value axis on the left is Y, with the chart's axis options;18+// X runs along the bottom.19+//20+// 60 ┤ ⠛ ⠛⠛21+// 40 ┤ ⠛⠛ ⠛22+// 20 ┤⠛23+// 0 ┼──┬──┬──┬24+// 0 2 4 625+type scatterPlan struct {26+ xs []float6427+ ys []sheet.ChartSeries28+ trend bool29+ grid bool30+ msg string31+ xsc scale32+ ysc scale33+ labelW int34+ axisX int35+ axisRow int36+ tickRow int37+ plot image.Rectangle38+}39+40+// scatterSeries splits d into X values and Y series: the first series is41+// X when there are two or more, and otherwise the categories are, read as42+// numbers or else counted from 1.43+func scatterSeries(d sheet.ChartData) ([]float64, []sheet.ChartSeries) {44+ if len(d.Series) >= 2 {45+ return d.Series[0].Values, d.Series[1:]46+ }47+ xs := make([]float64, len(d.Categories))48+ for i, c := range d.Categories {49+ x, err := strconv.ParseFloat(c, 64)50+ if err != nil {51+ x = float64(i + 1)52+ }53+ xs[i] = x54+ }55+ return xs, d.Series56+}57+58+func newScatterPlan(d sheet.ChartData, w, h int, o sheet.ChartOptions) *scatterPlan {59+ d = prepare(d, o, false)60+ xs, ys := scatterSeries(d)61+ p := &scatterPlan{xs: xs, ys: ys, trend: o.Trend, grid: !o.NoGrid}62+ xlo, xhi, okX := finite([]sheet.ChartSeries{{Values: xs}})63+ ylo, yhi, okY := finite(ys)64+ if !okX || !okY {65+ p.msg = "No numbers to chart"66+ return p67+ }68+ p.tickRow = h - 169+ p.axisRow = h - 270+ if p.axisRow < 2 {71+ p.msg = "Too small to chart"72+ return p73+ }74+ p.ysc = newAxis(ylo, yhi, p.axisRow, 2, 6, d.Format, o)75+ for j := 0; j <= p.ysc.n; j++ {76+ p.labelW = max(p.labelW, ansi.StringWidth(p.ysc.label(p.ysc.tick(j))))77+ }78+ p.axisX = p.labelW + 179+ x0 := p.axisX + 180+ labelW := 181+ for range 2 {82+ plotW := w - x0 - (labelW+1)/283+ if plotW < 2 {84+ p.msg = "Too small to chart"85+ return p86+ }87+ p.xsc = newScale(xlo, xhi, plotW, labelW+2, 6, d.Format)88+ for j := 0; j <= p.xsc.n; j++ {89+ labelW = max(labelW, ansi.StringWidth(p.xsc.label(p.xsc.tick(j))))90+ }91+ }92+ p.plot = image.Rect(x0, 0, x0+p.xsc.cells(), p.axisRow)93+ return p94+}95+96+// points calls fn with each point of every series, in cells from the97+// plot's bottom left.98+func (p *scatterPlan) points(fn func(j int, x, y float64)) {99+ for j, s := range p.ys {100+ for i, x := range p.xs {101+ y := value(s, i)102+ if math.IsNaN(y) || math.IsNaN(x) || math.IsInf(x, 0) {103+ continue104+ }105+ fn(j, p.xsc.at(x), p.ysc.at(y))106+ }107+ }108+}109+110+// fit returns the least squares line y = a + b*x through series j's111+// points, and false with fewer than two distinct X values.112+func (p *scatterPlan) fit(j int) (a, b float64, ok bool) {113+ var n, sx, sy, sxx, sxy float64114+ for i, x := range p.xs {115+ y := value(p.ys[j], i)116+ if math.IsNaN(y) || math.IsNaN(x) || math.IsInf(x, 0) {117+ continue118+ }119+ n++120+ sx, sy, sxx, sxy = sx+x, sy+y, sxx+x*x, sxy+x*y121+ }122+ d := n*sxx - sx*sx123+ if n < 2 || math.Abs(d) < 1e-12*max(1, sxx*n) {124+ return 0, 0, false125+ }126+ b = (n*sxy - sx*sy) / d127+ return (sy - b*sx) / n, b, true128+}129+130+// trendPoints calls fn with points along series j's trend line, in cells131+// from the plot's bottom left, steps per cell across the plot; ok is132+// false where the line leaves the value axis.133+func (p *scatterPlan) trendPoints(j int, steps float64, fn func(x, y float64, ok bool)) {134+ a, b, ok := p.fit(j)135+ if !ok {136+ return137+ }138+ cells := float64(p.xsc.cells())139+ n := int(cells * steps)140+ for k := 0; k <= n; k++ {141+ x := cells * float64(k) / float64(max(n, 1))142+ v := a + b*(p.xsc.lo+x/cells*(p.xsc.hi-p.xsc.lo))143+ y := p.ysc.pos(v)144+ fn(x, y, !math.IsNaN(y) && y >= 0 && y <= float64(p.ysc.cells()))145+ }146+}147+148+func (p *scatterPlan) draw(g *Grid, o Options) {149+ drawValueAxis(g, p.ysc, p.labelW, p.axisX, p.axisRow, p.plot.Min.X, p.plot.Max.X)150+ drawXTicks(g, p.xsc, p.axisX, p.plot.Min.X, p.axisRow, p.tickRow)151+ if o.Image {152+ return153+ }154+ if p.grid {155+ hGridlines(g, p.ysc, p.axisRow, p.plot)156+ }157+ w, h := p.plot.Dx(), p.axisRow158+ b := newBraille(w, h)159+ // Dots: two per cell across, four down, from the top left.160+ dot := func(x, y float64) (int, int) {161+ dx := int(math.Round(x / float64(max(p.xsc.cells(), 1)) * float64(w*2-1)))162+ dy := h*4 - 1 - int(math.Round(y/float64(max(p.ysc.cells(), 1))*float64(p.ysc.cells()*4-1)))163+ return min(max(dx, 0), w*2-1), min(max(dy, 0), h*4-1)164+ }165+ if p.trend {166+ for j := range p.ys {167+ px, py, have := 0, 0, false168+ p.trendPoints(j, 2, func(x, y float64, ok bool) {169+ if !ok {170+ have = false171+ return172+ }173+ dx, dy := dot(x, y)174+ if have {175+ b.line(px, py, dx, dy, j)176+ }177+ px, py, have = dx, dy, true178+ })179+ }180+ }181+ p.points(func(j int, x, y float64) {182+ dx, dy := dot(x, y)183+ // A point is two dots by two, kept inside the plot.184+ dx, dy = min(dx, w*2-2), max(dy-1, 0)185+ for k := range 4 {186+ b.dot(dx+k%2, dy+k/2, j)187+ }188+ })189+ b.draw(g, p.plot.Min.X, 0)190+}191+192+// image draws the points as discs and the trend lines through them.193+func (p *scatterPlan) image(cv *canvas) {194+ bottom := float64(p.plot.Dy()) * cv.ch195+ px := func(x float64) float64 { return x * cv.cw }196+ py := func(y float64) float64 { return bottom - y*cv.ch }197+ if p.grid {198+ for j := 1; j <= p.ysc.n; j++ {199+ cv.hline(int(math.Round(py(float64(j*p.ysc.k)))), cv.pal.Grid)200+ }201+ for j := 1; j <= p.xsc.n; j++ {202+ cv.vline(int(math.Round(px(float64(j*p.xsc.k))))-1, cv.pal.Grid)203+ }204+ }205+ if p.trend {206+ width := max(cv.ch/12, 1.5)207+ for j := range p.ys {208+ var run [][2]float64209+ flush := func() {210+ if len(run) > 1 {211+ cv.polyline(run, width, cv.pal.Series[j%Colors])212+ }213+ run = run[:0]214+ }215+ p.trendPoints(j, 1, func(x, y float64, ok bool) {216+ if !ok {217+ flush()218+ return219+ }220+ run = append(run, [2]float64{px(x), py(y)})221+ })222+ flush()223+ }224+ }225+ r := max(min(cv.cw, cv.ch)*0.3, 2.5)226+ p.points(func(j int, x, y float64) {227+ cv.disc(px(x), py(y), r, cv.pal.Series[j%Colors])228+ })229+}230+231+// disc fills a circle of radius r centered at cx, cy, antialiased.232+func (cv *canvas) disc(cx, cy, r float64, c color.RGBA) {233+ for y := int(math.Floor(cy - r - 1)); y <= int(math.Ceil(cy+r+1)); y++ {234+ for x := int(math.Floor(cx - r - 1)); x <= int(math.Ceil(cx+r+1)); x++ {235+ d := math.Hypot(float64(x)+0.5-cx, float64(y)+0.5-cy)236+ cv.blend(x, y, c, r+0.5-d)237+ }238+ }239+}
A internal/chart/stack.go +197 -0
@@ -0,0 +1,197 @@
1+package chart2+3+import (4+ "math"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+)8+9+// 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.11+type span struct {12+ lo, hi float6413+ j int14+}15+16+// 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.19+func 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+}28+29+// shares is d with each value divided by the sum of its category's sizes,30+// formatted as percentages.31+func 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+}53+54+// value returns series s's value i, NaN when missing or infinite.55+func 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+}61+62+// stackedRange is the range of the stacks' ends over the first n63+// categories: positive values pile up from zero and negative ones down.64+func 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+}80+81+// stack returns category i's spans, one per series with a value, piled82+// up from zero (positive values) and down from it (negative ones).83+func 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+}86+87+// pileUp stacks n values, v(j) for series j, into out.88+func 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+}103+104+// 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.112+func 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+}150+151+// 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.155+func 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+}177+178+// inCells maps spans in values to spans in cells along sc, into out.179+func inCells(sc scale, spans []span, out []span) []span {180+ out = out[:0]181+ for _, s := range spans {182+ out = append(out, span{sc.at(s.lo), sc.at(s.hi), s.j})183+ }184+ return out185+}186+187+// pileExtent is the first and last cell a pile in cells touches.188+func pileExtent(spans []span) (from, to int) {189+ lo, hi := math.Inf(1), math.Inf(-1)190+ for _, s := range spans {191+ lo, hi = min(lo, s.lo), max(hi, s.hi)192+ }193+ if lo > hi {194+ return 0, -1195+ }196+ return int(math.Floor(lo)), int(math.Ceil(hi)) - 1197+}
M internal/chart/stress_test.go +2 -0
A internal/chart/testdata/area-negative.txt +10 -0
A internal/chart/testdata/area-stacked.txt +12 -0
A internal/chart/testdata/area.txt +12 -0
A internal/chart/testdata/axis-log.txt +12 -0
A internal/chart/testdata/axis-min-max.txt +12 -0
A internal/chart/testdata/axis-min.txt +12 -0
A internal/chart/testdata/bar-log.txt +10 -0
A internal/chart/testdata/bar-percent.txt +12 -0
M internal/chart/testdata/bar-single.txt +5 -5
A internal/chart/testdata/bar-stacked.txt +12 -0
M internal/chart/testdata/bar.txt +7 -7
M internal/chart/testdata/column-narrow.txt +2 -2
M internal/chart/testdata/column-negative.txt +3 -3
A internal/chart/testdata/column-percent.txt +12 -0
M internal/chart/testdata/column-single.txt +3 -3
A internal/chart/testdata/column-stacked-negative.txt +12 -0
A internal/chart/testdata/column-stacked.txt +12 -0
M internal/chart/testdata/column.txt +3 -3
A internal/chart/testdata/images.txt +17 -0
A internal/chart/testdata/legend-none.txt +12 -0
A internal/chart/testdata/legend-right.txt +12 -0
M internal/chart/testdata/line-single.txt +4 -4
M internal/chart/testdata/line.txt +3 -3
A internal/chart/testdata/no-gridlines.txt +10 -0
A internal/chart/testdata/pie-no-legend.txt +10 -0
A internal/chart/testdata/scatter-single.txt +10 -0
A internal/chart/testdata/scatter-trend.txt +12 -0
A internal/chart/testdata/scatter.txt +12 -0
M internal/chart/types.go +47 -19
@@ -20,33 +20,61 @@ type plan interface {
2020 image(cv *canvas)2121 }2222 23-// A layout lays out data as one type of chart.24-type layout func(d sheet.ChartData, w, h int, o Options) plan23+// A chartType lays out data as one type of chart. The legend of series24+// is Draw's: it gives the layout the room the legend leaves, and names25+// returns the series it lists, nil for a type that draws its own.26+type chartType struct {27+ layout func(d sheet.ChartData, w, h int, o Options) plan28+ names func(d sheet.ChartData) []string29+}2530 2631 // types are the chart types, each a layout whose plan draws it as text2732 // and as an image. The types themselves, their names and the order the2833 // chart editor offers them, are in the sheet package, since charts are2934 // saved with sheets; adding one is a constant there and a row here.30-var types = map[sheet.ChartType]layout{31- sheet.ChartColumn: func(d sheet.ChartData, w, h int, _ Options) plan { return newColumnPlan(d, w, h, false) },32- sheet.ChartBar: func(d sheet.ChartData, w, h int, _ Options) plan { return newBarPlan(d, w, h) },33- sheet.ChartLine: func(d sheet.ChartData, w, h int, _ Options) plan { return newColumnPlan(d, w, h, true) },34- sheet.ChartPie: func(d sheet.ChartData, w, h int, o Options) plan { return newPiePlan(d, w, h, o) },35+var types = map[sheet.ChartType]chartType{36+ sheet.ChartColumn: {columnLayout(columnBars), allSeries},37+ sheet.ChartBar: {func(d sheet.ChartData, w, h int, o Options) plan {38+ return newBarPlan(d, w, h, o.Chart)39+ }, allSeries},40+ sheet.ChartLine: {columnLayout(columnLine), allSeries},41+ sheet.ChartPie: {func(d sheet.ChartData, w, h int, o Options) plan {42+ return newPiePlan(d, w, h, o)43+ }, nil},44+ sheet.ChartArea: {columnLayout(columnArea), allSeries},45+ sheet.ChartScatter: {func(d sheet.ChartData, w, h int, o Options) plan {46+ return newScatterPlan(d, w, h, o.Chart)47+ }, func(d sheet.ChartData) []string {48+ _, ys := scatterSeries(d)49+ return seriesNames(ys)50+ }},51+}52+53+func columnLayout(kind columnKind) func(d sheet.ChartData, w, h int, o Options) plan {54+ return func(d sheet.ChartData, w, h int, o Options) plan { return newColumnPlan(d, w, h, kind, o.Chart) }3555 }3656 37-// planFor lays out d as a chart of type t; an unknown type is drawn as38-// columns.39-func planFor(t sheet.ChartType, d sheet.ChartData, w, h int, o Options) plan {40- lay, ok := types[t]57+func allSeries(d sheet.ChartData) []string { return seriesNames(d.Series) }58+59+// planFor lays out d as a chart of type t, with its legend; an unknown60+// type is drawn as columns.61+func planFor(t sheet.ChartType, d sheet.ChartData, w, h int, o Options) (plan, legendBox) {62+ ct, ok := types[t]4163 if !ok {42- lay = types[sheet.ChartColumn]64+ ct = types[sheet.ChartColumn]65+ }66+ lg := legendBox{pos: sheet.LegendNone}67+ if ct.names != nil {68+ lg, w, h = placeLegend(ct.names(d), o.Chart.Legend, w, h)4369 }44- return lay(d, w, h, o)70+ return ct.layout(d, w, h, o), lg4571 }4672 47-func (p *columnPlan) note() string { return p.msg }48-func (p *columnPlan) plotArea() image.Rectangle { return p.plot }49-func (p *barPlan) note() string { return p.msg }50-func (p *barPlan) plotArea() image.Rectangle { return p.plot }51-func (p *piePlan) note() string { return p.msg }52-func (p *piePlan) plotArea() image.Rectangle { return p.disc }73+func (p *columnPlan) note() string { return p.msg }74+func (p *columnPlan) plotArea() image.Rectangle { return p.plot }75+func (p *barPlan) note() string { return p.msg }76+func (p *barPlan) plotArea() image.Rectangle { return p.plot }77+func (p *piePlan) note() string { return p.msg }78+func (p *piePlan) plotArea() image.Rectangle { return p.disc }79+func (p *scatterPlan) note() string { return p.msg }80+func (p *scatterPlan) plotArea() image.Rectangle { return p.plot }
A internal/chart/types_test.go +155 -0
@@ -0,0 +1,155 @@
1+package chart2+3+import (4+ "crypto/sha256"5+ "fmt"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// imageCases are the images TestImageHashes pins: every type, and the15+// options that change how the plot is drawn.16+var imageCases = []struct {17+ name string18+ t sheet.ChartType19+ d sheet.ChartData20+ o sheet.ChartOptions21+}{22+ {"column", sheet.ChartColumn, budget, sheet.ChartOptions{}},23+ {"column-negative", sheet.ChartColumn, mixed, sheet.ChartOptions{}},24+ {"bar", sheet.ChartBar, budget, sheet.ChartOptions{}},25+ {"line", sheet.ChartLine, budget, sheet.ChartOptions{}},26+ {"pie", sheet.ChartPie, single, sheet.ChartOptions{}},27+ {"area", sheet.ChartArea, budget, sheet.ChartOptions{}},28+ {"area-stacked", sheet.ChartArea, budget, sheet.ChartOptions{Stack: sheet.StackNormal}},29+ {"column-stacked", sheet.ChartColumn, signs, sheet.ChartOptions{Stack: sheet.StackNormal}},30+ {"column-percent", sheet.ChartColumn, budget, sheet.ChartOptions{Stack: sheet.StackPercent}},31+ {"bar-stacked", sheet.ChartBar, signs, sheet.ChartOptions{Stack: sheet.StackNormal}},32+ {"bar-percent", sheet.ChartBar, budget, sheet.ChartOptions{Stack: sheet.StackPercent}},33+ {"scatter", sheet.ChartScatter, xy, sheet.ChartOptions{}},34+ {"scatter-trend", sheet.ChartScatter, xy, sheet.ChartOptions{Trend: true}},35+ {"no-gridlines", sheet.ChartColumn, budget, sheet.ChartOptions{NoGrid: true}},36+ {"axis-min-max", sheet.ChartLine, budget, sheet.ChartOptions{Min: 500, HasMin: true, Max: 1600, HasMax: true}},37+ {"axis-log", sheet.ChartColumn, growth, sheet.ChartOptions{Log: true}},38+ {"legend-right", sheet.ChartColumn, budget, sheet.ChartOptions{Legend: sheet.LegendRight}},39+}40+41+// TestImageHashes pins the image of each case by a hash of its pixels,42+// kept in testdata/images.txt (go test ./internal/chart -update rewrites43+// it). A changed hash means the image changed: look at it before44+// updating.45+func TestImageHashes(t *testing.T) {46+ var got strings.Builder47+ for _, tc := range imageCases {48+ img := Image(tc.t, tc.d, 40, 12, Options{CellW: 8, CellH: 16, Chart: tc.o}, testPalette)49+ if img == nil {50+ t.Errorf("%s: no image", tc.name)51+ continue52+ }53+ sum := sha256.Sum256(img.Pix)54+ fmt.Fprintf(&got, "%s %dx%d %x\n", tc.name, img.Bounds().Dx(), img.Bounds().Dy(), sum[:8])55+ }56+ path := filepath.Join("testdata", "images.txt")57+ if *update {58+ os.WriteFile(path, []byte(got.String()), 0o644)59+ return60+ }61+ want, err := os.ReadFile(path)62+ if err != nil {63+ t.Fatalf("missing hashes (go test ./internal/chart -update): %v", err)64+ }65+ if got.String() != string(want) {66+ t.Errorf("images changed:\n%s\nwant:\n%s", got.String(), want)67+ }68+}69+70+// TestNewTypesPixels checks a few pixels of the stacked, scatter and71+// area images directly.72+func TestNewTypesPixels(t *testing.T) {73+ o := Options{CellW: 8, CellH: 16}74+ // A stacked column: Rent at the bottom, Food above it.75+ o.Chart = sheet.ChartOptions{Stack: sheet.StackNormal}76+ p := newColumnPlan(budget, 44, 11, columnBars, o.Chart)77+ img := Image(sheet.ChartColumn, budget, 44, 12, o, testPalette)78+ x0, x1 := p.bar(0, 0)79+ mid := ((x0+x1)/2 - p.plot.Min.X) * 880+ bottom := img.Bounds().Dy()81+ rentTop := int(float64(bottom) - p.sc.pos(1450)*16)82+ if c := img.RGBAAt(mid, bottom-2); c != testPalette.Series[0] {83+ t.Errorf("stack bottom %v, want Rent's color", c)84+ }85+ if c := img.RGBAAt(mid, rentTop-4); c != testPalette.Series[1] {86+ t.Errorf("stack top %v, want Food's color", c)87+ }88+ if x0b, _ := p.bar(0, 1); x0b != x0 {89+ t.Errorf("stacked series at %d and %d, want one column", x0, x0b)90+ }91+ // A scatter point is its series' color at its center.92+ o.Chart = sheet.ChartOptions{}93+ sp := newScatterPlan(xy, 40, 11, o.Chart)94+ simg := Image(sheet.ChartScatter, xy, 40, 12, o, testPalette)95+ cx := int(sp.xsc.at(1) * 8)96+ cy := int(float64(simg.Bounds().Dy()) - sp.ysc.at(52)*16)97+ if c := simg.RGBAAt(cx, cy); c != testPalette.Series[0] {98+ t.Errorf("scatter point %v at %d,%d", c, cx, cy)99+ }100+ // The area under a line is filled, above it is clear.101+ aimg := Image(sheet.ChartArea, single, 40, 10, o, testPalette)102+ b := aimg.Bounds()103+ if c := aimg.RGBAAt(2, b.Dy()-3); c.A == 0 {104+ t.Errorf("under the area %v, want filled", c)105+ }106+ if c := aimg.RGBAAt(b.Dx()-2, 2); c.A != 0 {107+ t.Errorf("above the area %v, want clear", c)108+ }109+}110+111+func TestLogAndFixedScales(t *testing.T) {112+ s := newAxis(3, 9000, 10, 2, 6, sheet.Format{}, sheet.ChartOptions{Log: true})113+ if s.tick(0) != 1 || s.tick(s.n) != 10000 || s.n != 4 {114+ t.Errorf("log scale %v..%v in %d", s.tick(0), s.tick(s.n), s.n)115+ }116+ if got := s.label(1000); got != "1,000" {117+ t.Errorf("log label %q", got)118+ }119+ if p := s.pos(100); p != float64(2*s.k) {120+ t.Errorf("100 at %v cells", p)121+ }122+ f := newAxis(0, 1500, 9, 2, 6, sheet.Format{}, sheet.ChartOptions{Min: 200, HasMin: true})123+ if f.lo != 200 || f.hi < 1500 {124+ t.Errorf("fixed minimum: %v..%v", f.lo, f.hi)125+ }126+ f = newAxis(0, 1500, 8, 2, 6, sheet.Format{}, sheet.ChartOptions{Min: 0, HasMin: true, Max: 2000, HasMax: true})127+ if f.lo != 0 || f.hi != 2000 || niceStep(f.step) != f.step {128+ t.Errorf("fixed ends: %v..%v step %v", f.lo, f.hi, f.step)129+ }130+ // A value past a fixed end stops at the end of the axis.131+ if at := f.at(5000); at != float64(f.cells()) {132+ t.Errorf("clipped at %v", at)133+ }134+}135+136+// TestLegendPlacement checks the legend takes its room from the chart.137+func TestLegendPlacement(t *testing.T) {138+ names := []string{"Rent", "Food"}139+ if lg, w, h := placeLegend(names, sheet.LegendRight, 44, 12); lg.pos != sheet.LegendRight || w != 44-6-1 || h != 12 || lg.x != 38 {140+ t.Errorf("right: %+v in %dx%d", lg, w, h)141+ }142+ if lg, w, h := placeLegend(names, sheet.LegendBottom, 44, 12); lg.row != 11 || w != 44 || h != 11 {143+ t.Errorf("bottom: %+v in %dx%d", lg, w, h)144+ }145+ if lg, w, h := placeLegend(names, sheet.LegendNone, 44, 12); lg.pos != sheet.LegendNone || w != 44 || h != 12 {146+ t.Errorf("none: %+v in %dx%d", lg, w, h)147+ }148+ // One series, or too little room: no legend.149+ if lg, _, _ := placeLegend(names[:1], sheet.LegendBottom, 44, 12); lg.pos != sheet.LegendNone {150+ t.Errorf("one series: %+v", lg)151+ }152+ if lg, _, _ := placeLegend(names, sheet.LegendRight, 20, 12); lg.pos != sheet.LegendNone {153+ t.Errorf("narrow: %+v", lg)154+ }155+}