diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 22e77a8..ce56fe7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,12 +14,12 @@ jobs:
strategy:
matrix:
go:
+ - '1.22'
+ - '1.21'
+ - '1.20'
+ - '1.19'
- '1.18'
- '1.17'
- - '1.16'
- - '1.15'
- - '1.14'
- - '1.13'
steps:
- name: Go ${{ matrix.go }} test
diff --git a/.gitignore b/.gitignore
index 2e33342..57206ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,5 @@
*.png
*.svg
tmp
+NotoSansSC.ttf
+.vscode
\ No newline at end of file
diff --git a/README.md b/README.md
index 22d3205..0650395 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,13 @@
# go-charts
+Clone from https://github.com/vicanso/go-charts
+
[](https://github.com/vicanso/go-charts/blob/master/LICENSE)
[](https://github.com/vicanso/go-charts/actions)
[中文](./README_zh.md)
-`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`.
+`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. The default format is `png` and the default theme is `light`.
`Apache ECharts` is popular among Front-end developers, so `go-charts` supports the option of `Apache ECharts`. Developers can generate charts almost the same as `Apache ECharts`.
@@ -15,59 +17,55 @@ Screenshot of common charts, the left part is light theme, the right part is gra
+
+
+
+
## Chart Type
-These chart types are supported: `line`, `bar`, `pie`, `radar` or `funnel`.
+These chart types are supported: `line`, `bar`, `horizontal bar`, `pie`, `radar` or `funnel` and `table`.
## Example
-The example is for `golang option` and `echarts option`, more examples can be found in the `./examples/` directory.
+More examples can be found in the [./examples/](./examples/) directory.
+
+### Line Chart
```go
package main
import (
- "os"
- "path/filepath"
-
- charts "github.com/vicanso/go-charts"
+ charts "git.smarteching.com/zeni/go-charts/v2"
)
-func writeFile(file string, buf []byte) error {
- tmpPath := "./tmp"
- err := os.MkdirAll(tmpPath, 0700)
- if err != nil {
- return err
- }
-
- file = filepath.Join(tmpPath, file)
- err = os.WriteFile(file, buf, 0600)
- if err != nil {
- return err
- }
- return nil
-}
-
-func chartsRender() ([]byte, error) {
- d, err := charts.LineRender([][]float64{
+func main() {
+ values := [][]float64{
{
- 150,
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
230,
- 224,
- 218,
- 135,
- 147,
- 260,
+ 210,
},
- },
- // output type
- charts.PNGTypeOption(),
- // title
- charts.TitleOptionFunc(charts.TitleOption{
- Text: "Line",
- }),
- // x axis
- charts.XAxisOptionFunc(charts.NewXAxisOption([]string{
+ {
+ // snip...
+ },
+ {
+ // snip...
+ },
+ {
+ // snip...
+ },
+ {
+ // snip...
+ },
+ }
+ p, err := charts.LineRender(
+ values,
+ charts.TitleTextOptionFunc("Line"),
+ charts.XAxisDataOptionFunc([]string{
"Mon",
"Tue",
"Wed",
@@ -75,16 +73,389 @@ func chartsRender() ([]byte, error) {
"Fri",
"Sat",
"Sun",
- })),
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }, charts.PositionCenter),
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ // snip...
+}
+```
+
+### Bar Chart
+
+```go
+package main
+
+import (
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func main() {
+ values := [][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ // snip...
+ },
+ }
+ p, err := charts.BarRender(
+ values,
+ charts.XAxisDataOptionFunc([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "Rainfall",
+ "Evaporation",
+ }, charts.PositionRight),
+ charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage),
+ charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin),
+ // custom option func
+ func(opt *charts.ChartOption) {
+ opt.SeriesList[1].MarkPoint = charts.NewMarkPoint(
+ charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin,
+ )
+ opt.SeriesList[1].MarkLine = charts.NewMarkLine(
+ charts.SeriesMarkDataTypeAverage,
+ )
+ },
)
if err != nil {
- return nil, err
+ panic(err)
}
- return d.Bytes()
-}
-func echartsRender() ([]byte, error) {
- return charts.RenderEChartsToPNG(`{
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ // snip...
+}
+```
+
+### Horizontal Bar Chart
+
+```go
+package main
+
+import (
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func main() {
+ values := [][]float64{
+ {
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230,
+ },
+ {
+ // snip...
+ },
+ }
+ p, err := charts.HorizontalBarRender(
+ values,
+ charts.TitleTextOptionFunc("World Population"),
+ charts.PaddingOptionFunc(charts.Box{
+ Top: 20,
+ Right: 40,
+ Bottom: 20,
+ Left: 20,
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "2011",
+ "2012",
+ }),
+ charts.YAxisDataOptionFunc([]string{
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ // snip...
+}
+```
+
+### Pie Chart
+
+```go
+package main
+
+import (
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func main() {
+ values := []float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }
+ p, err := charts.PieRender(
+ values,
+ charts.TitleOptionFunc(charts.TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ Left: charts.PositionCenter,
+ }),
+ charts.PaddingOptionFunc(charts.Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ }),
+ charts.LegendOptionFunc(charts.LegendOption{
+ Orient: charts.OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: charts.PositionLeft,
+ }),
+ charts.PieSeriesShowLabel(),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ // snip...
+}
+```
+
+### Radar Chart
+
+```go
+package main
+
+import (
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func main() {
+ values := [][]float64{
+ {
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ },
+ {
+ // snip...
+ },
+ }
+ p, err := charts.RadarRender(
+ values,
+ charts.TitleTextOptionFunc("Basic Radar Chart"),
+ charts.LegendLabelsOptionFunc([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ charts.RadarIndicatorOptionFunc([]string{
+ "Sales",
+ "Administration",
+ "Information Technology",
+ "Customer Support",
+ "Development",
+ "Marketing",
+ }, []float64{
+ 6500,
+ 16000,
+ 30000,
+ 38000,
+ 52000,
+ 25000,
+ }),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ // snip...
+}
+```
+
+### Funnel Chart
+
+```go
+package main
+
+import (
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func main() {
+ values := []float64{
+ 100,
+ 80,
+ 60,
+ 40,
+ 20,
+ }
+ p, err := charts.FunnelRender(
+ values,
+ charts.TitleTextOptionFunc("Funnel"),
+ charts.LegendLabelsOptionFunc([]string{
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order",
+ }),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ // snip...
+}
+```
+
+### Table
+
+```go
+package main
+
+import (
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func main() {
+ header := []string{
+ "Name",
+ "Age",
+ "Address",
+ "Tag",
+ "Action",
+ }
+ data := [][]string{
+ {
+ "John Brown",
+ "32",
+ "New York No. 1 Lake Park",
+ "nice, developer",
+ "Send Mail",
+ },
+ {
+ "Jim Green ",
+ "42",
+ "London No. 1 Lake Park",
+ "wow",
+ "Send Mail",
+ },
+ {
+ "Joe Black ",
+ "32",
+ "Sidney No. 1 Lake Park",
+ "cool, teacher",
+ "Send Mail",
+ },
+ }
+ spans := map[int]int{
+ 0: 2,
+ 1: 1,
+ // 设置第三列的span
+ 2: 3,
+ 3: 2,
+ 4: 2,
+ }
+ p, err := charts.TableRender(
+ header,
+ data,
+ spans,
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ // snip...
+}
+```
+
+### ECharts Render
+
+```go
+package main
+
+import (
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func main() {
+ buf, err := charts.RenderEChartsToPNG(`{
"title": {
"text": "Line"
},
@@ -97,25 +468,7 @@ func echartsRender() ([]byte, error) {
}
]
}`)
-}
-
-type Render func() ([]byte, error)
-
-func main() {
- m := map[string]Render{
- "charts-line.png": chartsRender,
- "echarts-line.png": echartsRender,
- }
- for name, fn := range m {
- buf, err := fn()
- if err != nil {
- panic(err)
- }
- err = writeFile(name, buf)
- if err != nil {
- panic(err)
- }
- }
+ // snip...
}
```
diff --git a/README_zh.md b/README_zh.md
index 1589923..3f35b97 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -3,7 +3,7 @@
[](https://github.com/vicanso/go-charts/blob/master/LICENSE)
[](https://github.com/vicanso/go-charts/actions)
-`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持主题`light`, `dark`, `grafana`以及`ant`。
+`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持主题`light`, `dark`, `grafana`以及`ant`。默认的输入格式为`png`,默认主题为`light`。
`Apache ECharts`在前端开发中得到众多开发者的认可,因此`go-charts`提供了兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的图表截图(主题为light与grafana):
@@ -12,62 +12,57 @@
+
+
+
> 1
- } else {
- y += b.Height() >> 1
- }
- // 左右位置的x不一样
- x := width - renderOpt.textMaxWith
- if position == PositionLeft {
- x = labelWidth - b.Width() - 1
- }
- d.text(text, x, y)
- }
+ padding.Left = top.Width() - width
default:
- // 定位bottom,重新计算y0的定位
- y0 := height - labelMargin
- if position == PositionTop {
- y0 = labelHeight - labelMargin
- }
- values := autoDivide(width, count)
- for index, text := range data.TextList() {
- if unitCount != 0 && index%unitCount != modValue {
- continue
- }
- x := values[index]
- leftOffset := 0
- b := r.MeasureText(text)
- if boundaryGap {
- width := values[index+1] - x
- leftOffset = (width - b.Width()) >> 1
- } else {
- // 左移文本长度
- leftOffset = -b.Width() >> 1
- }
- d.text(text, x+leftOffset, y0)
- }
+ padding.Top = top.Height() - defaultXAxisHeight
}
-}
-func (a *axis) axisLine(renderOpt *axisRenderOption) {
- d := a.d
- r := d.Render
- option := a.option
- s := option.Style(d.Font)
- s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
+ p := top.Child(PainterPaddingOption(padding))
x0 := 0
y0 := 0
x1 := 0
y1 := 0
- width := d.Box.Width()
- height := d.Box.Height()
- labelMargin := option.GetLabelMargin()
+ ticksPaddingTop := 0
+ ticksPaddingLeft := 0
+ labelPaddingTop := 0
+ labelPaddingLeft := 0
+ labelPaddingRight := 0
+ orient := ""
+ textAlign := ""
- // 轴线
- labelHeight := labelMargin + renderOpt.textMaxHeight
- labelWidth := labelMargin + renderOpt.textMaxWith
- tickLength := option.GetTickLength()
- switch option.Position {
- case PositionLeft:
- x0 = tickLength + labelWidth
- x1 = x0
- y0 = 0
- y1 = height
- case PositionRight:
- x0 = width - labelWidth
- x1 = x0
- y0 = 0
- y1 = height
+ switch opt.Position {
case PositionTop:
- x0 = 0
- x1 = width
- y0 = labelHeight
+ labelPaddingTop = 0
+ x1 = p.Width()
+ y0 = labelMargin + int(opt.FontSize)
+ ticksPaddingTop = int(opt.FontSize)
y1 = y0
- // bottom
- default:
- x0 = 0
- x1 = width
- y0 = height - tickLength - labelHeight
- y1 = y0
- }
-
- d.moveTo(x0, y0)
- d.lineTo(x1, y1)
- r.FillStroke()
-}
-
-func (a *axis) axisTick(renderOpt *axisRenderOption) {
- d := a.d
- r := d.Render
-
- option := a.option
- s := option.Style(d.Font)
- s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
-
- width := d.Box.Width()
- height := d.Box.Height()
- data := *a.data
- tickCount := len(data)
- if tickCount == 0 {
- return
- }
- if !renderOpt.boundaryGap {
- tickCount--
- }
- labelMargin := option.GetLabelMargin()
- tickShow := true
- if isFalse(option.TickShow) {
- tickShow = false
- }
- unitCount := renderOpt.unitCount
-
- tickLengthValue := option.GetTickLength()
- labelHeight := labelMargin + renderOpt.textMaxHeight
- labelWidth := labelMargin + renderOpt.textMaxWith
- position := option.Position
- switch position {
+ orient = OrientHorizontal
case PositionLeft:
- fallthrough
+ x0 = p.Width()
+ y0 = 0
+ x1 = p.Width()
+ y1 = p.Height()
+ orient = OrientVertical
+ textAlign = AlignRight
+ ticksPaddingLeft = textMaxWidth + tickLength
+ labelPaddingRight = width - textMaxWidth
case PositionRight:
- values := autoDivide(height, tickCount)
- // 左右仅是x0的位置不一样
- x0 := width - labelWidth
- if option.Position == PositionLeft {
- x0 = labelWidth
- }
- if tickShow {
- for _, v := range values {
- x := x0
- y := v
- d.moveTo(x, y)
- d.lineTo(x+tickLengthValue, y)
- r.Stroke()
- }
- }
- // 辅助线
- if option.SplitLineShow && !option.SplitLineColor.IsZero() {
- r.SetStrokeColor(option.SplitLineColor)
- splitLineWidth := width - labelWidth - tickLengthValue
- x0 = labelWidth + tickLengthValue
- if position == PositionRight {
+ orient = OrientVertical
+ y1 = p.Height()
+ labelPaddingLeft = width - textMaxWidth
+ default:
+ labelPaddingTop = height
+ x1 = p.Width()
+ orient = OrientHorizontal
+ }
+
+ if strokeWidth > 0 {
+ p.Child(PainterPaddingOption(Box{
+ Top: ticksPaddingTop,
+ Left: ticksPaddingLeft,
+ })).Ticks(TicksOption{
+ Count: tickCount,
+ Length: tickLength,
+ Unit: unit,
+ Orient: orient,
+ First: opt.FirstAxis,
+ })
+ p.LineStroke([]Point{
+ {
+ X: x0,
+ Y: y0,
+ },
+ {
+ X: x1,
+ Y: y1,
+ },
+ })
+ }
+
+ p.Child(PainterPaddingOption(Box{
+ Left: labelPaddingLeft,
+ Top: labelPaddingTop,
+ Right: labelPaddingRight,
+ })).MultiText(MultiTextOption{
+ First: opt.FirstAxis,
+ Align: textAlign,
+ TextList: data,
+ Orient: orient,
+ Unit: unit,
+ Position: labelPosition,
+ TextRotation: opt.TextRotation,
+ Offset: opt.LabelOffset,
+ })
+ // 显示辅助线
+ if opt.SplitLineShow {
+ style.StrokeColor = opt.SplitLineColor
+ style.StrokeWidth = 1
+ top.OverrideDrawingStyle(style)
+ if isVertical {
+ x0 := p.Width()
+ x1 := top.Width()
+ if opt.Position == PositionRight {
x0 = 0
- splitLineWidth = width - labelWidth - 1
+ x1 = top.Width() - p.Width()
}
- for _, v := range values[0 : len(values)-1] {
- x := x0
- y := v
- d.moveTo(x, y)
- d.lineTo(x+splitLineWidth, y)
- r.Stroke()
+ yValues := autoDivide(height, tickCount)
+ yValues = yValues[0 : len(yValues)-1]
+ for _, y := range yValues {
+ top.LineStroke([]Point{
+ {
+ X: x0,
+ Y: y,
+ },
+ {
+ X: x1,
+ Y: y,
+ },
+ })
}
- }
- default:
- values := autoDivide(width, tickCount)
- // 上下仅是y0的位置不一样
- y0 := height - labelHeight
- if position == PositionTop {
- y0 = labelHeight
- }
- if tickShow {
- for index, v := range values {
- if index%unitCount != 0 {
+ } else {
+ y0 := p.Height() - defaultXAxisHeight
+ y1 := top.Height() - defaultXAxisHeight
+ for index, x := range autoDivide(width, tickCount) {
+ if index == 0 {
continue
}
- x := v
- y := y0
- d.moveTo(x, y-tickLengthValue)
- d.lineTo(x, y)
- r.Stroke()
- }
- }
- // 辅助线
- if option.SplitLineShow && !option.SplitLineColor.IsZero() {
- r.SetStrokeColor(option.SplitLineColor)
- y0 = 0
- splitLineHeight := height - labelHeight - tickLengthValue
- if position == PositionTop {
- y0 = labelHeight
- splitLineHeight = height - labelHeight
- }
-
- for index, v := range values {
- if index%unitCount != 0 {
- continue
- }
- x := v
- y := y0
-
- d.moveTo(x, y)
- d.lineTo(x, y0+splitLineHeight)
- r.Stroke()
+ top.LineStroke([]Point{
+ {
+ X: x,
+ Y: y0,
+ },
+ {
+ X: x,
+ Y: y1,
+ },
+ })
}
}
}
-}
-
-func (a *axis) measureTextMaxWidthHeight() (int, int) {
- d := a.d
- r := d.Render
- s := a.option.Style(d.Font)
- data := a.data
- s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
- s.GetTextOptions().WriteTextOptionsToRenderer(r)
- return measureTextMaxWidthHeight(data.TextList(), r)
-}
-
-// measure returns the measurement of axis.
-// Width will be textMaxWidth + labelMargin + tickLength for position left or right.
-// Height will be textMaxHeight + labelMargin + tickLength for position top or bottom.
-func (a *axis) measure() axisMeasurement {
- option := a.option
- value := option.GetLabelMargin() + option.GetTickLength()
- textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight()
- info := axisMeasurement{}
- if option.Position == PositionLeft ||
- option.Position == PositionRight {
- info.Width = textMaxWidth + value
- } else {
- info.Height = textMaxHeight + value
- }
- return info
-}
-
-// Render renders the axis for chart
-func (a *axis) Render() {
- option := a.option
- if isFalse(option.Show) {
- return
- }
- textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight()
- opt := &axisRenderOption{
- textMaxWith: textMaxWidth,
- textMaxHeight: textMaxHeight,
- boundaryGap: true,
- }
- if isFalse(option.BoundaryGap) {
- opt.boundaryGap = false
- }
-
- unitCount := chart.MaxInt(option.SplitNumber, 1)
- width := a.d.Box.Width()
- textList := a.data.TextList()
- count := len(textList)
-
- position := option.Position
- switch position {
- case PositionLeft:
- fallthrough
- case PositionRight:
- default:
- maxCount := width / (opt.textMaxWith + 10)
- // 可以显示所有
- if maxCount >= count {
- unitCount = 1
- } else if maxCount < count/unitCount {
- unitCount = int(math.Ceil(float64(count) / float64(maxCount)))
- }
- }
-
- boundaryGap := opt.boundaryGap
- modValue := 0
- if boundaryGap && unitCount > 1 {
- // 如果是居中,unit count需要设置为奇数
- if unitCount%2 == 0 {
- unitCount++
- }
- modValue = unitCount / 2
- }
- opt.modValue = modValue
- opt.unitCount = unitCount
-
- // 坐标轴线
- a.axisLine(opt)
- a.axisTick(opt)
- // 坐标文本
- a.axisLabel(opt)
+
+ return Box{
+ Bottom: height,
+ Right: width,
+ }, nil
}
diff --git a/axis_test.go b/axis_test.go
index 37c8314..85e18ca 100644
--- a/axis_test.go
+++ b/axis_test.go
@@ -25,235 +25,149 @@ package charts
import (
"testing"
- "github.com/golang/freetype/truetype"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
-func TestAxisOption(t *testing.T) {
- assert := assert.New(t)
-
- as := AxisOption{}
-
- assert.Equal(8, as.GetLabelMargin())
- as.LabelMargin = 10
- assert.Equal(10, as.GetLabelMargin())
-
- assert.Equal(5, as.GetTickLength())
- as.TickLength = 6
- assert.Equal(6, as.GetTickLength())
-
- f := &truetype.Font{}
- style := as.Style(f)
- assert.Equal(float64(10), style.FontSize)
- assert.Equal(f, style.Font)
-}
-
-func TestAxisDataList(t *testing.T) {
- assert := assert.New(t)
-
- textList := []string{
- "a",
- "b",
- }
- data := NewAxisDataListFromStringList(textList)
- assert.Equal(textList, data.TextList())
-}
-
func TestAxis(t *testing.T) {
assert := assert.New(t)
-
- axisData := NewAxisDataListFromStringList([]string{
- "Mon",
- "Tue",
- "Wed",
- "Thu",
- "Fri",
- "Sat",
- "Sun",
- })
- getDefaultOption := func() AxisOption {
- return AxisOption{
- StrokeColor: drawing.ColorBlack,
- StrokeWidth: 1,
- FontColor: drawing.ColorBlack,
- Show: TrueFlag(),
- TickShow: TrueFlag(),
- SplitLineShow: true,
- SplitLineColor: drawing.ColorBlack.WithAlpha(60),
- }
- }
tests := []struct {
- newOption func() AxisOption
- newData func() AxisDataList
- result string
+ render func(*Painter) ([]byte, error)
+ result string
}{
- // 文本按起始位置展示
- // axis位于bottom
+ // 底部x轴
{
- newOption: func() AxisOption {
- opt := getDefaultOption()
- opt.BoundaryGap = FalseFlag()
- return opt
+ render: func(p *Painter) ([]byte, error) {
+ _, _ = NewAxisPainter(p, AxisOption{
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ SplitLineShow: true,
+ SplitLineColor: drawing.ColorBlack,
+ }).Render()
+ return p.Bytes()
},
- result: "\\nMon Tue Wed Thu Fri Sat Sun ",
+ result: "\\nMon Tue Wed Thu Fri Sat Sun ",
},
- // 文本居中展示
- // axis位于bottom
+ // 底部x轴文本居左
{
- newOption: func() AxisOption {
- opt := getDefaultOption()
- return opt
+ render: func(p *Painter) ([]byte, error) {
+ _, _ = NewAxisPainter(p, AxisOption{
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ BoundaryGap: FalseFlag(),
+ }).Render()
+ return p.Bytes()
},
- result: "\\nMon Tue Wed Thu Fri Sat Sun ",
+ result: "\\nMon Tue Wed Thu Fri Sat Sun ",
},
- // 文本按起始位置展示
- // axis位于top
+ // 左侧y轴
{
- newOption: func() AxisOption {
- opt := getDefaultOption()
- opt.Position = PositionTop
- opt.BoundaryGap = FalseFlag()
- return opt
+ render: func(p *Painter) ([]byte, error) {
+ _, _ = NewAxisPainter(p, AxisOption{
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ Position: PositionLeft,
+ }).Render()
+ return p.Bytes()
},
- result: "\\nMon Tue Wed Thu Fri Sat Sun ",
+ result: "\\nMon Tue Wed Thu Fri Sat Sun ",
},
- // 文本居中展示
- // axis位于top
+ // 左侧y轴居中
{
- newOption: func() AxisOption {
- opt := getDefaultOption()
- opt.Position = PositionTop
- return opt
+ render: func(p *Painter) ([]byte, error) {
+ _, _ = NewAxisPainter(p, AxisOption{
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ Position: PositionLeft,
+ BoundaryGap: FalseFlag(),
+ SplitLineShow: true,
+ SplitLineColor: drawing.ColorBlack,
+ }).Render()
+ return p.Bytes()
},
- result: "\\nMon Tue Wed Thu Fri Sat Sun ",
+ result: "\\nMon Tue Wed Thu Fri Sat Sun ",
},
- // 文本按起始位置展示
- // axis位于left
+ // 右侧
{
- newOption: func() AxisOption {
- opt := getDefaultOption()
- opt.Position = PositionLeft
- opt.BoundaryGap = FalseFlag()
- return opt
+ render: func(p *Painter) ([]byte, error) {
+ _, _ = NewAxisPainter(p, AxisOption{
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ Position: PositionRight,
+ BoundaryGap: FalseFlag(),
+ SplitLineShow: true,
+ SplitLineColor: drawing.ColorBlack,
+ }).Render()
+ return p.Bytes()
},
- result: "\\nMon Tue Wed Thu Fri Sat Sun ",
+ result: "\\nMon Tue Wed Thu Fri Sat Sun ",
},
- // 文本居中展示
- // axis位于left
+ // 顶部
{
- newOption: func() AxisOption {
- opt := getDefaultOption()
- opt.Position = PositionLeft
- return opt
+ render: func(p *Painter) ([]byte, error) {
+ _, _ = NewAxisPainter(p, AxisOption{
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ Formatter: "{value} --",
+ Position: PositionTop,
+ }).Render()
+ return p.Bytes()
},
- result: "\\nMon Tue Wed Thu Fri Sat Sun ",
- },
- // 文本按起始位置展示
- // axis位于right
- {
- newOption: func() AxisOption {
- opt := getDefaultOption()
- opt.Position = PositionRight
- opt.BoundaryGap = FalseFlag()
- return opt
- },
- result: "\\nMon Tue Wed Thu Fri Sat Sun ",
- },
- // 文本居中展示
- // axis位于right
- {
- newOption: func() AxisOption {
- opt := getDefaultOption()
- opt.Position = PositionRight
- return opt
- },
- result: "\\nMon Tue Wed Thu Fri Sat Sun ",
- },
- // 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-02 01-05 01-08 01-11 01-14 01-17 01-20 ",
+ result: "\\nMon -- Tue -- Wed -- Thu -- Fri -- Sat -- Sun -- ",
},
}
+
for _, tt := range tests {
- d, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- }, PaddingOption(chart.Box{
- Left: 5,
- Top: 5,
- Right: 5,
- Bottom: 5,
- }))
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
assert.Nil(err)
- style := tt.newOption()
- data := axisData
- if tt.newData != nil {
- data = tt.newData()
- }
- NewAxis(d, data, style).Render()
-
- result, err := d.Bytes()
+ data, err := tt.render(p)
assert.Nil(err)
- assert.Equal(tt.result, string(result))
+ assert.Equal(tt.result, string(data))
}
}
-
-func TestMeasureAxis(t *testing.T) {
- assert := assert.New(t)
-
- d, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- assert.Nil(err)
- data := NewAxisDataListFromStringList([]string{
- "Mon",
- "Sun",
- })
- f, _ := chart.GetDefaultFont()
- width := NewAxis(d, data, AxisOption{
- FontSize: 12,
- Font: f,
- Position: PositionLeft,
- }).measure().Width
- assert.Equal(44, width)
-
- height := NewAxis(d, data, AxisOption{
- FontSize: 12,
- Font: f,
- Position: PositionTop,
- }).measure().Height
- assert.Equal(28, height)
-}
diff --git a/bar_chart.go b/bar_chart.go
index 32373b1..043e044 100644
--- a/bar_chart.go
+++ b/bar_chart.go
@@ -23,31 +23,60 @@
package charts
import (
+ "math"
+
"github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2"
)
-type barChartOption struct {
- // The series list fo bar chart
- SeriesList SeriesList
- // The theme
- Theme string
- // The font
- Font *truetype.Font
+type barChart struct {
+ p *Painter
+ opt *BarChartOption
}
-func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointRenderOption, error) {
- d, err := NewDraw(DrawOption{
- Parent: result.d,
- }, PaddingOption(chart.Box{
- Top: result.titleBox.Height(),
- // TODO 后续考虑是否需要根据左侧是否展示y轴再生成对应的left
- Left: YAxisWidth,
- }))
- if err != nil {
- return nil, err
+// NewBarChart returns a bar chart renderer
+func NewBarChart(p *Painter, opt BarChartOption) *barChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
}
- xRange := result.xRange
+ return &barChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type BarChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The x axis option
+ XAxis XAxisOption
+ // The padding of line chart
+ Padding Box
+ // The y axis option
+ YAxisOptions []YAxisOption
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ BarWidth int
+ // Margin of bar
+ BarMargin int
+}
+
+func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ p := b.p
+ opt := b.opt
+ seriesPainter := result.seriesPainter
+
+ xRange := NewRange(AxisRangeOption{
+ Painter: b.p,
+ DivideCount: len(opt.XAxis.Data),
+ Size: seriesPainter.Width(),
+ })
x0, x1 := xRange.GetRange(0)
width := int(x1 - x0)
// 每一块之间的margin
@@ -61,50 +90,54 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR
margin = 5
barMargin = 3
}
-
- seriesCount := len(opt.SeriesList)
+ if opt.BarMargin > 0 {
+ barMargin = opt.BarMargin
+ }
+ seriesCount := len(seriesList)
// 总的宽度-两个margin-(总数-1)的barMargin
- barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / len(opt.SeriesList)
+ barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / seriesCount
+ if opt.BarWidth > 0 && opt.BarWidth < barWidth {
+ barWidth = opt.BarWidth
+ // 重新计算margin
+ margin = (width - seriesCount*barWidth - barMargin*(seriesCount-1)) / 2
+ }
+ barMaxHeight := seriesPainter.Height()
+ theme := opt.Theme
+ seriesNames := seriesList.Names()
- barMaxHeight := result.getYRange(0).Size
- theme := NewTheme(opt.Theme)
+ markPointPainter := NewMarkPointPainter(seriesPainter)
+ markLinePainter := NewMarkLinePainter(seriesPainter)
+ rendererList := []Renderer{
+ markPointPainter,
+ markLinePainter,
+ }
+ for index := range seriesList {
+ series := seriesList[index]
+ yRange := result.axisRanges[series.AxisIndex]
+ seriesColor := theme.GetSeriesColor(series.index)
- seriesNames := opt.SeriesList.Names()
-
- r := d.Render
-
- markPointRenderOptions := make([]markPointRenderOption, 0)
-
- for i, s := range opt.SeriesList {
- // 由于series是for range,为同一个数据,因此需要clone
- // 后续需要使用,如mark point
- series := s
- yRange := result.getYRange(series.YAxisIndex)
- points := make([]Point, len(series.Data))
- index := series.index
- if index == 0 {
- index = i
- }
- seriesColor := theme.GetSeriesColor(index)
- // mark line
- markLineRender(markLineRenderOption{
- Draw: d,
- FillColor: seriesColor,
- FontColor: theme.GetTextColor(),
- StrokeColor: seriesColor,
- Font: opt.Font,
- Series: &series,
- Range: yRange,
- })
divideValues := xRange.AutoDivide()
+ points := make([]Point, len(series.Data))
+ var labelPainter *SeriesLabelPainter
+ if series.Label.Show {
+ labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
+ P: seriesPainter,
+ SeriesNames: seriesNames,
+ Label: series.Label,
+ Theme: opt.Theme,
+ Font: opt.Font,
+ })
+ rendererList = append(rendererList, labelPainter)
+ }
+
for j, item := range series.Data {
if j >= xRange.divideCount {
continue
}
x := divideValues[j]
x += margin
- if i != 0 {
- x += i * (barWidth + barMargin)
+ if index != 0 {
+ x += index * (barWidth + barMargin)
}
h := int(yRange.getHeight(item.Value))
@@ -113,14 +146,32 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR
fillColor = item.Style.FillColor
}
top := barMaxHeight - h
- d.Bar(chart.Box{
- Top: top,
- Left: x,
- Right: x + barWidth,
- Bottom: barMaxHeight - 1,
- }, BarStyle{
- FillColor: fillColor,
- })
+
+ if series.RoundRadius <= 0 {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).Rect(chart.Box{
+ Top: top,
+ Left: x,
+ Right: x + barWidth,
+ Bottom: barMaxHeight - 1,
+ })
+ } else {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).RoundedRect(chart.Box{
+ Top: top,
+ Left: x,
+ Right: x + barWidth,
+ Bottom: barMaxHeight - 1,
+ }, series.RoundRadius)
+ }
+ // 用于生成marker point
+ points[j] = Point{
+ // 居中的位置
+ X: x + barWidth>>1,
+ Y: top,
+ }
// 用于生成marker point
points[j] = Point{
// 居中的位置
@@ -128,36 +179,75 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR
Y: top,
}
// 如果label不需要展示,则返回
- if !series.Label.Show {
+ if labelPainter == nil {
continue
}
- distance := series.Label.Distance
- if distance == 0 {
- distance = 5
+ y := barMaxHeight - h
+ radians := float64(0)
+ fontColor := series.Label.Color
+ if series.Label.Position == PositionBottom {
+ y = barMaxHeight
+ radians = -math.Pi / 2
+ if fontColor.IsZero() {
+ if isLightColor(fillColor) {
+ fontColor = defaultLightFontColor
+ } else {
+ fontColor = defaultDarkFontColor
+ }
+ }
}
- text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1)
- labelStyle := chart.Style{
- FontColor: theme.GetTextColor(),
- FontSize: labelFontSize,
- Font: opt.Font,
- }
- if !series.Label.Color.IsZero() {
- labelStyle.FontColor = series.Label.Color
- }
- labelStyle.GetTextOptions().WriteToRenderer(r)
- textBox := r.MeasureText(text)
- d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance)
+ labelPainter.Add(LabelValue{
+ Index: index,
+ Value: item.Value,
+ X: x + barWidth>>1,
+ Y: y,
+ // 旋转
+ Radians: radians,
+ FontColor: fontColor,
+ Offset: series.Label.Offset,
+ FontSize: series.Label.FontSize,
+ })
}
- // 生成mark point的参数
- markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{
- Draw: d,
+ markPointPainter.Add(markPointRenderOption{
FillColor: seriesColor,
Font: opt.Font,
+ Series: series,
Points: points,
- Series: &series,
+ })
+ markLinePainter.Add(markLineRenderOption{
+ FillColor: seriesColor,
+ FontColor: opt.Theme.GetTextColor(),
+ StrokeColor: seriesColor,
+ Font: opt.Font,
+ Series: series,
+ Range: yRange,
})
}
+ // 最大、最小的mark point
+ err := doRender(rendererList...)
+ if err != nil {
+ return BoxZero, err
+ }
- return markPointRenderOptions, nil
+ return p.box, nil
+}
+
+func (b *barChart) Render() (Box, error) {
+ p := b.p
+ opt := b.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: opt.XAxis,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeLine)
+ return b.render(renderResult, seriesList)
}
diff --git a/bar_chart_test.go b/bar_chart_test.go
index f10a1bc..654c320 100644
--- a/bar_chart_test.go
+++ b/bar_chart_test.go
@@ -26,106 +26,165 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
)
-func TestBarChartRender(t *testing.T) {
+func TestBarChart(t *testing.T) {
assert := assert.New(t)
- width := 400
- height := 300
- d, err := NewDraw(DrawOption{
- Width: width,
- Height: height,
- })
- assert.Nil(err)
-
- result := basicRenderResult{
- xRange: &Range{
- Min: 0,
- Max: 4,
- divideCount: 4,
- Size: width,
- Boundary: true,
- },
- yRangeList: []*Range{
- {
- divideCount: 6,
- Max: 100,
- Min: 0,
- Size: height,
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ seriesList := NewSeriesListDataFromValues([][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ },
+ })
+ for index := range seriesList {
+ seriesList[index].Label.Show = true
+ }
+ _, err := NewBarChart(p, BarChartOption{
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+ SeriesList: seriesList,
+ XAxis: NewXAxisOption([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ YAxisOptions: NewYAxisOptions([]string{
+ "Rainfall",
+ "Evaporation",
+ }),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\n240 200 160 120 80 40 0 Feb May Aug Nov 2 4.9 7 23.2 25.6 76.7 135.6 162.2 32.6 20 6.4 3.3 2.6 5.9 9 26.4 28.7 70.7 175.6 182.2 48.7 18.8 6 2.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: "\\n240 200 160 120 80 40 0 Feb May Aug Nov 2 4.9 7 23.2 25.6 76.7 135.6 162.2 32.6 20 6.4 3.3 2.6 5.9 9 26.4 28.7 70.7 175.6 182.2 48.7 18.8 6 2.3 ",
},
- d: d,
}
- f, _ := chart.GetDefaultFont()
- markPointOptions, err := barChartRender(barChartOption{
- Font: f,
- SeriesList: SeriesList{
- {
- Label: SeriesLabel{
- Show: true,
- Color: drawing.ColorBlue,
- },
- MarkLine: NewMarkLine(
- SeriesMarkDataTypeMin,
- ),
- Data: []SeriesData{
- {
- Value: 20,
- },
- {
- Value: 60,
- Style: chart.Style{
- FillColor: drawing.ColorRed,
- },
- },
- {
- Value: 90,
- },
- },
- },
- NewSeriesFromValues([]float64{
- 80,
- 30,
- 70,
- }),
- },
- }, &result)
- assert.Nil(err)
- assert.Equal(2, len(markPointOptions))
- assert.Equal([]Point{
- {
- X: 28,
- Y: 240,
- },
- {
- X: 128,
- Y: 120,
- },
- {
- X: 228,
- Y: 30,
- },
- }, markPointOptions[0].Points)
- assert.Equal([]Point{
- {
- X: 70,
- Y: 60,
- },
- {
- X: 170,
- Y: 210,
- },
- {
- X: 270,
- Y: 90,
- },
- }, markPointOptions[1].Points)
-
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal("\\n20 20 60 90 ", string(data))
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
}
diff --git a/bar_test.go b/bar_test.go
deleted file mode 100644
index 01b6d3c..0000000
--- a/bar_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
-)
-
-func TestBarStyle(t *testing.T) {
- assert := assert.New(t)
-
- bs := BarStyle{
- ClassName: "test",
- StrokeDashArray: []float64{
- 1.0,
- },
- FillColor: drawing.ColorBlack,
- }
-
- assert.Equal(chart.Style{
- ClassName: "test",
- StrokeDashArray: []float64{
- 1.0,
- },
- StrokeWidth: 1,
- FillColor: drawing.ColorBlack,
- StrokeColor: drawing.ColorBlack,
- }, bs.Style())
-}
-
-func TestDrawBar(t *testing.T) {
- assert := assert.New(t)
- d, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- }, PaddingOption(chart.Box{
- Left: 10,
- Top: 20,
- Right: 30,
- Bottom: 40,
- }))
- assert.Nil(err)
- d.Bar(chart.Box{
- Left: 0,
- Top: 0,
- Right: 20,
- Bottom: 200,
- }, BarStyle{
- FillColor: drawing.ColorBlack,
- })
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal("\\n ", string(data))
-}
diff --git a/chart.go b/chart.go
deleted file mode 100644
index 21f2071..0000000
--- a/chart.go
+++ /dev/null
@@ -1,502 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "errors"
- "math"
- "sort"
- "strings"
-
- "github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
-)
-
-const (
- ChartTypeLine = "line"
- ChartTypeBar = "bar"
- ChartTypePie = "pie"
- ChartTypeRadar = "radar"
- ChartTypeFunnel = "funnel"
-)
-
-const (
- ChartOutputSVG = "svg"
- ChartOutputPNG = "png"
-)
-
-type Point struct {
- X int
- Y int
-}
-
-const labelFontSize = 10
-const defaultDotWidth = 2.0
-const defaultStrokeWidth = 2.0
-
-var defaultChartWidth = 600
-var defaultChartHeight = 400
-
-type ChartOption struct {
- // The output type of chart, "svg" or "png", default value is "svg"
- Type string
- // The font family, which should be installed first
- FontFamily string
- // The font of chart, the default font is "roboto"
- Font *truetype.Font
- // The theme of chart, "light" and "dark".
- // The default theme is "light"
- Theme string
- // The title option
- Title TitleOption
- // The legend option
- Legend LegendOption
- // The x axis option
- XAxis XAxisOption
- // The y axis option list
- YAxisList []YAxisOption
- // The width of chart, default width is 600
- Width int
- // The height of chart, default height is 400
- Height int
- Parent *Draw
- // The padding for chart, default padding is [20, 10, 10, 10]
- Padding chart.Box
- // The canvas box for chart
- Box chart.Box
- // The series list
- SeriesList SeriesList
- // The radar indicator list
- RadarIndicators []RadarIndicator
- // The background color of chart
- BackgroundColor drawing.Color
- // The child charts
- Children []ChartOption
-}
-
-// FillDefault fills the default value for chart option
-func (o *ChartOption) FillDefault(theme string) {
- t := NewTheme(theme)
- // 如果为空,初始化
- yAxisCount := 1
- for _, series := range o.SeriesList {
- if series.YAxisIndex >= yAxisCount {
- yAxisCount++
- }
- }
- yAxisList := make([]YAxisOption, yAxisCount)
- copy(yAxisList, o.YAxisList)
- o.YAxisList = yAxisList
-
- if o.Font == nil {
- o.Font, _ = chart.GetDefaultFont()
- }
- if o.BackgroundColor.IsZero() {
- o.BackgroundColor = t.GetBackgroundColor()
- }
- if o.Padding.IsZero() {
- o.Padding = chart.Box{
- Top: 10,
- Right: 10,
- Bottom: 10,
- Left: 10,
- }
- }
-
- // 标题的默认值
- if o.Title.Style.FontColor.IsZero() {
- o.Title.Style.FontColor = t.GetTextColor()
- }
- if o.Title.Style.FontSize == 0 {
- o.Title.Style.FontSize = 14
- }
- if o.Title.Style.Font == nil {
- o.Title.Style.Font = o.Font
- }
- if o.Title.Style.Padding.IsZero() {
- o.Title.Style.Padding = chart.Box{
- Bottom: 10,
- }
- }
- // 副标题
- if o.Title.SubtextStyle.FontColor.IsZero() {
- o.Title.SubtextStyle.FontColor = o.Title.Style.FontColor.WithAlpha(180)
- }
- if o.Title.SubtextStyle.FontSize == 0 {
- o.Title.SubtextStyle.FontSize = labelFontSize
- }
- if o.Title.SubtextStyle.Font == nil {
- o.Title.SubtextStyle.Font = o.Font
- }
-
- o.Legend.theme = theme
- if o.Legend.Style.FontSize == 0 {
- o.Legend.Style.FontSize = labelFontSize
- }
- if o.Legend.Left == "" {
- o.Legend.Left = PositionCenter
- }
- // legend与series name的关联
- if len(o.Legend.Data) == 0 {
- o.Legend.Data = o.SeriesList.Names()
- } else {
- seriesCount := len(o.SeriesList)
- for index, name := range o.Legend.Data {
- if index < seriesCount &&
- len(o.SeriesList[index].Name) == 0 {
- o.SeriesList[index].Name = name
- }
- }
- nameIndexDict := map[string]int{}
- for index, name := range o.Legend.Data {
- nameIndexDict[name] = index
- }
- // 保证series的顺序与legend一致
- sort.Slice(o.SeriesList, func(i, j int) bool {
- return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name]
- })
- }
- // 如果无legend数据,则隐藏
- if len(strings.Join(o.Legend.Data, "")) == 0 {
- o.Legend.Show = FalseFlag()
- }
- if o.Legend.Style.Font == nil {
- o.Legend.Style.Font = o.Font
- }
- if o.Legend.Style.FontColor.IsZero() {
- o.Legend.Style.FontColor = t.GetTextColor()
- }
- if o.XAxis.Theme == "" {
- o.XAxis.Theme = theme
- }
- o.XAxis.Font = o.Font
-}
-
-func (o *ChartOption) getWidth() int {
- if o.Width != 0 {
- return o.Width
- }
- if o.Parent != nil {
- return o.Parent.Box.Width()
- }
- return defaultChartWidth
-}
-
-func SetDefaultWidth(width int) {
- if width > 0 {
- defaultChartWidth = width
- }
-}
-func SetDefaultHeight(height int) {
- if height > 0 {
- defaultChartHeight = height
- }
-}
-
-func (o *ChartOption) getHeight() int {
-
- if o.Height != 0 {
- return o.Height
- }
- if o.Parent != nil {
- return o.Parent.Box.Height()
- }
- return defaultChartHeight
-}
-
-func (o *ChartOption) newYRange(axisIndex int) Range {
- min := math.MaxFloat64
- max := -math.MaxFloat64
- if axisIndex >= len(o.YAxisList) {
- axisIndex = 0
- }
- yAxis := o.YAxisList[axisIndex]
-
- for _, series := range o.SeriesList {
- if series.YAxisIndex != axisIndex {
- continue
- }
- for _, item := range series.Data {
- if item.Value > max {
- max = item.Value
- }
- if item.Value < min {
- min = item.Value
- }
- }
- }
- min = min * 0.9
- max = max * 1.1
- if yAxis.Min != nil {
- min = *yAxis.Min
- }
- if yAxis.Max != nil {
- max = *yAxis.Max
- }
- divideCount := 6
- // y轴分设置默认划分为6块
- r := NewRange(min, max, divideCount)
-
- // 由于NewRange会重新计算min max
- if yAxis.Min != nil {
- r.Min = min
- }
- if yAxis.Max != nil {
- r.Max = max
- }
-
- return r
-}
-
-type basicRenderResult struct {
- xRange *Range
- yRangeList []*Range
- d *Draw
- titleBox chart.Box
-}
-
-func (r *basicRenderResult) getYRange(index int) *Range {
- if index >= len(r.yRangeList) {
- index = 0
- }
- return r.yRangeList[index]
-}
-
-// Render renders the chart by option
-func Render(opt ChartOption, optFuncs ...OptionFunc) (*Draw, error) {
- for _, optFunc := range optFuncs {
- optFunc(&opt)
- }
- if len(opt.SeriesList) == 0 {
- return nil, errors.New("series can not be nil")
- }
- if len(opt.FontFamily) != 0 {
- f, err := GetFont(opt.FontFamily)
- if err != nil {
- return nil, err
- }
- opt.Font = f
- }
- opt.FillDefault(opt.Theme)
-
- lineSeries := make([]Series, 0)
- barSeries := make([]Series, 0)
- isPieChart := false
- isRadarChart := false
- isFunnelChart := false
- for index := range opt.SeriesList {
- opt.SeriesList[index].index = index
- item := opt.SeriesList[index]
- switch item.Type {
- case ChartTypePie:
- isPieChart = true
- case ChartTypeRadar:
- isRadarChart = true
- case ChartTypeFunnel:
- isFunnelChart = true
- case ChartTypeBar:
- barSeries = append(barSeries, item)
- default:
- lineSeries = append(lineSeries, item)
- }
- }
- // 如果指定了pie,则以pie的形式处理,pie不支持多类型图表
- // pie不需要axis
- // radar 同样处理
- if isPieChart ||
- isRadarChart ||
- isFunnelChart {
- opt.XAxis.Hidden = true
- for index := range opt.YAxisList {
- opt.YAxisList[index].Hidden = true
- }
- }
- result, err := chartBasicRender(&opt)
- if err != nil {
- return nil, err
- }
- markPointRenderOptions := make([]markPointRenderOption, 0)
- fns := []func() error{
- // pie render
- func() error {
- if !isPieChart {
- return nil
- }
- return pieChartRender(pieChartOption{
- SeriesList: opt.SeriesList,
- Theme: opt.Theme,
- Font: opt.Font,
- }, result)
- },
- // radar render
- func() error {
- if !isRadarChart {
- return nil
- }
- return radarChartRender(radarChartOption{
- SeriesList: opt.SeriesList,
- Theme: opt.Theme,
- Font: opt.Font,
- Indicators: opt.RadarIndicators,
- }, result)
- },
- // funnel render
- func() error {
- if !isFunnelChart {
- return nil
- }
- return funnelChartRender(funnelChartOption{
- SeriesList: opt.SeriesList,
- Theme: opt.Theme,
- Font: opt.Font,
- }, result)
- },
- // bar render
- func() error {
- // 如果无bar类型的series
- if len(barSeries) == 0 {
- return nil
- }
- options, err := barChartRender(barChartOption{
- SeriesList: barSeries,
- Theme: opt.Theme,
- Font: opt.Font,
- }, result)
- if err != nil {
- return err
- }
- markPointRenderOptions = append(markPointRenderOptions, options...)
- return nil
- },
- // line render
- func() error {
- // 如果无line类型的series
- if len(lineSeries) == 0 {
- return nil
- }
- options, err := lineChartRender(lineChartOption{
- Theme: opt.Theme,
- SeriesList: lineSeries,
- Font: opt.Font,
- }, result)
- if err != nil {
- return err
- }
- markPointRenderOptions = append(markPointRenderOptions, options...)
- return nil
- },
- // legend需要在顶层,因此此处render
- func() error {
- _, err := NewLegend(result.d, opt.Legend).Render()
- return err
- },
- // mark point最后render
- func() error {
- // mark point render不会出错
- for _, opt := range markPointRenderOptions {
- markPointRender(opt)
- }
- return nil
- },
- }
-
- for _, fn := range fns {
- err = fn()
- if err != nil {
- return nil, err
- }
- }
- for _, child := range opt.Children {
- child.Parent = result.d
- if len(child.Theme) == 0 {
- child.Theme = opt.Theme
- }
- _, err = Render(child)
- if err != nil {
- return nil, err
- }
- }
- return result.d, nil
-}
-
-func chartBasicRender(opt *ChartOption) (*basicRenderResult, error) {
- d, err := NewDraw(
- DrawOption{
- Type: opt.Type,
- Parent: opt.Parent,
- Width: opt.getWidth(),
- Height: opt.getHeight(),
- },
- BoxOption(opt.Box),
- PaddingOption(opt.Padding),
- )
- if err != nil {
- return nil, err
- }
-
- if len(opt.YAxisList) > 2 {
- return nil, errors.New("y axis should not be gt 2")
- }
- if opt.Parent == nil {
- d.setBackground(opt.getWidth(), opt.getHeight(), opt.BackgroundColor)
- }
-
- // 标题
- titleBox, err := drawTitle(d, &opt.Title)
- if err != nil {
- return nil, err
- }
-
- xAxisHeight := 0
- var xRange *Range
-
- if !opt.XAxis.Hidden {
- // xAxis
- xAxisHeight, xRange, err = drawXAxis(d, &opt.XAxis, len(opt.YAxisList))
- if err != nil {
- return nil, err
- }
- }
-
- yRangeList := make([]*Range, len(opt.YAxisList))
-
- for index, yAxis := range opt.YAxisList {
- var yRange *Range
- if !yAxis.Hidden {
- yRange, err = drawYAxis(d, opt, index, xAxisHeight, chart.Box{
- Top: titleBox.Height(),
- })
- if err != nil {
- return nil, err
- }
- yRangeList[index] = yRange
- }
- }
- return &basicRenderResult{
- xRange: xRange,
- yRangeList: yRangeList,
- d: d,
- titleBox: titleBox,
- }, nil
-}
diff --git a/chart_option.go b/chart_option.go
index 5e25873..d80a383 100644
--- a/chart_option.go
+++ b/chart_option.go
@@ -23,13 +23,72 @@
package charts
import (
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "sort"
+
+ "github.com/golang/freetype/truetype"
)
+type ChartOption struct {
+ theme ColorPalette
+ font *truetype.Font
+ // The output type of chart, "svg" or "png", default value is "svg"
+ Type string
+ // The font family, which should be installed first
+ FontFamily string
+ // The theme of chart, "light" and "dark".
+ // The default theme is "light"
+ Theme string
+ // The title option
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ // The x axis option
+ XAxis XAxisOption
+ // The y axis option list
+ YAxisOptions []YAxisOption
+ // The width of chart, default width is 600
+ Width int
+ // The height of chart, default height is 400
+ Height int
+ Parent *Painter
+ // The padding for chart, default padding is [20, 10, 10, 10]
+ Padding Box
+ // The canvas box for chart
+ Box Box
+ // The series list
+ SeriesList SeriesList
+ // The radar indicator list
+ RadarIndicators []RadarIndicator
+ // The background color of chart
+ BackgroundColor Color
+ // The flag for show symbol of line, set this to *false will hide symbol
+ SymbolShow *bool
+ // The stroke width of line chart
+ LineStrokeWidth float64
+ // The bar with of bar chart
+ BarWidth int
+ // The margin of each bar
+ BarMargin int
+ // The bar height of horizontal bar chart
+ BarHeight int
+ // Fill the area of line chart
+ FillArea bool
+ // background fill (alpha) opacity
+ Opacity uint8
+ // The child charts
+ Children []ChartOption
+ // The value formatter
+ ValueFormatter ValueFormatter
+}
+
// OptionFunc option function
type OptionFunc func(opt *ChartOption)
+// SVGTypeOption set svg type of chart's output
+func SVGTypeOption() OptionFunc {
+ return TypeOptionFunc(ChartOutputSVG)
+}
+
// PNGTypeOption set png type of chart's output
func PNGTypeOption() OptionFunc {
return TypeOptionFunc(ChartOutputPNG)
@@ -63,6 +122,16 @@ func TitleOptionFunc(title TitleOption) OptionFunc {
}
}
+// TitleTextOptionFunc set title text of chart
+func TitleTextOptionFunc(text string, subtext ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Title.Text = text
+ if len(subtext) != 0 {
+ opt.Title.Subtext = subtext[0]
+ }
+ }
+}
+
// LegendOptionFunc set legend of chart
func LegendOptionFunc(legend LegendOption) OptionFunc {
return func(opt *ChartOption) {
@@ -70,6 +139,13 @@ func LegendOptionFunc(legend LegendOption) OptionFunc {
}
}
+// LegendLabelsOptionFunc set legend labels of chart
+func LegendLabelsOptionFunc(labels []string, left ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Legend = NewLegendOption(labels, left...)
+ }
+}
+
// XAxisOptionFunc set x axis of chart
func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc {
return func(opt *ChartOption) {
@@ -77,10 +153,24 @@ func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc {
}
}
+// XAxisDataOptionFunc set x axis data of chart
+func XAxisDataOptionFunc(data []string, boundaryGap ...*bool) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.XAxis = NewXAxisOption(data, boundaryGap...)
+ }
+}
+
// YAxisOptionFunc set y axis of chart, support two y axis
func YAxisOptionFunc(yAxisOption ...YAxisOption) OptionFunc {
return func(opt *ChartOption) {
- opt.YAxisList = yAxisOption
+ opt.YAxisOptions = yAxisOption
+ }
+}
+
+// YAxisDataOptionFunc set y axis data of chart
+func YAxisDataOptionFunc(data []string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.YAxisOptions = NewYAxisOptions(data)
}
}
@@ -99,19 +189,28 @@ func HeightOptionFunc(height int) OptionFunc {
}
// PaddingOptionFunc set padding of chart
-func PaddingOptionFunc(padding chart.Box) OptionFunc {
+func PaddingOptionFunc(padding Box) OptionFunc {
return func(opt *ChartOption) {
opt.Padding = padding
}
}
// BoxOptionFunc set box of chart
-func BoxOptionFunc(box chart.Box) OptionFunc {
+func BoxOptionFunc(box Box) OptionFunc {
return func(opt *ChartOption) {
opt.Box = box
}
}
+// PieSeriesShowLabel set pie series show label
+func PieSeriesShowLabel() OptionFunc {
+ return func(opt *ChartOption) {
+ for index := range opt.SeriesList {
+ opt.SeriesList[index].Label.Show = true
+ }
+ }
+}
+
// ChildOptionFunc add child chart
func ChildOptionFunc(child ...ChartOption) OptionFunc {
return func(opt *ChartOption) {
@@ -123,68 +222,205 @@ func ChildOptionFunc(child ...ChartOption) OptionFunc {
}
// RadarIndicatorOptionFunc set radar indicator of chart
-func RadarIndicatorOptionFunc(radarIndicator ...RadarIndicator) OptionFunc {
+func RadarIndicatorOptionFunc(names []string, values []float64) OptionFunc {
return func(opt *ChartOption) {
- opt.RadarIndicators = radarIndicator
+ opt.RadarIndicators = NewRadarIndicators(names, values)
}
}
// BackgroundColorOptionFunc set background color of chart
-func BackgroundColorOptionFunc(color drawing.Color) OptionFunc {
+func BackgroundColorOptionFunc(color Color) OptionFunc {
return func(opt *ChartOption) {
opt.BackgroundColor = color
}
}
-// LineRender line chart render
-func LineRender(values [][]float64, opts ...OptionFunc) (*Draw, error) {
- seriesList := make(SeriesList, len(values))
- for index, value := range values {
- seriesList[index] = NewSeriesFromValues(value, ChartTypeLine)
+// MarkLineOptionFunc set mark line for series of chart
+func MarkLineOptionFunc(seriesIndex int, markLineTypes ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ if len(opt.SeriesList) <= seriesIndex {
+ return
+ }
+ opt.SeriesList[seriesIndex].MarkLine = NewMarkLine(markLineTypes...)
}
+}
+
+// MarkPointOptionFunc set mark point for series of chart
+func MarkPointOptionFunc(seriesIndex int, markPointTypes ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ if len(opt.SeriesList) <= seriesIndex {
+ return
+ }
+ opt.SeriesList[seriesIndex].MarkPoint = NewMarkPoint(markPointTypes...)
+ }
+}
+
+func (o *ChartOption) fillDefault() {
+ t := NewTheme(o.Theme)
+ o.theme = t
+ // 如果为空,初始化
+ axisCount := 1
+ for _, series := range o.SeriesList {
+ if series.AxisIndex >= axisCount {
+ axisCount++
+ }
+ }
+ o.Width = getDefaultInt(o.Width, defaultChartWidth)
+ o.Height = getDefaultInt(o.Height, defaultChartHeight)
+ yAxisOptions := make([]YAxisOption, axisCount)
+ copy(yAxisOptions, o.YAxisOptions)
+ o.YAxisOptions = yAxisOptions
+ o.font, _ = GetFont(o.FontFamily)
+
+ if o.font == nil {
+ o.font, _ = GetDefaultFont()
+ } else {
+ // 如果指定了字体,则设置主题的字体
+ t.SetFont(o.font)
+ }
+ if o.BackgroundColor.IsZero() {
+ o.BackgroundColor = t.GetBackgroundColor()
+ }
+ if o.Padding.IsZero() {
+ o.Padding = Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ }
+ }
+ // legend与series name的关联
+ if len(o.Legend.Data) == 0 {
+ o.Legend.Data = o.SeriesList.Names()
+ } else {
+ seriesCount := len(o.SeriesList)
+ for index, name := range o.Legend.Data {
+ if index < seriesCount &&
+ len(o.SeriesList[index].Name) == 0 {
+ o.SeriesList[index].Name = name
+ }
+ }
+ nameIndexDict := map[string]int{}
+ for index, name := range o.Legend.Data {
+ nameIndexDict[name] = index
+ }
+ // 保证series的顺序与legend一致
+ sort.Slice(o.SeriesList, func(i, j int) bool {
+ return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name]
+ })
+ }
+}
+
+// LineRender line chart render
+func LineRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeLine)
return Render(ChartOption{
SeriesList: seriesList,
}, opts...)
}
// BarRender bar chart render
-func BarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) {
- seriesList := make(SeriesList, len(values))
- for index, value := range values {
- seriesList[index] = NewSeriesFromValues(value, ChartTypeBar)
- }
+func BarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeBar)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// HorizontalBarRender horizontal bar chart render
+func HorizontalBarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeHorizontalBar)
return Render(ChartOption{
SeriesList: seriesList,
}, opts...)
}
// PieRender pie chart render
-func PieRender(values []float64, opts ...OptionFunc) (*Draw, error) {
+func PieRender(values []float64, opts ...OptionFunc) (*Painter, error) {
return Render(ChartOption{
SeriesList: NewPieSeriesList(values),
}, opts...)
}
// RadarRender radar chart render
-func RadarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) {
- seriesList := make(SeriesList, len(values))
- for index, value := range values {
- seriesList[index] = NewSeriesFromValues(value, ChartTypeRadar)
- }
+func RadarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeRadar)
return Render(ChartOption{
SeriesList: seriesList,
}, opts...)
}
// FunnelRender funnel chart render
-func FunnelRender(values []float64, opts ...OptionFunc) (*Draw, error) {
- seriesList := make(SeriesList, len(values))
- for index, value := range values {
- seriesList[index] = NewSeriesFromValues([]float64{
- value,
- }, ChartTypeFunnel)
- }
+func FunnelRender(values []float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewFunnelSeriesList(values)
return Render(ChartOption{
SeriesList: seriesList,
}, opts...)
}
+
+// TableRender table chart render
+func TableRender(header []string, data [][]string, spanMaps ...map[int]int) (*Painter, error) {
+ opt := TableChartOption{
+ Header: header,
+ Data: data,
+ }
+ if len(spanMaps) != 0 {
+ spanMap := spanMaps[0]
+ spans := make([]int, len(opt.Header))
+ for index := range spans {
+ v, ok := spanMap[index]
+ if !ok {
+ v = 1
+ }
+ spans[index] = v
+ }
+ opt.Spans = spans
+ }
+ return TableOptionRender(opt)
+}
+
+// TableOptionRender table render with option
+func TableOptionRender(opt TableChartOption) (*Painter, error) {
+ if opt.Type == "" {
+ opt.Type = ChartOutputPNG
+ }
+ if opt.Width <= 0 {
+ opt.Width = defaultChartWidth
+ }
+ if opt.FontFamily != "" {
+ opt.Font, _ = GetFont(opt.FontFamily)
+ }
+ if opt.Font == nil {
+ opt.Font, _ = GetDefaultFont()
+ }
+
+ p, err := NewPainter(PainterOptions{
+ Type: opt.Type,
+ Width: opt.Width,
+ // 仅用于计算表格高度,因此随便设置即可
+ Height: 100,
+ Font: opt.Font,
+ })
+ if err != nil {
+ return nil, err
+ }
+ info, err := NewTableChart(p, opt).render()
+ if err != nil {
+ return nil, err
+ }
+
+ p, err = NewPainter(PainterOptions{
+ Type: opt.Type,
+ Width: info.Width,
+ Height: info.Height,
+ Font: opt.Font,
+ })
+ if err != nil {
+ return nil, err
+ }
+ _, err = NewTableChart(p, opt).renderWithInfo(info)
+ if err != nil {
+ return nil, err
+ }
+ return p, nil
+}
diff --git a/chart_option_test.go b/chart_option_test.go
index 41e8d50..c354b26 100644
--- a/chart_option_test.go
+++ b/chart_option_test.go
@@ -26,213 +26,426 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
-func TestOptionFunc(t *testing.T) {
+func TestChartOption(t *testing.T) {
assert := assert.New(t)
fns := []OptionFunc{
- TypeOptionFunc(ChartOutputPNG),
+ SVGTypeOption(),
FontFamilyOptionFunc("fontFamily"),
- ThemeOptionFunc("black"),
- TitleOptionFunc(TitleOption{
- Text: "title",
+ ThemeOptionFunc("theme"),
+ TitleTextOptionFunc("title"),
+ LegendLabelsOptionFunc([]string{
+ "label",
}),
- LegendOptionFunc(LegendOption{
- Data: []string{
- "a",
- "b",
- },
+ XAxisDataOptionFunc([]string{
+ "xaxis",
}),
- XAxisOptionFunc(NewXAxisOption([]string{
- "Mon",
- "Tue",
- })),
- YAxisOptionFunc(YAxisOption{
- Min: NewFloatPoint(0),
- Max: NewFloatPoint(100),
+ YAxisDataOptionFunc([]string{
+ "yaxis",
}),
- WidthOptionFunc(400),
- HeightOptionFunc(300),
- PaddingOptionFunc(chart.Box{
- Top: 10,
- }),
- BoxOptionFunc(chart.Box{
- Left: 0,
- Right: 300,
- }),
- ChildOptionFunc(ChartOption{}),
- RadarIndicatorOptionFunc(RadarIndicator{
- Min: 0,
- Max: 10,
+ WidthOptionFunc(800),
+ HeightOptionFunc(600),
+ PaddingOptionFunc(Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
}),
BackgroundColorOptionFunc(drawing.ColorBlack),
}
-
opt := ChartOption{}
for _, fn := range fns {
fn(&opt)
}
+ assert.Equal(ChartOption{
+ Type: ChartOutputSVG,
+ FontFamily: "fontFamily",
+ Theme: "theme",
+ Title: TitleOption{
+ Text: "title",
+ },
+ Legend: LegendOption{
+ Data: []string{
+ "label",
+ },
+ },
+ XAxis: XAxisOption{
+ Data: []string{
+ "xaxis",
+ },
+ },
+ YAxisOptions: []YAxisOption{
+ {
+ Data: []string{
+ "yaxis",
+ },
+ },
+ },
+ Width: 800,
+ Height: 600,
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+ BackgroundColor: drawing.ColorBlack,
+ }, opt)
+}
- assert.Equal("png", opt.Type)
- assert.Equal("fontFamily", opt.FontFamily)
- assert.Equal("black", opt.Theme)
- assert.Equal(TitleOption{
- Text: "title",
- }, opt.Title)
- assert.Equal(LegendOption{
- Data: []string{
- "a",
- "b",
- },
- }, opt.Legend)
- assert.Equal(NewXAxisOption([]string{
- "Mon",
- "Tue",
- }), opt.XAxis)
- assert.Equal([]YAxisOption{
- {
- Min: NewFloatPoint(0),
- Max: NewFloatPoint(100),
- },
- }, opt.YAxisList)
- assert.Equal(400, opt.Width)
- assert.Equal(300, opt.Height)
- assert.Equal(chart.Box{
- Top: 10,
- }, opt.Padding)
- assert.Equal(chart.Box{
- Left: 0,
- Right: 300,
- }, opt.Box)
- assert.Equal(1, len(opt.Children))
- assert.Equal([]RadarIndicator{
- {
- Min: 0,
- Max: 10,
- },
- }, opt.RadarIndicators)
- assert.Equal(drawing.ColorBlack, opt.BackgroundColor)
+func TestChartOptionPieSeriesShowLabel(t *testing.T) {
+ assert := assert.New(t)
+
+ opt := ChartOption{
+ SeriesList: NewPieSeriesList([]float64{
+ 1,
+ 2,
+ }),
+ }
+ PieSeriesShowLabel()(&opt)
+ assert.True(opt.SeriesList[0].Label.Show)
+}
+
+func TestChartOptionMarkLine(t *testing.T) {
+ assert := assert.New(t)
+ opt := ChartOption{
+ SeriesList: NewSeriesListDataFromValues([][]float64{
+ {1, 2},
+ }),
+ }
+ MarkLineOptionFunc(0, "min", "max")(&opt)
+ assert.Equal(NewMarkLine("min", "max"), opt.SeriesList[0].MarkLine)
+}
+
+func TestChartOptionMarkPoint(t *testing.T) {
+ assert := assert.New(t)
+ opt := ChartOption{
+ SeriesList: NewSeriesListDataFromValues([][]float64{
+ {1, 2},
+ }),
+ }
+ MarkPointOptionFunc(0, "min", "max")(&opt)
+ assert.Equal(NewMarkPoint("min", "max"), opt.SeriesList[0].MarkPoint)
}
func TestLineRender(t *testing.T) {
assert := assert.New(t)
-
- d, err := LineRender([][]float64{
+ values := [][]float64{
{
- 1,
- 2,
- 3,
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
},
{
- 1,
- 5,
- 2,
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
},
- },
- XAxisOptionFunc(NewXAxisOption([]string{
- "01",
- "02",
- "03",
- })),
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ p, err := LineRender(
+ values,
+ SVGTypeOption(),
+ TitleTextOptionFunc("Line"),
+ XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ LegendLabelsOptionFunc([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }, PositionCenter),
)
assert.Nil(err)
- data, err := d.Bytes()
+ data, err := p.Bytes()
assert.Nil(err)
- assert.Equal("\\n01 02 03 0 2 4 6 8 10 12 ", string(data))
+ assert.Equal("\\nEmail Union Ads Video Ads Direct Search Engine Line 1.44k 1.2k 960 720 480 240 0 Mon Tue Wed Thu Fri Sat Sun ", string(data))
}
func TestBarRender(t *testing.T) {
assert := assert.New(t)
-
- d, err := BarRender([][]float64{
+ values := [][]float64{
{
- 1,
- 2,
- 3,
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
},
{
- 1,
- 5,
- 2,
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ },
+ }
+ p, err := BarRender(
+ values,
+ SVGTypeOption(),
+ XAxisDataOptionFunc([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ LegendLabelsOptionFunc([]string{
+ "Rainfall",
+ "Evaporation",
+ }, PositionRight),
+ MarkLineOptionFunc(0, SeriesMarkDataTypeAverage),
+ MarkPointOptionFunc(0, SeriesMarkDataTypeMax,
+ SeriesMarkDataTypeMin),
+ // custom option func
+ func(opt *ChartOption) {
+ opt.SeriesList[1].MarkPoint = NewMarkPoint(
+ SeriesMarkDataTypeMax,
+ SeriesMarkDataTypeMin,
+ )
+ opt.SeriesList[1].MarkLine = NewMarkLine(
+ SeriesMarkDataTypeAverage,
+ )
},
- },
- XAxisOptionFunc(NewXAxisOption([]string{
- "01",
- "02",
- "03",
- })),
)
assert.Nil(err)
- data, err := d.Bytes()
+ data, err := p.Bytes()
assert.Nil(err)
- assert.Equal("\\n01 02 03 0 2 4 6 8 10 12 ", string(data))
+ assert.Equal("\\nRainfall Evaporation 240 200 160 120 80 40 0 Feb May Aug Nov 162.2 2 182.2 2.3 41.62 48.07 ", string(data))
+}
+
+func TestHorizontalBarRender(t *testing.T) {
+ assert := assert.New(t)
+ values := [][]float64{
+ {
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230,
+ },
+ {
+ 19325,
+ 23438,
+ 31000,
+ 121594,
+ 134141,
+ 681807,
+ },
+ }
+ p, err := HorizontalBarRender(
+ values,
+ SVGTypeOption(),
+ TitleTextOptionFunc("World Population"),
+ PaddingOptionFunc(Box{
+ Top: 20,
+ Right: 40,
+ Bottom: 20,
+ Left: 20,
+ }),
+ LegendLabelsOptionFunc([]string{
+ "2011",
+ "2012",
+ }),
+ YAxisDataOptionFunc([]string{
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ )
+ assert.Nil(err)
+ data, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("\\n2011 2012 World Population World China India USA Indonesia Brazil 0 122.28k 244.56k 366.84k 489.12k 611.4k 733.68k ", string(data))
}
func TestPieRender(t *testing.T) {
assert := assert.New(t)
-
- d, err := PieRender([]float64{
- 1,
- 3,
- 5,
- })
+ values := []float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }
+ p, err := PieRender(
+ values,
+ SVGTypeOption(),
+ TitleOptionFunc(TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ Left: PositionCenter,
+ }),
+ PaddingOptionFunc(Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ }),
+ LegendOptionFunc(LegendOption{
+ Orient: OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: PositionLeft,
+ }),
+ PieSeriesShowLabel(),
+ )
assert.Nil(err)
- data, err := d.Bytes()
+ data, err := p.Bytes()
assert.Nil(err)
- assert.Equal("\\n ", string(data))
+ assert.Equal("\\nSearch Engine Direct Email Union Ads Video Ads Rainfall vs Evaporation Fake Data Search Engine: 33.3% Direct: 23.35% Email: 18.43% Union Ads: 15.37% Video Ads: 9.53% ", string(data))
}
func TestRadarRender(t *testing.T) {
assert := assert.New(t)
- d, err := RadarRender([][]float64{
+
+ values := [][]float64{
{
- 1,
- 2,
- 3,
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
},
{
- 1,
- 5,
- 2,
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
},
- },
- RadarIndicatorOptionFunc([]RadarIndicator{
- {
- Name: "A",
- Min: 0,
- Max: 10,
- },
- {
- Name: "B",
- Min: 0,
- Max: 10,
- },
- {
- Name: "C",
- Min: 0,
- Max: 10,
- },
- }...),
+ }
+ p, err := RadarRender(
+ values,
+ SVGTypeOption(),
+ TitleTextOptionFunc("Basic Radar Chart"),
+ LegendLabelsOptionFunc([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ RadarIndicatorOptionFunc([]string{
+ "Sales",
+ "Administration",
+ "Information Technology",
+ "Customer Support",
+ "Development",
+ "Marketing",
+ }, []float64{
+ 6500,
+ 16000,
+ 30000,
+ 38000,
+ 52000,
+ 25000,
+ }),
)
assert.Nil(err)
- data, err := d.Bytes()
+ data, err := p.Bytes()
assert.Nil(err)
- assert.Equal("\\nA B C ", string(data))
+ assert.Equal("\\nAllocated Budget Actual Spending Basic Radar Chart Sales Administration Information Technology Customer Support Development Marketing ", string(data))
}
func TestFunnelRender(t *testing.T) {
assert := assert.New(t)
- d, err := FunnelRender([]float64{
- 5,
- 3,
- 1,
- })
+ values := []float64{
+ 100,
+ 80,
+ 60,
+ 40,
+ 20,
+ }
+ p, err := FunnelRender(
+ values,
+ SVGTypeOption(),
+ TitleTextOptionFunc("Funnel"),
+ LegendLabelsOptionFunc([]string{
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order",
+ }),
+ )
assert.Nil(err)
- data, err := d.Bytes()
+ data, err := p.Bytes()
assert.Nil(err)
- assert.Equal("\\n(100%) (60%) (20%) ", string(data))
+ assert.Equal("\\nShow Click Visit Inquiry Order Funnel Show(100%) Click(80%) Visit(60%) Inquiry(40%) Order(20%) ", string(data))
}
diff --git a/chart_test.go b/chart_test.go
deleted file mode 100644
index c73745e..0000000
--- a/chart_test.go
+++ /dev/null
@@ -1,567 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "errors"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
-)
-
-func TestChartSetDefaultWidthHeight(t *testing.T) {
- assert := assert.New(t)
-
- width := defaultChartWidth
- height := defaultChartHeight
- defer SetDefaultWidth(width)
- defer SetDefaultHeight(height)
-
- SetDefaultWidth(60)
- assert.Equal(60, defaultChartWidth)
- SetDefaultHeight(40)
- assert.Equal(40, defaultChartHeight)
-}
-
-func TestChartFillDefault(t *testing.T) {
- assert := assert.New(t)
- // default value
- opt := ChartOption{}
- opt.FillDefault("")
- // padding
- assert.Equal(chart.Box{
- Top: 10,
- Right: 10,
- Bottom: 10,
- Left: 10,
- }, opt.Padding)
- // background color
- assert.Equal(drawing.ColorWhite, opt.BackgroundColor)
- // title font color
- assert.Equal(drawing.Color{
- R: 70,
- G: 70,
- B: 70,
- A: 255,
- }, opt.Title.Style.FontColor)
- // title font size
- assert.Equal(float64(14), opt.Title.Style.FontSize)
- // sub title font color
- assert.Equal(drawing.Color{
- R: 70,
- G: 70,
- B: 70,
- A: 180,
- }, opt.Title.SubtextStyle.FontColor)
- // sub title font size
- assert.Equal(float64(10), opt.Title.SubtextStyle.FontSize)
- // legend font size
- assert.Equal(float64(10), opt.Legend.Style.FontSize)
- // legend position
- assert.Equal("center", opt.Legend.Left)
- assert.Equal(drawing.Color{
- R: 70,
- G: 70,
- B: 70,
- A: 255,
- }, opt.Legend.Style.FontColor)
-
- // y axis
- opt = ChartOption{
- SeriesList: SeriesList{
- {
- YAxisIndex: 1,
- },
- },
- }
- opt.FillDefault("")
- assert.Equal([]YAxisOption{
- {},
- {},
- }, opt.YAxisList)
- opt = ChartOption{}
- opt.FillDefault("")
- assert.Equal([]YAxisOption{
- {},
- }, opt.YAxisList)
-
- // legend get from series's name
-
- opt = ChartOption{
- SeriesList: SeriesList{
- {
- Name: "a",
- },
- {
- Name: "b",
- },
- },
- }
- opt.FillDefault("")
- assert.Equal([]string{
- "a",
- "b",
- }, opt.Legend.Data)
- // series name set by legend
- opt = ChartOption{
- Legend: LegendOption{
- Data: []string{
- "a",
- "b",
- },
- },
- SeriesList: SeriesList{
- {},
- {},
- },
- }
- opt.FillDefault("")
- assert.Equal("a", opt.SeriesList[0].Name)
- assert.Equal("b", opt.SeriesList[1].Name)
-}
-
-func TestChartGetWidthHeight(t *testing.T) {
- assert := assert.New(t)
-
- opt := ChartOption{
- Width: 10,
- }
- assert.Equal(10, opt.getWidth())
- opt.Width = 0
- assert.Equal(600, opt.getWidth())
- opt.Parent = &Draw{
- Box: chart.Box{
- Left: 10,
- Right: 50,
- },
- }
- assert.Equal(40, opt.getWidth())
-
- opt = ChartOption{
- Height: 20,
- }
- assert.Equal(20, opt.getHeight())
- opt.Height = 0
- assert.Equal(400, opt.getHeight())
- opt.Parent = &Draw{
- Box: chart.Box{
- Top: 20,
- Bottom: 80,
- },
- }
- assert.Equal(60, opt.getHeight())
-}
-
-func TestChartRender(t *testing.T) {
- assert := assert.New(t)
-
- d, err := Render(ChartOption{
- Width: 800,
- Height: 600,
- Legend: LegendOption{
- Top: "-90",
- Data: []string{
- "Milk Tea",
- "Matcha Latte",
- "Cheese Cocoa",
- "Walnut Brownie",
- },
- },
- Padding: chart.Box{
- Top: 100,
- },
- XAxis: NewXAxisOption([]string{
- "2012",
- "2013",
- "2014",
- "2015",
- "2016",
- "2017",
- }),
- YAxisList: []YAxisOption{
- {
-
- Min: NewFloatPoint(0),
- Max: NewFloatPoint(90),
- },
- },
- SeriesList: []Series{
- NewSeriesFromValues([]float64{
- 56.5,
- 82.1,
- 88.7,
- 70.1,
- 53.4,
- 85.1,
- }),
- NewSeriesFromValues([]float64{
- 51.1,
- 51.4,
- 55.1,
- 53.3,
- 73.8,
- 68.7,
- }),
- NewSeriesFromValues([]float64{
- 40.1,
- 62.2,
- 69.5,
- 36.4,
- 45.2,
- 32.5,
- }, ChartTypeBar),
- NewSeriesFromValues([]float64{
- 25.2,
- 37.1,
- 41.2,
- 18,
- 33.9,
- 49.1,
- }, ChartTypeBar),
- },
- Children: []ChartOption{
- {
- Legend: LegendOption{
- Show: FalseFlag(),
- Data: []string{
- "Milk Tea",
- "Matcha Latte",
- "Cheese Cocoa",
- "Walnut Brownie",
- },
- },
- Box: chart.Box{
- Top: 20,
- Left: 400,
- Right: 500,
- Bottom: 120,
- },
- SeriesList: NewPieSeriesList([]float64{
- 435.9,
- 354.3,
- 285.9,
- 204.5,
- }, PieSeriesOption{
- Label: SeriesLabel{
- Show: true,
- },
- Radius: "35%",
- }),
- },
- {
- Legend: NewLegendOption([]string{
- "Allocated Budget",
- "Actual Spending",
- }),
- Box: chart.Box{
- Top: 20,
- Left: 0,
- Right: 200,
- Bottom: 120,
- },
- RadarIndicators: []RadarIndicator{
- {
- Name: "Sales",
- Max: 6500,
- },
- {
- Name: "Administration",
- Max: 16000,
- },
- {
- Name: "Information Technology",
- Max: 30000,
- },
- {
- Name: "Customer Support",
- Max: 38000,
- },
- {
- Name: "Development",
- Max: 52000,
- },
- {
- Name: "Marketing",
- Max: 25000,
- },
- },
- SeriesList: SeriesList{
- {
- Type: ChartTypeRadar,
- Data: NewSeriesDataFromValues([]float64{
- 4200,
- 3000,
- 20000,
- 35000,
- 50000,
- 18000,
- }),
- },
- {
- Type: ChartTypeRadar,
- index: 1,
- Data: NewSeriesDataFromValues([]float64{
- 5000,
- 14000,
- 28000,
- 26000,
- 42000,
- 21000,
- }),
- },
- },
- },
- },
- })
- assert.Nil(err)
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal("\\n2012 2013 2014 2015 2016 2017 0 15 30 45 60 75 90 Milk Tea Matcha Latte Cheese Cocoa Walnut Brownie Milk Tea: 34.03% Matcha Latte: 27.66% Cheese Cocoa: 22.32% Walnut Brownie: 15.96% Sales Administration Information Technology Customer Support Development Marketing Allocated Budget Actual Spending ", string(data))
-}
-
-func BenchmarkMultiChartPNGRender(b *testing.B) {
- for i := 0; i < b.N; i++ {
- opt := ChartOption{
- Type: ChartOutputPNG,
- Legend: LegendOption{
- Top: "-90",
- Data: []string{
- "Milk Tea",
- "Matcha Latte",
- "Cheese Cocoa",
- "Walnut Brownie",
- },
- },
- Padding: chart.Box{
- Top: 100,
- Right: 10,
- Bottom: 10,
- Left: 10,
- },
- XAxis: NewXAxisOption([]string{
- "2012",
- "2013",
- "2014",
- "2015",
- "2016",
- "2017",
- }),
- YAxisList: []YAxisOption{
- {
-
- Min: NewFloatPoint(0),
- Max: NewFloatPoint(90),
- },
- },
- SeriesList: []Series{
- NewSeriesFromValues([]float64{
- 56.5,
- 82.1,
- 88.7,
- 70.1,
- 53.4,
- 85.1,
- }),
- NewSeriesFromValues([]float64{
- 51.1,
- 51.4,
- 55.1,
- 53.3,
- 73.8,
- 68.7,
- }),
- NewSeriesFromValues([]float64{
- 40.1,
- 62.2,
- 69.5,
- 36.4,
- 45.2,
- 32.5,
- }, ChartTypeBar),
- NewSeriesFromValues([]float64{
- 25.2,
- 37.1,
- 41.2,
- 18,
- 33.9,
- 49.1,
- }, ChartTypeBar),
- },
- Children: []ChartOption{
- {
- Legend: LegendOption{
- Show: FalseFlag(),
- Data: []string{
- "Milk Tea",
- "Matcha Latte",
- "Cheese Cocoa",
- "Walnut Brownie",
- },
- },
- Box: chart.Box{
- Top: 20,
- Left: 400,
- Right: 500,
- Bottom: 120,
- },
- SeriesList: NewPieSeriesList([]float64{
- 435.9,
- 354.3,
- 285.9,
- 204.5,
- }, PieSeriesOption{
- Label: SeriesLabel{
- Show: true,
- },
- Radius: "35%",
- }),
- },
- },
- }
- d, err := Render(opt)
- if err != nil {
- panic(err)
- }
- buf, err := d.Bytes()
- if err != nil {
- panic(err)
- }
- if len(buf) == 0 {
- panic(errors.New("data is nil"))
- }
- }
-}
-
-func BenchmarkMultiChartSVGRender(b *testing.B) {
- for i := 0; i < b.N; i++ {
- opt := ChartOption{
- Legend: LegendOption{
- Top: "-90",
- Data: []string{
- "Milk Tea",
- "Matcha Latte",
- "Cheese Cocoa",
- "Walnut Brownie",
- },
- },
- Padding: chart.Box{
- Top: 100,
- Right: 10,
- Bottom: 10,
- Left: 10,
- },
- XAxis: NewXAxisOption([]string{
- "2012",
- "2013",
- "2014",
- "2015",
- "2016",
- "2017",
- }),
- YAxisList: []YAxisOption{
- {
-
- Min: NewFloatPoint(0),
- Max: NewFloatPoint(90),
- },
- },
- SeriesList: []Series{
- NewSeriesFromValues([]float64{
- 56.5,
- 82.1,
- 88.7,
- 70.1,
- 53.4,
- 85.1,
- }),
- NewSeriesFromValues([]float64{
- 51.1,
- 51.4,
- 55.1,
- 53.3,
- 73.8,
- 68.7,
- }),
- NewSeriesFromValues([]float64{
- 40.1,
- 62.2,
- 69.5,
- 36.4,
- 45.2,
- 32.5,
- }, ChartTypeBar),
- NewSeriesFromValues([]float64{
- 25.2,
- 37.1,
- 41.2,
- 18,
- 33.9,
- 49.1,
- }, ChartTypeBar),
- },
- Children: []ChartOption{
- {
- Legend: LegendOption{
- Show: FalseFlag(),
- Data: []string{
- "Milk Tea",
- "Matcha Latte",
- "Cheese Cocoa",
- "Walnut Brownie",
- },
- },
- Box: chart.Box{
- Top: 20,
- Left: 400,
- Right: 500,
- Bottom: 120,
- },
- SeriesList: NewPieSeriesList([]float64{
- 435.9,
- 354.3,
- 285.9,
- 204.5,
- }, PieSeriesOption{
- Label: SeriesLabel{
- Show: true,
- },
- Radius: "35%",
- }),
- },
- },
- }
- d, err := Render(opt)
- if err != nil {
- panic(err)
- }
- buf, err := d.Bytes()
- if err != nil {
- panic(err)
- }
- if len(buf) == 0 {
- panic(errors.New("data is nil"))
- }
- }
-}
diff --git a/charts.go b/charts.go
new file mode 100644
index 0000000..31df11c
--- /dev/null
+++ b/charts.go
@@ -0,0 +1,473 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "errors"
+ "math"
+ "sort"
+
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+const labelFontSize = 10
+const smallLabelFontSize = 8
+const defaultDotWidth = 2.0
+const defaultStrokeWidth = 2.0
+
+var defaultChartWidth = 600
+var defaultChartHeight = 400
+
+// SetDefaultWidth sets default width of chart
+func SetDefaultWidth(width int) {
+ if width > 0 {
+ defaultChartWidth = width
+ }
+}
+
+// SetDefaultHeight sets default height of chart
+func SetDefaultHeight(height int) {
+ if height > 0 {
+ defaultChartHeight = height
+ }
+}
+
+var nullValue = math.MaxFloat64
+
+// SetNullValue sets the null value, default is MaxFloat64
+func SetNullValue(v float64) {
+ nullValue = v
+}
+
+// GetNullValue gets the null value
+func GetNullValue() float64 {
+ return nullValue
+}
+
+type Renderer interface {
+ Render() (Box, error)
+}
+
+type renderHandler struct {
+ list []func() error
+}
+
+func (rh *renderHandler) Add(fn func() error) {
+ list := rh.list
+ if len(list) == 0 {
+ list = make([]func() error, 0)
+ }
+ rh.list = append(list, fn)
+}
+
+func (rh *renderHandler) Do() error {
+ for _, fn := range rh.list {
+ err := fn()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type defaultRenderOption struct {
+ Theme ColorPalette
+ Padding Box
+ SeriesList SeriesList
+ // The y axis option
+ YAxisOptions []YAxisOption
+ // The x axis option
+ XAxis XAxisOption
+ // The title option
+ TitleOption TitleOption
+ // The legend option
+ LegendOption LegendOption
+ // background is filled
+ backgroundIsFilled bool
+ // x y axis is reversed
+ axisReversed bool
+}
+
+type defaultRenderResult struct {
+ axisRanges map[int]axisRange
+ // 图例区域
+ seriesPainter *Painter
+}
+
+func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, error) {
+ seriesList := opt.SeriesList
+ seriesList.init()
+ if !opt.backgroundIsFilled {
+ p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor())
+ }
+
+ if !opt.Padding.IsZero() {
+ p = p.Child(PainterPaddingOption(opt.Padding))
+ }
+
+ legendHeight := 0
+ if len(opt.LegendOption.Data) != 0 {
+ if opt.LegendOption.Theme == nil {
+ opt.LegendOption.Theme = opt.Theme
+ }
+ legendResult, err := NewLegendPainter(p, opt.LegendOption).Render()
+ if err != nil {
+ return nil, err
+ }
+ legendHeight = legendResult.Height()
+ }
+
+ // 如果有标题
+ if opt.TitleOption.Text != "" {
+ if opt.TitleOption.Theme == nil {
+ opt.TitleOption.Theme = opt.Theme
+ }
+ titlePainter := NewTitlePainter(p, opt.TitleOption)
+
+ titleBox, err := titlePainter.Render()
+ if err != nil {
+ return nil, err
+ }
+
+ top := chart.MaxInt(legendHeight, titleBox.Height())
+ // 如果是垂直方式,则不计算legend高度
+ if opt.LegendOption.Orient == OrientVertical {
+ top = titleBox.Height()
+ }
+ p = p.Child(PainterPaddingOption(Box{
+ // 标题下留白
+ Top: top + 20,
+ }))
+ }
+
+ result := defaultRenderResult{
+ axisRanges: make(map[int]axisRange),
+ }
+
+ // 计算图表对应的轴有哪些
+ axisIndexList := make([]int, 0)
+ for _, series := range opt.SeriesList {
+ if containsInt(axisIndexList, series.AxisIndex) {
+ continue
+ }
+ axisIndexList = append(axisIndexList, series.AxisIndex)
+ }
+ // 高度需要减去x轴的高度
+ rangeHeight := p.Height() - defaultXAxisHeight
+ rangeWidthLeft := 0
+ rangeWidthRight := 0
+
+ // 倒序
+ sort.Sort(sort.Reverse(sort.IntSlice(axisIndexList)))
+
+ // 计算对应的axis range
+ for _, index := range axisIndexList {
+ yAxisOption := YAxisOption{}
+ if len(opt.YAxisOptions) > index {
+ yAxisOption = opt.YAxisOptions[index]
+ }
+ divideCount := yAxisOption.DivideCount
+ if divideCount <= 0 {
+ divideCount = defaultAxisDivideCount
+ }
+ max, min := opt.SeriesList.GetMaxMin(index)
+ r := NewRange(AxisRangeOption{
+ Painter: p,
+ Min: min,
+ Max: max,
+ // 高度需要减去x轴的高度
+ Size: rangeHeight,
+ // 分隔数量
+ DivideCount: divideCount,
+ })
+ if yAxisOption.Min != nil && *yAxisOption.Min <= min {
+ r.min = *yAxisOption.Min
+ }
+ if yAxisOption.Max != nil && *yAxisOption.Max >= max {
+ r.max = *yAxisOption.Max
+ }
+ result.axisRanges[index] = r
+
+ if yAxisOption.Theme == nil {
+ yAxisOption.Theme = opt.Theme
+ }
+ if !opt.axisReversed {
+ yAxisOption.Data = r.Values()
+ } else {
+ yAxisOption.isCategoryAxis = true
+ // 由于x轴为value部分,因此计算其label单独处理
+ opt.XAxis.Data = NewRange(AxisRangeOption{
+ Painter: p,
+ Min: min,
+ Max: max,
+ // 高度需要减去x轴的高度
+ Size: rangeHeight,
+ // 分隔数量
+ DivideCount: defaultAxisDivideCount,
+ }).Values()
+ opt.XAxis.isValueAxis = true
+ }
+ reverseStringSlice(yAxisOption.Data)
+ // TODO生成其它位置既yAxis
+ var yAxis *axisPainter
+ child := p.Child(PainterPaddingOption(Box{
+ Left: rangeWidthLeft,
+ Right: rangeWidthRight,
+ }))
+ if index == 0 {
+ yAxis = NewLeftYAxis(child, yAxisOption)
+ } else {
+ yAxis = NewRightYAxis(child, yAxisOption)
+ }
+ yAxisBox, err := yAxis.Render()
+ if err != nil {
+ return nil, err
+ }
+ if index == 0 {
+ rangeWidthLeft += yAxisBox.Width()
+ } else {
+ rangeWidthRight += yAxisBox.Width()
+ }
+ }
+
+ if opt.XAxis.Theme == nil {
+ opt.XAxis.Theme = opt.Theme
+ }
+ xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{
+ Left: rangeWidthLeft,
+ Right: rangeWidthRight,
+ })), opt.XAxis)
+ _, err := xAxis.Render()
+ if err != nil {
+ return nil, err
+ }
+
+ result.seriesPainter = p.Child(PainterPaddingOption(Box{
+ Bottom: defaultXAxisHeight,
+ Left: rangeWidthLeft,
+ Right: rangeWidthRight,
+ }))
+ return &result, nil
+}
+
+func doRender(renderers ...Renderer) error {
+ for _, r := range renderers {
+ _, err := r.Render()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) {
+ for _, fn := range opts {
+ fn(&opt)
+ }
+ opt.fillDefault()
+
+ isChild := true
+ if opt.Parent == nil {
+ isChild = false
+ p, err := NewPainter(PainterOptions{
+ Type: opt.Type,
+ Width: opt.Width,
+ Height: opt.Height,
+ Font: opt.font,
+ })
+ if err != nil {
+ return nil, err
+ }
+ opt.Parent = p
+ }
+ p := opt.Parent
+ if opt.ValueFormatter != nil {
+ p.valueFormatter = opt.ValueFormatter
+ }
+ if !opt.Box.IsZero() {
+ p = p.Child(PainterBoxOption(opt.Box))
+ }
+ if !isChild {
+ p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
+ }
+ seriesList := opt.SeriesList
+ seriesList.init()
+
+ seriesCount := len(seriesList)
+
+ // line chart
+ lineSeriesList := seriesList.Filter(ChartTypeLine)
+ barSeriesList := seriesList.Filter(ChartTypeBar)
+ horizontalBarSeriesList := seriesList.Filter(ChartTypeHorizontalBar)
+ pieSeriesList := seriesList.Filter(ChartTypePie)
+ radarSeriesList := seriesList.Filter(ChartTypeRadar)
+ funnelSeriesList := seriesList.Filter(ChartTypeFunnel)
+
+ if len(horizontalBarSeriesList) != 0 && len(horizontalBarSeriesList) != seriesCount {
+ return nil, errors.New("Horizontal bar can not mix other charts")
+ }
+ if len(pieSeriesList) != 0 && len(pieSeriesList) != seriesCount {
+ return nil, errors.New("Pie can not mix other charts")
+ }
+ if len(radarSeriesList) != 0 && len(radarSeriesList) != seriesCount {
+ return nil, errors.New("Radar can not mix other charts")
+ }
+ if len(funnelSeriesList) != 0 && len(funnelSeriesList) != seriesCount {
+ return nil, errors.New("Funnel can not mix other charts")
+ }
+
+ axisReversed := len(horizontalBarSeriesList) != 0
+ renderOpt := defaultRenderOption{
+ Theme: opt.theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: opt.XAxis,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ axisReversed: axisReversed,
+ // 前置已设置背景色
+ backgroundIsFilled: true,
+ }
+ if len(pieSeriesList) != 0 ||
+ len(radarSeriesList) != 0 ||
+ len(funnelSeriesList) != 0 {
+ renderOpt.XAxis.Show = FalseFlag()
+ renderOpt.YAxisOptions = []YAxisOption{
+ {
+ Show: FalseFlag(),
+ },
+ }
+ }
+ if len(horizontalBarSeriesList) != 0 {
+ renderOpt.YAxisOptions[0].DivideCount = len(renderOpt.YAxisOptions[0].Data)
+ renderOpt.YAxisOptions[0].Unit = 1
+ }
+
+ renderResult, err := defaultRender(p, renderOpt)
+ if err != nil {
+ return nil, err
+ }
+
+ handler := renderHandler{}
+
+ // bar chart
+ if len(barSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewBarChart(p, BarChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ XAxis: opt.XAxis,
+ BarWidth: opt.BarWidth,
+ BarMargin: opt.BarMargin,
+ }).render(renderResult, barSeriesList)
+ return err
+ })
+ }
+
+ // horizontal bar chart
+ if len(horizontalBarSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewHorizontalBarChart(p, HorizontalBarChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ BarHeight: opt.BarHeight,
+ BarMargin: opt.BarMargin,
+ YAxisOptions: opt.YAxisOptions,
+ }).render(renderResult, horizontalBarSeriesList)
+ return err
+ })
+ }
+
+ // pie chart
+ if len(pieSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewPieChart(p, PieChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ }).render(renderResult, pieSeriesList)
+ return err
+ })
+ }
+
+ // line chart
+ if len(lineSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewLineChart(p, LineChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ XAxis: opt.XAxis,
+ SymbolShow: opt.SymbolShow,
+ StrokeWidth: opt.LineStrokeWidth,
+ FillArea: opt.FillArea,
+ Opacity: opt.Opacity,
+ }).render(renderResult, lineSeriesList)
+ return err
+ })
+ }
+
+ // radar chart
+ if len(radarSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewRadarChart(p, RadarChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ // 相应值
+ RadarIndicators: opt.RadarIndicators,
+ }).render(renderResult, radarSeriesList)
+ return err
+ })
+ }
+
+ // funnel chart
+ if len(funnelSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewFunnelChart(p, FunnelChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ }).render(renderResult, funnelSeriesList)
+ return err
+ })
+ }
+
+ err = handler.Do()
+
+ if err != nil {
+ return nil, err
+ }
+ for _, item := range opt.Children {
+ item.Parent = p
+ if item.Theme == "" {
+ item.Theme = opt.Theme
+ }
+ if item.FontFamily == "" {
+ item.FontFamily = opt.FontFamily
+ }
+ _, err = Render(item)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return p, nil
+}
diff --git a/charts_test.go b/charts_test.go
new file mode 100644
index 0000000..bd581e9
--- /dev/null
+++ b/charts_test.go
@@ -0,0 +1,255 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "errors"
+ "testing"
+
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+func BenchmarkMultiChartPNGRender(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ opt := ChartOption{
+ Type: ChartOutputPNG,
+ Legend: LegendOption{
+ Top: "-90",
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Padding: chart.Box{
+ Top: 100,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: NewXAxisOption([]string{
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017",
+ }),
+ YAxisOptions: []YAxisOption{
+ {
+
+ Min: NewFloatPoint(0),
+ Max: NewFloatPoint(90),
+ },
+ },
+ SeriesList: []Series{
+ NewSeriesFromValues([]float64{
+ 56.5,
+ 82.1,
+ 88.7,
+ 70.1,
+ 53.4,
+ 85.1,
+ }),
+ NewSeriesFromValues([]float64{
+ 51.1,
+ 51.4,
+ 55.1,
+ 53.3,
+ 73.8,
+ 68.7,
+ }),
+ NewSeriesFromValues([]float64{
+ 40.1,
+ 62.2,
+ 69.5,
+ 36.4,
+ 45.2,
+ 32.5,
+ }, ChartTypeBar),
+ NewSeriesFromValues([]float64{
+ 25.2,
+ 37.1,
+ 41.2,
+ 18,
+ 33.9,
+ 49.1,
+ }, ChartTypeBar),
+ },
+ Children: []ChartOption{
+ {
+ Legend: LegendOption{
+ Show: FalseFlag(),
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Box: chart.Box{
+ Top: 20,
+ Left: 400,
+ Right: 500,
+ Bottom: 120,
+ },
+ SeriesList: NewPieSeriesList([]float64{
+ 435.9,
+ 354.3,
+ 285.9,
+ 204.5,
+ }, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ },
+ Radius: "35%",
+ }),
+ },
+ },
+ }
+ d, err := Render(opt)
+ if err != nil {
+ panic(err)
+ }
+ buf, err := d.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ if len(buf) == 0 {
+ panic(errors.New("data is nil"))
+ }
+ }
+}
+
+func BenchmarkMultiChartSVGRender(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ opt := ChartOption{
+ Legend: LegendOption{
+ Top: "-90",
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Padding: chart.Box{
+ Top: 100,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: NewXAxisOption([]string{
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017",
+ }),
+ YAxisOptions: []YAxisOption{
+ {
+
+ Min: NewFloatPoint(0),
+ Max: NewFloatPoint(90),
+ },
+ },
+ SeriesList: []Series{
+ NewSeriesFromValues([]float64{
+ 56.5,
+ 82.1,
+ 88.7,
+ 70.1,
+ 53.4,
+ 85.1,
+ }),
+ NewSeriesFromValues([]float64{
+ 51.1,
+ 51.4,
+ 55.1,
+ 53.3,
+ 73.8,
+ 68.7,
+ }),
+ NewSeriesFromValues([]float64{
+ 40.1,
+ 62.2,
+ 69.5,
+ 36.4,
+ 45.2,
+ 32.5,
+ }, ChartTypeBar),
+ NewSeriesFromValues([]float64{
+ 25.2,
+ 37.1,
+ 41.2,
+ 18,
+ 33.9,
+ 49.1,
+ }, ChartTypeBar),
+ },
+ Children: []ChartOption{
+ {
+ Legend: LegendOption{
+ Show: FalseFlag(),
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Box: chart.Box{
+ Top: 20,
+ Left: 400,
+ Right: 500,
+ Bottom: 120,
+ },
+ SeriesList: NewPieSeriesList([]float64{
+ 435.9,
+ 354.3,
+ 285.9,
+ 204.5,
+ }, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ },
+ Radius: "35%",
+ }),
+ },
+ },
+ }
+ d, err := Render(opt)
+ if err != nil {
+ panic(err)
+ }
+ buf, err := d.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ if len(buf) == 0 {
+ panic(errors.New("data is nil"))
+ }
+ }
+}
diff --git a/draw.go b/draw.go
deleted file mode 100644
index 1708662..0000000
--- a/draw.go
+++ /dev/null
@@ -1,372 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "bytes"
- "errors"
- "math"
-
- "github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
-)
-
-const (
- PositionLeft = "left"
- PositionRight = "right"
- PositionCenter = "center"
- PositionTop = "top"
- PositionBottom = "bottom"
-)
-
-const (
- OrientHorizontal = "horizontal"
- OrientVertical = "vertical"
-)
-
-type Draw struct {
- // Render
- Render chart.Renderer
- // The canvas box
- Box chart.Box
- // The font for draw
- Font *truetype.Font
- // The parent of draw
- parent *Draw
-}
-
-type DrawOption struct {
- // Draw type, "svg" or "png", default type is "svg"
- Type string
- // Parent of draw
- Parent *Draw
- // The width of draw canvas
- Width int
- // The height of draw canvas
- Height int
-}
-
-type Option func(*Draw) error
-
-// PaddingOption sets the padding of draw canvas
-func PaddingOption(padding chart.Box) Option {
- return func(d *Draw) error {
- d.Box.Left += padding.Left
- d.Box.Top += padding.Top
- d.Box.Right -= padding.Right
- d.Box.Bottom -= padding.Bottom
- return nil
- }
-}
-
-// BoxOption set the box of draw canvas
-func BoxOption(box chart.Box) Option {
- return func(d *Draw) error {
- if box.IsZero() {
- return nil
- }
- d.Box = box
- return nil
- }
-}
-
-// NewDraw returns a new draw canvas
-func NewDraw(opt DrawOption, opts ...Option) (*Draw, error) {
- if opt.Parent == nil && (opt.Width <= 0 || opt.Height <= 0) {
- return nil, errors.New("parent and width/height can not be nil")
- }
- font, _ := chart.GetDefaultFont()
- d := &Draw{
- Font: font,
- }
- width := opt.Width
- height := opt.Height
- if opt.Parent != nil {
- d.parent = opt.Parent
- d.Render = d.parent.Render
- d.Box = opt.Parent.Box.Clone()
- }
- if width != 0 && height != 0 {
- d.Box.Right = width + d.Box.Left
- d.Box.Bottom = height + d.Box.Top
- }
- // 创建render
- if d.parent == nil {
- fn := chart.SVG
- if opt.Type == ChartOutputPNG {
- fn = chart.PNG
- }
- r, err := fn(d.Box.Right, d.Box.Bottom)
- if err != nil {
- return nil, err
- }
- d.Render = r
- }
-
- for _, o := range opts {
- err := o(d)
- if err != nil {
- return nil, err
- }
- }
- return d, nil
-}
-
-// Parent returns the parent of draw
-func (d *Draw) Parent() *Draw {
- return d.parent
-}
-
-// Top returns the top parent of draw
-func (d *Draw) Top() *Draw {
- if d.parent == nil {
- return nil
- }
- t := d.parent
- // 限制最多查询次数,避免嵌套引用
- for i := 50; i > 0; i-- {
- if t.parent == nil {
- break
- }
- t = t.parent
- }
- return t
-}
-
-// Bytes returns the data of draw canvas
-func (d *Draw) Bytes() ([]byte, error) {
- buffer := bytes.Buffer{}
- err := d.Render.Save(&buffer)
- if err != nil {
- return nil, err
- }
- return buffer.Bytes(), err
-}
-
-func (d *Draw) moveTo(x, y int) {
- d.Render.MoveTo(x+d.Box.Left, y+d.Box.Top)
-}
-
-func (d *Draw) arcTo(cx, cy int, rx, ry, startAngle, delta float64) {
- d.Render.ArcTo(cx+d.Box.Left, cy+d.Box.Top, rx, ry, startAngle, delta)
-}
-
-func (d *Draw) lineTo(x, y int) {
- d.Render.LineTo(x+d.Box.Left, y+d.Box.Top)
-}
-
-func (d *Draw) pin(x, y, width int) {
- r := float64(width) / 2
- y -= width / 4
- angle := chart.DegreesToRadians(15)
-
- startAngle := math.Pi/2 + angle
- delta := 2*math.Pi - 2*angle
- d.arcTo(x, y, r, r, startAngle, delta)
- d.lineTo(x, y)
- d.Render.Close()
- d.Render.FillStroke()
-
- startX := x - int(r)
- startY := y
- endX := x + int(r)
- endY := y
- d.moveTo(startX, startY)
-
- left := d.Box.Left
- top := d.Box.Top
- cx := x
- cy := y + int(r*2.5)
- d.Render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top)
- d.Render.Close()
- d.Render.Fill()
-}
-
-func (d *Draw) arrowLeft(x, y, width, height int) {
- d.arrow(x, y, width, height, PositionLeft)
-}
-
-func (d *Draw) arrowRight(x, y, width, height int) {
- d.arrow(x, y, width, height, PositionRight)
-}
-
-func (d *Draw) arrowTop(x, y, width, height int) {
- d.arrow(x, y, width, height, PositionTop)
-}
-func (d *Draw) arrowBottom(x, y, width, height int) {
- d.arrow(x, y, width, height, PositionBottom)
-}
-
-func (d *Draw) arrow(x, y, width, height int, direction string) {
- halfWidth := width >> 1
- halfHeight := height >> 1
- if direction == PositionTop || direction == PositionBottom {
- x0 := x - halfWidth
- x1 := x0 + width
- dy := -height / 3
- y0 := y
- y1 := y0 - height
- if direction == PositionBottom {
- y0 = y - height
- y1 = y
- dy = 2 * dy
- }
- d.moveTo(x0, y0)
- d.lineTo(x0+halfWidth, y1)
- d.lineTo(x1, y0)
- d.lineTo(x0+halfWidth, y+dy)
- d.lineTo(x0, y0)
- } else {
- x0 := x + width
- x1 := x0 - width
- y0 := y - halfHeight
- dx := -width / 3
- if direction == PositionRight {
- x0 = x - width
- dx = -dx
- x1 = x0 + width
- }
- d.moveTo(x0, y0)
- d.lineTo(x1, y0+halfHeight)
- d.lineTo(x0, y0+height)
- d.lineTo(x0+dx, y0+halfHeight)
- d.lineTo(x0, y0)
- }
- d.Render.FillStroke()
-}
-
-func (d *Draw) makeLine(x, y, width int) {
- arrowWidth := 16
- arrowHeight := 10
- endX := x + width
- d.circle(3, x, y)
- d.Render.Fill()
- d.moveTo(x+5, y)
- d.lineTo(endX-arrowWidth, y)
- d.Render.Stroke()
- d.Render.SetStrokeDashArray([]float64{})
- d.arrowRight(endX, y, arrowWidth, arrowHeight)
-}
-
-func (d *Draw) circle(radius float64, x, y int) {
- d.Render.Circle(radius, x+d.Box.Left, y+d.Box.Top)
-}
-
-func (d *Draw) text(body string, x, y int) {
- d.Render.Text(body, x+d.Box.Left, y+d.Box.Top)
-}
-
-func (d *Draw) textFit(body string, x, y, width int, style chart.Style) chart.Box {
- style.TextWrap = chart.TextWrapWord
- r := d.Render
- lines := chart.Text.WrapFit(r, body, width, style)
- style.WriteTextOptionsToRenderer(r)
- var output chart.Box
-
- for index, line := range lines {
- x0 := x
- y0 := y + output.Height()
- d.text(line, x0, y0)
- lineBox := r.MeasureText(line)
- output.Right = chart.MaxInt(lineBox.Right, output.Right)
- output.Bottom += lineBox.Height()
- if index < len(lines)-1 {
- output.Bottom += +style.GetTextLineSpacing()
- }
- }
- return output
-}
-
-func (d *Draw) measureTextFit(body string, x, y, width int, style chart.Style) chart.Box {
- style.TextWrap = chart.TextWrapWord
- r := d.Render
- lines := chart.Text.WrapFit(r, body, width, style)
- style.WriteTextOptionsToRenderer(r)
- return chart.Text.MeasureLines(r, lines, style)
-}
-
-func (d *Draw) lineStroke(points []Point, style LineStyle) {
- s := style.Style()
- if !s.ShouldDrawStroke() {
- return
- }
- r := d.Render
- s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
- for index, point := range points {
- x := point.X
- y := point.Y
- if index == 0 {
- d.moveTo(x, y)
- } else {
- d.lineTo(x, y)
- }
- }
- r.Stroke()
-}
-
-func (d *Draw) setBackground(width, height int, color drawing.Color) {
- r := d.Render
- s := chart.Style{
- FillColor: color,
- }
- s.WriteToRenderer(r)
- r.MoveTo(0, 0)
- r.LineTo(width, 0)
- r.LineTo(width, height)
- r.LineTo(0, height)
- r.LineTo(0, 0)
- r.FillStroke()
-}
-
-func (d *Draw) polygon(center Point, radius float64, sides int) {
- points := getPolygonPoints(center, radius, sides)
- for i, p := range points {
- if i == 0 {
- d.moveTo(p.X, p.Y)
- } else {
- d.lineTo(p.X, p.Y)
- }
- }
- d.lineTo(points[0].X, points[0].Y)
- d.Render.Stroke()
-}
-
-func (d *Draw) fill(points []Point, s chart.Style) {
- if !s.ShouldDrawFill() {
- return
- }
- r := d.Render
- var x, y int
- s.GetFillOptions().WriteDrawingOptionsToRenderer(r)
- for index, point := range points {
- x = point.X
- y = point.Y
- if index == 0 {
- d.moveTo(x, y)
- } else {
- d.lineTo(x, y)
- }
- }
- r.Fill()
-}
diff --git a/echarts.go b/echarts.go
index 4ebb9ad..aaef1f1 100644
--- a/echarts.go
+++ b/echarts.go
@@ -29,7 +29,7 @@ import (
"regexp"
"strconv"
- "github.com/wcharczuk/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2"
)
func convertToArray(data []byte) []byte {
@@ -60,9 +60,9 @@ type EChartStyle struct {
Color string `json:"color"`
}
-func (es *EChartStyle) ToStyle() chart.Style {
+func (es *EChartStyle) ToStyle() Style {
color := parseColor(es.Color)
- return chart.Style{
+ return Style{
FillColor: color,
FontColor: color,
StrokeColor: color,
@@ -130,6 +130,7 @@ type EChartsXAxisData struct {
BoundaryGap *bool `json:"boundaryGap"`
SplitNumber int `json:"splitNumber"`
Data []string `json:"data"`
+ Type string `json:"type"`
}
type EChartsXAxis struct {
Data []EChartsXAxisData
@@ -155,6 +156,7 @@ type EChartsYAxisData struct {
Color string `json:"color"`
} `json:"lineStyle"`
} `json:"axisLine"`
+ Data []string `json:"data"`
}
type EChartsYAxis struct {
Data []EChartsYAxisData `json:"data"`
@@ -342,6 +344,11 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList {
Data: NewSeriesDataFromValues(dataItem.Value.values),
Max: item.Max,
Min: item.Min,
+ Label: SeriesLabel{
+ Color: parseColor(item.Label.Color),
+ Show: item.Label.Show,
+ Distance: item.Label.Distance,
+ },
})
}
continue
@@ -354,10 +361,10 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList {
}
}
seriesList = append(seriesList, Series{
- Type: item.Type,
- Data: data,
- YAxisIndex: item.YAxisIndex,
- Style: item.ItemStyle.ToStyle(),
+ Type: item.Type,
+ Data: data,
+ AxisIndex: item.YAxisIndex,
+ Style: item.ItemStyle.ToStyle(),
Label: SeriesLabel{
Color: parseColor(item.Label.Color),
Show: item.Label.Show,
@@ -419,26 +426,32 @@ func (eo *EChartsOption) ToOption() ChartOption {
if len(fontFamily) == 0 {
fontFamily = eo.Title.TextStyle.FontFamily
}
+ titleTextStyle := eo.Title.TextStyle.ToStyle()
+ titleSubtextStyle := eo.Title.SubtextStyle.ToStyle()
+ legendTextStyle := eo.Legend.TextStyle.ToStyle()
o := ChartOption{
Type: eo.Type,
FontFamily: fontFamily,
Theme: eo.Theme,
Title: TitleOption{
- Text: eo.Title.Text,
- Subtext: eo.Title.Subtext,
- Style: eo.Title.TextStyle.ToStyle(),
- SubtextStyle: eo.Title.SubtextStyle.ToStyle(),
- Left: string(eo.Title.Left),
- Top: string(eo.Title.Top),
+ Text: eo.Title.Text,
+ Subtext: eo.Title.Subtext,
+ FontColor: titleTextStyle.FontColor,
+ FontSize: titleTextStyle.FontSize,
+ SubtextFontSize: titleSubtextStyle.FontSize,
+ SubtextFontColor: titleSubtextStyle.FontColor,
+ Left: string(eo.Title.Left),
+ Top: string(eo.Title.Top),
},
Legend: LegendOption{
- Show: eo.Legend.Show,
- Style: eo.Legend.TextStyle.ToStyle(),
- Data: eo.Legend.Data,
- Left: string(eo.Legend.Left),
- Top: string(eo.Legend.Top),
- Align: eo.Legend.Align,
- Orient: eo.Legend.Orient,
+ Show: eo.Legend.Show,
+ FontSize: legendTextStyle.FontSize,
+ FontColor: legendTextStyle.FontColor,
+ Data: eo.Legend.Data,
+ Left: string(eo.Legend.Left),
+ Top: string(eo.Legend.Top),
+ Align: eo.Legend.Align,
+ Orient: eo.Legend.Orient,
},
RadarIndicators: eo.Radar.Indicator,
Width: eo.Width,
@@ -447,6 +460,21 @@ func (eo *EChartsOption) ToOption() ChartOption {
Box: eo.Box,
SeriesList: eo.Series.ToSeriesList(),
}
+ isHorizontalChart := false
+ for _, item := range eo.XAxis.Data {
+ if item.Type == "value" {
+ isHorizontalChart = true
+ }
+ }
+ if isHorizontalChart {
+ for index := range o.SeriesList {
+ series := o.SeriesList[index]
+ if series.Type == ChartTypeBar {
+ o.SeriesList[index].Type = ChartTypeHorizontalBar
+ }
+ }
+ }
+
if len(eo.XAxis.Data) != 0 {
xAxisData := eo.XAxis.Data[0]
o.XAxis = XAxisOption{
@@ -462,9 +490,10 @@ func (eo *EChartsOption) ToOption() ChartOption {
Max: item.Max,
Formatter: item.AxisLabel.Formatter,
Color: parseColor(item.AxisLine.LineStyle.Color),
+ Data: item.Data,
}
}
- o.YAxisList = yAxisOptions
+ o.YAxisOptions = yAxisOptions
if len(eo.Children) != 0 {
o.Children = make([]ChartOption, len(eo.Children))
diff --git a/echarts_test.go b/echarts_test.go
index 05c2a40..2077278 100644
--- a/echarts_test.go
+++ b/echarts_test.go
@@ -27,566 +27,556 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
+func TestConvertToArray(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal([]byte(`[1]`), convertToArray([]byte("1")))
+ assert.Equal([]byte(`[1]`), convertToArray([]byte("[1]")))
+}
+
func TestEChartsPosition(t *testing.T) {
assert := assert.New(t)
-
var p EChartsPosition
- err := p.UnmarshalJSON([]byte("12"))
+ err := p.UnmarshalJSON([]byte("1"))
assert.Nil(err)
- assert.Equal("12", string(p))
-
- err = p.UnmarshalJSON([]byte(`"12%"`))
+ assert.Equal(EChartsPosition("1"), p)
+ err = p.UnmarshalJSON([]byte(`"left"`))
assert.Nil(err)
- assert.Equal("12%", string(p))
+ assert.Equal(EChartsPosition("left"), p)
}
-func TestEChartStyle(t *testing.T) {
+
+func TestEChartsSeriesDataValue(t *testing.T) {
assert := assert.New(t)
- s := EChartStyle{
- Color: "#aaa",
- }
- r := drawing.Color{
- R: 170,
- G: 170,
- B: 170,
- A: 255,
- }
- assert.Equal(chart.Style{
- FillColor: r,
- FontColor: r,
- StrokeColor: r,
- }, s.ToStyle())
+ es := EChartsSeriesDataValue{}
+ err := es.UnmarshalJSON([]byte(`[1, 2]`))
+ assert.Nil(err)
+ assert.Equal(EChartsSeriesDataValue{
+ values: []float64{
+ 1,
+ 2,
+ },
+ }, es)
+ assert.Equal(NewEChartsSeriesDataValue(1, 2), es)
+ assert.Equal(1.0, es.First())
+}
+
+func TestEChartsSeriesData(t *testing.T) {
+ assert := assert.New(t)
+ es := EChartsSeriesData{}
+ err := es.UnmarshalJSON([]byte("1.1"))
+ assert.Nil(err)
+ assert.Equal(EChartsSeriesDataValue{
+ values: []float64{
+ 1.1,
+ },
+ }, es.Value)
+
+ err = es.UnmarshalJSON([]byte(`{"value":200,"itemStyle":{"color":"#a90000"}}`))
+ assert.Nil(err)
+ assert.Nil(err)
+ assert.Equal(EChartsSeriesData{
+ Value: EChartsSeriesDataValue{
+ values: []float64{
+ 200.0,
+ },
+ },
+ ItemStyle: EChartStyle{
+ Color: "#a90000",
+ },
+ }, es)
}
func TestEChartsXAxis(t *testing.T) {
assert := assert.New(t)
ex := EChartsXAxis{}
- err := ex.UnmarshalJSON([]byte(`{
- "boundaryGap": false,
- "splitNumber": 5,
- "data": [
- "Mon",
- "Tue",
- "Wed",
- "Thu",
- "Fri",
- "Sat",
- "Sun"
- ]
- }`))
+ err := ex.UnmarshalJSON([]byte(`{"boundaryGap": true, "splitNumber": 5, "data": ["a", "b"], "type": "value"}`))
assert.Nil(err)
+
assert.Equal(EChartsXAxis{
Data: []EChartsXAxisData{
{
- BoundaryGap: FalseFlag(),
+ BoundaryGap: TrueFlag(),
SplitNumber: 5,
Data: []string{
- "Mon",
- "Tue",
- "Wed",
- "Thu",
- "Fri",
- "Sat",
- "Sun",
+ "a",
+ "b",
},
+ Type: "value",
},
},
}, ex)
}
-func TestEChartsYAxis(t *testing.T) {
+func TestEChartStyle(t *testing.T) {
assert := assert.New(t)
- ey := EChartsYAxis{}
- err := ey.UnmarshalJSON([]byte(`{
- "min": 1,
- "max": 10,
- "axisLabel": {
- "formatter": "ab"
- }
- }`))
- assert.Nil(err)
- assert.Equal(EChartsYAxis{
- Data: []EChartsYAxisData{
- {
- Min: NewFloatPoint(1),
- Max: NewFloatPoint(10),
- AxisLabel: EChartsAxisLabel{
- Formatter: "ab",
- },
- },
- },
- }, ey)
-
- ey = EChartsYAxis{}
- err = ey.UnmarshalJSON([]byte(`[
- {
- "min": 1,
- "max": 10,
- "axisLabel": {
- "formatter": "ab"
- }
- },
- {
- "min": 2,
- "max": 20,
- "axisLabel": {
- "formatter": "cd"
- }
- }
- ]`))
- assert.Nil(err)
- assert.Equal(EChartsYAxis{
- Data: []EChartsYAxisData{
- {
- Min: NewFloatPoint(1),
- Max: NewFloatPoint(10),
- AxisLabel: EChartsAxisLabel{
- Formatter: "ab",
- },
- },
- {
- Min: NewFloatPoint(2),
- Max: NewFloatPoint(20),
- AxisLabel: EChartsAxisLabel{
- Formatter: "cd",
- },
- },
- },
- }, ey)
+ es := EChartStyle{
+ Color: "#999",
+ }
+ color := drawing.Color{
+ R: 153,
+ G: 153,
+ B: 153,
+ A: 255,
+ }
+ assert.Equal(Style{
+ FillColor: color,
+ FontColor: color,
+ StrokeColor: color,
+ }, es.ToStyle())
}
func TestEChartsPadding(t *testing.T) {
assert := assert.New(t)
- ep := EChartsPadding{}
+ eb := EChartsPadding{}
- err := ep.UnmarshalJSON([]byte(`10`))
+ err := eb.UnmarshalJSON([]byte(`1`))
assert.Nil(err)
- assert.Equal(EChartsPadding{
- Box: chart.Box{
- Top: 10,
- Right: 10,
- Bottom: 10,
- Left: 10,
- },
- }, ep)
+ assert.Equal(Box{
+ Left: 1,
+ Top: 1,
+ Right: 1,
+ Bottom: 1,
+ }, eb.Box)
- ep = EChartsPadding{}
- err = ep.UnmarshalJSON([]byte(`[10, 20]`))
+ err = eb.UnmarshalJSON([]byte(`[2, 3]`))
assert.Nil(err)
- assert.Equal(EChartsPadding{
- Box: chart.Box{
- Top: 10,
- Right: 20,
- Bottom: 10,
- Left: 20,
- },
- }, ep)
+ assert.Equal(Box{
+ Left: 3,
+ Top: 2,
+ Right: 3,
+ Bottom: 2,
+ }, eb.Box)
- ep = EChartsPadding{}
- err = ep.UnmarshalJSON([]byte(`[10, 20, 30]`))
+ err = eb.UnmarshalJSON([]byte(`[4, 5, 6]`))
assert.Nil(err)
- assert.Equal(EChartsPadding{
- Box: chart.Box{
- Top: 10,
- Right: 20,
- Bottom: 30,
- Left: 20,
- },
- }, ep)
+ assert.Equal(Box{
+ Left: 5,
+ Top: 4,
+ Right: 5,
+ Bottom: 6,
+ }, eb.Box)
- ep = EChartsPadding{}
- err = ep.UnmarshalJSON([]byte(`[10, 20, 30, 40]`))
+ err = eb.UnmarshalJSON([]byte(`[4, 5, 6, 7]`))
assert.Nil(err)
- assert.Equal(EChartsPadding{
- Box: chart.Box{
- Top: 10,
- Right: 20,
- Bottom: 30,
- Left: 40,
- },
- }, ep)
-
-}
-func TestEChartsLegend(t *testing.T) {
- assert := assert.New(t)
-
- el := EChartsLegend{}
-
- err := json.Unmarshal([]byte(`{
- "data": ["a", "b", "c"],
- "align": "right",
- "padding": [10],
- "left": "20%",
- "top": 10
- }`), &el)
- assert.Nil(err)
- assert.Equal(EChartsLegend{
- Data: []string{
- "a",
- "b",
- "c",
- },
- Align: "right",
- Padding: EChartsPadding{
- Box: chart.Box{
- Left: 10,
- Top: 10,
- Right: 10,
- Bottom: 10,
- },
- },
- Left: EChartsPosition("20%"),
- Top: EChartsPosition("10"),
- }, el)
-}
-
-func TestEChartsSeriesData(t *testing.T) {
- assert := assert.New(t)
-
- esd := EChartsSeriesData{}
- err := esd.UnmarshalJSON([]byte(`123`))
- assert.Nil(err)
- assert.Equal(EChartsSeriesData{
- Value: NewEChartsSeriesDataValue(123),
- }, esd)
-
- esd = EChartsSeriesData{}
- err = esd.UnmarshalJSON([]byte(`2.1`))
- assert.Nil(err)
- assert.Equal(EChartsSeriesData{
- Value: NewEChartsSeriesDataValue(2.1),
- }, esd)
-
- esd = EChartsSeriesData{}
- err = esd.UnmarshalJSON([]byte(`{
- "value": 123.12,
- "name": "test",
- "itemStyle": {
- "color": "#aaa"
- }
- }`))
- assert.Nil(err)
- assert.Equal(EChartsSeriesData{
- Value: NewEChartsSeriesDataValue(123.12),
- Name: "test",
- ItemStyle: EChartStyle{
- Color: "#aaa",
- },
- }, esd)
-}
-
-func TestEChartsSeries(t *testing.T) {
- assert := assert.New(t)
-
- esList := make([]EChartsSeries, 0)
- err := json.Unmarshal([]byte(`[
- {
- "name": "Email",
- "data": [
- 120,
- 132
- ]
- },
- {
- "name": "Union Ads",
- "type": "bar",
- "data": [
- 220,
- 182
- ]
- }
- ]`), &esList)
- assert.Nil(err)
- assert.Equal([]EChartsSeries{
- {
- Name: "Email",
- Data: []EChartsSeriesData{
- {
- Value: NewEChartsSeriesDataValue(120),
- },
- {
- Value: NewEChartsSeriesDataValue(132),
- },
- },
- },
- {
- Name: "Union Ads",
- Type: "bar",
- Data: []EChartsSeriesData{
- {
- Value: NewEChartsSeriesDataValue(220),
- },
- {
- Value: NewEChartsSeriesDataValue(182),
- },
- },
- },
- }, esList)
-}
-
-func TestEChartsMarkData(t *testing.T) {
- assert := assert.New(t)
-
- emd := EChartsMarkData{}
- err := emd.UnmarshalJSON([]byte(`{"type": "average"}`))
- assert.Nil(err)
- assert.Equal("average", emd.Type)
-
- emd = EChartsMarkData{}
- err = emd.UnmarshalJSON([]byte(`[{}, {"type": "average"}]`))
- assert.Nil(err)
- assert.Equal("average", emd.Type)
+ assert.Equal(Box{
+ Left: 7,
+ Top: 4,
+ Right: 5,
+ Bottom: 6,
+ }, eb.Box)
}
func TestEChartsMarkPoint(t *testing.T) {
assert := assert.New(t)
- p := EChartsMarkPoint{}
-
- err := json.Unmarshal([]byte(`{
- "symbolSize": 30,
- "data": [
+ emp := EChartsMarkPoint{
+ SymbolSize: 30,
+ Data: []EChartsMarkData{
{
- "type": "max"
+ Type: "test",
},
- {
- "type": "min"
- }
- ]
- }`), &p)
- assert.Nil(err)
+ },
+ }
assert.Equal(SeriesMarkPoint{
SymbolSize: 30,
Data: []SeriesMarkData{
{
- Type: "max",
- },
- {
- Type: "min",
+ Type: "test",
},
},
- }, p.ToSeriesMarkPoint())
+ }, emp.ToSeriesMarkPoint())
}
func TestEChartsMarkLine(t *testing.T) {
assert := assert.New(t)
- l := EChartsMarkLine{}
- err := json.Unmarshal([]byte(`{
- "data": [
- {
- "type": "max"
- },
- {
- "type": "min"
- }
- ]
- }`), &l)
- assert.Nil(err)
- assert.Equal(SeriesMarkLine{
- Data: []SeriesMarkData{
- {
- Type: "max",
- },
+ eml := EChartsMarkLine{
+ Data: []EChartsMarkData{
{
Type: "min",
},
+ {
+ Type: "max",
+ },
},
- }, l.ToSeriesMarkLine())
-}
-
-func TestEChartsTextStyle(t *testing.T) {
- assert := assert.New(t)
-
- s := EChartsTextStyle{
- Color: "#aaa",
- FontFamily: "test",
- FontSize: 14,
}
- assert.Equal(chart.Style{
- FontColor: drawing.Color{
- R: 170,
- G: 170,
- B: 170,
- A: 255,
+ assert.Equal(SeriesMarkLine{
+ Data: []SeriesMarkData{
+ {
+ Type: "min",
+ },
+ {
+ Type: "max",
+ },
},
- FontSize: 14,
- }, s.ToStyle())
+ }, eml.ToSeriesMarkLine())
}
-func TestEChartsSeriesList(t *testing.T) {
+func TestEChartsOption(t *testing.T) {
assert := assert.New(t)
- // pie
- es := EChartsSeriesList{
+ tests := []struct {
+ option string
+ }{
{
- Type: ChartTypePie,
- Radius: "30%",
- Data: []EChartsSeriesData{
- {
- Name: "1",
- Value: EChartsSeriesDataValue{
- values: []float64{
- 1,
- },
- },
+ option: `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
},
- {
- Name: "2",
- Value: EChartsSeriesDataValue{
- values: []float64{
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 120,
+ {
+ "value": 200,
+ "itemStyle": {
+ "color": "#a90000"
+ }
+ },
+ 150,
+ 80,
+ 70,
+ 110,
+ 130
+ ],
+ "type": "bar"
+ }
+ ]
+ }`,
+ },
+ {
+ option: `{
+ "title": {
+ "text": "Referer of a Website",
+ "subtext": "Fake Data",
+ "left": "center"
+ },
+ "tooltip": {
+ "trigger": "item"
+ },
+ "legend": {
+ "orient": "vertical",
+ "left": "left"
+ },
+ "series": [
+ {
+ "name": "Access From",
+ "type": "pie",
+ "radius": "50%",
+ "data": [
+ {
+ "value": 1048,
+ "name": "Search Engine"
+ },
+ {
+ "value": 735,
+ "name": "Direct"
+ },
+ {
+ "value": 580,
+ "name": "Email"
+ },
+ {
+ "value": 484,
+ "name": "Union Ads"
+ },
+ {
+ "value": 300,
+ "name": "Video Ads"
+ }
+ ],
+ "emphasis": {
+ "itemStyle": {
+ "shadowBlur": 10,
+ "shadowOffsetX": 0,
+ "shadowColor": "rgba(0, 0, 0, 0.5)"
+ }
+ }
+ }
+ ]
+ }`,
+ },
+ {
+ option: `{
+ "title": {
+ "text": "Rainfall vs Evaporation",
+ "subtext": "Fake Data"
+ },
+ "tooltip": {
+ "trigger": "axis"
+ },
+ "legend": {
+ "data": [
+ "Rainfall",
+ "Evaporation"
+ ]
+ },
+ "toolbox": {
+ "show": true,
+ "feature": {
+ "dataView": {
+ "show": true,
+ "readOnly": false
+ },
+ "magicType": {
+ "show": true,
+ "type": [
+ "line",
+ "bar"
+ ]
+ },
+ "restore": {
+ "show": true
+ },
+ "saveAsImage": {
+ "show": true
+ }
+ }
+ },
+ "calculable": true,
+ "xAxis": [
+ {
+ "type": "category",
+ "data": [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec"
+ ]
+ }
+ ],
+ "yAxis": [
+ {
+ "type": "value"
+ }
+ ],
+ "series": [
+ {
+ "name": "Rainfall",
+ "type": "bar",
+ "data": [
2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20,
+ 6.4,
+ 3.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max",
+ "name": "Max"
+ },
+ {
+ "type": "min",
+ "name": "Min"
+ }
+ ]
},
+ "markLine": {
+ "data": [
+ {
+ "type": "average",
+ "name": "Avg"
+ }
+ ]
+ }
},
- },
- },
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6,
+ 2.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "name": "Max",
+ "value": 182.2,
+ "xAxis": 7,
+ "yAxis": 183
+ },
+ {
+ "name": "Min",
+ "value": 2.3,
+ "xAxis": 11,
+ "yAxis": 3
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average",
+ "name": "Avg"
+ }
+ ]
+ }
+ }
+ ]
+ }`,
},
}
- assert.Equal(SeriesList{
- {
- Type: ChartTypePie,
- Name: "1",
- Label: SeriesLabel{
- Show: true,
- },
- Radius: "30%",
- Data: []SeriesData{
- {
- Value: 1,
- },
- },
- },
- {
- Type: ChartTypePie,
- Name: "2",
- Label: SeriesLabel{
- Show: true,
- },
- Radius: "30%",
- Data: []SeriesData{
- {
- Value: 2,
- },
- },
- },
- }, es.ToSeriesList())
-
- es = EChartsSeriesList{
- {
- Type: ChartTypeBar,
- Data: []EChartsSeriesData{
- {
- Value: NewEChartsSeriesDataValue(1),
- ItemStyle: EChartStyle{
- Color: "#aaa",
- },
- },
- {
- Value: NewEChartsSeriesDataValue(2),
- },
- },
- YAxisIndex: 1,
- },
- {
- Data: []EChartsSeriesData{
- {
- Value: NewEChartsSeriesDataValue(3),
- },
- {
- Value: NewEChartsSeriesDataValue(4),
- },
- },
- ItemStyle: EChartStyle{
- Color: "#ccc",
- },
- Label: EChartsLabelOption{
- Color: "#ddd",
- Show: true,
- Distance: 5,
- },
- },
+ for _, tt := range tests {
+ opt := EChartsOption{}
+ err := json.Unmarshal([]byte(tt.option), &opt)
+ assert.Nil(err)
+ assert.NotEmpty(opt.Series)
+ assert.NotEmpty(opt.ToOption().SeriesList)
}
- assert.Equal(SeriesList{
- {
- Type: ChartTypeBar,
- Data: []SeriesData{
- {
- Value: 1,
- Style: chart.Style{
- FontColor: drawing.Color{
- R: 170,
- G: 170,
- B: 170,
- A: 255,
- },
- StrokeColor: drawing.Color{
- R: 170,
- G: 170,
- B: 170,
- A: 255,
- },
- FillColor: drawing.Color{
- R: 170,
- G: 170,
- B: 170,
- A: 255,
- },
- },
- },
- {
- Value: 2,
- },
- },
- YAxisIndex: 1,
- },
- {
- Data: []SeriesData{
- {
- Value: 3,
- },
- {
- Value: 4,
- },
- },
- Style: chart.Style{
- FontColor: drawing.Color{
- R: 204,
- G: 204,
- B: 204,
- A: 255,
- },
- StrokeColor: drawing.Color{
- R: 204,
- G: 204,
- B: 204,
- A: 255,
- },
- FillColor: drawing.Color{
- R: 204,
- G: 204,
- B: 204,
- A: 255,
- },
- },
- Label: SeriesLabel{
- Color: drawing.Color{
- R: 221,
- G: 221,
- B: 221,
- A: 255,
- },
- Show: true,
- Distance: 5,
- },
- MarkPoint: SeriesMarkPoint{},
- MarkLine: SeriesMarkLine{},
- },
- }, es.ToSeriesList())
-
+}
+
+func TestRenderEChartsToSVG(t *testing.T) {
+ assert := assert.New(t)
+
+ data, err := RenderEChartsToSVG(`{
+ "title": {
+ "text": "Rainfall vs Evaporation",
+ "subtext": "Fake Data"
+ },
+ "legend": {
+ "data": [
+ "Rainfall",
+ "Evaporation"
+ ]
+ },
+ "padding": [10, 30, 10, 10],
+ "xAxis": [
+ {
+ "type": "category",
+ "data": [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "name": "Rainfall",
+ "type": "bar",
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20,
+ 6.4,
+ 3.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ },
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6,
+ 2.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ }
+ ]
+ }`)
+ assert.Nil(err)
+ assert.Equal("\\nRainfall Evaporation Rainfall vs Evaporation Fake Data 240 200 160 120 80 40 0 Feb May Aug Nov 162.2 2 182.2 2.3 41.62 48.07 ", string(data))
}
diff --git a/examples/area_line_chart/main.go b/examples/area_line_chart/main.go
new file mode 100644
index 0000000..57ca1e9
--- /dev/null
+++ b/examples/area_line_chart/main.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "area-line-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ }
+ p, err := charts.LineRender(
+ values,
+ charts.TitleTextOptionFunc("Line"),
+ charts.XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "Email",
+ }, "50"),
+ func(opt *charts.ChartOption) {
+ opt.Legend.Padding = charts.Box{
+ Top: 5,
+ Bottom: 10,
+ }
+ opt.FillArea = true
+ },
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/bar_chart/main.go b/examples/bar_chart/main.go
new file mode 100644
index 0000000..91c9f81
--- /dev/null
+++ b/examples/bar_chart/main.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "bar-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ },
+ }
+ p, err := charts.BarRender(
+ values,
+ charts.XAxisDataOptionFunc([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "Rainfall",
+ "Evaporation",
+ }, charts.PositionRight),
+ charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage),
+ charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin),
+ // custom option func
+ func(opt *charts.ChartOption) {
+ opt.SeriesList[1].MarkPoint = charts.NewMarkPoint(
+ charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin,
+ )
+ opt.SeriesList[1].MarkLine = charts.NewMarkLine(
+ charts.SeriesMarkDataTypeAverage,
+ )
+ },
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/basic/main.go b/examples/basic/main.go
deleted file mode 100644
index 1e7af8d..0000000
--- a/examples/basic/main.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package main
-
-import (
- "io/ioutil"
- "os"
- "path/filepath"
-
- charts "github.com/vicanso/go-charts"
-)
-
-func writeFile(file string, buf []byte) error {
- tmpPath := "./tmp"
- err := os.MkdirAll(tmpPath, 0700)
- if err != nil {
- return err
- }
-
- file = filepath.Join(tmpPath, file)
- err = ioutil.WriteFile(file, buf, 0600)
- if err != nil {
- return err
- }
- return nil
-}
-
-func chartsRender() ([]byte, error) {
- d, err := charts.LineRender([][]float64{
- {
- 150,
- 230,
- 224,
- 218,
- 135,
- 147,
- 260,
- },
- },
- // output type
- charts.PNGTypeOption(),
- // title
- charts.TitleOptionFunc(charts.TitleOption{
- Text: "Line",
- }),
- // x axis
- charts.XAxisOptionFunc(charts.NewXAxisOption([]string{
- "Mon",
- "Tue",
- "Wed",
- "Thu",
- "Fri",
- "Sat",
- "Sun",
- })),
- )
- if err != nil {
- return nil, err
- }
- return d.Bytes()
-}
-
-func echartsRender() ([]byte, error) {
- return charts.RenderEChartsToPNG(`{
- "title": {
- "text": "Line"
- },
- "xAxis": {
- "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
- },
- "series": [
- {
- "data": [150, 230, 224, 218, 135, 147, 260]
- }
- ]
- }`)
-}
-
-type Render func() ([]byte, error)
-
-func main() {
- m := map[string]Render{
- "charts-line.png": chartsRender,
- "echarts-line.png": echartsRender,
- }
- for name, fn := range m {
- buf, err := fn()
- if err != nil {
- panic(err)
- }
- err = writeFile(name, buf)
- if err != nil {
- panic(err)
- }
- }
-}
diff --git a/examples/charts/main.go b/examples/charts/main.go
index fddbe6d..81bc4f2 100644
--- a/examples/charts/main.go
+++ b/examples/charts/main.go
@@ -2,12 +2,11 @@ package main
import (
"bytes"
+ "fmt"
"net/http"
"strconv"
- charts "github.com/vicanso/go-charts"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ charts "git.smarteching.com/zeni/go-charts/v2"
)
var html = `
@@ -75,6 +74,7 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha
bytesList := make([][]byte, 0)
for _, opt := range chartOptions {
opt.Theme = theme
+ opt.Type = charts.ChartOutputSVG
d, err := charts.Render(opt)
if err != nil {
panic(err)
@@ -93,6 +93,48 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha
bytesList = append(bytesList, buf)
}
+ p, err := charts.TableOptionRender(charts.TableChartOption{
+ Type: charts.ChartOutputSVG,
+ Header: []string{
+ "Name",
+ "Age",
+ "Address",
+ "Tag",
+ "Action",
+ },
+ Data: [][]string{
+ {
+ "John Brown",
+ "32",
+ "New York No. 1 Lake Park",
+ "nice, developer",
+ "Send Mail",
+ },
+ {
+ "Jim Green ",
+ "42",
+ "London No. 1 Lake Park",
+ "wow",
+ "Send Mail",
+ },
+ {
+ "Joe Black ",
+ "32",
+ "Sidney No. 1 Lake Park",
+ "cool, teacher",
+ "Send Mail",
+ },
+ },
+ })
+ if err != nil {
+ panic(err)
+ }
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ bytesList = append(bytesList, buf)
+
data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), bytes.Join(bytesList, []byte("")))
w.Header().Set("Content-Type", "text/html")
w.Write(data)
@@ -100,7 +142,6 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha
func indexHandler(w http.ResponseWriter, req *http.Request) {
chartOptions := []charts.ChartOption{
- // 普通折线图
{
Title: charts.TitleOption{
Text: "Line",
@@ -174,7 +215,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
Title: charts.TitleOption{
Text: "Temperature Change in the Coming Week",
},
- Padding: chart.Box{
+ Padding: charts.Box{
Top: 20,
Left: 20,
Right: 30,
@@ -221,6 +262,35 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
},
},
},
+ {
+ Title: charts.TitleOption{
+ Text: "Line Area",
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Email",
+ }),
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ SeriesList: []charts.Series{
+ charts.NewSeriesFromValues([]float64{
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ }),
+ },
+ FillArea: true,
+ },
// 柱状图
{
Title: charts.TitleOption{
@@ -240,7 +310,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Rainfall",
"Evaporation",
},
- Icon: charts.LegendIconRect,
+ Icon: charts.IconRect,
},
SeriesList: []charts.Series{
charts.NewSeriesFromValues([]float64{
@@ -260,8 +330,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
},
{
Value: 190,
- Style: chart.Style{
- FillColor: drawing.Color{
+ Style: charts.Style{
+ FillColor: charts.Color{
R: 169,
G: 0,
B: 0,
@@ -285,16 +355,68 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
Value: 180,
},
},
+ Label: charts.SeriesLabel{
+ Show: true,
+ Position: charts.PositionBottom,
+ },
},
},
},
- // 柱状图+mark
+ // 水平柱状图
+ {
+ Title: charts.TitleOption{
+ Text: "World Population",
+ },
+ Padding: charts.Box{
+ Top: 20,
+ Right: 40,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: charts.NewLegendOption([]string{
+ "2011",
+ "2012",
+ }),
+ YAxisOptions: charts.NewYAxisOptions([]string{
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ SeriesList: []charts.Series{
+ {
+ Type: charts.ChartTypeHorizontalBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230,
+ }),
+ },
+ {
+ Type: charts.ChartTypeHorizontalBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 19325,
+ 23438,
+ 31000,
+ 121594,
+ 134141,
+ 681807,
+ }),
+ },
+ },
+ },
+ // 柱状图+标记
{
Title: charts.TitleOption{
Text: "Rainfall vs Evaporation",
Subtext: "Fake Data",
},
- Padding: chart.Box{
+ Padding: charts.Box{
Top: 20,
Right: 20,
Bottom: 20,
@@ -371,6 +493,9 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
},
// 双Y轴示例
{
+ Title: charts.TitleOption{
+ Text: "Temperature",
+ },
XAxis: charts.NewXAxisOption([]string{
"Jan",
"Feb",
@@ -390,22 +515,22 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Precipitation",
"Temperature",
}),
- YAxisList: []charts.YAxisOption{
+ YAxisOptions: []charts.YAxisOption{
{
- Formatter: "{value}°C",
- Color: drawing.Color{
- R: 250,
- G: 200,
- B: 88,
+ Formatter: "{value}ml",
+ Color: charts.Color{
+ R: 84,
+ G: 112,
+ B: 198,
A: 255,
},
},
{
- Formatter: "{value}ml",
- Color: drawing.Color{
- R: 84,
- G: 112,
- B: 198,
+ Formatter: "{value}°C",
+ Color: charts.Color{
+ R: 250,
+ G: 200,
+ B: 88,
A: 255,
},
},
@@ -426,9 +551,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
20.0,
6.4,
3.3,
- 10.2,
}),
- YAxisIndex: 1,
},
{
Type: charts.ChartTypeBar,
@@ -445,9 +568,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
18.8,
6.0,
2.3,
- 20.2,
}),
- YAxisIndex: 1,
},
{
Data: charts.NewSeriesDataFromValues([]float64{
@@ -463,8 +584,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
16.5,
12.0,
6.2,
- 30.3,
}),
+ AxisIndex: 1,
},
},
},
@@ -572,6 +693,20 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Order",
}),
SeriesList: []charts.Series{
+ {
+ Type: charts.ChartTypeFunnel,
+ Name: "Show",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 100,
+ }),
+ },
+ {
+ Type: charts.ChartTypeFunnel,
+ Name: "Click",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 80,
+ }),
+ },
{
Type: charts.ChartTypeFunnel,
Name: "Visit",
@@ -593,20 +728,6 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
20,
}),
},
- {
- Type: charts.ChartTypeFunnel,
- Name: "Click",
- Data: charts.NewSeriesDataFromValues([]float64{
- 80,
- }),
- },
- {
- Type: charts.ChartTypeFunnel,
- Name: "Show",
- Data: charts.NewSeriesDataFromValues([]float64{
- 100,
- }),
- },
},
},
// 多图展示
@@ -620,7 +741,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Walnut Brownie",
},
},
- Padding: chart.Box{
+ Padding: charts.Box{
Top: 100,
Right: 10,
Bottom: 10,
@@ -634,7 +755,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"2016",
"2017",
}),
- YAxisList: []charts.YAxisOption{
+ YAxisOptions: []charts.YAxisOption{
{
Min: charts.NewFloatPoint(0),
@@ -686,7 +807,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Walnut Brownie",
},
},
- Box: chart.Box{
+ Box: charts.Box{
Top: 20,
Left: 400,
Right: 500,
@@ -1011,6 +1132,64 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
}
]
}`,
+ `{
+ "title": {
+ "text": "World Population"
+ },
+ "tooltip": {
+ "trigger": "axis",
+ "axisPointer": {
+ "type": "shadow"
+ }
+ },
+ "legend": {},
+ "grid": {
+ "left": "3%",
+ "right": "4%",
+ "bottom": "3%",
+ "containLabel": true
+ },
+ "xAxis": {
+ "type": "value"
+ },
+ "yAxis": {
+ "type": "category",
+ "data": [
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World"
+ ]
+ },
+ "series": [
+ {
+ "name": "2011",
+ "type": "bar",
+ "data": [
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230
+ ]
+ },
+ {
+ "name": "2012",
+ "type": "bar",
+ "data": [
+ 19325,
+ 23438,
+ 31000,
+ 121594,
+ 134141,
+ 681807
+ ]
+ }
+ ]
+ }`,
`{
"title": {
"text": "Rainfall vs Evaporation",
@@ -1172,12 +1351,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
23.2,
25.6,
76.7,
- 135.6,
- 162.2,
- 32.6,
- 20,
- 6.4,
- 3.3
+ 135.6
]
},
{
@@ -1191,12 +1365,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
26.4,
28.7,
70.7,
- 175.6,
- 182.2,
- 48.7,
- 18.8,
- 6,
- 2.3
+ 175.6
]
},
{
@@ -1211,12 +1380,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
4.5,
6.3,
10.2,
- 20.3,
- 23.4,
- 23,
- 16.5,
- 12,
- 6.2
+ 20.3
]
}
]
@@ -1805,5 +1969,6 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
func main() {
http.HandleFunc("/", indexHandler)
http.HandleFunc("/echarts", echartsHandler)
+ fmt.Println("http://127.0.0.1:3012/")
http.ListenAndServe(":3012", nil)
}
diff --git a/examples/chinese/main.go b/examples/chinese/main.go
index e0125b4..601f54e 100644
--- a/examples/chinese/main.go
+++ b/examples/chinese/main.go
@@ -2,49 +2,119 @@ package main
import (
"io/ioutil"
- "log"
+ "os"
+ "path/filepath"
- charts "github.com/vicanso/go-charts"
+ "git.smarteching.com/zeni/go-charts/v2"
)
-func echartsRender() ([]byte, error) {
- return charts.RenderEChartsToPNG(`{
- "title": {
- "text": "用户访问次数",
- "textStyle": {
- "fontFamily": "chinese"
- }
- },
- "xAxis": {
- "data": ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
- },
- "series": [
- {
- "data": [150, 230, 224, 218, 135, 147, 260],
- "label": {
- "show": true
- }
- }
- ]
- }`)
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "chinese-line-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
}
func main() {
- fontData, err := ioutil.ReadFile("/Users/darcy/Downloads/NotoSansCJKsc-VF.ttf")
+ // 字体文件需要自行下载
+ // https://github.com/googlefonts/noto-cjk
+ buf, err := ioutil.ReadFile("./NotoSansSC.ttf")
if err != nil {
- log.Fatalln("Error when reading font file:", err)
+ panic(err)
}
-
- if err := charts.InstallFont("chinese", fontData); err != nil {
- log.Fatalln("Error when instaling font:", err)
+ err = charts.InstallFont("noto", buf)
+ if err != nil {
+ panic(err)
}
+ font, _ := charts.GetFont("noto")
+ charts.SetDefaultFont(font)
- fileData, err := echartsRender()
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ p, err := charts.LineRender(
+ values,
+ charts.TitleTextOptionFunc("测试"),
+ charts.XAxisDataOptionFunc([]string{
+ "星期一",
+ "星期二",
+ "星期三",
+ "星期四",
+ "星期五",
+ "星期六",
+ "星期日",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "邮件",
+ "广告",
+ "视频广告",
+ "直接访问",
+ "搜索引擎",
+ }, charts.PositionCenter),
+ )
if err != nil {
- log.Fatalln("Error when rendering image:", err)
+ panic(err)
}
- if err := ioutil.WriteFile("chinese.png", fileData, 0644); err != nil {
- log.Fatalln("Error when save image to chinese.png:", err)
+
+ buf, err = p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
}
}
diff --git a/examples/funnel_chart/main.go b/examples/funnel_chart/main.go
new file mode 100644
index 0000000..653f834
--- /dev/null
+++ b/examples/funnel_chart/main.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "funnel-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := []float64{
+ 100,
+ 80,
+ 60,
+ 40,
+ 20,
+ 10,
+ 0,
+ }
+ p, err := charts.FunnelRender(
+ values,
+ charts.TitleTextOptionFunc("Funnel"),
+ charts.LegendLabelsOptionFunc([]string{
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order",
+ "Pay",
+ "Cancel",
+ }),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/horizontal_bar_chart/main.go b/examples/horizontal_bar_chart/main.go
new file mode 100644
index 0000000..f5d8497
--- /dev/null
+++ b/examples/horizontal_bar_chart/main.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "horizontal-bar-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 10,
+ 30,
+ 50,
+ 70,
+ 90,
+ 110,
+ 130,
+ },
+ {
+ 20,
+ 40,
+ 60,
+ 80,
+ 100,
+ 120,
+ 140,
+ },
+ }
+ p, err := charts.HorizontalBarRender(
+ values,
+ charts.TitleTextOptionFunc("World Population"),
+ charts.PaddingOptionFunc(charts.Box{
+ Top: 20,
+ Right: 40,
+ Bottom: 20,
+ Left: 20,
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "2011",
+ "2012",
+ }),
+ charts.YAxisDataOptionFunc([]string{
+ "UN",
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ func(opt *charts.ChartOption) {
+ opt.SeriesList[0].RoundRadius = 5
+ },
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/line_chart/main.go b/examples/line_chart/main.go
new file mode 100644
index 0000000..baee8a3
--- /dev/null
+++ b/examples/line_chart/main.go
@@ -0,0 +1,124 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "line-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ // 134,
+ charts.GetNullValue(),
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ p, err := charts.LineRender(
+ values,
+ charts.TitleTextOptionFunc("Line"),
+ charts.XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }, "50"),
+ func(opt *charts.ChartOption) {
+ opt.Legend.Padding = charts.Box{
+ Top: 5,
+ Bottom: 10,
+ }
+ opt.YAxisOptions = []charts.YAxisOption{
+ {
+ SplitLineShow: charts.FalseFlag(),
+ },
+ }
+ opt.SymbolShow = charts.FalseFlag()
+ opt.LineStrokeWidth = 1
+ opt.ValueFormatter = func(f float64) string {
+ return fmt.Sprintf("%.0f", f)
+ }
+ },
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/painter/main.go b/examples/painter/main.go
new file mode 100644
index 0000000..1b842b3
--- /dev/null
+++ b/examples/painter/main.go
@@ -0,0 +1,607 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ charts "git.smarteching.com/zeni/go-charts/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "painter.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ p, err := charts.NewPainter(charts.PainterOptions{
+ Width: 600,
+ Height: 2000,
+ Type: charts.ChartOutputPNG,
+ })
+ if err != nil {
+ panic(err)
+ }
+ // 背景色
+ p.SetBackground(p.Width(), p.Height(), drawing.ColorWhite)
+
+ top := 0
+
+ // 画线
+ p.SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ })
+ p.LineStroke([]charts.Point{
+ {
+ X: 0,
+ Y: 0,
+ },
+ {
+ X: 100,
+ Y: 10,
+ },
+ {
+ X: 200,
+ Y: 0,
+ },
+ {
+ X: 300,
+ Y: 10,
+ },
+ })
+
+ // 圆滑曲线
+ // top += 50
+ // p.Child(charts.PainterPaddingOption(charts.Box{
+ // Top: top,
+ // })).SetDrawingStyle(charts.Style{
+ // StrokeColor: drawing.ColorBlack,
+ // FillColor: drawing.ColorBlack,
+ // StrokeWidth: 1,
+ // }).SmoothLineStroke([]charts.Point{
+ // {
+ // X: 0,
+ // Y: 0,
+ // },
+ // {
+ // X: 100,
+ // Y: 10,
+ // },
+ // {
+ // X: 200,
+ // Y: 0,
+ // },
+ // {
+ // X: 300,
+ // Y: 10,
+ // },
+ // })
+
+ // 标线
+ top += 50
+ p.Child(charts.PainterPaddingOption(charts.Box{
+ Top: top,
+ })).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ StrokeDashArray: []float64{
+ 4,
+ 2,
+ },
+ }).MarkLine(0, 0, p.Width())
+
+ top += 60
+ // Polygon
+ p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ })).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Polygon(charts.Point{
+ X: 100,
+ Y: 0,
+ }, 50, 6)
+
+ // FillArea
+ top += 60
+ p.Child(charts.PainterPaddingOption(charts.Box{
+ Top: top,
+ })).SetDrawingStyle(charts.Style{
+ FillColor: drawing.ColorBlack,
+ }).FillArea([]charts.Point{
+ {
+ X: 0,
+ Y: 0,
+ },
+ {
+ X: 100,
+ Y: 0,
+ },
+ {
+ X: 150,
+ Y: 40,
+ },
+ {
+ X: 80,
+ Y: 30,
+ },
+ {
+ X: 0,
+ Y: 0,
+ },
+ })
+
+ // 坐标轴的点
+ top += 50
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: 20,
+ }),
+ ).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Ticks(charts.TicksOption{
+ Count: 7,
+ Length: 5,
+ })
+
+ // 坐标轴的点,每2格显示一个
+ top += 20
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: 20,
+ }),
+ ).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Ticks(charts.TicksOption{
+ Unit: 2,
+ Count: 7,
+ Length: 5,
+ })
+
+ // 坐标轴的点,纵向
+ top += 20
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 100,
+ }),
+ ).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Ticks(charts.TicksOption{
+ Orient: charts.OrientVertical,
+ Count: 7,
+ Length: 5,
+ })
+
+ // 横向展示文本
+ top += 120
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: 20,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+
+ // 横向显示文本,靠左
+ top += 20
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: 20,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ Position: charts.PositionLeft,
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+
+ // 纵向显示文本
+ top += 20
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: 50,
+ Bottom: top + 150,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ Orient: charts.OrientVertical,
+ Align: charts.AlignRight,
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+ // 纵向 文本居中
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 50,
+ Right: 100,
+ Bottom: top + 150,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ Orient: charts.OrientVertical,
+ Align: charts.AlignCenter,
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+ // 纵向 文本置顶
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 100,
+ Right: 150,
+ Bottom: top + 150,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ Orient: charts.OrientVertical,
+ Position: charts.PositionTop,
+ Align: charts.AlignCenter,
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+
+ // grid
+ top += 150
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 100,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).Grid(charts.GridOption{
+ Column: 8,
+ IgnoreColumnLines: []int{0, 8},
+ Row: 8,
+ IgnoreRowLines: []int{0, 8},
+ })
+
+ // dots
+ top += 100
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 20,
+ }),
+ ).OverrideDrawingStyle(charts.Style{
+ FillColor: drawing.ColorWhite,
+ StrokeColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Dots([]charts.Point{
+ {
+ X: 0,
+ Y: 0,
+ },
+ {
+ X: 50,
+ Y: 0,
+ },
+ {
+ X: 100,
+ Y: 10,
+ },
+ })
+
+ // rect
+ top += 30
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: 200,
+ Bottom: top + 50,
+ }),
+ ).OverrideDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ }).Rect(charts.Box{
+ Left: 10,
+ Top: 0,
+ Right: 110,
+ Bottom: 20,
+ })
+ // legend line dot
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 200,
+ Right: p.Width() - 1,
+ Bottom: top + 50,
+ }),
+ ).OverrideDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ }).LegendLineDot(charts.Box{
+ Left: 10,
+ Top: 0,
+ Right: 50,
+ Bottom: 20,
+ })
+
+ // grid
+ top += 50
+ charts.NewGridPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 100,
+ })), charts.GridPainterOption{
+ Row: 5,
+ IgnoreFirstRow: true,
+ IgnoreLastRow: true,
+ StrokeColor: drawing.ColorBlue,
+ }).Render()
+
+ // legend
+ top += 100
+ charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 30,
+ })), charts.LegendOption{
+ Left: "10",
+ Data: []string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ },
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // legend
+ top += 30
+ charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 30,
+ })), charts.LegendOption{
+ Left: charts.PositionRight,
+ Data: []string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ },
+ Align: charts.AlignRight,
+ FontSize: 16,
+ Icon: charts.IconRect,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // legend
+ top += 30
+ charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 100,
+ })), charts.LegendOption{
+ Top: "10",
+ Data: []string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ },
+ Orient: charts.OrientVertical,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // axis bottom
+ top += 100
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 50,
+ })), charts.AxisOption{
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ StrokeColor: drawing.ColorBlack,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // axis top
+ top += 50
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 50,
+ })), charts.AxisOption{
+ Position: charts.PositionTop,
+ BoundaryGap: charts.FalseFlag(),
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ StrokeColor: drawing.ColorBlack,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // axis left
+ top += 50
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 10,
+ Right: 60,
+ Bottom: top + 200,
+ })), charts.AxisOption{
+ Position: charts.PositionLeft,
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ StrokeColor: drawing.ColorBlack,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+ // axis right
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 100,
+ Right: 150,
+ Bottom: top + 200,
+ })), charts.AxisOption{
+ Position: charts.PositionRight,
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ StrokeColor: drawing.ColorBlack,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // axis left no tick
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 150,
+ Right: 300,
+ Bottom: top + 200,
+ })), charts.AxisOption{
+ BoundaryGap: charts.FalseFlag(),
+ Position: charts.PositionLeft,
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ SplitLineShow: true,
+ SplitLineColor: drawing.ColorBlack.WithAlpha(100),
+ }).Render()
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go
new file mode 100644
index 0000000..5d70438
--- /dev/null
+++ b/examples/pie_chart/main.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "pie-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := []float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }
+ p, err := charts.PieRender(
+ values,
+ charts.TitleOptionFunc(charts.TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ Left: charts.PositionCenter,
+ }),
+ charts.PaddingOptionFunc(charts.Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ }),
+ charts.LegendOptionFunc(charts.LegendOption{
+ Orient: charts.OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: charts.PositionLeft,
+ }),
+ charts.PieSeriesShowLabel(),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/radar_chart/main.go b/examples/radar_chart/main.go
new file mode 100644
index 0000000..e7053af
--- /dev/null
+++ b/examples/radar_chart/main.go
@@ -0,0 +1,79 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "radar-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ },
+ {
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
+ },
+ }
+ p, err := charts.RadarRender(
+ values,
+ charts.TitleTextOptionFunc("Basic Radar Chart"),
+ charts.LegendLabelsOptionFunc([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ charts.RadarIndicatorOptionFunc([]string{
+ "Sales",
+ "Administration",
+ "Information Technology",
+ "Customer Support",
+ "Development",
+ "Marketing",
+ }, []float64{
+ 6500,
+ 16000,
+ 30000,
+ 38000,
+ 52000,
+ 25000,
+ }),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/table/main.go b/examples/table/main.go
new file mode 100644
index 0000000..de994eb
--- /dev/null
+++ b/examples/table/main.go
@@ -0,0 +1,178 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func writeFile(buf []byte, filename string) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, filename)
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ // charts.SetDefaultTableSetting(charts.TableDarkThemeSetting)
+ charts.SetDefaultWidth(810)
+ header := []string{
+ "Name",
+ "Age",
+ "Address",
+ "Tag",
+ "Action",
+ }
+ data := [][]string{
+ {
+ "John Brown",
+ "32",
+ "New York No. 1 Lake Park",
+ "nice, developer",
+ "Send Mail",
+ },
+ {
+ "Jim Green ",
+ "42",
+ "London No. 1 Lake Park",
+ "wow",
+ "Send Mail",
+ },
+ {
+ "Joe Black ",
+ "32",
+ "Sidney No. 1 Lake Park",
+ "cool, teacher",
+ "Send Mail",
+ },
+ }
+ spans := map[int]int{
+ 0: 2,
+ 1: 1,
+ // 设置第三列的span
+ 2: 3,
+ 3: 2,
+ 4: 2,
+ }
+ p, err := charts.TableRender(
+ header,
+ data,
+ spans,
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf, "table.png")
+ if err != nil {
+ panic(err)
+ }
+
+ bgColor := charts.Color{
+ R: 16,
+ G: 22,
+ B: 30,
+ A: 255,
+ }
+ p, err = charts.TableOptionRender(charts.TableChartOption{
+ Header: []string{
+ "Name",
+ "Price",
+ "Change",
+ },
+ BackgroundColor: bgColor,
+ HeaderBackgroundColor: bgColor,
+ RowBackgroundColors: []charts.Color{
+ bgColor,
+ },
+ HeaderFontColor: drawing.ColorWhite,
+ FontColor: drawing.ColorWhite,
+ Padding: charts.Box{
+ Top: 15,
+ Right: 10,
+ Bottom: 15,
+ Left: 10,
+ },
+ Data: [][]string{
+ {
+ "Datadog Inc",
+ "97.32",
+ "-7.49%",
+ },
+ {
+ "Hashicorp Inc",
+ "28.66",
+ "-9.25%",
+ },
+ {
+ "Gitlab Inc",
+ "51.63",
+ "+4.32%",
+ },
+ },
+ TextAligns: []string{
+ "",
+ charts.AlignRight,
+ charts.AlignRight,
+ },
+ CellStyle: func(tc charts.TableCell) *charts.Style {
+ column := tc.Column
+ if column != 2 {
+ return nil
+ }
+ value, _ := strconv.ParseFloat(strings.Replace(tc.Text, "%", "", 1), 64)
+ if value == 0 {
+ return nil
+ }
+ style := charts.Style{
+ Padding: charts.Box{
+ Bottom: 5,
+ },
+ }
+ if value > 0 {
+ style.FillColor = charts.Color{
+ R: 179,
+ G: 53,
+ B: 20,
+ A: 255,
+ }
+ } else if value < 0 {
+ style.FillColor = charts.Color{
+ R: 33,
+ G: 124,
+ B: 50,
+ A: 255,
+ }
+ }
+ return &style
+ },
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err = p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf, "table-color.png")
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/time_line_chart/main.go b/examples/time_line_chart/main.go
new file mode 100644
index 0000000..c6c93bf
--- /dev/null
+++ b/examples/time_line_chart/main.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+ "crypto/rand"
+ "fmt"
+ "math/big"
+ "os"
+ "path/filepath"
+ "time"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "time-line-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ xAxisValue := []string{}
+ values := []float64{}
+ now := time.Now()
+ firstAxis := 0
+ for i := 0; i < 300; i++ {
+ // 设置首个axis为xx:00的时间点
+ if firstAxis == 0 && now.Minute() == 0 {
+ firstAxis = i
+ }
+ xAxisValue = append(xAxisValue, now.Format("15:04"))
+ now = now.Add(time.Minute)
+ value, _ := rand.Int(rand.Reader, big.NewInt(100))
+ values = append(values, float64(value.Int64()))
+ }
+ p, err := charts.LineRender(
+ [][]float64{
+ values,
+ },
+ charts.TitleTextOptionFunc("Line"),
+ charts.XAxisDataOptionFunc(xAxisValue, charts.FalseFlag()),
+ charts.LegendLabelsOptionFunc([]string{
+ "Demo",
+ }, "50"),
+ func(opt *charts.ChartOption) {
+ opt.XAxis.FirstAxis = firstAxis
+ // 必须要比计算得来的最小值更大(每60分钟)
+ opt.XAxis.SplitNumber = 60
+ opt.Legend.Padding = charts.Box{
+ Top: 5,
+ Bottom: 10,
+ }
+ opt.SymbolShow = charts.FalseFlag()
+ opt.LineStrokeWidth = 1
+ opt.ValueFormatter = func(f float64) string {
+ return fmt.Sprintf("%.0f", f)
+ }
+ },
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/font.go b/font.go
index c40b51e..828654e 100644
--- a/font.go
+++ b/font.go
@@ -27,14 +27,18 @@ import (
"sync"
"github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2/roboto"
+ "git.smarteching.com/zeni/go-chart/v2/roboto"
)
var fonts = sync.Map{}
var ErrFontNotExists = errors.New("font is not exists")
+var defaultFontFamily = "defaultFontFamily"
func init() {
- _ = InstallFont("roboto", roboto.Roboto)
+ name := "roboto"
+ _ = InstallFont(name, roboto.Roboto)
+ font, _ := GetFont(name)
+ SetDefaultFont(font)
}
// InstallFont installs the font for charts
@@ -47,6 +51,19 @@ func InstallFont(fontFamily string, data []byte) error {
return nil
}
+// GetDefaultFont get default font
+func GetDefaultFont() (*truetype.Font, error) {
+ return GetFont(defaultFontFamily)
+}
+
+// SetDefaultFont set default font
+func SetDefaultFont(font *truetype.Font) {
+ if font == nil {
+ return
+ }
+ fonts.Store(defaultFontFamily, font)
+}
+
// GetFont get the font by font family
func GetFont(fontFamily string) (*truetype.Font, error) {
value, ok := fonts.Load(fontFamily)
diff --git a/font_test.go b/font_test.go
index 9dc731c..e0c56b2 100644
--- a/font_test.go
+++ b/font_test.go
@@ -26,7 +26,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2/roboto"
+ "git.smarteching.com/zeni/go-chart/v2/roboto"
)
func TestInstallFont(t *testing.T) {
diff --git a/funnel.go b/funnel_chart.go
similarity index 53%
rename from funnel.go
rename to funnel_chart.go
index f083306..d4a8bdd 100644
--- a/funnel.go
+++ b/funnel_chart.go
@@ -23,35 +23,54 @@
package charts
import (
- "fmt"
- "sort"
-
- "github.com/dustin/go-humanize"
"github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
)
-type funnelChartOption struct {
- Theme string
- Font *truetype.Font
- SeriesList SeriesList
+type funnelChart struct {
+ p *Painter
+ opt *FunnelChartOption
}
-func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error {
- d, err := NewDraw(DrawOption{
- Parent: result.d,
- }, PaddingOption(chart.Box{
- Top: result.titleBox.Height(),
- }))
- if err != nil {
- return err
+// NewFunnelSeriesList returns a series list for funnel
+func NewFunnelSeriesList(values []float64) SeriesList {
+ seriesList := make(SeriesList, len(values))
+ for index, value := range values {
+ seriesList[index] = NewSeriesFromValues([]float64{
+ value,
+ }, ChartTypeFunnel)
}
- seriesList := make([]Series, len(opt.SeriesList))
- copy(seriesList, opt.SeriesList)
- sort.Slice(seriesList, func(i, j int) bool {
- // 大的数据在前
- return seriesList[i].Data[0].Value > seriesList[j].Data[0].Value
- })
+ return seriesList
+}
+
+// NewFunnelChart returns a funnel chart renderer
+func NewFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &funnelChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type FunnelChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The padding of line chart
+ Padding Box
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+}
+
+func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ opt := f.opt
+ seriesPainter := result.seriesPainter
max := seriesList[0].Data[0].Value
min := float64(0)
for _, item := range seriesList {
@@ -62,11 +81,10 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error {
min = *item.Min
}
}
-
- theme := NewTheme(opt.Theme)
+ theme := opt.Theme
gap := 2
- height := d.Box.Height()
- width := d.Box.Width()
+ height := seriesPainter.Height()
+ width := seriesPainter.Width()
count := len(seriesList)
h := (height - gap*(count-1)) / count
@@ -74,13 +92,23 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error {
y := 0
widthList := make([]int, len(seriesList))
textList := make([]string, len(seriesList))
+ seriesNames := seriesList.Names()
+ offset := max - min
for index, item := range seriesList {
value := item.Data[0].Value
- widthPercent := (value - min) / (max - min)
+ // 最大最小值一致则为100%
+ widthPercent := 100.0
+ if offset != 0 {
+ widthPercent = (value - min) / offset
+ }
w := int(widthPercent * float64(width))
widthList[index] = w
- p := humanize.CommafWithDigits(value/max*100, 2) + "%"
- textList[index] = fmt.Sprintf("%s(%s)", item.Name, p)
+ // 如果最大值为0,则占比100%
+ percent := 1.0
+ if max != 0 {
+ percent = value / max
+ }
+ textList[index] = NewFunnelLabelFormatter(seriesNames, item.Label.Formatter)(index, value, percent)
}
for index, w := range widthList {
@@ -116,26 +144,49 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error {
},
}
color := theme.GetSeriesColor(series.index)
- d.fill(points, chart.Style{
+
+ seriesPainter.OverrideDrawingStyle(Style{
FillColor: color,
- })
+ }).FillArea(points)
// 文本
text := textList[index]
- r := d.Render
- textStyle := chart.Style{
+ seriesPainter.OverrideTextStyle(Style{
FontColor: theme.GetTextColor(),
FontSize: labelFontSize,
Font: opt.Font,
- }
- textStyle.GetTextOptions().WriteToRenderer(r)
- textBox := r.MeasureText(text)
+ })
+ textBox := seriesPainter.MeasureText(text)
textX := width>>1 - textBox.Width()>>1
textY := y + h>>1
- d.text(text, textX, textY)
-
+ seriesPainter.Text(text, textX, textY)
y += (h + gap)
}
- return nil
+ return f.p.box, nil
+}
+
+func (f *funnelChart) Render() (Box, error) {
+ p := f.p
+ opt := f.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: XAxisOption{
+ Show: FalseFlag(),
+ },
+ YAxisOptions: []YAxisOption{
+ {
+ Show: FalseFlag(),
+ },
+ },
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeFunnel)
+ return f.render(renderResult, seriesList)
}
diff --git a/funnel_chart_test.go b/funnel_chart_test.go
new file mode 100644
index 0000000..d260bfb
--- /dev/null
+++ b/funnel_chart_test.go
@@ -0,0 +1,79 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFunnelChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewFunnelChart(p, FunnelChartOption{
+ SeriesList: NewFunnelSeriesList([]float64{
+ 100,
+ 80,
+ 60,
+ 40,
+ 20,
+ }),
+ Legend: NewLegendOption([]string{
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order",
+ }),
+ Title: TitleOption{
+ Text: "Funnel",
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\nShow Click Visit Inquiry Order Funnel (100%) (80%) (60%) (40%) (20%) ",
+ },
+ }
+
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/funnel_test.go b/funnel_test.go
deleted file mode 100644
index 530fa53..0000000
--- a/funnel_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
-)
-
-func TestFunnelChartRender(t *testing.T) {
- assert := assert.New(t)
-
- d, err := NewDraw(DrawOption{
- Width: 250,
- Height: 150,
- })
- assert.Nil(err)
- f, _ := chart.GetDefaultFont()
- err = funnelChartRender(funnelChartOption{
- Font: f,
- SeriesList: []Series{
- {
- Type: ChartTypeFunnel,
- Name: "Visit",
- Data: NewSeriesDataFromValues([]float64{
- 60,
- }),
- },
- {
- Type: ChartTypeFunnel,
- Name: "Inquiry",
- Data: NewSeriesDataFromValues([]float64{
- 40,
- }),
- index: 1,
- },
- {
- Type: ChartTypeFunnel,
- Name: "Order",
- Data: NewSeriesDataFromValues([]float64{
- 20,
- }),
- index: 2,
- },
- {
- Type: ChartTypeFunnel,
- Name: "Click",
- Data: NewSeriesDataFromValues([]float64{
- 80,
- }),
- index: 3,
- },
- {
- Type: ChartTypeFunnel,
- Name: "Show",
- Data: NewSeriesDataFromValues([]float64{
- 100,
- }),
- index: 4,
- },
- },
- }, &basicRenderResult{
- d: d,
- })
- assert.Nil(err)
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal("\\nShow(100%) Click(80%) Visit(60%) Inquiry(40%) Order(20%) ", string(data))
-}
diff --git a/go.mod b/go.mod
index 610af22..76a47b6 100644
--- a/go.mod
+++ b/go.mod
@@ -1,17 +1,17 @@
-module github.com/vicanso/go-charts
+module git.smarteching.com/zeni/go-charts/v2
-go 1.17
+go 1.24.1
require (
- github.com/dustin/go-humanize v1.0.0
+ git.smarteching.com/zeni/go-chart/v2 v2.1.4
+ github.com/dustin/go-humanize v1.0.1
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
- github.com/stretchr/testify v1.7.1
- github.com/wcharczuk/go-chart/v2 v2.1.0
+ github.com/stretchr/testify v1.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 // indirect
- gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+ golang.org/x/image v0.21.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index d88f473..3e1a48a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,25 +1,18 @@
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+git.smarteching.com/zeni/go-chart/v2 v2.1.4 h1:pF06+F6eqJLIG8uMiTVPR5TygPGMjM/FHMzTxmu5V/Q=
+git.smarteching.com/zeni/go-chart/v2 v2.1.4/go.mod h1:b3ueW9h3pGGXyhkormZAvilHaG4+mQti+bMNPdQBeOQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
-github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I=
-github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA=
-golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE=
-golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
+golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/grid.go b/grid.go
new file mode 100644
index 0000000..0ebd226
--- /dev/null
+++ b/grid.go
@@ -0,0 +1,92 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+type gridPainter struct {
+ p *Painter
+ opt *GridPainterOption
+}
+
+type GridPainterOption struct {
+ // The stroke width
+ StrokeWidth float64
+ // The stroke color
+ StrokeColor Color
+ // The spans of column
+ ColumnSpans []int
+ // The column of grid
+ Column int
+ // The row of grid
+ Row int
+ // Ignore first row
+ IgnoreFirstRow bool
+ // Ignore last row
+ IgnoreLastRow bool
+ // Ignore first column
+ IgnoreFirstColumn bool
+ // Ignore last column
+ IgnoreLastColumn bool
+}
+
+// NewGridPainter returns new a grid renderer
+func NewGridPainter(p *Painter, opt GridPainterOption) *gridPainter {
+ return &gridPainter{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (g *gridPainter) Render() (Box, error) {
+ opt := g.opt
+ ignoreColumnLines := make([]int, 0)
+ if opt.IgnoreFirstColumn {
+ ignoreColumnLines = append(ignoreColumnLines, 0)
+ }
+ if opt.IgnoreLastColumn {
+ ignoreColumnLines = append(ignoreColumnLines, opt.Column)
+ }
+ ignoreRowLines := make([]int, 0)
+ if opt.IgnoreFirstRow {
+ ignoreRowLines = append(ignoreRowLines, 0)
+ }
+ if opt.IgnoreLastRow {
+ ignoreRowLines = append(ignoreRowLines, opt.Row)
+ }
+ strokeWidth := opt.StrokeWidth
+ if strokeWidth <= 0 {
+ strokeWidth = 1
+ }
+
+ g.p.SetDrawingStyle(Style{
+ StrokeWidth: strokeWidth,
+ StrokeColor: opt.StrokeColor,
+ })
+ g.p.Grid(GridOption{
+ Column: opt.Column,
+ ColumnSpans: opt.ColumnSpans,
+ Row: opt.Row,
+ IgnoreColumnLines: ignoreColumnLines,
+ IgnoreRowLines: ignoreRowLines,
+ })
+ return g.p.box, nil
+}
diff --git a/grid_test.go b/grid_test.go
new file mode 100644
index 0000000..fa9c3a6
--- /dev/null
+++ b/grid_test.go
@@ -0,0 +1,87 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestGrid(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewGridPainter(p, GridPainterOption{
+ StrokeColor: drawing.ColorBlack,
+ Column: 6,
+ Row: 6,
+ IgnoreFirstRow: true,
+ IgnoreLastRow: true,
+ IgnoreFirstColumn: true,
+ IgnoreLastColumn: true,
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\n ",
+ },
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewGridPainter(p, GridPainterOption{
+ StrokeColor: drawing.ColorBlack,
+ ColumnSpans: []int{
+ 2,
+ 5,
+ 3,
+ },
+ Row: 6,
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\n ",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go
new file mode 100644
index 0000000..ed091c9
--- /dev/null
+++ b/horizontal_bar_chart.go
@@ -0,0 +1,216 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type horizontalBarChart struct {
+ p *Painter
+ opt *HorizontalBarChartOption
+}
+
+type HorizontalBarChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The x axis option
+ XAxis XAxisOption
+ // The padding of line chart
+ Padding Box
+ // The y axis option
+ YAxisOptions []YAxisOption
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ BarHeight int
+ // Margin of bar
+ BarMargin int
+}
+
+// NewHorizontalBarChart returns a horizontal bar chart renderer
+func NewHorizontalBarChart(p *Painter, opt HorizontalBarChartOption) *horizontalBarChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &horizontalBarChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ p := h.p
+ opt := h.opt
+ seriesPainter := result.seriesPainter
+ yRange := result.axisRanges[0]
+ y0, y1 := yRange.GetRange(0)
+ height := int(y1 - y0)
+ // 每一块之间的margin
+ margin := 10
+ // 每一个bar之间的margin
+ barMargin := 5
+ if height < 20 {
+ margin = 2
+ barMargin = 2
+ } else if height < 50 {
+ margin = 5
+ barMargin = 3
+ }
+ if opt.BarMargin > 0 {
+ barMargin = opt.BarMargin
+ }
+ seriesCount := len(seriesList)
+ // 总的高度-两个margin-(总数-1)的barMargin
+ barHeight := (height - 2*margin - barMargin*(seriesCount-1)) / seriesCount
+ if opt.BarHeight > 0 && opt.BarHeight < barHeight {
+ barHeight = opt.BarHeight
+ margin = (height - seriesCount*barHeight - barMargin*(seriesCount-1)) / 2
+ }
+
+ theme := opt.Theme
+
+ max, min := seriesList.GetMaxMin(0)
+ xRange := NewRange(AxisRangeOption{
+ Painter: p,
+ Min: min,
+ Max: max,
+ DivideCount: defaultAxisDivideCount,
+ Size: seriesPainter.Width(),
+ })
+ seriesNames := seriesList.Names()
+
+ rendererList := []Renderer{}
+ for index := range seriesList {
+ series := seriesList[index]
+ seriesColor := theme.GetSeriesColor(series.index)
+ divideValues := yRange.AutoDivide()
+
+ var labelPainter *SeriesLabelPainter
+ if series.Label.Show {
+ labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
+ P: seriesPainter,
+ SeriesNames: seriesNames,
+ Label: series.Label,
+ Theme: opt.Theme,
+ Font: opt.Font,
+ })
+ rendererList = append(rendererList, labelPainter)
+ }
+ for j, item := range series.Data {
+ if j >= yRange.divideCount {
+ continue
+ }
+ // 显示位置切换
+ j = yRange.divideCount - j - 1
+ y := divideValues[j]
+ y += margin
+ if index != 0 {
+ y += index * (barHeight + barMargin)
+ }
+
+ w := int(xRange.getHeight(item.Value))
+ fillColor := seriesColor
+ if !item.Style.FillColor.IsZero() {
+ fillColor = item.Style.FillColor
+ }
+ right := w
+ if series.RoundRadius <= 0 {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).Rect(chart.Box{
+ Top: y,
+ Left: 0,
+ Right: right,
+ Bottom: y + barHeight,
+ })
+ } else {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).RoundedRect(chart.Box{
+ Top: y,
+ Left: 0,
+ Right: right,
+ Bottom: y + barHeight,
+ }, series.RoundRadius)
+ }
+
+ // 如果label不需要展示,则返回
+ if labelPainter == nil {
+ continue
+ }
+ labelValue := LabelValue{
+ Orient: OrientHorizontal,
+ Index: index,
+ Value: item.Value,
+ X: right,
+ Y: y + barHeight>>1,
+ Offset: series.Label.Offset,
+ FontColor: series.Label.Color,
+ FontSize: series.Label.FontSize,
+ }
+ if series.Label.Position == PositionLeft {
+ labelValue.X = 0
+ if labelValue.FontColor.IsZero() {
+ if isLightColor(fillColor) {
+ labelValue.FontColor = defaultLightFontColor
+ } else {
+ labelValue.FontColor = defaultDarkFontColor
+ }
+ }
+ }
+ labelPainter.Add(labelValue)
+ }
+ }
+ err := doRender(rendererList...)
+ if err != nil {
+ return BoxZero, err
+ }
+ return p.box, nil
+}
+
+func (h *horizontalBarChart) Render() (Box, error) {
+ p := h.p
+ opt := h.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: opt.XAxis,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ axisReversed: true,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeHorizontalBar)
+ return h.render(renderResult, seriesList)
+}
diff --git a/horizontal_bar_chart_test.go b/horizontal_bar_chart_test.go
new file mode 100644
index 0000000..e078c4a
--- /dev/null
+++ b/horizontal_bar_chart_test.go
@@ -0,0 +1,100 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHorizontalBarChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewHorizontalBarChart(p, HorizontalBarChartOption{
+ Padding: Box{
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ SeriesList: NewSeriesListDataFromValues([][]float64{
+ {
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230,
+ },
+ {
+ 19325,
+ 23438,
+ 31000,
+ 121594,
+ 134141,
+ 681807,
+ },
+ }, ChartTypeHorizontalBar),
+ Title: TitleOption{
+ Text: "World Population",
+ },
+ Legend: NewLegendOption([]string{
+ "2011",
+ "2012",
+ }),
+ YAxisOptions: NewYAxisOptions([]string{
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\n2011 2012 World Population World China India USA Indonesia Brazil 0 122.28k 244.56k 366.84k 489.12k 611.4k 733.68k ",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/legend.go b/legend.go
index df72757..035642c 100644
--- a/legend.go
+++ b/legend.go
@@ -25,16 +25,19 @@ package charts
import (
"strconv"
"strings"
-
- "github.com/wcharczuk/go-chart/v2"
)
+type legendPainter struct {
+ p *Painter
+ opt *LegendOption
+}
+
+const IconRect = "rect"
+const IconLineDot = "lineDot"
+
type LegendOption struct {
- theme string
- // Legend show flag, if nil or true, the legend will be shown
- Show *bool
- // Legend text style
- Style chart.Style
+ // The theme
+ Theme ColorPalette
// Text array of legend
Data []string
// Distance between legend component and the left side of the container.
@@ -50,177 +53,199 @@ type LegendOption struct {
Orient string
// Icon of the legend.
Icon string
+ // Font size of legend text
+ FontSize float64
+ // FontColor color of legend text
+ FontColor Color
+ // The flag for show legend, set this to *false will hide legend
+ Show *bool
+ // The padding of legend
+ Padding Box
}
-const (
- LegendIconRect = "rect"
-)
-
-// NewLegendOption creates a new legend option by legend text list
-func NewLegendOption(data []string, position ...string) LegendOption {
+// NewLegendOption returns a legend option
+func NewLegendOption(labels []string, left ...string) LegendOption {
opt := LegendOption{
- Data: data,
+ Data: labels,
}
- if len(position) != 0 {
- opt.Left = position[0]
+ if len(left) != 0 {
+ opt.Left = left[0]
}
return opt
}
-type legend struct {
- d *Draw
- opt *LegendOption
+// IsEmpty checks legend is empty
+func (opt *LegendOption) IsEmpty() bool {
+ isEmpty := true
+ for _, v := range opt.Data {
+ if v != "" {
+ isEmpty = false
+ break
+ }
+ }
+ return isEmpty
}
-func NewLegend(d *Draw, opt LegendOption) *legend {
- return &legend{
- d: d,
+// NewLegendPainter returns a legend renderer
+func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter {
+ return &legendPainter{
+ p: p,
opt: &opt,
}
}
-func (l *legend) Render() (chart.Box, error) {
- d := l.d
+func (l *legendPainter) Render() (Box, error) {
opt := l.opt
- if len(opt.Data) == 0 || isFalse(opt.Show) {
- return chart.BoxZero, nil
+ theme := opt.Theme
+ if opt.IsEmpty() ||
+ isFalse(opt.Show) {
+ return BoxZero, nil
}
- theme := NewTheme(opt.theme)
- padding := opt.Style.Padding
- legendDraw, err := NewDraw(DrawOption{
- Parent: d,
- }, PaddingOption(padding))
- if err != nil {
- return chart.BoxZero, err
+ if theme == nil {
+ theme = l.p.theme
}
- r := legendDraw.Render
- opt.Style.GetTextOptions().WriteToRenderer(r)
-
- x := 0
- y := 0
- top := 0
- // TODO TOP 暂只支持数值
- if opt.Top != "" {
- top, _ = strconv.Atoi(opt.Top)
- y += top
+ if opt.FontSize == 0 {
+ opt.FontSize = theme.GetFontSize()
}
- legendWidth := 30
- legendDotHeight := 5
- textPadding := 5
- legendMargin := 10
- // 往下移2倍dot的高度
- y += 2 * legendDotHeight
-
- widthCount := 0
+ if opt.FontColor.IsZero() {
+ opt.FontColor = theme.GetTextColor()
+ }
+ if opt.Left == "" {
+ opt.Left = PositionCenter
+ }
+ padding := opt.Padding
+ if padding.IsZero() {
+ padding.Top = 5
+ }
+ p := l.p.Child(PainterPaddingOption(padding))
+ p.SetTextStyle(Style{
+ FontSize: opt.FontSize,
+ FontColor: opt.FontColor,
+ })
+ measureList := make([]Box, len(opt.Data))
maxTextWidth := 0
- // 文本宽度
- for _, text := range opt.Data {
- b := r.MeasureText(text)
+ for index, text := range opt.Data {
+ b := p.MeasureText(text)
if b.Width() > maxTextWidth {
maxTextWidth = b.Width()
}
- widthCount += b.Width()
- }
- if opt.Orient == OrientVertical {
- widthCount = maxTextWidth + legendWidth + textPadding
- } else {
- // 加上标记
- widthCount += legendWidth * len(opt.Data)
- // 文本的padding
- widthCount += 2 * textPadding * len(opt.Data)
- // margin的宽度
- widthCount += legendMargin * (len(opt.Data) - 1)
+ measureList[index] = b
}
+ // 计算展示的宽高
+ width := 0
+ height := 0
+ offset := 20
+ textOffset := 2
+ legendWidth := 30
+ legendHeight := 20
+ itemMaxHeight := 0
+ for _, item := range measureList {
+ if item.Height() > itemMaxHeight {
+ itemMaxHeight = item.Height()
+ }
+ if opt.Orient == OrientVertical {
+ height += item.Height()
+ } else {
+ width += item.Width()
+ }
+ }
+ // 增加padding
+ itemMaxHeight += 10
+ if opt.Orient == OrientVertical {
+ width = maxTextWidth + textOffset + legendWidth
+ height = offset * len(opt.Data)
+ } else {
+ height = legendHeight
+ offsetValue := (len(opt.Data) - 1) * (offset + textOffset)
+ allLegendWidth := len(opt.Data) * legendWidth
+ width += (offsetValue + allLegendWidth)
+ }
+
+ // 计算开始的位置
left := 0
switch opt.Left {
case PositionRight:
- left = legendDraw.Box.Width() - widthCount
+ left = p.Width() - width
case PositionCenter:
- left = (legendDraw.Box.Width() - widthCount) >> 1
+ left = (p.Width() - width) >> 1
default:
if strings.HasSuffix(opt.Left, "%") {
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
- left = legendDraw.Box.Width() * value / 100
+ left = p.Width() * value / 100
} else {
value, _ := strconv.Atoi(opt.Left)
left = value
}
}
- x = left
+ top, _ := strconv.Atoi(opt.Top)
+
+ if left < 0 {
+ left = 0
+ }
+
+ x := int(left)
+ y := int(top) + 10
+ startY := y
+ x0 := x
+ y0 := y
+
+ drawIcon := func(top, left int) int {
+ if opt.Icon == IconRect {
+ p.Rect(Box{
+ Top: top - legendHeight + 8,
+ Left: left,
+ Right: left + legendWidth,
+ Bottom: top + 1,
+ })
+ } else {
+ p.LegendLineDot(Box{
+ Top: top + 1,
+ Left: left,
+ Right: left + legendWidth,
+ Bottom: top + legendHeight + 1,
+ })
+ }
+ return left + legendWidth
+ }
+ lastIndex := len(opt.Data) - 1
for index, text := range opt.Data {
- textBox := r.MeasureText(text)
- var renderText func()
+ color := theme.GetSeriesColor(index)
+ p.SetDrawingStyle(Style{
+ FillColor: color,
+ StrokeColor: color,
+ })
+ itemWidth := x0 + measureList[index].Width() + textOffset + offset + legendWidth
+ if lastIndex == index {
+ itemWidth = x0 + measureList[index].Width() + legendWidth
+ }
+ if itemWidth > p.Width() {
+ x0 = 0
+ y += itemMaxHeight
+ y0 = y
+ }
+ if opt.Align != AlignRight {
+ x0 = drawIcon(y0, x0)
+ x0 += textOffset
+ }
+ p.Text(text, x0, y0)
+ x0 += measureList[index].Width()
+ if opt.Align == AlignRight {
+ x0 += textOffset
+ x0 = drawIcon(y0, x0)
+ }
if opt.Orient == OrientVertical {
- // 垂直
- // 重置x的位置
- x = left
- renderText = func() {
- x += textPadding
- legendDraw.text(text, x, y+legendDotHeight)
- x += textBox.Width()
- y += (2*legendDotHeight + legendMargin)
- }
-
+ y0 += offset
+ x0 = x
} else {
- // 水平
- if index != 0 {
- x += legendMargin
- }
- renderText = func() {
- x += textPadding
- legendDraw.text(text, x, y+legendDotHeight)
- x += textBox.Width()
- x += textPadding
- }
- }
- if opt.Align == PositionRight {
- renderText()
- }
- seriesColor := theme.GetSeriesColor(index)
- fillColor := seriesColor
- if !theme.IsDark() {
- fillColor = theme.GetBackgroundColor()
- }
- style := chart.Style{
- StrokeColor: seriesColor,
- FillColor: fillColor,
- StrokeWidth: 3,
- }
- if opt.Icon == LegendIconRect {
- style.FillColor = seriesColor
- style.StrokeWidth = 1
- }
- style.GetFillAndStrokeOptions().WriteDrawingOptionsToRenderer(r)
-
- if opt.Icon == LegendIconRect {
- legendDraw.moveTo(x, y-legendDotHeight)
- legendDraw.lineTo(x+legendWidth, y-legendDotHeight)
- legendDraw.lineTo(x+legendWidth, y+legendDotHeight)
- legendDraw.lineTo(x, y+legendDotHeight)
- legendDraw.lineTo(x, y-legendDotHeight)
- r.FillStroke()
- } else {
- legendDraw.moveTo(x, y)
- legendDraw.lineTo(x+legendWidth, y)
- r.Stroke()
- legendDraw.circle(float64(legendDotHeight), x+legendWidth>>1, y)
- r.FillStroke()
- }
- x += legendWidth
-
- if opt.Align != PositionRight {
- renderText()
+ x0 += offset
+ y0 = y
}
+ height = y0 - startY + 10
}
- legendBox := padding.Clone()
- // 计算展示区域
- if opt.Orient == OrientVertical {
- legendBox.Right = legendBox.Left + left + maxTextWidth + legendWidth + textPadding
- legendBox.Bottom = legendBox.Top + y
- } else {
- legendBox.Right = legendBox.Left + x
- legendBox.Bottom = legendBox.Top + 2*legendDotHeight + top + textPadding
- }
- return legendBox, nil
+
+ return Box{
+ Right: width,
+ Bottom: height + padding.Bottom + padding.Top,
+ }, nil
}
diff --git a/legend_test.go b/legend_test.go
index c5d7e50..526f178 100644
--- a/legend_test.go
+++ b/legend_test.go
@@ -26,160 +26,77 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
)
-func TestNewLegendOption(t *testing.T) {
+func TestNewLegend(t *testing.T) {
assert := assert.New(t)
- opt := NewLegendOption([]string{
- "a",
- "b",
- }, PositionRight)
- assert.Equal(LegendOption{
- Data: []string{
- "a",
- "b",
- },
- Left: PositionRight,
- }, opt)
-}
-
-func TestLegendRender(t *testing.T) {
- assert := assert.New(t)
-
- newDraw := func() *Draw {
- d, _ := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- return d
- }
- style := chart.Style{
- FontSize: 10,
- FontColor: drawing.ColorBlack,
- }
- style.Font, _ = chart.GetDefaultFont()
-
tests := []struct {
- newDraw func() *Draw
- newLegend func(*Draw) *legend
- box chart.Box
- result string
+ render func(*Painter) ([]byte, error)
+ result string
}{
{
- newDraw: newDraw,
- newLegend: func(d *Draw) *legend {
- return NewLegend(d, LegendOption{
- Top: "10",
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewLegendPainter(p, LegendOption{
Data: []string{
- "Mon",
- "Tue",
- "Wed",
+ "One",
+ "Two",
+ "Three",
},
- Style: style,
- })
- },
- result: "\\nMon Tue Wed ",
- box: chart.Box{
- Right: 214,
- Bottom: 25,
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\nOne Two Three ",
},
{
- newDraw: newDraw,
- newLegend: func(d *Draw) *legend {
- return NewLegend(d, LegendOption{
- Top: "10",
- Left: PositionRight,
- Align: PositionRight,
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewLegendPainter(p, LegendOption{
Data: []string{
- "Mon",
- "Tue",
- "Wed",
+ "One",
+ "Two",
+ "Three",
},
- Style: style,
- })
- },
- result: "\\nMon Tue Wed ",
- 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: "\\nMon Tue Wed ",
- box: chart.Box{
- Right: 307,
- Bottom: 25,
- },
- },
- {
- newDraw: newDraw,
- newLegend: func(d *Draw) *legend {
- return NewLegend(d, LegendOption{
- Top: "10",
Left: PositionLeft,
- Data: []string{
- "Mon",
- "Tue",
- "Wed",
- },
- Style: style,
- Orient: OrientVertical,
- })
- },
- result: "\\nMon Tue Wed ",
- box: chart.Box{
- Right: 61,
- Bottom: 80,
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\nOne Two Three ",
},
{
- newDraw: newDraw,
- newLegend: func(d *Draw) *legend {
- return NewLegend(d, LegendOption{
- Top: "10",
- Left: "10%",
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewLegendPainter(p, LegendOption{
Data: []string{
- "Mon",
- "Tue",
- "Wed",
+ "One",
+ "Two",
+ "Three",
},
- Style: style,
Orient: OrientVertical,
- })
+ Icon: IconRect,
+ Left: "10%",
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
- box: chart.Box{
- Right: 101,
- Bottom: 80,
- },
- result: "\\nMon Tue Wed ",
+ result: "\\nOne Two Three ",
},
}
-
for _, tt := range tests {
- d := tt.newDraw()
- b, err := tt.newLegend(d).Render()
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
assert.Nil(err)
- assert.Equal(tt.box, b)
- data, err := d.Bytes()
+ data, err := tt.render(p)
assert.Nil(err)
- assert.NotEmpty(data)
assert.Equal(tt.result, string(data))
}
}
diff --git a/line.go b/line.go
deleted file mode 100644
index 0fc25d6..0000000
--- a/line.go
+++ /dev/null
@@ -1,103 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
-)
-
-type LineStyle struct {
- ClassName string
- StrokeDashArray []float64
- StrokeColor drawing.Color
- StrokeWidth float64
- FillColor drawing.Color
- DotWidth float64
- DotColor drawing.Color
- DotFillColor drawing.Color
-}
-
-func (ls *LineStyle) Style() chart.Style {
- return chart.Style{
- ClassName: ls.ClassName,
- StrokeDashArray: ls.StrokeDashArray,
- StrokeColor: ls.StrokeColor,
- StrokeWidth: ls.StrokeWidth,
- FillColor: ls.FillColor,
- DotWidth: ls.DotWidth,
- DotColor: ls.DotColor,
- }
-}
-
-func (d *Draw) lineFill(points []Point, style LineStyle) {
- s := style.Style()
- if !(s.ShouldDrawStroke() && s.ShouldDrawFill()) {
- return
- }
-
- newPoints := make([]Point, len(points))
- copy(newPoints, points)
- x0 := points[0].X
- y0 := points[0].Y
- height := d.Box.Height()
- newPoints = append(newPoints, Point{
- X: points[len(points)-1].X,
- Y: height,
- }, Point{
- X: x0,
- Y: height,
- }, Point{
- X: x0,
- Y: y0,
- })
- d.fill(newPoints, style.Style())
-}
-
-func (d *Draw) lineDot(points []Point, style LineStyle) {
- s := style.Style()
- if !s.ShouldDrawDot() {
- return
- }
- r := d.Render
- dotWith := s.GetDotWidth()
-
- s.GetDotOptions().WriteDrawingOptionsToRenderer(r)
- for _, point := range points {
- if !style.DotFillColor.IsZero() {
- r.SetFillColor(style.DotFillColor)
- }
- r.SetStrokeColor(s.DotColor)
- d.circle(dotWith, point.X, point.Y)
- r.FillStroke()
- }
-}
-
-func (d *Draw) Line(points []Point, style LineStyle) {
- if len(points) == 0 {
- return
- }
- d.lineFill(points, style)
- d.lineStroke(points, style)
- d.lineDot(points, style)
-}
diff --git a/line_chart.go b/line_chart.go
index ac9091c..fb1d16a 100644
--- a/line_chart.go
+++ b/line_chart.go
@@ -23,109 +23,218 @@
package charts
import (
+ "math"
+
"github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
-type lineChartOption struct {
- Theme string
- SeriesList SeriesList
- Font *truetype.Font
+type lineChart struct {
+ p *Painter
+ opt *LineChartOption
}
-func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPointRenderOption, error) {
-
- theme := NewTheme(opt.Theme)
-
- d, err := NewDraw(DrawOption{
- Parent: result.d,
- }, PaddingOption(chart.Box{
- Top: result.titleBox.Height(),
- Left: YAxisWidth,
- }))
- if err != nil {
- return nil, err
+// NewLineChart returns a line chart render
+func NewLineChart(p *Painter, opt LineChartOption) *lineChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
}
- seriesNames := opt.SeriesList.Names()
+ return &lineChart{
+ p: p,
+ opt: &opt,
+ }
+}
- r := d.Render
- xRange := result.xRange
- markPointRenderOptions := make([]markPointRenderOption, 0)
- for i, s := range opt.SeriesList {
- // 由于series是for range,为同一个数据,因此需要clone
- // 后续需要使用,如mark point
- series := s
- index := series.index
- if index == 0 {
- index = i
+type LineChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The x axis option
+ XAxis XAxisOption
+ // The padding of line chart
+ Padding Box
+ // The y axis option
+ YAxisOptions []YAxisOption
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ // The flag for show symbol of line, set this to *false will hide symbol
+ SymbolShow *bool
+ // The stroke width of line
+ StrokeWidth float64
+ // Fill the area of line
+ FillArea bool
+ // background is filled
+ backgroundIsFilled bool
+ // background fill (alpha) opacity
+ Opacity uint8
+}
+
+func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ p := l.p
+ opt := l.opt
+ boundaryGap := true
+ if isFalse(opt.XAxis.BoundaryGap) {
+ boundaryGap = false
+ }
+
+ seriesPainter := result.seriesPainter
+
+ xDivideCount := len(opt.XAxis.Data)
+ if !boundaryGap {
+ xDivideCount--
+ }
+ xDivideValues := autoDivide(seriesPainter.Width(), xDivideCount)
+ xValues := make([]int, len(xDivideValues)-1)
+ if boundaryGap {
+ for i := 0; i < len(xDivideValues)-1; i++ {
+ xValues[i] = (xDivideValues[i] + xDivideValues[i+1]) >> 1
}
- seriesColor := theme.GetSeriesColor(index)
-
- yRange := result.getYRange(series.YAxisIndex)
- points := make([]Point, 0, len(series.Data))
- // mark line
- markLineRender(markLineRenderOption{
- Draw: d,
- FillColor: seriesColor,
- FontColor: theme.GetTextColor(),
+ } else {
+ xValues = xDivideValues
+ }
+ markPointPainter := NewMarkPointPainter(seriesPainter)
+ markLinePainter := NewMarkLinePainter(seriesPainter)
+ rendererList := []Renderer{
+ markPointPainter,
+ markLinePainter,
+ }
+ strokeWidth := opt.StrokeWidth
+ if strokeWidth == 0 {
+ strokeWidth = defaultStrokeWidth
+ }
+ seriesNames := seriesList.Names()
+ for index := range seriesList {
+ series := seriesList[index]
+ seriesColor := opt.Theme.GetSeriesColor(series.index)
+ drawingStyle := Style{
StrokeColor: seriesColor,
- Font: opt.Font,
- Series: &series,
- Range: yRange,
- })
+ StrokeWidth: strokeWidth,
+ }
+ if len(series.Style.StrokeDashArray) > 0 {
+ drawingStyle.StrokeDashArray = series.Style.StrokeDashArray
+ }
- for j, item := range series.Data {
- if j >= xRange.divideCount {
- continue
- }
- y := yRange.getRestHeight(item.Value)
- x := xRange.getWidth(float64(j))
- points = append(points, Point{
- Y: y,
- X: x,
+ yRange := result.axisRanges[series.AxisIndex]
+ points := make([]Point, 0)
+ var labelPainter *SeriesLabelPainter
+ if series.Label.Show {
+ labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
+ P: seriesPainter,
+ SeriesNames: seriesNames,
+ Label: series.Label,
+ Theme: opt.Theme,
+ Font: opt.Font,
})
- if !series.Label.Show {
+ rendererList = append(rendererList, labelPainter)
+ }
+ for i, item := range series.Data {
+ h := yRange.getRestHeight(item.Value)
+ if item.Value == nullValue {
+ h = int(math.MaxInt32)
+ }
+ p := Point{
+ X: xValues[i],
+ Y: h,
+ }
+ points = append(points, p)
+
+ // 如果label不需要展示,则返回
+ if labelPainter == nil {
continue
}
- distance := series.Label.Distance
- if distance == 0 {
- distance = 5
- }
- text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1)
- labelStyle := chart.Style{
- FontColor: theme.GetTextColor(),
- FontSize: labelFontSize,
- Font: opt.Font,
- }
- if !series.Label.Color.IsZero() {
- labelStyle.FontColor = series.Label.Color
- }
- labelStyle.GetTextOptions().WriteToRenderer(r)
- textBox := r.MeasureText(text)
- d.text(text, x-textBox.Width()>>1, y-distance)
+ labelPainter.Add(LabelValue{
+ Index: index,
+ Value: item.Value,
+ X: p.X,
+ Y: p.Y,
+ // 字体大小
+ FontSize: series.Label.FontSize,
+ })
}
+ // 如果需要填充区域
+ if opt.FillArea {
+ areaPoints := make([]Point, len(points))
+ copy(areaPoints, points)
+ bottomY := yRange.getRestHeight(yRange.min)
+ var opacity uint8 = 200
+ if opt.Opacity != 0 {
+ opacity = opt.Opacity
+ }
+ areaPoints = append(areaPoints, Point{
+ X: areaPoints[len(areaPoints)-1].X,
+ Y: bottomY,
+ }, Point{
+ X: areaPoints[0].X,
+ Y: bottomY,
+ }, areaPoints[0])
+ seriesPainter.SetDrawingStyle(Style{
+ FillColor: seriesColor.WithAlpha(opacity),
+ })
+ seriesPainter.FillArea(areaPoints)
+ }
+ seriesPainter.SetDrawingStyle(drawingStyle)
- dotFillColor := drawing.ColorWhite
- if theme.IsDark() {
- dotFillColor = seriesColor
+ // 画线
+ seriesPainter.LineStroke(points)
+
+ // 画点
+ if opt.Theme.IsDark() {
+ drawingStyle.FillColor = drawingStyle.StrokeColor
+ } else {
+ drawingStyle.FillColor = drawing.ColorWhite
}
- d.Line(points, LineStyle{
- StrokeColor: seriesColor,
- StrokeWidth: 2,
- DotColor: seriesColor,
- DotWidth: defaultDotWidth,
- DotFillColor: dotFillColor,
- })
- // draw mark point
- markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{
- Draw: d,
+ drawingStyle.StrokeWidth = 1
+ seriesPainter.SetDrawingStyle(drawingStyle)
+ if !isFalse(opt.SymbolShow) {
+ seriesPainter.Dots(points)
+ }
+ markPointPainter.Add(markPointRenderOption{
FillColor: seriesColor,
Font: opt.Font,
Points: points,
- Series: &series,
+ Series: series,
+ })
+ markLinePainter.Add(markLineRenderOption{
+ FillColor: seriesColor,
+ FontColor: opt.Theme.GetTextColor(),
+ StrokeColor: seriesColor,
+ Font: opt.Font,
+ Series: series,
+ Range: yRange,
})
}
+ // 最大、最小的mark point
+ err := doRender(rendererList...)
+ if err != nil {
+ return BoxZero, err
+ }
- return markPointRenderOptions, nil
+ return p.box, nil
+}
+
+func (l *lineChart) Render() (Box, error) {
+ p := l.p
+ opt := l.opt
+
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: opt.XAxis,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ backgroundIsFilled: opt.backgroundIsFilled,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeLine)
+
+ return l.render(renderResult, seriesList)
}
diff --git a/line_chart_test.go b/line_chart_test.go
index 9f5d9af..e169f90 100644
--- a/line_chart_test.go
+++ b/line_chart_test.go
@@ -26,72 +26,194 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
)
-func TestLineChartRender(t *testing.T) {
+func TestLineChart(t *testing.T) {
assert := assert.New(t)
- width := 400
- height := 300
- d, err := NewDraw(DrawOption{
- Width: width,
- Height: height,
- })
- assert.Nil(err)
-
- result := basicRenderResult{
- xRange: &Range{
- Min: 0,
- Max: 4,
- divideCount: 4,
- Size: width,
- Boundary: true,
- },
- yRangeList: []*Range{
- {
- divideCount: 6,
- Max: 100,
- Min: 0,
- Size: height,
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ _, err := NewLineChart(p, LineChartOption{
+ Title: TitleOption{
+ Text: "Line",
+ },
+ Padding: Box{
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ Legend: NewLegendOption([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }, PositionCenter),
+ SeriesList: NewSeriesListDataFromValues(values),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\nEmail Union Ads Video Ads Direct Search Engine Line 1.44k 1.2k 960 720 480 240 0 Mon Tue Wed Thu Fri Sat Sun ",
+ },
+ {
+ 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: "\\nEmail Union Ads Video Ads Direct Search Engine Line 1.44k 1.2k 960 720 480 240 0 Mon Tue Wed Thu Fri Sat Sun ",
},
- 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.66 20 60 90 ", string(data))
+
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
}
diff --git a/line_test.go b/line_test.go
deleted file mode 100644
index e10b806..0000000
--- a/line_test.go
+++ /dev/null
@@ -1,165 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
-)
-
-func TestLineStyle(t *testing.T) {
- assert := assert.New(t)
-
- ls := LineStyle{
- ClassName: "test",
- StrokeDashArray: []float64{
- 1.0,
- },
- StrokeColor: drawing.ColorBlack,
- StrokeWidth: 1,
- FillColor: drawing.ColorBlack.WithAlpha(60),
- DotWidth: 2,
- DotColor: drawing.ColorBlack,
- DotFillColor: drawing.ColorWhite,
- }
-
- assert.Equal(chart.Style{
- ClassName: "test",
- StrokeDashArray: []float64{
- 1.0,
- },
- StrokeColor: drawing.ColorBlack,
- StrokeWidth: 1,
- FillColor: drawing.ColorBlack.WithAlpha(60),
- DotWidth: 2,
- DotColor: drawing.ColorBlack,
- }, ls.Style())
-}
-
-func TestDrawLineFill(t *testing.T) {
- assert := assert.New(t)
-
- ls := LineStyle{
- StrokeColor: drawing.ColorBlack,
- StrokeWidth: 1,
- FillColor: drawing.ColorBlack.WithAlpha(60),
- DotWidth: 2,
- DotColor: drawing.ColorBlack,
- DotFillColor: drawing.ColorWhite,
- }
- d, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- assert.Nil(err)
- d.lineFill([]Point{
- {
- X: 0,
- Y: 0,
- },
- {
- X: 10,
- Y: 20,
- },
- {
- X: 50,
- Y: 60,
- },
- }, ls)
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal("\\n ", string(data))
-}
-
-func TestDrawLineDot(t *testing.T) {
- assert := assert.New(t)
-
- ls := LineStyle{
- StrokeColor: drawing.ColorBlack,
- StrokeWidth: 1,
- FillColor: drawing.ColorBlack.WithAlpha(60),
- DotWidth: 2,
- DotColor: drawing.ColorBlack,
- DotFillColor: drawing.ColorWhite,
- }
- d, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- assert.Nil(err)
- d.lineDot([]Point{
- {
- X: 0,
- Y: 0,
- },
- {
- X: 10,
- Y: 20,
- },
- {
- X: 50,
- Y: 60,
- },
- }, ls)
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal("\\n ", string(data))
-}
-
-func TestDrawLine(t *testing.T) {
- assert := assert.New(t)
-
- ls := LineStyle{
- StrokeColor: drawing.ColorBlack,
- StrokeWidth: 1,
- FillColor: drawing.ColorBlack.WithAlpha(60),
- DotWidth: 2,
- DotColor: drawing.ColorBlack,
- DotFillColor: drawing.ColorWhite,
- }
- d, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- assert.Nil(err)
- d.Line([]Point{
- {
- X: 0,
- Y: 0,
- },
- {
- X: 10,
- Y: 20,
- },
- {
- X: 50,
- Y: 60,
- },
- }, ls)
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal("\\n ", string(data))
-}
diff --git a/mark_line.go b/mark_line.go
index 464fe71..bc850bb 100644
--- a/mark_line.go
+++ b/mark_line.go
@@ -24,10 +24,9 @@ package charts
import (
"github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
)
+// NewMarkLine returns a series mark line
func NewMarkLine(markLineTypes ...string) SeriesMarkLine {
data := make([]SeriesMarkData, len(markLineTypes))
for index, t := range markLineTypes {
@@ -40,53 +39,75 @@ func NewMarkLine(markLineTypes ...string) SeriesMarkLine {
}
}
+type markLinePainter struct {
+ p *Painter
+ options []markLineRenderOption
+}
+
+func (m *markLinePainter) Add(opt markLineRenderOption) {
+ m.options = append(m.options, opt)
+}
+
+// NewMarkLinePainter returns a mark line renderer
+func NewMarkLinePainter(p *Painter) *markLinePainter {
+ return &markLinePainter{
+ p: p,
+ options: make([]markLineRenderOption, 0),
+ }
+}
+
type markLineRenderOption struct {
- Draw *Draw
- FillColor drawing.Color
- FontColor drawing.Color
- StrokeColor drawing.Color
+ FillColor Color
+ FontColor Color
+ StrokeColor Color
Font *truetype.Font
- Series *Series
- Range *Range
+ Series Series
+ Range axisRange
}
-func markLineRender(opt markLineRenderOption) {
- d := opt.Draw
- s := opt.Series
- if len(s.MarkLine.Data) == 0 {
- return
- }
- r := d.Render
- summary := s.Summary()
- for _, markLine := range s.MarkLine.Data {
- // 由于mark line会修改style,因此每次重新设置
- chart.Style{
- FillColor: opt.FillColor,
- FontColor: opt.FontColor,
- FontSize: labelFontSize,
- StrokeColor: opt.StrokeColor,
- StrokeWidth: 1,
- Font: opt.Font,
- StrokeDashArray: []float64{
- 4,
- 2,
- },
- }.WriteToRenderer(r)
- value := float64(0)
- switch markLine.Type {
- case SeriesMarkDataTypeMax:
- value = summary.MaxValue
- case SeriesMarkDataTypeMin:
- value = summary.MinValue
- default:
- value = summary.AverageValue
+func (m *markLinePainter) Render() (Box, error) {
+ painter := m.p
+ for _, opt := range m.options {
+ s := opt.Series
+ if len(s.MarkLine.Data) == 0 {
+ continue
+ }
+ font := opt.Font
+ if font == nil {
+ font, _ = GetDefaultFont()
+ }
+ summary := s.Summary()
+ for _, markLine := range s.MarkLine.Data {
+ // 由于mark line会修改style,因此每次重新设置
+ painter.OverrideDrawingStyle(Style{
+ FillColor: opt.FillColor,
+ StrokeColor: opt.StrokeColor,
+ StrokeWidth: 1,
+ StrokeDashArray: []float64{
+ 4,
+ 2,
+ },
+ }).OverrideTextStyle(Style{
+ Font: font,
+ FontColor: opt.FontColor,
+ FontSize: labelFontSize,
+ })
+ value := float64(0)
+ switch markLine.Type {
+ case SeriesMarkDataTypeMax:
+ value = summary.MaxValue
+ case SeriesMarkDataTypeMin:
+ value = summary.MinValue
+ default:
+ value = summary.AverageValue
+ }
+ y := opt.Range.getRestHeight(value)
+ width := painter.Width()
+ text := commafWithDigits(value)
+ textBox := painter.MeasureText(text)
+ painter.MarkLine(0, y, width-2)
+ painter.Text(text, width, y+textBox.Height()>>1-2)
}
- y := opt.Range.getRestHeight(value)
- width := d.Box.Width()
- text := commafWithDigits(value)
- textBox := r.MeasureText(text)
- d.makeLine(0, y, width-2)
- d.text(text, width, y+textBox.Height()>>1-2)
}
-
+ return BoxZero, nil
}
diff --git a/mark_line_test.go b/mark_line_test.go
index abb3308..0448cda 100644
--- a/mark_line_test.go
+++ b/mark_line_test.go
@@ -26,74 +26,65 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
-func TestNewMarkLine(t *testing.T) {
+func TestMarkLine(t *testing.T) {
assert := assert.New(t)
- markLine := NewMarkLine(
- SeriesMarkDataTypeMax,
- SeriesMarkDataTypeMin,
- SeriesMarkDataTypeAverage,
- )
-
- assert.Equal(SeriesMarkLine{
- Data: []SeriesMarkData{
- {
- Type: SeriesMarkDataTypeMax,
- },
- {
- Type: SeriesMarkDataTypeMin,
- },
- {
- Type: SeriesMarkDataTypeAverage,
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ markLine := NewMarkLinePainter(p)
+ series := NewSeriesFromValues([]float64{
+ 1,
+ 2,
+ 3,
+ })
+ series.MarkLine = NewMarkLine(
+ SeriesMarkDataTypeMax,
+ SeriesMarkDataTypeAverage,
+ SeriesMarkDataTypeMin,
+ )
+ markLine.Add(markLineRenderOption{
+ FillColor: drawing.ColorBlack,
+ FontColor: drawing.ColorBlack,
+ StrokeColor: drawing.ColorBlack,
+ Series: series,
+ Range: NewRange(AxisRangeOption{
+ Painter: p,
+ Min: 0,
+ Max: 5,
+ Size: p.Height(),
+ DivideCount: 6,
+ }),
+ })
+ _, err := markLine.Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\n3 2 1 ",
},
- }, 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("\\n9 1 5 ", string(data))
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
}
diff --git a/mark_point.go b/mark_point.go
index 5fd34c4..fd8a88b 100644
--- a/mark_point.go
+++ b/mark_point.go
@@ -24,10 +24,9 @@ package charts
import (
"github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
)
+// NewMarkPoint returns a series mark point
func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint {
data := make([]SeriesMarkData, len(markPointTypes))
for index, t := range markPointTypes {
@@ -40,50 +39,77 @@ func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint {
}
}
+type markPointPainter struct {
+ p *Painter
+ options []markPointRenderOption
+}
+
+func (m *markPointPainter) Add(opt markPointRenderOption) {
+ m.options = append(m.options, opt)
+}
+
type markPointRenderOption struct {
- Draw *Draw
- FillColor drawing.Color
+ FillColor Color
Font *truetype.Font
- Series *Series
+ Series Series
Points []Point
}
-func markPointRender(opt markPointRenderOption) {
- d := opt.Draw
- s := opt.Series
- if len(s.MarkPoint.Data) == 0 {
- return
- }
- points := opt.Points
- summary := s.Summary()
- symbolSize := s.MarkPoint.SymbolSize
- if symbolSize == 0 {
- symbolSize = 30
- }
- r := d.Render
- // 设置填充样式
- chart.Style{
- FillColor: opt.FillColor,
- }.WriteToRenderer(r)
- // 设置文本样式
- chart.Style{
- FontColor: NewTheme(ThemeDark).GetTextColor(),
- FontSize: labelFontSize,
- StrokeWidth: 1,
- Font: opt.Font,
- }.WriteTextOptionsToRenderer(r)
- for _, markPointData := range s.MarkPoint.Data {
- p := points[summary.MinIndex]
- value := summary.MinValue
- switch markPointData.Type {
- case SeriesMarkDataTypeMax:
- p = points[summary.MaxIndex]
- value = summary.MaxValue
- }
-
- d.pin(p.X, p.Y-symbolSize>>1, symbolSize)
- text := commafWithDigits(value)
- textBox := r.MeasureText(text)
- d.text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2)
+// NewMarkPointPainter returns a mark point renderer
+func NewMarkPointPainter(p *Painter) *markPointPainter {
+ return &markPointPainter{
+ p: p,
+ options: make([]markPointRenderOption, 0),
}
}
+
+func (m *markPointPainter) Render() (Box, error) {
+ painter := m.p
+ for _, opt := range m.options {
+ s := opt.Series
+ if len(s.MarkPoint.Data) == 0 {
+ continue
+ }
+ points := opt.Points
+ summary := s.Summary()
+ symbolSize := s.MarkPoint.SymbolSize
+ if symbolSize == 0 {
+ symbolSize = 30
+ }
+ textStyle := Style{
+ FontSize: labelFontSize,
+ StrokeWidth: 1,
+ Font: opt.Font,
+ }
+ if isLightColor(opt.FillColor) {
+ textStyle.FontColor = defaultLightFontColor
+ } else {
+ textStyle.FontColor = defaultDarkFontColor
+ }
+ painter.OverrideDrawingStyle(Style{
+ FillColor: opt.FillColor,
+ }).OverrideTextStyle(textStyle)
+ for _, markPointData := range s.MarkPoint.Data {
+ textStyle.FontSize = labelFontSize
+ painter.OverrideTextStyle(textStyle)
+ p := points[summary.MinIndex]
+ value := summary.MinValue
+ switch markPointData.Type {
+ case SeriesMarkDataTypeMax:
+ p = points[summary.MaxIndex]
+ value = summary.MaxValue
+ }
+
+ painter.Pin(p.X, p.Y-symbolSize>>1, symbolSize)
+ text := commafWithDigits(value)
+ textBox := painter.MeasureText(text)
+ if textBox.Width() > symbolSize {
+ textStyle.FontSize = smallLabelFontSize
+ painter.OverrideTextStyle(textStyle)
+ textBox = painter.MeasureText(text)
+ }
+ painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2)
+ }
+ }
+ return BoxZero, nil
+}
diff --git a/mark_point_test.go b/mark_point_test.go
index 2cd8fdd..298345b 100644
--- a/mark_point_test.go
+++ b/mark_point_test.go
@@ -26,78 +26,67 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
-func TestNewMarkPoint(t *testing.T) {
+func TestMarkPoint(t *testing.T) {
assert := assert.New(t)
- markPoint := NewMarkPoint(
- SeriesMarkDataTypeMax,
- SeriesMarkDataTypeMin,
- SeriesMarkDataTypeAverage,
- )
-
- assert.Equal(SeriesMarkPoint{
- Data: []SeriesMarkData{
- {
- Type: SeriesMarkDataTypeMax,
- },
- {
- Type: SeriesMarkDataTypeMin,
- },
- {
- Type: SeriesMarkDataTypeAverage,
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ series := NewSeriesFromValues([]float64{
+ 1,
+ 2,
+ 3,
+ })
+ series.MarkPoint = NewMarkPoint(SeriesMarkDataTypeMax)
+ markPoint := NewMarkPointPainter(p)
+ markPoint.Add(markPointRenderOption{
+ FillColor: drawing.ColorBlack,
+ Series: series,
+ Points: []Point{
+ {
+ X: 10,
+ Y: 10,
+ },
+ {
+ X: 30,
+ Y: 30,
+ },
+ {
+ X: 50,
+ Y: 50,
+ },
+ },
+ })
+ _, err := markPoint.Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\n3 ",
},
- }, markPoint)
-}
-
-func TestMarkPointRender(t *testing.T) {
- assert := assert.New(t)
-
- d, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- }, PaddingOption(chart.Box{
- Left: 20,
- Right: 20,
- }))
- assert.Nil(err)
- f, _ := chart.GetDefaultFont()
-
- markPointRender(markPointRenderOption{
- Draw: d,
- FillColor: drawing.ColorBlack,
- Font: f,
- Series: &Series{
- MarkPoint: NewMarkPoint(
- SeriesMarkDataTypeMax,
- SeriesMarkDataTypeMin,
- ),
- Data: NewSeriesDataFromValues([]float64{
- 1,
- 3,
- 5,
- }),
- },
- Points: []Point{
- {
- X: 1,
- Y: 50,
- },
- {
- X: 100,
- Y: 100,
- },
- {
- X: 200,
- Y: 200,
- },
- },
- })
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal("\\n5 1 ", string(data))
+ }
+
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
}
diff --git a/painter.go b/painter.go
new file mode 100644
index 0000000..bee646f
--- /dev/null
+++ b/painter.go
@@ -0,0 +1,866 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "bytes"
+ "errors"
+ "math"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type ValueFormatter func(float64) string
+
+type Painter struct {
+ render chart.Renderer
+ box Box
+ font *truetype.Font
+ parent *Painter
+ style Style
+ theme ColorPalette
+ // 类型
+ outputType string
+ valueFormatter ValueFormatter
+}
+
+type PainterOptions struct {
+ // Draw type, "svg" or "png", default type is "png"
+ Type string
+ // The width of draw painter
+ Width int
+ // The height of draw painter
+ Height int
+ // The font for painter
+ Font *truetype.Font
+}
+
+type PainterOption func(*Painter)
+
+type TicksOption struct {
+ // the first tick
+ First int
+ Length int
+ Orient string
+ Count int
+ Unit int
+}
+
+type MultiTextOption struct {
+ TextList []string
+ Orient string
+ Unit int
+ Position string
+ Align string
+ // The text rotation of label
+ TextRotation float64
+ Offset Box
+ // The first text index
+ First int
+}
+
+type GridOption struct {
+ Column int
+ Row int
+ ColumnSpans []int
+ // 忽略不展示的column
+ IgnoreColumnLines []int
+ // 忽略不展示的row
+ IgnoreRowLines []int
+}
+
+// PainterPaddingOption sets the padding of draw painter
+func PainterPaddingOption(padding Box) PainterOption {
+ return func(p *Painter) {
+ p.box.Left += padding.Left
+ p.box.Top += padding.Top
+ p.box.Right -= padding.Right
+ p.box.Bottom -= padding.Bottom
+ }
+}
+
+// PainterBoxOption sets the box of draw painter
+func PainterBoxOption(box Box) PainterOption {
+ return func(p *Painter) {
+ if box.IsZero() {
+ return
+ }
+ p.box = box
+ }
+}
+
+// PainterFontOption sets the font of draw painter
+func PainterFontOption(font *truetype.Font) PainterOption {
+ return func(p *Painter) {
+ if font == nil {
+ return
+ }
+ p.font = font
+ }
+}
+
+// PainterStyleOption sets the style of draw painter
+func PainterStyleOption(style Style) PainterOption {
+ return func(p *Painter) {
+ p.SetStyle(style)
+ }
+}
+
+// PainterThemeOption sets the theme of draw painter
+func PainterThemeOption(theme ColorPalette) PainterOption {
+ return func(p *Painter) {
+ if theme == nil {
+ return
+ }
+ p.theme = theme
+ }
+}
+
+// PainterWidthHeightOption set width or height of draw painter
+func PainterWidthHeightOption(width, height int) PainterOption {
+ return func(p *Painter) {
+ if width > 0 {
+ p.box.Right = p.box.Left + width
+ }
+ if height > 0 {
+ p.box.Bottom = p.box.Top + height
+ }
+ }
+}
+
+// NewPainter creates a painter
+func NewPainter(opts PainterOptions, opt ...PainterOption) (*Painter, error) {
+ if opts.Width <= 0 || opts.Height <= 0 {
+ return nil, errors.New("width/height can not be nil")
+ }
+ font := opts.Font
+ if font == nil {
+ f, err := GetDefaultFont()
+ if err != nil {
+ return nil, err
+ }
+ font = f
+ }
+ fn := chart.PNG
+ if opts.Type == ChartOutputSVG {
+ fn = chart.SVG
+ }
+ width := opts.Width
+ height := opts.Height
+ r, err := fn(width, height)
+ if err != nil {
+ return nil, err
+ }
+ r.SetFont(font)
+
+ p := &Painter{
+ render: r,
+ box: Box{
+ Right: opts.Width,
+ Bottom: opts.Height,
+ },
+ font: font,
+ // 类型
+ outputType: opts.Type,
+ }
+ p.setOptions(opt...)
+ if p.theme == nil {
+ p.theme = NewTheme(ThemeLight)
+ }
+ return p, nil
+}
+func (p *Painter) setOptions(opts ...PainterOption) {
+ for _, fn := range opts {
+ fn(p)
+ }
+}
+
+func (p *Painter) Child(opt ...PainterOption) *Painter {
+ child := &Painter{
+ // 格式化
+ valueFormatter: p.valueFormatter,
+ // render
+ render: p.render,
+ box: p.box.Clone(),
+ font: p.font,
+ parent: p,
+ style: p.style,
+ theme: p.theme,
+ }
+ child.setOptions(opt...)
+ return child
+}
+
+func (p *Painter) SetStyle(style Style) {
+ if style.Font == nil {
+ style.Font = p.font
+ }
+ p.style = style
+ style.WriteToRenderer(p.render)
+}
+
+func overrideStyle(defaultStyle Style, style Style) Style {
+ if style.StrokeWidth == 0 {
+ style.StrokeWidth = defaultStyle.StrokeWidth
+ }
+ if style.StrokeColor.IsZero() {
+ style.StrokeColor = defaultStyle.StrokeColor
+ }
+ if style.StrokeDashArray == nil {
+ style.StrokeDashArray = defaultStyle.StrokeDashArray
+ }
+ if style.DotColor.IsZero() {
+ style.DotColor = defaultStyle.DotColor
+ }
+ if style.DotWidth == 0 {
+ style.DotWidth = defaultStyle.DotWidth
+ }
+ if style.FillColor.IsZero() {
+ style.FillColor = defaultStyle.FillColor
+ }
+ if style.FontSize == 0 {
+ style.FontSize = defaultStyle.FontSize
+ }
+ if style.FontColor.IsZero() {
+ style.FontColor = defaultStyle.FontColor
+ }
+ if style.Font == nil {
+ style.Font = defaultStyle.Font
+ }
+ return style
+}
+
+func (p *Painter) OverrideDrawingStyle(style Style) *Painter {
+ s := overrideStyle(p.style, style)
+ p.SetDrawingStyle(s)
+ return p
+}
+
+func (p *Painter) SetDrawingStyle(style Style) *Painter {
+ style.WriteDrawingOptionsToRenderer(p.render)
+ return p
+}
+
+func (p *Painter) SetTextStyle(style Style) *Painter {
+ if style.Font == nil {
+ style.Font = p.font
+ }
+ style.WriteTextOptionsToRenderer(p.render)
+ return p
+}
+func (p *Painter) OverrideTextStyle(style Style) *Painter {
+ s := overrideStyle(p.style, style)
+ p.SetTextStyle(s)
+ return p
+}
+
+func (p *Painter) ResetStyle() *Painter {
+ p.style.WriteToRenderer(p.render)
+ return p
+}
+
+// Bytes returns the data of draw canvas
+func (p *Painter) Bytes() ([]byte, error) {
+ buffer := bytes.Buffer{}
+ err := p.render.Save(&buffer)
+ if err != nil {
+ return nil, err
+ }
+ return buffer.Bytes(), err
+}
+
+// MoveTo moves the cursor to a given point
+func (p *Painter) MoveTo(x, y int) *Painter {
+ p.render.MoveTo(x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) *Painter {
+ p.render.ArcTo(cx+p.box.Left, cy+p.box.Top, rx, ry, startAngle, delta)
+ return p
+}
+
+func (p *Painter) LineTo(x, y int) *Painter {
+ p.render.LineTo(x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) QuadCurveTo(cx, cy, x, y int) *Painter {
+ p.render.QuadCurveTo(cx+p.box.Left, cy+p.box.Top, x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) Pin(x, y, width int) *Painter {
+ r := float64(width) / 2
+ y -= width / 4
+ angle := chart.DegreesToRadians(15)
+ box := p.box
+
+ startAngle := math.Pi/2 + angle
+ delta := 2*math.Pi - 2*angle
+ p.ArcTo(x, y, r, r, startAngle, delta)
+ p.LineTo(x, y)
+ p.Close()
+ p.FillStroke()
+
+ startX := x - int(r)
+ startY := y
+ endX := x + int(r)
+ endY := y
+ p.MoveTo(startX, startY)
+
+ left := box.Left
+ top := box.Top
+ cx := x
+ cy := y + int(r*2.5)
+ p.render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top)
+ p.Close()
+ p.Fill()
+ return p
+}
+
+func (p *Painter) arrow(x, y, width, height int, direction string) *Painter {
+ halfWidth := width >> 1
+ halfHeight := height >> 1
+ if direction == PositionTop || direction == PositionBottom {
+ x0 := x - halfWidth
+ x1 := x0 + width
+ dy := -height / 3
+ y0 := y
+ y1 := y0 - height
+ if direction == PositionBottom {
+ y0 = y - height
+ y1 = y
+ dy = 2 * dy
+ }
+ p.MoveTo(x0, y0)
+ p.LineTo(x0+halfWidth, y1)
+ p.LineTo(x1, y0)
+ p.LineTo(x0+halfWidth, y+dy)
+ p.LineTo(x0, y0)
+ } else {
+ x0 := x + width
+ x1 := x0 - width
+ y0 := y - halfHeight
+ dx := -width / 3
+ if direction == PositionRight {
+ x0 = x - width
+ dx = -dx
+ x1 = x0 + width
+ }
+ p.MoveTo(x0, y0)
+ p.LineTo(x1, y0+halfHeight)
+ p.LineTo(x0, y0+height)
+ p.LineTo(x0+dx, y0+halfHeight)
+ p.LineTo(x0, y0)
+ }
+ p.FillStroke()
+ return p
+}
+
+func (p *Painter) ArrowLeft(x, y, width, height int) *Painter {
+ p.arrow(x, y, width, height, PositionLeft)
+ return p
+}
+
+func (p *Painter) ArrowRight(x, y, width, height int) *Painter {
+ p.arrow(x, y, width, height, PositionRight)
+ return p
+}
+
+func (p *Painter) ArrowTop(x, y, width, height int) *Painter {
+ p.arrow(x, y, width, height, PositionTop)
+ return p
+}
+func (p *Painter) ArrowBottom(x, y, width, height int) *Painter {
+ p.arrow(x, y, width, height, PositionBottom)
+ return p
+}
+
+func (p *Painter) Circle(radius float64, x, y int) *Painter {
+ p.render.Circle(radius, x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) Stroke() *Painter {
+ p.render.Stroke()
+ return p
+}
+
+func (p *Painter) Close() *Painter {
+ p.render.Close()
+ return p
+}
+
+func (p *Painter) FillStroke() *Painter {
+ p.render.FillStroke()
+ return p
+}
+
+func (p *Painter) Fill() *Painter {
+ p.render.Fill()
+ return p
+}
+
+func (p *Painter) Width() int {
+ return p.box.Width()
+}
+
+func (p *Painter) Height() int {
+ return p.box.Height()
+}
+
+func (p *Painter) MeasureText(text string) Box {
+ return p.render.MeasureText(text)
+}
+
+func (p *Painter) MeasureTextMaxWidthHeight(textList []string) (int, int) {
+ maxWidth := 0
+ maxHeight := 0
+ for _, text := range textList {
+ box := p.MeasureText(text)
+ if maxWidth < box.Width() {
+ maxWidth = box.Width()
+ }
+ if maxHeight < box.Height() {
+ maxHeight = box.Height()
+ }
+ }
+ return maxWidth, maxHeight
+}
+
+func (p *Painter) LineStroke(points []Point) *Painter {
+ shouldMoveTo := false
+ for index, point := range points {
+ x := point.X
+ y := point.Y
+ if y == int(math.MaxInt32) {
+ p.Stroke()
+ shouldMoveTo = true
+ continue
+ }
+ if shouldMoveTo || index == 0 {
+ p.MoveTo(x, y)
+ shouldMoveTo = false
+ } else {
+ p.LineTo(x, y)
+ }
+ }
+ p.Stroke()
+ return p
+}
+
+func (p *Painter) SmoothLineStroke(points []Point) *Painter {
+ prevX := 0
+ prevY := 0
+ // TODO 如何生成平滑的折线
+ for index, point := range points {
+ x := point.X
+ y := point.Y
+ if index == 0 {
+ p.MoveTo(x, y)
+ } else {
+ cx := prevX + (x-prevX)/5
+ cy := y + (y-prevY)/2
+ p.QuadCurveTo(cx, cy, x, y)
+ }
+ prevX = x
+ prevY = y
+ }
+ p.Stroke()
+ return p
+}
+
+func (p *Painter) SetBackground(width, height int, color Color, inside ...bool) *Painter {
+ r := p.render
+ s := chart.Style{
+ FillColor: color,
+ }
+ // 背景色
+ p.SetDrawingStyle(s)
+ defer p.ResetStyle()
+ if len(inside) != 0 && inside[0] {
+ p.MoveTo(0, 0)
+ p.LineTo(width, 0)
+ p.LineTo(width, height)
+ p.LineTo(0, height)
+ p.LineTo(0, 0)
+ } else {
+ // 设置背景色不使用box,因此不直接使用Painter
+ r.MoveTo(0, 0)
+ r.LineTo(width, 0)
+ r.LineTo(width, height)
+ r.LineTo(0, height)
+ r.LineTo(0, 0)
+ }
+ p.FillStroke()
+ return p
+}
+func (p *Painter) MarkLine(x, y, width int) *Painter {
+ arrowWidth := 16
+ arrowHeight := 10
+ endX := x + width
+ radius := 3
+ p.Circle(3, x+radius, y)
+ p.render.Fill()
+ p.MoveTo(x+radius*3, y)
+ p.LineTo(endX-arrowWidth, y)
+ p.Stroke()
+ p.ArrowRight(endX, y, arrowWidth, arrowHeight)
+ return p
+}
+
+func (p *Painter) Polygon(center Point, radius float64, sides int) *Painter {
+ points := getPolygonPoints(center, radius, sides)
+ for i, item := range points {
+ if i == 0 {
+ p.MoveTo(item.X, item.Y)
+ } else {
+ p.LineTo(item.X, item.Y)
+ }
+ }
+ p.LineTo(points[0].X, points[0].Y)
+ p.Stroke()
+ return p
+}
+
+func (p *Painter) FillArea(points []Point) *Painter {
+ var x, y int
+ for index, point := range points {
+ x = point.X
+ y = point.Y
+ if index == 0 {
+ p.MoveTo(x, y)
+ } else {
+ p.LineTo(x, y)
+ }
+ }
+ p.Fill()
+ return p
+}
+
+func (p *Painter) Text(body string, x, y int) *Painter {
+ p.render.Text(body, x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) TextRotation(body string, x, y int, radians float64) {
+ p.render.SetTextRotation(radians)
+ p.render.Text(body, x+p.box.Left, y+p.box.Top)
+ p.render.ClearTextRotation()
+}
+
+func (p *Painter) SetTextRotation(radians float64) {
+ p.render.SetTextRotation(radians)
+}
+func (p *Painter) ClearTextRotation() {
+ p.render.ClearTextRotation()
+}
+
+func (p *Painter) TextFit(body string, x, y, width int, textAligns ...string) chart.Box {
+ style := p.style
+ textWarp := style.TextWrap
+ style.TextWrap = chart.TextWrapWord
+ r := p.render
+ lines := chart.Text.WrapFit(r, body, width, style)
+ p.SetTextStyle(style)
+ var output chart.Box
+
+ textAlign := ""
+ if len(textAligns) != 0 {
+ textAlign = textAligns[0]
+ }
+ for index, line := range lines {
+ if line == "" {
+ continue
+ }
+ x0 := x
+ y0 := y + output.Height()
+ lineBox := r.MeasureText(line)
+ switch textAlign {
+ case AlignRight:
+ x0 += width - lineBox.Width()
+ case AlignCenter:
+ x0 += (width - lineBox.Width()) >> 1
+ }
+ p.Text(line, x0, y0)
+ output.Right = chart.MaxInt(lineBox.Right, output.Right)
+ output.Bottom += lineBox.Height()
+ if index < len(lines)-1 {
+ output.Bottom += +style.GetTextLineSpacing()
+ }
+ }
+ p.style.TextWrap = textWarp
+ return output
+}
+
+func (p *Painter) Ticks(opt TicksOption) *Painter {
+ if opt.Count <= 0 || opt.Length <= 0 {
+ return p
+ }
+ count := opt.Count
+ first := opt.First
+ width := p.Width()
+ height := p.Height()
+ unit := 1
+ if opt.Unit > 1 {
+ unit = opt.Unit
+ }
+ var values []int
+ isVertical := opt.Orient == OrientVertical
+ if isVertical {
+ values = autoDivide(height, count)
+ } else {
+ values = autoDivide(width, count)
+ }
+ for index, value := range values {
+ if index < first {
+ continue
+ }
+ if (index-first)%unit != 0 {
+ continue
+ }
+ if isVertical {
+ p.LineStroke([]Point{
+ {
+ X: 0,
+ Y: value,
+ },
+ {
+ X: opt.Length,
+ Y: value,
+ },
+ })
+ } else {
+ p.LineStroke([]Point{
+ {
+ X: value,
+ Y: opt.Length,
+ },
+ {
+ X: value,
+ Y: 0,
+ },
+ })
+ }
+ }
+ return p
+}
+
+func (p *Painter) MultiText(opt MultiTextOption) *Painter {
+ if len(opt.TextList) == 0 {
+ return p
+ }
+ count := len(opt.TextList)
+ positionCenter := true
+ showIndex := opt.Unit / 2
+ if containsString([]string{
+ PositionLeft,
+ PositionTop,
+ }, opt.Position) {
+ positionCenter = false
+ count--
+ // 非居中
+ showIndex = 0
+ }
+ width := p.Width()
+ height := p.Height()
+ var values []int
+ isVertical := opt.Orient == OrientVertical
+ if isVertical {
+ values = autoDivide(height, count)
+ } else {
+ values = autoDivide(width, count)
+ }
+ isTextRotation := opt.TextRotation != 0
+ offset := opt.Offset
+ for index, text := range opt.TextList {
+ if index < opt.First {
+ continue
+ }
+ if opt.Unit != 0 && (index-opt.First)%opt.Unit != showIndex {
+ continue
+ }
+ if isTextRotation {
+ p.ClearTextRotation()
+ p.SetTextRotation(opt.TextRotation)
+ }
+ box := p.MeasureText(text)
+ start := values[index]
+ if positionCenter {
+ start = (values[index] + values[index+1]) >> 1
+ }
+ x := 0
+ y := 0
+ if isVertical {
+ y = start + box.Height()>>1
+ switch opt.Align {
+ case AlignRight:
+ x = width - box.Width()
+ case AlignCenter:
+ x = width - box.Width()>>1
+ default:
+ x = 0
+ }
+ } else {
+ x = start - box.Width()>>1
+ }
+ x += offset.Left
+ y += offset.Top
+ p.Text(text, x, y)
+ }
+ if isTextRotation {
+ p.ClearTextRotation()
+ }
+ return p
+}
+
+func (p *Painter) Grid(opt GridOption) *Painter {
+ width := p.Width()
+ height := p.Height()
+ drawLines := func(values []int, ignoreIndexList []int, isVertical bool) {
+ for index, v := range values {
+ if containsInt(ignoreIndexList, index) {
+ continue
+ }
+ x0 := 0
+ y0 := 0
+ x1 := 0
+ y1 := 0
+ if isVertical {
+
+ x0 = v
+ x1 = v
+ y1 = height
+ } else {
+ x1 = width
+ y0 = v
+ y1 = v
+ }
+ p.LineStroke([]Point{
+ {
+ X: x0,
+ Y: y0,
+ },
+ {
+ X: x1,
+ Y: y1,
+ },
+ })
+ }
+ }
+ columnCount := sumInt(opt.ColumnSpans)
+ if columnCount == 0 {
+ columnCount = opt.Column
+ }
+ if columnCount > 0 {
+ values := autoDivideSpans(width, columnCount, opt.ColumnSpans)
+ drawLines(values, opt.IgnoreColumnLines, true)
+ }
+ if opt.Row > 0 {
+ values := autoDivide(height, opt.Row)
+ drawLines(values, opt.IgnoreRowLines, false)
+ }
+ return p
+}
+
+func (p *Painter) Dots(points []Point) *Painter {
+ for _, item := range points {
+ p.Circle(2, item.X, item.Y)
+ }
+ p.FillStroke()
+ return p
+}
+
+func (p *Painter) Rect(box Box) *Painter {
+ p.MoveTo(box.Left, box.Top)
+ p.LineTo(box.Right, box.Top)
+ p.LineTo(box.Right, box.Bottom)
+ p.LineTo(box.Left, box.Bottom)
+ p.LineTo(box.Left, box.Top)
+ p.FillStroke()
+ return p
+}
+
+func (p *Painter) RoundedRect(box Box, radius int) *Painter {
+ r := (box.Right - box.Left) / 2
+ if radius > r {
+ radius = r
+ }
+ rx := float64(radius)
+ ry := float64(radius)
+ p.MoveTo(box.Left+radius, box.Top)
+ p.LineTo(box.Right-radius, box.Top)
+
+ cx := box.Right - radius
+ cy := box.Top + radius
+ // right top
+ p.ArcTo(cx, cy, rx, ry, -math.Pi/2, math.Pi/2)
+
+ p.LineTo(box.Right, box.Bottom-radius)
+
+ // right bottom
+ cx = box.Right - radius
+ cy = box.Bottom - radius
+ p.ArcTo(cx, cy, rx, ry, 0.0, math.Pi/2)
+
+ p.LineTo(box.Left+radius, box.Bottom)
+
+ // left bottom
+ cx = box.Left + radius
+ cy = box.Bottom - radius
+ p.ArcTo(cx, cy, rx, ry, math.Pi/2, math.Pi/2)
+
+ p.LineTo(box.Left, box.Top+radius)
+
+ // left top
+ cx = box.Left + radius
+ cy = box.Top + radius
+ p.ArcTo(cx, cy, rx, ry, math.Pi, math.Pi/2)
+
+ p.Close()
+ p.FillStroke()
+ p.Fill()
+ return p
+}
+
+func (p *Painter) LegendLineDot(box Box) *Painter {
+ width := box.Width()
+ height := box.Height()
+ strokeWidth := 3
+ dotHeight := 5
+
+ p.render.SetStrokeWidth(float64(strokeWidth))
+ center := (height-strokeWidth)>>1 - 1
+ p.MoveTo(box.Left, box.Top-center)
+ p.LineTo(box.Right, box.Top-center)
+ p.Stroke()
+ p.Circle(float64(dotHeight), box.Left+width>>1, box.Top-center)
+ p.FillStroke()
+ return p
+}
+
+func (p *Painter) GetRenderer() chart.Renderer {
+ return p.render
+}
diff --git a/draw_test.go b/painter_test.go
similarity index 63%
rename from draw_test.go
rename to painter_test.go
index f6a3dd1..07c4113 100644
--- a/draw_test.go
+++ b/painter_test.go
@@ -26,217 +26,85 @@ import (
"math"
"testing"
+ "github.com/golang/freetype/truetype"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
-func TestParentOption(t *testing.T) {
- assert := assert.New(t)
- p, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- assert.Nil(err)
-
- d, err := NewDraw(DrawOption{
- Parent: p,
- })
- assert.Nil(err)
- assert.Equal(p, d.parent)
-}
-
-func TestWidthHeightOption(t *testing.T) {
+func TestPainterOption(t *testing.T) {
assert := assert.New(t)
- // no parent
- width := 300
- height := 200
- d, err := NewDraw(DrawOption{
- Width: width,
- Height: height,
- })
- assert.Nil(err)
- assert.Equal(chart.Box{
- Top: 0,
- Left: 0,
- Right: width,
- Bottom: height,
- }, d.Box)
-
- width = 500
- height = 600
- // with parent
- p, err := NewDraw(
- DrawOption{
- Width: width,
- Height: height,
- },
- PaddingOption(chart.NewBox(5, 5, 5, 5)),
- )
- assert.Nil(err)
- d, err = NewDraw(
- DrawOption{
- Parent: p,
- },
- PaddingOption(chart.NewBox(1, 2, 3, 4)),
- )
- assert.Nil(err)
- assert.Equal(chart.Box{
- Top: 6,
- Left: 7,
- Right: 492,
- Bottom: 591,
- }, d.Box)
-}
-
-func TestBoxOption(t *testing.T) {
- assert := assert.New(t)
-
- d, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- assert.Nil(err)
-
- err = BoxOption(chart.Box{
- Left: 10,
- Top: 20,
- Right: 50,
- Bottom: 100,
- })(d)
- assert.Nil(err)
- assert.Equal(chart.Box{
- Left: 10,
- Top: 20,
- Right: 50,
- Bottom: 100,
- }, d.Box)
-
- // zero box will be ignored
- err = BoxOption(chart.Box{})(d)
- assert.Nil(err)
- assert.Equal(chart.Box{
- Left: 10,
- Top: 20,
- Right: 50,
- Bottom: 100,
- }, d.Box)
-}
-
-func TestPaddingOption(t *testing.T) {
- assert := assert.New(t)
-
- d, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- assert.Nil(err)
-
- // 默认的box
- assert.Equal(chart.Box{
- Right: 400,
- Bottom: 300,
- }, d.Box)
-
- // 设置padding之后的box
- d, err = NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- }, PaddingOption(chart.Box{
- Left: 1,
- Top: 2,
- Right: 3,
- Bottom: 4,
- }))
- assert.Nil(err)
- assert.Equal(chart.Box{
- Top: 2,
- Left: 1,
- Right: 397,
- Bottom: 296,
- }, d.Box)
-
- p := d
- // 设置父元素之后的box
- d, err = NewDraw(
- DrawOption{
- Parent: p,
- },
- PaddingOption(chart.Box{
+ font := &truetype.Font{}
+ d, err := NewPainter(PainterOptions{
+ Width: 800,
+ Height: 600,
+ Type: ChartOutputSVG,
+ },
+ PainterBoxOption(Box{
+ Right: 400,
+ Bottom: 300,
+ }),
+ PainterPaddingOption(Box{
Left: 1,
Top: 2,
Right: 3,
Bottom: 4,
}),
+ PainterFontOption(font),
+ PainterStyleOption(Style{
+ ClassName: "test",
+ }),
)
assert.Nil(err)
- assert.Equal(chart.Box{
- Top: 4,
- Left: 2,
- Right: 394,
- Bottom: 292,
- }, d.Box)
+ assert.Equal(Box{
+ Left: 1,
+ Top: 2,
+ Right: 397,
+ Bottom: 296,
+ }, d.box)
+ assert.Equal(font, d.font)
+ assert.Equal("test", d.style.ClassName)
}
-func TestParentTop(t *testing.T) {
- assert := assert.New(t)
- d1, err := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- assert.Nil(err)
-
- d2, err := NewDraw(DrawOption{
- Parent: d1,
- })
- assert.Nil(err)
-
- d3, err := NewDraw(DrawOption{
- Parent: d2,
- })
- assert.Nil(err)
-
- assert.Equal(d2, d3.Parent())
- assert.Equal(d1, d2.Parent())
- assert.Equal(d1, d3.Top())
- assert.Equal(d1, d2.Top())
-}
-
-func TestDraw(t *testing.T) {
+func TestPainter(t *testing.T) {
assert := assert.New(t)
tests := []struct {
- fn func(d *Draw)
+ fn func(*Painter)
result string
}{
// moveTo, lineTo
{
- fn: func(d *Draw) {
- d.moveTo(1, 1)
- d.lineTo(2, 2)
- d.Render.Stroke()
+ fn: func(p *Painter) {
+ p.MoveTo(1, 1)
+ p.LineTo(2, 2)
+ p.Stroke()
},
result: "\\n ",
},
// circle
{
- fn: func(d *Draw) {
- d.circle(5, 2, 3)
+ fn: func(p *Painter) {
+ p.Circle(5, 2, 3)
},
result: "\\n ",
},
// text
{
- fn: func(d *Draw) {
- d.text("hello world!", 3, 6)
+ fn: func(p *Painter) {
+ p.Text("hello world!", 3, 6)
},
- result: "\\nhello world! ",
+ result: "\\nhello world! ",
},
// line stroke
{
- fn: func(d *Draw) {
- d.lineStroke([]Point{
+ fn: func(p *Painter) {
+ p.SetDrawingStyle(Style{
+ StrokeColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ })
+ p.LineStroke([]Point{
{
X: 1,
Y: 2,
@@ -245,156 +113,153 @@ func TestDraw(t *testing.T) {
X: 3,
Y: 4,
},
- }, LineStyle{
- StrokeColor: drawing.ColorBlack,
- StrokeWidth: 1,
})
},
result: "\\n ",
},
// set background
{
- fn: func(d *Draw) {
- d.setBackground(400, 300, chart.ColorWhite)
+ fn: func(p *Painter) {
+ p.SetBackground(400, 300, chart.ColorWhite)
},
result: "\\n ",
},
// arcTo
{
- fn: func(d *Draw) {
- chart.Style{
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
StrokeWidth: 1,
StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlue,
- }.WriteToRenderer(d.Render)
- d.arcTo(100, 100, 100, 100, 0, math.Pi/2)
- d.Render.Close()
- d.Render.FillStroke()
+ })
+ p.ArcTo(100, 100, 100, 100, 0, math.Pi/2)
+ p.Close()
+ p.FillStroke()
},
result: "\\n ",
},
// pin
{
- fn: func(d *Draw) {
- chart.Style{
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
StrokeWidth: 1,
- StrokeColor: drawing.Color{
+ StrokeColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- FillColor: drawing.Color{
+ FillColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- }.WriteToRenderer(d.Render)
- d.pin(30, 30, 30)
+ })
+ p.Pin(30, 30, 30)
},
result: "\\n ",
},
// arrow left
{
- fn: func(d *Draw) {
- chart.Style{
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
StrokeWidth: 1,
- StrokeColor: drawing.Color{
+ StrokeColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- FillColor: drawing.Color{
+ FillColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- }.WriteToRenderer(d.Render)
- d.arrowLeft(30, 30, 16, 10)
+ })
+ p.ArrowLeft(30, 30, 16, 10)
},
result: "\\n ",
},
// arrow right
{
- fn: func(d *Draw) {
- chart.Style{
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
StrokeWidth: 1,
- StrokeColor: drawing.Color{
+ StrokeColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- FillColor: drawing.Color{
+ FillColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- }.WriteToRenderer(d.Render)
- d.arrowRight(30, 30, 16, 10)
+ })
+ p.ArrowRight(30, 30, 16, 10)
},
result: "\\n ",
},
// arrow top
{
- fn: func(d *Draw) {
- chart.Style{
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
StrokeWidth: 1,
- StrokeColor: drawing.Color{
+ StrokeColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- FillColor: drawing.Color{
+ FillColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- }.WriteToRenderer(d.Render)
- d.arrowTop(30, 30, 10, 16)
+ })
+ p.ArrowTop(30, 30, 10, 16)
},
result: "\\n ",
},
// arrow bottom
{
- fn: func(d *Draw) {
- chart.Style{
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
StrokeWidth: 1,
- StrokeColor: drawing.Color{
+ StrokeColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- FillColor: drawing.Color{
+ FillColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- }.WriteToRenderer(d.Render)
- d.arrowBottom(30, 30, 10, 16)
+ })
+ p.ArrowBottom(30, 30, 10, 16)
},
result: "\\n ",
},
// mark line
{
- fn: func(d *Draw) {
- chart.Style{
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
StrokeWidth: 1,
- StrokeColor: drawing.Color{
+ StrokeColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- FillColor: drawing.Color{
+ FillColor: Color{
R: 84,
G: 112,
B: 198,
@@ -404,34 +269,42 @@ func TestDraw(t *testing.T) {
4,
2,
},
- }.WriteToRenderer(d.Render)
- d.makeLine(0, 20, 300)
+ })
+ p.MarkLine(0, 20, 300)
},
- result: "\\n ",
+ result: "\\n ",
},
// polygon
{
- fn: func(d *Draw) {
- chart.Style{
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
StrokeWidth: 1,
- StrokeColor: drawing.Color{
+ StrokeColor: Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
- }.WriteToRenderer(d.Render)
- d.polygon(Point{
+ })
+ p.Polygon(Point{
X: 100,
Y: 100,
}, 50, 6)
},
result: "\\n ",
},
- // fill
+ // FillArea
{
- fn: func(d *Draw) {
- d.fill([]Point{
+ fn: func(p *Painter) {
+ p.SetDrawingStyle(Style{
+ FillColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ })
+ p.FillArea([]Point{
{
X: 0,
Y: 0,
@@ -448,23 +321,17 @@ func TestDraw(t *testing.T) {
X: 0,
Y: 0,
},
- }, chart.Style{
- FillColor: drawing.Color{
- R: 84,
- G: 112,
- B: 198,
- A: 255,
- },
})
},
result: "\\n ",
},
}
for _, tt := range tests {
- d, err := NewDraw(DrawOption{
+ d, err := NewPainter(PainterOptions{
Width: 400,
Height: 300,
- }, PaddingOption(chart.Box{
+ Type: ChartOutputSVG,
+ }, PainterPaddingOption(chart.Box{
Left: 5,
Top: 10,
}))
@@ -476,32 +343,57 @@ func TestDraw(t *testing.T) {
}
}
-func TestDrawTextFit(t *testing.T) {
+func TestRoundedRect(t *testing.T) {
assert := assert.New(t)
- d, err := NewDraw(DrawOption{
+ p, err := NewPainter(PainterOptions{
Width: 400,
Height: 300,
+ Type: ChartOutputSVG,
})
assert.Nil(err)
- f, _ := chart.GetDefaultFont()
- style := chart.Style{
+ p.OverrideDrawingStyle(Style{
+ FillColor: drawing.ColorWhite,
+ StrokeWidth: 1,
+ StrokeColor: drawing.ColorWhite,
+ }).RoundedRect(Box{
+ Left: 10,
+ Right: 30,
+ Bottom: 150,
+ Top: 10,
+ }, 5)
+ buf, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("\\n ", string(buf))
+}
+
+func TestPainterTextFit(t *testing.T) {
+ assert := assert.New(t)
+ p, err := NewPainter(PainterOptions{
+ Width: 400,
+ Height: 300,
+ Type: ChartOutputSVG,
+ })
+ assert.Nil(err)
+ f, _ := GetDefaultFont()
+ style := Style{
FontSize: 12,
FontColor: chart.ColorBlack,
Font: f,
}
- box := d.textFit("Hello World!", 0, 20, 80, style)
+ p.SetStyle(style)
+ box := p.TextFit("Hello World!", 0, 20, 80)
assert.Equal(chart.Box{
Right: 45,
Bottom: 35,
}, box)
- box = d.textFit("Hello World!", 0, 100, 200, style)
+ box = p.TextFit("Hello World!", 0, 100, 200)
assert.Equal(chart.Box{
Right: 84,
Bottom: 15,
}, box)
- buf, err := d.Bytes()
+ buf, err := p.Bytes()
assert.Nil(err)
assert.Equal(`\nHello World! Hello World! `, string(buf))
}
diff --git a/pie_chart.go b/pie_chart.go
index 15c0d35..5c04ed8 100644
--- a/pie_chart.go
+++ b/pie_chart.go
@@ -27,38 +27,138 @@ import (
"math"
"github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2"
)
-func getPieStyle(theme *Theme, index int) chart.Style {
- seriesColor := theme.GetSeriesColor(index)
- return chart.Style{
- StrokeColor: seriesColor,
- StrokeWidth: 1,
- FillColor: seriesColor,
- }
+type pieChart struct {
+ p *Painter
+ opt *PieChartOption
}
-type pieChartOption struct {
- Theme string
- Font *truetype.Font
+type PieChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
SeriesList SeriesList
+ // The padding of line chart
+ Padding Box
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ // background is filled
+ backgroundIsFilled bool
}
-func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
- d, err := NewDraw(DrawOption{
- Parent: result.d,
- }, PaddingOption(chart.Box{
- Top: result.titleBox.Height(),
- }))
- if err != nil {
- return err
+// NewPieChart returns a pie chart renderer
+func NewPieChart(p *Painter, opt PieChartOption) *pieChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
}
+ return &pieChart{
+ p: p,
+ opt: &opt,
+ }
+}
- values := make([]float64, len(opt.SeriesList))
+type sector struct {
+ value float64
+ percent float64
+ cx int
+ cy int
+ rx float64
+ ry float64
+ start float64
+ delta float64
+ offset int
+ quadrant int
+ lineStartX int
+ lineStartY int
+ lineBranchX int
+ lineBranchY int
+ lineEndX int
+ lineEndY int
+ showLabel bool
+ label string
+ series Series
+ color Color
+}
+
+func NewSector(cx int, cy int, radius float64, labelRadius float64, value float64, currentValue float64, totalValue float64, labelLineLength int, label string, series Series, color Color) sector {
+ s := sector{}
+ s.value = value
+ s.percent = value / totalValue
+ s.cx = cx
+ s.cy = cy
+ s.rx = radius
+ s.ry = radius
+ p := (currentValue + value/2) / totalValue
+ if p < 0.25 {
+ s.quadrant = 1
+ } else if p < 0.5 {
+ s.quadrant = 4
+ } else if p < 0.75 {
+ s.quadrant = 3
+ } else {
+ s.quadrant = 2
+ }
+ s.start = chart.PercentToRadians(currentValue/totalValue) - math.Pi/2
+ s.delta = chart.PercentToRadians(value / totalValue)
+ angle := s.start + s.delta/2
+ s.lineStartX = cx + int(radius*math.Cos(angle))
+ s.lineStartY = cy + int(radius*math.Sin(angle))
+ s.lineBranchX = cx + int(labelRadius*math.Cos(angle))
+ s.lineBranchY = cy + int(labelRadius*math.Sin(angle))
+ s.offset = labelLineLength
+ if s.lineBranchX <= cx {
+ s.offset *= -1
+ }
+ s.lineEndX = s.lineBranchX + s.offset
+ s.lineEndY = s.lineBranchY
+ s.series = series
+ s.color = color
+ s.showLabel = series.Label.Show
+ s.label = NewPieLabelFormatter([]string{label}, series.Label.Formatter)(0, s.value, s.percent)
+ return s
+}
+
+func (s *sector) calculateY(prevY int) int {
+ for i := 0; i <= s.cy; i++ {
+ if s.quadrant <= 2 {
+ if (prevY - s.lineBranchY) > labelFontSize+5 {
+ break
+ }
+ s.lineBranchY -= 1
+ } else {
+ if (s.lineBranchY - prevY) > labelFontSize+5 {
+ break
+ }
+ s.lineBranchY += 1
+ }
+ }
+ s.lineEndY = s.lineBranchY
+ return s.lineBranchY
+}
+
+func (s *sector) calculateTextXY(textBox Box) (x int, y int) {
+ textMargin := 3
+ x = s.lineEndX + textMargin
+ y = s.lineEndY + textBox.Height()>>1 - 1
+ if s.offset < 0 {
+ textWidth := textBox.Width()
+ x = s.lineEndX - textWidth - textMargin
+ }
+ return
+}
+
+func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ opt := p.opt
+ values := make([]float64, len(seriesList))
total := float64(0)
radiusValue := ""
- for index, series := range opt.SeriesList {
+ for index, series := range seriesList {
if len(series.Radius) != 0 {
radiusValue = series.Radius
}
@@ -70,16 +170,13 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
total += value
}
if total <= 0 {
- return errors.New("The sum value of pie chart should gt 0")
+ return BoxZero, errors.New("The sum value of pie chart should gt 0")
}
- r := d.Render
- theme := NewTheme(opt.Theme)
+ seriesPainter := result.seriesPainter
+ cx := seriesPainter.Width() >> 1
+ cy := seriesPainter.Height() >> 1
- box := d.Box
- cx := box.Width() >> 1
- cy := box.Height() >> 1
-
- diameter := chart.MinInt(box.Width(), box.Height())
+ diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
radius := getRadius(float64(diameter), radiusValue)
labelLineWidth := 15
@@ -87,83 +184,135 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
labelLineWidth = 10
}
labelRadius := radius + float64(labelLineWidth)
-
- seriesNames := opt.SeriesList.Names()
-
- if len(values) == 1 {
- getPieStyle(theme, 0).WriteToRenderer(r)
- d.moveTo(cx, cy)
- d.circle(radius, cx, cy)
- } else {
- currentValue := float64(0)
- prevEndX := 0
- prevEndY := 0
- for index, v := range values {
-
- pieStyle := getPieStyle(theme, index)
- pieStyle.WriteToRenderer(r)
- d.moveTo(cx, cy)
- start := chart.PercentToRadians(currentValue/total) - math.Pi/2
- currentValue += v
- percent := (v / total)
- delta := chart.PercentToRadians(percent)
- d.arcTo(cx, cy, radius, radius, start, delta)
- d.lineTo(cx, cy)
- r.Close()
- r.FillStroke()
-
- series := opt.SeriesList[index]
- // 是否显示label
- showLabel := series.Label.Show
- if !showLabel {
- continue
- }
-
- // label的角度为饼块中间
- angle := start + delta/2
- startx := cx + int(radius*math.Cos(angle))
- starty := cy + int(radius*math.Sin(angle))
-
- endx := cx + int(labelRadius*math.Cos(angle))
- endy := cy + int(labelRadius*math.Sin(angle))
- // 计算是否有重叠,如果有则调整y坐标位置
- if index != 0 &&
- math.Abs(float64(endx-prevEndX)) < labelFontSize &&
- math.Abs(float64(endy-prevEndY)) < labelFontSize {
- endy -= (labelFontSize << 1)
- }
- prevEndX = endx
- prevEndY = endy
- d.moveTo(startx, starty)
- d.lineTo(endx, endy)
- offset := labelLineWidth
- if endx < cx {
- offset *= -1
- }
- d.moveTo(endx, endy)
- endx += offset
- d.lineTo(endx, endy)
- r.Stroke()
- textStyle := chart.Style{
- FontColor: theme.GetTextColor(),
- FontSize: labelFontSize,
- Font: opt.Font,
- }
- if !series.Label.Color.IsZero() {
- textStyle.FontColor = series.Label.Color
- }
- textStyle.GetTextOptions().WriteToRenderer(r)
- text := NewPieLabelFormatter(seriesNames, series.Label.Formatter)(index, v, percent)
- textBox := r.MeasureText(text)
- textMargin := 3
- x := endx + textMargin
- y := endy + textBox.Height()>>1 - 1
- if offset < 0 {
- textWidth := textBox.Width()
- x = endx - textWidth - textMargin
- }
- d.text(text, x, y)
- }
+ seriesNames := opt.Legend.Data
+ if len(seriesNames) == 0 {
+ seriesNames = seriesList.Names()
}
- return nil
+ theme := opt.Theme
+
+ currentValue := float64(0)
+
+ var quadrant1, quadrant2, quadrant3, quadrant4 []sector
+ for index, v := range values {
+ series := seriesList[index]
+ color := theme.GetSeriesColor(index)
+ if index == len(values)-1 {
+ if color == theme.GetSeriesColor(0) {
+ color = theme.GetSeriesColor(1)
+ }
+ }
+ s := NewSector(cx, cy, radius, labelRadius, v, currentValue, total, labelLineWidth, seriesNames[index], series, color)
+ switch quadrant := s.quadrant; quadrant {
+ case 1:
+ quadrant1 = append([]sector{s}, quadrant1...)
+ case 2:
+ quadrant2 = append(quadrant2, s)
+ case 3:
+ quadrant3 = append([]sector{s}, quadrant3...)
+ case 4:
+ quadrant4 = append(quadrant4, s)
+ }
+ currentValue += v
+ }
+ sectors := append(quadrant1, quadrant4...)
+ sectors = append(sectors, quadrant3...)
+ sectors = append(sectors, quadrant2...)
+
+ currentQuadrant := 0
+ prevY := 0
+ maxY := 0
+ minY := 0
+ for _, s := range sectors {
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: s.color,
+ FillColor: s.color,
+ })
+ seriesPainter.MoveTo(s.cx, s.cy)
+ seriesPainter.ArcTo(s.cx, s.cy, s.rx, s.ry, s.start, s.delta).LineTo(s.cx, s.cy).Close().FillStroke()
+ if !s.showLabel {
+ continue
+ }
+ if currentQuadrant != s.quadrant {
+ if s.quadrant == 1 {
+ minY = cy * 2
+ maxY = 0
+ prevY = cy * 2
+ }
+ if s.quadrant == 2 {
+ if currentQuadrant != 3 {
+ prevY = s.lineEndY
+ } else {
+ prevY = minY
+ }
+ }
+ if s.quadrant == 3 {
+ if currentQuadrant != 4 {
+ prevY = s.lineEndY
+ } else {
+ minY = cy * 2
+ maxY = 0
+ prevY = 0
+ }
+ }
+ if s.quadrant == 4 {
+ if currentQuadrant != 1 {
+ prevY = s.lineEndY
+ } else {
+ prevY = maxY
+ }
+ }
+ currentQuadrant = s.quadrant
+ }
+ prevY = s.calculateY(prevY)
+ if prevY > maxY {
+ maxY = prevY
+ }
+ if prevY < minY {
+ minY = prevY
+ }
+ seriesPainter.MoveTo(s.lineStartX, s.lineStartY)
+ seriesPainter.LineTo(s.lineBranchX, s.lineBranchY)
+ seriesPainter.MoveTo(s.lineBranchX, s.lineBranchY)
+ seriesPainter.LineTo(s.lineEndX, s.lineEndY)
+ seriesPainter.Stroke()
+ textStyle := Style{
+ FontColor: theme.GetTextColor(),
+ FontSize: labelFontSize,
+ Font: opt.Font,
+ }
+ if !s.series.Label.Color.IsZero() {
+ textStyle.FontColor = s.series.Label.Color
+ }
+ seriesPainter.OverrideTextStyle(textStyle)
+ x, y := s.calculateTextXY(seriesPainter.MeasureText(s.label))
+ seriesPainter.Text(s.label, x, y)
+ }
+ return p.p.box, nil
+}
+
+func (p *pieChart) Render() (Box, error) {
+ opt := p.opt
+
+ renderResult, err := defaultRender(p.p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: XAxisOption{
+ Show: FalseFlag(),
+ },
+ YAxisOptions: []YAxisOption{
+ {
+ Show: FalseFlag(),
+ },
+ },
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ backgroundIsFilled: opt.backgroundIsFilled,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypePie)
+ return p.render(renderResult, seriesList)
}
diff --git a/pie_chart_test.go b/pie_chart_test.go
index 84072be..3795d32 100644
--- a/pie_chart_test.go
+++ b/pie_chart_test.go
@@ -23,47 +23,511 @@
package charts
import (
+ "strconv"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
)
-func TestPieChartRender(t *testing.T) {
+func TestPieChart(t *testing.T) {
assert := assert.New(t)
- d, err := NewDraw(DrawOption{
- Width: 250,
- Height: 150,
- })
- assert.Nil(err)
-
- f, _ := chart.GetDefaultFont()
-
- err = pieChartRender(pieChartOption{
- Font: f,
- SeriesList: NewPieSeriesList([]float64{
- 5,
- 10,
- 0,
- }, PieSeriesOption{
- Names: []string{
- "a",
- "b",
- "c",
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := []float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }
+ _, err := NewPieChart(p, PieChartOption{
+ SeriesList: NewPieSeriesList(values, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ },
+ }),
+ Title: TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ Left: PositionCenter,
+ },
+ Padding: Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: LegendOption{
+ Orient: OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: PositionLeft,
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
- Label: SeriesLabel{
- Show: true,
- Color: drawing.ColorRed,
- },
- Radius: "20%",
- }),
- }, &basicRenderResult{
- d: d,
- })
- assert.Nil(err)
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal("\\na: 33.33% b: 66.66% c: 0% ", string(data))
+ result: "\\nSearch Engine Direct Email Union Ads Video Ads Rainfall vs Evaporation Fake Data Search Engine: 33.3% Direct: 23.35% Email: 18.43% Union Ads: 15.37% Video Ads: 9.53% ",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
+
+func TestPieChartWithLabelsValuesSortedDescending(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := []float64{
+ 84358845,
+ 68070697,
+ 58850717,
+ 48059777,
+ 36753736,
+ 19051562,
+ 17947406,
+ 11754004,
+ 10827529,
+ 10521556,
+ 10467366,
+ 10394055,
+ 9597085,
+ 9104772,
+ 6447710,
+ 5932654,
+ 5563970,
+ 5428792,
+ 5194336,
+ 3850894,
+ 2857279,
+ 2116792,
+ 1883008,
+ 1373101,
+ 920701,
+ 660809,
+ 542051,
+ }
+ _, err := NewPieChart(p, PieChartOption{
+ SeriesList: NewPieSeriesList(values, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ Formatter: "{b} ({c} ≅ {d})",
+ },
+ Radius: "200",
+ }),
+ Title: TitleOption{
+ Text: "European Union member states by population",
+ Left: PositionRight,
+ },
+ Padding: Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: LegendOption{
+ Data: []string{
+ "Germany",
+ "France",
+ "Italy",
+ "Spain",
+ "Poland",
+ "Romania",
+ "Netherlands",
+ "Belgium",
+ "Czech Republic",
+ "Sweden",
+ "Portugal",
+ "Greece",
+ "Hungary",
+ "Austria",
+ "Bulgaria",
+ "Denmark",
+ "Finland",
+ "Slovakia",
+ "Ireland",
+ "Croatia",
+ "Lithuania",
+ "Slovenia",
+ "Latvia",
+ "Estonia",
+ "Cyprus",
+ "Luxembourg",
+ "Malta",
+ },
+ Show: FalseFlag(),
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\nEuropean Union member states by population Germany (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 population France (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 labels Label 25: 1% Label 24: 1% Label 23: 1% Label 22: 1% Label 21: 1% Label 20: 1% Label 19: 1% Label 18: 1% Label 17: 1% Label 16: 1% Label 15: 1% Label 14: 1% Label 13: 1% Label 12: 1% Label 11: 1% Label 10: 1% Label 9: 1% Label 8: 1% Label 7: 1% Label 6: 1% Label 5: 1% Label 4: 1% Label 3: 1% Label 2: 1% Label 1: 1% Label 26: 1% Label 27: 1% Label 28: 1% Label 29: 1% Label 30: 1% Label 31: 1% Label 32: 1% Label 33: 1% Label 34: 1% Label 35: 1% Label 36: 1% Label 37: 1% Label 38: 1% Label 39: 1% Label 40: 1% Label 41: 1% Label 42: 1% Label 43: 1% Label 44: 1% Label 45: 1% Label 46: 1% Label 47: 1% Label 48: 1% Label 49: 1% Label 50: 1% Label 75: 1% Label 74: 1% Label 73: 1% Label 72: 1% Label 71: 1% Label 70: 1% Label 69: 1% Label 68: 1% Label 67: 1% Label 66: 1% Label 65: 1% Label 64: 1% Label 63: 1% Label 62: 1% Label 61: 1% Label 60: 1% Label 59: 1% Label 58: 1% Label 57: 1% Label 56: 1% Label 55: 1% Label 54: 1% Label 53: 1% Label 52: 1% Label 51: 1% Label 76: 1% Label 77: 1% Label 78: 1% Label 79: 1% Label 80: 1% Label 81: 1% Label 82: 1% Label 83: 1% Label 84: 1% Label 85: 1% Label 86: 1% Label 87: 1% Label 88: 1% Label 89: 1% Label 90: 1% Label 91: 1% Label 92: 1% Label 93: 1% Label 94: 1% Label 95: 1% Label 96: 1% Label 97: 1% Label 98: 1% Label 99: 1% Label 100: 1% ",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 1000,
+ Height: 900,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
+
+func TestPieChartFixLabelPos72586(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := []float64{
+ 397594,
+ 185596,
+ 149086,
+ 144258,
+ 120194,
+ 117514,
+ 99412,
+ 91135,
+ 87282,
+ 76790,
+ 72586,
+ 58818,
+ 58270,
+ 56306,
+ 55486,
+ 54792,
+ 53746,
+ 51460,
+ 41242,
+ 39476,
+ 37414,
+ 36644,
+ 33784,
+ 32788,
+ 32566,
+ 29608,
+ 29558,
+ 29384,
+ 28166,
+ 26998,
+ 26948,
+ 26054,
+ 25804,
+ 25730,
+ 24438,
+ 23782,
+ 22896,
+ 21404,
+ 428978,
+ }
+ _, err := NewPieChart(p, PieChartOption{
+ SeriesList: NewPieSeriesList(values, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ Formatter: "{b} ({c} ≅ {d})",
+ },
+ Radius: "150",
+ }),
+ Title: TitleOption{
+ Text: "Fix label K (72586)",
+ Left: PositionRight,
+ },
+ Padding: Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: LegendOption{
+ Data: []string{
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G",
+ "H",
+ "I",
+ "J",
+ "K",
+ "L",
+ "M",
+ "N",
+ "O",
+ "P",
+ "Q",
+ "R",
+ "S",
+ "T",
+ "U",
+ "V",
+ "W",
+ "X",
+ "Y",
+ "Z",
+ "AA",
+ "AB",
+ "AC",
+ "AD",
+ "AE",
+ "AF",
+ "AG",
+ "AH",
+ "AI",
+ "AJ",
+ "AK",
+ "AL",
+ "AM",
+ },
+ Show: FalseFlag(),
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\nFix label K (72586) C (149086 ≅ 5.04%) B (185596 ≅ 6.28%) A (397594 ≅ 13.45%) D (144258 ≅ 4.88%) E (120194 ≅ 4.06%) F (117514 ≅ 3.97%) G (99412 ≅ 3.36%) H (91135 ≅ 3.08%) I (87282 ≅ 2.95%) J (76790 ≅ 2.59%) Z (29608 ≅ 1%) Y (32566 ≅ 1.1%) X (32788 ≅ 1.1%) W (33784 ≅ 1.14%) V (36644 ≅ 1.24%) U (37414 ≅ 1.26%) T (39476 ≅ 1.33%) S (41242 ≅ 1.39%) R (51460 ≅ 1.74%) Q (53746 ≅ 1.81%) P (54792 ≅ 1.85%) O (55486 ≅ 1.87%) N (56306 ≅ 1.9%) M (58270 ≅ 1.97%) L (58818 ≅ 1.99%) K (72586 ≅ 2.45%) AA (29558 ≅ 1%) AB (29384 ≅ 0.99%) AC (28166 ≅ 0.95%) AD (26998 ≅ 0.91%) AE (26948 ≅ 0.91%) AF (26054 ≅ 0.88%) AG (25804 ≅ 0.87%) AH (25730 ≅ 0.87%) AI (24438 ≅ 0.82%) AJ (23782 ≅ 0.8%) AK (22896 ≅ 0.77%) AL (21404 ≅ 0.72%) AM (428978 ≅ 14.52%) ",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 1150,
+ Height: 550,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
}
diff --git a/radar_chart.go b/radar_chart.go
index 364213d..cf18135 100644
--- a/radar_chart.go
+++ b/radar_chart.go
@@ -25,11 +25,17 @@ package charts
import (
"errors"
+ "github.com/dustin/go-humanize"
"github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
+type radarChart struct {
+ p *Painter
+ opt *RadarChartOption
+}
+
type RadarIndicator struct {
// Indicator's name
Name string
@@ -39,89 +45,118 @@ type RadarIndicator struct {
Min float64
}
-type radarChartOption struct {
- Theme string
- Font *truetype.Font
+type RadarChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
SeriesList SeriesList
- Indicators []RadarIndicator
+ // The padding of line chart
+ Padding Box
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ // The radar indicator list
+ RadarIndicators []RadarIndicator
+ // background is filled
+ backgroundIsFilled bool
}
-func radarChartRender(opt radarChartOption, result *basicRenderResult) error {
- sides := len(opt.Indicators)
- if sides < 3 {
- return errors.New("The count of indicator should be >= 3")
+// NewRadarIndicators returns a radar indicator list
+func NewRadarIndicators(names []string, values []float64) []RadarIndicator {
+ if len(names) != len(values) {
+ return nil
}
- maxValues := make([]float64, len(opt.Indicators))
- for _, series := range opt.SeriesList {
+ indicators := make([]RadarIndicator, len(names))
+ for index, name := range names {
+ indicators[index] = RadarIndicator{
+ Name: name,
+ Max: values[index],
+ }
+ }
+ return indicators
+}
+
+// NewRadarChart returns a radar chart renderer
+func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &radarChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ opt := r.opt
+ indicators := opt.RadarIndicators
+ sides := len(indicators)
+ if sides < 3 {
+ return BoxZero, errors.New("The count of indicator should be >= 3")
+ }
+ maxValues := make([]float64, len(indicators))
+ for _, series := range seriesList {
for index, item := range series.Data {
if index < len(maxValues) && item.Value > maxValues[index] {
maxValues[index] = item.Value
}
}
}
- for index, indicator := range opt.Indicators {
+ for index, indicator := range indicators {
if indicator.Max <= 0 {
- opt.Indicators[index].Max = maxValues[index]
+ indicators[index].Max = maxValues[index]
}
}
- d, err := NewDraw(DrawOption{
- Parent: result.d,
- }, PaddingOption(chart.Box{
- Top: result.titleBox.Height(),
- }))
- if err != nil {
- return err
- }
+
radiusValue := ""
- for _, series := range opt.SeriesList {
+ for _, series := range seriesList {
if len(series.Radius) != 0 {
radiusValue = series.Radius
}
}
- box := d.Box
- cx := box.Width() >> 1
- cy := box.Height() >> 1
- diameter := chart.MinInt(box.Width(), box.Height())
- radius := getRadius(float64(diameter), radiusValue)
+ seriesPainter := result.seriesPainter
+ theme := opt.Theme
- theme := NewTheme(opt.Theme)
+ cx := seriesPainter.Width() >> 1
+ cy := seriesPainter.Height() >> 1
+ diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
+ radius := getRadius(float64(diameter), radiusValue)
divideCount := 5
divideRadius := float64(int(radius / float64(divideCount)))
radius = divideRadius * float64(divideCount)
- style := chart.Style{
+ seriesPainter.OverrideDrawingStyle(Style{
StrokeColor: theme.GetAxisSplitLineColor(),
StrokeWidth: 1,
- }
- r := d.Render
- style.WriteToRenderer(r)
+ })
center := Point{
X: cx,
Y: cy,
}
for i := 0; i < divideCount; i++ {
- d.polygon(center, divideRadius*float64(i+1), sides)
+ seriesPainter.Polygon(center, divideRadius*float64(i+1), sides)
}
points := getPolygonPoints(center, radius, sides)
for _, p := range points {
- d.moveTo(center.X, center.Y)
- d.lineTo(p.X, p.Y)
- d.Render.Stroke()
+ seriesPainter.MoveTo(center.X, center.Y)
+ seriesPainter.LineTo(p.X, p.Y)
+ seriesPainter.Stroke()
}
- // 文本
- textStyle := chart.Style{
+ seriesPainter.OverrideTextStyle(Style{
FontColor: theme.GetTextColor(),
FontSize: labelFontSize,
Font: opt.Font,
- }
- textStyle.GetTextOptions().WriteToRenderer(r)
+ })
offset := 5
// 文本生成
for index, p := range points {
- name := opt.Indicators[index].Name
- b := r.MeasureText(name)
+ name := indicators[index].Name
+ b := seriesPainter.MeasureText(name)
isXCenter := p.X == center.X
isYCenter := p.Y == center.Y
isRight := p.X > center.X
@@ -153,20 +188,24 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error {
if isLeft {
x -= (b.Width() + offset)
}
- d.text(name, x, y)
+ seriesPainter.Text(name, x, y)
}
// 雷达图
angles := getPolygonPointAngles(sides)
- maxCount := len(opt.Indicators)
- for _, series := range opt.SeriesList {
+ maxCount := len(indicators)
+ for _, series := range seriesList {
linePoints := make([]Point, 0, maxCount)
for j, item := range series.Data {
if j >= maxCount {
continue
}
- indicator := opt.Indicators[j]
- percent := (item.Value - indicator.Min) / (indicator.Max - indicator.Min)
+ indicator := indicators[j]
+ var percent float64
+ offset := indicator.Max - indicator.Min
+ if offset > 0 {
+ percent = (item.Value - indicator.Min) / offset
+ }
r := percent * radius
p := getPolygonPoint(center, r, angles[j])
linePoints = append(linePoints, p)
@@ -177,17 +216,58 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error {
dotFillColor = color
}
linePoints = append(linePoints, linePoints[0])
- s := LineStyle{
- StrokeColor: color,
- StrokeWidth: defaultStrokeWidth,
- DotWidth: defaultDotWidth,
- DotColor: color,
- DotFillColor: dotFillColor,
- FillColor: color.WithAlpha(20),
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeColor: color,
+ StrokeWidth: defaultStrokeWidth,
+ DotWidth: defaultDotWidth,
+ DotColor: color,
+ FillColor: color.WithAlpha(20),
+ })
+ seriesPainter.LineStroke(linePoints).
+ FillArea(linePoints)
+ dotWith := 2.0
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeWidth: defaultStrokeWidth,
+ StrokeColor: color,
+ FillColor: dotFillColor,
+ })
+ for index, point := range linePoints {
+ seriesPainter.Circle(dotWith, point.X, point.Y)
+ seriesPainter.FillStroke()
+ if series.Label.Show && index < len(series.Data) {
+ value := humanize.FtoaWithDigits(series.Data[index].Value, 2)
+ b := seriesPainter.MeasureText(value)
+ seriesPainter.Text(value, point.X-b.Width()/2, point.Y)
+ }
+
}
- d.lineStroke(linePoints, s)
- d.fill(linePoints, s.Style())
- d.lineDot(linePoints[0:len(linePoints)-1], s)
}
- return nil
+
+ return r.p.box, nil
+}
+
+func (r *radarChart) Render() (Box, error) {
+ p := r.p
+ opt := r.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: XAxisOption{
+ Show: FalseFlag(),
+ },
+ YAxisOptions: []YAxisOption{
+ {
+ Show: FalseFlag(),
+ },
+ },
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ backgroundIsFilled: opt.backgroundIsFilled,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeRadar)
+ return r.render(renderResult, seriesList)
}
diff --git a/radar_chart_test.go b/radar_chart_test.go
index c5d2aa9..79fd9ac 100644
--- a/radar_chart_test.go
+++ b/radar_chart_test.go
@@ -26,77 +26,82 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
)
-func TestRadarChartRender(t *testing.T) {
+func TestRadarChart(t *testing.T) {
assert := assert.New(t)
- d, err := NewDraw(DrawOption{
- Width: 250,
- Height: 150,
- })
- assert.Nil(err)
-
- f, _ := chart.GetDefaultFont()
- err = radarChartRender(radarChartOption{
- Font: f,
- Indicators: []RadarIndicator{
- {
- Name: "Sales",
- Max: 6500,
- },
- {
- Name: "Administration",
- Max: 16000,
- },
- {
- Name: "Information Technology",
- Max: 30000,
- },
- {
- Name: "Customer Support",
- Max: 38000,
- },
- {
- Name: "Development",
- Max: 52000,
- },
- {
- Name: "Marketing",
- Max: 25000,
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := [][]float64{
+ {
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ },
+ {
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
+ },
+ }
+ _, err := NewRadarChart(p, RadarChartOption{
+ SeriesList: NewSeriesListDataFromValues(values, ChartTypeRadar),
+ Title: TitleOption{
+ Text: "Basic Radar Chart",
+ },
+ Legend: NewLegendOption([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ RadarIndicators: NewRadarIndicators([]string{
+ "Sales",
+ "Administration",
+ "Information Technology",
+ "Customer Support",
+ "Development",
+ "Marketing",
+ }, []float64{
+ 6500,
+ 16000,
+ 30000,
+ 38000,
+ 52000,
+ 25000,
+ }),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\nAllocated Budget Actual Spending Basic Radar Chart Sales Administration Information Technology Customer Support Development Marketing ",
},
- 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("\\nSales Administration Information Technology Customer Support Development Marketing ", string(data))
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
}
diff --git a/range.go b/range.go
index 255a51b..ec64c2d 100644
--- a/range.go
+++ b/range.go
@@ -26,19 +26,46 @@ import (
"math"
)
-type Range struct {
+const defaultAxisDivideCount = 6
+
+type axisRange struct {
+ p *Painter
divideCount int
- Min float64
- Max float64
- Size int
- Boundary bool
+ min float64
+ max float64
+ size int
+ boundary bool
}
-func NewRange(min, max float64, divideCount int) Range {
+type AxisRangeOption struct {
+ Painter *Painter
+ // The min value of axis
+ Min float64
+ // The max value of axis
+ Max float64
+ // The size of axis
+ Size int
+ // Boundary gap
+ Boundary bool
+ // The count of divide
+ DivideCount int
+}
+
+// NewRange returns a axis range
+func NewRange(opt AxisRangeOption) axisRange {
+ max := opt.Max
+ min := opt.Min
+
+ max += math.Abs(max * 0.1)
+ min -= math.Abs(min * 0.1)
+ divideCount := opt.DivideCount
r := math.Abs(max - min)
// 最小单位计算
- unit := 2
+ unit := 1
+ if r > 5 {
+ unit = 2
+ }
if r > 10 {
unit = 4
}
@@ -63,47 +90,55 @@ func NewRange(min, max float64, divideCount int) Range {
}
}
max = min + float64(unit*divideCount)
- return Range{
- Min: min,
- Max: max,
+ expectMax := opt.Max * 2
+ if max > expectMax {
+ max = float64(ceilFloatToInt(expectMax))
+ }
+ return axisRange{
+ p: opt.Painter,
divideCount: divideCount,
+ min: min,
+ max: max,
+ size: opt.Size,
+ boundary: opt.Boundary,
}
}
-func (r Range) Values() []string {
- offset := (r.Max - r.Min) / float64(r.divideCount)
+// Values returns values of range
+func (r axisRange) Values() []string {
+ offset := (r.max - r.min) / float64(r.divideCount)
values := make([]string, 0)
+ formatter := commafWithDigits
+ if r.p != nil && r.p.valueFormatter != nil {
+ formatter = r.p.valueFormatter
+ }
for i := 0; i <= r.divideCount; i++ {
- v := r.Min + float64(i)*offset
- value := commafWithDigits(v)
+ v := r.min + float64(i)*offset
+ value := formatter(v)
values = append(values, value)
}
return values
}
-func (r *Range) getHeight(value float64) int {
- v := (value - r.Min) / (r.Max - r.Min)
- return int(v * float64(r.Size))
+func (r *axisRange) getHeight(value float64) int {
+ if r.max <= r.min {
+ return 0
+ }
+ v := (value - r.min) / (r.max - r.min)
+ return int(v * float64(r.size))
}
-func (r *Range) getRestHeight(value float64) int {
- return r.Size - r.getHeight(value)
+func (r *axisRange) getRestHeight(value float64) int {
+ return r.size - r.getHeight(value)
}
-func (r *Range) GetRange(index int) (float64, float64) {
- unit := float64(r.Size) / float64(r.divideCount)
+// GetRange returns a range of index
+func (r *axisRange) GetRange(index int) (float64, float64) {
+ unit := float64(r.size) / float64(r.divideCount)
return unit * float64(index), unit * float64(index+1)
}
-func (r *Range) AutoDivide() []int {
- return autoDivide(r.Size, r.divideCount)
-}
-func (r *Range) getWidth(value float64) int {
- v := value / (r.Max - r.Min)
- // 移至居中
- if r.Boundary &&
- r.divideCount != 0 {
- v += 1 / float64(r.divideCount*2)
- }
- return int(v * float64(r.Size))
+// AutoDivide divides the axis
+func (r *axisRange) AutoDivide() []int {
+ return autoDivide(r.size, r.divideCount)
}
diff --git a/range_test.go b/range_test.go
deleted file mode 100644
index d1aea8f..0000000
--- a/range_test.go
+++ /dev/null
@@ -1,94 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestRange(t *testing.T) {
- assert := assert.New(t)
-
- r := NewRange(0, 8, 6)
- assert.Equal(0.0, r.Min)
- assert.Equal(12.0, r.Max)
-
- r = NewRange(0, 12, 6)
- assert.Equal(0.0, r.Min)
- assert.Equal(24.0, r.Max)
-
- r = NewRange(-13, 18, 6)
- assert.Equal(-20.0, r.Min)
- assert.Equal(40.0, r.Max)
-
- r = NewRange(0, 150, 6)
- assert.Equal(0.0, r.Min)
- assert.Equal(180.0, r.Max)
-
- r = NewRange(0, 400, 6)
- assert.Equal(0.0, r.Min)
- assert.Equal(480.0, r.Max)
-}
-
-func TestRangeHeightWidth(t *testing.T) {
- assert := assert.New(t)
- r := NewRange(0, 8, 6)
- r.Size = 100
-
- assert.Equal(33, r.getHeight(4))
- assert.Equal(67, r.getRestHeight(4))
-
- assert.Equal(33, r.getWidth(4))
- r.Boundary = true
- assert.Equal(41, r.getWidth(4))
-}
-
-func TestRangeGetRange(t *testing.T) {
- assert := assert.New(t)
- r := NewRange(0, 8, 6)
- r.Size = 120
-
- f1, f2 := r.GetRange(0)
- assert.Equal(0.0, f1)
- assert.Equal(20.0, f2)
-
- f1, f2 = r.GetRange(2)
- assert.Equal(40.0, f1)
- assert.Equal(60.0, f2)
-}
-
-func TestRangeAutoDivide(t *testing.T) {
- assert := assert.New(t)
-
- r := Range{
- Size: 120,
- divideCount: 6,
- }
-
- assert.Equal([]int{0, 20, 40, 60, 80, 100, 120}, r.AutoDivide())
-
- r.Size = 130
- assert.Equal([]int{0, 22, 44, 66, 88, 109, 130}, r.AutoDivide())
-}
diff --git a/series.go b/series.go
index 14227d1..da50e64 100644
--- a/series.go
+++ b/series.go
@@ -19,7 +19,6 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
-
package charts
import (
@@ -27,17 +26,26 @@ import (
"strings"
"github.com/dustin/go-humanize"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2"
)
type SeriesData struct {
// The value of series data
Value float64
// The style of series data
- Style chart.Style
+ Style Style
}
+// NewSeriesListDataFromValues returns a series list
+func NewSeriesListDataFromValues(values [][]float64, chartType ...string) SeriesList {
+ seriesList := make(SeriesList, len(values))
+ for index, value := range values {
+ seriesList[index] = NewSeriesFromValues(value, chartType...)
+ }
+ return seriesList
+}
+
+// NewSeriesFromValues returns a series
func NewSeriesFromValues(values []float64, chartType ...string) Series {
s := Series{
Data: NewSeriesDataFromValues(values),
@@ -48,6 +56,7 @@ func NewSeriesFromValues(values []float64, chartType ...string) Series {
return s
}
+// NewSeriesDataFromValues return a series data
func NewSeriesDataFromValues(values []float64) []SeriesData {
data := make([]SeriesData, len(values))
for index, value := range values {
@@ -65,11 +74,17 @@ type SeriesLabel struct {
// {d}: the percent of a data item(pie chart).
Formatter string
// The color for label
- Color drawing.Color
+ Color Color
// Show flag for label
Show bool
// Distance to the host graphic element.
Distance int
+ // The position of label
+ Position string
+ // The offset of label's position
+ Offset Box
+ // The font size of label
+ FontSize float64
}
const (
@@ -101,8 +116,8 @@ type Series struct {
// The data list of series
Data []SeriesData
// The Y axis index, it should be 0 or 1.
- // Default value is 1
- YAxisIndex int
+ // Default value is 0
+ AxisIndex int
// The style for series
Style chart.Style
// The label for series
@@ -111,6 +126,8 @@ type Series struct {
Name string
// Radius for Pie chart, e.g.: 40%, default is "40%"
Radius string
+ // Round for bar chart
+ RoundRadius int
// Mark point for series
MarkPoint SeriesMarkPoint
// Make line for series
@@ -122,6 +139,55 @@ type Series struct {
}
type SeriesList []Series
+func (sl SeriesList) init() {
+ if len(sl) == 0 {
+ return
+ }
+ if sl[len(sl)-1].index != 0 {
+ return
+ }
+ for i := 0; i < len(sl); i++ {
+ if sl[i].Type == "" {
+ sl[i].Type = ChartTypeLine
+ }
+ sl[i].index = i
+ }
+}
+
+func (sl SeriesList) Filter(chartType string) SeriesList {
+ arr := make(SeriesList, 0)
+ for index, item := range sl {
+ if item.Type == chartType {
+ arr = append(arr, sl[index])
+ }
+ }
+ return arr
+}
+
+// GetMaxMin get max and min value of series list
+func (sl SeriesList) GetMaxMin(axisIndex int) (float64, float64) {
+ min := math.MaxFloat64
+ max := -math.MaxFloat64
+ for _, series := range sl {
+ if series.AxisIndex != axisIndex {
+ continue
+ }
+ for _, item := range series.Data {
+ // 如果为空值,忽略
+ if item.Value == nullValue {
+ continue
+ }
+ if item.Value > max {
+ max = item.Value
+ }
+ if item.Value < min {
+ min = item.Value
+ }
+ }
+ }
+ return max, min
+}
+
type PieSeriesOption struct {
Radius string
Label SeriesLabel
@@ -156,13 +222,19 @@ func NewPieSeriesList(values []float64, opts ...PieSeriesOption) SeriesList {
}
type seriesSummary struct {
- MaxIndex int
- MaxValue float64
- MinIndex int
- MinValue float64
+ // The index of max value
+ MaxIndex int
+ // The max value
+ MaxValue float64
+ // The index of min value
+ MinIndex int
+ // The min value
+ MinValue float64
+ // THe average value
AverageValue float64
}
+// Summary get summary of series
func (s *Series) Summary() seriesSummary {
minIndex := -1
maxIndex := -1
@@ -189,6 +261,7 @@ func (s *Series) Summary() seriesSummary {
}
}
+// Names returns the names of series list
func (sl SeriesList) Names() []string {
names := make([]string, len(sl))
for index, s := range sl {
@@ -197,8 +270,10 @@ func (sl SeriesList) Names() []string {
return names
}
+// LabelFormatter label formatter
type LabelFormatter func(index int, value float64, percent float64) string
+// NewPieLabelFormatter returns a pie label formatter
func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter {
if len(layout) == 0 {
layout = "{b}: {d}"
@@ -206,13 +281,23 @@ func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter {
return NewLabelFormatter(seriesNames, layout)
}
-func NewValueLabelFormater(seriesNames []string, layout string) LabelFormatter {
+// NewFunnelLabelFormatter returns a funner label formatter
+func NewFunnelLabelFormatter(seriesNames []string, layout string) LabelFormatter {
+ if len(layout) == 0 {
+ layout = "{b}({d})"
+ }
+ return NewLabelFormatter(seriesNames, layout)
+}
+
+// NewValueLabelFormatter returns a value formatter
+func NewValueLabelFormatter(seriesNames []string, layout string) LabelFormatter {
if len(layout) == 0 {
layout = "{c}"
}
return NewLabelFormatter(seriesNames, layout)
}
+// NewLabelFormatter returns a label formaatter
func NewLabelFormatter(seriesNames []string, layout string) LabelFormatter {
return func(index int, value, percent float64) string {
// 如果无percent的则设置为<0
diff --git a/series_label.go b/series_label.go
new file mode 100644
index 0000000..af873fc
--- /dev/null
+++ b/series_label.go
@@ -0,0 +1,148 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type labelRenderValue struct {
+ Text string
+ Style Style
+ X int
+ Y int
+ // 旋转
+ Radians float64
+}
+
+type LabelValue struct {
+ Index int
+ Value float64
+ X int
+ Y int
+ // 旋转
+ Radians float64
+ // 字体颜色
+ FontColor Color
+ // 字体大小
+ FontSize float64
+ Orient string
+ Offset Box
+}
+
+type SeriesLabelPainter struct {
+ p *Painter
+ seriesNames []string
+ label *SeriesLabel
+ theme ColorPalette
+ font *truetype.Font
+ values []labelRenderValue
+}
+
+type SeriesLabelPainterParams struct {
+ P *Painter
+ SeriesNames []string
+ Label SeriesLabel
+ Theme ColorPalette
+ Font *truetype.Font
+}
+
+func NewSeriesLabelPainter(params SeriesLabelPainterParams) *SeriesLabelPainter {
+ return &SeriesLabelPainter{
+ p: params.P,
+ seriesNames: params.SeriesNames,
+ label: ¶ms.Label,
+ theme: params.Theme,
+ font: params.Font,
+ values: make([]labelRenderValue, 0),
+ }
+}
+
+func (o *SeriesLabelPainter) Add(value LabelValue) {
+ label := o.label
+ distance := label.Distance
+ if distance == 0 {
+ distance = 5
+ }
+ text := NewValueLabelFormatter(o.seriesNames, label.Formatter)(value.Index, value.Value, -1)
+ labelStyle := Style{
+ FontColor: o.theme.GetTextColor(),
+ FontSize: labelFontSize,
+ Font: o.font,
+ }
+ if value.FontSize != 0 {
+ labelStyle.FontSize = value.FontSize
+ }
+ if !value.FontColor.IsZero() {
+ label.Color = value.FontColor
+ }
+ if !label.Color.IsZero() {
+ labelStyle.FontColor = label.Color
+ }
+ p := o.p
+ p.OverrideDrawingStyle(labelStyle)
+ rotated := value.Radians != 0
+ if rotated {
+ p.SetTextRotation(value.Radians)
+ }
+ textBox := p.MeasureText(text)
+ renderValue := labelRenderValue{
+ Text: text,
+ Style: labelStyle,
+ X: value.X,
+ Y: value.Y,
+ Radians: value.Radians,
+ }
+ if value.Orient != OrientHorizontal {
+ renderValue.X -= textBox.Width() >> 1
+ renderValue.Y -= distance
+ } else {
+ renderValue.X += distance
+ renderValue.Y += textBox.Height() >> 1
+ renderValue.Y -= 2
+ }
+ if rotated {
+ renderValue.X = value.X + textBox.Width()>>1 - 1
+ p.ClearTextRotation()
+ } else {
+ if textBox.Width()%2 != 0 {
+ renderValue.X++
+ }
+ }
+ renderValue.X += value.Offset.Left
+ renderValue.Y += value.Offset.Top
+ o.values = append(o.values, renderValue)
+}
+
+func (o *SeriesLabelPainter) Render() (Box, error) {
+ for _, item := range o.values {
+ o.p.OverrideTextStyle(item.Style)
+ if item.Radians != 0 {
+ o.p.TextRotation(item.Text, item.X, item.Y, item.Radians)
+ } else {
+ o.p.Text(item.Text, item.X, item.Y)
+ }
+ }
+ return chart.BoxZero, nil
+}
diff --git a/series_test.go b/series_test.go
index 1460180..40d2f91 100644
--- a/series_test.go
+++ b/series_test.go
@@ -19,7 +19,6 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
-
package charts
import (
@@ -28,139 +27,63 @@ import (
"github.com/stretchr/testify/assert"
)
-func TestNewSeriesFromValues(t *testing.T) {
- assert := assert.New(t)
-
- assert.Equal(Series{
- Data: []SeriesData{
- {
- Value: 1,
- },
- {
- Value: 2,
- },
- },
- Type: ChartTypeBar,
- }, NewSeriesFromValues([]float64{
- 1,
- 2,
- }, ChartTypeBar))
-}
-
-func TestNewSeriesDataFromValues(t *testing.T) {
- assert := assert.New(t)
-
- assert.Equal([]SeriesData{
- {
- Value: 1,
- },
- {
- Value: 2,
- },
- }, NewSeriesDataFromValues([]float64{
- 1,
- 2,
- }))
-}
-
-func TestNewPieSeriesList(t *testing.T) {
+func TestNewSeriesListDataFromValues(t *testing.T) {
assert := assert.New(t)
assert.Equal(SeriesList{
{
- Type: ChartTypePie,
- Name: "a",
- Label: SeriesLabel{
- Show: true,
- },
- Radius: "30%",
+ Type: ChartTypeBar,
Data: []SeriesData{
{
- Value: 1,
+ Value: 1.0,
},
},
},
+ }, NewSeriesListDataFromValues([][]float64{
{
- Type: ChartTypePie,
- Name: "b",
- Label: SeriesLabel{
- Show: true,
- },
- Radius: "30%",
- Data: []SeriesData{
- {
- Value: 2,
- },
- },
+ 1,
},
- }, NewPieSeriesList([]float64{
- 1,
- 2,
- }, PieSeriesOption{
- Radius: "30%",
- Label: SeriesLabel{
- Show: true,
- },
- Names: []string{
- "a",
- "b",
- },
- }))
+ }, ChartTypeBar))
}
-func TestSeriesSummary(t *testing.T) {
+func TestSeriesLists(t *testing.T) {
assert := assert.New(t)
-
- s := Series{
- Data: NewSeriesDataFromValues([]float64{
+ seriesList := NewSeriesListDataFromValues([][]float64{
+ {
1,
- 3,
- 5,
- 7,
- 9,
- }),
- }
+ 2,
+ },
+ {
+ 10,
+ },
+ }, ChartTypeBar)
+
+ assert.Equal(2, len(seriesList.Filter(ChartTypeBar)))
+ assert.Equal(0, len(seriesList.Filter(ChartTypeLine)))
+
+ max, min := seriesList.GetMaxMin(0)
+ assert.Equal(float64(10), max)
+ assert.Equal(float64(1), min)
+
assert.Equal(seriesSummary{
- MaxIndex: 4,
- MaxValue: 9,
+ MaxIndex: 1,
+ MaxValue: 2,
MinIndex: 0,
MinValue: 1,
- AverageValue: 5,
- }, s.Summary())
+ AverageValue: 1.5,
+ }, seriesList[0].Summary())
}
-func TestGetSeriesNames(t *testing.T) {
+func TestFormatter(t *testing.T) {
assert := assert.New(t)
- sl := SeriesList{
- {
- Name: "a",
- },
- {
- Name: "b",
- },
- }
- assert.Equal([]string{
+ assert.Equal("a: 12%", NewPieLabelFormatter([]string{
"a",
"b",
- }, sl.Names())
-}
+ }, "")(0, 10, 0.12))
-func TestNewPieLabelFormatter(t *testing.T) {
- assert := assert.New(t)
-
- fn := NewPieLabelFormatter([]string{
+ assert.Equal("10", NewValueLabelFormatter([]string{
"a",
"b",
- }, "")
- assert.Equal("a: 35%", fn(0, 1.2, 0.35))
-}
-
-func TestNewValueLabelFormater(t *testing.T) {
- assert := assert.New(t)
- fn := NewValueLabelFormater([]string{
- "a",
- "b",
- }, "")
- assert.Equal("1.2", fn(0, 1.2, 0.35))
+ }, "")(0, 10, 0.12))
}
diff --git a/start_zh.md b/start_zh.md
new file mode 100644
index 0000000..ee8359c
--- /dev/null
+++ b/start_zh.md
@@ -0,0 +1,254 @@
+# go-charts
+
+`go-charts`主要分为了下几个模块:
+
+- `标题`:图表的标题,包括主副标题,位置为图表的顶部
+- `图例`:图表的图例列表,用于标识每个图例对应的颜色与名称信息,默认为图表的顶部,可自定义位置
+- `X轴`:图表的x轴,用于折线图、柱状图中,表示每个点对应的时间,位置图表的底部
+- `Y轴`:图表的y轴,用于折线图、柱状图中,最多可使用两组y轴(一左一右),默认位置图表的左侧
+- `内容`: 图表的内容,折线图、柱状图、饼图等,在图表的中间区域
+
+## 标题
+
+### 常用设置
+
+标题一般仅需要设置主副标题即可,其它的属性均会设置默认值,常用的方式是使用`TitleTextOptionFunc`设置,其中副标题为可选值,方式如下:
+
+```go
+ charts.TitleTextOptionFunc("Text", "Subtext"),
+```
+
+### 个性化设置
+
+```go
+func(opt *charts.ChartOption) {
+ opt.Title = charts.TitleOption{
+ // 主标题
+ Text: "Text",
+ // 副标题
+ Subtext: "Subtext",
+ // 标题左侧位置,可设置为"center","right",数值("20")或百份比("20%")
+ Left: charts.PositionRight,
+ // 标题顶部位置,只可调为数值
+ Top: "20",
+ // 主标题文字大小
+ FontSize: 14,
+ // 副标题文字大小
+ SubtextFontSize: 12,
+ // 主标题字体颜色
+ FontColor: charts.Color{
+ R: 100,
+ G: 100,
+ B: 100,
+ A: 255,
+ },
+ // 副标题字体影响
+ SubtextFontColor: charts.Color{
+ R: 200,
+ G: 200,
+ B: 200,
+ A: 255,
+ },
+ }
+},
+```
+
+### 部分属性个性化设置
+
+```go
+charts.TitleTextOptionFunc("Text", "Subtext"),
+func(opt *charts.ChartOption) {
+ // 修改top的值
+ opt.Title.Top = "20"
+},
+```
+
+## 图例
+
+### 常用设置
+
+图例组件与图表中的数据一一对应,常用仅设置其名称及左侧的值即可(可选),方式如下:
+
+
+```go
+charts.LegendLabelsOptionFunc([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+}, "50"),
+```
+
+### 个性化设置
+
+```go
+func(opt *charts.ChartOption) {
+ opt.Legend = charts.LegendOption{
+ // 图例名称
+ Data: []string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ },
+ // 图例左侧位置,可设置为"center","right",数值("20")或百份比("20%")
+ // 如果示例有多行,只影响第一行,而且对于多行的示例,设置"center", "right"无效
+ Left: "50",
+ // 图例顶部位置,只可调为数值
+ Top: "10",
+ // 图例图标的位置,默认为左侧,只允许左或右
+ Align: charts.AlignRight,
+ // 图例排列方式,默认为水平,只允许水平或垂直
+ Orient: charts.OrientVertical,
+ // 图标类型,提供"rect"与"lineDot"两种类型
+ Icon: charts.IconRect,
+ // 字体大小
+ FontSize: 14,
+ // 字体颜色
+ FontColor: charts.Color{
+ R: 150,
+ G: 150,
+ B: 150,
+ A: 255,
+ },
+ // 是否展示,如果不需要展示则设置
+ // Show: charts.FalseFlag(),
+ // 图例区域的padding值
+ Padding: charts.Box{
+ Top: 10,
+ Left: 10,
+ },
+ }
+},
+```
+
+### 部分属性个性化设置
+
+```go
+charts.LegendLabelsOptionFunc([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+}, "50"),
+func(opt *charts.ChartOption) {
+ opt.Legend.Top = "10"
+},
+```
+
+## X轴
+
+### 常用设置
+
+图表中X轴的展示,常用的设置方式是指定数组即可:
+
+```go
+charts.XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+}),
+```
+
+### 个性化设置
+
+```go
+func(opt *charts.ChartOption) {
+ opt.XAxis = charts.XAxisOption{
+ // X轴内容
+ Data: []string{
+ "01",
+ "02",
+ "03",
+ "04",
+ "05",
+ "06",
+ "07",
+ "08",
+ "09",
+ },
+ // 如果数据点不居中,则设置为false
+ BoundaryGap: charts.FalseFlag(),
+ // 字体大小
+ FontSize: 14,
+ // 是否展示,如果不需要展示则设置
+ // Show: charts.FalseFlag(),
+ // 会根据文本内容以及此值选择适合的分块大小,一般不需要设置
+ // SplitNumber: 3,
+ // 线条颜色
+ StrokeColor: charts.Color{
+ R: 200,
+ G: 200,
+ B: 200,
+ A: 255,
+ },
+ // 文字颜色
+ FontColor: charts.Color{
+ R: 100,
+ G: 100,
+ B: 100,
+ A: 255,
+ },
+ }
+},
+```
+
+### 部分属性个性化设置
+
+```go
+charts.XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+}),
+func(opt *charts.ChartOption) {
+ opt.XAxis.FontColor = charts.Color{
+ R: 100,
+ G: 100,
+ B: 100,
+ A: 255,
+ },
+},
+```
+
+## Y轴
+
+图表中的y轴展示的相关数据会根据图表中的数据自动生成适合的值,如果需要自定义,则可自定义以下部分数据:
+
+```go
+func(opt *charts.ChartOption) {
+ opt.YAxisOptions = []charts.YAxisOption{
+ {
+ // 字体大小
+ FontSize: 16,
+ // 字体颜色
+ FontColor: charts.Color{
+ R: 100,
+ G: 100,
+ B: 100,
+ A: 255,
+ },
+ // 内容,{value}会替换为对应的值
+ Formatter: "{value} ml",
+ // Y轴颜色,如果设置此值,会覆盖font color
+ Color: charts.Color{
+ R: 255,
+ G: 0,
+ B: 0,
+ A: 255,
+ },
+ },
+ }
+},
+```
diff --git a/table.go b/table.go
index 9cfc6b1..3e6f273 100644
--- a/table.go
+++ b/table.go
@@ -25,121 +25,414 @@ package charts
import (
"errors"
- "github.com/wcharczuk/go-chart/v2"
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
-type TableOption struct {
- // draw
- Draw *Draw
+type tableChart struct {
+ p *Painter
+ opt *TableChartOption
+}
+
+// NewTableChart returns a table chart render
+func NewTableChart(p *Painter, opt TableChartOption) *tableChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &tableChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type TableCell struct {
+ // Text the text of table cell
+ Text string
+ // Style the current style of table cell
+ Style Style
+ // Row the row index of table cell
+ Row int
+ // Column the column index of table cell
+ Column int
+}
+
+type TableChartOption struct {
+ // The output type
+ Type string
// The width of table
Width int
- // The header of table
+ // The theme
+ Theme ColorPalette
+ // The padding of table cell
+ Padding Box
+ // The header data of table
Header []string
- // The style of table
- Style chart.Style
- ColumnWidths []float64
- // 是否仅测量高度
- measurement bool
+ // The data of table
+ Data [][]string
+ // The span list of table column
+ Spans []int
+ // The text align list of table cell
+ TextAligns []string
+ // The font size of table
+ FontSize float64
+ // The font family, which should be installed first
+ FontFamily string
+ Font *truetype.Font
+ // The font color of table
+ FontColor Color
+ // The background color of header
+ HeaderBackgroundColor Color
+ // The header font color
+ HeaderFontColor Color
+ // The background color of row
+ RowBackgroundColors []Color
+ // The background color
+ BackgroundColor Color
+ // CellTextStyle customize text style of table cell
+ CellTextStyle func(TableCell) *Style
+ // CellStyle customize drawing style of table cell
+ CellStyle func(TableCell) *Style
}
-var ErrTableColumnWidthInvalid = errors.New("table column width is invalid")
-
-func tableDivideWidth(width, size int, columnWidths []float64) ([]int, error) {
- widths := make([]int, size)
-
- autoFillCount := size
- restWidth := width
- if len(columnWidths) != 0 {
- for index, v := range columnWidths {
- if v <= 0 {
- continue
- }
- autoFillCount--
- // 小于1的表示占比
- if v < 1 {
- widths[index] = int(v * float64(width))
- } else {
- widths[index] = int(v)
- }
- restWidth -= widths[index]
- }
- }
- if restWidth < 0 {
- return nil, ErrTableColumnWidthInvalid
- }
- // 填充其它未指定的宽度
- if autoFillCount > 0 {
- autoWidth := restWidth / autoFillCount
- for index, v := range widths {
- if v == 0 {
- widths[index] = autoWidth
- }
- }
- }
- widthSum := 0
- for _, v := range widths {
- widthSum += v
- }
- if widthSum > width {
- return nil, ErrTableColumnWidthInvalid
- }
- return widths, nil
+type TableSetting struct {
+ // The color of header
+ HeaderColor Color
+ // The color of heder text
+ HeaderFontColor Color
+ // The color of table text
+ FontColor Color
+ // The color list of row
+ RowColors []Color
+ // The padding of cell
+ Padding Box
}
-func TableMeasure(opt TableOption) (chart.Box, error) {
- d, err := NewDraw(DrawOption{
- Width: opt.Width,
- Height: 600,
- })
- if err != nil {
- return chart.BoxZero, err
- }
- opt.Draw = d
- opt.measurement = true
- return tableRender(opt)
+var TableLightThemeSetting = TableSetting{
+ HeaderColor: Color{
+ R: 240,
+ G: 240,
+ B: 240,
+ A: 255,
+ },
+ HeaderFontColor: Color{
+ R: 98,
+ G: 105,
+ B: 118,
+ A: 255,
+ },
+ FontColor: Color{
+ R: 70,
+ G: 70,
+ B: 70,
+ A: 255,
+ },
+ RowColors: []Color{
+ drawing.ColorWhite,
+ {
+ R: 247,
+ G: 247,
+ B: 247,
+ A: 255,
+ },
+ },
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
}
-func tableRender(opt TableOption) (chart.Box, error) {
- if opt.Draw == nil {
- return chart.BoxZero, errors.New("draw can not be nil")
+var TableDarkThemeSetting = TableSetting{
+ HeaderColor: Color{
+ R: 38,
+ G: 38,
+ B: 42,
+ A: 255,
+ },
+ HeaderFontColor: Color{
+ R: 216,
+ G: 217,
+ B: 218,
+ A: 255,
+ },
+ FontColor: Color{
+ R: 216,
+ G: 217,
+ B: 218,
+ A: 255,
+ },
+ RowColors: []Color{
+ {
+ R: 24,
+ G: 24,
+ B: 28,
+ A: 255,
+ },
+ {
+ R: 38,
+ G: 38,
+ B: 42,
+ A: 255,
+ },
+ },
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+}
+
+var tableDefaultSetting = TableLightThemeSetting
+
+// SetDefaultTableSetting sets the default setting for table
+func SetDefaultTableSetting(setting TableSetting) {
+ tableDefaultSetting = setting
+}
+
+type renderInfo struct {
+ Width int
+ Height int
+ HeaderHeight int
+ RowHeights []int
+ ColumnWidths []int
+}
+
+func (t *tableChart) render() (*renderInfo, error) {
+ info := renderInfo{
+ RowHeights: make([]int, 0),
}
+ p := t.p
+ opt := t.opt
if len(opt.Header) == 0 {
- return chart.BoxZero, errors.New("header can not be nil")
+ return nil, errors.New("header can not be nil")
}
- width := opt.Width
- if width == 0 {
- width = opt.Draw.Box.Width()
+ theme := opt.Theme
+ if theme == nil {
+ theme = p.theme
+ }
+ fontSize := opt.FontSize
+ if fontSize == 0 {
+ fontSize = 12
+ }
+ fontColor := opt.FontColor
+ if fontColor.IsZero() {
+ fontColor = tableDefaultSetting.FontColor
+ }
+ font := opt.Font
+ if font == nil {
+ font = theme.GetFont()
+ }
+ headerFontColor := opt.HeaderFontColor
+ if opt.HeaderFontColor.IsZero() {
+ headerFontColor = tableDefaultSetting.HeaderFontColor
}
- columnWidths, err := tableDivideWidth(width, len(opt.Header), opt.ColumnWidths)
+ spans := opt.Spans
+ if len(spans) != len(opt.Header) {
+ newSpans := make([]int, len(opt.Header))
+ for index := range opt.Header {
+ if index >= len(spans) {
+ newSpans[index] = 1
+ } else {
+ newSpans[index] = spans[index]
+ }
+ }
+ spans = newSpans
+ }
+
+ sum := sumInt(spans)
+ values := autoDivideSpans(p.Width(), sum, spans)
+ columnWidths := make([]int, 0)
+ for index, v := range values {
+ if index == len(values)-1 {
+ break
+ }
+ columnWidths = append(columnWidths, values[index+1]-v)
+ }
+ info.ColumnWidths = columnWidths
+
+ height := 0
+ textStyle := Style{
+ FontSize: fontSize,
+ FontColor: headerFontColor,
+ FillColor: headerFontColor,
+ Font: font,
+ }
+
+ headerHeight := 0
+ padding := opt.Padding
+ if padding.IsZero() {
+ padding = tableDefaultSetting.Padding
+ }
+ getCellTextStyle := opt.CellTextStyle
+ if getCellTextStyle == nil {
+ getCellTextStyle = func(_ TableCell) *Style {
+ return nil
+ }
+ }
+ // textAligns := opt.TextAligns
+ getTextAlign := func(index int) string {
+ if len(opt.TextAligns) <= index {
+ return ""
+ }
+ return opt.TextAligns[index]
+ }
+
+ // 表格单元的处理
+ renderTableCells := func(
+ currentStyle Style,
+ rowIndex int,
+ textList []string,
+ currentHeight int,
+ cellPadding Box,
+ ) int {
+ cellMaxHeight := 0
+ paddingHeight := cellPadding.Top + cellPadding.Bottom
+ paddingWidth := cellPadding.Left + cellPadding.Right
+ for index, text := range textList {
+ cellStyle := getCellTextStyle(TableCell{
+ Text: text,
+ Row: rowIndex,
+ Column: index,
+ Style: currentStyle,
+ })
+ if cellStyle == nil {
+ cellStyle = ¤tStyle
+ }
+ p.SetStyle(*cellStyle)
+ x := values[index]
+ y := currentHeight + cellPadding.Top
+ width := values[index+1] - x
+ x += cellPadding.Left
+ width -= paddingWidth
+ box := p.TextFit(text, x, y+int(fontSize), width, getTextAlign(index))
+ // 计算最高的高度
+ if box.Height()+paddingHeight > cellMaxHeight {
+ cellMaxHeight = box.Height() + paddingHeight
+ }
+ }
+ return cellMaxHeight
+ }
+
+ // 表头的处理
+ headerHeight = renderTableCells(textStyle, 0, opt.Header, height, padding)
+ height += headerHeight
+ info.HeaderHeight = headerHeight
+
+ // 表格内容的处理
+ textStyle.FontColor = fontColor
+ textStyle.FillColor = fontColor
+ for index, textList := range opt.Data {
+ cellHeight := renderTableCells(textStyle, index+1, textList, height, padding)
+ info.RowHeights = append(info.RowHeights, cellHeight)
+ height += cellHeight
+ }
+
+ info.Width = p.Width()
+ info.Height = height
+ return &info, nil
+}
+
+func (t *tableChart) renderWithInfo(info *renderInfo) (Box, error) {
+ p := t.p
+ opt := t.opt
+ if !opt.BackgroundColor.IsZero() {
+ p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
+ }
+ headerBGColor := opt.HeaderBackgroundColor
+ if headerBGColor.IsZero() {
+ headerBGColor = tableDefaultSetting.HeaderColor
+ }
+
+ // 如果设置表头背景色
+ p.SetBackground(info.Width, info.HeaderHeight, headerBGColor, true)
+ currentHeight := info.HeaderHeight
+ rowColors := opt.RowBackgroundColors
+ if rowColors == nil {
+ rowColors = tableDefaultSetting.RowColors
+ }
+ for index, h := range info.RowHeights {
+ color := rowColors[index%len(rowColors)]
+ child := p.Child(PainterPaddingOption(Box{
+ Top: currentHeight,
+ }))
+ child.SetBackground(p.Width(), h, color, true)
+ currentHeight += h
+ }
+ // 根据是否有设置表格样式调整背景色
+ getCellStyle := opt.CellStyle
+ if getCellStyle != nil {
+ arr := [][]string{
+ opt.Header,
+ }
+ arr = append(arr, opt.Data...)
+ top := 0
+ heights := []int{
+ info.HeaderHeight,
+ }
+ heights = append(heights, info.RowHeights...)
+ // 循环所有表格单元,生成背景色
+ for i, textList := range arr {
+ left := 0
+ for j, v := range textList {
+ style := getCellStyle(TableCell{
+ Text: v,
+ Row: i,
+ Column: j,
+ })
+ if style != nil && !style.FillColor.IsZero() {
+ padding := style.Padding
+ child := p.Child(PainterPaddingOption(Box{
+ Top: top + padding.Top,
+ Left: left + padding.Left,
+ }))
+ w := info.ColumnWidths[j] - padding.Left - padding.Top
+ h := heights[i] - padding.Top - padding.Bottom
+ child.SetBackground(w, h, style.FillColor, true)
+ }
+ left += info.ColumnWidths[j]
+ }
+ top += heights[i]
+ }
+ }
+ _, err := t.render()
if err != nil {
- return chart.BoxZero, err
+ return BoxZero, err
}
- d := opt.Draw
- style := opt.Style
- y := 0
- x := 0
-
- headerMaxHeight := 0
- for index, text := range opt.Header {
- var box chart.Box
- w := columnWidths[index]
- y0 := y + int(opt.Style.FontSize)
- if opt.measurement {
- box = d.measureTextFit(text, x, y0, w, style)
- } else {
- box = d.textFit(text, x, y0, w, style)
- }
- if box.Height() > headerMaxHeight {
- headerMaxHeight = box.Height()
- }
- x += w
- }
- y += headerMaxHeight
-
- return chart.Box{
- Right: width,
- Bottom: y,
+ return Box{
+ Right: info.Width,
+ Bottom: info.Height,
}, nil
}
+
+func (t *tableChart) Render() (Box, error) {
+ p := t.p
+ opt := t.opt
+ if !opt.BackgroundColor.IsZero() {
+ p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
+ }
+ if opt.Font == nil && opt.FontFamily != "" {
+ opt.Font, _ = GetFont(opt.FontFamily)
+ }
+
+ r := p.render
+ fn := chart.PNG
+ if p.outputType == ChartOutputSVG {
+ fn = chart.SVG
+ }
+ newRender, err := fn(p.Width(), 100)
+ if err != nil {
+ return BoxZero, err
+ }
+ p.render = newRender
+ info, err := t.render()
+ if err != nil {
+ return BoxZero, err
+ }
+ p.render = r
+ return t.renderWithInfo(info)
+}
diff --git a/table_test.go b/table_test.go
new file mode 100644
index 0000000..a958c95
--- /dev/null
+++ b/table_test.go
@@ -0,0 +1,140 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTableChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewTableChart(p, TableChartOption{
+ Header: []string{
+ "Name",
+ "Age",
+ "Address",
+ "Tag",
+ "Action",
+ },
+ Spans: []int{
+ 1,
+ 1,
+ 2,
+ 1,
+ // span和header不匹配,最后自动设置为1
+ // 1,
+ },
+ Data: [][]string{
+ {
+ "John Brown",
+ "32",
+ "New York No. 1 Lake Park",
+ "nice, developer",
+ "Send Mail",
+ },
+ {
+ "Jim Green ",
+ "42",
+ "London No. 1 Lake Park",
+ "wow",
+ "Send Mail",
+ },
+ {
+ "Joe Black ",
+ "32",
+ "Sidney No. 1 Lake Park",
+ "cool, teacher",
+ "Send Mail",
+ },
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\nName Age Address Tag Action 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: 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: "\\nName Age Address Tag Action 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 ",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/theme.go b/theme.go
index e3f9773..85016a5 100644
--- a/theme.go
+++ b/theme.go
@@ -23,7 +23,8 @@
package charts
import (
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
const ThemeDark = "dark"
@@ -31,23 +32,65 @@ const ThemeLight = "light"
const ThemeGrafana = "grafana"
const ThemeAnt = "ant"
-type Theme struct {
- palette *themeColorPalette
+type ColorPalette interface {
+ IsDark() bool
+ GetAxisStrokeColor() Color
+ SetAxisStrokeColor(Color)
+ GetAxisSplitLineColor() Color
+ SetAxisSplitLineColor(Color)
+ GetSeriesColor(int) Color
+ SetSeriesColor([]Color)
+ GetBackgroundColor() Color
+ SetBackgroundColor(Color)
+ GetTextColor() Color
+ SetTextColor(Color)
+ GetFontSize() float64
+ SetFontSize(float64)
+ GetFont() *truetype.Font
+ SetFont(*truetype.Font)
}
type themeColorPalette struct {
isDarkMode bool
- axisStrokeColor drawing.Color
- axisSplitLineColor drawing.Color
- backgroundColor drawing.Color
- textColor drawing.Color
- seriesColors []drawing.Color
+ axisStrokeColor Color
+ axisSplitLineColor Color
+ backgroundColor Color
+ textColor Color
+ seriesColors []Color
+ fontSize float64
+ font *truetype.Font
+}
+
+type ThemeOption struct {
+ IsDarkMode bool
+ AxisStrokeColor Color
+ AxisSplitLineColor Color
+ BackgroundColor Color
+ TextColor Color
+ SeriesColors []Color
}
var palettes = map[string]*themeColorPalette{}
+const defaultFontSize = 12.0
+
+var defaultTheme ColorPalette
+
+var defaultLightFontColor = drawing.Color{
+ R: 70,
+ G: 70,
+ B: 70,
+ A: 255,
+}
+var defaultDarkFontColor = drawing.Color{
+ R: 238,
+ G: 238,
+ B: 238,
+ A: 255,
+}
+
func init() {
- echartSeriesColors := []drawing.Color{
+ echartSeriesColors := []Color{
parseColor("#5470c6"),
parseColor("#91cc75"),
parseColor("#fac858"),
@@ -58,7 +101,7 @@ func init() {
parseColor("#9a60b4"),
parseColor("#ea7ccc"),
}
- grafanaSeriesColors := []drawing.Color{
+ grafanaSeriesColors := []Color{
parseColor("#7EB26D"),
parseColor("#EAB839"),
parseColor("#6ED0E0"),
@@ -68,7 +111,7 @@ func init() {
parseColor("#705DA0"),
parseColor("#508642"),
}
- antSeriesColors := []drawing.Color{
+ antSeriesColors := []Color{
parseColor("#5b8ff9"),
parseColor("#5ad8a6"),
parseColor("#5d7092"),
@@ -80,155 +123,210 @@ func init() {
}
AddTheme(
ThemeDark,
- true,
- drawing.Color{
- R: 185,
- G: 184,
- B: 206,
- A: 255,
+ ThemeOption{
+ IsDarkMode: true,
+ AxisStrokeColor: Color{
+ R: 185,
+ G: 184,
+ B: 206,
+ A: 255,
+ },
+ AxisSplitLineColor: Color{
+ R: 72,
+ G: 71,
+ B: 83,
+ A: 255,
+ },
+ BackgroundColor: Color{
+ R: 16,
+ G: 12,
+ B: 42,
+ A: 255,
+ },
+ TextColor: Color{
+ R: 238,
+ G: 238,
+ B: 238,
+ A: 255,
+ },
+ SeriesColors: echartSeriesColors,
},
- drawing.Color{
- R: 72,
- G: 71,
- B: 83,
- A: 255,
- },
- drawing.Color{
- R: 16,
- G: 12,
- B: 42,
- A: 255,
- },
- drawing.Color{
- R: 238,
- G: 238,
- B: 238,
- A: 255,
- },
- echartSeriesColors,
)
AddTheme(
ThemeLight,
- false,
- drawing.Color{
- R: 110,
- G: 112,
- B: 121,
- A: 255,
+ ThemeOption{
+ IsDarkMode: false,
+ AxisStrokeColor: Color{
+ R: 110,
+ G: 112,
+ B: 121,
+ A: 255,
+ },
+ AxisSplitLineColor: Color{
+ R: 224,
+ G: 230,
+ B: 242,
+ A: 255,
+ },
+ BackgroundColor: drawing.ColorWhite,
+ TextColor: Color{
+ R: 70,
+ G: 70,
+ B: 70,
+ A: 255,
+ },
+ SeriesColors: echartSeriesColors,
},
- drawing.Color{
- R: 224,
- G: 230,
- B: 242,
- A: 255,
- },
- drawing.ColorWhite,
- drawing.Color{
- R: 70,
- G: 70,
- B: 70,
- A: 255,
- },
- echartSeriesColors,
)
AddTheme(
ThemeAnt,
- false,
- drawing.Color{
- R: 110,
- G: 112,
- B: 121,
- A: 255,
+ ThemeOption{
+ IsDarkMode: false,
+ AxisStrokeColor: Color{
+ R: 110,
+ G: 112,
+ B: 121,
+ A: 255,
+ },
+ AxisSplitLineColor: Color{
+ R: 224,
+ G: 230,
+ B: 242,
+ A: 255,
+ },
+ BackgroundColor: drawing.ColorWhite,
+ TextColor: drawing.Color{
+ R: 70,
+ G: 70,
+ B: 70,
+ A: 255,
+ },
+ SeriesColors: antSeriesColors,
},
- drawing.Color{
- R: 224,
- G: 230,
- B: 242,
- A: 255,
- },
- drawing.ColorWhite,
- drawing.Color{
- R: 70,
- G: 70,
- B: 70,
- A: 255,
- },
- antSeriesColors,
)
AddTheme(
ThemeGrafana,
- true,
- drawing.Color{
- R: 185,
- G: 184,
- B: 206,
- A: 255,
+ ThemeOption{
+ IsDarkMode: true,
+ AxisStrokeColor: Color{
+ R: 185,
+ G: 184,
+ B: 206,
+ A: 255,
+ },
+ AxisSplitLineColor: Color{
+ R: 68,
+ G: 67,
+ B: 67,
+ A: 255,
+ },
+ BackgroundColor: drawing.Color{
+ R: 31,
+ G: 29,
+ B: 29,
+ A: 255,
+ },
+ TextColor: Color{
+ R: 216,
+ G: 217,
+ B: 218,
+ A: 255,
+ },
+ SeriesColors: grafanaSeriesColors,
},
- drawing.Color{
- R: 68,
- G: 67,
- B: 67,
- A: 255,
- },
- drawing.Color{
- R: 31,
- G: 29,
- B: 29,
- A: 255,
- },
- drawing.Color{
- R: 216,
- G: 217,
- B: 218,
- A: 255,
- },
- grafanaSeriesColors,
)
+ SetDefaultTheme(ThemeLight)
}
-func AddTheme(name string, isDarkMode bool, axisStrokeColor, axisSplitLineColor, backgroundColor, textColor drawing.Color, seriesColors []drawing.Color) {
+// SetDefaultTheme sets default theme
+func SetDefaultTheme(name string) {
+ defaultTheme = NewTheme(name)
+}
+
+func AddTheme(name string, opt ThemeOption) {
palettes[name] = &themeColorPalette{
- isDarkMode: isDarkMode,
- axisStrokeColor: axisStrokeColor,
- axisSplitLineColor: axisSplitLineColor,
- backgroundColor: backgroundColor,
- textColor: textColor,
- seriesColors: seriesColors,
+ isDarkMode: opt.IsDarkMode,
+ axisStrokeColor: opt.AxisStrokeColor,
+ axisSplitLineColor: opt.AxisSplitLineColor,
+ backgroundColor: opt.BackgroundColor,
+ textColor: opt.TextColor,
+ seriesColors: opt.SeriesColors,
}
}
-func NewTheme(name string) *Theme {
+func NewTheme(name string) ColorPalette {
p, ok := palettes[name]
if !ok {
p = palettes[ThemeLight]
}
- return &Theme{
- palette: p,
- }
+ clone := *p
+ return &clone
}
-func (t *Theme) IsDark() bool {
- return t.palette.isDarkMode
+func (t *themeColorPalette) IsDark() bool {
+ return t.isDarkMode
}
-func (t *Theme) GetAxisStrokeColor() drawing.Color {
- return t.palette.axisStrokeColor
+func (t *themeColorPalette) GetAxisStrokeColor() Color {
+ return t.axisStrokeColor
}
-func (t *Theme) GetAxisSplitLineColor() drawing.Color {
- return t.palette.axisSplitLineColor
+func (t *themeColorPalette) SetAxisStrokeColor(c Color) {
+ t.axisStrokeColor = c
}
-func (t *Theme) GetSeriesColor(index int) drawing.Color {
- colors := t.palette.seriesColors
+func (t *themeColorPalette) GetAxisSplitLineColor() Color {
+ return t.axisSplitLineColor
+}
+
+func (t *themeColorPalette) SetAxisSplitLineColor(c Color) {
+ t.axisSplitLineColor = c
+}
+
+func (t *themeColorPalette) GetSeriesColor(index int) Color {
+ colors := t.seriesColors
return colors[index%len(colors)]
}
-
-func (t *Theme) GetBackgroundColor() drawing.Color {
- return t.palette.backgroundColor
+func (t *themeColorPalette) SetSeriesColor(colors []Color) {
+ t.seriesColors = colors
}
-func (t *Theme) GetTextColor() drawing.Color {
- return t.palette.textColor
+func (t *themeColorPalette) GetBackgroundColor() Color {
+ return t.backgroundColor
+}
+
+func (t *themeColorPalette) SetBackgroundColor(c Color) {
+ t.backgroundColor = c
+}
+
+func (t *themeColorPalette) GetTextColor() Color {
+ return t.textColor
+}
+
+func (t *themeColorPalette) SetTextColor(c Color) {
+ t.textColor = c
+}
+
+func (t *themeColorPalette) GetFontSize() float64 {
+ if t.fontSize != 0 {
+ return t.fontSize
+ }
+ return defaultFontSize
+}
+
+func (t *themeColorPalette) SetFontSize(fontSize float64) {
+ t.fontSize = fontSize
+}
+
+func (t *themeColorPalette) GetFont() *truetype.Font {
+ if t.font != nil {
+ return t.font
+ }
+ f, _ := GetDefaultFont()
+ return f
+}
+
+func (t *themeColorPalette) SetFont(f *truetype.Font) {
+ t.font = f
}
diff --git a/theme_test.go b/theme_test.go
deleted file mode 100644
index bf22afd..0000000
--- a/theme_test.go
+++ /dev/null
@@ -1,87 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2/drawing"
-)
-
-func TestTheme(t *testing.T) {
- assert := assert.New(t)
-
- darkTheme := NewTheme(ThemeDark)
- lightTheme := NewTheme(ThemeLight)
-
- assert.True(darkTheme.IsDark())
- assert.False(lightTheme.IsDark())
-
- assert.Equal(drawing.Color{
- R: 185,
- G: 184,
- B: 206,
- A: 255,
- }, darkTheme.GetAxisStrokeColor())
- assert.Equal(drawing.Color{
- R: 110,
- G: 112,
- B: 121,
- A: 255,
- }, lightTheme.GetAxisStrokeColor())
-
- assert.Equal(drawing.Color{
- R: 72,
- G: 71,
- B: 83,
- A: 255,
- }, darkTheme.GetAxisSplitLineColor())
- assert.Equal(drawing.Color{
- R: 224,
- G: 230,
- B: 242,
- A: 255,
- }, lightTheme.GetAxisSplitLineColor())
-
- assert.Equal(drawing.Color{
- R: 16,
- G: 12,
- B: 42,
- A: 255,
- }, darkTheme.GetBackgroundColor())
- assert.Equal(drawing.ColorWhite, lightTheme.GetBackgroundColor())
-
- assert.Equal(drawing.Color{
- R: 238,
- G: 238,
- B: 238,
- A: 255,
- }, darkTheme.GetTextColor())
- assert.Equal(drawing.Color{
- R: 70,
- G: 70,
- B: 70,
- A: 255,
- }, lightTheme.GetTextColor())
-}
diff --git a/title.go b/title.go
index 07a2eef..74ab4f9 100644
--- a/title.go
+++ b/title.go
@@ -26,18 +26,16 @@ import (
"strconv"
"strings"
- "github.com/wcharczuk/go-chart/v2"
+ "github.com/golang/freetype/truetype"
)
type TitleOption struct {
+ // The theme of chart
+ Theme ColorPalette
// Title text, support \n for new line
Text string
// Subtitle text, support \n for new line
Subtext string
- // Title style
- Style chart.Style
- // Subtitle style
- SubtextStyle chart.Style
// Distance between title component and the left side of the container.
// It can be pixel value: 20, percentage value: 20%,
// or position value: right, center.
@@ -45,12 +43,23 @@ type TitleOption struct {
// Distance between title component and the top side of the container.
// It can be pixel value: 20.
Top string
+ // The font of label
+ Font *truetype.Font
+ // The font size of label
+ FontSize float64
+ // The color of label
+ FontColor Color
+ // The subtext font size of label
+ SubtextFontSize float64
+ // The subtext font color of label
+ SubtextFontColor Color
}
+
type titleMeasureOption struct {
width int
height int
text string
- style chart.Style
+ style Style
}
func splitTitleText(text string) []string {
@@ -66,44 +75,78 @@ func splitTitleText(text string) []string {
return result
}
-func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) {
- if len(opt.Text) == 0 {
- return chart.BoxZero, nil
- }
+type titlePainter struct {
+ p *Painter
+ opt *TitleOption
+}
- padding := opt.Style.Padding
- d, err := NewDraw(DrawOption{
- Parent: p,
- }, PaddingOption(padding))
- if err != nil {
- return chart.BoxZero, err
+// NewTitlePainter returns a title renderer
+func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter {
+ return &titlePainter{
+ p: p,
+ opt: &opt,
}
+}
- r := d.Render
+func (t *titlePainter) Render() (Box, error) {
+ opt := t.opt
+ p := t.p
+ theme := opt.Theme
+
+ if theme == nil {
+ theme = p.theme
+ }
+ if opt.Text == "" && opt.Subtext == "" {
+ return BoxZero, nil
+ }
measureOptions := make([]titleMeasureOption, 0)
+ if opt.Font == nil {
+ opt.Font = theme.GetFont()
+ }
+ if opt.FontColor.IsZero() {
+ opt.FontColor = theme.GetTextColor()
+ }
+ if opt.FontSize == 0 {
+ opt.FontSize = theme.GetFontSize()
+ }
+ if opt.SubtextFontColor.IsZero() {
+ opt.SubtextFontColor = opt.FontColor
+ }
+ if opt.SubtextFontSize == 0 {
+ opt.SubtextFontSize = opt.FontSize
+ }
+
+ titleTextStyle := Style{
+ Font: opt.Font,
+ FontSize: opt.FontSize,
+ FontColor: opt.FontColor,
+ }
// 主标题
for _, v := range splitTitleText(opt.Text) {
measureOptions = append(measureOptions, titleMeasureOption{
text: v,
- style: opt.Style.GetTextOptions(),
+ style: titleTextStyle,
})
}
+ subtextStyle := Style{
+ Font: opt.Font,
+ FontSize: opt.SubtextFontSize,
+ FontColor: opt.SubtextFontColor,
+ }
// 副标题
for _, v := range splitTitleText(opt.Subtext) {
measureOptions = append(measureOptions, titleMeasureOption{
text: v,
- style: opt.SubtextStyle.GetTextOptions(),
+ style: subtextStyle,
})
}
-
textMaxWidth := 0
textMaxHeight := 0
- width := 0
for index, item := range measureOptions {
- item.style.WriteTextOptionsToRenderer(r)
- textBox := r.MeasureText(item.text)
+ p.OverrideTextStyle(item.style)
+ textBox := p.MeasureText(item.text)
w := textBox.Width()
h := textBox.Height()
@@ -116,18 +159,18 @@ func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) {
measureOptions[index].height = h
measureOptions[index].width = w
}
- width = textMaxWidth
+ width := textMaxWidth
+
titleX := 0
- b := d.Box
switch opt.Left {
case PositionRight:
- titleX = b.Width() - textMaxWidth
+ titleX = p.Width() - textMaxWidth
case PositionCenter:
- titleX = b.Width()>>1 - (textMaxWidth >> 1)
+ titleX = p.Width()>>1 - (textMaxWidth >> 1)
default:
if strings.HasSuffix(opt.Left, "%") {
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
- titleX = b.Width() * value / 100
+ titleX = p.Width() * value / 100
} else {
value, _ := strconv.Atoi(opt.Left)
titleX = value
@@ -140,16 +183,15 @@ func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) {
titleY += value
}
for _, item := range measureOptions {
- item.style.WriteTextOptionsToRenderer(r)
+ p.OverrideTextStyle(item.style)
x := titleX + (textMaxWidth-item.width)>>1
y := titleY + item.height
- d.text(item.text, x, y)
+ p.Text(item.text, x, y)
titleY += item.height
}
- height := titleY + padding.Top + padding.Bottom
- box := padding.Clone()
- box.Right = box.Left + titleX + width
- box.Bottom = box.Top + height
- return box, nil
+ return Box{
+ Bottom: titleY,
+ Right: titleX + width,
+ }, nil
}
diff --git a/title_test.go b/title_test.go
index 23573c3..add8163 100644
--- a/title_test.go
+++ b/title_test.go
@@ -26,117 +26,68 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
)
-func TestSplitTitleText(t *testing.T) {
+func TestTitleRenderer(t *testing.T) {
assert := assert.New(t)
-
- assert.Equal([]string{
- "a",
- "b",
- }, splitTitleText("a\nb"))
- assert.Equal([]string{
- "a",
- }, splitTitleText("a\n "))
-}
-
-func TestDrawTitle(t *testing.T) {
- assert := assert.New(t)
-
- newOption := func() *TitleOption {
- f, _ := chart.GetDefaultFont()
- return &TitleOption{
- Text: "title\nHello",
- Subtext: "subtitle\nWorld!",
- Style: chart.Style{
- FontSize: 14,
- Font: f,
- FontColor: drawing.ColorBlack,
- },
- SubtextStyle: chart.Style{
- FontSize: 10,
- Font: f,
- FontColor: drawing.ColorBlue,
- },
- }
- }
- newDraw := func() *Draw {
- d, _ := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- return d
- }
-
tests := []struct {
- newDraw func() *Draw
- newOption func() *TitleOption
- result string
- box chart.Box
+ render func(*Painter) ([]byte, error)
+ result string
}{
{
- newDraw: newDraw,
- newOption: newOption,
- result: "\\ntitle Hello subtitle World! ",
- box: chart.Box{
- Right: 43,
- Bottom: 58,
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewTitlePainter(p, TitleOption{
+ Text: "title",
+ Subtext: "subTitle",
+ Left: "20",
+ Top: "20",
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\ntitle subTitle ",
},
{
- newDraw: newDraw,
- newOption: func() *TitleOption {
- opt := newOption()
- opt.Left = PositionRight
- opt.Top = "50"
- return opt
- },
- result: "\\ntitle Hello subtitle World! ",
- box: chart.Box{
- Right: 400,
- Bottom: 108,
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewTitlePainter(p, TitleOption{
+ Text: "title",
+ Subtext: "subTitle",
+ Left: "20%",
+ Top: "20",
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\ntitle subTitle ",
},
{
- newDraw: newDraw,
- newOption: func() *TitleOption {
- opt := newOption()
- opt.Left = PositionCenter
- opt.Top = "10"
- return opt
- },
- result: "\\ntitle Hello subtitle World! ",
- box: chart.Box{
- Right: 222,
- Bottom: 68,
- },
- },
- {
- newDraw: newDraw,
- newOption: func() *TitleOption {
- opt := newOption()
- opt.Left = "10%"
- opt.Top = "10"
- return opt
- },
- result: "\\ntitle Hello subtitle World! ",
- box: chart.Box{
- Right: 83,
- Bottom: 68,
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewTitlePainter(p, TitleOption{
+ Text: "title",
+ Subtext: "subTitle",
+ Left: PositionRight,
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
},
+ result: "\\ntitle subTitle ",
},
}
for _, tt := range tests {
- d := tt.newDraw()
- o := tt.newOption()
- b, err := drawTitle(d, o)
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
assert.Nil(err)
- assert.Equal(tt.box, b)
- data, err := d.Bytes()
+ data, err := tt.render(p)
assert.Nil(err)
- assert.NotEmpty(data)
assert.Equal(tt.result, string(data))
}
}
diff --git a/util.go b/util.go
index c895cc3..87ff31c 100644
--- a/util.go
+++ b/util.go
@@ -29,8 +29,8 @@ import (
"strings"
"github.com/dustin/go-humanize"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
func TrueFlag() *bool {
@@ -43,6 +43,24 @@ func FalseFlag() *bool {
return &f
}
+func containsInt(values []int, value int) bool {
+ for _, v := range values {
+ if v == value {
+ return true
+ }
+ }
+ return false
+}
+
+func containsString(values []string, value string) bool {
+ for _, v := range values {
+ if v == value {
+ return true
+ }
+ }
+ return false
+}
+
func ceilFloatToInt(value float64) int {
i := int(value)
if value == float64(i) {
@@ -59,28 +77,49 @@ func getDefaultInt(value, defaultValue int) int {
}
func autoDivide(max, size int) []int {
- unit := max / size
+ unit := float64(max) / float64(size)
- rest := max - unit*size
values := make([]int, size+1)
- value := 0
- for i := 0; i < size; i++ {
- values[i] = value
- if i < rest {
- value++
+ for i := 0; i < size+1; i++ {
+ if i == size {
+ values[i] = max
+ } else {
+ values[i] = int(float64(i) * unit)
}
- value += unit
}
- values[size] = max
return values
}
+func autoDivideSpans(max, size int, spans []int) []int {
+ values := autoDivide(max, size)
+ // 重新合并
+ if len(spans) != 0 {
+ newValues := make([]int, len(spans)+1)
+ newValues[0] = 0
+ end := 0
+ for index, v := range spans {
+ end += v
+ newValues[index+1] = values[end]
+ }
+ values = newValues
+ }
+ return values
+}
+
+func sumInt(values []int) int {
+ sum := 0
+ for _, v := range values {
+ sum += v
+ }
+ return sum
+}
+
// measureTextMaxWidthHeight returns maxWidth and maxHeight of text list
-func measureTextMaxWidthHeight(textList []string, r chart.Renderer) (int, int) {
+func measureTextMaxWidthHeight(textList []string, p *Painter) (int, int) {
maxWidth := 0
maxHeight := 0
for _, text := range textList {
- box := r.MeasureText(text)
+ box := p.MeasureText(text)
maxWidth = chart.MaxInt(maxWidth, box.Width())
maxHeight = chart.MaxInt(maxHeight, box.Height())
}
@@ -121,21 +160,31 @@ func NewFloatPoint(f float64) *float64 {
v := f
return &v
}
+
+const K_VALUE = float64(1000)
+const M_VALUE = K_VALUE * K_VALUE
+const G_VALUE = M_VALUE * K_VALUE
+const T_VALUE = G_VALUE * K_VALUE
+
func commafWithDigits(value float64) string {
decimals := 2
- m := float64(1000 * 1000)
- if value >= m {
- return humanize.CommafWithDigits(value/m, decimals) + "M"
+ if value >= T_VALUE {
+ return humanize.CommafWithDigits(value/T_VALUE, decimals) + "T"
}
- k := float64(1000)
- if value >= k {
- return humanize.CommafWithDigits(value/k, decimals) + "k"
+ if value >= G_VALUE {
+ return humanize.CommafWithDigits(value/G_VALUE, decimals) + "G"
+ }
+ if value >= M_VALUE {
+ return humanize.CommafWithDigits(value/M_VALUE, decimals) + "M"
+ }
+ if value >= K_VALUE {
+ return humanize.CommafWithDigits(value/K_VALUE, decimals) + "k"
}
return humanize.CommafWithDigits(value, decimals)
}
-func parseColor(color string) drawing.Color {
- c := drawing.Color{}
+func parseColor(color string) Color {
+ c := Color{}
if color == "" {
return c
}
@@ -213,3 +262,10 @@ func getPolygonPoints(center Point, radius float64, sides int) []Point {
}
return points
}
+
+func isLightColor(c Color) bool {
+ r := float64(c.R) * float64(c.R) * 0.299
+ g := float64(c.G) * float64(c.G) * 0.587
+ b := float64(c.B) * float64(c.B) * 0.114
+ return math.Sqrt(r+g+b) > 127.5
+}
diff --git a/util_test.go b/util_test.go
index 6489ab3..5770776 100644
--- a/util_test.go
+++ b/util_test.go
@@ -26,8 +26,8 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
func TestGetDefaultInt(t *testing.T) {
@@ -60,12 +60,12 @@ func TestAutoDivide(t *testing.T) {
assert.Equal([]int{
0,
- 86,
- 172,
- 258,
- 344,
- 430,
- 515,
+ 85,
+ 171,
+ 257,
+ 342,
+ 428,
+ 514,
600,
}, autoDivide(600, 7))
}
@@ -80,13 +80,15 @@ func TestGetRadius(t *testing.T) {
func TestMeasureTextMaxWidthHeight(t *testing.T) {
assert := assert.New(t)
- r, err := chart.SVG(400, 300)
+ p, err := NewPainter(PainterOptions{
+ Width: 400,
+ Height: 300,
+ })
assert.Nil(err)
style := chart.Style{
FontSize: 10,
}
- style.Font, _ = chart.GetDefaultFont()
- style.WriteToRenderer(r)
+ p.SetStyle(style)
maxWidth, maxHeight := measureTextMaxWidthHeight([]string{
"Mon",
@@ -96,8 +98,8 @@ func TestMeasureTextMaxWidthHeight(t *testing.T) {
"Fri",
"Sat",
"Sun",
- }, r)
- assert.Equal(26, maxWidth)
+ }, p)
+ assert.Equal(31, maxWidth)
assert.Equal(12, maxHeight)
}
@@ -187,3 +189,35 @@ func TestParseColor(t *testing.T) {
A: 250,
}, c)
}
+
+func TestIsLightColor(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.True(isLightColor(drawing.Color{
+ R: 255,
+ G: 255,
+ B: 255,
+ }))
+ assert.True(isLightColor(drawing.Color{
+ R: 145,
+ G: 204,
+ B: 117,
+ }))
+
+ assert.False(isLightColor(drawing.Color{
+ R: 88,
+ G: 112,
+ B: 198,
+ }))
+
+ assert.False(isLightColor(drawing.Color{
+ R: 0,
+ G: 0,
+ B: 0,
+ }))
+ assert.False(isLightColor(drawing.Color{
+ R: 16,
+ G: 12,
+ B: 42,
+ }))
+}
diff --git a/xaxis.go b/xaxis.go
index edd017f..61698d7 100644
--- a/xaxis.go
+++ b/xaxis.go
@@ -24,10 +24,10 @@ package charts
import (
"github.com/golang/freetype/truetype"
- "github.com/wcharczuk/go-chart/v2"
)
type XAxisOption struct {
+ // The font of x axis
Font *truetype.Font
// The boundary gap on both sides of a coordinate axis.
// Nil or *true means the center part of two axis ticks
@@ -35,13 +35,31 @@ type XAxisOption struct {
// The data value of x axis
Data []string
// The theme of chart
- Theme string
- // Hidden x axis
- Hidden bool
+ Theme ColorPalette
+ // The font size of x axis label
+ FontSize float64
+ // The flag for show axis, set this to *false will hide axis
+ Show *bool
// Number of segments that the axis is split into. Note that this number serves only as a recommendation.
SplitNumber int
+ // The position of axis, it can be 'top' or 'bottom'
+ Position string
+ // The line color of axis
+ StrokeColor Color
+ // The color of label
+ FontColor Color
+ // The text rotation of label
+ TextRotation float64
+ // The first axis
+ FirstAxis int
+ // The offset of label
+ LabelOffset Box
+ isValueAxis bool
}
+const defaultXAxisHeight = 30
+
+// NewXAxisOption returns a x axis option
func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption {
opt := XAxisOption{
Data: data,
@@ -52,51 +70,36 @@ func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption {
return opt
}
-// drawXAxis draws x axis, and returns the height, range of if.
-func drawXAxis(p *Draw, opt *XAxisOption, yAxisCount int) (int, *Range, error) {
- if opt.Hidden {
- return 0, nil, nil
+func (opt *XAxisOption) ToAxisOption() AxisOption {
+ position := PositionBottom
+ if opt.Position == PositionTop {
+ position = PositionTop
}
- left := YAxisWidth
- right := (yAxisCount - 1) * YAxisWidth
- dXAxis, err := NewDraw(
- DrawOption{
- Parent: p,
- },
- PaddingOption(chart.Box{
- Left: left,
- Right: right,
- }),
- )
- if opt.Font != nil {
- dXAxis.Font = opt.Font
+ axisOpt := AxisOption{
+ Theme: opt.Theme,
+ Data: opt.Data,
+ BoundaryGap: opt.BoundaryGap,
+ Position: position,
+ SplitNumber: opt.SplitNumber,
+ StrokeColor: opt.StrokeColor,
+ FontSize: opt.FontSize,
+ Font: opt.Font,
+ FontColor: opt.FontColor,
+ Show: opt.Show,
+ SplitLineColor: opt.Theme.GetAxisSplitLineColor(),
+ TextRotation: opt.TextRotation,
+ LabelOffset: opt.LabelOffset,
+ FirstAxis: opt.FirstAxis,
}
- if err != nil {
- return 0, nil, err
+ if opt.isValueAxis {
+ axisOpt.SplitLineShow = true
+ axisOpt.StrokeWidth = -1
+ axisOpt.BoundaryGap = FalseFlag()
}
- theme := NewTheme(opt.Theme)
- data := NewAxisDataListFromStringList(opt.Data)
- style := AxisOption{
- BoundaryGap: opt.BoundaryGap,
- StrokeColor: theme.GetAxisStrokeColor(),
- FontColor: theme.GetAxisStrokeColor(),
- StrokeWidth: 1,
- SplitNumber: opt.SplitNumber,
- }
-
- boundary := true
- max := float64(len(opt.Data))
- if isFalse(opt.BoundaryGap) {
- boundary = false
- max--
- }
- axis := NewAxis(dXAxis, data, style)
- axis.Render()
- return axis.measure().Height, &Range{
- divideCount: len(opt.Data),
- Min: 0,
- Max: max,
- Size: dXAxis.Box.Width(),
- Boundary: boundary,
- }, nil
+ return axisOpt
+}
+
+// NewBottomXAxis returns a bottom x axis renderer
+func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter {
+ return NewAxisPainter(p, opt.ToAxisOption())
}
diff --git a/xaxis_test.go b/xaxis_test.go
deleted file mode 100644
index 267cdb1..0000000
--- a/xaxis_test.go
+++ /dev/null
@@ -1,108 +0,0 @@
-// MIT License
-
-// Copyright (c) 2022 Tree Xie
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-package charts
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestNewXAxisOption(t *testing.T) {
- assert := assert.New(t)
-
- opt := NewXAxisOption([]string{
- "a",
- "b",
- }, FalseFlag())
-
- assert.Equal(XAxisOption{
- Data: []string{
- "a",
- "b",
- },
- BoundaryGap: FalseFlag(),
- }, opt)
-
-}
-func TestDrawXAxis(t *testing.T) {
- assert := assert.New(t)
-
- newDraw := func() *Draw {
- d, _ := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- return d
- }
-
- tests := []struct {
- newDraw func() *Draw
- newOption func() *XAxisOption
- result string
- }{
- {
- newDraw: newDraw,
- newOption: func() *XAxisOption {
- return &XAxisOption{
- BoundaryGap: FalseFlag(),
- Data: []string{
- "Mon",
- "Tue",
- },
- }
- },
- result: "\\nMon Tue ",
- },
- {
- 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-02 01-05 01-08 ",
- },
- }
-
- for _, tt := range tests {
- d := tt.newDraw()
- height, _, err := drawXAxis(d, tt.newOption(), 1)
- assert.Nil(err)
- assert.Equal(25, height)
- data, err := d.Bytes()
- assert.Nil(err)
- assert.Equal(tt.result, string(data))
- }
-}
diff --git a/yaxis.go b/yaxis.go
index a14e409..e58b7a6 100644
--- a/yaxis.go
+++ b/yaxis.go
@@ -22,84 +22,107 @@
package charts
-import (
- "strings"
-
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
-)
+import "github.com/golang/freetype/truetype"
type YAxisOption struct {
// The minimun value of axis.
Min *float64
// The maximum value of axis.
Max *float64
- // Hidden y axis
- Hidden bool
+ // The font of y axis
+ Font *truetype.Font
+ // The data value of x axis
+ Data []string
+ // The theme of chart
+ Theme ColorPalette
+ // The font size of x axis label
+ FontSize float64
+ // The position of axis, it can be 'left' or 'right'
+ Position string
+ // The color of label
+ FontColor Color
// Formatter for y axis text value
Formatter string
// Color for y axis
- Color drawing.Color
+ Color Color
+ // The flag for show axis, set this to *false will hide axis
+ Show *bool
+ DivideCount int
+ Unit int
+ isCategoryAxis bool
+ // The flag for show axis split line, set this to true will show axis split line
+ SplitLineShow *bool
}
-// TODO 长度是否可以变化
-const YAxisWidth = 40
-
-func drawYAxis(p *Draw, opt *ChartOption, axisIndex, xAxisHeight int, padding chart.Box) (*Range, error) {
- theme := NewTheme(opt.Theme)
- yRange := opt.newYRange(axisIndex)
- values := yRange.Values()
- yAxis := opt.YAxisList[axisIndex]
- formatter := yAxis.Formatter
- if len(formatter) != 0 {
- for index, text := range values {
- values[index] = strings.ReplaceAll(formatter, "{value}", text)
- }
+// NewYAxisOptions returns a y axis option
+func NewYAxisOptions(data []string, others ...[]string) []YAxisOption {
+ arr := [][]string{
+ data,
}
+ arr = append(arr, others...)
+ opts := make([]YAxisOption, 0)
+ for _, data := range arr {
+ opts = append(opts, YAxisOption{
+ Data: data,
+ })
+ }
+ return opts
+}
- data := NewAxisDataListFromStringList(values)
- style := AxisOption{
- Position: PositionLeft,
+func (opt *YAxisOption) ToAxisOption(p *Painter) AxisOption {
+ position := PositionLeft
+ if opt.Position == PositionRight {
+ position = PositionRight
+ }
+ theme := opt.Theme
+ if theme == nil {
+ theme = p.theme
+ }
+ axisOpt := AxisOption{
+ Formatter: opt.Formatter,
+ Theme: theme,
+ Data: opt.Data,
+ Position: position,
+ FontSize: opt.FontSize,
+ StrokeWidth: -1,
+ Font: opt.Font,
+ FontColor: opt.FontColor,
BoundaryGap: FalseFlag(),
- FontColor: theme.GetAxisStrokeColor(),
- TickShow: FalseFlag(),
- StrokeWidth: 1,
- SplitLineColor: theme.GetAxisSplitLineColor(),
SplitLineShow: true,
+ SplitLineColor: theme.GetAxisSplitLineColor(),
+ Show: opt.Show,
+ Unit: opt.Unit,
}
- if !yAxis.Color.IsZero() {
- style.FontColor = yAxis.Color
- style.StrokeColor = yAxis.Color
+ if !opt.Color.IsZero() {
+ axisOpt.FontColor = opt.Color
+ axisOpt.StrokeColor = opt.Color
}
- width := NewAxis(p, data, style).measure().Width
-
- yAxisCount := len(opt.YAxisList)
- boxWidth := p.Box.Width()
- if axisIndex > 0 {
- style.SplitLineShow = false
- style.Position = PositionRight
- padding.Right += (axisIndex - 1) * YAxisWidth
- } else {
- boxWidth = p.Box.Width() - (yAxisCount-1)*YAxisWidth
- padding.Left += (YAxisWidth - width)
+ if opt.isCategoryAxis {
+ axisOpt.BoundaryGap = TrueFlag()
+ axisOpt.StrokeWidth = 1
+ axisOpt.SplitLineShow = false
}
-
- dYAxis, err := NewDraw(
- DrawOption{
- Parent: p,
- Width: boxWidth,
- // 减去x轴的高
- Height: p.Box.Height() - xAxisHeight,
- },
- PaddingOption(padding),
- )
- if err != nil {
- return nil, err
+ if opt.SplitLineShow != nil {
+ axisOpt.SplitLineShow = *opt.SplitLineShow
}
- if opt.Font != nil {
- dYAxis.Font = opt.Font
- }
- NewAxis(dYAxis, data, style).Render()
- yRange.Size = dYAxis.Box.Height()
- return &yRange, nil
+ return axisOpt
+}
+
+// NewLeftYAxis returns a left y axis renderer
+func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter {
+ p = p.Child(PainterPaddingOption(Box{
+ Bottom: defaultXAxisHeight,
+ }))
+ return NewAxisPainter(p, opt.ToAxisOption(p))
+}
+
+// NewRightYAxis returns a right y axis renderer
+func NewRightYAxis(p *Painter, opt YAxisOption) *axisPainter {
+ p = p.Child(PainterPaddingOption(Box{
+ Bottom: defaultXAxisHeight,
+ }))
+ axisOpt := opt.ToAxisOption(p)
+ axisOpt.Position = PositionRight
+ axisOpt.SplitLineShow = false
+ return NewAxisPainter(p, axisOpt)
}
diff --git a/yaxis_test.go b/yaxis_test.go
index 0bbef7a..0f565ac 100644
--- a/yaxis_test.go
+++ b/yaxis_test.go
@@ -26,93 +26,44 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
)
-func TestDrawYAxis(t *testing.T) {
+func TestRightYAxis(t *testing.T) {
assert := assert.New(t)
- newDraw := func() *Draw {
- d, _ := NewDraw(DrawOption{
- Width: 400,
- Height: 300,
- })
- return d
- }
-
tests := []struct {
- newDraw func() *Draw
- newOption func() *ChartOption
- axisIndex int
- xAxisHeight int
- result string
+ render func(*Painter) ([]byte, error)
+ result string
}{
{
- newDraw: newDraw,
- newOption: func() *ChartOption {
- return &ChartOption{
- YAxisList: []YAxisOption{
- {
- Max: NewFloatPoint(20),
- },
- },
- SeriesList: []Series{
- {
- Data: []SeriesData{
- {
- Value: 1,
- },
- {
- Value: 2,
- },
- },
- },
- },
+ render: func(p *Painter) ([]byte, error) {
+ opt := NewYAxisOptions([]string{
+ "a",
+ "b",
+ "c",
+ "d",
+ })[0]
+ _, err := NewRightYAxis(p, opt).Render()
+ if err != nil {
+ return nil, err
}
+ return p.Bytes()
},
- result: "\\n0 3.33 6.66 10 13.33 16.66 20 ",
- },
- {
- 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 C 3.33 C 6.66 C 10 C 13.33 C 16.66 C 20 C ",
+ result: "\\na b c d ",
},
}
-
for _, tt := range tests {
- d := tt.newDraw()
- r, err := drawYAxis(d, tt.newOption(), tt.axisIndex, tt.xAxisHeight, chart.NewBox(10, 10, 10, 10))
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme), PainterPaddingOption(Box{
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ }))
assert.Nil(err)
- assert.Equal(&Range{
- divideCount: 6,
- Max: 20,
- Size: 280,
- }, r)
-
- data, err := d.Bytes()
+ data, err := tt.render(p)
assert.Nil(err)
assert.Equal(tt.result, string(data))
}