- Support 9 chart types: line, bar, pie, scatter, bubble, donut, mixed, polar, radar - Multi-format output: ANSI, SVG, PNG, Markdown - Go + Fiber + gonum/plot - Docker support - Morandi color palette
621 lines
17 KiB
Go
621 lines
17 KiB
Go
package renderer
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"math"
|
|
|
|
"github.com/picoclaw/chart/internal/types"
|
|
"gonum.org/v1/plot"
|
|
"gonum.org/v1/plot/plotter"
|
|
"gonum.org/v1/plot/vg"
|
|
"gonum.org/v1/plot/vg/draw"
|
|
"gonum.org/v1/plot/vg/vgsvg"
|
|
)
|
|
|
|
type SVGRenderer struct {
|
|
width vg.Length
|
|
height vg.Length
|
|
}
|
|
|
|
func NewSVGRenderer() *SVGRenderer {
|
|
return &SVGRenderer{
|
|
width: 600,
|
|
height: 400,
|
|
}
|
|
}
|
|
|
|
func (r *SVGRenderer) Render(chart *types.Chart) ([]byte, error) {
|
|
p := plot.New()
|
|
|
|
if chart.Title != "" {
|
|
p.Title.Text = chart.Title
|
|
}
|
|
|
|
p.X.Label.Text = ""
|
|
p.Y.Label.Text = ""
|
|
|
|
width := r.width
|
|
height := r.height
|
|
if chart.Data.Options != nil {
|
|
if chart.Data.Options.Width > 0 {
|
|
width = vg.Length(chart.Data.Options.Width)
|
|
}
|
|
if chart.Data.Options.Height > 0 {
|
|
height = vg.Length(chart.Data.Options.Height)
|
|
}
|
|
}
|
|
|
|
switch chart.Type {
|
|
case types.ChartTypeLine:
|
|
r.addLineChart(p, &chart.Data)
|
|
case types.ChartTypeBar:
|
|
r.addBarChart(p, &chart.Data)
|
|
case types.ChartTypePie:
|
|
return r.renderPieChart(chart, false)
|
|
case types.ChartTypeScatter:
|
|
r.addScatterChart(p, &chart.Data)
|
|
case types.ChartTypeBubble:
|
|
return r.renderBubbleChart(chart)
|
|
case types.ChartTypeDonut:
|
|
return r.renderPieChart(chart, true)
|
|
case types.ChartTypeMixed:
|
|
return r.renderMixedChart(chart)
|
|
case types.ChartTypePolar:
|
|
return r.renderPolarChart(chart)
|
|
case types.ChartTypeRadar:
|
|
return r.renderRadarChart(chart)
|
|
default:
|
|
r.addLineChart(p, &chart.Data)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
canvas := vgsvg.New(width, height)
|
|
p.Draw(draw.New(canvas))
|
|
|
|
_, err := canvas.WriteTo(&buf)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to write svg: %w", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func (r *SVGRenderer) addLineChart(p *plot.Plot, data *types.ChartData) {
|
|
for i, dataset := range data.Datasets {
|
|
pts := make(plotter.XYs, len(dataset.Values))
|
|
for j, v := range dataset.Values {
|
|
pts[j].X = float64(j)
|
|
pts[j].Y = v
|
|
}
|
|
|
|
line, err := plotter.NewLine(pts)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
colorStr := getColor(i)
|
|
if dataset.Color != "" {
|
|
colorStr = dataset.Color
|
|
}
|
|
line.Color = parseColor(colorStr)
|
|
|
|
p.Add(line)
|
|
}
|
|
|
|
if len(data.Labels) > 0 {
|
|
p.NominalX(data.Labels...)
|
|
}
|
|
}
|
|
|
|
func (r *SVGRenderer) addBarChart(p *plot.Plot, data *types.ChartData) {
|
|
if len(data.Datasets) == 0 {
|
|
return
|
|
}
|
|
|
|
dataset := data.Datasets[0]
|
|
labels := data.Labels
|
|
if len(labels) == 0 {
|
|
labels = make([]string, len(dataset.Values))
|
|
for i := range labels {
|
|
labels[i] = fmt.Sprintf("%d", i)
|
|
}
|
|
}
|
|
|
|
barChart, err := plotter.NewBarChart(plotter.Values(dataset.Values), vg.Points(20))
|
|
if err != nil {
|
|
return
|
|
}
|
|
barChart.Color = parseColor(getColor(0))
|
|
if len(data.Datasets) > 0 && data.Datasets[0].Color != "" {
|
|
barChart.Color = parseColor(data.Datasets[0].Color)
|
|
}
|
|
|
|
p.Add(barChart)
|
|
p.NominalX(labels...)
|
|
}
|
|
|
|
func (r *SVGRenderer) renderPieChart(chart *types.Chart, isDonut bool) ([]byte, error) {
|
|
if len(chart.Data.Datasets) == 0 || len(chart.Data.Datasets[0].Values) == 0 {
|
|
return []byte{}, nil
|
|
}
|
|
|
|
dataset := chart.Data.Datasets[0]
|
|
values := dataset.Values
|
|
labels := chart.Data.Labels
|
|
|
|
total := 0.0
|
|
for _, v := range values {
|
|
total += v
|
|
}
|
|
|
|
var sb bytes.Buffer
|
|
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
|
|
|
|
if chart.Title != "" {
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"50%\" y=\"30\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", escapeXML(chart.Title)))
|
|
}
|
|
|
|
cx, cy := float64(r.width)/2, float64(r.height)/2-20
|
|
radius := math.Min(cx, cy) * 0.7
|
|
innerRadius := radius * 0.5
|
|
|
|
if !isDonut {
|
|
innerRadius = 0
|
|
}
|
|
|
|
startAngle := -90.0
|
|
legendY := float64(r.height) - 80
|
|
|
|
for i, v := range values {
|
|
label := fmt.Sprintf("Item %d", i+1)
|
|
if i < len(labels) {
|
|
label = labels[i]
|
|
}
|
|
|
|
percentage := v / total * 100
|
|
angle := v / total * 360
|
|
|
|
color := getColor(i)
|
|
if i < len(dataset.Color) && dataset.Color != "" {
|
|
color = dataset.Color
|
|
}
|
|
|
|
endAngle := startAngle + angle
|
|
largeArc := 0
|
|
if angle > 180 {
|
|
largeArc = 1
|
|
}
|
|
|
|
if isDonut {
|
|
x1 := cx + radius*math.Cos(startAngle*math.Pi/180)
|
|
y1 := cy + radius*math.Sin(startAngle*math.Pi/180)
|
|
x2 := cx + radius*math.Cos(endAngle*math.Pi/180)
|
|
y2 := cy + radius*math.Sin(endAngle*math.Pi/180)
|
|
x3 := cx + innerRadius*math.Cos(endAngle*math.Pi/180)
|
|
y3 := cy + innerRadius*math.Sin(endAngle*math.Pi/180)
|
|
x4 := cx + innerRadius*math.Cos(startAngle*math.Pi/180)
|
|
y4 := cy + innerRadius*math.Sin(startAngle*math.Pi/180)
|
|
|
|
path := fmt.Sprintf("M %f,%f L %f,%f A %f,%f 0 %d,1 %f,%f L %f,%f A %f,%f 0 %d,0 %f,%f Z",
|
|
x4, y4, x1, y1, radius, radius, largeArc, x2, y2, x3, y3, innerRadius, innerRadius, largeArc, x4, y4)
|
|
sb.WriteString(fmt.Sprintf("\n<path d=\"%s\" fill=\"%s\" stroke=\"white\" stroke-width=\"2\"/>", path, color))
|
|
} else {
|
|
x1 := cx + radius*math.Cos(startAngle*math.Pi/180)
|
|
y1 := cy + radius*math.Sin(startAngle*math.Pi/180)
|
|
x2 := cx + radius*math.Cos(endAngle*math.Pi/180)
|
|
y2 := cy + radius*math.Sin(endAngle*math.Pi/180)
|
|
|
|
path := fmt.Sprintf("M %f,%f L %f,%f A %f,%f 0 %d,1 %f,%f Z",
|
|
cx, cy, x1, y1, radius, radius, largeArc, x2, y2)
|
|
sb.WriteString(fmt.Sprintf("\n<path d=\"%s\" fill=\"%s\" stroke=\"white\" stroke-width=\"2\"/>", path, color))
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"%f\" y=\"%f\" text-anchor=\"middle\" font-size=\"12\" fill=\"white\">%.1f%%</text>",
|
|
cx+radius*0.5*math.Cos((startAngle+angle/2)*math.Pi/180),
|
|
cy+radius*0.5*math.Sin((startAngle+angle/2)*math.Pi/180),
|
|
percentage))
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("\n<rect x=\"%f\" y=\"%f\" width=\"15\" height=\"15\" fill=\"%s\"/>", 20, legendY+float64(i)*25, color))
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"40\" y=\"%f\" font-size=\"12\">%s: %.2f (%.1f%%)</text>", legendY+float64(i)*25+12, escapeXML(label), v, percentage))
|
|
|
|
startAngle = endAngle
|
|
}
|
|
|
|
sb.WriteString("\n</svg>")
|
|
return sb.Bytes(), nil
|
|
}
|
|
|
|
func (r *SVGRenderer) addScatterChart(p *plot.Plot, data *types.ChartData) {
|
|
for i, dataset := range data.Datasets {
|
|
pts := make(plotter.XYs, len(dataset.Values))
|
|
for j, v := range dataset.Values {
|
|
pts[j].X = float64(j)
|
|
pts[j].Y = v
|
|
}
|
|
|
|
scatter, err := plotter.NewScatter(pts)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
colorStr := getColor(i)
|
|
if dataset.Color != "" {
|
|
colorStr = dataset.Color
|
|
}
|
|
scatter.Color = parseColor(colorStr)
|
|
scatter.GlyphStyle.Radius = vg.Points(4)
|
|
|
|
p.Add(scatter)
|
|
}
|
|
|
|
if len(data.Labels) > 0 {
|
|
p.NominalX(data.Labels...)
|
|
}
|
|
}
|
|
|
|
func escapeXML(s string) string {
|
|
var result bytes.Buffer
|
|
for _, c := range s {
|
|
switch c {
|
|
case '<':
|
|
result.WriteString("<")
|
|
case '>':
|
|
result.WriteString(">")
|
|
case '&':
|
|
result.WriteString("&")
|
|
case '"':
|
|
result.WriteString(""")
|
|
case '\'':
|
|
result.WriteString("'")
|
|
default:
|
|
result.WriteRune(c)
|
|
}
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
func (r *SVGRenderer) renderBubbleChart(chart *types.Chart) ([]byte, error) {
|
|
if len(chart.Data.Datasets) == 0 {
|
|
return []byte{}, nil
|
|
}
|
|
|
|
var sb bytes.Buffer
|
|
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
|
|
|
|
if chart.Title != "" {
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"50%%\" y=\"30\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", escapeXML(chart.Title)))
|
|
}
|
|
|
|
padding := 60.0
|
|
chartWidth := float64(r.width) - 2*padding
|
|
chartHeight := float64(r.height) - 2*padding
|
|
|
|
maxValue := 0.0
|
|
maxBubble := 0.0
|
|
for _, dataset := range chart.Data.Datasets {
|
|
for i, v := range dataset.Values {
|
|
if v > maxValue {
|
|
maxValue = v
|
|
}
|
|
if i < len(dataset.Values) && float64(i+1)*50 > maxBubble {
|
|
maxBubble = float64(i+1) * 50
|
|
}
|
|
}
|
|
}
|
|
if maxValue == 0 {
|
|
maxValue = 100
|
|
}
|
|
if maxBubble == 0 {
|
|
maxBubble = 100
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("\n<rect x=\"%f\" y=\"%f\" width=\"%f\" height=\"%f\" fill=\"#f8f9fa\" stroke=\"#ddd\"/>", padding, padding, chartWidth, chartHeight))
|
|
|
|
for i, dataset := range chart.Data.Datasets {
|
|
color := getColor(i)
|
|
if dataset.Color != "" {
|
|
color = dataset.Color
|
|
}
|
|
|
|
for j, v := range dataset.Values {
|
|
x := padding + (float64(j)/float64(len(dataset.Values)))*chartWidth
|
|
y := padding + chartHeight - (v/maxValue)*chartHeight
|
|
bubbleRadius := 10 + (v/maxValue)*30
|
|
|
|
sb.WriteString(fmt.Sprintf("\n<circle cx=\"%f\" cy=\"%f\" r=\"%f\" fill=\"%s\" opacity=\"0.6\" stroke=\"%s\" stroke-width=\"2\"/>",
|
|
x, y, bubbleRadius, color, color))
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"%f\" y=\"%f\" text-anchor=\"middle\" font-size=\"10\" fill=\"#333\">%.0f</text>",
|
|
x, y+4, v))
|
|
}
|
|
}
|
|
|
|
labels := chart.Data.Labels
|
|
if len(labels) > 0 {
|
|
labelSpacing := chartWidth / float64(len(labels)-1)
|
|
for i, label := range labels {
|
|
x := padding + float64(i)*labelSpacing
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"%f\" y=\"%f\" text-anchor=\"middle\" font-size=\"11\" fill=\"#666\">%s</text>",
|
|
x, float64(r.height)-padding+20, escapeXML(label)))
|
|
}
|
|
}
|
|
|
|
sb.WriteString("\n</svg>")
|
|
return sb.Bytes(), nil
|
|
}
|
|
|
|
func (r *SVGRenderer) renderMixedChart(chart *types.Chart) ([]byte, error) {
|
|
if len(chart.Data.Datasets) == 0 {
|
|
return []byte{}, nil
|
|
}
|
|
|
|
p := plot.New()
|
|
|
|
if chart.Title != "" {
|
|
p.Title.Text = chart.Title
|
|
}
|
|
|
|
p.X.Label.Text = ""
|
|
p.Y.Label.Text = ""
|
|
|
|
padding := 60.0
|
|
chartWidth := float64(r.width) - 2*padding
|
|
chartHeight := float64(r.height) - 2*padding
|
|
|
|
labels := chart.Data.Labels
|
|
if len(labels) == 0 {
|
|
labels = make([]string, len(chart.Data.Datasets[0].Values))
|
|
for i := range labels {
|
|
labels[i] = fmt.Sprintf("%d", i+1)
|
|
}
|
|
}
|
|
|
|
maxValue := 0.0
|
|
for _, dataset := range chart.Data.Datasets {
|
|
for _, v := range dataset.Values {
|
|
if v > maxValue {
|
|
maxValue = v
|
|
}
|
|
}
|
|
}
|
|
if maxValue == 0 {
|
|
maxValue = 100
|
|
}
|
|
|
|
var sb bytes.Buffer
|
|
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
|
|
|
|
if chart.Title != "" {
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"50%%\" y=\"25\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", escapeXML(chart.Title)))
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("\n<rect x=\"%f\" y=\"%f\" width=\"%f\" height=\"%f\" fill=\"#f8f9fa\" stroke=\"#ddd\"/>", padding, padding, chartWidth, chartHeight))
|
|
|
|
barWidth := chartWidth / float64(len(labels)*len(chart.Data.Datasets)+len(chart.Data.Datasets))
|
|
seriesWidth := barWidth * float64(len(labels))
|
|
|
|
for i, dataset := range chart.Data.Datasets {
|
|
color := getColor(i)
|
|
if dataset.Color != "" {
|
|
color = dataset.Color
|
|
}
|
|
|
|
isLine := i%2 == 1
|
|
|
|
if isLine {
|
|
points := ""
|
|
for j, v := range dataset.Values {
|
|
x := padding + float64(j)*seriesWidth + seriesWidth/2
|
|
y := padding + chartHeight - (v/maxValue)*chartHeight
|
|
points += fmt.Sprintf("%f,%f ", x, y)
|
|
}
|
|
sb.WriteString(fmt.Sprintf("\n<polyline points=\"%s\" fill=\"none\" stroke=\"%s\" stroke-width=\"3\"/>", points, color))
|
|
|
|
for j, v := range dataset.Values {
|
|
x := padding + float64(j)*seriesWidth + seriesWidth/2
|
|
y := padding + chartHeight - (v/maxValue)*chartHeight
|
|
sb.WriteString(fmt.Sprintf("\n<circle cx=\"%f\" cy=\"%f\" r=\"5\" fill=\"%s\"/>", x, y, color))
|
|
}
|
|
} else {
|
|
for j, v := range dataset.Values {
|
|
x := padding + float64(j)*seriesWidth + float64(i)*barWidth
|
|
barHeight := (v / maxValue) * chartHeight
|
|
y := padding + chartHeight - barHeight
|
|
sb.WriteString(fmt.Sprintf("\n<rect x=\"%f\" y=\"%f\" width=\"%f\" height=\"%f\" fill=\"%s\"/>", x, y, barWidth*0.8, barHeight, color))
|
|
}
|
|
}
|
|
}
|
|
|
|
labelSpacing := chartWidth / float64(len(labels))
|
|
for i, label := range labels {
|
|
x := padding + float64(i)*labelSpacing + labelSpacing/2
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"%f\" y=\"%f\" text-anchor=\"middle\" font-size=\"11\" fill=\"#666\">%s</text>",
|
|
x, float64(r.height)-padding+20, escapeXML(label)))
|
|
}
|
|
|
|
sb.WriteString("\n</svg>")
|
|
return sb.Bytes(), nil
|
|
}
|
|
|
|
func (r *SVGRenderer) renderPolarChart(chart *types.Chart) ([]byte, error) {
|
|
if len(chart.Data.Datasets) == 0 || len(chart.Data.Datasets[0].Values) == 0 {
|
|
return []byte{}, nil
|
|
}
|
|
|
|
dataset := chart.Data.Datasets[0]
|
|
values := dataset.Values
|
|
labels := chart.Data.Labels
|
|
|
|
maxValue := 0.0
|
|
for _, v := range values {
|
|
if v > maxValue {
|
|
maxValue = v
|
|
}
|
|
}
|
|
if maxValue == 0 {
|
|
maxValue = 100
|
|
}
|
|
|
|
var sb bytes.Buffer
|
|
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
|
|
|
|
if chart.Title != "" {
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"50%%\" y=\"25\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", escapeXML(chart.Title)))
|
|
}
|
|
|
|
cx, cy := float64(r.width)/2, float64(r.height)/2
|
|
radius := math.Min(cx, cy) * 0.6
|
|
|
|
numCategories := len(values)
|
|
angleStep := 360.0 / float64(numCategories)
|
|
|
|
for i := 1; i <= 4; i++ {
|
|
rRing := radius * float64(i) / 4
|
|
points := ""
|
|
for j := 0; j <= numCategories; j++ {
|
|
angle := -90 + float64(j)*angleStep
|
|
x := cx + rRing*math.Cos(angle*math.Pi/180)
|
|
y := cy + rRing*math.Sin(angle*math.Pi/180)
|
|
points += fmt.Sprintf("%f,%f ", x, y)
|
|
}
|
|
sb.WriteString(fmt.Sprintf("\n<polygon points=\"%s\" fill=\"none\" stroke=\"#ddd\" stroke-width=\"1\"/>", points))
|
|
}
|
|
|
|
nodes := ""
|
|
for i, v := range values {
|
|
angle := -90 + float64(i)*angleStep
|
|
normalizedValue := v / maxValue
|
|
x := cx + radius*normalizedValue*math.Cos(angle*math.Pi/180)
|
|
y := cy + radius*normalizedValue*math.Sin(angle*math.Pi/180)
|
|
nodes += fmt.Sprintf("%f,%f ", x, y)
|
|
|
|
labelRadius := radius + 25
|
|
labelX := cx + labelRadius*math.Cos(angle*math.Pi/180)
|
|
labelY := cy + labelRadius*math.Sin(angle*math.Pi/180)
|
|
|
|
label := fmt.Sprintf("Item %d", i+1)
|
|
if i < len(labels) {
|
|
label = labels[i]
|
|
}
|
|
|
|
anchor := "middle"
|
|
if math.Cos(angle*math.Pi/180) > 0.1 {
|
|
anchor = "start"
|
|
} else if math.Cos(angle*math.Pi/180) < -0.1 {
|
|
anchor = "end"
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"%f\" y=\"%f\" text-anchor=\"%s\" font-size=\"11\" fill=\"#666\">%s</text>",
|
|
labelX, labelY+4, anchor, escapeXML(label)))
|
|
}
|
|
|
|
color := getColor(0)
|
|
if dataset.Color != "" {
|
|
color = dataset.Color
|
|
}
|
|
sb.WriteString(fmt.Sprintf("\n<polygon points=\"%s\" fill=\"%s\" opacity=\"0.5\" stroke=\"%s\" stroke-width=\"2\"/>", nodes, color, color))
|
|
|
|
sb.WriteString("\n</svg>")
|
|
return sb.Bytes(), nil
|
|
}
|
|
|
|
func (r *SVGRenderer) renderRadarChart(chart *types.Chart) ([]byte, error) {
|
|
if len(chart.Data.Datasets) == 0 || len(chart.Data.Datasets[0].Values) == 0 {
|
|
return []byte{}, nil
|
|
}
|
|
|
|
dataset := chart.Data.Datasets[0]
|
|
values := dataset.Values
|
|
labels := chart.Data.Labels
|
|
|
|
maxValue := 0.0
|
|
for _, v := range values {
|
|
if v > maxValue {
|
|
maxValue = v
|
|
}
|
|
}
|
|
if maxValue == 0 {
|
|
maxValue = 100
|
|
}
|
|
|
|
var sb bytes.Buffer
|
|
sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
|
|
|
|
if chart.Title != "" {
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"50%%\" y=\"25\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", escapeXML(chart.Title)))
|
|
}
|
|
|
|
cx, cy := float64(r.width)/2, float64(r.height)/2
|
|
radius := math.Min(cx, cy) * 0.5
|
|
|
|
numAxes := len(values)
|
|
angleStep := 360.0 / float64(numAxes)
|
|
|
|
for level := 1; level <= 5; level++ {
|
|
rLevel := radius * float64(level) / 5
|
|
points := ""
|
|
for i := 0; i <= numAxes; i++ {
|
|
angle := -90 + float64(i)*angleStep
|
|
x := cx + rLevel*math.Cos(angle*math.Pi/180)
|
|
y := cy + rLevel*math.Sin(angle*math.Pi/180)
|
|
points += fmt.Sprintf("%f,%f ", x, y)
|
|
}
|
|
sb.WriteString(fmt.Sprintf("\n<polygon points=\"%s\" fill=\"none\" stroke=\"#ddd\" stroke-width=\"1\"/>", points))
|
|
}
|
|
|
|
for i := 0; i < len(values); i++ {
|
|
angle := -90 + float64(i)*angleStep
|
|
x := cx + radius*math.Cos(angle*math.Pi/180)
|
|
y := cy + radius*math.Sin(angle*math.Pi/180)
|
|
sb.WriteString(fmt.Sprintf("\n<line x1=\"%f\" y1=\"%f\" x2=\"%f\" y2=\"%f\" stroke=\"#ddd\" stroke-width=\"1\"/>", cx, cy, x, y))
|
|
|
|
labelRadius := radius + 20
|
|
labelX := cx + labelRadius*math.Cos(angle*math.Pi/180)
|
|
labelY := cy + labelRadius*math.Sin(angle*math.Pi/180)
|
|
|
|
label := fmt.Sprintf("Item %d", i+1)
|
|
if i < len(labels) {
|
|
label = labels[i]
|
|
}
|
|
|
|
anchor := "middle"
|
|
if math.Cos(angle*math.Pi/180) > 0.1 {
|
|
anchor = "start"
|
|
} else if math.Cos(angle*math.Pi/180) < -0.1 {
|
|
anchor = "end"
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf("\n<text x=\"%f\" y=\"%f\" text-anchor=\"%s\" font-size=\"11\" fill=\"#666\">%s</text>",
|
|
labelX, labelY+4, anchor, escapeXML(label)))
|
|
}
|
|
|
|
nodes := ""
|
|
for i, v := range values {
|
|
angle := -90 + float64(i)*angleStep
|
|
normalizedValue := v / maxValue
|
|
x := cx + radius*normalizedValue*math.Cos(angle*math.Pi/180)
|
|
y := cy + radius*normalizedValue*math.Sin(angle*math.Pi/180)
|
|
nodes += fmt.Sprintf("%f,%f ", x, y)
|
|
}
|
|
|
|
color := getColor(0)
|
|
if dataset.Color != "" {
|
|
color = dataset.Color
|
|
}
|
|
sb.WriteString(fmt.Sprintf("\n<polygon points=\"%s\" fill=\"%s\" opacity=\"0.3\" stroke=\"%s\" stroke-width=\"2\"/>", nodes, color, color))
|
|
|
|
for i, v := range values {
|
|
angle := -90 + float64(i)*angleStep
|
|
normalizedValue := v / maxValue
|
|
x := cx + radius*normalizedValue*math.Cos(angle*math.Pi/180)
|
|
y := cy + radius*normalizedValue*math.Sin(angle*math.Pi/180)
|
|
sb.WriteString(fmt.Sprintf("\n<circle cx=\"%f\" cy=\"%f\" r=\"4\" fill=\"%s\"/>", x, y, color))
|
|
}
|
|
|
|
sb.WriteString("\n</svg>")
|
|
return sb.Bytes(), nil
|
|
}
|