From b93d0966335862e20cf951d043ef3de7de9d2b8e Mon Sep 17 00:00:00 2001 From: vicanso Date: Thu, 3 Mar 2022 23:01:42 +0800 Subject: [PATCH] feat: support radar option of echarts --- README.md | 5 ++ chart_test.go | 65 ++++++++++++++++++++++++- draw.go | 19 ++++++++ draw_test.go | 50 ++++++++++++++++++++ echarts.go | 69 +++++++++++++++++++++------ echarts_test.go | 38 +++++++++------ examples/charts/main.go | 69 +++++++++++++++++++++++++++ line.go | 32 ++++++------- radar_chart.go | 18 +++---- radar_chart_test.go | 102 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 411 insertions(+), 56 deletions(-) create mode 100644 radar_chart_test.go diff --git a/README.md b/README.md index 6710cdd..30d9675 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,11 @@ The name with `[]` is new parameter, others are the same as `echarts`. - `legend.padding` legend space around content - `legend.left` Distance between legend component and the left side of the container. Left value can be instant pixel value like 20; it can also be a percentage value relative to container width like '20%'; and it can also be 'left', 'center', or 'right'. - `legend.top` Distance between legend component and the top side of the container. Top value can be instant pixel value like 20 +- `radar` Coordinate for radar charts + - `radar.indicator` Indicator of radar chart, which is used to assign multiple variables(dimensions) in radar chart + - `radar.indicator.name` Indicator's name + - `radar.indicator.max` The maximum value of indicator + - `radar.indicator.min` The minimum value of indicator, default value is 0. - `series` The series for chart - `series.name` Series name used for displaying in legend. - `series.type` Series type: `line`, `bar` or`pie` diff --git a/chart_test.go b/chart_test.go index 4fc3d20..1f4d1f4 100644 --- a/chart_test.go +++ b/chart_test.go @@ -270,12 +270,75 @@ func TestChartRender(t *testing.T) { Radius: "35%", }), }, + { + Legend: NewLegendOption([]string{ + "Allocated Budget", + "Actual Spending", + }), + Box: chart.Box{ + Top: 20, + Left: 0, + Right: 200, + Bottom: 120, + }, + RadarIndicators: []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: SeriesList{ + { + Type: ChartTypeRadar, + Data: NewSeriesDataFromValues([]float64{ + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }), + }, + { + Type: ChartTypeRadar, + index: 1, + Data: NewSeriesDataFromValues([]float64{ + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, + }), + }, + }, + }, }, }) assert.Nil(err) data, err := d.Bytes() assert.Nil(err) - assert.Equal("\\n2012201320142015201620170153045607590Milk TeaMatcha LatteCheese CocoaWalnut BrownieMilk Tea: 34.03%Matcha Latte: 27.66%Cheese Cocoa: 22.32%Walnut Brownie: 15.96%", string(data)) + assert.Equal("\\n2012201320142015201620170153045607590Milk TeaMatcha LatteCheese CocoaWalnut BrownieMilk Tea: 34.03%Matcha Latte: 27.66%Cheese Cocoa: 22.32%Walnut Brownie: 15.96%SalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketingAllocated BudgetActual Spending", string(data)) } func BenchmarkMultiChartPNGRender(b *testing.B) { diff --git a/draw.go b/draw.go index ae52f05..f6ee73c 100644 --- a/draw.go +++ b/draw.go @@ -322,3 +322,22 @@ func (d *Draw) polygon(center Point, radius float64, sides int) { d.lineTo(points[0].X, points[0].Y) d.Render.Stroke() } + +func (d *Draw) fill(points []Point, s chart.Style) { + if !s.ShouldDrawFill() { + return + } + r := d.Render + var x, y int + s.GetFillOptions().WriteDrawingOptionsToRenderer(r) + for index, point := range points { + x = point.X + y = point.Y + if index == 0 { + d.moveTo(x, y) + } else { + d.lineTo(x, y) + } + } + r.Fill() +} diff --git a/draw_test.go b/draw_test.go index 712641a..694d72a 100644 --- a/draw_test.go +++ b/draw_test.go @@ -409,6 +409,56 @@ func TestDraw(t *testing.T) { }, result: "\\n", }, + // polygon + { + fn: func(d *Draw) { + chart.Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }.WriteToRenderer(d.Render) + d.polygon(Point{ + X: 100, + Y: 100, + }, 50, 6) + }, + result: "\\n", + }, + // fill + { + fn: func(d *Draw) { + d.fill([]Point{ + { + X: 0, + Y: 0, + }, + { + X: 0, + Y: 100, + }, + { + X: 100, + Y: 100, + }, + { + X: 0, + Y: 0, + }, + }, chart.Style{ + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + }, + result: "\\n", + }, } for _, tt := range tests { d, err := NewDraw(DrawOption{ diff --git a/echarts.go b/echarts.go index 926ab5f..086dc45 100644 --- a/echarts.go +++ b/echarts.go @@ -69,10 +69,30 @@ func (es *EChartStyle) ToStyle() chart.Style { } } +type EChartsSeriesDataValue struct { + values []float64 +} + +func (value *EChartsSeriesDataValue) UnmarshalJSON(data []byte) error { + data = convertToArray(data) + return json.Unmarshal(data, &value.values) +} +func (value *EChartsSeriesDataValue) First() float64 { + if len(value.values) == 0 { + return 0 + } + return value.values[0] +} +func NewEChartsSeriesDataValue(values ...float64) EChartsSeriesDataValue { + return EChartsSeriesDataValue{ + values: values, + } +} + type EChartsSeriesData struct { - Value float64 `json:"value"` - Name string `json:"name"` - ItemStyle EChartStyle `json:"itemStyle"` + Value EChartsSeriesDataValue `json:"value"` + Name string `json:"name"` + ItemStyle EChartStyle `json:"itemStyle"` } type _EChartsSeriesData EChartsSeriesData @@ -88,7 +108,11 @@ func (es *EChartsSeriesData) UnmarshalJSON(data []byte) error { if err != nil { return err } - es.Value = v + es.Value = EChartsSeriesDataValue{ + values: []float64{ + v, + }, + } return nil } v := _EChartsSeriesData{} @@ -291,7 +315,7 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { if item.Type == ChartTypePie { for _, dataItem := range item.Data { seriesList = append(seriesList, Series{ - Type: ChartTypePie, + Type: item.Type, Name: dataItem.Name, Label: SeriesLabel{ Show: true, @@ -299,17 +323,28 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { Radius: item.Radius, Data: []SeriesData{ { - Value: dataItem.Value, + Value: dataItem.Value.First(), }, }, }) } continue } + // 如果是radar + if item.Type == ChartTypeRadar { + for _, dataItem := range item.Data { + seriesList = append(seriesList, Series{ + Name: dataItem.Name, + Type: item.Type, + Data: NewSeriesDataFromValues(dataItem.Value.values), + }) + } + continue + } data := make([]SeriesData, len(item.Data)) for j, dataItem := range item.Data { data[j] = SeriesData{ - Value: dataItem.Value, + Value: dataItem.Value.First(), Style: dataItem.ItemStyle.ToStyle(), } } @@ -364,9 +399,12 @@ type EChartsOption struct { TextStyle EChartsTextStyle `json:"textStyle"` SubtextStyle EChartsTextStyle `json:"subtextStyle"` } `json:"title"` - XAxis EChartsXAxis `json:"xAxis"` - YAxis EChartsYAxis `json:"yAxis"` - Legend EChartsLegend `json:"legend"` + XAxis EChartsXAxis `json:"xAxis"` + YAxis EChartsYAxis `json:"yAxis"` + Legend EChartsLegend `json:"legend"` + Radar struct { + Indicator []RadarIndicator `json:"indicator"` + } `json:"radar"` Series EChartsSeriesList `json:"series"` Children []EChartsOption `json:"children"` } @@ -397,11 +435,12 @@ func (eo *EChartsOption) ToOption() ChartOption { Align: eo.Legend.Align, Orient: eo.Legend.Orient, }, - Width: eo.Width, - Height: eo.Height, - Padding: eo.Padding.Box, - Box: eo.Box, - SeriesList: eo.Series.ToSeriesList(), + RadarIndicators: eo.Radar.Indicator, + Width: eo.Width, + Height: eo.Height, + Padding: eo.Padding.Box, + Box: eo.Box, + SeriesList: eo.Series.ToSeriesList(), } if len(eo.XAxis.Data) != 0 { xAxisData := eo.XAxis.Data[0] diff --git a/echarts_test.go b/echarts_test.go index f3b70f1..05c2a40 100644 --- a/echarts_test.go +++ b/echarts_test.go @@ -253,14 +253,14 @@ func TestEChartsSeriesData(t *testing.T) { err := esd.UnmarshalJSON([]byte(`123`)) assert.Nil(err) assert.Equal(EChartsSeriesData{ - Value: 123, + Value: NewEChartsSeriesDataValue(123), }, esd) esd = EChartsSeriesData{} err = esd.UnmarshalJSON([]byte(`2.1`)) assert.Nil(err) assert.Equal(EChartsSeriesData{ - Value: 2.1, + Value: NewEChartsSeriesDataValue(2.1), }, esd) esd = EChartsSeriesData{} @@ -273,7 +273,7 @@ func TestEChartsSeriesData(t *testing.T) { }`)) assert.Nil(err) assert.Equal(EChartsSeriesData{ - Value: 123.12, + Value: NewEChartsSeriesDataValue(123.12), Name: "test", ItemStyle: EChartStyle{ Color: "#aaa", @@ -308,10 +308,10 @@ func TestEChartsSeries(t *testing.T) { Name: "Email", Data: []EChartsSeriesData{ { - Value: 120, + Value: NewEChartsSeriesDataValue(120), }, { - Value: 132, + Value: NewEChartsSeriesDataValue(132), }, }, }, @@ -320,10 +320,10 @@ func TestEChartsSeries(t *testing.T) { Type: "bar", Data: []EChartsSeriesData{ { - Value: 220, + Value: NewEChartsSeriesDataValue(220), }, { - Value: 182, + Value: NewEChartsSeriesDataValue(182), }, }, }, @@ -430,12 +430,20 @@ func TestEChartsSeriesList(t *testing.T) { Radius: "30%", Data: []EChartsSeriesData{ { - Name: "1", - Value: 1, + Name: "1", + Value: EChartsSeriesDataValue{ + values: []float64{ + 1, + }, + }, }, { - Name: "2", - Value: 2, + Name: "2", + Value: EChartsSeriesDataValue{ + values: []float64{ + 2, + }, + }, }, }, }, @@ -474,13 +482,13 @@ func TestEChartsSeriesList(t *testing.T) { Type: ChartTypeBar, Data: []EChartsSeriesData{ { - Value: 1, + Value: NewEChartsSeriesDataValue(1), ItemStyle: EChartStyle{ Color: "#aaa", }, }, { - Value: 2, + Value: NewEChartsSeriesDataValue(2), }, }, YAxisIndex: 1, @@ -488,10 +496,10 @@ func TestEChartsSeriesList(t *testing.T) { { Data: []EChartsSeriesData{ { - Value: 3, + Value: NewEChartsSeriesDataValue(3), }, { - Value: 4, + Value: NewEChartsSeriesDataValue(4), }, }, ItemStyle: EChartStyle{ diff --git a/examples/charts/main.go b/examples/charts/main.go index b175126..3ecb711 100644 --- a/examples/charts/main.go +++ b/examples/charts/main.go @@ -1483,6 +1483,75 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { } ] }`, + `{ + "title": { + "text": "Basic Radar Chart" + }, + "legend": { + "data": [ + "Allocated Budget", + "Actual Spending" + ] + }, + "radar": { + "indicator": [ + { + "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 + } + ] + }, + "series": [ + { + "name": "Budget vs spending", + "type": "radar", + "data": [ + { + "value": [ + 4200, + 3000, + 20000, + 35000, + 50000, + 18000 + ], + "name": "Allocated Budget" + }, + { + "value": [ + 5000, + 14000, + 28000, + 26000, + 42000, + 21000 + ], + "name": "Actual Spending" + } + ] + } + ] + }`, `{ "legend": { "top": "-140", diff --git a/line.go b/line.go index e1f3583..0fc25d6 100644 --- a/line.go +++ b/line.go @@ -55,25 +55,23 @@ func (d *Draw) lineFill(points []Point, style LineStyle) { if !(s.ShouldDrawStroke() && s.ShouldDrawFill()) { return } - r := d.Render - var x, y int - s.GetFillOptions().WriteDrawingOptionsToRenderer(r) - for index, point := range points { - x = point.X - y = point.Y - if index == 0 { - d.moveTo(x, y) - } else { - d.lineTo(x, y) - } - } - height := d.Box.Height() - d.lineTo(x, height) + + newPoints := make([]Point, len(points)) + copy(newPoints, points) x0 := points[0].X y0 := points[0].Y - d.lineTo(x0, height) - d.lineTo(x0, y0) - r.Fill() + height := d.Box.Height() + newPoints = append(newPoints, Point{ + X: points[len(points)-1].X, + Y: height, + }, Point{ + X: x0, + Y: height, + }, Point{ + X: x0, + Y: y0, + }) + d.fill(newPoints, style.Style()) } func (d *Draw) lineDot(points []Point, style LineStyle) { diff --git a/radar_chart.go b/radar_chart.go index 667e3a0..c0f61b0 100644 --- a/radar_chart.go +++ b/radar_chart.go @@ -30,12 +30,13 @@ import ( "github.com/wcharczuk/go-chart/v2/drawing" ) -// 线 E0E6F1 -// 填充 rgb(210,219,238) fill-opacity="0.2" - type RadarIndicator struct { + // Indicator's name Name string - Max float64 + // The maximum value of indicator + Max float64 + // The minimum value of indicator + Min float64 } type radarChartOption struct { @@ -150,18 +151,19 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error { // 雷达图 angles := getPolygonPointAngles(sides) maxCount := len(opt.Indicators) - for i, series := range opt.SeriesList { + for _, 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 + indicator := opt.Indicators[j] + percent := (item.Value - indicator.Min) / (indicator.Max - indicator.Min) r := percent * radius p := getPolygonPoint(center, r, angles[j]) linePoints = append(linePoints, p) } - color := theme.GetSeriesColor(i) + color := theme.GetSeriesColor(series.index) dotFillColor := drawing.ColorWhite if theme.IsDark() { dotFillColor = color @@ -176,7 +178,7 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error { FillColor: color.WithAlpha(20), } d.lineStroke(linePoints, s) - d.lineFill(linePoints, s) + d.fill(linePoints, s.Style()) d.lineDot(linePoints[0:len(linePoints)-1], s) } return nil diff --git a/radar_chart_test.go b/radar_chart_test.go new file mode 100644 index 0000000..c5d2aa9 --- /dev/null +++ b/radar_chart_test.go @@ -0,0 +1,102 @@ +// 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 TestRadarChartRender(t *testing.T) { + assert := assert.New(t) + + d, err := NewDraw(DrawOption{ + Width: 250, + Height: 150, + }) + assert.Nil(err) + + f, _ := chart.GetDefaultFont() + err = radarChartRender(radarChartOption{ + Font: f, + Indicators: []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: SeriesList{ + { + Type: ChartTypeRadar, + Data: NewSeriesDataFromValues([]float64{ + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }), + }, + { + Type: ChartTypeRadar, + index: 1, + Data: NewSeriesDataFromValues([]float64{ + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, + }), + }, + }, + }, &basicRenderResult{ + d: d, + }) + assert.Nil(err) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal("\\nSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", string(data)) +}