- 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
566 lines
13 KiB
Go
566 lines
13 KiB
Go
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}
|
|
}
|