diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ebc9a02 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/README.md b/README.md index 93db83e..c267461 100644 --- a/README.md +++ b/README.md @@ -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) @@ -44,4 +42,38 @@ func main() { } os.WriteFile("output.png", buf, 0600) } -``` \ No newline at end of file +``` + +## 参数说明 + +- `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"}] \ No newline at end of file diff --git a/charts.go b/charts.go index cdc4b76..f5fcb1f 100644 --- a/charts.go +++ b/charts.go @@ -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, }), } } diff --git a/echarts.go b/echarts.go index 672a21a..bd5a950 100644 --- a/echarts.go +++ b/echarts.go @@ -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)) diff --git a/echarts_test.go b/echarts_test.go index b06da53..9639ac1 100644 --- a/echarts_test.go +++ b/echarts_test.go @@ -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) + } + } +} diff --git a/examples/charts/main.go b/examples/charts/main.go index 4cf72e2..72b75b0 100644 --- a/examples/charts/main.go +++ b/examples/charts/main.go @@ -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": { diff --git a/legend.go b/legend.go index b019868..bf968fe 100644 --- a/legend.go +++ b/legend.go @@ -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 } } } diff --git a/legend_test.go b/legend_test.go index d2af129..5bfacf8 100644 --- a/legend_test.go +++ b/legend_test.go @@ -43,16 +43,16 @@ func TestLegendCustomize(t *testing.T) { }, chart.TickPositionBetweenTicks, "") tests := []struct { - textPosition string - svg string + align string + svg string }{ { - textPosition: LegendTextPositionRight, - svg: "\\nchromeedge", + align: LegendAlignLeft, + svg: "\\nchromeedge", }, { - textPosition: LegendAlignLeft, - svg: "\\nchromeedge", + align: LegendAlignRight, + svg: "\\nchromeedge", }, } @@ -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,