diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
deleted file mode 100644
index d620e62..0000000
--- a/.github/workflows/build.yml
+++ /dev/null
@@ -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 }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..ce56fe7
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 2ac8a25..57206ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,4 +14,7 @@
# Dependency directories (remove the comment below to include it)
# vendor/
*.png
-*.svg
\ No newline at end of file
+*.svg
+tmp
+NotoSansSC.ttf
+.vscode
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 3fdd253..0000000
--- a/Dockerfile
+++ /dev/null
@@ -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", "-"]
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 22af142..7b718c4 100644
--- a/Makefile
+++ b/Makefile
@@ -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/
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0650395
--- /dev/null
+++ b/README.md
@@ -0,0 +1,542 @@
+# go-charts
+
+Clone from https://github.com/vicanso/go-charts
+
+[](https://github.com/vicanso/go-charts/blob/master/LICENSE)
+[](https://github.com/vicanso/go-charts/actions)
+
+[中文](./README_zh.md)
+
+`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. 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.
+
+
+
+
+
+
+
+
+
+## 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
+```
diff --git a/README_zh.md b/README_zh.md
new file mode 100644
index 0000000..3f35b97
--- /dev/null
+++ b/README_zh.md
@@ -0,0 +1,576 @@
+# go-charts
+
+[](https://github.com/vicanso/go-charts/blob/master/LICENSE)
+[](https://github.com/vicanso/go-charts/actions)
+
+`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持主题`light`, `dark`, `grafana`以及`ant`。默认的输入格式为`png`,默认主题为`light`。
+
+`Apache ECharts`在前端开发中得到众多开发者的认可,因此`go-charts`提供了兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的图表截图(主题为light与grafana):
+
+
+
+
+
+
+
+
+
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
+}
diff --git a/axis_test.go b/axis_test.go
new file mode 100644
index 0000000..85e18ca
--- /dev/null
+++ b/axis_test.go
@@ -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: "",
+ },
+ // 底部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: "",
+ },
+ // 左侧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: "",
+ },
+ // 左侧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: "",
+ },
+ // 右侧
+ {
+ 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: "",
+ },
+ // 顶部
+ {
+ 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: "",
+ },
+ }
+
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/bar_chart.go b/bar_chart.go
new file mode 100644
index 0000000..043e044
--- /dev/null
+++ b/bar_chart.go
@@ -0,0 +1,253 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "math"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type barChart struct {
+ p *Painter
+ opt *BarChartOption
+}
+
+// NewBarChart returns a bar chart renderer
+func NewBarChart(p *Painter, opt BarChartOption) *barChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &barChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type BarChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The x axis option
+ XAxis XAxisOption
+ // The padding of line chart
+ Padding Box
+ // The y axis option
+ YAxisOptions []YAxisOption
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ BarWidth int
+ // Margin of bar
+ BarMargin int
+}
+
+func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ p := b.p
+ opt := b.opt
+ seriesPainter := result.seriesPainter
+
+ xRange := NewRange(AxisRangeOption{
+ Painter: b.p,
+ DivideCount: len(opt.XAxis.Data),
+ Size: seriesPainter.Width(),
+ })
+ x0, x1 := xRange.GetRange(0)
+ width := int(x1 - x0)
+ // 每一块之间的margin
+ margin := 10
+ // 每一个bar之间的margin
+ barMargin := 5
+ if width < 20 {
+ margin = 2
+ barMargin = 2
+ } else if width < 50 {
+ margin = 5
+ barMargin = 3
+ }
+ if opt.BarMargin > 0 {
+ barMargin = opt.BarMargin
+ }
+ seriesCount := len(seriesList)
+ // 总的宽度-两个margin-(总数-1)的barMargin
+ barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / seriesCount
+ if opt.BarWidth > 0 && opt.BarWidth < barWidth {
+ barWidth = opt.BarWidth
+ // 重新计算margin
+ margin = (width - seriesCount*barWidth - barMargin*(seriesCount-1)) / 2
+ }
+ barMaxHeight := seriesPainter.Height()
+ theme := opt.Theme
+ seriesNames := seriesList.Names()
+
+ markPointPainter := NewMarkPointPainter(seriesPainter)
+ markLinePainter := NewMarkLinePainter(seriesPainter)
+ rendererList := []Renderer{
+ markPointPainter,
+ markLinePainter,
+ }
+ for index := range seriesList {
+ series := seriesList[index]
+ yRange := result.axisRanges[series.AxisIndex]
+ seriesColor := theme.GetSeriesColor(series.index)
+
+ divideValues := xRange.AutoDivide()
+ points := make([]Point, len(series.Data))
+ var labelPainter *SeriesLabelPainter
+ if series.Label.Show {
+ labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
+ P: seriesPainter,
+ SeriesNames: seriesNames,
+ Label: series.Label,
+ Theme: opt.Theme,
+ Font: opt.Font,
+ })
+ rendererList = append(rendererList, labelPainter)
+ }
+
+ for j, item := range series.Data {
+ if j >= xRange.divideCount {
+ continue
+ }
+ x := divideValues[j]
+ x += margin
+ if index != 0 {
+ x += index * (barWidth + barMargin)
+ }
+
+ h := int(yRange.getHeight(item.Value))
+ fillColor := seriesColor
+ if !item.Style.FillColor.IsZero() {
+ fillColor = item.Style.FillColor
+ }
+ top := barMaxHeight - h
+
+ if series.RoundRadius <= 0 {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).Rect(chart.Box{
+ Top: top,
+ Left: x,
+ Right: x + barWidth,
+ Bottom: barMaxHeight - 1,
+ })
+ } else {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).RoundedRect(chart.Box{
+ Top: top,
+ Left: x,
+ Right: x + barWidth,
+ Bottom: barMaxHeight - 1,
+ }, series.RoundRadius)
+ }
+ // 用于生成marker point
+ points[j] = Point{
+ // 居中的位置
+ X: x + barWidth>>1,
+ Y: top,
+ }
+ // 用于生成marker point
+ points[j] = Point{
+ // 居中的位置
+ X: x + barWidth>>1,
+ Y: top,
+ }
+ // 如果label不需要展示,则返回
+ if labelPainter == nil {
+ continue
+ }
+ y := barMaxHeight - h
+ radians := float64(0)
+ fontColor := series.Label.Color
+ if series.Label.Position == PositionBottom {
+ y = barMaxHeight
+ radians = -math.Pi / 2
+ if fontColor.IsZero() {
+ if isLightColor(fillColor) {
+ fontColor = defaultLightFontColor
+ } else {
+ fontColor = defaultDarkFontColor
+ }
+ }
+ }
+ labelPainter.Add(LabelValue{
+ Index: index,
+ Value: item.Value,
+ X: x + barWidth>>1,
+ Y: y,
+ // 旋转
+ Radians: radians,
+ FontColor: fontColor,
+ Offset: series.Label.Offset,
+ FontSize: series.Label.FontSize,
+ })
+ }
+
+ markPointPainter.Add(markPointRenderOption{
+ FillColor: seriesColor,
+ Font: opt.Font,
+ Series: series,
+ Points: points,
+ })
+ markLinePainter.Add(markLineRenderOption{
+ FillColor: seriesColor,
+ FontColor: opt.Theme.GetTextColor(),
+ StrokeColor: seriesColor,
+ Font: opt.Font,
+ Series: series,
+ Range: yRange,
+ })
+ }
+ // 最大、最小的mark point
+ err := doRender(rendererList...)
+ if err != nil {
+ return BoxZero, err
+ }
+
+ return p.box, nil
+}
+
+func (b *barChart) Render() (Box, error) {
+ p := b.p
+ opt := b.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: opt.XAxis,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeLine)
+ return b.render(renderResult, seriesList)
+}
diff --git a/bar_chart_test.go b/bar_chart_test.go
new file mode 100644
index 0000000..654c320
--- /dev/null
+++ b/bar_chart_test.go
@@ -0,0 +1,190 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBarChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ seriesList := NewSeriesListDataFromValues([][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ },
+ })
+ for index := range seriesList {
+ seriesList[index].Label.Show = true
+ }
+ _, err := NewBarChart(p, BarChartOption{
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+ SeriesList: seriesList,
+ XAxis: NewXAxisOption([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ YAxisOptions: NewYAxisOptions([]string{
+ "Rainfall",
+ "Evaporation",
+ }),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ {
+ render: func(p *Painter) ([]byte, error) {
+ seriesList := NewSeriesListDataFromValues([][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ },
+ })
+ for index := range seriesList {
+ seriesList[index].Label.Show = true
+ seriesList[index].RoundRadius = 5
+ }
+ _, err := NewBarChart(p, BarChartOption{
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+ SeriesList: seriesList,
+ XAxis: NewXAxisOption([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ YAxisOptions: NewYAxisOptions([]string{
+ "Rainfall",
+ "Evaporation",
+ }),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/chart_option.go b/chart_option.go
new file mode 100644
index 0000000..d80a383
--- /dev/null
+++ b/chart_option.go
@@ -0,0 +1,426 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "sort"
+
+ "github.com/golang/freetype/truetype"
+)
+
+type ChartOption struct {
+ theme ColorPalette
+ font *truetype.Font
+ // The output type of chart, "svg" or "png", default value is "svg"
+ Type string
+ // The font family, which should be installed first
+ FontFamily string
+ // The theme of chart, "light" and "dark".
+ // The default theme is "light"
+ Theme string
+ // The title option
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ // The x axis option
+ XAxis XAxisOption
+ // The y axis option list
+ YAxisOptions []YAxisOption
+ // The width of chart, default width is 600
+ Width int
+ // The height of chart, default height is 400
+ Height int
+ Parent *Painter
+ // The padding for chart, default padding is [20, 10, 10, 10]
+ Padding Box
+ // The canvas box for chart
+ Box Box
+ // The series list
+ SeriesList SeriesList
+ // The radar indicator list
+ RadarIndicators []RadarIndicator
+ // The background color of chart
+ BackgroundColor Color
+ // The flag for show symbol of line, set this to *false will hide symbol
+ SymbolShow *bool
+ // The stroke width of line chart
+ LineStrokeWidth float64
+ // The bar with of bar chart
+ BarWidth int
+ // The margin of each bar
+ BarMargin int
+ // The bar height of horizontal bar chart
+ BarHeight int
+ // Fill the area of line chart
+ FillArea bool
+ // background fill (alpha) opacity
+ Opacity uint8
+ // The child charts
+ Children []ChartOption
+ // The value formatter
+ ValueFormatter ValueFormatter
+}
+
+// OptionFunc option function
+type OptionFunc func(opt *ChartOption)
+
+// SVGTypeOption set svg type of chart's output
+func SVGTypeOption() OptionFunc {
+ return TypeOptionFunc(ChartOutputSVG)
+}
+
+// PNGTypeOption set png type of chart's output
+func PNGTypeOption() OptionFunc {
+ return TypeOptionFunc(ChartOutputPNG)
+}
+
+// TypeOptionFunc set type of chart's output
+func TypeOptionFunc(t string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Type = t
+ }
+}
+
+// FontFamilyOptionFunc set font family of chart
+func FontFamilyOptionFunc(fontFamily string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.FontFamily = fontFamily
+ }
+}
+
+// ThemeOptionFunc set them of chart
+func ThemeOptionFunc(theme string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Theme = theme
+ }
+}
+
+// TitleOptionFunc set title of chart
+func TitleOptionFunc(title TitleOption) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Title = title
+ }
+}
+
+// TitleTextOptionFunc set title text of chart
+func TitleTextOptionFunc(text string, subtext ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Title.Text = text
+ if len(subtext) != 0 {
+ opt.Title.Subtext = subtext[0]
+ }
+ }
+}
+
+// LegendOptionFunc set legend of chart
+func LegendOptionFunc(legend LegendOption) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Legend = legend
+ }
+}
+
+// LegendLabelsOptionFunc set legend labels of chart
+func LegendLabelsOptionFunc(labels []string, left ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Legend = NewLegendOption(labels, left...)
+ }
+}
+
+// XAxisOptionFunc set x axis of chart
+func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.XAxis = xAxisOption
+ }
+}
+
+// XAxisDataOptionFunc set x axis data of chart
+func XAxisDataOptionFunc(data []string, boundaryGap ...*bool) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.XAxis = NewXAxisOption(data, boundaryGap...)
+ }
+}
+
+// YAxisOptionFunc set y axis of chart, support two y axis
+func YAxisOptionFunc(yAxisOption ...YAxisOption) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.YAxisOptions = yAxisOption
+ }
+}
+
+// YAxisDataOptionFunc set y axis data of chart
+func YAxisDataOptionFunc(data []string) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.YAxisOptions = NewYAxisOptions(data)
+ }
+}
+
+// WidthOptionFunc set width of chart
+func WidthOptionFunc(width int) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Width = width
+ }
+}
+
+// HeightOptionFunc set height of chart
+func HeightOptionFunc(height int) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Height = height
+ }
+}
+
+// PaddingOptionFunc set padding of chart
+func PaddingOptionFunc(padding Box) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Padding = padding
+ }
+}
+
+// BoxOptionFunc set box of chart
+func BoxOptionFunc(box Box) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.Box = box
+ }
+}
+
+// PieSeriesShowLabel set pie series show label
+func PieSeriesShowLabel() OptionFunc {
+ return func(opt *ChartOption) {
+ for index := range opt.SeriesList {
+ opt.SeriesList[index].Label.Show = true
+ }
+ }
+}
+
+// ChildOptionFunc add child chart
+func ChildOptionFunc(child ...ChartOption) OptionFunc {
+ return func(opt *ChartOption) {
+ if opt.Children == nil {
+ opt.Children = make([]ChartOption, 0)
+ }
+ opt.Children = append(opt.Children, child...)
+ }
+}
+
+// RadarIndicatorOptionFunc set radar indicator of chart
+func RadarIndicatorOptionFunc(names []string, values []float64) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.RadarIndicators = NewRadarIndicators(names, values)
+ }
+}
+
+// BackgroundColorOptionFunc set background color of chart
+func BackgroundColorOptionFunc(color Color) OptionFunc {
+ return func(opt *ChartOption) {
+ opt.BackgroundColor = color
+ }
+}
+
+// MarkLineOptionFunc set mark line for series of chart
+func MarkLineOptionFunc(seriesIndex int, markLineTypes ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ if len(opt.SeriesList) <= seriesIndex {
+ return
+ }
+ opt.SeriesList[seriesIndex].MarkLine = NewMarkLine(markLineTypes...)
+ }
+}
+
+// MarkPointOptionFunc set mark point for series of chart
+func MarkPointOptionFunc(seriesIndex int, markPointTypes ...string) OptionFunc {
+ return func(opt *ChartOption) {
+ if len(opt.SeriesList) <= seriesIndex {
+ return
+ }
+ opt.SeriesList[seriesIndex].MarkPoint = NewMarkPoint(markPointTypes...)
+ }
+}
+
+func (o *ChartOption) fillDefault() {
+ t := NewTheme(o.Theme)
+ o.theme = t
+ // 如果为空,初始化
+ axisCount := 1
+ for _, series := range o.SeriesList {
+ if series.AxisIndex >= axisCount {
+ axisCount++
+ }
+ }
+ o.Width = getDefaultInt(o.Width, defaultChartWidth)
+ o.Height = getDefaultInt(o.Height, defaultChartHeight)
+ yAxisOptions := make([]YAxisOption, axisCount)
+ copy(yAxisOptions, o.YAxisOptions)
+ o.YAxisOptions = yAxisOptions
+ o.font, _ = GetFont(o.FontFamily)
+
+ if o.font == nil {
+ o.font, _ = GetDefaultFont()
+ } else {
+ // 如果指定了字体,则设置主题的字体
+ t.SetFont(o.font)
+ }
+ if o.BackgroundColor.IsZero() {
+ o.BackgroundColor = t.GetBackgroundColor()
+ }
+ if o.Padding.IsZero() {
+ o.Padding = Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ }
+ }
+ // legend与series name的关联
+ if len(o.Legend.Data) == 0 {
+ o.Legend.Data = o.SeriesList.Names()
+ } else {
+ seriesCount := len(o.SeriesList)
+ for index, name := range o.Legend.Data {
+ if index < seriesCount &&
+ len(o.SeriesList[index].Name) == 0 {
+ o.SeriesList[index].Name = name
+ }
+ }
+ nameIndexDict := map[string]int{}
+ for index, name := range o.Legend.Data {
+ nameIndexDict[name] = index
+ }
+ // 保证series的顺序与legend一致
+ sort.Slice(o.SeriesList, func(i, j int) bool {
+ return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name]
+ })
+ }
+}
+
+// LineRender line chart render
+func LineRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeLine)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// BarRender bar chart render
+func BarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeBar)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// HorizontalBarRender horizontal bar chart render
+func HorizontalBarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeHorizontalBar)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// PieRender pie chart render
+func PieRender(values []float64, opts ...OptionFunc) (*Painter, error) {
+ return Render(ChartOption{
+ SeriesList: NewPieSeriesList(values),
+ }, opts...)
+}
+
+// RadarRender radar chart render
+func RadarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewSeriesListDataFromValues(values, ChartTypeRadar)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// FunnelRender funnel chart render
+func FunnelRender(values []float64, opts ...OptionFunc) (*Painter, error) {
+ seriesList := NewFunnelSeriesList(values)
+ return Render(ChartOption{
+ SeriesList: seriesList,
+ }, opts...)
+}
+
+// TableRender table chart render
+func TableRender(header []string, data [][]string, spanMaps ...map[int]int) (*Painter, error) {
+ opt := TableChartOption{
+ Header: header,
+ Data: data,
+ }
+ if len(spanMaps) != 0 {
+ spanMap := spanMaps[0]
+ spans := make([]int, len(opt.Header))
+ for index := range spans {
+ v, ok := spanMap[index]
+ if !ok {
+ v = 1
+ }
+ spans[index] = v
+ }
+ opt.Spans = spans
+ }
+ return TableOptionRender(opt)
+}
+
+// TableOptionRender table render with option
+func TableOptionRender(opt TableChartOption) (*Painter, error) {
+ if opt.Type == "" {
+ opt.Type = ChartOutputPNG
+ }
+ if opt.Width <= 0 {
+ opt.Width = defaultChartWidth
+ }
+ if opt.FontFamily != "" {
+ opt.Font, _ = GetFont(opt.FontFamily)
+ }
+ if opt.Font == nil {
+ opt.Font, _ = GetDefaultFont()
+ }
+
+ p, err := NewPainter(PainterOptions{
+ Type: opt.Type,
+ Width: opt.Width,
+ // 仅用于计算表格高度,因此随便设置即可
+ Height: 100,
+ Font: opt.Font,
+ })
+ if err != nil {
+ return nil, err
+ }
+ info, err := NewTableChart(p, opt).render()
+ if err != nil {
+ return nil, err
+ }
+
+ p, err = NewPainter(PainterOptions{
+ Type: opt.Type,
+ Width: info.Width,
+ Height: info.Height,
+ Font: opt.Font,
+ })
+ if err != nil {
+ return nil, err
+ }
+ _, err = NewTableChart(p, opt).renderWithInfo(info)
+ if err != nil {
+ return nil, err
+ }
+ return p, nil
+}
diff --git a/chart_option_test.go b/chart_option_test.go
new file mode 100644
index 0000000..c354b26
--- /dev/null
+++ b/chart_option_test.go
@@ -0,0 +1,451 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestChartOption(t *testing.T) {
+ assert := assert.New(t)
+
+ fns := []OptionFunc{
+ SVGTypeOption(),
+ FontFamilyOptionFunc("fontFamily"),
+ ThemeOptionFunc("theme"),
+ TitleTextOptionFunc("title"),
+ LegendLabelsOptionFunc([]string{
+ "label",
+ }),
+ XAxisDataOptionFunc([]string{
+ "xaxis",
+ }),
+ YAxisDataOptionFunc([]string{
+ "yaxis",
+ }),
+ WidthOptionFunc(800),
+ HeightOptionFunc(600),
+ PaddingOptionFunc(Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ }),
+ BackgroundColorOptionFunc(drawing.ColorBlack),
+ }
+ opt := ChartOption{}
+ for _, fn := range fns {
+ fn(&opt)
+ }
+ assert.Equal(ChartOption{
+ Type: ChartOutputSVG,
+ FontFamily: "fontFamily",
+ Theme: "theme",
+ Title: TitleOption{
+ Text: "title",
+ },
+ Legend: LegendOption{
+ Data: []string{
+ "label",
+ },
+ },
+ XAxis: XAxisOption{
+ Data: []string{
+ "xaxis",
+ },
+ },
+ YAxisOptions: []YAxisOption{
+ {
+ Data: []string{
+ "yaxis",
+ },
+ },
+ },
+ Width: 800,
+ Height: 600,
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+ BackgroundColor: drawing.ColorBlack,
+ }, opt)
+}
+
+func TestChartOptionPieSeriesShowLabel(t *testing.T) {
+ assert := assert.New(t)
+
+ opt := ChartOption{
+ SeriesList: NewPieSeriesList([]float64{
+ 1,
+ 2,
+ }),
+ }
+ PieSeriesShowLabel()(&opt)
+ assert.True(opt.SeriesList[0].Label.Show)
+}
+
+func TestChartOptionMarkLine(t *testing.T) {
+ assert := assert.New(t)
+ opt := ChartOption{
+ SeriesList: NewSeriesListDataFromValues([][]float64{
+ {1, 2},
+ }),
+ }
+ MarkLineOptionFunc(0, "min", "max")(&opt)
+ assert.Equal(NewMarkLine("min", "max"), opt.SeriesList[0].MarkLine)
+}
+
+func TestChartOptionMarkPoint(t *testing.T) {
+ assert := assert.New(t)
+ opt := ChartOption{
+ SeriesList: NewSeriesListDataFromValues([][]float64{
+ {1, 2},
+ }),
+ }
+ MarkPointOptionFunc(0, "min", "max")(&opt)
+ assert.Equal(NewMarkPoint("min", "max"), opt.SeriesList[0].MarkPoint)
+}
+
+func TestLineRender(t *testing.T) {
+ assert := assert.New(t)
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ p, err := LineRender(
+ values,
+ SVGTypeOption(),
+ TitleTextOptionFunc("Line"),
+ XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ LegendLabelsOptionFunc([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }, PositionCenter),
+ )
+ assert.Nil(err)
+ data, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("", string(data))
+}
+
+func TestBarRender(t *testing.T) {
+ assert := assert.New(t)
+ values := [][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ },
+ }
+ p, err := BarRender(
+ values,
+ SVGTypeOption(),
+ XAxisDataOptionFunc([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ LegendLabelsOptionFunc([]string{
+ "Rainfall",
+ "Evaporation",
+ }, PositionRight),
+ MarkLineOptionFunc(0, SeriesMarkDataTypeAverage),
+ MarkPointOptionFunc(0, SeriesMarkDataTypeMax,
+ SeriesMarkDataTypeMin),
+ // custom option func
+ func(opt *ChartOption) {
+ opt.SeriesList[1].MarkPoint = NewMarkPoint(
+ SeriesMarkDataTypeMax,
+ SeriesMarkDataTypeMin,
+ )
+ opt.SeriesList[1].MarkLine = NewMarkLine(
+ SeriesMarkDataTypeAverage,
+ )
+ },
+ )
+ assert.Nil(err)
+ data, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("", string(data))
+}
+
+func TestHorizontalBarRender(t *testing.T) {
+ assert := assert.New(t)
+ values := [][]float64{
+ {
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230,
+ },
+ {
+ 19325,
+ 23438,
+ 31000,
+ 121594,
+ 134141,
+ 681807,
+ },
+ }
+ p, err := HorizontalBarRender(
+ values,
+ SVGTypeOption(),
+ TitleTextOptionFunc("World Population"),
+ PaddingOptionFunc(Box{
+ Top: 20,
+ Right: 40,
+ Bottom: 20,
+ Left: 20,
+ }),
+ LegendLabelsOptionFunc([]string{
+ "2011",
+ "2012",
+ }),
+ YAxisDataOptionFunc([]string{
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ )
+ assert.Nil(err)
+ data, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("", string(data))
+}
+
+func TestPieRender(t *testing.T) {
+ assert := assert.New(t)
+ values := []float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }
+ p, err := PieRender(
+ values,
+ SVGTypeOption(),
+ TitleOptionFunc(TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ Left: PositionCenter,
+ }),
+ PaddingOptionFunc(Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ }),
+ LegendOptionFunc(LegendOption{
+ Orient: OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: PositionLeft,
+ }),
+ PieSeriesShowLabel(),
+ )
+ assert.Nil(err)
+ data, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("", string(data))
+}
+
+func TestRadarRender(t *testing.T) {
+ assert := assert.New(t)
+
+ values := [][]float64{
+ {
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ },
+ {
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
+ },
+ }
+ p, err := RadarRender(
+ values,
+ SVGTypeOption(),
+ TitleTextOptionFunc("Basic Radar Chart"),
+ LegendLabelsOptionFunc([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ RadarIndicatorOptionFunc([]string{
+ "Sales",
+ "Administration",
+ "Information Technology",
+ "Customer Support",
+ "Development",
+ "Marketing",
+ }, []float64{
+ 6500,
+ 16000,
+ 30000,
+ 38000,
+ 52000,
+ 25000,
+ }),
+ )
+ assert.Nil(err)
+ data, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("", string(data))
+}
+
+func TestFunnelRender(t *testing.T) {
+ assert := assert.New(t)
+
+ values := []float64{
+ 100,
+ 80,
+ 60,
+ 40,
+ 20,
+ }
+ p, err := FunnelRender(
+ values,
+ SVGTypeOption(),
+ TitleTextOptionFunc("Funnel"),
+ LegendLabelsOptionFunc([]string{
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order",
+ }),
+ )
+ assert.Nil(err)
+ data, err := p.Bytes()
+ assert.Nil(err)
+ assert.Equal("", string(data))
+}
diff --git a/charts.go b/charts.go
new file mode 100644
index 0000000..31df11c
--- /dev/null
+++ b/charts.go
@@ -0,0 +1,473 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "errors"
+ "math"
+ "sort"
+
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+const labelFontSize = 10
+const smallLabelFontSize = 8
+const defaultDotWidth = 2.0
+const defaultStrokeWidth = 2.0
+
+var defaultChartWidth = 600
+var defaultChartHeight = 400
+
+// SetDefaultWidth sets default width of chart
+func SetDefaultWidth(width int) {
+ if width > 0 {
+ defaultChartWidth = width
+ }
+}
+
+// SetDefaultHeight sets default height of chart
+func SetDefaultHeight(height int) {
+ if height > 0 {
+ defaultChartHeight = height
+ }
+}
+
+var nullValue = math.MaxFloat64
+
+// SetNullValue sets the null value, default is MaxFloat64
+func SetNullValue(v float64) {
+ nullValue = v
+}
+
+// GetNullValue gets the null value
+func GetNullValue() float64 {
+ return nullValue
+}
+
+type Renderer interface {
+ Render() (Box, error)
+}
+
+type renderHandler struct {
+ list []func() error
+}
+
+func (rh *renderHandler) Add(fn func() error) {
+ list := rh.list
+ if len(list) == 0 {
+ list = make([]func() error, 0)
+ }
+ rh.list = append(list, fn)
+}
+
+func (rh *renderHandler) Do() error {
+ for _, fn := range rh.list {
+ err := fn()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type defaultRenderOption struct {
+ Theme ColorPalette
+ Padding Box
+ SeriesList SeriesList
+ // The y axis option
+ YAxisOptions []YAxisOption
+ // The x axis option
+ XAxis XAxisOption
+ // The title option
+ TitleOption TitleOption
+ // The legend option
+ LegendOption LegendOption
+ // background is filled
+ backgroundIsFilled bool
+ // x y axis is reversed
+ axisReversed bool
+}
+
+type defaultRenderResult struct {
+ axisRanges map[int]axisRange
+ // 图例区域
+ seriesPainter *Painter
+}
+
+func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, error) {
+ seriesList := opt.SeriesList
+ seriesList.init()
+ if !opt.backgroundIsFilled {
+ p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor())
+ }
+
+ if !opt.Padding.IsZero() {
+ p = p.Child(PainterPaddingOption(opt.Padding))
+ }
+
+ legendHeight := 0
+ if len(opt.LegendOption.Data) != 0 {
+ if opt.LegendOption.Theme == nil {
+ opt.LegendOption.Theme = opt.Theme
+ }
+ legendResult, err := NewLegendPainter(p, opt.LegendOption).Render()
+ if err != nil {
+ return nil, err
+ }
+ legendHeight = legendResult.Height()
+ }
+
+ // 如果有标题
+ if opt.TitleOption.Text != "" {
+ if opt.TitleOption.Theme == nil {
+ opt.TitleOption.Theme = opt.Theme
+ }
+ titlePainter := NewTitlePainter(p, opt.TitleOption)
+
+ titleBox, err := titlePainter.Render()
+ if err != nil {
+ return nil, err
+ }
+
+ top := chart.MaxInt(legendHeight, titleBox.Height())
+ // 如果是垂直方式,则不计算legend高度
+ if opt.LegendOption.Orient == OrientVertical {
+ top = titleBox.Height()
+ }
+ p = p.Child(PainterPaddingOption(Box{
+ // 标题下留白
+ Top: top + 20,
+ }))
+ }
+
+ result := defaultRenderResult{
+ axisRanges: make(map[int]axisRange),
+ }
+
+ // 计算图表对应的轴有哪些
+ axisIndexList := make([]int, 0)
+ for _, series := range opt.SeriesList {
+ if containsInt(axisIndexList, series.AxisIndex) {
+ continue
+ }
+ axisIndexList = append(axisIndexList, series.AxisIndex)
+ }
+ // 高度需要减去x轴的高度
+ rangeHeight := p.Height() - defaultXAxisHeight
+ rangeWidthLeft := 0
+ rangeWidthRight := 0
+
+ // 倒序
+ sort.Sort(sort.Reverse(sort.IntSlice(axisIndexList)))
+
+ // 计算对应的axis range
+ for _, index := range axisIndexList {
+ yAxisOption := YAxisOption{}
+ if len(opt.YAxisOptions) > index {
+ yAxisOption = opt.YAxisOptions[index]
+ }
+ divideCount := yAxisOption.DivideCount
+ if divideCount <= 0 {
+ divideCount = defaultAxisDivideCount
+ }
+ max, min := opt.SeriesList.GetMaxMin(index)
+ r := NewRange(AxisRangeOption{
+ Painter: p,
+ Min: min,
+ Max: max,
+ // 高度需要减去x轴的高度
+ Size: rangeHeight,
+ // 分隔数量
+ DivideCount: divideCount,
+ })
+ if yAxisOption.Min != nil && *yAxisOption.Min <= min {
+ r.min = *yAxisOption.Min
+ }
+ if yAxisOption.Max != nil && *yAxisOption.Max >= max {
+ r.max = *yAxisOption.Max
+ }
+ result.axisRanges[index] = r
+
+ if yAxisOption.Theme == nil {
+ yAxisOption.Theme = opt.Theme
+ }
+ if !opt.axisReversed {
+ yAxisOption.Data = r.Values()
+ } else {
+ yAxisOption.isCategoryAxis = true
+ // 由于x轴为value部分,因此计算其label单独处理
+ opt.XAxis.Data = NewRange(AxisRangeOption{
+ Painter: p,
+ Min: min,
+ Max: max,
+ // 高度需要减去x轴的高度
+ Size: rangeHeight,
+ // 分隔数量
+ DivideCount: defaultAxisDivideCount,
+ }).Values()
+ opt.XAxis.isValueAxis = true
+ }
+ reverseStringSlice(yAxisOption.Data)
+ // TODO生成其它位置既yAxis
+ var yAxis *axisPainter
+ child := p.Child(PainterPaddingOption(Box{
+ Left: rangeWidthLeft,
+ Right: rangeWidthRight,
+ }))
+ if index == 0 {
+ yAxis = NewLeftYAxis(child, yAxisOption)
+ } else {
+ yAxis = NewRightYAxis(child, yAxisOption)
+ }
+ yAxisBox, err := yAxis.Render()
+ if err != nil {
+ return nil, err
+ }
+ if index == 0 {
+ rangeWidthLeft += yAxisBox.Width()
+ } else {
+ rangeWidthRight += yAxisBox.Width()
+ }
+ }
+
+ if opt.XAxis.Theme == nil {
+ opt.XAxis.Theme = opt.Theme
+ }
+ xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{
+ Left: rangeWidthLeft,
+ Right: rangeWidthRight,
+ })), opt.XAxis)
+ _, err := xAxis.Render()
+ if err != nil {
+ return nil, err
+ }
+
+ result.seriesPainter = p.Child(PainterPaddingOption(Box{
+ Bottom: defaultXAxisHeight,
+ Left: rangeWidthLeft,
+ Right: rangeWidthRight,
+ }))
+ return &result, nil
+}
+
+func doRender(renderers ...Renderer) error {
+ for _, r := range renderers {
+ _, err := r.Render()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) {
+ for _, fn := range opts {
+ fn(&opt)
+ }
+ opt.fillDefault()
+
+ isChild := true
+ if opt.Parent == nil {
+ isChild = false
+ p, err := NewPainter(PainterOptions{
+ Type: opt.Type,
+ Width: opt.Width,
+ Height: opt.Height,
+ Font: opt.font,
+ })
+ if err != nil {
+ return nil, err
+ }
+ opt.Parent = p
+ }
+ p := opt.Parent
+ if opt.ValueFormatter != nil {
+ p.valueFormatter = opt.ValueFormatter
+ }
+ if !opt.Box.IsZero() {
+ p = p.Child(PainterBoxOption(opt.Box))
+ }
+ if !isChild {
+ p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
+ }
+ seriesList := opt.SeriesList
+ seriesList.init()
+
+ seriesCount := len(seriesList)
+
+ // line chart
+ lineSeriesList := seriesList.Filter(ChartTypeLine)
+ barSeriesList := seriesList.Filter(ChartTypeBar)
+ horizontalBarSeriesList := seriesList.Filter(ChartTypeHorizontalBar)
+ pieSeriesList := seriesList.Filter(ChartTypePie)
+ radarSeriesList := seriesList.Filter(ChartTypeRadar)
+ funnelSeriesList := seriesList.Filter(ChartTypeFunnel)
+
+ if len(horizontalBarSeriesList) != 0 && len(horizontalBarSeriesList) != seriesCount {
+ return nil, errors.New("Horizontal bar can not mix other charts")
+ }
+ if len(pieSeriesList) != 0 && len(pieSeriesList) != seriesCount {
+ return nil, errors.New("Pie can not mix other charts")
+ }
+ if len(radarSeriesList) != 0 && len(radarSeriesList) != seriesCount {
+ return nil, errors.New("Radar can not mix other charts")
+ }
+ if len(funnelSeriesList) != 0 && len(funnelSeriesList) != seriesCount {
+ return nil, errors.New("Funnel can not mix other charts")
+ }
+
+ axisReversed := len(horizontalBarSeriesList) != 0
+ renderOpt := defaultRenderOption{
+ Theme: opt.theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: opt.XAxis,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ axisReversed: axisReversed,
+ // 前置已设置背景色
+ backgroundIsFilled: true,
+ }
+ if len(pieSeriesList) != 0 ||
+ len(radarSeriesList) != 0 ||
+ len(funnelSeriesList) != 0 {
+ renderOpt.XAxis.Show = FalseFlag()
+ renderOpt.YAxisOptions = []YAxisOption{
+ {
+ Show: FalseFlag(),
+ },
+ }
+ }
+ if len(horizontalBarSeriesList) != 0 {
+ renderOpt.YAxisOptions[0].DivideCount = len(renderOpt.YAxisOptions[0].Data)
+ renderOpt.YAxisOptions[0].Unit = 1
+ }
+
+ renderResult, err := defaultRender(p, renderOpt)
+ if err != nil {
+ return nil, err
+ }
+
+ handler := renderHandler{}
+
+ // bar chart
+ if len(barSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewBarChart(p, BarChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ XAxis: opt.XAxis,
+ BarWidth: opt.BarWidth,
+ BarMargin: opt.BarMargin,
+ }).render(renderResult, barSeriesList)
+ return err
+ })
+ }
+
+ // horizontal bar chart
+ if len(horizontalBarSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewHorizontalBarChart(p, HorizontalBarChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ BarHeight: opt.BarHeight,
+ BarMargin: opt.BarMargin,
+ YAxisOptions: opt.YAxisOptions,
+ }).render(renderResult, horizontalBarSeriesList)
+ return err
+ })
+ }
+
+ // pie chart
+ if len(pieSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewPieChart(p, PieChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ }).render(renderResult, pieSeriesList)
+ return err
+ })
+ }
+
+ // line chart
+ if len(lineSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewLineChart(p, LineChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ XAxis: opt.XAxis,
+ SymbolShow: opt.SymbolShow,
+ StrokeWidth: opt.LineStrokeWidth,
+ FillArea: opt.FillArea,
+ Opacity: opt.Opacity,
+ }).render(renderResult, lineSeriesList)
+ return err
+ })
+ }
+
+ // radar chart
+ if len(radarSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewRadarChart(p, RadarChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ // 相应值
+ RadarIndicators: opt.RadarIndicators,
+ }).render(renderResult, radarSeriesList)
+ return err
+ })
+ }
+
+ // funnel chart
+ if len(funnelSeriesList) != 0 {
+ handler.Add(func() error {
+ _, err := NewFunnelChart(p, FunnelChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ }).render(renderResult, funnelSeriesList)
+ return err
+ })
+ }
+
+ err = handler.Do()
+
+ if err != nil {
+ return nil, err
+ }
+ for _, item := range opt.Children {
+ item.Parent = p
+ if item.Theme == "" {
+ item.Theme = opt.Theme
+ }
+ if item.FontFamily == "" {
+ item.FontFamily = opt.FontFamily
+ }
+ _, err = Render(item)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return p, nil
+}
diff --git a/charts_test.go b/charts_test.go
new file mode 100644
index 0000000..bd581e9
--- /dev/null
+++ b/charts_test.go
@@ -0,0 +1,255 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "errors"
+ "testing"
+
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+func BenchmarkMultiChartPNGRender(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ opt := ChartOption{
+ Type: ChartOutputPNG,
+ Legend: LegendOption{
+ Top: "-90",
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Padding: chart.Box{
+ Top: 100,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: NewXAxisOption([]string{
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017",
+ }),
+ YAxisOptions: []YAxisOption{
+ {
+
+ Min: NewFloatPoint(0),
+ Max: NewFloatPoint(90),
+ },
+ },
+ SeriesList: []Series{
+ NewSeriesFromValues([]float64{
+ 56.5,
+ 82.1,
+ 88.7,
+ 70.1,
+ 53.4,
+ 85.1,
+ }),
+ NewSeriesFromValues([]float64{
+ 51.1,
+ 51.4,
+ 55.1,
+ 53.3,
+ 73.8,
+ 68.7,
+ }),
+ NewSeriesFromValues([]float64{
+ 40.1,
+ 62.2,
+ 69.5,
+ 36.4,
+ 45.2,
+ 32.5,
+ }, ChartTypeBar),
+ NewSeriesFromValues([]float64{
+ 25.2,
+ 37.1,
+ 41.2,
+ 18,
+ 33.9,
+ 49.1,
+ }, ChartTypeBar),
+ },
+ Children: []ChartOption{
+ {
+ Legend: LegendOption{
+ Show: FalseFlag(),
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Box: chart.Box{
+ Top: 20,
+ Left: 400,
+ Right: 500,
+ Bottom: 120,
+ },
+ SeriesList: NewPieSeriesList([]float64{
+ 435.9,
+ 354.3,
+ 285.9,
+ 204.5,
+ }, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ },
+ Radius: "35%",
+ }),
+ },
+ },
+ }
+ d, err := Render(opt)
+ if err != nil {
+ panic(err)
+ }
+ buf, err := d.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ if len(buf) == 0 {
+ panic(errors.New("data is nil"))
+ }
+ }
+}
+
+func BenchmarkMultiChartSVGRender(b *testing.B) {
+ for i := 0; i < b.N; i++ {
+ opt := ChartOption{
+ Legend: LegendOption{
+ Top: "-90",
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Padding: chart.Box{
+ Top: 100,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: NewXAxisOption([]string{
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017",
+ }),
+ YAxisOptions: []YAxisOption{
+ {
+
+ Min: NewFloatPoint(0),
+ Max: NewFloatPoint(90),
+ },
+ },
+ SeriesList: []Series{
+ NewSeriesFromValues([]float64{
+ 56.5,
+ 82.1,
+ 88.7,
+ 70.1,
+ 53.4,
+ 85.1,
+ }),
+ NewSeriesFromValues([]float64{
+ 51.1,
+ 51.4,
+ 55.1,
+ 53.3,
+ 73.8,
+ 68.7,
+ }),
+ NewSeriesFromValues([]float64{
+ 40.1,
+ 62.2,
+ 69.5,
+ 36.4,
+ 45.2,
+ 32.5,
+ }, ChartTypeBar),
+ NewSeriesFromValues([]float64{
+ 25.2,
+ 37.1,
+ 41.2,
+ 18,
+ 33.9,
+ 49.1,
+ }, ChartTypeBar),
+ },
+ Children: []ChartOption{
+ {
+ Legend: LegendOption{
+ Show: FalseFlag(),
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Box: chart.Box{
+ Top: 20,
+ Left: 400,
+ Right: 500,
+ Bottom: 120,
+ },
+ SeriesList: NewPieSeriesList([]float64{
+ 435.9,
+ 354.3,
+ 285.9,
+ 204.5,
+ }, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ },
+ Radius: "35%",
+ }),
+ },
+ },
+ }
+ d, err := Render(opt)
+ if err != nil {
+ panic(err)
+ }
+ buf, err := d.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ if len(buf) == 0 {
+ panic(errors.New("data is nil"))
+ }
+ }
+}
diff --git a/echarts.go b/echarts.go
new file mode 100644
index 0000000..aaef1f1
--- /dev/null
+++ b/echarts.go
@@ -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")
+}
diff --git a/echarts_test.go b/echarts_test.go
new file mode 100644
index 0000000..2077278
--- /dev/null
+++ b/echarts_test.go
@@ -0,0 +1,582 @@
+// 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 (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestConvertToArray(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal([]byte(`[1]`), convertToArray([]byte("1")))
+ assert.Equal([]byte(`[1]`), convertToArray([]byte("[1]")))
+}
+
+func TestEChartsPosition(t *testing.T) {
+ assert := assert.New(t)
+ var p EChartsPosition
+ err := p.UnmarshalJSON([]byte("1"))
+ assert.Nil(err)
+ assert.Equal(EChartsPosition("1"), p)
+ err = p.UnmarshalJSON([]byte(`"left"`))
+ assert.Nil(err)
+ assert.Equal(EChartsPosition("left"), p)
+}
+
+func TestEChartsSeriesDataValue(t *testing.T) {
+ assert := assert.New(t)
+
+ es := EChartsSeriesDataValue{}
+ err := es.UnmarshalJSON([]byte(`[1, 2]`))
+ assert.Nil(err)
+ assert.Equal(EChartsSeriesDataValue{
+ values: []float64{
+ 1,
+ 2,
+ },
+ }, es)
+ assert.Equal(NewEChartsSeriesDataValue(1, 2), es)
+ assert.Equal(1.0, es.First())
+}
+
+func TestEChartsSeriesData(t *testing.T) {
+ assert := assert.New(t)
+ es := EChartsSeriesData{}
+ err := es.UnmarshalJSON([]byte("1.1"))
+ assert.Nil(err)
+ assert.Equal(EChartsSeriesDataValue{
+ values: []float64{
+ 1.1,
+ },
+ }, es.Value)
+
+ err = es.UnmarshalJSON([]byte(`{"value":200,"itemStyle":{"color":"#a90000"}}`))
+ assert.Nil(err)
+ assert.Nil(err)
+ assert.Equal(EChartsSeriesData{
+ Value: EChartsSeriesDataValue{
+ values: []float64{
+ 200.0,
+ },
+ },
+ ItemStyle: EChartStyle{
+ Color: "#a90000",
+ },
+ }, es)
+}
+
+func TestEChartsXAxis(t *testing.T) {
+ assert := assert.New(t)
+ ex := EChartsXAxis{}
+ err := ex.UnmarshalJSON([]byte(`{"boundaryGap": true, "splitNumber": 5, "data": ["a", "b"], "type": "value"}`))
+ assert.Nil(err)
+
+ assert.Equal(EChartsXAxis{
+ Data: []EChartsXAxisData{
+ {
+ BoundaryGap: TrueFlag(),
+ SplitNumber: 5,
+ Data: []string{
+ "a",
+ "b",
+ },
+ Type: "value",
+ },
+ },
+ }, ex)
+}
+
+func TestEChartStyle(t *testing.T) {
+ assert := assert.New(t)
+
+ es := EChartStyle{
+ Color: "#999",
+ }
+ color := drawing.Color{
+ R: 153,
+ G: 153,
+ B: 153,
+ A: 255,
+ }
+ assert.Equal(Style{
+ FillColor: color,
+ FontColor: color,
+ StrokeColor: color,
+ }, es.ToStyle())
+}
+
+func TestEChartsPadding(t *testing.T) {
+ assert := assert.New(t)
+
+ eb := EChartsPadding{}
+
+ err := eb.UnmarshalJSON([]byte(`1`))
+ assert.Nil(err)
+ assert.Equal(Box{
+ Left: 1,
+ Top: 1,
+ Right: 1,
+ Bottom: 1,
+ }, eb.Box)
+
+ err = eb.UnmarshalJSON([]byte(`[2, 3]`))
+ assert.Nil(err)
+ assert.Equal(Box{
+ Left: 3,
+ Top: 2,
+ Right: 3,
+ Bottom: 2,
+ }, eb.Box)
+
+ err = eb.UnmarshalJSON([]byte(`[4, 5, 6]`))
+ assert.Nil(err)
+ assert.Equal(Box{
+ Left: 5,
+ Top: 4,
+ Right: 5,
+ Bottom: 6,
+ }, eb.Box)
+
+ err = eb.UnmarshalJSON([]byte(`[4, 5, 6, 7]`))
+ assert.Nil(err)
+ assert.Equal(Box{
+ Left: 7,
+ Top: 4,
+ Right: 5,
+ Bottom: 6,
+ }, eb.Box)
+}
+
+func TestEChartsMarkPoint(t *testing.T) {
+ assert := assert.New(t)
+
+ emp := EChartsMarkPoint{
+ SymbolSize: 30,
+ Data: []EChartsMarkData{
+ {
+ Type: "test",
+ },
+ },
+ }
+ assert.Equal(SeriesMarkPoint{
+ SymbolSize: 30,
+ Data: []SeriesMarkData{
+ {
+ Type: "test",
+ },
+ },
+ }, emp.ToSeriesMarkPoint())
+}
+
+func TestEChartsMarkLine(t *testing.T) {
+ assert := assert.New(t)
+
+ eml := EChartsMarkLine{
+ Data: []EChartsMarkData{
+ {
+ Type: "min",
+ },
+ {
+ Type: "max",
+ },
+ },
+ }
+ assert.Equal(SeriesMarkLine{
+ Data: []SeriesMarkData{
+ {
+ Type: "min",
+ },
+ {
+ Type: "max",
+ },
+ },
+ }, eml.ToSeriesMarkLine())
+}
+
+func TestEChartsOption(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ option string
+ }{
+ {
+ option: `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 120,
+ {
+ "value": 200,
+ "itemStyle": {
+ "color": "#a90000"
+ }
+ },
+ 150,
+ 80,
+ 70,
+ 110,
+ 130
+ ],
+ "type": "bar"
+ }
+ ]
+ }`,
+ },
+ {
+ option: `{
+ "title": {
+ "text": "Referer of a Website",
+ "subtext": "Fake Data",
+ "left": "center"
+ },
+ "tooltip": {
+ "trigger": "item"
+ },
+ "legend": {
+ "orient": "vertical",
+ "left": "left"
+ },
+ "series": [
+ {
+ "name": "Access From",
+ "type": "pie",
+ "radius": "50%",
+ "data": [
+ {
+ "value": 1048,
+ "name": "Search Engine"
+ },
+ {
+ "value": 735,
+ "name": "Direct"
+ },
+ {
+ "value": 580,
+ "name": "Email"
+ },
+ {
+ "value": 484,
+ "name": "Union Ads"
+ },
+ {
+ "value": 300,
+ "name": "Video Ads"
+ }
+ ],
+ "emphasis": {
+ "itemStyle": {
+ "shadowBlur": 10,
+ "shadowOffsetX": 0,
+ "shadowColor": "rgba(0, 0, 0, 0.5)"
+ }
+ }
+ }
+ ]
+ }`,
+ },
+ {
+ option: `{
+ "title": {
+ "text": "Rainfall vs Evaporation",
+ "subtext": "Fake Data"
+ },
+ "tooltip": {
+ "trigger": "axis"
+ },
+ "legend": {
+ "data": [
+ "Rainfall",
+ "Evaporation"
+ ]
+ },
+ "toolbox": {
+ "show": true,
+ "feature": {
+ "dataView": {
+ "show": true,
+ "readOnly": false
+ },
+ "magicType": {
+ "show": true,
+ "type": [
+ "line",
+ "bar"
+ ]
+ },
+ "restore": {
+ "show": true
+ },
+ "saveAsImage": {
+ "show": true
+ }
+ }
+ },
+ "calculable": true,
+ "xAxis": [
+ {
+ "type": "category",
+ "data": [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec"
+ ]
+ }
+ ],
+ "yAxis": [
+ {
+ "type": "value"
+ }
+ ],
+ "series": [
+ {
+ "name": "Rainfall",
+ "type": "bar",
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20,
+ 6.4,
+ 3.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max",
+ "name": "Max"
+ },
+ {
+ "type": "min",
+ "name": "Min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average",
+ "name": "Avg"
+ }
+ ]
+ }
+ },
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6,
+ 2.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "name": "Max",
+ "value": 182.2,
+ "xAxis": 7,
+ "yAxis": 183
+ },
+ {
+ "name": "Min",
+ "value": 2.3,
+ "xAxis": 11,
+ "yAxis": 3
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average",
+ "name": "Avg"
+ }
+ ]
+ }
+ }
+ ]
+ }`,
+ },
+ }
+ for _, tt := range tests {
+ opt := EChartsOption{}
+ err := json.Unmarshal([]byte(tt.option), &opt)
+ assert.Nil(err)
+ assert.NotEmpty(opt.Series)
+ assert.NotEmpty(opt.ToOption().SeriesList)
+ }
+}
+
+func TestRenderEChartsToSVG(t *testing.T) {
+ assert := assert.New(t)
+
+ data, err := RenderEChartsToSVG(`{
+ "title": {
+ "text": "Rainfall vs Evaporation",
+ "subtext": "Fake Data"
+ },
+ "legend": {
+ "data": [
+ "Rainfall",
+ "Evaporation"
+ ]
+ },
+ "padding": [10, 30, 10, 10],
+ "xAxis": [
+ {
+ "type": "category",
+ "data": [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "name": "Rainfall",
+ "type": "bar",
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20,
+ 6.4,
+ 3.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ },
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6,
+ 2.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ }
+ ]
+ }`)
+ assert.Nil(err)
+ assert.Equal("", string(data))
+}
diff --git a/entrypoint.sh b/entrypoint.sh
deleted file mode 100755
index ad8111b..0000000
--- a/entrypoint.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/sh
-set -e
-
-if [ "${1:0:1}" = '-' ]; then
- set -- go-charts "$@"
-fi
-
-exec "$@"
\ No newline at end of file
diff --git a/examples/area_line_chart/main.go b/examples/area_line_chart/main.go
new file mode 100644
index 0000000..57ca1e9
--- /dev/null
+++ b/examples/area_line_chart/main.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "area-line-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ }
+ p, err := charts.LineRender(
+ values,
+ charts.TitleTextOptionFunc("Line"),
+ charts.XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "Email",
+ }, "50"),
+ func(opt *charts.ChartOption) {
+ opt.Legend.Padding = charts.Box{
+ Top: 5,
+ Bottom: 10,
+ }
+ opt.FillArea = true
+ },
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/bar_chart/main.go b/examples/bar_chart/main.go
new file mode 100644
index 0000000..91c9f81
--- /dev/null
+++ b/examples/bar_chart/main.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "bar-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ },
+ {
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ },
+ }
+ p, err := charts.BarRender(
+ values,
+ charts.XAxisDataOptionFunc([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "Rainfall",
+ "Evaporation",
+ }, charts.PositionRight),
+ charts.MarkLineOptionFunc(0, charts.SeriesMarkDataTypeAverage),
+ charts.MarkPointOptionFunc(0, charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin),
+ // custom option func
+ func(opt *charts.ChartOption) {
+ opt.SeriesList[1].MarkPoint = charts.NewMarkPoint(
+ charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin,
+ )
+ opt.SeriesList[1].MarkLine = charts.NewMarkLine(
+ charts.SeriesMarkDataTypeAverage,
+ )
+ },
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/charts/main.go b/examples/charts/main.go
new file mode 100644
index 0000000..81bc4f2
--- /dev/null
+++ b/examples/charts/main.go
@@ -0,0 +1,1974 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ charts "git.smarteching.com/zeni/go-charts/v2"
+)
+
+var html = `
+
+
+
+
+
+
+ go-charts
+
+
+
{{body}}
+
+
+`
+
+func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.ChartOption, echartsOptions []string) {
+ if req.URL.Path != "/" &&
+ req.URL.Path != "/echarts" {
+ return
+ }
+ query := req.URL.Query()
+ theme := query.Get("theme")
+ width, _ := strconv.Atoi(query.Get("width"))
+ height, _ := strconv.Atoi(query.Get("height"))
+ charts.SetDefaultWidth(width)
+ charts.SetDefaultWidth(height)
+ bytesList := make([][]byte, 0)
+ for _, opt := range chartOptions {
+ opt.Theme = theme
+ opt.Type = charts.ChartOutputSVG
+ d, err := charts.Render(opt)
+ if err != nil {
+ panic(err)
+ }
+ buf, err := d.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ bytesList = append(bytesList, buf)
+ }
+ for _, opt := range echartsOptions {
+ buf, err := charts.RenderEChartsToSVG(opt)
+ if err != nil {
+ panic(err)
+ }
+ bytesList = append(bytesList, buf)
+ }
+
+ p, err := charts.TableOptionRender(charts.TableChartOption{
+ Type: charts.ChartOutputSVG,
+ Header: []string{
+ "Name",
+ "Age",
+ "Address",
+ "Tag",
+ "Action",
+ },
+ Data: [][]string{
+ {
+ "John Brown",
+ "32",
+ "New York No. 1 Lake Park",
+ "nice, developer",
+ "Send Mail",
+ },
+ {
+ "Jim Green ",
+ "42",
+ "London No. 1 Lake Park",
+ "wow",
+ "Send Mail",
+ },
+ {
+ "Joe Black ",
+ "32",
+ "Sidney No. 1 Lake Park",
+ "cool, teacher",
+ "Send Mail",
+ },
+ },
+ })
+ if err != nil {
+ panic(err)
+ }
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ bytesList = append(bytesList, buf)
+
+ data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), bytes.Join(bytesList, []byte("")))
+ w.Header().Set("Content-Type", "text/html")
+ w.Write(data)
+}
+
+func indexHandler(w http.ResponseWriter, req *http.Request) {
+ chartOptions := []charts.ChartOption{
+ {
+ Title: charts.TitleOption{
+ Text: "Line",
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }),
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ SeriesList: []charts.Series{
+ charts.NewSeriesFromValues([]float64{
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ }),
+ },
+ },
+ // 温度折线图
+ {
+ Title: charts.TitleOption{
+ Text: "Temperature Change in the Coming Week",
+ },
+ Padding: charts.Box{
+ Top: 20,
+ Left: 20,
+ Right: 30,
+ Bottom: 20,
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Highest",
+ "Lowest",
+ }, charts.PositionRight),
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }, charts.FalseFlag()),
+ SeriesList: []charts.Series{
+ {
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 14,
+ 11,
+ 13,
+ 11,
+ 12,
+ 12,
+ 7,
+ }),
+ MarkPoint: charts.NewMarkPoint(charts.SeriesMarkDataTypeMax, charts.SeriesMarkDataTypeMin),
+ MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage),
+ },
+ {
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 1,
+ -2,
+ 2,
+ 5,
+ 3,
+ 2,
+ 0,
+ }),
+ MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage),
+ },
+ },
+ },
+ {
+ Title: charts.TitleOption{
+ Text: "Line Area",
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Email",
+ }),
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ SeriesList: []charts.Series{
+ charts.NewSeriesFromValues([]float64{
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ }),
+ },
+ FillArea: true,
+ },
+ // 柱状图
+ {
+ Title: charts.TitleOption{
+ Text: "Bar",
+ },
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ Legend: charts.LegendOption{
+ Data: []string{
+ "Rainfall",
+ "Evaporation",
+ },
+ Icon: charts.IconRect,
+ },
+ SeriesList: []charts.Series{
+ charts.NewSeriesFromValues([]float64{
+ 120,
+ 200,
+ 150,
+ 80,
+ 70,
+ 110,
+ 130,
+ }, charts.ChartTypeBar),
+ {
+ Type: charts.ChartTypeBar,
+ Data: []charts.SeriesData{
+ {
+ Value: 100,
+ },
+ {
+ Value: 190,
+ Style: charts.Style{
+ FillColor: charts.Color{
+ R: 169,
+ G: 0,
+ B: 0,
+ A: 255,
+ },
+ },
+ },
+ {
+ Value: 230,
+ },
+ {
+ Value: 140,
+ },
+ {
+ Value: 100,
+ },
+ {
+ Value: 200,
+ },
+ {
+ Value: 180,
+ },
+ },
+ Label: charts.SeriesLabel{
+ Show: true,
+ Position: charts.PositionBottom,
+ },
+ },
+ },
+ },
+ // 水平柱状图
+ {
+ Title: charts.TitleOption{
+ Text: "World Population",
+ },
+ Padding: charts.Box{
+ Top: 20,
+ Right: 40,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: charts.NewLegendOption([]string{
+ "2011",
+ "2012",
+ }),
+ YAxisOptions: charts.NewYAxisOptions([]string{
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ SeriesList: []charts.Series{
+ {
+ Type: charts.ChartTypeHorizontalBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230,
+ }),
+ },
+ {
+ Type: charts.ChartTypeHorizontalBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 19325,
+ 23438,
+ 31000,
+ 121594,
+ 134141,
+ 681807,
+ }),
+ },
+ },
+ },
+ // 柱状图+标记
+ {
+ Title: charts.TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ },
+ Padding: charts.Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ XAxis: charts.NewXAxisOption([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ Legend: charts.NewLegendOption([]string{
+ "Rainfall",
+ "Evaporation",
+ }, charts.PositionRight),
+ SeriesList: []charts.Series{
+ {
+ Type: charts.ChartTypeBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ }),
+ MarkPoint: charts.NewMarkPoint(
+ charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin,
+ ),
+ MarkLine: charts.NewMarkLine(
+ charts.SeriesMarkDataTypeAverage,
+ ),
+ },
+ {
+ Type: charts.ChartTypeBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ }),
+ MarkPoint: charts.NewMarkPoint(
+ charts.SeriesMarkDataTypeMax,
+ charts.SeriesMarkDataTypeMin,
+ ),
+ MarkLine: charts.NewMarkLine(
+ charts.SeriesMarkDataTypeAverage,
+ ),
+ },
+ },
+ },
+ // 双Y轴示例
+ {
+ Title: charts.TitleOption{
+ Text: "Temperature",
+ },
+ XAxis: charts.NewXAxisOption([]string{
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ }),
+ Legend: charts.NewLegendOption([]string{
+ "Evaporation",
+ "Precipitation",
+ "Temperature",
+ }),
+ YAxisOptions: []charts.YAxisOption{
+ {
+ Formatter: "{value}ml",
+ Color: charts.Color{
+ R: 84,
+ G: 112,
+ B: 198,
+ A: 255,
+ },
+ },
+ {
+ Formatter: "{value}°C",
+ Color: charts.Color{
+ R: 250,
+ G: 200,
+ B: 88,
+ A: 255,
+ },
+ },
+ },
+ SeriesList: []charts.Series{
+ {
+ Type: charts.ChartTypeBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.0,
+ 4.9,
+ 7.0,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20.0,
+ 6.4,
+ 3.3,
+ }),
+ },
+ {
+ Type: charts.ChartTypeBar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.6,
+ 5.9,
+ 9.0,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6.0,
+ 2.3,
+ }),
+ },
+ {
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 2.0,
+ 2.2,
+ 3.3,
+ 4.5,
+ 6.3,
+ 10.2,
+ 20.3,
+ 23.4,
+ 23.0,
+ 16.5,
+ 12.0,
+ 6.2,
+ }),
+ AxisIndex: 1,
+ },
+ },
+ },
+ // 饼图
+ {
+ Title: charts.TitleOption{
+ Text: "Referer of a Website",
+ Subtext: "Fake Data",
+ Left: charts.PositionCenter,
+ },
+ Legend: charts.LegendOption{
+ Orient: charts.OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: charts.PositionLeft,
+ },
+ SeriesList: charts.NewPieSeriesList([]float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }, charts.PieSeriesOption{
+ Label: charts.SeriesLabel{
+ Show: true,
+ },
+ Radius: "35%",
+ }),
+ },
+ // 雷达图
+ {
+ Title: charts.TitleOption{
+ Text: "Basic Radar Chart",
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ RadarIndicators: []charts.RadarIndicator{
+ {
+ Name: "Sales",
+ Max: 6500,
+ },
+ {
+ Name: "Administration",
+ Max: 16000,
+ },
+ {
+ Name: "Information Technology",
+ Max: 30000,
+ },
+ {
+ Name: "Customer Support",
+ Max: 38000,
+ },
+ {
+ Name: "Development",
+ Max: 52000,
+ },
+ {
+ Name: "Marketing",
+ Max: 25000,
+ },
+ },
+ SeriesList: charts.SeriesList{
+ {
+ Type: charts.ChartTypeRadar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ }),
+ },
+ {
+ Type: charts.ChartTypeRadar,
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
+ }),
+ },
+ },
+ },
+ // 漏斗图
+ {
+ Title: charts.TitleOption{
+ Text: "Funnel",
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order",
+ }),
+ SeriesList: []charts.Series{
+ {
+ Type: charts.ChartTypeFunnel,
+ Name: "Show",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 100,
+ }),
+ },
+ {
+ Type: charts.ChartTypeFunnel,
+ Name: "Click",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 80,
+ }),
+ },
+ {
+ Type: charts.ChartTypeFunnel,
+ Name: "Visit",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 60,
+ }),
+ },
+ {
+ Type: charts.ChartTypeFunnel,
+ Name: "Inquiry",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 40,
+ }),
+ },
+ {
+ Type: charts.ChartTypeFunnel,
+ Name: "Order",
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 20,
+ }),
+ },
+ },
+ },
+ // 多图展示
+ {
+ Legend: charts.LegendOption{
+ Top: "-90",
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Padding: charts.Box{
+ Top: 100,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: charts.NewXAxisOption([]string{
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017",
+ }),
+ YAxisOptions: []charts.YAxisOption{
+ {
+
+ Min: charts.NewFloatPoint(0),
+ Max: charts.NewFloatPoint(90),
+ },
+ },
+ SeriesList: []charts.Series{
+ charts.NewSeriesFromValues([]float64{
+ 56.5,
+ 82.1,
+ 88.7,
+ 70.1,
+ 53.4,
+ 85.1,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 51.1,
+ 51.4,
+ 55.1,
+ 53.3,
+ 73.8,
+ 68.7,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 40.1,
+ 62.2,
+ 69.5,
+ 36.4,
+ 45.2,
+ 32.5,
+ }, charts.ChartTypeBar),
+ charts.NewSeriesFromValues([]float64{
+ 25.2,
+ 37.1,
+ 41.2,
+ 18,
+ 33.9,
+ 49.1,
+ }, charts.ChartTypeBar),
+ },
+ Children: []charts.ChartOption{
+ {
+ Legend: charts.LegendOption{
+ Show: charts.FalseFlag(),
+ Data: []string{
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie",
+ },
+ },
+ Box: charts.Box{
+ Top: 20,
+ Left: 400,
+ Right: 500,
+ Bottom: 120,
+ },
+ SeriesList: charts.NewPieSeriesList([]float64{
+ 435.9,
+ 354.3,
+ 285.9,
+ 204.5,
+ }, charts.PieSeriesOption{
+ Label: charts.SeriesLabel{
+ Show: true,
+ },
+ Radius: "35%",
+ }),
+ },
+ },
+ },
+ }
+ handler(w, req, chartOptions, nil)
+}
+
+func echartsHandler(w http.ResponseWriter, req *http.Request) {
+ echartsOptions := []string{
+ `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 150,
+ 230,
+ 224,
+ 218,
+ 135,
+ 147,
+ 260
+ ],
+ "type": "line"
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Multiple Line"
+ },
+ "tooltip": {
+ "trigger": "axis"
+ },
+ "legend": {
+ "left": "right",
+ "data": [
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine"
+ ]
+ },
+ "grid": {
+ "left": "3%",
+ "right": "4%",
+ "bottom": "3%",
+ "containLabel": true
+ },
+ "toolbox": {
+ "feature": {
+ "saveAsImage": {}
+ }
+ },
+ "xAxis": {
+ "type": "category",
+ "boundaryGap": false,
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "name": "Email",
+ "type": "line",
+ "data": [
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210
+ ]
+ },
+ {
+ "name": "Union Ads",
+ "type": "line",
+ "data": [
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310
+ ]
+ },
+ {
+ "name": "Video Ads",
+ "type": "line",
+ "data": [
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410
+ ]
+ },
+ {
+ "name": "Direct",
+ "type": "line",
+ "data": [
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320
+ ]
+ },
+ {
+ "name": "Search Engine",
+ "type": "line",
+ "data": [
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Temperature Change in the Coming Week"
+ },
+ "legend": {
+ "left": "right"
+ },
+ "padding": [10, 30, 10, 10],
+ "xAxis": {
+ "type": "category",
+ "boundaryGap": false,
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "axisLabel": {
+ "formatter": "{value} °C"
+ }
+ },
+ "series": [
+ {
+ "name": "Highest",
+ "type": "line",
+ "data": [
+ 10,
+ 11,
+ 13,
+ 11,
+ 12,
+ 12,
+ 9
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ },
+ {
+ "name": "Lowest",
+ "type": "line",
+ "data": [
+ 1,
+ -2,
+ 2,
+ 5,
+ 3,
+ 2,
+ 0
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ },
+ {
+ "type": "max"
+ }
+ ]
+ }
+ }
+ ]
+ }`,
+ `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 120,
+ 200,
+ 150,
+ 80,
+ 70,
+ 110,
+ 130
+ ],
+ "type": "bar"
+ }
+ ]
+ }`,
+ `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 120,
+ {
+ "value": 200,
+ "itemStyle": {
+ "color": "#a90000"
+ }
+ },
+ 150,
+ 80,
+ 70,
+ 110,
+ 130
+ ],
+ "type": "bar"
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "World Population"
+ },
+ "tooltip": {
+ "trigger": "axis",
+ "axisPointer": {
+ "type": "shadow"
+ }
+ },
+ "legend": {},
+ "grid": {
+ "left": "3%",
+ "right": "4%",
+ "bottom": "3%",
+ "containLabel": true
+ },
+ "xAxis": {
+ "type": "value"
+ },
+ "yAxis": {
+ "type": "category",
+ "data": [
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World"
+ ]
+ },
+ "series": [
+ {
+ "name": "2011",
+ "type": "bar",
+ "data": [
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230
+ ]
+ },
+ {
+ "name": "2012",
+ "type": "bar",
+ "data": [
+ 19325,
+ 23438,
+ 31000,
+ 121594,
+ 134141,
+ 681807
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Rainfall vs Evaporation",
+ "subtext": "Fake Data"
+ },
+ "legend": {
+ "data": [
+ "Rainfall",
+ "Evaporation"
+ ]
+ },
+ "padding": [10, 30, 10, 10],
+ "xAxis": [
+ {
+ "type": "category",
+ "data": [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "name": "Rainfall",
+ "type": "bar",
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20,
+ 6.4,
+ 3.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ },
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6,
+ 2.3
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ }
+ ]
+ }`,
+ `{
+ "legend": {
+ "data": [
+ "Evaporation",
+ "Precipitation",
+ "Temperature"
+ ]
+ },
+ "xAxis": [
+ {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ],
+ "axisPointer": {
+ "type": "shadow"
+ }
+ }
+ ],
+ "yAxis": [
+ {
+ "type": "value",
+ "name": "Precipitation",
+ "min": 0,
+ "max": 240,
+ "axisLabel": {
+ "formatter": "{value} ml"
+ }
+ },
+ {
+ "type": "value",
+ "name": "Temperature",
+ "min": 0,
+ "max": 24,
+ "axisLabel": {
+ "formatter": "{value} °C"
+ }
+ }
+ ],
+ "series": [
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "tooltip": {},
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6
+ ]
+ },
+ {
+ "name": "Precipitation",
+ "type": "bar",
+ "tooltip": {},
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6
+ ]
+ },
+ {
+ "name": "Temperature",
+ "type": "line",
+ "yAxisIndex": 1,
+ "tooltip": {},
+ "data": [
+ 2,
+ 2.2,
+ 3.3,
+ 4.5,
+ 6.3,
+ 10.2,
+ 20.3
+ ]
+ }
+ ]
+ }`,
+ `{
+ "tooltip": {
+ "trigger": "axis",
+ "axisPointer": {
+ "type": "cross"
+ }
+ },
+ "grid": {
+ "right": "20%"
+ },
+ "toolbox": {
+ "feature": {
+ "dataView": {
+ "show": true,
+ "readOnly": false
+ },
+ "restore": {
+ "show": true
+ },
+ "saveAsImage": {
+ "show": true
+ }
+ }
+ },
+ "legend": {
+ "data": [
+ "Evaporation",
+ "Precipitation",
+ "Temperature"
+ ]
+ },
+ "xAxis": [
+ {
+ "type": "category",
+ "axisTick": {
+ "alignWithLabel": true
+ },
+ "data": [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec"
+ ]
+ }
+ ],
+ "yAxis": [
+ {
+ "type": "value",
+ "name": "温度",
+ "position": "left",
+ "alignTicks": true,
+ "axisLine": {
+ "show": true,
+ "lineStyle": {
+ "color": "#EE6666"
+ }
+ },
+ "axisLabel": {
+ "formatter": "{value} °C"
+ }
+ },
+ {
+ "type": "value",
+ "name": "Evaporation",
+ "position": "right",
+ "alignTicks": true,
+ "axisLine": {
+ "show": true,
+ "lineStyle": {
+ "color": "#5470C6"
+ }
+ },
+ "axisLabel": {
+ "formatter": "{value} ml"
+ }
+ }
+ ],
+ "series": [
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "yAxisIndex": 1,
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20,
+ 6.4,
+ 3.3
+ ]
+ },
+ {
+ "name": "Precipitation",
+ "type": "bar",
+ "yAxisIndex": 1,
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6,
+ 2.3
+ ]
+ },
+ {
+ "name": "Temperature",
+ "type": "line",
+ "data": [
+ 2,
+ 2.2,
+ 3.3,
+ 4.5,
+ 6.3,
+ 10.2,
+ 20.3,
+ 23.4,
+ 23,
+ 16.5,
+ 12,
+ 6.2
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Referer of a Website",
+ "subtext": "Fake Data",
+ "left": "center"
+ },
+ "tooltip": {
+ "trigger": "item"
+ },
+ "legend": {
+ "orient": "vertical",
+ "left": "left"
+ },
+ "series": [
+ {
+ "name": "Access From",
+ "type": "pie",
+ "radius": "50%",
+ "data": [
+ {
+ "value": 1048,
+ "name": "Search Engine"
+ },
+ {
+ "value": 735,
+ "name": "Direct"
+ },
+ {
+ "value": 580,
+ "name": "Email"
+ },
+ {
+ "value": 484,
+ "name": "Union Ads"
+ },
+ {
+ "value": 300,
+ "name": "Video Ads"
+ }
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Rainfall"
+ },
+ "padding": [10, 10, 10, 30],
+ "legend": {
+ "data": [
+ "GZ",
+ "SH"
+ ]
+ },
+ "xAxis": {
+ "type": "category",
+ "splitNumber": 6,
+ "data": [
+ "01-01",
+ "01-02",
+ "01-03",
+ "01-04",
+ "01-05",
+ "01-06",
+ "01-07",
+ "01-08",
+ "01-09",
+ "01-10",
+ "01-11",
+ "01-12",
+ "01-13",
+ "01-14",
+ "01-15",
+ "01-16",
+ "01-17",
+ "01-18",
+ "01-19",
+ "01-20",
+ "01-21",
+ "01-22",
+ "01-23",
+ "01-24",
+ "01-25",
+ "01-26",
+ "01-27",
+ "01-28",
+ "01-29",
+ "01-30",
+ "01-31"
+ ]
+ },
+ "yAxis": {
+ "axisLabel": {
+ "formatter": "{value} mm"
+ }
+ },
+ "series": [
+ {
+ "type": "bar",
+ "data": [
+ 928,
+ 821,
+ 889,
+ 600,
+ 547,
+ 783,
+ 197,
+ 853,
+ 430,
+ 346,
+ 63,
+ 465,
+ 309,
+ 334,
+ 141,
+ 538,
+ 792,
+ 58,
+ 922,
+ 807,
+ 298,
+ 243,
+ 744,
+ 885,
+ 812,
+ 231,
+ 330,
+ 220,
+ 984,
+ 221,
+ 429
+ ]
+ },
+ {
+ "type": "bar",
+ "data": [
+ 749,
+ 201,
+ 296,
+ 579,
+ 255,
+ 159,
+ 902,
+ 246,
+ 149,
+ 158,
+ 507,
+ 776,
+ 186,
+ 79,
+ 390,
+ 222,
+ 601,
+ 367,
+ 221,
+ 411,
+ 714,
+ 620,
+ 966,
+ 73,
+ 203,
+ 631,
+ 833,
+ 610,
+ 487,
+ 677,
+ 596
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Basic Radar Chart"
+ },
+ "legend": {
+ "data": [
+ "Allocated Budget",
+ "Actual Spending"
+ ]
+ },
+ "radar": {
+ "indicator": [
+ {
+ "name": "Sales",
+ "max": 6500
+ },
+ {
+ "name": "Administration",
+ "max": 16000
+ },
+ {
+ "name": "Information Technology",
+ "max": 30000
+ },
+ {
+ "name": "Customer Support",
+ "max": 38000
+ },
+ {
+ "name": "Development",
+ "max": 52000
+ },
+ {
+ "name": "Marketing",
+ "max": 25000
+ }
+ ]
+ },
+ "series": [
+ {
+ "name": "Budget vs spending",
+ "type": "radar",
+ "data": [
+ {
+ "value": [
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000
+ ],
+ "name": "Allocated Budget"
+ },
+ {
+ "value": [
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000
+ ],
+ "name": "Actual Spending"
+ }
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Funnel"
+ },
+ "tooltip": {
+ "trigger": "item",
+ "formatter": "{a} {b} : {c}%"
+ },
+ "toolbox": {
+ "feature": {
+ "dataView": {
+ "readOnly": false
+ },
+ "restore": {},
+ "saveAsImage": {}
+ }
+ },
+ "legend": {
+ "data": [
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order"
+ ]
+ },
+ "series": [
+ {
+ "name": "Funnel",
+ "type": "funnel",
+ "left": "10%",
+ "top": 60,
+ "bottom": 60,
+ "width": "80%",
+ "min": 0,
+ "max": 100,
+ "minSize": "0%",
+ "maxSize": "100%",
+ "sort": "descending",
+ "gap": 2,
+ "label": {
+ "show": true,
+ "position": "inside"
+ },
+ "labelLine": {
+ "length": 10,
+ "lineStyle": {
+ "width": 1,
+ "type": "solid"
+ }
+ },
+ "itemStyle": {
+ "borderColor": "#fff",
+ "borderWidth": 1
+ },
+ "emphasis": {
+ "label": {
+ "fontSize": 20
+ }
+ },
+ "data": [
+ {
+ "value": 60,
+ "name": "Visit"
+ },
+ {
+ "value": 40,
+ "name": "Inquiry"
+ },
+ {
+ "value": 20,
+ "name": "Order"
+ },
+ {
+ "value": 80,
+ "name": "Click"
+ },
+ {
+ "value": 100,
+ "name": "Show"
+ }
+ ]
+ }
+ ]
+ }`,
+ `{
+ "legend": {
+ "top": "-140",
+ "data": [
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie"
+ ]
+ },
+ "padding": [
+ 150,
+ 10,
+ 10,
+ 10
+ ],
+ "xAxis": [
+ {
+ "data": [
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "data": [
+ 56.5,
+ 82.1,
+ 88.7,
+ 70.1,
+ 53.4,
+ 85.1
+ ]
+ },
+ {
+ "data": [
+ 51.1,
+ 51.4,
+ 55.1,
+ 53.3,
+ 73.8,
+ 68.7
+ ]
+ },
+ {
+ "data": [
+ 40.1,
+ 62.2,
+ 69.5,
+ 36.4,
+ 45.2,
+ 32.5
+ ]
+ },
+ {
+ "data": [
+ 25.2,
+ 37.1,
+ 41.2,
+ 18,
+ 33.9,
+ 49.1
+ ]
+ }
+ ],
+ "children": [
+ {
+ "box": {
+ "left": 0,
+ "top": 30,
+ "right": 600,
+ "bottom": 150
+ },
+ "legend": {
+ "show": false
+ },
+ "series": [
+ {
+ "type": "pie",
+ "radius": "50%",
+ "data": [
+ {
+ "value": 435.9,
+ "name": "Milk Tea"
+ },
+ {
+ "value": 354.3,
+ "name": "Matcha Latte"
+ },
+ {
+ "value": 285.9,
+ "name": "Cheese Cocoa"
+ },
+ {
+ "value": 204.5,
+ "name": "Walnut Brownie"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }`,
+ }
+ handler(w, req, nil, echartsOptions)
+}
+
+func main() {
+ http.HandleFunc("/", indexHandler)
+ http.HandleFunc("/echarts", echartsHandler)
+ fmt.Println("http://127.0.0.1:3012/")
+ http.ListenAndServe(":3012", nil)
+}
diff --git a/examples/chinese/main.go b/examples/chinese/main.go
new file mode 100644
index 0000000..601f54e
--- /dev/null
+++ b/examples/chinese/main.go
@@ -0,0 +1,120 @@
+package main
+
+import (
+ "io/ioutil"
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "chinese-line-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ // 字体文件需要自行下载
+ // https://github.com/googlefonts/noto-cjk
+ buf, err := ioutil.ReadFile("./NotoSansSC.ttf")
+ if err != nil {
+ panic(err)
+ }
+ err = charts.InstallFont("noto", buf)
+ if err != nil {
+ panic(err)
+ }
+ font, _ := charts.GetFont("noto")
+ charts.SetDefaultFont(font)
+
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ p, err := charts.LineRender(
+ values,
+ charts.TitleTextOptionFunc("测试"),
+ charts.XAxisDataOptionFunc([]string{
+ "星期一",
+ "星期二",
+ "星期三",
+ "星期四",
+ "星期五",
+ "星期六",
+ "星期日",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "邮件",
+ "广告",
+ "视频广告",
+ "直接访问",
+ "搜索引擎",
+ }, charts.PositionCenter),
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err = p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/funnel_chart/main.go b/examples/funnel_chart/main.go
new file mode 100644
index 0000000..653f834
--- /dev/null
+++ b/examples/funnel_chart/main.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "funnel-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := []float64{
+ 100,
+ 80,
+ 60,
+ 40,
+ 20,
+ 10,
+ 0,
+ }
+ p, err := charts.FunnelRender(
+ values,
+ charts.TitleTextOptionFunc("Funnel"),
+ charts.LegendLabelsOptionFunc([]string{
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order",
+ "Pay",
+ "Cancel",
+ }),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/horizontal_bar_chart/main.go b/examples/horizontal_bar_chart/main.go
new file mode 100644
index 0000000..f5d8497
--- /dev/null
+++ b/examples/horizontal_bar_chart/main.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "horizontal-bar-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 10,
+ 30,
+ 50,
+ 70,
+ 90,
+ 110,
+ 130,
+ },
+ {
+ 20,
+ 40,
+ 60,
+ 80,
+ 100,
+ 120,
+ 140,
+ },
+ }
+ p, err := charts.HorizontalBarRender(
+ values,
+ charts.TitleTextOptionFunc("World Population"),
+ charts.PaddingOptionFunc(charts.Box{
+ Top: 20,
+ Right: 40,
+ Bottom: 20,
+ Left: 20,
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "2011",
+ "2012",
+ }),
+ charts.YAxisDataOptionFunc([]string{
+ "UN",
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ func(opt *charts.ChartOption) {
+ opt.SeriesList[0].RoundRadius = 5
+ },
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/line_chart/main.go b/examples/line_chart/main.go
new file mode 100644
index 0000000..baee8a3
--- /dev/null
+++ b/examples/line_chart/main.go
@@ -0,0 +1,124 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "line-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ // 134,
+ charts.GetNullValue(),
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ p, err := charts.LineRender(
+ values,
+ charts.TitleTextOptionFunc("Line"),
+ charts.XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ charts.LegendLabelsOptionFunc([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }, "50"),
+ func(opt *charts.ChartOption) {
+ opt.Legend.Padding = charts.Box{
+ Top: 5,
+ Bottom: 10,
+ }
+ opt.YAxisOptions = []charts.YAxisOption{
+ {
+ SplitLineShow: charts.FalseFlag(),
+ },
+ }
+ opt.SymbolShow = charts.FalseFlag()
+ opt.LineStrokeWidth = 1
+ opt.ValueFormatter = func(f float64) string {
+ return fmt.Sprintf("%.0f", f)
+ }
+ },
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/painter/main.go b/examples/painter/main.go
new file mode 100644
index 0000000..1b842b3
--- /dev/null
+++ b/examples/painter/main.go
@@ -0,0 +1,607 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ charts "git.smarteching.com/zeni/go-charts/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "painter.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ p, err := charts.NewPainter(charts.PainterOptions{
+ Width: 600,
+ Height: 2000,
+ Type: charts.ChartOutputPNG,
+ })
+ if err != nil {
+ panic(err)
+ }
+ // 背景色
+ p.SetBackground(p.Width(), p.Height(), drawing.ColorWhite)
+
+ top := 0
+
+ // 画线
+ p.SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ })
+ p.LineStroke([]charts.Point{
+ {
+ X: 0,
+ Y: 0,
+ },
+ {
+ X: 100,
+ Y: 10,
+ },
+ {
+ X: 200,
+ Y: 0,
+ },
+ {
+ X: 300,
+ Y: 10,
+ },
+ })
+
+ // 圆滑曲线
+ // top += 50
+ // p.Child(charts.PainterPaddingOption(charts.Box{
+ // Top: top,
+ // })).SetDrawingStyle(charts.Style{
+ // StrokeColor: drawing.ColorBlack,
+ // FillColor: drawing.ColorBlack,
+ // StrokeWidth: 1,
+ // }).SmoothLineStroke([]charts.Point{
+ // {
+ // X: 0,
+ // Y: 0,
+ // },
+ // {
+ // X: 100,
+ // Y: 10,
+ // },
+ // {
+ // X: 200,
+ // Y: 0,
+ // },
+ // {
+ // X: 300,
+ // Y: 10,
+ // },
+ // })
+
+ // 标线
+ top += 50
+ p.Child(charts.PainterPaddingOption(charts.Box{
+ Top: top,
+ })).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ StrokeDashArray: []float64{
+ 4,
+ 2,
+ },
+ }).MarkLine(0, 0, p.Width())
+
+ top += 60
+ // Polygon
+ p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ })).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Polygon(charts.Point{
+ X: 100,
+ Y: 0,
+ }, 50, 6)
+
+ // FillArea
+ top += 60
+ p.Child(charts.PainterPaddingOption(charts.Box{
+ Top: top,
+ })).SetDrawingStyle(charts.Style{
+ FillColor: drawing.ColorBlack,
+ }).FillArea([]charts.Point{
+ {
+ X: 0,
+ Y: 0,
+ },
+ {
+ X: 100,
+ Y: 0,
+ },
+ {
+ X: 150,
+ Y: 40,
+ },
+ {
+ X: 80,
+ Y: 30,
+ },
+ {
+ X: 0,
+ Y: 0,
+ },
+ })
+
+ // 坐标轴的点
+ top += 50
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: 20,
+ }),
+ ).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Ticks(charts.TicksOption{
+ Count: 7,
+ Length: 5,
+ })
+
+ // 坐标轴的点,每2格显示一个
+ top += 20
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: 20,
+ }),
+ ).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Ticks(charts.TicksOption{
+ Unit: 2,
+ Count: 7,
+ Length: 5,
+ })
+
+ // 坐标轴的点,纵向
+ top += 20
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 100,
+ }),
+ ).SetDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Ticks(charts.TicksOption{
+ Orient: charts.OrientVertical,
+ Count: 7,
+ Length: 5,
+ })
+
+ // 横向展示文本
+ top += 120
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: 20,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+
+ // 横向显示文本,靠左
+ top += 20
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: 20,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ Position: charts.PositionLeft,
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+
+ // 纵向显示文本
+ top += 20
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: 50,
+ Bottom: top + 150,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ Orient: charts.OrientVertical,
+ Align: charts.AlignRight,
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+ // 纵向 文本居中
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 50,
+ Right: 100,
+ Bottom: top + 150,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ Orient: charts.OrientVertical,
+ Align: charts.AlignCenter,
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+ // 纵向 文本置顶
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 100,
+ Right: 150,
+ Bottom: top + 150,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).MultiText(charts.MultiTextOption{
+ Orient: charts.OrientVertical,
+ Position: charts.PositionTop,
+ Align: charts.AlignCenter,
+ TextList: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ })
+
+ // grid
+ top += 150
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 100,
+ }),
+ ).OverrideTextStyle(charts.Style{
+ FontColor: drawing.ColorBlack,
+ FontSize: 10,
+ }).Grid(charts.GridOption{
+ Column: 8,
+ IgnoreColumnLines: []int{0, 8},
+ Row: 8,
+ IgnoreRowLines: []int{0, 8},
+ })
+
+ // dots
+ top += 100
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 20,
+ }),
+ ).OverrideDrawingStyle(charts.Style{
+ FillColor: drawing.ColorWhite,
+ StrokeColor: drawing.ColorBlack,
+ StrokeWidth: 1,
+ }).Dots([]charts.Point{
+ {
+ X: 0,
+ Y: 0,
+ },
+ {
+ X: 50,
+ Y: 0,
+ },
+ {
+ X: 100,
+ Y: 10,
+ },
+ })
+
+ // rect
+ top += 30
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: 200,
+ Bottom: top + 50,
+ }),
+ ).OverrideDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ }).Rect(charts.Box{
+ Left: 10,
+ Top: 0,
+ Right: 110,
+ Bottom: 20,
+ })
+ // legend line dot
+ p.Child(
+ charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 200,
+ Right: p.Width() - 1,
+ Bottom: top + 50,
+ }),
+ ).OverrideDrawingStyle(charts.Style{
+ StrokeColor: drawing.ColorBlack,
+ FillColor: drawing.ColorBlack,
+ }).LegendLineDot(charts.Box{
+ Left: 10,
+ Top: 0,
+ Right: 50,
+ Bottom: 20,
+ })
+
+ // grid
+ top += 50
+ charts.NewGridPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 100,
+ })), charts.GridPainterOption{
+ Row: 5,
+ IgnoreFirstRow: true,
+ IgnoreLastRow: true,
+ StrokeColor: drawing.ColorBlue,
+ }).Render()
+
+ // legend
+ top += 100
+ charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 30,
+ })), charts.LegendOption{
+ Left: "10",
+ Data: []string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ },
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // legend
+ top += 30
+ charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 30,
+ })), charts.LegendOption{
+ Left: charts.PositionRight,
+ Data: []string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ },
+ Align: charts.AlignRight,
+ FontSize: 16,
+ Icon: charts.IconRect,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // legend
+ top += 30
+ charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 100,
+ })), charts.LegendOption{
+ Top: "10",
+ Data: []string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ },
+ Orient: charts.OrientVertical,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // axis bottom
+ top += 100
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 50,
+ })), charts.AxisOption{
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ StrokeColor: drawing.ColorBlack,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // axis top
+ top += 50
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 1,
+ Right: p.Width() - 1,
+ Bottom: top + 50,
+ })), charts.AxisOption{
+ Position: charts.PositionTop,
+ BoundaryGap: charts.FalseFlag(),
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ StrokeColor: drawing.ColorBlack,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // axis left
+ top += 50
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 10,
+ Right: 60,
+ Bottom: top + 200,
+ })), charts.AxisOption{
+ Position: charts.PositionLeft,
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ StrokeColor: drawing.ColorBlack,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+ // axis right
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 100,
+ Right: 150,
+ Bottom: top + 200,
+ })), charts.AxisOption{
+ Position: charts.PositionRight,
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ StrokeColor: drawing.ColorBlack,
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ }).Render()
+
+ // axis left no tick
+ charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
+ Top: top,
+ Left: 150,
+ Right: 300,
+ Bottom: top + 200,
+ })), charts.AxisOption{
+ BoundaryGap: charts.FalseFlag(),
+ Position: charts.PositionLeft,
+ Data: []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ },
+ FontSize: 12,
+ FontColor: drawing.ColorBlack,
+ SplitLineShow: true,
+ SplitLineColor: drawing.ColorBlack.WithAlpha(100),
+ }).Render()
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go
new file mode 100644
index 0000000..5d70438
--- /dev/null
+++ b/examples/pie_chart/main.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "pie-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := []float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }
+ p, err := charts.PieRender(
+ values,
+ charts.TitleOptionFunc(charts.TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ Left: charts.PositionCenter,
+ }),
+ charts.PaddingOptionFunc(charts.Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ }),
+ charts.LegendOptionFunc(charts.LegendOption{
+ Orient: charts.OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: charts.PositionLeft,
+ }),
+ charts.PieSeriesShowLabel(),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/radar_chart/main.go b/examples/radar_chart/main.go
new file mode 100644
index 0000000..e7053af
--- /dev/null
+++ b/examples/radar_chart/main.go
@@ -0,0 +1,79 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "radar-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ values := [][]float64{
+ {
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ },
+ {
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
+ },
+ }
+ p, err := charts.RadarRender(
+ values,
+ charts.TitleTextOptionFunc("Basic Radar Chart"),
+ charts.LegendLabelsOptionFunc([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ charts.RadarIndicatorOptionFunc([]string{
+ "Sales",
+ "Administration",
+ "Information Technology",
+ "Customer Support",
+ "Development",
+ "Marketing",
+ }, []float64{
+ 6500,
+ 16000,
+ 30000,
+ 38000,
+ 52000,
+ 25000,
+ }),
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/table/main.go b/examples/table/main.go
new file mode 100644
index 0000000..de994eb
--- /dev/null
+++ b/examples/table/main.go
@@ -0,0 +1,178 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func writeFile(buf []byte, filename string) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, filename)
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ // charts.SetDefaultTableSetting(charts.TableDarkThemeSetting)
+ charts.SetDefaultWidth(810)
+ header := []string{
+ "Name",
+ "Age",
+ "Address",
+ "Tag",
+ "Action",
+ }
+ data := [][]string{
+ {
+ "John Brown",
+ "32",
+ "New York No. 1 Lake Park",
+ "nice, developer",
+ "Send Mail",
+ },
+ {
+ "Jim Green ",
+ "42",
+ "London No. 1 Lake Park",
+ "wow",
+ "Send Mail",
+ },
+ {
+ "Joe Black ",
+ "32",
+ "Sidney No. 1 Lake Park",
+ "cool, teacher",
+ "Send Mail",
+ },
+ }
+ spans := map[int]int{
+ 0: 2,
+ 1: 1,
+ // 设置第三列的span
+ 2: 3,
+ 3: 2,
+ 4: 2,
+ }
+ p, err := charts.TableRender(
+ header,
+ data,
+ spans,
+ )
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf, "table.png")
+ if err != nil {
+ panic(err)
+ }
+
+ bgColor := charts.Color{
+ R: 16,
+ G: 22,
+ B: 30,
+ A: 255,
+ }
+ p, err = charts.TableOptionRender(charts.TableChartOption{
+ Header: []string{
+ "Name",
+ "Price",
+ "Change",
+ },
+ BackgroundColor: bgColor,
+ HeaderBackgroundColor: bgColor,
+ RowBackgroundColors: []charts.Color{
+ bgColor,
+ },
+ HeaderFontColor: drawing.ColorWhite,
+ FontColor: drawing.ColorWhite,
+ Padding: charts.Box{
+ Top: 15,
+ Right: 10,
+ Bottom: 15,
+ Left: 10,
+ },
+ Data: [][]string{
+ {
+ "Datadog Inc",
+ "97.32",
+ "-7.49%",
+ },
+ {
+ "Hashicorp Inc",
+ "28.66",
+ "-9.25%",
+ },
+ {
+ "Gitlab Inc",
+ "51.63",
+ "+4.32%",
+ },
+ },
+ TextAligns: []string{
+ "",
+ charts.AlignRight,
+ charts.AlignRight,
+ },
+ CellStyle: func(tc charts.TableCell) *charts.Style {
+ column := tc.Column
+ if column != 2 {
+ return nil
+ }
+ value, _ := strconv.ParseFloat(strings.Replace(tc.Text, "%", "", 1), 64)
+ if value == 0 {
+ return nil
+ }
+ style := charts.Style{
+ Padding: charts.Box{
+ Bottom: 5,
+ },
+ }
+ if value > 0 {
+ style.FillColor = charts.Color{
+ R: 179,
+ G: 53,
+ B: 20,
+ A: 255,
+ }
+ } else if value < 0 {
+ style.FillColor = charts.Color{
+ R: 33,
+ G: 124,
+ B: 50,
+ A: 255,
+ }
+ }
+ return &style
+ },
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err = p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf, "table-color.png")
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/examples/time_line_chart/main.go b/examples/time_line_chart/main.go
new file mode 100644
index 0000000..c6c93bf
--- /dev/null
+++ b/examples/time_line_chart/main.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+ "crypto/rand"
+ "fmt"
+ "math/big"
+ "os"
+ "path/filepath"
+ "time"
+
+ "git.smarteching.com/zeni/go-charts/v2"
+)
+
+func writeFile(buf []byte) error {
+ tmpPath := "./tmp"
+ err := os.MkdirAll(tmpPath, 0700)
+ if err != nil {
+ return err
+ }
+
+ file := filepath.Join(tmpPath, "time-line-chart.png")
+ err = os.WriteFile(file, buf, 0600)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func main() {
+ xAxisValue := []string{}
+ values := []float64{}
+ now := time.Now()
+ firstAxis := 0
+ for i := 0; i < 300; i++ {
+ // 设置首个axis为xx:00的时间点
+ if firstAxis == 0 && now.Minute() == 0 {
+ firstAxis = i
+ }
+ xAxisValue = append(xAxisValue, now.Format("15:04"))
+ now = now.Add(time.Minute)
+ value, _ := rand.Int(rand.Reader, big.NewInt(100))
+ values = append(values, float64(value.Int64()))
+ }
+ p, err := charts.LineRender(
+ [][]float64{
+ values,
+ },
+ charts.TitleTextOptionFunc("Line"),
+ charts.XAxisDataOptionFunc(xAxisValue, charts.FalseFlag()),
+ charts.LegendLabelsOptionFunc([]string{
+ "Demo",
+ }, "50"),
+ func(opt *charts.ChartOption) {
+ opt.XAxis.FirstAxis = firstAxis
+ // 必须要比计算得来的最小值更大(每60分钟)
+ opt.XAxis.SplitNumber = 60
+ opt.Legend.Padding = charts.Box{
+ Top: 5,
+ Bottom: 10,
+ }
+ opt.SymbolShow = charts.FalseFlag()
+ opt.LineStrokeWidth = 1
+ opt.ValueFormatter = func(f float64) string {
+ return fmt.Sprintf("%.0f", f)
+ }
+ },
+ )
+
+ if err != nil {
+ panic(err)
+ }
+
+ buf, err := p.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ err = writeFile(buf)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/font.go b/font.go
new file mode 100644
index 0000000..828654e
--- /dev/null
+++ b/font.go
@@ -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
+}
diff --git a/font_test.go b/font_test.go
new file mode 100644
index 0000000..e0c56b2
--- /dev/null
+++ b/font_test.go
@@ -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)
+}
diff --git a/funnel_chart.go b/funnel_chart.go
new file mode 100644
index 0000000..d4a8bdd
--- /dev/null
+++ b/funnel_chart.go
@@ -0,0 +1,192 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "github.com/golang/freetype/truetype"
+)
+
+type funnelChart struct {
+ p *Painter
+ opt *FunnelChartOption
+}
+
+// NewFunnelSeriesList returns a series list for funnel
+func NewFunnelSeriesList(values []float64) SeriesList {
+ seriesList := make(SeriesList, len(values))
+ for index, value := range values {
+ seriesList[index] = NewSeriesFromValues([]float64{
+ value,
+ }, ChartTypeFunnel)
+ }
+ return seriesList
+}
+
+// NewFunnelChart returns a funnel chart renderer
+func NewFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &funnelChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type FunnelChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The padding of line chart
+ Padding Box
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+}
+
+func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ opt := f.opt
+ seriesPainter := result.seriesPainter
+ max := seriesList[0].Data[0].Value
+ min := float64(0)
+ for _, item := range seriesList {
+ if item.Max != nil {
+ max = *item.Max
+ }
+ if item.Min != nil {
+ min = *item.Min
+ }
+ }
+ theme := opt.Theme
+ gap := 2
+ height := seriesPainter.Height()
+ width := seriesPainter.Width()
+ count := len(seriesList)
+
+ h := (height - gap*(count-1)) / count
+
+ y := 0
+ widthList := make([]int, len(seriesList))
+ textList := make([]string, len(seriesList))
+ seriesNames := seriesList.Names()
+ offset := max - min
+ for index, item := range seriesList {
+ value := item.Data[0].Value
+ // 最大最小值一致则为100%
+ widthPercent := 100.0
+ if offset != 0 {
+ widthPercent = (value - min) / offset
+ }
+ w := int(widthPercent * float64(width))
+ widthList[index] = w
+ // 如果最大值为0,则占比100%
+ percent := 1.0
+ if max != 0 {
+ percent = value / max
+ }
+ textList[index] = NewFunnelLabelFormatter(seriesNames, item.Label.Formatter)(index, value, percent)
+ }
+
+ for index, w := range widthList {
+ series := seriesList[index]
+ nextWidth := 0
+ if index+1 < len(widthList) {
+ nextWidth = widthList[index+1]
+ }
+ topStartX := (width - w) >> 1
+ topEndX := topStartX + w
+ bottomStartX := (width - nextWidth) >> 1
+ bottomEndX := bottomStartX + nextWidth
+ points := []Point{
+ {
+ X: topStartX,
+ Y: y,
+ },
+ {
+ X: topEndX,
+ Y: y,
+ },
+ {
+ X: bottomEndX,
+ Y: y + h,
+ },
+ {
+ X: bottomStartX,
+ Y: y + h,
+ },
+ {
+ X: topStartX,
+ Y: y,
+ },
+ }
+ color := theme.GetSeriesColor(series.index)
+
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: color,
+ }).FillArea(points)
+
+ // 文本
+ text := textList[index]
+ seriesPainter.OverrideTextStyle(Style{
+ FontColor: theme.GetTextColor(),
+ FontSize: labelFontSize,
+ Font: opt.Font,
+ })
+ textBox := seriesPainter.MeasureText(text)
+ textX := width>>1 - textBox.Width()>>1
+ textY := y + h>>1
+ seriesPainter.Text(text, textX, textY)
+ y += (h + gap)
+ }
+
+ return f.p.box, nil
+}
+
+func (f *funnelChart) Render() (Box, error) {
+ p := f.p
+ opt := f.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: XAxisOption{
+ Show: FalseFlag(),
+ },
+ YAxisOptions: []YAxisOption{
+ {
+ Show: FalseFlag(),
+ },
+ },
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeFunnel)
+ return f.render(renderResult, seriesList)
+}
diff --git a/funnel_chart_test.go b/funnel_chart_test.go
new file mode 100644
index 0000000..d260bfb
--- /dev/null
+++ b/funnel_chart_test.go
@@ -0,0 +1,79 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFunnelChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewFunnelChart(p, FunnelChartOption{
+ SeriesList: NewFunnelSeriesList([]float64{
+ 100,
+ 80,
+ 60,
+ 40,
+ 20,
+ }),
+ Legend: NewLegendOption([]string{
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order",
+ }),
+ Title: TitleOption{
+ Text: "Funnel",
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/go.mod b/go.mod
index 60f8934..76a47b6 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index fba9022..3e1a48a 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/grid.go b/grid.go
new file mode 100644
index 0000000..0ebd226
--- /dev/null
+++ b/grid.go
@@ -0,0 +1,92 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+type gridPainter struct {
+ p *Painter
+ opt *GridPainterOption
+}
+
+type GridPainterOption struct {
+ // The stroke width
+ StrokeWidth float64
+ // The stroke color
+ StrokeColor Color
+ // The spans of column
+ ColumnSpans []int
+ // The column of grid
+ Column int
+ // The row of grid
+ Row int
+ // Ignore first row
+ IgnoreFirstRow bool
+ // Ignore last row
+ IgnoreLastRow bool
+ // Ignore first column
+ IgnoreFirstColumn bool
+ // Ignore last column
+ IgnoreLastColumn bool
+}
+
+// NewGridPainter returns new a grid renderer
+func NewGridPainter(p *Painter, opt GridPainterOption) *gridPainter {
+ return &gridPainter{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (g *gridPainter) Render() (Box, error) {
+ opt := g.opt
+ ignoreColumnLines := make([]int, 0)
+ if opt.IgnoreFirstColumn {
+ ignoreColumnLines = append(ignoreColumnLines, 0)
+ }
+ if opt.IgnoreLastColumn {
+ ignoreColumnLines = append(ignoreColumnLines, opt.Column)
+ }
+ ignoreRowLines := make([]int, 0)
+ if opt.IgnoreFirstRow {
+ ignoreRowLines = append(ignoreRowLines, 0)
+ }
+ if opt.IgnoreLastRow {
+ ignoreRowLines = append(ignoreRowLines, opt.Row)
+ }
+ strokeWidth := opt.StrokeWidth
+ if strokeWidth <= 0 {
+ strokeWidth = 1
+ }
+
+ g.p.SetDrawingStyle(Style{
+ StrokeWidth: strokeWidth,
+ StrokeColor: opt.StrokeColor,
+ })
+ g.p.Grid(GridOption{
+ Column: opt.Column,
+ ColumnSpans: opt.ColumnSpans,
+ Row: opt.Row,
+ IgnoreColumnLines: ignoreColumnLines,
+ IgnoreRowLines: ignoreRowLines,
+ })
+ return g.p.box, nil
+}
diff --git a/grid_test.go b/grid_test.go
new file mode 100644
index 0000000..fa9c3a6
--- /dev/null
+++ b/grid_test.go
@@ -0,0 +1,87 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestGrid(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewGridPainter(p, GridPainterOption{
+ StrokeColor: drawing.ColorBlack,
+ Column: 6,
+ Row: 6,
+ IgnoreFirstRow: true,
+ IgnoreLastRow: true,
+ IgnoreFirstColumn: true,
+ IgnoreLastColumn: true,
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ {
+ 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: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go
new file mode 100644
index 0000000..ed091c9
--- /dev/null
+++ b/horizontal_bar_chart.go
@@ -0,0 +1,216 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type horizontalBarChart struct {
+ p *Painter
+ opt *HorizontalBarChartOption
+}
+
+type HorizontalBarChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The x axis option
+ XAxis XAxisOption
+ // The padding of line chart
+ Padding Box
+ // The y axis option
+ YAxisOptions []YAxisOption
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ BarHeight int
+ // Margin of bar
+ BarMargin int
+}
+
+// NewHorizontalBarChart returns a horizontal bar chart renderer
+func NewHorizontalBarChart(p *Painter, opt HorizontalBarChartOption) *horizontalBarChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &horizontalBarChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ p := h.p
+ opt := h.opt
+ seriesPainter := result.seriesPainter
+ yRange := result.axisRanges[0]
+ y0, y1 := yRange.GetRange(0)
+ height := int(y1 - y0)
+ // 每一块之间的margin
+ margin := 10
+ // 每一个bar之间的margin
+ barMargin := 5
+ if height < 20 {
+ margin = 2
+ barMargin = 2
+ } else if height < 50 {
+ margin = 5
+ barMargin = 3
+ }
+ if opt.BarMargin > 0 {
+ barMargin = opt.BarMargin
+ }
+ seriesCount := len(seriesList)
+ // 总的高度-两个margin-(总数-1)的barMargin
+ barHeight := (height - 2*margin - barMargin*(seriesCount-1)) / seriesCount
+ if opt.BarHeight > 0 && opt.BarHeight < barHeight {
+ barHeight = opt.BarHeight
+ margin = (height - seriesCount*barHeight - barMargin*(seriesCount-1)) / 2
+ }
+
+ theme := opt.Theme
+
+ max, min := seriesList.GetMaxMin(0)
+ xRange := NewRange(AxisRangeOption{
+ Painter: p,
+ Min: min,
+ Max: max,
+ DivideCount: defaultAxisDivideCount,
+ Size: seriesPainter.Width(),
+ })
+ seriesNames := seriesList.Names()
+
+ rendererList := []Renderer{}
+ for index := range seriesList {
+ series := seriesList[index]
+ seriesColor := theme.GetSeriesColor(series.index)
+ divideValues := yRange.AutoDivide()
+
+ var labelPainter *SeriesLabelPainter
+ if series.Label.Show {
+ labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
+ P: seriesPainter,
+ SeriesNames: seriesNames,
+ Label: series.Label,
+ Theme: opt.Theme,
+ Font: opt.Font,
+ })
+ rendererList = append(rendererList, labelPainter)
+ }
+ for j, item := range series.Data {
+ if j >= yRange.divideCount {
+ continue
+ }
+ // 显示位置切换
+ j = yRange.divideCount - j - 1
+ y := divideValues[j]
+ y += margin
+ if index != 0 {
+ y += index * (barHeight + barMargin)
+ }
+
+ w := int(xRange.getHeight(item.Value))
+ fillColor := seriesColor
+ if !item.Style.FillColor.IsZero() {
+ fillColor = item.Style.FillColor
+ }
+ right := w
+ if series.RoundRadius <= 0 {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).Rect(chart.Box{
+ Top: y,
+ Left: 0,
+ Right: right,
+ Bottom: y + barHeight,
+ })
+ } else {
+ seriesPainter.OverrideDrawingStyle(Style{
+ FillColor: fillColor,
+ }).RoundedRect(chart.Box{
+ Top: y,
+ Left: 0,
+ Right: right,
+ Bottom: y + barHeight,
+ }, series.RoundRadius)
+ }
+
+ // 如果label不需要展示,则返回
+ if labelPainter == nil {
+ continue
+ }
+ labelValue := LabelValue{
+ Orient: OrientHorizontal,
+ Index: index,
+ Value: item.Value,
+ X: right,
+ Y: y + barHeight>>1,
+ Offset: series.Label.Offset,
+ FontColor: series.Label.Color,
+ FontSize: series.Label.FontSize,
+ }
+ if series.Label.Position == PositionLeft {
+ labelValue.X = 0
+ if labelValue.FontColor.IsZero() {
+ if isLightColor(fillColor) {
+ labelValue.FontColor = defaultLightFontColor
+ } else {
+ labelValue.FontColor = defaultDarkFontColor
+ }
+ }
+ }
+ labelPainter.Add(labelValue)
+ }
+ }
+ err := doRender(rendererList...)
+ if err != nil {
+ return BoxZero, err
+ }
+ return p.box, nil
+}
+
+func (h *horizontalBarChart) Render() (Box, error) {
+ p := h.p
+ opt := h.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: opt.XAxis,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ axisReversed: true,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeHorizontalBar)
+ return h.render(renderResult, seriesList)
+}
diff --git a/horizontal_bar_chart_test.go b/horizontal_bar_chart_test.go
new file mode 100644
index 0000000..e078c4a
--- /dev/null
+++ b/horizontal_bar_chart_test.go
@@ -0,0 +1,100 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHorizontalBarChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewHorizontalBarChart(p, HorizontalBarChartOption{
+ Padding: Box{
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ SeriesList: NewSeriesListDataFromValues([][]float64{
+ {
+ 18203,
+ 23489,
+ 29034,
+ 104970,
+ 131744,
+ 630230,
+ },
+ {
+ 19325,
+ 23438,
+ 31000,
+ 121594,
+ 134141,
+ 681807,
+ },
+ }, ChartTypeHorizontalBar),
+ Title: TitleOption{
+ Text: "World Population",
+ },
+ Legend: NewLegendOption([]string{
+ "2011",
+ "2012",
+ }),
+ YAxisOptions: NewYAxisOptions([]string{
+ "Brazil",
+ "Indonesia",
+ "USA",
+ "India",
+ "China",
+ "World",
+ }),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/legend.go b/legend.go
new file mode 100644
index 0000000..035642c
--- /dev/null
+++ b/legend.go
@@ -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
+}
diff --git a/legend_test.go b/legend_test.go
new file mode 100644
index 0000000..526f178
--- /dev/null
+++ b/legend_test.go
@@ -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: "",
+ },
+ {
+ 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: "",
+ },
+ {
+ 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: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/line_chart.go b/line_chart.go
new file mode 100644
index 0000000..fb1d16a
--- /dev/null
+++ b/line_chart.go
@@ -0,0 +1,240 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "math"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+type lineChart struct {
+ p *Painter
+ opt *LineChartOption
+}
+
+// NewLineChart returns a line chart render
+func NewLineChart(p *Painter, opt LineChartOption) *lineChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &lineChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type LineChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The x axis option
+ XAxis XAxisOption
+ // The padding of line chart
+ Padding Box
+ // The y axis option
+ YAxisOptions []YAxisOption
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ // The flag for show symbol of line, set this to *false will hide symbol
+ SymbolShow *bool
+ // The stroke width of line
+ StrokeWidth float64
+ // Fill the area of line
+ FillArea bool
+ // background is filled
+ backgroundIsFilled bool
+ // background fill (alpha) opacity
+ Opacity uint8
+}
+
+func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ p := l.p
+ opt := l.opt
+ boundaryGap := true
+ if isFalse(opt.XAxis.BoundaryGap) {
+ boundaryGap = false
+ }
+
+ seriesPainter := result.seriesPainter
+
+ xDivideCount := len(opt.XAxis.Data)
+ if !boundaryGap {
+ xDivideCount--
+ }
+ xDivideValues := autoDivide(seriesPainter.Width(), xDivideCount)
+ xValues := make([]int, len(xDivideValues)-1)
+ if boundaryGap {
+ for i := 0; i < len(xDivideValues)-1; i++ {
+ xValues[i] = (xDivideValues[i] + xDivideValues[i+1]) >> 1
+ }
+ } else {
+ xValues = xDivideValues
+ }
+ markPointPainter := NewMarkPointPainter(seriesPainter)
+ markLinePainter := NewMarkLinePainter(seriesPainter)
+ rendererList := []Renderer{
+ markPointPainter,
+ markLinePainter,
+ }
+ strokeWidth := opt.StrokeWidth
+ if strokeWidth == 0 {
+ strokeWidth = defaultStrokeWidth
+ }
+ seriesNames := seriesList.Names()
+ for index := range seriesList {
+ series := seriesList[index]
+ seriesColor := opt.Theme.GetSeriesColor(series.index)
+ drawingStyle := Style{
+ StrokeColor: seriesColor,
+ StrokeWidth: strokeWidth,
+ }
+ if len(series.Style.StrokeDashArray) > 0 {
+ drawingStyle.StrokeDashArray = series.Style.StrokeDashArray
+ }
+
+ yRange := result.axisRanges[series.AxisIndex]
+ points := make([]Point, 0)
+ var labelPainter *SeriesLabelPainter
+ if series.Label.Show {
+ labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{
+ P: seriesPainter,
+ SeriesNames: seriesNames,
+ Label: series.Label,
+ Theme: opt.Theme,
+ Font: opt.Font,
+ })
+ rendererList = append(rendererList, labelPainter)
+ }
+ for i, item := range series.Data {
+ h := yRange.getRestHeight(item.Value)
+ if item.Value == nullValue {
+ h = int(math.MaxInt32)
+ }
+ p := Point{
+ X: xValues[i],
+ Y: h,
+ }
+ points = append(points, p)
+
+ // 如果label不需要展示,则返回
+ if labelPainter == nil {
+ continue
+ }
+ labelPainter.Add(LabelValue{
+ Index: index,
+ Value: item.Value,
+ X: p.X,
+ Y: p.Y,
+ // 字体大小
+ FontSize: series.Label.FontSize,
+ })
+ }
+ // 如果需要填充区域
+ if opt.FillArea {
+ areaPoints := make([]Point, len(points))
+ copy(areaPoints, points)
+ bottomY := yRange.getRestHeight(yRange.min)
+ var opacity uint8 = 200
+ if opt.Opacity != 0 {
+ opacity = opt.Opacity
+ }
+ areaPoints = append(areaPoints, Point{
+ X: areaPoints[len(areaPoints)-1].X,
+ Y: bottomY,
+ }, Point{
+ X: areaPoints[0].X,
+ Y: bottomY,
+ }, areaPoints[0])
+ seriesPainter.SetDrawingStyle(Style{
+ FillColor: seriesColor.WithAlpha(opacity),
+ })
+ seriesPainter.FillArea(areaPoints)
+ }
+ seriesPainter.SetDrawingStyle(drawingStyle)
+
+ // 画线
+ seriesPainter.LineStroke(points)
+
+ // 画点
+ if opt.Theme.IsDark() {
+ drawingStyle.FillColor = drawingStyle.StrokeColor
+ } else {
+ drawingStyle.FillColor = drawing.ColorWhite
+ }
+ drawingStyle.StrokeWidth = 1
+ seriesPainter.SetDrawingStyle(drawingStyle)
+ if !isFalse(opt.SymbolShow) {
+ seriesPainter.Dots(points)
+ }
+ markPointPainter.Add(markPointRenderOption{
+ FillColor: seriesColor,
+ Font: opt.Font,
+ Points: points,
+ Series: series,
+ })
+ markLinePainter.Add(markLineRenderOption{
+ FillColor: seriesColor,
+ FontColor: opt.Theme.GetTextColor(),
+ StrokeColor: seriesColor,
+ Font: opt.Font,
+ Series: series,
+ Range: yRange,
+ })
+ }
+ // 最大、最小的mark point
+ err := doRender(rendererList...)
+ if err != nil {
+ return BoxZero, err
+ }
+
+ return p.box, nil
+}
+
+func (l *lineChart) Render() (Box, error) {
+ p := l.p
+ opt := l.opt
+
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: opt.XAxis,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ backgroundIsFilled: opt.backgroundIsFilled,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeLine)
+
+ return l.render(renderResult, seriesList)
+}
diff --git a/line_chart_test.go b/line_chart_test.go
new file mode 100644
index 0000000..e169f90
--- /dev/null
+++ b/line_chart_test.go
@@ -0,0 +1,219 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLineChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ _, err := NewLineChart(p, LineChartOption{
+ Title: TitleOption{
+ Text: "Line",
+ },
+ Padding: Box{
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ Legend: NewLegendOption([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }, PositionCenter),
+ SeriesList: NewSeriesListDataFromValues(values),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := [][]float64{
+ {
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ },
+ {
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ },
+ {
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ },
+ {
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ },
+ {
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ },
+ }
+ _, err := NewLineChart(p, LineChartOption{
+ Title: TitleOption{
+ Text: "Line",
+ },
+ Padding: Box{
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ Left: 10,
+ },
+ XAxis: NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }, FalseFlag()),
+ Legend: NewLegendOption([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }, PositionCenter),
+ SeriesList: NewSeriesListDataFromValues(values),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/main.go b/main.go
deleted file mode 100644
index 113a3ed..0000000
--- a/main.go
+++ /dev/null
@@ -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)
- }
-}
diff --git a/mark_line.go b/mark_line.go
new file mode 100644
index 0000000..bc850bb
--- /dev/null
+++ b/mark_line.go
@@ -0,0 +1,113 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "github.com/golang/freetype/truetype"
+)
+
+// NewMarkLine returns a series mark line
+func NewMarkLine(markLineTypes ...string) SeriesMarkLine {
+ data := make([]SeriesMarkData, len(markLineTypes))
+ for index, t := range markLineTypes {
+ data[index] = SeriesMarkData{
+ Type: t,
+ }
+ }
+ return SeriesMarkLine{
+ Data: data,
+ }
+}
+
+type markLinePainter struct {
+ p *Painter
+ options []markLineRenderOption
+}
+
+func (m *markLinePainter) Add(opt markLineRenderOption) {
+ m.options = append(m.options, opt)
+}
+
+// NewMarkLinePainter returns a mark line renderer
+func NewMarkLinePainter(p *Painter) *markLinePainter {
+ return &markLinePainter{
+ p: p,
+ options: make([]markLineRenderOption, 0),
+ }
+}
+
+type markLineRenderOption struct {
+ FillColor Color
+ FontColor Color
+ StrokeColor Color
+ Font *truetype.Font
+ Series Series
+ Range axisRange
+}
+
+func (m *markLinePainter) Render() (Box, error) {
+ painter := m.p
+ for _, opt := range m.options {
+ s := opt.Series
+ if len(s.MarkLine.Data) == 0 {
+ continue
+ }
+ font := opt.Font
+ if font == nil {
+ font, _ = GetDefaultFont()
+ }
+ summary := s.Summary()
+ for _, markLine := range s.MarkLine.Data {
+ // 由于mark line会修改style,因此每次重新设置
+ painter.OverrideDrawingStyle(Style{
+ FillColor: opt.FillColor,
+ StrokeColor: opt.StrokeColor,
+ StrokeWidth: 1,
+ StrokeDashArray: []float64{
+ 4,
+ 2,
+ },
+ }).OverrideTextStyle(Style{
+ Font: font,
+ FontColor: opt.FontColor,
+ FontSize: labelFontSize,
+ })
+ value := float64(0)
+ switch markLine.Type {
+ case SeriesMarkDataTypeMax:
+ value = summary.MaxValue
+ case SeriesMarkDataTypeMin:
+ value = summary.MinValue
+ default:
+ value = summary.AverageValue
+ }
+ y := opt.Range.getRestHeight(value)
+ width := painter.Width()
+ text := commafWithDigits(value)
+ textBox := painter.MeasureText(text)
+ painter.MarkLine(0, y, width-2)
+ painter.Text(text, width, y+textBox.Height()>>1-2)
+ }
+ }
+ return BoxZero, nil
+}
diff --git a/mark_line_test.go b/mark_line_test.go
new file mode 100644
index 0000000..0448cda
--- /dev/null
+++ b/mark_line_test.go
@@ -0,0 +1,90 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestMarkLine(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ markLine := NewMarkLinePainter(p)
+ series := NewSeriesFromValues([]float64{
+ 1,
+ 2,
+ 3,
+ })
+ series.MarkLine = NewMarkLine(
+ SeriesMarkDataTypeMax,
+ SeriesMarkDataTypeAverage,
+ SeriesMarkDataTypeMin,
+ )
+ markLine.Add(markLineRenderOption{
+ FillColor: drawing.ColorBlack,
+ FontColor: drawing.ColorBlack,
+ StrokeColor: drawing.ColorBlack,
+ Series: series,
+ Range: NewRange(AxisRangeOption{
+ Painter: p,
+ Min: 0,
+ Max: 5,
+ Size: p.Height(),
+ DivideCount: 6,
+ }),
+ })
+ _, err := markLine.Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/mark_point.go b/mark_point.go
new file mode 100644
index 0000000..fd8a88b
--- /dev/null
+++ b/mark_point.go
@@ -0,0 +1,115 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "github.com/golang/freetype/truetype"
+)
+
+// NewMarkPoint returns a series mark point
+func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint {
+ data := make([]SeriesMarkData, len(markPointTypes))
+ for index, t := range markPointTypes {
+ data[index] = SeriesMarkData{
+ Type: t,
+ }
+ }
+ return SeriesMarkPoint{
+ Data: data,
+ }
+}
+
+type markPointPainter struct {
+ p *Painter
+ options []markPointRenderOption
+}
+
+func (m *markPointPainter) Add(opt markPointRenderOption) {
+ m.options = append(m.options, opt)
+}
+
+type markPointRenderOption struct {
+ FillColor Color
+ Font *truetype.Font
+ Series Series
+ Points []Point
+}
+
+// NewMarkPointPainter returns a mark point renderer
+func NewMarkPointPainter(p *Painter) *markPointPainter {
+ return &markPointPainter{
+ p: p,
+ options: make([]markPointRenderOption, 0),
+ }
+}
+
+func (m *markPointPainter) Render() (Box, error) {
+ painter := m.p
+ for _, opt := range m.options {
+ s := opt.Series
+ if len(s.MarkPoint.Data) == 0 {
+ continue
+ }
+ points := opt.Points
+ summary := s.Summary()
+ symbolSize := s.MarkPoint.SymbolSize
+ if symbolSize == 0 {
+ symbolSize = 30
+ }
+ textStyle := Style{
+ FontSize: labelFontSize,
+ StrokeWidth: 1,
+ Font: opt.Font,
+ }
+ if isLightColor(opt.FillColor) {
+ textStyle.FontColor = defaultLightFontColor
+ } else {
+ textStyle.FontColor = defaultDarkFontColor
+ }
+ painter.OverrideDrawingStyle(Style{
+ FillColor: opt.FillColor,
+ }).OverrideTextStyle(textStyle)
+ for _, markPointData := range s.MarkPoint.Data {
+ textStyle.FontSize = labelFontSize
+ painter.OverrideTextStyle(textStyle)
+ p := points[summary.MinIndex]
+ value := summary.MinValue
+ switch markPointData.Type {
+ case SeriesMarkDataTypeMax:
+ p = points[summary.MaxIndex]
+ value = summary.MaxValue
+ }
+
+ painter.Pin(p.X, p.Y-symbolSize>>1, symbolSize)
+ text := commafWithDigits(value)
+ textBox := painter.MeasureText(text)
+ if textBox.Width() > symbolSize {
+ textStyle.FontSize = smallLabelFontSize
+ painter.OverrideTextStyle(textStyle)
+ textBox = painter.MeasureText(text)
+ }
+ painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2)
+ }
+ }
+ return BoxZero, nil
+}
diff --git a/mark_point_test.go b/mark_point_test.go
new file mode 100644
index 0000000..298345b
--- /dev/null
+++ b/mark_point_test.go
@@ -0,0 +1,92 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestMarkPoint(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ series := NewSeriesFromValues([]float64{
+ 1,
+ 2,
+ 3,
+ })
+ series.MarkPoint = NewMarkPoint(SeriesMarkDataTypeMax)
+ markPoint := NewMarkPointPainter(p)
+ markPoint.Add(markPointRenderOption{
+ FillColor: drawing.ColorBlack,
+ Series: series,
+ Points: []Point{
+ {
+ X: 10,
+ Y: 10,
+ },
+ {
+ X: 30,
+ Y: 30,
+ },
+ {
+ X: 50,
+ Y: 50,
+ },
+ },
+ })
+ _, err := markPoint.Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/painter.go b/painter.go
new file mode 100644
index 0000000..bee646f
--- /dev/null
+++ b/painter.go
@@ -0,0 +1,866 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "bytes"
+ "errors"
+ "math"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type ValueFormatter func(float64) string
+
+type Painter struct {
+ render chart.Renderer
+ box Box
+ font *truetype.Font
+ parent *Painter
+ style Style
+ theme ColorPalette
+ // 类型
+ outputType string
+ valueFormatter ValueFormatter
+}
+
+type PainterOptions struct {
+ // Draw type, "svg" or "png", default type is "png"
+ Type string
+ // The width of draw painter
+ Width int
+ // The height of draw painter
+ Height int
+ // The font for painter
+ Font *truetype.Font
+}
+
+type PainterOption func(*Painter)
+
+type TicksOption struct {
+ // the first tick
+ First int
+ Length int
+ Orient string
+ Count int
+ Unit int
+}
+
+type MultiTextOption struct {
+ TextList []string
+ Orient string
+ Unit int
+ Position string
+ Align string
+ // The text rotation of label
+ TextRotation float64
+ Offset Box
+ // The first text index
+ First int
+}
+
+type GridOption struct {
+ Column int
+ Row int
+ ColumnSpans []int
+ // 忽略不展示的column
+ IgnoreColumnLines []int
+ // 忽略不展示的row
+ IgnoreRowLines []int
+}
+
+// PainterPaddingOption sets the padding of draw painter
+func PainterPaddingOption(padding Box) PainterOption {
+ return func(p *Painter) {
+ p.box.Left += padding.Left
+ p.box.Top += padding.Top
+ p.box.Right -= padding.Right
+ p.box.Bottom -= padding.Bottom
+ }
+}
+
+// PainterBoxOption sets the box of draw painter
+func PainterBoxOption(box Box) PainterOption {
+ return func(p *Painter) {
+ if box.IsZero() {
+ return
+ }
+ p.box = box
+ }
+}
+
+// PainterFontOption sets the font of draw painter
+func PainterFontOption(font *truetype.Font) PainterOption {
+ return func(p *Painter) {
+ if font == nil {
+ return
+ }
+ p.font = font
+ }
+}
+
+// PainterStyleOption sets the style of draw painter
+func PainterStyleOption(style Style) PainterOption {
+ return func(p *Painter) {
+ p.SetStyle(style)
+ }
+}
+
+// PainterThemeOption sets the theme of draw painter
+func PainterThemeOption(theme ColorPalette) PainterOption {
+ return func(p *Painter) {
+ if theme == nil {
+ return
+ }
+ p.theme = theme
+ }
+}
+
+// PainterWidthHeightOption set width or height of draw painter
+func PainterWidthHeightOption(width, height int) PainterOption {
+ return func(p *Painter) {
+ if width > 0 {
+ p.box.Right = p.box.Left + width
+ }
+ if height > 0 {
+ p.box.Bottom = p.box.Top + height
+ }
+ }
+}
+
+// NewPainter creates a painter
+func NewPainter(opts PainterOptions, opt ...PainterOption) (*Painter, error) {
+ if opts.Width <= 0 || opts.Height <= 0 {
+ return nil, errors.New("width/height can not be nil")
+ }
+ font := opts.Font
+ if font == nil {
+ f, err := GetDefaultFont()
+ if err != nil {
+ return nil, err
+ }
+ font = f
+ }
+ fn := chart.PNG
+ if opts.Type == ChartOutputSVG {
+ fn = chart.SVG
+ }
+ width := opts.Width
+ height := opts.Height
+ r, err := fn(width, height)
+ if err != nil {
+ return nil, err
+ }
+ r.SetFont(font)
+
+ p := &Painter{
+ render: r,
+ box: Box{
+ Right: opts.Width,
+ Bottom: opts.Height,
+ },
+ font: font,
+ // 类型
+ outputType: opts.Type,
+ }
+ p.setOptions(opt...)
+ if p.theme == nil {
+ p.theme = NewTheme(ThemeLight)
+ }
+ return p, nil
+}
+func (p *Painter) setOptions(opts ...PainterOption) {
+ for _, fn := range opts {
+ fn(p)
+ }
+}
+
+func (p *Painter) Child(opt ...PainterOption) *Painter {
+ child := &Painter{
+ // 格式化
+ valueFormatter: p.valueFormatter,
+ // render
+ render: p.render,
+ box: p.box.Clone(),
+ font: p.font,
+ parent: p,
+ style: p.style,
+ theme: p.theme,
+ }
+ child.setOptions(opt...)
+ return child
+}
+
+func (p *Painter) SetStyle(style Style) {
+ if style.Font == nil {
+ style.Font = p.font
+ }
+ p.style = style
+ style.WriteToRenderer(p.render)
+}
+
+func overrideStyle(defaultStyle Style, style Style) Style {
+ if style.StrokeWidth == 0 {
+ style.StrokeWidth = defaultStyle.StrokeWidth
+ }
+ if style.StrokeColor.IsZero() {
+ style.StrokeColor = defaultStyle.StrokeColor
+ }
+ if style.StrokeDashArray == nil {
+ style.StrokeDashArray = defaultStyle.StrokeDashArray
+ }
+ if style.DotColor.IsZero() {
+ style.DotColor = defaultStyle.DotColor
+ }
+ if style.DotWidth == 0 {
+ style.DotWidth = defaultStyle.DotWidth
+ }
+ if style.FillColor.IsZero() {
+ style.FillColor = defaultStyle.FillColor
+ }
+ if style.FontSize == 0 {
+ style.FontSize = defaultStyle.FontSize
+ }
+ if style.FontColor.IsZero() {
+ style.FontColor = defaultStyle.FontColor
+ }
+ if style.Font == nil {
+ style.Font = defaultStyle.Font
+ }
+ return style
+}
+
+func (p *Painter) OverrideDrawingStyle(style Style) *Painter {
+ s := overrideStyle(p.style, style)
+ p.SetDrawingStyle(s)
+ return p
+}
+
+func (p *Painter) SetDrawingStyle(style Style) *Painter {
+ style.WriteDrawingOptionsToRenderer(p.render)
+ return p
+}
+
+func (p *Painter) SetTextStyle(style Style) *Painter {
+ if style.Font == nil {
+ style.Font = p.font
+ }
+ style.WriteTextOptionsToRenderer(p.render)
+ return p
+}
+func (p *Painter) OverrideTextStyle(style Style) *Painter {
+ s := overrideStyle(p.style, style)
+ p.SetTextStyle(s)
+ return p
+}
+
+func (p *Painter) ResetStyle() *Painter {
+ p.style.WriteToRenderer(p.render)
+ return p
+}
+
+// Bytes returns the data of draw canvas
+func (p *Painter) Bytes() ([]byte, error) {
+ buffer := bytes.Buffer{}
+ err := p.render.Save(&buffer)
+ if err != nil {
+ return nil, err
+ }
+ return buffer.Bytes(), err
+}
+
+// MoveTo moves the cursor to a given point
+func (p *Painter) MoveTo(x, y int) *Painter {
+ p.render.MoveTo(x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) *Painter {
+ p.render.ArcTo(cx+p.box.Left, cy+p.box.Top, rx, ry, startAngle, delta)
+ return p
+}
+
+func (p *Painter) LineTo(x, y int) *Painter {
+ p.render.LineTo(x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) QuadCurveTo(cx, cy, x, y int) *Painter {
+ p.render.QuadCurveTo(cx+p.box.Left, cy+p.box.Top, x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) Pin(x, y, width int) *Painter {
+ r := float64(width) / 2
+ y -= width / 4
+ angle := chart.DegreesToRadians(15)
+ box := p.box
+
+ startAngle := math.Pi/2 + angle
+ delta := 2*math.Pi - 2*angle
+ p.ArcTo(x, y, r, r, startAngle, delta)
+ p.LineTo(x, y)
+ p.Close()
+ p.FillStroke()
+
+ startX := x - int(r)
+ startY := y
+ endX := x + int(r)
+ endY := y
+ p.MoveTo(startX, startY)
+
+ left := box.Left
+ top := box.Top
+ cx := x
+ cy := y + int(r*2.5)
+ p.render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top)
+ p.Close()
+ p.Fill()
+ return p
+}
+
+func (p *Painter) arrow(x, y, width, height int, direction string) *Painter {
+ halfWidth := width >> 1
+ halfHeight := height >> 1
+ if direction == PositionTop || direction == PositionBottom {
+ x0 := x - halfWidth
+ x1 := x0 + width
+ dy := -height / 3
+ y0 := y
+ y1 := y0 - height
+ if direction == PositionBottom {
+ y0 = y - height
+ y1 = y
+ dy = 2 * dy
+ }
+ p.MoveTo(x0, y0)
+ p.LineTo(x0+halfWidth, y1)
+ p.LineTo(x1, y0)
+ p.LineTo(x0+halfWidth, y+dy)
+ p.LineTo(x0, y0)
+ } else {
+ x0 := x + width
+ x1 := x0 - width
+ y0 := y - halfHeight
+ dx := -width / 3
+ if direction == PositionRight {
+ x0 = x - width
+ dx = -dx
+ x1 = x0 + width
+ }
+ p.MoveTo(x0, y0)
+ p.LineTo(x1, y0+halfHeight)
+ p.LineTo(x0, y0+height)
+ p.LineTo(x0+dx, y0+halfHeight)
+ p.LineTo(x0, y0)
+ }
+ p.FillStroke()
+ return p
+}
+
+func (p *Painter) ArrowLeft(x, y, width, height int) *Painter {
+ p.arrow(x, y, width, height, PositionLeft)
+ return p
+}
+
+func (p *Painter) ArrowRight(x, y, width, height int) *Painter {
+ p.arrow(x, y, width, height, PositionRight)
+ return p
+}
+
+func (p *Painter) ArrowTop(x, y, width, height int) *Painter {
+ p.arrow(x, y, width, height, PositionTop)
+ return p
+}
+func (p *Painter) ArrowBottom(x, y, width, height int) *Painter {
+ p.arrow(x, y, width, height, PositionBottom)
+ return p
+}
+
+func (p *Painter) Circle(radius float64, x, y int) *Painter {
+ p.render.Circle(radius, x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) Stroke() *Painter {
+ p.render.Stroke()
+ return p
+}
+
+func (p *Painter) Close() *Painter {
+ p.render.Close()
+ return p
+}
+
+func (p *Painter) FillStroke() *Painter {
+ p.render.FillStroke()
+ return p
+}
+
+func (p *Painter) Fill() *Painter {
+ p.render.Fill()
+ return p
+}
+
+func (p *Painter) Width() int {
+ return p.box.Width()
+}
+
+func (p *Painter) Height() int {
+ return p.box.Height()
+}
+
+func (p *Painter) MeasureText(text string) Box {
+ return p.render.MeasureText(text)
+}
+
+func (p *Painter) MeasureTextMaxWidthHeight(textList []string) (int, int) {
+ maxWidth := 0
+ maxHeight := 0
+ for _, text := range textList {
+ box := p.MeasureText(text)
+ if maxWidth < box.Width() {
+ maxWidth = box.Width()
+ }
+ if maxHeight < box.Height() {
+ maxHeight = box.Height()
+ }
+ }
+ return maxWidth, maxHeight
+}
+
+func (p *Painter) LineStroke(points []Point) *Painter {
+ shouldMoveTo := false
+ for index, point := range points {
+ x := point.X
+ y := point.Y
+ if y == int(math.MaxInt32) {
+ p.Stroke()
+ shouldMoveTo = true
+ continue
+ }
+ if shouldMoveTo || index == 0 {
+ p.MoveTo(x, y)
+ shouldMoveTo = false
+ } else {
+ p.LineTo(x, y)
+ }
+ }
+ p.Stroke()
+ return p
+}
+
+func (p *Painter) SmoothLineStroke(points []Point) *Painter {
+ prevX := 0
+ prevY := 0
+ // TODO 如何生成平滑的折线
+ for index, point := range points {
+ x := point.X
+ y := point.Y
+ if index == 0 {
+ p.MoveTo(x, y)
+ } else {
+ cx := prevX + (x-prevX)/5
+ cy := y + (y-prevY)/2
+ p.QuadCurveTo(cx, cy, x, y)
+ }
+ prevX = x
+ prevY = y
+ }
+ p.Stroke()
+ return p
+}
+
+func (p *Painter) SetBackground(width, height int, color Color, inside ...bool) *Painter {
+ r := p.render
+ s := chart.Style{
+ FillColor: color,
+ }
+ // 背景色
+ p.SetDrawingStyle(s)
+ defer p.ResetStyle()
+ if len(inside) != 0 && inside[0] {
+ p.MoveTo(0, 0)
+ p.LineTo(width, 0)
+ p.LineTo(width, height)
+ p.LineTo(0, height)
+ p.LineTo(0, 0)
+ } else {
+ // 设置背景色不使用box,因此不直接使用Painter
+ r.MoveTo(0, 0)
+ r.LineTo(width, 0)
+ r.LineTo(width, height)
+ r.LineTo(0, height)
+ r.LineTo(0, 0)
+ }
+ p.FillStroke()
+ return p
+}
+func (p *Painter) MarkLine(x, y, width int) *Painter {
+ arrowWidth := 16
+ arrowHeight := 10
+ endX := x + width
+ radius := 3
+ p.Circle(3, x+radius, y)
+ p.render.Fill()
+ p.MoveTo(x+radius*3, y)
+ p.LineTo(endX-arrowWidth, y)
+ p.Stroke()
+ p.ArrowRight(endX, y, arrowWidth, arrowHeight)
+ return p
+}
+
+func (p *Painter) Polygon(center Point, radius float64, sides int) *Painter {
+ points := getPolygonPoints(center, radius, sides)
+ for i, item := range points {
+ if i == 0 {
+ p.MoveTo(item.X, item.Y)
+ } else {
+ p.LineTo(item.X, item.Y)
+ }
+ }
+ p.LineTo(points[0].X, points[0].Y)
+ p.Stroke()
+ return p
+}
+
+func (p *Painter) FillArea(points []Point) *Painter {
+ var x, y int
+ for index, point := range points {
+ x = point.X
+ y = point.Y
+ if index == 0 {
+ p.MoveTo(x, y)
+ } else {
+ p.LineTo(x, y)
+ }
+ }
+ p.Fill()
+ return p
+}
+
+func (p *Painter) Text(body string, x, y int) *Painter {
+ p.render.Text(body, x+p.box.Left, y+p.box.Top)
+ return p
+}
+
+func (p *Painter) TextRotation(body string, x, y int, radians float64) {
+ p.render.SetTextRotation(radians)
+ p.render.Text(body, x+p.box.Left, y+p.box.Top)
+ p.render.ClearTextRotation()
+}
+
+func (p *Painter) SetTextRotation(radians float64) {
+ p.render.SetTextRotation(radians)
+}
+func (p *Painter) ClearTextRotation() {
+ p.render.ClearTextRotation()
+}
+
+func (p *Painter) TextFit(body string, x, y, width int, textAligns ...string) chart.Box {
+ style := p.style
+ textWarp := style.TextWrap
+ style.TextWrap = chart.TextWrapWord
+ r := p.render
+ lines := chart.Text.WrapFit(r, body, width, style)
+ p.SetTextStyle(style)
+ var output chart.Box
+
+ textAlign := ""
+ if len(textAligns) != 0 {
+ textAlign = textAligns[0]
+ }
+ for index, line := range lines {
+ if line == "" {
+ continue
+ }
+ x0 := x
+ y0 := y + output.Height()
+ lineBox := r.MeasureText(line)
+ switch textAlign {
+ case AlignRight:
+ x0 += width - lineBox.Width()
+ case AlignCenter:
+ x0 += (width - lineBox.Width()) >> 1
+ }
+ p.Text(line, x0, y0)
+ output.Right = chart.MaxInt(lineBox.Right, output.Right)
+ output.Bottom += lineBox.Height()
+ if index < len(lines)-1 {
+ output.Bottom += +style.GetTextLineSpacing()
+ }
+ }
+ p.style.TextWrap = textWarp
+ return output
+}
+
+func (p *Painter) Ticks(opt TicksOption) *Painter {
+ if opt.Count <= 0 || opt.Length <= 0 {
+ return p
+ }
+ count := opt.Count
+ first := opt.First
+ width := p.Width()
+ height := p.Height()
+ unit := 1
+ if opt.Unit > 1 {
+ unit = opt.Unit
+ }
+ var values []int
+ isVertical := opt.Orient == OrientVertical
+ if isVertical {
+ values = autoDivide(height, count)
+ } else {
+ values = autoDivide(width, count)
+ }
+ for index, value := range values {
+ if index < first {
+ continue
+ }
+ if (index-first)%unit != 0 {
+ continue
+ }
+ if isVertical {
+ p.LineStroke([]Point{
+ {
+ X: 0,
+ Y: value,
+ },
+ {
+ X: opt.Length,
+ Y: value,
+ },
+ })
+ } else {
+ p.LineStroke([]Point{
+ {
+ X: value,
+ Y: opt.Length,
+ },
+ {
+ X: value,
+ Y: 0,
+ },
+ })
+ }
+ }
+ return p
+}
+
+func (p *Painter) MultiText(opt MultiTextOption) *Painter {
+ if len(opt.TextList) == 0 {
+ return p
+ }
+ count := len(opt.TextList)
+ positionCenter := true
+ showIndex := opt.Unit / 2
+ if containsString([]string{
+ PositionLeft,
+ PositionTop,
+ }, opt.Position) {
+ positionCenter = false
+ count--
+ // 非居中
+ showIndex = 0
+ }
+ width := p.Width()
+ height := p.Height()
+ var values []int
+ isVertical := opt.Orient == OrientVertical
+ if isVertical {
+ values = autoDivide(height, count)
+ } else {
+ values = autoDivide(width, count)
+ }
+ isTextRotation := opt.TextRotation != 0
+ offset := opt.Offset
+ for index, text := range opt.TextList {
+ if index < opt.First {
+ continue
+ }
+ if opt.Unit != 0 && (index-opt.First)%opt.Unit != showIndex {
+ continue
+ }
+ if isTextRotation {
+ p.ClearTextRotation()
+ p.SetTextRotation(opt.TextRotation)
+ }
+ box := p.MeasureText(text)
+ start := values[index]
+ if positionCenter {
+ start = (values[index] + values[index+1]) >> 1
+ }
+ x := 0
+ y := 0
+ if isVertical {
+ y = start + box.Height()>>1
+ switch opt.Align {
+ case AlignRight:
+ x = width - box.Width()
+ case AlignCenter:
+ x = width - box.Width()>>1
+ default:
+ x = 0
+ }
+ } else {
+ x = start - box.Width()>>1
+ }
+ x += offset.Left
+ y += offset.Top
+ p.Text(text, x, y)
+ }
+ if isTextRotation {
+ p.ClearTextRotation()
+ }
+ return p
+}
+
+func (p *Painter) Grid(opt GridOption) *Painter {
+ width := p.Width()
+ height := p.Height()
+ drawLines := func(values []int, ignoreIndexList []int, isVertical bool) {
+ for index, v := range values {
+ if containsInt(ignoreIndexList, index) {
+ continue
+ }
+ x0 := 0
+ y0 := 0
+ x1 := 0
+ y1 := 0
+ if isVertical {
+
+ x0 = v
+ x1 = v
+ y1 = height
+ } else {
+ x1 = width
+ y0 = v
+ y1 = v
+ }
+ p.LineStroke([]Point{
+ {
+ X: x0,
+ Y: y0,
+ },
+ {
+ X: x1,
+ Y: y1,
+ },
+ })
+ }
+ }
+ columnCount := sumInt(opt.ColumnSpans)
+ if columnCount == 0 {
+ columnCount = opt.Column
+ }
+ if columnCount > 0 {
+ values := autoDivideSpans(width, columnCount, opt.ColumnSpans)
+ drawLines(values, opt.IgnoreColumnLines, true)
+ }
+ if opt.Row > 0 {
+ values := autoDivide(height, opt.Row)
+ drawLines(values, opt.IgnoreRowLines, false)
+ }
+ return p
+}
+
+func (p *Painter) Dots(points []Point) *Painter {
+ for _, item := range points {
+ p.Circle(2, item.X, item.Y)
+ }
+ p.FillStroke()
+ return p
+}
+
+func (p *Painter) Rect(box Box) *Painter {
+ p.MoveTo(box.Left, box.Top)
+ p.LineTo(box.Right, box.Top)
+ p.LineTo(box.Right, box.Bottom)
+ p.LineTo(box.Left, box.Bottom)
+ p.LineTo(box.Left, box.Top)
+ p.FillStroke()
+ return p
+}
+
+func (p *Painter) RoundedRect(box Box, radius int) *Painter {
+ r := (box.Right - box.Left) / 2
+ if radius > r {
+ radius = r
+ }
+ rx := float64(radius)
+ ry := float64(radius)
+ p.MoveTo(box.Left+radius, box.Top)
+ p.LineTo(box.Right-radius, box.Top)
+
+ cx := box.Right - radius
+ cy := box.Top + radius
+ // right top
+ p.ArcTo(cx, cy, rx, ry, -math.Pi/2, math.Pi/2)
+
+ p.LineTo(box.Right, box.Bottom-radius)
+
+ // right bottom
+ cx = box.Right - radius
+ cy = box.Bottom - radius
+ p.ArcTo(cx, cy, rx, ry, 0.0, math.Pi/2)
+
+ p.LineTo(box.Left+radius, box.Bottom)
+
+ // left bottom
+ cx = box.Left + radius
+ cy = box.Bottom - radius
+ p.ArcTo(cx, cy, rx, ry, math.Pi/2, math.Pi/2)
+
+ p.LineTo(box.Left, box.Top+radius)
+
+ // left top
+ cx = box.Left + radius
+ cy = box.Top + radius
+ p.ArcTo(cx, cy, rx, ry, math.Pi, math.Pi/2)
+
+ p.Close()
+ p.FillStroke()
+ p.Fill()
+ return p
+}
+
+func (p *Painter) LegendLineDot(box Box) *Painter {
+ width := box.Width()
+ height := box.Height()
+ strokeWidth := 3
+ dotHeight := 5
+
+ p.render.SetStrokeWidth(float64(strokeWidth))
+ center := (height-strokeWidth)>>1 - 1
+ p.MoveTo(box.Left, box.Top-center)
+ p.LineTo(box.Right, box.Top-center)
+ p.Stroke()
+ p.Circle(float64(dotHeight), box.Left+width>>1, box.Top-center)
+ p.FillStroke()
+ return p
+}
+
+func (p *Painter) GetRenderer() chart.Renderer {
+ return p.render
+}
diff --git a/painter_test.go b/painter_test.go
new file mode 100644
index 0000000..07c4113
--- /dev/null
+++ b/painter_test.go
@@ -0,0 +1,399 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "math"
+ "testing"
+
+ "github.com/golang/freetype/truetype"
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestPainterOption(t *testing.T) {
+ assert := assert.New(t)
+
+ font := &truetype.Font{}
+ d, err := NewPainter(PainterOptions{
+ Width: 800,
+ Height: 600,
+ Type: ChartOutputSVG,
+ },
+ PainterBoxOption(Box{
+ Right: 400,
+ Bottom: 300,
+ }),
+ PainterPaddingOption(Box{
+ Left: 1,
+ Top: 2,
+ Right: 3,
+ Bottom: 4,
+ }),
+ PainterFontOption(font),
+ PainterStyleOption(Style{
+ ClassName: "test",
+ }),
+ )
+ assert.Nil(err)
+ assert.Equal(Box{
+ Left: 1,
+ Top: 2,
+ Right: 397,
+ Bottom: 296,
+ }, d.box)
+ assert.Equal(font, d.font)
+ assert.Equal("test", d.style.ClassName)
+}
+
+func TestPainter(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ fn func(*Painter)
+ result string
+ }{
+ // moveTo, lineTo
+ {
+ fn: func(p *Painter) {
+ p.MoveTo(1, 1)
+ p.LineTo(2, 2)
+ p.Stroke()
+ },
+ result: "",
+ },
+ // circle
+ {
+ fn: func(p *Painter) {
+ p.Circle(5, 2, 3)
+ },
+ result: "",
+ },
+ // text
+ {
+ fn: func(p *Painter) {
+ p.Text("hello world!", 3, 6)
+ },
+ result: "",
+ },
+ // 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: "",
+ },
+ // set background
+ {
+ fn: func(p *Painter) {
+ p.SetBackground(400, 300, chart.ColorWhite)
+ },
+ result: "",
+ },
+ // 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: "",
+ },
+ // 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: "",
+ },
+ // 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: "",
+ },
+ // 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: "",
+ },
+ // 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: "",
+ },
+ // 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: "",
+ },
+ // 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: "",
+ },
+ // 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: "",
+ },
+ // 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: "",
+ },
+ }
+ 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("", 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(``, string(buf))
+}
diff --git a/pie_chart.go b/pie_chart.go
new file mode 100644
index 0000000..5c04ed8
--- /dev/null
+++ b/pie_chart.go
@@ -0,0 +1,318 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "errors"
+ "math"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type pieChart struct {
+ p *Painter
+ opt *PieChartOption
+}
+
+type PieChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The padding of line chart
+ Padding Box
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ // background is filled
+ backgroundIsFilled bool
+}
+
+// NewPieChart returns a pie chart renderer
+func NewPieChart(p *Painter, opt PieChartOption) *pieChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &pieChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type sector struct {
+ value float64
+ percent float64
+ cx int
+ cy int
+ rx float64
+ ry float64
+ start float64
+ delta float64
+ offset int
+ quadrant int
+ lineStartX int
+ lineStartY int
+ lineBranchX int
+ lineBranchY int
+ lineEndX int
+ lineEndY int
+ showLabel bool
+ label string
+ series Series
+ color Color
+}
+
+func NewSector(cx int, cy int, radius float64, labelRadius float64, value float64, currentValue float64, totalValue float64, labelLineLength int, label string, series Series, color Color) sector {
+ s := sector{}
+ s.value = value
+ s.percent = value / totalValue
+ s.cx = cx
+ s.cy = cy
+ s.rx = radius
+ s.ry = radius
+ p := (currentValue + value/2) / totalValue
+ if p < 0.25 {
+ s.quadrant = 1
+ } else if p < 0.5 {
+ s.quadrant = 4
+ } else if p < 0.75 {
+ s.quadrant = 3
+ } else {
+ s.quadrant = 2
+ }
+ s.start = chart.PercentToRadians(currentValue/totalValue) - math.Pi/2
+ s.delta = chart.PercentToRadians(value / totalValue)
+ angle := s.start + s.delta/2
+ s.lineStartX = cx + int(radius*math.Cos(angle))
+ s.lineStartY = cy + int(radius*math.Sin(angle))
+ s.lineBranchX = cx + int(labelRadius*math.Cos(angle))
+ s.lineBranchY = cy + int(labelRadius*math.Sin(angle))
+ s.offset = labelLineLength
+ if s.lineBranchX <= cx {
+ s.offset *= -1
+ }
+ s.lineEndX = s.lineBranchX + s.offset
+ s.lineEndY = s.lineBranchY
+ s.series = series
+ s.color = color
+ s.showLabel = series.Label.Show
+ s.label = NewPieLabelFormatter([]string{label}, series.Label.Formatter)(0, s.value, s.percent)
+ return s
+}
+
+func (s *sector) calculateY(prevY int) int {
+ for i := 0; i <= s.cy; i++ {
+ if s.quadrant <= 2 {
+ if (prevY - s.lineBranchY) > labelFontSize+5 {
+ break
+ }
+ s.lineBranchY -= 1
+ } else {
+ if (s.lineBranchY - prevY) > labelFontSize+5 {
+ break
+ }
+ s.lineBranchY += 1
+ }
+ }
+ s.lineEndY = s.lineBranchY
+ return s.lineBranchY
+}
+
+func (s *sector) calculateTextXY(textBox Box) (x int, y int) {
+ textMargin := 3
+ x = s.lineEndX + textMargin
+ y = s.lineEndY + textBox.Height()>>1 - 1
+ if s.offset < 0 {
+ textWidth := textBox.Width()
+ x = s.lineEndX - textWidth - textMargin
+ }
+ return
+}
+
+func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ opt := p.opt
+ values := make([]float64, len(seriesList))
+ total := float64(0)
+ radiusValue := ""
+ for index, series := range seriesList {
+ if len(series.Radius) != 0 {
+ radiusValue = series.Radius
+ }
+ value := float64(0)
+ for _, item := range series.Data {
+ value += item.Value
+ }
+ values[index] = value
+ total += value
+ }
+ if total <= 0 {
+ return BoxZero, errors.New("The sum value of pie chart should gt 0")
+ }
+ seriesPainter := result.seriesPainter
+ cx := seriesPainter.Width() >> 1
+ cy := seriesPainter.Height() >> 1
+
+ diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
+ radius := getRadius(float64(diameter), radiusValue)
+
+ labelLineWidth := 15
+ if radius < 50 {
+ labelLineWidth = 10
+ }
+ labelRadius := radius + float64(labelLineWidth)
+ seriesNames := opt.Legend.Data
+ if len(seriesNames) == 0 {
+ seriesNames = seriesList.Names()
+ }
+ theme := opt.Theme
+
+ currentValue := float64(0)
+
+ var quadrant1, quadrant2, quadrant3, quadrant4 []sector
+ for index, v := range values {
+ series := seriesList[index]
+ color := theme.GetSeriesColor(index)
+ if index == len(values)-1 {
+ if color == theme.GetSeriesColor(0) {
+ color = theme.GetSeriesColor(1)
+ }
+ }
+ s := NewSector(cx, cy, radius, labelRadius, v, currentValue, total, labelLineWidth, seriesNames[index], series, color)
+ switch quadrant := s.quadrant; quadrant {
+ case 1:
+ quadrant1 = append([]sector{s}, quadrant1...)
+ case 2:
+ quadrant2 = append(quadrant2, s)
+ case 3:
+ quadrant3 = append([]sector{s}, quadrant3...)
+ case 4:
+ quadrant4 = append(quadrant4, s)
+ }
+ currentValue += v
+ }
+ sectors := append(quadrant1, quadrant4...)
+ sectors = append(sectors, quadrant3...)
+ sectors = append(sectors, quadrant2...)
+
+ currentQuadrant := 0
+ prevY := 0
+ maxY := 0
+ minY := 0
+ for _, s := range sectors {
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeWidth: 1,
+ StrokeColor: s.color,
+ FillColor: s.color,
+ })
+ seriesPainter.MoveTo(s.cx, s.cy)
+ seriesPainter.ArcTo(s.cx, s.cy, s.rx, s.ry, s.start, s.delta).LineTo(s.cx, s.cy).Close().FillStroke()
+ if !s.showLabel {
+ continue
+ }
+ if currentQuadrant != s.quadrant {
+ if s.quadrant == 1 {
+ minY = cy * 2
+ maxY = 0
+ prevY = cy * 2
+ }
+ if s.quadrant == 2 {
+ if currentQuadrant != 3 {
+ prevY = s.lineEndY
+ } else {
+ prevY = minY
+ }
+ }
+ if s.quadrant == 3 {
+ if currentQuadrant != 4 {
+ prevY = s.lineEndY
+ } else {
+ minY = cy * 2
+ maxY = 0
+ prevY = 0
+ }
+ }
+ if s.quadrant == 4 {
+ if currentQuadrant != 1 {
+ prevY = s.lineEndY
+ } else {
+ prevY = maxY
+ }
+ }
+ currentQuadrant = s.quadrant
+ }
+ prevY = s.calculateY(prevY)
+ if prevY > maxY {
+ maxY = prevY
+ }
+ if prevY < minY {
+ minY = prevY
+ }
+ seriesPainter.MoveTo(s.lineStartX, s.lineStartY)
+ seriesPainter.LineTo(s.lineBranchX, s.lineBranchY)
+ seriesPainter.MoveTo(s.lineBranchX, s.lineBranchY)
+ seriesPainter.LineTo(s.lineEndX, s.lineEndY)
+ seriesPainter.Stroke()
+ textStyle := Style{
+ FontColor: theme.GetTextColor(),
+ FontSize: labelFontSize,
+ Font: opt.Font,
+ }
+ if !s.series.Label.Color.IsZero() {
+ textStyle.FontColor = s.series.Label.Color
+ }
+ seriesPainter.OverrideTextStyle(textStyle)
+ x, y := s.calculateTextXY(seriesPainter.MeasureText(s.label))
+ seriesPainter.Text(s.label, x, y)
+ }
+ return p.p.box, nil
+}
+
+func (p *pieChart) Render() (Box, error) {
+ opt := p.opt
+
+ renderResult, err := defaultRender(p.p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: XAxisOption{
+ Show: FalseFlag(),
+ },
+ YAxisOptions: []YAxisOption{
+ {
+ Show: FalseFlag(),
+ },
+ },
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ backgroundIsFilled: opt.backgroundIsFilled,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypePie)
+ return p.render(renderResult, seriesList)
+}
diff --git a/pie_chart_test.go b/pie_chart_test.go
new file mode 100644
index 0000000..3795d32
--- /dev/null
+++ b/pie_chart_test.go
@@ -0,0 +1,533 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPieChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := []float64{
+ 1048,
+ 735,
+ 580,
+ 484,
+ 300,
+ }
+ _, err := NewPieChart(p, PieChartOption{
+ SeriesList: NewPieSeriesList(values, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ },
+ }),
+ Title: TitleOption{
+ Text: "Rainfall vs Evaporation",
+ Subtext: "Fake Data",
+ Left: PositionCenter,
+ },
+ Padding: Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: LegendOption{
+ Orient: OrientVertical,
+ Data: []string{
+ "Search Engine",
+ "Direct",
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ },
+ Left: PositionLeft,
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
+
+func TestPieChartWithLabelsValuesSortedDescending(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := []float64{
+ 84358845,
+ 68070697,
+ 58850717,
+ 48059777,
+ 36753736,
+ 19051562,
+ 17947406,
+ 11754004,
+ 10827529,
+ 10521556,
+ 10467366,
+ 10394055,
+ 9597085,
+ 9104772,
+ 6447710,
+ 5932654,
+ 5563970,
+ 5428792,
+ 5194336,
+ 3850894,
+ 2857279,
+ 2116792,
+ 1883008,
+ 1373101,
+ 920701,
+ 660809,
+ 542051,
+ }
+ _, err := NewPieChart(p, PieChartOption{
+ SeriesList: NewPieSeriesList(values, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ Formatter: "{b} ({c} ≅ {d})",
+ },
+ Radius: "200",
+ }),
+ Title: TitleOption{
+ Text: "European Union member states by population",
+ Left: PositionRight,
+ },
+ Padding: Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: LegendOption{
+ Data: []string{
+ "Germany",
+ "France",
+ "Italy",
+ "Spain",
+ "Poland",
+ "Romania",
+ "Netherlands",
+ "Belgium",
+ "Czech Republic",
+ "Sweden",
+ "Portugal",
+ "Greece",
+ "Hungary",
+ "Austria",
+ "Bulgaria",
+ "Denmark",
+ "Finland",
+ "Slovakia",
+ "Ireland",
+ "Croatia",
+ "Lithuania",
+ "Slovenia",
+ "Latvia",
+ "Estonia",
+ "Cyprus",
+ "Luxembourg",
+ "Malta",
+ },
+ Show: FalseFlag(),
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 1000,
+ Height: 800,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
+
+func TestPieChartWithLabelsValuesUnsorted(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := []float64{
+ 9104772,
+ 11754004,
+ 6447710,
+ 3850894,
+ 920701,
+ 10827529,
+ 5932654,
+ 1373101,
+ 5563970,
+ 68070697,
+ 84358845,
+ 10394055,
+ 9597085,
+ 5194336,
+ 58850717,
+ 1883008,
+ 2857279,
+ 660809,
+ 542051,
+ 17947406,
+ 36753736,
+ 10467366,
+ 19051562,
+ 5428792,
+ 2116792,
+ 48059777,
+ 10521556,
+ }
+ _, err := NewPieChart(p, PieChartOption{
+ SeriesList: NewPieSeriesList(values, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ Formatter: "{b} ({c} ≅ {d})",
+ },
+ Radius: "200",
+ }),
+ Title: TitleOption{
+ Text: "European Union member states by population",
+ Left: PositionRight,
+ },
+ Padding: Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: LegendOption{
+ Data: []string{
+ "Austria",
+ "Belgium",
+ "Bulgaria",
+ "Croatia",
+ "Cyprus",
+ "Czech Republic",
+ "Denmark",
+ "Estonia",
+ "Finland",
+ "France",
+ "Germany",
+ "Greece",
+ "Hungary",
+ "Ireland",
+ "Italy",
+ "Latvia",
+ "Lithuania",
+ "Luxembourg",
+ "Malta",
+ "Netherlands",
+ "Poland",
+ "Portugal",
+ "Romania",
+ "Slovakia",
+ "Slovenia",
+ "Spain",
+ "Sweden",
+ },
+ Show: FalseFlag(),
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 1000,
+ Height: 800,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
+
+func TestPieChartWith100Labels(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ var values []float64
+ var labels []string
+ for i := 1; i <= 100; i++ {
+ values = append(values, float64(1))
+ labels = append(labels, "Label "+strconv.Itoa(i))
+ }
+ _, err := NewPieChart(p, PieChartOption{
+ SeriesList: NewPieSeriesList(values, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ },
+ Radius: "200",
+ }),
+ Title: TitleOption{
+ Text: "Test with 100 labels",
+ Left: PositionRight,
+ },
+ Padding: Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: LegendOption{
+ Data: labels,
+ Show: FalseFlag(),
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 1000,
+ Height: 900,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
+
+func TestPieChartFixLabelPos72586(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := []float64{
+ 397594,
+ 185596,
+ 149086,
+ 144258,
+ 120194,
+ 117514,
+ 99412,
+ 91135,
+ 87282,
+ 76790,
+ 72586,
+ 58818,
+ 58270,
+ 56306,
+ 55486,
+ 54792,
+ 53746,
+ 51460,
+ 41242,
+ 39476,
+ 37414,
+ 36644,
+ 33784,
+ 32788,
+ 32566,
+ 29608,
+ 29558,
+ 29384,
+ 28166,
+ 26998,
+ 26948,
+ 26054,
+ 25804,
+ 25730,
+ 24438,
+ 23782,
+ 22896,
+ 21404,
+ 428978,
+ }
+ _, err := NewPieChart(p, PieChartOption{
+ SeriesList: NewPieSeriesList(values, PieSeriesOption{
+ Label: SeriesLabel{
+ Show: true,
+ Formatter: "{b} ({c} ≅ {d})",
+ },
+ Radius: "150",
+ }),
+ Title: TitleOption{
+ Text: "Fix label K (72586)",
+ Left: PositionRight,
+ },
+ Padding: Box{
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ Left: 20,
+ },
+ Legend: LegendOption{
+ Data: []string{
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G",
+ "H",
+ "I",
+ "J",
+ "K",
+ "L",
+ "M",
+ "N",
+ "O",
+ "P",
+ "Q",
+ "R",
+ "S",
+ "T",
+ "U",
+ "V",
+ "W",
+ "X",
+ "Y",
+ "Z",
+ "AA",
+ "AB",
+ "AC",
+ "AD",
+ "AE",
+ "AF",
+ "AG",
+ "AH",
+ "AI",
+ "AJ",
+ "AK",
+ "AL",
+ "AM",
+ },
+ Show: FalseFlag(),
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 1150,
+ Height: 550,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/radar_chart.go b/radar_chart.go
new file mode 100644
index 0000000..cf18135
--- /dev/null
+++ b/radar_chart.go
@@ -0,0 +1,273 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "errors"
+
+ "github.com/dustin/go-humanize"
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+type radarChart struct {
+ p *Painter
+ opt *RadarChartOption
+}
+
+type RadarIndicator struct {
+ // Indicator's name
+ Name string
+ // The maximum value of indicator
+ Max float64
+ // The minimum value of indicator
+ Min float64
+}
+
+type RadarChartOption struct {
+ // The theme
+ Theme ColorPalette
+ // The font size
+ Font *truetype.Font
+ // The data series list
+ SeriesList SeriesList
+ // The padding of line chart
+ Padding Box
+ // The option of title
+ Title TitleOption
+ // The legend option
+ Legend LegendOption
+ // The radar indicator list
+ RadarIndicators []RadarIndicator
+ // background is filled
+ backgroundIsFilled bool
+}
+
+// NewRadarIndicators returns a radar indicator list
+func NewRadarIndicators(names []string, values []float64) []RadarIndicator {
+ if len(names) != len(values) {
+ return nil
+ }
+ indicators := make([]RadarIndicator, len(names))
+ for index, name := range names {
+ indicators[index] = RadarIndicator{
+ Name: name,
+ Max: values[index],
+ }
+ }
+ return indicators
+}
+
+// NewRadarChart returns a radar chart renderer
+func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &radarChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
+ opt := r.opt
+ indicators := opt.RadarIndicators
+ sides := len(indicators)
+ if sides < 3 {
+ return BoxZero, errors.New("The count of indicator should be >= 3")
+ }
+ maxValues := make([]float64, len(indicators))
+ for _, series := range seriesList {
+ for index, item := range series.Data {
+ if index < len(maxValues) && item.Value > maxValues[index] {
+ maxValues[index] = item.Value
+ }
+ }
+ }
+ for index, indicator := range indicators {
+ if indicator.Max <= 0 {
+ indicators[index].Max = maxValues[index]
+ }
+ }
+
+ radiusValue := ""
+ for _, series := range seriesList {
+ if len(series.Radius) != 0 {
+ radiusValue = series.Radius
+ }
+ }
+
+ seriesPainter := result.seriesPainter
+ theme := opt.Theme
+
+ cx := seriesPainter.Width() >> 1
+ cy := seriesPainter.Height() >> 1
+ diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
+ radius := getRadius(float64(diameter), radiusValue)
+
+ divideCount := 5
+ divideRadius := float64(int(radius / float64(divideCount)))
+ radius = divideRadius * float64(divideCount)
+
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeColor: theme.GetAxisSplitLineColor(),
+ StrokeWidth: 1,
+ })
+ center := Point{
+ X: cx,
+ Y: cy,
+ }
+ for i := 0; i < divideCount; i++ {
+ seriesPainter.Polygon(center, divideRadius*float64(i+1), sides)
+ }
+ points := getPolygonPoints(center, radius, sides)
+ for _, p := range points {
+ seriesPainter.MoveTo(center.X, center.Y)
+ seriesPainter.LineTo(p.X, p.Y)
+ seriesPainter.Stroke()
+ }
+ seriesPainter.OverrideTextStyle(Style{
+ FontColor: theme.GetTextColor(),
+ FontSize: labelFontSize,
+ Font: opt.Font,
+ })
+ offset := 5
+ // 文本生成
+ for index, p := range points {
+ name := indicators[index].Name
+ b := seriesPainter.MeasureText(name)
+ isXCenter := p.X == center.X
+ isYCenter := p.Y == center.Y
+ isRight := p.X > center.X
+ isLeft := p.X < center.X
+ isTop := p.Y < center.Y
+ isBottom := p.Y > center.Y
+ x := p.X
+ y := p.Y
+ if isXCenter {
+ x -= b.Width() >> 1
+ if isTop {
+ y -= b.Height()
+ } else {
+ y += b.Height()
+ }
+ }
+ if isYCenter {
+ y += b.Height() >> 1
+ }
+ if isTop {
+ y += offset
+ }
+ if isBottom {
+ y += offset
+ }
+ if isRight {
+ x += offset
+ }
+ if isLeft {
+ x -= (b.Width() + offset)
+ }
+ seriesPainter.Text(name, x, y)
+ }
+
+ // 雷达图
+ angles := getPolygonPointAngles(sides)
+ maxCount := len(indicators)
+ for _, series := range seriesList {
+ linePoints := make([]Point, 0, maxCount)
+ for j, item := range series.Data {
+ if j >= maxCount {
+ continue
+ }
+ indicator := indicators[j]
+ var percent float64
+ offset := indicator.Max - indicator.Min
+ if offset > 0 {
+ percent = (item.Value - indicator.Min) / offset
+ }
+ r := percent * radius
+ p := getPolygonPoint(center, r, angles[j])
+ linePoints = append(linePoints, p)
+ }
+ color := theme.GetSeriesColor(series.index)
+ dotFillColor := drawing.ColorWhite
+ if theme.IsDark() {
+ dotFillColor = color
+ }
+ linePoints = append(linePoints, linePoints[0])
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeColor: color,
+ StrokeWidth: defaultStrokeWidth,
+ DotWidth: defaultDotWidth,
+ DotColor: color,
+ FillColor: color.WithAlpha(20),
+ })
+ seriesPainter.LineStroke(linePoints).
+ FillArea(linePoints)
+ dotWith := 2.0
+ seriesPainter.OverrideDrawingStyle(Style{
+ StrokeWidth: defaultStrokeWidth,
+ StrokeColor: color,
+ FillColor: dotFillColor,
+ })
+ for index, point := range linePoints {
+ seriesPainter.Circle(dotWith, point.X, point.Y)
+ seriesPainter.FillStroke()
+ if series.Label.Show && index < len(series.Data) {
+ value := humanize.FtoaWithDigits(series.Data[index].Value, 2)
+ b := seriesPainter.MeasureText(value)
+ seriesPainter.Text(value, point.X-b.Width()/2, point.Y)
+ }
+
+ }
+ }
+
+ return r.p.box, nil
+}
+
+func (r *radarChart) Render() (Box, error) {
+ p := r.p
+ opt := r.opt
+ renderResult, err := defaultRender(p, defaultRenderOption{
+ Theme: opt.Theme,
+ Padding: opt.Padding,
+ SeriesList: opt.SeriesList,
+ XAxis: XAxisOption{
+ Show: FalseFlag(),
+ },
+ YAxisOptions: []YAxisOption{
+ {
+ Show: FalseFlag(),
+ },
+ },
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ backgroundIsFilled: opt.backgroundIsFilled,
+ })
+ if err != nil {
+ return BoxZero, err
+ }
+ seriesList := opt.SeriesList.Filter(ChartTypeRadar)
+ return r.render(renderResult, seriesList)
+}
diff --git a/radar_chart_test.go b/radar_chart_test.go
new file mode 100644
index 0000000..79fd9ac
--- /dev/null
+++ b/radar_chart_test.go
@@ -0,0 +1,107 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRadarChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ values := [][]float64{
+ {
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000,
+ },
+ {
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000,
+ },
+ }
+ _, err := NewRadarChart(p, RadarChartOption{
+ SeriesList: NewSeriesListDataFromValues(values, ChartTypeRadar),
+ Title: TitleOption{
+ Text: "Basic Radar Chart",
+ },
+ Legend: NewLegendOption([]string{
+ "Allocated Budget",
+ "Actual Spending",
+ }),
+ RadarIndicators: NewRadarIndicators([]string{
+ "Sales",
+ "Administration",
+ "Information Technology",
+ "Customer Support",
+ "Development",
+ "Marketing",
+ }, []float64{
+ 6500,
+ 16000,
+ 30000,
+ 38000,
+ 52000,
+ 25000,
+ }),
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p.Child(PainterPaddingOption(Box{
+ Left: 20,
+ Top: 20,
+ Right: 20,
+ Bottom: 20,
+ })))
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/range.go b/range.go
new file mode 100644
index 0000000..ec64c2d
--- /dev/null
+++ b/range.go
@@ -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)
+ // 如果是小于0,int的时候向上取整了,因此调整
+ if min < 0 ||
+ (isLessThanZero && min == 0) {
+ min -= float64(unit)
+ }
+ }
+ max = min + float64(unit*divideCount)
+ expectMax := opt.Max * 2
+ if max > expectMax {
+ max = float64(ceilFloatToInt(expectMax))
+ }
+ return axisRange{
+ p: opt.Painter,
+ divideCount: divideCount,
+ min: min,
+ max: max,
+ size: opt.Size,
+ boundary: opt.Boundary,
+ }
+}
+
+// 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)
+}
diff --git a/series.go b/series.go
new file mode 100644
index 0000000..da50e64
--- /dev/null
+++ b/series.go
@@ -0,0 +1,318 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+package charts
+
+import (
+ "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
+ }
+}
diff --git a/series_label.go b/series_label.go
new file mode 100644
index 0000000..af873fc
--- /dev/null
+++ b/series_label.go
@@ -0,0 +1,148 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+)
+
+type labelRenderValue struct {
+ Text string
+ Style Style
+ X int
+ Y int
+ // 旋转
+ Radians float64
+}
+
+type LabelValue struct {
+ Index int
+ Value float64
+ X int
+ Y int
+ // 旋转
+ Radians float64
+ // 字体颜色
+ FontColor Color
+ // 字体大小
+ FontSize float64
+ Orient string
+ Offset Box
+}
+
+type SeriesLabelPainter struct {
+ p *Painter
+ seriesNames []string
+ label *SeriesLabel
+ theme ColorPalette
+ font *truetype.Font
+ values []labelRenderValue
+}
+
+type SeriesLabelPainterParams struct {
+ P *Painter
+ SeriesNames []string
+ Label SeriesLabel
+ Theme ColorPalette
+ Font *truetype.Font
+}
+
+func NewSeriesLabelPainter(params SeriesLabelPainterParams) *SeriesLabelPainter {
+ return &SeriesLabelPainter{
+ p: params.P,
+ seriesNames: params.SeriesNames,
+ label: ¶ms.Label,
+ theme: params.Theme,
+ font: params.Font,
+ values: make([]labelRenderValue, 0),
+ }
+}
+
+func (o *SeriesLabelPainter) Add(value LabelValue) {
+ label := o.label
+ distance := label.Distance
+ if distance == 0 {
+ distance = 5
+ }
+ text := NewValueLabelFormatter(o.seriesNames, label.Formatter)(value.Index, value.Value, -1)
+ labelStyle := Style{
+ FontColor: o.theme.GetTextColor(),
+ FontSize: labelFontSize,
+ Font: o.font,
+ }
+ if value.FontSize != 0 {
+ labelStyle.FontSize = value.FontSize
+ }
+ if !value.FontColor.IsZero() {
+ label.Color = value.FontColor
+ }
+ if !label.Color.IsZero() {
+ labelStyle.FontColor = label.Color
+ }
+ p := o.p
+ p.OverrideDrawingStyle(labelStyle)
+ rotated := value.Radians != 0
+ if rotated {
+ p.SetTextRotation(value.Radians)
+ }
+ textBox := p.MeasureText(text)
+ renderValue := labelRenderValue{
+ Text: text,
+ Style: labelStyle,
+ X: value.X,
+ Y: value.Y,
+ Radians: value.Radians,
+ }
+ if value.Orient != OrientHorizontal {
+ renderValue.X -= textBox.Width() >> 1
+ renderValue.Y -= distance
+ } else {
+ renderValue.X += distance
+ renderValue.Y += textBox.Height() >> 1
+ renderValue.Y -= 2
+ }
+ if rotated {
+ renderValue.X = value.X + textBox.Width()>>1 - 1
+ p.ClearTextRotation()
+ } else {
+ if textBox.Width()%2 != 0 {
+ renderValue.X++
+ }
+ }
+ renderValue.X += value.Offset.Left
+ renderValue.Y += value.Offset.Top
+ o.values = append(o.values, renderValue)
+}
+
+func (o *SeriesLabelPainter) Render() (Box, error) {
+ for _, item := range o.values {
+ o.p.OverrideTextStyle(item.Style)
+ if item.Radians != 0 {
+ o.p.TextRotation(item.Text, item.X, item.Y, item.Radians)
+ } else {
+ o.p.Text(item.Text, item.X, item.Y)
+ }
+ }
+ return chart.BoxZero, nil
+}
diff --git a/series_test.go b/series_test.go
new file mode 100644
index 0000000..40d2f91
--- /dev/null
+++ b/series_test.go
@@ -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))
+}
diff --git a/start_zh.md b/start_zh.md
new file mode 100644
index 0000000..ee8359c
--- /dev/null
+++ b/start_zh.md
@@ -0,0 +1,254 @@
+# go-charts
+
+`go-charts`主要分为了下几个模块:
+
+- `标题`:图表的标题,包括主副标题,位置为图表的顶部
+- `图例`:图表的图例列表,用于标识每个图例对应的颜色与名称信息,默认为图表的顶部,可自定义位置
+- `X轴`:图表的x轴,用于折线图、柱状图中,表示每个点对应的时间,位置图表的底部
+- `Y轴`:图表的y轴,用于折线图、柱状图中,最多可使用两组y轴(一左一右),默认位置图表的左侧
+- `内容`: 图表的内容,折线图、柱状图、饼图等,在图表的中间区域
+
+## 标题
+
+### 常用设置
+
+标题一般仅需要设置主副标题即可,其它的属性均会设置默认值,常用的方式是使用`TitleTextOptionFunc`设置,其中副标题为可选值,方式如下:
+
+```go
+ charts.TitleTextOptionFunc("Text", "Subtext"),
+```
+
+### 个性化设置
+
+```go
+func(opt *charts.ChartOption) {
+ opt.Title = charts.TitleOption{
+ // 主标题
+ Text: "Text",
+ // 副标题
+ Subtext: "Subtext",
+ // 标题左侧位置,可设置为"center","right",数值("20")或百份比("20%")
+ Left: charts.PositionRight,
+ // 标题顶部位置,只可调为数值
+ Top: "20",
+ // 主标题文字大小
+ FontSize: 14,
+ // 副标题文字大小
+ SubtextFontSize: 12,
+ // 主标题字体颜色
+ FontColor: charts.Color{
+ R: 100,
+ G: 100,
+ B: 100,
+ A: 255,
+ },
+ // 副标题字体影响
+ SubtextFontColor: charts.Color{
+ R: 200,
+ G: 200,
+ B: 200,
+ A: 255,
+ },
+ }
+},
+```
+
+### 部分属性个性化设置
+
+```go
+charts.TitleTextOptionFunc("Text", "Subtext"),
+func(opt *charts.ChartOption) {
+ // 修改top的值
+ opt.Title.Top = "20"
+},
+```
+
+## 图例
+
+### 常用设置
+
+图例组件与图表中的数据一一对应,常用仅设置其名称及左侧的值即可(可选),方式如下:
+
+
+```go
+charts.LegendLabelsOptionFunc([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+}, "50"),
+```
+
+### 个性化设置
+
+```go
+func(opt *charts.ChartOption) {
+ opt.Legend = charts.LegendOption{
+ // 图例名称
+ Data: []string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ },
+ // 图例左侧位置,可设置为"center","right",数值("20")或百份比("20%")
+ // 如果示例有多行,只影响第一行,而且对于多行的示例,设置"center", "right"无效
+ Left: "50",
+ // 图例顶部位置,只可调为数值
+ Top: "10",
+ // 图例图标的位置,默认为左侧,只允许左或右
+ Align: charts.AlignRight,
+ // 图例排列方式,默认为水平,只允许水平或垂直
+ Orient: charts.OrientVertical,
+ // 图标类型,提供"rect"与"lineDot"两种类型
+ Icon: charts.IconRect,
+ // 字体大小
+ FontSize: 14,
+ // 字体颜色
+ FontColor: charts.Color{
+ R: 150,
+ G: 150,
+ B: 150,
+ A: 255,
+ },
+ // 是否展示,如果不需要展示则设置
+ // Show: charts.FalseFlag(),
+ // 图例区域的padding值
+ Padding: charts.Box{
+ Top: 10,
+ Left: 10,
+ },
+ }
+},
+```
+
+### 部分属性个性化设置
+
+```go
+charts.LegendLabelsOptionFunc([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+}, "50"),
+func(opt *charts.ChartOption) {
+ opt.Legend.Top = "10"
+},
+```
+
+## X轴
+
+### 常用设置
+
+图表中X轴的展示,常用的设置方式是指定数组即可:
+
+```go
+charts.XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+}),
+```
+
+### 个性化设置
+
+```go
+func(opt *charts.ChartOption) {
+ opt.XAxis = charts.XAxisOption{
+ // X轴内容
+ Data: []string{
+ "01",
+ "02",
+ "03",
+ "04",
+ "05",
+ "06",
+ "07",
+ "08",
+ "09",
+ },
+ // 如果数据点不居中,则设置为false
+ BoundaryGap: charts.FalseFlag(),
+ // 字体大小
+ FontSize: 14,
+ // 是否展示,如果不需要展示则设置
+ // Show: charts.FalseFlag(),
+ // 会根据文本内容以及此值选择适合的分块大小,一般不需要设置
+ // SplitNumber: 3,
+ // 线条颜色
+ StrokeColor: charts.Color{
+ R: 200,
+ G: 200,
+ B: 200,
+ A: 255,
+ },
+ // 文字颜色
+ FontColor: charts.Color{
+ R: 100,
+ G: 100,
+ B: 100,
+ A: 255,
+ },
+ }
+},
+```
+
+### 部分属性个性化设置
+
+```go
+charts.XAxisDataOptionFunc([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+}),
+func(opt *charts.ChartOption) {
+ opt.XAxis.FontColor = charts.Color{
+ R: 100,
+ G: 100,
+ B: 100,
+ A: 255,
+ },
+},
+```
+
+## Y轴
+
+图表中的y轴展示的相关数据会根据图表中的数据自动生成适合的值,如果需要自定义,则可自定义以下部分数据:
+
+```go
+func(opt *charts.ChartOption) {
+ opt.YAxisOptions = []charts.YAxisOption{
+ {
+ // 字体大小
+ FontSize: 16,
+ // 字体颜色
+ FontColor: charts.Color{
+ R: 100,
+ G: 100,
+ B: 100,
+ A: 255,
+ },
+ // 内容,{value}会替换为对应的值
+ Formatter: "{value} ml",
+ // Y轴颜色,如果设置此值,会覆盖font color
+ Color: charts.Color{
+ R: 255,
+ G: 0,
+ B: 0,
+ A: 255,
+ },
+ },
+ }
+},
+```
diff --git a/table.go b/table.go
new file mode 100644
index 0000000..3e6f273
--- /dev/null
+++ b/table.go
@@ -0,0 +1,438 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "errors"
+
+ "github.com/golang/freetype/truetype"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+type tableChart struct {
+ p *Painter
+ opt *TableChartOption
+}
+
+// NewTableChart returns a table chart render
+func NewTableChart(p *Painter, opt TableChartOption) *tableChart {
+ if opt.Theme == nil {
+ opt.Theme = defaultTheme
+ }
+ return &tableChart{
+ p: p,
+ opt: &opt,
+ }
+}
+
+type TableCell struct {
+ // Text the text of table cell
+ Text string
+ // Style the current style of table cell
+ Style Style
+ // Row the row index of table cell
+ Row int
+ // Column the column index of table cell
+ Column int
+}
+
+type TableChartOption struct {
+ // The output type
+ Type string
+ // The width of table
+ Width int
+ // The theme
+ Theme ColorPalette
+ // The padding of table cell
+ Padding Box
+ // The header data of table
+ Header []string
+ // The data of table
+ Data [][]string
+ // The span list of table column
+ Spans []int
+ // The text align list of table cell
+ TextAligns []string
+ // The font size of table
+ FontSize float64
+ // The font family, which should be installed first
+ FontFamily string
+ Font *truetype.Font
+ // The font color of table
+ FontColor Color
+ // The background color of header
+ HeaderBackgroundColor Color
+ // The header font color
+ HeaderFontColor Color
+ // The background color of row
+ RowBackgroundColors []Color
+ // The background color
+ BackgroundColor Color
+ // CellTextStyle customize text style of table cell
+ CellTextStyle func(TableCell) *Style
+ // CellStyle customize drawing style of table cell
+ CellStyle func(TableCell) *Style
+}
+
+type TableSetting struct {
+ // The color of header
+ HeaderColor Color
+ // The color of heder text
+ HeaderFontColor Color
+ // The color of table text
+ FontColor Color
+ // The color list of row
+ RowColors []Color
+ // The padding of cell
+ Padding Box
+}
+
+var TableLightThemeSetting = TableSetting{
+ HeaderColor: Color{
+ R: 240,
+ G: 240,
+ B: 240,
+ A: 255,
+ },
+ HeaderFontColor: Color{
+ R: 98,
+ G: 105,
+ B: 118,
+ A: 255,
+ },
+ FontColor: Color{
+ R: 70,
+ G: 70,
+ B: 70,
+ A: 255,
+ },
+ RowColors: []Color{
+ drawing.ColorWhite,
+ {
+ R: 247,
+ G: 247,
+ B: 247,
+ A: 255,
+ },
+ },
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+}
+
+var TableDarkThemeSetting = TableSetting{
+ HeaderColor: Color{
+ R: 38,
+ G: 38,
+ B: 42,
+ A: 255,
+ },
+ HeaderFontColor: Color{
+ R: 216,
+ G: 217,
+ B: 218,
+ A: 255,
+ },
+ FontColor: Color{
+ R: 216,
+ G: 217,
+ B: 218,
+ A: 255,
+ },
+ RowColors: []Color{
+ {
+ R: 24,
+ G: 24,
+ B: 28,
+ A: 255,
+ },
+ {
+ R: 38,
+ G: 38,
+ B: 42,
+ A: 255,
+ },
+ },
+ Padding: Box{
+ Left: 10,
+ Top: 10,
+ Right: 10,
+ Bottom: 10,
+ },
+}
+
+var tableDefaultSetting = TableLightThemeSetting
+
+// SetDefaultTableSetting sets the default setting for table
+func SetDefaultTableSetting(setting TableSetting) {
+ tableDefaultSetting = setting
+}
+
+type renderInfo struct {
+ Width int
+ Height int
+ HeaderHeight int
+ RowHeights []int
+ ColumnWidths []int
+}
+
+func (t *tableChart) render() (*renderInfo, error) {
+ info := renderInfo{
+ RowHeights: make([]int, 0),
+ }
+ p := t.p
+ opt := t.opt
+ if len(opt.Header) == 0 {
+ return nil, errors.New("header can not be nil")
+ }
+ theme := opt.Theme
+ if theme == nil {
+ theme = p.theme
+ }
+ fontSize := opt.FontSize
+ if fontSize == 0 {
+ fontSize = 12
+ }
+ fontColor := opt.FontColor
+ if fontColor.IsZero() {
+ fontColor = tableDefaultSetting.FontColor
+ }
+ font := opt.Font
+ if font == nil {
+ font = theme.GetFont()
+ }
+ headerFontColor := opt.HeaderFontColor
+ if opt.HeaderFontColor.IsZero() {
+ headerFontColor = tableDefaultSetting.HeaderFontColor
+ }
+
+ spans := opt.Spans
+ if len(spans) != len(opt.Header) {
+ newSpans := make([]int, len(opt.Header))
+ for index := range opt.Header {
+ if index >= len(spans) {
+ newSpans[index] = 1
+ } else {
+ newSpans[index] = spans[index]
+ }
+ }
+ spans = newSpans
+ }
+
+ sum := sumInt(spans)
+ values := autoDivideSpans(p.Width(), sum, spans)
+ columnWidths := make([]int, 0)
+ for index, v := range values {
+ if index == len(values)-1 {
+ break
+ }
+ columnWidths = append(columnWidths, values[index+1]-v)
+ }
+ info.ColumnWidths = columnWidths
+
+ height := 0
+ textStyle := Style{
+ FontSize: fontSize,
+ FontColor: headerFontColor,
+ FillColor: headerFontColor,
+ Font: font,
+ }
+
+ headerHeight := 0
+ padding := opt.Padding
+ if padding.IsZero() {
+ padding = tableDefaultSetting.Padding
+ }
+ getCellTextStyle := opt.CellTextStyle
+ if getCellTextStyle == nil {
+ getCellTextStyle = func(_ TableCell) *Style {
+ return nil
+ }
+ }
+ // textAligns := opt.TextAligns
+ getTextAlign := func(index int) string {
+ if len(opt.TextAligns) <= index {
+ return ""
+ }
+ return opt.TextAligns[index]
+ }
+
+ // 表格单元的处理
+ renderTableCells := func(
+ currentStyle Style,
+ rowIndex int,
+ textList []string,
+ currentHeight int,
+ cellPadding Box,
+ ) int {
+ cellMaxHeight := 0
+ paddingHeight := cellPadding.Top + cellPadding.Bottom
+ paddingWidth := cellPadding.Left + cellPadding.Right
+ for index, text := range textList {
+ cellStyle := getCellTextStyle(TableCell{
+ Text: text,
+ Row: rowIndex,
+ Column: index,
+ Style: currentStyle,
+ })
+ if cellStyle == nil {
+ cellStyle = ¤tStyle
+ }
+ p.SetStyle(*cellStyle)
+ x := values[index]
+ y := currentHeight + cellPadding.Top
+ width := values[index+1] - x
+ x += cellPadding.Left
+ width -= paddingWidth
+ box := p.TextFit(text, x, y+int(fontSize), width, getTextAlign(index))
+ // 计算最高的高度
+ if box.Height()+paddingHeight > cellMaxHeight {
+ cellMaxHeight = box.Height() + paddingHeight
+ }
+ }
+ return cellMaxHeight
+ }
+
+ // 表头的处理
+ headerHeight = renderTableCells(textStyle, 0, opt.Header, height, padding)
+ height += headerHeight
+ info.HeaderHeight = headerHeight
+
+ // 表格内容的处理
+ textStyle.FontColor = fontColor
+ textStyle.FillColor = fontColor
+ for index, textList := range opt.Data {
+ cellHeight := renderTableCells(textStyle, index+1, textList, height, padding)
+ info.RowHeights = append(info.RowHeights, cellHeight)
+ height += cellHeight
+ }
+
+ info.Width = p.Width()
+ info.Height = height
+ return &info, nil
+}
+
+func (t *tableChart) renderWithInfo(info *renderInfo) (Box, error) {
+ p := t.p
+ opt := t.opt
+ if !opt.BackgroundColor.IsZero() {
+ p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
+ }
+ headerBGColor := opt.HeaderBackgroundColor
+ if headerBGColor.IsZero() {
+ headerBGColor = tableDefaultSetting.HeaderColor
+ }
+
+ // 如果设置表头背景色
+ p.SetBackground(info.Width, info.HeaderHeight, headerBGColor, true)
+ currentHeight := info.HeaderHeight
+ rowColors := opt.RowBackgroundColors
+ if rowColors == nil {
+ rowColors = tableDefaultSetting.RowColors
+ }
+ for index, h := range info.RowHeights {
+ color := rowColors[index%len(rowColors)]
+ child := p.Child(PainterPaddingOption(Box{
+ Top: currentHeight,
+ }))
+ child.SetBackground(p.Width(), h, color, true)
+ currentHeight += h
+ }
+ // 根据是否有设置表格样式调整背景色
+ getCellStyle := opt.CellStyle
+ if getCellStyle != nil {
+ arr := [][]string{
+ opt.Header,
+ }
+ arr = append(arr, opt.Data...)
+ top := 0
+ heights := []int{
+ info.HeaderHeight,
+ }
+ heights = append(heights, info.RowHeights...)
+ // 循环所有表格单元,生成背景色
+ for i, textList := range arr {
+ left := 0
+ for j, v := range textList {
+ style := getCellStyle(TableCell{
+ Text: v,
+ Row: i,
+ Column: j,
+ })
+ if style != nil && !style.FillColor.IsZero() {
+ padding := style.Padding
+ child := p.Child(PainterPaddingOption(Box{
+ Top: top + padding.Top,
+ Left: left + padding.Left,
+ }))
+ w := info.ColumnWidths[j] - padding.Left - padding.Top
+ h := heights[i] - padding.Top - padding.Bottom
+ child.SetBackground(w, h, style.FillColor, true)
+ }
+ left += info.ColumnWidths[j]
+ }
+ top += heights[i]
+ }
+ }
+ _, err := t.render()
+ if err != nil {
+ return BoxZero, err
+ }
+
+ return Box{
+ Right: info.Width,
+ Bottom: info.Height,
+ }, nil
+}
+
+func (t *tableChart) Render() (Box, error) {
+ p := t.p
+ opt := t.opt
+ if !opt.BackgroundColor.IsZero() {
+ p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
+ }
+ if opt.Font == nil && opt.FontFamily != "" {
+ opt.Font, _ = GetFont(opt.FontFamily)
+ }
+
+ r := p.render
+ fn := chart.PNG
+ if p.outputType == ChartOutputSVG {
+ fn = chart.SVG
+ }
+ newRender, err := fn(p.Width(), 100)
+ if err != nil {
+ return BoxZero, err
+ }
+ p.render = newRender
+ info, err := t.render()
+ if err != nil {
+ return BoxZero, err
+ }
+ p.render = r
+ return t.renderWithInfo(info)
+}
diff --git a/table_test.go b/table_test.go
new file mode 100644
index 0000000..a958c95
--- /dev/null
+++ b/table_test.go
@@ -0,0 +1,140 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTableChart(t *testing.T) {
+ assert := assert.New(t)
+
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewTableChart(p, TableChartOption{
+ Header: []string{
+ "Name",
+ "Age",
+ "Address",
+ "Tag",
+ "Action",
+ },
+ Spans: []int{
+ 1,
+ 1,
+ 2,
+ 1,
+ // span和header不匹配,最后自动设置为1
+ // 1,
+ },
+ Data: [][]string{
+ {
+ "John Brown",
+ "32",
+ "New York No. 1 Lake Park",
+ "nice, developer",
+ "Send Mail",
+ },
+ {
+ "Jim Green ",
+ "42",
+ "London No. 1 Lake Park",
+ "wow",
+ "Send Mail",
+ },
+ {
+ "Joe Black ",
+ "32",
+ "Sidney No. 1 Lake Park",
+ "cool, teacher",
+ "Send Mail",
+ },
+ },
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ {
+ 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: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/theme.go b/theme.go
new file mode 100644
index 0000000..85016a5
--- /dev/null
+++ b/theme.go
@@ -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
+}
diff --git a/title.go b/title.go
new file mode 100644
index 0000000..74ab4f9
--- /dev/null
+++ b/title.go
@@ -0,0 +1,197 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/golang/freetype/truetype"
+)
+
+type TitleOption struct {
+ // The theme of chart
+ Theme ColorPalette
+ // Title text, support \n for new line
+ Text string
+ // Subtitle text, support \n for new line
+ Subtext string
+ // Distance between title component and the left side of the container.
+ // It can be pixel value: 20, percentage value: 20%,
+ // or position value: right, center.
+ Left string
+ // Distance between title component and the top side of the container.
+ // It can be pixel value: 20.
+ Top string
+ // The font of label
+ Font *truetype.Font
+ // The font size of label
+ FontSize float64
+ // The color of label
+ FontColor Color
+ // The subtext font size of label
+ SubtextFontSize float64
+ // The subtext font color of label
+ SubtextFontColor Color
+}
+
+type titleMeasureOption struct {
+ width int
+ height int
+ text string
+ style Style
+}
+
+func splitTitleText(text string) []string {
+ arr := strings.Split(text, "\n")
+ result := make([]string, 0)
+ for _, v := range arr {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ continue
+ }
+ result = append(result, v)
+ }
+ return result
+}
+
+type titlePainter struct {
+ p *Painter
+ opt *TitleOption
+}
+
+// NewTitlePainter returns a title renderer
+func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter {
+ return &titlePainter{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (t *titlePainter) Render() (Box, error) {
+ opt := t.opt
+ p := t.p
+ theme := opt.Theme
+
+ if theme == nil {
+ theme = p.theme
+ }
+ if opt.Text == "" && opt.Subtext == "" {
+ return BoxZero, nil
+ }
+
+ measureOptions := make([]titleMeasureOption, 0)
+
+ if opt.Font == nil {
+ opt.Font = theme.GetFont()
+ }
+ if opt.FontColor.IsZero() {
+ opt.FontColor = theme.GetTextColor()
+ }
+ if opt.FontSize == 0 {
+ opt.FontSize = theme.GetFontSize()
+ }
+ if opt.SubtextFontColor.IsZero() {
+ opt.SubtextFontColor = opt.FontColor
+ }
+ if opt.SubtextFontSize == 0 {
+ opt.SubtextFontSize = opt.FontSize
+ }
+
+ titleTextStyle := Style{
+ Font: opt.Font,
+ FontSize: opt.FontSize,
+ FontColor: opt.FontColor,
+ }
+ // 主标题
+ for _, v := range splitTitleText(opt.Text) {
+ measureOptions = append(measureOptions, titleMeasureOption{
+ text: v,
+ style: titleTextStyle,
+ })
+ }
+ subtextStyle := Style{
+ Font: opt.Font,
+ FontSize: opt.SubtextFontSize,
+ FontColor: opt.SubtextFontColor,
+ }
+ // 副标题
+ for _, v := range splitTitleText(opt.Subtext) {
+ measureOptions = append(measureOptions, titleMeasureOption{
+ text: v,
+ style: subtextStyle,
+ })
+ }
+ textMaxWidth := 0
+ textMaxHeight := 0
+ for index, item := range measureOptions {
+ p.OverrideTextStyle(item.style)
+ textBox := p.MeasureText(item.text)
+
+ w := textBox.Width()
+ h := textBox.Height()
+ if w > textMaxWidth {
+ textMaxWidth = w
+ }
+ if h > textMaxHeight {
+ textMaxHeight = h
+ }
+ measureOptions[index].height = h
+ measureOptions[index].width = w
+ }
+ width := textMaxWidth
+
+ titleX := 0
+ switch opt.Left {
+ case PositionRight:
+ titleX = p.Width() - textMaxWidth
+ case PositionCenter:
+ titleX = p.Width()>>1 - (textMaxWidth >> 1)
+ default:
+ if strings.HasSuffix(opt.Left, "%") {
+ value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
+ titleX = p.Width() * value / 100
+ } else {
+ value, _ := strconv.Atoi(opt.Left)
+ titleX = value
+ }
+ }
+ titleY := 0
+ // TODO TOP 暂只支持数值
+ if opt.Top != "" {
+ value, _ := strconv.Atoi(opt.Top)
+ titleY += value
+ }
+ for _, item := range measureOptions {
+ p.OverrideTextStyle(item.style)
+ x := titleX + (textMaxWidth-item.width)>>1
+ y := titleY + item.height
+ p.Text(item.text, x, y)
+ titleY += item.height
+ }
+
+ return Box{
+ Bottom: titleY,
+ Right: titleX + width,
+ }, nil
+}
diff --git a/title_test.go b/title_test.go
new file mode 100644
index 0000000..add8163
--- /dev/null
+++ b/title_test.go
@@ -0,0 +1,93 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTitleRenderer(t *testing.T) {
+ assert := assert.New(t)
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ _, err := NewTitlePainter(p, TitleOption{
+ Text: "title",
+ Subtext: "subTitle",
+ Left: "20",
+ Top: "20",
+ }).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ {
+ 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: "",
+ },
+ {
+ 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: "",
+ },
+ }
+ for _, tt := range tests {
+ p, err := NewPainter(PainterOptions{
+ Type: ChartOutputSVG,
+ Width: 600,
+ Height: 400,
+ }, PainterThemeOption(defaultTheme))
+ assert.Nil(err)
+ data, err := tt.render(p)
+ assert.Nil(err)
+ assert.Equal(tt.result, string(data))
+ }
+}
diff --git a/util.go b/util.go
new file mode 100644
index 0000000..87ff31c
--- /dev/null
+++ b/util.go
@@ -0,0 +1,271 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "math"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/dustin/go-humanize"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TrueFlag() *bool {
+ t := true
+ return &t
+}
+
+func FalseFlag() *bool {
+ f := false
+ return &f
+}
+
+func containsInt(values []int, value int) bool {
+ for _, v := range values {
+ if v == value {
+ return true
+ }
+ }
+ return false
+}
+
+func containsString(values []string, value string) bool {
+ for _, v := range values {
+ if v == value {
+ return true
+ }
+ }
+ return false
+}
+
+func ceilFloatToInt(value float64) int {
+ i := int(value)
+ if value == float64(i) {
+ return i
+ }
+ return i + 1
+}
+
+func getDefaultInt(value, defaultValue int) int {
+ if value == 0 {
+ return defaultValue
+ }
+ return value
+}
+
+func autoDivide(max, size int) []int {
+ unit := float64(max) / float64(size)
+
+ values := make([]int, size+1)
+ for i := 0; i < size+1; i++ {
+ if i == size {
+ values[i] = max
+ } else {
+ values[i] = int(float64(i) * unit)
+ }
+ }
+ return values
+}
+
+func autoDivideSpans(max, size int, spans []int) []int {
+ values := autoDivide(max, size)
+ // 重新合并
+ if len(spans) != 0 {
+ newValues := make([]int, len(spans)+1)
+ newValues[0] = 0
+ end := 0
+ for index, v := range spans {
+ end += v
+ newValues[index+1] = values[end]
+ }
+ values = newValues
+ }
+ return values
+}
+
+func sumInt(values []int) int {
+ sum := 0
+ for _, v := range values {
+ sum += v
+ }
+ return sum
+}
+
+// measureTextMaxWidthHeight returns maxWidth and maxHeight of text list
+func measureTextMaxWidthHeight(textList []string, p *Painter) (int, int) {
+ maxWidth := 0
+ maxHeight := 0
+ for _, text := range textList {
+ box := p.MeasureText(text)
+ maxWidth = chart.MaxInt(maxWidth, box.Width())
+ maxHeight = chart.MaxInt(maxHeight, box.Height())
+ }
+ return maxWidth, maxHeight
+}
+
+func reverseStringSlice(stringList []string) {
+ for i, j := 0, len(stringList)-1; i < j; i, j = i+1, j-1 {
+ stringList[i], stringList[j] = stringList[j], stringList[i]
+ }
+}
+
+func reverseIntSlice(intList []int) {
+ for i, j := 0, len(intList)-1; i < j; i, j = i+1, j-1 {
+ intList[i], intList[j] = intList[j], intList[i]
+ }
+}
+
+func convertPercent(value string) float64 {
+ if !strings.HasSuffix(value, "%") {
+ return -1
+ }
+ v, err := strconv.Atoi(strings.ReplaceAll(value, "%", ""))
+ if err != nil {
+ return -1
+ }
+ return float64(v) / 100
+}
+
+func isFalse(flag *bool) bool {
+ if flag != nil && !*flag {
+ return true
+ }
+ return false
+}
+
+func NewFloatPoint(f float64) *float64 {
+ v := f
+ return &v
+}
+
+const K_VALUE = float64(1000)
+const M_VALUE = K_VALUE * K_VALUE
+const G_VALUE = M_VALUE * K_VALUE
+const T_VALUE = G_VALUE * K_VALUE
+
+func commafWithDigits(value float64) string {
+ decimals := 2
+ if value >= T_VALUE {
+ return humanize.CommafWithDigits(value/T_VALUE, decimals) + "T"
+ }
+ if value >= G_VALUE {
+ return humanize.CommafWithDigits(value/G_VALUE, decimals) + "G"
+ }
+ if value >= M_VALUE {
+ return humanize.CommafWithDigits(value/M_VALUE, decimals) + "M"
+ }
+ if value >= K_VALUE {
+ return humanize.CommafWithDigits(value/K_VALUE, decimals) + "k"
+ }
+ return humanize.CommafWithDigits(value, decimals)
+}
+
+func parseColor(color string) Color {
+ c := Color{}
+ if color == "" {
+ return c
+ }
+ if strings.HasPrefix(color, "#") {
+ return drawing.ColorFromHex(color[1:])
+ }
+ reg := regexp.MustCompile(`\((\S+)\)`)
+ result := reg.FindAllStringSubmatch(color, 1)
+ if len(result) == 0 || len(result[0]) != 2 {
+ return c
+ }
+ arr := strings.Split(result[0][1], ",")
+ if len(arr) < 3 {
+ return c
+ }
+ // 设置默认为255
+ c.A = 255
+ for index, v := range arr {
+ value, _ := strconv.Atoi(strings.TrimSpace(v))
+ ui8 := uint8(value)
+ switch index {
+ case 0:
+ c.R = ui8
+ case 1:
+ c.G = ui8
+ case 2:
+ c.B = ui8
+ default:
+ c.A = ui8
+ }
+ }
+ return c
+}
+
+const defaultRadiusPercent = 0.4
+
+func getRadius(diameter float64, radiusValue string) float64 {
+ var radius float64
+ if len(radiusValue) != 0 {
+ v := convertPercent(radiusValue)
+ if v != -1 {
+ radius = float64(diameter) * v
+ } else {
+ radius, _ = strconv.ParseFloat(radiusValue, 64)
+ }
+ }
+ if radius <= 0 {
+ radius = float64(diameter) * defaultRadiusPercent
+ }
+ return radius
+}
+
+func getPolygonPointAngles(sides int) []float64 {
+ angles := make([]float64, sides)
+ for i := 0; i < sides; i++ {
+ angle := 2*math.Pi/float64(sides)*float64(i) - (math.Pi / 2)
+ angles[i] = angle
+ }
+ return angles
+}
+
+func getPolygonPoint(center Point, radius, angle float64) Point {
+ x := center.X + int(radius*math.Cos(angle))
+ y := center.Y + int(radius*math.Sin(angle))
+ return Point{
+ X: x,
+ Y: y,
+ }
+}
+
+func getPolygonPoints(center Point, radius float64, sides int) []Point {
+ points := make([]Point, sides)
+ for i, angle := range getPolygonPointAngles(sides) {
+ points[i] = getPolygonPoint(center, radius, angle)
+ }
+ return points
+}
+
+func isLightColor(c Color) bool {
+ r := float64(c.R) * float64(c.R) * 0.299
+ g := float64(c.G) * float64(c.G) * 0.587
+ b := float64(c.B) * float64(c.B) * 0.114
+ return math.Sqrt(r+g+b) > 127.5
+}
diff --git a/util_test.go b/util_test.go
new file mode 100644
index 0000000..5770776
--- /dev/null
+++ b/util_test.go
@@ -0,0 +1,223 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "git.smarteching.com/zeni/go-chart/v2"
+ "git.smarteching.com/zeni/go-chart/v2/drawing"
+)
+
+func TestGetDefaultInt(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal(1, getDefaultInt(0, 1))
+ assert.Equal(10, getDefaultInt(10, 1))
+}
+
+func TestCeilFloatToInt(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal(1, ceilFloatToInt(0.8))
+ assert.Equal(1, ceilFloatToInt(1.0))
+ assert.Equal(2, ceilFloatToInt(1.2))
+}
+
+func TestCommafWithDigits(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal("1.2", commafWithDigits(1.2))
+ assert.Equal("1.21", commafWithDigits(1.21231))
+
+ assert.Equal("1.20k", commafWithDigits(1200.121))
+ assert.Equal("1.20M", commafWithDigits(1200000.121))
+}
+
+func TestAutoDivide(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal([]int{
+ 0,
+ 85,
+ 171,
+ 257,
+ 342,
+ 428,
+ 514,
+ 600,
+ }, autoDivide(600, 7))
+}
+
+func TestGetRadius(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal(50.0, getRadius(100, "50%"))
+ assert.Equal(30.0, getRadius(100, "30"))
+ assert.Equal(40.0, getRadius(100, ""))
+}
+
+func TestMeasureTextMaxWidthHeight(t *testing.T) {
+ assert := assert.New(t)
+ p, err := NewPainter(PainterOptions{
+ Width: 400,
+ Height: 300,
+ })
+ assert.Nil(err)
+ style := chart.Style{
+ FontSize: 10,
+ }
+ p.SetStyle(style)
+
+ maxWidth, maxHeight := measureTextMaxWidthHeight([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }, p)
+ assert.Equal(31, maxWidth)
+ assert.Equal(12, maxHeight)
+}
+
+func TestReverseSlice(t *testing.T) {
+ assert := assert.New(t)
+
+ arr := []string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }
+ reverseStringSlice(arr)
+ assert.Equal([]string{
+ "Sun",
+ "Sat",
+ "Fri",
+ "Thu",
+ "Wed",
+ "Tue",
+ "Mon",
+ }, arr)
+
+ numbers := []int{
+ 1,
+ 3,
+ 5,
+ 7,
+ 9,
+ }
+ reverseIntSlice(numbers)
+ assert.Equal([]int{
+ 9,
+ 7,
+ 5,
+ 3,
+ 1,
+ }, numbers)
+}
+
+func TestConvertPercent(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.Equal(-1.0, convertPercent("1"))
+ assert.Equal(-1.0, convertPercent("a%"))
+ assert.Equal(0.1, convertPercent("10%"))
+}
+
+func TestParseColor(t *testing.T) {
+ assert := assert.New(t)
+
+ c := parseColor("")
+ assert.True(c.IsZero())
+
+ c = parseColor("#333")
+ assert.Equal(drawing.Color{
+ R: 51,
+ G: 51,
+ B: 51,
+ A: 255,
+ }, c)
+
+ c = parseColor("#313233")
+ assert.Equal(drawing.Color{
+ R: 49,
+ G: 50,
+ B: 51,
+ A: 255,
+ }, c)
+
+ c = parseColor("rgb(31,32,33)")
+ assert.Equal(drawing.Color{
+ R: 31,
+ G: 32,
+ B: 33,
+ A: 255,
+ }, c)
+
+ c = parseColor("rgba(50,51,52,250)")
+ assert.Equal(drawing.Color{
+ R: 50,
+ G: 51,
+ B: 52,
+ A: 250,
+ }, c)
+}
+
+func TestIsLightColor(t *testing.T) {
+ assert := assert.New(t)
+
+ assert.True(isLightColor(drawing.Color{
+ R: 255,
+ G: 255,
+ B: 255,
+ }))
+ assert.True(isLightColor(drawing.Color{
+ R: 145,
+ G: 204,
+ B: 117,
+ }))
+
+ assert.False(isLightColor(drawing.Color{
+ R: 88,
+ G: 112,
+ B: 198,
+ }))
+
+ assert.False(isLightColor(drawing.Color{
+ R: 0,
+ G: 0,
+ B: 0,
+ }))
+ assert.False(isLightColor(drawing.Color{
+ R: 16,
+ G: 12,
+ B: 42,
+ }))
+}
diff --git a/web/index.css b/web/index.css
deleted file mode 100644
index 7709f9b..0000000
--- a/web/index.css
+++ /dev/null
@@ -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;
-}
\ No newline at end of file
diff --git a/web/index.html b/web/index.html
deleted file mode 100644
index 4de3b5c..0000000
--- a/web/index.html
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Go Charts
- 选择图表输出格式:
-
-
-
-
-
ECharts配置
-
-
-
-
图表SVG效果
-
- 运行
-
-
-
-
\ No newline at end of file
diff --git a/web/index.js b/web/index.js
deleted file mode 100644
index 7cfe3bb..0000000
--- a/web/index.js
+++ /dev/null
@@ -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 = '';
- } else {
- document.getElementById("svg").innerHTML = resp;
- }
- }).catch(function(err) {
- alert(err.message);
- });
-}
\ No newline at end of file
diff --git a/web/javascript.js b/web/javascript.js
deleted file mode 100644
index 09ba4c3..0000000
--- a/web/javascript.js
+++ /dev/null
@@ -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 });
-
- });
\ No newline at end of file
diff --git a/xaxis.go b/xaxis.go
new file mode 100644
index 0000000..61698d7
--- /dev/null
+++ b/xaxis.go
@@ -0,0 +1,105 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "github.com/golang/freetype/truetype"
+)
+
+type XAxisOption struct {
+ // The font of x axis
+ Font *truetype.Font
+ // The boundary gap on both sides of a coordinate axis.
+ // Nil or *true means the center part of two axis ticks
+ BoundaryGap *bool
+ // The data value of x axis
+ Data []string
+ // The theme of chart
+ Theme ColorPalette
+ // The font size of x axis label
+ FontSize float64
+ // The flag for show axis, set this to *false will hide axis
+ Show *bool
+ // Number of segments that the axis is split into. Note that this number serves only as a recommendation.
+ SplitNumber int
+ // The position of axis, it can be 'top' or 'bottom'
+ Position string
+ // The line color of axis
+ StrokeColor Color
+ // The color of label
+ FontColor Color
+ // The text rotation of label
+ TextRotation float64
+ // The first axis
+ FirstAxis int
+ // The offset of label
+ LabelOffset Box
+ isValueAxis bool
+}
+
+const defaultXAxisHeight = 30
+
+// NewXAxisOption returns a x axis option
+func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption {
+ opt := XAxisOption{
+ Data: data,
+ }
+ if len(boundaryGap) != 0 {
+ opt.BoundaryGap = boundaryGap[0]
+ }
+ return opt
+}
+
+func (opt *XAxisOption) ToAxisOption() AxisOption {
+ position := PositionBottom
+ if opt.Position == PositionTop {
+ position = PositionTop
+ }
+ axisOpt := AxisOption{
+ Theme: opt.Theme,
+ Data: opt.Data,
+ BoundaryGap: opt.BoundaryGap,
+ Position: position,
+ SplitNumber: opt.SplitNumber,
+ StrokeColor: opt.StrokeColor,
+ FontSize: opt.FontSize,
+ Font: opt.Font,
+ FontColor: opt.FontColor,
+ Show: opt.Show,
+ SplitLineColor: opt.Theme.GetAxisSplitLineColor(),
+ TextRotation: opt.TextRotation,
+ LabelOffset: opt.LabelOffset,
+ FirstAxis: opt.FirstAxis,
+ }
+ if opt.isValueAxis {
+ axisOpt.SplitLineShow = true
+ axisOpt.StrokeWidth = -1
+ axisOpt.BoundaryGap = FalseFlag()
+ }
+ return axisOpt
+}
+
+// NewBottomXAxis returns a bottom x axis renderer
+func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter {
+ return NewAxisPainter(p, opt.ToAxisOption())
+}
diff --git a/yaxis.go b/yaxis.go
new file mode 100644
index 0000000..e58b7a6
--- /dev/null
+++ b/yaxis.go
@@ -0,0 +1,128 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import "github.com/golang/freetype/truetype"
+
+type YAxisOption struct {
+ // The minimun value of axis.
+ Min *float64
+ // The maximum value of axis.
+ Max *float64
+ // The font of y axis
+ Font *truetype.Font
+ // The data value of x axis
+ Data []string
+ // The theme of chart
+ Theme ColorPalette
+ // The font size of x axis label
+ FontSize float64
+ // The position of axis, it can be 'left' or 'right'
+ Position string
+ // The color of label
+ FontColor Color
+ // Formatter for y axis text value
+ Formatter string
+ // Color for y axis
+ Color Color
+ // The flag for show axis, set this to *false will hide axis
+ Show *bool
+ DivideCount int
+ Unit int
+ isCategoryAxis bool
+ // The flag for show axis split line, set this to true will show axis split line
+ SplitLineShow *bool
+}
+
+// NewYAxisOptions returns a y axis option
+func NewYAxisOptions(data []string, others ...[]string) []YAxisOption {
+ arr := [][]string{
+ data,
+ }
+ arr = append(arr, others...)
+ opts := make([]YAxisOption, 0)
+ for _, data := range arr {
+ opts = append(opts, YAxisOption{
+ Data: data,
+ })
+ }
+ return opts
+}
+
+func (opt *YAxisOption) ToAxisOption(p *Painter) AxisOption {
+ position := PositionLeft
+ if opt.Position == PositionRight {
+ position = PositionRight
+ }
+ theme := opt.Theme
+ if theme == nil {
+ theme = p.theme
+ }
+ axisOpt := AxisOption{
+ Formatter: opt.Formatter,
+ Theme: theme,
+ Data: opt.Data,
+ Position: position,
+ FontSize: opt.FontSize,
+ StrokeWidth: -1,
+ Font: opt.Font,
+ FontColor: opt.FontColor,
+ BoundaryGap: FalseFlag(),
+ SplitLineShow: true,
+ SplitLineColor: theme.GetAxisSplitLineColor(),
+ Show: opt.Show,
+ Unit: opt.Unit,
+ }
+ if !opt.Color.IsZero() {
+ axisOpt.FontColor = opt.Color
+ axisOpt.StrokeColor = opt.Color
+ }
+ if opt.isCategoryAxis {
+ axisOpt.BoundaryGap = TrueFlag()
+ axisOpt.StrokeWidth = 1
+ axisOpt.SplitLineShow = false
+ }
+ if opt.SplitLineShow != nil {
+ axisOpt.SplitLineShow = *opt.SplitLineShow
+ }
+ return axisOpt
+}
+
+// NewLeftYAxis returns a left y axis renderer
+func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter {
+ p = p.Child(PainterPaddingOption(Box{
+ Bottom: defaultXAxisHeight,
+ }))
+ return NewAxisPainter(p, opt.ToAxisOption(p))
+}
+
+// NewRightYAxis returns a right y axis renderer
+func NewRightYAxis(p *Painter, opt YAxisOption) *axisPainter {
+ p = p.Child(PainterPaddingOption(Box{
+ Bottom: defaultXAxisHeight,
+ }))
+ axisOpt := opt.ToAxisOption(p)
+ axisOpt.Position = PositionRight
+ axisOpt.SplitLineShow = false
+ return NewAxisPainter(p, axisOpt)
+}
diff --git a/yaxis_test.go b/yaxis_test.go
new file mode 100644
index 0000000..0f565ac
--- /dev/null
+++ b/yaxis_test.go
@@ -0,0 +1,70 @@
+// MIT License
+
+// Copyright (c) 2022 Tree Xie
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package charts
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRightYAxis(t *testing.T) {
+ assert := assert.New(t)
+ tests := []struct {
+ render func(*Painter) ([]byte, error)
+ result string
+ }{
+ {
+ render: func(p *Painter) ([]byte, error) {
+ opt := NewYAxisOptions([]string{
+ "a",
+ "b",
+ "c",
+ "d",
+ })[0]
+ _, err := NewRightYAxis(p, opt).Render()
+ if err != nil {
+ return nil, err
+ }
+ return p.Bytes()
+ },
+ result: "",
+ },
+ }
+ 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))
+ }
+}