Compare commits

..

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

77 changed files with 16049 additions and 1326 deletions

View file

@ -1,36 +0,0 @@
name: build on tag
on:
push:
branches: [ web ]
pull_request:
branches: [ web ]
jobs:
docker:
runs-on: ubuntu-latest
name: Build
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Set output
id: vars
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
platforms: linux/amd64, linux/arm64
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/go-charts
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

44
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
matrix:
go:
- '1.22'
- '1.21'
- '1.20'
- '1.19'
- '1.18'
- '1.17'
steps:
- name: Go ${{ matrix.go }} test
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run:
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin latest
- name: Lint
run: make lint
- name: Test
run: make test
- name: Bench
run: make bench

5
.gitignore vendored
View file

@ -14,4 +14,7 @@
# Dependency directories (remove the comment below to include it)
# vendor/
*.png
*.svg
*.svg
tmp
NotoSansSC.ttf
.vscode

View file

@ -1,23 +0,0 @@
FROM golang:1.19-alpine as builder
ADD ./ /go-charts
RUN apk update \
&& apk add docker git gcc make \
&& cd /go-charts \
&& make build
FROM alpine
EXPOSE 7001
COPY --from=builder /go-charts/go-charts /usr/local/bin/go-charts
COPY --from=builder /go-charts/entrypoint.sh /entrypoint.sh
CMD ["go-charts"]
ENTRYPOINT ["/entrypoint.sh"]
HEALTHCHECK --timeout=10s --interval=10s CMD [ "wget", "http://127.0.0.1:7001/ping", "-q", "-O", "-"]

View file

@ -1,9 +1,20 @@
export GO111MODULE = on
.PHONY: default test test-cover dev build
.PHONY: default test test-cover dev hooks
build:
go build -tags netgo -o go-charts
release:
go mod tidy
# for test
test:
go test -race -cover ./...
test-cover:
go test -race -coverprofile=test.out ./... && go tool cover --html=test.out
bench:
go test --benchmem -bench=. ./...
lint:
golangci-lint run
hooks:
cp hooks/* .git/hooks/

542
README.md Normal file
View file

@ -0,0 +1,542 @@
# go-charts
Clone from https://github.com/vicanso/go-charts
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/vicanso/go-charts/blob/master/LICENSE)
[![Build Status](https://github.com/vicanso/go-charts/workflows/Test/badge.svg)](https://github.com/vicanso/go-charts/actions)
[中文](./README_zh.md)
`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart)it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. The default format is `png` and the default theme is `light`.
`Apache ECharts` is popular among Front-end developers, so `go-charts` supports the option of `Apache ECharts`. Developers can generate charts almost the same as `Apache ECharts`.
Screenshot of common charts, the left part is light theme, the right part is grafana theme.
<p align="center">
<img src="./assets/go-charts.png" alt="go-charts">
</p>
<p align="center">
<img src="./assets/go-table.png" alt="go-table">
</p>
## Chart Type
These chart types are supported: `line`, `bar`, `horizontal bar`, `pie`, `radar` or `funnel` and `table`.
## Example
More examples can be found in the [./examples/](./examples/) directory.
### Line 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...
}
```
## ECharts Option
The name with `[]` is new parameter, others are the same as `echarts`.
- `[type]` The canvas type, support `svg` and `png`, default is `svg`
- `[theme]` The theme, support `dark`, `light` and `grafana`, default is `light`
- `[fontFamily]` The font family for chart
- `[padding]` The padding of chart
- `[box]` The canvas box of chart
- `[width]` The width of chart
- `[height]` The height of chart
- `title` Title component, including main title and subtitle
- `title.text` The main title text, supporting for \n for newlines
- `title.subtext`Subtitle text, supporting for \n for newlines
- `title.left` Distance between title component and the left side of the container. Left value can be instant pixel value like 20; it can also be a percentage value relative to container width like '20%'; and it can also be 'left', 'center', or 'right'.
- `title.top` Distance between title component and the top side of the container. Top value can be instant pixel value like 20
- `title.textStyle.color` Text color for title
- `title.textStyle.fontSize` Text font size for title
- `title.textStyle.fontFamily` Text font family for title, it will change the font family for chart
- `xAxis` The x axis in cartesian(rectangular) coordinate. `go-charts` only support one x axis.
- `xAxis.boundaryGap` The boundary gap on both sides of a coordinate axis. The setting and behavior of category axes and non-category axes are different. If set `null` or `true`, the label appear in the center part of two axis ticks.
- `xAxis.splitNumber` Number of segments that the axis is split into. Note that this number serves only as a recommendation, and the true segments may be adjusted based on readability
- `xAxis.data` Category data, only support string array.
- `yAxis` The y axis in cartesian(rectangular) coordinate, it support 2 y axis
- `yAxis.min` The minimum value of axis. It will be automatically computed to make sure axis tick is equally distributed when not set
- `yAxis.max` The maximum value of axis. It will be automatically computed to make sure axis tick is equally distributed when not se.
- `yAxis.axisLabel.formatter` Formatter of axis label, which supports string template: `"formatter": "{value} kg"`
- `yAxis.axisLine.lineStyle.color` The color for line
- `legend` Legend component
- `legend.show` Whether to show legend
- `legend.data` Data array of legend, only support string array: ["Email", "Video Ads"]
- `legend.align` Legend marker and text aligning. Support `left` and `right`, default is `left`
- `legend.padding` legend space around content
- `legend.left` Distance between legend component and the left side of the container. Left value can be instant pixel value like 20; it can also be a percentage value relative to container width like '20%'; and it can also be 'left', 'center', or 'right'.
- `legend.top` Distance between legend component and the top side of the container. Top value can be instant pixel value like 20
- `radar` Coordinate for radar charts
- `radar.indicator` Indicator of radar chart, which is used to assign multiple variables(dimensions) in radar chart
- `radar.indicator.name` Indicator's name
- `radar.indicator.max` The maximum value of indicator
- `radar.indicator.min` The minimum value of indicator, default value is 0.
- `series` The series for chart
- `series.name` Series name used for displaying in legend.
- `series.type` Series type: `line`, `bar`, `pie`, `radar` or `funnel`
- `series.radius` Radius of Pie chart:`50%`, default is `40%`
- `series.yAxisIndex` Index of y axis to combine with, which is useful for multiple y axes in one chart
- `series.label.show` Whether to show label
- `series.label.distance` Distance to the host graphic element
- `series.label.color` Label color
- `series.itemStyle.color` Color for the series's item
- `series.markPoint` Mark point in a chart.
- `series.markPoint.symbolSize` Symbol size, default is `30`
- `series.markPoint.data` Data array for mark points, each of which is an object and the type only support `max` and `min`: `[{"type": "max"}, {"type": "min"}]`
- `series.markLine` Mark line in a chart
- `series.markPoint.data` Data array for mark points, each of which is an object and the type only support `max`, `min` and `average`: `[{"type": "max"}, {"type": "min"}, {"type": "average"}]``
- `series.data` Data array of series, which can be in the following forms:
- `value` It's a float array: [1.1, 2,3, 5.2]
- `object` It's a object value array: [{"value": 1048, "name": "Search Engine"},{"value": 735,"name": "Direct"}]
- `[children]` The options of children chart
## Performance
Generate a png chart will be less than 20ms. It's better than using `chrome headless` with `echarts`.
```bash
BenchmarkMultiChartPNGRender-8 78 15216336 ns/op 2298308 B/op 1148 allocs/op
BenchmarkMultiChartSVGRender-8 367 3356325 ns/op 20597282 B/op 3088 allocs/op
```

576
README_zh.md Normal file
View file

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

73
alias.go Normal file
View file

@ -0,0 +1,73 @@
// 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 (
"git.smarteching.com/zeni/go-chart/v2"
"git.smarteching.com/zeni/go-chart/v2/drawing"
)
type Box = chart.Box
type Style = chart.Style
type Color = drawing.Color
var BoxZero = chart.BoxZero
type Point struct {
X int
Y int
}
const (
ChartTypeLine = "line"
ChartTypeBar = "bar"
ChartTypePie = "pie"
ChartTypeRadar = "radar"
ChartTypeFunnel = "funnel"
// horizontal bar
ChartTypeHorizontalBar = "horizontalBar"
)
const (
ChartOutputSVG = "svg"
ChartOutputPNG = "png"
)
const (
PositionLeft = "left"
PositionRight = "right"
PositionCenter = "center"
PositionTop = "top"
PositionBottom = "bottom"
)
const (
AlignLeft = "left"
AlignRight = "right"
AlignCenter = "center"
)
const (
OrientHorizontal = "horizontal"
OrientVertical = "vertical"
)

BIN
assets/go-charts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

BIN
assets/go-table.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

339
axis.go Normal file
View file

@ -0,0 +1,339 @@
// 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 (
"strings"
"github.com/golang/freetype/truetype"
"git.smarteching.com/zeni/go-chart/v2"
)
type axisPainter struct {
p *Painter
opt *AxisOption
}
func NewAxisPainter(p *Painter, opt AxisOption) *axisPainter {
return &axisPainter{
p: p,
opt: &opt,
}
}
type AxisOption struct {
// The theme of chart
Theme ColorPalette
// Formatter for y axis text value
Formatter string
// The label of axis
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)
}
}
dataCount := len(data)
tickCount := dataCount
boundaryGap := true
if isFalse(opt.BoundaryGap) {
boundaryGap = false
}
isVertical := opt.Position == PositionLeft ||
opt.Position == PositionRight
labelPosition := ""
if !boundaryGap {
tickCount--
labelPosition = PositionLeft
}
if isVertical && boundaryGap {
labelPosition = PositionCenter
}
// 如果小于0则表示不处理
tickLength := getDefaultInt(opt.TickLength, 5)
labelMargin := getDefaultInt(opt.LabelMargin, 5)
style := Style{
StrokeColor: strokeColor,
StrokeWidth: strokeWidth,
Font: font,
FontColor: fontColor,
FontSize: fontSize,
}
top.SetDrawingStyle(style).OverrideTextStyle(style)
isTextRotation := opt.TextRotation != 0
if isTextRotation {
top.SetTextRotation(opt.TextRotation)
}
textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(data)
if isTextRotation {
top.ClearTextRotation()
}
// 增加30px来计算文本展示区域
textFillWidth := float64(textMaxWidth + 20)
// 根据文本宽度计算较为符合的展示项
fitTextCount := ceilFloatToInt(float64(top.Width()) / textFillWidth)
unit := opt.Unit
if unit <= 0 {
unit = ceilFloatToInt(float64(dataCount) / float64(fitTextCount))
unit = chart.MaxInt(unit, opt.SplitNumber)
// 偶数
if unit%2 == 0 && dataCount%(unit+1) == 0 {
unit++
}
}
width := 0
height := 0
// 垂直
if isVertical {
width = textMaxWidth + tickLength<<1
height = top.Height()
} else {
width = top.Width()
height = tickLength<<1 + textMaxHeight
}
padding := Box{}
switch opt.Position {
case PositionTop:
padding.Top = top.Height() - height
case PositionLeft:
padding.Right = top.Width() - width
case PositionRight:
padding.Left = top.Width() - width
default:
padding.Top = top.Height() - defaultXAxisHeight
}
p := top.Child(PainterPaddingOption(padding))
x0 := 0
y0 := 0
x1 := 0
y1 := 0
ticksPaddingTop := 0
ticksPaddingLeft := 0
labelPaddingTop := 0
labelPaddingLeft := 0
labelPaddingRight := 0
orient := ""
textAlign := ""
switch opt.Position {
case PositionTop:
labelPaddingTop = 0
x1 = p.Width()
y0 = labelMargin + int(opt.FontSize)
ticksPaddingTop = int(opt.FontSize)
y1 = y0
orient = OrientHorizontal
case PositionLeft:
x0 = p.Width()
y0 = 0
x1 = p.Width()
y1 = p.Height()
orient = OrientVertical
textAlign = AlignRight
ticksPaddingLeft = textMaxWidth + tickLength
labelPaddingRight = width - textMaxWidth
case PositionRight:
orient = OrientVertical
y1 = p.Height()
labelPaddingLeft = width - textMaxWidth
default:
labelPaddingTop = height
x1 = p.Width()
orient = OrientHorizontal
}
if strokeWidth > 0 {
p.Child(PainterPaddingOption(Box{
Top: ticksPaddingTop,
Left: ticksPaddingLeft,
})).Ticks(TicksOption{
Count: tickCount,
Length: tickLength,
Unit: unit,
Orient: orient,
First: opt.FirstAxis,
})
p.LineStroke([]Point{
{
X: x0,
Y: y0,
},
{
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 Box{
Bottom: height,
Right: width,
}, nil
}

173
axis_test.go Normal file
View file

@ -0,0 +1,173 @@
// 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 TestAxis(t *testing.T) {
assert := assert.New(t)
tests := []struct {
render func(*Painter) ([]byte, error)
result string
}{
// 底部x轴
{
render: func(p *Painter) ([]byte, error) {
_, _ = NewAxisPainter(p, AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
SplitLineShow: true,
SplitLineColor: drawing.ColorBlack,
}).Render()
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 375\nL 0 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 85 375\nL 85 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 171 375\nL 171 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 257 375\nL 257 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 342 375\nL 342 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 428 375\nL 428 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 514 375\nL 514 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 600 375\nL 600 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 0 370\nL 600 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"27\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"115\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"199\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"286\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"376\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"460\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"544\" y=\"395\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun</text><path d=\"M 85 0\nL 85 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 171 0\nL 171 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 257 0\nL 257 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 342 0\nL 342 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 428 0\nL 428 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 514 0\nL 514 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 600 0\nL 600 370\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
},
// 底部x轴文本居左
{
render: func(p *Painter) ([]byte, error) {
_, _ = NewAxisPainter(p, AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
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轴
{
render: func(p *Painter) ([]byte, error) {
_, _ = NewAxisPainter(p, AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
Position: PositionLeft,
}).Render()
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 36 0\nL 41 0\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 57\nL 41 57\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 114\nL 41 114\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 171\nL 41 171\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 228\nL 41 228\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 285\nL 41 285\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 342\nL 41 342\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 400\nL 41 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 41 0\nL 41 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"0\" y=\"35\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"4\" y=\"92\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"0\" y=\"149\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"4\" y=\"206\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"13\" y=\"263\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"8\" y=\"320\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"4\" y=\"378\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 左侧y轴居中
{
render: func(p *Painter) ([]byte, error) {
_, _ = NewAxisPainter(p, AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
Position: PositionLeft,
BoundaryGap: FalseFlag(),
SplitLineShow: true,
SplitLineColor: drawing.ColorBlack,
}).Render()
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 36 0\nL 41 0\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 66\nL 41 66\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 133\nL 41 133\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 200\nL 41 200\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 266\nL 41 266\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 333\nL 41 333\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 36 400\nL 41 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 41 0\nL 41 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"0\" y=\"7\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"4\" y=\"73\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"0\" y=\"140\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"4\" y=\"207\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"13\" y=\"273\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"8\" y=\"340\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"4\" y=\"407\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun</text><path d=\"M 41 0\nL 600 0\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 66\nL 600 66\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 133\nL 600 133\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 200\nL 600 200\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 266\nL 600 266\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 41 333\nL 600 333\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
},
// 右侧
{
render: func(p *Painter) ([]byte, error) {
_, _ = NewAxisPainter(p, AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
Position: PositionRight,
BoundaryGap: FalseFlag(),
SplitLineShow: true,
SplitLineColor: drawing.ColorBlack,
}).Render()
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 559 0\nL 564 0\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 66\nL 564 66\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 133\nL 564 133\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 200\nL 564 200\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 266\nL 564 266\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 333\nL 564 333\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 400\nL 564 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 559 0\nL 559 400\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"569\" y=\"7\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"569\" y=\"73\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"569\" y=\"140\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"569\" y=\"207\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"569\" y=\"273\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"569\" y=\"340\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"569\" y=\"407\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun</text><path d=\"M 0 0\nL 559 0\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 66\nL 559 66\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 133\nL 559 133\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 200\nL 559 200\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 266\nL 559 266\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 0 333\nL 559 333\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
},
// 顶部
{
render: func(p *Painter) ([]byte, error) {
_, _ = NewAxisPainter(p, AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
Formatter: "{value} --",
Position: PositionTop,
}).Render()
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 380\nL 0 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 85 380\nL 85 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 171 380\nL 171 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 257 380\nL 257 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 342 380\nL 342 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 428 380\nL 428 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 514 380\nL 514 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 600 380\nL 600 375\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 0 380\nL 600 380\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"20\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Mon --</text><text x=\"108\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Tue --</text><text x=\"192\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Wed --</text><text x=\"279\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Thu --</text><text x=\"369\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Fri --</text><text x=\"453\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sat --</text><text x=\"537\" y=\"375\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Sun --</text></svg>",
},
}
for _, tt := range tests {
p, err := NewPainter(PainterOptions{
Type: ChartOutputSVG,
Width: 600,
Height: 400,
}, PainterThemeOption(defaultTheme))
assert.Nil(err)
data, err := tt.render(p)
assert.Nil(err)
assert.Equal(tt.result, string(data))
}
}

253
bar_chart.go Normal file
View 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

File diff suppressed because one or more lines are too long

426
chart_option.go Normal file
View 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

File diff suppressed because one or more lines are too long

473
charts.go Normal file
View file

@ -0,0 +1,473 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"errors"
"math"
"sort"
"git.smarteching.com/zeni/go-chart/v2"
)
const labelFontSize = 10
const smallLabelFontSize = 8
const defaultDotWidth = 2.0
const defaultStrokeWidth = 2.0
var defaultChartWidth = 600
var defaultChartHeight = 400
// SetDefaultWidth sets default width of chart
func SetDefaultWidth(width int) {
if width > 0 {
defaultChartWidth = width
}
}
// SetDefaultHeight sets default height of chart
func SetDefaultHeight(height int) {
if height > 0 {
defaultChartHeight = height
}
}
var nullValue = math.MaxFloat64
// SetNullValue sets the null value, default is MaxFloat64
func SetNullValue(v float64) {
nullValue = v
}
// GetNullValue gets the null value
func GetNullValue() float64 {
return nullValue
}
type Renderer interface {
Render() (Box, error)
}
type renderHandler struct {
list []func() error
}
func (rh *renderHandler) Add(fn func() error) {
list := rh.list
if len(list) == 0 {
list = make([]func() error, 0)
}
rh.list = append(list, fn)
}
func (rh *renderHandler) Do() error {
for _, fn := range rh.list {
err := fn()
if err != nil {
return err
}
}
return nil
}
type defaultRenderOption struct {
Theme ColorPalette
Padding Box
SeriesList SeriesList
// The y axis option
YAxisOptions []YAxisOption
// The x axis option
XAxis XAxisOption
// The title option
TitleOption TitleOption
// The legend option
LegendOption LegendOption
// background is filled
backgroundIsFilled bool
// x y axis is reversed
axisReversed bool
}
type defaultRenderResult struct {
axisRanges map[int]axisRange
// 图例区域
seriesPainter *Painter
}
func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, error) {
seriesList := opt.SeriesList
seriesList.init()
if !opt.backgroundIsFilled {
p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor())
}
if !opt.Padding.IsZero() {
p = p.Child(PainterPaddingOption(opt.Padding))
}
legendHeight := 0
if len(opt.LegendOption.Data) != 0 {
if opt.LegendOption.Theme == nil {
opt.LegendOption.Theme = opt.Theme
}
legendResult, err := NewLegendPainter(p, opt.LegendOption).Render()
if err != nil {
return nil, err
}
legendHeight = legendResult.Height()
}
// 如果有标题
if opt.TitleOption.Text != "" {
if opt.TitleOption.Theme == nil {
opt.TitleOption.Theme = opt.Theme
}
titlePainter := NewTitlePainter(p, opt.TitleOption)
titleBox, err := titlePainter.Render()
if err != nil {
return nil, err
}
top := chart.MaxInt(legendHeight, titleBox.Height())
// 如果是垂直方式则不计算legend高度
if opt.LegendOption.Orient == OrientVertical {
top = titleBox.Height()
}
p = p.Child(PainterPaddingOption(Box{
// 标题下留白
Top: top + 20,
}))
}
result := defaultRenderResult{
axisRanges: make(map[int]axisRange),
}
// 计算图表对应的轴有哪些
axisIndexList := make([]int, 0)
for _, series := range opt.SeriesList {
if containsInt(axisIndexList, series.AxisIndex) {
continue
}
axisIndexList = append(axisIndexList, series.AxisIndex)
}
// 高度需要减去x轴的高度
rangeHeight := p.Height() - defaultXAxisHeight
rangeWidthLeft := 0
rangeWidthRight := 0
// 倒序
sort.Sort(sort.Reverse(sort.IntSlice(axisIndexList)))
// 计算对应的axis range
for _, index := range axisIndexList {
yAxisOption := YAxisOption{}
if len(opt.YAxisOptions) > index {
yAxisOption = opt.YAxisOptions[index]
}
divideCount := yAxisOption.DivideCount
if divideCount <= 0 {
divideCount = defaultAxisDivideCount
}
max, min := opt.SeriesList.GetMaxMin(index)
r := NewRange(AxisRangeOption{
Painter: p,
Min: min,
Max: max,
// 高度需要减去x轴的高度
Size: rangeHeight,
// 分隔数量
DivideCount: divideCount,
})
if yAxisOption.Min != nil && *yAxisOption.Min <= min {
r.min = *yAxisOption.Min
}
if yAxisOption.Max != nil && *yAxisOption.Max >= max {
r.max = *yAxisOption.Max
}
result.axisRanges[index] = r
if yAxisOption.Theme == nil {
yAxisOption.Theme = opt.Theme
}
if !opt.axisReversed {
yAxisOption.Data = r.Values()
} else {
yAxisOption.isCategoryAxis = true
// 由于x轴为value部分因此计算其label单独处理
opt.XAxis.Data = NewRange(AxisRangeOption{
Painter: p,
Min: min,
Max: max,
// 高度需要减去x轴的高度
Size: rangeHeight,
// 分隔数量
DivideCount: defaultAxisDivideCount,
}).Values()
opt.XAxis.isValueAxis = true
}
reverseStringSlice(yAxisOption.Data)
// TODO生成其它位置既yAxis
var yAxis *axisPainter
child := p.Child(PainterPaddingOption(Box{
Left: rangeWidthLeft,
Right: rangeWidthRight,
}))
if index == 0 {
yAxis = NewLeftYAxis(child, yAxisOption)
} else {
yAxis = NewRightYAxis(child, yAxisOption)
}
yAxisBox, err := yAxis.Render()
if err != nil {
return nil, err
}
if index == 0 {
rangeWidthLeft += yAxisBox.Width()
} else {
rangeWidthRight += yAxisBox.Width()
}
}
if opt.XAxis.Theme == nil {
opt.XAxis.Theme = opt.Theme
}
xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{
Left: rangeWidthLeft,
Right: rangeWidthRight,
})), opt.XAxis)
_, err := xAxis.Render()
if err != nil {
return nil, err
}
result.seriesPainter = p.Child(PainterPaddingOption(Box{
Bottom: defaultXAxisHeight,
Left: rangeWidthLeft,
Right: rangeWidthRight,
}))
return &result, nil
}
func doRender(renderers ...Renderer) error {
for _, r := range renderers {
_, err := r.Render()
if err != nil {
return err
}
}
return nil
}
func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) {
for _, fn := range opts {
fn(&opt)
}
opt.fillDefault()
isChild := true
if opt.Parent == nil {
isChild = false
p, err := NewPainter(PainterOptions{
Type: opt.Type,
Width: opt.Width,
Height: opt.Height,
Font: opt.font,
})
if err != nil {
return nil, err
}
opt.Parent = p
}
p := opt.Parent
if opt.ValueFormatter != nil {
p.valueFormatter = opt.ValueFormatter
}
if !opt.Box.IsZero() {
p = p.Child(PainterBoxOption(opt.Box))
}
if !isChild {
p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
}
seriesList := opt.SeriesList
seriesList.init()
seriesCount := len(seriesList)
// line chart
lineSeriesList := seriesList.Filter(ChartTypeLine)
barSeriesList := seriesList.Filter(ChartTypeBar)
horizontalBarSeriesList := seriesList.Filter(ChartTypeHorizontalBar)
pieSeriesList := seriesList.Filter(ChartTypePie)
radarSeriesList := seriesList.Filter(ChartTypeRadar)
funnelSeriesList := seriesList.Filter(ChartTypeFunnel)
if len(horizontalBarSeriesList) != 0 && len(horizontalBarSeriesList) != seriesCount {
return nil, errors.New("Horizontal bar can not mix other charts")
}
if len(pieSeriesList) != 0 && len(pieSeriesList) != seriesCount {
return nil, errors.New("Pie can not mix other charts")
}
if len(radarSeriesList) != 0 && len(radarSeriesList) != seriesCount {
return nil, errors.New("Radar can not mix other charts")
}
if len(funnelSeriesList) != 0 && len(funnelSeriesList) != seriesCount {
return nil, errors.New("Funnel can not mix other charts")
}
axisReversed := len(horizontalBarSeriesList) != 0
renderOpt := defaultRenderOption{
Theme: opt.theme,
Padding: opt.Padding,
SeriesList: opt.SeriesList,
XAxis: opt.XAxis,
YAxisOptions: opt.YAxisOptions,
TitleOption: opt.Title,
LegendOption: opt.Legend,
axisReversed: axisReversed,
// 前置已设置背景色
backgroundIsFilled: true,
}
if len(pieSeriesList) != 0 ||
len(radarSeriesList) != 0 ||
len(funnelSeriesList) != 0 {
renderOpt.XAxis.Show = FalseFlag()
renderOpt.YAxisOptions = []YAxisOption{
{
Show: FalseFlag(),
},
}
}
if len(horizontalBarSeriesList) != 0 {
renderOpt.YAxisOptions[0].DivideCount = len(renderOpt.YAxisOptions[0].Data)
renderOpt.YAxisOptions[0].Unit = 1
}
renderResult, err := defaultRender(p, renderOpt)
if err != nil {
return nil, err
}
handler := renderHandler{}
// bar chart
if len(barSeriesList) != 0 {
handler.Add(func() error {
_, err := NewBarChart(p, BarChartOption{
Theme: opt.theme,
Font: opt.font,
XAxis: opt.XAxis,
BarWidth: opt.BarWidth,
BarMargin: opt.BarMargin,
}).render(renderResult, barSeriesList)
return err
})
}
// horizontal bar chart
if len(horizontalBarSeriesList) != 0 {
handler.Add(func() error {
_, err := NewHorizontalBarChart(p, HorizontalBarChartOption{
Theme: opt.theme,
Font: opt.font,
BarHeight: opt.BarHeight,
BarMargin: opt.BarMargin,
YAxisOptions: opt.YAxisOptions,
}).render(renderResult, horizontalBarSeriesList)
return err
})
}
// pie chart
if len(pieSeriesList) != 0 {
handler.Add(func() error {
_, err := NewPieChart(p, PieChartOption{
Theme: opt.theme,
Font: opt.font,
}).render(renderResult, pieSeriesList)
return err
})
}
// line chart
if len(lineSeriesList) != 0 {
handler.Add(func() error {
_, err := NewLineChart(p, LineChartOption{
Theme: opt.theme,
Font: opt.font,
XAxis: opt.XAxis,
SymbolShow: opt.SymbolShow,
StrokeWidth: opt.LineStrokeWidth,
FillArea: opt.FillArea,
Opacity: opt.Opacity,
}).render(renderResult, lineSeriesList)
return err
})
}
// radar chart
if len(radarSeriesList) != 0 {
handler.Add(func() error {
_, err := NewRadarChart(p, RadarChartOption{
Theme: opt.theme,
Font: opt.font,
// 相应值
RadarIndicators: opt.RadarIndicators,
}).render(renderResult, radarSeriesList)
return err
})
}
// funnel chart
if len(funnelSeriesList) != 0 {
handler.Add(func() error {
_, err := NewFunnelChart(p, FunnelChartOption{
Theme: opt.theme,
Font: opt.font,
}).render(renderResult, funnelSeriesList)
return err
})
}
err = handler.Do()
if err != nil {
return nil, err
}
for _, item := range opt.Children {
item.Parent = p
if item.Theme == "" {
item.Theme = opt.Theme
}
if item.FontFamily == "" {
item.FontFamily = opt.FontFamily
}
_, err = Render(item)
if err != nil {
return nil, err
}
}
return p, nil
}

255
charts_test.go Normal file
View file

@ -0,0 +1,255 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"errors"
"testing"
"git.smarteching.com/zeni/go-chart/v2"
)
func BenchmarkMultiChartPNGRender(b *testing.B) {
for i := 0; i < b.N; i++ {
opt := ChartOption{
Type: ChartOutputPNG,
Legend: LegendOption{
Top: "-90",
Data: []string{
"Milk Tea",
"Matcha Latte",
"Cheese Cocoa",
"Walnut Brownie",
},
},
Padding: chart.Box{
Top: 100,
Right: 10,
Bottom: 10,
Left: 10,
},
XAxis: NewXAxisOption([]string{
"2012",
"2013",
"2014",
"2015",
"2016",
"2017",
}),
YAxisOptions: []YAxisOption{
{
Min: NewFloatPoint(0),
Max: NewFloatPoint(90),
},
},
SeriesList: []Series{
NewSeriesFromValues([]float64{
56.5,
82.1,
88.7,
70.1,
53.4,
85.1,
}),
NewSeriesFromValues([]float64{
51.1,
51.4,
55.1,
53.3,
73.8,
68.7,
}),
NewSeriesFromValues([]float64{
40.1,
62.2,
69.5,
36.4,
45.2,
32.5,
}, ChartTypeBar),
NewSeriesFromValues([]float64{
25.2,
37.1,
41.2,
18,
33.9,
49.1,
}, ChartTypeBar),
},
Children: []ChartOption{
{
Legend: LegendOption{
Show: FalseFlag(),
Data: []string{
"Milk Tea",
"Matcha Latte",
"Cheese Cocoa",
"Walnut Brownie",
},
},
Box: chart.Box{
Top: 20,
Left: 400,
Right: 500,
Bottom: 120,
},
SeriesList: NewPieSeriesList([]float64{
435.9,
354.3,
285.9,
204.5,
}, PieSeriesOption{
Label: SeriesLabel{
Show: true,
},
Radius: "35%",
}),
},
},
}
d, err := Render(opt)
if err != nil {
panic(err)
}
buf, err := d.Bytes()
if err != nil {
panic(err)
}
if len(buf) == 0 {
panic(errors.New("data is nil"))
}
}
}
func BenchmarkMultiChartSVGRender(b *testing.B) {
for i := 0; i < b.N; i++ {
opt := ChartOption{
Legend: LegendOption{
Top: "-90",
Data: []string{
"Milk Tea",
"Matcha Latte",
"Cheese Cocoa",
"Walnut Brownie",
},
},
Padding: chart.Box{
Top: 100,
Right: 10,
Bottom: 10,
Left: 10,
},
XAxis: NewXAxisOption([]string{
"2012",
"2013",
"2014",
"2015",
"2016",
"2017",
}),
YAxisOptions: []YAxisOption{
{
Min: NewFloatPoint(0),
Max: NewFloatPoint(90),
},
},
SeriesList: []Series{
NewSeriesFromValues([]float64{
56.5,
82.1,
88.7,
70.1,
53.4,
85.1,
}),
NewSeriesFromValues([]float64{
51.1,
51.4,
55.1,
53.3,
73.8,
68.7,
}),
NewSeriesFromValues([]float64{
40.1,
62.2,
69.5,
36.4,
45.2,
32.5,
}, ChartTypeBar),
NewSeriesFromValues([]float64{
25.2,
37.1,
41.2,
18,
33.9,
49.1,
}, ChartTypeBar),
},
Children: []ChartOption{
{
Legend: LegendOption{
Show: FalseFlag(),
Data: []string{
"Milk Tea",
"Matcha Latte",
"Cheese Cocoa",
"Walnut Brownie",
},
},
Box: chart.Box{
Top: 20,
Left: 400,
Right: 500,
Bottom: 120,
},
SeriesList: NewPieSeriesList([]float64{
435.9,
354.3,
285.9,
204.5,
}, PieSeriesOption{
Label: SeriesLabel{
Show: true,
},
Radius: "35%",
}),
},
},
}
d, err := Render(opt)
if err != nil {
panic(err)
}
buf, err := d.Bytes()
if err != nil {
panic(err)
}
if len(buf) == 0 {
panic(errors.New("data is nil"))
}
}
}

528
echarts.go Normal file
View file

@ -0,0 +1,528 @@
// 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"
"encoding/json"
"fmt"
"regexp"
"strconv"
"git.smarteching.com/zeni/go-chart/v2"
)
func convertToArray(data []byte) []byte {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return nil
}
if data[0] != '[' {
data = []byte("[" + string(data) + "]")
}
return data
}
type EChartsPosition string
func (p *EChartsPosition) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
return nil
}
if regexp.MustCompile(`^\d+`).Match(data) {
data = []byte(fmt.Sprintf(`"%s"`, string(data)))
}
s := (*string)(p)
return json.Unmarshal(data, s)
}
type EChartStyle struct {
Color string `json:"color"`
}
func (es *EChartStyle) ToStyle() Style {
color := parseColor(es.Color)
return Style{
FillColor: color,
FontColor: color,
StrokeColor: color,
}
}
type EChartsSeriesDataValue struct {
values []float64
}
func (value *EChartsSeriesDataValue) UnmarshalJSON(data []byte) error {
data = convertToArray(data)
return json.Unmarshal(data, &value.values)
}
func (value *EChartsSeriesDataValue) First() float64 {
if len(value.values) == 0 {
return 0
}
return value.values[0]
}
func NewEChartsSeriesDataValue(values ...float64) EChartsSeriesDataValue {
return EChartsSeriesDataValue{
values: values,
}
}
type EChartsSeriesData struct {
Value EChartsSeriesDataValue `json:"value"`
Name string `json:"name"`
ItemStyle EChartStyle `json:"itemStyle"`
}
type _EChartsSeriesData EChartsSeriesData
var numericRep = regexp.MustCompile(`^[-+]?[0-9]+(?:\.[0-9]+)?$`)
func (es *EChartsSeriesData) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return nil
}
if numericRep.Match(data) {
v, err := strconv.ParseFloat(string(data), 64)
if err != nil {
return err
}
es.Value = EChartsSeriesDataValue{
values: []float64{
v,
},
}
return nil
}
v := _EChartsSeriesData{}
err := json.Unmarshal(data, &v)
if err != nil {
return err
}
es.Name = v.Name
es.Value = v.Value
es.ItemStyle = v.ItemStyle
return nil
}
type EChartsXAxisData struct {
BoundaryGap *bool `json:"boundaryGap"`
SplitNumber int `json:"splitNumber"`
Data []string `json:"data"`
Type string `json:"type"`
}
type EChartsXAxis struct {
Data []EChartsXAxisData
}
func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error {
data = convertToArray(data)
if len(data) == 0 {
return nil
}
return json.Unmarshal(data, &ex.Data)
}
type EChartsAxisLabel struct {
Formatter string `json:"formatter"`
}
type EChartsYAxisData struct {
Min *float64 `json:"min"`
Max *float64 `json:"max"`
AxisLabel EChartsAxisLabel `json:"axisLabel"`
AxisLine struct {
LineStyle struct {
Color string `json:"color"`
} `json:"lineStyle"`
} `json:"axisLine"`
Data []string `json:"data"`
}
type EChartsYAxis struct {
Data []EChartsYAxisData `json:"data"`
}
func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error {
data = convertToArray(data)
if len(data) == 0 {
return nil
}
return json.Unmarshal(data, &ey.Data)
}
type EChartsPadding struct {
Box chart.Box
}
func (eb *EChartsPadding) UnmarshalJSON(data []byte) error {
data = convertToArray(data)
if len(data) == 0 {
return nil
}
arr := make([]int, 0)
err := json.Unmarshal(data, &arr)
if err != nil {
return err
}
if len(arr) == 0 {
return nil
}
switch len(arr) {
case 1:
eb.Box = chart.Box{
Left: arr[0],
Top: arr[0],
Bottom: arr[0],
Right: arr[0],
}
case 2:
eb.Box = chart.Box{
Top: arr[0],
Bottom: arr[0],
Left: arr[1],
Right: arr[1],
}
default:
result := make([]int, 4)
copy(result, arr)
if len(arr) == 3 {
result[3] = result[1]
}
// 上右下左
eb.Box = chart.Box{
Top: result[0],
Right: result[1],
Bottom: result[2],
Left: result[3],
}
}
return nil
}
type EChartsLabelOption struct {
Show bool `json:"show"`
Distance int `json:"distance"`
Color string `json:"color"`
}
type EChartsLegend struct {
Show *bool `json:"show"`
Data []string `json:"data"`
Align string `json:"align"`
Orient string `json:"orient"`
Padding EChartsPadding `json:"padding"`
Left EChartsPosition `json:"left"`
Top EChartsPosition `json:"top"`
TextStyle EChartsTextStyle `json:"textStyle"`
}
type EChartsMarkData struct {
Type string `json:"type"`
}
type _EChartsMarkData EChartsMarkData
func (emd *EChartsMarkData) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return nil
}
data = convertToArray(data)
ds := make([]*_EChartsMarkData, 0)
err := json.Unmarshal(data, &ds)
if err != nil {
return err
}
for _, d := range ds {
if d.Type != "" {
emd.Type = d.Type
}
}
return nil
}
type EChartsMarkPoint struct {
SymbolSize int `json:"symbolSize"`
Data []EChartsMarkData `json:"data"`
}
func (emp *EChartsMarkPoint) ToSeriesMarkPoint() SeriesMarkPoint {
sp := SeriesMarkPoint{
SymbolSize: emp.SymbolSize,
}
if len(emp.Data) == 0 {
return sp
}
data := make([]SeriesMarkData, len(emp.Data))
for index, item := range emp.Data {
data[index].Type = item.Type
}
sp.Data = data
return sp
}
type EChartsMarkLine struct {
Data []EChartsMarkData `json:"data"`
}
func (eml *EChartsMarkLine) ToSeriesMarkLine() SeriesMarkLine {
sl := SeriesMarkLine{}
if len(eml.Data) == 0 {
return sl
}
data := make([]SeriesMarkData, len(eml.Data))
for index, item := range eml.Data {
data[index].Type = item.Type
}
sl.Data = data
return sl
}
type EChartsSeries struct {
Data []EChartsSeriesData `json:"data"`
Name string `json:"name"`
Type string `json:"type"`
Radius string `json:"radius"`
YAxisIndex int `json:"yAxisIndex"`
ItemStyle EChartStyle `json:"itemStyle"`
// label的配置
Label EChartsLabelOption `json:"label"`
MarkPoint EChartsMarkPoint `json:"markPoint"`
MarkLine EChartsMarkLine `json:"markLine"`
Max *float64 `json:"max"`
Min *float64 `json:"min"`
}
type EChartsSeriesList []EChartsSeries
func (esList EChartsSeriesList) ToSeriesList() SeriesList {
seriesList := make(SeriesList, 0, len(esList))
for _, item := range esList {
// 如果是pie则每个子荐生成一个series
if item.Type == ChartTypePie {
for _, dataItem := range item.Data {
seriesList = append(seriesList, Series{
Type: item.Type,
Name: dataItem.Name,
Label: SeriesLabel{
Show: true,
},
Radius: item.Radius,
Data: []SeriesData{
{
Value: dataItem.Value.First(),
},
},
})
}
continue
}
// 如果是radar或funnel
if item.Type == ChartTypeRadar ||
item.Type == ChartTypeFunnel {
for _, dataItem := range item.Data {
seriesList = append(seriesList, Series{
Name: dataItem.Name,
Type: item.Type,
Data: NewSeriesDataFromValues(dataItem.Value.values),
Max: item.Max,
Min: item.Min,
Label: SeriesLabel{
Color: parseColor(item.Label.Color),
Show: item.Label.Show,
Distance: item.Label.Distance,
},
})
}
continue
}
data := make([]SeriesData, len(item.Data))
for j, dataItem := range item.Data {
data[j] = SeriesData{
Value: dataItem.Value.First(),
Style: dataItem.ItemStyle.ToStyle(),
}
}
seriesList = append(seriesList, Series{
Type: item.Type,
Data: data,
AxisIndex: item.YAxisIndex,
Style: item.ItemStyle.ToStyle(),
Label: SeriesLabel{
Color: parseColor(item.Label.Color),
Show: item.Label.Show,
Distance: item.Label.Distance,
},
Name: item.Name,
MarkPoint: item.MarkPoint.ToSeriesMarkPoint(),
MarkLine: item.MarkLine.ToSeriesMarkLine(),
})
}
return seriesList
}
type EChartsTextStyle struct {
Color string `json:"color"`
FontFamily string `json:"fontFamily"`
FontSize float64 `json:"fontSize"`
}
func (et *EChartsTextStyle) ToStyle() chart.Style {
s := chart.Style{
FontSize: et.FontSize,
FontColor: parseColor(et.Color),
}
if et.FontFamily != "" {
s.Font, _ = GetFont(et.FontFamily)
}
return s
}
type EChartsOption struct {
Type string `json:"type"`
Theme string `json:"theme"`
FontFamily string `json:"fontFamily"`
Padding EChartsPadding `json:"padding"`
Box chart.Box `json:"box"`
Width int `json:"width"`
Height int `json:"height"`
Title struct {
Text string `json:"text"`
Subtext string `json:"subtext"`
Left EChartsPosition `json:"left"`
Top EChartsPosition `json:"top"`
TextStyle EChartsTextStyle `json:"textStyle"`
SubtextStyle EChartsTextStyle `json:"subtextStyle"`
} `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(),
}
isHorizontalChart := false
for _, item := range eo.XAxis.Data {
if item.Type == "value" {
isHorizontalChart = true
}
}
if isHorizontalChart {
for index := range o.SeriesList {
series := o.SeriesList[index]
if series.Type == ChartTypeBar {
o.SeriesList[index].Type = ChartTypeHorizontalBar
}
}
}
if len(eo.XAxis.Data) != 0 {
xAxisData := eo.XAxis.Data[0]
o.XAxis = XAxisOption{
BoundaryGap: xAxisData.BoundaryGap,
Data: xAxisData.Data,
SplitNumber: xAxisData.SplitNumber,
}
}
yAxisOptions := make([]YAxisOption, len(eo.YAxis.Data))
for index, item := range eo.YAxis.Data {
yAxisOptions[index] = YAxisOption{
Min: item.Min,
Max: item.Max,
Formatter: item.AxisLabel.Formatter,
Color: parseColor(item.AxisLine.LineStyle.Color),
Data: item.Data,
}
}
o.YAxisOptions = yAxisOptions
if len(eo.Children) != 0 {
o.Children = make([]ChartOption, len(eo.Children))
for index, item := range eo.Children {
o.Children[index] = item.ToOption()
}
}
return o
}
func renderEcharts(options, outputType string) ([]byte, error) {
o := EChartsOption{}
err := json.Unmarshal([]byte(options), &o)
if err != nil {
return nil, err
}
opt := o.ToOption()
opt.Type = outputType
d, err := Render(opt)
if err != nil {
return nil, err
}
return d.Bytes()
}
func RenderEChartsToPNG(options string) ([]byte, error) {
return renderEcharts(options, "png")
}
func RenderEChartsToSVG(options string) ([]byte, error) {
return renderEcharts(options, "svg")
}

582
echarts_test.go Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,8 +0,0 @@
#!/bin/sh
set -e
if [ "${1:0:1}" = '-' ]; then
set -- go-charts "$@"
fi
exec "$@"

View 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
View 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)
}
}

1974
examples/charts/main.go Normal file

File diff suppressed because it is too large Load diff

120
examples/chinese/main.go Normal file
View 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)
}
}

View 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)
}
}

View 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
View 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
View 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)
}
}

View 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)
}
}

View 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
View 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)
}
}

View 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)
}
}

78
font.go Normal file
View file

@ -0,0 +1,78 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"errors"
"sync"
"github.com/golang/freetype/truetype"
"git.smarteching.com/zeni/go-chart/v2/roboto"
)
var fonts = sync.Map{}
var ErrFontNotExists = errors.New("font is not exists")
var defaultFontFamily = "defaultFontFamily"
func init() {
name := "roboto"
_ = InstallFont(name, roboto.Roboto)
font, _ := GetFont(name)
SetDefaultFont(font)
}
// InstallFont installs the font for charts
func InstallFont(fontFamily string, data []byte) error {
font, err := truetype.Parse(data)
if err != nil {
return err
}
fonts.Store(fontFamily, font)
return nil
}
// GetDefaultFont get default font
func GetDefaultFont() (*truetype.Font, error) {
return GetFont(defaultFontFamily)
}
// SetDefaultFont set default font
func SetDefaultFont(font *truetype.Font) {
if font == nil {
return
}
fonts.Store(defaultFontFamily, font)
}
// GetFont get the font by font family
func GetFont(fontFamily string) (*truetype.Font, error) {
value, ok := fonts.Load(fontFamily)
if !ok {
return nil, ErrFontNotExists
}
f, ok := value.(*truetype.Font)
if !ok {
return nil, ErrFontNotExists
}
return f, nil
}

42
font_test.go Normal file
View file

@ -0,0 +1,42 @@
// 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/roboto"
)
func TestInstallFont(t *testing.T) {
assert := assert.New(t)
fontFamily := "test"
err := InstallFont(fontFamily, roboto.Roboto)
assert.Nil(err)
font, err := GetFont(fontFamily)
assert.Nil(err)
assert.NotNil(font)
}

192
funnel_chart.go Normal file
View 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
View 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))
}
}

25
go.mod
View file

@ -1,22 +1,17 @@
module github.com/vicanso/go-charts-web
module git.smarteching.com/zeni/go-charts/v2
go 1.19
go 1.24.1
require (
github.com/vicanso/elton v1.10.0
github.com/vicanso/go-charts/v2 v2.6.0
git.smarteching.com/zeni/go-chart/v2 v2.1.4
github.com/dustin/go-humanize v1.0.1
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/stretchr/testify v1.10.0
)
require (
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/vicanso/hes v0.6.1 // indirect
github.com/vicanso/intranet-ip v0.1.0 // indirect
github.com/vicanso/keygrip v1.2.1 // indirect
github.com/wcharczuk/go-chart/v2 v2.1.0 // indirect
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/image v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

41
go.sum
View file

@ -1,6 +1,5 @@
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
git.smarteching.com/zeni/go-chart/v2 v2.1.4 h1:pF06+F6eqJLIG8uMiTVPR5TygPGMjM/FHMzTxmu5V/Q=
git.smarteching.com/zeni/go-chart/v2 v2.1.4/go.mod h1:b3ueW9h3pGGXyhkormZAvilHaG4+mQti+bMNPdQBeOQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@ -9,37 +8,11 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/vicanso/elton v1.10.0 h1:Qd6Dr5sarzkij+Vdgvtsd08jaNAKuZpBDpyPtKzESaY=
github.com/vicanso/elton v1.10.0/go.mod h1:GnFxH3+Vtz0HQbhGhCmssbdi67+yLjFOUbZDdB8mRcQ=
github.com/vicanso/go-charts/v2 v2.6.0 h1:nDaJuIr4pc1leQVjwmSXIMSmRvrTr5hUwZaxG3GExKI=
github.com/vicanso/go-charts/v2 v2.6.0/go.mod h1:aEuuwzCT+p/Pd8YnAPMEV+sMKjpKK6anH1u6CxiboIw=
github.com/vicanso/hes v0.6.1 h1:BRGUDhV2sJYMieJf4dgxFXjvuhDgUWBsINELcti0Z8M=
github.com/vicanso/hes v0.6.1/go.mod h1:awwBbvcDTk8APxRmiV7Hxrir89/iCxgB6RMeLc5toh0=
github.com/vicanso/intranet-ip v0.1.0 h1:UeoxilO2VDIkeZZxmu6aT+f5o79mfGdsSdwoEv75nYo=
github.com/vicanso/intranet-ip v0.1.0/go.mod h1:N1yrHdDYWNsOs5V374DuAJHba+d2dxUDcjVALgIlfOg=
github.com/vicanso/keygrip v1.2.1 h1:876fXDwGJqxdi4JxZ1lNGBxYswyLZotrs7AA2QWcLeY=
github.com/vicanso/keygrip v1.2.1/go.mod h1:tfB5az1yqold78zotkzNugk3sV+QW5m71CFz3zg9eeo=
github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I=
github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

92
grid.go Normal file
View 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
View 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
View 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)
}

File diff suppressed because one or more lines are too long

251
legend.go Normal file
View file

@ -0,0 +1,251 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"strconv"
"strings"
)
type legendPainter struct {
p *Painter
opt *LegendOption
}
const IconRect = "rect"
const IconLineDot = "lineDot"
type LegendOption struct {
// The theme
Theme ColorPalette
// Text array of legend
Data []string
// Distance between legend component and the left side of the container.
// It can be pixel value: 20, percentage value: 20%,
// or position value: right, center.
Left string
// Distance between legend component and the top side of the container.
// It can be pixel value: 20.
Top string
// Legend marker and text aligning, it can be left or right, default is left.
Align string
// The layout orientation of legend, it can be horizontal or vertical, default is horizontal.
Orient string
// Icon of the legend.
Icon string
// Font size of legend text
FontSize float64
// FontColor color of legend text
FontColor Color
// The flag for show legend, set this to *false will hide legend
Show *bool
// The padding of legend
Padding Box
}
// NewLegendOption returns a legend option
func NewLegendOption(labels []string, left ...string) LegendOption {
opt := LegendOption{
Data: labels,
}
if len(left) != 0 {
opt.Left = left[0]
}
return opt
}
// IsEmpty checks legend is empty
func (opt *LegendOption) IsEmpty() bool {
isEmpty := true
for _, v := range opt.Data {
if v != "" {
isEmpty = false
break
}
}
return isEmpty
}
// NewLegendPainter returns a legend renderer
func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter {
return &legendPainter{
p: p,
opt: &opt,
}
}
func (l *legendPainter) Render() (Box, error) {
opt := l.opt
theme := opt.Theme
if opt.IsEmpty() ||
isFalse(opt.Show) {
return BoxZero, nil
}
if theme == nil {
theme = l.p.theme
}
if opt.FontSize == 0 {
opt.FontSize = theme.GetFontSize()
}
if opt.FontColor.IsZero() {
opt.FontColor = theme.GetTextColor()
}
if opt.Left == "" {
opt.Left = PositionCenter
}
padding := opt.Padding
if padding.IsZero() {
padding.Top = 5
}
p := l.p.Child(PainterPaddingOption(padding))
p.SetTextStyle(Style{
FontSize: opt.FontSize,
FontColor: opt.FontColor,
})
measureList := make([]Box, len(opt.Data))
maxTextWidth := 0
for index, text := range opt.Data {
b := p.MeasureText(text)
if b.Width() > maxTextWidth {
maxTextWidth = b.Width()
}
measureList[index] = b
}
// 计算展示的宽高
width := 0
height := 0
offset := 20
textOffset := 2
legendWidth := 30
legendHeight := 20
itemMaxHeight := 0
for _, item := range measureList {
if item.Height() > itemMaxHeight {
itemMaxHeight = item.Height()
}
if opt.Orient == OrientVertical {
height += item.Height()
} else {
width += item.Width()
}
}
// 增加padding
itemMaxHeight += 10
if opt.Orient == OrientVertical {
width = maxTextWidth + textOffset + legendWidth
height = offset * len(opt.Data)
} else {
height = legendHeight
offsetValue := (len(opt.Data) - 1) * (offset + textOffset)
allLegendWidth := len(opt.Data) * legendWidth
width += (offsetValue + allLegendWidth)
}
// 计算开始的位置
left := 0
switch opt.Left {
case PositionRight:
left = p.Width() - width
case PositionCenter:
left = (p.Width() - width) >> 1
default:
if strings.HasSuffix(opt.Left, "%") {
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
left = p.Width() * value / 100
} else {
value, _ := strconv.Atoi(opt.Left)
left = value
}
}
top, _ := strconv.Atoi(opt.Top)
if left < 0 {
left = 0
}
x := int(left)
y := int(top) + 10
startY := y
x0 := x
y0 := y
drawIcon := func(top, left int) int {
if opt.Icon == IconRect {
p.Rect(Box{
Top: top - legendHeight + 8,
Left: left,
Right: left + legendWidth,
Bottom: top + 1,
})
} else {
p.LegendLineDot(Box{
Top: top + 1,
Left: left,
Right: left + legendWidth,
Bottom: top + legendHeight + 1,
})
}
return left + legendWidth
}
lastIndex := len(opt.Data) - 1
for index, text := range opt.Data {
color := theme.GetSeriesColor(index)
p.SetDrawingStyle(Style{
FillColor: color,
StrokeColor: color,
})
itemWidth := x0 + measureList[index].Width() + textOffset + offset + legendWidth
if lastIndex == index {
itemWidth = x0 + measureList[index].Width() + legendWidth
}
if itemWidth > p.Width() {
x0 = 0
y += itemMaxHeight
y0 = y
}
if opt.Align != AlignRight {
x0 = drawIcon(y0, x0)
x0 += textOffset
}
p.Text(text, x0, y0)
x0 += measureList[index].Width()
if opt.Align == AlignRight {
x0 += textOffset
x0 = drawIcon(y0, x0)
}
if opt.Orient == OrientVertical {
y0 += offset
x0 = x
} else {
x0 += offset
y0 = y
}
height = y0 - startY + 10
}
return Box{
Right: width,
Bottom: height + padding.Bottom + padding.Top,
}, nil
}

102
legend_test.go Normal file
View file

@ -0,0 +1,102 @@
// 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 TestNewLegend(t *testing.T) {
assert := assert.New(t)
tests := []struct {
render func(*Painter) ([]byte, error)
result string
}{
{
render: func(p *Painter) ([]byte, error) {
_, err := NewLegendPainter(p, LegendOption{
Data: []string{
"One",
"Two",
"Three",
},
}).Render()
if err != nil {
return nil, err
}
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 184 9\nL 214 9\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><circle cx=\"199\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"216\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">One</text><path d=\"M 264 9\nL 294 9\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><circle cx=\"279\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><text x=\"296\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Two</text><path d=\"M 346 9\nL 376 9\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><circle cx=\"361\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><text x=\"378\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Three</text></svg>",
},
{
render: func(p *Painter) ([]byte, error) {
_, err := NewLegendPainter(p, LegendOption{
Data: []string{
"One",
"Two",
"Three",
},
Left: PositionLeft,
}).Render()
if err != nil {
return nil, err
}
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 9\nL 30 9\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><circle cx=\"15\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"32\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">One</text><path d=\"M 80 9\nL 110 9\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><circle cx=\"95\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><text x=\"112\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Two</text><path d=\"M 162 9\nL 192 9\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><circle cx=\"177\" cy=\"9\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><text x=\"194\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Three</text></svg>",
},
{
render: func(p *Painter) ([]byte, error) {
_, err := NewLegendPainter(p, LegendOption{
Data: []string{
"One",
"Two",
"Three",
},
Orient: OrientVertical,
Icon: IconRect,
Left: "10%",
}).Render()
if err != nil {
return nil, err
}
return p.Bytes()
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 60 3\nL 90 3\nL 90 16\nL 60 16\nL 60 3\" style=\"stroke-width:0;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"92\" y=\"15\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">One</text><path d=\"M 60 23\nL 90 23\nL 90 36\nL 60 36\nL 60 23\" style=\"stroke-width:0;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><text x=\"92\" y=\"35\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Two</text><path d=\"M 60 43\nL 90 43\nL 90 56\nL 60 56\nL 60 43\" style=\"stroke-width:0;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><text x=\"92\" y=\"55\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif\">Three</text></svg>",
},
}
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))
}
}

240
line_chart.go Normal file
View 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

File diff suppressed because one or more lines are too long

82
main.go
View file

@ -1,82 +0,0 @@
package main
import (
"bytes"
"embed"
"encoding/base64"
"fmt"
"time"
"github.com/vicanso/elton"
"github.com/vicanso/elton/middleware"
"github.com/vicanso/go-charts/v2"
)
//go:embed web
var webFS embed.FS
func main() {
e := elton.New()
e.Use(middleware.NewLogger(middleware.LoggerConfig{
Format: `{real-ip} {when-iso} "{method} {uri} {proto}" {status} {size-human} "{userAgent}"`,
OnLog: func(s string, _ *elton.Context) {
fmt.Println(s)
},
}))
e.Use(middleware.NewDefaultError())
e.Use(middleware.NewDefaultBodyParser())
e.Use(func(c *elton.Context) error {
c.NoCache()
return c.Next()
})
assetFS := middleware.NewEmbedStaticFS(webFS, "web")
e.GET("/static/*", middleware.NewStaticServe(assetFS, middleware.StaticServeConfig{
// 客户端缓存
MaxAge: 10 * time.Minute,
// 缓存服务器缓存
SMaxAge: 5 * time.Minute,
DenyQueryString: true,
DisableLastModified: true,
EnableStrongETag: true,
}))
e.GET("/ping", func(c *elton.Context) error {
c.BodyBuffer = bytes.NewBufferString("pong")
return nil
})
e.GET("/", func(c *elton.Context) error {
buf, err := webFS.ReadFile("web/index.html")
if err != nil {
return err
}
c.SetContentTypeByExt(".html")
c.BodyBuffer = bytes.NewBuffer(buf)
return nil
})
e.POST("/", func(c *elton.Context) error {
outputType := c.QueryParam("outputType")
fn := charts.RenderEChartsToSVG
isPNG := false
if outputType == "png" {
isPNG = true
fn = charts.RenderEChartsToPNG
}
buf, err := fn(string(c.RequestBody))
if err != nil {
return err
}
if isPNG {
buf = []byte(base64.StdEncoding.EncodeToString(buf))
}
c.BodyBuffer = bytes.NewBuffer(buf)
return nil
})
err := e.ListenAndServe(":7001")
if err != nil {
panic(err)
}
}

113
mark_line.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

273
radar_chart.go Normal file
View 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

File diff suppressed because one or more lines are too long

144
range.go Normal file
View file

@ -0,0 +1,144 @@
// 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"
)
const defaultAxisDivideCount = 6
type axisRange struct {
p *Painter
divideCount int
min float64
max float64
size int
boundary bool
}
type AxisRangeOption struct {
Painter *Painter
// The min value of axis
Min float64
// The max value of axis
Max float64
// The size of axis
Size int
// Boundary gap
Boundary bool
// The count of divide
DivideCount int
}
// NewRange returns a axis range
func NewRange(opt AxisRangeOption) axisRange {
max := opt.Max
min := opt.Min
max += math.Abs(max * 0.1)
min -= math.Abs(min * 0.1)
divideCount := opt.DivideCount
r := math.Abs(max - min)
// 最小单位计算
unit := 1
if r > 5 {
unit = 2
}
if r > 10 {
unit = 4
}
if r > 30 {
unit = 5
}
if r > 100 {
unit = 10
}
if r > 200 {
unit = 20
}
unit = int((r/float64(divideCount))/float64(unit))*unit + unit
if min != 0 {
isLessThanZero := min < 0
min = float64(int(min/float64(unit)) * unit)
// 如果是小于0int的时候向上取整了因此调整
if min < 0 ||
(isLessThanZero && min == 0) {
min -= float64(unit)
}
}
max = min + float64(unit*divideCount)
expectMax := opt.Max * 2
if max > expectMax {
max = float64(ceilFloatToInt(expectMax))
}
return axisRange{
p: opt.Painter,
divideCount: divideCount,
min: min,
max: max,
size: opt.Size,
boundary: opt.Boundary,
}
}
// Values returns values of range
func (r axisRange) Values() []string {
offset := (r.max - r.min) / float64(r.divideCount)
values := make([]string, 0)
formatter := commafWithDigits
if r.p != nil && r.p.valueFormatter != nil {
formatter = r.p.valueFormatter
}
for i := 0; i <= r.divideCount; i++ {
v := r.min + float64(i)*offset
value := formatter(v)
values = append(values, value)
}
return values
}
func (r *axisRange) getHeight(value float64) int {
if r.max <= r.min {
return 0
}
v := (value - r.min) / (r.max - r.min)
return int(v * float64(r.size))
}
func (r *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)
}

318
series.go Normal file
View 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 (
"math"
"strings"
"github.com/dustin/go-humanize"
"git.smarteching.com/zeni/go-chart/v2"
)
type SeriesData struct {
// The value of series data
Value float64
// The style of series data
Style Style
}
// NewSeriesListDataFromValues returns a series list
func NewSeriesListDataFromValues(values [][]float64, chartType ...string) SeriesList {
seriesList := make(SeriesList, len(values))
for index, value := range values {
seriesList[index] = NewSeriesFromValues(value, chartType...)
}
return seriesList
}
// NewSeriesFromValues returns a series
func NewSeriesFromValues(values []float64, chartType ...string) Series {
s := Series{
Data: NewSeriesDataFromValues(values),
}
if len(chartType) != 0 {
s.Type = chartType[0]
}
return s
}
// NewSeriesDataFromValues return a series data
func NewSeriesDataFromValues(values []float64) []SeriesData {
data := make([]SeriesData, len(values))
for index, value := range values {
data[index] = SeriesData{
Value: value,
}
}
return data
}
type SeriesLabel struct {
// Data label formatter, which supports string template.
// {b}: the name of a data item.
// {c}: the value of a data item.
// {d}: the percent of a data item(pie chart).
Formatter string
// The color for label
Color Color
// Show flag for label
Show bool
// Distance to the host graphic element.
Distance int
// The position of label
Position string
// The offset of label's position
Offset Box
// The font size of label
FontSize float64
}
const (
SeriesMarkDataTypeMax = "max"
SeriesMarkDataTypeMin = "min"
SeriesMarkDataTypeAverage = "average"
)
type SeriesMarkData struct {
// The mark data type, it can be "max", "min", "average".
// The "average" is only for mark line
Type string
}
type SeriesMarkPoint struct {
// The width of symbol, default value is 30
SymbolSize int
// The mark data of series mark point
Data []SeriesMarkData
}
type SeriesMarkLine struct {
// The mark data of series mark line
Data []SeriesMarkData
}
type Series struct {
index int
// The type of series, it can be "line", "bar" or "pie".
// Default value is "line"
Type string
// The data list of series
Data []SeriesData
// The Y axis index, it should be 0 or 1.
// Default value is 0
AxisIndex int
// The style for series
Style chart.Style
// The label for series
Label SeriesLabel
// The name of series
Name string
// Radius for Pie chart, e.g.: 40%, default is "40%"
Radius string
// Round for bar chart
RoundRadius int
// Mark point for series
MarkPoint SeriesMarkPoint
// Make line for series
MarkLine SeriesMarkLine
// Max value of series
Min *float64
// Min value of series
Max *float64
}
type SeriesList []Series
func (sl SeriesList) init() {
if len(sl) == 0 {
return
}
if sl[len(sl)-1].index != 0 {
return
}
for i := 0; i < len(sl); i++ {
if sl[i].Type == "" {
sl[i].Type = ChartTypeLine
}
sl[i].index = i
}
}
func (sl SeriesList) Filter(chartType string) SeriesList {
arr := make(SeriesList, 0)
for index, item := range sl {
if item.Type == chartType {
arr = append(arr, sl[index])
}
}
return arr
}
// GetMaxMin get max and min value of series list
func (sl SeriesList) GetMaxMin(axisIndex int) (float64, float64) {
min := math.MaxFloat64
max := -math.MaxFloat64
for _, series := range sl {
if series.AxisIndex != axisIndex {
continue
}
for _, item := range series.Data {
// 如果为空值,忽略
if item.Value == nullValue {
continue
}
if item.Value > max {
max = item.Value
}
if item.Value < min {
min = item.Value
}
}
}
return max, min
}
type PieSeriesOption struct {
Radius string
Label SeriesLabel
Names []string
}
func NewPieSeriesList(values []float64, opts ...PieSeriesOption) SeriesList {
result := make([]Series, len(values))
var opt PieSeriesOption
if len(opts) != 0 {
opt = opts[0]
}
for index, v := range values {
name := ""
if index < len(opt.Names) {
name = opt.Names[index]
}
s := Series{
Type: ChartTypePie,
Data: []SeriesData{
{
Value: v,
},
},
Radius: opt.Radius,
Label: opt.Label,
Name: name,
}
result[index] = s
}
return result
}
type seriesSummary struct {
// The index of max value
MaxIndex int
// The max value
MaxValue float64
// The index of min value
MinIndex int
// The min value
MinValue float64
// THe average value
AverageValue float64
}
// Summary get summary of series
func (s *Series) Summary() seriesSummary {
minIndex := -1
maxIndex := -1
minValue := math.MaxFloat64
maxValue := -math.MaxFloat64
sum := float64(0)
for j, item := range s.Data {
if item.Value < minValue {
minIndex = j
minValue = item.Value
}
if item.Value > maxValue {
maxIndex = j
maxValue = item.Value
}
sum += item.Value
}
return seriesSummary{
MaxIndex: maxIndex,
MaxValue: maxValue,
MinIndex: minIndex,
MinValue: minValue,
AverageValue: sum / float64(len(s.Data)),
}
}
// Names returns the names of series list
func (sl SeriesList) Names() []string {
names := make([]string, len(sl))
for index, s := range sl {
names[index] = s.Name
}
return names
}
// LabelFormatter label formatter
type LabelFormatter func(index int, value float64, percent float64) string
// NewPieLabelFormatter returns a pie label formatter
func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter {
if len(layout) == 0 {
layout = "{b}: {d}"
}
return NewLabelFormatter(seriesNames, layout)
}
// NewFunnelLabelFormatter returns a funner label formatter
func NewFunnelLabelFormatter(seriesNames []string, layout string) LabelFormatter {
if len(layout) == 0 {
layout = "{b}({d})"
}
return NewLabelFormatter(seriesNames, layout)
}
// NewValueLabelFormatter returns a value formatter
func NewValueLabelFormatter(seriesNames []string, layout string) LabelFormatter {
if len(layout) == 0 {
layout = "{c}"
}
return NewLabelFormatter(seriesNames, layout)
}
// NewLabelFormatter returns a label formaatter
func NewLabelFormatter(seriesNames []string, layout string) LabelFormatter {
return func(index int, value, percent float64) string {
// 如果无percent的则设置为<0
percentText := ""
if percent >= 0 {
percentText = humanize.FtoaWithDigits(percent*100, 2) + "%"
}
valueText := humanize.FtoaWithDigits(value, 2)
name := ""
if len(seriesNames) > index {
name = seriesNames[index]
}
text := strings.ReplaceAll(layout, "{c}", valueText)
text = strings.ReplaceAll(text, "{d}", percentText)
text = strings.ReplaceAll(text, "{b}", name)
return text
}
}

148
series_label.go Normal file
View 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: &params.Label,
theme: params.Theme,
font: params.Font,
values: make([]labelRenderValue, 0),
}
}
func (o *SeriesLabelPainter) Add(value LabelValue) {
label := o.label
distance := label.Distance
if distance == 0 {
distance = 5
}
text := NewValueLabelFormatter(o.seriesNames, label.Formatter)(value.Index, value.Value, -1)
labelStyle := Style{
FontColor: o.theme.GetTextColor(),
FontSize: labelFontSize,
Font: o.font,
}
if value.FontSize != 0 {
labelStyle.FontSize = value.FontSize
}
if !value.FontColor.IsZero() {
label.Color = value.FontColor
}
if !label.Color.IsZero() {
labelStyle.FontColor = label.Color
}
p := o.p
p.OverrideDrawingStyle(labelStyle)
rotated := value.Radians != 0
if rotated {
p.SetTextRotation(value.Radians)
}
textBox := p.MeasureText(text)
renderValue := labelRenderValue{
Text: text,
Style: labelStyle,
X: value.X,
Y: value.Y,
Radians: value.Radians,
}
if value.Orient != OrientHorizontal {
renderValue.X -= textBox.Width() >> 1
renderValue.Y -= distance
} else {
renderValue.X += distance
renderValue.Y += textBox.Height() >> 1
renderValue.Y -= 2
}
if rotated {
renderValue.X = value.X + textBox.Width()>>1 - 1
p.ClearTextRotation()
} else {
if textBox.Width()%2 != 0 {
renderValue.X++
}
}
renderValue.X += value.Offset.Left
renderValue.Y += value.Offset.Top
o.values = append(o.values, renderValue)
}
func (o *SeriesLabelPainter) Render() (Box, error) {
for _, item := range o.values {
o.p.OverrideTextStyle(item.Style)
if item.Radians != 0 {
o.p.TextRotation(item.Text, item.X, item.Y, item.Radians)
} else {
o.p.Text(item.Text, item.X, item.Y)
}
}
return chart.BoxZero, nil
}

89
series_test.go Normal file
View file

@ -0,0 +1,89 @@
// 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 TestNewSeriesListDataFromValues(t *testing.T) {
assert := assert.New(t)
assert.Equal(SeriesList{
{
Type: ChartTypeBar,
Data: []SeriesData{
{
Value: 1.0,
},
},
},
}, NewSeriesListDataFromValues([][]float64{
{
1,
},
}, ChartTypeBar))
}
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.Equal("a: 12%", NewPieLabelFormatter([]string{
"a",
"b",
}, "")(0, 10, 0.12))
assert.Equal("10", NewValueLabelFormatter([]string{
"a",
"b",
}, "")(0, 10, 0.12))
}

254
start_zh.md Normal file
View 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
View 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 = &currentStyle
}
p.SetStyle(*cellStyle)
x := values[index]
y := currentHeight + cellPadding.Top
width := values[index+1] - x
x += cellPadding.Left
width -= paddingWidth
box := p.TextFit(text, x, y+int(fontSize), width, getTextAlign(index))
// 计算最高的高度
if box.Height()+paddingHeight > cellMaxHeight {
cellMaxHeight = box.Height() + paddingHeight
}
}
return cellMaxHeight
}
// 表头的处理
headerHeight = renderTableCells(textStyle, 0, opt.Header, height, padding)
height += headerHeight
info.HeaderHeight = headerHeight
// 表格内容的处理
textStyle.FontColor = fontColor
textStyle.FillColor = fontColor
for index, textList := range opt.Data {
cellHeight := renderTableCells(textStyle, index+1, textList, height, padding)
info.RowHeights = append(info.RowHeights, cellHeight)
height += cellHeight
}
info.Width = p.Width()
info.Height = height
return &info, nil
}
func (t *tableChart) renderWithInfo(info *renderInfo) (Box, error) {
p := t.p
opt := t.opt
if !opt.BackgroundColor.IsZero() {
p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
}
headerBGColor := opt.HeaderBackgroundColor
if headerBGColor.IsZero() {
headerBGColor = tableDefaultSetting.HeaderColor
}
// 如果设置表头背景色
p.SetBackground(info.Width, info.HeaderHeight, headerBGColor, true)
currentHeight := info.HeaderHeight
rowColors := opt.RowBackgroundColors
if rowColors == nil {
rowColors = tableDefaultSetting.RowColors
}
for index, h := range info.RowHeights {
color := rowColors[index%len(rowColors)]
child := p.Child(PainterPaddingOption(Box{
Top: currentHeight,
}))
child.SetBackground(p.Width(), h, color, true)
currentHeight += h
}
// 根据是否有设置表格样式调整背景色
getCellStyle := opt.CellStyle
if getCellStyle != nil {
arr := [][]string{
opt.Header,
}
arr = append(arr, opt.Data...)
top := 0
heights := []int{
info.HeaderHeight,
}
heights = append(heights, info.RowHeights...)
// 循环所有表格单元,生成背景色
for i, textList := range arr {
left := 0
for j, v := range textList {
style := getCellStyle(TableCell{
Text: v,
Row: i,
Column: j,
})
if style != nil && !style.FillColor.IsZero() {
padding := style.Padding
child := p.Child(PainterPaddingOption(Box{
Top: top + padding.Top,
Left: left + padding.Left,
}))
w := info.ColumnWidths[j] - padding.Left - padding.Top
h := heights[i] - padding.Top - padding.Bottom
child.SetBackground(w, h, style.FillColor, true)
}
left += info.ColumnWidths[j]
}
top += heights[i]
}
}
_, err := t.render()
if err != nil {
return BoxZero, err
}
return Box{
Right: info.Width,
Bottom: info.Height,
}, nil
}
func (t *tableChart) Render() (Box, error) {
p := t.p
opt := t.opt
if !opt.BackgroundColor.IsZero() {
p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
}
if opt.Font == nil && opt.FontFamily != "" {
opt.Font, _ = GetFont(opt.FontFamily)
}
r := p.render
fn := chart.PNG
if p.outputType == ChartOutputSVG {
fn = chart.SVG
}
newRender, err := fn(p.Width(), 100)
if err != nil {
return BoxZero, err
}
p.render = newRender
info, err := t.render()
if err != nil {
return BoxZero, err
}
p.render = r
return t.renderWithInfo(info)
}

140
table_test.go Normal file
View 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))
}
}

332
theme.go Normal file
View file

@ -0,0 +1,332 @@
// 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/drawing"
)
const ThemeDark = "dark"
const ThemeLight = "light"
const ThemeGrafana = "grafana"
const ThemeAnt = "ant"
type ColorPalette interface {
IsDark() bool
GetAxisStrokeColor() Color
SetAxisStrokeColor(Color)
GetAxisSplitLineColor() Color
SetAxisSplitLineColor(Color)
GetSeriesColor(int) Color
SetSeriesColor([]Color)
GetBackgroundColor() Color
SetBackgroundColor(Color)
GetTextColor() Color
SetTextColor(Color)
GetFontSize() float64
SetFontSize(float64)
GetFont() *truetype.Font
SetFont(*truetype.Font)
}
type themeColorPalette struct {
isDarkMode bool
axisStrokeColor Color
axisSplitLineColor Color
backgroundColor Color
textColor Color
seriesColors []Color
fontSize float64
font *truetype.Font
}
type ThemeOption struct {
IsDarkMode bool
AxisStrokeColor Color
AxisSplitLineColor Color
BackgroundColor Color
TextColor Color
SeriesColors []Color
}
var palettes = map[string]*themeColorPalette{}
const defaultFontSize = 12.0
var defaultTheme ColorPalette
var defaultLightFontColor = drawing.Color{
R: 70,
G: 70,
B: 70,
A: 255,
}
var defaultDarkFontColor = drawing.Color{
R: 238,
G: 238,
B: 238,
A: 255,
}
func init() {
echartSeriesColors := []Color{
parseColor("#5470c6"),
parseColor("#91cc75"),
parseColor("#fac858"),
parseColor("#ee6666"),
parseColor("#73c0de"),
parseColor("#3ba272"),
parseColor("#fc8452"),
parseColor("#9a60b4"),
parseColor("#ea7ccc"),
}
grafanaSeriesColors := []Color{
parseColor("#7EB26D"),
parseColor("#EAB839"),
parseColor("#6ED0E0"),
parseColor("#EF843C"),
parseColor("#E24D42"),
parseColor("#1F78C1"),
parseColor("#705DA0"),
parseColor("#508642"),
}
antSeriesColors := []Color{
parseColor("#5b8ff9"),
parseColor("#5ad8a6"),
parseColor("#5d7092"),
parseColor("#f6bd16"),
parseColor("#6f5ef9"),
parseColor("#6dc8ec"),
parseColor("#945fb9"),
parseColor("#ff9845"),
}
AddTheme(
ThemeDark,
ThemeOption{
IsDarkMode: true,
AxisStrokeColor: Color{
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)
}
// SetDefaultTheme sets default theme
func SetDefaultTheme(name string) {
defaultTheme = NewTheme(name)
}
func AddTheme(name string, opt ThemeOption) {
palettes[name] = &themeColorPalette{
isDarkMode: opt.IsDarkMode,
axisStrokeColor: opt.AxisStrokeColor,
axisSplitLineColor: opt.AxisSplitLineColor,
backgroundColor: opt.BackgroundColor,
textColor: opt.TextColor,
seriesColors: opt.SeriesColors,
}
}
func NewTheme(name string) ColorPalette {
p, ok := palettes[name]
if !ok {
p = palettes[ThemeLight]
}
clone := *p
return &clone
}
func (t *themeColorPalette) IsDark() bool {
return t.isDarkMode
}
func (t *themeColorPalette) GetAxisStrokeColor() Color {
return t.axisStrokeColor
}
func (t *themeColorPalette) SetAxisStrokeColor(c Color) {
t.axisStrokeColor = c
}
func (t *themeColorPalette) GetAxisSplitLineColor() Color {
return t.axisSplitLineColor
}
func (t *themeColorPalette) SetAxisSplitLineColor(c Color) {
t.axisSplitLineColor = c
}
func (t *themeColorPalette) GetSeriesColor(index int) Color {
colors := t.seriesColors
return colors[index%len(colors)]
}
func (t *themeColorPalette) SetSeriesColor(colors []Color) {
t.seriesColors = colors
}
func (t *themeColorPalette) GetBackgroundColor() Color {
return t.backgroundColor
}
func (t *themeColorPalette) SetBackgroundColor(c Color) {
t.backgroundColor = c
}
func (t *themeColorPalette) GetTextColor() Color {
return t.textColor
}
func (t *themeColorPalette) SetTextColor(c Color) {
t.textColor = c
}
func (t *themeColorPalette) GetFontSize() float64 {
if t.fontSize != 0 {
return t.fontSize
}
return defaultFontSize
}
func (t *themeColorPalette) SetFontSize(fontSize float64) {
t.fontSize = fontSize
}
func (t *themeColorPalette) GetFont() *truetype.Font {
if t.font != nil {
return t.font
}
f, _ := GetDefaultFont()
return f
}
func (t *themeColorPalette) SetFont(f *truetype.Font) {
t.font = f
}

197
title.go Normal file
View file

@ -0,0 +1,197 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"strconv"
"strings"
"github.com/golang/freetype/truetype"
)
type TitleOption struct {
// The theme of chart
Theme ColorPalette
// Title text, support \n for new line
Text string
// Subtitle text, support \n for new line
Subtext string
// Distance between title component and the left side of the container.
// It can be pixel value: 20, percentage value: 20%,
// or position value: right, center.
Left string
// Distance between title component and the top side of the container.
// It can be pixel value: 20.
Top string
// The font of label
Font *truetype.Font
// The font size of label
FontSize float64
// The color of label
FontColor Color
// The subtext font size of label
SubtextFontSize float64
// The subtext font color of label
SubtextFontColor Color
}
type titleMeasureOption struct {
width int
height int
text string
style Style
}
func splitTitleText(text string) []string {
arr := strings.Split(text, "\n")
result := make([]string, 0)
for _, v := range arr {
v = strings.TrimSpace(v)
if v == "" {
continue
}
result = append(result, v)
}
return result
}
type titlePainter struct {
p *Painter
opt *TitleOption
}
// NewTitlePainter returns a title renderer
func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter {
return &titlePainter{
p: p,
opt: &opt,
}
}
func (t *titlePainter) Render() (Box, error) {
opt := t.opt
p := t.p
theme := opt.Theme
if theme == nil {
theme = p.theme
}
if opt.Text == "" && opt.Subtext == "" {
return BoxZero, nil
}
measureOptions := make([]titleMeasureOption, 0)
if opt.Font == nil {
opt.Font = theme.GetFont()
}
if opt.FontColor.IsZero() {
opt.FontColor = theme.GetTextColor()
}
if opt.FontSize == 0 {
opt.FontSize = theme.GetFontSize()
}
if opt.SubtextFontColor.IsZero() {
opt.SubtextFontColor = opt.FontColor
}
if opt.SubtextFontSize == 0 {
opt.SubtextFontSize = opt.FontSize
}
titleTextStyle := Style{
Font: opt.Font,
FontSize: opt.FontSize,
FontColor: opt.FontColor,
}
// 主标题
for _, v := range splitTitleText(opt.Text) {
measureOptions = append(measureOptions, titleMeasureOption{
text: v,
style: titleTextStyle,
})
}
subtextStyle := Style{
Font: opt.Font,
FontSize: opt.SubtextFontSize,
FontColor: opt.SubtextFontColor,
}
// 副标题
for _, v := range splitTitleText(opt.Subtext) {
measureOptions = append(measureOptions, titleMeasureOption{
text: v,
style: subtextStyle,
})
}
textMaxWidth := 0
textMaxHeight := 0
for index, item := range measureOptions {
p.OverrideTextStyle(item.style)
textBox := p.MeasureText(item.text)
w := textBox.Width()
h := textBox.Height()
if w > textMaxWidth {
textMaxWidth = w
}
if h > textMaxHeight {
textMaxHeight = h
}
measureOptions[index].height = h
measureOptions[index].width = w
}
width := textMaxWidth
titleX := 0
switch opt.Left {
case PositionRight:
titleX = p.Width() - textMaxWidth
case PositionCenter:
titleX = p.Width()>>1 - (textMaxWidth >> 1)
default:
if strings.HasSuffix(opt.Left, "%") {
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
titleX = p.Width() * value / 100
} else {
value, _ := strconv.Atoi(opt.Left)
titleX = value
}
}
titleY := 0
// TODO TOP 暂只支持数值
if opt.Top != "" {
value, _ := strconv.Atoi(opt.Top)
titleY += value
}
for _, item := range measureOptions {
p.OverrideTextStyle(item.style)
x := titleX + (textMaxWidth-item.width)>>1
y := titleY + item.height
p.Text(item.text, x, y)
titleY += item.height
}
return Box{
Bottom: titleY,
Right: titleX + width,
}, nil
}

93
title_test.go Normal file
View file

@ -0,0 +1,93 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTitleRenderer(t *testing.T) {
assert := assert.New(t)
tests := []struct {
render func(*Painter) ([]byte, error)
result string
}{
{
render: func(p *Painter) ([]byte, error) {
_, err := NewTitlePainter(p, TitleOption{
Text: "title",
Subtext: "subTitle",
Left: "20",
Top: "20",
}).Render()
if err != nil {
return nil, err
}
return p.Bytes()
},
result: "<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>",
},
{
render: func(p *Painter) ([]byte, error) {
_, err := NewTitlePainter(p, TitleOption{
Text: "title",
Subtext: "subTitle",
Left: "20%",
Top: "20",
}).Render()
if err != nil {
return nil, err
}
return p.Bytes()
},
result: "<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>",
},
{
render: func(p *Painter) ([]byte, error) {
_, err := NewTitlePainter(p, TitleOption{
Text: "title",
Subtext: "subTitle",
Left: PositionRight,
}).Render()
if err != nil {
return nil, err
}
return p.Bytes()
},
result: "<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 {
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))
}
}

271
util.go Normal file
View 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
View 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,
}))
}

View file

@ -1,71 +0,0 @@
body {
font-family: BlinkMacSystemFont,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
height: 100vh;
background-color: #242424;
}
.header {
height: 60px;
line-height: 60px;
color: #fff;
font-size: 16px;
background-color: #383838;
text-indent: 2em;
}
.header span {
margin-left: 50px;
margin-right: 5px;
}
.codeWrapper {
position: fixed;
left: 0;
right: 50%;
top: 60px;
bottom: 50px;
background-color: #d6dbe3;
}
.previewWrapper {
position: fixed;
left: 50%;
right: 0;
top: 60px;
bottom: 50px;
}
.optionTips {
position: absolute;
top: 3px;
right: 10px;
}
.previewTips {
position: absolute;
top: 3px;
left: 10px;
color: #fff;
}
.run {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 50px;
line-height: 50px;
background-color: #0052D9;
color: #fff;
text-align: center;
font-size: 16px;
text-decoration: none;
}
.run:active, .run:visited {
color: #fff;
}
#svg {
position: absolute;
left: 0;
right: 0;
top: 50%;
margin-top: -200px;
}
#svg svg, #svg img {
display: block;
margin: auto;
}

View file

@ -1,42 +0,0 @@
<!doctype html>
<html lang=zh>
<head>
<link rel="stylesheet" type="text/css" href="https://cdn.staticfile.org/normalize/0/normalize.min.css" />
<link rel="stylesheet" type="text/css" href="https://cdn.staticfile.org/codemirror/2.33.0/codemirror.css" />
<link rel="stylesheet" type="text/css" href="/static/index.css" />
<script>
var _hmt = _hmt || [];
(function() {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?57f9ba58196c7a5eb5ca761b27817bbd";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
<script src="https://cdn.staticfile.org/codemirror/2.33.0/codemirror.js"></script>
<script src="https://cdn.staticfile.org/axios/0.1.0/axios.min.js"></script>
<script src="/static/javascript.js"></script>
</head>
<body>
<div class="header">Go Charts
<span>选择图表输出格式:</span>
<select id="outputType">
<option value="svg">SVG</option>
<option value="png">PNG</option>
</select>
</div>
<div class="codeWrapper">
<textarea id="codeInput"></textarea>
<div class="optionTips">ECharts配置</div>
</div>
<div class="previewWrapper">
<div id="svg"></div>
<div class="previewTips">图表SVG效果</div>
</div>
<a href="#" class="run" onclick="run()">运行</a>
</body>
<script src="/static/index.js"></script>
</html>

View file

@ -1,50 +0,0 @@
var height = document.body.clientHeight- 110;
var editor = CodeMirror.fromTextArea(document.getElementById("codeInput"), {
lineNumbers: true,
lineWrapping: true,
mode: "javascript"
});
editor.setSize("100%", height);
editor.setValue(`option = {
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [
{
data: [150, 230, 224, 218, 135, 147, 260],
type: 'line'
}
]
};`);
function run() {
var option = editor.getValue();
var data = null;
try {
if (option.indexOf("option = ") !== -1) {
var fn = new Function("var " + option + ";return option;");
data = fn();
} else {
data = JSON.parse(option);
}
} catch (err) {
alert(err.message);
return;
}
var dom = document.getElementById("outputType")
var outputType = dom.value;
axios.post("/?outputType=" + outputType, data).then(function(resp) {
if (outputType == "png") {
document.getElementById("svg").innerHTML = '<img src="data:image/png;base64,' + resp + '" />';
} else {
document.getElementById("svg").innerHTML = resp;
}
}).catch(function(err) {
alert(err.message);
});
}

View file

@ -1,959 +0,0 @@
// CodeMirror, copyright (c) by Marijn Haverbeke and others
// Distributed under an MIT license: https://codemirror.net/LICENSE
(function(mod) {
if (typeof exports == "object" && typeof module == "object") // CommonJS
mod(require("../../lib/codemirror"));
else if (typeof define == "function" && define.amd) // AMD
define(["../../lib/codemirror"], mod);
else // Plain browser env
mod(CodeMirror);
})(function(CodeMirror) {
"use strict";
CodeMirror.defineMode("javascript", function(config, parserConfig) {
var indentUnit = config.indentUnit;
var statementIndent = parserConfig.statementIndent;
var jsonldMode = parserConfig.jsonld;
var jsonMode = parserConfig.json || jsonldMode;
var trackScope = parserConfig.trackScope !== false
var isTS = parserConfig.typescript;
var wordRE = parserConfig.wordCharacters || /[\w$\xa1-\uffff]/;
// Tokenizer
var keywords = function(){
function kw(type) {return {type: type, style: "keyword"};}
var A = kw("keyword a"), B = kw("keyword b"), C = kw("keyword c"), D = kw("keyword d");
var operator = kw("operator"), atom = {type: "atom", style: "atom"};
return {
"if": kw("if"), "while": A, "with": A, "else": B, "do": B, "try": B, "finally": B,
"return": D, "break": D, "continue": D, "new": kw("new"), "delete": C, "void": C, "throw": C,
"debugger": kw("debugger"), "var": kw("var"), "const": kw("var"), "let": kw("var"),
"function": kw("function"), "catch": kw("catch"),
"for": kw("for"), "switch": kw("switch"), "case": kw("case"), "default": kw("default"),
"in": operator, "typeof": operator, "instanceof": operator,
"true": atom, "false": atom, "null": atom, "undefined": atom, "NaN": atom, "Infinity": atom,
"this": kw("this"), "class": kw("class"), "super": kw("atom"),
"yield": C, "export": kw("export"), "import": kw("import"), "extends": C,
"await": C
};
}();
var isOperatorChar = /[+\-*&%=<>!?|~^@]/;
var isJsonldKeyword = /^@(context|id|value|language|type|container|list|set|reverse|index|base|vocab|graph)"/;
function readRegexp(stream) {
var escaped = false, next, inSet = false;
while ((next = stream.next()) != null) {
if (!escaped) {
if (next == "/" && !inSet) return;
if (next == "[") inSet = true;
else if (inSet && next == "]") inSet = false;
}
escaped = !escaped && next == "\\";
}
}
// Used as scratch variables to communicate multiple values without
// consing up tons of objects.
var type, content;
function ret(tp, style, cont) {
type = tp; content = cont;
return style;
}
function tokenBase(stream, state) {
var ch = stream.next();
if (ch == '"' || ch == "'") {
state.tokenize = tokenString(ch);
return state.tokenize(stream, state);
} else if (ch == "." && stream.match(/^\d[\d_]*(?:[eE][+\-]?[\d_]+)?/)) {
return ret("number", "number");
} else if (ch == "." && stream.match("..")) {
return ret("spread", "meta");
} else if (/[\[\]{}\(\),;\:\.]/.test(ch)) {
return ret(ch);
} else if (ch == "=" && stream.eat(">")) {
return ret("=>", "operator");
} else if (ch == "0" && stream.match(/^(?:x[\dA-Fa-f_]+|o[0-7_]+|b[01_]+)n?/)) {
return ret("number", "number");
} else if (/\d/.test(ch)) {
stream.match(/^[\d_]*(?:n|(?:\.[\d_]*)?(?:[eE][+\-]?[\d_]+)?)?/);
return ret("number", "number");
} else if (ch == "/") {
if (stream.eat("*")) {
state.tokenize = tokenComment;
return tokenComment(stream, state);
} else if (stream.eat("/")) {
stream.skipToEnd();
return ret("comment", "comment");
} else if (expressionAllowed(stream, state, 1)) {
readRegexp(stream);
stream.match(/^\b(([gimyus])(?![gimyus]*\2))+\b/);
return ret("regexp", "string-2");
} else {
stream.eat("=");
return ret("operator", "operator", stream.current());
}
} else if (ch == "`") {
state.tokenize = tokenQuasi;
return tokenQuasi(stream, state);
} else if (ch == "#" && stream.peek() == "!") {
stream.skipToEnd();
return ret("meta", "meta");
} else if (ch == "#" && stream.eatWhile(wordRE)) {
return ret("variable", "property")
} else if (ch == "<" && stream.match("!--") ||
(ch == "-" && stream.match("->") && !/\S/.test(stream.string.slice(0, stream.start)))) {
stream.skipToEnd()
return ret("comment", "comment")
} else if (isOperatorChar.test(ch)) {
if (ch != ">" || !state.lexical || state.lexical.type != ">") {
if (stream.eat("=")) {
if (ch == "!" || ch == "=") stream.eat("=")
} else if (/[<>*+\-|&?]/.test(ch)) {
stream.eat(ch)
if (ch == ">") stream.eat(ch)
}
}
if (ch == "?" && stream.eat(".")) return ret(".")
return ret("operator", "operator", stream.current());
} else if (wordRE.test(ch)) {
stream.eatWhile(wordRE);
var word = stream.current()
if (state.lastType != ".") {
if (keywords.propertyIsEnumerable(word)) {
var kw = keywords[word]
return ret(kw.type, kw.style, word)
}
if (word == "async" && stream.match(/^(\s|\/\*([^*]|\*(?!\/))*?\*\/)*[\[\(\w]/, false))
return ret("async", "keyword", word)
}
return ret("variable", "variable", word)
}
}
function tokenString(quote) {
return function(stream, state) {
var escaped = false, next;
if (jsonldMode && stream.peek() == "@" && stream.match(isJsonldKeyword)){
state.tokenize = tokenBase;
return ret("jsonld-keyword", "meta");
}
while ((next = stream.next()) != null) {
if (next == quote && !escaped) break;
escaped = !escaped && next == "\\";
}
if (!escaped) state.tokenize = tokenBase;
return ret("string", "string");
};
}
function tokenComment(stream, state) {
var maybeEnd = false, ch;
while (ch = stream.next()) {
if (ch == "/" && maybeEnd) {
state.tokenize = tokenBase;
break;
}
maybeEnd = (ch == "*");
}
return ret("comment", "comment");
}
function tokenQuasi(stream, state) {
var escaped = false, next;
while ((next = stream.next()) != null) {
if (!escaped && (next == "`" || next == "$" && stream.eat("{"))) {
state.tokenize = tokenBase;
break;
}
escaped = !escaped && next == "\\";
}
return ret("quasi", "string-2", stream.current());
}
var brackets = "([{}])";
// This is a crude lookahead trick to try and notice that we're
// parsing the argument patterns for a fat-arrow function before we
// actually hit the arrow token. It only works if the arrow is on
// the same line as the arguments and there's no strange noise
// (comments) in between. Fallback is to only notice when we hit the
// arrow, and not declare the arguments as locals for the arrow
// body.
function findFatArrow(stream, state) {
if (state.fatArrowAt) state.fatArrowAt = null;
var arrow = stream.string.indexOf("=>", stream.start);
if (arrow < 0) return;
if (isTS) { // Try to skip TypeScript return type declarations after the arguments
var m = /:\s*(?:\w+(?:<[^>]*>|\[\])?|\{[^}]*\})\s*$/.exec(stream.string.slice(stream.start, arrow))
if (m) arrow = m.index
}
var depth = 0, sawSomething = false;
for (var pos = arrow - 1; pos >= 0; --pos) {
var ch = stream.string.charAt(pos);
var bracket = brackets.indexOf(ch);
if (bracket >= 0 && bracket < 3) {
if (!depth) { ++pos; break; }
if (--depth == 0) { if (ch == "(") sawSomething = true; break; }
} else if (bracket >= 3 && bracket < 6) {
++depth;
} else if (wordRE.test(ch)) {
sawSomething = true;
} else if (/["'\/`]/.test(ch)) {
for (;; --pos) {
if (pos == 0) return
var next = stream.string.charAt(pos - 1)
if (next == ch && stream.string.charAt(pos - 2) != "\\") { pos--; break }
}
} else if (sawSomething && !depth) {
++pos;
break;
}
}
if (sawSomething && !depth) state.fatArrowAt = pos;
}
// Parser
var atomicTypes = {"atom": true, "number": true, "variable": true, "string": true,
"regexp": true, "this": true, "import": true, "jsonld-keyword": true};
function JSLexical(indented, column, type, align, prev, info) {
this.indented = indented;
this.column = column;
this.type = type;
this.prev = prev;
this.info = info;
if (align != null) this.align = align;
}
function inScope(state, varname) {
if (!trackScope) return false
for (var v = state.localVars; v; v = v.next)
if (v.name == varname) return true;
for (var cx = state.context; cx; cx = cx.prev) {
for (var v = cx.vars; v; v = v.next)
if (v.name == varname) return true;
}
}
function parseJS(state, style, type, content, stream) {
var cc = state.cc;
// Communicate our context to the combinators.
// (Less wasteful than consing up a hundred closures on every call.)
cx.state = state; cx.stream = stream; cx.marked = null, cx.cc = cc; cx.style = style;
if (!state.lexical.hasOwnProperty("align"))
state.lexical.align = true;
while(true) {
var combinator = cc.length ? cc.pop() : jsonMode ? expression : statement;
if (combinator(type, content)) {
while(cc.length && cc[cc.length - 1].lex)
cc.pop()();
if (cx.marked) return cx.marked;
if (type == "variable" && inScope(state, content)) return "variable-2";
return style;
}
}
}
// Combinator utils
var cx = {state: null, column: null, marked: null, cc: null};
function pass() {
for (var i = arguments.length - 1; i >= 0; i--) cx.cc.push(arguments[i]);
}
function cont() {
pass.apply(null, arguments);
return true;
}
function inList(name, list) {
for (var v = list; v; v = v.next) if (v.name == name) return true
return false;
}
function register(varname) {
var state = cx.state;
cx.marked = "def";
if (!trackScope) return
if (state.context) {
if (state.lexical.info == "var" && state.context && state.context.block) {
// FIXME function decls are also not block scoped
var newContext = registerVarScoped(varname, state.context)
if (newContext != null) {
state.context = newContext
return
}
} else if (!inList(varname, state.localVars)) {
state.localVars = new Var(varname, state.localVars)
return
}
}
// Fall through means this is global
if (parserConfig.globalVars && !inList(varname, state.globalVars))
state.globalVars = new Var(varname, state.globalVars)
}
function registerVarScoped(varname, context) {
if (!context) {
return null
} else if (context.block) {
var inner = registerVarScoped(varname, context.prev)
if (!inner) return null
if (inner == context.prev) return context
return new Context(inner, context.vars, true)
} else if (inList(varname, context.vars)) {
return context
} else {
return new Context(context.prev, new Var(varname, context.vars), false)
}
}
function isModifier(name) {
return name == "public" || name == "private" || name == "protected" || name == "abstract" || name == "readonly"
}
// Combinators
function Context(prev, vars, block) { this.prev = prev; this.vars = vars; this.block = block }
function Var(name, next) { this.name = name; this.next = next }
var defaultVars = new Var("this", new Var("arguments", null))
function pushcontext() {
cx.state.context = new Context(cx.state.context, cx.state.localVars, false)
cx.state.localVars = defaultVars
}
function pushblockcontext() {
cx.state.context = new Context(cx.state.context, cx.state.localVars, true)
cx.state.localVars = null
}
function popcontext() {
cx.state.localVars = cx.state.context.vars
cx.state.context = cx.state.context.prev
}
popcontext.lex = true
function pushlex(type, info) {
var result = function() {
var state = cx.state, indent = state.indented;
if (state.lexical.type == "stat") indent = state.lexical.indented;
else for (var outer = state.lexical; outer && outer.type == ")" && outer.align; outer = outer.prev)
indent = outer.indented;
state.lexical = new JSLexical(indent, cx.stream.column(), type, null, state.lexical, info);
};
result.lex = true;
return result;
}
function poplex() {
var state = cx.state;
if (state.lexical.prev) {
if (state.lexical.type == ")")
state.indented = state.lexical.indented;
state.lexical = state.lexical.prev;
}
}
poplex.lex = true;
function expect(wanted) {
function exp(type) {
if (type == wanted) return cont();
else if (wanted == ";" || type == "}" || type == ")" || type == "]") return pass();
else return cont(exp);
};
return exp;
}
function statement(type, value) {
if (type == "var") return cont(pushlex("vardef", value), vardef, expect(";"), poplex);
if (type == "keyword a") return cont(pushlex("form"), parenExpr, statement, poplex);
if (type == "keyword b") return cont(pushlex("form"), statement, poplex);
if (type == "keyword d") return cx.stream.match(/^\s*$/, false) ? cont() : cont(pushlex("stat"), maybeexpression, expect(";"), poplex);
if (type == "debugger") return cont(expect(";"));
if (type == "{") return cont(pushlex("}"), pushblockcontext, block, poplex, popcontext);
if (type == ";") return cont();
if (type == "if") {
if (cx.state.lexical.info == "else" && cx.state.cc[cx.state.cc.length - 1] == poplex)
cx.state.cc.pop()();
return cont(pushlex("form"), parenExpr, statement, poplex, maybeelse);
}
if (type == "function") return cont(functiondef);
if (type == "for") return cont(pushlex("form"), pushblockcontext, forspec, statement, popcontext, poplex);
if (type == "class" || (isTS && value == "interface")) {
cx.marked = "keyword"
return cont(pushlex("form", type == "class" ? type : value), className, poplex)
}
if (type == "variable") {
if (isTS && value == "declare") {
cx.marked = "keyword"
return cont(statement)
} else if (isTS && (value == "module" || value == "enum" || value == "type") && cx.stream.match(/^\s*\w/, false)) {
cx.marked = "keyword"
if (value == "enum") return cont(enumdef);
else if (value == "type") return cont(typename, expect("operator"), typeexpr, expect(";"));
else return cont(pushlex("form"), pattern, expect("{"), pushlex("}"), block, poplex, poplex)
} else if (isTS && value == "namespace") {
cx.marked = "keyword"
return cont(pushlex("form"), expression, statement, poplex)
} else if (isTS && value == "abstract") {
cx.marked = "keyword"
return cont(statement)
} else {
return cont(pushlex("stat"), maybelabel);
}
}
if (type == "switch") return cont(pushlex("form"), parenExpr, expect("{"), pushlex("}", "switch"), pushblockcontext,
block, poplex, poplex, popcontext);
if (type == "case") return cont(expression, expect(":"));
if (type == "default") return cont(expect(":"));
if (type == "catch") return cont(pushlex("form"), pushcontext, maybeCatchBinding, statement, poplex, popcontext);
if (type == "export") return cont(pushlex("stat"), afterExport, poplex);
if (type == "import") return cont(pushlex("stat"), afterImport, poplex);
if (type == "async") return cont(statement)
if (value == "@") return cont(expression, statement)
return pass(pushlex("stat"), expression, expect(";"), poplex);
}
function maybeCatchBinding(type) {
if (type == "(") return cont(funarg, expect(")"))
}
function expression(type, value) {
return expressionInner(type, value, false);
}
function expressionNoComma(type, value) {
return expressionInner(type, value, true);
}
function parenExpr(type) {
if (type != "(") return pass()
return cont(pushlex(")"), maybeexpression, expect(")"), poplex)
}
function expressionInner(type, value, noComma) {
if (cx.state.fatArrowAt == cx.stream.start) {
var body = noComma ? arrowBodyNoComma : arrowBody;
if (type == "(") return cont(pushcontext, pushlex(")"), commasep(funarg, ")"), poplex, expect("=>"), body, popcontext);
else if (type == "variable") return pass(pushcontext, pattern, expect("=>"), body, popcontext);
}
var maybeop = noComma ? maybeoperatorNoComma : maybeoperatorComma;
if (atomicTypes.hasOwnProperty(type)) return cont(maybeop);
if (type == "function") return cont(functiondef, maybeop);
if (type == "class" || (isTS && value == "interface")) { cx.marked = "keyword"; return cont(pushlex("form"), classExpression, poplex); }
if (type == "keyword c" || type == "async") return cont(noComma ? expressionNoComma : expression);
if (type == "(") return cont(pushlex(")"), maybeexpression, expect(")"), poplex, maybeop);
if (type == "operator" || type == "spread") return cont(noComma ? expressionNoComma : expression);
if (type == "[") return cont(pushlex("]"), arrayLiteral, poplex, maybeop);
if (type == "{") return contCommasep(objprop, "}", null, maybeop);
if (type == "quasi") return pass(quasi, maybeop);
if (type == "new") return cont(maybeTarget(noComma));
return cont();
}
function maybeexpression(type) {
if (type.match(/[;\}\)\],]/)) return pass();
return pass(expression);
}
function maybeoperatorComma(type, value) {
if (type == ",") return cont(maybeexpression);
return maybeoperatorNoComma(type, value, false);
}
function maybeoperatorNoComma(type, value, noComma) {
var me = noComma == false ? maybeoperatorComma : maybeoperatorNoComma;
var expr = noComma == false ? expression : expressionNoComma;
if (type == "=>") return cont(pushcontext, noComma ? arrowBodyNoComma : arrowBody, popcontext);
if (type == "operator") {
if (/\+\+|--/.test(value) || isTS && value == "!") return cont(me);
if (isTS && value == "<" && cx.stream.match(/^([^<>]|<[^<>]*>)*>\s*\(/, false))
return cont(pushlex(">"), commasep(typeexpr, ">"), poplex, me);
if (value == "?") return cont(expression, expect(":"), expr);
return cont(expr);
}
if (type == "quasi") { return pass(quasi, me); }
if (type == ";") return;
if (type == "(") return contCommasep(expressionNoComma, ")", "call", me);
if (type == ".") return cont(property, me);
if (type == "[") return cont(pushlex("]"), maybeexpression, expect("]"), poplex, me);
if (isTS && value == "as") { cx.marked = "keyword"; return cont(typeexpr, me) }
if (type == "regexp") {
cx.state.lastType = cx.marked = "operator"
cx.stream.backUp(cx.stream.pos - cx.stream.start - 1)
return cont(expr)
}
}
function quasi(type, value) {
if (type != "quasi") return pass();
if (value.slice(value.length - 2) != "${") return cont(quasi);
return cont(maybeexpression, continueQuasi);
}
function continueQuasi(type) {
if (type == "}") {
cx.marked = "string-2";
cx.state.tokenize = tokenQuasi;
return cont(quasi);
}
}
function arrowBody(type) {
findFatArrow(cx.stream, cx.state);
return pass(type == "{" ? statement : expression);
}
function arrowBodyNoComma(type) {
findFatArrow(cx.stream, cx.state);
return pass(type == "{" ? statement : expressionNoComma);
}
function maybeTarget(noComma) {
return function(type) {
if (type == ".") return cont(noComma ? targetNoComma : target);
else if (type == "variable" && isTS) return cont(maybeTypeArgs, noComma ? maybeoperatorNoComma : maybeoperatorComma)
else return pass(noComma ? expressionNoComma : expression);
};
}
function target(_, value) {
if (value == "target") { cx.marked = "keyword"; return cont(maybeoperatorComma); }
}
function targetNoComma(_, value) {
if (value == "target") { cx.marked = "keyword"; return cont(maybeoperatorNoComma); }
}
function maybelabel(type) {
if (type == ":") return cont(poplex, statement);
return pass(maybeoperatorComma, expect(";"), poplex);
}
function property(type) {
if (type == "variable") {cx.marked = "property"; return cont();}
}
function objprop(type, value) {
if (type == "async") {
cx.marked = "property";
return cont(objprop);
} else if (type == "variable" || cx.style == "keyword") {
cx.marked = "property";
if (value == "get" || value == "set") return cont(getterSetter);
var m // Work around fat-arrow-detection complication for detecting typescript typed arrow params
if (isTS && cx.state.fatArrowAt == cx.stream.start && (m = cx.stream.match(/^\s*:\s*/, false)))
cx.state.fatArrowAt = cx.stream.pos + m[0].length
return cont(afterprop);
} else if (type == "number" || type == "string") {
cx.marked = jsonldMode ? "property" : (cx.style + " property");
return cont(afterprop);
} else if (type == "jsonld-keyword") {
return cont(afterprop);
} else if (isTS && isModifier(value)) {
cx.marked = "keyword"
return cont(objprop)
} else if (type == "[") {
return cont(expression, maybetype, expect("]"), afterprop);
} else if (type == "spread") {
return cont(expressionNoComma, afterprop);
} else if (value == "*") {
cx.marked = "keyword";
return cont(objprop);
} else if (type == ":") {
return pass(afterprop)
}
}
function getterSetter(type) {
if (type != "variable") return pass(afterprop);
cx.marked = "property";
return cont(functiondef);
}
function afterprop(type) {
if (type == ":") return cont(expressionNoComma);
if (type == "(") return pass(functiondef);
}
function commasep(what, end, sep) {
function proceed(type, value) {
if (sep ? sep.indexOf(type) > -1 : type == ",") {
var lex = cx.state.lexical;
if (lex.info == "call") lex.pos = (lex.pos || 0) + 1;
return cont(function(type, value) {
if (type == end || value == end) return pass()
return pass(what)
}, proceed);
}
if (type == end || value == end) return cont();
if (sep && sep.indexOf(";") > -1) return pass(what)
return cont(expect(end));
}
return function(type, value) {
if (type == end || value == end) return cont();
return pass(what, proceed);
};
}
function contCommasep(what, end, info) {
for (var i = 3; i < arguments.length; i++)
cx.cc.push(arguments[i]);
return cont(pushlex(end, info), commasep(what, end), poplex);
}
function block(type) {
if (type == "}") return cont();
return pass(statement, block);
}
function maybetype(type, value) {
if (isTS) {
if (type == ":") return cont(typeexpr);
if (value == "?") return cont(maybetype);
}
}
function maybetypeOrIn(type, value) {
if (isTS && (type == ":" || value == "in")) return cont(typeexpr)
}
function mayberettype(type) {
if (isTS && type == ":") {
if (cx.stream.match(/^\s*\w+\s+is\b/, false)) return cont(expression, isKW, typeexpr)
else return cont(typeexpr)
}
}
function isKW(_, value) {
if (value == "is") {
cx.marked = "keyword"
return cont()
}
}
function typeexpr(type, value) {
if (value == "keyof" || value == "typeof" || value == "infer" || value == "readonly") {
cx.marked = "keyword"
return cont(value == "typeof" ? expressionNoComma : typeexpr)
}
if (type == "variable" || value == "void") {
cx.marked = "type"
return cont(afterType)
}
if (value == "|" || value == "&") return cont(typeexpr)
if (type == "string" || type == "number" || type == "atom") return cont(afterType);
if (type == "[") return cont(pushlex("]"), commasep(typeexpr, "]", ","), poplex, afterType)
if (type == "{") return cont(pushlex("}"), typeprops, poplex, afterType)
if (type == "(") return cont(commasep(typearg, ")"), maybeReturnType, afterType)
if (type == "<") return cont(commasep(typeexpr, ">"), typeexpr)
if (type == "quasi") { return pass(quasiType, afterType); }
}
function maybeReturnType(type) {
if (type == "=>") return cont(typeexpr)
}
function typeprops(type) {
if (type.match(/[\}\)\]]/)) return cont()
if (type == "," || type == ";") return cont(typeprops)
return pass(typeprop, typeprops)
}
function typeprop(type, value) {
if (type == "variable" || cx.style == "keyword") {
cx.marked = "property"
return cont(typeprop)
} else if (value == "?" || type == "number" || type == "string") {
return cont(typeprop)
} else if (type == ":") {
return cont(typeexpr)
} else if (type == "[") {
return cont(expect("variable"), maybetypeOrIn, expect("]"), typeprop)
} else if (type == "(") {
return pass(functiondecl, typeprop)
} else if (!type.match(/[;\}\)\],]/)) {
return cont()
}
}
function quasiType(type, value) {
if (type != "quasi") return pass();
if (value.slice(value.length - 2) != "${") return cont(quasiType);
return cont(typeexpr, continueQuasiType);
}
function continueQuasiType(type) {
if (type == "}") {
cx.marked = "string-2";
cx.state.tokenize = tokenQuasi;
return cont(quasiType);
}
}
function typearg(type, value) {
if (type == "variable" && cx.stream.match(/^\s*[?:]/, false) || value == "?") return cont(typearg)
if (type == ":") return cont(typeexpr)
if (type == "spread") return cont(typearg)
return pass(typeexpr)
}
function afterType(type, value) {
if (value == "<") return cont(pushlex(">"), commasep(typeexpr, ">"), poplex, afterType)
if (value == "|" || type == "." || value == "&") return cont(typeexpr)
if (type == "[") return cont(typeexpr, expect("]"), afterType)
if (value == "extends" || value == "implements") { cx.marked = "keyword"; return cont(typeexpr) }
if (value == "?") return cont(typeexpr, expect(":"), typeexpr)
}
function maybeTypeArgs(_, value) {
if (value == "<") return cont(pushlex(">"), commasep(typeexpr, ">"), poplex, afterType)
}
function typeparam() {
return pass(typeexpr, maybeTypeDefault)
}
function maybeTypeDefault(_, value) {
if (value == "=") return cont(typeexpr)
}
function vardef(_, value) {
if (value == "enum") {cx.marked = "keyword"; return cont(enumdef)}
return pass(pattern, maybetype, maybeAssign, vardefCont);
}
function pattern(type, value) {
if (isTS && isModifier(value)) { cx.marked = "keyword"; return cont(pattern) }
if (type == "variable") { register(value); return cont(); }
if (type == "spread") return cont(pattern);
if (type == "[") return contCommasep(eltpattern, "]");
if (type == "{") return contCommasep(proppattern, "}");
}
function proppattern(type, value) {
if (type == "variable" && !cx.stream.match(/^\s*:/, false)) {
register(value);
return cont(maybeAssign);
}
if (type == "variable") cx.marked = "property";
if (type == "spread") return cont(pattern);
if (type == "}") return pass();
if (type == "[") return cont(expression, expect(']'), expect(':'), proppattern);
return cont(expect(":"), pattern, maybeAssign);
}
function eltpattern() {
return pass(pattern, maybeAssign)
}
function maybeAssign(_type, value) {
if (value == "=") return cont(expressionNoComma);
}
function vardefCont(type) {
if (type == ",") return cont(vardef);
}
function maybeelse(type, value) {
if (type == "keyword b" && value == "else") return cont(pushlex("form", "else"), statement, poplex);
}
function forspec(type, value) {
if (value == "await") return cont(forspec);
if (type == "(") return cont(pushlex(")"), forspec1, poplex);
}
function forspec1(type) {
if (type == "var") return cont(vardef, forspec2);
if (type == "variable") return cont(forspec2);
return pass(forspec2)
}
function forspec2(type, value) {
if (type == ")") return cont()
if (type == ";") return cont(forspec2)
if (value == "in" || value == "of") { cx.marked = "keyword"; return cont(expression, forspec2) }
return pass(expression, forspec2)
}
function functiondef(type, value) {
if (value == "*") {cx.marked = "keyword"; return cont(functiondef);}
if (type == "variable") {register(value); return cont(functiondef);}
if (type == "(") return cont(pushcontext, pushlex(")"), commasep(funarg, ")"), poplex, mayberettype, statement, popcontext);
if (isTS && value == "<") return cont(pushlex(">"), commasep(typeparam, ">"), poplex, functiondef)
}
function functiondecl(type, value) {
if (value == "*") {cx.marked = "keyword"; return cont(functiondecl);}
if (type == "variable") {register(value); return cont(functiondecl);}
if (type == "(") return cont(pushcontext, pushlex(")"), commasep(funarg, ")"), poplex, mayberettype, popcontext);
if (isTS && value == "<") return cont(pushlex(">"), commasep(typeparam, ">"), poplex, functiondecl)
}
function typename(type, value) {
if (type == "keyword" || type == "variable") {
cx.marked = "type"
return cont(typename)
} else if (value == "<") {
return cont(pushlex(">"), commasep(typeparam, ">"), poplex)
}
}
function funarg(type, value) {
if (value == "@") cont(expression, funarg)
if (type == "spread") return cont(funarg);
if (isTS && isModifier(value)) { cx.marked = "keyword"; return cont(funarg); }
if (isTS && type == "this") return cont(maybetype, maybeAssign)
return pass(pattern, maybetype, maybeAssign);
}
function classExpression(type, value) {
// Class expressions may have an optional name.
if (type == "variable") return className(type, value);
return classNameAfter(type, value);
}
function className(type, value) {
if (type == "variable") {register(value); return cont(classNameAfter);}
}
function classNameAfter(type, value) {
if (value == "<") return cont(pushlex(">"), commasep(typeparam, ">"), poplex, classNameAfter)
if (value == "extends" || value == "implements" || (isTS && type == ",")) {
if (value == "implements") cx.marked = "keyword";
return cont(isTS ? typeexpr : expression, classNameAfter);
}
if (type == "{") return cont(pushlex("}"), classBody, poplex);
}
function classBody(type, value) {
if (type == "async" ||
(type == "variable" &&
(value == "static" || value == "get" || value == "set" || (isTS && isModifier(value))) &&
cx.stream.match(/^\s+[\w$\xa1-\uffff]/, false))) {
cx.marked = "keyword";
return cont(classBody);
}
if (type == "variable" || cx.style == "keyword") {
cx.marked = "property";
return cont(classfield, classBody);
}
if (type == "number" || type == "string") return cont(classfield, classBody);
if (type == "[")
return cont(expression, maybetype, expect("]"), classfield, classBody)
if (value == "*") {
cx.marked = "keyword";
return cont(classBody);
}
if (isTS && type == "(") return pass(functiondecl, classBody)
if (type == ";" || type == ",") return cont(classBody);
if (type == "}") return cont();
if (value == "@") return cont(expression, classBody)
}
function classfield(type, value) {
if (value == "!") return cont(classfield)
if (value == "?") return cont(classfield)
if (type == ":") return cont(typeexpr, maybeAssign)
if (value == "=") return cont(expressionNoComma)
var context = cx.state.lexical.prev, isInterface = context && context.info == "interface"
return pass(isInterface ? functiondecl : functiondef)
}
function afterExport(type, value) {
if (value == "*") { cx.marked = "keyword"; return cont(maybeFrom, expect(";")); }
if (value == "default") { cx.marked = "keyword"; return cont(expression, expect(";")); }
if (type == "{") return cont(commasep(exportField, "}"), maybeFrom, expect(";"));
return pass(statement);
}
function exportField(type, value) {
if (value == "as") { cx.marked = "keyword"; return cont(expect("variable")); }
if (type == "variable") return pass(expressionNoComma, exportField);
}
function afterImport(type) {
if (type == "string") return cont();
if (type == "(") return pass(expression);
if (type == ".") return pass(maybeoperatorComma);
return pass(importSpec, maybeMoreImports, maybeFrom);
}
function importSpec(type, value) {
if (type == "{") return contCommasep(importSpec, "}");
if (type == "variable") register(value);
if (value == "*") cx.marked = "keyword";
return cont(maybeAs);
}
function maybeMoreImports(type) {
if (type == ",") return cont(importSpec, maybeMoreImports)
}
function maybeAs(_type, value) {
if (value == "as") { cx.marked = "keyword"; return cont(importSpec); }
}
function maybeFrom(_type, value) {
if (value == "from") { cx.marked = "keyword"; return cont(expression); }
}
function arrayLiteral(type) {
if (type == "]") return cont();
return pass(commasep(expressionNoComma, "]"));
}
function enumdef() {
return pass(pushlex("form"), pattern, expect("{"), pushlex("}"), commasep(enummember, "}"), poplex, poplex)
}
function enummember() {
return pass(pattern, maybeAssign);
}
function isContinuedStatement(state, textAfter) {
return state.lastType == "operator" || state.lastType == "," ||
isOperatorChar.test(textAfter.charAt(0)) ||
/[,.]/.test(textAfter.charAt(0));
}
function expressionAllowed(stream, state, backUp) {
return state.tokenize == tokenBase &&
/^(?:operator|sof|keyword [bcd]|case|new|export|default|spread|[\[{}\(,;:]|=>)$/.test(state.lastType) ||
(state.lastType == "quasi" && /\{\s*$/.test(stream.string.slice(0, stream.pos - (backUp || 0))))
}
// Interface
return {
startState: function(basecolumn) {
var state = {
tokenize: tokenBase,
lastType: "sof",
cc: [],
lexical: new JSLexical((basecolumn || 0) - indentUnit, 0, "block", false),
localVars: parserConfig.localVars,
context: parserConfig.localVars && new Context(null, null, false),
indented: basecolumn || 0
};
if (parserConfig.globalVars && typeof parserConfig.globalVars == "object")
state.globalVars = parserConfig.globalVars;
return state;
},
token: function(stream, state) {
if (stream.sol()) {
if (!state.lexical.hasOwnProperty("align"))
state.lexical.align = false;
state.indented = stream.indentation();
findFatArrow(stream, state);
}
if (state.tokenize != tokenComment && stream.eatSpace()) return null;
var style = state.tokenize(stream, state);
if (type == "comment") return style;
state.lastType = type == "operator" && (content == "++" || content == "--") ? "incdec" : type;
return parseJS(state, style, type, content, stream);
},
indent: function(state, textAfter) {
if (state.tokenize == tokenComment || state.tokenize == tokenQuasi) return CodeMirror.Pass;
if (state.tokenize != tokenBase) return 0;
var firstChar = textAfter && textAfter.charAt(0), lexical = state.lexical, top
// Kludge to prevent 'maybelse' from blocking lexical scope pops
if (!/^\s*else\b/.test(textAfter)) for (var i = state.cc.length - 1; i >= 0; --i) {
var c = state.cc[i];
if (c == poplex) lexical = lexical.prev;
else if (c != maybeelse && c != popcontext) break;
}
while ((lexical.type == "stat" || lexical.type == "form") &&
(firstChar == "}" || ((top = state.cc[state.cc.length - 1]) &&
(top == maybeoperatorComma || top == maybeoperatorNoComma) &&
!/^[,\.=+\-*:?[\(]/.test(textAfter))))
lexical = lexical.prev;
if (statementIndent && lexical.type == ")" && lexical.prev.type == "stat")
lexical = lexical.prev;
var type = lexical.type, closing = firstChar == type;
if (type == "vardef") return lexical.indented + (state.lastType == "operator" || state.lastType == "," ? lexical.info.length + 1 : 0);
else if (type == "form" && firstChar == "{") return lexical.indented;
else if (type == "form") return lexical.indented + indentUnit;
else if (type == "stat")
return lexical.indented + (isContinuedStatement(state, textAfter) ? statementIndent || indentUnit : 0);
else if (lexical.info == "switch" && !closing && parserConfig.doubleIndentSwitch != false)
return lexical.indented + (/^(?:case|default)\b/.test(textAfter) ? indentUnit : 2 * indentUnit);
else if (lexical.align) return lexical.column + (closing ? 0 : 1);
else return lexical.indented + (closing ? 0 : indentUnit);
},
electricInput: /^\s*(?:case .*?:|default:|\{|\})$/,
blockCommentStart: jsonMode ? null : "/*",
blockCommentEnd: jsonMode ? null : "*/",
blockCommentContinue: jsonMode ? null : " * ",
lineComment: jsonMode ? null : "//",
fold: "brace",
closeBrackets: "()[]{}''\"\"``",
helperType: jsonMode ? "json" : "javascript",
jsonldMode: jsonldMode,
jsonMode: jsonMode,
expressionAllowed: expressionAllowed,
skipExpression: function(state) {
parseJS(state, "atom", "atom", "true", new CodeMirror.StringStream("", 2, null))
}
};
});
// CodeMirror.registerHelper("wordChars", "javascript", /[\w$]/);
CodeMirror.defineMIME("text/javascript", "javascript");
CodeMirror.defineMIME("text/ecmascript", "javascript");
CodeMirror.defineMIME("application/javascript", "javascript");
CodeMirror.defineMIME("application/x-javascript", "javascript");
CodeMirror.defineMIME("application/ecmascript", "javascript");
CodeMirror.defineMIME("application/json", { name: "javascript", json: true });
CodeMirror.defineMIME("application/x-json", { name: "javascript", json: true });
CodeMirror.defineMIME("application/manifest+json", { name: "javascript", json: true })
CodeMirror.defineMIME("application/ld+json", { name: "javascript", jsonld: true });
CodeMirror.defineMIME("text/typescript", { name: "javascript", typescript: true });
CodeMirror.defineMIME("application/typescript", { name: "javascript", typescript: true });
});

105
xaxis.go Normal file
View 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
View 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
View 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))
}
}