diff --git a/chart.go b/chart.go index 5178b04..2903051 100644 --- a/chart.go +++ b/chart.go @@ -33,9 +33,10 @@ import ( ) const ( - ChartTypeLine = "line" - ChartTypeBar = "bar" - ChartTypePie = "pie" + ChartTypeLine = "line" + ChartTypeBar = "bar" + ChartTypePie = "pie" + ChartTypeRadar = "radar" ) const ( @@ -49,6 +50,8 @@ type Point struct { } const labelFontSize = 10 +const defaultDotWidth = 2.0 +const defaultStrokeWidth = 2.0 var defaultChartWidth = 600 var defaultChartHeight = 400 @@ -82,6 +85,8 @@ type ChartOption struct { Box chart.Box // The series list SeriesList SeriesList + // The radar indicator list + RadarIndicators []RadarIndicator // The background color of chart BackgroundColor drawing.Color // The child charts @@ -283,11 +288,14 @@ func Render(opt ChartOption) (*Draw, error) { lineSeries := make([]Series, 0) barSeries := make([]Series, 0) isPieChart := false + isRadarChart := false for index, item := range opt.SeriesList { item.index = index switch item.Type { case ChartTypePie: isPieChart = true + case ChartTypeRadar: + isRadarChart = true case ChartTypeBar: barSeries = append(barSeries, item) default: @@ -296,7 +304,8 @@ func Render(opt ChartOption) (*Draw, error) { } // 如果指定了pie,则以pie的形式处理,pie不支持多类型图表 // pie不需要axis - if isPieChart { + // radar 同样处理 + if isPieChart || isRadarChart { opt.XAxis.Hidden = true for index := range opt.YAxisList { opt.YAxisList[index].Hidden = true @@ -313,12 +322,23 @@ func Render(opt ChartOption) (*Draw, error) { if !isPieChart { return nil } - err := pieChartRender(pieChartOption{ + return pieChartRender(pieChartOption{ SeriesList: opt.SeriesList, Theme: opt.Theme, Font: opt.Font, }, result) - return err + }, + // radar render + func() error { + if !isRadarChart { + return nil + } + return radarChartRender(radarChartOption{ + SeriesList: opt.SeriesList, + Theme: opt.Theme, + Font: opt.Font, + Indicators: opt.RadarIndicators, + }, result) }, // bar render func() error { diff --git a/draw.go b/draw.go index bc3d9e8..ae52f05 100644 --- a/draw.go +++ b/draw.go @@ -309,3 +309,16 @@ func (d *Draw) setBackground(width, height int, color drawing.Color) { r.LineTo(0, 0) r.FillStroke() } + +func (d *Draw) polygon(center Point, radius float64, sides int) { + points := getPolygonPoints(center, radius, sides) + for i, p := range points { + if i == 0 { + d.moveTo(p.X, p.Y) + } else { + d.lineTo(p.X, p.Y) + } + } + d.lineTo(points[0].X, points[0].Y) + d.Render.Stroke() +} diff --git a/examples/charts/main.go b/examples/charts/main.go index cac7c10..b175126 100644 --- a/examples/charts/main.go +++ b/examples/charts/main.go @@ -496,6 +496,66 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Radius: "35%", }), }, + // 雷达图 + { + Title: charts.TitleOption{ + Text: "Basic Radar Chart", + }, + Legend: charts.NewLegendOption([]string{ + "Allocated Budget", + "Actual Spending", + }), + RadarIndicators: []charts.RadarIndicator{ + { + Name: "Sales", + Max: 6500, + }, + { + Name: "Administration", + Max: 16000, + }, + { + Name: "Information Technology", + Max: 30000, + }, + { + Name: "Customer Support", + Max: 38000, + }, + { + Name: "Development", + Max: 52000, + }, + { + Name: "Marketing", + Max: 25000, + }, + }, + SeriesList: charts.SeriesList{ + { + Type: charts.ChartTypeRadar, + Data: charts.NewSeriesDataFromValues([]float64{ + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }), + }, + { + Type: charts.ChartTypeRadar, + Data: charts.NewSeriesDataFromValues([]float64{ + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, + }), + }, + }, + }, // 多图展示 { Legend: charts.LegendOption{ diff --git a/line_chart.go b/line_chart.go index c51997f..ac9091c 100644 --- a/line_chart.go +++ b/line_chart.go @@ -114,7 +114,7 @@ func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPoin StrokeColor: seriesColor, StrokeWidth: 2, DotColor: seriesColor, - DotWidth: 2, + DotWidth: defaultDotWidth, DotFillColor: dotFillColor, }) // draw mark point diff --git a/pie_chart.go b/pie_chart.go index f581273..4833c34 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -24,14 +24,11 @@ package charts import ( "math" - "strconv" "github.com/golang/freetype/truetype" "github.com/wcharczuk/go-chart/v2" ) -const defaultRadiusPercent = 0.4 - func getPieStyle(theme *Theme, index int) chart.Style { seriesColor := theme.GetSeriesColor(index) return chart.Style{ @@ -47,22 +44,6 @@ type pieChartOption struct { SeriesList SeriesList } -func getPieRadius(diameter float64, radiusValue string) float64 { - 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 - } - return radius -} - func pieChartRender(opt pieChartOption, result *basicRenderResult) error { d, err := NewDraw(DrawOption{ Parent: result.d, @@ -95,7 +76,7 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error { cy := box.Height() >> 1 diameter := chart.MinInt(box.Width(), box.Height()) - radius := getPieRadius(float64(diameter), radiusValue) + radius := getRadius(float64(diameter), radiusValue) labelLineWidth := 15 if radius < 50 { diff --git a/pie_chart_test.go b/pie_chart_test.go index 92ef6d0..7cb9fde 100644 --- a/pie_chart_test.go +++ b/pie_chart_test.go @@ -30,14 +30,6 @@ import ( "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestGetPieRadius(t *testing.T) { - assert := assert.New(t) - - assert.Equal(50.0, getPieRadius(100, "50%")) - assert.Equal(30.0, getPieRadius(100, "30")) - assert.Equal(40.0, getPieRadius(100, "")) -} - func TestPieChartRender(t *testing.T) { assert := assert.New(t) diff --git a/radar_chart.go b/radar_chart.go new file mode 100644 index 0000000..667e3a0 --- /dev/null +++ b/radar_chart.go @@ -0,0 +1,183 @@ +// 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 ( + "errors" + + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +// 线 E0E6F1 +// 填充 rgb(210,219,238) fill-opacity="0.2" + +type RadarIndicator struct { + Name string + Max float64 +} + +type radarChartOption struct { + Theme string + Font *truetype.Font + SeriesList SeriesList + Indicators []RadarIndicator +} + +func radarChartRender(opt radarChartOption, result *basicRenderResult) error { + sides := len(opt.Indicators) + if sides < 3 { + return errors.New("The count of indicator should be >= 3") + } + for _, indicator := range opt.Indicators { + if indicator.Max <= 0 { + return errors.New("The max of indicator should be > 0") + } + } + d, err := NewDraw(DrawOption{ + Parent: result.d, + }, PaddingOption(chart.Box{ + Top: result.titleBox.Height(), + })) + if err != nil { + return err + } + radiusValue := "" + for _, series := range opt.SeriesList { + if len(series.Radius) != 0 { + radiusValue = series.Radius + } + } + + box := d.Box + cx := box.Width() >> 1 + cy := box.Height() >> 1 + diameter := chart.MinInt(box.Width(), box.Height()) + radius := getRadius(float64(diameter), radiusValue) + + theme := NewTheme(opt.Theme) + + divideCount := 5 + divideRadius := float64(int(radius / float64(divideCount))) + radius = divideRadius * float64(divideCount) + + style := chart.Style{ + StrokeColor: theme.GetAxisSplitLineColor(), + StrokeWidth: 1, + } + r := d.Render + style.WriteToRenderer(r) + center := Point{ + X: cx, + Y: cy, + } + for i := 0; i < divideCount; i++ { + d.polygon(center, divideRadius*float64(i+1), sides) + } + points := getPolygonPoints(center, radius, sides) + for _, p := range points { + d.moveTo(center.X, center.Y) + d.lineTo(p.X, p.Y) + d.Render.Stroke() + } + // 文本 + textStyle := chart.Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + } + textStyle.GetTextOptions().WriteToRenderer(r) + offset := 5 + // 文本生成 + for index, p := range points { + name := opt.Indicators[index].Name + b := r.MeasureText(name) + isXCenter := p.X == center.X + isYCenter := p.Y == center.Y + isRight := p.X > center.X + isLeft := p.X < center.X + isTop := p.Y < center.Y + isBottom := p.Y > center.Y + x := p.X + y := p.Y + if isXCenter { + x -= b.Width() >> 1 + if isTop { + y -= b.Height() + } else { + y += b.Height() + } + } + if isYCenter { + y += b.Height() >> 1 + } + if isTop { + y += offset + } + if isBottom { + y += offset + } + if isRight { + x += offset + } + if isLeft { + x -= (b.Width() + offset) + } + d.text(name, x, y) + } + + // 雷达图 + angles := getPolygonPointAngles(sides) + maxCount := len(opt.Indicators) + for i, series := range opt.SeriesList { + linePoints := make([]Point, 0, maxCount) + for j, item := range series.Data { + if j >= maxCount { + continue + } + percent := item.Value / opt.Indicators[j].Max + r := percent * radius + p := getPolygonPoint(center, r, angles[j]) + linePoints = append(linePoints, p) + } + color := theme.GetSeriesColor(i) + dotFillColor := drawing.ColorWhite + if theme.IsDark() { + dotFillColor = color + } + linePoints = append(linePoints, linePoints[0]) + s := LineStyle{ + StrokeColor: color, + StrokeWidth: defaultStrokeWidth, + DotWidth: defaultDotWidth, + DotColor: color, + DotFillColor: dotFillColor, + FillColor: color.WithAlpha(20), + } + d.lineStroke(linePoints, s) + d.lineFill(linePoints, s) + d.lineDot(linePoints[0:len(linePoints)-1], s) + } + return nil +} diff --git a/util.go b/util.go index 2adaba8..c895cc3 100644 --- a/util.go +++ b/util.go @@ -23,6 +23,7 @@ package charts import ( + "math" "regexp" "strconv" "strings" @@ -168,3 +169,47 @@ func parseColor(color string) drawing.Color { } return c } + +const defaultRadiusPercent = 0.4 + +func getRadius(diameter float64, radiusValue string) float64 { + 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 + } + return radius +} + +func getPolygonPointAngles(sides int) []float64 { + angles := make([]float64, sides) + for i := 0; i < sides; i++ { + angle := 2*math.Pi/float64(sides)*float64(i) - (math.Pi / 2) + angles[i] = angle + } + return angles +} + +func getPolygonPoint(center Point, radius, angle float64) Point { + x := center.X + int(radius*math.Cos(angle)) + y := center.Y + int(radius*math.Sin(angle)) + return Point{ + X: x, + Y: y, + } +} + +func getPolygonPoints(center Point, radius float64, sides int) []Point { + points := make([]Point, sides) + for i, angle := range getPolygonPointAngles(sides) { + points[i] = getPolygonPoint(center, radius, angle) + } + return points +} diff --git a/util_test.go b/util_test.go index dc5d98e..6489ab3 100644 --- a/util_test.go +++ b/util_test.go @@ -70,6 +70,14 @@ func TestAutoDivide(t *testing.T) { }, autoDivide(600, 7)) } +func TestGetRadius(t *testing.T) { + assert := assert.New(t) + + assert.Equal(50.0, getRadius(100, "50%")) + assert.Equal(30.0, getRadius(100, "30")) + assert.Equal(40.0, getRadius(100, "")) +} + func TestMeasureTextMaxWidthHeight(t *testing.T) { assert := assert.New(t) r, err := chart.SVG(400, 300)