From 5068828ca703411491661d4218d5055ab8cdc890 Mon Sep 17 00:00:00 2001 From: vicanso Date: Sun, 15 May 2022 15:07:03 +0800 Subject: [PATCH] feat: support painter for chart draw function --- alias.go | 33 +++++ line.go | 9 +- painer.go | 394 +++++++++++++++++++++++++++++++++++++++++++++++++ painer_test.go | 371 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 802 insertions(+), 5 deletions(-) create mode 100644 alias.go create mode 100644 painer.go create mode 100644 painer_test.go diff --git a/alias.go b/alias.go new file mode 100644 index 0000000..3a09919 --- /dev/null +++ b/alias.go @@ -0,0 +1,33 @@ +// 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 Box = chart.Box +type Renderer = chart.Renderer +type Style = chart.Style +type Color = drawing.Color diff --git a/line.go b/line.go index 0fc25d6..15ab575 100644 --- a/line.go +++ b/line.go @@ -24,18 +24,17 @@ 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 + StrokeColor Color StrokeWidth float64 - FillColor drawing.Color + FillColor Color DotWidth float64 - DotColor drawing.Color - DotFillColor drawing.Color + DotColor Color + DotFillColor Color } func (ls *LineStyle) Style() chart.Style { diff --git a/painer.go b/painer.go new file mode 100644 index 0000000..670f6f6 --- /dev/null +++ b/painer.go @@ -0,0 +1,394 @@ +// 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 Renderer + box Box + font *truetype.Font + parent *Painter + style Style + previousStyle Style +} + +type PainterOptions struct { + // Draw type, "svg" or "png", default type is "svg" + Type string + // The width of draw canvas + Width int + // The height of draw canvas + Height int + // The font for painter + Font *truetype.Font +} + +type PainterOption func(*Painter) + +// PainterPaddingOption sets the padding of draw canvas +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 canvas +func PainterBoxOption(box Box) PainterOption { + return func(p *Painter) { + if box.IsZero() { + return + } + p.box = box + } +} + +// PainterFontOption sets the font of draw canvas +func PainterFontOption(font *truetype.Font) PainterOption { + return func(p *Painter) { + p.font = font + } +} + +// PainterStyleOption sets the style of draw canvas +func PainterStyleOption(style Style) PainterOption { + return func(p *Painter) { + p.SetDrawingStyle(style) + } +} + +// 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.SVG + if opts.Type == ChartOutputPNG { + fn = chart.PNG + } + width := opts.Width + height := opts.Height + r, err := fn(width, height) + if err != nil { + return nil, err + } + p := &Painter{ + render: r, + box: Box{ + Right: opts.Width, + Bottom: opts.Height, + }, + font: font, + } + p.setOptions(opt...) + 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, + previousStyle: p.previousStyle, + } + child.setOptions(opt...) + return child +} + +func (p *Painter) SetStyle(style Style) { + p.previousStyle = p.style + p.style = style + style.WriteToRenderer(p.render) +} + +func (p *Painter) SetDrawingStyle(style Style) { + p.previousStyle = p.style + p.style = style + style.WriteDrawingOptionsToRenderer(p.render) +} + +func (p *Painter) SetTextStyle(style Style) { + p.previousStyle = p.style + p.style = style + style.WriteTextOptionsToRenderer(p.render) +} + +func (p *Painter) RestoreStyle() { + p.style = p.previousStyle +} + +// 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) { + p.render.MoveTo(x+p.box.Left, y+p.box.Top) +} + +func (p *Painter) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) { + p.render.ArcTo(cx+p.box.Left, cy+p.box.Top, rx, ry, startAngle, delta) +} + +func (p *Painter) LineTo(x, y int) { + p.render.LineTo(x+p.box.Left, y+p.box.Top) +} + +func (p *Painter) Pin(x, y, width int) { + 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() +} + +func (p *Painter) 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 + } + 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() +} + +func (p *Painter) ArrowLeft(x, y, width, height int) { + p.arrow(x, y, width, height, PositionLeft) +} + +func (p *Painter) ArrowRight(x, y, width, height int) { + p.arrow(x, y, width, height, PositionRight) +} + +func (p *Painter) ArrowTop(x, y, width, height int) { + p.arrow(x, y, width, height, PositionTop) +} +func (p *Painter) ArrowBottom(x, y, width, height int) { + p.arrow(x, y, width, height, PositionBottom) +} + +func (p *Painter) Circle(radius float64, x, y int) { + p.render.Circle(radius, x+p.box.Left, y+p.box.Top) +} + +func (p *Painter) Stroke() { + p.render.Stroke() +} + +func (p *Painter) Close() { + p.render.Close() +} + +func (p *Painter) FillStroke() { + p.render.FillStroke() +} + +func (p *Painter) Fill() { + p.render.Fill() +} + +func (p *Painter) LineStroke(points []Point, style LineStyle) { + s := style.Style() + if !s.ShouldDrawStroke() { + return + } + defer p.RestoreStyle() + p.SetDrawingStyle(s.GetStrokeOptions()) + 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() +} + +func (p *Painter) SetBackground(width, height int, color Color) { + r := p.render + s := chart.Style{ + FillColor: color, + } + defer p.RestoreStyle() + p.SetStyle(s) + // 设置背景色不使用box,因此不直接使用Painter + r.MoveTo(0, 0) + r.LineTo(width, 0) + r.LineTo(width, height) + r.LineTo(0, height) + r.LineTo(0, 0) + p.FillStroke() +} +func (p *Painter) MarkLine(x, y, width int) { + arrowWidth := 16 + arrowHeight := 10 + endX := x + width + p.Circle(3, x, y) + p.render.Fill() + p.MoveTo(x+5, y) + p.LineTo(endX-arrowWidth, y) + p.Stroke() + p.render.SetStrokeDashArray([]float64{}) + p.ArrowRight(endX, y, arrowWidth, arrowHeight) +} + +func (p *Painter) Polygon(center Point, radius float64, sides int) { + 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() +} + +func (p *Painter) FillArea(points []Point, s Style) { + if !s.ShouldDrawFill() { + return + } + defer p.RestoreStyle() + var x, y int + p.SetDrawingStyle(s.GetFillOptions()) + 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() +} + +func (p *Painter) Text(body string, x, y int) { + p.render.Text(body, x+p.box.Left, y+p.box.Top) +} + +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 +} diff --git a/painer_test.go b/painer_test.go new file mode 100644 index 0000000..425dbbe --- /dev/null +++ b/painer_test.go @@ -0,0 +1,371 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "math" + "testing" + + "github.com/golang/freetype/truetype" + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func TestPainterOption(t *testing.T) { + assert := assert.New(t) + + 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(Box{ + Left: 1, + Top: 2, + Right: 397, + Bottom: 296, + }, d.box) + assert.Equal(font, d.font) + assert.Equal("test", d.style.ClassName) +} + +func TestPainter(t *testing.T) { + assert := assert.New(t) + + tests := []struct { + fn func(*Painter) + result string + }{ + // moveTo, lineTo + { + fn: func(p *Painter) { + p.MoveTo(1, 1) + p.LineTo(2, 2) + p.Stroke() + }, + result: "\\n", + }, + // circle + { + fn: func(p *Painter) { + p.Circle(5, 2, 3) + }, + result: "\\n", + }, + // text + { + fn: func(p *Painter) { + p.Text("hello world!", 3, 6) + }, + result: "\\nhello world!", + }, + // line stroke + { + fn: func(p *Painter) { + p.LineStroke([]Point{ + { + X: 1, + Y: 2, + }, + { + X: 3, + Y: 4, + }, + }, LineStyle{ + StrokeColor: drawing.ColorBlack, + StrokeWidth: 1, + }) + }, + result: "\\n", + }, + // set background + { + fn: func(p *Painter) { + p.SetBackground(400, 300, chart.ColorWhite) + }, + result: "\\n", + }, + // arcTo + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlue, + }) + p.ArcTo(100, 100, 100, 100, 0, math.Pi/2) + p.Close() + p.FillStroke() + }, + result: "\\n", + }, + // pin + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.Pin(30, 30, 30) + }, + result: "\\n", + }, + // arrow left + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.ArrowLeft(30, 30, 16, 10) + }, + result: "\\n", + }, + // arrow right + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.ArrowRight(30, 30, 16, 10) + }, + result: "\\n", + }, + // arrow top + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.ArrowTop(30, 30, 10, 16) + }, + result: "\\n", + }, + // arrow bottom + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.ArrowBottom(30, 30, 10, 16) + }, + result: "\\n", + }, + // mark line + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + StrokeDashArray: []float64{ + 4, + 2, + }, + }) + p.MarkLine(0, 20, 300) + }, + result: "\\n", + }, + // polygon + { + fn: func(p *Painter) { + p.SetStyle(Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + p.Polygon(Point{ + X: 100, + Y: 100, + }, 50, 6) + }, + result: "\\n", + }, + // FillArea + { + fn: func(p *Painter) { + p.FillArea([]Point{ + { + X: 0, + Y: 0, + }, + { + X: 0, + Y: 100, + }, + { + X: 100, + Y: 100, + }, + { + X: 0, + Y: 0, + }, + }, Style{ + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }) + }, + result: "\\n", + }, + } + for _, tt := range tests { + d, err := NewPainter(PainterOptions{ + Width: 400, + Height: 300, + }, PainterPaddingOption(chart.Box{ + Left: 5, + Top: 10, + })) + assert.Nil(err) + tt.fn(d) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} + +func TestPainterTextFit(t *testing.T) { + assert := assert.New(t) + p, err := NewPainter(PainterOptions{ + Width: 400, + Height: 300, + }) + assert.Nil(err) + f, _ := chart.GetDefaultFont() + style := Style{ + FontSize: 12, + FontColor: chart.ColorBlack, + Font: f, + } + p.SetStyle(style) + box := p.TextFit("Hello World!", 0, 20, 80) + assert.Equal(chart.Box{ + Right: 45, + Bottom: 35, + }, box) + + box = p.TextFit("Hello World!", 0, 100, 200) + assert.Equal(chart.Box{ + Right: 84, + Bottom: 15, + }, box) + + buf, err := p.Bytes() + assert.Nil(err) + assert.Equal(`\nHelloWorld!Hello World!`, string(buf)) +}