diff --git a/bar_chart.go b/bar_chart.go index 11e9d5a..f291d78 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -125,6 +125,10 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR if !series.Label.Show { continue } + distance := series.Label.Distance + if distance == 0 { + distance = 5 + } text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) labelStyle := chart.Style{ FontColor: theme.GetTextColor(), @@ -136,7 +140,7 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR } labelStyle.GetTextOptions().WriteToRenderer(r) textBox := r.MeasureText(text) - d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-5) + d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance) } // 生成mark point的参数 diff --git a/chart.go b/chart.go index 44bee0c..c211a6d 100644 --- a/chart.go +++ b/chart.go @@ -25,6 +25,7 @@ package charts import ( "errors" "math" + "strings" "github.com/golang/freetype/truetype" "github.com/wcharczuk/go-chart/v2" @@ -155,6 +156,10 @@ func (o *ChartOption) FillDefault(theme string) { } } } + // 如果无legend数据,则隐藏 + if len(strings.Join(o.Legend.Data, "")) == 0 { + o.Legend.Show = FalseFlag() + } if o.Legend.Style.Font == nil { o.Legend.Style.Font = o.Font } diff --git a/echarts.go b/echarts.go new file mode 100644 index 0000000..9232ed7 --- /dev/null +++ b/echarts.go @@ -0,0 +1,407 @@ +// 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 ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "strconv" + + "github.com/wcharczuk/go-chart/v2" +) + +func convertToArray(data []byte) []byte { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return nil + } + if data[0] != '[' { + data = []byte("[" + string(data) + "]") + } + return data +} + +type EChartsPosition string + +func (p *EChartsPosition) 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)(p) + return json.Unmarshal(data, s) +} + +type EChartStyle struct { + Color string `json:"color"` +} + +func (es *EChartStyle) ToStyle() chart.Style { + color := parseColor(es.Color) + return chart.Style{ + FillColor: color, + FontColor: color, + StrokeColor: color, + } +} + +type EChartsSeriesData struct { + Value float64 `json:"value"` + Name string `json:"name"` + ItemStyle EChartStyle `json:"itemStyle"` +} +type _EChartsSeriesData EChartsSeriesData + +func (es *EChartsSeriesData) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return nil + } + if regexp.MustCompile(`^\d+`).Match(data) { + v, err := strconv.ParseFloat(string(data), 64) + if err != nil { + return err + } + es.Value = v + return nil + } + v := _EChartsSeriesData{} + err := json.Unmarshal(data, &v) + if err != nil { + return err + } + es.Name = v.Name + es.Value = v.Value + es.ItemStyle = v.ItemStyle + return nil +} + +type EChartsXAxisData struct { + BoundaryGap *bool `json:"boundaryGap"` + SplitNumber int `json:"splitNumber"` + Data []string `json:"data"` +} +type EChartsXAxis struct { + Data []EChartsXAxisData +} + +func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error { + data = convertToArray(data) + if len(data) == 0 { + return nil + } + return json.Unmarshal(data, &ex.Data) +} + +type EChartsAxisLabel struct { + Formatter string `json:"formatter"` +} +type EChartsYAxisData struct { + Min *float64 `json:"min"` + Max *float64 `json:"max"` + AxisLabel EChartsAxisLabel `json:"axisLabel"` + AxisLine struct { + LineStyle struct { + Color string + } + } +} +type EChartsYAxis struct { + Data []EChartsYAxisData `json:"data"` +} + +func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error { + data = convertToArray(data) + if len(data) == 0 { + return nil + } + return json.Unmarshal(data, &ey.Data) +} + +type EChartsPadding struct { + Box chart.Box +} + +func (eb *EChartsPadding) UnmarshalJSON(data []byte) error { + data = convertToArray(data) + if len(data) == 0 { + return nil + } + arr := make([]int, 0) + err := json.Unmarshal(data, &arr) + if err != nil { + return err + } + if len(arr) == 0 { + return nil + } + switch len(arr) { + case 1: + eb.Box = chart.Box{ + Left: arr[0], + Top: arr[0], + Bottom: arr[0], + Right: arr[0], + } + case 2: + eb.Box = chart.Box{ + Top: arr[0], + Bottom: arr[0], + Left: arr[1], + Right: arr[1], + } + default: + result := make([]int, 4) + copy(result, arr) + if len(arr) == 3 { + result[3] = result[1] + } + // 上右下左 + eb.Box = chart.Box{ + Top: result[0], + Right: result[1], + Bottom: result[2], + Left: result[3], + } + } + return nil +} + +type EChartsLabelOption struct { + Show bool `json:"show"` + Distance int `json:"distance"` + Color string `json:"color"` +} +type EChartsLegend struct { + Show *bool `json:"show"` + Data []string `json:"data"` + Align string `json:"align"` + Orient string `json:"orient"` + Padding EChartsPadding `json:"padding"` + Left EChartsPosition `json:"left"` + Top EChartsPosition `json:"top"` + TextStyle EChartsTextStyle `json:"textStyle"` +} + +type EChartsMarkPoint struct { + SymbolSize int `json:"symbolSize"` + Data []struct { + Type string `json:"type"` + } `json:"data"` +} + +func (emp *EChartsMarkPoint) ToSeriesMarkPoint() SeriesMarkPoint { + data := make([]SeriesMarkData, len(emp.Data)) + for index, item := range emp.Data { + data[index] = SeriesMarkData{ + Type: item.Type, + } + } + return SeriesMarkPoint{ + Data: data, + SymbolSize: emp.SymbolSize, + } +} + +type EChartsMarkLine struct { + Data []struct { + Type string `json:"type"` + } `json:"data"` +} + +func (eml *EChartsMarkLine) ToSeriesMarkLine() SeriesMarkLine { + data := make([]SeriesMarkData, len(eml.Data)) + for index, item := range eml.Data { + data[index] = SeriesMarkData{ + Type: item.Type, + } + } + return SeriesMarkLine{ + Data: data, + } +} + +type EChartsSeries struct { + Data []EChartsSeriesData `json:"data"` + Name string `json:"name"` + Type string `json:"type"` + Radius string `json:"radius"` + YAxisIndex int `json:"yAxisIndex"` + ItemStyle EChartStyle `json:"itemStyle"` + // label的配置 + Label EChartsLabelOption `json:"label"` + MarkPoint EChartsMarkPoint `json:"markPoint"` + MarkLine EChartsMarkLine `json:"markLine"` +} +type EChartsSeriesList []EChartsSeries + +func (esList EChartsSeriesList) ToSeriesList() SeriesList { + seriesList := make(SeriesList, len(esList)) + for index, item := range esList { + data := make([]SeriesData, len(item.Data)) + for j, dataItem := range item.Data { + data[j] = SeriesData{ + Value: dataItem.Value, + Style: dataItem.ItemStyle.ToStyle(), + } + } + seriesList[index] = Series{ + Type: item.Type, + Data: data, + YAxisIndex: item.YAxisIndex, + Style: item.ItemStyle.ToStyle(), + Label: SeriesLabel{ + Color: parseColor(item.Label.Color), + Show: item.Label.Show, + Distance: item.Label.Distance, + }, + Name: item.Name, + Radius: item.Radius, + MarkPoint: item.MarkPoint.ToSeriesMarkPoint(), + MarkLine: item.MarkLine.ToSeriesMarkLine(), + } + } + return seriesList +} + +type EChartsTextStyle struct { + Color string `json:"color"` + FontFamily string `json:"fontFamily"` + FontSize float64 `json:"fontSize"` +} + +func (et *EChartsTextStyle) ToStyle() chart.Style { + return chart.Style{ + FontSize: et.FontSize, + FontColor: parseColor(et.Color), + } +} + +type EChartsOption struct { + Type string `json:"type"` + Theme string `json:"theme"` + FontFamily string `json:"fontFamily"` + Padding EChartsPadding `json:"padding"` + Box chart.Box `json:"box"` + Width int `json:"width"` + Height int `json:"height"` + Title struct { + Text string `json:"text"` + Subtext string `json:"subtext"` + Left EChartsPosition `json:"left"` + Top EChartsPosition `json:"top"` + TextStyle EChartsTextStyle `json:"textStyle"` + SubtextStyle EChartsTextStyle `json:"subtextStyle"` + } `json:"title"` + XAxis EChartsXAxis `json:"xAxis"` + YAxis EChartsYAxis `json:"yAxis"` + Legend EChartsLegend `json:"legend"` + Series EChartsSeriesList `json:"series"` + Children []EChartsOption `json:"children"` +} + +func (eo *EChartsOption) ToOption() ChartOption { + fontFamily := eo.FontFamily + if len(fontFamily) == 0 { + fontFamily = eo.Title.TextStyle.FontFamily + } + o := ChartOption{ + Type: eo.Type, + FontFamily: fontFamily, + Theme: eo.Theme, + Title: TitleOption{ + Text: eo.Title.Text, + Subtext: eo.Title.Subtext, + Style: eo.Title.TextStyle.ToStyle(), + SubtextStyle: eo.Title.SubtextStyle.ToStyle(), + Left: string(eo.Title.Left), + Top: string(eo.Title.Top), + }, + Legend: LegendOption{ + Show: eo.Legend.Show, + Style: eo.Legend.TextStyle.ToStyle(), + Data: eo.Legend.Data, + Left: string(eo.Legend.Left), + Top: string(eo.Legend.Top), + 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(), + } + if len(eo.XAxis.Data) != 0 { + xAxisData := eo.XAxis.Data[0] + o.XAxis = XAxisOption{ + BoundaryGap: xAxisData.BoundaryGap, + Data: xAxisData.Data, + SplitNumber: xAxisData.SplitNumber, + } + } + yAxisOptions := make([]YAxisOption, len(eo.YAxis.Data)) + for index, item := range eo.YAxis.Data { + yAxisOptions[index] = YAxisOption{ + Min: item.Min, + Max: item.Max, + Formatter: item.AxisLabel.Formatter, + Color: parseColor(item.AxisLine.LineStyle.Color), + } + } + + if len(eo.Children) != 0 { + o.Children = make([]ChartOption, len(eo.Children)) + for index, item := range eo.Children { + o.Children[index] = item.ToOption() + } + } + return o +} + +func renderEcharts(options, outputType string) ([]byte, error) { + o := EChartsOption{} + err := json.Unmarshal([]byte(options), &o) + if err != nil { + return nil, err + } + opt := o.ToOption() + opt.Type = outputType + d, err := Render(opt) + if err != nil { + return nil, err + } + return d.Bytes() +} + +func RenderEChartsToPNG(options string) ([]byte, error) { + return renderEcharts(options, "png") +} + +func RenderEChartsToSVG(options string) ([]byte, error) { + return renderEcharts(options, "svg") +} diff --git a/echarts_test.go b/echarts_test.go new file mode 100644 index 0000000..3cefb5a --- /dev/null +++ b/echarts_test.go @@ -0,0 +1,301 @@ +// 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 ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" +) + +func TestEChartsPosition(t *testing.T) { + assert := assert.New(t) + + var p EChartsPosition + err := p.UnmarshalJSON([]byte("12")) + assert.Nil(err) + assert.Equal("12", string(p)) + + err = p.UnmarshalJSON([]byte(`"12%"`)) + assert.Nil(err) + assert.Equal("12%", string(p)) +} + +func TestEChartsXAxis(t *testing.T) { + assert := assert.New(t) + ex := EChartsXAxis{} + err := ex.UnmarshalJSON([]byte(`{ + "boundaryGap": false, + "splitNumber": 5, + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }`)) + assert.Nil(err) + assert.Equal(EChartsXAxis{ + Data: []EChartsXAxisData{ + { + BoundaryGap: FalseFlag(), + SplitNumber: 5, + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + }, + }, + }, ex) +} + +func TestEChartsYAxis(t *testing.T) { + assert := assert.New(t) + ey := EChartsYAxis{} + + err := ey.UnmarshalJSON([]byte(`{ + "min": 1, + "max": 10, + "axisLabel": { + "formatter": "ab" + } + }`)) + assert.Nil(err) + assert.Equal(EChartsYAxis{ + Data: []EChartsYAxisData{ + { + Min: NewFloatPoint(1), + Max: NewFloatPoint(10), + AxisLabel: EChartsAxisLabel{ + Formatter: "ab", + }, + }, + }, + }, ey) + + ey = EChartsYAxis{} + err = ey.UnmarshalJSON([]byte(`[ + { + "min": 1, + "max": 10, + "axisLabel": { + "formatter": "ab" + } + }, + { + "min": 2, + "max": 20, + "axisLabel": { + "formatter": "cd" + } + } + ]`)) + assert.Nil(err) + assert.Equal(EChartsYAxis{ + Data: []EChartsYAxisData{ + { + Min: NewFloatPoint(1), + Max: NewFloatPoint(10), + AxisLabel: EChartsAxisLabel{ + Formatter: "ab", + }, + }, + { + Min: NewFloatPoint(2), + Max: NewFloatPoint(20), + AxisLabel: EChartsAxisLabel{ + Formatter: "cd", + }, + }, + }, + }, ey) +} + +func TestEChartsPadding(t *testing.T) { + assert := assert.New(t) + + ep := EChartsPadding{} + + ep.UnmarshalJSON([]byte(`10`)) + assert.Equal(EChartsPadding{ + Box: chart.Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + }, + }, ep) + + ep = EChartsPadding{} + ep.UnmarshalJSON([]byte(`[10, 20]`)) + assert.Equal(EChartsPadding{ + Box: chart.Box{ + Top: 10, + Right: 20, + Bottom: 10, + Left: 20, + }, + }, ep) + + ep = EChartsPadding{} + ep.UnmarshalJSON([]byte(`[10, 20, 30]`)) + assert.Equal(EChartsPadding{ + Box: chart.Box{ + Top: 10, + Right: 20, + Bottom: 30, + Left: 20, + }, + }, ep) + + ep = EChartsPadding{} + ep.UnmarshalJSON([]byte(`[10, 20, 30, 40]`)) + assert.Equal(EChartsPadding{ + Box: chart.Box{ + Top: 10, + Right: 20, + Bottom: 30, + Left: 40, + }, + }, ep) + +} +func TestEChartsLegend(t *testing.T) { + assert := assert.New(t) + + el := EChartsLegend{} + + err := json.Unmarshal([]byte(`{ + "data": ["a", "b", "c"], + "align": "right", + "padding": [10], + "left": "20%", + "top": 10 + }`), &el) + assert.Nil(err) + assert.Equal(EChartsLegend{ + Data: []string{ + "a", + "b", + "c", + }, + Align: "right", + Padding: EChartsPadding{ + Box: chart.Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, + }, + Left: EChartsPosition("20%"), + Top: EChartsPosition("10"), + }, el) +} + +func TestEChartsSeriesData(t *testing.T) { + assert := assert.New(t) + + esd := EChartsSeriesData{} + err := esd.UnmarshalJSON([]byte(`123`)) + assert.Nil(err) + assert.Equal(EChartsSeriesData{ + Value: 123, + }, esd) + + esd = EChartsSeriesData{} + err = esd.UnmarshalJSON([]byte(`{ + "value": 123.12, + "name": "test", + "itemStyle": { + "color": "#aaa" + } + }`)) + assert.Nil(err) + assert.Equal(EChartsSeriesData{ + Value: 123.12, + Name: "test", + ItemStyle: EChartStyle{ + Color: "#aaa", + }, + }, esd) +} + +func TestEChartsSeries(t *testing.T) { + assert := assert.New(t) + + esList := make([]EChartsSeries, 0) + err := json.Unmarshal([]byte(`[ + { + "name": "Email", + "data": [ + 120, + 132 + ] + }, + { + "name": "Union Ads", + "type": "bar", + "data": [ + 220, + 182 + ] + } + ]`), &esList) + assert.Nil(err) + assert.Equal([]EChartsSeries{ + { + Name: "Email", + Data: []EChartsSeriesData{ + { + Value: 120, + }, + { + Value: 132, + }, + }, + }, + { + Name: "Union Ads", + Type: "bar", + Data: []EChartsSeriesData{ + { + Value: 220, + }, + { + Value: 182, + }, + }, + }, + }, esList) +} diff --git a/line_chart.go b/line_chart.go index d2eb5e2..78790d3 100644 --- a/line_chart.go +++ b/line_chart.go @@ -85,6 +85,10 @@ func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPoin if !series.Label.Show { continue } + distance := series.Label.Distance + if distance == 0 { + distance = 5 + } text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) labelStyle := chart.Style{ FontColor: theme.GetTextColor(), @@ -96,7 +100,7 @@ func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPoin } labelStyle.GetTextOptions().WriteToRenderer(r) textBox := r.MeasureText(text) - d.text(text, x-textBox.Width()>>1, y-5) + d.text(text, x-textBox.Width()>>1, y-distance) } dotFillColor := drawing.ColorWhite diff --git a/series.go b/series.go index e5d9bd8..8a9ba73 100644 --- a/series.go +++ b/series.go @@ -68,6 +68,8 @@ type SeriesLabel struct { Color drawing.Color // Show flag for label Show bool + // Distance to the host graphic element. + Distance int } const ( diff --git a/util.go b/util.go index 03aad20..2adaba8 100644 --- a/util.go +++ b/util.go @@ -23,11 +23,13 @@ package charts import ( + "regexp" "strconv" "strings" "github.com/dustin/go-humanize" "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) func TrueFlag() *bool { @@ -130,3 +132,39 @@ func commafWithDigits(value float64) string { } return humanize.CommafWithDigits(value, decimals) } + +func parseColor(color string) drawing.Color { + c := drawing.Color{} + if color == "" { + return c + } + if strings.HasPrefix(color, "#") { + return drawing.ColorFromHex(color[1:]) + } + reg := regexp.MustCompile(`\((\S+)\)`) + result := reg.FindAllStringSubmatch(color, 1) + if len(result) == 0 || len(result[0]) != 2 { + return c + } + arr := strings.Split(result[0][1], ",") + if len(arr) < 3 { + return c + } + // 设置默认为255 + c.A = 255 + for index, v := range arr { + value, _ := strconv.Atoi(strings.TrimSpace(v)) + ui8 := uint8(value) + switch index { + case 0: + c.R = ui8 + case 1: + c.G = ui8 + case 2: + c.B = ui8 + default: + c.A = ui8 + } + } + return c +} diff --git a/util_test.go b/util_test.go index 7c70447..dc5d98e 100644 --- a/util_test.go +++ b/util_test.go @@ -27,6 +27,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) func TestGetDefaultInt(t *testing.T) { @@ -139,3 +140,42 @@ func TestConvertPercent(t *testing.T) { assert.Equal(-1.0, convertPercent("a%")) assert.Equal(0.1, convertPercent("10%")) } + +func TestParseColor(t *testing.T) { + assert := assert.New(t) + + c := parseColor("") + assert.True(c.IsZero()) + + c = parseColor("#333") + assert.Equal(drawing.Color{ + R: 51, + G: 51, + B: 51, + A: 255, + }, c) + + c = parseColor("#313233") + assert.Equal(drawing.Color{ + R: 49, + G: 50, + B: 51, + A: 255, + }, c) + + c = parseColor("rgb(31,32,33)") + assert.Equal(drawing.Color{ + R: 31, + G: 32, + B: 33, + A: 255, + }, c) + + c = parseColor("rgba(50,51,52,250)") + assert.Equal(drawing.Color{ + R: 50, + G: 51, + B: 52, + A: 250, + }, c) +}