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>
@@ -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+}@@ -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+}@@ -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-}@@ -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 (@@ -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@@ -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 }@@ -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 }@@ -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+}@@ -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 {@@ -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+}@@ -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+}@@ -0,0 +1,12 @@
1+$3,000 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈2+ │3+ │ ▃▃▃▃▃▄▄▄▄▄▅▅4+$2,000 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈████████████5+ │▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄6+ │███████████████████████████████████7+$1,000 ┤███████████████████████████████████8+ │███████████████████████████████████9+ │███████████████████████████████████10+ $0 ┼────────────────────────────────────11+ Jan Feb Mar Apr12+ ■ Rent ■ Food@@ -0,0 +1,12 @@
1+$1,500 ┤▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▆▇▇▇▇▇██████████████2+ │███████████████████████████████████3+ │███████████████████████████████████4+$1,000 ┤███████████████████████████████████5+ │████████████████████████████████▁▁▂6+ │███████████████████████▅▆▆▇▇▇██████7+ $500 ┤███████████████████████████████████8+ │███████████████████████████████████9+ │███████████████████████████████████10+ $0 ┼────────────────────────────────────11+ Jan Feb Mar Apr12+ ■ Rent ■ Food@@ -0,0 +1,12 @@
1+2+3+ 10K ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈▇▇▇▇▇┈┈┈┈┈┈┈┈4+ │ █████5+1,000 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈▆▆▆▆▆┈█████┈┈┈┈┈┈┈┈6+ │ █████ █████7+ 100 ┤┈┈┈┈┈┈┈▂▂▂▂▂┈█████┈█████┈┈┈┈┈┈┈┈8+ │ █████ █████ █████9+ 10 ┤┈┈┈┈┈┈┈█████┈█████┈█████┈┈┈┈┈┈┈┈10+ │ █████ █████ █████ █████11+ 1 ┼─────────────────────────────────12+ Y1 Y2 Y3 Y4 Y5@@ -0,0 +1,12 @@
1+2+$1,600 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈▂▂▂┈┈┈┈┈▂▂▂┈┈┈┈┈┈3+ │ ▇▇▇ ▇▇▇ ███ ███4+$1,325 ┤┈┈███┈┈┈┈┈███┈┈┈┈┈███┈┈┈┈┈███┈┈┈┈┈┈5+ │ ███ ███ ███ ███6+$1,050 ┤┈┈███┈┈┈┈┈███┈┈┈┈┈███┈┈┈┈┈███┈┈┈┈┈┈7+ │ ███ ███ ███ ███8+ $775 ┤┈┈███┈┈┈┈┈███┈┈┈┈┈███┈┈┈┈┈███▄▄▄┈┈┈9+ │ ███▆▆▆ ███ ███▇▇▇ ██████10+ $500 ┼────────────────────────────────────11+ Jan Feb Mar Apr12+ ■ Rent ■ Food@@ -0,0 +1,12 @@
1+$1,900 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈2+ │3+ │⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠔⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒4+$1,400 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈5+ │6+ │7+ $900 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈8+ │⠄ ⠤⠤⠤⠒⠒⠒⠒⠒⠒⠉⠉⠉9+ │10+ $400 ┼────────────────────────────────────11+ Jan Feb Mar Apr12+ ■ Rent ■ Food@@ -0,0 +1,12 @@
1+Jan │████████████████████████▊██████████2+ │ ┊ ┊ ┊ ┊ ┊3+Feb │███████████████████████████████████4+ │ ┊ ┊ ┊ ┊ ┊5+Mar │████████████████████████▉██████████6+ │ ┊ ┊ ┊ ┊ ┊7+Apr │███████████████████████▉███████████8+ │ ┊ ┊ ┊ ┊ ┊9+ │ ┊ ┊ ┊ ┊ ┊10+ └──────┬──────┬──────┬──────┬──────┬11+ 0% 20% 40% 60% 80% 100%12+ ■ Rent ■ Food@@ -1,8 +1,8 @@
11 North │██████████████████████████████2-South │████████████████████3- East │███████████▎4- West │███████▌5- │6- │2+South │████████████████████ ┊ ┊3+ East │███████████▎ ┊ ┊ ┊ ┊4+ West │███████▌ ┊ ┊ ┊ ┊ ┊5+ │ ┊ ┊ ┊ ┊ ┊ ┊6+ │ ┊ ┊ ┊ ┊ ┊ ┊77 └────┬────┬────┬────┬────┬────┬88 0 20 40 60 80 100 120@@ -1,12 +1,12 @@
1-Jan │██████████████████████████████████▊2- │██████████████▍3-Feb │██████████████████████████████████▊4- │1+Jan │██████████████████████████████████▊┊2+ │██████████████▍ ┊ ┊3+Feb │██████████████████████████████████▊┊4+ │ ┊ ┊ ┊55 Mar │████████████████████████████████████6- │██████████████▊6+ │██████████████▊ ┊ ┊77 Apr │████████████████████████████████████8- │████████████████▊9- │8+ │████████████████▊ ┊ ┊9+ │ ┊ ┊ ┊1010 └───────────┬───────────┬───────────┬1111 $0 $500 $1,000 $1,5001212 ■ Rent ■ Food@@ -0,0 +1,12 @@
1+2+100% ┤┈███████┈┈███████┈┈███████┈┈███████┈┈3+ │ ███████ ███████ ███████ ███████4+ 75% ┤┈▅▅▅▅▅▅▅┈┈███████┈┈▅▅▅▅▅▅▅┈┈▄▄▄▄▄▄▄┈┈5+ │ ███████ ███████ ███████ ███████6+ 50% ┤┈███████┈┈███████┈┈███████┈┈███████┈┈7+ │ ███████ ███████ ███████ ███████8+ 25% ┤┈███████┈┈███████┈┈███████┈┈███████┈┈9+ │ ███████ ███████ ███████ ███████10+ 0% ┼──────────────────────────────────────11+ Jan Feb Mar Apr12+ ■ Rent ■ Food@@ -1,10 +1,10 @@
11 22 3-150 ┤3+150 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈44 │ ▆▆▆▆▆▆5-100 ┤ ██████ ▂▂▂▂▂▂5+100 ┤┈┈██████┈┈▂▂▂▂▂▂┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈66 │ ██████ ██████7- 50 ┤ ██████ ██████ ▆▆▆▆▆▆ ▂▂▂▂▂▂7+ 50 ┤┈┈██████┈┈██████┈┈▆▆▆▆▆▆┈┈▂▂▂▂▂▂┈┈88 │ ██████ ██████ ██████ ██████99 0 ┼───────────────────────────────────1010 North South East West@@ -0,0 +1,12 @@
1+2+ 40 ┤┈█████████┈┈█████████┈┈▄▄▄▄▄▄▄▄▄┈┈3+ │ █████████ █████████ █████████4+ 20 ┤┈█████████┈┈█████████┈┈█████████┈┈5+ │ █████████ █████████ █████████6+ 0 ┤┈█████████┈┈█████████┈┈█████████┈┈7+ │ █████████ ▀▀▀▀▀▀▀▀▀ █████████8+-20 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈█████████┈┈9+ │10+-40 ┼───────────────────────────────────11+ Q1 Q2 Q312+ ■ Sales ■ Costs ■ Other@@ -0,0 +1,12 @@
1+$3,000 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈2+ │3+ │ ▁▁▁▁▁▁ ▃▃▃▃▃▃ ▅▅▅▅▅▅4+$2,000 ┤┈┈██████┈┈┈┈┈┈┈┈┈┈██████┈┈██████┈┈┈5+ │ ▃▃▃▃▃▃ ▃▃▃▃▃▃ ▄▄▄▄▄▄ ▄▄▄▄▄▄6+ │ ██████ ██████ ██████ ██████7+$1,000 ┤┈┈██████┈┈██████┈┈██████┈┈██████┈┈┈8+ │ ██████ ██████ ██████ ██████9+ │ ██████ ██████ ██████ ██████10+ $0 ┼────────────────────────────────────11+ Jan Feb Mar Apr12+ ■ Rent ■ Food@@ -1,10 +1,10 @@
1-$1,500 ┤ ▆▆▆ ▆▆▆ ███ ███1+$1,500 ┤┈┈▆▆▆┈┈┈┈┈▆▆▆┈┈┈┈┈███┈┈┈┈┈███┈┈┈┈┈┈22 │ ███ ███ ███ ███33 │ ███ ███ ███ ███4-$1,000 ┤ ███ ███ ███ ███4+$1,000 ┤┈┈███┈┈┈┈┈███┈┈┈┈┈███┈┈┈┈┈███┈┈┈┈┈┈55 │ ███ ███ ███ ███▂▂▂66 │ ███▅▅▅ ███ ███▅▅▅ ██████7- $500 ┤ ██████ ███ ██████ ██████7+ $500 ┤┈┈██████┈┈███┈┈┈┈┈██████┈┈██████┈┈┈88 │ ██████ ███ ██████ ██████99 │ ██████ ███ ██████ ██████1010 $0 ┼────────────────────────────────────@@ -0,0 +1,17 @@
1+column 248x144 fe9d3f1b8e6fa3862+column-negative 272x160 61f43de6bd5344b63+bar 240x144 c16c035b2efa6cb94+line 248x144 07985b71585187035+pie 192x192 115fb8665fa10c8e6+area 248x144 f1008eec737859ce7+area-stacked 248x144 5bd86ca592c2d09f8+column-stacked 272x144 b36dcf0bf71684269+column-percent 264x144 339e292b4d2f72a110+bar-stacked 256x144 09799885e24b814811+bar-percent 240x144 c3fa2eff19992fa612+scatter 256x144 e420894afe581b7d13+scatter-trend 256x144 84ade560f090529014+no-gridlines 248x144 5c4aa23944a6150515+axis-min-max 248x144 c9ef52016bbccb9716+axis-log 256x160 42931f3bdb73561f17+legend-right 192x160 8cc9e1ee1766595a@@ -0,0 +1,12 @@
1+$1,600 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈2+ │⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠔⠒⠒⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉3+$1,400 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈4+ │5+$1,200 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈6+ │7+$1,000 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈8+ │9+ $800 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈⢀⣀10+ │⡀ ⣀⡠⠤⠤⠔⠒⠒⠊⠉⠉⠁11+ $600 ┼────────────────────────────────────12+ Jan Feb Mar Apr@@ -0,0 +1,12 @@
1+2+$1,500 ┤▆▆▆┈┈┈┈▆▆▆┈┈┈┈███┈┈┈┈███┈┈┈┈3+ │███ ███ ███ ███4+ │███ ███ ███ ███5+$1,000 ┤███┈┈┈┈███┈┈┈┈███┈┈┈┈███┈┈┈┈6+ │███ ███ ███ ███▂▂▂ ■ Rent7+ │███▅▅▅ ███ ███▅▅▅ ██████ ■ Food8+ $500 ┤██████┈███┈┈┈┈██████┈██████┈9+ │██████ ███ ██████ ██████10+ │██████ ███ ██████ ██████11+ $0 ┼─────────────────────────────12+ Jan Feb Mar Apr@@ -1,10 +1,10 @@
1-125 ┤⢄⡀1+125 ┤⢄⡀┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈22 │ ⠈⠑⠢⢄⡀3-100 ┤ ⠈⠒⠤⣀3+100 ┤┈┈┈┈┈⠈⠒⠤⣀┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈44 │ ⠉⠒⠤⣀5- 75 ┤ ⠉⠒⠤⣀5+ 75 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈⠉⠒⠤⣀┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈66 │ ⠉⠒⠤⣀7- 50 ┤ ⠉⠒⠒⠤⠤⠤⣀⣀⡀7+ 50 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈⠉⠒⠒⠤⠤⠤⣀⣀⡀┈┈┈┈88 │ ⠈⠉⠉⠑⠒99 25 ┼───────────────────────────────────1010 North South East West@@ -1,11 +1,11 @@
11 22 $1,500 ┤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠔⠒⠒⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉33 │4-$1,250 ┤4+$1,250 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈55 │6-$1,000 ┤6+$1,000 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈77 │8- $750 ┤ ⣀⣀⣀⣀⠤⠤⠤⠤⠒⠒8+ $750 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈⣀⣀⣀⣀⠤⠤⠤⠤⠒⠒99 │⠁ ⠉⠉1010 $500 ┼────────────────────────────────────1111 Jan Feb Mar Apr@@ -0,0 +1,12 @@
1+ ⣀2+80 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈⢀⣀⠤⠔⠊⠉3+ │ ⣀ ⢀⣀⢤⡔⠒⠉⠁ ⢀⣀4+70 ┤┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈⣀⡠⠤⠛⠉⠁┈⢠⡄┈⢀⡠⠤⠒⠊⠁⠛5+ │ ⢠⣄⠤⠔⠒⠉ ⣀⡠⠔⠒⠉⠁6+60 ┤┈┈┈┈┈┈┈┈⣀⣤⠔⠒⠉⠁┈┈┈┈⢀⣀⠤⠔⠛⠉┈┈┈┈┈┈┈┈┈┈┈┈7+ │ ⣀⡠⠴⠖⠊⠉ ⢠⣄⠤⠔⠒⠁8+50 ┤⠊⠉┈┈┈┈┈┈⢀⡠⠤⠒⠊⠁┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈9+ │ ⢀⣠⡔⠒⠉⠁10+40 ┼────────┬────────┬────────┬────────┬11+ 0 2 4 6 812+ ■ Score ■ Retake@@ -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 }@@ -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+}