diff --git a/axis.go b/axis.go index 04229e8..6f9452b 100644 --- a/axis.go +++ b/axis.go @@ -25,7 +25,6 @@ package charts import ( "github.com/dustin/go-humanize" "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) type ( @@ -41,6 +40,10 @@ type ( } ) +type YAxisOption struct { + Formater chart.ValueFormatter +} + const axisStrokeWidth = 1 func GetXAxisAndValues(xAxis XAxis, tickPosition chart.TickPosition, theme string) (chart.XAxis, []float64) { @@ -85,24 +88,29 @@ func GetXAxisAndValues(xAxis XAxis, tickPosition chart.TickPosition, theme strin }) } } - // TODO - if theme == ThemeDark { - return chart.XAxis{ - Ticks: ticks, - }, xValues - } return chart.XAxis{ Ticks: ticks, TickPosition: tickPosition, Style: chart.Style{ - FontColor: AxisColorLight, - StrokeColor: AxisColorLight, + FontColor: getAxisColor(theme), + StrokeColor: getAxisColor(theme), StrokeWidth: axisStrokeWidth, }, }, xValues } -func GetSecondaryYAxis(theme string) chart.YAxis { +func defaultFloatFormater(v interface{}) string { + value, ok := v.(float64) + if !ok { + return "" + } + if value >= 10 { + return humanize.CommafWithDigits(value, 0) + } + return humanize.CommafWithDigits(value, 2) +} + +func GetSecondaryYAxis(theme string, option *YAxisOption) chart.YAxis { // TODO if theme == ThemeDark { return chart.YAxis{} @@ -113,41 +121,46 @@ func GetSecondaryYAxis(theme string) chart.YAxis { // 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(), + formater := defaultFloatFormater + if option != nil { + if option.Formater != nil { + formater = option.Formater + } } + hidden := chart.Hidden() + return chart.YAxis{ + ValueFormatter: formater, + AxisType: chart.YAxisPrimary, + GridMajorStyle: hidden, + GridMinorStyle: hidden, + Style: chart.Style{ + FontColor: getAxisColor(theme), + // alpha 0,隐藏 + StrokeColor: hiddenColor, + StrokeWidth: axisStrokeWidth, + }, + } + // c := chart.Hidden() + // return chart.YAxis{ + // ValueFormatter: defaultFloatFormater, + // AxisType: chart.YAxisPrimary, + // GridMajorStyle: c, + // GridMinorStyle: c, + // Style: c, + // } } -func GetYAxis(theme string) chart.YAxis { - // TODO - if theme == ThemeDark { - return chart.YAxis{} - } - strokeColor := drawing.Color{ - R: 224, - G: 230, - B: 241, - A: 255, +func GetYAxis(theme string, option *YAxisOption) chart.YAxis { + strokeColor := getGridColor(theme) + formater := defaultFloatFormater + if option != nil { + if option.Formater != nil { + formater = option.Formater + } } return chart.YAxis{ - ValueFormatter: func(v interface{}) string { - value, ok := v.(float64) - if !ok { - return "" - } - return humanize.Commaf(value) - }, - AxisType: chart.YAxisSecondary, + ValueFormatter: formater, + AxisType: chart.YAxisSecondary, GridMajorStyle: chart.Style{ StrokeColor: strokeColor, StrokeWidth: axisStrokeWidth, @@ -157,7 +170,7 @@ func GetYAxis(theme string) chart.YAxis { StrokeWidth: axisStrokeWidth, }, Style: chart.Style{ - FontColor: AxisColorLight, + FontColor: getAxisColor(theme), // alpha 0,隐藏 StrokeColor: hiddenColor, StrokeWidth: axisStrokeWidth, diff --git a/charts.go b/charts.go index 69af23e..e7bda2e 100644 --- a/charts.go +++ b/charts.go @@ -53,7 +53,7 @@ type ( Height int Theme string XAxis XAxis - YAxisList []chart.YAxis + YAxisOptions []*YAxisOption Series []Series Title Title Legend Legend @@ -72,7 +72,7 @@ func (o *Options) validate() error { } for _, item := range o.Series { - if len(item.Data) != xAxisCount { + if item.Type != SeriesPie && len(item.Data) != xAxisCount { return errors.New("series and xAxis is not matched") } } @@ -109,18 +109,6 @@ func New(opt Options) (Graph, error) { if height <= 0 { height = DefaultChartHeight } - - xAxis, xValues := GetXAxisAndValues(opt.XAxis, tickPosition, opt.Theme) - - legendSize := len(opt.Legend.Data) - for index, item := range opt.Series { - if len(item.XValues) == 0 { - opt.Series[index].XValues = xValues - } - if index < legendSize && opt.Series[index].Name == "" { - opt.Series[index].Name = opt.Legend.Data[index] - } - } if opt.Series[0].Type == SeriesPie { values := make(chart.Values, len(opt.Series)) for index, item := range opt.Series { @@ -142,21 +130,49 @@ func New(opt Options) (Graph, error) { return g, nil } + xAxis, xValues := GetXAxisAndValues(opt.XAxis, tickPosition, opt.Theme) + + legendSize := len(opt.Legend.Data) + for index, item := range opt.Series { + if len(item.XValues) == 0 { + opt.Series[index].XValues = xValues + } + if index < legendSize && opt.Series[index].Name == "" { + opt.Series[index].Name = opt.Legend.Data[index] + } + } + + var yAxisOption *YAxisOption + if len(opt.YAxisOptions) != 0 { + yAxisOption = opt.YAxisOptions[0] + } + var secondaryYAxisOption *YAxisOption + if len(opt.YAxisOptions) > 1 { + secondaryYAxisOption = opt.YAxisOptions[1] + } + g := &chart.Chart{ + ColorPalette: &ThemeColorPalette{ + Theme: opt.Theme, + }, Title: opt.Title.Text, TitleStyle: opt.Title.Style, Width: width, Height: height, XAxis: xAxis, - YAxis: GetYAxis(opt.Theme), - YAxisSecondary: GetSecondaryYAxis(opt.Theme), + YAxis: GetYAxis(opt.Theme, yAxisOption), + YAxisSecondary: GetSecondaryYAxis(opt.Theme, secondaryYAxisOption), Series: GetSeries(opt.Series, tickPosition, opt.Theme), } // 设置secondary的样式 if legendSize != 0 { g.Elements = []chart.Renderable{ - DefaultLegend(g), + LegendCustomize(g, LegendOption{ + Theme: opt.Theme, + TextPosition: LegendTextPositionRight, + IconDraw: DefaultLegendIconDraw, + }), } } return g, nil diff --git a/echarts.go b/echarts.go index 28c73db..6361837 100644 --- a/echarts.go +++ b/echarts.go @@ -27,6 +27,7 @@ import ( "encoding/json" "regexp" "strconv" + "strings" "github.com/wcharczuk/go-chart/v2" ) @@ -36,6 +37,7 @@ type EChartStyle struct { } type ECharsSeriesData struct { Value float64 `json:"value"` + Name string `json:"name"` ItemStyle EChartStyle `json:"itemStyle"` } type _ECharsSeriesData ECharsSeriesData @@ -58,6 +60,7 @@ func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error { if err != nil { return err } + es.Name = v.Name es.Value = v.Value es.ItemStyle = v.ItemStyle return nil @@ -86,6 +89,7 @@ func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error { } type ECharsOptions struct { + Theme string `json:"theme"` Title struct { Text string `json:"text"` TextStyle struct { @@ -104,30 +108,34 @@ type ECharsOptions struct { Data []string `json:"data"` } `json:"legend"` Series []struct { - Data []ECharsSeriesData `json:"data"` - Type string `json:"type"` + Data []ECharsSeriesData `json:"data"` + Type string `json:"type"` + YAxisIndex int `json:"yAxisIndex"` } `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 - +func convertEChartsSeries(e *ECharsOptions) ([]Series, chart.TickPosition) { tickPosition := chart.TickPositionUnset + + if len(e.Series) == 0 { + return nil, tickPosition + } + seriesType := e.Series[0].Type + if seriesType == SeriesPie { + series := make([]Series, len(e.Series[0].Data)) + for index, item := range e.Series[0].Data { + series[index] = Series{ + Data: []SeriesData{ + { + Value: item.Value, + }, + }, + Type: seriesType, + Name: item.Name, + } + } + return series, tickPosition + } series := make([]Series, len(e.Series)) for index, item := range e.Series { // bar默认tick居中 @@ -146,11 +154,54 @@ func (e *ECharsOptions) ToOptions() Options { } data[j] = sd } + yAxisType := chart.YAxisPrimary + if item.YAxisIndex != 0 { + yAxisType = chart.YAxisSecondary + } series[index] = Series{ - Data: data, - Type: item.Type, + YAxis: yAxisType, + Data: data, + Type: item.Type, } } + return series, tickPosition +} + +func (e *ECharsOptions) ToOptions() Options { + o := Options{ + Theme: e.Theme, + } + 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, + } + if len(e.YAxis.Data) != 0 { + yAxisOptions := make([]*YAxisOption, len(e.YAxis.Data)) + for index, item := range e.YAxis.Data { + opt := &YAxisOption{} + template := item.AxisLabel.Formatter + if template != "" { + opt.Formater = func(v interface{}) string { + str := defaultFloatFormater(v) + return strings.ReplaceAll(template, "{value}", str) + } + } + yAxisOptions[index] = opt + } + o.YAxisOptions = yAxisOptions + } + + series, tickPosition := convertEChartsSeries(e) + o.Series = series if e.XAxis.BoundaryGap == nil || *e.XAxis.BoundaryGap { diff --git a/examples/main.go b/examples/main.go new file mode 100644 index 0000000..3d3d5d8 --- /dev/null +++ b/examples/main.go @@ -0,0 +1,337 @@ +package main + +import ( + "bytes" + "net/http" + + charts "github.com/vicanso/echarts" +) + +var html = ` + + + + + + + + go-echarts + + + {{body}} + + +` + +var chartOptions = []map[string]string{ + { + "title": "折线图", + "option": `{ + "xAxis": { + "type": "category", + "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + }, + "series": [ + { + "data": [150, 230, 224, 218, 135, 147, 260], + "type": "line" + } + ] +}`, + }, + { + "title": "多折线图", + "option": `{ + "title": { + "text": "Multi Line" + }, + "legend": { + "data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"] + }, + "xAxis": { + "type": "category", + "boundaryGap": false, + "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + }, + "series": [ + { + "type": "line", + "data": [120, 132, 101, 134, 90, 230, 210] + }, + { + "data": [220, 182, 191, 234, 290, 330, 310] + }, + { + "data": [150, 232, 201, 154, 190, 330, 410] + }, + { + "data": [320, 332, 301, 334, 390, 330, 320] + }, + { + "data": [820, 932, 901, 934, 1290, 1330, 1320] + } + ] +}`, + }, + { + "title": "柱状图", + "option": `{ + "xAxis": { + "type": "category", + "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + }, + "series": [ + { + "data": [120, 200, 150, 80, 70, 110, 130], + "type": "bar" + } + ] +}`, + }, + { + "title": "柱状图(自定义颜色)", + "option": `{ + "xAxis": { + "type": "category", + "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + }, + "series": [ + { + "data": [ + 120, + { + "value": 200, + "itemStyle": { + "color": "#a90000" + } + }, + 150, + 80, + 70, + 110, + 130 + ], + "type": "bar" + } + ] +}`, + }, + { + "title": "多柱状图", + "option": `{ + "title": { + "text": "Rainfall vs Evaporation" + }, + "legend": { + "data": ["Rainfall", "Evaporation"] + }, + "xAxis": { + "type": "category", + "splitNumber": 12, + "data": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + }, + "series": [ + { + "name": "Rainfall", + "type": "bar", + "data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6, 162.2, 32.6, 20, 6.4, 3.3] + }, + { + "name": "Evaporation", + "type": "bar", + "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6, 2.3] + } + ] +}`, + }, + { + "title": "折柱混合", + "option": `{ + "legend": { + "data": [ + "Evaporation", + "Precipitation", + "Temperature" + ] + }, + "xAxis": { + "type": "category", + "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + }, + "yAxis": [ + { + "type": "value", + "name": "Precipitation", + "min": 0, + "max": 250, + "interval": 50, + "axisLabel": { + "formatter": "{value} ml" + } + }, + { + "type": "value", + "name": "Temperature", + "min": 0, + "max": 25, + "interval": 5, + "axisLabel": { + "formatter": "{value} °C" + } + } + ], + "series": [ + { + "name": "Evaporation", + "type": "bar", + "data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6] + }, + { + "name": "Precipitation", + "type": "bar", + "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6] + }, + { + "name": "Temperature", + "type": "line", + "yAxisIndex": 1, + "data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3] + } + ] +}`, + }, + { + "title": "降雨量", + "option": `{ + "title": { + "text": "降雨量" + }, + "legend": { + "data": ["GZ", "SH"] + }, + "xAxis": { + "type": "category", + "splitNumber": 12, + "data": ["01-01","01-02","01-03","01-04","01-05","01-06","01-07","01-08","01-09","01-10","01-11","01-12","01-13","01-14","01-15","01-16","01-17","01-18","01-19","01-20","01-21","01-22","01-23","01-24","01-25","01-26","01-27","01-28","01-29","01-30","01-31"] + }, + "yAxis": { + "axisLabel": { + "formatter": "{value} mm" + } + }, + "series": [ + { + "type": "bar", + "data": [928,821,889,600,547,783,197,853,430,346,63,465,309,334,141,538,792,58,922,807,298,243,744,885,812,231,330,220,984,221,429] + }, + { + "type": "bar", + "data": [749,201,296,579,255,159,902,246,149,158,507,776,186,79,390,222,601,367,221,411,714,620,966,73,203,631,833,610,487,677,596] + } + ] +}`, + }, + { + "title": "饼图", + "option": `{ + "title": { + "text": "Referer of a Website" + }, + "series": [ + { + "name": "Access From", + "type": "pie", + "radius": "50%", + "data": [ + { + "value": 1048, + "name": "Search Engine" + }, + { + "value": 735, + "name": "Direct" + }, + { + "value": 580, + "name": "Email" + }, + { + "value": 484, + "name": "Union Ads" + }, + { + "value": 300, + "name": "Video Ads" + } + ] + } + ] +}`, + }, +} + +func render(theme string) ([]byte, error) { + data := bytes.Buffer{} + for _, m := range chartOptions { + if m["title"] != "折柱混合" { + continue + } + chartHTML := []byte(`
+

{{title}}

+
{{option}}
+ {{svg}} +
`) + o, err := charts.ParseECharsOptions(m["option"]) + o.Theme = theme + if err != nil { + return nil, err + } + g, err := charts.New(o) + if err != nil { + return nil, err + } + buf, err := charts.ToSVG(g) + if err != nil { + return nil, err + } + buf = bytes.ReplaceAll(chartHTML, []byte("{{svg}}"), buf) + buf = bytes.ReplaceAll(buf, []byte("{{title}}"), []byte(m["title"])) + buf = bytes.ReplaceAll(buf, []byte("{{option}}"), []byte(m["option"])) + data.Write(buf) + } + return data.Bytes(), nil +} + +func indexHandler(w http.ResponseWriter, r *http.Request) { + + buf, err := render(r.URL.Query().Get("theme")) + if err != nil { + w.WriteHeader(400) + w.Write([]byte(err.Error())) + return + } + + data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), buf) + w.Header().Set("Content-Type", "text/html") + w.Write(data) +} + +func main() { + http.HandleFunc("/", indexHandler) + http.ListenAndServe(":3012", nil) +} diff --git a/legend.go b/legend.go index fc0a0bd..e5cc823 100644 --- a/legend.go +++ b/legend.go @@ -65,22 +65,15 @@ func DefaultLegendIconDraw(r chart.Renderer, opt LegendIconDrawOption) { r.MoveTo(opt.Box.Left, ly) r.LineTo(opt.Box.Right, ly) r.Stroke() - r.SetFillColor(chart.ColorWhite) + r.SetFillColor(getBackgroundColor(opt.Theme)) r.Circle(5, (opt.Box.Left+opt.Box.Right)/2, ly) r.FillStroke() } -func DefaultLegend(c *chart.Chart) chart.Renderable { - return LegendCustomize(c, LegendOption{ - TextPosition: LegendTextPositionRight, - IconDraw: DefaultLegendIconDraw, - }) -} - func LegendCustomize(c *chart.Chart, opt LegendOption) chart.Renderable { return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) { legendDefaults := chart.Style{ - FontColor: chart.DefaultTextColor, + FontColor: getTextColor(opt.Theme), FontSize: 8.0, StrokeColor: chart.DefaultAxisColor, } diff --git a/series.go b/series.go index c697959..867051b 100644 --- a/series.go +++ b/series.go @@ -36,6 +36,7 @@ type Series struct { Name string Data []SeriesData XValues []float64 + YAxis chart.YAxisType } const lineStrokeWidth = 2 @@ -102,6 +103,7 @@ func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) [ Style: style, YValues: yValues, TickPosition: tickPosition, + YAxis: item.YAxis, } // TODO 判断类型 switch item.Type { diff --git a/theme.go b/theme.go index d67bf9e..efbdf4a 100644 --- a/theme.go +++ b/theme.go @@ -37,6 +37,50 @@ var AxisColorLight = drawing.Color{ B: 121, A: 255, } +var AxisColorDark = drawing.Color{ + R: 185, + G: 184, + B: 206, + A: 255, +} + +var BackgroundColorDark = drawing.Color{ + R: 16, + G: 12, + B: 42, + A: 255, +} + +var TextColorDark = drawing.Color{ + R: 204, + G: 204, + B: 204, + A: 255, +} + +func getAxisColor(theme string) drawing.Color { + if theme == ThemeDark { + return AxisColorDark + } + return AxisColorLight +} + +func getGridColor(theme string) drawing.Color { + if theme == ThemeDark { + return drawing.Color{ + R: 72, + G: 71, + B: 83, + A: 255, + } + } + return drawing.Color{ + R: 224, + G: 230, + B: 241, + A: 255, + } +} var SeriesColorsLight = []drawing.Color{ { @@ -71,12 +115,26 @@ var SeriesColorsLight = []drawing.Color{ }, } +func getBackgroundColor(theme string) drawing.Color { + if theme == ThemeDark { + return BackgroundColorDark + } + return chart.DefaultBackgroundColor +} + +func getTextColor(theme string) drawing.Color { + if theme == ThemeDark { + return TextColorDark + } + return chart.DefaultTextColor +} + type ThemeColorPalette struct { Theme string } func (tp ThemeColorPalette) BackgroundColor() drawing.Color { - return chart.DefaultBackgroundColor + return getBackgroundColor(tp.Theme) } func (tp ThemeColorPalette) BackgroundStrokeColor() drawing.Color { @@ -84,6 +142,9 @@ func (tp ThemeColorPalette) BackgroundStrokeColor() drawing.Color { } func (tp ThemeColorPalette) CanvasColor() drawing.Color { + if tp.Theme == ThemeDark { + return BackgroundColorDark + } return chart.DefaultCanvasColor } @@ -92,11 +153,14 @@ func (tp ThemeColorPalette) CanvasStrokeColor() drawing.Color { } func (tp ThemeColorPalette) AxisStrokeColor() drawing.Color { + if tp.Theme == ThemeDark { + return BackgroundColorDark + } return chart.DefaultAxisColor } func (tp ThemeColorPalette) TextColor() drawing.Color { - return chart.DefaultTextColor + return getTextColor(tp.Theme) } func (tp ThemeColorPalette) GetSeriesColor(index int) drawing.Color { @@ -104,9 +168,6 @@ func (tp ThemeColorPalette) GetSeriesColor(index int) drawing.Color { } func getSeriesColor(theme string, index int) drawing.Color { - // TODO - if theme == ThemeDark { - } return SeriesColorsLight[index%len(SeriesColorsLight)] } @@ -117,6 +178,6 @@ func parseColor(color string) drawing.Color { if strings.HasPrefix(color, "#") { return drawing.ColorFromHex(color[1:]) } - // TODO + // TODO rgba return drawing.Color{} }