cat / 012
clone

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

main
012 / internal / chart / image.go
image.go 9.4K
1package chart23import (4 "image"5 "image/color"6 "math"78 "src.rest/cat/012/internal/sheet"9)1011// Palette holds the colors an image draws with: the terminal's colors for12// the series roles, so the image matches the text legend, and a faint13// color for gridlines.14type Palette struct {15 Series [Colors]color.RGBA16 Grid color.RGBA17}1819// Image draws the plot area of the chart Draw lays out with the same20// arguments, cw by ch pixels per cell, on a transparent background. It21// returns nil when there's nothing to plot.22func Image(t sheet.ChartType, d sheet.ChartData, w, h int, o Options, pal Palette) *image.RGBA {23 cw, ch := o.CellW, o.CellH24 if cw <= 0 || ch <= 0 {25 cw, ch = 10, 2026 o.CellW, o.CellH = cw, ch27 }28 if w < 8 || h < 3 {29 return nil30 }31 p, _ := planFor(t, d, w, h, o)32 plot := p.plotArea()33 if p.note() != "" || plot.Empty() {34 return nil35 }36 c := &canvas{37 RGBA: image.NewRGBA(image.Rect(0, 0, plot.Dx()*cw, plot.Dy()*ch)),38 cw: float64(cw), ch: float64(ch), pal: pal,39 }40 p.image(c)41 return c.RGBA42}4344// canvas is an image with blending and the size of a cell.45type canvas struct {46 *image.RGBA47 cw, ch float6448 pal Palette49 // vec, when set, takes the shapes as SVG instead of pixels, and50 // RGBA has no pixels, only its bounds (see SVG).51 vec *vector52}5354// blend paints c over the pixel at x, y with coverage a.55func (cv *canvas) blend(x, y int, c color.RGBA, a float64) {56 if !(image.Point{x, y}.In(cv.Rect)) || a <= 0 {57 return58 }59 a = min(a, 1) * float64(c.A) / 25560 i := cv.PixOffset(x, y)61 p := cv.Pix[i : i+4 : i+4]62 // Premultiplied source over.63 for k, v := range []uint8{c.R, c.G, c.B} {64 p[k] = uint8(math.Round(float64(v)*a + float64(p[k])*(1-a)))65 }66 p[3] = uint8(math.Round(255*a + float64(p[3])*(1-a)))67}6869// rect fills x0..x1, y0..y1 in pixels, antialiasing fractional edges.70func (cv *canvas) rect(x0, y0, x1, y1 float64, c color.RGBA) {71 if cv.vec != nil {72 cv.vec.rect(x0, y0, x1, y1, c)73 return74 }75 for y := int(math.Floor(y0)); float64(y) < y1; y++ {76 cy := min(y1, float64(y+1)) - max(y0, float64(y))77 for x := int(math.Floor(x0)); float64(x) < x1; x++ {78 cx := min(x1, float64(x+1)) - max(x0, float64(x))79 cv.blend(x, y, c, cx*cy)80 }81 }82}8384// hline draws a one pixel horizontal line at y.85func (cv *canvas) hline(y int, c color.RGBA) {86 if cv.vec != nil {87 cv.vec.line(float64(cv.Rect.Min.X), float64(y)+0.5, float64(cv.Rect.Max.X), float64(y)+0.5, c)88 return89 }90 for x := cv.Rect.Min.X; x < cv.Rect.Max.X; x++ {91 cv.blend(x, y, c, 1)92 }93}9495// vline draws a one pixel vertical line at x.96func (cv *canvas) vline(x int, c color.RGBA) {97 if cv.vec != nil {98 cv.vec.line(float64(x)+0.5, float64(cv.Rect.Min.Y), float64(x)+0.5, float64(cv.Rect.Max.Y), c)99 return100 }101 for y := cv.Rect.Min.Y; y < cv.Rect.Max.Y; y++ {102 cv.blend(x, y, c, 1)103 }104}105106// polyline strokes the points with a round-capped line width px wide.107// Coverage is kept per pixel, so joints aren't painted twice.108func (cv *canvas) polyline(pts [][2]float64, width float64, c color.RGBA) {109 if cv.vec != nil {110 cv.vec.polyline(pts, width, c)111 return112 }113 b := cv.Rect114 cover := make([]float64, b.Dx()*b.Dy())115 r := width / 2116 seg := func(ax, ay, bx, by float64) {117 x0, x1 := int(math.Floor(min(ax, bx)-r-1)), int(math.Ceil(max(ax, bx)+r+1))118 y0, y1 := int(math.Floor(min(ay, by)-r-1)), int(math.Ceil(max(ay, by)+r+1))119 for y := max(y0, b.Min.Y); y < min(y1, b.Max.Y); y++ {120 for x := max(x0, b.Min.X); x < min(x1, b.Max.X); x++ {121 d := segDist(float64(x)+0.5, float64(y)+0.5, ax, ay, bx, by)122 if a := r + 0.5 - d; a > 0 {123 i := (y-b.Min.Y)*b.Dx() + (x - b.Min.X)124 cover[i] = max(cover[i], min(a, 1))125 }126 }127 }128 }129 for i := range pts {130 if i == 0 {131 seg(pts[0][0], pts[0][1], pts[0][0], pts[0][1])132 continue133 }134 seg(pts[i-1][0], pts[i-1][1], pts[i][0], pts[i][1])135 }136 for i, a := range cover {137 if a > 0 {138 cv.blend(b.Min.X+i%b.Dx(), b.Min.Y+i/b.Dx(), c, a)139 }140 }141}142143// segDist is the distance from p to the segment a-b.144func segDist(px, py, ax, ay, bx, by float64) float64 {145 dx, dy := bx-ax, by-ay146 t := 0.0147 if l := dx*dx + dy*dy; l > 0 {148 t = min(max(((px-ax)*dx+(py-ay)*dy)/l, 0), 1)149 }150 return math.Hypot(px-(ax+t*dx), py-(ay+t*dy))151}152153// image draws columns, lines or areas. Values map exactly, while154// gridlines sit on the cell boundaries the text ticks label.155func (p *columnPlan) image(cv *canvas) {156 bottom := float64(p.plot.Dy()) * cv.ch157 y := func(v float64) float64 { return bottom - p.sc.at(v)*cv.ch }158 if p.grid {159 for j := 1; j <= p.sc.n; j++ {160 cv.hline(int(math.Round(y(p.sc.tick(j)))), cv.pal.Grid)161 }162 }163 if p.kind != columnLine && p.sc.crossesZero() {164 cv.hline(int(math.Round(y(0))), cv.pal.Grid)165 }166 switch p.kind {167 case columnLine:168 p.lineImage(cv, y)169 return170 case columnArea:171 p.areaImage(cv, y)172 return173 }174 inset := min(cv.cw/6, 2)175 var piles []span176 for i := 0; i < p.shown; i++ {177 piles = p.piles(i, piles)178 for _, s := range piles {179 x0, x1 := p.bar(i, s.j)180 px0 := float64(x0-p.plot.Min.X)*cv.cw + inset181 px1 := float64(x1-p.plot.Min.X)*cv.cw - inset182 cv.rect(px0, y(s.hi), px1, y(s.lo), cv.pal.Series[s.j%Colors])183 }184 }185}186187// lineImage strokes each series as lines, broken where values are188// missing; y maps a value to a pixel row.189func (p *columnPlan) lineImage(cv *canvas, y func(float64) float64) {190 width := max(cv.ch/8, 2)191 for j, s := range p.d.Series {192 var run [][2]float64193 flush := func() {194 if len(run) > 0 {195 cv.polyline(run, width, cv.pal.Series[j%Colors])196 }197 run = run[:0]198 }199 for i := 0; i < p.shown && i < len(s.Values); i++ {200 if math.IsNaN(s.Values[i]) {201 flush()202 continue203 }204 x := (float64(p.dotX(i)) + 0.5) * cv.cw / 2205 run = append(run, [2]float64{x, y(s.Values[i])})206 }207 flush()208 }209}210211// image draws horizontal bars.212func (p *barPlan) image(cv *canvas) {213 x := func(v float64) float64 { return p.sc.at(v) * cv.cw }214 if p.grid {215 for j := 1; j <= p.sc.n; j++ {216 cv.vline(int(math.Round(x(p.sc.tick(j))))-1, cv.pal.Grid)217 }218 }219 if p.sc.crossesZero() {220 cv.vline(int(math.Round(x(0))), cv.pal.Grid)221 }222 inset := min(cv.ch/8, 2)223 var piles []span224 for i := 0; i < p.shown; i++ {225 piles = p.piles(i, piles)226 for _, s := range piles {227 row := float64(p.barRow(i, s.j))228 cv.rect(x(s.lo), row*cv.ch+inset, x(s.hi), (row+1)*cv.ch-inset, cv.pal.Series[s.j%Colors])229 }230 }231}232233// image draws the pie, supersampled, with a thin gap between slices.234func (p *piePlan) image(cv *canvas) {235 if cv.vec != nil {236 p.vector(cv)237 return238 }239 b := cv.Rect240 cx, cy := float64(b.Dx())/2, float64(b.Dy())/2241 pr := newPieRaster(p, cx, cy, min(cx, cy)-1, 1)242 for y := range b.Dy() {243 for x := range b.Dx() {244 if i, ok := pr.solid(float64(x)+0.5-cx, float64(y)+0.5-cy); ok {245 if i >= 0 {246 cv.blend(x, y, cv.pal.Series[i%Colors], 1)247 }248 continue249 }250 for i, k := range pr.coverage(x, y) {251 if k > 0 {252 cv.blend(x, y, cv.pal.Series[i%Colors], k/(pieSamples*pieSamples))253 }254 }255 }256 }257}258259// pieSamples is the samples per pixel along each axis.260const pieSamples = 4261262// halfDiagonal is a little over the distance from a pixel's center to263// its corners.264const halfDiagonal = 0.7072265266// pieRaster decides which slices cover each pixel of a pie of radius r267// pixels centered at cx, cy, leaving gap pixels between slices. Only268// pixels near an edge or the rim are supersampled; any other lies wholly269// in one slice, or outside the disc.270type pieRaster struct {271 p *piePlan272 cx, cy float64273 r, gap float64274 // edges are unit vectors along each slice's straight edges, from the275 // center, with y down.276 edges [][2][2]float64277 counts []float64278}279280func newPieRaster(p *piePlan, cx, cy, r, gap float64) *pieRaster {281 pr := &pieRaster{p: p, cx: cx, cy: cy, r: r, gap: gap, counts: make([]float64, len(p.slices))}282 pr.edges = make([][2][2]float64, len(p.slices))283 for i, s := range p.slices {284 for k, f := range []float64{s.from, s.to} {285 a := f * 2 * math.Pi286 pr.edges[i][k] = [2]float64{math.Sin(a), -math.Cos(a)}287 }288 }289 return pr290}291292// solid reports the slice covering all of the pixel whose center is px,293// py pixels from the pie's center, or -1 when the pixel is wholly294// outside the disc; false means the pixel needs supersampling.295func (pr *pieRaster) solid(px, py float64) (int, bool) {296 d := math.Hypot(px, py)297 switch {298 case d-halfDiagonal > pr.r:299 return -1, true300 case d+halfDiagonal >= pr.r:301 return 0, false302 }303 i := pr.p.sliceAt(px/pr.r, py/pr.r)304 if len(pr.p.slices) == 1 {305 return i, true306 }307 for _, e := range pr.edges[i] {308 // The distance to the edge, a ray from the center.309 dist := d310 if px*e[0]+py*e[1] > 0 {311 dist = math.Abs(px*e[1] - py*e[0])312 }313 if dist <= pr.gap/2+halfDiagonal {314 return 0, false315 }316 }317 return i, true318}319320// coverage returns how many of pixel x, y's samples fall in each slice.321func (pr *pieRaster) coverage(x, y int) []float64 {322 const n = pieSamples323 clear(pr.counts)324 for sy := range n {325 for sx := range n {326 dx := (float64(x) + (float64(sx)+0.5)/n - pr.cx) / pr.r327 dy := (float64(y) + (float64(sy)+0.5)/n - pr.cy) / pr.r328 i := pr.p.sliceAt(dx, dy)329 if i < 0 || len(pr.p.slices) > 1 && pr.nearEdge(i, dx, dy) {330 continue331 }332 pr.counts[i]++333 }334 }335 return pr.counts336}337338// nearEdge reports whether a point of slice i, as a fraction of the339// radius from the center, lies within half the gap of the slice's340// straight edges.341func (pr *pieRaster) nearEdge(i int, dx, dy float64) bool {342 gap := pr.gap / pr.r343 for _, e := range pr.edges[i] {344 along := dx*e[0] + dy*e[1]345 if along > 0 && math.Abs(dx*e[1]-dy*e[0]) < gap/2 {346 return true347 }348 }349 return false350}