feat: support funnel chart

This commit is contained in:
vicanso 2022-03-05 07:22:32 +08:00
parent b93d096633
commit 5519d2eca6
9 changed files with 432 additions and 18 deletions

View file

@ -5,7 +5,7 @@
[中文](./README_zh.md) [中文](./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`. `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 ## Chart Type
Support three chart types: `line`, `bar` and `pie`. These chart types are supported: `line`, `bar`, `pie`, `radar` or `funnel`.
## Example ## 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. - `radar.indicator.min` The minimum value of indicator, default value is 0.
- `series` The series for chart - `series` The series for chart
- `series.name` Series name used for displaying in legend. - `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.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.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 - `series.label.show` Whether to show label

View file

@ -3,7 +3,7 @@
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/vicanso/go-charts/blob/master/LICENSE) [![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) [![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) `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.padding` legend的padding配置方式与图表的`padding`一致
- `legend.left` legend离容器左侧的距离其值可以为具体的像素值(20)或百分比(20%)、`left`或者`right` - `legend.left` legend离容器左侧的距离其值可以为具体的像素值(20)或百分比(20%)、`left`或者`right`
- `legend.top` legend离容器顶部的距离暂仅支持数值形式 - `legend.top` legend离容器顶部的距离暂仅支持数值形式
- `radar` 雷达图的坐标系
- `radar.indicator` 雷达图的指示器,用来指定雷达图中的多个变量(维度)
- `radar.indicator.name` 指示器名称
- `radar.indicator.max` 指示器的最大值,可选,建议设置
- `radar.indicator.min` 指示器的最小值,可选,默认为 0
- `series` 图表的数据项列表 - `series` 图表的数据项列表
- `series.name` 图表的名称,与`legend.data`对应,两者只只设置其一 - `series.name` 图表的名称,与`legend.data`对应,两者只只设置其一
- `series.type` 图表的展示类型,暂支持`line`, `bar`以及`pie`,需要注意`pie`只能单独使用 - `series.type` 图表的展示类型,暂支持`line`, `bar`, `pie`, `radar` 以及 `funnel`。需要注意只有`line``bar`可以混
- `series.radius` 饼图的半径值,如`50%`,默认为`40%` - `series.radius` 饼图的半径值,如`50%`,默认为`40%`
- `series.yAxisIndex` 该数据项使用的y轴默认为0对yAxis的配置对应 - `series.yAxisIndex` 该数据项使用的y轴默认为0对yAxis的配置对应
- `series.label.show` 是否显示文本标签(默认为对应的值) - `series.label.show` 是否显示文本标签(默认为对应的值)

View file

@ -25,6 +25,7 @@ package charts
import ( import (
"errors" "errors"
"math" "math"
"sort"
"strings" "strings"
"github.com/golang/freetype/truetype" "github.com/golang/freetype/truetype"
@ -37,6 +38,7 @@ const (
ChartTypeBar = "bar" ChartTypeBar = "bar"
ChartTypePie = "pie" ChartTypePie = "pie"
ChartTypeRadar = "radar" ChartTypeRadar = "radar"
ChartTypeFunnel = "funnel"
) )
const ( const (
@ -161,10 +163,19 @@ func (o *ChartOption) FillDefault(theme string) {
} else { } else {
seriesCount := len(o.SeriesList) seriesCount := len(o.SeriesList)
for index, name := range o.Legend.Data { for index, name := range o.Legend.Data {
if index < seriesCount { if index < seriesCount &&
len(o.SeriesList[index].Name) == 0 {
o.SeriesList[index].Name = name 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数据则隐藏 // 如果无legend数据则隐藏
if len(strings.Join(o.Legend.Data, "")) == 0 { if len(strings.Join(o.Legend.Data, "")) == 0 {
@ -289,13 +300,17 @@ func Render(opt ChartOption) (*Draw, error) {
barSeries := make([]Series, 0) barSeries := make([]Series, 0)
isPieChart := false isPieChart := false
isRadarChart := false isRadarChart := false
for index, item := range opt.SeriesList { isFunnelChart := false
item.index = index for index := range opt.SeriesList {
opt.SeriesList[index].index = index
item := opt.SeriesList[index]
switch item.Type { switch item.Type {
case ChartTypePie: case ChartTypePie:
isPieChart = true isPieChart = true
case ChartTypeRadar: case ChartTypeRadar:
isRadarChart = true isRadarChart = true
case ChartTypeFunnel:
isFunnelChart = true
case ChartTypeBar: case ChartTypeBar:
barSeries = append(barSeries, item) barSeries = append(barSeries, item)
default: default:
@ -305,7 +320,9 @@ func Render(opt ChartOption) (*Draw, error) {
// 如果指定了pie则以pie的形式处理pie不支持多类型图表 // 如果指定了pie则以pie的形式处理pie不支持多类型图表
// pie不需要axis // pie不需要axis
// radar 同样处理 // radar 同样处理
if isPieChart || isRadarChart { if isPieChart ||
isRadarChart ||
isFunnelChart {
opt.XAxis.Hidden = true opt.XAxis.Hidden = true
for index := range opt.YAxisList { for index := range opt.YAxisList {
opt.YAxisList[index].Hidden = true opt.YAxisList[index].Hidden = true
@ -340,6 +357,17 @@ func Render(opt ChartOption) (*Draw, error) {
Indicators: opt.RadarIndicators, Indicators: opt.RadarIndicators,
}, result) }, result)
}, },
// funnel render
func() error {
if !isFunnelChart {
return nil
}
return funnelChartRender(funnelChartOption{
SeriesList: opt.SeriesList,
Theme: opt.Theme,
Font: opt.Font,
}, result)
},
// bar render // bar render
func() error { func() error {
// 如果是pie或者无bar类型的series // 如果是pie或者无bar类型的series

View file

@ -330,8 +330,9 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList {
} }
continue continue
} }
// 如果是radar // 如果是radar或funnel
if item.Type == ChartTypeRadar { if item.Type == ChartTypeRadar ||
item.Type == ChartTypeFunnel {
for _, dataItem := range item.Data { for _, dataItem := range item.Data {
seriesList = append(seriesList, Series{ seriesList = append(seriesList, Series{
Name: dataItem.Name, Name: dataItem.Name,

View file

@ -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{ Legend: charts.LegendOption{
@ -1552,6 +1603,91 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
} }
] ]
}`, }`,
`{
"title": {
"text": "Funnel"
},
"tooltip": {
"trigger": "item",
"formatter": "{a} <br/>{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": { "legend": {
"top": "-140", "top": "-140",

141
funnel.go Normal file
View file

@ -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
}

91
funnel_test.go Normal file
View file

@ -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("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"250\" height=\"150\">\\n<path d=\"M 0 0\nL 250 0\nL 225 28\nL 25 28\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(115,192,222,1.0)\"/><text x=\"89\" y=\"14\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Show(100%)</text><path d=\"M 25 30\nL 225 30\nL 200 58\nL 50 58\nL 25 30\" style=\"stroke-width:0;stroke:none;fill:rgba(238,102,102,1.0)\"/><text x=\"94\" y=\"44\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Click(80%)</text><path d=\"M 50 60\nL 200 60\nL 175 88\nL 75 88\nL 50 60\" style=\"stroke-width:0;stroke:none;fill:rgba(84,112,198,1.0)\"/><text x=\"96\" y=\"74\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Visit(60%)</text><path d=\"M 75 90\nL 175 90\nL 150 118\nL 100 118\nL 75 90\" style=\"stroke-width:0;stroke:none;fill:rgba(145,204,117,1.0)\"/><text x=\"89\" y=\"104\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Inquiry(40%)</text><path d=\"M 100 120\nL 150 120\nL 125 148\nL 125 148\nL 100 120\" style=\"stroke-width:0;stroke:none;fill:rgba(250,200,88,1.0)\"/><text x=\"93\" y=\"134\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Order(20%)</text></svg>", string(data))
}

View file

@ -51,9 +51,17 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error {
if sides < 3 { if sides < 3 {
return errors.New("The count of indicator should be >= 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 { if indicator.Max <= 0 {
return errors.New("The max of indicator should be > 0") opt.Indicators[index].Max = maxValues[index]
} }
} }
d, err := NewDraw(DrawOption{ d, err := NewDraw(DrawOption{

View file

@ -115,6 +115,10 @@ type Series struct {
MarkPoint SeriesMarkPoint MarkPoint SeriesMarkPoint
// Make line for series // Make line for series
MarkLine SeriesMarkLine MarkLine SeriesMarkLine
// Max value of series
Min *float64
// Min value of series
Max *float64
} }
type SeriesList []Series type SeriesList []Series