package renderer import ( "bytes" "fmt" "image" "image/color" "image/png" "math" "strconv" "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/vgimg" ) type PNGRenderer struct { width vg.Length height vg.Length } func NewPNGRenderer() *PNGRenderer { return &PNGRenderer{ width: 600, height: 400, } } func (r *PNGRenderer) Render(chart *types.Chart) ([]byte, error) { switch chart.Type { case types.ChartTypePie, types.ChartTypeDonut: return r.renderPieChart(chart, chart.Type == types.ChartTypeDonut) case types.ChartTypeBubble: return r.renderBubbleChart(chart) case types.ChartTypeMixed: return r.renderMixedChart(chart) case types.ChartTypePolar: return r.renderPolarChart(chart) case types.ChartTypeRadar: return r.renderRadarChart(chart) } 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.ChartTypeScatter: r.addScatterChart(p, &chart.Data) default: r.addLineChart(p, &chart.Data) } canvas := vgimg.New(width, height) p.Draw(draw.New(canvas)) img := canvas.Image() var buf bytes.Buffer err := png.Encode(&buf, img) if err != nil { return nil, fmt.Errorf("failed to encode png: %w", err) } return buf.Bytes(), nil } func (r *PNGRenderer) 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 *PNGRenderer) 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 *PNGRenderer) 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 (r *PNGRenderer) 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 total := 0.0 for _, v := range values { total += v } imgWidth := int(r.width) imgHeight := int(r.height) img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) for y := 0; y < imgHeight; y++ { for x := 0; x < imgWidth; x++ { img.Set(x, y, color.White) } } cx := float64(imgWidth) / 2 cy := float64(imgHeight)/2 - 30 radius := math.Min(cx, cy) * 0.6 innerRadius := radius * 0.5 if !isDonut { innerRadius = 0 } startAngle := -90.0 for i, v := range values { angle := v / total * 360 colorStr := getColor(i) col := parseHexColor(colorStr) endAngle := startAngle + angle r.drawPieSlice(img, cx, cy, radius, innerRadius, startAngle, endAngle, col) startAngle = endAngle } var buf bytes.Buffer err := png.Encode(&buf, img) if err != nil { return nil, fmt.Errorf("failed to encode png: %w", err) } return buf.Bytes(), nil } func (r *PNGRenderer) drawPieSlice(img *image.RGBA, cx, cy, radius, innerRadius, startAngle, endAngle float64, col color.RGBA) { maxRadius := int(radius) + 1 for dy := -maxRadius; dy <= maxRadius; dy++ { for dx := -maxRadius; dx <= maxRadius; dx++ { dist := math.Sqrt(float64(dx*dx + dy*dy)) if dist <= radius && dist >= innerRadius { px, py := cx+float64(dx), cy+float64(dy) pointAngle := math.Atan2(float64(dy), float64(dx)) * 180 / math.Pi normalizedAngle := pointAngle if normalizedAngle < -90 { normalizedAngle += 360 } normalizedStart := startAngle if normalizedStart < -90 { normalizedStart += 360 } normalizedEnd := endAngle if normalizedEnd < -90 { normalizedEnd += 360 } var inSlice bool if normalizedEnd > normalizedStart { inSlice = normalizedAngle >= normalizedStart && normalizedAngle <= normalizedEnd } else { inSlice = normalizedAngle >= normalizedStart || normalizedAngle <= normalizedEnd } if inSlice { img.Set(int(px), int(py), col) } } } } } func (r *PNGRenderer) renderBubbleChart(chart *types.Chart) ([]byte, error) { if len(chart.Data.Datasets) == 0 { return []byte{}, nil } imgWidth := int(r.width) imgHeight := int(r.height) img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) for y := 0; y < imgHeight; y++ { for x := 0; x < imgWidth; x++ { img.Set(x, y, color.White) } } padding := 60.0 chartWidth := float64(imgWidth) - 2*padding chartHeight := float64(imgHeight) - 2*padding maxValue := 0.0 for _, dataset := range chart.Data.Datasets { for _, v := range dataset.Values { if v > maxValue { maxValue = v } } } if maxValue == 0 { maxValue = 100 } for i, dataset := range chart.Data.Datasets { colorStr := getColor(i) col := parseHexColor(colorStr) 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 for dy := -int(bubbleRadius); dy <= int(bubbleRadius); dy++ { for dx := -int(bubbleRadius); dx <= int(bubbleRadius); dx++ { if float64(dx*dx+dy*dy) <= bubbleRadius*bubbleRadius { px, py := int(x)+dx, int(y)+dy if px >= 0 && px < imgWidth && py >= 0 && py < imgHeight { img.Set(px, py, col) } } } } } } var buf bytes.Buffer err := png.Encode(&buf, img) if err != nil { return nil, fmt.Errorf("failed to encode png: %w", err) } return buf.Bytes(), nil } func (r *PNGRenderer) renderMixedChart(chart *types.Chart) ([]byte, error) { if len(chart.Data.Datasets) == 0 { return []byte{}, nil } imgWidth := int(r.width) imgHeight := int(r.height) img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) for y := 0; y < imgHeight; y++ { for x := 0; x < imgWidth; x++ { img.Set(x, y, color.White) } } padding := 60.0 chartWidth := float64(imgWidth) - 2*padding chartHeight := float64(imgHeight) - 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 } barWidth := chartWidth / float64(len(labels)*len(chart.Data.Datasets)) seriesWidth := barWidth * float64(len(labels)) for i, dataset := range chart.Data.Datasets { colorStr := getColor(i) col := parseHexColor(colorStr) isLine := i%2 == 1 if isLine { for j := 0; j < len(dataset.Values)-1; j++ { x1 := padding + float64(j)*seriesWidth + seriesWidth/2 y1 := padding + chartHeight - (dataset.Values[j]/maxValue)*chartHeight x2 := padding + float64(j+1)*seriesWidth + seriesWidth/2 y2 := padding + chartHeight - (dataset.Values[j+1]/maxValue)*chartHeight r.drawLine(img, x1, y1, x2, y2, col) } } else { for j, v := range dataset.Values { x := padding + float64(j)*seriesWidth + float64(i)*barWidth barHeight := (v / maxValue) * chartHeight y := padding + chartHeight - barHeight for py := int(y); py < int(y+barHeight); py++ { for px := int(x); px < int(x+barWidth*0.8); px++ { if px >= 0 && px < imgWidth && py >= 0 && py < imgHeight { img.Set(px, py, col) } } } } } } var buf bytes.Buffer err := png.Encode(&buf, img) if err != nil { return nil, fmt.Errorf("failed to encode png: %w", err) } return buf.Bytes(), nil } func (r *PNGRenderer) drawLine(img *image.RGBA, x1, y1, x2, y2 float64, col color.RGBA) { dx := math.Abs(x2 - x1) dy := math.Abs(y2 - y1) var steps float64 if dx > dy { steps = dx } else { steps = dy } if steps == 0 { return } for i := 0.0; i <= steps; i++ { t := i / steps x := x1 + t*(x2-x1) y := y1 + t*(y2-y1) px, py := int(x), int(y) if px >= 0 && px < int(img.Bounds().Dx()) && py >= 0 && py < int(img.Bounds().Dy()) { img.Set(px, py, col) } } } func (r *PNGRenderer) renderPolarChart(chart *types.Chart) ([]byte, error) { return r.renderRadarLikeChart(chart, true) } func (r *PNGRenderer) renderRadarChart(chart *types.Chart) ([]byte, error) { return r.renderRadarLikeChart(chart, false) } func (r *PNGRenderer) renderRadarLikeChart(chart *types.Chart, polar 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 maxValue := 0.0 for _, v := range values { if v > maxValue { maxValue = v } } if maxValue == 0 { maxValue = 100 } imgWidth := int(r.width) imgHeight := int(r.height) img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) for y := 0; y < imgHeight; y++ { for x := 0; x < imgWidth; x++ { img.Set(x, y, color.White) } } cx, cy := float64(imgWidth)/2, float64(imgHeight)/2 radius := math.Min(cx, cy) * 0.5 numCategories := len(values) angleStep := 360.0 / float64(numCategories) for level := 1; level <= 4; level++ { rLevel := radius * float64(level) / 4 for i := 0; i <= numCategories; i++ { angle := -90 + float64(i)*angleStep x := cx + rLevel*math.Cos(angle*math.Pi/180) y := cy + rLevel*math.Sin(angle*math.Pi/180) if i < numCategories { nextAngle := -90 + float64(i+1)*angleStep nextX := cx + rLevel*math.Cos(nextAngle*math.Pi/180) nextY := cy + rLevel*math.Sin(nextAngle*math.Pi/180) r.drawLine(img, x, y, nextX, nextY, color.RGBA{R: 200, G: 200, B: 200, A: 255}) } } } 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) r.drawLine(img, cx, cy, x, y, color.RGBA{R: 200, G: 200, B: 200, A: 255}) } colorStr := getColor(0) col := parseHexColor(colorStr) prevX, prevY := cx, cy 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) if i > 0 { r.drawLine(img, prevX, prevY, x, y, col) } prevX, prevY = x, y for dy := -3.0; dy <= 3.0; dy++ { for dx := -3.0; dx <= 3.0; dx++ { if dx*dx+dy*dy <= 9 { px, py := int(x+dx), int(y+dy) if px >= 0 && px < imgWidth && py >= 0 && py < imgHeight { img.Set(px, py, col) } } } } } firstAngle := -90.0 firstX := cx + radius*(values[0]/maxValue)*math.Cos(firstAngle*math.Pi/180) firstY := cy + radius*(values[0]/maxValue)*math.Sin(firstAngle*math.Pi/180) r.drawLine(img, prevX, prevY, firstX, firstY, col) var buf bytes.Buffer err := png.Encode(&buf, img) if err != nil { return nil, fmt.Errorf("failed to encode png: %w", err) } return buf.Bytes(), nil } func parseColor(hex string) color.Color { if len(hex) != 7 || hex[0] != '#' { return color.RGBA{R: 76, G: 175, B: 80, A: 255} } r, _ := strconv.ParseUint(hex[1:3], 16, 8) g, _ := strconv.ParseUint(hex[3:5], 16, 8) b, _ := strconv.ParseUint(hex[5:7], 16, 8) return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} } func parseHexColor(hex string) color.RGBA { if len(hex) != 7 || hex[0] != '#' { return color.RGBA{R: 76, G: 175, B: 80, A: 255} } r, _ := strconv.ParseUint(hex[1:3], 16, 8) g, _ := strconv.ParseUint(hex[3:5], 16, 8) b, _ := strconv.ParseUint(hex[5:7], 16, 8) return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} }