From 65a1cb11adfda2466a77ecb333fd3f216467993d Mon Sep 17 00:00:00 2001 From: vicanso Date: Thu, 16 Jun 2022 23:08:20 +0800 Subject: [PATCH] feat: support pie, radar and funnel chart --- axis.go | 34 ++- bar_chart.go | 16 +- chart_option.go | 2 +- charts.go | 150 ++++++++-- echarts.go | 505 ++++++++++++++++++++++++++++++++++ examples/charts/main.go | 372 +++++++++++++++++++++++-- examples/funnel_chart/main.go | 97 +++++++ examples/pie_chart/main.go | 83 ++++++ examples/radar_chart/main.go | 112 ++++++++ funnel_chart.go | 172 ++++++++++++ horizontal_bar_chart.go | 14 +- legend.go | 21 +- line_chart.go | 2 +- painter.go | 3 + pie_chart.go | 211 ++++++++++++++ radar_chart.go | 245 +++++++++++++++++ xaxis.go | 5 +- yaxis.go | 28 +- 18 files changed, 1987 insertions(+), 85 deletions(-) create mode 100644 echarts.go create mode 100644 examples/funnel_chart/main.go create mode 100644 examples/pie_chart/main.go create mode 100644 examples/radar_chart/main.go create mode 100644 funnel_chart.go create mode 100644 pie_chart.go create mode 100644 radar_chart.go diff --git a/axis.go b/axis.go index d069c39..7b828d2 100644 --- a/axis.go +++ b/axis.go @@ -23,7 +23,10 @@ package charts import ( + "strings" + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" ) type axisPainter struct { @@ -41,11 +44,15 @@ func NewAxisPainter(p *Painter, opt AxisOption) *axisPainter { type AxisOption struct { // The theme of chart Theme ColorPalette + // Formatter for y axis text value + Formatter string // The label of axis Data []string // The boundary gap on both sides of a coordinate axis. // Nil or *true means the center part of two axis ticks BoundaryGap *bool + // The flag for show axis, set this to *false will hide axis + Show *bool // The position of axis, it can be 'left', 'top', 'right' or 'bottom' Position string // Number of segments that the axis is split into. Note that this number serves only as a recommendation. @@ -74,6 +81,9 @@ func (a *axisPainter) Render() (Box, error) { opt := a.opt top := a.p theme := opt.Theme + if opt.Show != nil && !*opt.Show { + return BoxZero, nil + } strokeWidth := opt.StrokeWidth if strokeWidth == 0 { @@ -97,10 +107,15 @@ func (a *axisPainter) Render() (Box, error) { strokeColor = theme.GetAxisStrokeColor() } - tickCount := opt.SplitNumber - if tickCount == 0 { - tickCount = len(opt.Data) + data := opt.Data + formatter := opt.Formatter + if len(formatter) != 0 { + for index, text := range data { + data[index] = strings.ReplaceAll(formatter, "{value}", text) + } } + dataCount := len(data) + tickCount := dataCount boundaryGap := true if opt.BoundaryGap != nil && !*opt.BoundaryGap { @@ -118,8 +133,6 @@ func (a *axisPainter) Render() (Box, error) { labelPosition = PositionCenter } - // TODO 计算unit - unit := 1 // 如果小于0,则表示不处理 tickLength := getDefaultInt(opt.TickLength, 5) labelMargin := getDefaultInt(opt.LabelMargin, 5) @@ -133,7 +146,9 @@ func (a *axisPainter) Render() (Box, error) { } top.SetDrawingStyle(style).OverrideTextStyle(style) - textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(opt.Data) + textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(data) + textCount := ceilFloatToInt(float64(top.Width()) / float64(textMaxWidth)) + unit := ceilFloatToInt(float64(dataCount) / float64(chart.MaxInt(textCount, opt.SplitNumber))) width := 0 height := 0 @@ -226,7 +241,7 @@ func (a *axisPainter) Render() (Box, error) { Right: labelPaddingRight, })).MultiText(MultiTextOption{ Align: textAlign, - TextList: opt.Data, + TextList: data, Orient: orient, Unit: unit, Position: labelPosition, @@ -242,10 +257,7 @@ func (a *axisPainter) Render() (Box, error) { x0 = 0 x1 = top.Width() - p.Width() } - for index, y := range autoDivide(height, tickCount) { - if index == 0 { - continue - } + for _, y := range autoDivide(height, tickCount) { top.LineStroke([]Point{ { X: x0, diff --git a/bar_chart.go b/bar_chart.go index 8fae4df..8330542 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -95,14 +95,10 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B markPointPainter, markLinePainter, } - for i := range seriesList { - series := seriesList[i] + for index := range seriesList { + series := seriesList[index] yRange := result.axisRanges[series.AxisIndex] - index := series.index - if index == 0 { - index = i - } - seriesColor := theme.GetSeriesColor(index) + seriesColor := theme.GetSeriesColor(series.index) divideValues := xRange.AutoDivide() points := make([]Point, len(series.Data)) @@ -112,8 +108,8 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B } x := divideValues[j] x += margin - if i != 0 { - x += i * (barWidth + barMargin) + if index != 0 { + x += index * (barWidth + barMargin) } h := int(yRange.getHeight(item.Value)) @@ -151,7 +147,7 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B if distance == 0 { distance = 5 } - text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) + text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(index, item.Value, -1) labelStyle := Style{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, diff --git a/chart_option.go b/chart_option.go index 6ca7cd7..0cea754 100644 --- a/chart_option.go +++ b/chart_option.go @@ -59,7 +59,7 @@ type ChartOption struct { // The series list SeriesList SeriesList // The radar indicator list - // RadarIndicators []RadarIndicator + RadarIndicators []RadarIndicator // The background color of chart BackgroundColor Color // The child charts diff --git a/charts.go b/charts.go index ae14c8d..51e247a 100644 --- a/charts.go +++ b/charts.go @@ -22,7 +22,10 @@ package charts -import "errors" +import ( + "errors" + "sort" +) const labelFontSize = 10 const defaultDotWidth = 2.0 @@ -140,16 +143,29 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e if containsInt(axisIndexList, series.AxisIndex) { continue } - axisIndexList = append(axisIndexList, series.index) + axisIndexList = append(axisIndexList, series.AxisIndex) } // 高度需要减去x轴的高度 rangeHeight := p.Height() - defaultXAxisHeight rangeWidthLeft := 0 rangeWidthRight := 0 + // 倒序 + sort.Sort(sort.Reverse(sort.IntSlice(axisIndexList))) + // 计算对应的axis range for _, index := range axisIndexList { + yAxisOption := YAxisOption{} + if len(opt.YAxisOptions) > index { + yAxisOption = opt.YAxisOptions[index] + } max, min := opt.SeriesList.GetMaxMin(index) + if yAxisOption.Min != nil { + min = *yAxisOption.Min + } + if yAxisOption.Max != nil { + max = *yAxisOption.Max + } r := NewRange(AxisRangeOption{ Min: min, Max: max, @@ -159,10 +175,7 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e DivideCount: defaultAxisDivideCount, }) result.axisRanges[index] = r - yAxisOption := YAxisOption{} - if len(opt.YAxisOptions) > index { - yAxisOption = opt.YAxisOptions[index] - } + if yAxisOption.Theme == nil { yAxisOption.Theme = opt.Theme } @@ -175,7 +188,16 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e } reverseStringSlice(yAxisOption.Data) // TODO生成其它位置既yAxis - yAxis := NewLeftYAxis(p, yAxisOption) + var yAxis *axisPainter + child := p.Child(PainterPaddingOption(Box{ + Left: rangeWidthLeft, + Right: rangeWidthRight, + })) + if index == 0 { + yAxis = NewLeftYAxis(child, yAxisOption) + } else { + yAxis = NewRightYAxis(child, yAxisOption) + } yAxisBox, err := yAxis.Render() if err != nil { return nil, err @@ -191,7 +213,8 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e opt.XAxis.Theme = opt.Theme } xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{ - Left: rangeWidthLeft, + Left: rangeWidthLeft, + Right: rangeWidthRight, })), opt.XAxis) _, err := xAxis.Render() if err != nil { @@ -219,7 +242,9 @@ func doRender(renderers ...Renderer) error { func Render(opt ChartOption) (*Painter, error) { opt.fillDefault() + isChild := true if opt.Parent == nil { + isChild = false p, err := NewPainter(PainterOptions{ Type: opt.Type, Width: opt.Width, @@ -231,21 +256,40 @@ func Render(opt ChartOption) (*Painter, error) { opt.Parent = p } p := opt.Parent - p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor) + if !opt.Box.IsZero() { + p = p.Child(PainterBoxOption(opt.Box)) + } + if !isChild { + p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor) + } seriesList := opt.SeriesList seriesList.init() + seriesCount := len(seriesList) + // line chart lineSeriesList := seriesList.Filter(ChartTypeLine) barSeriesList := seriesList.Filter(ChartTypeBar) horizontalBarSeriesList := seriesList.Filter(ChartTypeHorizontalBar) - if len(horizontalBarSeriesList) != 0 && len(horizontalBarSeriesList) != len(seriesList) { + pieSeriesList := seriesList.Filter(ChartTypePie) + radarSeriesList := seriesList.Filter(ChartTypeRadar) + funnelSeriesList := seriesList.Filter(ChartTypeFunnel) + + if len(horizontalBarSeriesList) != 0 && len(horizontalBarSeriesList) != seriesCount { return nil, errors.New("Horizontal bar can not mix other charts") } + if len(pieSeriesList) != 0 && len(pieSeriesList) != seriesCount { + return nil, errors.New("Pie can not mix other charts") + } + if len(radarSeriesList) != 0 && len(radarSeriesList) != seriesCount { + return nil, errors.New("Radar can not mix other charts") + } + if len(funnelSeriesList) != 0 && len(funnelSeriesList) != seriesCount { + return nil, errors.New("Funnel can not mix other charts") + } axisReversed := len(horizontalBarSeriesList) != 0 - - renderResult, err := defaultRender(p, defaultRenderOption{ + renderOpt := defaultRenderOption{ Theme: opt.theme, Padding: opt.Padding, SeriesList: opt.SeriesList, @@ -254,24 +298,28 @@ func Render(opt ChartOption) (*Painter, error) { TitleOption: opt.Title, LegendOption: opt.Legend, axisReversed: axisReversed, - }) + } + if isChild { + renderOpt.backgroundIsFilled = true + } + if len(pieSeriesList) != 0 || + len(radarSeriesList) != 0 || + len(funnelSeriesList) != 0 { + renderOpt.XAxis.Show = FalseFlag() + renderOpt.YAxisOptions = []YAxisOption{ + { + Show: FalseFlag(), + }, + } + } + + renderResult, err := defaultRender(p, renderOpt) if err != nil { return nil, err } handler := renderHandler{} - if len(lineSeriesList) != 0 { - handler.Add(func() error { - _, err := NewLineChart(p, LineChartOption{ - Theme: opt.theme, - Font: opt.font, - XAxis: opt.XAxis, - }).render(renderResult, lineSeriesList) - return err - }) - } - // bar chart if len(barSeriesList) != 0 { handler.Add(func() error { @@ -296,11 +344,65 @@ func Render(opt ChartOption) (*Painter, error) { }) } + // pie chart + if len(pieSeriesList) != 0 { + handler.Add(func() error { + _, err := NewPieChart(p, PieChartOption{ + Theme: opt.theme, + Font: opt.font, + }).render(renderResult, pieSeriesList) + return err + }) + } + + // line chart + if len(lineSeriesList) != 0 { + handler.Add(func() error { + _, err := NewLineChart(p, LineChartOption{ + Theme: opt.theme, + Font: opt.font, + XAxis: opt.XAxis, + }).render(renderResult, lineSeriesList) + return err + }) + } + + // radar chart + if len(radarSeriesList) != 0 { + handler.Add(func() error { + _, err := NewRadarChart(p, RadarChartOption{ + Theme: opt.theme, + Font: opt.font, + // 相应值 + RadarIndicators: opt.RadarIndicators, + }).render(renderResult, radarSeriesList) + return err + }) + } + + // funnel chart + if len(funnelSeriesList) != 0 { + handler.Add(func() error { + _, err := NewFunnelChart(p, FunnelChartOption{ + Theme: opt.theme, + Font: opt.font, + }).render(renderResult, funnelSeriesList) + return err + }) + } + err = handler.Do() if err != nil { return nil, err } + for _, item := range opt.Children { + item.Parent = p + _, err = Render(item) + if err != nil { + return nil, err + } + } return p, nil } diff --git a/echarts.go b/echarts.go new file mode 100644 index 0000000..ac28436 --- /dev/null +++ b/echarts.go @@ -0,0 +1,505 @@ +// 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 EChartsSeriesDataValue struct { + values []float64 +} + +func (value *EChartsSeriesDataValue) UnmarshalJSON(data []byte) error { + data = convertToArray(data) + return json.Unmarshal(data, &value.values) +} +func (value *EChartsSeriesDataValue) First() float64 { + if len(value.values) == 0 { + return 0 + } + return value.values[0] +} +func NewEChartsSeriesDataValue(values ...float64) EChartsSeriesDataValue { + return EChartsSeriesDataValue{ + values: values, + } +} + +type EChartsSeriesData struct { + Value EChartsSeriesDataValue `json:"value"` + Name string `json:"name"` + ItemStyle EChartStyle `json:"itemStyle"` +} +type _EChartsSeriesData EChartsSeriesData + +var numericRep = regexp.MustCompile(`^[-+]?[0-9]+(?:\.[0-9]+)?$`) + +func (es *EChartsSeriesData) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return nil + } + if numericRep.Match(data) { + v, err := strconv.ParseFloat(string(data), 64) + if err != nil { + return err + } + es.Value = EChartsSeriesDataValue{ + values: []float64{ + 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 `json:"color"` + } `json:"lineStyle"` + } `json:"axisLine"` +} +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 EChartsMarkData struct { + Type string `json:"type"` +} +type _EChartsMarkData EChartsMarkData + +func (emd *EChartsMarkData) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return nil + } + data = convertToArray(data) + ds := make([]*_EChartsMarkData, 0) + err := json.Unmarshal(data, &ds) + if err != nil { + return err + } + for _, d := range ds { + if d.Type != "" { + emd.Type = d.Type + } + } + return nil +} + +type EChartsMarkPoint struct { + SymbolSize int `json:"symbolSize"` + Data []EChartsMarkData `json:"data"` +} + +func (emp *EChartsMarkPoint) ToSeriesMarkPoint() SeriesMarkPoint { + sp := SeriesMarkPoint{ + SymbolSize: emp.SymbolSize, + } + if len(emp.Data) == 0 { + return sp + } + data := make([]SeriesMarkData, len(emp.Data)) + for index, item := range emp.Data { + data[index].Type = item.Type + } + sp.Data = data + return sp +} + +type EChartsMarkLine struct { + Data []EChartsMarkData `json:"data"` +} + +func (eml *EChartsMarkLine) ToSeriesMarkLine() SeriesMarkLine { + sl := SeriesMarkLine{} + if len(eml.Data) == 0 { + return sl + } + data := make([]SeriesMarkData, len(eml.Data)) + for index, item := range eml.Data { + data[index].Type = item.Type + } + sl.Data = data + return sl +} + +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"` + Max *float64 `json:"max"` + Min *float64 `json:"min"` +} +type EChartsSeriesList []EChartsSeries + +func (esList EChartsSeriesList) ToSeriesList() SeriesList { + seriesList := make(SeriesList, 0, len(esList)) + for _, item := range esList { + // 如果是pie,则每个子荐生成一个series + if item.Type == ChartTypePie { + for _, dataItem := range item.Data { + seriesList = append(seriesList, Series{ + Type: item.Type, + Name: dataItem.Name, + Label: SeriesLabel{ + Show: true, + }, + Radius: item.Radius, + Data: []SeriesData{ + { + Value: dataItem.Value.First(), + }, + }, + }) + } + continue + } + // 如果是radar或funnel + if item.Type == ChartTypeRadar || + item.Type == ChartTypeFunnel { + for _, dataItem := range item.Data { + seriesList = append(seriesList, Series{ + Name: dataItem.Name, + Type: item.Type, + Data: NewSeriesDataFromValues(dataItem.Value.values), + Max: item.Max, + Min: item.Min, + }) + } + continue + } + data := make([]SeriesData, len(item.Data)) + for j, dataItem := range item.Data { + data[j] = SeriesData{ + Value: dataItem.Value.First(), + Style: dataItem.ItemStyle.ToStyle(), + } + } + seriesList = append(seriesList, Series{ + Type: item.Type, + Data: data, + AxisIndex: item.YAxisIndex, + Style: item.ItemStyle.ToStyle(), + Label: SeriesLabel{ + Color: parseColor(item.Label.Color), + Show: item.Label.Show, + Distance: item.Label.Distance, + }, + Name: item.Name, + 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 { + s := chart.Style{ + FontSize: et.FontSize, + FontColor: parseColor(et.Color), + } + if et.FontFamily != "" { + s.Font, _ = GetFont(et.FontFamily) + } + return s +} + +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"` + Radar struct { + Indicator []RadarIndicator `json:"indicator"` + } `json:"radar"` + 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 + } + titleTextStyle := eo.Title.TextStyle.ToStyle() + titleSubtextStyle := eo.Title.SubtextStyle.ToStyle() + legendTextStyle := eo.Legend.TextStyle.ToStyle() + o := ChartOption{ + Type: eo.Type, + FontFamily: fontFamily, + Theme: eo.Theme, + Title: TitleOption{ + Text: eo.Title.Text, + Subtext: eo.Title.Subtext, + FontColor: titleTextStyle.FontColor, + FontSize: titleTextStyle.FontSize, + SubtextFontSize: titleSubtextStyle.FontSize, + SubtextFontColor: titleSubtextStyle.FontColor, + Left: string(eo.Title.Left), + Top: string(eo.Title.Top), + }, + Legend: LegendOption{ + Show: eo.Legend.Show, + FontSize: legendTextStyle.FontSize, + FontColor: legendTextStyle.FontColor, + Data: eo.Legend.Data, + Left: string(eo.Legend.Left), + Top: string(eo.Legend.Top), + Align: eo.Legend.Align, + Orient: eo.Legend.Orient, + }, + RadarIndicators: eo.Radar.Indicator, + 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), + } + } + o.YAxisOptions = yAxisOptions + + 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/examples/charts/main.go b/examples/charts/main.go index c7986a4..3a625f7 100644 --- a/examples/charts/main.go +++ b/examples/charts/main.go @@ -83,13 +83,13 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha } bytesList = append(bytesList, buf) } - // for _, opt := range echartsOptions { - // buf, err := charts.RenderEChartsToSVG(opt) - // 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") @@ -333,6 +333,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, }, }, + // 柱状图+标记 { Title: charts.TitleOption{ Text: "Rainfall vs Evaporation", @@ -413,6 +414,342 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, }, }, + // 双Y轴示例 + { + Title: charts.TitleOption{ + Text: "Temperature", + }, + XAxis: charts.NewXAxisOption([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + Legend: charts.NewLegendOption([]string{ + "Evaporation", + "Precipitation", + "Temperature", + }), + YAxisOptions: []charts.YAxisOption{ + { + Formatter: "{value}ml", + Color: charts.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }, + { + Formatter: "{value}°C", + Color: charts.Color{ + R: 250, + G: 200, + B: 88, + A: 255, + }, + }, + }, + SeriesList: []charts.Series{ + { + Type: charts.ChartTypeBar, + Data: charts.NewSeriesDataFromValues([]float64{ + 2.0, + 4.9, + 7.0, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20.0, + 6.4, + 3.3, + }), + }, + { + Type: charts.ChartTypeBar, + Data: charts.NewSeriesDataFromValues([]float64{ + 2.6, + 5.9, + 9.0, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6.0, + 2.3, + }), + }, + { + Data: charts.NewSeriesDataFromValues([]float64{ + 2.0, + 2.2, + 3.3, + 4.5, + 6.3, + 10.2, + 20.3, + 23.4, + 23.0, + 16.5, + 12.0, + 6.2, + }), + AxisIndex: 1, + }, + }, + }, + // 饼图 + { + Title: charts.TitleOption{ + Text: "Referer of a Website", + Subtext: "Fake Data", + Left: charts.PositionCenter, + }, + Legend: charts.LegendOption{ + Orient: charts.OrientVertical, + Data: []string{ + "Search Engine", + "Direct", + "Email", + "Union Ads", + "Video Ads", + }, + Left: charts.PositionLeft, + }, + SeriesList: charts.NewPieSeriesList([]float64{ + 1048, + 735, + 580, + 484, + 300, + }, charts.PieSeriesOption{ + Label: charts.SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }, + // 雷达图 + { + Title: charts.TitleOption{ + Text: "Basic Radar Chart", + }, + Legend: charts.NewLegendOption([]string{ + "Allocated Budget", + "Actual Spending", + }), + RadarIndicators: []charts.RadarIndicator{ + { + 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, + }, + }, + SeriesList: charts.SeriesList{ + { + Type: charts.ChartTypeRadar, + Data: charts.NewSeriesDataFromValues([]float64{ + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }), + }, + { + Type: charts.ChartTypeRadar, + Data: charts.NewSeriesDataFromValues([]float64{ + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, + }), + }, + }, + }, + // 漏斗图 + { + Title: charts.TitleOption{ + Text: "Funnel", + }, + Legend: charts.NewLegendOption([]string{ + "Show", + "Click", + "Visit", + "Inquiry", + "Order", + }), + SeriesList: []charts.Series{ + { + Type: charts.ChartTypeFunnel, + Name: "Show", + Data: charts.NewSeriesDataFromValues([]float64{ + 100, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Click", + Data: charts.NewSeriesDataFromValues([]float64{ + 80, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Visit", + Data: charts.NewSeriesDataFromValues([]float64{ + 60, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Inquiry", + Data: charts.NewSeriesDataFromValues([]float64{ + 40, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Order", + Data: charts.NewSeriesDataFromValues([]float64{ + 20, + }), + }, + }, + }, + // 多图展示 + { + Legend: charts.LegendOption{ + Top: "-90", + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Padding: charts.Box{ + Top: 100, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: charts.NewXAxisOption([]string{ + "2012", + "2013", + "2014", + "2015", + "2016", + "2017", + }), + YAxisOptions: []charts.YAxisOption{ + { + + Min: charts.NewFloatPoint(0), + Max: charts.NewFloatPoint(90), + }, + }, + SeriesList: []charts.Series{ + charts.NewSeriesFromValues([]float64{ + 56.5, + 82.1, + 88.7, + 70.1, + 53.4, + 85.1, + }), + charts.NewSeriesFromValues([]float64{ + 51.1, + 51.4, + 55.1, + 53.3, + 73.8, + 68.7, + }), + charts.NewSeriesFromValues([]float64{ + 40.1, + 62.2, + 69.5, + 36.4, + 45.2, + 32.5, + }, charts.ChartTypeBar), + charts.NewSeriesFromValues([]float64{ + 25.2, + 37.1, + 41.2, + 18, + 33.9, + 49.1, + }, charts.ChartTypeBar), + }, + Children: []charts.ChartOption{ + { + Legend: charts.LegendOption{ + Show: charts.FalseFlag(), + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Box: charts.Box{ + Top: 20, + Left: 400, + Right: 500, + Bottom: 120, + }, + SeriesList: charts.NewPieSeriesList([]float64{ + 435.9, + 354.3, + 285.9, + 204.5, + }, charts.PieSeriesOption{ + Label: charts.SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }, + }, + }, } handler(w, req, chartOptions, nil) } @@ -879,12 +1216,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { 23.2, 25.6, 76.7, - 135.6, - 162.2, - 32.6, - 20, - 6.4, - 3.3 + 135.6 ] }, { @@ -898,12 +1230,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { 26.4, 28.7, 70.7, - 175.6, - 182.2, - 48.7, - 18.8, - 6, - 2.3 + 175.6 ] }, { @@ -918,12 +1245,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { 4.5, 6.3, 10.2, - 20.3, - 23.4, - 23, - 16.5, - 12, - 6.2 + 20.3 ] } ] diff --git a/examples/funnel_chart/main.go b/examples/funnel_chart/main.go new file mode 100644 index 0000000..eb753fd --- /dev/null +++ b/examples/funnel_chart/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + + "github.com/vicanso/go-charts" +) + +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "funnel-chart.png") + err = ioutil.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + p, err := charts.NewPainter(charts.PainterOptions{ + Width: 800, + Height: 600, + Type: charts.ChartOutputPNG, + }) + if err != nil { + panic(err) + } + _, err = charts.NewFunnelChart(p, charts.FunnelChartOption{ + Title: charts.TitleOption{ + Text: "Funnel", + }, + Legend: charts.NewLegendOption([]string{ + "Show", + "Click", + "Visit", + "Inquiry", + "Order", + }), + SeriesList: []charts.Series{ + + { + Type: charts.ChartTypeFunnel, + Name: "Show", + Data: charts.NewSeriesDataFromValues([]float64{ + 100, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Click", + Data: charts.NewSeriesDataFromValues([]float64{ + 80, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Visit", + Data: charts.NewSeriesDataFromValues([]float64{ + 60, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Inquiry", + Data: charts.NewSeriesDataFromValues([]float64{ + 40, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Order", + Data: charts.NewSeriesDataFromValues([]float64{ + 20, + }), + }, + }, + }).Render() + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + err = writeFile(buf) + if err != nil { + panic(err) + } +} diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go new file mode 100644 index 0000000..e69bf60 --- /dev/null +++ b/examples/pie_chart/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + + "github.com/vicanso/go-charts" +) + +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "pie-chart.png") + err = ioutil.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + p, err := charts.NewPainter(charts.PainterOptions{ + Width: 800, + Height: 600, + Type: charts.ChartOutputPNG, + }) + if err != nil { + panic(err) + } + _, err = charts.NewPieChart(p, charts.PieChartOption{ + Title: charts.TitleOption{ + Text: "Rainfall vs Evaporation", + Subtext: "Fake Data", + Left: charts.PositionCenter, + }, + Padding: charts.Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: charts.LegendOption{ + Orient: charts.OrientVertical, + Data: []string{ + "Search Engine", + "Direct", + "Email", + "Union Ads", + "Video Ads", + }, + Left: charts.PositionLeft, + }, + SeriesList: charts.NewPieSeriesList([]float64{ + 1048, + 735, + 580, + 484, + 300, + }, charts.PieSeriesOption{ + Label: charts.SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }).Render() + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + err = writeFile(buf) + if err != nil { + panic(err) + } +} diff --git a/examples/radar_chart/main.go b/examples/radar_chart/main.go new file mode 100644 index 0000000..077fa48 --- /dev/null +++ b/examples/radar_chart/main.go @@ -0,0 +1,112 @@ +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + + "github.com/vicanso/go-charts" +) + +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "radar-chart.png") + err = ioutil.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + p, err := charts.NewPainter(charts.PainterOptions{ + Width: 800, + Height: 600, + Type: charts.ChartOutputPNG, + }) + if err != nil { + panic(err) + } + _, err = charts.NewRadarChart(p, charts.RadarChartOption{ + Padding: charts.Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, + Title: charts.TitleOption{ + Text: "Basic Radar Chart", + }, + Legend: charts.NewLegendOption([]string{ + "Allocated Budget", + "Actual Spending", + }), + RadarIndicators: []charts.RadarIndicator{ + { + 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, + }, + }, + SeriesList: charts.SeriesList{ + { + Type: charts.ChartTypeRadar, + Data: charts.NewSeriesDataFromValues([]float64{ + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }), + }, + { + Type: charts.ChartTypeRadar, + Data: charts.NewSeriesDataFromValues([]float64{ + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, + }), + }, + }, + }).Render() + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + err = writeFile(buf) + if err != nil { + panic(err) + } +} diff --git a/funnel_chart.go b/funnel_chart.go new file mode 100644 index 0000000..c8457dd --- /dev/null +++ b/funnel_chart.go @@ -0,0 +1,172 @@ +// 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 ( + "fmt" + + "github.com/dustin/go-humanize" + "github.com/golang/freetype/truetype" +) + +type funnelChart struct { + p *Painter + opt *FunnelChartOption +} + +func NewFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart { + if opt.Theme == nil { + opt.Theme = NewTheme("") + } + return &funnelChart{ + p: p, + opt: &opt, + } +} + +type FunnelChartOption struct { + Theme ColorPalette + // The font size + Font *truetype.Font + // The data series list + SeriesList SeriesList + // The padding of line chart + Padding Box + // The option of title + Title TitleOption + // The legend option + Legend LegendOption +} + +func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + opt := f.opt + seriesPainter := result.seriesPainter + max := seriesList[0].Data[0].Value + min := float64(0) + for _, item := range seriesList { + if item.Max != nil { + max = *item.Max + } + if item.Min != nil { + min = *item.Min + } + } + theme := opt.Theme + gap := 2 + height := seriesPainter.Height() + width := seriesPainter.Width() + count := len(seriesList) + + h := (height - gap*(count-1)) / count + + y := 0 + widthList := make([]int, len(seriesList)) + textList := make([]string, len(seriesList)) + for index, item := range seriesList { + value := item.Data[0].Value + widthPercent := (value - min) / (max - min) + w := int(widthPercent * float64(width)) + widthList[index] = w + p := humanize.CommafWithDigits(value/max*100, 2) + "%" + textList[index] = fmt.Sprintf("%s(%s)", item.Name, p) + } + + for index, w := range widthList { + series := seriesList[index] + nextWidth := 0 + if index+1 < len(widthList) { + nextWidth = widthList[index+1] + } + topStartX := (width - w) >> 1 + topEndX := topStartX + w + bottomStartX := (width - nextWidth) >> 1 + bottomEndX := bottomStartX + nextWidth + points := []Point{ + { + X: topStartX, + Y: y, + }, + { + X: topEndX, + Y: y, + }, + { + X: bottomEndX, + Y: y + h, + }, + { + X: bottomStartX, + Y: y + h, + }, + { + X: topStartX, + Y: y, + }, + } + color := theme.GetSeriesColor(series.index) + + seriesPainter.OverrideDrawingStyle(Style{ + FillColor: color, + }).FillArea(points) + + // 文本 + text := textList[index] + seriesPainter.OverrideTextStyle(Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + }) + textBox := seriesPainter.MeasureText(text) + textX := width>>1 - textBox.Width()>>1 + textY := y + h>>1 + seriesPainter.Text(text, textX, textY) + y += (h + gap) + } + + return f.p.box, nil +} + +func (f *funnelChart) Render() (Box, error) { + p := f.p + opt := f.opt + renderResult, err := defaultRender(p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: XAxisOption{ + Show: FalseFlag(), + }, + YAxisOptions: []YAxisOption{ + { + Show: FalseFlag(), + }, + }, + TitleOption: opt.Title, + LegendOption: opt.Legend, + }) + if err != nil { + return BoxZero, err + } + seriesList := opt.SeriesList.Filter(ChartTypeFunnel) + return f.render(renderResult, seriesList) +} diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go index 87ca9ae..c98d688 100644 --- a/horizontal_bar_chart.go +++ b/horizontal_bar_chart.go @@ -92,13 +92,9 @@ func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList Seri Size: seriesPainter.Width(), }) - for i := range seriesList { - series := seriesList[i] - index := series.index - if index == 0 { - index = i - } - seriesColor := theme.GetSeriesColor(index) + for index := range seriesList { + series := seriesList[index] + seriesColor := theme.GetSeriesColor(series.index) divideValues := yRange.AutoDivide() for j, item := range series.Data { if j >= yRange.divideCount { @@ -108,8 +104,8 @@ func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList Seri j = yRange.divideCount - j - 1 y := divideValues[j] y += margin - if i != 0 { - y += i * (barHeight + barMargin) + if index != 0 { + y += index * (barHeight + barMargin) } w := int(xRange.getHeight(item.Value)) diff --git a/legend.go b/legend.go index 65793c9..cf8d417 100644 --- a/legend.go +++ b/legend.go @@ -56,6 +56,8 @@ type LegendOption struct { FontSize float64 // FontColor color of legend text FontColor Color + // The flag for show legend, set this to *false will hide legend + Show *bool } func NewLegendOption(labels []string, left ...string) LegendOption { @@ -68,6 +70,17 @@ func NewLegendOption(labels []string, left ...string) LegendOption { return opt } +func (opt *LegendOption) IsEmpty() bool { + isEmpty := true + for _, v := range opt.Data { + if v != "" { + isEmpty = false + break + } + } + return isEmpty +} + func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter { return &legendPainter{ p: p, @@ -78,6 +91,10 @@ func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter { func (l *legendPainter) Render() (Box, error) { opt := l.opt theme := opt.Theme + if opt.IsEmpty() || + (opt.Show != nil && !*opt.Show) { + return BoxZero, nil + } if theme == nil { theme = l.p.theme } @@ -90,7 +107,9 @@ func (l *legendPainter) Render() (Box, error) { if opt.Left == "" { opt.Left = PositionCenter } - p := l.p + p := l.p.Child(PainterPaddingOption(Box{ + Top: 5, + })) p.SetTextStyle(Style{ FontSize: opt.FontSize, FontColor: opt.FontColor, diff --git a/line_chart.go b/line_chart.go index 47a497f..c505a91 100644 --- a/line_chart.go +++ b/line_chart.go @@ -93,7 +93,7 @@ func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) ( } for index := range seriesList { series := seriesList[index] - seriesColor := opt.Theme.GetSeriesColor(index) + seriesColor := opt.Theme.GetSeriesColor(series.index) drawingStyle := Style{ StrokeColor: seriesColor, StrokeWidth: defaultStrokeWidth, diff --git a/painter.go b/painter.go index fff6ca7..5a8dd89 100644 --- a/painter.go +++ b/painter.go @@ -628,6 +628,9 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { values = autoDivide(width, count) } for index, text := range opt.TextList { + if index%opt.Unit != 0 { + continue + } box := p.MeasureText(text) start := values[index] if positionCenter { diff --git a/pie_chart.go b/pie_chart.go new file mode 100644 index 0000000..c5a2ff2 --- /dev/null +++ b/pie_chart.go @@ -0,0 +1,211 @@ +// 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 ( + "errors" + "math" + + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" +) + +type pieChart struct { + p *Painter + opt *PieChartOption +} + +type PieChartOption struct { + Theme ColorPalette + // The font size + Font *truetype.Font + // The data series list + SeriesList SeriesList + // The padding of line chart + Padding Box + // The option of title + Title TitleOption + // The legend option + Legend LegendOption + // background is filled + backgroundIsFilled bool +} + +func NewPieChart(p *Painter, opt PieChartOption) *pieChart { + if opt.Theme == nil { + opt.Theme = NewTheme("") + } + return &pieChart{ + p: p, + opt: &opt, + } +} + +func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + opt := p.opt + values := make([]float64, len(seriesList)) + total := float64(0) + radiusValue := "" + for index, series := range seriesList { + if len(series.Radius) != 0 { + radiusValue = series.Radius + } + value := float64(0) + for _, item := range series.Data { + value += item.Value + } + values[index] = value + total += value + } + if total <= 0 { + return BoxZero, errors.New("The sum value of pie chart should gt 0") + } + seriesPainter := result.seriesPainter + cx := seriesPainter.Width() >> 1 + cy := seriesPainter.Height() >> 1 + + diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height()) + radius := getRadius(float64(diameter), radiusValue) + + labelLineWidth := 15 + if radius < 50 { + labelLineWidth = 10 + } + labelRadius := radius + float64(labelLineWidth) + seriesNames := opt.Legend.Data + if len(seriesNames) == 0 { + seriesNames = seriesList.Names() + } + theme := opt.Theme + if len(values) == 1 { + seriesPainter.OverrideDrawingStyle(Style{ + StrokeWidth: 1, + StrokeColor: theme.GetSeriesColor(0), + FillColor: theme.GetSeriesColor(0), + }) + seriesPainter.MoveTo(cx, cy). + Circle(radius, cx, cy) + } else { + currentValue := float64(0) + prevEndX := 0 + prevEndY := 0 + for index, v := range values { + seriesPainter.OverrideDrawingStyle(Style{ + StrokeWidth: 1, + StrokeColor: theme.GetSeriesColor(index), + FillColor: theme.GetSeriesColor(index), + }) + seriesPainter.MoveTo(cx, cy) + start := chart.PercentToRadians(currentValue/total) - math.Pi/2 + currentValue += v + percent := (v / total) + delta := chart.PercentToRadians(percent) + seriesPainter.ArcTo(cx, cy, radius, radius, start, delta). + LineTo(cx, cy). + Close(). + FillStroke() + + series := seriesList[index] + // 是否显示label + showLabel := series.Label.Show + if !showLabel { + continue + } + + // label的角度为饼块中间 + angle := start + delta/2 + startx := cx + int(radius*math.Cos(angle)) + starty := cy + int(radius*math.Sin(angle)) + + endx := cx + int(labelRadius*math.Cos(angle)) + endy := cy + int(labelRadius*math.Sin(angle)) + // 计算是否有重叠,如果有则调整y坐标位置 + if index != 0 && + math.Abs(float64(endx-prevEndX)) < labelFontSize && + math.Abs(float64(endy-prevEndY)) < labelFontSize { + endy -= (labelFontSize << 1) + } + prevEndX = endx + prevEndY = endy + + seriesPainter.MoveTo(startx, starty) + seriesPainter.LineTo(endx, endy) + offset := labelLineWidth + if endx < cx { + offset *= -1 + } + seriesPainter.MoveTo(endx, endy) + endx += offset + seriesPainter.LineTo(endx, endy) + seriesPainter.Stroke() + + textStyle := Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + } + if !series.Label.Color.IsZero() { + textStyle.FontColor = series.Label.Color + } + seriesPainter.OverrideTextStyle(textStyle) + text := NewPieLabelFormatter(seriesNames, series.Label.Formatter)(index, v, percent) + textBox := seriesPainter.MeasureText(text) + textMargin := 3 + x := endx + textMargin + y := endy + textBox.Height()>>1 - 1 + if offset < 0 { + textWidth := textBox.Width() + x = endx - textWidth - textMargin + } + seriesPainter.Text(text, x, y) + } + } + + return p.p.box, nil +} + +func (p *pieChart) Render() (Box, error) { + opt := p.opt + + renderResult, err := defaultRender(p.p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: XAxisOption{ + Show: FalseFlag(), + }, + YAxisOptions: []YAxisOption{ + { + Show: FalseFlag(), + }, + }, + TitleOption: opt.Title, + LegendOption: opt.Legend, + backgroundIsFilled: opt.backgroundIsFilled, + }) + if err != nil { + return BoxZero, err + } + seriesList := opt.SeriesList.Filter(ChartTypePie) + return p.render(renderResult, seriesList) +} diff --git a/radar_chart.go b/radar_chart.go new file mode 100644 index 0000000..dc93ca8 --- /dev/null +++ b/radar_chart.go @@ -0,0 +1,245 @@ +// 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 ( + "errors" + + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +type radarChart struct { + p *Painter + opt *RadarChartOption +} + +type RadarIndicator struct { + // Indicator's name + Name string + // The maximum value of indicator + Max float64 + // The minimum value of indicator + Min float64 +} + +type RadarChartOption struct { + Theme ColorPalette + // The font size + Font *truetype.Font + // The data series list + SeriesList SeriesList + // The padding of line chart + Padding Box + // The option of title + Title TitleOption + // The legend option + Legend LegendOption + // The radar indicator list + RadarIndicators []RadarIndicator + // background is filled + backgroundIsFilled bool +} + +func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart { + if opt.Theme == nil { + opt.Theme = NewTheme("") + } + return &radarChart{ + p: p, + opt: &opt, + } +} + +func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + opt := r.opt + indicators := opt.RadarIndicators + sides := len(indicators) + if sides < 3 { + return BoxZero, errors.New("The count of indicator should be >= 3") + } + maxValues := make([]float64, len(indicators)) + for _, series := range seriesList { + for index, item := range series.Data { + if index < len(maxValues) && item.Value > maxValues[index] { + maxValues[index] = item.Value + } + } + } + for index, indicator := range indicators { + if indicator.Max <= 0 { + indicators[index].Max = maxValues[index] + } + } + + radiusValue := "" + for _, series := range seriesList { + if len(series.Radius) != 0 { + radiusValue = series.Radius + } + } + + seriesPainter := result.seriesPainter + theme := opt.Theme + + cx := seriesPainter.Width() >> 1 + cy := seriesPainter.Height() >> 1 + diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height()) + radius := getRadius(float64(diameter), radiusValue) + + divideCount := 5 + divideRadius := float64(int(radius / float64(divideCount))) + radius = divideRadius * float64(divideCount) + + seriesPainter.OverrideDrawingStyle(Style{ + StrokeColor: theme.GetAxisSplitLineColor(), + StrokeWidth: 1, + }) + center := Point{ + X: cx, + Y: cy, + } + for i := 0; i < divideCount; i++ { + seriesPainter.Polygon(center, divideRadius*float64(i+1), sides) + } + points := getPolygonPoints(center, radius, sides) + for _, p := range points { + seriesPainter.MoveTo(center.X, center.Y) + seriesPainter.LineTo(p.X, p.Y) + seriesPainter.Stroke() + } + seriesPainter.OverrideTextStyle(Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + }) + offset := 5 + // 文本生成 + for index, p := range points { + name := indicators[index].Name + b := seriesPainter.MeasureText(name) + isXCenter := p.X == center.X + isYCenter := p.Y == center.Y + isRight := p.X > center.X + isLeft := p.X < center.X + isTop := p.Y < center.Y + isBottom := p.Y > center.Y + x := p.X + y := p.Y + if isXCenter { + x -= b.Width() >> 1 + if isTop { + y -= b.Height() + } else { + y += b.Height() + } + } + if isYCenter { + y += b.Height() >> 1 + } + if isTop { + y += offset + } + if isBottom { + y += offset + } + if isRight { + x += offset + } + if isLeft { + x -= (b.Width() + offset) + } + seriesPainter.Text(name, x, y) + } + + // 雷达图 + angles := getPolygonPointAngles(sides) + maxCount := len(indicators) + for _, series := range seriesList { + linePoints := make([]Point, 0, maxCount) + for j, item := range series.Data { + if j >= maxCount { + continue + } + indicator := indicators[j] + percent := (item.Value - indicator.Min) / (indicator.Max - indicator.Min) + r := percent * radius + p := getPolygonPoint(center, r, angles[j]) + linePoints = append(linePoints, p) + } + color := theme.GetSeriesColor(series.index) + dotFillColor := drawing.ColorWhite + if theme.IsDark() { + dotFillColor = color + } + linePoints = append(linePoints, linePoints[0]) + seriesPainter.OverrideDrawingStyle(Style{ + StrokeColor: color, + StrokeWidth: defaultStrokeWidth, + DotWidth: defaultDotWidth, + DotColor: color, + FillColor: color.WithAlpha(20), + }) + seriesPainter.LineStroke(linePoints). + FillArea(linePoints) + dotWith := 2.0 + seriesPainter.OverrideDrawingStyle(Style{ + StrokeWidth: defaultStrokeWidth, + StrokeColor: color, + FillColor: dotFillColor, + }) + for _, point := range linePoints { + seriesPainter.Circle(dotWith, point.X, point.Y) + seriesPainter.FillStroke() + } + } + + return r.p.box, nil +} + +func (r *radarChart) Render() (Box, error) { + p := r.p + opt := r.opt + renderResult, err := defaultRender(p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: XAxisOption{ + Show: FalseFlag(), + }, + YAxisOptions: []YAxisOption{ + { + Show: FalseFlag(), + }, + }, + TitleOption: opt.Title, + LegendOption: opt.Legend, + backgroundIsFilled: opt.backgroundIsFilled, + }) + if err != nil { + return BoxZero, err + } + seriesList := opt.SeriesList.Filter(ChartTypeRadar) + return r.render(renderResult, seriesList) +} diff --git a/xaxis.go b/xaxis.go index f06d71f..bfb57cb 100644 --- a/xaxis.go +++ b/xaxis.go @@ -38,8 +38,8 @@ type XAxisOption struct { Theme ColorPalette // The font size of x axis label FontSize float64 - // Hidden x axis - Hidden bool + // The flag for show axis, set this to *false will hide axis + Show *bool // Number of segments that the axis is split into. Note that this number serves only as a recommendation. SplitNumber int // The position of axis, it can be 'top' or 'bottom' @@ -78,6 +78,7 @@ func (opt *XAxisOption) ToAxisOption() AxisOption { FontSize: opt.FontSize, Font: opt.Font, FontColor: opt.FontColor, + Show: opt.Show, SplitLineColor: opt.Theme.GetAxisSplitLineColor(), } if opt.isValueAxis { diff --git a/yaxis.go b/yaxis.go index 609924f..265ac59 100644 --- a/yaxis.go +++ b/yaxis.go @@ -25,6 +25,10 @@ package charts import "github.com/golang/freetype/truetype" type YAxisOption struct { + // The minimun value of axis. + Min *float64 + // The maximum value of axis. + Max *float64 // The font of y axis Font *truetype.Font // The data value of x axis @@ -36,7 +40,13 @@ type YAxisOption struct { // The position of axis, it can be 'left' or 'right' Position string // The color of label - FontColor Color + FontColor Color + // Formatter for y axis text value + Formatter string + // Color for y axis + Color Color + // The flag for show axis, set this to *false will hide axis + Show *bool isCategoryAxis bool } @@ -60,6 +70,7 @@ func (opt *YAxisOption) ToAxisOption() AxisOption { position = PositionRight } axisOpt := AxisOption{ + Formatter: opt.Formatter, Theme: opt.Theme, Data: opt.Data, Position: position, @@ -70,6 +81,11 @@ func (opt *YAxisOption) ToAxisOption() AxisOption { BoundaryGap: FalseFlag(), SplitLineShow: true, SplitLineColor: opt.Theme.GetAxisSplitLineColor(), + Show: opt.Show, + } + if !opt.Color.IsZero() { + axisOpt.FontColor = opt.Color + axisOpt.StrokeColor = opt.Color } if opt.isCategoryAxis { axisOpt.BoundaryGap = TrueFlag() @@ -85,3 +101,13 @@ func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter { })) return NewAxisPainter(p, opt.ToAxisOption()) } + +func NewRightYAxis(p *Painter, opt YAxisOption) *axisPainter { + p = p.Child(PainterPaddingOption(Box{ + Bottom: defaultXAxisHeight, + })) + axisOpt := opt.ToAxisOption() + axisOpt.Position = PositionRight + axisOpt.SplitLineShow = false + return NewAxisPainter(p, axisOpt) +}