diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ebc9a02..ce56fe7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -14,11 +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 2ac8a25..57206ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,7 @@
# Dependency directories (remove the comment below to include it)
# vendor/
*.png
-*.svg
\ No newline at end of file
+*.svg
+tmp
+NotoSansSC.ttf
+.vscode
\ No newline at end of file
diff --git a/README.md b/README.md
index 2486162..0650395 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,457 @@
# 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)
-`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart)生成数据图表,支持`svg`与`png`的输出,`Apache ECharts`在前端开发中得到众多开发者的认可,`go-charts`兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的几种图表截图(黑夜模式):
+[中文](./README_zh.md)
-
+`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. The default format is `png` and the default theme is `light`.
-## 支持图表类型
+`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`.
-暂仅支持三种的图表类型:`line`, `bar` 以及 `pie`
+Screenshot of common charts, the left part is light theme, the right part is grafana theme.
+
+
+
+
+
+
+
+
+
+## Chart Type
+
+These chart types are supported: `line`, `bar`, `horizontal bar`, `pie`, `radar` or `funnel` and `table`.
+
+## Example
+
+More examples can be found in the [./examples/](./examples/) directory.
-## 示例
+### Line Chart
+```go
+package main
-`go-charts`兼容了`echarts`的参数配置,可简单的使用json形式的配置字符串则可快速生成图表。
+import (
+ charts "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func main() {
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ {
+ // snip...
+ },
+ {
+ // snip...
+ },
+ {
+ // snip...
+ },
+ {
+ // snip...
+ },
+ }
+ 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",
+ }, charts.PositionCenter),
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ // snip...
+}
+```
+
+### Bar Chart
```go
package main
import (
- "os"
+ "git.smarteching.com/zeni/go-charts/v2"
+)
- charts "github.com/vicanso/go-charts"
+func main() {
+ values := [][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ // snip...
+ },
+ }
+ p, err := charts.BarRender(
+ values,
+ charts.XAxisDataOptionFunc([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "Rainfall",
+ "Evaporation",
+ }, charts.PositionRight),
+ charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage),
+ charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin),
+ // custom option func
+ func(opt *charts.ChartOption) {
+ opt.SeriesList[1].MarkPoint = charts.NewMarkPoint(
+ charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin,
+ )
+ opt.SeriesList[1].MarkLine = charts.NewMarkLine(
+ charts.SeriesMarkDataTypeAverage,
+ )
+ },
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ 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() {
@@ -31,7 +460,6 @@ func main() {
"text": "Line"
},
"xAxis": {
- "type": "category",
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
},
"series": [
@@ -40,43 +468,75 @@ func main() {
}
]
}`)
- if err != nil {
- panic(err)
- }
- os.WriteFile("output.png", buf, 0600)
+ // snip...
}
```
-## 参数说明
+## ECharts Option
-- `theme` 颜色主题,支持`dark`与`light`模式,默认为`light`
-- `padding` 图表的内边距,单位px。支持以下几种模式的设置
- - `padding: 5` 设置内边距为5
- - `padding: [5, 10]` 设置上下的内边距为 5,左右的内边距为 10
- - `padding:[5, 10, 5, 10]` 分别设置`上右下左`边距
-- `title` 图表标题,包括标题内容、高度、颜色等
- - `title.text` 标题内容
- - `title.textStyle.color` 标题文字颜色
- - `title.textStyle.fontSize` 标题文字字体大小
- - `title.textStyle.height` 标题高度
-- `xAxis` 直角坐标系grid中的x轴,由于go-charts仅支持单一个x轴,因此若参数为数组多个x轴,只使用第一个配置
- - `xAxis.boundaryGap` 坐标轴两边留白策略,仅支持三种设置方式`null`, `true`或者`false`。`null`或`true`时则数据点展示在两个刻度中间
- - `xAxis.splitNumber` 坐标轴的分割段数,需要注意的是这个分割段数只是个预估值,最后实际显示的段数会在这个基础上根据分割后坐标轴刻度显示的易读程度作调整
- - `xAxis.data` x轴的展示文案,暂只支持字符串数组,如["Mon", "Tue"],其数量需要与展示点一致
-- `yAxis` 直角坐标系grid中的y轴,最多支持两个y轴
- - `yAxis.min` 坐标轴刻度最小值,若不设置则自动计算
- - `yAxis.max` 坐标轴刻度最大值,若不设置则自动计算
- - `yAxis.axisLabel.formatter` 刻度标签的内容格式器,如`"formatter": "{value} kg"`
-- `legend` 图表中不同系列的标记
- - `legend.data` 图例的数据数组,为字符串数组,如["Email", "Video Ads"]
- - `legend.align` 图例标记和文本的对齐,默认为标记靠左`left`
- - `legend.padding` legend的padding,配置方式与图表的`padding`一致
- - `legend.left` legend离容器左侧的距离,其值可以为具体的像素值(20)或百分比(20%)
- - `legend.right` legend离容器右侧的距离,其值可以为具体的像素值(20)或百分比(20%)
-- `series` 图表的数据项列表
- - `series.type` 图表的展示类型,暂支持`line`, `bar`以及`pie`,需要注意`pie`只能单独使用
- - `series.yAxisIndex` 该数据项使用的y轴,默认为0,对yAxis的配置对应
- - `series.itemStyle.color` 该数据项展示时使用的颜色
- - `series.data` 数据项对应的数据数组,支持以下形式的数据:
- - `数值` 常用形式,数组数据为浮点数组,如[1.1, 2,3, 5.2]
- - `结构体` pie图表或bar图表中指定样式使用,如[{"value": 1048, "name": "Search Engine"},{"value": 735,"name": "Direct"}]
\ No newline at end of file
+The name with `[]` is new parameter, others are the same as `echarts`.
+
+- `[type]` The canvas type, support `svg` and `png`, default is `svg`
+- `[theme]` The theme, support `dark`, `light` and `grafana`, default is `light`
+- `[fontFamily]` The font family for chart
+- `[padding]` The padding of chart
+- `[box]` The canvas box of chart
+- `[width]` The width of chart
+- `[height]` The height of chart
+- `title` Title component, including main title and subtitle
+ - `title.text` The main title text, supporting for \n for newlines
+ - `title.subtext`Subtitle text, supporting for \n for newlines
+ - `title.left` Distance between title component and the left side of the container. Left value can be instant pixel value like 20; it can also be a percentage value relative to container width like '20%'; and it can also be 'left', 'center', or 'right'.
+ - `title.top` Distance between title component and the top side of the container. Top value can be instant pixel value like 20
+ - `title.textStyle.color` Text color for title
+ - `title.textStyle.fontSize` Text font size for title
+ - `title.textStyle.fontFamily` Text font family for title, it will change the font family for chart
+- `xAxis` The x axis in cartesian(rectangular) coordinate. `go-charts` only support one x axis.
+ - `xAxis.boundaryGap` The boundary gap on both sides of a coordinate axis. The setting and behavior of category axes and non-category axes are different. If set `null` or `true`, the label appear in the center part of two axis ticks.
+ - `xAxis.splitNumber` Number of segments that the axis is split into. Note that this number serves only as a recommendation, and the true segments may be adjusted based on readability
+ - `xAxis.data` Category data, only support string array.
+- `yAxis` The y axis in cartesian(rectangular) coordinate, it support 2 y axis
+ - `yAxis.min` The minimum value of axis. It will be automatically computed to make sure axis tick is equally distributed when not set
+ - `yAxis.max` The maximum value of axis. It will be automatically computed to make sure axis tick is equally distributed when not se.
+ - `yAxis.axisLabel.formatter` Formatter of axis label, which supports string template: `"formatter": "{value} kg"`
+ - `yAxis.axisLine.lineStyle.color` The color for line
+- `legend` Legend component
+ - `legend.show` Whether to show legend
+ - `legend.data` Data array of legend, only support string array: ["Email", "Video Ads"]
+ - `legend.align` Legend marker and text aligning. Support `left` and `right`, default is `left`
+ - `legend.padding` legend space around content
+ - `legend.left` Distance between legend component and the left side of the container. Left value can be instant pixel value like 20; it can also be a percentage value relative to container width like '20%'; and it can also be 'left', 'center', or 'right'.
+ - `legend.top` Distance between legend component and the top side of the container. Top value can be instant pixel value like 20
+- `radar` Coordinate for radar charts
+ - `radar.indicator` Indicator of radar chart, which is used to assign multiple variables(dimensions) in radar chart
+ - `radar.indicator.name` Indicator's name
+ - `radar.indicator.max` The maximum value of indicator
+ - `radar.indicator.min` The minimum value of indicator, default value is 0.
+- `series` The series for chart
+ - `series.name` Series name used for displaying in legend.
+ - `series.type` Series type: `line`, `bar`, `pie`, `radar` or `funnel`
+ - `series.radius` Radius of Pie chart:`50%`, default is `40%`
+ - `series.yAxisIndex` Index of y axis to combine with, which is useful for multiple y axes in one chart
+ - `series.label.show` Whether to show label
+ - `series.label.distance` Distance to the host graphic element
+ - `series.label.color` Label color
+ - `series.itemStyle.color` Color for the series's item
+ - `series.markPoint` Mark point in a chart.
+ - `series.markPoint.symbolSize` Symbol size, default is `30`
+ - `series.markPoint.data` Data array for mark points, each of which is an object and the type only support `max` and `min`: `[{"type": "max"}, {"type": "min"}]`
+ - `series.markLine` Mark line in a chart
+ - `series.markPoint.data` Data array for mark points, each of which is an object and the type only support `max`, `min` and `average`: `[{"type": "max"}, {"type": "min"}, {"type": "average"}]``
+ - `series.data` Data array of series, which can be in the following forms:
+ - `value` It's a float array: [1.1, 2,3, 5.2]
+ - `object` It's a object value array: [{"value": 1048, "name": "Search Engine"},{"value": 735,"name": "Direct"}]
+- `[children]` The options of children chart
+
+
+## Performance
+
+Generate a png chart will be less than 20ms. It's better than using `chrome headless` with `echarts`.
+
+```bash
+BenchmarkMultiChartPNGRender-8 78 15216336 ns/op 2298308 B/op 1148 allocs/op
+BenchmarkMultiChartSVGRender-8 367 3356325 ns/op 20597282 B/op 3088 allocs/op
+```
diff --git a/README_zh.md b/README_zh.md
new file mode 100644
index 0000000..3f35b97
--- /dev/null
+++ b/README_zh.md
@@ -0,0 +1,576 @@
+# go-charts
+
+[](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`。默认的输入格式为`png`,默认主题为`light`。
+
+`Apache ECharts`在前端开发中得到众多开发者的认可,因此`go-charts`提供了兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的图表截图(主题为light与grafana):
+
+
+
+
+
+
+
+
+
result {
- result = v
+type AxisOption struct {
+ // The theme of chart
+ Theme ColorPalette
+ // Formatter for y axis text value
+ Formatter string
+ // The label of axis
+ Data []string
+ // The boundary gap on both sides of a coordinate axis.
+ // Nil or *true means the center part of two axis ticks
+ BoundaryGap *bool
+ // The flag for show axis, set this to *false will hide axis
+ Show *bool
+ // The position of axis, it can be 'left', 'top', 'right' or 'bottom'
+ Position string
+ // Number of segments that the axis is split into. Note that this number serves only as a recommendation.
+ SplitNumber int
+ // The line color of axis
+ StrokeColor Color
+ // The line width
+ StrokeWidth float64
+ // The length of the axis tick
+ TickLength int
+ // The first axis
+ FirstAxis int
+ // The margin value of label
+ LabelMargin int
+ // The font size of label
+ FontSize float64
+ // The font of label
+ Font *truetype.Font
+ // The color of label
+ FontColor Color
+ // The flag for show axis split line, set this to true will show axis split line
+ SplitLineShow bool
+ // The color of split line
+ SplitLineColor Color
+ // The text rotation of label
+ TextRotation float64
+ // The offset of label
+ LabelOffset Box
+ Unit int
+}
+
+func (a *axisPainter) Render() (Box, error) {
+ opt := a.opt
+ top := a.p
+ theme := opt.Theme
+ if theme == nil {
+ theme = top.theme
+ }
+ if isFalse(opt.Show) {
+ return BoxZero, nil
+ }
+
+ strokeWidth := opt.StrokeWidth
+ if strokeWidth == 0 {
+ strokeWidth = 1
+ }
+
+ font := opt.Font
+ if font == nil {
+ font = a.p.font
+ }
+ if font == nil {
+ font = theme.GetFont()
+ }
+ fontColor := opt.FontColor
+ if fontColor.IsZero() {
+ fontColor = theme.GetTextColor()
+ }
+ fontSize := opt.FontSize
+ if fontSize == 0 {
+ fontSize = theme.GetFontSize()
+ }
+ strokeColor := opt.StrokeColor
+ if strokeColor.IsZero() {
+ strokeColor = theme.GetAxisStrokeColor()
+ }
+
+ data := opt.Data
+ formatter := opt.Formatter
+ if len(formatter) != 0 {
+ for index, text := range data {
+ data[index] = strings.ReplaceAll(formatter, "{value}", text)
}
}
- return result
-}
+ dataCount := len(data)
+ tickCount := dataCount
-// GetXAxisAndValues returns x axis by theme, and the values of axis.
-func GetXAxisAndValues(xAxis XAxis, tickPosition chart.TickPosition, theme string) (chart.XAxis, []float64) {
- data := xAxis.Data
- originalSize := len(data)
- // 如果居中,则需要多添加一个值
- if tickPosition == chart.TickPositionBetweenTicks {
- data = append([]string{
- "",
- }, data...)
+ boundaryGap := true
+ if isFalse(opt.BoundaryGap) {
+ boundaryGap = false
+ }
+ isVertical := opt.Position == PositionLeft ||
+ opt.Position == PositionRight
+
+ labelPosition := ""
+ if !boundaryGap {
+ tickCount--
+ labelPosition = PositionLeft
+ }
+ if isVertical && boundaryGap {
+ labelPosition = PositionCenter
}
- size := len(data)
+ // 如果小于0,则表示不处理
+ tickLength := getDefaultInt(opt.TickLength, 5)
+ labelMargin := getDefaultInt(opt.LabelMargin, 5)
- xValues := make([]float64, size)
- ticks := make([]chart.Tick, 0)
-
- // tick width
- maxTicks := maxInt(xAxis.SplitNumber, 10)
-
- // 计息最多每个unit至少放多个
- minUnitSize := originalSize / maxTicks
- if originalSize%maxTicks != 0 {
- minUnitSize++
+ style := Style{
+ StrokeColor: strokeColor,
+ StrokeWidth: strokeWidth,
+ Font: font,
+ FontColor: fontColor,
+ FontSize: fontSize,
}
- unitSize := minUnitSize
- // 尽可能选择一格展示更多的块
- for i := minUnitSize; i < 2*minUnitSize; i++ {
- if originalSize%i == 0 {
- unitSize = i
+ top.SetDrawingStyle(style).OverrideTextStyle(style)
+
+ isTextRotation := opt.TextRotation != 0
+
+ if isTextRotation {
+ top.SetTextRotation(opt.TextRotation)
+ }
+ textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(data)
+ if isTextRotation {
+ top.ClearTextRotation()
+ }
+
+ // 增加30px来计算文本展示区域
+ textFillWidth := float64(textMaxWidth + 20)
+ // 根据文本宽度计算较为符合的展示项
+ fitTextCount := ceilFloatToInt(float64(top.Width()) / textFillWidth)
+
+ unit := opt.Unit
+ if unit <= 0 {
+
+ unit = ceilFloatToInt(float64(dataCount) / float64(fitTextCount))
+ unit = chart.MaxInt(unit, opt.SplitNumber)
+ // 偶数
+ if unit%2 == 0 && dataCount%(unit+1) == 0 {
+ unit++
}
}
- for index, key := range data {
- f := float64(index)
- xValues[index] = f
- if index%unitSize == 0 || index == size-1 {
- ticks = append(ticks, chart.Tick{
- Value: f,
- Label: key,
- })
+ width := 0
+ height := 0
+ // 垂直
+ if isVertical {
+ width = textMaxWidth + tickLength<<1
+ height = top.Height()
+ } else {
+ width = top.Width()
+ height = tickLength<<1 + textMaxHeight
+ }
+ padding := Box{}
+ switch opt.Position {
+ case PositionTop:
+ padding.Top = top.Height() - height
+ case PositionLeft:
+ padding.Right = top.Width() - width
+ case PositionRight:
+ padding.Left = top.Width() - width
+ default:
+ padding.Top = top.Height() - defaultXAxisHeight
+ }
+
+ p := top.Child(PainterPaddingOption(padding))
+
+ x0 := 0
+ y0 := 0
+ x1 := 0
+ y1 := 0
+ ticksPaddingTop := 0
+ ticksPaddingLeft := 0
+ labelPaddingTop := 0
+ labelPaddingLeft := 0
+ labelPaddingRight := 0
+ orient := ""
+ textAlign := ""
+
+ switch opt.Position {
+ case PositionTop:
+ labelPaddingTop = 0
+ x1 = p.Width()
+ y0 = labelMargin + int(opt.FontSize)
+ ticksPaddingTop = int(opt.FontSize)
+ y1 = y0
+ orient = OrientHorizontal
+ case PositionLeft:
+ x0 = p.Width()
+ y0 = 0
+ x1 = p.Width()
+ y1 = p.Height()
+ orient = OrientVertical
+ textAlign = AlignRight
+ ticksPaddingLeft = textMaxWidth + tickLength
+ labelPaddingRight = width - textMaxWidth
+ case 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
+ x1 = top.Width() - p.Width()
+ }
+ 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,
+ },
+ })
+ }
+ } else {
+ y0 := p.Height() - defaultXAxisHeight
+ y1 := top.Height() - defaultXAxisHeight
+ for index, x := range autoDivide(width, tickCount) {
+ if index == 0 {
+ continue
+ }
+ top.LineStroke([]Point{
+ {
+ X: x,
+ Y: y0,
+ },
+ {
+ X: x,
+ Y: y1,
+ },
+ })
+ }
}
}
- return chart.XAxis{
- Ticks: ticks,
- TickPosition: tickPosition,
- Style: chart.Style{
- FontColor: getAxisColor(theme),
- StrokeColor: getAxisColor(theme),
- StrokeWidth: axisStrokeWidth,
- },
- }, xValues
-}
-func defaultFloatFormater(v interface{}) string {
- value, ok := v.(float64)
- if !ok {
- return ""
- }
- // 大于10的则直接取整展示
- if value >= 10 {
- return humanize.CommafWithDigits(value, 0)
- }
- return humanize.CommafWithDigits(value, 2)
-}
-
-func newYContinuousRange(option *YAxisOption) *YContinuousRange {
- m := YContinuousRange{}
- m.Min = -math.MaxFloat64
- m.Max = math.MaxFloat64
- if option != nil {
- if option.Min != nil {
- m.Min = *option.Min
- }
- if option.Max != nil {
- m.Max = *option.Max
- }
- }
- return &m
-}
-
-// GetSecondaryYAxis returns the secondary y axis by theme
-func GetSecondaryYAxis(theme string, option *YAxisOption) chart.YAxis {
- strokeColor := getGridColor(theme)
- yAxis := chart.YAxis{
- Range: newYContinuousRange(option),
- ValueFormatter: defaultFloatFormater,
- AxisType: chart.YAxisSecondary,
- GridMajorStyle: chart.Style{
- StrokeColor: strokeColor,
- StrokeWidth: axisStrokeWidth,
- },
- GridMinorStyle: chart.Style{
- StrokeColor: strokeColor,
- StrokeWidth: axisStrokeWidth,
- },
- Style: chart.Style{
- FontColor: getAxisColor(theme),
- // alpha 0,隐藏
- StrokeColor: hiddenColor,
- StrokeWidth: axisStrokeWidth,
- },
- }
- setYAxisOption(&yAxis, option)
- return yAxis
-}
-
-func setYAxisOption(yAxis *chart.YAxis, option *YAxisOption) {
- if option == nil {
- return
- }
- if option.Formater != nil {
- yAxis.ValueFormatter = option.Formater
- }
-}
-
-// GetYAxis returns the primary y axis by theme
-func GetYAxis(theme string, option *YAxisOption) chart.YAxis {
- disabled := false
- if option != nil {
- disabled = option.Disabled
- }
- hidden := chart.Hidden()
-
- yAxis := chart.YAxis{
- Range: newYContinuousRange(option),
- ValueFormatter: defaultFloatFormater,
- AxisType: chart.YAxisPrimary,
- GridMajorStyle: hidden,
- GridMinorStyle: hidden,
- Style: chart.Style{
- FontColor: getAxisColor(theme),
- // alpha 0,隐藏
- StrokeColor: hiddenColor,
- StrokeWidth: axisStrokeWidth,
- },
- }
- // 如果禁用,则默认为隐藏,并设置range
- if disabled {
- yAxis.Range = &HiddenRange{}
- yAxis.Style.Hidden = true
- }
- setYAxisOption(&yAxis, option)
- return yAxis
+ return Box{
+ Bottom: height,
+ Right: width,
+ }, nil
}
diff --git a/axis_test.go b/axis_test.go
index cc50864..85e18ca 100644
--- a/axis_test.go
+++ b/axis_test.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -23,150 +23,151 @@
package charts
import (
- "strconv"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
-func TestGetXAxisAndValues(t *testing.T) {
+func TestAxis(t *testing.T) {
assert := assert.New(t)
-
- genLabels := func(count int) []string {
- arr := make([]string, count)
- for i := 0; i < count; i++ {
- arr[i] = strconv.Itoa(i)
- }
- return arr
- }
- genValues := func(count int, betweenTicks bool) []float64 {
- if betweenTicks {
- count++
- }
- arr := make([]float64, count)
- for i := 0; i < count; i++ {
- arr[i] = float64(i)
- }
- return arr
- }
- genTicks := func(count int, betweenTicks bool) []chart.Tick {
- arr := make([]chart.Tick, 0)
- offset := 0
- if betweenTicks {
- offset = 1
- arr = append(arr, chart.Tick{})
- }
- for i := 0; i < count; i++ {
- arr = append(arr, chart.Tick{
- Value: float64(i + offset),
- Label: strconv.Itoa(i),
- })
- }
- return arr
- }
-
tests := []struct {
- xAxis XAxis
- tickPosition chart.TickPosition
- theme string
- result chart.XAxis
- values []float64
+ render func(*Painter) ([]byte, error)
+ result string
}{
+ // 底部x轴
{
- xAxis: XAxis{
- Data: genLabels(5),
- },
- values: genValues(5, false),
- result: chart.XAxis{
- Ticks: genTicks(5, false),
+ 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 ",
},
- // 居中
+ // 底部x轴文本居左
{
- xAxis: XAxis{
- Data: genLabels(5),
- },
- tickPosition: chart.TickPositionBetweenTicks,
- // 居中因此value多一个
- values: genValues(5, true),
- result: chart.XAxis{
- Ticks: genTicks(5, true),
+ 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 ",
},
+ // 左侧y轴
{
- xAxis: XAxis{
- Data: genLabels(20),
+ render: func(p *Painter) ([]byte, error) {
+ _, _ = NewAxisPainter(p, AxisOption{
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ Position: PositionLeft,
+ }).Render()
+ return p.Bytes()
},
- // 居中因此value多一个
- values: genValues(20, false),
- result: chart.XAxis{
- Ticks: []chart.Tick{
- {Value: 0, Label: "0"}, {Value: 2, Label: "2"}, {Value: 4, Label: "4"}, {Value: 6, Label: "6"}, {Value: 8, Label: "8"}, {Value: 10, Label: "10"}, {Value: 12, Label: "12"}, {Value: 14, Label: "14"}, {Value: 16, Label: "16"}, {Value: 18, Label: "18"}, {Value: 19, Label: "19"}},
+ result: "\\nMon Tue Wed Thu Fri Sat Sun ",
+ },
+ // 左侧y轴居中
+ {
+ 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 ",
+ },
+ // 右侧
+ {
+ 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 ",
+ },
+ // 顶部
+ {
+ 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 -- ",
},
}
for _, tt := range tests {
- xAxis, values := GetXAxisAndValues(tt.xAxis, tt.tickPosition, tt.theme)
-
- assert.Equal(tt.result.Ticks, xAxis.Ticks)
- assert.Equal(tt.tickPosition, xAxis.TickPosition)
- assert.Equal(tt.values, values)
+ 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))
}
}
-
-func TestDefaultFloatFormater(t *testing.T) {
- assert := assert.New(t)
-
- assert.Equal("", defaultFloatFormater(1))
-
- assert.Equal("0.1", defaultFloatFormater(0.1))
- assert.Equal("0.12", defaultFloatFormater(0.123))
- assert.Equal("10", defaultFloatFormater(10.1))
-}
-
-func TestSetYAxisOption(t *testing.T) {
- assert := assert.New(t)
- min := 10.0
- max := 20.0
- opt := &YAxisOption{
- Formater: func(v interface{}) string {
- return ""
- },
- Min: &min,
- Max: &max,
- }
- yAxis := &chart.YAxis{
- Range: newYContinuousRange(opt),
- }
- setYAxisOption(yAxis, opt)
-
- assert.NotEmpty(yAxis.ValueFormatter)
- assert.Equal(max, yAxis.Range.GetMax())
- assert.Equal(min, yAxis.Range.GetMin())
-}
-
-func TestGetYAxis(t *testing.T) {
- assert := assert.New(t)
-
- yAxis := GetYAxis(ThemeDark, nil)
-
- assert.True(yAxis.GridMajorStyle.Hidden)
- assert.True(yAxis.GridMajorStyle.Hidden)
- assert.False(yAxis.Style.Hidden)
-
- yAxis = GetYAxis(ThemeDark, &YAxisOption{
- Disabled: true,
- })
-
- assert.True(yAxis.GridMajorStyle.Hidden)
- assert.True(yAxis.GridMajorStyle.Hidden)
- assert.True(yAxis.Style.Hidden)
-
- // secondary yAxis
- yAxis = GetSecondaryYAxis(ThemeDark, nil)
- assert.False(yAxis.GridMajorStyle.Hidden)
- assert.False(yAxis.GridMajorStyle.Hidden)
- assert.True(yAxis.Style.StrokeColor.IsZero())
-}
diff --git a/bar_chart.go b/bar_chart.go
new file mode 100644
index 0000000..043e044
--- /dev/null
+++ b/bar_chart.go
@@ -0,0 +1,253 @@
+// 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 (
+ "math"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type barChart struct {
+ p *Painter
+ opt *BarChartOption
+}
+
+// NewBarChart returns a bar chart renderer
+func NewBarChart(p *Painter, opt BarChartOption) *barChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &barChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type BarChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ 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
+ margin := 10
+ // 每一个bar之间的margin
+ barMargin := 5
+ if width < 20 {
+ margin = 2
+ barMargin = 2
+ } else if width < 50 {
+ margin = 5
+ barMargin = 3
+ }
+ if opt.BarMargin > 0 {
+ barMargin = opt.BarMargin
+ }
+ seriesCount := len(seriesList)
+ // 总的宽度-两个margin-(总数-1)的barMargin
+ barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / seriesCount
+ if opt.BarWidth > 0 && opt.BarWidth < barWidth {
+ barWidth = opt.BarWidth
+ // 重新计算margin
+ margin = (width - seriesCount*barWidth - barMargin*(seriesCount-1)) / 2
+ }
+ barMaxHeight := seriesPainter.Height()
+ theme := opt.Theme
+ seriesNames := seriesList.Names()
+
+ 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)
+
+ 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 index != 0 {
+ x += index * (barWidth + barMargin)
+ }
+
+ h := int(yRange.getHeight(item.Value))
+ fillColor := seriesColor
+ if !item.Style.FillColor.IsZero() {
+ fillColor = item.Style.FillColor
+ }
+ top := barMaxHeight - h
+
+ if series.RoundRadius <= 0 {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).Rect(chart.Box{
+ Top: top,
+ Left: x,
+ Right: x + barWidth,
+ Bottom: barMaxHeight - 1,
+ })
+ } else {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).RoundedRect(chart.Box{
+ Top: top,
+ Left: x,
+ Right: x + barWidth,
+ Bottom: barMaxHeight - 1,
+ }, series.RoundRadius)
+ }
+ // 用于生成marker point
+ points[j] = Point{
+ // 居中的位置
+ X: x + barWidth>>1,
+ Y: top,
+ }
+ // 用于生成marker point
+ points[j] = Point{
+ // 居中的位置
+ X: x + barWidth>>1,
+ Y: top,
+ }
+ // 如果label不需要展示,则返回
+ if labelPainter == nil {
+ continue
+ }
+ y := barMaxHeight - h
+ radians := float64(0)
+ fontColor := series.Label.Color
+ if series.Label.Position == PositionBottom {
+ y = barMaxHeight
+ radians = -math.Pi / 2
+ if fontColor.IsZero() {
+ if isLightColor(fillColor) {
+ fontColor = defaultLightFontColor
+ } else {
+ fontColor = defaultDarkFontColor
+ }
+ }
+ }
+ 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,
+ })
+ }
+
+ markPointPainter.Add(markPointRenderOption{
+ FillColor: seriesColor,
+ Font: opt.Font,
+ Series: series,
+ Points: points,
+ })
+ markLinePainter.Add(markLineRenderOption{
+ FillColor: seriesColor,
+ FontColor: opt.Theme.GetTextColor(),
+ StrokeColor: seriesColor,
+ Font: opt.Font,
+ Series: series,
+ Range: yRange,
+ })
+ }
+ // 最大、最小的mark point
+ err := doRender(rendererList...)
+ if err != nil {
+ return BoxZero, err
+ }
+
+ return p.box, nil
+}
+
+func (b *barChart) Render() (Box, error) {
+ p := b.p
+ opt := b.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: opt.XAxis,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeLine)
+ return b.render(renderResult, seriesList)
+}
diff --git a/bar_chart_test.go b/bar_chart_test.go
new file mode 100644
index 0000000..654c320
--- /dev/null
+++ b/bar_chart_test.go
@@ -0,0 +1,190 @@
+// 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 TestBarChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ seriesList := NewSeriesListDataFromValues([][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ },
+ })
+ for index := range seriesList {
+ seriesList[index].Label.Show = true
+ }
+ _, err := NewBarChart(p, BarChartOption{
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+ SeriesList: seriesList,
+ XAxis: NewXAxisOption([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ YAxisOptions: NewYAxisOptions([]string{
+ "Rainfall",
+ "Evaporation",
+ }),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\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 ",
+ },
+ }
+
+ 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_series.go b/bar_series.go
deleted file mode 100644
index b730099..0000000
--- a/bar_series.go
+++ /dev/null
@@ -1,137 +0,0 @@
-// MIT License
-
-// Copyright (c) 2021 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"
-)
-
-const defaultBarMargin = 10
-
-type BarSeriesCustomStyle struct {
- PointIndex int
- Index int
- Style chart.Style
-}
-
-type BarSeries struct {
- BaseSeries
- Count int
- Index int
- // 间隔
- Margin int
- // 偏移量
- Offset int
- // 宽度
- BarWidth int
- CustomStyles []BarSeriesCustomStyle
-}
-
-type barSeriesWidthValues struct {
- columnWidth int
- columnMargin int
- margin int
- barWidth int
-}
-
-func (bs BarSeries) GetBarStyle(index, pointIndex int) chart.Style {
- // 指定样式
- for _, item := range bs.CustomStyles {
- if item.Index == index && item.PointIndex == pointIndex {
- return item.Style
- }
- }
- // 其它非指定样式
- return chart.Style{}
-}
-
-func (bs BarSeries) getWidthValues(width int) barSeriesWidthValues {
- columnWidth := width / bs.Len()
- // 块间隔
- columnMargin := columnWidth / 10
- minColumnMargin := 2
- if columnMargin < minColumnMargin {
- columnMargin = minColumnMargin
- }
- margin := bs.Margin
- if margin <= 0 {
- margin = defaultBarMargin
- }
- // 如果margin大于column margin
- if margin > columnMargin {
- margin = columnMargin
- }
-
- allBarMarginWidth := (bs.Count - 1) * margin
- barWidth := ((columnWidth - 2*columnMargin) - allBarMarginWidth) / bs.Count
- if bs.BarWidth > 0 && bs.BarWidth < barWidth {
- barWidth = bs.BarWidth
- // 重新计息columnMargin
- columnMargin = (columnWidth - allBarMarginWidth - (bs.Count * barWidth)) / 2
- }
- return barSeriesWidthValues{
- columnWidth: columnWidth,
- columnMargin: columnMargin,
- margin: margin,
- barWidth: barWidth,
- }
-}
-
-func (bs BarSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) {
- if bs.Len() == 0 || bs.Count <= 0 {
- return
- }
- style := bs.Style.InheritFrom(defaults)
- style.FillColor = style.StrokeColor
- if !style.ShouldDrawStroke() {
- return
- }
-
- cb := canvasBox.Bottom
- cl := canvasBox.Left
- widthValues := bs.getWidthValues(canvasBox.Width())
-
- for i := 0; i < bs.Len(); i++ {
- vx, vy := bs.GetValues(i)
- customStyle := bs.GetBarStyle(bs.Index, i)
- cloneStyle := style
- if !customStyle.IsZero() {
- cloneStyle.FillColor = customStyle.FillColor
- cloneStyle.StrokeColor = customStyle.StrokeColor
- }
-
- x := cl + xrange.Translate(vx)
- // 由于bar是居中展示,因此需要往前移一个显示块
- x += (-widthValues.columnWidth + widthValues.columnMargin)
- // 计算是第几个bar,位置右偏
- x += bs.Index * (widthValues.margin + widthValues.barWidth)
- y := cb - yrange.Translate(vy)
-
- chart.Draw.Box(r, chart.Box{
- Left: x,
- Top: y,
- Right: x + widthValues.barWidth,
- Bottom: canvasBox.Bottom - 1,
- }, cloneStyle)
- }
-}
diff --git a/bar_series_test.go b/bar_series_test.go
deleted file mode 100644
index 8703367..0000000
--- a/bar_series_test.go
+++ /dev/null
@@ -1,166 +0,0 @@
-// MIT License
-
-// Copyright (c) 2021 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"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
-)
-
-func TestBarSeries(t *testing.T) {
- assert := assert.New(t)
-
- customStyle := chart.Style{
- StrokeColor: drawing.ColorBlue,
- }
- bs := BarSeries{
- CustomStyles: []BarSeriesCustomStyle{
- {
- PointIndex: 1,
- Style: customStyle,
- },
- },
- }
-
- assert.Equal(customStyle, bs.GetBarStyle(0, 1))
-
- assert.True(bs.GetBarStyle(1, 0).IsZero())
-}
-
-func TestBarSeriesGetWidthValues(t *testing.T) {
- assert := assert.New(t)
-
- bs := BarSeries{
- Count: 1,
- BaseSeries: BaseSeries{
- XValues: []float64{
- 1,
- 2,
- 3,
- },
- },
- }
- widthValues := bs.getWidthValues(300)
- assert.Equal(barSeriesWidthValues{
- columnWidth: 100,
- columnMargin: 10,
- margin: 10,
- barWidth: 80,
- }, widthValues)
-
- // 指定margin
- bs.Margin = 5
- widthValues = bs.getWidthValues(300)
- assert.Equal(barSeriesWidthValues{
- columnWidth: 100,
- columnMargin: 10,
- margin: 5,
- barWidth: 80,
- }, widthValues)
-
- // 指定bar的宽度
- bs.BarWidth = 60
- widthValues = bs.getWidthValues(300)
- assert.Equal(barSeriesWidthValues{
- columnWidth: 100,
- columnMargin: 20,
- margin: 5,
- barWidth: 60,
- }, widthValues)
-}
-
-func TestBarSeriesRender(t *testing.T) {
- assert := assert.New(t)
-
- width := 800
- height := 400
-
- r, err := chart.SVG(width, height)
- assert.Nil(err)
-
- bs := BarSeries{
- Count: 1,
- CustomStyles: []BarSeriesCustomStyle{
- {
- Index: 0,
- PointIndex: 1,
- Style: chart.Style{
- StrokeColor: SeriesColorsLight[1],
- },
- },
- },
- BaseSeries: BaseSeries{
- TickPosition: chart.TickPositionBetweenTicks,
- Style: chart.Style{
- StrokeColor: SeriesColorsLight[0],
- StrokeWidth: 1,
- },
- XValues: []float64{
- 0,
- 1,
- 2,
- 3,
- 4,
- 5,
- 6,
- 7,
- },
- YValues: []float64{
- // 第一个点为占位点
- 0,
- 120,
- 200,
- 150,
- 80,
- 70,
- 110,
- 130,
- },
- },
- }
- xrange := &chart.ContinuousRange{
- Min: 0,
- Max: 7,
- Domain: 753,
- }
- yrange := &chart.ContinuousRange{
- Min: 70,
- Max: 200,
- Domain: 362,
- }
- bs.Render(r, chart.Box{
- Top: 11,
- Left: 42,
- Right: 795,
- Bottom: 373,
- }, xrange, yrange, chart.Style{})
-
- buffer := bytes.Buffer{}
- err = r.Save(&buffer)
- assert.Nil(err)
- assert.Equal("\\n ", buffer.String())
-}
diff --git a/base_series.go b/base_series.go
deleted file mode 100644
index e17b4b9..0000000
--- a/base_series.go
+++ /dev/null
@@ -1,133 +0,0 @@
-// MIT License
-
-// Copyright (c) 2021 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 (
- "fmt"
-
- "github.com/wcharczuk/go-chart/v2"
-)
-
-// Interface Assertions.
-var (
- _ chart.Series = (*BaseSeries)(nil)
- _ chart.FirstValuesProvider = (*BaseSeries)(nil)
- _ chart.LastValuesProvider = (*BaseSeries)(nil)
-)
-
-// BaseSeries represents a line on a chart.
-type BaseSeries struct {
- Name string
- Style chart.Style
- TickPosition chart.TickPosition
-
- YAxis chart.YAxisType
-
- XValueFormatter chart.ValueFormatter
- YValueFormatter chart.ValueFormatter
-
- XValues []float64
- YValues []float64
-}
-
-// GetName returns the name of the time series.
-func (bs BaseSeries) GetName() string {
- return bs.Name
-}
-
-// GetStyle returns the line style.
-func (bs BaseSeries) GetStyle() chart.Style {
- return bs.Style
-}
-
-// Len returns the number of elements in the series.
-func (bs BaseSeries) Len() int {
- offset := 0
- if bs.TickPosition == chart.TickPositionBetweenTicks {
- offset = -1
- }
- return len(bs.XValues) + offset
-}
-
-// GetValues gets the x,y values at a given index.
-func (bs BaseSeries) GetValues(index int) (float64, float64) {
- if bs.TickPosition == chart.TickPositionBetweenTicks {
- index++
- }
- return bs.XValues[index], bs.YValues[index]
-}
-
-// GetFirstValues gets the first x,y values.
-func (bs BaseSeries) GetFirstValues() (float64, float64) {
- index := 0
- if bs.TickPosition == chart.TickPositionBetweenTicks {
- index++
- }
- return bs.XValues[index], bs.YValues[index]
-}
-
-// GetLastValues gets the last x,y values.
-func (bs BaseSeries) GetLastValues() (float64, float64) {
- return bs.XValues[len(bs.XValues)-1], bs.YValues[len(bs.YValues)-1]
-}
-
-// GetValueFormatters returns value formatter defaults for the series.
-func (bs BaseSeries) GetValueFormatters() (x, y chart.ValueFormatter) {
- if bs.XValueFormatter != nil {
- x = bs.XValueFormatter
- } else {
- x = chart.FloatValueFormatter
- }
- if bs.YValueFormatter != nil {
- y = bs.YValueFormatter
- } else {
- y = chart.FloatValueFormatter
- }
- return
-}
-
-// GetYAxis returns which YAxis the series draws on.
-func (bs BaseSeries) GetYAxis() chart.YAxisType {
- return bs.YAxis
-}
-
-// Render renders the series.
-func (bs BaseSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) {
- fmt.Println("should be override the function")
-}
-
-// Validate validates the series.
-func (bs BaseSeries) Validate() error {
- if len(bs.XValues) == 0 {
- return fmt.Errorf("continuous series; must have xvalues set")
- }
-
- if len(bs.YValues) == 0 {
- return fmt.Errorf("continuous series; must have yvalues set")
- }
-
- if len(bs.XValues) != len(bs.YValues) {
- return fmt.Errorf("continuous series; must have same length xvalues as yvalues")
- }
- return nil
-}
diff --git a/base_series_test.go b/base_series_test.go
deleted file mode 100644
index 0c9b5d1..0000000
--- a/base_series_test.go
+++ /dev/null
@@ -1,94 +0,0 @@
-// MIT License
-
-// Copyright (c) 2021 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 (
- "reflect"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
-)
-
-func TestBaseSeries(t *testing.T) {
- assert := assert.New(t)
-
- bs := BaseSeries{
- XValues: []float64{
- 1,
- 2,
- 3,
- },
- YValues: []float64{
- 10,
- 20,
- 30,
- },
- }
- assert.Equal(3, bs.Len())
- bs.TickPosition = chart.TickPositionBetweenTicks
- assert.Equal(2, bs.Len())
-
- bs.TickPosition = chart.TickPositionUnset
- x, y := bs.GetValues(1)
- assert.Equal(float64(2), x)
- assert.Equal(float64(20), y)
- bs.TickPosition = chart.TickPositionBetweenTicks
- x, y = bs.GetValues(1)
- assert.Equal(float64(3), x)
- assert.Equal(float64(30), y)
-
- bs.TickPosition = chart.TickPositionUnset
- x, y = bs.GetFirstValues()
- assert.Equal(float64(1), x)
- assert.Equal(float64(10), y)
- bs.TickPosition = chart.TickPositionBetweenTicks
- x, y = bs.GetFirstValues()
- assert.Equal(float64(2), x)
- assert.Equal(float64(20), y)
-
- bs.TickPosition = chart.TickPositionUnset
- x, y = bs.GetLastValues()
- assert.Equal(float64(3), x)
- assert.Equal(float64(30), y)
- bs.TickPosition = chart.TickPositionBetweenTicks
- x, y = bs.GetLastValues()
- assert.Equal(float64(3), x)
- assert.Equal(float64(30), y)
-
- xFormater, yFormater := bs.GetValueFormatters()
- assert.Equal(reflect.ValueOf(chart.FloatValueFormatter).Pointer(), reflect.ValueOf(xFormater).Pointer())
- assert.Equal(reflect.ValueOf(chart.FloatValueFormatter).Pointer(), reflect.ValueOf(yFormater).Pointer())
- formater := func(v interface{}) string {
- return ""
- }
- bs.XValueFormatter = formater
- bs.YValueFormatter = formater
- xFormater, yFormater = bs.GetValueFormatters()
- assert.Equal(reflect.ValueOf(formater).Pointer(), reflect.ValueOf(xFormater).Pointer())
- assert.Equal(reflect.ValueOf(formater).Pointer(), reflect.ValueOf(yFormater).Pointer())
-
- assert.Equal(chart.YAxisPrimary, bs.GetYAxis())
-
- assert.Nil(bs.Validate())
-}
diff --git a/chart_option.go b/chart_option.go
new file mode 100644
index 0000000..d80a383
--- /dev/null
+++ b/chart_option.go
@@ -0,0 +1,426 @@
+// 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 (
+ "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)
+}
+
+// TypeOptionFunc set type of chart's output
+func TypeOptionFunc(t string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Type = t
+ }
+}
+
+// FontFamilyOptionFunc set font family of chart
+func FontFamilyOptionFunc(fontFamily string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.FontFamily = fontFamily
+ }
+}
+
+// ThemeOptionFunc set them of chart
+func ThemeOptionFunc(theme string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Theme = theme
+ }
+}
+
+// TitleOptionFunc set title of chart
+func TitleOptionFunc(title TitleOption) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Title = title
+ }
+}
+
+// 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) {
+ opt.Legend = legend
+ }
+}
+
+// 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) {
+ opt.XAxis = xAxisOption
+ }
+}
+
+// XAxisDataOptionFunc set x axis data of chart
+func XAxisDataOptionFunc(data []string, boundaryGap ...*bool) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.XAxis = NewXAxisOption(data, boundaryGap...)
+ }
+}
+
+// YAxisOptionFunc set y axis of chart, support two y axis
+func YAxisOptionFunc(yAxisOption ...YAxisOption) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.YAxisOptions = yAxisOption
+ }
+}
+
+// YAxisDataOptionFunc set y axis data of chart
+func YAxisDataOptionFunc(data []string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.YAxisOptions = NewYAxisOptions(data)
+ }
+}
+
+// WidthOptionFunc set width of chart
+func WidthOptionFunc(width int) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Width = width
+ }
+}
+
+// HeightOptionFunc set height of chart
+func HeightOptionFunc(height int) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Height = height
+ }
+}
+
+// PaddingOptionFunc set padding of chart
+func PaddingOptionFunc(padding Box) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Padding = padding
+ }
+}
+
+// BoxOptionFunc set box of chart
+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) {
+ if opt.Children == nil {
+ opt.Children = make([]ChartOption, 0)
+ }
+ opt.Children = append(opt.Children, child...)
+ }
+}
+
+// RadarIndicatorOptionFunc set radar indicator of chart
+func RadarIndicatorOptionFunc(names []string, values []float64) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.RadarIndicators = NewRadarIndicators(names, values)
+ }
+}
+
+// BackgroundColorOptionFunc set background color of chart
+func BackgroundColorOptionFunc(color Color) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.BackgroundColor = color
+ }
+}
+
+// MarkLineOptionFunc set mark line for series of chart
+func MarkLineOptionFunc(seriesIndex int, markLineTypes ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ if len(opt.SeriesList) <= seriesIndex {
+ return
+ }
+ opt.SeriesList[seriesIndex].MarkLine = NewMarkLine(markLineTypes...)
+ }
+}
+
+// MarkPointOptionFunc set mark point for series of chart
+func MarkPointOptionFunc(seriesIndex int, markPointTypes ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ if len(opt.SeriesList) <= seriesIndex {
+ return
+ }
+ opt.SeriesList[seriesIndex].MarkPoint = NewMarkPoint(markPointTypes...)
+ }
+}
+
+func (o *ChartOption) fillDefault() {
+ t := NewTheme(o.Theme)
+ o.theme = t
+ // 如果为空,初始化
+ axisCount := 1
+ for _, series := range o.SeriesList {
+ if series.AxisIndex >= axisCount {
+ axisCount++
+ }
+ }
+ o.Width = getDefaultInt(o.Width, defaultChartWidth)
+ o.Height = getDefaultInt(o.Height, defaultChartHeight)
+ yAxisOptions := make([]YAxisOption, axisCount)
+ copy(yAxisOptions, o.YAxisOptions)
+ o.YAxisOptions = yAxisOptions
+ o.font, _ = GetFont(o.FontFamily)
+
+ if o.font == nil {
+ o.font, _ = GetDefaultFont()
+ } else {
+ // 如果指定了字体,则设置主题的字体
+ t.SetFont(o.font)
+ }
+ if o.BackgroundColor.IsZero() {
+ o.BackgroundColor = t.GetBackgroundColor()
+ }
+ if o.Padding.IsZero() {
+ o.Padding = Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ }
+ }
+ // legend与series name的关联
+ if len(o.Legend.Data) == 0 {
+ o.Legend.Data = o.SeriesList.Names()
+ } else {
+ seriesCount := len(o.SeriesList)
+ for index, name := range o.Legend.Data {
+ if index < seriesCount &&
+ len(o.SeriesList[index].Name) == 0 {
+ o.SeriesList[index].Name = name
+ }
+ }
+ nameIndexDict := map[string]int{}
+ for index, name := range o.Legend.Data {
+ nameIndexDict[name] = index
+ }
+ // 保证series的顺序与legend一致
+ sort.Slice(o.SeriesList, func(i, j int) bool {
+ return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name]
+ })
+ }
+}
+
+// LineRender line chart render
+func LineRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeLine)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// BarRender bar chart render
+func BarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeBar)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// HorizontalBarRender horizontal bar chart render
+func HorizontalBarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeHorizontalBar)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// PieRender pie chart render
+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) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeRadar)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// FunnelRender funnel chart render
+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
new file mode 100644
index 0000000..c354b26
--- /dev/null
+++ b/chart_option_test.go
@@ -0,0 +1,451 @@
+// 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 TestChartOption(t *testing.T) {
+ assert := assert.New(t)
+
+ fns := []OptionFunc{
+ SVGTypeOption(),
+ FontFamilyOptionFunc("fontFamily"),
+ ThemeOptionFunc("theme"),
+ TitleTextOptionFunc("title"),
+ LegendLabelsOptionFunc([]string{
+ "label",
+ }),
+ XAxisDataOptionFunc([]string{
+ "xaxis",
+ }),
+ YAxisDataOptionFunc([]string{
+ "yaxis",
+ }),
+ 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)
+}
+
+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)
+ 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 := 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 := p.Bytes()
+ assert.Nil(err)
+ 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)
+ values := [][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ },
+ }
+ p, err := BarRender(
+ values,
+ SVGTypeOption(),
+ XAxisDataOptionFunc([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ LegendLabelsOptionFunc([]string{
+ "Rainfall",
+ "Evaporation",
+ }, PositionRight),
+ MarkLineOptionFunc(0, SeriesMarkDataTypeAverage),
+ MarkPointOptionFunc(0, SeriesMarkDataTypeMax,
+ SeriesMarkDataTypeMin),
+ // custom option func
+ func(opt *ChartOption) {
+ opt.SeriesList[1].MarkPoint = NewMarkPoint(
+ SeriesMarkDataTypeMax,
+ SeriesMarkDataTypeMin,
+ )
+ opt.SeriesList[1].MarkLine = NewMarkLine(
+ SeriesMarkDataTypeAverage,
+ )
+ },
+ )
+ assert.Nil(err)
+ data, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("\\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)
+ 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 := p.Bytes()
+ assert.Nil(err)
+ 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)
+
+ values := [][]float64{
+ {
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ },
+ {
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
+ },
+ }
+ 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 := p.Bytes()
+ assert.Nil(err)
+ 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)
+
+ 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 := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("\\nShow Click Visit Inquiry Order Funnel Show(100%) Click(80%) Visit(60%) Inquiry(40%) Order(20%) ", string(data))
+}
diff --git a/charts.go b/charts.go
index f5fcb1f..31df11c 100644
--- a/charts.go
+++ b/charts.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -23,202 +23,451 @@
package charts
import (
- "bytes"
"errors"
- "io"
+ "math"
+ "sort"
- "github.com/wcharczuk/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2"
)
-const (
- ThemeLight = "light"
- ThemeDark = "dark"
-)
+const labelFontSize = 10
+const smallLabelFontSize = 8
+const defaultDotWidth = 2.0
+const defaultStrokeWidth = 2.0
-const (
- DefaultChartWidth = 800
- DefaultChartHeight = 400
-)
+var defaultChartWidth = 600
+var defaultChartHeight = 400
-type (
- Title struct {
- Text string
- Style chart.Style
+// SetDefaultWidth sets default width of chart
+func SetDefaultWidth(width int) {
+ if width > 0 {
+ defaultChartWidth = width
}
- Legend struct {
- Data []string
- Align string
- Padding chart.Box
- Left string
- Right string
- Top string
- Bottom string
- }
- Options struct {
- Padding chart.Box
- Width int
- Height int
- Theme string
- XAxis XAxis
- YAxisOptions []*YAxisOption
- Series []Series
- Title Title
- Legend Legend
- TickPosition chart.TickPosition
- Log chart.Logger
- }
-)
-
-type Graph interface {
- Render(rp chart.RendererProvider, w io.Writer) error
}
-func (o *Options) validate() error {
- if len(o.Series) == 0 {
- return errors.New("series can not be empty")
+// SetDefaultHeight sets default height of chart
+func SetDefaultHeight(height int) {
+ if height > 0 {
+ defaultChartHeight = height
}
- xAxisCount := len(o.XAxis.Data)
+}
- for _, item := range o.Series {
- if item.Type != SeriesPie && len(item.Data) != xAxisCount {
- return errors.New("series and xAxis is not matched")
+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
}
-func (o *Options) getWidth() int {
- width := o.Width
- if width <= 0 {
- width = DefaultChartWidth
- }
- return width
+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
}
-func (o *Options) getHeight() int {
- height := o.Height
- if height <= 0 {
- height = DefaultChartHeight
- }
- return height
+type defaultRenderResult struct {
+ axisRanges map[int]axisRange
+ // 图例区域
+ seriesPainter *Painter
}
-func (o *Options) getBackground() chart.Style {
- bg := chart.Style{
- Padding: o.Padding,
+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())
}
- return bg
-}
-func render(g Graph, rp chart.RendererProvider) ([]byte, error) {
- buf := bytes.Buffer{}
- err := g.Render(rp, &buf)
+ 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
}
- return buf.Bytes(), nil
+
+ result.seriesPainter = p.Child(PainterPaddingOption(Box{
+ Bottom: defaultXAxisHeight,
+ Left: rangeWidthLeft,
+ Right: rangeWidthRight,
+ }))
+ return &result, nil
}
-func ToPNG(g Graph) ([]byte, error) {
- return render(g, chart.PNG)
-}
-
-func ToSVG(g Graph) ([]byte, error) {
- return render(g, chart.SVG)
-}
-
-func newPieChart(opt Options) *chart.PieChart {
- values := make(chart.Values, len(opt.Series))
- for index, item := range opt.Series {
- values[index] = chart.Value{
- Value: item.Data[0].Value,
- Label: item.Name,
+func doRender(renderers ...Renderer) error {
+ for _, r := range renderers {
+ _, err := r.Render()
+ if err != nil {
+ return err
}
}
- return &chart.PieChart{
- Background: opt.getBackground(),
- Title: opt.Title.Text,
- TitleStyle: opt.Title.Style,
- Width: opt.getWidth(),
- Height: opt.getHeight(),
- Values: values,
- ColorPalette: &PieThemeColorPalette{
- ThemeColorPalette: ThemeColorPalette{
- Theme: opt.Theme,
+ 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(),
},
- },
- }
-}
-
-func newChart(opt Options) *chart.Chart {
- tickPosition := opt.TickPosition
-
- xAxis, xValues := GetXAxisAndValues(opt.XAxis, tickPosition, opt.Theme)
-
- legendSize := len(opt.Legend.Data)
- for index, item := range opt.Series {
- if len(item.XValues) == 0 {
- opt.Series[index].XValues = xValues
- }
- if index < legendSize && opt.Series[index].Name == "" {
- opt.Series[index].Name = opt.Legend.Data[index]
}
}
-
- var secondaryYAxisOption *YAxisOption
- if len(opt.YAxisOptions) != 0 {
- secondaryYAxisOption = opt.YAxisOptions[0]
+ if len(horizontalBarSeriesList) != 0 {
+ renderOpt.YAxisOptions[0].DivideCount = len(renderOpt.YAxisOptions[0].Data)
+ renderOpt.YAxisOptions[0].Unit = 1
}
- yAxisOption := &YAxisOption{
- Disabled: true,
- }
- if len(opt.YAxisOptions) > 1 {
- yAxisOption = opt.YAxisOptions[1]
- }
-
- c := &chart.Chart{
- Log: opt.Log,
- Background: opt.getBackground(),
- ColorPalette: &ThemeColorPalette{
- Theme: opt.Theme,
- },
- Title: opt.Title.Text,
- TitleStyle: opt.Title.Style,
- Width: opt.getWidth(),
- Height: opt.getHeight(),
- XAxis: xAxis,
- YAxis: GetYAxis(opt.Theme, yAxisOption),
- YAxisSecondary: GetSecondaryYAxis(opt.Theme, secondaryYAxisOption),
- Series: GetSeries(opt.Series, tickPosition, opt.Theme),
- }
-
- // 设置secondary的样式
- if legendSize != 0 {
- c.Elements = []chart.Renderable{
- LegendCustomize(c.Series, LegendOption{
- Theme: opt.Theme,
- IconDraw: DefaultLegendIconDraw,
- Align: opt.Legend.Align,
- Padding: opt.Legend.Padding,
- Left: opt.Legend.Left,
- Right: opt.Legend.Right,
- Top: opt.Legend.Top,
- Bottom: opt.Legend.Bottom,
- }),
- }
- }
- return c
-}
-
-func New(opt Options) (Graph, error) {
- err := opt.validate()
+ renderResult, err := defaultRender(p, renderOpt)
if err != nil {
return nil, err
}
- if opt.Series[0].Type == SeriesPie {
- return newPieChart(opt), nil
+
+ 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
+ })
}
- return newChart(opt), nil
+ // 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
index 3173868..bd581e9 100644
--- a/charts_test.go
+++ b/charts_test.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -26,118 +26,230 @@ import (
"errors"
"testing"
- "github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2"
)
-func TestChartsOptions(t *testing.T) {
- assert := assert.New(t)
-
- o := Options{}
-
- assert.Equal(errors.New("series can not be empty"), o.validate())
-
- o.Series = []Series{
- {
- Data: []SeriesData{
- {
- Value: 1,
+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",
},
},
- },
- }
- assert.Equal(errors.New("series and xAxis is not matched"), o.validate())
- o.XAxis.Data = []string{
- "1",
- }
- assert.Nil(o.validate())
-
- assert.Equal(DefaultChartWidth, o.getWidth())
- o.Width = 10
- assert.Equal(10, o.getWidth())
-
- assert.Equal(DefaultChartHeight, o.getHeight())
- o.Height = 10
- assert.Equal(10, o.getHeight())
-
- padding := chart.NewBox(10, 10, 10, 10)
- o.Padding = padding
- assert.Equal(padding, o.getBackground().Padding)
-}
-
-func TestNewPieChart(t *testing.T) {
- assert := assert.New(t)
-
- data := []Series{
- {
- Data: []SeriesData{
+ Padding: chart.Box{
+ Top: 100,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: NewXAxisOption([]string{
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017",
+ }),
+ YAxisOptions: []YAxisOption{
{
- Value: 10,
+
+ Min: NewFloatPoint(0),
+ Max: NewFloatPoint(90),
},
},
- Name: "chrome",
- },
- {
- Data: []SeriesData{
+ 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{
{
- Value: 2,
+ 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%",
+ }),
},
},
- Name: "edge",
- },
- }
- pie := newPieChart(Options{
- Series: data,
- })
- for index, item := range pie.Values {
- assert.Equal(data[index].Name, item.Label)
- assert.Equal(data[index].Data[0].Value, item.Value)
+ }
+ 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 TestNewChart(t *testing.T) {
- assert := assert.New(t)
+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{
+ {
- data := []Series{
- {
- Data: []SeriesData{
- {
- Value: 10,
- },
- {
- Value: 20,
+ Min: NewFloatPoint(0),
+ Max: NewFloatPoint(90),
},
},
- Name: "chrome",
- },
- {
- Data: []SeriesData{
+ 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{
{
- Value: 2,
- },
- {
- Value: 3,
+ 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%",
+ }),
},
},
- Name: "edge",
- },
+ }
+ 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"))
+ }
}
-
- c := newChart(Options{
- Series: data,
- })
- assert.Empty(c.Elements)
- for index, series := range c.Series {
- assert.Equal(data[index].Name, series.GetName())
- }
-
- c = newChart(Options{
- Legend: Legend{
- Data: []string{
- "chrome",
- "edge",
- },
- },
- })
- assert.Equal(1, len(c.Elements))
}
diff --git a/echarts.go b/echarts.go
index bd5a950..aaef1f1 100644
--- a/echarts.go
+++ b/echarts.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -28,21 +28,10 @@ import (
"fmt"
"regexp"
"strconv"
- "strings"
- "github.com/wcharczuk/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2"
)
-type EChartStyle struct {
- Color string `json:"color"`
-}
-type ECharsSeriesData struct {
- Value float64 `json:"value"`
- Name string `json:"name"`
- ItemStyle EChartStyle `json:"itemStyle"`
-}
-type _ECharsSeriesData ECharsSeriesData
-
func convertToArray(data []byte) []byte {
data = bytes.TrimSpace(data)
if len(data) == 0 {
@@ -54,20 +43,79 @@ func convertToArray(data []byte) []byte {
return data
}
-func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
- data = bytes.TrimSpace(data)
+type EChartsPosition string
+
+func (p *EChartsPosition) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
return nil
}
if regexp.MustCompile(`^\d+`).Match(data) {
+ data = []byte(fmt.Sprintf(`"%s"`, string(data)))
+ }
+ s := (*string)(p)
+ return json.Unmarshal(data, s)
+}
+
+type EChartStyle struct {
+ Color string `json:"color"`
+}
+
+func (es *EChartStyle) ToStyle() Style {
+ color := parseColor(es.Color)
+ return Style{
+ FillColor: color,
+ FontColor: color,
+ StrokeColor: color,
+ }
+}
+
+type EChartsSeriesDataValue struct {
+ values []float64
+}
+
+func (value *EChartsSeriesDataValue) UnmarshalJSON(data []byte) error {
+ data = convertToArray(data)
+ return json.Unmarshal(data, &value.values)
+}
+func (value *EChartsSeriesDataValue) First() float64 {
+ if len(value.values) == 0 {
+ return 0
+ }
+ return value.values[0]
+}
+func NewEChartsSeriesDataValue(values ...float64) EChartsSeriesDataValue {
+ return EChartsSeriesDataValue{
+ values: values,
+ }
+}
+
+type EChartsSeriesData struct {
+ Value EChartsSeriesDataValue `json:"value"`
+ Name string `json:"name"`
+ ItemStyle EChartStyle `json:"itemStyle"`
+}
+type _EChartsSeriesData EChartsSeriesData
+
+var numericRep = regexp.MustCompile(`^[-+]?[0-9]+(?:\.[0-9]+)?$`)
+
+func (es *EChartsSeriesData) UnmarshalJSON(data []byte) error {
+ data = bytes.TrimSpace(data)
+ if len(data) == 0 {
+ return nil
+ }
+ if numericRep.Match(data) {
v, err := strconv.ParseFloat(string(data), 64)
if err != nil {
return err
}
- es.Value = v
+ es.Value = EChartsSeriesDataValue{
+ values: []float64{
+ v,
+ },
+ }
return nil
}
- v := _ECharsSeriesData{}
+ v := _EChartsSeriesData{}
err := json.Unmarshal(data, &v)
if err != nil {
return err
@@ -78,24 +126,55 @@ func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
return nil
}
-type EChartsPadding struct {
- box chart.Box
+type EChartsXAxisData struct {
+ BoundaryGap *bool `json:"boundaryGap"`
+ SplitNumber int `json:"splitNumber"`
+ Data []string `json:"data"`
+ Type string `json:"type"`
+}
+type EChartsXAxis struct {
+ Data []EChartsXAxisData
}
-type LegendPostion string
-
-func (lp *LegendPostion) UnmarshalJSON(data []byte) error {
+func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error {
+ data = convertToArray(data)
if len(data) == 0 {
return nil
}
- if regexp.MustCompile(`^\d+`).Match(data) {
- data = []byte(fmt.Sprintf(`"%s"`, string(data)))
- }
- s := (*string)(lp)
- return json.Unmarshal(data, s)
+ return json.Unmarshal(data, &ex.Data)
}
-func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
+type EChartsAxisLabel struct {
+ Formatter string `json:"formatter"`
+}
+type EChartsYAxisData struct {
+ Min *float64 `json:"min"`
+ Max *float64 `json:"max"`
+ AxisLabel EChartsAxisLabel `json:"axisLabel"`
+ AxisLine struct {
+ LineStyle struct {
+ Color string `json:"color"`
+ } `json:"lineStyle"`
+ } `json:"axisLine"`
+ Data []string `json:"data"`
+}
+type EChartsYAxis struct {
+ Data []EChartsYAxisData `json:"data"`
+}
+
+func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error {
+ data = convertToArray(data)
+ if len(data) == 0 {
+ return nil
+ }
+ return json.Unmarshal(data, &ey.Data)
+}
+
+type EChartsPadding struct {
+ Box chart.Box
+}
+
+func (eb *EChartsPadding) UnmarshalJSON(data []byte) error {
data = convertToArray(data)
if len(data) == 0 {
return nil
@@ -110,14 +189,14 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
}
switch len(arr) {
case 1:
- ep.box = chart.Box{
+ eb.Box = chart.Box{
Left: arr[0],
Top: arr[0],
Bottom: arr[0],
Right: arr[0],
}
case 2:
- ep.box = chart.Box{
+ eb.Box = chart.Box{
Top: arr[0],
Bottom: arr[0],
Left: arr[1],
@@ -130,7 +209,7 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
result[3] = result[1]
}
// 上右下左
- ep.box = chart.Box{
+ eb.Box = chart.Box{
Top: result[0],
Right: result[1],
Bottom: result[2],
@@ -140,236 +219,310 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
return nil
}
-type EChartsYAxis struct {
- Data []struct {
- Min *float64 `json:"min"`
- Max *float64 `json:"max"`
- // Interval int `json:"interval"`
- AxisLabel struct {
- Formatter string `json:"formatter"`
- } `json:"axisLabel"`
- } `json:"data"`
+type EChartsLabelOption struct {
+ Show bool `json:"show"`
+ Distance int `json:"distance"`
+ Color string `json:"color"`
+}
+type EChartsLegend struct {
+ Show *bool `json:"show"`
+ Data []string `json:"data"`
+ Align string `json:"align"`
+ Orient string `json:"orient"`
+ Padding EChartsPadding `json:"padding"`
+ Left EChartsPosition `json:"left"`
+ Top EChartsPosition `json:"top"`
+ TextStyle EChartsTextStyle `json:"textStyle"`
}
-func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error {
- data = convertToArray(data)
+type EChartsMarkData struct {
+ Type string `json:"type"`
+}
+type _EChartsMarkData EChartsMarkData
+
+func (emd *EChartsMarkData) UnmarshalJSON(data []byte) error {
+ data = bytes.TrimSpace(data)
if len(data) == 0 {
return nil
}
- return json.Unmarshal(data, &ey.Data)
-}
-
-type EChartsXAxis struct {
- Data []struct {
- // Type string `json:"type"`
- BoundaryGap *bool `json:"boundaryGap"`
- SplitNumber int `json:"splitNumber"`
- Data []string `json:"data"`
- }
-}
-
-func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error {
data = convertToArray(data)
- if len(data) == 0 {
- return nil
+ ds := make([]*_EChartsMarkData, 0)
+ err := json.Unmarshal(data, &ds)
+ if err != nil {
+ return err
}
- return json.Unmarshal(data, &ex.Data)
+ for _, d := range ds {
+ if d.Type != "" {
+ emd.Type = d.Type
+ }
+ }
+ return nil
}
-type ECharsOptions struct {
- Theme string `json:"theme"`
- Padding EChartsPadding `json:"padding"`
- Title struct {
- Text string `json:"text"`
- // 暂不支持(go-chart默认title只能居中)
- TextAlign string `json:"textAlign"`
- TextStyle struct {
- Color string `json:"color"`
- // TODO 字体支持
- FontFamily string `json:"fontFamily"`
- FontSize float64 `json:"fontSize"`
- Height float64 `json:"height"`
- } `json:"textStyle"`
- } `json:"title"`
- XAxis EChartsXAxis `json:"xAxis"`
- YAxis EChartsYAxis `json:"yAxis"`
- Legend struct {
- Data []string `json:"data"`
- Align string `json:"align"`
- Padding EChartsPadding `json:"padding"`
- Left LegendPostion `json:"left"`
- Right LegendPostion `json:"right"`
- // Top string `json:"top"`
- // Bottom string `json:"bottom"`
- } `json:"legend"`
- Series []struct {
- Data []ECharsSeriesData `json:"data"`
- Type string `json:"type"`
- YAxisIndex int `json:"yAxisIndex"`
- ItemStyle EChartStyle `json:"itemStyle"`
- } `json:"series"`
+type EChartsMarkPoint struct {
+ SymbolSize int `json:"symbolSize"`
+ Data []EChartsMarkData `json:"data"`
}
-func convertEChartsSeries(e *ECharsOptions) ([]Series, chart.TickPosition) {
- tickPosition := chart.TickPositionUnset
-
- if len(e.Series) == 0 {
- return nil, tickPosition
+func (emp *EChartsMarkPoint) ToSeriesMarkPoint() SeriesMarkPoint {
+ sp := SeriesMarkPoint{
+ SymbolSize: emp.SymbolSize,
}
- seriesType := e.Series[0].Type
- if seriesType == SeriesPie {
- series := make([]Series, len(e.Series[0].Data))
- for index, item := range e.Series[0].Data {
- style := chart.Style{}
- if item.ItemStyle.Color != "" {
- c := parseColor(item.ItemStyle.Color)
- style.FillColor = c
- style.StrokeColor = c
- }
+ if len(emp.Data) == 0 {
+ return sp
+ }
+ data := make([]SeriesMarkData, len(emp.Data))
+ for index, item := range emp.Data {
+ data[index].Type = item.Type
+ }
+ sp.Data = data
+ return sp
+}
- series[index] = Series{
- Style: style,
- Data: []SeriesData{
- {
- Value: item.Value,
+type EChartsMarkLine struct {
+ Data []EChartsMarkData `json:"data"`
+}
+
+func (eml *EChartsMarkLine) ToSeriesMarkLine() SeriesMarkLine {
+ sl := SeriesMarkLine{}
+ if len(eml.Data) == 0 {
+ return sl
+ }
+ data := make([]SeriesMarkData, len(eml.Data))
+ for index, item := range eml.Data {
+ data[index].Type = item.Type
+ }
+ sl.Data = data
+ return sl
+}
+
+type EChartsSeries struct {
+ Data []EChartsSeriesData `json:"data"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Radius string `json:"radius"`
+ YAxisIndex int `json:"yAxisIndex"`
+ ItemStyle EChartStyle `json:"itemStyle"`
+ // label的配置
+ Label EChartsLabelOption `json:"label"`
+ MarkPoint EChartsMarkPoint `json:"markPoint"`
+ MarkLine EChartsMarkLine `json:"markLine"`
+ Max *float64 `json:"max"`
+ Min *float64 `json:"min"`
+}
+type EChartsSeriesList []EChartsSeries
+
+func (esList EChartsSeriesList) ToSeriesList() SeriesList {
+ seriesList := make(SeriesList, 0, len(esList))
+ for _, item := range esList {
+ // 如果是pie,则每个子荐生成一个series
+ if item.Type == ChartTypePie {
+ for _, dataItem := range item.Data {
+ seriesList = append(seriesList, Series{
+ Type: item.Type,
+ Name: dataItem.Name,
+ Label: SeriesLabel{
+ Show: true,
},
- },
- Type: seriesType,
- Name: item.Name,
+ Radius: item.Radius,
+ Data: []SeriesData{
+ {
+ Value: dataItem.Value.First(),
+ },
+ },
+ })
}
+ continue
}
- return series, tickPosition
- }
- series := make([]Series, len(e.Series))
- for index, item := range e.Series {
- // bar默认tick居中
- if item.Type == SeriesBar {
- tickPosition = chart.TickPositionBetweenTicks
- }
- style := chart.Style{}
- if item.ItemStyle.Color != "" {
- c := parseColor(item.ItemStyle.Color)
- style.FillColor = c
- style.StrokeColor = c
+ // 如果是radar或funnel
+ if item.Type == ChartTypeRadar ||
+ item.Type == ChartTypeFunnel {
+ for _, dataItem := range item.Data {
+ seriesList = append(seriesList, Series{
+ Name: dataItem.Name,
+ Type: item.Type,
+ 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
}
data := make([]SeriesData, len(item.Data))
- for j, itemData := range item.Data {
- sd := SeriesData{
- Value: itemData.Value,
+ for j, dataItem := range item.Data {
+ data[j] = SeriesData{
+ Value: dataItem.Value.First(),
+ Style: dataItem.ItemStyle.ToStyle(),
}
- if itemData.ItemStyle.Color != "" {
- c := parseColor(itemData.ItemStyle.Color)
- sd.Style.FillColor = c
- sd.Style.StrokeColor = c
- }
- data[j] = sd
- }
- series[index] = Series{
- Style: style,
- YAxisIndex: item.YAxisIndex,
- Data: data,
- Type: item.Type,
}
+ seriesList = append(seriesList, Series{
+ Type: item.Type,
+ Data: data,
+ AxisIndex: item.YAxisIndex,
+ Style: item.ItemStyle.ToStyle(),
+ Label: SeriesLabel{
+ Color: parseColor(item.Label.Color),
+ Show: item.Label.Show,
+ Distance: item.Label.Distance,
+ },
+ Name: item.Name,
+ MarkPoint: item.MarkPoint.ToSeriesMarkPoint(),
+ MarkLine: item.MarkLine.ToSeriesMarkLine(),
+ })
}
- return series, tickPosition
+ return seriesList
}
-func (e *ECharsOptions) ToOptions() Options {
- o := Options{
- Theme: e.Theme,
- Padding: e.Padding.box,
- }
+type EChartsTextStyle struct {
+ Color string `json:"color"`
+ FontFamily string `json:"fontFamily"`
+ FontSize float64 `json:"fontSize"`
+}
- titleTextStyle := e.Title.TextStyle
- o.Title = Title{
- Text: e.Title.Text,
- Style: chart.Style{
- FontColor: parseColor(titleTextStyle.Color),
- FontSize: titleTextStyle.FontSize,
+func (et *EChartsTextStyle) ToStyle() chart.Style {
+ s := chart.Style{
+ FontSize: et.FontSize,
+ FontColor: parseColor(et.Color),
+ }
+ if et.FontFamily != "" {
+ s.Font, _ = GetFont(et.FontFamily)
+ }
+ return s
+}
+
+type EChartsOption struct {
+ Type string `json:"type"`
+ Theme string `json:"theme"`
+ FontFamily string `json:"fontFamily"`
+ Padding EChartsPadding `json:"padding"`
+ Box chart.Box `json:"box"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Title struct {
+ Text string `json:"text"`
+ Subtext string `json:"subtext"`
+ Left EChartsPosition `json:"left"`
+ Top EChartsPosition `json:"top"`
+ TextStyle EChartsTextStyle `json:"textStyle"`
+ SubtextStyle EChartsTextStyle `json:"subtextStyle"`
+ } `json:"title"`
+ XAxis EChartsXAxis `json:"xAxis"`
+ YAxis EChartsYAxis `json:"yAxis"`
+ Legend EChartsLegend `json:"legend"`
+ Radar struct {
+ Indicator []RadarIndicator `json:"indicator"`
+ } `json:"radar"`
+ Series EChartsSeriesList `json:"series"`
+ Children []EChartsOption `json:"children"`
+}
+
+func (eo *EChartsOption) ToOption() ChartOption {
+ fontFamily := eo.FontFamily
+ if len(fontFamily) == 0 {
+ fontFamily = eo.Title.TextStyle.FontFamily
+ }
+ titleTextStyle := eo.Title.TextStyle.ToStyle()
+ titleSubtextStyle := eo.Title.SubtextStyle.ToStyle()
+ legendTextStyle := eo.Legend.TextStyle.ToStyle()
+ o := ChartOption{
+ Type: eo.Type,
+ FontFamily: fontFamily,
+ Theme: eo.Theme,
+ Title: TitleOption{
+ Text: eo.Title.Text,
+ Subtext: eo.Title.Subtext,
+ FontColor: titleTextStyle.FontColor,
+ FontSize: titleTextStyle.FontSize,
+ SubtextFontSize: titleSubtextStyle.FontSize,
+ SubtextFontColor: titleSubtextStyle.FontColor,
+ Left: string(eo.Title.Left),
+ Top: string(eo.Title.Top),
},
+ Legend: LegendOption{
+ Show: eo.Legend.Show,
+ FontSize: legendTextStyle.FontSize,
+ FontColor: legendTextStyle.FontColor,
+ Data: eo.Legend.Data,
+ Left: string(eo.Legend.Left),
+ Top: string(eo.Legend.Top),
+ Align: eo.Legend.Align,
+ Orient: eo.Legend.Orient,
+ },
+ RadarIndicators: eo.Radar.Indicator,
+ Width: eo.Width,
+ Height: eo.Height,
+ Padding: eo.Padding.Box,
+ Box: eo.Box,
+ SeriesList: eo.Series.ToSeriesList(),
}
-
- if titleTextStyle.FontSize != 0 && titleTextStyle.Height > titleTextStyle.FontSize {
- padding := int(titleTextStyle.Height-titleTextStyle.FontSize) / 2
- o.Title.Style.Padding.Top = padding
- o.Title.Style.Padding.Bottom = padding
- }
-
- boundaryGap := false
- if len(e.XAxis.Data) != 0 {
- xAxis := e.XAxis.Data[0]
- o.XAxis = XAxis{
- Data: xAxis.Data,
- SplitNumber: xAxis.SplitNumber,
- }
- if xAxis.BoundaryGap == nil || *xAxis.BoundaryGap {
- boundaryGap = true
+ isHorizontalChart := false
+ for _, item := range eo.XAxis.Data {
+ if item.Type == "value" {
+ isHorizontalChart = true
}
}
-
- o.Legend = Legend{
- Data: e.Legend.Data,
- Align: e.Legend.Align,
- Padding: e.Legend.Padding.box,
- Left: string(e.Legend.Left),
- Right: string(e.Legend.Right),
- }
- if len(e.YAxis.Data) != 0 {
- yAxisOptions := make([]*YAxisOption, len(e.YAxis.Data))
- for index, item := range e.YAxis.Data {
- opt := &YAxisOption{
- Max: item.Max,
- Min: item.Min,
+ if isHorizontalChart {
+ for index := range o.SeriesList {
+ series := o.SeriesList[index]
+ if series.Type == ChartTypeBar {
+ o.SeriesList[index].Type = ChartTypeHorizontalBar
}
- template := item.AxisLabel.Formatter
- if template != "" {
- opt.Formater = func(v interface{}) string {
- str := defaultFloatFormater(v)
- return strings.ReplaceAll(template, "{value}", str)
- }
- }
- yAxisOptions[index] = opt
}
- o.YAxisOptions = yAxisOptions
}
- series, tickPosition := convertEChartsSeries(e)
-
- o.Series = series
-
- if boundaryGap {
- tickPosition = chart.TickPositionBetweenTicks
+ if len(eo.XAxis.Data) != 0 {
+ xAxisData := eo.XAxis.Data[0]
+ o.XAxis = XAxisOption{
+ BoundaryGap: xAxisData.BoundaryGap,
+ Data: xAxisData.Data,
+ SplitNumber: xAxisData.SplitNumber,
+ }
+ }
+ yAxisOptions := make([]YAxisOption, len(eo.YAxis.Data))
+ for index, item := range eo.YAxis.Data {
+ yAxisOptions[index] = YAxisOption{
+ Min: item.Min,
+ Max: item.Max,
+ Formatter: item.AxisLabel.Formatter,
+ Color: parseColor(item.AxisLine.LineStyle.Color),
+ Data: item.Data,
+ }
+ }
+ o.YAxisOptions = yAxisOptions
+
+ if len(eo.Children) != 0 {
+ o.Children = make([]ChartOption, len(eo.Children))
+ for index, item := range eo.Children {
+ o.Children[index] = item.ToOption()
+ }
}
- o.TickPosition = tickPosition
return o
}
-func ParseECharsOptions(options string) (Options, error) {
- e := ECharsOptions{}
- err := json.Unmarshal([]byte(options), &e)
- if err != nil {
- return Options{}, err
- }
-
- return e.ToOptions(), nil
-}
-
-func echartsRender(options string, rp chart.RendererProvider) ([]byte, error) {
- o, err := ParseECharsOptions(options)
+func renderEcharts(options, outputType string) ([]byte, error) {
+ o := EChartsOption{}
+ err := json.Unmarshal([]byte(options), &o)
if err != nil {
return nil, err
}
- g, err := New(o)
+ opt := o.ToOption()
+ opt.Type = outputType
+ d, err := Render(opt)
if err != nil {
return nil, err
}
- return render(g, rp)
+ return d.Bytes()
}
func RenderEChartsToPNG(options string) ([]byte, error) {
- return echartsRender(options, chart.PNG)
+ return renderEcharts(options, "png")
}
func RenderEChartsToSVG(options string) ([]byte, error) {
- return echartsRender(options, chart.SVG)
+ return renderEcharts(options, "svg")
}
diff --git a/echarts_test.go b/echarts_test.go
index 9639ac1..2077278 100644
--- a/echarts_test.go
+++ b/echarts_test.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -27,395 +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.Nil(convertToArray([]byte(" ")))
-
- assert.Equal([]byte("[{}]"), convertToArray([]byte("{}")))
- assert.Equal([]byte("[{}]"), convertToArray([]byte("[{}]")))
+ assert.Equal([]byte(`[1]`), convertToArray([]byte("1")))
+ assert.Equal([]byte(`[1]`), convertToArray([]byte("[1]")))
}
-func TestECharsSeriesData(t *testing.T) {
+func TestEChartsPosition(t *testing.T) {
+ assert := assert.New(t)
+ var p EChartsPosition
+ err := p.UnmarshalJSON([]byte("1"))
+ assert.Nil(err)
+ assert.Equal(EChartsPosition("1"), p)
+ err = p.UnmarshalJSON([]byte(`"left"`))
+ assert.Nil(err)
+ assert.Equal(EChartsPosition("left"), p)
+}
+
+func TestEChartsSeriesDataValue(t *testing.T) {
assert := assert.New(t)
- es := ECharsSeriesData{}
- err := es.UnmarshalJSON([]byte(" "))
+ es := EChartsSeriesDataValue{}
+ err := es.UnmarshalJSON([]byte(`[1, 2]`))
assert.Nil(err)
- assert.Equal(ECharsSeriesData{}, es)
-
- es = ECharsSeriesData{}
- err = es.UnmarshalJSON([]byte("12.1"))
- assert.Nil(err)
- assert.Equal(ECharsSeriesData{
- Value: 12.1,
- }, es)
-
- es = ECharsSeriesData{}
- err = es.UnmarshalJSON([]byte(`{
- "value": 12.1,
- "name": "test",
- "itemStyle": {
- "color": "#333"
- }
- }`))
- assert.Nil(err)
- assert.Equal(ECharsSeriesData{
- Value: 12.1,
- Name: "test",
- ItemStyle: EChartStyle{
- Color: "#333",
+ 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": true, "splitNumber": 5, "data": ["a", "b"], "type": "value"}`))
+ assert.Nil(err)
+
+ assert.Equal(EChartsXAxis{
+ Data: []EChartsXAxisData{
+ {
+ BoundaryGap: TrueFlag(),
+ SplitNumber: 5,
+ Data: []string{
+ "a",
+ "b",
+ },
+ Type: "value",
+ },
+ },
+ }, ex)
+}
+
+func TestEChartStyle(t *testing.T) {
+ assert := assert.New(t)
+
+ 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{}
- err := ep.UnmarshalJSON([]byte(" "))
- assert.Nil(err)
- assert.Equal(EChartsPadding{}, ep)
+ eb := EChartsPadding{}
- ep = EChartsPadding{}
- err = ep.UnmarshalJSON([]byte("1"))
+ err := eb.UnmarshalJSON([]byte(`1`))
assert.Nil(err)
- assert.Equal(EChartsPadding{
- box: chart.Box{
- Top: 1,
- Left: 1,
- Right: 1,
- Bottom: 1,
- },
- }, ep)
+ assert.Equal(Box{
+ Left: 1,
+ Top: 1,
+ Right: 1,
+ Bottom: 1,
+ }, eb.Box)
- ep = EChartsPadding{}
- err = ep.UnmarshalJSON([]byte("[1, 2]"))
+ err = eb.UnmarshalJSON([]byte(`[2, 3]`))
assert.Nil(err)
- assert.Equal(EChartsPadding{
- box: chart.Box{
- Top: 1,
- Left: 2,
- Right: 2,
- Bottom: 1,
- },
- }, ep)
+ assert.Equal(Box{
+ Left: 3,
+ Top: 2,
+ Right: 3,
+ Bottom: 2,
+ }, eb.Box)
- ep = EChartsPadding{}
- err = ep.UnmarshalJSON([]byte("[1, 2, 3]"))
+ err = eb.UnmarshalJSON([]byte(`[4, 5, 6]`))
assert.Nil(err)
- assert.Equal(EChartsPadding{
- box: chart.Box{
- Top: 1,
- Right: 2,
- Bottom: 3,
- Left: 2,
- },
- }, ep)
+ assert.Equal(Box{
+ Left: 5,
+ Top: 4,
+ Right: 5,
+ Bottom: 6,
+ }, eb.Box)
- ep = EChartsPadding{}
- err = ep.UnmarshalJSON([]byte("[1, 2, 3, 4]"))
+ err = eb.UnmarshalJSON([]byte(`[4, 5, 6, 7]`))
assert.Nil(err)
- assert.Equal(EChartsPadding{
- box: chart.Box{
- Top: 1,
- Right: 2,
- Bottom: 3,
- Left: 4,
- },
- }, ep)
+ assert.Equal(Box{
+ Left: 7,
+ Top: 4,
+ Right: 5,
+ Bottom: 6,
+ }, eb.Box)
}
-func TestConvertEChartsSeries(t *testing.T) {
+func TestEChartsMarkPoint(t *testing.T) {
assert := assert.New(t)
- seriesList, tickPosition := convertEChartsSeries(&ECharsOptions{})
- assert.Empty(seriesList)
- assert.Equal(chart.TickPositionUnset, tickPosition)
-
- e := ECharsOptions{}
- err := json.Unmarshal([]byte(`{
- "title": {
- "text": "Referer of a Website"
- },
- "series": [
+ emp := EChartsMarkPoint{
+ SymbolSize: 30,
+ Data: []EChartsMarkData{
{
- "name": "Access From",
- "type": "pie",
- "radius": "50%",
- "data": [
+ Type: "test",
+ },
+ },
+ }
+ assert.Equal(SeriesMarkPoint{
+ SymbolSize: 30,
+ Data: []SeriesMarkData{
+ {
+ Type: "test",
+ },
+ },
+ }, emp.ToSeriesMarkPoint())
+}
+
+func TestEChartsMarkLine(t *testing.T) {
+ assert := assert.New(t)
+
+ eml := EChartsMarkLine{
+ Data: []EChartsMarkData{
+ {
+ Type: "min",
+ },
+ {
+ Type: "max",
+ },
+ },
+ }
+ assert.Equal(SeriesMarkLine{
+ Data: []SeriesMarkData{
+ {
+ Type: "min",
+ },
+ {
+ Type: "max",
+ },
+ },
+ }, eml.ToSeriesMarkLine())
+}
+
+func TestEChartsOption(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ option string
+ }{
+ {
+ option: `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
{
- "value": 1048,
- "name": "Search Engine"
- },
- {
- "value": 735,
- "name": "Direct"
+ "data": [
+ 120,
+ {
+ "value": 200,
+ "itemStyle": {
+ "color": "#a90000"
+ }
+ },
+ 150,
+ 80,
+ 70,
+ 110,
+ 130
+ ],
+ "type": "bar"
}
]
- }
- ]
- }`), &e)
- assert.Nil(err)
- seriesList, tickPosition = convertEChartsSeries(&e)
- assert.Equal(chart.TickPositionUnset, tickPosition)
- assert.Equal([]Series{
- {
- Data: []SeriesData{
- {
- Value: 1048,
- },
- },
- Type: SeriesPie,
- Name: "Search Engine",
+ }`,
},
{
- Data: []SeriesData{
- {
- Value: 735,
+ option: `{
+ "title": {
+ "text": "Referer of a Website",
+ "subtext": "Fake Data",
+ "left": "center"
},
- },
- Type: SeriesPie,
- Name: "Direct",
- },
- }, seriesList)
-
- err = json.Unmarshal([]byte(`{
- "series": [
- {
- "name": "Evaporation",
- "type": "bar",
- "data": [2, {
- "value": 4.9,
- "itemStyle": {
- "color": "#a90000"
+ "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)"
+ }
+ }
}
- }, 7, 23.2, 25.6, 76.7, 135.6]
- },
- {
- "name": "Precipitation",
- "type": "bar",
- "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6]
- },
- {
- "name": "Temperature",
- "type": "line",
- "yAxisIndex": 1,
- "data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3]
- }
- ]
- }`), &e)
- assert.Nil(err)
- bar1Data := NewSeriesDataListFromFloat([]float64{
- 2, 4.9, 7, 23.2, 25.6, 76.7, 135.6,
- })
- bar1Data[1].Style.FillColor = parseColor("#a90000")
- bar1Data[1].Style.StrokeColor = bar1Data[1].Style.FillColor
-
- seriesList, tickPosition = convertEChartsSeries(&e)
- assert.Equal(chart.TickPositionBetweenTicks, tickPosition)
- assert.Equal([]Series{
- {
- Data: bar1Data,
- Type: SeriesBar,
+ ]
+ }`,
},
{
- Data: NewSeriesDataListFromFloat([]float64{
- 2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6,
- }),
- Type: SeriesBar,
+ 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"
+ }
+ ]
+ }
+ }
+ ]
+ }`,
},
- {
- Data: NewSeriesDataListFromFloat([]float64{
- 2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3,
- }),
- Type: SeriesLine,
- YAxisIndex: 1,
- },
- }, seriesList)
-
+ }
+ for _, tt := range tests {
+ opt := EChartsOption{}
+ err := json.Unmarshal([]byte(tt.option), &opt)
+ assert.Nil(err)
+ assert.NotEmpty(opt.Series)
+ assert.NotEmpty(opt.ToOption().SeriesList)
+ }
}
-func TestParseECharsOptions(t *testing.T) {
-
+func TestRenderEChartsToSVG(t *testing.T) {
assert := assert.New(t)
- options, err := ParseECharsOptions(`{
- "theme": "dark",
- "padding": [5, 10],
+
+ data, err := RenderEChartsToSVG(`{
"title": {
- "text": "Multi Line",
- "textAlign": "left",
- "textStyle": {
- "color": "#333",
- "fontSize": 24,
- "height": 40
- }
+ "text": "Rainfall vs Evaporation",
+ "subtext": "Fake Data"
},
"legend": {
- "align": "left",
- "padding": [5, 0, 0, 50],
- "data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"]
+ "data": [
+ "Rainfall",
+ "Evaporation"
+ ]
},
- "xAxis": {
- "type": "category",
- "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
- "splitNumber": 10
- },
- "yAxis": [
+ "padding": [10, 30, 10, 10],
+ "xAxis": [
{
- "min": 0,
- "max": 250
- },
- {
- "min": 0,
- "max": 25
+ "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, {
- "value": 4.9,
- "itemStyle": {
- "color": "#a90000"
- }
- }, 7, 23.2, 25.6, 76.7, 135.6]
- },
- {
- "name": "Precipitation",
- "type": "bar",
- "itemStyle": {
- "color": "#0052d9"
+ "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"
+ }
+ ]
},
- "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6]
- },
- {
- "name": "Temperature",
- "type": "line",
- "yAxisIndex": 1,
- "data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3]
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
}
]
}`)
-
assert.Nil(err)
-
- min1 := float64(0)
- max1 := float64(250)
- min2 := float64(0)
- max2 := float64(25)
-
- bar1Data := NewSeriesDataListFromFloat([]float64{
- 2, 4.9, 7, 23.2, 25.6, 76.7, 135.6,
- })
- bar1Data[1].Style.FillColor = parseColor("#a90000")
- bar1Data[1].Style.StrokeColor = bar1Data[1].Style.FillColor
-
- assert.Equal(Options{
- Theme: ThemeDark,
- Padding: chart.Box{
- Top: 5,
- Bottom: 5,
- Left: 10,
- Right: 10,
- },
- Title: Title{
- Text: "Multi Line",
- Style: chart.Style{
- FontColor: parseColor("#333"),
- FontSize: 24,
- Padding: chart.Box{
- Top: 8,
- Bottom: 8,
- },
- },
- },
- Legend: Legend{
- Data: []string{
- "Email", "Union Ads", "Video Ads", "Direct", "Search Engine",
- },
- Align: "left",
- Padding: chart.Box{
- Top: 5,
- Right: 0,
- Bottom: 0,
- Left: 50,
- },
- },
- XAxis: XAxis{
- Data: []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"},
- SplitNumber: 10,
- },
- TickPosition: chart.TickPositionBetweenTicks,
- YAxisOptions: []*YAxisOption{
- {
- Min: &min1,
- Max: &max1,
- },
- {
- Min: &min2,
- Max: &max2,
- },
- },
- Series: []Series{
- {
- Data: bar1Data,
- Type: SeriesBar,
- },
- {
- Data: NewSeriesDataListFromFloat([]float64{
- 2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6,
- }),
- Type: SeriesBar,
- Style: chart.Style{
- StrokeColor: drawing.Color{
- R: 0,
- G: 82,
- B: 217,
- A: 255,
- },
- FillColor: drawing.Color{
- R: 0,
- G: 82,
- B: 217,
- A: 255,
- },
- },
- },
- {
- Data: NewSeriesDataListFromFloat([]float64{
- 2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3,
- }),
- Type: SeriesLine,
- YAxisIndex: 1,
- },
- },
- }, options)
-}
-
-func BenchmarkEChartsRender(b *testing.B) {
- for i := 0; i < b.N; i++ {
- _, err := RenderEChartsToPNG(`{
- "title": {
- "text": "Line"
- },
- "xAxis": {
- "type": "category",
- "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
- },
- "series": [
- {
- "data": [150, 230, 224, 218, 135, 147, 260]
- }
- ]
- }`)
- if err != nil {
- panic(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 9efc745..0000000
--- a/examples/basic/main.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package main
-
-import (
- "os"
-
- charts "github.com/vicanso/go-charts"
-)
-
-func main() {
- buf, err := charts.RenderEChartsToPNG(`{
- "title": {
- "text": "Line"
- },
- "xAxis": {
- "type": "category",
- "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
- },
- "series": [
- {
- "data": [150, 230, 224, 218, 135, 147, 260]
- }
- ]
- }`)
- if err != nil {
- panic(err)
- }
- file, err := os.Create("output.png")
- if err != nil {
- panic(err)
- }
- defer file.Close()
- file.Write(buf)
-}
diff --git a/examples/charts/main.go b/examples/charts/main.go
index 4000b44..81bc4f2 100644
--- a/examples/charts/main.go
+++ b/examples/charts/main.go
@@ -2,9 +2,11 @@ package main
import (
"bytes"
+ "fmt"
"net/http"
+ "strconv"
- charts "github.com/vicanso/go-charts"
+ charts "git.smarteching.com/zeni/go-charts/v2"
)
var html = `
@@ -12,11 +14,16 @@ var html = `
-
@@ -50,342 +60,1915 @@ var html = `
`
-var chartOptions = []map[string]string{
- {
- "title": "折线图",
- "option": `{
- "title": {
- "text": "Line",
- "textAlign": "left",
- "textStyle": {
- "fontSize": 24,
- "height": 40
- }
- },
- "yAxis": {
- "min": 0,
- "max": 300
- },
- "xAxis": {
- "type": "category",
- "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
- },
- "series": [
- {
- "data": [150, 230, 224, 218, 135, 147, 260],
- "type": "line"
- }
- ]
-}`,
- },
- {
- "title": "多折线图",
- "option": `{
- "title": {
- "text": "Multi Line"
- },
- "legend": {
- "align": "left",
- "right": 0,
- "data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"]
- },
- "xAxis": {
- "type": "category",
- "boundaryGap": false,
- "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
- },
- "series": [
- {
- "type": "line",
- "data": [120, 132, 101, 134, 90, 230, 210]
- },
- {
- "data": [220, 182, 191, 234, 290, 330, 310]
- },
- {
- "data": [150, 232, 201, 154, 190, 330, 410]
- },
- {
- "data": [320, 332, 301, 334, 390, 330, 320]
- },
- {
- "data": [820, 932, 901, 934, 1290, 1330, 1320]
- }
- ]
-}`,
- },
- {
- "title": "柱状图",
- "option": `{
- "xAxis": {
- "type": "category",
- "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
- },
- "series": [
- {
- "data": [120, 200, 150, 80, 70, 110, 130],
- "type": "bar"
- }
- ]
-}`,
- },
- {
- "title": "柱状图(自定义颜色)",
- "option": `{
- "xAxis": {
- "type": "category",
- "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
- },
- "series": [
- {
- "data": [
- 120,
- {
- "value": 200,
- "itemStyle": {
- "color": "#a90000"
- }
- },
- 150,
- 80,
- 70,
- 110,
- 130
- ],
- "type": "bar"
- }
- ]
-}`,
- },
- {
- "title": "多柱状图",
- "option": `{
- "title": {
- "text": "Rainfall vs Evaporation"
- },
- "legend": {
- "data": ["Rainfall", "Evaporation"]
- },
- "xAxis": {
- "type": "category",
- "splitNumber": 12,
- "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]
- },
- {
- "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]
- }
- ]
-}`,
- },
- {
- "title": "折柱混合",
- "option": `{
- "legend": {
- "data": [
- "Evaporation",
- "Precipitation",
- "Temperature"
- ]
- },
- "xAxis": {
- "type": "category",
- "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
- },
- "yAxis": [
- {
- "type": "value",
- "name": "Precipitation",
- "min": 0,
- "max": 250,
- "interval": 50,
- "axisLabel": {
- "formatter": "{value} ml"
- }
- },
- {
- "type": "value",
- "name": "Temperature",
- "min": 0,
- "max": 25,
- "interval": 5,
- "axisLabel": {
- "formatter": "{value} °C"
- }
- }
- ],
- "series": [
- {
- "name": "Evaporation",
- "type": "bar",
- "itemStyle": {
- "color": "#0052d9"
- },
- "data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6]
- },
- {
- "name": "Precipitation",
- "type": "bar",
- "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6]
- },
- {
- "name": "Temperature",
- "type": "line",
- "yAxisIndex": 1,
- "data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3]
- }
- ]
-}`,
- },
- {
- "title": "降雨量",
- "option": `{
- "title": {
- "text": "降雨量"
- },
- "legend": {
- "data": ["GZ", "SH"]
- },
- "xAxis": {
- "type": "category",
- "splitNumber": 6,
- "data": ["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","01-22","01-23","01-24","01-25","01-26","01-27","01-28","01-29","01-30","01-31"]
- },
- "yAxis": {
- "axisLabel": {
- "formatter": "{value} mm"
- }
- },
- "series": [
- {
- "type": "bar",
- "data": [928,821,889,600,547,783,197,853,430,346,63,465,309,334,141,538,792,58,922,807,298,243,744,885,812,231,330,220,984,221,429]
- },
- {
- "type": "bar",
- "data": [749,201,296,579,255,159,902,246,149,158,507,776,186,79,390,222,601,367,221,411,714,620,966,73,203,631,833,610,487,677,596]
- }
- ]
-}`,
- },
- {
- "title": "饼图",
- "option": `{
- "title": {
- "text": "Referer of a Website"
- },
- "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"
- }
- ]
- }
- ]
-}`,
- },
-}
-
-type renderOptions struct {
- theme string
- width int
- height int
- onlyCharts bool
-}
-
-func render(opts renderOptions) ([]byte, error) {
- data := bytes.Buffer{}
- for _, m := range chartOptions {
- chartHTML := []byte(`
-
{{title}}
-
{{option}}
- {{svg}}
-
`)
- if opts.onlyCharts {
- chartHTML = []byte(`
- {{svg}}
-
`)
- }
- o, err := charts.ParseECharsOptions(m["option"])
- if opts.width > 0 {
- o.Width = opts.width
- }
- if opts.height > 0 {
- o.Height = opts.height
- }
-
- o.Theme = opts.theme
- if err != nil {
- return nil, err
- }
- g, err := charts.New(o)
- if err != nil {
- return nil, err
- }
- buf, err := charts.ToSVG(g)
- if err != nil {
- return nil, err
- }
- buf = bytes.ReplaceAll(chartHTML, []byte("{{svg}}"), buf)
- buf = bytes.ReplaceAll(buf, []byte("{{title}}"), []byte(m["title"]))
- buf = bytes.ReplaceAll(buf, []byte("{{option}}"), []byte(m["option"]))
- data.Write(buf)
- }
- return data.Bytes(), nil
-}
-
-func indexHandler(w http.ResponseWriter, r *http.Request) {
- query := r.URL.Query()
- opts := renderOptions{
- theme: query.Get("theme"),
- }
- if query.Get("view") == "grid" {
- opts.width = 400
- opts.height = 200
- opts.onlyCharts = true
- }
-
- buf, err := render(opts)
- if err != nil {
- w.WriteHeader(400)
- w.Write([]byte(err.Error()))
+func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.ChartOption, echartsOptions []string) {
+ if req.URL.Path != "/" &&
+ req.URL.Path != "/echarts" {
return
}
+ query := req.URL.Query()
+ theme := query.Get("theme")
+ width, _ := strconv.Atoi(query.Get("width"))
+ height, _ := strconv.Atoi(query.Get("height"))
+ charts.SetDefaultWidth(width)
+ charts.SetDefaultWidth(height)
+ 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)
+ }
+ buf, err := d.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ bytesList = append(bytesList, buf)
+ }
+ for _, opt := range echartsOptions {
+ buf, err := charts.RenderEChartsToSVG(opt)
+ if err != nil {
+ panic(err)
+ }
+ bytesList = append(bytesList, buf)
+ }
- data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), 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)
}
+func indexHandler(w http.ResponseWriter, req *http.Request) {
+ chartOptions := []charts.ChartOption{
+ {
+ Title: charts.TitleOption{
+ Text: "Line",
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }),
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ SeriesList: []charts.Series{
+ charts.NewSeriesFromValues([]float64{
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ }),
+ },
+ },
+ // 温度折线图
+ {
+ Title: charts.TitleOption{
+ Text: "Temperature Change in the Coming Week",
+ },
+ Padding: charts.Box{
+ Top: 20,
+ Left: 20,
+ Right: 30,
+ Bottom: 20,
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Highest",
+ "Lowest",
+ }, charts.PositionRight),
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }, charts.FalseFlag()),
+ SeriesList: []charts.Series{
+ {
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 14,
+ 11,
+ 13,
+ 11,
+ 12,
+ 12,
+ 7,
+ }),
+ MarkPoint: charts.NewMarkPoint(charts.SeriesMarkDataTypeMax, charts.SeriesMarkDataTypeMin),
+ MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage),
+ },
+ {
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 1,
+ -2,
+ 2,
+ 5,
+ 3,
+ 2,
+ 0,
+ }),
+ MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage),
+ },
+ },
+ },
+ {
+ 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{
+ Text: "Bar",
+ },
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ Legend: charts.LegendOption{
+ Data: []string{
+ "Rainfall",
+ "Evaporation",
+ },
+ Icon: charts.IconRect,
+ },
+ SeriesList: []charts.Series{
+ charts.NewSeriesFromValues([]float64{
+ 120,
+ 200,
+ 150,
+ 80,
+ 70,
+ 110,
+ 130,
+ }, charts.ChartTypeBar),
+ {
+ Type: charts.ChartTypeBar,
+ Data: []charts.SeriesData{
+ {
+ Value: 100,
+ },
+ {
+ Value: 190,
+ Style: charts.Style{
+ FillColor: charts.Color{
+ R: 169,
+ G: 0,
+ B: 0,
+ A: 255,
+ },
+ },
+ },
+ {
+ Value: 230,
+ },
+ {
+ Value: 140,
+ },
+ {
+ Value: 100,
+ },
+ {
+ Value: 200,
+ },
+ {
+ Value: 180,
+ },
+ },
+ Label: charts.SeriesLabel{
+ Show: true,
+ Position: charts.PositionBottom,
+ },
+ },
+ },
+ },
+ // 水平柱状图
+ {
+ Title: charts.TitleOption{
+ Text: "World Population",
+ },
+ Padding: charts.Box{
+ Top: 20,
+ Right: 40,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: charts.NewLegendOption([]string{
+ "2011",
+ "2012",
+ }),
+ YAxisOptions: charts.NewYAxisOptions([]string{
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ SeriesList: []charts.Series{
+ {
+ Type: charts.ChartTypeHorizontalBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230,
+ }),
+ },
+ {
+ Type: charts.ChartTypeHorizontalBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 19325,
+ 23438,
+ 31000,
+ 121594,
+ 134141,
+ 681807,
+ }),
+ },
+ },
+ },
+ // 柱状图+标记
+ {
+ Title: charts.TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ },
+ Padding: charts.Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ XAxis: charts.NewXAxisOption([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ Legend: charts.NewLegendOption([]string{
+ "Rainfall",
+ "Evaporation",
+ }, charts.PositionRight),
+ SeriesList: []charts.Series{
+ {
+ Type: charts.ChartTypeBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ }),
+ MarkPoint: charts.NewMarkPoint(
+ charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin,
+ ),
+ MarkLine: charts.NewMarkLine(
+ charts.SeriesMarkDataTypeAverage,
+ ),
+ },
+ {
+ Type: charts.ChartTypeBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ }),
+ MarkPoint: charts.NewMarkPoint(
+ charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin,
+ ),
+ MarkLine: charts.NewMarkLine(
+ charts.SeriesMarkDataTypeAverage,
+ ),
+ },
+ },
+ },
+ // 双Y轴示例
+ {
+ Title: charts.TitleOption{
+ Text: "Temperature",
+ },
+ XAxis: charts.NewXAxisOption([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ Legend: charts.NewLegendOption([]string{
+ "Evaporation",
+ "Precipitation",
+ "Temperature",
+ }),
+ YAxisOptions: []charts.YAxisOption{
+ {
+ Formatter: "{value}ml",
+ Color: charts.Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ },
+ {
+ Formatter: "{value}°C",
+ Color: charts.Color{
+ R: 250,
+ G: 200,
+ B: 88,
+ A: 255,
+ },
+ },
+ },
+ SeriesList: []charts.Series{
+ {
+ Type: charts.ChartTypeBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ }),
+ },
+ {
+ Type: charts.ChartTypeBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ }),
+ },
+ {
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.0,
+ 2.2,
+ 3.3,
+ 4.5,
+ 6.3,
+ 10.2,
+ 20.3,
+ 23.4,
+ 23.0,
+ 16.5,
+ 12.0,
+ 6.2,
+ }),
+ AxisIndex: 1,
+ },
+ },
+ },
+ // 饼图
+ {
+ Title: charts.TitleOption{
+ Text: "Referer of a Website",
+ Subtext: "Fake Data",
+ Left: charts.PositionCenter,
+ },
+ Legend: charts.LegendOption{
+ Orient: charts.OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: charts.PositionLeft,
+ },
+ SeriesList: charts.NewPieSeriesList([]float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }, charts.PieSeriesOption{
+ Label: charts.SeriesLabel{
+ Show: true,
+ },
+ Radius: "35%",
+ }),
+ },
+ // 雷达图
+ {
+ Title: charts.TitleOption{
+ Text: "Basic Radar Chart",
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ RadarIndicators: []charts.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: charts.SeriesList{
+ {
+ Type: charts.ChartTypeRadar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ }),
+ },
+ {
+ Type: charts.ChartTypeRadar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
+ }),
+ },
+ },
+ },
+ // 漏斗图
+ {
+ Title: charts.TitleOption{
+ Text: "Funnel",
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "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",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 60,
+ }),
+ },
+ {
+ Type: charts.ChartTypeFunnel,
+ Name: "Inquiry",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 40,
+ }),
+ },
+ {
+ Type: charts.ChartTypeFunnel,
+ Name: "Order",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 20,
+ }),
+ },
+ },
+ },
+ // 多图展示
+ {
+ Legend: charts.LegendOption{
+ Top: "-90",
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Padding: charts.Box{
+ Top: 100,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: charts.NewXAxisOption([]string{
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017",
+ }),
+ YAxisOptions: []charts.YAxisOption{
+ {
+
+ Min: charts.NewFloatPoint(0),
+ Max: charts.NewFloatPoint(90),
+ },
+ },
+ SeriesList: []charts.Series{
+ charts.NewSeriesFromValues([]float64{
+ 56.5,
+ 82.1,
+ 88.7,
+ 70.1,
+ 53.4,
+ 85.1,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 51.1,
+ 51.4,
+ 55.1,
+ 53.3,
+ 73.8,
+ 68.7,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 40.1,
+ 62.2,
+ 69.5,
+ 36.4,
+ 45.2,
+ 32.5,
+ }, charts.ChartTypeBar),
+ charts.NewSeriesFromValues([]float64{
+ 25.2,
+ 37.1,
+ 41.2,
+ 18,
+ 33.9,
+ 49.1,
+ }, charts.ChartTypeBar),
+ },
+ Children: []charts.ChartOption{
+ {
+ Legend: charts.LegendOption{
+ Show: charts.FalseFlag(),
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Box: charts.Box{
+ Top: 20,
+ Left: 400,
+ Right: 500,
+ Bottom: 120,
+ },
+ SeriesList: charts.NewPieSeriesList([]float64{
+ 435.9,
+ 354.3,
+ 285.9,
+ 204.5,
+ }, charts.PieSeriesOption{
+ Label: charts.SeriesLabel{
+ Show: true,
+ },
+ Radius: "35%",
+ }),
+ },
+ },
+ },
+ }
+ handler(w, req, chartOptions, nil)
+}
+
+func echartsHandler(w http.ResponseWriter, req *http.Request) {
+ echartsOptions := []string{
+ `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 150,
+ 230,
+ 224,
+ 218,
+ 135,
+ 147,
+ 260
+ ],
+ "type": "line"
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Multiple Line"
+ },
+ "tooltip": {
+ "trigger": "axis"
+ },
+ "legend": {
+ "left": "right",
+ "data": [
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine"
+ ]
+ },
+ "grid": {
+ "left": "3%",
+ "right": "4%",
+ "bottom": "3%",
+ "containLabel": true
+ },
+ "toolbox": {
+ "feature": {
+ "saveAsImage": {}
+ }
+ },
+ "xAxis": {
+ "type": "category",
+ "boundaryGap": false,
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "name": "Email",
+ "type": "line",
+ "data": [
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210
+ ]
+ },
+ {
+ "name": "Union Ads",
+ "type": "line",
+ "data": [
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310
+ ]
+ },
+ {
+ "name": "Video Ads",
+ "type": "line",
+ "data": [
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410
+ ]
+ },
+ {
+ "name": "Direct",
+ "type": "line",
+ "data": [
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320
+ ]
+ },
+ {
+ "name": "Search Engine",
+ "type": "line",
+ "data": [
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Temperature Change in the Coming Week"
+ },
+ "legend": {
+ "left": "right"
+ },
+ "padding": [10, 30, 10, 10],
+ "xAxis": {
+ "type": "category",
+ "boundaryGap": false,
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "axisLabel": {
+ "formatter": "{value} °C"
+ }
+ },
+ "series": [
+ {
+ "name": "Highest",
+ "type": "line",
+ "data": [
+ 10,
+ 11,
+ 13,
+ 11,
+ 12,
+ 12,
+ 9
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ },
+ {
+ "name": "Lowest",
+ "type": "line",
+ "data": [
+ 1,
+ -2,
+ 2,
+ 5,
+ 3,
+ 2,
+ 0
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ },
+ {
+ "type": "max"
+ }
+ ]
+ }
+ }
+ ]
+ }`,
+ `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 120,
+ 200,
+ 150,
+ 80,
+ 70,
+ 110,
+ 130
+ ],
+ "type": "bar"
+ }
+ ]
+ }`,
+ `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 120,
+ {
+ "value": 200,
+ "itemStyle": {
+ "color": "#a90000"
+ }
+ },
+ 150,
+ 80,
+ 70,
+ 110,
+ 130
+ ],
+ "type": "bar"
+ }
+ ]
+ }`,
+ `{
+ "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",
+ "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"
+ }
+ ]
+ }
+ }
+ ]
+ }`,
+ `{
+ "legend": {
+ "data": [
+ "Evaporation",
+ "Precipitation",
+ "Temperature"
+ ]
+ },
+ "xAxis": [
+ {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ],
+ "axisPointer": {
+ "type": "shadow"
+ }
+ }
+ ],
+ "yAxis": [
+ {
+ "type": "value",
+ "name": "Precipitation",
+ "min": 0,
+ "max": 240,
+ "axisLabel": {
+ "formatter": "{value} ml"
+ }
+ },
+ {
+ "type": "value",
+ "name": "Temperature",
+ "min": 0,
+ "max": 24,
+ "axisLabel": {
+ "formatter": "{value} °C"
+ }
+ }
+ ],
+ "series": [
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "tooltip": {},
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6
+ ]
+ },
+ {
+ "name": "Precipitation",
+ "type": "bar",
+ "tooltip": {},
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6
+ ]
+ },
+ {
+ "name": "Temperature",
+ "type": "line",
+ "yAxisIndex": 1,
+ "tooltip": {},
+ "data": [
+ 2,
+ 2.2,
+ 3.3,
+ 4.5,
+ 6.3,
+ 10.2,
+ 20.3
+ ]
+ }
+ ]
+ }`,
+ `{
+ "tooltip": {
+ "trigger": "axis",
+ "axisPointer": {
+ "type": "cross"
+ }
+ },
+ "grid": {
+ "right": "20%"
+ },
+ "toolbox": {
+ "feature": {
+ "dataView": {
+ "show": true,
+ "readOnly": false
+ },
+ "restore": {
+ "show": true
+ },
+ "saveAsImage": {
+ "show": true
+ }
+ }
+ },
+ "legend": {
+ "data": [
+ "Evaporation",
+ "Precipitation",
+ "Temperature"
+ ]
+ },
+ "xAxis": [
+ {
+ "type": "category",
+ "axisTick": {
+ "alignWithLabel": true
+ },
+ "data": [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec"
+ ]
+ }
+ ],
+ "yAxis": [
+ {
+ "type": "value",
+ "name": "温度",
+ "position": "left",
+ "alignTicks": true,
+ "axisLine": {
+ "show": true,
+ "lineStyle": {
+ "color": "#EE6666"
+ }
+ },
+ "axisLabel": {
+ "formatter": "{value} °C"
+ }
+ },
+ {
+ "type": "value",
+ "name": "Evaporation",
+ "position": "right",
+ "alignTicks": true,
+ "axisLine": {
+ "show": true,
+ "lineStyle": {
+ "color": "#5470C6"
+ }
+ },
+ "axisLabel": {
+ "formatter": "{value} ml"
+ }
+ }
+ ],
+ "series": [
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "yAxisIndex": 1,
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20,
+ 6.4,
+ 3.3
+ ]
+ },
+ {
+ "name": "Precipitation",
+ "type": "bar",
+ "yAxisIndex": 1,
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6,
+ 2.3
+ ]
+ },
+ {
+ "name": "Temperature",
+ "type": "line",
+ "data": [
+ 2,
+ 2.2,
+ 3.3,
+ 4.5,
+ 6.3,
+ 10.2,
+ 20.3,
+ 23.4,
+ 23,
+ 16.5,
+ 12,
+ 6.2
+ ]
+ }
+ ]
+ }`,
+ `{
+ "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"
+ }
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Rainfall"
+ },
+ "padding": [10, 10, 10, 30],
+ "legend": {
+ "data": [
+ "GZ",
+ "SH"
+ ]
+ },
+ "xAxis": {
+ "type": "category",
+ "splitNumber": 6,
+ "data": [
+ "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",
+ "01-22",
+ "01-23",
+ "01-24",
+ "01-25",
+ "01-26",
+ "01-27",
+ "01-28",
+ "01-29",
+ "01-30",
+ "01-31"
+ ]
+ },
+ "yAxis": {
+ "axisLabel": {
+ "formatter": "{value} mm"
+ }
+ },
+ "series": [
+ {
+ "type": "bar",
+ "data": [
+ 928,
+ 821,
+ 889,
+ 600,
+ 547,
+ 783,
+ 197,
+ 853,
+ 430,
+ 346,
+ 63,
+ 465,
+ 309,
+ 334,
+ 141,
+ 538,
+ 792,
+ 58,
+ 922,
+ 807,
+ 298,
+ 243,
+ 744,
+ 885,
+ 812,
+ 231,
+ 330,
+ 220,
+ 984,
+ 221,
+ 429
+ ]
+ },
+ {
+ "type": "bar",
+ "data": [
+ 749,
+ 201,
+ 296,
+ 579,
+ 255,
+ 159,
+ 902,
+ 246,
+ 149,
+ 158,
+ 507,
+ 776,
+ 186,
+ 79,
+ 390,
+ 222,
+ 601,
+ 367,
+ 221,
+ 411,
+ 714,
+ 620,
+ 966,
+ 73,
+ 203,
+ 631,
+ 833,
+ 610,
+ 487,
+ 677,
+ 596
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Basic Radar Chart"
+ },
+ "legend": {
+ "data": [
+ "Allocated Budget",
+ "Actual Spending"
+ ]
+ },
+ "radar": {
+ "indicator": [
+ {
+ "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
+ }
+ ]
+ },
+ "series": [
+ {
+ "name": "Budget vs spending",
+ "type": "radar",
+ "data": [
+ {
+ "value": [
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000
+ ],
+ "name": "Allocated Budget"
+ },
+ {
+ "value": [
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000
+ ],
+ "name": "Actual Spending"
+ }
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Funnel"
+ },
+ "tooltip": {
+ "trigger": "item",
+ "formatter": "{a} {b} : {c}%"
+ },
+ "toolbox": {
+ "feature": {
+ "dataView": {
+ "readOnly": false
+ },
+ "restore": {},
+ "saveAsImage": {}
+ }
+ },
+ "legend": {
+ "data": [
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order"
+ ]
+ },
+ "series": [
+ {
+ "name": "Funnel",
+ "type": "funnel",
+ "left": "10%",
+ "top": 60,
+ "bottom": 60,
+ "width": "80%",
+ "min": 0,
+ "max": 100,
+ "minSize": "0%",
+ "maxSize": "100%",
+ "sort": "descending",
+ "gap": 2,
+ "label": {
+ "show": true,
+ "position": "inside"
+ },
+ "labelLine": {
+ "length": 10,
+ "lineStyle": {
+ "width": 1,
+ "type": "solid"
+ }
+ },
+ "itemStyle": {
+ "borderColor": "#fff",
+ "borderWidth": 1
+ },
+ "emphasis": {
+ "label": {
+ "fontSize": 20
+ }
+ },
+ "data": [
+ {
+ "value": 60,
+ "name": "Visit"
+ },
+ {
+ "value": 40,
+ "name": "Inquiry"
+ },
+ {
+ "value": 20,
+ "name": "Order"
+ },
+ {
+ "value": 80,
+ "name": "Click"
+ },
+ {
+ "value": 100,
+ "name": "Show"
+ }
+ ]
+ }
+ ]
+ }`,
+ `{
+ "legend": {
+ "top": "-140",
+ "data": [
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie"
+ ]
+ },
+ "padding": [
+ 150,
+ 10,
+ 10,
+ 10
+ ],
+ "xAxis": [
+ {
+ "data": [
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "data": [
+ 56.5,
+ 82.1,
+ 88.7,
+ 70.1,
+ 53.4,
+ 85.1
+ ]
+ },
+ {
+ "data": [
+ 51.1,
+ 51.4,
+ 55.1,
+ 53.3,
+ 73.8,
+ 68.7
+ ]
+ },
+ {
+ "data": [
+ 40.1,
+ 62.2,
+ 69.5,
+ 36.4,
+ 45.2,
+ 32.5
+ ]
+ },
+ {
+ "data": [
+ 25.2,
+ 37.1,
+ 41.2,
+ 18,
+ 33.9,
+ 49.1
+ ]
+ }
+ ],
+ "children": [
+ {
+ "box": {
+ "left": 0,
+ "top": 30,
+ "right": 600,
+ "bottom": 150
+ },
+ "legend": {
+ "show": false
+ },
+ "series": [
+ {
+ "type": "pie",
+ "radius": "50%",
+ "data": [
+ {
+ "value": 435.9,
+ "name": "Milk Tea"
+ },
+ {
+ "value": 354.3,
+ "name": "Matcha Latte"
+ },
+ {
+ "value": 285.9,
+ "name": "Cheese Cocoa"
+ },
+ {
+ "value": 204.5,
+ "name": "Walnut Brownie"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }`,
+ }
+ handler(w, req, nil, echartsOptions)
+}
+
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
new file mode 100644
index 0000000..601f54e
--- /dev/null
+++ b/examples/chinese/main.go
@@ -0,0 +1,120 @@
+package main
+
+import (
+ "io/ioutil"
+ "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, "chinese-line-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ // 字体文件需要自行下载
+ // https://github.com/googlefonts/noto-cjk
+ buf, err := ioutil.ReadFile("./NotoSansSC.ttf")
+ if err != nil {
+ panic(err)
+ }
+ err = charts.InstallFont("noto", buf)
+ if err != nil {
+ panic(err)
+ }
+ font, _ := charts.GetFont("noto")
+ charts.SetDefaultFont(font)
+
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ p, err := charts.LineRender(
+ values,
+ charts.TitleTextOptionFunc("测试"),
+ charts.XAxisDataOptionFunc([]string{
+ "星期一",
+ "星期二",
+ "星期三",
+ "星期四",
+ "星期五",
+ "星期六",
+ "星期日",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "邮件",
+ "广告",
+ "视频广告",
+ "直接访问",
+ "搜索引擎",
+ }, charts.PositionCenter),
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ 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/line_series.go b/font.go
similarity index 50%
rename from line_series.go
rename to font.go
index f3686ad..828654e 100644
--- a/line_series.go
+++ b/font.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -23,23 +23,56 @@
package charts
import (
- "github.com/wcharczuk/go-chart/v2"
+ "errors"
+ "sync"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2/roboto"
)
-type LineSeries struct {
- BaseSeries
+var fonts = sync.Map{}
+var ErrFontNotExists = errors.New("font is not exists")
+var defaultFontFamily = "defaultFontFamily"
+
+func init() {
+ name := "roboto"
+ _ = InstallFont(name, roboto.Roboto)
+ font, _ := GetFont(name)
+ SetDefaultFont(font)
}
-func (ls LineSeries) getXRange(xrange chart.Range) chart.Range {
- if ls.TickPosition != chart.TickPositionBetweenTicks {
- return xrange
+// InstallFont installs the font for charts
+func InstallFont(fontFamily string, data []byte) error {
+ font, err := truetype.Parse(data)
+ if err != nil {
+ return err
}
- // 如果是居中,画线时重新调整
- return wrapRange(xrange, ls.TickPosition)
+ fonts.Store(fontFamily, font)
+ return nil
}
-func (ls LineSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) {
- style := ls.Style.InheritFrom(defaults)
- xrange = ls.getXRange(xrange)
- chart.Draw.LineSeries(r, canvasBox, xrange, yrange, style, ls)
+// 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)
+ if !ok {
+ return nil, ErrFontNotExists
+ }
+ f, ok := value.(*truetype.Font)
+ if !ok {
+ return nil, ErrFontNotExists
+ }
+ return f, nil
}
diff --git a/line_series_test.go b/font_test.go
similarity index 73%
rename from line_series_test.go
rename to font_test.go
index 27c9371..e0c56b2 100644
--- a/line_series_test.go
+++ b/font_test.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -26,21 +26,17 @@ import (
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/roboto"
)
-func TestLineSeries(t *testing.T) {
+func TestInstallFont(t *testing.T) {
assert := assert.New(t)
- ls := LineSeries{}
+ fontFamily := "test"
+ err := InstallFont(fontFamily, roboto.Roboto)
+ assert.Nil(err)
- originalRange := &chart.ContinuousRange{}
- xrange := ls.getXRange(originalRange)
- assert.Equal(originalRange, xrange)
-
- ls.TickPosition = chart.TickPositionBetweenTicks
- xrange = ls.getXRange(originalRange)
- value, ok := xrange.(*Range)
- assert.True(ok)
- assert.Equal(originalRange, &value.ContinuousRange)
+ font, err := GetFont(fontFamily)
+ assert.Nil(err)
+ assert.NotNil(font)
}
diff --git a/funnel_chart.go b/funnel_chart.go
new file mode 100644
index 0000000..d4a8bdd
--- /dev/null
+++ b/funnel_chart.go
@@ -0,0 +1,192 @@
+// 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"
+)
+
+type funnelChart struct {
+ p *Painter
+ opt *FunnelChartOption
+}
+
+// NewFunnelSeriesList returns a series list for funnel
+func NewFunnelSeriesList(values []float64) SeriesList {
+ seriesList := make(SeriesList, len(values))
+ for index, value := range values {
+ seriesList[index] = NewSeriesFromValues([]float64{
+ value,
+ }, ChartTypeFunnel)
+ }
+ return seriesList
+}
+
+// NewFunnelChart returns a funnel chart renderer
+func NewFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &funnelChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type FunnelChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ 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 {
+ if item.Max != nil {
+ max = *item.Max
+ }
+ if item.Min != nil {
+ min = *item.Min
+ }
+ }
+ theme := opt.Theme
+ gap := 2
+ height := seriesPainter.Height()
+ width := seriesPainter.Width()
+ count := len(seriesList)
+
+ h := (height - gap*(count-1)) / count
+
+ y := 0
+ widthList := make([]int, len(seriesList))
+ textList := make([]string, len(seriesList))
+ seriesNames := seriesList.Names()
+ offset := max - min
+ for index, item := range seriesList {
+ value := item.Data[0].Value
+ // 最大最小值一致则为100%
+ widthPercent := 100.0
+ if offset != 0 {
+ widthPercent = (value - min) / offset
+ }
+ w := int(widthPercent * float64(width))
+ widthList[index] = w
+ // 如果最大值为0,则占比100%
+ percent := 1.0
+ if max != 0 {
+ percent = value / max
+ }
+ textList[index] = NewFunnelLabelFormatter(seriesNames, item.Label.Formatter)(index, value, percent)
+ }
+
+ for index, w := range widthList {
+ series := seriesList[index]
+ nextWidth := 0
+ if index+1 < len(widthList) {
+ nextWidth = widthList[index+1]
+ }
+ topStartX := (width - w) >> 1
+ topEndX := topStartX + w
+ bottomStartX := (width - nextWidth) >> 1
+ bottomEndX := bottomStartX + nextWidth
+ points := []Point{
+ {
+ X: topStartX,
+ Y: y,
+ },
+ {
+ X: topEndX,
+ Y: y,
+ },
+ {
+ X: bottomEndX,
+ Y: y + h,
+ },
+ {
+ X: bottomStartX,
+ Y: y + h,
+ },
+ {
+ X: topStartX,
+ Y: y,
+ },
+ }
+ color := theme.GetSeriesColor(series.index)
+
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: color,
+ }).FillArea(points)
+
+ // 文本
+ text := textList[index]
+ seriesPainter.OverrideTextStyle(Style{
+ FontColor: theme.GetTextColor(),
+ FontSize: labelFontSize,
+ Font: opt.Font,
+ })
+ textBox := seriesPainter.MeasureText(text)
+ textX := width>>1 - textBox.Width()>>1
+ textY := y + h>>1
+ seriesPainter.Text(text, textX, textY)
+ y += (h + gap)
+ }
+
+ return f.p.box, nil
+}
+
+func (f *funnelChart) Render() (Box, error) {
+ p := f.p
+ opt := f.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: XAxisOption{
+ Show: FalseFlag(),
+ },
+ YAxisOptions: []YAxisOption{
+ {
+ Show: FalseFlag(),
+ },
+ },
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeFunnel)
+ return f.render(renderResult, seriesList)
+}
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/go.mod b/go.mod
index b21ce18..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
- github.com/stretchr/testify v1.7.0
- github.com/wcharczuk/go-chart/v2 v2.1.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.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // 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 51e8735..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.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
-github.com/stretchr/testify v1.7.0/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-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
-golang.org/x/image v0.0.0-20211028202545-6944b10bf410/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 bf968fe..035642c 100644
--- a/legend.go
+++ b/legend.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -25,227 +25,227 @@ 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 {
- Style chart.Style
- Padding chart.Box
- Left string
- Right string
- Top string
- Bottom string
- Align string
- Theme string
- IconDraw LegendIconDraw
+ // The theme
+ Theme ColorPalette
+ // Text array of legend
+ Data []string
+ // Distance between legend component and the left side of the container.
+ // It can be pixel value: 20, percentage value: 20%,
+ // or position value: right, center.
+ Left string
+ // Distance between legend component and the top side of the container.
+ // It can be pixel value: 20.
+ Top string
+ // Legend marker and text aligning, it can be left or right, default is left.
+ Align string
+ // The layout orientation of legend, it can be horizontal or vertical, default is horizontal.
+ 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
}
-type LegendIconDrawOption struct {
- Box chart.Box
- Style chart.Style
- Index int
- Theme string
+// NewLegendOption returns a legend option
+func NewLegendOption(labels []string, left ...string) LegendOption {
+ opt := LegendOption{
+ Data: labels,
+ }
+ if len(left) != 0 {
+ opt.Left = left[0]
+ }
+ return opt
}
-const (
- LegendAlignLeft = "left"
- LegendAlignRight = "right"
-)
-
-type LegendIconDraw func(r chart.Renderer, opt LegendIconDrawOption)
-
-func DefaultLegendIconDraw(r chart.Renderer, opt LegendIconDrawOption) {
- if opt.Box.IsZero() {
- return
+// IsEmpty checks legend is empty
+func (opt *LegendOption) IsEmpty() bool {
+ isEmpty := true
+ for _, v := range opt.Data {
+ if v != "" {
+ isEmpty = false
+ break
+ }
}
- r.SetStrokeColor(opt.Style.GetStrokeColor())
- strokeWidth := opt.Style.GetStrokeWidth()
- r.SetStrokeWidth(strokeWidth)
- height := opt.Box.Bottom - opt.Box.Top
- ly := opt.Box.Top - (height / 2) + 2
- r.MoveTo(opt.Box.Left, ly)
- r.LineTo(opt.Box.Right, ly)
- r.Stroke()
- r.SetFillColor(getBackgroundColor(opt.Theme))
- r.Circle(5, (opt.Box.Left+opt.Box.Right)/2, ly)
- r.FillStroke()
+ return isEmpty
}
-func covertPercent(value string) float64 {
- if !strings.HasSuffix(value, "%") {
- return -1
- }
- v, err := strconv.Atoi(strings.ReplaceAll(value, "%", ""))
- if err != nil {
- return -1
- }
- return float64(v) / 100
-}
-
-func getLegendLeft(width, legendBoxWidth int, opt LegendOption) int {
- left := (width - legendBoxWidth) / 2
- leftValue := opt.Left
- if leftValue == "auto" || leftValue == "center" {
- leftValue = ""
- }
- if leftValue == "left" {
- leftValue = "0"
- }
-
- rightValue := opt.Right
- if rightValue == "auto" || leftValue == "center" {
- rightValue = ""
- }
- if rightValue == "right" {
- rightValue = "0"
- }
- if leftValue == "" && rightValue == "" {
- return left
- }
- if leftValue != "" {
- percent := covertPercent(leftValue)
- if percent >= 0 {
- return int(float64(width) * percent)
- }
- v, _ := strconv.Atoi(leftValue)
- return v
- }
- if rightValue != "" {
- percent := covertPercent(rightValue)
- if percent >= 0 {
- return width - legendBoxWidth - int(float64(width)*percent)
- }
- v, _ := strconv.Atoi(rightValue)
- return width - legendBoxWidth - v
- }
- return left
-}
-
-func getLegendTop(height, legendBoxHeight int, opt LegendOption) int {
- // TODO 支持top的处理
- return 0
-}
-
-func LegendCustomize(series []chart.Series, opt LegendOption) chart.Renderable {
- return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) {
- legendDefaults := chart.Style{
- FontColor: getTextColor(opt.Theme),
- FontSize: 8.0,
- StrokeColor: chart.DefaultAxisColor,
- }
-
- legendStyle := opt.Style.InheritFrom(chartDefaults.InheritFrom(legendDefaults))
-
- r.SetFont(legendStyle.GetFont())
- r.SetFontColor(legendStyle.GetFontColor())
- r.SetFontSize(legendStyle.GetFontSize())
-
- var labels []string
- var lines []chart.Style
- // 计算label和lines
- for _, s := range series {
- if !s.GetStyle().Hidden {
- if _, isAnnotationSeries := s.(chart.AnnotationSeries); !isAnnotationSeries {
- labels = append(labels, s.GetName())
- lines = append(lines, s.GetStyle())
- }
- }
- }
-
- var textHeight int
- var textWidth int
- var textBox chart.Box
- labelWidth := 0
- // 计算文本宽度与高度(取最大值)
- for x := 0; x < len(labels); x++ {
- if len(labels[x]) > 0 {
- textBox = r.MeasureText(labels[x])
- labelWidth += textBox.Width()
- textHeight = chart.MaxInt(textBox.Height(), textHeight)
- textWidth = chart.MaxInt(textBox.Width(), textWidth)
- }
- }
-
- legendBoxHeight := textHeight + legendStyle.Padding.Top + legendStyle.Padding.Bottom
- chartPadding := cb.Top
- legendYMargin := (chartPadding - legendBoxHeight) >> 1
-
- iconWidth := 25
- lineTextGap := 5
-
- iconAllWidth := iconWidth * len(labels)
- spaceAllWidth := chart.DefaultMinimumTickHorizontalSpacing * (len(labels) - 1)
-
- legendBoxWidth := labelWidth + iconAllWidth + spaceAllWidth
-
- left := getLegendLeft(cb.Width(), legendBoxWidth, opt)
- top := getLegendTop(cb.Height(), legendBoxHeight, opt)
-
- left += opt.Padding.Left
- top += opt.Padding.Top
-
- legendBox := chart.Box{
- Left: left,
- Right: left + legendBoxWidth,
- Top: top,
- Bottom: top + legendBoxHeight,
- }
-
- chart.Draw.Box(r, legendBox, legendDefaults)
-
- r.SetFont(legendStyle.GetFont())
- r.SetFontColor(legendStyle.GetFontColor())
- r.SetFontSize(legendStyle.GetFontSize())
-
- startX := legendBox.Left + legendStyle.Padding.Left
- ty := top + legendYMargin + legendStyle.Padding.Top + textHeight
- var label string
- var x int
- iconDraw := opt.IconDraw
- if iconDraw == nil {
- iconDraw = DefaultLegendIconDraw
- }
- align := opt.Align
- if align == "" {
- align = LegendAlignLeft
- }
- for index := range labels {
- label = labels[index]
- if len(label) > 0 {
- x = startX
-
- // 如果图例标记靠右展示
- if align == LegendAlignRight {
- textBox = r.MeasureText(label)
- r.Text(label, x, ty)
- x = startX + textBox.Width() + lineTextGap
- }
-
- // 图标
- iconDraw(r, LegendIconDrawOption{
- Theme: opt.Theme,
- Index: index,
- Style: lines[index],
- Box: chart.Box{
- Left: x,
- Top: ty,
- Right: x + iconWidth,
- Bottom: ty + textHeight,
- },
- })
- x += (iconWidth + lineTextGap)
-
- // 如果图例标记靠左展示
- if align == LegendAlignLeft {
- textBox = r.MeasureText(label)
- r.Text(label, x, ty)
- x += textBox.Width()
- }
-
- // 计算下一个legend的位置
- startX = x + chart.DefaultMinimumTickHorizontalSpacing
- }
- }
+// NewLegendPainter returns a legend renderer
+func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter {
+ return &legendPainter{
+ p: p,
+ opt: &opt,
}
}
+
+func (l *legendPainter) Render() (Box, error) {
+ opt := l.opt
+ theme := opt.Theme
+ if opt.IsEmpty() ||
+ isFalse(opt.Show) {
+ return BoxZero, nil
+ }
+ if theme == nil {
+ theme = l.p.theme
+ }
+ if opt.FontSize == 0 {
+ opt.FontSize = theme.GetFontSize()
+ }
+ 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 index, text := range opt.Data {
+ b := p.MeasureText(text)
+ if b.Width() > maxTextWidth {
+ maxTextWidth = b.Width()
+ }
+ 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 = p.Width() - width
+ case PositionCenter:
+ left = (p.Width() - width) >> 1
+ default:
+ if strings.HasSuffix(opt.Left, "%") {
+ value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
+ left = p.Width() * value / 100
+ } else {
+ value, _ := strconv.Atoi(opt.Left)
+ left = value
+ }
+ }
+ top, _ := strconv.Atoi(opt.Top)
+
+ if left < 0 {
+ left = 0
+ }
+
+ x := int(left)
+ y := int(top) + 10
+ startY := y
+ x0 := x
+ y0 := y
+
+ drawIcon := func(top, left int) int {
+ if opt.Icon == IconRect {
+ p.Rect(Box{
+ Top: top - legendHeight + 8,
+ Left: left,
+ Right: left + legendWidth,
+ Bottom: top + 1,
+ })
+ } else {
+ p.LegendLineDot(Box{
+ Top: top + 1,
+ Left: left,
+ Right: left + legendWidth,
+ Bottom: top + legendHeight + 1,
+ })
+ }
+ return left + legendWidth
+ }
+ lastIndex := len(opt.Data) - 1
+ for index, text := range opt.Data {
+ color := theme.GetSeriesColor(index)
+ p.SetDrawingStyle(Style{
+ FillColor: color,
+ StrokeColor: color,
+ })
+ itemWidth := x0 + measureList[index].Width() + textOffset + offset + legendWidth
+ if lastIndex == index {
+ itemWidth = x0 + measureList[index].Width() + legendWidth
+ }
+ if itemWidth > p.Width() {
+ x0 = 0
+ y += itemMaxHeight
+ y0 = y
+ }
+ if opt.Align != AlignRight {
+ x0 = drawIcon(y0, x0)
+ x0 += textOffset
+ }
+ p.Text(text, x0, y0)
+ x0 += measureList[index].Width()
+ if opt.Align == AlignRight {
+ x0 += textOffset
+ x0 = drawIcon(y0, x0)
+ }
+ if opt.Orient == OrientVertical {
+ y0 += offset
+ x0 = x
+ } else {
+ x0 += offset
+ y0 = y
+ }
+ height = y0 - startY + 10
+ }
+
+ return Box{
+ Right: width,
+ Bottom: height + padding.Bottom + padding.Top,
+ }, nil
+}
diff --git a/legend_test.go b/legend_test.go
index 5bfacf8..526f178 100644
--- a/legend_test.go
+++ b/legend_test.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -23,56 +23,80 @@
package charts
import (
- "bytes"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/wcharczuk/go-chart/v2"
)
-func TestLegendCustomize(t *testing.T) {
+func TestNewLegend(t *testing.T) {
assert := assert.New(t)
- series := GetSeries([]Series{
- {
- Name: "chrome",
- },
- {
- Name: "edge",
- },
- }, chart.TickPositionBetweenTicks, "")
-
tests := []struct {
- align string
- svg string
+ render func(*Painter) ([]byte, error)
+ result string
}{
{
- align: LegendAlignLeft,
- svg: "\\nchrome edge ",
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewLegendPainter(p, LegendOption{
+ Data: []string{
+ "One",
+ "Two",
+ "Three",
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\nOne Two Three ",
},
{
- align: LegendAlignRight,
- svg: "\\nchrome edge ",
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewLegendPainter(p, LegendOption{
+ Data: []string{
+ "One",
+ "Two",
+ "Three",
+ },
+ Left: PositionLeft,
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\nOne Two Three ",
+ },
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewLegendPainter(p, LegendOption{
+ Data: []string{
+ "One",
+ "Two",
+ "Three",
+ },
+ Orient: OrientVertical,
+ Icon: IconRect,
+ Left: "10%",
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\nOne Two Three ",
},
}
-
for _, tt := range tests {
- r, err := chart.SVG(800, 600)
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
assert.Nil(err)
- fn := LegendCustomize(series, LegendOption{
- Align: tt.align,
- IconDraw: DefaultLegendIconDraw,
- Padding: chart.Box{
- Left: 100,
- Top: 100,
- },
- })
- fn(r, chart.NewBox(11, 47, 784, 373), chart.Style{
- Font: chart.StyleTextDefaults().Font,
- })
- buf := bytes.Buffer{}
- err = r.Save(&buf)
+ data, err := tt.render(p)
assert.Nil(err)
- assert.Equal(tt.svg, buf.String())
+ assert.Equal(tt.result, string(data))
}
}
diff --git a/line_chart.go b/line_chart.go
new file mode 100644
index 0000000..fb1d16a
--- /dev/null
+++ b/line_chart.go
@@ -0,0 +1,240 @@
+// 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 (
+ "math"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+type lineChart struct {
+ p *Painter
+ opt *LineChartOption
+}
+
+// NewLineChart returns a line chart render
+func NewLineChart(p *Painter, opt LineChartOption) *lineChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &lineChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type LineChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ 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
+ }
+ } 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,
+ StrokeWidth: strokeWidth,
+ }
+ if len(series.Style.StrokeDashArray) > 0 {
+ drawingStyle.StrokeDashArray = series.Style.StrokeDashArray
+ }
+
+ yRange := result.axisRanges[series.AxisIndex]
+ points := make([]Point, 0)
+ var labelPainter *SeriesLabelPainter
+ if series.Label.Show {
+ labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
+ P: seriesPainter,
+ SeriesNames: seriesNames,
+ Label: series.Label,
+ Theme: opt.Theme,
+ Font: opt.Font,
+ })
+ rendererList = append(rendererList, labelPainter)
+ }
+ for i, item := range series.Data {
+ h := yRange.getRestHeight(item.Value)
+ if item.Value == nullValue {
+ h = int(math.MaxInt32)
+ }
+ p := Point{
+ X: xValues[i],
+ Y: h,
+ }
+ points = append(points, p)
+
+ // 如果label不需要展示,则返回
+ if labelPainter == nil {
+ continue
+ }
+ 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)
+
+ // 画线
+ seriesPainter.LineStroke(points)
+
+ // 画点
+ if opt.Theme.IsDark() {
+ drawingStyle.FillColor = drawingStyle.StrokeColor
+ } else {
+ drawingStyle.FillColor = drawing.ColorWhite
+ }
+ 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,
+ })
+ 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 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
new file mode 100644
index 0000000..e169f90
--- /dev/null
+++ b/line_chart_test.go
@@ -0,0 +1,219 @@
+// 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 TestLineChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ _, err := NewLineChart(p, LineChartOption{
+ Title: TitleOption{
+ Text: "Line",
+ },
+ Padding: Box{
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ Legend: NewLegendOption([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }, PositionCenter),
+ SeriesList: NewSeriesListDataFromValues(values),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\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 ",
+ },
+ }
+
+ 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/mark_line.go b/mark_line.go
new file mode 100644
index 0000000..bc850bb
--- /dev/null
+++ b/mark_line.go
@@ -0,0 +1,113 @@
+// 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"
+)
+
+// NewMarkLine returns a series mark line
+func NewMarkLine(markLineTypes ...string) SeriesMarkLine {
+ data := make([]SeriesMarkData, len(markLineTypes))
+ for index, t := range markLineTypes {
+ data[index] = SeriesMarkData{
+ Type: t,
+ }
+ }
+ return SeriesMarkLine{
+ Data: data,
+ }
+}
+
+type markLinePainter struct {
+ p *Painter
+ options []markLineRenderOption
+}
+
+func (m *markLinePainter) Add(opt markLineRenderOption) {
+ m.options = append(m.options, opt)
+}
+
+// NewMarkLinePainter returns a mark line renderer
+func NewMarkLinePainter(p *Painter) *markLinePainter {
+ return &markLinePainter{
+ p: p,
+ options: make([]markLineRenderOption, 0),
+ }
+}
+
+type markLineRenderOption struct {
+ FillColor Color
+ FontColor Color
+ StrokeColor Color
+ Font *truetype.Font
+ Series Series
+ Range axisRange
+}
+
+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)
+ }
+ }
+ return BoxZero, nil
+}
diff --git a/mark_line_test.go b/mark_line_test.go
new file mode 100644
index 0000000..0448cda
--- /dev/null
+++ b/mark_line_test.go
@@ -0,0 +1,90 @@
+// 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 TestMarkLine(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ markLine := NewMarkLinePainter(p)
+ series := NewSeriesFromValues([]float64{
+ 1,
+ 2,
+ 3,
+ })
+ series.MarkLine = NewMarkLine(
+ SeriesMarkDataTypeMax,
+ SeriesMarkDataTypeAverage,
+ SeriesMarkDataTypeMin,
+ )
+ markLine.Add(markLineRenderOption{
+ FillColor: drawing.ColorBlack,
+ FontColor: drawing.ColorBlack,
+ StrokeColor: drawing.ColorBlack,
+ Series: series,
+ Range: NewRange(AxisRangeOption{
+ Painter: p,
+ Min: 0,
+ Max: 5,
+ Size: p.Height(),
+ DivideCount: 6,
+ }),
+ })
+ _, err := markLine.Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\n3 2 1 ",
+ },
+ }
+ 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
new file mode 100644
index 0000000..fd8a88b
--- /dev/null
+++ b/mark_point.go
@@ -0,0 +1,115 @@
+// 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"
+)
+
+// NewMarkPoint returns a series mark point
+func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint {
+ data := make([]SeriesMarkData, len(markPointTypes))
+ for index, t := range markPointTypes {
+ data[index] = SeriesMarkData{
+ Type: t,
+ }
+ }
+ return SeriesMarkPoint{
+ Data: data,
+ }
+}
+
+type markPointPainter struct {
+ p *Painter
+ options []markPointRenderOption
+}
+
+func (m *markPointPainter) Add(opt markPointRenderOption) {
+ m.options = append(m.options, opt)
+}
+
+type markPointRenderOption struct {
+ FillColor Color
+ Font *truetype.Font
+ Series Series
+ Points []Point
+}
+
+// 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
new file mode 100644
index 0000000..298345b
--- /dev/null
+++ b/mark_point_test.go
@@ -0,0 +1,92 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestMarkPoint(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ series := NewSeriesFromValues([]float64{
+ 1,
+ 2,
+ 3,
+ })
+ series.MarkPoint = NewMarkPoint(SeriesMarkDataTypeMax)
+ markPoint := NewMarkPointPainter(p)
+ markPoint.Add(markPointRenderOption{
+ FillColor: drawing.ColorBlack,
+ Series: series,
+ Points: []Point{
+ {
+ X: 10,
+ Y: 10,
+ },
+ {
+ X: 30,
+ Y: 30,
+ },
+ {
+ X: 50,
+ Y: 50,
+ },
+ },
+ })
+ _, err := markPoint.Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\n3 ",
+ },
+ }
+
+ 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/painter_test.go b/painter_test.go
new file mode 100644
index 0000000..07c4113
--- /dev/null
+++ b/painter_test.go
@@ -0,0 +1,399 @@
+// 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 (
+ "math"
+ "testing"
+
+ "github.com/golang/freetype/truetype"
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestPainterOption(t *testing.T) {
+ assert := assert.New(t)
+
+ font := &truetype.Font{}
+ d, err := NewPainter(PainterOptions{
+ Width: 800,
+ Height: 600,
+ Type: ChartOutputSVG,
+ },
+ PainterBoxOption(Box{
+ Right: 400,
+ Bottom: 300,
+ }),
+ PainterPaddingOption(Box{
+ Left: 1,
+ Top: 2,
+ Right: 3,
+ Bottom: 4,
+ }),
+ PainterFontOption(font),
+ PainterStyleOption(Style{
+ ClassName: "test",
+ }),
+ )
+ assert.Nil(err)
+ assert.Equal(Box{
+ Left: 1,
+ Top: 2,
+ Right: 397,
+ Bottom: 296,
+ }, d.box)
+ assert.Equal(font, d.font)
+ assert.Equal("test", d.style.ClassName)
+}
+
+func TestPainter(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ fn func(*Painter)
+ result string
+ }{
+ // moveTo, lineTo
+ {
+ fn: func(p *Painter) {
+ p.MoveTo(1, 1)
+ p.LineTo(2, 2)
+ p.Stroke()
+ },
+ result: "\\n ",
+ },
+ // circle
+ {
+ fn: func(p *Painter) {
+ p.Circle(5, 2, 3)
+ },
+ result: "\\n ",
+ },
+ // text
+ {
+ fn: func(p *Painter) {
+ p.Text("hello world!", 3, 6)
+ },
+ result: "\\nhello world! ",
+ },
+ // line stroke
+ {
+ fn: func(p *Painter) {
+ p.SetDrawingStyle(Style{
+ StrokeColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ })
+ p.LineStroke([]Point{
+ {
+ X: 1,
+ Y: 2,
+ },
+ {
+ X: 3,
+ Y: 4,
+ },
+ })
+ },
+ result: "\\n ",
+ },
+ // set background
+ {
+ fn: func(p *Painter) {
+ p.SetBackground(400, 300, chart.ColorWhite)
+ },
+ result: "\\n ",
+ },
+ // arcTo
+ {
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlue,
+ })
+ p.ArcTo(100, 100, 100, 100, 0, math.Pi/2)
+ p.Close()
+ p.FillStroke()
+ },
+ result: "\\n ",
+ },
+ // pin
+ {
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ FillColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ })
+ p.Pin(30, 30, 30)
+ },
+ result: "\\n ",
+ },
+ // arrow left
+ {
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ FillColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ })
+ p.ArrowLeft(30, 30, 16, 10)
+ },
+ result: "\\n ",
+ },
+ // arrow right
+ {
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ FillColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ })
+ p.ArrowRight(30, 30, 16, 10)
+ },
+ result: "\\n ",
+ },
+ // arrow top
+ {
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ FillColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ })
+ p.ArrowTop(30, 30, 10, 16)
+ },
+ result: "\\n ",
+ },
+ // arrow bottom
+ {
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ FillColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ })
+ p.ArrowBottom(30, 30, 10, 16)
+ },
+ result: "\\n ",
+ },
+ // mark line
+ {
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ FillColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ StrokeDashArray: []float64{
+ 4,
+ 2,
+ },
+ })
+ p.MarkLine(0, 20, 300)
+ },
+ result: "\\n ",
+ },
+ // polygon
+ {
+ fn: func(p *Painter) {
+ p.SetStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ })
+ p.Polygon(Point{
+ X: 100,
+ Y: 100,
+ }, 50, 6)
+ },
+ result: "\\n ",
+ },
+ // FillArea
+ {
+ fn: func(p *Painter) {
+ p.SetDrawingStyle(Style{
+ FillColor: Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ })
+ p.FillArea([]Point{
+ {
+ X: 0,
+ Y: 0,
+ },
+ {
+ X: 0,
+ Y: 100,
+ },
+ {
+ X: 100,
+ Y: 100,
+ },
+ {
+ X: 0,
+ Y: 0,
+ },
+ })
+ },
+ result: "\\n ",
+ },
+ }
+ for _, tt := range tests {
+ d, err := NewPainter(PainterOptions{
+ Width: 400,
+ Height: 300,
+ Type: ChartOutputSVG,
+ }, PainterPaddingOption(chart.Box{
+ Left: 5,
+ Top: 10,
+ }))
+ assert.Nil(err)
+ tt.fn(d)
+ data, err := d.Bytes()
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
+
+func TestRoundedRect(t *testing.T) {
+ assert := assert.New(t)
+ p, err := NewPainter(PainterOptions{
+ Width: 400,
+ Height: 300,
+ Type: ChartOutputSVG,
+ })
+ assert.Nil(err)
+ p.OverrideDrawingStyle(Style{
+ FillColor: drawing.ColorWhite,
+ StrokeWidth: 1,
+ StrokeColor: drawing.ColorWhite,
+ }).RoundedRect(Box{
+ Left: 10,
+ Right: 30,
+ Bottom: 150,
+ Top: 10,
+ }, 5)
+ buf, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("\\n ", string(buf))
+}
+
+func TestPainterTextFit(t *testing.T) {
+ assert := assert.New(t)
+ p, err := NewPainter(PainterOptions{
+ Width: 400,
+ Height: 300,
+ Type: ChartOutputSVG,
+ })
+ assert.Nil(err)
+ f, _ := GetDefaultFont()
+ style := Style{
+ FontSize: 12,
+ FontColor: chart.ColorBlack,
+ Font: f,
+ }
+ p.SetStyle(style)
+ box := p.TextFit("Hello World!", 0, 20, 80)
+ assert.Equal(chart.Box{
+ Right: 45,
+ Bottom: 35,
+ }, box)
+
+ box = p.TextFit("Hello World!", 0, 100, 200)
+ assert.Equal(chart.Box{
+ Right: 84,
+ Bottom: 15,
+ }, box)
+
+ 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
new file mode 100644
index 0000000..5c04ed8
--- /dev/null
+++ b/pie_chart.go
@@ -0,0 +1,318 @@
+// 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"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type pieChart struct {
+ p *Painter
+ opt *PieChartOption
+}
+
+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
+}
+
+// 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,
+ }
+}
+
+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 seriesList {
+ if len(series.Radius) != 0 {
+ radiusValue = series.Radius
+ }
+ value := float64(0)
+ for _, item := range series.Data {
+ value += item.Value
+ }
+ values[index] = value
+ total += value
+ }
+ if total <= 0 {
+ return BoxZero, errors.New("The sum value of pie chart should gt 0")
+ }
+ seriesPainter := result.seriesPainter
+ cx := seriesPainter.Width() >> 1
+ cy := seriesPainter.Height() >> 1
+
+ diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
+ radius := getRadius(float64(diameter), radiusValue)
+
+ labelLineWidth := 15
+ if radius < 50 {
+ labelLineWidth = 10
+ }
+ labelRadius := radius + float64(labelLineWidth)
+ seriesNames := opt.Legend.Data
+ if len(seriesNames) == 0 {
+ seriesNames = seriesList.Names()
+ }
+ 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
new file mode 100644
index 0000000..3795d32
--- /dev/null
+++ b/pie_chart_test.go
@@ -0,0 +1,533 @@
+// 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 (
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPieChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := []float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }
+ _, err := NewPieChart(p, PieChartOption{
+ SeriesList: NewPieSeriesList(values, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ },
+ }),
+ Title: TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ Left: PositionCenter,
+ },
+ Padding: Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: LegendOption{
+ Orient: OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: PositionLeft,
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ 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
new file mode 100644
index 0000000..cf18135
--- /dev/null
+++ b/radar_chart.go
@@ -0,0 +1,273 @@
+// 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"
+
+ "github.com/dustin/go-humanize"
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+type radarChart struct {
+ p *Painter
+ opt *RadarChartOption
+}
+
+type RadarIndicator struct {
+ // Indicator's name
+ Name string
+ // The maximum value of indicator
+ Max float64
+ // The minimum value of indicator
+ Min float64
+}
+
+type RadarChartOption 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
+ // The radar indicator list
+ RadarIndicators []RadarIndicator
+ // background is filled
+ backgroundIsFilled bool
+}
+
+// NewRadarIndicators returns a radar indicator list
+func NewRadarIndicators(names []string, values []float64) []RadarIndicator {
+ if len(names) != len(values) {
+ return nil
+ }
+ indicators := make([]RadarIndicator, len(names))
+ for index, name := range names {
+ indicators[index] = RadarIndicator{
+ Name: name,
+ Max: values[index],
+ }
+ }
+ return indicators
+}
+
+// NewRadarChart returns a radar chart renderer
+func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &radarChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ opt := r.opt
+ indicators := opt.RadarIndicators
+ sides := len(indicators)
+ 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 indicators {
+ if indicator.Max <= 0 {
+ indicators[index].Max = maxValues[index]
+ }
+ }
+
+ radiusValue := ""
+ for _, series := range seriesList {
+ if len(series.Radius) != 0 {
+ radiusValue = series.Radius
+ }
+ }
+
+ seriesPainter := result.seriesPainter
+ theme := opt.Theme
+
+ cx := seriesPainter.Width() >> 1
+ cy := seriesPainter.Height() >> 1
+ diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
+ radius := getRadius(float64(diameter), radiusValue)
+
+ divideCount := 5
+ divideRadius := float64(int(radius / float64(divideCount)))
+ radius = divideRadius * float64(divideCount)
+
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeColor: theme.GetAxisSplitLineColor(),
+ StrokeWidth: 1,
+ })
+ center := Point{
+ X: cx,
+ Y: cy,
+ }
+ for i := 0; i < divideCount; i++ {
+ seriesPainter.Polygon(center, divideRadius*float64(i+1), sides)
+ }
+ points := getPolygonPoints(center, radius, sides)
+ for _, p := range points {
+ seriesPainter.MoveTo(center.X, center.Y)
+ seriesPainter.LineTo(p.X, p.Y)
+ seriesPainter.Stroke()
+ }
+ seriesPainter.OverrideTextStyle(Style{
+ FontColor: theme.GetTextColor(),
+ FontSize: labelFontSize,
+ Font: opt.Font,
+ })
+ offset := 5
+ // 文本生成
+ for index, p := range points {
+ name := indicators[index].Name
+ b := seriesPainter.MeasureText(name)
+ isXCenter := p.X == center.X
+ isYCenter := p.Y == center.Y
+ isRight := p.X > center.X
+ isLeft := p.X < center.X
+ isTop := p.Y < center.Y
+ isBottom := p.Y > center.Y
+ x := p.X
+ y := p.Y
+ if isXCenter {
+ x -= b.Width() >> 1
+ if isTop {
+ y -= b.Height()
+ } else {
+ y += b.Height()
+ }
+ }
+ if isYCenter {
+ y += b.Height() >> 1
+ }
+ if isTop {
+ y += offset
+ }
+ if isBottom {
+ y += offset
+ }
+ if isRight {
+ x += offset
+ }
+ if isLeft {
+ x -= (b.Width() + offset)
+ }
+ seriesPainter.Text(name, x, y)
+ }
+
+ // 雷达图
+ angles := getPolygonPointAngles(sides)
+ maxCount := len(indicators)
+ for _, series := range seriesList {
+ linePoints := make([]Point, 0, maxCount)
+ for j, item := range series.Data {
+ if j >= maxCount {
+ continue
+ }
+ indicator := indicators[j]
+ var percent float64
+ offset := indicator.Max - indicator.Min
+ if offset > 0 {
+ percent = (item.Value - indicator.Min) / offset
+ }
+ r := percent * radius
+ p := getPolygonPoint(center, r, angles[j])
+ linePoints = append(linePoints, p)
+ }
+ color := theme.GetSeriesColor(series.index)
+ dotFillColor := drawing.ColorWhite
+ if theme.IsDark() {
+ dotFillColor = color
+ }
+ linePoints = append(linePoints, linePoints[0])
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeColor: color,
+ StrokeWidth: defaultStrokeWidth,
+ DotWidth: defaultDotWidth,
+ DotColor: color,
+ FillColor: color.WithAlpha(20),
+ })
+ seriesPainter.LineStroke(linePoints).
+ FillArea(linePoints)
+ dotWith := 2.0
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeWidth: defaultStrokeWidth,
+ StrokeColor: color,
+ FillColor: dotFillColor,
+ })
+ for index, point := range linePoints {
+ seriesPainter.Circle(dotWith, point.X, point.Y)
+ seriesPainter.FillStroke()
+ if series.Label.Show && index < len(series.Data) {
+ value := humanize.FtoaWithDigits(series.Data[index].Value, 2)
+ b := seriesPainter.MeasureText(value)
+ seriesPainter.Text(value, point.X-b.Width()/2, point.Y)
+ }
+
+ }
+ }
+
+ 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
new file mode 100644
index 0000000..79fd9ac
--- /dev/null
+++ b/radar_chart_test.go
@@ -0,0 +1,107 @@
+// 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 TestRadarChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := [][]float64{
+ {
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ },
+ {
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
+ },
+ }
+ _, err := NewRadarChart(p, RadarChartOption{
+ SeriesList: NewSeriesListDataFromValues(values, ChartTypeRadar),
+ Title: TitleOption{
+ Text: "Basic Radar Chart",
+ },
+ Legend: NewLegendOption([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ RadarIndicators: NewRadarIndicators([]string{
+ "Sales",
+ "Administration",
+ "Information Technology",
+ "Customer Support",
+ "Development",
+ "Marketing",
+ }, []float64{
+ 6500,
+ 16000,
+ 30000,
+ 38000,
+ 52000,
+ 25000,
+ }),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\nAllocated Budget Actual Spending Basic Radar Chart Sales Administration Information Technology Customer Support Development Marketing ",
+ },
+ }
+ 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 4e00c60..ec64c2d 100644
--- a/range.go
+++ b/range.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -24,70 +24,121 @@ package charts
import (
"math"
-
- "github.com/wcharczuk/go-chart/v2"
)
-type Range struct {
- TickPosition chart.TickPosition
- chart.ContinuousRange
+const defaultAxisDivideCount = 6
+
+type axisRange struct {
+ p *Painter
+ divideCount int
+ min float64
+ max float64
+ size int
+ boundary bool
}
-func wrapRange(r chart.Range, tickPosition chart.TickPosition) chart.Range {
- xr, ok := r.(*chart.ContinuousRange)
- if !ok {
- return r
+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 := 1
+ if r > 5 {
+ unit = 2
}
- return &Range{
- TickPosition: tickPosition,
- ContinuousRange: *xr,
+ if r > 10 {
+ unit = 4
+ }
+ if r > 30 {
+ unit = 5
+ }
+ if r > 100 {
+ unit = 10
+ }
+ if r > 200 {
+ unit = 20
+ }
+ unit = int((r/float64(divideCount))/float64(unit))*unit + unit
+
+ if min != 0 {
+ isLessThanZero := min < 0
+ min = float64(int(min/float64(unit)) * unit)
+ // 如果是小于0,int的时候向上取整了,因此调整
+ if min < 0 ||
+ (isLessThanZero && min == 0) {
+ min -= float64(unit)
+ }
+ }
+ max = min + float64(unit*divideCount)
+ 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,
}
}
-// Translate maps a given value into the ContinuousRange space.
-func (r Range) Translate(value float64) int {
- v := r.ContinuousRange.Translate(value)
- if r.TickPosition == chart.TickPositionBetweenTicks {
- v -= int(float64(r.Domain) / (r.GetDelta() * 2))
+// 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
}
- return v
-}
-
-type HiddenRange struct {
- chart.ContinuousRange
-}
-
-func (r HiddenRange) GetDelta() float64 {
- return 0
-}
-
-// Y轴使用的continuous range
-// min 与max只允许设置一次
-// 如果是计算得出的max,增加20%的值并取整
-type YContinuousRange struct {
- chart.ContinuousRange
-}
-
-func (m YContinuousRange) IsZero() bool {
- // 默认返回true,允许修改
- return true
-}
-
-func (m *YContinuousRange) SetMin(min float64) {
- // 如果已修改,则忽略
- if m.Min != -math.MaxFloat64 {
- return
+ for i := 0; i <= r.divideCount; i++ {
+ v := r.min + float64(i)*offset
+ value := formatter(v)
+ values = append(values, value)
}
- m.Min = min
+ return values
}
-func (m *YContinuousRange) SetMax(max float64) {
- // 如果已修改,则忽略
- if m.Max != math.MaxFloat64 {
- return
+func (r *axisRange) getHeight(value float64) int {
+ if r.max <= r.min {
+ return 0
}
- // 此处为计算得来的最大值,放大20%
- v := int(max * 1.2)
- // TODO 是否要取整十整百
- m.Max = float64(v)
+ v := (value - r.min) / (r.max - r.min)
+ return int(v * float64(r.size))
+}
+
+func (r *axisRange) getRestHeight(value float64) int {
+ return r.size - r.getHeight(value)
+}
+
+// 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)
+}
+
+// AutoDivide divides the axis
+func (r *axisRange) AutoDivide() []int {
+ return autoDivide(r.size, r.divideCount)
}
diff --git a/series.go b/series.go
index 36e57bd..da50e64 100644
--- a/series.go
+++ b/series.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -19,114 +19,300 @@
// 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"
+ "math"
+ "strings"
+
+ "github.com/dustin/go-humanize"
+ "git.smarteching.com/zeni/go-chart/v2"
)
type SeriesData struct {
+ // The value of series data
Value float64
- Style chart.Style
+ // The style of series data
+ Style Style
}
-type Series struct {
- Type string
- Name string
- Data []SeriesData
- XValues []float64
- YAxisIndex int
- Style chart.Style
-}
-
-const lineStrokeWidth = 2
-const dotWith = 2
-
-const (
- SeriesBar = "bar"
- SeriesLine = "line"
- SeriesPie = "pie"
-)
-
-func NewSeriesDataListFromFloat(values []float64) []SeriesData {
- dataList := make([]SeriesData, len(values))
+// NewSeriesListDataFromValues returns a series list
+func NewSeriesListDataFromValues(values [][]float64, chartType ...string) SeriesList {
+ seriesList := make(SeriesList, len(values))
for index, value := range values {
- dataList[index] = SeriesData{
+ seriesList[index] = NewSeriesFromValues(value, chartType...)
+ }
+ return seriesList
+}
+
+// NewSeriesFromValues returns a series
+func NewSeriesFromValues(values []float64, chartType ...string) Series {
+ s := Series{
+ Data: NewSeriesDataFromValues(values),
+ }
+ if len(chartType) != 0 {
+ s.Type = chartType[0]
+ }
+ return s
+}
+
+// NewSeriesDataFromValues return a series data
+func NewSeriesDataFromValues(values []float64) []SeriesData {
+ data := make([]SeriesData, len(values))
+ for index, value := range values {
+ data[index] = SeriesData{
Value: value,
}
}
- return dataList
+ return data
}
-func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) []chart.Series {
- arr := make([]chart.Series, len(series))
- barCount := 0
- barIndex := 0
- for _, item := range series {
- if item.Type == SeriesBar {
- barCount++
- }
- }
- for index, item := range series {
- style := chart.Style{
- StrokeWidth: lineStrokeWidth,
- StrokeColor: getSeriesColor(theme, index),
- // TODO 调整为通过dot with color 生成
- DotColor: getSeriesColor(theme, index),
- DotWidth: dotWith,
+type SeriesLabel struct {
+ // Data label formatter, which supports string template.
+ // {b}: the name of a data item.
+ // {c}: the value of a data item.
+ // {d}: the percent of a data item(pie chart).
+ Formatter string
+ // The color for label
+ 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 (
+ SeriesMarkDataTypeMax = "max"
+ SeriesMarkDataTypeMin = "min"
+ SeriesMarkDataTypeAverage = "average"
+)
+
+type SeriesMarkData struct {
+ // The mark data type, it can be "max", "min", "average".
+ // The "average" is only for mark line
+ Type string
+}
+type SeriesMarkPoint struct {
+ // The width of symbol, default value is 30
+ SymbolSize int
+ // The mark data of series mark point
+ Data []SeriesMarkData
+}
+type SeriesMarkLine struct {
+ // The mark data of series mark line
+ Data []SeriesMarkData
+}
+type Series struct {
+ index int
+ // The type of series, it can be "line", "bar" or "pie".
+ // Default value is "line"
+ Type string
+ // The data list of series
+ Data []SeriesData
+ // The Y axis index, it should be 0 or 1.
+ // Default value is 0
+ AxisIndex int
+ // The style for series
+ Style chart.Style
+ // The label for series
+ Label SeriesLabel
+ // The name of series
+ 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
+ MarkLine SeriesMarkLine
+ // Max value of series
+ Min *float64
+ // Min value of series
+ Max *float64
+}
+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
}
- if !item.Style.StrokeColor.IsZero() {
- style.StrokeColor = item.Style.StrokeColor
- style.DotColor = item.Style.StrokeColor
- }
- pointIndexOffset := 0
- // 如果居中,需要多增加一个点
- if tickPosition == chart.TickPositionBetweenTicks {
- item.Data = append([]SeriesData{
- {
- Value: 0.0,
- },
- }, item.Data...)
- pointIndexOffset = -1
- }
- yValues := make([]float64, len(item.Data))
- barCustomStyles := make([]BarSeriesCustomStyle, 0)
- for i, item := range item.Data {
- yValues[i] = item.Value
- if !item.Style.IsZero() {
- barCustomStyles = append(barCustomStyles, BarSeriesCustomStyle{
- PointIndex: i + pointIndexOffset,
- Index: barIndex,
- Style: item.Style,
- })
- }
- }
- baseSeries := BaseSeries{
- Name: item.Name,
- XValues: item.XValues,
- Style: style,
- YValues: yValues,
- TickPosition: tickPosition,
- YAxis: chart.YAxisSecondary,
- }
- if item.YAxisIndex != 0 {
- baseSeries.YAxis = chart.YAxisPrimary
- }
- switch item.Type {
- case SeriesBar:
- arr[index] = BarSeries{
- Count: barCount,
- Index: barIndex,
- BaseSeries: baseSeries,
- CustomStyles: barCustomStyles,
- }
- barIndex++
- default:
- arr[index] = LineSeries{
- BaseSeries: baseSeries,
- }
+ 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
+ Names []string
+}
+
+func NewPieSeriesList(values []float64, opts ...PieSeriesOption) SeriesList {
+ result := make([]Series, len(values))
+ var opt PieSeriesOption
+ if len(opts) != 0 {
+ opt = opts[0]
+ }
+ for index, v := range values {
+ name := ""
+ if index < len(opt.Names) {
+ name = opt.Names[index]
+ }
+ s := Series{
+ Type: ChartTypePie,
+ Data: []SeriesData{
+ {
+ Value: v,
+ },
+ },
+ Radius: opt.Radius,
+ Label: opt.Label,
+ Name: name,
+ }
+ result[index] = s
+ }
+ return result
+}
+
+type seriesSummary struct {
+ // The index of max value
+ MaxIndex int
+ // The max value
+ MaxValue float64
+ // The index of min value
+ MinIndex int
+ // The min value
+ MinValue float64
+ // THe average value
+ AverageValue float64
+}
+
+// Summary get summary of series
+func (s *Series) Summary() seriesSummary {
+ minIndex := -1
+ maxIndex := -1
+ minValue := math.MaxFloat64
+ maxValue := -math.MaxFloat64
+ sum := float64(0)
+ for j, item := range s.Data {
+ if item.Value < minValue {
+ minIndex = j
+ minValue = item.Value
+ }
+ if item.Value > maxValue {
+ maxIndex = j
+ maxValue = item.Value
+ }
+ sum += item.Value
+ }
+ return seriesSummary{
+ MaxIndex: maxIndex,
+ MaxValue: maxValue,
+ MinIndex: minIndex,
+ MinValue: minValue,
+ AverageValue: sum / float64(len(s.Data)),
+ }
+}
+
+// Names returns the names of series list
+func (sl SeriesList) Names() []string {
+ names := make([]string, len(sl))
+ for index, s := range sl {
+ names[index] = s.Name
+ }
+ 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}"
+ }
+ return NewLabelFormatter(seriesNames, layout)
+}
+
+// NewFunnelLabelFormatter returns a funner label formatter
+func NewFunnelLabelFormatter(seriesNames []string, layout string) LabelFormatter {
+ if len(layout) == 0 {
+ layout = "{b}({d})"
+ }
+ return NewLabelFormatter(seriesNames, layout)
+}
+
+// NewValueLabelFormatter returns a value formatter
+func NewValueLabelFormatter(seriesNames []string, layout string) LabelFormatter {
+ 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
+ percentText := ""
+ if percent >= 0 {
+ percentText = humanize.FtoaWithDigits(percent*100, 2) + "%"
+ }
+ valueText := humanize.FtoaWithDigits(value, 2)
+ name := ""
+ if len(seriesNames) > index {
+ name = seriesNames[index]
+ }
+ text := strings.ReplaceAll(layout, "{c}", valueText)
+ text = strings.ReplaceAll(text, "{d}", percentText)
+ text = strings.ReplaceAll(text, "{b}", name)
+ return text
+ }
+}
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 5016aab..40d2f91 100644
--- a/series_test.go
+++ b/series_test.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -19,107 +19,71 @@
// 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 TestNewSeriesDataListFromFloat(t *testing.T) {
+func TestNewSeriesListDataFromValues(t *testing.T) {
assert := assert.New(t)
- assert.Equal([]SeriesData{
+ assert.Equal(SeriesList{
{
- Value: 1,
+ Type: ChartTypeBar,
+ Data: []SeriesData{
+ {
+ Value: 1.0,
+ },
+ },
},
+ }, NewSeriesListDataFromValues([][]float64{
{
- Value: 2,
+ 1,
},
- }, NewSeriesDataListFromFloat([]float64{
- 1,
- 2,
- }))
+ }, ChartTypeBar))
}
-func TestGetSeries(t *testing.T) {
+func TestSeriesLists(t *testing.T) {
+ assert := assert.New(t)
+ seriesList := NewSeriesListDataFromValues([][]float64{
+ {
+ 1,
+ 2,
+ },
+ {
+ 10,
+ },
+ }, ChartTypeBar)
+
+ assert.Equal(2, len(seriesList.Filter(ChartTypeBar)))
+ assert.Equal(0, len(seriesList.Filter(ChartTypeLine)))
+
+ max, min := seriesList.GetMaxMin(0)
+ assert.Equal(float64(10), max)
+ assert.Equal(float64(1), min)
+
+ assert.Equal(seriesSummary{
+ MaxIndex: 1,
+ MaxValue: 2,
+ MinIndex: 0,
+ MinValue: 1,
+ AverageValue: 1.5,
+ }, seriesList[0].Summary())
+}
+
+func TestFormatter(t *testing.T) {
assert := assert.New(t)
- xValues := []float64{
- 1,
- 2,
- 3,
- 4,
- 5,
- }
+ assert.Equal("a: 12%", NewPieLabelFormatter([]string{
+ "a",
+ "b",
+ }, "")(0, 10, 0.12))
- barData := NewSeriesDataListFromFloat([]float64{
- 10,
- 20,
- 30,
- 40,
- 50,
- })
- barData[1].Style = chart.Style{
- FillColor: AxisColorDark,
- }
- seriesList := GetSeries([]Series{
- {
- Type: SeriesBar,
- Data: barData,
- XValues: xValues,
- YAxisIndex: 1,
- },
- {
- Data: NewSeriesDataListFromFloat([]float64{
- 11,
- 21,
- 31,
- 41,
- 51,
- }),
- XValues: xValues,
- },
- }, chart.TickPositionBetweenTicks, "")
-
- assert.Equal(seriesList[0].GetYAxis(), chart.YAxisPrimary)
- assert.Equal(seriesList[1].GetYAxis(), chart.YAxisSecondary)
-
- barSeries, ok := seriesList[0].(BarSeries)
- assert.True(ok)
- // 居中前置多插入一个点
- assert.Equal([]float64{
- 0,
- 10,
- 20,
- 30,
- 40,
- 50,
- }, barSeries.YValues)
- assert.Equal(xValues, barSeries.XValues)
- assert.Equal(1, barSeries.Count)
- assert.Equal(0, barSeries.Index)
- assert.Equal([]BarSeriesCustomStyle{
- {
- PointIndex: 1,
- Index: 0,
- Style: barData[1].Style,
- },
- }, barSeries.CustomStyles)
-
- lineSeries, ok := seriesList[1].(LineSeries)
- assert.True(ok)
- // 居中前置多插入一个点
- assert.Equal([]float64{
- 0,
- 11,
- 21,
- 31,
- 41,
- 51,
- }, lineSeries.YValues)
- assert.Equal(xValues, lineSeries.XValues)
+ assert.Equal("10", NewValueLabelFormatter([]string{
+ "a",
+ "b",
+ }, "")(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
new file mode 100644
index 0000000..3e6f273
--- /dev/null
+++ b/table.go
@@ -0,0 +1,438 @@
+// 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"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+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 theme
+ Theme ColorPalette
+ // The padding of table cell
+ Padding Box
+ // The header data of table
+ Header []string
+ // The data of table
+ Data [][]string
+ // The span list of table column
+ Spans []int
+ // The text align list of table cell
+ TextAligns []string
+ // The font size of table
+ FontSize float64
+ // The font family, which should be installed first
+ FontFamily string
+ Font *truetype.Font
+ // The font color of table
+ FontColor Color
+ // The background color of header
+ HeaderBackgroundColor Color
+ // The header font color
+ HeaderFontColor Color
+ // The background color of row
+ RowBackgroundColors []Color
+ // The background color
+ BackgroundColor Color
+ // CellTextStyle customize text style of table cell
+ CellTextStyle func(TableCell) *Style
+ // CellStyle customize drawing style of table cell
+ CellStyle func(TableCell) *Style
+}
+
+type TableSetting struct {
+ // The color of header
+ HeaderColor Color
+ // The color of heder text
+ HeaderFontColor Color
+ // The color of table text
+ FontColor Color
+ // The color list of row
+ RowColors []Color
+ // The padding of cell
+ Padding Box
+}
+
+var 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,
+ },
+}
+
+var TableDarkThemeSetting = TableSetting{
+ HeaderColor: Color{
+ R: 38,
+ G: 38,
+ B: 42,
+ A: 255,
+ },
+ HeaderFontColor: Color{
+ R: 216,
+ G: 217,
+ B: 218,
+ A: 255,
+ },
+ FontColor: Color{
+ R: 216,
+ G: 217,
+ B: 218,
+ A: 255,
+ },
+ RowColors: []Color{
+ {
+ R: 24,
+ G: 24,
+ B: 28,
+ A: 255,
+ },
+ {
+ R: 38,
+ G: 38,
+ B: 42,
+ A: 255,
+ },
+ },
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+}
+
+var tableDefaultSetting = TableLightThemeSetting
+
+// SetDefaultTableSetting sets the default setting for table
+func SetDefaultTableSetting(setting TableSetting) {
+ tableDefaultSetting = setting
+}
+
+type renderInfo struct {
+ Width int
+ Height int
+ HeaderHeight int
+ RowHeights []int
+ ColumnWidths []int
+}
+
+func (t *tableChart) render() (*renderInfo, error) {
+ info := renderInfo{
+ RowHeights: make([]int, 0),
+ }
+ p := t.p
+ opt := t.opt
+ if len(opt.Header) == 0 {
+ return nil, errors.New("header can not be nil")
+ }
+ theme := opt.Theme
+ if theme == nil {
+ theme = p.theme
+ }
+ fontSize := opt.FontSize
+ if fontSize == 0 {
+ fontSize = 12
+ }
+ fontColor := opt.FontColor
+ if fontColor.IsZero() {
+ fontColor = tableDefaultSetting.FontColor
+ }
+ font := opt.Font
+ if font == nil {
+ font = theme.GetFont()
+ }
+ headerFontColor := opt.HeaderFontColor
+ if opt.HeaderFontColor.IsZero() {
+ headerFontColor = tableDefaultSetting.HeaderFontColor
+ }
+
+ spans := opt.Spans
+ if len(spans) != len(opt.Header) {
+ newSpans := make([]int, len(opt.Header))
+ for index := range opt.Header {
+ if index >= len(spans) {
+ newSpans[index] = 1
+ } 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 BoxZero, err
+ }
+
+ 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 051e099..85016a5 100644
--- a/theme.go
+++ b/theme.go
@@ -1,6 +1,6 @@
// MIT License
-// Copyright (c) 2021 Tree Xie
+// 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
@@ -23,200 +23,310 @@
package charts
import (
- "regexp"
- "strconv"
- "strings"
-
- "github.com/wcharczuk/go-chart/v2"
- "github.com/wcharczuk/go-chart/v2/drawing"
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
)
-var hiddenColor = drawing.Color{R: 0, G: 0, B: 0, A: 0}
+const ThemeDark = "dark"
+const ThemeLight = "light"
+const ThemeGrafana = "grafana"
+const ThemeAnt = "ant"
-var AxisColorLight = drawing.Color{
- R: 110,
- G: 112,
- B: 121,
+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 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 AxisColorDark = drawing.Color{
- R: 185,
- G: 184,
- B: 206,
+var defaultDarkFontColor = drawing.Color{
+ R: 238,
+ G: 238,
+ B: 238,
A: 255,
}
-var GridColorDark = drawing.Color{
- R: 72,
- G: 71,
- B: 83,
- A: 255,
-}
-
-var GridColorLight = drawing.Color{
- R: 224,
- G: 230,
- B: 241,
- A: 255,
-}
-
-var BackgroundColorDark = drawing.Color{
- R: 16,
- G: 12,
- B: 42,
- A: 255,
-}
-
-var TextColorDark = drawing.Color{
- R: 204,
- G: 204,
- B: 204,
- A: 255,
-}
-
-func getAxisColor(theme string) drawing.Color {
- if theme == ThemeDark {
- return AxisColorDark
+func init() {
+ echartSeriesColors := []Color{
+ parseColor("#5470c6"),
+ parseColor("#91cc75"),
+ parseColor("#fac858"),
+ parseColor("#ee6666"),
+ parseColor("#73c0de"),
+ parseColor("#3ba272"),
+ parseColor("#fc8452"),
+ parseColor("#9a60b4"),
+ parseColor("#ea7ccc"),
}
- return AxisColorLight
-}
-
-func getGridColor(theme string) drawing.Color {
- if theme == ThemeDark {
- return GridColorDark
+ grafanaSeriesColors := []Color{
+ parseColor("#7EB26D"),
+ parseColor("#EAB839"),
+ parseColor("#6ED0E0"),
+ parseColor("#EF843C"),
+ parseColor("#E24D42"),
+ parseColor("#1F78C1"),
+ parseColor("#705DA0"),
+ parseColor("#508642"),
}
- return GridColorLight
-}
-
-var SeriesColorsLight = []drawing.Color{
- {
- R: 84,
- G: 112,
- B: 198,
- A: 255,
- },
- {
- R: 145,
- G: 204,
- B: 117,
- A: 255,
- },
- {
- R: 250,
- G: 200,
- B: 88,
- A: 255,
- },
- {
- R: 238,
- G: 102,
- B: 102,
- A: 255,
- },
- {
- R: 115,
- G: 192,
- B: 222,
- A: 255,
- },
-}
-
-func getBackgroundColor(theme string) drawing.Color {
- if theme == ThemeDark {
- return BackgroundColorDark
+ antSeriesColors := []Color{
+ parseColor("#5b8ff9"),
+ parseColor("#5ad8a6"),
+ parseColor("#5d7092"),
+ parseColor("#f6bd16"),
+ parseColor("#6f5ef9"),
+ parseColor("#6dc8ec"),
+ parseColor("#945fb9"),
+ parseColor("#ff9845"),
}
- return chart.DefaultBackgroundColor
+ AddTheme(
+ ThemeDark,
+ ThemeOption{
+ IsDarkMode: true,
+ AxisStrokeColor: Color{
+ R: 185,
+ G: 184,
+ B: 206,
+ A: 255,
+ },
+ AxisSplitLineColor: Color{
+ R: 72,
+ G: 71,
+ B: 83,
+ A: 255,
+ },
+ BackgroundColor: Color{
+ R: 16,
+ G: 12,
+ B: 42,
+ A: 255,
+ },
+ TextColor: Color{
+ R: 238,
+ G: 238,
+ B: 238,
+ A: 255,
+ },
+ SeriesColors: echartSeriesColors,
+ },
+ )
+
+ AddTheme(
+ ThemeLight,
+ ThemeOption{
+ IsDarkMode: false,
+ AxisStrokeColor: Color{
+ R: 110,
+ G: 112,
+ B: 121,
+ A: 255,
+ },
+ AxisSplitLineColor: Color{
+ R: 224,
+ G: 230,
+ B: 242,
+ A: 255,
+ },
+ BackgroundColor: drawing.ColorWhite,
+ TextColor: Color{
+ R: 70,
+ G: 70,
+ B: 70,
+ A: 255,
+ },
+ SeriesColors: echartSeriesColors,
+ },
+ )
+ AddTheme(
+ ThemeAnt,
+ ThemeOption{
+ IsDarkMode: false,
+ AxisStrokeColor: Color{
+ R: 110,
+ G: 112,
+ B: 121,
+ A: 255,
+ },
+ AxisSplitLineColor: Color{
+ R: 224,
+ G: 230,
+ B: 242,
+ A: 255,
+ },
+ BackgroundColor: drawing.ColorWhite,
+ TextColor: drawing.Color{
+ R: 70,
+ G: 70,
+ B: 70,
+ A: 255,
+ },
+ SeriesColors: antSeriesColors,
+ },
+ )
+ AddTheme(
+ ThemeGrafana,
+ ThemeOption{
+ IsDarkMode: true,
+ AxisStrokeColor: Color{
+ R: 185,
+ G: 184,
+ B: 206,
+ A: 255,
+ },
+ AxisSplitLineColor: Color{
+ R: 68,
+ G: 67,
+ B: 67,
+ A: 255,
+ },
+ BackgroundColor: drawing.Color{
+ R: 31,
+ G: 29,
+ B: 29,
+ A: 255,
+ },
+ TextColor: Color{
+ R: 216,
+ G: 217,
+ B: 218,
+ A: 255,
+ },
+ SeriesColors: grafanaSeriesColors,
+ },
+ )
+ SetDefaultTheme(ThemeLight)
}
-func getTextColor(theme string) drawing.Color {
- if theme == ThemeDark {
- return TextColorDark
+// SetDefaultTheme sets default theme
+func SetDefaultTheme(name string) {
+ defaultTheme = NewTheme(name)
+}
+
+func AddTheme(name string, opt ThemeOption) {
+ palettes[name] = &themeColorPalette{
+ isDarkMode: opt.IsDarkMode,
+ axisStrokeColor: opt.AxisStrokeColor,
+ axisSplitLineColor: opt.AxisSplitLineColor,
+ backgroundColor: opt.BackgroundColor,
+ textColor: opt.TextColor,
+ seriesColors: opt.SeriesColors,
}
- return chart.DefaultTextColor
}
-type ThemeColorPalette struct {
- Theme string
-}
-
-type PieThemeColorPalette struct {
- ThemeColorPalette
-}
-
-func (tp PieThemeColorPalette) TextColor() drawing.Color {
- return getTextColor("")
-}
-
-func (tp ThemeColorPalette) BackgroundColor() drawing.Color {
- return getBackgroundColor(tp.Theme)
-}
-
-func (tp ThemeColorPalette) BackgroundStrokeColor() drawing.Color {
- return chart.DefaultBackgroundStrokeColor
-}
-
-func (tp ThemeColorPalette) CanvasColor() drawing.Color {
- if tp.Theme == ThemeDark {
- return BackgroundColorDark
+func NewTheme(name string) ColorPalette {
+ p, ok := palettes[name]
+ if !ok {
+ p = palettes[ThemeLight]
}
- return chart.DefaultCanvasColor
+ clone := *p
+ return &clone
}
-func (tp ThemeColorPalette) CanvasStrokeColor() drawing.Color {
- return chart.DefaultCanvasStrokeColor
+func (t *themeColorPalette) IsDark() bool {
+ return t.isDarkMode
}
-func (tp ThemeColorPalette) AxisStrokeColor() drawing.Color {
- if tp.Theme == ThemeDark {
- return BackgroundColorDark
- }
- return chart.DefaultAxisColor
+func (t *themeColorPalette) GetAxisStrokeColor() Color {
+ return t.axisStrokeColor
}
-func (tp ThemeColorPalette) TextColor() drawing.Color {
- return getTextColor(tp.Theme)
+func (t *themeColorPalette) SetAxisStrokeColor(c Color) {
+ t.axisStrokeColor = c
}
-func (tp ThemeColorPalette) GetSeriesColor(index int) drawing.Color {
- return getSeriesColor(tp.Theme, index)
+func (t *themeColorPalette) GetAxisSplitLineColor() Color {
+ return t.axisSplitLineColor
}
-func getSeriesColor(theme string, index int) drawing.Color {
- return SeriesColorsLight[index%len(SeriesColorsLight)]
+func (t *themeColorPalette) SetAxisSplitLineColor(c Color) {
+ t.axisSplitLineColor = c
}
-func parseColor(color string) drawing.Color {
- c := drawing.Color{}
- if color == "" {
- return c
- }
- if strings.HasPrefix(color, "#") {
- return drawing.ColorFromHex(color[1:])
- }
- reg := regexp.MustCompile(`\((\S+)\)`)
- result := reg.FindAllStringSubmatch(color, 1)
- if len(result) == 0 || len(result[0]) != 2 {
- return c
- }
- arr := strings.Split(result[0][1], ",")
- if len(arr) < 3 {
- return c
- }
- // 设置默认为255
- c.A = 255
- for index, v := range arr {
- value, _ := strconv.Atoi(strings.TrimSpace(v))
- ui8 := uint8(value)
- switch index {
- case 0:
- c.R = ui8
- case 1:
- c.G = ui8
- case 2:
- c.B = ui8
- default:
- c.A = ui8
- }
- }
- return c
+func (t *themeColorPalette) GetSeriesColor(index int) Color {
+ colors := t.seriesColors
+ return colors[index%len(colors)]
+}
+func (t *themeColorPalette) SetSeriesColor(colors []Color) {
+ t.seriesColors = colors
+}
+
+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 a25a2db..0000000
--- a/theme_test.go
+++ /dev/null
@@ -1,122 +0,0 @@
-// MIT License
-
-// Copyright (c) 2021 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 TestThemeColors(t *testing.T) {
- assert := assert.New(t)
-
- assert.Equal(AxisColorDark, getAxisColor(ThemeDark))
- assert.Equal(AxisColorLight, getAxisColor(""))
-
- assert.Equal(GridColorDark, getGridColor(ThemeDark))
- assert.Equal(GridColorLight, getGridColor(""))
-
- assert.Equal(BackgroundColorDark, getBackgroundColor(ThemeDark))
- assert.Equal(chart.DefaultBackgroundColor, getBackgroundColor(""))
-
- assert.Equal(TextColorDark, getTextColor(ThemeDark))
- assert.Equal(chart.DefaultTextColor, getTextColor(""))
-}
-
-func TestThemeColorPalette(t *testing.T) {
- assert := assert.New(t)
-
- dark := ThemeColorPalette{
- Theme: ThemeDark,
- }
- assert.Equal(BackgroundColorDark, dark.BackgroundColor())
- assert.Equal(chart.DefaultBackgroundStrokeColor, dark.BackgroundStrokeColor())
- assert.Equal(BackgroundColorDark, dark.CanvasColor())
- assert.Equal(chart.DefaultCanvasStrokeColor, dark.CanvasStrokeColor())
- assert.Equal(BackgroundColorDark, dark.AxisStrokeColor())
- assert.Equal(TextColorDark, dark.TextColor())
- // series 使用统一的color
- assert.Equal(SeriesColorsLight[0], dark.GetSeriesColor(0))
-
- light := ThemeColorPalette{}
- assert.Equal(chart.DefaultBackgroundColor, light.BackgroundColor())
- assert.Equal(chart.DefaultBackgroundStrokeColor, light.BackgroundStrokeColor())
- assert.Equal(chart.DefaultCanvasColor, light.CanvasColor())
- assert.Equal(chart.DefaultCanvasStrokeColor, light.CanvasStrokeColor())
- assert.Equal(chart.DefaultAxisColor, light.AxisStrokeColor())
- assert.Equal(chart.DefaultTextColor, light.TextColor())
- // series 使用统一的color
- assert.Equal(SeriesColorsLight[0], light.GetSeriesColor(0))
-}
-
-func TestPieThemeColorPalette(t *testing.T) {
- assert := assert.New(t)
- p := PieThemeColorPalette{}
-
- // pie无认哪种theme,文本的颜色都一样
- assert.Equal(chart.DefaultTextColor, p.TextColor())
- p.Theme = ThemeDark
- assert.Equal(chart.DefaultTextColor, p.TextColor())
-}
-
-func TestParseColor(t *testing.T) {
- assert := assert.New(t)
-
- c := parseColor("")
- assert.True(c.IsZero())
-
- c = parseColor("#333")
- assert.Equal(drawing.Color{
- R: 51,
- G: 51,
- B: 51,
- A: 255,
- }, c)
-
- c = parseColor("#313233")
- assert.Equal(drawing.Color{
- R: 49,
- G: 50,
- B: 51,
- A: 255,
- }, c)
-
- c = parseColor("rgb(31,32,33)")
- assert.Equal(drawing.Color{
- R: 31,
- G: 32,
- B: 33,
- A: 255,
- }, c)
-
- c = parseColor("rgba(50,51,52,250)")
- assert.Equal(drawing.Color{
- R: 50,
- G: 51,
- B: 52,
- A: 250,
- }, c)
-}
diff --git a/title.go b/title.go
new file mode 100644
index 0000000..74ab4f9
--- /dev/null
+++ b/title.go
@@ -0,0 +1,197 @@
+// 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 (
+ "strconv"
+ "strings"
+
+ "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
+ // 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.
+ Left string
+ // Distance between title component and the top side of the container.
+ // It can be pixel value: 20.
+ Top string
+ // The font of label
+ Font *truetype.Font
+ // The font size of label
+ FontSize float64
+ // The color of label
+ FontColor Color
+ // The subtext font size of label
+ SubtextFontSize float64
+ // The subtext font color of label
+ SubtextFontColor Color
+}
+
+type titleMeasureOption struct {
+ width int
+ height int
+ text string
+ style Style
+}
+
+func splitTitleText(text string) []string {
+ arr := strings.Split(text, "\n")
+ result := make([]string, 0)
+ for _, v := range arr {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ continue
+ }
+ result = append(result, v)
+ }
+ return result
+}
+
+type titlePainter struct {
+ p *Painter
+ opt *TitleOption
+}
+
+// NewTitlePainter returns a title renderer
+func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter {
+ return &titlePainter{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (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: 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: subtextStyle,
+ })
+ }
+ textMaxWidth := 0
+ textMaxHeight := 0
+ for index, item := range measureOptions {
+ p.OverrideTextStyle(item.style)
+ textBox := p.MeasureText(item.text)
+
+ w := textBox.Width()
+ h := textBox.Height()
+ if w > textMaxWidth {
+ textMaxWidth = w
+ }
+ if h > textMaxHeight {
+ textMaxHeight = h
+ }
+ measureOptions[index].height = h
+ measureOptions[index].width = w
+ }
+ width := textMaxWidth
+
+ titleX := 0
+ switch opt.Left {
+ case PositionRight:
+ titleX = p.Width() - textMaxWidth
+ case PositionCenter:
+ titleX = p.Width()>>1 - (textMaxWidth >> 1)
+ default:
+ if strings.HasSuffix(opt.Left, "%") {
+ value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
+ titleX = p.Width() * value / 100
+ } else {
+ value, _ := strconv.Atoi(opt.Left)
+ titleX = value
+ }
+ }
+ titleY := 0
+ // TODO TOP 暂只支持数值
+ if opt.Top != "" {
+ value, _ := strconv.Atoi(opt.Top)
+ titleY += value
+ }
+ for _, item := range measureOptions {
+ p.OverrideTextStyle(item.style)
+ x := titleX + (textMaxWidth-item.width)>>1
+ y := titleY + item.height
+ p.Text(item.text, x, y)
+ titleY += item.height
+ }
+
+ return Box{
+ Bottom: titleY,
+ Right: titleX + width,
+ }, nil
+}
diff --git a/title_test.go b/title_test.go
new file mode 100644
index 0000000..add8163
--- /dev/null
+++ b/title_test.go
@@ -0,0 +1,93 @@
+// 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 TestTitleRenderer(t *testing.T) {
+ assert := assert.New(t)
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ 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 ",
+ },
+ {
+ 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 ",
+ },
+ {
+ 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 {
+ 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/util.go b/util.go
new file mode 100644
index 0000000..87ff31c
--- /dev/null
+++ b/util.go
@@ -0,0 +1,271 @@
+// 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 (
+ "math"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/dustin/go-humanize"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TrueFlag() *bool {
+ t := true
+ return &t
+}
+
+func FalseFlag() *bool {
+ f := false
+ 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) {
+ return i
+ }
+ return i + 1
+}
+
+func getDefaultInt(value, defaultValue int) int {
+ if value == 0 {
+ return defaultValue
+ }
+ return value
+}
+
+func autoDivide(max, size int) []int {
+ unit := float64(max) / float64(size)
+
+ values := make([]int, size+1)
+ for i := 0; i < size+1; i++ {
+ if i == size {
+ values[i] = max
+ } else {
+ values[i] = int(float64(i) * unit)
+ }
+ }
+ return values
+}
+
+func autoDivideSpans(max, size int, spans []int) []int {
+ values := autoDivide(max, size)
+ // 重新合并
+ if len(spans) != 0 {
+ newValues := make([]int, len(spans)+1)
+ newValues[0] = 0
+ end := 0
+ for index, v := range spans {
+ end += v
+ newValues[index+1] = values[end]
+ }
+ values = newValues
+ }
+ return values
+}
+
+func sumInt(values []int) int {
+ sum := 0
+ for _, v := range values {
+ sum += v
+ }
+ return sum
+}
+
+// measureTextMaxWidthHeight returns maxWidth and maxHeight of text list
+func measureTextMaxWidthHeight(textList []string, p *Painter) (int, int) {
+ maxWidth := 0
+ maxHeight := 0
+ for _, text := range textList {
+ box := p.MeasureText(text)
+ maxWidth = chart.MaxInt(maxWidth, box.Width())
+ maxHeight = chart.MaxInt(maxHeight, box.Height())
+ }
+ return maxWidth, maxHeight
+}
+
+func reverseStringSlice(stringList []string) {
+ for i, j := 0, len(stringList)-1; i < j; i, j = i+1, j-1 {
+ stringList[i], stringList[j] = stringList[j], stringList[i]
+ }
+}
+
+func reverseIntSlice(intList []int) {
+ for i, j := 0, len(intList)-1; i < j; i, j = i+1, j-1 {
+ intList[i], intList[j] = intList[j], intList[i]
+ }
+}
+
+func convertPercent(value string) float64 {
+ if !strings.HasSuffix(value, "%") {
+ return -1
+ }
+ v, err := strconv.Atoi(strings.ReplaceAll(value, "%", ""))
+ if err != nil {
+ return -1
+ }
+ return float64(v) / 100
+}
+
+func isFalse(flag *bool) bool {
+ if flag != nil && !*flag {
+ return true
+ }
+ return false
+}
+
+func NewFloatPoint(f float64) *float64 {
+ v := f
+ return &v
+}
+
+const K_VALUE = float64(1000)
+const M_VALUE = K_VALUE * K_VALUE
+const G_VALUE = M_VALUE * K_VALUE
+const T_VALUE = G_VALUE * K_VALUE
+
+func commafWithDigits(value float64) string {
+ decimals := 2
+ if value >= T_VALUE {
+ return humanize.CommafWithDigits(value/T_VALUE, decimals) + "T"
+ }
+ 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) Color {
+ c := Color{}
+ if color == "" {
+ return c
+ }
+ if strings.HasPrefix(color, "#") {
+ return drawing.ColorFromHex(color[1:])
+ }
+ reg := regexp.MustCompile(`\((\S+)\)`)
+ result := reg.FindAllStringSubmatch(color, 1)
+ if len(result) == 0 || len(result[0]) != 2 {
+ return c
+ }
+ arr := strings.Split(result[0][1], ",")
+ if len(arr) < 3 {
+ return c
+ }
+ // 设置默认为255
+ c.A = 255
+ for index, v := range arr {
+ value, _ := strconv.Atoi(strings.TrimSpace(v))
+ ui8 := uint8(value)
+ switch index {
+ case 0:
+ c.R = ui8
+ case 1:
+ c.G = ui8
+ case 2:
+ c.B = ui8
+ default:
+ c.A = ui8
+ }
+ }
+ return c
+}
+
+const defaultRadiusPercent = 0.4
+
+func getRadius(diameter float64, radiusValue string) float64 {
+ var radius float64
+ if len(radiusValue) != 0 {
+ v := convertPercent(radiusValue)
+ if v != -1 {
+ radius = float64(diameter) * v
+ } else {
+ radius, _ = strconv.ParseFloat(radiusValue, 64)
+ }
+ }
+ if radius <= 0 {
+ radius = float64(diameter) * defaultRadiusPercent
+ }
+ return radius
+}
+
+func getPolygonPointAngles(sides int) []float64 {
+ angles := make([]float64, sides)
+ for i := 0; i < sides; i++ {
+ angle := 2*math.Pi/float64(sides)*float64(i) - (math.Pi / 2)
+ angles[i] = angle
+ }
+ return angles
+}
+
+func getPolygonPoint(center Point, radius, angle float64) Point {
+ x := center.X + int(radius*math.Cos(angle))
+ y := center.Y + int(radius*math.Sin(angle))
+ return Point{
+ X: x,
+ Y: y,
+ }
+}
+
+func getPolygonPoints(center Point, radius float64, sides int) []Point {
+ points := make([]Point, sides)
+ for i, angle := range getPolygonPointAngles(sides) {
+ points[i] = getPolygonPoint(center, radius, angle)
+ }
+ 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
new file mode 100644
index 0000000..5770776
--- /dev/null
+++ b/util_test.go
@@ -0,0 +1,223 @@
+// 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"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestGetDefaultInt(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal(1, getDefaultInt(0, 1))
+ assert.Equal(10, getDefaultInt(10, 1))
+}
+
+func TestCeilFloatToInt(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal(1, ceilFloatToInt(0.8))
+ assert.Equal(1, ceilFloatToInt(1.0))
+ assert.Equal(2, ceilFloatToInt(1.2))
+}
+
+func TestCommafWithDigits(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal("1.2", commafWithDigits(1.2))
+ assert.Equal("1.21", commafWithDigits(1.21231))
+
+ assert.Equal("1.20k", commafWithDigits(1200.121))
+ assert.Equal("1.20M", commafWithDigits(1200000.121))
+}
+
+func TestAutoDivide(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal([]int{
+ 0,
+ 85,
+ 171,
+ 257,
+ 342,
+ 428,
+ 514,
+ 600,
+ }, autoDivide(600, 7))
+}
+
+func TestGetRadius(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal(50.0, getRadius(100, "50%"))
+ assert.Equal(30.0, getRadius(100, "30"))
+ assert.Equal(40.0, getRadius(100, ""))
+}
+
+func TestMeasureTextMaxWidthHeight(t *testing.T) {
+ assert := assert.New(t)
+ p, err := NewPainter(PainterOptions{
+ Width: 400,
+ Height: 300,
+ })
+ assert.Nil(err)
+ style := chart.Style{
+ FontSize: 10,
+ }
+ p.SetStyle(style)
+
+ maxWidth, maxHeight := measureTextMaxWidthHeight([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }, p)
+ assert.Equal(31, maxWidth)
+ assert.Equal(12, maxHeight)
+}
+
+func TestReverseSlice(t *testing.T) {
+ assert := assert.New(t)
+
+ arr := []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }
+ reverseStringSlice(arr)
+ assert.Equal([]string{
+ "Sun",
+ "Sat",
+ "Fri",
+ "Thu",
+ "Wed",
+ "Tue",
+ "Mon",
+ }, arr)
+
+ numbers := []int{
+ 1,
+ 3,
+ 5,
+ 7,
+ 9,
+ }
+ reverseIntSlice(numbers)
+ assert.Equal([]int{
+ 9,
+ 7,
+ 5,
+ 3,
+ 1,
+ }, numbers)
+}
+
+func TestConvertPercent(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal(-1.0, convertPercent("1"))
+ assert.Equal(-1.0, convertPercent("a%"))
+ assert.Equal(0.1, convertPercent("10%"))
+}
+
+func TestParseColor(t *testing.T) {
+ assert := assert.New(t)
+
+ c := parseColor("")
+ assert.True(c.IsZero())
+
+ c = parseColor("#333")
+ assert.Equal(drawing.Color{
+ R: 51,
+ G: 51,
+ B: 51,
+ A: 255,
+ }, c)
+
+ c = parseColor("#313233")
+ assert.Equal(drawing.Color{
+ R: 49,
+ G: 50,
+ B: 51,
+ A: 255,
+ }, c)
+
+ c = parseColor("rgb(31,32,33)")
+ assert.Equal(drawing.Color{
+ R: 31,
+ G: 32,
+ B: 33,
+ A: 255,
+ }, c)
+
+ c = parseColor("rgba(50,51,52,250)")
+ assert.Equal(drawing.Color{
+ R: 50,
+ G: 51,
+ B: 52,
+ 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
new file mode 100644
index 0000000..61698d7
--- /dev/null
+++ b/xaxis.go
@@ -0,0 +1,105 @@
+// 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"
+)
+
+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
+ BoundaryGap *bool
+ // The data value of x axis
+ Data []string
+ // The theme of chart
+ Theme ColorPalette
+ // The font size of x axis label
+ FontSize float64
+ // The flag for show axis, set this to *false will hide axis
+ Show *bool
+ // 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,
+ }
+ if len(boundaryGap) != 0 {
+ opt.BoundaryGap = boundaryGap[0]
+ }
+ return opt
+}
+
+func (opt *XAxisOption) ToAxisOption() AxisOption {
+ position := PositionBottom
+ if opt.Position == PositionTop {
+ position = PositionTop
+ }
+ 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 opt.isValueAxis {
+ axisOpt.SplitLineShow = true
+ axisOpt.StrokeWidth = -1
+ axisOpt.BoundaryGap = FalseFlag()
+ }
+ return axisOpt
+}
+
+// NewBottomXAxis returns a bottom x axis renderer
+func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter {
+ return NewAxisPainter(p, opt.ToAxisOption())
+}
diff --git a/yaxis.go b/yaxis.go
new file mode 100644
index 0000000..e58b7a6
--- /dev/null
+++ b/yaxis.go
@@ -0,0 +1,128 @@
+// 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"
+
+type YAxisOption struct {
+ // The minimun value of axis.
+ Min *float64
+ // The maximum value of axis.
+ Max *float64
+ // The font of y axis
+ Font *truetype.Font
+ // The data value of x axis
+ Data []string
+ // The theme of chart
+ Theme ColorPalette
+ // The font size of x axis label
+ FontSize float64
+ // The position of axis, it can be 'left' or 'right'
+ Position string
+ // The color of label
+ FontColor Color
+ // Formatter for y axis text value
+ Formatter string
+ // Color for y axis
+ Color Color
+ // The flag for show axis, set this to *false will hide axis
+ Show *bool
+ DivideCount int
+ Unit int
+ isCategoryAxis bool
+ // The flag for show axis split line, set this to true will show axis split line
+ SplitLineShow *bool
+}
+
+// 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
+}
+
+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(),
+ SplitLineShow: true,
+ SplitLineColor: theme.GetAxisSplitLineColor(),
+ Show: opt.Show,
+ Unit: opt.Unit,
+ }
+ if !opt.Color.IsZero() {
+ axisOpt.FontColor = opt.Color
+ axisOpt.StrokeColor = opt.Color
+ }
+ if opt.isCategoryAxis {
+ axisOpt.BoundaryGap = TrueFlag()
+ axisOpt.StrokeWidth = 1
+ axisOpt.SplitLineShow = false
+ }
+ if opt.SplitLineShow != nil {
+ axisOpt.SplitLineShow = *opt.SplitLineShow
+ }
+ 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
new file mode 100644
index 0000000..0f565ac
--- /dev/null
+++ b/yaxis_test.go
@@ -0,0 +1,70 @@
+// 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 TestRightYAxis(t *testing.T) {
+ assert := assert.New(t)
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ opt := NewYAxisOptions([]string{
+ "a",
+ "b",
+ "c",
+ "d",
+ })[0]
+ _, err := NewRightYAxis(p, opt).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "\\na b c d ",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme), PainterPaddingOption(Box{
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ }))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}