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 + +[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/vicanso/go-charts/blob/master/LICENSE) +[![Build Status](https://github.com/vicanso/go-charts/workflows/Test/badge.svg)](https://github.com/vicanso/go-charts/actions) + +[中文](./README_zh.md) + +`go-charts` base on [go-chart](https://github.com/wcharczuk/go-chart),it is simpler way for generating charts, which supports `svg` and `png` format and themes: `light`, `dark`, `grafana` and `ant`. The default format is `png` and the default theme is `light`. + +`Apache ECharts` is popular among Front-end developers, so `go-charts` supports the option of `Apache ECharts`. Developers can generate charts almost the same as `Apache ECharts`. + +Screenshot of common charts, the left part is light theme, the right part is grafana theme. + +

+ go-charts +

+ +

+ go-table +

+ +## 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 + +[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/vicanso/go-charts/blob/master/LICENSE) +[![Build Status](https://github.com/vicanso/go-charts/workflows/Test/badge.svg)](https://github.com/vicanso/go-charts/actions) + +`go-charts`基于[go-chart](https://github.com/wcharczuk/go-chart),更简单方便的形式生成数据图表,支持`svg`与`png`两种方式的输出,支持主题`light`, `dark`, `grafana`以及`ant`。默认的输入格式为`png`,默认主题为`light`。 + +`Apache ECharts`在前端开发中得到众多开发者的认可,因此`go-charts`提供了兼容`Apache ECharts`的配置参数,简单快捷的生成相似的图表(`svg`或`png`),方便插入至Email或分享使用。下面为常用的图表截图(主题为light与grafana): + + +

+ go-charts +

+ +

+ go-table +

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: "\\nMonTueWedThuFriSatSun", + }, + // 底部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: "\\nMonTueWedThuFriSatSun", + }, + // 左侧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: "\\nMonTueWedThuFriSatSun", + }, + // 左侧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: "\\nMonTueWedThuFriSatSun", + }, + // 右侧 + { + 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: "\\nMonTueWedThuFriSatSun", + }, + // 顶部 + { + render: func(p *Painter) ([]byte, error) { + _, _ = NewAxisPainter(p, AxisOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + Formatter: "{value} --", + Position: PositionTop, + }).Render() + return p.Bytes() + }, + result: "\\nMon --Tue --Wed --Thu --Fri --Sat --Sun --", + }, + } + + for _, tt := range tests { + 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: "\\n24020016012080400FebMayAugNov24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + }, + { + render: func(p *Painter) ([]byte, error) { + seriesList := NewSeriesListDataFromValues([][]float64{ + { + 2.0, + 4.9, + 7.0, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20.0, + 6.4, + 3.3, + }, + { + 2.6, + 5.9, + 9.0, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6.0, + 2.3, + }, + }) + for index := range seriesList { + seriesList[index].Label.Show = true + seriesList[index].RoundRadius = 5 + } + _, err := NewBarChart(p, BarChartOption{ + Padding: Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, + SeriesList: seriesList, + XAxis: NewXAxisOption([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + YAxisOptions: NewYAxisOptions([]string{ + "Rainfall", + "Evaporation", + }), + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\n24020016012080400FebMayAugNov24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + }, + } + + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/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("\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", 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("\\nRainfallEvaporation24020016012080400FebMayAugNov162.22182.22.341.6248.07", string(data)) +} + +func TestHorizontalBarRender(t *testing.T) { + assert := assert.New(t) + values := [][]float64{ + { + 18203, + 23489, + 29034, + 104970, + 131744, + 630230, + }, + { + 19325, + 23438, + 31000, + 121594, + 134141, + 681807, + }, + } + p, err := HorizontalBarRender( + values, + SVGTypeOption(), + TitleTextOptionFunc("World Population"), + PaddingOptionFunc(Box{ + Top: 20, + Right: 40, + Bottom: 20, + Left: 20, + }), + LegendLabelsOptionFunc([]string{ + "2011", + "2012", + }), + YAxisDataOptionFunc([]string{ + "Brazil", + "Indonesia", + "USA", + "India", + "China", + "World", + }), + ) + assert.Nil(err) + data, err := p.Bytes() + assert.Nil(err) + assert.Equal("\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0122.28k244.56k366.84k489.12k611.4k733.68k", string(data)) +} + +func TestPieRender(t *testing.T) { + assert := assert.New(t) + values := []float64{ + 1048, + 735, + 580, + 484, + 300, + } + p, err := PieRender( + values, + SVGTypeOption(), + TitleOptionFunc(TitleOption{ + Text: "Rainfall vs Evaporation", + Subtext: "Fake Data", + Left: PositionCenter, + }), + PaddingOptionFunc(Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }), + LegendOptionFunc(LegendOption{ + Orient: OrientVertical, + Data: []string{ + "Search Engine", + "Direct", + "Email", + "Union Ads", + "Video Ads", + }, + Left: PositionLeft, + }), + PieSeriesShowLabel(), + ) + assert.Nil(err) + data, err := p.Bytes() + assert.Nil(err) + assert.Equal("\\nSearch EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", string(data)) +} + +func TestRadarRender(t *testing.T) { + assert := assert.New(t) + + values := [][]float64{ + { + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }, + { + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, + }, + } + p, err := RadarRender( + values, + SVGTypeOption(), + TitleTextOptionFunc("Basic Radar Chart"), + LegendLabelsOptionFunc([]string{ + "Allocated Budget", + "Actual Spending", + }), + RadarIndicatorOptionFunc([]string{ + "Sales", + "Administration", + "Information Technology", + "Customer Support", + "Development", + "Marketing", + }, []float64{ + 6500, + 16000, + 30000, + 38000, + 52000, + 25000, + }), + ) + assert.Nil(err) + data, err := p.Bytes() + assert.Nil(err) + assert.Equal("\\nAllocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", 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("\\nShowClickVisitInquiryOrderFunnelShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", 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("\\nRainfallEvaporationRainfall vs EvaporationFake Data24020016012080400FebMayAugNov162.22182.22.341.6248.07", 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: "\\nShowClickVisitInquiryOrderFunnel(100%)(80%)(60%)(40%)(20%)", + }, + } + + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/go.mod b/go.mod index 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: "\\n", + }, + { + render: func(p *Painter) ([]byte, error) { + _, err := NewGridPainter(p, GridPainterOption{ + StrokeColor: drawing.ColorBlack, + ColumnSpans: []int{ + 2, + 5, + 3, + }, + Row: 6, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\n", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go new file mode 100644 index 0000000..ed091c9 --- /dev/null +++ b/horizontal_bar_chart.go @@ -0,0 +1,216 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/golang/freetype/truetype" + "git.smarteching.com/zeni/go-chart/v2" +) + +type horizontalBarChart struct { + p *Painter + opt *HorizontalBarChartOption +} + +type HorizontalBarChartOption struct { + // The theme + Theme ColorPalette + // The font size + Font *truetype.Font + // The data series list + SeriesList SeriesList + // The x axis option + XAxis XAxisOption + // The padding of line chart + Padding Box + // The y axis option + YAxisOptions []YAxisOption + // The option of title + Title TitleOption + // The legend option + Legend LegendOption + BarHeight int + // Margin of bar + BarMargin int +} + +// NewHorizontalBarChart returns a horizontal bar chart renderer +func NewHorizontalBarChart(p *Painter, opt HorizontalBarChartOption) *horizontalBarChart { + if opt.Theme == nil { + opt.Theme = defaultTheme + } + return &horizontalBarChart{ + p: p, + opt: &opt, + } +} + +func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + p := h.p + opt := h.opt + seriesPainter := result.seriesPainter + yRange := result.axisRanges[0] + y0, y1 := yRange.GetRange(0) + height := int(y1 - y0) + // 每一块之间的margin + margin := 10 + // 每一个bar之间的margin + barMargin := 5 + if height < 20 { + margin = 2 + barMargin = 2 + } else if height < 50 { + margin = 5 + barMargin = 3 + } + if opt.BarMargin > 0 { + barMargin = opt.BarMargin + } + seriesCount := len(seriesList) + // 总的高度-两个margin-(总数-1)的barMargin + barHeight := (height - 2*margin - barMargin*(seriesCount-1)) / seriesCount + if opt.BarHeight > 0 && opt.BarHeight < barHeight { + barHeight = opt.BarHeight + margin = (height - seriesCount*barHeight - barMargin*(seriesCount-1)) / 2 + } + + theme := opt.Theme + + max, min := seriesList.GetMaxMin(0) + xRange := NewRange(AxisRangeOption{ + Painter: p, + Min: min, + Max: max, + DivideCount: defaultAxisDivideCount, + Size: seriesPainter.Width(), + }) + seriesNames := seriesList.Names() + + rendererList := []Renderer{} + for index := range seriesList { + series := seriesList[index] + seriesColor := theme.GetSeriesColor(series.index) + divideValues := yRange.AutoDivide() + + var labelPainter *SeriesLabelPainter + if series.Label.Show { + labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{ + P: seriesPainter, + SeriesNames: seriesNames, + Label: series.Label, + Theme: opt.Theme, + Font: opt.Font, + }) + rendererList = append(rendererList, labelPainter) + } + for j, item := range series.Data { + if j >= yRange.divideCount { + continue + } + // 显示位置切换 + j = yRange.divideCount - j - 1 + y := divideValues[j] + y += margin + if index != 0 { + y += index * (barHeight + barMargin) + } + + w := int(xRange.getHeight(item.Value)) + fillColor := seriesColor + if !item.Style.FillColor.IsZero() { + fillColor = item.Style.FillColor + } + right := w + if series.RoundRadius <= 0 { + seriesPainter.OverrideDrawingStyle(Style{ + FillColor: fillColor, + }).Rect(chart.Box{ + Top: y, + Left: 0, + Right: right, + Bottom: y + barHeight, + }) + } else { + seriesPainter.OverrideDrawingStyle(Style{ + FillColor: fillColor, + }).RoundedRect(chart.Box{ + Top: y, + Left: 0, + Right: right, + Bottom: y + barHeight, + }, series.RoundRadius) + } + + // 如果label不需要展示,则返回 + if labelPainter == nil { + continue + } + labelValue := LabelValue{ + Orient: OrientHorizontal, + Index: index, + Value: item.Value, + X: right, + Y: y + barHeight>>1, + Offset: series.Label.Offset, + FontColor: series.Label.Color, + FontSize: series.Label.FontSize, + } + if series.Label.Position == PositionLeft { + labelValue.X = 0 + if labelValue.FontColor.IsZero() { + if isLightColor(fillColor) { + labelValue.FontColor = defaultLightFontColor + } else { + labelValue.FontColor = defaultDarkFontColor + } + } + } + labelPainter.Add(labelValue) + } + } + err := doRender(rendererList...) + if err != nil { + return BoxZero, err + } + return p.box, nil +} + +func (h *horizontalBarChart) Render() (Box, error) { + p := h.p + opt := h.opt + renderResult, err := defaultRender(p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: opt.XAxis, + YAxisOptions: opt.YAxisOptions, + TitleOption: opt.Title, + LegendOption: opt.Legend, + axisReversed: true, + }) + if err != nil { + return BoxZero, err + } + seriesList := opt.SeriesList.Filter(ChartTypeHorizontalBar) + return h.render(renderResult, seriesList) +} diff --git a/horizontal_bar_chart_test.go b/horizontal_bar_chart_test.go new file mode 100644 index 0000000..e078c4a --- /dev/null +++ b/horizontal_bar_chart_test.go @@ -0,0 +1,100 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHorizontalBarChart(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + _, err := NewHorizontalBarChart(p, HorizontalBarChartOption{ + Padding: Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + }, + SeriesList: NewSeriesListDataFromValues([][]float64{ + { + 18203, + 23489, + 29034, + 104970, + 131744, + 630230, + }, + { + 19325, + 23438, + 31000, + 121594, + 134141, + 681807, + }, + }, ChartTypeHorizontalBar), + Title: TitleOption{ + Text: "World Population", + }, + Legend: NewLegendOption([]string{ + "2011", + "2012", + }), + YAxisOptions: NewYAxisOptions([]string{ + "Brazil", + "Indonesia", + "USA", + "India", + "China", + "World", + }), + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0122.28k244.56k366.84k489.12k611.4k733.68k", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/legend.go b/legend.go 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: "\\nOneTwoThree", + }, + { + 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: "\\nOneTwoThree", + }, + { + 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: "\\nOneTwoThree", + }, + } + 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: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", + }, + { + 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: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", + }, + } + + 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: "\\n321", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/mark_point.go b/mark_point.go new file mode 100644 index 0000000..fd8a88b --- /dev/null +++ b/mark_point.go @@ -0,0 +1,115 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/golang/freetype/truetype" +) + +// NewMarkPoint returns a series mark point +func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint { + data := make([]SeriesMarkData, len(markPointTypes)) + for index, t := range markPointTypes { + data[index] = SeriesMarkData{ + Type: t, + } + } + return SeriesMarkPoint{ + Data: data, + } +} + +type markPointPainter struct { + p *Painter + options []markPointRenderOption +} + +func (m *markPointPainter) Add(opt markPointRenderOption) { + m.options = append(m.options, opt) +} + +type markPointRenderOption struct { + FillColor Color + Font *truetype.Font + Series Series + Points []Point +} + +// NewMarkPointPainter returns a mark point renderer +func NewMarkPointPainter(p *Painter) *markPointPainter { + return &markPointPainter{ + p: p, + options: make([]markPointRenderOption, 0), + } +} + +func (m *markPointPainter) Render() (Box, error) { + painter := m.p + for _, opt := range m.options { + s := opt.Series + if len(s.MarkPoint.Data) == 0 { + continue + } + points := opt.Points + summary := s.Summary() + symbolSize := s.MarkPoint.SymbolSize + if symbolSize == 0 { + symbolSize = 30 + } + textStyle := Style{ + FontSize: labelFontSize, + StrokeWidth: 1, + Font: opt.Font, + } + if isLightColor(opt.FillColor) { + textStyle.FontColor = defaultLightFontColor + } else { + textStyle.FontColor = defaultDarkFontColor + } + painter.OverrideDrawingStyle(Style{ + FillColor: opt.FillColor, + }).OverrideTextStyle(textStyle) + for _, markPointData := range s.MarkPoint.Data { + textStyle.FontSize = labelFontSize + painter.OverrideTextStyle(textStyle) + p := points[summary.MinIndex] + value := summary.MinValue + switch markPointData.Type { + case SeriesMarkDataTypeMax: + p = points[summary.MaxIndex] + value = summary.MaxValue + } + + painter.Pin(p.X, p.Y-symbolSize>>1, symbolSize) + text := commafWithDigits(value) + textBox := painter.MeasureText(text) + if textBox.Width() > symbolSize { + textStyle.FontSize = smallLabelFontSize + painter.OverrideTextStyle(textStyle) + textBox = painter.MeasureText(text) + } + painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2) + } + } + return BoxZero, nil +} diff --git a/mark_point_test.go b/mark_point_test.go new file mode 100644 index 0000000..298345b --- /dev/null +++ b/mark_point_test.go @@ -0,0 +1,92 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "git.smarteching.com/zeni/go-chart/v2/drawing" +) + +func TestMarkPoint(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + series := NewSeriesFromValues([]float64{ + 1, + 2, + 3, + }) + series.MarkPoint = NewMarkPoint(SeriesMarkDataTypeMax) + markPoint := NewMarkPointPainter(p) + markPoint.Add(markPointRenderOption{ + FillColor: drawing.ColorBlack, + Series: series, + Points: []Point{ + { + X: 10, + Y: 10, + }, + { + X: 30, + Y: 30, + }, + { + X: 50, + Y: 50, + }, + }, + }) + _, err := markPoint.Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\n3", + }, + } + + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/painter.go b/painter.go new file mode 100644 index 0000000..bee646f --- /dev/null +++ b/painter.go @@ -0,0 +1,866 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "bytes" + "errors" + "math" + + "github.com/golang/freetype/truetype" + "git.smarteching.com/zeni/go-chart/v2" +) + +type ValueFormatter func(float64) string + +type Painter struct { + render chart.Renderer + box Box + font *truetype.Font + parent *Painter + style Style + theme ColorPalette + // 类型 + outputType string + valueFormatter ValueFormatter +} + +type PainterOptions struct { + // Draw type, "svg" or "png", default type is "png" + Type string + // The width of draw painter + Width int + // The height of draw painter + Height int + // The font for painter + Font *truetype.Font +} + +type PainterOption func(*Painter) + +type TicksOption struct { + // the first tick + First int + Length int + Orient string + Count int + Unit int +} + +type MultiTextOption struct { + TextList []string + Orient string + Unit int + Position string + Align string + // The text rotation of label + TextRotation float64 + Offset Box + // The first text index + First int +} + +type GridOption struct { + Column int + Row int + ColumnSpans []int + // 忽略不展示的column + IgnoreColumnLines []int + // 忽略不展示的row + IgnoreRowLines []int +} + +// PainterPaddingOption sets the padding of draw painter +func PainterPaddingOption(padding Box) PainterOption { + return func(p *Painter) { + p.box.Left += padding.Left + p.box.Top += padding.Top + p.box.Right -= padding.Right + p.box.Bottom -= padding.Bottom + } +} + +// PainterBoxOption sets the box of draw painter +func PainterBoxOption(box Box) PainterOption { + return func(p *Painter) { + if box.IsZero() { + return + } + p.box = box + } +} + +// PainterFontOption sets the font of draw painter +func PainterFontOption(font *truetype.Font) PainterOption { + return func(p *Painter) { + if font == nil { + return + } + p.font = font + } +} + +// PainterStyleOption sets the style of draw painter +func PainterStyleOption(style Style) PainterOption { + return func(p *Painter) { + p.SetStyle(style) + } +} + +// PainterThemeOption sets the theme of draw painter +func PainterThemeOption(theme ColorPalette) PainterOption { + return func(p *Painter) { + if theme == nil { + return + } + p.theme = theme + } +} + +// PainterWidthHeightOption set width or height of draw painter +func PainterWidthHeightOption(width, height int) PainterOption { + return func(p *Painter) { + if width > 0 { + p.box.Right = p.box.Left + width + } + if height > 0 { + p.box.Bottom = p.box.Top + height + } + } +} + +// NewPainter creates a painter +func NewPainter(opts PainterOptions, opt ...PainterOption) (*Painter, error) { + if opts.Width <= 0 || opts.Height <= 0 { + return nil, errors.New("width/height can not be nil") + } + font := opts.Font + if font == nil { + f, err := GetDefaultFont() + if err != nil { + return nil, err + } + font = f + } + fn := chart.PNG + if opts.Type == ChartOutputSVG { + fn = chart.SVG + } + width := opts.Width + height := opts.Height + r, err := fn(width, height) + if err != nil { + return nil, err + } + r.SetFont(font) + + p := &Painter{ + render: r, + box: Box{ + Right: opts.Width, + Bottom: opts.Height, + }, + font: font, + // 类型 + outputType: opts.Type, + } + p.setOptions(opt...) + if p.theme == nil { + p.theme = NewTheme(ThemeLight) + } + return p, nil +} +func (p *Painter) setOptions(opts ...PainterOption) { + for _, fn := range opts { + fn(p) + } +} + +func (p *Painter) Child(opt ...PainterOption) *Painter { + child := &Painter{ + // 格式化 + valueFormatter: p.valueFormatter, + // render + render: p.render, + box: p.box.Clone(), + font: p.font, + parent: p, + style: p.style, + theme: p.theme, + } + child.setOptions(opt...) + return child +} + +func (p *Painter) SetStyle(style Style) { + if style.Font == nil { + style.Font = p.font + } + p.style = style + style.WriteToRenderer(p.render) +} + +func overrideStyle(defaultStyle Style, style Style) Style { + if style.StrokeWidth == 0 { + style.StrokeWidth = defaultStyle.StrokeWidth + } + if style.StrokeColor.IsZero() { + style.StrokeColor = defaultStyle.StrokeColor + } + if style.StrokeDashArray == nil { + style.StrokeDashArray = defaultStyle.StrokeDashArray + } + if style.DotColor.IsZero() { + style.DotColor = defaultStyle.DotColor + } + if style.DotWidth == 0 { + style.DotWidth = defaultStyle.DotWidth + } + if style.FillColor.IsZero() { + style.FillColor = defaultStyle.FillColor + } + if style.FontSize == 0 { + style.FontSize = defaultStyle.FontSize + } + if style.FontColor.IsZero() { + style.FontColor = defaultStyle.FontColor + } + if style.Font == nil { + style.Font = defaultStyle.Font + } + return style +} + +func (p *Painter) OverrideDrawingStyle(style Style) *Painter { + s := overrideStyle(p.style, style) + p.SetDrawingStyle(s) + return p +} + +func (p *Painter) SetDrawingStyle(style Style) *Painter { + style.WriteDrawingOptionsToRenderer(p.render) + return p +} + +func (p *Painter) SetTextStyle(style Style) *Painter { + if style.Font == nil { + style.Font = p.font + } + style.WriteTextOptionsToRenderer(p.render) + return p +} +func (p *Painter) OverrideTextStyle(style Style) *Painter { + s := overrideStyle(p.style, style) + p.SetTextStyle(s) + return p +} + +func (p *Painter) ResetStyle() *Painter { + p.style.WriteToRenderer(p.render) + return p +} + +// Bytes returns the data of draw canvas +func (p *Painter) Bytes() ([]byte, error) { + buffer := bytes.Buffer{} + err := p.render.Save(&buffer) + if err != nil { + return nil, err + } + return buffer.Bytes(), err +} + +// MoveTo moves the cursor to a given point +func (p *Painter) MoveTo(x, y int) *Painter { + p.render.MoveTo(x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) *Painter { + p.render.ArcTo(cx+p.box.Left, cy+p.box.Top, rx, ry, startAngle, delta) + return p +} + +func (p *Painter) LineTo(x, y int) *Painter { + p.render.LineTo(x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) QuadCurveTo(cx, cy, x, y int) *Painter { + p.render.QuadCurveTo(cx+p.box.Left, cy+p.box.Top, x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) Pin(x, y, width int) *Painter { + r := float64(width) / 2 + y -= width / 4 + angle := chart.DegreesToRadians(15) + box := p.box + + startAngle := math.Pi/2 + angle + delta := 2*math.Pi - 2*angle + p.ArcTo(x, y, r, r, startAngle, delta) + p.LineTo(x, y) + p.Close() + p.FillStroke() + + startX := x - int(r) + startY := y + endX := x + int(r) + endY := y + p.MoveTo(startX, startY) + + left := box.Left + top := box.Top + cx := x + cy := y + int(r*2.5) + p.render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top) + p.Close() + p.Fill() + return p +} + +func (p *Painter) arrow(x, y, width, height int, direction string) *Painter { + halfWidth := width >> 1 + halfHeight := height >> 1 + if direction == PositionTop || direction == PositionBottom { + x0 := x - halfWidth + x1 := x0 + width + dy := -height / 3 + y0 := y + y1 := y0 - height + if direction == PositionBottom { + y0 = y - height + y1 = y + dy = 2 * dy + } + p.MoveTo(x0, y0) + p.LineTo(x0+halfWidth, y1) + p.LineTo(x1, y0) + p.LineTo(x0+halfWidth, y+dy) + p.LineTo(x0, y0) + } else { + x0 := x + width + x1 := x0 - width + y0 := y - halfHeight + dx := -width / 3 + if direction == PositionRight { + x0 = x - width + dx = -dx + x1 = x0 + width + } + p.MoveTo(x0, y0) + p.LineTo(x1, y0+halfHeight) + p.LineTo(x0, y0+height) + p.LineTo(x0+dx, y0+halfHeight) + p.LineTo(x0, y0) + } + p.FillStroke() + return p +} + +func (p *Painter) ArrowLeft(x, y, width, height int) *Painter { + p.arrow(x, y, width, height, PositionLeft) + return p +} + +func (p *Painter) ArrowRight(x, y, width, height int) *Painter { + p.arrow(x, y, width, height, PositionRight) + return p +} + +func (p *Painter) ArrowTop(x, y, width, height int) *Painter { + p.arrow(x, y, width, height, PositionTop) + return p +} +func (p *Painter) ArrowBottom(x, y, width, height int) *Painter { + p.arrow(x, y, width, height, PositionBottom) + return p +} + +func (p *Painter) Circle(radius float64, x, y int) *Painter { + p.render.Circle(radius, x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) Stroke() *Painter { + p.render.Stroke() + return p +} + +func (p *Painter) Close() *Painter { + p.render.Close() + return p +} + +func (p *Painter) FillStroke() *Painter { + p.render.FillStroke() + return p +} + +func (p *Painter) Fill() *Painter { + p.render.Fill() + return p +} + +func (p *Painter) Width() int { + return p.box.Width() +} + +func (p *Painter) Height() int { + return p.box.Height() +} + +func (p *Painter) MeasureText(text string) Box { + return p.render.MeasureText(text) +} + +func (p *Painter) MeasureTextMaxWidthHeight(textList []string) (int, int) { + maxWidth := 0 + maxHeight := 0 + for _, text := range textList { + box := p.MeasureText(text) + if maxWidth < box.Width() { + maxWidth = box.Width() + } + if maxHeight < box.Height() { + maxHeight = box.Height() + } + } + return maxWidth, maxHeight +} + +func (p *Painter) LineStroke(points []Point) *Painter { + shouldMoveTo := false + for index, point := range points { + x := point.X + y := point.Y + if y == int(math.MaxInt32) { + p.Stroke() + shouldMoveTo = true + continue + } + if shouldMoveTo || index == 0 { + p.MoveTo(x, y) + shouldMoveTo = false + } else { + p.LineTo(x, y) + } + } + p.Stroke() + return p +} + +func (p *Painter) SmoothLineStroke(points []Point) *Painter { + prevX := 0 + prevY := 0 + // TODO 如何生成平滑的折线 + for index, point := range points { + x := point.X + y := point.Y + if index == 0 { + p.MoveTo(x, y) + } else { + cx := prevX + (x-prevX)/5 + cy := y + (y-prevY)/2 + p.QuadCurveTo(cx, cy, x, y) + } + prevX = x + prevY = y + } + p.Stroke() + return p +} + +func (p *Painter) SetBackground(width, height int, color Color, inside ...bool) *Painter { + r := p.render + s := chart.Style{ + FillColor: color, + } + // 背景色 + p.SetDrawingStyle(s) + defer p.ResetStyle() + if len(inside) != 0 && inside[0] { + p.MoveTo(0, 0) + p.LineTo(width, 0) + p.LineTo(width, height) + p.LineTo(0, height) + p.LineTo(0, 0) + } else { + // 设置背景色不使用box,因此不直接使用Painter + r.MoveTo(0, 0) + r.LineTo(width, 0) + r.LineTo(width, height) + r.LineTo(0, height) + r.LineTo(0, 0) + } + p.FillStroke() + return p +} +func (p *Painter) MarkLine(x, y, width int) *Painter { + arrowWidth := 16 + arrowHeight := 10 + endX := x + width + radius := 3 + p.Circle(3, x+radius, y) + p.render.Fill() + p.MoveTo(x+radius*3, y) + p.LineTo(endX-arrowWidth, y) + p.Stroke() + p.ArrowRight(endX, y, arrowWidth, arrowHeight) + return p +} + +func (p *Painter) Polygon(center Point, radius float64, sides int) *Painter { + points := getPolygonPoints(center, radius, sides) + for i, item := range points { + if i == 0 { + p.MoveTo(item.X, item.Y) + } else { + p.LineTo(item.X, item.Y) + } + } + p.LineTo(points[0].X, points[0].Y) + p.Stroke() + return p +} + +func (p *Painter) FillArea(points []Point) *Painter { + var x, y int + for index, point := range points { + x = point.X + y = point.Y + if index == 0 { + p.MoveTo(x, y) + } else { + p.LineTo(x, y) + } + } + p.Fill() + return p +} + +func (p *Painter) Text(body string, x, y int) *Painter { + p.render.Text(body, x+p.box.Left, y+p.box.Top) + return p +} + +func (p *Painter) TextRotation(body string, x, y int, radians float64) { + p.render.SetTextRotation(radians) + p.render.Text(body, x+p.box.Left, y+p.box.Top) + p.render.ClearTextRotation() +} + +func (p *Painter) SetTextRotation(radians float64) { + p.render.SetTextRotation(radians) +} +func (p *Painter) ClearTextRotation() { + p.render.ClearTextRotation() +} + +func (p *Painter) TextFit(body string, x, y, width int, textAligns ...string) chart.Box { + style := p.style + textWarp := style.TextWrap + style.TextWrap = chart.TextWrapWord + r := p.render + lines := chart.Text.WrapFit(r, body, width, style) + p.SetTextStyle(style) + var output chart.Box + + textAlign := "" + if len(textAligns) != 0 { + textAlign = textAligns[0] + } + for index, line := range lines { + if line == "" { + continue + } + x0 := x + y0 := y + output.Height() + lineBox := r.MeasureText(line) + switch textAlign { + case AlignRight: + x0 += width - lineBox.Width() + case AlignCenter: + x0 += (width - lineBox.Width()) >> 1 + } + p.Text(line, x0, y0) + output.Right = chart.MaxInt(lineBox.Right, output.Right) + output.Bottom += lineBox.Height() + if index < len(lines)-1 { + output.Bottom += +style.GetTextLineSpacing() + } + } + p.style.TextWrap = textWarp + return output +} + +func (p *Painter) Ticks(opt TicksOption) *Painter { + if opt.Count <= 0 || opt.Length <= 0 { + return p + } + count := opt.Count + first := opt.First + width := p.Width() + height := p.Height() + unit := 1 + if opt.Unit > 1 { + unit = opt.Unit + } + var values []int + isVertical := opt.Orient == OrientVertical + if isVertical { + values = autoDivide(height, count) + } else { + values = autoDivide(width, count) + } + for index, value := range values { + if index < first { + continue + } + if (index-first)%unit != 0 { + continue + } + if isVertical { + p.LineStroke([]Point{ + { + X: 0, + Y: value, + }, + { + X: opt.Length, + Y: value, + }, + }) + } else { + p.LineStroke([]Point{ + { + X: value, + Y: opt.Length, + }, + { + X: value, + Y: 0, + }, + }) + } + } + return p +} + +func (p *Painter) MultiText(opt MultiTextOption) *Painter { + if len(opt.TextList) == 0 { + return p + } + count := len(opt.TextList) + positionCenter := true + showIndex := opt.Unit / 2 + if containsString([]string{ + PositionLeft, + PositionTop, + }, opt.Position) { + positionCenter = false + count-- + // 非居中 + showIndex = 0 + } + width := p.Width() + height := p.Height() + var values []int + isVertical := opt.Orient == OrientVertical + if isVertical { + values = autoDivide(height, count) + } else { + values = autoDivide(width, count) + } + isTextRotation := opt.TextRotation != 0 + offset := opt.Offset + for index, text := range opt.TextList { + if index < opt.First { + continue + } + if opt.Unit != 0 && (index-opt.First)%opt.Unit != showIndex { + continue + } + if isTextRotation { + p.ClearTextRotation() + p.SetTextRotation(opt.TextRotation) + } + box := p.MeasureText(text) + start := values[index] + if positionCenter { + start = (values[index] + values[index+1]) >> 1 + } + x := 0 + y := 0 + if isVertical { + y = start + box.Height()>>1 + switch opt.Align { + case AlignRight: + x = width - box.Width() + case AlignCenter: + x = width - box.Width()>>1 + default: + x = 0 + } + } else { + x = start - box.Width()>>1 + } + x += offset.Left + y += offset.Top + p.Text(text, x, y) + } + if isTextRotation { + p.ClearTextRotation() + } + return p +} + +func (p *Painter) Grid(opt GridOption) *Painter { + width := p.Width() + height := p.Height() + drawLines := func(values []int, ignoreIndexList []int, isVertical bool) { + for index, v := range values { + if containsInt(ignoreIndexList, index) { + continue + } + x0 := 0 + y0 := 0 + x1 := 0 + y1 := 0 + if isVertical { + + x0 = v + x1 = v + y1 = height + } else { + x1 = width + y0 = v + y1 = v + } + p.LineStroke([]Point{ + { + X: x0, + Y: y0, + }, + { + X: x1, + Y: y1, + }, + }) + } + } + columnCount := sumInt(opt.ColumnSpans) + if columnCount == 0 { + columnCount = opt.Column + } + if columnCount > 0 { + values := autoDivideSpans(width, columnCount, opt.ColumnSpans) + drawLines(values, opt.IgnoreColumnLines, true) + } + if opt.Row > 0 { + values := autoDivide(height, opt.Row) + drawLines(values, opt.IgnoreRowLines, false) + } + return p +} + +func (p *Painter) Dots(points []Point) *Painter { + for _, item := range points { + p.Circle(2, item.X, item.Y) + } + p.FillStroke() + return p +} + +func (p *Painter) Rect(box Box) *Painter { + p.MoveTo(box.Left, box.Top) + p.LineTo(box.Right, box.Top) + p.LineTo(box.Right, box.Bottom) + p.LineTo(box.Left, box.Bottom) + p.LineTo(box.Left, box.Top) + p.FillStroke() + return p +} + +func (p *Painter) RoundedRect(box Box, radius int) *Painter { + r := (box.Right - box.Left) / 2 + if radius > r { + radius = r + } + rx := float64(radius) + ry := float64(radius) + p.MoveTo(box.Left+radius, box.Top) + p.LineTo(box.Right-radius, box.Top) + + cx := box.Right - radius + cy := box.Top + radius + // right top + p.ArcTo(cx, cy, rx, ry, -math.Pi/2, math.Pi/2) + + p.LineTo(box.Right, box.Bottom-radius) + + // right bottom + cx = box.Right - radius + cy = box.Bottom - radius + p.ArcTo(cx, cy, rx, ry, 0.0, math.Pi/2) + + p.LineTo(box.Left+radius, box.Bottom) + + // left bottom + cx = box.Left + radius + cy = box.Bottom - radius + p.ArcTo(cx, cy, rx, ry, math.Pi/2, math.Pi/2) + + p.LineTo(box.Left, box.Top+radius) + + // left top + cx = box.Left + radius + cy = box.Top + radius + p.ArcTo(cx, cy, rx, ry, math.Pi, math.Pi/2) + + p.Close() + p.FillStroke() + p.Fill() + return p +} + +func (p *Painter) LegendLineDot(box Box) *Painter { + width := box.Width() + height := box.Height() + strokeWidth := 3 + dotHeight := 5 + + p.render.SetStrokeWidth(float64(strokeWidth)) + center := (height-strokeWidth)>>1 - 1 + p.MoveTo(box.Left, box.Top-center) + p.LineTo(box.Right, box.Top-center) + p.Stroke() + p.Circle(float64(dotHeight), box.Left+width>>1, box.Top-center) + p.FillStroke() + return p +} + +func (p *Painter) GetRenderer() chart.Renderer { + return p.render +} diff --git a/painter_test.go b/painter_test.go new file mode 100644 index 0000000..07c4113 --- /dev/null +++ b/painter_test.go @@ -0,0 +1,399 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "math" + "testing" + + "github.com/golang/freetype/truetype" + "github.com/stretchr/testify/assert" + "git.smarteching.com/zeni/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2/drawing" +) + +func TestPainterOption(t *testing.T) { + assert := assert.New(t) + + font := &truetype.Font{} + d, err := NewPainter(PainterOptions{ + Width: 800, + Height: 600, + Type: ChartOutputSVG, + }, + PainterBoxOption(Box{ + Right: 400, + Bottom: 300, + }), + PainterPaddingOption(Box{ + Left: 1, + Top: 2, + Right: 3, + Bottom: 4, + }), + PainterFontOption(font), + PainterStyleOption(Style{ + ClassName: "test", + }), + ) + assert.Nil(err) + assert.Equal(Box{ + Left: 1, + Top: 2, + Right: 397, + Bottom: 296, + }, d.box) + assert.Equal(font, d.font) + assert.Equal("test", d.style.ClassName) +} + +func TestPainter(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + fn func(*Painter) + result string + }{ + // moveTo, lineTo + { + fn: func(p *Painter) { + p.MoveTo(1, 1) + p.LineTo(2, 2) + p.Stroke() + }, + result: "\\n", + }, + // circle + { + fn: func(p *Painter) { + p.Circle(5, 2, 3) + }, + result: "\\n", + }, + // text + { + fn: func(p *Painter) { + p.Text("hello world!", 3, 6) + }, + result: "\\nhello world!", + }, + // line stroke + { + fn: func(p *Painter) { + p.SetDrawingStyle(Style{ + StrokeColor: drawing.ColorBlack, + StrokeWidth: 1, + }) + p.LineStroke([]Point{ + { + X: 1, + Y: 2, + }, + { + X: 3, + Y: 4, + }, + }) + }, + result: "\\n", + }, + // set background + { + fn: func(p *Painter) { + p.SetBackground(400, 300, chart.ColorWhite) + }, + result: "\\n", + }, + // arcTo + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlue, + }) + p.ArcTo(100, 100, 100, 100, 0, math.Pi/2) + p.Close() + p.FillStroke() + }, + result: "\\n", + }, + // pin + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.Pin(30, 30, 30) + }, + result: "\\n", + }, + // arrow left + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.ArrowLeft(30, 30, 16, 10) + }, + result: "\\n", + }, + // arrow right + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.ArrowRight(30, 30, 16, 10) + }, + result: "\\n", + }, + // arrow top + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.ArrowTop(30, 30, 10, 16) + }, + result: "\\n", + }, + // arrow bottom + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.ArrowBottom(30, 30, 10, 16) + }, + result: "\\n", + }, + // mark line + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + StrokeDashArray: []float64{ + 4, + 2, + }, + }) + p.MarkLine(0, 20, 300) + }, + result: "\\n", + }, + // polygon + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.Polygon(Point{ + X: 100, + Y: 100, + }, 50, 6) + }, + result: "\\n", + }, + // FillArea + { + fn: func(p *Painter) { + p.SetDrawingStyle(Style{ + FillColor: Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.FillArea([]Point{ + { + X: 0, + Y: 0, + }, + { + X: 0, + Y: 100, + }, + { + X: 100, + Y: 100, + }, + { + X: 0, + Y: 0, + }, + }) + }, + result: "\\n", + }, + } + for _, tt := range tests { + d, err := NewPainter(PainterOptions{ + Width: 400, + Height: 300, + Type: ChartOutputSVG, + }, PainterPaddingOption(chart.Box{ + Left: 5, + Top: 10, + })) + assert.Nil(err) + tt.fn(d) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestRoundedRect(t *testing.T) { + assert := assert.New(t) + p, err := NewPainter(PainterOptions{ + Width: 400, + Height: 300, + Type: ChartOutputSVG, + }) + assert.Nil(err) + p.OverrideDrawingStyle(Style{ + FillColor: drawing.ColorWhite, + StrokeWidth: 1, + StrokeColor: drawing.ColorWhite, + }).RoundedRect(Box{ + Left: 10, + Right: 30, + Bottom: 150, + Top: 10, + }, 5) + buf, err := p.Bytes() + assert.Nil(err) + assert.Equal("\\n", string(buf)) +} + +func TestPainterTextFit(t *testing.T) { + assert := assert.New(t) + p, err := NewPainter(PainterOptions{ + Width: 400, + Height: 300, + Type: ChartOutputSVG, + }) + assert.Nil(err) + f, _ := GetDefaultFont() + style := Style{ + FontSize: 12, + FontColor: chart.ColorBlack, + Font: f, + } + p.SetStyle(style) + box := p.TextFit("Hello World!", 0, 20, 80) + assert.Equal(chart.Box{ + Right: 45, + Bottom: 35, + }, box) + + box = p.TextFit("Hello World!", 0, 100, 200) + assert.Equal(chart.Box{ + Right: 84, + Bottom: 15, + }, box) + + buf, err := p.Bytes() + assert.Nil(err) + assert.Equal(`\nHelloWorld!Hello World!`, string(buf)) +} diff --git a/pie_chart.go b/pie_chart.go new file mode 100644 index 0000000..5c04ed8 --- /dev/null +++ b/pie_chart.go @@ -0,0 +1,318 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "errors" + "math" + + "github.com/golang/freetype/truetype" + "git.smarteching.com/zeni/go-chart/v2" +) + +type pieChart struct { + p *Painter + opt *PieChartOption +} + +type PieChartOption struct { + // The theme + Theme ColorPalette + // The font size + Font *truetype.Font + // The data series list + SeriesList SeriesList + // The padding of line chart + Padding Box + // The option of title + Title TitleOption + // The legend option + Legend LegendOption + // background is filled + backgroundIsFilled bool +} + +// NewPieChart returns a pie chart renderer +func NewPieChart(p *Painter, opt PieChartOption) *pieChart { + if opt.Theme == nil { + opt.Theme = defaultTheme + } + return &pieChart{ + p: p, + opt: &opt, + } +} + +type sector struct { + value float64 + percent float64 + cx int + cy int + rx float64 + ry float64 + start float64 + delta float64 + offset int + quadrant int + lineStartX int + lineStartY int + lineBranchX int + lineBranchY int + lineEndX int + lineEndY int + showLabel bool + label string + series Series + color Color +} + +func NewSector(cx int, cy int, radius float64, labelRadius float64, value float64, currentValue float64, totalValue float64, labelLineLength int, label string, series Series, color Color) sector { + s := sector{} + s.value = value + s.percent = value / totalValue + s.cx = cx + s.cy = cy + s.rx = radius + s.ry = radius + p := (currentValue + value/2) / totalValue + if p < 0.25 { + s.quadrant = 1 + } else if p < 0.5 { + s.quadrant = 4 + } else if p < 0.75 { + s.quadrant = 3 + } else { + s.quadrant = 2 + } + s.start = chart.PercentToRadians(currentValue/totalValue) - math.Pi/2 + s.delta = chart.PercentToRadians(value / totalValue) + angle := s.start + s.delta/2 + s.lineStartX = cx + int(radius*math.Cos(angle)) + s.lineStartY = cy + int(radius*math.Sin(angle)) + s.lineBranchX = cx + int(labelRadius*math.Cos(angle)) + s.lineBranchY = cy + int(labelRadius*math.Sin(angle)) + s.offset = labelLineLength + if s.lineBranchX <= cx { + s.offset *= -1 + } + s.lineEndX = s.lineBranchX + s.offset + s.lineEndY = s.lineBranchY + s.series = series + s.color = color + s.showLabel = series.Label.Show + s.label = NewPieLabelFormatter([]string{label}, series.Label.Formatter)(0, s.value, s.percent) + return s +} + +func (s *sector) calculateY(prevY int) int { + for i := 0; i <= s.cy; i++ { + if s.quadrant <= 2 { + if (prevY - s.lineBranchY) > labelFontSize+5 { + break + } + s.lineBranchY -= 1 + } else { + if (s.lineBranchY - prevY) > labelFontSize+5 { + break + } + s.lineBranchY += 1 + } + } + s.lineEndY = s.lineBranchY + return s.lineBranchY +} + +func (s *sector) calculateTextXY(textBox Box) (x int, y int) { + textMargin := 3 + x = s.lineEndX + textMargin + y = s.lineEndY + textBox.Height()>>1 - 1 + if s.offset < 0 { + textWidth := textBox.Width() + x = s.lineEndX - textWidth - textMargin + } + return +} + +func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + opt := p.opt + values := make([]float64, len(seriesList)) + total := float64(0) + radiusValue := "" + for index, series := range seriesList { + if len(series.Radius) != 0 { + radiusValue = series.Radius + } + value := float64(0) + for _, item := range series.Data { + value += item.Value + } + values[index] = value + total += value + } + if total <= 0 { + return BoxZero, errors.New("The sum value of pie chart should gt 0") + } + seriesPainter := result.seriesPainter + cx := seriesPainter.Width() >> 1 + cy := seriesPainter.Height() >> 1 + + diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height()) + radius := getRadius(float64(diameter), radiusValue) + + labelLineWidth := 15 + if radius < 50 { + labelLineWidth = 10 + } + labelRadius := radius + float64(labelLineWidth) + seriesNames := opt.Legend.Data + if len(seriesNames) == 0 { + seriesNames = seriesList.Names() + } + theme := opt.Theme + + currentValue := float64(0) + + var quadrant1, quadrant2, quadrant3, quadrant4 []sector + for index, v := range values { + series := seriesList[index] + color := theme.GetSeriesColor(index) + if index == len(values)-1 { + if color == theme.GetSeriesColor(0) { + color = theme.GetSeriesColor(1) + } + } + s := NewSector(cx, cy, radius, labelRadius, v, currentValue, total, labelLineWidth, seriesNames[index], series, color) + switch quadrant := s.quadrant; quadrant { + case 1: + quadrant1 = append([]sector{s}, quadrant1...) + case 2: + quadrant2 = append(quadrant2, s) + case 3: + quadrant3 = append([]sector{s}, quadrant3...) + case 4: + quadrant4 = append(quadrant4, s) + } + currentValue += v + } + sectors := append(quadrant1, quadrant4...) + sectors = append(sectors, quadrant3...) + sectors = append(sectors, quadrant2...) + + currentQuadrant := 0 + prevY := 0 + maxY := 0 + minY := 0 + for _, s := range sectors { + seriesPainter.OverrideDrawingStyle(Style{ + StrokeWidth: 1, + StrokeColor: s.color, + FillColor: s.color, + }) + seriesPainter.MoveTo(s.cx, s.cy) + seriesPainter.ArcTo(s.cx, s.cy, s.rx, s.ry, s.start, s.delta).LineTo(s.cx, s.cy).Close().FillStroke() + if !s.showLabel { + continue + } + if currentQuadrant != s.quadrant { + if s.quadrant == 1 { + minY = cy * 2 + maxY = 0 + prevY = cy * 2 + } + if s.quadrant == 2 { + if currentQuadrant != 3 { + prevY = s.lineEndY + } else { + prevY = minY + } + } + if s.quadrant == 3 { + if currentQuadrant != 4 { + prevY = s.lineEndY + } else { + minY = cy * 2 + maxY = 0 + prevY = 0 + } + } + if s.quadrant == 4 { + if currentQuadrant != 1 { + prevY = s.lineEndY + } else { + prevY = maxY + } + } + currentQuadrant = s.quadrant + } + prevY = s.calculateY(prevY) + if prevY > maxY { + maxY = prevY + } + if prevY < minY { + minY = prevY + } + seriesPainter.MoveTo(s.lineStartX, s.lineStartY) + seriesPainter.LineTo(s.lineBranchX, s.lineBranchY) + seriesPainter.MoveTo(s.lineBranchX, s.lineBranchY) + seriesPainter.LineTo(s.lineEndX, s.lineEndY) + seriesPainter.Stroke() + textStyle := Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + } + if !s.series.Label.Color.IsZero() { + textStyle.FontColor = s.series.Label.Color + } + seriesPainter.OverrideTextStyle(textStyle) + x, y := s.calculateTextXY(seriesPainter.MeasureText(s.label)) + seriesPainter.Text(s.label, x, y) + } + return p.p.box, nil +} + +func (p *pieChart) Render() (Box, error) { + opt := p.opt + + renderResult, err := defaultRender(p.p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: XAxisOption{ + Show: FalseFlag(), + }, + YAxisOptions: []YAxisOption{ + { + Show: FalseFlag(), + }, + }, + TitleOption: opt.Title, + LegendOption: opt.Legend, + backgroundIsFilled: opt.backgroundIsFilled, + }) + if err != nil { + return BoxZero, err + } + seriesList := opt.SeriesList.Filter(ChartTypePie) + return p.render(renderResult, seriesList) +} diff --git a/pie_chart_test.go b/pie_chart_test.go new file mode 100644 index 0000000..3795d32 --- /dev/null +++ b/pie_chart_test.go @@ -0,0 +1,533 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPieChart(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := []float64{ + 1048, + 735, + 580, + 484, + 300, + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + }, + }), + Title: TitleOption{ + Text: "Rainfall vs Evaporation", + Subtext: "Fake Data", + Left: PositionCenter, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Orient: OrientVertical, + Data: []string{ + "Search Engine", + "Direct", + "Email", + "Union Ads", + "Video Ads", + }, + Left: PositionLeft, + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nSearch EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestPieChartWithLabelsValuesSortedDescending(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := []float64{ + 84358845, + 68070697, + 58850717, + 48059777, + 36753736, + 19051562, + 17947406, + 11754004, + 10827529, + 10521556, + 10467366, + 10394055, + 9597085, + 9104772, + 6447710, + 5932654, + 5563970, + 5428792, + 5194336, + 3850894, + 2857279, + 2116792, + 1883008, + 1373101, + 920701, + 660809, + 542051, + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + Formatter: "{b} ({c} ≅ {d})", + }, + Radius: "200", + }), + Title: TitleOption{ + Text: "European Union member states by population", + Left: PositionRight, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Data: []string{ + "Germany", + "France", + "Italy", + "Spain", + "Poland", + "Romania", + "Netherlands", + "Belgium", + "Czech Republic", + "Sweden", + "Portugal", + "Greece", + "Hungary", + "Austria", + "Bulgaria", + "Denmark", + "Finland", + "Slovakia", + "Ireland", + "Croatia", + "Lithuania", + "Slovenia", + "Latvia", + "Estonia", + "Cyprus", + "Luxembourg", + "Malta", + }, + Show: FalseFlag(), + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nEuropean Union member states by populationGermany (84358845 ≅ 18.8%)France (68070697 ≅ 15.17%)Italy (58850717 ≅ 13.12%)Netherlands (17947406 ≅ 4%)Romania (19051562 ≅ 4.24%)Poland (36753736 ≅ 8.19%)Spain (48059777 ≅ 10.71%)Belgium (11754004 ≅ 2.62%)Czech Republic (10827529 ≅ 2.41%)Sweden (10521556 ≅ 2.34%)Portugal (10467366 ≅ 2.33%)Greece (10394055 ≅ 2.31%)Hungary (9597085 ≅ 2.13%)Austria (9104772 ≅ 2.02%)Bulgaria (6447710 ≅ 1.43%)Denmark (5932654 ≅ 1.32%)Finland (5563970 ≅ 1.24%)Slovakia (5428792 ≅ 1.21%)Ireland (5194336 ≅ 1.15%)Croatia (3850894 ≅ 0.85%)Lithuania (2857279 ≅ 0.63%)Slovenia (2116792 ≅ 0.47%)Latvia (1883008 ≅ 0.41%)Estonia (1373101 ≅ 0.3%)Cyprus (920701 ≅ 0.2%)Luxembourg (660809 ≅ 0.14%)Malta (542051 ≅ 0.12%)", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 1000, + Height: 800, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestPieChartWithLabelsValuesUnsorted(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := []float64{ + 9104772, + 11754004, + 6447710, + 3850894, + 920701, + 10827529, + 5932654, + 1373101, + 5563970, + 68070697, + 84358845, + 10394055, + 9597085, + 5194336, + 58850717, + 1883008, + 2857279, + 660809, + 542051, + 17947406, + 36753736, + 10467366, + 19051562, + 5428792, + 2116792, + 48059777, + 10521556, + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + Formatter: "{b} ({c} ≅ {d})", + }, + Radius: "200", + }), + Title: TitleOption{ + Text: "European Union member states by population", + Left: PositionRight, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Data: []string{ + "Austria", + "Belgium", + "Bulgaria", + "Croatia", + "Cyprus", + "Czech Republic", + "Denmark", + "Estonia", + "Finland", + "France", + "Germany", + "Greece", + "Hungary", + "Ireland", + "Italy", + "Latvia", + "Lithuania", + "Luxembourg", + "Malta", + "Netherlands", + "Poland", + "Portugal", + "Romania", + "Slovakia", + "Slovenia", + "Spain", + "Sweden", + }, + Show: FalseFlag(), + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nEuropean Union member states by populationFrance (68070697 ≅ 15.17%)Finland (5563970 ≅ 1.24%)Estonia (1373101 ≅ 0.3%)Denmark (5932654 ≅ 1.32%)Czech Republic (10827529 ≅ 2.41%)Cyprus (920701 ≅ 0.2%)Croatia (3850894 ≅ 0.85%)Bulgaria (6447710 ≅ 1.43%)Belgium (11754004 ≅ 2.62%)Austria (9104772 ≅ 2.02%)Germany (84358845 ≅ 18.8%)Greece (10394055 ≅ 2.31%)Hungary (9597085 ≅ 2.13%)Poland (36753736 ≅ 8.19%)Netherlands (17947406 ≅ 4%)Malta (542051 ≅ 0.12%)Luxembourg (660809 ≅ 0.14%)Lithuania (2857279 ≅ 0.63%)Latvia (1883008 ≅ 0.41%)Italy (58850717 ≅ 13.12%)Ireland (5194336 ≅ 1.15%)Portugal (10467366 ≅ 2.33%)Romania (19051562 ≅ 4.24%)Slovakia (5428792 ≅ 1.21%)Slovenia (2116792 ≅ 0.47%)Spain (48059777 ≅ 10.71%)Sweden (10521556 ≅ 2.34%)", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 1000, + Height: 800, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestPieChartWith100Labels(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + var values []float64 + var labels []string + for i := 1; i <= 100; i++ { + values = append(values, float64(1)) + labels = append(labels, "Label "+strconv.Itoa(i)) + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + }, + Radius: "200", + }), + Title: TitleOption{ + Text: "Test with 100 labels", + Left: PositionRight, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Data: labels, + Show: FalseFlag(), + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nTest with 100 labelsLabel 25: 1%Label 24: 1%Label 23: 1%Label 22: 1%Label 21: 1%Label 20: 1%Label 19: 1%Label 18: 1%Label 17: 1%Label 16: 1%Label 15: 1%Label 14: 1%Label 13: 1%Label 12: 1%Label 11: 1%Label 10: 1%Label 9: 1%Label 8: 1%Label 7: 1%Label 6: 1%Label 5: 1%Label 4: 1%Label 3: 1%Label 2: 1%Label 1: 1%Label 26: 1%Label 27: 1%Label 28: 1%Label 29: 1%Label 30: 1%Label 31: 1%Label 32: 1%Label 33: 1%Label 34: 1%Label 35: 1%Label 36: 1%Label 37: 1%Label 38: 1%Label 39: 1%Label 40: 1%Label 41: 1%Label 42: 1%Label 43: 1%Label 44: 1%Label 45: 1%Label 46: 1%Label 47: 1%Label 48: 1%Label 49: 1%Label 50: 1%Label 75: 1%Label 74: 1%Label 73: 1%Label 72: 1%Label 71: 1%Label 70: 1%Label 69: 1%Label 68: 1%Label 67: 1%Label 66: 1%Label 65: 1%Label 64: 1%Label 63: 1%Label 62: 1%Label 61: 1%Label 60: 1%Label 59: 1%Label 58: 1%Label 57: 1%Label 56: 1%Label 55: 1%Label 54: 1%Label 53: 1%Label 52: 1%Label 51: 1%Label 76: 1%Label 77: 1%Label 78: 1%Label 79: 1%Label 80: 1%Label 81: 1%Label 82: 1%Label 83: 1%Label 84: 1%Label 85: 1%Label 86: 1%Label 87: 1%Label 88: 1%Label 89: 1%Label 90: 1%Label 91: 1%Label 92: 1%Label 93: 1%Label 94: 1%Label 95: 1%Label 96: 1%Label 97: 1%Label 98: 1%Label 99: 1%Label 100: 1%", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 1000, + Height: 900, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestPieChartFixLabelPos72586(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := []float64{ + 397594, + 185596, + 149086, + 144258, + 120194, + 117514, + 99412, + 91135, + 87282, + 76790, + 72586, + 58818, + 58270, + 56306, + 55486, + 54792, + 53746, + 51460, + 41242, + 39476, + 37414, + 36644, + 33784, + 32788, + 32566, + 29608, + 29558, + 29384, + 28166, + 26998, + 26948, + 26054, + 25804, + 25730, + 24438, + 23782, + 22896, + 21404, + 428978, + } + _, err := NewPieChart(p, PieChartOption{ + SeriesList: NewPieSeriesList(values, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + Formatter: "{b} ({c} ≅ {d})", + }, + Radius: "150", + }), + Title: TitleOption{ + Text: "Fix label K (72586)", + Left: PositionRight, + }, + Padding: Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + Legend: LegendOption{ + Data: []string{ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "AA", + "AB", + "AC", + "AD", + "AE", + "AF", + "AG", + "AH", + "AI", + "AJ", + "AK", + "AL", + "AM", + }, + Show: FalseFlag(), + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nFix label K (72586)C (149086 ≅ 5.04%)B (185596 ≅ 6.28%)A (397594 ≅ 13.45%)D (144258 ≅ 4.88%)E (120194 ≅ 4.06%)F (117514 ≅ 3.97%)G (99412 ≅ 3.36%)H (91135 ≅ 3.08%)I (87282 ≅ 2.95%)J (76790 ≅ 2.59%)Z (29608 ≅ 1%)Y (32566 ≅ 1.1%)X (32788 ≅ 1.1%)W (33784 ≅ 1.14%)V (36644 ≅ 1.24%)U (37414 ≅ 1.26%)T (39476 ≅ 1.33%)S (41242 ≅ 1.39%)R (51460 ≅ 1.74%)Q (53746 ≅ 1.81%)P (54792 ≅ 1.85%)O (55486 ≅ 1.87%)N (56306 ≅ 1.9%)M (58270 ≅ 1.97%)L (58818 ≅ 1.99%)K (72586 ≅ 2.45%)AA (29558 ≅ 1%)AB (29384 ≅ 0.99%)AC (28166 ≅ 0.95%)AD (26998 ≅ 0.91%)AE (26948 ≅ 0.91%)AF (26054 ≅ 0.88%)AG (25804 ≅ 0.87%)AH (25730 ≅ 0.87%)AI (24438 ≅ 0.82%)AJ (23782 ≅ 0.8%)AK (22896 ≅ 0.77%)AL (21404 ≅ 0.72%)AM (428978 ≅ 14.52%)", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 1150, + Height: 550, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p.Child(PainterPaddingOption(Box{ + Left: 20, + Top: 20, + Right: 20, + Bottom: 20, + }))) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/radar_chart.go b/radar_chart.go new file mode 100644 index 0000000..cf18135 --- /dev/null +++ b/radar_chart.go @@ -0,0 +1,273 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "errors" + + "github.com/dustin/go-humanize" + "github.com/golang/freetype/truetype" + "git.smarteching.com/zeni/go-chart/v2" + "git.smarteching.com/zeni/go-chart/v2/drawing" +) + +type radarChart struct { + p *Painter + opt *RadarChartOption +} + +type RadarIndicator struct { + // Indicator's name + Name string + // The maximum value of indicator + Max float64 + // The minimum value of indicator + Min float64 +} + +type RadarChartOption struct { + // The theme + Theme ColorPalette + // The font size + Font *truetype.Font + // The data series list + SeriesList SeriesList + // The padding of line chart + Padding Box + // The option of title + Title TitleOption + // The legend option + Legend LegendOption + // The radar indicator list + RadarIndicators []RadarIndicator + // background is filled + backgroundIsFilled bool +} + +// NewRadarIndicators returns a radar indicator list +func NewRadarIndicators(names []string, values []float64) []RadarIndicator { + if len(names) != len(values) { + return nil + } + indicators := make([]RadarIndicator, len(names)) + for index, name := range names { + indicators[index] = RadarIndicator{ + Name: name, + Max: values[index], + } + } + return indicators +} + +// NewRadarChart returns a radar chart renderer +func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart { + if opt.Theme == nil { + opt.Theme = defaultTheme + } + return &radarChart{ + p: p, + opt: &opt, + } +} + +func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + opt := r.opt + indicators := opt.RadarIndicators + sides := len(indicators) + if sides < 3 { + return BoxZero, errors.New("The count of indicator should be >= 3") + } + maxValues := make([]float64, len(indicators)) + for _, series := range seriesList { + for index, item := range series.Data { + if index < len(maxValues) && item.Value > maxValues[index] { + maxValues[index] = item.Value + } + } + } + for index, indicator := range indicators { + if indicator.Max <= 0 { + indicators[index].Max = maxValues[index] + } + } + + radiusValue := "" + for _, series := range seriesList { + if len(series.Radius) != 0 { + radiusValue = series.Radius + } + } + + seriesPainter := result.seriesPainter + theme := opt.Theme + + cx := seriesPainter.Width() >> 1 + cy := seriesPainter.Height() >> 1 + diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height()) + radius := getRadius(float64(diameter), radiusValue) + + divideCount := 5 + divideRadius := float64(int(radius / float64(divideCount))) + radius = divideRadius * float64(divideCount) + + seriesPainter.OverrideDrawingStyle(Style{ + StrokeColor: theme.GetAxisSplitLineColor(), + StrokeWidth: 1, + }) + center := Point{ + X: cx, + Y: cy, + } + for i := 0; i < divideCount; i++ { + seriesPainter.Polygon(center, divideRadius*float64(i+1), sides) + } + points := getPolygonPoints(center, radius, sides) + for _, p := range points { + seriesPainter.MoveTo(center.X, center.Y) + seriesPainter.LineTo(p.X, p.Y) + seriesPainter.Stroke() + } + seriesPainter.OverrideTextStyle(Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + }) + offset := 5 + // 文本生成 + for index, p := range points { + name := indicators[index].Name + b := seriesPainter.MeasureText(name) + isXCenter := p.X == center.X + isYCenter := p.Y == center.Y + isRight := p.X > center.X + isLeft := p.X < center.X + isTop := p.Y < center.Y + isBottom := p.Y > center.Y + x := p.X + y := p.Y + if isXCenter { + x -= b.Width() >> 1 + if isTop { + y -= b.Height() + } else { + y += b.Height() + } + } + if isYCenter { + y += b.Height() >> 1 + } + if isTop { + y += offset + } + if isBottom { + y += offset + } + if isRight { + x += offset + } + if isLeft { + x -= (b.Width() + offset) + } + seriesPainter.Text(name, x, y) + } + + // 雷达图 + angles := getPolygonPointAngles(sides) + maxCount := len(indicators) + for _, series := range seriesList { + linePoints := make([]Point, 0, maxCount) + for j, item := range series.Data { + if j >= maxCount { + continue + } + indicator := indicators[j] + var percent float64 + offset := indicator.Max - indicator.Min + if offset > 0 { + percent = (item.Value - indicator.Min) / offset + } + r := percent * radius + p := getPolygonPoint(center, r, angles[j]) + linePoints = append(linePoints, p) + } + color := theme.GetSeriesColor(series.index) + dotFillColor := drawing.ColorWhite + if theme.IsDark() { + dotFillColor = color + } + linePoints = append(linePoints, linePoints[0]) + seriesPainter.OverrideDrawingStyle(Style{ + StrokeColor: color, + StrokeWidth: defaultStrokeWidth, + DotWidth: defaultDotWidth, + DotColor: color, + FillColor: color.WithAlpha(20), + }) + seriesPainter.LineStroke(linePoints). + FillArea(linePoints) + dotWith := 2.0 + seriesPainter.OverrideDrawingStyle(Style{ + StrokeWidth: defaultStrokeWidth, + StrokeColor: color, + FillColor: dotFillColor, + }) + for index, point := range linePoints { + seriesPainter.Circle(dotWith, point.X, point.Y) + seriesPainter.FillStroke() + if series.Label.Show && index < len(series.Data) { + value := humanize.FtoaWithDigits(series.Data[index].Value, 2) + b := seriesPainter.MeasureText(value) + seriesPainter.Text(value, point.X-b.Width()/2, point.Y) + } + + } + } + + return r.p.box, nil +} + +func (r *radarChart) Render() (Box, error) { + p := r.p + opt := r.opt + renderResult, err := defaultRender(p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + SeriesList: opt.SeriesList, + XAxis: XAxisOption{ + Show: FalseFlag(), + }, + YAxisOptions: []YAxisOption{ + { + Show: FalseFlag(), + }, + }, + TitleOption: opt.Title, + LegendOption: opt.Legend, + backgroundIsFilled: opt.backgroundIsFilled, + }) + if err != nil { + return BoxZero, err + } + seriesList := opt.SeriesList.Filter(ChartTypeRadar) + return r.render(renderResult, seriesList) +} diff --git a/radar_chart_test.go b/radar_chart_test.go new file mode 100644 index 0000000..79fd9ac --- /dev/null +++ b/radar_chart_test.go @@ -0,0 +1,107 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRadarChart(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + render func(*Painter) ([]byte, error) + result string + }{ + { + render: func(p *Painter) ([]byte, error) { + values := [][]float64{ + { + 4200, + 3000, + 20000, + 35000, + 50000, + 18000, + }, + { + 5000, + 14000, + 28000, + 26000, + 42000, + 21000, + }, + } + _, err := NewRadarChart(p, RadarChartOption{ + SeriesList: NewSeriesListDataFromValues(values, ChartTypeRadar), + Title: TitleOption{ + Text: "Basic Radar Chart", + }, + Legend: NewLegendOption([]string{ + "Allocated Budget", + "Actual Spending", + }), + RadarIndicators: NewRadarIndicators([]string{ + "Sales", + "Administration", + "Information Technology", + "Customer Support", + "Development", + "Marketing", + }, []float64{ + 6500, + 16000, + 30000, + 38000, + 52000, + 25000, + }), + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nAllocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", + }, + } + 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: "\\nNameAgeAddressTagActionJohnBrown32New York No. 1 Lake Parknice,developerSend MailJim Green42London No. 1 Lake ParkwowSend MailJoe Black32Sidney No. 1 Lake Parkcool,teacherSend Mail", + }, + { + render: func(p *Painter) ([]byte, error) { + _, err := NewTableChart(p, TableChartOption{ + Header: []string{ + "Name", + "Age", + "Address", + "Tag", + "Action", + }, + Data: [][]string{ + { + "John Brown", + "32", + "New York No. 1 Lake Park", + "nice, developer", + "Send Mail", + }, + { + "Jim Green ", + "42", + "London No. 1 Lake Park", + "wow", + "Send Mail", + }, + { + "Joe Black ", + "32", + "Sidney No. 1 Lake Park", + "cool, teacher", + "Send Mail", + }, + }, + }).Render() + if err != nil { + return nil, err + } + return p.Bytes() + }, + result: "\\nNameAgeAddressTagActionJohn Brown32New York No.1 Lake Parknice,developerSend MailJim Green42London No. 1Lake ParkwowSend MailJoe Black32Sidney No. 1Lake Parkcool, teacherSend Mail", + }, + } + for _, tt := range tests { + p, err := NewPainter(PainterOptions{ + Type: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterThemeOption(defaultTheme)) + assert.Nil(err) + data, err := tt.render(p) + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/theme.go b/theme.go 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: "\\ntitlesubTitle", + }, + { + 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: "\\ntitlesubTitle", + }, + { + 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: "\\ntitlesubTitle", + }, + } + 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: "\\nabcd", + }, + } + 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)) + } +}