diff --git a/axis.go b/axis.go index e6488cd..14648cf 100644 --- a/axis.go +++ b/axis.go @@ -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-- } diff --git a/bar_chart.go b/bar_chart.go index dfaf50f..d9917f1 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -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) } } diff --git a/chart.go b/chart.go index a5cf8ab..a485df5 100644 --- a/chart.go +++ b/chart.go @@ -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 + } + o := opt + o.SeriesList = barSeries + _, 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 + }, } - if len(barSeries) != 0 { - o := opt - o.SeriesList = barSeries - _, err = barChartRender(o, result) + + 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 diff --git a/draw_test.go b/draw_test.go index 65c2398..9627d0e 100644 --- a/draw_test.go +++ b/draw_test.go @@ -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)) } } diff --git a/line_chart.go b/line_chart.go index cf769e8..c9b1b7a 100644 --- a/line_chart.go +++ b/line_chart.go @@ -40,33 +40,52 @@ 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, }) - index := series.index - if index == 0 { - index = i + if !series.Label.Show { + continue } - seriesColor := theme.GetSeriesColor(index) - dotFillColor := drawing.ColorWhite - if theme.IsDark() { - dotFillColor = seriesColor + text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) + labelStyle := chart.Style{ + FontColor: theme.GetTextColor(), + FontSize: 10, + Font: opt.Font, } - d.Line(points, LineStyle{ - StrokeColor: seriesColor, - StrokeWidth: 2, - DotColor: seriesColor, - DotWidth: 2, - DotFillColor: dotFillColor, - }) + 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 + } + seriesColor := theme.GetSeriesColor(index) + dotFillColor := drawing.ColorWhite + if theme.IsDark() { + dotFillColor = seriesColor + } + d.Line(points, LineStyle{ + StrokeColor: seriesColor, + StrokeWidth: 2, + DotColor: seriesColor, + DotWidth: 2, + DotFillColor: dotFillColor, + }) } return result.d, nil diff --git a/pie_chart.go b/pie_chart.go index 210681c..d2cd029 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -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 diff --git a/range.go b/range.go index ec9265d..8a3db1e 100644 --- a/range.go +++ b/range.go @@ -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)) diff --git a/series.go b/series.go new file mode 100644 index 0000000..1ab95f8 --- /dev/null +++ b/series.go @@ -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 + } +} diff --git a/title_test.go b/title_test.go index 9556d9c..e5f895c 100644 --- a/title_test.go +++ b/title_test.go @@ -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)) } } diff --git a/util.go b/util.go index 10fcb18..9765bb5 100644 --- a/util.go +++ b/util.go @@ -104,3 +104,8 @@ func isFalse(flag *bool) bool { } return false } + +func toFloatPoint(f float64) *float64 { + v := f + return &v +} diff --git a/yaxis.go b/yaxis.go index 79ed299..8603924 100644 --- a/yaxis.go +++ b/yaxis.go @@ -27,8 +27,11 @@ import ( ) type YAxisOption struct { - Min *float64 - Max *float64 + // The minimun value of axis. + Min *float64 + // The maximum value of axis. + Max *float64 + // Hidden y axis Hidden bool } diff --git a/yaxis_test.go b/yaxis_test.go new file mode 100644 index 0000000..634ff59 --- /dev/null +++ b/yaxis_test.go @@ -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: "\\n03.336.661013.3316.6620", + }, + } + + 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)) + } +}