feat: initial release v0.3.0
- 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
This commit is contained in:
245
internal/renderer/ansi.go
Normal file
245
internal/renderer/ansi.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/picoclaw/chart/internal/types"
|
||||
)
|
||||
|
||||
type ANSIRenderer struct {
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func NewANSIRenderer() *ANSIRenderer {
|
||||
return &ANSIRenderer{
|
||||
width: 60,
|
||||
height: 15,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ANSIRenderer) Render(chart *types.Chart) ([]byte, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
ansiGreen := "\033[32m"
|
||||
ansiBlue := "\033[34m"
|
||||
ansiYellow := "\033[33m"
|
||||
ansiRed := "\033[31m"
|
||||
ansiReset := "\033[0m"
|
||||
ansiBold := "\033[1m"
|
||||
ansiCyan := "\033[36m"
|
||||
ansiMagenta := "\033[35m"
|
||||
|
||||
if chart.Title != "" {
|
||||
sb.WriteString(fmt.Sprintf("%s%s📊 %s%s\n", ansiBold, ansiGreen, chart.Title, ansiReset))
|
||||
}
|
||||
|
||||
if len(chart.Data.Datasets) == 0 {
|
||||
return []byte(sb.String()), nil
|
||||
}
|
||||
|
||||
switch chart.Type {
|
||||
case types.ChartTypePie, types.ChartTypeDonut:
|
||||
r.renderPieChart(&sb, &chart.Data, ansiBold, ansiReset)
|
||||
case types.ChartTypeBubble:
|
||||
r.renderBubbleChart(&sb, &chart.Data, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiRed, ansiReset)
|
||||
case types.ChartTypeMixed:
|
||||
r.renderMixedChart(&sb, &chart.Data, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiReset)
|
||||
case types.ChartTypePolar, types.ChartTypeRadar:
|
||||
r.renderRadarLikeChart(&sb, &chart.Data, ansiBold, ansiCyan, ansiMagenta, ansiYellow, ansiReset)
|
||||
default:
|
||||
r.renderBarLikeChart(&sb, &chart.Data, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiRed, ansiReset)
|
||||
}
|
||||
|
||||
return []byte(sb.String()), nil
|
||||
}
|
||||
|
||||
func (r *ANSIRenderer) renderPieChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiReset string) {
|
||||
dataset := data.Datasets[0]
|
||||
values := dataset.Values
|
||||
labels := data.Labels
|
||||
|
||||
total := 0.0
|
||||
for _, v := range values {
|
||||
total += v
|
||||
}
|
||||
|
||||
colors := []string{"\033[31m", "\033[32m", "\033[34m", "\033[33m", "\033[35m", "\033[36m"}
|
||||
|
||||
for i, v := range values {
|
||||
label := fmt.Sprintf("Item %d", i+1)
|
||||
if i < len(labels) {
|
||||
label = labels[i]
|
||||
}
|
||||
|
||||
percentage := v / total * 100
|
||||
color := colors[i%len(colors)]
|
||||
|
||||
barLen := int(percentage / 4)
|
||||
if barLen > 15 {
|
||||
barLen = 15
|
||||
}
|
||||
bar := strings.Repeat("█", barLen)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s%-12s %s| %s%s [%.1f%%]%s\n",
|
||||
color, label, ansiReset,
|
||||
color, bar, percentage, ansiReset,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ANSIRenderer) renderBubbleChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiRed, ansiReset string) {
|
||||
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("X%d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
maxValue := dataset.Values[0]
|
||||
for _, v := range dataset.Values {
|
||||
if v > maxValue {
|
||||
maxValue = v
|
||||
}
|
||||
}
|
||||
|
||||
colors := []string{ansiGreen, ansiBlue, ansiYellow, ansiRed}
|
||||
|
||||
for i, value := range dataset.Values {
|
||||
label := labels[i]
|
||||
bubbleSize := 3 + int((value/maxValue)*5)
|
||||
|
||||
color := colors[i%len(colors)]
|
||||
bubble := strings.Repeat("●", bubbleSize)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s%-8s%s %s%s %s%.2f%s\n",
|
||||
ansiBold, label, ansiReset,
|
||||
color, bubble, ansiReset,
|
||||
ansiYellow, value, ansiReset,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ANSIRenderer) renderMixedChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiReset string) {
|
||||
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("X%d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
maxValue := dataset.Values[0]
|
||||
for _, v := range dataset.Values {
|
||||
if v > maxValue {
|
||||
maxValue = v
|
||||
}
|
||||
}
|
||||
|
||||
colors := []string{ansiGreen, ansiBlue}
|
||||
|
||||
for i, value := range dataset.Values {
|
||||
label := labels[i]
|
||||
|
||||
barLen := int((value / maxValue) * float64(r.width-20))
|
||||
if barLen < 1 {
|
||||
barLen = 1
|
||||
}
|
||||
|
||||
isLine := i%2 == 1
|
||||
var bar string
|
||||
if isLine {
|
||||
bar = strings.Repeat("●", barLen/2)
|
||||
} else {
|
||||
bar = strings.Repeat("█", barLen)
|
||||
}
|
||||
|
||||
color := colors[i%len(colors)]
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s%-8s%s %s%s %s%.2f [%s]%s\n",
|
||||
ansiBold, label, ansiReset,
|
||||
color, bar, ansiReset,
|
||||
ansiYellow, value, ansiReset,
|
||||
"mixed",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ANSIRenderer) renderRadarLikeChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiCyan, ansiMagenta, ansiYellow, ansiReset string) {
|
||||
dataset := data.Datasets[0]
|
||||
values := dataset.Values
|
||||
labels := data.Labels
|
||||
|
||||
maxValue := values[0]
|
||||
for _, v := range values {
|
||||
if v > maxValue {
|
||||
maxValue = v
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s%s雷达图/极区图 (共 %d 维度)%s\n",
|
||||
ansiBold, ansiCyan, len(values), ansiReset))
|
||||
|
||||
for i, v := range values {
|
||||
label := fmt.Sprintf("维度 %d", i+1)
|
||||
if i < len(labels) {
|
||||
label = labels[i]
|
||||
}
|
||||
|
||||
barLen := int((v / maxValue) * float64(r.width-25))
|
||||
if barLen < 1 {
|
||||
barLen = 1
|
||||
}
|
||||
|
||||
bar := strings.Repeat("◆", barLen)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%s%-12s%s %s%s %s%.2f%s\n",
|
||||
ansiBold, label, ansiReset,
|
||||
ansiMagenta, bar, ansiReset,
|
||||
ansiYellow, v, ansiReset,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ANSIRenderer) renderBarLikeChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiRed, ansiReset string) {
|
||||
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("X%d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
maxValue := dataset.Values[0]
|
||||
for _, v := range dataset.Values {
|
||||
if v > maxValue {
|
||||
maxValue = v
|
||||
}
|
||||
}
|
||||
|
||||
colors := []string{ansiGreen, ansiBlue, ansiYellow, ansiRed}
|
||||
for i, value := range dataset.Values {
|
||||
label := labels[i]
|
||||
if i >= len(labels) {
|
||||
label = fmt.Sprintf("X%d", i+1)
|
||||
}
|
||||
|
||||
barLen := int((value / maxValue) * float64(r.width-20))
|
||||
if barLen < 1 {
|
||||
barLen = 1
|
||||
}
|
||||
|
||||
color := colors[i%len(colors)]
|
||||
bar := strings.Repeat("█", barLen)
|
||||
|
||||
line := ansiBold + fmt.Sprintf("%-8s", label) + ansiReset + " " +
|
||||
color + bar + ansiReset + " " +
|
||||
ansiYellow + fmt.Sprintf("%.2f", value) + ansiReset + "\n"
|
||||
sb.WriteString(line)
|
||||
}
|
||||
}
|
||||
565
internal/renderer/png.go
Normal file
565
internal/renderer/png.go
Normal file
@@ -0,0 +1,565 @@
|
||||
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}
|
||||
}
|
||||
14
internal/renderer/renderer.go
Normal file
14
internal/renderer/renderer.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package renderer
|
||||
|
||||
import "github.com/picoclaw/chart/internal/types"
|
||||
|
||||
type Renderer interface {
|
||||
Render(chart *types.Chart) ([]byte, error)
|
||||
}
|
||||
|
||||
type RenderResult struct {
|
||||
Text string
|
||||
ANSI string
|
||||
SVG string
|
||||
PNG []byte
|
||||
}
|
||||
620
internal/renderer/svg.go
Normal file
620
internal/renderer/svg.go
Normal file
@@ -0,0 +1,620 @@
|
||||
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
|
||||
}
|
||||
198
internal/renderer/text.go
Normal file
198
internal/renderer/text.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/picoclaw/chart/internal/types"
|
||||
)
|
||||
|
||||
type TextRenderer struct {
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func NewTextRenderer() *TextRenderer {
|
||||
return &TextRenderer{
|
||||
width: 60,
|
||||
height: 15,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TextRenderer) Render(chart *types.Chart) ([]byte, error) {
|
||||
var sb strings.Builder
|
||||
|
||||
if chart.Title != "" {
|
||||
sb.WriteString(fmt.Sprintf("[ Chart: %s ]\n", chart.Title))
|
||||
}
|
||||
|
||||
if len(chart.Data.Datasets) == 0 {
|
||||
sb.WriteString("No data available.\n")
|
||||
return []byte(sb.String()), nil
|
||||
}
|
||||
|
||||
switch chart.Type {
|
||||
case types.ChartTypePie, types.ChartTypeDonut:
|
||||
r.renderPieChart(&sb, &chart.Data)
|
||||
case types.ChartTypeBubble:
|
||||
r.renderBubbleChart(&sb, &chart.Data)
|
||||
case types.ChartTypeMixed:
|
||||
r.renderMixedChart(&sb, &chart.Data)
|
||||
case types.ChartTypePolar, types.ChartTypeRadar:
|
||||
r.renderRadarLikeChart(&sb, &chart.Data)
|
||||
default:
|
||||
r.renderBarLikeChart(&sb, &chart.Data)
|
||||
}
|
||||
|
||||
return []byte(sb.String()), nil
|
||||
}
|
||||
|
||||
func (r *TextRenderer) renderPieChart(sb *strings.Builder, data *types.ChartData) {
|
||||
dataset := data.Datasets[0]
|
||||
values := dataset.Values
|
||||
labels := data.Labels
|
||||
|
||||
total := 0.0
|
||||
for _, v := range values {
|
||||
total += v
|
||||
}
|
||||
|
||||
for i, v := range values {
|
||||
label := fmt.Sprintf("Item %d", i+1)
|
||||
if i < len(labels) {
|
||||
label = labels[i]
|
||||
}
|
||||
|
||||
percentage := v / total * 100
|
||||
|
||||
barLen := int(percentage / 4)
|
||||
if barLen > 12 {
|
||||
barLen = 12
|
||||
}
|
||||
bar := strings.Repeat("#", barLen)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%-12s |%s [%.1f%%]\n", label, bar, percentage))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TextRenderer) renderBubbleChart(sb *strings.Builder, data *types.ChartData) {
|
||||
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("X%d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
maxValue := dataset.Values[0]
|
||||
for _, v := range dataset.Values {
|
||||
if v > maxValue {
|
||||
maxValue = v
|
||||
}
|
||||
}
|
||||
|
||||
for i, value := range dataset.Values {
|
||||
label := labels[i]
|
||||
bubbleSize := 2 + int((value/maxValue)*5)
|
||||
|
||||
bubble := strings.Repeat("o", bubbleSize)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%-8s |%s %.2f\n", label, bubble, value))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TextRenderer) renderMixedChart(sb *strings.Builder, data *types.ChartData) {
|
||||
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("X%d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
maxValue := dataset.Values[0]
|
||||
for _, v := range dataset.Values {
|
||||
if v > maxValue {
|
||||
maxValue = v
|
||||
}
|
||||
}
|
||||
|
||||
for i, value := range dataset.Values {
|
||||
label := labels[i]
|
||||
|
||||
barLen := int((value / maxValue) * float64(r.width-20))
|
||||
if barLen < 1 {
|
||||
barLen = 1
|
||||
}
|
||||
|
||||
bar := strings.Repeat("#", barLen)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%-8s |%s %.2f [mixed]\n", label, bar, value))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TextRenderer) renderRadarLikeChart(sb *strings.Builder, data *types.ChartData) {
|
||||
dataset := data.Datasets[0]
|
||||
values := dataset.Values
|
||||
labels := data.Labels
|
||||
|
||||
maxValue := values[0]
|
||||
for _, v := range values {
|
||||
if v > maxValue {
|
||||
maxValue = v
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("[ Radar/Polar Chart: %d dimensions ]\n", len(values)))
|
||||
|
||||
for i, v := range values {
|
||||
label := fmt.Sprintf("Dim %d", i+1)
|
||||
if i < len(labels) {
|
||||
label = labels[i]
|
||||
}
|
||||
|
||||
barLen := int((v / maxValue) * float64(r.width-25))
|
||||
if barLen < 1 {
|
||||
barLen = 1
|
||||
}
|
||||
|
||||
bar := strings.Repeat("#", barLen)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%-12s |%s %.2f\n", label, bar, v))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TextRenderer) renderBarLikeChart(sb *strings.Builder, data *types.ChartData) {
|
||||
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("X%d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
maxValue := dataset.Values[0]
|
||||
for _, v := range dataset.Values {
|
||||
if v > maxValue {
|
||||
maxValue = v
|
||||
}
|
||||
}
|
||||
|
||||
for i, value := range dataset.Values {
|
||||
label := labels[i]
|
||||
if i >= len(labels) {
|
||||
label = fmt.Sprintf("X%d", i+1)
|
||||
}
|
||||
|
||||
barLen := int((value / maxValue) * float64(r.width-20))
|
||||
if barLen < 1 {
|
||||
barLen = 1
|
||||
}
|
||||
|
||||
bar := strings.Repeat("#", barLen)
|
||||
sb.WriteString(fmt.Sprintf("%-8s |%s %.2f\n", label, bar, value))
|
||||
}
|
||||
}
|
||||
9
internal/renderer/utils.go
Normal file
9
internal/renderer/utils.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package renderer
|
||||
|
||||
func getColor(index int) string {
|
||||
colors := []string{
|
||||
"#f9c2c8", "#f3d2b1", "#e1e9c5", "#b2d5e8",
|
||||
"#8fa3c4", "#75B4A0", "#3b7f8d", "#a18b7c",
|
||||
}
|
||||
return colors[index%len(colors)]
|
||||
}
|
||||
Reference in New Issue
Block a user