feat: support radius for pie chart

This commit is contained in:
vicanso 2022-02-07 23:29:00 +08:00
parent e07cb90607
commit 524eb79a8e
12 changed files with 376 additions and 66 deletions

View file

@ -277,6 +277,9 @@ func (a *axis) axisTick(opt *axisRenderOption) {
height := d.Box.Height()
data := *a.data
tickCount := len(data)
if tickCount == 0 {
return
}
if !opt.boundaryGap {
tickCount--
}

View file

@ -53,6 +53,10 @@ func barChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) {
barMaxHeight := yRange.Size
theme := NewTheme(opt.Theme)
seriesNames := opt.Legend.Data
r := d.Render
for i, series := range opt.SeriesList {
for j, item := range series.Data {
x0, _ := xRange.GetRange(j)
@ -63,6 +67,10 @@ func barChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) {
}
h := int(yRange.getHeight(item.Value))
fillColor := theme.GetSeriesColor(i)
if !item.Style.FillColor.IsZero() {
fillColor = item.Style.FillColor
}
d.Bar(chart.Box{
Top: barMaxHeight - h,
@ -70,8 +78,23 @@ func barChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) {
Right: x + barWidth,
Bottom: barMaxHeight - 1,
}, BarStyle{
FillColor: theme.GetSeriesColor(i),
FillColor: fillColor,
})
if !series.Label.Show {
continue
}
text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1)
labelStyle := chart.Style{
FontColor: theme.GetTextColor(),
FontSize: 10,
Font: opt.Font,
}
if !series.Label.Color.IsZero() {
labelStyle.FontColor = series.Label.Color
}
labelStyle.GetTextOptions().WriteToRenderer(r)
textBox := r.MeasureText(text)
d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-5)
}
}

102
chart.go
View file

@ -26,7 +26,6 @@ import (
"errors"
"math"
"github.com/dustin/go-humanize"
"github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
@ -38,23 +37,11 @@ const (
ChartTypePie = "pie"
)
type SeriesData struct {
Value float64
Style chart.Style
}
type Point struct {
X int
Y int
}
type Series struct {
index int
Type string
Data []SeriesData
YAxisIndex int
Style chart.Style
}
type ChartOption struct {
Font *truetype.Font
Theme string
@ -68,6 +55,7 @@ type ChartOption struct {
Padding chart.Box
SeriesList []Series
BackgroundColor drawing.Color
Children []ChartOption
}
func (o *ChartOption) FillDefault(theme string) {
@ -115,6 +103,13 @@ func (o *ChartOption) FillDefault(theme string) {
if o.Legend.Left == "" {
o.Legend.Left = PositionCenter
}
if len(o.Legend.Data) == 0 {
names := make([]string, len(o.SeriesList))
for index, item := range o.SeriesList {
names[index] = item.Name
}
o.Legend.Data = names
}
if o.Legend.Style.Font == nil {
o.Legend.Style.Font = o.Font
}
@ -165,20 +160,19 @@ func (o *ChartOption) getYRange(axisIndex int) Range {
if o.YAxis.Max != nil {
max = *o.YAxis.Max
}
divideCount := 6
// y轴分设置默认划分为6块
r := NewRange(min, max, 6)
return r
}
r := NewRange(min, max, divideCount)
func (r Range) Values() []string {
offset := (r.Max - r.Min) / float64(r.divideCount)
values := make([]string, 0)
for i := 0; i <= r.divideCount; i++ {
v := r.Min + float64(i)*offset
value := humanize.CommafWithDigits(v, 2)
values = append(values, value)
// 由于NewRange会重新计算min max
if o.YAxis.Min != nil {
r.Min = min
}
return values
if o.YAxis.Max != nil {
r.Max = max
}
return r
}
type basicRenderResult struct {
@ -188,7 +182,7 @@ type basicRenderResult struct {
titleBox chart.Box
}
func ChartRender(opt ChartOption) (*Draw, error) {
func Render(opt ChartOption) (*Draw, error) {
if len(opt.SeriesList) == 0 {
return nil, errors.New("series can not be nil")
}
@ -207,7 +201,7 @@ func ChartRender(opt ChartOption) (*Draw, error) {
lineSeries = append(lineSeries, item)
}
}
// 如果指定了pie则以pie的形式处理不支持多类型图表
// 如果指定了pie则以pie的形式处理pie不支持多类型图表
// pie不需要axis
if isPieChart {
opt.XAxis.Hidden = true
@ -217,21 +211,56 @@ func ChartRender(opt ChartOption) (*Draw, error) {
if err != nil {
return nil, err
}
if isPieChart {
return pieChartRender(opt, result)
fns := []func() error{
// pie render
func() error {
if !isPieChart {
return nil
}
_, err := pieChartRender(opt, result)
return err
},
// bar render
func() error {
// 如果是pie或者无bar类型的series
if isPieChart || len(barSeries) == 0 {
return nil
}
if len(barSeries) != 0 {
o := opt
o.SeriesList = barSeries
_, err = barChartRender(o, result)
_, err := barChartRender(o, result)
return err
},
// line render
func() error {
// 如果是pie或者无line类型的series
if isPieChart || len(lineSeries) == 0 {
return nil
}
o := opt
o.SeriesList = lineSeries
_, err := lineChartRender(o, result)
return err
},
// legend需要在顶层因此最后render
func() error {
_, err := NewLegend(result.d, opt.Legend).Render()
return err
},
}
for _, fn := range fns {
err = fn()
if err != nil {
return nil, err
}
}
if len(lineSeries) != 0 {
o := opt
o.SeriesList = lineSeries
_, err = lineChartRender(o, result)
for _, child := range opt.Children {
child.Parent = result.d
if len(child.Theme) == 0 {
child.Theme = opt.Theme
}
_, err = Render(child)
if err != nil {
return nil, err
}
@ -263,11 +292,6 @@ func chartBasicRender(opt *ChartOption) (*basicRenderResult, error) {
return nil, err
}
_, err = NewLegend(d, opt.Legend).Render()
if err != nil {
return nil, err
}
xAxisHeight := 0
var xRange *Range

View file

@ -23,7 +23,6 @@
package charts
import (
"fmt"
"math"
"testing"
@ -253,7 +252,6 @@ func TestDraw(t *testing.T) {
tt.fn(d)
data, err := d.Bytes()
assert.Nil(err)
fmt.Println(string(data))
assert.Equal(tt.result, string(data))
}
}

View file

@ -40,16 +40,36 @@ func lineChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error)
if err != nil {
return nil, err
}
seriesNames := opt.Legend.Data
r := d.Render
yRange := result.yRange
xRange := result.xRange
for i, series := range opt.SeriesList {
points := make([]Point, 0)
for j, item := range series.Data {
y := yRange.getRestHeight(item.Value)
x := xRange.getWidth(float64(j))
points = append(points, Point{
Y: y,
X: xRange.getWidth(float64(j)),
X: x,
})
if !series.Label.Show {
continue
}
text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1)
labelStyle := chart.Style{
FontColor: theme.GetTextColor(),
FontSize: 10,
Font: opt.Font,
}
if !series.Label.Color.IsZero() {
labelStyle.FontColor = series.Label.Color
}
labelStyle.GetTextOptions().WriteToRenderer(r)
textBox := r.MeasureText(text)
d.text(text, x-textBox.Width()>>1, y-5)
}
index := series.index
if index == 0 {
index = i
@ -67,7 +87,6 @@ func lineChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error)
DotFillColor: dotFillColor,
})
}
}
return result.d, nil
}

View file

@ -24,8 +24,8 @@ package charts
import (
"math"
"strconv"
"github.com/dustin/go-humanize"
"github.com/wcharczuk/go-chart/v2"
)
@ -53,7 +53,11 @@ func pieChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) {
values := make([]float64, len(opt.SeriesList))
total := float64(0)
radiusValue := ""
for index, series := range opt.SeriesList {
if len(series.Radius) != 0 {
radiusValue = series.Radius
}
value := float64(0)
for _, item := range series.Data {
value += item.Value
@ -69,9 +73,23 @@ func pieChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) {
cy := box.Height() >> 1
diameter := chart.MinInt(box.Width(), box.Height())
radius := float64(diameter) * defaultRadiusPercent
var radius float64
if len(radiusValue) != 0 {
v := convertPercent(radiusValue)
if v != -1 {
radius = float64(diameter) * v
} else {
radius, _ = strconv.ParseFloat(radiusValue, 64)
}
}
if radius <= 0 {
radius = float64(diameter) * defaultRadiusPercent
}
labelRadius := radius + 20
seriesNames := opt.Legend.Data
if len(values) == 1 {
getPieStyle(theme, 0).WriteToRenderer(r)
d.moveTo(cx, cy)
@ -79,6 +97,7 @@ func pieChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) {
} else {
currentValue := float64(0)
for index, v := range values {
pieStyle := getPieStyle(theme, index)
pieStyle.WriteToRenderer(r)
d.moveTo(cx, cy)
@ -91,6 +110,13 @@ func pieChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) {
r.Close()
r.FillStroke()
series := opt.SeriesList[index]
// 是否显示label
showLabel := series.Label.Show
if !showLabel {
continue
}
// label的角度为饼块中间
angle := start + delta/2
startx := cx + int(radius*math.Cos(angle))
@ -113,8 +139,11 @@ func pieChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) {
FontSize: 10,
Font: opt.Font,
}
if !series.Label.Color.IsZero() {
textStyle.FontColor = series.Label.Color
}
textStyle.GetTextOptions().WriteToRenderer(r)
text := humanize.FtoaWithDigits(percent*100, 2) + "%"
text := NewPieLabelFormatter(seriesNames, series.Label.Formatter)(index, v, percent)
textBox := r.MeasureText(text)
textMargin := 3
x := endx + textMargin

View file

@ -24,6 +24,8 @@ package charts
import (
"math"
"github.com/dustin/go-humanize"
)
type Range struct {
@ -62,6 +64,17 @@ func NewRange(min, max float64, divideCount int) Range {
}
}
func (r Range) Values() []string {
offset := (r.Max - r.Min) / float64(r.divideCount)
values := make([]string, 0)
for i := 0; i <= r.divideCount; i++ {
v := r.Min + float64(i)*offset
value := humanize.CommafWithDigits(v, 2)
values = append(values, value)
}
return values
}
func (r *Range) getHeight(value float64) int {
v := (value - r.Min) / (r.Max - r.Min)
return int(v * float64(r.Size))

108
series.go Normal file
View file

@ -0,0 +1,108 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"strings"
"github.com/dustin/go-humanize"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
type SeriesData struct {
Value float64
Style chart.Style
}
func NewSeriesFromValues(values []float64, chartType ...string) Series {
s := Series{
Data: NewSeriesDataFromValues(values),
}
if len(chartType) != 0 {
s.Type = chartType[0]
}
return s
}
func NewSeriesDataFromValues(values []float64) []SeriesData {
data := make([]SeriesData, len(values))
for index, value := range values {
data[index] = SeriesData{
Value: value,
}
}
return data
}
type SeriesLabel struct {
Formatter string
Color drawing.Color
Show bool
}
type Series struct {
index int
Type string
Data []SeriesData
YAxisIndex int
Style chart.Style
Label SeriesLabel
Name string
// Radius of Pie chart, e.g.: 40%
Radius string
}
type LabelFormatter func(index int, value float64, percent float64) string
func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter {
if len(layout) == 0 {
layout = "{b}: {d}"
}
return NewLabelFormatter(seriesNames, layout)
}
func NewValueLabelFormater(seriesNames []string, layout string) LabelFormatter {
if len(layout) == 0 {
layout = "{c}"
}
return NewLabelFormatter(seriesNames, layout)
}
func NewLabelFormatter(seriesNames []string, layout string) LabelFormatter {
return func(index int, value, percent float64) string {
// 如果无percent的则设置为<0
percentText := ""
if percent >= 0 {
percentText = humanize.FtoaWithDigits(percent*100, 2) + "%"
}
valueText := humanize.FtoaWithDigits(value, 2)
name := ""
if len(seriesNames) > index {
name = seriesNames[index]
}
text := strings.ReplaceAll(layout, "{c}", valueText)
text = strings.ReplaceAll(text, "{d}", percentText)
text = strings.ReplaceAll(text, "{b}", name)
return text
}
}

View file

@ -23,7 +23,6 @@
package charts
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
@ -138,7 +137,6 @@ func TestDrawTitle(t *testing.T) {
data, err := d.Bytes()
assert.Nil(err)
assert.NotEmpty(data)
fmt.Println(string(data))
assert.Equal(tt.result, string(data))
}
}

View file

@ -104,3 +104,8 @@ func isFalse(flag *bool) bool {
}
return false
}
func toFloatPoint(f float64) *float64 {
v := f
return &v
}

View file

@ -27,8 +27,11 @@ import (
)
type YAxisOption struct {
// The minimun value of axis.
Min *float64
// The maximum value of axis.
Max *float64
// Hidden y axis
Hidden bool
}

87
yaxis_test.go Normal file
View file

@ -0,0 +1,87 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
)
func TestDrawYAxis(t *testing.T) {
assert := assert.New(t)
newDraw := func() *Draw {
d, _ := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
return d
}
tests := []struct {
newDraw func() *Draw
newOption func() *ChartOption
xAxisHeight int
result string
}{
{
newDraw: newDraw,
newOption: func() *ChartOption {
return &ChartOption{
YAxis: YAxisOption{
Max: toFloatPoint(20),
},
SeriesList: []Series{
{
Data: []SeriesData{
{
Value: 1,
},
{
Value: 2,
},
},
},
},
}
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 50 10\nL 50 290\" style=\"stroke-width:1;stroke:none;fill:none\"/><path d=\"M 50 10\nL 390 10\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 57\nL 390 57\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 104\nL 390 104\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 151\nL 390 151\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 198\nL 390 198\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 244\nL 390 244\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><text x=\"36\" y=\"294\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">0</text><text x=\"18\" y=\"248\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">3.33</text><text x=\"18\" y=\"202\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">6.66</text><text x=\"29\" y=\"155\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">10</text><text x=\"11\" y=\"108\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">13.33</text><text x=\"11\" y=\"61\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">16.66</text><text x=\"29\" y=\"14\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20</text></svg>",
},
}
for _, tt := range tests {
d := tt.newDraw()
r, err := drawYAxis(d, tt.newOption(), tt.xAxisHeight, chart.NewBox(10, 10, 10, 10))
assert.Nil(err)
assert.Equal(&Range{
divideCount: 6,
Max: 20,
Size: 280,
}, r)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal(tt.result, string(data))
}
}