diff --git a/axis.go b/axis.go index 5e33062..9a9508f 100644 --- a/axis.go +++ b/axis.go @@ -239,6 +239,10 @@ func (d *Draw) axisTick(opt *axisOption) { tickCount-- } labelMargin := style.GetLabelMargin() + tickShow := true + if opt.style.TickShow != nil && !*opt.style.TickShow { + tickShow = false + } tickLengthValue := style.GetTickLength() labelHeight := labelMargin + opt.textMaxHeight @@ -254,17 +258,20 @@ func (d *Draw) axisTick(opt *axisOption) { if style.Position == PositionLeft { x0 = labelWidth } - for _, v := range values { - x := x0 - y := v - d.moveTo(x, y) - d.lineTo(x+tickLengthValue, y) - r.Stroke() + if tickShow { + for _, v := range values { + x := x0 + y := v + d.moveTo(x, y) + d.lineTo(x+tickLengthValue, y) + r.Stroke() + } } // 辅助线 if style.SplitLineShow && !style.SplitLineColor.IsZero() { r.SetStrokeColor(style.SplitLineColor) - splitLineWidth := width - labelWidth + splitLineWidth := width - labelWidth - tickLengthValue + x0 = labelWidth + tickLengthValue if position == PositionRight { x0 = 0 splitLineWidth = width - labelWidth - 1 @@ -284,12 +291,14 @@ func (d *Draw) axisTick(opt *axisOption) { if position == PositionTop { y0 = labelHeight } - for _, v := range values { - x := v - y := y0 - d.moveTo(x, y-tickLengthValue) - d.lineTo(x, y) - r.Stroke() + if tickShow { + for _, v := range values { + x := v + y := y0 + d.moveTo(x, y-tickLengthValue) + d.lineTo(x, y) + r.Stroke() + } } // 辅助线 if style.SplitLineShow && !style.SplitLineColor.IsZero() { diff --git a/axis_test.go b/axis_test.go index cfbaec4..06b1a97 100644 --- a/axis_test.go +++ b/axis_test.go @@ -140,7 +140,7 @@ func TestAxis(t *testing.T) { opt.style.BoundaryGap = FalseFlag() return opt }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, // 文本居中展示 // axis位于left @@ -150,7 +150,7 @@ func TestAxis(t *testing.T) { opt.style.Position = PositionLeft return opt }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, // 文本按起始位置展示 // axis位于right diff --git a/chart.go b/chart.go index b768993..e0e9a9a 100644 --- a/chart.go +++ b/chart.go @@ -23,17 +23,13 @@ package charts import ( + "math" + "github.com/dustin/go-humanize" "github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2/drawing" ) -type XAxisOption struct { - BoundaryGap *bool - Data []string - // TODO split number -} - type SeriesData struct { Value float64 Style chart.Style @@ -43,31 +39,6 @@ type Point struct { Y int } -type Range struct { - originalMin float64 - originalMax float64 - divideCount int - Min float64 - Max float64 - Size int - Boundary bool -} - -func (r *Range) getHeight(value float64) int { - v := 1 - value/(r.Max-r.Min) - return int(v * float64(r.Size)) -} - -func (r *Range) getWidth(value float64) int { - v := value / (r.Max - r.Min) - // 移至居中 - if r.Boundary && - r.divideCount != 0 { - v += 1 / float64(r.divideCount*2) - } - return int(v * float64(r.Size)) -} - type Series struct { Type string Name string @@ -78,6 +49,7 @@ type Series struct { type ChartOption struct { Theme string + Title TitleOption XAxis XAxisOption Width int Height int @@ -87,6 +59,29 @@ type ChartOption struct { BackgroundColor drawing.Color } +func (o *ChartOption) FillDefault(t *Theme) { + if o.BackgroundColor.IsZero() { + o.BackgroundColor = t.GetBackgroundColor() + } + if o.Title.Style.FontColor.IsZero() { + o.Title.Style.FontColor = t.GetTitleColor() + } + if o.Title.Style.FontSize == 0 { + o.Title.Style.FontSize = 14 + } + if o.Title.Style.Font == nil { + o.Title.Style.Font, _ = chart.GetDefaultFont() + } + if o.Title.Style.Padding.IsZero() { + o.Title.Style.Padding = chart.Box{ + Left: 5, + Top: 5, + Right: 5, + Bottom: 5, + } + } +} + func (o *ChartOption) getWidth() int { if o.Width == 0 { return 600 @@ -102,8 +97,8 @@ func (o *ChartOption) getHeight() int { } func (o *ChartOption) getYRange(axisIndex int) Range { - min := float64(0) - max := float64(0) + min := math.MaxFloat64 + max := -math.MaxFloat64 for _, series := range o.SeriesList { if series.YAxisIndex != axisIndex { @@ -118,18 +113,8 @@ func (o *ChartOption) getYRange(axisIndex int) Range { } } } - // TODO 对于小数的处理 - - divideCount := 6 - r := Range{ - originalMin: min, - originalMax: max, - Min: float64(int(min * 0.8)), - Max: max * 1.2, - divideCount: divideCount, - } - value := int((r.Max - r.Min) / float64(divideCount)) - r.Max = float64(int(float64(value*divideCount) + r.Min)) + // y轴分设置默认划分为6块 + r := NewRange(min*0.9, max*1.1, 6) return r } diff --git a/draw.go b/draw.go index e7c37a4..037124b 100644 --- a/draw.go +++ b/draw.go @@ -34,6 +34,7 @@ import ( const ( PositionLeft = "left" PositionRight = "right" + PositionCenter = "center" PositionTop = "top" PositionBottom = "bottom" ) diff --git a/line_chart.go b/line_chart.go index f1eea22..553e9b8 100644 --- a/line_chart.go +++ b/line_chart.go @@ -31,78 +31,6 @@ type LineChartOption struct { ChartOption } -const YAxisWidth = 50 - -func drawXAxis(d *Draw, opt *XAxisOption, theme *Theme) (int, *Range, error) { - dXAxis, err := NewDraw( - DrawOption{ - Parent: d, - }, - PaddingOption(chart.Box{ - Left: YAxisWidth, - }), - ) - if err != nil { - return 0, nil, err - } - data := NewAxisDataListFromStringList(opt.Data) - style := AxisStyle{ - BoundaryGap: opt.BoundaryGap, - StrokeColor: theme.GetAxisStrokeColor(), - FontColor: theme.GetAxisStrokeColor(), - StrokeWidth: 1, - } - - boundary := true - max := float64(len(opt.Data)) - if opt.BoundaryGap != nil && !*opt.BoundaryGap { - boundary = false - max-- - } - - dXAxis.Axis(data, style) - return d.measureAxis(data, style), &Range{ - divideCount: len(opt.Data), - Min: 0, - Max: max, - Size: dXAxis.Box.Width(), - Boundary: boundary, - }, nil -} - -func drawYAxis(d *Draw, opt *ChartOption, theme *Theme, xAxisHeight int) (*Range, error) { - yRange := opt.getYRange(0) - data := NewAxisDataListFromStringList(yRange.Values()) - style := AxisStyle{ - Position: PositionLeft, - BoundaryGap: FalseFlag(), - // StrokeColor: theme.GetAxisStrokeColor(), - FontColor: theme.GetAxisStrokeColor(), - StrokeWidth: 1, - SplitLineColor: theme.GetAxisSplitLineColor(), - SplitLineShow: true, - } - width := d.measureAxis(data, style) - - dYAxis, err := NewDraw( - DrawOption{ - Parent: d, - Width: d.Box.Width(), - // 减去x轴的高 - Height: d.Box.Height() - xAxisHeight, - }, - PaddingOption(chart.Box{ - Left: YAxisWidth - width, - }), - ) - if err != nil { - return nil, err - } - dYAxis.Axis(data, style) - yRange.Size = dYAxis.Box.Height() - return &yRange, nil -} - func NewLineChart(opt LineChartOption) (*Draw, error) { d, err := NewDraw( DrawOption{ @@ -119,27 +47,35 @@ func NewLineChart(opt LineChartOption) (*Draw, error) { theme := Theme{ mode: opt.Theme, } - // 设置背景色 - bg := opt.BackgroundColor - if bg.IsZero() { - bg = theme.GetBackgroundColor() - } + opt.FillDefault(&theme) if opt.Parent == nil { - d.setBackground(opt.getWidth(), opt.getHeight(), bg) + d.setBackground(opt.getWidth(), opt.getHeight(), opt.BackgroundColor) } + // 标题 + _, titleHeight, err := drawTitle(d, &opt.Title) + if err != nil { + return nil, err + } + + // xAxis xAxisHeight, xRange, err := drawXAxis(d, &opt.XAxis, &theme) if err != nil { return nil, err } + // 暂时仅支持单一yaxis - yRange, err := drawYAxis(d, &opt.ChartOption, &theme, xAxisHeight) + yRange, err := drawYAxis(d, &opt.ChartOption, &theme, xAxisHeight, chart.Box{ + Top: titleHeight, + }) if err != nil { return nil, err } + sd, err := NewDraw(DrawOption{ Parent: d, }, PaddingOption(chart.Box{ + Top: titleHeight, Left: YAxisWidth, })) if err != nil { @@ -166,9 +102,7 @@ func NewLineChart(opt LineChartOption) (*Draw, error) { DotFillColor: dotFillColor, }) } - } - // fmt.Println(yRange) return d, nil } diff --git a/range.go b/range.go new file mode 100644 index 0000000..c2faf8b --- /dev/null +++ b/range.go @@ -0,0 +1,75 @@ +// 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" +) + +type Range struct { + divideCount int + Min float64 + Max float64 + Size int + Boundary bool +} + +func NewRange(min, max float64, divideCount int) Range { + r := math.Abs(max - min) + + // 最小单位计算 + unit := 5 + if r > 100 { + unit = 20 + } + unit = int((r/float64(divideCount))/float64(unit))*unit + unit + + if min != 0 { + min = float64(int(min/float64(unit)) * unit) + // 如果是小于0,int的时候向上取整了,因此调整 + if min < 0 { + min -= float64(unit) + } + } + max = min + float64(unit*divideCount) + return Range{ + Min: min, + Max: max, + divideCount: divideCount, + } +} + +func (r *Range) getHeight(value float64) int { + v := 1 - (value-r.Min)/(r.Max-r.Min) + return int(v * float64(r.Size)) +} + +func (r *Range) getWidth(value float64) int { + v := value / (r.Max - r.Min) + // 移至居中 + if r.Boundary && + r.divideCount != 0 { + v += 1 / float64(r.divideCount*2) + } + return int(v * float64(r.Size)) +} diff --git a/range_test.go b/range_test.go new file mode 100644 index 0000000..151a0bb --- /dev/null +++ b/range_test.go @@ -0,0 +1,49 @@ +// 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(30.0, r.Max) + + r = NewRange(0, 12, 6) + assert.Equal(0.0, r.Min) + assert.Equal(30.0, r.Max) + + r = NewRange(-13, 18, 6) + assert.Equal(-20.0, r.Min) + assert.Equal(40.0, r.Max) + + r = NewRange(0, 400, 6) + assert.Equal(0.0, r.Min) + assert.Equal(480.0, r.Max) +} diff --git a/theme.go b/theme.go index 488e11c..a67dc02 100644 --- a/theme.go +++ b/theme.go @@ -120,3 +120,20 @@ func (t *Theme) GetBackgroundColor() drawing.Color { } return drawing.ColorWhite } + +func (t *Theme) GetTitleColor() drawing.Color { + if t.IsDark() { + return drawing.Color{ + R: 238, + G: 241, + B: 250, + A: 255, + } + } + return drawing.Color{ + R: 70, + G: 70, + B: 70, + A: 255, + } +} diff --git a/title.go b/title.go new file mode 100644 index 0000000..b37c0da --- /dev/null +++ b/title.go @@ -0,0 +1,112 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "strconv" + "strings" + + "github.com/wcharczuk/go-chart/v2" +) + +type TitleOption struct { + Text string + Style chart.Style + Left string + Top string +} +type titleMeasureOption struct { + width int + height int + text string +} + +func drawTitle(d *Draw, opt *TitleOption) (int, int, error) { + if len(opt.Text) == 0 { + return 0, 0, nil + } + + padding := opt.Style.Padding + titleDraw, err := NewDraw(DrawOption{ + Parent: d, + }, PaddingOption(padding)) + if err != nil { + return 0, 0, err + } + + r := titleDraw.Render + opt.Style.GetTextOptions().WriteToRenderer(r) + arr := strings.Split(opt.Text, "\n") + textMaxWidth := 0 + textMaxHeight := 0 + width := 0 + measureOptions := make([]titleMeasureOption, len(arr)) + for index, str := range arr { + textBox := r.MeasureText(str) + + w := textBox.Width() + h := textBox.Height() + if w > textMaxWidth { + textMaxWidth = w + } + if h > textMaxHeight { + textMaxHeight = h + } + measureOptions[index] = titleMeasureOption{ + text: str, + width: w, + height: h, + } + } + width = textMaxWidth + titleX := 0 + b := titleDraw.Box + switch opt.Left { + case PositionRight: + titleX = b.Width() - textMaxWidth + case PositionCenter: + titleX = b.Width()>>1 - (textMaxWidth >> 1) + default: + if strings.HasSuffix(opt.Left, "%") { + value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) + titleX = b.Width() * value / 100 + } else { + value, _ := strconv.Atoi(opt.Left) + titleX = value + } + } + titleY := 0 + // TODO TOP 暂只支持数值 + if opt.Top != "" { + value, _ := strconv.Atoi(opt.Top) + titleY += value + } + for _, item := range measureOptions { + x := titleX + (textMaxWidth-item.width)>>1 + titleDraw.text(item.text, x, titleY) + titleY += textMaxHeight + } + height := titleY + padding.Top + padding.Bottom + + return width, height, nil +} diff --git a/util.go b/util.go index 11fc066..041b8c5 100644 --- a/util.go +++ b/util.go @@ -67,6 +67,7 @@ func maxInt(values ...int) int { return result } +// measureTextMaxWidthHeight returns maxWidth and maxHeight of text list func measureTextMaxWidthHeight(textList []string, r chart.Renderer) (int, int) { maxWidth := 0 maxHeight := 0 diff --git a/xaxis.go b/xaxis.go new file mode 100644 index 0000000..01ef2d3 --- /dev/null +++ b/xaxis.go @@ -0,0 +1,69 @@ +// 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" + +type XAxisOption struct { + BoundaryGap *bool + Data []string + // TODO split number +} + +// drawXAxis draws x axis, and returns the height, range of if. +func drawXAxis(d *Draw, opt *XAxisOption, theme *Theme) (int, *Range, error) { + dXAxis, err := NewDraw( + DrawOption{ + Parent: d, + }, + PaddingOption(chart.Box{ + Left: YAxisWidth, + }), + ) + if err != nil { + return 0, nil, err + } + data := NewAxisDataListFromStringList(opt.Data) + style := AxisStyle{ + BoundaryGap: opt.BoundaryGap, + StrokeColor: theme.GetAxisStrokeColor(), + FontColor: theme.GetAxisStrokeColor(), + StrokeWidth: 1, + } + + boundary := true + max := float64(len(opt.Data)) + if opt.BoundaryGap != nil && !*opt.BoundaryGap { + boundary = false + max-- + } + + dXAxis.Axis(data, style) + return d.measureAxis(data, style), &Range{ + divideCount: len(opt.Data), + Min: 0, + Max: max, + Size: dXAxis.Box.Width(), + Boundary: boundary, + }, nil +} diff --git a/yaxis.go b/yaxis.go new file mode 100644 index 0000000..1777b02 --- /dev/null +++ b/yaxis.go @@ -0,0 +1,62 @@ +// 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" +) + +const YAxisWidth = 40 + +func drawYAxis(d *Draw, opt *ChartOption, theme *Theme, xAxisHeight int, padding chart.Box) (*Range, error) { + yRange := opt.getYRange(0) + data := NewAxisDataListFromStringList(yRange.Values()) + style := AxisStyle{ + Position: PositionLeft, + BoundaryGap: FalseFlag(), + FontColor: theme.GetAxisStrokeColor(), + TickShow: FalseFlag(), + StrokeWidth: 1, + SplitLineColor: theme.GetAxisSplitLineColor(), + SplitLineShow: true, + } + width := d.measureAxis(data, style) + + padding.Left += (YAxisWidth - width) + + dYAxis, err := NewDraw( + DrawOption{ + Parent: d, + Width: d.Box.Width(), + // 减去x轴的高 + Height: d.Box.Height() - xAxisHeight, + }, + PaddingOption(padding), + ) + if err != nil { + return nil, err + } + dYAxis.Axis(data, style) + yRange.Size = dYAxis.Box.Height() + return &yRange, nil +}