diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22e77a8..ce56fe7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,12 +14,12 @@ jobs: strategy: matrix: go: + - '1.22' + - '1.21' + - '1.20' + - '1.19' - '1.18' - '1.17' - - '1.16' - - '1.15' - - '1.14' - - '1.13' steps: - name: Go ${{ matrix.go }} test diff --git a/.gitignore b/.gitignore index 2e33342..57206ee 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ *.png *.svg tmp +NotoSansSC.ttf +.vscode \ No newline at end of file diff --git a/README.md b/README.md index 22d3205..0650395 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # go-charts +Clone from https://github.com/vicanso/go-charts + [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/vicanso/go-charts/blob/master/LICENSE) [![Build Status](https://github.com/vicanso/go-charts/workflows/Test/badge.svg)](https://github.com/vicanso/go-charts/actions) [中文](./README_zh.md) -`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. +`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. The default format is `png` and the default theme is `light`. `Apache ECharts` is popular among Front-end developers, so `go-charts` supports the option of `Apache ECharts`. Developers can generate charts almost the same as `Apache ECharts`. @@ -15,59 +17,55 @@ Screenshot of common charts, the left part is light theme, the right part is gra go-charts

+

+ go-table +

+ ## Chart Type -These chart types are supported: `line`, `bar`, `pie`, `radar` or `funnel`. +These chart types are supported: `line`, `bar`, `horizontal bar`, `pie`, `radar` or `funnel` and `table`. ## Example -The example is for `golang option` and `echarts option`, more examples can be found in the `./examples/` directory. +More examples can be found in the [./examples/](./examples/) directory. + +### Line Chart ```go package main import ( - "os" - "path/filepath" - - charts "github.com/vicanso/go-charts" + charts "git.smarteching.com/zeni/go-charts/v2" ) -func writeFile(file string, buf []byte) error { - tmpPath := "./tmp" - err := os.MkdirAll(tmpPath, 0700) - if err != nil { - return err - } - - file = filepath.Join(tmpPath, file) - err = os.WriteFile(file, buf, 0600) - if err != nil { - return err - } - return nil -} - -func chartsRender() ([]byte, error) { - d, err := charts.LineRender([][]float64{ +func main() { + values := [][]float64{ { - 150, + 120, + 132, + 101, + 134, + 90, 230, - 224, - 218, - 135, - 147, - 260, + 210, }, - }, - // output type - charts.PNGTypeOption(), - // title - charts.TitleOptionFunc(charts.TitleOption{ - Text: "Line", - }), - // x axis - charts.XAxisOptionFunc(charts.NewXAxisOption([]string{ + { + // snip... + }, + { + // snip... + }, + { + // snip... + }, + { + // snip... + }, + } + p, err := charts.LineRender( + values, + charts.TitleTextOptionFunc("Line"), + charts.XAxisDataOptionFunc([]string{ "Mon", "Tue", "Wed", @@ -75,16 +73,389 @@ func chartsRender() ([]byte, error) { "Fri", "Sat", "Sun", - })), + }), + charts.LegendLabelsOptionFunc([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, charts.PositionCenter), + ) + + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + // snip... +} +``` + +### Bar Chart + +```go +package main + +import ( + "git.smarteching.com/zeni/go-charts/v2" +) + +func main() { + values := [][]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, + }, + { + // snip... + }, + } + p, err := charts.BarRender( + values, + charts.XAxisDataOptionFunc([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + charts.LegendLabelsOptionFunc([]string{ + "Rainfall", + "Evaporation", + }, charts.PositionRight), + charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage), + charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax, + charts.SeriesMarkDataTypeMin), + // custom option func + func(opt *charts.ChartOption) { + opt.SeriesList[1].MarkPoint = charts.NewMarkPoint( + charts.SeriesMarkDataTypeMax, + charts.SeriesMarkDataTypeMin, + ) + opt.SeriesList[1].MarkLine = charts.NewMarkLine( + charts.SeriesMarkDataTypeAverage, + ) + }, ) if err != nil { - return nil, err + panic(err) } - return d.Bytes() -} -func echartsRender() ([]byte, error) { - return charts.RenderEChartsToPNG(`{ + buf, err := p.Bytes() + if err != nil { + panic(err) + } + // snip... +} +``` + +### Horizontal Bar Chart + +```go +package main + +import ( + "git.smarteching.com/zeni/go-charts/v2" +) + +func main() { + values := [][]float64{ + { + 18203, + 23489, + 29034, + 104970, + 131744, + 630230, + }, + { + // snip... + }, + } + p, err := charts.HorizontalBarRender( + values, + charts.TitleTextOptionFunc("World Population"), + charts.PaddingOptionFunc(charts.Box{ + Top: 20, + Right: 40, + Bottom: 20, + Left: 20, + }), + charts.LegendLabelsOptionFunc([]string{ + "2011", + "2012", + }), + charts.YAxisDataOptionFunc([]string{ + "Brazil", + "Indonesia", + "USA", + "India", + "China", + "World", + }), + ) + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + // snip... +} +``` + +### Pie Chart + +```go +package main + +import ( + "git.smarteching.com/zeni/go-charts/v2" +) + +func main() { + values := []float64{ + 1048, + 735, + 580, + 484, + 300, + } + p, err := charts.PieRender( + values, + charts.TitleOptionFunc(charts.TitleOption{ + Text: "Rainfall vs Evaporation", + Subtext: "Fake Data", + Left: charts.PositionCenter, + }), + charts.PaddingOptionFunc(charts.Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }), + charts.LegendOptionFunc(charts.LegendOption{ + Orient: charts.OrientVertical, + Data: []string{ + "Search Engine", + "Direct", + "Email", + "Union Ads", + "Video Ads", + }, + Left: charts.PositionLeft, + }), + charts.PieSeriesShowLabel(), + ) + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + // snip... +} +``` + +### Radar Chart + +```go +package main + +import ( + "git.smarteching.com/zeni/go-charts/v2" +) + +func main() { + values := [][]float64{ + { + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }, + { + // snip... + }, + } + p, err := charts.RadarRender( + values, + charts.TitleTextOptionFunc("Basic Radar Chart"), + charts.LegendLabelsOptionFunc([]string{ + "Allocated Budget", + "Actual Spending", + }), + charts.RadarIndicatorOptionFunc([]string{ + "Sales", + "Administration", + "Information Technology", + "Customer Support", + "Development", + "Marketing", + }, []float64{ + 6500, + 16000, + 30000, + 38000, + 52000, + 25000, + }), + ) + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + // snip... +} +``` + +### Funnel Chart + +```go +package main + +import ( + "git.smarteching.com/zeni/go-charts/v2" +) + +func main() { + values := []float64{ + 100, + 80, + 60, + 40, + 20, + } + p, err := charts.FunnelRender( + values, + charts.TitleTextOptionFunc("Funnel"), + charts.LegendLabelsOptionFunc([]string{ + "Show", + "Click", + "Visit", + "Inquiry", + "Order", + }), + ) + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + // snip... +} +``` + +### Table + +```go +package main + +import ( + "git.smarteching.com/zeni/go-charts/v2" +) + +func main() { + header := []string{ + "Name", + "Age", + "Address", + "Tag", + "Action", + } + data := [][]string{ + { + "John Brown", + "32", + "New York No. 1 Lake Park", + "nice, developer", + "Send Mail", + }, + { + "Jim Green ", + "42", + "London No. 1 Lake Park", + "wow", + "Send Mail", + }, + { + "Joe Black ", + "32", + "Sidney No. 1 Lake Park", + "cool, teacher", + "Send Mail", + }, + } + spans := map[int]int{ + 0: 2, + 1: 1, + // 设置第三列的span + 2: 3, + 3: 2, + 4: 2, + } + p, err := charts.TableRender( + header, + data, + spans, + ) + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + // snip... +} +``` + +### ECharts Render + +```go +package main + +import ( + "git.smarteching.com/zeni/go-charts/v2" +) + +func main() { + buf, err := charts.RenderEChartsToPNG(`{ "title": { "text": "Line" }, @@ -97,25 +468,7 @@ func echartsRender() ([]byte, error) { } ] }`) -} - -type Render func() ([]byte, error) - -func main() { - m := map[string]Render{ - "charts-line.png": chartsRender, - "echarts-line.png": echartsRender, - } - for name, fn := range m { - buf, err := fn() - if err != nil { - panic(err) - } - err = writeFile(name, buf) - if err != nil { - panic(err) - } - } + // snip... } ``` diff --git a/README_zh.md b/README_zh.md index 1589923..3f35b97 100644 --- a/README_zh.md +++ b/README_zh.md @@ -3,7 +3,7 @@ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/vicanso/go-charts/blob/master/LICENSE) [![Build Status](https://github.com/vicanso/go-charts/workflows/Test/badge.svg)](https://github.com/vicanso/go-charts/actions) -`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持主题`light`, `dark`, `grafana`以及`ant`。 +`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持主题`light`, `dark`, `grafana`以及`ant`。默认的输入格式为`png`,默认主题为`light`。 `Apache ECharts`在前端开发中得到众多开发者的认可,因此`go-charts`提供了兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的图表截图(主题为light与grafana): @@ -12,62 +12,57 @@ go-charts

+

+ go-table +

> 1 - } else { - y += b.Height() >> 1 - } - // 左右位置的x不一样 - x := width - renderOpt.textMaxWith - if position == PositionLeft { - x = labelWidth - b.Width() - 1 - } - d.text(text, x, y) - } + padding.Left = top.Width() - width default: - // 定位bottom,重新计算y0的定位 - y0 := height - labelMargin - if position == PositionTop { - y0 = labelHeight - labelMargin - } - values := autoDivide(width, count) - for index, text := range data.TextList() { - if unitCount != 0 && index%unitCount != modValue { - continue - } - x := values[index] - leftOffset := 0 - b := r.MeasureText(text) - if boundaryGap { - width := values[index+1] - x - leftOffset = (width - b.Width()) >> 1 - } else { - // 左移文本长度 - leftOffset = -b.Width() >> 1 - } - d.text(text, x+leftOffset, y0) - } + padding.Top = top.Height() - defaultXAxisHeight } -} -func (a *axis) axisLine(renderOpt *axisRenderOption) { - d := a.d - r := d.Render - option := a.option - s := option.Style(d.Font) - s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) + p := top.Child(PainterPaddingOption(padding)) x0 := 0 y0 := 0 x1 := 0 y1 := 0 - width := d.Box.Width() - height := d.Box.Height() - labelMargin := option.GetLabelMargin() + ticksPaddingTop := 0 + ticksPaddingLeft := 0 + labelPaddingTop := 0 + labelPaddingLeft := 0 + labelPaddingRight := 0 + orient := "" + textAlign := "" - // 轴线 - labelHeight := labelMargin + renderOpt.textMaxHeight - labelWidth := labelMargin + renderOpt.textMaxWith - tickLength := option.GetTickLength() - switch option.Position { - case PositionLeft: - x0 = tickLength + labelWidth - x1 = x0 - y0 = 0 - y1 = height - case PositionRight: - x0 = width - labelWidth - x1 = x0 - y0 = 0 - y1 = height + switch opt.Position { case PositionTop: - x0 = 0 - x1 = width - y0 = labelHeight + labelPaddingTop = 0 + x1 = p.Width() + y0 = labelMargin + int(opt.FontSize) + ticksPaddingTop = int(opt.FontSize) y1 = y0 - // bottom - default: - x0 = 0 - x1 = width - y0 = height - tickLength - labelHeight - y1 = y0 - } - - d.moveTo(x0, y0) - d.lineTo(x1, y1) - r.FillStroke() -} - -func (a *axis) axisTick(renderOpt *axisRenderOption) { - d := a.d - r := d.Render - - option := a.option - s := option.Style(d.Font) - s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) - - width := d.Box.Width() - height := d.Box.Height() - data := *a.data - tickCount := len(data) - if tickCount == 0 { - return - } - if !renderOpt.boundaryGap { - tickCount-- - } - labelMargin := option.GetLabelMargin() - tickShow := true - if isFalse(option.TickShow) { - tickShow = false - } - unitCount := renderOpt.unitCount - - tickLengthValue := option.GetTickLength() - labelHeight := labelMargin + renderOpt.textMaxHeight - labelWidth := labelMargin + renderOpt.textMaxWith - position := option.Position - switch position { + orient = OrientHorizontal case PositionLeft: - fallthrough + x0 = p.Width() + y0 = 0 + x1 = p.Width() + y1 = p.Height() + orient = OrientVertical + textAlign = AlignRight + ticksPaddingLeft = textMaxWidth + tickLength + labelPaddingRight = width - textMaxWidth case PositionRight: - values := autoDivide(height, tickCount) - // 左右仅是x0的位置不一样 - x0 := width - labelWidth - if option.Position == PositionLeft { - x0 = labelWidth - } - if tickShow { - for _, v := range values { - x := x0 - y := v - d.moveTo(x, y) - d.lineTo(x+tickLengthValue, y) - r.Stroke() - } - } - // 辅助线 - if option.SplitLineShow && !option.SplitLineColor.IsZero() { - r.SetStrokeColor(option.SplitLineColor) - splitLineWidth := width - labelWidth - tickLengthValue - x0 = labelWidth + tickLengthValue - if position == PositionRight { + orient = OrientVertical + y1 = p.Height() + labelPaddingLeft = width - textMaxWidth + default: + labelPaddingTop = height + x1 = p.Width() + orient = OrientHorizontal + } + + if strokeWidth > 0 { + p.Child(PainterPaddingOption(Box{ + Top: ticksPaddingTop, + Left: ticksPaddingLeft, + })).Ticks(TicksOption{ + Count: tickCount, + Length: tickLength, + Unit: unit, + Orient: orient, + First: opt.FirstAxis, + }) + p.LineStroke([]Point{ + { + X: x0, + Y: y0, + }, + { + X: x1, + Y: y1, + }, + }) + } + + p.Child(PainterPaddingOption(Box{ + Left: labelPaddingLeft, + Top: labelPaddingTop, + Right: labelPaddingRight, + })).MultiText(MultiTextOption{ + First: opt.FirstAxis, + Align: textAlign, + TextList: data, + Orient: orient, + Unit: unit, + Position: labelPosition, + TextRotation: opt.TextRotation, + Offset: opt.LabelOffset, + }) + // 显示辅助线 + if opt.SplitLineShow { + style.StrokeColor = opt.SplitLineColor + style.StrokeWidth = 1 + top.OverrideDrawingStyle(style) + if isVertical { + x0 := p.Width() + x1 := top.Width() + if opt.Position == PositionRight { x0 = 0 - splitLineWidth = width - labelWidth - 1 + x1 = top.Width() - p.Width() } - for _, v := range values[0 : len(values)-1] { - x := x0 - y := v - d.moveTo(x, y) - d.lineTo(x+splitLineWidth, y) - r.Stroke() + yValues := autoDivide(height, tickCount) + yValues = yValues[0 : len(yValues)-1] + for _, y := range yValues { + top.LineStroke([]Point{ + { + X: x0, + Y: y, + }, + { + X: x1, + Y: y, + }, + }) } - } - default: - values := autoDivide(width, tickCount) - // 上下仅是y0的位置不一样 - y0 := height - labelHeight - if position == PositionTop { - y0 = labelHeight - } - if tickShow { - for index, v := range values { - if index%unitCount != 0 { + } else { + y0 := p.Height() - defaultXAxisHeight + y1 := top.Height() - defaultXAxisHeight + for index, x := range autoDivide(width, tickCount) { + if index == 0 { continue } - x := v - y := y0 - d.moveTo(x, y-tickLengthValue) - d.lineTo(x, y) - r.Stroke() - } - } - // 辅助线 - if option.SplitLineShow && !option.SplitLineColor.IsZero() { - r.SetStrokeColor(option.SplitLineColor) - y0 = 0 - splitLineHeight := height - labelHeight - tickLengthValue - if position == PositionTop { - y0 = labelHeight - splitLineHeight = height - labelHeight - } - - for index, v := range values { - if index%unitCount != 0 { - continue - } - x := v - y := y0 - - d.moveTo(x, y) - d.lineTo(x, y0+splitLineHeight) - r.Stroke() + top.LineStroke([]Point{ + { + X: x, + Y: y0, + }, + { + X: x, + Y: y1, + }, + }) } } } -} - -func (a *axis) measureTextMaxWidthHeight() (int, int) { - d := a.d - r := d.Render - s := a.option.Style(d.Font) - data := a.data - s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) - s.GetTextOptions().WriteTextOptionsToRenderer(r) - return measureTextMaxWidthHeight(data.TextList(), r) -} - -// 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 { - option := a.option - value := option.GetLabelMargin() + option.GetTickLength() - textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight() - info := axisMeasurement{} - if option.Position == PositionLeft || - option.Position == PositionRight { - info.Width = textMaxWidth + value - } else { - info.Height = textMaxHeight + value - } - return info -} - -// Render renders the axis for chart -func (a *axis) Render() { - option := a.option - if isFalse(option.Show) { - return - } - textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight() - opt := &axisRenderOption{ - textMaxWith: textMaxWidth, - textMaxHeight: textMaxHeight, - boundaryGap: true, - } - if isFalse(option.BoundaryGap) { - opt.boundaryGap = false - } - - unitCount := chart.MaxInt(option.SplitNumber, 1) - width := a.d.Box.Width() - textList := a.data.TextList() - count := len(textList) - - position := option.Position - switch position { - case PositionLeft: - fallthrough - case PositionRight: - default: - maxCount := width / (opt.textMaxWith + 10) - // 可以显示所有 - if maxCount >= count { - unitCount = 1 - } else if maxCount < count/unitCount { - unitCount = int(math.Ceil(float64(count) / float64(maxCount))) - } - } - - boundaryGap := opt.boundaryGap - modValue := 0 - if boundaryGap && unitCount > 1 { - // 如果是居中,unit count需要设置为奇数 - if unitCount%2 == 0 { - unitCount++ - } - modValue = unitCount / 2 - } - opt.modValue = modValue - opt.unitCount = unitCount - - // 坐标轴线 - a.axisLine(opt) - a.axisTick(opt) - // 坐标文本 - a.axisLabel(opt) + + return Box{ + Bottom: height, + Right: width, + }, nil } diff --git a/axis_test.go b/axis_test.go index 37c8314..85e18ca 100644 --- a/axis_test.go +++ b/axis_test.go @@ -25,235 +25,149 @@ package charts import ( "testing" - "github.com/golang/freetype/truetype" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) -func TestAxisOption(t *testing.T) { - assert := assert.New(t) - - as := AxisOption{} - - assert.Equal(8, as.GetLabelMargin()) - as.LabelMargin = 10 - assert.Equal(10, as.GetLabelMargin()) - - assert.Equal(5, as.GetTickLength()) - as.TickLength = 6 - assert.Equal(6, as.GetTickLength()) - - f := &truetype.Font{} - style := as.Style(f) - assert.Equal(float64(10), style.FontSize) - assert.Equal(f, style.Font) -} - -func TestAxisDataList(t *testing.T) { - assert := assert.New(t) - - textList := []string{ - "a", - "b", - } - data := NewAxisDataListFromStringList(textList) - assert.Equal(textList, data.TextList()) -} - func TestAxis(t *testing.T) { assert := assert.New(t) - - axisData := NewAxisDataListFromStringList([]string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", - }) - getDefaultOption := func() AxisOption { - return AxisOption{ - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, - FontColor: drawing.ColorBlack, - Show: TrueFlag(), - TickShow: TrueFlag(), - SplitLineShow: true, - SplitLineColor: drawing.ColorBlack.WithAlpha(60), - } - } tests := []struct { - newOption func() AxisOption - newData func() AxisDataList - result string + render func(*Painter) ([]byte, error) + result string }{ - // 文本按起始位置展示 - // axis位于bottom + // 底部x轴 { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.BoundaryGap = FalseFlag() - return opt + render: func(p *Painter) ([]byte, error) { + _, _ = NewAxisPainter(p, AxisOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + SplitLineShow: true, + SplitLineColor: drawing.ColorBlack, + }).Render() + return p.Bytes() }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 文本居中展示 - // axis位于bottom + // 底部x轴文本居左 { - newOption: func() AxisOption { - opt := getDefaultOption() - return opt + render: func(p *Painter) ([]byte, error) { + _, _ = NewAxisPainter(p, AxisOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + BoundaryGap: FalseFlag(), + }).Render() + return p.Bytes() }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 文本按起始位置展示 - // axis位于top + // 左侧y轴 { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionTop - opt.BoundaryGap = FalseFlag() - return opt + render: func(p *Painter) ([]byte, error) { + _, _ = NewAxisPainter(p, AxisOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + Position: PositionLeft, + }).Render() + return p.Bytes() }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 文本居中展示 - // axis位于top + // 左侧y轴居中 { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionTop - return opt + render: func(p *Painter) ([]byte, error) { + _, _ = NewAxisPainter(p, AxisOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + Position: PositionLeft, + BoundaryGap: FalseFlag(), + SplitLineShow: true, + SplitLineColor: drawing.ColorBlack, + }).Render() + return p.Bytes() }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 文本按起始位置展示 - // axis位于left + // 右侧 { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionLeft - opt.BoundaryGap = FalseFlag() - return opt + render: func(p *Painter) ([]byte, error) { + _, _ = NewAxisPainter(p, AxisOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + Position: PositionRight, + BoundaryGap: FalseFlag(), + SplitLineShow: true, + SplitLineColor: drawing.ColorBlack, + }).Render() + return p.Bytes() }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 文本居中展示 - // axis位于left + // 顶部 { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionLeft - return opt + render: func(p *Painter) ([]byte, error) { + _, _ = NewAxisPainter(p, AxisOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + Formatter: "{value} --", + Position: PositionTop, + }).Render() + return p.Bytes() }, - result: "\\nMonTueWedThuFriSatSun", - }, - // 文本按起始位置展示 - // axis位于right - { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionRight - opt.BoundaryGap = FalseFlag() - return opt - }, - result: "\\nMonTueWedThuFriSatSun", - }, - // 文本居中展示 - // axis位于right - { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionRight - return opt - }, - result: "\\nMonTueWedThuFriSatSun", - }, - // text较多,仅展示部分 - { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionBottom - return opt - }, - newData: func() AxisDataList { - return NewAxisDataListFromStringList([]string{ - "01-01", - "01-02", - "01-03", - "01-04", - "01-05", - "01-06", - "01-07", - "01-08", - "01-09", - "01-10", - "01-11", - "01-12", - "01-13", - "01-14", - "01-15", - "01-16", - "01-17", - "01-18", - "01-19", - "01-20", - "01-21", - }) - }, - result: "\\n01-0201-0501-0801-1101-1401-1701-20", + result: "\\nMon --Tue --Wed --Thu --Fri --Sat --Sun --", }, } + for _, tt := range tests { - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }, PaddingOption(chart.Box{ - Left: 5, - Top: 5, - Right: 5, - Bottom: 5, - })) + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) assert.Nil(err) - style := tt.newOption() - data := axisData - if tt.newData != nil { - data = tt.newData() - } - NewAxis(d, data, style).Render() - - result, err := d.Bytes() + data, err := tt.render(p) assert.Nil(err) - assert.Equal(tt.result, string(result)) + assert.Equal(tt.result, string(data)) } } - -func TestMeasureAxis(t *testing.T) { - assert := assert.New(t) - - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - assert.Nil(err) - data := NewAxisDataListFromStringList([]string{ - "Mon", - "Sun", - }) - f, _ := chart.GetDefaultFont() - width := NewAxis(d, data, AxisOption{ - FontSize: 12, - Font: f, - Position: PositionLeft, - }).measure().Width - assert.Equal(44, width) - - height := NewAxis(d, data, AxisOption{ - FontSize: 12, - Font: f, - Position: PositionTop, - }).measure().Height - assert.Equal(28, height) -} diff --git a/bar_chart.go b/bar_chart.go index 32373b1..043e044 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -23,31 +23,60 @@ package charts import ( + "math" + "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2" ) -type barChartOption struct { - // The series list fo bar chart - SeriesList SeriesList - // The theme - Theme string - // The font - Font *truetype.Font +type barChart struct { + p *Painter + opt *BarChartOption } -func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointRenderOption, error) { - d, err := NewDraw(DrawOption{ - Parent: result.d, - }, PaddingOption(chart.Box{ - Top: result.titleBox.Height(), - // TODO 后续考虑是否需要根据左侧是否展示y轴再生成对应的left - Left: YAxisWidth, - })) - if err != nil { - return nil, err +// NewBarChart returns a bar chart renderer +func NewBarChart(p *Painter, opt BarChartOption) *barChart { + if opt.Theme == nil { + opt.Theme = defaultTheme } - xRange := result.xRange + return &barChart{ + p: p, + opt: &opt, + } +} + +type BarChartOption struct { + // The theme + 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 + BarWidth int + // Margin of bar + BarMargin int +} + +func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + p := b.p + opt := b.opt + seriesPainter := result.seriesPainter + + xRange := NewRange(AxisRangeOption{ + Painter: b.p, + DivideCount: len(opt.XAxis.Data), + Size: seriesPainter.Width(), + }) x0, x1 := xRange.GetRange(0) width := int(x1 - x0) // 每一块之间的margin @@ -61,50 +90,54 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR margin = 5 barMargin = 3 } - - seriesCount := len(opt.SeriesList) + if opt.BarMargin > 0 { + barMargin = opt.BarMargin + } + seriesCount := len(seriesList) // 总的宽度-两个margin-(总数-1)的barMargin - barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / len(opt.SeriesList) + barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / seriesCount + if opt.BarWidth > 0 && opt.BarWidth < barWidth { + barWidth = opt.BarWidth + // 重新计算margin + margin = (width - seriesCount*barWidth - barMargin*(seriesCount-1)) / 2 + } + barMaxHeight := seriesPainter.Height() + theme := opt.Theme + seriesNames := seriesList.Names() - barMaxHeight := result.getYRange(0).Size - theme := NewTheme(opt.Theme) + markPointPainter := NewMarkPointPainter(seriesPainter) + markLinePainter := NewMarkLinePainter(seriesPainter) + rendererList := []Renderer{ + markPointPainter, + markLinePainter, + } + for index := range seriesList { + series := seriesList[index] + yRange := result.axisRanges[series.AxisIndex] + seriesColor := theme.GetSeriesColor(series.index) - seriesNames := opt.SeriesList.Names() - - r := d.Render - - 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)) - 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, - }) divideValues := xRange.AutoDivide() + points := make([]Point, len(series.Data)) + var labelPainter *SeriesLabelPainter + if series.Label.Show { + labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{ + P: seriesPainter, + SeriesNames: seriesNames, + Label: series.Label, + Theme: opt.Theme, + Font: opt.Font, + }) + rendererList = append(rendererList, labelPainter) + } + for j, item := range series.Data { if j >= xRange.divideCount { continue } 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)) @@ -113,14 +146,32 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR fillColor = item.Style.FillColor } top := barMaxHeight - h - d.Bar(chart.Box{ - Top: top, - Left: x, - Right: x + barWidth, - Bottom: barMaxHeight - 1, - }, BarStyle{ - FillColor: fillColor, - }) + + if series.RoundRadius <= 0 { + seriesPainter.OverrideDrawingStyle(Style{ + FillColor: fillColor, + }).Rect(chart.Box{ + Top: top, + Left: x, + Right: x + barWidth, + Bottom: barMaxHeight - 1, + }) + } else { + seriesPainter.OverrideDrawingStyle(Style{ + FillColor: fillColor, + }).RoundedRect(chart.Box{ + Top: top, + Left: x, + Right: x + barWidth, + Bottom: barMaxHeight - 1, + }, series.RoundRadius) + } + // 用于生成marker point + points[j] = Point{ + // 居中的位置 + X: x + barWidth>>1, + Y: top, + } // 用于生成marker point points[j] = Point{ // 居中的位置 @@ -128,36 +179,75 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR Y: top, } // 如果label不需要展示,则返回 - if !series.Label.Show { + if labelPainter == nil { continue } - distance := series.Label.Distance - if distance == 0 { - distance = 5 + y := barMaxHeight - h + radians := float64(0) + fontColor := series.Label.Color + if series.Label.Position == PositionBottom { + y = barMaxHeight + radians = -math.Pi / 2 + if fontColor.IsZero() { + if isLightColor(fillColor) { + fontColor = defaultLightFontColor + } else { + fontColor = defaultDarkFontColor + } + } } - text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) - labelStyle := chart.Style{ - FontColor: theme.GetTextColor(), - FontSize: labelFontSize, - Font: opt.Font, - } - if !series.Label.Color.IsZero() { - labelStyle.FontColor = series.Label.Color - } - labelStyle.GetTextOptions().WriteToRenderer(r) - textBox := r.MeasureText(text) - d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance) + labelPainter.Add(LabelValue{ + Index: index, + Value: item.Value, + X: x + barWidth>>1, + Y: y, + // 旋转 + Radians: radians, + FontColor: fontColor, + Offset: series.Label.Offset, + FontSize: series.Label.FontSize, + }) } - // 生成mark point的参数 - markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{ - Draw: d, + markPointPainter.Add(markPointRenderOption{ FillColor: seriesColor, Font: opt.Font, + Series: series, Points: points, - Series: &series, + }) + 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 BoxZero, err + } - return markPointRenderOptions, nil + return p.box, nil +} + +func (b *barChart) Render() (Box, error) { + p := b.p + opt := b.opt + renderResult, err := defaultRender(p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: opt.XAxis, + YAxisOptions: opt.YAxisOptions, + TitleOption: opt.Title, + LegendOption: opt.Legend, + }) + if err != nil { + return BoxZero, err + } + seriesList := opt.SeriesList.Filter(ChartTypeLine) + return b.render(renderResult, seriesList) } diff --git a/bar_chart_test.go b/bar_chart_test.go index f10a1bc..654c320 100644 --- a/bar_chart_test.go +++ b/bar_chart_test.go @@ -26,106 +26,165 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestBarChartRender(t *testing.T) { +func TestBarChart(t *testing.T) { assert := assert.New(t) - width := 400 - height := 300 - d, err := NewDraw(DrawOption{ - Width: width, - Height: height, - }) - assert.Nil(err) - - result := basicRenderResult{ - xRange: &Range{ - Min: 0, - Max: 4, - divideCount: 4, - Size: width, - Boundary: true, - }, - yRangeList: []*Range{ - { - divideCount: 6, - Max: 100, - Min: 0, - Size: height, + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + seriesList := NewSeriesListDataFromValues([][]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, + }, + { + 2.6, + 5.9, + 9.0, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6.0, + 2.3, + }, + }) + for index := range seriesList { + seriesList[index].Label.Show = true + } + _, err := NewBarChart(p, BarChartOption{ + Padding: Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, + SeriesList: seriesList, + XAxis: NewXAxisOption([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + YAxisOptions: NewYAxisOptions([]string{ + "Rainfall", + "Evaporation", + }), + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\n24020016012080400FebMayAugNov24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + }, + { + render: func(p *Painter) ([]byte, error) { + seriesList := NewSeriesListDataFromValues([][]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, + }, + { + 2.6, + 5.9, + 9.0, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6.0, + 2.3, + }, + }) + for index := range seriesList { + seriesList[index].Label.Show = true + seriesList[index].RoundRadius = 5 + } + _, err := NewBarChart(p, BarChartOption{ + Padding: Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, + SeriesList: seriesList, + XAxis: NewXAxisOption([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + YAxisOptions: NewYAxisOptions([]string{ + "Rainfall", + "Evaporation", + }), + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\n24020016012080400FebMayAugNov24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, - d: d, } - f, _ := chart.GetDefaultFont() - markPointOptions, err := barChartRender(barChartOption{ - Font: f, - SeriesList: SeriesList{ - { - Label: SeriesLabel{ - Show: true, - Color: drawing.ColorBlue, - }, - MarkLine: NewMarkLine( - SeriesMarkDataTypeMin, - ), - Data: []SeriesData{ - { - Value: 20, - }, - { - Value: 60, - Style: chart.Style{ - FillColor: drawing.ColorRed, - }, - }, - { - Value: 90, - }, - }, - }, - NewSeriesFromValues([]float64{ - 80, - 30, - 70, - }), - }, - }, &result) - assert.Nil(err) - assert.Equal(2, len(markPointOptions)) - assert.Equal([]Point{ - { - X: 28, - Y: 240, - }, - { - X: 128, - Y: 120, - }, - { - X: 228, - Y: 30, - }, - }, markPointOptions[0].Points) - assert.Equal([]Point{ - { - X: 70, - Y: 60, - }, - { - X: 170, - Y: 210, - }, - { - X: 270, - Y: 90, - }, - }, markPointOptions[1].Points) - - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n20206090", string(data)) + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } } diff --git a/bar_test.go b/bar_test.go deleted file mode 100644 index 01b6d3c..0000000 --- a/bar_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// 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 ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -func TestBarStyle(t *testing.T) { - assert := assert.New(t) - - bs := BarStyle{ - ClassName: "test", - StrokeDashArray: []float64{ - 1.0, - }, - FillColor: drawing.ColorBlack, - } - - assert.Equal(chart.Style{ - ClassName: "test", - StrokeDashArray: []float64{ - 1.0, - }, - StrokeWidth: 1, - FillColor: drawing.ColorBlack, - StrokeColor: drawing.ColorBlack, - }, bs.Style()) -} - -func TestDrawBar(t *testing.T) { - assert := assert.New(t) - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }, PaddingOption(chart.Box{ - Left: 10, - Top: 20, - Right: 30, - Bottom: 40, - })) - assert.Nil(err) - d.Bar(chart.Box{ - Left: 0, - Top: 0, - Right: 20, - Bottom: 200, - }, BarStyle{ - FillColor: drawing.ColorBlack, - }) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n", string(data)) -} diff --git a/chart.go b/chart.go deleted file mode 100644 index 21f2071..0000000 --- a/chart.go +++ /dev/null @@ -1,502 +0,0 @@ -// 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" - "sort" - "strings" - - "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -const ( - ChartTypeLine = "line" - ChartTypeBar = "bar" - ChartTypePie = "pie" - ChartTypeRadar = "radar" - ChartTypeFunnel = "funnel" -) - -const ( - ChartOutputSVG = "svg" - ChartOutputPNG = "png" -) - -type Point struct { - X int - Y int -} - -const labelFontSize = 10 -const defaultDotWidth = 2.0 -const defaultStrokeWidth = 2.0 - -var defaultChartWidth = 600 -var defaultChartHeight = 400 - -type ChartOption struct { - // The output type of chart, "svg" or "png", default value is "svg" - Type string - // The font family, which should be installed first - FontFamily string - // The font of chart, the default font is "roboto" - Font *truetype.Font - // The theme of chart, "light" and "dark". - // The default theme is "light" - Theme string - // The title option - Title TitleOption - // The legend option - Legend LegendOption - // The x axis option - XAxis XAxisOption - // The y axis option list - YAxisList []YAxisOption - // The width of chart, default width is 600 - Width int - // The height of chart, default height is 400 - Height int - Parent *Draw - // The padding for chart, default padding is [20, 10, 10, 10] - Padding chart.Box - // The canvas box for chart - Box chart.Box - // The series list - SeriesList SeriesList - // The radar indicator list - RadarIndicators []RadarIndicator - // The background color of chart - BackgroundColor drawing.Color - // The child charts - Children []ChartOption -} - -// FillDefault fills the default value for chart option -func (o *ChartOption) FillDefault(theme string) { - t := NewTheme(theme) - // 如果为空,初始化 - yAxisCount := 1 - for _, series := range o.SeriesList { - if series.YAxisIndex >= yAxisCount { - yAxisCount++ - } - } - yAxisList := make([]YAxisOption, yAxisCount) - copy(yAxisList, o.YAxisList) - o.YAxisList = yAxisList - - if o.Font == nil { - o.Font, _ = chart.GetDefaultFont() - } - if o.BackgroundColor.IsZero() { - o.BackgroundColor = t.GetBackgroundColor() - } - if o.Padding.IsZero() { - o.Padding = chart.Box{ - Top: 10, - Right: 10, - Bottom: 10, - Left: 10, - } - } - - // 标题的默认值 - if o.Title.Style.FontColor.IsZero() { - o.Title.Style.FontColor = t.GetTextColor() - } - if o.Title.Style.FontSize == 0 { - o.Title.Style.FontSize = 14 - } - if o.Title.Style.Font == nil { - o.Title.Style.Font = o.Font - } - if o.Title.Style.Padding.IsZero() { - o.Title.Style.Padding = chart.Box{ - Bottom: 10, - } - } - // 副标题 - if o.Title.SubtextStyle.FontColor.IsZero() { - o.Title.SubtextStyle.FontColor = o.Title.Style.FontColor.WithAlpha(180) - } - if o.Title.SubtextStyle.FontSize == 0 { - o.Title.SubtextStyle.FontSize = labelFontSize - } - if o.Title.SubtextStyle.Font == nil { - o.Title.SubtextStyle.Font = o.Font - } - - o.Legend.theme = theme - if o.Legend.Style.FontSize == 0 { - o.Legend.Style.FontSize = labelFontSize - } - if o.Legend.Left == "" { - o.Legend.Left = PositionCenter - } - // legend与series name的关联 - if len(o.Legend.Data) == 0 { - o.Legend.Data = o.SeriesList.Names() - } else { - seriesCount := len(o.SeriesList) - for index, name := range o.Legend.Data { - if index < seriesCount && - len(o.SeriesList[index].Name) == 0 { - o.SeriesList[index].Name = name - } - } - nameIndexDict := map[string]int{} - for index, name := range o.Legend.Data { - nameIndexDict[name] = index - } - // 保证series的顺序与legend一致 - sort.Slice(o.SeriesList, func(i, j int) bool { - return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name] - }) - } - // 如果无legend数据,则隐藏 - if len(strings.Join(o.Legend.Data, "")) == 0 { - o.Legend.Show = FalseFlag() - } - if o.Legend.Style.Font == nil { - o.Legend.Style.Font = o.Font - } - if o.Legend.Style.FontColor.IsZero() { - o.Legend.Style.FontColor = t.GetTextColor() - } - if o.XAxis.Theme == "" { - o.XAxis.Theme = theme - } - o.XAxis.Font = o.Font -} - -func (o *ChartOption) getWidth() int { - if o.Width != 0 { - return o.Width - } - if o.Parent != nil { - return o.Parent.Box.Width() - } - return defaultChartWidth -} - -func SetDefaultWidth(width int) { - if width > 0 { - defaultChartWidth = width - } -} -func SetDefaultHeight(height int) { - if height > 0 { - defaultChartHeight = height - } -} - -func (o *ChartOption) getHeight() int { - - if o.Height != 0 { - return o.Height - } - if o.Parent != nil { - return o.Parent.Box.Height() - } - return defaultChartHeight -} - -func (o *ChartOption) newYRange(axisIndex int) Range { - min := math.MaxFloat64 - max := -math.MaxFloat64 - if axisIndex >= len(o.YAxisList) { - axisIndex = 0 - } - yAxis := o.YAxisList[axisIndex] - - for _, series := range o.SeriesList { - if series.YAxisIndex != axisIndex { - continue - } - for _, item := range series.Data { - if item.Value > max { - max = item.Value - } - if item.Value < min { - min = item.Value - } - } - } - min = min * 0.9 - max = max * 1.1 - if yAxis.Min != nil { - min = *yAxis.Min - } - if yAxis.Max != nil { - max = *yAxis.Max - } - divideCount := 6 - // y轴分设置默认划分为6块 - r := NewRange(min, max, divideCount) - - // 由于NewRange会重新计算min max - if yAxis.Min != nil { - r.Min = min - } - if yAxis.Max != nil { - r.Max = max - } - - return r -} - -type basicRenderResult struct { - xRange *Range - yRangeList []*Range - d *Draw - titleBox chart.Box -} - -func (r *basicRenderResult) getYRange(index int) *Range { - if index >= len(r.yRangeList) { - index = 0 - } - return r.yRangeList[index] -} - -// Render renders the chart by option -func Render(opt ChartOption, optFuncs ...OptionFunc) (*Draw, error) { - for _, optFunc := range optFuncs { - optFunc(&opt) - } - if len(opt.SeriesList) == 0 { - return nil, errors.New("series can not be nil") - } - if len(opt.FontFamily) != 0 { - f, err := GetFont(opt.FontFamily) - if err != nil { - return nil, err - } - opt.Font = f - } - opt.FillDefault(opt.Theme) - - lineSeries := make([]Series, 0) - barSeries := make([]Series, 0) - isPieChart := false - isRadarChart := false - isFunnelChart := false - for index := range opt.SeriesList { - opt.SeriesList[index].index = index - item := opt.SeriesList[index] - switch item.Type { - case ChartTypePie: - isPieChart = true - case ChartTypeRadar: - isRadarChart = true - case ChartTypeFunnel: - isFunnelChart = true - case ChartTypeBar: - barSeries = append(barSeries, item) - default: - lineSeries = append(lineSeries, item) - } - } - // 如果指定了pie,则以pie的形式处理,pie不支持多类型图表 - // pie不需要axis - // radar 同样处理 - if isPieChart || - isRadarChart || - isFunnelChart { - opt.XAxis.Hidden = true - for index := range opt.YAxisList { - opt.YAxisList[index].Hidden = true - } - } - result, err := chartBasicRender(&opt) - if err != nil { - return nil, err - } - markPointRenderOptions := make([]markPointRenderOption, 0) - fns := []func() error{ - // pie render - func() error { - if !isPieChart { - return nil - } - return pieChartRender(pieChartOption{ - SeriesList: opt.SeriesList, - Theme: opt.Theme, - Font: opt.Font, - }, result) - }, - // radar render - func() error { - if !isRadarChart { - return nil - } - return radarChartRender(radarChartOption{ - SeriesList: opt.SeriesList, - Theme: opt.Theme, - Font: opt.Font, - Indicators: opt.RadarIndicators, - }, result) - }, - // funnel render - func() error { - if !isFunnelChart { - return nil - } - return funnelChartRender(funnelChartOption{ - SeriesList: opt.SeriesList, - Theme: opt.Theme, - Font: opt.Font, - }, result) - }, - // bar render - func() error { - // 如果无bar类型的series - if len(barSeries) == 0 { - return nil - } - options, err := barChartRender(barChartOption{ - SeriesList: barSeries, - Theme: opt.Theme, - Font: opt.Font, - }, result) - if err != nil { - return err - } - markPointRenderOptions = append(markPointRenderOptions, options...) - return nil - }, - // line render - func() error { - // 如果无line类型的series - if len(lineSeries) == 0 { - return nil - } - options, err := lineChartRender(lineChartOption{ - Theme: opt.Theme, - SeriesList: lineSeries, - Font: opt.Font, - }, result) - if err != nil { - return err - } - markPointRenderOptions = append(markPointRenderOptions, options...) - return nil - }, - // 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 { - err = fn() - if err != nil { - return nil, err - } - } - for _, child := range opt.Children { - child.Parent = result.d - if len(child.Theme) == 0 { - child.Theme = opt.Theme - } - _, err = Render(child) - if err != nil { - return nil, err - } - } - return result.d, nil -} - -func chartBasicRender(opt *ChartOption) (*basicRenderResult, error) { - d, err := NewDraw( - DrawOption{ - Type: opt.Type, - Parent: opt.Parent, - Width: opt.getWidth(), - Height: opt.getHeight(), - }, - BoxOption(opt.Box), - PaddingOption(opt.Padding), - ) - if err != nil { - return nil, err - } - - if len(opt.YAxisList) > 2 { - return nil, errors.New("y axis should not be gt 2") - } - if opt.Parent == nil { - d.setBackground(opt.getWidth(), opt.getHeight(), opt.BackgroundColor) - } - - // 标题 - titleBox, err := drawTitle(d, &opt.Title) - if err != nil { - return nil, err - } - - xAxisHeight := 0 - var xRange *Range - - if !opt.XAxis.Hidden { - // xAxis - xAxisHeight, xRange, err = drawXAxis(d, &opt.XAxis, len(opt.YAxisList)) - if err != nil { - return nil, err - } - } - - yRangeList := make([]*Range, len(opt.YAxisList)) - - for index, yAxis := range opt.YAxisList { - var yRange *Range - if !yAxis.Hidden { - yRange, err = drawYAxis(d, opt, index, xAxisHeight, chart.Box{ - Top: titleBox.Height(), - }) - if err != nil { - return nil, err - } - yRangeList[index] = yRange - } - } - return &basicRenderResult{ - xRange: xRange, - yRangeList: yRangeList, - d: d, - titleBox: titleBox, - }, nil -} diff --git a/chart_option.go b/chart_option.go index 5e25873..d80a383 100644 --- a/chart_option.go +++ b/chart_option.go @@ -23,13 +23,72 @@ package charts import ( - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "sort" + + "github.com/golang/freetype/truetype" ) +type ChartOption struct { + theme ColorPalette + font *truetype.Font + // The output type of chart, "svg" or "png", default value is "svg" + Type string + // The font family, which should be installed first + FontFamily string + // The theme of chart, "light" and "dark". + // The default theme is "light" + Theme string + // The title option + Title TitleOption + // The legend option + Legend LegendOption + // The x axis option + XAxis XAxisOption + // The y axis option list + YAxisOptions []YAxisOption + // The width of chart, default width is 600 + Width int + // The height of chart, default height is 400 + Height int + Parent *Painter + // The padding for chart, default padding is [20, 10, 10, 10] + Padding Box + // The canvas box for chart + Box Box + // The series list + SeriesList SeriesList + // The radar indicator list + RadarIndicators []RadarIndicator + // The background color of chart + BackgroundColor Color + // The flag for show symbol of line, set this to *false will hide symbol + SymbolShow *bool + // The stroke width of line chart + LineStrokeWidth float64 + // The bar with of bar chart + BarWidth int + // The margin of each bar + BarMargin int + // The bar height of horizontal bar chart + BarHeight int + // Fill the area of line chart + FillArea bool + // background fill (alpha) opacity + Opacity uint8 + // The child charts + Children []ChartOption + // The value formatter + ValueFormatter ValueFormatter +} + // OptionFunc option function type OptionFunc func(opt *ChartOption) +// SVGTypeOption set svg type of chart's output +func SVGTypeOption() OptionFunc { + return TypeOptionFunc(ChartOutputSVG) +} + // PNGTypeOption set png type of chart's output func PNGTypeOption() OptionFunc { return TypeOptionFunc(ChartOutputPNG) @@ -63,6 +122,16 @@ func TitleOptionFunc(title TitleOption) OptionFunc { } } +// TitleTextOptionFunc set title text of chart +func TitleTextOptionFunc(text string, subtext ...string) OptionFunc { + return func(opt *ChartOption) { + opt.Title.Text = text + if len(subtext) != 0 { + opt.Title.Subtext = subtext[0] + } + } +} + // LegendOptionFunc set legend of chart func LegendOptionFunc(legend LegendOption) OptionFunc { return func(opt *ChartOption) { @@ -70,6 +139,13 @@ func LegendOptionFunc(legend LegendOption) OptionFunc { } } +// LegendLabelsOptionFunc set legend labels of chart +func LegendLabelsOptionFunc(labels []string, left ...string) OptionFunc { + return func(opt *ChartOption) { + opt.Legend = NewLegendOption(labels, left...) + } +} + // XAxisOptionFunc set x axis of chart func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc { return func(opt *ChartOption) { @@ -77,10 +153,24 @@ func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc { } } +// XAxisDataOptionFunc set x axis data of chart +func XAxisDataOptionFunc(data []string, boundaryGap ...*bool) OptionFunc { + return func(opt *ChartOption) { + opt.XAxis = NewXAxisOption(data, boundaryGap...) + } +} + // YAxisOptionFunc set y axis of chart, support two y axis func YAxisOptionFunc(yAxisOption ...YAxisOption) OptionFunc { return func(opt *ChartOption) { - opt.YAxisList = yAxisOption + opt.YAxisOptions = yAxisOption + } +} + +// YAxisDataOptionFunc set y axis data of chart +func YAxisDataOptionFunc(data []string) OptionFunc { + return func(opt *ChartOption) { + opt.YAxisOptions = NewYAxisOptions(data) } } @@ -99,19 +189,28 @@ func HeightOptionFunc(height int) OptionFunc { } // PaddingOptionFunc set padding of chart -func PaddingOptionFunc(padding chart.Box) OptionFunc { +func PaddingOptionFunc(padding Box) OptionFunc { return func(opt *ChartOption) { opt.Padding = padding } } // BoxOptionFunc set box of chart -func BoxOptionFunc(box chart.Box) OptionFunc { +func BoxOptionFunc(box Box) OptionFunc { return func(opt *ChartOption) { opt.Box = box } } +// PieSeriesShowLabel set pie series show label +func PieSeriesShowLabel() OptionFunc { + return func(opt *ChartOption) { + for index := range opt.SeriesList { + opt.SeriesList[index].Label.Show = true + } + } +} + // ChildOptionFunc add child chart func ChildOptionFunc(child ...ChartOption) OptionFunc { return func(opt *ChartOption) { @@ -123,68 +222,205 @@ func ChildOptionFunc(child ...ChartOption) OptionFunc { } // RadarIndicatorOptionFunc set radar indicator of chart -func RadarIndicatorOptionFunc(radarIndicator ...RadarIndicator) OptionFunc { +func RadarIndicatorOptionFunc(names []string, values []float64) OptionFunc { return func(opt *ChartOption) { - opt.RadarIndicators = radarIndicator + opt.RadarIndicators = NewRadarIndicators(names, values) } } // BackgroundColorOptionFunc set background color of chart -func BackgroundColorOptionFunc(color drawing.Color) OptionFunc { +func BackgroundColorOptionFunc(color Color) OptionFunc { return func(opt *ChartOption) { opt.BackgroundColor = color } } -// LineRender line chart render -func LineRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { - seriesList := make(SeriesList, len(values)) - for index, value := range values { - seriesList[index] = NewSeriesFromValues(value, ChartTypeLine) +// MarkLineOptionFunc set mark line for series of chart +func MarkLineOptionFunc(seriesIndex int, markLineTypes ...string) OptionFunc { + return func(opt *ChartOption) { + if len(opt.SeriesList) <= seriesIndex { + return + } + opt.SeriesList[seriesIndex].MarkLine = NewMarkLine(markLineTypes...) } +} + +// MarkPointOptionFunc set mark point for series of chart +func MarkPointOptionFunc(seriesIndex int, markPointTypes ...string) OptionFunc { + return func(opt *ChartOption) { + if len(opt.SeriesList) <= seriesIndex { + return + } + opt.SeriesList[seriesIndex].MarkPoint = NewMarkPoint(markPointTypes...) + } +} + +func (o *ChartOption) fillDefault() { + t := NewTheme(o.Theme) + o.theme = t + // 如果为空,初始化 + axisCount := 1 + for _, series := range o.SeriesList { + if series.AxisIndex >= axisCount { + axisCount++ + } + } + o.Width = getDefaultInt(o.Width, defaultChartWidth) + o.Height = getDefaultInt(o.Height, defaultChartHeight) + yAxisOptions := make([]YAxisOption, axisCount) + copy(yAxisOptions, o.YAxisOptions) + o.YAxisOptions = yAxisOptions + o.font, _ = GetFont(o.FontFamily) + + if o.font == nil { + o.font, _ = GetDefaultFont() + } else { + // 如果指定了字体,则设置主题的字体 + t.SetFont(o.font) + } + if o.BackgroundColor.IsZero() { + o.BackgroundColor = t.GetBackgroundColor() + } + if o.Padding.IsZero() { + o.Padding = Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + } + } + // legend与series name的关联 + if len(o.Legend.Data) == 0 { + o.Legend.Data = o.SeriesList.Names() + } else { + seriesCount := len(o.SeriesList) + for index, name := range o.Legend.Data { + if index < seriesCount && + len(o.SeriesList[index].Name) == 0 { + o.SeriesList[index].Name = name + } + } + nameIndexDict := map[string]int{} + for index, name := range o.Legend.Data { + nameIndexDict[name] = index + } + // 保证series的顺序与legend一致 + sort.Slice(o.SeriesList, func(i, j int) bool { + return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name] + }) + } +} + +// LineRender line chart render +func LineRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { + seriesList := NewSeriesListDataFromValues(values, ChartTypeLine) return Render(ChartOption{ SeriesList: seriesList, }, opts...) } // BarRender bar chart render -func BarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { - seriesList := make(SeriesList, len(values)) - for index, value := range values { - seriesList[index] = NewSeriesFromValues(value, ChartTypeBar) - } +func BarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { + seriesList := NewSeriesListDataFromValues(values, ChartTypeBar) + return Render(ChartOption{ + SeriesList: seriesList, + }, opts...) +} + +// HorizontalBarRender horizontal bar chart render +func HorizontalBarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { + seriesList := NewSeriesListDataFromValues(values, ChartTypeHorizontalBar) return Render(ChartOption{ SeriesList: seriesList, }, opts...) } // PieRender pie chart render -func PieRender(values []float64, opts ...OptionFunc) (*Draw, error) { +func PieRender(values []float64, opts ...OptionFunc) (*Painter, error) { return Render(ChartOption{ SeriesList: NewPieSeriesList(values), }, opts...) } // RadarRender radar chart render -func RadarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { - seriesList := make(SeriesList, len(values)) - for index, value := range values { - seriesList[index] = NewSeriesFromValues(value, ChartTypeRadar) - } +func RadarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { + seriesList := NewSeriesListDataFromValues(values, ChartTypeRadar) return Render(ChartOption{ SeriesList: seriesList, }, opts...) } // FunnelRender funnel chart render -func FunnelRender(values []float64, opts ...OptionFunc) (*Draw, error) { - seriesList := make(SeriesList, len(values)) - for index, value := range values { - seriesList[index] = NewSeriesFromValues([]float64{ - value, - }, ChartTypeFunnel) - } +func FunnelRender(values []float64, opts ...OptionFunc) (*Painter, error) { + seriesList := NewFunnelSeriesList(values) return Render(ChartOption{ SeriesList: seriesList, }, opts...) } + +// TableRender table chart render +func TableRender(header []string, data [][]string, spanMaps ...map[int]int) (*Painter, error) { + opt := TableChartOption{ + Header: header, + Data: data, + } + if len(spanMaps) != 0 { + spanMap := spanMaps[0] + spans := make([]int, len(opt.Header)) + for index := range spans { + v, ok := spanMap[index] + if !ok { + v = 1 + } + spans[index] = v + } + opt.Spans = spans + } + return TableOptionRender(opt) +} + +// TableOptionRender table render with option +func TableOptionRender(opt TableChartOption) (*Painter, error) { + if opt.Type == "" { + opt.Type = ChartOutputPNG + } + if opt.Width <= 0 { + opt.Width = defaultChartWidth + } + if opt.FontFamily != "" { + opt.Font, _ = GetFont(opt.FontFamily) + } + if opt.Font == nil { + opt.Font, _ = GetDefaultFont() + } + + p, err := NewPainter(PainterOptions{ + Type: opt.Type, + Width: opt.Width, + // 仅用于计算表格高度,因此随便设置即可 + Height: 100, + Font: opt.Font, + }) + if err != nil { + return nil, err + } + info, err := NewTableChart(p, opt).render() + if err != nil { + return nil, err + } + + p, err = NewPainter(PainterOptions{ + Type: opt.Type, + Width: info.Width, + Height: info.Height, + Font: opt.Font, + }) + if err != nil { + return nil, err + } + _, err = NewTableChart(p, opt).renderWithInfo(info) + if err != nil { + return nil, err + } + return p, nil +} diff --git a/chart_option_test.go b/chart_option_test.go index 41e8d50..c354b26 100644 --- a/chart_option_test.go +++ b/chart_option_test.go @@ -26,213 +26,426 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) -func TestOptionFunc(t *testing.T) { +func TestChartOption(t *testing.T) { assert := assert.New(t) fns := []OptionFunc{ - TypeOptionFunc(ChartOutputPNG), + SVGTypeOption(), FontFamilyOptionFunc("fontFamily"), - ThemeOptionFunc("black"), - TitleOptionFunc(TitleOption{ - Text: "title", + ThemeOptionFunc("theme"), + TitleTextOptionFunc("title"), + LegendLabelsOptionFunc([]string{ + "label", }), - LegendOptionFunc(LegendOption{ - Data: []string{ - "a", - "b", - }, + XAxisDataOptionFunc([]string{ + "xaxis", }), - XAxisOptionFunc(NewXAxisOption([]string{ - "Mon", - "Tue", - })), - YAxisOptionFunc(YAxisOption{ - Min: NewFloatPoint(0), - Max: NewFloatPoint(100), + YAxisDataOptionFunc([]string{ + "yaxis", }), - WidthOptionFunc(400), - HeightOptionFunc(300), - PaddingOptionFunc(chart.Box{ - Top: 10, - }), - BoxOptionFunc(chart.Box{ - Left: 0, - Right: 300, - }), - ChildOptionFunc(ChartOption{}), - RadarIndicatorOptionFunc(RadarIndicator{ - Min: 0, - Max: 10, + WidthOptionFunc(800), + HeightOptionFunc(600), + PaddingOptionFunc(Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, }), BackgroundColorOptionFunc(drawing.ColorBlack), } - opt := ChartOption{} for _, fn := range fns { fn(&opt) } + assert.Equal(ChartOption{ + Type: ChartOutputSVG, + FontFamily: "fontFamily", + Theme: "theme", + Title: TitleOption{ + Text: "title", + }, + Legend: LegendOption{ + Data: []string{ + "label", + }, + }, + XAxis: XAxisOption{ + Data: []string{ + "xaxis", + }, + }, + YAxisOptions: []YAxisOption{ + { + Data: []string{ + "yaxis", + }, + }, + }, + Width: 800, + Height: 600, + Padding: Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, + BackgroundColor: drawing.ColorBlack, + }, opt) +} - assert.Equal("png", opt.Type) - assert.Equal("fontFamily", opt.FontFamily) - assert.Equal("black", opt.Theme) - assert.Equal(TitleOption{ - Text: "title", - }, opt.Title) - assert.Equal(LegendOption{ - Data: []string{ - "a", - "b", - }, - }, opt.Legend) - assert.Equal(NewXAxisOption([]string{ - "Mon", - "Tue", - }), opt.XAxis) - assert.Equal([]YAxisOption{ - { - Min: NewFloatPoint(0), - Max: NewFloatPoint(100), - }, - }, opt.YAxisList) - assert.Equal(400, opt.Width) - assert.Equal(300, opt.Height) - assert.Equal(chart.Box{ - Top: 10, - }, opt.Padding) - assert.Equal(chart.Box{ - Left: 0, - Right: 300, - }, opt.Box) - assert.Equal(1, len(opt.Children)) - assert.Equal([]RadarIndicator{ - { - Min: 0, - Max: 10, - }, - }, opt.RadarIndicators) - assert.Equal(drawing.ColorBlack, opt.BackgroundColor) +func TestChartOptionPieSeriesShowLabel(t *testing.T) { + assert := assert.New(t) + + opt := ChartOption{ + SeriesList: NewPieSeriesList([]float64{ + 1, + 2, + }), + } + PieSeriesShowLabel()(&opt) + assert.True(opt.SeriesList[0].Label.Show) +} + +func TestChartOptionMarkLine(t *testing.T) { + assert := assert.New(t) + opt := ChartOption{ + SeriesList: NewSeriesListDataFromValues([][]float64{ + {1, 2}, + }), + } + MarkLineOptionFunc(0, "min", "max")(&opt) + assert.Equal(NewMarkLine("min", "max"), opt.SeriesList[0].MarkLine) +} + +func TestChartOptionMarkPoint(t *testing.T) { + assert := assert.New(t) + opt := ChartOption{ + SeriesList: NewSeriesListDataFromValues([][]float64{ + {1, 2}, + }), + } + MarkPointOptionFunc(0, "min", "max")(&opt) + assert.Equal(NewMarkPoint("min", "max"), opt.SeriesList[0].MarkPoint) } func TestLineRender(t *testing.T) { assert := assert.New(t) - - d, err := LineRender([][]float64{ + values := [][]float64{ { - 1, - 2, - 3, + 120, + 132, + 101, + 134, + 90, + 230, + 210, }, { - 1, - 5, - 2, + 220, + 182, + 191, + 234, + 290, + 330, + 310, }, - }, - XAxisOptionFunc(NewXAxisOption([]string{ - "01", - "02", - "03", - })), + { + 150, + 232, + 201, + 154, + 190, + 330, + 410, + }, + { + 320, + 332, + 301, + 334, + 390, + 330, + 320, + }, + { + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320, + }, + } + p, err := LineRender( + values, + SVGTypeOption(), + TitleTextOptionFunc("Line"), + XAxisDataOptionFunc([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + LegendLabelsOptionFunc([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, PositionCenter), ) assert.Nil(err) - data, err := d.Bytes() + data, err := p.Bytes() assert.Nil(err) - assert.Equal("\\n010203024681012", string(data)) + assert.Equal("\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", string(data)) } func TestBarRender(t *testing.T) { assert := assert.New(t) - - d, err := BarRender([][]float64{ + values := [][]float64{ { - 1, - 2, - 3, + 2.0, + 4.9, + 7.0, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20.0, + 6.4, + 3.3, }, { - 1, - 5, - 2, + 2.6, + 5.9, + 9.0, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6.0, + 2.3, + }, + } + p, err := BarRender( + values, + SVGTypeOption(), + XAxisDataOptionFunc([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + LegendLabelsOptionFunc([]string{ + "Rainfall", + "Evaporation", + }, PositionRight), + MarkLineOptionFunc(0, SeriesMarkDataTypeAverage), + MarkPointOptionFunc(0, SeriesMarkDataTypeMax, + SeriesMarkDataTypeMin), + // custom option func + func(opt *ChartOption) { + opt.SeriesList[1].MarkPoint = NewMarkPoint( + SeriesMarkDataTypeMax, + SeriesMarkDataTypeMin, + ) + opt.SeriesList[1].MarkLine = NewMarkLine( + SeriesMarkDataTypeAverage, + ) }, - }, - XAxisOptionFunc(NewXAxisOption([]string{ - "01", - "02", - "03", - })), ) assert.Nil(err) - data, err := d.Bytes() + data, err := p.Bytes() assert.Nil(err) - assert.Equal("\\n010203024681012", string(data)) + assert.Equal("\\nRainfallEvaporation24020016012080400FebMayAugNov162.22182.22.341.6248.07", string(data)) +} + +func TestHorizontalBarRender(t *testing.T) { + assert := assert.New(t) + values := [][]float64{ + { + 18203, + 23489, + 29034, + 104970, + 131744, + 630230, + }, + { + 19325, + 23438, + 31000, + 121594, + 134141, + 681807, + }, + } + p, err := HorizontalBarRender( + values, + SVGTypeOption(), + TitleTextOptionFunc("World Population"), + PaddingOptionFunc(Box{ + Top: 20, + Right: 40, + Bottom: 20, + Left: 20, + }), + LegendLabelsOptionFunc([]string{ + "2011", + "2012", + }), + YAxisDataOptionFunc([]string{ + "Brazil", + "Indonesia", + "USA", + "India", + "China", + "World", + }), + ) + assert.Nil(err) + data, err := p.Bytes() + assert.Nil(err) + assert.Equal("\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0122.28k244.56k366.84k489.12k611.4k733.68k", string(data)) } func TestPieRender(t *testing.T) { assert := assert.New(t) - - d, err := PieRender([]float64{ - 1, - 3, - 5, - }) + values := []float64{ + 1048, + 735, + 580, + 484, + 300, + } + p, err := PieRender( + values, + SVGTypeOption(), + TitleOptionFunc(TitleOption{ + Text: "Rainfall vs Evaporation", + Subtext: "Fake Data", + Left: PositionCenter, + }), + PaddingOptionFunc(Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }), + LegendOptionFunc(LegendOption{ + Orient: OrientVertical, + Data: []string{ + "Search Engine", + "Direct", + "Email", + "Union Ads", + "Video Ads", + }, + Left: PositionLeft, + }), + PieSeriesShowLabel(), + ) assert.Nil(err) - data, err := d.Bytes() + data, err := p.Bytes() assert.Nil(err) - assert.Equal("\\n", string(data)) + assert.Equal("\\nSearch EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", string(data)) } func TestRadarRender(t *testing.T) { assert := assert.New(t) - d, err := RadarRender([][]float64{ + + values := [][]float64{ { - 1, - 2, - 3, + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, }, { - 1, - 5, - 2, + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, }, - }, - RadarIndicatorOptionFunc([]RadarIndicator{ - { - Name: "A", - Min: 0, - Max: 10, - }, - { - Name: "B", - Min: 0, - Max: 10, - }, - { - Name: "C", - Min: 0, - Max: 10, - }, - }...), + } + p, err := RadarRender( + values, + SVGTypeOption(), + TitleTextOptionFunc("Basic Radar Chart"), + LegendLabelsOptionFunc([]string{ + "Allocated Budget", + "Actual Spending", + }), + RadarIndicatorOptionFunc([]string{ + "Sales", + "Administration", + "Information Technology", + "Customer Support", + "Development", + "Marketing", + }, []float64{ + 6500, + 16000, + 30000, + 38000, + 52000, + 25000, + }), ) assert.Nil(err) - data, err := d.Bytes() + data, err := p.Bytes() assert.Nil(err) - assert.Equal("\\nABC", string(data)) + assert.Equal("\\nAllocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", string(data)) } func TestFunnelRender(t *testing.T) { assert := assert.New(t) - d, err := FunnelRender([]float64{ - 5, - 3, - 1, - }) + values := []float64{ + 100, + 80, + 60, + 40, + 20, + } + p, err := FunnelRender( + values, + SVGTypeOption(), + TitleTextOptionFunc("Funnel"), + LegendLabelsOptionFunc([]string{ + "Show", + "Click", + "Visit", + "Inquiry", + "Order", + }), + ) assert.Nil(err) - data, err := d.Bytes() + data, err := p.Bytes() assert.Nil(err) - assert.Equal("\\n(100%)(60%)(20%)", string(data)) + assert.Equal("\\nShowClickVisitInquiryOrderFunnelShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", string(data)) } diff --git a/chart_test.go b/chart_test.go deleted file mode 100644 index c73745e..0000000 --- a/chart_test.go +++ /dev/null @@ -1,567 +0,0 @@ -// 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" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -func TestChartSetDefaultWidthHeight(t *testing.T) { - assert := assert.New(t) - - width := defaultChartWidth - height := defaultChartHeight - defer SetDefaultWidth(width) - defer SetDefaultHeight(height) - - SetDefaultWidth(60) - assert.Equal(60, defaultChartWidth) - SetDefaultHeight(40) - assert.Equal(40, defaultChartHeight) -} - -func TestChartFillDefault(t *testing.T) { - assert := assert.New(t) - // default value - opt := ChartOption{} - opt.FillDefault("") - // padding - assert.Equal(chart.Box{ - Top: 10, - Right: 10, - Bottom: 10, - Left: 10, - }, opt.Padding) - // background color - assert.Equal(drawing.ColorWhite, opt.BackgroundColor) - // title font color - assert.Equal(drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 255, - }, opt.Title.Style.FontColor) - // title font size - assert.Equal(float64(14), opt.Title.Style.FontSize) - // sub title font color - assert.Equal(drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 180, - }, opt.Title.SubtextStyle.FontColor) - // sub title font size - assert.Equal(float64(10), opt.Title.SubtextStyle.FontSize) - // legend font size - assert.Equal(float64(10), opt.Legend.Style.FontSize) - // legend position - assert.Equal("center", opt.Legend.Left) - assert.Equal(drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 255, - }, opt.Legend.Style.FontColor) - - // y axis - opt = ChartOption{ - SeriesList: SeriesList{ - { - YAxisIndex: 1, - }, - }, - } - opt.FillDefault("") - assert.Equal([]YAxisOption{ - {}, - {}, - }, opt.YAxisList) - opt = ChartOption{} - opt.FillDefault("") - assert.Equal([]YAxisOption{ - {}, - }, opt.YAxisList) - - // legend get from series's name - - opt = ChartOption{ - SeriesList: SeriesList{ - { - Name: "a", - }, - { - Name: "b", - }, - }, - } - opt.FillDefault("") - assert.Equal([]string{ - "a", - "b", - }, opt.Legend.Data) - // series name set by legend - opt = ChartOption{ - Legend: LegendOption{ - Data: []string{ - "a", - "b", - }, - }, - SeriesList: SeriesList{ - {}, - {}, - }, - } - opt.FillDefault("") - assert.Equal("a", opt.SeriesList[0].Name) - assert.Equal("b", opt.SeriesList[1].Name) -} - -func TestChartGetWidthHeight(t *testing.T) { - assert := assert.New(t) - - opt := ChartOption{ - Width: 10, - } - assert.Equal(10, opt.getWidth()) - opt.Width = 0 - assert.Equal(600, opt.getWidth()) - opt.Parent = &Draw{ - Box: chart.Box{ - Left: 10, - Right: 50, - }, - } - assert.Equal(40, opt.getWidth()) - - opt = ChartOption{ - Height: 20, - } - assert.Equal(20, opt.getHeight()) - opt.Height = 0 - assert.Equal(400, opt.getHeight()) - opt.Parent = &Draw{ - Box: chart.Box{ - Top: 20, - Bottom: 80, - }, - } - assert.Equal(60, opt.getHeight()) -} - -func TestChartRender(t *testing.T) { - assert := assert.New(t) - - d, err := Render(ChartOption{ - Width: 800, - Height: 600, - Legend: LegendOption{ - Top: "-90", - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Padding: chart.Box{ - Top: 100, - }, - XAxis: NewXAxisOption([]string{ - "2012", - "2013", - "2014", - "2015", - "2016", - "2017", - }), - YAxisList: []YAxisOption{ - { - - Min: NewFloatPoint(0), - Max: NewFloatPoint(90), - }, - }, - SeriesList: []Series{ - NewSeriesFromValues([]float64{ - 56.5, - 82.1, - 88.7, - 70.1, - 53.4, - 85.1, - }), - NewSeriesFromValues([]float64{ - 51.1, - 51.4, - 55.1, - 53.3, - 73.8, - 68.7, - }), - NewSeriesFromValues([]float64{ - 40.1, - 62.2, - 69.5, - 36.4, - 45.2, - 32.5, - }, ChartTypeBar), - NewSeriesFromValues([]float64{ - 25.2, - 37.1, - 41.2, - 18, - 33.9, - 49.1, - }, ChartTypeBar), - }, - Children: []ChartOption{ - { - Legend: LegendOption{ - Show: FalseFlag(), - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Box: chart.Box{ - Top: 20, - Left: 400, - Right: 500, - Bottom: 120, - }, - SeriesList: NewPieSeriesList([]float64{ - 435.9, - 354.3, - 285.9, - 204.5, - }, PieSeriesOption{ - Label: SeriesLabel{ - Show: true, - }, - Radius: "35%", - }), - }, - { - Legend: NewLegendOption([]string{ - "Allocated Budget", - "Actual Spending", - }), - Box: chart.Box{ - Top: 20, - Left: 0, - Right: 200, - Bottom: 120, - }, - RadarIndicators: []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: SeriesList{ - { - Type: ChartTypeRadar, - Data: NewSeriesDataFromValues([]float64{ - 4200, - 3000, - 20000, - 35000, - 50000, - 18000, - }), - }, - { - Type: ChartTypeRadar, - index: 1, - Data: NewSeriesDataFromValues([]float64{ - 5000, - 14000, - 28000, - 26000, - 42000, - 21000, - }), - }, - }, - }, - }, - }) - assert.Nil(err) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n2012201320142015201620170153045607590Milk TeaMatcha LatteCheese CocoaWalnut BrownieMilk Tea: 34.03%Matcha Latte: 27.66%Cheese Cocoa: 22.32%Walnut Brownie: 15.96%SalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketingAllocated BudgetActual Spending", string(data)) -} - -func BenchmarkMultiChartPNGRender(b *testing.B) { - for i := 0; i < b.N; i++ { - opt := ChartOption{ - Type: ChartOutputPNG, - Legend: LegendOption{ - Top: "-90", - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Padding: chart.Box{ - Top: 100, - Right: 10, - Bottom: 10, - Left: 10, - }, - XAxis: NewXAxisOption([]string{ - "2012", - "2013", - "2014", - "2015", - "2016", - "2017", - }), - YAxisList: []YAxisOption{ - { - - Min: NewFloatPoint(0), - Max: NewFloatPoint(90), - }, - }, - SeriesList: []Series{ - NewSeriesFromValues([]float64{ - 56.5, - 82.1, - 88.7, - 70.1, - 53.4, - 85.1, - }), - NewSeriesFromValues([]float64{ - 51.1, - 51.4, - 55.1, - 53.3, - 73.8, - 68.7, - }), - NewSeriesFromValues([]float64{ - 40.1, - 62.2, - 69.5, - 36.4, - 45.2, - 32.5, - }, ChartTypeBar), - NewSeriesFromValues([]float64{ - 25.2, - 37.1, - 41.2, - 18, - 33.9, - 49.1, - }, ChartTypeBar), - }, - Children: []ChartOption{ - { - Legend: LegendOption{ - Show: FalseFlag(), - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Box: chart.Box{ - Top: 20, - Left: 400, - Right: 500, - Bottom: 120, - }, - SeriesList: NewPieSeriesList([]float64{ - 435.9, - 354.3, - 285.9, - 204.5, - }, PieSeriesOption{ - Label: SeriesLabel{ - Show: true, - }, - Radius: "35%", - }), - }, - }, - } - d, err := Render(opt) - if err != nil { - panic(err) - } - buf, err := d.Bytes() - if err != nil { - panic(err) - } - if len(buf) == 0 { - panic(errors.New("data is nil")) - } - } -} - -func BenchmarkMultiChartSVGRender(b *testing.B) { - for i := 0; i < b.N; i++ { - opt := ChartOption{ - Legend: LegendOption{ - Top: "-90", - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Padding: chart.Box{ - Top: 100, - Right: 10, - Bottom: 10, - Left: 10, - }, - XAxis: NewXAxisOption([]string{ - "2012", - "2013", - "2014", - "2015", - "2016", - "2017", - }), - YAxisList: []YAxisOption{ - { - - Min: NewFloatPoint(0), - Max: NewFloatPoint(90), - }, - }, - SeriesList: []Series{ - NewSeriesFromValues([]float64{ - 56.5, - 82.1, - 88.7, - 70.1, - 53.4, - 85.1, - }), - NewSeriesFromValues([]float64{ - 51.1, - 51.4, - 55.1, - 53.3, - 73.8, - 68.7, - }), - NewSeriesFromValues([]float64{ - 40.1, - 62.2, - 69.5, - 36.4, - 45.2, - 32.5, - }, ChartTypeBar), - NewSeriesFromValues([]float64{ - 25.2, - 37.1, - 41.2, - 18, - 33.9, - 49.1, - }, ChartTypeBar), - }, - Children: []ChartOption{ - { - Legend: LegendOption{ - Show: FalseFlag(), - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Box: chart.Box{ - Top: 20, - Left: 400, - Right: 500, - Bottom: 120, - }, - SeriesList: NewPieSeriesList([]float64{ - 435.9, - 354.3, - 285.9, - 204.5, - }, PieSeriesOption{ - Label: SeriesLabel{ - Show: true, - }, - Radius: "35%", - }), - }, - }, - } - d, err := Render(opt) - if err != nil { - panic(err) - } - buf, err := d.Bytes() - if err != nil { - panic(err) - } - if len(buf) == 0 { - panic(errors.New("data is nil")) - } - } -} diff --git a/charts.go b/charts.go new file mode 100644 index 0000000..31df11c --- /dev/null +++ b/charts.go @@ -0,0 +1,473 @@ +// 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" + "sort" + + "git.smarteching.com/zeni/go-chart/v2" +) + +const labelFontSize = 10 +const smallLabelFontSize = 8 +const defaultDotWidth = 2.0 +const defaultStrokeWidth = 2.0 + +var defaultChartWidth = 600 +var defaultChartHeight = 400 + +// SetDefaultWidth sets default width of chart +func SetDefaultWidth(width int) { + if width > 0 { + defaultChartWidth = width + } +} + +// SetDefaultHeight sets default height of chart +func SetDefaultHeight(height int) { + if height > 0 { + defaultChartHeight = height + } +} + +var nullValue = math.MaxFloat64 + +// SetNullValue sets the null value, default is MaxFloat64 +func SetNullValue(v float64) { + nullValue = v +} + +// GetNullValue gets the null value +func GetNullValue() float64 { + return nullValue +} + +type Renderer interface { + Render() (Box, error) +} + +type renderHandler struct { + list []func() error +} + +func (rh *renderHandler) Add(fn func() error) { + list := rh.list + if len(list) == 0 { + list = make([]func() error, 0) + } + rh.list = append(list, fn) +} + +func (rh *renderHandler) Do() error { + for _, fn := range rh.list { + err := fn() + if err != nil { + return err + } + } + return nil +} + +type defaultRenderOption struct { + Theme ColorPalette + Padding Box + SeriesList SeriesList + // The y axis option + YAxisOptions []YAxisOption + // The x axis option + XAxis XAxisOption + // The title option + TitleOption TitleOption + // The legend option + LegendOption LegendOption + // background is filled + backgroundIsFilled bool + // x y axis is reversed + axisReversed bool +} + +type defaultRenderResult struct { + axisRanges map[int]axisRange + // 图例区域 + seriesPainter *Painter +} + +func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, error) { + seriesList := opt.SeriesList + seriesList.init() + if !opt.backgroundIsFilled { + p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor()) + } + + if !opt.Padding.IsZero() { + p = p.Child(PainterPaddingOption(opt.Padding)) + } + + legendHeight := 0 + if len(opt.LegendOption.Data) != 0 { + if opt.LegendOption.Theme == nil { + opt.LegendOption.Theme = opt.Theme + } + legendResult, err := NewLegendPainter(p, opt.LegendOption).Render() + if err != nil { + return nil, err + } + legendHeight = legendResult.Height() + } + + // 如果有标题 + if opt.TitleOption.Text != "" { + if opt.TitleOption.Theme == nil { + opt.TitleOption.Theme = opt.Theme + } + titlePainter := NewTitlePainter(p, opt.TitleOption) + + titleBox, err := titlePainter.Render() + if err != nil { + return nil, err + } + + top := chart.MaxInt(legendHeight, titleBox.Height()) + // 如果是垂直方式,则不计算legend高度 + if opt.LegendOption.Orient == OrientVertical { + top = titleBox.Height() + } + p = p.Child(PainterPaddingOption(Box{ + // 标题下留白 + Top: top + 20, + })) + } + + result := defaultRenderResult{ + axisRanges: make(map[int]axisRange), + } + + // 计算图表对应的轴有哪些 + axisIndexList := make([]int, 0) + for _, series := range opt.SeriesList { + if containsInt(axisIndexList, series.AxisIndex) { + continue + } + 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] + } + divideCount := yAxisOption.DivideCount + if divideCount <= 0 { + divideCount = defaultAxisDivideCount + } + max, min := opt.SeriesList.GetMaxMin(index) + r := NewRange(AxisRangeOption{ + Painter: p, + Min: min, + Max: max, + // 高度需要减去x轴的高度 + Size: rangeHeight, + // 分隔数量 + DivideCount: divideCount, + }) + if yAxisOption.Min != nil && *yAxisOption.Min <= min { + r.min = *yAxisOption.Min + } + if yAxisOption.Max != nil && *yAxisOption.Max >= max { + r.max = *yAxisOption.Max + } + result.axisRanges[index] = r + + if yAxisOption.Theme == nil { + yAxisOption.Theme = opt.Theme + } + if !opt.axisReversed { + yAxisOption.Data = r.Values() + } else { + yAxisOption.isCategoryAxis = true + // 由于x轴为value部分,因此计算其label单独处理 + opt.XAxis.Data = NewRange(AxisRangeOption{ + Painter: p, + Min: min, + Max: max, + // 高度需要减去x轴的高度 + Size: rangeHeight, + // 分隔数量 + DivideCount: defaultAxisDivideCount, + }).Values() + opt.XAxis.isValueAxis = true + } + reverseStringSlice(yAxisOption.Data) + // TODO生成其它位置既yAxis + 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 + } + if index == 0 { + rangeWidthLeft += yAxisBox.Width() + } else { + rangeWidthRight += yAxisBox.Width() + } + } + + if opt.XAxis.Theme == nil { + opt.XAxis.Theme = opt.Theme + } + xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{ + Left: rangeWidthLeft, + Right: rangeWidthRight, + })), opt.XAxis) + _, err := xAxis.Render() + if err != nil { + return nil, err + } + + result.seriesPainter = p.Child(PainterPaddingOption(Box{ + Bottom: defaultXAxisHeight, + Left: rangeWidthLeft, + Right: rangeWidthRight, + })) + return &result, nil +} + +func doRender(renderers ...Renderer) error { + for _, r := range renderers { + _, err := r.Render() + if err != nil { + return err + } + } + return nil +} + +func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { + for _, fn := range opts { + fn(&opt) + } + opt.fillDefault() + + isChild := true + if opt.Parent == nil { + isChild = false + p, err := NewPainter(PainterOptions{ + Type: opt.Type, + Width: opt.Width, + Height: opt.Height, + Font: opt.font, + }) + if err != nil { + return nil, err + } + opt.Parent = p + } + p := opt.Parent + if opt.ValueFormatter != nil { + p.valueFormatter = opt.ValueFormatter + } + 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) + 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 + renderOpt := defaultRenderOption{ + Theme: opt.theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: opt.XAxis, + YAxisOptions: opt.YAxisOptions, + TitleOption: opt.Title, + LegendOption: opt.Legend, + axisReversed: axisReversed, + // 前置已设置背景色 + backgroundIsFilled: true, + } + if len(pieSeriesList) != 0 || + len(radarSeriesList) != 0 || + len(funnelSeriesList) != 0 { + renderOpt.XAxis.Show = FalseFlag() + renderOpt.YAxisOptions = []YAxisOption{ + { + Show: FalseFlag(), + }, + } + } + if len(horizontalBarSeriesList) != 0 { + renderOpt.YAxisOptions[0].DivideCount = len(renderOpt.YAxisOptions[0].Data) + renderOpt.YAxisOptions[0].Unit = 1 + } + + renderResult, err := defaultRender(p, renderOpt) + if err != nil { + return nil, err + } + + handler := renderHandler{} + + // bar chart + if len(barSeriesList) != 0 { + handler.Add(func() error { + _, err := NewBarChart(p, BarChartOption{ + Theme: opt.theme, + Font: opt.font, + XAxis: opt.XAxis, + BarWidth: opt.BarWidth, + BarMargin: opt.BarMargin, + }).render(renderResult, barSeriesList) + return err + }) + } + + // horizontal bar chart + if len(horizontalBarSeriesList) != 0 { + handler.Add(func() error { + _, err := NewHorizontalBarChart(p, HorizontalBarChartOption{ + Theme: opt.theme, + Font: opt.font, + BarHeight: opt.BarHeight, + BarMargin: opt.BarMargin, + YAxisOptions: opt.YAxisOptions, + }).render(renderResult, horizontalBarSeriesList) + return err + }) + } + + // 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, + SymbolShow: opt.SymbolShow, + StrokeWidth: opt.LineStrokeWidth, + FillArea: opt.FillArea, + Opacity: opt.Opacity, + }).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 + if item.Theme == "" { + item.Theme = opt.Theme + } + if item.FontFamily == "" { + item.FontFamily = opt.FontFamily + } + _, err = Render(item) + if err != nil { + return nil, err + } + } + + return p, nil +} diff --git a/charts_test.go b/charts_test.go new file mode 100644 index 0000000..bd581e9 --- /dev/null +++ b/charts_test.go @@ -0,0 +1,255 @@ +// 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" + "testing" + + "git.smarteching.com/zeni/go-chart/v2" +) + +func BenchmarkMultiChartPNGRender(b *testing.B) { + for i := 0; i < b.N; i++ { + opt := ChartOption{ + Type: ChartOutputPNG, + Legend: LegendOption{ + Top: "-90", + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Padding: chart.Box{ + Top: 100, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: NewXAxisOption([]string{ + "2012", + "2013", + "2014", + "2015", + "2016", + "2017", + }), + YAxisOptions: []YAxisOption{ + { + + Min: NewFloatPoint(0), + Max: NewFloatPoint(90), + }, + }, + SeriesList: []Series{ + NewSeriesFromValues([]float64{ + 56.5, + 82.1, + 88.7, + 70.1, + 53.4, + 85.1, + }), + NewSeriesFromValues([]float64{ + 51.1, + 51.4, + 55.1, + 53.3, + 73.8, + 68.7, + }), + NewSeriesFromValues([]float64{ + 40.1, + 62.2, + 69.5, + 36.4, + 45.2, + 32.5, + }, ChartTypeBar), + NewSeriesFromValues([]float64{ + 25.2, + 37.1, + 41.2, + 18, + 33.9, + 49.1, + }, ChartTypeBar), + }, + Children: []ChartOption{ + { + Legend: LegendOption{ + Show: FalseFlag(), + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Box: chart.Box{ + Top: 20, + Left: 400, + Right: 500, + Bottom: 120, + }, + SeriesList: NewPieSeriesList([]float64{ + 435.9, + 354.3, + 285.9, + 204.5, + }, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }, + }, + } + d, err := Render(opt) + if err != nil { + panic(err) + } + buf, err := d.Bytes() + if err != nil { + panic(err) + } + if len(buf) == 0 { + panic(errors.New("data is nil")) + } + } +} + +func BenchmarkMultiChartSVGRender(b *testing.B) { + for i := 0; i < b.N; i++ { + opt := ChartOption{ + Legend: LegendOption{ + Top: "-90", + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Padding: chart.Box{ + Top: 100, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: NewXAxisOption([]string{ + "2012", + "2013", + "2014", + "2015", + "2016", + "2017", + }), + YAxisOptions: []YAxisOption{ + { + + Min: NewFloatPoint(0), + Max: NewFloatPoint(90), + }, + }, + SeriesList: []Series{ + NewSeriesFromValues([]float64{ + 56.5, + 82.1, + 88.7, + 70.1, + 53.4, + 85.1, + }), + NewSeriesFromValues([]float64{ + 51.1, + 51.4, + 55.1, + 53.3, + 73.8, + 68.7, + }), + NewSeriesFromValues([]float64{ + 40.1, + 62.2, + 69.5, + 36.4, + 45.2, + 32.5, + }, ChartTypeBar), + NewSeriesFromValues([]float64{ + 25.2, + 37.1, + 41.2, + 18, + 33.9, + 49.1, + }, ChartTypeBar), + }, + Children: []ChartOption{ + { + Legend: LegendOption{ + Show: FalseFlag(), + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Box: chart.Box{ + Top: 20, + Left: 400, + Right: 500, + Bottom: 120, + }, + SeriesList: NewPieSeriesList([]float64{ + 435.9, + 354.3, + 285.9, + 204.5, + }, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }, + }, + } + d, err := Render(opt) + if err != nil { + panic(err) + } + buf, err := d.Bytes() + if err != nil { + panic(err) + } + if len(buf) == 0 { + panic(errors.New("data is nil")) + } + } +} diff --git a/draw.go b/draw.go deleted file mode 100644 index 1708662..0000000 --- a/draw.go +++ /dev/null @@ -1,372 +0,0 @@ -// 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" - "errors" - "math" - - "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -const ( - PositionLeft = "left" - PositionRight = "right" - PositionCenter = "center" - PositionTop = "top" - PositionBottom = "bottom" -) - -const ( - OrientHorizontal = "horizontal" - OrientVertical = "vertical" -) - -type Draw struct { - // Render - Render chart.Renderer - // The canvas box - Box chart.Box - // The font for draw - Font *truetype.Font - // The parent of draw - parent *Draw -} - -type DrawOption struct { - // Draw type, "svg" or "png", default type is "svg" - Type string - // Parent of draw - Parent *Draw - // The width of draw canvas - Width int - // The height of draw canvas - Height int -} - -type Option func(*Draw) error - -// PaddingOption sets the padding of draw canvas -func PaddingOption(padding chart.Box) Option { - return func(d *Draw) error { - d.Box.Left += padding.Left - d.Box.Top += padding.Top - d.Box.Right -= padding.Right - d.Box.Bottom -= padding.Bottom - return nil - } -} - -// BoxOption set the box of draw canvas -func BoxOption(box chart.Box) Option { - return func(d *Draw) error { - if box.IsZero() { - return nil - } - d.Box = box - return nil - } -} - -// NewDraw returns a new draw canvas -func NewDraw(opt DrawOption, opts ...Option) (*Draw, error) { - if opt.Parent == nil && (opt.Width <= 0 || opt.Height <= 0) { - return nil, errors.New("parent and width/height can not be nil") - } - font, _ := chart.GetDefaultFont() - d := &Draw{ - Font: font, - } - width := opt.Width - height := opt.Height - if opt.Parent != nil { - d.parent = opt.Parent - d.Render = d.parent.Render - d.Box = opt.Parent.Box.Clone() - } - if width != 0 && height != 0 { - d.Box.Right = width + d.Box.Left - d.Box.Bottom = height + d.Box.Top - } - // 创建render - if d.parent == nil { - fn := chart.SVG - if opt.Type == ChartOutputPNG { - fn = chart.PNG - } - r, err := fn(d.Box.Right, d.Box.Bottom) - if err != nil { - return nil, err - } - d.Render = r - } - - for _, o := range opts { - err := o(d) - if err != nil { - return nil, err - } - } - return d, nil -} - -// Parent returns the parent of draw -func (d *Draw) Parent() *Draw { - return d.parent -} - -// Top returns the top parent of draw -func (d *Draw) Top() *Draw { - if d.parent == nil { - return nil - } - t := d.parent - // 限制最多查询次数,避免嵌套引用 - for i := 50; i > 0; i-- { - if t.parent == nil { - break - } - t = t.parent - } - return t -} - -// Bytes returns the data of draw canvas -func (d *Draw) Bytes() ([]byte, error) { - buffer := bytes.Buffer{} - err := d.Render.Save(&buffer) - if err != nil { - return nil, err - } - return buffer.Bytes(), err -} - -func (d *Draw) moveTo(x, y int) { - d.Render.MoveTo(x+d.Box.Left, y+d.Box.Top) -} - -func (d *Draw) arcTo(cx, cy int, rx, ry, startAngle, delta float64) { - d.Render.ArcTo(cx+d.Box.Left, cy+d.Box.Top, rx, ry, startAngle, delta) -} - -func (d *Draw) lineTo(x, y int) { - d.Render.LineTo(x+d.Box.Left, y+d.Box.Top) -} - -func (d *Draw) pin(x, y, width int) { - r := float64(width) / 2 - y -= width / 4 - angle := chart.DegreesToRadians(15) - - startAngle := math.Pi/2 + angle - delta := 2*math.Pi - 2*angle - d.arcTo(x, y, r, r, startAngle, delta) - d.lineTo(x, y) - d.Render.Close() - d.Render.FillStroke() - - startX := x - int(r) - startY := y - endX := x + int(r) - endY := y - d.moveTo(startX, startY) - - left := d.Box.Left - top := d.Box.Top - cx := x - cy := y + int(r*2.5) - d.Render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top) - d.Render.Close() - d.Render.Fill() -} - -func (d *Draw) arrowLeft(x, y, width, height int) { - d.arrow(x, y, width, height, PositionLeft) -} - -func (d *Draw) arrowRight(x, y, width, height int) { - d.arrow(x, y, width, height, PositionRight) -} - -func (d *Draw) arrowTop(x, y, width, height int) { - d.arrow(x, y, width, height, PositionTop) -} -func (d *Draw) arrowBottom(x, y, width, height int) { - d.arrow(x, y, width, height, PositionBottom) -} - -func (d *Draw) arrow(x, y, width, height int, direction string) { - halfWidth := width >> 1 - halfHeight := height >> 1 - if direction == PositionTop || direction == PositionBottom { - x0 := x - halfWidth - x1 := x0 + width - dy := -height / 3 - y0 := y - y1 := y0 - height - if direction == PositionBottom { - y0 = y - height - y1 = y - dy = 2 * dy - } - d.moveTo(x0, y0) - d.lineTo(x0+halfWidth, y1) - d.lineTo(x1, y0) - d.lineTo(x0+halfWidth, y+dy) - d.lineTo(x0, y0) - } else { - x0 := x + width - x1 := x0 - width - y0 := y - halfHeight - dx := -width / 3 - if direction == PositionRight { - x0 = x - width - dx = -dx - x1 = x0 + width - } - d.moveTo(x0, y0) - d.lineTo(x1, y0+halfHeight) - d.lineTo(x0, y0+height) - 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) { - d.Render.Circle(radius, x+d.Box.Left, y+d.Box.Top) -} - -func (d *Draw) text(body string, x, y int) { - d.Render.Text(body, x+d.Box.Left, y+d.Box.Top) -} - -func (d *Draw) textFit(body string, x, y, width int, style chart.Style) chart.Box { - style.TextWrap = chart.TextWrapWord - r := d.Render - lines := chart.Text.WrapFit(r, body, width, style) - style.WriteTextOptionsToRenderer(r) - var output chart.Box - - for index, line := range lines { - x0 := x - y0 := y + output.Height() - d.text(line, x0, y0) - lineBox := r.MeasureText(line) - output.Right = chart.MaxInt(lineBox.Right, output.Right) - output.Bottom += lineBox.Height() - if index < len(lines)-1 { - output.Bottom += +style.GetTextLineSpacing() - } - } - return output -} - -func (d *Draw) measureTextFit(body string, x, y, width int, style chart.Style) chart.Box { - style.TextWrap = chart.TextWrapWord - r := d.Render - lines := chart.Text.WrapFit(r, body, width, style) - style.WriteTextOptionsToRenderer(r) - return chart.Text.MeasureLines(r, lines, style) -} - -func (d *Draw) lineStroke(points []Point, style LineStyle) { - s := style.Style() - if !s.ShouldDrawStroke() { - return - } - r := d.Render - s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) - for index, point := range points { - x := point.X - y := point.Y - if index == 0 { - d.moveTo(x, y) - } else { - d.lineTo(x, y) - } - } - r.Stroke() -} - -func (d *Draw) setBackground(width, height int, color drawing.Color) { - r := d.Render - s := chart.Style{ - FillColor: color, - } - s.WriteToRenderer(r) - r.MoveTo(0, 0) - r.LineTo(width, 0) - r.LineTo(width, height) - r.LineTo(0, height) - r.LineTo(0, 0) - r.FillStroke() -} - -func (d *Draw) polygon(center Point, radius float64, sides int) { - points := getPolygonPoints(center, radius, sides) - for i, p := range points { - if i == 0 { - d.moveTo(p.X, p.Y) - } else { - d.lineTo(p.X, p.Y) - } - } - d.lineTo(points[0].X, points[0].Y) - d.Render.Stroke() -} - -func (d *Draw) fill(points []Point, s chart.Style) { - if !s.ShouldDrawFill() { - return - } - r := d.Render - var x, y int - s.GetFillOptions().WriteDrawingOptionsToRenderer(r) - for index, point := range points { - x = point.X - y = point.Y - if index == 0 { - d.moveTo(x, y) - } else { - d.lineTo(x, y) - } - } - r.Fill() -} diff --git a/echarts.go b/echarts.go index 4ebb9ad..aaef1f1 100644 --- a/echarts.go +++ b/echarts.go @@ -29,7 +29,7 @@ import ( "regexp" "strconv" - "github.com/wcharczuk/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2" ) func convertToArray(data []byte) []byte { @@ -60,9 +60,9 @@ type EChartStyle struct { Color string `json:"color"` } -func (es *EChartStyle) ToStyle() chart.Style { +func (es *EChartStyle) ToStyle() Style { color := parseColor(es.Color) - return chart.Style{ + return Style{ FillColor: color, FontColor: color, StrokeColor: color, @@ -130,6 +130,7 @@ type EChartsXAxisData struct { BoundaryGap *bool `json:"boundaryGap"` SplitNumber int `json:"splitNumber"` Data []string `json:"data"` + Type string `json:"type"` } type EChartsXAxis struct { Data []EChartsXAxisData @@ -155,6 +156,7 @@ type EChartsYAxisData struct { Color string `json:"color"` } `json:"lineStyle"` } `json:"axisLine"` + Data []string `json:"data"` } type EChartsYAxis struct { Data []EChartsYAxisData `json:"data"` @@ -342,6 +344,11 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { Data: NewSeriesDataFromValues(dataItem.Value.values), Max: item.Max, Min: item.Min, + Label: SeriesLabel{ + Color: parseColor(item.Label.Color), + Show: item.Label.Show, + Distance: item.Label.Distance, + }, }) } continue @@ -354,10 +361,10 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { } } seriesList = append(seriesList, Series{ - Type: item.Type, - Data: data, - YAxisIndex: item.YAxisIndex, - Style: item.ItemStyle.ToStyle(), + Type: item.Type, + Data: data, + AxisIndex: item.YAxisIndex, + Style: item.ItemStyle.ToStyle(), Label: SeriesLabel{ Color: parseColor(item.Label.Color), Show: item.Label.Show, @@ -419,26 +426,32 @@ func (eo *EChartsOption) ToOption() ChartOption { 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, - Style: eo.Title.TextStyle.ToStyle(), - SubtextStyle: eo.Title.SubtextStyle.ToStyle(), - Left: string(eo.Title.Left), - Top: string(eo.Title.Top), + 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, - Style: eo.Legend.TextStyle.ToStyle(), - Data: eo.Legend.Data, - Left: string(eo.Legend.Left), - Top: string(eo.Legend.Top), - Align: eo.Legend.Align, - Orient: eo.Legend.Orient, + 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, @@ -447,6 +460,21 @@ func (eo *EChartsOption) ToOption() ChartOption { Box: eo.Box, SeriesList: eo.Series.ToSeriesList(), } + isHorizontalChart := false + for _, item := range eo.XAxis.Data { + if item.Type == "value" { + isHorizontalChart = true + } + } + if isHorizontalChart { + for index := range o.SeriesList { + series := o.SeriesList[index] + if series.Type == ChartTypeBar { + o.SeriesList[index].Type = ChartTypeHorizontalBar + } + } + } + if len(eo.XAxis.Data) != 0 { xAxisData := eo.XAxis.Data[0] o.XAxis = XAxisOption{ @@ -462,9 +490,10 @@ func (eo *EChartsOption) ToOption() ChartOption { Max: item.Max, Formatter: item.AxisLabel.Formatter, Color: parseColor(item.AxisLine.LineStyle.Color), + Data: item.Data, } } - o.YAxisList = yAxisOptions + o.YAxisOptions = yAxisOptions if len(eo.Children) != 0 { o.Children = make([]ChartOption, len(eo.Children)) diff --git a/echarts_test.go b/echarts_test.go index 05c2a40..2077278 100644 --- a/echarts_test.go +++ b/echarts_test.go @@ -27,566 +27,556 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) +func TestConvertToArray(t *testing.T) { + assert := assert.New(t) + + assert.Equal([]byte(`[1]`), convertToArray([]byte("1"))) + assert.Equal([]byte(`[1]`), convertToArray([]byte("[1]"))) +} + func TestEChartsPosition(t *testing.T) { assert := assert.New(t) - var p EChartsPosition - err := p.UnmarshalJSON([]byte("12")) + err := p.UnmarshalJSON([]byte("1")) assert.Nil(err) - assert.Equal("12", string(p)) - - err = p.UnmarshalJSON([]byte(`"12%"`)) + assert.Equal(EChartsPosition("1"), p) + err = p.UnmarshalJSON([]byte(`"left"`)) assert.Nil(err) - assert.Equal("12%", string(p)) + assert.Equal(EChartsPosition("left"), p) } -func TestEChartStyle(t *testing.T) { + +func TestEChartsSeriesDataValue(t *testing.T) { assert := assert.New(t) - s := EChartStyle{ - Color: "#aaa", - } - r := drawing.Color{ - R: 170, - G: 170, - B: 170, - A: 255, - } - assert.Equal(chart.Style{ - FillColor: r, - FontColor: r, - StrokeColor: r, - }, s.ToStyle()) + es := EChartsSeriesDataValue{} + err := es.UnmarshalJSON([]byte(`[1, 2]`)) + assert.Nil(err) + assert.Equal(EChartsSeriesDataValue{ + values: []float64{ + 1, + 2, + }, + }, es) + assert.Equal(NewEChartsSeriesDataValue(1, 2), es) + assert.Equal(1.0, es.First()) +} + +func TestEChartsSeriesData(t *testing.T) { + assert := assert.New(t) + es := EChartsSeriesData{} + err := es.UnmarshalJSON([]byte("1.1")) + assert.Nil(err) + assert.Equal(EChartsSeriesDataValue{ + values: []float64{ + 1.1, + }, + }, es.Value) + + err = es.UnmarshalJSON([]byte(`{"value":200,"itemStyle":{"color":"#a90000"}}`)) + assert.Nil(err) + assert.Nil(err) + assert.Equal(EChartsSeriesData{ + Value: EChartsSeriesDataValue{ + values: []float64{ + 200.0, + }, + }, + ItemStyle: EChartStyle{ + Color: "#a90000", + }, + }, es) } func TestEChartsXAxis(t *testing.T) { assert := assert.New(t) ex := EChartsXAxis{} - err := ex.UnmarshalJSON([]byte(`{ - "boundaryGap": false, - "splitNumber": 5, - "data": [ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun" - ] - }`)) + err := ex.UnmarshalJSON([]byte(`{"boundaryGap": true, "splitNumber": 5, "data": ["a", "b"], "type": "value"}`)) assert.Nil(err) + assert.Equal(EChartsXAxis{ Data: []EChartsXAxisData{ { - BoundaryGap: FalseFlag(), + BoundaryGap: TrueFlag(), SplitNumber: 5, Data: []string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", + "a", + "b", }, + Type: "value", }, }, }, ex) } -func TestEChartsYAxis(t *testing.T) { +func TestEChartStyle(t *testing.T) { assert := assert.New(t) - ey := EChartsYAxis{} - err := ey.UnmarshalJSON([]byte(`{ - "min": 1, - "max": 10, - "axisLabel": { - "formatter": "ab" - } - }`)) - assert.Nil(err) - assert.Equal(EChartsYAxis{ - Data: []EChartsYAxisData{ - { - Min: NewFloatPoint(1), - Max: NewFloatPoint(10), - AxisLabel: EChartsAxisLabel{ - Formatter: "ab", - }, - }, - }, - }, ey) - - ey = EChartsYAxis{} - err = ey.UnmarshalJSON([]byte(`[ - { - "min": 1, - "max": 10, - "axisLabel": { - "formatter": "ab" - } - }, - { - "min": 2, - "max": 20, - "axisLabel": { - "formatter": "cd" - } - } - ]`)) - assert.Nil(err) - assert.Equal(EChartsYAxis{ - Data: []EChartsYAxisData{ - { - Min: NewFloatPoint(1), - Max: NewFloatPoint(10), - AxisLabel: EChartsAxisLabel{ - Formatter: "ab", - }, - }, - { - Min: NewFloatPoint(2), - Max: NewFloatPoint(20), - AxisLabel: EChartsAxisLabel{ - Formatter: "cd", - }, - }, - }, - }, ey) + es := EChartStyle{ + Color: "#999", + } + color := drawing.Color{ + R: 153, + G: 153, + B: 153, + A: 255, + } + assert.Equal(Style{ + FillColor: color, + FontColor: color, + StrokeColor: color, + }, es.ToStyle()) } func TestEChartsPadding(t *testing.T) { assert := assert.New(t) - ep := EChartsPadding{} + eb := EChartsPadding{} - err := ep.UnmarshalJSON([]byte(`10`)) + err := eb.UnmarshalJSON([]byte(`1`)) assert.Nil(err) - assert.Equal(EChartsPadding{ - Box: chart.Box{ - Top: 10, - Right: 10, - Bottom: 10, - Left: 10, - }, - }, ep) + assert.Equal(Box{ + Left: 1, + Top: 1, + Right: 1, + Bottom: 1, + }, eb.Box) - ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte(`[10, 20]`)) + err = eb.UnmarshalJSON([]byte(`[2, 3]`)) assert.Nil(err) - assert.Equal(EChartsPadding{ - Box: chart.Box{ - Top: 10, - Right: 20, - Bottom: 10, - Left: 20, - }, - }, ep) + assert.Equal(Box{ + Left: 3, + Top: 2, + Right: 3, + Bottom: 2, + }, eb.Box) - ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte(`[10, 20, 30]`)) + err = eb.UnmarshalJSON([]byte(`[4, 5, 6]`)) assert.Nil(err) - assert.Equal(EChartsPadding{ - Box: chart.Box{ - Top: 10, - Right: 20, - Bottom: 30, - Left: 20, - }, - }, ep) + assert.Equal(Box{ + Left: 5, + Top: 4, + Right: 5, + Bottom: 6, + }, eb.Box) - ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte(`[10, 20, 30, 40]`)) + err = eb.UnmarshalJSON([]byte(`[4, 5, 6, 7]`)) assert.Nil(err) - assert.Equal(EChartsPadding{ - Box: chart.Box{ - Top: 10, - Right: 20, - Bottom: 30, - Left: 40, - }, - }, ep) - -} -func TestEChartsLegend(t *testing.T) { - assert := assert.New(t) - - el := EChartsLegend{} - - err := json.Unmarshal([]byte(`{ - "data": ["a", "b", "c"], - "align": "right", - "padding": [10], - "left": "20%", - "top": 10 - }`), &el) - assert.Nil(err) - assert.Equal(EChartsLegend{ - Data: []string{ - "a", - "b", - "c", - }, - Align: "right", - Padding: EChartsPadding{ - Box: chart.Box{ - Left: 10, - Top: 10, - Right: 10, - Bottom: 10, - }, - }, - Left: EChartsPosition("20%"), - Top: EChartsPosition("10"), - }, el) -} - -func TestEChartsSeriesData(t *testing.T) { - assert := assert.New(t) - - esd := EChartsSeriesData{} - err := esd.UnmarshalJSON([]byte(`123`)) - assert.Nil(err) - assert.Equal(EChartsSeriesData{ - Value: NewEChartsSeriesDataValue(123), - }, esd) - - esd = EChartsSeriesData{} - err = esd.UnmarshalJSON([]byte(`2.1`)) - assert.Nil(err) - assert.Equal(EChartsSeriesData{ - Value: NewEChartsSeriesDataValue(2.1), - }, esd) - - esd = EChartsSeriesData{} - err = esd.UnmarshalJSON([]byte(`{ - "value": 123.12, - "name": "test", - "itemStyle": { - "color": "#aaa" - } - }`)) - assert.Nil(err) - assert.Equal(EChartsSeriesData{ - Value: NewEChartsSeriesDataValue(123.12), - Name: "test", - ItemStyle: EChartStyle{ - Color: "#aaa", - }, - }, esd) -} - -func TestEChartsSeries(t *testing.T) { - assert := assert.New(t) - - esList := make([]EChartsSeries, 0) - err := json.Unmarshal([]byte(`[ - { - "name": "Email", - "data": [ - 120, - 132 - ] - }, - { - "name": "Union Ads", - "type": "bar", - "data": [ - 220, - 182 - ] - } - ]`), &esList) - assert.Nil(err) - assert.Equal([]EChartsSeries{ - { - Name: "Email", - Data: []EChartsSeriesData{ - { - Value: NewEChartsSeriesDataValue(120), - }, - { - Value: NewEChartsSeriesDataValue(132), - }, - }, - }, - { - Name: "Union Ads", - Type: "bar", - Data: []EChartsSeriesData{ - { - Value: NewEChartsSeriesDataValue(220), - }, - { - Value: NewEChartsSeriesDataValue(182), - }, - }, - }, - }, esList) -} - -func TestEChartsMarkData(t *testing.T) { - assert := assert.New(t) - - emd := EChartsMarkData{} - err := emd.UnmarshalJSON([]byte(`{"type": "average"}`)) - assert.Nil(err) - assert.Equal("average", emd.Type) - - emd = EChartsMarkData{} - err = emd.UnmarshalJSON([]byte(`[{}, {"type": "average"}]`)) - assert.Nil(err) - assert.Equal("average", emd.Type) + assert.Equal(Box{ + Left: 7, + Top: 4, + Right: 5, + Bottom: 6, + }, eb.Box) } func TestEChartsMarkPoint(t *testing.T) { assert := assert.New(t) - p := EChartsMarkPoint{} - - err := json.Unmarshal([]byte(`{ - "symbolSize": 30, - "data": [ + emp := EChartsMarkPoint{ + SymbolSize: 30, + Data: []EChartsMarkData{ { - "type": "max" + Type: "test", }, - { - "type": "min" - } - ] - }`), &p) - assert.Nil(err) + }, + } assert.Equal(SeriesMarkPoint{ SymbolSize: 30, Data: []SeriesMarkData{ { - Type: "max", - }, - { - Type: "min", + Type: "test", }, }, - }, p.ToSeriesMarkPoint()) + }, emp.ToSeriesMarkPoint()) } func TestEChartsMarkLine(t *testing.T) { assert := assert.New(t) - l := EChartsMarkLine{} - err := json.Unmarshal([]byte(`{ - "data": [ - { - "type": "max" - }, - { - "type": "min" - } - ] - }`), &l) - assert.Nil(err) - assert.Equal(SeriesMarkLine{ - Data: []SeriesMarkData{ - { - Type: "max", - }, + eml := EChartsMarkLine{ + Data: []EChartsMarkData{ { Type: "min", }, + { + Type: "max", + }, }, - }, l.ToSeriesMarkLine()) -} - -func TestEChartsTextStyle(t *testing.T) { - assert := assert.New(t) - - s := EChartsTextStyle{ - Color: "#aaa", - FontFamily: "test", - FontSize: 14, } - assert.Equal(chart.Style{ - FontColor: drawing.Color{ - R: 170, - G: 170, - B: 170, - A: 255, + assert.Equal(SeriesMarkLine{ + Data: []SeriesMarkData{ + { + Type: "min", + }, + { + Type: "max", + }, }, - FontSize: 14, - }, s.ToStyle()) + }, eml.ToSeriesMarkLine()) } -func TestEChartsSeriesList(t *testing.T) { +func TestEChartsOption(t *testing.T) { assert := assert.New(t) - // pie - es := EChartsSeriesList{ + tests := []struct { + option string + }{ { - Type: ChartTypePie, - Radius: "30%", - Data: []EChartsSeriesData{ - { - Name: "1", - Value: EChartsSeriesDataValue{ - values: []float64{ - 1, - }, - }, + option: `{ + "xAxis": { + "type": "category", + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] }, - { - Name: "2", - Value: EChartsSeriesDataValue{ - values: []float64{ + "yAxis": { + "type": "value" + }, + "series": [ + { + "data": [ + 120, + { + "value": 200, + "itemStyle": { + "color": "#a90000" + } + }, + 150, + 80, + 70, + 110, + 130 + ], + "type": "bar" + } + ] + }`, + }, + { + option: `{ + "title": { + "text": "Referer of a Website", + "subtext": "Fake Data", + "left": "center" + }, + "tooltip": { + "trigger": "item" + }, + "legend": { + "orient": "vertical", + "left": "left" + }, + "series": [ + { + "name": "Access From", + "type": "pie", + "radius": "50%", + "data": [ + { + "value": 1048, + "name": "Search Engine" + }, + { + "value": 735, + "name": "Direct" + }, + { + "value": 580, + "name": "Email" + }, + { + "value": 484, + "name": "Union Ads" + }, + { + "value": 300, + "name": "Video Ads" + } + ], + "emphasis": { + "itemStyle": { + "shadowBlur": 10, + "shadowOffsetX": 0, + "shadowColor": "rgba(0, 0, 0, 0.5)" + } + } + } + ] + }`, + }, + { + option: `{ + "title": { + "text": "Rainfall vs Evaporation", + "subtext": "Fake Data" + }, + "tooltip": { + "trigger": "axis" + }, + "legend": { + "data": [ + "Rainfall", + "Evaporation" + ] + }, + "toolbox": { + "show": true, + "feature": { + "dataView": { + "show": true, + "readOnly": false + }, + "magicType": { + "show": true, + "type": [ + "line", + "bar" + ] + }, + "restore": { + "show": true + }, + "saveAsImage": { + "show": true + } + } + }, + "calculable": true, + "xAxis": [ + { + "type": "category", + "data": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + } + ], + "yAxis": [ + { + "type": "value" + } + ], + "series": [ + { + "name": "Rainfall", + "type": "bar", + "data": [ 2, + 4.9, + 7, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20, + 6.4, + 3.3 + ], + "markPoint": { + "data": [ + { + "type": "max", + "name": "Max" + }, + { + "type": "min", + "name": "Min" + } + ] }, + "markLine": { + "data": [ + { + "type": "average", + "name": "Avg" + } + ] + } }, - }, - }, + { + "name": "Evaporation", + "type": "bar", + "data": [ + 2.6, + 5.9, + 9, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6, + 2.3 + ], + "markPoint": { + "data": [ + { + "name": "Max", + "value": 182.2, + "xAxis": 7, + "yAxis": 183 + }, + { + "name": "Min", + "value": 2.3, + "xAxis": 11, + "yAxis": 3 + } + ] + }, + "markLine": { + "data": [ + { + "type": "average", + "name": "Avg" + } + ] + } + } + ] + }`, }, } - assert.Equal(SeriesList{ - { - Type: ChartTypePie, - Name: "1", - Label: SeriesLabel{ - Show: true, - }, - Radius: "30%", - Data: []SeriesData{ - { - Value: 1, - }, - }, - }, - { - Type: ChartTypePie, - Name: "2", - Label: SeriesLabel{ - Show: true, - }, - Radius: "30%", - Data: []SeriesData{ - { - Value: 2, - }, - }, - }, - }, es.ToSeriesList()) - - es = EChartsSeriesList{ - { - Type: ChartTypeBar, - Data: []EChartsSeriesData{ - { - Value: NewEChartsSeriesDataValue(1), - ItemStyle: EChartStyle{ - Color: "#aaa", - }, - }, - { - Value: NewEChartsSeriesDataValue(2), - }, - }, - YAxisIndex: 1, - }, - { - Data: []EChartsSeriesData{ - { - Value: NewEChartsSeriesDataValue(3), - }, - { - Value: NewEChartsSeriesDataValue(4), - }, - }, - ItemStyle: EChartStyle{ - Color: "#ccc", - }, - Label: EChartsLabelOption{ - Color: "#ddd", - Show: true, - Distance: 5, - }, - }, + for _, tt := range tests { + opt := EChartsOption{} + err := json.Unmarshal([]byte(tt.option), &opt) + assert.Nil(err) + assert.NotEmpty(opt.Series) + assert.NotEmpty(opt.ToOption().SeriesList) } - assert.Equal(SeriesList{ - { - Type: ChartTypeBar, - Data: []SeriesData{ - { - Value: 1, - Style: chart.Style{ - FontColor: drawing.Color{ - R: 170, - G: 170, - B: 170, - A: 255, - }, - StrokeColor: drawing.Color{ - R: 170, - G: 170, - B: 170, - A: 255, - }, - FillColor: drawing.Color{ - R: 170, - G: 170, - B: 170, - A: 255, - }, - }, - }, - { - Value: 2, - }, - }, - YAxisIndex: 1, - }, - { - Data: []SeriesData{ - { - Value: 3, - }, - { - Value: 4, - }, - }, - Style: chart.Style{ - FontColor: drawing.Color{ - R: 204, - G: 204, - B: 204, - A: 255, - }, - StrokeColor: drawing.Color{ - R: 204, - G: 204, - B: 204, - A: 255, - }, - FillColor: drawing.Color{ - R: 204, - G: 204, - B: 204, - A: 255, - }, - }, - Label: SeriesLabel{ - Color: drawing.Color{ - R: 221, - G: 221, - B: 221, - A: 255, - }, - Show: true, - Distance: 5, - }, - MarkPoint: SeriesMarkPoint{}, - MarkLine: SeriesMarkLine{}, - }, - }, es.ToSeriesList()) - +} + +func TestRenderEChartsToSVG(t *testing.T) { + assert := assert.New(t) + + data, err := RenderEChartsToSVG(`{ + "title": { + "text": "Rainfall vs Evaporation", + "subtext": "Fake Data" + }, + "legend": { + "data": [ + "Rainfall", + "Evaporation" + ] + }, + "padding": [10, 30, 10, 10], + "xAxis": [ + { + "type": "category", + "data": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + } + ], + "series": [ + { + "name": "Rainfall", + "type": "bar", + "data": [ + 2, + 4.9, + 7, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20, + 6.4, + 3.3 + ], + "markPoint": { + "data": [ + { + "type": "max" + }, + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + } + ] + } + }, + { + "name": "Evaporation", + "type": "bar", + "data": [ + 2.6, + 5.9, + 9, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6, + 2.3 + ], + "markPoint": { + "data": [ + { + "type": "max" + }, + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + } + ] + } + } + ] + }`) + assert.Nil(err) + assert.Equal("\\nRainfallEvaporationRainfall vs EvaporationFake Data24020016012080400FebMayAugNov162.22182.22.341.6248.07", string(data)) } diff --git a/examples/area_line_chart/main.go b/examples/area_line_chart/main.go new file mode 100644 index 0000000..57ca1e9 --- /dev/null +++ b/examples/area_line_chart/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "os" + "path/filepath" + + "git.smarteching.com/zeni/go-charts/v2" +) + +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "area-line-chart.png") + err = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + values := [][]float64{ + { + 120, + 132, + 101, + 134, + 90, + 230, + 210, + }, + } + p, err := charts.LineRender( + values, + charts.TitleTextOptionFunc("Line"), + charts.XAxisDataOptionFunc([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + charts.LegendLabelsOptionFunc([]string{ + "Email", + }, "50"), + func(opt *charts.ChartOption) { + opt.Legend.Padding = charts.Box{ + Top: 5, + Bottom: 10, + } + opt.FillArea = true + }, + ) + + 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/bar_chart/main.go b/examples/bar_chart/main.go new file mode 100644 index 0000000..91c9f81 --- /dev/null +++ b/examples/bar_chart/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "os" + "path/filepath" + + "git.smarteching.com/zeni/go-charts/v2" +) + +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 = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + values := [][]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, + }, + { + 2.6, + 5.9, + 9.0, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6.0, + 2.3, + }, + } + p, err := charts.BarRender( + values, + charts.XAxisDataOptionFunc([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + charts.LegendLabelsOptionFunc([]string{ + "Rainfall", + "Evaporation", + }, charts.PositionRight), + charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage), + charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax, + charts.SeriesMarkDataTypeMin), + // custom option func + func(opt *charts.ChartOption) { + opt.SeriesList[1].MarkPoint = charts.NewMarkPoint( + charts.SeriesMarkDataTypeMax, + charts.SeriesMarkDataTypeMin, + ) + opt.SeriesList[1].MarkLine = charts.NewMarkLine( + charts.SeriesMarkDataTypeAverage, + ) + }, + ) + 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/basic/main.go b/examples/basic/main.go deleted file mode 100644 index 1e7af8d..0000000 --- a/examples/basic/main.go +++ /dev/null @@ -1,94 +0,0 @@ -package main - -import ( - "io/ioutil" - "os" - "path/filepath" - - charts "github.com/vicanso/go-charts" -) - -func writeFile(file string, buf []byte) error { - tmpPath := "./tmp" - err := os.MkdirAll(tmpPath, 0700) - if err != nil { - return err - } - - file = filepath.Join(tmpPath, file) - err = ioutil.WriteFile(file, buf, 0600) - if err != nil { - return err - } - return nil -} - -func chartsRender() ([]byte, error) { - d, err := charts.LineRender([][]float64{ - { - 150, - 230, - 224, - 218, - 135, - 147, - 260, - }, - }, - // output type - charts.PNGTypeOption(), - // title - charts.TitleOptionFunc(charts.TitleOption{ - Text: "Line", - }), - // x axis - charts.XAxisOptionFunc(charts.NewXAxisOption([]string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", - })), - ) - if err != nil { - return nil, err - } - return d.Bytes() -} - -func echartsRender() ([]byte, error) { - return charts.RenderEChartsToPNG(`{ - "title": { - "text": "Line" - }, - "xAxis": { - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "data": [150, 230, 224, 218, 135, 147, 260] - } - ] - }`) -} - -type Render func() ([]byte, error) - -func main() { - m := map[string]Render{ - "charts-line.png": chartsRender, - "echarts-line.png": echartsRender, - } - for name, fn := range m { - buf, err := fn() - if err != nil { - panic(err) - } - err = writeFile(name, buf) - if err != nil { - panic(err) - } - } -} diff --git a/examples/charts/main.go b/examples/charts/main.go index fddbe6d..81bc4f2 100644 --- a/examples/charts/main.go +++ b/examples/charts/main.go @@ -2,12 +2,11 @@ package main import ( "bytes" + "fmt" "net/http" "strconv" - charts "github.com/vicanso/go-charts" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + charts "git.smarteching.com/zeni/go-charts/v2" ) var html = ` @@ -75,6 +74,7 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha bytesList := make([][]byte, 0) for _, opt := range chartOptions { opt.Theme = theme + opt.Type = charts.ChartOutputSVG d, err := charts.Render(opt) if err != nil { panic(err) @@ -93,6 +93,48 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha bytesList = append(bytesList, buf) } + p, err := charts.TableOptionRender(charts.TableChartOption{ + Type: charts.ChartOutputSVG, + Header: []string{ + "Name", + "Age", + "Address", + "Tag", + "Action", + }, + Data: [][]string{ + { + "John Brown", + "32", + "New York No. 1 Lake Park", + "nice, developer", + "Send Mail", + }, + { + "Jim Green ", + "42", + "London No. 1 Lake Park", + "wow", + "Send Mail", + }, + { + "Joe Black ", + "32", + "Sidney No. 1 Lake Park", + "cool, teacher", + "Send Mail", + }, + }, + }) + if err != nil { + panic(err) + } + buf, err := p.Bytes() + if err != nil { + panic(err) + } + bytesList = append(bytesList, buf) + data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), bytes.Join(bytesList, []byte(""))) w.Header().Set("Content-Type", "text/html") w.Write(data) @@ -100,7 +142,6 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha func indexHandler(w http.ResponseWriter, req *http.Request) { chartOptions := []charts.ChartOption{ - // 普通折线图 { Title: charts.TitleOption{ Text: "Line", @@ -174,7 +215,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Title: charts.TitleOption{ Text: "Temperature Change in the Coming Week", }, - Padding: chart.Box{ + Padding: charts.Box{ Top: 20, Left: 20, Right: 30, @@ -221,6 +262,35 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, }, }, + { + Title: charts.TitleOption{ + Text: "Line Area", + }, + Legend: charts.NewLegendOption([]string{ + "Email", + }), + XAxis: charts.NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + SeriesList: []charts.Series{ + charts.NewSeriesFromValues([]float64{ + 120, + 132, + 101, + 134, + 90, + 230, + 210, + }), + }, + FillArea: true, + }, // 柱状图 { Title: charts.TitleOption{ @@ -240,7 +310,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Rainfall", "Evaporation", }, - Icon: charts.LegendIconRect, + Icon: charts.IconRect, }, SeriesList: []charts.Series{ charts.NewSeriesFromValues([]float64{ @@ -260,8 +330,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, { Value: 190, - Style: chart.Style{ - FillColor: drawing.Color{ + Style: charts.Style{ + FillColor: charts.Color{ R: 169, G: 0, B: 0, @@ -285,16 +355,68 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Value: 180, }, }, + Label: charts.SeriesLabel{ + Show: true, + Position: charts.PositionBottom, + }, }, }, }, - // 柱状图+mark + // 水平柱状图 + { + Title: charts.TitleOption{ + Text: "World Population", + }, + Padding: charts.Box{ + Top: 20, + Right: 40, + Bottom: 20, + Left: 20, + }, + Legend: charts.NewLegendOption([]string{ + "2011", + "2012", + }), + YAxisOptions: charts.NewYAxisOptions([]string{ + "Brazil", + "Indonesia", + "USA", + "India", + "China", + "World", + }), + SeriesList: []charts.Series{ + { + Type: charts.ChartTypeHorizontalBar, + Data: charts.NewSeriesDataFromValues([]float64{ + 18203, + 23489, + 29034, + 104970, + 131744, + 630230, + }), + }, + { + Type: charts.ChartTypeHorizontalBar, + Data: charts.NewSeriesDataFromValues([]float64{ + 19325, + 23438, + 31000, + 121594, + 134141, + 681807, + }), + }, + }, + }, + // 柱状图+标记 { Title: charts.TitleOption{ Text: "Rainfall vs Evaporation", Subtext: "Fake Data", }, - Padding: chart.Box{ + Padding: charts.Box{ Top: 20, Right: 20, Bottom: 20, @@ -371,6 +493,9 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, // 双Y轴示例 { + Title: charts.TitleOption{ + Text: "Temperature", + }, XAxis: charts.NewXAxisOption([]string{ "Jan", "Feb", @@ -390,22 +515,22 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Precipitation", "Temperature", }), - YAxisList: []charts.YAxisOption{ + YAxisOptions: []charts.YAxisOption{ { - Formatter: "{value}°C", - Color: drawing.Color{ - R: 250, - G: 200, - B: 88, + Formatter: "{value}ml", + Color: charts.Color{ + R: 84, + G: 112, + B: 198, A: 255, }, }, { - Formatter: "{value}ml", - Color: drawing.Color{ - R: 84, - G: 112, - B: 198, + Formatter: "{value}°C", + Color: charts.Color{ + R: 250, + G: 200, + B: 88, A: 255, }, }, @@ -426,9 +551,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 20.0, 6.4, 3.3, - 10.2, }), - YAxisIndex: 1, }, { Type: charts.ChartTypeBar, @@ -445,9 +568,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 18.8, 6.0, 2.3, - 20.2, }), - YAxisIndex: 1, }, { Data: charts.NewSeriesDataFromValues([]float64{ @@ -463,8 +584,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 16.5, 12.0, 6.2, - 30.3, }), + AxisIndex: 1, }, }, }, @@ -572,6 +693,20 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "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", @@ -593,20 +728,6 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 20, }), }, - { - Type: charts.ChartTypeFunnel, - Name: "Click", - Data: charts.NewSeriesDataFromValues([]float64{ - 80, - }), - }, - { - Type: charts.ChartTypeFunnel, - Name: "Show", - Data: charts.NewSeriesDataFromValues([]float64{ - 100, - }), - }, }, }, // 多图展示 @@ -620,7 +741,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Walnut Brownie", }, }, - Padding: chart.Box{ + Padding: charts.Box{ Top: 100, Right: 10, Bottom: 10, @@ -634,7 +755,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "2016", "2017", }), - YAxisList: []charts.YAxisOption{ + YAxisOptions: []charts.YAxisOption{ { Min: charts.NewFloatPoint(0), @@ -686,7 +807,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Walnut Brownie", }, }, - Box: chart.Box{ + Box: charts.Box{ Top: 20, Left: 400, Right: 500, @@ -1011,6 +1132,64 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { } ] }`, + `{ + "title": { + "text": "World Population" + }, + "tooltip": { + "trigger": "axis", + "axisPointer": { + "type": "shadow" + } + }, + "legend": {}, + "grid": { + "left": "3%", + "right": "4%", + "bottom": "3%", + "containLabel": true + }, + "xAxis": { + "type": "value" + }, + "yAxis": { + "type": "category", + "data": [ + "Brazil", + "Indonesia", + "USA", + "India", + "China", + "World" + ] + }, + "series": [ + { + "name": "2011", + "type": "bar", + "data": [ + 18203, + 23489, + 29034, + 104970, + 131744, + 630230 + ] + }, + { + "name": "2012", + "type": "bar", + "data": [ + 19325, + 23438, + 31000, + 121594, + 134141, + 681807 + ] + } + ] + }`, `{ "title": { "text": "Rainfall vs Evaporation", @@ -1172,12 +1351,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 ] }, { @@ -1191,12 +1365,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 ] }, { @@ -1211,12 +1380,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 ] } ] @@ -1805,5 +1969,6 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { func main() { http.HandleFunc("/", indexHandler) http.HandleFunc("/echarts", echartsHandler) + fmt.Println("http://127.0.0.1:3012/") http.ListenAndServe(":3012", nil) } diff --git a/examples/chinese/main.go b/examples/chinese/main.go index e0125b4..601f54e 100644 --- a/examples/chinese/main.go +++ b/examples/chinese/main.go @@ -2,49 +2,119 @@ package main import ( "io/ioutil" - "log" + "os" + "path/filepath" - charts "github.com/vicanso/go-charts" + "git.smarteching.com/zeni/go-charts/v2" ) -func echartsRender() ([]byte, error) { - return charts.RenderEChartsToPNG(`{ - "title": { - "text": "用户访问次数", - "textStyle": { - "fontFamily": "chinese" - } - }, - "xAxis": { - "data": ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] - }, - "series": [ - { - "data": [150, 230, 224, 218, 135, 147, 260], - "label": { - "show": true - } - } - ] - }`) +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "chinese-line-chart.png") + err = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil } func main() { - fontData, err := ioutil.ReadFile("/Users/darcy/Downloads/NotoSansCJKsc-VF.ttf") + // 字体文件需要自行下载 + // https://github.com/googlefonts/noto-cjk + buf, err := ioutil.ReadFile("./NotoSansSC.ttf") if err != nil { - log.Fatalln("Error when reading font file:", err) + panic(err) } - - if err := charts.InstallFont("chinese", fontData); err != nil { - log.Fatalln("Error when instaling font:", err) + err = charts.InstallFont("noto", buf) + if err != nil { + panic(err) } + font, _ := charts.GetFont("noto") + charts.SetDefaultFont(font) - fileData, err := echartsRender() + values := [][]float64{ + { + 120, + 132, + 101, + 134, + 90, + 230, + 210, + }, + { + 220, + 182, + 191, + 234, + 290, + 330, + 310, + }, + { + 150, + 232, + 201, + 154, + 190, + 330, + 410, + }, + { + 320, + 332, + 301, + 334, + 390, + 330, + 320, + }, + { + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320, + }, + } + p, err := charts.LineRender( + values, + charts.TitleTextOptionFunc("测试"), + charts.XAxisDataOptionFunc([]string{ + "星期一", + "星期二", + "星期三", + "星期四", + "星期五", + "星期六", + "星期日", + }), + charts.LegendLabelsOptionFunc([]string{ + "邮件", + "广告", + "视频广告", + "直接访问", + "搜索引擎", + }, charts.PositionCenter), + ) if err != nil { - log.Fatalln("Error when rendering image:", err) + panic(err) } - if err := ioutil.WriteFile("chinese.png", fileData, 0644); err != nil { - log.Fatalln("Error when save image to chinese.png:", err) + + buf, err = p.Bytes() + if err != nil { + panic(err) + } + err = writeFile(buf) + if err != nil { + panic(err) } } diff --git a/examples/funnel_chart/main.go b/examples/funnel_chart/main.go new file mode 100644 index 0000000..653f834 --- /dev/null +++ b/examples/funnel_chart/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "path/filepath" + + "git.smarteching.com/zeni/go-charts/v2" +) + +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 = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + values := []float64{ + 100, + 80, + 60, + 40, + 20, + 10, + 0, + } + p, err := charts.FunnelRender( + values, + charts.TitleTextOptionFunc("Funnel"), + charts.LegendLabelsOptionFunc([]string{ + "Show", + "Click", + "Visit", + "Inquiry", + "Order", + "Pay", + "Cancel", + }), + ) + 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/horizontal_bar_chart/main.go b/examples/horizontal_bar_chart/main.go new file mode 100644 index 0000000..f5d8497 --- /dev/null +++ b/examples/horizontal_bar_chart/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "os" + "path/filepath" + + "git.smarteching.com/zeni/go-charts/v2" +) + +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "horizontal-bar-chart.png") + err = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + values := [][]float64{ + { + 10, + 30, + 50, + 70, + 90, + 110, + 130, + }, + { + 20, + 40, + 60, + 80, + 100, + 120, + 140, + }, + } + p, err := charts.HorizontalBarRender( + values, + charts.TitleTextOptionFunc("World Population"), + charts.PaddingOptionFunc(charts.Box{ + Top: 20, + Right: 40, + Bottom: 20, + Left: 20, + }), + charts.LegendLabelsOptionFunc([]string{ + "2011", + "2012", + }), + charts.YAxisDataOptionFunc([]string{ + "UN", + "Brazil", + "Indonesia", + "USA", + "India", + "China", + "World", + }), + func(opt *charts.ChartOption) { + opt.SeriesList[0].RoundRadius = 5 + }, + ) + 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 new file mode 100644 index 0000000..baee8a3 --- /dev/null +++ b/examples/line_chart/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "git.smarteching.com/zeni/go-charts/v2" +) + +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "line-chart.png") + err = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + values := [][]float64{ + { + 120, + 132, + 101, + // 134, + charts.GetNullValue(), + 90, + 230, + 210, + }, + { + 220, + 182, + 191, + 234, + 290, + 330, + 310, + }, + { + 150, + 232, + 201, + 154, + 190, + 330, + 410, + }, + { + 320, + 332, + 301, + 334, + 390, + 330, + 320, + }, + { + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320, + }, + } + p, err := charts.LineRender( + values, + charts.TitleTextOptionFunc("Line"), + charts.XAxisDataOptionFunc([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + charts.LegendLabelsOptionFunc([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, "50"), + func(opt *charts.ChartOption) { + opt.Legend.Padding = charts.Box{ + Top: 5, + Bottom: 10, + } + opt.YAxisOptions = []charts.YAxisOption{ + { + SplitLineShow: charts.FalseFlag(), + }, + } + opt.SymbolShow = charts.FalseFlag() + opt.LineStrokeWidth = 1 + opt.ValueFormatter = func(f float64) string { + return fmt.Sprintf("%.0f", f) + } + }, + ) + + 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/painter/main.go b/examples/painter/main.go new file mode 100644 index 0000000..1b842b3 --- /dev/null +++ b/examples/painter/main.go @@ -0,0 +1,607 @@ +package main + +import ( + "os" + "path/filepath" + + charts "git.smarteching.com/zeni/go-charts/v2" + "git.smarteching.com/zeni/go-chart/v2/drawing" +) + +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "painter.png") + err = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + p, err := charts.NewPainter(charts.PainterOptions{ + Width: 600, + Height: 2000, + Type: charts.ChartOutputPNG, + }) + if err != nil { + panic(err) + } + // 背景色 + p.SetBackground(p.Width(), p.Height(), drawing.ColorWhite) + + top := 0 + + // 画线 + p.SetDrawingStyle(charts.Style{ + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlack, + StrokeWidth: 1, + }) + p.LineStroke([]charts.Point{ + { + X: 0, + Y: 0, + }, + { + X: 100, + Y: 10, + }, + { + X: 200, + Y: 0, + }, + { + X: 300, + Y: 10, + }, + }) + + // 圆滑曲线 + // top += 50 + // p.Child(charts.PainterPaddingOption(charts.Box{ + // Top: top, + // })).SetDrawingStyle(charts.Style{ + // StrokeColor: drawing.ColorBlack, + // FillColor: drawing.ColorBlack, + // StrokeWidth: 1, + // }).SmoothLineStroke([]charts.Point{ + // { + // X: 0, + // Y: 0, + // }, + // { + // X: 100, + // Y: 10, + // }, + // { + // X: 200, + // Y: 0, + // }, + // { + // X: 300, + // Y: 10, + // }, + // }) + + // 标线 + top += 50 + p.Child(charts.PainterPaddingOption(charts.Box{ + Top: top, + })).SetDrawingStyle(charts.Style{ + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlack, + StrokeWidth: 1, + StrokeDashArray: []float64{ + 4, + 2, + }, + }).MarkLine(0, 0, p.Width()) + + top += 60 + // Polygon + p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + })).SetDrawingStyle(charts.Style{ + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlack, + StrokeWidth: 1, + }).Polygon(charts.Point{ + X: 100, + Y: 0, + }, 50, 6) + + // FillArea + top += 60 + p.Child(charts.PainterPaddingOption(charts.Box{ + Top: top, + })).SetDrawingStyle(charts.Style{ + FillColor: drawing.ColorBlack, + }).FillArea([]charts.Point{ + { + X: 0, + Y: 0, + }, + { + X: 100, + Y: 0, + }, + { + X: 150, + Y: 40, + }, + { + X: 80, + Y: 30, + }, + { + X: 0, + Y: 0, + }, + }) + + // 坐标轴的点 + top += 50 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: 20, + }), + ).SetDrawingStyle(charts.Style{ + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlack, + StrokeWidth: 1, + }).Ticks(charts.TicksOption{ + Count: 7, + Length: 5, + }) + + // 坐标轴的点,每2格显示一个 + top += 20 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: 20, + }), + ).SetDrawingStyle(charts.Style{ + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlack, + StrokeWidth: 1, + }).Ticks(charts.TicksOption{ + Unit: 2, + Count: 7, + Length: 5, + }) + + // 坐标轴的点,纵向 + top += 20 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 100, + }), + ).SetDrawingStyle(charts.Style{ + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlack, + StrokeWidth: 1, + }).Ticks(charts.TicksOption{ + Orient: charts.OrientVertical, + Count: 7, + Length: 5, + }) + + // 横向展示文本 + top += 120 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: 20, + }), + ).OverrideTextStyle(charts.Style{ + FontColor: drawing.ColorBlack, + FontSize: 10, + }).MultiText(charts.MultiTextOption{ + TextList: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + }) + + // 横向显示文本,靠左 + top += 20 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: 20, + }), + ).OverrideTextStyle(charts.Style{ + FontColor: drawing.ColorBlack, + FontSize: 10, + }).MultiText(charts.MultiTextOption{ + Position: charts.PositionLeft, + TextList: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + }) + + // 纵向显示文本 + top += 20 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: 50, + Bottom: top + 150, + }), + ).OverrideTextStyle(charts.Style{ + FontColor: drawing.ColorBlack, + FontSize: 10, + }).MultiText(charts.MultiTextOption{ + Orient: charts.OrientVertical, + Align: charts.AlignRight, + TextList: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + }) + // 纵向 文本居中 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 50, + Right: 100, + Bottom: top + 150, + }), + ).OverrideTextStyle(charts.Style{ + FontColor: drawing.ColorBlack, + FontSize: 10, + }).MultiText(charts.MultiTextOption{ + Orient: charts.OrientVertical, + Align: charts.AlignCenter, + TextList: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + }) + // 纵向 文本置顶 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 100, + Right: 150, + Bottom: top + 150, + }), + ).OverrideTextStyle(charts.Style{ + FontColor: drawing.ColorBlack, + FontSize: 10, + }).MultiText(charts.MultiTextOption{ + Orient: charts.OrientVertical, + Position: charts.PositionTop, + Align: charts.AlignCenter, + TextList: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + }) + + // grid + top += 150 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 100, + }), + ).OverrideTextStyle(charts.Style{ + FontColor: drawing.ColorBlack, + FontSize: 10, + }).Grid(charts.GridOption{ + Column: 8, + IgnoreColumnLines: []int{0, 8}, + Row: 8, + IgnoreRowLines: []int{0, 8}, + }) + + // dots + top += 100 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 20, + }), + ).OverrideDrawingStyle(charts.Style{ + FillColor: drawing.ColorWhite, + StrokeColor: drawing.ColorBlack, + StrokeWidth: 1, + }).Dots([]charts.Point{ + { + X: 0, + Y: 0, + }, + { + X: 50, + Y: 0, + }, + { + X: 100, + Y: 10, + }, + }) + + // rect + top += 30 + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: 200, + Bottom: top + 50, + }), + ).OverrideDrawingStyle(charts.Style{ + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlack, + }).Rect(charts.Box{ + Left: 10, + Top: 0, + Right: 110, + Bottom: 20, + }) + // legend line dot + p.Child( + charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 200, + Right: p.Width() - 1, + Bottom: top + 50, + }), + ).OverrideDrawingStyle(charts.Style{ + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlack, + }).LegendLineDot(charts.Box{ + Left: 10, + Top: 0, + Right: 50, + Bottom: 20, + }) + + // grid + top += 50 + charts.NewGridPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 100, + })), charts.GridPainterOption{ + Row: 5, + IgnoreFirstRow: true, + IgnoreLastRow: true, + StrokeColor: drawing.ColorBlue, + }).Render() + + // legend + top += 100 + charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 30, + })), charts.LegendOption{ + Left: "10", + Data: []string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + }, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + + // legend + top += 30 + charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 30, + })), charts.LegendOption{ + Left: charts.PositionRight, + Data: []string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + }, + Align: charts.AlignRight, + FontSize: 16, + Icon: charts.IconRect, + FontColor: drawing.ColorBlack, + }).Render() + + // legend + top += 30 + charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 100, + })), charts.LegendOption{ + Top: "10", + Data: []string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + }, + Orient: charts.OrientVertical, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + + // axis bottom + top += 100 + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 50, + })), charts.AxisOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + StrokeColor: drawing.ColorBlack, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + + // axis top + top += 50 + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 50, + })), charts.AxisOption{ + Position: charts.PositionTop, + BoundaryGap: charts.FalseFlag(), + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + StrokeColor: drawing.ColorBlack, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + + // axis left + top += 50 + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 10, + Right: 60, + Bottom: top + 200, + })), charts.AxisOption{ + Position: charts.PositionLeft, + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + StrokeColor: drawing.ColorBlack, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + // axis right + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 100, + Right: 150, + Bottom: top + 200, + })), charts.AxisOption{ + Position: charts.PositionRight, + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + StrokeColor: drawing.ColorBlack, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + + // axis left no tick + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 150, + Right: 300, + Bottom: top + 200, + })), charts.AxisOption{ + BoundaryGap: charts.FalseFlag(), + Position: charts.PositionLeft, + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + FontSize: 12, + FontColor: drawing.ColorBlack, + SplitLineShow: true, + SplitLineColor: drawing.ColorBlack.WithAlpha(100), + }).Render() + + 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..5d70438 --- /dev/null +++ b/examples/pie_chart/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "os" + "path/filepath" + + "git.smarteching.com/zeni/go-charts/v2" +) + +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 = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + values := []float64{ + 1048, + 735, + 580, + 484, + 300, + } + p, err := charts.PieRender( + values, + charts.TitleOptionFunc(charts.TitleOption{ + Text: "Rainfall vs Evaporation", + Subtext: "Fake Data", + Left: charts.PositionCenter, + }), + charts.PaddingOptionFunc(charts.Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }), + charts.LegendOptionFunc(charts.LegendOption{ + Orient: charts.OrientVertical, + Data: []string{ + "Search Engine", + "Direct", + "Email", + "Union Ads", + "Video Ads", + }, + Left: charts.PositionLeft, + }), + charts.PieSeriesShowLabel(), + ) + 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..e7053af --- /dev/null +++ b/examples/radar_chart/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "os" + "path/filepath" + + "git.smarteching.com/zeni/go-charts/v2" +) + +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 = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + values := [][]float64{ + { + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }, + { + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, + }, + } + p, err := charts.RadarRender( + values, + charts.TitleTextOptionFunc("Basic Radar Chart"), + charts.LegendLabelsOptionFunc([]string{ + "Allocated Budget", + "Actual Spending", + }), + charts.RadarIndicatorOptionFunc([]string{ + "Sales", + "Administration", + "Information Technology", + "Customer Support", + "Development", + "Marketing", + }, []float64{ + 6500, + 16000, + 30000, + 38000, + 52000, + 25000, + }), + ) + 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/table/main.go b/examples/table/main.go new file mode 100644 index 0000000..de994eb --- /dev/null +++ b/examples/table/main.go @@ -0,0 +1,178 @@ +package main + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + "git.smarteching.com/zeni/go-charts/v2" + "git.smarteching.com/zeni/go-chart/v2/drawing" +) + +func writeFile(buf []byte, filename string) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, filename) + err = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + // charts.SetDefaultTableSetting(charts.TableDarkThemeSetting) + charts.SetDefaultWidth(810) + header := []string{ + "Name", + "Age", + "Address", + "Tag", + "Action", + } + data := [][]string{ + { + "John Brown", + "32", + "New York No. 1 Lake Park", + "nice, developer", + "Send Mail", + }, + { + "Jim Green ", + "42", + "London No. 1 Lake Park", + "wow", + "Send Mail", + }, + { + "Joe Black ", + "32", + "Sidney No. 1 Lake Park", + "cool, teacher", + "Send Mail", + }, + } + spans := map[int]int{ + 0: 2, + 1: 1, + // 设置第三列的span + 2: 3, + 3: 2, + 4: 2, + } + p, err := charts.TableRender( + header, + data, + spans, + ) + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + err = writeFile(buf, "table.png") + if err != nil { + panic(err) + } + + bgColor := charts.Color{ + R: 16, + G: 22, + B: 30, + A: 255, + } + p, err = charts.TableOptionRender(charts.TableChartOption{ + Header: []string{ + "Name", + "Price", + "Change", + }, + BackgroundColor: bgColor, + HeaderBackgroundColor: bgColor, + RowBackgroundColors: []charts.Color{ + bgColor, + }, + HeaderFontColor: drawing.ColorWhite, + FontColor: drawing.ColorWhite, + Padding: charts.Box{ + Top: 15, + Right: 10, + Bottom: 15, + Left: 10, + }, + Data: [][]string{ + { + "Datadog Inc", + "97.32", + "-7.49%", + }, + { + "Hashicorp Inc", + "28.66", + "-9.25%", + }, + { + "Gitlab Inc", + "51.63", + "+4.32%", + }, + }, + TextAligns: []string{ + "", + charts.AlignRight, + charts.AlignRight, + }, + CellStyle: func(tc charts.TableCell) *charts.Style { + column := tc.Column + if column != 2 { + return nil + } + value, _ := strconv.ParseFloat(strings.Replace(tc.Text, "%", "", 1), 64) + if value == 0 { + return nil + } + style := charts.Style{ + Padding: charts.Box{ + Bottom: 5, + }, + } + if value > 0 { + style.FillColor = charts.Color{ + R: 179, + G: 53, + B: 20, + A: 255, + } + } else if value < 0 { + style.FillColor = charts.Color{ + R: 33, + G: 124, + B: 50, + A: 255, + } + } + return &style + }, + }) + if err != nil { + panic(err) + } + + buf, err = p.Bytes() + if err != nil { + panic(err) + } + err = writeFile(buf, "table-color.png") + if err != nil { + panic(err) + } +} diff --git a/examples/time_line_chart/main.go b/examples/time_line_chart/main.go new file mode 100644 index 0000000..c6c93bf --- /dev/null +++ b/examples/time_line_chart/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "crypto/rand" + "fmt" + "math/big" + "os" + "path/filepath" + "time" + + "git.smarteching.com/zeni/go-charts/v2" +) + +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "time-line-chart.png") + err = os.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + xAxisValue := []string{} + values := []float64{} + now := time.Now() + firstAxis := 0 + for i := 0; i < 300; i++ { + // 设置首个axis为xx:00的时间点 + if firstAxis == 0 && now.Minute() == 0 { + firstAxis = i + } + xAxisValue = append(xAxisValue, now.Format("15:04")) + now = now.Add(time.Minute) + value, _ := rand.Int(rand.Reader, big.NewInt(100)) + values = append(values, float64(value.Int64())) + } + p, err := charts.LineRender( + [][]float64{ + values, + }, + charts.TitleTextOptionFunc("Line"), + charts.XAxisDataOptionFunc(xAxisValue, charts.FalseFlag()), + charts.LegendLabelsOptionFunc([]string{ + "Demo", + }, "50"), + func(opt *charts.ChartOption) { + opt.XAxis.FirstAxis = firstAxis + // 必须要比计算得来的最小值更大(每60分钟) + opt.XAxis.SplitNumber = 60 + opt.Legend.Padding = charts.Box{ + Top: 5, + Bottom: 10, + } + opt.SymbolShow = charts.FalseFlag() + opt.LineStrokeWidth = 1 + opt.ValueFormatter = func(f float64) string { + return fmt.Sprintf("%.0f", f) + } + }, + ) + + 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/font.go b/font.go index c40b51e..828654e 100644 --- a/font.go +++ b/font.go @@ -27,14 +27,18 @@ import ( "sync" "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2/roboto" + "git.smarteching.com/zeni/go-chart/v2/roboto" ) var fonts = sync.Map{} var ErrFontNotExists = errors.New("font is not exists") +var defaultFontFamily = "defaultFontFamily" func init() { - _ = InstallFont("roboto", roboto.Roboto) + name := "roboto" + _ = InstallFont(name, roboto.Roboto) + font, _ := GetFont(name) + SetDefaultFont(font) } // InstallFont installs the font for charts @@ -47,6 +51,19 @@ func InstallFont(fontFamily string, data []byte) error { return nil } +// GetDefaultFont get default font +func GetDefaultFont() (*truetype.Font, error) { + return GetFont(defaultFontFamily) +} + +// SetDefaultFont set default font +func SetDefaultFont(font *truetype.Font) { + if font == nil { + return + } + fonts.Store(defaultFontFamily, font) +} + // GetFont get the font by font family func GetFont(fontFamily string) (*truetype.Font, error) { value, ok := fonts.Load(fontFamily) diff --git a/font_test.go b/font_test.go index 9dc731c..e0c56b2 100644 --- a/font_test.go +++ b/font_test.go @@ -26,7 +26,7 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2/roboto" + "git.smarteching.com/zeni/go-chart/v2/roboto" ) func TestInstallFont(t *testing.T) { diff --git a/funnel.go b/funnel_chart.go similarity index 53% rename from funnel.go rename to funnel_chart.go index f083306..d4a8bdd 100644 --- a/funnel.go +++ b/funnel_chart.go @@ -23,35 +23,54 @@ package charts import ( - "fmt" - "sort" - - "github.com/dustin/go-humanize" "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" ) -type funnelChartOption struct { - Theme string - Font *truetype.Font - SeriesList SeriesList +type funnelChart struct { + p *Painter + opt *FunnelChartOption } -func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error { - d, err := NewDraw(DrawOption{ - Parent: result.d, - }, PaddingOption(chart.Box{ - Top: result.titleBox.Height(), - })) - if err != nil { - return err +// NewFunnelSeriesList returns a series list for funnel +func NewFunnelSeriesList(values []float64) SeriesList { + seriesList := make(SeriesList, len(values)) + for index, value := range values { + seriesList[index] = NewSeriesFromValues([]float64{ + value, + }, ChartTypeFunnel) } - seriesList := make([]Series, len(opt.SeriesList)) - copy(seriesList, opt.SeriesList) - sort.Slice(seriesList, func(i, j int) bool { - // 大的数据在前 - return seriesList[i].Data[0].Value > seriesList[j].Data[0].Value - }) + return seriesList +} + +// NewFunnelChart returns a funnel chart renderer +func NewFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart { + if opt.Theme == nil { + opt.Theme = defaultTheme + } + return &funnelChart{ + p: p, + opt: &opt, + } +} + +type FunnelChartOption struct { + // The theme + 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 { @@ -62,11 +81,10 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error { min = *item.Min } } - - theme := NewTheme(opt.Theme) + theme := opt.Theme gap := 2 - height := d.Box.Height() - width := d.Box.Width() + height := seriesPainter.Height() + width := seriesPainter.Width() count := len(seriesList) h := (height - gap*(count-1)) / count @@ -74,13 +92,23 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error { y := 0 widthList := make([]int, len(seriesList)) textList := make([]string, len(seriesList)) + seriesNames := seriesList.Names() + offset := max - min for index, item := range seriesList { value := item.Data[0].Value - widthPercent := (value - min) / (max - min) + // 最大最小值一致则为100% + widthPercent := 100.0 + if offset != 0 { + widthPercent = (value - min) / offset + } w := int(widthPercent * float64(width)) widthList[index] = w - p := humanize.CommafWithDigits(value/max*100, 2) + "%" - textList[index] = fmt.Sprintf("%s(%s)", item.Name, p) + // 如果最大值为0,则占比100% + percent := 1.0 + if max != 0 { + percent = value / max + } + textList[index] = NewFunnelLabelFormatter(seriesNames, item.Label.Formatter)(index, value, percent) } for index, w := range widthList { @@ -116,26 +144,49 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error { }, } color := theme.GetSeriesColor(series.index) - d.fill(points, chart.Style{ + + seriesPainter.OverrideDrawingStyle(Style{ FillColor: color, - }) + }).FillArea(points) // 文本 text := textList[index] - r := d.Render - textStyle := chart.Style{ + seriesPainter.OverrideTextStyle(Style{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, - } - textStyle.GetTextOptions().WriteToRenderer(r) - textBox := r.MeasureText(text) + }) + textBox := seriesPainter.MeasureText(text) textX := width>>1 - textBox.Width()>>1 textY := y + h>>1 - d.text(text, textX, textY) - + seriesPainter.Text(text, textX, textY) y += (h + gap) } - return nil + 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/funnel_chart_test.go b/funnel_chart_test.go new file mode 100644 index 0000000..d260bfb --- /dev/null +++ b/funnel_chart_test.go @@ -0,0 +1,79 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFunnelChart(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + _, err := NewFunnelChart(p, FunnelChartOption{ + SeriesList: NewFunnelSeriesList([]float64{ + 100, + 80, + 60, + 40, + 20, + }), + Legend: NewLegendOption([]string{ + "Show", + "Click", + "Visit", + "Inquiry", + "Order", + }), + Title: TitleOption{ + Text: "Funnel", + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nShowClickVisitInquiryOrderFunnel(100%)(80%)(60%)(40%)(20%)", + }, + } + + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/funnel_test.go b/funnel_test.go deleted file mode 100644 index 530fa53..0000000 --- a/funnel_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// 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 ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" -) - -func TestFunnelChartRender(t *testing.T) { - assert := assert.New(t) - - d, err := NewDraw(DrawOption{ - Width: 250, - Height: 150, - }) - assert.Nil(err) - f, _ := chart.GetDefaultFont() - err = funnelChartRender(funnelChartOption{ - Font: f, - SeriesList: []Series{ - { - Type: ChartTypeFunnel, - Name: "Visit", - Data: NewSeriesDataFromValues([]float64{ - 60, - }), - }, - { - Type: ChartTypeFunnel, - Name: "Inquiry", - Data: NewSeriesDataFromValues([]float64{ - 40, - }), - index: 1, - }, - { - Type: ChartTypeFunnel, - Name: "Order", - Data: NewSeriesDataFromValues([]float64{ - 20, - }), - index: 2, - }, - { - Type: ChartTypeFunnel, - Name: "Click", - Data: NewSeriesDataFromValues([]float64{ - 80, - }), - index: 3, - }, - { - Type: ChartTypeFunnel, - Name: "Show", - Data: NewSeriesDataFromValues([]float64{ - 100, - }), - index: 4, - }, - }, - }, &basicRenderResult{ - d: d, - }) - assert.Nil(err) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\nShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", string(data)) -} diff --git a/go.mod b/go.mod index 610af22..76a47b6 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,17 @@ -module github.com/vicanso/go-charts +module git.smarteching.com/zeni/go-charts/v2 -go 1.17 +go 1.24.1 require ( - github.com/dustin/go-humanize v1.0.0 + git.smarteching.com/zeni/go-chart/v2 v2.1.4 + github.com/dustin/go-humanize v1.0.1 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 - github.com/stretchr/testify v1.7.1 - github.com/wcharczuk/go-chart/v2 v2.1.0 + github.com/stretchr/testify v1.10.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + golang.org/x/image v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d88f473..3e1a48a 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +1,18 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +git.smarteching.com/zeni/go-chart/v2 v2.1.4 h1:pF06+F6eqJLIG8uMiTVPR5TygPGMjM/FHMzTxmu5V/Q= +git.smarteching.com/zeni/go-chart/v2 v2.1.4/go.mod h1:b3ueW9h3pGGXyhkormZAvilHaG4+mQti+bMNPdQBeOQ= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I= -github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= -golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE= -golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= +golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grid.go b/grid.go new file mode 100644 index 0000000..0ebd226 --- /dev/null +++ b/grid.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 + +type gridPainter struct { + p *Painter + opt *GridPainterOption +} + +type GridPainterOption struct { + // The stroke width + StrokeWidth float64 + // The stroke color + StrokeColor Color + // The spans of column + ColumnSpans []int + // The column of grid + Column int + // The row of grid + Row int + // Ignore first row + IgnoreFirstRow bool + // Ignore last row + IgnoreLastRow bool + // Ignore first column + IgnoreFirstColumn bool + // Ignore last column + IgnoreLastColumn bool +} + +// NewGridPainter returns new a grid renderer +func NewGridPainter(p *Painter, opt GridPainterOption) *gridPainter { + return &gridPainter{ + p: p, + opt: &opt, + } +} + +func (g *gridPainter) Render() (Box, error) { + opt := g.opt + ignoreColumnLines := make([]int, 0) + if opt.IgnoreFirstColumn { + ignoreColumnLines = append(ignoreColumnLines, 0) + } + if opt.IgnoreLastColumn { + ignoreColumnLines = append(ignoreColumnLines, opt.Column) + } + ignoreRowLines := make([]int, 0) + if opt.IgnoreFirstRow { + ignoreRowLines = append(ignoreRowLines, 0) + } + if opt.IgnoreLastRow { + ignoreRowLines = append(ignoreRowLines, opt.Row) + } + strokeWidth := opt.StrokeWidth + if strokeWidth <= 0 { + strokeWidth = 1 + } + + g.p.SetDrawingStyle(Style{ + StrokeWidth: strokeWidth, + StrokeColor: opt.StrokeColor, + }) + g.p.Grid(GridOption{ + Column: opt.Column, + ColumnSpans: opt.ColumnSpans, + Row: opt.Row, + IgnoreColumnLines: ignoreColumnLines, + IgnoreRowLines: ignoreRowLines, + }) + return g.p.box, nil +} diff --git a/grid_test.go b/grid_test.go new file mode 100644 index 0000000..fa9c3a6 --- /dev/null +++ b/grid_test.go @@ -0,0 +1,87 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "git.smarteching.com/zeni/go-chart/v2/drawing" +) + +func TestGrid(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + _, err := NewGridPainter(p, GridPainterOption{ + StrokeColor: drawing.ColorBlack, + Column: 6, + Row: 6, + IgnoreFirstRow: true, + IgnoreLastRow: true, + IgnoreFirstColumn: true, + IgnoreLastColumn: true, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\n", + }, + { + render: func(p *Painter) ([]byte, error) { + _, err := NewGridPainter(p, GridPainterOption{ + StrokeColor: drawing.ColorBlack, + ColumnSpans: []int{ + 2, + 5, + 3, + }, + Row: 6, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\n", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go new file mode 100644 index 0000000..ed091c9 --- /dev/null +++ b/horizontal_bar_chart.go @@ -0,0 +1,216 @@ +// 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" + "git.smarteching.com/zeni/go-chart/v2" +) + +type horizontalBarChart struct { + p *Painter + opt *HorizontalBarChartOption +} + +type HorizontalBarChartOption struct { + // The theme + 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 + BarHeight int + // Margin of bar + BarMargin int +} + +// NewHorizontalBarChart returns a horizontal bar chart renderer +func NewHorizontalBarChart(p *Painter, opt HorizontalBarChartOption) *horizontalBarChart { + if opt.Theme == nil { + opt.Theme = defaultTheme + } + return &horizontalBarChart{ + p: p, + opt: &opt, + } +} + +func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + p := h.p + opt := h.opt + seriesPainter := result.seriesPainter + yRange := result.axisRanges[0] + y0, y1 := yRange.GetRange(0) + height := int(y1 - y0) + // 每一块之间的margin + margin := 10 + // 每一个bar之间的margin + barMargin := 5 + if height < 20 { + margin = 2 + barMargin = 2 + } else if height < 50 { + margin = 5 + barMargin = 3 + } + if opt.BarMargin > 0 { + barMargin = opt.BarMargin + } + seriesCount := len(seriesList) + // 总的高度-两个margin-(总数-1)的barMargin + barHeight := (height - 2*margin - barMargin*(seriesCount-1)) / seriesCount + if opt.BarHeight > 0 && opt.BarHeight < barHeight { + barHeight = opt.BarHeight + margin = (height - seriesCount*barHeight - barMargin*(seriesCount-1)) / 2 + } + + theme := opt.Theme + + max, min := seriesList.GetMaxMin(0) + xRange := NewRange(AxisRangeOption{ + Painter: p, + Min: min, + Max: max, + DivideCount: defaultAxisDivideCount, + Size: seriesPainter.Width(), + }) + seriesNames := seriesList.Names() + + rendererList := []Renderer{} + for index := range seriesList { + series := seriesList[index] + seriesColor := theme.GetSeriesColor(series.index) + divideValues := yRange.AutoDivide() + + var labelPainter *SeriesLabelPainter + if series.Label.Show { + labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{ + P: seriesPainter, + SeriesNames: seriesNames, + Label: series.Label, + Theme: opt.Theme, + Font: opt.Font, + }) + rendererList = append(rendererList, labelPainter) + } + for j, item := range series.Data { + if j >= yRange.divideCount { + continue + } + // 显示位置切换 + j = yRange.divideCount - j - 1 + y := divideValues[j] + y += margin + if index != 0 { + y += index * (barHeight + barMargin) + } + + w := int(xRange.getHeight(item.Value)) + fillColor := seriesColor + if !item.Style.FillColor.IsZero() { + fillColor = item.Style.FillColor + } + right := w + if series.RoundRadius <= 0 { + seriesPainter.OverrideDrawingStyle(Style{ + FillColor: fillColor, + }).Rect(chart.Box{ + Top: y, + Left: 0, + Right: right, + Bottom: y + barHeight, + }) + } else { + seriesPainter.OverrideDrawingStyle(Style{ + FillColor: fillColor, + }).RoundedRect(chart.Box{ + Top: y, + Left: 0, + Right: right, + Bottom: y + barHeight, + }, series.RoundRadius) + } + + // 如果label不需要展示,则返回 + if labelPainter == nil { + continue + } + labelValue := LabelValue{ + Orient: OrientHorizontal, + Index: index, + Value: item.Value, + X: right, + Y: y + barHeight>>1, + Offset: series.Label.Offset, + FontColor: series.Label.Color, + FontSize: series.Label.FontSize, + } + if series.Label.Position == PositionLeft { + labelValue.X = 0 + if labelValue.FontColor.IsZero() { + if isLightColor(fillColor) { + labelValue.FontColor = defaultLightFontColor + } else { + labelValue.FontColor = defaultDarkFontColor + } + } + } + labelPainter.Add(labelValue) + } + } + err := doRender(rendererList...) + if err != nil { + return BoxZero, err + } + return p.box, nil +} + +func (h *horizontalBarChart) Render() (Box, error) { + p := h.p + opt := h.opt + renderResult, err := defaultRender(p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: opt.XAxis, + YAxisOptions: opt.YAxisOptions, + TitleOption: opt.Title, + LegendOption: opt.Legend, + axisReversed: true, + }) + if err != nil { + return BoxZero, err + } + seriesList := opt.SeriesList.Filter(ChartTypeHorizontalBar) + return h.render(renderResult, seriesList) +} diff --git a/horizontal_bar_chart_test.go b/horizontal_bar_chart_test.go new file mode 100644 index 0000000..e078c4a --- /dev/null +++ b/horizontal_bar_chart_test.go @@ -0,0 +1,100 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHorizontalBarChart(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + _, err := NewHorizontalBarChart(p, HorizontalBarChartOption{ + Padding: Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + }, + SeriesList: NewSeriesListDataFromValues([][]float64{ + { + 18203, + 23489, + 29034, + 104970, + 131744, + 630230, + }, + { + 19325, + 23438, + 31000, + 121594, + 134141, + 681807, + }, + }, ChartTypeHorizontalBar), + Title: TitleOption{ + Text: "World Population", + }, + Legend: NewLegendOption([]string{ + "2011", + "2012", + }), + YAxisOptions: NewYAxisOptions([]string{ + "Brazil", + "Indonesia", + "USA", + "India", + "China", + "World", + }), + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0122.28k244.56k366.84k489.12k611.4k733.68k", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/legend.go b/legend.go index df72757..035642c 100644 --- a/legend.go +++ b/legend.go @@ -25,16 +25,19 @@ package charts import ( "strconv" "strings" - - "github.com/wcharczuk/go-chart/v2" ) +type legendPainter struct { + p *Painter + opt *LegendOption +} + +const IconRect = "rect" +const IconLineDot = "lineDot" + type LegendOption struct { - theme string - // Legend show flag, if nil or true, the legend will be shown - Show *bool - // Legend text style - Style chart.Style + // The theme + Theme ColorPalette // Text array of legend Data []string // Distance between legend component and the left side of the container. @@ -50,177 +53,199 @@ type LegendOption struct { Orient string // Icon of the legend. Icon string + // Font size of legend text + FontSize float64 + // FontColor color of legend text + FontColor Color + // The flag for show legend, set this to *false will hide legend + Show *bool + // The padding of legend + Padding Box } -const ( - LegendIconRect = "rect" -) - -// NewLegendOption creates a new legend option by legend text list -func NewLegendOption(data []string, position ...string) LegendOption { +// NewLegendOption returns a legend option +func NewLegendOption(labels []string, left ...string) LegendOption { opt := LegendOption{ - Data: data, + Data: labels, } - if len(position) != 0 { - opt.Left = position[0] + if len(left) != 0 { + opt.Left = left[0] } return opt } -type legend struct { - d *Draw - opt *LegendOption +// IsEmpty checks legend is empty +func (opt *LegendOption) IsEmpty() bool { + isEmpty := true + for _, v := range opt.Data { + if v != "" { + isEmpty = false + break + } + } + return isEmpty } -func NewLegend(d *Draw, opt LegendOption) *legend { - return &legend{ - d: d, +// NewLegendPainter returns a legend renderer +func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter { + return &legendPainter{ + p: p, opt: &opt, } } -func (l *legend) Render() (chart.Box, error) { - d := l.d +func (l *legendPainter) Render() (Box, error) { opt := l.opt - if len(opt.Data) == 0 || isFalse(opt.Show) { - return chart.BoxZero, nil + theme := opt.Theme + if opt.IsEmpty() || + isFalse(opt.Show) { + return BoxZero, nil } - theme := NewTheme(opt.theme) - padding := opt.Style.Padding - legendDraw, err := NewDraw(DrawOption{ - Parent: d, - }, PaddingOption(padding)) - if err != nil { - return chart.BoxZero, err + if theme == nil { + theme = l.p.theme } - r := legendDraw.Render - opt.Style.GetTextOptions().WriteToRenderer(r) - - x := 0 - y := 0 - top := 0 - // TODO TOP 暂只支持数值 - if opt.Top != "" { - top, _ = strconv.Atoi(opt.Top) - y += top + if opt.FontSize == 0 { + opt.FontSize = theme.GetFontSize() } - legendWidth := 30 - legendDotHeight := 5 - textPadding := 5 - legendMargin := 10 - // 往下移2倍dot的高度 - y += 2 * legendDotHeight - - widthCount := 0 + if opt.FontColor.IsZero() { + opt.FontColor = theme.GetTextColor() + } + if opt.Left == "" { + opt.Left = PositionCenter + } + padding := opt.Padding + if padding.IsZero() { + padding.Top = 5 + } + p := l.p.Child(PainterPaddingOption(padding)) + p.SetTextStyle(Style{ + FontSize: opt.FontSize, + FontColor: opt.FontColor, + }) + measureList := make([]Box, len(opt.Data)) maxTextWidth := 0 - // 文本宽度 - for _, text := range opt.Data { - b := r.MeasureText(text) + for index, text := range opt.Data { + b := p.MeasureText(text) if b.Width() > maxTextWidth { maxTextWidth = b.Width() } - widthCount += b.Width() - } - if opt.Orient == OrientVertical { - widthCount = maxTextWidth + legendWidth + textPadding - } else { - // 加上标记 - widthCount += legendWidth * len(opt.Data) - // 文本的padding - widthCount += 2 * textPadding * len(opt.Data) - // margin的宽度 - widthCount += legendMargin * (len(opt.Data) - 1) + measureList[index] = b } + // 计算展示的宽高 + width := 0 + height := 0 + offset := 20 + textOffset := 2 + legendWidth := 30 + legendHeight := 20 + itemMaxHeight := 0 + for _, item := range measureList { + if item.Height() > itemMaxHeight { + itemMaxHeight = item.Height() + } + if opt.Orient == OrientVertical { + height += item.Height() + } else { + width += item.Width() + } + } + // 增加padding + itemMaxHeight += 10 + if opt.Orient == OrientVertical { + width = maxTextWidth + textOffset + legendWidth + height = offset * len(opt.Data) + } else { + height = legendHeight + offsetValue := (len(opt.Data) - 1) * (offset + textOffset) + allLegendWidth := len(opt.Data) * legendWidth + width += (offsetValue + allLegendWidth) + } + + // 计算开始的位置 left := 0 switch opt.Left { case PositionRight: - left = legendDraw.Box.Width() - widthCount + left = p.Width() - width case PositionCenter: - left = (legendDraw.Box.Width() - widthCount) >> 1 + left = (p.Width() - width) >> 1 default: if strings.HasSuffix(opt.Left, "%") { value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) - left = legendDraw.Box.Width() * value / 100 + left = p.Width() * value / 100 } else { value, _ := strconv.Atoi(opt.Left) left = value } } - x = left + top, _ := strconv.Atoi(opt.Top) + + if left < 0 { + left = 0 + } + + x := int(left) + y := int(top) + 10 + startY := y + x0 := x + y0 := y + + drawIcon := func(top, left int) int { + if opt.Icon == IconRect { + p.Rect(Box{ + Top: top - legendHeight + 8, + Left: left, + Right: left + legendWidth, + Bottom: top + 1, + }) + } else { + p.LegendLineDot(Box{ + Top: top + 1, + Left: left, + Right: left + legendWidth, + Bottom: top + legendHeight + 1, + }) + } + return left + legendWidth + } + lastIndex := len(opt.Data) - 1 for index, text := range opt.Data { - textBox := r.MeasureText(text) - var renderText func() + color := theme.GetSeriesColor(index) + p.SetDrawingStyle(Style{ + FillColor: color, + StrokeColor: color, + }) + itemWidth := x0 + measureList[index].Width() + textOffset + offset + legendWidth + if lastIndex == index { + itemWidth = x0 + measureList[index].Width() + legendWidth + } + if itemWidth > p.Width() { + x0 = 0 + y += itemMaxHeight + y0 = y + } + if opt.Align != AlignRight { + x0 = drawIcon(y0, x0) + x0 += textOffset + } + p.Text(text, x0, y0) + x0 += measureList[index].Width() + if opt.Align == AlignRight { + x0 += textOffset + x0 = drawIcon(y0, x0) + } if opt.Orient == OrientVertical { - // 垂直 - // 重置x的位置 - x = left - renderText = func() { - x += textPadding - legendDraw.text(text, x, y+legendDotHeight) - x += textBox.Width() - y += (2*legendDotHeight + legendMargin) - } - + y0 += offset + x0 = x } else { - // 水平 - if index != 0 { - x += legendMargin - } - renderText = func() { - x += textPadding - legendDraw.text(text, x, y+legendDotHeight) - x += textBox.Width() - x += textPadding - } - } - if opt.Align == PositionRight { - renderText() - } - seriesColor := theme.GetSeriesColor(index) - fillColor := seriesColor - if !theme.IsDark() { - fillColor = theme.GetBackgroundColor() - } - style := chart.Style{ - StrokeColor: seriesColor, - FillColor: fillColor, - StrokeWidth: 3, - } - if opt.Icon == LegendIconRect { - style.FillColor = seriesColor - style.StrokeWidth = 1 - } - style.GetFillAndStrokeOptions().WriteDrawingOptionsToRenderer(r) - - if opt.Icon == LegendIconRect { - legendDraw.moveTo(x, y-legendDotHeight) - legendDraw.lineTo(x+legendWidth, y-legendDotHeight) - legendDraw.lineTo(x+legendWidth, y+legendDotHeight) - legendDraw.lineTo(x, y+legendDotHeight) - legendDraw.lineTo(x, y-legendDotHeight) - r.FillStroke() - } else { - legendDraw.moveTo(x, y) - legendDraw.lineTo(x+legendWidth, y) - r.Stroke() - legendDraw.circle(float64(legendDotHeight), x+legendWidth>>1, y) - r.FillStroke() - } - x += legendWidth - - if opt.Align != PositionRight { - renderText() + x0 += offset + y0 = y } + height = y0 - startY + 10 } - legendBox := padding.Clone() - // 计算展示区域 - if opt.Orient == OrientVertical { - legendBox.Right = legendBox.Left + left + maxTextWidth + legendWidth + textPadding - legendBox.Bottom = legendBox.Top + y - } else { - legendBox.Right = legendBox.Left + x - legendBox.Bottom = legendBox.Top + 2*legendDotHeight + top + textPadding - } - return legendBox, nil + + return Box{ + Right: width, + Bottom: height + padding.Bottom + padding.Top, + }, nil } diff --git a/legend_test.go b/legend_test.go index c5d7e50..526f178 100644 --- a/legend_test.go +++ b/legend_test.go @@ -26,160 +26,77 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestNewLegendOption(t *testing.T) { +func TestNewLegend(t *testing.T) { assert := assert.New(t) - opt := NewLegendOption([]string{ - "a", - "b", - }, PositionRight) - assert.Equal(LegendOption{ - Data: []string{ - "a", - "b", - }, - Left: PositionRight, - }, opt) -} - -func TestLegendRender(t *testing.T) { - assert := assert.New(t) - - newDraw := func() *Draw { - d, _ := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - return d - } - style := chart.Style{ - FontSize: 10, - FontColor: drawing.ColorBlack, - } - style.Font, _ = chart.GetDefaultFont() - tests := []struct { - newDraw func() *Draw - newLegend func(*Draw) *legend - box chart.Box - result string + render func(*Painter) ([]byte, error) + result string }{ { - newDraw: newDraw, - newLegend: func(d *Draw) *legend { - return NewLegend(d, LegendOption{ - Top: "10", + render: func(p *Painter) ([]byte, error) { + _, err := NewLegendPainter(p, LegendOption{ Data: []string{ - "Mon", - "Tue", - "Wed", + "One", + "Two", + "Three", }, - Style: style, - }) - }, - result: "\\nMonTueWed", - box: chart.Box{ - Right: 214, - Bottom: 25, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\nOneTwoThree", }, { - newDraw: newDraw, - newLegend: func(d *Draw) *legend { - return NewLegend(d, LegendOption{ - Top: "10", - Left: PositionRight, - Align: PositionRight, + render: func(p *Painter) ([]byte, error) { + _, err := NewLegendPainter(p, LegendOption{ Data: []string{ - "Mon", - "Tue", - "Wed", + "One", + "Two", + "Three", }, - Style: style, - }) - }, - result: "\\nMonTueWed", - box: chart.Box{ - Right: 400, - Bottom: 25, - }, - }, - { - newDraw: newDraw, - newLegend: func(d *Draw) *legend { - return NewLegend(d, LegendOption{ - Top: "10", - Left: PositionCenter, - Data: []string{ - "Mon", - "Tue", - "Wed", - }, - Style: style, - }) - }, - result: "\\nMonTueWed", - box: chart.Box{ - Right: 307, - Bottom: 25, - }, - }, - { - newDraw: newDraw, - newLegend: func(d *Draw) *legend { - return NewLegend(d, LegendOption{ - Top: "10", Left: PositionLeft, - Data: []string{ - "Mon", - "Tue", - "Wed", - }, - Style: style, - Orient: OrientVertical, - }) - }, - result: "\\nMonTueWed", - box: chart.Box{ - Right: 61, - Bottom: 80, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\nOneTwoThree", }, { - newDraw: newDraw, - newLegend: func(d *Draw) *legend { - return NewLegend(d, LegendOption{ - Top: "10", - Left: "10%", + render: func(p *Painter) ([]byte, error) { + _, err := NewLegendPainter(p, LegendOption{ Data: []string{ - "Mon", - "Tue", - "Wed", + "One", + "Two", + "Three", }, - Style: style, Orient: OrientVertical, - }) + Icon: IconRect, + Left: "10%", + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, - box: chart.Box{ - Right: 101, - Bottom: 80, - }, - result: "\\nMonTueWed", + result: "\\nOneTwoThree", }, } - for _, tt := range tests { - d := tt.newDraw() - b, err := tt.newLegend(d).Render() + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) assert.Nil(err) - assert.Equal(tt.box, b) - data, err := d.Bytes() + data, err := tt.render(p) assert.Nil(err) - assert.NotEmpty(data) assert.Equal(tt.result, string(data)) } } diff --git a/line.go b/line.go deleted file mode 100644 index 0fc25d6..0000000 --- a/line.go +++ /dev/null @@ -1,103 +0,0 @@ -// 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/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -type LineStyle struct { - ClassName string - StrokeDashArray []float64 - StrokeColor drawing.Color - StrokeWidth float64 - FillColor drawing.Color - DotWidth float64 - DotColor drawing.Color - DotFillColor drawing.Color -} - -func (ls *LineStyle) Style() chart.Style { - return chart.Style{ - ClassName: ls.ClassName, - StrokeDashArray: ls.StrokeDashArray, - StrokeColor: ls.StrokeColor, - StrokeWidth: ls.StrokeWidth, - FillColor: ls.FillColor, - DotWidth: ls.DotWidth, - DotColor: ls.DotColor, - } -} - -func (d *Draw) lineFill(points []Point, style LineStyle) { - s := style.Style() - if !(s.ShouldDrawStroke() && s.ShouldDrawFill()) { - return - } - - newPoints := make([]Point, len(points)) - copy(newPoints, points) - x0 := points[0].X - y0 := points[0].Y - height := d.Box.Height() - newPoints = append(newPoints, Point{ - X: points[len(points)-1].X, - Y: height, - }, Point{ - X: x0, - Y: height, - }, Point{ - X: x0, - Y: y0, - }) - d.fill(newPoints, style.Style()) -} - -func (d *Draw) lineDot(points []Point, style LineStyle) { - s := style.Style() - if !s.ShouldDrawDot() { - return - } - r := d.Render - dotWith := s.GetDotWidth() - - s.GetDotOptions().WriteDrawingOptionsToRenderer(r) - for _, point := range points { - if !style.DotFillColor.IsZero() { - r.SetFillColor(style.DotFillColor) - } - r.SetStrokeColor(s.DotColor) - d.circle(dotWith, point.X, point.Y) - r.FillStroke() - } -} - -func (d *Draw) Line(points []Point, style LineStyle) { - if len(points) == 0 { - return - } - d.lineFill(points, style) - d.lineStroke(points, style) - d.lineDot(points, style) -} diff --git a/line_chart.go b/line_chart.go index ac9091c..fb1d16a 100644 --- a/line_chart.go +++ b/line_chart.go @@ -23,109 +23,218 @@ package charts import ( + "math" + "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) -type lineChartOption struct { - Theme string - SeriesList SeriesList - Font *truetype.Font +type lineChart struct { + p *Painter + opt *LineChartOption } -func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPointRenderOption, error) { - - theme := NewTheme(opt.Theme) - - d, err := NewDraw(DrawOption{ - Parent: result.d, - }, PaddingOption(chart.Box{ - Top: result.titleBox.Height(), - Left: YAxisWidth, - })) - if err != nil { - return nil, err +// NewLineChart returns a line chart render +func NewLineChart(p *Painter, opt LineChartOption) *lineChart { + if opt.Theme == nil { + opt.Theme = defaultTheme } - seriesNames := opt.SeriesList.Names() + return &lineChart{ + p: p, + opt: &opt, + } +} - r := d.Render - xRange := result.xRange - 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 +type LineChartOption struct { + // The theme + 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 + // The flag for show symbol of line, set this to *false will hide symbol + SymbolShow *bool + // The stroke width of line + StrokeWidth float64 + // Fill the area of line + FillArea bool + // background is filled + backgroundIsFilled bool + // background fill (alpha) opacity + Opacity uint8 +} + +func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + p := l.p + opt := l.opt + boundaryGap := true + if isFalse(opt.XAxis.BoundaryGap) { + boundaryGap = false + } + + seriesPainter := result.seriesPainter + + xDivideCount := len(opt.XAxis.Data) + if !boundaryGap { + xDivideCount-- + } + xDivideValues := autoDivide(seriesPainter.Width(), xDivideCount) + xValues := make([]int, len(xDivideValues)-1) + if boundaryGap { + for i := 0; i < len(xDivideValues)-1; i++ { + xValues[i] = (xDivideValues[i] + xDivideValues[i+1]) >> 1 } - seriesColor := theme.GetSeriesColor(index) - - yRange := result.getYRange(series.YAxisIndex) - points := make([]Point, 0, len(series.Data)) - // mark line - markLineRender(markLineRenderOption{ - Draw: d, - FillColor: seriesColor, - FontColor: theme.GetTextColor(), + } else { + xValues = xDivideValues + } + markPointPainter := NewMarkPointPainter(seriesPainter) + markLinePainter := NewMarkLinePainter(seriesPainter) + rendererList := []Renderer{ + markPointPainter, + markLinePainter, + } + strokeWidth := opt.StrokeWidth + if strokeWidth == 0 { + strokeWidth = defaultStrokeWidth + } + seriesNames := seriesList.Names() + for index := range seriesList { + series := seriesList[index] + seriesColor := opt.Theme.GetSeriesColor(series.index) + drawingStyle := Style{ StrokeColor: seriesColor, - Font: opt.Font, - Series: &series, - Range: yRange, - }) + StrokeWidth: strokeWidth, + } + if len(series.Style.StrokeDashArray) > 0 { + drawingStyle.StrokeDashArray = series.Style.StrokeDashArray + } - for j, item := range series.Data { - if j >= xRange.divideCount { - continue - } - y := yRange.getRestHeight(item.Value) - x := xRange.getWidth(float64(j)) - points = append(points, Point{ - Y: y, - X: x, + yRange := result.axisRanges[series.AxisIndex] + points := make([]Point, 0) + var labelPainter *SeriesLabelPainter + if series.Label.Show { + labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{ + P: seriesPainter, + SeriesNames: seriesNames, + Label: series.Label, + Theme: opt.Theme, + Font: opt.Font, }) - if !series.Label.Show { + rendererList = append(rendererList, labelPainter) + } + for i, item := range series.Data { + h := yRange.getRestHeight(item.Value) + if item.Value == nullValue { + h = int(math.MaxInt32) + } + p := Point{ + X: xValues[i], + Y: h, + } + points = append(points, p) + + // 如果label不需要展示,则返回 + if labelPainter == nil { continue } - distance := series.Label.Distance - if distance == 0 { - distance = 5 - } - text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) - labelStyle := chart.Style{ - FontColor: theme.GetTextColor(), - FontSize: labelFontSize, - Font: opt.Font, - } - if !series.Label.Color.IsZero() { - labelStyle.FontColor = series.Label.Color - } - labelStyle.GetTextOptions().WriteToRenderer(r) - textBox := r.MeasureText(text) - d.text(text, x-textBox.Width()>>1, y-distance) + labelPainter.Add(LabelValue{ + Index: index, + Value: item.Value, + X: p.X, + Y: p.Y, + // 字体大小 + FontSize: series.Label.FontSize, + }) } + // 如果需要填充区域 + if opt.FillArea { + areaPoints := make([]Point, len(points)) + copy(areaPoints, points) + bottomY := yRange.getRestHeight(yRange.min) + var opacity uint8 = 200 + if opt.Opacity != 0 { + opacity = opt.Opacity + } + areaPoints = append(areaPoints, Point{ + X: areaPoints[len(areaPoints)-1].X, + Y: bottomY, + }, Point{ + X: areaPoints[0].X, + Y: bottomY, + }, areaPoints[0]) + seriesPainter.SetDrawingStyle(Style{ + FillColor: seriesColor.WithAlpha(opacity), + }) + seriesPainter.FillArea(areaPoints) + } + seriesPainter.SetDrawingStyle(drawingStyle) - dotFillColor := drawing.ColorWhite - if theme.IsDark() { - dotFillColor = seriesColor + // 画线 + seriesPainter.LineStroke(points) + + // 画点 + if opt.Theme.IsDark() { + drawingStyle.FillColor = drawingStyle.StrokeColor + } else { + drawingStyle.FillColor = drawing.ColorWhite } - d.Line(points, LineStyle{ - StrokeColor: seriesColor, - StrokeWidth: 2, - DotColor: seriesColor, - DotWidth: defaultDotWidth, - DotFillColor: dotFillColor, - }) - // draw mark point - markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{ - Draw: d, + drawingStyle.StrokeWidth = 1 + seriesPainter.SetDrawingStyle(drawingStyle) + if !isFalse(opt.SymbolShow) { + seriesPainter.Dots(points) + } + markPointPainter.Add(markPointRenderOption{ FillColor: seriesColor, Font: opt.Font, Points: points, - Series: &series, + Series: series, + }) + 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 BoxZero, err + } - return markPointRenderOptions, nil + return p.box, nil +} + +func (l *lineChart) Render() (Box, error) { + p := l.p + opt := l.opt + + renderResult, err := defaultRender(p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: opt.XAxis, + YAxisOptions: opt.YAxisOptions, + TitleOption: opt.Title, + LegendOption: opt.Legend, + backgroundIsFilled: opt.backgroundIsFilled, + }) + if err != nil { + return BoxZero, err + } + seriesList := opt.SeriesList.Filter(ChartTypeLine) + + return l.render(renderResult, seriesList) } diff --git a/line_chart_test.go b/line_chart_test.go index 9f5d9af..e169f90 100644 --- a/line_chart_test.go +++ b/line_chart_test.go @@ -26,72 +26,194 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestLineChartRender(t *testing.T) { +func TestLineChart(t *testing.T) { assert := assert.New(t) - width := 400 - height := 300 - d, err := NewDraw(DrawOption{ - Width: width, - Height: height, - }) - assert.Nil(err) - - result := basicRenderResult{ - xRange: &Range{ - Min: 0, - Max: 4, - divideCount: 4, - Size: width, - Boundary: true, - }, - yRangeList: []*Range{ - { - divideCount: 6, - Max: 100, - Min: 0, - Size: height, + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := [][]float64{ + { + 120, + 132, + 101, + 134, + 90, + 230, + 210, + }, + { + 220, + 182, + 191, + 234, + 290, + 330, + 310, + }, + { + 150, + 232, + 201, + 154, + 190, + 330, + 410, + }, + { + 320, + 332, + 301, + 334, + 390, + 330, + 320, + }, + { + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320, + }, + } + _, err := NewLineChart(p, LineChartOption{ + Title: TitleOption{ + Text: "Line", + }, + Padding: Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + Legend: NewLegendOption([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, PositionCenter), + SeriesList: NewSeriesListDataFromValues(values), + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", + }, + { + render: func(p *Painter) ([]byte, error) { + values := [][]float64{ + { + 120, + 132, + 101, + 134, + 90, + 230, + 210, + }, + { + 220, + 182, + 191, + 234, + 290, + 330, + 310, + }, + { + 150, + 232, + 201, + 154, + 190, + 330, + 410, + }, + { + 320, + 332, + 301, + 334, + 390, + 330, + 320, + }, + { + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320, + }, + } + _, err := NewLineChart(p, LineChartOption{ + Title: TitleOption{ + Text: "Line", + }, + Padding: Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, FalseFlag()), + Legend: NewLegendOption([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, PositionCenter), + SeriesList: NewSeriesListDataFromValues(values), + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", }, - d: d, } - f, _ := chart.GetDefaultFont() - _, err = lineChartRender(lineChartOption{ - Font: f, - SeriesList: SeriesList{ - { - Label: SeriesLabel{ - Show: true, - Color: drawing.ColorBlue, - }, - MarkLine: NewMarkLine( - SeriesMarkDataTypeAverage, - ), - Data: []SeriesData{ - { - Value: 20, - }, - { - Value: 60, - }, - { - Value: 90, - }, - }, - }, - NewSeriesFromValues([]float64{ - 40, - 60, - 70, - }), - }, - }, &result) - assert.Nil(err) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n56.66206090", string(data)) + + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } } diff --git a/line_test.go b/line_test.go deleted file mode 100644 index e10b806..0000000 --- a/line_test.go +++ /dev/null @@ -1,165 +0,0 @@ -// 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 ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -func TestLineStyle(t *testing.T) { - assert := assert.New(t) - - ls := LineStyle{ - ClassName: "test", - StrokeDashArray: []float64{ - 1.0, - }, - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, - FillColor: drawing.ColorBlack.WithAlpha(60), - DotWidth: 2, - DotColor: drawing.ColorBlack, - DotFillColor: drawing.ColorWhite, - } - - assert.Equal(chart.Style{ - ClassName: "test", - StrokeDashArray: []float64{ - 1.0, - }, - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, - FillColor: drawing.ColorBlack.WithAlpha(60), - DotWidth: 2, - DotColor: drawing.ColorBlack, - }, ls.Style()) -} - -func TestDrawLineFill(t *testing.T) { - assert := assert.New(t) - - ls := LineStyle{ - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, - FillColor: drawing.ColorBlack.WithAlpha(60), - DotWidth: 2, - DotColor: drawing.ColorBlack, - DotFillColor: drawing.ColorWhite, - } - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - assert.Nil(err) - d.lineFill([]Point{ - { - X: 0, - Y: 0, - }, - { - X: 10, - Y: 20, - }, - { - X: 50, - Y: 60, - }, - }, ls) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n", string(data)) -} - -func TestDrawLineDot(t *testing.T) { - assert := assert.New(t) - - ls := LineStyle{ - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, - FillColor: drawing.ColorBlack.WithAlpha(60), - DotWidth: 2, - DotColor: drawing.ColorBlack, - DotFillColor: drawing.ColorWhite, - } - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - assert.Nil(err) - d.lineDot([]Point{ - { - X: 0, - Y: 0, - }, - { - X: 10, - Y: 20, - }, - { - X: 50, - Y: 60, - }, - }, ls) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n", string(data)) -} - -func TestDrawLine(t *testing.T) { - assert := assert.New(t) - - ls := LineStyle{ - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, - FillColor: drawing.ColorBlack.WithAlpha(60), - DotWidth: 2, - DotColor: drawing.ColorBlack, - DotFillColor: drawing.ColorWhite, - } - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - assert.Nil(err) - d.Line([]Point{ - { - X: 0, - Y: 0, - }, - { - X: 10, - Y: 20, - }, - { - X: 50, - Y: 60, - }, - }, ls) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n", string(data)) -} diff --git a/mark_line.go b/mark_line.go index 464fe71..bc850bb 100644 --- a/mark_line.go +++ b/mark_line.go @@ -24,10 +24,9 @@ package charts import ( "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) +// NewMarkLine returns a series mark line func NewMarkLine(markLineTypes ...string) SeriesMarkLine { data := make([]SeriesMarkData, len(markLineTypes)) for index, t := range markLineTypes { @@ -40,53 +39,75 @@ func NewMarkLine(markLineTypes ...string) SeriesMarkLine { } } +type markLinePainter struct { + p *Painter + options []markLineRenderOption +} + +func (m *markLinePainter) Add(opt markLineRenderOption) { + m.options = append(m.options, opt) +} + +// NewMarkLinePainter returns a mark line renderer +func NewMarkLinePainter(p *Painter) *markLinePainter { + return &markLinePainter{ + p: p, + options: make([]markLineRenderOption, 0), + } +} + type markLineRenderOption struct { - Draw *Draw - FillColor drawing.Color - FontColor drawing.Color - StrokeColor drawing.Color + FillColor Color + FontColor Color + StrokeColor Color Font *truetype.Font - Series *Series - Range *Range + Series Series + Range axisRange } -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 +func (m *markLinePainter) Render() (Box, error) { + painter := m.p + for _, opt := range m.options { + s := opt.Series + if len(s.MarkLine.Data) == 0 { + continue + } + font := opt.Font + if font == nil { + font, _ = GetDefaultFont() + } + summary := s.Summary() + for _, markLine := range s.MarkLine.Data { + // 由于mark line会修改style,因此每次重新设置 + painter.OverrideDrawingStyle(Style{ + FillColor: opt.FillColor, + StrokeColor: opt.StrokeColor, + StrokeWidth: 1, + StrokeDashArray: []float64{ + 4, + 2, + }, + }).OverrideTextStyle(Style{ + Font: font, + FontColor: opt.FontColor, + FontSize: labelFontSize, + }) + value := float64(0) + switch markLine.Type { + case SeriesMarkDataTypeMax: + value = summary.MaxValue + case SeriesMarkDataTypeMin: + value = summary.MinValue + default: + value = summary.AverageValue + } + y := opt.Range.getRestHeight(value) + width := painter.Width() + text := commafWithDigits(value) + textBox := painter.MeasureText(text) + painter.MarkLine(0, y, width-2) + painter.Text(text, width, y+textBox.Height()>>1-2) } - y := opt.Range.getRestHeight(value) - width := d.Box.Width() - text := commafWithDigits(value) - textBox := r.MeasureText(text) - d.makeLine(0, y, width-2) - d.text(text, width, y+textBox.Height()>>1-2) } - + return BoxZero, nil } diff --git a/mark_line_test.go b/mark_line_test.go index abb3308..0448cda 100644 --- a/mark_line_test.go +++ b/mark_line_test.go @@ -26,74 +26,65 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) -func TestNewMarkLine(t *testing.T) { +func TestMarkLine(t *testing.T) { assert := assert.New(t) - markLine := NewMarkLine( - SeriesMarkDataTypeMax, - SeriesMarkDataTypeMin, - SeriesMarkDataTypeAverage, - ) - - assert.Equal(SeriesMarkLine{ - Data: []SeriesMarkData{ - { - Type: SeriesMarkDataTypeMax, - }, - { - Type: SeriesMarkDataTypeMin, - }, - { - Type: SeriesMarkDataTypeAverage, + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + markLine := NewMarkLinePainter(p) + series := NewSeriesFromValues([]float64{ + 1, + 2, + 3, + }) + series.MarkLine = NewMarkLine( + SeriesMarkDataTypeMax, + SeriesMarkDataTypeAverage, + SeriesMarkDataTypeMin, + ) + markLine.Add(markLineRenderOption{ + FillColor: drawing.ColorBlack, + FontColor: drawing.ColorBlack, + StrokeColor: drawing.ColorBlack, + Series: series, + Range: NewRange(AxisRangeOption{ + Painter: p, + Min: 0, + Max: 5, + Size: p.Height(), + DivideCount: 6, + }), + }) + _, err := markLine.Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\n321", }, - }, markLine) -} - -func TestMarkLineRender(t *testing.T) { - assert := assert.New(t) - - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }, PaddingOption(chart.Box{ - Left: 20, - Right: 20, - })) - assert.Nil(err) - f, _ := chart.GetDefaultFont() - - markLineRender(markLineRenderOption{ - Draw: d, - FillColor: drawing.ColorBlack, - FontColor: drawing.ColorBlack, - StrokeColor: drawing.ColorBlack, - Font: f, - Series: &Series{ - MarkLine: NewMarkLine( - SeriesMarkDataTypeMax, - SeriesMarkDataTypeMin, - SeriesMarkDataTypeAverage, - ), - Data: NewSeriesDataFromValues([]float64{ - 1, - 3, - 5, - 7, - 9, - }), - }, - Range: &Range{ - Min: 0, - Max: 10, - Size: 200, - }, - }) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n915", string(data)) + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } } diff --git a/mark_point.go b/mark_point.go index 5fd34c4..fd8a88b 100644 --- a/mark_point.go +++ b/mark_point.go @@ -24,10 +24,9 @@ package charts import ( "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) +// NewMarkPoint returns a series mark point func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint { data := make([]SeriesMarkData, len(markPointTypes)) for index, t := range markPointTypes { @@ -40,50 +39,77 @@ func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint { } } +type markPointPainter struct { + p *Painter + options []markPointRenderOption +} + +func (m *markPointPainter) Add(opt markPointRenderOption) { + m.options = append(m.options, opt) +} + type markPointRenderOption struct { - Draw *Draw - FillColor drawing.Color + FillColor Color Font *truetype.Font - Series *Series + Series Series Points []Point } -func markPointRender(opt markPointRenderOption) { - d := opt.Draw - s := opt.Series - if len(s.MarkPoint.Data) == 0 { - return - } - points := opt.Points - summary := s.Summary() - symbolSize := s.MarkPoint.SymbolSize - if symbolSize == 0 { - symbolSize = 30 - } - r := d.Render - // 设置填充样式 - chart.Style{ - FillColor: opt.FillColor, - }.WriteToRenderer(r) - // 设置文本样式 - chart.Style{ - FontColor: NewTheme(ThemeDark).GetTextColor(), - FontSize: labelFontSize, - StrokeWidth: 1, - Font: opt.Font, - }.WriteTextOptionsToRenderer(r) - for _, markPointData := range s.MarkPoint.Data { - p := points[summary.MinIndex] - value := summary.MinValue - switch markPointData.Type { - case SeriesMarkDataTypeMax: - p = points[summary.MaxIndex] - value = summary.MaxValue - } - - d.pin(p.X, p.Y-symbolSize>>1, symbolSize) - text := commafWithDigits(value) - textBox := r.MeasureText(text) - d.text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2) +// NewMarkPointPainter returns a mark point renderer +func NewMarkPointPainter(p *Painter) *markPointPainter { + return &markPointPainter{ + p: p, + options: make([]markPointRenderOption, 0), } } + +func (m *markPointPainter) Render() (Box, error) { + painter := m.p + for _, opt := range m.options { + s := opt.Series + if len(s.MarkPoint.Data) == 0 { + continue + } + points := opt.Points + summary := s.Summary() + symbolSize := s.MarkPoint.SymbolSize + if symbolSize == 0 { + symbolSize = 30 + } + textStyle := Style{ + FontSize: labelFontSize, + StrokeWidth: 1, + Font: opt.Font, + } + if isLightColor(opt.FillColor) { + textStyle.FontColor = defaultLightFontColor + } else { + textStyle.FontColor = defaultDarkFontColor + } + painter.OverrideDrawingStyle(Style{ + FillColor: opt.FillColor, + }).OverrideTextStyle(textStyle) + for _, markPointData := range s.MarkPoint.Data { + textStyle.FontSize = labelFontSize + painter.OverrideTextStyle(textStyle) + p := points[summary.MinIndex] + value := summary.MinValue + switch markPointData.Type { + case SeriesMarkDataTypeMax: + p = points[summary.MaxIndex] + value = summary.MaxValue + } + + painter.Pin(p.X, p.Y-symbolSize>>1, symbolSize) + text := commafWithDigits(value) + textBox := painter.MeasureText(text) + if textBox.Width() > symbolSize { + textStyle.FontSize = smallLabelFontSize + painter.OverrideTextStyle(textStyle) + textBox = painter.MeasureText(text) + } + painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2) + } + } + return BoxZero, nil +} diff --git a/mark_point_test.go b/mark_point_test.go index 2cd8fdd..298345b 100644 --- a/mark_point_test.go +++ b/mark_point_test.go @@ -26,78 +26,67 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) -func TestNewMarkPoint(t *testing.T) { +func TestMarkPoint(t *testing.T) { assert := assert.New(t) - markPoint := NewMarkPoint( - SeriesMarkDataTypeMax, - SeriesMarkDataTypeMin, - SeriesMarkDataTypeAverage, - ) - - assert.Equal(SeriesMarkPoint{ - Data: []SeriesMarkData{ - { - Type: SeriesMarkDataTypeMax, - }, - { - Type: SeriesMarkDataTypeMin, - }, - { - Type: SeriesMarkDataTypeAverage, + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + series := NewSeriesFromValues([]float64{ + 1, + 2, + 3, + }) + series.MarkPoint = NewMarkPoint(SeriesMarkDataTypeMax) + markPoint := NewMarkPointPainter(p) + markPoint.Add(markPointRenderOption{ + FillColor: drawing.ColorBlack, + Series: series, + Points: []Point{ + { + X: 10, + Y: 10, + }, + { + X: 30, + Y: 30, + }, + { + X: 50, + Y: 50, + }, + }, + }) + _, err := markPoint.Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\n3", }, - }, markPoint) -} - -func TestMarkPointRender(t *testing.T) { - assert := assert.New(t) - - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }, PaddingOption(chart.Box{ - Left: 20, - Right: 20, - })) - assert.Nil(err) - f, _ := chart.GetDefaultFont() - - markPointRender(markPointRenderOption{ - Draw: d, - FillColor: drawing.ColorBlack, - Font: f, - Series: &Series{ - MarkPoint: NewMarkPoint( - SeriesMarkDataTypeMax, - SeriesMarkDataTypeMin, - ), - Data: NewSeriesDataFromValues([]float64{ - 1, - 3, - 5, - }), - }, - Points: []Point{ - { - X: 1, - Y: 50, - }, - { - X: 100, - Y: 100, - }, - { - X: 200, - Y: 200, - }, - }, - }) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n51", string(data)) + } + + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } } diff --git a/painter.go b/painter.go new file mode 100644 index 0000000..bee646f --- /dev/null +++ b/painter.go @@ -0,0 +1,866 @@ +// 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" + "errors" + "math" + + "github.com/golang/freetype/truetype" + "git.smarteching.com/zeni/go-chart/v2" +) + +type ValueFormatter func(float64) string + +type Painter struct { + render chart.Renderer + box Box + font *truetype.Font + parent *Painter + style Style + theme ColorPalette + // 类型 + outputType string + valueFormatter ValueFormatter +} + +type PainterOptions struct { + // Draw type, "svg" or "png", default type is "png" + Type string + // The width of draw painter + Width int + // The height of draw painter + Height int + // The font for painter + Font *truetype.Font +} + +type PainterOption func(*Painter) + +type TicksOption struct { + // the first tick + First int + Length int + Orient string + Count int + Unit int +} + +type MultiTextOption struct { + TextList []string + Orient string + Unit int + Position string + Align string + // The text rotation of label + TextRotation float64 + Offset Box + // The first text index + First int +} + +type GridOption struct { + Column int + Row int + ColumnSpans []int + // 忽略不展示的column + IgnoreColumnLines []int + // 忽略不展示的row + IgnoreRowLines []int +} + +// PainterPaddingOption sets the padding of draw painter +func PainterPaddingOption(padding Box) PainterOption { + return func(p *Painter) { + p.box.Left += padding.Left + p.box.Top += padding.Top + p.box.Right -= padding.Right + p.box.Bottom -= padding.Bottom + } +} + +// PainterBoxOption sets the box of draw painter +func PainterBoxOption(box Box) PainterOption { + return func(p *Painter) { + if box.IsZero() { + return + } + p.box = box + } +} + +// PainterFontOption sets the font of draw painter +func PainterFontOption(font *truetype.Font) PainterOption { + return func(p *Painter) { + if font == nil { + return + } + p.font = font + } +} + +// PainterStyleOption sets the style of draw painter +func PainterStyleOption(style Style) PainterOption { + return func(p *Painter) { + p.SetStyle(style) + } +} + +// PainterThemeOption sets the theme of draw painter +func PainterThemeOption(theme ColorPalette) PainterOption { + return func(p *Painter) { + if theme == nil { + return + } + p.theme = theme + } +} + +// PainterWidthHeightOption set width or height of draw painter +func PainterWidthHeightOption(width, height int) PainterOption { + return func(p *Painter) { + if width > 0 { + p.box.Right = p.box.Left + width + } + if height > 0 { + p.box.Bottom = p.box.Top + height + } + } +} + +// NewPainter creates a painter +func NewPainter(opts PainterOptions, opt ...PainterOption) (*Painter, error) { + if opts.Width <= 0 || opts.Height <= 0 { + return nil, errors.New("width/height can not be nil") + } + font := opts.Font + if font == nil { + f, err := GetDefaultFont() + if err != nil { + return nil, err + } + font = f + } + fn := chart.PNG + if opts.Type == ChartOutputSVG { + fn = chart.SVG + } + width := opts.Width + height := opts.Height + r, err := fn(width, height) + if err != nil { + return nil, err + } + r.SetFont(font) + + p := &Painter{ + render: r, + box: Box{ + Right: opts.Width, + Bottom: opts.Height, + }, + font: font, + // 类型 + outputType: opts.Type, + } + p.setOptions(opt...) + if p.theme == nil { + p.theme = NewTheme(ThemeLight) + } + return p, nil +} +func (p *Painter) setOptions(opts ...PainterOption) { + for _, fn := range opts { + fn(p) + } +} + +func (p *Painter) Child(opt ...PainterOption) *Painter { + child := &Painter{ + // 格式化 + valueFormatter: p.valueFormatter, + // render + render: p.render, + box: p.box.Clone(), + font: p.font, + parent: p, + style: p.style, + theme: p.theme, + } + child.setOptions(opt...) + return child +} + +func (p *Painter) SetStyle(style Style) { + if style.Font == nil { + style.Font = p.font + } + p.style = style + style.WriteToRenderer(p.render) +} + +func overrideStyle(defaultStyle Style, style Style) Style { + if style.StrokeWidth == 0 { + style.StrokeWidth = defaultStyle.StrokeWidth + } + if style.StrokeColor.IsZero() { + style.StrokeColor = defaultStyle.StrokeColor + } + if style.StrokeDashArray == nil { + style.StrokeDashArray = defaultStyle.StrokeDashArray + } + if style.DotColor.IsZero() { + style.DotColor = defaultStyle.DotColor + } + if style.DotWidth == 0 { + style.DotWidth = defaultStyle.DotWidth + } + if style.FillColor.IsZero() { + style.FillColor = defaultStyle.FillColor + } + if style.FontSize == 0 { + style.FontSize = defaultStyle.FontSize + } + if style.FontColor.IsZero() { + style.FontColor = defaultStyle.FontColor + } + if style.Font == nil { + style.Font = defaultStyle.Font + } + return style +} + +func (p *Painter) OverrideDrawingStyle(style Style) *Painter { + s := overrideStyle(p.style, style) + p.SetDrawingStyle(s) + return p +} + +func (p *Painter) SetDrawingStyle(style Style) *Painter { + style.WriteDrawingOptionsToRenderer(p.render) + return p +} + +func (p *Painter) SetTextStyle(style Style) *Painter { + if style.Font == nil { + style.Font = p.font + } + style.WriteTextOptionsToRenderer(p.render) + return p +} +func (p *Painter) OverrideTextStyle(style Style) *Painter { + s := overrideStyle(p.style, style) + p.SetTextStyle(s) + return p +} + +func (p *Painter) ResetStyle() *Painter { + p.style.WriteToRenderer(p.render) + return p +} + +// Bytes returns the data of draw canvas +func (p *Painter) Bytes() ([]byte, error) { + buffer := bytes.Buffer{} + err := p.render.Save(&buffer) + if err != nil { + return nil, err + } + return buffer.Bytes(), err +} + +// MoveTo moves the cursor to a given point +func (p *Painter) MoveTo(x, y int) *Painter { + p.render.MoveTo(x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) *Painter { + p.render.ArcTo(cx+p.box.Left, cy+p.box.Top, rx, ry, startAngle, delta) + return p +} + +func (p *Painter) LineTo(x, y int) *Painter { + p.render.LineTo(x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) QuadCurveTo(cx, cy, x, y int) *Painter { + p.render.QuadCurveTo(cx+p.box.Left, cy+p.box.Top, x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) Pin(x, y, width int) *Painter { + r := float64(width) / 2 + y -= width / 4 + angle := chart.DegreesToRadians(15) + box := p.box + + startAngle := math.Pi/2 + angle + delta := 2*math.Pi - 2*angle + p.ArcTo(x, y, r, r, startAngle, delta) + p.LineTo(x, y) + p.Close() + p.FillStroke() + + startX := x - int(r) + startY := y + endX := x + int(r) + endY := y + p.MoveTo(startX, startY) + + left := box.Left + top := box.Top + cx := x + cy := y + int(r*2.5) + p.render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top) + p.Close() + p.Fill() + return p +} + +func (p *Painter) arrow(x, y, width, height int, direction string) *Painter { + halfWidth := width >> 1 + halfHeight := height >> 1 + if direction == PositionTop || direction == PositionBottom { + x0 := x - halfWidth + x1 := x0 + width + dy := -height / 3 + y0 := y + y1 := y0 - height + if direction == PositionBottom { + y0 = y - height + y1 = y + dy = 2 * dy + } + p.MoveTo(x0, y0) + p.LineTo(x0+halfWidth, y1) + p.LineTo(x1, y0) + p.LineTo(x0+halfWidth, y+dy) + p.LineTo(x0, y0) + } else { + x0 := x + width + x1 := x0 - width + y0 := y - halfHeight + dx := -width / 3 + if direction == PositionRight { + x0 = x - width + dx = -dx + x1 = x0 + width + } + p.MoveTo(x0, y0) + p.LineTo(x1, y0+halfHeight) + p.LineTo(x0, y0+height) + p.LineTo(x0+dx, y0+halfHeight) + p.LineTo(x0, y0) + } + p.FillStroke() + return p +} + +func (p *Painter) ArrowLeft(x, y, width, height int) *Painter { + p.arrow(x, y, width, height, PositionLeft) + return p +} + +func (p *Painter) ArrowRight(x, y, width, height int) *Painter { + p.arrow(x, y, width, height, PositionRight) + return p +} + +func (p *Painter) ArrowTop(x, y, width, height int) *Painter { + p.arrow(x, y, width, height, PositionTop) + return p +} +func (p *Painter) ArrowBottom(x, y, width, height int) *Painter { + p.arrow(x, y, width, height, PositionBottom) + return p +} + +func (p *Painter) Circle(radius float64, x, y int) *Painter { + p.render.Circle(radius, x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) Stroke() *Painter { + p.render.Stroke() + return p +} + +func (p *Painter) Close() *Painter { + p.render.Close() + return p +} + +func (p *Painter) FillStroke() *Painter { + p.render.FillStroke() + return p +} + +func (p *Painter) Fill() *Painter { + p.render.Fill() + return p +} + +func (p *Painter) Width() int { + return p.box.Width() +} + +func (p *Painter) Height() int { + return p.box.Height() +} + +func (p *Painter) MeasureText(text string) Box { + return p.render.MeasureText(text) +} + +func (p *Painter) MeasureTextMaxWidthHeight(textList []string) (int, int) { + maxWidth := 0 + maxHeight := 0 + for _, text := range textList { + box := p.MeasureText(text) + if maxWidth < box.Width() { + maxWidth = box.Width() + } + if maxHeight < box.Height() { + maxHeight = box.Height() + } + } + return maxWidth, maxHeight +} + +func (p *Painter) LineStroke(points []Point) *Painter { + shouldMoveTo := false + for index, point := range points { + x := point.X + y := point.Y + if y == int(math.MaxInt32) { + p.Stroke() + shouldMoveTo = true + continue + } + if shouldMoveTo || index == 0 { + p.MoveTo(x, y) + shouldMoveTo = false + } else { + p.LineTo(x, y) + } + } + p.Stroke() + return p +} + +func (p *Painter) SmoothLineStroke(points []Point) *Painter { + prevX := 0 + prevY := 0 + // TODO 如何生成平滑的折线 + for index, point := range points { + x := point.X + y := point.Y + if index == 0 { + p.MoveTo(x, y) + } else { + cx := prevX + (x-prevX)/5 + cy := y + (y-prevY)/2 + p.QuadCurveTo(cx, cy, x, y) + } + prevX = x + prevY = y + } + p.Stroke() + return p +} + +func (p *Painter) SetBackground(width, height int, color Color, inside ...bool) *Painter { + r := p.render + s := chart.Style{ + FillColor: color, + } + // 背景色 + p.SetDrawingStyle(s) + defer p.ResetStyle() + if len(inside) != 0 && inside[0] { + p.MoveTo(0, 0) + p.LineTo(width, 0) + p.LineTo(width, height) + p.LineTo(0, height) + p.LineTo(0, 0) + } else { + // 设置背景色不使用box,因此不直接使用Painter + r.MoveTo(0, 0) + r.LineTo(width, 0) + r.LineTo(width, height) + r.LineTo(0, height) + r.LineTo(0, 0) + } + p.FillStroke() + return p +} +func (p *Painter) MarkLine(x, y, width int) *Painter { + arrowWidth := 16 + arrowHeight := 10 + endX := x + width + radius := 3 + p.Circle(3, x+radius, y) + p.render.Fill() + p.MoveTo(x+radius*3, y) + p.LineTo(endX-arrowWidth, y) + p.Stroke() + p.ArrowRight(endX, y, arrowWidth, arrowHeight) + return p +} + +func (p *Painter) Polygon(center Point, radius float64, sides int) *Painter { + points := getPolygonPoints(center, radius, sides) + for i, item := range points { + if i == 0 { + p.MoveTo(item.X, item.Y) + } else { + p.LineTo(item.X, item.Y) + } + } + p.LineTo(points[0].X, points[0].Y) + p.Stroke() + return p +} + +func (p *Painter) FillArea(points []Point) *Painter { + var x, y int + for index, point := range points { + x = point.X + y = point.Y + if index == 0 { + p.MoveTo(x, y) + } else { + p.LineTo(x, y) + } + } + p.Fill() + return p +} + +func (p *Painter) Text(body string, x, y int) *Painter { + p.render.Text(body, x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) TextRotation(body string, x, y int, radians float64) { + p.render.SetTextRotation(radians) + p.render.Text(body, x+p.box.Left, y+p.box.Top) + p.render.ClearTextRotation() +} + +func (p *Painter) SetTextRotation(radians float64) { + p.render.SetTextRotation(radians) +} +func (p *Painter) ClearTextRotation() { + p.render.ClearTextRotation() +} + +func (p *Painter) TextFit(body string, x, y, width int, textAligns ...string) chart.Box { + style := p.style + textWarp := style.TextWrap + style.TextWrap = chart.TextWrapWord + r := p.render + lines := chart.Text.WrapFit(r, body, width, style) + p.SetTextStyle(style) + var output chart.Box + + textAlign := "" + if len(textAligns) != 0 { + textAlign = textAligns[0] + } + for index, line := range lines { + if line == "" { + continue + } + x0 := x + y0 := y + output.Height() + lineBox := r.MeasureText(line) + switch textAlign { + case AlignRight: + x0 += width - lineBox.Width() + case AlignCenter: + x0 += (width - lineBox.Width()) >> 1 + } + p.Text(line, x0, y0) + output.Right = chart.MaxInt(lineBox.Right, output.Right) + output.Bottom += lineBox.Height() + if index < len(lines)-1 { + output.Bottom += +style.GetTextLineSpacing() + } + } + p.style.TextWrap = textWarp + return output +} + +func (p *Painter) Ticks(opt TicksOption) *Painter { + if opt.Count <= 0 || opt.Length <= 0 { + return p + } + count := opt.Count + first := opt.First + width := p.Width() + height := p.Height() + unit := 1 + if opt.Unit > 1 { + unit = opt.Unit + } + var values []int + isVertical := opt.Orient == OrientVertical + if isVertical { + values = autoDivide(height, count) + } else { + values = autoDivide(width, count) + } + for index, value := range values { + if index < first { + continue + } + if (index-first)%unit != 0 { + continue + } + if isVertical { + p.LineStroke([]Point{ + { + X: 0, + Y: value, + }, + { + X: opt.Length, + Y: value, + }, + }) + } else { + p.LineStroke([]Point{ + { + X: value, + Y: opt.Length, + }, + { + X: value, + Y: 0, + }, + }) + } + } + return p +} + +func (p *Painter) MultiText(opt MultiTextOption) *Painter { + if len(opt.TextList) == 0 { + return p + } + count := len(opt.TextList) + positionCenter := true + showIndex := opt.Unit / 2 + if containsString([]string{ + PositionLeft, + PositionTop, + }, opt.Position) { + positionCenter = false + count-- + // 非居中 + showIndex = 0 + } + width := p.Width() + height := p.Height() + var values []int + isVertical := opt.Orient == OrientVertical + if isVertical { + values = autoDivide(height, count) + } else { + values = autoDivide(width, count) + } + isTextRotation := opt.TextRotation != 0 + offset := opt.Offset + for index, text := range opt.TextList { + if index < opt.First { + continue + } + if opt.Unit != 0 && (index-opt.First)%opt.Unit != showIndex { + continue + } + if isTextRotation { + p.ClearTextRotation() + p.SetTextRotation(opt.TextRotation) + } + box := p.MeasureText(text) + start := values[index] + if positionCenter { + start = (values[index] + values[index+1]) >> 1 + } + x := 0 + y := 0 + if isVertical { + y = start + box.Height()>>1 + switch opt.Align { + case AlignRight: + x = width - box.Width() + case AlignCenter: + x = width - box.Width()>>1 + default: + x = 0 + } + } else { + x = start - box.Width()>>1 + } + x += offset.Left + y += offset.Top + p.Text(text, x, y) + } + if isTextRotation { + p.ClearTextRotation() + } + return p +} + +func (p *Painter) Grid(opt GridOption) *Painter { + width := p.Width() + height := p.Height() + drawLines := func(values []int, ignoreIndexList []int, isVertical bool) { + for index, v := range values { + if containsInt(ignoreIndexList, index) { + continue + } + x0 := 0 + y0 := 0 + x1 := 0 + y1 := 0 + if isVertical { + + x0 = v + x1 = v + y1 = height + } else { + x1 = width + y0 = v + y1 = v + } + p.LineStroke([]Point{ + { + X: x0, + Y: y0, + }, + { + X: x1, + Y: y1, + }, + }) + } + } + columnCount := sumInt(opt.ColumnSpans) + if columnCount == 0 { + columnCount = opt.Column + } + if columnCount > 0 { + values := autoDivideSpans(width, columnCount, opt.ColumnSpans) + drawLines(values, opt.IgnoreColumnLines, true) + } + if opt.Row > 0 { + values := autoDivide(height, opt.Row) + drawLines(values, opt.IgnoreRowLines, false) + } + return p +} + +func (p *Painter) Dots(points []Point) *Painter { + for _, item := range points { + p.Circle(2, item.X, item.Y) + } + p.FillStroke() + return p +} + +func (p *Painter) Rect(box Box) *Painter { + p.MoveTo(box.Left, box.Top) + p.LineTo(box.Right, box.Top) + p.LineTo(box.Right, box.Bottom) + p.LineTo(box.Left, box.Bottom) + p.LineTo(box.Left, box.Top) + p.FillStroke() + return p +} + +func (p *Painter) RoundedRect(box Box, radius int) *Painter { + r := (box.Right - box.Left) / 2 + if radius > r { + radius = r + } + rx := float64(radius) + ry := float64(radius) + p.MoveTo(box.Left+radius, box.Top) + p.LineTo(box.Right-radius, box.Top) + + cx := box.Right - radius + cy := box.Top + radius + // right top + p.ArcTo(cx, cy, rx, ry, -math.Pi/2, math.Pi/2) + + p.LineTo(box.Right, box.Bottom-radius) + + // right bottom + cx = box.Right - radius + cy = box.Bottom - radius + p.ArcTo(cx, cy, rx, ry, 0.0, math.Pi/2) + + p.LineTo(box.Left+radius, box.Bottom) + + // left bottom + cx = box.Left + radius + cy = box.Bottom - radius + p.ArcTo(cx, cy, rx, ry, math.Pi/2, math.Pi/2) + + p.LineTo(box.Left, box.Top+radius) + + // left top + cx = box.Left + radius + cy = box.Top + radius + p.ArcTo(cx, cy, rx, ry, math.Pi, math.Pi/2) + + p.Close() + p.FillStroke() + p.Fill() + return p +} + +func (p *Painter) LegendLineDot(box Box) *Painter { + width := box.Width() + height := box.Height() + strokeWidth := 3 + dotHeight := 5 + + p.render.SetStrokeWidth(float64(strokeWidth)) + center := (height-strokeWidth)>>1 - 1 + p.MoveTo(box.Left, box.Top-center) + p.LineTo(box.Right, box.Top-center) + p.Stroke() + p.Circle(float64(dotHeight), box.Left+width>>1, box.Top-center) + p.FillStroke() + return p +} + +func (p *Painter) GetRenderer() chart.Renderer { + return p.render +} diff --git a/draw_test.go b/painter_test.go similarity index 63% rename from draw_test.go rename to painter_test.go index f6a3dd1..07c4113 100644 --- a/draw_test.go +++ b/painter_test.go @@ -26,217 +26,85 @@ import ( "math" "testing" + "github.com/golang/freetype/truetype" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) -func TestParentOption(t *testing.T) { - assert := assert.New(t) - p, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - assert.Nil(err) - - d, err := NewDraw(DrawOption{ - Parent: p, - }) - assert.Nil(err) - assert.Equal(p, d.parent) -} - -func TestWidthHeightOption(t *testing.T) { +func TestPainterOption(t *testing.T) { assert := assert.New(t) - // no parent - width := 300 - height := 200 - d, err := NewDraw(DrawOption{ - Width: width, - Height: height, - }) - assert.Nil(err) - assert.Equal(chart.Box{ - Top: 0, - Left: 0, - Right: width, - Bottom: height, - }, d.Box) - - width = 500 - height = 600 - // with parent - p, err := NewDraw( - DrawOption{ - Width: width, - Height: height, - }, - PaddingOption(chart.NewBox(5, 5, 5, 5)), - ) - assert.Nil(err) - d, err = NewDraw( - DrawOption{ - Parent: p, - }, - PaddingOption(chart.NewBox(1, 2, 3, 4)), - ) - assert.Nil(err) - assert.Equal(chart.Box{ - Top: 6, - Left: 7, - Right: 492, - Bottom: 591, - }, d.Box) -} - -func TestBoxOption(t *testing.T) { - assert := assert.New(t) - - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - assert.Nil(err) - - err = BoxOption(chart.Box{ - Left: 10, - Top: 20, - Right: 50, - Bottom: 100, - })(d) - assert.Nil(err) - assert.Equal(chart.Box{ - Left: 10, - Top: 20, - Right: 50, - Bottom: 100, - }, d.Box) - - // zero box will be ignored - err = BoxOption(chart.Box{})(d) - assert.Nil(err) - assert.Equal(chart.Box{ - Left: 10, - Top: 20, - Right: 50, - Bottom: 100, - }, d.Box) -} - -func TestPaddingOption(t *testing.T) { - assert := assert.New(t) - - d, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - assert.Nil(err) - - // 默认的box - assert.Equal(chart.Box{ - Right: 400, - Bottom: 300, - }, d.Box) - - // 设置padding之后的box - d, err = NewDraw(DrawOption{ - Width: 400, - Height: 300, - }, PaddingOption(chart.Box{ - Left: 1, - Top: 2, - Right: 3, - Bottom: 4, - })) - assert.Nil(err) - assert.Equal(chart.Box{ - Top: 2, - Left: 1, - Right: 397, - Bottom: 296, - }, d.Box) - - p := d - // 设置父元素之后的box - d, err = NewDraw( - DrawOption{ - Parent: p, - }, - PaddingOption(chart.Box{ + font := &truetype.Font{} + d, err := NewPainter(PainterOptions{ + Width: 800, + Height: 600, + Type: ChartOutputSVG, + }, + PainterBoxOption(Box{ + Right: 400, + Bottom: 300, + }), + PainterPaddingOption(Box{ Left: 1, Top: 2, Right: 3, Bottom: 4, }), + PainterFontOption(font), + PainterStyleOption(Style{ + ClassName: "test", + }), ) assert.Nil(err) - assert.Equal(chart.Box{ - Top: 4, - Left: 2, - Right: 394, - Bottom: 292, - }, d.Box) + assert.Equal(Box{ + Left: 1, + Top: 2, + Right: 397, + Bottom: 296, + }, d.box) + assert.Equal(font, d.font) + assert.Equal("test", d.style.ClassName) } -func TestParentTop(t *testing.T) { - assert := assert.New(t) - d1, err := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - assert.Nil(err) - - d2, err := NewDraw(DrawOption{ - Parent: d1, - }) - assert.Nil(err) - - d3, err := NewDraw(DrawOption{ - Parent: d2, - }) - assert.Nil(err) - - assert.Equal(d2, d3.Parent()) - assert.Equal(d1, d2.Parent()) - assert.Equal(d1, d3.Top()) - assert.Equal(d1, d2.Top()) -} - -func TestDraw(t *testing.T) { +func TestPainter(t *testing.T) { assert := assert.New(t) tests := []struct { - fn func(d *Draw) + fn func(*Painter) result string }{ // moveTo, lineTo { - fn: func(d *Draw) { - d.moveTo(1, 1) - d.lineTo(2, 2) - d.Render.Stroke() + fn: func(p *Painter) { + p.MoveTo(1, 1) + p.LineTo(2, 2) + p.Stroke() }, result: "\\n", }, // circle { - fn: func(d *Draw) { - d.circle(5, 2, 3) + fn: func(p *Painter) { + p.Circle(5, 2, 3) }, result: "\\n", }, // text { - fn: func(d *Draw) { - d.text("hello world!", 3, 6) + fn: func(p *Painter) { + p.Text("hello world!", 3, 6) }, - result: "\\nhello world!", + result: "\\nhello world!", }, // line stroke { - fn: func(d *Draw) { - d.lineStroke([]Point{ + fn: func(p *Painter) { + p.SetDrawingStyle(Style{ + StrokeColor: drawing.ColorBlack, + StrokeWidth: 1, + }) + p.LineStroke([]Point{ { X: 1, Y: 2, @@ -245,156 +113,153 @@ func TestDraw(t *testing.T) { X: 3, Y: 4, }, - }, LineStyle{ - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, }) }, result: "\\n", }, // set background { - fn: func(d *Draw) { - d.setBackground(400, 300, chart.ColorWhite) + fn: func(p *Painter) { + p.SetBackground(400, 300, chart.ColorWhite) }, result: "\\n", }, // arcTo { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, StrokeColor: drawing.ColorBlack, FillColor: drawing.ColorBlue, - }.WriteToRenderer(d.Render) - d.arcTo(100, 100, 100, 100, 0, math.Pi/2) - d.Render.Close() - d.Render.FillStroke() + }) + p.ArcTo(100, 100, 100, 100, 0, math.Pi/2) + p.Close() + p.FillStroke() }, result: "\\n", }, // pin { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, - StrokeColor: drawing.Color{ + StrokeColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: drawing.Color{ + FillColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.pin(30, 30, 30) + }) + p.Pin(30, 30, 30) }, result: "\\n", }, // arrow left { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, - StrokeColor: drawing.Color{ + StrokeColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: drawing.Color{ + FillColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.arrowLeft(30, 30, 16, 10) + }) + p.ArrowLeft(30, 30, 16, 10) }, result: "\\n", }, // arrow right { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, - StrokeColor: drawing.Color{ + StrokeColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: drawing.Color{ + FillColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.arrowRight(30, 30, 16, 10) + }) + p.ArrowRight(30, 30, 16, 10) }, result: "\\n", }, // arrow top { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, - StrokeColor: drawing.Color{ + StrokeColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: drawing.Color{ + FillColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.arrowTop(30, 30, 10, 16) + }) + p.ArrowTop(30, 30, 10, 16) }, result: "\\n", }, // arrow bottom { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, - StrokeColor: drawing.Color{ + StrokeColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: drawing.Color{ + FillColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.arrowBottom(30, 30, 10, 16) + }) + p.ArrowBottom(30, 30, 10, 16) }, result: "\\n", }, // mark line { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, - StrokeColor: drawing.Color{ + StrokeColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: drawing.Color{ + FillColor: Color{ R: 84, G: 112, B: 198, @@ -404,34 +269,42 @@ func TestDraw(t *testing.T) { 4, 2, }, - }.WriteToRenderer(d.Render) - d.makeLine(0, 20, 300) + }) + p.MarkLine(0, 20, 300) }, - result: "\\n", + result: "\\n", }, // polygon { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, - StrokeColor: drawing.Color{ + StrokeColor: Color{ R: 84, G: 112, B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.polygon(Point{ + }) + p.Polygon(Point{ X: 100, Y: 100, }, 50, 6) }, result: "\\n", }, - // fill + // FillArea { - fn: func(d *Draw) { - d.fill([]Point{ + fn: func(p *Painter) { + p.SetDrawingStyle(Style{ + FillColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.FillArea([]Point{ { X: 0, Y: 0, @@ -448,23 +321,17 @@ func TestDraw(t *testing.T) { X: 0, Y: 0, }, - }, chart.Style{ - FillColor: drawing.Color{ - R: 84, - G: 112, - B: 198, - A: 255, - }, }) }, result: "\\n", }, } for _, tt := range tests { - d, err := NewDraw(DrawOption{ + d, err := NewPainter(PainterOptions{ Width: 400, Height: 300, - }, PaddingOption(chart.Box{ + Type: ChartOutputSVG, + }, PainterPaddingOption(chart.Box{ Left: 5, Top: 10, })) @@ -476,32 +343,57 @@ func TestDraw(t *testing.T) { } } -func TestDrawTextFit(t *testing.T) { +func TestRoundedRect(t *testing.T) { assert := assert.New(t) - d, err := NewDraw(DrawOption{ + p, err := NewPainter(PainterOptions{ Width: 400, Height: 300, + Type: ChartOutputSVG, }) assert.Nil(err) - f, _ := chart.GetDefaultFont() - style := chart.Style{ + p.OverrideDrawingStyle(Style{ + FillColor: drawing.ColorWhite, + StrokeWidth: 1, + StrokeColor: drawing.ColorWhite, + }).RoundedRect(Box{ + Left: 10, + Right: 30, + Bottom: 150, + Top: 10, + }, 5) + buf, err := p.Bytes() + assert.Nil(err) + assert.Equal("\\n", string(buf)) +} + +func TestPainterTextFit(t *testing.T) { + assert := assert.New(t) + p, err := NewPainter(PainterOptions{ + Width: 400, + Height: 300, + Type: ChartOutputSVG, + }) + assert.Nil(err) + f, _ := GetDefaultFont() + style := Style{ FontSize: 12, FontColor: chart.ColorBlack, Font: f, } - box := d.textFit("Hello World!", 0, 20, 80, style) + p.SetStyle(style) + box := p.TextFit("Hello World!", 0, 20, 80) assert.Equal(chart.Box{ Right: 45, Bottom: 35, }, box) - box = d.textFit("Hello World!", 0, 100, 200, style) + box = p.TextFit("Hello World!", 0, 100, 200) assert.Equal(chart.Box{ Right: 84, Bottom: 15, }, box) - buf, err := d.Bytes() + buf, err := p.Bytes() assert.Nil(err) assert.Equal(`\nHelloWorld!Hello World!`, string(buf)) } diff --git a/pie_chart.go b/pie_chart.go index 15c0d35..5c04ed8 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -27,38 +27,138 @@ import ( "math" "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2" ) -func getPieStyle(theme *Theme, index int) chart.Style { - seriesColor := theme.GetSeriesColor(index) - return chart.Style{ - StrokeColor: seriesColor, - StrokeWidth: 1, - FillColor: seriesColor, - } +type pieChart struct { + p *Painter + opt *PieChartOption } -type pieChartOption struct { - Theme string - Font *truetype.Font +type PieChartOption struct { + // The theme + 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 pieChartRender(opt pieChartOption, result *basicRenderResult) error { - d, err := NewDraw(DrawOption{ - Parent: result.d, - }, PaddingOption(chart.Box{ - Top: result.titleBox.Height(), - })) - if err != nil { - return err +// NewPieChart returns a pie chart renderer +func NewPieChart(p *Painter, opt PieChartOption) *pieChart { + if opt.Theme == nil { + opt.Theme = defaultTheme } + return &pieChart{ + p: p, + opt: &opt, + } +} - values := make([]float64, len(opt.SeriesList)) +type sector struct { + value float64 + percent float64 + cx int + cy int + rx float64 + ry float64 + start float64 + delta float64 + offset int + quadrant int + lineStartX int + lineStartY int + lineBranchX int + lineBranchY int + lineEndX int + lineEndY int + showLabel bool + label string + series Series + color Color +} + +func NewSector(cx int, cy int, radius float64, labelRadius float64, value float64, currentValue float64, totalValue float64, labelLineLength int, label string, series Series, color Color) sector { + s := sector{} + s.value = value + s.percent = value / totalValue + s.cx = cx + s.cy = cy + s.rx = radius + s.ry = radius + p := (currentValue + value/2) / totalValue + if p < 0.25 { + s.quadrant = 1 + } else if p < 0.5 { + s.quadrant = 4 + } else if p < 0.75 { + s.quadrant = 3 + } else { + s.quadrant = 2 + } + s.start = chart.PercentToRadians(currentValue/totalValue) - math.Pi/2 + s.delta = chart.PercentToRadians(value / totalValue) + angle := s.start + s.delta/2 + s.lineStartX = cx + int(radius*math.Cos(angle)) + s.lineStartY = cy + int(radius*math.Sin(angle)) + s.lineBranchX = cx + int(labelRadius*math.Cos(angle)) + s.lineBranchY = cy + int(labelRadius*math.Sin(angle)) + s.offset = labelLineLength + if s.lineBranchX <= cx { + s.offset *= -1 + } + s.lineEndX = s.lineBranchX + s.offset + s.lineEndY = s.lineBranchY + s.series = series + s.color = color + s.showLabel = series.Label.Show + s.label = NewPieLabelFormatter([]string{label}, series.Label.Formatter)(0, s.value, s.percent) + return s +} + +func (s *sector) calculateY(prevY int) int { + for i := 0; i <= s.cy; i++ { + if s.quadrant <= 2 { + if (prevY - s.lineBranchY) > labelFontSize+5 { + break + } + s.lineBranchY -= 1 + } else { + if (s.lineBranchY - prevY) > labelFontSize+5 { + break + } + s.lineBranchY += 1 + } + } + s.lineEndY = s.lineBranchY + return s.lineBranchY +} + +func (s *sector) calculateTextXY(textBox Box) (x int, y int) { + textMargin := 3 + x = s.lineEndX + textMargin + y = s.lineEndY + textBox.Height()>>1 - 1 + if s.offset < 0 { + textWidth := textBox.Width() + x = s.lineEndX - textWidth - textMargin + } + return +} + +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 opt.SeriesList { + for index, series := range seriesList { if len(series.Radius) != 0 { radiusValue = series.Radius } @@ -70,16 +170,13 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error { total += value } if total <= 0 { - return errors.New("The sum value of pie chart should gt 0") + return BoxZero, errors.New("The sum value of pie chart should gt 0") } - r := d.Render - theme := NewTheme(opt.Theme) + seriesPainter := result.seriesPainter + cx := seriesPainter.Width() >> 1 + cy := seriesPainter.Height() >> 1 - box := d.Box - cx := box.Width() >> 1 - cy := box.Height() >> 1 - - diameter := chart.MinInt(box.Width(), box.Height()) + diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height()) radius := getRadius(float64(diameter), radiusValue) labelLineWidth := 15 @@ -87,83 +184,135 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error { labelLineWidth = 10 } labelRadius := radius + float64(labelLineWidth) - - seriesNames := opt.SeriesList.Names() - - if len(values) == 1 { - getPieStyle(theme, 0).WriteToRenderer(r) - d.moveTo(cx, cy) - d.circle(radius, cx, cy) - } else { - currentValue := float64(0) - prevEndX := 0 - prevEndY := 0 - for index, v := range values { - - pieStyle := getPieStyle(theme, index) - pieStyle.WriteToRenderer(r) - d.moveTo(cx, cy) - start := chart.PercentToRadians(currentValue/total) - math.Pi/2 - currentValue += v - percent := (v / total) - delta := chart.PercentToRadians(percent) - d.arcTo(cx, cy, radius, radius, start, delta) - d.lineTo(cx, cy) - r.Close() - r.FillStroke() - - series := opt.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 - d.moveTo(startx, starty) - d.lineTo(endx, endy) - offset := labelLineWidth - if endx < cx { - offset *= -1 - } - d.moveTo(endx, endy) - endx += offset - d.lineTo(endx, endy) - r.Stroke() - textStyle := chart.Style{ - FontColor: theme.GetTextColor(), - FontSize: labelFontSize, - Font: opt.Font, - } - if !series.Label.Color.IsZero() { - textStyle.FontColor = series.Label.Color - } - textStyle.GetTextOptions().WriteToRenderer(r) - text := NewPieLabelFormatter(seriesNames, series.Label.Formatter)(index, v, percent) - textBox := r.MeasureText(text) - textMargin := 3 - x := endx + textMargin - y := endy + textBox.Height()>>1 - 1 - if offset < 0 { - textWidth := textBox.Width() - x = endx - textWidth - textMargin - } - d.text(text, x, y) - } + seriesNames := opt.Legend.Data + if len(seriesNames) == 0 { + seriesNames = seriesList.Names() } - return nil + theme := opt.Theme + + currentValue := float64(0) + + var quadrant1, quadrant2, quadrant3, quadrant4 []sector + for index, v := range values { + series := seriesList[index] + color := theme.GetSeriesColor(index) + if index == len(values)-1 { + if color == theme.GetSeriesColor(0) { + color = theme.GetSeriesColor(1) + } + } + s := NewSector(cx, cy, radius, labelRadius, v, currentValue, total, labelLineWidth, seriesNames[index], series, color) + switch quadrant := s.quadrant; quadrant { + case 1: + quadrant1 = append([]sector{s}, quadrant1...) + case 2: + quadrant2 = append(quadrant2, s) + case 3: + quadrant3 = append([]sector{s}, quadrant3...) + case 4: + quadrant4 = append(quadrant4, s) + } + currentValue += v + } + sectors := append(quadrant1, quadrant4...) + sectors = append(sectors, quadrant3...) + sectors = append(sectors, quadrant2...) + + currentQuadrant := 0 + prevY := 0 + maxY := 0 + minY := 0 + for _, s := range sectors { + seriesPainter.OverrideDrawingStyle(Style{ + StrokeWidth: 1, + StrokeColor: s.color, + FillColor: s.color, + }) + seriesPainter.MoveTo(s.cx, s.cy) + seriesPainter.ArcTo(s.cx, s.cy, s.rx, s.ry, s.start, s.delta).LineTo(s.cx, s.cy).Close().FillStroke() + if !s.showLabel { + continue + } + if currentQuadrant != s.quadrant { + if s.quadrant == 1 { + minY = cy * 2 + maxY = 0 + prevY = cy * 2 + } + if s.quadrant == 2 { + if currentQuadrant != 3 { + prevY = s.lineEndY + } else { + prevY = minY + } + } + if s.quadrant == 3 { + if currentQuadrant != 4 { + prevY = s.lineEndY + } else { + minY = cy * 2 + maxY = 0 + prevY = 0 + } + } + if s.quadrant == 4 { + if currentQuadrant != 1 { + prevY = s.lineEndY + } else { + prevY = maxY + } + } + currentQuadrant = s.quadrant + } + prevY = s.calculateY(prevY) + if prevY > maxY { + maxY = prevY + } + if prevY < minY { + minY = prevY + } + seriesPainter.MoveTo(s.lineStartX, s.lineStartY) + seriesPainter.LineTo(s.lineBranchX, s.lineBranchY) + seriesPainter.MoveTo(s.lineBranchX, s.lineBranchY) + seriesPainter.LineTo(s.lineEndX, s.lineEndY) + seriesPainter.Stroke() + textStyle := Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + } + if !s.series.Label.Color.IsZero() { + textStyle.FontColor = s.series.Label.Color + } + seriesPainter.OverrideTextStyle(textStyle) + x, y := s.calculateTextXY(seriesPainter.MeasureText(s.label)) + seriesPainter.Text(s.label, 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/pie_chart_test.go b/pie_chart_test.go index 84072be..3795d32 100644 --- a/pie_chart_test.go +++ b/pie_chart_test.go @@ -23,47 +23,511 @@ package charts import ( + "strconv" "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestPieChartRender(t *testing.T) { +func TestPieChart(t *testing.T) { assert := assert.New(t) - d, err := NewDraw(DrawOption{ - Width: 250, - Height: 150, - }) - assert.Nil(err) - - f, _ := chart.GetDefaultFont() - - err = pieChartRender(pieChartOption{ - Font: f, - SeriesList: NewPieSeriesList([]float64{ - 5, - 10, - 0, - }, PieSeriesOption{ - Names: []string{ - "a", - "b", - "c", + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := []float64{ + 1048, + 735, + 580, + 484, + 300, + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + }, + }), + Title: TitleOption{ + Text: "Rainfall vs Evaporation", + Subtext: "Fake Data", + Left: PositionCenter, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Orient: OrientVertical, + Data: []string{ + "Search Engine", + "Direct", + "Email", + "Union Ads", + "Video Ads", + }, + Left: PositionLeft, + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, - Label: SeriesLabel{ - Show: true, - Color: drawing.ColorRed, - }, - Radius: "20%", - }), - }, &basicRenderResult{ - d: d, - }) - assert.Nil(err) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\na: 33.33%b: 66.66%c: 0%", string(data)) + result: "\\nSearch EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestPieChartWithLabelsValuesSortedDescending(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := []float64{ + 84358845, + 68070697, + 58850717, + 48059777, + 36753736, + 19051562, + 17947406, + 11754004, + 10827529, + 10521556, + 10467366, + 10394055, + 9597085, + 9104772, + 6447710, + 5932654, + 5563970, + 5428792, + 5194336, + 3850894, + 2857279, + 2116792, + 1883008, + 1373101, + 920701, + 660809, + 542051, + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + Formatter: "{b} ({c} ≅ {d})", + }, + Radius: "200", + }), + Title: TitleOption{ + Text: "European Union member states by population", + Left: PositionRight, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Data: []string{ + "Germany", + "France", + "Italy", + "Spain", + "Poland", + "Romania", + "Netherlands", + "Belgium", + "Czech Republic", + "Sweden", + "Portugal", + "Greece", + "Hungary", + "Austria", + "Bulgaria", + "Denmark", + "Finland", + "Slovakia", + "Ireland", + "Croatia", + "Lithuania", + "Slovenia", + "Latvia", + "Estonia", + "Cyprus", + "Luxembourg", + "Malta", + }, + Show: FalseFlag(), + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nEuropean Union member states by populationGermany (84358845 ≅ 18.8%)France (68070697 ≅ 15.17%)Italy (58850717 ≅ 13.12%)Netherlands (17947406 ≅ 4%)Romania (19051562 ≅ 4.24%)Poland (36753736 ≅ 8.19%)Spain (48059777 ≅ 10.71%)Belgium (11754004 ≅ 2.62%)Czech Republic (10827529 ≅ 2.41%)Sweden (10521556 ≅ 2.34%)Portugal (10467366 ≅ 2.33%)Greece (10394055 ≅ 2.31%)Hungary (9597085 ≅ 2.13%)Austria (9104772 ≅ 2.02%)Bulgaria (6447710 ≅ 1.43%)Denmark (5932654 ≅ 1.32%)Finland (5563970 ≅ 1.24%)Slovakia (5428792 ≅ 1.21%)Ireland (5194336 ≅ 1.15%)Croatia (3850894 ≅ 0.85%)Lithuania (2857279 ≅ 0.63%)Slovenia (2116792 ≅ 0.47%)Latvia (1883008 ≅ 0.41%)Estonia (1373101 ≅ 0.3%)Cyprus (920701 ≅ 0.2%)Luxembourg (660809 ≅ 0.14%)Malta (542051 ≅ 0.12%)", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 1000, + Height: 800, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestPieChartWithLabelsValuesUnsorted(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := []float64{ + 9104772, + 11754004, + 6447710, + 3850894, + 920701, + 10827529, + 5932654, + 1373101, + 5563970, + 68070697, + 84358845, + 10394055, + 9597085, + 5194336, + 58850717, + 1883008, + 2857279, + 660809, + 542051, + 17947406, + 36753736, + 10467366, + 19051562, + 5428792, + 2116792, + 48059777, + 10521556, + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + Formatter: "{b} ({c} ≅ {d})", + }, + Radius: "200", + }), + Title: TitleOption{ + Text: "European Union member states by population", + Left: PositionRight, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Data: []string{ + "Austria", + "Belgium", + "Bulgaria", + "Croatia", + "Cyprus", + "Czech Republic", + "Denmark", + "Estonia", + "Finland", + "France", + "Germany", + "Greece", + "Hungary", + "Ireland", + "Italy", + "Latvia", + "Lithuania", + "Luxembourg", + "Malta", + "Netherlands", + "Poland", + "Portugal", + "Romania", + "Slovakia", + "Slovenia", + "Spain", + "Sweden", + }, + Show: FalseFlag(), + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nEuropean Union member states by populationFrance (68070697 ≅ 15.17%)Finland (5563970 ≅ 1.24%)Estonia (1373101 ≅ 0.3%)Denmark (5932654 ≅ 1.32%)Czech Republic (10827529 ≅ 2.41%)Cyprus (920701 ≅ 0.2%)Croatia (3850894 ≅ 0.85%)Bulgaria (6447710 ≅ 1.43%)Belgium (11754004 ≅ 2.62%)Austria (9104772 ≅ 2.02%)Germany (84358845 ≅ 18.8%)Greece (10394055 ≅ 2.31%)Hungary (9597085 ≅ 2.13%)Poland (36753736 ≅ 8.19%)Netherlands (17947406 ≅ 4%)Malta (542051 ≅ 0.12%)Luxembourg (660809 ≅ 0.14%)Lithuania (2857279 ≅ 0.63%)Latvia (1883008 ≅ 0.41%)Italy (58850717 ≅ 13.12%)Ireland (5194336 ≅ 1.15%)Portugal (10467366 ≅ 2.33%)Romania (19051562 ≅ 4.24%)Slovakia (5428792 ≅ 1.21%)Slovenia (2116792 ≅ 0.47%)Spain (48059777 ≅ 10.71%)Sweden (10521556 ≅ 2.34%)", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 1000, + Height: 800, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestPieChartWith100Labels(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + var values []float64 + var labels []string + for i := 1; i <= 100; i++ { + values = append(values, float64(1)) + labels = append(labels, "Label "+strconv.Itoa(i)) + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + }, + Radius: "200", + }), + Title: TitleOption{ + Text: "Test with 100 labels", + Left: PositionRight, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Data: labels, + Show: FalseFlag(), + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nTest with 100 labelsLabel 25: 1%Label 24: 1%Label 23: 1%Label 22: 1%Label 21: 1%Label 20: 1%Label 19: 1%Label 18: 1%Label 17: 1%Label 16: 1%Label 15: 1%Label 14: 1%Label 13: 1%Label 12: 1%Label 11: 1%Label 10: 1%Label 9: 1%Label 8: 1%Label 7: 1%Label 6: 1%Label 5: 1%Label 4: 1%Label 3: 1%Label 2: 1%Label 1: 1%Label 26: 1%Label 27: 1%Label 28: 1%Label 29: 1%Label 30: 1%Label 31: 1%Label 32: 1%Label 33: 1%Label 34: 1%Label 35: 1%Label 36: 1%Label 37: 1%Label 38: 1%Label 39: 1%Label 40: 1%Label 41: 1%Label 42: 1%Label 43: 1%Label 44: 1%Label 45: 1%Label 46: 1%Label 47: 1%Label 48: 1%Label 49: 1%Label 50: 1%Label 75: 1%Label 74: 1%Label 73: 1%Label 72: 1%Label 71: 1%Label 70: 1%Label 69: 1%Label 68: 1%Label 67: 1%Label 66: 1%Label 65: 1%Label 64: 1%Label 63: 1%Label 62: 1%Label 61: 1%Label 60: 1%Label 59: 1%Label 58: 1%Label 57: 1%Label 56: 1%Label 55: 1%Label 54: 1%Label 53: 1%Label 52: 1%Label 51: 1%Label 76: 1%Label 77: 1%Label 78: 1%Label 79: 1%Label 80: 1%Label 81: 1%Label 82: 1%Label 83: 1%Label 84: 1%Label 85: 1%Label 86: 1%Label 87: 1%Label 88: 1%Label 89: 1%Label 90: 1%Label 91: 1%Label 92: 1%Label 93: 1%Label 94: 1%Label 95: 1%Label 96: 1%Label 97: 1%Label 98: 1%Label 99: 1%Label 100: 1%", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 1000, + Height: 900, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestPieChartFixLabelPos72586(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := []float64{ + 397594, + 185596, + 149086, + 144258, + 120194, + 117514, + 99412, + 91135, + 87282, + 76790, + 72586, + 58818, + 58270, + 56306, + 55486, + 54792, + 53746, + 51460, + 41242, + 39476, + 37414, + 36644, + 33784, + 32788, + 32566, + 29608, + 29558, + 29384, + 28166, + 26998, + 26948, + 26054, + 25804, + 25730, + 24438, + 23782, + 22896, + 21404, + 428978, + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + Formatter: "{b} ({c} ≅ {d})", + }, + Radius: "150", + }), + Title: TitleOption{ + Text: "Fix label K (72586)", + Left: PositionRight, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Data: []string{ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "AA", + "AB", + "AC", + "AD", + "AE", + "AF", + "AG", + "AH", + "AI", + "AJ", + "AK", + "AL", + "AM", + }, + Show: FalseFlag(), + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nFix label K (72586)C (149086 ≅ 5.04%)B (185596 ≅ 6.28%)A (397594 ≅ 13.45%)D (144258 ≅ 4.88%)E (120194 ≅ 4.06%)F (117514 ≅ 3.97%)G (99412 ≅ 3.36%)H (91135 ≅ 3.08%)I (87282 ≅ 2.95%)J (76790 ≅ 2.59%)Z (29608 ≅ 1%)Y (32566 ≅ 1.1%)X (32788 ≅ 1.1%)W (33784 ≅ 1.14%)V (36644 ≅ 1.24%)U (37414 ≅ 1.26%)T (39476 ≅ 1.33%)S (41242 ≅ 1.39%)R (51460 ≅ 1.74%)Q (53746 ≅ 1.81%)P (54792 ≅ 1.85%)O (55486 ≅ 1.87%)N (56306 ≅ 1.9%)M (58270 ≅ 1.97%)L (58818 ≅ 1.99%)K (72586 ≅ 2.45%)AA (29558 ≅ 1%)AB (29384 ≅ 0.99%)AC (28166 ≅ 0.95%)AD (26998 ≅ 0.91%)AE (26948 ≅ 0.91%)AF (26054 ≅ 0.88%)AG (25804 ≅ 0.87%)AH (25730 ≅ 0.87%)AI (24438 ≅ 0.82%)AJ (23782 ≅ 0.8%)AK (22896 ≅ 0.77%)AL (21404 ≅ 0.72%)AM (428978 ≅ 14.52%)", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 1150, + Height: 550, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } } diff --git a/radar_chart.go b/radar_chart.go index 364213d..cf18135 100644 --- a/radar_chart.go +++ b/radar_chart.go @@ -25,11 +25,17 @@ package charts import ( "errors" + "github.com/dustin/go-humanize" "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) +type radarChart struct { + p *Painter + opt *RadarChartOption +} + type RadarIndicator struct { // Indicator's name Name string @@ -39,89 +45,118 @@ type RadarIndicator struct { Min float64 } -type radarChartOption struct { - Theme string - Font *truetype.Font +type RadarChartOption struct { + // The theme + Theme ColorPalette + // The font size + Font *truetype.Font + // The data series list SeriesList SeriesList - Indicators []RadarIndicator + // 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 radarChartRender(opt radarChartOption, result *basicRenderResult) error { - sides := len(opt.Indicators) - if sides < 3 { - return errors.New("The count of indicator should be >= 3") +// NewRadarIndicators returns a radar indicator list +func NewRadarIndicators(names []string, values []float64) []RadarIndicator { + if len(names) != len(values) { + return nil } - maxValues := make([]float64, len(opt.Indicators)) - for _, series := range opt.SeriesList { + indicators := make([]RadarIndicator, len(names)) + for index, name := range names { + indicators[index] = RadarIndicator{ + Name: name, + Max: values[index], + } + } + return indicators +} + +// NewRadarChart returns a radar chart renderer +func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart { + if opt.Theme == nil { + opt.Theme = defaultTheme + } + 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 opt.Indicators { + for index, indicator := range indicators { if indicator.Max <= 0 { - opt.Indicators[index].Max = maxValues[index] + indicators[index].Max = maxValues[index] } } - d, err := NewDraw(DrawOption{ - Parent: result.d, - }, PaddingOption(chart.Box{ - Top: result.titleBox.Height(), - })) - if err != nil { - return err - } + radiusValue := "" - for _, series := range opt.SeriesList { + for _, series := range seriesList { if len(series.Radius) != 0 { radiusValue = series.Radius } } - box := d.Box - cx := box.Width() >> 1 - cy := box.Height() >> 1 - diameter := chart.MinInt(box.Width(), box.Height()) - radius := getRadius(float64(diameter), radiusValue) + seriesPainter := result.seriesPainter + theme := opt.Theme - theme := NewTheme(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) - style := chart.Style{ + seriesPainter.OverrideDrawingStyle(Style{ StrokeColor: theme.GetAxisSplitLineColor(), StrokeWidth: 1, - } - r := d.Render - style.WriteToRenderer(r) + }) center := Point{ X: cx, Y: cy, } for i := 0; i < divideCount; i++ { - d.polygon(center, divideRadius*float64(i+1), sides) + seriesPainter.Polygon(center, divideRadius*float64(i+1), sides) } points := getPolygonPoints(center, radius, sides) for _, p := range points { - d.moveTo(center.X, center.Y) - d.lineTo(p.X, p.Y) - d.Render.Stroke() + seriesPainter.MoveTo(center.X, center.Y) + seriesPainter.LineTo(p.X, p.Y) + seriesPainter.Stroke() } - // 文本 - textStyle := chart.Style{ + seriesPainter.OverrideTextStyle(Style{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, - } - textStyle.GetTextOptions().WriteToRenderer(r) + }) offset := 5 // 文本生成 for index, p := range points { - name := opt.Indicators[index].Name - b := r.MeasureText(name) + name := indicators[index].Name + b := seriesPainter.MeasureText(name) isXCenter := p.X == center.X isYCenter := p.Y == center.Y isRight := p.X > center.X @@ -153,20 +188,24 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error { if isLeft { x -= (b.Width() + offset) } - d.text(name, x, y) + seriesPainter.Text(name, x, y) } // 雷达图 angles := getPolygonPointAngles(sides) - maxCount := len(opt.Indicators) - for _, series := range opt.SeriesList { + maxCount := len(indicators) + for _, series := range seriesList { linePoints := make([]Point, 0, maxCount) for j, item := range series.Data { if j >= maxCount { continue } - indicator := opt.Indicators[j] - percent := (item.Value - indicator.Min) / (indicator.Max - indicator.Min) + indicator := indicators[j] + var percent float64 + offset := indicator.Max - indicator.Min + if offset > 0 { + percent = (item.Value - indicator.Min) / offset + } r := percent * radius p := getPolygonPoint(center, r, angles[j]) linePoints = append(linePoints, p) @@ -177,17 +216,58 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error { dotFillColor = color } linePoints = append(linePoints, linePoints[0]) - s := LineStyle{ - StrokeColor: color, - StrokeWidth: defaultStrokeWidth, - DotWidth: defaultDotWidth, - DotColor: color, - DotFillColor: dotFillColor, - FillColor: color.WithAlpha(20), + 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 index, point := range linePoints { + seriesPainter.Circle(dotWith, point.X, point.Y) + seriesPainter.FillStroke() + if series.Label.Show && index < len(series.Data) { + value := humanize.FtoaWithDigits(series.Data[index].Value, 2) + b := seriesPainter.MeasureText(value) + seriesPainter.Text(value, point.X-b.Width()/2, point.Y) + } + } - d.lineStroke(linePoints, s) - d.fill(linePoints, s.Style()) - d.lineDot(linePoints[0:len(linePoints)-1], s) } - return nil + + 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/radar_chart_test.go b/radar_chart_test.go index c5d2aa9..79fd9ac 100644 --- a/radar_chart_test.go +++ b/radar_chart_test.go @@ -26,77 +26,82 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" ) -func TestRadarChartRender(t *testing.T) { +func TestRadarChart(t *testing.T) { assert := assert.New(t) - d, err := NewDraw(DrawOption{ - Width: 250, - Height: 150, - }) - assert.Nil(err) - - f, _ := chart.GetDefaultFont() - err = radarChartRender(radarChartOption{ - Font: f, - Indicators: []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, + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := [][]float64{ + { + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }, + { + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, + }, + } + _, err := NewRadarChart(p, RadarChartOption{ + SeriesList: NewSeriesListDataFromValues(values, ChartTypeRadar), + Title: TitleOption{ + Text: "Basic Radar Chart", + }, + Legend: NewLegendOption([]string{ + "Allocated Budget", + "Actual Spending", + }), + RadarIndicators: NewRadarIndicators([]string{ + "Sales", + "Administration", + "Information Technology", + "Customer Support", + "Development", + "Marketing", + }, []float64{ + 6500, + 16000, + 30000, + 38000, + 52000, + 25000, + }), + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\nAllocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", }, - SeriesList: SeriesList{ - { - Type: ChartTypeRadar, - Data: NewSeriesDataFromValues([]float64{ - 4200, - 3000, - 20000, - 35000, - 50000, - 18000, - }), - }, - { - Type: ChartTypeRadar, - index: 1, - Data: NewSeriesDataFromValues([]float64{ - 5000, - 14000, - 28000, - 26000, - 42000, - 21000, - }), - }, - }, - }, &basicRenderResult{ - d: d, - }) - assert.Nil(err) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\nSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", string(data)) + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } } diff --git a/range.go b/range.go index 255a51b..ec64c2d 100644 --- a/range.go +++ b/range.go @@ -26,19 +26,46 @@ import ( "math" ) -type Range struct { +const defaultAxisDivideCount = 6 + +type axisRange struct { + p *Painter divideCount int - Min float64 - Max float64 - Size int - Boundary bool + min float64 + max float64 + size int + boundary bool } -func NewRange(min, max float64, divideCount int) Range { +type AxisRangeOption struct { + Painter *Painter + // The min value of axis + Min float64 + // The max value of axis + Max float64 + // The size of axis + Size int + // Boundary gap + Boundary bool + // The count of divide + DivideCount int +} + +// NewRange returns a axis range +func NewRange(opt AxisRangeOption) axisRange { + max := opt.Max + min := opt.Min + + max += math.Abs(max * 0.1) + min -= math.Abs(min * 0.1) + divideCount := opt.DivideCount r := math.Abs(max - min) // 最小单位计算 - unit := 2 + unit := 1 + if r > 5 { + unit = 2 + } if r > 10 { unit = 4 } @@ -63,47 +90,55 @@ func NewRange(min, max float64, divideCount int) Range { } } max = min + float64(unit*divideCount) - return Range{ - Min: min, - Max: max, + expectMax := opt.Max * 2 + if max > expectMax { + max = float64(ceilFloatToInt(expectMax)) + } + return axisRange{ + p: opt.Painter, divideCount: divideCount, + min: min, + max: max, + size: opt.Size, + boundary: opt.Boundary, } } -func (r Range) Values() []string { - offset := (r.Max - r.Min) / float64(r.divideCount) +// Values returns values of range +func (r axisRange) Values() []string { + offset := (r.max - r.min) / float64(r.divideCount) values := make([]string, 0) + formatter := commafWithDigits + if r.p != nil && r.p.valueFormatter != nil { + formatter = r.p.valueFormatter + } for i := 0; i <= r.divideCount; i++ { - v := r.Min + float64(i)*offset - value := commafWithDigits(v) + v := r.min + float64(i)*offset + value := formatter(v) values = append(values, value) } return values } -func (r *Range) getHeight(value float64) int { - v := (value - r.Min) / (r.Max - r.Min) - return int(v * float64(r.Size)) +func (r *axisRange) getHeight(value float64) int { + if r.max <= r.min { + return 0 + } + v := (value - r.min) / (r.max - r.min) + return int(v * float64(r.size)) } -func (r *Range) getRestHeight(value float64) int { - return r.Size - r.getHeight(value) +func (r *axisRange) getRestHeight(value float64) int { + return r.size - r.getHeight(value) } -func (r *Range) GetRange(index int) (float64, float64) { - unit := float64(r.Size) / float64(r.divideCount) +// GetRange returns a range of index +func (r *axisRange) GetRange(index int) (float64, float64) { + unit := float64(r.size) / float64(r.divideCount) return unit * float64(index), unit * float64(index+1) } -func (r *Range) AutoDivide() []int { - return autoDivide(r.Size, r.divideCount) -} -func (r *Range) getWidth(value float64) int { - v := value / (r.Max - r.Min) - // 移至居中 - if r.Boundary && - r.divideCount != 0 { - v += 1 / float64(r.divideCount*2) - } - return int(v * float64(r.Size)) +// AutoDivide divides the axis +func (r *axisRange) AutoDivide() []int { + return autoDivide(r.size, r.divideCount) } diff --git a/range_test.go b/range_test.go deleted file mode 100644 index d1aea8f..0000000 --- a/range_test.go +++ /dev/null @@ -1,94 +0,0 @@ -// 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 ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestRange(t *testing.T) { - assert := assert.New(t) - - r := NewRange(0, 8, 6) - assert.Equal(0.0, r.Min) - assert.Equal(12.0, r.Max) - - r = NewRange(0, 12, 6) - assert.Equal(0.0, r.Min) - assert.Equal(24.0, r.Max) - - r = NewRange(-13, 18, 6) - assert.Equal(-20.0, r.Min) - assert.Equal(40.0, r.Max) - - r = NewRange(0, 150, 6) - assert.Equal(0.0, r.Min) - assert.Equal(180.0, r.Max) - - r = NewRange(0, 400, 6) - assert.Equal(0.0, r.Min) - assert.Equal(480.0, r.Max) -} - -func TestRangeHeightWidth(t *testing.T) { - assert := assert.New(t) - r := NewRange(0, 8, 6) - r.Size = 100 - - assert.Equal(33, r.getHeight(4)) - assert.Equal(67, r.getRestHeight(4)) - - assert.Equal(33, r.getWidth(4)) - r.Boundary = true - assert.Equal(41, r.getWidth(4)) -} - -func TestRangeGetRange(t *testing.T) { - assert := assert.New(t) - r := NewRange(0, 8, 6) - r.Size = 120 - - f1, f2 := r.GetRange(0) - assert.Equal(0.0, f1) - assert.Equal(20.0, f2) - - f1, f2 = r.GetRange(2) - assert.Equal(40.0, f1) - assert.Equal(60.0, f2) -} - -func TestRangeAutoDivide(t *testing.T) { - assert := assert.New(t) - - r := Range{ - Size: 120, - divideCount: 6, - } - - assert.Equal([]int{0, 20, 40, 60, 80, 100, 120}, r.AutoDivide()) - - r.Size = 130 - assert.Equal([]int{0, 22, 44, 66, 88, 109, 130}, r.AutoDivide()) -} diff --git a/series.go b/series.go index 14227d1..da50e64 100644 --- a/series.go +++ b/series.go @@ -19,7 +19,6 @@ // 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 ( @@ -27,17 +26,26 @@ import ( "strings" "github.com/dustin/go-humanize" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2" ) type SeriesData struct { // The value of series data Value float64 // The style of series data - Style chart.Style + Style Style } +// NewSeriesListDataFromValues returns a series list +func NewSeriesListDataFromValues(values [][]float64, chartType ...string) SeriesList { + seriesList := make(SeriesList, len(values)) + for index, value := range values { + seriesList[index] = NewSeriesFromValues(value, chartType...) + } + return seriesList +} + +// NewSeriesFromValues returns a series func NewSeriesFromValues(values []float64, chartType ...string) Series { s := Series{ Data: NewSeriesDataFromValues(values), @@ -48,6 +56,7 @@ func NewSeriesFromValues(values []float64, chartType ...string) Series { return s } +// NewSeriesDataFromValues return a series data func NewSeriesDataFromValues(values []float64) []SeriesData { data := make([]SeriesData, len(values)) for index, value := range values { @@ -65,11 +74,17 @@ type SeriesLabel struct { // {d}: the percent of a data item(pie chart). Formatter string // The color for label - Color drawing.Color + Color Color // Show flag for label Show bool // Distance to the host graphic element. Distance int + // The position of label + Position string + // The offset of label's position + Offset Box + // The font size of label + FontSize float64 } const ( @@ -101,8 +116,8 @@ type Series struct { // The data list of series Data []SeriesData // The Y axis index, it should be 0 or 1. - // Default value is 1 - YAxisIndex int + // Default value is 0 + AxisIndex int // The style for series Style chart.Style // The label for series @@ -111,6 +126,8 @@ type Series struct { Name string // Radius for Pie chart, e.g.: 40%, default is "40%" Radius string + // Round for bar chart + RoundRadius int // Mark point for series MarkPoint SeriesMarkPoint // Make line for series @@ -122,6 +139,55 @@ type Series struct { } type SeriesList []Series +func (sl SeriesList) init() { + if len(sl) == 0 { + return + } + if sl[len(sl)-1].index != 0 { + return + } + for i := 0; i < len(sl); i++ { + if sl[i].Type == "" { + sl[i].Type = ChartTypeLine + } + sl[i].index = i + } +} + +func (sl SeriesList) Filter(chartType string) SeriesList { + arr := make(SeriesList, 0) + for index, item := range sl { + if item.Type == chartType { + arr = append(arr, sl[index]) + } + } + return arr +} + +// GetMaxMin get max and min value of series list +func (sl SeriesList) GetMaxMin(axisIndex int) (float64, float64) { + min := math.MaxFloat64 + max := -math.MaxFloat64 + for _, series := range sl { + if series.AxisIndex != axisIndex { + continue + } + for _, item := range series.Data { + // 如果为空值,忽略 + if item.Value == nullValue { + continue + } + if item.Value > max { + max = item.Value + } + if item.Value < min { + min = item.Value + } + } + } + return max, min +} + type PieSeriesOption struct { Radius string Label SeriesLabel @@ -156,13 +222,19 @@ func NewPieSeriesList(values []float64, opts ...PieSeriesOption) SeriesList { } type seriesSummary struct { - MaxIndex int - MaxValue float64 - MinIndex int - MinValue float64 + // The index of max value + MaxIndex int + // The max value + MaxValue float64 + // The index of min value + MinIndex int + // The min value + MinValue float64 + // THe average value AverageValue float64 } +// Summary get summary of series func (s *Series) Summary() seriesSummary { minIndex := -1 maxIndex := -1 @@ -189,6 +261,7 @@ func (s *Series) Summary() seriesSummary { } } +// Names returns the names of series list func (sl SeriesList) Names() []string { names := make([]string, len(sl)) for index, s := range sl { @@ -197,8 +270,10 @@ func (sl SeriesList) Names() []string { return names } +// LabelFormatter label formatter type LabelFormatter func(index int, value float64, percent float64) string +// NewPieLabelFormatter returns a pie label formatter func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter { if len(layout) == 0 { layout = "{b}: {d}" @@ -206,13 +281,23 @@ func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter { return NewLabelFormatter(seriesNames, layout) } -func NewValueLabelFormater(seriesNames []string, layout string) LabelFormatter { +// NewFunnelLabelFormatter returns a funner label formatter +func NewFunnelLabelFormatter(seriesNames []string, layout string) LabelFormatter { + if len(layout) == 0 { + layout = "{b}({d})" + } + return NewLabelFormatter(seriesNames, layout) +} + +// NewValueLabelFormatter returns a value formatter +func NewValueLabelFormatter(seriesNames []string, layout string) LabelFormatter { if len(layout) == 0 { layout = "{c}" } return NewLabelFormatter(seriesNames, layout) } +// NewLabelFormatter returns a label formaatter func NewLabelFormatter(seriesNames []string, layout string) LabelFormatter { return func(index int, value, percent float64) string { // 如果无percent的则设置为<0 diff --git a/series_label.go b/series_label.go new file mode 100644 index 0000000..af873fc --- /dev/null +++ b/series_label.go @@ -0,0 +1,148 @@ +// 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" + "git.smarteching.com/zeni/go-chart/v2" +) + +type labelRenderValue struct { + Text string + Style Style + X int + Y int + // 旋转 + Radians float64 +} + +type LabelValue struct { + Index int + Value float64 + X int + Y int + // 旋转 + Radians float64 + // 字体颜色 + FontColor Color + // 字体大小 + FontSize float64 + Orient string + Offset Box +} + +type SeriesLabelPainter struct { + p *Painter + seriesNames []string + label *SeriesLabel + theme ColorPalette + font *truetype.Font + values []labelRenderValue +} + +type SeriesLabelPainterParams struct { + P *Painter + SeriesNames []string + Label SeriesLabel + Theme ColorPalette + Font *truetype.Font +} + +func NewSeriesLabelPainter(params SeriesLabelPainterParams) *SeriesLabelPainter { + return &SeriesLabelPainter{ + p: params.P, + seriesNames: params.SeriesNames, + label: ¶ms.Label, + theme: params.Theme, + font: params.Font, + values: make([]labelRenderValue, 0), + } +} + +func (o *SeriesLabelPainter) Add(value LabelValue) { + label := o.label + distance := label.Distance + if distance == 0 { + distance = 5 + } + text := NewValueLabelFormatter(o.seriesNames, label.Formatter)(value.Index, value.Value, -1) + labelStyle := Style{ + FontColor: o.theme.GetTextColor(), + FontSize: labelFontSize, + Font: o.font, + } + if value.FontSize != 0 { + labelStyle.FontSize = value.FontSize + } + if !value.FontColor.IsZero() { + label.Color = value.FontColor + } + if !label.Color.IsZero() { + labelStyle.FontColor = label.Color + } + p := o.p + p.OverrideDrawingStyle(labelStyle) + rotated := value.Radians != 0 + if rotated { + p.SetTextRotation(value.Radians) + } + textBox := p.MeasureText(text) + renderValue := labelRenderValue{ + Text: text, + Style: labelStyle, + X: value.X, + Y: value.Y, + Radians: value.Radians, + } + if value.Orient != OrientHorizontal { + renderValue.X -= textBox.Width() >> 1 + renderValue.Y -= distance + } else { + renderValue.X += distance + renderValue.Y += textBox.Height() >> 1 + renderValue.Y -= 2 + } + if rotated { + renderValue.X = value.X + textBox.Width()>>1 - 1 + p.ClearTextRotation() + } else { + if textBox.Width()%2 != 0 { + renderValue.X++ + } + } + renderValue.X += value.Offset.Left + renderValue.Y += value.Offset.Top + o.values = append(o.values, renderValue) +} + +func (o *SeriesLabelPainter) Render() (Box, error) { + for _, item := range o.values { + o.p.OverrideTextStyle(item.Style) + if item.Radians != 0 { + o.p.TextRotation(item.Text, item.X, item.Y, item.Radians) + } else { + o.p.Text(item.Text, item.X, item.Y) + } + } + return chart.BoxZero, nil +} diff --git a/series_test.go b/series_test.go index 1460180..40d2f91 100644 --- a/series_test.go +++ b/series_test.go @@ -19,7 +19,6 @@ // 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 ( @@ -28,139 +27,63 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewSeriesFromValues(t *testing.T) { - assert := assert.New(t) - - assert.Equal(Series{ - Data: []SeriesData{ - { - Value: 1, - }, - { - Value: 2, - }, - }, - Type: ChartTypeBar, - }, NewSeriesFromValues([]float64{ - 1, - 2, - }, ChartTypeBar)) -} - -func TestNewSeriesDataFromValues(t *testing.T) { - assert := assert.New(t) - - assert.Equal([]SeriesData{ - { - Value: 1, - }, - { - Value: 2, - }, - }, NewSeriesDataFromValues([]float64{ - 1, - 2, - })) -} - -func TestNewPieSeriesList(t *testing.T) { +func TestNewSeriesListDataFromValues(t *testing.T) { assert := assert.New(t) assert.Equal(SeriesList{ { - Type: ChartTypePie, - Name: "a", - Label: SeriesLabel{ - Show: true, - }, - Radius: "30%", + Type: ChartTypeBar, Data: []SeriesData{ { - Value: 1, + Value: 1.0, }, }, }, + }, NewSeriesListDataFromValues([][]float64{ { - Type: ChartTypePie, - Name: "b", - Label: SeriesLabel{ - Show: true, - }, - Radius: "30%", - Data: []SeriesData{ - { - Value: 2, - }, - }, + 1, }, - }, NewPieSeriesList([]float64{ - 1, - 2, - }, PieSeriesOption{ - Radius: "30%", - Label: SeriesLabel{ - Show: true, - }, - Names: []string{ - "a", - "b", - }, - })) + }, ChartTypeBar)) } -func TestSeriesSummary(t *testing.T) { +func TestSeriesLists(t *testing.T) { assert := assert.New(t) - - s := Series{ - Data: NewSeriesDataFromValues([]float64{ + seriesList := NewSeriesListDataFromValues([][]float64{ + { 1, - 3, - 5, - 7, - 9, - }), - } + 2, + }, + { + 10, + }, + }, ChartTypeBar) + + assert.Equal(2, len(seriesList.Filter(ChartTypeBar))) + assert.Equal(0, len(seriesList.Filter(ChartTypeLine))) + + max, min := seriesList.GetMaxMin(0) + assert.Equal(float64(10), max) + assert.Equal(float64(1), min) + assert.Equal(seriesSummary{ - MaxIndex: 4, - MaxValue: 9, + MaxIndex: 1, + MaxValue: 2, MinIndex: 0, MinValue: 1, - AverageValue: 5, - }, s.Summary()) + AverageValue: 1.5, + }, seriesList[0].Summary()) } -func TestGetSeriesNames(t *testing.T) { +func TestFormatter(t *testing.T) { assert := assert.New(t) - sl := SeriesList{ - { - Name: "a", - }, - { - Name: "b", - }, - } - assert.Equal([]string{ + assert.Equal("a: 12%", NewPieLabelFormatter([]string{ "a", "b", - }, sl.Names()) -} + }, "")(0, 10, 0.12)) -func TestNewPieLabelFormatter(t *testing.T) { - assert := assert.New(t) - - fn := NewPieLabelFormatter([]string{ + assert.Equal("10", NewValueLabelFormatter([]string{ "a", "b", - }, "") - assert.Equal("a: 35%", fn(0, 1.2, 0.35)) -} - -func TestNewValueLabelFormater(t *testing.T) { - assert := assert.New(t) - fn := NewValueLabelFormater([]string{ - "a", - "b", - }, "") - assert.Equal("1.2", fn(0, 1.2, 0.35)) + }, "")(0, 10, 0.12)) } diff --git a/start_zh.md b/start_zh.md new file mode 100644 index 0000000..ee8359c --- /dev/null +++ b/start_zh.md @@ -0,0 +1,254 @@ +# go-charts + +`go-charts`主要分为了下几个模块: + +- `标题`:图表的标题,包括主副标题,位置为图表的顶部 +- `图例`:图表的图例列表,用于标识每个图例对应的颜色与名称信息,默认为图表的顶部,可自定义位置 +- `X轴`:图表的x轴,用于折线图、柱状图中,表示每个点对应的时间,位置图表的底部 +- `Y轴`:图表的y轴,用于折线图、柱状图中,最多可使用两组y轴(一左一右),默认位置图表的左侧 +- `内容`: 图表的内容,折线图、柱状图、饼图等,在图表的中间区域 + +## 标题 + +### 常用设置 + +标题一般仅需要设置主副标题即可,其它的属性均会设置默认值,常用的方式是使用`TitleTextOptionFunc`设置,其中副标题为可选值,方式如下: + +```go + charts.TitleTextOptionFunc("Text", "Subtext"), +``` + +### 个性化设置 + +```go +func(opt *charts.ChartOption) { + opt.Title = charts.TitleOption{ + // 主标题 + Text: "Text", + // 副标题 + Subtext: "Subtext", + // 标题左侧位置,可设置为"center","right",数值("20")或百份比("20%") + Left: charts.PositionRight, + // 标题顶部位置,只可调为数值 + Top: "20", + // 主标题文字大小 + FontSize: 14, + // 副标题文字大小 + SubtextFontSize: 12, + // 主标题字体颜色 + FontColor: charts.Color{ + R: 100, + G: 100, + B: 100, + A: 255, + }, + // 副标题字体影响 + SubtextFontColor: charts.Color{ + R: 200, + G: 200, + B: 200, + A: 255, + }, + } +}, +``` + +### 部分属性个性化设置 + +```go +charts.TitleTextOptionFunc("Text", "Subtext"), +func(opt *charts.ChartOption) { + // 修改top的值 + opt.Title.Top = "20" +}, +``` + +## 图例 + +### 常用设置 + +图例组件与图表中的数据一一对应,常用仅设置其名称及左侧的值即可(可选),方式如下: + + +```go +charts.LegendLabelsOptionFunc([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", +}, "50"), +``` + +### 个性化设置 + +```go +func(opt *charts.ChartOption) { + opt.Legend = charts.LegendOption{ + // 图例名称 + Data: []string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, + // 图例左侧位置,可设置为"center","right",数值("20")或百份比("20%") + // 如果示例有多行,只影响第一行,而且对于多行的示例,设置"center", "right"无效 + Left: "50", + // 图例顶部位置,只可调为数值 + Top: "10", + // 图例图标的位置,默认为左侧,只允许左或右 + Align: charts.AlignRight, + // 图例排列方式,默认为水平,只允许水平或垂直 + Orient: charts.OrientVertical, + // 图标类型,提供"rect"与"lineDot"两种类型 + Icon: charts.IconRect, + // 字体大小 + FontSize: 14, + // 字体颜色 + FontColor: charts.Color{ + R: 150, + G: 150, + B: 150, + A: 255, + }, + // 是否展示,如果不需要展示则设置 + // Show: charts.FalseFlag(), + // 图例区域的padding值 + Padding: charts.Box{ + Top: 10, + Left: 10, + }, + } +}, +``` + +### 部分属性个性化设置 + +```go +charts.LegendLabelsOptionFunc([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", +}, "50"), +func(opt *charts.ChartOption) { + opt.Legend.Top = "10" +}, +``` + +## X轴 + +### 常用设置 + +图表中X轴的展示,常用的设置方式是指定数组即可: + +```go +charts.XAxisDataOptionFunc([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", +}), +``` + +### 个性化设置 + +```go +func(opt *charts.ChartOption) { + opt.XAxis = charts.XAxisOption{ + // X轴内容 + Data: []string{ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + }, + // 如果数据点不居中,则设置为false + BoundaryGap: charts.FalseFlag(), + // 字体大小 + FontSize: 14, + // 是否展示,如果不需要展示则设置 + // Show: charts.FalseFlag(), + // 会根据文本内容以及此值选择适合的分块大小,一般不需要设置 + // SplitNumber: 3, + // 线条颜色 + StrokeColor: charts.Color{ + R: 200, + G: 200, + B: 200, + A: 255, + }, + // 文字颜色 + FontColor: charts.Color{ + R: 100, + G: 100, + B: 100, + A: 255, + }, + } +}, +``` + +### 部分属性个性化设置 + +```go +charts.XAxisDataOptionFunc([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", +}), +func(opt *charts.ChartOption) { + opt.XAxis.FontColor = charts.Color{ + R: 100, + G: 100, + B: 100, + A: 255, + }, +}, +``` + +## Y轴 + +图表中的y轴展示的相关数据会根据图表中的数据自动生成适合的值,如果需要自定义,则可自定义以下部分数据: + +```go +func(opt *charts.ChartOption) { + opt.YAxisOptions = []charts.YAxisOption{ + { + // 字体大小 + FontSize: 16, + // 字体颜色 + FontColor: charts.Color{ + R: 100, + G: 100, + B: 100, + A: 255, + }, + // 内容,{value}会替换为对应的值 + Formatter: "{value} ml", + // Y轴颜色,如果设置此值,会覆盖font color + Color: charts.Color{ + R: 255, + G: 0, + B: 0, + A: 255, + }, + }, + } +}, +``` diff --git a/table.go b/table.go index 9cfc6b1..3e6f273 100644 --- a/table.go +++ b/table.go @@ -25,121 +25,414 @@ package charts import ( "errors" - "github.com/wcharczuk/go-chart/v2" + "github.com/golang/freetype/truetype" + "git.smarteching.com/zeni/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) -type TableOption struct { - // draw - Draw *Draw +type tableChart struct { + p *Painter + opt *TableChartOption +} + +// NewTableChart returns a table chart render +func NewTableChart(p *Painter, opt TableChartOption) *tableChart { + if opt.Theme == nil { + opt.Theme = defaultTheme + } + return &tableChart{ + p: p, + opt: &opt, + } +} + +type TableCell struct { + // Text the text of table cell + Text string + // Style the current style of table cell + Style Style + // Row the row index of table cell + Row int + // Column the column index of table cell + Column int +} + +type TableChartOption struct { + // The output type + Type string // The width of table Width int - // The header of table + // The theme + Theme ColorPalette + // The padding of table cell + Padding Box + // The header data of table Header []string - // The style of table - Style chart.Style - ColumnWidths []float64 - // 是否仅测量高度 - measurement bool + // The data of table + Data [][]string + // The span list of table column + Spans []int + // The text align list of table cell + TextAligns []string + // The font size of table + FontSize float64 + // The font family, which should be installed first + FontFamily string + Font *truetype.Font + // The font color of table + FontColor Color + // The background color of header + HeaderBackgroundColor Color + // The header font color + HeaderFontColor Color + // The background color of row + RowBackgroundColors []Color + // The background color + BackgroundColor Color + // CellTextStyle customize text style of table cell + CellTextStyle func(TableCell) *Style + // CellStyle customize drawing style of table cell + CellStyle func(TableCell) *Style } -var ErrTableColumnWidthInvalid = errors.New("table column width is invalid") - -func tableDivideWidth(width, size int, columnWidths []float64) ([]int, error) { - widths := make([]int, size) - - autoFillCount := size - restWidth := width - if len(columnWidths) != 0 { - for index, v := range columnWidths { - if v <= 0 { - continue - } - autoFillCount-- - // 小于1的表示占比 - if v < 1 { - widths[index] = int(v * float64(width)) - } else { - widths[index] = int(v) - } - restWidth -= widths[index] - } - } - if restWidth < 0 { - return nil, ErrTableColumnWidthInvalid - } - // 填充其它未指定的宽度 - if autoFillCount > 0 { - autoWidth := restWidth / autoFillCount - for index, v := range widths { - if v == 0 { - widths[index] = autoWidth - } - } - } - widthSum := 0 - for _, v := range widths { - widthSum += v - } - if widthSum > width { - return nil, ErrTableColumnWidthInvalid - } - return widths, nil +type TableSetting struct { + // The color of header + HeaderColor Color + // The color of heder text + HeaderFontColor Color + // The color of table text + FontColor Color + // The color list of row + RowColors []Color + // The padding of cell + Padding Box } -func TableMeasure(opt TableOption) (chart.Box, error) { - d, err := NewDraw(DrawOption{ - Width: opt.Width, - Height: 600, - }) - if err != nil { - return chart.BoxZero, err - } - opt.Draw = d - opt.measurement = true - return tableRender(opt) +var TableLightThemeSetting = TableSetting{ + HeaderColor: Color{ + R: 240, + G: 240, + B: 240, + A: 255, + }, + HeaderFontColor: Color{ + R: 98, + G: 105, + B: 118, + A: 255, + }, + FontColor: Color{ + R: 70, + G: 70, + B: 70, + A: 255, + }, + RowColors: []Color{ + drawing.ColorWhite, + { + R: 247, + G: 247, + B: 247, + A: 255, + }, + }, + Padding: Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, } -func tableRender(opt TableOption) (chart.Box, error) { - if opt.Draw == nil { - return chart.BoxZero, errors.New("draw can not be nil") +var TableDarkThemeSetting = TableSetting{ + HeaderColor: Color{ + R: 38, + G: 38, + B: 42, + A: 255, + }, + HeaderFontColor: Color{ + R: 216, + G: 217, + B: 218, + A: 255, + }, + FontColor: Color{ + R: 216, + G: 217, + B: 218, + A: 255, + }, + RowColors: []Color{ + { + R: 24, + G: 24, + B: 28, + A: 255, + }, + { + R: 38, + G: 38, + B: 42, + A: 255, + }, + }, + Padding: Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, +} + +var tableDefaultSetting = TableLightThemeSetting + +// SetDefaultTableSetting sets the default setting for table +func SetDefaultTableSetting(setting TableSetting) { + tableDefaultSetting = setting +} + +type renderInfo struct { + Width int + Height int + HeaderHeight int + RowHeights []int + ColumnWidths []int +} + +func (t *tableChart) render() (*renderInfo, error) { + info := renderInfo{ + RowHeights: make([]int, 0), } + p := t.p + opt := t.opt if len(opt.Header) == 0 { - return chart.BoxZero, errors.New("header can not be nil") + return nil, errors.New("header can not be nil") } - width := opt.Width - if width == 0 { - width = opt.Draw.Box.Width() + theme := opt.Theme + if theme == nil { + theme = p.theme + } + fontSize := opt.FontSize + if fontSize == 0 { + fontSize = 12 + } + fontColor := opt.FontColor + if fontColor.IsZero() { + fontColor = tableDefaultSetting.FontColor + } + font := opt.Font + if font == nil { + font = theme.GetFont() + } + headerFontColor := opt.HeaderFontColor + if opt.HeaderFontColor.IsZero() { + headerFontColor = tableDefaultSetting.HeaderFontColor } - columnWidths, err := tableDivideWidth(width, len(opt.Header), opt.ColumnWidths) + spans := opt.Spans + if len(spans) != len(opt.Header) { + newSpans := make([]int, len(opt.Header)) + for index := range opt.Header { + if index >= len(spans) { + newSpans[index] = 1 + } else { + newSpans[index] = spans[index] + } + } + spans = newSpans + } + + sum := sumInt(spans) + values := autoDivideSpans(p.Width(), sum, spans) + columnWidths := make([]int, 0) + for index, v := range values { + if index == len(values)-1 { + break + } + columnWidths = append(columnWidths, values[index+1]-v) + } + info.ColumnWidths = columnWidths + + height := 0 + textStyle := Style{ + FontSize: fontSize, + FontColor: headerFontColor, + FillColor: headerFontColor, + Font: font, + } + + headerHeight := 0 + padding := opt.Padding + if padding.IsZero() { + padding = tableDefaultSetting.Padding + } + getCellTextStyle := opt.CellTextStyle + if getCellTextStyle == nil { + getCellTextStyle = func(_ TableCell) *Style { + return nil + } + } + // textAligns := opt.TextAligns + getTextAlign := func(index int) string { + if len(opt.TextAligns) <= index { + return "" + } + return opt.TextAligns[index] + } + + // 表格单元的处理 + renderTableCells := func( + currentStyle Style, + rowIndex int, + textList []string, + currentHeight int, + cellPadding Box, + ) int { + cellMaxHeight := 0 + paddingHeight := cellPadding.Top + cellPadding.Bottom + paddingWidth := cellPadding.Left + cellPadding.Right + for index, text := range textList { + cellStyle := getCellTextStyle(TableCell{ + Text: text, + Row: rowIndex, + Column: index, + Style: currentStyle, + }) + if cellStyle == nil { + cellStyle = ¤tStyle + } + p.SetStyle(*cellStyle) + x := values[index] + y := currentHeight + cellPadding.Top + width := values[index+1] - x + x += cellPadding.Left + width -= paddingWidth + box := p.TextFit(text, x, y+int(fontSize), width, getTextAlign(index)) + // 计算最高的高度 + if box.Height()+paddingHeight > cellMaxHeight { + cellMaxHeight = box.Height() + paddingHeight + } + } + return cellMaxHeight + } + + // 表头的处理 + headerHeight = renderTableCells(textStyle, 0, opt.Header, height, padding) + height += headerHeight + info.HeaderHeight = headerHeight + + // 表格内容的处理 + textStyle.FontColor = fontColor + textStyle.FillColor = fontColor + for index, textList := range opt.Data { + cellHeight := renderTableCells(textStyle, index+1, textList, height, padding) + info.RowHeights = append(info.RowHeights, cellHeight) + height += cellHeight + } + + info.Width = p.Width() + info.Height = height + return &info, nil +} + +func (t *tableChart) renderWithInfo(info *renderInfo) (Box, error) { + p := t.p + opt := t.opt + if !opt.BackgroundColor.IsZero() { + p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor) + } + headerBGColor := opt.HeaderBackgroundColor + if headerBGColor.IsZero() { + headerBGColor = tableDefaultSetting.HeaderColor + } + + // 如果设置表头背景色 + p.SetBackground(info.Width, info.HeaderHeight, headerBGColor, true) + currentHeight := info.HeaderHeight + rowColors := opt.RowBackgroundColors + if rowColors == nil { + rowColors = tableDefaultSetting.RowColors + } + for index, h := range info.RowHeights { + color := rowColors[index%len(rowColors)] + child := p.Child(PainterPaddingOption(Box{ + Top: currentHeight, + })) + child.SetBackground(p.Width(), h, color, true) + currentHeight += h + } + // 根据是否有设置表格样式调整背景色 + getCellStyle := opt.CellStyle + if getCellStyle != nil { + arr := [][]string{ + opt.Header, + } + arr = append(arr, opt.Data...) + top := 0 + heights := []int{ + info.HeaderHeight, + } + heights = append(heights, info.RowHeights...) + // 循环所有表格单元,生成背景色 + for i, textList := range arr { + left := 0 + for j, v := range textList { + style := getCellStyle(TableCell{ + Text: v, + Row: i, + Column: j, + }) + if style != nil && !style.FillColor.IsZero() { + padding := style.Padding + child := p.Child(PainterPaddingOption(Box{ + Top: top + padding.Top, + Left: left + padding.Left, + })) + w := info.ColumnWidths[j] - padding.Left - padding.Top + h := heights[i] - padding.Top - padding.Bottom + child.SetBackground(w, h, style.FillColor, true) + } + left += info.ColumnWidths[j] + } + top += heights[i] + } + } + _, err := t.render() if err != nil { - return chart.BoxZero, err + return BoxZero, err } - d := opt.Draw - style := opt.Style - y := 0 - x := 0 - - headerMaxHeight := 0 - for index, text := range opt.Header { - var box chart.Box - w := columnWidths[index] - y0 := y + int(opt.Style.FontSize) - if opt.measurement { - box = d.measureTextFit(text, x, y0, w, style) - } else { - box = d.textFit(text, x, y0, w, style) - } - if box.Height() > headerMaxHeight { - headerMaxHeight = box.Height() - } - x += w - } - y += headerMaxHeight - - return chart.Box{ - Right: width, - Bottom: y, + return Box{ + Right: info.Width, + Bottom: info.Height, }, nil } + +func (t *tableChart) Render() (Box, error) { + p := t.p + opt := t.opt + if !opt.BackgroundColor.IsZero() { + p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor) + } + if opt.Font == nil && opt.FontFamily != "" { + opt.Font, _ = GetFont(opt.FontFamily) + } + + r := p.render + fn := chart.PNG + if p.outputType == ChartOutputSVG { + fn = chart.SVG + } + newRender, err := fn(p.Width(), 100) + if err != nil { + return BoxZero, err + } + p.render = newRender + info, err := t.render() + if err != nil { + return BoxZero, err + } + p.render = r + return t.renderWithInfo(info) +} diff --git a/table_test.go b/table_test.go new file mode 100644 index 0000000..a958c95 --- /dev/null +++ b/table_test.go @@ -0,0 +1,140 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTableChart(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + _, err := NewTableChart(p, TableChartOption{ + Header: []string{ + "Name", + "Age", + "Address", + "Tag", + "Action", + }, + Spans: []int{ + 1, + 1, + 2, + 1, + // span和header不匹配,最后自动设置为1 + // 1, + }, + Data: [][]string{ + { + "John Brown", + "32", + "New York No. 1 Lake Park", + "nice, developer", + "Send Mail", + }, + { + "Jim Green ", + "42", + "London No. 1 Lake Park", + "wow", + "Send Mail", + }, + { + "Joe Black ", + "32", + "Sidney No. 1 Lake Park", + "cool, teacher", + "Send Mail", + }, + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nNameAgeAddressTagActionJohnBrown32New York No. 1 Lake Parknice,developerSend MailJim Green42London No. 1 Lake ParkwowSend MailJoe Black32Sidney No. 1 Lake Parkcool,teacherSend Mail", + }, + { + render: func(p *Painter) ([]byte, error) { + _, err := NewTableChart(p, TableChartOption{ + Header: []string{ + "Name", + "Age", + "Address", + "Tag", + "Action", + }, + Data: [][]string{ + { + "John Brown", + "32", + "New York No. 1 Lake Park", + "nice, developer", + "Send Mail", + }, + { + "Jim Green ", + "42", + "London No. 1 Lake Park", + "wow", + "Send Mail", + }, + { + "Joe Black ", + "32", + "Sidney No. 1 Lake Park", + "cool, teacher", + "Send Mail", + }, + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nNameAgeAddressTagActionJohn Brown32New York No.1 Lake Parknice,developerSend MailJim Green42London No. 1Lake ParkwowSend MailJoe Black32Sidney No. 1Lake Parkcool, teacherSend Mail", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/theme.go b/theme.go index e3f9773..85016a5 100644 --- a/theme.go +++ b/theme.go @@ -23,7 +23,8 @@ package charts import ( - "github.com/wcharczuk/go-chart/v2/drawing" + "github.com/golang/freetype/truetype" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) const ThemeDark = "dark" @@ -31,23 +32,65 @@ const ThemeLight = "light" const ThemeGrafana = "grafana" const ThemeAnt = "ant" -type Theme struct { - palette *themeColorPalette +type ColorPalette interface { + IsDark() bool + GetAxisStrokeColor() Color + SetAxisStrokeColor(Color) + GetAxisSplitLineColor() Color + SetAxisSplitLineColor(Color) + GetSeriesColor(int) Color + SetSeriesColor([]Color) + GetBackgroundColor() Color + SetBackgroundColor(Color) + GetTextColor() Color + SetTextColor(Color) + GetFontSize() float64 + SetFontSize(float64) + GetFont() *truetype.Font + SetFont(*truetype.Font) } type themeColorPalette struct { isDarkMode bool - axisStrokeColor drawing.Color - axisSplitLineColor drawing.Color - backgroundColor drawing.Color - textColor drawing.Color - seriesColors []drawing.Color + axisStrokeColor Color + axisSplitLineColor Color + backgroundColor Color + textColor Color + seriesColors []Color + fontSize float64 + font *truetype.Font +} + +type ThemeOption struct { + IsDarkMode bool + AxisStrokeColor Color + AxisSplitLineColor Color + BackgroundColor Color + TextColor Color + SeriesColors []Color } var palettes = map[string]*themeColorPalette{} +const defaultFontSize = 12.0 + +var defaultTheme ColorPalette + +var defaultLightFontColor = drawing.Color{ + R: 70, + G: 70, + B: 70, + A: 255, +} +var defaultDarkFontColor = drawing.Color{ + R: 238, + G: 238, + B: 238, + A: 255, +} + func init() { - echartSeriesColors := []drawing.Color{ + echartSeriesColors := []Color{ parseColor("#5470c6"), parseColor("#91cc75"), parseColor("#fac858"), @@ -58,7 +101,7 @@ func init() { parseColor("#9a60b4"), parseColor("#ea7ccc"), } - grafanaSeriesColors := []drawing.Color{ + grafanaSeriesColors := []Color{ parseColor("#7EB26D"), parseColor("#EAB839"), parseColor("#6ED0E0"), @@ -68,7 +111,7 @@ func init() { parseColor("#705DA0"), parseColor("#508642"), } - antSeriesColors := []drawing.Color{ + antSeriesColors := []Color{ parseColor("#5b8ff9"), parseColor("#5ad8a6"), parseColor("#5d7092"), @@ -80,155 +123,210 @@ func init() { } AddTheme( ThemeDark, - true, - drawing.Color{ - R: 185, - G: 184, - B: 206, - A: 255, + ThemeOption{ + IsDarkMode: true, + AxisStrokeColor: Color{ + R: 185, + G: 184, + B: 206, + A: 255, + }, + AxisSplitLineColor: Color{ + R: 72, + G: 71, + B: 83, + A: 255, + }, + BackgroundColor: Color{ + R: 16, + G: 12, + B: 42, + A: 255, + }, + TextColor: Color{ + R: 238, + G: 238, + B: 238, + A: 255, + }, + SeriesColors: echartSeriesColors, }, - drawing.Color{ - R: 72, - G: 71, - B: 83, - A: 255, - }, - drawing.Color{ - R: 16, - G: 12, - B: 42, - A: 255, - }, - drawing.Color{ - R: 238, - G: 238, - B: 238, - A: 255, - }, - echartSeriesColors, ) AddTheme( ThemeLight, - false, - drawing.Color{ - R: 110, - G: 112, - B: 121, - A: 255, + ThemeOption{ + IsDarkMode: false, + AxisStrokeColor: Color{ + R: 110, + G: 112, + B: 121, + A: 255, + }, + AxisSplitLineColor: Color{ + R: 224, + G: 230, + B: 242, + A: 255, + }, + BackgroundColor: drawing.ColorWhite, + TextColor: Color{ + R: 70, + G: 70, + B: 70, + A: 255, + }, + SeriesColors: echartSeriesColors, }, - drawing.Color{ - R: 224, - G: 230, - B: 242, - A: 255, - }, - drawing.ColorWhite, - drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 255, - }, - echartSeriesColors, ) AddTheme( ThemeAnt, - false, - drawing.Color{ - R: 110, - G: 112, - B: 121, - A: 255, + ThemeOption{ + IsDarkMode: false, + AxisStrokeColor: Color{ + R: 110, + G: 112, + B: 121, + A: 255, + }, + AxisSplitLineColor: Color{ + R: 224, + G: 230, + B: 242, + A: 255, + }, + BackgroundColor: drawing.ColorWhite, + TextColor: drawing.Color{ + R: 70, + G: 70, + B: 70, + A: 255, + }, + SeriesColors: antSeriesColors, }, - drawing.Color{ - R: 224, - G: 230, - B: 242, - A: 255, - }, - drawing.ColorWhite, - drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 255, - }, - antSeriesColors, ) AddTheme( ThemeGrafana, - true, - drawing.Color{ - R: 185, - G: 184, - B: 206, - A: 255, + ThemeOption{ + IsDarkMode: true, + AxisStrokeColor: Color{ + R: 185, + G: 184, + B: 206, + A: 255, + }, + AxisSplitLineColor: Color{ + R: 68, + G: 67, + B: 67, + A: 255, + }, + BackgroundColor: drawing.Color{ + R: 31, + G: 29, + B: 29, + A: 255, + }, + TextColor: Color{ + R: 216, + G: 217, + B: 218, + A: 255, + }, + SeriesColors: grafanaSeriesColors, }, - drawing.Color{ - R: 68, - G: 67, - B: 67, - A: 255, - }, - drawing.Color{ - R: 31, - G: 29, - B: 29, - A: 255, - }, - drawing.Color{ - R: 216, - G: 217, - B: 218, - A: 255, - }, - grafanaSeriesColors, ) + SetDefaultTheme(ThemeLight) } -func AddTheme(name string, isDarkMode bool, axisStrokeColor, axisSplitLineColor, backgroundColor, textColor drawing.Color, seriesColors []drawing.Color) { +// SetDefaultTheme sets default theme +func SetDefaultTheme(name string) { + defaultTheme = NewTheme(name) +} + +func AddTheme(name string, opt ThemeOption) { palettes[name] = &themeColorPalette{ - isDarkMode: isDarkMode, - axisStrokeColor: axisStrokeColor, - axisSplitLineColor: axisSplitLineColor, - backgroundColor: backgroundColor, - textColor: textColor, - seriesColors: seriesColors, + isDarkMode: opt.IsDarkMode, + axisStrokeColor: opt.AxisStrokeColor, + axisSplitLineColor: opt.AxisSplitLineColor, + backgroundColor: opt.BackgroundColor, + textColor: opt.TextColor, + seriesColors: opt.SeriesColors, } } -func NewTheme(name string) *Theme { +func NewTheme(name string) ColorPalette { p, ok := palettes[name] if !ok { p = palettes[ThemeLight] } - return &Theme{ - palette: p, - } + clone := *p + return &clone } -func (t *Theme) IsDark() bool { - return t.palette.isDarkMode +func (t *themeColorPalette) IsDark() bool { + return t.isDarkMode } -func (t *Theme) GetAxisStrokeColor() drawing.Color { - return t.palette.axisStrokeColor +func (t *themeColorPalette) GetAxisStrokeColor() Color { + return t.axisStrokeColor } -func (t *Theme) GetAxisSplitLineColor() drawing.Color { - return t.palette.axisSplitLineColor +func (t *themeColorPalette) SetAxisStrokeColor(c Color) { + t.axisStrokeColor = c } -func (t *Theme) GetSeriesColor(index int) drawing.Color { - colors := t.palette.seriesColors +func (t *themeColorPalette) GetAxisSplitLineColor() Color { + return t.axisSplitLineColor +} + +func (t *themeColorPalette) SetAxisSplitLineColor(c Color) { + t.axisSplitLineColor = c +} + +func (t *themeColorPalette) GetSeriesColor(index int) Color { + colors := t.seriesColors return colors[index%len(colors)] } - -func (t *Theme) GetBackgroundColor() drawing.Color { - return t.palette.backgroundColor +func (t *themeColorPalette) SetSeriesColor(colors []Color) { + t.seriesColors = colors } -func (t *Theme) GetTextColor() drawing.Color { - return t.palette.textColor +func (t *themeColorPalette) GetBackgroundColor() Color { + return t.backgroundColor +} + +func (t *themeColorPalette) SetBackgroundColor(c Color) { + t.backgroundColor = c +} + +func (t *themeColorPalette) GetTextColor() Color { + return t.textColor +} + +func (t *themeColorPalette) SetTextColor(c Color) { + t.textColor = c +} + +func (t *themeColorPalette) GetFontSize() float64 { + if t.fontSize != 0 { + return t.fontSize + } + return defaultFontSize +} + +func (t *themeColorPalette) SetFontSize(fontSize float64) { + t.fontSize = fontSize +} + +func (t *themeColorPalette) GetFont() *truetype.Font { + if t.font != nil { + return t.font + } + f, _ := GetDefaultFont() + return f +} + +func (t *themeColorPalette) SetFont(f *truetype.Font) { + t.font = f } diff --git a/theme_test.go b/theme_test.go deleted file mode 100644 index bf22afd..0000000 --- a/theme_test.go +++ /dev/null @@ -1,87 +0,0 @@ -// 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 ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -func TestTheme(t *testing.T) { - assert := assert.New(t) - - darkTheme := NewTheme(ThemeDark) - lightTheme := NewTheme(ThemeLight) - - assert.True(darkTheme.IsDark()) - assert.False(lightTheme.IsDark()) - - assert.Equal(drawing.Color{ - R: 185, - G: 184, - B: 206, - A: 255, - }, darkTheme.GetAxisStrokeColor()) - assert.Equal(drawing.Color{ - R: 110, - G: 112, - B: 121, - A: 255, - }, lightTheme.GetAxisStrokeColor()) - - assert.Equal(drawing.Color{ - R: 72, - G: 71, - B: 83, - A: 255, - }, darkTheme.GetAxisSplitLineColor()) - assert.Equal(drawing.Color{ - R: 224, - G: 230, - B: 242, - A: 255, - }, lightTheme.GetAxisSplitLineColor()) - - assert.Equal(drawing.Color{ - R: 16, - G: 12, - B: 42, - A: 255, - }, darkTheme.GetBackgroundColor()) - assert.Equal(drawing.ColorWhite, lightTheme.GetBackgroundColor()) - - assert.Equal(drawing.Color{ - R: 238, - G: 238, - B: 238, - A: 255, - }, darkTheme.GetTextColor()) - assert.Equal(drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 255, - }, lightTheme.GetTextColor()) -} diff --git a/title.go b/title.go index 07a2eef..74ab4f9 100644 --- a/title.go +++ b/title.go @@ -26,18 +26,16 @@ import ( "strconv" "strings" - "github.com/wcharczuk/go-chart/v2" + "github.com/golang/freetype/truetype" ) type TitleOption struct { + // The theme of chart + Theme ColorPalette // Title text, support \n for new line Text string // Subtitle text, support \n for new line Subtext string - // Title style - Style chart.Style - // Subtitle style - SubtextStyle chart.Style // Distance between title component and the left side of the container. // It can be pixel value: 20, percentage value: 20%, // or position value: right, center. @@ -45,12 +43,23 @@ type TitleOption struct { // Distance between title component and the top side of the container. // It can be pixel value: 20. Top string + // The font of label + Font *truetype.Font + // The font size of label + FontSize float64 + // The color of label + FontColor Color + // The subtext font size of label + SubtextFontSize float64 + // The subtext font color of label + SubtextFontColor Color } + type titleMeasureOption struct { width int height int text string - style chart.Style + style Style } func splitTitleText(text string) []string { @@ -66,44 +75,78 @@ func splitTitleText(text string) []string { return result } -func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) { - if len(opt.Text) == 0 { - return chart.BoxZero, nil - } +type titlePainter struct { + p *Painter + opt *TitleOption +} - padding := opt.Style.Padding - d, err := NewDraw(DrawOption{ - Parent: p, - }, PaddingOption(padding)) - if err != nil { - return chart.BoxZero, err +// NewTitlePainter returns a title renderer +func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter { + return &titlePainter{ + p: p, + opt: &opt, } +} - r := d.Render +func (t *titlePainter) Render() (Box, error) { + opt := t.opt + p := t.p + theme := opt.Theme + + if theme == nil { + theme = p.theme + } + if opt.Text == "" && opt.Subtext == "" { + return BoxZero, nil + } measureOptions := make([]titleMeasureOption, 0) + if opt.Font == nil { + opt.Font = theme.GetFont() + } + if opt.FontColor.IsZero() { + opt.FontColor = theme.GetTextColor() + } + if opt.FontSize == 0 { + opt.FontSize = theme.GetFontSize() + } + if opt.SubtextFontColor.IsZero() { + opt.SubtextFontColor = opt.FontColor + } + if opt.SubtextFontSize == 0 { + opt.SubtextFontSize = opt.FontSize + } + + titleTextStyle := Style{ + Font: opt.Font, + FontSize: opt.FontSize, + FontColor: opt.FontColor, + } // 主标题 for _, v := range splitTitleText(opt.Text) { measureOptions = append(measureOptions, titleMeasureOption{ text: v, - style: opt.Style.GetTextOptions(), + style: titleTextStyle, }) } + subtextStyle := Style{ + Font: opt.Font, + FontSize: opt.SubtextFontSize, + FontColor: opt.SubtextFontColor, + } // 副标题 for _, v := range splitTitleText(opt.Subtext) { measureOptions = append(measureOptions, titleMeasureOption{ text: v, - style: opt.SubtextStyle.GetTextOptions(), + style: subtextStyle, }) } - textMaxWidth := 0 textMaxHeight := 0 - width := 0 for index, item := range measureOptions { - item.style.WriteTextOptionsToRenderer(r) - textBox := r.MeasureText(item.text) + p.OverrideTextStyle(item.style) + textBox := p.MeasureText(item.text) w := textBox.Width() h := textBox.Height() @@ -116,18 +159,18 @@ func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) { measureOptions[index].height = h measureOptions[index].width = w } - width = textMaxWidth + width := textMaxWidth + titleX := 0 - b := d.Box switch opt.Left { case PositionRight: - titleX = b.Width() - textMaxWidth + titleX = p.Width() - textMaxWidth case PositionCenter: - titleX = b.Width()>>1 - (textMaxWidth >> 1) + titleX = p.Width()>>1 - (textMaxWidth >> 1) default: if strings.HasSuffix(opt.Left, "%") { value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) - titleX = b.Width() * value / 100 + titleX = p.Width() * value / 100 } else { value, _ := strconv.Atoi(opt.Left) titleX = value @@ -140,16 +183,15 @@ func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) { titleY += value } for _, item := range measureOptions { - item.style.WriteTextOptionsToRenderer(r) + p.OverrideTextStyle(item.style) x := titleX + (textMaxWidth-item.width)>>1 y := titleY + item.height - d.text(item.text, x, y) + p.Text(item.text, x, y) titleY += item.height } - height := titleY + padding.Top + padding.Bottom - box := padding.Clone() - box.Right = box.Left + titleX + width - box.Bottom = box.Top + height - return box, nil + return Box{ + Bottom: titleY, + Right: titleX + width, + }, nil } diff --git a/title_test.go b/title_test.go index 23573c3..add8163 100644 --- a/title_test.go +++ b/title_test.go @@ -26,117 +26,68 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestSplitTitleText(t *testing.T) { +func TestTitleRenderer(t *testing.T) { assert := assert.New(t) - - assert.Equal([]string{ - "a", - "b", - }, splitTitleText("a\nb")) - assert.Equal([]string{ - "a", - }, splitTitleText("a\n ")) -} - -func TestDrawTitle(t *testing.T) { - assert := assert.New(t) - - newOption := func() *TitleOption { - f, _ := chart.GetDefaultFont() - return &TitleOption{ - Text: "title\nHello", - Subtext: "subtitle\nWorld!", - Style: chart.Style{ - FontSize: 14, - Font: f, - FontColor: drawing.ColorBlack, - }, - SubtextStyle: chart.Style{ - FontSize: 10, - Font: f, - FontColor: drawing.ColorBlue, - }, - } - } - newDraw := func() *Draw { - d, _ := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - return d - } - tests := []struct { - newDraw func() *Draw - newOption func() *TitleOption - result string - box chart.Box + render func(*Painter) ([]byte, error) + result string }{ { - newDraw: newDraw, - newOption: newOption, - result: "\\ntitleHellosubtitleWorld!", - box: chart.Box{ - Right: 43, - Bottom: 58, + render: func(p *Painter) ([]byte, error) { + _, err := NewTitlePainter(p, TitleOption{ + Text: "title", + Subtext: "subTitle", + Left: "20", + Top: "20", + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\ntitlesubTitle", }, { - newDraw: newDraw, - newOption: func() *TitleOption { - opt := newOption() - opt.Left = PositionRight - opt.Top = "50" - return opt - }, - result: "\\ntitleHellosubtitleWorld!", - box: chart.Box{ - Right: 400, - Bottom: 108, + render: func(p *Painter) ([]byte, error) { + _, err := NewTitlePainter(p, TitleOption{ + Text: "title", + Subtext: "subTitle", + Left: "20%", + Top: "20", + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\ntitlesubTitle", }, { - newDraw: newDraw, - newOption: func() *TitleOption { - opt := newOption() - opt.Left = PositionCenter - opt.Top = "10" - return opt - }, - result: "\\ntitleHellosubtitleWorld!", - box: chart.Box{ - Right: 222, - Bottom: 68, - }, - }, - { - newDraw: newDraw, - newOption: func() *TitleOption { - opt := newOption() - opt.Left = "10%" - opt.Top = "10" - return opt - }, - result: "\\ntitleHellosubtitleWorld!", - box: chart.Box{ - Right: 83, - Bottom: 68, + render: func(p *Painter) ([]byte, error) { + _, err := NewTitlePainter(p, TitleOption{ + Text: "title", + Subtext: "subTitle", + Left: PositionRight, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() }, + result: "\\ntitlesubTitle", }, } for _, tt := range tests { - d := tt.newDraw() - o := tt.newOption() - b, err := drawTitle(d, o) + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) assert.Nil(err) - assert.Equal(tt.box, b) - data, err := d.Bytes() + data, err := tt.render(p) assert.Nil(err) - assert.NotEmpty(data) assert.Equal(tt.result, string(data)) } } diff --git a/util.go b/util.go index c895cc3..87ff31c 100644 --- a/util.go +++ b/util.go @@ -29,8 +29,8 @@ import ( "strings" "github.com/dustin/go-humanize" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) func TrueFlag() *bool { @@ -43,6 +43,24 @@ func FalseFlag() *bool { return &f } +func containsInt(values []int, value int) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + +func containsString(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + func ceilFloatToInt(value float64) int { i := int(value) if value == float64(i) { @@ -59,28 +77,49 @@ func getDefaultInt(value, defaultValue int) int { } func autoDivide(max, size int) []int { - unit := max / size + unit := float64(max) / float64(size) - rest := max - unit*size values := make([]int, size+1) - value := 0 - for i := 0; i < size; i++ { - values[i] = value - if i < rest { - value++ + for i := 0; i < size+1; i++ { + if i == size { + values[i] = max + } else { + values[i] = int(float64(i) * unit) } - value += unit } - values[size] = max return values } +func autoDivideSpans(max, size int, spans []int) []int { + values := autoDivide(max, size) + // 重新合并 + if len(spans) != 0 { + newValues := make([]int, len(spans)+1) + newValues[0] = 0 + end := 0 + for index, v := range spans { + end += v + newValues[index+1] = values[end] + } + values = newValues + } + return values +} + +func sumInt(values []int) int { + sum := 0 + for _, v := range values { + sum += v + } + return sum +} + // measureTextMaxWidthHeight returns maxWidth and maxHeight of text list -func measureTextMaxWidthHeight(textList []string, r chart.Renderer) (int, int) { +func measureTextMaxWidthHeight(textList []string, p *Painter) (int, int) { maxWidth := 0 maxHeight := 0 for _, text := range textList { - box := r.MeasureText(text) + box := p.MeasureText(text) maxWidth = chart.MaxInt(maxWidth, box.Width()) maxHeight = chart.MaxInt(maxHeight, box.Height()) } @@ -121,21 +160,31 @@ func NewFloatPoint(f float64) *float64 { v := f return &v } + +const K_VALUE = float64(1000) +const M_VALUE = K_VALUE * K_VALUE +const G_VALUE = M_VALUE * K_VALUE +const T_VALUE = G_VALUE * K_VALUE + func commafWithDigits(value float64) string { decimals := 2 - m := float64(1000 * 1000) - if value >= m { - return humanize.CommafWithDigits(value/m, decimals) + "M" + if value >= T_VALUE { + return humanize.CommafWithDigits(value/T_VALUE, decimals) + "T" } - k := float64(1000) - if value >= k { - return humanize.CommafWithDigits(value/k, decimals) + "k" + if value >= G_VALUE { + return humanize.CommafWithDigits(value/G_VALUE, decimals) + "G" + } + if value >= M_VALUE { + return humanize.CommafWithDigits(value/M_VALUE, decimals) + "M" + } + if value >= K_VALUE { + return humanize.CommafWithDigits(value/K_VALUE, decimals) + "k" } return humanize.CommafWithDigits(value, decimals) } -func parseColor(color string) drawing.Color { - c := drawing.Color{} +func parseColor(color string) Color { + c := Color{} if color == "" { return c } @@ -213,3 +262,10 @@ func getPolygonPoints(center Point, radius float64, sides int) []Point { } return points } + +func isLightColor(c Color) bool { + r := float64(c.R) * float64(c.R) * 0.299 + g := float64(c.G) * float64(c.G) * 0.587 + b := float64(c.B) * float64(c.B) * 0.114 + return math.Sqrt(r+g+b) > 127.5 +} diff --git a/util_test.go b/util_test.go index 6489ab3..5770776 100644 --- a/util_test.go +++ b/util_test.go @@ -26,8 +26,8 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + "git.smarteching.com/zeni/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2/drawing" ) func TestGetDefaultInt(t *testing.T) { @@ -60,12 +60,12 @@ func TestAutoDivide(t *testing.T) { assert.Equal([]int{ 0, - 86, - 172, - 258, - 344, - 430, - 515, + 85, + 171, + 257, + 342, + 428, + 514, 600, }, autoDivide(600, 7)) } @@ -80,13 +80,15 @@ func TestGetRadius(t *testing.T) { func TestMeasureTextMaxWidthHeight(t *testing.T) { assert := assert.New(t) - r, err := chart.SVG(400, 300) + p, err := NewPainter(PainterOptions{ + Width: 400, + Height: 300, + }) assert.Nil(err) style := chart.Style{ FontSize: 10, } - style.Font, _ = chart.GetDefaultFont() - style.WriteToRenderer(r) + p.SetStyle(style) maxWidth, maxHeight := measureTextMaxWidthHeight([]string{ "Mon", @@ -96,8 +98,8 @@ func TestMeasureTextMaxWidthHeight(t *testing.T) { "Fri", "Sat", "Sun", - }, r) - assert.Equal(26, maxWidth) + }, p) + assert.Equal(31, maxWidth) assert.Equal(12, maxHeight) } @@ -187,3 +189,35 @@ func TestParseColor(t *testing.T) { A: 250, }, c) } + +func TestIsLightColor(t *testing.T) { + assert := assert.New(t) + + assert.True(isLightColor(drawing.Color{ + R: 255, + G: 255, + B: 255, + })) + assert.True(isLightColor(drawing.Color{ + R: 145, + G: 204, + B: 117, + })) + + assert.False(isLightColor(drawing.Color{ + R: 88, + G: 112, + B: 198, + })) + + assert.False(isLightColor(drawing.Color{ + R: 0, + G: 0, + B: 0, + })) + assert.False(isLightColor(drawing.Color{ + R: 16, + G: 12, + B: 42, + })) +} diff --git a/xaxis.go b/xaxis.go index edd017f..61698d7 100644 --- a/xaxis.go +++ b/xaxis.go @@ -24,10 +24,10 @@ package charts import ( "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" ) type XAxisOption struct { + // The font of x axis Font *truetype.Font // The boundary gap on both sides of a coordinate axis. // Nil or *true means the center part of two axis ticks @@ -35,13 +35,31 @@ type XAxisOption struct { // The data value of x axis Data []string // The theme of chart - Theme string - // Hidden x axis - Hidden bool + Theme ColorPalette + // The font size of x axis label + FontSize float64 + // 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' + Position string + // The line color of axis + StrokeColor Color + // The color of label + FontColor Color + // The text rotation of label + TextRotation float64 + // The first axis + FirstAxis int + // The offset of label + LabelOffset Box + isValueAxis bool } +const defaultXAxisHeight = 30 + +// NewXAxisOption returns a x axis option func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption { opt := XAxisOption{ Data: data, @@ -52,51 +70,36 @@ func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption { return opt } -// drawXAxis draws x axis, and returns the height, range of if. -func drawXAxis(p *Draw, opt *XAxisOption, yAxisCount int) (int, *Range, error) { - if opt.Hidden { - return 0, nil, nil +func (opt *XAxisOption) ToAxisOption() AxisOption { + position := PositionBottom + if opt.Position == PositionTop { + position = PositionTop } - left := YAxisWidth - right := (yAxisCount - 1) * YAxisWidth - dXAxis, err := NewDraw( - DrawOption{ - Parent: p, - }, - PaddingOption(chart.Box{ - Left: left, - Right: right, - }), - ) - if opt.Font != nil { - dXAxis.Font = opt.Font + axisOpt := AxisOption{ + Theme: opt.Theme, + Data: opt.Data, + BoundaryGap: opt.BoundaryGap, + Position: position, + SplitNumber: opt.SplitNumber, + StrokeColor: opt.StrokeColor, + FontSize: opt.FontSize, + Font: opt.Font, + FontColor: opt.FontColor, + Show: opt.Show, + SplitLineColor: opt.Theme.GetAxisSplitLineColor(), + TextRotation: opt.TextRotation, + LabelOffset: opt.LabelOffset, + FirstAxis: opt.FirstAxis, } - if err != nil { - return 0, nil, err + if opt.isValueAxis { + axisOpt.SplitLineShow = true + axisOpt.StrokeWidth = -1 + axisOpt.BoundaryGap = FalseFlag() } - theme := NewTheme(opt.Theme) - data := NewAxisDataListFromStringList(opt.Data) - style := AxisOption{ - BoundaryGap: opt.BoundaryGap, - StrokeColor: theme.GetAxisStrokeColor(), - FontColor: theme.GetAxisStrokeColor(), - StrokeWidth: 1, - SplitNumber: opt.SplitNumber, - } - - boundary := true - max := float64(len(opt.Data)) - if isFalse(opt.BoundaryGap) { - boundary = false - max-- - } - axis := NewAxis(dXAxis, data, style) - axis.Render() - return axis.measure().Height, &Range{ - divideCount: len(opt.Data), - Min: 0, - Max: max, - Size: dXAxis.Box.Width(), - Boundary: boundary, - }, nil + return axisOpt +} + +// NewBottomXAxis returns a bottom x axis renderer +func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter { + return NewAxisPainter(p, opt.ToAxisOption()) } diff --git a/xaxis_test.go b/xaxis_test.go deleted file mode 100644 index 267cdb1..0000000 --- a/xaxis_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// 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 ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewXAxisOption(t *testing.T) { - assert := assert.New(t) - - opt := NewXAxisOption([]string{ - "a", - "b", - }, FalseFlag()) - - assert.Equal(XAxisOption{ - Data: []string{ - "a", - "b", - }, - BoundaryGap: FalseFlag(), - }, opt) - -} -func TestDrawXAxis(t *testing.T) { - assert := assert.New(t) - - newDraw := func() *Draw { - d, _ := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - return d - } - - tests := []struct { - newDraw func() *Draw - newOption func() *XAxisOption - result string - }{ - { - newDraw: newDraw, - newOption: func() *XAxisOption { - return &XAxisOption{ - BoundaryGap: FalseFlag(), - Data: []string{ - "Mon", - "Tue", - }, - } - }, - result: "\\nMonTue", - }, - { - newDraw: newDraw, - newOption: func() *XAxisOption { - return &XAxisOption{ - Data: []string{ - "01-01", - "01-02", - "01-03", - "01-04", - "01-05", - "01-06", - "01-07", - "01-08", - "01-09", - }, - SplitNumber: 3, - } - }, - result: "\\n01-0201-0501-08", - }, - } - - for _, tt := range tests { - d := tt.newDraw() - height, _, err := drawXAxis(d, tt.newOption(), 1) - assert.Nil(err) - assert.Equal(25, height) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal(tt.result, string(data)) - } -} diff --git a/yaxis.go b/yaxis.go index a14e409..e58b7a6 100644 --- a/yaxis.go +++ b/yaxis.go @@ -22,84 +22,107 @@ package charts -import ( - "strings" - - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) +import "github.com/golang/freetype/truetype" type YAxisOption struct { // The minimun value of axis. Min *float64 // The maximum value of axis. Max *float64 - // Hidden y axis - Hidden bool + // The font of y axis + Font *truetype.Font + // The data value of x axis + Data []string + // The theme of chart + Theme ColorPalette + // The font size of x axis label + FontSize float64 + // The position of axis, it can be 'left' or 'right' + Position string + // The color of label + FontColor Color // Formatter for y axis text value Formatter string // Color for y axis - Color drawing.Color + Color Color + // The flag for show axis, set this to *false will hide axis + Show *bool + DivideCount int + Unit int + isCategoryAxis bool + // The flag for show axis split line, set this to true will show axis split line + SplitLineShow *bool } -// TODO 长度是否可以变化 -const YAxisWidth = 40 - -func drawYAxis(p *Draw, opt *ChartOption, axisIndex, xAxisHeight int, padding chart.Box) (*Range, error) { - theme := NewTheme(opt.Theme) - yRange := opt.newYRange(axisIndex) - values := yRange.Values() - yAxis := opt.YAxisList[axisIndex] - formatter := yAxis.Formatter - if len(formatter) != 0 { - for index, text := range values { - values[index] = strings.ReplaceAll(formatter, "{value}", text) - } +// NewYAxisOptions returns a y axis option +func NewYAxisOptions(data []string, others ...[]string) []YAxisOption { + arr := [][]string{ + data, } + arr = append(arr, others...) + opts := make([]YAxisOption, 0) + for _, data := range arr { + opts = append(opts, YAxisOption{ + Data: data, + }) + } + return opts +} - data := NewAxisDataListFromStringList(values) - style := AxisOption{ - Position: PositionLeft, +func (opt *YAxisOption) ToAxisOption(p *Painter) AxisOption { + position := PositionLeft + if opt.Position == PositionRight { + position = PositionRight + } + theme := opt.Theme + if theme == nil { + theme = p.theme + } + axisOpt := AxisOption{ + Formatter: opt.Formatter, + Theme: theme, + Data: opt.Data, + Position: position, + FontSize: opt.FontSize, + StrokeWidth: -1, + Font: opt.Font, + FontColor: opt.FontColor, BoundaryGap: FalseFlag(), - FontColor: theme.GetAxisStrokeColor(), - TickShow: FalseFlag(), - StrokeWidth: 1, - SplitLineColor: theme.GetAxisSplitLineColor(), SplitLineShow: true, + SplitLineColor: theme.GetAxisSplitLineColor(), + Show: opt.Show, + Unit: opt.Unit, } - if !yAxis.Color.IsZero() { - style.FontColor = yAxis.Color - style.StrokeColor = yAxis.Color + if !opt.Color.IsZero() { + axisOpt.FontColor = opt.Color + axisOpt.StrokeColor = opt.Color } - width := NewAxis(p, data, style).measure().Width - - yAxisCount := len(opt.YAxisList) - boxWidth := p.Box.Width() - if axisIndex > 0 { - style.SplitLineShow = false - style.Position = PositionRight - padding.Right += (axisIndex - 1) * YAxisWidth - } else { - boxWidth = p.Box.Width() - (yAxisCount-1)*YAxisWidth - padding.Left += (YAxisWidth - width) + if opt.isCategoryAxis { + axisOpt.BoundaryGap = TrueFlag() + axisOpt.StrokeWidth = 1 + axisOpt.SplitLineShow = false } - - dYAxis, err := NewDraw( - DrawOption{ - Parent: p, - Width: boxWidth, - // 减去x轴的高 - Height: p.Box.Height() - xAxisHeight, - }, - PaddingOption(padding), - ) - if err != nil { - return nil, err + if opt.SplitLineShow != nil { + axisOpt.SplitLineShow = *opt.SplitLineShow } - if opt.Font != nil { - dYAxis.Font = opt.Font - } - NewAxis(dYAxis, data, style).Render() - yRange.Size = dYAxis.Box.Height() - return &yRange, nil + return axisOpt +} + +// NewLeftYAxis returns a left y axis renderer +func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter { + p = p.Child(PainterPaddingOption(Box{ + Bottom: defaultXAxisHeight, + })) + return NewAxisPainter(p, opt.ToAxisOption(p)) +} + +// NewRightYAxis returns a right y axis renderer +func NewRightYAxis(p *Painter, opt YAxisOption) *axisPainter { + p = p.Child(PainterPaddingOption(Box{ + Bottom: defaultXAxisHeight, + })) + axisOpt := opt.ToAxisOption(p) + axisOpt.Position = PositionRight + axisOpt.SplitLineShow = false + return NewAxisPainter(p, axisOpt) } diff --git a/yaxis_test.go b/yaxis_test.go index 0bbef7a..0f565ac 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -26,93 +26,44 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" ) -func TestDrawYAxis(t *testing.T) { +func TestRightYAxis(t *testing.T) { assert := assert.New(t) - newDraw := func() *Draw { - d, _ := NewDraw(DrawOption{ - Width: 400, - Height: 300, - }) - return d - } - tests := []struct { - newDraw func() *Draw - newOption func() *ChartOption - axisIndex int - xAxisHeight int - result string + render func(*Painter) ([]byte, error) + result string }{ { - newDraw: newDraw, - newOption: func() *ChartOption { - return &ChartOption{ - YAxisList: []YAxisOption{ - { - Max: NewFloatPoint(20), - }, - }, - SeriesList: []Series{ - { - Data: []SeriesData{ - { - Value: 1, - }, - { - Value: 2, - }, - }, - }, - }, + render: func(p *Painter) ([]byte, error) { + opt := NewYAxisOptions([]string{ + "a", + "b", + "c", + "d", + })[0] + _, err := NewRightYAxis(p, opt).Render() + if err != nil { + return nil, err } + return p.Bytes() }, - 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", + result: "\\nabcd", }, } - for _, tt := range tests { - d := tt.newDraw() - r, err := drawYAxis(d, tt.newOption(), tt.axisIndex, tt.xAxisHeight, chart.NewBox(10, 10, 10, 10)) + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme), PainterPaddingOption(Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + })) assert.Nil(err) - assert.Equal(&Range{ - divideCount: 6, - Max: 20, - Size: 280, - }, r) - - data, err := d.Bytes() + data, err := tt.render(p) assert.Nil(err) assert.Equal(tt.result, string(data)) }