diff --git a/README.md b/README.md index 22d3205..a58adb4 100644 --- a/README.md +++ b/README.md @@ -21,53 +21,45 @@ These chart types are supported: `line`, `bar`, `pie`, `radar` or `funnel`. ## 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 package main import ( - "os" - "path/filepath" - - charts "github.com/vicanso/go-charts" + charts "github.com/vicanso/go-charts/v2" ) -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 = os.WriteFile(file, buf, 0600) - if err != nil { - return err - } - return nil -} - -func chartsRender() ([]byte, error) { - d, err := charts.LineRender([][]float64{ +func main() { +values := [][]float64{ { - 150, + 120, + 132, + 101, + 134, + 90, 230, - 224, - 218, - 135, - 147, - 260, + 210, }, - }, - // output type - charts.PNGTypeOption(), - // title - charts.TitleOptionFunc(charts.TitleOption{ - Text: "Line", - }), - // x axis - charts.XAxisOptionFunc(charts.NewXAxisOption([]string{ + { + // snip... + }, + { + // snip... + }, + { + // snip... + }, + { + // snip... + }, + } + p, err := charts.LineRender( + values, + charts.TitleTextOptionFunc("Line"), + charts.XAxisDataOptionFunc([]string{ "Mon", "Tue", "Wed", @@ -75,16 +67,324 @@ func chartsRender() ([]byte, error) { "Fri", "Sat", "Sun", - })), + }), + charts.LegendLabelsOptionFunc([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, charts.PositionCenter), + ) + + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + // snip... +} +``` + +### Bar Chart + +```go +package main + +import ( + "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 { - return nil, err + panic(err) } - return d.Bytes() -} -func echartsRender() ([]byte, error) { - return charts.RenderEChartsToPNG(`{ + buf, err := p.Bytes() + 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": { "text": "Line" }, @@ -97,25 +397,7 @@ func echartsRender() ([]byte, error) { } ] }`) -} - -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) - } - } + // snip... } ``` diff --git a/README_zh.md b/README_zh.md index 1589923..9c0be5b 100644 --- a/README_zh.md +++ b/README_zh.md @@ -21,53 +21,44 @@ 下面的示例为`go-charts`两种方式的参数配置:golang的参数配置、echarts的JSON配置,输出相同的折线图。 -更多的示例参考:`./examples/`目录 +更多的示例参考:[./examples/](./examples/)目录 +### Line Chart ```go package main import ( - "os" - "path/filepath" - - charts "github.com/vicanso/go-charts" + charts "github.com/vicanso/go-charts/v2" ) -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 = os.WriteFile(file, buf, 0600) - if err != nil { - return err - } - return nil -} - -func chartsRender() ([]byte, error) { - d, err := charts.LineRender([][]float64{ +func main() { +values := [][]float64{ { - 150, + 120, + 132, + 101, + 134, + 90, 230, - 224, - 218, - 135, - 147, - 260, + 210, }, - }, - // output type - charts.PNGTypeOption(), - // title - charts.TitleOptionFunc(charts.TitleOption{ - Text: "Line", - }), - // x axis - charts.XAxisOptionFunc(charts.NewXAxisOption([]string{ + { + // snip... + }, + { + // snip... + }, + { + // snip... + }, + { + // snip... + }, + } + p, err := charts.LineRender( + values, + charts.TitleTextOptionFunc("Line"), + charts.XAxisDataOptionFunc([]string{ "Mon", "Tue", "Wed", @@ -75,16 +66,324 @@ func chartsRender() ([]byte, error) { "Fri", "Sat", "Sun", - })), + }), + charts.LegendLabelsOptionFunc([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, charts.PositionCenter), + ) + + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + // snip... +} +``` + +### Bar Chart + +```go +package main + +import ( + "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 { - return nil, err + panic(err) } - return d.Bytes() -} -func echartsRender() ([]byte, error) { - return charts.RenderEChartsToPNG(`{ + buf, err := p.Bytes() + 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": { "text": "Line" }, @@ -97,25 +396,7 @@ func echartsRender() ([]byte, error) { } ] }`) -} - -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) - } - } + // snip... } ``` @@ -222,4 +503,6 @@ BenchmarkMultiChartSVGRender-8 367 3356325 ns/op 字体文件可以在[中文字库noto-cjk](https://github.com/googlefonts/noto-cjk)下载,注意下载时选择字体格式为 `ttf` 格式,如果选用 `otf` 格式可能会加载失败。 -示例见 [examples/basic/chinese.go](examples/basic/chinese.go) \ No newline at end of file + +示例见 [examples/chinese/main.go](examples/chinese/main.go) + diff --git a/bar.go b/alias.go similarity index 64% rename from bar.go rename to alias.go index 1090f6b..a96f50b 100644 --- a/bar.go +++ b/alias.go @@ -27,32 +27,47 @@ import ( "github.com/wcharczuk/go-chart/v2/drawing" ) -type BarStyle struct { - ClassName string - StrokeDashArray []float64 - FillColor drawing.Color +type Box = chart.Box +type Style = chart.Style +type Color = drawing.Color + +var BoxZero = chart.BoxZero + +type Point struct { + X int + Y int } -func (bs *BarStyle) Style() chart.Style { - return chart.Style{ - ClassName: bs.ClassName, - StrokeDashArray: bs.StrokeDashArray, - StrokeColor: bs.FillColor, - StrokeWidth: 1, - FillColor: bs.FillColor, - } -} +const ( + ChartTypeLine = "line" + ChartTypeBar = "bar" + ChartTypePie = "pie" + ChartTypeRadar = "radar" + ChartTypeFunnel = "funnel" + // horizontal bar + ChartTypeHorizontalBar = "horizontalBar" +) -// Bar renders bar for chart -func (d *Draw) Bar(b chart.Box, style BarStyle) { - s := style.Style() +const ( + ChartOutputSVG = "svg" + ChartOutputPNG = "png" +) - r := d.Render - s.GetFillAndStrokeOptions().WriteToRenderer(r) - d.moveTo(b.Left, b.Top) - d.lineTo(b.Right, b.Top) - d.lineTo(b.Right, b.Bottom) - d.lineTo(b.Left, b.Bottom) - d.lineTo(b.Left, b.Top) - d.Render.FillStroke() -} +const ( + PositionLeft = "left" + PositionRight = "right" + PositionCenter = "center" + PositionTop = "top" + PositionBottom = "bottom" +) + +const ( + AlignLeft = "left" + AlignRight = "right" + AlignCenter = "center" +) + +const ( + OrientHorizontal = "horizontal" + OrientVertical = "vertical" +) diff --git a/assets/go-charts.png b/assets/go-charts.png index 5ead961..a80e241 100644 Binary files a/assets/go-charts.png and b/assets/go-charts.png differ diff --git a/axis.go b/axis.go index 46292e4..aa7cf7d 100644 --- a/axis.go +++ b/axis.go @@ -23,14 +23,31 @@ package charts import ( - "math" + "strings" "github.com/golang/freetype/truetype" "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 { + // The theme of chart + Theme ColorPalette + // Formatter for y axis text value + Formatter string + // The label of axis + Data []string // The boundary gap on both sides of a coordinate axis. // Nil or *true means the center part of two axis ticks BoundaryGap *bool @@ -40,15 +57,12 @@ type AxisOption struct { Position string // Number of segments that the axis is split into. Note that this number serves only as a recommendation. SplitNumber int - ClassName string // The line color of axis - StrokeColor drawing.Color + StrokeColor Color // The line width StrokeWidth float64 // The length of the axis tick TickLength int - // The flag for show axis tick, set this to *false will hide axis tick - TickShow *bool // The margin value of label LabelMargin int // The font size of label @@ -56,413 +70,231 @@ type AxisOption struct { // The font of label Font *truetype.Font // 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 SplitLineShow bool // The color of split line - SplitLineColor drawing.Color + SplitLineColor Color } -type axis struct { - d *Draw - data *AxisDataList - option *AxisOption -} -type axisMeasurement struct { - 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, +func (a *axisPainter) Render() (Box, error) { + opt := a.opt + top := a.p + theme := opt.Theme + if opt.Show != nil && !*opt.Show { + return BoxZero, nil } -} - -// GetLabelMargin returns the label margin value -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, + strokeWidth := opt.StrokeWidth + if strokeWidth == 0 { + strokeWidth = 1 } - if s.FontSize == 0 { - s.FontSize = chart.DefaultFontSize + + font := opt.Font + if font == nil { + font = a.p.font } - if s.Font == nil { - s.Font = f + if font == nil { + font = theme.GetFont() } - return s -} - -type AxisData struct { - // The text value of axis - Text string -} -type AxisDataList []AxisData - -// TextList returns the text list of axis data -func (l AxisDataList) TextList() []string { - textList := make([]string, len(l)) - for index, item := range l { - textList[index] = item.Text + fontColor := opt.FontColor + if fontColor.IsZero() { + fontColor = theme.GetTextColor() + } + fontSize := opt.FontSize + if fontSize == 0 { + fontSize = theme.GetFontSize() + } + strokeColor := opt.StrokeColor + if strokeColor.IsZero() { + strokeColor = theme.GetAxisStrokeColor() } - return textList -} -type axisRenderOption struct { - textMaxWith int - textMaxHeight int - boundaryGap bool - unitCount int - 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, + data := opt.Data + formatter := opt.Formatter + if len(formatter) != 0 { + for index, text := range data { + data[index] = strings.ReplaceAll(formatter, "{value}", text) } } - return list -} + dataCount := len(data) + tickCount := dataCount -func (a *axis) axisLabel(renderOpt *axisRenderOption) { - option := a.option - data := *a.data - d := a.d - if option.FontColor.IsZero() || len(data) == 0 { - return + boundaryGap := true + if opt.BoundaryGap != nil && !*opt.BoundaryGap { + boundaryGap = false } - r := d.Render + isVertical := opt.Position == PositionLeft || + opt.Position == PositionRight - s := option.Style(d.Font) - s.GetTextOptions().WriteTextOptionsToRenderer(r) - - width := d.Box.Width() - height := d.Box.Height() - textList := data.TextList() - count := len(textList) - - boundaryGap := renderOpt.boundaryGap + labelPosition := "" if !boundaryGap { - count-- + tickCount-- + labelPosition = PositionLeft + } + if isVertical && boundaryGap { + labelPosition = PositionCenter } - unitCount := renderOpt.unitCount - modValue := renderOpt.modValue - labelMargin := option.GetLabelMargin() + // 如果小于0,则表示不处理 + tickLength := getDefaultInt(opt.TickLength, 5) + labelMargin := getDefaultInt(opt.LabelMargin, 5) - // 轴线 - labelHeight := labelMargin + renderOpt.textMaxHeight - labelWidth := labelMargin + renderOpt.textMaxWith + style := Style{ + StrokeColor: strokeColor, + StrokeWidth: strokeWidth, + Font: font, + FontColor: fontColor, + FontSize: fontSize, + } + top.SetDrawingStyle(style).OverrideTextStyle(style) - // 坐标轴文本 - position := option.Position - switch position { + textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(data) + textCount := ceilFloatToInt(float64(top.Width()) / float64(textMaxWidth)) + 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: - fallthrough + padding.Right = top.Width() - width case PositionRight: - values := autoDivide(height, count) - 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) - } + padding.Left = top.Width() - width default: - // 定位bottom,重新计算y0的定位 - 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) - } + padding.Top = top.Height() - defaultXAxisHeight } -} -func (a *axis) axisLine(renderOpt *axisRenderOption) { - d := a.d - r := d.Render - option := a.option - s := option.Style(d.Font) - s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) + p := top.Child(PainterPaddingOption(padding)) x0 := 0 y0 := 0 x1 := 0 y1 := 0 - width := d.Box.Width() - height := d.Box.Height() - labelMargin := option.GetLabelMargin() + ticksPaddingTop := 0 + ticksPaddingLeft := 0 + labelPaddingTop := 0 + labelPaddingLeft := 0 + labelPaddingRight := 0 + orient := "" + textAlign := "" - // 轴线 - 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 + switch opt.Position { case PositionTop: - x0 = 0 - x1 = width - y0 = labelHeight + labelPaddingTop = labelMargin + x1 = p.Width() + y0 = labelMargin + int(opt.FontSize) + ticksPaddingTop = int(opt.FontSize) y1 = y0 - // bottom - 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 { + orient = OrientHorizontal 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: - values := autoDivide(height, tickCount) - // 左右仅是x0的位置不一样 - x0 := width - labelWidth - if option.Position == PositionLeft { - x0 = labelWidth - } - if tickShow { - for _, v := range values { - x := x0 - y := v - d.moveTo(x, y) - d.lineTo(x+tickLengthValue, y) - r.Stroke() - } - } - // 辅助线 - if option.SplitLineShow && !option.SplitLineColor.IsZero() { - r.SetStrokeColor(option.SplitLineColor) - splitLineWidth := width - labelWidth - tickLengthValue - x0 = labelWidth + tickLengthValue - if position == PositionRight { + orient = OrientVertical + y1 = p.Height() + labelPaddingLeft = width - textMaxWidth + default: + labelPaddingTop = height + x1 = p.Width() + orient = OrientHorizontal + } + + if strokeWidth > 0 { + p.Child(PainterPaddingOption(Box{ + Top: ticksPaddingTop, + Left: ticksPaddingLeft, + })).Ticks(TicksOption{ + Count: tickCount, + Length: tickLength, + Unit: unit, + Orient: orient, + }) + p.LineStroke([]Point{ + { + 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 - splitLineWidth = width - labelWidth - 1 + x1 = top.Width() - p.Width() } - for _, v := range values[0 : len(values)-1] { - x := x0 - y := v - d.moveTo(x, y) - d.lineTo(x+splitLineWidth, y) - r.Stroke() + for _, y := range autoDivide(height, tickCount) { + top.LineStroke([]Point{ + { + X: x0, + Y: y, + }, + { + X: x1, + Y: y, + }, + }) } - } - default: - values := autoDivide(width, tickCount) - // 上下仅是y0的位置不一样 - y0 := height - labelHeight - if position == PositionTop { - y0 = labelHeight - } - if tickShow { - for index, v := range values { - if index%unitCount != 0 { + } else { + y0 := p.Height() - defaultXAxisHeight + y1 := top.Height() - defaultXAxisHeight + for index, x := range autoDivide(width, tickCount) { + if index == 0 { continue } - x := v - y := y0 - d.moveTo(x, y-tickLengthValue) - d.lineTo(x, y) - r.Stroke() - } - } - // 辅助线 - 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() + top.LineStroke([]Point{ + { + X: x, + Y: y0, + }, + { + X: x, + Y: y1, + }, + }) } } } -} - -func (a *axis) measureTextMaxWidthHeight() (int, int) { - d := a.d - r := d.Render - 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) + + return Box{ + Bottom: height, + Right: width, + }, nil } diff --git a/axis_test.go b/axis_test.go deleted file mode 100644 index 37c8314..0000000 --- a/axis_test.go +++ /dev/null @@ -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: "\\nMonTueWedThuFriSatSun", - }, - // 文本居中展示 - // axis位于bottom - { - newOption: func() AxisOption { - opt := getDefaultOption() - return opt - }, - result: "\\nMonTueWedThuFriSatSun", - }, - // 文本按起始位置展示 - // axis位于top - { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionTop - opt.BoundaryGap = FalseFlag() - return opt - }, - result: "\\nMonTueWedThuFriSatSun", - }, - // 文本居中展示 - // axis位于top - { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionTop - return opt - }, - result: "\\nMonTueWedThuFriSatSun", - }, - // 文本按起始位置展示 - // axis位于left - { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionLeft - opt.BoundaryGap = FalseFlag() - return opt - }, - result: "\\nMonTueWedThuFriSatSun", - }, - // 文本居中展示 - // axis位于left - { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionLeft - return opt - }, - result: "\\nMonTueWedThuFriSatSun", - }, - // 文本按起始位置展示 - // axis位于right - { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionRight - opt.BoundaryGap = FalseFlag() - return opt - }, - result: "\\nMonTueWedThuFriSatSun", - }, - // 文本居中展示 - // axis位于right - { - newOption: func() AxisOption { - opt := getDefaultOption() - opt.Position = PositionRight - return opt - }, - result: "\\nMonTueWedThuFriSatSun", - }, - // 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: "\\n01-0201-0501-0801-1101-1401-1701-20", - }, - } - 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) -} diff --git a/bar_chart.go b/bar_chart.go index 32373b1..2982829 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -27,27 +27,48 @@ import ( "github.com/wcharczuk/go-chart/v2" ) -type barChartOption struct { - // The series list fo bar chart - SeriesList SeriesList - // The theme - Theme string - // The font - Font *truetype.Font +type barChart struct { + p *Painter + opt *BarChartOption } -func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointRenderOption, error) { - d, err := NewDraw(DrawOption{ - Parent: result.d, - }, PaddingOption(chart.Box{ - Top: result.titleBox.Height(), - // TODO 后续考虑是否需要根据左侧是否展示y轴再生成对应的left - Left: YAxisWidth, - })) - if err != nil { - return nil, err +func NewBarChart(p *Painter, opt BarChartOption) *barChart { + if opt.Theme == nil { + opt.Theme = defaultTheme } - 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) width := int(x1 - x0) // 每一块之间的margin @@ -61,50 +82,34 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR margin = 5 barMargin = 3 } - - seriesCount := len(opt.SeriesList) + seriesCount := len(seriesList) // 总的宽度-两个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 - theme := NewTheme(opt.Theme) + markPointPainter := NewMarkPointPainter(seriesPainter) + markLinePainter := NewMarkLinePainter(seriesPainter) + rendererList := []Renderer{ + markPointPainter, + markLinePainter, + } + for index := range seriesList { + series := seriesList[index] + yRange := result.axisRanges[series.AxisIndex] + seriesColor := theme.GetSeriesColor(series.index) - 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() + points := make([]Point, len(series.Data)) for j, item := range series.Data { if j >= xRange.divideCount { continue } x := divideValues[j] x += margin - if i != 0 { - x += i * (barWidth + barMargin) + if index != 0 { + x += index * (barWidth + barMargin) } h := int(yRange.getHeight(item.Value)) @@ -113,13 +118,14 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR fillColor = item.Style.FillColor } top := barMaxHeight - h - d.Bar(chart.Box{ + + seriesPainter.OverrideDrawingStyle(Style{ + FillColor: fillColor, + }).Rect(chart.Box{ Top: top, Left: x, Right: x + barWidth, Bottom: barMaxHeight - 1, - }, BarStyle{ - FillColor: fillColor, }) // 用于生成marker point points[j] = Point{ @@ -127,6 +133,12 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR X: x + barWidth>>1, Y: top, } + // 用于生成marker point + points[j] = Point{ + // 居中的位置 + X: x + barWidth>>1, + Y: top, + } // 如果label不需要展示,则返回 if !series.Label.Show { continue @@ -135,8 +147,8 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR if distance == 0 { distance = 5 } - text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) - labelStyle := chart.Style{ + text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(index, item.Value, -1) + labelStyle := Style{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, @@ -144,20 +156,50 @@ func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointR if !series.Label.Color.IsZero() { labelStyle.FontColor = series.Label.Color } - labelStyle.GetTextOptions().WriteToRenderer(r) - textBox := r.MeasureText(text) - d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance) + seriesPainter.OverrideTextStyle(labelStyle) + textBox := seriesPainter.MeasureText(text) + seriesPainter.Text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance) } - // 生成mark point的参数 - markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{ - Draw: d, + markPointPainter.Add(markPointRenderOption{ FillColor: seriesColor, Font: opt.Font, + Series: series, 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) } diff --git a/bar_chart_test.go b/bar_chart_test.go deleted file mode 100644 index f10a1bc..0000000 --- a/bar_chart_test.go +++ /dev/null @@ -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("\\n20206090", string(data)) -} diff --git a/bar_test.go b/bar_test.go deleted file mode 100644 index 01b6d3c..0000000 --- a/bar_test.go +++ /dev/null @@ -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("\\n", string(data)) -} diff --git a/chart.go b/chart.go deleted file mode 100644 index 21f2071..0000000 --- a/chart.go +++ /dev/null @@ -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 -} diff --git a/chart_option.go b/chart_option.go index 5e25873..0bc0a34 100644 --- a/chart_option.go +++ b/chart_option.go @@ -23,10 +23,49 @@ package charts import ( + "sort" + + "github.com/golang/freetype/truetype" "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 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 func LegendOptionFunc(legend LegendOption) OptionFunc { 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 func XAxisOptionFunc(xAxisOption XAxisOption) OptionFunc { 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 func YAxisOptionFunc(yAxisOption ...YAxisOption) OptionFunc { 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 -func PaddingOptionFunc(padding chart.Box) OptionFunc { +func PaddingOptionFunc(padding Box) OptionFunc { return func(opt *ChartOption) { opt.Padding = padding } } // BoxOptionFunc set box of chart -func BoxOptionFunc(box chart.Box) OptionFunc { +func BoxOptionFunc(box Box) OptionFunc { return func(opt *ChartOption) { opt.Box = box } } +// PieSeriesShowLabel set pie series show label +func PieSeriesShowLabel() OptionFunc { + return func(opt *ChartOption) { + for index := range opt.SeriesList { + opt.SeriesList[index].Label.Show = true + } + } +} + // ChildOptionFunc add child chart func ChildOptionFunc(child ...ChartOption) OptionFunc { return func(opt *ChartOption) { @@ -123,61 +199,143 @@ func ChildOptionFunc(child ...ChartOption) OptionFunc { } // RadarIndicatorOptionFunc set radar indicator of chart -func RadarIndicatorOptionFunc(radarIndicator ...RadarIndicator) OptionFunc { +func RadarIndicatorOptionFunc(names []string, values []float64) OptionFunc { 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 -func BackgroundColorOptionFunc(color drawing.Color) OptionFunc { +func BackgroundColorOptionFunc(color Color) OptionFunc { return func(opt *ChartOption) { opt.BackgroundColor = color } } -// LineRender line chart render -func LineRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { - seriesList := make(SeriesList, len(values)) - for index, value := range values { - seriesList[index] = NewSeriesFromValues(value, ChartTypeLine) +// MarkLineOptionFunc set mark line for series of chart +func MarkLineOptionFunc(seriesIndex int, markLineTypes ...string) OptionFunc { + return func(opt *ChartOption) { + if len(opt.SeriesList) <= seriesIndex { + return + } + opt.SeriesList[seriesIndex].MarkLine = NewMarkLine(markLineTypes...) } +} + +// MarkPointOptionFunc set mark point for series of chart +func MarkPointOptionFunc(seriesIndex int, markPointTypes ...string) OptionFunc { + return func(opt *ChartOption) { + if len(opt.SeriesList) <= seriesIndex { + return + } + opt.SeriesList[seriesIndex].MarkPoint = NewMarkPoint(markPointTypes...) + } +} + +func (o *ChartOption) fillDefault() { + t := NewTheme(o.Theme) + o.theme = t + // 如果为空,初始化 + axisCount := 1 + for _, series := range o.SeriesList { + if series.AxisIndex >= axisCount { + axisCount++ + } + } + o.Width = getDefaultInt(o.Width, defaultChartWidth) + o.Height = getDefaultInt(o.Height, defaultChartHeight) + yAxisOptions := make([]YAxisOption, axisCount) + copy(yAxisOptions, o.YAxisOptions) + o.YAxisOptions = yAxisOptions + o.font, _ = GetFont(o.FontFamily) + + if o.font == nil { + o.font, _ = 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{ SeriesList: seriesList, }, opts...) } // BarRender bar chart render -func BarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { - seriesList := make(SeriesList, len(values)) - for index, value := range values { - seriesList[index] = NewSeriesFromValues(value, ChartTypeBar) - } +func BarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { + seriesList := NewSeriesListDataFromValues(values, ChartTypeBar) + return Render(ChartOption{ + SeriesList: seriesList, + }, opts...) +} + +// HorizontalBarRender horizontal bar chart render +func HorizontalBarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { + seriesList := NewSeriesListDataFromValues(values, ChartTypeHorizontalBar) return Render(ChartOption{ SeriesList: seriesList, }, opts...) } // PieRender pie chart render -func PieRender(values []float64, opts ...OptionFunc) (*Draw, error) { +func PieRender(values []float64, opts ...OptionFunc) (*Painter, error) { return Render(ChartOption{ SeriesList: NewPieSeriesList(values), }, opts...) } // RadarRender radar chart render -func RadarRender(values [][]float64, opts ...OptionFunc) (*Draw, error) { - seriesList := make(SeriesList, len(values)) - for index, value := range values { - seriesList[index] = NewSeriesFromValues(value, ChartTypeRadar) - } +func RadarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { + seriesList := NewSeriesListDataFromValues(values, ChartTypeRadar) return Render(ChartOption{ SeriesList: seriesList, }, opts...) } // FunnelRender funnel chart render -func FunnelRender(values []float64, opts ...OptionFunc) (*Draw, error) { +func FunnelRender(values []float64, opts ...OptionFunc) (*Painter, error) { seriesList := make(SeriesList, len(values)) for index, value := range values { seriesList[index] = NewSeriesFromValues([]float64{ diff --git a/chart_option_test.go b/chart_option_test.go deleted file mode 100644 index 41e8d50..0000000 --- a/chart_option_test.go +++ /dev/null @@ -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("\\n010203024681012", 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("\\n010203024681012", 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("\\n", 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("\\nABC", 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("\\n(100%)(60%)(20%)", string(data)) -} diff --git a/chart_test.go b/chart_test.go deleted file mode 100644 index c73745e..0000000 --- a/chart_test.go +++ /dev/null @@ -1,567 +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" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -func TestChartSetDefaultWidthHeight(t *testing.T) { - assert := assert.New(t) - - width := defaultChartWidth - height := defaultChartHeight - defer SetDefaultWidth(width) - defer SetDefaultHeight(height) - - SetDefaultWidth(60) - assert.Equal(60, defaultChartWidth) - SetDefaultHeight(40) - assert.Equal(40, defaultChartHeight) -} - -func TestChartFillDefault(t *testing.T) { - assert := assert.New(t) - // default value - opt := ChartOption{} - opt.FillDefault("") - // padding - assert.Equal(chart.Box{ - Top: 10, - Right: 10, - Bottom: 10, - Left: 10, - }, opt.Padding) - // background color - assert.Equal(drawing.ColorWhite, opt.BackgroundColor) - // title font color - assert.Equal(drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 255, - }, opt.Title.Style.FontColor) - // title font size - assert.Equal(float64(14), opt.Title.Style.FontSize) - // sub title font color - assert.Equal(drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 180, - }, opt.Title.SubtextStyle.FontColor) - // sub title font size - assert.Equal(float64(10), opt.Title.SubtextStyle.FontSize) - // legend font size - assert.Equal(float64(10), opt.Legend.Style.FontSize) - // legend position - assert.Equal("center", opt.Legend.Left) - assert.Equal(drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 255, - }, opt.Legend.Style.FontColor) - - // y axis - opt = ChartOption{ - SeriesList: SeriesList{ - { - YAxisIndex: 1, - }, - }, - } - opt.FillDefault("") - assert.Equal([]YAxisOption{ - {}, - {}, - }, opt.YAxisList) - opt = ChartOption{} - opt.FillDefault("") - assert.Equal([]YAxisOption{ - {}, - }, opt.YAxisList) - - // legend get from series's name - - opt = ChartOption{ - SeriesList: SeriesList{ - { - Name: "a", - }, - { - Name: "b", - }, - }, - } - opt.FillDefault("") - assert.Equal([]string{ - "a", - "b", - }, opt.Legend.Data) - // series name set by legend - opt = ChartOption{ - Legend: LegendOption{ - Data: []string{ - "a", - "b", - }, - }, - SeriesList: SeriesList{ - {}, - {}, - }, - } - opt.FillDefault("") - assert.Equal("a", opt.SeriesList[0].Name) - assert.Equal("b", opt.SeriesList[1].Name) -} - -func TestChartGetWidthHeight(t *testing.T) { - assert := assert.New(t) - - opt := ChartOption{ - Width: 10, - } - assert.Equal(10, opt.getWidth()) - opt.Width = 0 - assert.Equal(600, opt.getWidth()) - opt.Parent = &Draw{ - Box: chart.Box{ - Left: 10, - Right: 50, - }, - } - assert.Equal(40, opt.getWidth()) - - opt = ChartOption{ - Height: 20, - } - assert.Equal(20, opt.getHeight()) - opt.Height = 0 - assert.Equal(400, opt.getHeight()) - opt.Parent = &Draw{ - Box: chart.Box{ - Top: 20, - Bottom: 80, - }, - } - assert.Equal(60, opt.getHeight()) -} - -func TestChartRender(t *testing.T) { - assert := assert.New(t) - - d, err := Render(ChartOption{ - Width: 800, - Height: 600, - Legend: LegendOption{ - Top: "-90", - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Padding: chart.Box{ - Top: 100, - }, - XAxis: NewXAxisOption([]string{ - "2012", - "2013", - "2014", - "2015", - "2016", - "2017", - }), - YAxisList: []YAxisOption{ - { - - Min: NewFloatPoint(0), - Max: NewFloatPoint(90), - }, - }, - SeriesList: []Series{ - NewSeriesFromValues([]float64{ - 56.5, - 82.1, - 88.7, - 70.1, - 53.4, - 85.1, - }), - NewSeriesFromValues([]float64{ - 51.1, - 51.4, - 55.1, - 53.3, - 73.8, - 68.7, - }), - NewSeriesFromValues([]float64{ - 40.1, - 62.2, - 69.5, - 36.4, - 45.2, - 32.5, - }, ChartTypeBar), - NewSeriesFromValues([]float64{ - 25.2, - 37.1, - 41.2, - 18, - 33.9, - 49.1, - }, ChartTypeBar), - }, - Children: []ChartOption{ - { - Legend: LegendOption{ - Show: FalseFlag(), - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Box: chart.Box{ - Top: 20, - Left: 400, - Right: 500, - Bottom: 120, - }, - SeriesList: NewPieSeriesList([]float64{ - 435.9, - 354.3, - 285.9, - 204.5, - }, PieSeriesOption{ - Label: SeriesLabel{ - Show: true, - }, - Radius: "35%", - }), - }, - { - Legend: NewLegendOption([]string{ - "Allocated Budget", - "Actual Spending", - }), - Box: chart.Box{ - Top: 20, - Left: 0, - Right: 200, - Bottom: 120, - }, - RadarIndicators: []RadarIndicator{ - { - Name: "Sales", - Max: 6500, - }, - { - Name: "Administration", - Max: 16000, - }, - { - Name: "Information Technology", - Max: 30000, - }, - { - Name: "Customer Support", - Max: 38000, - }, - { - Name: "Development", - Max: 52000, - }, - { - Name: "Marketing", - Max: 25000, - }, - }, - SeriesList: SeriesList{ - { - Type: ChartTypeRadar, - Data: NewSeriesDataFromValues([]float64{ - 4200, - 3000, - 20000, - 35000, - 50000, - 18000, - }), - }, - { - Type: ChartTypeRadar, - index: 1, - Data: NewSeriesDataFromValues([]float64{ - 5000, - 14000, - 28000, - 26000, - 42000, - 21000, - }), - }, - }, - }, - }, - }) - assert.Nil(err) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\n2012201320142015201620170153045607590Milk TeaMatcha LatteCheese CocoaWalnut BrownieMilk Tea: 34.03%Matcha Latte: 27.66%Cheese Cocoa: 22.32%Walnut Brownie: 15.96%SalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketingAllocated BudgetActual Spending", string(data)) -} - -func BenchmarkMultiChartPNGRender(b *testing.B) { - for i := 0; i < b.N; i++ { - opt := ChartOption{ - Type: ChartOutputPNG, - Legend: LegendOption{ - Top: "-90", - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Padding: chart.Box{ - Top: 100, - Right: 10, - Bottom: 10, - Left: 10, - }, - XAxis: NewXAxisOption([]string{ - "2012", - "2013", - "2014", - "2015", - "2016", - "2017", - }), - YAxisList: []YAxisOption{ - { - - Min: NewFloatPoint(0), - Max: NewFloatPoint(90), - }, - }, - SeriesList: []Series{ - NewSeriesFromValues([]float64{ - 56.5, - 82.1, - 88.7, - 70.1, - 53.4, - 85.1, - }), - NewSeriesFromValues([]float64{ - 51.1, - 51.4, - 55.1, - 53.3, - 73.8, - 68.7, - }), - NewSeriesFromValues([]float64{ - 40.1, - 62.2, - 69.5, - 36.4, - 45.2, - 32.5, - }, ChartTypeBar), - NewSeriesFromValues([]float64{ - 25.2, - 37.1, - 41.2, - 18, - 33.9, - 49.1, - }, ChartTypeBar), - }, - Children: []ChartOption{ - { - Legend: LegendOption{ - Show: FalseFlag(), - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Box: chart.Box{ - Top: 20, - Left: 400, - Right: 500, - Bottom: 120, - }, - SeriesList: NewPieSeriesList([]float64{ - 435.9, - 354.3, - 285.9, - 204.5, - }, PieSeriesOption{ - Label: SeriesLabel{ - Show: true, - }, - Radius: "35%", - }), - }, - }, - } - d, err := Render(opt) - if err != nil { - panic(err) - } - buf, err := d.Bytes() - if err != nil { - panic(err) - } - if len(buf) == 0 { - panic(errors.New("data is nil")) - } - } -} - -func BenchmarkMultiChartSVGRender(b *testing.B) { - for i := 0; i < b.N; i++ { - opt := ChartOption{ - Legend: LegendOption{ - Top: "-90", - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Padding: chart.Box{ - Top: 100, - Right: 10, - Bottom: 10, - Left: 10, - }, - XAxis: NewXAxisOption([]string{ - "2012", - "2013", - "2014", - "2015", - "2016", - "2017", - }), - YAxisList: []YAxisOption{ - { - - Min: NewFloatPoint(0), - Max: NewFloatPoint(90), - }, - }, - SeriesList: []Series{ - NewSeriesFromValues([]float64{ - 56.5, - 82.1, - 88.7, - 70.1, - 53.4, - 85.1, - }), - NewSeriesFromValues([]float64{ - 51.1, - 51.4, - 55.1, - 53.3, - 73.8, - 68.7, - }), - NewSeriesFromValues([]float64{ - 40.1, - 62.2, - 69.5, - 36.4, - 45.2, - 32.5, - }, ChartTypeBar), - NewSeriesFromValues([]float64{ - 25.2, - 37.1, - 41.2, - 18, - 33.9, - 49.1, - }, ChartTypeBar), - }, - Children: []ChartOption{ - { - Legend: LegendOption{ - Show: FalseFlag(), - Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", - }, - }, - Box: chart.Box{ - Top: 20, - Left: 400, - Right: 500, - Bottom: 120, - }, - SeriesList: NewPieSeriesList([]float64{ - 435.9, - 354.3, - 285.9, - 204.5, - }, PieSeriesOption{ - Label: SeriesLabel{ - Show: true, - }, - Radius: "35%", - }), - }, - }, - } - d, err := Render(opt) - if err != nil { - panic(err) - } - buf, err := d.Bytes() - if err != nil { - panic(err) - } - if len(buf) == 0 { - panic(errors.New("data is nil")) - } - } -} diff --git a/charts.go b/charts.go new file mode 100644 index 0000000..cd1ac2b --- /dev/null +++ b/charts.go @@ -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 +} diff --git a/draw.go b/draw.go deleted file mode 100644 index 1708662..0000000 --- a/draw.go +++ /dev/null @@ -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() -} diff --git a/echarts.go b/echarts.go index 4ebb9ad..d2602b3 100644 --- a/echarts.go +++ b/echarts.go @@ -130,6 +130,7 @@ type EChartsXAxisData struct { BoundaryGap *bool `json:"boundaryGap"` SplitNumber int `json:"splitNumber"` Data []string `json:"data"` + Type string `json:"type"` } type EChartsXAxis struct { Data []EChartsXAxisData @@ -155,6 +156,7 @@ type EChartsYAxisData struct { Color string `json:"color"` } `json:"lineStyle"` } `json:"axisLine"` + Data []string `json:"data"` } type EChartsYAxis struct { Data []EChartsYAxisData `json:"data"` @@ -354,10 +356,10 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { } } seriesList = append(seriesList, Series{ - Type: item.Type, - Data: data, - YAxisIndex: item.YAxisIndex, - Style: item.ItemStyle.ToStyle(), + Type: item.Type, + Data: data, + AxisIndex: item.YAxisIndex, + Style: item.ItemStyle.ToStyle(), Label: SeriesLabel{ Color: parseColor(item.Label.Color), Show: item.Label.Show, @@ -419,26 +421,32 @@ func (eo *EChartsOption) ToOption() ChartOption { if len(fontFamily) == 0 { fontFamily = eo.Title.TextStyle.FontFamily } + titleTextStyle := eo.Title.TextStyle.ToStyle() + titleSubtextStyle := eo.Title.SubtextStyle.ToStyle() + legendTextStyle := eo.Legend.TextStyle.ToStyle() o := ChartOption{ Type: eo.Type, FontFamily: fontFamily, Theme: eo.Theme, Title: TitleOption{ - Text: eo.Title.Text, - Subtext: eo.Title.Subtext, - Style: eo.Title.TextStyle.ToStyle(), - SubtextStyle: eo.Title.SubtextStyle.ToStyle(), - Left: string(eo.Title.Left), - Top: string(eo.Title.Top), + Text: eo.Title.Text, + Subtext: eo.Title.Subtext, + FontColor: titleTextStyle.FontColor, + FontSize: titleTextStyle.FontSize, + SubtextFontSize: titleSubtextStyle.FontSize, + SubtextFontColor: titleSubtextStyle.FontColor, + Left: string(eo.Title.Left), + Top: string(eo.Title.Top), }, Legend: LegendOption{ - Show: eo.Legend.Show, - Style: eo.Legend.TextStyle.ToStyle(), - Data: eo.Legend.Data, - Left: string(eo.Legend.Left), - Top: string(eo.Legend.Top), - Align: eo.Legend.Align, - Orient: eo.Legend.Orient, + Show: eo.Legend.Show, + FontSize: legendTextStyle.FontSize, + FontColor: legendTextStyle.FontColor, + Data: eo.Legend.Data, + Left: string(eo.Legend.Left), + Top: string(eo.Legend.Top), + Align: eo.Legend.Align, + Orient: eo.Legend.Orient, }, RadarIndicators: eo.Radar.Indicator, Width: eo.Width, @@ -447,6 +455,21 @@ func (eo *EChartsOption) ToOption() ChartOption { Box: eo.Box, SeriesList: eo.Series.ToSeriesList(), } + isHorizontalChart := false + for _, item := range eo.XAxis.Data { + if item.Type == "value" { + isHorizontalChart = true + } + } + if isHorizontalChart { + for index := range o.SeriesList { + series := o.SeriesList[index] + if series.Type == ChartTypeBar { + o.SeriesList[index].Type = ChartTypeHorizontalBar + } + } + } + if len(eo.XAxis.Data) != 0 { xAxisData := eo.XAxis.Data[0] o.XAxis = XAxisOption{ @@ -462,9 +485,10 @@ func (eo *EChartsOption) ToOption() ChartOption { Max: item.Max, Formatter: item.AxisLabel.Formatter, Color: parseColor(item.AxisLine.LineStyle.Color), + Data: item.Data, } } - o.YAxisList = yAxisOptions + o.YAxisOptions = yAxisOptions if len(eo.Children) != 0 { o.Children = make([]ChartOption, len(eo.Children)) diff --git a/echarts_test.go b/echarts_test.go deleted file mode 100644 index 05c2a40..0000000 --- a/echarts_test.go +++ /dev/null @@ -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()) - -} diff --git a/examples/bar_chart/main.go b/examples/bar_chart/main.go new file mode 100644 index 0000000..c559a76 --- /dev/null +++ b/examples/bar_chart/main.go @@ -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) + } +} diff --git a/examples/basic/main.go b/examples/basic/main.go deleted file mode 100644 index 1e7af8d..0000000 --- a/examples/basic/main.go +++ /dev/null @@ -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) - } - } -} diff --git a/examples/charts/main.go b/examples/charts/main.go index fddbe6d..0e1d48e 100644 --- a/examples/charts/main.go +++ b/examples/charts/main.go @@ -5,9 +5,7 @@ import ( "net/http" "strconv" - charts "github.com/vicanso/go-charts" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" + charts "github.com/vicanso/go-charts/v2" ) var html = ` @@ -75,6 +73,7 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha bytesList := make([][]byte, 0) for _, opt := range chartOptions { opt.Theme = theme + opt.Type = charts.ChartOutputSVG d, err := charts.Render(opt) if err != nil { panic(err) @@ -100,7 +99,6 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha func indexHandler(w http.ResponseWriter, req *http.Request) { chartOptions := []charts.ChartOption{ - // 普通折线图 { Title: charts.TitleOption{ Text: "Line", @@ -174,7 +172,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Title: charts.TitleOption{ Text: "Temperature Change in the Coming Week", }, - Padding: chart.Box{ + Padding: charts.Box{ Top: 20, Left: 20, Right: 30, @@ -240,7 +238,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Rainfall", "Evaporation", }, - Icon: charts.LegendIconRect, + Icon: charts.IconRect, }, SeriesList: []charts.Series{ charts.NewSeriesFromValues([]float64{ @@ -260,8 +258,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, { Value: 190, - Style: chart.Style{ - FillColor: drawing.Color{ + Style: charts.Style{ + FillColor: charts.Color{ R: 169, G: 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{ Text: "Rainfall vs Evaporation", Subtext: "Fake Data", }, - Padding: chart.Box{ + Padding: charts.Box{ Top: 20, Right: 20, Bottom: 20, @@ -371,6 +417,9 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, // 双Y轴示例 { + Title: charts.TitleOption{ + Text: "Temperature", + }, XAxis: charts.NewXAxisOption([]string{ "Jan", "Feb", @@ -390,22 +439,22 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Precipitation", "Temperature", }), - YAxisList: []charts.YAxisOption{ + YAxisOptions: []charts.YAxisOption{ { - Formatter: "{value}°C", - Color: drawing.Color{ - R: 250, - G: 200, - B: 88, + Formatter: "{value}ml", + Color: charts.Color{ + R: 84, + G: 112, + B: 198, A: 255, }, }, { - Formatter: "{value}ml", - Color: drawing.Color{ - R: 84, - G: 112, - B: 198, + Formatter: "{value}°C", + Color: charts.Color{ + R: 250, + G: 200, + B: 88, A: 255, }, }, @@ -426,9 +475,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 20.0, 6.4, 3.3, - 10.2, }), - YAxisIndex: 1, }, { Type: charts.ChartTypeBar, @@ -445,9 +492,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 18.8, 6.0, 2.3, - 20.2, }), - YAxisIndex: 1, }, { Data: charts.NewSeriesDataFromValues([]float64{ @@ -463,8 +508,8 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 16.5, 12.0, 6.2, - 30.3, }), + AxisIndex: 1, }, }, }, @@ -572,6 +617,20 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Order", }), SeriesList: []charts.Series{ + { + Type: charts.ChartTypeFunnel, + Name: "Show", + Data: charts.NewSeriesDataFromValues([]float64{ + 100, + }), + }, + { + Type: charts.ChartTypeFunnel, + Name: "Click", + Data: charts.NewSeriesDataFromValues([]float64{ + 80, + }), + }, { Type: charts.ChartTypeFunnel, Name: "Visit", @@ -593,20 +652,6 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { 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", }, }, - Padding: chart.Box{ + Padding: charts.Box{ Top: 100, Right: 10, Bottom: 10, @@ -634,7 +679,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "2016", "2017", }), - YAxisList: []charts.YAxisOption{ + YAxisOptions: []charts.YAxisOption{ { Min: charts.NewFloatPoint(0), @@ -686,7 +731,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { "Walnut Brownie", }, }, - Box: chart.Box{ + Box: charts.Box{ Top: 20, Left: 400, 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": { "text": "Rainfall vs Evaporation", @@ -1172,12 +1275,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { 23.2, 25.6, 76.7, - 135.6, - 162.2, - 32.6, - 20, - 6.4, - 3.3 + 135.6 ] }, { @@ -1191,12 +1289,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { 26.4, 28.7, 70.7, - 175.6, - 182.2, - 48.7, - 18.8, - 6, - 2.3 + 175.6 ] }, { @@ -1211,12 +1304,7 @@ func echartsHandler(w http.ResponseWriter, req *http.Request) { 4.5, 6.3, 10.2, - 20.3, - 23.4, - 23, - 16.5, - 12, - 6.2 + 20.3 ] } ] diff --git a/examples/chinese/main.go b/examples/chinese/main.go index e0125b4..9c2d6a5 100644 --- a/examples/chinese/main.go +++ b/examples/chinese/main.go @@ -2,49 +2,118 @@ package main import ( "io/ioutil" - "log" + "os" + "path/filepath" - charts "github.com/vicanso/go-charts" + "github.com/vicanso/go-charts/v2" ) -func echartsRender() ([]byte, error) { - return charts.RenderEChartsToPNG(`{ - "title": { - "text": "用户访问次数", - "textStyle": { - "fontFamily": "chinese" - } - }, - "xAxis": { - "data": ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] - }, - "series": [ - { - "data": [150, 230, 224, 218, 135, 147, 260], - "label": { - "show": true - } - } - ] - }`) +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "chinese-line-chart.png") + err = ioutil.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil } func main() { - fontData, err := ioutil.ReadFile("/Users/darcy/Downloads/NotoSansCJKsc-VF.ttf") + // 字体文件需要自行下载 + buf, err := ioutil.ReadFile("../NotoSansSC.ttf") 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 { - log.Fatalln("Error when instaling font:", err) + 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, + }, } - - fileData, err := echartsRender() + p, err := charts.LineRender( + values, + charts.TitleTextOptionFunc("Line"), + charts.FontFamilyOptionFunc("noto"), + charts.XAxisDataOptionFunc([]string{ + "星期一", + "星期二", + "星期三", + "星期四", + "星期五", + "星期六", + "星期日", + }), + charts.LegendLabelsOptionFunc([]string{ + "邮件", + "广告", + "视频广告", + "直接访问", + "搜索引擎", + }, charts.PositionCenter), + ) 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) + } +) + diff --git a/examples/funnel_chart/main.go b/examples/funnel_chart/main.go new file mode 100644 index 0000000..8f21db6 --- /dev/null +++ b/examples/funnel_chart/main.go @@ -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) + } +} diff --git a/examples/horizontal_bar_chart/main.go b/examples/horizontal_bar_chart/main.go new file mode 100644 index 0000000..8b996b6 --- /dev/null +++ b/examples/horizontal_bar_chart/main.go @@ -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) + } +} diff --git a/examples/line_chart/main.go b/examples/line_chart/main.go new file mode 100644 index 0000000..45ff894 --- /dev/null +++ b/examples/line_chart/main.go @@ -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) + } +} diff --git a/examples/painter/main.go b/examples/painter/main.go new file mode 100644 index 0000000..304361d --- /dev/null +++ b/examples/painter/main.go @@ -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) + } +} diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go new file mode 100644 index 0000000..3721ed1 --- /dev/null +++ b/examples/pie_chart/main.go @@ -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) + } +} diff --git a/examples/radar_chart/main.go b/examples/radar_chart/main.go new file mode 100644 index 0000000..51f7409 --- /dev/null +++ b/examples/radar_chart/main.go @@ -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) + } +} diff --git a/funnel.go b/funnel_chart.go similarity index 65% rename from funnel.go rename to funnel_chart.go index f083306..63b3504 100644 --- a/funnel.go +++ b/funnel_chart.go @@ -24,34 +24,43 @@ package charts import ( "fmt" - "sort" "github.com/dustin/go-humanize" "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" ) -type funnelChartOption struct { - Theme string - Font *truetype.Font - SeriesList SeriesList +type funnelChart struct { + p *Painter + opt *FunnelChartOption } -func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error { - d, err := NewDraw(DrawOption{ - Parent: result.d, - }, PaddingOption(chart.Box{ - Top: result.titleBox.Height(), - })) - if err != nil { - return err +func NewFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart { + if opt.Theme == nil { + opt.Theme = defaultTheme } - seriesList := make([]Series, len(opt.SeriesList)) - copy(seriesList, opt.SeriesList) - sort.Slice(seriesList, func(i, j int) bool { - // 大的数据在前 - return seriesList[i].Data[0].Value > seriesList[j].Data[0].Value - }) + return &funnelChart{ + p: p, + opt: &opt, + } +} + +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 min := float64(0) for _, item := range seriesList { @@ -62,11 +71,10 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error { min = *item.Min } } - - theme := NewTheme(opt.Theme) + theme := opt.Theme gap := 2 - height := d.Box.Height() - width := d.Box.Width() + height := seriesPainter.Height() + width := seriesPainter.Width() count := len(seriesList) h := (height - gap*(count-1)) / count @@ -116,26 +124,49 @@ func funnelChartRender(opt funnelChartOption, result *basicRenderResult) error { }, } color := theme.GetSeriesColor(series.index) - d.fill(points, chart.Style{ + + seriesPainter.OverrideDrawingStyle(Style{ FillColor: color, - }) + }).FillArea(points) // 文本 text := textList[index] - r := d.Render - textStyle := chart.Style{ + seriesPainter.OverrideTextStyle(Style{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, - } - textStyle.GetTextOptions().WriteToRenderer(r) - textBox := r.MeasureText(text) + }) + textBox := seriesPainter.MeasureText(text) textX := width>>1 - textBox.Width()>>1 textY := y + h>>1 - d.text(text, textX, textY) - + seriesPainter.Text(text, textX, textY) 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) } diff --git a/funnel_test.go b/funnel_test.go deleted file mode 100644 index 530fa53..0000000 --- a/funnel_test.go +++ /dev/null @@ -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("\\nShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", string(data)) -} diff --git a/go.mod b/go.mod index 610af22..66145c7 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,17 @@ -module github.com/vicanso/go-charts +module github.com/vicanso/go-charts/v2 go 1.17 require ( github.com/dustin/go-humanize v1.0.0 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 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + golang.org/x/image v0.0.0-20220617043117-41969df76e82 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d88f473..5f953b0 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +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/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-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE= -golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20220617043117-41969df76e82 h1:KpZB5pUSBvrHltNEdK/tw0xlPeD13M6M6aGP32gKqiw= +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.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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/grid.go b/grid.go new file mode 100644 index 0000000..252fe2e --- /dev/null +++ b/grid.go @@ -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 +} diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go new file mode 100644 index 0000000..fb23734 --- /dev/null +++ b/horizontal_bar_chart.go @@ -0,0 +1,148 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/golang/freetype/truetype" + "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) +} diff --git a/legend.go b/legend.go index df72757..cf8d417 100644 --- a/legend.go +++ b/legend.go @@ -25,16 +25,18 @@ package charts import ( "strconv" "strings" - - "github.com/wcharczuk/go-chart/v2" ) +type legendPainter struct { + p *Painter + opt *LegendOption +} + +const IconRect = "rect" +const IconLineDot = "lineDot" + type LegendOption struct { - theme string - // Legend show flag, if nil or true, the legend will be shown - Show *bool - // Legend text style - Style chart.Style + Theme ColorPalette // Text array of legend Data []string // Distance between legend component and the left side of the container. @@ -50,177 +52,170 @@ type LegendOption struct { Orient string // Icon of the legend. Icon string + // Font size of legend text + FontSize float64 + // FontColor color of legend text + FontColor Color + // The flag for show legend, set this to *false will hide legend + Show *bool } -const ( - LegendIconRect = "rect" -) - -// NewLegendOption creates a new legend option by legend text list -func NewLegendOption(data []string, position ...string) LegendOption { +func NewLegendOption(labels []string, left ...string) LegendOption { opt := LegendOption{ - Data: data, + Data: labels, } - if len(position) != 0 { - opt.Left = position[0] + if len(left) != 0 { + opt.Left = left[0] } return opt } -type legend struct { - d *Draw - opt *LegendOption +func (opt *LegendOption) IsEmpty() bool { + isEmpty := true + for _, v := range opt.Data { + if v != "" { + isEmpty = false + break + } + } + return isEmpty } -func NewLegend(d *Draw, opt LegendOption) *legend { - return &legend{ - d: d, +func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter { + return &legendPainter{ + p: p, opt: &opt, } } -func (l *legend) Render() (chart.Box, error) { - d := l.d +func (l *legendPainter) Render() (Box, error) { opt := l.opt - if len(opt.Data) == 0 || isFalse(opt.Show) { - return chart.BoxZero, nil + theme := opt.Theme + if opt.IsEmpty() || + (opt.Show != nil && !*opt.Show) { + return BoxZero, nil } - theme := NewTheme(opt.theme) - padding := opt.Style.Padding - legendDraw, err := NewDraw(DrawOption{ - Parent: d, - }, PaddingOption(padding)) - if err != nil { - return chart.BoxZero, err + if theme == nil { + theme = l.p.theme } - r := legendDraw.Render - opt.Style.GetTextOptions().WriteToRenderer(r) - - x := 0 - y := 0 - top := 0 - // TODO TOP 暂只支持数值 - if opt.Top != "" { - top, _ = strconv.Atoi(opt.Top) - y += top + if opt.FontSize == 0 { + opt.FontSize = theme.GetFontSize() } - legendWidth := 30 - legendDotHeight := 5 - textPadding := 5 - legendMargin := 10 - // 往下移2倍dot的高度 - y += 2 * legendDotHeight - - widthCount := 0 + if opt.FontColor.IsZero() { + opt.FontColor = theme.GetTextColor() + } + if opt.Left == "" { + opt.Left = PositionCenter + } + p := l.p.Child(PainterPaddingOption(Box{ + Top: 5, + })) + p.SetTextStyle(Style{ + FontSize: opt.FontSize, + FontColor: opt.FontColor, + }) + measureList := make([]Box, len(opt.Data)) maxTextWidth := 0 - // 文本宽度 - for _, text := range opt.Data { - b := r.MeasureText(text) + for index, text := range opt.Data { + b := p.MeasureText(text) if b.Width() > maxTextWidth { maxTextWidth = b.Width() } - widthCount += b.Width() - } - 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) + measureList[index] = b } + // 计算展示的宽高 + 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 switch opt.Left { case PositionRight: - left = legendDraw.Box.Width() - widthCount + left = p.Width() - width case PositionCenter: - left = (legendDraw.Box.Width() - widthCount) >> 1 + left = (p.Width() - width) >> 1 default: if strings.HasSuffix(opt.Left, "%") { value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) - left = legendDraw.Box.Width() * value / 100 + left = p.Width() * value / 100 } else { value, _ := strconv.Atoi(opt.Left) 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 { - textBox := r.MeasureText(text) - var renderText func() + color := theme.GetSeriesColor(index) + 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 { - // 垂直 - // 重置x的位置 - x = left - renderText = func() { - x += textPadding - legendDraw.text(text, x, y+legendDotHeight) - x += textBox.Width() - y += (2*legendDotHeight + legendMargin) - } - + y0 += offset + x0 = x } else { - // 水平 - if index != 0 { - 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() + x0 += offset + y0 = y } } - legendBox := padding.Clone() - // 计算展示区域 - if opt.Orient == OrientVertical { - legendBox.Right = legendBox.Left + left + maxTextWidth + legendWidth + textPadding - legendBox.Bottom = legendBox.Top + y - } else { - legendBox.Right = legendBox.Left + x - legendBox.Bottom = legendBox.Top + 2*legendDotHeight + top + textPadding - } - return legendBox, nil + + return Box{ + Right: width, + Bottom: height, + }, nil } diff --git a/legend_test.go b/legend_test.go deleted file mode 100644 index c5d7e50..0000000 --- a/legend_test.go +++ /dev/null @@ -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: "\\nMonTueWed", - 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: "\\nMonTueWed", - 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: "\\nMonTueWed", - 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: "\\nMonTueWed", - 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: "\\nMonTueWed", - }, - } - - 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)) - } -} diff --git a/line.go b/line.go deleted file mode 100644 index 0fc25d6..0000000 --- a/line.go +++ /dev/null @@ -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) -} diff --git a/line_chart.go b/line_chart.go index ac9091c..0dc0fd8 100644 --- a/line_chart.go +++ b/line_chart.go @@ -24,108 +24,146 @@ package charts import ( "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2/drawing" ) -type lineChartOption struct { - Theme string - SeriesList SeriesList - Font *truetype.Font +type lineChart struct { + p *Painter + opt *LineChartOption } -func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPointRenderOption, error) { - - theme := NewTheme(opt.Theme) - - d, err := NewDraw(DrawOption{ - Parent: result.d, - }, PaddingOption(chart.Box{ - Top: result.titleBox.Height(), - Left: YAxisWidth, - })) - if err != nil { - return nil, err +func NewLineChart(p *Painter, opt LineChartOption) *lineChart { + if opt.Theme == nil { + opt.Theme = defaultTheme } - seriesNames := opt.SeriesList.Names() + return &lineChart{ + p: p, + opt: &opt, + } +} - r := d.Render - xRange := result.xRange - markPointRenderOptions := make([]markPointRenderOption, 0) - for i, s := range opt.SeriesList { - // 由于series是for range,为同一个数据,因此需要clone - // 后续需要使用,如mark point - series := s - index := series.index - if index == 0 { - index = i +type LineChartOption 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 + // 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) - - yRange := result.getYRange(series.YAxisIndex) - points := make([]Point, 0, len(series.Data)) - // mark line - markLineRender(markLineRenderOption{ - Draw: d, - FillColor: seriesColor, - FontColor: theme.GetTextColor(), + } else { + xValues = xDivideValues + } + markPointPainter := NewMarkPointPainter(seriesPainter) + markLinePainter := NewMarkLinePainter(seriesPainter) + rendererList := []Renderer{ + markPointPainter, + markLinePainter, + } + for index := range seriesList { + series := seriesList[index] + seriesColor := opt.Theme.GetSeriesColor(series.index) + drawingStyle := Style{ StrokeColor: seriesColor, - Font: opt.Font, - 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) + StrokeWidth: defaultStrokeWidth, } - dotFillColor := drawing.ColorWhite - if theme.IsDark() { - dotFillColor = seriesColor + seriesPainter.SetDrawingStyle(drawingStyle) + yRange := result.axisRanges[series.AxisIndex] + 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, - StrokeWidth: 2, - DotColor: seriesColor, - DotWidth: defaultDotWidth, - DotFillColor: dotFillColor, - }) - // draw mark point - markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{ - Draw: d, + // 画线 + seriesPainter.LineStroke(points) + + // 画点 + if opt.Theme.IsDark() { + drawingStyle.FillColor = drawingStyle.StrokeColor + } else { + drawingStyle.FillColor = drawing.ColorWhite + } + drawingStyle.StrokeWidth = 1 + seriesPainter.SetDrawingStyle(drawingStyle) + seriesPainter.Dots(points) + markPointPainter.Add(markPointRenderOption{ FillColor: seriesColor, Font: opt.Font, 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) } diff --git a/line_chart_test.go b/line_chart_test.go deleted file mode 100644 index 9f5d9af..0000000 --- a/line_chart_test.go +++ /dev/null @@ -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("\\n56.66206090", string(data)) -} diff --git a/line_test.go b/line_test.go deleted file mode 100644 index e10b806..0000000 --- a/line_test.go +++ /dev/null @@ -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("\\n", 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("\\n", 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("\\n", string(data)) -} diff --git a/mark_line.go b/mark_line.go index 464fe71..bb1b602 100644 --- a/mark_line.go +++ b/mark_line.go @@ -24,8 +24,6 @@ package charts import ( "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) 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 { - Draw *Draw - FillColor drawing.Color - FontColor drawing.Color - StrokeColor drawing.Color + FillColor Color + FontColor Color + StrokeColor Color Font *truetype.Font - Series *Series - Range *Range + Series Series + Range axisRange +} + +func (m *markLinePainter) Render() (Box, error) { + painter := m.p + for _, opt := range m.options { + s := opt.Series + if len(s.MarkLine.Data) == 0 { + continue + } + 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) { - d := opt.Draw - s := opt.Series - if len(s.MarkLine.Data) == 0 { - return - } - 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) - } + // d := opt.Draw + // s := opt.Series + // if len(s.MarkLine.Data) == 0 { + // return + // } + // r := d.Render } diff --git a/mark_line_test.go b/mark_line_test.go deleted file mode 100644 index abb3308..0000000 --- a/mark_line_test.go +++ /dev/null @@ -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("\\n915", string(data)) -} diff --git a/mark_point.go b/mark_point.go index 5fd34c4..3d43a73 100644 --- a/mark_point.go +++ b/mark_point.go @@ -24,8 +24,6 @@ package charts import ( "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) 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 { - Draw *Draw - FillColor drawing.Color + FillColor Color Font *truetype.Font - Series *Series + Series Series Points []Point } -func markPointRender(opt markPointRenderOption) { - d := opt.Draw - s := opt.Series - if len(s.MarkPoint.Data) == 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 NewMarkPointPainter(p *Painter) *markPointPainter { + return &markPointPainter{ + p: p, + options: make([]markPointRenderOption, 0), } } + +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 +} diff --git a/mark_point_test.go b/mark_point_test.go deleted file mode 100644 index 2cd8fdd..0000000 --- a/mark_point_test.go +++ /dev/null @@ -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("\\n51", string(data)) -} diff --git a/painter.go b/painter.go new file mode 100644 index 0000000..0bacd3c --- /dev/null +++ b/painter.go @@ -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 +} diff --git a/draw_test.go b/painter_test.go similarity index 66% rename from draw_test.go rename to painter_test.go index f6a3dd1..c847aff 100644 --- a/draw_test.go +++ b/painter_test.go @@ -26,217 +26,84 @@ import ( "math" "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 TestParentOption(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) { +func TestPainterOption(t *testing.T) { assert := assert.New(t) - // no parent - width := 300 - height := 200 - d, err := NewDraw(DrawOption{ - Width: width, - Height: height, - }) - assert.Nil(err) - assert.Equal(chart.Box{ - Top: 0, - 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{ + font := &truetype.Font{} + d, err := NewPainter(PainterOptions{ + Width: 800, + Height: 600, + }, + PainterBoxOption(Box{ + Right: 400, + Bottom: 300, + }), + PainterPaddingOption(Box{ Left: 1, Top: 2, Right: 3, Bottom: 4, }), + PainterFontOption(font), + PainterStyleOption(Style{ + ClassName: "test", + }), ) assert.Nil(err) - assert.Equal(chart.Box{ - Top: 4, - Left: 2, - Right: 394, - Bottom: 292, - }, d.Box) + assert.Equal(Box{ + Left: 1, + Top: 2, + Right: 397, + Bottom: 296, + }, d.box) + assert.Equal(font, d.font) + assert.Equal("test", d.style.ClassName) } -func TestParentTop(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) { +func TestPainter(t *testing.T) { assert := assert.New(t) tests := []struct { - fn func(d *Draw) + fn func(*Painter) result string }{ // moveTo, lineTo { - fn: func(d *Draw) { - d.moveTo(1, 1) - d.lineTo(2, 2) - d.Render.Stroke() + fn: func(p *Painter) { + p.MoveTo(1, 1) + p.LineTo(2, 2) + p.Stroke() }, result: "\\n", }, // circle { - fn: func(d *Draw) { - d.circle(5, 2, 3) + fn: func(p *Painter) { + p.Circle(5, 2, 3) }, result: "\\n", }, // text { - fn: func(d *Draw) { - d.text("hello world!", 3, 6) + fn: func(p *Painter) { + p.Text("hello world!", 3, 6) }, - result: "\\nhello world!", + result: "\\nhello world!", }, // line stroke { - fn: func(d *Draw) { - d.lineStroke([]Point{ + fn: func(p *Painter) { + p.SetDrawingStyle(Style{ + StrokeColor: drawing.ColorBlack, + StrokeWidth: 1, + }) + p.LineStroke([]Point{ { X: 1, Y: 2, @@ -245,38 +112,35 @@ func TestDraw(t *testing.T) { X: 3, Y: 4, }, - }, LineStyle{ - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, }) }, result: "\\n", }, // set background { - fn: func(d *Draw) { - d.setBackground(400, 300, chart.ColorWhite) + fn: func(p *Painter) { + p.SetBackground(400, 300, chart.ColorWhite) }, result: "\\n", }, // arcTo { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, StrokeColor: drawing.ColorBlack, FillColor: drawing.ColorBlue, - }.WriteToRenderer(d.Render) - d.arcTo(100, 100, 100, 100, 0, math.Pi/2) - d.Render.Close() - d.Render.FillStroke() + }) + p.ArcTo(100, 100, 100, 100, 0, math.Pi/2) + p.Close() + p.FillStroke() }, result: "\\n", }, // pin { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, StrokeColor: drawing.Color{ R: 84, @@ -290,15 +154,15 @@ func TestDraw(t *testing.T) { B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.pin(30, 30, 30) + }) + p.Pin(30, 30, 30) }, result: "\\n", }, // arrow left { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, StrokeColor: drawing.Color{ R: 84, @@ -312,15 +176,15 @@ func TestDraw(t *testing.T) { B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.arrowLeft(30, 30, 16, 10) + }) + p.ArrowLeft(30, 30, 16, 10) }, result: "\\n", }, // arrow right { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, StrokeColor: drawing.Color{ R: 84, @@ -334,15 +198,15 @@ func TestDraw(t *testing.T) { B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.arrowRight(30, 30, 16, 10) + }) + p.ArrowRight(30, 30, 16, 10) }, result: "\\n", }, // arrow top { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, StrokeColor: drawing.Color{ R: 84, @@ -356,15 +220,15 @@ func TestDraw(t *testing.T) { B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.arrowTop(30, 30, 10, 16) + }) + p.ArrowTop(30, 30, 10, 16) }, result: "\\n", }, // arrow bottom { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, StrokeColor: drawing.Color{ R: 84, @@ -378,15 +242,15 @@ func TestDraw(t *testing.T) { B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.arrowBottom(30, 30, 10, 16) + }) + p.ArrowBottom(30, 30, 10, 16) }, result: "\\n", }, // mark line { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, StrokeColor: drawing.Color{ R: 84, @@ -404,15 +268,15 @@ func TestDraw(t *testing.T) { 4, 2, }, - }.WriteToRenderer(d.Render) - d.makeLine(0, 20, 300) + }) + p.MarkLine(0, 20, 300) }, - result: "\\n", + result: "\\n", }, // polygon { - fn: func(d *Draw) { - chart.Style{ + fn: func(p *Painter) { + p.SetStyle(Style{ StrokeWidth: 1, StrokeColor: drawing.Color{ R: 84, @@ -420,18 +284,26 @@ func TestDraw(t *testing.T) { B: 198, A: 255, }, - }.WriteToRenderer(d.Render) - d.polygon(Point{ + }) + p.Polygon(Point{ X: 100, Y: 100, }, 50, 6) }, result: "\\n", }, - // fill + // FillArea { - fn: func(d *Draw) { - d.fill([]Point{ + fn: func(p *Painter) { + p.SetDrawingStyle(Style{ + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.FillArea([]Point{ { X: 0, Y: 0, @@ -448,23 +320,16 @@ func TestDraw(t *testing.T) { X: 0, Y: 0, }, - }, chart.Style{ - FillColor: drawing.Color{ - R: 84, - G: 112, - B: 198, - A: 255, - }, }) }, result: "\\n", }, } for _, tt := range tests { - d, err := NewDraw(DrawOption{ + d, err := NewPainter(PainterOptions{ Width: 400, Height: 300, - }, PaddingOption(chart.Box{ + }, PainterPaddingOption(chart.Box{ Left: 5, 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) - d, err := NewDraw(DrawOption{ + p, err := NewPainter(PainterOptions{ Width: 400, Height: 300, }) assert.Nil(err) f, _ := chart.GetDefaultFont() - style := chart.Style{ + style := Style{ FontSize: 12, FontColor: chart.ColorBlack, 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{ Right: 45, Bottom: 35, }, box) - box = d.textFit("Hello World!", 0, 100, 200, style) + box = p.TextFit("Hello World!", 0, 100, 200) assert.Equal(chart.Box{ Right: 84, Bottom: 15, }, box) - buf, err := d.Bytes() + buf, err := p.Bytes() assert.Nil(err) assert.Equal(`\nHelloWorld!Hello World!`, string(buf)) } diff --git a/pie_chart.go b/pie_chart.go index 15c0d35..972b4c1 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -30,35 +30,43 @@ import ( "github.com/wcharczuk/go-chart/v2" ) -func getPieStyle(theme *Theme, index int) chart.Style { - seriesColor := theme.GetSeriesColor(index) - return chart.Style{ - StrokeColor: seriesColor, - StrokeWidth: 1, - FillColor: seriesColor, - } +type pieChart struct { + p *Painter + opt *PieChartOption } -type pieChartOption struct { - Theme string - Font *truetype.Font +type PieChartOption 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 + // background is filled + backgroundIsFilled bool } -func pieChartRender(opt pieChartOption, result *basicRenderResult) error { - d, err := NewDraw(DrawOption{ - Parent: result.d, - }, PaddingOption(chart.Box{ - Top: result.titleBox.Height(), - })) - if err != nil { - return err +func NewPieChart(p *Painter, opt PieChartOption) *pieChart { + if opt.Theme == nil { + opt.Theme = defaultTheme } + 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) radiusValue := "" - for index, series := range opt.SeriesList { + for index, series := range seriesList { if len(series.Radius) != 0 { radiusValue = series.Radius } @@ -70,16 +78,13 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error { total += value } 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 - theme := NewTheme(opt.Theme) + seriesPainter := result.seriesPainter + cx := seriesPainter.Width() >> 1 + cy := seriesPainter.Height() >> 1 - box := d.Box - cx := box.Width() >> 1 - cy := box.Height() >> 1 - - diameter := chart.MinInt(box.Width(), box.Height()) + diameter := chart.MinInt(seriesPainter.Width(), seriesPainter.Height()) radius := getRadius(float64(diameter), radiusValue) labelLineWidth := 15 @@ -87,32 +92,40 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error { labelLineWidth = 10 } labelRadius := radius + float64(labelLineWidth) - - seriesNames := opt.SeriesList.Names() - + seriesNames := opt.Legend.Data + if len(seriesNames) == 0 { + seriesNames = seriesList.Names() + } + theme := opt.Theme if len(values) == 1 { - getPieStyle(theme, 0).WriteToRenderer(r) - d.moveTo(cx, cy) - d.circle(radius, cx, cy) + seriesPainter.OverrideDrawingStyle(Style{ + StrokeWidth: 1, + StrokeColor: theme.GetSeriesColor(0), + FillColor: theme.GetSeriesColor(0), + }) + seriesPainter.MoveTo(cx, cy). + Circle(radius, cx, cy) } else { currentValue := float64(0) prevEndX := 0 prevEndY := 0 for index, v := range values { - - pieStyle := getPieStyle(theme, index) - pieStyle.WriteToRenderer(r) - d.moveTo(cx, cy) + seriesPainter.OverrideDrawingStyle(Style{ + StrokeWidth: 1, + StrokeColor: theme.GetSeriesColor(index), + FillColor: theme.GetSeriesColor(index), + }) + seriesPainter.MoveTo(cx, cy) start := chart.PercentToRadians(currentValue/total) - math.Pi/2 currentValue += v percent := (v / total) delta := chart.PercentToRadians(percent) - d.arcTo(cx, cy, radius, radius, start, delta) - d.lineTo(cx, cy) - r.Close() - r.FillStroke() + seriesPainter.ArcTo(cx, cy, radius, radius, start, delta). + LineTo(cx, cy). + Close(). + FillStroke() - series := opt.SeriesList[index] + series := seriesList[index] // 是否显示label showLabel := series.Label.Show if !showLabel { @@ -134,17 +147,19 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error { } prevEndX = endx prevEndY = endy - d.moveTo(startx, starty) - d.lineTo(endx, endy) + + seriesPainter.MoveTo(startx, starty) + seriesPainter.LineTo(endx, endy) offset := labelLineWidth if endx < cx { offset *= -1 } - d.moveTo(endx, endy) + seriesPainter.MoveTo(endx, endy) endx += offset - d.lineTo(endx, endy) - r.Stroke() - textStyle := chart.Style{ + seriesPainter.LineTo(endx, endy) + seriesPainter.Stroke() + + textStyle := Style{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, @@ -152,9 +167,9 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error { if !series.Label.Color.IsZero() { textStyle.FontColor = series.Label.Color } - textStyle.GetTextOptions().WriteToRenderer(r) + seriesPainter.OverrideTextStyle(textStyle) text := NewPieLabelFormatter(seriesNames, series.Label.Formatter)(index, v, percent) - textBox := r.MeasureText(text) + textBox := seriesPainter.MeasureText(text) textMargin := 3 x := endx + textMargin y := endy + textBox.Height()>>1 - 1 @@ -162,8 +177,35 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error { textWidth := textBox.Width() 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) } diff --git a/pie_chart_test.go b/pie_chart_test.go deleted file mode 100644 index 84072be..0000000 --- a/pie_chart_test.go +++ /dev/null @@ -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("\\na: 33.33%b: 66.66%c: 0%", string(data)) -} diff --git a/radar_chart.go b/radar_chart.go index 364213d..610d5f7 100644 --- a/radar_chart.go +++ b/radar_chart.go @@ -30,6 +30,11 @@ import ( "github.com/wcharczuk/go-chart/v2/drawing" ) +type radarChart struct { + p *Painter + opt *RadarChartOption +} + type RadarIndicator struct { // Indicator's name Name string @@ -39,89 +44,101 @@ type RadarIndicator struct { Min float64 } -type radarChartOption struct { - Theme string - Font *truetype.Font +type RadarChartOption struct { + Theme ColorPalette + // The font size + Font *truetype.Font + // The data series list 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 { - sides := len(opt.Indicators) - if sides < 3 { - return errors.New("The count of indicator should be >= 3") +func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart { + if opt.Theme == nil { + opt.Theme = defaultTheme } - maxValues := make([]float64, len(opt.Indicators)) - for _, series := range opt.SeriesList { + return &radarChart{ + p: p, + opt: &opt, + } +} + +func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { + opt := r.opt + indicators := opt.RadarIndicators + sides := len(indicators) + if sides < 3 { + return BoxZero, errors.New("The count of indicator should be >= 3") + } + maxValues := make([]float64, len(indicators)) + for _, series := range seriesList { for index, item := range series.Data { if index < len(maxValues) && item.Value > maxValues[index] { maxValues[index] = item.Value } } } - for index, indicator := range opt.Indicators { + for index, indicator := range indicators { 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 := "" - for _, series := range opt.SeriesList { + for _, series := range seriesList { if len(series.Radius) != 0 { radiusValue = series.Radius } } - box := d.Box - cx := box.Width() >> 1 - cy := box.Height() >> 1 - diameter := chart.MinInt(box.Width(), box.Height()) - radius := getRadius(float64(diameter), radiusValue) + seriesPainter := result.seriesPainter + theme := opt.Theme - 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 divideRadius := float64(int(radius / float64(divideCount))) radius = divideRadius * float64(divideCount) - style := chart.Style{ + seriesPainter.OverrideDrawingStyle(Style{ StrokeColor: theme.GetAxisSplitLineColor(), StrokeWidth: 1, - } - r := d.Render - style.WriteToRenderer(r) + }) center := Point{ X: cx, Y: cy, } 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) for _, p := range points { - d.moveTo(center.X, center.Y) - d.lineTo(p.X, p.Y) - d.Render.Stroke() + seriesPainter.MoveTo(center.X, center.Y) + seriesPainter.LineTo(p.X, p.Y) + seriesPainter.Stroke() } - // 文本 - textStyle := chart.Style{ + seriesPainter.OverrideTextStyle(Style{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, - } - textStyle.GetTextOptions().WriteToRenderer(r) + }) offset := 5 // 文本生成 for index, p := range points { - name := opt.Indicators[index].Name - b := r.MeasureText(name) + name := indicators[index].Name + b := seriesPainter.MeasureText(name) isXCenter := p.X == center.X isYCenter := p.Y == center.Y isRight := p.X > center.X @@ -153,19 +170,19 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error { if isLeft { x -= (b.Width() + offset) } - d.text(name, x, y) + seriesPainter.Text(name, x, y) } // 雷达图 angles := getPolygonPointAngles(sides) - maxCount := len(opt.Indicators) - for _, series := range opt.SeriesList { + maxCount := len(indicators) + for _, series := range seriesList { linePoints := make([]Point, 0, maxCount) for j, item := range series.Data { if j >= maxCount { continue } - indicator := opt.Indicators[j] + indicator := indicators[j] percent := (item.Value - indicator.Min) / (indicator.Max - indicator.Min) r := percent * radius p := getPolygonPoint(center, r, angles[j]) @@ -177,17 +194,52 @@ func radarChartRender(opt radarChartOption, result *basicRenderResult) error { dotFillColor = color } linePoints = append(linePoints, linePoints[0]) - s := LineStyle{ - StrokeColor: color, - StrokeWidth: defaultStrokeWidth, - DotWidth: defaultDotWidth, - DotColor: color, - DotFillColor: dotFillColor, - FillColor: color.WithAlpha(20), + seriesPainter.OverrideDrawingStyle(Style{ + StrokeColor: color, + StrokeWidth: defaultStrokeWidth, + DotWidth: defaultDotWidth, + DotColor: color, + FillColor: color.WithAlpha(20), + }) + seriesPainter.LineStroke(linePoints). + FillArea(linePoints) + dotWith := 2.0 + seriesPainter.OverrideDrawingStyle(Style{ + StrokeWidth: defaultStrokeWidth, + StrokeColor: color, + FillColor: dotFillColor, + }) + for _, 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) } diff --git a/radar_chart_test.go b/radar_chart_test.go deleted file mode 100644 index c5d2aa9..0000000 --- a/radar_chart_test.go +++ /dev/null @@ -1,102 +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 TestRadarChartRender(t *testing.T) { - assert := assert.New(t) - - d, err := NewDraw(DrawOption{ - Width: 250, - Height: 150, - }) - assert.Nil(err) - - f, _ := chart.GetDefaultFont() - err = radarChartRender(radarChartOption{ - Font: f, - Indicators: []RadarIndicator{ - { - Name: "Sales", - Max: 6500, - }, - { - Name: "Administration", - Max: 16000, - }, - { - Name: "Information Technology", - Max: 30000, - }, - { - Name: "Customer Support", - Max: 38000, - }, - { - Name: "Development", - Max: 52000, - }, - { - Name: "Marketing", - Max: 25000, - }, - }, - SeriesList: SeriesList{ - { - Type: ChartTypeRadar, - Data: NewSeriesDataFromValues([]float64{ - 4200, - 3000, - 20000, - 35000, - 50000, - 18000, - }), - }, - { - Type: ChartTypeRadar, - index: 1, - Data: NewSeriesDataFromValues([]float64{ - 5000, - 14000, - 28000, - 26000, - 42000, - 21000, - }), - }, - }, - }, &basicRenderResult{ - d: d, - }) - assert.Nil(err) - data, err := d.Bytes() - assert.Nil(err) - assert.Equal("\\nSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", string(data)) -} diff --git a/range.go b/range.go index 255a51b..399c449 100644 --- a/range.go +++ b/range.go @@ -26,15 +26,31 @@ import ( "math" ) -type Range struct { +const defaultAxisDivideCount = 6 + +type axisRange struct { divideCount int + min float64 + max float64 + size int + boundary bool +} + +type AxisRangeOption struct { Min float64 Max float64 Size int 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) // 最小单位计算 @@ -63,47 +79,49 @@ func NewRange(min, max float64, divideCount int) Range { } } max = min + float64(unit*divideCount) - return Range{ - Min: min, - Max: max, + return axisRange{ divideCount: divideCount, + min: min, + max: max, + size: opt.Size, + boundary: opt.Boundary, } } -func (r Range) Values() []string { - offset := (r.Max - r.Min) / float64(r.divideCount) +func (r axisRange) Values() []string { + offset := (r.max - r.min) / float64(r.divideCount) values := make([]string, 0) for i := 0; i <= r.divideCount; i++ { - v := r.Min + float64(i)*offset + v := r.min + float64(i)*offset value := commafWithDigits(v) values = append(values, value) } return values } -func (r *Range) getHeight(value float64) int { - v := (value - r.Min) / (r.Max - r.Min) - return int(v * float64(r.Size)) +func (r *axisRange) getHeight(value float64) int { + v := (value - r.min) / (r.max - r.min) + return int(v * float64(r.size)) } -func (r *Range) getRestHeight(value float64) int { - return r.Size - r.getHeight(value) +func (r *axisRange) getRestHeight(value float64) int { + return r.size - r.getHeight(value) } -func (r *Range) GetRange(index int) (float64, float64) { - unit := float64(r.Size) / float64(r.divideCount) +func (r *axisRange) GetRange(index int) (float64, float64) { + unit := float64(r.size) / float64(r.divideCount) return unit * float64(index), unit * float64(index+1) } -func (r *Range) AutoDivide() []int { - return autoDivide(r.Size, r.divideCount) +func (r *axisRange) AutoDivide() []int { + return autoDivide(r.size, r.divideCount) } -func (r *Range) getWidth(value float64) int { - v := value / (r.Max - r.Min) +func (r *axisRange) getWidth(value float64) int { + v := value / (r.max - r.min) // 移至居中 - if r.Boundary && + if r.boundary && r.divideCount != 0 { v += 1 / float64(r.divideCount*2) } - return int(v * float64(r.Size)) + return int(v * float64(r.size)) } diff --git a/range_test.go b/range_test.go deleted file mode 100644 index d1aea8f..0000000 --- a/range_test.go +++ /dev/null @@ -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()) -} diff --git a/series.go b/series.go index 14227d1..905c140 100644 --- a/series.go +++ b/series.go @@ -19,7 +19,6 @@ // 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 ( @@ -28,14 +27,21 @@ import ( "github.com/dustin/go-humanize" "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" ) type SeriesData struct { // The value of series data Value float64 // 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 { @@ -65,7 +71,7 @@ type SeriesLabel struct { // {d}: the percent of a data item(pie chart). Formatter string // The color for label - Color drawing.Color + Color Color // Show flag for label Show bool // Distance to the host graphic element. @@ -101,8 +107,8 @@ type Series struct { // The data list of series Data []SeriesData // The Y axis index, it should be 0 or 1. - // Default value is 1 - YAxisIndex int + // Default value is 0 + AxisIndex int // The style for series Style chart.Style // The label for series @@ -122,6 +128,54 @@ type Series struct { } 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 { Radius string Label SeriesLabel diff --git a/series_test.go b/series_test.go deleted file mode 100644 index 1460180..0000000 --- a/series_test.go +++ /dev/null @@ -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)) -} diff --git a/table.go b/table.go deleted file mode 100644 index 9cfc6b1..0000000 --- a/table.go +++ /dev/null @@ -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 -} diff --git a/theme.go b/theme.go index e3f9773..26786b9 100644 --- a/theme.go +++ b/theme.go @@ -23,6 +23,8 @@ package charts import ( + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2/drawing" ) @@ -31,23 +33,45 @@ const ThemeLight = "light" const ThemeGrafana = "grafana" const ThemeAnt = "ant" -type Theme struct { - palette *themeColorPalette +type ColorPalette interface { + IsDark() bool + GetAxisStrokeColor() Color + GetAxisSplitLineColor() Color + GetSeriesColor(int) Color + GetBackgroundColor() Color + GetTextColor() Color + GetFontSize() float64 + GetFont() *truetype.Font } type themeColorPalette struct { isDarkMode bool - axisStrokeColor drawing.Color - axisSplitLineColor drawing.Color - backgroundColor drawing.Color - textColor drawing.Color - seriesColors []drawing.Color + axisStrokeColor Color + axisSplitLineColor Color + backgroundColor Color + textColor 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() { - echartSeriesColors := []drawing.Color{ + echartSeriesColors := []Color{ parseColor("#5470c6"), parseColor("#91cc75"), parseColor("#fac858"), @@ -58,7 +82,7 @@ func init() { parseColor("#9a60b4"), parseColor("#ea7ccc"), } - grafanaSeriesColors := []drawing.Color{ + grafanaSeriesColors := []Color{ parseColor("#7EB26D"), parseColor("#EAB839"), parseColor("#6ED0E0"), @@ -68,7 +92,7 @@ func init() { parseColor("#705DA0"), parseColor("#508642"), } - antSeriesColors := []drawing.Color{ + antSeriesColors := []Color{ parseColor("#5b8ff9"), parseColor("#5ad8a6"), parseColor("#5d7092"), @@ -80,155 +104,181 @@ func init() { } AddTheme( ThemeDark, - true, - drawing.Color{ - R: 185, - G: 184, - B: 206, - A: 255, + ThemeOption{ + IsDarkMode: true, + AxisStrokeColor: Color{ + R: 185, + G: 184, + B: 206, + A: 255, + }, + AxisSplitLineColor: Color{ + R: 72, + G: 71, + B: 83, + A: 255, + }, + BackgroundColor: Color{ + R: 16, + G: 12, + B: 42, + A: 255, + }, + TextColor: Color{ + R: 238, + G: 238, + B: 238, + A: 255, + }, + SeriesColors: echartSeriesColors, }, - 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( ThemeLight, - false, - drawing.Color{ - R: 110, - G: 112, - B: 121, - A: 255, + ThemeOption{ + IsDarkMode: false, + AxisStrokeColor: Color{ + R: 110, + G: 112, + B: 121, + A: 255, + }, + AxisSplitLineColor: Color{ + R: 224, + G: 230, + B: 242, + A: 255, + }, + BackgroundColor: drawing.ColorWhite, + TextColor: Color{ + R: 70, + G: 70, + B: 70, + A: 255, + }, + SeriesColors: echartSeriesColors, }, - drawing.Color{ - R: 224, - G: 230, - B: 242, - A: 255, - }, - drawing.ColorWhite, - drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 255, - }, - echartSeriesColors, ) AddTheme( ThemeAnt, - false, - drawing.Color{ - R: 110, - G: 112, - B: 121, - A: 255, + ThemeOption{ + IsDarkMode: false, + AxisStrokeColor: Color{ + R: 110, + G: 112, + B: 121, + A: 255, + }, + AxisSplitLineColor: Color{ + R: 224, + G: 230, + B: 242, + A: 255, + }, + BackgroundColor: drawing.ColorWhite, + TextColor: drawing.Color{ + R: 70, + G: 70, + B: 70, + A: 255, + }, + SeriesColors: antSeriesColors, }, - drawing.Color{ - R: 224, - G: 230, - B: 242, - A: 255, - }, - drawing.ColorWhite, - drawing.Color{ - R: 70, - G: 70, - B: 70, - A: 255, - }, - antSeriesColors, ) AddTheme( ThemeGrafana, - true, - drawing.Color{ - R: 185, - G: 184, - B: 206, - A: 255, + ThemeOption{ + IsDarkMode: true, + AxisStrokeColor: Color{ + R: 185, + G: 184, + B: 206, + A: 255, + }, + AxisSplitLineColor: Color{ + R: 68, + G: 67, + B: 67, + A: 255, + }, + BackgroundColor: drawing.Color{ + R: 31, + G: 29, + B: 29, + A: 255, + }, + TextColor: Color{ + R: 216, + G: 217, + B: 218, + A: 255, + }, + SeriesColors: grafanaSeriesColors, }, - 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{ - isDarkMode: isDarkMode, - axisStrokeColor: axisStrokeColor, - axisSplitLineColor: axisSplitLineColor, - backgroundColor: backgroundColor, - textColor: textColor, - seriesColors: seriesColors, + isDarkMode: opt.IsDarkMode, + axisStrokeColor: opt.AxisStrokeColor, + axisSplitLineColor: opt.AxisSplitLineColor, + backgroundColor: opt.BackgroundColor, + textColor: opt.TextColor, + seriesColors: opt.SeriesColors, } } -func NewTheme(name string) *Theme { +func NewTheme(name string) ColorPalette { p, ok := palettes[name] if !ok { p = palettes[ThemeLight] } - return &Theme{ - palette: p, - } + return p } -func (t *Theme) IsDark() bool { - return t.palette.isDarkMode +func (t *themeColorPalette) IsDark() bool { + return t.isDarkMode } -func (t *Theme) GetAxisStrokeColor() drawing.Color { - return t.palette.axisStrokeColor +func (t *themeColorPalette) GetAxisStrokeColor() Color { + return t.axisStrokeColor } -func (t *Theme) GetAxisSplitLineColor() drawing.Color { - return t.palette.axisSplitLineColor +func (t *themeColorPalette) GetAxisSplitLineColor() Color { + return t.axisSplitLineColor } -func (t *Theme) GetSeriesColor(index int) drawing.Color { - colors := t.palette.seriesColors +func (t *themeColorPalette) GetSeriesColor(index int) Color { + colors := t.seriesColors return colors[index%len(colors)] } -func (t *Theme) GetBackgroundColor() drawing.Color { - return t.palette.backgroundColor +func (t *themeColorPalette) GetBackgroundColor() Color { + return t.backgroundColor } -func (t *Theme) GetTextColor() drawing.Color { - return t.palette.textColor +func (t *themeColorPalette) GetTextColor() Color { + 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 } diff --git a/theme_test.go b/theme_test.go deleted file mode 100644 index bf22afd..0000000 --- a/theme_test.go +++ /dev/null @@ -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()) -} diff --git a/title.go b/title.go index 07a2eef..a805c55 100644 --- a/title.go +++ b/title.go @@ -26,18 +26,20 @@ import ( "strconv" "strings" - "github.com/wcharczuk/go-chart/v2" + "github.com/golang/freetype/truetype" ) type TitleOption struct { + // The theme of chart + Theme ColorPalette // Title text, support \n for new line Text string // Subtitle text, support \n for new line Subtext string - // Title style - Style chart.Style - // Subtitle style - SubtextStyle chart.Style + // // Title style + // Style Style + // // Subtitle style + // SubtextStyle Style // Distance between title component and the left side of the container. // It can be pixel value: 20, percentage value: 20%, // or position value: right, center. @@ -45,12 +47,23 @@ type TitleOption struct { // Distance between title component and the top side of the container. // It can be pixel value: 20. Top string + // The font of label + Font *truetype.Font + // The font size of label + FontSize float64 + // The color of label + FontColor Color + // The subtext font size of label + SubtextFontSize float64 + // The subtext font color of label + SubtextFontColor Color } + type titleMeasureOption struct { width int height int text string - style chart.Style + style Style } func splitTitleText(text string) []string { @@ -66,44 +79,74 @@ func splitTitleText(text string) []string { return result } -func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) { - if len(opt.Text) == 0 { - return chart.BoxZero, nil - } +type titlePainter struct { + p *Painter + opt *TitleOption +} - padding := opt.Style.Padding - d, err := NewDraw(DrawOption{ - Parent: p, - }, PaddingOption(padding)) - if err != nil { - return chart.BoxZero, err +func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter { + return &titlePainter{ + p: p, + opt: &opt, } +} - 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) + if opt.Font == nil { + opt.Font = theme.GetFont() + } + if opt.FontColor.IsZero() { + opt.FontColor = theme.GetTextColor() + } + if opt.FontSize == 0 { + opt.FontSize = theme.GetFontSize() + } + if opt.SubtextFontColor.IsZero() { + opt.SubtextFontColor = opt.FontColor + } + if opt.SubtextFontSize == 0 { + opt.SubtextFontSize = opt.FontSize + } + + titleTextStyle := Style{ + Font: opt.Font, + FontSize: opt.FontSize, + FontColor: opt.FontColor, + } // 主标题 for _, v := range splitTitleText(opt.Text) { measureOptions = append(measureOptions, titleMeasureOption{ text: v, - style: opt.Style.GetTextOptions(), + style: titleTextStyle, }) } + subtextStyle := Style{ + Font: opt.Font, + FontSize: opt.SubtextFontSize, + FontColor: opt.SubtextFontColor, + } // 副标题 for _, v := range splitTitleText(opt.Subtext) { measureOptions = append(measureOptions, titleMeasureOption{ text: v, - style: opt.SubtextStyle.GetTextOptions(), + style: subtextStyle, }) } - textMaxWidth := 0 textMaxHeight := 0 - width := 0 for index, item := range measureOptions { - item.style.WriteTextOptionsToRenderer(r) - textBox := r.MeasureText(item.text) + p.OverrideTextStyle(item.style) + textBox := p.MeasureText(item.text) w := textBox.Width() h := textBox.Height() @@ -116,18 +159,18 @@ func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) { measureOptions[index].height = h measureOptions[index].width = w } - width = textMaxWidth + width := textMaxWidth + titleX := 0 - b := d.Box switch opt.Left { case PositionRight: - titleX = b.Width() - textMaxWidth + titleX = p.Width() - textMaxWidth case PositionCenter: - titleX = b.Width()>>1 - (textMaxWidth >> 1) + titleX = p.Width()>>1 - (textMaxWidth >> 1) default: if strings.HasSuffix(opt.Left, "%") { value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) - titleX = b.Width() * value / 100 + titleX = p.Width() * value / 100 } else { value, _ := strconv.Atoi(opt.Left) titleX = value @@ -140,16 +183,15 @@ func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) { titleY += value } for _, item := range measureOptions { - item.style.WriteTextOptionsToRenderer(r) + p.OverrideTextStyle(item.style) x := titleX + (textMaxWidth-item.width)>>1 y := titleY + item.height - d.text(item.text, x, y) + p.Text(item.text, x, y) 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 } diff --git a/title_test.go b/title_test.go deleted file mode 100644 index 23573c3..0000000 --- a/title_test.go +++ /dev/null @@ -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: "\\ntitleHellosubtitleWorld!", - box: chart.Box{ - Right: 43, - Bottom: 58, - }, - }, - { - newDraw: newDraw, - newOption: func() *TitleOption { - opt := newOption() - opt.Left = PositionRight - opt.Top = "50" - return opt - }, - result: "\\ntitleHellosubtitleWorld!", - box: chart.Box{ - Right: 400, - Bottom: 108, - }, - }, - { - newDraw: newDraw, - newOption: func() *TitleOption { - opt := newOption() - opt.Left = PositionCenter - opt.Top = "10" - return opt - }, - result: "\\ntitleHellosubtitleWorld!", - box: chart.Box{ - Right: 222, - Bottom: 68, - }, - }, - { - newDraw: newDraw, - newOption: func() *TitleOption { - opt := newOption() - opt.Left = "10%" - opt.Top = "10" - return opt - }, - result: "\\ntitleHellosubtitleWorld!", - 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)) - } -} diff --git a/util.go b/util.go index c895cc3..adfa9fd 100644 --- a/util.go +++ b/util.go @@ -43,6 +43,24 @@ func FalseFlag() *bool { return &f } +func containsInt(values []int, value int) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + +func containsString(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} + func ceilFloatToInt(value float64) int { i := int(value) if value == float64(i) { @@ -59,28 +77,25 @@ func getDefaultInt(value, defaultValue 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) - value := 0 - for i := 0; i < size; i++ { - values[i] = value - if i < rest { - value++ + for i := 0; i < size+1; i++ { + if i == size { + values[i] = max + } else { + values[i] = int(float64(i) * unit) } - value += unit } - values[size] = max return values } // 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 maxHeight := 0 for _, text := range textList { - box := r.MeasureText(text) + box := p.MeasureText(text) maxWidth = chart.MaxInt(maxWidth, box.Width()) maxHeight = chart.MaxInt(maxHeight, box.Height()) } @@ -134,8 +149,8 @@ func commafWithDigits(value float64) string { return humanize.CommafWithDigits(value, decimals) } -func parseColor(color string) drawing.Color { - c := drawing.Color{} +func parseColor(color string) Color { + c := Color{} if color == "" { return c } diff --git a/util_test.go b/util_test.go index 6489ab3..b25c60d 100644 --- a/util_test.go +++ b/util_test.go @@ -60,12 +60,12 @@ func TestAutoDivide(t *testing.T) { assert.Equal([]int{ 0, - 86, - 172, - 258, - 344, - 430, - 515, + 85, + 171, + 257, + 342, + 428, + 514, 600, }, autoDivide(600, 7)) } @@ -80,13 +80,15 @@ func TestGetRadius(t *testing.T) { func TestMeasureTextMaxWidthHeight(t *testing.T) { assert := assert.New(t) - r, err := chart.SVG(400, 300) + p, err := NewPainter(PainterOptions{ + Width: 400, + Height: 300, + }) assert.Nil(err) style := chart.Style{ FontSize: 10, } - style.Font, _ = chart.GetDefaultFont() - style.WriteToRenderer(r) + p.SetStyle(style) maxWidth, maxHeight := measureTextMaxWidthHeight([]string{ "Mon", @@ -96,7 +98,7 @@ func TestMeasureTextMaxWidthHeight(t *testing.T) { "Fri", "Sat", "Sun", - }, r) + }, p) assert.Equal(26, maxWidth) assert.Equal(12, maxHeight) } diff --git a/xaxis.go b/xaxis.go index edd017f..bfb57cb 100644 --- a/xaxis.go +++ b/xaxis.go @@ -24,10 +24,10 @@ package charts import ( "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" ) type XAxisOption struct { + // The font of x axis Font *truetype.Font // The boundary gap on both sides of a coordinate axis. // Nil or *true means the center part of two axis ticks @@ -35,13 +35,24 @@ type XAxisOption struct { // The data value of x axis Data []string // The theme of chart - Theme string - // Hidden x axis - Hidden bool + Theme ColorPalette + // The font size of x axis label + FontSize float64 + // The flag for show axis, set this to *false will hide axis + Show *bool // Number of segments that the axis is split into. Note that this number serves only as a recommendation. SplitNumber int + // The position of axis, it can be 'top' or 'bottom' + Position string + // The line color of axis + StrokeColor Color + // The color of label + FontColor Color + isValueAxis bool } +const defaultXAxisHeight = 30 + func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption { opt := XAxisOption{ Data: data, @@ -52,51 +63,32 @@ func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption { return opt } -// drawXAxis draws x axis, and returns the height, range of if. -func drawXAxis(p *Draw, opt *XAxisOption, yAxisCount int) (int, *Range, error) { - if opt.Hidden { - return 0, nil, nil +func (opt *XAxisOption) ToAxisOption() AxisOption { + position := PositionBottom + if opt.Position == PositionTop { + position = PositionTop } - left := YAxisWidth - right := (yAxisCount - 1) * YAxisWidth - dXAxis, err := NewDraw( - DrawOption{ - Parent: p, - }, - PaddingOption(chart.Box{ - Left: left, - Right: right, - }), - ) - if opt.Font != nil { - dXAxis.Font = opt.Font + axisOpt := AxisOption{ + Theme: opt.Theme, + Data: opt.Data, + BoundaryGap: opt.BoundaryGap, + Position: position, + SplitNumber: opt.SplitNumber, + StrokeColor: opt.StrokeColor, + FontSize: opt.FontSize, + Font: opt.Font, + FontColor: opt.FontColor, + Show: opt.Show, + SplitLineColor: opt.Theme.GetAxisSplitLineColor(), } - if err != nil { - return 0, nil, err + if opt.isValueAxis { + axisOpt.SplitLineShow = true + axisOpt.StrokeWidth = -1 + axisOpt.BoundaryGap = FalseFlag() } - theme := NewTheme(opt.Theme) - data := NewAxisDataListFromStringList(opt.Data) - style := AxisOption{ - BoundaryGap: opt.BoundaryGap, - StrokeColor: theme.GetAxisStrokeColor(), - 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 + return axisOpt +} + +func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter { + return NewAxisPainter(p, opt.ToAxisOption()) } diff --git a/xaxis_test.go b/xaxis_test.go deleted file mode 100644 index 267cdb1..0000000 --- a/xaxis_test.go +++ /dev/null @@ -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: "\\nMonTue", - }, - { - 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: "\\n01-0201-0501-08", - }, - } - - 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)) - } -} diff --git a/yaxis.go b/yaxis.go index a14e409..265ac59 100644 --- a/yaxis.go +++ b/yaxis.go @@ -22,84 +22,92 @@ package charts -import ( - "strings" - - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) +import "github.com/golang/freetype/truetype" type YAxisOption struct { // The minimun value of axis. Min *float64 // The maximum value of axis. Max *float64 - // Hidden y axis - Hidden bool + // The font of y axis + Font *truetype.Font + // The data value of x axis + Data []string + // The theme of chart + Theme ColorPalette + // The font size of x axis label + FontSize float64 + // The position of axis, it can be 'left' or 'right' + Position string + // The color of label + FontColor Color // Formatter for y axis text value Formatter string // Color for y axis - Color drawing.Color + Color Color + // The flag for show axis, set this to *false will hide axis + Show *bool + isCategoryAxis bool } -// TODO 长度是否可以变化 -const YAxisWidth = 40 - -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) - } +func NewYAxisOptions(data []string, others ...[]string) []YAxisOption { + arr := [][]string{ + data, } + arr = append(arr, others...) + opts := make([]YAxisOption, 0) + for _, data := range arr { + opts = append(opts, YAxisOption{ + Data: data, + }) + } + return opts +} - data := NewAxisDataListFromStringList(values) - style := AxisOption{ - Position: PositionLeft, +func (opt *YAxisOption) ToAxisOption() AxisOption { + 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(), - FontColor: theme.GetAxisStrokeColor(), - TickShow: FalseFlag(), - StrokeWidth: 1, - SplitLineColor: theme.GetAxisSplitLineColor(), SplitLineShow: true, + SplitLineColor: opt.Theme.GetAxisSplitLineColor(), + Show: opt.Show, } - if !yAxis.Color.IsZero() { - style.FontColor = yAxis.Color - style.StrokeColor = yAxis.Color + if !opt.Color.IsZero() { + axisOpt.FontColor = opt.Color + axisOpt.StrokeColor = opt.Color } - width := NewAxis(p, data, style).measure().Width - - yAxisCount := len(opt.YAxisList) - boxWidth := p.Box.Width() - 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) + if opt.isCategoryAxis { + axisOpt.BoundaryGap = TrueFlag() + axisOpt.StrokeWidth = 1 + axisOpt.SplitLineShow = false } - - dYAxis, err := NewDraw( - DrawOption{ - Parent: p, - Width: boxWidth, - // 减去x轴的高 - Height: p.Box.Height() - xAxisHeight, - }, - PaddingOption(padding), - ) - if err != nil { - return nil, err - } - if opt.Font != nil { - dYAxis.Font = opt.Font - } - NewAxis(dYAxis, data, style).Render() - yRange.Size = dYAxis.Box.Height() - return &yRange, nil + return axisOpt +} + +func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter { + p = p.Child(PainterPaddingOption(Box{ + Bottom: defaultXAxisHeight, + })) + return NewAxisPainter(p, opt.ToAxisOption()) +} + +func NewRightYAxis(p *Painter, opt YAxisOption) *axisPainter { + p = p.Child(PainterPaddingOption(Box{ + Bottom: defaultXAxisHeight, + })) + axisOpt := opt.ToAxisOption() + axisOpt.Position = PositionRight + axisOpt.SplitLineShow = false + return NewAxisPainter(p, axisOpt) } diff --git a/yaxis_test.go b/yaxis_test.go deleted file mode 100644 index 0bbef7a..0000000 --- a/yaxis_test.go +++ /dev/null @@ -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: "\\n03.336.661013.3316.6620", - }, - { - 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: "\\n0 C3.33 C6.66 C10 C13.33 C16.66 C20 C", - }, - } - - 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)) - } -}