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(``) sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) if chart.Title != "" { sb.WriteString(fmt.Sprintf("\n%s", 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, 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, color)) sb.WriteString(fmt.Sprintf("\n%.1f%%", 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", 20, legendY+float64(i)*25, color)) sb.WriteString(fmt.Sprintf("\n%s: %.2f (%.1f%%)", legendY+float64(i)*25+12, escapeXML(label), v, percentage)) startAngle = endAngle } sb.WriteString("\n") 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(``) sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) if chart.Title != "" { sb.WriteString(fmt.Sprintf("\n%s", 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", 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", x, y, bubbleRadius, color, color)) sb.WriteString(fmt.Sprintf("\n%.0f", 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%s", x, float64(r.height)-padding+20, escapeXML(label))) } } sb.WriteString("\n") 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(``) sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) if chart.Title != "" { sb.WriteString(fmt.Sprintf("\n%s", escapeXML(chart.Title))) } sb.WriteString(fmt.Sprintf("\n", 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", 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", 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", 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%s", x, float64(r.height)-padding+20, escapeXML(label))) } sb.WriteString("\n") 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(``) sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) if chart.Title != "" { sb.WriteString(fmt.Sprintf("\n%s", 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", 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%s", labelX, labelY+4, anchor, escapeXML(label))) } color := getColor(0) if dataset.Color != "" { color = dataset.Color } sb.WriteString(fmt.Sprintf("\n", nodes, color, color)) sb.WriteString("\n") 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(``) sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) if chart.Title != "" { sb.WriteString(fmt.Sprintf("\n%s", 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", 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", 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%s", 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", 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", x, y, color)) } sb.WriteString("\n") return sb.Bytes(), nil }