diff --git a/axis.go b/axis.go index 775747b..04229e8 100644 --- a/axis.go +++ b/axis.go @@ -102,6 +102,32 @@ func GetXAxisAndValues(xAxis XAxis, tickPosition chart.TickPosition, theme strin }, xValues } +func GetSecondaryYAxis(theme string) chart.YAxis { + // TODO + if theme == ThemeDark { + return chart.YAxis{} + } + // strokeColor := drawing.Color{ + // R: 224, + // G: 230, + // B: 241, + // A: 255, + // } + return chart.YAxis{ + ValueFormatter: func(v interface{}) string { + value, ok := v.(float64) + if !ok { + return "" + } + return humanize.Commaf(value) + }, + AxisType: chart.YAxisPrimary, + GridMajorStyle: chart.Hidden(), + GridMinorStyle: chart.Hidden(), + Style: chart.Hidden(), + } +} + func GetYAxis(theme string) chart.YAxis { // TODO if theme == ThemeDark { diff --git a/charts.go b/charts.go index e396d73..69af23e 100644 --- a/charts.go +++ b/charts.go @@ -35,19 +35,25 @@ const ( ThemeDark = "dark" ) +const ( + DefaultChartWidth = 800 + DefaultChartHeight = 400 +) + type ( Title struct { - Text string + Text string + Style chart.Style } Legend struct { Data []string } - Option struct { - Width int - Height int - Theme string - XAxis XAxis - // YAxis Axis + Options struct { + Width int + Height int + Theme string + XAxis XAxis + YAxisList []chart.YAxis Series []Series Title Title Legend Legend @@ -55,27 +61,11 @@ type ( } ) -type Chart interface { +type Graph interface { Render(rp chart.RendererProvider, w io.Writer) error } -type ECharOption struct { - Title struct { - Text string - TextStyle struct { - Color string - FontFamily string - } - } - XAxis struct { - Type string - BoundaryGap *bool - SplitNumber int - Data []string - } -} - -func (o *Option) validate() error { +func (o *Options) validate() error { xAxisCount := len(o.XAxis.Data) if len(o.Series) == 0 { return errors.New("series can not be empty") @@ -89,28 +79,36 @@ func (o *Option) validate() error { return nil } -func render(c Chart, rp chart.RendererProvider) ([]byte, error) { +func render(g Graph, rp chart.RendererProvider) ([]byte, error) { buf := bytes.Buffer{} - err := c.Render(rp, &buf) + err := g.Render(rp, &buf) if err != nil { return nil, err } return buf.Bytes(), nil } -func ToPNG(c Chart) ([]byte, error) { - return render(c, chart.PNG) +func ToPNG(g Graph) ([]byte, error) { + return render(g, chart.PNG) } -func ToSVG(c Chart) ([]byte, error) { - return render(c, chart.SVG) +func ToSVG(g Graph) ([]byte, error) { + return render(g, chart.SVG) } -func New(opt Option) (Chart, error) { +func New(opt Options) (Graph, error) { err := opt.validate() if err != nil { return nil, err } tickPosition := opt.TickPosition + width := opt.Width + if width <= 0 { + width = DefaultChartWidth + } + height := opt.Height + if height <= 0 { + height = DefaultChartHeight + } xAxis, xValues := GetXAxisAndValues(opt.XAxis, tickPosition, opt.Theme) @@ -131,33 +129,35 @@ func New(opt Option) (Chart, error) { Label: item.Name, } } - c := &chart.PieChart{ - Title: opt.Title.Text, - Width: opt.Width, - Height: opt.Height, - Values: values, + g := &chart.PieChart{ + Title: opt.Title.Text, + TitleStyle: opt.Title.Style, + Width: width, + Height: height, + Values: values, ColorPalette: &ThemeColorPalette{ Theme: opt.Theme, }, } - return c, nil + return g, nil } - c := &chart.Chart{ - Title: opt.Title.Text, - Width: opt.Width, - Height: opt.Height, - XAxis: xAxis, - YAxis: GetYAxis(opt.Theme), - Series: GetSeries(opt.Series, tickPosition, opt.Theme), + g := &chart.Chart{ + Title: opt.Title.Text, + TitleStyle: opt.Title.Style, + Width: width, + Height: height, + XAxis: xAxis, + YAxis: GetYAxis(opt.Theme), + YAxisSecondary: GetSecondaryYAxis(opt.Theme), + Series: GetSeries(opt.Series, tickPosition, opt.Theme), } // 设置secondary的样式 - c.YAxisSecondary.Style = c.YAxis.Style if legendSize != 0 { - c.Elements = []chart.Renderable{ - DefaultLegend(c), + g.Elements = []chart.Renderable{ + DefaultLegend(g), } } - return c, nil + return g, nil } diff --git a/echarts.go b/echarts.go new file mode 100644 index 0000000..28c73db --- /dev/null +++ b/echarts.go @@ -0,0 +1,171 @@ +// MIT License + +// Copyright (c) 2021 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" + "regexp" + "strconv" + + "github.com/wcharczuk/go-chart/v2" +) + +type EChartStyle struct { + Color string `json:"color"` +} +type ECharsSeriesData struct { + Value float64 `json:"value"` + ItemStyle EChartStyle `json:"itemStyle"` +} +type _ECharsSeriesData ECharsSeriesData + +func (es *ECharsSeriesData) 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 := _ECharsSeriesData{} + err := json.Unmarshal(data, &v) + if err != nil { + return err + } + es.Value = v.Value + es.ItemStyle = v.ItemStyle + return nil +} + +type EChartsYAxis struct { + Data []struct { + Min int `json:"min"` + Max int `json:"max"` + Interval int `json:"interval"` + AxisLabel struct { + Formatter string `json:"formatter"` + } `json:"axisLabel"` + } `json:"data"` +} + +func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return nil + } + if data[0] != '[' { + data = []byte("[" + string(data) + "]") + } + return json.Unmarshal(data, &ey.Data) +} + +type ECharsOptions struct { + Title struct { + Text string `json:"text"` + TextStyle struct { + Color string `json:"color"` + FontFamily string `json:"fontFamily"` + } `json:"textStyle"` + } `json:"title"` + XAxis struct { + Type string `json:"type"` + BoundaryGap *bool `json:"boundaryGap"` + SplitNumber int `json:"splitNumber"` + Data []string `json:"data"` + } `json:"xAxis"` + YAxis EChartsYAxis `json:"yAxis"` + Legend struct { + Data []string `json:"data"` + } `json:"legend"` + Series []struct { + Data []ECharsSeriesData `json:"data"` + Type string `json:"type"` + } `json:"series"` +} + +func (e *ECharsOptions) ToOptions() Options { + o := Options{} + o.Title = Title{ + Text: e.Title.Text, + } + + o.XAxis = XAxis{ + Type: e.XAxis.Type, + Data: e.XAxis.Data, + SplitNumber: e.XAxis.SplitNumber, + } + + o.Legend = Legend{ + Data: e.Legend.Data, + } + + // TODO 生成yAxis + + tickPosition := chart.TickPositionUnset + series := make([]Series, len(e.Series)) + for index, item := range e.Series { + // bar默认tick居中 + if item.Type == SeriesBar { + tickPosition = chart.TickPositionBetweenTicks + } + data := make([]SeriesData, len(item.Data)) + for j, itemData := range item.Data { + sd := SeriesData{ + Value: itemData.Value, + } + if itemData.ItemStyle.Color != "" { + c := parseColor(itemData.ItemStyle.Color) + sd.Style.FillColor = c + sd.Style.StrokeColor = c + } + data[j] = sd + } + series[index] = Series{ + Data: data, + Type: item.Type, + } + } + o.Series = series + + if e.XAxis.BoundaryGap == nil || *e.XAxis.BoundaryGap { + tickPosition = chart.TickPositionBetweenTicks + } + o.TickPosition = tickPosition + return o +} + +func ParseECharsOptions(options string) (Options, error) { + e := ECharsOptions{} + err := json.Unmarshal([]byte(options), &e) + if err != nil { + return Options{}, err + } + + return e.ToOptions(), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..185a2c5 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/vicanso/echarts + +go 1.17 + +require ( + github.com/dustin/go-humanize v1.0.0 + github.com/wcharczuk/go-chart/v2 v2.1.0 +) + +require ( + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b3869e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I= +github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= +golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM= +golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/series.go b/series.go index 09a6c54..c697959 100644 --- a/series.go +++ b/series.go @@ -30,6 +30,7 @@ type SeriesData struct { Value float64 Style chart.Style } + type Series struct { Type string Name string @@ -46,6 +47,15 @@ const ( SeriesPie = "pie" ) +func NewSeriesDataListFromFloat(values []float64) []SeriesData { + dataList := make([]SeriesData, len(values)) + for index, value := range values { + dataList[index] = SeriesData{ + Value: value, + } + } + return dataList +} func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) []chart.Series { arr := make([]chart.Series, len(series)) barCount := 0 @@ -60,11 +70,11 @@ func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) [ style := chart.Style{ StrokeWidth: lineStrokeWidth, StrokeColor: getSeriesColor(theme, index), - // FillColor: getSeriesColor(theme, index), // TODO 调整为通过dot with color 生成 DotColor: getSeriesColor(theme, index), DotWidth: dotWith, } + pointIndexOffset := 0 // 如果居中,需要多增加一个点 if tickPosition == chart.TickPositionBetweenTicks { item.Data = append([]SeriesData{ @@ -72,6 +82,7 @@ func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) [ Value: 0.0, }, }, item.Data...) + pointIndexOffset = -1 } yValues := make([]float64, len(item.Data)) barCustomStyles := make([]BarSeriesCustomStyle, 0) @@ -79,7 +90,7 @@ func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) [ yValues[i] = item.Value if !item.Style.IsZero() { barCustomStyles = append(barCustomStyles, BarSeriesCustomStyle{ - PointIndex: i, + PointIndex: i + pointIndexOffset, Index: barIndex, Style: item.Style, }) diff --git a/theme.go b/theme.go index 1962a3f..d67bf9e 100644 --- a/theme.go +++ b/theme.go @@ -23,6 +23,8 @@ package charts import ( + "strings" + "github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2/drawing" ) @@ -107,3 +109,14 @@ func getSeriesColor(theme string, index int) drawing.Color { } return SeriesColorsLight[index%len(SeriesColorsLight)] } + +func parseColor(color string) drawing.Color { + if color == "" { + return drawing.Color{} + } + if strings.HasPrefix(color, "#") { + return drawing.ColorFromHex(color[1:]) + } + // TODO + return drawing.Color{} +}