From b69728dd1260336d9816b654e69797b7187dbbc6 Mon Sep 17 00:00:00 2001 From: vicanso Date: Tue, 14 Jun 2022 23:07:11 +0800 Subject: [PATCH] feat: support bar chart render --- axis.go | 6 +- bar_chart.go | 205 ++++++++++++++++++++++++++++++++++++ charts.go | 30 ++++-- examples/bar_chart/main.go | 127 ++++++++++++++++++++++ examples/line_chart/main.go | 4 +- examples/painter/main.go | 10 +- legend.go | 3 + line_chart.go | 58 ++++++---- mark_point.go | 3 +- xaxis.go | 6 +- yaxis.go | 6 +- 11 files changed, 408 insertions(+), 50 deletions(-) create mode 100644 bar_chart.go create mode 100644 examples/bar_chart/main.go diff --git a/axis.go b/axis.go index bb2e6a3..bd760b6 100644 --- a/axis.go +++ b/axis.go @@ -28,17 +28,17 @@ import ( type axisPainter struct { p *Painter - opt *AxisPainterOption + opt *AxisOption } -func NewAxisPainter(p *Painter, opt AxisPainterOption) *axisPainter { +func NewAxisPainter(p *Painter, opt AxisOption) *axisPainter { return &axisPainter{ p: p, opt: &opt, } } -type AxisPainterOption struct { +type AxisOption struct { // The theme of chart Theme ColorPalette // The label of axis diff --git a/bar_chart.go b/bar_chart.go new file mode 100644 index 0000000..9dadb1e --- /dev/null +++ b/bar_chart.go @@ -0,0 +1,205 @@ +// 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" +) + +type barChart struct { + p *Painter + opt *BarChartOption +} + +func NewBarChart(p *Painter, opt BarChartOption) *barChart { + if opt.Theme == nil { + opt.Theme = NewTheme("") + } + return &barChart{ + p: p, + opt: &opt, + } +} + +type BarChartOption struct { + Theme ColorPalette + // The font size + Font *truetype.Font + // The data series list + SeriesList SeriesList + // The x axis option + XAxis XAxisOption + // The padding of line chart + Padding Box + // The y axis option + YAxisOptions []YAxisOption + // The option of title + Title TitleOption + // The legend option + Legend LegendOption +} + +func (b *barChart) Render() (Box, error) { + p := b.p + opt := b.opt + seriesList := opt.SeriesList + seriesList.init() + renderResult, err := defaultRender(p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: seriesList, + XAxis: opt.XAxis, + YAxisOptions: opt.YAxisOptions, + TitleOption: opt.Title, + LegendOption: opt.Legend, + }) + if err != nil { + return chart.BoxZero, err + } + seriesPainter := renderResult.seriesPainter + seriesList = seriesList.Filter(ChartTypeBar) + + xRange := NewRange(AxisRangeOption{ + DivideCount: len(opt.XAxis.Data), + Size: seriesPainter.Width(), + }) + x0, x1 := xRange.GetRange(0) + width := int(x1 - x0) + // 每一块之间的margin + margin := 10 + // 每一个bar之间的margin + barMargin := 5 + if width < 20 { + margin = 2 + barMargin = 2 + } else if width < 50 { + margin = 5 + barMargin = 3 + } + seriesCount := len(seriesList) + // 总的宽度-两个margin-(总数-1)的barMargin + barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / len(seriesList) + barMaxHeight := seriesPainter.Height() + theme := opt.Theme + seriesNames := seriesList.Names() + + markPointPainter := NewMarkPointPainter(seriesPainter) + markLinePainter := NewMarkLinePainter(seriesPainter) + rendererList := []Renderer{ + markPointPainter, + markLinePainter, + } + for i := range seriesList { + series := seriesList[i] + yRange := renderResult.axisRanges[series.AxisIndex] + index := series.index + if index == 0 { + index = i + } + seriesColor := theme.GetSeriesColor(index) + + divideValues := xRange.AutoDivide() + points := make([]Point, len(series.Data)) + for j, item := range series.Data { + if j >= xRange.divideCount { + continue + } + x := divideValues[j] + x += margin + if i != 0 { + x += i * (barWidth + barMargin) + } + + h := int(yRange.getHeight(item.Value)) + fillColor := seriesColor + if !item.Style.FillColor.IsZero() { + fillColor = item.Style.FillColor + } + top := barMaxHeight - h + + seriesPainter.OverrideDrawingStyle(Style{ + FillColor: fillColor, + }).Rect(chart.Box{ + Top: top, + Left: x, + Right: x + barWidth, + Bottom: barMaxHeight - 1, + }) + // 用于生成marker point + points[j] = Point{ + // 居中的位置 + X: x + barWidth>>1, + Y: top, + } + // 用于生成marker point + points[j] = Point{ + // 居中的位置 + X: x + barWidth>>1, + Y: top, + } + // 如果label不需要展示,则返回 + if !series.Label.Show { + continue + } + distance := series.Label.Distance + if distance == 0 { + distance = 5 + } + text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) + labelStyle := Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + } + if !series.Label.Color.IsZero() { + labelStyle.FontColor = series.Label.Color + } + seriesPainter.OverrideTextStyle(labelStyle) + textBox := seriesPainter.MeasureText(text) + seriesPainter.Text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance) + } + + markPointPainter.Add(markPointRenderOption{ + FillColor: seriesColor, + Font: opt.Font, + Series: series, + Points: points, + }) + markLinePainter.Add(markLineRenderOption{ + FillColor: seriesColor, + FontColor: opt.Theme.GetTextColor(), + StrokeColor: seriesColor, + Font: opt.Font, + Series: series, + Range: yRange, + }) + } + // 最大、最小的mark point + err = doRender(rendererList...) + if err != nil { + return chart.BoxZero, err + } + + return chart.BoxZero, nil +} diff --git a/charts.go b/charts.go index 947fa8d..09dfc3f 100644 --- a/charts.go +++ b/charts.go @@ -56,14 +56,21 @@ type defaultRenderOption struct { TitleOption TitleOption // The legend option LegendOption LegendOption + // background is filled + backgroundIsFilled bool } type defaultRenderResult struct { axisRanges map[int]axisRange - p *Painter + // 图例区域 + seriesPainter *Painter } func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, error) { + if !opt.backgroundIsFilled { + p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor()) + } + if !opt.Padding.IsZero() { p = p.Child(PainterPaddingOption(opt.Padding)) } @@ -157,8 +164,8 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e return nil, err } - result.p = p.Child(PainterPaddingOption(Box{ - Bottom: rangeHeight, + result.seriesPainter = p.Child(PainterPaddingOption(Box{ + Bottom: defaultXAxisHeight, Left: rangeWidthLeft, Right: rangeWidthRight, })) @@ -200,14 +207,15 @@ func Render(opt ChartOption) (*Painter, error) { 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, + Theme: opt.theme, + Font: opt.font, + SeriesList: lineChartSeriesList, + XAxis: opt.XAxis, + Padding: opt.Padding, + YAxisOptions: opt.YAxisOptions, + Title: opt.Title, + Legend: opt.Legend, + backgroundIsFilled: true, }) rendererList = append(rendererList, renderer) } diff --git a/examples/bar_chart/main.go b/examples/bar_chart/main.go new file mode 100644 index 0000000..5d5da2a --- /dev/null +++ b/examples/bar_chart/main.go @@ -0,0 +1,127 @@ +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, "bar-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.NewBarChart(p, charts.BarChartOption{ + Title: charts.TitleOption{ + Text: "Rainfall vs Evaporation", + Subtext: "Fake Data", + }, + Padding: charts.Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + XAxis: charts.NewXAxisOption([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + Legend: charts.NewLegendOption([]string{ + "Rainfall", + "Evaporation", + }, charts.PositionRight), + 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, + }), + MarkPoint: charts.NewMarkPoint( + charts.SeriesMarkDataTypeMax, + charts.SeriesMarkDataTypeMin, + ), + MarkLine: charts.NewMarkLine( + charts.SeriesMarkDataTypeAverage, + ), + }, + { + 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, + }), + MarkPoint: charts.NewMarkPoint( + charts.SeriesMarkDataTypeMax, + charts.SeriesMarkDataTypeMin, + ), + MarkLine: charts.NewMarkLine( + charts.SeriesMarkDataTypeAverage, + ), + }, + }, + }).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/line_chart/main.go b/examples/line_chart/main.go index c168f08..414f676 100644 --- a/examples/line_chart/main.go +++ b/examples/line_chart/main.go @@ -39,10 +39,10 @@ func main() { Right: 10, Bottom: 10, }, - TitleOption: charts.TitleOption{ + Title: charts.TitleOption{ Text: "Line", }, - LegendOption: charts.LegendOption{ + Legend: charts.LegendOption{ Data: []string{ "Email", "Union Ads", diff --git a/examples/painter/main.go b/examples/painter/main.go index 5022584..cf2bb81 100644 --- a/examples/painter/main.go +++ b/examples/painter/main.go @@ -490,7 +490,7 @@ func main() { Left: 1, Right: p.Width() - 1, Bottom: top + 50, - })), charts.AxisPainterOption{ + })), charts.AxisOption{ Data: []string{ "Mon", "Tue", @@ -512,7 +512,7 @@ func main() { Left: 1, Right: p.Width() - 1, Bottom: top + 50, - })), charts.AxisPainterOption{ + })), charts.AxisOption{ Position: charts.PositionTop, BoundaryGap: charts.FalseFlag(), Data: []string{ @@ -536,7 +536,7 @@ func main() { Left: 10, Right: 60, Bottom: top + 200, - })), charts.AxisPainterOption{ + })), charts.AxisOption{ Position: charts.PositionLeft, Data: []string{ "Mon", @@ -557,7 +557,7 @@ func main() { Left: 100, Right: 150, Bottom: top + 200, - })), charts.AxisPainterOption{ + })), charts.AxisOption{ Position: charts.PositionRight, Data: []string{ "Mon", @@ -579,7 +579,7 @@ func main() { Left: 150, Right: 300, Bottom: top + 200, - })), charts.AxisPainterOption{ + })), charts.AxisOption{ BoundaryGap: charts.FalseFlag(), Position: charts.PositionLeft, Data: []string{ diff --git a/legend.go b/legend.go index b8a6fdc..e645e17 100644 --- a/legend.go +++ b/legend.go @@ -87,6 +87,9 @@ func (l *legendPainter) Render() (Box, error) { if opt.FontColor.IsZero() { opt.FontColor = theme.GetTextColor() } + if opt.Left == "" { + opt.Left = PositionCenter + } p := l.p p.SetTextStyle(Style{ FontSize: opt.FontSize, diff --git a/line_chart.go b/line_chart.go index 5f4ea4f..451edfe 100644 --- a/line_chart.go +++ b/line_chart.go @@ -56,9 +56,11 @@ type LineChartOption struct { // The y axis option YAxisOptions []YAxisOption // The option of title - TitleOption TitleOption + Title TitleOption // The legend option - LegendOption LegendOption + Legend LegendOption + // background is filled + backgroundIsFilled bool } func (l *lineChart) Render() (Box, error) { @@ -67,26 +69,39 @@ func (l *lineChart) Render() (Box, error) { seriesList := opt.SeriesList seriesList.init() renderResult, err := defaultRender(p, defaultRenderOption{ - Theme: opt.Theme, - Padding: opt.Padding, - SeriesList: seriesList, - XAxis: opt.XAxis, - YAxisOptions: opt.YAxisOptions, - TitleOption: opt.TitleOption, - LegendOption: opt.LegendOption, + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: seriesList, + XAxis: opt.XAxis, + YAxisOptions: opt.YAxisOptions, + TitleOption: opt.Title, + LegendOption: opt.Legend, + backgroundIsFilled: opt.backgroundIsFilled, }) if err != nil { return chart.BoxZero, err } + boundaryGap := true + if opt.XAxis.BoundaryGap != nil && !*opt.XAxis.BoundaryGap { + boundaryGap = false + } seriesList = seriesList.Filter(ChartTypeLine) - seriesPainter := renderResult.p + seriesPainter := renderResult.seriesPainter - xDivideValues := autoDivide(seriesPainter.Width(), len(opt.XAxis.Data)) + xDivideCount := len(opt.XAxis.Data) + if !boundaryGap { + xDivideCount-- + } + xDivideValues := autoDivide(seriesPainter.Width(), xDivideCount) xValues := make([]int, len(xDivideValues)-1) - for i := 0; i < len(xDivideValues)-1; i++ { - xValues[i] = (xDivideValues[i] + xDivideValues[i+1]) >> 1 + if boundaryGap { + for i := 0; i < len(xDivideValues)-1; i++ { + xValues[i] = (xDivideValues[i] + xDivideValues[i+1]) >> 1 + } + } else { + xValues = xDivideValues } markPointPainter := NewMarkPointPainter(seriesPainter) markLinePainter := NewMarkLinePainter(seriesPainter) @@ -94,7 +109,8 @@ func (l *lineChart) Render() (Box, error) { markPointPainter, markLinePainter, } - for index, series := range seriesList { + for index := range seriesList { + series := seriesList[index] seriesColor := opt.Theme.GetSeriesColor(index) drawingStyle := Style{ StrokeColor: seriesColor, @@ -102,10 +118,10 @@ func (l *lineChart) Render() (Box, error) { } seriesPainter.SetDrawingStyle(drawingStyle) - yr := renderResult.axisRanges[series.AxisIndex] + yRange := renderResult.axisRanges[series.AxisIndex] points := make([]Point, 0) for i, item := range series.Data { - h := yr.getRestHeight(item.Value) + h := yRange.getRestHeight(item.Value) p := Point{ X: xValues[i], Y: h, @@ -136,15 +152,13 @@ func (l *lineChart) Render() (Box, error) { StrokeColor: seriesColor, Font: opt.Font, Series: series, - Range: yr, + Range: yRange, }) } // 最大、最小的mark point - for _, renderer := range rendererList { - _, err = renderer.Render() - if err != nil { - return chart.BoxZero, err - } + err = doRender(rendererList...) + if err != nil { + return chart.BoxZero, err } return p.box, nil diff --git a/mark_point.go b/mark_point.go index ce3cb0f..07daf57 100644 --- a/mark_point.go +++ b/mark_point.go @@ -64,6 +64,7 @@ func NewMarkPointPainter(p *Painter) *markPointPainter { func (m *markPointPainter) Render() (Box, error) { painter := m.p + theme := m.p.theme for _, opt := range m.options { s := opt.Series if len(s.MarkPoint.Data) == 0 { @@ -78,7 +79,7 @@ func (m *markPointPainter) Render() (Box, error) { painter.OverrideDrawingStyle(Style{ FillColor: opt.FillColor, }).OverrideTextStyle(Style{ - FontColor: NewTheme(ThemeDark).GetTextColor(), + FontColor: theme.GetTextColor(), FontSize: labelFontSize, StrokeWidth: 1, Font: opt.Font, diff --git a/xaxis.go b/xaxis.go index a8c28c0..d8f6700 100644 --- a/xaxis.go +++ b/xaxis.go @@ -62,12 +62,12 @@ func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption { return opt } -func (opt *XAxisOption) ToAxisPainterOption() AxisPainterOption { +func (opt *XAxisOption) ToAxisOption() AxisOption { position := PositionBottom if opt.Position == PositionTop { position = PositionTop } - return AxisPainterOption{ + return AxisOption{ Theme: opt.Theme, Data: opt.Data, BoundaryGap: opt.BoundaryGap, @@ -84,5 +84,5 @@ func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter { p = p.Child(PainterPaddingOption(Box{ Top: p.Height() - defaultXAxisHeight, })) - return NewAxisPainter(p, opt.ToAxisPainterOption()) + return NewAxisPainter(p, opt.ToAxisOption()) } diff --git a/yaxis.go b/yaxis.go index 653f6ec..b011a74 100644 --- a/yaxis.go +++ b/yaxis.go @@ -39,12 +39,12 @@ type YAxisOption struct { FontColor Color } -func (opt *YAxisOption) ToAxisPainterOption() AxisPainterOption { +func (opt *YAxisOption) ToAxisOption() AxisOption { position := PositionLeft if opt.Position == PositionRight { position = PositionRight } - return AxisPainterOption{ + return AxisOption{ Theme: opt.Theme, Data: opt.Data, Position: position, @@ -62,5 +62,5 @@ func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter { p = p.Child(PainterPaddingOption(Box{ Bottom: defaultXAxisHeight, })) - return NewAxisPainter(p, opt.ToAxisPainterOption()) + return NewAxisPainter(p, opt.ToAxisOption()) }