Compare commits

..

No commits in common. "main" and "v0.0.3" have entirely different histories.
main ... v0.0.3

75 changed files with 3541 additions and 15007 deletions

View file

@ -14,12 +14,11 @@ 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

3
.gitignore vendored
View file

@ -15,6 +15,3 @@
# vendor/
*.png
*.svg
tmp
NotoSansSC.ttf
.vscode

560
README.md
View file

@ -1,457 +1,28 @@
# go-charts
Clone from https://github.com/vicanso/go-charts
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/vicanso/go-charts/blob/master/LICENSE)
[![Build Status](https://github.com/vicanso/go-charts/workflows/Test/badge.svg)](https://github.com/vicanso/go-charts/actions)
[中文](./README_zh.md)
`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart)生成数据图表无其它模块的依赖纯golang的实现支持`svg``png`的输出,`Apache ECharts`在前端开发中得到众多开发者的认可,`go-charts`兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg``png`)方便插入至Email或分享使用。下面为常用的几种图表截图(两种模式)
`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart)it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. The default format is `png` and the default theme is `light`.
![go-charts](./assets/go-charts.png)
`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`.
## 支持图表类型
Screenshot of common charts, the left part is light theme, the right part is grafana theme.
<p align="center">
<img src="./assets/go-charts.png" alt="go-charts">
</p>
<p align="center">
<img src="./assets/go-table.png" alt="go-table">
</p>
## 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`, `bar` 以及 `pie`
### Line Chart
```go
package main
## 示例
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-charts`兼容了`echarts`的参数配置可简单的使用json形式的配置字符串则可快速生成图表。
```go
package main
import (
"git.smarteching.com/zeni/go-charts/v2"
)
"os"
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"
charts "github.com/vicanso/go-charts"
)
func main() {
@ -460,6 +31,7 @@ func main() {
"text": "Line"
},
"xAxis": {
"type": "category",
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
},
"series": [
@ -468,75 +40,65 @@ func main() {
}
]
}`)
// snip...
if err != nil {
panic(err)
}
os.WriteFile("output.png", buf, 0600)
}
```
## ECharts Option
## 参数说明
The name with `[]` is new parameter, others are the same as `echarts`.
- `theme` 颜色主题,支持`dark``light`模式,默认为`light`
- `padding` 图表的内边距单位px。支持以下几种模式的设置
- `padding: 5` 设置内边距为5
- `padding: [5, 10]` 设置上下的内边距为 5左右的内边距为 10
- `padding:[5, 10, 5, 10]` 分别设置`上右下左`边距
- `title` 图表标题,包括标题内容、高度、颜色等
- `title.text` 标题内容
- `title.left` 标题与容器左侧的距离,可设置为`left`, `right`, `center`, `20%` 以及 `20` 这样的具体数值
- `title.top` 标题与容器顶部的距离,暂仅支持具体数值,如`20`
- `title.textStyle.color` 标题文字颜色
- `title.textStyle.fontSize` 标题文字字体大小
- `title.textStyle.height` 标题高度
- `title.textStyle.fontFamily` 标题文字的字体系列,需要注意此配置是会影响整个图表的字体
- `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"}]
- `[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`.
简单的图表生成PNG在20ms左右而SVG的性能则更快性能上比起使用`chrome headless`加载`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
goos: darwin
goarch: amd64
pkg: github.com/vicanso/go-charts
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
BenchmarkEChartsRenderPNG-8 60 17765045 ns/op 2492854 B/op 1007 allocs/op
BenchmarkEChartsRenderSVG-8 282 4303042 ns/op 32622688 B/op 2983 allocs/op
```
## 中文字符
默认使用的字符为`Roboto`为英文字体库,因此如果需要显示中文字符需要增加中文字体库,`InstallFont`函数可添加对应的字体库,成功添加之后则指定`title.textStyle.fontFamily`即可。
在浏览器中使用`svg`时,如果指定的`fontFamily`不支持中文字符,展示的中文并不会乱码,但是会导致在计算字符宽度等错误。

View file

@ -1,576 +0,0 @@
# go-charts
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/vicanso/go-charts/blob/master/LICENSE)
[![Build Status](https://github.com/vicanso/go-charts/workflows/Test/badge.svg)](https://github.com/vicanso/go-charts/actions)
`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)
<p align="center">
<img src="./assets/go-charts.png" alt="go-charts">
</p>
<p align="center">
<img src="./assets/go-table.png" alt="go-table">
</p
## 支持图表类型
支持以下的图表类型:`line`, `bar`, `horizontal bar`, `pie`, `radar`, `funnel` 以及 `table`
## 示例
下面的示例为`go-charts`两种方式的参数配置golang的参数配置、echarts的JSON配置输出相同的折线图。
更多的示例参考:[./examples/](./examples/)目录
### Line Chart
```go
package main
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 (
"git.smarteching.com/zeni/go-charts/v2"
)
func main() {
values := [][]float64{
{
2.0,
4.9,
7.0,
23.2,
25.6,
76.7,
135.6,
162.2,
32.6,
20.0,
6.4,
3.3,
},
{
// snip...
},
}
p, err := charts.BarRender(
values,
charts.XAxisDataOptionFunc([]string{
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
}),
charts.LegendLabelsOptionFunc([]string{
"Rainfall",
"Evaporation",
}, charts.PositionRight),
charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage),
charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax,
charts.SeriesMarkDataTypeMin),
// custom option func
func(opt *charts.ChartOption) {
opt.SeriesList[1].MarkPoint = charts.NewMarkPoint(
charts.SeriesMarkDataTypeMax,
charts.SeriesMarkDataTypeMin,
)
opt.SeriesList[1].MarkLine = charts.NewMarkLine(
charts.SeriesMarkDataTypeAverage,
)
},
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
// snip...
}
```
### Horizontal Bar Chart
```go
package main
import (
"git.smarteching.com/zeni/go-charts/v2"
)
func main() {
values := [][]float64{
{
18203,
23489,
29034,
104970,
131744,
630230,
},
{
// snip...
},
}
p, err := charts.HorizontalBarRender(
values,
charts.TitleTextOptionFunc("World Population"),
charts.PaddingOptionFunc(charts.Box{
Top: 20,
Right: 40,
Bottom: 20,
Left: 20,
}),
charts.LegendLabelsOptionFunc([]string{
"2011",
"2012",
}),
charts.YAxisDataOptionFunc([]string{
"Brazil",
"Indonesia",
"USA",
"India",
"China",
"World",
}),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
// snip...
}
```
### Pie Chart
```go
package main
import (
"git.smarteching.com/zeni/go-charts/v2"
)
func main() {
values := []float64{
1048,
735,
580,
484,
300,
}
p, err := charts.PieRender(
values,
charts.TitleOptionFunc(charts.TitleOption{
Text: "Rainfall vs Evaporation",
Subtext: "Fake Data",
Left: charts.PositionCenter,
}),
charts.PaddingOptionFunc(charts.Box{
Top: 20,
Right: 20,
Bottom: 20,
Left: 20,
}),
charts.LegendOptionFunc(charts.LegendOption{
Orient: charts.OrientVertical,
Data: []string{
"Search Engine",
"Direct",
"Email",
"Union Ads",
"Video Ads",
},
Left: charts.PositionLeft,
}),
charts.PieSeriesShowLabel(),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
// snip...
}
```
### Radar Chart
```go
package main
import (
"git.smarteching.com/zeni/go-charts/v2"
)
func main() {
values := [][]float64{
{
4200,
3000,
20000,
35000,
50000,
18000,
},
{
// snip...
},
}
p, err := charts.RadarRender(
values,
charts.TitleTextOptionFunc("Basic Radar Chart"),
charts.LegendLabelsOptionFunc([]string{
"Allocated Budget",
"Actual Spending",
}),
charts.RadarIndicatorOptionFunc([]string{
"Sales",
"Administration",
"Information Technology",
"Customer Support",
"Development",
"Marketing",
}, []float64{
6500,
16000,
30000,
38000,
52000,
25000,
}),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
// snip...
}
```
### Funnel Chart
```go
package main
import (
"git.smarteching.com/zeni/go-charts/v2"
)
func main() {
values := []float64{
100,
80,
60,
40,
20,
}
p, err := charts.FunnelRender(
values,
charts.TitleTextOptionFunc("Funnel"),
charts.LegendLabelsOptionFunc([]string{
"Show",
"Click",
"Visit",
"Inquiry",
"Order",
}),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
// snip...
}
```
### Table
```go
package main
import (
"git.smarteching.com/zeni/go-charts/v2"
)
func main() {
header := []string{
"Name",
"Age",
"Address",
"Tag",
"Action",
}
data := [][]string{
{
"John Brown",
"32",
"New York No. 1 Lake Park",
"nice, developer",
"Send Mail",
},
{
"Jim Green ",
"42",
"London No. 1 Lake Park",
"wow",
"Send Mail",
},
{
"Joe Black ",
"32",
"Sidney No. 1 Lake Park",
"cool, teacher",
"Send Mail",
},
}
spans := map[int]int{
0: 2,
1: 1,
// 设置第三列的span
2: 3,
3: 2,
4: 2,
}
p, err := charts.TableRender(
header,
data,
spans,
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
// snip...
}
```
### ECharts Render
```go
package main
import (
"git.smarteching.com/zeni/go-charts/v2"
)
func main() {
buf, err := charts.RenderEChartsToPNG(`{
"title": {
"text": "Line"
},
"xAxis": {
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
},
"series": [
{
"data": [150, 230, 224, 218, 135, 147, 260]
}
]
}`)
// snip...
}
```
## 常用函数
`go-charts`针对常用的几种图表提供了简单的调用方式以及几种常用的Option设置便捷的生成常用图表。
- `LineRender`: 折线图表第一个参数为二维浮点数对应图表中的点支持不定长的OptionFunc参数用于指定其它的属性
- `BarRender`: 柱状图表第一个参数为二维浮点数对应柱状图的高度支持不定长的OptionFunc参数用于指定其它的属性
- `PieRender`: 饼图表第一个参数为浮点数数组对应各占比支持不定长的OptionFunc参数用于指定其它的属性
- `RadarRender`: 雷达图第一个参数为二维浮点数对应雷达图中的各值支持不定长的OptionFunc参数用于指定其它的属性
- `FunnelRender`: 漏斗图第一个参数为浮点数数组对应各占比支持不定长的OptionFunc参数用于指定其它的属性
- `PNGTypeOption`: 指定输出PNG
- `FontFamilyOptionFunc`: 指定使用的字体
- `ThemeOptionFunc`: 指定使用的主题类型
- `TitleOptionFunc`: 指定标题相关属性
- `LegendOptionFunc`: 指定图例相关属性
- `XAxisOptionFunc`: 指定x轴的相关属性
- `YAxisOptionFunc`: 指定y轴的相关属性
- `WidthOptionFunc`: 指定宽度
- `HeightOptionFunc`: 指定高度
- `PaddingOptionFunc`: 指定空白填充区域
- `BoxOptionFunc`: 指定内容区域
- `ChildOptionFunc`: 指定子图表
- `RadarIndicatorOptionFunc`: 雷达图指示器相关属性
- `BackgroundColorOptionFunc`: 设置背景图颜色
## ECharts参数说明
名称有[]的参数非echarts的原有参数`go-charts`的新增参数,可根据实际使用场景添加。
- `[type]` 画布类型,支持`svg``png`,默认为`svg`
- `[theme]` 颜色主题,支持`dark``light`以及`grafana`模式,默认为`light`
- `[fontFamily]` 字体,全局的字体设置
- `[padding]` 图表的内边距单位px。支持以下几种模式的设置
- `padding: 5` 设置内边距为5
- `padding: [5, 10]` 设置上下的内边距为 5左右的内边距为 10
- `padding:[5, 10, 5, 10]` 分别设置`上右下左`边距
- `[box]` 图表的区域,以{"left": Int, "right": Int, "top": Int, "bottom": Int}的形式配置
- `[width]` 画布宽度默认为600
- `[height]` 画布高度默认为400
- `title` 图表标题,包括标题内容、高度、颜色等
- `title.text` 标题文本,支持以`\n`的形式换行
- `title.subtext` 副标题文本,支持以`\n`的形式换行
- `title.left` 标题与容器左侧的距离,可设置为`left`, `right`, `center`, `20%` 以及 `20` 这样的具体数值
- `title.top` 标题与容器顶部的距离,暂仅支持具体数值,如`20`
- `title.textStyle.color` 标题文字颜色
- `title.textStyle.fontSize` 标题文字字体大小
- `title.textStyle.fontFamily` 标题文字的字体系列,需要注意此配置是会影响整个图表的字体
- `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"`
- `yAxis.axisLine.lineStyle.color` 坐标轴颜色
- `legend` 图表中不同系列的标记
- `legend.show` 图例是否显示,如果不需要展示需要设置为`false`
- `legend.data` 图例的数据数组,为字符串数组,如["Email", "Video Ads"]
- `legend.align` 图例标记和文本的对齐,可设置为`left`或者`right`,默认为标记靠左`left`
- `legend.padding` legend的padding配置方式与图表的`padding`一致
- `legend.left` legend离容器左侧的距离其值可以为具体的像素值(20)或百分比(20%)、`left`或者`right`
- `legend.top` legend离容器顶部的距离暂仅支持数值形式
- `radar` 雷达图的坐标系
- `radar.indicator` 雷达图的指示器,用来指定雷达图中的多个变量(维度)
- `radar.indicator.name` 指示器名称
- `radar.indicator.max` 指示器的最大值,可选,建议设置
- `radar.indicator.min` 指示器的最小值,可选,默认为 0
- `series` 图表的数据项列表
- `series.name` 图表的名称,与`legend.data`对应,两者只只设置其一
- `series.type` 图表的展示类型,暂支持`line`, `bar`, `pie`, `radar` 以及 `funnel`。需要注意只有`line``bar`可以混用
- `series.radius` 饼图的半径值,如`50%`,默认为`40%`
- `series.yAxisIndex` 该数据项使用的y轴默认为0对yAxis的配置对应
- `series.label.show` 是否显示文本标签(默认为对应的值)
- `series.label.distance` 距离图形元素的距离
- `series.label.color` 文本标签的颜色
- `series.itemStyle.color` 该数据项展示时使用的颜色
- `series.markPoint` 图表的标注配置
- `series.markPoint.symbolSize` 标注的大小默认为30
- `series.markPoint.data` 标注类型,仅支持数组形式,其类型只支持`max``min`,如:`[{"type": "max"}, {"type": "min"}]
- `series.markLine` 图表的标线配置
- `series.markPoint.data` 标线类型,仅支持数组形式,其类型只支持`max``min`以及`average`,如:`[{"type": "max"}, {"type": "min"}, {"type": "average"}]
- `series.data` 数据项对应的数据数组,支持以下形式的数据:
- `数值` 常用形式,数组数据为浮点数组,如[1.1, 2,3, 5.2]
- `结构体` pie图表或bar图表中指定样式使用如[{"value": 1048, "name": "Search Engine"},{"value": 735,"name": "Direct"}]
- `[children]` 嵌套的子图表参数列表,图表支持嵌套的形式=
## 性能
简单的图表生成PNG在20ms左右而SVG的性能则更快性能上比起使用`chrome headless`加载`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
```
## 中文字符
默认使用的字符为`roboto`为英文字体库,因此如果需要显示中文字符需要增加中文字体库,`InstallFont`函数可添加对应的字体库,成功添加之后则指定`title.textStyle.fontFamily`即可。
在浏览器中使用`svg`时,如果指定的`fontFamily`不支持中文字符,展示的中文并不会乱码,但是会导致在计算字符宽度等错误。
字体文件可以在[中文字库noto-cjk](https://github.com/googlefonts/noto-cjk)下载,注意下载时选择字体格式为 `ttf` 格式,如果选用 `otf` 格式可能会加载失败字体尽量选择Bold类型否则生成的图片会有点模糊。
示例见 [examples/chinese/main.go](examples/chinese/main.go)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

443
axis.go
View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -23,317 +23,186 @@
package charts
import (
"strings"
"math"
"github.com/golang/freetype/truetype"
"git.smarteching.com/zeni/go-chart/v2"
"github.com/dustin/go-humanize"
"github.com/wcharczuk/go-chart/v2"
)
type axisPainter struct {
p *Painter
opt *AxisOption
}
func NewAxisPainter(p *Painter, opt AxisOption) *axisPainter {
return &axisPainter{
p: p,
opt: &opt,
}
}
type AxisOption struct {
// The theme of chart
Theme ColorPalette
// Formatter for y axis text value
Formatter string
// The label of axis
type (
// AxisData string
XAxis struct {
// data value 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.
// number of segments
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
}
)
type YAxisOption struct {
// formater of axis
Formater chart.ValueFormatter
// disabled axis
Disabled bool
// min value of axis
Min *float64
// max value of axis
Max *float64
}
func (a *axisPainter) Render() (Box, error) {
opt := a.opt
top := a.p
theme := opt.Theme
if theme == nil {
theme = top.theme
const axisStrokeWidth = 1
func maxInt(values ...int) int {
result := 0
for _, v := range values {
if v > result {
result = v
}
if isFalse(opt.Show) {
return BoxZero, nil
}
return result
}
// 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...)
}
strokeWidth := opt.StrokeWidth
if strokeWidth == 0 {
strokeWidth = 1
size := len(data)
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++
}
unitSize := minUnitSize
// 尽可能选择一格展示更多的块
for i := minUnitSize; i < 2*minUnitSize; i++ {
if originalSize%i == 0 {
unitSize = i
}
}
font := opt.Font
if font == nil {
font = a.p.font
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,
})
}
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()
}
return chart.XAxis{
Ticks: ticks,
TickPosition: tickPosition,
Style: chart.Style{
FontColor: getAxisColor(theme),
StrokeColor: getAxisColor(theme),
StrokeWidth: axisStrokeWidth,
},
}, xValues
}
data := opt.Data
formatter := opt.Formatter
if len(formatter) != 0 {
for index, text := range data {
data[index] = strings.ReplaceAll(formatter, "{value}", text)
func defaultFloatFormater(v interface{}) string {
value, ok := v.(float64)
if !ok {
return ""
}
// 大于10的则直接取整展示
if value >= 10 {
return humanize.CommafWithDigits(value, 0)
}
dataCount := len(data)
tickCount := dataCount
return humanize.CommafWithDigits(value, 2)
}
boundaryGap := true
if isFalse(opt.BoundaryGap) {
boundaryGap = false
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
}
isVertical := opt.Position == PositionLeft ||
opt.Position == PositionRight
labelPosition := ""
if !boundaryGap {
tickCount--
labelPosition = PositionLeft
if option.Max != nil {
m.Max = *option.Max
}
if isVertical && boundaryGap {
labelPosition = PositionCenter
}
return &m
}
// 如果小于0则表示不处理
tickLength := getDefaultInt(opt.TickLength, 5)
labelMargin := getDefaultInt(opt.LabelMargin, 5)
style := Style{
// 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: strokeWidth,
Font: font,
FontColor: fontColor,
FontSize: fontSize,
}
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++
}
}
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,
StrokeWidth: axisStrokeWidth,
},
{
X: x1,
Y: y1,
GridMinorStyle: chart.Style{
StrokeColor: strokeColor,
StrokeWidth: axisStrokeWidth,
},
})
}
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,
Style: chart.Style{
FontColor: getAxisColor(theme),
// alpha 0隐藏
StrokeColor: hiddenColor,
StrokeWidth: axisStrokeWidth,
},
{
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 Box{
Bottom: height,
Right: width,
}, nil
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
}

View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -23,151 +23,150 @@
package charts
import (
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"git.smarteching.com/zeni/go-chart/v2/drawing"
"github.com/wcharczuk/go-chart/v2"
)
func TestAxis(t *testing.T) {
func TestGetXAxisAndValues(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 {
render func(*Painter) ([]byte, error)
result string
xAxis XAxis
tickPosition chart.TickPosition
theme string
result chart.XAxis
values []float64
}{
// 底部x轴
{
render: func(p *Painter) ([]byte, error) {
_, _ = NewAxisPainter(p, AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
xAxis: XAxis{
Data: genLabels(5),
},
SplitLineShow: true,
SplitLineColor: drawing.ColorBlack,
}).Render()
return p.Bytes()
values: genValues(5, false),
result: chart.XAxis{
Ticks: genTicks(5, false),
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 375\nL 0 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 85 375\nL 85 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 171 375\nL 171 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 257 375\nL 257 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 342 375\nL 342 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 428 375\nL 428 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 514 375\nL 514 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 600 375\nL 600 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 0 370\nL 600 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"27\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"115\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"199\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"286\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"376\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"460\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"544\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun</text><path d=\"M 85 0\nL 85 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 171 0\nL 171 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 257 0\nL 257 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 342 0\nL 342 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 428 0\nL 428 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 514 0\nL 514 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 600 0\nL 600 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
},
// 底部x轴文本居左
// 居中
{
render: func(p *Painter) ([]byte, error) {
_, _ = NewAxisPainter(p, AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
xAxis: XAxis{
Data: genLabels(5),
},
BoundaryGap: FalseFlag(),
}).Render()
return p.Bytes()
tickPosition: chart.TickPositionBetweenTicks,
// 居中因此value多一个
values: genValues(5, true),
result: chart.XAxis{
Ticks: genTicks(5, true),
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 375\nL 0 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 100 375\nL 100 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 200 375\nL 200 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 300 375\nL 300 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 400 375\nL 400 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 500 375\nL 500 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 600 375\nL 600 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 0 370\nL 600 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"-15\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"87\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"185\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"287\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"391\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"489\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"587\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 左侧y轴
{
render: func(p *Painter) ([]byte, error) {
_, _ = NewAxisPainter(p, AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
xAxis: XAxis{
Data: genLabels(20),
},
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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 36 0\nL 41 0\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 57\nL 41 57\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 114\nL 41 114\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 171\nL 41 171\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 228\nL 41 228\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 285\nL 41 285\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 342\nL 41 342\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 400\nL 41 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 41 0\nL 41 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"0\" y=\"35\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"4\" y=\"92\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"0\" y=\"149\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"4\" y=\"206\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"13\" y=\"263\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"8\" y=\"320\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"4\" y=\"378\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 左侧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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 36 0\nL 41 0\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 66\nL 41 66\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 133\nL 41 133\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 200\nL 41 200\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 266\nL 41 266\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 333\nL 41 333\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 400\nL 41 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 41 0\nL 41 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"0\" y=\"7\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"4\" y=\"73\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"0\" y=\"140\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"4\" y=\"207\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"13\" y=\"273\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"8\" y=\"340\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"4\" y=\"407\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun</text><path d=\"M 41 0\nL 600 0\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 66\nL 600 66\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 133\nL 600 133\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 200\nL 600 200\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 266\nL 600 266\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 333\nL 600 333\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
},
// 右侧
{
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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 559 0\nL 564 0\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 66\nL 564 66\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 133\nL 564 133\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 200\nL 564 200\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 266\nL 564 266\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 333\nL 564 333\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 400\nL 564 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 0\nL 559 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"569\" y=\"7\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"569\" y=\"73\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"569\" y=\"140\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"569\" y=\"207\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"569\" y=\"273\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"569\" y=\"340\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"569\" y=\"407\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun</text><path d=\"M 0 0\nL 559 0\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 66\nL 559 66\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 133\nL 559 133\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 200\nL 559 200\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 266\nL 559 266\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 333\nL 559 333\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
},
// 顶部
{
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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 380\nL 0 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 85 380\nL 85 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 171 380\nL 171 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 257 380\nL 257 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 342 380\nL 342 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 428 380\nL 428 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 514 380\nL 514 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 600 380\nL 600 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 0 380\nL 600 380\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"20\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon --</text><text x=\"108\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue --</text><text x=\"192\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed --</text><text x=\"279\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu --</text><text x=\"369\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri --</text><text x=\"453\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat --</text><text x=\"537\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun --</text></svg>",
},
}
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))
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)
}
}
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.IsTransparent())
}

View file

@ -1,253 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"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)
}

File diff suppressed because one or more lines are too long

137
bar_series.go Normal file
View file

@ -0,0 +1,137 @@
// 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)
}
}

166
bar_series_test.go Normal file
View file

@ -0,0 +1,166 @@
// 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("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"400\">\\n<path d=\"M 53 233\nL 140 233\nL 140 372\nL 53 372\nL 53 233\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 161 11\nL 248 11\nL 248 372\nL 161 372\nL 161 11\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:none\"/><path d=\"M 268 150\nL 355 150\nL 355 372\nL 268 372\nL 268 150\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 376 345\nL 463 345\nL 463 372\nL 376 372\nL 376 345\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 483 373\nL 570 373\nL 570 372\nL 483 372\nL 483 373\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 591 261\nL 678 261\nL 678 372\nL 591 372\nL 591 261\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 698 205\nL 785 205\nL 785 372\nL 698 372\nL 698 205\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>", buffer.String())
}

133
base_series.go Normal file
View file

@ -0,0 +1,133 @@
// 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
}

94
base_series_test.go Normal file
View file

@ -0,0 +1,94 @@
// 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())
}

View file

@ -1,426 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"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
}

File diff suppressed because one or more lines are too long

636
charts.go
View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -23,451 +23,265 @@
package charts
import (
"bytes"
"errors"
"math"
"sort"
"io"
"sync"
"git.smarteching.com/zeni/go-chart/v2"
"github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
const labelFontSize = 10
const smallLabelFontSize = 8
const defaultDotWidth = 2.0
const defaultStrokeWidth = 2.0
const (
ThemeLight = "light"
ThemeDark = "dark"
)
var defaultChartWidth = 600
var defaultChartHeight = 400
const (
DefaultChartWidth = 800
DefaultChartHeight = 400
)
// SetDefaultWidth sets default width of chart
func SetDefaultWidth(width int) {
if width > 0 {
defaultChartWidth = width
type (
Title struct {
Text string
Style chart.Style
Font *truetype.Font
Left string
Top string
}
}
// SetDefaultHeight sets default height of chart
func SetDefaultHeight(height int) {
if height > 0 {
defaultChartHeight = height
Legend struct {
Data []string
Align string
Padding chart.Box
Left string
Right string
Top string
Bottom string
}
}
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)
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
Font *truetype.Font
}
rh.list = append(list, fn)
}
)
func (rh *renderHandler) Do() error {
for _, fn := range rh.list {
err := fn()
var fonts = sync.Map{}
var ErrFontNotExists = errors.New("font is not exists")
// InstallFont installs the font for charts
func InstallFont(fontFamily string, data []byte) error {
font, err := truetype.Parse(data)
if err != nil {
return err
}
fonts.Store(fontFamily, font)
return nil
}
// GetFont returns the font of 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
}
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")
}
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")
}
}
return nil
}
type defaultRenderOption struct {
Theme ColorPalette
Padding Box
SeriesList SeriesList
// The y axis option
YAxisOptions []YAxisOption
// The x axis option
XAxis XAxisOption
// The title option
TitleOption TitleOption
// The legend option
LegendOption LegendOption
// background is filled
backgroundIsFilled bool
// x y axis is reversed
axisReversed bool
func (o *Options) getWidth() int {
width := o.Width
if width <= 0 {
width = DefaultChartWidth
}
return width
}
type defaultRenderResult struct {
axisRanges map[int]axisRange
// 图例区域
seriesPainter *Painter
func (o *Options) getHeight() int {
height := o.Height
if height <= 0 {
height = DefaultChartHeight
}
return height
}
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())
func (o *Options) getBackground() chart.Style {
bg := chart.Style{
Padding: o.Padding,
}
if !opt.Padding.IsZero() {
p = p.Child(PainterPaddingOption(opt.Padding))
}
legendHeight := 0
if len(opt.LegendOption.Data) != 0 {
if opt.LegendOption.Theme == nil {
opt.LegendOption.Theme = opt.Theme
}
legendResult, err := NewLegendPainter(p, opt.LegendOption).Render()
if err != nil {
return nil, err
}
legendHeight = legendResult.Height()
}
// 如果有标题
if opt.TitleOption.Text != "" {
if opt.TitleOption.Theme == nil {
opt.TitleOption.Theme = opt.Theme
}
titlePainter := NewTitlePainter(p, opt.TitleOption)
titleBox, err := titlePainter.Render()
if err != nil {
return nil, err
}
top := chart.MaxInt(legendHeight, titleBox.Height())
// 如果是垂直方式则不计算legend高度
if opt.LegendOption.Orient == OrientVertical {
top = titleBox.Height()
}
p = p.Child(PainterPaddingOption(Box{
// 标题下留白
Top: top + 20,
}))
}
result := defaultRenderResult{
axisRanges: make(map[int]axisRange),
}
// 计算图表对应的轴有哪些
axisIndexList := make([]int, 0)
for _, series := range opt.SeriesList {
if containsInt(axisIndexList, series.AxisIndex) {
continue
}
axisIndexList = append(axisIndexList, series.AxisIndex)
}
// 高度需要减去x轴的高度
rangeHeight := p.Height() - defaultXAxisHeight
rangeWidthLeft := 0
rangeWidthRight := 0
// 倒序
sort.Sort(sort.Reverse(sort.IntSlice(axisIndexList)))
// 计算对应的axis range
for _, index := range axisIndexList {
yAxisOption := YAxisOption{}
if len(opt.YAxisOptions) > index {
yAxisOption = opt.YAxisOptions[index]
}
divideCount := yAxisOption.DivideCount
if divideCount <= 0 {
divideCount = defaultAxisDivideCount
}
max, min := opt.SeriesList.GetMaxMin(index)
r := NewRange(AxisRangeOption{
Painter: p,
Min: min,
Max: max,
// 高度需要减去x轴的高度
Size: rangeHeight,
// 分隔数量
DivideCount: divideCount,
})
if yAxisOption.Min != nil && *yAxisOption.Min <= min {
r.min = *yAxisOption.Min
}
if yAxisOption.Max != nil && *yAxisOption.Max >= max {
r.max = *yAxisOption.Max
}
result.axisRanges[index] = r
if yAxisOption.Theme == nil {
yAxisOption.Theme = opt.Theme
}
if !opt.axisReversed {
yAxisOption.Data = r.Values()
} else {
yAxisOption.isCategoryAxis = true
// 由于x轴为value部分因此计算其label单独处理
opt.XAxis.Data = NewRange(AxisRangeOption{
Painter: p,
Min: min,
Max: max,
// 高度需要减去x轴的高度
Size: rangeHeight,
// 分隔数量
DivideCount: defaultAxisDivideCount,
}).Values()
opt.XAxis.isValueAxis = true
}
reverseStringSlice(yAxisOption.Data)
// TODO生成其它位置既yAxis
var yAxis *axisPainter
child := p.Child(PainterPaddingOption(Box{
Left: rangeWidthLeft,
Right: rangeWidthRight,
}))
if index == 0 {
yAxis = NewLeftYAxis(child, yAxisOption)
} else {
yAxis = NewRightYAxis(child, yAxisOption)
}
yAxisBox, err := yAxis.Render()
if err != nil {
return nil, err
}
if index == 0 {
rangeWidthLeft += yAxisBox.Width()
} else {
rangeWidthRight += yAxisBox.Width()
}
}
if opt.XAxis.Theme == nil {
opt.XAxis.Theme = opt.Theme
}
xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{
Left: rangeWidthLeft,
Right: rangeWidthRight,
})), opt.XAxis)
_, err := xAxis.Render()
if err != nil {
return nil, err
}
result.seriesPainter = p.Child(PainterPaddingOption(Box{
Bottom: defaultXAxisHeight,
Left: rangeWidthLeft,
Right: rangeWidthRight,
}))
return &result, nil
return bg
}
func doRender(renderers ...Renderer) error {
for _, r := range renderers {
_, err := r.Render()
func render(g Graph, rp chart.RendererProvider) ([]byte, error) {
buf := bytes.Buffer{}
err := g.Render(rp, &buf)
if err != nil {
return err
}
return nil, err
}
return buf.Bytes(), nil
}
func ToPNG(g Graph) ([]byte, error) {
return render(g, chart.PNG)
}
func ToSVG(g Graph) ([]byte, error) {
return render(g, chart.SVG)
}
func newTitleRenderable(title Title, font *truetype.Font, textColor drawing.Color) chart.Renderable {
if title.Text == "" || title.Style.Hidden {
return nil
}
title.Font = font
if title.Style.FontColor.IsZero() {
title.Style.FontColor = textColor
}
return NewTitleCustomize(title)
}
func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) {
for _, fn := range opts {
fn(&opt)
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,
}
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(),
p := &chart.PieChart{
Font: opt.Font,
Background: opt.getBackground(),
Width: opt.getWidth(),
Height: opt.getHeight(),
Values: values,
ColorPalette: &PieThemeColorPalette{
ThemeColorPalette: ThemeColorPalette{
Theme: opt.Theme,
},
},
}
// pie 图表默认设置为居中
if opt.Title.Left == "" {
opt.Title.Left = "center"
}
if len(horizontalBarSeriesList) != 0 {
renderOpt.YAxisOptions[0].DivideCount = len(renderOpt.YAxisOptions[0].Data)
renderOpt.YAxisOptions[0].Unit = 1
titleColor := drawing.ColorBlack
if opt.Theme == ThemeDark {
titleColor = drawing.ColorWhite
}
renderResult, err := defaultRender(p, renderOpt)
if err != nil {
return nil, err
}
handler := renderHandler{}
// bar chart
if len(barSeriesList) != 0 {
handler.Add(func() error {
_, err := NewBarChart(p, BarChartOption{
Theme: opt.theme,
Font: opt.font,
XAxis: opt.XAxis,
BarWidth: opt.BarWidth,
BarMargin: opt.BarMargin,
}).render(renderResult, barSeriesList)
return err
})
}
// horizontal bar chart
if len(horizontalBarSeriesList) != 0 {
handler.Add(func() error {
_, err := NewHorizontalBarChart(p, HorizontalBarChartOption{
Theme: opt.theme,
Font: opt.font,
BarHeight: opt.BarHeight,
BarMargin: opt.BarMargin,
YAxisOptions: opt.YAxisOptions,
}).render(renderResult, horizontalBarSeriesList)
return err
})
}
// pie chart
if len(pieSeriesList) != 0 {
handler.Add(func() error {
_, err := NewPieChart(p, PieChartOption{
Theme: opt.theme,
Font: opt.font,
}).render(renderResult, pieSeriesList)
return err
})
}
// line chart
if len(lineSeriesList) != 0 {
handler.Add(func() error {
_, err := NewLineChart(p, LineChartOption{
Theme: opt.theme,
Font: opt.font,
XAxis: opt.XAxis,
SymbolShow: opt.SymbolShow,
StrokeWidth: opt.LineStrokeWidth,
FillArea: opt.FillArea,
Opacity: opt.Opacity,
}).render(renderResult, lineSeriesList)
return err
})
}
// radar chart
if len(radarSeriesList) != 0 {
handler.Add(func() error {
_, err := NewRadarChart(p, RadarChartOption{
Theme: opt.theme,
Font: opt.font,
// 相应值
RadarIndicators: opt.RadarIndicators,
}).render(renderResult, radarSeriesList)
return err
})
}
// funnel chart
if len(funnelSeriesList) != 0 {
handler.Add(func() error {
_, err := NewFunnelChart(p, FunnelChartOption{
Theme: opt.theme,
Font: opt.font,
}).render(renderResult, funnelSeriesList)
return err
})
}
err = handler.Do()
if err != nil {
return nil, err
}
for _, item := range opt.Children {
item.Parent = p
if item.Theme == "" {
item.Theme = opt.Theme
}
if item.FontFamily == "" {
item.FontFamily = opt.FontFamily
}
_, err = Render(item)
if err != nil {
return nil, err
titleRender := newTitleRenderable(opt.Title, p.GetFont(), titleColor)
if titleRender != nil {
p.Elements = []chart.Renderable{
titleRender,
}
}
return p, nil
return p
}
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]
}
yAxisOption := &YAxisOption{
Disabled: true,
}
if len(opt.YAxisOptions) > 1 {
yAxisOption = opt.YAxisOptions[1]
}
c := &chart.Chart{
Font: opt.Font,
Log: opt.Log,
Background: opt.getBackground(),
ColorPalette: &ThemeColorPalette{
Theme: opt.Theme,
},
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),
}
elements := make([]chart.Renderable, 0)
if legendSize != 0 {
elements = append(elements, NewLegendCustomize(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,
}))
}
titleRender := newTitleRenderable(opt.Title, c.GetFont(), c.GetColorPalette().TextColor())
if titleRender != nil {
elements = append(elements, titleRender)
}
if len(elements) != 0 {
c.Elements = elements
}
return c
}
func New(opt Options) (Graph, error) {
err := opt.validate()
if err != nil {
return nil, err
}
if opt.Series[0].Type == SeriesPie {
return newPieChart(opt), nil
}
return newChart(opt), nil
}

View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -26,230 +26,131 @@ import (
"errors"
"testing"
"git.smarteching.com/zeni/go-chart/v2"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/roboto"
)
func BenchmarkMultiChartPNGRender(b *testing.B) {
for i := 0; i < b.N; i++ {
opt := ChartOption{
Type: ChartOutputPNG,
Legend: LegendOption{
Top: "-90",
Data: []string{
"Milk Tea",
"Matcha Latte",
"Cheese Cocoa",
"Walnut Brownie",
},
},
Padding: chart.Box{
Top: 100,
Right: 10,
Bottom: 10,
Left: 10,
},
XAxis: NewXAxisOption([]string{
"2012",
"2013",
"2014",
"2015",
"2016",
"2017",
}),
YAxisOptions: []YAxisOption{
{
func TestFont(t *testing.T) {
assert := assert.New(t)
Min: NewFloatPoint(0),
Max: NewFloatPoint(90),
},
},
SeriesList: []Series{
NewSeriesFromValues([]float64{
56.5,
82.1,
88.7,
70.1,
53.4,
85.1,
}),
NewSeriesFromValues([]float64{
51.1,
51.4,
55.1,
53.3,
73.8,
68.7,
}),
NewSeriesFromValues([]float64{
40.1,
62.2,
69.5,
36.4,
45.2,
32.5,
}, ChartTypeBar),
NewSeriesFromValues([]float64{
25.2,
37.1,
41.2,
18,
33.9,
49.1,
}, ChartTypeBar),
},
Children: []ChartOption{
fontFamily := "roboto"
err := InstallFont(fontFamily, roboto.Roboto)
assert.Nil(err)
font, err := GetFont(fontFamily)
assert.Nil(err)
assert.NotNil(font)
}
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{
{
Legend: LegendOption{
Show: FalseFlag(),
Data: []string{
"Milk Tea",
"Matcha Latte",
"Cheese Cocoa",
"Walnut Brownie",
Data: []SeriesData{
{
Value: 1,
},
},
Box: chart.Box{
Top: 20,
Left: 400,
Right: 500,
Bottom: 120,
},
SeriesList: NewPieSeriesList([]float64{
435.9,
354.3,
285.9,
204.5,
}, PieSeriesOption{
Label: SeriesLabel{
Show: true,
},
Radius: "35%",
}),
},
},
}
d, err := Render(opt)
if err != nil {
panic(err)
assert.Equal(errors.New("series and xAxis is not matched"), o.validate())
o.XAxis.Data = []string{
"1",
}
buf, err := d.Bytes()
if err != nil {
panic(err)
}
if len(buf) == 0 {
panic(errors.New("data is nil"))
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{
{
Value: 10,
},
},
Name: "chrome",
},
{
Data: []SeriesData{
{
Value: 2,
},
},
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)
}
}
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{
{
func TestNewChart(t *testing.T) {
assert := assert.New(t)
Min: NewFloatPoint(0),
Max: NewFloatPoint(90),
},
},
SeriesList: []Series{
NewSeriesFromValues([]float64{
56.5,
82.1,
88.7,
70.1,
53.4,
85.1,
}),
NewSeriesFromValues([]float64{
51.1,
51.4,
55.1,
53.3,
73.8,
68.7,
}),
NewSeriesFromValues([]float64{
40.1,
62.2,
69.5,
36.4,
45.2,
32.5,
}, ChartTypeBar),
NewSeriesFromValues([]float64{
25.2,
37.1,
41.2,
18,
33.9,
49.1,
}, ChartTypeBar),
},
Children: []ChartOption{
data := []Series{
{
Legend: LegendOption{
Show: FalseFlag(),
Data: []SeriesData{
{
Value: 10,
},
{
Value: 20,
},
},
Name: "chrome",
},
{
Data: []SeriesData{
{
Value: 2,
},
{
Value: 3,
},
},
Name: "edge",
},
}
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{
"Milk Tea",
"Matcha Latte",
"Cheese Cocoa",
"Walnut Brownie",
"chrome",
"edge",
},
},
Box: chart.Box{
Top: 20,
Left: 400,
Right: 500,
Bottom: 120,
},
SeriesList: NewPieSeriesList([]float64{
435.9,
354.3,
285.9,
204.5,
}, PieSeriesOption{
Label: SeriesLabel{
Show: true,
},
Radius: "35%",
}),
},
},
}
d, err := Render(opt)
if err != nil {
panic(err)
}
buf, err := d.Bytes()
if err != nil {
panic(err)
}
if len(buf) == 0 {
panic(errors.New("data is nil"))
}
}
})
assert.Equal(1, len(c.Elements))
}

View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -28,10 +28,21 @@ import (
"fmt"
"regexp"
"strconv"
"strings"
"git.smarteching.com/zeni/go-chart/v2"
"github.com/wcharczuk/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 {
@ -43,79 +54,20 @@ func convertToArray(data []byte) []byte {
return 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 {
func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return nil
}
if numericRep.Match(data) {
if regexp.MustCompile(`^\d+`).Match(data) {
v, err := strconv.ParseFloat(string(data), 64)
if err != nil {
return err
}
es.Value = EChartsSeriesDataValue{
values: []float64{
v,
},
}
es.Value = v
return nil
}
v := _EChartsSeriesData{}
v := _ECharsSeriesData{}
err := json.Unmarshal(data, &v)
if err != nil {
return err
@ -126,55 +78,24 @@ func (es *EChartsSeriesData) UnmarshalJSON(data []byte) error {
return nil
}
type EChartsXAxisData struct {
BoundaryGap *bool `json:"boundaryGap"`
SplitNumber int `json:"splitNumber"`
Data []string `json:"data"`
Type string `json:"type"`
}
type EChartsXAxis struct {
Data []EChartsXAxisData
}
func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error {
data = convertToArray(data)
if len(data) == 0 {
return nil
}
return json.Unmarshal(data, &ex.Data)
}
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
box chart.Box
}
func (eb *EChartsPadding) UnmarshalJSON(data []byte) error {
type Position string
func (lp *Position) 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)(lp)
return json.Unmarshal(data, s)
}
func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
data = convertToArray(data)
if len(data) == 0 {
return nil
@ -189,14 +110,14 @@ func (eb *EChartsPadding) UnmarshalJSON(data []byte) error {
}
switch len(arr) {
case 1:
eb.Box = chart.Box{
ep.box = chart.Box{
Left: arr[0],
Top: arr[0],
Bottom: arr[0],
Right: arr[0],
}
case 2:
eb.Box = chart.Box{
ep.box = chart.Box{
Top: arr[0],
Bottom: arr[0],
Left: arr[1],
@ -209,7 +130,7 @@ func (eb *EChartsPadding) UnmarshalJSON(data []byte) error {
result[3] = result[1]
}
// 上右下左
eb.Box = chart.Box{
ep.box = chart.Box{
Top: result[0],
Right: result[1],
Bottom: result[2],
@ -219,310 +140,241 @@ func (eb *EChartsPadding) UnmarshalJSON(data []byte) error {
return nil
}
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"`
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 EChartsMarkData struct {
Type string `json:"type"`
}
type _EChartsMarkData EChartsMarkData
func (emd *EChartsMarkData) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error {
data = convertToArray(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)
ds := make([]*_EChartsMarkData, 0)
err := json.Unmarshal(data, &ds)
if err != nil {
return err
}
for _, d := range ds {
if d.Type != "" {
emd.Type = d.Type
}
}
if len(data) == 0 {
return nil
}
return json.Unmarshal(data, &ex.Data)
}
type EChartsMarkPoint struct {
SymbolSize int `json:"symbolSize"`
Data []EChartsMarkData `json:"data"`
}
func (emp *EChartsMarkPoint) ToSeriesMarkPoint() SeriesMarkPoint {
sp := SeriesMarkPoint{
SymbolSize: emp.SymbolSize,
}
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
}
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,
},
Radius: item.Radius,
Data: []SeriesData{
{
Value: dataItem.Value.First(),
},
},
})
}
continue
}
// 如果是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, dataItem := range item.Data {
data[j] = SeriesData{
Value: dataItem.Value.First(),
Style: dataItem.ItemStyle.ToStyle(),
}
}
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 seriesList
}
type EChartsTextStyle struct {
type ECharsOptions struct {
Theme string `json:"theme"`
Padding EChartsPadding `json:"padding"`
Title struct {
Text string `json:"text"`
Left Position `json:"left"`
Top Position `json:"top"`
TextStyle struct {
Color string `json:"color"`
FontFamily string `json:"fontFamily"`
FontSize float64 `json:"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"`
Height float64 `json:"height"`
} `json:"textStyle"`
} `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"`
Legend struct {
Data []string `json:"data"`
Align string `json:"align"`
Padding EChartsPadding `json:"padding"`
Left Position `json:"left"`
Right Position `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"`
}
func (eo *EChartsOption) ToOption() ChartOption {
fontFamily := eo.FontFamily
if len(fontFamily) == 0 {
fontFamily = eo.Title.TextStyle.FontFamily
func convertEChartsSeries(e *ECharsOptions) ([]Series, chart.TickPosition) {
tickPosition := chart.TickPositionUnset
if len(e.Series) == 0 {
return nil, tickPosition
}
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,
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
}
series[index] = Series{
Style: style,
Data: []SeriesData{
{
Value: item.Value,
},
},
Type: seriesType,
Name: item.Name,
}
}
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
}
data := make([]SeriesData, len(item.Data))
for j, itemData := range item.Data {
sd := SeriesData{
Value: itemData.Value,
}
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,
}
}
return series, tickPosition
}
func (e *ECharsOptions) ToOptions() Options {
o := Options{
Theme: e.Theme,
Padding: e.Padding.box,
}
titleTextStyle := e.Title.TextStyle
o.Title = Title{
Text: e.Title.Text,
Left: string(e.Title.Left),
Top: string(e.Title.Top),
Style: chart.Style{
FontColor: parseColor(titleTextStyle.Color),
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(),
}
isHorizontalChart := false
for _, item := range eo.XAxis.Data {
if item.Type == "value" {
isHorizontalChart = true
if e.Title.TextStyle.FontFamily != "" {
// 如果获取字体失败忽略
o.Font, _ = GetFont(e.Title.TextStyle.FontFamily)
}
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
}
if isHorizontalChart {
for index := range o.SeriesList {
series := o.SeriesList[index]
if series.Type == ChartTypeBar {
o.SeriesList[index].Type = ChartTypeHorizontalBar
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
}
}
if len(eo.XAxis.Data) != 0 {
xAxisData := eo.XAxis.Data[0]
o.XAxis = XAxisOption{
BoundaryGap: xAxisData.BoundaryGap,
Data: xAxisData.Data,
SplitNumber: xAxisData.SplitNumber,
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),
}
}
yAxisOptions := make([]YAxisOption, len(eo.YAxis.Data))
for index, item := range eo.YAxis.Data {
yAxisOptions[index] = YAxisOption{
Min: item.Min,
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,
Formatter: item.AxisLabel.Formatter,
Color: parseColor(item.AxisLine.LineStyle.Color),
Data: item.Data,
Min: item.Min,
}
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
}
if len(eo.Children) != 0 {
o.Children = make([]ChartOption, len(eo.Children))
for index, item := range eo.Children {
o.Children[index] = item.ToOption()
}
series, tickPosition := convertEChartsSeries(e)
o.Series = series
if boundaryGap {
tickPosition = chart.TickPositionBetweenTicks
}
o.TickPosition = tickPosition
return o
}
func renderEcharts(options, outputType string) ([]byte, error) {
o := EChartsOption{}
err := json.Unmarshal([]byte(options), &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)
if err != nil {
return nil, err
}
opt := o.ToOption()
opt.Type = outputType
d, err := Render(opt)
g, err := New(o)
if err != nil {
return nil, err
}
return d.Bytes()
return render(g, rp)
}
func RenderEChartsToPNG(options string) ([]byte, error) {
return renderEcharts(options, "png")
return echartsRender(options, chart.PNG)
}
func RenderEChartsToSVG(options string) ([]byte, error) {
return renderEcharts(options, "svg")
return echartsRender(options, chart.SVG)
}

File diff suppressed because one or more lines are too long

View file

@ -1,73 +0,0 @@
package main
import (
"os"
"path/filepath"
"git.smarteching.com/zeni/go-charts/v2"
)
func writeFile(buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, "area-line-chart.png")
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := [][]float64{
{
120,
132,
101,
134,
90,
230,
210,
},
}
p, err := charts.LineRender(
values,
charts.TitleTextOptionFunc("Line"),
charts.XAxisDataOptionFunc([]string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
}),
charts.LegendLabelsOptionFunc([]string{
"Email",
}, "50"),
func(opt *charts.ChartOption) {
opt.Legend.Padding = charts.Box{
Top: 5,
Bottom: 10,
}
opt.FillArea = true
},
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -1,102 +0,0 @@
package main
import (
"os"
"path/filepath"
"git.smarteching.com/zeni/go-charts/v2"
)
func writeFile(buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, "bar-chart.png")
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := [][]float64{
{
2.0,
4.9,
7.0,
23.2,
25.6,
76.7,
135.6,
162.2,
32.6,
20.0,
6.4,
3.3,
},
{
2.6,
5.9,
9.0,
26.4,
28.7,
70.7,
175.6,
182.2,
48.7,
18.8,
6.0,
2.3,
},
}
p, err := charts.BarRender(
values,
charts.XAxisDataOptionFunc([]string{
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
}),
charts.LegendLabelsOptionFunc([]string{
"Rainfall",
"Evaporation",
}, charts.PositionRight),
charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage),
charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax,
charts.SeriesMarkDataTypeMin),
// custom option func
func(opt *charts.ChartOption) {
opt.SeriesList[1].MarkPoint = charts.NewMarkPoint(
charts.SeriesMarkDataTypeMax,
charts.SeriesMarkDataTypeMin,
)
opt.SeriesList[1].MarkLine = charts.NewMarkLine(
charts.SeriesMarkDataTypeAverage,
)
},
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

33
examples/basic/main.go Normal file
View file

@ -0,0 +1,33 @@
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)
}

File diff suppressed because it is too large Load diff

View file

@ -1,120 +0,0 @@
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)
}
}

368
examples/demo/main.go Normal file
View file

@ -0,0 +1,368 @@
package main
import (
"bytes"
"net/http"
charts "github.com/vicanso/go-charts"
)
var html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link type="text/css" rel="styleSheet" href="https://unpkg.com/normalize.css@8.0.1/normalize.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
background-color: #e0e0e0;
}
.charts {
width: 830px;
margin: 10px auto;
overflow: hidden;
}
.grid {
float: left;
margin-right: 10px;
}
.grid svg {
margin-bottom: 10px;
}
h1 {
text-align: center;
}
pre {
width: 100%;
margin: auto auto 20px auto;
max-height: 300px;
overflow: auto;
display: block;
}
svg{
margin: auto auto 50px auto;
display: block;
}
</style>
<title>go-charts</title>
</head>
<body>
<div class="charts">{{body}}</div>
</body>
</html>
`
var chartOptions = []map[string]string{
{
"option": `{
"title": {
"text": "Line",
"left": "center"
},
"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"
}
]
}`,
},
{
"option": `{
"legend": {
"align": "left",
"left": 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,
{
"value": 200,
"itemStyle": {
"color": "#a90000"
}
},
150,
80,
70,
110,
130
],
"type": "bar"
}
]
}`,
},
{
"title": "多柱状图",
"option": `{
"title": {
"text": "Rainfall vs Evaporation",
"top": 10
},
"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": "Rainfall",
"left": "right"
},
"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(`<div class="grid">
{{svg}}
</div>`)
o, err := charts.ParseECharsOptions(m["option"])
if err != nil {
return nil, err
}
if opts.width > 0 {
o.Width = opts.width
}
if opts.height > 0 {
o.Height = opts.height
}
for _, theme := range []string{
charts.ThemeDark,
charts.ThemeLight,
} {
o.Theme = theme
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)
data.Write(buf)
}
}
return data.Bytes(), nil
}
func indexHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
return
}
query := r.URL.Query()
opts := renderOptions{
theme: query.Get("theme"),
width: 400,
height: 200,
onlyCharts: true,
}
buf, err := render(opts)
if err != nil {
w.WriteHeader(400)
w.Write([]byte(err.Error()))
return
}
data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), buf)
w.Header().Set("Content-Type", "text/html")
w.Write(data)
}
func main() {
http.HandleFunc("/", indexHandler)
http.ListenAndServe(":3012", nil)
}

View file

@ -1,60 +0,0 @@
package main
import (
"os"
"path/filepath"
"git.smarteching.com/zeni/go-charts/v2"
)
func writeFile(buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, "funnel-chart.png")
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := []float64{
100,
80,
60,
40,
20,
10,
0,
}
p, err := charts.FunnelRender(
values,
charts.TitleTextOptionFunc("Funnel"),
charts.LegendLabelsOptionFunc([]string{
"Show",
"Click",
"Visit",
"Inquiry",
"Order",
"Pay",
"Cancel",
}),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -1,84 +0,0 @@
package main
import (
"os"
"path/filepath"
"git.smarteching.com/zeni/go-charts/v2"
)
func writeFile(buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, "horizontal-bar-chart.png")
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := [][]float64{
{
10,
30,
50,
70,
90,
110,
130,
},
{
20,
40,
60,
80,
100,
120,
140,
},
}
p, err := charts.HorizontalBarRender(
values,
charts.TitleTextOptionFunc("World Population"),
charts.PaddingOptionFunc(charts.Box{
Top: 20,
Right: 40,
Bottom: 20,
Left: 20,
}),
charts.LegendLabelsOptionFunc([]string{
"2011",
"2012",
}),
charts.YAxisDataOptionFunc([]string{
"UN",
"Brazil",
"Indonesia",
"USA",
"India",
"China",
"World",
}),
func(opt *charts.ChartOption) {
opt.SeriesList[0].RoundRadius = 5
},
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -1,124 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"git.smarteching.com/zeni/go-charts/v2"
)
func writeFile(buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, "line-chart.png")
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := [][]float64{
{
120,
132,
101,
// 134,
charts.GetNullValue(),
90,
230,
210,
},
{
220,
182,
191,
234,
290,
330,
310,
},
{
150,
232,
201,
154,
190,
330,
410,
},
{
320,
332,
301,
334,
390,
330,
320,
},
{
820,
932,
901,
934,
1290,
1330,
1320,
},
}
p, err := charts.LineRender(
values,
charts.TitleTextOptionFunc("Line"),
charts.XAxisDataOptionFunc([]string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
}),
charts.LegendLabelsOptionFunc([]string{
"Email",
"Union Ads",
"Video Ads",
"Direct",
"Search Engine",
}, "50"),
func(opt *charts.ChartOption) {
opt.Legend.Padding = charts.Box{
Top: 5,
Bottom: 10,
}
opt.YAxisOptions = []charts.YAxisOption{
{
SplitLineShow: charts.FalseFlag(),
},
}
opt.SymbolShow = charts.FalseFlag()
opt.LineStrokeWidth = 1
opt.ValueFormatter = func(f float64) string {
return fmt.Sprintf("%.0f", f)
}
},
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -1,607 +0,0 @@
package main
import (
"os"
"path/filepath"
charts "git.smarteching.com/zeni/go-charts/v2"
"git.smarteching.com/zeni/go-chart/v2/drawing"
)
func writeFile(buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, "painter.png")
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
p, err := charts.NewPainter(charts.PainterOptions{
Width: 600,
Height: 2000,
Type: charts.ChartOutputPNG,
})
if err != nil {
panic(err)
}
// 背景色
p.SetBackground(p.Width(), p.Height(), drawing.ColorWhite)
top := 0
// 画线
p.SetDrawingStyle(charts.Style{
StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlack,
StrokeWidth: 1,
})
p.LineStroke([]charts.Point{
{
X: 0,
Y: 0,
},
{
X: 100,
Y: 10,
},
{
X: 200,
Y: 0,
},
{
X: 300,
Y: 10,
},
})
// 圆滑曲线
// top += 50
// p.Child(charts.PainterPaddingOption(charts.Box{
// Top: top,
// })).SetDrawingStyle(charts.Style{
// StrokeColor: drawing.ColorBlack,
// FillColor: drawing.ColorBlack,
// StrokeWidth: 1,
// }).SmoothLineStroke([]charts.Point{
// {
// X: 0,
// Y: 0,
// },
// {
// X: 100,
// Y: 10,
// },
// {
// X: 200,
// Y: 0,
// },
// {
// X: 300,
// Y: 10,
// },
// })
// 标线
top += 50
p.Child(charts.PainterPaddingOption(charts.Box{
Top: top,
})).SetDrawingStyle(charts.Style{
StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlack,
StrokeWidth: 1,
StrokeDashArray: []float64{
4,
2,
},
}).MarkLine(0, 0, p.Width())
top += 60
// Polygon
p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
})).SetDrawingStyle(charts.Style{
StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlack,
StrokeWidth: 1,
}).Polygon(charts.Point{
X: 100,
Y: 0,
}, 50, 6)
// FillArea
top += 60
p.Child(charts.PainterPaddingOption(charts.Box{
Top: top,
})).SetDrawingStyle(charts.Style{
FillColor: drawing.ColorBlack,
}).FillArea([]charts.Point{
{
X: 0,
Y: 0,
},
{
X: 100,
Y: 0,
},
{
X: 150,
Y: 40,
},
{
X: 80,
Y: 30,
},
{
X: 0,
Y: 0,
},
})
// 坐标轴的点
top += 50
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: 20,
}),
).SetDrawingStyle(charts.Style{
StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlack,
StrokeWidth: 1,
}).Ticks(charts.TicksOption{
Count: 7,
Length: 5,
})
// 坐标轴的点每2格显示一个
top += 20
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: 20,
}),
).SetDrawingStyle(charts.Style{
StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlack,
StrokeWidth: 1,
}).Ticks(charts.TicksOption{
Unit: 2,
Count: 7,
Length: 5,
})
// 坐标轴的点,纵向
top += 20
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 100,
}),
).SetDrawingStyle(charts.Style{
StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlack,
StrokeWidth: 1,
}).Ticks(charts.TicksOption{
Orient: charts.OrientVertical,
Count: 7,
Length: 5,
})
// 横向展示文本
top += 120
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: 20,
}),
).OverrideTextStyle(charts.Style{
FontColor: drawing.ColorBlack,
FontSize: 10,
}).MultiText(charts.MultiTextOption{
TextList: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
})
// 横向显示文本,靠左
top += 20
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: 20,
}),
).OverrideTextStyle(charts.Style{
FontColor: drawing.ColorBlack,
FontSize: 10,
}).MultiText(charts.MultiTextOption{
Position: charts.PositionLeft,
TextList: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
})
// 纵向显示文本
top += 20
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: 50,
Bottom: top + 150,
}),
).OverrideTextStyle(charts.Style{
FontColor: drawing.ColorBlack,
FontSize: 10,
}).MultiText(charts.MultiTextOption{
Orient: charts.OrientVertical,
Align: charts.AlignRight,
TextList: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
})
// 纵向 文本居中
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 50,
Right: 100,
Bottom: top + 150,
}),
).OverrideTextStyle(charts.Style{
FontColor: drawing.ColorBlack,
FontSize: 10,
}).MultiText(charts.MultiTextOption{
Orient: charts.OrientVertical,
Align: charts.AlignCenter,
TextList: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
})
// 纵向 文本置顶
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 100,
Right: 150,
Bottom: top + 150,
}),
).OverrideTextStyle(charts.Style{
FontColor: drawing.ColorBlack,
FontSize: 10,
}).MultiText(charts.MultiTextOption{
Orient: charts.OrientVertical,
Position: charts.PositionTop,
Align: charts.AlignCenter,
TextList: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
})
// grid
top += 150
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 100,
}),
).OverrideTextStyle(charts.Style{
FontColor: drawing.ColorBlack,
FontSize: 10,
}).Grid(charts.GridOption{
Column: 8,
IgnoreColumnLines: []int{0, 8},
Row: 8,
IgnoreRowLines: []int{0, 8},
})
// dots
top += 100
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 20,
}),
).OverrideDrawingStyle(charts.Style{
FillColor: drawing.ColorWhite,
StrokeColor: drawing.ColorBlack,
StrokeWidth: 1,
}).Dots([]charts.Point{
{
X: 0,
Y: 0,
},
{
X: 50,
Y: 0,
},
{
X: 100,
Y: 10,
},
})
// rect
top += 30
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: 200,
Bottom: top + 50,
}),
).OverrideDrawingStyle(charts.Style{
StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlack,
}).Rect(charts.Box{
Left: 10,
Top: 0,
Right: 110,
Bottom: 20,
})
// legend line dot
p.Child(
charts.PainterBoxOption(charts.Box{
Top: top,
Left: 200,
Right: p.Width() - 1,
Bottom: top + 50,
}),
).OverrideDrawingStyle(charts.Style{
StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlack,
}).LegendLineDot(charts.Box{
Left: 10,
Top: 0,
Right: 50,
Bottom: 20,
})
// grid
top += 50
charts.NewGridPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 100,
})), charts.GridPainterOption{
Row: 5,
IgnoreFirstRow: true,
IgnoreLastRow: true,
StrokeColor: drawing.ColorBlue,
}).Render()
// legend
top += 100
charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 30,
})), charts.LegendOption{
Left: "10",
Data: []string{
"Email",
"Union Ads",
"Video Ads",
"Direct",
},
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// legend
top += 30
charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 30,
})), charts.LegendOption{
Left: charts.PositionRight,
Data: []string{
"Email",
"Union Ads",
"Video Ads",
"Direct",
},
Align: charts.AlignRight,
FontSize: 16,
Icon: charts.IconRect,
FontColor: drawing.ColorBlack,
}).Render()
// legend
top += 30
charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 100,
})), charts.LegendOption{
Top: "10",
Data: []string{
"Email",
"Union Ads",
"Video Ads",
"Direct",
},
Orient: charts.OrientVertical,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis bottom
top += 100
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 50,
})), charts.AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
StrokeColor: drawing.ColorBlack,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis top
top += 50
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 50,
})), charts.AxisOption{
Position: charts.PositionTop,
BoundaryGap: charts.FalseFlag(),
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
StrokeColor: drawing.ColorBlack,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis left
top += 50
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 10,
Right: 60,
Bottom: top + 200,
})), charts.AxisOption{
Position: charts.PositionLeft,
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
StrokeColor: drawing.ColorBlack,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis right
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 100,
Right: 150,
Bottom: top + 200,
})), charts.AxisOption{
Position: charts.PositionRight,
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
StrokeColor: drawing.ColorBlack,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis left no tick
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 150,
Right: 300,
Bottom: top + 200,
})), charts.AxisOption{
BoundaryGap: charts.FalseFlag(),
Position: charts.PositionLeft,
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
FontSize: 12,
FontColor: drawing.ColorBlack,
SplitLineShow: true,
SplitLineColor: drawing.ColorBlack.WithAlpha(100),
}).Render()
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -1,71 +0,0 @@
package main
import (
"os"
"path/filepath"
"git.smarteching.com/zeni/go-charts/v2"
)
func writeFile(buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, "pie-chart.png")
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := []float64{
1048,
735,
580,
484,
300,
}
p, err := charts.PieRender(
values,
charts.TitleOptionFunc(charts.TitleOption{
Text: "Rainfall vs Evaporation",
Subtext: "Fake Data",
Left: charts.PositionCenter,
}),
charts.PaddingOptionFunc(charts.Box{
Top: 20,
Right: 20,
Bottom: 20,
Left: 20,
}),
charts.LegendOptionFunc(charts.LegendOption{
Orient: charts.OrientVertical,
Data: []string{
"Search Engine",
"Direct",
"Email",
"Union Ads",
"Video Ads",
},
Left: charts.PositionLeft,
}),
charts.PieSeriesShowLabel(),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -1,79 +0,0 @@
package main
import (
"os"
"path/filepath"
"git.smarteching.com/zeni/go-charts/v2"
)
func writeFile(buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, "radar-chart.png")
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := [][]float64{
{
4200,
3000,
20000,
35000,
50000,
18000,
},
{
5000,
14000,
28000,
26000,
42000,
21000,
},
}
p, err := charts.RadarRender(
values,
charts.TitleTextOptionFunc("Basic Radar Chart"),
charts.LegendLabelsOptionFunc([]string{
"Allocated Budget",
"Actual Spending",
}),
charts.RadarIndicatorOptionFunc([]string{
"Sales",
"Administration",
"Information Technology",
"Customer Support",
"Development",
"Marketing",
}, []float64{
6500,
16000,
30000,
38000,
52000,
25000,
}),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -1,178 +0,0 @@
package main
import (
"os"
"path/filepath"
"strconv"
"strings"
"git.smarteching.com/zeni/go-charts/v2"
"git.smarteching.com/zeni/go-chart/v2/drawing"
)
func writeFile(buf []byte, filename string) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, filename)
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
// charts.SetDefaultTableSetting(charts.TableDarkThemeSetting)
charts.SetDefaultWidth(810)
header := []string{
"Name",
"Age",
"Address",
"Tag",
"Action",
}
data := [][]string{
{
"John Brown",
"32",
"New York No. 1 Lake Park",
"nice, developer",
"Send Mail",
},
{
"Jim Green ",
"42",
"London No. 1 Lake Park",
"wow",
"Send Mail",
},
{
"Joe Black ",
"32",
"Sidney No. 1 Lake Park",
"cool, teacher",
"Send Mail",
},
}
spans := map[int]int{
0: 2,
1: 1,
// 设置第三列的span
2: 3,
3: 2,
4: 2,
}
p, err := charts.TableRender(
header,
data,
spans,
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf, "table.png")
if err != nil {
panic(err)
}
bgColor := charts.Color{
R: 16,
G: 22,
B: 30,
A: 255,
}
p, err = charts.TableOptionRender(charts.TableChartOption{
Header: []string{
"Name",
"Price",
"Change",
},
BackgroundColor: bgColor,
HeaderBackgroundColor: bgColor,
RowBackgroundColors: []charts.Color{
bgColor,
},
HeaderFontColor: drawing.ColorWhite,
FontColor: drawing.ColorWhite,
Padding: charts.Box{
Top: 15,
Right: 10,
Bottom: 15,
Left: 10,
},
Data: [][]string{
{
"Datadog Inc",
"97.32",
"-7.49%",
},
{
"Hashicorp Inc",
"28.66",
"-9.25%",
},
{
"Gitlab Inc",
"51.63",
"+4.32%",
},
},
TextAligns: []string{
"",
charts.AlignRight,
charts.AlignRight,
},
CellStyle: func(tc charts.TableCell) *charts.Style {
column := tc.Column
if column != 2 {
return nil
}
value, _ := strconv.ParseFloat(strings.Replace(tc.Text, "%", "", 1), 64)
if value == 0 {
return nil
}
style := charts.Style{
Padding: charts.Box{
Bottom: 5,
},
}
if value > 0 {
style.FillColor = charts.Color{
R: 179,
G: 53,
B: 20,
A: 255,
}
} else if value < 0 {
style.FillColor = charts.Color{
R: 33,
G: 124,
B: 50,
A: 255,
}
}
return &style
},
})
if err != nil {
panic(err)
}
buf, err = p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf, "table-color.png")
if err != nil {
panic(err)
}
}

View file

@ -1,81 +0,0 @@
package main
import (
"crypto/rand"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
"git.smarteching.com/zeni/go-charts/v2"
)
func writeFile(buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file := filepath.Join(tmpPath, "time-line-chart.png")
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
xAxisValue := []string{}
values := []float64{}
now := time.Now()
firstAxis := 0
for i := 0; i < 300; i++ {
// 设置首个axis为xx:00的时间点
if firstAxis == 0 && now.Minute() == 0 {
firstAxis = i
}
xAxisValue = append(xAxisValue, now.Format("15:04"))
now = now.Add(time.Minute)
value, _ := rand.Int(rand.Reader, big.NewInt(100))
values = append(values, float64(value.Int64()))
}
p, err := charts.LineRender(
[][]float64{
values,
},
charts.TitleTextOptionFunc("Line"),
charts.XAxisDataOptionFunc(xAxisValue, charts.FalseFlag()),
charts.LegendLabelsOptionFunc([]string{
"Demo",
}, "50"),
func(opt *charts.ChartOption) {
opt.XAxis.FirstAxis = firstAxis
// 必须要比计算得来的最小值更大(每60分钟)
opt.XAxis.SplitNumber = 60
opt.Legend.Padding = charts.Box{
Top: 5,
Bottom: 10,
}
opt.SymbolShow = charts.FalseFlag()
opt.LineStrokeWidth = 1
opt.ValueFormatter = func(f float64) string {
return fmt.Sprintf("%.0f", f)
}
},
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -1,192 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"github.com/golang/freetype/truetype"
)
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)
}

View file

@ -1,79 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFunnelChart(t *testing.T) {
assert := assert.New(t)
tests := []struct {
render func(*Painter) ([]byte, error)
result string
}{
{
render: func(p *Painter) ([]byte, error) {
_, err := NewFunnelChart(p, FunnelChartOption{
SeriesList: NewFunnelSeriesList([]float64{
100,
80,
60,
40,
20,
}),
Legend: NewLegendOption([]string{
"Show",
"Click",
"Visit",
"Inquiry",
"Order",
}),
Title: TitleOption{
Text: "Funnel",
},
}).Render()
if err != nil {
return nil, err
}
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 0\nL 600 0\nL 600 400\nL 0 400\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><path d=\"M 86 9\nL 116 9\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><circle cx=\"101\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"118\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Show</text><path d=\"M 176 9\nL 206 9\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><circle cx=\"191\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><text x=\"208\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Click</text><path d=\"M 262 9\nL 292 9\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><circle cx=\"277\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><text x=\"294\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Visit</text><path d=\"M 345 9\nL 375 9\" style=\"stroke-width:3;stroke:rgba(238,102,102,1.0);fill:rgba(238,102,102,1.0)\"/><circle cx=\"360\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(238,102,102,1.0);fill:rgba(238,102,102,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(238,102,102,1.0);fill:rgba(238,102,102,1.0)\"/><text x=\"377\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Inquiry</text><path d=\"M 444 9\nL 474 9\" style=\"stroke-width:3;stroke:rgba(115,192,222,1.0);fill:rgba(115,192,222,1.0)\"/><circle cx=\"459\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(115,192,222,1.0);fill:rgba(115,192,222,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(115,192,222,1.0);fill:rgba(115,192,222,1.0)\"/><text x=\"476\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Order</text><text x=\"0\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Funnel</text><path d=\"M 0 35\nL 600 35\nL 540 100\nL 60 100\nL 0 35\" style=\"stroke-width:0;stroke:none;fill:rgba(84,112,198,1.0)\"/><text x=\"280\" y=\"67\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">(100%)</text><path d=\"M 60 102\nL 540 102\nL 480 167\nL 120 167\nL 60 102\" style=\"stroke-width:0;stroke:none;fill:rgba(145,204,117,1.0)\"/><text x=\"284\" y=\"134\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">(80%)</text><path d=\"M 120 169\nL 480 169\nL 420 234\nL 180 234\nL 120 169\" style=\"stroke-width:0;stroke:none;fill:rgba(250,200,88,1.0)\"/><text x=\"284\" y=\"201\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">(60%)</text><path d=\"M 180 236\nL 420 236\nL 360 301\nL 240 301\nL 180 236\" style=\"stroke-width:0;stroke:none;fill:rgba(238,102,102,1.0)\"/><text x=\"284\" y=\"268\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">(40%)</text><path d=\"M 240 303\nL 360 303\nL 300 368\nL 300 368\nL 240 303\" style=\"stroke-width:0;stroke:none;fill:rgba(115,192,222,1.0)\"/><text x=\"284\" y=\"335\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">(20%)</text></svg>",
},
}
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))
}
}

16
go.mod
View file

@ -1,17 +1,17 @@
module git.smarteching.com/zeni/go-charts/v2
module github.com/vicanso/go-charts
go 1.24.1
go 1.17
require (
git.smarteching.com/zeni/go-chart/v2 v2.1.4
github.com/dustin/go-humanize v1.0.1
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/stretchr/testify v1.10.0
github.com/dustin/go-humanize v1.0.0
github.com/stretchr/testify v1.7.0
github.com/wcharczuk/go-chart/v2 v2.1.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.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

27
go.sum
View file

@ -1,18 +1,25 @@
git.smarteching.com/zeni/go-chart/v2 v2.1.4 h1:pF06+F6eqJLIG8uMiTVPR5TygPGMjM/FHMzTxmu5V/Q=
git.smarteching.com/zeni/go-chart/v2 v2.1.4/go.mod h1:b3ueW9h3pGGXyhkormZAvilHaG4+mQti+bMNPdQBeOQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

92
grid.go
View file

@ -1,92 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
type gridPainter struct {
p *Painter
opt *GridPainterOption
}
type GridPainterOption struct {
// The stroke width
StrokeWidth float64
// The stroke color
StrokeColor Color
// The spans of column
ColumnSpans []int
// The column of grid
Column int
// The row of grid
Row int
// Ignore first row
IgnoreFirstRow bool
// Ignore last row
IgnoreLastRow bool
// Ignore first column
IgnoreFirstColumn bool
// Ignore last column
IgnoreLastColumn bool
}
// NewGridPainter returns new a grid renderer
func NewGridPainter(p *Painter, opt GridPainterOption) *gridPainter {
return &gridPainter{
p: p,
opt: &opt,
}
}
func (g *gridPainter) Render() (Box, error) {
opt := g.opt
ignoreColumnLines := make([]int, 0)
if opt.IgnoreFirstColumn {
ignoreColumnLines = append(ignoreColumnLines, 0)
}
if opt.IgnoreLastColumn {
ignoreColumnLines = append(ignoreColumnLines, opt.Column)
}
ignoreRowLines := make([]int, 0)
if opt.IgnoreFirstRow {
ignoreRowLines = append(ignoreRowLines, 0)
}
if opt.IgnoreLastRow {
ignoreRowLines = append(ignoreRowLines, opt.Row)
}
strokeWidth := opt.StrokeWidth
if strokeWidth <= 0 {
strokeWidth = 1
}
g.p.SetDrawingStyle(Style{
StrokeWidth: strokeWidth,
StrokeColor: opt.StrokeColor,
})
g.p.Grid(GridOption{
Column: opt.Column,
ColumnSpans: opt.ColumnSpans,
Row: opt.Row,
IgnoreColumnLines: ignoreColumnLines,
IgnoreRowLines: ignoreRowLines,
})
return g.p.box, nil
}

View file

@ -1,87 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"git.smarteching.com/zeni/go-chart/v2/drawing"
)
func TestGrid(t *testing.T) {
assert := assert.New(t)
tests := []struct {
render func(*Painter) ([]byte, error)
result string
}{
{
render: func(p *Painter) ([]byte, error) {
_, err := NewGridPainter(p, GridPainterOption{
StrokeColor: drawing.ColorBlack,
Column: 6,
Row: 6,
IgnoreFirstRow: true,
IgnoreLastRow: true,
IgnoreFirstColumn: true,
IgnoreLastColumn: true,
}).Render()
if err != nil {
return nil, err
}
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 100 0\nL 100 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 200 0\nL 200 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 300 0\nL 300 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 400 0\nL 400 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 500 0\nL 500 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 66\nL 600 66\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 133\nL 600 133\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 200\nL 600 200\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 266\nL 600 266\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 333\nL 600 333\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
},
{
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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 0\nL 0 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 120 0\nL 120 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 420 0\nL 420 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 600 0\nL 600 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 0\nL 600 0\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 66\nL 600 66\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 133\nL 600 133\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 200\nL 600 200\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 266\nL 600 266\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 333\nL 600 333\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 400\nL 600 400\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
},
}
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))
}
}

View file

@ -1,216 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"github.com/golang/freetype/truetype"
"git.smarteching.com/zeni/go-chart/v2"
)
type horizontalBarChart struct {
p *Painter
opt *HorizontalBarChartOption
}
type HorizontalBarChartOption struct {
// The theme
Theme ColorPalette
// The font size
Font *truetype.Font
// The data series list
SeriesList SeriesList
// The x axis option
XAxis XAxisOption
// The padding of line chart
Padding Box
// The y axis option
YAxisOptions []YAxisOption
// The option of title
Title TitleOption
// The legend option
Legend LegendOption
BarHeight int
// Margin of bar
BarMargin int
}
// NewHorizontalBarChart returns a horizontal bar chart renderer
func NewHorizontalBarChart(p *Painter, opt HorizontalBarChartOption) *horizontalBarChart {
if opt.Theme == nil {
opt.Theme = defaultTheme
}
return &horizontalBarChart{
p: p,
opt: &opt,
}
}
func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
p := h.p
opt := h.opt
seriesPainter := result.seriesPainter
yRange := result.axisRanges[0]
y0, y1 := yRange.GetRange(0)
height := int(y1 - y0)
// 每一块之间的margin
margin := 10
// 每一个bar之间的margin
barMargin := 5
if height < 20 {
margin = 2
barMargin = 2
} else if height < 50 {
margin = 5
barMargin = 3
}
if opt.BarMargin > 0 {
barMargin = opt.BarMargin
}
seriesCount := len(seriesList)
// 总的高度-两个margin-(总数-1)的barMargin
barHeight := (height - 2*margin - barMargin*(seriesCount-1)) / seriesCount
if opt.BarHeight > 0 && opt.BarHeight < barHeight {
barHeight = opt.BarHeight
margin = (height - seriesCount*barHeight - barMargin*(seriesCount-1)) / 2
}
theme := opt.Theme
max, min := seriesList.GetMaxMin(0)
xRange := NewRange(AxisRangeOption{
Painter: p,
Min: min,
Max: max,
DivideCount: defaultAxisDivideCount,
Size: seriesPainter.Width(),
})
seriesNames := seriesList.Names()
rendererList := []Renderer{}
for index := range seriesList {
series := seriesList[index]
seriesColor := theme.GetSeriesColor(series.index)
divideValues := yRange.AutoDivide()
var labelPainter *SeriesLabelPainter
if series.Label.Show {
labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
P: seriesPainter,
SeriesNames: seriesNames,
Label: series.Label,
Theme: opt.Theme,
Font: opt.Font,
})
rendererList = append(rendererList, labelPainter)
}
for j, item := range series.Data {
if j >= yRange.divideCount {
continue
}
// 显示位置切换
j = yRange.divideCount - j - 1
y := divideValues[j]
y += margin
if index != 0 {
y += index * (barHeight + barMargin)
}
w := int(xRange.getHeight(item.Value))
fillColor := seriesColor
if !item.Style.FillColor.IsZero() {
fillColor = item.Style.FillColor
}
right := w
if series.RoundRadius <= 0 {
seriesPainter.OverrideDrawingStyle(Style{
FillColor: fillColor,
}).Rect(chart.Box{
Top: y,
Left: 0,
Right: right,
Bottom: y + barHeight,
})
} else {
seriesPainter.OverrideDrawingStyle(Style{
FillColor: fillColor,
}).RoundedRect(chart.Box{
Top: y,
Left: 0,
Right: right,
Bottom: y + barHeight,
}, series.RoundRadius)
}
// 如果label不需要展示则返回
if labelPainter == nil {
continue
}
labelValue := LabelValue{
Orient: OrientHorizontal,
Index: index,
Value: item.Value,
X: right,
Y: y + barHeight>>1,
Offset: series.Label.Offset,
FontColor: series.Label.Color,
FontSize: series.Label.FontSize,
}
if series.Label.Position == PositionLeft {
labelValue.X = 0
if labelValue.FontColor.IsZero() {
if isLightColor(fillColor) {
labelValue.FontColor = defaultLightFontColor
} else {
labelValue.FontColor = defaultDarkFontColor
}
}
}
labelPainter.Add(labelValue)
}
}
err := doRender(rendererList...)
if err != nil {
return BoxZero, err
}
return p.box, nil
}
func (h *horizontalBarChart) Render() (Box, error) {
p := h.p
opt := h.opt
renderResult, err := defaultRender(p, defaultRenderOption{
Theme: opt.Theme,
Padding: opt.Padding,
SeriesList: opt.SeriesList,
XAxis: opt.XAxis,
YAxisOptions: opt.YAxisOptions,
TitleOption: opt.Title,
LegendOption: opt.Legend,
axisReversed: true,
})
if err != nil {
return BoxZero, err
}
seriesList := opt.SeriesList.Filter(ChartTypeHorizontalBar)
return h.render(renderResult, seriesList)
}

File diff suppressed because one or more lines are too long

382
legend.go
View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -25,227 +25,225 @@ package charts
import (
"strconv"
"strings"
"github.com/wcharczuk/go-chart/v2"
)
type legendPainter struct {
p *Painter
opt *LegendOption
}
const IconRect = "rect"
const IconLineDot = "lineDot"
type LegendOption struct {
// The theme
Theme ColorPalette
// 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.
Style chart.Style
Padding chart.Box
Left string
// Distance between legend component and the top side of the container.
// It can be pixel value: 20.
Right string
Top string
// Legend marker and text aligning, it can be left or right, default is left.
Bottom string
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
Theme string
IconDraw LegendIconDraw
}
// 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
type LegendIconDrawOption struct {
Box chart.Box
Style chart.Style
Index int
Theme string
}
// IsEmpty checks legend is empty
func (opt *LegendOption) IsEmpty() bool {
isEmpty := true
for _, v := range opt.Data {
if v != "" {
isEmpty = false
break
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
}
}
return isEmpty
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()
}
// NewLegendPainter returns a legend renderer
func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter {
return &legendPainter{
p: p,
opt: &opt,
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 (l *legendPainter) Render() (Box, error) {
opt := l.opt
theme := opt.Theme
if opt.IsEmpty() ||
isFalse(opt.Show) {
return BoxZero, nil
func getLegendLeft(canvasWidth, legendBoxWidth int, opt LegendOption) int {
left := (canvasWidth - legendBoxWidth) / 2
leftValue := opt.Left
if leftValue == "auto" || leftValue == "center" {
leftValue = ""
}
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
if leftValue == "left" {
leftValue = "0"
}
// 计算展示的宽高
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()
rightValue := opt.Right
if rightValue == "auto" || leftValue == "center" {
rightValue = ""
}
if opt.Orient == OrientVertical {
height += item.Height()
} else {
width += item.Width()
if rightValue == "right" {
rightValue = "0"
}
if leftValue == "" && rightValue == "" {
return left
}
// 增加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)
if leftValue != "" {
percent := convertPercent(leftValue)
if percent >= 0 {
return int(float64(canvasWidth) * percent)
}
v, _ := strconv.Atoi(leftValue)
return v
}
if rightValue != "" {
percent := convertPercent(rightValue)
if percent >= 0 {
return canvasWidth - legendBoxWidth - int(float64(canvasWidth)*percent)
}
v, _ := strconv.Atoi(rightValue)
return canvasWidth - legendBoxWidth - v
}
return left
}
func getLegendTop(height, legendBoxHeight int, opt LegendOption) int {
// TODO 支持top的处理
return 0
}
func NewLegendCustomize(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,
}
// 计算开始的位置
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
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())
}
}
top, _ := strconv.Atoi(opt.Top)
if left < 0 {
left = 0
}
x := int(left)
y := int(top) + 10
startY := y
x0 := x
y0 := y
var textHeight 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)
}
}
drawIcon := func(top, left int) int {
if opt.Icon == IconRect {
p.Rect(Box{
Top: top - legendHeight + 8,
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 + lineTextGap) * (len(labels) - 1)
legendBoxWidth := labelWidth + iconAllWidth + spaceAllWidth
left := getLegendLeft(cb.Width(), legendBoxWidth, opt)
top := getLegendTop(cb.Height(), legendBoxHeight, opt)
left += (opt.Padding.Left + cb.Left)
top += (opt.Padding.Top + cb.Top)
legendBox := chart.Box{
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
Right: left + legendBoxWidth,
Top: top,
Bottom: top + legendBoxHeight,
}
return Box{
Right: width,
Bottom: height + padding.Bottom + padding.Top,
}, nil
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
}
}
}
}

View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -23,80 +23,91 @@
package charts
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
)
func TestNewLegend(t *testing.T) {
func TestNewLegendCustomize(t *testing.T) {
assert := assert.New(t)
series := GetSeries([]Series{
{
Name: "chrome",
},
{
Name: "edge",
},
}, chart.TickPositionBetweenTicks, "")
tests := []struct {
render func(*Painter) ([]byte, error)
result string
align string
svg string
}{
{
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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 184 9\nL 214 9\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><circle cx=\"199\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"216\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">One</text><path d=\"M 264 9\nL 294 9\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><circle cx=\"279\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><text x=\"296\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Two</text><path d=\"M 346 9\nL 376 9\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><circle cx=\"361\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><text x=\"378\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Three</text></svg>",
align: LegendAlignLeft,
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<path d=\"M 449 111\nL 582 111\nL 582 121\nL 449 121\nL 449 111\" style=\"stroke-width:0;stroke:rgba(51,51,51,1.0);fill:none\"/><path d=\"M 449 118\nL 474 118\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"461\" cy=\"118\" r=\"5\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"479\" y=\"121\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">chrome</text><path d=\"M 534 118\nL 559 118\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"546\" cy=\"118\" r=\"5\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"564\" y=\"121\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">edge</text></svg>",
},
{
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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 9\nL 30 9\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><circle cx=\"15\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"32\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">One</text><path d=\"M 80 9\nL 110 9\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><circle cx=\"95\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><text x=\"112\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Two</text><path d=\"M 162 9\nL 192 9\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><circle cx=\"177\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><text x=\"194\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Three</text></svg>",
},
{
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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 60 3\nL 90 3\nL 90 16\nL 60 16\nL 60 3\" style=\"stroke-width:0;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"92\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">One</text><path d=\"M 60 23\nL 90 23\nL 90 36\nL 60 36\nL 60 23\" style=\"stroke-width:0;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><text x=\"92\" y=\"35\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Two</text><path d=\"M 60 43\nL 90 43\nL 90 56\nL 60 56\nL 60 43\" style=\"stroke-width:0;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><text x=\"92\" y=\"55\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Three</text></svg>",
align: LegendAlignRight,
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<path d=\"M 449 111\nL 582 111\nL 582 121\nL 449 121\nL 449 111\" style=\"stroke-width:0;stroke:rgba(51,51,51,1.0);fill:none\"/><text x=\"449\" y=\"121\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">chrome</text><path d=\"M 489 118\nL 514 118\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"501\" cy=\"118\" r=\"5\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"539\" y=\"121\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">edge</text><path d=\"M 567 118\nL 592 118\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"579\" cy=\"118\" r=\"5\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/></svg>",
},
}
for _, tt := range tests {
p, err := NewPainter(PainterOptions{
Type: ChartOutputSVG,
Width: 600,
Height: 400,
}, PainterThemeOption(defaultTheme))
r, err := chart.SVG(800, 600)
assert.Nil(err)
data, err := tt.render(p)
fn := NewLegendCustomize(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)
assert.Nil(err)
assert.Equal(tt.result, string(data))
assert.Equal(tt.svg, buf.String())
}
}
func TestConvertPercent(t *testing.T) {
assert := assert.New(t)
assert.Equal(-1.0, convertPercent("12"))
assert.Equal(0.12, convertPercent("12%"))
}
func TestGetLegendLeft(t *testing.T) {
assert := assert.New(t)
assert.Equal(150, getLegendLeft(500, 200, LegendOption{}))
assert.Equal(0, getLegendLeft(500, 200, LegendOption{
Left: "left",
}))
assert.Equal(100, getLegendLeft(500, 200, LegendOption{
Left: "20%",
}))
assert.Equal(20, getLegendLeft(500, 200, LegendOption{
Left: "20",
}))
assert.Equal(300, getLegendLeft(500, 200, LegendOption{
Right: "right",
}))
assert.Equal(200, getLegendLeft(500, 200, LegendOption{
Right: "20%",
}))
assert.Equal(280, getLegendLeft(500, 200, LegendOption{
Right: "20",
}))
}

View file

@ -1,240 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"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)
}

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -23,51 +23,23 @@
package charts
import (
"git.smarteching.com/zeni/go-chart/v2"
"git.smarteching.com/zeni/go-chart/v2/drawing"
"github.com/wcharczuk/go-chart/v2"
)
type Box = chart.Box
type Style = chart.Style
type Color = drawing.Color
var BoxZero = chart.BoxZero
type Point struct {
X int
Y int
type LineSeries struct {
BaseSeries
}
const (
ChartTypeLine = "line"
ChartTypeBar = "bar"
ChartTypePie = "pie"
ChartTypeRadar = "radar"
ChartTypeFunnel = "funnel"
// horizontal bar
ChartTypeHorizontalBar = "horizontalBar"
)
func (ls LineSeries) getXRange(xrange chart.Range) chart.Range {
if ls.TickPosition != chart.TickPositionBetweenTicks {
return xrange
}
// 如果是居中,画线时重新调整
return wrapRange(xrange, ls.TickPosition)
}
const (
ChartOutputSVG = "svg"
ChartOutputPNG = "png"
)
const (
PositionLeft = "left"
PositionRight = "right"
PositionCenter = "center"
PositionTop = "top"
PositionBottom = "bottom"
)
const (
AlignLeft = "left"
AlignRight = "right"
AlignCenter = "center"
)
const (
OrientHorizontal = "horizontal"
OrientVertical = "vertical"
)
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)
}

View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -26,17 +26,21 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"git.smarteching.com/zeni/go-chart/v2/roboto"
"github.com/wcharczuk/go-chart/v2"
)
func TestInstallFont(t *testing.T) {
func TestLineSeries(t *testing.T) {
assert := assert.New(t)
fontFamily := "test"
err := InstallFont(fontFamily, roboto.Roboto)
assert.Nil(err)
ls := LineSeries{}
font, err := GetFont(fontFamily)
assert.Nil(err)
assert.NotNil(font)
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)
}

View file

@ -1,113 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"github.com/golang/freetype/truetype"
)
// 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
}

View file

@ -1,90 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"git.smarteching.com/zeni/go-chart/v2/drawing"
)
func 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<circle cx=\"23\" cy=\"272\" r=\"3\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 29 272\nL 562 272\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 562 267\nL 578 272\nL 562 277\nL 567 272\nL 562 267\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><text x=\"580\" y=\"276\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">3</text><circle cx=\"23\" cy=\"308\" r=\"3\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 29 308\nL 562 308\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 562 303\nL 578 308\nL 562 313\nL 567 308\nL 562 303\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><text x=\"580\" y=\"312\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">2</text><circle cx=\"23\" cy=\"344\" r=\"3\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 29 344\nL 562 344\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 562 339\nL 578 344\nL 562 349\nL 567 344\nL 562 339\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><text x=\"580\" y=\"348\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">1</text></svg>",
},
}
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))
}
}

View file

@ -1,115 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"github.com/golang/freetype/truetype"
)
// 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
}

View file

@ -1,92 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 67 62\nA 15 15 330.00 1 1 73 62\nL 70 48\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><path d=\"M 55 48\nQ70,85 85,48\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><text x=\"66\" y=\"53\" style=\"stroke-width:0;stroke:none;fill:rgba(238,238,238,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">3</text></svg>",
},
}
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))
}
}

View file

@ -1,866 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"bytes"
"errors"
"math"
"github.com/golang/freetype/truetype"
"git.smarteching.com/zeni/go-chart/v2"
)
type ValueFormatter func(float64) string
type Painter struct {
render chart.Renderer
box Box
font *truetype.Font
parent *Painter
style Style
theme ColorPalette
// 类型
outputType string
valueFormatter ValueFormatter
}
type PainterOptions struct {
// Draw type, "svg" or "png", default type is "png"
Type string
// The width of draw painter
Width int
// The height of draw painter
Height int
// The font for painter
Font *truetype.Font
}
type PainterOption func(*Painter)
type TicksOption struct {
// the first tick
First int
Length int
Orient string
Count int
Unit int
}
type MultiTextOption struct {
TextList []string
Orient string
Unit int
Position string
Align string
// The text rotation of label
TextRotation float64
Offset Box
// The first text index
First int
}
type GridOption struct {
Column int
Row int
ColumnSpans []int
// 忽略不展示的column
IgnoreColumnLines []int
// 忽略不展示的row
IgnoreRowLines []int
}
// PainterPaddingOption sets the padding of draw painter
func PainterPaddingOption(padding Box) PainterOption {
return func(p *Painter) {
p.box.Left += padding.Left
p.box.Top += padding.Top
p.box.Right -= padding.Right
p.box.Bottom -= padding.Bottom
}
}
// PainterBoxOption sets the box of draw painter
func PainterBoxOption(box Box) PainterOption {
return func(p *Painter) {
if box.IsZero() {
return
}
p.box = box
}
}
// PainterFontOption sets the font of draw painter
func PainterFontOption(font *truetype.Font) PainterOption {
return func(p *Painter) {
if font == nil {
return
}
p.font = font
}
}
// PainterStyleOption sets the style of draw painter
func PainterStyleOption(style Style) PainterOption {
return func(p *Painter) {
p.SetStyle(style)
}
}
// PainterThemeOption sets the theme of draw painter
func PainterThemeOption(theme ColorPalette) PainterOption {
return func(p *Painter) {
if theme == nil {
return
}
p.theme = theme
}
}
// PainterWidthHeightOption set width or height of draw painter
func PainterWidthHeightOption(width, height int) PainterOption {
return func(p *Painter) {
if width > 0 {
p.box.Right = p.box.Left + width
}
if height > 0 {
p.box.Bottom = p.box.Top + height
}
}
}
// NewPainter creates a painter
func NewPainter(opts PainterOptions, opt ...PainterOption) (*Painter, error) {
if opts.Width <= 0 || opts.Height <= 0 {
return nil, errors.New("width/height can not be nil")
}
font := opts.Font
if font == nil {
f, err := GetDefaultFont()
if err != nil {
return nil, err
}
font = f
}
fn := chart.PNG
if opts.Type == ChartOutputSVG {
fn = chart.SVG
}
width := opts.Width
height := opts.Height
r, err := fn(width, height)
if err != nil {
return nil, err
}
r.SetFont(font)
p := &Painter{
render: r,
box: Box{
Right: opts.Width,
Bottom: opts.Height,
},
font: font,
// 类型
outputType: opts.Type,
}
p.setOptions(opt...)
if p.theme == nil {
p.theme = NewTheme(ThemeLight)
}
return p, nil
}
func (p *Painter) setOptions(opts ...PainterOption) {
for _, fn := range opts {
fn(p)
}
}
func (p *Painter) Child(opt ...PainterOption) *Painter {
child := &Painter{
// 格式化
valueFormatter: p.valueFormatter,
// render
render: p.render,
box: p.box.Clone(),
font: p.font,
parent: p,
style: p.style,
theme: p.theme,
}
child.setOptions(opt...)
return child
}
func (p *Painter) SetStyle(style Style) {
if style.Font == nil {
style.Font = p.font
}
p.style = style
style.WriteToRenderer(p.render)
}
func overrideStyle(defaultStyle Style, style Style) Style {
if style.StrokeWidth == 0 {
style.StrokeWidth = defaultStyle.StrokeWidth
}
if style.StrokeColor.IsZero() {
style.StrokeColor = defaultStyle.StrokeColor
}
if style.StrokeDashArray == nil {
style.StrokeDashArray = defaultStyle.StrokeDashArray
}
if style.DotColor.IsZero() {
style.DotColor = defaultStyle.DotColor
}
if style.DotWidth == 0 {
style.DotWidth = defaultStyle.DotWidth
}
if style.FillColor.IsZero() {
style.FillColor = defaultStyle.FillColor
}
if style.FontSize == 0 {
style.FontSize = defaultStyle.FontSize
}
if style.FontColor.IsZero() {
style.FontColor = defaultStyle.FontColor
}
if style.Font == nil {
style.Font = defaultStyle.Font
}
return style
}
func (p *Painter) OverrideDrawingStyle(style Style) *Painter {
s := overrideStyle(p.style, style)
p.SetDrawingStyle(s)
return p
}
func (p *Painter) SetDrawingStyle(style Style) *Painter {
style.WriteDrawingOptionsToRenderer(p.render)
return p
}
func (p *Painter) SetTextStyle(style Style) *Painter {
if style.Font == nil {
style.Font = p.font
}
style.WriteTextOptionsToRenderer(p.render)
return p
}
func (p *Painter) OverrideTextStyle(style Style) *Painter {
s := overrideStyle(p.style, style)
p.SetTextStyle(s)
return p
}
func (p *Painter) ResetStyle() *Painter {
p.style.WriteToRenderer(p.render)
return p
}
// Bytes returns the data of draw canvas
func (p *Painter) Bytes() ([]byte, error) {
buffer := bytes.Buffer{}
err := p.render.Save(&buffer)
if err != nil {
return nil, err
}
return buffer.Bytes(), err
}
// MoveTo moves the cursor to a given point
func (p *Painter) MoveTo(x, y int) *Painter {
p.render.MoveTo(x+p.box.Left, y+p.box.Top)
return p
}
func (p *Painter) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) *Painter {
p.render.ArcTo(cx+p.box.Left, cy+p.box.Top, rx, ry, startAngle, delta)
return p
}
func (p *Painter) LineTo(x, y int) *Painter {
p.render.LineTo(x+p.box.Left, y+p.box.Top)
return p
}
func (p *Painter) QuadCurveTo(cx, cy, x, y int) *Painter {
p.render.QuadCurveTo(cx+p.box.Left, cy+p.box.Top, x+p.box.Left, y+p.box.Top)
return p
}
func (p *Painter) Pin(x, y, width int) *Painter {
r := float64(width) / 2
y -= width / 4
angle := chart.DegreesToRadians(15)
box := p.box
startAngle := math.Pi/2 + angle
delta := 2*math.Pi - 2*angle
p.ArcTo(x, y, r, r, startAngle, delta)
p.LineTo(x, y)
p.Close()
p.FillStroke()
startX := x - int(r)
startY := y
endX := x + int(r)
endY := y
p.MoveTo(startX, startY)
left := box.Left
top := box.Top
cx := x
cy := y + int(r*2.5)
p.render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top)
p.Close()
p.Fill()
return p
}
func (p *Painter) arrow(x, y, width, height int, direction string) *Painter {
halfWidth := width >> 1
halfHeight := height >> 1
if direction == PositionTop || direction == PositionBottom {
x0 := x - halfWidth
x1 := x0 + width
dy := -height / 3
y0 := y
y1 := y0 - height
if direction == PositionBottom {
y0 = y - height
y1 = y
dy = 2 * dy
}
p.MoveTo(x0, y0)
p.LineTo(x0+halfWidth, y1)
p.LineTo(x1, y0)
p.LineTo(x0+halfWidth, y+dy)
p.LineTo(x0, y0)
} else {
x0 := x + width
x1 := x0 - width
y0 := y - halfHeight
dx := -width / 3
if direction == PositionRight {
x0 = x - width
dx = -dx
x1 = x0 + width
}
p.MoveTo(x0, y0)
p.LineTo(x1, y0+halfHeight)
p.LineTo(x0, y0+height)
p.LineTo(x0+dx, y0+halfHeight)
p.LineTo(x0, y0)
}
p.FillStroke()
return p
}
func (p *Painter) ArrowLeft(x, y, width, height int) *Painter {
p.arrow(x, y, width, height, PositionLeft)
return p
}
func (p *Painter) ArrowRight(x, y, width, height int) *Painter {
p.arrow(x, y, width, height, PositionRight)
return p
}
func (p *Painter) ArrowTop(x, y, width, height int) *Painter {
p.arrow(x, y, width, height, PositionTop)
return p
}
func (p *Painter) ArrowBottom(x, y, width, height int) *Painter {
p.arrow(x, y, width, height, PositionBottom)
return p
}
func (p *Painter) Circle(radius float64, x, y int) *Painter {
p.render.Circle(radius, x+p.box.Left, y+p.box.Top)
return p
}
func (p *Painter) Stroke() *Painter {
p.render.Stroke()
return p
}
func (p *Painter) Close() *Painter {
p.render.Close()
return p
}
func (p *Painter) FillStroke() *Painter {
p.render.FillStroke()
return p
}
func (p *Painter) Fill() *Painter {
p.render.Fill()
return p
}
func (p *Painter) Width() int {
return p.box.Width()
}
func (p *Painter) Height() int {
return p.box.Height()
}
func (p *Painter) MeasureText(text string) Box {
return p.render.MeasureText(text)
}
func (p *Painter) MeasureTextMaxWidthHeight(textList []string) (int, int) {
maxWidth := 0
maxHeight := 0
for _, text := range textList {
box := p.MeasureText(text)
if maxWidth < box.Width() {
maxWidth = box.Width()
}
if maxHeight < box.Height() {
maxHeight = box.Height()
}
}
return maxWidth, maxHeight
}
func (p *Painter) LineStroke(points []Point) *Painter {
shouldMoveTo := false
for index, point := range points {
x := point.X
y := point.Y
if y == int(math.MaxInt32) {
p.Stroke()
shouldMoveTo = true
continue
}
if shouldMoveTo || index == 0 {
p.MoveTo(x, y)
shouldMoveTo = false
} else {
p.LineTo(x, y)
}
}
p.Stroke()
return p
}
func (p *Painter) SmoothLineStroke(points []Point) *Painter {
prevX := 0
prevY := 0
// TODO 如何生成平滑的折线
for index, point := range points {
x := point.X
y := point.Y
if index == 0 {
p.MoveTo(x, y)
} else {
cx := prevX + (x-prevX)/5
cy := y + (y-prevY)/2
p.QuadCurveTo(cx, cy, x, y)
}
prevX = x
prevY = y
}
p.Stroke()
return p
}
func (p *Painter) SetBackground(width, height int, color Color, inside ...bool) *Painter {
r := p.render
s := chart.Style{
FillColor: color,
}
// 背景色
p.SetDrawingStyle(s)
defer p.ResetStyle()
if len(inside) != 0 && inside[0] {
p.MoveTo(0, 0)
p.LineTo(width, 0)
p.LineTo(width, height)
p.LineTo(0, height)
p.LineTo(0, 0)
} else {
// 设置背景色不使用box因此不直接使用Painter
r.MoveTo(0, 0)
r.LineTo(width, 0)
r.LineTo(width, height)
r.LineTo(0, height)
r.LineTo(0, 0)
}
p.FillStroke()
return p
}
func (p *Painter) MarkLine(x, y, width int) *Painter {
arrowWidth := 16
arrowHeight := 10
endX := x + width
radius := 3
p.Circle(3, x+radius, y)
p.render.Fill()
p.MoveTo(x+radius*3, y)
p.LineTo(endX-arrowWidth, y)
p.Stroke()
p.ArrowRight(endX, y, arrowWidth, arrowHeight)
return p
}
func (p *Painter) Polygon(center Point, radius float64, sides int) *Painter {
points := getPolygonPoints(center, radius, sides)
for i, item := range points {
if i == 0 {
p.MoveTo(item.X, item.Y)
} else {
p.LineTo(item.X, item.Y)
}
}
p.LineTo(points[0].X, points[0].Y)
p.Stroke()
return p
}
func (p *Painter) FillArea(points []Point) *Painter {
var x, y int
for index, point := range points {
x = point.X
y = point.Y
if index == 0 {
p.MoveTo(x, y)
} else {
p.LineTo(x, y)
}
}
p.Fill()
return p
}
func (p *Painter) Text(body string, x, y int) *Painter {
p.render.Text(body, x+p.box.Left, y+p.box.Top)
return p
}
func (p *Painter) TextRotation(body string, x, y int, radians float64) {
p.render.SetTextRotation(radians)
p.render.Text(body, x+p.box.Left, y+p.box.Top)
p.render.ClearTextRotation()
}
func (p *Painter) SetTextRotation(radians float64) {
p.render.SetTextRotation(radians)
}
func (p *Painter) ClearTextRotation() {
p.render.ClearTextRotation()
}
func (p *Painter) TextFit(body string, x, y, width int, textAligns ...string) chart.Box {
style := p.style
textWarp := style.TextWrap
style.TextWrap = chart.TextWrapWord
r := p.render
lines := chart.Text.WrapFit(r, body, width, style)
p.SetTextStyle(style)
var output chart.Box
textAlign := ""
if len(textAligns) != 0 {
textAlign = textAligns[0]
}
for index, line := range lines {
if line == "" {
continue
}
x0 := x
y0 := y + output.Height()
lineBox := r.MeasureText(line)
switch textAlign {
case AlignRight:
x0 += width - lineBox.Width()
case AlignCenter:
x0 += (width - lineBox.Width()) >> 1
}
p.Text(line, x0, y0)
output.Right = chart.MaxInt(lineBox.Right, output.Right)
output.Bottom += lineBox.Height()
if index < len(lines)-1 {
output.Bottom += +style.GetTextLineSpacing()
}
}
p.style.TextWrap = textWarp
return output
}
func (p *Painter) Ticks(opt TicksOption) *Painter {
if opt.Count <= 0 || opt.Length <= 0 {
return p
}
count := opt.Count
first := opt.First
width := p.Width()
height := p.Height()
unit := 1
if opt.Unit > 1 {
unit = opt.Unit
}
var values []int
isVertical := opt.Orient == OrientVertical
if isVertical {
values = autoDivide(height, count)
} else {
values = autoDivide(width, count)
}
for index, value := range values {
if index < first {
continue
}
if (index-first)%unit != 0 {
continue
}
if isVertical {
p.LineStroke([]Point{
{
X: 0,
Y: value,
},
{
X: opt.Length,
Y: value,
},
})
} else {
p.LineStroke([]Point{
{
X: value,
Y: opt.Length,
},
{
X: value,
Y: 0,
},
})
}
}
return p
}
func (p *Painter) MultiText(opt MultiTextOption) *Painter {
if len(opt.TextList) == 0 {
return p
}
count := len(opt.TextList)
positionCenter := true
showIndex := opt.Unit / 2
if containsString([]string{
PositionLeft,
PositionTop,
}, opt.Position) {
positionCenter = false
count--
// 非居中
showIndex = 0
}
width := p.Width()
height := p.Height()
var values []int
isVertical := opt.Orient == OrientVertical
if isVertical {
values = autoDivide(height, count)
} else {
values = autoDivide(width, count)
}
isTextRotation := opt.TextRotation != 0
offset := opt.Offset
for index, text := range opt.TextList {
if index < opt.First {
continue
}
if opt.Unit != 0 && (index-opt.First)%opt.Unit != showIndex {
continue
}
if isTextRotation {
p.ClearTextRotation()
p.SetTextRotation(opt.TextRotation)
}
box := p.MeasureText(text)
start := values[index]
if positionCenter {
start = (values[index] + values[index+1]) >> 1
}
x := 0
y := 0
if isVertical {
y = start + box.Height()>>1
switch opt.Align {
case AlignRight:
x = width - box.Width()
case AlignCenter:
x = width - box.Width()>>1
default:
x = 0
}
} else {
x = start - box.Width()>>1
}
x += offset.Left
y += offset.Top
p.Text(text, x, y)
}
if isTextRotation {
p.ClearTextRotation()
}
return p
}
func (p *Painter) Grid(opt GridOption) *Painter {
width := p.Width()
height := p.Height()
drawLines := func(values []int, ignoreIndexList []int, isVertical bool) {
for index, v := range values {
if containsInt(ignoreIndexList, index) {
continue
}
x0 := 0
y0 := 0
x1 := 0
y1 := 0
if isVertical {
x0 = v
x1 = v
y1 = height
} else {
x1 = width
y0 = v
y1 = v
}
p.LineStroke([]Point{
{
X: x0,
Y: y0,
},
{
X: x1,
Y: y1,
},
})
}
}
columnCount := sumInt(opt.ColumnSpans)
if columnCount == 0 {
columnCount = opt.Column
}
if columnCount > 0 {
values := autoDivideSpans(width, columnCount, opt.ColumnSpans)
drawLines(values, opt.IgnoreColumnLines, true)
}
if opt.Row > 0 {
values := autoDivide(height, opt.Row)
drawLines(values, opt.IgnoreRowLines, false)
}
return p
}
func (p *Painter) Dots(points []Point) *Painter {
for _, item := range points {
p.Circle(2, item.X, item.Y)
}
p.FillStroke()
return p
}
func (p *Painter) Rect(box Box) *Painter {
p.MoveTo(box.Left, box.Top)
p.LineTo(box.Right, box.Top)
p.LineTo(box.Right, box.Bottom)
p.LineTo(box.Left, box.Bottom)
p.LineTo(box.Left, box.Top)
p.FillStroke()
return p
}
func (p *Painter) RoundedRect(box Box, radius int) *Painter {
r := (box.Right - box.Left) / 2
if radius > r {
radius = r
}
rx := float64(radius)
ry := float64(radius)
p.MoveTo(box.Left+radius, box.Top)
p.LineTo(box.Right-radius, box.Top)
cx := box.Right - radius
cy := box.Top + radius
// right top
p.ArcTo(cx, cy, rx, ry, -math.Pi/2, math.Pi/2)
p.LineTo(box.Right, box.Bottom-radius)
// right bottom
cx = box.Right - radius
cy = box.Bottom - radius
p.ArcTo(cx, cy, rx, ry, 0.0, math.Pi/2)
p.LineTo(box.Left+radius, box.Bottom)
// left bottom
cx = box.Left + radius
cy = box.Bottom - radius
p.ArcTo(cx, cy, rx, ry, math.Pi/2, math.Pi/2)
p.LineTo(box.Left, box.Top+radius)
// left top
cx = box.Left + radius
cy = box.Top + radius
p.ArcTo(cx, cy, rx, ry, math.Pi, math.Pi/2)
p.Close()
p.FillStroke()
p.Fill()
return p
}
func (p *Painter) LegendLineDot(box Box) *Painter {
width := box.Width()
height := box.Height()
strokeWidth := 3
dotHeight := 5
p.render.SetStrokeWidth(float64(strokeWidth))
center := (height-strokeWidth)>>1 - 1
p.MoveTo(box.Left, box.Top-center)
p.LineTo(box.Right, box.Top-center)
p.Stroke()
p.Circle(float64(dotHeight), box.Left+width>>1, box.Top-center)
p.FillStroke()
return p
}
func (p *Painter) GetRenderer() chart.Renderer {
return p.render
}

View file

@ -1,399 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 6 11\nL 7 12\" style=\"stroke-width:0;stroke:none;fill:none\"/></svg>",
},
// circle
{
fn: func(p *Painter) {
p.Circle(5, 2, 3)
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"7\" cy=\"13\" r=\"5\" style=\"stroke-width:0;stroke:none;fill:none\"/></svg>",
},
// text
{
fn: func(p *Painter) {
p.Text("hello world!", 3, 6)
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"8\" y=\"16\" style=\"stroke-width:0;stroke:none;fill:none;font-family:'Roboto Medium',sans-serif\">hello world!</text></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 6 12\nL 8 14\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
},
// set background
{
fn: func(p *Painter) {
p.SetBackground(400, 300, chart.ColorWhite)
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 0\nL 400 0\nL 400 300\nL 0 300\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 205 110\nA 100 100 90.00 0 1 105 210\nZ\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,255,1.0)\"/></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 32 47\nA 15 15 330.00 1 1 38 47\nL 35 33\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 20 33\nQ35,70 50,33\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 51 35\nL 35 40\nL 51 45\nL 46 40\nL 51 35\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 19 35\nL 35 40\nL 19 45\nL 24 40\nL 19 35\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 30 40\nL 35 24\nL 40 40\nL 35 35\nL 30 40\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 30 24\nL 35 40\nL 40 24\nL 35 30\nL 30 24\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"8\" cy=\"30\" r=\"3\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 14 30\nL 289 30\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 289 25\nL 305 30\nL 289 35\nL 294 30\nL 289 25\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 105 60\nL 148 85\nL 148 134\nL 105 160\nL 62 135\nL 62 86\nL 105 60\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:none\"/></svg>",
},
// 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 10\nL 5 110\nL 105 110\nL 5 10\" style=\"stroke-width:0;stroke:none;fill:rgba(84,112,198,1.0)\"/></svg>",
},
}
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("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 15 10\nL 25 10\nL 25 10\nA 5 5 90.00 0 1 30 15\nL 30 145\nL 30 145\nA 5 5 90.00 0 1 25 150\nL 15 150\nL 15 150\nA 5 5 90.00 0 1 10 145\nL 10 15\nL 10 15\nA 5 5 90.00 0 1 15 10\nZ\" style=\"stroke-width:1;stroke:rgba(255,255,255,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(255,255,255,1.0);fill:rgba(255,255,255,1.0)\"/></svg>", 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(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">\n<text x="0" y="20" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">Hello</text><text x="0" y="40" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">World!</text><text x="0" y="100" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">Hello World!</text></svg>`, string(buf))
}

View file

@ -1,318 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"errors"
"math"
"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)
}

File diff suppressed because one or more lines are too long

View file

@ -1,273 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"errors"
"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)
}

File diff suppressed because one or more lines are too long

155
range.go
View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -24,121 +24,70 @@ package charts
import (
"math"
"github.com/wcharczuk/go-chart/v2"
)
const defaultAxisDivideCount = 6
type axisRange struct {
p *Painter
divideCount int
min float64
max float64
size int
boundary bool
type Range struct {
TickPosition chart.TickPosition
chart.ContinuousRange
}
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
func wrapRange(r chart.Range, tickPosition chart.TickPosition) chart.Range {
xr, ok := r.(*chart.ContinuousRange)
if !ok {
return r
}
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)
// 如果是小于0int的时候向上取整了因此调整
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,
return &Range{
TickPosition: tickPosition,
ContinuousRange: *xr,
}
}
// 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
// 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))
}
for i := 0; i <= r.divideCount; i++ {
v := r.min + float64(i)*offset
value := formatter(v)
values = append(values, value)
}
return values
return v
}
func (r *axisRange) getHeight(value float64) int {
if r.max <= r.min {
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
}
v := (value - r.min) / (r.max - r.min)
return int(v * float64(r.size))
m.Min = min
}
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)
func (m *YContinuousRange) SetMax(max float64) {
// 如果已修改,则忽略
if m.Max != math.MaxFloat64 {
return
}
// 此处为计算得来的最大值放大20%
v := int(max * 1.2)
// TODO 是否要取整十整百
m.Max = float64(v)
}

View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -23,56 +23,55 @@
package charts
import (
"errors"
"sync"
"math"
"testing"
"github.com/golang/freetype/truetype"
"git.smarteching.com/zeni/go-chart/v2/roboto"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
)
var fonts = sync.Map{}
var ErrFontNotExists = errors.New("font is not exists")
var defaultFontFamily = "defaultFontFamily"
func TestRange(t *testing.T) {
assert := assert.New(t)
func init() {
name := "roboto"
_ = InstallFont(name, roboto.Roboto)
font, _ := GetFont(name)
SetDefaultFont(font)
}
// InstallFont installs the font for charts
func InstallFont(fontFamily string, data []byte) error {
font, err := truetype.Parse(data)
if err != nil {
return err
r := Range{
ContinuousRange: chart.ContinuousRange{
Min: 0,
Max: 5,
Domain: 500,
},
}
fonts.Store(fontFamily, font)
return nil
assert.Equal(100, r.Translate(1))
r.TickPosition = chart.TickPositionBetweenTicks
assert.Equal(50, r.Translate(1))
}
// GetDefaultFont get default font
func GetDefaultFont() (*truetype.Font, error) {
return GetFont(defaultFontFamily)
func TestHiddenRange(t *testing.T) {
assert := assert.New(t)
r := HiddenRange{}
assert.Equal(float64(0), r.GetDelta())
}
// SetDefaultFont set default font
func SetDefaultFont(font *truetype.Font) {
if font == nil {
return
}
fonts.Store(defaultFontFamily, font)
}
func TestYContinuousRange(t *testing.T) {
assert := assert.New(t)
r := YContinuousRange{}
r.Min = -math.MaxFloat64
r.Max = math.MaxFloat64
// 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
assert.True(r.IsZero())
r.SetMin(1.0)
assert.Equal(1.0, r.GetMin())
// 再次设置无效
r.SetMin(2.0)
assert.Equal(1.0, r.GetMin())
r.SetMax(5.0)
// *1.2
assert.Equal(6.0, r.GetMax())
// 再次设置无效
r.SetMax(10.0)
assert.Equal(6.0, r.GetMax())
}

368
series.go
View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -19,300 +19,114 @@
// 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"
"strings"
"github.com/dustin/go-humanize"
"git.smarteching.com/zeni/go-chart/v2"
"github.com/wcharczuk/go-chart/v2"
)
type SeriesData struct {
// The value of series data
Value float64
// The style of series data
Style Style
Style chart.Style
}
// NewSeriesListDataFromValues returns a series list
func NewSeriesListDataFromValues(values [][]float64, chartType ...string) SeriesList {
seriesList := make(SeriesList, len(values))
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))
for index, value := range values {
seriesList[index] = NewSeriesFromValues(value, chartType...)
}
return seriesList
}
// NewSeriesFromValues returns a series
func NewSeriesFromValues(values []float64, chartType ...string) Series {
s := Series{
Data: NewSeriesDataFromValues(values),
}
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{
dataList[index] = SeriesData{
Value: value,
}
}
return data
return dataList
}
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
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++
}
if sl[len(sl)-1].index != 0 {
return
}
for i := 0; i < len(sl); i++ {
if sl[i].Type == "" {
sl[i].Type = ChartTypeLine
}
sl[i].index = i
}
}
func (sl SeriesList) Filter(chartType string) SeriesList {
arr := make(SeriesList, 0)
for index, item := range sl {
if item.Type == chartType {
arr = append(arr, sl[index])
for index, item := range series {
style := chart.Style{
StrokeWidth: lineStrokeWidth,
StrokeColor: getSeriesColor(theme, index),
// TODO 调整为通过dot with color 生成
DotColor: getSeriesColor(theme, index),
DotWidth: dotWith,
}
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,
}
}
}
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
}
}

View file

@ -1,148 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"github.com/golang/freetype/truetype"
"git.smarteching.com/zeni/go-chart/v2"
)
type labelRenderValue struct {
Text string
Style Style
X int
Y int
// 旋转
Radians float64
}
type LabelValue struct {
Index int
Value float64
X int
Y int
// 旋转
Radians float64
// 字体颜色
FontColor Color
// 字体大小
FontSize float64
Orient string
Offset Box
}
type SeriesLabelPainter struct {
p *Painter
seriesNames []string
label *SeriesLabel
theme ColorPalette
font *truetype.Font
values []labelRenderValue
}
type SeriesLabelPainterParams struct {
P *Painter
SeriesNames []string
Label SeriesLabel
Theme ColorPalette
Font *truetype.Font
}
func NewSeriesLabelPainter(params SeriesLabelPainterParams) *SeriesLabelPainter {
return &SeriesLabelPainter{
p: params.P,
seriesNames: params.SeriesNames,
label: &params.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
}

View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -19,71 +19,107 @@
// 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 TestNewSeriesListDataFromValues(t *testing.T) {
func TestNewSeriesDataListFromFloat(t *testing.T) {
assert := assert.New(t)
assert.Equal(SeriesList{
assert.Equal([]SeriesData{
{
Type: ChartTypeBar,
Data: []SeriesData{
Value: 1,
},
{
Value: 1.0,
Value: 2,
},
},
},
}, NewSeriesListDataFromValues([][]float64{
{
1,
},
}, ChartTypeBar))
}
func TestSeriesLists(t *testing.T) {
assert := assert.New(t)
seriesList := NewSeriesListDataFromValues([][]float64{
{
}, NewSeriesDataListFromFloat([]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) {
func TestGetSeries(t *testing.T) {
assert := assert.New(t)
assert.Equal("a: 12%", NewPieLabelFormatter([]string{
"a",
"b",
}, "")(0, 10, 0.12))
xValues := []float64{
1,
2,
3,
4,
5,
}
assert.Equal("10", NewValueLabelFormatter([]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)
}

View file

@ -1,254 +0,0 @@
# go-charts
`go-charts`主要分为了下几个模块:
- `标题`:图表的标题,包括主副标题,位置为图表的顶部
- `图例`:图表的图例列表,用于标识每个图例对应的颜色与名称信息,默认为图表的顶部,可自定义位置
- `X轴`图表的x轴用于折线图、柱状图中表示每个点对应的时间位置图表的底部
- `Y轴`图表的y轴用于折线图、柱状图中最多可使用两组y轴一左一右默认位置图表的左侧
- `内容`: 图表的内容,折线图、柱状图、饼图等,在图表的中间区域
## 标题
### 常用设置
标题一般仅需要设置主副标题即可,其它的属性均会设置默认值,常用的方式是使用`TitleTextOptionFunc`设置,其中副标题为可选值,方式如下:
```go
charts.TitleTextOptionFunc("Text", "Subtext"),
```
### 个性化设置
```go
func(opt *charts.ChartOption) {
opt.Title = charts.TitleOption{
// 主标题
Text: "Text",
// 副标题
Subtext: "Subtext",
// 标题左侧位置,可设置为"center""right",数值("20")或百份比("20%")
Left: charts.PositionRight,
// 标题顶部位置,只可调为数值
Top: "20",
// 主标题文字大小
FontSize: 14,
// 副标题文字大小
SubtextFontSize: 12,
// 主标题字体颜色
FontColor: charts.Color{
R: 100,
G: 100,
B: 100,
A: 255,
},
// 副标题字体影响
SubtextFontColor: charts.Color{
R: 200,
G: 200,
B: 200,
A: 255,
},
}
},
```
### 部分属性个性化设置
```go
charts.TitleTextOptionFunc("Text", "Subtext"),
func(opt *charts.ChartOption) {
// 修改top的值
opt.Title.Top = "20"
},
```
## 图例
### 常用设置
图例组件与图表中的数据一一对应,常用仅设置其名称及左侧的值即可(可选),方式如下:
```go
charts.LegendLabelsOptionFunc([]string{
"Email",
"Union Ads",
"Video Ads",
"Direct",
"Search Engine",
}, "50"),
```
### 个性化设置
```go
func(opt *charts.ChartOption) {
opt.Legend = charts.LegendOption{
// 图例名称
Data: []string{
"Email",
"Union Ads",
"Video Ads",
"Direct",
"Search Engine",
},
// 图例左侧位置,可设置为"center""right",数值("20")或百份比("20%")
// 如果示例有多行,只影响第一行,而且对于多行的示例,设置"center", "right"无效
Left: "50",
// 图例顶部位置,只可调为数值
Top: "10",
// 图例图标的位置,默认为左侧,只允许左或右
Align: charts.AlignRight,
// 图例排列方式,默认为水平,只允许水平或垂直
Orient: charts.OrientVertical,
// 图标类型,提供"rect"与"lineDot"两种类型
Icon: charts.IconRect,
// 字体大小
FontSize: 14,
// 字体颜色
FontColor: charts.Color{
R: 150,
G: 150,
B: 150,
A: 255,
},
// 是否展示,如果不需要展示则设置
// Show: charts.FalseFlag(),
// 图例区域的padding值
Padding: charts.Box{
Top: 10,
Left: 10,
},
}
},
```
### 部分属性个性化设置
```go
charts.LegendLabelsOptionFunc([]string{
"Email",
"Union Ads",
"Video Ads",
"Direct",
"Search Engine",
}, "50"),
func(opt *charts.ChartOption) {
opt.Legend.Top = "10"
},
```
## X轴
### 常用设置
图表中X轴的展示常用的设置方式是指定数组即可
```go
charts.XAxisDataOptionFunc([]string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
}),
```
### 个性化设置
```go
func(opt *charts.ChartOption) {
opt.XAxis = charts.XAxisOption{
// X轴内容
Data: []string{
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
},
// 如果数据点不居中则设置为false
BoundaryGap: charts.FalseFlag(),
// 字体大小
FontSize: 14,
// 是否展示,如果不需要展示则设置
// Show: charts.FalseFlag(),
// 会根据文本内容以及此值选择适合的分块大小,一般不需要设置
// SplitNumber: 3,
// 线条颜色
StrokeColor: charts.Color{
R: 200,
G: 200,
B: 200,
A: 255,
},
// 文字颜色
FontColor: charts.Color{
R: 100,
G: 100,
B: 100,
A: 255,
},
}
},
```
### 部分属性个性化设置
```go
charts.XAxisDataOptionFunc([]string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
}),
func(opt *charts.ChartOption) {
opt.XAxis.FontColor = charts.Color{
R: 100,
G: 100,
B: 100,
A: 255,
},
},
```
## Y轴
图表中的y轴展示的相关数据会根据图表中的数据自动生成适合的值如果需要自定义则可自定义以下部分数据
```go
func(opt *charts.ChartOption) {
opt.YAxisOptions = []charts.YAxisOption{
{
// 字体大小
FontSize: 16,
// 字体颜色
FontColor: charts.Color{
R: 100,
G: 100,
B: 100,
A: 255,
},
// 内容,{value}会替换为对应的值
Formatter: "{value} ml",
// Y轴颜色如果设置此值会覆盖font color
Color: charts.Color{
R: 255,
G: 0,
B: 0,
A: 255,
},
},
}
},
```

438
table.go
View file

@ -1,438 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"errors"
"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 = &currentStyle
}
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)
}

View file

@ -1,140 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTableChart(t *testing.T) {
assert := assert.New(t)
tests := []struct {
render func(*Painter) ([]byte, error)
result string
}{
{
render: func(p *Painter) ([]byte, error) {
_, err := NewTableChart(p, TableChartOption{
Header: []string{
"Name",
"Age",
"Address",
"Tag",
"Action",
},
Spans: []int{
1,
1,
2,
1,
// span和header不匹配最后自动设置为1
// 1,
},
Data: [][]string{
{
"John Brown",
"32",
"New York No. 1 Lake Park",
"nice, developer",
"Send Mail",
},
{
"Jim Green ",
"42",
"London No. 1 Lake Park",
"wow",
"Send Mail",
},
{
"Joe Black ",
"32",
"Sidney No. 1 Lake Park",
"cool, teacher",
"Send Mail",
},
},
}).Render()
if err != nil {
return nil, err
}
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 0\nL 600 0\nL 600 35\nL 0 35\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(240,240,240,1.0)\"/><path d=\"M 0 35\nL 600 35\nL 600 90\nL 0 90\nL 0 35\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><path d=\"M 0 90\nL 600 90\nL 600 125\nL 0 125\nL 0 90\" style=\"stroke-width:0;stroke:none;fill:rgba(247,247,247,1.0)\"/><path d=\"M 0 125\nL 600 125\nL 600 180\nL 0 180\nL 0 125\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><text x=\"10\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Name</text><text x=\"110\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Age</text><text x=\"210\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Address</text><text x=\"410\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tag</text><text x=\"510\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Action</text><text x=\"10\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">John</text><text x=\"10\" y=\"77\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Brown</text><text x=\"110\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">32</text><text x=\"210\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">New York No. 1 Lake Park</text><text x=\"410\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">nice,</text><text x=\"410\" y=\"77\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">developer</text><text x=\"510\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Send Mail</text><text x=\"10\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Jim Green</text><text x=\"110\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">42</text><text x=\"210\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">London No. 1 Lake Park</text><text x=\"410\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">wow</text><text x=\"510\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Send Mail</text><text x=\"10\" y=\"147\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Joe Black</text><text x=\"110\" y=\"147\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">32</text><text x=\"210\" y=\"147\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sidney No. 1 Lake Park</text><text x=\"410\" y=\"147\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">cool,</text><text x=\"410\" y=\"167\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">teacher</text><text x=\"510\" y=\"147\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Send Mail</text></svg>",
},
{
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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 0\nL 600 0\nL 600 35\nL 0 35\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(240,240,240,1.0)\"/><path d=\"M 0 35\nL 600 35\nL 600 90\nL 0 90\nL 0 35\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><path d=\"M 0 90\nL 600 90\nL 600 145\nL 0 145\nL 0 90\" style=\"stroke-width:0;stroke:none;fill:rgba(247,247,247,1.0)\"/><path d=\"M 0 145\nL 600 145\nL 600 200\nL 0 200\nL 0 145\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><text x=\"10\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Name</text><text x=\"130\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Age</text><text x=\"250\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Address</text><text x=\"370\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tag</text><text x=\"490\" y=\"22\" style=\"stroke-width:0;stroke:none;fill:rgba(98,105,118,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Action</text><text x=\"10\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">John Brown</text><text x=\"130\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">32</text><text x=\"250\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">New York No.</text><text x=\"250\" y=\"77\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">1 Lake Park</text><text x=\"370\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">nice,</text><text x=\"370\" y=\"77\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">developer</text><text x=\"490\" y=\"57\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Send Mail</text><text x=\"10\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Jim Green</text><text x=\"130\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">42</text><text x=\"250\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">London No. 1</text><text x=\"250\" y=\"132\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Lake Park</text><text x=\"370\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">wow</text><text x=\"490\" y=\"112\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Send Mail</text><text x=\"10\" y=\"167\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Joe Black</text><text x=\"130\" y=\"167\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">32</text><text x=\"250\" y=\"167\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sidney No. 1</text><text x=\"250\" y=\"187\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Lake Park</text><text x=\"370\" y=\"167\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">cool, teacher</text><text x=\"490\" y=\"167\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Send Mail</text></svg>",
},
}
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))
}
}

406
theme.go
View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -23,310 +23,200 @@
package charts
import (
"github.com/golang/freetype/truetype"
"git.smarteching.com/zeni/go-chart/v2/drawing"
"regexp"
"strconv"
"strings"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
const ThemeDark = "dark"
const ThemeLight = "light"
const ThemeGrafana = "grafana"
const ThemeAnt = "ant"
var hiddenColor = drawing.Color{R: 255, G: 255, B: 255, A: 0}
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,
var AxisColorLight = drawing.Color{
R: 110,
G: 112,
B: 121,
A: 255,
}
var defaultDarkFontColor = drawing.Color{
R: 238,
G: 238,
B: 238,
A: 255,
}
func init() {
echartSeriesColors := []Color{
parseColor("#5470c6"),
parseColor("#91cc75"),
parseColor("#fac858"),
parseColor("#ee6666"),
parseColor("#73c0de"),
parseColor("#3ba272"),
parseColor("#fc8452"),
parseColor("#9a60b4"),
parseColor("#ea7ccc"),
}
grafanaSeriesColors := []Color{
parseColor("#7EB26D"),
parseColor("#EAB839"),
parseColor("#6ED0E0"),
parseColor("#EF843C"),
parseColor("#E24D42"),
parseColor("#1F78C1"),
parseColor("#705DA0"),
parseColor("#508642"),
}
antSeriesColors := []Color{
parseColor("#5b8ff9"),
parseColor("#5ad8a6"),
parseColor("#5d7092"),
parseColor("#f6bd16"),
parseColor("#6f5ef9"),
parseColor("#6dc8ec"),
parseColor("#945fb9"),
parseColor("#ff9845"),
}
AddTheme(
ThemeDark,
ThemeOption{
IsDarkMode: true,
AxisStrokeColor: Color{
var AxisColorDark = drawing.Color{
R: 185,
G: 184,
B: 206,
A: 255,
},
AxisSplitLineColor: Color{
}
var GridColorDark = drawing.Color{
R: 72,
G: 71,
B: 83,
A: 255,
},
BackgroundColor: Color{
}
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
}
return AxisColorLight
}
func getGridColor(theme string) drawing.Color {
if theme == ThemeDark {
return GridColorDark
}
return GridColorLight
}
var SeriesColorsLight = []drawing.Color{
{
R: 84,
G: 112,
B: 198,
A: 255,
},
TextColor: Color{
{
R: 145,
G: 204,
B: 117,
A: 255,
},
{
R: 250,
G: 200,
B: 88,
A: 255,
},
{
R: 238,
G: 238,
B: 238,
G: 102,
B: 102,
A: 255,
},
SeriesColors: echartSeriesColors,
},
)
AddTheme(
ThemeLight,
ThemeOption{
IsDarkMode: false,
AxisStrokeColor: Color{
R: 110,
G: 112,
B: 121,
{
R: 115,
G: 192,
B: 222,
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)
}
// 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,
func getBackgroundColor(theme string) drawing.Color {
if theme == ThemeDark {
return BackgroundColorDark
}
return chart.DefaultBackgroundColor
}
func NewTheme(name string) ColorPalette {
p, ok := palettes[name]
if !ok {
p = palettes[ThemeLight]
func getTextColor(theme string) drawing.Color {
if theme == ThemeDark {
return TextColorDark
}
clone := *p
return &clone
return chart.DefaultTextColor
}
func (t *themeColorPalette) IsDark() bool {
return t.isDarkMode
type ThemeColorPalette struct {
Theme string
}
func (t *themeColorPalette) GetAxisStrokeColor() Color {
return t.axisStrokeColor
type PieThemeColorPalette struct {
ThemeColorPalette
}
func (t *themeColorPalette) SetAxisStrokeColor(c Color) {
t.axisStrokeColor = c
func (tp PieThemeColorPalette) TextColor() drawing.Color {
return getTextColor("")
}
func (t *themeColorPalette) GetAxisSplitLineColor() Color {
return t.axisSplitLineColor
func (tp ThemeColorPalette) BackgroundColor() drawing.Color {
return getBackgroundColor(tp.Theme)
}
func (t *themeColorPalette) SetAxisSplitLineColor(c Color) {
t.axisSplitLineColor = c
func (tp ThemeColorPalette) BackgroundStrokeColor() drawing.Color {
return chart.DefaultBackgroundStrokeColor
}
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
func (tp ThemeColorPalette) CanvasColor() drawing.Color {
if tp.Theme == ThemeDark {
return BackgroundColorDark
}
return defaultFontSize
return chart.DefaultCanvasColor
}
func (t *themeColorPalette) SetFontSize(fontSize float64) {
t.fontSize = fontSize
func (tp ThemeColorPalette) CanvasStrokeColor() drawing.Color {
return chart.DefaultCanvasStrokeColor
}
func (t *themeColorPalette) GetFont() *truetype.Font {
if t.font != nil {
return t.font
func (tp ThemeColorPalette) AxisStrokeColor() drawing.Color {
if tp.Theme == ThemeDark {
return BackgroundColorDark
}
f, _ := GetDefaultFont()
return f
return chart.DefaultAxisColor
}
func (t *themeColorPalette) SetFont(f *truetype.Font) {
t.font = f
func (tp ThemeColorPalette) TextColor() drawing.Color {
return getTextColor(tp.Theme)
}
func (tp ThemeColorPalette) GetSeriesColor(index int) drawing.Color {
return getSeriesColor(tp.Theme, index)
}
func getSeriesColor(theme string, index int) drawing.Color {
return SeriesColorsLight[index%len(SeriesColorsLight)]
}
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
}

122
theme_test.go Normal file
View file

@ -0,0 +1,122 @@
// 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)
}

196
title.go
View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -26,172 +26,78 @@ import (
"strconv"
"strings"
"github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
)
type TitleOption struct {
// The theme of chart
Theme ColorPalette
// Title text, support \n for new line
Text string
// Subtitle text, support \n for new line
Subtext string
// 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
func NewTitleCustomize(title Title) chart.Renderable {
return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) {
if len(title.Text) == 0 || title.Style.Hidden {
return
}
result = append(result, v)
font := title.Font
if font == nil {
font, _ = chart.GetDefaultFont()
}
return result
}
r.SetFont(font)
r.SetFontColor(title.Style.FontColor)
titleFontSize := title.Style.GetFontSize(chart.DefaultTitleFontSize)
r.SetFontSize(titleFontSize)
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)
arr := strings.Split(title.Text, "\n")
textWidth := 0
textHeight := 0
measureOptions := make([]titleMeasureOption, len(arr))
for index, str := range arr {
textBox := r.MeasureText(str)
w := textBox.Width()
h := textBox.Height()
if w > textMaxWidth {
textMaxWidth = w
if w > textWidth {
textWidth = w
}
if h > textMaxHeight {
textMaxHeight = h
if h > textHeight {
textHeight = h
}
measureOptions[index] = titleMeasureOption{
text: str,
width: w,
height: 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)
switch title.Left {
case "right":
titleX = cb.Left + cb.Width() - textWidth
case "center":
titleX = cb.Left + cb.Width()>>1 - (textWidth >> 1)
default:
if strings.HasSuffix(opt.Left, "%") {
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
titleX = p.Width() * value / 100
if strings.HasSuffix(title.Left, "%") {
value, _ := strconv.Atoi(strings.ReplaceAll(title.Left, "%", ""))
titleX = cb.Left + cb.Width()*value/100
} else {
value, _ := strconv.Atoi(opt.Left)
titleX = value
value, _ := strconv.Atoi(title.Left)
titleX = cb.Left + 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
titleY := cb.Top + title.Style.Padding.GetTop(chart.DefaultTitleTop) + (textHeight >> 1)
// TOP 暂只支持数值
if title.Top != "" {
value, _ := strconv.Atoi(title.Top)
titleY += value
}
for _, item := range measureOptions {
x := titleX + (textWidth-item.width)>>1
r.Text(item.text, x, titleY)
titleY += textHeight
}
}
}

View file

@ -1,6 +1,6 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// 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
@ -23,71 +23,63 @@
package charts
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestTitleRenderer(t *testing.T) {
func TestTitleCustomize(t *testing.T) {
assert := assert.New(t)
tests := []struct {
render func(*Painter) ([]byte, error)
result string
title Title
svg 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()
title: Title{
Text: "Hello World!",
Style: chart.Style{
FontColor: drawing.ColorBlack,
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<text x=\"34\" y=\"35\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"20\" y=\"50\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">subTitle</text></svg>",
},
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<text x=\"50\" y=\"71\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:23.0px;font-family:'Roboto Medium',sans-serif\">Hello World!</text></svg>",
},
// 多行标题,靠右
{
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()
title: Title{
Text: "Hello World!\nHello World",
Style: chart.Style{
FontColor: drawing.ColorBlack,
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<text x=\"134\" y=\"35\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"120\" y=\"50\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">subTitle</text></svg>",
Left: "right",
},
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<text x=\"474\" y=\"71\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:23.0px;font-family:'Roboto Medium',sans-serif\">Hello World!</text><text x=\"477\" y=\"94\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:23.0px;font-family:'Roboto Medium',sans-serif\">Hello World</text></svg>",
},
// 标题居中
{
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()
title: Title{
Text: "Hello World!",
Style: chart.Style{
FontColor: drawing.ColorBlack,
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<text x=\"558\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"544\" y=\"30\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">subTitle</text></svg>",
Left: "center",
},
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<text x=\"262\" y=\"71\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:23.0px;font-family:'Roboto Medium',sans-serif\">Hello World!</text></svg>",
},
}
for _, tt := range tests {
p, err := NewPainter(PainterOptions{
Type: ChartOutputSVG,
Width: 600,
Height: 400,
}, PainterThemeOption(defaultTheme))
r, err := chart.SVG(800, 600)
assert.Nil(err)
data, err := tt.render(p)
fn := NewTitleCustomize(tt.title)
fn(r, chart.NewBox(50, 50, 600, 400), chart.Style{
Font: chart.StyleTextDefaults().Font,
})
buf := bytes.Buffer{}
err = r.Save(&buf)
assert.Nil(err)
assert.Equal(tt.result, string(data))
assert.Equal(tt.svg, buf.String())
}
}

271
util.go
View file

@ -1,271 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"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
}

View file

@ -1,223 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"git.smarteching.com/zeni/go-chart/v2"
"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,
}))
}

105
xaxis.go
View file

@ -1,105 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"github.com/golang/freetype/truetype"
)
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())
}

128
yaxis.go
View file

@ -1,128 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import "github.com/golang/freetype/truetype"
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)
}

View file

@ -1,70 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
)
func 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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<text x=\"581\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">a</text><text x=\"581\" y=\"133\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">b</text><text x=\"581\" y=\"250\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">c</text><text x=\"581\" y=\"367\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">d</text></svg>",
},
}
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))
}
}