From 8a5990fe8fd8ce0f3b40538ed645840c3e587f47 Mon Sep 17 00:00:00 2001 From: vicanso Date: Mon, 13 Jun 2022 23:22:15 +0800 Subject: [PATCH] feat: support mark line and mark point render --- chart_option.go | 120 ++++ charts.go | 117 +++- examples/charts/main.go | 1321 +++++++++++++++++++++++++++++++++++ examples/line_chart/main.go | 13 + legend.go | 24 +- line_chart.go | 56 +- mark_line.go | 118 ++++ mark_point.go | 102 +++ painter.go | 2 +- title.go | 192 +++++ 10 files changed, 2046 insertions(+), 19 deletions(-) create mode 100644 chart_option.go create mode 100644 examples/charts/main.go create mode 100644 mark_line.go create mode 100644 mark_point.go create mode 100644 title.go diff --git a/chart_option.go b/chart_option.go new file mode 100644 index 0000000..6ca7cd7 --- /dev/null +++ b/chart_option.go @@ -0,0 +1,120 @@ +// 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 ( + "sort" + + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" +) + +type ChartOption struct { + theme ColorPalette + font *truetype.Font + // The output type of chart, "svg" or "png", default value is "svg" + Type string + // The font family, which should be installed first + FontFamily string + // The theme of chart, "light" and "dark". + // The default theme is "light" + Theme string + // The title option + Title TitleOption + // The legend option + Legend LegendOption + // The x axis option + XAxis XAxisOption + // The y axis option list + YAxisOptions []YAxisOption + // The width of chart, default width is 600 + Width int + // The height of chart, default height is 400 + Height int + Parent *Painter + // The padding for chart, default padding is [20, 10, 10, 10] + Padding Box + // The canvas box for chart + Box Box + // The series list + SeriesList SeriesList + // The radar indicator list + // RadarIndicators []RadarIndicator + // The background color of chart + BackgroundColor Color + // The child charts + Children []ChartOption +} + +func (o *ChartOption) fillDefault() { + t := NewTheme(o.Theme) + o.theme = t + // 如果为空,初始化 + axisCount := 1 + for _, series := range o.SeriesList { + if series.AxisIndex >= axisCount { + axisCount++ + } + } + o.Width = getDefaultInt(o.Width, defaultChartWidth) + o.Height = getDefaultInt(o.Height, defaultChartHeight) + yAxisOptions := make([]YAxisOption, axisCount) + copy(yAxisOptions, o.YAxisOptions) + o.YAxisOptions = yAxisOptions + o.font, _ = GetFont(o.FontFamily) + + if o.font == nil { + o.font, _ = chart.GetDefaultFont() + } + if o.BackgroundColor.IsZero() { + o.BackgroundColor = t.GetBackgroundColor() + } + if o.Padding.IsZero() { + o.Padding = chart.Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + } + } + // legend与series name的关联 + if len(o.Legend.Data) == 0 { + o.Legend.Data = o.SeriesList.Names() + } else { + seriesCount := len(o.SeriesList) + for index, name := range o.Legend.Data { + if index < seriesCount && + len(o.SeriesList[index].Name) == 0 { + o.SeriesList[index].Name = name + } + } + nameIndexDict := map[string]int{} + for index, name := range o.Legend.Data { + nameIndexDict[name] = index + } + // 保证series的顺序与legend一致 + sort.Slice(o.SeriesList, func(i, j int) bool { + return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name] + }) + } +} diff --git a/charts.go b/charts.go index 6a2f5f2..947fa8d 100644 --- a/charts.go +++ b/charts.go @@ -22,6 +22,24 @@ package charts +const labelFontSize = 10 +const defaultDotWidth = 2.0 +const defaultStrokeWidth = 2.0 + +var defaultChartWidth = 600 +var defaultChartHeight = 400 + +func SetDefaultWidth(width int) { + if width > 0 { + defaultChartWidth = width + } +} +func SetDefaultHeight(height int) { + if height > 0 { + defaultChartHeight = height + } +} + type Renderer interface { Render() (Box, error) } @@ -34,6 +52,10 @@ type defaultRenderOption struct { YAxisOptions []YAxisOption // The x axis option XAxis XAxisOption + // The title option + TitleOption TitleOption + // The legend option + LegendOption LegendOption } type defaultRenderResult struct { @@ -42,10 +64,37 @@ type defaultRenderResult struct { } func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, error) { - p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor()) if !opt.Padding.IsZero() { p = p.Child(PainterPaddingOption(opt.Padding)) } + + if len(opt.LegendOption.Data) != 0 { + if opt.LegendOption.Theme == nil { + opt.LegendOption.Theme = opt.Theme + } + _, err := NewLegendPainter(p, opt.LegendOption).Render() + if err != nil { + return nil, err + } + } + + // 如果有标题 + if opt.TitleOption.Text != "" { + if opt.TitleOption.Theme == nil { + opt.TitleOption.Theme = opt.Theme + } + titlePainter := NewTitlePainter(p, opt.TitleOption) + + titleBox, err := titlePainter.Render() + if err != nil { + return nil, err + } + p = p.Child(PainterPaddingOption(Box{ + // 标题下留白 + Top: titleBox.Height() + 20, + })) + } + result := defaultRenderResult{ axisRanges: make(map[int]axisRange), } @@ -60,7 +109,8 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e } // 高度需要减去x轴的高度 rangeHeight := p.Height() - defaultXAxisHeight - rangeWidth := 0 + rangeWidthLeft := 0 + rangeWidthRight := 0 // 计算对应的axis range for _, index := range axisIndexList { @@ -89,28 +139,28 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e if err != nil { return nil, err } - rangeWidth += yAxisBox.Width() + if index == 0 { + rangeWidthLeft += yAxisBox.Width() + } else { + rangeWidthRight += yAxisBox.Width() + } } if opt.XAxis.Theme == nil { opt.XAxis.Theme = opt.Theme } xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{ - Left: rangeWidth, + Left: rangeWidthLeft, })), opt.XAxis) _, err := xAxis.Render() if err != nil { return nil, err } - // // 生成Y轴 - // for _, yAxisOption := range opt.YAxisOptions { - - // } - result.p = p.Child(PainterPaddingOption(Box{ Bottom: rangeHeight, - Left: rangeWidth, + Left: rangeWidthLeft, + Right: rangeWidthRight, })) return &result, nil } @@ -124,3 +174,50 @@ func doRender(renderers ...Renderer) error { } return nil } + +func Render(opt ChartOption) (*Painter, error) { + opt.fillDefault() + + if opt.Parent == nil { + p, err := NewPainter(PainterOptions{ + Type: opt.Type, + Width: opt.Width, + Height: opt.Height, + }) + if err != nil { + return nil, err + } + opt.Parent = p + } + p := opt.Parent + p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor) + seriesList := opt.SeriesList + seriesList.init() + + rendererList := make([]Renderer, 0) + + // line chart + lineChartSeriesList := seriesList.Filter(ChartTypeLine) + if len(lineChartSeriesList) != 0 { + renderer := NewLineChart(p, LineChartOption{ + Theme: opt.theme, + Font: opt.font, + SeriesList: lineChartSeriesList, + XAxis: opt.XAxis, + Padding: opt.Padding, + YAxisOptions: opt.YAxisOptions, + TitleOption: opt.Title, + LegendOption: opt.Legend, + }) + rendererList = append(rendererList, renderer) + } + + for _, renderer := range rendererList { + _, err := renderer.Render() + if err != nil { + return nil, err + } + } + + return p, nil +} diff --git a/examples/charts/main.go b/examples/charts/main.go new file mode 100644 index 0000000..18f5a95 --- /dev/null +++ b/examples/charts/main.go @@ -0,0 +1,1321 @@ +package main + +import ( + "bytes" + "net/http" + "strconv" + + charts "github.com/vicanso/go-charts" +) + +var html = ` + + + + + + + go-charts + + +
{{body}}
+ + +` + +func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.ChartOption, echartsOptions []string) { + if req.URL.Path != "/" && + req.URL.Path != "/echarts" { + return + } + query := req.URL.Query() + theme := query.Get("theme") + width, _ := strconv.Atoi(query.Get("width")) + height, _ := strconv.Atoi(query.Get("height")) + charts.SetDefaultWidth(width) + charts.SetDefaultWidth(height) + bytesList := make([][]byte, 0) + for _, opt := range chartOptions { + opt.Theme = theme + d, err := charts.Render(opt) + if err != nil { + panic(err) + } + buf, err := d.Bytes() + if err != nil { + panic(err) + } + bytesList = append(bytesList, buf) + } + // for _, opt := range echartsOptions { + // buf, err := charts.RenderEChartsToSVG(opt) + // if err != nil { + // panic(err) + // } + // bytesList = append(bytesList, buf) + // } + + data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), bytes.Join(bytesList, []byte(""))) + w.Header().Set("Content-Type", "text/html") + w.Write(data) +} + +func indexHandler(w http.ResponseWriter, req *http.Request) { + chartOptions := []charts.ChartOption{ + { + Title: charts.TitleOption{ + Text: "Line", + }, + Legend: charts.NewLegendOption([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }), + XAxis: charts.NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + SeriesList: []charts.Series{ + charts.NewSeriesFromValues([]float64{ + 120, + 132, + 101, + 134, + 90, + 230, + 210, + }), + charts.NewSeriesFromValues([]float64{ + 220, + 182, + 191, + 234, + 290, + 330, + 310, + }), + charts.NewSeriesFromValues([]float64{ + 150, + 232, + 201, + 154, + 190, + 330, + 410, + }), + charts.NewSeriesFromValues([]float64{ + 320, + 332, + 301, + 334, + 390, + 330, + 320, + }), + charts.NewSeriesFromValues([]float64{ + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320, + }), + }, + }, + // 温度折线图 + { + Title: charts.TitleOption{ + Text: "Temperature Change in the Coming Week", + }, + Padding: charts.Box{ + Top: 20, + Left: 20, + Right: 30, + Bottom: 20, + }, + Legend: charts.NewLegendOption([]string{ + "Highest", + "Lowest", + }, charts.PositionRight), + XAxis: charts.NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, charts.FalseFlag()), + SeriesList: []charts.Series{ + { + Data: charts.NewSeriesDataFromValues([]float64{ + 14, + 11, + 13, + 11, + 12, + 12, + 7, + }), + MarkPoint: charts.NewMarkPoint(charts.SeriesMarkDataTypeMax, charts.SeriesMarkDataTypeMin), + MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage), + }, + { + Data: charts.NewSeriesDataFromValues([]float64{ + 1, + -2, + 2, + 5, + 3, + 2, + 0, + }), + MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage), + }, + }, + }, + } + handler(w, req, chartOptions, nil) +} + +func echartsHandler(w http.ResponseWriter, req *http.Request) { + echartsOptions := []string{ + `{ + "xAxis": { + "type": "category", + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "type": "value" + }, + "series": [ + { + "data": [ + 150, + 230, + 224, + 218, + 135, + 147, + 260 + ], + "type": "line" + } + ] + }`, + `{ + "title": { + "text": "Multiple Line" + }, + "tooltip": { + "trigger": "axis" + }, + "legend": { + "left": "right", + "data": [ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine" + ] + }, + "grid": { + "left": "3%", + "right": "4%", + "bottom": "3%", + "containLabel": true + }, + "toolbox": { + "feature": { + "saveAsImage": {} + } + }, + "xAxis": { + "type": "category", + "boundaryGap": false, + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "type": "value" + }, + "series": [ + { + "name": "Email", + "type": "line", + "data": [ + 120, + 132, + 101, + 134, + 90, + 230, + 210 + ] + }, + { + "name": "Union Ads", + "type": "line", + "data": [ + 220, + 182, + 191, + 234, + 290, + 330, + 310 + ] + }, + { + "name": "Video Ads", + "type": "line", + "data": [ + 150, + 232, + 201, + 154, + 190, + 330, + 410 + ] + }, + { + "name": "Direct", + "type": "line", + "data": [ + 320, + 332, + 301, + 334, + 390, + 330, + 320 + ] + }, + { + "name": "Search Engine", + "type": "line", + "data": [ + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320 + ] + } + ] + }`, + `{ + "title": { + "text": "Temperature Change in the Coming Week" + }, + "legend": { + "left": "right" + }, + "padding": [10, 30, 10, 10], + "xAxis": { + "type": "category", + "boundaryGap": false, + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "axisLabel": { + "formatter": "{value} °C" + } + }, + "series": [ + { + "name": "Highest", + "type": "line", + "data": [ + 10, + 11, + 13, + 11, + 12, + 12, + 9 + ], + "markPoint": { + "data": [ + { + "type": "max" + }, + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + } + ] + } + }, + { + "name": "Lowest", + "type": "line", + "data": [ + 1, + -2, + 2, + 5, + 3, + 2, + 0 + ], + "markPoint": { + "data": [ + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + }, + { + "type": "max" + } + ] + } + } + ] + }`, + `{ + "xAxis": { + "type": "category", + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "type": "value" + }, + "series": [ + { + "data": [ + 120, + 200, + 150, + 80, + 70, + 110, + 130 + ], + "type": "bar" + } + ] + }`, + `{ + "xAxis": { + "type": "category", + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "type": "value" + }, + "series": [ + { + "data": [ + 120, + { + "value": 200, + "itemStyle": { + "color": "#a90000" + } + }, + 150, + 80, + 70, + 110, + 130 + ], + "type": "bar" + } + ] + }`, + `{ + "title": { + "text": "Rainfall vs Evaporation", + "subtext": "Fake Data" + }, + "legend": { + "data": [ + "Rainfall", + "Evaporation" + ] + }, + "padding": [10, 30, 10, 10], + "xAxis": [ + { + "type": "category", + "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 + ], + "markPoint": { + "data": [ + { + "type": "max" + }, + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + } + ] + } + }, + { + "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 + ], + "markPoint": { + "data": [ + { + "type": "max" + }, + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + } + ] + } + } + ] + }`, + `{ + "legend": { + "data": [ + "Evaporation", + "Precipitation", + "Temperature" + ] + }, + "xAxis": [ + { + "type": "category", + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ], + "axisPointer": { + "type": "shadow" + } + } + ], + "yAxis": [ + { + "type": "value", + "name": "Precipitation", + "min": 0, + "max": 240, + "axisLabel": { + "formatter": "{value} ml" + } + }, + { + "type": "value", + "name": "Temperature", + "min": 0, + "max": 24, + "axisLabel": { + "formatter": "{value} °C" + } + } + ], + "series": [ + { + "name": "Evaporation", + "type": "bar", + "tooltip": {}, + "data": [ + 2, + 4.9, + 7, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20, + 6.4, + 3.3 + ] + }, + { + "name": "Precipitation", + "type": "bar", + "tooltip": {}, + "data": [ + 2.6, + 5.9, + 9, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6, + 2.3 + ] + }, + { + "name": "Temperature", + "type": "line", + "yAxisIndex": 1, + "tooltip": {}, + "data": [ + 2, + 2.2, + 3.3, + 4.5, + 6.3, + 10.2, + 20.3, + 23.4, + 23, + 16.5, + 12, + 6.2 + ] + } + ] + }`, + `{ + "tooltip": { + "trigger": "axis", + "axisPointer": { + "type": "cross" + } + }, + "grid": { + "right": "20%" + }, + "toolbox": { + "feature": { + "dataView": { + "show": true, + "readOnly": false + }, + "restore": { + "show": true + }, + "saveAsImage": { + "show": true + } + } + }, + "legend": { + "data": [ + "Evaporation", + "Precipitation", + "Temperature" + ] + }, + "xAxis": [ + { + "type": "category", + "axisTick": { + "alignWithLabel": true + }, + "data": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + } + ], + "yAxis": [ + { + "type": "value", + "name": "温度", + "position": "left", + "alignTicks": true, + "axisLine": { + "show": true, + "lineStyle": { + "color": "#EE6666" + } + }, + "axisLabel": { + "formatter": "{value} °C" + } + }, + { + "type": "value", + "name": "Evaporation", + "position": "right", + "alignTicks": true, + "axisLine": { + "show": true, + "lineStyle": { + "color": "#5470C6" + } + }, + "axisLabel": { + "formatter": "{value} ml" + } + } + ], + "series": [ + { + "name": "Evaporation", + "type": "bar", + "yAxisIndex": 1, + "data": [ + 2, + 4.9, + 7, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20, + 6.4, + 3.3 + ] + }, + { + "name": "Precipitation", + "type": "bar", + "yAxisIndex": 1, + "data": [ + 2.6, + 5.9, + 9, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6, + 2.3 + ] + }, + { + "name": "Temperature", + "type": "line", + "data": [ + 2, + 2.2, + 3.3, + 4.5, + 6.3, + 10.2, + 20.3, + 23.4, + 23, + 16.5, + 12, + 6.2 + ] + } + ] + }`, + `{ + "title": { + "text": "Referer of a Website", + "subtext": "Fake Data", + "left": "center" + }, + "tooltip": { + "trigger": "item" + }, + "legend": { + "orient": "vertical", + "left": "left" + }, + "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" + } + ] + } + ] + }`, + `{ + "title": { + "text": "Rainfall" + }, + "padding": [10, 10, 10, 30], + "legend": { + "data": [ + "GZ", + "SH" + ] + }, + "xAxis": { + "type": "category", + "splitNumber": 6, + "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": { + "text": "Basic Radar Chart" + }, + "legend": { + "data": [ + "Allocated Budget", + "Actual Spending" + ] + }, + "radar": { + "indicator": [ + { + "name": "Sales", + "max": 6500 + }, + { + "name": "Administration", + "max": 16000 + }, + { + "name": "Information Technology", + "max": 30000 + }, + { + "name": "Customer Support", + "max": 38000 + }, + { + "name": "Development", + "max": 52000 + }, + { + "name": "Marketing", + "max": 25000 + } + ] + }, + "series": [ + { + "name": "Budget vs spending", + "type": "radar", + "data": [ + { + "value": [ + 4200, + 3000, + 20000, + 35000, + 50000, + 18000 + ], + "name": "Allocated Budget" + }, + { + "value": [ + 5000, + 14000, + 28000, + 26000, + 42000, + 21000 + ], + "name": "Actual Spending" + } + ] + } + ] + }`, + `{ + "title": { + "text": "Funnel" + }, + "tooltip": { + "trigger": "item", + "formatter": "{a}
{b} : {c}%" + }, + "toolbox": { + "feature": { + "dataView": { + "readOnly": false + }, + "restore": {}, + "saveAsImage": {} + } + }, + "legend": { + "data": [ + "Show", + "Click", + "Visit", + "Inquiry", + "Order" + ] + }, + "series": [ + { + "name": "Funnel", + "type": "funnel", + "left": "10%", + "top": 60, + "bottom": 60, + "width": "80%", + "min": 0, + "max": 100, + "minSize": "0%", + "maxSize": "100%", + "sort": "descending", + "gap": 2, + "label": { + "show": true, + "position": "inside" + }, + "labelLine": { + "length": 10, + "lineStyle": { + "width": 1, + "type": "solid" + } + }, + "itemStyle": { + "borderColor": "#fff", + "borderWidth": 1 + }, + "emphasis": { + "label": { + "fontSize": 20 + } + }, + "data": [ + { + "value": 60, + "name": "Visit" + }, + { + "value": 40, + "name": "Inquiry" + }, + { + "value": 20, + "name": "Order" + }, + { + "value": 80, + "name": "Click" + }, + { + "value": 100, + "name": "Show" + } + ] + } + ] + }`, + `{ + "legend": { + "top": "-140", + "data": [ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie" + ] + }, + "padding": [ + 150, + 10, + 10, + 10 + ], + "xAxis": [ + { + "data": [ + "2012", + "2013", + "2014", + "2015", + "2016", + "2017" + ] + } + ], + "series": [ + { + "data": [ + 56.5, + 82.1, + 88.7, + 70.1, + 53.4, + 85.1 + ] + }, + { + "data": [ + 51.1, + 51.4, + 55.1, + 53.3, + 73.8, + 68.7 + ] + }, + { + "data": [ + 40.1, + 62.2, + 69.5, + 36.4, + 45.2, + 32.5 + ] + }, + { + "data": [ + 25.2, + 37.1, + 41.2, + 18, + 33.9, + 49.1 + ] + } + ], + "children": [ + { + "box": { + "left": 0, + "top": 30, + "right": 600, + "bottom": 150 + }, + "legend": { + "show": false + }, + "series": [ + { + "type": "pie", + "radius": "50%", + "data": [ + { + "value": 435.9, + "name": "Milk Tea" + }, + { + "value": 354.3, + "name": "Matcha Latte" + }, + { + "value": 285.9, + "name": "Cheese Cocoa" + }, + { + "value": 204.5, + "name": "Walnut Brownie" + } + ] + } + ] + } + ] + }`, + } + handler(w, req, nil, echartsOptions) +} + +func main() { + http.HandleFunc("/", indexHandler) + http.HandleFunc("/echarts", echartsHandler) + http.ListenAndServe(":3012", nil) +} diff --git a/examples/line_chart/main.go b/examples/line_chart/main.go index e15500c..c168f08 100644 --- a/examples/line_chart/main.go +++ b/examples/line_chart/main.go @@ -39,6 +39,19 @@ func main() { Right: 10, Bottom: 10, }, + TitleOption: charts.TitleOption{ + Text: "Line", + }, + LegendOption: charts.LegendOption{ + Data: []string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, + Left: charts.PositionCenter, + }, XAxis: charts.NewXAxisOption([]string{ "Mon", "Tue", diff --git a/legend.go b/legend.go index 16341e4..b8a6fdc 100644 --- a/legend.go +++ b/legend.go @@ -29,13 +29,13 @@ import ( type legendPainter struct { p *Painter - opt *LegendPainterOption + opt *LegendOption } const IconRect = "rect" const IconLineDot = "lineDot" -type LegendPainterOption struct { +type LegendOption struct { Theme ColorPalette // Text array of legend Data []string @@ -58,7 +58,17 @@ type LegendPainterOption struct { FontColor Color } -func NewLegendPainter(p *Painter, opt LegendPainterOption) *legendPainter { +func NewLegendOption(labels []string, left ...string) LegendOption { + opt := LegendOption{ + Data: labels, + } + if len(left) != 0 { + opt.Left = left[0] + } + return opt +} + +func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter { return &legendPainter{ p: p, opt: &opt, @@ -71,6 +81,12 @@ func (l *legendPainter) Render() (Box, error) { if theme == nil { theme = l.p.theme } + if opt.FontSize == 0 { + opt.FontSize = theme.GetFontSize() + } + if opt.FontColor.IsZero() { + opt.FontColor = theme.GetTextColor() + } p := l.p p.SetTextStyle(Style{ FontSize: opt.FontSize, @@ -129,7 +145,7 @@ func (l *legendPainter) Render() (Box, error) { top, _ := strconv.Atoi(opt.Top) x := int(left) - y := int(top) + y := int(top) + 10 x0 := x y0 := y diff --git a/line_chart.go b/line_chart.go index 3d93341..5f4ea4f 100644 --- a/line_chart.go +++ b/line_chart.go @@ -23,7 +23,9 @@ package charts import ( + "github.com/golang/freetype/truetype" "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) type lineChart struct { @@ -43,6 +45,8 @@ func NewLineChart(p *Painter, opt LineChartOption) *lineChart { type LineChartOption struct { Theme ColorPalette + // The font size + Font *truetype.Font // The data series list SeriesList SeriesList // The x axis option @@ -51,6 +55,10 @@ type LineChartOption struct { Padding Box // The y axis option YAxisOptions []YAxisOption + // The option of title + TitleOption TitleOption + // The legend option + LegendOption LegendOption } func (l *lineChart) Render() (Box, error) { @@ -64,6 +72,8 @@ func (l *lineChart) Render() (Box, error) { SeriesList: seriesList, XAxis: opt.XAxis, YAxisOptions: opt.YAxisOptions, + TitleOption: opt.TitleOption, + LegendOption: opt.LegendOption, }) if err != nil { return chart.BoxZero, err @@ -78,13 +88,20 @@ func (l *lineChart) Render() (Box, error) { for i := 0; i < len(xDivideValues)-1; i++ { xValues[i] = (xDivideValues[i] + xDivideValues[i+1]) >> 1 } + markPointPainter := NewMarkPointPainter(seriesPainter) + markLinePainter := NewMarkLinePainter(seriesPainter) + rendererList := []Renderer{ + markPointPainter, + markLinePainter, + } for index, series := range seriesList { seriesColor := opt.Theme.GetSeriesColor(index) - seriesPainter.SetDrawingStyle(Style{ + drawingStyle := Style{ StrokeColor: seriesColor, - StrokeWidth: 2, - FillColor: seriesColor, - }) + StrokeWidth: defaultStrokeWidth, + } + + seriesPainter.SetDrawingStyle(drawingStyle) yr := renderResult.axisRanges[series.AxisIndex] points := make([]Point, 0) for i, item := range series.Data { @@ -95,8 +112,39 @@ func (l *lineChart) Render() (Box, error) { } points = append(points, p) } + // 画线 seriesPainter.LineStroke(points) + + // 画点 + if opt.Theme.IsDark() { + drawingStyle.FillColor = drawingStyle.StrokeColor + } else { + drawingStyle.FillColor = drawing.ColorWhite + } + drawingStyle.StrokeWidth = 1 + seriesPainter.SetDrawingStyle(drawingStyle) seriesPainter.Dots(points) + markPointPainter.Add(markPointRenderOption{ + FillColor: seriesColor, + Font: opt.Font, + Points: points, + Series: series, + }) + markLinePainter.Add(markLineRenderOption{ + FillColor: seriesColor, + FontColor: opt.Theme.GetTextColor(), + StrokeColor: seriesColor, + Font: opt.Font, + Series: series, + Range: yr, + }) + } + // 最大、最小的mark point + for _, renderer := range rendererList { + _, err = renderer.Render() + if err != nil { + return chart.BoxZero, err + } } return p.box, nil diff --git a/mark_line.go b/mark_line.go new file mode 100644 index 0000000..9a9d568 --- /dev/null +++ b/mark_line.go @@ -0,0 +1,118 @@ +// 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 ( + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" +) + +func NewMarkLine(markLineTypes ...string) SeriesMarkLine { + data := make([]SeriesMarkData, len(markLineTypes)) + for index, t := range markLineTypes { + data[index] = SeriesMarkData{ + Type: t, + } + } + return SeriesMarkLine{ + Data: data, + } +} + +type markLinePainter struct { + p *Painter + options []markLineRenderOption +} + +func (m *markLinePainter) Add(opt markLineRenderOption) { + m.options = append(m.options, opt) +} + +func NewMarkLinePainter(p *Painter) *markLinePainter { + return &markLinePainter{ + p: p, + options: make([]markLineRenderOption, 0), + } +} + +type markLineRenderOption struct { + FillColor Color + FontColor Color + StrokeColor Color + Font *truetype.Font + Series Series + Range axisRange +} + +func (m *markLinePainter) Render() (Box, error) { + painter := m.p + for _, opt := range m.options { + s := opt.Series + if len(s.MarkLine.Data) == 0 { + continue + } + summary := s.Summary() + for _, markLine := range s.MarkLine.Data { + // 由于mark line会修改style,因此每次重新设置 + painter.OverrideDrawingStyle(Style{ + FillColor: opt.FillColor, + StrokeColor: opt.StrokeColor, + StrokeWidth: 1, + StrokeDashArray: []float64{ + 4, + 2, + }, + }).OverrideTextStyle(Style{ + Font: opt.Font, + FontColor: opt.FontColor, + FontSize: labelFontSize, + }) + value := float64(0) + switch markLine.Type { + case SeriesMarkDataTypeMax: + value = summary.MaxValue + case SeriesMarkDataTypeMin: + value = summary.MinValue + default: + value = summary.AverageValue + } + y := opt.Range.getRestHeight(value) + width := painter.Width() + text := commafWithDigits(value) + textBox := painter.MeasureText(text) + painter.MarkLine(0, y, width-2) + painter.Text(text, width, y+textBox.Height()>>1-2) + } + } + return chart.BoxZero, nil +} + +func markLineRender(opt markLineRenderOption) { + // d := opt.Draw + // s := opt.Series + // if len(s.MarkLine.Data) == 0 { + // return + // } + // r := d.Render + +} diff --git a/mark_point.go b/mark_point.go new file mode 100644 index 0000000..ce3cb0f --- /dev/null +++ b/mark_point.go @@ -0,0 +1,102 @@ +// 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 ( + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" +) + +func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint { + data := make([]SeriesMarkData, len(markPointTypes)) + for index, t := range markPointTypes { + data[index] = SeriesMarkData{ + Type: t, + } + } + return SeriesMarkPoint{ + Data: data, + } +} + +type markPointPainter struct { + p *Painter + options []markPointRenderOption +} + +func (m *markPointPainter) Add(opt markPointRenderOption) { + m.options = append(m.options, opt) +} + +type markPointRenderOption struct { + FillColor Color + Font *truetype.Font + Series Series + Points []Point +} + +func NewMarkPointPainter(p *Painter) *markPointPainter { + return &markPointPainter{ + p: p, + options: make([]markPointRenderOption, 0), + } +} + +func (m *markPointPainter) Render() (Box, error) { + painter := m.p + for _, opt := range m.options { + s := opt.Series + if len(s.MarkPoint.Data) == 0 { + continue + } + points := opt.Points + summary := s.Summary() + symbolSize := s.MarkPoint.SymbolSize + if symbolSize == 0 { + symbolSize = 30 + } + painter.OverrideDrawingStyle(Style{ + FillColor: opt.FillColor, + }).OverrideTextStyle(Style{ + FontColor: NewTheme(ThemeDark).GetTextColor(), + FontSize: labelFontSize, + StrokeWidth: 1, + Font: opt.Font, + }) + for _, markPointData := range s.MarkPoint.Data { + p := points[summary.MinIndex] + value := summary.MinValue + switch markPointData.Type { + case SeriesMarkDataTypeMax: + p = points[summary.MaxIndex] + value = summary.MaxValue + } + + painter.Pin(p.X, p.Y-symbolSize>>1, symbolSize) + text := commafWithDigits(value) + textBox := painter.MeasureText(text) + painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2) + } + } + return chart.BoxZero, nil +} diff --git a/painter.go b/painter.go index 75d4a38..fff6ca7 100644 --- a/painter.go +++ b/painter.go @@ -700,7 +700,7 @@ func (p *Painter) Grid(opt GridOption) *Painter { func (p *Painter) Dots(points []Point) *Painter { for _, item := range points { - p.Circle(3, item.X, item.Y) + p.Circle(2, item.X, item.Y) } p.FillStroke() return p diff --git a/title.go b/title.go new file mode 100644 index 0000000..30831ac --- /dev/null +++ b/title.go @@ -0,0 +1,192 @@ +// 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 ( + "strconv" + "strings" + + "github.com/golang/freetype/truetype" +) + +type TitleOption struct { + // The theme of chart + Theme ColorPalette + // Title text, support \n for new line + Text string + // Subtitle text, support \n for new line + Subtext string + // // Title style + // Style Style + // // Subtitle style + // SubtextStyle Style + // Distance between title component and the left side of the container. + // It can be pixel value: 20, percentage value: 20%, + // or position value: right, center. + Left string + // Distance between title component and the top side of the container. + // It can be pixel value: 20. + Top string + // The font of label + Font *truetype.Font + // The font size of label + FontSize float64 + // The color of label + FontColor Color + // The subtext font size of label + SubtextFontSize float64 + // The subtext font color of label + SubtextFontColor Color +} + +type titleMeasureOption struct { + width int + height int + text string + style Style +} + +func splitTitleText(text string) []string { + arr := strings.Split(text, "\n") + result := make([]string, 0) + for _, v := range arr { + v = strings.TrimSpace(v) + if v == "" { + continue + } + result = append(result, v) + } + return result +} + +type titlePainter struct { + p *Painter + opt *TitleOption +} + +func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter { + return &titlePainter{ + p: p, + opt: &opt, + } +} + +func (t *titlePainter) Render() (Box, error) { + opt := t.opt + p := t.p + theme := opt.Theme + measureOptions := make([]titleMeasureOption, 0) + + if opt.Font == nil { + opt.Font = theme.GetFont() + } + if opt.FontColor.IsZero() { + opt.FontColor = theme.GetTextColor() + } + if opt.FontSize == 0 { + opt.FontSize = theme.GetFontSize() + } + if opt.SubtextFontColor.IsZero() { + opt.SubtextFontColor = opt.FontColor + } + if opt.SubtextFontSize == 0 { + opt.SubtextFontSize = opt.FontSize + } + + titleTextStyle := Style{ + Font: opt.Font, + FontSize: opt.FontSize, + FontColor: opt.FontColor, + } + // 主标题 + for _, v := range splitTitleText(opt.Text) { + measureOptions = append(measureOptions, titleMeasureOption{ + text: v, + style: titleTextStyle, + }) + } + subtextStyle := Style{ + Font: opt.Font, + FontSize: opt.SubtextFontSize, + FontColor: opt.SubtextFontColor, + } + // 副标题 + for _, v := range splitTitleText(opt.Subtext) { + measureOptions = append(measureOptions, titleMeasureOption{ + text: v, + style: subtextStyle, + }) + } + textMaxWidth := 0 + textMaxHeight := 0 + for index, item := range measureOptions { + p.OverrideTextStyle(item.style) + textBox := p.MeasureText(item.text) + + w := textBox.Width() + h := textBox.Height() + if w > textMaxWidth { + textMaxWidth = w + } + if h > textMaxHeight { + textMaxHeight = h + } + measureOptions[index].height = h + measureOptions[index].width = w + } + width := textMaxWidth + + titleX := 0 + switch opt.Left { + case PositionRight: + titleX = p.Width() - textMaxWidth + case PositionCenter: + titleX = p.Width()>>1 - (textMaxWidth >> 1) + default: + if strings.HasSuffix(opt.Left, "%") { + value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) + titleX = p.Width() * value / 100 + } else { + value, _ := strconv.Atoi(opt.Left) + titleX = value + } + } + titleY := 0 + // TODO TOP 暂只支持数值 + if opt.Top != "" { + value, _ := strconv.Atoi(opt.Top) + titleY += value + } + for _, item := range measureOptions { + p.OverrideTextStyle(item.style) + x := titleX + (textMaxWidth-item.width)>>1 + y := titleY + item.height + p.Text(item.text, x, y) + titleY += item.height + } + + return Box{ + Bottom: titleY, + Right: titleX + width, + }, nil +}