Draw and Image look the type up in one table; each layout returns a plan that draws its chart as text and as an image, and Draw shows a plan's note in its place. The bar glyph walk that bar and column charts each had is shared as barCells, and the bar chart's axes are drawn by their own method.
- commit
- 2daa81534f84bb1da002a4658ea5fb0c14a7ed76
- parent
- 3945e7f2ef42ca3e7f094e63bf28101c28c14ee1
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 92 | +++++++++++++++++------------- |
| M | internal/ | 15 | ++--- |
| M | internal/ | 12 | ++++ |
| M | internal/ | 27 | +-------- |
| M | internal/ | 78 | ++++++++++--------------- |
| M | internal/ | 5 | -- |
| A | internal/ | 52 | +++++++++++++++++ |
@@ -82,12 +82,36 @@ func newBarPlan(d sheet.ChartData, w, h int) *barPlan {
8282 func (p *barPlan) barRow(i, j int) int { return i*p.rows + j }8383 8484 func (p *barPlan) draw(g *Grid, o Options) {85- if p.msg != "" {86- g.message(p.msg)85+ p.drawAxis(g)86+ ns := len(p.d.Series)87+ for i := 0; i < p.shown; i++ {88+ 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))93+ }94+ if o.Image {8795 return8896 }89- g.Plot = p.plot90- ns := len(p.d.Series)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+ // Left of the axis, bars fill from the right.105+ barCells(zero, p.sc.pos(v), v >= 0, leftEighths, "▐", func(c int, glyph string) {106+ g.set(p.plot.Min.X+c, p.barRow(i, j), glyph, SeriesRole(j))107+ })108+ }109+ }110+}111+112+// drawAxis draws the category axis on the left and the value axis along113+// the bottom, labeling as many ticks as fit without touching.114+func (p *barPlan) drawAxis(g *Grid) {91115 for y := 0; y < p.axisRow; y++ {92116 g.set(p.axisX, y, "│", Axis)93117 }@@ -95,7 +119,7 @@ func (p *barPlan) draw(g *Grid, o Options) {
95119 for x := p.axisX + 1; x < p.plot.Max.X; x++ {96120 g.set(x, p.axisRow, "─", Axis)97121 }98- next := 0122+ next := 0 // first free column99123 for j := 0; j <= p.sc.n; j++ {100124 x := p.axisX101125 if j > 0 {@@ -110,42 +134,28 @@ func (p *barPlan) draw(g *Grid, o Options) {
110134 next = lx + lw + 1111135 }112136 }113- for i := 0; i < p.shown; i++ {114- label := ansi.Truncate(p.d.Categories[i], p.catW, "…")115- g.text(p.catW-ansi.StringWidth(label), p.barRow(i, (ns-1)/2), label, Label)116- }117- if p.legend >= 0 {118- g.legend(p.legend, seriesNames(p.d))119- }120- if o.Image {121- return122- }123- zero := p.sc.pos(0)124- for j, s := range p.d.Series {125- for i := 0; i < p.shown && i < len(s.Values); i++ {126- v := s.Values[i]127- if math.IsNaN(v) {128- continue129- }130- end := p.sc.pos(v)131- lo, hi := min(zero, end), max(zero, end)132- for c := int(math.Floor(lo)); float64(c) < hi; c++ {133- cover := min(hi, float64(c+1)) - max(lo, float64(c))134- glyph := ""135- if v >= 0 {136- glyph = leftEighths[int(math.Round(cover*8))]137- } else {138- switch {139- case cover >= 0.75:140- glyph = "█"141- case cover >= 0.25:142- glyph = "▐" // left of the axis, bars fill from the right143- }144- }145- if glyph != "" && glyph != " " {146- g.set(p.plot.Min.X+c, p.barRow(i, j), glyph, SeriesRole(j))147- }148- }137+}138+139+// barCells walks the cells a bar covers from zero to end, distances140+// along its axis in cells, calling fill with each cell and its glyph.141+// A positive bar grows away from the axis in eighths; a negative one142+// has no eighths growing the other way, so its partly covered cells are143+// a full block or the half block half.144+func barCells(zero, end float64, positive bool, eighths []string, half string, fill func(c int, glyph string)) {145+ lo, hi := min(zero, end), max(zero, end)146+ for c := int(math.Floor(lo)); float64(c) < hi; c++ {147+ cover := min(hi, float64(c+1)) - max(lo, float64(c))148+ glyph := ""149+ switch {150+ case positive:151+ glyph = eighths[int(math.Round(cover*8))]152+ case cover >= 0.75:153+ glyph = "█"154+ case cover >= 0.25:155+ glyph = half156+ }157+ if glyph != "" && glyph != " " {158+ fill(c, glyph)149159 }150160 }151161 }@@ -128,16 +128,13 @@ func Draw(t sheet.ChartType, d sheet.ChartData, w, h int, o Options) *Grid {
128128 if w < 8 || h < 3 {129129 return g130130 }131- switch t {132- case sheet.ChartBar:133- newBarPlan(d, w, h).draw(g, o)134- case sheet.ChartLine:135- newColumnPlan(d, w, h, true).draw(g, o)136- case sheet.ChartPie:137- newPiePlan(d, w, h, o).draw(g, o)138- default:139- newColumnPlan(d, w, h, false).draw(g, o)131+ p := planFor(t, d, w, h, o)132+ if msg := p.note(); msg != "" {133+ g.message(msg)134+ return g140135 }136+ g.Plot = p.plotArea()137+ p.draw(g, o)141138 return g142139 }143140 @@ -157,6 +157,18 @@ func TestLayout(t *testing.T) {
157157 }158158 }159159 160+// TestTypes checks every chart type the editor offers has a layout.161+func TestTypes(t *testing.T) {162+ for _, ty := range sheet.ChartTypes {163+ if types[ty] == nil {164+ t.Errorf("%v has no layout", ty)165+ }166+ }167+ if len(types) != len(sheet.ChartTypes) {168+ t.Errorf("%d layouts for %d chart types", len(types), len(sheet.ChartTypes))169+ }170+}171+160172 func TestImageLeavesPlotToImage(t *testing.T) {161173 g := Draw(sheet.ChartColumn, budget, 44, 12, Options{Image: true})162174 for y := g.Plot.Min.Y; y < g.Plot.Max.Y; y++ {@@ -108,11 +108,6 @@ func (p *columnPlan) catCenter(i int) int {
108108 }109109 110110 func (p *columnPlan) draw(g *Grid, o Options) {111- if p.msg != "" {112- g.message(p.msg)113- return114- }115- g.Plot = p.plot116111 // Value axis with a label at each tick, up to the top one.117112 for y := p.axisRow - p.sc.cells(); y < p.axisRow; y++ {118113 g.set(p.axisX, y, "│", Axis)@@ -173,28 +168,12 @@ func (p *columnPlan) drawBars(g *Grid) {
173168 continue174169 }175170 x0, x1 := p.bar(i, j)176- top := p.sc.pos(v)177- lo, hi := min(zero, top), max(zero, top)178- for r := int(math.Floor(lo)); float64(r) < hi; r++ {179- cover := min(hi, float64(r+1)) - max(lo, float64(r))180- glyph := ""181- if v >= 0 {182- glyph = lowerEighths[int(math.Round(cover*8))]183- } else {184- switch {185- case cover >= 0.75:186- glyph = "█"187- case cover >= 0.25:188- glyph = "▀" // below the axis, bars fill from the top189- }190- }191- if glyph == "" || glyph == " " {192- continue193- }171+ // Below the axis, bars fill from the top.172+ barCells(zero, p.sc.pos(v), v >= 0, lowerEighths, "▀", func(r int, glyph string) {194173 for x := x0; x < x1; x++ {195174 g.set(x, p.axisRow-1-r, glyph, SeriesRole(j))196175 }197- }176+ })198177 }199178 }200179 }@@ -28,36 +28,16 @@ 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- var plot image.Rectangle32- var draw func(c *canvas)33- switch t {34- case sheet.ChartBar:35- p := newBarPlan(d, w, h)36- plot, draw = p.plot, p.image37- if p.msg != "" {38- return nil39- }40- case sheet.ChartPie:41- p := newPiePlan(d, w, h, o)42- plot, draw = p.disc, p.image43- if p.msg != "" {44- return nil45- }46- default:47- p := newColumnPlan(d, w, h, t == sheet.ChartLine)48- plot, draw = p.plot, p.image49- if p.msg != "" {50- return nil51- }52- }53- if plot.Empty() {31+ p := planFor(t, d, w, h, o)32+ plot := p.plotArea()33+ if p.note() != "" || plot.Empty() {5434 return nil5535 }5636 c := &canvas{5737 RGBA: image.NewRGBA(image.Rect(0, 0, plot.Dx()*cw, plot.Dy()*ch)),5838 cw: float64(cw), ch: float64(ch), pal: pal,5939 }60- draw(c)40+ p.image(c)6141 return c.RGBA6242 }6343 @@ -159,31 +139,13 @@ func (p *columnPlan) image(cv *canvas) {
159139 for j := 1; j <= p.sc.n; j++ {160140 cv.hline(int(math.Round(y(p.sc.tick(j)))), cv.pal.Grid)161141 }162- if p.sc.lo < 0 && !p.line {163- cv.hline(int(math.Round(y(0))), cv.pal.Grid)164- }165142 if p.line {166- width := max(cv.ch/8, 2)167- for j, s := range p.d.Series {168- var run [][2]float64169- flush := func() {170- if len(run) > 0 {171- cv.polyline(run, width, cv.pal.Series[j%Colors])172- }173- run = run[:0]174- }175- for i := 0; i < p.shown && i < len(s.Values); i++ {176- if math.IsNaN(s.Values[i]) {177- flush()178- continue179- }180- x := (float64(p.dotX(i)) + 0.5) * cv.cw / 2181- run = append(run, [2]float64{x, y(s.Values[i])})182- }183- flush()184- }143+ p.lineImage(cv, y)185144 return186145 }146+ if p.sc.lo < 0 {147+ cv.hline(int(math.Round(y(0))), cv.pal.Grid)148+ }187149 inset := min(cv.cw/6, 2)188150 for j, s := range p.d.Series {189151 for i := 0; i < p.shown && i < len(s.Values); i++ {@@ -199,6 +161,30 @@ func (p *columnPlan) image(cv *canvas) {
199161 }200162 }201163 164+// lineImage strokes each series as lines, broken where values are165+// missing; y maps a value to a pixel row.166+func (p *columnPlan) lineImage(cv *canvas, y func(float64) float64) {167+ width := max(cv.ch/8, 2)168+ for j, s := range p.d.Series {169+ var run [][2]float64170+ flush := func() {171+ if len(run) > 0 {172+ cv.polyline(run, width, cv.pal.Series[j%Colors])173+ }174+ run = run[:0]175+ }176+ for i := 0; i < p.shown && i < len(s.Values); i++ {177+ if math.IsNaN(s.Values[i]) {178+ flush()179+ continue180+ }181+ x := (float64(p.dotX(i)) + 0.5) * cv.cw / 2182+ run = append(run, [2]float64{x, y(s.Values[i])})183+ }184+ flush()185+ }186+}187+202188 // image draws horizontal bars.203189 func (p *barPlan) image(cv *canvas) {204190 x := func(v float64) float64 { return p.sc.pos(v) * cv.cw }@@ -93,11 +93,6 @@ func (p *piePlan) sliceAt(dx, dy float64) int {
9393 }9494 9595 func (p *piePlan) draw(g *Grid, o Options) {96- if p.msg != "" {97- g.message(p.msg)98- return99- }100- g.Plot = p.disc10196 p.drawLegend(g)10297 if o.Image {10398 return@@ -0,0 +1,52 @@
1+package chart2+3+import (4+ "image"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+)8+9+// A plan is a chart laid out in w by h cells: the layout Draw and Image10+// share, so text and image line up.11+type plan interface {12+ // note is a message shown instead of the chart, such as "No numbers13+ // to chart", or "" when there's a chart to draw.14+ note() string15+ // plotArea is the rectangle, in cells, that an image covers.16+ plotArea() image.Rectangle17+ // draw draws the chart as text; with o.Image, all but the plot area.18+ draw(g *Grid, o Options)19+ // image draws the plot area, one cell per cv.cw by cv.ch pixels.20+ image(cv *canvas)21+}22+23+// A layout lays out data as one type of chart.24+type layout func(d sheet.ChartData, w, h int, o Options) plan25+26+// types are the chart types, each a layout whose plan draws it as text27+// and as an image. The types themselves, their names and the order the28+// chart editor offers them, are in the sheet package, since charts are29+// 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+}36+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]41+ if !ok {42+ lay = types[sheet.ChartColumn]43+ }44+ return lay(d, w, h, o)45+}46+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 }