diff --git a/axis.go b/axis.go index 29cc3cc..333c8c8 100644 --- a/axis.go +++ b/axis.go @@ -68,6 +68,10 @@ type axis struct { data *AxisDataList style *AxisOption } +type axisMeasurement struct { + Width int + Height int +} func NewAxis(d *Draw, data AxisDataList, style AxisOption) *axis { return &axis{ @@ -379,7 +383,7 @@ func (a *axis) axisTick(opt *axisRenderOption) { } } -func (a *axis) axisMeasureTextMaxWidthHeight() (int, int) { +func (a *axis) measureTextMaxWidthHeight() (int, int) { d := a.d r := d.Render s := a.style.Style(d.Font) @@ -389,18 +393,21 @@ func (a *axis) axisMeasureTextMaxWidthHeight() (int, int) { return measureTextMaxWidthHeight(data.TextList(), r) } -// measureAxis returns the measurement of axis. -// If the position is left or right, it will be textMaxWidth + labelMargin + tickLength. -// If the position is top or bottom, it will be textMaxHeight + labelMargin + tickLength. -func (a *axis) measureAxis() int { +// measure returns the measurement of axis. +// Width will be textMaxWidth + labelMargin + tickLength for position left or right. +// Height will be textMaxHeight + labelMargin + tickLength for position top or bottom. +func (a *axis) measure() axisMeasurement { style := a.style value := style.GetLabelMargin() + style.GetTickLength() - textMaxWidth, textMaxHeight := a.axisMeasureTextMaxWidthHeight() + textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight() + info := axisMeasurement{} if style.Position == PositionLeft || style.Position == PositionRight { - return textMaxWidth + value + info.Width = textMaxWidth + value + } else { + info.Height = textMaxHeight + value } - return textMaxHeight + value + return info } // Render renders the axis for chart @@ -409,7 +416,7 @@ func (a *axis) Render() { if isFalse(style.Show) { return } - textMaxWidth, textMaxHeight := a.axisMeasureTextMaxWidthHeight() + textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight() opt := &axisRenderOption{ textMaxWith: textMaxWidth, textMaxHeight: textMaxHeight, diff --git a/axis_test.go b/axis_test.go index a6a7690..37c8314 100644 --- a/axis_test.go +++ b/axis_test.go @@ -247,13 +247,13 @@ func TestMeasureAxis(t *testing.T) { FontSize: 12, Font: f, Position: PositionLeft, - }).measureAxis() + }).measure().Width assert.Equal(44, width) height := NewAxis(d, data, AxisOption{ FontSize: 12, Font: f, Position: PositionTop, - }).measureAxis() + }).measure().Height assert.Equal(28, height) } diff --git a/bar_chart.go b/bar_chart.go index 7a5805b..597388c 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -26,7 +26,7 @@ import ( "github.com/wcharczuk/go-chart/v2" ) -func barChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) { +func barChartRender(opt ChartOption, result *basicRenderResult) ([]*markPointRenderOption, error) { d, err := NewDraw(DrawOption{ Parent: result.d, @@ -57,10 +57,29 @@ func barChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) { r := d.Render - for i, series := range opt.SeriesList { + markPointRenderOptions := make([]*markPointRenderOption, 0) + + for i, s := range opt.SeriesList { + // 由于series是for range,为同一个数据,因此需要clone + // 后续需要使用,如mark point + series := s yRange := result.getYRange(series.YAxisIndex) points := make([]Point, len(series.Data)) - seriesColor := theme.GetSeriesColor(i) + index := series.index + if index == 0 { + index = i + } + seriesColor := theme.GetSeriesColor(index) + // mark line + markLineRender(&markLineRenderOption{ + Draw: d, + FillColor: seriesColor, + FontColor: theme.GetTextColor(), + StrokeColor: seriesColor, + Font: opt.Font, + Series: &series, + Range: yRange, + }) for j, item := range series.Data { x0, _ := xRange.GetRange(j) x := int(x0) @@ -105,7 +124,9 @@ func barChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) { textBox := r.MeasureText(text) d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-5) } - markPointRender(d, markPointRenderOption{ + + markPointRenderOptions = append(markPointRenderOptions, &markPointRenderOption{ + Draw: d, FillColor: seriesColor, Font: opt.Font, Points: points, @@ -113,5 +134,5 @@ func barChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) { }) } - return result.d, nil + return markPointRenderOptions, nil } diff --git a/chart.go b/chart.go index dbc4db4..3321f50 100644 --- a/chart.go +++ b/chart.go @@ -80,6 +80,14 @@ func (o *ChartOption) FillDefault(theme string) { if o.BackgroundColor.IsZero() { o.BackgroundColor = t.GetBackgroundColor() } + if o.Padding.IsZero() { + o.Padding = chart.Box{ + Top: 20, + Right: 10, + Bottom: 10, + Left: 10, + } + } // 标题的默认值 if o.Title.Style.FontColor.IsZero() { @@ -110,7 +118,7 @@ func (o *ChartOption) FillDefault(theme string) { o.Title.SubtextStyle.Font = o.Font } - o.Legend.Theme = theme + o.Legend.theme = theme if o.Legend.Style.FontSize == 0 { o.Legend.Style.FontSize = 10 } @@ -238,13 +246,14 @@ func Render(opt ChartOption) (*Draw, error) { if err != nil { return nil, err } + markPointRenderOptions := make([]*markPointRenderOption, 0) fns := []func() error{ // pie render func() error { if !isPieChart { return nil } - _, err := pieChartRender(opt, result) + err := pieChartRender(opt, result) return err }, // bar render @@ -255,8 +264,12 @@ func Render(opt ChartOption) (*Draw, error) { } o := opt o.SeriesList = barSeries - _, err := barChartRender(o, result) - return err + options, err := barChartRender(o, result) + if err != nil { + return err + } + markPointRenderOptions = append(markPointRenderOptions, options...) + return nil }, // line render func() error { @@ -266,14 +279,26 @@ func Render(opt ChartOption) (*Draw, error) { } o := opt o.SeriesList = lineSeries - _, err := lineChartRender(o, result) - return err + options, err := lineChartRender(o, result) + if err != nil { + return err + } + markPointRenderOptions = append(markPointRenderOptions, options...) + return nil }, - // legend需要在顶层,因此最后render + // legend需要在顶层,因此此处render func() error { _, err := NewLegend(result.d, opt.Legend).Render() return err }, + // mark point最后render + func() error { + // mark point render不会出错 + for _, opt := range markPointRenderOptions { + markPointRender(opt) + } + return nil + }, } for _, fn := range fns { @@ -296,6 +321,7 @@ func Render(opt ChartOption) (*Draw, error) { } func chartBasicRender(opt *ChartOption) (*basicRenderResult, error) { + opt.FillDefault(opt.Theme) d, err := NewDraw( DrawOption{ Type: opt.Type, @@ -309,7 +335,6 @@ func chartBasicRender(opt *ChartOption) (*basicRenderResult, error) { return nil, err } - opt.FillDefault(opt.Theme) if len(opt.YAxisList) > 2 { return nil, errors.New("y axis should not be gt 2") } diff --git a/draw.go b/draw.go index 5c65d87..12c9d06 100644 --- a/draw.go +++ b/draw.go @@ -161,7 +161,9 @@ func (d *Draw) pin(x, y, width int) { delta := 2*math.Pi - 2*angle d.arcTo(x, y, r, r, startAngle, delta) d.lineTo(x, y) - d.Render.Fill() + d.Render.Close() + d.Render.FillStroke() + startX := x - int(r) startY := y endX := x + int(r) @@ -173,7 +175,8 @@ func (d *Draw) pin(x, y, width int) { cx := x cy := y + int(r*2.5) d.Render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top) - d.Render.Stroke() + d.Render.Close() + d.Render.Fill() } func (d *Draw) arrowLeft(x, y, width, height int) { @@ -226,7 +229,20 @@ func (d *Draw) arrow(x, y, width, height int, direction string) { d.lineTo(x0+dx, y0+halfHeight) d.lineTo(x0, y0) } + d.Render.FillStroke() +} + +func (d *Draw) makeLine(x, y, width int) { + arrowWidth := 16 + arrowHeight := 10 + endX := x + width + d.circle(3, x, y) + d.Render.Fill() + d.moveTo(x+5, y) + d.lineTo(endX-arrowWidth, y) d.Render.Stroke() + d.Render.SetStrokeDashArray([]float64{}) + d.arrowRight(endX, y, arrowWidth, arrowHeight) } func (d *Draw) circle(radius float64, x, y int) { diff --git a/draw_test.go b/draw_test.go index efda7eb..11f8709 100644 --- a/draw_test.go +++ b/draw_test.go @@ -259,7 +259,7 @@ func TestDraw(t *testing.T) { }.WriteToRenderer(d.Render) d.pin(30, 30, 30) }, - result: "\\n", + result: "\\n", }, // arrow left { @@ -349,6 +349,32 @@ func TestDraw(t *testing.T) { }, result: "\\n", }, + // mark line + { + fn: func(d *Draw) { + chart.Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + StrokeDashArray: []float64{ + 4, + 2, + }, + }.WriteToRenderer(d.Render) + d.makeLine(0, 20, 300) + }, + result: "\\n", + }, } for _, tt := range tests { d, err := NewDraw(DrawOption{ diff --git a/legend.go b/legend.go index 7b6722e..263b312 100644 --- a/legend.go +++ b/legend.go @@ -30,7 +30,7 @@ import ( ) type LegendOption struct { - Theme string + theme string // Legend show flag, if nil or true, the legend will be shown Show *bool // Legend text style @@ -67,7 +67,7 @@ func (l *legend) Render() (chart.Box, error) { if len(opt.Data) == 0 || isFalse(opt.Show) { return chart.BoxZero, nil } - theme := NewTheme(opt.Theme) + theme := NewTheme(opt.theme) padding := opt.Style.Padding legendDraw, err := NewDraw(DrawOption{ Parent: d, diff --git a/line_chart.go b/line_chart.go index 39d8b4d..af87cc9 100644 --- a/line_chart.go +++ b/line_chart.go @@ -27,7 +27,7 @@ import ( "github.com/wcharczuk/go-chart/v2/drawing" ) -func lineChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) { +func lineChartRender(opt ChartOption, result *basicRenderResult) ([]*markPointRenderOption, error) { theme := NewTheme(opt.Theme) @@ -44,9 +44,29 @@ func lineChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) r := d.Render xRange := result.xRange - for i, series := range opt.SeriesList { + markPointRenderOptions := make([]*markPointRenderOption, 0) + for i, s := range opt.SeriesList { + // 由于series是for range,为同一个数据,因此需要clone + // 后续需要使用,如mark point + series := s + index := series.index + if index == 0 { + index = i + } + seriesColor := theme.GetSeriesColor(index) + yRange := result.getYRange(series.YAxisIndex) points := make([]Point, len(series.Data)) + // mark line + markLineRender(&markLineRenderOption{ + Draw: d, + FillColor: seriesColor, + FontColor: theme.GetTextColor(), + StrokeColor: seriesColor, + Font: opt.Font, + Series: &series, + Range: yRange, + }) for j, item := range series.Data { y := yRange.getRestHeight(item.Value) @@ -71,11 +91,7 @@ func lineChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) textBox := r.MeasureText(text) d.text(text, x-textBox.Width()>>1, y-5) } - index := series.index - if index == 0 { - index = i - } - seriesColor := theme.GetSeriesColor(index) + dotFillColor := drawing.ColorWhite if theme.IsDark() { dotFillColor = seriesColor @@ -88,7 +104,8 @@ func lineChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) DotFillColor: dotFillColor, }) // draw mark point - markPointRender(d, markPointRenderOption{ + markPointRenderOptions = append(markPointRenderOptions, &markPointRenderOption{ + Draw: d, FillColor: seriesColor, Font: opt.Font, Points: points, @@ -96,5 +113,5 @@ func lineChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) }) } - return result.d, nil + return markPointRenderOptions, nil } diff --git a/mark_line.go b/mark_line.go new file mode 100644 index 0000000..e895131 --- /dev/null +++ b/mark_line.go @@ -0,0 +1,92 @@ +// 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" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func NewMarkLine(markLineTypes ...string) SeriesMarkLine { + data := make([]SeriesMarkLineData, len(markLineTypes)) + for index, t := range markLineTypes { + data[index] = SeriesMarkLineData{ + Type: t, + } + } + return SeriesMarkLine{ + Data: data, + } +} + +type markLineRenderOption struct { + Draw *Draw + FillColor drawing.Color + FontColor drawing.Color + StrokeColor drawing.Color + Font *truetype.Font + Series *Series + Range *Range +} + +func markLineRender(opt *markLineRenderOption) { + d := opt.Draw + s := opt.Series + if len(s.MarkLine.Data) == 0 { + return + } + r := d.Render + summary := s.Summary() + for _, markLine := range s.MarkLine.Data { + // 由于mark line会修改style,因此每次重新设置 + chart.Style{ + FillColor: opt.FillColor, + FontColor: opt.FontColor, + FontSize: labelFontSize, + StrokeColor: opt.StrokeColor, + StrokeWidth: 1, + Font: opt.Font, + StrokeDashArray: []float64{ + 4, + 2, + }, + }.WriteToRenderer(r) + 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 := d.Box.Width() + text := commafWithDigits(value) + textBox := r.MeasureText(text) + d.makeLine(0, y, width) + d.text(text, width, y+textBox.Height()>>1-2) + } + +} diff --git a/mark_point.go b/mark_point.go index f1a429f..3f05445 100644 --- a/mark_point.go +++ b/mark_point.go @@ -28,14 +28,28 @@ import ( "github.com/wcharczuk/go-chart/v2/drawing" ) +func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint { + data := make([]SeriesMarkPointData, len(markPointTypes)) + for index, t := range markPointTypes { + data[index] = SeriesMarkPointData{ + Type: t, + } + } + return SeriesMarkPoint{ + Data: data, + } +} + type markPointRenderOption struct { + Draw *Draw FillColor drawing.Color Font *truetype.Font Series *Series Points []Point } -func markPointRender(d *Draw, opt markPointRenderOption) { +func markPointRender(opt *markPointRenderOption) { + d := opt.Draw s := opt.Series if len(s.MarkPoint.Data) == 0 { return @@ -54,7 +68,7 @@ func markPointRender(d *Draw, opt markPointRenderOption) { // 设置文本样式 chart.Style{ FontColor: NewTheme(ThemeDark).GetTextColor(), - FontSize: 10, + FontSize: labelFontSize, StrokeWidth: 1, Font: opt.Font, }.WriteTextOptionsToRenderer(r) @@ -62,7 +76,7 @@ func markPointRender(d *Draw, opt markPointRenderOption) { p := points[summary.MinIndex] value := summary.MinValue switch markPointData.Type { - case SeriesMarkPointDataTypeMax: + case SeriesMarkDataTypeMax: p = points[summary.MaxIndex] value = summary.MaxValue } diff --git a/pie_chart.go b/pie_chart.go index 84751f9..a8deb7c 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -41,14 +41,14 @@ func getPieStyle(theme *Theme, index int) chart.Style { } -func pieChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) { +func pieChartRender(opt ChartOption, result *basicRenderResult) error { d, err := NewDraw(DrawOption{ Parent: result.d, }, PaddingOption(chart.Box{ Top: result.titleBox.Height(), })) if err != nil { - return nil, err + return err } values := make([]float64, len(opt.SeriesList)) @@ -155,5 +155,5 @@ func pieChartRender(opt ChartOption, result *basicRenderResult) (*Draw, error) { d.text(text, x, y) } } - return result.d, nil + return nil } diff --git a/range.go b/range.go index 308dba8..1e02a51 100644 --- a/range.go +++ b/range.go @@ -54,9 +54,11 @@ func NewRange(min, max float64, divideCount int) Range { unit = int((r/float64(divideCount))/float64(unit))*unit + unit if min != 0 { + isLessThanZero := min < 0 min = float64(int(min/float64(unit)) * unit) // 如果是小于0,int的时候向上取整了,因此调整 - if min < 0 { + if min < 0 || + (isLessThanZero && min == 0) { min -= float64(unit) } } diff --git a/series.go b/series.go index b37e13d..57fe12a 100644 --- a/series.go +++ b/series.go @@ -63,8 +63,9 @@ type SeriesLabel struct { } const ( - SeriesMarkPointDataTypeMax = "max" - SeriesMarkPointDataTypeMin = "min" + SeriesMarkDataTypeMax = "max" + SeriesMarkDataTypeMin = "min" + SeriesMarkDataTypeAverage = "average" ) type SeriesMarkPointData struct { @@ -74,6 +75,12 @@ type SeriesMarkPoint struct { SymbolSize int Data []SeriesMarkPointData } +type SeriesMarkLineData struct { + Type string +} +type SeriesMarkLine struct { + Data []SeriesMarkLineData +} type Series struct { index int Type string @@ -85,6 +92,7 @@ type Series struct { // Radius of Pie chart, e.g.: 40% Radius string MarkPoint SeriesMarkPoint + MarkLine SeriesMarkLine } type seriesSummary struct { diff --git a/xaxis.go b/xaxis.go index 5c21c14..cb7cf33 100644 --- a/xaxis.go +++ b/xaxis.go @@ -77,8 +77,7 @@ func drawXAxis(p *Draw, opt *XAxisOption, yAxisCount int) (int, *Range, error) { } axis := NewAxis(dXAxis, data, style) axis.Render() - - return axis.measureAxis(), &Range{ + return axis.measure().Height, &Range{ divideCount: len(opt.Data), Min: 0, Max: max, diff --git a/yaxis.go b/yaxis.go index b978d08..cbce44f 100644 --- a/yaxis.go +++ b/yaxis.go @@ -62,7 +62,7 @@ func drawYAxis(p *Draw, opt *ChartOption, axisIndex, xAxisHeight int, padding ch SplitLineColor: theme.GetAxisSplitLineColor(), SplitLineShow: true, } - width := NewAxis(p, data, style).measureAxis() + width := NewAxis(p, data, style).measure().Width yAxisCount := len(opt.YAxisList) boxWidth := p.Box.Width() diff --git a/yaxis_test.go b/yaxis_test.go index 5c66caa..0bbef7a 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -42,6 +42,7 @@ func TestDrawYAxis(t *testing.T) { tests := []struct { newDraw func() *Draw newOption func() *ChartOption + axisIndex int xAxisHeight int result string }{ @@ -70,11 +71,40 @@ func TestDrawYAxis(t *testing.T) { }, result: "\\n03.336.661013.3316.6620", }, + { + newDraw: newDraw, + newOption: func() *ChartOption { + return &ChartOption{ + YAxisList: []YAxisOption{ + {}, + { + Max: NewFloatPoint(20), + Formatter: "{value} C", + }, + }, + SeriesList: []Series{ + { + YAxisIndex: 1, + Data: []SeriesData{ + { + Value: 1, + }, + { + Value: 2, + }, + }, + }, + }, + } + }, + axisIndex: 1, + result: "\\n0 C3.33 C6.66 C10 C13.33 C16.66 C20 C", + }, } for _, tt := range tests { d := tt.newDraw() - r, err := drawYAxis(d, tt.newOption(), 0, tt.xAxisHeight, chart.NewBox(10, 10, 10, 10)) + r, err := drawYAxis(d, tt.newOption(), tt.axisIndex, tt.xAxisHeight, chart.NewBox(10, 10, 10, 10)) assert.Nil(err) assert.Equal(&Range{ divideCount: 6,