docs: add document for echarts option

This commit is contained in:
vicanso 2021-12-25 12:13:32 +08:00
parent 3406bd75a1
commit 805184b74d
8 changed files with 236 additions and 57 deletions

43
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
go:
- '1.17'
- '1.16'
- '1.15'
- '1.14'
- '1.13'
steps:
- name: Go ${{ matrix.go }} test
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run:
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin latest
- name: Lint
run: make lint
- name: Test
run: make test
- name: Bench
run: make bench

View file

@ -1,7 +1,5 @@
# go-echarts
![Alt](https://repobeats.axiom.co/api/embed/9071915842d72a909465be75eb6c12ffb7de2dcf.svg "Repobeats analytics image")
[go-chart](https://github.com/wcharczuk/go-chart)是golang常用的可视化图表库支持`svg``png`的输出,`Apache ECharts`在前端开发中得到众多开发者的认可。go-echarts则是结合两者的方式兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg``png`)方便插入至Email或分享使用。下面为常用的几种图表截图
![](./assets/go-echarts.png)
@ -45,3 +43,37 @@ func main() {
os.WriteFile("output.png", buf, 0600)
}
```
## 参数说明
- `theme` 颜色主题,支持`dark``light`模式,默认为`light`
- `padding` 图表的内边距单位px。支持以下几种模式的设置
- `padding: 5` 设置内边距为5
- `padding: [5, 10]` 设置上下的内边距为 5左右的内边距为 10
- `padding:[5, 10, 5, 10]` 分别设置`上右下左`边距
- `title` 图表标题,包括标题内容、高度、颜色等
- `title.text` 标题内容
- `title.textStyle.color` 标题文字颜色
- `title.textStyle.fontSize` 标题文字字体大小
- `title.textStyle.height` 标题高度
- `xAxis` 直角坐标系grid中的x轴由于go-echarts仅支持单一个x轴因此若参数为数组多个x轴只使用第一个配置
- `xAxis.boundaryGap` 坐标轴两边留白策略,仅支持三种设置方式`null`, `true`或者`false``null``true`时则数据点展示在两个刻度中间
- `xAxis.splitNumber` 坐标轴的分割段数,需要注意的是这个分割段数只是个预估值,最后实际显示的段数会在这个基础上根据分割后坐标轴刻度显示的易读程度作调整
- `xAxis.data` x轴的展示文案暂只支持字符串数组如["Mon", "Tue"],其数量需要与展示点一致
- `yAxis` 直角坐标系grid中的y轴最多支持两个y轴
- `yAxis.min` 坐标轴刻度最小值,若不设置则自动计算
- `yAxis.max` 坐标轴刻度最大值,若不设置则自动计算
- `yAxis.axisLabel.formatter` 刻度标签的内容格式器,如`"formatter": "{value} kg"`
- `legend` 图表中不同系列的标记
- `legend.data` 图例的数据数组,为字符串数组,如["Email", "Video Ads"]
- `legend.align` 图例标记和文本的对齐,默认为标记靠左`left`
- `legend.padding` legend的padding配置方式与图表的`padding`一致
- `legend.left` legend离容器左侧的距离其值可以为具体的像素值(20)或百分比(20%)
- `legend.right` legend离容器右侧的距离其值可以为具体的像素值(20)或百分比(20%)
- `series` 图表的数据项列表
- `series.type` 图表的展示类型,暂支持`line`, `bar`以及`pie`,需要注意`pie`只能单独使用
- `series.yAxisIndex` 该数据项使用的y轴默认为0对yAxis的配置对应
- `series.itemStyle.color` 该数据项展示时使用的颜色
- `series.data` 数据项对应的数据数组,支持以下形式的数据:
- `数值` 常用形式,数组数据为浮点数组,如[1.1, 2,3, 5.2]
- `结构体` pie图表或bar图表中指定样式使用如[{"value": 1048, "name": "Search Engine"},{"value": 735,"name": "Direct"}]

View file

@ -49,6 +49,10 @@ type (
Data []string
Align string
Padding chart.Box
Left string
Right string
Top string
Bottom string
}
Options struct {
Padding chart.Box
@ -193,11 +197,14 @@ func newChart(opt Options) *chart.Chart {
if legendSize != 0 {
c.Elements = []chart.Renderable{
LegendCustomize(c.Series, LegendOption{
Theme: opt.Theme,
TextPosition: LegendTextPositionRight,
IconDraw: DefaultLegendIconDraw,
Align: opt.Legend.Align,
Padding: opt.Legend.Padding,
Theme: opt.Theme,
IconDraw: DefaultLegendIconDraw,
Align: opt.Legend.Align,
Padding: opt.Legend.Padding,
Left: opt.Legend.Left,
Right: opt.Legend.Right,
Top: opt.Legend.Top,
Bottom: opt.Legend.Bottom,
}),
}
}

View file

@ -25,6 +25,7 @@ package charts
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
@ -81,6 +82,19 @@ type EChartsPadding struct {
box chart.Box
}
type LegendPostion string
func (lp *LegendPostion) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
return nil
}
if regexp.MustCompile(`^\d+`).Match(data) {
data = []byte(fmt.Sprintf(`"%s"`, string(data)))
}
s := (*string)(lp)
return json.Unmarshal(data, s)
}
func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
data = convertToArray(data)
if len(data) == 0 {
@ -128,9 +142,9 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
type EChartsYAxis struct {
Data []struct {
Min *float64 `json:"min"`
Max *float64 `json:"max"`
Interval int `json:"interval"`
Min *float64 `json:"min"`
Max *float64 `json:"max"`
// Interval int `json:"interval"`
AxisLabel struct {
Formatter string `json:"formatter"`
} `json:"axisLabel"`
@ -147,7 +161,7 @@ func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error {
type EChartsXAxis struct {
Data []struct {
Type string `json:"type"`
// Type string `json:"type"`
BoundaryGap *bool `json:"boundaryGap"`
SplitNumber int `json:"splitNumber"`
Data []string `json:"data"`
@ -183,6 +197,10 @@ type ECharsOptions struct {
Data []string `json:"data"`
Align string `json:"align"`
Padding EChartsPadding `json:"padding"`
Left LegendPostion `json:"left"`
Right LegendPostion `json:"right"`
// Top string `json:"top"`
// Bottom string `json:"bottom"`
} `json:"legend"`
Series []struct {
Data []ECharsSeriesData `json:"data"`
@ -293,6 +311,8 @@ func (e *ECharsOptions) ToOptions() Options {
Data: e.Legend.Data,
Align: e.Legend.Align,
Padding: e.Legend.Padding.box,
Left: string(e.Legend.Left),
Right: string(e.Legend.Right),
}
if len(e.YAxis.Data) != 0 {
yAxisOptions := make([]*YAxisOption, len(e.YAxis.Data))

View file

@ -397,3 +397,25 @@ func TestParseECharsOptions(t *testing.T) {
},
}, options)
}
func BenchmarkEChartsRender(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := RenderEChartsToPNG(`{
"title": {
"text": "Line"
},
"xAxis": {
"type": "category",
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
},
"series": [
{
"data": [150, 230, 224, 218, 135, 147, 260]
}
]
}`)
if err != nil {
panic(err)
}
}
}

View file

@ -86,7 +86,7 @@ var chartOptions = []map[string]string{
},
"legend": {
"align": "left",
"padding": [5, 0, 0, 50],
"right": 0,
"data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"]
},
"xAxis": {

126
legend.go
View file

@ -23,16 +23,22 @@
package charts
import (
"strconv"
"strings"
"github.com/wcharczuk/go-chart/v2"
)
type LegendOption struct {
Style chart.Style
Padding chart.Box
Align string
TextPosition string
Theme string
IconDraw LegendIconDraw
Style chart.Style
Padding chart.Box
Left string
Right string
Top string
Bottom string
Align string
Theme string
IconDraw LegendIconDraw
}
type LegendIconDrawOption struct {
@ -43,13 +49,8 @@ type LegendIconDrawOption struct {
}
const (
LegendAlignLeft = "left"
LegendAlignCenter = "center"
LegendAlignRight = "right"
)
const (
LegendTextPositionRight = "right"
LegendAlignLeft = "left"
LegendAlignRight = "right"
)
type LegendIconDraw func(r chart.Renderer, opt LegendIconDrawOption)
@ -71,6 +72,61 @@ func DefaultLegendIconDraw(r chart.Renderer, opt LegendIconDrawOption) {
r.FillStroke()
}
func covertPercent(value string) float64 {
if !strings.HasSuffix(value, "%") {
return -1
}
v, err := strconv.Atoi(strings.ReplaceAll(value, "%", ""))
if err != nil {
return -1
}
return float64(v) / 100
}
func getLegendLeft(width, legendBoxWidth int, opt LegendOption) int {
left := (width - legendBoxWidth) / 2
leftValue := opt.Left
if leftValue == "auto" || leftValue == "center" {
leftValue = ""
}
if leftValue == "left" {
leftValue = "0"
}
rightValue := opt.Right
if rightValue == "auto" || leftValue == "center" {
rightValue = ""
}
if rightValue == "right" {
rightValue = "0"
}
if leftValue == "" && rightValue == "" {
return left
}
if leftValue != "" {
percent := covertPercent(leftValue)
if percent >= 0 {
return int(float64(width) * percent)
}
v, _ := strconv.Atoi(leftValue)
return v
}
if rightValue != "" {
percent := covertPercent(rightValue)
if percent >= 0 {
return width - legendBoxWidth - int(float64(width)*percent)
}
v, _ := strconv.Atoi(rightValue)
return width - legendBoxWidth - v
}
return left
}
func getLegendTop(height, legendBoxHeight int, opt LegendOption) int {
// TODO 支持top的处理
return 0
}
func LegendCustomize(series []chart.Series, opt LegendOption) chart.Renderable {
return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) {
legendDefaults := chart.Style{
@ -115,26 +171,23 @@ func LegendCustomize(series []chart.Series, opt LegendOption) chart.Renderable {
chartPadding := cb.Top
legendYMargin := (chartPadding - legendBoxHeight) >> 1
lineLengthMinimum := 25
iconWidth := 25
lineTextGap := 5
labelWidth += lineLengthMinimum * len(labels)
iconAllWidth := iconWidth * len(labels)
spaceAllWidth := chart.DefaultMinimumTickHorizontalSpacing * (len(labels) - 1)
left := 0
switch opt.Align {
case LegendAlignLeft:
left = 0
case LegendAlignRight:
left = cb.Width() - labelWidth
default:
left = (cb.Width() - labelWidth) / 2
}
legendBoxWidth := labelWidth + iconAllWidth + spaceAllWidth
left := getLegendLeft(cb.Width(), legendBoxWidth, opt)
top := getLegendTop(cb.Height(), legendBoxHeight, opt)
left += opt.Padding.Left
top := opt.Padding.Top
top += opt.Padding.Top
legendBox := chart.Box{
Left: left,
Right: left + labelWidth,
Right: left + legendBoxWidth,
Top: top,
Bottom: top + legendBoxHeight,
}
@ -145,8 +198,6 @@ func LegendCustomize(series []chart.Series, opt LegendOption) chart.Renderable {
r.SetFontColor(legendStyle.GetFontColor())
r.SetFontSize(legendStyle.GetFontSize())
lineTextGap := 5
startX := legendBox.Left + legendStyle.Padding.Left
ty := top + legendYMargin + legendStyle.Padding.Top + textHeight
var label string
@ -155,13 +206,17 @@ func LegendCustomize(series []chart.Series, opt LegendOption) chart.Renderable {
if iconDraw == nil {
iconDraw = DefaultLegendIconDraw
}
align := opt.Align
if align == "" {
align = LegendAlignLeft
}
for index := range labels {
label = labels[index]
if len(label) > 0 {
x = startX
// 如果文本靠左显
if opt.TextPosition != LegendTextPositionRight {
// 如果图例标记靠右展
if align == LegendAlignRight {
textBox = r.MeasureText(label)
r.Text(label, x, ty)
x = startX + textBox.Width() + lineTextGap
@ -175,20 +230,21 @@ func LegendCustomize(series []chart.Series, opt LegendOption) chart.Renderable {
Box: chart.Box{
Left: x,
Top: ty,
Right: x + lineLengthMinimum,
Right: x + iconWidth,
Bottom: ty + textHeight,
},
})
x += (lineLengthMinimum + lineTextGap)
x += (iconWidth + lineTextGap)
// 如果文本靠右显
if opt.TextPosition == LegendTextPositionRight {
// 如果图例标记靠左展
if align == LegendAlignLeft {
textBox = r.MeasureText(label)
r.Text(label, x, ty)
x += textBox.Width()
}
// 计算下一个legend的位置
startX += textBox.Width() + chart.DefaultMinimumTickHorizontalSpacing + lineTextGap + lineLengthMinimum
startX = x + chart.DefaultMinimumTickHorizontalSpacing
}
}
}

View file

@ -43,16 +43,16 @@ func TestLegendCustomize(t *testing.T) {
}, chart.TickPositionBetweenTicks, "")
tests := []struct {
textPosition string
svg string
align string
svg string
}{
{
textPosition: LegendTextPositionRight,
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<path d=\"M 100 100\nL 208 100\nL 208 110\nL 100 110\nL 100 100\" style=\"stroke-width:0;stroke:rgba(51,51,51,1.0);fill:none\"/><path d=\"M 100 107\nL 125 107\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"112\" cy=\"107\" r=\"5\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"130\" y=\"110\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">chrome</text><path d=\"M 185 107\nL 210 107\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"197\" cy=\"107\" r=\"5\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"215\" y=\"110\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">edge</text></svg>",
align: LegendAlignLeft,
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<path d=\"M 404 100\nL 532 100\nL 532 110\nL 404 110\nL 404 100\" style=\"stroke-width:0;stroke:rgba(51,51,51,1.0);fill:none\"/><path d=\"M 404 107\nL 429 107\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"416\" cy=\"107\" r=\"5\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"434\" y=\"110\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">chrome</text><path d=\"M 489 107\nL 514 107\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"501\" cy=\"107\" r=\"5\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"519\" y=\"110\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">edge</text></svg>",
},
{
textPosition: LegendAlignLeft,
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<path d=\"M 100 100\nL 208 100\nL 208 110\nL 100 110\nL 100 100\" style=\"stroke-width:0;stroke:rgba(51,51,51,1.0);fill:none\"/><text x=\"100\" y=\"110\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">chrome</text><path d=\"M 140 107\nL 165 107\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"152\" cy=\"107\" r=\"5\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"185\" y=\"110\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">edge</text><path d=\"M 213 107\nL 238 107\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"225\" cy=\"107\" r=\"5\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/></svg>",
align: LegendAlignRight,
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<path d=\"M 404 100\nL 532 100\nL 532 110\nL 404 110\nL 404 100\" style=\"stroke-width:0;stroke:rgba(51,51,51,1.0);fill:none\"/><text x=\"404\" y=\"110\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">chrome</text><path d=\"M 444 107\nL 469 107\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"456\" cy=\"107\" r=\"5\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"494\" y=\"110\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">edge</text><path d=\"M 522 107\nL 547 107\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"534\" cy=\"107\" r=\"5\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/></svg>",
},
}
@ -60,9 +60,8 @@ func TestLegendCustomize(t *testing.T) {
r, err := chart.SVG(800, 600)
assert.Nil(err)
fn := LegendCustomize(series, LegendOption{
TextPosition: tt.textPosition,
IconDraw: DefaultLegendIconDraw,
Align: LegendAlignLeft,
Align: tt.align,
IconDraw: DefaultLegendIconDraw,
Padding: chart.Box{
Left: 100,
Top: 100,