diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce56fe7..22e77a8 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 57206ee..2e33342 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,3 @@ *.png *.svg tmp -NotoSansSC.ttf -.vscode \ No newline at end of file diff --git a/README.md b/README.md index 0650395..22d3205 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ # 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`. The default format is `png` and the default theme is `light`. +`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`. `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`. @@ -17,55 +15,59 @@ 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`, `horizontal bar`, `pie`, `radar` or `funnel` and `table`. +These chart types are supported: `line`, `bar`, `pie`, `radar` or `funnel`. ## Example -More examples can be found in the [./examples/](./examples/) directory. +The example is for `golang option` and `echarts option`, more examples can be found in the `./examples/` directory. - -### Line Chart ```go package main import ( - charts "git.smarteching.com/zeni/go-charts/v2" + "os" + "path/filepath" + + charts "github.com/vicanso/go-charts" ) -func main() { - values := [][]float64{ - { - 120, - 132, - 101, - 134, - 90, - 230, - 210, - }, - { - // snip... - }, - { - // snip... - }, - { - // snip... - }, - { - // snip... - }, +func writeFile(file string, buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err } - p, err := charts.LineRender( - values, - charts.TitleTextOptionFunc("Line"), - charts.XAxisDataOptionFunc([]string{ + + 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{ + { + 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", @@ -73,389 +75,16 @@ func main() { "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 { - panic(err) + return nil, err } - - buf, err := p.Bytes() - if err != nil { - panic(err) - } - // snip... + return d.Bytes() } -``` -### 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(`{ +func echartsRender() ([]byte, error) { + return charts.RenderEChartsToPNG(`{ "title": { "text": "Line" }, @@ -468,7 +97,25 @@ func main() { } ] }`) - // snip... +} + +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/README_zh.md b/README_zh.md index 3f35b97..1589923 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`。默认的输入格式为`png`,默认主题为`light`。 +`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持主题`light`, `dark`, `grafana`以及`ant`。 `Apache ECharts`在前端开发中得到众多开发者的认可,因此`go-charts`提供了兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的图表截图(主题为light与grafana): @@ -12,57 +12,62 @@ 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) + } + 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) } } +} - width := 0 - height := 0 - // 垂直 - if isVertical { - width = textMaxWidth + tickLength<<1 - height = top.Height() - } else { - width = top.Width() - height = tickLength<<1 + textMaxHeight - } - padding := Box{} - switch opt.Position { - case PositionTop: - padding.Top = top.Height() - height - case PositionLeft: - padding.Right = top.Width() - width - case PositionRight: - padding.Left = top.Width() - width - default: - padding.Top = top.Height() - defaultXAxisHeight - } - - p := top.Child(PainterPaddingOption(padding)) +func (a *axis) axisLine(renderOpt *axisRenderOption) { + d := a.d + r := d.Render + option := a.option + s := option.Style(d.Font) + s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) x0 := 0 y0 := 0 x1 := 0 y1 := 0 - ticksPaddingTop := 0 - ticksPaddingLeft := 0 - labelPaddingTop := 0 - labelPaddingLeft := 0 - labelPaddingRight := 0 - orient := "" - textAlign := "" + width := d.Box.Width() + height := d.Box.Height() + labelMargin := option.GetLabelMargin() - switch opt.Position { - case PositionTop: - labelPaddingTop = 0 - x1 = p.Width() - y0 = labelMargin + int(opt.FontSize) - ticksPaddingTop = int(opt.FontSize) - y1 = y0 - orient = OrientHorizontal + // 轴线 + labelHeight := labelMargin + renderOpt.textMaxHeight + labelWidth := labelMargin + renderOpt.textMaxWith + tickLength := option.GetTickLength() + switch option.Position { case PositionLeft: - x0 = p.Width() + x0 = tickLength + labelWidth + x1 = x0 y0 = 0 - x1 = p.Width() - y1 = p.Height() - orient = OrientVertical - textAlign = AlignRight - ticksPaddingLeft = textMaxWidth + tickLength - labelPaddingRight = width - textMaxWidth + y1 = height case PositionRight: - orient = OrientVertical - y1 = p.Height() - labelPaddingLeft = width - textMaxWidth + x0 = width - labelWidth + x1 = x0 + y0 = 0 + y1 = height + case PositionTop: + x0 = 0 + x1 = width + y0 = labelHeight + y1 = y0 + // bottom default: - labelPaddingTop = height - x1 = p.Width() - orient = OrientHorizontal + x0 = 0 + x1 = width + y0 = height - tickLength - labelHeight + y1 = y0 } - 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, - }, - }) - } + d.moveTo(x0, y0) + d.lineTo(x1, y1) + r.FillStroke() +} - 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 { +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 { + case PositionLeft: + fallthrough + 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 { x0 = 0 - x1 = top.Width() - p.Width() + splitLineWidth = width - labelWidth - 1 } - 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, - }, - }) + for _, v := range values[0 : len(values)-1] { + x := x0 + y := v + d.moveTo(x, y) + d.lineTo(x+splitLineWidth, y) + r.Stroke() } - } else { - y0 := p.Height() - defaultXAxisHeight - y1 := top.Height() - defaultXAxisHeight - for index, x := range autoDivide(width, tickCount) { - if index == 0 { + } + 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 { continue } - top.LineStroke([]Point{ - { - X: x, - Y: y0, - }, - { - X: x, - Y: y1, - }, - }) + 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() } } } - - return Box{ - Bottom: height, - Right: width, - }, nil +} + +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) } diff --git a/axis_test.go b/axis_test.go index 85e18ca..37c8314 100644 --- a/axis_test.go +++ b/axis_test.go @@ -25,149 +25,235 @@ package charts import ( "testing" + "github.com/golang/freetype/truetype" "github.com/stretchr/testify/assert" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/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 { - render func(*Painter) ([]byte, error) - result string + newOption func() AxisOption + newData func() AxisDataList + result string }{ - // 底部x轴 + // 文本按起始位置展示 + // axis位于bottom { - 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() + newOption: func() AxisOption { + opt := getDefaultOption() + opt.BoundaryGap = FalseFlag() + return opt }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 底部x轴文本居左 + // 文本居中展示 + // axis位于bottom { - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ - Data: []string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", - }, - BoundaryGap: FalseFlag(), - }).Render() - return p.Bytes() + newOption: func() AxisOption { + opt := getDefaultOption() + return opt }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 左侧y轴 + // 文本按起始位置展示 + // axis位于top { - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ - Data: []string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", - }, - Position: PositionLeft, - }).Render() - return p.Bytes() + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionTop + opt.BoundaryGap = FalseFlag() + return opt }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 左侧y轴居中 + // 文本居中展示 + // axis位于top { - 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() + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionTop + return opt }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 右侧 + // 文本按起始位置展示 + // axis位于left { - 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() + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionLeft + opt.BoundaryGap = FalseFlag() + return opt }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, - // 顶部 + // 文本居中展示 + // axis位于left { - 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() + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionLeft + return opt }, - result: "\\nMon --Tue --Wed --Thu --Fri --Sat --Sun --", + 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", }, } - for _, tt := range tests { - p, err := NewPainter(PainterOptions{ - Type: ChartOutputSVG, - Width: 600, - Height: 400, - }, PainterThemeOption(defaultTheme)) + d, err := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }, PaddingOption(chart.Box{ + Left: 5, + Top: 5, + Right: 5, + Bottom: 5, + })) assert.Nil(err) - data, err := tt.render(p) + style := tt.newOption() + data := axisData + if tt.newData != nil { + data = tt.newData() + } + NewAxis(d, data, style).Render() + + result, err := d.Bytes() assert.Nil(err) - assert.Equal(tt.result, string(data)) + assert.Equal(tt.result, string(result)) } } + +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/alias.go b/bar.go similarity index 59% rename from alias.go rename to bar.go index edf0dec..1090f6b 100644 --- a/alias.go +++ b/bar.go @@ -23,51 +23,36 @@ package charts import ( - "git.smarteching.com/zeni/go-chart/v2" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -type Box = chart.Box -type Style = chart.Style -type Color = drawing.Color - -var BoxZero = chart.BoxZero - -type Point struct { - X int - Y int +type BarStyle struct { + ClassName string + StrokeDashArray []float64 + FillColor drawing.Color } -const ( - ChartTypeLine = "line" - ChartTypeBar = "bar" - ChartTypePie = "pie" - ChartTypeRadar = "radar" - ChartTypeFunnel = "funnel" - // horizontal bar - ChartTypeHorizontalBar = "horizontalBar" -) +func (bs *BarStyle) Style() chart.Style { + return chart.Style{ + ClassName: bs.ClassName, + StrokeDashArray: bs.StrokeDashArray, + StrokeColor: bs.FillColor, + StrokeWidth: 1, + FillColor: bs.FillColor, + } +} -const ( - ChartOutputSVG = "svg" - ChartOutputPNG = "png" -) +// Bar renders bar for chart +func (d *Draw) Bar(b chart.Box, style BarStyle) { + s := style.Style() -const ( - PositionLeft = "left" - PositionRight = "right" - PositionCenter = "center" - PositionTop = "top" - PositionBottom = "bottom" -) - -const ( - AlignLeft = "left" - AlignRight = "right" - AlignCenter = "center" -) - -const ( - OrientHorizontal = "horizontal" - OrientVertical = "vertical" -) + r := d.Render + s.GetFillAndStrokeOptions().WriteToRenderer(r) + d.moveTo(b.Left, b.Top) + d.lineTo(b.Right, b.Top) + d.lineTo(b.Right, b.Bottom) + d.lineTo(b.Left, b.Bottom) + d.lineTo(b.Left, b.Top) + d.Render.FillStroke() +} diff --git a/bar_chart.go b/bar_chart.go index 043e044..32373b1 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -23,60 +23,31 @@ package charts import ( - "math" - "github.com/golang/freetype/truetype" - "git.smarteching.com/zeni/go-chart/v2" + "github.com/wcharczuk/go-chart/v2" ) -type barChart struct { - p *Painter - opt *BarChartOption -} - -// NewBarChart returns a bar chart renderer -func NewBarChart(p *Painter, opt BarChartOption) *barChart { - if opt.Theme == nil { - opt.Theme = defaultTheme - } - return &barChart{ - p: p, - opt: &opt, - } -} - -type BarChartOption struct { - // The theme - Theme ColorPalette - // The font size - Font *truetype.Font - // The data series list +type barChartOption struct { + // The series list fo bar chart 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 + // The theme + Theme string + // The font + Font *truetype.Font } -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(), - }) +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 + } + xRange := result.xRange x0, x1 := xRange.GetRange(0) width := int(x1 - x0) // 每一块之间的margin @@ -90,54 +61,50 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B margin = 5 barMargin = 3 } - if opt.BarMargin > 0 { - barMargin = opt.BarMargin - } - seriesCount := len(seriesList) + + seriesCount := len(opt.SeriesList) // 总的宽度-两个margin-(总数-1)的barMargin - 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() + barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / len(opt.SeriesList) - 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) + barMaxHeight := result.getYRange(0).Size + theme := NewTheme(opt.Theme) - divideValues := xRange.AutoDivide() + 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)) - 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) + 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() for j, item := range series.Data { if j >= xRange.divideCount { continue } x := divideValues[j] x += margin - if index != 0 { - x += index * (barWidth + barMargin) + if i != 0 { + x += i * (barWidth + barMargin) } h := int(yRange.getHeight(item.Value)) @@ -146,32 +113,14 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B fillColor = item.Style.FillColor } top := barMaxHeight - h - - 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, - } + d.Bar(chart.Box{ + Top: top, + Left: x, + Right: x + barWidth, + Bottom: barMaxHeight - 1, + }, BarStyle{ + FillColor: fillColor, + }) // 用于生成marker point points[j] = Point{ // 居中的位置 @@ -179,75 +128,36 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B Y: top, } // 如果label不需要展示,则返回 - if labelPainter == nil { + if !series.Label.Show { continue } - 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 - } - } + distance := series.Label.Distance + if distance == 0 { + distance = 5 } - 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, - }) + 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) } - markPointPainter.Add(markPointRenderOption{ + // 生成mark point的参数 + markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{ + Draw: d, FillColor: seriesColor, Font: opt.Font, - Series: series, Points: points, - }) - markLinePainter.Add(markLineRenderOption{ - FillColor: seriesColor, - FontColor: opt.Theme.GetTextColor(), - StrokeColor: seriesColor, - Font: opt.Font, - Series: series, - Range: yRange, + Series: &series, }) } - // 最大、最小的mark point - err := doRender(rendererList...) - if err != nil { - return BoxZero, err - } - 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) + return markPointRenderOptions, nil } diff --git a/bar_chart_test.go b/bar_chart_test.go index 654c320..f10a1bc 100644 --- a/bar_chart_test.go +++ b/bar_chart_test.go @@ -26,165 +26,106 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestBarChart(t *testing.T) { +func TestBarChartRender(t *testing.T) { assert := assert.New(t) - 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", - }, - } + width := 400 + height := 300 + d, err := NewDraw(DrawOption{ + Width: width, + Height: height, + }) + assert.Nil(err) - 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)) + result := basicRenderResult{ + xRange: &Range{ + Min: 0, + Max: 4, + divideCount: 4, + Size: width, + Boundary: true, + }, + yRangeList: []*Range{ + { + divideCount: 6, + Max: 100, + Min: 0, + Size: height, + }, + }, + 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)) } diff --git a/bar_test.go b/bar_test.go new file mode 100644 index 0000000..01b6d3c --- /dev/null +++ b/bar_test.go @@ -0,0 +1,78 @@ +// 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 new file mode 100644 index 0000000..21f2071 --- /dev/null +++ b/chart.go @@ -0,0 +1,502 @@ +// 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 d80a383..5e25873 100644 --- a/chart_option.go +++ b/chart_option.go @@ -23,72 +23,13 @@ package charts import ( - "sort" - - "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -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) @@ -122,16 +63,6 @@ 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) { @@ -139,13 +70,6 @@ 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) { @@ -153,24 +77,10 @@ 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.YAxisOptions = yAxisOption - } -} - -// YAxisDataOptionFunc set y axis data of chart -func YAxisDataOptionFunc(data []string) OptionFunc { - return func(opt *ChartOption) { - opt.YAxisOptions = NewYAxisOptions(data) + opt.YAxisList = yAxisOption } } @@ -189,28 +99,19 @@ func HeightOptionFunc(height int) OptionFunc { } // PaddingOptionFunc set padding of chart -func PaddingOptionFunc(padding Box) OptionFunc { +func PaddingOptionFunc(padding chart.Box) OptionFunc { return func(opt *ChartOption) { opt.Padding = padding } } // BoxOptionFunc set box of chart -func BoxOptionFunc(box Box) OptionFunc { +func BoxOptionFunc(box chart.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) { @@ -222,205 +123,68 @@ func ChildOptionFunc(child ...ChartOption) OptionFunc { } // RadarIndicatorOptionFunc set radar indicator of chart -func RadarIndicatorOptionFunc(names []string, values []float64) OptionFunc { +func RadarIndicatorOptionFunc(radarIndicator ...RadarIndicator) OptionFunc { return func(opt *ChartOption) { - opt.RadarIndicators = NewRadarIndicators(names, values) + opt.RadarIndicators = radarIndicator } } // BackgroundColorOptionFunc set background color of chart -func BackgroundColorOptionFunc(color Color) OptionFunc { +func BackgroundColorOptionFunc(color drawing.Color) OptionFunc { return func(opt *ChartOption) { opt.BackgroundColor = color } } -// 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) +func LineRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { + seriesList := make(SeriesList, len(values)) + for index, value := range values { + seriesList[index] = NewSeriesFromValues(value, ChartTypeLine) + } return Render(ChartOption{ SeriesList: seriesList, }, opts...) } // BarRender bar chart render -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) +func BarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { + seriesList := make(SeriesList, len(values)) + for index, value := range values { + seriesList[index] = NewSeriesFromValues(value, ChartTypeBar) + } return Render(ChartOption{ SeriesList: seriesList, }, opts...) } // PieRender pie chart render -func PieRender(values []float64, opts ...OptionFunc) (*Painter, error) { +func PieRender(values []float64, opts ...OptionFunc) (*Draw, error) { return Render(ChartOption{ SeriesList: NewPieSeriesList(values), }, opts...) } // RadarRender radar chart render -func RadarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { - seriesList := NewSeriesListDataFromValues(values, ChartTypeRadar) +func RadarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { + seriesList := make(SeriesList, len(values)) + for index, value := range values { + seriesList[index] = NewSeriesFromValues(value, ChartTypeRadar) + } return Render(ChartOption{ SeriesList: seriesList, }, opts...) } // FunnelRender funnel chart render -func FunnelRender(values []float64, opts ...OptionFunc) (*Painter, error) { - seriesList := NewFunnelSeriesList(values) +func FunnelRender(values []float64, opts ...OptionFunc) (*Draw, error) { + seriesList := make(SeriesList, len(values)) + for index, value := range values { + seriesList[index] = NewSeriesFromValues([]float64{ + value, + }, ChartTypeFunnel) + } 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 c354b26..41e8d50 100644 --- a/chart_option_test.go +++ b/chart_option_test.go @@ -26,426 +26,213 @@ import ( "testing" "github.com/stretchr/testify/assert" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestChartOption(t *testing.T) { +func TestOptionFunc(t *testing.T) { assert := assert.New(t) fns := []OptionFunc{ - SVGTypeOption(), + TypeOptionFunc(ChartOutputPNG), FontFamilyOptionFunc("fontFamily"), - ThemeOptionFunc("theme"), - TitleTextOptionFunc("title"), - LegendLabelsOptionFunc([]string{ - "label", + ThemeOptionFunc("black"), + TitleOptionFunc(TitleOption{ + Text: "title", }), - XAxisDataOptionFunc([]string{ - "xaxis", + LegendOptionFunc(LegendOption{ + Data: []string{ + "a", + "b", + }, }), - YAxisDataOptionFunc([]string{ - "yaxis", + XAxisOptionFunc(NewXAxisOption([]string{ + "Mon", + "Tue", + })), + YAxisOptionFunc(YAxisOption{ + Min: NewFloatPoint(0), + Max: NewFloatPoint(100), }), - WidthOptionFunc(800), - HeightOptionFunc(600), - PaddingOptionFunc(Box{ - Left: 10, - Top: 10, - Right: 10, - Bottom: 10, + WidthOptionFunc(400), + HeightOptionFunc(300), + PaddingOptionFunc(chart.Box{ + Top: 10, + }), + BoxOptionFunc(chart.Box{ + Left: 0, + Right: 300, + }), + ChildOptionFunc(ChartOption{}), + RadarIndicatorOptionFunc(RadarIndicator{ + Min: 0, + Max: 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) -} -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) + 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 TestLineRender(t *testing.T) { assert := assert.New(t) - values := [][]float64{ + + d, err := LineRender([][]float64{ { - 120, - 132, - 101, - 134, - 90, - 230, - 210, + 1, + 2, + 3, }, { - 220, - 182, - 191, - 234, - 290, - 330, - 310, + 1, + 5, + 2, }, - { - 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), + }, + XAxisOptionFunc(NewXAxisOption([]string{ + "01", + "02", + "03", + })), ) assert.Nil(err) - data, err := p.Bytes() + data, err := d.Bytes() assert.Nil(err) - assert.Equal("\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", string(data)) + assert.Equal("\\n010203024681012", string(data)) } func TestBarRender(t *testing.T) { assert := assert.New(t) - 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 := 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, - ) - }, - ) - assert.Nil(err) - data, err := p.Bytes() - assert.Nil(err) - assert.Equal("\\nRainfallEvaporation24020016012080400FebMayAugNov162.22182.22.341.6248.07", string(data)) -} -func TestHorizontalBarRender(t *testing.T) { - assert := assert.New(t) - values := [][]float64{ + d, err := BarRender([][]float64{ { - 18203, - 23489, - 29034, - 104970, - 131744, - 630230, + 1, + 2, + 3, }, { - 19325, - 23438, - 31000, - 121594, - 134141, - 681807, + 1, + 5, + 2, }, - } - 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", - }), + }, + XAxisOptionFunc(NewXAxisOption([]string{ + "01", + "02", + "03", + })), ) assert.Nil(err) - data, err := p.Bytes() + data, err := d.Bytes() assert.Nil(err) - assert.Equal("\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0122.28k244.56k366.84k489.12k611.4k733.68k", string(data)) + assert.Equal("\\n010203024681012", string(data)) } func TestPieRender(t *testing.T) { assert := assert.New(t) - 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(), - ) + + d, err := PieRender([]float64{ + 1, + 3, + 5, + }) assert.Nil(err) - data, err := p.Bytes() + data, err := d.Bytes() assert.Nil(err) - 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)) + assert.Equal("\\n", string(data)) } func TestRadarRender(t *testing.T) { assert := assert.New(t) - - values := [][]float64{ + d, err := RadarRender([][]float64{ { - 4200, - 3000, - 20000, - 35000, - 50000, - 18000, + 1, + 2, + 3, }, { - 5000, - 14000, - 28000, - 26000, - 42000, - 21000, + 1, + 5, + 2, }, - } - 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, - }), + }, + RadarIndicatorOptionFunc([]RadarIndicator{ + { + Name: "A", + Min: 0, + Max: 10, + }, + { + Name: "B", + Min: 0, + Max: 10, + }, + { + Name: "C", + Min: 0, + Max: 10, + }, + }...), ) assert.Nil(err) - data, err := p.Bytes() + data, err := d.Bytes() assert.Nil(err) - assert.Equal("\\nAllocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", string(data)) + assert.Equal("\\nABC", string(data)) } func TestFunnelRender(t *testing.T) { assert := assert.New(t) - values := []float64{ - 100, - 80, - 60, - 40, - 20, - } - p, err := FunnelRender( - values, - SVGTypeOption(), - TitleTextOptionFunc("Funnel"), - LegendLabelsOptionFunc([]string{ - "Show", - "Click", - "Visit", - "Inquiry", - "Order", - }), - ) + d, err := FunnelRender([]float64{ + 5, + 3, + 1, + }) assert.Nil(err) - data, err := p.Bytes() + data, err := d.Bytes() assert.Nil(err) - assert.Equal("\\nShowClickVisitInquiryOrderFunnelShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", string(data)) + assert.Equal("\\n(100%)(60%)(20%)", string(data)) } diff --git a/chart_test.go b/chart_test.go new file mode 100644 index 0000000..c73745e --- /dev/null +++ b/chart_test.go @@ -0,0 +1,567 @@ +// 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 deleted file mode 100644 index 31df11c..0000000 --- a/charts.go +++ /dev/null @@ -1,473 +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" - - "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 deleted file mode 100644 index bd581e9..0000000 --- a/charts_test.go +++ /dev/null @@ -1,255 +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" - - "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 new file mode 100644 index 0000000..1708662 --- /dev/null +++ b/draw.go @@ -0,0 +1,372 @@ +// 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/painter_test.go b/draw_test.go similarity index 63% rename from painter_test.go rename to draw_test.go index 07c4113..f6a3dd1 100644 --- a/painter_test.go +++ b/draw_test.go @@ -26,85 +26,217 @@ import ( "math" "testing" - "github.com/golang/freetype/truetype" "github.com/stretchr/testify/assert" - "git.smarteching.com/zeni/go-chart/v2" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestPainterOption(t *testing.T) { +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) { assert := assert.New(t) - font := &truetype.Font{} - d, err := NewPainter(PainterOptions{ - Width: 800, - Height: 600, - Type: ChartOutputSVG, - }, - PainterBoxOption(Box{ - Right: 400, - Bottom: 300, - }), - PainterPaddingOption(Box{ + // 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{ Left: 1, Top: 2, Right: 3, Bottom: 4, }), - PainterFontOption(font), - PainterStyleOption(Style{ - ClassName: "test", - }), ) assert.Nil(err) - assert.Equal(Box{ - Left: 1, - Top: 2, - Right: 397, - Bottom: 296, - }, d.box) - assert.Equal(font, d.font) - assert.Equal("test", d.style.ClassName) + assert.Equal(chart.Box{ + Top: 4, + Left: 2, + Right: 394, + Bottom: 292, + }, d.Box) } -func TestPainter(t *testing.T) { +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) { assert := assert.New(t) tests := []struct { - fn func(*Painter) + fn func(d *Draw) result string }{ // moveTo, lineTo { - fn: func(p *Painter) { - p.MoveTo(1, 1) - p.LineTo(2, 2) - p.Stroke() + fn: func(d *Draw) { + d.moveTo(1, 1) + d.lineTo(2, 2) + d.Render.Stroke() }, result: "\\n", }, // circle { - fn: func(p *Painter) { - p.Circle(5, 2, 3) + fn: func(d *Draw) { + d.circle(5, 2, 3) }, result: "\\n", }, // text { - fn: func(p *Painter) { - p.Text("hello world!", 3, 6) + fn: func(d *Draw) { + d.text("hello world!", 3, 6) }, - result: "\\nhello world!", + result: "\\nhello world!", }, // line stroke { - fn: func(p *Painter) { - p.SetDrawingStyle(Style{ - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, - }) - p.LineStroke([]Point{ + fn: func(d *Draw) { + d.lineStroke([]Point{ { X: 1, Y: 2, @@ -113,153 +245,156 @@ func TestPainter(t *testing.T) { X: 3, Y: 4, }, + }, LineStyle{ + StrokeColor: drawing.ColorBlack, + StrokeWidth: 1, }) }, result: "\\n", }, // set background { - fn: func(p *Painter) { - p.SetBackground(400, 300, chart.ColorWhite) + fn: func(d *Draw) { + d.setBackground(400, 300, chart.ColorWhite) }, result: "\\n", }, // arcTo { - fn: func(p *Painter) { - p.SetStyle(Style{ + fn: func(d *Draw) { + chart.Style{ StrokeWidth: 1, StrokeColor: drawing.ColorBlack, FillColor: drawing.ColorBlue, - }) - p.ArcTo(100, 100, 100, 100, 0, math.Pi/2) - p.Close() - p.FillStroke() + }.WriteToRenderer(d.Render) + d.arcTo(100, 100, 100, 100, 0, math.Pi/2) + d.Render.Close() + d.Render.FillStroke() }, result: "\\n", }, // pin { - fn: func(p *Painter) { - p.SetStyle(Style{ + fn: func(d *Draw) { + chart.Style{ StrokeWidth: 1, - StrokeColor: Color{ + StrokeColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: Color{ + FillColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - }) - p.Pin(30, 30, 30) + }.WriteToRenderer(d.Render) + d.pin(30, 30, 30) }, result: "\\n", }, // arrow left { - fn: func(p *Painter) { - p.SetStyle(Style{ + fn: func(d *Draw) { + chart.Style{ StrokeWidth: 1, - StrokeColor: Color{ + StrokeColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: Color{ + FillColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - }) - p.ArrowLeft(30, 30, 16, 10) + }.WriteToRenderer(d.Render) + d.arrowLeft(30, 30, 16, 10) }, result: "\\n", }, // arrow right { - fn: func(p *Painter) { - p.SetStyle(Style{ + fn: func(d *Draw) { + chart.Style{ StrokeWidth: 1, - StrokeColor: Color{ + StrokeColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: Color{ + FillColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - }) - p.ArrowRight(30, 30, 16, 10) + }.WriteToRenderer(d.Render) + d.arrowRight(30, 30, 16, 10) }, result: "\\n", }, // arrow top { - fn: func(p *Painter) { - p.SetStyle(Style{ + fn: func(d *Draw) { + chart.Style{ StrokeWidth: 1, - StrokeColor: Color{ + StrokeColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: Color{ + FillColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - }) - p.ArrowTop(30, 30, 10, 16) + }.WriteToRenderer(d.Render) + d.arrowTop(30, 30, 10, 16) }, result: "\\n", }, // arrow bottom { - fn: func(p *Painter) { - p.SetStyle(Style{ + fn: func(d *Draw) { + chart.Style{ StrokeWidth: 1, - StrokeColor: Color{ + StrokeColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: Color{ + FillColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - }) - p.ArrowBottom(30, 30, 10, 16) + }.WriteToRenderer(d.Render) + d.arrowBottom(30, 30, 10, 16) }, result: "\\n", }, // mark line { - fn: func(p *Painter) { - p.SetStyle(Style{ + fn: func(d *Draw) { + chart.Style{ StrokeWidth: 1, - StrokeColor: Color{ + StrokeColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - FillColor: Color{ + FillColor: drawing.Color{ R: 84, G: 112, B: 198, @@ -269,42 +404,34 @@ func TestPainter(t *testing.T) { 4, 2, }, - }) - p.MarkLine(0, 20, 300) + }.WriteToRenderer(d.Render) + d.makeLine(0, 20, 300) }, - result: "\\n", + result: "\\n", }, // polygon { - fn: func(p *Painter) { - p.SetStyle(Style{ + fn: func(d *Draw) { + chart.Style{ StrokeWidth: 1, - StrokeColor: Color{ + StrokeColor: drawing.Color{ R: 84, G: 112, B: 198, A: 255, }, - }) - p.Polygon(Point{ + }.WriteToRenderer(d.Render) + d.polygon(Point{ X: 100, Y: 100, }, 50, 6) }, result: "\\n", }, - // FillArea + // fill { - fn: func(p *Painter) { - p.SetDrawingStyle(Style{ - FillColor: Color{ - R: 84, - G: 112, - B: 198, - A: 255, - }, - }) - p.FillArea([]Point{ + fn: func(d *Draw) { + d.fill([]Point{ { X: 0, Y: 0, @@ -321,17 +448,23 @@ func TestPainter(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 := NewPainter(PainterOptions{ + d, err := NewDraw(DrawOption{ Width: 400, Height: 300, - Type: ChartOutputSVG, - }, PainterPaddingOption(chart.Box{ + }, PaddingOption(chart.Box{ Left: 5, Top: 10, })) @@ -343,57 +476,32 @@ func TestPainter(t *testing.T) { } } -func TestRoundedRect(t *testing.T) { +func TestDrawTextFit(t *testing.T) { assert := assert.New(t) - p, err := NewPainter(PainterOptions{ + d, err := NewDraw(DrawOption{ Width: 400, Height: 300, - Type: ChartOutputSVG, }) assert.Nil(err) - 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{ + f, _ := chart.GetDefaultFont() + style := chart.Style{ FontSize: 12, FontColor: chart.ColorBlack, Font: f, } - p.SetStyle(style) - box := p.TextFit("Hello World!", 0, 20, 80) + box := d.textFit("Hello World!", 0, 20, 80, style) assert.Equal(chart.Box{ Right: 45, Bottom: 35, }, box) - box = p.TextFit("Hello World!", 0, 100, 200) + box = d.textFit("Hello World!", 0, 100, 200, style) assert.Equal(chart.Box{ Right: 84, Bottom: 15, }, box) - buf, err := p.Bytes() + buf, err := d.Bytes() assert.Nil(err) assert.Equal(`\nHelloWorld!Hello World!`, string(buf)) } diff --git a/echarts.go b/echarts.go index aaef1f1..4ebb9ad 100644 --- a/echarts.go +++ b/echarts.go @@ -29,7 +29,7 @@ import ( "regexp" "strconv" - "git.smarteching.com/zeni/go-chart/v2" + "github.com/wcharczuk/go-chart/v2" ) func convertToArray(data []byte) []byte { @@ -60,9 +60,9 @@ type EChartStyle struct { Color string `json:"color"` } -func (es *EChartStyle) ToStyle() Style { +func (es *EChartStyle) ToStyle() chart.Style { color := parseColor(es.Color) - return Style{ + return chart.Style{ FillColor: color, FontColor: color, StrokeColor: color, @@ -130,7 +130,6 @@ type EChartsXAxisData struct { BoundaryGap *bool `json:"boundaryGap"` SplitNumber int `json:"splitNumber"` Data []string `json:"data"` - Type string `json:"type"` } type EChartsXAxis struct { Data []EChartsXAxisData @@ -156,7 +155,6 @@ type EChartsYAxisData struct { Color string `json:"color"` } `json:"lineStyle"` } `json:"axisLine"` - Data []string `json:"data"` } type EChartsYAxis struct { Data []EChartsYAxisData `json:"data"` @@ -344,11 +342,6 @@ 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 @@ -361,10 +354,10 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { } } seriesList = append(seriesList, Series{ - Type: item.Type, - Data: data, - AxisIndex: item.YAxisIndex, - Style: item.ItemStyle.ToStyle(), + Type: item.Type, + Data: data, + YAxisIndex: item.YAxisIndex, + Style: item.ItemStyle.ToStyle(), Label: SeriesLabel{ Color: parseColor(item.Label.Color), Show: item.Label.Show, @@ -426,32 +419,26 @@ 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, - FontColor: titleTextStyle.FontColor, - FontSize: titleTextStyle.FontSize, - SubtextFontSize: titleSubtextStyle.FontSize, - SubtextFontColor: titleSubtextStyle.FontColor, - Left: string(eo.Title.Left), - Top: string(eo.Title.Top), + 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), }, Legend: LegendOption{ - Show: eo.Legend.Show, - FontSize: legendTextStyle.FontSize, - FontColor: legendTextStyle.FontColor, - Data: eo.Legend.Data, - Left: string(eo.Legend.Left), - Top: string(eo.Legend.Top), - Align: eo.Legend.Align, - Orient: eo.Legend.Orient, + 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, }, RadarIndicators: eo.Radar.Indicator, Width: eo.Width, @@ -460,21 +447,6 @@ 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{ @@ -490,10 +462,9 @@ func (eo *EChartsOption) ToOption() ChartOption { Max: item.Max, Formatter: item.AxisLabel.Formatter, Color: parseColor(item.AxisLine.LineStyle.Color), - Data: item.Data, } } - o.YAxisOptions = yAxisOptions + o.YAxisList = yAxisOptions if len(eo.Children) != 0 { o.Children = make([]ChartOption, len(eo.Children)) diff --git a/echarts_test.go b/echarts_test.go index 2077278..05c2a40 100644 --- a/echarts_test.go +++ b/echarts_test.go @@ -27,556 +27,566 @@ import ( "testing" "github.com/stretchr/testify/assert" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/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("1")) + err := p.UnmarshalJSON([]byte("12")) assert.Nil(err) - assert.Equal(EChartsPosition("1"), p) - err = p.UnmarshalJSON([]byte(`"left"`)) - assert.Nil(err) - assert.Equal(EChartsPosition("left"), p) -} + assert.Equal("12", string(p)) -func TestEChartsSeriesDataValue(t *testing.T) { + err = p.UnmarshalJSON([]byte(`"12%"`)) + assert.Nil(err) + assert.Equal("12%", string(p)) +} +func TestEChartStyle(t *testing.T) { assert := assert.New(t) - 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) + 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()) } func TestEChartsXAxis(t *testing.T) { assert := assert.New(t) ex := EChartsXAxis{} - err := ex.UnmarshalJSON([]byte(`{"boundaryGap": true, "splitNumber": 5, "data": ["a", "b"], "type": "value"}`)) + err := ex.UnmarshalJSON([]byte(`{ + "boundaryGap": false, + "splitNumber": 5, + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }`)) assert.Nil(err) - assert.Equal(EChartsXAxis{ Data: []EChartsXAxisData{ { - BoundaryGap: TrueFlag(), + BoundaryGap: FalseFlag(), SplitNumber: 5, Data: []string{ - "a", - "b", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", }, - Type: "value", }, }, }, ex) } -func TestEChartStyle(t *testing.T) { +func TestEChartsYAxis(t *testing.T) { assert := assert.New(t) + ey := EChartsYAxis{} - 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()) + 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) } func TestEChartsPadding(t *testing.T) { assert := assert.New(t) - eb := EChartsPadding{} + ep := EChartsPadding{} - err := eb.UnmarshalJSON([]byte(`1`)) + err := ep.UnmarshalJSON([]byte(`10`)) assert.Nil(err) - assert.Equal(Box{ - Left: 1, - Top: 1, - Right: 1, - Bottom: 1, - }, eb.Box) + assert.Equal(EChartsPadding{ + Box: chart.Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + }, + }, ep) - err = eb.UnmarshalJSON([]byte(`[2, 3]`)) + ep = EChartsPadding{} + err = ep.UnmarshalJSON([]byte(`[10, 20]`)) assert.Nil(err) - assert.Equal(Box{ - Left: 3, - Top: 2, - Right: 3, - Bottom: 2, - }, eb.Box) + assert.Equal(EChartsPadding{ + Box: chart.Box{ + Top: 10, + Right: 20, + Bottom: 10, + Left: 20, + }, + }, ep) - err = eb.UnmarshalJSON([]byte(`[4, 5, 6]`)) + ep = EChartsPadding{} + err = ep.UnmarshalJSON([]byte(`[10, 20, 30]`)) assert.Nil(err) - assert.Equal(Box{ - Left: 5, - Top: 4, - Right: 5, - Bottom: 6, - }, eb.Box) + assert.Equal(EChartsPadding{ + Box: chart.Box{ + Top: 10, + Right: 20, + Bottom: 30, + Left: 20, + }, + }, ep) - err = eb.UnmarshalJSON([]byte(`[4, 5, 6, 7]`)) + ep = EChartsPadding{} + err = ep.UnmarshalJSON([]byte(`[10, 20, 30, 40]`)) assert.Nil(err) - assert.Equal(Box{ - Left: 7, - Top: 4, - Right: 5, - Bottom: 6, - }, eb.Box) + 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) } func TestEChartsMarkPoint(t *testing.T) { assert := assert.New(t) - emp := EChartsMarkPoint{ - SymbolSize: 30, - Data: []EChartsMarkData{ + p := EChartsMarkPoint{} + + err := json.Unmarshal([]byte(`{ + "symbolSize": 30, + "data": [ { - Type: "test", + "type": "max" }, - }, - } + { + "type": "min" + } + ] + }`), &p) + assert.Nil(err) assert.Equal(SeriesMarkPoint{ SymbolSize: 30, Data: []SeriesMarkData{ { - Type: "test", + Type: "max", + }, + { + Type: "min", }, }, - }, emp.ToSeriesMarkPoint()) + }, p.ToSeriesMarkPoint()) } func TestEChartsMarkLine(t *testing.T) { assert := assert.New(t) + l := EChartsMarkLine{} - eml := EChartsMarkLine{ - Data: []EChartsMarkData{ + err := json.Unmarshal([]byte(`{ + "data": [ { - Type: "min", + "type": "max" }, { - Type: "max", - }, - }, - } + "type": "min" + } + ] + }`), &l) + assert.Nil(err) assert.Equal(SeriesMarkLine{ Data: []SeriesMarkData{ - { - Type: "min", - }, { Type: "max", }, - }, - }, eml.ToSeriesMarkLine()) -} - -func TestEChartsOption(t *testing.T) { - assert := assert.New(t) - - tests := []struct { - option string - }{ - { - option: `{ - "xAxis": { - "type": "category", - "data": [ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun" - ] - }, - "yAxis": { - "type": "value" - }, - "series": [ - { - "data": [ - 120, - { - "value": 200, - "itemStyle": { - "color": "#a90000" - } - }, - 150, - 80, - 70, - 110, - 130 - ], - "type": "bar" - } - ] - }`, - }, - { - 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" - } - ] - } - } - ] - }`, - }, - } - 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) - } -} - -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" - } - ] - } + Type: "min", }, - { - "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)) + }, + }, 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, + }, + FontSize: 14, + }, s.ToStyle()) +} + +func TestEChartsSeriesList(t *testing.T) { + assert := assert.New(t) + + // pie + es := EChartsSeriesList{ + { + Type: ChartTypePie, + Radius: "30%", + Data: []EChartsSeriesData{ + { + Name: "1", + Value: EChartsSeriesDataValue{ + values: []float64{ + 1, + }, + }, + }, + { + Name: "2", + Value: EChartsSeriesDataValue{ + values: []float64{ + 2, + }, + }, + }, + }, + }, + } + 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, + }, + }, + } + 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()) + } diff --git a/examples/area_line_chart/main.go b/examples/area_line_chart/main.go deleted file mode 100644 index 57ca1e9..0000000 --- a/examples/area_line_chart/main.go +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 91c9f81..0000000 --- a/examples/bar_chart/main.go +++ /dev/null @@ -1,102 +0,0 @@ -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 new file mode 100644 index 0000000..1e7af8d --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,94 @@ +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 81bc4f2..fddbe6d 100644 --- a/examples/charts/main.go +++ b/examples/charts/main.go @@ -2,11 +2,12 @@ package main import ( "bytes" - "fmt" "net/http" "strconv" - charts "git.smarteching.com/zeni/go-charts/v2" + charts "github.com/vicanso/go-charts" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) var html = ` @@ -74,7 +75,6 @@ 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,48 +93,6 @@ 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) @@ -142,6 +100,7 @@ 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", @@ -215,7 +174,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Title: charts.TitleOption{ Text: "Temperature Change in the Coming Week", }, - Padding: charts.Box{ + Padding: chart.Box{ Top: 20, Left: 20, Right: 30, @@ -262,35 +221,6 @@ 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{ @@ -310,7 +240,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Rainfall", "Evaporation", }, - Icon: charts.IconRect, + Icon: charts.LegendIconRect, }, SeriesList: []charts.Series{ charts.NewSeriesFromValues([]float64{ @@ -330,8 +260,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, { Value: 190, - Style: charts.Style{ - FillColor: charts.Color{ + Style: chart.Style{ + FillColor: drawing.Color{ R: 169, G: 0, B: 0, @@ -355,68 +285,16 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Value: 180, }, }, - Label: charts.SeriesLabel{ - Show: true, - Position: charts.PositionBottom, - }, }, }, }, - // 水平柱状图 - { - 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, - }), - }, - }, - }, - // 柱状图+标记 + // 柱状图+mark { Title: charts.TitleOption{ Text: "Rainfall vs Evaporation", Subtext: "Fake Data", }, - Padding: charts.Box{ + Padding: chart.Box{ Top: 20, Right: 20, Bottom: 20, @@ -493,9 +371,6 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, // 双Y轴示例 { - Title: charts.TitleOption{ - Text: "Temperature", - }, XAxis: charts.NewXAxisOption([]string{ "Jan", "Feb", @@ -515,22 +390,22 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Precipitation", "Temperature", }), - YAxisOptions: []charts.YAxisOption{ + YAxisList: []charts.YAxisOption{ { - Formatter: "{value}ml", - Color: charts.Color{ - R: 84, - G: 112, - B: 198, + Formatter: "{value}°C", + Color: drawing.Color{ + R: 250, + G: 200, + B: 88, A: 255, }, }, { - Formatter: "{value}°C", - Color: charts.Color{ - R: 250, - G: 200, - B: 88, + Formatter: "{value}ml", + Color: drawing.Color{ + R: 84, + G: 112, + B: 198, A: 255, }, }, @@ -551,7 +426,9 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 20.0, 6.4, 3.3, + 10.2, }), + YAxisIndex: 1, }, { Type: charts.ChartTypeBar, @@ -568,7 +445,9 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 18.8, 6.0, 2.3, + 20.2, }), + YAxisIndex: 1, }, { Data: charts.NewSeriesDataFromValues([]float64{ @@ -584,8 +463,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 16.5, 12.0, 6.2, + 30.3, }), - AxisIndex: 1, }, }, }, @@ -693,20 +572,6 @@ 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", @@ -728,6 +593,20 @@ 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, + }), + }, }, }, // 多图展示 @@ -741,7 +620,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Walnut Brownie", }, }, - Padding: charts.Box{ + Padding: chart.Box{ Top: 100, Right: 10, Bottom: 10, @@ -755,7 +634,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "2016", "2017", }), - YAxisOptions: []charts.YAxisOption{ + YAxisList: []charts.YAxisOption{ { Min: charts.NewFloatPoint(0), @@ -807,7 +686,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Walnut Brownie", }, }, - Box: charts.Box{ + Box: chart.Box{ Top: 20, Left: 400, Right: 500, @@ -1132,64 +1011,6 @@ 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", @@ -1351,7 +1172,12 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { 23.2, 25.6, 76.7, - 135.6 + 135.6, + 162.2, + 32.6, + 20, + 6.4, + 3.3 ] }, { @@ -1365,7 +1191,12 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { 26.4, 28.7, 70.7, - 175.6 + 175.6, + 182.2, + 48.7, + 18.8, + 6, + 2.3 ] }, { @@ -1380,7 +1211,12 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { 4.5, 6.3, 10.2, - 20.3 + 20.3, + 23.4, + 23, + 16.5, + 12, + 6.2 ] } ] @@ -1969,6 +1805,5 @@ 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 601f54e..e0125b4 100644 --- a/examples/chinese/main.go +++ b/examples/chinese/main.go @@ -2,119 +2,49 @@ package main import ( "io/ioutil" - "os" - "path/filepath" + "log" - "git.smarteching.com/zeni/go-charts/v2" + charts "github.com/vicanso/go-charts" ) -func writeFile(buf []byte) error { - tmpPath := "./tmp" - err := os.MkdirAll(tmpPath, 0700) - if err != nil { - return err - } - - file := filepath.Join(tmpPath, "chinese-line-chart.png") - err = os.WriteFile(file, buf, 0600) - if err != nil { - return err - } - return nil +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 main() { - // 字体文件需要自行下载 - // https://github.com/googlefonts/noto-cjk - buf, err := ioutil.ReadFile("./NotoSansSC.ttf") + fontData, err := ioutil.ReadFile("/Users/darcy/Downloads/NotoSansCJKsc-VF.ttf") if err != nil { - panic(err) - } - err = charts.InstallFont("noto", buf) - if err != nil { - panic(err) - } - font, _ := charts.GetFont("noto") - charts.SetDefaultFont(font) - - 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 { - panic(err) + log.Fatalln("Error when reading font file:", err) } - buf, err = p.Bytes() - if err != nil { - panic(err) + if err := charts.InstallFont("chinese", fontData); err != nil { + log.Fatalln("Error when instaling font:", err) } - err = writeFile(buf) + + fileData, err := echartsRender() + if err != nil { - panic(err) + log.Fatalln("Error when rendering image:", err) + } + if err := ioutil.WriteFile("chinese.png", fileData, 0644); err != nil { + log.Fatalln("Error when save image to chinese.png:", err) } } diff --git a/examples/funnel_chart/main.go b/examples/funnel_chart/main.go deleted file mode 100644 index 653f834..0000000 --- a/examples/funnel_chart/main.go +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index f5d8497..0000000 --- a/examples/horizontal_bar_chart/main.go +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index baee8a3..0000000 --- a/examples/line_chart/main.go +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 1b842b3..0000000 --- a/examples/painter/main.go +++ /dev/null @@ -1,607 +0,0 @@ -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 deleted file mode 100644 index 5d70438..0000000 --- a/examples/pie_chart/main.go +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index e7053af..0000000 --- a/examples/radar_chart/main.go +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index de994eb..0000000 --- a/examples/table/main.go +++ /dev/null @@ -1,178 +0,0 @@ -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 deleted file mode 100644 index c6c93bf..0000000 --- a/examples/time_line_chart/main.go +++ /dev/null @@ -1,81 +0,0 @@ -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 828654e..c40b51e 100644 --- a/font.go +++ b/font.go @@ -27,18 +27,14 @@ import ( "sync" "github.com/golang/freetype/truetype" - "git.smarteching.com/zeni/go-chart/v2/roboto" + "github.com/wcharczuk/go-chart/v2/roboto" ) var fonts = sync.Map{} var ErrFontNotExists = errors.New("font is not exists") -var defaultFontFamily = "defaultFontFamily" func init() { - name := "roboto" - _ = InstallFont(name, roboto.Roboto) - font, _ := GetFont(name) - SetDefaultFont(font) + _ = InstallFont("roboto", roboto.Roboto) } // InstallFont installs the font for charts @@ -51,19 +47,6 @@ 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 e0c56b2..9dc731c 100644 --- a/font_test.go +++ b/font_test.go @@ -26,7 +26,7 @@ import ( "testing" "github.com/stretchr/testify/assert" - "git.smarteching.com/zeni/go-chart/v2/roboto" + "github.com/wcharczuk/go-chart/v2/roboto" ) func TestInstallFont(t *testing.T) { diff --git a/funnel_chart.go b/funnel.go similarity index 53% rename from funnel_chart.go rename to funnel.go index d4a8bdd..f083306 100644 --- a/funnel_chart.go +++ b/funnel.go @@ -23,54 +23,35 @@ package charts import ( + "fmt" + "sort" + + "github.com/dustin/go-humanize" "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" ) -type funnelChart struct { - p *Painter - opt *FunnelChartOption -} - -// 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) - } - 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 +type funnelChartOption struct { + Theme string + Font *truetype.Font 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 +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 + } + 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 + }) max := seriesList[0].Data[0].Value min := float64(0) for _, item := range seriesList { @@ -81,10 +62,11 @@ func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) min = *item.Min } } - theme := opt.Theme + + theme := NewTheme(opt.Theme) gap := 2 - height := seriesPainter.Height() - width := seriesPainter.Width() + height := d.Box.Height() + width := d.Box.Width() count := len(seriesList) h := (height - gap*(count-1)) / count @@ -92,23 +74,13 @@ func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) 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 - // 最大最小值一致则为100% - widthPercent := 100.0 - if offset != 0 { - widthPercent = (value - min) / offset - } + widthPercent := (value - min) / (max - min) w := int(widthPercent * float64(width)) widthList[index] = w - // 如果最大值为0,则占比100% - percent := 1.0 - if max != 0 { - percent = value / max - } - textList[index] = NewFunnelLabelFormatter(seriesNames, item.Label.Formatter)(index, value, percent) + p := humanize.CommafWithDigits(value/max*100, 2) + "%" + textList[index] = fmt.Sprintf("%s(%s)", item.Name, p) } for index, w := range widthList { @@ -144,49 +116,26 @@ func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) }, } color := theme.GetSeriesColor(series.index) - - seriesPainter.OverrideDrawingStyle(Style{ + d.fill(points, chart.Style{ FillColor: color, - }).FillArea(points) + }) // 文本 text := textList[index] - seriesPainter.OverrideTextStyle(Style{ + r := d.Render + textStyle := chart.Style{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, - }) - textBox := seriesPainter.MeasureText(text) + } + textStyle.GetTextOptions().WriteToRenderer(r) + textBox := r.MeasureText(text) textX := width>>1 - textBox.Width()>>1 textY := y + h>>1 - seriesPainter.Text(text, textX, textY) + d.text(text, textX, textY) + y += (h + gap) } - return f.p.box, nil -} - -func (f *funnelChart) Render() (Box, error) { - p := f.p - opt := f.opt - renderResult, err := defaultRender(p, defaultRenderOption{ - Theme: opt.Theme, - Padding: opt.Padding, - SeriesList: opt.SeriesList, - XAxis: XAxisOption{ - Show: FalseFlag(), - }, - YAxisOptions: []YAxisOption{ - { - Show: FalseFlag(), - }, - }, - TitleOption: opt.Title, - LegendOption: opt.Legend, - }) - if err != nil { - return BoxZero, err - } - seriesList := opt.SeriesList.Filter(ChartTypeFunnel) - return f.render(renderResult, seriesList) + return nil } diff --git a/funnel_chart_test.go b/funnel_chart_test.go deleted file mode 100644 index d260bfb..0000000 --- a/funnel_chart_test.go +++ /dev/null @@ -1,79 +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 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 new file mode 100644 index 0000000..530fa53 --- /dev/null +++ b/funnel_test.go @@ -0,0 +1,91 @@ +// 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 76a47b6..610af22 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,17 @@ -module git.smarteching.com/zeni/go-charts/v2 +module github.com/vicanso/go-charts -go 1.24.1 +go 1.17 require ( - git.smarteching.com/zeni/go-chart/v2 v2.1.4 - github.com/dustin/go-humanize v1.0.1 + github.com/dustin/go-humanize v1.0.0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.7.1 + github.com/wcharczuk/go-chart/v2 v2.1.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.21.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index 3e1a48a..d88f473 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,25 @@ -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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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/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/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= +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= 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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= diff --git a/grid.go b/grid.go deleted file mode 100644 index 0ebd226..0000000 --- a/grid.go +++ /dev/null @@ -1,92 +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 - -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 deleted file mode 100644 index fa9c3a6..0000000 --- a/grid_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" - "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 deleted file mode 100644 index ed091c9..0000000 --- a/horizontal_bar_chart.go +++ /dev/null @@ -1,216 +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/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 deleted file mode 100644 index e078c4a..0000000 --- a/horizontal_bar_chart_test.go +++ /dev/null @@ -1,100 +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 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 035642c..df72757 100644 --- a/legend.go +++ b/legend.go @@ -25,19 +25,16 @@ 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 { - // The theme - Theme ColorPalette + theme string + // Legend show flag, if nil or true, the legend will be shown + Show *bool + // Legend text style + Style chart.Style // Text array of legend Data []string // Distance between legend component and the left side of the container. @@ -53,199 +50,177 @@ 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 } -// NewLegendOption returns a legend option -func NewLegendOption(labels []string, left ...string) LegendOption { +const ( + LegendIconRect = "rect" +) + +// NewLegendOption creates a new legend option by legend text list +func NewLegendOption(data []string, position ...string) LegendOption { opt := LegendOption{ - Data: labels, + Data: data, } - if len(left) != 0 { - opt.Left = left[0] + if len(position) != 0 { + opt.Left = position[0] } return opt } -// IsEmpty checks legend is empty -func (opt *LegendOption) IsEmpty() bool { - isEmpty := true - for _, v := range opt.Data { - if v != "" { - isEmpty = false - break - } - } - return isEmpty +type legend struct { + d *Draw + opt *LegendOption } -// NewLegendPainter returns a legend renderer -func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter { - return &legendPainter{ - p: p, +func NewLegend(d *Draw, opt LegendOption) *legend { + return &legend{ + d: d, opt: &opt, } } -func (l *legendPainter) Render() (Box, error) { +func (l *legend) Render() (chart.Box, error) { + d := l.d opt := l.opt - theme := opt.Theme - if opt.IsEmpty() || - isFalse(opt.Show) { - return BoxZero, nil + if len(opt.Data) == 0 || isFalse(opt.Show) { + return chart.BoxZero, nil } - if theme == nil { - theme = l.p.theme + theme := NewTheme(opt.theme) + padding := opt.Style.Padding + legendDraw, err := NewDraw(DrawOption{ + Parent: d, + }, PaddingOption(padding)) + if err != nil { + return chart.BoxZero, err } - if opt.FontSize == 0 { - opt.FontSize = theme.GetFontSize() + 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.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)) + legendWidth := 30 + legendDotHeight := 5 + textPadding := 5 + legendMargin := 10 + // 往下移2倍dot的高度 + y += 2 * legendDotHeight + + widthCount := 0 maxTextWidth := 0 - for index, text := range opt.Data { - b := p.MeasureText(text) + // 文本宽度 + for _, text := range opt.Data { + b := r.MeasureText(text) if b.Width() > maxTextWidth { maxTextWidth = b.Width() } - measureList[index] = b + widthCount += b.Width() } - - // 计算展示的宽高 - 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) + widthCount = maxTextWidth + legendWidth + textPadding } else { - height = legendHeight - offsetValue := (len(opt.Data) - 1) * (offset + textOffset) - allLegendWidth := len(opt.Data) * legendWidth - width += (offsetValue + allLegendWidth) + // 加上标记 + widthCount += legendWidth * len(opt.Data) + // 文本的padding + widthCount += 2 * textPadding * len(opt.Data) + // margin的宽度 + widthCount += legendMargin * (len(opt.Data) - 1) } - // 计算开始的位置 left := 0 switch opt.Left { case PositionRight: - left = p.Width() - width + left = legendDraw.Box.Width() - widthCount case PositionCenter: - left = (p.Width() - width) >> 1 + left = (legendDraw.Box.Width() - widthCount) >> 1 default: if strings.HasSuffix(opt.Left, "%") { value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) - left = p.Width() * value / 100 + left = legendDraw.Box.Width() * value / 100 } else { value, _ := strconv.Atoi(opt.Left) left = value } } - 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 + x = left for index, text := range opt.Data { - 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) - } + textBox := r.MeasureText(text) + var renderText func() if opt.Orient == OrientVertical { - y0 += offset - x0 = x - } else { - x0 += offset - y0 = y - } - height = y0 - startY + 10 - } + // 垂直 + // 重置x的位置 + x = left + renderText = func() { + x += textPadding + legendDraw.text(text, x, y+legendDotHeight) + x += textBox.Width() + y += (2*legendDotHeight + legendMargin) + } - return Box{ - Right: width, - Bottom: height + padding.Bottom + padding.Top, - }, nil + } 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() + } + } + 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 } diff --git a/legend_test.go b/legend_test.go index 526f178..c5d7e50 100644 --- a/legend_test.go +++ b/legend_test.go @@ -26,77 +26,160 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestNewLegend(t *testing.T) { +func TestNewLegendOption(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 { - render func(*Painter) ([]byte, error) - result string + newDraw func() *Draw + newLegend func(*Draw) *legend + box chart.Box + result string }{ { - render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + newDraw: newDraw, + newLegend: func(d *Draw) *legend { + return NewLegend(d, LegendOption{ + Top: "10", Data: []string{ - "One", - "Two", - "Three", + "Mon", + "Tue", + "Wed", }, - }).Render() - if err != nil { - return nil, err - } - return p.Bytes() + Style: style, + }) + }, + result: "\\nMonTueWed", + box: chart.Box{ + Right: 214, + Bottom: 25, }, - result: "\\nOneTwoThree", }, { - render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + newDraw: newDraw, + newLegend: func(d *Draw) *legend { + return NewLegend(d, LegendOption{ + Top: "10", + Left: PositionRight, + Align: PositionRight, Data: []string{ - "One", - "Two", - "Three", + "Mon", + "Tue", + "Wed", }, + 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, - }).Render() - if err != nil { - return nil, err - } - return p.Bytes() + Data: []string{ + "Mon", + "Tue", + "Wed", + }, + Style: style, + Orient: OrientVertical, + }) + }, + result: "\\nMonTueWed", + box: chart.Box{ + Right: 61, + Bottom: 80, }, - result: "\\nOneTwoThree", }, { - render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + newDraw: newDraw, + newLegend: func(d *Draw) *legend { + return NewLegend(d, LegendOption{ + Top: "10", + Left: "10%", Data: []string{ - "One", - "Two", - "Three", + "Mon", + "Tue", + "Wed", }, + Style: style, Orient: OrientVertical, - Icon: IconRect, - Left: "10%", - }).Render() - if err != nil { - return nil, err - } - return p.Bytes() + }) }, - result: "\\nOneTwoThree", + box: chart.Box{ + Right: 101, + Bottom: 80, + }, + result: "\\nMonTueWed", }, } + for _, tt := range tests { - p, err := NewPainter(PainterOptions{ - Type: ChartOutputSVG, - Width: 600, - Height: 400, - }, PainterThemeOption(defaultTheme)) + d := tt.newDraw() + b, err := tt.newLegend(d).Render() assert.Nil(err) - data, err := tt.render(p) + assert.Equal(tt.box, b) + data, err := d.Bytes() assert.Nil(err) + assert.NotEmpty(data) assert.Equal(tt.result, string(data)) } } diff --git a/line.go b/line.go new file mode 100644 index 0000000..0fc25d6 --- /dev/null +++ b/line.go @@ -0,0 +1,103 @@ +// 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 fb1d16a..ac9091c 100644 --- a/line_chart.go +++ b/line_chart.go @@ -23,218 +23,109 @@ package charts import ( - "math" - "github.com/golang/freetype/truetype" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -type lineChart struct { - p *Painter - opt *LineChartOption -} - -// NewLineChart returns a line chart render -func NewLineChart(p *Painter, opt LineChartOption) *lineChart { - if opt.Theme == nil { - opt.Theme = defaultTheme - } - return &lineChart{ - p: p, - opt: &opt, - } -} - -type LineChartOption struct { - // The theme - Theme ColorPalette - // The font size - Font *truetype.Font - // The data series list +type lineChartOption struct { + Theme string 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 + Font *truetype.Font } -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 - } +func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPointRenderOption, error) { - seriesPainter := result.seriesPainter + theme := NewTheme(opt.Theme) - xDivideCount := len(opt.XAxis.Data) - if !boundaryGap { - xDivideCount-- + d, err := NewDraw(DrawOption{ + Parent: result.d, + }, PaddingOption(chart.Box{ + Top: result.titleBox.Height(), + Left: YAxisWidth, + })) + if err != nil { + return nil, err } - 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 + seriesNames := opt.SeriesList.Names() + + 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 } - } 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{ + 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(), StrokeColor: seriesColor, - StrokeWidth: strokeWidth, - } - if len(series.Style.StrokeDashArray) > 0 { - drawingStyle.StrokeDashArray = series.Style.StrokeDashArray - } + Font: opt.Font, + Series: &series, + Range: yRange, + }) - 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, - }) - 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 { + for j, item := range series.Data { + if j >= xRange.divideCount { continue } - labelPainter.Add(LabelValue{ - Index: index, - Value: item.Value, - X: p.X, - Y: p.Y, - // 字体大小 - FontSize: series.Label.FontSize, + y := yRange.getRestHeight(item.Value) + x := xRange.getWidth(float64(j)) + points = append(points, Point{ + Y: y, + X: x, }) - } - // 如果需要填充区域 - 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 + if !series.Label.Show { + continue } - 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) + 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) } - seriesPainter.SetDrawingStyle(drawingStyle) - // 画线 - seriesPainter.LineStroke(points) - - // 画点 - if opt.Theme.IsDark() { - drawingStyle.FillColor = drawingStyle.StrokeColor - } else { - drawingStyle.FillColor = drawing.ColorWhite + dotFillColor := drawing.ColorWhite + if theme.IsDark() { + dotFillColor = seriesColor } - drawingStyle.StrokeWidth = 1 - seriesPainter.SetDrawingStyle(drawingStyle) - if !isFalse(opt.SymbolShow) { - seriesPainter.Dots(points) - } - markPointPainter.Add(markPointRenderOption{ + d.Line(points, LineStyle{ + StrokeColor: seriesColor, + StrokeWidth: 2, + DotColor: seriesColor, + DotWidth: defaultDotWidth, + DotFillColor: dotFillColor, + }) + // draw mark point + markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{ + Draw: d, FillColor: seriesColor, Font: opt.Font, Points: points, - Series: series, - }) - markLinePainter.Add(markLineRenderOption{ - FillColor: seriesColor, - FontColor: opt.Theme.GetTextColor(), - StrokeColor: seriesColor, - Font: opt.Font, - Series: series, - Range: yRange, + Series: &series, }) } - // 最大、最小的mark point - err := doRender(rendererList...) - if err != nil { - return BoxZero, err - } - 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) + return markPointRenderOptions, nil } diff --git a/line_chart_test.go b/line_chart_test.go index e169f90..9f5d9af 100644 --- a/line_chart_test.go +++ b/line_chart_test.go @@ -26,194 +26,72 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestLineChart(t *testing.T) { +func TestLineChartRender(t *testing.T) { assert := assert.New(t) - 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", - }, - } + width := 400 + height := 300 + d, err := NewDraw(DrawOption{ + Width: width, + Height: height, + }) + assert.Nil(err) - 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)) + result := basicRenderResult{ + xRange: &Range{ + Min: 0, + Max: 4, + divideCount: 4, + Size: width, + Boundary: true, + }, + yRangeList: []*Range{ + { + divideCount: 6, + Max: 100, + Min: 0, + Size: height, + }, + }, + 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)) } diff --git a/line_test.go b/line_test.go new file mode 100644 index 0000000..e10b806 --- /dev/null +++ b/line_test.go @@ -0,0 +1,165 @@ +// 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 bc850bb..464fe71 100644 --- a/mark_line.go +++ b/mark_line.go @@ -24,9 +24,10 @@ 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 { @@ -39,75 +40,53 @@ 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 { - FillColor Color - FontColor Color - StrokeColor Color + Draw *Draw + FillColor drawing.Color + FontColor drawing.Color + StrokeColor drawing.Color Font *truetype.Font - Series Series - Range axisRange + Series *Series + Range *Range } -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) - } +func markLineRender(opt markLineRenderOption) { + d := opt.Draw + s := opt.Series + if len(s.MarkLine.Data) == 0 { + return } - return BoxZero, nil + r := d.Render + summary := s.Summary() + for _, markLine := range s.MarkLine.Data { + // 由于mark line会修改style,因此每次重新设置 + chart.Style{ + FillColor: opt.FillColor, + FontColor: opt.FontColor, + FontSize: labelFontSize, + StrokeColor: opt.StrokeColor, + StrokeWidth: 1, + Font: opt.Font, + StrokeDashArray: []float64{ + 4, + 2, + }, + }.WriteToRenderer(r) + value := float64(0) + switch markLine.Type { + case SeriesMarkDataTypeMax: + value = summary.MaxValue + case SeriesMarkDataTypeMin: + value = summary.MinValue + default: + value = summary.AverageValue + } + y := opt.Range.getRestHeight(value) + width := d.Box.Width() + text := commafWithDigits(value) + textBox := r.MeasureText(text) + d.makeLine(0, y, width-2) + d.text(text, width, y+textBox.Height()>>1-2) + } + } diff --git a/mark_line_test.go b/mark_line_test.go index 0448cda..abb3308 100644 --- a/mark_line_test.go +++ b/mark_line_test.go @@ -26,65 +26,74 @@ import ( "testing" "github.com/stretchr/testify/assert" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestMarkLine(t *testing.T) { +func TestNewMarkLine(t *testing.T) { assert := assert.New(t) - 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() + markLine := NewMarkLine( + SeriesMarkDataTypeMax, + SeriesMarkDataTypeMin, + SeriesMarkDataTypeAverage, + ) + + assert.Equal(SeriesMarkLine{ + Data: []SeriesMarkData{ + { + Type: SeriesMarkDataTypeMax, + }, + { + Type: SeriesMarkDataTypeMin, + }, + { + Type: SeriesMarkDataTypeAverage, }, - result: "\\n321", }, - } - 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)) - } + }, 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)) } diff --git a/mark_point.go b/mark_point.go index fd8a88b..5fd34c4 100644 --- a/mark_point.go +++ b/mark_point.go @@ -24,9 +24,10 @@ 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 { @@ -39,77 +40,50 @@ 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 { - FillColor Color + Draw *Draw + FillColor drawing.Color Font *truetype.Font - Series Series + Series *Series Points []Point } -// NewMarkPointPainter returns a mark point renderer -func NewMarkPointPainter(p *Painter) *markPointPainter { - return &markPointPainter{ - p: p, - options: make([]markPointRenderOption, 0), +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) } } - -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 298345b..2cd8fdd 100644 --- a/mark_point_test.go +++ b/mark_point_test.go @@ -26,67 +26,78 @@ import ( "testing" "github.com/stretchr/testify/assert" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestMarkPoint(t *testing.T) { +func TestNewMarkPoint(t *testing.T) { assert := assert.New(t) - 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 := NewMarkPoint( + SeriesMarkDataTypeMax, + SeriesMarkDataTypeMin, + SeriesMarkDataTypeAverage, + ) - 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)) - } + assert.Equal(SeriesMarkPoint{ + Data: []SeriesMarkData{ + { + Type: SeriesMarkDataTypeMax, + }, + { + Type: SeriesMarkDataTypeMin, + }, + { + Type: SeriesMarkDataTypeAverage, + }, + }, + }, 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)) } diff --git a/painter.go b/painter.go deleted file mode 100644 index bee646f..0000000 --- a/painter.go +++ /dev/null @@ -1,866 +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" - "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/pie_chart.go b/pie_chart.go index 5c04ed8..15c0d35 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -27,138 +27,38 @@ import ( "math" "github.com/golang/freetype/truetype" - "git.smarteching.com/zeni/go-chart/v2" + "github.com/wcharczuk/go-chart/v2" ) -type pieChart struct { - p *Painter - opt *PieChartOption +func getPieStyle(theme *Theme, index int) chart.Style { + seriesColor := theme.GetSeriesColor(index) + return chart.Style{ + StrokeColor: seriesColor, + StrokeWidth: 1, + FillColor: seriesColor, + } } -type PieChartOption struct { - // The theme - Theme ColorPalette - // The font size - Font *truetype.Font - // The data series list +type pieChartOption struct { + Theme string + Font *truetype.Font 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 } -// NewPieChart returns a pie chart renderer -func NewPieChart(p *Painter, opt PieChartOption) *pieChart { - if opt.Theme == nil { - opt.Theme = defaultTheme +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 } - return &pieChart{ - p: p, - opt: &opt, - } -} -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)) + values := make([]float64, len(opt.SeriesList)) total := float64(0) radiusValue := "" - for index, series := range seriesList { + for index, series := range opt.SeriesList { if len(series.Radius) != 0 { radiusValue = series.Radius } @@ -170,13 +70,16 @@ func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (B total += value } if total <= 0 { - return BoxZero, errors.New("The sum value of pie chart should gt 0") + return errors.New("The sum value of pie chart should gt 0") } - seriesPainter := result.seriesPainter - cx := seriesPainter.Width() >> 1 - cy := seriesPainter.Height() >> 1 + r := d.Render + theme := NewTheme(opt.Theme) - diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height()) + box := d.Box + cx := box.Width() >> 1 + cy := box.Height() >> 1 + + diameter := chart.MinInt(box.Width(), box.Height()) radius := getRadius(float64(diameter), radiusValue) labelLineWidth := 15 @@ -184,135 +87,83 @@ func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (B labelLineWidth = 10 } labelRadius := radius + float64(labelLineWidth) - seriesNames := opt.Legend.Data - if len(seriesNames) == 0 { - seriesNames = seriesList.Names() - } - theme := opt.Theme - currentValue := float64(0) + seriesNames := opt.SeriesList.Names() - 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...) + 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 { - 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 + 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 } - if s.quadrant == 2 { - if currentQuadrant != 3 { - prevY = s.lineEndY - } else { - prevY = minY - } + + // 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) } - if s.quadrant == 3 { - if currentQuadrant != 4 { - prevY = s.lineEndY - } else { - minY = cy * 2 - maxY = 0 - prevY = 0 - } + prevEndX = endx + prevEndY = endy + d.moveTo(startx, starty) + d.lineTo(endx, endy) + offset := labelLineWidth + if endx < cx { + offset *= -1 } - if s.quadrant == 4 { - if currentQuadrant != 1 { - prevY = s.lineEndY - } else { - prevY = maxY - } + d.moveTo(endx, endy) + endx += offset + d.lineTo(endx, endy) + r.Stroke() + textStyle := chart.Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, } - currentQuadrant = s.quadrant + 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) } - 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) + return nil } diff --git a/pie_chart_test.go b/pie_chart_test.go index 3795d32..84072be 100644 --- a/pie_chart_test.go +++ b/pie_chart_test.go @@ -23,511 +23,47 @@ package charts import ( - "strconv" "testing" "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestPieChart(t *testing.T) { +func TestPieChartRender(t *testing.T) { assert := assert.New(t) - 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() + 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", }, - 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() + Label: SeriesLabel{ + Show: true, + Color: drawing.ColorRed, }, - 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)) - } + 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)) } diff --git a/radar_chart.go b/radar_chart.go index cf18135..364213d 100644 --- a/radar_chart.go +++ b/radar_chart.go @@ -25,17 +25,11 @@ package charts import ( "errors" - "github.com/dustin/go-humanize" "github.com/golang/freetype/truetype" - "git.smarteching.com/zeni/go-chart/v2" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -type radarChart struct { - p *Painter - opt *RadarChartOption -} - type RadarIndicator struct { // Indicator's name Name string @@ -45,118 +39,89 @@ type RadarIndicator struct { Min float64 } -type RadarChartOption struct { - // The theme - Theme ColorPalette - // The font size - Font *truetype.Font - // The data series list +type radarChartOption struct { + Theme string + Font *truetype.Font SeriesList SeriesList - // The padding of line chart - Padding Box - // The option of title - Title TitleOption - // The legend option - Legend LegendOption - // The radar indicator list - RadarIndicators []RadarIndicator - // background is filled - backgroundIsFilled bool + Indicators []RadarIndicator } -// NewRadarIndicators returns a radar indicator list -func NewRadarIndicators(names []string, values []float64) []RadarIndicator { - if len(names) != len(values) { - return nil - } - 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) +func radarChartRender(opt radarChartOption, result *basicRenderResult) error { + sides := len(opt.Indicators) if sides < 3 { - return BoxZero, errors.New("The count of indicator should be >= 3") + return errors.New("The count of indicator should be >= 3") } - maxValues := make([]float64, len(indicators)) - for _, series := range seriesList { + maxValues := make([]float64, len(opt.Indicators)) + for _, series := range opt.SeriesList { for index, item := range series.Data { if index < len(maxValues) && item.Value > maxValues[index] { maxValues[index] = item.Value } } } - for index, indicator := range indicators { + for index, indicator := range opt.Indicators { if indicator.Max <= 0 { - indicators[index].Max = maxValues[index] + opt.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 seriesList { + for _, series := range opt.SeriesList { if len(series.Radius) != 0 { radiusValue = series.Radius } } - seriesPainter := result.seriesPainter - theme := opt.Theme - - cx := seriesPainter.Width() >> 1 - cy := seriesPainter.Height() >> 1 - diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height()) + box := d.Box + cx := box.Width() >> 1 + cy := box.Height() >> 1 + diameter := chart.MinInt(box.Width(), box.Height()) radius := getRadius(float64(diameter), radiusValue) + theme := NewTheme(opt.Theme) + divideCount := 5 divideRadius := float64(int(radius / float64(divideCount))) radius = divideRadius * float64(divideCount) - seriesPainter.OverrideDrawingStyle(Style{ + style := chart.Style{ StrokeColor: theme.GetAxisSplitLineColor(), StrokeWidth: 1, - }) + } + r := d.Render + style.WriteToRenderer(r) center := Point{ X: cx, Y: cy, } for i := 0; i < divideCount; i++ { - seriesPainter.Polygon(center, divideRadius*float64(i+1), sides) + d.polygon(center, divideRadius*float64(i+1), sides) } points := getPolygonPoints(center, radius, sides) for _, p := range points { - seriesPainter.MoveTo(center.X, center.Y) - seriesPainter.LineTo(p.X, p.Y) - seriesPainter.Stroke() + d.moveTo(center.X, center.Y) + d.lineTo(p.X, p.Y) + d.Render.Stroke() } - seriesPainter.OverrideTextStyle(Style{ + // 文本 + textStyle := chart.Style{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, - }) + } + textStyle.GetTextOptions().WriteToRenderer(r) offset := 5 // 文本生成 for index, p := range points { - name := indicators[index].Name - b := seriesPainter.MeasureText(name) + name := opt.Indicators[index].Name + b := r.MeasureText(name) isXCenter := p.X == center.X isYCenter := p.Y == center.Y isRight := p.X > center.X @@ -188,24 +153,20 @@ func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) if isLeft { x -= (b.Width() + offset) } - seriesPainter.Text(name, x, y) + d.text(name, x, y) } // 雷达图 angles := getPolygonPointAngles(sides) - maxCount := len(indicators) - for _, series := range seriesList { + maxCount := len(opt.Indicators) + for _, series := range opt.SeriesList { linePoints := make([]Point, 0, maxCount) for j, item := range series.Data { if j >= maxCount { continue } - indicator := indicators[j] - var percent float64 - offset := indicator.Max - indicator.Min - if offset > 0 { - percent = (item.Value - indicator.Min) / offset - } + indicator := opt.Indicators[j] + percent := (item.Value - indicator.Min) / (indicator.Max - indicator.Min) r := percent * radius p := getPolygonPoint(center, r, angles[j]) linePoints = append(linePoints, p) @@ -216,58 +177,17 @@ func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) dotFillColor = color } linePoints = append(linePoints, linePoints[0]) - seriesPainter.OverrideDrawingStyle(Style{ - StrokeColor: color, - StrokeWidth: defaultStrokeWidth, - DotWidth: defaultDotWidth, - DotColor: color, - FillColor: color.WithAlpha(20), - }) - seriesPainter.LineStroke(linePoints). - FillArea(linePoints) - dotWith := 2.0 - seriesPainter.OverrideDrawingStyle(Style{ - StrokeWidth: defaultStrokeWidth, - StrokeColor: color, - FillColor: dotFillColor, - }) - for 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) - } - + s := LineStyle{ + StrokeColor: color, + StrokeWidth: defaultStrokeWidth, + DotWidth: defaultDotWidth, + DotColor: color, + DotFillColor: dotFillColor, + FillColor: color.WithAlpha(20), } + d.lineStroke(linePoints, s) + d.fill(linePoints, s.Style()) + d.lineDot(linePoints[0:len(linePoints)-1], s) } - - 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) + return nil } diff --git a/radar_chart_test.go b/radar_chart_test.go index 79fd9ac..c5d2aa9 100644 --- a/radar_chart_test.go +++ b/radar_chart_test.go @@ -26,82 +26,77 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" ) -func TestRadarChart(t *testing.T) { +func TestRadarChartRender(t *testing.T) { assert := assert.New(t) - 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() + 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, }, - result: "\\nAllocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", }, - } - 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)) - } + 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)) } diff --git a/range.go b/range.go index ec64c2d..255a51b 100644 --- a/range.go +++ b/range.go @@ -26,46 +26,19 @@ import ( "math" ) -const defaultAxisDivideCount = 6 - -type axisRange struct { - p *Painter +type Range struct { divideCount int - min float64 - max float64 - size int - boundary bool + Min float64 + Max float64 + Size int + Boundary bool } -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 +func NewRange(min, max float64, divideCount int) Range { r := math.Abs(max - min) // 最小单位计算 - unit := 1 - if r > 5 { - unit = 2 - } + unit := 2 if r > 10 { unit = 4 } @@ -90,55 +63,47 @@ func NewRange(opt AxisRangeOption) axisRange { } } max = min + float64(unit*divideCount) - expectMax := opt.Max * 2 - if max > expectMax { - max = float64(ceilFloatToInt(expectMax)) - } - return axisRange{ - p: opt.Painter, + return Range{ + Min: min, + Max: max, divideCount: divideCount, - min: min, - max: max, - size: opt.Size, - boundary: opt.Boundary, } } -// Values returns values of range -func (r axisRange) Values() []string { - offset := (r.max - r.min) / float64(r.divideCount) +func (r Range) 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 := formatter(v) + v := r.Min + float64(i)*offset + value := commafWithDigits(v) values = append(values, value) } return values } -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) getHeight(value float64) int { + v := (value - r.Min) / (r.Max - r.Min) + return int(v * float64(r.Size)) } -func (r *axisRange) getRestHeight(value float64) int { - return r.size - r.getHeight(value) +func (r *Range) getRestHeight(value float64) int { + return r.Size - r.getHeight(value) } -// GetRange returns a range of index -func (r *axisRange) GetRange(index int) (float64, float64) { - unit := float64(r.size) / float64(r.divideCount) +func (r *Range) GetRange(index int) (float64, float64) { + unit := float64(r.Size) / float64(r.divideCount) return unit * float64(index), unit * float64(index+1) } - -// AutoDivide divides the axis -func (r *axisRange) AutoDivide() []int { - return autoDivide(r.size, r.divideCount) +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)) } diff --git a/range_test.go b/range_test.go new file mode 100644 index 0000000..d1aea8f --- /dev/null +++ b/range_test.go @@ -0,0 +1,94 @@ +// 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 da50e64..14227d1 100644 --- a/series.go +++ b/series.go @@ -19,6 +19,7 @@ // 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 ( @@ -26,26 +27,17 @@ import ( "strings" "github.com/dustin/go-humanize" - "git.smarteching.com/zeni/go-chart/v2" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) type SeriesData struct { // The value of series data Value float64 // The style of series data - Style Style + Style chart.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), @@ -56,7 +48,6 @@ 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 { @@ -74,17 +65,11 @@ type SeriesLabel struct { // {d}: the percent of a data item(pie chart). Formatter string // The color for label - Color Color + Color drawing.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 ( @@ -116,8 +101,8 @@ type Series struct { // The data list of series Data []SeriesData // The Y axis index, it should be 0 or 1. - // Default value is 0 - AxisIndex int + // Default value is 1 + YAxisIndex int // The style for series Style chart.Style // The label for series @@ -126,8 +111,6 @@ 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 @@ -139,55 +122,6 @@ 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 @@ -222,19 +156,13 @@ func NewPieSeriesList(values []float64, opts ...PieSeriesOption) SeriesList { } type seriesSummary struct { - // 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 + MaxIndex int + MaxValue float64 + MinIndex int + MinValue float64 AverageValue float64 } -// Summary get summary of series func (s *Series) Summary() seriesSummary { minIndex := -1 maxIndex := -1 @@ -261,7 +189,6 @@ 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 { @@ -270,10 +197,8 @@ 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}" @@ -281,23 +206,13 @@ func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter { return NewLabelFormatter(seriesNames, layout) } -// 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 { +func NewValueLabelFormater(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 deleted file mode 100644 index af873fc..0000000 --- a/series_label.go +++ /dev/null @@ -1,148 +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/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 40d2f91..1460180 100644 --- a/series_test.go +++ b/series_test.go @@ -19,6 +19,7 @@ // 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,63 +28,139 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewSeriesListDataFromValues(t *testing.T) { +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) { assert := assert.New(t) assert.Equal(SeriesList{ { - Type: ChartTypeBar, + Type: ChartTypePie, + Name: "a", + Label: SeriesLabel{ + Show: true, + }, + Radius: "30%", Data: []SeriesData{ { - Value: 1.0, + Value: 1, }, }, }, - }, NewSeriesListDataFromValues([][]float64{ { - 1, + Type: ChartTypePie, + Name: "b", + Label: SeriesLabel{ + Show: true, + }, + Radius: "30%", + Data: []SeriesData{ + { + Value: 2, + }, + }, }, - }, ChartTypeBar)) + }, NewPieSeriesList([]float64{ + 1, + 2, + }, PieSeriesOption{ + Radius: "30%", + Label: SeriesLabel{ + Show: true, + }, + Names: []string{ + "a", + "b", + }, + })) } -func TestSeriesLists(t *testing.T) { +func TestSeriesSummary(t *testing.T) { assert := assert.New(t) - seriesList := NewSeriesListDataFromValues([][]float64{ - { + + s := Series{ + Data: NewSeriesDataFromValues([]float64{ 1, - 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) - + 3, + 5, + 7, + 9, + }), + } assert.Equal(seriesSummary{ - MaxIndex: 1, - MaxValue: 2, + MaxIndex: 4, + MaxValue: 9, MinIndex: 0, MinValue: 1, - AverageValue: 1.5, - }, seriesList[0].Summary()) + AverageValue: 5, + }, s.Summary()) } -func TestFormatter(t *testing.T) { +func TestGetSeriesNames(t *testing.T) { assert := assert.New(t) - assert.Equal("a: 12%", NewPieLabelFormatter([]string{ + sl := SeriesList{ + { + Name: "a", + }, + { + Name: "b", + }, + } + assert.Equal([]string{ "a", "b", - }, "")(0, 10, 0.12)) - - assert.Equal("10", NewValueLabelFormatter([]string{ - "a", - "b", - }, "")(0, 10, 0.12)) + }, sl.Names()) +} + +func TestNewPieLabelFormatter(t *testing.T) { + assert := assert.New(t) + + fn := NewPieLabelFormatter([]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)) } diff --git a/start_zh.md b/start_zh.md deleted file mode 100644 index ee8359c..0000000 --- a/start_zh.md +++ /dev/null @@ -1,254 +0,0 @@ -# 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 3e6f273..9cfc6b1 100644 --- a/table.go +++ b/table.go @@ -25,414 +25,121 @@ package charts import ( "errors" - "github.com/golang/freetype/truetype" - "git.smarteching.com/zeni/go-chart/v2" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" ) -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 +type TableOption struct { + // draw + Draw *Draw // The width of table Width int - // The theme - Theme ColorPalette - // The padding of table cell - Padding Box - // The header data of table + // The header of table Header []string - // 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 + // The style of table + Style chart.Style + ColumnWidths []float64 + // 是否仅测量高度 + measurement bool } -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 -} +var ErrTableColumnWidthInvalid = errors.New("table column width is invalid") -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 tableDivideWidth(width, size int, columnWidths []float64) ([]int, error) { + widths := make([]int, size) -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 nil, errors.New("header can not be nil") - } - 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 - } - - 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 + 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 { - newSpans[index] = spans[index] + 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 } } - 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) + widthSum := 0 + for _, v := range widths { + widthSum += v } - info.ColumnWidths = columnWidths - - height := 0 - textStyle := Style{ - FontSize: fontSize, - FontColor: headerFontColor, - FillColor: headerFontColor, - Font: font, + if widthSum > width { + return nil, ErrTableColumnWidthInvalid } - - 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 + return widths, 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() +func TableMeasure(opt TableOption) (chart.Box, error) { + d, err := NewDraw(DrawOption{ + Width: opt.Width, + Height: 600, + }) if err != nil { - return BoxZero, err + return chart.BoxZero, err + } + opt.Draw = d + opt.measurement = true + return tableRender(opt) +} + +func tableRender(opt TableOption) (chart.Box, error) { + if opt.Draw == nil { + return chart.BoxZero, errors.New("draw can not be nil") + } + if len(opt.Header) == 0 { + return chart.BoxZero, errors.New("header can not be nil") + } + width := opt.Width + if width == 0 { + width = opt.Draw.Box.Width() } - return Box{ - Right: info.Width, - Bottom: info.Height, + columnWidths, err := tableDivideWidth(width, len(opt.Header), opt.ColumnWidths) + if err != nil { + return chart.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, }, 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 deleted file mode 100644 index a958c95..0000000 --- a/table_test.go +++ /dev/null @@ -1,140 +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 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 85016a5..e3f9773 100644 --- a/theme.go +++ b/theme.go @@ -23,8 +23,7 @@ package charts import ( - "github.com/golang/freetype/truetype" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2/drawing" ) const ThemeDark = "dark" @@ -32,65 +31,23 @@ const ThemeLight = "light" const ThemeGrafana = "grafana" const ThemeAnt = "ant" -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 Theme struct { + palette *themeColorPalette } type themeColorPalette struct { isDarkMode bool - 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 + axisStrokeColor drawing.Color + axisSplitLineColor drawing.Color + backgroundColor drawing.Color + textColor drawing.Color + seriesColors []drawing.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 := []Color{ + echartSeriesColors := []drawing.Color{ parseColor("#5470c6"), parseColor("#91cc75"), parseColor("#fac858"), @@ -101,7 +58,7 @@ func init() { parseColor("#9a60b4"), parseColor("#ea7ccc"), } - grafanaSeriesColors := []Color{ + grafanaSeriesColors := []drawing.Color{ parseColor("#7EB26D"), parseColor("#EAB839"), parseColor("#6ED0E0"), @@ -111,7 +68,7 @@ func init() { parseColor("#705DA0"), parseColor("#508642"), } - antSeriesColors := []Color{ + antSeriesColors := []drawing.Color{ parseColor("#5b8ff9"), parseColor("#5ad8a6"), parseColor("#5d7092"), @@ -123,210 +80,155 @@ func init() { } AddTheme( ThemeDark, - 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, + true, + drawing.Color{ + R: 185, + G: 184, + B: 206, + A: 255, }, + 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, - 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, + false, + drawing.Color{ + R: 110, + G: 112, + B: 121, + A: 255, }, + 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, - 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, + false, + drawing.Color{ + R: 110, + G: 112, + B: 121, + A: 255, }, + 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, - 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, + true, + drawing.Color{ + R: 185, + G: 184, + B: 206, + A: 255, }, + 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) } -// SetDefaultTheme sets default theme -func SetDefaultTheme(name string) { - defaultTheme = NewTheme(name) -} - -func AddTheme(name string, opt ThemeOption) { +func AddTheme(name string, isDarkMode bool, axisStrokeColor, axisSplitLineColor, backgroundColor, textColor drawing.Color, seriesColors []drawing.Color) { palettes[name] = &themeColorPalette{ - isDarkMode: opt.IsDarkMode, - axisStrokeColor: opt.AxisStrokeColor, - axisSplitLineColor: opt.AxisSplitLineColor, - backgroundColor: opt.BackgroundColor, - textColor: opt.TextColor, - seriesColors: opt.SeriesColors, + isDarkMode: isDarkMode, + axisStrokeColor: axisStrokeColor, + axisSplitLineColor: axisSplitLineColor, + backgroundColor: backgroundColor, + textColor: textColor, + seriesColors: seriesColors, } } -func NewTheme(name string) ColorPalette { +func NewTheme(name string) *Theme { p, ok := palettes[name] if !ok { p = palettes[ThemeLight] } - clone := *p - return &clone + return &Theme{ + palette: p, + } } -func (t *themeColorPalette) IsDark() bool { - return t.isDarkMode +func (t *Theme) IsDark() bool { + return t.palette.isDarkMode } -func (t *themeColorPalette) GetAxisStrokeColor() Color { - return t.axisStrokeColor +func (t *Theme) GetAxisStrokeColor() drawing.Color { + return t.palette.axisStrokeColor } -func (t *themeColorPalette) SetAxisStrokeColor(c Color) { - t.axisStrokeColor = c +func (t *Theme) GetAxisSplitLineColor() drawing.Color { + return t.palette.axisSplitLineColor } -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 +func (t *Theme) GetSeriesColor(index int) drawing.Color { + colors := t.palette.seriesColors return colors[index%len(colors)] } -func (t *themeColorPalette) SetSeriesColor(colors []Color) { - t.seriesColors = colors + +func (t *Theme) GetBackgroundColor() drawing.Color { + return t.palette.backgroundColor } -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 +func (t *Theme) GetTextColor() drawing.Color { + return t.palette.textColor } diff --git a/theme_test.go b/theme_test.go new file mode 100644 index 0000000..bf22afd --- /dev/null +++ b/theme_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" + "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 74ab4f9..07a2eef 100644 --- a/title.go +++ b/title.go @@ -26,16 +26,18 @@ import ( "strconv" "strings" - "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" ) 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. @@ -43,23 +45,12 @@ 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 Style + style chart.Style } func splitTitleText(text string) []string { @@ -75,78 +66,44 @@ func splitTitleText(text string) []string { return result } -type titlePainter struct { - p *Painter - opt *TitleOption -} - -// NewTitlePainter returns a title renderer -func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter { - return &titlePainter{ - p: p, - opt: &opt, +func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) { + if len(opt.Text) == 0 { + return chart.BoxZero, nil } -} -func (t *titlePainter) Render() (Box, error) { - opt := t.opt - p := t.p - theme := opt.Theme + padding := opt.Style.Padding + d, err := NewDraw(DrawOption{ + Parent: p, + }, PaddingOption(padding)) + if err != nil { + return chart.BoxZero, err + } - if theme == nil { - theme = p.theme - } - if opt.Text == "" && opt.Subtext == "" { - return BoxZero, nil - } + r := d.Render measureOptions := make([]titleMeasureOption, 0) - if opt.Font == nil { - opt.Font = theme.GetFont() - } - if opt.FontColor.IsZero() { - opt.FontColor = theme.GetTextColor() - } - if opt.FontSize == 0 { - opt.FontSize = theme.GetFontSize() - } - if opt.SubtextFontColor.IsZero() { - opt.SubtextFontColor = opt.FontColor - } - if opt.SubtextFontSize == 0 { - opt.SubtextFontSize = opt.FontSize - } - - titleTextStyle := Style{ - Font: opt.Font, - FontSize: opt.FontSize, - FontColor: opt.FontColor, - } // 主标题 for _, v := range splitTitleText(opt.Text) { measureOptions = append(measureOptions, titleMeasureOption{ text: v, - style: titleTextStyle, + style: opt.Style.GetTextOptions(), }) } - subtextStyle := Style{ - Font: opt.Font, - FontSize: opt.SubtextFontSize, - FontColor: opt.SubtextFontColor, - } // 副标题 for _, v := range splitTitleText(opt.Subtext) { measureOptions = append(measureOptions, titleMeasureOption{ text: v, - style: subtextStyle, + style: opt.SubtextStyle.GetTextOptions(), }) } + textMaxWidth := 0 textMaxHeight := 0 + width := 0 for index, item := range measureOptions { - p.OverrideTextStyle(item.style) - textBox := p.MeasureText(item.text) + item.style.WriteTextOptionsToRenderer(r) + textBox := r.MeasureText(item.text) w := textBox.Width() h := textBox.Height() @@ -159,18 +116,18 @@ func (t *titlePainter) Render() (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 = p.Width() - textMaxWidth + titleX = b.Width() - textMaxWidth case PositionCenter: - titleX = p.Width()>>1 - (textMaxWidth >> 1) + titleX = b.Width()>>1 - (textMaxWidth >> 1) default: if strings.HasSuffix(opt.Left, "%") { value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) - titleX = p.Width() * value / 100 + titleX = b.Width() * value / 100 } else { value, _ := strconv.Atoi(opt.Left) titleX = value @@ -183,15 +140,16 @@ func (t *titlePainter) Render() (Box, error) { titleY += value } for _, item := range measureOptions { - p.OverrideTextStyle(item.style) + item.style.WriteTextOptionsToRenderer(r) x := titleX + (textMaxWidth-item.width)>>1 y := titleY + item.height - p.Text(item.text, x, y) + d.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{ - Bottom: titleY, - Right: titleX + width, - }, nil + return box, nil } diff --git a/title_test.go b/title_test.go index add8163..23573c3 100644 --- a/title_test.go +++ b/title_test.go @@ -26,68 +26,117 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestTitleRenderer(t *testing.T) { +func TestSplitTitleText(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 { - render func(*Painter) ([]byte, error) - result string + newDraw func() *Draw + newOption func() *TitleOption + result string + box chart.Box }{ { - 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() + newDraw: newDraw, + newOption: newOption, + result: "\\ntitleHellosubtitleWorld!", + box: chart.Box{ + Right: 43, + Bottom: 58, }, - result: "\\ntitlesubTitle", }, { - 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() + newDraw: newDraw, + newOption: func() *TitleOption { + opt := newOption() + opt.Left = PositionRight + opt.Top = "50" + return opt + }, + result: "\\ntitleHellosubtitleWorld!", + box: chart.Box{ + Right: 400, + Bottom: 108, }, - result: "\\ntitlesubTitle", }, { - 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() + 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, }, - result: "\\ntitlesubTitle", }, } for _, tt := range tests { - p, err := NewPainter(PainterOptions{ - Type: ChartOutputSVG, - Width: 600, - Height: 400, - }, PainterThemeOption(defaultTheme)) + d := tt.newDraw() + o := tt.newOption() + b, err := drawTitle(d, o) assert.Nil(err) - data, err := tt.render(p) + assert.Equal(tt.box, b) + data, err := d.Bytes() assert.Nil(err) + assert.NotEmpty(data) assert.Equal(tt.result, string(data)) } } diff --git a/util.go b/util.go index 87ff31c..c895cc3 100644 --- a/util.go +++ b/util.go @@ -29,8 +29,8 @@ import ( "strings" "github.com/dustin/go-humanize" - "git.smarteching.com/zeni/go-chart/v2" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) func TrueFlag() *bool { @@ -43,24 +43,6 @@ 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) { @@ -77,49 +59,28 @@ func getDefaultInt(value, defaultValue int) int { } func autoDivide(max, size int) []int { - unit := float64(max) / float64(size) + unit := max / size + rest := max - unit*size values := make([]int, size+1) - for i := 0; i < size+1; i++ { - if i == size { - values[i] = max - } else { - values[i] = int(float64(i) * unit) + value := 0 + for i := 0; i < size; i++ { + values[i] = value + if i < rest { + value++ } + 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, p *Painter) (int, int) { +func measureTextMaxWidthHeight(textList []string, r chart.Renderer) (int, int) { maxWidth := 0 maxHeight := 0 for _, text := range textList { - box := p.MeasureText(text) + box := r.MeasureText(text) maxWidth = chart.MaxInt(maxWidth, box.Width()) maxHeight = chart.MaxInt(maxHeight, box.Height()) } @@ -160,31 +121,21 @@ 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 - if value >= T_VALUE { - return humanize.CommafWithDigits(value/T_VALUE, decimals) + "T" + m := float64(1000 * 1000) + if value >= m { + return humanize.CommafWithDigits(value/m, decimals) + "M" } - 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" + k := float64(1000) + if value >= k { + return humanize.CommafWithDigits(value/k, decimals) + "k" } return humanize.CommafWithDigits(value, decimals) } -func parseColor(color string) Color { - c := Color{} +func parseColor(color string) drawing.Color { + c := drawing.Color{} if color == "" { return c } @@ -262,10 +213,3 @@ 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 5770776..6489ab3 100644 --- a/util_test.go +++ b/util_test.go @@ -26,8 +26,8 @@ import ( "testing" "github.com/stretchr/testify/assert" - "git.smarteching.com/zeni/go-chart/v2" - "git.smarteching.com/zeni/go-chart/v2/drawing" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) func TestGetDefaultInt(t *testing.T) { @@ -60,12 +60,12 @@ func TestAutoDivide(t *testing.T) { assert.Equal([]int{ 0, - 85, - 171, - 257, - 342, - 428, - 514, + 86, + 172, + 258, + 344, + 430, + 515, 600, }, autoDivide(600, 7)) } @@ -80,15 +80,13 @@ func TestGetRadius(t *testing.T) { func TestMeasureTextMaxWidthHeight(t *testing.T) { assert := assert.New(t) - p, err := NewPainter(PainterOptions{ - Width: 400, - Height: 300, - }) + r, err := chart.SVG(400, 300) assert.Nil(err) style := chart.Style{ FontSize: 10, } - p.SetStyle(style) + style.Font, _ = chart.GetDefaultFont() + style.WriteToRenderer(r) maxWidth, maxHeight := measureTextMaxWidthHeight([]string{ "Mon", @@ -98,8 +96,8 @@ func TestMeasureTextMaxWidthHeight(t *testing.T) { "Fri", "Sat", "Sun", - }, p) - assert.Equal(31, maxWidth) + }, r) + assert.Equal(26, maxWidth) assert.Equal(12, maxHeight) } @@ -189,35 +187,3 @@ 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 61698d7..edd017f 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,31 +35,13 @@ type XAxisOption struct { // The data value of x axis Data []string // The theme of chart - 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 + Theme string + // Hidden x axis + Hidden 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, @@ -70,36 +52,51 @@ func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption { return opt } -func (opt *XAxisOption) ToAxisOption() AxisOption { - position := PositionBottom - if opt.Position == PositionTop { - position = PositionTop +// 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 } - 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, + 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 } - if opt.isValueAxis { - axisOpt.SplitLineShow = true - axisOpt.StrokeWidth = -1 - axisOpt.BoundaryGap = FalseFlag() + if err != nil { + return 0, nil, err + } + theme := NewTheme(opt.Theme) + data := NewAxisDataListFromStringList(opt.Data) + style := AxisOption{ + BoundaryGap: opt.BoundaryGap, + StrokeColor: theme.GetAxisStrokeColor(), + FontColor: theme.GetAxisStrokeColor(), + StrokeWidth: 1, + SplitNumber: opt.SplitNumber, } - return axisOpt -} -// NewBottomXAxis returns a bottom x axis renderer -func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter { - return NewAxisPainter(p, opt.ToAxisOption()) + 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 } diff --git a/xaxis_test.go b/xaxis_test.go new file mode 100644 index 0000000..267cdb1 --- /dev/null +++ b/xaxis_test.go @@ -0,0 +1,108 @@ +// 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 e58b7a6..a14e409 100644 --- a/yaxis.go +++ b/yaxis.go @@ -22,107 +22,84 @@ package charts -import "github.com/golang/freetype/truetype" +import ( + "strings" + + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) type YAxisOption struct { // The minimun value of axis. Min *float64 // The maximum value of axis. Max *float64 - // The font of y axis - Font *truetype.Font - // The data value of x axis - 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 + // Hidden y axis + Hidden bool // Formatter for y axis text value Formatter string // Color for y axis - Color Color - // The flag for show axis, set this to *false will hide axis - Show *bool - DivideCount int - Unit int - isCategoryAxis bool - // The flag for show axis split line, set this to true will show axis split line - SplitLineShow *bool + Color drawing.Color } -// 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 -} +// TODO 长度是否可以变化 +const YAxisWidth = 40 -func (opt *YAxisOption) ToAxisOption(p *Painter) AxisOption { - position := PositionLeft - if opt.Position == PositionRight { - position = PositionRight +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) + } } - 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, + + data := NewAxisDataListFromStringList(values) + style := AxisOption{ + Position: PositionLeft, BoundaryGap: FalseFlag(), - SplitLineShow: true, + FontColor: theme.GetAxisStrokeColor(), + TickShow: FalseFlag(), + StrokeWidth: 1, SplitLineColor: theme.GetAxisSplitLineColor(), - Show: opt.Show, - Unit: opt.Unit, + SplitLineShow: true, } - if !opt.Color.IsZero() { - axisOpt.FontColor = opt.Color - axisOpt.StrokeColor = opt.Color + if !yAxis.Color.IsZero() { + style.FontColor = yAxis.Color + style.StrokeColor = yAxis.Color } - if opt.isCategoryAxis { - axisOpt.BoundaryGap = TrueFlag() - axisOpt.StrokeWidth = 1 - axisOpt.SplitLineShow = false - } - if opt.SplitLineShow != nil { - axisOpt.SplitLineShow = *opt.SplitLineShow - } - return axisOpt -} + width := NewAxis(p, data, style).measure().Width -// 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)) -} + 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) + } -// 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) + dYAxis, err := NewDraw( + DrawOption{ + Parent: p, + Width: boxWidth, + // 减去x轴的高 + Height: p.Box.Height() - xAxisHeight, + }, + PaddingOption(padding), + ) + if err != nil { + return nil, err + } + if opt.Font != nil { + dYAxis.Font = opt.Font + } + NewAxis(dYAxis, data, style).Render() + yRange.Size = dYAxis.Box.Height() + return &yRange, nil } diff --git a/yaxis_test.go b/yaxis_test.go index 0f565ac..0bbef7a 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -26,44 +26,93 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" ) -func TestRightYAxis(t *testing.T) { +func TestDrawYAxis(t *testing.T) { assert := assert.New(t) + newDraw := func() *Draw { + d, _ := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }) + return d + } + tests := []struct { - render func(*Painter) ([]byte, error) - result string + newDraw func() *Draw + newOption func() *ChartOption + axisIndex int + xAxisHeight int + result string }{ { - 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 + newDraw: newDraw, + newOption: func() *ChartOption { + return &ChartOption{ + YAxisList: []YAxisOption{ + { + Max: NewFloatPoint(20), + }, + }, + SeriesList: []Series{ + { + Data: []SeriesData{ + { + Value: 1, + }, + { + Value: 2, + }, + }, + }, + }, } - return p.Bytes() }, - result: "\\nabcd", + result: "\\n03.336.661013.3316.6620", + }, + { + newDraw: newDraw, + newOption: func() *ChartOption { + return &ChartOption{ + YAxisList: []YAxisOption{ + {}, + { + Max: NewFloatPoint(20), + Formatter: "{value} C", + }, + }, + SeriesList: []Series{ + { + YAxisIndex: 1, + Data: []SeriesData{ + { + Value: 1, + }, + { + Value: 2, + }, + }, + }, + }, + } + }, + axisIndex: 1, + result: "\\n0 C3.33 C6.66 C10 C13.33 C16.66 C20 C", }, } + for _, tt := range tests { - p, err := NewPainter(PainterOptions{ - Type: ChartOutputSVG, - Width: 600, - Height: 400, - }, PainterThemeOption(defaultTheme), PainterPaddingOption(Box{ - Top: 10, - Right: 10, - Bottom: 10, - Left: 10, - })) + d := tt.newDraw() + r, err := drawYAxis(d, tt.newOption(), tt.axisIndex, tt.xAxisHeight, chart.NewBox(10, 10, 10, 10)) assert.Nil(err) - data, err := tt.render(p) + assert.Equal(&Range{ + divideCount: 6, + Max: 20, + Size: 280, + }, r) + + data, err := d.Bytes() assert.Nil(err) assert.Equal(tt.result, string(data)) }