Compare commits
200 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 958172a1f1 | |||
| 0eacc8e394 | |||
|
|
d25a827706 | ||
|
|
5842c71b1d | ||
|
|
e7dc4189d5 | ||
|
|
32e6dd52d0 | ||
|
|
9614835723 | ||
|
|
9b7634c2c2 | ||
|
|
5a69c3e5a3 | ||
|
|
8c6c4e007c | ||
|
|
765febd03a | ||
|
|
19a4d783fd | ||
|
|
06fe1006d5 | ||
|
|
f1a231ff4b | ||
|
|
c7c0655113 | ||
|
|
310800a5f0 | ||
|
|
e09ab2c3c7 | ||
|
|
c2f709a742 | ||
|
|
98af9866a4 | ||
|
|
c302d0ffa4 | ||
|
|
8bcb584aba | ||
|
|
0ddb9e4ef1 | ||
|
|
18d8ee51fb | ||
|
|
687baad0af | ||
|
|
a158191faf | ||
|
|
c810369730 | ||
|
|
19173dfd37 | ||
|
|
e7a49c2c21 | ||
|
|
20e8d4a078 | ||
|
|
29a5ece545 | ||
|
|
d3f7a773af | ||
|
|
8ba9e2e1b2 | ||
|
|
e10175594b | ||
|
|
b3cb5a75cb | ||
|
|
a767b3e1af | ||
|
|
830d4bdd21 | ||
|
|
d5533447f5 | ||
|
|
ef04ac14ab | ||
|
|
f9a534ea02 | ||
|
|
df6180e59a | ||
|
|
5f0aec60d3 | ||
|
|
6db8e2c8dc | ||
|
|
4fc250aefc | ||
|
|
55eca7b0b9 | ||
|
|
a42d0727df | ||
|
|
7e1f003be8 | ||
|
|
de4250f60b | ||
|
|
2ed86a81d0 | ||
|
|
6f6d6c3447 | ||
|
|
bdcc871ab1 | ||
|
|
a88e607bfc | ||
|
|
74a47a9858 | ||
|
|
0a1061a8db | ||
|
|
6652ece0fe | ||
|
|
0a80e7056f | ||
|
|
1f5b9d513e | ||
|
|
de49ef8c68 | ||
|
|
825e65d930 | ||
|
|
50605907c7 | ||
|
|
bb9af986be | ||
|
|
4a1ff80556 | ||
|
|
128d5b2774 | ||
|
|
dc1a89d3ff | ||
|
|
93e03856ca | ||
|
|
550b9874d2 | ||
|
|
e530adccb6 | ||
|
|
817fceff73 | ||
|
|
e095223705 | ||
|
|
1713bc283f | ||
|
|
cac6fd03d3 | ||
|
|
3d20bea846 | ||
|
|
8740c55a1a | ||
|
|
3af0d4d445 | ||
|
|
b5b2d37e87 | ||
|
|
805f4381a3 | ||
|
|
959377542e | ||
|
|
c220b10ae6 | ||
|
|
0a3ac7096a | ||
|
|
eef3a2f97b | ||
|
|
b56d0c5460 | ||
|
|
c862467a5b | ||
|
|
f483e2a850 | ||
|
|
d53fa1a329 | ||
|
|
0eecb6c5b7 | ||
|
|
aed2250cb8 | ||
|
|
93eec00bbe | ||
|
|
f1276067d7 | ||
|
|
da3ad16c23 | ||
|
|
b3a3018ea2 | ||
|
|
2fb0ebcbf7 | ||
|
|
8c5647f65f | ||
|
|
706896737b | ||
|
|
92458aece2 | ||
|
|
4121829e6e | ||
|
|
6695a3a062 | ||
|
|
212a51083f | ||
|
|
a6b92f1d47 | ||
|
|
368add795f | ||
|
|
ad70a48944 | ||
|
|
29c9281d7c | ||
|
|
d3c6649cd9 | ||
|
|
635e440e85 | ||
|
|
6568f1d046 | ||
|
|
2067bc0062 | ||
|
|
5db24de7ed | ||
|
|
38c4978e44 | ||
|
|
65a1cb11ad | ||
|
|
3f24521593 | ||
|
|
b69728dd12 | ||
|
|
8a5990fe8f | ||
|
|
72e11e49b1 | ||
|
|
c4045cfbbe | ||
|
|
b394e1b49f | ||
|
|
4cf494088e | ||
|
|
7ee13fe914 | ||
|
|
4bec97baa5 | ||
|
|
622bd8491b | ||
|
|
6041098d33 | ||
|
|
e090622326 | ||
|
|
8314a2cb37 | ||
|
|
7e4de64a0d | ||
|
|
1dcd50ba9a | ||
|
|
4201c7d439 | ||
|
|
ddd5cf6d43 | ||
|
|
bf5bd32ed5 | ||
|
|
e82fe34a2b | ||
|
|
c363d1d5e3 | ||
|
|
7e80e9a848 | ||
|
|
5068828ca7 | ||
|
|
7e2f112eea | ||
|
|
e64498a061 | ||
|
|
cf2eb91690 | ||
|
|
cad8296e28 | ||
|
|
a713c3023e | ||
|
|
a5754bb1b3 | ||
|
|
054839b0b7 | ||
|
|
1258262f2c | ||
|
|
2316689ce5 | ||
|
|
1be8d43405 | ||
|
|
981e5a0d27 | ||
|
|
82e05eec64 | ||
|
|
58aa096ae1 | ||
|
|
1894670c2a | ||
|
|
5519d2eca6 | ||
|
|
b93d096633 | ||
|
|
570828d35f | ||
|
|
6209a9ce63 | ||
|
|
28bb9c57bc | ||
|
|
bbbdbe7c5e | ||
|
|
513c93e209 | ||
|
|
edee23a6dd | ||
|
|
78ba3017ae | ||
|
|
edc01d3b37 | ||
|
|
7f91f2d5ef | ||
|
|
ae02450bb4 | ||
|
|
c39306034c | ||
|
|
7ea306b7f4 | ||
|
|
9763d48eef | ||
|
|
9cc2b9fadd | ||
|
|
c15fec21ad | ||
|
|
519c8a492e | ||
|
|
b934b853a9 | ||
|
|
11fdd9121a | ||
|
|
56709e22b7 | ||
|
|
cae55c3163 | ||
|
|
da5e950565 | ||
|
|
bdf7bff313 | ||
|
|
63d4b0e229 | ||
|
|
51682069d7 | ||
|
|
1c89ed29be | ||
|
|
bff06b2aa5 | ||
|
|
4262b148ca | ||
|
|
e558634dda | ||
|
|
fd05250305 | ||
|
|
c0bb1654c2 | ||
|
|
524eb79a8e | ||
|
|
e07cb90607 | ||
|
|
cc6a1832fe | ||
|
|
d080d568cd | ||
|
|
f053b49440 | ||
|
|
54f0195c53 | ||
|
|
c01f4001f1 | ||
|
|
3219ce521b | ||
|
|
c5e2ae67cb | ||
|
|
126244ba52 | ||
|
|
dfba1ceafc | ||
|
|
eb45c6479e | ||
|
|
445a781b04 | ||
|
|
5ccc497ad3 | ||
|
|
6ae7e1d1b3 | ||
|
|
3a9897f9ad | ||
|
|
910e2dc422 | ||
|
|
9dbea37f55 | ||
|
|
29a1bdc1fb | ||
|
|
c4b5ac3f42 | ||
|
|
ffbda8f214 | ||
|
|
ccdaf70dcb | ||
|
|
4ac419fce9 | ||
|
|
c5d95eae0a | ||
|
|
eb421892fe |
75 changed files with 15000 additions and 3534 deletions
9
.github/workflows/test.yml
vendored
9
.github/workflows/test.yml
vendored
|
|
@ -14,11 +14,12 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go:
|
go:
|
||||||
|
- '1.22'
|
||||||
|
- '1.21'
|
||||||
|
- '1.20'
|
||||||
|
- '1.19'
|
||||||
|
- '1.18'
|
||||||
- '1.17'
|
- '1.17'
|
||||||
- '1.16'
|
|
||||||
- '1.15'
|
|
||||||
- '1.14'
|
|
||||||
- '1.13'
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Go ${{ matrix.go }} test
|
- name: Go ${{ matrix.go }} test
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -14,4 +14,7 @@
|
||||||
# Dependency directories (remove the comment below to include it)
|
# Dependency directories (remove the comment below to include it)
|
||||||
# vendor/
|
# vendor/
|
||||||
*.png
|
*.png
|
||||||
*.svg
|
*.svg
|
||||||
|
tmp
|
||||||
|
NotoSansSC.ttf
|
||||||
|
.vscode
|
||||||
560
README.md
560
README.md
|
|
@ -1,28 +1,457 @@
|
||||||
# go-charts
|
# go-charts
|
||||||
|
|
||||||
|
Clone from https://github.com/vicanso/go-charts
|
||||||
|
|
||||||
[](https://github.com/vicanso/go-charts/blob/master/LICENSE)
|
[](https://github.com/vicanso/go-charts/blob/master/LICENSE)
|
||||||
[](https://github.com/vicanso/go-charts/actions)
|
[](https://github.com/vicanso/go-charts/actions)
|
||||||
|
|
||||||
`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart)生成数据图表,无其它模块的依赖纯golang的实现,支持`svg`与`png`的输出,`Apache ECharts`在前端开发中得到众多开发者的认可,`go-charts`兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的几种图表截图(两种模式):
|
[中文](./README_zh.md)
|
||||||
|
|
||||||

|
`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. The default format is `png` and the default theme is `light`.
|
||||||
|
|
||||||
## 支持图表类型
|
`Apache ECharts` is popular among Front-end developers, so `go-charts` supports the option of `Apache ECharts`. Developers can generate charts almost the same as `Apache ECharts`.
|
||||||
|
|
||||||
暂仅支持三种的图表类型:`line`, `bar` 以及 `pie`
|
Screenshot of common charts, the left part is light theme, the right part is grafana theme.
|
||||||
|
|
||||||
|
<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 Chart
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
`go-charts`兼容了`echarts`的参数配置,可简单的使用json形式的配置字符串则可快速生成图表。
|
import (
|
||||||
|
charts "git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
120,
|
||||||
|
132,
|
||||||
|
101,
|
||||||
|
134,
|
||||||
|
90,
|
||||||
|
230,
|
||||||
|
210,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// snip...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// snip...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// snip...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// snip...
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.LineRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("Line"),
|
||||||
|
charts.XAxisDataOptionFunc([]string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
}),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
"Direct",
|
||||||
|
"Search Engine",
|
||||||
|
}, charts.PositionCenter),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// snip...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bar Chart
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
charts "github.com/vicanso/go-charts"
|
func main() {
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
2.0,
|
||||||
|
4.9,
|
||||||
|
7.0,
|
||||||
|
23.2,
|
||||||
|
25.6,
|
||||||
|
76.7,
|
||||||
|
135.6,
|
||||||
|
162.2,
|
||||||
|
32.6,
|
||||||
|
20.0,
|
||||||
|
6.4,
|
||||||
|
3.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// snip...
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.BarRender(
|
||||||
|
values,
|
||||||
|
charts.XAxisDataOptionFunc([]string{
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
}),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Rainfall",
|
||||||
|
"Evaporation",
|
||||||
|
}, charts.PositionRight),
|
||||||
|
charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage),
|
||||||
|
charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax,
|
||||||
|
charts.SeriesMarkDataTypeMin),
|
||||||
|
// custom option func
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.SeriesList[1].MarkPoint = charts.NewMarkPoint(
|
||||||
|
charts.SeriesMarkDataTypeMax,
|
||||||
|
charts.SeriesMarkDataTypeMin,
|
||||||
|
)
|
||||||
|
opt.SeriesList[1].MarkLine = charts.NewMarkLine(
|
||||||
|
charts.SeriesMarkDataTypeAverage,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// snip...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Horizontal Bar Chart
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
18203,
|
||||||
|
23489,
|
||||||
|
29034,
|
||||||
|
104970,
|
||||||
|
131744,
|
||||||
|
630230,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// snip...
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.HorizontalBarRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("World Population"),
|
||||||
|
charts.PaddingOptionFunc(charts.Box{
|
||||||
|
Top: 20,
|
||||||
|
Right: 40,
|
||||||
|
Bottom: 20,
|
||||||
|
Left: 20,
|
||||||
|
}),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"2011",
|
||||||
|
"2012",
|
||||||
|
}),
|
||||||
|
charts.YAxisDataOptionFunc([]string{
|
||||||
|
"Brazil",
|
||||||
|
"Indonesia",
|
||||||
|
"USA",
|
||||||
|
"India",
|
||||||
|
"China",
|
||||||
|
"World",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// snip...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pie Chart
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := []float64{
|
||||||
|
1048,
|
||||||
|
735,
|
||||||
|
580,
|
||||||
|
484,
|
||||||
|
300,
|
||||||
|
}
|
||||||
|
p, err := charts.PieRender(
|
||||||
|
values,
|
||||||
|
charts.TitleOptionFunc(charts.TitleOption{
|
||||||
|
Text: "Rainfall vs Evaporation",
|
||||||
|
Subtext: "Fake Data",
|
||||||
|
Left: charts.PositionCenter,
|
||||||
|
}),
|
||||||
|
charts.PaddingOptionFunc(charts.Box{
|
||||||
|
Top: 20,
|
||||||
|
Right: 20,
|
||||||
|
Bottom: 20,
|
||||||
|
Left: 20,
|
||||||
|
}),
|
||||||
|
charts.LegendOptionFunc(charts.LegendOption{
|
||||||
|
Orient: charts.OrientVertical,
|
||||||
|
Data: []string{
|
||||||
|
"Search Engine",
|
||||||
|
"Direct",
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
},
|
||||||
|
Left: charts.PositionLeft,
|
||||||
|
}),
|
||||||
|
charts.PieSeriesShowLabel(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// snip...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Radar Chart
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
4200,
|
||||||
|
3000,
|
||||||
|
20000,
|
||||||
|
35000,
|
||||||
|
50000,
|
||||||
|
18000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// snip...
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.RadarRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("Basic Radar Chart"),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Allocated Budget",
|
||||||
|
"Actual Spending",
|
||||||
|
}),
|
||||||
|
charts.RadarIndicatorOptionFunc([]string{
|
||||||
|
"Sales",
|
||||||
|
"Administration",
|
||||||
|
"Information Technology",
|
||||||
|
"Customer Support",
|
||||||
|
"Development",
|
||||||
|
"Marketing",
|
||||||
|
}, []float64{
|
||||||
|
6500,
|
||||||
|
16000,
|
||||||
|
30000,
|
||||||
|
38000,
|
||||||
|
52000,
|
||||||
|
25000,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// snip...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Funnel Chart
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := []float64{
|
||||||
|
100,
|
||||||
|
80,
|
||||||
|
60,
|
||||||
|
40,
|
||||||
|
20,
|
||||||
|
}
|
||||||
|
p, err := charts.FunnelRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("Funnel"),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Show",
|
||||||
|
"Click",
|
||||||
|
"Visit",
|
||||||
|
"Inquiry",
|
||||||
|
"Order",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// snip...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
header := []string{
|
||||||
|
"Name",
|
||||||
|
"Age",
|
||||||
|
"Address",
|
||||||
|
"Tag",
|
||||||
|
"Action",
|
||||||
|
}
|
||||||
|
data := [][]string{
|
||||||
|
{
|
||||||
|
"John Brown",
|
||||||
|
"32",
|
||||||
|
"New York No. 1 Lake Park",
|
||||||
|
"nice, developer",
|
||||||
|
"Send Mail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Jim Green ",
|
||||||
|
"42",
|
||||||
|
"London No. 1 Lake Park",
|
||||||
|
"wow",
|
||||||
|
"Send Mail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Joe Black ",
|
||||||
|
"32",
|
||||||
|
"Sidney No. 1 Lake Park",
|
||||||
|
"cool, teacher",
|
||||||
|
"Send Mail",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
spans := map[int]int{
|
||||||
|
0: 2,
|
||||||
|
1: 1,
|
||||||
|
// 设置第三列的span
|
||||||
|
2: 3,
|
||||||
|
3: 2,
|
||||||
|
4: 2,
|
||||||
|
}
|
||||||
|
p, err := charts.TableRender(
|
||||||
|
header,
|
||||||
|
data,
|
||||||
|
spans,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// snip...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ECharts Render
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -31,7 +460,6 @@ func main() {
|
||||||
"text": "Line"
|
"text": "Line"
|
||||||
},
|
},
|
||||||
"xAxis": {
|
"xAxis": {
|
||||||
"type": "category",
|
|
||||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
},
|
},
|
||||||
"series": [
|
"series": [
|
||||||
|
|
@ -40,65 +468,75 @@ func main() {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`)
|
}`)
|
||||||
if err != nil {
|
// snip...
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
os.WriteFile("output.png", buf, 0600)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 参数说明
|
## ECharts Option
|
||||||
|
|
||||||
- `theme` 颜色主题,支持`dark`与`light`模式,默认为`light`
|
The name with `[]` is new parameter, others are the same as `echarts`.
|
||||||
- `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
|
||||||
|
|
||||||
|
|
||||||
简单的图表生成PNG在20ms左右,而SVG的性能则更快,性能上比起使用`chrome headless`加载`echarts`图表展示页面再截图生成的方式大幅度提升,满足简单的图表生成需求。
|
## Performance
|
||||||
|
|
||||||
|
Generate a png chart will be less than 20ms. It's better than using `chrome headless` with `echarts`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
goos: darwin
|
BenchmarkMultiChartPNGRender-8 78 15216336 ns/op 2298308 B/op 1148 allocs/op
|
||||||
goarch: amd64
|
BenchmarkMultiChartSVGRender-8 367 3356325 ns/op 20597282 B/op 3088 allocs/op
|
||||||
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`不支持中文字符,展示的中文并不会乱码,但是会导致在计算字符宽度等错误。
|
|
||||||
|
|
|
||||||
576
README_zh.md
Normal file
576
README_zh.md
Normal file
|
|
@ -0,0 +1,576 @@
|
||||||
|
# go-charts
|
||||||
|
|
||||||
|
[](https://github.com/vicanso/go-charts/blob/master/LICENSE)
|
||||||
|
[](https://github.com/vicanso/go-charts/actions)
|
||||||
|
|
||||||
|
`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持主题`light`, `dark`, `grafana`以及`ant`。默认的输入格式为`png`,默认主题为`light`。
|
||||||
|
|
||||||
|
`Apache ECharts`在前端开发中得到众多开发者的认可,因此`go-charts`提供了兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的图表截图(主题为light与grafana):
|
||||||
|
|
||||||
|
|
||||||
|
<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)
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,23 +23,51 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LineSeries struct {
|
type Box = chart.Box
|
||||||
BaseSeries
|
type Style = chart.Style
|
||||||
|
type Color = drawing.Color
|
||||||
|
|
||||||
|
var BoxZero = chart.BoxZero
|
||||||
|
|
||||||
|
type Point struct {
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ls LineSeries) getXRange(xrange chart.Range) chart.Range {
|
const (
|
||||||
if ls.TickPosition != chart.TickPositionBetweenTicks {
|
ChartTypeLine = "line"
|
||||||
return xrange
|
ChartTypeBar = "bar"
|
||||||
}
|
ChartTypePie = "pie"
|
||||||
// 如果是居中,画线时重新调整
|
ChartTypeRadar = "radar"
|
||||||
return wrapRange(xrange, ls.TickPosition)
|
ChartTypeFunnel = "funnel"
|
||||||
}
|
// horizontal bar
|
||||||
|
ChartTypeHorizontalBar = "horizontalBar"
|
||||||
|
)
|
||||||
|
|
||||||
func (ls LineSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) {
|
const (
|
||||||
style := ls.Style.InheritFrom(defaults)
|
ChartOutputSVG = "svg"
|
||||||
xrange = ls.getXRange(xrange)
|
ChartOutputPNG = "png"
|
||||||
chart.Draw.LineSeries(r, canvasBox, xrange, yrange, style, ls)
|
)
|
||||||
}
|
|
||||||
|
const (
|
||||||
|
PositionLeft = "left"
|
||||||
|
PositionRight = "right"
|
||||||
|
PositionCenter = "center"
|
||||||
|
PositionTop = "top"
|
||||||
|
PositionBottom = "bottom"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlignLeft = "left"
|
||||||
|
AlignRight = "right"
|
||||||
|
AlignCenter = "center"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
OrientHorizontal = "horizontal"
|
||||||
|
OrientVertical = "vertical"
|
||||||
|
)
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 332 KiB |
BIN
assets/go-table.png
Normal file
BIN
assets/go-table.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
459
axis.go
459
axis.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,186 +23,317 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"strings"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/golang/freetype/truetype"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type axisPainter struct {
|
||||||
// AxisData string
|
p *Painter
|
||||||
XAxis struct {
|
opt *AxisOption
|
||||||
// data value of axis
|
|
||||||
Data []string
|
|
||||||
// number of segments
|
|
||||||
SplitNumber 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const axisStrokeWidth = 1
|
func NewAxisPainter(p *Painter, opt AxisOption) *axisPainter {
|
||||||
|
return &axisPainter{
|
||||||
|
p: p,
|
||||||
|
opt: &opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func maxInt(values ...int) int {
|
type AxisOption struct {
|
||||||
result := 0
|
// The theme of chart
|
||||||
for _, v := range values {
|
Theme ColorPalette
|
||||||
if v > result {
|
// Formatter for y axis text value
|
||||||
result = v
|
Formatter string
|
||||||
|
// The label of axis
|
||||||
|
Data []string
|
||||||
|
// The boundary gap on both sides of a coordinate axis.
|
||||||
|
// Nil or *true means the center part of two axis ticks
|
||||||
|
BoundaryGap *bool
|
||||||
|
// The flag for show axis, set this to *false will hide axis
|
||||||
|
Show *bool
|
||||||
|
// The position of axis, it can be 'left', 'top', 'right' or 'bottom'
|
||||||
|
Position string
|
||||||
|
// Number of segments that the axis is split into. Note that this number serves only as a recommendation.
|
||||||
|
SplitNumber int
|
||||||
|
// The line color of axis
|
||||||
|
StrokeColor Color
|
||||||
|
// The line width
|
||||||
|
StrokeWidth float64
|
||||||
|
// The length of the axis tick
|
||||||
|
TickLength int
|
||||||
|
// The first axis
|
||||||
|
FirstAxis int
|
||||||
|
// The margin value of label
|
||||||
|
LabelMargin int
|
||||||
|
// The font size of label
|
||||||
|
FontSize float64
|
||||||
|
// The font of label
|
||||||
|
Font *truetype.Font
|
||||||
|
// The color of label
|
||||||
|
FontColor Color
|
||||||
|
// The flag for show axis split line, set this to true will show axis split line
|
||||||
|
SplitLineShow bool
|
||||||
|
// The color of split line
|
||||||
|
SplitLineColor Color
|
||||||
|
// The text rotation of label
|
||||||
|
TextRotation float64
|
||||||
|
// The offset of label
|
||||||
|
LabelOffset Box
|
||||||
|
Unit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *axisPainter) Render() (Box, error) {
|
||||||
|
opt := a.opt
|
||||||
|
top := a.p
|
||||||
|
theme := opt.Theme
|
||||||
|
if theme == nil {
|
||||||
|
theme = top.theme
|
||||||
|
}
|
||||||
|
if isFalse(opt.Show) {
|
||||||
|
return BoxZero, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
strokeWidth := opt.StrokeWidth
|
||||||
|
if strokeWidth == 0 {
|
||||||
|
strokeWidth = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
font := opt.Font
|
||||||
|
if font == nil {
|
||||||
|
font = a.p.font
|
||||||
|
}
|
||||||
|
if font == nil {
|
||||||
|
font = theme.GetFont()
|
||||||
|
}
|
||||||
|
fontColor := opt.FontColor
|
||||||
|
if fontColor.IsZero() {
|
||||||
|
fontColor = theme.GetTextColor()
|
||||||
|
}
|
||||||
|
fontSize := opt.FontSize
|
||||||
|
if fontSize == 0 {
|
||||||
|
fontSize = theme.GetFontSize()
|
||||||
|
}
|
||||||
|
strokeColor := opt.StrokeColor
|
||||||
|
if strokeColor.IsZero() {
|
||||||
|
strokeColor = theme.GetAxisStrokeColor()
|
||||||
|
}
|
||||||
|
|
||||||
|
data := opt.Data
|
||||||
|
formatter := opt.Formatter
|
||||||
|
if len(formatter) != 0 {
|
||||||
|
for index, text := range data {
|
||||||
|
data[index] = strings.ReplaceAll(formatter, "{value}", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
dataCount := len(data)
|
||||||
}
|
tickCount := dataCount
|
||||||
|
|
||||||
// GetXAxisAndValues returns x axis by theme, and the values of axis.
|
boundaryGap := true
|
||||||
func GetXAxisAndValues(xAxis XAxis, tickPosition chart.TickPosition, theme string) (chart.XAxis, []float64) {
|
if isFalse(opt.BoundaryGap) {
|
||||||
data := xAxis.Data
|
boundaryGap = false
|
||||||
originalSize := len(data)
|
}
|
||||||
// 如果居中,则需要多添加一个值
|
isVertical := opt.Position == PositionLeft ||
|
||||||
if tickPosition == chart.TickPositionBetweenTicks {
|
opt.Position == PositionRight
|
||||||
data = append([]string{
|
|
||||||
"",
|
labelPosition := ""
|
||||||
}, data...)
|
if !boundaryGap {
|
||||||
|
tickCount--
|
||||||
|
labelPosition = PositionLeft
|
||||||
|
}
|
||||||
|
if isVertical && boundaryGap {
|
||||||
|
labelPosition = PositionCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
size := len(data)
|
// 如果小于0,则表示不处理
|
||||||
|
tickLength := getDefaultInt(opt.TickLength, 5)
|
||||||
|
labelMargin := getDefaultInt(opt.LabelMargin, 5)
|
||||||
|
|
||||||
xValues := make([]float64, size)
|
style := Style{
|
||||||
ticks := make([]chart.Tick, 0)
|
StrokeColor: strokeColor,
|
||||||
|
StrokeWidth: strokeWidth,
|
||||||
// tick width
|
Font: font,
|
||||||
maxTicks := maxInt(xAxis.SplitNumber, 10)
|
FontColor: fontColor,
|
||||||
|
FontSize: fontSize,
|
||||||
// 计息最多每个unit至少放多个
|
|
||||||
minUnitSize := originalSize / maxTicks
|
|
||||||
if originalSize%maxTicks != 0 {
|
|
||||||
minUnitSize++
|
|
||||||
}
|
}
|
||||||
unitSize := minUnitSize
|
top.SetDrawingStyle(style).OverrideTextStyle(style)
|
||||||
// 尽可能选择一格展示更多的块
|
|
||||||
for i := minUnitSize; i < 2*minUnitSize; i++ {
|
isTextRotation := opt.TextRotation != 0
|
||||||
if originalSize%i == 0 {
|
|
||||||
unitSize = i
|
if isTextRotation {
|
||||||
|
top.SetTextRotation(opt.TextRotation)
|
||||||
|
}
|
||||||
|
textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(data)
|
||||||
|
if isTextRotation {
|
||||||
|
top.ClearTextRotation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加30px来计算文本展示区域
|
||||||
|
textFillWidth := float64(textMaxWidth + 20)
|
||||||
|
// 根据文本宽度计算较为符合的展示项
|
||||||
|
fitTextCount := ceilFloatToInt(float64(top.Width()) / textFillWidth)
|
||||||
|
|
||||||
|
unit := opt.Unit
|
||||||
|
if unit <= 0 {
|
||||||
|
|
||||||
|
unit = ceilFloatToInt(float64(dataCount) / float64(fitTextCount))
|
||||||
|
unit = chart.MaxInt(unit, opt.SplitNumber)
|
||||||
|
// 偶数
|
||||||
|
if unit%2 == 0 && dataCount%(unit+1) == 0 {
|
||||||
|
unit++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for index, key := range data {
|
width := 0
|
||||||
f := float64(index)
|
height := 0
|
||||||
xValues[index] = f
|
// 垂直
|
||||||
if index%unitSize == 0 || index == size-1 {
|
if isVertical {
|
||||||
ticks = append(ticks, chart.Tick{
|
width = textMaxWidth + tickLength<<1
|
||||||
Value: f,
|
height = top.Height()
|
||||||
Label: key,
|
} else {
|
||||||
})
|
width = top.Width()
|
||||||
|
height = tickLength<<1 + textMaxHeight
|
||||||
|
}
|
||||||
|
padding := Box{}
|
||||||
|
switch opt.Position {
|
||||||
|
case PositionTop:
|
||||||
|
padding.Top = top.Height() - height
|
||||||
|
case PositionLeft:
|
||||||
|
padding.Right = top.Width() - width
|
||||||
|
case PositionRight:
|
||||||
|
padding.Left = top.Width() - width
|
||||||
|
default:
|
||||||
|
padding.Top = top.Height() - defaultXAxisHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
p := top.Child(PainterPaddingOption(padding))
|
||||||
|
|
||||||
|
x0 := 0
|
||||||
|
y0 := 0
|
||||||
|
x1 := 0
|
||||||
|
y1 := 0
|
||||||
|
ticksPaddingTop := 0
|
||||||
|
ticksPaddingLeft := 0
|
||||||
|
labelPaddingTop := 0
|
||||||
|
labelPaddingLeft := 0
|
||||||
|
labelPaddingRight := 0
|
||||||
|
orient := ""
|
||||||
|
textAlign := ""
|
||||||
|
|
||||||
|
switch opt.Position {
|
||||||
|
case PositionTop:
|
||||||
|
labelPaddingTop = 0
|
||||||
|
x1 = p.Width()
|
||||||
|
y0 = labelMargin + int(opt.FontSize)
|
||||||
|
ticksPaddingTop = int(opt.FontSize)
|
||||||
|
y1 = y0
|
||||||
|
orient = OrientHorizontal
|
||||||
|
case PositionLeft:
|
||||||
|
x0 = p.Width()
|
||||||
|
y0 = 0
|
||||||
|
x1 = p.Width()
|
||||||
|
y1 = p.Height()
|
||||||
|
orient = OrientVertical
|
||||||
|
textAlign = AlignRight
|
||||||
|
ticksPaddingLeft = textMaxWidth + tickLength
|
||||||
|
labelPaddingRight = width - textMaxWidth
|
||||||
|
case PositionRight:
|
||||||
|
orient = OrientVertical
|
||||||
|
y1 = p.Height()
|
||||||
|
labelPaddingLeft = width - textMaxWidth
|
||||||
|
default:
|
||||||
|
labelPaddingTop = height
|
||||||
|
x1 = p.Width()
|
||||||
|
orient = OrientHorizontal
|
||||||
|
}
|
||||||
|
|
||||||
|
if strokeWidth > 0 {
|
||||||
|
p.Child(PainterPaddingOption(Box{
|
||||||
|
Top: ticksPaddingTop,
|
||||||
|
Left: ticksPaddingLeft,
|
||||||
|
})).Ticks(TicksOption{
|
||||||
|
Count: tickCount,
|
||||||
|
Length: tickLength,
|
||||||
|
Unit: unit,
|
||||||
|
Orient: orient,
|
||||||
|
First: opt.FirstAxis,
|
||||||
|
})
|
||||||
|
p.LineStroke([]Point{
|
||||||
|
{
|
||||||
|
X: x0,
|
||||||
|
Y: y0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: x1,
|
||||||
|
Y: y1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Child(PainterPaddingOption(Box{
|
||||||
|
Left: labelPaddingLeft,
|
||||||
|
Top: labelPaddingTop,
|
||||||
|
Right: labelPaddingRight,
|
||||||
|
})).MultiText(MultiTextOption{
|
||||||
|
First: opt.FirstAxis,
|
||||||
|
Align: textAlign,
|
||||||
|
TextList: data,
|
||||||
|
Orient: orient,
|
||||||
|
Unit: unit,
|
||||||
|
Position: labelPosition,
|
||||||
|
TextRotation: opt.TextRotation,
|
||||||
|
Offset: opt.LabelOffset,
|
||||||
|
})
|
||||||
|
// 显示辅助线
|
||||||
|
if opt.SplitLineShow {
|
||||||
|
style.StrokeColor = opt.SplitLineColor
|
||||||
|
style.StrokeWidth = 1
|
||||||
|
top.OverrideDrawingStyle(style)
|
||||||
|
if isVertical {
|
||||||
|
x0 := p.Width()
|
||||||
|
x1 := top.Width()
|
||||||
|
if opt.Position == PositionRight {
|
||||||
|
x0 = 0
|
||||||
|
x1 = top.Width() - p.Width()
|
||||||
|
}
|
||||||
|
yValues := autoDivide(height, tickCount)
|
||||||
|
yValues = yValues[0 : len(yValues)-1]
|
||||||
|
for _, y := range yValues {
|
||||||
|
top.LineStroke([]Point{
|
||||||
|
{
|
||||||
|
X: x0,
|
||||||
|
Y: y,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: x1,
|
||||||
|
Y: y,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
y0 := p.Height() - defaultXAxisHeight
|
||||||
|
y1 := top.Height() - defaultXAxisHeight
|
||||||
|
for index, x := range autoDivide(width, tickCount) {
|
||||||
|
if index == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
top.LineStroke([]Point{
|
||||||
|
{
|
||||||
|
X: x,
|
||||||
|
Y: y0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: x,
|
||||||
|
Y: y1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return chart.XAxis{
|
|
||||||
Ticks: ticks,
|
|
||||||
TickPosition: tickPosition,
|
|
||||||
Style: chart.Style{
|
|
||||||
FontColor: getAxisColor(theme),
|
|
||||||
StrokeColor: getAxisColor(theme),
|
|
||||||
StrokeWidth: axisStrokeWidth,
|
|
||||||
},
|
|
||||||
}, xValues
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultFloatFormater(v interface{}) string {
|
return Box{
|
||||||
value, ok := v.(float64)
|
Bottom: height,
|
||||||
if !ok {
|
Right: width,
|
||||||
return ""
|
}, nil
|
||||||
}
|
|
||||||
// 大于10的则直接取整展示
|
|
||||||
if value >= 10 {
|
|
||||||
return humanize.CommafWithDigits(value, 0)
|
|
||||||
}
|
|
||||||
return humanize.CommafWithDigits(value, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newYContinuousRange(option *YAxisOption) *YContinuousRange {
|
|
||||||
m := YContinuousRange{}
|
|
||||||
m.Min = -math.MaxFloat64
|
|
||||||
m.Max = math.MaxFloat64
|
|
||||||
if option != nil {
|
|
||||||
if option.Min != nil {
|
|
||||||
m.Min = *option.Min
|
|
||||||
}
|
|
||||||
if option.Max != nil {
|
|
||||||
m.Max = *option.Max
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &m
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSecondaryYAxis returns the secondary y axis by theme
|
|
||||||
func GetSecondaryYAxis(theme string, option *YAxisOption) chart.YAxis {
|
|
||||||
strokeColor := getGridColor(theme)
|
|
||||||
yAxis := chart.YAxis{
|
|
||||||
Range: newYContinuousRange(option),
|
|
||||||
ValueFormatter: defaultFloatFormater,
|
|
||||||
AxisType: chart.YAxisSecondary,
|
|
||||||
GridMajorStyle: chart.Style{
|
|
||||||
StrokeColor: strokeColor,
|
|
||||||
StrokeWidth: axisStrokeWidth,
|
|
||||||
},
|
|
||||||
GridMinorStyle: chart.Style{
|
|
||||||
StrokeColor: strokeColor,
|
|
||||||
StrokeWidth: axisStrokeWidth,
|
|
||||||
},
|
|
||||||
Style: chart.Style{
|
|
||||||
FontColor: getAxisColor(theme),
|
|
||||||
// alpha 0,隐藏
|
|
||||||
StrokeColor: hiddenColor,
|
|
||||||
StrokeWidth: axisStrokeWidth,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
setYAxisOption(&yAxis, option)
|
|
||||||
return yAxis
|
|
||||||
}
|
|
||||||
|
|
||||||
func setYAxisOption(yAxis *chart.YAxis, option *YAxisOption) {
|
|
||||||
if option == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if option.Formater != nil {
|
|
||||||
yAxis.ValueFormatter = option.Formater
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetYAxis returns the primary y axis by theme
|
|
||||||
func GetYAxis(theme string, option *YAxisOption) chart.YAxis {
|
|
||||||
disabled := false
|
|
||||||
if option != nil {
|
|
||||||
disabled = option.Disabled
|
|
||||||
}
|
|
||||||
hidden := chart.Hidden()
|
|
||||||
|
|
||||||
yAxis := chart.YAxis{
|
|
||||||
Range: newYContinuousRange(option),
|
|
||||||
ValueFormatter: defaultFloatFormater,
|
|
||||||
AxisType: chart.YAxisPrimary,
|
|
||||||
GridMajorStyle: hidden,
|
|
||||||
GridMinorStyle: hidden,
|
|
||||||
Style: chart.Style{
|
|
||||||
FontColor: getAxisColor(theme),
|
|
||||||
// alpha 0,隐藏
|
|
||||||
StrokeColor: hiddenColor,
|
|
||||||
StrokeWidth: axisStrokeWidth,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// 如果禁用,则默认为隐藏,并设置range
|
|
||||||
if disabled {
|
|
||||||
yAxis.Range = &HiddenRange{}
|
|
||||||
yAxis.Style.Hidden = true
|
|
||||||
}
|
|
||||||
setYAxisOption(&yAxis, option)
|
|
||||||
return yAxis
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
251
axis_test.go
251
axis_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,150 +23,151 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetXAxisAndValues(t *testing.T) {
|
func TestAxis(t *testing.T) {
|
||||||
assert := assert.New(t)
|
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 {
|
tests := []struct {
|
||||||
xAxis XAxis
|
render func(*Painter) ([]byte, error)
|
||||||
tickPosition chart.TickPosition
|
result string
|
||||||
theme string
|
|
||||||
result chart.XAxis
|
|
||||||
values []float64
|
|
||||||
}{
|
}{
|
||||||
|
// 底部x轴
|
||||||
{
|
{
|
||||||
xAxis: XAxis{
|
render: func(p *Painter) ([]byte, error) {
|
||||||
Data: genLabels(5),
|
_, _ = NewAxisPainter(p, AxisOption{
|
||||||
},
|
Data: []string{
|
||||||
values: genValues(5, false),
|
"Mon",
|
||||||
result: chart.XAxis{
|
"Tue",
|
||||||
Ticks: genTicks(5, false),
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
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 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轴文本居左
|
||||||
{
|
{
|
||||||
xAxis: XAxis{
|
render: func(p *Painter) ([]byte, error) {
|
||||||
Data: genLabels(5),
|
_, _ = NewAxisPainter(p, AxisOption{
|
||||||
},
|
Data: []string{
|
||||||
tickPosition: chart.TickPositionBetweenTicks,
|
"Mon",
|
||||||
// 居中因此value多一个
|
"Tue",
|
||||||
values: genValues(5, true),
|
"Wed",
|
||||||
result: chart.XAxis{
|
"Thu",
|
||||||
Ticks: genTicks(5, true),
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
BoundaryGap: FalseFlag(),
|
||||||
|
}).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 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轴
|
||||||
{
|
{
|
||||||
xAxis: XAxis{
|
render: func(p *Painter) ([]byte, error) {
|
||||||
Data: genLabels(20),
|
_, _ = NewAxisPainter(p, AxisOption{
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
Position: PositionLeft,
|
||||||
|
}).Render()
|
||||||
|
return p.Bytes()
|
||||||
},
|
},
|
||||||
// 居中因此value多一个
|
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>",
|
||||||
values: genValues(20, false),
|
},
|
||||||
result: chart.XAxis{
|
// 左侧y轴居中
|
||||||
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"}},
|
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 {
|
for _, tt := range tests {
|
||||||
xAxis, values := GetXAxisAndValues(tt.xAxis, tt.tickPosition, tt.theme)
|
p, err := NewPainter(PainterOptions{
|
||||||
|
Type: ChartOutputSVG,
|
||||||
assert.Equal(tt.result.Ticks, xAxis.Ticks)
|
Width: 600,
|
||||||
assert.Equal(tt.tickPosition, xAxis.TickPosition)
|
Height: 400,
|
||||||
assert.Equal(tt.values, values)
|
}, PainterThemeOption(defaultTheme))
|
||||||
|
assert.Nil(err)
|
||||||
|
data, err := tt.render(p)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(tt.result, string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultFloatFormater(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
assert.Equal("", defaultFloatFormater(1))
|
|
||||||
|
|
||||||
assert.Equal("0.1", defaultFloatFormater(0.1))
|
|
||||||
assert.Equal("0.12", defaultFloatFormater(0.123))
|
|
||||||
assert.Equal("10", defaultFloatFormater(10.1))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetYAxisOption(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
min := 10.0
|
|
||||||
max := 20.0
|
|
||||||
opt := &YAxisOption{
|
|
||||||
Formater: func(v interface{}) string {
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
Min: &min,
|
|
||||||
Max: &max,
|
|
||||||
}
|
|
||||||
yAxis := &chart.YAxis{
|
|
||||||
Range: newYContinuousRange(opt),
|
|
||||||
}
|
|
||||||
setYAxisOption(yAxis, opt)
|
|
||||||
|
|
||||||
assert.NotEmpty(yAxis.ValueFormatter)
|
|
||||||
assert.Equal(max, yAxis.Range.GetMax())
|
|
||||||
assert.Equal(min, yAxis.Range.GetMin())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetYAxis(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
yAxis := GetYAxis(ThemeDark, nil)
|
|
||||||
|
|
||||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
|
||||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
|
||||||
assert.False(yAxis.Style.Hidden)
|
|
||||||
|
|
||||||
yAxis = GetYAxis(ThemeDark, &YAxisOption{
|
|
||||||
Disabled: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
|
||||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
|
||||||
assert.True(yAxis.Style.Hidden)
|
|
||||||
|
|
||||||
// secondary yAxis
|
|
||||||
yAxis = GetSecondaryYAxis(ThemeDark, nil)
|
|
||||||
assert.False(yAxis.GridMajorStyle.Hidden)
|
|
||||||
assert.False(yAxis.GridMajorStyle.Hidden)
|
|
||||||
assert.True(yAxis.Style.StrokeColor.IsTransparent())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
253
bar_chart.go
Normal file
253
bar_chart.go
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type barChart struct {
|
||||||
|
p *Painter
|
||||||
|
opt *BarChartOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBarChart returns a bar chart renderer
|
||||||
|
func NewBarChart(p *Painter, opt BarChartOption) *barChart {
|
||||||
|
if opt.Theme == nil {
|
||||||
|
opt.Theme = defaultTheme
|
||||||
|
}
|
||||||
|
return &barChart{
|
||||||
|
p: p,
|
||||||
|
opt: &opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BarChartOption struct {
|
||||||
|
// The theme
|
||||||
|
Theme ColorPalette
|
||||||
|
// The font size
|
||||||
|
Font *truetype.Font
|
||||||
|
// The data series list
|
||||||
|
SeriesList SeriesList
|
||||||
|
// The x axis option
|
||||||
|
XAxis XAxisOption
|
||||||
|
// The padding of line chart
|
||||||
|
Padding Box
|
||||||
|
// The y axis option
|
||||||
|
YAxisOptions []YAxisOption
|
||||||
|
// The option of title
|
||||||
|
Title TitleOption
|
||||||
|
// The legend option
|
||||||
|
Legend LegendOption
|
||||||
|
BarWidth int
|
||||||
|
// Margin of bar
|
||||||
|
BarMargin int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
|
||||||
|
p := b.p
|
||||||
|
opt := b.opt
|
||||||
|
seriesPainter := result.seriesPainter
|
||||||
|
|
||||||
|
xRange := NewRange(AxisRangeOption{
|
||||||
|
Painter: b.p,
|
||||||
|
DivideCount: len(opt.XAxis.Data),
|
||||||
|
Size: seriesPainter.Width(),
|
||||||
|
})
|
||||||
|
x0, x1 := xRange.GetRange(0)
|
||||||
|
width := int(x1 - x0)
|
||||||
|
// 每一块之间的margin
|
||||||
|
margin := 10
|
||||||
|
// 每一个bar之间的margin
|
||||||
|
barMargin := 5
|
||||||
|
if width < 20 {
|
||||||
|
margin = 2
|
||||||
|
barMargin = 2
|
||||||
|
} else if width < 50 {
|
||||||
|
margin = 5
|
||||||
|
barMargin = 3
|
||||||
|
}
|
||||||
|
if opt.BarMargin > 0 {
|
||||||
|
barMargin = opt.BarMargin
|
||||||
|
}
|
||||||
|
seriesCount := len(seriesList)
|
||||||
|
// 总的宽度-两个margin-(总数-1)的barMargin
|
||||||
|
barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / seriesCount
|
||||||
|
if opt.BarWidth > 0 && opt.BarWidth < barWidth {
|
||||||
|
barWidth = opt.BarWidth
|
||||||
|
// 重新计算margin
|
||||||
|
margin = (width - seriesCount*barWidth - barMargin*(seriesCount-1)) / 2
|
||||||
|
}
|
||||||
|
barMaxHeight := seriesPainter.Height()
|
||||||
|
theme := opt.Theme
|
||||||
|
seriesNames := seriesList.Names()
|
||||||
|
|
||||||
|
markPointPainter := NewMarkPointPainter(seriesPainter)
|
||||||
|
markLinePainter := NewMarkLinePainter(seriesPainter)
|
||||||
|
rendererList := []Renderer{
|
||||||
|
markPointPainter,
|
||||||
|
markLinePainter,
|
||||||
|
}
|
||||||
|
for index := range seriesList {
|
||||||
|
series := seriesList[index]
|
||||||
|
yRange := result.axisRanges[series.AxisIndex]
|
||||||
|
seriesColor := theme.GetSeriesColor(series.index)
|
||||||
|
|
||||||
|
divideValues := xRange.AutoDivide()
|
||||||
|
points := make([]Point, len(series.Data))
|
||||||
|
var labelPainter *SeriesLabelPainter
|
||||||
|
if series.Label.Show {
|
||||||
|
labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
|
||||||
|
P: seriesPainter,
|
||||||
|
SeriesNames: seriesNames,
|
||||||
|
Label: series.Label,
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Font: opt.Font,
|
||||||
|
})
|
||||||
|
rendererList = append(rendererList, labelPainter)
|
||||||
|
}
|
||||||
|
|
||||||
|
for j, item := range series.Data {
|
||||||
|
if j >= xRange.divideCount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x := divideValues[j]
|
||||||
|
x += margin
|
||||||
|
if index != 0 {
|
||||||
|
x += index * (barWidth + barMargin)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := int(yRange.getHeight(item.Value))
|
||||||
|
fillColor := seriesColor
|
||||||
|
if !item.Style.FillColor.IsZero() {
|
||||||
|
fillColor = item.Style.FillColor
|
||||||
|
}
|
||||||
|
top := barMaxHeight - h
|
||||||
|
|
||||||
|
if series.RoundRadius <= 0 {
|
||||||
|
seriesPainter.OverrideDrawingStyle(Style{
|
||||||
|
FillColor: fillColor,
|
||||||
|
}).Rect(chart.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: x,
|
||||||
|
Right: x + barWidth,
|
||||||
|
Bottom: barMaxHeight - 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
seriesPainter.OverrideDrawingStyle(Style{
|
||||||
|
FillColor: fillColor,
|
||||||
|
}).RoundedRect(chart.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: x,
|
||||||
|
Right: x + barWidth,
|
||||||
|
Bottom: barMaxHeight - 1,
|
||||||
|
}, series.RoundRadius)
|
||||||
|
}
|
||||||
|
// 用于生成marker point
|
||||||
|
points[j] = Point{
|
||||||
|
// 居中的位置
|
||||||
|
X: x + barWidth>>1,
|
||||||
|
Y: top,
|
||||||
|
}
|
||||||
|
// 用于生成marker point
|
||||||
|
points[j] = Point{
|
||||||
|
// 居中的位置
|
||||||
|
X: x + barWidth>>1,
|
||||||
|
Y: top,
|
||||||
|
}
|
||||||
|
// 如果label不需要展示,则返回
|
||||||
|
if labelPainter == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
y := barMaxHeight - h
|
||||||
|
radians := float64(0)
|
||||||
|
fontColor := series.Label.Color
|
||||||
|
if series.Label.Position == PositionBottom {
|
||||||
|
y = barMaxHeight
|
||||||
|
radians = -math.Pi / 2
|
||||||
|
if fontColor.IsZero() {
|
||||||
|
if isLightColor(fillColor) {
|
||||||
|
fontColor = defaultLightFontColor
|
||||||
|
} else {
|
||||||
|
fontColor = defaultDarkFontColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelPainter.Add(LabelValue{
|
||||||
|
Index: index,
|
||||||
|
Value: item.Value,
|
||||||
|
X: x + barWidth>>1,
|
||||||
|
Y: y,
|
||||||
|
// 旋转
|
||||||
|
Radians: radians,
|
||||||
|
FontColor: fontColor,
|
||||||
|
Offset: series.Label.Offset,
|
||||||
|
FontSize: series.Label.FontSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
markPointPainter.Add(markPointRenderOption{
|
||||||
|
FillColor: seriesColor,
|
||||||
|
Font: opt.Font,
|
||||||
|
Series: series,
|
||||||
|
Points: points,
|
||||||
|
})
|
||||||
|
markLinePainter.Add(markLineRenderOption{
|
||||||
|
FillColor: seriesColor,
|
||||||
|
FontColor: opt.Theme.GetTextColor(),
|
||||||
|
StrokeColor: seriesColor,
|
||||||
|
Font: opt.Font,
|
||||||
|
Series: series,
|
||||||
|
Range: yRange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 最大、最小的mark point
|
||||||
|
err := doRender(rendererList...)
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.box, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *barChart) Render() (Box, error) {
|
||||||
|
p := b.p
|
||||||
|
opt := b.opt
|
||||||
|
renderResult, err := defaultRender(p, defaultRenderOption{
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Padding: opt.Padding,
|
||||||
|
SeriesList: opt.SeriesList,
|
||||||
|
XAxis: opt.XAxis,
|
||||||
|
YAxisOptions: opt.YAxisOptions,
|
||||||
|
TitleOption: opt.Title,
|
||||||
|
LegendOption: opt.Legend,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
seriesList := opt.SeriesList.Filter(ChartTypeLine)
|
||||||
|
return b.render(renderResult, seriesList)
|
||||||
|
}
|
||||||
190
bar_chart_test.go
Normal file
190
bar_chart_test.go
Normal file
File diff suppressed because one or more lines are too long
137
bar_series.go
137
bar_series.go
|
|
@ -1,137 +0,0 @@
|
||||||
// MIT License
|
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
|
||||||
// in the Software without restriction, including without limitation the rights
|
|
||||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
// copies of the Software, and to permit persons to whom the Software is
|
|
||||||
// furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
// The above copyright notice and this permission notice shall be included in all
|
|
||||||
// copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
// SOFTWARE.
|
|
||||||
|
|
||||||
package charts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultBarMargin = 10
|
|
||||||
|
|
||||||
type BarSeriesCustomStyle struct {
|
|
||||||
PointIndex int
|
|
||||||
Index int
|
|
||||||
Style chart.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
type BarSeries struct {
|
|
||||||
BaseSeries
|
|
||||||
Count int
|
|
||||||
Index int
|
|
||||||
// 间隔
|
|
||||||
Margin int
|
|
||||||
// 偏移量
|
|
||||||
Offset int
|
|
||||||
// 宽度
|
|
||||||
BarWidth int
|
|
||||||
CustomStyles []BarSeriesCustomStyle
|
|
||||||
}
|
|
||||||
|
|
||||||
type barSeriesWidthValues struct {
|
|
||||||
columnWidth int
|
|
||||||
columnMargin int
|
|
||||||
margin int
|
|
||||||
barWidth int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bs BarSeries) GetBarStyle(index, pointIndex int) chart.Style {
|
|
||||||
// 指定样式
|
|
||||||
for _, item := range bs.CustomStyles {
|
|
||||||
if item.Index == index && item.PointIndex == pointIndex {
|
|
||||||
return item.Style
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 其它非指定样式
|
|
||||||
return chart.Style{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bs BarSeries) getWidthValues(width int) barSeriesWidthValues {
|
|
||||||
columnWidth := width / bs.Len()
|
|
||||||
// 块间隔
|
|
||||||
columnMargin := columnWidth / 10
|
|
||||||
minColumnMargin := 2
|
|
||||||
if columnMargin < minColumnMargin {
|
|
||||||
columnMargin = minColumnMargin
|
|
||||||
}
|
|
||||||
margin := bs.Margin
|
|
||||||
if margin <= 0 {
|
|
||||||
margin = defaultBarMargin
|
|
||||||
}
|
|
||||||
// 如果margin大于column margin
|
|
||||||
if margin > columnMargin {
|
|
||||||
margin = columnMargin
|
|
||||||
}
|
|
||||||
|
|
||||||
allBarMarginWidth := (bs.Count - 1) * margin
|
|
||||||
barWidth := ((columnWidth - 2*columnMargin) - allBarMarginWidth) / bs.Count
|
|
||||||
if bs.BarWidth > 0 && bs.BarWidth < barWidth {
|
|
||||||
barWidth = bs.BarWidth
|
|
||||||
// 重新计息columnMargin
|
|
||||||
columnMargin = (columnWidth - allBarMarginWidth - (bs.Count * barWidth)) / 2
|
|
||||||
}
|
|
||||||
return barSeriesWidthValues{
|
|
||||||
columnWidth: columnWidth,
|
|
||||||
columnMargin: columnMargin,
|
|
||||||
margin: margin,
|
|
||||||
barWidth: barWidth,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (bs BarSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) {
|
|
||||||
if bs.Len() == 0 || bs.Count <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
style := bs.Style.InheritFrom(defaults)
|
|
||||||
style.FillColor = style.StrokeColor
|
|
||||||
if !style.ShouldDrawStroke() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cb := canvasBox.Bottom
|
|
||||||
cl := canvasBox.Left
|
|
||||||
widthValues := bs.getWidthValues(canvasBox.Width())
|
|
||||||
|
|
||||||
for i := 0; i < bs.Len(); i++ {
|
|
||||||
vx, vy := bs.GetValues(i)
|
|
||||||
customStyle := bs.GetBarStyle(bs.Index, i)
|
|
||||||
cloneStyle := style
|
|
||||||
if !customStyle.IsZero() {
|
|
||||||
cloneStyle.FillColor = customStyle.FillColor
|
|
||||||
cloneStyle.StrokeColor = customStyle.StrokeColor
|
|
||||||
}
|
|
||||||
|
|
||||||
x := cl + xrange.Translate(vx)
|
|
||||||
// 由于bar是居中展示,因此需要往前移一个显示块
|
|
||||||
x += (-widthValues.columnWidth + widthValues.columnMargin)
|
|
||||||
// 计算是第几个bar,位置右偏
|
|
||||||
x += bs.Index * (widthValues.margin + widthValues.barWidth)
|
|
||||||
y := cb - yrange.Translate(vy)
|
|
||||||
|
|
||||||
chart.Draw.Box(r, chart.Box{
|
|
||||||
Left: x,
|
|
||||||
Top: y,
|
|
||||||
Right: x + widthValues.barWidth,
|
|
||||||
Bottom: canvasBox.Bottom - 1,
|
|
||||||
}, cloneStyle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
// MIT License
|
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
|
||||||
// in the Software without restriction, including without limitation the rights
|
|
||||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
// copies of the Software, and to permit persons to whom the Software is
|
|
||||||
// furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
// The above copyright notice and this permission notice shall be included in all
|
|
||||||
// copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
// SOFTWARE.
|
|
||||||
|
|
||||||
package charts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBarSeries(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
customStyle := chart.Style{
|
|
||||||
StrokeColor: drawing.ColorBlue,
|
|
||||||
}
|
|
||||||
bs := BarSeries{
|
|
||||||
CustomStyles: []BarSeriesCustomStyle{
|
|
||||||
{
|
|
||||||
PointIndex: 1,
|
|
||||||
Style: customStyle,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(customStyle, bs.GetBarStyle(0, 1))
|
|
||||||
|
|
||||||
assert.True(bs.GetBarStyle(1, 0).IsZero())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBarSeriesGetWidthValues(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
bs := BarSeries{
|
|
||||||
Count: 1,
|
|
||||||
BaseSeries: BaseSeries{
|
|
||||||
XValues: []float64{
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
widthValues := bs.getWidthValues(300)
|
|
||||||
assert.Equal(barSeriesWidthValues{
|
|
||||||
columnWidth: 100,
|
|
||||||
columnMargin: 10,
|
|
||||||
margin: 10,
|
|
||||||
barWidth: 80,
|
|
||||||
}, widthValues)
|
|
||||||
|
|
||||||
// 指定margin
|
|
||||||
bs.Margin = 5
|
|
||||||
widthValues = bs.getWidthValues(300)
|
|
||||||
assert.Equal(barSeriesWidthValues{
|
|
||||||
columnWidth: 100,
|
|
||||||
columnMargin: 10,
|
|
||||||
margin: 5,
|
|
||||||
barWidth: 80,
|
|
||||||
}, widthValues)
|
|
||||||
|
|
||||||
// 指定bar的宽度
|
|
||||||
bs.BarWidth = 60
|
|
||||||
widthValues = bs.getWidthValues(300)
|
|
||||||
assert.Equal(barSeriesWidthValues{
|
|
||||||
columnWidth: 100,
|
|
||||||
columnMargin: 20,
|
|
||||||
margin: 5,
|
|
||||||
barWidth: 60,
|
|
||||||
}, widthValues)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBarSeriesRender(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
width := 800
|
|
||||||
height := 400
|
|
||||||
|
|
||||||
r, err := chart.SVG(width, height)
|
|
||||||
assert.Nil(err)
|
|
||||||
|
|
||||||
bs := BarSeries{
|
|
||||||
Count: 1,
|
|
||||||
CustomStyles: []BarSeriesCustomStyle{
|
|
||||||
{
|
|
||||||
Index: 0,
|
|
||||||
PointIndex: 1,
|
|
||||||
Style: chart.Style{
|
|
||||||
StrokeColor: SeriesColorsLight[1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
BaseSeries: BaseSeries{
|
|
||||||
TickPosition: chart.TickPositionBetweenTicks,
|
|
||||||
Style: chart.Style{
|
|
||||||
StrokeColor: SeriesColorsLight[0],
|
|
||||||
StrokeWidth: 1,
|
|
||||||
},
|
|
||||||
XValues: []float64{
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
5,
|
|
||||||
6,
|
|
||||||
7,
|
|
||||||
},
|
|
||||||
YValues: []float64{
|
|
||||||
// 第一个点为占位点
|
|
||||||
0,
|
|
||||||
120,
|
|
||||||
200,
|
|
||||||
150,
|
|
||||||
80,
|
|
||||||
70,
|
|
||||||
110,
|
|
||||||
130,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
xrange := &chart.ContinuousRange{
|
|
||||||
Min: 0,
|
|
||||||
Max: 7,
|
|
||||||
Domain: 753,
|
|
||||||
}
|
|
||||||
yrange := &chart.ContinuousRange{
|
|
||||||
Min: 70,
|
|
||||||
Max: 200,
|
|
||||||
Domain: 362,
|
|
||||||
}
|
|
||||||
bs.Render(r, chart.Box{
|
|
||||||
Top: 11,
|
|
||||||
Left: 42,
|
|
||||||
Right: 795,
|
|
||||||
Bottom: 373,
|
|
||||||
}, xrange, yrange, chart.Style{})
|
|
||||||
|
|
||||||
buffer := bytes.Buffer{}
|
|
||||||
err = r.Save(&buffer)
|
|
||||||
assert.Nil(err)
|
|
||||||
assert.Equal("<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
133
base_series.go
|
|
@ -1,133 +0,0 @@
|
||||||
// MIT License
|
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
|
||||||
// in the Software without restriction, including without limitation the rights
|
|
||||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
// copies of the Software, and to permit persons to whom the Software is
|
|
||||||
// furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
// The above copyright notice and this permission notice shall be included in all
|
|
||||||
// copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
// SOFTWARE.
|
|
||||||
|
|
||||||
package charts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Interface Assertions.
|
|
||||||
var (
|
|
||||||
_ chart.Series = (*BaseSeries)(nil)
|
|
||||||
_ chart.FirstValuesProvider = (*BaseSeries)(nil)
|
|
||||||
_ chart.LastValuesProvider = (*BaseSeries)(nil)
|
|
||||||
)
|
|
||||||
|
|
||||||
// BaseSeries represents a line on a chart.
|
|
||||||
type BaseSeries struct {
|
|
||||||
Name string
|
|
||||||
Style chart.Style
|
|
||||||
TickPosition chart.TickPosition
|
|
||||||
|
|
||||||
YAxis chart.YAxisType
|
|
||||||
|
|
||||||
XValueFormatter chart.ValueFormatter
|
|
||||||
YValueFormatter chart.ValueFormatter
|
|
||||||
|
|
||||||
XValues []float64
|
|
||||||
YValues []float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetName returns the name of the time series.
|
|
||||||
func (bs BaseSeries) GetName() string {
|
|
||||||
return bs.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStyle returns the line style.
|
|
||||||
func (bs BaseSeries) GetStyle() chart.Style {
|
|
||||||
return bs.Style
|
|
||||||
}
|
|
||||||
|
|
||||||
// Len returns the number of elements in the series.
|
|
||||||
func (bs BaseSeries) Len() int {
|
|
||||||
offset := 0
|
|
||||||
if bs.TickPosition == chart.TickPositionBetweenTicks {
|
|
||||||
offset = -1
|
|
||||||
}
|
|
||||||
return len(bs.XValues) + offset
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValues gets the x,y values at a given index.
|
|
||||||
func (bs BaseSeries) GetValues(index int) (float64, float64) {
|
|
||||||
if bs.TickPosition == chart.TickPositionBetweenTicks {
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return bs.XValues[index], bs.YValues[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFirstValues gets the first x,y values.
|
|
||||||
func (bs BaseSeries) GetFirstValues() (float64, float64) {
|
|
||||||
index := 0
|
|
||||||
if bs.TickPosition == chart.TickPositionBetweenTicks {
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return bs.XValues[index], bs.YValues[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLastValues gets the last x,y values.
|
|
||||||
func (bs BaseSeries) GetLastValues() (float64, float64) {
|
|
||||||
return bs.XValues[len(bs.XValues)-1], bs.YValues[len(bs.YValues)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValueFormatters returns value formatter defaults for the series.
|
|
||||||
func (bs BaseSeries) GetValueFormatters() (x, y chart.ValueFormatter) {
|
|
||||||
if bs.XValueFormatter != nil {
|
|
||||||
x = bs.XValueFormatter
|
|
||||||
} else {
|
|
||||||
x = chart.FloatValueFormatter
|
|
||||||
}
|
|
||||||
if bs.YValueFormatter != nil {
|
|
||||||
y = bs.YValueFormatter
|
|
||||||
} else {
|
|
||||||
y = chart.FloatValueFormatter
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetYAxis returns which YAxis the series draws on.
|
|
||||||
func (bs BaseSeries) GetYAxis() chart.YAxisType {
|
|
||||||
return bs.YAxis
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render renders the series.
|
|
||||||
func (bs BaseSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) {
|
|
||||||
fmt.Println("should be override the function")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate validates the series.
|
|
||||||
func (bs BaseSeries) Validate() error {
|
|
||||||
if len(bs.XValues) == 0 {
|
|
||||||
return fmt.Errorf("continuous series; must have xvalues set")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(bs.YValues) == 0 {
|
|
||||||
return fmt.Errorf("continuous series; must have yvalues set")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(bs.XValues) != len(bs.YValues) {
|
|
||||||
return fmt.Errorf("continuous series; must have same length xvalues as yvalues")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
// MIT License
|
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
|
||||||
// in the Software without restriction, including without limitation the rights
|
|
||||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
// copies of the Software, and to permit persons to whom the Software is
|
|
||||||
// furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
// The above copyright notice and this permission notice shall be included in all
|
|
||||||
// copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
// SOFTWARE.
|
|
||||||
|
|
||||||
package charts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBaseSeries(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
bs := BaseSeries{
|
|
||||||
XValues: []float64{
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
},
|
|
||||||
YValues: []float64{
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
30,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
assert.Equal(3, bs.Len())
|
|
||||||
bs.TickPosition = chart.TickPositionBetweenTicks
|
|
||||||
assert.Equal(2, bs.Len())
|
|
||||||
|
|
||||||
bs.TickPosition = chart.TickPositionUnset
|
|
||||||
x, y := bs.GetValues(1)
|
|
||||||
assert.Equal(float64(2), x)
|
|
||||||
assert.Equal(float64(20), y)
|
|
||||||
bs.TickPosition = chart.TickPositionBetweenTicks
|
|
||||||
x, y = bs.GetValues(1)
|
|
||||||
assert.Equal(float64(3), x)
|
|
||||||
assert.Equal(float64(30), y)
|
|
||||||
|
|
||||||
bs.TickPosition = chart.TickPositionUnset
|
|
||||||
x, y = bs.GetFirstValues()
|
|
||||||
assert.Equal(float64(1), x)
|
|
||||||
assert.Equal(float64(10), y)
|
|
||||||
bs.TickPosition = chart.TickPositionBetweenTicks
|
|
||||||
x, y = bs.GetFirstValues()
|
|
||||||
assert.Equal(float64(2), x)
|
|
||||||
assert.Equal(float64(20), y)
|
|
||||||
|
|
||||||
bs.TickPosition = chart.TickPositionUnset
|
|
||||||
x, y = bs.GetLastValues()
|
|
||||||
assert.Equal(float64(3), x)
|
|
||||||
assert.Equal(float64(30), y)
|
|
||||||
bs.TickPosition = chart.TickPositionBetweenTicks
|
|
||||||
x, y = bs.GetLastValues()
|
|
||||||
assert.Equal(float64(3), x)
|
|
||||||
assert.Equal(float64(30), y)
|
|
||||||
|
|
||||||
xFormater, yFormater := bs.GetValueFormatters()
|
|
||||||
assert.Equal(reflect.ValueOf(chart.FloatValueFormatter).Pointer(), reflect.ValueOf(xFormater).Pointer())
|
|
||||||
assert.Equal(reflect.ValueOf(chart.FloatValueFormatter).Pointer(), reflect.ValueOf(yFormater).Pointer())
|
|
||||||
formater := func(v interface{}) string {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
bs.XValueFormatter = formater
|
|
||||||
bs.YValueFormatter = formater
|
|
||||||
xFormater, yFormater = bs.GetValueFormatters()
|
|
||||||
assert.Equal(reflect.ValueOf(formater).Pointer(), reflect.ValueOf(xFormater).Pointer())
|
|
||||||
assert.Equal(reflect.ValueOf(formater).Pointer(), reflect.ValueOf(yFormater).Pointer())
|
|
||||||
|
|
||||||
assert.Equal(chart.YAxisPrimary, bs.GetYAxis())
|
|
||||||
|
|
||||||
assert.Nil(bs.Validate())
|
|
||||||
}
|
|
||||||
426
chart_option.go
Normal file
426
chart_option.go
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChartOption struct {
|
||||||
|
theme ColorPalette
|
||||||
|
font *truetype.Font
|
||||||
|
// The output type of chart, "svg" or "png", default value is "svg"
|
||||||
|
Type string
|
||||||
|
// The font family, which should be installed first
|
||||||
|
FontFamily string
|
||||||
|
// The theme of chart, "light" and "dark".
|
||||||
|
// The default theme is "light"
|
||||||
|
Theme string
|
||||||
|
// The title option
|
||||||
|
Title TitleOption
|
||||||
|
// The legend option
|
||||||
|
Legend LegendOption
|
||||||
|
// The x axis option
|
||||||
|
XAxis XAxisOption
|
||||||
|
// The y axis option list
|
||||||
|
YAxisOptions []YAxisOption
|
||||||
|
// The width of chart, default width is 600
|
||||||
|
Width int
|
||||||
|
// The height of chart, default height is 400
|
||||||
|
Height int
|
||||||
|
Parent *Painter
|
||||||
|
// The padding for chart, default padding is [20, 10, 10, 10]
|
||||||
|
Padding Box
|
||||||
|
// The canvas box for chart
|
||||||
|
Box Box
|
||||||
|
// The series list
|
||||||
|
SeriesList SeriesList
|
||||||
|
// The radar indicator list
|
||||||
|
RadarIndicators []RadarIndicator
|
||||||
|
// The background color of chart
|
||||||
|
BackgroundColor Color
|
||||||
|
// The flag for show symbol of line, set this to *false will hide symbol
|
||||||
|
SymbolShow *bool
|
||||||
|
// The stroke width of line chart
|
||||||
|
LineStrokeWidth float64
|
||||||
|
// The bar with of bar chart
|
||||||
|
BarWidth int
|
||||||
|
// The margin of each bar
|
||||||
|
BarMargin int
|
||||||
|
// The bar height of horizontal bar chart
|
||||||
|
BarHeight int
|
||||||
|
// Fill the area of line chart
|
||||||
|
FillArea bool
|
||||||
|
// background fill (alpha) opacity
|
||||||
|
Opacity uint8
|
||||||
|
// The child charts
|
||||||
|
Children []ChartOption
|
||||||
|
// The value formatter
|
||||||
|
ValueFormatter ValueFormatter
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionFunc option function
|
||||||
|
type OptionFunc func(opt *ChartOption)
|
||||||
|
|
||||||
|
// SVGTypeOption set svg type of chart's output
|
||||||
|
func SVGTypeOption() OptionFunc {
|
||||||
|
return TypeOptionFunc(ChartOutputSVG)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PNGTypeOption set png type of chart's output
|
||||||
|
func PNGTypeOption() OptionFunc {
|
||||||
|
return TypeOptionFunc(ChartOutputPNG)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeOptionFunc set type of chart's output
|
||||||
|
func TypeOptionFunc(t string) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Type = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FontFamilyOptionFunc set font family of chart
|
||||||
|
func FontFamilyOptionFunc(fontFamily string) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.FontFamily = fontFamily
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemeOptionFunc set them of chart
|
||||||
|
func ThemeOptionFunc(theme string) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Theme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TitleOptionFunc set title of chart
|
||||||
|
func TitleOptionFunc(title TitleOption) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TitleTextOptionFunc set title text of chart
|
||||||
|
func TitleTextOptionFunc(text string, subtext ...string) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Title.Text = text
|
||||||
|
if len(subtext) != 0 {
|
||||||
|
opt.Title.Subtext = subtext[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegendOptionFunc set legend of chart
|
||||||
|
func LegendOptionFunc(legend LegendOption) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Legend = legend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegendLabelsOptionFunc set legend labels of chart
|
||||||
|
func LegendLabelsOptionFunc(labels []string, left ...string) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Legend = NewLegendOption(labels, left...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// XAxisOptionFunc set x axis of chart
|
||||||
|
func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.XAxis = xAxisOption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// XAxisDataOptionFunc set x axis data of chart
|
||||||
|
func XAxisDataOptionFunc(data []string, boundaryGap ...*bool) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.XAxis = NewXAxisOption(data, boundaryGap...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAxisOptionFunc set y axis of chart, support two y axis
|
||||||
|
func YAxisOptionFunc(yAxisOption ...YAxisOption) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.YAxisOptions = yAxisOption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAxisDataOptionFunc set y axis data of chart
|
||||||
|
func YAxisDataOptionFunc(data []string) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.YAxisOptions = NewYAxisOptions(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WidthOptionFunc set width of chart
|
||||||
|
func WidthOptionFunc(width int) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Width = width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeightOptionFunc set height of chart
|
||||||
|
func HeightOptionFunc(height int) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Height = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaddingOptionFunc set padding of chart
|
||||||
|
func PaddingOptionFunc(padding Box) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Padding = padding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoxOptionFunc set box of chart
|
||||||
|
func BoxOptionFunc(box Box) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.Box = box
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PieSeriesShowLabel set pie series show label
|
||||||
|
func PieSeriesShowLabel() OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
for index := range opt.SeriesList {
|
||||||
|
opt.SeriesList[index].Label.Show = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildOptionFunc add child chart
|
||||||
|
func ChildOptionFunc(child ...ChartOption) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
if opt.Children == nil {
|
||||||
|
opt.Children = make([]ChartOption, 0)
|
||||||
|
}
|
||||||
|
opt.Children = append(opt.Children, child...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RadarIndicatorOptionFunc set radar indicator of chart
|
||||||
|
func RadarIndicatorOptionFunc(names []string, values []float64) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.RadarIndicators = NewRadarIndicators(names, values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackgroundColorOptionFunc set background color of chart
|
||||||
|
func BackgroundColorOptionFunc(color Color) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
opt.BackgroundColor = color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkLineOptionFunc set mark line for series of chart
|
||||||
|
func MarkLineOptionFunc(seriesIndex int, markLineTypes ...string) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
if len(opt.SeriesList) <= seriesIndex {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opt.SeriesList[seriesIndex].MarkLine = NewMarkLine(markLineTypes...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPointOptionFunc set mark point for series of chart
|
||||||
|
func MarkPointOptionFunc(seriesIndex int, markPointTypes ...string) OptionFunc {
|
||||||
|
return func(opt *ChartOption) {
|
||||||
|
if len(opt.SeriesList) <= seriesIndex {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opt.SeriesList[seriesIndex].MarkPoint = NewMarkPoint(markPointTypes...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *ChartOption) fillDefault() {
|
||||||
|
t := NewTheme(o.Theme)
|
||||||
|
o.theme = t
|
||||||
|
// 如果为空,初始化
|
||||||
|
axisCount := 1
|
||||||
|
for _, series := range o.SeriesList {
|
||||||
|
if series.AxisIndex >= axisCount {
|
||||||
|
axisCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.Width = getDefaultInt(o.Width, defaultChartWidth)
|
||||||
|
o.Height = getDefaultInt(o.Height, defaultChartHeight)
|
||||||
|
yAxisOptions := make([]YAxisOption, axisCount)
|
||||||
|
copy(yAxisOptions, o.YAxisOptions)
|
||||||
|
o.YAxisOptions = yAxisOptions
|
||||||
|
o.font, _ = GetFont(o.FontFamily)
|
||||||
|
|
||||||
|
if o.font == nil {
|
||||||
|
o.font, _ = GetDefaultFont()
|
||||||
|
} else {
|
||||||
|
// 如果指定了字体,则设置主题的字体
|
||||||
|
t.SetFont(o.font)
|
||||||
|
}
|
||||||
|
if o.BackgroundColor.IsZero() {
|
||||||
|
o.BackgroundColor = t.GetBackgroundColor()
|
||||||
|
}
|
||||||
|
if o.Padding.IsZero() {
|
||||||
|
o.Padding = Box{
|
||||||
|
Top: 20,
|
||||||
|
Right: 20,
|
||||||
|
Bottom: 20,
|
||||||
|
Left: 20,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// legend与series name的关联
|
||||||
|
if len(o.Legend.Data) == 0 {
|
||||||
|
o.Legend.Data = o.SeriesList.Names()
|
||||||
|
} else {
|
||||||
|
seriesCount := len(o.SeriesList)
|
||||||
|
for index, name := range o.Legend.Data {
|
||||||
|
if index < seriesCount &&
|
||||||
|
len(o.SeriesList[index].Name) == 0 {
|
||||||
|
o.SeriesList[index].Name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nameIndexDict := map[string]int{}
|
||||||
|
for index, name := range o.Legend.Data {
|
||||||
|
nameIndexDict[name] = index
|
||||||
|
}
|
||||||
|
// 保证series的顺序与legend一致
|
||||||
|
sort.Slice(o.SeriesList, func(i, j int) bool {
|
||||||
|
return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LineRender line chart render
|
||||||
|
func LineRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
|
||||||
|
seriesList := NewSeriesListDataFromValues(values, ChartTypeLine)
|
||||||
|
return Render(ChartOption{
|
||||||
|
SeriesList: seriesList,
|
||||||
|
}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BarRender bar chart render
|
||||||
|
func BarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
|
||||||
|
seriesList := NewSeriesListDataFromValues(values, ChartTypeBar)
|
||||||
|
return Render(ChartOption{
|
||||||
|
SeriesList: seriesList,
|
||||||
|
}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HorizontalBarRender horizontal bar chart render
|
||||||
|
func HorizontalBarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
|
||||||
|
seriesList := NewSeriesListDataFromValues(values, ChartTypeHorizontalBar)
|
||||||
|
return Render(ChartOption{
|
||||||
|
SeriesList: seriesList,
|
||||||
|
}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PieRender pie chart render
|
||||||
|
func PieRender(values []float64, opts ...OptionFunc) (*Painter, error) {
|
||||||
|
return Render(ChartOption{
|
||||||
|
SeriesList: NewPieSeriesList(values),
|
||||||
|
}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RadarRender radar chart render
|
||||||
|
func RadarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
|
||||||
|
seriesList := NewSeriesListDataFromValues(values, ChartTypeRadar)
|
||||||
|
return Render(ChartOption{
|
||||||
|
SeriesList: seriesList,
|
||||||
|
}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunnelRender funnel chart render
|
||||||
|
func FunnelRender(values []float64, opts ...OptionFunc) (*Painter, error) {
|
||||||
|
seriesList := NewFunnelSeriesList(values)
|
||||||
|
return Render(ChartOption{
|
||||||
|
SeriesList: seriesList,
|
||||||
|
}, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableRender table chart render
|
||||||
|
func TableRender(header []string, data [][]string, spanMaps ...map[int]int) (*Painter, error) {
|
||||||
|
opt := TableChartOption{
|
||||||
|
Header: header,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
if len(spanMaps) != 0 {
|
||||||
|
spanMap := spanMaps[0]
|
||||||
|
spans := make([]int, len(opt.Header))
|
||||||
|
for index := range spans {
|
||||||
|
v, ok := spanMap[index]
|
||||||
|
if !ok {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
spans[index] = v
|
||||||
|
}
|
||||||
|
opt.Spans = spans
|
||||||
|
}
|
||||||
|
return TableOptionRender(opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableOptionRender table render with option
|
||||||
|
func TableOptionRender(opt TableChartOption) (*Painter, error) {
|
||||||
|
if opt.Type == "" {
|
||||||
|
opt.Type = ChartOutputPNG
|
||||||
|
}
|
||||||
|
if opt.Width <= 0 {
|
||||||
|
opt.Width = defaultChartWidth
|
||||||
|
}
|
||||||
|
if opt.FontFamily != "" {
|
||||||
|
opt.Font, _ = GetFont(opt.FontFamily)
|
||||||
|
}
|
||||||
|
if opt.Font == nil {
|
||||||
|
opt.Font, _ = GetDefaultFont()
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := NewPainter(PainterOptions{
|
||||||
|
Type: opt.Type,
|
||||||
|
Width: opt.Width,
|
||||||
|
// 仅用于计算表格高度,因此随便设置即可
|
||||||
|
Height: 100,
|
||||||
|
Font: opt.Font,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := NewTableChart(p, opt).render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err = NewPainter(PainterOptions{
|
||||||
|
Type: opt.Type,
|
||||||
|
Width: info.Width,
|
||||||
|
Height: info.Height,
|
||||||
|
Font: opt.Font,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, err = NewTableChart(p, opt).renderWithInfo(info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
451
chart_option_test.go
Normal file
451
chart_option_test.go
Normal file
File diff suppressed because one or more lines are too long
632
charts.go
632
charts.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,265 +23,451 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"math"
|
||||||
"sync"
|
"sort"
|
||||||
|
|
||||||
"github.com/golang/freetype/truetype"
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const labelFontSize = 10
|
||||||
ThemeLight = "light"
|
const smallLabelFontSize = 8
|
||||||
ThemeDark = "dark"
|
const defaultDotWidth = 2.0
|
||||||
)
|
const defaultStrokeWidth = 2.0
|
||||||
|
|
||||||
const (
|
var defaultChartWidth = 600
|
||||||
DefaultChartWidth = 800
|
var defaultChartHeight = 400
|
||||||
DefaultChartHeight = 400
|
|
||||||
)
|
|
||||||
|
|
||||||
type (
|
// SetDefaultWidth sets default width of chart
|
||||||
Title struct {
|
func SetDefaultWidth(width int) {
|
||||||
Text string
|
if width > 0 {
|
||||||
Style chart.Style
|
defaultChartWidth = width
|
||||||
Font *truetype.Font
|
|
||||||
Left string
|
|
||||||
Top string
|
|
||||||
}
|
}
|
||||||
Legend struct {
|
|
||||||
Data []string
|
|
||||||
Align string
|
|
||||||
Padding chart.Box
|
|
||||||
Left string
|
|
||||||
Right string
|
|
||||||
Top string
|
|
||||||
Bottom string
|
|
||||||
}
|
|
||||||
Options struct {
|
|
||||||
Padding chart.Box
|
|
||||||
Width int
|
|
||||||
Height int
|
|
||||||
Theme string
|
|
||||||
XAxis XAxis
|
|
||||||
YAxisOptions []*YAxisOption
|
|
||||||
Series []Series
|
|
||||||
Title Title
|
|
||||||
Legend Legend
|
|
||||||
TickPosition chart.TickPosition
|
|
||||||
Log chart.Logger
|
|
||||||
Font *truetype.Font
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
// SetDefaultHeight sets default height of chart
|
||||||
func GetFont(fontFamily string) (*truetype.Font, error) {
|
func SetDefaultHeight(height int) {
|
||||||
value, ok := fonts.Load(fontFamily)
|
if height > 0 {
|
||||||
if !ok {
|
defaultChartHeight = height
|
||||||
return nil, ErrFontNotExists
|
|
||||||
}
|
}
|
||||||
f, ok := value.(*truetype.Font)
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrFontNotExists
|
|
||||||
}
|
|
||||||
return f, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Graph interface {
|
var nullValue = math.MaxFloat64
|
||||||
Render(rp chart.RendererProvider, w io.Writer) error
|
|
||||||
|
// SetNullValue sets the null value, default is MaxFloat64
|
||||||
|
func SetNullValue(v float64) {
|
||||||
|
nullValue = v
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Options) validate() error {
|
// GetNullValue gets the null value
|
||||||
if len(o.Series) == 0 {
|
func GetNullValue() float64 {
|
||||||
return errors.New("series can not be empty")
|
return nullValue
|
||||||
}
|
}
|
||||||
xAxisCount := len(o.XAxis.Data)
|
|
||||||
|
|
||||||
for _, item := range o.Series {
|
type Renderer interface {
|
||||||
if item.Type != SeriesPie && len(item.Data) != xAxisCount {
|
Render() (Box, error)
|
||||||
return errors.New("series and xAxis is not matched")
|
}
|
||||||
|
|
||||||
|
type renderHandler struct {
|
||||||
|
list []func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rh *renderHandler) Add(fn func() error) {
|
||||||
|
list := rh.list
|
||||||
|
if len(list) == 0 {
|
||||||
|
list = make([]func() error, 0)
|
||||||
|
}
|
||||||
|
rh.list = append(list, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rh *renderHandler) Do() error {
|
||||||
|
for _, fn := range rh.list {
|
||||||
|
err := fn()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *Options) getWidth() int {
|
type defaultRenderOption struct {
|
||||||
width := o.Width
|
Theme ColorPalette
|
||||||
if width <= 0 {
|
Padding Box
|
||||||
width = DefaultChartWidth
|
SeriesList SeriesList
|
||||||
|
// The y axis option
|
||||||
|
YAxisOptions []YAxisOption
|
||||||
|
// The x axis option
|
||||||
|
XAxis XAxisOption
|
||||||
|
// The title option
|
||||||
|
TitleOption TitleOption
|
||||||
|
// The legend option
|
||||||
|
LegendOption LegendOption
|
||||||
|
// background is filled
|
||||||
|
backgroundIsFilled bool
|
||||||
|
// x y axis is reversed
|
||||||
|
axisReversed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type defaultRenderResult struct {
|
||||||
|
axisRanges map[int]axisRange
|
||||||
|
// 图例区域
|
||||||
|
seriesPainter *Painter
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, error) {
|
||||||
|
seriesList := opt.SeriesList
|
||||||
|
seriesList.init()
|
||||||
|
if !opt.backgroundIsFilled {
|
||||||
|
p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor())
|
||||||
}
|
}
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Options) getHeight() int {
|
if !opt.Padding.IsZero() {
|
||||||
height := o.Height
|
p = p.Child(PainterPaddingOption(opt.Padding))
|
||||||
if height <= 0 {
|
|
||||||
height = DefaultChartHeight
|
|
||||||
}
|
}
|
||||||
return height
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Options) getBackground() chart.Style {
|
legendHeight := 0
|
||||||
bg := chart.Style{
|
if len(opt.LegendOption.Data) != 0 {
|
||||||
Padding: o.Padding,
|
if opt.LegendOption.Theme == nil {
|
||||||
}
|
opt.LegendOption.Theme = opt.Theme
|
||||||
return bg
|
|
||||||
}
|
|
||||||
|
|
||||||
func render(g Graph, rp chart.RendererProvider) ([]byte, error) {
|
|
||||||
buf := bytes.Buffer{}
|
|
||||||
err := g.Render(rp, &buf)
|
|
||||||
if err != nil {
|
|
||||||
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 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,
|
|
||||||
}
|
}
|
||||||
}
|
legendResult, err := NewLegendPainter(p, opt.LegendOption).Render()
|
||||||
p := &chart.PieChart{
|
if err != nil {
|
||||||
Font: opt.Font,
|
return nil, err
|
||||||
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"
|
|
||||||
}
|
|
||||||
titleColor := drawing.ColorBlack
|
|
||||||
if opt.Theme == ThemeDark {
|
|
||||||
titleColor = drawing.ColorWhite
|
|
||||||
}
|
|
||||||
titleRender := newTitleRenderable(opt.Title, p.GetFont(), titleColor)
|
|
||||||
if titleRender != nil {
|
|
||||||
p.Elements = []chart.Renderable{
|
|
||||||
titleRender,
|
|
||||||
}
|
}
|
||||||
|
legendHeight = legendResult.Height()
|
||||||
}
|
}
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func newChart(opt Options) *chart.Chart {
|
// 如果有标题
|
||||||
tickPosition := opt.TickPosition
|
if opt.TitleOption.Text != "" {
|
||||||
|
if opt.TitleOption.Theme == nil {
|
||||||
xAxis, xValues := GetXAxisAndValues(opt.XAxis, tickPosition, opt.Theme)
|
opt.TitleOption.Theme = 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 == "" {
|
titlePainter := NewTitlePainter(p, opt.TitleOption)
|
||||||
opt.Series[index].Name = opt.Legend.Data[index]
|
|
||||||
|
titleBox, err := titlePainter.Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var secondaryYAxisOption *YAxisOption
|
top := chart.MaxInt(legendHeight, titleBox.Height())
|
||||||
if len(opt.YAxisOptions) != 0 {
|
// 如果是垂直方式,则不计算legend高度
|
||||||
secondaryYAxisOption = opt.YAxisOptions[0]
|
if opt.LegendOption.Orient == OrientVertical {
|
||||||
}
|
top = titleBox.Height()
|
||||||
|
}
|
||||||
yAxisOption := &YAxisOption{
|
p = p.Child(PainterPaddingOption(Box{
|
||||||
Disabled: true,
|
// 标题下留白
|
||||||
}
|
Top: top + 20,
|
||||||
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) {
|
result := defaultRenderResult{
|
||||||
err := opt.validate()
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if opt.Series[0].Type == SeriesPie {
|
|
||||||
return newPieChart(opt), nil
|
result.seriesPainter = p.Child(PainterPaddingOption(Box{
|
||||||
|
Bottom: defaultXAxisHeight,
|
||||||
|
Left: rangeWidthLeft,
|
||||||
|
Right: rangeWidthRight,
|
||||||
|
}))
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func doRender(renderers ...Renderer) error {
|
||||||
|
for _, r := range renderers {
|
||||||
|
_, err := r.Render()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) {
|
||||||
|
for _, fn := range opts {
|
||||||
|
fn(&opt)
|
||||||
|
}
|
||||||
|
opt.fillDefault()
|
||||||
|
|
||||||
|
isChild := true
|
||||||
|
if opt.Parent == nil {
|
||||||
|
isChild = false
|
||||||
|
p, err := NewPainter(PainterOptions{
|
||||||
|
Type: opt.Type,
|
||||||
|
Width: opt.Width,
|
||||||
|
Height: opt.Height,
|
||||||
|
Font: opt.font,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opt.Parent = p
|
||||||
|
}
|
||||||
|
p := opt.Parent
|
||||||
|
if opt.ValueFormatter != nil {
|
||||||
|
p.valueFormatter = opt.ValueFormatter
|
||||||
|
}
|
||||||
|
if !opt.Box.IsZero() {
|
||||||
|
p = p.Child(PainterBoxOption(opt.Box))
|
||||||
|
}
|
||||||
|
if !isChild {
|
||||||
|
p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
|
||||||
|
}
|
||||||
|
seriesList := opt.SeriesList
|
||||||
|
seriesList.init()
|
||||||
|
|
||||||
|
seriesCount := len(seriesList)
|
||||||
|
|
||||||
|
// line chart
|
||||||
|
lineSeriesList := seriesList.Filter(ChartTypeLine)
|
||||||
|
barSeriesList := seriesList.Filter(ChartTypeBar)
|
||||||
|
horizontalBarSeriesList := seriesList.Filter(ChartTypeHorizontalBar)
|
||||||
|
pieSeriesList := seriesList.Filter(ChartTypePie)
|
||||||
|
radarSeriesList := seriesList.Filter(ChartTypeRadar)
|
||||||
|
funnelSeriesList := seriesList.Filter(ChartTypeFunnel)
|
||||||
|
|
||||||
|
if len(horizontalBarSeriesList) != 0 && len(horizontalBarSeriesList) != seriesCount {
|
||||||
|
return nil, errors.New("Horizontal bar can not mix other charts")
|
||||||
|
}
|
||||||
|
if len(pieSeriesList) != 0 && len(pieSeriesList) != seriesCount {
|
||||||
|
return nil, errors.New("Pie can not mix other charts")
|
||||||
|
}
|
||||||
|
if len(radarSeriesList) != 0 && len(radarSeriesList) != seriesCount {
|
||||||
|
return nil, errors.New("Radar can not mix other charts")
|
||||||
|
}
|
||||||
|
if len(funnelSeriesList) != 0 && len(funnelSeriesList) != seriesCount {
|
||||||
|
return nil, errors.New("Funnel can not mix other charts")
|
||||||
}
|
}
|
||||||
|
|
||||||
return newChart(opt), nil
|
axisReversed := len(horizontalBarSeriesList) != 0
|
||||||
|
renderOpt := defaultRenderOption{
|
||||||
|
Theme: opt.theme,
|
||||||
|
Padding: opt.Padding,
|
||||||
|
SeriesList: opt.SeriesList,
|
||||||
|
XAxis: opt.XAxis,
|
||||||
|
YAxisOptions: opt.YAxisOptions,
|
||||||
|
TitleOption: opt.Title,
|
||||||
|
LegendOption: opt.Legend,
|
||||||
|
axisReversed: axisReversed,
|
||||||
|
// 前置已设置背景色
|
||||||
|
backgroundIsFilled: true,
|
||||||
|
}
|
||||||
|
if len(pieSeriesList) != 0 ||
|
||||||
|
len(radarSeriesList) != 0 ||
|
||||||
|
len(funnelSeriesList) != 0 {
|
||||||
|
renderOpt.XAxis.Show = FalseFlag()
|
||||||
|
renderOpt.YAxisOptions = []YAxisOption{
|
||||||
|
{
|
||||||
|
Show: FalseFlag(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(horizontalBarSeriesList) != 0 {
|
||||||
|
renderOpt.YAxisOptions[0].DivideCount = len(renderOpt.YAxisOptions[0].Data)
|
||||||
|
renderOpt.YAxisOptions[0].Unit = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
renderResult, err := defaultRender(p, renderOpt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := renderHandler{}
|
||||||
|
|
||||||
|
// bar chart
|
||||||
|
if len(barSeriesList) != 0 {
|
||||||
|
handler.Add(func() error {
|
||||||
|
_, err := NewBarChart(p, BarChartOption{
|
||||||
|
Theme: opt.theme,
|
||||||
|
Font: opt.font,
|
||||||
|
XAxis: opt.XAxis,
|
||||||
|
BarWidth: opt.BarWidth,
|
||||||
|
BarMargin: opt.BarMargin,
|
||||||
|
}).render(renderResult, barSeriesList)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// horizontal bar chart
|
||||||
|
if len(horizontalBarSeriesList) != 0 {
|
||||||
|
handler.Add(func() error {
|
||||||
|
_, err := NewHorizontalBarChart(p, HorizontalBarChartOption{
|
||||||
|
Theme: opt.theme,
|
||||||
|
Font: opt.font,
|
||||||
|
BarHeight: opt.BarHeight,
|
||||||
|
BarMargin: opt.BarMargin,
|
||||||
|
YAxisOptions: opt.YAxisOptions,
|
||||||
|
}).render(renderResult, horizontalBarSeriesList)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pie chart
|
||||||
|
if len(pieSeriesList) != 0 {
|
||||||
|
handler.Add(func() error {
|
||||||
|
_, err := NewPieChart(p, PieChartOption{
|
||||||
|
Theme: opt.theme,
|
||||||
|
Font: opt.font,
|
||||||
|
}).render(renderResult, pieSeriesList)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// line chart
|
||||||
|
if len(lineSeriesList) != 0 {
|
||||||
|
handler.Add(func() error {
|
||||||
|
_, err := NewLineChart(p, LineChartOption{
|
||||||
|
Theme: opt.theme,
|
||||||
|
Font: opt.font,
|
||||||
|
XAxis: opt.XAxis,
|
||||||
|
SymbolShow: opt.SymbolShow,
|
||||||
|
StrokeWidth: opt.LineStrokeWidth,
|
||||||
|
FillArea: opt.FillArea,
|
||||||
|
Opacity: opt.Opacity,
|
||||||
|
}).render(renderResult, lineSeriesList)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// radar chart
|
||||||
|
if len(radarSeriesList) != 0 {
|
||||||
|
handler.Add(func() error {
|
||||||
|
_, err := NewRadarChart(p, RadarChartOption{
|
||||||
|
Theme: opt.theme,
|
||||||
|
Font: opt.font,
|
||||||
|
// 相应值
|
||||||
|
RadarIndicators: opt.RadarIndicators,
|
||||||
|
}).render(renderResult, radarSeriesList)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// funnel chart
|
||||||
|
if len(funnelSeriesList) != 0 {
|
||||||
|
handler.Add(func() error {
|
||||||
|
_, err := NewFunnelChart(p, FunnelChartOption{
|
||||||
|
Theme: opt.theme,
|
||||||
|
Font: opt.font,
|
||||||
|
}).render(renderResult, funnelSeriesList)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.Do()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, item := range opt.Children {
|
||||||
|
item.Parent = p
|
||||||
|
if item.Theme == "" {
|
||||||
|
item.Theme = opt.Theme
|
||||||
|
}
|
||||||
|
if item.FontFamily == "" {
|
||||||
|
item.FontFamily = opt.FontFamily
|
||||||
|
}
|
||||||
|
_, err = Render(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
315
charts_test.go
315
charts_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -26,131 +26,230 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
"github.com/wcharczuk/go-chart/v2/roboto"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFont(t *testing.T) {
|
func BenchmarkMultiChartPNGRender(b *testing.B) {
|
||||||
assert := assert.New(t)
|
for i := 0; i < b.N; i++ {
|
||||||
|
opt := ChartOption{
|
||||||
fontFamily := "roboto"
|
Type: ChartOutputPNG,
|
||||||
err := InstallFont(fontFamily, roboto.Roboto)
|
Legend: LegendOption{
|
||||||
assert.Nil(err)
|
Top: "-90",
|
||||||
|
Data: []string{
|
||||||
font, err := GetFont(fontFamily)
|
"Milk Tea",
|
||||||
assert.Nil(err)
|
"Matcha Latte",
|
||||||
assert.NotNil(font)
|
"Cheese Cocoa",
|
||||||
}
|
"Walnut Brownie",
|
||||||
|
|
||||||
func TestChartsOptions(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
o := Options{}
|
|
||||||
|
|
||||||
assert.Equal(errors.New("series can not be empty"), o.validate())
|
|
||||||
|
|
||||||
o.Series = []Series{
|
|
||||||
{
|
|
||||||
Data: []SeriesData{
|
|
||||||
{
|
|
||||||
Value: 1,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
Padding: chart.Box{
|
||||||
}
|
Top: 100,
|
||||||
assert.Equal(errors.New("series and xAxis is not matched"), o.validate())
|
Right: 10,
|
||||||
o.XAxis.Data = []string{
|
Bottom: 10,
|
||||||
"1",
|
Left: 10,
|
||||||
}
|
},
|
||||||
assert.Nil(o.validate())
|
XAxis: NewXAxisOption([]string{
|
||||||
|
"2012",
|
||||||
assert.Equal(DefaultChartWidth, o.getWidth())
|
"2013",
|
||||||
o.Width = 10
|
"2014",
|
||||||
assert.Equal(10, o.getWidth())
|
"2015",
|
||||||
|
"2016",
|
||||||
assert.Equal(DefaultChartHeight, o.getHeight())
|
"2017",
|
||||||
o.Height = 10
|
}),
|
||||||
assert.Equal(10, o.getHeight())
|
YAxisOptions: []YAxisOption{
|
||||||
|
|
||||||
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,
|
|
||||||
|
Min: NewFloatPoint(0),
|
||||||
|
Max: NewFloatPoint(90),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Name: "chrome",
|
SeriesList: []Series{
|
||||||
},
|
NewSeriesFromValues([]float64{
|
||||||
{
|
56.5,
|
||||||
Data: []SeriesData{
|
82.1,
|
||||||
|
88.7,
|
||||||
|
70.1,
|
||||||
|
53.4,
|
||||||
|
85.1,
|
||||||
|
}),
|
||||||
|
NewSeriesFromValues([]float64{
|
||||||
|
51.1,
|
||||||
|
51.4,
|
||||||
|
55.1,
|
||||||
|
53.3,
|
||||||
|
73.8,
|
||||||
|
68.7,
|
||||||
|
}),
|
||||||
|
NewSeriesFromValues([]float64{
|
||||||
|
40.1,
|
||||||
|
62.2,
|
||||||
|
69.5,
|
||||||
|
36.4,
|
||||||
|
45.2,
|
||||||
|
32.5,
|
||||||
|
}, ChartTypeBar),
|
||||||
|
NewSeriesFromValues([]float64{
|
||||||
|
25.2,
|
||||||
|
37.1,
|
||||||
|
41.2,
|
||||||
|
18,
|
||||||
|
33.9,
|
||||||
|
49.1,
|
||||||
|
}, ChartTypeBar),
|
||||||
|
},
|
||||||
|
Children: []ChartOption{
|
||||||
{
|
{
|
||||||
Value: 2,
|
Legend: LegendOption{
|
||||||
|
Show: FalseFlag(),
|
||||||
|
Data: []string{
|
||||||
|
"Milk Tea",
|
||||||
|
"Matcha Latte",
|
||||||
|
"Cheese Cocoa",
|
||||||
|
"Walnut Brownie",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Box: chart.Box{
|
||||||
|
Top: 20,
|
||||||
|
Left: 400,
|
||||||
|
Right: 500,
|
||||||
|
Bottom: 120,
|
||||||
|
},
|
||||||
|
SeriesList: NewPieSeriesList([]float64{
|
||||||
|
435.9,
|
||||||
|
354.3,
|
||||||
|
285.9,
|
||||||
|
204.5,
|
||||||
|
}, PieSeriesOption{
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Show: true,
|
||||||
|
},
|
||||||
|
Radius: "35%",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Name: "edge",
|
}
|
||||||
},
|
d, err := Render(opt)
|
||||||
}
|
if err != nil {
|
||||||
pie := newPieChart(Options{
|
panic(err)
|
||||||
Series: data,
|
}
|
||||||
})
|
buf, err := d.Bytes()
|
||||||
for index, item := range pie.Values {
|
if err != nil {
|
||||||
assert.Equal(data[index].Name, item.Label)
|
panic(err)
|
||||||
assert.Equal(data[index].Data[0].Value, item.Value)
|
}
|
||||||
|
if len(buf) == 0 {
|
||||||
|
panic(errors.New("data is nil"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewChart(t *testing.T) {
|
func BenchmarkMultiChartSVGRender(b *testing.B) {
|
||||||
assert := assert.New(t)
|
for i := 0; i < b.N; i++ {
|
||||||
|
opt := ChartOption{
|
||||||
|
Legend: LegendOption{
|
||||||
|
Top: "-90",
|
||||||
|
Data: []string{
|
||||||
|
"Milk Tea",
|
||||||
|
"Matcha Latte",
|
||||||
|
"Cheese Cocoa",
|
||||||
|
"Walnut Brownie",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Padding: chart.Box{
|
||||||
|
Top: 100,
|
||||||
|
Right: 10,
|
||||||
|
Bottom: 10,
|
||||||
|
Left: 10,
|
||||||
|
},
|
||||||
|
XAxis: NewXAxisOption([]string{
|
||||||
|
"2012",
|
||||||
|
"2013",
|
||||||
|
"2014",
|
||||||
|
"2015",
|
||||||
|
"2016",
|
||||||
|
"2017",
|
||||||
|
}),
|
||||||
|
YAxisOptions: []YAxisOption{
|
||||||
|
{
|
||||||
|
|
||||||
data := []Series{
|
Min: NewFloatPoint(0),
|
||||||
{
|
Max: NewFloatPoint(90),
|
||||||
Data: []SeriesData{
|
|
||||||
{
|
|
||||||
Value: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Value: 20,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Name: "chrome",
|
SeriesList: []Series{
|
||||||
},
|
NewSeriesFromValues([]float64{
|
||||||
{
|
56.5,
|
||||||
Data: []SeriesData{
|
82.1,
|
||||||
|
88.7,
|
||||||
|
70.1,
|
||||||
|
53.4,
|
||||||
|
85.1,
|
||||||
|
}),
|
||||||
|
NewSeriesFromValues([]float64{
|
||||||
|
51.1,
|
||||||
|
51.4,
|
||||||
|
55.1,
|
||||||
|
53.3,
|
||||||
|
73.8,
|
||||||
|
68.7,
|
||||||
|
}),
|
||||||
|
NewSeriesFromValues([]float64{
|
||||||
|
40.1,
|
||||||
|
62.2,
|
||||||
|
69.5,
|
||||||
|
36.4,
|
||||||
|
45.2,
|
||||||
|
32.5,
|
||||||
|
}, ChartTypeBar),
|
||||||
|
NewSeriesFromValues([]float64{
|
||||||
|
25.2,
|
||||||
|
37.1,
|
||||||
|
41.2,
|
||||||
|
18,
|
||||||
|
33.9,
|
||||||
|
49.1,
|
||||||
|
}, ChartTypeBar),
|
||||||
|
},
|
||||||
|
Children: []ChartOption{
|
||||||
{
|
{
|
||||||
Value: 2,
|
Legend: LegendOption{
|
||||||
},
|
Show: FalseFlag(),
|
||||||
{
|
Data: []string{
|
||||||
Value: 3,
|
"Milk Tea",
|
||||||
|
"Matcha Latte",
|
||||||
|
"Cheese Cocoa",
|
||||||
|
"Walnut Brownie",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Box: chart.Box{
|
||||||
|
Top: 20,
|
||||||
|
Left: 400,
|
||||||
|
Right: 500,
|
||||||
|
Bottom: 120,
|
||||||
|
},
|
||||||
|
SeriesList: NewPieSeriesList([]float64{
|
||||||
|
435.9,
|
||||||
|
354.3,
|
||||||
|
285.9,
|
||||||
|
204.5,
|
||||||
|
}, PieSeriesOption{
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Show: true,
|
||||||
|
},
|
||||||
|
Radius: "35%",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Name: "edge",
|
}
|
||||||
},
|
d, err := Render(opt)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
buf, err := d.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if len(buf) == 0 {
|
||||||
|
panic(errors.New("data is nil"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c := newChart(Options{
|
|
||||||
Series: data,
|
|
||||||
})
|
|
||||||
assert.Empty(c.Elements)
|
|
||||||
for index, series := range c.Series {
|
|
||||||
assert.Equal(data[index].Name, series.GetName())
|
|
||||||
}
|
|
||||||
|
|
||||||
c = newChart(Options{
|
|
||||||
Legend: Legend{
|
|
||||||
Data: []string{
|
|
||||||
"chrome",
|
|
||||||
"edge",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
assert.Equal(1, len(c.Elements))
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
588
echarts.go
588
echarts.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -28,21 +28,10 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EChartStyle struct {
|
|
||||||
Color string `json:"color"`
|
|
||||||
}
|
|
||||||
type ECharsSeriesData struct {
|
|
||||||
Value float64 `json:"value"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
ItemStyle EChartStyle `json:"itemStyle"`
|
|
||||||
}
|
|
||||||
type _ECharsSeriesData ECharsSeriesData
|
|
||||||
|
|
||||||
func convertToArray(data []byte) []byte {
|
func convertToArray(data []byte) []byte {
|
||||||
data = bytes.TrimSpace(data)
|
data = bytes.TrimSpace(data)
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
|
|
@ -54,20 +43,79 @@ func convertToArray(data []byte) []byte {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
|
type EChartsPosition string
|
||||||
data = bytes.TrimSpace(data)
|
|
||||||
|
func (p *EChartsPosition) UnmarshalJSON(data []byte) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if regexp.MustCompile(`^\d+`).Match(data) {
|
if regexp.MustCompile(`^\d+`).Match(data) {
|
||||||
|
data = []byte(fmt.Sprintf(`"%s"`, string(data)))
|
||||||
|
}
|
||||||
|
s := (*string)(p)
|
||||||
|
return json.Unmarshal(data, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartStyle struct {
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (es *EChartStyle) ToStyle() Style {
|
||||||
|
color := parseColor(es.Color)
|
||||||
|
return Style{
|
||||||
|
FillColor: color,
|
||||||
|
FontColor: color,
|
||||||
|
StrokeColor: color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartsSeriesDataValue struct {
|
||||||
|
values []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *EChartsSeriesDataValue) UnmarshalJSON(data []byte) error {
|
||||||
|
data = convertToArray(data)
|
||||||
|
return json.Unmarshal(data, &value.values)
|
||||||
|
}
|
||||||
|
func (value *EChartsSeriesDataValue) First() float64 {
|
||||||
|
if len(value.values) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value.values[0]
|
||||||
|
}
|
||||||
|
func NewEChartsSeriesDataValue(values ...float64) EChartsSeriesDataValue {
|
||||||
|
return EChartsSeriesDataValue{
|
||||||
|
values: values,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartsSeriesData struct {
|
||||||
|
Value EChartsSeriesDataValue `json:"value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ItemStyle EChartStyle `json:"itemStyle"`
|
||||||
|
}
|
||||||
|
type _EChartsSeriesData EChartsSeriesData
|
||||||
|
|
||||||
|
var numericRep = regexp.MustCompile(`^[-+]?[0-9]+(?:\.[0-9]+)?$`)
|
||||||
|
|
||||||
|
func (es *EChartsSeriesData) UnmarshalJSON(data []byte) error {
|
||||||
|
data = bytes.TrimSpace(data)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if numericRep.Match(data) {
|
||||||
v, err := strconv.ParseFloat(string(data), 64)
|
v, err := strconv.ParseFloat(string(data), 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
es.Value = v
|
es.Value = EChartsSeriesDataValue{
|
||||||
|
values: []float64{
|
||||||
|
v,
|
||||||
|
},
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
v := _ECharsSeriesData{}
|
v := _EChartsSeriesData{}
|
||||||
err := json.Unmarshal(data, &v)
|
err := json.Unmarshal(data, &v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -78,24 +126,55 @@ func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type EChartsPadding struct {
|
type EChartsXAxisData struct {
|
||||||
box chart.Box
|
BoundaryGap *bool `json:"boundaryGap"`
|
||||||
|
SplitNumber int `json:"splitNumber"`
|
||||||
|
Data []string `json:"data"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
type EChartsXAxis struct {
|
||||||
|
Data []EChartsXAxisData
|
||||||
}
|
}
|
||||||
|
|
||||||
type Position string
|
func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error {
|
||||||
|
data = convertToArray(data)
|
||||||
func (lp *Position) UnmarshalJSON(data []byte) error {
|
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if regexp.MustCompile(`^\d+`).Match(data) {
|
return json.Unmarshal(data, &ex.Data)
|
||||||
data = []byte(fmt.Sprintf(`"%s"`, string(data)))
|
|
||||||
}
|
|
||||||
s := (*string)(lp)
|
|
||||||
return json.Unmarshal(data, s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
|
type EChartsAxisLabel struct {
|
||||||
|
Formatter string `json:"formatter"`
|
||||||
|
}
|
||||||
|
type EChartsYAxisData struct {
|
||||||
|
Min *float64 `json:"min"`
|
||||||
|
Max *float64 `json:"max"`
|
||||||
|
AxisLabel EChartsAxisLabel `json:"axisLabel"`
|
||||||
|
AxisLine struct {
|
||||||
|
LineStyle struct {
|
||||||
|
Color string `json:"color"`
|
||||||
|
} `json:"lineStyle"`
|
||||||
|
} `json:"axisLine"`
|
||||||
|
Data []string `json:"data"`
|
||||||
|
}
|
||||||
|
type EChartsYAxis struct {
|
||||||
|
Data []EChartsYAxisData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error {
|
||||||
|
data = convertToArray(data)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &ey.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartsPadding struct {
|
||||||
|
Box chart.Box
|
||||||
|
}
|
||||||
|
|
||||||
|
func (eb *EChartsPadding) UnmarshalJSON(data []byte) error {
|
||||||
data = convertToArray(data)
|
data = convertToArray(data)
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -110,14 +189,14 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
|
||||||
}
|
}
|
||||||
switch len(arr) {
|
switch len(arr) {
|
||||||
case 1:
|
case 1:
|
||||||
ep.box = chart.Box{
|
eb.Box = chart.Box{
|
||||||
Left: arr[0],
|
Left: arr[0],
|
||||||
Top: arr[0],
|
Top: arr[0],
|
||||||
Bottom: arr[0],
|
Bottom: arr[0],
|
||||||
Right: arr[0],
|
Right: arr[0],
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
ep.box = chart.Box{
|
eb.Box = chart.Box{
|
||||||
Top: arr[0],
|
Top: arr[0],
|
||||||
Bottom: arr[0],
|
Bottom: arr[0],
|
||||||
Left: arr[1],
|
Left: arr[1],
|
||||||
|
|
@ -130,7 +209,7 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
|
||||||
result[3] = result[1]
|
result[3] = result[1]
|
||||||
}
|
}
|
||||||
// 上右下左
|
// 上右下左
|
||||||
ep.box = chart.Box{
|
eb.Box = chart.Box{
|
||||||
Top: result[0],
|
Top: result[0],
|
||||||
Right: result[1],
|
Right: result[1],
|
||||||
Bottom: result[2],
|
Bottom: result[2],
|
||||||
|
|
@ -140,241 +219,310 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type EChartsYAxis struct {
|
type EChartsLabelOption struct {
|
||||||
Data []struct {
|
Show bool `json:"show"`
|
||||||
Min *float64 `json:"min"`
|
Distance int `json:"distance"`
|
||||||
Max *float64 `json:"max"`
|
Color string `json:"color"`
|
||||||
// Interval int `json:"interval"`
|
}
|
||||||
AxisLabel struct {
|
type EChartsLegend struct {
|
||||||
Formatter string `json:"formatter"`
|
Show *bool `json:"show"`
|
||||||
} `json:"axisLabel"`
|
Data []string `json:"data"`
|
||||||
} `json:"data"`
|
Align string `json:"align"`
|
||||||
|
Orient string `json:"orient"`
|
||||||
|
Padding EChartsPadding `json:"padding"`
|
||||||
|
Left EChartsPosition `json:"left"`
|
||||||
|
Top EChartsPosition `json:"top"`
|
||||||
|
TextStyle EChartsTextStyle `json:"textStyle"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error {
|
type EChartsMarkData struct {
|
||||||
data = convertToArray(data)
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
type _EChartsMarkData EChartsMarkData
|
||||||
|
|
||||||
|
func (emd *EChartsMarkData) UnmarshalJSON(data []byte) error {
|
||||||
|
data = bytes.TrimSpace(data)
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
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)
|
data = convertToArray(data)
|
||||||
if len(data) == 0 {
|
ds := make([]*_EChartsMarkData, 0)
|
||||||
return nil
|
err := json.Unmarshal(data, &ds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return json.Unmarshal(data, &ex.Data)
|
for _, d := range ds {
|
||||||
|
if d.Type != "" {
|
||||||
|
emd.Type = d.Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type ECharsOptions struct {
|
type EChartsMarkPoint struct {
|
||||||
Theme string `json:"theme"`
|
SymbolSize int `json:"symbolSize"`
|
||||||
Padding EChartsPadding `json:"padding"`
|
Data []EChartsMarkData `json:"data"`
|
||||||
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"`
|
|
||||||
Height float64 `json:"height"`
|
|
||||||
} `json:"textStyle"`
|
|
||||||
} `json:"title"`
|
|
||||||
XAxis EChartsXAxis `json:"xAxis"`
|
|
||||||
YAxis EChartsYAxis `json:"yAxis"`
|
|
||||||
Legend struct {
|
|
||||||
Data []string `json:"data"`
|
|
||||||
Align string `json:"align"`
|
|
||||||
Padding EChartsPadding `json:"padding"`
|
|
||||||
Left 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 convertEChartsSeries(e *ECharsOptions) ([]Series, chart.TickPosition) {
|
func (emp *EChartsMarkPoint) ToSeriesMarkPoint() SeriesMarkPoint {
|
||||||
tickPosition := chart.TickPositionUnset
|
sp := SeriesMarkPoint{
|
||||||
|
SymbolSize: emp.SymbolSize,
|
||||||
if len(e.Series) == 0 {
|
|
||||||
return nil, tickPosition
|
|
||||||
}
|
}
|
||||||
seriesType := e.Series[0].Type
|
if len(emp.Data) == 0 {
|
||||||
if seriesType == SeriesPie {
|
return sp
|
||||||
series := make([]Series, len(e.Series[0].Data))
|
}
|
||||||
for index, item := range e.Series[0].Data {
|
data := make([]SeriesMarkData, len(emp.Data))
|
||||||
style := chart.Style{}
|
for index, item := range emp.Data {
|
||||||
if item.ItemStyle.Color != "" {
|
data[index].Type = item.Type
|
||||||
c := parseColor(item.ItemStyle.Color)
|
}
|
||||||
style.FillColor = c
|
sp.Data = data
|
||||||
style.StrokeColor = c
|
return sp
|
||||||
}
|
}
|
||||||
|
|
||||||
series[index] = Series{
|
type EChartsMarkLine struct {
|
||||||
Style: style,
|
Data []EChartsMarkData `json:"data"`
|
||||||
Data: []SeriesData{
|
}
|
||||||
{
|
|
||||||
Value: item.Value,
|
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,
|
||||||
Type: seriesType,
|
Data: []SeriesData{
|
||||||
Name: item.Name,
|
{
|
||||||
|
Value: dataItem.Value.First(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
return series, tickPosition
|
// 如果是radar或funnel
|
||||||
}
|
if item.Type == ChartTypeRadar ||
|
||||||
series := make([]Series, len(e.Series))
|
item.Type == ChartTypeFunnel {
|
||||||
for index, item := range e.Series {
|
for _, dataItem := range item.Data {
|
||||||
// bar默认tick居中
|
seriesList = append(seriesList, Series{
|
||||||
if item.Type == SeriesBar {
|
Name: dataItem.Name,
|
||||||
tickPosition = chart.TickPositionBetweenTicks
|
Type: item.Type,
|
||||||
}
|
Data: NewSeriesDataFromValues(dataItem.Value.values),
|
||||||
style := chart.Style{}
|
Max: item.Max,
|
||||||
if item.ItemStyle.Color != "" {
|
Min: item.Min,
|
||||||
c := parseColor(item.ItemStyle.Color)
|
Label: SeriesLabel{
|
||||||
style.FillColor = c
|
Color: parseColor(item.Label.Color),
|
||||||
style.StrokeColor = c
|
Show: item.Label.Show,
|
||||||
|
Distance: item.Label.Distance,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
data := make([]SeriesData, len(item.Data))
|
data := make([]SeriesData, len(item.Data))
|
||||||
for j, itemData := range item.Data {
|
for j, dataItem := range item.Data {
|
||||||
sd := SeriesData{
|
data[j] = SeriesData{
|
||||||
Value: itemData.Value,
|
Value: dataItem.Value.First(),
|
||||||
|
Style: dataItem.ItemStyle.ToStyle(),
|
||||||
}
|
}
|
||||||
if itemData.ItemStyle.Color != "" {
|
|
||||||
c := parseColor(itemData.ItemStyle.Color)
|
|
||||||
sd.Style.FillColor = c
|
|
||||||
sd.Style.StrokeColor = c
|
|
||||||
}
|
|
||||||
data[j] = sd
|
|
||||||
}
|
|
||||||
series[index] = Series{
|
|
||||||
Style: style,
|
|
||||||
YAxisIndex: item.YAxisIndex,
|
|
||||||
Data: data,
|
|
||||||
Type: item.Type,
|
|
||||||
}
|
}
|
||||||
|
seriesList = append(seriesList, Series{
|
||||||
|
Type: item.Type,
|
||||||
|
Data: data,
|
||||||
|
AxisIndex: item.YAxisIndex,
|
||||||
|
Style: item.ItemStyle.ToStyle(),
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Color: parseColor(item.Label.Color),
|
||||||
|
Show: item.Label.Show,
|
||||||
|
Distance: item.Label.Distance,
|
||||||
|
},
|
||||||
|
Name: item.Name,
|
||||||
|
MarkPoint: item.MarkPoint.ToSeriesMarkPoint(),
|
||||||
|
MarkLine: item.MarkLine.ToSeriesMarkLine(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return series, tickPosition
|
return seriesList
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ECharsOptions) ToOptions() Options {
|
type EChartsTextStyle struct {
|
||||||
o := Options{
|
Color string `json:"color"`
|
||||||
Theme: e.Theme,
|
FontFamily string `json:"fontFamily"`
|
||||||
Padding: e.Padding.box,
|
FontSize float64 `json:"fontSize"`
|
||||||
}
|
}
|
||||||
|
|
||||||
titleTextStyle := e.Title.TextStyle
|
func (et *EChartsTextStyle) ToStyle() chart.Style {
|
||||||
o.Title = Title{
|
s := chart.Style{
|
||||||
Text: e.Title.Text,
|
FontSize: et.FontSize,
|
||||||
Left: string(e.Title.Left),
|
FontColor: parseColor(et.Color),
|
||||||
Top: string(e.Title.Top),
|
}
|
||||||
Style: chart.Style{
|
if et.FontFamily != "" {
|
||||||
FontColor: parseColor(titleTextStyle.Color),
|
s.Font, _ = GetFont(et.FontFamily)
|
||||||
FontSize: titleTextStyle.FontSize,
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartsOption struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
FontFamily string `json:"fontFamily"`
|
||||||
|
Padding EChartsPadding `json:"padding"`
|
||||||
|
Box chart.Box `json:"box"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Title struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Subtext string `json:"subtext"`
|
||||||
|
Left EChartsPosition `json:"left"`
|
||||||
|
Top EChartsPosition `json:"top"`
|
||||||
|
TextStyle EChartsTextStyle `json:"textStyle"`
|
||||||
|
SubtextStyle EChartsTextStyle `json:"subtextStyle"`
|
||||||
|
} `json:"title"`
|
||||||
|
XAxis EChartsXAxis `json:"xAxis"`
|
||||||
|
YAxis EChartsYAxis `json:"yAxis"`
|
||||||
|
Legend EChartsLegend `json:"legend"`
|
||||||
|
Radar struct {
|
||||||
|
Indicator []RadarIndicator `json:"indicator"`
|
||||||
|
} `json:"radar"`
|
||||||
|
Series EChartsSeriesList `json:"series"`
|
||||||
|
Children []EChartsOption `json:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (eo *EChartsOption) ToOption() ChartOption {
|
||||||
|
fontFamily := eo.FontFamily
|
||||||
|
if len(fontFamily) == 0 {
|
||||||
|
fontFamily = eo.Title.TextStyle.FontFamily
|
||||||
|
}
|
||||||
|
titleTextStyle := eo.Title.TextStyle.ToStyle()
|
||||||
|
titleSubtextStyle := eo.Title.SubtextStyle.ToStyle()
|
||||||
|
legendTextStyle := eo.Legend.TextStyle.ToStyle()
|
||||||
|
o := ChartOption{
|
||||||
|
Type: eo.Type,
|
||||||
|
FontFamily: fontFamily,
|
||||||
|
Theme: eo.Theme,
|
||||||
|
Title: TitleOption{
|
||||||
|
Text: eo.Title.Text,
|
||||||
|
Subtext: eo.Title.Subtext,
|
||||||
|
FontColor: titleTextStyle.FontColor,
|
||||||
|
FontSize: titleTextStyle.FontSize,
|
||||||
|
SubtextFontSize: titleSubtextStyle.FontSize,
|
||||||
|
SubtextFontColor: titleSubtextStyle.FontColor,
|
||||||
|
Left: string(eo.Title.Left),
|
||||||
|
Top: string(eo.Title.Top),
|
||||||
},
|
},
|
||||||
|
Legend: LegendOption{
|
||||||
|
Show: eo.Legend.Show,
|
||||||
|
FontSize: legendTextStyle.FontSize,
|
||||||
|
FontColor: legendTextStyle.FontColor,
|
||||||
|
Data: eo.Legend.Data,
|
||||||
|
Left: string(eo.Legend.Left),
|
||||||
|
Top: string(eo.Legend.Top),
|
||||||
|
Align: eo.Legend.Align,
|
||||||
|
Orient: eo.Legend.Orient,
|
||||||
|
},
|
||||||
|
RadarIndicators: eo.Radar.Indicator,
|
||||||
|
Width: eo.Width,
|
||||||
|
Height: eo.Height,
|
||||||
|
Padding: eo.Padding.Box,
|
||||||
|
Box: eo.Box,
|
||||||
|
SeriesList: eo.Series.ToSeriesList(),
|
||||||
}
|
}
|
||||||
if e.Title.TextStyle.FontFamily != "" {
|
isHorizontalChart := false
|
||||||
// 如果获取字体失败忽略
|
for _, item := range eo.XAxis.Data {
|
||||||
o.Font, _ = GetFont(e.Title.TextStyle.FontFamily)
|
if item.Type == "value" {
|
||||||
}
|
isHorizontalChart = true
|
||||||
|
|
||||||
if titleTextStyle.FontSize != 0 && titleTextStyle.Height > titleTextStyle.FontSize {
|
|
||||||
padding := int(titleTextStyle.Height-titleTextStyle.FontSize) / 2
|
|
||||||
o.Title.Style.Padding.Top = padding
|
|
||||||
o.Title.Style.Padding.Bottom = padding
|
|
||||||
}
|
|
||||||
|
|
||||||
boundaryGap := false
|
|
||||||
if len(e.XAxis.Data) != 0 {
|
|
||||||
xAxis := e.XAxis.Data[0]
|
|
||||||
o.XAxis = XAxis{
|
|
||||||
Data: xAxis.Data,
|
|
||||||
SplitNumber: xAxis.SplitNumber,
|
|
||||||
}
|
|
||||||
if xAxis.BoundaryGap == nil || *xAxis.BoundaryGap {
|
|
||||||
boundaryGap = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if isHorizontalChart {
|
||||||
o.Legend = Legend{
|
for index := range o.SeriesList {
|
||||||
Data: e.Legend.Data,
|
series := o.SeriesList[index]
|
||||||
Align: e.Legend.Align,
|
if series.Type == ChartTypeBar {
|
||||||
Padding: e.Legend.Padding.box,
|
o.SeriesList[index].Type = ChartTypeHorizontalBar
|
||||||
Left: string(e.Legend.Left),
|
|
||||||
Right: string(e.Legend.Right),
|
|
||||||
}
|
|
||||||
if len(e.YAxis.Data) != 0 {
|
|
||||||
yAxisOptions := make([]*YAxisOption, len(e.YAxis.Data))
|
|
||||||
for index, item := range e.YAxis.Data {
|
|
||||||
opt := &YAxisOption{
|
|
||||||
Max: item.Max,
|
|
||||||
Min: item.Min,
|
|
||||||
}
|
}
|
||||||
template := item.AxisLabel.Formatter
|
|
||||||
if template != "" {
|
|
||||||
opt.Formater = func(v interface{}) string {
|
|
||||||
str := defaultFloatFormater(v)
|
|
||||||
return strings.ReplaceAll(template, "{value}", str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yAxisOptions[index] = opt
|
|
||||||
}
|
}
|
||||||
o.YAxisOptions = yAxisOptions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
series, tickPosition := convertEChartsSeries(e)
|
if len(eo.XAxis.Data) != 0 {
|
||||||
|
xAxisData := eo.XAxis.Data[0]
|
||||||
o.Series = series
|
o.XAxis = XAxisOption{
|
||||||
|
BoundaryGap: xAxisData.BoundaryGap,
|
||||||
if boundaryGap {
|
Data: xAxisData.Data,
|
||||||
tickPosition = chart.TickPositionBetweenTicks
|
SplitNumber: xAxisData.SplitNumber,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yAxisOptions := make([]YAxisOption, len(eo.YAxis.Data))
|
||||||
|
for index, item := range eo.YAxis.Data {
|
||||||
|
yAxisOptions[index] = YAxisOption{
|
||||||
|
Min: item.Min,
|
||||||
|
Max: item.Max,
|
||||||
|
Formatter: item.AxisLabel.Formatter,
|
||||||
|
Color: parseColor(item.AxisLine.LineStyle.Color),
|
||||||
|
Data: item.Data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.YAxisOptions = yAxisOptions
|
||||||
|
|
||||||
|
if len(eo.Children) != 0 {
|
||||||
|
o.Children = make([]ChartOption, len(eo.Children))
|
||||||
|
for index, item := range eo.Children {
|
||||||
|
o.Children[index] = item.ToOption()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
o.TickPosition = tickPosition
|
|
||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseECharsOptions(options string) (Options, error) {
|
func renderEcharts(options, outputType string) ([]byte, error) {
|
||||||
e := ECharsOptions{}
|
o := EChartsOption{}
|
||||||
err := json.Unmarshal([]byte(options), &e)
|
err := json.Unmarshal([]byte(options), &o)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g, err := New(o)
|
opt := o.ToOption()
|
||||||
|
opt.Type = outputType
|
||||||
|
d, err := Render(opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return render(g, rp)
|
return d.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderEChartsToPNG(options string) ([]byte, error) {
|
func RenderEChartsToPNG(options string) ([]byte, error) {
|
||||||
return echartsRender(options, chart.PNG)
|
return renderEcharts(options, "png")
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderEChartsToSVG(options string) ([]byte, error) {
|
func RenderEChartsToSVG(options string) ([]byte, error) {
|
||||||
return echartsRender(options, chart.SVG)
|
return renderEcharts(options, "svg")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
861
echarts_test.go
861
echarts_test.go
File diff suppressed because one or more lines are too long
73
examples/area_line_chart/main.go
Normal file
73
examples/area_line_chart/main.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "area-line-chart.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
120,
|
||||||
|
132,
|
||||||
|
101,
|
||||||
|
134,
|
||||||
|
90,
|
||||||
|
230,
|
||||||
|
210,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.LineRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("Line"),
|
||||||
|
charts.XAxisDataOptionFunc([]string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
}),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Email",
|
||||||
|
}, "50"),
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.Legend.Padding = charts.Box{
|
||||||
|
Top: 5,
|
||||||
|
Bottom: 10,
|
||||||
|
}
|
||||||
|
opt.FillArea = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
examples/bar_chart/main.go
Normal file
102
examples/bar_chart/main.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "bar-chart.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
2.0,
|
||||||
|
4.9,
|
||||||
|
7.0,
|
||||||
|
23.2,
|
||||||
|
25.6,
|
||||||
|
76.7,
|
||||||
|
135.6,
|
||||||
|
162.2,
|
||||||
|
32.6,
|
||||||
|
20.0,
|
||||||
|
6.4,
|
||||||
|
3.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
2.6,
|
||||||
|
5.9,
|
||||||
|
9.0,
|
||||||
|
26.4,
|
||||||
|
28.7,
|
||||||
|
70.7,
|
||||||
|
175.6,
|
||||||
|
182.2,
|
||||||
|
48.7,
|
||||||
|
18.8,
|
||||||
|
6.0,
|
||||||
|
2.3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.BarRender(
|
||||||
|
values,
|
||||||
|
charts.XAxisDataOptionFunc([]string{
|
||||||
|
"Jan",
|
||||||
|
"Feb",
|
||||||
|
"Mar",
|
||||||
|
"Apr",
|
||||||
|
"May",
|
||||||
|
"Jun",
|
||||||
|
"Jul",
|
||||||
|
"Aug",
|
||||||
|
"Sep",
|
||||||
|
"Oct",
|
||||||
|
"Nov",
|
||||||
|
"Dec",
|
||||||
|
}),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Rainfall",
|
||||||
|
"Evaporation",
|
||||||
|
}, charts.PositionRight),
|
||||||
|
charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage),
|
||||||
|
charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax,
|
||||||
|
charts.SeriesMarkDataTypeMin),
|
||||||
|
// custom option func
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.SeriesList[1].MarkPoint = charts.NewMarkPoint(
|
||||||
|
charts.SeriesMarkDataTypeMax,
|
||||||
|
charts.SeriesMarkDataTypeMin,
|
||||||
|
)
|
||||||
|
opt.SeriesList[1].MarkLine = charts.NewMarkLine(
|
||||||
|
charts.SeriesMarkDataTypeAverage,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
charts "github.com/vicanso/go-charts"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
buf, err := charts.RenderEChartsToPNG(`{
|
|
||||||
"title": {
|
|
||||||
"text": "Line"
|
|
||||||
},
|
|
||||||
"xAxis": {
|
|
||||||
"type": "category",
|
|
||||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
||||||
},
|
|
||||||
"series": [
|
|
||||||
{
|
|
||||||
"data": [150, 230, 224, 218, 135, 147, 260]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}`)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
file, err := os.Create("output.png")
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
file.Write(buf)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
120
examples/chinese/main.go
Normal file
120
examples/chinese/main.go
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "chinese-line-chart.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 字体文件需要自行下载
|
||||||
|
// https://github.com/googlefonts/noto-cjk
|
||||||
|
buf, err := ioutil.ReadFile("./NotoSansSC.ttf")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = charts.InstallFont("noto", buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
font, _ := charts.GetFont("noto")
|
||||||
|
charts.SetDefaultFont(font)
|
||||||
|
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
120,
|
||||||
|
132,
|
||||||
|
101,
|
||||||
|
134,
|
||||||
|
90,
|
||||||
|
230,
|
||||||
|
210,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
220,
|
||||||
|
182,
|
||||||
|
191,
|
||||||
|
234,
|
||||||
|
290,
|
||||||
|
330,
|
||||||
|
310,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
150,
|
||||||
|
232,
|
||||||
|
201,
|
||||||
|
154,
|
||||||
|
190,
|
||||||
|
330,
|
||||||
|
410,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
320,
|
||||||
|
332,
|
||||||
|
301,
|
||||||
|
334,
|
||||||
|
390,
|
||||||
|
330,
|
||||||
|
320,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
820,
|
||||||
|
932,
|
||||||
|
901,
|
||||||
|
934,
|
||||||
|
1290,
|
||||||
|
1330,
|
||||||
|
1320,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.LineRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("测试"),
|
||||||
|
charts.XAxisDataOptionFunc([]string{
|
||||||
|
"星期一",
|
||||||
|
"星期二",
|
||||||
|
"星期三",
|
||||||
|
"星期四",
|
||||||
|
"星期五",
|
||||||
|
"星期六",
|
||||||
|
"星期日",
|
||||||
|
}),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"邮件",
|
||||||
|
"广告",
|
||||||
|
"视频广告",
|
||||||
|
"直接访问",
|
||||||
|
"搜索引擎",
|
||||||
|
}, charts.PositionCenter),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err = p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,368 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
60
examples/funnel_chart/main.go
Normal file
60
examples/funnel_chart/main.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "funnel-chart.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := []float64{
|
||||||
|
100,
|
||||||
|
80,
|
||||||
|
60,
|
||||||
|
40,
|
||||||
|
20,
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
}
|
||||||
|
p, err := charts.FunnelRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("Funnel"),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Show",
|
||||||
|
"Click",
|
||||||
|
"Visit",
|
||||||
|
"Inquiry",
|
||||||
|
"Order",
|
||||||
|
"Pay",
|
||||||
|
"Cancel",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
examples/horizontal_bar_chart/main.go
Normal file
84
examples/horizontal_bar_chart/main.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "horizontal-bar-chart.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
50,
|
||||||
|
70,
|
||||||
|
90,
|
||||||
|
110,
|
||||||
|
130,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
20,
|
||||||
|
40,
|
||||||
|
60,
|
||||||
|
80,
|
||||||
|
100,
|
||||||
|
120,
|
||||||
|
140,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.HorizontalBarRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("World Population"),
|
||||||
|
charts.PaddingOptionFunc(charts.Box{
|
||||||
|
Top: 20,
|
||||||
|
Right: 40,
|
||||||
|
Bottom: 20,
|
||||||
|
Left: 20,
|
||||||
|
}),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"2011",
|
||||||
|
"2012",
|
||||||
|
}),
|
||||||
|
charts.YAxisDataOptionFunc([]string{
|
||||||
|
"UN",
|
||||||
|
"Brazil",
|
||||||
|
"Indonesia",
|
||||||
|
"USA",
|
||||||
|
"India",
|
||||||
|
"China",
|
||||||
|
"World",
|
||||||
|
}),
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.SeriesList[0].RoundRadius = 5
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
124
examples/line_chart/main.go
Normal file
124
examples/line_chart/main.go
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "line-chart.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
120,
|
||||||
|
132,
|
||||||
|
101,
|
||||||
|
// 134,
|
||||||
|
charts.GetNullValue(),
|
||||||
|
90,
|
||||||
|
230,
|
||||||
|
210,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
220,
|
||||||
|
182,
|
||||||
|
191,
|
||||||
|
234,
|
||||||
|
290,
|
||||||
|
330,
|
||||||
|
310,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
150,
|
||||||
|
232,
|
||||||
|
201,
|
||||||
|
154,
|
||||||
|
190,
|
||||||
|
330,
|
||||||
|
410,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
320,
|
||||||
|
332,
|
||||||
|
301,
|
||||||
|
334,
|
||||||
|
390,
|
||||||
|
330,
|
||||||
|
320,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
820,
|
||||||
|
932,
|
||||||
|
901,
|
||||||
|
934,
|
||||||
|
1290,
|
||||||
|
1330,
|
||||||
|
1320,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.LineRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("Line"),
|
||||||
|
charts.XAxisDataOptionFunc([]string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
}),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
"Direct",
|
||||||
|
"Search Engine",
|
||||||
|
}, "50"),
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.Legend.Padding = charts.Box{
|
||||||
|
Top: 5,
|
||||||
|
Bottom: 10,
|
||||||
|
}
|
||||||
|
opt.YAxisOptions = []charts.YAxisOption{
|
||||||
|
{
|
||||||
|
SplitLineShow: charts.FalseFlag(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
opt.SymbolShow = charts.FalseFlag()
|
||||||
|
opt.LineStrokeWidth = 1
|
||||||
|
opt.ValueFormatter = func(f float64) string {
|
||||||
|
return fmt.Sprintf("%.0f", f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
607
examples/painter/main.go
Normal file
607
examples/painter/main.go
Normal file
|
|
@ -0,0 +1,607 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
charts "git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "painter.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
p, err := charts.NewPainter(charts.PainterOptions{
|
||||||
|
Width: 600,
|
||||||
|
Height: 2000,
|
||||||
|
Type: charts.ChartOutputPNG,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
// 背景色
|
||||||
|
p.SetBackground(p.Width(), p.Height(), drawing.ColorWhite)
|
||||||
|
|
||||||
|
top := 0
|
||||||
|
|
||||||
|
// 画线
|
||||||
|
p.SetDrawingStyle(charts.Style{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
})
|
||||||
|
p.LineStroke([]charts.Point{
|
||||||
|
{
|
||||||
|
X: 0,
|
||||||
|
Y: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 100,
|
||||||
|
Y: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 200,
|
||||||
|
Y: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 300,
|
||||||
|
Y: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 圆滑曲线
|
||||||
|
// top += 50
|
||||||
|
// p.Child(charts.PainterPaddingOption(charts.Box{
|
||||||
|
// Top: top,
|
||||||
|
// })).SetDrawingStyle(charts.Style{
|
||||||
|
// StrokeColor: drawing.ColorBlack,
|
||||||
|
// FillColor: drawing.ColorBlack,
|
||||||
|
// StrokeWidth: 1,
|
||||||
|
// }).SmoothLineStroke([]charts.Point{
|
||||||
|
// {
|
||||||
|
// X: 0,
|
||||||
|
// Y: 0,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// X: 100,
|
||||||
|
// Y: 10,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// X: 200,
|
||||||
|
// Y: 0,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// X: 300,
|
||||||
|
// Y: 10,
|
||||||
|
// },
|
||||||
|
// })
|
||||||
|
|
||||||
|
// 标线
|
||||||
|
top += 50
|
||||||
|
p.Child(charts.PainterPaddingOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
})).SetDrawingStyle(charts.Style{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeDashArray: []float64{
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
},
|
||||||
|
}).MarkLine(0, 0, p.Width())
|
||||||
|
|
||||||
|
top += 60
|
||||||
|
// Polygon
|
||||||
|
p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
})).SetDrawingStyle(charts.Style{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
}).Polygon(charts.Point{
|
||||||
|
X: 100,
|
||||||
|
Y: 0,
|
||||||
|
}, 50, 6)
|
||||||
|
|
||||||
|
// FillArea
|
||||||
|
top += 60
|
||||||
|
p.Child(charts.PainterPaddingOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
})).SetDrawingStyle(charts.Style{
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
}).FillArea([]charts.Point{
|
||||||
|
{
|
||||||
|
X: 0,
|
||||||
|
Y: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 100,
|
||||||
|
Y: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 150,
|
||||||
|
Y: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 80,
|
||||||
|
Y: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 0,
|
||||||
|
Y: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 坐标轴的点
|
||||||
|
top += 50
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: 20,
|
||||||
|
}),
|
||||||
|
).SetDrawingStyle(charts.Style{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
}).Ticks(charts.TicksOption{
|
||||||
|
Count: 7,
|
||||||
|
Length: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 坐标轴的点,每2格显示一个
|
||||||
|
top += 20
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: 20,
|
||||||
|
}),
|
||||||
|
).SetDrawingStyle(charts.Style{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
}).Ticks(charts.TicksOption{
|
||||||
|
Unit: 2,
|
||||||
|
Count: 7,
|
||||||
|
Length: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 坐标轴的点,纵向
|
||||||
|
top += 20
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 100,
|
||||||
|
}),
|
||||||
|
).SetDrawingStyle(charts.Style{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
}).Ticks(charts.TicksOption{
|
||||||
|
Orient: charts.OrientVertical,
|
||||||
|
Count: 7,
|
||||||
|
Length: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 横向展示文本
|
||||||
|
top += 120
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: 20,
|
||||||
|
}),
|
||||||
|
).OverrideTextStyle(charts.Style{
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
FontSize: 10,
|
||||||
|
}).MultiText(charts.MultiTextOption{
|
||||||
|
TextList: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 横向显示文本,靠左
|
||||||
|
top += 20
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: 20,
|
||||||
|
}),
|
||||||
|
).OverrideTextStyle(charts.Style{
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
FontSize: 10,
|
||||||
|
}).MultiText(charts.MultiTextOption{
|
||||||
|
Position: charts.PositionLeft,
|
||||||
|
TextList: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 纵向显示文本
|
||||||
|
top += 20
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: 50,
|
||||||
|
Bottom: top + 150,
|
||||||
|
}),
|
||||||
|
).OverrideTextStyle(charts.Style{
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
FontSize: 10,
|
||||||
|
}).MultiText(charts.MultiTextOption{
|
||||||
|
Orient: charts.OrientVertical,
|
||||||
|
Align: charts.AlignRight,
|
||||||
|
TextList: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// 纵向 文本居中
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 50,
|
||||||
|
Right: 100,
|
||||||
|
Bottom: top + 150,
|
||||||
|
}),
|
||||||
|
).OverrideTextStyle(charts.Style{
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
FontSize: 10,
|
||||||
|
}).MultiText(charts.MultiTextOption{
|
||||||
|
Orient: charts.OrientVertical,
|
||||||
|
Align: charts.AlignCenter,
|
||||||
|
TextList: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// 纵向 文本置顶
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 100,
|
||||||
|
Right: 150,
|
||||||
|
Bottom: top + 150,
|
||||||
|
}),
|
||||||
|
).OverrideTextStyle(charts.Style{
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
FontSize: 10,
|
||||||
|
}).MultiText(charts.MultiTextOption{
|
||||||
|
Orient: charts.OrientVertical,
|
||||||
|
Position: charts.PositionTop,
|
||||||
|
Align: charts.AlignCenter,
|
||||||
|
TextList: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// grid
|
||||||
|
top += 150
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 100,
|
||||||
|
}),
|
||||||
|
).OverrideTextStyle(charts.Style{
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
FontSize: 10,
|
||||||
|
}).Grid(charts.GridOption{
|
||||||
|
Column: 8,
|
||||||
|
IgnoreColumnLines: []int{0, 8},
|
||||||
|
Row: 8,
|
||||||
|
IgnoreRowLines: []int{0, 8},
|
||||||
|
})
|
||||||
|
|
||||||
|
// dots
|
||||||
|
top += 100
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 20,
|
||||||
|
}),
|
||||||
|
).OverrideDrawingStyle(charts.Style{
|
||||||
|
FillColor: drawing.ColorWhite,
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
}).Dots([]charts.Point{
|
||||||
|
{
|
||||||
|
X: 0,
|
||||||
|
Y: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 50,
|
||||||
|
Y: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 100,
|
||||||
|
Y: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// rect
|
||||||
|
top += 30
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: 200,
|
||||||
|
Bottom: top + 50,
|
||||||
|
}),
|
||||||
|
).OverrideDrawingStyle(charts.Style{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
}).Rect(charts.Box{
|
||||||
|
Left: 10,
|
||||||
|
Top: 0,
|
||||||
|
Right: 110,
|
||||||
|
Bottom: 20,
|
||||||
|
})
|
||||||
|
// legend line dot
|
||||||
|
p.Child(
|
||||||
|
charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 200,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 50,
|
||||||
|
}),
|
||||||
|
).OverrideDrawingStyle(charts.Style{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
}).LegendLineDot(charts.Box{
|
||||||
|
Left: 10,
|
||||||
|
Top: 0,
|
||||||
|
Right: 50,
|
||||||
|
Bottom: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
// grid
|
||||||
|
top += 50
|
||||||
|
charts.NewGridPainter(p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 100,
|
||||||
|
})), charts.GridPainterOption{
|
||||||
|
Row: 5,
|
||||||
|
IgnoreFirstRow: true,
|
||||||
|
IgnoreLastRow: true,
|
||||||
|
StrokeColor: drawing.ColorBlue,
|
||||||
|
}).Render()
|
||||||
|
|
||||||
|
// legend
|
||||||
|
top += 100
|
||||||
|
charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 30,
|
||||||
|
})), charts.LegendOption{
|
||||||
|
Left: "10",
|
||||||
|
Data: []string{
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
"Direct",
|
||||||
|
},
|
||||||
|
FontSize: 12,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
}).Render()
|
||||||
|
|
||||||
|
// legend
|
||||||
|
top += 30
|
||||||
|
charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 30,
|
||||||
|
})), charts.LegendOption{
|
||||||
|
Left: charts.PositionRight,
|
||||||
|
Data: []string{
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
"Direct",
|
||||||
|
},
|
||||||
|
Align: charts.AlignRight,
|
||||||
|
FontSize: 16,
|
||||||
|
Icon: charts.IconRect,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
}).Render()
|
||||||
|
|
||||||
|
// legend
|
||||||
|
top += 30
|
||||||
|
charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 100,
|
||||||
|
})), charts.LegendOption{
|
||||||
|
Top: "10",
|
||||||
|
Data: []string{
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
"Direct",
|
||||||
|
},
|
||||||
|
Orient: charts.OrientVertical,
|
||||||
|
FontSize: 12,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
}).Render()
|
||||||
|
|
||||||
|
// axis bottom
|
||||||
|
top += 100
|
||||||
|
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 50,
|
||||||
|
})), charts.AxisOption{
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FontSize: 12,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
}).Render()
|
||||||
|
|
||||||
|
// axis top
|
||||||
|
top += 50
|
||||||
|
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 1,
|
||||||
|
Right: p.Width() - 1,
|
||||||
|
Bottom: top + 50,
|
||||||
|
})), charts.AxisOption{
|
||||||
|
Position: charts.PositionTop,
|
||||||
|
BoundaryGap: charts.FalseFlag(),
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FontSize: 12,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
}).Render()
|
||||||
|
|
||||||
|
// axis left
|
||||||
|
top += 50
|
||||||
|
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 10,
|
||||||
|
Right: 60,
|
||||||
|
Bottom: top + 200,
|
||||||
|
})), charts.AxisOption{
|
||||||
|
Position: charts.PositionLeft,
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FontSize: 12,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
}).Render()
|
||||||
|
// axis right
|
||||||
|
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 100,
|
||||||
|
Right: 150,
|
||||||
|
Bottom: top + 200,
|
||||||
|
})), charts.AxisOption{
|
||||||
|
Position: charts.PositionRight,
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FontSize: 12,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
}).Render()
|
||||||
|
|
||||||
|
// axis left no tick
|
||||||
|
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: 150,
|
||||||
|
Right: 300,
|
||||||
|
Bottom: top + 200,
|
||||||
|
})), charts.AxisOption{
|
||||||
|
BoundaryGap: charts.FalseFlag(),
|
||||||
|
Position: charts.PositionLeft,
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
FontSize: 12,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
SplitLineShow: true,
|
||||||
|
SplitLineColor: drawing.ColorBlack.WithAlpha(100),
|
||||||
|
}).Render()
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
71
examples/pie_chart/main.go
Normal file
71
examples/pie_chart/main.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "pie-chart.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := []float64{
|
||||||
|
1048,
|
||||||
|
735,
|
||||||
|
580,
|
||||||
|
484,
|
||||||
|
300,
|
||||||
|
}
|
||||||
|
p, err := charts.PieRender(
|
||||||
|
values,
|
||||||
|
charts.TitleOptionFunc(charts.TitleOption{
|
||||||
|
Text: "Rainfall vs Evaporation",
|
||||||
|
Subtext: "Fake Data",
|
||||||
|
Left: charts.PositionCenter,
|
||||||
|
}),
|
||||||
|
charts.PaddingOptionFunc(charts.Box{
|
||||||
|
Top: 20,
|
||||||
|
Right: 20,
|
||||||
|
Bottom: 20,
|
||||||
|
Left: 20,
|
||||||
|
}),
|
||||||
|
charts.LegendOptionFunc(charts.LegendOption{
|
||||||
|
Orient: charts.OrientVertical,
|
||||||
|
Data: []string{
|
||||||
|
"Search Engine",
|
||||||
|
"Direct",
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
},
|
||||||
|
Left: charts.PositionLeft,
|
||||||
|
}),
|
||||||
|
charts.PieSeriesShowLabel(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
examples/radar_chart/main.go
Normal file
79
examples/radar_chart/main.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "radar-chart.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
values := [][]float64{
|
||||||
|
{
|
||||||
|
4200,
|
||||||
|
3000,
|
||||||
|
20000,
|
||||||
|
35000,
|
||||||
|
50000,
|
||||||
|
18000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
5000,
|
||||||
|
14000,
|
||||||
|
28000,
|
||||||
|
26000,
|
||||||
|
42000,
|
||||||
|
21000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p, err := charts.RadarRender(
|
||||||
|
values,
|
||||||
|
charts.TitleTextOptionFunc("Basic Radar Chart"),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Allocated Budget",
|
||||||
|
"Actual Spending",
|
||||||
|
}),
|
||||||
|
charts.RadarIndicatorOptionFunc([]string{
|
||||||
|
"Sales",
|
||||||
|
"Administration",
|
||||||
|
"Information Technology",
|
||||||
|
"Customer Support",
|
||||||
|
"Development",
|
||||||
|
"Marketing",
|
||||||
|
}, []float64{
|
||||||
|
6500,
|
||||||
|
16000,
|
||||||
|
30000,
|
||||||
|
38000,
|
||||||
|
52000,
|
||||||
|
25000,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
178
examples/table/main.go
Normal file
178
examples/table/main.go
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte, filename string) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, filename)
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// charts.SetDefaultTableSetting(charts.TableDarkThemeSetting)
|
||||||
|
charts.SetDefaultWidth(810)
|
||||||
|
header := []string{
|
||||||
|
"Name",
|
||||||
|
"Age",
|
||||||
|
"Address",
|
||||||
|
"Tag",
|
||||||
|
"Action",
|
||||||
|
}
|
||||||
|
data := [][]string{
|
||||||
|
{
|
||||||
|
"John Brown",
|
||||||
|
"32",
|
||||||
|
"New York No. 1 Lake Park",
|
||||||
|
"nice, developer",
|
||||||
|
"Send Mail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Jim Green ",
|
||||||
|
"42",
|
||||||
|
"London No. 1 Lake Park",
|
||||||
|
"wow",
|
||||||
|
"Send Mail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Joe Black ",
|
||||||
|
"32",
|
||||||
|
"Sidney No. 1 Lake Park",
|
||||||
|
"cool, teacher",
|
||||||
|
"Send Mail",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
spans := map[int]int{
|
||||||
|
0: 2,
|
||||||
|
1: 1,
|
||||||
|
// 设置第三列的span
|
||||||
|
2: 3,
|
||||||
|
3: 2,
|
||||||
|
4: 2,
|
||||||
|
}
|
||||||
|
p, err := charts.TableRender(
|
||||||
|
header,
|
||||||
|
data,
|
||||||
|
spans,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf, "table.png")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bgColor := charts.Color{
|
||||||
|
R: 16,
|
||||||
|
G: 22,
|
||||||
|
B: 30,
|
||||||
|
A: 255,
|
||||||
|
}
|
||||||
|
p, err = charts.TableOptionRender(charts.TableChartOption{
|
||||||
|
Header: []string{
|
||||||
|
"Name",
|
||||||
|
"Price",
|
||||||
|
"Change",
|
||||||
|
},
|
||||||
|
BackgroundColor: bgColor,
|
||||||
|
HeaderBackgroundColor: bgColor,
|
||||||
|
RowBackgroundColors: []charts.Color{
|
||||||
|
bgColor,
|
||||||
|
},
|
||||||
|
HeaderFontColor: drawing.ColorWhite,
|
||||||
|
FontColor: drawing.ColorWhite,
|
||||||
|
Padding: charts.Box{
|
||||||
|
Top: 15,
|
||||||
|
Right: 10,
|
||||||
|
Bottom: 15,
|
||||||
|
Left: 10,
|
||||||
|
},
|
||||||
|
Data: [][]string{
|
||||||
|
{
|
||||||
|
"Datadog Inc",
|
||||||
|
"97.32",
|
||||||
|
"-7.49%",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Hashicorp Inc",
|
||||||
|
"28.66",
|
||||||
|
"-9.25%",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Gitlab Inc",
|
||||||
|
"51.63",
|
||||||
|
"+4.32%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TextAligns: []string{
|
||||||
|
"",
|
||||||
|
charts.AlignRight,
|
||||||
|
charts.AlignRight,
|
||||||
|
},
|
||||||
|
CellStyle: func(tc charts.TableCell) *charts.Style {
|
||||||
|
column := tc.Column
|
||||||
|
if column != 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value, _ := strconv.ParseFloat(strings.Replace(tc.Text, "%", "", 1), 64)
|
||||||
|
if value == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
style := charts.Style{
|
||||||
|
Padding: charts.Box{
|
||||||
|
Bottom: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if value > 0 {
|
||||||
|
style.FillColor = charts.Color{
|
||||||
|
R: 179,
|
||||||
|
G: 53,
|
||||||
|
B: 20,
|
||||||
|
A: 255,
|
||||||
|
}
|
||||||
|
} else if value < 0 {
|
||||||
|
style.FillColor = charts.Color{
|
||||||
|
R: 33,
|
||||||
|
G: 124,
|
||||||
|
B: 50,
|
||||||
|
A: 255,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &style
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err = p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf, "table-color.png")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
81
examples/time_line_chart/main.go
Normal file
81
examples/time_line_chart/main.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.smarteching.com/zeni/go-charts/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(buf []byte) error {
|
||||||
|
tmpPath := "./tmp"
|
||||||
|
err := os.MkdirAll(tmpPath, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(tmpPath, "time-line-chart.png")
|
||||||
|
err = os.WriteFile(file, buf, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
xAxisValue := []string{}
|
||||||
|
values := []float64{}
|
||||||
|
now := time.Now()
|
||||||
|
firstAxis := 0
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
// 设置首个axis为xx:00的时间点
|
||||||
|
if firstAxis == 0 && now.Minute() == 0 {
|
||||||
|
firstAxis = i
|
||||||
|
}
|
||||||
|
xAxisValue = append(xAxisValue, now.Format("15:04"))
|
||||||
|
now = now.Add(time.Minute)
|
||||||
|
value, _ := rand.Int(rand.Reader, big.NewInt(100))
|
||||||
|
values = append(values, float64(value.Int64()))
|
||||||
|
}
|
||||||
|
p, err := charts.LineRender(
|
||||||
|
[][]float64{
|
||||||
|
values,
|
||||||
|
},
|
||||||
|
charts.TitleTextOptionFunc("Line"),
|
||||||
|
charts.XAxisDataOptionFunc(xAxisValue, charts.FalseFlag()),
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Demo",
|
||||||
|
}, "50"),
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.XAxis.FirstAxis = firstAxis
|
||||||
|
// 必须要比计算得来的最小值更大(每60分钟)
|
||||||
|
opt.XAxis.SplitNumber = 60
|
||||||
|
opt.Legend.Padding = charts.Box{
|
||||||
|
Top: 5,
|
||||||
|
Bottom: 10,
|
||||||
|
}
|
||||||
|
opt.SymbolShow = charts.FalseFlag()
|
||||||
|
opt.LineStrokeWidth = 1
|
||||||
|
opt.ValueFormatter = func(f float64) string {
|
||||||
|
return fmt.Sprintf("%.0f", f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
err = writeFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,55 +23,56 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"errors"
|
||||||
"testing"
|
"sync"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/golang/freetype/truetype"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"git.smarteching.com/zeni/go-chart/v2/roboto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRange(t *testing.T) {
|
var fonts = sync.Map{}
|
||||||
assert := assert.New(t)
|
var ErrFontNotExists = errors.New("font is not exists")
|
||||||
|
var defaultFontFamily = "defaultFontFamily"
|
||||||
|
|
||||||
r := Range{
|
func init() {
|
||||||
ContinuousRange: chart.ContinuousRange{
|
name := "roboto"
|
||||||
Min: 0,
|
_ = InstallFont(name, roboto.Roboto)
|
||||||
Max: 5,
|
font, _ := GetFont(name)
|
||||||
Domain: 500,
|
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
|
||||||
}
|
}
|
||||||
|
fonts.Store(fontFamily, font)
|
||||||
assert.Equal(100, r.Translate(1))
|
return nil
|
||||||
|
|
||||||
r.TickPosition = chart.TickPositionBetweenTicks
|
|
||||||
assert.Equal(50, r.Translate(1))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHiddenRange(t *testing.T) {
|
// GetDefaultFont get default font
|
||||||
assert := assert.New(t)
|
func GetDefaultFont() (*truetype.Font, error) {
|
||||||
r := HiddenRange{}
|
return GetFont(defaultFontFamily)
|
||||||
|
|
||||||
assert.Equal(float64(0), r.GetDelta())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestYContinuousRange(t *testing.T) {
|
// SetDefaultFont set default font
|
||||||
assert := assert.New(t)
|
func SetDefaultFont(font *truetype.Font) {
|
||||||
r := YContinuousRange{}
|
if font == nil {
|
||||||
r.Min = -math.MaxFloat64
|
return
|
||||||
r.Max = math.MaxFloat64
|
}
|
||||||
|
fonts.Store(defaultFontFamily, font)
|
||||||
assert.True(r.IsZero())
|
}
|
||||||
|
|
||||||
r.SetMin(1.0)
|
// GetFont get the font by font family
|
||||||
assert.Equal(1.0, r.GetMin())
|
func GetFont(fontFamily string) (*truetype.Font, error) {
|
||||||
// 再次设置无效
|
value, ok := fonts.Load(fontFamily)
|
||||||
r.SetMin(2.0)
|
if !ok {
|
||||||
assert.Equal(1.0, r.GetMin())
|
return nil, ErrFontNotExists
|
||||||
|
}
|
||||||
r.SetMax(5.0)
|
f, ok := value.(*truetype.Font)
|
||||||
// *1.2
|
if !ok {
|
||||||
assert.Equal(6.0, r.GetMax())
|
return nil, ErrFontNotExists
|
||||||
// 再次设置无效
|
}
|
||||||
r.SetMax(10.0)
|
return f, nil
|
||||||
assert.Equal(6.0, r.GetMax())
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -26,21 +26,17 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"git.smarteching.com/zeni/go-chart/v2/roboto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLineSeries(t *testing.T) {
|
func TestInstallFont(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ls := LineSeries{}
|
fontFamily := "test"
|
||||||
|
err := InstallFont(fontFamily, roboto.Roboto)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
originalRange := &chart.ContinuousRange{}
|
font, err := GetFont(fontFamily)
|
||||||
xrange := ls.getXRange(originalRange)
|
assert.Nil(err)
|
||||||
assert.Equal(originalRange, xrange)
|
assert.NotNil(font)
|
||||||
|
|
||||||
ls.TickPosition = chart.TickPositionBetweenTicks
|
|
||||||
xrange = ls.getXRange(originalRange)
|
|
||||||
value, ok := xrange.(*Range)
|
|
||||||
assert.True(ok)
|
|
||||||
assert.Equal(originalRange, &value.ContinuousRange)
|
|
||||||
}
|
}
|
||||||
192
funnel_chart.go
Normal file
192
funnel_chart.go
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type funnelChart struct {
|
||||||
|
p *Painter
|
||||||
|
opt *FunnelChartOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFunnelSeriesList returns a series list for funnel
|
||||||
|
func NewFunnelSeriesList(values []float64) SeriesList {
|
||||||
|
seriesList := make(SeriesList, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
seriesList[index] = NewSeriesFromValues([]float64{
|
||||||
|
value,
|
||||||
|
}, ChartTypeFunnel)
|
||||||
|
}
|
||||||
|
return seriesList
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFunnelChart returns a funnel chart renderer
|
||||||
|
func NewFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart {
|
||||||
|
if opt.Theme == nil {
|
||||||
|
opt.Theme = defaultTheme
|
||||||
|
}
|
||||||
|
return &funnelChart{
|
||||||
|
p: p,
|
||||||
|
opt: &opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunnelChartOption struct {
|
||||||
|
// The theme
|
||||||
|
Theme ColorPalette
|
||||||
|
// The font size
|
||||||
|
Font *truetype.Font
|
||||||
|
// The data series list
|
||||||
|
SeriesList SeriesList
|
||||||
|
// The padding of line chart
|
||||||
|
Padding Box
|
||||||
|
// The option of title
|
||||||
|
Title TitleOption
|
||||||
|
// The legend option
|
||||||
|
Legend LegendOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
|
||||||
|
opt := f.opt
|
||||||
|
seriesPainter := result.seriesPainter
|
||||||
|
max := seriesList[0].Data[0].Value
|
||||||
|
min := float64(0)
|
||||||
|
for _, item := range seriesList {
|
||||||
|
if item.Max != nil {
|
||||||
|
max = *item.Max
|
||||||
|
}
|
||||||
|
if item.Min != nil {
|
||||||
|
min = *item.Min
|
||||||
|
}
|
||||||
|
}
|
||||||
|
theme := opt.Theme
|
||||||
|
gap := 2
|
||||||
|
height := seriesPainter.Height()
|
||||||
|
width := seriesPainter.Width()
|
||||||
|
count := len(seriesList)
|
||||||
|
|
||||||
|
h := (height - gap*(count-1)) / count
|
||||||
|
|
||||||
|
y := 0
|
||||||
|
widthList := make([]int, len(seriesList))
|
||||||
|
textList := make([]string, len(seriesList))
|
||||||
|
seriesNames := seriesList.Names()
|
||||||
|
offset := max - min
|
||||||
|
for index, item := range seriesList {
|
||||||
|
value := item.Data[0].Value
|
||||||
|
// 最大最小值一致则为100%
|
||||||
|
widthPercent := 100.0
|
||||||
|
if offset != 0 {
|
||||||
|
widthPercent = (value - min) / offset
|
||||||
|
}
|
||||||
|
w := int(widthPercent * float64(width))
|
||||||
|
widthList[index] = w
|
||||||
|
// 如果最大值为0,则占比100%
|
||||||
|
percent := 1.0
|
||||||
|
if max != 0 {
|
||||||
|
percent = value / max
|
||||||
|
}
|
||||||
|
textList[index] = NewFunnelLabelFormatter(seriesNames, item.Label.Formatter)(index, value, percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, w := range widthList {
|
||||||
|
series := seriesList[index]
|
||||||
|
nextWidth := 0
|
||||||
|
if index+1 < len(widthList) {
|
||||||
|
nextWidth = widthList[index+1]
|
||||||
|
}
|
||||||
|
topStartX := (width - w) >> 1
|
||||||
|
topEndX := topStartX + w
|
||||||
|
bottomStartX := (width - nextWidth) >> 1
|
||||||
|
bottomEndX := bottomStartX + nextWidth
|
||||||
|
points := []Point{
|
||||||
|
{
|
||||||
|
X: topStartX,
|
||||||
|
Y: y,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: topEndX,
|
||||||
|
Y: y,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: bottomEndX,
|
||||||
|
Y: y + h,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: bottomStartX,
|
||||||
|
Y: y + h,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: topStartX,
|
||||||
|
Y: y,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
color := theme.GetSeriesColor(series.index)
|
||||||
|
|
||||||
|
seriesPainter.OverrideDrawingStyle(Style{
|
||||||
|
FillColor: color,
|
||||||
|
}).FillArea(points)
|
||||||
|
|
||||||
|
// 文本
|
||||||
|
text := textList[index]
|
||||||
|
seriesPainter.OverrideTextStyle(Style{
|
||||||
|
FontColor: theme.GetTextColor(),
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
Font: opt.Font,
|
||||||
|
})
|
||||||
|
textBox := seriesPainter.MeasureText(text)
|
||||||
|
textX := width>>1 - textBox.Width()>>1
|
||||||
|
textY := y + h>>1
|
||||||
|
seriesPainter.Text(text, textX, textY)
|
||||||
|
y += (h + gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.p.box, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *funnelChart) Render() (Box, error) {
|
||||||
|
p := f.p
|
||||||
|
opt := f.opt
|
||||||
|
renderResult, err := defaultRender(p, defaultRenderOption{
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Padding: opt.Padding,
|
||||||
|
SeriesList: opt.SeriesList,
|
||||||
|
XAxis: XAxisOption{
|
||||||
|
Show: FalseFlag(),
|
||||||
|
},
|
||||||
|
YAxisOptions: []YAxisOption{
|
||||||
|
{
|
||||||
|
Show: FalseFlag(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TitleOption: opt.Title,
|
||||||
|
LegendOption: opt.Legend,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
seriesList := opt.SeriesList.Filter(ChartTypeFunnel)
|
||||||
|
return f.render(renderResult, seriesList)
|
||||||
|
}
|
||||||
79
funnel_chart_test.go
Normal file
79
funnel_chart_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFunnelChart(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
render func(*Painter) ([]byte, error)
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
render: func(p *Painter) ([]byte, error) {
|
||||||
|
_, err := NewFunnelChart(p, FunnelChartOption{
|
||||||
|
SeriesList: NewFunnelSeriesList([]float64{
|
||||||
|
100,
|
||||||
|
80,
|
||||||
|
60,
|
||||||
|
40,
|
||||||
|
20,
|
||||||
|
}),
|
||||||
|
Legend: NewLegendOption([]string{
|
||||||
|
"Show",
|
||||||
|
"Click",
|
||||||
|
"Visit",
|
||||||
|
"Inquiry",
|
||||||
|
"Order",
|
||||||
|
}),
|
||||||
|
Title: TitleOption{
|
||||||
|
Text: "Funnel",
|
||||||
|
},
|
||||||
|
}).Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Bytes()
|
||||||
|
},
|
||||||
|
result: "<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
16
go.mod
|
|
@ -1,17 +1,17 @@
|
||||||
module github.com/vicanso/go-charts
|
module git.smarteching.com/zeni/go-charts/v2
|
||||||
|
|
||||||
go 1.17
|
go 1.24.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.0
|
git.smarteching.com/zeni/go-chart/v2 v2.1.4
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.0
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
||||||
|
github.com/stretchr/testify v1.10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
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
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
|
golang.org/x/image v0.21.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
27
go.sum
27
go.sum
|
|
@ -1,25 +1,18 @@
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
git.smarteching.com/zeni/go-chart/v2 v2.1.4 h1:pF06+F6eqJLIG8uMiTVPR5TygPGMjM/FHMzTxmu5V/Q=
|
||||||
|
git.smarteching.com/zeni/go-chart/v2 v2.1.4/go.mod h1:b3ueW9h3pGGXyhkormZAvilHaG4+mQti+bMNPdQBeOQ=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||||
github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I=
|
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
||||||
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|
|
||||||
92
grid.go
Normal file
92
grid.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
type gridPainter struct {
|
||||||
|
p *Painter
|
||||||
|
opt *GridPainterOption
|
||||||
|
}
|
||||||
|
|
||||||
|
type GridPainterOption struct {
|
||||||
|
// The stroke width
|
||||||
|
StrokeWidth float64
|
||||||
|
// The stroke color
|
||||||
|
StrokeColor Color
|
||||||
|
// The spans of column
|
||||||
|
ColumnSpans []int
|
||||||
|
// The column of grid
|
||||||
|
Column int
|
||||||
|
// The row of grid
|
||||||
|
Row int
|
||||||
|
// Ignore first row
|
||||||
|
IgnoreFirstRow bool
|
||||||
|
// Ignore last row
|
||||||
|
IgnoreLastRow bool
|
||||||
|
// Ignore first column
|
||||||
|
IgnoreFirstColumn bool
|
||||||
|
// Ignore last column
|
||||||
|
IgnoreLastColumn bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGridPainter returns new a grid renderer
|
||||||
|
func NewGridPainter(p *Painter, opt GridPainterOption) *gridPainter {
|
||||||
|
return &gridPainter{
|
||||||
|
p: p,
|
||||||
|
opt: &opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gridPainter) Render() (Box, error) {
|
||||||
|
opt := g.opt
|
||||||
|
ignoreColumnLines := make([]int, 0)
|
||||||
|
if opt.IgnoreFirstColumn {
|
||||||
|
ignoreColumnLines = append(ignoreColumnLines, 0)
|
||||||
|
}
|
||||||
|
if opt.IgnoreLastColumn {
|
||||||
|
ignoreColumnLines = append(ignoreColumnLines, opt.Column)
|
||||||
|
}
|
||||||
|
ignoreRowLines := make([]int, 0)
|
||||||
|
if opt.IgnoreFirstRow {
|
||||||
|
ignoreRowLines = append(ignoreRowLines, 0)
|
||||||
|
}
|
||||||
|
if opt.IgnoreLastRow {
|
||||||
|
ignoreRowLines = append(ignoreRowLines, opt.Row)
|
||||||
|
}
|
||||||
|
strokeWidth := opt.StrokeWidth
|
||||||
|
if strokeWidth <= 0 {
|
||||||
|
strokeWidth = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
g.p.SetDrawingStyle(Style{
|
||||||
|
StrokeWidth: strokeWidth,
|
||||||
|
StrokeColor: opt.StrokeColor,
|
||||||
|
})
|
||||||
|
g.p.Grid(GridOption{
|
||||||
|
Column: opt.Column,
|
||||||
|
ColumnSpans: opt.ColumnSpans,
|
||||||
|
Row: opt.Row,
|
||||||
|
IgnoreColumnLines: ignoreColumnLines,
|
||||||
|
IgnoreRowLines: ignoreRowLines,
|
||||||
|
})
|
||||||
|
return g.p.box, nil
|
||||||
|
}
|
||||||
87
grid_test.go
Normal file
87
grid_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGrid(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
render func(*Painter) ([]byte, error)
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
render: func(p *Painter) ([]byte, error) {
|
||||||
|
_, err := NewGridPainter(p, GridPainterOption{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
Column: 6,
|
||||||
|
Row: 6,
|
||||||
|
IgnoreFirstRow: true,
|
||||||
|
IgnoreLastRow: true,
|
||||||
|
IgnoreFirstColumn: true,
|
||||||
|
IgnoreLastColumn: true,
|
||||||
|
}).Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Bytes()
|
||||||
|
},
|
||||||
|
result: "<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
216
horizontal_bar_chart.go
Normal file
216
horizontal_bar_chart.go
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type horizontalBarChart struct {
|
||||||
|
p *Painter
|
||||||
|
opt *HorizontalBarChartOption
|
||||||
|
}
|
||||||
|
|
||||||
|
type HorizontalBarChartOption struct {
|
||||||
|
// The theme
|
||||||
|
Theme ColorPalette
|
||||||
|
// The font size
|
||||||
|
Font *truetype.Font
|
||||||
|
// The data series list
|
||||||
|
SeriesList SeriesList
|
||||||
|
// The x axis option
|
||||||
|
XAxis XAxisOption
|
||||||
|
// The padding of line chart
|
||||||
|
Padding Box
|
||||||
|
// The y axis option
|
||||||
|
YAxisOptions []YAxisOption
|
||||||
|
// The option of title
|
||||||
|
Title TitleOption
|
||||||
|
// The legend option
|
||||||
|
Legend LegendOption
|
||||||
|
BarHeight int
|
||||||
|
// Margin of bar
|
||||||
|
BarMargin int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHorizontalBarChart returns a horizontal bar chart renderer
|
||||||
|
func NewHorizontalBarChart(p *Painter, opt HorizontalBarChartOption) *horizontalBarChart {
|
||||||
|
if opt.Theme == nil {
|
||||||
|
opt.Theme = defaultTheme
|
||||||
|
}
|
||||||
|
return &horizontalBarChart{
|
||||||
|
p: p,
|
||||||
|
opt: &opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
|
||||||
|
p := h.p
|
||||||
|
opt := h.opt
|
||||||
|
seriesPainter := result.seriesPainter
|
||||||
|
yRange := result.axisRanges[0]
|
||||||
|
y0, y1 := yRange.GetRange(0)
|
||||||
|
height := int(y1 - y0)
|
||||||
|
// 每一块之间的margin
|
||||||
|
margin := 10
|
||||||
|
// 每一个bar之间的margin
|
||||||
|
barMargin := 5
|
||||||
|
if height < 20 {
|
||||||
|
margin = 2
|
||||||
|
barMargin = 2
|
||||||
|
} else if height < 50 {
|
||||||
|
margin = 5
|
||||||
|
barMargin = 3
|
||||||
|
}
|
||||||
|
if opt.BarMargin > 0 {
|
||||||
|
barMargin = opt.BarMargin
|
||||||
|
}
|
||||||
|
seriesCount := len(seriesList)
|
||||||
|
// 总的高度-两个margin-(总数-1)的barMargin
|
||||||
|
barHeight := (height - 2*margin - barMargin*(seriesCount-1)) / seriesCount
|
||||||
|
if opt.BarHeight > 0 && opt.BarHeight < barHeight {
|
||||||
|
barHeight = opt.BarHeight
|
||||||
|
margin = (height - seriesCount*barHeight - barMargin*(seriesCount-1)) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
theme := opt.Theme
|
||||||
|
|
||||||
|
max, min := seriesList.GetMaxMin(0)
|
||||||
|
xRange := NewRange(AxisRangeOption{
|
||||||
|
Painter: p,
|
||||||
|
Min: min,
|
||||||
|
Max: max,
|
||||||
|
DivideCount: defaultAxisDivideCount,
|
||||||
|
Size: seriesPainter.Width(),
|
||||||
|
})
|
||||||
|
seriesNames := seriesList.Names()
|
||||||
|
|
||||||
|
rendererList := []Renderer{}
|
||||||
|
for index := range seriesList {
|
||||||
|
series := seriesList[index]
|
||||||
|
seriesColor := theme.GetSeriesColor(series.index)
|
||||||
|
divideValues := yRange.AutoDivide()
|
||||||
|
|
||||||
|
var labelPainter *SeriesLabelPainter
|
||||||
|
if series.Label.Show {
|
||||||
|
labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
|
||||||
|
P: seriesPainter,
|
||||||
|
SeriesNames: seriesNames,
|
||||||
|
Label: series.Label,
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Font: opt.Font,
|
||||||
|
})
|
||||||
|
rendererList = append(rendererList, labelPainter)
|
||||||
|
}
|
||||||
|
for j, item := range series.Data {
|
||||||
|
if j >= yRange.divideCount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 显示位置切换
|
||||||
|
j = yRange.divideCount - j - 1
|
||||||
|
y := divideValues[j]
|
||||||
|
y += margin
|
||||||
|
if index != 0 {
|
||||||
|
y += index * (barHeight + barMargin)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := int(xRange.getHeight(item.Value))
|
||||||
|
fillColor := seriesColor
|
||||||
|
if !item.Style.FillColor.IsZero() {
|
||||||
|
fillColor = item.Style.FillColor
|
||||||
|
}
|
||||||
|
right := w
|
||||||
|
if series.RoundRadius <= 0 {
|
||||||
|
seriesPainter.OverrideDrawingStyle(Style{
|
||||||
|
FillColor: fillColor,
|
||||||
|
}).Rect(chart.Box{
|
||||||
|
Top: y,
|
||||||
|
Left: 0,
|
||||||
|
Right: right,
|
||||||
|
Bottom: y + barHeight,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
seriesPainter.OverrideDrawingStyle(Style{
|
||||||
|
FillColor: fillColor,
|
||||||
|
}).RoundedRect(chart.Box{
|
||||||
|
Top: y,
|
||||||
|
Left: 0,
|
||||||
|
Right: right,
|
||||||
|
Bottom: y + barHeight,
|
||||||
|
}, series.RoundRadius)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果label不需要展示,则返回
|
||||||
|
if labelPainter == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
labelValue := LabelValue{
|
||||||
|
Orient: OrientHorizontal,
|
||||||
|
Index: index,
|
||||||
|
Value: item.Value,
|
||||||
|
X: right,
|
||||||
|
Y: y + barHeight>>1,
|
||||||
|
Offset: series.Label.Offset,
|
||||||
|
FontColor: series.Label.Color,
|
||||||
|
FontSize: series.Label.FontSize,
|
||||||
|
}
|
||||||
|
if series.Label.Position == PositionLeft {
|
||||||
|
labelValue.X = 0
|
||||||
|
if labelValue.FontColor.IsZero() {
|
||||||
|
if isLightColor(fillColor) {
|
||||||
|
labelValue.FontColor = defaultLightFontColor
|
||||||
|
} else {
|
||||||
|
labelValue.FontColor = defaultDarkFontColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelPainter.Add(labelValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := doRender(rendererList...)
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
return p.box, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *horizontalBarChart) Render() (Box, error) {
|
||||||
|
p := h.p
|
||||||
|
opt := h.opt
|
||||||
|
renderResult, err := defaultRender(p, defaultRenderOption{
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Padding: opt.Padding,
|
||||||
|
SeriesList: opt.SeriesList,
|
||||||
|
XAxis: opt.XAxis,
|
||||||
|
YAxisOptions: opt.YAxisOptions,
|
||||||
|
TitleOption: opt.Title,
|
||||||
|
LegendOption: opt.Legend,
|
||||||
|
axisReversed: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
seriesList := opt.SeriesList.Filter(ChartTypeHorizontalBar)
|
||||||
|
return h.render(renderResult, seriesList)
|
||||||
|
}
|
||||||
100
horizontal_bar_chart_test.go
Normal file
100
horizontal_bar_chart_test.go
Normal file
File diff suppressed because one or more lines are too long
424
legend.go
424
legend.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -25,225 +25,227 @@ package charts
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type legendPainter struct {
|
||||||
|
p *Painter
|
||||||
|
opt *LegendOption
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconRect = "rect"
|
||||||
|
const IconLineDot = "lineDot"
|
||||||
|
|
||||||
type LegendOption struct {
|
type LegendOption struct {
|
||||||
Style chart.Style
|
// The theme
|
||||||
Padding chart.Box
|
Theme ColorPalette
|
||||||
Left string
|
// Text array of legend
|
||||||
Right string
|
Data []string
|
||||||
Top string
|
// Distance between legend component and the left side of the container.
|
||||||
Bottom string
|
// It can be pixel value: 20, percentage value: 20%,
|
||||||
Align string
|
// or position value: right, center.
|
||||||
Theme string
|
Left string
|
||||||
IconDraw LegendIconDraw
|
// Distance between legend component and the top side of the container.
|
||||||
|
// It can be pixel value: 20.
|
||||||
|
Top string
|
||||||
|
// Legend marker and text aligning, it can be left or right, default is left.
|
||||||
|
Align string
|
||||||
|
// The layout orientation of legend, it can be horizontal or vertical, default is horizontal.
|
||||||
|
Orient string
|
||||||
|
// Icon of the legend.
|
||||||
|
Icon string
|
||||||
|
// Font size of legend text
|
||||||
|
FontSize float64
|
||||||
|
// FontColor color of legend text
|
||||||
|
FontColor Color
|
||||||
|
// The flag for show legend, set this to *false will hide legend
|
||||||
|
Show *bool
|
||||||
|
// The padding of legend
|
||||||
|
Padding Box
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegendIconDrawOption struct {
|
// NewLegendOption returns a legend option
|
||||||
Box chart.Box
|
func NewLegendOption(labels []string, left ...string) LegendOption {
|
||||||
Style chart.Style
|
opt := LegendOption{
|
||||||
Index int
|
Data: labels,
|
||||||
Theme string
|
}
|
||||||
|
if len(left) != 0 {
|
||||||
|
opt.Left = left[0]
|
||||||
|
}
|
||||||
|
return opt
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// IsEmpty checks legend is empty
|
||||||
LegendAlignLeft = "left"
|
func (opt *LegendOption) IsEmpty() bool {
|
||||||
LegendAlignRight = "right"
|
isEmpty := true
|
||||||
)
|
for _, v := range opt.Data {
|
||||||
|
if v != "" {
|
||||||
type LegendIconDraw func(r chart.Renderer, opt LegendIconDrawOption)
|
isEmpty = false
|
||||||
|
break
|
||||||
func DefaultLegendIconDraw(r chart.Renderer, opt LegendIconDrawOption) {
|
}
|
||||||
if opt.Box.IsZero() {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
r.SetStrokeColor(opt.Style.GetStrokeColor())
|
return isEmpty
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertPercent(value string) float64 {
|
// NewLegendPainter returns a legend renderer
|
||||||
if !strings.HasSuffix(value, "%") {
|
func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter {
|
||||||
return -1
|
return &legendPainter{
|
||||||
}
|
p: p,
|
||||||
v, err := strconv.Atoi(strings.ReplaceAll(value, "%", ""))
|
opt: &opt,
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return float64(v) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLegendLeft(canvasWidth, legendBoxWidth int, opt LegendOption) int {
|
|
||||||
left := (canvasWidth - legendBoxWidth) / 2
|
|
||||||
leftValue := opt.Left
|
|
||||||
if leftValue == "auto" || leftValue == "center" {
|
|
||||||
leftValue = ""
|
|
||||||
}
|
|
||||||
if leftValue == "left" {
|
|
||||||
leftValue = "0"
|
|
||||||
}
|
|
||||||
|
|
||||||
rightValue := opt.Right
|
|
||||||
if rightValue == "auto" || leftValue == "center" {
|
|
||||||
rightValue = ""
|
|
||||||
}
|
|
||||||
if rightValue == "right" {
|
|
||||||
rightValue = "0"
|
|
||||||
}
|
|
||||||
if leftValue == "" && rightValue == "" {
|
|
||||||
return left
|
|
||||||
}
|
|
||||||
if leftValue != "" {
|
|
||||||
percent := 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
legendStyle := opt.Style.InheritFrom(chartDefaults.InheritFrom(legendDefaults))
|
|
||||||
|
|
||||||
r.SetFont(legendStyle.GetFont())
|
|
||||||
r.SetFontColor(legendStyle.GetFontColor())
|
|
||||||
r.SetFontSize(legendStyle.GetFontSize())
|
|
||||||
|
|
||||||
var labels []string
|
|
||||||
var lines []chart.Style
|
|
||||||
// 计算label和lines
|
|
||||||
for _, s := range series {
|
|
||||||
if !s.GetStyle().Hidden {
|
|
||||||
if _, isAnnotationSeries := s.(chart.AnnotationSeries); !isAnnotationSeries {
|
|
||||||
labels = append(labels, s.GetName())
|
|
||||||
lines = append(lines, s.GetStyle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var textHeight int
|
|
||||||
var 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 + legendBoxWidth,
|
|
||||||
Top: top,
|
|
||||||
Bottom: top + legendBoxHeight,
|
|
||||||
}
|
|
||||||
|
|
||||||
chart.Draw.Box(r, legendBox, legendDefaults)
|
|
||||||
|
|
||||||
r.SetFont(legendStyle.GetFont())
|
|
||||||
r.SetFontColor(legendStyle.GetFontColor())
|
|
||||||
r.SetFontSize(legendStyle.GetFontSize())
|
|
||||||
|
|
||||||
startX := legendBox.Left + legendStyle.Padding.Left
|
|
||||||
ty := top + legendYMargin + legendStyle.Padding.Top + textHeight
|
|
||||||
var label string
|
|
||||||
var x int
|
|
||||||
iconDraw := opt.IconDraw
|
|
||||||
if iconDraw == nil {
|
|
||||||
iconDraw = DefaultLegendIconDraw
|
|
||||||
}
|
|
||||||
align := opt.Align
|
|
||||||
if align == "" {
|
|
||||||
align = LegendAlignLeft
|
|
||||||
}
|
|
||||||
for index := range labels {
|
|
||||||
label = labels[index]
|
|
||||||
if len(label) > 0 {
|
|
||||||
x = startX
|
|
||||||
|
|
||||||
// 如果图例标记靠右展示
|
|
||||||
if align == LegendAlignRight {
|
|
||||||
textBox = r.MeasureText(label)
|
|
||||||
r.Text(label, x, ty)
|
|
||||||
x = startX + textBox.Width() + lineTextGap
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图标
|
|
||||||
iconDraw(r, LegendIconDrawOption{
|
|
||||||
Theme: opt.Theme,
|
|
||||||
Index: index,
|
|
||||||
Style: lines[index],
|
|
||||||
Box: chart.Box{
|
|
||||||
Left: x,
|
|
||||||
Top: ty,
|
|
||||||
Right: x + iconWidth,
|
|
||||||
Bottom: ty + textHeight,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
x += (iconWidth + lineTextGap)
|
|
||||||
|
|
||||||
// 如果图例标记靠左展示
|
|
||||||
if align == LegendAlignLeft {
|
|
||||||
textBox = r.MeasureText(label)
|
|
||||||
r.Text(label, x, ty)
|
|
||||||
x += textBox.Width()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算下一个legend的位置
|
|
||||||
startX = x + chart.DefaultMinimumTickHorizontalSpacing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *legendPainter) Render() (Box, error) {
|
||||||
|
opt := l.opt
|
||||||
|
theme := opt.Theme
|
||||||
|
if opt.IsEmpty() ||
|
||||||
|
isFalse(opt.Show) {
|
||||||
|
return BoxZero, nil
|
||||||
|
}
|
||||||
|
if theme == nil {
|
||||||
|
theme = l.p.theme
|
||||||
|
}
|
||||||
|
if opt.FontSize == 0 {
|
||||||
|
opt.FontSize = theme.GetFontSize()
|
||||||
|
}
|
||||||
|
if opt.FontColor.IsZero() {
|
||||||
|
opt.FontColor = theme.GetTextColor()
|
||||||
|
}
|
||||||
|
if opt.Left == "" {
|
||||||
|
opt.Left = PositionCenter
|
||||||
|
}
|
||||||
|
padding := opt.Padding
|
||||||
|
if padding.IsZero() {
|
||||||
|
padding.Top = 5
|
||||||
|
}
|
||||||
|
p := l.p.Child(PainterPaddingOption(padding))
|
||||||
|
p.SetTextStyle(Style{
|
||||||
|
FontSize: opt.FontSize,
|
||||||
|
FontColor: opt.FontColor,
|
||||||
|
})
|
||||||
|
measureList := make([]Box, len(opt.Data))
|
||||||
|
maxTextWidth := 0
|
||||||
|
for index, text := range opt.Data {
|
||||||
|
b := p.MeasureText(text)
|
||||||
|
if b.Width() > maxTextWidth {
|
||||||
|
maxTextWidth = b.Width()
|
||||||
|
}
|
||||||
|
measureList[index] = b
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算展示的宽高
|
||||||
|
width := 0
|
||||||
|
height := 0
|
||||||
|
offset := 20
|
||||||
|
textOffset := 2
|
||||||
|
legendWidth := 30
|
||||||
|
legendHeight := 20
|
||||||
|
itemMaxHeight := 0
|
||||||
|
for _, item := range measureList {
|
||||||
|
if item.Height() > itemMaxHeight {
|
||||||
|
itemMaxHeight = item.Height()
|
||||||
|
}
|
||||||
|
if opt.Orient == OrientVertical {
|
||||||
|
height += item.Height()
|
||||||
|
} else {
|
||||||
|
width += item.Width()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 增加padding
|
||||||
|
itemMaxHeight += 10
|
||||||
|
if opt.Orient == OrientVertical {
|
||||||
|
width = maxTextWidth + textOffset + legendWidth
|
||||||
|
height = offset * len(opt.Data)
|
||||||
|
} else {
|
||||||
|
height = legendHeight
|
||||||
|
offsetValue := (len(opt.Data) - 1) * (offset + textOffset)
|
||||||
|
allLegendWidth := len(opt.Data) * legendWidth
|
||||||
|
width += (offsetValue + allLegendWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算开始的位置
|
||||||
|
left := 0
|
||||||
|
switch opt.Left {
|
||||||
|
case PositionRight:
|
||||||
|
left = p.Width() - width
|
||||||
|
case PositionCenter:
|
||||||
|
left = (p.Width() - width) >> 1
|
||||||
|
default:
|
||||||
|
if strings.HasSuffix(opt.Left, "%") {
|
||||||
|
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
|
||||||
|
left = p.Width() * value / 100
|
||||||
|
} else {
|
||||||
|
value, _ := strconv.Atoi(opt.Left)
|
||||||
|
left = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
top, _ := strconv.Atoi(opt.Top)
|
||||||
|
|
||||||
|
if left < 0 {
|
||||||
|
left = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
x := int(left)
|
||||||
|
y := int(top) + 10
|
||||||
|
startY := y
|
||||||
|
x0 := x
|
||||||
|
y0 := y
|
||||||
|
|
||||||
|
drawIcon := func(top, left int) int {
|
||||||
|
if opt.Icon == IconRect {
|
||||||
|
p.Rect(Box{
|
||||||
|
Top: top - legendHeight + 8,
|
||||||
|
Left: left,
|
||||||
|
Right: left + legendWidth,
|
||||||
|
Bottom: top + 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
p.LegendLineDot(Box{
|
||||||
|
Top: top + 1,
|
||||||
|
Left: left,
|
||||||
|
Right: left + legendWidth,
|
||||||
|
Bottom: top + legendHeight + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return left + legendWidth
|
||||||
|
}
|
||||||
|
lastIndex := len(opt.Data) - 1
|
||||||
|
for index, text := range opt.Data {
|
||||||
|
color := theme.GetSeriesColor(index)
|
||||||
|
p.SetDrawingStyle(Style{
|
||||||
|
FillColor: color,
|
||||||
|
StrokeColor: color,
|
||||||
|
})
|
||||||
|
itemWidth := x0 + measureList[index].Width() + textOffset + offset + legendWidth
|
||||||
|
if lastIndex == index {
|
||||||
|
itemWidth = x0 + measureList[index].Width() + legendWidth
|
||||||
|
}
|
||||||
|
if itemWidth > p.Width() {
|
||||||
|
x0 = 0
|
||||||
|
y += itemMaxHeight
|
||||||
|
y0 = y
|
||||||
|
}
|
||||||
|
if opt.Align != AlignRight {
|
||||||
|
x0 = drawIcon(y0, x0)
|
||||||
|
x0 += textOffset
|
||||||
|
}
|
||||||
|
p.Text(text, x0, y0)
|
||||||
|
x0 += measureList[index].Width()
|
||||||
|
if opt.Align == AlignRight {
|
||||||
|
x0 += textOffset
|
||||||
|
x0 = drawIcon(y0, x0)
|
||||||
|
}
|
||||||
|
if opt.Orient == OrientVertical {
|
||||||
|
y0 += offset
|
||||||
|
x0 = x
|
||||||
|
} else {
|
||||||
|
x0 += offset
|
||||||
|
y0 = y
|
||||||
|
}
|
||||||
|
height = y0 - startY + 10
|
||||||
|
}
|
||||||
|
|
||||||
|
return Box{
|
||||||
|
Right: width,
|
||||||
|
Bottom: height + padding.Bottom + padding.Top,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
129
legend_test.go
129
legend_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,91 +23,80 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewLegendCustomize(t *testing.T) {
|
func TestNewLegend(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
series := GetSeries([]Series{
|
|
||||||
{
|
|
||||||
Name: "chrome",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "edge",
|
|
||||||
},
|
|
||||||
}, chart.TickPositionBetweenTicks, "")
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
align string
|
render func(*Painter) ([]byte, error)
|
||||||
svg string
|
result string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
align: LegendAlignLeft,
|
render: func(p *Painter) ([]byte, error) {
|
||||||
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>",
|
_, 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: LegendAlignRight,
|
render: func(p *Painter) ([]byte, error) {
|
||||||
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>",
|
_, 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>",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
r, err := chart.SVG(800, 600)
|
p, err := NewPainter(PainterOptions{
|
||||||
|
Type: ChartOutputSVG,
|
||||||
|
Width: 600,
|
||||||
|
Height: 400,
|
||||||
|
}, PainterThemeOption(defaultTheme))
|
||||||
assert.Nil(err)
|
assert.Nil(err)
|
||||||
fn := NewLegendCustomize(series, LegendOption{
|
data, err := tt.render(p)
|
||||||
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.Nil(err)
|
||||||
assert.Equal(tt.svg, buf.String())
|
assert.Equal(tt.result, string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
|
||||||
}))
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
||||||
240
line_chart.go
Normal file
240
line_chart.go
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type lineChart struct {
|
||||||
|
p *Painter
|
||||||
|
opt *LineChartOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLineChart returns a line chart render
|
||||||
|
func NewLineChart(p *Painter, opt LineChartOption) *lineChart {
|
||||||
|
if opt.Theme == nil {
|
||||||
|
opt.Theme = defaultTheme
|
||||||
|
}
|
||||||
|
return &lineChart{
|
||||||
|
p: p,
|
||||||
|
opt: &opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LineChartOption struct {
|
||||||
|
// The theme
|
||||||
|
Theme ColorPalette
|
||||||
|
// The font size
|
||||||
|
Font *truetype.Font
|
||||||
|
// The data series list
|
||||||
|
SeriesList SeriesList
|
||||||
|
// The x axis option
|
||||||
|
XAxis XAxisOption
|
||||||
|
// The padding of line chart
|
||||||
|
Padding Box
|
||||||
|
// The y axis option
|
||||||
|
YAxisOptions []YAxisOption
|
||||||
|
// The option of title
|
||||||
|
Title TitleOption
|
||||||
|
// The legend option
|
||||||
|
Legend LegendOption
|
||||||
|
// The flag for show symbol of line, set this to *false will hide symbol
|
||||||
|
SymbolShow *bool
|
||||||
|
// The stroke width of line
|
||||||
|
StrokeWidth float64
|
||||||
|
// Fill the area of line
|
||||||
|
FillArea bool
|
||||||
|
// background is filled
|
||||||
|
backgroundIsFilled bool
|
||||||
|
// background fill (alpha) opacity
|
||||||
|
Opacity uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
|
||||||
|
p := l.p
|
||||||
|
opt := l.opt
|
||||||
|
boundaryGap := true
|
||||||
|
if isFalse(opt.XAxis.BoundaryGap) {
|
||||||
|
boundaryGap = false
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesPainter := result.seriesPainter
|
||||||
|
|
||||||
|
xDivideCount := len(opt.XAxis.Data)
|
||||||
|
if !boundaryGap {
|
||||||
|
xDivideCount--
|
||||||
|
}
|
||||||
|
xDivideValues := autoDivide(seriesPainter.Width(), xDivideCount)
|
||||||
|
xValues := make([]int, len(xDivideValues)-1)
|
||||||
|
if boundaryGap {
|
||||||
|
for i := 0; i < len(xDivideValues)-1; i++ {
|
||||||
|
xValues[i] = (xDivideValues[i] + xDivideValues[i+1]) >> 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
xValues = xDivideValues
|
||||||
|
}
|
||||||
|
markPointPainter := NewMarkPointPainter(seriesPainter)
|
||||||
|
markLinePainter := NewMarkLinePainter(seriesPainter)
|
||||||
|
rendererList := []Renderer{
|
||||||
|
markPointPainter,
|
||||||
|
markLinePainter,
|
||||||
|
}
|
||||||
|
strokeWidth := opt.StrokeWidth
|
||||||
|
if strokeWidth == 0 {
|
||||||
|
strokeWidth = defaultStrokeWidth
|
||||||
|
}
|
||||||
|
seriesNames := seriesList.Names()
|
||||||
|
for index := range seriesList {
|
||||||
|
series := seriesList[index]
|
||||||
|
seriesColor := opt.Theme.GetSeriesColor(series.index)
|
||||||
|
drawingStyle := Style{
|
||||||
|
StrokeColor: seriesColor,
|
||||||
|
StrokeWidth: strokeWidth,
|
||||||
|
}
|
||||||
|
if len(series.Style.StrokeDashArray) > 0 {
|
||||||
|
drawingStyle.StrokeDashArray = series.Style.StrokeDashArray
|
||||||
|
}
|
||||||
|
|
||||||
|
yRange := result.axisRanges[series.AxisIndex]
|
||||||
|
points := make([]Point, 0)
|
||||||
|
var labelPainter *SeriesLabelPainter
|
||||||
|
if series.Label.Show {
|
||||||
|
labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
|
||||||
|
P: seriesPainter,
|
||||||
|
SeriesNames: seriesNames,
|
||||||
|
Label: series.Label,
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Font: opt.Font,
|
||||||
|
})
|
||||||
|
rendererList = append(rendererList, labelPainter)
|
||||||
|
}
|
||||||
|
for i, item := range series.Data {
|
||||||
|
h := yRange.getRestHeight(item.Value)
|
||||||
|
if item.Value == nullValue {
|
||||||
|
h = int(math.MaxInt32)
|
||||||
|
}
|
||||||
|
p := Point{
|
||||||
|
X: xValues[i],
|
||||||
|
Y: h,
|
||||||
|
}
|
||||||
|
points = append(points, p)
|
||||||
|
|
||||||
|
// 如果label不需要展示,则返回
|
||||||
|
if labelPainter == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
labelPainter.Add(LabelValue{
|
||||||
|
Index: index,
|
||||||
|
Value: item.Value,
|
||||||
|
X: p.X,
|
||||||
|
Y: p.Y,
|
||||||
|
// 字体大小
|
||||||
|
FontSize: series.Label.FontSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 如果需要填充区域
|
||||||
|
if opt.FillArea {
|
||||||
|
areaPoints := make([]Point, len(points))
|
||||||
|
copy(areaPoints, points)
|
||||||
|
bottomY := yRange.getRestHeight(yRange.min)
|
||||||
|
var opacity uint8 = 200
|
||||||
|
if opt.Opacity != 0 {
|
||||||
|
opacity = opt.Opacity
|
||||||
|
}
|
||||||
|
areaPoints = append(areaPoints, Point{
|
||||||
|
X: areaPoints[len(areaPoints)-1].X,
|
||||||
|
Y: bottomY,
|
||||||
|
}, Point{
|
||||||
|
X: areaPoints[0].X,
|
||||||
|
Y: bottomY,
|
||||||
|
}, areaPoints[0])
|
||||||
|
seriesPainter.SetDrawingStyle(Style{
|
||||||
|
FillColor: seriesColor.WithAlpha(opacity),
|
||||||
|
})
|
||||||
|
seriesPainter.FillArea(areaPoints)
|
||||||
|
}
|
||||||
|
seriesPainter.SetDrawingStyle(drawingStyle)
|
||||||
|
|
||||||
|
// 画线
|
||||||
|
seriesPainter.LineStroke(points)
|
||||||
|
|
||||||
|
// 画点
|
||||||
|
if opt.Theme.IsDark() {
|
||||||
|
drawingStyle.FillColor = drawingStyle.StrokeColor
|
||||||
|
} else {
|
||||||
|
drawingStyle.FillColor = drawing.ColorWhite
|
||||||
|
}
|
||||||
|
drawingStyle.StrokeWidth = 1
|
||||||
|
seriesPainter.SetDrawingStyle(drawingStyle)
|
||||||
|
if !isFalse(opt.SymbolShow) {
|
||||||
|
seriesPainter.Dots(points)
|
||||||
|
}
|
||||||
|
markPointPainter.Add(markPointRenderOption{
|
||||||
|
FillColor: seriesColor,
|
||||||
|
Font: opt.Font,
|
||||||
|
Points: points,
|
||||||
|
Series: series,
|
||||||
|
})
|
||||||
|
markLinePainter.Add(markLineRenderOption{
|
||||||
|
FillColor: seriesColor,
|
||||||
|
FontColor: opt.Theme.GetTextColor(),
|
||||||
|
StrokeColor: seriesColor,
|
||||||
|
Font: opt.Font,
|
||||||
|
Series: series,
|
||||||
|
Range: yRange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 最大、最小的mark point
|
||||||
|
err := doRender(rendererList...)
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.box, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *lineChart) Render() (Box, error) {
|
||||||
|
p := l.p
|
||||||
|
opt := l.opt
|
||||||
|
|
||||||
|
renderResult, err := defaultRender(p, defaultRenderOption{
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Padding: opt.Padding,
|
||||||
|
SeriesList: opt.SeriesList,
|
||||||
|
XAxis: opt.XAxis,
|
||||||
|
YAxisOptions: opt.YAxisOptions,
|
||||||
|
TitleOption: opt.Title,
|
||||||
|
LegendOption: opt.Legend,
|
||||||
|
backgroundIsFilled: opt.backgroundIsFilled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
seriesList := opt.SeriesList.Filter(ChartTypeLine)
|
||||||
|
|
||||||
|
return l.render(renderResult, seriesList)
|
||||||
|
}
|
||||||
219
line_chart_test.go
Normal file
219
line_chart_test.go
Normal file
File diff suppressed because one or more lines are too long
113
mark_line.go
Normal file
113
mark_line.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewMarkLine returns a series mark line
|
||||||
|
func NewMarkLine(markLineTypes ...string) SeriesMarkLine {
|
||||||
|
data := make([]SeriesMarkData, len(markLineTypes))
|
||||||
|
for index, t := range markLineTypes {
|
||||||
|
data[index] = SeriesMarkData{
|
||||||
|
Type: t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SeriesMarkLine{
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type markLinePainter struct {
|
||||||
|
p *Painter
|
||||||
|
options []markLineRenderOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *markLinePainter) Add(opt markLineRenderOption) {
|
||||||
|
m.options = append(m.options, opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMarkLinePainter returns a mark line renderer
|
||||||
|
func NewMarkLinePainter(p *Painter) *markLinePainter {
|
||||||
|
return &markLinePainter{
|
||||||
|
p: p,
|
||||||
|
options: make([]markLineRenderOption, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type markLineRenderOption struct {
|
||||||
|
FillColor Color
|
||||||
|
FontColor Color
|
||||||
|
StrokeColor Color
|
||||||
|
Font *truetype.Font
|
||||||
|
Series Series
|
||||||
|
Range axisRange
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *markLinePainter) Render() (Box, error) {
|
||||||
|
painter := m.p
|
||||||
|
for _, opt := range m.options {
|
||||||
|
s := opt.Series
|
||||||
|
if len(s.MarkLine.Data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
font := opt.Font
|
||||||
|
if font == nil {
|
||||||
|
font, _ = GetDefaultFont()
|
||||||
|
}
|
||||||
|
summary := s.Summary()
|
||||||
|
for _, markLine := range s.MarkLine.Data {
|
||||||
|
// 由于mark line会修改style,因此每次重新设置
|
||||||
|
painter.OverrideDrawingStyle(Style{
|
||||||
|
FillColor: opt.FillColor,
|
||||||
|
StrokeColor: opt.StrokeColor,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeDashArray: []float64{
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
},
|
||||||
|
}).OverrideTextStyle(Style{
|
||||||
|
Font: font,
|
||||||
|
FontColor: opt.FontColor,
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
})
|
||||||
|
value := float64(0)
|
||||||
|
switch markLine.Type {
|
||||||
|
case SeriesMarkDataTypeMax:
|
||||||
|
value = summary.MaxValue
|
||||||
|
case SeriesMarkDataTypeMin:
|
||||||
|
value = summary.MinValue
|
||||||
|
default:
|
||||||
|
value = summary.AverageValue
|
||||||
|
}
|
||||||
|
y := opt.Range.getRestHeight(value)
|
||||||
|
width := painter.Width()
|
||||||
|
text := commafWithDigits(value)
|
||||||
|
textBox := painter.MeasureText(text)
|
||||||
|
painter.MarkLine(0, y, width-2)
|
||||||
|
painter.Text(text, width, y+textBox.Height()>>1-2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BoxZero, nil
|
||||||
|
}
|
||||||
90
mark_line_test.go
Normal file
90
mark_line_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarkLine(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
render func(*Painter) ([]byte, error)
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
render: func(p *Painter) ([]byte, error) {
|
||||||
|
markLine := NewMarkLinePainter(p)
|
||||||
|
series := NewSeriesFromValues([]float64{
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
})
|
||||||
|
series.MarkLine = NewMarkLine(
|
||||||
|
SeriesMarkDataTypeMax,
|
||||||
|
SeriesMarkDataTypeAverage,
|
||||||
|
SeriesMarkDataTypeMin,
|
||||||
|
)
|
||||||
|
markLine.Add(markLineRenderOption{
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
Series: series,
|
||||||
|
Range: NewRange(AxisRangeOption{
|
||||||
|
Painter: p,
|
||||||
|
Min: 0,
|
||||||
|
Max: 5,
|
||||||
|
Size: p.Height(),
|
||||||
|
DivideCount: 6,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
_, err := markLine.Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Bytes()
|
||||||
|
},
|
||||||
|
result: "<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
115
mark_point.go
Normal file
115
mark_point.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewMarkPoint returns a series mark point
|
||||||
|
func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint {
|
||||||
|
data := make([]SeriesMarkData, len(markPointTypes))
|
||||||
|
for index, t := range markPointTypes {
|
||||||
|
data[index] = SeriesMarkData{
|
||||||
|
Type: t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SeriesMarkPoint{
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type markPointPainter struct {
|
||||||
|
p *Painter
|
||||||
|
options []markPointRenderOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *markPointPainter) Add(opt markPointRenderOption) {
|
||||||
|
m.options = append(m.options, opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
type markPointRenderOption struct {
|
||||||
|
FillColor Color
|
||||||
|
Font *truetype.Font
|
||||||
|
Series Series
|
||||||
|
Points []Point
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMarkPointPainter returns a mark point renderer
|
||||||
|
func NewMarkPointPainter(p *Painter) *markPointPainter {
|
||||||
|
return &markPointPainter{
|
||||||
|
p: p,
|
||||||
|
options: make([]markPointRenderOption, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *markPointPainter) Render() (Box, error) {
|
||||||
|
painter := m.p
|
||||||
|
for _, opt := range m.options {
|
||||||
|
s := opt.Series
|
||||||
|
if len(s.MarkPoint.Data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
points := opt.Points
|
||||||
|
summary := s.Summary()
|
||||||
|
symbolSize := s.MarkPoint.SymbolSize
|
||||||
|
if symbolSize == 0 {
|
||||||
|
symbolSize = 30
|
||||||
|
}
|
||||||
|
textStyle := Style{
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
Font: opt.Font,
|
||||||
|
}
|
||||||
|
if isLightColor(opt.FillColor) {
|
||||||
|
textStyle.FontColor = defaultLightFontColor
|
||||||
|
} else {
|
||||||
|
textStyle.FontColor = defaultDarkFontColor
|
||||||
|
}
|
||||||
|
painter.OverrideDrawingStyle(Style{
|
||||||
|
FillColor: opt.FillColor,
|
||||||
|
}).OverrideTextStyle(textStyle)
|
||||||
|
for _, markPointData := range s.MarkPoint.Data {
|
||||||
|
textStyle.FontSize = labelFontSize
|
||||||
|
painter.OverrideTextStyle(textStyle)
|
||||||
|
p := points[summary.MinIndex]
|
||||||
|
value := summary.MinValue
|
||||||
|
switch markPointData.Type {
|
||||||
|
case SeriesMarkDataTypeMax:
|
||||||
|
p = points[summary.MaxIndex]
|
||||||
|
value = summary.MaxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
painter.Pin(p.X, p.Y-symbolSize>>1, symbolSize)
|
||||||
|
text := commafWithDigits(value)
|
||||||
|
textBox := painter.MeasureText(text)
|
||||||
|
if textBox.Width() > symbolSize {
|
||||||
|
textStyle.FontSize = smallLabelFontSize
|
||||||
|
painter.OverrideTextStyle(textStyle)
|
||||||
|
textBox = painter.MeasureText(text)
|
||||||
|
}
|
||||||
|
painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BoxZero, nil
|
||||||
|
}
|
||||||
92
mark_point_test.go
Normal file
92
mark_point_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarkPoint(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
render func(*Painter) ([]byte, error)
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
render: func(p *Painter) ([]byte, error) {
|
||||||
|
series := NewSeriesFromValues([]float64{
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
})
|
||||||
|
series.MarkPoint = NewMarkPoint(SeriesMarkDataTypeMax)
|
||||||
|
markPoint := NewMarkPointPainter(p)
|
||||||
|
markPoint.Add(markPointRenderOption{
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
Series: series,
|
||||||
|
Points: []Point{
|
||||||
|
{
|
||||||
|
X: 10,
|
||||||
|
Y: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 30,
|
||||||
|
Y: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 50,
|
||||||
|
Y: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_, err := markPoint.Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Bytes()
|
||||||
|
},
|
||||||
|
result: "<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
866
painter.go
Normal file
866
painter.go
Normal file
|
|
@ -0,0 +1,866 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ValueFormatter func(float64) string
|
||||||
|
|
||||||
|
type Painter struct {
|
||||||
|
render chart.Renderer
|
||||||
|
box Box
|
||||||
|
font *truetype.Font
|
||||||
|
parent *Painter
|
||||||
|
style Style
|
||||||
|
theme ColorPalette
|
||||||
|
// 类型
|
||||||
|
outputType string
|
||||||
|
valueFormatter ValueFormatter
|
||||||
|
}
|
||||||
|
|
||||||
|
type PainterOptions struct {
|
||||||
|
// Draw type, "svg" or "png", default type is "png"
|
||||||
|
Type string
|
||||||
|
// The width of draw painter
|
||||||
|
Width int
|
||||||
|
// The height of draw painter
|
||||||
|
Height int
|
||||||
|
// The font for painter
|
||||||
|
Font *truetype.Font
|
||||||
|
}
|
||||||
|
|
||||||
|
type PainterOption func(*Painter)
|
||||||
|
|
||||||
|
type TicksOption struct {
|
||||||
|
// the first tick
|
||||||
|
First int
|
||||||
|
Length int
|
||||||
|
Orient string
|
||||||
|
Count int
|
||||||
|
Unit int
|
||||||
|
}
|
||||||
|
|
||||||
|
type MultiTextOption struct {
|
||||||
|
TextList []string
|
||||||
|
Orient string
|
||||||
|
Unit int
|
||||||
|
Position string
|
||||||
|
Align string
|
||||||
|
// The text rotation of label
|
||||||
|
TextRotation float64
|
||||||
|
Offset Box
|
||||||
|
// The first text index
|
||||||
|
First int
|
||||||
|
}
|
||||||
|
|
||||||
|
type GridOption struct {
|
||||||
|
Column int
|
||||||
|
Row int
|
||||||
|
ColumnSpans []int
|
||||||
|
// 忽略不展示的column
|
||||||
|
IgnoreColumnLines []int
|
||||||
|
// 忽略不展示的row
|
||||||
|
IgnoreRowLines []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PainterPaddingOption sets the padding of draw painter
|
||||||
|
func PainterPaddingOption(padding Box) PainterOption {
|
||||||
|
return func(p *Painter) {
|
||||||
|
p.box.Left += padding.Left
|
||||||
|
p.box.Top += padding.Top
|
||||||
|
p.box.Right -= padding.Right
|
||||||
|
p.box.Bottom -= padding.Bottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PainterBoxOption sets the box of draw painter
|
||||||
|
func PainterBoxOption(box Box) PainterOption {
|
||||||
|
return func(p *Painter) {
|
||||||
|
if box.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.box = box
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PainterFontOption sets the font of draw painter
|
||||||
|
func PainterFontOption(font *truetype.Font) PainterOption {
|
||||||
|
return func(p *Painter) {
|
||||||
|
if font == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.font = font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PainterStyleOption sets the style of draw painter
|
||||||
|
func PainterStyleOption(style Style) PainterOption {
|
||||||
|
return func(p *Painter) {
|
||||||
|
p.SetStyle(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PainterThemeOption sets the theme of draw painter
|
||||||
|
func PainterThemeOption(theme ColorPalette) PainterOption {
|
||||||
|
return func(p *Painter) {
|
||||||
|
if theme == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.theme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PainterWidthHeightOption set width or height of draw painter
|
||||||
|
func PainterWidthHeightOption(width, height int) PainterOption {
|
||||||
|
return func(p *Painter) {
|
||||||
|
if width > 0 {
|
||||||
|
p.box.Right = p.box.Left + width
|
||||||
|
}
|
||||||
|
if height > 0 {
|
||||||
|
p.box.Bottom = p.box.Top + height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPainter creates a painter
|
||||||
|
func NewPainter(opts PainterOptions, opt ...PainterOption) (*Painter, error) {
|
||||||
|
if opts.Width <= 0 || opts.Height <= 0 {
|
||||||
|
return nil, errors.New("width/height can not be nil")
|
||||||
|
}
|
||||||
|
font := opts.Font
|
||||||
|
if font == nil {
|
||||||
|
f, err := GetDefaultFont()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
font = f
|
||||||
|
}
|
||||||
|
fn := chart.PNG
|
||||||
|
if opts.Type == ChartOutputSVG {
|
||||||
|
fn = chart.SVG
|
||||||
|
}
|
||||||
|
width := opts.Width
|
||||||
|
height := opts.Height
|
||||||
|
r, err := fn(width, height)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.SetFont(font)
|
||||||
|
|
||||||
|
p := &Painter{
|
||||||
|
render: r,
|
||||||
|
box: Box{
|
||||||
|
Right: opts.Width,
|
||||||
|
Bottom: opts.Height,
|
||||||
|
},
|
||||||
|
font: font,
|
||||||
|
// 类型
|
||||||
|
outputType: opts.Type,
|
||||||
|
}
|
||||||
|
p.setOptions(opt...)
|
||||||
|
if p.theme == nil {
|
||||||
|
p.theme = NewTheme(ThemeLight)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
func (p *Painter) setOptions(opts ...PainterOption) {
|
||||||
|
for _, fn := range opts {
|
||||||
|
fn(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Child(opt ...PainterOption) *Painter {
|
||||||
|
child := &Painter{
|
||||||
|
// 格式化
|
||||||
|
valueFormatter: p.valueFormatter,
|
||||||
|
// render
|
||||||
|
render: p.render,
|
||||||
|
box: p.box.Clone(),
|
||||||
|
font: p.font,
|
||||||
|
parent: p,
|
||||||
|
style: p.style,
|
||||||
|
theme: p.theme,
|
||||||
|
}
|
||||||
|
child.setOptions(opt...)
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) SetStyle(style Style) {
|
||||||
|
if style.Font == nil {
|
||||||
|
style.Font = p.font
|
||||||
|
}
|
||||||
|
p.style = style
|
||||||
|
style.WriteToRenderer(p.render)
|
||||||
|
}
|
||||||
|
|
||||||
|
func overrideStyle(defaultStyle Style, style Style) Style {
|
||||||
|
if style.StrokeWidth == 0 {
|
||||||
|
style.StrokeWidth = defaultStyle.StrokeWidth
|
||||||
|
}
|
||||||
|
if style.StrokeColor.IsZero() {
|
||||||
|
style.StrokeColor = defaultStyle.StrokeColor
|
||||||
|
}
|
||||||
|
if style.StrokeDashArray == nil {
|
||||||
|
style.StrokeDashArray = defaultStyle.StrokeDashArray
|
||||||
|
}
|
||||||
|
if style.DotColor.IsZero() {
|
||||||
|
style.DotColor = defaultStyle.DotColor
|
||||||
|
}
|
||||||
|
if style.DotWidth == 0 {
|
||||||
|
style.DotWidth = defaultStyle.DotWidth
|
||||||
|
}
|
||||||
|
if style.FillColor.IsZero() {
|
||||||
|
style.FillColor = defaultStyle.FillColor
|
||||||
|
}
|
||||||
|
if style.FontSize == 0 {
|
||||||
|
style.FontSize = defaultStyle.FontSize
|
||||||
|
}
|
||||||
|
if style.FontColor.IsZero() {
|
||||||
|
style.FontColor = defaultStyle.FontColor
|
||||||
|
}
|
||||||
|
if style.Font == nil {
|
||||||
|
style.Font = defaultStyle.Font
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) OverrideDrawingStyle(style Style) *Painter {
|
||||||
|
s := overrideStyle(p.style, style)
|
||||||
|
p.SetDrawingStyle(s)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) SetDrawingStyle(style Style) *Painter {
|
||||||
|
style.WriteDrawingOptionsToRenderer(p.render)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) SetTextStyle(style Style) *Painter {
|
||||||
|
if style.Font == nil {
|
||||||
|
style.Font = p.font
|
||||||
|
}
|
||||||
|
style.WriteTextOptionsToRenderer(p.render)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
func (p *Painter) OverrideTextStyle(style Style) *Painter {
|
||||||
|
s := overrideStyle(p.style, style)
|
||||||
|
p.SetTextStyle(s)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) ResetStyle() *Painter {
|
||||||
|
p.style.WriteToRenderer(p.render)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes returns the data of draw canvas
|
||||||
|
func (p *Painter) Bytes() ([]byte, error) {
|
||||||
|
buffer := bytes.Buffer{}
|
||||||
|
err := p.render.Save(&buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveTo moves the cursor to a given point
|
||||||
|
func (p *Painter) MoveTo(x, y int) *Painter {
|
||||||
|
p.render.MoveTo(x+p.box.Left, y+p.box.Top)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) *Painter {
|
||||||
|
p.render.ArcTo(cx+p.box.Left, cy+p.box.Top, rx, ry, startAngle, delta)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) LineTo(x, y int) *Painter {
|
||||||
|
p.render.LineTo(x+p.box.Left, y+p.box.Top)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) QuadCurveTo(cx, cy, x, y int) *Painter {
|
||||||
|
p.render.QuadCurveTo(cx+p.box.Left, cy+p.box.Top, x+p.box.Left, y+p.box.Top)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Pin(x, y, width int) *Painter {
|
||||||
|
r := float64(width) / 2
|
||||||
|
y -= width / 4
|
||||||
|
angle := chart.DegreesToRadians(15)
|
||||||
|
box := p.box
|
||||||
|
|
||||||
|
startAngle := math.Pi/2 + angle
|
||||||
|
delta := 2*math.Pi - 2*angle
|
||||||
|
p.ArcTo(x, y, r, r, startAngle, delta)
|
||||||
|
p.LineTo(x, y)
|
||||||
|
p.Close()
|
||||||
|
p.FillStroke()
|
||||||
|
|
||||||
|
startX := x - int(r)
|
||||||
|
startY := y
|
||||||
|
endX := x + int(r)
|
||||||
|
endY := y
|
||||||
|
p.MoveTo(startX, startY)
|
||||||
|
|
||||||
|
left := box.Left
|
||||||
|
top := box.Top
|
||||||
|
cx := x
|
||||||
|
cy := y + int(r*2.5)
|
||||||
|
p.render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top)
|
||||||
|
p.Close()
|
||||||
|
p.Fill()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) arrow(x, y, width, height int, direction string) *Painter {
|
||||||
|
halfWidth := width >> 1
|
||||||
|
halfHeight := height >> 1
|
||||||
|
if direction == PositionTop || direction == PositionBottom {
|
||||||
|
x0 := x - halfWidth
|
||||||
|
x1 := x0 + width
|
||||||
|
dy := -height / 3
|
||||||
|
y0 := y
|
||||||
|
y1 := y0 - height
|
||||||
|
if direction == PositionBottom {
|
||||||
|
y0 = y - height
|
||||||
|
y1 = y
|
||||||
|
dy = 2 * dy
|
||||||
|
}
|
||||||
|
p.MoveTo(x0, y0)
|
||||||
|
p.LineTo(x0+halfWidth, y1)
|
||||||
|
p.LineTo(x1, y0)
|
||||||
|
p.LineTo(x0+halfWidth, y+dy)
|
||||||
|
p.LineTo(x0, y0)
|
||||||
|
} else {
|
||||||
|
x0 := x + width
|
||||||
|
x1 := x0 - width
|
||||||
|
y0 := y - halfHeight
|
||||||
|
dx := -width / 3
|
||||||
|
if direction == PositionRight {
|
||||||
|
x0 = x - width
|
||||||
|
dx = -dx
|
||||||
|
x1 = x0 + width
|
||||||
|
}
|
||||||
|
p.MoveTo(x0, y0)
|
||||||
|
p.LineTo(x1, y0+halfHeight)
|
||||||
|
p.LineTo(x0, y0+height)
|
||||||
|
p.LineTo(x0+dx, y0+halfHeight)
|
||||||
|
p.LineTo(x0, y0)
|
||||||
|
}
|
||||||
|
p.FillStroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) ArrowLeft(x, y, width, height int) *Painter {
|
||||||
|
p.arrow(x, y, width, height, PositionLeft)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) ArrowRight(x, y, width, height int) *Painter {
|
||||||
|
p.arrow(x, y, width, height, PositionRight)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) ArrowTop(x, y, width, height int) *Painter {
|
||||||
|
p.arrow(x, y, width, height, PositionTop)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
func (p *Painter) ArrowBottom(x, y, width, height int) *Painter {
|
||||||
|
p.arrow(x, y, width, height, PositionBottom)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Circle(radius float64, x, y int) *Painter {
|
||||||
|
p.render.Circle(radius, x+p.box.Left, y+p.box.Top)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Stroke() *Painter {
|
||||||
|
p.render.Stroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Close() *Painter {
|
||||||
|
p.render.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) FillStroke() *Painter {
|
||||||
|
p.render.FillStroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Fill() *Painter {
|
||||||
|
p.render.Fill()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Width() int {
|
||||||
|
return p.box.Width()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Height() int {
|
||||||
|
return p.box.Height()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) MeasureText(text string) Box {
|
||||||
|
return p.render.MeasureText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) MeasureTextMaxWidthHeight(textList []string) (int, int) {
|
||||||
|
maxWidth := 0
|
||||||
|
maxHeight := 0
|
||||||
|
for _, text := range textList {
|
||||||
|
box := p.MeasureText(text)
|
||||||
|
if maxWidth < box.Width() {
|
||||||
|
maxWidth = box.Width()
|
||||||
|
}
|
||||||
|
if maxHeight < box.Height() {
|
||||||
|
maxHeight = box.Height()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxWidth, maxHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) LineStroke(points []Point) *Painter {
|
||||||
|
shouldMoveTo := false
|
||||||
|
for index, point := range points {
|
||||||
|
x := point.X
|
||||||
|
y := point.Y
|
||||||
|
if y == int(math.MaxInt32) {
|
||||||
|
p.Stroke()
|
||||||
|
shouldMoveTo = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shouldMoveTo || index == 0 {
|
||||||
|
p.MoveTo(x, y)
|
||||||
|
shouldMoveTo = false
|
||||||
|
} else {
|
||||||
|
p.LineTo(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Stroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) SmoothLineStroke(points []Point) *Painter {
|
||||||
|
prevX := 0
|
||||||
|
prevY := 0
|
||||||
|
// TODO 如何生成平滑的折线
|
||||||
|
for index, point := range points {
|
||||||
|
x := point.X
|
||||||
|
y := point.Y
|
||||||
|
if index == 0 {
|
||||||
|
p.MoveTo(x, y)
|
||||||
|
} else {
|
||||||
|
cx := prevX + (x-prevX)/5
|
||||||
|
cy := y + (y-prevY)/2
|
||||||
|
p.QuadCurveTo(cx, cy, x, y)
|
||||||
|
}
|
||||||
|
prevX = x
|
||||||
|
prevY = y
|
||||||
|
}
|
||||||
|
p.Stroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) SetBackground(width, height int, color Color, inside ...bool) *Painter {
|
||||||
|
r := p.render
|
||||||
|
s := chart.Style{
|
||||||
|
FillColor: color,
|
||||||
|
}
|
||||||
|
// 背景色
|
||||||
|
p.SetDrawingStyle(s)
|
||||||
|
defer p.ResetStyle()
|
||||||
|
if len(inside) != 0 && inside[0] {
|
||||||
|
p.MoveTo(0, 0)
|
||||||
|
p.LineTo(width, 0)
|
||||||
|
p.LineTo(width, height)
|
||||||
|
p.LineTo(0, height)
|
||||||
|
p.LineTo(0, 0)
|
||||||
|
} else {
|
||||||
|
// 设置背景色不使用box,因此不直接使用Painter
|
||||||
|
r.MoveTo(0, 0)
|
||||||
|
r.LineTo(width, 0)
|
||||||
|
r.LineTo(width, height)
|
||||||
|
r.LineTo(0, height)
|
||||||
|
r.LineTo(0, 0)
|
||||||
|
}
|
||||||
|
p.FillStroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
func (p *Painter) MarkLine(x, y, width int) *Painter {
|
||||||
|
arrowWidth := 16
|
||||||
|
arrowHeight := 10
|
||||||
|
endX := x + width
|
||||||
|
radius := 3
|
||||||
|
p.Circle(3, x+radius, y)
|
||||||
|
p.render.Fill()
|
||||||
|
p.MoveTo(x+radius*3, y)
|
||||||
|
p.LineTo(endX-arrowWidth, y)
|
||||||
|
p.Stroke()
|
||||||
|
p.ArrowRight(endX, y, arrowWidth, arrowHeight)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Polygon(center Point, radius float64, sides int) *Painter {
|
||||||
|
points := getPolygonPoints(center, radius, sides)
|
||||||
|
for i, item := range points {
|
||||||
|
if i == 0 {
|
||||||
|
p.MoveTo(item.X, item.Y)
|
||||||
|
} else {
|
||||||
|
p.LineTo(item.X, item.Y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.LineTo(points[0].X, points[0].Y)
|
||||||
|
p.Stroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) FillArea(points []Point) *Painter {
|
||||||
|
var x, y int
|
||||||
|
for index, point := range points {
|
||||||
|
x = point.X
|
||||||
|
y = point.Y
|
||||||
|
if index == 0 {
|
||||||
|
p.MoveTo(x, y)
|
||||||
|
} else {
|
||||||
|
p.LineTo(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Fill()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Text(body string, x, y int) *Painter {
|
||||||
|
p.render.Text(body, x+p.box.Left, y+p.box.Top)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) TextRotation(body string, x, y int, radians float64) {
|
||||||
|
p.render.SetTextRotation(radians)
|
||||||
|
p.render.Text(body, x+p.box.Left, y+p.box.Top)
|
||||||
|
p.render.ClearTextRotation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) SetTextRotation(radians float64) {
|
||||||
|
p.render.SetTextRotation(radians)
|
||||||
|
}
|
||||||
|
func (p *Painter) ClearTextRotation() {
|
||||||
|
p.render.ClearTextRotation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) TextFit(body string, x, y, width int, textAligns ...string) chart.Box {
|
||||||
|
style := p.style
|
||||||
|
textWarp := style.TextWrap
|
||||||
|
style.TextWrap = chart.TextWrapWord
|
||||||
|
r := p.render
|
||||||
|
lines := chart.Text.WrapFit(r, body, width, style)
|
||||||
|
p.SetTextStyle(style)
|
||||||
|
var output chart.Box
|
||||||
|
|
||||||
|
textAlign := ""
|
||||||
|
if len(textAligns) != 0 {
|
||||||
|
textAlign = textAligns[0]
|
||||||
|
}
|
||||||
|
for index, line := range lines {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x0 := x
|
||||||
|
y0 := y + output.Height()
|
||||||
|
lineBox := r.MeasureText(line)
|
||||||
|
switch textAlign {
|
||||||
|
case AlignRight:
|
||||||
|
x0 += width - lineBox.Width()
|
||||||
|
case AlignCenter:
|
||||||
|
x0 += (width - lineBox.Width()) >> 1
|
||||||
|
}
|
||||||
|
p.Text(line, x0, y0)
|
||||||
|
output.Right = chart.MaxInt(lineBox.Right, output.Right)
|
||||||
|
output.Bottom += lineBox.Height()
|
||||||
|
if index < len(lines)-1 {
|
||||||
|
output.Bottom += +style.GetTextLineSpacing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.style.TextWrap = textWarp
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Ticks(opt TicksOption) *Painter {
|
||||||
|
if opt.Count <= 0 || opt.Length <= 0 {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
count := opt.Count
|
||||||
|
first := opt.First
|
||||||
|
width := p.Width()
|
||||||
|
height := p.Height()
|
||||||
|
unit := 1
|
||||||
|
if opt.Unit > 1 {
|
||||||
|
unit = opt.Unit
|
||||||
|
}
|
||||||
|
var values []int
|
||||||
|
isVertical := opt.Orient == OrientVertical
|
||||||
|
if isVertical {
|
||||||
|
values = autoDivide(height, count)
|
||||||
|
} else {
|
||||||
|
values = autoDivide(width, count)
|
||||||
|
}
|
||||||
|
for index, value := range values {
|
||||||
|
if index < first {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (index-first)%unit != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isVertical {
|
||||||
|
p.LineStroke([]Point{
|
||||||
|
{
|
||||||
|
X: 0,
|
||||||
|
Y: value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: opt.Length,
|
||||||
|
Y: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
p.LineStroke([]Point{
|
||||||
|
{
|
||||||
|
X: value,
|
||||||
|
Y: opt.Length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: value,
|
||||||
|
Y: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) MultiText(opt MultiTextOption) *Painter {
|
||||||
|
if len(opt.TextList) == 0 {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
count := len(opt.TextList)
|
||||||
|
positionCenter := true
|
||||||
|
showIndex := opt.Unit / 2
|
||||||
|
if containsString([]string{
|
||||||
|
PositionLeft,
|
||||||
|
PositionTop,
|
||||||
|
}, opt.Position) {
|
||||||
|
positionCenter = false
|
||||||
|
count--
|
||||||
|
// 非居中
|
||||||
|
showIndex = 0
|
||||||
|
}
|
||||||
|
width := p.Width()
|
||||||
|
height := p.Height()
|
||||||
|
var values []int
|
||||||
|
isVertical := opt.Orient == OrientVertical
|
||||||
|
if isVertical {
|
||||||
|
values = autoDivide(height, count)
|
||||||
|
} else {
|
||||||
|
values = autoDivide(width, count)
|
||||||
|
}
|
||||||
|
isTextRotation := opt.TextRotation != 0
|
||||||
|
offset := opt.Offset
|
||||||
|
for index, text := range opt.TextList {
|
||||||
|
if index < opt.First {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if opt.Unit != 0 && (index-opt.First)%opt.Unit != showIndex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isTextRotation {
|
||||||
|
p.ClearTextRotation()
|
||||||
|
p.SetTextRotation(opt.TextRotation)
|
||||||
|
}
|
||||||
|
box := p.MeasureText(text)
|
||||||
|
start := values[index]
|
||||||
|
if positionCenter {
|
||||||
|
start = (values[index] + values[index+1]) >> 1
|
||||||
|
}
|
||||||
|
x := 0
|
||||||
|
y := 0
|
||||||
|
if isVertical {
|
||||||
|
y = start + box.Height()>>1
|
||||||
|
switch opt.Align {
|
||||||
|
case AlignRight:
|
||||||
|
x = width - box.Width()
|
||||||
|
case AlignCenter:
|
||||||
|
x = width - box.Width()>>1
|
||||||
|
default:
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
x = start - box.Width()>>1
|
||||||
|
}
|
||||||
|
x += offset.Left
|
||||||
|
y += offset.Top
|
||||||
|
p.Text(text, x, y)
|
||||||
|
}
|
||||||
|
if isTextRotation {
|
||||||
|
p.ClearTextRotation()
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Grid(opt GridOption) *Painter {
|
||||||
|
width := p.Width()
|
||||||
|
height := p.Height()
|
||||||
|
drawLines := func(values []int, ignoreIndexList []int, isVertical bool) {
|
||||||
|
for index, v := range values {
|
||||||
|
if containsInt(ignoreIndexList, index) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x0 := 0
|
||||||
|
y0 := 0
|
||||||
|
x1 := 0
|
||||||
|
y1 := 0
|
||||||
|
if isVertical {
|
||||||
|
|
||||||
|
x0 = v
|
||||||
|
x1 = v
|
||||||
|
y1 = height
|
||||||
|
} else {
|
||||||
|
x1 = width
|
||||||
|
y0 = v
|
||||||
|
y1 = v
|
||||||
|
}
|
||||||
|
p.LineStroke([]Point{
|
||||||
|
{
|
||||||
|
X: x0,
|
||||||
|
Y: y0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: x1,
|
||||||
|
Y: y1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
columnCount := sumInt(opt.ColumnSpans)
|
||||||
|
if columnCount == 0 {
|
||||||
|
columnCount = opt.Column
|
||||||
|
}
|
||||||
|
if columnCount > 0 {
|
||||||
|
values := autoDivideSpans(width, columnCount, opt.ColumnSpans)
|
||||||
|
drawLines(values, opt.IgnoreColumnLines, true)
|
||||||
|
}
|
||||||
|
if opt.Row > 0 {
|
||||||
|
values := autoDivide(height, opt.Row)
|
||||||
|
drawLines(values, opt.IgnoreRowLines, false)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Dots(points []Point) *Painter {
|
||||||
|
for _, item := range points {
|
||||||
|
p.Circle(2, item.X, item.Y)
|
||||||
|
}
|
||||||
|
p.FillStroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) Rect(box Box) *Painter {
|
||||||
|
p.MoveTo(box.Left, box.Top)
|
||||||
|
p.LineTo(box.Right, box.Top)
|
||||||
|
p.LineTo(box.Right, box.Bottom)
|
||||||
|
p.LineTo(box.Left, box.Bottom)
|
||||||
|
p.LineTo(box.Left, box.Top)
|
||||||
|
p.FillStroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) RoundedRect(box Box, radius int) *Painter {
|
||||||
|
r := (box.Right - box.Left) / 2
|
||||||
|
if radius > r {
|
||||||
|
radius = r
|
||||||
|
}
|
||||||
|
rx := float64(radius)
|
||||||
|
ry := float64(radius)
|
||||||
|
p.MoveTo(box.Left+radius, box.Top)
|
||||||
|
p.LineTo(box.Right-radius, box.Top)
|
||||||
|
|
||||||
|
cx := box.Right - radius
|
||||||
|
cy := box.Top + radius
|
||||||
|
// right top
|
||||||
|
p.ArcTo(cx, cy, rx, ry, -math.Pi/2, math.Pi/2)
|
||||||
|
|
||||||
|
p.LineTo(box.Right, box.Bottom-radius)
|
||||||
|
|
||||||
|
// right bottom
|
||||||
|
cx = box.Right - radius
|
||||||
|
cy = box.Bottom - radius
|
||||||
|
p.ArcTo(cx, cy, rx, ry, 0.0, math.Pi/2)
|
||||||
|
|
||||||
|
p.LineTo(box.Left+radius, box.Bottom)
|
||||||
|
|
||||||
|
// left bottom
|
||||||
|
cx = box.Left + radius
|
||||||
|
cy = box.Bottom - radius
|
||||||
|
p.ArcTo(cx, cy, rx, ry, math.Pi/2, math.Pi/2)
|
||||||
|
|
||||||
|
p.LineTo(box.Left, box.Top+radius)
|
||||||
|
|
||||||
|
// left top
|
||||||
|
cx = box.Left + radius
|
||||||
|
cy = box.Top + radius
|
||||||
|
p.ArcTo(cx, cy, rx, ry, math.Pi, math.Pi/2)
|
||||||
|
|
||||||
|
p.Close()
|
||||||
|
p.FillStroke()
|
||||||
|
p.Fill()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) LegendLineDot(box Box) *Painter {
|
||||||
|
width := box.Width()
|
||||||
|
height := box.Height()
|
||||||
|
strokeWidth := 3
|
||||||
|
dotHeight := 5
|
||||||
|
|
||||||
|
p.render.SetStrokeWidth(float64(strokeWidth))
|
||||||
|
center := (height-strokeWidth)>>1 - 1
|
||||||
|
p.MoveTo(box.Left, box.Top-center)
|
||||||
|
p.LineTo(box.Right, box.Top-center)
|
||||||
|
p.Stroke()
|
||||||
|
p.Circle(float64(dotHeight), box.Left+width>>1, box.Top-center)
|
||||||
|
p.FillStroke()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Painter) GetRenderer() chart.Renderer {
|
||||||
|
return p.render
|
||||||
|
}
|
||||||
399
painter_test.go
Normal file
399
painter_test.go
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPainterOption(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
font := &truetype.Font{}
|
||||||
|
d, err := NewPainter(PainterOptions{
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Type: ChartOutputSVG,
|
||||||
|
},
|
||||||
|
PainterBoxOption(Box{
|
||||||
|
Right: 400,
|
||||||
|
Bottom: 300,
|
||||||
|
}),
|
||||||
|
PainterPaddingOption(Box{
|
||||||
|
Left: 1,
|
||||||
|
Top: 2,
|
||||||
|
Right: 3,
|
||||||
|
Bottom: 4,
|
||||||
|
}),
|
||||||
|
PainterFontOption(font),
|
||||||
|
PainterStyleOption(Style{
|
||||||
|
ClassName: "test",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(Box{
|
||||||
|
Left: 1,
|
||||||
|
Top: 2,
|
||||||
|
Right: 397,
|
||||||
|
Bottom: 296,
|
||||||
|
}, d.box)
|
||||||
|
assert.Equal(font, d.font)
|
||||||
|
assert.Equal("test", d.style.ClassName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPainter(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
fn func(*Painter)
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
// moveTo, lineTo
|
||||||
|
{
|
||||||
|
fn: func(p *Painter) {
|
||||||
|
p.MoveTo(1, 1)
|
||||||
|
p.LineTo(2, 2)
|
||||||
|
p.Stroke()
|
||||||
|
},
|
||||||
|
result: "<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))
|
||||||
|
}
|
||||||
318
pie_chart.go
Normal file
318
pie_chart.go
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pieChart struct {
|
||||||
|
p *Painter
|
||||||
|
opt *PieChartOption
|
||||||
|
}
|
||||||
|
|
||||||
|
type PieChartOption struct {
|
||||||
|
// The theme
|
||||||
|
Theme ColorPalette
|
||||||
|
// The font size
|
||||||
|
Font *truetype.Font
|
||||||
|
// The data series list
|
||||||
|
SeriesList SeriesList
|
||||||
|
// The padding of line chart
|
||||||
|
Padding Box
|
||||||
|
// The option of title
|
||||||
|
Title TitleOption
|
||||||
|
// The legend option
|
||||||
|
Legend LegendOption
|
||||||
|
// background is filled
|
||||||
|
backgroundIsFilled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPieChart returns a pie chart renderer
|
||||||
|
func NewPieChart(p *Painter, opt PieChartOption) *pieChart {
|
||||||
|
if opt.Theme == nil {
|
||||||
|
opt.Theme = defaultTheme
|
||||||
|
}
|
||||||
|
return &pieChart{
|
||||||
|
p: p,
|
||||||
|
opt: &opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type sector struct {
|
||||||
|
value float64
|
||||||
|
percent float64
|
||||||
|
cx int
|
||||||
|
cy int
|
||||||
|
rx float64
|
||||||
|
ry float64
|
||||||
|
start float64
|
||||||
|
delta float64
|
||||||
|
offset int
|
||||||
|
quadrant int
|
||||||
|
lineStartX int
|
||||||
|
lineStartY int
|
||||||
|
lineBranchX int
|
||||||
|
lineBranchY int
|
||||||
|
lineEndX int
|
||||||
|
lineEndY int
|
||||||
|
showLabel bool
|
||||||
|
label string
|
||||||
|
series Series
|
||||||
|
color Color
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSector(cx int, cy int, radius float64, labelRadius float64, value float64, currentValue float64, totalValue float64, labelLineLength int, label string, series Series, color Color) sector {
|
||||||
|
s := sector{}
|
||||||
|
s.value = value
|
||||||
|
s.percent = value / totalValue
|
||||||
|
s.cx = cx
|
||||||
|
s.cy = cy
|
||||||
|
s.rx = radius
|
||||||
|
s.ry = radius
|
||||||
|
p := (currentValue + value/2) / totalValue
|
||||||
|
if p < 0.25 {
|
||||||
|
s.quadrant = 1
|
||||||
|
} else if p < 0.5 {
|
||||||
|
s.quadrant = 4
|
||||||
|
} else if p < 0.75 {
|
||||||
|
s.quadrant = 3
|
||||||
|
} else {
|
||||||
|
s.quadrant = 2
|
||||||
|
}
|
||||||
|
s.start = chart.PercentToRadians(currentValue/totalValue) - math.Pi/2
|
||||||
|
s.delta = chart.PercentToRadians(value / totalValue)
|
||||||
|
angle := s.start + s.delta/2
|
||||||
|
s.lineStartX = cx + int(radius*math.Cos(angle))
|
||||||
|
s.lineStartY = cy + int(radius*math.Sin(angle))
|
||||||
|
s.lineBranchX = cx + int(labelRadius*math.Cos(angle))
|
||||||
|
s.lineBranchY = cy + int(labelRadius*math.Sin(angle))
|
||||||
|
s.offset = labelLineLength
|
||||||
|
if s.lineBranchX <= cx {
|
||||||
|
s.offset *= -1
|
||||||
|
}
|
||||||
|
s.lineEndX = s.lineBranchX + s.offset
|
||||||
|
s.lineEndY = s.lineBranchY
|
||||||
|
s.series = series
|
||||||
|
s.color = color
|
||||||
|
s.showLabel = series.Label.Show
|
||||||
|
s.label = NewPieLabelFormatter([]string{label}, series.Label.Formatter)(0, s.value, s.percent)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sector) calculateY(prevY int) int {
|
||||||
|
for i := 0; i <= s.cy; i++ {
|
||||||
|
if s.quadrant <= 2 {
|
||||||
|
if (prevY - s.lineBranchY) > labelFontSize+5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.lineBranchY -= 1
|
||||||
|
} else {
|
||||||
|
if (s.lineBranchY - prevY) > labelFontSize+5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.lineBranchY += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.lineEndY = s.lineBranchY
|
||||||
|
return s.lineBranchY
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sector) calculateTextXY(textBox Box) (x int, y int) {
|
||||||
|
textMargin := 3
|
||||||
|
x = s.lineEndX + textMargin
|
||||||
|
y = s.lineEndY + textBox.Height()>>1 - 1
|
||||||
|
if s.offset < 0 {
|
||||||
|
textWidth := textBox.Width()
|
||||||
|
x = s.lineEndX - textWidth - textMargin
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
|
||||||
|
opt := p.opt
|
||||||
|
values := make([]float64, len(seriesList))
|
||||||
|
total := float64(0)
|
||||||
|
radiusValue := ""
|
||||||
|
for index, series := range seriesList {
|
||||||
|
if len(series.Radius) != 0 {
|
||||||
|
radiusValue = series.Radius
|
||||||
|
}
|
||||||
|
value := float64(0)
|
||||||
|
for _, item := range series.Data {
|
||||||
|
value += item.Value
|
||||||
|
}
|
||||||
|
values[index] = value
|
||||||
|
total += value
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
return BoxZero, errors.New("The sum value of pie chart should gt 0")
|
||||||
|
}
|
||||||
|
seriesPainter := result.seriesPainter
|
||||||
|
cx := seriesPainter.Width() >> 1
|
||||||
|
cy := seriesPainter.Height() >> 1
|
||||||
|
|
||||||
|
diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
|
||||||
|
radius := getRadius(float64(diameter), radiusValue)
|
||||||
|
|
||||||
|
labelLineWidth := 15
|
||||||
|
if radius < 50 {
|
||||||
|
labelLineWidth = 10
|
||||||
|
}
|
||||||
|
labelRadius := radius + float64(labelLineWidth)
|
||||||
|
seriesNames := opt.Legend.Data
|
||||||
|
if len(seriesNames) == 0 {
|
||||||
|
seriesNames = seriesList.Names()
|
||||||
|
}
|
||||||
|
theme := opt.Theme
|
||||||
|
|
||||||
|
currentValue := float64(0)
|
||||||
|
|
||||||
|
var quadrant1, quadrant2, quadrant3, quadrant4 []sector
|
||||||
|
for index, v := range values {
|
||||||
|
series := seriesList[index]
|
||||||
|
color := theme.GetSeriesColor(index)
|
||||||
|
if index == len(values)-1 {
|
||||||
|
if color == theme.GetSeriesColor(0) {
|
||||||
|
color = theme.GetSeriesColor(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := NewSector(cx, cy, radius, labelRadius, v, currentValue, total, labelLineWidth, seriesNames[index], series, color)
|
||||||
|
switch quadrant := s.quadrant; quadrant {
|
||||||
|
case 1:
|
||||||
|
quadrant1 = append([]sector{s}, quadrant1...)
|
||||||
|
case 2:
|
||||||
|
quadrant2 = append(quadrant2, s)
|
||||||
|
case 3:
|
||||||
|
quadrant3 = append([]sector{s}, quadrant3...)
|
||||||
|
case 4:
|
||||||
|
quadrant4 = append(quadrant4, s)
|
||||||
|
}
|
||||||
|
currentValue += v
|
||||||
|
}
|
||||||
|
sectors := append(quadrant1, quadrant4...)
|
||||||
|
sectors = append(sectors, quadrant3...)
|
||||||
|
sectors = append(sectors, quadrant2...)
|
||||||
|
|
||||||
|
currentQuadrant := 0
|
||||||
|
prevY := 0
|
||||||
|
maxY := 0
|
||||||
|
minY := 0
|
||||||
|
for _, s := range sectors {
|
||||||
|
seriesPainter.OverrideDrawingStyle(Style{
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeColor: s.color,
|
||||||
|
FillColor: s.color,
|
||||||
|
})
|
||||||
|
seriesPainter.MoveTo(s.cx, s.cy)
|
||||||
|
seriesPainter.ArcTo(s.cx, s.cy, s.rx, s.ry, s.start, s.delta).LineTo(s.cx, s.cy).Close().FillStroke()
|
||||||
|
if !s.showLabel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if currentQuadrant != s.quadrant {
|
||||||
|
if s.quadrant == 1 {
|
||||||
|
minY = cy * 2
|
||||||
|
maxY = 0
|
||||||
|
prevY = cy * 2
|
||||||
|
}
|
||||||
|
if s.quadrant == 2 {
|
||||||
|
if currentQuadrant != 3 {
|
||||||
|
prevY = s.lineEndY
|
||||||
|
} else {
|
||||||
|
prevY = minY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.quadrant == 3 {
|
||||||
|
if currentQuadrant != 4 {
|
||||||
|
prevY = s.lineEndY
|
||||||
|
} else {
|
||||||
|
minY = cy * 2
|
||||||
|
maxY = 0
|
||||||
|
prevY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.quadrant == 4 {
|
||||||
|
if currentQuadrant != 1 {
|
||||||
|
prevY = s.lineEndY
|
||||||
|
} else {
|
||||||
|
prevY = maxY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentQuadrant = s.quadrant
|
||||||
|
}
|
||||||
|
prevY = s.calculateY(prevY)
|
||||||
|
if prevY > maxY {
|
||||||
|
maxY = prevY
|
||||||
|
}
|
||||||
|
if prevY < minY {
|
||||||
|
minY = prevY
|
||||||
|
}
|
||||||
|
seriesPainter.MoveTo(s.lineStartX, s.lineStartY)
|
||||||
|
seriesPainter.LineTo(s.lineBranchX, s.lineBranchY)
|
||||||
|
seriesPainter.MoveTo(s.lineBranchX, s.lineBranchY)
|
||||||
|
seriesPainter.LineTo(s.lineEndX, s.lineEndY)
|
||||||
|
seriesPainter.Stroke()
|
||||||
|
textStyle := Style{
|
||||||
|
FontColor: theme.GetTextColor(),
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
Font: opt.Font,
|
||||||
|
}
|
||||||
|
if !s.series.Label.Color.IsZero() {
|
||||||
|
textStyle.FontColor = s.series.Label.Color
|
||||||
|
}
|
||||||
|
seriesPainter.OverrideTextStyle(textStyle)
|
||||||
|
x, y := s.calculateTextXY(seriesPainter.MeasureText(s.label))
|
||||||
|
seriesPainter.Text(s.label, x, y)
|
||||||
|
}
|
||||||
|
return p.p.box, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pieChart) Render() (Box, error) {
|
||||||
|
opt := p.opt
|
||||||
|
|
||||||
|
renderResult, err := defaultRender(p.p, defaultRenderOption{
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Padding: opt.Padding,
|
||||||
|
SeriesList: opt.SeriesList,
|
||||||
|
XAxis: XAxisOption{
|
||||||
|
Show: FalseFlag(),
|
||||||
|
},
|
||||||
|
YAxisOptions: []YAxisOption{
|
||||||
|
{
|
||||||
|
Show: FalseFlag(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TitleOption: opt.Title,
|
||||||
|
LegendOption: opt.Legend,
|
||||||
|
backgroundIsFilled: opt.backgroundIsFilled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
seriesList := opt.SeriesList.Filter(ChartTypePie)
|
||||||
|
return p.render(renderResult, seriesList)
|
||||||
|
}
|
||||||
533
pie_chart_test.go
Normal file
533
pie_chart_test.go
Normal file
File diff suppressed because one or more lines are too long
273
radar_chart.go
Normal file
273
radar_chart.go
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type radarChart struct {
|
||||||
|
p *Painter
|
||||||
|
opt *RadarChartOption
|
||||||
|
}
|
||||||
|
|
||||||
|
type RadarIndicator struct {
|
||||||
|
// Indicator's name
|
||||||
|
Name string
|
||||||
|
// The maximum value of indicator
|
||||||
|
Max float64
|
||||||
|
// The minimum value of indicator
|
||||||
|
Min float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type RadarChartOption struct {
|
||||||
|
// The theme
|
||||||
|
Theme ColorPalette
|
||||||
|
// The font size
|
||||||
|
Font *truetype.Font
|
||||||
|
// The data series list
|
||||||
|
SeriesList SeriesList
|
||||||
|
// The padding of line chart
|
||||||
|
Padding Box
|
||||||
|
// The option of title
|
||||||
|
Title TitleOption
|
||||||
|
// The legend option
|
||||||
|
Legend LegendOption
|
||||||
|
// The radar indicator list
|
||||||
|
RadarIndicators []RadarIndicator
|
||||||
|
// background is filled
|
||||||
|
backgroundIsFilled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRadarIndicators returns a radar indicator list
|
||||||
|
func NewRadarIndicators(names []string, values []float64) []RadarIndicator {
|
||||||
|
if len(names) != len(values) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
indicators := make([]RadarIndicator, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
indicators[index] = RadarIndicator{
|
||||||
|
Name: name,
|
||||||
|
Max: values[index],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indicators
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRadarChart returns a radar chart renderer
|
||||||
|
func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart {
|
||||||
|
if opt.Theme == nil {
|
||||||
|
opt.Theme = defaultTheme
|
||||||
|
}
|
||||||
|
return &radarChart{
|
||||||
|
p: p,
|
||||||
|
opt: &opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
|
||||||
|
opt := r.opt
|
||||||
|
indicators := opt.RadarIndicators
|
||||||
|
sides := len(indicators)
|
||||||
|
if sides < 3 {
|
||||||
|
return BoxZero, errors.New("The count of indicator should be >= 3")
|
||||||
|
}
|
||||||
|
maxValues := make([]float64, len(indicators))
|
||||||
|
for _, series := range seriesList {
|
||||||
|
for index, item := range series.Data {
|
||||||
|
if index < len(maxValues) && item.Value > maxValues[index] {
|
||||||
|
maxValues[index] = item.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index, indicator := range indicators {
|
||||||
|
if indicator.Max <= 0 {
|
||||||
|
indicators[index].Max = maxValues[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
radiusValue := ""
|
||||||
|
for _, series := range seriesList {
|
||||||
|
if len(series.Radius) != 0 {
|
||||||
|
radiusValue = series.Radius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesPainter := result.seriesPainter
|
||||||
|
theme := opt.Theme
|
||||||
|
|
||||||
|
cx := seriesPainter.Width() >> 1
|
||||||
|
cy := seriesPainter.Height() >> 1
|
||||||
|
diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
|
||||||
|
radius := getRadius(float64(diameter), radiusValue)
|
||||||
|
|
||||||
|
divideCount := 5
|
||||||
|
divideRadius := float64(int(radius / float64(divideCount)))
|
||||||
|
radius = divideRadius * float64(divideCount)
|
||||||
|
|
||||||
|
seriesPainter.OverrideDrawingStyle(Style{
|
||||||
|
StrokeColor: theme.GetAxisSplitLineColor(),
|
||||||
|
StrokeWidth: 1,
|
||||||
|
})
|
||||||
|
center := Point{
|
||||||
|
X: cx,
|
||||||
|
Y: cy,
|
||||||
|
}
|
||||||
|
for i := 0; i < divideCount; i++ {
|
||||||
|
seriesPainter.Polygon(center, divideRadius*float64(i+1), sides)
|
||||||
|
}
|
||||||
|
points := getPolygonPoints(center, radius, sides)
|
||||||
|
for _, p := range points {
|
||||||
|
seriesPainter.MoveTo(center.X, center.Y)
|
||||||
|
seriesPainter.LineTo(p.X, p.Y)
|
||||||
|
seriesPainter.Stroke()
|
||||||
|
}
|
||||||
|
seriesPainter.OverrideTextStyle(Style{
|
||||||
|
FontColor: theme.GetTextColor(),
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
Font: opt.Font,
|
||||||
|
})
|
||||||
|
offset := 5
|
||||||
|
// 文本生成
|
||||||
|
for index, p := range points {
|
||||||
|
name := indicators[index].Name
|
||||||
|
b := seriesPainter.MeasureText(name)
|
||||||
|
isXCenter := p.X == center.X
|
||||||
|
isYCenter := p.Y == center.Y
|
||||||
|
isRight := p.X > center.X
|
||||||
|
isLeft := p.X < center.X
|
||||||
|
isTop := p.Y < center.Y
|
||||||
|
isBottom := p.Y > center.Y
|
||||||
|
x := p.X
|
||||||
|
y := p.Y
|
||||||
|
if isXCenter {
|
||||||
|
x -= b.Width() >> 1
|
||||||
|
if isTop {
|
||||||
|
y -= b.Height()
|
||||||
|
} else {
|
||||||
|
y += b.Height()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isYCenter {
|
||||||
|
y += b.Height() >> 1
|
||||||
|
}
|
||||||
|
if isTop {
|
||||||
|
y += offset
|
||||||
|
}
|
||||||
|
if isBottom {
|
||||||
|
y += offset
|
||||||
|
}
|
||||||
|
if isRight {
|
||||||
|
x += offset
|
||||||
|
}
|
||||||
|
if isLeft {
|
||||||
|
x -= (b.Width() + offset)
|
||||||
|
}
|
||||||
|
seriesPainter.Text(name, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 雷达图
|
||||||
|
angles := getPolygonPointAngles(sides)
|
||||||
|
maxCount := len(indicators)
|
||||||
|
for _, series := range seriesList {
|
||||||
|
linePoints := make([]Point, 0, maxCount)
|
||||||
|
for j, item := range series.Data {
|
||||||
|
if j >= maxCount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
indicator := indicators[j]
|
||||||
|
var percent float64
|
||||||
|
offset := indicator.Max - indicator.Min
|
||||||
|
if offset > 0 {
|
||||||
|
percent = (item.Value - indicator.Min) / offset
|
||||||
|
}
|
||||||
|
r := percent * radius
|
||||||
|
p := getPolygonPoint(center, r, angles[j])
|
||||||
|
linePoints = append(linePoints, p)
|
||||||
|
}
|
||||||
|
color := theme.GetSeriesColor(series.index)
|
||||||
|
dotFillColor := drawing.ColorWhite
|
||||||
|
if theme.IsDark() {
|
||||||
|
dotFillColor = color
|
||||||
|
}
|
||||||
|
linePoints = append(linePoints, linePoints[0])
|
||||||
|
seriesPainter.OverrideDrawingStyle(Style{
|
||||||
|
StrokeColor: color,
|
||||||
|
StrokeWidth: defaultStrokeWidth,
|
||||||
|
DotWidth: defaultDotWidth,
|
||||||
|
DotColor: color,
|
||||||
|
FillColor: color.WithAlpha(20),
|
||||||
|
})
|
||||||
|
seriesPainter.LineStroke(linePoints).
|
||||||
|
FillArea(linePoints)
|
||||||
|
dotWith := 2.0
|
||||||
|
seriesPainter.OverrideDrawingStyle(Style{
|
||||||
|
StrokeWidth: defaultStrokeWidth,
|
||||||
|
StrokeColor: color,
|
||||||
|
FillColor: dotFillColor,
|
||||||
|
})
|
||||||
|
for index, point := range linePoints {
|
||||||
|
seriesPainter.Circle(dotWith, point.X, point.Y)
|
||||||
|
seriesPainter.FillStroke()
|
||||||
|
if series.Label.Show && index < len(series.Data) {
|
||||||
|
value := humanize.FtoaWithDigits(series.Data[index].Value, 2)
|
||||||
|
b := seriesPainter.MeasureText(value)
|
||||||
|
seriesPainter.Text(value, point.X-b.Width()/2, point.Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.p.box, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *radarChart) Render() (Box, error) {
|
||||||
|
p := r.p
|
||||||
|
opt := r.opt
|
||||||
|
renderResult, err := defaultRender(p, defaultRenderOption{
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Padding: opt.Padding,
|
||||||
|
SeriesList: opt.SeriesList,
|
||||||
|
XAxis: XAxisOption{
|
||||||
|
Show: FalseFlag(),
|
||||||
|
},
|
||||||
|
YAxisOptions: []YAxisOption{
|
||||||
|
{
|
||||||
|
Show: FalseFlag(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TitleOption: opt.Title,
|
||||||
|
LegendOption: opt.Legend,
|
||||||
|
backgroundIsFilled: opt.backgroundIsFilled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
seriesList := opt.SeriesList.Filter(ChartTypeRadar)
|
||||||
|
return r.render(renderResult, seriesList)
|
||||||
|
}
|
||||||
107
radar_chart_test.go
Normal file
107
radar_chart_test.go
Normal file
File diff suppressed because one or more lines are too long
159
range.go
159
range.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -24,70 +24,121 @@ package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Range struct {
|
const defaultAxisDivideCount = 6
|
||||||
TickPosition chart.TickPosition
|
|
||||||
chart.ContinuousRange
|
type axisRange struct {
|
||||||
|
p *Painter
|
||||||
|
divideCount int
|
||||||
|
min float64
|
||||||
|
max float64
|
||||||
|
size int
|
||||||
|
boundary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapRange(r chart.Range, tickPosition chart.TickPosition) chart.Range {
|
type AxisRangeOption struct {
|
||||||
xr, ok := r.(*chart.ContinuousRange)
|
Painter *Painter
|
||||||
if !ok {
|
// The min value of axis
|
||||||
return r
|
Min float64
|
||||||
|
// The max value of axis
|
||||||
|
Max float64
|
||||||
|
// The size of axis
|
||||||
|
Size int
|
||||||
|
// Boundary gap
|
||||||
|
Boundary bool
|
||||||
|
// The count of divide
|
||||||
|
DivideCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRange returns a axis range
|
||||||
|
func NewRange(opt AxisRangeOption) axisRange {
|
||||||
|
max := opt.Max
|
||||||
|
min := opt.Min
|
||||||
|
|
||||||
|
max += math.Abs(max * 0.1)
|
||||||
|
min -= math.Abs(min * 0.1)
|
||||||
|
divideCount := opt.DivideCount
|
||||||
|
r := math.Abs(max - min)
|
||||||
|
|
||||||
|
// 最小单位计算
|
||||||
|
unit := 1
|
||||||
|
if r > 5 {
|
||||||
|
unit = 2
|
||||||
}
|
}
|
||||||
return &Range{
|
if r > 10 {
|
||||||
TickPosition: tickPosition,
|
unit = 4
|
||||||
ContinuousRange: *xr,
|
}
|
||||||
|
if r > 30 {
|
||||||
|
unit = 5
|
||||||
|
}
|
||||||
|
if r > 100 {
|
||||||
|
unit = 10
|
||||||
|
}
|
||||||
|
if r > 200 {
|
||||||
|
unit = 20
|
||||||
|
}
|
||||||
|
unit = int((r/float64(divideCount))/float64(unit))*unit + unit
|
||||||
|
|
||||||
|
if min != 0 {
|
||||||
|
isLessThanZero := min < 0
|
||||||
|
min = float64(int(min/float64(unit)) * unit)
|
||||||
|
// 如果是小于0,int的时候向上取整了,因此调整
|
||||||
|
if min < 0 ||
|
||||||
|
(isLessThanZero && min == 0) {
|
||||||
|
min -= float64(unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
max = min + float64(unit*divideCount)
|
||||||
|
expectMax := opt.Max * 2
|
||||||
|
if max > expectMax {
|
||||||
|
max = float64(ceilFloatToInt(expectMax))
|
||||||
|
}
|
||||||
|
return axisRange{
|
||||||
|
p: opt.Painter,
|
||||||
|
divideCount: divideCount,
|
||||||
|
min: min,
|
||||||
|
max: max,
|
||||||
|
size: opt.Size,
|
||||||
|
boundary: opt.Boundary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate maps a given value into the ContinuousRange space.
|
// Values returns values of range
|
||||||
func (r Range) Translate(value float64) int {
|
func (r axisRange) Values() []string {
|
||||||
v := r.ContinuousRange.Translate(value)
|
offset := (r.max - r.min) / float64(r.divideCount)
|
||||||
if r.TickPosition == chart.TickPositionBetweenTicks {
|
values := make([]string, 0)
|
||||||
v -= int(float64(r.Domain) / (r.GetDelta() * 2))
|
formatter := commafWithDigits
|
||||||
|
if r.p != nil && r.p.valueFormatter != nil {
|
||||||
|
formatter = r.p.valueFormatter
|
||||||
}
|
}
|
||||||
return v
|
for i := 0; i <= r.divideCount; i++ {
|
||||||
}
|
v := r.min + float64(i)*offset
|
||||||
|
value := formatter(v)
|
||||||
type HiddenRange struct {
|
values = append(values, value)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
m.Min = min
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *YContinuousRange) SetMax(max float64) {
|
func (r *axisRange) getHeight(value float64) int {
|
||||||
// 如果已修改,则忽略
|
if r.max <= r.min {
|
||||||
if m.Max != math.MaxFloat64 {
|
return 0
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// 此处为计算得来的最大值,放大20%
|
v := (value - r.min) / (r.max - r.min)
|
||||||
v := int(max * 1.2)
|
return int(v * float64(r.size))
|
||||||
// TODO 是否要取整十整百
|
}
|
||||||
m.Max = float64(v)
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
370
series.go
370
series.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -19,114 +19,300 @@
|
||||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
// 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
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SeriesData struct {
|
type SeriesData struct {
|
||||||
|
// The value of series data
|
||||||
Value float64
|
Value float64
|
||||||
Style chart.Style
|
// The style of series data
|
||||||
|
Style Style
|
||||||
}
|
}
|
||||||
|
|
||||||
type Series struct {
|
// NewSeriesListDataFromValues returns a series list
|
||||||
Type string
|
func NewSeriesListDataFromValues(values [][]float64, chartType ...string) SeriesList {
|
||||||
Name string
|
seriesList := make(SeriesList, len(values))
|
||||||
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 {
|
for index, value := range values {
|
||||||
dataList[index] = SeriesData{
|
seriesList[index] = NewSeriesFromValues(value, chartType...)
|
||||||
|
}
|
||||||
|
return seriesList
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSeriesFromValues returns a series
|
||||||
|
func NewSeriesFromValues(values []float64, chartType ...string) Series {
|
||||||
|
s := Series{
|
||||||
|
Data: NewSeriesDataFromValues(values),
|
||||||
|
}
|
||||||
|
if len(chartType) != 0 {
|
||||||
|
s.Type = chartType[0]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSeriesDataFromValues return a series data
|
||||||
|
func NewSeriesDataFromValues(values []float64) []SeriesData {
|
||||||
|
data := make([]SeriesData, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
data[index] = SeriesData{
|
||||||
Value: value,
|
Value: value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dataList
|
return data
|
||||||
}
|
}
|
||||||
func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) []chart.Series {
|
|
||||||
arr := make([]chart.Series, len(series))
|
|
||||||
barCount := 0
|
|
||||||
barIndex := 0
|
|
||||||
for _, item := range series {
|
|
||||||
if item.Type == SeriesBar {
|
|
||||||
barCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for index, item := range series {
|
type SeriesLabel struct {
|
||||||
style := chart.Style{
|
// Data label formatter, which supports string template.
|
||||||
StrokeWidth: lineStrokeWidth,
|
// {b}: the name of a data item.
|
||||||
StrokeColor: getSeriesColor(theme, index),
|
// {c}: the value of a data item.
|
||||||
// TODO 调整为通过dot with color 生成
|
// {d}: the percent of a data item(pie chart).
|
||||||
DotColor: getSeriesColor(theme, index),
|
Formatter string
|
||||||
DotWidth: dotWith,
|
// The color for label
|
||||||
|
Color Color
|
||||||
|
// Show flag for label
|
||||||
|
Show bool
|
||||||
|
// Distance to the host graphic element.
|
||||||
|
Distance int
|
||||||
|
// The position of label
|
||||||
|
Position string
|
||||||
|
// The offset of label's position
|
||||||
|
Offset Box
|
||||||
|
// The font size of label
|
||||||
|
FontSize float64
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeriesMarkDataTypeMax = "max"
|
||||||
|
SeriesMarkDataTypeMin = "min"
|
||||||
|
SeriesMarkDataTypeAverage = "average"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SeriesMarkData struct {
|
||||||
|
// The mark data type, it can be "max", "min", "average".
|
||||||
|
// The "average" is only for mark line
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
type SeriesMarkPoint struct {
|
||||||
|
// The width of symbol, default value is 30
|
||||||
|
SymbolSize int
|
||||||
|
// The mark data of series mark point
|
||||||
|
Data []SeriesMarkData
|
||||||
|
}
|
||||||
|
type SeriesMarkLine struct {
|
||||||
|
// The mark data of series mark line
|
||||||
|
Data []SeriesMarkData
|
||||||
|
}
|
||||||
|
type Series struct {
|
||||||
|
index int
|
||||||
|
// The type of series, it can be "line", "bar" or "pie".
|
||||||
|
// Default value is "line"
|
||||||
|
Type string
|
||||||
|
// The data list of series
|
||||||
|
Data []SeriesData
|
||||||
|
// The Y axis index, it should be 0 or 1.
|
||||||
|
// Default value is 0
|
||||||
|
AxisIndex int
|
||||||
|
// The style for series
|
||||||
|
Style chart.Style
|
||||||
|
// The label for series
|
||||||
|
Label SeriesLabel
|
||||||
|
// The name of series
|
||||||
|
Name string
|
||||||
|
// Radius for Pie chart, e.g.: 40%, default is "40%"
|
||||||
|
Radius string
|
||||||
|
// Round for bar chart
|
||||||
|
RoundRadius int
|
||||||
|
// Mark point for series
|
||||||
|
MarkPoint SeriesMarkPoint
|
||||||
|
// Make line for series
|
||||||
|
MarkLine SeriesMarkLine
|
||||||
|
// Max value of series
|
||||||
|
Min *float64
|
||||||
|
// Min value of series
|
||||||
|
Max *float64
|
||||||
|
}
|
||||||
|
type SeriesList []Series
|
||||||
|
|
||||||
|
func (sl SeriesList) init() {
|
||||||
|
if len(sl) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sl[len(sl)-1].index != 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := 0; i < len(sl); i++ {
|
||||||
|
if sl[i].Type == "" {
|
||||||
|
sl[i].Type = ChartTypeLine
|
||||||
}
|
}
|
||||||
if !item.Style.StrokeColor.IsZero() {
|
sl[i].index = i
|
||||||
style.StrokeColor = item.Style.StrokeColor
|
}
|
||||||
style.DotColor = item.Style.StrokeColor
|
}
|
||||||
}
|
|
||||||
pointIndexOffset := 0
|
func (sl SeriesList) Filter(chartType string) SeriesList {
|
||||||
// 如果居中,需要多增加一个点
|
arr := make(SeriesList, 0)
|
||||||
if tickPosition == chart.TickPositionBetweenTicks {
|
for index, item := range sl {
|
||||||
item.Data = append([]SeriesData{
|
if item.Type == chartType {
|
||||||
{
|
arr = append(arr, sl[index])
|
||||||
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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
148
series_label.go
Normal file
148
series_label.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type labelRenderValue struct {
|
||||||
|
Text string
|
||||||
|
Style Style
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
// 旋转
|
||||||
|
Radians float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelValue struct {
|
||||||
|
Index int
|
||||||
|
Value float64
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
// 旋转
|
||||||
|
Radians float64
|
||||||
|
// 字体颜色
|
||||||
|
FontColor Color
|
||||||
|
// 字体大小
|
||||||
|
FontSize float64
|
||||||
|
Orient string
|
||||||
|
Offset Box
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeriesLabelPainter struct {
|
||||||
|
p *Painter
|
||||||
|
seriesNames []string
|
||||||
|
label *SeriesLabel
|
||||||
|
theme ColorPalette
|
||||||
|
font *truetype.Font
|
||||||
|
values []labelRenderValue
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeriesLabelPainterParams struct {
|
||||||
|
P *Painter
|
||||||
|
SeriesNames []string
|
||||||
|
Label SeriesLabel
|
||||||
|
Theme ColorPalette
|
||||||
|
Font *truetype.Font
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSeriesLabelPainter(params SeriesLabelPainterParams) *SeriesLabelPainter {
|
||||||
|
return &SeriesLabelPainter{
|
||||||
|
p: params.P,
|
||||||
|
seriesNames: params.SeriesNames,
|
||||||
|
label: ¶ms.Label,
|
||||||
|
theme: params.Theme,
|
||||||
|
font: params.Font,
|
||||||
|
values: make([]labelRenderValue, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *SeriesLabelPainter) Add(value LabelValue) {
|
||||||
|
label := o.label
|
||||||
|
distance := label.Distance
|
||||||
|
if distance == 0 {
|
||||||
|
distance = 5
|
||||||
|
}
|
||||||
|
text := NewValueLabelFormatter(o.seriesNames, label.Formatter)(value.Index, value.Value, -1)
|
||||||
|
labelStyle := Style{
|
||||||
|
FontColor: o.theme.GetTextColor(),
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
Font: o.font,
|
||||||
|
}
|
||||||
|
if value.FontSize != 0 {
|
||||||
|
labelStyle.FontSize = value.FontSize
|
||||||
|
}
|
||||||
|
if !value.FontColor.IsZero() {
|
||||||
|
label.Color = value.FontColor
|
||||||
|
}
|
||||||
|
if !label.Color.IsZero() {
|
||||||
|
labelStyle.FontColor = label.Color
|
||||||
|
}
|
||||||
|
p := o.p
|
||||||
|
p.OverrideDrawingStyle(labelStyle)
|
||||||
|
rotated := value.Radians != 0
|
||||||
|
if rotated {
|
||||||
|
p.SetTextRotation(value.Radians)
|
||||||
|
}
|
||||||
|
textBox := p.MeasureText(text)
|
||||||
|
renderValue := labelRenderValue{
|
||||||
|
Text: text,
|
||||||
|
Style: labelStyle,
|
||||||
|
X: value.X,
|
||||||
|
Y: value.Y,
|
||||||
|
Radians: value.Radians,
|
||||||
|
}
|
||||||
|
if value.Orient != OrientHorizontal {
|
||||||
|
renderValue.X -= textBox.Width() >> 1
|
||||||
|
renderValue.Y -= distance
|
||||||
|
} else {
|
||||||
|
renderValue.X += distance
|
||||||
|
renderValue.Y += textBox.Height() >> 1
|
||||||
|
renderValue.Y -= 2
|
||||||
|
}
|
||||||
|
if rotated {
|
||||||
|
renderValue.X = value.X + textBox.Width()>>1 - 1
|
||||||
|
p.ClearTextRotation()
|
||||||
|
} else {
|
||||||
|
if textBox.Width()%2 != 0 {
|
||||||
|
renderValue.X++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderValue.X += value.Offset.Left
|
||||||
|
renderValue.Y += value.Offset.Top
|
||||||
|
o.values = append(o.values, renderValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *SeriesLabelPainter) Render() (Box, error) {
|
||||||
|
for _, item := range o.values {
|
||||||
|
o.p.OverrideTextStyle(item.Style)
|
||||||
|
if item.Radians != 0 {
|
||||||
|
o.p.TextRotation(item.Text, item.X, item.Y, item.Radians)
|
||||||
|
} else {
|
||||||
|
o.p.Text(item.Text, item.X, item.Y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chart.BoxZero, nil
|
||||||
|
}
|
||||||
134
series_test.go
134
series_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -19,107 +19,71 @@
|
||||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
// 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
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSeriesDataListFromFloat(t *testing.T) {
|
func TestNewSeriesListDataFromValues(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
assert.Equal([]SeriesData{
|
assert.Equal(SeriesList{
|
||||||
{
|
{
|
||||||
Value: 1,
|
Type: ChartTypeBar,
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 1.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
}, NewSeriesListDataFromValues([][]float64{
|
||||||
{
|
{
|
||||||
Value: 2,
|
1,
|
||||||
},
|
},
|
||||||
}, NewSeriesDataListFromFloat([]float64{
|
}, ChartTypeBar))
|
||||||
1,
|
|
||||||
2,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSeries(t *testing.T) {
|
func TestSeriesLists(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
seriesList := NewSeriesListDataFromValues([][]float64{
|
||||||
|
{
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
10,
|
||||||
|
},
|
||||||
|
}, ChartTypeBar)
|
||||||
|
|
||||||
|
assert.Equal(2, len(seriesList.Filter(ChartTypeBar)))
|
||||||
|
assert.Equal(0, len(seriesList.Filter(ChartTypeLine)))
|
||||||
|
|
||||||
|
max, min := seriesList.GetMaxMin(0)
|
||||||
|
assert.Equal(float64(10), max)
|
||||||
|
assert.Equal(float64(1), min)
|
||||||
|
|
||||||
|
assert.Equal(seriesSummary{
|
||||||
|
MaxIndex: 1,
|
||||||
|
MaxValue: 2,
|
||||||
|
MinIndex: 0,
|
||||||
|
MinValue: 1,
|
||||||
|
AverageValue: 1.5,
|
||||||
|
}, seriesList[0].Summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatter(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
xValues := []float64{
|
assert.Equal("a: 12%", NewPieLabelFormatter([]string{
|
||||||
1,
|
"a",
|
||||||
2,
|
"b",
|
||||||
3,
|
}, "")(0, 10, 0.12))
|
||||||
4,
|
|
||||||
5,
|
|
||||||
}
|
|
||||||
|
|
||||||
barData := NewSeriesDataListFromFloat([]float64{
|
assert.Equal("10", NewValueLabelFormatter([]string{
|
||||||
10,
|
"a",
|
||||||
20,
|
"b",
|
||||||
30,
|
}, "")(0, 10, 0.12))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
254
start_zh.md
Normal file
254
start_zh.md
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
# go-charts
|
||||||
|
|
||||||
|
`go-charts`主要分为了下几个模块:
|
||||||
|
|
||||||
|
- `标题`:图表的标题,包括主副标题,位置为图表的顶部
|
||||||
|
- `图例`:图表的图例列表,用于标识每个图例对应的颜色与名称信息,默认为图表的顶部,可自定义位置
|
||||||
|
- `X轴`:图表的x轴,用于折线图、柱状图中,表示每个点对应的时间,位置图表的底部
|
||||||
|
- `Y轴`:图表的y轴,用于折线图、柱状图中,最多可使用两组y轴(一左一右),默认位置图表的左侧
|
||||||
|
- `内容`: 图表的内容,折线图、柱状图、饼图等,在图表的中间区域
|
||||||
|
|
||||||
|
## 标题
|
||||||
|
|
||||||
|
### 常用设置
|
||||||
|
|
||||||
|
标题一般仅需要设置主副标题即可,其它的属性均会设置默认值,常用的方式是使用`TitleTextOptionFunc`设置,其中副标题为可选值,方式如下:
|
||||||
|
|
||||||
|
```go
|
||||||
|
charts.TitleTextOptionFunc("Text", "Subtext"),
|
||||||
|
```
|
||||||
|
|
||||||
|
### 个性化设置
|
||||||
|
|
||||||
|
```go
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.Title = charts.TitleOption{
|
||||||
|
// 主标题
|
||||||
|
Text: "Text",
|
||||||
|
// 副标题
|
||||||
|
Subtext: "Subtext",
|
||||||
|
// 标题左侧位置,可设置为"center","right",数值("20")或百份比("20%")
|
||||||
|
Left: charts.PositionRight,
|
||||||
|
// 标题顶部位置,只可调为数值
|
||||||
|
Top: "20",
|
||||||
|
// 主标题文字大小
|
||||||
|
FontSize: 14,
|
||||||
|
// 副标题文字大小
|
||||||
|
SubtextFontSize: 12,
|
||||||
|
// 主标题字体颜色
|
||||||
|
FontColor: charts.Color{
|
||||||
|
R: 100,
|
||||||
|
G: 100,
|
||||||
|
B: 100,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
// 副标题字体影响
|
||||||
|
SubtextFontColor: charts.Color{
|
||||||
|
R: 200,
|
||||||
|
G: 200,
|
||||||
|
B: 200,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部分属性个性化设置
|
||||||
|
|
||||||
|
```go
|
||||||
|
charts.TitleTextOptionFunc("Text", "Subtext"),
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
// 修改top的值
|
||||||
|
opt.Title.Top = "20"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
## 图例
|
||||||
|
|
||||||
|
### 常用设置
|
||||||
|
|
||||||
|
图例组件与图表中的数据一一对应,常用仅设置其名称及左侧的值即可(可选),方式如下:
|
||||||
|
|
||||||
|
|
||||||
|
```go
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
"Direct",
|
||||||
|
"Search Engine",
|
||||||
|
}, "50"),
|
||||||
|
```
|
||||||
|
|
||||||
|
### 个性化设置
|
||||||
|
|
||||||
|
```go
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.Legend = charts.LegendOption{
|
||||||
|
// 图例名称
|
||||||
|
Data: []string{
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
"Direct",
|
||||||
|
"Search Engine",
|
||||||
|
},
|
||||||
|
// 图例左侧位置,可设置为"center","right",数值("20")或百份比("20%")
|
||||||
|
// 如果示例有多行,只影响第一行,而且对于多行的示例,设置"center", "right"无效
|
||||||
|
Left: "50",
|
||||||
|
// 图例顶部位置,只可调为数值
|
||||||
|
Top: "10",
|
||||||
|
// 图例图标的位置,默认为左侧,只允许左或右
|
||||||
|
Align: charts.AlignRight,
|
||||||
|
// 图例排列方式,默认为水平,只允许水平或垂直
|
||||||
|
Orient: charts.OrientVertical,
|
||||||
|
// 图标类型,提供"rect"与"lineDot"两种类型
|
||||||
|
Icon: charts.IconRect,
|
||||||
|
// 字体大小
|
||||||
|
FontSize: 14,
|
||||||
|
// 字体颜色
|
||||||
|
FontColor: charts.Color{
|
||||||
|
R: 150,
|
||||||
|
G: 150,
|
||||||
|
B: 150,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
// 是否展示,如果不需要展示则设置
|
||||||
|
// Show: charts.FalseFlag(),
|
||||||
|
// 图例区域的padding值
|
||||||
|
Padding: charts.Box{
|
||||||
|
Top: 10,
|
||||||
|
Left: 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部分属性个性化设置
|
||||||
|
|
||||||
|
```go
|
||||||
|
charts.LegendLabelsOptionFunc([]string{
|
||||||
|
"Email",
|
||||||
|
"Union Ads",
|
||||||
|
"Video Ads",
|
||||||
|
"Direct",
|
||||||
|
"Search Engine",
|
||||||
|
}, "50"),
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.Legend.Top = "10"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
## X轴
|
||||||
|
|
||||||
|
### 常用设置
|
||||||
|
|
||||||
|
图表中X轴的展示,常用的设置方式是指定数组即可:
|
||||||
|
|
||||||
|
```go
|
||||||
|
charts.XAxisDataOptionFunc([]string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
### 个性化设置
|
||||||
|
|
||||||
|
```go
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.XAxis = charts.XAxisOption{
|
||||||
|
// X轴内容
|
||||||
|
Data: []string{
|
||||||
|
"01",
|
||||||
|
"02",
|
||||||
|
"03",
|
||||||
|
"04",
|
||||||
|
"05",
|
||||||
|
"06",
|
||||||
|
"07",
|
||||||
|
"08",
|
||||||
|
"09",
|
||||||
|
},
|
||||||
|
// 如果数据点不居中,则设置为false
|
||||||
|
BoundaryGap: charts.FalseFlag(),
|
||||||
|
// 字体大小
|
||||||
|
FontSize: 14,
|
||||||
|
// 是否展示,如果不需要展示则设置
|
||||||
|
// Show: charts.FalseFlag(),
|
||||||
|
// 会根据文本内容以及此值选择适合的分块大小,一般不需要设置
|
||||||
|
// SplitNumber: 3,
|
||||||
|
// 线条颜色
|
||||||
|
StrokeColor: charts.Color{
|
||||||
|
R: 200,
|
||||||
|
G: 200,
|
||||||
|
B: 200,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
// 文字颜色
|
||||||
|
FontColor: charts.Color{
|
||||||
|
R: 100,
|
||||||
|
G: 100,
|
||||||
|
B: 100,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部分属性个性化设置
|
||||||
|
|
||||||
|
```go
|
||||||
|
charts.XAxisDataOptionFunc([]string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
}),
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.XAxis.FontColor = charts.Color{
|
||||||
|
R: 100,
|
||||||
|
G: 100,
|
||||||
|
B: 100,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
## Y轴
|
||||||
|
|
||||||
|
图表中的y轴展示的相关数据会根据图表中的数据自动生成适合的值,如果需要自定义,则可自定义以下部分数据:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func(opt *charts.ChartOption) {
|
||||||
|
opt.YAxisOptions = []charts.YAxisOption{
|
||||||
|
{
|
||||||
|
// 字体大小
|
||||||
|
FontSize: 16,
|
||||||
|
// 字体颜色
|
||||||
|
FontColor: charts.Color{
|
||||||
|
R: 100,
|
||||||
|
G: 100,
|
||||||
|
B: 100,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
// 内容,{value}会替换为对应的值
|
||||||
|
Formatter: "{value} ml",
|
||||||
|
// Y轴颜色,如果设置此值,会覆盖font color
|
||||||
|
Color: charts.Color{
|
||||||
|
R: 255,
|
||||||
|
G: 0,
|
||||||
|
B: 0,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
438
table.go
Normal file
438
table.go
Normal file
|
|
@ -0,0 +1,438 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tableChart struct {
|
||||||
|
p *Painter
|
||||||
|
opt *TableChartOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTableChart returns a table chart render
|
||||||
|
func NewTableChart(p *Painter, opt TableChartOption) *tableChart {
|
||||||
|
if opt.Theme == nil {
|
||||||
|
opt.Theme = defaultTheme
|
||||||
|
}
|
||||||
|
return &tableChart{
|
||||||
|
p: p,
|
||||||
|
opt: &opt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableCell struct {
|
||||||
|
// Text the text of table cell
|
||||||
|
Text string
|
||||||
|
// Style the current style of table cell
|
||||||
|
Style Style
|
||||||
|
// Row the row index of table cell
|
||||||
|
Row int
|
||||||
|
// Column the column index of table cell
|
||||||
|
Column int
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableChartOption struct {
|
||||||
|
// The output type
|
||||||
|
Type string
|
||||||
|
// The width of table
|
||||||
|
Width int
|
||||||
|
// The theme
|
||||||
|
Theme ColorPalette
|
||||||
|
// The padding of table cell
|
||||||
|
Padding Box
|
||||||
|
// The header data of table
|
||||||
|
Header []string
|
||||||
|
// The data of table
|
||||||
|
Data [][]string
|
||||||
|
// The span list of table column
|
||||||
|
Spans []int
|
||||||
|
// The text align list of table cell
|
||||||
|
TextAligns []string
|
||||||
|
// The font size of table
|
||||||
|
FontSize float64
|
||||||
|
// The font family, which should be installed first
|
||||||
|
FontFamily string
|
||||||
|
Font *truetype.Font
|
||||||
|
// The font color of table
|
||||||
|
FontColor Color
|
||||||
|
// The background color of header
|
||||||
|
HeaderBackgroundColor Color
|
||||||
|
// The header font color
|
||||||
|
HeaderFontColor Color
|
||||||
|
// The background color of row
|
||||||
|
RowBackgroundColors []Color
|
||||||
|
// The background color
|
||||||
|
BackgroundColor Color
|
||||||
|
// CellTextStyle customize text style of table cell
|
||||||
|
CellTextStyle func(TableCell) *Style
|
||||||
|
// CellStyle customize drawing style of table cell
|
||||||
|
CellStyle func(TableCell) *Style
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableSetting struct {
|
||||||
|
// The color of header
|
||||||
|
HeaderColor Color
|
||||||
|
// The color of heder text
|
||||||
|
HeaderFontColor Color
|
||||||
|
// The color of table text
|
||||||
|
FontColor Color
|
||||||
|
// The color list of row
|
||||||
|
RowColors []Color
|
||||||
|
// The padding of cell
|
||||||
|
Padding Box
|
||||||
|
}
|
||||||
|
|
||||||
|
var TableLightThemeSetting = TableSetting{
|
||||||
|
HeaderColor: Color{
|
||||||
|
R: 240,
|
||||||
|
G: 240,
|
||||||
|
B: 240,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
HeaderFontColor: Color{
|
||||||
|
R: 98,
|
||||||
|
G: 105,
|
||||||
|
B: 118,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FontColor: Color{
|
||||||
|
R: 70,
|
||||||
|
G: 70,
|
||||||
|
B: 70,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
RowColors: []Color{
|
||||||
|
drawing.ColorWhite,
|
||||||
|
{
|
||||||
|
R: 247,
|
||||||
|
G: 247,
|
||||||
|
B: 247,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Padding: Box{
|
||||||
|
Left: 10,
|
||||||
|
Top: 10,
|
||||||
|
Right: 10,
|
||||||
|
Bottom: 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var TableDarkThemeSetting = TableSetting{
|
||||||
|
HeaderColor: Color{
|
||||||
|
R: 38,
|
||||||
|
G: 38,
|
||||||
|
B: 42,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
HeaderFontColor: Color{
|
||||||
|
R: 216,
|
||||||
|
G: 217,
|
||||||
|
B: 218,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FontColor: Color{
|
||||||
|
R: 216,
|
||||||
|
G: 217,
|
||||||
|
B: 218,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
RowColors: []Color{
|
||||||
|
{
|
||||||
|
R: 24,
|
||||||
|
G: 24,
|
||||||
|
B: 28,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
R: 38,
|
||||||
|
G: 38,
|
||||||
|
B: 42,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Padding: Box{
|
||||||
|
Left: 10,
|
||||||
|
Top: 10,
|
||||||
|
Right: 10,
|
||||||
|
Bottom: 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var tableDefaultSetting = TableLightThemeSetting
|
||||||
|
|
||||||
|
// SetDefaultTableSetting sets the default setting for table
|
||||||
|
func SetDefaultTableSetting(setting TableSetting) {
|
||||||
|
tableDefaultSetting = setting
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderInfo struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
HeaderHeight int
|
||||||
|
RowHeights []int
|
||||||
|
ColumnWidths []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tableChart) render() (*renderInfo, error) {
|
||||||
|
info := renderInfo{
|
||||||
|
RowHeights: make([]int, 0),
|
||||||
|
}
|
||||||
|
p := t.p
|
||||||
|
opt := t.opt
|
||||||
|
if len(opt.Header) == 0 {
|
||||||
|
return nil, errors.New("header can not be nil")
|
||||||
|
}
|
||||||
|
theme := opt.Theme
|
||||||
|
if theme == nil {
|
||||||
|
theme = p.theme
|
||||||
|
}
|
||||||
|
fontSize := opt.FontSize
|
||||||
|
if fontSize == 0 {
|
||||||
|
fontSize = 12
|
||||||
|
}
|
||||||
|
fontColor := opt.FontColor
|
||||||
|
if fontColor.IsZero() {
|
||||||
|
fontColor = tableDefaultSetting.FontColor
|
||||||
|
}
|
||||||
|
font := opt.Font
|
||||||
|
if font == nil {
|
||||||
|
font = theme.GetFont()
|
||||||
|
}
|
||||||
|
headerFontColor := opt.HeaderFontColor
|
||||||
|
if opt.HeaderFontColor.IsZero() {
|
||||||
|
headerFontColor = tableDefaultSetting.HeaderFontColor
|
||||||
|
}
|
||||||
|
|
||||||
|
spans := opt.Spans
|
||||||
|
if len(spans) != len(opt.Header) {
|
||||||
|
newSpans := make([]int, len(opt.Header))
|
||||||
|
for index := range opt.Header {
|
||||||
|
if index >= len(spans) {
|
||||||
|
newSpans[index] = 1
|
||||||
|
} else {
|
||||||
|
newSpans[index] = spans[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spans = newSpans
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sumInt(spans)
|
||||||
|
values := autoDivideSpans(p.Width(), sum, spans)
|
||||||
|
columnWidths := make([]int, 0)
|
||||||
|
for index, v := range values {
|
||||||
|
if index == len(values)-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
columnWidths = append(columnWidths, values[index+1]-v)
|
||||||
|
}
|
||||||
|
info.ColumnWidths = columnWidths
|
||||||
|
|
||||||
|
height := 0
|
||||||
|
textStyle := Style{
|
||||||
|
FontSize: fontSize,
|
||||||
|
FontColor: headerFontColor,
|
||||||
|
FillColor: headerFontColor,
|
||||||
|
Font: font,
|
||||||
|
}
|
||||||
|
|
||||||
|
headerHeight := 0
|
||||||
|
padding := opt.Padding
|
||||||
|
if padding.IsZero() {
|
||||||
|
padding = tableDefaultSetting.Padding
|
||||||
|
}
|
||||||
|
getCellTextStyle := opt.CellTextStyle
|
||||||
|
if getCellTextStyle == nil {
|
||||||
|
getCellTextStyle = func(_ TableCell) *Style {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// textAligns := opt.TextAligns
|
||||||
|
getTextAlign := func(index int) string {
|
||||||
|
if len(opt.TextAligns) <= index {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return opt.TextAligns[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格单元的处理
|
||||||
|
renderTableCells := func(
|
||||||
|
currentStyle Style,
|
||||||
|
rowIndex int,
|
||||||
|
textList []string,
|
||||||
|
currentHeight int,
|
||||||
|
cellPadding Box,
|
||||||
|
) int {
|
||||||
|
cellMaxHeight := 0
|
||||||
|
paddingHeight := cellPadding.Top + cellPadding.Bottom
|
||||||
|
paddingWidth := cellPadding.Left + cellPadding.Right
|
||||||
|
for index, text := range textList {
|
||||||
|
cellStyle := getCellTextStyle(TableCell{
|
||||||
|
Text: text,
|
||||||
|
Row: rowIndex,
|
||||||
|
Column: index,
|
||||||
|
Style: currentStyle,
|
||||||
|
})
|
||||||
|
if cellStyle == nil {
|
||||||
|
cellStyle = ¤tStyle
|
||||||
|
}
|
||||||
|
p.SetStyle(*cellStyle)
|
||||||
|
x := values[index]
|
||||||
|
y := currentHeight + cellPadding.Top
|
||||||
|
width := values[index+1] - x
|
||||||
|
x += cellPadding.Left
|
||||||
|
width -= paddingWidth
|
||||||
|
box := p.TextFit(text, x, y+int(fontSize), width, getTextAlign(index))
|
||||||
|
// 计算最高的高度
|
||||||
|
if box.Height()+paddingHeight > cellMaxHeight {
|
||||||
|
cellMaxHeight = box.Height() + paddingHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cellMaxHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表头的处理
|
||||||
|
headerHeight = renderTableCells(textStyle, 0, opt.Header, height, padding)
|
||||||
|
height += headerHeight
|
||||||
|
info.HeaderHeight = headerHeight
|
||||||
|
|
||||||
|
// 表格内容的处理
|
||||||
|
textStyle.FontColor = fontColor
|
||||||
|
textStyle.FillColor = fontColor
|
||||||
|
for index, textList := range opt.Data {
|
||||||
|
cellHeight := renderTableCells(textStyle, index+1, textList, height, padding)
|
||||||
|
info.RowHeights = append(info.RowHeights, cellHeight)
|
||||||
|
height += cellHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Width = p.Width()
|
||||||
|
info.Height = height
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tableChart) renderWithInfo(info *renderInfo) (Box, error) {
|
||||||
|
p := t.p
|
||||||
|
opt := t.opt
|
||||||
|
if !opt.BackgroundColor.IsZero() {
|
||||||
|
p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
|
||||||
|
}
|
||||||
|
headerBGColor := opt.HeaderBackgroundColor
|
||||||
|
if headerBGColor.IsZero() {
|
||||||
|
headerBGColor = tableDefaultSetting.HeaderColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果设置表头背景色
|
||||||
|
p.SetBackground(info.Width, info.HeaderHeight, headerBGColor, true)
|
||||||
|
currentHeight := info.HeaderHeight
|
||||||
|
rowColors := opt.RowBackgroundColors
|
||||||
|
if rowColors == nil {
|
||||||
|
rowColors = tableDefaultSetting.RowColors
|
||||||
|
}
|
||||||
|
for index, h := range info.RowHeights {
|
||||||
|
color := rowColors[index%len(rowColors)]
|
||||||
|
child := p.Child(PainterPaddingOption(Box{
|
||||||
|
Top: currentHeight,
|
||||||
|
}))
|
||||||
|
child.SetBackground(p.Width(), h, color, true)
|
||||||
|
currentHeight += h
|
||||||
|
}
|
||||||
|
// 根据是否有设置表格样式调整背景色
|
||||||
|
getCellStyle := opt.CellStyle
|
||||||
|
if getCellStyle != nil {
|
||||||
|
arr := [][]string{
|
||||||
|
opt.Header,
|
||||||
|
}
|
||||||
|
arr = append(arr, opt.Data...)
|
||||||
|
top := 0
|
||||||
|
heights := []int{
|
||||||
|
info.HeaderHeight,
|
||||||
|
}
|
||||||
|
heights = append(heights, info.RowHeights...)
|
||||||
|
// 循环所有表格单元,生成背景色
|
||||||
|
for i, textList := range arr {
|
||||||
|
left := 0
|
||||||
|
for j, v := range textList {
|
||||||
|
style := getCellStyle(TableCell{
|
||||||
|
Text: v,
|
||||||
|
Row: i,
|
||||||
|
Column: j,
|
||||||
|
})
|
||||||
|
if style != nil && !style.FillColor.IsZero() {
|
||||||
|
padding := style.Padding
|
||||||
|
child := p.Child(PainterPaddingOption(Box{
|
||||||
|
Top: top + padding.Top,
|
||||||
|
Left: left + padding.Left,
|
||||||
|
}))
|
||||||
|
w := info.ColumnWidths[j] - padding.Left - padding.Top
|
||||||
|
h := heights[i] - padding.Top - padding.Bottom
|
||||||
|
child.SetBackground(w, h, style.FillColor, true)
|
||||||
|
}
|
||||||
|
left += info.ColumnWidths[j]
|
||||||
|
}
|
||||||
|
top += heights[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := t.render()
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Box{
|
||||||
|
Right: info.Width,
|
||||||
|
Bottom: info.Height,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tableChart) Render() (Box, error) {
|
||||||
|
p := t.p
|
||||||
|
opt := t.opt
|
||||||
|
if !opt.BackgroundColor.IsZero() {
|
||||||
|
p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
|
||||||
|
}
|
||||||
|
if opt.Font == nil && opt.FontFamily != "" {
|
||||||
|
opt.Font, _ = GetFont(opt.FontFamily)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := p.render
|
||||||
|
fn := chart.PNG
|
||||||
|
if p.outputType == ChartOutputSVG {
|
||||||
|
fn = chart.SVG
|
||||||
|
}
|
||||||
|
newRender, err := fn(p.Width(), 100)
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
p.render = newRender
|
||||||
|
info, err := t.render()
|
||||||
|
if err != nil {
|
||||||
|
return BoxZero, err
|
||||||
|
}
|
||||||
|
p.render = r
|
||||||
|
return t.renderWithInfo(info)
|
||||||
|
}
|
||||||
140
table_test.go
Normal file
140
table_test.go
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTableChart(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
render func(*Painter) ([]byte, error)
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
render: func(p *Painter) ([]byte, error) {
|
||||||
|
_, err := NewTableChart(p, TableChartOption{
|
||||||
|
Header: []string{
|
||||||
|
"Name",
|
||||||
|
"Age",
|
||||||
|
"Address",
|
||||||
|
"Tag",
|
||||||
|
"Action",
|
||||||
|
},
|
||||||
|
Spans: []int{
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
// span和header不匹配,最后自动设置为1
|
||||||
|
// 1,
|
||||||
|
},
|
||||||
|
Data: [][]string{
|
||||||
|
{
|
||||||
|
"John Brown",
|
||||||
|
"32",
|
||||||
|
"New York No. 1 Lake Park",
|
||||||
|
"nice, developer",
|
||||||
|
"Send Mail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Jim Green ",
|
||||||
|
"42",
|
||||||
|
"London No. 1 Lake Park",
|
||||||
|
"wow",
|
||||||
|
"Send Mail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Joe Black ",
|
||||||
|
"32",
|
||||||
|
"Sidney No. 1 Lake Park",
|
||||||
|
"cool, teacher",
|
||||||
|
"Send Mail",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Bytes()
|
||||||
|
},
|
||||||
|
result: "<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
446
theme.go
446
theme.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,200 +23,310 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"github.com/golang/freetype/truetype"
|
||||||
"strconv"
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var hiddenColor = drawing.Color{R: 255, G: 255, B: 255, A: 0}
|
const ThemeDark = "dark"
|
||||||
|
const ThemeLight = "light"
|
||||||
|
const ThemeGrafana = "grafana"
|
||||||
|
const ThemeAnt = "ant"
|
||||||
|
|
||||||
var AxisColorLight = drawing.Color{
|
type ColorPalette interface {
|
||||||
R: 110,
|
IsDark() bool
|
||||||
G: 112,
|
GetAxisStrokeColor() Color
|
||||||
B: 121,
|
SetAxisStrokeColor(Color)
|
||||||
|
GetAxisSplitLineColor() Color
|
||||||
|
SetAxisSplitLineColor(Color)
|
||||||
|
GetSeriesColor(int) Color
|
||||||
|
SetSeriesColor([]Color)
|
||||||
|
GetBackgroundColor() Color
|
||||||
|
SetBackgroundColor(Color)
|
||||||
|
GetTextColor() Color
|
||||||
|
SetTextColor(Color)
|
||||||
|
GetFontSize() float64
|
||||||
|
SetFontSize(float64)
|
||||||
|
GetFont() *truetype.Font
|
||||||
|
SetFont(*truetype.Font)
|
||||||
|
}
|
||||||
|
|
||||||
|
type themeColorPalette struct {
|
||||||
|
isDarkMode bool
|
||||||
|
axisStrokeColor Color
|
||||||
|
axisSplitLineColor Color
|
||||||
|
backgroundColor Color
|
||||||
|
textColor Color
|
||||||
|
seriesColors []Color
|
||||||
|
fontSize float64
|
||||||
|
font *truetype.Font
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeOption struct {
|
||||||
|
IsDarkMode bool
|
||||||
|
AxisStrokeColor Color
|
||||||
|
AxisSplitLineColor Color
|
||||||
|
BackgroundColor Color
|
||||||
|
TextColor Color
|
||||||
|
SeriesColors []Color
|
||||||
|
}
|
||||||
|
|
||||||
|
var palettes = map[string]*themeColorPalette{}
|
||||||
|
|
||||||
|
const defaultFontSize = 12.0
|
||||||
|
|
||||||
|
var defaultTheme ColorPalette
|
||||||
|
|
||||||
|
var defaultLightFontColor = drawing.Color{
|
||||||
|
R: 70,
|
||||||
|
G: 70,
|
||||||
|
B: 70,
|
||||||
A: 255,
|
A: 255,
|
||||||
}
|
}
|
||||||
var AxisColorDark = drawing.Color{
|
var defaultDarkFontColor = drawing.Color{
|
||||||
R: 185,
|
R: 238,
|
||||||
G: 184,
|
G: 238,
|
||||||
B: 206,
|
B: 238,
|
||||||
A: 255,
|
A: 255,
|
||||||
}
|
}
|
||||||
|
|
||||||
var GridColorDark = drawing.Color{
|
func init() {
|
||||||
R: 72,
|
echartSeriesColors := []Color{
|
||||||
G: 71,
|
parseColor("#5470c6"),
|
||||||
B: 83,
|
parseColor("#91cc75"),
|
||||||
A: 255,
|
parseColor("#fac858"),
|
||||||
}
|
parseColor("#ee6666"),
|
||||||
|
parseColor("#73c0de"),
|
||||||
var GridColorLight = drawing.Color{
|
parseColor("#3ba272"),
|
||||||
R: 224,
|
parseColor("#fc8452"),
|
||||||
G: 230,
|
parseColor("#9a60b4"),
|
||||||
B: 241,
|
parseColor("#ea7ccc"),
|
||||||
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
|
grafanaSeriesColors := []Color{
|
||||||
}
|
parseColor("#7EB26D"),
|
||||||
|
parseColor("#EAB839"),
|
||||||
func getGridColor(theme string) drawing.Color {
|
parseColor("#6ED0E0"),
|
||||||
if theme == ThemeDark {
|
parseColor("#EF843C"),
|
||||||
return GridColorDark
|
parseColor("#E24D42"),
|
||||||
|
parseColor("#1F78C1"),
|
||||||
|
parseColor("#705DA0"),
|
||||||
|
parseColor("#508642"),
|
||||||
}
|
}
|
||||||
return GridColorLight
|
antSeriesColors := []Color{
|
||||||
}
|
parseColor("#5b8ff9"),
|
||||||
|
parseColor("#5ad8a6"),
|
||||||
var SeriesColorsLight = []drawing.Color{
|
parseColor("#5d7092"),
|
||||||
{
|
parseColor("#f6bd16"),
|
||||||
R: 84,
|
parseColor("#6f5ef9"),
|
||||||
G: 112,
|
parseColor("#6dc8ec"),
|
||||||
B: 198,
|
parseColor("#945fb9"),
|
||||||
A: 255,
|
parseColor("#ff9845"),
|
||||||
},
|
|
||||||
{
|
|
||||||
R: 145,
|
|
||||||
G: 204,
|
|
||||||
B: 117,
|
|
||||||
A: 255,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
R: 250,
|
|
||||||
G: 200,
|
|
||||||
B: 88,
|
|
||||||
A: 255,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
R: 238,
|
|
||||||
G: 102,
|
|
||||||
B: 102,
|
|
||||||
A: 255,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
R: 115,
|
|
||||||
G: 192,
|
|
||||||
B: 222,
|
|
||||||
A: 255,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBackgroundColor(theme string) drawing.Color {
|
|
||||||
if theme == ThemeDark {
|
|
||||||
return BackgroundColorDark
|
|
||||||
}
|
}
|
||||||
return chart.DefaultBackgroundColor
|
AddTheme(
|
||||||
|
ThemeDark,
|
||||||
|
ThemeOption{
|
||||||
|
IsDarkMode: true,
|
||||||
|
AxisStrokeColor: Color{
|
||||||
|
R: 185,
|
||||||
|
G: 184,
|
||||||
|
B: 206,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
AxisSplitLineColor: Color{
|
||||||
|
R: 72,
|
||||||
|
G: 71,
|
||||||
|
B: 83,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
BackgroundColor: Color{
|
||||||
|
R: 16,
|
||||||
|
G: 12,
|
||||||
|
B: 42,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
TextColor: Color{
|
||||||
|
R: 238,
|
||||||
|
G: 238,
|
||||||
|
B: 238,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
SeriesColors: echartSeriesColors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
AddTheme(
|
||||||
|
ThemeLight,
|
||||||
|
ThemeOption{
|
||||||
|
IsDarkMode: false,
|
||||||
|
AxisStrokeColor: Color{
|
||||||
|
R: 110,
|
||||||
|
G: 112,
|
||||||
|
B: 121,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
AxisSplitLineColor: Color{
|
||||||
|
R: 224,
|
||||||
|
G: 230,
|
||||||
|
B: 242,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
BackgroundColor: drawing.ColorWhite,
|
||||||
|
TextColor: Color{
|
||||||
|
R: 70,
|
||||||
|
G: 70,
|
||||||
|
B: 70,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
SeriesColors: echartSeriesColors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AddTheme(
|
||||||
|
ThemeAnt,
|
||||||
|
ThemeOption{
|
||||||
|
IsDarkMode: false,
|
||||||
|
AxisStrokeColor: Color{
|
||||||
|
R: 110,
|
||||||
|
G: 112,
|
||||||
|
B: 121,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
AxisSplitLineColor: Color{
|
||||||
|
R: 224,
|
||||||
|
G: 230,
|
||||||
|
B: 242,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
BackgroundColor: drawing.ColorWhite,
|
||||||
|
TextColor: drawing.Color{
|
||||||
|
R: 70,
|
||||||
|
G: 70,
|
||||||
|
B: 70,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
SeriesColors: antSeriesColors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AddTheme(
|
||||||
|
ThemeGrafana,
|
||||||
|
ThemeOption{
|
||||||
|
IsDarkMode: true,
|
||||||
|
AxisStrokeColor: Color{
|
||||||
|
R: 185,
|
||||||
|
G: 184,
|
||||||
|
B: 206,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
AxisSplitLineColor: Color{
|
||||||
|
R: 68,
|
||||||
|
G: 67,
|
||||||
|
B: 67,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
BackgroundColor: drawing.Color{
|
||||||
|
R: 31,
|
||||||
|
G: 29,
|
||||||
|
B: 29,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
TextColor: Color{
|
||||||
|
R: 216,
|
||||||
|
G: 217,
|
||||||
|
B: 218,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
SeriesColors: grafanaSeriesColors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SetDefaultTheme(ThemeLight)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTextColor(theme string) drawing.Color {
|
// SetDefaultTheme sets default theme
|
||||||
if theme == ThemeDark {
|
func SetDefaultTheme(name string) {
|
||||||
return TextColorDark
|
defaultTheme = NewTheme(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddTheme(name string, opt ThemeOption) {
|
||||||
|
palettes[name] = &themeColorPalette{
|
||||||
|
isDarkMode: opt.IsDarkMode,
|
||||||
|
axisStrokeColor: opt.AxisStrokeColor,
|
||||||
|
axisSplitLineColor: opt.AxisSplitLineColor,
|
||||||
|
backgroundColor: opt.BackgroundColor,
|
||||||
|
textColor: opt.TextColor,
|
||||||
|
seriesColors: opt.SeriesColors,
|
||||||
}
|
}
|
||||||
return chart.DefaultTextColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThemeColorPalette struct {
|
func NewTheme(name string) ColorPalette {
|
||||||
Theme string
|
p, ok := palettes[name]
|
||||||
}
|
if !ok {
|
||||||
|
p = palettes[ThemeLight]
|
||||||
type PieThemeColorPalette struct {
|
|
||||||
ThemeColorPalette
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tp PieThemeColorPalette) TextColor() drawing.Color {
|
|
||||||
return getTextColor("")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tp ThemeColorPalette) BackgroundColor() drawing.Color {
|
|
||||||
return getBackgroundColor(tp.Theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tp ThemeColorPalette) BackgroundStrokeColor() drawing.Color {
|
|
||||||
return chart.DefaultBackgroundStrokeColor
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tp ThemeColorPalette) CanvasColor() drawing.Color {
|
|
||||||
if tp.Theme == ThemeDark {
|
|
||||||
return BackgroundColorDark
|
|
||||||
}
|
}
|
||||||
return chart.DefaultCanvasColor
|
clone := *p
|
||||||
|
return &clone
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tp ThemeColorPalette) CanvasStrokeColor() drawing.Color {
|
func (t *themeColorPalette) IsDark() bool {
|
||||||
return chart.DefaultCanvasStrokeColor
|
return t.isDarkMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tp ThemeColorPalette) AxisStrokeColor() drawing.Color {
|
func (t *themeColorPalette) GetAxisStrokeColor() Color {
|
||||||
if tp.Theme == ThemeDark {
|
return t.axisStrokeColor
|
||||||
return BackgroundColorDark
|
|
||||||
}
|
|
||||||
return chart.DefaultAxisColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tp ThemeColorPalette) TextColor() drawing.Color {
|
func (t *themeColorPalette) SetAxisStrokeColor(c Color) {
|
||||||
return getTextColor(tp.Theme)
|
t.axisStrokeColor = c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tp ThemeColorPalette) GetSeriesColor(index int) drawing.Color {
|
func (t *themeColorPalette) GetAxisSplitLineColor() Color {
|
||||||
return getSeriesColor(tp.Theme, index)
|
return t.axisSplitLineColor
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSeriesColor(theme string, index int) drawing.Color {
|
func (t *themeColorPalette) SetAxisSplitLineColor(c Color) {
|
||||||
return SeriesColorsLight[index%len(SeriesColorsLight)]
|
t.axisSplitLineColor = c
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseColor(color string) drawing.Color {
|
func (t *themeColorPalette) GetSeriesColor(index int) Color {
|
||||||
c := drawing.Color{}
|
colors := t.seriesColors
|
||||||
if color == "" {
|
return colors[index%len(colors)]
|
||||||
return c
|
}
|
||||||
}
|
func (t *themeColorPalette) SetSeriesColor(colors []Color) {
|
||||||
if strings.HasPrefix(color, "#") {
|
t.seriesColors = colors
|
||||||
return drawing.ColorFromHex(color[1:])
|
}
|
||||||
}
|
|
||||||
reg := regexp.MustCompile(`\((\S+)\)`)
|
func (t *themeColorPalette) GetBackgroundColor() Color {
|
||||||
result := reg.FindAllStringSubmatch(color, 1)
|
return t.backgroundColor
|
||||||
if len(result) == 0 || len(result[0]) != 2 {
|
}
|
||||||
return c
|
|
||||||
}
|
func (t *themeColorPalette) SetBackgroundColor(c Color) {
|
||||||
arr := strings.Split(result[0][1], ",")
|
t.backgroundColor = c
|
||||||
if len(arr) < 3 {
|
}
|
||||||
return c
|
|
||||||
}
|
func (t *themeColorPalette) GetTextColor() Color {
|
||||||
// 设置默认为255
|
return t.textColor
|
||||||
c.A = 255
|
}
|
||||||
for index, v := range arr {
|
|
||||||
value, _ := strconv.Atoi(strings.TrimSpace(v))
|
func (t *themeColorPalette) SetTextColor(c Color) {
|
||||||
ui8 := uint8(value)
|
t.textColor = c
|
||||||
switch index {
|
}
|
||||||
case 0:
|
|
||||||
c.R = ui8
|
func (t *themeColorPalette) GetFontSize() float64 {
|
||||||
case 1:
|
if t.fontSize != 0 {
|
||||||
c.G = ui8
|
return t.fontSize
|
||||||
case 2:
|
}
|
||||||
c.B = ui8
|
return defaultFontSize
|
||||||
default:
|
}
|
||||||
c.A = ui8
|
|
||||||
}
|
func (t *themeColorPalette) SetFontSize(fontSize float64) {
|
||||||
}
|
t.fontSize = fontSize
|
||||||
return c
|
}
|
||||||
|
|
||||||
|
func (t *themeColorPalette) GetFont() *truetype.Font {
|
||||||
|
if t.font != nil {
|
||||||
|
return t.font
|
||||||
|
}
|
||||||
|
f, _ := GetDefaultFont()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *themeColorPalette) SetFont(f *truetype.Font) {
|
||||||
|
t.font = f
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
theme_test.go
122
theme_test.go
|
|
@ -1,122 +0,0 @@
|
||||||
// MIT License
|
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
|
||||||
// in the Software without restriction, including without limitation the rights
|
|
||||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
// copies of the Software, and to permit persons to whom the Software is
|
|
||||||
// furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
// The above copyright notice and this permission notice shall be included in all
|
|
||||||
// copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
// SOFTWARE.
|
|
||||||
|
|
||||||
package charts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestThemeColors(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
assert.Equal(AxisColorDark, getAxisColor(ThemeDark))
|
|
||||||
assert.Equal(AxisColorLight, getAxisColor(""))
|
|
||||||
|
|
||||||
assert.Equal(GridColorDark, getGridColor(ThemeDark))
|
|
||||||
assert.Equal(GridColorLight, getGridColor(""))
|
|
||||||
|
|
||||||
assert.Equal(BackgroundColorDark, getBackgroundColor(ThemeDark))
|
|
||||||
assert.Equal(chart.DefaultBackgroundColor, getBackgroundColor(""))
|
|
||||||
|
|
||||||
assert.Equal(TextColorDark, getTextColor(ThemeDark))
|
|
||||||
assert.Equal(chart.DefaultTextColor, getTextColor(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestThemeColorPalette(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
dark := ThemeColorPalette{
|
|
||||||
Theme: ThemeDark,
|
|
||||||
}
|
|
||||||
assert.Equal(BackgroundColorDark, dark.BackgroundColor())
|
|
||||||
assert.Equal(chart.DefaultBackgroundStrokeColor, dark.BackgroundStrokeColor())
|
|
||||||
assert.Equal(BackgroundColorDark, dark.CanvasColor())
|
|
||||||
assert.Equal(chart.DefaultCanvasStrokeColor, dark.CanvasStrokeColor())
|
|
||||||
assert.Equal(BackgroundColorDark, dark.AxisStrokeColor())
|
|
||||||
assert.Equal(TextColorDark, dark.TextColor())
|
|
||||||
// series 使用统一的color
|
|
||||||
assert.Equal(SeriesColorsLight[0], dark.GetSeriesColor(0))
|
|
||||||
|
|
||||||
light := ThemeColorPalette{}
|
|
||||||
assert.Equal(chart.DefaultBackgroundColor, light.BackgroundColor())
|
|
||||||
assert.Equal(chart.DefaultBackgroundStrokeColor, light.BackgroundStrokeColor())
|
|
||||||
assert.Equal(chart.DefaultCanvasColor, light.CanvasColor())
|
|
||||||
assert.Equal(chart.DefaultCanvasStrokeColor, light.CanvasStrokeColor())
|
|
||||||
assert.Equal(chart.DefaultAxisColor, light.AxisStrokeColor())
|
|
||||||
assert.Equal(chart.DefaultTextColor, light.TextColor())
|
|
||||||
// series 使用统一的color
|
|
||||||
assert.Equal(SeriesColorsLight[0], light.GetSeriesColor(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPieThemeColorPalette(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
p := PieThemeColorPalette{}
|
|
||||||
|
|
||||||
// pie无认哪种theme,文本的颜色都一样
|
|
||||||
assert.Equal(chart.DefaultTextColor, p.TextColor())
|
|
||||||
p.Theme = ThemeDark
|
|
||||||
assert.Equal(chart.DefaultTextColor, p.TextColor())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseColor(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
|
||||||
|
|
||||||
c := parseColor("")
|
|
||||||
assert.True(c.IsZero())
|
|
||||||
|
|
||||||
c = parseColor("#333")
|
|
||||||
assert.Equal(drawing.Color{
|
|
||||||
R: 51,
|
|
||||||
G: 51,
|
|
||||||
B: 51,
|
|
||||||
A: 255,
|
|
||||||
}, c)
|
|
||||||
|
|
||||||
c = parseColor("#313233")
|
|
||||||
assert.Equal(drawing.Color{
|
|
||||||
R: 49,
|
|
||||||
G: 50,
|
|
||||||
B: 51,
|
|
||||||
A: 255,
|
|
||||||
}, c)
|
|
||||||
|
|
||||||
c = parseColor("rgb(31,32,33)")
|
|
||||||
assert.Equal(drawing.Color{
|
|
||||||
R: 31,
|
|
||||||
G: 32,
|
|
||||||
B: 33,
|
|
||||||
A: 255,
|
|
||||||
}, c)
|
|
||||||
|
|
||||||
c = parseColor("rgba(50,51,52,250)")
|
|
||||||
assert.Equal(drawing.Color{
|
|
||||||
R: 50,
|
|
||||||
G: 51,
|
|
||||||
B: 52,
|
|
||||||
A: 250,
|
|
||||||
}, c)
|
|
||||||
}
|
|
||||||
220
title.go
220
title.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -26,78 +26,172 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/golang/freetype/truetype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type TitleOption struct {
|
||||||
|
// The theme of chart
|
||||||
|
Theme ColorPalette
|
||||||
|
// Title text, support \n for new line
|
||||||
|
Text string
|
||||||
|
// Subtitle text, support \n for new line
|
||||||
|
Subtext string
|
||||||
|
// 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 {
|
type titleMeasureOption struct {
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
text string
|
text string
|
||||||
|
style Style
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTitleCustomize(title Title) chart.Renderable {
|
func splitTitleText(text string) []string {
|
||||||
return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) {
|
arr := strings.Split(text, "\n")
|
||||||
if len(title.Text) == 0 || title.Style.Hidden {
|
result := make([]string, 0)
|
||||||
return
|
for _, v := range arr {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
font := title.Font
|
result = append(result, v)
|
||||||
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)
|
|
||||||
|
|
||||||
arr := strings.Split(title.Text, "\n")
|
type titlePainter struct {
|
||||||
textWidth := 0
|
p *Painter
|
||||||
textHeight := 0
|
opt *TitleOption
|
||||||
measureOptions := make([]titleMeasureOption, len(arr))
|
}
|
||||||
for index, str := range arr {
|
|
||||||
textBox := r.MeasureText(str)
|
|
||||||
|
|
||||||
w := textBox.Width()
|
// NewTitlePainter returns a title renderer
|
||||||
h := textBox.Height()
|
func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter {
|
||||||
if w > textWidth {
|
return &titlePainter{
|
||||||
textWidth = w
|
p: p,
|
||||||
}
|
opt: &opt,
|
||||||
if h > textHeight {
|
|
||||||
textHeight = h
|
|
||||||
}
|
|
||||||
measureOptions[index] = titleMeasureOption{
|
|
||||||
text: str,
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
titleX := 0
|
|
||||||
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(title.Left, "%") {
|
|
||||||
value, _ := strconv.Atoi(strings.ReplaceAll(title.Left, "%", ""))
|
|
||||||
titleX = cb.Left + cb.Width()*value/100
|
|
||||||
} else {
|
|
||||||
value, _ := strconv.Atoi(title.Left)
|
|
||||||
titleX = cb.Left + value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *titlePainter) Render() (Box, error) {
|
||||||
|
opt := t.opt
|
||||||
|
p := t.p
|
||||||
|
theme := opt.Theme
|
||||||
|
|
||||||
|
if theme == nil {
|
||||||
|
theme = p.theme
|
||||||
|
}
|
||||||
|
if opt.Text == "" && opt.Subtext == "" {
|
||||||
|
return BoxZero, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
measureOptions := make([]titleMeasureOption, 0)
|
||||||
|
|
||||||
|
if opt.Font == nil {
|
||||||
|
opt.Font = theme.GetFont()
|
||||||
|
}
|
||||||
|
if opt.FontColor.IsZero() {
|
||||||
|
opt.FontColor = theme.GetTextColor()
|
||||||
|
}
|
||||||
|
if opt.FontSize == 0 {
|
||||||
|
opt.FontSize = theme.GetFontSize()
|
||||||
|
}
|
||||||
|
if opt.SubtextFontColor.IsZero() {
|
||||||
|
opt.SubtextFontColor = opt.FontColor
|
||||||
|
}
|
||||||
|
if opt.SubtextFontSize == 0 {
|
||||||
|
opt.SubtextFontSize = opt.FontSize
|
||||||
|
}
|
||||||
|
|
||||||
|
titleTextStyle := Style{
|
||||||
|
Font: opt.Font,
|
||||||
|
FontSize: opt.FontSize,
|
||||||
|
FontColor: opt.FontColor,
|
||||||
|
}
|
||||||
|
// 主标题
|
||||||
|
for _, v := range splitTitleText(opt.Text) {
|
||||||
|
measureOptions = append(measureOptions, titleMeasureOption{
|
||||||
|
text: v,
|
||||||
|
style: titleTextStyle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
subtextStyle := Style{
|
||||||
|
Font: opt.Font,
|
||||||
|
FontSize: opt.SubtextFontSize,
|
||||||
|
FontColor: opt.SubtextFontColor,
|
||||||
|
}
|
||||||
|
// 副标题
|
||||||
|
for _, v := range splitTitleText(opt.Subtext) {
|
||||||
|
measureOptions = append(measureOptions, titleMeasureOption{
|
||||||
|
text: v,
|
||||||
|
style: subtextStyle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
textMaxWidth := 0
|
||||||
|
textMaxHeight := 0
|
||||||
|
for index, item := range measureOptions {
|
||||||
|
p.OverrideTextStyle(item.style)
|
||||||
|
textBox := p.MeasureText(item.text)
|
||||||
|
|
||||||
|
w := textBox.Width()
|
||||||
|
h := textBox.Height()
|
||||||
|
if w > textMaxWidth {
|
||||||
|
textMaxWidth = w
|
||||||
|
}
|
||||||
|
if h > textMaxHeight {
|
||||||
|
textMaxHeight = h
|
||||||
|
}
|
||||||
|
measureOptions[index].height = h
|
||||||
|
measureOptions[index].width = w
|
||||||
|
}
|
||||||
|
width := textMaxWidth
|
||||||
|
|
||||||
|
titleX := 0
|
||||||
|
switch opt.Left {
|
||||||
|
case PositionRight:
|
||||||
|
titleX = p.Width() - textMaxWidth
|
||||||
|
case PositionCenter:
|
||||||
|
titleX = p.Width()>>1 - (textMaxWidth >> 1)
|
||||||
|
default:
|
||||||
|
if strings.HasSuffix(opt.Left, "%") {
|
||||||
|
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
|
||||||
|
titleX = p.Width() * value / 100
|
||||||
|
} else {
|
||||||
|
value, _ := strconv.Atoi(opt.Left)
|
||||||
|
titleX = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
titleY := 0
|
||||||
|
// TODO TOP 暂只支持数值
|
||||||
|
if opt.Top != "" {
|
||||||
|
value, _ := strconv.Atoi(opt.Top)
|
||||||
|
titleY += value
|
||||||
|
}
|
||||||
|
for _, item := range measureOptions {
|
||||||
|
p.OverrideTextStyle(item.style)
|
||||||
|
x := titleX + (textMaxWidth-item.width)>>1
|
||||||
|
y := titleY + item.height
|
||||||
|
p.Text(item.text, x, y)
|
||||||
|
titleY += item.height
|
||||||
|
}
|
||||||
|
|
||||||
|
return Box{
|
||||||
|
Bottom: titleY,
|
||||||
|
Right: titleX + width,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
// Copyright (c) 2021 Tree Xie
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,63 +23,71 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTitleCustomize(t *testing.T) {
|
func TestTitleRenderer(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
title Title
|
render func(*Painter) ([]byte, error)
|
||||||
svg string
|
result string
|
||||||
}{
|
}{
|
||||||
// 单行标题
|
|
||||||
{
|
{
|
||||||
title: Title{
|
render: func(p *Painter) ([]byte, error) {
|
||||||
Text: "Hello World!",
|
_, err := NewTitlePainter(p, TitleOption{
|
||||||
Style: chart.Style{
|
Text: "title",
|
||||||
FontColor: drawing.ColorBlack,
|
Subtext: "subTitle",
|
||||||
},
|
Left: "20",
|
||||||
|
Top: "20",
|
||||||
|
}).Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Bytes()
|
||||||
},
|
},
|
||||||
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>",
|
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>",
|
||||||
},
|
},
|
||||||
// 多行标题,靠右
|
|
||||||
{
|
{
|
||||||
title: Title{
|
render: func(p *Painter) ([]byte, error) {
|
||||||
Text: "Hello World!\nHello World",
|
_, err := NewTitlePainter(p, TitleOption{
|
||||||
Style: chart.Style{
|
Text: "title",
|
||||||
FontColor: drawing.ColorBlack,
|
Subtext: "subTitle",
|
||||||
},
|
Left: "20%",
|
||||||
Left: "right",
|
Top: "20",
|
||||||
|
}).Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Bytes()
|
||||||
},
|
},
|
||||||
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>",
|
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>",
|
||||||
},
|
},
|
||||||
// 标题居中
|
|
||||||
{
|
{
|
||||||
title: Title{
|
render: func(p *Painter) ([]byte, error) {
|
||||||
Text: "Hello World!",
|
_, err := NewTitlePainter(p, TitleOption{
|
||||||
Style: chart.Style{
|
Text: "title",
|
||||||
FontColor: drawing.ColorBlack,
|
Subtext: "subTitle",
|
||||||
},
|
Left: PositionRight,
|
||||||
Left: "center",
|
}).Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Bytes()
|
||||||
},
|
},
|
||||||
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>",
|
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>",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
r, err := chart.SVG(800, 600)
|
p, err := NewPainter(PainterOptions{
|
||||||
|
Type: ChartOutputSVG,
|
||||||
|
Width: 600,
|
||||||
|
Height: 400,
|
||||||
|
}, PainterThemeOption(defaultTheme))
|
||||||
assert.Nil(err)
|
assert.Nil(err)
|
||||||
fn := NewTitleCustomize(tt.title)
|
data, err := tt.render(p)
|
||||||
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.Nil(err)
|
||||||
assert.Equal(tt.svg, buf.String())
|
assert.Equal(tt.result, string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
271
util.go
Normal file
271
util.go
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TrueFlag() *bool {
|
||||||
|
t := true
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func FalseFlag() *bool {
|
||||||
|
f := false
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsInt(values []int, value int) bool {
|
||||||
|
for _, v := range values {
|
||||||
|
if v == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, value string) bool {
|
||||||
|
for _, v := range values {
|
||||||
|
if v == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ceilFloatToInt(value float64) int {
|
||||||
|
i := int(value)
|
||||||
|
if value == float64(i) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDefaultInt(value, defaultValue int) int {
|
||||||
|
if value == 0 {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoDivide(max, size int) []int {
|
||||||
|
unit := float64(max) / float64(size)
|
||||||
|
|
||||||
|
values := make([]int, size+1)
|
||||||
|
for i := 0; i < size+1; i++ {
|
||||||
|
if i == size {
|
||||||
|
values[i] = max
|
||||||
|
} else {
|
||||||
|
values[i] = int(float64(i) * unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoDivideSpans(max, size int, spans []int) []int {
|
||||||
|
values := autoDivide(max, size)
|
||||||
|
// 重新合并
|
||||||
|
if len(spans) != 0 {
|
||||||
|
newValues := make([]int, len(spans)+1)
|
||||||
|
newValues[0] = 0
|
||||||
|
end := 0
|
||||||
|
for index, v := range spans {
|
||||||
|
end += v
|
||||||
|
newValues[index+1] = values[end]
|
||||||
|
}
|
||||||
|
values = newValues
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func sumInt(values []int) int {
|
||||||
|
sum := 0
|
||||||
|
for _, v := range values {
|
||||||
|
sum += v
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureTextMaxWidthHeight returns maxWidth and maxHeight of text list
|
||||||
|
func measureTextMaxWidthHeight(textList []string, p *Painter) (int, int) {
|
||||||
|
maxWidth := 0
|
||||||
|
maxHeight := 0
|
||||||
|
for _, text := range textList {
|
||||||
|
box := p.MeasureText(text)
|
||||||
|
maxWidth = chart.MaxInt(maxWidth, box.Width())
|
||||||
|
maxHeight = chart.MaxInt(maxHeight, box.Height())
|
||||||
|
}
|
||||||
|
return maxWidth, maxHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseStringSlice(stringList []string) {
|
||||||
|
for i, j := 0, len(stringList)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
stringList[i], stringList[j] = stringList[j], stringList[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseIntSlice(intList []int) {
|
||||||
|
for i, j := 0, len(intList)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
intList[i], intList[j] = intList[j], intList[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertPercent(value string) float64 {
|
||||||
|
if !strings.HasSuffix(value, "%") {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(strings.ReplaceAll(value, "%", ""))
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return float64(v) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFalse(flag *bool) bool {
|
||||||
|
if flag != nil && !*flag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFloatPoint(f float64) *float64 {
|
||||||
|
v := f
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
const K_VALUE = float64(1000)
|
||||||
|
const M_VALUE = K_VALUE * K_VALUE
|
||||||
|
const G_VALUE = M_VALUE * K_VALUE
|
||||||
|
const T_VALUE = G_VALUE * K_VALUE
|
||||||
|
|
||||||
|
func commafWithDigits(value float64) string {
|
||||||
|
decimals := 2
|
||||||
|
if value >= T_VALUE {
|
||||||
|
return humanize.CommafWithDigits(value/T_VALUE, decimals) + "T"
|
||||||
|
}
|
||||||
|
if value >= G_VALUE {
|
||||||
|
return humanize.CommafWithDigits(value/G_VALUE, decimals) + "G"
|
||||||
|
}
|
||||||
|
if value >= M_VALUE {
|
||||||
|
return humanize.CommafWithDigits(value/M_VALUE, decimals) + "M"
|
||||||
|
}
|
||||||
|
if value >= K_VALUE {
|
||||||
|
return humanize.CommafWithDigits(value/K_VALUE, decimals) + "k"
|
||||||
|
}
|
||||||
|
return humanize.CommafWithDigits(value, decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseColor(color string) Color {
|
||||||
|
c := Color{}
|
||||||
|
if color == "" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(color, "#") {
|
||||||
|
return drawing.ColorFromHex(color[1:])
|
||||||
|
}
|
||||||
|
reg := regexp.MustCompile(`\((\S+)\)`)
|
||||||
|
result := reg.FindAllStringSubmatch(color, 1)
|
||||||
|
if len(result) == 0 || len(result[0]) != 2 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
arr := strings.Split(result[0][1], ",")
|
||||||
|
if len(arr) < 3 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
// 设置默认为255
|
||||||
|
c.A = 255
|
||||||
|
for index, v := range arr {
|
||||||
|
value, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
ui8 := uint8(value)
|
||||||
|
switch index {
|
||||||
|
case 0:
|
||||||
|
c.R = ui8
|
||||||
|
case 1:
|
||||||
|
c.G = ui8
|
||||||
|
case 2:
|
||||||
|
c.B = ui8
|
||||||
|
default:
|
||||||
|
c.A = ui8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRadiusPercent = 0.4
|
||||||
|
|
||||||
|
func getRadius(diameter float64, radiusValue string) float64 {
|
||||||
|
var radius float64
|
||||||
|
if len(radiusValue) != 0 {
|
||||||
|
v := convertPercent(radiusValue)
|
||||||
|
if v != -1 {
|
||||||
|
radius = float64(diameter) * v
|
||||||
|
} else {
|
||||||
|
radius, _ = strconv.ParseFloat(radiusValue, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if radius <= 0 {
|
||||||
|
radius = float64(diameter) * defaultRadiusPercent
|
||||||
|
}
|
||||||
|
return radius
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPolygonPointAngles(sides int) []float64 {
|
||||||
|
angles := make([]float64, sides)
|
||||||
|
for i := 0; i < sides; i++ {
|
||||||
|
angle := 2*math.Pi/float64(sides)*float64(i) - (math.Pi / 2)
|
||||||
|
angles[i] = angle
|
||||||
|
}
|
||||||
|
return angles
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPolygonPoint(center Point, radius, angle float64) Point {
|
||||||
|
x := center.X + int(radius*math.Cos(angle))
|
||||||
|
y := center.Y + int(radius*math.Sin(angle))
|
||||||
|
return Point{
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPolygonPoints(center Point, radius float64, sides int) []Point {
|
||||||
|
points := make([]Point, sides)
|
||||||
|
for i, angle := range getPolygonPointAngles(sides) {
|
||||||
|
points[i] = getPolygonPoint(center, radius, angle)
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLightColor(c Color) bool {
|
||||||
|
r := float64(c.R) * float64(c.R) * 0.299
|
||||||
|
g := float64(c.G) * float64(c.G) * 0.587
|
||||||
|
b := float64(c.B) * float64(c.B) * 0.114
|
||||||
|
return math.Sqrt(r+g+b) > 127.5
|
||||||
|
}
|
||||||
223
util_test.go
Normal file
223
util_test.go
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2"
|
||||||
|
"git.smarteching.com/zeni/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDefaultInt(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal(1, getDefaultInt(0, 1))
|
||||||
|
assert.Equal(10, getDefaultInt(10, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCeilFloatToInt(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal(1, ceilFloatToInt(0.8))
|
||||||
|
assert.Equal(1, ceilFloatToInt(1.0))
|
||||||
|
assert.Equal(2, ceilFloatToInt(1.2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommafWithDigits(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal("1.2", commafWithDigits(1.2))
|
||||||
|
assert.Equal("1.21", commafWithDigits(1.21231))
|
||||||
|
|
||||||
|
assert.Equal("1.20k", commafWithDigits(1200.121))
|
||||||
|
assert.Equal("1.20M", commafWithDigits(1200000.121))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoDivide(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal([]int{
|
||||||
|
0,
|
||||||
|
85,
|
||||||
|
171,
|
||||||
|
257,
|
||||||
|
342,
|
||||||
|
428,
|
||||||
|
514,
|
||||||
|
600,
|
||||||
|
}, autoDivide(600, 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRadius(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal(50.0, getRadius(100, "50%"))
|
||||||
|
assert.Equal(30.0, getRadius(100, "30"))
|
||||||
|
assert.Equal(40.0, getRadius(100, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeasureTextMaxWidthHeight(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
p, err := NewPainter(PainterOptions{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
})
|
||||||
|
assert.Nil(err)
|
||||||
|
style := chart.Style{
|
||||||
|
FontSize: 10,
|
||||||
|
}
|
||||||
|
p.SetStyle(style)
|
||||||
|
|
||||||
|
maxWidth, maxHeight := measureTextMaxWidthHeight([]string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
}, p)
|
||||||
|
assert.Equal(31, maxWidth)
|
||||||
|
assert.Equal(12, maxHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverseSlice(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
arr := []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
}
|
||||||
|
reverseStringSlice(arr)
|
||||||
|
assert.Equal([]string{
|
||||||
|
"Sun",
|
||||||
|
"Sat",
|
||||||
|
"Fri",
|
||||||
|
"Thu",
|
||||||
|
"Wed",
|
||||||
|
"Tue",
|
||||||
|
"Mon",
|
||||||
|
}, arr)
|
||||||
|
|
||||||
|
numbers := []int{
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
9,
|
||||||
|
}
|
||||||
|
reverseIntSlice(numbers)
|
||||||
|
assert.Equal([]int{
|
||||||
|
9,
|
||||||
|
7,
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
}, numbers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPercent(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal(-1.0, convertPercent("1"))
|
||||||
|
assert.Equal(-1.0, convertPercent("a%"))
|
||||||
|
assert.Equal(0.1, convertPercent("10%"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseColor(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
c := parseColor("")
|
||||||
|
assert.True(c.IsZero())
|
||||||
|
|
||||||
|
c = parseColor("#333")
|
||||||
|
assert.Equal(drawing.Color{
|
||||||
|
R: 51,
|
||||||
|
G: 51,
|
||||||
|
B: 51,
|
||||||
|
A: 255,
|
||||||
|
}, c)
|
||||||
|
|
||||||
|
c = parseColor("#313233")
|
||||||
|
assert.Equal(drawing.Color{
|
||||||
|
R: 49,
|
||||||
|
G: 50,
|
||||||
|
B: 51,
|
||||||
|
A: 255,
|
||||||
|
}, c)
|
||||||
|
|
||||||
|
c = parseColor("rgb(31,32,33)")
|
||||||
|
assert.Equal(drawing.Color{
|
||||||
|
R: 31,
|
||||||
|
G: 32,
|
||||||
|
B: 33,
|
||||||
|
A: 255,
|
||||||
|
}, c)
|
||||||
|
|
||||||
|
c = parseColor("rgba(50,51,52,250)")
|
||||||
|
assert.Equal(drawing.Color{
|
||||||
|
R: 50,
|
||||||
|
G: 51,
|
||||||
|
B: 52,
|
||||||
|
A: 250,
|
||||||
|
}, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsLightColor(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.True(isLightColor(drawing.Color{
|
||||||
|
R: 255,
|
||||||
|
G: 255,
|
||||||
|
B: 255,
|
||||||
|
}))
|
||||||
|
assert.True(isLightColor(drawing.Color{
|
||||||
|
R: 145,
|
||||||
|
G: 204,
|
||||||
|
B: 117,
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.False(isLightColor(drawing.Color{
|
||||||
|
R: 88,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
}))
|
||||||
|
|
||||||
|
assert.False(isLightColor(drawing.Color{
|
||||||
|
R: 0,
|
||||||
|
G: 0,
|
||||||
|
B: 0,
|
||||||
|
}))
|
||||||
|
assert.False(isLightColor(drawing.Color{
|
||||||
|
R: 16,
|
||||||
|
G: 12,
|
||||||
|
B: 42,
|
||||||
|
}))
|
||||||
|
}
|
||||||
105
xaxis.go
Normal file
105
xaxis.go
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type XAxisOption struct {
|
||||||
|
// The font of x axis
|
||||||
|
Font *truetype.Font
|
||||||
|
// The boundary gap on both sides of a coordinate axis.
|
||||||
|
// Nil or *true means the center part of two axis ticks
|
||||||
|
BoundaryGap *bool
|
||||||
|
// The data value of x axis
|
||||||
|
Data []string
|
||||||
|
// The theme of chart
|
||||||
|
Theme ColorPalette
|
||||||
|
// The font size of x axis label
|
||||||
|
FontSize float64
|
||||||
|
// The flag for show axis, set this to *false will hide axis
|
||||||
|
Show *bool
|
||||||
|
// Number of segments that the axis is split into. Note that this number serves only as a recommendation.
|
||||||
|
SplitNumber int
|
||||||
|
// The position of axis, it can be 'top' or 'bottom'
|
||||||
|
Position string
|
||||||
|
// The line color of axis
|
||||||
|
StrokeColor Color
|
||||||
|
// The color of label
|
||||||
|
FontColor Color
|
||||||
|
// The text rotation of label
|
||||||
|
TextRotation float64
|
||||||
|
// The first axis
|
||||||
|
FirstAxis int
|
||||||
|
// The offset of label
|
||||||
|
LabelOffset Box
|
||||||
|
isValueAxis bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultXAxisHeight = 30
|
||||||
|
|
||||||
|
// NewXAxisOption returns a x axis option
|
||||||
|
func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption {
|
||||||
|
opt := XAxisOption{
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
if len(boundaryGap) != 0 {
|
||||||
|
opt.BoundaryGap = boundaryGap[0]
|
||||||
|
}
|
||||||
|
return opt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt *XAxisOption) ToAxisOption() AxisOption {
|
||||||
|
position := PositionBottom
|
||||||
|
if opt.Position == PositionTop {
|
||||||
|
position = PositionTop
|
||||||
|
}
|
||||||
|
axisOpt := AxisOption{
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Data: opt.Data,
|
||||||
|
BoundaryGap: opt.BoundaryGap,
|
||||||
|
Position: position,
|
||||||
|
SplitNumber: opt.SplitNumber,
|
||||||
|
StrokeColor: opt.StrokeColor,
|
||||||
|
FontSize: opt.FontSize,
|
||||||
|
Font: opt.Font,
|
||||||
|
FontColor: opt.FontColor,
|
||||||
|
Show: opt.Show,
|
||||||
|
SplitLineColor: opt.Theme.GetAxisSplitLineColor(),
|
||||||
|
TextRotation: opt.TextRotation,
|
||||||
|
LabelOffset: opt.LabelOffset,
|
||||||
|
FirstAxis: opt.FirstAxis,
|
||||||
|
}
|
||||||
|
if opt.isValueAxis {
|
||||||
|
axisOpt.SplitLineShow = true
|
||||||
|
axisOpt.StrokeWidth = -1
|
||||||
|
axisOpt.BoundaryGap = FalseFlag()
|
||||||
|
}
|
||||||
|
return axisOpt
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBottomXAxis returns a bottom x axis renderer
|
||||||
|
func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter {
|
||||||
|
return NewAxisPainter(p, opt.ToAxisOption())
|
||||||
|
}
|
||||||
128
yaxis.go
Normal file
128
yaxis.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import "github.com/golang/freetype/truetype"
|
||||||
|
|
||||||
|
type YAxisOption struct {
|
||||||
|
// The minimun value of axis.
|
||||||
|
Min *float64
|
||||||
|
// The maximum value of axis.
|
||||||
|
Max *float64
|
||||||
|
// The font of y axis
|
||||||
|
Font *truetype.Font
|
||||||
|
// The data value of x axis
|
||||||
|
Data []string
|
||||||
|
// The theme of chart
|
||||||
|
Theme ColorPalette
|
||||||
|
// The font size of x axis label
|
||||||
|
FontSize float64
|
||||||
|
// The position of axis, it can be 'left' or 'right'
|
||||||
|
Position string
|
||||||
|
// The color of label
|
||||||
|
FontColor Color
|
||||||
|
// Formatter for y axis text value
|
||||||
|
Formatter string
|
||||||
|
// Color for y axis
|
||||||
|
Color Color
|
||||||
|
// The flag for show axis, set this to *false will hide axis
|
||||||
|
Show *bool
|
||||||
|
DivideCount int
|
||||||
|
Unit int
|
||||||
|
isCategoryAxis bool
|
||||||
|
// The flag for show axis split line, set this to true will show axis split line
|
||||||
|
SplitLineShow *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewYAxisOptions returns a y axis option
|
||||||
|
func NewYAxisOptions(data []string, others ...[]string) []YAxisOption {
|
||||||
|
arr := [][]string{
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
arr = append(arr, others...)
|
||||||
|
opts := make([]YAxisOption, 0)
|
||||||
|
for _, data := range arr {
|
||||||
|
opts = append(opts, YAxisOption{
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opt *YAxisOption) ToAxisOption(p *Painter) AxisOption {
|
||||||
|
position := PositionLeft
|
||||||
|
if opt.Position == PositionRight {
|
||||||
|
position = PositionRight
|
||||||
|
}
|
||||||
|
theme := opt.Theme
|
||||||
|
if theme == nil {
|
||||||
|
theme = p.theme
|
||||||
|
}
|
||||||
|
axisOpt := AxisOption{
|
||||||
|
Formatter: opt.Formatter,
|
||||||
|
Theme: theme,
|
||||||
|
Data: opt.Data,
|
||||||
|
Position: position,
|
||||||
|
FontSize: opt.FontSize,
|
||||||
|
StrokeWidth: -1,
|
||||||
|
Font: opt.Font,
|
||||||
|
FontColor: opt.FontColor,
|
||||||
|
BoundaryGap: FalseFlag(),
|
||||||
|
SplitLineShow: true,
|
||||||
|
SplitLineColor: theme.GetAxisSplitLineColor(),
|
||||||
|
Show: opt.Show,
|
||||||
|
Unit: opt.Unit,
|
||||||
|
}
|
||||||
|
if !opt.Color.IsZero() {
|
||||||
|
axisOpt.FontColor = opt.Color
|
||||||
|
axisOpt.StrokeColor = opt.Color
|
||||||
|
}
|
||||||
|
if opt.isCategoryAxis {
|
||||||
|
axisOpt.BoundaryGap = TrueFlag()
|
||||||
|
axisOpt.StrokeWidth = 1
|
||||||
|
axisOpt.SplitLineShow = false
|
||||||
|
}
|
||||||
|
if opt.SplitLineShow != nil {
|
||||||
|
axisOpt.SplitLineShow = *opt.SplitLineShow
|
||||||
|
}
|
||||||
|
return axisOpt
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLeftYAxis returns a left y axis renderer
|
||||||
|
func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter {
|
||||||
|
p = p.Child(PainterPaddingOption(Box{
|
||||||
|
Bottom: defaultXAxisHeight,
|
||||||
|
}))
|
||||||
|
return NewAxisPainter(p, opt.ToAxisOption(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRightYAxis returns a right y axis renderer
|
||||||
|
func NewRightYAxis(p *Painter, opt YAxisOption) *axisPainter {
|
||||||
|
p = p.Child(PainterPaddingOption(Box{
|
||||||
|
Bottom: defaultXAxisHeight,
|
||||||
|
}))
|
||||||
|
axisOpt := opt.ToAxisOption(p)
|
||||||
|
axisOpt.Position = PositionRight
|
||||||
|
axisOpt.SplitLineShow = false
|
||||||
|
return NewAxisPainter(p, axisOpt)
|
||||||
|
}
|
||||||
70
yaxis_test.go
Normal file
70
yaxis_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRightYAxis(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
tests := []struct {
|
||||||
|
render func(*Painter) ([]byte, error)
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
render: func(p *Painter) ([]byte, error) {
|
||||||
|
opt := NewYAxisOptions([]string{
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
"d",
|
||||||
|
})[0]
|
||||||
|
_, err := NewRightYAxis(p, opt).Render()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Bytes()
|
||||||
|
},
|
||||||
|
result: "<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue