1package chart23import (4 "fmt"5 "html"6 "image"7 "image/color"8 "math"9 "strings"1011 "src.rest/cat/012/internal/sheet"12)1314// SVG charts are the text chart and the image in one vector drawing,15// for the HTML export: the axes, labels and legend Draw lays out, as16// text on a grid of SVGCellW by SVGCellH pixels, and the plot area as17// the shapes Image paints, as SVG shapes rather than pixels. Colors are18// classes the page styles, so a chart follows the page's light or dark19// palette:20//21// ct-ax, ct-lb, ct-mu text of the axes, the labels and messages22// ct-s0 .. ct-s5 text in a series' color23// cf-0 .. cf-5 shapes filled in a series' color24// cs-0 .. cs-5 lines stroked in a series' color25// cs-gl gridlines26// cs-box axes, in their text class's color27// cs-gap the gaps between a pie's slices28const (29 SVGCellW = 930 SVGCellH = 2131)3233// vecGrid and vecSeries stand for the colors in the palette a vector34// canvas paints with, so each shape finds its class again: the red35// channel is the series and the blue one says it's a gridline.36var vecGrid = color.RGBA{B: 1, A: 255}3738func vecSeries(i int) color.RGBA { return color.RGBA{R: uint8(i), A: 255} }3940// SVG draws d as a chart of type t in w by h cells, as an svg element41// of w*SVGCellW by h*SVGCellH pixels whose title is title.42func SVG(t sheet.ChartType, d sheet.ChartData, w, h int, o Options, title string) string {43 w, h = max(w, 0), max(h, 0)44 o.Image, o.CellW, o.CellH = true, SVGCellW, SVGCellH45 g := Draw(t, d, w, h, o)46 var b strings.Builder47 fmt.Fprintf(&b, `<svg xmlns="http://www.w3.org/2000/svg" class="chart" width="%d" height="%d" viewBox="0 0 %d %d" role="img">`,48 w*SVGCellW, h*SVGCellH, w*SVGCellW, h*SVGCellH)49 if title != "" {50 b.WriteString("<title>" + html.EscapeString(title) + "</title>")51 }52 if !g.Plot.Empty() {53 b.WriteString(plotSVG(t, d, w, h, o, g.Plot))54 }55 gridText(&b, g)56 b.WriteString("</svg>")57 return b.String()58}5960// plotSVG is the plot area's shapes, placed over its cells.61func plotSVG(t sheet.ChartType, d sheet.ChartData, w, h int, o Options, plot image.Rectangle) string {62 p, _ := planFor(t, d, w, h, o)63 pal := Palette{Grid: vecGrid}64 for i := range pal.Series {65 pal.Series[i] = vecSeries(i)66 }67 v := &vector{}68 cv := &canvas{69 RGBA: &image.RGBA{Rect: image.Rect(0, 0, plot.Dx()*SVGCellW, plot.Dy()*SVGCellH)},70 cw: SVGCellW, ch: SVGCellH, pal: pal, vec: v,71 }72 p.image(cv)73 v.flush()74 return fmt.Sprintf(`<g transform="translate(%d %d)">%s</g>`, plot.Min.X*SVGCellW, plot.Min.Y*SVGCellH, v.b.String())75}7677// gridText writes the text chart's cells, a run of one role at a time.78func gridText(b *strings.Builder, g *Grid) {79 for y := range g.H {80 x := 081 for x < g.W {82 c := g.At(x, y)83 if c.Text == " " || c.Text == "" || c.Fg == None {84 x++85 continue86 }87 if arms, ok := boxArms[c.Text]; ok {88 boxLines(b, x, y, arms, textClass(c.Fg))89 x++90 continue91 }92 start, role := x, c.Fg93 var run strings.Builder94 for x < g.W && g.At(x, y).Fg == role && !isBox(g.At(x, y).Text) {95 run.WriteString(g.At(x, y).Text)96 x++97 }98 fmt.Fprintf(b, `<text x="%d" y="%.1f" class="%s" xml:space="preserve">%s</text>`, start*SVGCellW,99 float64(y)*SVGCellH+SVGCellH*0.72, textClass(role), html.EscapeString(strings.TrimRight(run.String(), " ")))100 }101 }102}103104// boxArms are the box-drawing characters the axes use, as the arms105// each draws from the middle of its cell: up, down, left, right. They106// are drawn as lines, which meet whatever font the page has.107var boxArms = map[string][4]bool{108 "│": {true, true, false, false}, "─": {false, false, true, true},109 "└": {true, false, false, true}, "┘": {true, false, true, false},110 "┌": {false, true, false, true}, "┐": {false, true, true, false},111 "├": {true, true, false, true}, "┤": {true, true, true, false},112 "┬": {false, true, true, true}, "┴": {true, false, true, true},113 "┼": {true, true, true, true},114}115116func isBox(s string) bool { _, ok := boxArms[s]; return ok }117118// boxLines draws a box-drawing character's arms in cell x, y.119func boxLines(b *strings.Builder, x, y int, arms [4]bool, class string) {120 cx, cy := float64(x)*SVGCellW+SVGCellW/2, float64(y)*SVGCellH+SVGCellH/2121 ends := [4][2]float64{{cx, float64(y) * SVGCellH}, {cx, float64(y+1) * SVGCellH},122 {float64(x) * SVGCellW, cy}, {float64(x+1) * SVGCellW, cy}}123 for i, on := range arms {124 if on {125 fmt.Fprintf(b, `<line x1="%s" y1="%s" x2="%s" y2="%s" class="%s cs-box"/>`, num(cx), num(cy), num(ends[i][0]), num(ends[i][1]), class)126 }127 }128}129130func textClass(r Role) string {131 switch {132 case r == Axis, r == Gridline:133 return "ct-ax"134 case r == Label:135 return "ct-lb"136 case r >= Series:137 return fmt.Sprintf("ct-s%d", int(r-Series)%Colors)138 }139 return "ct-mu"140}141142// vector collects a canvas's shapes as SVG. Area charts paint a pixel143// column at a time; those columns are joined into one polygon per color144// for as long as they run on without a gap.145type vector struct {146 b strings.Builder147 strips map[color.RGBA]*strip148 order []color.RGBA149}150151// strip is a run of one-pixel columns of one color: their tops and152// bottoms, left to right.153type strip struct {154 x0, x1 float64155 top, below [][2]float64156}157158func paint(c color.RGBA) (series int, grid bool, opacity string) {159 if c.A < 255 {160 opacity = fmt.Sprintf(` fill-opacity="%.2f"`, float64(c.A)/255)161 }162 return int(c.R) % Colors, c.B == 1, opacity163}164165func (v *vector) rect(x0, y0, x1, y1 float64, c color.RGBA) {166 if x1 <= x0 || y1 <= y0 {167 return168 }169 if x1-x0 <= 1.001 {170 v.column(x0, y0, x1, y1, c)171 return172 }173 v.flush()174 s, _, op := paint(c)175 fmt.Fprintf(&v.b, `<rect x="%s" y="%s" width="%s" height="%s" class="cf-%d"%s/>`, num(x0), num(y0), num(x1-x0), num(y1-y0), s, op)176}177178// column adds a pixel column to its color's strip, starting a new strip179// when it doesn't continue the last one.180func (v *vector) column(x0, y0, x1, y1 float64, c color.RGBA) {181 if v.strips == nil {182 v.strips = map[color.RGBA]*strip{}183 }184 st := v.strips[c]185 if st != nil && math.Abs(st.x1-x0) > 0.001 {186 v.emitStrip(c, st)187 st = nil188 }189 if st == nil {190 st = &strip{x0: x0}191 v.strips[c] = st192 v.order = append(v.order, c)193 }194 st.x1 = x1195 st.top = append(st.top, [2]float64{x0, y0}, [2]float64{x1, y0})196 st.below = append(st.below, [2]float64{x0, y1}, [2]float64{x1, y1})197}198199// flush writes the strips still open, in the order they started, so200// they're under whatever is drawn next.201func (v *vector) flush() {202 for _, c := range v.order {203 if st := v.strips[c]; st != nil {204 v.emitStrip(c, st)205 }206 }207 v.order = v.order[:0]208}209210func (v *vector) emitStrip(c color.RGBA, st *strip) {211 delete(v.strips, c)212 pts := simplify(st.top)213 below := simplify(st.below)214 for i := len(below) - 1; i >= 0; i-- {215 pts = append(pts, below[i])216 }217 s, _, op := paint(c)218 fmt.Fprintf(&v.b, `<polygon points="%s" class="cf-%d"%s/>`, points(pts), s, op)219}220221func (v *vector) line(x0, y0, x1, y1 float64, c color.RGBA) {222 v.flush()223 class := "cs-gl"224 if s, grid, _ := paint(c); !grid {225 class = fmt.Sprintf("cs-%d", s)226 }227 fmt.Fprintf(&v.b, `<line x1="%s" y1="%s" x2="%s" y2="%s" class="%s"/>`, num(x0), num(y0), num(x1), num(y1), class)228}229230func (v *vector) polyline(pts [][2]float64, width float64, c color.RGBA) {231 v.flush()232 s, _, _ := paint(c)233 if len(pts) == 1 {234 fmt.Fprintf(&v.b, `<circle cx="%s" cy="%s" r="%s" class="cf-%d"/>`, num(pts[0][0]), num(pts[0][1]), num(width/2), s)235 return236 }237 fmt.Fprintf(&v.b, `<polyline points="%s" fill="none" stroke-width="%s" stroke-linecap="round" stroke-linejoin="round" class="cs-%d"/>`,238 points(pts), num(width), s)239}240241func (v *vector) disc(cx, cy, r float64, c color.RGBA) {242 v.flush()243 s, _, _ := paint(c)244 fmt.Fprintf(&v.b, `<circle cx="%s" cy="%s" r="%s" class="cf-%d"/>`, num(cx), num(cy), num(r), s)245}246247// vector draws the pie as one path a slice, with the page's background248// stroked between them as the image leaves a gap.249func (p *piePlan) vector(cv *canvas) {250 v := cv.vec251 b := cv.Rect252 cx, cy := float64(b.Dx())/2, float64(b.Dy())/2253 r := min(cx, cy) - 1254 if len(p.slices) == 1 {255 fmt.Fprintf(&v.b, `<circle cx="%s" cy="%s" r="%s" class="cf-0"/>`, num(cx), num(cy), num(r))256 return257 }258 at := func(f float64) string {259 a := f * 2 * math.Pi260 return num(cx+r*math.Sin(a)) + " " + num(cy-r*math.Cos(a))261 }262 for i, s := range p.slices {263 if s.to <= s.from {264 continue265 }266 large := 0267 if s.to-s.from > 0.5 {268 large = 1269 }270 fmt.Fprintf(&v.b, `<path d="M%s %s L%s A%s %s 0 %d 1 %s Z" class="cf-%d cs-gap"/>`,271 num(cx), num(cy), at(s.from), num(r), num(r), large, at(s.to), i%Colors)272 }273}274275// simplify drops the points of a path that lie on the line between276// their neighbors.277func simplify(pts [][2]float64) [][2]float64 {278 if len(pts) < 3 {279 return pts280 }281 out := [][2]float64{pts[0]}282 for i := 1; i < len(pts)-1; i++ {283 a, b, c := out[len(out)-1], pts[i], pts[i+1]284 if math.Abs((b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])) > 0.25 {285 out = append(out, b)286 }287 }288 return append(out, pts[len(pts)-1])289}290291func points(pts [][2]float64) string {292 var b strings.Builder293 for i, p := range pts {294 if i > 0 {295 b.WriteByte(' ')296 }297 b.WriteString(num(p[0]) + "," + num(p[1]))298 }299 return b.String()300}301302// num writes a coordinate to a tenth of a pixel, without trailing303// zeros.304func num(f float64) string {305 s := fmt.Sprintf("%.1f", f)306 s = strings.TrimSuffix(s, ".0")307 if s == "-0" {308 return "0"309 }310 return s311}