diff --git a/README.md b/README.md index 30d9675..6cafc68 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [中文](./README_zh.md) -`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and three themes: `light`, `dark` and `grafana`. +`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. `Apache ECharts` is popular among Front-end developers, so `go-charts` supports the option of `Apache ECharts`. Developers can generate charts almost the same as `Apache ECharts`. @@ -17,7 +17,7 @@ Screenshot of common charts, the left part is light theme, the right part is gra ## Chart Type -Support three chart types: `line`, `bar` and `pie`. +These chart types are supported: `line`, `bar`, `pie`, `radar` or `funnel`. ## Example @@ -159,7 +159,7 @@ The name with `[]` is new parameter, others are the same as `echarts`. - `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` + - `series.type` Series type: `line`, `bar`, `pie`, `radar` or `funnel` - `series.radius` Radius of Pie chart:`50%`, default is `40%` - `series.yAxisIndex` Index of y axis to combine with, which is useful for multiple y axes in one chart - `series.label.show` Whether to show label diff --git a/README_zh.md b/README_zh.md index f4f5d4a..e3747e7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -3,7 +3,7 @@ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/vicanso/go-charts/blob/master/LICENSE) [![Build Status](https://github.com/vicanso/go-charts/workflows/Test/badge.svg)](https://github.com/vicanso/go-charts/actions) -`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持三种主题`light`, `dark`以及`grafana`。 +`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持主题`light`, `dark`, `grafana`以及`ant`。 `Apache ECharts`在前端开发中得到众多开发者的认可,因此`go-charts`提供了兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的图表截图(主题为light与grafana): @@ -14,7 +14,7 @@ ## 支持图表类型 -暂仅支持三种的图表类型:`line`, `bar` 以及 `pie` +支持以下的图表类型:`line`, `bar`, `pie`, `radar` 以及 `funnel` ## 示例 @@ -155,9 +155,14 @@ func main() { - `legend.padding` legend的padding,配置方式与图表的`padding`一致 - `legend.left` legend离容器左侧的距离,其值可以为具体的像素值(20)或百分比(20%)、`left`或者`right` - `legend.top` legend离容器顶部的距离,暂仅支持数值形式 +- `radar` 雷达图的坐标系 + - `radar.indicator` 雷达图的指示器,用来指定雷达图中的多个变量(维度) + - `radar.indicator.name` 指示器名称 + - `radar.indicator.max` 指示器的最大值,可选,建议设置 + - `radar.indicator.min` 指示器的最小值,可选,默认为 0 - `series` 图表的数据项列表 - `series.name` 图表的名称,与`legend.data`对应,两者只只设置其一 - - `series.type` 图表的展示类型,暂支持`line`, `bar`以及`pie`,需要注意`pie`只能单独使用 + - `series.type` 图表的展示类型,暂支持`line`, `bar`, `pie`, `radar` 以及 `funnel`。需要注意只有`line`与`bar`可以混用 - `series.radius` 饼图的半径值,如`50%`,默认为`40%` - `series.yAxisIndex` 该数据项使用的y轴,默认为0,对yAxis的配置对应 - `series.label.show` 是否显示文本标签(默认为对应的值) diff --git a/chart.go b/chart.go index 2903051..e169387 100644 --- a/chart.go +++ b/chart.go @@ -25,6 +25,7 @@ package charts import ( "errors" "math" + "sort" "strings" "github.com/golang/freetype/truetype" @@ -33,10 +34,11 @@ import ( ) const ( - ChartTypeLine = "line" - ChartTypeBar = "bar" - ChartTypePie = "pie" - ChartTypeRadar = "radar" + ChartTypeLine = "line" + ChartTypeBar = "bar" + ChartTypePie = "pie" + ChartTypeRadar = "radar" + ChartTypeFunnel = "funnel" ) const ( @@ -161,10 +163,19 @@ func (o *ChartOption) FillDefault(theme string) { } else { seriesCount := len(o.SeriesList) for index, name := range o.Legend.Data { - if index < seriesCount { + if index < seriesCount && + len(o.SeriesList[index].Name) == 0 { o.SeriesList[index].Name = name } } + nameIndexDict := map[string]int{} + for index, name := range o.Legend.Data { + nameIndexDict[name] = index + } + // 保证series的顺序与legend一致 + sort.Slice(o.SeriesList, func(i, j int) bool { + return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name] + }) } // 如果无legend数据,则隐藏 if len(strings.Join(o.Legend.Data, "")) == 0 { @@ -289,13 +300,17 @@ func Render(opt ChartOption) (*Draw, error) { barSeries := make([]Series, 0) isPieChart := false isRadarChart := false - for index, item := range opt.SeriesList { - item.index = index + isFunnelChart := false + for index := range opt.SeriesList { + opt.SeriesList[index].index = index + item := opt.SeriesList[index] switch item.Type { case ChartTypePie: isPieChart = true case ChartTypeRadar: isRadarChart = true + case ChartTypeFunnel: + isFunnelChart = true case ChartTypeBar: barSeries = append(barSeries, item) default: @@ -305,7 +320,9 @@ func Render(opt ChartOption) (*Draw, error) { // 如果指定了pie,则以pie的形式处理,pie不支持多类型图表 // pie不需要axis // radar 同样处理 - if isPieChart || isRadarChart { + if isPieChart || + isRadarChart || + isFunnelChart { opt.XAxis.Hidden = true for index := range opt.YAxisList { opt.YAxisList[index].Hidden = true @@ -340,6 +357,17 @@ func Render(opt ChartOption) (*Draw, error) { Indicators: opt.RadarIndicators, }, result) }, + // funnel render + func() error { + if !isFunnelChart { + return nil + } + return funnelChartRender(funnelChartOption{ + SeriesList: opt.SeriesList, + Theme: opt.Theme, + Font: opt.Font, + }, result) + }, // bar render func() error { // 如果是pie或者无bar类型的series diff --git a/echarts.go b/echarts.go index 086dc45..fd38376 100644 --- a/echarts.go +++ b/echarts.go @@ -330,8 +330,9 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { } continue } - // 如果是radar - if item.Type == ChartTypeRadar { + // 如果是radar或funnel + if item.Type == ChartTypeRadar || + item.Type == ChartTypeFunnel { for _, dataItem := range item.Data { seriesList = append(seriesList, Series{ Name: dataItem.Name, diff --git a/examples/charts/main.go b/examples/charts/main.go index 3ecb711..1d591fc 100644 --- a/examples/charts/main.go +++ b/examples/charts/main.go @@ -556,6 +556,57 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, }, }, + // 漏斗图 + { + Title: charts.TitleOption{ + Text: "Funnel", + }, + Legend: charts.NewLegendOption([]string{ + "Show", + "Click", + "Visit", + "Inquiry", + "Order", + }), + SeriesList: []charts.Series{ + { + Type: charts.ChartTypeFunnel, + Name: "Visit", + Data: charts.NewSeriesDataFromValues([]float64{ + 60, + }), + Max: charts.NewFloatPoint(120), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Inquiry", + Data: charts.NewSeriesDataFromValues([]float64{ + 40, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Order", + Data: charts.NewSeriesDataFromValues([]float64{ + 20, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Click", + Data: charts.NewSeriesDataFromValues([]float64{ + 80, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Show", + Data: charts.NewSeriesDataFromValues([]float64{ + 100, + }), + }, + }, + }, // 多图展示 { Legend: charts.LegendOption{ @@ -1552,6 +1603,91 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { } ] }`, + `{ + "title": { + "text": "Funnel" + }, + "tooltip": { + "trigger": "item", + "formatter": "{a}
{b} : {c}%" + }, + "toolbox": { + "feature": { + "dataView": { + "readOnly": false + }, + "restore": {}, + "saveAsImage": {} + } + }, + "legend": { + "data": [ + "Show", + "Click", + "Visit", + "Inquiry", + "Order" + ] + }, + "series": [ + { + "name": "Funnel", + "type": "funnel", + "left": "10%", + "top": 60, + "bottom": 60, + "width": "80%", + "min": 0, + "max": 100, + "minSize": "0%", + "maxSize": "100%", + "sort": "descending", + "gap": 2, + "label": { + "show": true, + "position": "inside" + }, + "labelLine": { + "length": 10, + "lineStyle": { + "width": 1, + "type": "solid" + } + }, + "itemStyle": { + "borderColor": "#fff", + "borderWidth": 1 + }, + "emphasis": { + "label": { + "fontSize": 20 + } + }, + "data": [ + { + "value": 60, + "name": "Visit" + }, + { + "value": 40, + "name": "Inquiry" + }, + { + "value": 20, + "name": "Order" + }, + { + "value": 80, + "name": "Click" + }, + { + "value": 100, + "name": "Show" + } + ] + } + ] + }`, `{ "legend": { "top": "-140", diff --git a/funnel.go b/funnel.go new file mode 100644 index 0000000..2307fea --- /dev/null +++ b/funnel.go @@ -0,0 +1,141 @@ +// 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 ( + "fmt" + "sort" + + "github.com/dustin/go-humanize" + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" +) + +type funnelChartOption struct { + Theme string + Font *truetype.Font + SeriesList SeriesList +} + +func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error { + d, err := NewDraw(DrawOption{ + Parent: result.d, + }, PaddingOption(chart.Box{ + Top: result.titleBox.Height(), + })) + if err != nil { + return err + } + seriesList := make([]Series, len(opt.SeriesList)) + copy(seriesList, opt.SeriesList) + sort.Slice(seriesList, func(i, j int) bool { + // 大的数据在前 + return seriesList[i].Data[0].Value > seriesList[j].Data[0].Value + }) + max := float64(100) + min := float64(0) + for _, item := range seriesList { + if item.Max != nil { + max = *item.Max + } + if item.Min != nil { + min = *item.Min + } + } + + theme := NewTheme(opt.Theme) + gap := 2 + height := d.Box.Height() + width := d.Box.Width() + count := len(seriesList) + + h := (height - gap*(count-1)) / count + + y := 0 + widthList := make([]int, len(seriesList)) + textList := make([]string, len(seriesList)) + for index, item := range seriesList { + value := item.Data[0].Value + percent := (item.Data[0].Value - min) / (max - min) + w := int(percent * float64(width)) + widthList[index] = w + p := humanize.CommafWithDigits(value, 2) + "%" + textList[index] = fmt.Sprintf("%s(%s)", item.Name, p) + } + + for index, w := range widthList { + series := seriesList[index] + nextWidth := 0 + if index+1 < len(widthList) { + nextWidth = widthList[index+1] + } + topStartX := (width - w) >> 1 + topEndX := topStartX + w + bottomStartX := (width - nextWidth) >> 1 + bottomEndX := bottomStartX + nextWidth + points := []Point{ + { + X: topStartX, + Y: y, + }, + { + X: topEndX, + Y: y, + }, + { + X: bottomEndX, + Y: y + h, + }, + { + X: bottomStartX, + Y: y + h, + }, + { + X: topStartX, + Y: y, + }, + } + color := theme.GetSeriesColor(series.index) + d.fill(points, chart.Style{ + FillColor: color, + }) + + // 文本 + text := textList[index] + r := d.Render + textStyle := chart.Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + } + textStyle.GetTextOptions().WriteToRenderer(r) + textBox := r.MeasureText(text) + textX := width>>1 - textBox.Width()>>1 + textY := y + h>>1 + d.text(text, textX, textY) + + y += (h + gap) + } + + return nil +} diff --git a/funnel_test.go b/funnel_test.go new file mode 100644 index 0000000..530fa53 --- /dev/null +++ b/funnel_test.go @@ -0,0 +1,91 @@ +// 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 TestFunnelChartRender(t *testing.T) { + assert := assert.New(t) + + d, err := NewDraw(DrawOption{ + Width: 250, + Height: 150, + }) + assert.Nil(err) + f, _ := chart.GetDefaultFont() + err = funnelChartRender(funnelChartOption{ + Font: f, + SeriesList: []Series{ + { + Type: ChartTypeFunnel, + Name: "Visit", + Data: NewSeriesDataFromValues([]float64{ + 60, + }), + }, + { + Type: ChartTypeFunnel, + Name: "Inquiry", + Data: NewSeriesDataFromValues([]float64{ + 40, + }), + index: 1, + }, + { + Type: ChartTypeFunnel, + Name: "Order", + Data: NewSeriesDataFromValues([]float64{ + 20, + }), + index: 2, + }, + { + Type: ChartTypeFunnel, + Name: "Click", + Data: NewSeriesDataFromValues([]float64{ + 80, + }), + index: 3, + }, + { + Type: ChartTypeFunnel, + Name: "Show", + Data: NewSeriesDataFromValues([]float64{ + 100, + }), + index: 4, + }, + }, + }, &basicRenderResult{ + d: d, + }) + assert.Nil(err) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal("\\nShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", string(data)) +} diff --git a/radar_chart.go b/radar_chart.go index c0f61b0..364213d 100644 --- a/radar_chart.go +++ b/radar_chart.go @@ -51,9 +51,17 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error { if sides < 3 { return errors.New("The count of indicator should be >= 3") } - for _, indicator := range opt.Indicators { + maxValues := make([]float64, len(opt.Indicators)) + for _, series := range opt.SeriesList { + for index, item := range series.Data { + if index < len(maxValues) && item.Value > maxValues[index] { + maxValues[index] = item.Value + } + } + } + for index, indicator := range opt.Indicators { if indicator.Max <= 0 { - return errors.New("The max of indicator should be > 0") + opt.Indicators[index].Max = maxValues[index] } } d, err := NewDraw(DrawOption{ diff --git a/series.go b/series.go index 8a9ba73..a1b7486 100644 --- a/series.go +++ b/series.go @@ -115,6 +115,10 @@ type Series struct { MarkPoint SeriesMarkPoint // Make line for series MarkLine SeriesMarkLine + // Max value of series + Min *float64 + // Min value of series + Max *float64 } type SeriesList []Series