Merge pull request #11 from vicanso/v2

v2 version
This commit is contained in:
Tree Xie 2022-06-18 09:22:00 +08:00 committed by GitHub
commit 635e440e85
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 5261 additions and 6446 deletions

406
README.md
View file

@ -21,53 +21,45 @@ These chart types are supported: `line`, `bar`, `pie`, `radar` or `funnel`.
## Example ## Example
The example is for `golang option` and `echarts option`, more examples can be found in the `./examples/` directory. More examples can be found in the [./examples/](./examples/) directory.
### Line Chart
```go ```go
package main package main
import ( import (
"os" charts "github.com/vicanso/go-charts/v2"
"path/filepath"
charts "github.com/vicanso/go-charts"
) )
func writeFile(file string, buf []byte) error { func main() {
tmpPath := "./tmp" values := [][]float64{
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file = filepath.Join(tmpPath, file)
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func chartsRender() ([]byte, error) {
d, err := charts.LineRender([][]float64{
{ {
150, 120,
132,
101,
134,
90,
230, 230,
224, 210,
218,
135,
147,
260,
}, },
}, {
// output type // snip...
charts.PNGTypeOption(), },
// title {
charts.TitleOptionFunc(charts.TitleOption{ // snip...
Text: "Line", },
}), {
// x axis // snip...
charts.XAxisOptionFunc(charts.NewXAxisOption([]string{ },
{
// snip...
},
}
p, err := charts.LineRender(
values,
charts.TitleTextOptionFunc("Line"),
charts.XAxisDataOptionFunc([]string{
"Mon", "Mon",
"Tue", "Tue",
"Wed", "Wed",
@ -75,16 +67,324 @@ func chartsRender() ([]byte, error) {
"Fri", "Fri",
"Sat", "Sat",
"Sun", "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 (
"github.com/vicanso/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 { if err != nil {
return nil, err panic(err)
} }
return d.Bytes()
}
func echartsRender() ([]byte, error) { buf, err := p.Bytes()
return charts.RenderEChartsToPNG(`{ if err != nil {
panic(err)
}
// snip...
}
```
### Horizontal Bar Chart
```go
package main
import (
"github.com/vicanso/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 (
"github.com/vicanso/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 (
"github.com/vicanso/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 (
"github.com/vicanso/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...
}
```
### ECharts Render
```go
package main
import (
"github.com/vicanso/go-charts/v2"
)
func main() {
buf, err := charts.RenderEChartsToPNG(`{
"title": { "title": {
"text": "Line" "text": "Line"
}, },
@ -97,25 +397,7 @@ func echartsRender() ([]byte, error) {
} }
] ]
}`) }`)
} // snip...
type Render func() ([]byte, error)
func main() {
m := map[string]Render{
"charts-line.png": chartsRender,
"echarts-line.png": echartsRender,
}
for name, fn := range m {
buf, err := fn()
if err != nil {
panic(err)
}
err = writeFile(name, buf)
if err != nil {
panic(err)
}
}
} }
``` ```

View file

@ -21,53 +21,44 @@
下面的示例为`go-charts`两种方式的参数配置golang的参数配置、echarts的JSON配置输出相同的折线图。 下面的示例为`go-charts`两种方式的参数配置golang的参数配置、echarts的JSON配置输出相同的折线图。
更多的示例参考:`./examples/`目录 更多的示例参考:[./examples/](./examples/)目录
### Line Chart
```go ```go
package main package main
import ( import (
"os" charts "github.com/vicanso/go-charts/v2"
"path/filepath"
charts "github.com/vicanso/go-charts"
) )
func writeFile(file string, buf []byte) error { func main() {
tmpPath := "./tmp" values := [][]float64{
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file = filepath.Join(tmpPath, file)
err = os.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func chartsRender() ([]byte, error) {
d, err := charts.LineRender([][]float64{
{ {
150, 120,
132,
101,
134,
90,
230, 230,
224, 210,
218,
135,
147,
260,
}, },
}, {
// output type // snip...
charts.PNGTypeOption(), },
// title {
charts.TitleOptionFunc(charts.TitleOption{ // snip...
Text: "Line", },
}), {
// x axis // snip...
charts.XAxisOptionFunc(charts.NewXAxisOption([]string{ },
{
// snip...
},
}
p, err := charts.LineRender(
values,
charts.TitleTextOptionFunc("Line"),
charts.XAxisDataOptionFunc([]string{
"Mon", "Mon",
"Tue", "Tue",
"Wed", "Wed",
@ -75,16 +66,324 @@ func chartsRender() ([]byte, error) {
"Fri", "Fri",
"Sat", "Sat",
"Sun", "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 (
"github.com/vicanso/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 { if err != nil {
return nil, err panic(err)
} }
return d.Bytes()
}
func echartsRender() ([]byte, error) { buf, err := p.Bytes()
return charts.RenderEChartsToPNG(`{ if err != nil {
panic(err)
}
// snip...
}
```
### Horizontal Bar Chart
```go
package main
import (
"github.com/vicanso/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 (
"github.com/vicanso/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 (
"github.com/vicanso/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 (
"github.com/vicanso/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...
}
```
### ECharts Render
```go
package main
import (
"github.com/vicanso/go-charts/v2"
)
func main() {
buf, err := charts.RenderEChartsToPNG(`{
"title": { "title": {
"text": "Line" "text": "Line"
}, },
@ -97,25 +396,7 @@ func echartsRender() ([]byte, error) {
} }
] ]
}`) }`)
} // snip...
type Render func() ([]byte, error)
func main() {
m := map[string]Render{
"charts-line.png": chartsRender,
"echarts-line.png": echartsRender,
}
for name, fn := range m {
buf, err := fn()
if err != nil {
panic(err)
}
err = writeFile(name, buf)
if err != nil {
panic(err)
}
}
} }
``` ```
@ -222,4 +503,6 @@ BenchmarkMultiChartSVGRender-8 367 3356325 ns/op
字体文件可以在[中文字库noto-cjk](https://github.com/googlefonts/noto-cjk)下载,注意下载时选择字体格式为 `ttf` 格式,如果选用 `otf` 格式可能会加载失败。 字体文件可以在[中文字库noto-cjk](https://github.com/googlefonts/noto-cjk)下载,注意下载时选择字体格式为 `ttf` 格式,如果选用 `otf` 格式可能会加载失败。
示例见 [examples/basic/chinese.go](examples/basic/chinese.go)
示例见 [examples/chinese/main.go](examples/chinese/main.go)

View file

@ -27,32 +27,47 @@ import (
"github.com/wcharczuk/go-chart/v2/drawing" "github.com/wcharczuk/go-chart/v2/drawing"
) )
type BarStyle struct { type Box = chart.Box
ClassName string type Style = chart.Style
StrokeDashArray []float64 type Color = drawing.Color
FillColor drawing.Color
var BoxZero = chart.BoxZero
type Point struct {
X int
Y int
} }
func (bs *BarStyle) Style() chart.Style { const (
return chart.Style{ ChartTypeLine = "line"
ClassName: bs.ClassName, ChartTypeBar = "bar"
StrokeDashArray: bs.StrokeDashArray, ChartTypePie = "pie"
StrokeColor: bs.FillColor, ChartTypeRadar = "radar"
StrokeWidth: 1, ChartTypeFunnel = "funnel"
FillColor: bs.FillColor, // horizontal bar
} ChartTypeHorizontalBar = "horizontalBar"
} )
// Bar renders bar for chart const (
func (d *Draw) Bar(b chart.Box, style BarStyle) { ChartOutputSVG = "svg"
s := style.Style() ChartOutputPNG = "png"
)
r := d.Render const (
s.GetFillAndStrokeOptions().WriteToRenderer(r) PositionLeft = "left"
d.moveTo(b.Left, b.Top) PositionRight = "right"
d.lineTo(b.Right, b.Top) PositionCenter = "center"
d.lineTo(b.Right, b.Bottom) PositionTop = "top"
d.lineTo(b.Left, b.Bottom) PositionBottom = "bottom"
d.lineTo(b.Left, b.Top) )
d.Render.FillStroke()
} const (
AlignLeft = "left"
AlignRight = "right"
AlignCenter = "center"
)
const (
OrientHorizontal = "horizontal"
OrientVertical = "vertical"
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Before After
Before After

572
axis.go
View file

@ -23,14 +23,31 @@
package charts package charts
import ( import (
"math" "strings"
"github.com/golang/freetype/truetype" "github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
) )
type axisPainter struct {
p *Painter
opt *AxisOption
}
func NewAxisPainter(p *Painter, opt AxisOption) *axisPainter {
return &axisPainter{
p: p,
opt: &opt,
}
}
type AxisOption struct { type AxisOption struct {
// The theme of chart
Theme ColorPalette
// Formatter for y axis text value
Formatter string
// The label of axis
Data []string
// The boundary gap on both sides of a coordinate axis. // The boundary gap on both sides of a coordinate axis.
// Nil or *true means the center part of two axis ticks // Nil or *true means the center part of two axis ticks
BoundaryGap *bool BoundaryGap *bool
@ -40,15 +57,12 @@ type AxisOption struct {
Position string Position string
// Number of segments that the axis is split into. Note that this number serves only as a recommendation. // Number of segments that the axis is split into. Note that this number serves only as a recommendation.
SplitNumber int SplitNumber int
ClassName string
// The line color of axis // The line color of axis
StrokeColor drawing.Color StrokeColor Color
// The line width // The line width
StrokeWidth float64 StrokeWidth float64
// The length of the axis tick // The length of the axis tick
TickLength int TickLength int
// The flag for show axis tick, set this to *false will hide axis tick
TickShow *bool
// The margin value of label // The margin value of label
LabelMargin int LabelMargin int
// The font size of label // The font size of label
@ -56,413 +70,231 @@ type AxisOption struct {
// The font of label // The font of label
Font *truetype.Font Font *truetype.Font
// The color of label // The color of label
FontColor drawing.Color FontColor Color
// The flag for show axis split line, set this to true will show axis split line // The flag for show axis split line, set this to true will show axis split line
SplitLineShow bool SplitLineShow bool
// The color of split line // The color of split line
SplitLineColor drawing.Color SplitLineColor Color
} }
type axis struct { func (a *axisPainter) Render() (Box, error) {
d *Draw opt := a.opt
data *AxisDataList top := a.p
option *AxisOption theme := opt.Theme
} if opt.Show != nil && !*opt.Show {
type axisMeasurement struct { return BoxZero, nil
Width int
Height int
}
// NewAxis creates a new axis with data and style options
func NewAxis(d *Draw, data AxisDataList, option AxisOption) *axis {
return &axis{
d: d,
data: &data,
option: &option,
} }
} strokeWidth := opt.StrokeWidth
if strokeWidth == 0 {
// GetLabelMargin returns the label margin value strokeWidth = 1
func (as *AxisOption) GetLabelMargin() int {
return getDefaultInt(as.LabelMargin, 8)
}
// GetTickLength returns the tick length value
func (as *AxisOption) GetTickLength() int {
return getDefaultInt(as.TickLength, 5)
}
// Style returns the style of axis
func (as *AxisOption) Style(f *truetype.Font) chart.Style {
s := chart.Style{
ClassName: as.ClassName,
StrokeColor: as.StrokeColor,
StrokeWidth: as.StrokeWidth,
FontSize: as.FontSize,
FontColor: as.FontColor,
Font: as.Font,
} }
if s.FontSize == 0 {
s.FontSize = chart.DefaultFontSize font := opt.Font
if font == nil {
font = a.p.font
} }
if s.Font == nil { if font == nil {
s.Font = f font = theme.GetFont()
} }
return s fontColor := opt.FontColor
} if fontColor.IsZero() {
fontColor = theme.GetTextColor()
type AxisData struct { }
// The text value of axis fontSize := opt.FontSize
Text string if fontSize == 0 {
} fontSize = theme.GetFontSize()
type AxisDataList []AxisData }
strokeColor := opt.StrokeColor
// TextList returns the text list of axis data if strokeColor.IsZero() {
func (l AxisDataList) TextList() []string { strokeColor = theme.GetAxisStrokeColor()
textList := make([]string, len(l))
for index, item := range l {
textList[index] = item.Text
} }
return textList
}
type axisRenderOption struct { data := opt.Data
textMaxWith int formatter := opt.Formatter
textMaxHeight int if len(formatter) != 0 {
boundaryGap bool for index, text := range data {
unitCount int data[index] = strings.ReplaceAll(formatter, "{value}", text)
modValue int
}
// NewAxisDataListFromStringList creates a new axis data list from string list
func NewAxisDataListFromStringList(textList []string) AxisDataList {
list := make(AxisDataList, len(textList))
for index, text := range textList {
list[index] = AxisData{
Text: text,
} }
} }
return list dataCount := len(data)
} tickCount := dataCount
func (a *axis) axisLabel(renderOpt *axisRenderOption) { boundaryGap := true
option := a.option if opt.BoundaryGap != nil && !*opt.BoundaryGap {
data := *a.data boundaryGap = false
d := a.d
if option.FontColor.IsZero() || len(data) == 0 {
return
} }
r := d.Render isVertical := opt.Position == PositionLeft ||
opt.Position == PositionRight
s := option.Style(d.Font) labelPosition := ""
s.GetTextOptions().WriteTextOptionsToRenderer(r)
width := d.Box.Width()
height := d.Box.Height()
textList := data.TextList()
count := len(textList)
boundaryGap := renderOpt.boundaryGap
if !boundaryGap { if !boundaryGap {
count-- tickCount--
labelPosition = PositionLeft
}
if isVertical && boundaryGap {
labelPosition = PositionCenter
} }
unitCount := renderOpt.unitCount // 如果小于0则表示不处理
modValue := renderOpt.modValue tickLength := getDefaultInt(opt.TickLength, 5)
labelMargin := option.GetLabelMargin() labelMargin := getDefaultInt(opt.LabelMargin, 5)
// 轴线 style := Style{
labelHeight := labelMargin + renderOpt.textMaxHeight StrokeColor: strokeColor,
labelWidth := labelMargin + renderOpt.textMaxWith StrokeWidth: strokeWidth,
Font: font,
FontColor: fontColor,
FontSize: fontSize,
}
top.SetDrawingStyle(style).OverrideTextStyle(style)
// 坐标轴文本 textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(data)
position := option.Position textCount := ceilFloatToInt(float64(top.Width()) / float64(textMaxWidth))
switch position { unit := ceilFloatToInt(float64(dataCount) / float64(chart.MaxInt(textCount, opt.SplitNumber)))
width := 0
height := 0
// 垂直
if isVertical {
width = textMaxWidth + tickLength<<1
height = top.Height()
} else {
width = top.Width()
height = tickLength<<1 + textMaxHeight
}
padding := Box{}
switch opt.Position {
case PositionTop:
padding.Top = top.Height() - height
case PositionLeft: case PositionLeft:
fallthrough padding.Right = top.Width() - width
case PositionRight: case PositionRight:
values := autoDivide(height, count) padding.Left = top.Width() - width
textList := data.TextList()
// 由下往上
reverseIntSlice(values)
for index, text := range textList {
y := values[index] - 2
b := r.MeasureText(text)
if boundaryGap {
height := y - values[index+1]
y -= (height - b.Height()) >> 1
} else {
y += b.Height() >> 1
}
// 左右位置的x不一样
x := width - renderOpt.textMaxWith
if position == PositionLeft {
x = labelWidth - b.Width() - 1
}
d.text(text, x, y)
}
default: default:
// 定位bottom重新计算y0的定位 padding.Top = top.Height() - defaultXAxisHeight
y0 := height - labelMargin
if position == PositionTop {
y0 = labelHeight - labelMargin
}
values := autoDivide(width, count)
for index, text := range data.TextList() {
if unitCount != 0 && index%unitCount != modValue {
continue
}
x := values[index]
leftOffset := 0
b := r.MeasureText(text)
if boundaryGap {
width := values[index+1] - x
leftOffset = (width - b.Width()) >> 1
} else {
// 左移文本长度
leftOffset = -b.Width() >> 1
}
d.text(text, x+leftOffset, y0)
}
} }
}
func (a *axis) axisLine(renderOpt *axisRenderOption) { p := top.Child(PainterPaddingOption(padding))
d := a.d
r := d.Render
option := a.option
s := option.Style(d.Font)
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
x0 := 0 x0 := 0
y0 := 0 y0 := 0
x1 := 0 x1 := 0
y1 := 0 y1 := 0
width := d.Box.Width() ticksPaddingTop := 0
height := d.Box.Height() ticksPaddingLeft := 0
labelMargin := option.GetLabelMargin() labelPaddingTop := 0
labelPaddingLeft := 0
labelPaddingRight := 0
orient := ""
textAlign := ""
// 轴线 switch opt.Position {
labelHeight := labelMargin + renderOpt.textMaxHeight
labelWidth := labelMargin + renderOpt.textMaxWith
tickLength := option.GetTickLength()
switch option.Position {
case PositionLeft:
x0 = tickLength + labelWidth
x1 = x0
y0 = 0
y1 = height
case PositionRight:
x0 = width - labelWidth
x1 = x0
y0 = 0
y1 = height
case PositionTop: case PositionTop:
x0 = 0 labelPaddingTop = labelMargin
x1 = width x1 = p.Width()
y0 = labelHeight y0 = labelMargin + int(opt.FontSize)
ticksPaddingTop = int(opt.FontSize)
y1 = y0 y1 = y0
// bottom orient = OrientHorizontal
default:
x0 = 0
x1 = width
y0 = height - tickLength - labelHeight
y1 = y0
}
d.moveTo(x0, y0)
d.lineTo(x1, y1)
r.FillStroke()
}
func (a *axis) axisTick(renderOpt *axisRenderOption) {
d := a.d
r := d.Render
option := a.option
s := option.Style(d.Font)
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
width := d.Box.Width()
height := d.Box.Height()
data := *a.data
tickCount := len(data)
if tickCount == 0 {
return
}
if !renderOpt.boundaryGap {
tickCount--
}
labelMargin := option.GetLabelMargin()
tickShow := true
if isFalse(option.TickShow) {
tickShow = false
}
unitCount := renderOpt.unitCount
tickLengthValue := option.GetTickLength()
labelHeight := labelMargin + renderOpt.textMaxHeight
labelWidth := labelMargin + renderOpt.textMaxWith
position := option.Position
switch position {
case PositionLeft: case PositionLeft:
fallthrough x0 = p.Width()
y0 = 0
x1 = p.Width()
y1 = p.Height()
orient = OrientVertical
textAlign = AlignRight
ticksPaddingLeft = textMaxWidth + tickLength
labelPaddingRight = width - textMaxWidth
case PositionRight: case PositionRight:
values := autoDivide(height, tickCount) orient = OrientVertical
// 左右仅是x0的位置不一样 y1 = p.Height()
x0 := width - labelWidth labelPaddingLeft = width - textMaxWidth
if option.Position == PositionLeft { default:
x0 = labelWidth labelPaddingTop = height
} x1 = p.Width()
if tickShow { orient = OrientHorizontal
for _, v := range values { }
x := x0
y := v if strokeWidth > 0 {
d.moveTo(x, y) p.Child(PainterPaddingOption(Box{
d.lineTo(x+tickLengthValue, y) Top: ticksPaddingTop,
r.Stroke() Left: ticksPaddingLeft,
} })).Ticks(TicksOption{
} Count: tickCount,
// 辅助线 Length: tickLength,
if option.SplitLineShow && !option.SplitLineColor.IsZero() { Unit: unit,
r.SetStrokeColor(option.SplitLineColor) Orient: orient,
splitLineWidth := width - labelWidth - tickLengthValue })
x0 = labelWidth + tickLengthValue p.LineStroke([]Point{
if position == PositionRight { {
X: x0,
Y: y0,
},
{
X: x1,
Y: y1,
},
})
}
p.Child(PainterPaddingOption(Box{
Left: labelPaddingLeft,
Top: labelPaddingTop,
Right: labelPaddingRight,
})).MultiText(MultiTextOption{
Align: textAlign,
TextList: data,
Orient: orient,
Unit: unit,
Position: labelPosition,
})
// 显示辅助线
if opt.SplitLineShow {
style.StrokeColor = opt.SplitLineColor
top.OverrideDrawingStyle(style)
if isVertical {
x0 := p.Width()
x1 := top.Width()
if opt.Position == PositionRight {
x0 = 0 x0 = 0
splitLineWidth = width - labelWidth - 1 x1 = top.Width() - p.Width()
} }
for _, v := range values[0 : len(values)-1] { for _, y := range autoDivide(height, tickCount) {
x := x0 top.LineStroke([]Point{
y := v {
d.moveTo(x, y) X: x0,
d.lineTo(x+splitLineWidth, y) Y: y,
r.Stroke() },
{
X: x1,
Y: y,
},
})
} }
} } else {
default: y0 := p.Height() - defaultXAxisHeight
values := autoDivide(width, tickCount) y1 := top.Height() - defaultXAxisHeight
// 上下仅是y0的位置不一样 for index, x := range autoDivide(width, tickCount) {
y0 := height - labelHeight if index == 0 {
if position == PositionTop {
y0 = labelHeight
}
if tickShow {
for index, v := range values {
if index%unitCount != 0 {
continue continue
} }
x := v top.LineStroke([]Point{
y := y0 {
d.moveTo(x, y-tickLengthValue) X: x,
d.lineTo(x, y) Y: y0,
r.Stroke() },
} {
} X: x,
// 辅助线 Y: y1,
if option.SplitLineShow && !option.SplitLineColor.IsZero() { },
r.SetStrokeColor(option.SplitLineColor) })
y0 = 0
splitLineHeight := height - labelHeight - tickLengthValue
if position == PositionTop {
y0 = labelHeight
splitLineHeight = height - labelHeight
}
for index, v := range values {
if index%unitCount != 0 {
continue
}
x := v
y := y0
d.moveTo(x, y)
d.lineTo(x, y0+splitLineHeight)
r.Stroke()
} }
} }
} }
}
return Box{
func (a *axis) measureTextMaxWidthHeight() (int, int) { Bottom: height,
d := a.d Right: width,
r := d.Render }, nil
s := a.option.Style(d.Font)
data := a.data
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
s.GetTextOptions().WriteTextOptionsToRenderer(r)
return measureTextMaxWidthHeight(data.TextList(), r)
}
// measure returns the measurement of axis.
// Width will be textMaxWidth + labelMargin + tickLength for position left or right.
// Height will be textMaxHeight + labelMargin + tickLength for position top or bottom.
func (a *axis) measure() axisMeasurement {
option := a.option
value := option.GetLabelMargin() + option.GetTickLength()
textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight()
info := axisMeasurement{}
if option.Position == PositionLeft ||
option.Position == PositionRight {
info.Width = textMaxWidth + value
} else {
info.Height = textMaxHeight + value
}
return info
}
// Render renders the axis for chart
func (a *axis) Render() {
option := a.option
if isFalse(option.Show) {
return
}
textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight()
opt := &axisRenderOption{
textMaxWith: textMaxWidth,
textMaxHeight: textMaxHeight,
boundaryGap: true,
}
if isFalse(option.BoundaryGap) {
opt.boundaryGap = false
}
unitCount := chart.MaxInt(option.SplitNumber, 1)
width := a.d.Box.Width()
textList := a.data.TextList()
count := len(textList)
position := option.Position
switch position {
case PositionLeft:
fallthrough
case PositionRight:
default:
maxCount := width / (opt.textMaxWith + 10)
// 可以显示所有
if maxCount >= count {
unitCount = 1
} else if maxCount < count/unitCount {
unitCount = int(math.Ceil(float64(count) / float64(maxCount)))
}
}
boundaryGap := opt.boundaryGap
modValue := 0
if boundaryGap && unitCount > 1 {
// 如果是居中unit count需要设置为奇数
if unitCount%2 == 0 {
unitCount++
}
modValue = unitCount / 2
}
opt.modValue = modValue
opt.unitCount = unitCount
// 坐标轴线
a.axisLine(opt)
a.axisTick(opt)
// 坐标文本
a.axisLabel(opt)
} }

View file

@ -1,259 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/golang/freetype/truetype"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestAxisOption(t *testing.T) {
assert := assert.New(t)
as := AxisOption{}
assert.Equal(8, as.GetLabelMargin())
as.LabelMargin = 10
assert.Equal(10, as.GetLabelMargin())
assert.Equal(5, as.GetTickLength())
as.TickLength = 6
assert.Equal(6, as.GetTickLength())
f := &truetype.Font{}
style := as.Style(f)
assert.Equal(float64(10), style.FontSize)
assert.Equal(f, style.Font)
}
func TestAxisDataList(t *testing.T) {
assert := assert.New(t)
textList := []string{
"a",
"b",
}
data := NewAxisDataListFromStringList(textList)
assert.Equal(textList, data.TextList())
}
func TestAxis(t *testing.T) {
assert := assert.New(t)
axisData := NewAxisDataListFromStringList([]string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
})
getDefaultOption := func() AxisOption {
return AxisOption{
StrokeColor: drawing.ColorBlack,
StrokeWidth: 1,
FontColor: drawing.ColorBlack,
Show: TrueFlag(),
TickShow: TrueFlag(),
SplitLineShow: true,
SplitLineColor: drawing.ColorBlack.WithAlpha(60),
}
}
tests := []struct {
newOption func() AxisOption
newData func() AxisDataList
result string
}{
// 文本按起始位置展示
// axis位于bottom
{
newOption: func() AxisOption {
opt := getDefaultOption()
opt.BoundaryGap = FalseFlag()
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 270\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 270\nL 5 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 70 270\nL 70 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 135 270\nL 135 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 200 270\nL 200 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 265 270\nL 265 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 330 270\nL 330 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 395 270\nL 395 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 5\nL 5 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 70 5\nL 70 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 135 5\nL 135 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 200 5\nL 200 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 265 5\nL 265 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 330 5\nL 330 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 395 5\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"-8\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"59\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"122\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"189\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"257\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"320\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"384\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 文本居中展示
// axis位于bottom
{
newOption: func() AxisOption {
opt := getDefaultOption()
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 270\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 270\nL 5 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 61 270\nL 61 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 117 270\nL 117 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 173 270\nL 173 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 229 270\nL 229 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 285 270\nL 285 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 340 270\nL 340 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 395 270\nL 395 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 5\nL 5 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 61 5\nL 61 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 117 5\nL 117 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 173 5\nL 173 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 229 5\nL 229 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 285 5\nL 285 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 340 5\nL 340 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 395 5\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"20\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"78\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"132\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"190\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"249\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"303\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"356\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 文本按起始位置展示
// axis位于top
{
newOption: func() AxisOption {
opt := getDefaultOption()
opt.Position = PositionTop
opt.BoundaryGap = FalseFlag()
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 25\nL 395 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 20\nL 5 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 70 20\nL 70 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 135 20\nL 135 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 200 20\nL 200 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 265 20\nL 265 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 330 20\nL 330 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 395 20\nL 395 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 25\nL 5 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 70 25\nL 70 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 135 25\nL 135 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 200 25\nL 200 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 265 25\nL 265 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 330 25\nL 330 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 395 25\nL 395 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"-8\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"59\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"122\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"189\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"257\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"320\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"384\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 文本居中展示
// axis位于top
{
newOption: func() AxisOption {
opt := getDefaultOption()
opt.Position = PositionTop
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 25\nL 395 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 20\nL 5 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 61 20\nL 61 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 117 20\nL 117 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 173 20\nL 173 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 229 20\nL 229 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 285 20\nL 285 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 340 20\nL 340 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 395 20\nL 395 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 25\nL 5 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 61 25\nL 61 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 117 25\nL 117 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 173 25\nL 173 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 229 25\nL 229 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 285 25\nL 285 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 340 25\nL 340 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 395 25\nL 395 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"20\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"78\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"132\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"190\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"249\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"303\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"356\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 文本按起始位置展示
// axis位于left
{
newOption: func() AxisOption {
opt := getDefaultOption()
opt.Position = PositionLeft
opt.BoundaryGap = FalseFlag()
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 44 5\nL 44 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 5\nL 44 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 54\nL 44 54\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 103\nL 44 103\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 151\nL 44 151\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 199\nL 44 199\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 247\nL 44 247\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 295\nL 44 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 44 5\nL 395 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 54\nL 395 54\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 103\nL 395 103\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 151\nL 395 151\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 199\nL 395 199\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 247\nL 395 247\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"12\" y=\"299\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"16\" y=\"251\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"12\" y=\"203\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"16\" y=\"155\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"23\" y=\"107\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"19\" y=\"58\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"16\" y=\"9\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 文本居中展示
// axis位于left
{
newOption: func() AxisOption {
opt := getDefaultOption()
opt.Position = PositionLeft
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 44 5\nL 44 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 5\nL 44 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 47\nL 44 47\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 89\nL 44 89\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 131\nL 44 131\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 172\nL 44 172\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 213\nL 44 213\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 254\nL 44 254\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 295\nL 44 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 44 5\nL 395 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 47\nL 395 47\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 89\nL 395 89\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 131\nL 395 131\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 172\nL 395 172\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 213\nL 395 213\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 254\nL 395 254\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"12\" y=\"280\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"16\" y=\"239\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"12\" y=\"198\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"16\" y=\"157\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"23\" y=\"115\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"19\" y=\"73\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"16\" y=\"31\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 文本按起始位置展示
// axis位于right
{
newOption: func() AxisOption {
opt := getDefaultOption()
opt.Position = PositionRight
opt.BoundaryGap = FalseFlag()
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 361 5\nL 361 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 5\nL 366 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 54\nL 366 54\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 103\nL 366 103\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 151\nL 366 151\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 199\nL 366 199\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 247\nL 366 247\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 295\nL 366 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 5\nL 360 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 54\nL 360 54\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 103\nL 360 103\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 151\nL 360 151\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 199\nL 360 199\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 247\nL 360 247\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"369\" y=\"299\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"369\" y=\"251\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"369\" y=\"203\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"369\" y=\"155\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"369\" y=\"107\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"369\" y=\"58\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"369\" y=\"9\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// 文本居中展示
// axis位于right
{
newOption: func() AxisOption {
opt := getDefaultOption()
opt.Position = PositionRight
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 361 5\nL 361 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 5\nL 366 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 47\nL 366 47\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 89\nL 366 89\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 131\nL 366 131\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 172\nL 366 172\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 213\nL 366 213\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 254\nL 366 254\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 295\nL 366 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 5\nL 360 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 47\nL 360 47\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 89\nL 360 89\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 131\nL 360 131\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 172\nL 360 172\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 213\nL 360 213\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 254\nL 360 254\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"369\" y=\"280\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"369\" y=\"239\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"369\" y=\"198\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"369\" y=\"157\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"369\" y=\"115\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"369\" y=\"73\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"369\" y=\"31\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
},
// text较多仅展示部分
{
newOption: func() AxisOption {
opt := getDefaultOption()
opt.Position = PositionBottom
return opt
},
newData: func() AxisDataList {
return NewAxisDataListFromStringList([]string{
"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",
})
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 270\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 270\nL 5 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 62 270\nL 62 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 119 270\nL 119 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 176 270\nL 176 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 233 270\nL 233 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 287 270\nL 287 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 341 270\nL 341 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 395 270\nL 395 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 5\nL 5 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 62 5\nL 62 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 119 5\nL 119 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 176 5\nL 176 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 233 5\nL 233 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 287 5\nL 287 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 341 5\nL 341 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 395 5\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"16\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-02</text><text x=\"73\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-05</text><text x=\"130\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-08</text><text x=\"187\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-11</text><text x=\"243\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-14</text><text x=\"297\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-17</text><text x=\"351\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-20</text></svg>",
},
}
for _, tt := range tests {
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
}, PaddingOption(chart.Box{
Left: 5,
Top: 5,
Right: 5,
Bottom: 5,
}))
assert.Nil(err)
style := tt.newOption()
data := axisData
if tt.newData != nil {
data = tt.newData()
}
NewAxis(d, data, style).Render()
result, err := d.Bytes()
assert.Nil(err)
assert.Equal(tt.result, string(result))
}
}
func TestMeasureAxis(t *testing.T) {
assert := assert.New(t)
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
assert.Nil(err)
data := NewAxisDataListFromStringList([]string{
"Mon",
"Sun",
})
f, _ := chart.GetDefaultFont()
width := NewAxis(d, data, AxisOption{
FontSize: 12,
Font: f,
Position: PositionLeft,
}).measure().Width
assert.Equal(44, width)
height := NewAxis(d, data, AxisOption{
FontSize: 12,
Font: f,
Position: PositionTop,
}).measure().Height
assert.Equal(28, height)
}

View file

@ -27,27 +27,48 @@ import (
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
) )
type barChartOption struct { type barChart struct {
// The series list fo bar chart p *Painter
SeriesList SeriesList opt *BarChartOption
// The theme
Theme string
// The font
Font *truetype.Font
} }
func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointRenderOption, error) { func NewBarChart(p *Painter, opt BarChartOption) *barChart {
d, err := NewDraw(DrawOption{ if opt.Theme == nil {
Parent: result.d, opt.Theme = defaultTheme
}, PaddingOption(chart.Box{
Top: result.titleBox.Height(),
// TODO 后续考虑是否需要根据左侧是否展示y轴再生成对应的left
Left: YAxisWidth,
}))
if err != nil {
return nil, err
} }
xRange := result.xRange return &barChart{
p: p,
opt: &opt,
}
}
type BarChartOption struct {
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
}
func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
p := b.p
opt := b.opt
seriesPainter := result.seriesPainter
xRange := NewRange(AxisRangeOption{
DivideCount: len(opt.XAxis.Data),
Size: seriesPainter.Width(),
})
x0, x1 := xRange.GetRange(0) x0, x1 := xRange.GetRange(0)
width := int(x1 - x0) width := int(x1 - x0)
// 每一块之间的margin // 每一块之间的margin
@ -61,50 +82,34 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR
margin = 5 margin = 5
barMargin = 3 barMargin = 3
} }
seriesCount := len(seriesList)
seriesCount := len(opt.SeriesList)
// 总的宽度-两个margin-(总数-1)的barMargin // 总的宽度-两个margin-(总数-1)的barMargin
barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / len(opt.SeriesList) barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / len(seriesList)
barMaxHeight := seriesPainter.Height()
theme := opt.Theme
seriesNames := seriesList.Names()
barMaxHeight := result.getYRange(0).Size markPointPainter := NewMarkPointPainter(seriesPainter)
theme := NewTheme(opt.Theme) markLinePainter := NewMarkLinePainter(seriesPainter)
rendererList := []Renderer{
markPointPainter,
markLinePainter,
}
for index := range seriesList {
series := seriesList[index]
yRange := result.axisRanges[series.AxisIndex]
seriesColor := theme.GetSeriesColor(series.index)
seriesNames := opt.SeriesList.Names()
r := d.Render
markPointRenderOptions := make([]markPointRenderOption, 0)
for i, s := range opt.SeriesList {
// 由于series是for range为同一个数据因此需要clone
// 后续需要使用如mark point
series := s
yRange := result.getYRange(series.YAxisIndex)
points := make([]Point, len(series.Data))
index := series.index
if index == 0 {
index = i
}
seriesColor := theme.GetSeriesColor(index)
// mark line
markLineRender(markLineRenderOption{
Draw: d,
FillColor: seriesColor,
FontColor: theme.GetTextColor(),
StrokeColor: seriesColor,
Font: opt.Font,
Series: &series,
Range: yRange,
})
divideValues := xRange.AutoDivide() divideValues := xRange.AutoDivide()
points := make([]Point, len(series.Data))
for j, item := range series.Data { for j, item := range series.Data {
if j >= xRange.divideCount { if j >= xRange.divideCount {
continue continue
} }
x := divideValues[j] x := divideValues[j]
x += margin x += margin
if i != 0 { if index != 0 {
x += i * (barWidth + barMargin) x += index * (barWidth + barMargin)
} }
h := int(yRange.getHeight(item.Value)) h := int(yRange.getHeight(item.Value))
@ -113,13 +118,14 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR
fillColor = item.Style.FillColor fillColor = item.Style.FillColor
} }
top := barMaxHeight - h top := barMaxHeight - h
d.Bar(chart.Box{
seriesPainter.OverrideDrawingStyle(Style{
FillColor: fillColor,
}).Rect(chart.Box{
Top: top, Top: top,
Left: x, Left: x,
Right: x + barWidth, Right: x + barWidth,
Bottom: barMaxHeight - 1, Bottom: barMaxHeight - 1,
}, BarStyle{
FillColor: fillColor,
}) })
// 用于生成marker point // 用于生成marker point
points[j] = Point{ points[j] = Point{
@ -127,6 +133,12 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR
X: x + barWidth>>1, X: x + barWidth>>1,
Y: top, Y: top,
} }
// 用于生成marker point
points[j] = Point{
// 居中的位置
X: x + barWidth>>1,
Y: top,
}
// 如果label不需要展示则返回 // 如果label不需要展示则返回
if !series.Label.Show { if !series.Label.Show {
continue continue
@ -135,8 +147,8 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR
if distance == 0 { if distance == 0 {
distance = 5 distance = 5
} }
text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(index, item.Value, -1)
labelStyle := chart.Style{ labelStyle := Style{
FontColor: theme.GetTextColor(), FontColor: theme.GetTextColor(),
FontSize: labelFontSize, FontSize: labelFontSize,
Font: opt.Font, Font: opt.Font,
@ -144,20 +156,50 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR
if !series.Label.Color.IsZero() { if !series.Label.Color.IsZero() {
labelStyle.FontColor = series.Label.Color labelStyle.FontColor = series.Label.Color
} }
labelStyle.GetTextOptions().WriteToRenderer(r) seriesPainter.OverrideTextStyle(labelStyle)
textBox := r.MeasureText(text) textBox := seriesPainter.MeasureText(text)
d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance) seriesPainter.Text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance)
} }
// 生成mark point的参数 markPointPainter.Add(markPointRenderOption{
markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{
Draw: d,
FillColor: seriesColor, FillColor: seriesColor,
Font: opt.Font, Font: opt.Font,
Series: series,
Points: points, 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 markPointRenderOptions, nil 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)
} }

View file

@ -1,131 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestBarChartRender(t *testing.T) {
assert := assert.New(t)
width := 400
height := 300
d, err := NewDraw(DrawOption{
Width: width,
Height: height,
})
assert.Nil(err)
result := basicRenderResult{
xRange: &Range{
Min: 0,
Max: 4,
divideCount: 4,
Size: width,
Boundary: true,
},
yRangeList: []*Range{
{
divideCount: 6,
Max: 100,
Min: 0,
Size: height,
},
},
d: d,
}
f, _ := chart.GetDefaultFont()
markPointOptions, err := barChartRender(barChartOption{
Font: f,
SeriesList: SeriesList{
{
Label: SeriesLabel{
Show: true,
Color: drawing.ColorBlue,
},
MarkLine: NewMarkLine(
SeriesMarkDataTypeMin,
),
Data: []SeriesData{
{
Value: 20,
},
{
Value: 60,
Style: chart.Style{
FillColor: drawing.ColorRed,
},
},
{
Value: 90,
},
},
},
NewSeriesFromValues([]float64{
80,
30,
70,
}),
},
}, &result)
assert.Nil(err)
assert.Equal(2, len(markPointOptions))
assert.Equal([]Point{
{
X: 28,
Y: 240,
},
{
X: 128,
Y: 120,
},
{
X: 228,
Y: 30,
},
}, markPointOptions[0].Points)
assert.Equal([]Point{
{
X: 70,
Y: 60,
},
{
X: 170,
Y: 210,
},
{
X: 270,
Y: 90,
},
}, markPointOptions[1].Points)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"40\" cy=\"240\" r=\"3\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 45 240\nL 382 240\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 382 235\nL 398 240\nL 382 245\nL 387 240\nL 382 235\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"400\" y=\"244\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20</text><path d=\"M 50 240\nL 87 240\nL 87 299\nL 50 299\nL 50 240\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"61\" y=\"235\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20</text><path d=\"M 150 120\nL 187 120\nL 187 299\nL 150 299\nL 150 120\" style=\"stroke-width:1;stroke:rgba(255,0,0,1.0);fill:rgba(255,0,0,1.0)\"/><text x=\"161\" y=\"115\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">60</text><path d=\"M 250 30\nL 287 30\nL 287 299\nL 250 299\nL 250 30\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"261\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">90</text><path d=\"M 92 60\nL 129 60\nL 129 299\nL 92 299\nL 92 60\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"M 192 210\nL 229 210\nL 229 299\nL 192 299\nL 192 210\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"M 292 90\nL 329 90\nL 329 299\nL 292 299\nL 292 90\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/></svg>", string(data))
}

View file

@ -1,78 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestBarStyle(t *testing.T) {
assert := assert.New(t)
bs := BarStyle{
ClassName: "test",
StrokeDashArray: []float64{
1.0,
},
FillColor: drawing.ColorBlack,
}
assert.Equal(chart.Style{
ClassName: "test",
StrokeDashArray: []float64{
1.0,
},
StrokeWidth: 1,
FillColor: drawing.ColorBlack,
StrokeColor: drawing.ColorBlack,
}, bs.Style())
}
func TestDrawBar(t *testing.T) {
assert := assert.New(t)
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
}, PaddingOption(chart.Box{
Left: 10,
Top: 20,
Right: 30,
Bottom: 40,
}))
assert.Nil(err)
d.Bar(chart.Box{
Left: 0,
Top: 0,
Right: 20,
Bottom: 200,
}, BarStyle{
FillColor: drawing.ColorBlack,
})
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 10 20\nL 30 20\nL 30 220\nL 10 220\nL 10 20\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/></svg>", string(data))
}

502
chart.go
View file

@ -1,502 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"errors"
"math"
"sort"
"strings"
"github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
const (
ChartTypeLine = "line"
ChartTypeBar = "bar"
ChartTypePie = "pie"
ChartTypeRadar = "radar"
ChartTypeFunnel = "funnel"
)
const (
ChartOutputSVG = "svg"
ChartOutputPNG = "png"
)
type Point struct {
X int
Y int
}
const labelFontSize = 10
const defaultDotWidth = 2.0
const defaultStrokeWidth = 2.0
var defaultChartWidth = 600
var defaultChartHeight = 400
type ChartOption struct {
// 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 font of chart, the default font is "roboto"
Font *truetype.Font
// 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
YAxisList []YAxisOption
// The width of chart, default width is 600
Width int
// The height of chart, default height is 400
Height int
Parent *Draw
// The padding for chart, default padding is [20, 10, 10, 10]
Padding chart.Box
// The canvas box for chart
Box chart.Box
// The series list
SeriesList SeriesList
// The radar indicator list
RadarIndicators []RadarIndicator
// The background color of chart
BackgroundColor drawing.Color
// The child charts
Children []ChartOption
}
// FillDefault fills the default value for chart option
func (o *ChartOption) FillDefault(theme string) {
t := NewTheme(theme)
// 如果为空,初始化
yAxisCount := 1
for _, series := range o.SeriesList {
if series.YAxisIndex >= yAxisCount {
yAxisCount++
}
}
yAxisList := make([]YAxisOption, yAxisCount)
copy(yAxisList, o.YAxisList)
o.YAxisList = yAxisList
if o.Font == nil {
o.Font, _ = chart.GetDefaultFont()
}
if o.BackgroundColor.IsZero() {
o.BackgroundColor = t.GetBackgroundColor()
}
if o.Padding.IsZero() {
o.Padding = chart.Box{
Top: 10,
Right: 10,
Bottom: 10,
Left: 10,
}
}
// 标题的默认值
if o.Title.Style.FontColor.IsZero() {
o.Title.Style.FontColor = t.GetTextColor()
}
if o.Title.Style.FontSize == 0 {
o.Title.Style.FontSize = 14
}
if o.Title.Style.Font == nil {
o.Title.Style.Font = o.Font
}
if o.Title.Style.Padding.IsZero() {
o.Title.Style.Padding = chart.Box{
Bottom: 10,
}
}
// 副标题
if o.Title.SubtextStyle.FontColor.IsZero() {
o.Title.SubtextStyle.FontColor = o.Title.Style.FontColor.WithAlpha(180)
}
if o.Title.SubtextStyle.FontSize == 0 {
o.Title.SubtextStyle.FontSize = labelFontSize
}
if o.Title.SubtextStyle.Font == nil {
o.Title.SubtextStyle.Font = o.Font
}
o.Legend.theme = theme
if o.Legend.Style.FontSize == 0 {
o.Legend.Style.FontSize = labelFontSize
}
if o.Legend.Left == "" {
o.Legend.Left = PositionCenter
}
// 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]
})
}
// 如果无legend数据则隐藏
if len(strings.Join(o.Legend.Data, "")) == 0 {
o.Legend.Show = FalseFlag()
}
if o.Legend.Style.Font == nil {
o.Legend.Style.Font = o.Font
}
if o.Legend.Style.FontColor.IsZero() {
o.Legend.Style.FontColor = t.GetTextColor()
}
if o.XAxis.Theme == "" {
o.XAxis.Theme = theme
}
o.XAxis.Font = o.Font
}
func (o *ChartOption) getWidth() int {
if o.Width != 0 {
return o.Width
}
if o.Parent != nil {
return o.Parent.Box.Width()
}
return defaultChartWidth
}
func SetDefaultWidth(width int) {
if width > 0 {
defaultChartWidth = width
}
}
func SetDefaultHeight(height int) {
if height > 0 {
defaultChartHeight = height
}
}
func (o *ChartOption) getHeight() int {
if o.Height != 0 {
return o.Height
}
if o.Parent != nil {
return o.Parent.Box.Height()
}
return defaultChartHeight
}
func (o *ChartOption) newYRange(axisIndex int) Range {
min := math.MaxFloat64
max := -math.MaxFloat64
if axisIndex >= len(o.YAxisList) {
axisIndex = 0
}
yAxis := o.YAxisList[axisIndex]
for _, series := range o.SeriesList {
if series.YAxisIndex != axisIndex {
continue
}
for _, item := range series.Data {
if item.Value > max {
max = item.Value
}
if item.Value < min {
min = item.Value
}
}
}
min = min * 0.9
max = max * 1.1
if yAxis.Min != nil {
min = *yAxis.Min
}
if yAxis.Max != nil {
max = *yAxis.Max
}
divideCount := 6
// y轴分设置默认划分为6块
r := NewRange(min, max, divideCount)
// 由于NewRange会重新计算min max
if yAxis.Min != nil {
r.Min = min
}
if yAxis.Max != nil {
r.Max = max
}
return r
}
type basicRenderResult struct {
xRange *Range
yRangeList []*Range
d *Draw
titleBox chart.Box
}
func (r *basicRenderResult) getYRange(index int) *Range {
if index >= len(r.yRangeList) {
index = 0
}
return r.yRangeList[index]
}
// Render renders the chart by option
func Render(opt ChartOption, optFuncs ...OptionFunc) (*Draw, error) {
for _, optFunc := range optFuncs {
optFunc(&opt)
}
if len(opt.SeriesList) == 0 {
return nil, errors.New("series can not be nil")
}
if len(opt.FontFamily) != 0 {
f, err := GetFont(opt.FontFamily)
if err != nil {
return nil, err
}
opt.Font = f
}
opt.FillDefault(opt.Theme)
lineSeries := make([]Series, 0)
barSeries := make([]Series, 0)
isPieChart := false
isRadarChart := false
isFunnelChart := false
for index := range opt.SeriesList {
opt.SeriesList[index].index = index
item := opt.SeriesList[index]
switch item.Type {
case ChartTypePie:
isPieChart = true
case ChartTypeRadar:
isRadarChart = true
case ChartTypeFunnel:
isFunnelChart = true
case ChartTypeBar:
barSeries = append(barSeries, item)
default:
lineSeries = append(lineSeries, item)
}
}
// 如果指定了pie则以pie的形式处理pie不支持多类型图表
// pie不需要axis
// radar 同样处理
if isPieChart ||
isRadarChart ||
isFunnelChart {
opt.XAxis.Hidden = true
for index := range opt.YAxisList {
opt.YAxisList[index].Hidden = true
}
}
result, err := chartBasicRender(&opt)
if err != nil {
return nil, err
}
markPointRenderOptions := make([]markPointRenderOption, 0)
fns := []func() error{
// pie render
func() error {
if !isPieChart {
return nil
}
return pieChartRender(pieChartOption{
SeriesList: opt.SeriesList,
Theme: opt.Theme,
Font: opt.Font,
}, result)
},
// radar render
func() error {
if !isRadarChart {
return nil
}
return radarChartRender(radarChartOption{
SeriesList: opt.SeriesList,
Theme: opt.Theme,
Font: opt.Font,
Indicators: opt.RadarIndicators,
}, result)
},
// funnel render
func() error {
if !isFunnelChart {
return nil
}
return funnelChartRender(funnelChartOption{
SeriesList: opt.SeriesList,
Theme: opt.Theme,
Font: opt.Font,
}, result)
},
// bar render
func() error {
// 如果无bar类型的series
if len(barSeries) == 0 {
return nil
}
options, err := barChartRender(barChartOption{
SeriesList: barSeries,
Theme: opt.Theme,
Font: opt.Font,
}, result)
if err != nil {
return err
}
markPointRenderOptions = append(markPointRenderOptions, options...)
return nil
},
// line render
func() error {
// 如果无line类型的series
if len(lineSeries) == 0 {
return nil
}
options, err := lineChartRender(lineChartOption{
Theme: opt.Theme,
SeriesList: lineSeries,
Font: opt.Font,
}, result)
if err != nil {
return err
}
markPointRenderOptions = append(markPointRenderOptions, options...)
return nil
},
// legend需要在顶层因此此处render
func() error {
_, err := NewLegend(result.d, opt.Legend).Render()
return err
},
// mark point最后render
func() error {
// mark point render不会出错
for _, opt := range markPointRenderOptions {
markPointRender(opt)
}
return nil
},
}
for _, fn := range fns {
err = fn()
if err != nil {
return nil, err
}
}
for _, child := range opt.Children {
child.Parent = result.d
if len(child.Theme) == 0 {
child.Theme = opt.Theme
}
_, err = Render(child)
if err != nil {
return nil, err
}
}
return result.d, nil
}
func chartBasicRender(opt *ChartOption) (*basicRenderResult, error) {
d, err := NewDraw(
DrawOption{
Type: opt.Type,
Parent: opt.Parent,
Width: opt.getWidth(),
Height: opt.getHeight(),
},
BoxOption(opt.Box),
PaddingOption(opt.Padding),
)
if err != nil {
return nil, err
}
if len(opt.YAxisList) > 2 {
return nil, errors.New("y axis should not be gt 2")
}
if opt.Parent == nil {
d.setBackground(opt.getWidth(), opt.getHeight(), opt.BackgroundColor)
}
// 标题
titleBox, err := drawTitle(d, &opt.Title)
if err != nil {
return nil, err
}
xAxisHeight := 0
var xRange *Range
if !opt.XAxis.Hidden {
// xAxis
xAxisHeight, xRange, err = drawXAxis(d, &opt.XAxis, len(opt.YAxisList))
if err != nil {
return nil, err
}
}
yRangeList := make([]*Range, len(opt.YAxisList))
for index, yAxis := range opt.YAxisList {
var yRange *Range
if !yAxis.Hidden {
yRange, err = drawYAxis(d, opt, index, xAxisHeight, chart.Box{
Top: titleBox.Height(),
})
if err != nil {
return nil, err
}
yRangeList[index] = yRange
}
}
return &basicRenderResult{
xRange: xRange,
yRangeList: yRangeList,
d: d,
titleBox: titleBox,
}, nil
}

View file

@ -23,10 +23,49 @@
package charts package charts
import ( import (
"sort"
"github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
) )
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 child charts
Children []ChartOption
}
// OptionFunc option function // OptionFunc option function
type OptionFunc func(opt *ChartOption) type OptionFunc func(opt *ChartOption)
@ -63,6 +102,13 @@ func TitleOptionFunc(title TitleOption) OptionFunc {
} }
} }
// TitleTextOptionFunc set title text of chart
func TitleTextOptionFunc(text string) OptionFunc {
return func(opt *ChartOption) {
opt.Title.Text = text
}
}
// LegendOptionFunc set legend of chart // LegendOptionFunc set legend of chart
func LegendOptionFunc(legend LegendOption) OptionFunc { func LegendOptionFunc(legend LegendOption) OptionFunc {
return func(opt *ChartOption) { return func(opt *ChartOption) {
@ -70,6 +116,13 @@ func LegendOptionFunc(legend LegendOption) OptionFunc {
} }
} }
// 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 // XAxisOptionFunc set x axis of chart
func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc { func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc {
return func(opt *ChartOption) { return func(opt *ChartOption) {
@ -77,10 +130,24 @@ func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc {
} }
} }
// 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 // YAxisOptionFunc set y axis of chart, support two y axis
func YAxisOptionFunc(yAxisOption ...YAxisOption) OptionFunc { func YAxisOptionFunc(yAxisOption ...YAxisOption) OptionFunc {
return func(opt *ChartOption) { return func(opt *ChartOption) {
opt.YAxisList = yAxisOption opt.YAxisOptions = yAxisOption
}
}
// YAxisDataOptionFunc set y axis data of chart
func YAxisDataOptionFunc(data []string) OptionFunc {
return func(opt *ChartOption) {
opt.YAxisOptions = NewYAxisOptions(data)
} }
} }
@ -99,19 +166,28 @@ func HeightOptionFunc(height int) OptionFunc {
} }
// PaddingOptionFunc set padding of chart // PaddingOptionFunc set padding of chart
func PaddingOptionFunc(padding chart.Box) OptionFunc { func PaddingOptionFunc(padding Box) OptionFunc {
return func(opt *ChartOption) { return func(opt *ChartOption) {
opt.Padding = padding opt.Padding = padding
} }
} }
// BoxOptionFunc set box of chart // BoxOptionFunc set box of chart
func BoxOptionFunc(box chart.Box) OptionFunc { func BoxOptionFunc(box Box) OptionFunc {
return func(opt *ChartOption) { return func(opt *ChartOption) {
opt.Box = box 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 // ChildOptionFunc add child chart
func ChildOptionFunc(child ...ChartOption) OptionFunc { func ChildOptionFunc(child ...ChartOption) OptionFunc {
return func(opt *ChartOption) { return func(opt *ChartOption) {
@ -123,61 +199,143 @@ func ChildOptionFunc(child ...ChartOption) OptionFunc {
} }
// RadarIndicatorOptionFunc set radar indicator of chart // RadarIndicatorOptionFunc set radar indicator of chart
func RadarIndicatorOptionFunc(radarIndicator ...RadarIndicator) OptionFunc { func RadarIndicatorOptionFunc(names []string, values []float64) OptionFunc {
return func(opt *ChartOption) { return func(opt *ChartOption) {
opt.RadarIndicators = radarIndicator if len(names) != len(values) {
return
}
indicators := make([]RadarIndicator, len(names))
for index, name := range names {
indicators[index] = RadarIndicator{
Name: name,
Max: values[index],
}
}
opt.RadarIndicators = indicators
} }
} }
// BackgroundColorOptionFunc set background color of chart // BackgroundColorOptionFunc set background color of chart
func BackgroundColorOptionFunc(color drawing.Color) OptionFunc { func BackgroundColorOptionFunc(color Color) OptionFunc {
return func(opt *ChartOption) { return func(opt *ChartOption) {
opt.BackgroundColor = color opt.BackgroundColor = color
} }
} }
// LineRender line chart render // MarkLineOptionFunc set mark line for series of chart
func LineRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { func MarkLineOptionFunc(seriesIndex int, markLineTypes ...string) OptionFunc {
seriesList := make(SeriesList, len(values)) return func(opt *ChartOption) {
for index, value := range values { if len(opt.SeriesList) <= seriesIndex {
seriesList[index] = NewSeriesFromValues(value, ChartTypeLine) 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, _ = chart.GetDefaultFont()
}
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{ return Render(ChartOption{
SeriesList: seriesList, SeriesList: seriesList,
}, opts...) }, opts...)
} }
// BarRender bar chart render // BarRender bar chart render
func BarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { func BarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
seriesList := make(SeriesList, len(values)) seriesList := NewSeriesListDataFromValues(values, ChartTypeBar)
for index, value := range values { return Render(ChartOption{
seriesList[index] = NewSeriesFromValues(value, ChartTypeBar) SeriesList: seriesList,
} }, opts...)
}
// HorizontalBarRender horizontal bar chart render
func HorizontalBarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
seriesList := NewSeriesListDataFromValues(values, ChartTypeHorizontalBar)
return Render(ChartOption{ return Render(ChartOption{
SeriesList: seriesList, SeriesList: seriesList,
}, opts...) }, opts...)
} }
// PieRender pie chart render // PieRender pie chart render
func PieRender(values []float64, opts ...OptionFunc) (*Draw, error) { func PieRender(values []float64, opts ...OptionFunc) (*Painter, error) {
return Render(ChartOption{ return Render(ChartOption{
SeriesList: NewPieSeriesList(values), SeriesList: NewPieSeriesList(values),
}, opts...) }, opts...)
} }
// RadarRender radar chart render // RadarRender radar chart render
func RadarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { func RadarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) {
seriesList := make(SeriesList, len(values)) seriesList := NewSeriesListDataFromValues(values, ChartTypeRadar)
for index, value := range values {
seriesList[index] = NewSeriesFromValues(value, ChartTypeRadar)
}
return Render(ChartOption{ return Render(ChartOption{
SeriesList: seriesList, SeriesList: seriesList,
}, opts...) }, opts...)
} }
// FunnelRender funnel chart render // FunnelRender funnel chart render
func FunnelRender(values []float64, opts ...OptionFunc) (*Draw, error) { func FunnelRender(values []float64, opts ...OptionFunc) (*Painter, error) {
seriesList := make(SeriesList, len(values)) seriesList := make(SeriesList, len(values))
for index, value := range values { for index, value := range values {
seriesList[index] = NewSeriesFromValues([]float64{ seriesList[index] = NewSeriesFromValues([]float64{

View file

@ -1,238 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestOptionFunc(t *testing.T) {
assert := assert.New(t)
fns := []OptionFunc{
TypeOptionFunc(ChartOutputPNG),
FontFamilyOptionFunc("fontFamily"),
ThemeOptionFunc("black"),
TitleOptionFunc(TitleOption{
Text: "title",
}),
LegendOptionFunc(LegendOption{
Data: []string{
"a",
"b",
},
}),
XAxisOptionFunc(NewXAxisOption([]string{
"Mon",
"Tue",
})),
YAxisOptionFunc(YAxisOption{
Min: NewFloatPoint(0),
Max: NewFloatPoint(100),
}),
WidthOptionFunc(400),
HeightOptionFunc(300),
PaddingOptionFunc(chart.Box{
Top: 10,
}),
BoxOptionFunc(chart.Box{
Left: 0,
Right: 300,
}),
ChildOptionFunc(ChartOption{}),
RadarIndicatorOptionFunc(RadarIndicator{
Min: 0,
Max: 10,
}),
BackgroundColorOptionFunc(drawing.ColorBlack),
}
opt := ChartOption{}
for _, fn := range fns {
fn(&opt)
}
assert.Equal("png", opt.Type)
assert.Equal("fontFamily", opt.FontFamily)
assert.Equal("black", opt.Theme)
assert.Equal(TitleOption{
Text: "title",
}, opt.Title)
assert.Equal(LegendOption{
Data: []string{
"a",
"b",
},
}, opt.Legend)
assert.Equal(NewXAxisOption([]string{
"Mon",
"Tue",
}), opt.XAxis)
assert.Equal([]YAxisOption{
{
Min: NewFloatPoint(0),
Max: NewFloatPoint(100),
},
}, opt.YAxisList)
assert.Equal(400, opt.Width)
assert.Equal(300, opt.Height)
assert.Equal(chart.Box{
Top: 10,
}, opt.Padding)
assert.Equal(chart.Box{
Left: 0,
Right: 300,
}, opt.Box)
assert.Equal(1, len(opt.Children))
assert.Equal([]RadarIndicator{
{
Min: 0,
Max: 10,
},
}, opt.RadarIndicators)
assert.Equal(drawing.ColorBlack, opt.BackgroundColor)
}
func TestLineRender(t *testing.T) {
assert := assert.New(t)
d, err := LineRender([][]float64{
{
1,
2,
3,
},
{
1,
5,
2,
},
},
XAxisOptionFunc(NewXAxisOption([]string{
"01",
"02",
"03",
})),
)
assert.Nil(err)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 0\nL 600 0\nL 600 400\nL 0 400\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><path d=\"M 50 365\nL 590 365\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 50 365\nL 50 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 230 365\nL 230 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 410 365\nL 410 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 590 365\nL 590 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"132\" y=\"382\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01</text><text x=\"312\" y=\"382\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">02</text><text x=\"492\" y=\"382\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">03</text><path d=\"M 50 10\nL 50 365\" style=\"stroke-width:1;stroke:none;fill:none\"/><path d=\"M 50 10\nL 590 10\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 70\nL 590 70\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 129\nL 590 129\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 188\nL 590 188\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 247\nL 590 247\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 306\nL 590 306\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><text x=\"36\" y=\"369\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">0</text><text x=\"36\" y=\"310\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">2</text><text x=\"36\" y=\"251\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">4</text><text x=\"36\" y=\"192\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">6</text><text x=\"36\" y=\"133\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">8</text><text x=\"29\" y=\"74\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">10</text><text x=\"29\" y=\"14\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">12</text><path d=\"M 140 336\nL 320 306\nL 499 277\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"140\" cy=\"336\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"320\" cy=\"306\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"499\" cy=\"277\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"M 140 336\nL 320 218\nL 499 306\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:none\"/><circle cx=\"140\" cy=\"336\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"320\" cy=\"218\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"499\" cy=\"306\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/></svg>", string(data))
}
func TestBarRender(t *testing.T) {
assert := assert.New(t)
d, err := BarRender([][]float64{
{
1,
2,
3,
},
{
1,
5,
2,
},
},
XAxisOptionFunc(NewXAxisOption([]string{
"01",
"02",
"03",
})),
)
assert.Nil(err)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 0\nL 600 0\nL 600 400\nL 0 400\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><path d=\"M 50 365\nL 590 365\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 50 365\nL 50 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 230 365\nL 230 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 410 365\nL 410 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 590 365\nL 590 370\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"132\" y=\"382\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01</text><text x=\"312\" y=\"382\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">02</text><text x=\"492\" y=\"382\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">03</text><path d=\"M 50 10\nL 50 365\" style=\"stroke-width:1;stroke:none;fill:none\"/><path d=\"M 50 10\nL 590 10\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 70\nL 590 70\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 129\nL 590 129\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 188\nL 590 188\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 247\nL 590 247\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 306\nL 590 306\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><text x=\"36\" y=\"369\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">0</text><text x=\"36\" y=\"310\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">2</text><text x=\"36\" y=\"251\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">4</text><text x=\"36\" y=\"192\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">6</text><text x=\"36\" y=\"133\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">8</text><text x=\"29\" y=\"74\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">10</text><text x=\"29\" y=\"14\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">12</text><path d=\"M 60 336\nL 137 336\nL 137 364\nL 60 364\nL 60 336\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 240 306\nL 317 306\nL 317 364\nL 240 364\nL 240 306\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 420 277\nL 497 277\nL 497 364\nL 420 364\nL 420 277\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 142 336\nL 219 336\nL 219 364\nL 142 364\nL 142 336\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"M 322 218\nL 399 218\nL 399 364\nL 322 364\nL 322 218\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"M 502 306\nL 579 306\nL 579 364\nL 502 364\nL 502 306\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/></svg>", string(data))
}
func TestPieRender(t *testing.T) {
assert := assert.New(t)
d, err := PieRender([]float64{
1,
3,
5,
})
assert.Nil(err)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 0\nL 600 0\nL 600 400\nL 0 400\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><path d=\"M 300 200\nL 300 48\nA 152 152 40.00 0 1 397 84\nL 300 200\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 300 200\nL 397 84\nA 152 152 120.00 0 1 351 342\nL 300 200\nZ\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"M 300 200\nL 351 342\nA 152 152 200.00 1 1 300 48\nL 300 200\nZ\" style=\"stroke-width:1;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/></svg>", string(data))
}
func TestRadarRender(t *testing.T) {
assert := assert.New(t)
d, err := RadarRender([][]float64{
{
1,
2,
3,
},
{
1,
5,
2,
},
},
RadarIndicatorOptionFunc([]RadarIndicator{
{
Name: "A",
Min: 0,
Max: 10,
},
{
Name: "B",
Min: 0,
Max: 10,
},
{
Name: "C",
Min: 0,
Max: 10,
},
}...),
)
assert.Nil(err)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 0\nL 600 0\nL 600 400\nL 0 400\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><path d=\"M 300 170\nL 325 214\nL 275 215\nL 300 170\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 300 140\nL 351 229\nL 249 230\nL 300 140\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 300 110\nL 377 244\nL 223 245\nL 300 110\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 300 80\nL 403 259\nL 197 260\nL 300 80\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 300 50\nL 429 274\nL 171 275\nL 300 50\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 300 200\nL 300 50\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 300 200\nL 429 274\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 300 200\nL 171 275\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><text x=\"296\" y=\"43\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">A</text><text x=\"434\" y=\"279\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">B</text><text x=\"157\" y=\"280\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">C</text><path d=\"M 300 185\nL 325 214\nL 262 222\nL 300 185\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><path d=\"M 300 185\nL 325 214\nL 262 222\nL 300 185\" style=\"stroke-width:0;stroke:none;fill:rgba(84,112,198,0.1)\"/><circle cx=\"300\" cy=\"185\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"325\" cy=\"214\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"262\" cy=\"222\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"M 300 185\nL 364 237\nL 275 215\nL 300 185\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:none\"/><path d=\"M 300 185\nL 364 237\nL 275 215\nL 300 185\" style=\"stroke-width:0;stroke:none;fill:rgba(145,204,117,0.1)\"/><circle cx=\"300\" cy=\"185\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"364\" cy=\"237\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"275\" cy=\"215\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/></svg>", string(data))
}
func TestFunnelRender(t *testing.T) {
assert := assert.New(t)
d, err := FunnelRender([]float64{
5,
3,
1,
})
assert.Nil(err)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"600\" height=\"400\">\\n<path d=\"M 0 0\nL 600 0\nL 600 400\nL 0 400\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/><path d=\"M 10 10\nL 590 10\nL 474 135\nL 126 135\nL 10 10\" style=\"stroke-width:0;stroke:none;fill:rgba(84,112,198,1.0)\"/><text x=\"280\" y=\"72\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">(100%)</text><path d=\"M 126 137\nL 474 137\nL 358 262\nL 242 262\nL 126 137\" style=\"stroke-width:0;stroke:none;fill:rgba(145,204,117,1.0)\"/><text x=\"284\" y=\"199\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">(60%)</text><path d=\"M 242 264\nL 358 264\nL 300 389\nL 300 389\nL 242 264\" style=\"stroke-width:0;stroke:none;fill:rgba(250,200,88,1.0)\"/><text x=\"284\" y=\"326\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">(20%)</text></svg>", string(data))
}

File diff suppressed because one or more lines are too long

418
charts.go Normal file
View file

@ -0,0 +1,418 @@
// 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"
"sort"
)
const labelFontSize = 10
const defaultDotWidth = 2.0
const defaultStrokeWidth = 2.0
var defaultChartWidth = 600
var defaultChartHeight = 400
func SetDefaultWidth(width int) {
if width > 0 {
defaultChartWidth = width
}
}
func SetDefaultHeight(height int) {
if height > 0 {
defaultChartHeight = height
}
}
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))
}
if len(opt.LegendOption.Data) != 0 {
if opt.LegendOption.Theme == nil {
opt.LegendOption.Theme = opt.Theme
}
_, err := NewLegendPainter(p, opt.LegendOption).Render()
if err != nil {
return nil, err
}
}
// 如果有标题
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
}
p = p.Child(PainterPaddingOption(Box{
// 标题下留白
Top: titleBox.Height() + 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]
}
max, min := opt.SeriesList.GetMaxMin(index)
if yAxisOption.Min != nil {
min = *yAxisOption.Min
}
if yAxisOption.Max != nil {
max = *yAxisOption.Max
}
r := NewRange(AxisRangeOption{
Min: min,
Max: max,
// 高度需要减去x轴的高度
Size: rangeHeight,
// 分隔数量
DivideCount: defaultAxisDivideCount,
})
result.axisRanges[index] = r
if yAxisOption.Theme == nil {
yAxisOption.Theme = opt.Theme
}
if !opt.axisReversed {
yAxisOption.Data = r.Values()
} else {
yAxisOption.isCategoryAxis = true
opt.XAxis.Data = r.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.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,
}
if isChild {
renderOpt.backgroundIsFilled = true
}
if len(pieSeriesList) != 0 ||
len(radarSeriesList) != 0 ||
len(funnelSeriesList) != 0 {
renderOpt.XAxis.Show = FalseFlag()
renderOpt.YAxisOptions = []YAxisOption{
{
Show: FalseFlag(),
},
}
}
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,
}).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,
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,
}).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
}

372
draw.go
View file

@ -1,372 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"bytes"
"errors"
"math"
"github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
const (
PositionLeft = "left"
PositionRight = "right"
PositionCenter = "center"
PositionTop = "top"
PositionBottom = "bottom"
)
const (
OrientHorizontal = "horizontal"
OrientVertical = "vertical"
)
type Draw struct {
// Render
Render chart.Renderer
// The canvas box
Box chart.Box
// The font for draw
Font *truetype.Font
// The parent of draw
parent *Draw
}
type DrawOption struct {
// Draw type, "svg" or "png", default type is "svg"
Type string
// Parent of draw
Parent *Draw
// The width of draw canvas
Width int
// The height of draw canvas
Height int
}
type Option func(*Draw) error
// PaddingOption sets the padding of draw canvas
func PaddingOption(padding chart.Box) Option {
return func(d *Draw) error {
d.Box.Left += padding.Left
d.Box.Top += padding.Top
d.Box.Right -= padding.Right
d.Box.Bottom -= padding.Bottom
return nil
}
}
// BoxOption set the box of draw canvas
func BoxOption(box chart.Box) Option {
return func(d *Draw) error {
if box.IsZero() {
return nil
}
d.Box = box
return nil
}
}
// NewDraw returns a new draw canvas
func NewDraw(opt DrawOption, opts ...Option) (*Draw, error) {
if opt.Parent == nil && (opt.Width <= 0 || opt.Height <= 0) {
return nil, errors.New("parent and width/height can not be nil")
}
font, _ := chart.GetDefaultFont()
d := &Draw{
Font: font,
}
width := opt.Width
height := opt.Height
if opt.Parent != nil {
d.parent = opt.Parent
d.Render = d.parent.Render
d.Box = opt.Parent.Box.Clone()
}
if width != 0 && height != 0 {
d.Box.Right = width + d.Box.Left
d.Box.Bottom = height + d.Box.Top
}
// 创建render
if d.parent == nil {
fn := chart.SVG
if opt.Type == ChartOutputPNG {
fn = chart.PNG
}
r, err := fn(d.Box.Right, d.Box.Bottom)
if err != nil {
return nil, err
}
d.Render = r
}
for _, o := range opts {
err := o(d)
if err != nil {
return nil, err
}
}
return d, nil
}
// Parent returns the parent of draw
func (d *Draw) Parent() *Draw {
return d.parent
}
// Top returns the top parent of draw
func (d *Draw) Top() *Draw {
if d.parent == nil {
return nil
}
t := d.parent
// 限制最多查询次数,避免嵌套引用
for i := 50; i > 0; i-- {
if t.parent == nil {
break
}
t = t.parent
}
return t
}
// Bytes returns the data of draw canvas
func (d *Draw) Bytes() ([]byte, error) {
buffer := bytes.Buffer{}
err := d.Render.Save(&buffer)
if err != nil {
return nil, err
}
return buffer.Bytes(), err
}
func (d *Draw) moveTo(x, y int) {
d.Render.MoveTo(x+d.Box.Left, y+d.Box.Top)
}
func (d *Draw) arcTo(cx, cy int, rx, ry, startAngle, delta float64) {
d.Render.ArcTo(cx+d.Box.Left, cy+d.Box.Top, rx, ry, startAngle, delta)
}
func (d *Draw) lineTo(x, y int) {
d.Render.LineTo(x+d.Box.Left, y+d.Box.Top)
}
func (d *Draw) pin(x, y, width int) {
r := float64(width) / 2
y -= width / 4
angle := chart.DegreesToRadians(15)
startAngle := math.Pi/2 + angle
delta := 2*math.Pi - 2*angle
d.arcTo(x, y, r, r, startAngle, delta)
d.lineTo(x, y)
d.Render.Close()
d.Render.FillStroke()
startX := x - int(r)
startY := y
endX := x + int(r)
endY := y
d.moveTo(startX, startY)
left := d.Box.Left
top := d.Box.Top
cx := x
cy := y + int(r*2.5)
d.Render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top)
d.Render.Close()
d.Render.Fill()
}
func (d *Draw) arrowLeft(x, y, width, height int) {
d.arrow(x, y, width, height, PositionLeft)
}
func (d *Draw) arrowRight(x, y, width, height int) {
d.arrow(x, y, width, height, PositionRight)
}
func (d *Draw) arrowTop(x, y, width, height int) {
d.arrow(x, y, width, height, PositionTop)
}
func (d *Draw) arrowBottom(x, y, width, height int) {
d.arrow(x, y, width, height, PositionBottom)
}
func (d *Draw) arrow(x, y, width, height int, direction string) {
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
}
d.moveTo(x0, y0)
d.lineTo(x0+halfWidth, y1)
d.lineTo(x1, y0)
d.lineTo(x0+halfWidth, y+dy)
d.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
}
d.moveTo(x0, y0)
d.lineTo(x1, y0+halfHeight)
d.lineTo(x0, y0+height)
d.lineTo(x0+dx, y0+halfHeight)
d.lineTo(x0, y0)
}
d.Render.FillStroke()
}
func (d *Draw) makeLine(x, y, width int) {
arrowWidth := 16
arrowHeight := 10
endX := x + width
d.circle(3, x, y)
d.Render.Fill()
d.moveTo(x+5, y)
d.lineTo(endX-arrowWidth, y)
d.Render.Stroke()
d.Render.SetStrokeDashArray([]float64{})
d.arrowRight(endX, y, arrowWidth, arrowHeight)
}
func (d *Draw) circle(radius float64, x, y int) {
d.Render.Circle(radius, x+d.Box.Left, y+d.Box.Top)
}
func (d *Draw) text(body string, x, y int) {
d.Render.Text(body, x+d.Box.Left, y+d.Box.Top)
}
func (d *Draw) textFit(body string, x, y, width int, style chart.Style) chart.Box {
style.TextWrap = chart.TextWrapWord
r := d.Render
lines := chart.Text.WrapFit(r, body, width, style)
style.WriteTextOptionsToRenderer(r)
var output chart.Box
for index, line := range lines {
x0 := x
y0 := y + output.Height()
d.text(line, x0, y0)
lineBox := r.MeasureText(line)
output.Right = chart.MaxInt(lineBox.Right, output.Right)
output.Bottom += lineBox.Height()
if index < len(lines)-1 {
output.Bottom += +style.GetTextLineSpacing()
}
}
return output
}
func (d *Draw) measureTextFit(body string, x, y, width int, style chart.Style) chart.Box {
style.TextWrap = chart.TextWrapWord
r := d.Render
lines := chart.Text.WrapFit(r, body, width, style)
style.WriteTextOptionsToRenderer(r)
return chart.Text.MeasureLines(r, lines, style)
}
func (d *Draw) lineStroke(points []Point, style LineStyle) {
s := style.Style()
if !s.ShouldDrawStroke() {
return
}
r := d.Render
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
for index, point := range points {
x := point.X
y := point.Y
if index == 0 {
d.moveTo(x, y)
} else {
d.lineTo(x, y)
}
}
r.Stroke()
}
func (d *Draw) setBackground(width, height int, color drawing.Color) {
r := d.Render
s := chart.Style{
FillColor: color,
}
s.WriteToRenderer(r)
r.MoveTo(0, 0)
r.LineTo(width, 0)
r.LineTo(width, height)
r.LineTo(0, height)
r.LineTo(0, 0)
r.FillStroke()
}
func (d *Draw) polygon(center Point, radius float64, sides int) {
points := getPolygonPoints(center, radius, sides)
for i, p := range points {
if i == 0 {
d.moveTo(p.X, p.Y)
} else {
d.lineTo(p.X, p.Y)
}
}
d.lineTo(points[0].X, points[0].Y)
d.Render.Stroke()
}
func (d *Draw) fill(points []Point, s chart.Style) {
if !s.ShouldDrawFill() {
return
}
r := d.Render
var x, y int
s.GetFillOptions().WriteDrawingOptionsToRenderer(r)
for index, point := range points {
x = point.X
y = point.Y
if index == 0 {
d.moveTo(x, y)
} else {
d.lineTo(x, y)
}
}
r.Fill()
}

View file

@ -130,6 +130,7 @@ type EChartsXAxisData struct {
BoundaryGap *bool `json:"boundaryGap"` BoundaryGap *bool `json:"boundaryGap"`
SplitNumber int `json:"splitNumber"` SplitNumber int `json:"splitNumber"`
Data []string `json:"data"` Data []string `json:"data"`
Type string `json:"type"`
} }
type EChartsXAxis struct { type EChartsXAxis struct {
Data []EChartsXAxisData Data []EChartsXAxisData
@ -155,6 +156,7 @@ type EChartsYAxisData struct {
Color string `json:"color"` Color string `json:"color"`
} `json:"lineStyle"` } `json:"lineStyle"`
} `json:"axisLine"` } `json:"axisLine"`
Data []string `json:"data"`
} }
type EChartsYAxis struct { type EChartsYAxis struct {
Data []EChartsYAxisData `json:"data"` Data []EChartsYAxisData `json:"data"`
@ -354,10 +356,10 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList {
} }
} }
seriesList = append(seriesList, Series{ seriesList = append(seriesList, Series{
Type: item.Type, Type: item.Type,
Data: data, Data: data,
YAxisIndex: item.YAxisIndex, AxisIndex: item.YAxisIndex,
Style: item.ItemStyle.ToStyle(), Style: item.ItemStyle.ToStyle(),
Label: SeriesLabel{ Label: SeriesLabel{
Color: parseColor(item.Label.Color), Color: parseColor(item.Label.Color),
Show: item.Label.Show, Show: item.Label.Show,
@ -419,26 +421,32 @@ func (eo *EChartsOption) ToOption() ChartOption {
if len(fontFamily) == 0 { if len(fontFamily) == 0 {
fontFamily = eo.Title.TextStyle.FontFamily fontFamily = eo.Title.TextStyle.FontFamily
} }
titleTextStyle := eo.Title.TextStyle.ToStyle()
titleSubtextStyle := eo.Title.SubtextStyle.ToStyle()
legendTextStyle := eo.Legend.TextStyle.ToStyle()
o := ChartOption{ o := ChartOption{
Type: eo.Type, Type: eo.Type,
FontFamily: fontFamily, FontFamily: fontFamily,
Theme: eo.Theme, Theme: eo.Theme,
Title: TitleOption{ Title: TitleOption{
Text: eo.Title.Text, Text: eo.Title.Text,
Subtext: eo.Title.Subtext, Subtext: eo.Title.Subtext,
Style: eo.Title.TextStyle.ToStyle(), FontColor: titleTextStyle.FontColor,
SubtextStyle: eo.Title.SubtextStyle.ToStyle(), FontSize: titleTextStyle.FontSize,
Left: string(eo.Title.Left), SubtextFontSize: titleSubtextStyle.FontSize,
Top: string(eo.Title.Top), SubtextFontColor: titleSubtextStyle.FontColor,
Left: string(eo.Title.Left),
Top: string(eo.Title.Top),
}, },
Legend: LegendOption{ Legend: LegendOption{
Show: eo.Legend.Show, Show: eo.Legend.Show,
Style: eo.Legend.TextStyle.ToStyle(), FontSize: legendTextStyle.FontSize,
Data: eo.Legend.Data, FontColor: legendTextStyle.FontColor,
Left: string(eo.Legend.Left), Data: eo.Legend.Data,
Top: string(eo.Legend.Top), Left: string(eo.Legend.Left),
Align: eo.Legend.Align, Top: string(eo.Legend.Top),
Orient: eo.Legend.Orient, Align: eo.Legend.Align,
Orient: eo.Legend.Orient,
}, },
RadarIndicators: eo.Radar.Indicator, RadarIndicators: eo.Radar.Indicator,
Width: eo.Width, Width: eo.Width,
@ -447,6 +455,21 @@ func (eo *EChartsOption) ToOption() ChartOption {
Box: eo.Box, Box: eo.Box,
SeriesList: eo.Series.ToSeriesList(), 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 { if len(eo.XAxis.Data) != 0 {
xAxisData := eo.XAxis.Data[0] xAxisData := eo.XAxis.Data[0]
o.XAxis = XAxisOption{ o.XAxis = XAxisOption{
@ -462,9 +485,10 @@ func (eo *EChartsOption) ToOption() ChartOption {
Max: item.Max, Max: item.Max,
Formatter: item.AxisLabel.Formatter, Formatter: item.AxisLabel.Formatter,
Color: parseColor(item.AxisLine.LineStyle.Color), Color: parseColor(item.AxisLine.LineStyle.Color),
Data: item.Data,
} }
} }
o.YAxisList = yAxisOptions o.YAxisOptions = yAxisOptions
if len(eo.Children) != 0 { if len(eo.Children) != 0 {
o.Children = make([]ChartOption, len(eo.Children)) o.Children = make([]ChartOption, len(eo.Children))

View file

@ -1,592 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestEChartsPosition(t *testing.T) {
assert := assert.New(t)
var p EChartsPosition
err := p.UnmarshalJSON([]byte("12"))
assert.Nil(err)
assert.Equal("12", string(p))
err = p.UnmarshalJSON([]byte(`"12%"`))
assert.Nil(err)
assert.Equal("12%", string(p))
}
func TestEChartStyle(t *testing.T) {
assert := assert.New(t)
s := EChartStyle{
Color: "#aaa",
}
r := drawing.Color{
R: 170,
G: 170,
B: 170,
A: 255,
}
assert.Equal(chart.Style{
FillColor: r,
FontColor: r,
StrokeColor: r,
}, s.ToStyle())
}
func TestEChartsXAxis(t *testing.T) {
assert := assert.New(t)
ex := EChartsXAxis{}
err := ex.UnmarshalJSON([]byte(`{
"boundaryGap": false,
"splitNumber": 5,
"data": [
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun"
]
}`))
assert.Nil(err)
assert.Equal(EChartsXAxis{
Data: []EChartsXAxisData{
{
BoundaryGap: FalseFlag(),
SplitNumber: 5,
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
},
},
}, ex)
}
func TestEChartsYAxis(t *testing.T) {
assert := assert.New(t)
ey := EChartsYAxis{}
err := ey.UnmarshalJSON([]byte(`{
"min": 1,
"max": 10,
"axisLabel": {
"formatter": "ab"
}
}`))
assert.Nil(err)
assert.Equal(EChartsYAxis{
Data: []EChartsYAxisData{
{
Min: NewFloatPoint(1),
Max: NewFloatPoint(10),
AxisLabel: EChartsAxisLabel{
Formatter: "ab",
},
},
},
}, ey)
ey = EChartsYAxis{}
err = ey.UnmarshalJSON([]byte(`[
{
"min": 1,
"max": 10,
"axisLabel": {
"formatter": "ab"
}
},
{
"min": 2,
"max": 20,
"axisLabel": {
"formatter": "cd"
}
}
]`))
assert.Nil(err)
assert.Equal(EChartsYAxis{
Data: []EChartsYAxisData{
{
Min: NewFloatPoint(1),
Max: NewFloatPoint(10),
AxisLabel: EChartsAxisLabel{
Formatter: "ab",
},
},
{
Min: NewFloatPoint(2),
Max: NewFloatPoint(20),
AxisLabel: EChartsAxisLabel{
Formatter: "cd",
},
},
},
}, ey)
}
func TestEChartsPadding(t *testing.T) {
assert := assert.New(t)
ep := EChartsPadding{}
err := ep.UnmarshalJSON([]byte(`10`))
assert.Nil(err)
assert.Equal(EChartsPadding{
Box: chart.Box{
Top: 10,
Right: 10,
Bottom: 10,
Left: 10,
},
}, ep)
ep = EChartsPadding{}
err = ep.UnmarshalJSON([]byte(`[10, 20]`))
assert.Nil(err)
assert.Equal(EChartsPadding{
Box: chart.Box{
Top: 10,
Right: 20,
Bottom: 10,
Left: 20,
},
}, ep)
ep = EChartsPadding{}
err = ep.UnmarshalJSON([]byte(`[10, 20, 30]`))
assert.Nil(err)
assert.Equal(EChartsPadding{
Box: chart.Box{
Top: 10,
Right: 20,
Bottom: 30,
Left: 20,
},
}, ep)
ep = EChartsPadding{}
err = ep.UnmarshalJSON([]byte(`[10, 20, 30, 40]`))
assert.Nil(err)
assert.Equal(EChartsPadding{
Box: chart.Box{
Top: 10,
Right: 20,
Bottom: 30,
Left: 40,
},
}, ep)
}
func TestEChartsLegend(t *testing.T) {
assert := assert.New(t)
el := EChartsLegend{}
err := json.Unmarshal([]byte(`{
"data": ["a", "b", "c"],
"align": "right",
"padding": [10],
"left": "20%",
"top": 10
}`), &el)
assert.Nil(err)
assert.Equal(EChartsLegend{
Data: []string{
"a",
"b",
"c",
},
Align: "right",
Padding: EChartsPadding{
Box: chart.Box{
Left: 10,
Top: 10,
Right: 10,
Bottom: 10,
},
},
Left: EChartsPosition("20%"),
Top: EChartsPosition("10"),
}, el)
}
func TestEChartsSeriesData(t *testing.T) {
assert := assert.New(t)
esd := EChartsSeriesData{}
err := esd.UnmarshalJSON([]byte(`123`))
assert.Nil(err)
assert.Equal(EChartsSeriesData{
Value: NewEChartsSeriesDataValue(123),
}, esd)
esd = EChartsSeriesData{}
err = esd.UnmarshalJSON([]byte(`2.1`))
assert.Nil(err)
assert.Equal(EChartsSeriesData{
Value: NewEChartsSeriesDataValue(2.1),
}, esd)
esd = EChartsSeriesData{}
err = esd.UnmarshalJSON([]byte(`{
"value": 123.12,
"name": "test",
"itemStyle": {
"color": "#aaa"
}
}`))
assert.Nil(err)
assert.Equal(EChartsSeriesData{
Value: NewEChartsSeriesDataValue(123.12),
Name: "test",
ItemStyle: EChartStyle{
Color: "#aaa",
},
}, esd)
}
func TestEChartsSeries(t *testing.T) {
assert := assert.New(t)
esList := make([]EChartsSeries, 0)
err := json.Unmarshal([]byte(`[
{
"name": "Email",
"data": [
120,
132
]
},
{
"name": "Union Ads",
"type": "bar",
"data": [
220,
182
]
}
]`), &esList)
assert.Nil(err)
assert.Equal([]EChartsSeries{
{
Name: "Email",
Data: []EChartsSeriesData{
{
Value: NewEChartsSeriesDataValue(120),
},
{
Value: NewEChartsSeriesDataValue(132),
},
},
},
{
Name: "Union Ads",
Type: "bar",
Data: []EChartsSeriesData{
{
Value: NewEChartsSeriesDataValue(220),
},
{
Value: NewEChartsSeriesDataValue(182),
},
},
},
}, esList)
}
func TestEChartsMarkData(t *testing.T) {
assert := assert.New(t)
emd := EChartsMarkData{}
err := emd.UnmarshalJSON([]byte(`{"type": "average"}`))
assert.Nil(err)
assert.Equal("average", emd.Type)
emd = EChartsMarkData{}
err = emd.UnmarshalJSON([]byte(`[{}, {"type": "average"}]`))
assert.Nil(err)
assert.Equal("average", emd.Type)
}
func TestEChartsMarkPoint(t *testing.T) {
assert := assert.New(t)
p := EChartsMarkPoint{}
err := json.Unmarshal([]byte(`{
"symbolSize": 30,
"data": [
{
"type": "max"
},
{
"type": "min"
}
]
}`), &p)
assert.Nil(err)
assert.Equal(SeriesMarkPoint{
SymbolSize: 30,
Data: []SeriesMarkData{
{
Type: "max",
},
{
Type: "min",
},
},
}, p.ToSeriesMarkPoint())
}
func TestEChartsMarkLine(t *testing.T) {
assert := assert.New(t)
l := EChartsMarkLine{}
err := json.Unmarshal([]byte(`{
"data": [
{
"type": "max"
},
{
"type": "min"
}
]
}`), &l)
assert.Nil(err)
assert.Equal(SeriesMarkLine{
Data: []SeriesMarkData{
{
Type: "max",
},
{
Type: "min",
},
},
}, l.ToSeriesMarkLine())
}
func TestEChartsTextStyle(t *testing.T) {
assert := assert.New(t)
s := EChartsTextStyle{
Color: "#aaa",
FontFamily: "test",
FontSize: 14,
}
assert.Equal(chart.Style{
FontColor: drawing.Color{
R: 170,
G: 170,
B: 170,
A: 255,
},
FontSize: 14,
}, s.ToStyle())
}
func TestEChartsSeriesList(t *testing.T) {
assert := assert.New(t)
// pie
es := EChartsSeriesList{
{
Type: ChartTypePie,
Radius: "30%",
Data: []EChartsSeriesData{
{
Name: "1",
Value: EChartsSeriesDataValue{
values: []float64{
1,
},
},
},
{
Name: "2",
Value: EChartsSeriesDataValue{
values: []float64{
2,
},
},
},
},
},
}
assert.Equal(SeriesList{
{
Type: ChartTypePie,
Name: "1",
Label: SeriesLabel{
Show: true,
},
Radius: "30%",
Data: []SeriesData{
{
Value: 1,
},
},
},
{
Type: ChartTypePie,
Name: "2",
Label: SeriesLabel{
Show: true,
},
Radius: "30%",
Data: []SeriesData{
{
Value: 2,
},
},
},
}, es.ToSeriesList())
es = EChartsSeriesList{
{
Type: ChartTypeBar,
Data: []EChartsSeriesData{
{
Value: NewEChartsSeriesDataValue(1),
ItemStyle: EChartStyle{
Color: "#aaa",
},
},
{
Value: NewEChartsSeriesDataValue(2),
},
},
YAxisIndex: 1,
},
{
Data: []EChartsSeriesData{
{
Value: NewEChartsSeriesDataValue(3),
},
{
Value: NewEChartsSeriesDataValue(4),
},
},
ItemStyle: EChartStyle{
Color: "#ccc",
},
Label: EChartsLabelOption{
Color: "#ddd",
Show: true,
Distance: 5,
},
},
}
assert.Equal(SeriesList{
{
Type: ChartTypeBar,
Data: []SeriesData{
{
Value: 1,
Style: chart.Style{
FontColor: drawing.Color{
R: 170,
G: 170,
B: 170,
A: 255,
},
StrokeColor: drawing.Color{
R: 170,
G: 170,
B: 170,
A: 255,
},
FillColor: drawing.Color{
R: 170,
G: 170,
B: 170,
A: 255,
},
},
},
{
Value: 2,
},
},
YAxisIndex: 1,
},
{
Data: []SeriesData{
{
Value: 3,
},
{
Value: 4,
},
},
Style: chart.Style{
FontColor: drawing.Color{
R: 204,
G: 204,
B: 204,
A: 255,
},
StrokeColor: drawing.Color{
R: 204,
G: 204,
B: 204,
A: 255,
},
FillColor: drawing.Color{
R: 204,
G: 204,
B: 204,
A: 255,
},
},
Label: SeriesLabel{
Color: drawing.Color{
R: 221,
G: 221,
B: 221,
A: 255,
},
Show: true,
Distance: 5,
},
MarkPoint: SeriesMarkPoint{},
MarkLine: SeriesMarkLine{},
},
}, es.ToSeriesList())
}

103
examples/bar_chart/main.go Normal file
View file

@ -0,0 +1,103 @@
package main
import (
"io/ioutil"
"os"
"path/filepath"
"github.com/vicanso/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 = ioutil.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)
}
}

View file

@ -1,94 +0,0 @@
package main
import (
"io/ioutil"
"os"
"path/filepath"
charts "github.com/vicanso/go-charts"
)
func writeFile(file string, buf []byte) error {
tmpPath := "./tmp"
err := os.MkdirAll(tmpPath, 0700)
if err != nil {
return err
}
file = filepath.Join(tmpPath, file)
err = ioutil.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func chartsRender() ([]byte, error) {
d, err := charts.LineRender([][]float64{
{
150,
230,
224,
218,
135,
147,
260,
},
},
// output type
charts.PNGTypeOption(),
// title
charts.TitleOptionFunc(charts.TitleOption{
Text: "Line",
}),
// x axis
charts.XAxisOptionFunc(charts.NewXAxisOption([]string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
})),
)
if err != nil {
return nil, err
}
return d.Bytes()
}
func echartsRender() ([]byte, error) {
return charts.RenderEChartsToPNG(`{
"title": {
"text": "Line"
},
"xAxis": {
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
},
"series": [
{
"data": [150, 230, 224, 218, 135, 147, 260]
}
]
}`)
}
type Render func() ([]byte, error)
func main() {
m := map[string]Render{
"charts-line.png": chartsRender,
"echarts-line.png": echartsRender,
}
for name, fn := range m {
buf, err := fn()
if err != nil {
panic(err)
}
err = writeFile(name, buf)
if err != nil {
panic(err)
}
}
}

View file

@ -5,9 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
charts "github.com/vicanso/go-charts" charts "github.com/vicanso/go-charts/v2"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
) )
var html = `<!DOCTYPE html> var html = `<!DOCTYPE html>
@ -75,6 +73,7 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha
bytesList := make([][]byte, 0) bytesList := make([][]byte, 0)
for _, opt := range chartOptions { for _, opt := range chartOptions {
opt.Theme = theme opt.Theme = theme
opt.Type = charts.ChartOutputSVG
d, err := charts.Render(opt) d, err := charts.Render(opt)
if err != nil { if err != nil {
panic(err) panic(err)
@ -100,7 +99,6 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha
func indexHandler(w http.ResponseWriter, req *http.Request) { func indexHandler(w http.ResponseWriter, req *http.Request) {
chartOptions := []charts.ChartOption{ chartOptions := []charts.ChartOption{
// 普通折线图
{ {
Title: charts.TitleOption{ Title: charts.TitleOption{
Text: "Line", Text: "Line",
@ -174,7 +172,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
Title: charts.TitleOption{ Title: charts.TitleOption{
Text: "Temperature Change in the Coming Week", Text: "Temperature Change in the Coming Week",
}, },
Padding: chart.Box{ Padding: charts.Box{
Top: 20, Top: 20,
Left: 20, Left: 20,
Right: 30, Right: 30,
@ -240,7 +238,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Rainfall", "Rainfall",
"Evaporation", "Evaporation",
}, },
Icon: charts.LegendIconRect, Icon: charts.IconRect,
}, },
SeriesList: []charts.Series{ SeriesList: []charts.Series{
charts.NewSeriesFromValues([]float64{ charts.NewSeriesFromValues([]float64{
@ -260,8 +258,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
}, },
{ {
Value: 190, Value: 190,
Style: chart.Style{ Style: charts.Style{
FillColor: drawing.Color{ FillColor: charts.Color{
R: 169, R: 169,
G: 0, G: 0,
B: 0, B: 0,
@ -288,13 +286,61 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
}, },
}, },
}, },
// 柱状图+mark // 水平柱状图
{
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{ Title: charts.TitleOption{
Text: "Rainfall vs Evaporation", Text: "Rainfall vs Evaporation",
Subtext: "Fake Data", Subtext: "Fake Data",
}, },
Padding: chart.Box{ Padding: charts.Box{
Top: 20, Top: 20,
Right: 20, Right: 20,
Bottom: 20, Bottom: 20,
@ -371,6 +417,9 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
}, },
// 双Y轴示例 // 双Y轴示例
{ {
Title: charts.TitleOption{
Text: "Temperature",
},
XAxis: charts.NewXAxisOption([]string{ XAxis: charts.NewXAxisOption([]string{
"Jan", "Jan",
"Feb", "Feb",
@ -390,22 +439,22 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Precipitation", "Precipitation",
"Temperature", "Temperature",
}), }),
YAxisList: []charts.YAxisOption{ YAxisOptions: []charts.YAxisOption{
{ {
Formatter: "{value}°C", Formatter: "{value}ml",
Color: drawing.Color{ Color: charts.Color{
R: 250, R: 84,
G: 200, G: 112,
B: 88, B: 198,
A: 255, A: 255,
}, },
}, },
{ {
Formatter: "{value}ml", Formatter: "{value}°C",
Color: drawing.Color{ Color: charts.Color{
R: 84, R: 250,
G: 112, G: 200,
B: 198, B: 88,
A: 255, A: 255,
}, },
}, },
@ -426,9 +475,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
20.0, 20.0,
6.4, 6.4,
3.3, 3.3,
10.2,
}), }),
YAxisIndex: 1,
}, },
{ {
Type: charts.ChartTypeBar, Type: charts.ChartTypeBar,
@ -445,9 +492,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
18.8, 18.8,
6.0, 6.0,
2.3, 2.3,
20.2,
}), }),
YAxisIndex: 1,
}, },
{ {
Data: charts.NewSeriesDataFromValues([]float64{ Data: charts.NewSeriesDataFromValues([]float64{
@ -463,8 +508,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
16.5, 16.5,
12.0, 12.0,
6.2, 6.2,
30.3,
}), }),
AxisIndex: 1,
}, },
}, },
}, },
@ -572,6 +617,20 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Order", "Order",
}), }),
SeriesList: []charts.Series{ 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, Type: charts.ChartTypeFunnel,
Name: "Visit", Name: "Visit",
@ -593,20 +652,6 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
20, 20,
}), }),
}, },
{
Type: charts.ChartTypeFunnel,
Name: "Click",
Data: charts.NewSeriesDataFromValues([]float64{
80,
}),
},
{
Type: charts.ChartTypeFunnel,
Name: "Show",
Data: charts.NewSeriesDataFromValues([]float64{
100,
}),
},
}, },
}, },
// 多图展示 // 多图展示
@ -620,7 +665,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Walnut Brownie", "Walnut Brownie",
}, },
}, },
Padding: chart.Box{ Padding: charts.Box{
Top: 100, Top: 100,
Right: 10, Right: 10,
Bottom: 10, Bottom: 10,
@ -634,7 +679,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"2016", "2016",
"2017", "2017",
}), }),
YAxisList: []charts.YAxisOption{ YAxisOptions: []charts.YAxisOption{
{ {
Min: charts.NewFloatPoint(0), Min: charts.NewFloatPoint(0),
@ -686,7 +731,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
"Walnut Brownie", "Walnut Brownie",
}, },
}, },
Box: chart.Box{ Box: charts.Box{
Top: 20, Top: 20,
Left: 400, Left: 400,
Right: 500, Right: 500,
@ -1011,6 +1056,64 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
} }
] ]
}`, }`,
`{
"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": { "title": {
"text": "Rainfall vs Evaporation", "text": "Rainfall vs Evaporation",
@ -1172,12 +1275,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
23.2, 23.2,
25.6, 25.6,
76.7, 76.7,
135.6, 135.6
162.2,
32.6,
20,
6.4,
3.3
] ]
}, },
{ {
@ -1191,12 +1289,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
26.4, 26.4,
28.7, 28.7,
70.7, 70.7,
175.6, 175.6
182.2,
48.7,
18.8,
6,
2.3
] ]
}, },
{ {
@ -1211,12 +1304,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) {
4.5, 4.5,
6.3, 6.3,
10.2, 10.2,
20.3, 20.3
23.4,
23,
16.5,
12,
6.2
] ]
} }
] ]

View file

@ -2,49 +2,118 @@ package main
import ( import (
"io/ioutil" "io/ioutil"
"log" "os"
"path/filepath"
charts "github.com/vicanso/go-charts" "github.com/vicanso/go-charts/v2"
) )
func echartsRender() ([]byte, error) { func writeFile(buf []byte) error {
return charts.RenderEChartsToPNG(`{ tmpPath := "./tmp"
"title": { err := os.MkdirAll(tmpPath, 0700)
"text": "用户访问次数", if err != nil {
"textStyle": { return err
"fontFamily": "chinese" }
}
}, file := filepath.Join(tmpPath, "chinese-line-chart.png")
"xAxis": { err = ioutil.WriteFile(file, buf, 0600)
"data": ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] if err != nil {
}, return err
"series": [ }
{ return nil
"data": [150, 230, 224, 218, 135, 147, 260],
"label": {
"show": true
}
}
]
}`)
} }
func main() { func main() {
fontData, err := ioutil.ReadFile("/Users/darcy/Downloads/NotoSansCJKsc-VF.ttf") // 字体文件需要自行下载
buf, err := ioutil.ReadFile("../NotoSansSC.ttf")
if err != nil { if err != nil {
log.Fatalln("Error when reading font file:", err) panic(err)
}
err = charts.InstallFont("noto", buf)
if err != nil {
panic(err)
} }
if err := charts.InstallFont("chinese", fontData); err != nil { values := [][]float64{
log.Fatalln("Error when instaling font:", err) {
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(
fileData, err := echartsRender() values,
charts.TitleTextOptionFunc("Line"),
charts.FontFamilyOptionFunc("noto"),
charts.XAxisDataOptionFunc([]string{
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
"星期日",
}),
charts.LegendLabelsOptionFunc([]string{
"邮件",
"广告",
"视频广告",
"直接访问",
"搜索引擎",
}, charts.PositionCenter),
)
if err != nil { if err != nil {
log.Fatalln("Error when rendering image:", err) panic(err)
} }
if err := ioutil.WriteFile("chinese.png", fileData, 0644); err != nil {
log.Fatalln("Error when save image to chinese.png:", err) buf, err = p.Bytes()
if err != nil {
panic(err)
} }
} err = writeFile(buf)
if err != nil {
panic(err)
}
)

View file

@ -0,0 +1,57 @@
package main
import (
"io/ioutil"
"os"
"path/filepath"
"github.com/vicanso/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 = ioutil.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
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)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -0,0 +1,79 @@
package main
import (
"io/ioutil"
"os"
"path/filepath"
"github.com/vicanso/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 = ioutil.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := [][]float64{
{
18203,
23489,
29034,
104970,
131744,
630230,
},
{
19325,
23438,
31000,
121594,
134141,
681807,
},
}
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)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

107
examples/line_chart/main.go Normal file
View file

@ -0,0 +1,107 @@
package main
import (
"io/ioutil"
"os"
"path/filepath"
"github.com/vicanso/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 = ioutil.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
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("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)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

608
examples/painter/main.go Normal file
View file

@ -0,0 +1,608 @@
package main
import (
"io/ioutil"
"os"
"path/filepath"
charts "github.com/vicanso/go-charts/v2"
"github.com/wcharczuk/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 = ioutil.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.LegendPainterOption{
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.LegendPainterOption{
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.LegendPainterOption{
Top: "10",
Data: []string{
"Email",
"Union Ads",
"Video Ads",
"Direct",
},
Orient: charts.OrientVertical,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis bottom
top += 100
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 50,
})), charts.AxisOption{
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
StrokeColor: drawing.ColorBlack,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis top
top += 50
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 1,
Right: p.Width() - 1,
Bottom: top + 50,
})), charts.AxisOption{
Position: charts.PositionTop,
BoundaryGap: charts.FalseFlag(),
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
StrokeColor: drawing.ColorBlack,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis left
top += 50
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 10,
Right: 60,
Bottom: top + 200,
})), charts.AxisOption{
Position: charts.PositionLeft,
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
StrokeColor: drawing.ColorBlack,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis right
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 100,
Right: 150,
Bottom: top + 200,
})), charts.AxisOption{
Position: charts.PositionRight,
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
StrokeColor: drawing.ColorBlack,
FontSize: 12,
FontColor: drawing.ColorBlack,
}).Render()
// axis left no tick
charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{
Top: top,
Left: 150,
Right: 300,
Bottom: top + 200,
})), charts.AxisOption{
BoundaryGap: charts.FalseFlag(),
Position: charts.PositionLeft,
Data: []string{
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
},
FontSize: 12,
FontColor: drawing.ColorBlack,
SplitLineShow: true,
SplitLineColor: drawing.ColorBlack.WithAlpha(100),
}).Render()
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -0,0 +1,72 @@
package main
import (
"io/ioutil"
"os"
"path/filepath"
"github.com/vicanso/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 = ioutil.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := []float64{
1048,
735,
580,
484,
300,
}
p, err := charts.PieRender(
values,
charts.TitleOptionFunc(charts.TitleOption{
Text: "Rainfall vs Evaporation",
Subtext: "Fake Data",
Left: charts.PositionCenter,
}),
charts.PaddingOptionFunc(charts.Box{
Top: 20,
Right: 20,
Bottom: 20,
Left: 20,
}),
charts.LegendOptionFunc(charts.LegendOption{
Orient: charts.OrientVertical,
Data: []string{
"Search Engine",
"Direct",
"Email",
"Union Ads",
"Video Ads",
},
Left: charts.PositionLeft,
}),
charts.PieSeriesShowLabel(),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -0,0 +1,80 @@
package main
import (
"io/ioutil"
"os"
"path/filepath"
"github.com/vicanso/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 = ioutil.WriteFile(file, buf, 0600)
if err != nil {
return err
}
return nil
}
func main() {
values := [][]float64{
{
4200,
3000,
20000,
35000,
50000,
18000,
},
{
5000,
14000,
28000,
26000,
42000,
21000,
},
}
p, err := charts.RadarRender(
values,
charts.TitleTextOptionFunc("Basic Radar Chart"),
charts.LegendLabelsOptionFunc([]string{
"Allocated Budget",
"Actual Spending",
}),
charts.RadarIndicatorOptionFunc([]string{
"Sales",
"Administration",
"Information Technology",
"Customer Support",
"Development",
"Marketing",
}, []float64{
6500,
16000,
30000,
38000,
52000,
25000,
}),
)
if err != nil {
panic(err)
}
buf, err := p.Bytes()
if err != nil {
panic(err)
}
err = writeFile(buf)
if err != nil {
panic(err)
}
}

View file

@ -24,34 +24,43 @@ package charts
import ( import (
"fmt" "fmt"
"sort"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/golang/freetype/truetype" "github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
) )
type funnelChartOption struct { type funnelChart struct {
Theme string p *Painter
Font *truetype.Font opt *FunnelChartOption
SeriesList SeriesList
} }
func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error { func NewFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart {
d, err := NewDraw(DrawOption{ if opt.Theme == nil {
Parent: result.d, opt.Theme = defaultTheme
}, PaddingOption(chart.Box{
Top: result.titleBox.Height(),
}))
if err != nil {
return err
} }
seriesList := make([]Series, len(opt.SeriesList)) return &funnelChart{
copy(seriesList, opt.SeriesList) p: p,
sort.Slice(seriesList, func(i, j int) bool { opt: &opt,
// 大的数据在前 }
return seriesList[i].Data[0].Value > seriesList[j].Data[0].Value }
})
type FunnelChartOption struct {
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 max := seriesList[0].Data[0].Value
min := float64(0) min := float64(0)
for _, item := range seriesList { for _, item := range seriesList {
@ -62,11 +71,10 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error {
min = *item.Min min = *item.Min
} }
} }
theme := opt.Theme
theme := NewTheme(opt.Theme)
gap := 2 gap := 2
height := d.Box.Height() height := seriesPainter.Height()
width := d.Box.Width() width := seriesPainter.Width()
count := len(seriesList) count := len(seriesList)
h := (height - gap*(count-1)) / count h := (height - gap*(count-1)) / count
@ -116,26 +124,49 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error {
}, },
} }
color := theme.GetSeriesColor(series.index) color := theme.GetSeriesColor(series.index)
d.fill(points, chart.Style{
seriesPainter.OverrideDrawingStyle(Style{
FillColor: color, FillColor: color,
}) }).FillArea(points)
// 文本 // 文本
text := textList[index] text := textList[index]
r := d.Render seriesPainter.OverrideTextStyle(Style{
textStyle := chart.Style{
FontColor: theme.GetTextColor(), FontColor: theme.GetTextColor(),
FontSize: labelFontSize, FontSize: labelFontSize,
Font: opt.Font, Font: opt.Font,
} })
textStyle.GetTextOptions().WriteToRenderer(r) textBox := seriesPainter.MeasureText(text)
textBox := r.MeasureText(text)
textX := width>>1 - textBox.Width()>>1 textX := width>>1 - textBox.Width()>>1
textY := y + h>>1 textY := y + h>>1
d.text(text, textX, textY) seriesPainter.Text(text, textX, textY)
y += (h + gap) y += (h + gap)
} }
return nil return f.p.box, nil
}
func (f *funnelChart) Render() (Box, error) {
p := f.p
opt := f.opt
renderResult, err := defaultRender(p, defaultRenderOption{
Theme: opt.Theme,
Padding: opt.Padding,
SeriesList: opt.SeriesList,
XAxis: XAxisOption{
Show: FalseFlag(),
},
YAxisOptions: []YAxisOption{
{
Show: FalseFlag(),
},
},
TitleOption: opt.Title,
LegendOption: opt.Legend,
})
if err != nil {
return BoxZero, err
}
seriesList := opt.SeriesList.Filter(ChartTypeFunnel)
return f.render(renderResult, seriesList)
} }

View file

@ -1,91 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
)
func TestFunnelChartRender(t *testing.T) {
assert := assert.New(t)
d, err := NewDraw(DrawOption{
Width: 250,
Height: 150,
})
assert.Nil(err)
f, _ := chart.GetDefaultFont()
err = funnelChartRender(funnelChartOption{
Font: f,
SeriesList: []Series{
{
Type: ChartTypeFunnel,
Name: "Visit",
Data: NewSeriesDataFromValues([]float64{
60,
}),
},
{
Type: ChartTypeFunnel,
Name: "Inquiry",
Data: NewSeriesDataFromValues([]float64{
40,
}),
index: 1,
},
{
Type: ChartTypeFunnel,
Name: "Order",
Data: NewSeriesDataFromValues([]float64{
20,
}),
index: 2,
},
{
Type: ChartTypeFunnel,
Name: "Click",
Data: NewSeriesDataFromValues([]float64{
80,
}),
index: 3,
},
{
Type: ChartTypeFunnel,
Name: "Show",
Data: NewSeriesDataFromValues([]float64{
100,
}),
index: 4,
},
},
}, &basicRenderResult{
d: d,
})
assert.Nil(err)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"250\" height=\"150\">\\n<path d=\"M 0 0\nL 250 0\nL 225 28\nL 25 28\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(115,192,222,1.0)\"/><text x=\"89\" y=\"14\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Show(100%)</text><path d=\"M 25 30\nL 225 30\nL 200 58\nL 50 58\nL 25 30\" style=\"stroke-width:0;stroke:none;fill:rgba(238,102,102,1.0)\"/><text x=\"94\" y=\"44\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Click(80%)</text><path d=\"M 50 60\nL 200 60\nL 175 88\nL 75 88\nL 50 60\" style=\"stroke-width:0;stroke:none;fill:rgba(84,112,198,1.0)\"/><text x=\"96\" y=\"74\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Visit(60%)</text><path d=\"M 75 90\nL 175 90\nL 150 118\nL 100 118\nL 75 90\" style=\"stroke-width:0;stroke:none;fill:rgba(145,204,117,1.0)\"/><text x=\"89\" y=\"104\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Inquiry(40%)</text><path d=\"M 100 120\nL 150 120\nL 125 148\nL 125 148\nL 100 120\" style=\"stroke-width:0;stroke:none;fill:rgba(250,200,88,1.0)\"/><text x=\"93\" y=\"134\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Order(20%)</text></svg>", string(data))
}

8
go.mod
View file

@ -1,17 +1,17 @@
module github.com/vicanso/go-charts module github.com/vicanso/go-charts/v2
go 1.17 go 1.17
require ( require (
github.com/dustin/go-humanize v1.0.0 github.com/dustin/go-humanize v1.0.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/stretchr/testify v1.7.1 github.com/stretchr/testify v1.7.2
github.com/wcharczuk/go-chart/v2 v2.1.0 github.com/wcharczuk/go-chart/v2 v2.1.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 // indirect golang.org/x/image v0.0.0-20220617043117-41969df76e82 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

15
go.sum
View file

@ -8,18 +8,17 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I= 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= github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA=
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE= golang.org/x/image v0.0.0-20220617043117-41969df76e82 h1:KpZB5pUSBvrHltNEdK/tw0xlPeD13M6M6aGP32gKqiw=
golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/image v0.0.0-20220617043117-41969df76e82/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

80
grid.go Normal file
View file

@ -0,0 +1,80 @@
// 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 {
StrokeWidth float64
StrokeColor Color
Column int
Row int
IgnoreFirstRow bool
IgnoreLastRow bool
IgnoreFirstColumn bool
IgnoreLastColumn bool
}
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,
Row: opt.Row,
IgnoreColumnLines: ignoreColumnLines,
IgnoreRowLines: ignoreRowLines,
})
return g.p.box, nil
}

148
horizontal_bar_chart.go Normal file
View file

@ -0,0 +1,148 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
)
type horizontalBarChart struct {
p *Painter
opt *HorizontalBarChartOption
}
type HorizontalBarChartOption struct {
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
}
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
}
seriesCount := len(seriesList)
// 总的高度-两个margin-(总数-1)的barMargin
barHeight := (height - 2*margin - barMargin*(seriesCount-1)) / len(seriesList)
theme := opt.Theme
max, min := seriesList.GetMaxMin(0)
xRange := NewRange(AxisRangeOption{
Min: min,
Max: max,
DivideCount: defaultAxisDivideCount,
Size: seriesPainter.Width(),
})
for index := range seriesList {
series := seriesList[index]
seriesColor := theme.GetSeriesColor(series.index)
divideValues := yRange.AutoDivide()
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
seriesPainter.OverrideDrawingStyle(Style{
FillColor: fillColor,
}).Rect(chart.Box{
Top: y,
Left: 0,
Right: right,
Bottom: y + barHeight,
})
}
}
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)
}

273
legend.go
View file

@ -25,16 +25,18 @@ package charts
import ( import (
"strconv" "strconv"
"strings" "strings"
"github.com/wcharczuk/go-chart/v2"
) )
type legendPainter struct {
p *Painter
opt *LegendOption
}
const IconRect = "rect"
const IconLineDot = "lineDot"
type LegendOption struct { type LegendOption struct {
theme string Theme ColorPalette
// Legend show flag, if nil or true, the legend will be shown
Show *bool
// Legend text style
Style chart.Style
// Text array of legend // Text array of legend
Data []string Data []string
// Distance between legend component and the left side of the container. // Distance between legend component and the left side of the container.
@ -50,177 +52,170 @@ type LegendOption struct {
Orient string Orient string
// Icon of the legend. // Icon of the legend.
Icon string 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
} }
const ( func NewLegendOption(labels []string, left ...string) LegendOption {
LegendIconRect = "rect"
)
// NewLegendOption creates a new legend option by legend text list
func NewLegendOption(data []string, position ...string) LegendOption {
opt := LegendOption{ opt := LegendOption{
Data: data, Data: labels,
} }
if len(position) != 0 { if len(left) != 0 {
opt.Left = position[0] opt.Left = left[0]
} }
return opt return opt
} }
type legend struct { func (opt *LegendOption) IsEmpty() bool {
d *Draw isEmpty := true
opt *LegendOption for _, v := range opt.Data {
if v != "" {
isEmpty = false
break
}
}
return isEmpty
} }
func NewLegend(d *Draw, opt LegendOption) *legend { func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter {
return &legend{ return &legendPainter{
d: d, p: p,
opt: &opt, opt: &opt,
} }
} }
func (l *legend) Render() (chart.Box, error) { func (l *legendPainter) Render() (Box, error) {
d := l.d
opt := l.opt opt := l.opt
if len(opt.Data) == 0 || isFalse(opt.Show) { theme := opt.Theme
return chart.BoxZero, nil if opt.IsEmpty() ||
(opt.Show != nil && !*opt.Show) {
return BoxZero, nil
} }
theme := NewTheme(opt.theme) if theme == nil {
padding := opt.Style.Padding theme = l.p.theme
legendDraw, err := NewDraw(DrawOption{
Parent: d,
}, PaddingOption(padding))
if err != nil {
return chart.BoxZero, err
} }
r := legendDraw.Render if opt.FontSize == 0 {
opt.Style.GetTextOptions().WriteToRenderer(r) opt.FontSize = theme.GetFontSize()
x := 0
y := 0
top := 0
// TODO TOP 暂只支持数值
if opt.Top != "" {
top, _ = strconv.Atoi(opt.Top)
y += top
} }
legendWidth := 30 if opt.FontColor.IsZero() {
legendDotHeight := 5 opt.FontColor = theme.GetTextColor()
textPadding := 5 }
legendMargin := 10 if opt.Left == "" {
// 往下移2倍dot的高度 opt.Left = PositionCenter
y += 2 * legendDotHeight }
p := l.p.Child(PainterPaddingOption(Box{
widthCount := 0 Top: 5,
}))
p.SetTextStyle(Style{
FontSize: opt.FontSize,
FontColor: opt.FontColor,
})
measureList := make([]Box, len(opt.Data))
maxTextWidth := 0 maxTextWidth := 0
// 文本宽度 for index, text := range opt.Data {
for _, text := range opt.Data { b := p.MeasureText(text)
b := r.MeasureText(text)
if b.Width() > maxTextWidth { if b.Width() > maxTextWidth {
maxTextWidth = b.Width() maxTextWidth = b.Width()
} }
widthCount += b.Width() measureList[index] = b
}
if opt.Orient == OrientVertical {
widthCount = maxTextWidth + legendWidth + textPadding
} else {
// 加上标记
widthCount += legendWidth * len(opt.Data)
// 文本的padding
widthCount += 2 * textPadding * len(opt.Data)
// margin的宽度
widthCount += legendMargin * (len(opt.Data) - 1)
} }
// 计算展示的宽高
width := 0
height := 0
offset := 20
textOffset := 2
legendWidth := 30
legendHeight := 20
for _, item := range measureList {
if opt.Orient == OrientVertical {
height += item.Height()
} else {
width += item.Width()
}
}
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 left := 0
switch opt.Left { switch opt.Left {
case PositionRight: case PositionRight:
left = legendDraw.Box.Width() - widthCount left = p.Width() - width
case PositionCenter: case PositionCenter:
left = (legendDraw.Box.Width() - widthCount) >> 1 left = (p.Width() - width) >> 1
default: default:
if strings.HasSuffix(opt.Left, "%") { if strings.HasSuffix(opt.Left, "%") {
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
left = legendDraw.Box.Width() * value / 100 left = p.Width() * value / 100
} else { } else {
value, _ := strconv.Atoi(opt.Left) value, _ := strconv.Atoi(opt.Left)
left = value left = value
} }
} }
x = left top, _ := strconv.Atoi(opt.Top)
x := int(left)
y := int(top) + 10
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
}
for index, text := range opt.Data { for index, text := range opt.Data {
textBox := r.MeasureText(text) color := theme.GetSeriesColor(index)
var renderText func() p.SetDrawingStyle(Style{
FillColor: color,
StrokeColor: color,
})
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(0, x0)
}
if opt.Orient == OrientVertical { if opt.Orient == OrientVertical {
// 垂直 y0 += offset
// 重置x的位置 x0 = x
x = left
renderText = func() {
x += textPadding
legendDraw.text(text, x, y+legendDotHeight)
x += textBox.Width()
y += (2*legendDotHeight + legendMargin)
}
} else { } else {
// 水平 x0 += offset
if index != 0 { y0 = y
x += legendMargin
}
renderText = func() {
x += textPadding
legendDraw.text(text, x, y+legendDotHeight)
x += textBox.Width()
x += textPadding
}
}
if opt.Align == PositionRight {
renderText()
}
seriesColor := theme.GetSeriesColor(index)
fillColor := seriesColor
if !theme.IsDark() {
fillColor = theme.GetBackgroundColor()
}
style := chart.Style{
StrokeColor: seriesColor,
FillColor: fillColor,
StrokeWidth: 3,
}
if opt.Icon == LegendIconRect {
style.FillColor = seriesColor
style.StrokeWidth = 1
}
style.GetFillAndStrokeOptions().WriteDrawingOptionsToRenderer(r)
if opt.Icon == LegendIconRect {
legendDraw.moveTo(x, y-legendDotHeight)
legendDraw.lineTo(x+legendWidth, y-legendDotHeight)
legendDraw.lineTo(x+legendWidth, y+legendDotHeight)
legendDraw.lineTo(x, y+legendDotHeight)
legendDraw.lineTo(x, y-legendDotHeight)
r.FillStroke()
} else {
legendDraw.moveTo(x, y)
legendDraw.lineTo(x+legendWidth, y)
r.Stroke()
legendDraw.circle(float64(legendDotHeight), x+legendWidth>>1, y)
r.FillStroke()
}
x += legendWidth
if opt.Align != PositionRight {
renderText()
} }
} }
legendBox := padding.Clone()
// 计算展示区域 return Box{
if opt.Orient == OrientVertical { Right: width,
legendBox.Right = legendBox.Left + left + maxTextWidth + legendWidth + textPadding Bottom: height,
legendBox.Bottom = legendBox.Top + y }, nil
} else {
legendBox.Right = legendBox.Left + x
legendBox.Bottom = legendBox.Top + 2*legendDotHeight + top + textPadding
}
return legendBox, nil
} }

View file

@ -1,185 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestNewLegendOption(t *testing.T) {
assert := assert.New(t)
opt := NewLegendOption([]string{
"a",
"b",
}, PositionRight)
assert.Equal(LegendOption{
Data: []string{
"a",
"b",
},
Left: PositionRight,
}, opt)
}
func TestLegendRender(t *testing.T) {
assert := assert.New(t)
newDraw := func() *Draw {
d, _ := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
return d
}
style := chart.Style{
FontSize: 10,
FontColor: drawing.ColorBlack,
}
style.Font, _ = chart.GetDefaultFont()
tests := []struct {
newDraw func() *Draw
newLegend func(*Draw) *legend
box chart.Box
result string
}{
{
newDraw: newDraw,
newLegend: func(d *Draw) *legend {
return NewLegend(d, LegendOption{
Top: "10",
Data: []string{
"Mon",
"Tue",
"Wed",
},
Style: style,
})
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 20\nL 30 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"15\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"35\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><path d=\"M 76 20\nL 106 20\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"91\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"111\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><path d=\"M 148 20\nL 178 20\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"163\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"183\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text></svg>",
box: chart.Box{
Right: 214,
Bottom: 25,
},
},
{
newDraw: newDraw,
newLegend: func(d *Draw) *legend {
return NewLegend(d, LegendOption{
Top: "10",
Left: PositionRight,
Align: PositionRight,
Data: []string{
"Mon",
"Tue",
"Wed",
},
Style: style,
})
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"191\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><path d=\"M 222 20\nL 252 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"237\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"267\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><path d=\"M 294 20\nL 324 20\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"309\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"339\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><path d=\"M 370 20\nL 400 20\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"385\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/></svg>",
box: chart.Box{
Right: 400,
Bottom: 25,
},
},
{
newDraw: newDraw,
newLegend: func(d *Draw) *legend {
return NewLegend(d, LegendOption{
Top: "10",
Left: PositionCenter,
Data: []string{
"Mon",
"Tue",
"Wed",
},
Style: style,
})
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 93 20\nL 123 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"108\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"128\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><path d=\"M 169 20\nL 199 20\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"184\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"204\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><path d=\"M 241 20\nL 271 20\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"256\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"276\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text></svg>",
box: chart.Box{
Right: 307,
Bottom: 25,
},
},
{
newDraw: newDraw,
newLegend: func(d *Draw) *legend {
return NewLegend(d, LegendOption{
Top: "10",
Left: PositionLeft,
Data: []string{
"Mon",
"Tue",
"Wed",
},
Style: style,
Orient: OrientVertical,
})
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 20\nL 30 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"15\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"35\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><path d=\"M 0 40\nL 30 40\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"15\" cy=\"40\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"35\" y=\"45\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><path d=\"M 0 60\nL 30 60\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"15\" cy=\"60\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"35\" y=\"65\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text></svg>",
box: chart.Box{
Right: 61,
Bottom: 80,
},
},
{
newDraw: newDraw,
newLegend: func(d *Draw) *legend {
return NewLegend(d, LegendOption{
Top: "10",
Left: "10%",
Data: []string{
"Mon",
"Tue",
"Wed",
},
Style: style,
Orient: OrientVertical,
})
},
box: chart.Box{
Right: 101,
Bottom: 80,
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 40 20\nL 70 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"55\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"75\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><path d=\"M 40 40\nL 70 40\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"55\" cy=\"40\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"75\" y=\"45\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><path d=\"M 40 60\nL 70 60\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"55\" cy=\"60\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"75\" y=\"65\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text></svg>",
},
}
for _, tt := range tests {
d := tt.newDraw()
b, err := tt.newLegend(d).Render()
assert.Nil(err)
assert.Equal(tt.box, b)
data, err := d.Bytes()
assert.Nil(err)
assert.NotEmpty(data)
assert.Equal(tt.result, string(data))
}
}

103
line.go
View file

@ -1,103 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
type LineStyle struct {
ClassName string
StrokeDashArray []float64
StrokeColor drawing.Color
StrokeWidth float64
FillColor drawing.Color
DotWidth float64
DotColor drawing.Color
DotFillColor drawing.Color
}
func (ls *LineStyle) Style() chart.Style {
return chart.Style{
ClassName: ls.ClassName,
StrokeDashArray: ls.StrokeDashArray,
StrokeColor: ls.StrokeColor,
StrokeWidth: ls.StrokeWidth,
FillColor: ls.FillColor,
DotWidth: ls.DotWidth,
DotColor: ls.DotColor,
}
}
func (d *Draw) lineFill(points []Point, style LineStyle) {
s := style.Style()
if !(s.ShouldDrawStroke() && s.ShouldDrawFill()) {
return
}
newPoints := make([]Point, len(points))
copy(newPoints, points)
x0 := points[0].X
y0 := points[0].Y
height := d.Box.Height()
newPoints = append(newPoints, Point{
X: points[len(points)-1].X,
Y: height,
}, Point{
X: x0,
Y: height,
}, Point{
X: x0,
Y: y0,
})
d.fill(newPoints, style.Style())
}
func (d *Draw) lineDot(points []Point, style LineStyle) {
s := style.Style()
if !s.ShouldDrawDot() {
return
}
r := d.Render
dotWith := s.GetDotWidth()
s.GetDotOptions().WriteDrawingOptionsToRenderer(r)
for _, point := range points {
if !style.DotFillColor.IsZero() {
r.SetFillColor(style.DotFillColor)
}
r.SetStrokeColor(s.DotColor)
d.circle(dotWith, point.X, point.Y)
r.FillStroke()
}
}
func (d *Draw) Line(points []Point, style LineStyle) {
if len(points) == 0 {
return
}
d.lineFill(points, style)
d.lineStroke(points, style)
d.lineDot(points, style)
}

View file

@ -24,108 +24,146 @@ package charts
import ( import (
"github.com/golang/freetype/truetype" "github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing" "github.com/wcharczuk/go-chart/v2/drawing"
) )
type lineChartOption struct { type lineChart struct {
Theme string p *Painter
SeriesList SeriesList opt *LineChartOption
Font *truetype.Font
} }
func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPointRenderOption, error) { func NewLineChart(p *Painter, opt LineChartOption) *lineChart {
if opt.Theme == nil {
theme := NewTheme(opt.Theme) opt.Theme = defaultTheme
d, err := NewDraw(DrawOption{
Parent: result.d,
}, PaddingOption(chart.Box{
Top: result.titleBox.Height(),
Left: YAxisWidth,
}))
if err != nil {
return nil, err
} }
seriesNames := opt.SeriesList.Names() return &lineChart{
p: p,
opt: &opt,
}
}
r := d.Render type LineChartOption struct {
xRange := result.xRange Theme ColorPalette
markPointRenderOptions := make([]markPointRenderOption, 0) // The font size
for i, s := range opt.SeriesList { Font *truetype.Font
// 由于series是for range为同一个数据因此需要clone // The data series list
// 后续需要使用如mark point SeriesList SeriesList
series := s // The x axis option
index := series.index XAxis XAxisOption
if index == 0 { // The padding of line chart
index = i Padding Box
// The y axis option
YAxisOptions []YAxisOption
// The option of title
Title TitleOption
// The legend option
Legend LegendOption
// background is filled
backgroundIsFilled bool
}
func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
p := l.p
opt := l.opt
boundaryGap := true
if opt.XAxis.BoundaryGap != nil && !*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
} }
seriesColor := theme.GetSeriesColor(index) } else {
xValues = xDivideValues
yRange := result.getYRange(series.YAxisIndex) }
points := make([]Point, 0, len(series.Data)) markPointPainter := NewMarkPointPainter(seriesPainter)
// mark line markLinePainter := NewMarkLinePainter(seriesPainter)
markLineRender(markLineRenderOption{ rendererList := []Renderer{
Draw: d, markPointPainter,
FillColor: seriesColor, markLinePainter,
FontColor: theme.GetTextColor(), }
for index := range seriesList {
series := seriesList[index]
seriesColor := opt.Theme.GetSeriesColor(series.index)
drawingStyle := Style{
StrokeColor: seriesColor, StrokeColor: seriesColor,
Font: opt.Font, StrokeWidth: defaultStrokeWidth,
Series: &series,
Range: yRange,
})
for j, item := range series.Data {
if j >= xRange.divideCount {
continue
}
y := yRange.getRestHeight(item.Value)
x := xRange.getWidth(float64(j))
points = append(points, Point{
Y: y,
X: x,
})
if !series.Label.Show {
continue
}
distance := series.Label.Distance
if distance == 0 {
distance = 5
}
text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1)
labelStyle := chart.Style{
FontColor: theme.GetTextColor(),
FontSize: labelFontSize,
Font: opt.Font,
}
if !series.Label.Color.IsZero() {
labelStyle.FontColor = series.Label.Color
}
labelStyle.GetTextOptions().WriteToRenderer(r)
textBox := r.MeasureText(text)
d.text(text, x-textBox.Width()>>1, y-distance)
} }
dotFillColor := drawing.ColorWhite seriesPainter.SetDrawingStyle(drawingStyle)
if theme.IsDark() { yRange := result.axisRanges[series.AxisIndex]
dotFillColor = seriesColor points := make([]Point, 0)
for i, item := range series.Data {
h := yRange.getRestHeight(item.Value)
p := Point{
X: xValues[i],
Y: h,
}
points = append(points, p)
} }
d.Line(points, LineStyle{ // 画线
StrokeColor: seriesColor, seriesPainter.LineStroke(points)
StrokeWidth: 2,
DotColor: seriesColor, // 画点
DotWidth: defaultDotWidth, if opt.Theme.IsDark() {
DotFillColor: dotFillColor, drawingStyle.FillColor = drawingStyle.StrokeColor
}) } else {
// draw mark point drawingStyle.FillColor = drawing.ColorWhite
markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{ }
Draw: d, drawingStyle.StrokeWidth = 1
seriesPainter.SetDrawingStyle(drawingStyle)
seriesPainter.Dots(points)
markPointPainter.Add(markPointRenderOption{
FillColor: seriesColor, FillColor: seriesColor,
Font: opt.Font, Font: opt.Font,
Points: points, Points: points,
Series: &series, 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 markPointRenderOptions, nil 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)
} }

View file

@ -1,97 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestLineChartRender(t *testing.T) {
assert := assert.New(t)
width := 400
height := 300
d, err := NewDraw(DrawOption{
Width: width,
Height: height,
})
assert.Nil(err)
result := basicRenderResult{
xRange: &Range{
Min: 0,
Max: 4,
divideCount: 4,
Size: width,
Boundary: true,
},
yRangeList: []*Range{
{
divideCount: 6,
Max: 100,
Min: 0,
Size: height,
},
},
d: d,
}
f, _ := chart.GetDefaultFont()
_, err = lineChartRender(lineChartOption{
Font: f,
SeriesList: SeriesList{
{
Label: SeriesLabel{
Show: true,
Color: drawing.ColorBlue,
},
MarkLine: NewMarkLine(
SeriesMarkDataTypeAverage,
),
Data: []SeriesData{
{
Value: 20,
},
{
Value: 60,
},
{
Value: 90,
},
},
},
NewSeriesFromValues([]float64{
40,
60,
70,
}),
},
}, &result)
assert.Nil(err)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"40\" cy=\"130\" r=\"3\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 45 130\nL 382 130\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 382 125\nL 398 130\nL 382 135\nL 387 130\nL 382 125\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"400\" y=\"134\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">56.66</text><text x=\"83\" y=\"235\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20</text><text x=\"183\" y=\"115\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">60</text><text x=\"283\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">90</text><path d=\"M 90 240\nL 190 120\nL 290 30\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"90\" cy=\"240\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"190\" cy=\"120\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"290\" cy=\"30\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"M 90 180\nL 190 120\nL 290 90\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:none\"/><circle cx=\"90\" cy=\"180\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"190\" cy=\"120\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"290\" cy=\"90\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/></svg>", string(data))
}

View file

@ -1,165 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestLineStyle(t *testing.T) {
assert := assert.New(t)
ls := LineStyle{
ClassName: "test",
StrokeDashArray: []float64{
1.0,
},
StrokeColor: drawing.ColorBlack,
StrokeWidth: 1,
FillColor: drawing.ColorBlack.WithAlpha(60),
DotWidth: 2,
DotColor: drawing.ColorBlack,
DotFillColor: drawing.ColorWhite,
}
assert.Equal(chart.Style{
ClassName: "test",
StrokeDashArray: []float64{
1.0,
},
StrokeColor: drawing.ColorBlack,
StrokeWidth: 1,
FillColor: drawing.ColorBlack.WithAlpha(60),
DotWidth: 2,
DotColor: drawing.ColorBlack,
}, ls.Style())
}
func TestDrawLineFill(t *testing.T) {
assert := assert.New(t)
ls := LineStyle{
StrokeColor: drawing.ColorBlack,
StrokeWidth: 1,
FillColor: drawing.ColorBlack.WithAlpha(60),
DotWidth: 2,
DotColor: drawing.ColorBlack,
DotFillColor: drawing.ColorWhite,
}
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
assert.Nil(err)
d.lineFill([]Point{
{
X: 0,
Y: 0,
},
{
X: 10,
Y: 20,
},
{
X: 50,
Y: 60,
},
}, ls)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 0\nL 10 20\nL 50 60\nL 50 300\nL 0 300\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,0.2)\"/></svg>", string(data))
}
func TestDrawLineDot(t *testing.T) {
assert := assert.New(t)
ls := LineStyle{
StrokeColor: drawing.ColorBlack,
StrokeWidth: 1,
FillColor: drawing.ColorBlack.WithAlpha(60),
DotWidth: 2,
DotColor: drawing.ColorBlack,
DotFillColor: drawing.ColorWhite,
}
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
assert.Nil(err)
d.lineDot([]Point{
{
X: 0,
Y: 0,
},
{
X: 10,
Y: 20,
},
{
X: 50,
Y: 60,
},
}, ls)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"0\" cy=\"0\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"10\" cy=\"20\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"50\" cy=\"60\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/></svg>", string(data))
}
func TestDrawLine(t *testing.T) {
assert := assert.New(t)
ls := LineStyle{
StrokeColor: drawing.ColorBlack,
StrokeWidth: 1,
FillColor: drawing.ColorBlack.WithAlpha(60),
DotWidth: 2,
DotColor: drawing.ColorBlack,
DotFillColor: drawing.ColorWhite,
}
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
assert.Nil(err)
d.Line([]Point{
{
X: 0,
Y: 0,
},
{
X: 10,
Y: 20,
},
{
X: 50,
Y: 60,
},
}, ls)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 0\nL 10 20\nL 50 60\nL 50 300\nL 0 300\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,0.2)\"/><path d=\"M 0 0\nL 10 20\nL 50 60\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><circle cx=\"0\" cy=\"0\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"10\" cy=\"20\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"50\" cy=\"60\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/></svg>", string(data))
}

View file

@ -24,8 +24,6 @@ package charts
import ( import (
"github.com/golang/freetype/truetype" "github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
) )
func NewMarkLine(markLineTypes ...string) SeriesMarkLine { func NewMarkLine(markLineTypes ...string) SeriesMarkLine {
@ -40,53 +38,80 @@ func NewMarkLine(markLineTypes ...string) SeriesMarkLine {
} }
} }
type markLinePainter struct {
p *Painter
options []markLineRenderOption
}
func (m *markLinePainter) Add(opt markLineRenderOption) {
m.options = append(m.options, opt)
}
func NewMarkLinePainter(p *Painter) *markLinePainter {
return &markLinePainter{
p: p,
options: make([]markLineRenderOption, 0),
}
}
type markLineRenderOption struct { type markLineRenderOption struct {
Draw *Draw FillColor Color
FillColor drawing.Color FontColor Color
FontColor drawing.Color StrokeColor Color
StrokeColor drawing.Color
Font *truetype.Font Font *truetype.Font
Series *Series Series Series
Range *Range 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
}
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: opt.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
} }
func markLineRender(opt markLineRenderOption) { func markLineRender(opt markLineRenderOption) {
d := opt.Draw // d := opt.Draw
s := opt.Series // s := opt.Series
if len(s.MarkLine.Data) == 0 { // if len(s.MarkLine.Data) == 0 {
return // return
} // }
r := d.Render // r := d.Render
summary := s.Summary()
for _, markLine := range s.MarkLine.Data {
// 由于mark line会修改style因此每次重新设置
chart.Style{
FillColor: opt.FillColor,
FontColor: opt.FontColor,
FontSize: labelFontSize,
StrokeColor: opt.StrokeColor,
StrokeWidth: 1,
Font: opt.Font,
StrokeDashArray: []float64{
4,
2,
},
}.WriteToRenderer(r)
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 := d.Box.Width()
text := commafWithDigits(value)
textBox := r.MeasureText(text)
d.makeLine(0, y, width-2)
d.text(text, width, y+textBox.Height()>>1-2)
}
} }

View file

@ -1,99 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestNewMarkLine(t *testing.T) {
assert := assert.New(t)
markLine := NewMarkLine(
SeriesMarkDataTypeMax,
SeriesMarkDataTypeMin,
SeriesMarkDataTypeAverage,
)
assert.Equal(SeriesMarkLine{
Data: []SeriesMarkData{
{
Type: SeriesMarkDataTypeMax,
},
{
Type: SeriesMarkDataTypeMin,
},
{
Type: SeriesMarkDataTypeAverage,
},
},
}, markLine)
}
func TestMarkLineRender(t *testing.T) {
assert := assert.New(t)
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
}, PaddingOption(chart.Box{
Left: 20,
Right: 20,
}))
assert.Nil(err)
f, _ := chart.GetDefaultFont()
markLineRender(markLineRenderOption{
Draw: d,
FillColor: drawing.ColorBlack,
FontColor: drawing.ColorBlack,
StrokeColor: drawing.ColorBlack,
Font: f,
Series: &Series{
MarkLine: NewMarkLine(
SeriesMarkDataTypeMax,
SeriesMarkDataTypeMin,
SeriesMarkDataTypeAverage,
),
Data: NewSeriesDataFromValues([]float64{
1,
3,
5,
7,
9,
}),
},
Range: &Range{
Min: 0,
Max: 10,
Size: 200,
},
})
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"20\" cy=\"20\" r=\"3\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 25 20\nL 362 20\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path d=\"M 362 15\nL 378 20\nL 362 25\nL 367 20\nL 362 15\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><text x=\"380\" y=\"24\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">9</text><circle cx=\"20\" cy=\"180\" r=\"3\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 25 180\nL 362 180\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path d=\"M 362 175\nL 378 180\nL 362 185\nL 367 180\nL 362 175\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><text x=\"380\" y=\"184\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">1</text><circle cx=\"20\" cy=\"100\" r=\"3\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 25 100\nL 362 100\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path d=\"M 362 95\nL 378 100\nL 362 105\nL 367 100\nL 362 95\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><text x=\"380\" y=\"104\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">5</text></svg>", string(data))
}

View file

@ -24,8 +24,6 @@ package charts
import ( import (
"github.com/golang/freetype/truetype" "github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
) )
func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint { func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint {
@ -40,50 +38,65 @@ func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint {
} }
} }
type markPointPainter struct {
p *Painter
options []markPointRenderOption
}
func (m *markPointPainter) Add(opt markPointRenderOption) {
m.options = append(m.options, opt)
}
type markPointRenderOption struct { type markPointRenderOption struct {
Draw *Draw FillColor Color
FillColor drawing.Color
Font *truetype.Font Font *truetype.Font
Series *Series Series Series
Points []Point Points []Point
} }
func markPointRender(opt markPointRenderOption) { func NewMarkPointPainter(p *Painter) *markPointPainter {
d := opt.Draw return &markPointPainter{
s := opt.Series p: p,
if len(s.MarkPoint.Data) == 0 { options: make([]markPointRenderOption, 0),
return
}
points := opt.Points
summary := s.Summary()
symbolSize := s.MarkPoint.SymbolSize
if symbolSize == 0 {
symbolSize = 30
}
r := d.Render
// 设置填充样式
chart.Style{
FillColor: opt.FillColor,
}.WriteToRenderer(r)
// 设置文本样式
chart.Style{
FontColor: NewTheme(ThemeDark).GetTextColor(),
FontSize: labelFontSize,
StrokeWidth: 1,
Font: opt.Font,
}.WriteTextOptionsToRenderer(r)
for _, markPointData := range s.MarkPoint.Data {
p := points[summary.MinIndex]
value := summary.MinValue
switch markPointData.Type {
case SeriesMarkDataTypeMax:
p = points[summary.MaxIndex]
value = summary.MaxValue
}
d.pin(p.X, p.Y-symbolSize>>1, symbolSize)
text := commafWithDigits(value)
textBox := r.MeasureText(text)
d.text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2)
} }
} }
func (m *markPointPainter) Render() (Box, error) {
painter := m.p
theme := m.p.theme
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
}
painter.OverrideDrawingStyle(Style{
FillColor: opt.FillColor,
}).OverrideTextStyle(Style{
FontColor: theme.GetTextColor(),
FontSize: labelFontSize,
StrokeWidth: 1,
Font: opt.Font,
})
for _, markPointData := range s.MarkPoint.Data {
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)
painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2)
}
}
return BoxZero, nil
}

View file

@ -1,103 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestNewMarkPoint(t *testing.T) {
assert := assert.New(t)
markPoint := NewMarkPoint(
SeriesMarkDataTypeMax,
SeriesMarkDataTypeMin,
SeriesMarkDataTypeAverage,
)
assert.Equal(SeriesMarkPoint{
Data: []SeriesMarkData{
{
Type: SeriesMarkDataTypeMax,
},
{
Type: SeriesMarkDataTypeMin,
},
{
Type: SeriesMarkDataTypeAverage,
},
},
}, markPoint)
}
func TestMarkPointRender(t *testing.T) {
assert := assert.New(t)
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
}, PaddingOption(chart.Box{
Left: 20,
Right: 20,
}))
assert.Nil(err)
f, _ := chart.GetDefaultFont()
markPointRender(markPointRenderOption{
Draw: d,
FillColor: drawing.ColorBlack,
Font: f,
Series: &Series{
MarkPoint: NewMarkPoint(
SeriesMarkDataTypeMax,
SeriesMarkDataTypeMin,
),
Data: NewSeriesDataFromValues([]float64{
1,
3,
5,
}),
},
Points: []Point{
{
X: 1,
Y: 50,
},
{
X: 100,
Y: 100,
},
{
X: 200,
Y: 200,
},
},
})
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 217 192\nA 15 15 330.00 1 1 223 192\nL 220 178\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><path d=\"M 205 178\nQ220,215 235,178\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><text x=\"216\" y=\"183\" style=\"stroke-width:0;stroke:none;fill:rgba(238,238,238,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">5</text><path d=\"M 18 42\nA 15 15 330.00 1 1 24 42\nL 21 28\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><path d=\"M 6 28\nQ21,65 36,28\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><text x=\"17\" y=\"33\" style=\"stroke-width:0;stroke:none;fill:rgba(238,238,238,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">1</text></svg>", string(data))
}

736
painter.go Normal file
View file

@ -0,0 +1,736 @@
// 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"
"github.com/wcharczuk/go-chart/v2"
)
type Painter struct {
render chart.Renderer
box Box
font *truetype.Font
parent *Painter
style Style
theme ColorPalette
}
type PainterOptions struct {
// Draw type, "svg" or "png", default type is "svg"
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 {
Length int
Orient string
Count int
Unit int
}
type MultiTextOption struct {
TextList []string
Orient string
Unit int
Position string
Align string
}
type GridOption struct {
Column int
Row 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 new 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 := chart.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,
}
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{
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 {
for index, point := range points {
x := point.X
y := point.Y
if index == 0 {
p.MoveTo(x, y)
} 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) *Painter {
r := p.render
s := chart.Style{
FillColor: color,
}
// 背景色
p.SetDrawingStyle(s)
defer p.ResetStyle()
// 设置背景色不使用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) TextFit(body string, x, y, width int) 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
for index, line := range lines {
x0 := x
y0 := y + output.Height()
p.Text(line, x0, y0)
lineBox := r.MeasureText(line)
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
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%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
if containsString([]string{
PositionLeft,
PositionTop,
}, opt.Position) {
positionCenter = false
count--
}
width := p.Width()
height := p.Height()
var values []int
isVertical := opt.Orient == OrientVertical
if isVertical {
values = autoDivide(height, count)
} else {
values = autoDivide(width, count)
}
for index, text := range opt.TextList {
if index%opt.Unit != 0 {
continue
}
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
}
p.Text(text, x, y)
}
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,
},
})
}
}
if opt.Column > 0 {
values := autoDivide(width, opt.Column)
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) 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
}

View file

@ -26,217 +26,84 @@ import (
"math" "math"
"testing" "testing"
"github.com/golang/freetype/truetype"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing" "github.com/wcharczuk/go-chart/v2/drawing"
) )
func TestParentOption(t *testing.T) { func TestPainterOption(t *testing.T) {
assert := assert.New(t)
p, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
assert.Nil(err)
d, err := NewDraw(DrawOption{
Parent: p,
})
assert.Nil(err)
assert.Equal(p, d.parent)
}
func TestWidthHeightOption(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
// no parent font := &truetype.Font{}
width := 300 d, err := NewPainter(PainterOptions{
height := 200 Width: 800,
d, err := NewDraw(DrawOption{ Height: 600,
Width: width, },
Height: height, PainterBoxOption(Box{
}) Right: 400,
assert.Nil(err) Bottom: 300,
assert.Equal(chart.Box{ }),
Top: 0, PainterPaddingOption(Box{
Left: 0,
Right: width,
Bottom: height,
}, d.Box)
width = 500
height = 600
// with parent
p, err := NewDraw(
DrawOption{
Width: width,
Height: height,
},
PaddingOption(chart.NewBox(5, 5, 5, 5)),
)
assert.Nil(err)
d, err = NewDraw(
DrawOption{
Parent: p,
},
PaddingOption(chart.NewBox(1, 2, 3, 4)),
)
assert.Nil(err)
assert.Equal(chart.Box{
Top: 6,
Left: 7,
Right: 492,
Bottom: 591,
}, d.Box)
}
func TestBoxOption(t *testing.T) {
assert := assert.New(t)
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
assert.Nil(err)
err = BoxOption(chart.Box{
Left: 10,
Top: 20,
Right: 50,
Bottom: 100,
})(d)
assert.Nil(err)
assert.Equal(chart.Box{
Left: 10,
Top: 20,
Right: 50,
Bottom: 100,
}, d.Box)
// zero box will be ignored
err = BoxOption(chart.Box{})(d)
assert.Nil(err)
assert.Equal(chart.Box{
Left: 10,
Top: 20,
Right: 50,
Bottom: 100,
}, d.Box)
}
func TestPaddingOption(t *testing.T) {
assert := assert.New(t)
d, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
assert.Nil(err)
// 默认的box
assert.Equal(chart.Box{
Right: 400,
Bottom: 300,
}, d.Box)
// 设置padding之后的box
d, err = NewDraw(DrawOption{
Width: 400,
Height: 300,
}, PaddingOption(chart.Box{
Left: 1,
Top: 2,
Right: 3,
Bottom: 4,
}))
assert.Nil(err)
assert.Equal(chart.Box{
Top: 2,
Left: 1,
Right: 397,
Bottom: 296,
}, d.Box)
p := d
// 设置父元素之后的box
d, err = NewDraw(
DrawOption{
Parent: p,
},
PaddingOption(chart.Box{
Left: 1, Left: 1,
Top: 2, Top: 2,
Right: 3, Right: 3,
Bottom: 4, Bottom: 4,
}), }),
PainterFontOption(font),
PainterStyleOption(Style{
ClassName: "test",
}),
) )
assert.Nil(err) assert.Nil(err)
assert.Equal(chart.Box{ assert.Equal(Box{
Top: 4, Left: 1,
Left: 2, Top: 2,
Right: 394, Right: 397,
Bottom: 292, Bottom: 296,
}, d.Box) }, d.box)
assert.Equal(font, d.font)
assert.Equal("test", d.style.ClassName)
} }
func TestParentTop(t *testing.T) { func TestPainter(t *testing.T) {
assert := assert.New(t)
d1, err := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
assert.Nil(err)
d2, err := NewDraw(DrawOption{
Parent: d1,
})
assert.Nil(err)
d3, err := NewDraw(DrawOption{
Parent: d2,
})
assert.Nil(err)
assert.Equal(d2, d3.Parent())
assert.Equal(d1, d2.Parent())
assert.Equal(d1, d3.Top())
assert.Equal(d1, d2.Top())
}
func TestDraw(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
tests := []struct { tests := []struct {
fn func(d *Draw) fn func(*Painter)
result string result string
}{ }{
// moveTo, lineTo // moveTo, lineTo
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
d.moveTo(1, 1) p.MoveTo(1, 1)
d.lineTo(2, 2) p.LineTo(2, 2)
d.Render.Stroke() p.Stroke()
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 6 11\nL 7 12\" style=\"stroke-width:0;stroke:none;fill:none\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 6 11\nL 7 12\" style=\"stroke-width:0;stroke:none;fill:none\"/></svg>",
}, },
// circle // circle
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
d.circle(5, 2, 3) p.Circle(5, 2, 3)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"7\" cy=\"13\" r=\"5\" style=\"stroke-width:0;stroke:none;fill:none\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"7\" cy=\"13\" r=\"5\" style=\"stroke-width:0;stroke:none;fill:none\"/></svg>",
}, },
// text // text
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
d.text("hello world!", 3, 6) p.Text("hello world!", 3, 6)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"8\" y=\"16\" style=\"stroke-width:0;stroke:none;fill:none\">hello world!</text></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"8\" y=\"16\" style=\"stroke-width:0;stroke:none;fill:none;font-family:'Roboto Medium',sans-serif\">hello world!</text></svg>",
}, },
// line stroke // line stroke
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
d.lineStroke([]Point{ p.SetDrawingStyle(Style{
StrokeColor: drawing.ColorBlack,
StrokeWidth: 1,
})
p.LineStroke([]Point{
{ {
X: 1, X: 1,
Y: 2, Y: 2,
@ -245,38 +112,35 @@ func TestDraw(t *testing.T) {
X: 3, X: 3,
Y: 4, Y: 4,
}, },
}, LineStyle{
StrokeColor: drawing.ColorBlack,
StrokeWidth: 1,
}) })
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 6 12\nL 8 14\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 6 12\nL 8 14\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
}, },
// set background // set background
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
d.setBackground(400, 300, chart.ColorWhite) p.SetBackground(400, 300, chart.ColorWhite)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 0\nL 400 0\nL 400 300\nL 0 300\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 0\nL 400 0\nL 400 300\nL 0 300\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/></svg>",
}, },
// arcTo // arcTo
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
chart.Style{ p.SetStyle(Style{
StrokeWidth: 1, StrokeWidth: 1,
StrokeColor: drawing.ColorBlack, StrokeColor: drawing.ColorBlack,
FillColor: drawing.ColorBlue, FillColor: drawing.ColorBlue,
}.WriteToRenderer(d.Render) })
d.arcTo(100, 100, 100, 100, 0, math.Pi/2) p.ArcTo(100, 100, 100, 100, 0, math.Pi/2)
d.Render.Close() p.Close()
d.Render.FillStroke() p.FillStroke()
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 205 110\nA 100 100 90.00 0 1 105 210\nZ\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,255,1.0)\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 205 110\nA 100 100 90.00 0 1 105 210\nZ\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,255,1.0)\"/></svg>",
}, },
// pin // pin
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
chart.Style{ p.SetStyle(Style{
StrokeWidth: 1, StrokeWidth: 1,
StrokeColor: drawing.Color{ StrokeColor: drawing.Color{
R: 84, R: 84,
@ -290,15 +154,15 @@ func TestDraw(t *testing.T) {
B: 198, B: 198,
A: 255, A: 255,
}, },
}.WriteToRenderer(d.Render) })
d.pin(30, 30, 30) p.Pin(30, 30, 30)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 32 47\nA 15 15 330.00 1 1 38 47\nL 35 33\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 20 33\nQ35,70 50,33\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 32 47\nA 15 15 330.00 1 1 38 47\nL 35 33\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 20 33\nQ35,70 50,33\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
}, },
// arrow left // arrow left
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
chart.Style{ p.SetStyle(Style{
StrokeWidth: 1, StrokeWidth: 1,
StrokeColor: drawing.Color{ StrokeColor: drawing.Color{
R: 84, R: 84,
@ -312,15 +176,15 @@ func TestDraw(t *testing.T) {
B: 198, B: 198,
A: 255, A: 255,
}, },
}.WriteToRenderer(d.Render) })
d.arrowLeft(30, 30, 16, 10) p.ArrowLeft(30, 30, 16, 10)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 51 35\nL 35 40\nL 51 45\nL 46 40\nL 51 35\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 51 35\nL 35 40\nL 51 45\nL 46 40\nL 51 35\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
}, },
// arrow right // arrow right
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
chart.Style{ p.SetStyle(Style{
StrokeWidth: 1, StrokeWidth: 1,
StrokeColor: drawing.Color{ StrokeColor: drawing.Color{
R: 84, R: 84,
@ -334,15 +198,15 @@ func TestDraw(t *testing.T) {
B: 198, B: 198,
A: 255, A: 255,
}, },
}.WriteToRenderer(d.Render) })
d.arrowRight(30, 30, 16, 10) p.ArrowRight(30, 30, 16, 10)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 19 35\nL 35 40\nL 19 45\nL 24 40\nL 19 35\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 19 35\nL 35 40\nL 19 45\nL 24 40\nL 19 35\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
}, },
// arrow top // arrow top
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
chart.Style{ p.SetStyle(Style{
StrokeWidth: 1, StrokeWidth: 1,
StrokeColor: drawing.Color{ StrokeColor: drawing.Color{
R: 84, R: 84,
@ -356,15 +220,15 @@ func TestDraw(t *testing.T) {
B: 198, B: 198,
A: 255, A: 255,
}, },
}.WriteToRenderer(d.Render) })
d.arrowTop(30, 30, 10, 16) p.ArrowTop(30, 30, 10, 16)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 30 40\nL 35 24\nL 40 40\nL 35 35\nL 30 40\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 30 40\nL 35 24\nL 40 40\nL 35 35\nL 30 40\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
}, },
// arrow bottom // arrow bottom
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
chart.Style{ p.SetStyle(Style{
StrokeWidth: 1, StrokeWidth: 1,
StrokeColor: drawing.Color{ StrokeColor: drawing.Color{
R: 84, R: 84,
@ -378,15 +242,15 @@ func TestDraw(t *testing.T) {
B: 198, B: 198,
A: 255, A: 255,
}, },
}.WriteToRenderer(d.Render) })
d.arrowBottom(30, 30, 10, 16) p.ArrowBottom(30, 30, 10, 16)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 30 24\nL 35 40\nL 40 24\nL 35 30\nL 30 24\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 30 24\nL 35 40\nL 40 24\nL 35 30\nL 30 24\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
}, },
// mark line // mark line
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
chart.Style{ p.SetStyle(Style{
StrokeWidth: 1, StrokeWidth: 1,
StrokeColor: drawing.Color{ StrokeColor: drawing.Color{
R: 84, R: 84,
@ -404,15 +268,15 @@ func TestDraw(t *testing.T) {
4, 4,
2, 2,
}, },
}.WriteToRenderer(d.Render) })
d.makeLine(0, 20, 300) p.MarkLine(0, 20, 300)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"5\" cy=\"30\" r=\"3\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 10 30\nL 289 30\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 289 25\nL 305 30\nL 289 35\nL 294 30\nL 289 25\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"8\" cy=\"30\" r=\"3\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 14 30\nL 289 30\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 289 25\nL 305 30\nL 289 35\nL 294 30\nL 289 25\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
}, },
// polygon // polygon
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
chart.Style{ p.SetStyle(Style{
StrokeWidth: 1, StrokeWidth: 1,
StrokeColor: drawing.Color{ StrokeColor: drawing.Color{
R: 84, R: 84,
@ -420,18 +284,26 @@ func TestDraw(t *testing.T) {
B: 198, B: 198,
A: 255, A: 255,
}, },
}.WriteToRenderer(d.Render) })
d.polygon(Point{ p.Polygon(Point{
X: 100, X: 100,
Y: 100, Y: 100,
}, 50, 6) }, 50, 6)
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 105 60\nL 148 85\nL 148 134\nL 105 160\nL 62 135\nL 62 86\nL 105 60\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:none\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 105 60\nL 148 85\nL 148 134\nL 105 160\nL 62 135\nL 62 86\nL 105 60\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:none\"/></svg>",
}, },
// fill // FillArea
{ {
fn: func(d *Draw) { fn: func(p *Painter) {
d.fill([]Point{ p.SetDrawingStyle(Style{
FillColor: drawing.Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
})
p.FillArea([]Point{
{ {
X: 0, X: 0,
Y: 0, Y: 0,
@ -448,23 +320,16 @@ func TestDraw(t *testing.T) {
X: 0, X: 0,
Y: 0, Y: 0,
}, },
}, chart.Style{
FillColor: drawing.Color{
R: 84,
G: 112,
B: 198,
A: 255,
},
}) })
}, },
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 10\nL 5 110\nL 105 110\nL 5 10\" style=\"stroke-width:0;stroke:none;fill:rgba(84,112,198,1.0)\"/></svg>", result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 10\nL 5 110\nL 105 110\nL 5 10\" style=\"stroke-width:0;stroke:none;fill:rgba(84,112,198,1.0)\"/></svg>",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
d, err := NewDraw(DrawOption{ d, err := NewPainter(PainterOptions{
Width: 400, Width: 400,
Height: 300, Height: 300,
}, PaddingOption(chart.Box{ }, PainterPaddingOption(chart.Box{
Left: 5, Left: 5,
Top: 10, Top: 10,
})) }))
@ -476,32 +341,33 @@ func TestDraw(t *testing.T) {
} }
} }
func TestDrawTextFit(t *testing.T) { func TestPainterTextFit(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
d, err := NewDraw(DrawOption{ p, err := NewPainter(PainterOptions{
Width: 400, Width: 400,
Height: 300, Height: 300,
}) })
assert.Nil(err) assert.Nil(err)
f, _ := chart.GetDefaultFont() f, _ := chart.GetDefaultFont()
style := chart.Style{ style := Style{
FontSize: 12, FontSize: 12,
FontColor: chart.ColorBlack, FontColor: chart.ColorBlack,
Font: f, Font: f,
} }
box := d.textFit("Hello World!", 0, 20, 80, style) p.SetStyle(style)
box := p.TextFit("Hello World!", 0, 20, 80)
assert.Equal(chart.Box{ assert.Equal(chart.Box{
Right: 45, Right: 45,
Bottom: 35, Bottom: 35,
}, box) }, box)
box = d.textFit("Hello World!", 0, 100, 200, style) box = p.TextFit("Hello World!", 0, 100, 200)
assert.Equal(chart.Box{ assert.Equal(chart.Box{
Right: 84, Right: 84,
Bottom: 15, Bottom: 15,
}, box) }, box)
buf, err := d.Bytes() buf, err := p.Bytes()
assert.Nil(err) assert.Nil(err)
assert.Equal(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">\n<text x="0" y="20" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">Hello</text><text x="0" y="40" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">World!</text><text x="0" y="100" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">Hello World!</text></svg>`, string(buf)) assert.Equal(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">\n<text x="0" y="20" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">Hello</text><text x="0" y="40" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">World!</text><text x="0" y="100" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">Hello World!</text></svg>`, string(buf))
} }

View file

@ -30,35 +30,43 @@ import (
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
) )
func getPieStyle(theme *Theme, index int) chart.Style { type pieChart struct {
seriesColor := theme.GetSeriesColor(index) p *Painter
return chart.Style{ opt *PieChartOption
StrokeColor: seriesColor,
StrokeWidth: 1,
FillColor: seriesColor,
}
} }
type pieChartOption struct { type PieChartOption struct {
Theme string Theme ColorPalette
Font *truetype.Font // The font size
Font *truetype.Font
// The data series list
SeriesList SeriesList 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
} }
func pieChartRender(opt pieChartOption, result *basicRenderResult) error { func NewPieChart(p *Painter, opt PieChartOption) *pieChart {
d, err := NewDraw(DrawOption{ if opt.Theme == nil {
Parent: result.d, opt.Theme = defaultTheme
}, PaddingOption(chart.Box{
Top: result.titleBox.Height(),
}))
if err != nil {
return err
} }
return &pieChart{
p: p,
opt: &opt,
}
}
values := make([]float64, len(opt.SeriesList)) func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) {
opt := p.opt
values := make([]float64, len(seriesList))
total := float64(0) total := float64(0)
radiusValue := "" radiusValue := ""
for index, series := range opt.SeriesList { for index, series := range seriesList {
if len(series.Radius) != 0 { if len(series.Radius) != 0 {
radiusValue = series.Radius radiusValue = series.Radius
} }
@ -70,16 +78,13 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
total += value total += value
} }
if total <= 0 { if total <= 0 {
return errors.New("The sum value of pie chart should gt 0") return BoxZero, errors.New("The sum value of pie chart should gt 0")
} }
r := d.Render seriesPainter := result.seriesPainter
theme := NewTheme(opt.Theme) cx := seriesPainter.Width() >> 1
cy := seriesPainter.Height() >> 1
box := d.Box diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
cx := box.Width() >> 1
cy := box.Height() >> 1
diameter := chart.MinInt(box.Width(), box.Height())
radius := getRadius(float64(diameter), radiusValue) radius := getRadius(float64(diameter), radiusValue)
labelLineWidth := 15 labelLineWidth := 15
@ -87,32 +92,40 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
labelLineWidth = 10 labelLineWidth = 10
} }
labelRadius := radius + float64(labelLineWidth) labelRadius := radius + float64(labelLineWidth)
seriesNames := opt.Legend.Data
seriesNames := opt.SeriesList.Names() if len(seriesNames) == 0 {
seriesNames = seriesList.Names()
}
theme := opt.Theme
if len(values) == 1 { if len(values) == 1 {
getPieStyle(theme, 0).WriteToRenderer(r) seriesPainter.OverrideDrawingStyle(Style{
d.moveTo(cx, cy) StrokeWidth: 1,
d.circle(radius, cx, cy) StrokeColor: theme.GetSeriesColor(0),
FillColor: theme.GetSeriesColor(0),
})
seriesPainter.MoveTo(cx, cy).
Circle(radius, cx, cy)
} else { } else {
currentValue := float64(0) currentValue := float64(0)
prevEndX := 0 prevEndX := 0
prevEndY := 0 prevEndY := 0
for index, v := range values { for index, v := range values {
seriesPainter.OverrideDrawingStyle(Style{
pieStyle := getPieStyle(theme, index) StrokeWidth: 1,
pieStyle.WriteToRenderer(r) StrokeColor: theme.GetSeriesColor(index),
d.moveTo(cx, cy) FillColor: theme.GetSeriesColor(index),
})
seriesPainter.MoveTo(cx, cy)
start := chart.PercentToRadians(currentValue/total) - math.Pi/2 start := chart.PercentToRadians(currentValue/total) - math.Pi/2
currentValue += v currentValue += v
percent := (v / total) percent := (v / total)
delta := chart.PercentToRadians(percent) delta := chart.PercentToRadians(percent)
d.arcTo(cx, cy, radius, radius, start, delta) seriesPainter.ArcTo(cx, cy, radius, radius, start, delta).
d.lineTo(cx, cy) LineTo(cx, cy).
r.Close() Close().
r.FillStroke() FillStroke()
series := opt.SeriesList[index] series := seriesList[index]
// 是否显示label // 是否显示label
showLabel := series.Label.Show showLabel := series.Label.Show
if !showLabel { if !showLabel {
@ -134,17 +147,19 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
} }
prevEndX = endx prevEndX = endx
prevEndY = endy prevEndY = endy
d.moveTo(startx, starty)
d.lineTo(endx, endy) seriesPainter.MoveTo(startx, starty)
seriesPainter.LineTo(endx, endy)
offset := labelLineWidth offset := labelLineWidth
if endx < cx { if endx < cx {
offset *= -1 offset *= -1
} }
d.moveTo(endx, endy) seriesPainter.MoveTo(endx, endy)
endx += offset endx += offset
d.lineTo(endx, endy) seriesPainter.LineTo(endx, endy)
r.Stroke() seriesPainter.Stroke()
textStyle := chart.Style{
textStyle := Style{
FontColor: theme.GetTextColor(), FontColor: theme.GetTextColor(),
FontSize: labelFontSize, FontSize: labelFontSize,
Font: opt.Font, Font: opt.Font,
@ -152,9 +167,9 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
if !series.Label.Color.IsZero() { if !series.Label.Color.IsZero() {
textStyle.FontColor = series.Label.Color textStyle.FontColor = series.Label.Color
} }
textStyle.GetTextOptions().WriteToRenderer(r) seriesPainter.OverrideTextStyle(textStyle)
text := NewPieLabelFormatter(seriesNames, series.Label.Formatter)(index, v, percent) text := NewPieLabelFormatter(seriesNames, series.Label.Formatter)(index, v, percent)
textBox := r.MeasureText(text) textBox := seriesPainter.MeasureText(text)
textMargin := 3 textMargin := 3
x := endx + textMargin x := endx + textMargin
y := endy + textBox.Height()>>1 - 1 y := endy + textBox.Height()>>1 - 1
@ -162,8 +177,35 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
textWidth := textBox.Width() textWidth := textBox.Width()
x = endx - textWidth - textMargin x = endx - textWidth - textMargin
} }
d.text(text, x, y) seriesPainter.Text(text, x, y)
} }
} }
return nil
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)
} }

View file

@ -1,69 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestPieChartRender(t *testing.T) {
assert := assert.New(t)
d, err := NewDraw(DrawOption{
Width: 250,
Height: 150,
})
assert.Nil(err)
f, _ := chart.GetDefaultFont()
err = pieChartRender(pieChartOption{
Font: f,
SeriesList: NewPieSeriesList([]float64{
5,
10,
0,
}, PieSeriesOption{
Names: []string{
"a",
"b",
"c",
},
Label: SeriesLabel{
Show: true,
Color: drawing.ColorRed,
},
Radius: "20%",
}),
}, &basicRenderResult{
d: d,
})
assert.Nil(err)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"250\" height=\"150\">\\n<path d=\"M 125 75\nL 125 45\nA 30 30 120.00 0 1 150 89\nL 125 75\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 150 60\nL 159 55\nM 159 55\nL 169 55\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"172\" y=\"60\" style=\"stroke-width:0;stroke:none;fill:rgba(255,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">a: 33.33%</text><path d=\"M 125 75\nL 150 89\nA 30 30 240.00 1 1 125 45\nL 125 75\nZ\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"M 100 90\nL 91 95\nM 91 95\nL 81 95\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><text x=\"22\" y=\"100\" style=\"stroke-width:0;stroke:none;fill:rgba(255,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">b: 66.66%</text><path d=\"M 125 75\nL 125 45\nA 30 30 0.00 0 1 125 45\nL 125 75\nZ\" style=\"stroke-width:1;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><path d=\"M 125 45\nL 125 35\nM 125 35\nL 135 35\" style=\"stroke-width:1;stroke:rgba(250,200,88,1.0);fill:rgba(250,200,88,1.0)\"/><text x=\"138\" y=\"40\" style=\"stroke-width:0;stroke:none;fill:rgba(255,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">c: 0%</text></svg>", string(data))
}

View file

@ -30,6 +30,11 @@ import (
"github.com/wcharczuk/go-chart/v2/drawing" "github.com/wcharczuk/go-chart/v2/drawing"
) )
type radarChart struct {
p *Painter
opt *RadarChartOption
}
type RadarIndicator struct { type RadarIndicator struct {
// Indicator's name // Indicator's name
Name string Name string
@ -39,89 +44,101 @@ type RadarIndicator struct {
Min float64 Min float64
} }
type radarChartOption struct { type RadarChartOption struct {
Theme string Theme ColorPalette
Font *truetype.Font // The font size
Font *truetype.Font
// The data series list
SeriesList SeriesList SeriesList SeriesList
Indicators []RadarIndicator // 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
} }
func radarChartRender(opt radarChartOption, result *basicRenderResult) error { func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart {
sides := len(opt.Indicators) if opt.Theme == nil {
if sides < 3 { opt.Theme = defaultTheme
return errors.New("The count of indicator should be >= 3")
} }
maxValues := make([]float64, len(opt.Indicators)) return &radarChart{
for _, series := range opt.SeriesList { 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 { for index, item := range series.Data {
if index < len(maxValues) && item.Value > maxValues[index] { if index < len(maxValues) && item.Value > maxValues[index] {
maxValues[index] = item.Value maxValues[index] = item.Value
} }
} }
} }
for index, indicator := range opt.Indicators { for index, indicator := range indicators {
if indicator.Max <= 0 { if indicator.Max <= 0 {
opt.Indicators[index].Max = maxValues[index] indicators[index].Max = maxValues[index]
} }
} }
d, err := NewDraw(DrawOption{
Parent: result.d,
}, PaddingOption(chart.Box{
Top: result.titleBox.Height(),
}))
if err != nil {
return err
}
radiusValue := "" radiusValue := ""
for _, series := range opt.SeriesList { for _, series := range seriesList {
if len(series.Radius) != 0 { if len(series.Radius) != 0 {
radiusValue = series.Radius radiusValue = series.Radius
} }
} }
box := d.Box seriesPainter := result.seriesPainter
cx := box.Width() >> 1 theme := opt.Theme
cy := box.Height() >> 1
diameter := chart.MinInt(box.Width(), box.Height())
radius := getRadius(float64(diameter), radiusValue)
theme := NewTheme(opt.Theme) cx := seriesPainter.Width() >> 1
cy := seriesPainter.Height() >> 1
diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height())
radius := getRadius(float64(diameter), radiusValue)
divideCount := 5 divideCount := 5
divideRadius := float64(int(radius / float64(divideCount))) divideRadius := float64(int(radius / float64(divideCount)))
radius = divideRadius * float64(divideCount) radius = divideRadius * float64(divideCount)
style := chart.Style{ seriesPainter.OverrideDrawingStyle(Style{
StrokeColor: theme.GetAxisSplitLineColor(), StrokeColor: theme.GetAxisSplitLineColor(),
StrokeWidth: 1, StrokeWidth: 1,
} })
r := d.Render
style.WriteToRenderer(r)
center := Point{ center := Point{
X: cx, X: cx,
Y: cy, Y: cy,
} }
for i := 0; i < divideCount; i++ { for i := 0; i < divideCount; i++ {
d.polygon(center, divideRadius*float64(i+1), sides) seriesPainter.Polygon(center, divideRadius*float64(i+1), sides)
} }
points := getPolygonPoints(center, radius, sides) points := getPolygonPoints(center, radius, sides)
for _, p := range points { for _, p := range points {
d.moveTo(center.X, center.Y) seriesPainter.MoveTo(center.X, center.Y)
d.lineTo(p.X, p.Y) seriesPainter.LineTo(p.X, p.Y)
d.Render.Stroke() seriesPainter.Stroke()
} }
// 文本 seriesPainter.OverrideTextStyle(Style{
textStyle := chart.Style{
FontColor: theme.GetTextColor(), FontColor: theme.GetTextColor(),
FontSize: labelFontSize, FontSize: labelFontSize,
Font: opt.Font, Font: opt.Font,
} })
textStyle.GetTextOptions().WriteToRenderer(r)
offset := 5 offset := 5
// 文本生成 // 文本生成
for index, p := range points { for index, p := range points {
name := opt.Indicators[index].Name name := indicators[index].Name
b := r.MeasureText(name) b := seriesPainter.MeasureText(name)
isXCenter := p.X == center.X isXCenter := p.X == center.X
isYCenter := p.Y == center.Y isYCenter := p.Y == center.Y
isRight := p.X > center.X isRight := p.X > center.X
@ -153,19 +170,19 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error {
if isLeft { if isLeft {
x -= (b.Width() + offset) x -= (b.Width() + offset)
} }
d.text(name, x, y) seriesPainter.Text(name, x, y)
} }
// 雷达图 // 雷达图
angles := getPolygonPointAngles(sides) angles := getPolygonPointAngles(sides)
maxCount := len(opt.Indicators) maxCount := len(indicators)
for _, series := range opt.SeriesList { for _, series := range seriesList {
linePoints := make([]Point, 0, maxCount) linePoints := make([]Point, 0, maxCount)
for j, item := range series.Data { for j, item := range series.Data {
if j >= maxCount { if j >= maxCount {
continue continue
} }
indicator := opt.Indicators[j] indicator := indicators[j]
percent := (item.Value - indicator.Min) / (indicator.Max - indicator.Min) percent := (item.Value - indicator.Min) / (indicator.Max - indicator.Min)
r := percent * radius r := percent * radius
p := getPolygonPoint(center, r, angles[j]) p := getPolygonPoint(center, r, angles[j])
@ -177,17 +194,52 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error {
dotFillColor = color dotFillColor = color
} }
linePoints = append(linePoints, linePoints[0]) linePoints = append(linePoints, linePoints[0])
s := LineStyle{ seriesPainter.OverrideDrawingStyle(Style{
StrokeColor: color, StrokeColor: color,
StrokeWidth: defaultStrokeWidth, StrokeWidth: defaultStrokeWidth,
DotWidth: defaultDotWidth, DotWidth: defaultDotWidth,
DotColor: color, DotColor: color,
DotFillColor: dotFillColor, FillColor: color.WithAlpha(20),
FillColor: color.WithAlpha(20), })
seriesPainter.LineStroke(linePoints).
FillArea(linePoints)
dotWith := 2.0
seriesPainter.OverrideDrawingStyle(Style{
StrokeWidth: defaultStrokeWidth,
StrokeColor: color,
FillColor: dotFillColor,
})
for _, point := range linePoints {
seriesPainter.Circle(dotWith, point.X, point.Y)
seriesPainter.FillStroke()
} }
d.lineStroke(linePoints, s)
d.fill(linePoints, s.Style())
d.lineDot(linePoints[0:len(linePoints)-1], s)
} }
return nil
return r.p.box, nil
}
func (r *radarChart) Render() (Box, error) {
p := r.p
opt := r.opt
renderResult, err := defaultRender(p, defaultRenderOption{
Theme: opt.Theme,
Padding: opt.Padding,
SeriesList: opt.SeriesList,
XAxis: XAxisOption{
Show: FalseFlag(),
},
YAxisOptions: []YAxisOption{
{
Show: FalseFlag(),
},
},
TitleOption: opt.Title,
LegendOption: opt.Legend,
backgroundIsFilled: opt.backgroundIsFilled,
})
if err != nil {
return BoxZero, err
}
seriesList := opt.SeriesList.Filter(ChartTypeRadar)
return r.render(renderResult, seriesList)
} }

File diff suppressed because one or more lines are too long

View file

@ -26,15 +26,31 @@ import (
"math" "math"
) )
type Range struct { const defaultAxisDivideCount = 6
type axisRange struct {
divideCount int divideCount int
min float64
max float64
size int
boundary bool
}
type AxisRangeOption struct {
Min float64 Min float64
Max float64 Max float64
Size int Size int
Boundary bool Boundary bool
DivideCount int
} }
func NewRange(min, max float64, divideCount int) 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) r := math.Abs(max - min)
// 最小单位计算 // 最小单位计算
@ -63,47 +79,49 @@ func NewRange(min, max float64, divideCount int) Range {
} }
} }
max = min + float64(unit*divideCount) max = min + float64(unit*divideCount)
return Range{ return axisRange{
Min: min,
Max: max,
divideCount: divideCount, divideCount: divideCount,
min: min,
max: max,
size: opt.Size,
boundary: opt.Boundary,
} }
} }
func (r Range) Values() []string { func (r axisRange) Values() []string {
offset := (r.Max - r.Min) / float64(r.divideCount) offset := (r.max - r.min) / float64(r.divideCount)
values := make([]string, 0) values := make([]string, 0)
for i := 0; i <= r.divideCount; i++ { for i := 0; i <= r.divideCount; i++ {
v := r.Min + float64(i)*offset v := r.min + float64(i)*offset
value := commafWithDigits(v) value := commafWithDigits(v)
values = append(values, value) values = append(values, value)
} }
return values return values
} }
func (r *Range) getHeight(value float64) int { func (r *axisRange) getHeight(value float64) int {
v := (value - r.Min) / (r.Max - r.Min) v := (value - r.min) / (r.max - r.min)
return int(v * float64(r.Size)) return int(v * float64(r.size))
} }
func (r *Range) getRestHeight(value float64) int { func (r *axisRange) getRestHeight(value float64) int {
return r.Size - r.getHeight(value) return r.size - r.getHeight(value)
} }
func (r *Range) GetRange(index int) (float64, float64) { func (r *axisRange) GetRange(index int) (float64, float64) {
unit := float64(r.Size) / float64(r.divideCount) unit := float64(r.size) / float64(r.divideCount)
return unit * float64(index), unit * float64(index+1) return unit * float64(index), unit * float64(index+1)
} }
func (r *Range) AutoDivide() []int { func (r *axisRange) AutoDivide() []int {
return autoDivide(r.Size, r.divideCount) return autoDivide(r.size, r.divideCount)
} }
func (r *Range) getWidth(value float64) int { func (r *axisRange) getWidth(value float64) int {
v := value / (r.Max - r.Min) v := value / (r.max - r.min)
// 移至居中 // 移至居中
if r.Boundary && if r.boundary &&
r.divideCount != 0 { r.divideCount != 0 {
v += 1 / float64(r.divideCount*2) v += 1 / float64(r.divideCount*2)
} }
return int(v * float64(r.Size)) return int(v * float64(r.size))
} }

View file

@ -1,94 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRange(t *testing.T) {
assert := assert.New(t)
r := NewRange(0, 8, 6)
assert.Equal(0.0, r.Min)
assert.Equal(12.0, r.Max)
r = NewRange(0, 12, 6)
assert.Equal(0.0, r.Min)
assert.Equal(24.0, r.Max)
r = NewRange(-13, 18, 6)
assert.Equal(-20.0, r.Min)
assert.Equal(40.0, r.Max)
r = NewRange(0, 150, 6)
assert.Equal(0.0, r.Min)
assert.Equal(180.0, r.Max)
r = NewRange(0, 400, 6)
assert.Equal(0.0, r.Min)
assert.Equal(480.0, r.Max)
}
func TestRangeHeightWidth(t *testing.T) {
assert := assert.New(t)
r := NewRange(0, 8, 6)
r.Size = 100
assert.Equal(33, r.getHeight(4))
assert.Equal(67, r.getRestHeight(4))
assert.Equal(33, r.getWidth(4))
r.Boundary = true
assert.Equal(41, r.getWidth(4))
}
func TestRangeGetRange(t *testing.T) {
assert := assert.New(t)
r := NewRange(0, 8, 6)
r.Size = 120
f1, f2 := r.GetRange(0)
assert.Equal(0.0, f1)
assert.Equal(20.0, f2)
f1, f2 = r.GetRange(2)
assert.Equal(40.0, f1)
assert.Equal(60.0, f2)
}
func TestRangeAutoDivide(t *testing.T) {
assert := assert.New(t)
r := Range{
Size: 120,
divideCount: 6,
}
assert.Equal([]int{0, 20, 40, 60, 80, 100, 120}, r.AutoDivide())
r.Size = 130
assert.Equal([]int{0, 22, 44, 66, 88, 109, 130}, r.AutoDivide())
}

View file

@ -19,7 +19,6 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
package charts package charts
import ( import (
@ -28,14 +27,21 @@ import (
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
) )
type SeriesData struct { type SeriesData struct {
// The value of series data // The value of series data
Value float64 Value float64
// The style of series data // The style of series data
Style chart.Style Style Style
}
func NewSeriesListDataFromValues(values [][]float64, chartType ...string) SeriesList {
seriesList := make(SeriesList, len(values))
for index, value := range values {
seriesList[index] = NewSeriesFromValues(value, chartType...)
}
return seriesList
} }
func NewSeriesFromValues(values []float64, chartType ...string) Series { func NewSeriesFromValues(values []float64, chartType ...string) Series {
@ -65,7 +71,7 @@ type SeriesLabel struct {
// {d}: the percent of a data item(pie chart). // {d}: the percent of a data item(pie chart).
Formatter string Formatter string
// The color for label // The color for label
Color drawing.Color Color Color
// Show flag for label // Show flag for label
Show bool Show bool
// Distance to the host graphic element. // Distance to the host graphic element.
@ -101,8 +107,8 @@ type Series struct {
// The data list of series // The data list of series
Data []SeriesData Data []SeriesData
// The Y axis index, it should be 0 or 1. // The Y axis index, it should be 0 or 1.
// Default value is 1 // Default value is 0
YAxisIndex int AxisIndex int
// The style for series // The style for series
Style chart.Style Style chart.Style
// The label for series // The label for series
@ -122,6 +128,54 @@ type Series struct {
} }
type SeriesList []Series type SeriesList []Series
func (sl SeriesList) init() {
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) reverse() {
for i, j := 0, len(sl)-1; i < j; i, j = i+1, j-1 {
sl[i], sl[j] = sl[j], sl[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 > max {
max = item.Value
}
if item.Value < min {
min = item.Value
}
}
}
return max, min
}
type PieSeriesOption struct { type PieSeriesOption struct {
Radius string Radius string
Label SeriesLabel Label SeriesLabel

View file

@ -1,166 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewSeriesFromValues(t *testing.T) {
assert := assert.New(t)
assert.Equal(Series{
Data: []SeriesData{
{
Value: 1,
},
{
Value: 2,
},
},
Type: ChartTypeBar,
}, NewSeriesFromValues([]float64{
1,
2,
}, ChartTypeBar))
}
func TestNewSeriesDataFromValues(t *testing.T) {
assert := assert.New(t)
assert.Equal([]SeriesData{
{
Value: 1,
},
{
Value: 2,
},
}, NewSeriesDataFromValues([]float64{
1,
2,
}))
}
func TestNewPieSeriesList(t *testing.T) {
assert := assert.New(t)
assert.Equal(SeriesList{
{
Type: ChartTypePie,
Name: "a",
Label: SeriesLabel{
Show: true,
},
Radius: "30%",
Data: []SeriesData{
{
Value: 1,
},
},
},
{
Type: ChartTypePie,
Name: "b",
Label: SeriesLabel{
Show: true,
},
Radius: "30%",
Data: []SeriesData{
{
Value: 2,
},
},
},
}, NewPieSeriesList([]float64{
1,
2,
}, PieSeriesOption{
Radius: "30%",
Label: SeriesLabel{
Show: true,
},
Names: []string{
"a",
"b",
},
}))
}
func TestSeriesSummary(t *testing.T) {
assert := assert.New(t)
s := Series{
Data: NewSeriesDataFromValues([]float64{
1,
3,
5,
7,
9,
}),
}
assert.Equal(seriesSummary{
MaxIndex: 4,
MaxValue: 9,
MinIndex: 0,
MinValue: 1,
AverageValue: 5,
}, s.Summary())
}
func TestGetSeriesNames(t *testing.T) {
assert := assert.New(t)
sl := SeriesList{
{
Name: "a",
},
{
Name: "b",
},
}
assert.Equal([]string{
"a",
"b",
}, sl.Names())
}
func TestNewPieLabelFormatter(t *testing.T) {
assert := assert.New(t)
fn := NewPieLabelFormatter([]string{
"a",
"b",
}, "")
assert.Equal("a: 35%", fn(0, 1.2, 0.35))
}
func TestNewValueLabelFormater(t *testing.T) {
assert := assert.New(t)
fn := NewValueLabelFormater([]string{
"a",
"b",
}, "")
assert.Equal("1.2", fn(0, 1.2, 0.35))
}

145
table.go
View file

@ -1,145 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"errors"
"github.com/wcharczuk/go-chart/v2"
)
type TableOption struct {
// draw
Draw *Draw
// The width of table
Width int
// The header of table
Header []string
// The style of table
Style chart.Style
ColumnWidths []float64
// 是否仅测量高度
measurement bool
}
var ErrTableColumnWidthInvalid = errors.New("table column width is invalid")
func tableDivideWidth(width, size int, columnWidths []float64) ([]int, error) {
widths := make([]int, size)
autoFillCount := size
restWidth := width
if len(columnWidths) != 0 {
for index, v := range columnWidths {
if v <= 0 {
continue
}
autoFillCount--
// 小于1的表示占比
if v < 1 {
widths[index] = int(v * float64(width))
} else {
widths[index] = int(v)
}
restWidth -= widths[index]
}
}
if restWidth < 0 {
return nil, ErrTableColumnWidthInvalid
}
// 填充其它未指定的宽度
if autoFillCount > 0 {
autoWidth := restWidth / autoFillCount
for index, v := range widths {
if v == 0 {
widths[index] = autoWidth
}
}
}
widthSum := 0
for _, v := range widths {
widthSum += v
}
if widthSum > width {
return nil, ErrTableColumnWidthInvalid
}
return widths, nil
}
func TableMeasure(opt TableOption) (chart.Box, error) {
d, err := NewDraw(DrawOption{
Width: opt.Width,
Height: 600,
})
if err != nil {
return chart.BoxZero, err
}
opt.Draw = d
opt.measurement = true
return tableRender(opt)
}
func tableRender(opt TableOption) (chart.Box, error) {
if opt.Draw == nil {
return chart.BoxZero, errors.New("draw can not be nil")
}
if len(opt.Header) == 0 {
return chart.BoxZero, errors.New("header can not be nil")
}
width := opt.Width
if width == 0 {
width = opt.Draw.Box.Width()
}
columnWidths, err := tableDivideWidth(width, len(opt.Header), opt.ColumnWidths)
if err != nil {
return chart.BoxZero, err
}
d := opt.Draw
style := opt.Style
y := 0
x := 0
headerMaxHeight := 0
for index, text := range opt.Header {
var box chart.Box
w := columnWidths[index]
y0 := y + int(opt.Style.FontSize)
if opt.measurement {
box = d.measureTextFit(text, x, y0, w, style)
} else {
box = d.textFit(text, x, y0, w, style)
}
if box.Height() > headerMaxHeight {
headerMaxHeight = box.Height()
}
x += w
}
y += headerMaxHeight
return chart.Box{
Right: width,
Bottom: y,
}, nil
}

298
theme.go
View file

@ -23,6 +23,8 @@
package charts package charts
import ( import (
"github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing" "github.com/wcharczuk/go-chart/v2/drawing"
) )
@ -31,23 +33,45 @@ const ThemeLight = "light"
const ThemeGrafana = "grafana" const ThemeGrafana = "grafana"
const ThemeAnt = "ant" const ThemeAnt = "ant"
type Theme struct { type ColorPalette interface {
palette *themeColorPalette IsDark() bool
GetAxisStrokeColor() Color
GetAxisSplitLineColor() Color
GetSeriesColor(int) Color
GetBackgroundColor() Color
GetTextColor() Color
GetFontSize() float64
GetFont() *truetype.Font
} }
type themeColorPalette struct { type themeColorPalette struct {
isDarkMode bool isDarkMode bool
axisStrokeColor drawing.Color axisStrokeColor Color
axisSplitLineColor drawing.Color axisSplitLineColor Color
backgroundColor drawing.Color backgroundColor Color
textColor drawing.Color textColor Color
seriesColors []drawing.Color seriesColors []Color
fontSize float64
font *truetype.Font
} }
var palettes = map[string]*themeColorPalette{} type ThemeOption struct {
IsDarkMode bool
AxisStrokeColor Color
AxisSplitLineColor Color
BackgroundColor Color
TextColor Color
SeriesColors []Color
}
var palettes = map[string]ColorPalette{}
const defaultFontSize = 12.0
var defaultTheme ColorPalette
func init() { func init() {
echartSeriesColors := []drawing.Color{ echartSeriesColors := []Color{
parseColor("#5470c6"), parseColor("#5470c6"),
parseColor("#91cc75"), parseColor("#91cc75"),
parseColor("#fac858"), parseColor("#fac858"),
@ -58,7 +82,7 @@ func init() {
parseColor("#9a60b4"), parseColor("#9a60b4"),
parseColor("#ea7ccc"), parseColor("#ea7ccc"),
} }
grafanaSeriesColors := []drawing.Color{ grafanaSeriesColors := []Color{
parseColor("#7EB26D"), parseColor("#7EB26D"),
parseColor("#EAB839"), parseColor("#EAB839"),
parseColor("#6ED0E0"), parseColor("#6ED0E0"),
@ -68,7 +92,7 @@ func init() {
parseColor("#705DA0"), parseColor("#705DA0"),
parseColor("#508642"), parseColor("#508642"),
} }
antSeriesColors := []drawing.Color{ antSeriesColors := []Color{
parseColor("#5b8ff9"), parseColor("#5b8ff9"),
parseColor("#5ad8a6"), parseColor("#5ad8a6"),
parseColor("#5d7092"), parseColor("#5d7092"),
@ -80,155 +104,181 @@ func init() {
} }
AddTheme( AddTheme(
ThemeDark, ThemeDark,
true, ThemeOption{
drawing.Color{ IsDarkMode: true,
R: 185, AxisStrokeColor: Color{
G: 184, R: 185,
B: 206, G: 184,
A: 255, 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,
}, },
drawing.Color{
R: 72,
G: 71,
B: 83,
A: 255,
},
drawing.Color{
R: 16,
G: 12,
B: 42,
A: 255,
},
drawing.Color{
R: 238,
G: 238,
B: 238,
A: 255,
},
echartSeriesColors,
) )
AddTheme( AddTheme(
ThemeLight, ThemeLight,
false, ThemeOption{
drawing.Color{ IsDarkMode: false,
R: 110, AxisStrokeColor: Color{
G: 112, R: 110,
B: 121, G: 112,
A: 255, 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,
}, },
drawing.Color{
R: 224,
G: 230,
B: 242,
A: 255,
},
drawing.ColorWhite,
drawing.Color{
R: 70,
G: 70,
B: 70,
A: 255,
},
echartSeriesColors,
) )
AddTheme( AddTheme(
ThemeAnt, ThemeAnt,
false, ThemeOption{
drawing.Color{ IsDarkMode: false,
R: 110, AxisStrokeColor: Color{
G: 112, R: 110,
B: 121, G: 112,
A: 255, 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,
}, },
drawing.Color{
R: 224,
G: 230,
B: 242,
A: 255,
},
drawing.ColorWhite,
drawing.Color{
R: 70,
G: 70,
B: 70,
A: 255,
},
antSeriesColors,
) )
AddTheme( AddTheme(
ThemeGrafana, ThemeGrafana,
true, ThemeOption{
drawing.Color{ IsDarkMode: true,
R: 185, AxisStrokeColor: Color{
G: 184, R: 185,
B: 206, G: 184,
A: 255, 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,
}, },
drawing.Color{
R: 68,
G: 67,
B: 67,
A: 255,
},
drawing.Color{
R: 31,
G: 29,
B: 29,
A: 255,
},
drawing.Color{
R: 216,
G: 217,
B: 218,
A: 255,
},
grafanaSeriesColors,
) )
SetDefaultTheme(ThemeLight)
} }
func AddTheme(name string, isDarkMode bool, axisStrokeColor, axisSplitLineColor, backgroundColor, textColor drawing.Color, seriesColors []drawing.Color) { func SetDefaultTheme(name string) {
defaultTheme = NewTheme(name)
}
func AddTheme(name string, opt ThemeOption) {
palettes[name] = &themeColorPalette{ palettes[name] = &themeColorPalette{
isDarkMode: isDarkMode, isDarkMode: opt.IsDarkMode,
axisStrokeColor: axisStrokeColor, axisStrokeColor: opt.AxisStrokeColor,
axisSplitLineColor: axisSplitLineColor, axisSplitLineColor: opt.AxisSplitLineColor,
backgroundColor: backgroundColor, backgroundColor: opt.BackgroundColor,
textColor: textColor, textColor: opt.TextColor,
seriesColors: seriesColors, seriesColors: opt.SeriesColors,
} }
} }
func NewTheme(name string) *Theme { func NewTheme(name string) ColorPalette {
p, ok := palettes[name] p, ok := palettes[name]
if !ok { if !ok {
p = palettes[ThemeLight] p = palettes[ThemeLight]
} }
return &Theme{ return p
palette: p,
}
} }
func (t *Theme) IsDark() bool { func (t *themeColorPalette) IsDark() bool {
return t.palette.isDarkMode return t.isDarkMode
} }
func (t *Theme) GetAxisStrokeColor() drawing.Color { func (t *themeColorPalette) GetAxisStrokeColor() Color {
return t.palette.axisStrokeColor return t.axisStrokeColor
} }
func (t *Theme) GetAxisSplitLineColor() drawing.Color { func (t *themeColorPalette) GetAxisSplitLineColor() Color {
return t.palette.axisSplitLineColor return t.axisSplitLineColor
} }
func (t *Theme) GetSeriesColor(index int) drawing.Color { func (t *themeColorPalette) GetSeriesColor(index int) Color {
colors := t.palette.seriesColors colors := t.seriesColors
return colors[index%len(colors)] return colors[index%len(colors)]
} }
func (t *Theme) GetBackgroundColor() drawing.Color { func (t *themeColorPalette) GetBackgroundColor() Color {
return t.palette.backgroundColor return t.backgroundColor
} }
func (t *Theme) GetTextColor() drawing.Color { func (t *themeColorPalette) GetTextColor() Color {
return t.palette.textColor return t.textColor
}
func (t *themeColorPalette) GetFontSize() float64 {
if t.fontSize != 0 {
return t.fontSize
}
return defaultFontSize
}
func (t *themeColorPalette) GetFont() *truetype.Font {
if t.font != nil {
return t.font
}
f, _ := chart.GetDefaultFont()
return f
} }

View file

@ -1,87 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestTheme(t *testing.T) {
assert := assert.New(t)
darkTheme := NewTheme(ThemeDark)
lightTheme := NewTheme(ThemeLight)
assert.True(darkTheme.IsDark())
assert.False(lightTheme.IsDark())
assert.Equal(drawing.Color{
R: 185,
G: 184,
B: 206,
A: 255,
}, darkTheme.GetAxisStrokeColor())
assert.Equal(drawing.Color{
R: 110,
G: 112,
B: 121,
A: 255,
}, lightTheme.GetAxisStrokeColor())
assert.Equal(drawing.Color{
R: 72,
G: 71,
B: 83,
A: 255,
}, darkTheme.GetAxisSplitLineColor())
assert.Equal(drawing.Color{
R: 224,
G: 230,
B: 242,
A: 255,
}, lightTheme.GetAxisSplitLineColor())
assert.Equal(drawing.Color{
R: 16,
G: 12,
B: 42,
A: 255,
}, darkTheme.GetBackgroundColor())
assert.Equal(drawing.ColorWhite, lightTheme.GetBackgroundColor())
assert.Equal(drawing.Color{
R: 238,
G: 238,
B: 238,
A: 255,
}, darkTheme.GetTextColor())
assert.Equal(drawing.Color{
R: 70,
G: 70,
B: 70,
A: 255,
}, lightTheme.GetTextColor())
}

112
title.go
View file

@ -26,18 +26,20 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/wcharczuk/go-chart/v2" "github.com/golang/freetype/truetype"
) )
type TitleOption struct { type TitleOption struct {
// The theme of chart
Theme ColorPalette
// Title text, support \n for new line // Title text, support \n for new line
Text string Text string
// Subtitle text, support \n for new line // Subtitle text, support \n for new line
Subtext string Subtext string
// Title style // // Title style
Style chart.Style // Style Style
// Subtitle style // // Subtitle style
SubtextStyle chart.Style // SubtextStyle Style
// Distance between title component and the left side of the container. // Distance between title component and the left side of the container.
// It can be pixel value: 20, percentage value: 20%, // It can be pixel value: 20, percentage value: 20%,
// or position value: right, center. // or position value: right, center.
@ -45,12 +47,23 @@ type TitleOption struct {
// Distance between title component and the top side of the container. // Distance between title component and the top side of the container.
// It can be pixel value: 20. // It can be pixel value: 20.
Top string Top string
// The font of label
Font *truetype.Font
// The font size of label
FontSize float64
// The color of label
FontColor Color
// The subtext font size of label
SubtextFontSize float64
// The subtext font color of label
SubtextFontColor Color
} }
type titleMeasureOption struct { type titleMeasureOption struct {
width int width int
height int height int
text string text string
style chart.Style style Style
} }
func splitTitleText(text string) []string { func splitTitleText(text string) []string {
@ -66,44 +79,74 @@ func splitTitleText(text string) []string {
return result return result
} }
func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) { type titlePainter struct {
if len(opt.Text) == 0 { p *Painter
return chart.BoxZero, nil opt *TitleOption
} }
padding := opt.Style.Padding func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter {
d, err := NewDraw(DrawOption{ return &titlePainter{
Parent: p, p: p,
}, PaddingOption(padding)) opt: &opt,
if err != nil {
return chart.BoxZero, err
} }
}
r := d.Render func (t *titlePainter) Render() (Box, error) {
opt := t.opt
p := t.p
theme := opt.Theme
if opt.Text == "" && opt.Subtext == "" {
return BoxZero, nil
}
measureOptions := make([]titleMeasureOption, 0) 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) { for _, v := range splitTitleText(opt.Text) {
measureOptions = append(measureOptions, titleMeasureOption{ measureOptions = append(measureOptions, titleMeasureOption{
text: v, text: v,
style: opt.Style.GetTextOptions(), style: titleTextStyle,
}) })
} }
subtextStyle := Style{
Font: opt.Font,
FontSize: opt.SubtextFontSize,
FontColor: opt.SubtextFontColor,
}
// 副标题 // 副标题
for _, v := range splitTitleText(opt.Subtext) { for _, v := range splitTitleText(opt.Subtext) {
measureOptions = append(measureOptions, titleMeasureOption{ measureOptions = append(measureOptions, titleMeasureOption{
text: v, text: v,
style: opt.SubtextStyle.GetTextOptions(), style: subtextStyle,
}) })
} }
textMaxWidth := 0 textMaxWidth := 0
textMaxHeight := 0 textMaxHeight := 0
width := 0
for index, item := range measureOptions { for index, item := range measureOptions {
item.style.WriteTextOptionsToRenderer(r) p.OverrideTextStyle(item.style)
textBox := r.MeasureText(item.text) textBox := p.MeasureText(item.text)
w := textBox.Width() w := textBox.Width()
h := textBox.Height() h := textBox.Height()
@ -116,18 +159,18 @@ func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) {
measureOptions[index].height = h measureOptions[index].height = h
measureOptions[index].width = w measureOptions[index].width = w
} }
width = textMaxWidth width := textMaxWidth
titleX := 0 titleX := 0
b := d.Box
switch opt.Left { switch opt.Left {
case PositionRight: case PositionRight:
titleX = b.Width() - textMaxWidth titleX = p.Width() - textMaxWidth
case PositionCenter: case PositionCenter:
titleX = b.Width()>>1 - (textMaxWidth >> 1) titleX = p.Width()>>1 - (textMaxWidth >> 1)
default: default:
if strings.HasSuffix(opt.Left, "%") { if strings.HasSuffix(opt.Left, "%") {
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
titleX = b.Width() * value / 100 titleX = p.Width() * value / 100
} else { } else {
value, _ := strconv.Atoi(opt.Left) value, _ := strconv.Atoi(opt.Left)
titleX = value titleX = value
@ -140,16 +183,15 @@ func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) {
titleY += value titleY += value
} }
for _, item := range measureOptions { for _, item := range measureOptions {
item.style.WriteTextOptionsToRenderer(r) p.OverrideTextStyle(item.style)
x := titleX + (textMaxWidth-item.width)>>1 x := titleX + (textMaxWidth-item.width)>>1
y := titleY + item.height y := titleY + item.height
d.text(item.text, x, y) p.Text(item.text, x, y)
titleY += item.height titleY += item.height
} }
height := titleY + padding.Top + padding.Bottom
box := padding.Clone()
box.Right = box.Left + titleX + width
box.Bottom = box.Top + height
return box, nil return Box{
Bottom: titleY,
Right: titleX + width,
}, nil
} }

View file

@ -1,142 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func TestSplitTitleText(t *testing.T) {
assert := assert.New(t)
assert.Equal([]string{
"a",
"b",
}, splitTitleText("a\nb"))
assert.Equal([]string{
"a",
}, splitTitleText("a\n "))
}
func TestDrawTitle(t *testing.T) {
assert := assert.New(t)
newOption := func() *TitleOption {
f, _ := chart.GetDefaultFont()
return &TitleOption{
Text: "title\nHello",
Subtext: "subtitle\nWorld!",
Style: chart.Style{
FontSize: 14,
Font: f,
FontColor: drawing.ColorBlack,
},
SubtextStyle: chart.Style{
FontSize: 10,
Font: f,
FontColor: drawing.ColorBlue,
},
}
}
newDraw := func() *Draw {
d, _ := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
return d
}
tests := []struct {
newDraw func() *Draw
newOption func() *TitleOption
result string
box chart.Box
}{
{
newDraw: newDraw,
newOption: newOption,
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"6\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"0\" y=\"34\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">Hello</text><text x=\"0\" y=\"46\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">subtitle</text><text x=\"3\" y=\"58\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">World!</text></svg>",
box: chart.Box{
Right: 43,
Bottom: 58,
},
},
{
newDraw: newDraw,
newOption: func() *TitleOption {
opt := newOption()
opt.Left = PositionRight
opt.Top = "50"
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"363\" y=\"67\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"357\" y=\"84\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">Hello</text><text x=\"357\" y=\"96\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">subtitle</text><text x=\"360\" y=\"108\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">World!</text></svg>",
box: chart.Box{
Right: 400,
Bottom: 108,
},
},
{
newDraw: newDraw,
newOption: func() *TitleOption {
opt := newOption()
opt.Left = PositionCenter
opt.Top = "10"
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"185\" y=\"27\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"179\" y=\"44\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">Hello</text><text x=\"179\" y=\"56\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">subtitle</text><text x=\"182\" y=\"68\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">World!</text></svg>",
box: chart.Box{
Right: 222,
Bottom: 68,
},
},
{
newDraw: newDraw,
newOption: func() *TitleOption {
opt := newOption()
opt.Left = "10%"
opt.Top = "10"
return opt
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"46\" y=\"27\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"40\" y=\"44\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">Hello</text><text x=\"40\" y=\"56\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">subtitle</text><text x=\"43\" y=\"68\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">World!</text></svg>",
box: chart.Box{
Right: 83,
Bottom: 68,
},
},
}
for _, tt := range tests {
d := tt.newDraw()
o := tt.newOption()
b, err := drawTitle(d, o)
assert.Nil(err)
assert.Equal(tt.box, b)
data, err := d.Bytes()
assert.Nil(err)
assert.NotEmpty(data)
assert.Equal(tt.result, string(data))
}
}

41
util.go
View file

@ -43,6 +43,24 @@ func FalseFlag() *bool {
return &f 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 { func ceilFloatToInt(value float64) int {
i := int(value) i := int(value)
if value == float64(i) { if value == float64(i) {
@ -59,28 +77,25 @@ func getDefaultInt(value, defaultValue int) int {
} }
func autoDivide(max, size int) []int { func autoDivide(max, size int) []int {
unit := max / size unit := float64(max) / float64(size)
rest := max - unit*size
values := make([]int, size+1) values := make([]int, size+1)
value := 0 for i := 0; i < size+1; i++ {
for i := 0; i < size; i++ { if i == size {
values[i] = value values[i] = max
if i < rest { } else {
value++ values[i] = int(float64(i) * unit)
} }
value += unit
} }
values[size] = max
return values return values
} }
// measureTextMaxWidthHeight returns maxWidth and maxHeight of text list // measureTextMaxWidthHeight returns maxWidth and maxHeight of text list
func measureTextMaxWidthHeight(textList []string, r chart.Renderer) (int, int) { func measureTextMaxWidthHeight(textList []string, p *Painter) (int, int) {
maxWidth := 0 maxWidth := 0
maxHeight := 0 maxHeight := 0
for _, text := range textList { for _, text := range textList {
box := r.MeasureText(text) box := p.MeasureText(text)
maxWidth = chart.MaxInt(maxWidth, box.Width()) maxWidth = chart.MaxInt(maxWidth, box.Width())
maxHeight = chart.MaxInt(maxHeight, box.Height()) maxHeight = chart.MaxInt(maxHeight, box.Height())
} }
@ -134,8 +149,8 @@ func commafWithDigits(value float64) string {
return humanize.CommafWithDigits(value, decimals) return humanize.CommafWithDigits(value, decimals)
} }
func parseColor(color string) drawing.Color { func parseColor(color string) Color {
c := drawing.Color{} c := Color{}
if color == "" { if color == "" {
return c return c
} }

View file

@ -60,12 +60,12 @@ func TestAutoDivide(t *testing.T) {
assert.Equal([]int{ assert.Equal([]int{
0, 0,
86, 85,
172, 171,
258, 257,
344, 342,
430, 428,
515, 514,
600, 600,
}, autoDivide(600, 7)) }, autoDivide(600, 7))
} }
@ -80,13 +80,15 @@ func TestGetRadius(t *testing.T) {
func TestMeasureTextMaxWidthHeight(t *testing.T) { func TestMeasureTextMaxWidthHeight(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
r, err := chart.SVG(400, 300) p, err := NewPainter(PainterOptions{
Width: 400,
Height: 300,
})
assert.Nil(err) assert.Nil(err)
style := chart.Style{ style := chart.Style{
FontSize: 10, FontSize: 10,
} }
style.Font, _ = chart.GetDefaultFont() p.SetStyle(style)
style.WriteToRenderer(r)
maxWidth, maxHeight := measureTextMaxWidthHeight([]string{ maxWidth, maxHeight := measureTextMaxWidthHeight([]string{
"Mon", "Mon",
@ -96,7 +98,7 @@ func TestMeasureTextMaxWidthHeight(t *testing.T) {
"Fri", "Fri",
"Sat", "Sat",
"Sun", "Sun",
}, r) }, p)
assert.Equal(26, maxWidth) assert.Equal(26, maxWidth)
assert.Equal(12, maxHeight) assert.Equal(12, maxHeight)
} }

View file

@ -24,10 +24,10 @@ package charts
import ( import (
"github.com/golang/freetype/truetype" "github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
) )
type XAxisOption struct { type XAxisOption struct {
// The font of x axis
Font *truetype.Font Font *truetype.Font
// The boundary gap on both sides of a coordinate axis. // The boundary gap on both sides of a coordinate axis.
// Nil or *true means the center part of two axis ticks // Nil or *true means the center part of two axis ticks
@ -35,13 +35,24 @@ type XAxisOption struct {
// The data value of x axis // The data value of x axis
Data []string Data []string
// The theme of chart // The theme of chart
Theme string Theme ColorPalette
// Hidden x axis // The font size of x axis label
Hidden bool 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. // Number of segments that the axis is split into. Note that this number serves only as a recommendation.
SplitNumber int 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
isValueAxis bool
} }
const defaultXAxisHeight = 30
func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption { func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption {
opt := XAxisOption{ opt := XAxisOption{
Data: data, Data: data,
@ -52,51 +63,32 @@ func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption {
return opt return opt
} }
// drawXAxis draws x axis, and returns the height, range of if. func (opt *XAxisOption) ToAxisOption() AxisOption {
func drawXAxis(p *Draw, opt *XAxisOption, yAxisCount int) (int, *Range, error) { position := PositionBottom
if opt.Hidden { if opt.Position == PositionTop {
return 0, nil, nil position = PositionTop
} }
left := YAxisWidth axisOpt := AxisOption{
right := (yAxisCount - 1) * YAxisWidth Theme: opt.Theme,
dXAxis, err := NewDraw( Data: opt.Data,
DrawOption{ BoundaryGap: opt.BoundaryGap,
Parent: p, Position: position,
}, SplitNumber: opt.SplitNumber,
PaddingOption(chart.Box{ StrokeColor: opt.StrokeColor,
Left: left, FontSize: opt.FontSize,
Right: right, Font: opt.Font,
}), FontColor: opt.FontColor,
) Show: opt.Show,
if opt.Font != nil { SplitLineColor: opt.Theme.GetAxisSplitLineColor(),
dXAxis.Font = opt.Font
} }
if err != nil { if opt.isValueAxis {
return 0, nil, err axisOpt.SplitLineShow = true
axisOpt.StrokeWidth = -1
axisOpt.BoundaryGap = FalseFlag()
} }
theme := NewTheme(opt.Theme) return axisOpt
data := NewAxisDataListFromStringList(opt.Data) }
style := AxisOption{
BoundaryGap: opt.BoundaryGap, func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter {
StrokeColor: theme.GetAxisStrokeColor(), return NewAxisPainter(p, opt.ToAxisOption())
FontColor: theme.GetAxisStrokeColor(),
StrokeWidth: 1,
SplitNumber: opt.SplitNumber,
}
boundary := true
max := float64(len(opt.Data))
if isFalse(opt.BoundaryGap) {
boundary = false
max--
}
axis := NewAxis(dXAxis, data, style)
axis.Render()
return axis.measure().Height, &Range{
divideCount: len(opt.Data),
Min: 0,
Max: max,
Size: dXAxis.Box.Width(),
Boundary: boundary,
}, nil
} }

View file

@ -1,108 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewXAxisOption(t *testing.T) {
assert := assert.New(t)
opt := NewXAxisOption([]string{
"a",
"b",
}, FalseFlag())
assert.Equal(XAxisOption{
Data: []string{
"a",
"b",
},
BoundaryGap: FalseFlag(),
}, opt)
}
func TestDrawXAxis(t *testing.T) {
assert := assert.New(t)
newDraw := func() *Draw {
d, _ := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
return d
}
tests := []struct {
newDraw func() *Draw
newOption func() *XAxisOption
result string
}{
{
newDraw: newDraw,
newOption: func() *XAxisOption {
return &XAxisOption{
BoundaryGap: FalseFlag(),
Data: []string{
"Mon",
"Tue",
},
}
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 40 275\nL 400 275\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 40 275\nL 40 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 400 275\nL 400 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"27\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"389\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text></svg>",
},
{
newDraw: newDraw,
newOption: func() *XAxisOption {
return &XAxisOption{
Data: []string{
"01-01",
"01-02",
"01-03",
"01-04",
"01-05",
"01-06",
"01-07",
"01-08",
"01-09",
},
SplitNumber: 3,
}
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 40 275\nL 400 275\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 40 275\nL 40 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 160 275\nL 160 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 280 275\nL 280 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 400 275\nL 400 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"83\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-02</text><text x=\"203\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-05</text><text x=\"323\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-08</text></svg>",
},
}
for _, tt := range tests {
d := tt.newDraw()
height, _, err := drawXAxis(d, tt.newOption(), 1)
assert.Nil(err)
assert.Equal(25, height)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal(tt.result, string(data))
}
}

132
yaxis.go
View file

@ -22,84 +22,92 @@
package charts package charts
import ( import "github.com/golang/freetype/truetype"
"strings"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
type YAxisOption struct { type YAxisOption struct {
// The minimun value of axis. // The minimun value of axis.
Min *float64 Min *float64
// The maximum value of axis. // The maximum value of axis.
Max *float64 Max *float64
// Hidden y axis // The font of y axis
Hidden bool 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 for y axis text value
Formatter string Formatter string
// Color for y axis // Color for y axis
Color drawing.Color Color Color
// The flag for show axis, set this to *false will hide axis
Show *bool
isCategoryAxis bool
} }
// TODO 长度是否可以变化 func NewYAxisOptions(data []string, others ...[]string) []YAxisOption {
const YAxisWidth = 40 arr := [][]string{
data,
func drawYAxis(p *Draw, opt *ChartOption, axisIndex, xAxisHeight int, padding chart.Box) (*Range, error) {
theme := NewTheme(opt.Theme)
yRange := opt.newYRange(axisIndex)
values := yRange.Values()
yAxis := opt.YAxisList[axisIndex]
formatter := yAxis.Formatter
if len(formatter) != 0 {
for index, text := range values {
values[index] = strings.ReplaceAll(formatter, "{value}", text)
}
} }
arr = append(arr, others...)
opts := make([]YAxisOption, 0)
for _, data := range arr {
opts = append(opts, YAxisOption{
Data: data,
})
}
return opts
}
data := NewAxisDataListFromStringList(values) func (opt *YAxisOption) ToAxisOption() AxisOption {
style := AxisOption{ position := PositionLeft
Position: PositionLeft, if opt.Position == PositionRight {
position = PositionRight
}
axisOpt := AxisOption{
Formatter: opt.Formatter,
Theme: opt.Theme,
Data: opt.Data,
Position: position,
FontSize: opt.FontSize,
StrokeWidth: -1,
Font: opt.Font,
FontColor: opt.FontColor,
BoundaryGap: FalseFlag(), BoundaryGap: FalseFlag(),
FontColor: theme.GetAxisStrokeColor(),
TickShow: FalseFlag(),
StrokeWidth: 1,
SplitLineColor: theme.GetAxisSplitLineColor(),
SplitLineShow: true, SplitLineShow: true,
SplitLineColor: opt.Theme.GetAxisSplitLineColor(),
Show: opt.Show,
} }
if !yAxis.Color.IsZero() { if !opt.Color.IsZero() {
style.FontColor = yAxis.Color axisOpt.FontColor = opt.Color
style.StrokeColor = yAxis.Color axisOpt.StrokeColor = opt.Color
} }
width := NewAxis(p, data, style).measure().Width if opt.isCategoryAxis {
axisOpt.BoundaryGap = TrueFlag()
yAxisCount := len(opt.YAxisList) axisOpt.StrokeWidth = 1
boxWidth := p.Box.Width() axisOpt.SplitLineShow = false
if axisIndex > 0 {
style.SplitLineShow = false
style.Position = PositionRight
padding.Right += (axisIndex - 1) * YAxisWidth
} else {
boxWidth = p.Box.Width() - (yAxisCount-1)*YAxisWidth
padding.Left += (YAxisWidth - width)
} }
return axisOpt
dYAxis, err := NewDraw( }
DrawOption{
Parent: p, func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter {
Width: boxWidth, p = p.Child(PainterPaddingOption(Box{
// 减去x轴的高 Bottom: defaultXAxisHeight,
Height: p.Box.Height() - xAxisHeight, }))
}, return NewAxisPainter(p, opt.ToAxisOption())
PaddingOption(padding), }
)
if err != nil { func NewRightYAxis(p *Painter, opt YAxisOption) *axisPainter {
return nil, err p = p.Child(PainterPaddingOption(Box{
} Bottom: defaultXAxisHeight,
if opt.Font != nil { }))
dYAxis.Font = opt.Font axisOpt := opt.ToAxisOption()
} axisOpt.Position = PositionRight
NewAxis(dYAxis, data, style).Render() axisOpt.SplitLineShow = false
yRange.Size = dYAxis.Box.Height() return NewAxisPainter(p, axisOpt)
return &yRange, nil
} }

View file

@ -1,119 +0,0 @@
// MIT License
// Copyright (c) 2022 Tree Xie
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package charts
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/wcharczuk/go-chart/v2"
)
func TestDrawYAxis(t *testing.T) {
assert := assert.New(t)
newDraw := func() *Draw {
d, _ := NewDraw(DrawOption{
Width: 400,
Height: 300,
})
return d
}
tests := []struct {
newDraw func() *Draw
newOption func() *ChartOption
axisIndex int
xAxisHeight int
result string
}{
{
newDraw: newDraw,
newOption: func() *ChartOption {
return &ChartOption{
YAxisList: []YAxisOption{
{
Max: NewFloatPoint(20),
},
},
SeriesList: []Series{
{
Data: []SeriesData{
{
Value: 1,
},
{
Value: 2,
},
},
},
},
}
},
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 50 10\nL 50 290\" style=\"stroke-width:1;stroke:none;fill:none\"/><path d=\"M 50 10\nL 390 10\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 57\nL 390 57\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 104\nL 390 104\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 151\nL 390 151\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 198\nL 390 198\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 244\nL 390 244\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><text x=\"36\" y=\"294\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">0</text><text x=\"18\" y=\"248\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">3.33</text><text x=\"18\" y=\"202\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">6.66</text><text x=\"29\" y=\"155\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">10</text><text x=\"11\" y=\"108\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">13.33</text><text x=\"11\" y=\"61\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">16.66</text><text x=\"29\" y=\"14\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20</text></svg>",
},
{
newDraw: newDraw,
newOption: func() *ChartOption {
return &ChartOption{
YAxisList: []YAxisOption{
{},
{
Max: NewFloatPoint(20),
Formatter: "{value} C",
},
},
SeriesList: []Series{
{
YAxisIndex: 1,
Data: []SeriesData{
{
Value: 1,
},
{
Value: 2,
},
},
},
},
}
},
axisIndex: 1,
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 337 10\nL 337 290\" style=\"stroke-width:1;stroke:none;fill:none\"/><text x=\"345\" y=\"294\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">0 C</text><text x=\"345\" y=\"248\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">3.33 C</text><text x=\"345\" y=\"202\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">6.66 C</text><text x=\"345\" y=\"155\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">10 C</text><text x=\"345\" y=\"108\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">13.33 C</text><text x=\"345\" y=\"61\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">16.66 C</text><text x=\"345\" y=\"14\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20 C</text></svg>",
},
}
for _, tt := range tests {
d := tt.newDraw()
r, err := drawYAxis(d, tt.newOption(), tt.axisIndex, tt.xAxisHeight, chart.NewBox(10, 10, 10, 10))
assert.Nil(err)
assert.Equal(&Range{
divideCount: 6,
Max: 20,
Size: 280,
}, r)
data, err := d.Bytes()
assert.Nil(err)
assert.Equal(tt.result, string(data))
}
}