diff --git a/axis.go b/axis.go index 8dcabd2..5e33062 100644 --- a/axis.go +++ b/axis.go @@ -1,6 +1,6 @@ // MIT License -// Copyright (c) 2021 Tree Xie +// 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 @@ -23,176 +23,336 @@ package charts import ( - "math" - - "github.com/dustin/go-humanize" + "github.com/golang/freetype/truetype" "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -type ( - // AxisData string - XAxis struct { - // data value of axis - Data []string - // number of segments - SplitNumber int - } -) - -type YAxisOption struct { - // formater of axis - Formater chart.ValueFormatter - // disabled axis - Disabled bool - // min value of axis - Min *float64 - // max value of axis - Max *float64 +type AxisStyle struct { + BoundaryGap *bool + Show *bool + Position string + ClassName string + StrokeColor drawing.Color + StrokeWidth float64 + TickLength int + TickShow *bool + LabelMargin int + FontSize float64 + Font *truetype.Font + FontColor drawing.Color + SplitLineShow bool + SplitLineColor drawing.Color } -const axisStrokeWidth = 1 +func (as *AxisStyle) GetLabelMargin() int { + return getDefaultInt(as.LabelMargin, 8) +} -// GetXAxisAndValues returns x axis by theme, and the values of axis. -func GetXAxisAndValues(xAxis XAxis, tickPosition chart.TickPosition, theme string) (chart.XAxis, []float64) { - data := xAxis.Data - originalSize := len(data) - // 如果居中,则需要多添加一个值 - if tickPosition == chart.TickPositionBetweenTicks { - data = append([]string{ - "", - }, data...) +func (as *AxisStyle) GetTickLength() int { + return getDefaultInt(as.TickLength, 5) +} + +func (as *AxisStyle) 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, } - - size := len(data) - - xValues := make([]float64, size) - ticks := make([]chart.Tick, 0) - - // tick width - maxTicks := maxInt(xAxis.SplitNumber, 10) - - // 计息最多每个unit至少放多个 - minUnitSize := originalSize / maxTicks - if originalSize%maxTicks != 0 { - minUnitSize++ + if s.FontSize == 0 { + s.FontSize = chart.DefaultFontSize } - unitSize := minUnitSize - // 尽可能选择一格展示更多的块 - for i := minUnitSize; i < 2*minUnitSize; i++ { - if originalSize%i == 0 { - unitSize = i + if s.Font == nil { + s.Font = f + } + return s +} + +type AxisData struct { + Text string +} +type AxisDataList []AxisData + +func (l AxisDataList) TextList() []string { + textList := make([]string, len(l)) + for index, item := range l { + textList[index] = item.Text + } + return textList +} + +type axisOption struct { + data *AxisDataList + style *AxisStyle + textMaxWith int + textMaxHeight int + boundaryGap bool +} + +func NewAxisDataListFromStringList(textList []string) AxisDataList { + list := make(AxisDataList, len(textList)) + for index, text := range textList { + list[index] = AxisData{ + Text: text, } } - - for index, key := range data { - f := float64(index) - xValues[index] = f - if index%unitSize == 0 || index == size-1 { - ticks = append(ticks, chart.Tick{ - Value: f, - Label: key, - }) - } - } - return chart.XAxis{ - Ticks: ticks, - TickPosition: tickPosition, - Style: chart.Style{ - FontColor: getAxisColor(theme), - StrokeColor: getAxisColor(theme), - StrokeWidth: axisStrokeWidth, - }, - }, xValues + return list } -func defaultFloatFormater(v interface{}) string { - value, ok := v.(float64) - if !ok { - return "" - } - // 大于10的则直接取整展示 - if value >= 10 { - return humanize.CommafWithDigits(value, 0) - } - return humanize.CommafWithDigits(value, 2) -} - -func newYContinuousRange(option *YAxisOption) *YContinuousRange { - m := YContinuousRange{} - m.Min = -math.MaxFloat64 - m.Max = math.MaxFloat64 - if option != nil { - if option.Min != nil { - m.Min = *option.Min - } - if option.Max != nil { - m.Max = *option.Max - } - } - return &m -} - -// GetSecondaryYAxis returns the secondary y axis by theme -func GetSecondaryYAxis(theme string, option *YAxisOption) chart.YAxis { - strokeColor := getGridColor(theme) - yAxis := chart.YAxis{ - Range: newYContinuousRange(option), - ValueFormatter: defaultFloatFormater, - AxisType: chart.YAxisSecondary, - GridMajorStyle: chart.Style{ - StrokeColor: strokeColor, - StrokeWidth: axisStrokeWidth, - }, - GridMinorStyle: chart.Style{ - StrokeColor: strokeColor, - StrokeWidth: axisStrokeWidth, - }, - Style: chart.Style{ - FontColor: getAxisColor(theme), - // alpha 0,隐藏 - StrokeColor: hiddenColor, - StrokeWidth: axisStrokeWidth, - }, - } - setYAxisOption(&yAxis, option) - return yAxis -} - -func setYAxisOption(yAxis *chart.YAxis, option *YAxisOption) { - if option == nil { +func (d *Draw) axisLabel(opt *axisOption) { + style := opt.style + data := *opt.data + if style.FontColor.IsZero() || len(data) == 0 { return } - if option.Formater != nil { - yAxis.ValueFormatter = option.Formater + r := d.Render + + s := style.Style(d.Font) + s.GetTextOptions().WriteTextOptionsToRenderer(r) + + width := d.Box.Width() + height := d.Box.Height() + textList := data.TextList() + count := len(textList) + boundaryGap := opt.boundaryGap + if !boundaryGap { + count-- + } + + labelMargin := style.GetLabelMargin() + + // 轴线 + labelHeight := labelMargin + opt.textMaxHeight + labelWidth := labelMargin + opt.textMaxWith + + // 坐标轴文本 + position := style.Position + switch position { + case PositionLeft: + fallthrough + 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 - opt.textMaxWith + if position == PositionLeft { + x = labelWidth - b.Width() - 1 + } + d.text(text, x, y) + } + default: + // 定位bottom,重新计算y0的定位 + y0 := height - labelMargin + if position == PositionTop { + y0 = labelHeight - labelMargin + } + values := autoDivide(width, count) + for index, text := range data.TextList() { + 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) + } } } -// GetYAxis returns the primary y axis by theme -func GetYAxis(theme string, option *YAxisOption) chart.YAxis { - disabled := false - if option != nil { - disabled = option.Disabled - } - hidden := chart.Hidden() +func (d *Draw) axisLine(opt *axisOption) { + r := d.Render + style := opt.style + s := style.Style(d.Font) + s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) - yAxis := chart.YAxis{ - Range: newYContinuousRange(option), - ValueFormatter: defaultFloatFormater, - AxisType: chart.YAxisPrimary, - GridMajorStyle: hidden, - GridMinorStyle: hidden, - Style: chart.Style{ - FontColor: getAxisColor(theme), - // alpha 0,隐藏 - StrokeColor: hiddenColor, - StrokeWidth: axisStrokeWidth, - }, + x0 := 0 + y0 := 0 + x1 := 0 + y1 := 0 + width := d.Box.Width() + height := d.Box.Height() + labelMargin := style.GetLabelMargin() + + // 轴线 + labelHeight := labelMargin + opt.textMaxHeight + labelWidth := labelMargin + opt.textMaxWith + tickLength := style.GetTickLength() + switch style.Position { + case PositionLeft: + x0 = tickLength + labelWidth + x1 = x0 + y0 = 0 + y1 = height + case PositionRight: + x0 = width - labelWidth + x1 = x0 + y0 = 0 + y1 = height + case PositionTop: + x0 = 0 + x1 = width + y0 = labelHeight + y1 = y0 + // bottom + default: + x0 = 0 + x1 = width + y0 = height - tickLength - labelHeight + y1 = y0 } - // 如果禁用,则默认为隐藏,并设置range - if disabled { - yAxis.Range = &HiddenRange{} - yAxis.Style.Hidden = true - } - setYAxisOption(&yAxis, option) - return yAxis + + d.moveTo(x0, y0) + d.lineTo(x1, y1) + r.FillStroke() +} + +func (d *Draw) axisTick(opt *axisOption) { + r := d.Render + + style := opt.style + s := style.Style(d.Font) + s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) + + width := d.Box.Width() + height := d.Box.Height() + data := *opt.data + tickCount := len(data) + if !opt.boundaryGap { + tickCount-- + } + labelMargin := style.GetLabelMargin() + + tickLengthValue := style.GetTickLength() + labelHeight := labelMargin + opt.textMaxHeight + labelWidth := labelMargin + opt.textMaxWith + position := style.Position + switch position { + case PositionLeft: + fallthrough + case PositionRight: + values := autoDivide(height, tickCount) + // 左右仅是x0的位置不一样 + x0 := width - labelWidth + 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 style.SplitLineShow && !style.SplitLineColor.IsZero() { + r.SetStrokeColor(style.SplitLineColor) + splitLineWidth := width - labelWidth + if position == PositionRight { + x0 = 0 + splitLineWidth = width - labelWidth - 1 + } + for _, v := range values[0 : len(values)-1] { + x := x0 + y := v + d.moveTo(x, y) + d.lineTo(x+splitLineWidth, y) + r.Stroke() + } + } + default: + values := autoDivide(width, tickCount) + // 上下仅是y0的位置不一样 + y0 := height - labelHeight + 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 style.SplitLineShow && !style.SplitLineColor.IsZero() { + r.SetStrokeColor(style.SplitLineColor) + y0 = 0 + splitLineHeight := height - labelHeight - tickLengthValue + if position == PositionTop { + y0 = labelHeight + splitLineHeight = height - labelHeight + } + + for _, v := range values { + x := v + y := y0 + + d.moveTo(x, y) + d.lineTo(x, y0+splitLineHeight) + r.Stroke() + } + } + } +} + +func (d *Draw) axisMeasureTextMaxWidthHeight(data AxisDataList, style AxisStyle) (int, int) { + r := d.Render + s := style.Style(d.Font) + s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) + s.GetTextOptions().WriteTextOptionsToRenderer(r) + return measureTextMaxWidthHeight(data.TextList(), r) +} + +// measureAxis return the measurement of axis. +// If the position is left or right, it will be textMaxWidth + labelMargin + tickLength. +// If the position is top or bottom, it will be textMaxHeight + labelMargin + tickLength. +func (d *Draw) measureAxis(data AxisDataList, style AxisStyle) int { + value := style.GetLabelMargin() + style.GetTickLength() + textMaxWidth, textMaxHeight := d.axisMeasureTextMaxWidthHeight(data, style) + if style.Position == PositionLeft || + style.Position == PositionRight { + return textMaxWidth + value + } + return textMaxHeight + value +} + +func (d *Draw) Axis(data AxisDataList, style AxisStyle) { + if style.Show != nil && !*style.Show { + return + } + textMaxWidth, textMaxHeight := d.axisMeasureTextMaxWidthHeight(data, style) + opt := &axisOption{ + data: &data, + style: &style, + textMaxWith: textMaxWidth, + textMaxHeight: textMaxHeight, + boundaryGap: true, + } + if style.BoundaryGap != nil && !*style.BoundaryGap { + opt.boundaryGap = false + } + + // 坐标轴线 + d.axisLine(opt) + d.axisTick(opt) + // 坐标文本 + d.axisLabel(opt) } diff --git a/axis_test.go b/axis_test.go index 43779e9..cfbaec4 100644 --- a/axis_test.go +++ b/axis_test.go @@ -1,6 +1,6 @@ // MIT License -// Copyright (c) 2021 Tree Xie +// 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 @@ -23,150 +23,202 @@ package charts import ( - "strconv" "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 TestGetXAxisAndValues(t *testing.T) { +func TestAxisStyle(t *testing.T) { assert := assert.New(t) - genLabels := func(count int) []string { - arr := make([]string, count) - for i := 0; i < count; i++ { - arr[i] = strconv.Itoa(i) - } - return arr - } - genValues := func(count int, betweenTicks bool) []float64 { - if betweenTicks { - count++ - } - arr := make([]float64, count) - for i := 0; i < count; i++ { - arr[i] = float64(i) - } - return arr - } - genTicks := func(count int, betweenTicks bool) []chart.Tick { - arr := make([]chart.Tick, 0) - offset := 0 - if betweenTicks { - offset = 1 - arr = append(arr, chart.Tick{}) - } - for i := 0; i < count; i++ { - arr = append(arr, chart.Tick{ - Value: float64(i + offset), - Label: strconv.Itoa(i), - }) - } - return arr - } + as := AxisStyle{} - tests := []struct { - xAxis XAxis - tickPosition chart.TickPosition - theme string - result chart.XAxis - values []float64 - }{ - { - xAxis: XAxis{ - Data: genLabels(5), - }, - values: genValues(5, false), - result: chart.XAxis{ - Ticks: genTicks(5, false), - }, - }, - // 居中 - { - xAxis: XAxis{ - Data: genLabels(5), - }, - tickPosition: chart.TickPositionBetweenTicks, - // 居中因此value多一个 - values: genValues(5, true), - result: chart.XAxis{ - Ticks: genTicks(5, true), - }, - }, - { - xAxis: XAxis{ - Data: genLabels(20), - }, - // 居中因此value多一个 - values: genValues(20, false), - result: chart.XAxis{ - Ticks: []chart.Tick{ - {Value: 0, Label: "0"}, {Value: 2, Label: "2"}, {Value: 4, Label: "4"}, {Value: 6, Label: "6"}, {Value: 8, Label: "8"}, {Value: 10, Label: "10"}, {Value: 12, Label: "12"}, {Value: 14, Label: "14"}, {Value: 16, Label: "16"}, {Value: 18, Label: "18"}, {Value: 19, Label: "19"}}, - }, - }, - } + assert.Equal(8, as.GetLabelMargin()) + as.LabelMargin = 10 + assert.Equal(10, as.GetLabelMargin()) - for _, tt := range tests { - xAxis, values := GetXAxisAndValues(tt.xAxis, tt.tickPosition, tt.theme) + assert.Equal(5, as.GetTickLength()) + as.TickLength = 6 + assert.Equal(6, as.GetTickLength()) - assert.Equal(tt.result.Ticks, xAxis.Ticks) - assert.Equal(tt.tickPosition, xAxis.TickPosition) - assert.Equal(tt.values, values) - } + f := &truetype.Font{} + style := as.Style(f) + assert.Equal(float64(10), style.FontSize) + assert.Equal(f, style.Font) } -func TestDefaultFloatFormater(t *testing.T) { +func TestAxisDataList(t *testing.T) { assert := assert.New(t) - assert.Equal("", defaultFloatFormater(1)) - - assert.Equal("0.1", defaultFloatFormater(0.1)) - assert.Equal("0.12", defaultFloatFormater(0.123)) - assert.Equal("10", defaultFloatFormater(10.1)) + textList := []string{ + "a", + "b", + } + data := NewAxisDataListFromStringList(textList) + assert.Equal(textList, data.TextList()) } -func TestSetYAxisOption(t *testing.T) { - assert := assert.New(t) - min := 10.0 - max := 20.0 - opt := &YAxisOption{ - Formater: func(v interface{}) string { - return "" - }, - Min: &min, - Max: &max, - } - yAxis := &chart.YAxis{ - Range: newYContinuousRange(opt), - } - setYAxisOption(yAxis, opt) - - assert.NotEmpty(yAxis.ValueFormatter) - assert.Equal(max, yAxis.Range.GetMax()) - assert.Equal(min, yAxis.Range.GetMin()) -} - -func TestGetYAxis(t *testing.T) { +func TestAxis(t *testing.T) { assert := assert.New(t) - yAxis := GetYAxis(ThemeDark, nil) - - assert.True(yAxis.GridMajorStyle.Hidden) - assert.True(yAxis.GridMajorStyle.Hidden) - assert.False(yAxis.Style.Hidden) - - yAxis = GetYAxis(ThemeDark, &YAxisOption{ - Disabled: true, + data := NewAxisDataListFromStringList([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", }) + getDefaultOption := func() *axisOption { + return &axisOption{ + data: &data, + style: &AxisStyle{ + StrokeColor: drawing.ColorBlack, + StrokeWidth: 1, + FontColor: drawing.ColorBlack, + Show: TrueFlag(), + TickShow: TrueFlag(), + SplitLineShow: true, + SplitLineColor: drawing.ColorBlack.WithAlpha(60), + }, + } + } + tests := []struct { + newOption func() *axisOption + result string + }{ + // 文本按起始位置展示 + // axis位于bottom + { + newOption: func() *axisOption { + opt := getDefaultOption() + opt.style.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.style.Position = PositionTop + opt.style.BoundaryGap = FalseFlag() + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本居中展示 + // axis位于top + { + newOption: func() *axisOption { + opt := getDefaultOption() + opt.style.Position = PositionTop + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本按起始位置展示 + // axis位于left + { + newOption: func() *axisOption { + opt := getDefaultOption() + opt.style.Position = PositionLeft + opt.style.BoundaryGap = FalseFlag() + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本居中展示 + // axis位于left + { + newOption: func() *axisOption { + opt := getDefaultOption() + opt.style.Position = PositionLeft + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本按起始位置展示 + // axis位于right + { + newOption: func() *axisOption { + opt := getDefaultOption() + opt.style.Position = PositionRight + opt.style.BoundaryGap = FalseFlag() + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本居中展示 + // axis位于right + { + newOption: func() *axisOption { + opt := getDefaultOption() + opt.style.Position = PositionRight + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + } + 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) + opt := tt.newOption() - assert.True(yAxis.GridMajorStyle.Hidden) - assert.True(yAxis.GridMajorStyle.Hidden) - assert.True(yAxis.Style.Hidden) + d.Axis(*opt.data, *opt.style) - // secondary yAxis - yAxis = GetSecondaryYAxis(ThemeDark, nil) - assert.False(yAxis.GridMajorStyle.Hidden) - assert.False(yAxis.GridMajorStyle.Hidden) - assert.True(yAxis.Style.StrokeColor.IsTransparent()) + 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 := d.measureAxis(data, AxisStyle{ + FontSize: 12, + Font: f, + Position: PositionLeft, + }) + assert.Equal(44, width) + + height := d.measureAxis(data, AxisStyle{ + FontSize: 12, + Font: f, + Position: PositionTop, + }) + assert.Equal(28, height) } diff --git a/line_series.go b/bar.go similarity index 62% rename from line_series.go rename to bar.go index 93a1479..eb88cb1 100644 --- a/line_series.go +++ b/bar.go @@ -1,6 +1,6 @@ // MIT License -// Copyright (c) 2021 Tree Xie +// 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 @@ -24,26 +24,34 @@ package charts import ( "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -type LineSeries struct { - BaseSeries +type BarStyle struct { + ClassName string + StrokeDashArray []float64 + FillColor drawing.Color } -func (ls LineSeries) getXRange(xrange chart.Range) chart.Range { - if ls.TickPosition != chart.TickPositionBetweenTicks { - return xrange +func (bs *BarStyle) Style() chart.Style { + return chart.Style{ + ClassName: bs.ClassName, + StrokeDashArray: bs.StrokeDashArray, + StrokeColor: bs.FillColor, + StrokeWidth: 1, + FillColor: bs.FillColor, } - // 如果是居中,画线时重新调整 - return wrapRange(xrange, ls.TickPosition) } -func (ls LineSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) { - style := ls.Style.InheritFrom(defaults) - xrange = ls.getXRange(xrange) - chart.Draw.LineSeries(r, canvasBox, xrange, yrange, style, ls) - lr := LabelRenderer{ - Options: ls.Label, - } - lr.Render(r, canvasBox, xrange, yrange, style, ls) +func (d *Draw) Bar(b chart.Box, style BarStyle) { + s := style.Style() + + 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() } diff --git a/bar_series.go b/bar_series.go deleted file mode 100644 index b9e8fc1..0000000 --- a/bar_series.go +++ /dev/null @@ -1,148 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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/dustin/go-humanize" - "github.com/wcharczuk/go-chart/v2" -) - -const defaultBarMargin = 10 - -type BarSeriesCustomStyle struct { - PointIndex int - Index int - Style chart.Style -} - -type BarSeries struct { - BaseSeries - Count int - Index int - // 间隔 - Margin int - // 偏移量 - Offset int - // 宽度 - BarWidth int - CustomStyles []BarSeriesCustomStyle -} - -type barSeriesWidthValues struct { - columnWidth int - columnMargin int - margin int - barWidth int -} - -func (bs BarSeries) GetBarStyle(index, pointIndex int) chart.Style { - // 指定样式 - for _, item := range bs.CustomStyles { - if item.Index == index && item.PointIndex == pointIndex { - return item.Style - } - } - // 其它非指定样式 - return chart.Style{} -} - -func (bs BarSeries) getWidthValues(width int) barSeriesWidthValues { - columnWidth := width / bs.Len() - // 块间隔 - columnMargin := columnWidth / 10 - minColumnMargin := 2 - if columnMargin < minColumnMargin { - columnMargin = minColumnMargin - } - margin := bs.Margin - if margin <= 0 { - margin = defaultBarMargin - } - // 如果margin大于column margin - if margin > columnMargin { - margin = columnMargin - } - - allBarMarginWidth := (bs.Count - 1) * margin - barWidth := ((columnWidth - 2*columnMargin) - allBarMarginWidth) / bs.Count - if bs.BarWidth > 0 && bs.BarWidth < barWidth { - barWidth = bs.BarWidth - // 重新计息columnMargin - columnMargin = (columnWidth - allBarMarginWidth - (bs.Count * barWidth)) / 2 - } - return barSeriesWidthValues{ - columnWidth: columnWidth, - columnMargin: columnMargin, - margin: margin, - barWidth: barWidth, - } -} - -func (bs BarSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) { - if bs.Len() == 0 || bs.Count <= 0 { - return - } - style := bs.Style.InheritFrom(defaults) - style.FillColor = style.StrokeColor - if !style.ShouldDrawStroke() { - return - } - - cb := canvasBox.Bottom - cl := canvasBox.Left - widthValues := bs.getWidthValues(canvasBox.Width()) - labelValues := make([]LabelValue, 0) - - for i := 0; i < bs.Len(); i++ { - vx, vy := bs.GetValues(i) - customStyle := bs.GetBarStyle(bs.Index, i) - cloneStyle := style - if !customStyle.IsZero() { - cloneStyle.FillColor = customStyle.FillColor - cloneStyle.StrokeColor = customStyle.StrokeColor - } - - x := cl + xrange.Translate(vx) - // 由于bar是居中展示,因此需要往前移一个显示块 - x += (-widthValues.columnWidth + widthValues.columnMargin) - // 计算是第几个bar,位置右偏 - x += bs.Index * (widthValues.margin + widthValues.barWidth) - y := cb - yrange.Translate(vy) - - chart.Draw.Box(r, chart.Box{ - Left: x, - Top: y, - Right: x + widthValues.barWidth, - Bottom: canvasBox.Bottom - 1, - }, cloneStyle) - labelValues = append(labelValues, LabelValue{ - Left: x + widthValues.barWidth/2, - Top: y, - Text: humanize.CommafWithDigits(vy, 2), - }) - } - lr := LabelRenderer{ - Options: bs.Label, - } - lr.CustomizeRender(r, style, labelValues) -} diff --git a/bar_series_test.go b/bar_series_test.go deleted file mode 100644 index 8703367..0000000 --- a/bar_series_test.go +++ /dev/null @@ -1,166 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -func TestBarSeries(t *testing.T) { - assert := assert.New(t) - - customStyle := chart.Style{ - StrokeColor: drawing.ColorBlue, - } - bs := BarSeries{ - CustomStyles: []BarSeriesCustomStyle{ - { - PointIndex: 1, - Style: customStyle, - }, - }, - } - - assert.Equal(customStyle, bs.GetBarStyle(0, 1)) - - assert.True(bs.GetBarStyle(1, 0).IsZero()) -} - -func TestBarSeriesGetWidthValues(t *testing.T) { - assert := assert.New(t) - - bs := BarSeries{ - Count: 1, - BaseSeries: BaseSeries{ - XValues: []float64{ - 1, - 2, - 3, - }, - }, - } - widthValues := bs.getWidthValues(300) - assert.Equal(barSeriesWidthValues{ - columnWidth: 100, - columnMargin: 10, - margin: 10, - barWidth: 80, - }, widthValues) - - // 指定margin - bs.Margin = 5 - widthValues = bs.getWidthValues(300) - assert.Equal(barSeriesWidthValues{ - columnWidth: 100, - columnMargin: 10, - margin: 5, - barWidth: 80, - }, widthValues) - - // 指定bar的宽度 - bs.BarWidth = 60 - widthValues = bs.getWidthValues(300) - assert.Equal(barSeriesWidthValues{ - columnWidth: 100, - columnMargin: 20, - margin: 5, - barWidth: 60, - }, widthValues) -} - -func TestBarSeriesRender(t *testing.T) { - assert := assert.New(t) - - width := 800 - height := 400 - - r, err := chart.SVG(width, height) - assert.Nil(err) - - bs := BarSeries{ - Count: 1, - CustomStyles: []BarSeriesCustomStyle{ - { - Index: 0, - PointIndex: 1, - Style: chart.Style{ - StrokeColor: SeriesColorsLight[1], - }, - }, - }, - BaseSeries: BaseSeries{ - TickPosition: chart.TickPositionBetweenTicks, - Style: chart.Style{ - StrokeColor: SeriesColorsLight[0], - StrokeWidth: 1, - }, - XValues: []float64{ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - }, - YValues: []float64{ - // 第一个点为占位点 - 0, - 120, - 200, - 150, - 80, - 70, - 110, - 130, - }, - }, - } - xrange := &chart.ContinuousRange{ - Min: 0, - Max: 7, - Domain: 753, - } - yrange := &chart.ContinuousRange{ - Min: 70, - Max: 200, - Domain: 362, - } - bs.Render(r, chart.Box{ - Top: 11, - Left: 42, - Right: 795, - Bottom: 373, - }, xrange, yrange, chart.Style{}) - - buffer := bytes.Buffer{} - err = r.Save(&buffer) - assert.Nil(err) - assert.Equal("\\n", buffer.String()) -} diff --git a/range_test.go b/bar_test.go similarity index 54% rename from range_test.go rename to bar_test.go index 33937bf..01b6d3c 100644 --- a/range_test.go +++ b/bar_test.go @@ -1,6 +1,6 @@ // MIT License -// Copyright (c) 2021 Tree Xie +// 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 @@ -23,55 +23,56 @@ package charts import ( - "math" "testing" "github.com/stretchr/testify/assert" "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestRange(t *testing.T) { +func TestBarStyle(t *testing.T) { assert := assert.New(t) - r := Range{ - ContinuousRange: chart.ContinuousRange{ - Min: 0, - Max: 5, - Domain: 500, + bs := BarStyle{ + ClassName: "test", + StrokeDashArray: []float64{ + 1.0, }, + FillColor: drawing.ColorBlack, } - assert.Equal(100, r.Translate(1)) - - r.TickPosition = chart.TickPositionBetweenTicks - assert.Equal(50, r.Translate(1)) + assert.Equal(chart.Style{ + ClassName: "test", + StrokeDashArray: []float64{ + 1.0, + }, + StrokeWidth: 1, + FillColor: drawing.ColorBlack, + StrokeColor: drawing.ColorBlack, + }, bs.Style()) } -func TestHiddenRange(t *testing.T) { +func TestDrawBar(t *testing.T) { assert := assert.New(t) - r := HiddenRange{} - - assert.Equal(float64(0), r.GetDelta()) -} - -func TestYContinuousRange(t *testing.T) { - assert := assert.New(t) - r := YContinuousRange{} - r.Min = -math.MaxFloat64 - r.Max = math.MaxFloat64 - - assert.True(r.IsZero()) - - r.SetMin(1.0) - assert.Equal(1.0, r.GetMin()) - // 再次设置无效 - r.SetMin(2.0) - assert.Equal(1.0, r.GetMin()) - - r.SetMax(5.0) - // *1.2 - assert.Equal(6.0, r.GetMax()) - // 再次设置无效 - r.SetMax(10.0) - assert.Equal(6.0, r.GetMax()) + 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/base_series.go b/base_series.go deleted file mode 100644 index 37e3689..0000000 --- a/base_series.go +++ /dev/null @@ -1,140 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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 ( - "fmt" - - "github.com/wcharczuk/go-chart/v2" -) - -// Interface Assertions. -var ( - _ chart.Series = (*BaseSeries)(nil) - _ chart.FirstValuesProvider = (*BaseSeries)(nil) - _ chart.LastValuesProvider = (*BaseSeries)(nil) -) - -type SeriesLabel struct { - Show bool - Offset chart.Box -} - -// BaseSeries represents a line on a chart. -type BaseSeries struct { - Name string - Style chart.Style - TickPosition chart.TickPosition - - YAxis chart.YAxisType - - XValueFormatter chart.ValueFormatter - YValueFormatter chart.ValueFormatter - - XValues []float64 - YValues []float64 - - Label SeriesLabel -} - -// GetName returns the name of the time series. -func (bs BaseSeries) GetName() string { - return bs.Name -} - -// GetStyle returns the line style. -func (bs BaseSeries) GetStyle() chart.Style { - return bs.Style -} - -// Len returns the number of elements in the series. -func (bs BaseSeries) Len() int { - offset := 0 - if bs.TickPosition == chart.TickPositionBetweenTicks { - offset = -1 - } - return len(bs.XValues) + offset -} - -// GetValues gets the x,y values at a given index. -func (bs BaseSeries) GetValues(index int) (float64, float64) { - if bs.TickPosition == chart.TickPositionBetweenTicks { - index++ - } - return bs.XValues[index], bs.YValues[index] -} - -// GetFirstValues gets the first x,y values. -func (bs BaseSeries) GetFirstValues() (float64, float64) { - index := 0 - if bs.TickPosition == chart.TickPositionBetweenTicks { - index++ - } - return bs.XValues[index], bs.YValues[index] -} - -// GetLastValues gets the last x,y values. -func (bs BaseSeries) GetLastValues() (float64, float64) { - return bs.XValues[len(bs.XValues)-1], bs.YValues[len(bs.YValues)-1] -} - -// GetValueFormatters returns value formatter defaults for the series. -func (bs BaseSeries) GetValueFormatters() (x, y chart.ValueFormatter) { - if bs.XValueFormatter != nil { - x = bs.XValueFormatter - } else { - x = chart.FloatValueFormatter - } - if bs.YValueFormatter != nil { - y = bs.YValueFormatter - } else { - y = chart.FloatValueFormatter - } - return -} - -// GetYAxis returns which YAxis the series draws on. -func (bs BaseSeries) GetYAxis() chart.YAxisType { - return bs.YAxis -} - -// Render renders the series. -func (bs BaseSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) { - fmt.Println("should be override the function") -} - -// Validate validates the series. -func (bs BaseSeries) Validate() error { - if len(bs.XValues) == 0 { - return fmt.Errorf("continuous series; must have xvalues set") - } - - if len(bs.YValues) == 0 { - return fmt.Errorf("continuous series; must have yvalues set") - } - - if len(bs.XValues) != len(bs.YValues) { - return fmt.Errorf("continuous series; must have same length xvalues as yvalues") - } - return nil -} diff --git a/base_series_test.go b/base_series_test.go deleted file mode 100644 index 0c9b5d1..0000000 --- a/base_series_test.go +++ /dev/null @@ -1,94 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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 ( - "reflect" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" -) - -func TestBaseSeries(t *testing.T) { - assert := assert.New(t) - - bs := BaseSeries{ - XValues: []float64{ - 1, - 2, - 3, - }, - YValues: []float64{ - 10, - 20, - 30, - }, - } - assert.Equal(3, bs.Len()) - bs.TickPosition = chart.TickPositionBetweenTicks - assert.Equal(2, bs.Len()) - - bs.TickPosition = chart.TickPositionUnset - x, y := bs.GetValues(1) - assert.Equal(float64(2), x) - assert.Equal(float64(20), y) - bs.TickPosition = chart.TickPositionBetweenTicks - x, y = bs.GetValues(1) - assert.Equal(float64(3), x) - assert.Equal(float64(30), y) - - bs.TickPosition = chart.TickPositionUnset - x, y = bs.GetFirstValues() - assert.Equal(float64(1), x) - assert.Equal(float64(10), y) - bs.TickPosition = chart.TickPositionBetweenTicks - x, y = bs.GetFirstValues() - assert.Equal(float64(2), x) - assert.Equal(float64(20), y) - - bs.TickPosition = chart.TickPositionUnset - x, y = bs.GetLastValues() - assert.Equal(float64(3), x) - assert.Equal(float64(30), y) - bs.TickPosition = chart.TickPositionBetweenTicks - x, y = bs.GetLastValues() - assert.Equal(float64(3), x) - assert.Equal(float64(30), y) - - xFormater, yFormater := bs.GetValueFormatters() - assert.Equal(reflect.ValueOf(chart.FloatValueFormatter).Pointer(), reflect.ValueOf(xFormater).Pointer()) - assert.Equal(reflect.ValueOf(chart.FloatValueFormatter).Pointer(), reflect.ValueOf(yFormater).Pointer()) - formater := func(v interface{}) string { - return "" - } - bs.XValueFormatter = formater - bs.YValueFormatter = formater - xFormater, yFormater = bs.GetValueFormatters() - assert.Equal(reflect.ValueOf(formater).Pointer(), reflect.ValueOf(xFormater).Pointer()) - assert.Equal(reflect.ValueOf(formater).Pointer(), reflect.ValueOf(yFormater).Pointer()) - - assert.Equal(chart.YAxisPrimary, bs.GetYAxis()) - - assert.Nil(bs.Validate()) -} diff --git a/chart.go b/chart.go new file mode 100644 index 0000000..b768993 --- /dev/null +++ b/chart.go @@ -0,0 +1,145 @@ +// 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/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 +} +type Point struct { + X int + 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 + Data []SeriesData + YAxisIndex int + Style chart.Style +} + +type ChartOption struct { + Theme string + XAxis XAxisOption + Width int + Height int + Parent *Draw + Padding chart.Box + SeriesList []Series + BackgroundColor drawing.Color +} + +func (o *ChartOption) getWidth() int { + if o.Width == 0 { + return 600 + } + return o.Width +} + +func (o *ChartOption) getHeight() int { + if o.Height == 0 { + return 400 + } + return o.Height +} + +func (o *ChartOption) getYRange(axisIndex int) Range { + min := float64(0) + max := float64(0) + + 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 + } + } + } + // 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)) + return r +} + +func (r Range) 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 + value := humanize.CommafWithDigits(v, 2) + values = append(values, value) + } + return values +} diff --git a/charts.go b/charts.go deleted file mode 100644 index 5957fdb..0000000 --- a/charts.go +++ /dev/null @@ -1,292 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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" - "io" - "sync" - - "github.com/dustin/go-humanize" - "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -const ( - ThemeLight = "light" - ThemeDark = "dark" -) - -const ( - DefaultChartWidth = 800 - DefaultChartHeight = 400 -) - -type ( - Title struct { - Text string - Style chart.Style - Font *truetype.Font - Left string - Top string - } - Legend struct { - Data []string - Align string - Padding chart.Box - Left string - Right string - Top string - Bottom string - } - Options struct { - Padding chart.Box - Width int - Height int - Theme string - XAxis XAxis - YAxisOptions []*YAxisOption - Series []Series - Title Title - Legend Legend - TickPosition chart.TickPosition - Log chart.Logger - Font *truetype.Font - } -) - -var fonts = sync.Map{} -var ErrFontNotExists = errors.New("font is not exists") - -// InstallFont installs the font for charts -func InstallFont(fontFamily string, data []byte) error { - font, err := truetype.Parse(data) - if err != nil { - return err - } - fonts.Store(fontFamily, font) - return nil -} - -// GetFont returns the font of font family -func GetFont(fontFamily string) (*truetype.Font, error) { - value, ok := fonts.Load(fontFamily) - if !ok { - return nil, ErrFontNotExists - } - f, ok := value.(*truetype.Font) - if !ok { - return nil, ErrFontNotExists - } - return f, nil -} - -type Graph interface { - Render(rp chart.RendererProvider, w io.Writer) error -} - -func (o *Options) validate() error { - if len(o.Series) == 0 { - return errors.New("series can not be empty") - } - xAxisCount := len(o.XAxis.Data) - - for _, item := range o.Series { - if item.Type != SeriesPie && len(item.Data) != xAxisCount { - return errors.New("series and xAxis is not matched") - } - } - return nil -} - -func (o *Options) getWidth() int { - width := o.Width - if width <= 0 { - width = DefaultChartWidth - } - return width -} - -func (o *Options) getHeight() int { - height := o.Height - if height <= 0 { - height = DefaultChartHeight - } - return height -} - -func (o *Options) getBackground() chart.Style { - bg := chart.Style{ - Padding: o.Padding, - } - return bg -} - -func render(g Graph, rp chart.RendererProvider) ([]byte, error) { - buf := bytes.Buffer{} - err := g.Render(rp, &buf) - if err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func ToPNG(g Graph) ([]byte, error) { - return render(g, chart.PNG) -} - -func ToSVG(g Graph) ([]byte, error) { - return render(g, chart.SVG) -} - -func newTitleRenderable(title Title, font *truetype.Font, textColor drawing.Color) chart.Renderable { - if title.Text == "" || title.Style.Hidden { - return nil - } - title.Font = font - if title.Style.FontColor.IsZero() { - title.Style.FontColor = textColor - } - return NewTitleCustomize(title) -} - -func newPieChart(opt Options) *chart.PieChart { - values := make(chart.Values, len(opt.Series)) - for index, item := range opt.Series { - label := item.Name - if item.Label.Show { - label += ":" + humanize.CommafWithDigits(item.Data[0].Value, 2) - } - values[index] = chart.Value{ - Value: item.Data[0].Value, - Label: label, - } - } - p := &chart.PieChart{ - Font: opt.Font, - Background: opt.getBackground(), - Width: opt.getWidth(), - Height: opt.getHeight(), - Values: values, - ColorPalette: &PieThemeColorPalette{ - ThemeColorPalette: ThemeColorPalette{ - Theme: opt.Theme, - }, - }, - } - // pie 图表默认设置为居中 - if opt.Title.Left == "" { - opt.Title.Left = "center" - } - titleColor := drawing.ColorBlack - if opt.Theme == ThemeDark { - titleColor = drawing.ColorWhite - } - titleRender := newTitleRenderable(opt.Title, p.GetFont(), titleColor) - if titleRender != nil { - p.Elements = []chart.Renderable{ - titleRender, - } - } - return p -} - -func newChart(opt Options) *chart.Chart { - tickPosition := opt.TickPosition - - xAxis, xValues := GetXAxisAndValues(opt.XAxis, tickPosition, opt.Theme) - - legendSize := len(opt.Legend.Data) - for index, item := range opt.Series { - if len(item.XValues) == 0 { - opt.Series[index].XValues = xValues - } - if index < legendSize && opt.Series[index].Name == "" { - opt.Series[index].Name = opt.Legend.Data[index] - } - } - - var secondaryYAxisOption *YAxisOption - if len(opt.YAxisOptions) != 0 { - secondaryYAxisOption = opt.YAxisOptions[0] - } - - yAxisOption := &YAxisOption{ - Disabled: true, - } - if len(opt.YAxisOptions) > 1 { - yAxisOption = opt.YAxisOptions[1] - } - - c := &chart.Chart{ - Font: opt.Font, - Log: opt.Log, - Background: opt.getBackground(), - ColorPalette: &ThemeColorPalette{ - Theme: opt.Theme, - }, - Width: opt.getWidth(), - Height: opt.getHeight(), - XAxis: xAxis, - YAxis: GetYAxis(opt.Theme, yAxisOption), - YAxisSecondary: GetSecondaryYAxis(opt.Theme, secondaryYAxisOption), - Series: GetSeries(opt.Series, tickPosition, opt.Theme), - } - - elements := make([]chart.Renderable, 0) - - if legendSize != 0 { - elements = append(elements, NewLegendCustomize(c.Series, LegendOption{ - Theme: opt.Theme, - IconDraw: DefaultLegendIconDraw, - Align: opt.Legend.Align, - Padding: opt.Legend.Padding, - Left: opt.Legend.Left, - Right: opt.Legend.Right, - Top: opt.Legend.Top, - Bottom: opt.Legend.Bottom, - })) - } - titleRender := newTitleRenderable(opt.Title, c.GetFont(), c.GetColorPalette().TextColor()) - if titleRender != nil { - elements = append(elements, titleRender) - } - if len(elements) != 0 { - c.Elements = elements - } - return c -} - -func New(opt Options) (Graph, error) { - err := opt.validate() - if err != nil { - return nil, err - } - if opt.Series[0].Type == SeriesPie { - return newPieChart(opt), nil - } - - return newChart(opt), nil -} diff --git a/charts_test.go b/charts_test.go deleted file mode 100644 index 98a7288..0000000 --- a/charts_test.go +++ /dev/null @@ -1,156 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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/roboto" -) - -func TestFont(t *testing.T) { - assert := assert.New(t) - - fontFamily := "roboto" - err := InstallFont(fontFamily, roboto.Roboto) - assert.Nil(err) - - font, err := GetFont(fontFamily) - assert.Nil(err) - assert.NotNil(font) -} - -func TestChartsOptions(t *testing.T) { - assert := assert.New(t) - - o := Options{} - - assert.Equal(errors.New("series can not be empty"), o.validate()) - - o.Series = []Series{ - { - Data: []SeriesData{ - { - Value: 1, - }, - }, - }, - } - assert.Equal(errors.New("series and xAxis is not matched"), o.validate()) - o.XAxis.Data = []string{ - "1", - } - assert.Nil(o.validate()) - - assert.Equal(DefaultChartWidth, o.getWidth()) - o.Width = 10 - assert.Equal(10, o.getWidth()) - - assert.Equal(DefaultChartHeight, o.getHeight()) - o.Height = 10 - assert.Equal(10, o.getHeight()) - - padding := chart.NewBox(10, 10, 10, 10) - o.Padding = padding - assert.Equal(padding, o.getBackground().Padding) -} - -func TestNewPieChart(t *testing.T) { - assert := assert.New(t) - - data := []Series{ - { - Data: []SeriesData{ - { - Value: 10, - }, - }, - Name: "chrome", - }, - { - Data: []SeriesData{ - { - Value: 2, - }, - }, - Name: "edge", - }, - } - pie := newPieChart(Options{ - Series: data, - }) - for index, item := range pie.Values { - assert.Equal(data[index].Name, item.Label) - assert.Equal(data[index].Data[0].Value, item.Value) - } -} - -func TestNewChart(t *testing.T) { - assert := assert.New(t) - - data := []Series{ - { - Data: []SeriesData{ - { - Value: 10, - }, - { - Value: 20, - }, - }, - Name: "chrome", - }, - { - Data: []SeriesData{ - { - Value: 2, - }, - { - Value: 3, - }, - }, - Name: "edge", - }, - } - - c := newChart(Options{ - Series: data, - }) - assert.Empty(c.Elements) - for index, series := range c.Series { - assert.Equal(data[index].Name, series.GetName()) - } - - c = newChart(Options{ - Legend: Legend{ - Data: []string{ - "chrome", - "edge", - }, - }, - }) - assert.Equal(1, len(c.Elements)) -} diff --git a/draw.go b/draw.go index 7b9dc56..e7c37a4 100644 --- a/draw.go +++ b/draw.go @@ -24,6 +24,7 @@ package charts import ( "bytes" + "errors" "github.com/golang/freetype/truetype" "github.com/wcharczuk/go-chart/v2" @@ -37,152 +38,78 @@ const ( PositionBottom = "bottom" ) -type draw struct { +type Draw struct { Render chart.Renderer Box chart.Box - parent *draw + Font *truetype.Font + parent *Draw } -type Point struct { - X int - Y int +type DrawOption struct { + Type string + Parent *Draw + Width int + Height int } -type LineStyle struct { - ClassName string - StrokeDashArray []float64 - StrokeColor drawing.Color - StrokeWidth float64 - FillColor drawing.Color - DotWidth float64 - DotColor drawing.Color - DotFillColor drawing.Color -} +type Option func(*Draw) error -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 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 } } -type BarStyle struct { - ClassName string - StrokeDashArray []float64 - FillColor drawing.Color -} - -func (bs *BarStyle) Style() chart.Style { - return chart.Style{ - ClassName: bs.ClassName, - StrokeDashArray: bs.StrokeDashArray, - StrokeColor: bs.FillColor, - StrokeWidth: 1, - FillColor: bs.FillColor, +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") } -} - -type AxisStyle struct { - BoundaryGap *bool - Show *bool - Position string - Offset int - ClassName string - StrokeColor drawing.Color - StrokeWidth float64 - TickLength int - TickShow *bool - LabelMargin int - FontSize float64 - Font *truetype.Font - FontColor drawing.Color -} - -func (as *AxisStyle) GetLabelMargin() int { - return getDefaultInt(as.LabelMargin, 8) -} - -func (as *AxisStyle) GetTickLength() int { - return getDefaultInt(as.TickLength, 5) -} - -func (as *AxisStyle) Style() chart.Style { - s := chart.Style{ - ClassName: as.ClassName, - StrokeColor: as.StrokeColor, - StrokeWidth: as.StrokeWidth, - FontSize: as.FontSize, - FontColor: as.FontColor, - Font: as.Font, + font, _ := chart.GetDefaultFont() + d := &Draw{ + Font: font, } - if s.FontSize == 0 { - s.FontSize = chart.DefaultFontSize + 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 s.Font == nil { - s.Font, _ = chart.GetDefaultFont() + if width != 0 && height != 0 { + d.Box.Right = width + d.Box.Left + d.Box.Bottom = height + d.Box.Top } - return s -} - -type AxisData struct { - Text string -} -type AxisDataList []AxisData - -func (l AxisDataList) TextList() []string { - textList := make([]string, len(l)) - for index, item := range l { - textList[index] = item.Text + // 创建render + if d.parent == nil { + fn := chart.SVG + if opt.Type == "png" { + fn = chart.PNG + } + r, err := fn(d.Box.Right, d.Box.Bottom) + if err != nil { + return nil, err + } + d.Render = r } - return textList -} -type axisOption struct { - data *AxisDataList - style *AxisStyle - textMaxWith int - textMaxHeight int -} - -func NewAxisDataListFromStringList(textList []string) AxisDataList { - list := make(AxisDataList, len(textList)) - for index, text := range textList { - list[index] = AxisData{ - Text: text, + for _, o := range opts { + err := o(d) + if err != nil { + return nil, err } } - return list + return d, nil } -type Option func(*draw) - -func ParentOption(p *draw) Option { - return func(d *draw) { - d.parent = p - } -} - -func NewDraw(r chart.Renderer, box chart.Box, opts ...Option) *draw { - d := &draw{ - Render: r, - Box: box, - } - for _, opt := range opts { - opt(d) - } - return d -} - -func (d *draw) Parent() *draw { +func (d *Draw) Parent() *Draw { return d.parent } -func (d *draw) Top() *draw { +func (d *Draw) Top() *Draw { if d.parent == nil { return nil } @@ -197,7 +124,7 @@ func (d *draw) Top() *draw { return t } -func (d *draw) Bytes() ([]byte, error) { +func (d *Draw) Bytes() ([]byte, error) { buffer := bytes.Buffer{} err := d.Render.Save(&buffer) if err != nil { @@ -206,23 +133,23 @@ func (d *draw) Bytes() ([]byte, error) { return buffer.Bytes(), err } -func (d *draw) moveTo(x, y int) { +func (d *Draw) moveTo(x, y int) { d.Render.MoveTo(x+d.Box.Left, y+d.Box.Top) } -func (d *draw) lineTo(x, y int) { +func (d *Draw) lineTo(x, y int) { d.Render.LineTo(x+d.Box.Left, y+d.Box.Top) } -func (d *draw) circle(radius float64, x, y int) { +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) { +func (d *Draw) text(body string, x, y int) { d.Render.Text(body, x+d.Box.Left, y+d.Box.Top) } -func (d *draw) lineStroke(points []Point, style LineStyle) { +func (d *Draw) lineStroke(points []Point, style LineStyle) { s := style.Style() if !s.ShouldDrawStroke() { return @@ -241,290 +168,16 @@ func (d *draw) lineStroke(points []Point, style LineStyle) { r.Stroke() } -func (d *draw) lineFill(points []Point, style LineStyle) { - s := style.Style() - if !(s.ShouldDrawStroke() && s.ShouldDrawFill()) { - return - } +func (d *Draw) setBackground(width, height int, color drawing.Color) { 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) - } + s := chart.Style{ + FillColor: color, } - height := d.Box.Height() - d.lineTo(x, height) - x0 := points[0].X - y0 := points[0].Y - d.lineTo(x0, height) - d.lineTo(x0, y0) - r.Fill() -} - -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) -} - -func (d *draw) Bar(b chart.Box, style BarStyle) { - s := style.Style() - - 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) + 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) axisLabel(opt *axisOption) { - style := opt.style - data := *opt.data - if style.FontColor.IsZero() || len(data) == 0 { - return - } - r := d.Render - - s := style.Style() - s.GetTextOptions().WriteTextOptionsToRenderer(r) - - width := d.Box.Width() - height := d.Box.Height() - textList := data.TextList() - count := len(textList) - x0 := 0 - y0 := 0 - tickLength := style.GetTickLength() - - // 坐标轴文本 - switch style.Position { - case PositionLeft: - values := autoDivide(height, count) - textList := data.TextList() - // 由下往上 - reverseIntSlice(values) - for index, text := range textList { - y := values[index] - height := y - values[index+1] - b := r.MeasureText(text) - y -= (height - b.Height()) >> 1 - x := x0 + opt.textMaxWith - (b.Width()) - d.text(text, x, y) - } - case PositionRight: - values := autoDivide(height, count) - textList := data.TextList() - // 由下往上 - reverseIntSlice(values) - for index, text := range textList { - y := values[index] - height := y - values[index+1] - b := r.MeasureText(text) - y -= (height - b.Height()) >> 1 - x := width - opt.textMaxWith - d.text(text, x, y) - } - case PositionTop: - y0 = tickLength + style.Offset - values := autoDivide(width, count) - maxIndex := len(values) - 2 - for index, text := range data.TextList() { - if index > maxIndex { - break - } - x := values[index] - width := values[index+1] - x - b := r.MeasureText(text) - leftOffset := (width - b.Width()) >> 1 - d.text(text, x+leftOffset, y0) - } - default: - // 定位bottom,重新计算y0的定位 - y0 = height - tickLength + style.Offset - values := autoDivide(width, count) - maxIndex := len(values) - 2 - for index, text := range data.TextList() { - if index > maxIndex { - break - } - x := values[index] - width := values[index+1] - x - b := r.MeasureText(text) - leftOffset := (width - b.Width()) >> 1 - d.text(text, x+leftOffset, y0) - } - } -} - -func (d *draw) axisLine(opt *axisOption) { - - r := d.Render - style := opt.style - s := style.Style() - s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) - - x0 := 0 - y0 := 0 - x1 := 0 - y1 := 0 - width := d.Box.Width() - height := d.Box.Height() - labelMargin := style.GetLabelMargin() - - // 轴线 - labelHeight := labelMargin + opt.textMaxHeight - labelWidth := labelMargin + opt.textMaxWith - tickLength := style.GetTickLength() - switch style.Position { - case PositionLeft: - x0 = tickLength + style.Offset + labelWidth - x1 = x0 - y0 = 0 - y1 = height - case PositionRight: - x0 = width + style.Offset - labelWidth - x1 = x0 - y0 = 0 - y1 = height - case PositionTop: - x0 = 0 - x1 = width - y0 = style.Offset + labelHeight - y1 = y0 - // bottom - default: - x0 = 0 - x1 = width - y0 = height - tickLength + style.Offset - labelHeight - y1 = y0 - } - - d.moveTo(x0, y0) - d.lineTo(x1, y1) - r.FillStroke() -} - -func (d *draw) axisTick(opt *axisOption) { - r := d.Render - - style := opt.style - s := style.Style() - s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) - - x0 := 0 - y0 := 0 - width := d.Box.Width() - height := d.Box.Height() - data := *opt.data - tickCount := len(data) - labelMargin := style.GetLabelMargin() - - tickLengthValue := style.GetTickLength() - labelHeight := labelMargin + opt.textMaxHeight - labelWidth := labelMargin + opt.textMaxWith - switch style.Position { - case PositionLeft: - x0 += labelWidth - values := autoDivide(height, tickCount) - for _, v := range values { - x := x0 - y := v - d.moveTo(x, y) - d.lineTo(x+tickLengthValue, y) - r.Stroke() - } - case PositionRight: - values := autoDivide(height, tickCount) - x0 = width - labelWidth - for _, v := range values { - x := x0 - y := v - d.moveTo(x, y) - d.lineTo(x+tickLengthValue, y) - r.Stroke() - } - case PositionTop: - values := autoDivide(width, tickCount) - y0 = style.Offset + labelHeight - for _, v := range values { - x := v - y := y0 - d.moveTo(x, y-tickLengthValue) - d.lineTo(x, y) - r.Stroke() - } - - default: - values := autoDivide(width, tickCount) - y0 = height + style.Offset - labelHeight - for _, v := range values { - x := v - y := y0 - d.moveTo(x, y-tickLengthValue) - d.lineTo(x, y) - r.Stroke() - } - } -} - -func (d *draw) axisMeasureTextMaxWidthHeight(data AxisDataList, style AxisStyle) (int, int) { - r := d.Render - s := style.Style() - s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) - s.GetTextOptions().WriteTextOptionsToRenderer(r) - return measureTextMaxWidthHeight(data.TextList(), r) -} - -func (d *draw) Axis(data AxisDataList, style AxisStyle) { - if style.Show != nil && !*style.Show { - return - } - r := d.Render - s := style.Style() - s.GetTextOptions().WriteTextOptionsToRenderer(r) - textMaxWidth, textMaxHeight := d.axisMeasureTextMaxWidthHeight(data, style) - opt := &axisOption{ - data: &data, - style: &style, - textMaxWith: textMaxWidth, - textMaxHeight: textMaxHeight, - } - - // 坐标轴线 - d.axisLine(opt) - d.axisTick(opt) - // 坐标文本 - d.axisLabel(opt) -} diff --git a/draw_test.go b/draw_test.go new file mode 100644 index 0000000..ef449dd --- /dev/null +++ b/draw_test.go @@ -0,0 +1,242 @@ +// 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 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) { + 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 TestPaddingOption(t *testing.T) { + assert := assert.New(t) + + d, err := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }) + assert.Nil(err) + + // 默认的box + assert.Equal(chart.Box{ + Right: 400, + Bottom: 300, + }, d.Box) + + // 设置padding之后的box + d, err = NewDraw(DrawOption{ + Width: 400, + Height: 300, + }, PaddingOption(chart.Box{ + Left: 1, + Top: 2, + Right: 3, + Bottom: 4, + })) + assert.Nil(err) + assert.Equal(chart.Box{ + Top: 2, + Left: 1, + Right: 397, + Bottom: 296, + }, d.Box) + + p := d + // 设置父元素之后的box + d, err = NewDraw( + DrawOption{ + Parent: p, + }, + PaddingOption(chart.Box{ + Left: 1, + Top: 2, + Right: 3, + Bottom: 4, + }), + ) + assert.Nil(err) + assert.Equal(chart.Box{ + Top: 4, + Left: 2, + Right: 394, + Bottom: 292, + }, d.Box) +} + +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) { + assert := assert.New(t) + + tests := []struct { + fn func(d *Draw) + result string + }{ + // moveTo, lineTo + { + fn: func(d *Draw) { + d.moveTo(1, 1) + d.lineTo(2, 2) + d.Render.Stroke() + }, + result: "\\n", + }, + // circle + { + fn: func(d *Draw) { + d.circle(5, 2, 3) + }, + result: "\\n", + }, + // text + { + fn: func(d *Draw) { + d.text("hello world!", 3, 6) + }, + result: "\\nhello world!", + }, + // line stroke + { + fn: func(d *Draw) { + d.lineStroke([]Point{ + { + X: 1, + Y: 2, + }, + { + X: 3, + Y: 4, + }, + }, LineStyle{ + StrokeColor: drawing.ColorBlack, + StrokeWidth: 1, + }) + }, + result: "\\n", + }, + // set background + { + fn: func(d *Draw) { + d.setBackground(400, 300, chart.ColorWhite) + }, + result: "\\n", + }, + } + for _, tt := range tests { + d, err := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }, PaddingOption(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)) + } +} diff --git a/echarts.go b/echarts.go deleted file mode 100644 index 7e1884c..0000000 --- a/echarts.go +++ /dev/null @@ -1,403 +0,0 @@ -// MIT License - -// Copyright (c) 2021 Tree Xie - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package charts - -import ( - "bytes" - "encoding/json" - "fmt" - "regexp" - "strconv" - "strings" - - "github.com/wcharczuk/go-chart/v2" -) - -type EChartStyle struct { - Color string `json:"color"` -} -type ECharsSeriesData struct { - Value float64 `json:"value"` - Name string `json:"name"` - ItemStyle EChartStyle `json:"itemStyle"` -} -type _ECharsSeriesData ECharsSeriesData - -func convertToArray(data []byte) []byte { - data = bytes.TrimSpace(data) - if len(data) == 0 { - return nil - } - if data[0] != '[' { - data = []byte("[" + string(data) + "]") - } - return data -} - -func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error { - data = bytes.TrimSpace(data) - if len(data) == 0 { - return nil - } - if regexp.MustCompile(`^\d+`).Match(data) { - v, err := strconv.ParseFloat(string(data), 64) - if err != nil { - return err - } - es.Value = v - return nil - } - v := _ECharsSeriesData{} - err := json.Unmarshal(data, &v) - if err != nil { - return err - } - es.Name = v.Name - es.Value = v.Value - es.ItemStyle = v.ItemStyle - return nil -} - -type EChartsPadding struct { - box chart.Box -} - -type Position string - -func (lp *Position) UnmarshalJSON(data []byte) error { - if len(data) == 0 { - return nil - } - if regexp.MustCompile(`^\d+`).Match(data) { - data = []byte(fmt.Sprintf(`"%s"`, string(data))) - } - s := (*string)(lp) - return json.Unmarshal(data, s) -} - -func (ep *EChartsPadding) UnmarshalJSON(data []byte) error { - data = convertToArray(data) - if len(data) == 0 { - return nil - } - arr := make([]int, 0) - err := json.Unmarshal(data, &arr) - if err != nil { - return err - } - if len(arr) == 0 { - return nil - } - switch len(arr) { - case 1: - ep.box = chart.Box{ - Left: arr[0], - Top: arr[0], - Bottom: arr[0], - Right: arr[0], - } - case 2: - ep.box = chart.Box{ - Top: arr[0], - Bottom: arr[0], - Left: arr[1], - Right: arr[1], - } - default: - result := make([]int, 4) - copy(result, arr) - if len(arr) == 3 { - result[3] = result[1] - } - // 上右下左 - ep.box = chart.Box{ - Top: result[0], - Right: result[1], - Bottom: result[2], - Left: result[3], - } - } - return nil -} - -type EChartsYAxis struct { - Data []struct { - Min *float64 `json:"min"` - Max *float64 `json:"max"` - // Interval int `json:"interval"` - AxisLabel struct { - Formatter string `json:"formatter"` - } `json:"axisLabel"` - } `json:"data"` -} - -func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error { - data = convertToArray(data) - if len(data) == 0 { - return nil - } - return json.Unmarshal(data, &ey.Data) -} - -type EChartsXAxis struct { - Data []struct { - // Type string `json:"type"` - BoundaryGap *bool `json:"boundaryGap"` - SplitNumber int `json:"splitNumber"` - Data []string `json:"data"` - } -} - -func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error { - data = convertToArray(data) - if len(data) == 0 { - return nil - } - return json.Unmarshal(data, &ex.Data) -} - -type EChartsLabelOption struct { - Show bool `json:"show"` - Distance int `json:"distance"` -} - -func (elo EChartsLabelOption) ToLabel() SeriesLabel { - if !elo.Show { - return SeriesLabel{} - } - return SeriesLabel{ - Show: true, - Offset: chart.Box{ - // 默认位置为top,因此设置为负 - Top: -elo.Distance, - }, - } -} - -type ECharsOptions struct { - Theme string `json:"theme"` - Padding EChartsPadding `json:"padding"` - Title struct { - Text string `json:"text"` - Left Position `json:"left"` - Top Position `json:"top"` - TextStyle struct { - Color string `json:"color"` - FontFamily string `json:"fontFamily"` - FontSize float64 `json:"fontSize"` - Height float64 `json:"height"` - } `json:"textStyle"` - } `json:"title"` - XAxis EChartsXAxis `json:"xAxis"` - YAxis EChartsYAxis `json:"yAxis"` - Legend struct { - Data []string `json:"data"` - Align string `json:"align"` - Padding EChartsPadding `json:"padding"` - Left Position `json:"left"` - Right Position `json:"right"` - // Top string `json:"top"` - // Bottom string `json:"bottom"` - } `json:"legend"` - Series []struct { - Data []ECharsSeriesData `json:"data"` - Type string `json:"type"` - YAxisIndex int `json:"yAxisIndex"` - ItemStyle EChartStyle `json:"itemStyle"` - // label的配置 - Label EChartsLabelOption `json:"label"` - } `json:"series"` -} - -func convertEChartsSeries(e *ECharsOptions) ([]Series, chart.TickPosition) { - tickPosition := chart.TickPositionUnset - - if len(e.Series) == 0 { - return nil, tickPosition - } - seriesType := e.Series[0].Type - if seriesType == SeriesPie { - series := make([]Series, len(e.Series[0].Data)) - label := e.Series[0].Label.ToLabel() - for index, item := range e.Series[0].Data { - style := chart.Style{} - if item.ItemStyle.Color != "" { - c := parseColor(item.ItemStyle.Color) - style.FillColor = c - style.StrokeColor = c - } - - series[index] = Series{ - Style: style, - Data: []SeriesData{ - { - Value: item.Value, - }, - }, - Type: seriesType, - Name: item.Name, - Label: label, - } - } - return series, tickPosition - } - series := make([]Series, len(e.Series)) - for index, item := range e.Series { - // bar默认tick居中 - if item.Type == SeriesBar { - tickPosition = chart.TickPositionBetweenTicks - } - style := chart.Style{} - if item.ItemStyle.Color != "" { - c := parseColor(item.ItemStyle.Color) - style.FillColor = c - style.StrokeColor = c - } - data := make([]SeriesData, len(item.Data)) - for j, itemData := range item.Data { - sd := SeriesData{ - Value: itemData.Value, - } - if itemData.ItemStyle.Color != "" { - c := parseColor(itemData.ItemStyle.Color) - sd.Style.FillColor = c - sd.Style.StrokeColor = c - } - data[j] = sd - } - series[index] = Series{ - Style: style, - YAxisIndex: item.YAxisIndex, - Data: data, - Type: item.Type, - Label: item.Label.ToLabel(), - } - } - return series, tickPosition -} - -func (e *ECharsOptions) ToOptions() Options { - o := Options{ - Theme: e.Theme, - Padding: e.Padding.box, - } - - titleTextStyle := e.Title.TextStyle - o.Title = Title{ - Text: e.Title.Text, - Left: string(e.Title.Left), - Top: string(e.Title.Top), - Style: chart.Style{ - FontColor: parseColor(titleTextStyle.Color), - FontSize: titleTextStyle.FontSize, - }, - } - if e.Title.TextStyle.FontFamily != "" { - // 如果获取字体失败忽略 - o.Font, _ = GetFont(e.Title.TextStyle.FontFamily) - } - - if titleTextStyle.FontSize != 0 && titleTextStyle.Height > titleTextStyle.FontSize { - padding := int(titleTextStyle.Height-titleTextStyle.FontSize) / 2 - o.Title.Style.Padding.Top = padding - o.Title.Style.Padding.Bottom = padding - } - - boundaryGap := false - if len(e.XAxis.Data) != 0 { - xAxis := e.XAxis.Data[0] - o.XAxis = XAxis{ - Data: xAxis.Data, - SplitNumber: xAxis.SplitNumber, - } - if xAxis.BoundaryGap == nil || *xAxis.BoundaryGap { - boundaryGap = true - } - } - - o.Legend = Legend{ - Data: e.Legend.Data, - Align: e.Legend.Align, - Padding: e.Legend.Padding.box, - Left: string(e.Legend.Left), - Right: string(e.Legend.Right), - } - if len(e.YAxis.Data) != 0 { - yAxisOptions := make([]*YAxisOption, len(e.YAxis.Data)) - for index, item := range e.YAxis.Data { - opt := &YAxisOption{ - Max: item.Max, - Min: item.Min, - } - template := item.AxisLabel.Formatter - if template != "" { - opt.Formater = func(v interface{}) string { - str := defaultFloatFormater(v) - return strings.ReplaceAll(template, "{value}", str) - } - } - yAxisOptions[index] = opt - } - o.YAxisOptions = yAxisOptions - } - - series, tickPosition := convertEChartsSeries(e) - - o.Series = series - - if boundaryGap { - tickPosition = chart.TickPositionBetweenTicks - } - o.TickPosition = tickPosition - return o -} - -func ParseECharsOptions(options string) (Options, error) { - e := ECharsOptions{} - err := json.Unmarshal([]byte(options), &e) - if err != nil { - return Options{}, err - } - - return e.ToOptions(), nil -} - -func echartsRender(options string, rp chart.RendererProvider) ([]byte, error) { - o, err := ParseECharsOptions(options) - if err != nil { - return nil, err - } - g, err := New(o) - if err != nil { - return nil, err - } - return render(g, rp) -} - -func RenderEChartsToPNG(options string) ([]byte, error) { - return echartsRender(options, chart.PNG) -} - -func RenderEChartsToSVG(options string) ([]byte, error) { - return echartsRender(options, chart.SVG) -} diff --git a/echarts_test.go b/echarts_test.go deleted file mode 100644 index 6dcc4d0..0000000 --- a/echarts_test.go +++ /dev/null @@ -1,455 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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 TestConvertToArray(t *testing.T) { - assert := assert.New(t) - - assert.Nil(convertToArray([]byte(" "))) - - assert.Equal([]byte("[{}]"), convertToArray([]byte("{}"))) - assert.Equal([]byte("[{}]"), convertToArray([]byte("[{}]"))) -} - -func TestECharsSeriesData(t *testing.T) { - assert := assert.New(t) - - es := ECharsSeriesData{} - err := es.UnmarshalJSON([]byte(" ")) - assert.Nil(err) - assert.Equal(ECharsSeriesData{}, es) - - es = ECharsSeriesData{} - err = es.UnmarshalJSON([]byte("12.1")) - assert.Nil(err) - assert.Equal(ECharsSeriesData{ - Value: 12.1, - }, es) - - es = ECharsSeriesData{} - err = es.UnmarshalJSON([]byte(`{ - "value": 12.1, - "name": "test", - "itemStyle": { - "color": "#333" - } - }`)) - assert.Nil(err) - assert.Equal(ECharsSeriesData{ - Value: 12.1, - Name: "test", - ItemStyle: EChartStyle{ - Color: "#333", - }, - }, es) -} - -func TestEChartsPadding(t *testing.T) { - assert := assert.New(t) - - ep := EChartsPadding{} - err := ep.UnmarshalJSON([]byte(" ")) - assert.Nil(err) - assert.Equal(EChartsPadding{}, ep) - - ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte("1")) - assert.Nil(err) - assert.Equal(EChartsPadding{ - box: chart.Box{ - Top: 1, - Left: 1, - Right: 1, - Bottom: 1, - }, - }, ep) - - ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte("[1, 2]")) - assert.Nil(err) - assert.Equal(EChartsPadding{ - box: chart.Box{ - Top: 1, - Left: 2, - Right: 2, - Bottom: 1, - }, - }, ep) - - ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte("[1, 2, 3]")) - assert.Nil(err) - assert.Equal(EChartsPadding{ - box: chart.Box{ - Top: 1, - Right: 2, - Bottom: 3, - Left: 2, - }, - }, ep) - - ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte("[1, 2, 3, 4]")) - assert.Nil(err) - assert.Equal(EChartsPadding{ - box: chart.Box{ - Top: 1, - Right: 2, - Bottom: 3, - Left: 4, - }, - }, ep) -} - -func TestConvertEChartsSeries(t *testing.T) { - assert := assert.New(t) - - seriesList, tickPosition := convertEChartsSeries(&ECharsOptions{}) - assert.Empty(seriesList) - assert.Equal(chart.TickPositionUnset, tickPosition) - - e := ECharsOptions{} - err := json.Unmarshal([]byte(`{ - "title": { - "text": "Referer of a Website" - }, - "series": [ - { - "name": "Access From", - "type": "pie", - "radius": "50%", - "data": [ - { - "value": 1048, - "name": "Search Engine" - }, - { - "value": 735, - "name": "Direct" - } - ] - } - ] - }`), &e) - assert.Nil(err) - seriesList, tickPosition = convertEChartsSeries(&e) - assert.Equal(chart.TickPositionUnset, tickPosition) - assert.Equal([]Series{ - { - Data: []SeriesData{ - { - Value: 1048, - }, - }, - Type: SeriesPie, - Name: "Search Engine", - }, - { - Data: []SeriesData{ - { - Value: 735, - }, - }, - Type: SeriesPie, - Name: "Direct", - }, - }, seriesList) - - err = json.Unmarshal([]byte(`{ - "series": [ - { - "name": "Evaporation", - "type": "bar", - "data": [2, { - "value": 4.9, - "itemStyle": { - "color": "#a90000" - } - }, 7, 23.2, 25.6, 76.7, 135.6] - }, - { - "name": "Precipitation", - "type": "bar", - "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6] - }, - { - "name": "Temperature", - "type": "line", - "yAxisIndex": 1, - "data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3] - } - ] - }`), &e) - assert.Nil(err) - bar1Data := NewSeriesDataListFromFloat([]float64{ - 2, 4.9, 7, 23.2, 25.6, 76.7, 135.6, - }) - bar1Data[1].Style.FillColor = parseColor("#a90000") - bar1Data[1].Style.StrokeColor = bar1Data[1].Style.FillColor - - seriesList, tickPosition = convertEChartsSeries(&e) - assert.Equal(chart.TickPositionBetweenTicks, tickPosition) - assert.Equal([]Series{ - { - Data: bar1Data, - Type: SeriesBar, - }, - { - Data: NewSeriesDataListFromFloat([]float64{ - 2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6, - }), - Type: SeriesBar, - }, - { - Data: NewSeriesDataListFromFloat([]float64{ - 2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3, - }), - Type: SeriesLine, - YAxisIndex: 1, - }, - }, seriesList) - -} - -func TestParseECharsOptions(t *testing.T) { - - assert := assert.New(t) - options, err := ParseECharsOptions(`{ - "theme": "dark", - "padding": [5, 10], - "title": { - "text": "Multi Line", - "textAlign": "left", - "textStyle": { - "color": "#333", - "fontSize": 24, - "height": 40 - } - }, - "legend": { - "align": "left", - "padding": [5, 0, 0, 50], - "data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"] - }, - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], - "splitNumber": 10 - }, - "yAxis": [ - { - "min": 0, - "max": 250 - }, - { - "min": 0, - "max": 25 - } - ], - "series": [ - { - "name": "Evaporation", - "type": "bar", - "data": [2, { - "value": 4.9, - "itemStyle": { - "color": "#a90000" - } - }, 7, 23.2, 25.6, 76.7, 135.6] - }, - { - "name": "Precipitation", - "type": "bar", - "itemStyle": { - "color": "#0052d9" - }, - "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6] - }, - { - "name": "Temperature", - "type": "line", - "yAxisIndex": 1, - "data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3] - } - ] - }`) - - assert.Nil(err) - - min1 := float64(0) - max1 := float64(250) - min2 := float64(0) - max2 := float64(25) - - bar1Data := NewSeriesDataListFromFloat([]float64{ - 2, 4.9, 7, 23.2, 25.6, 76.7, 135.6, - }) - bar1Data[1].Style.FillColor = parseColor("#a90000") - bar1Data[1].Style.StrokeColor = bar1Data[1].Style.FillColor - - assert.Equal(Options{ - Theme: ThemeDark, - Padding: chart.Box{ - Top: 5, - Bottom: 5, - Left: 10, - Right: 10, - }, - Title: Title{ - Text: "Multi Line", - Style: chart.Style{ - FontColor: parseColor("#333"), - FontSize: 24, - Padding: chart.Box{ - Top: 8, - Bottom: 8, - }, - }, - }, - Legend: Legend{ - Data: []string{ - "Email", "Union Ads", "Video Ads", "Direct", "Search Engine", - }, - Align: "left", - Padding: chart.Box{ - Top: 5, - Right: 0, - Bottom: 0, - Left: 50, - }, - }, - XAxis: XAxis{ - Data: []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}, - SplitNumber: 10, - }, - TickPosition: chart.TickPositionBetweenTicks, - YAxisOptions: []*YAxisOption{ - { - Min: &min1, - Max: &max1, - }, - { - Min: &min2, - Max: &max2, - }, - }, - Series: []Series{ - { - Data: bar1Data, - Type: SeriesBar, - }, - { - Data: NewSeriesDataListFromFloat([]float64{ - 2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6, - }), - Type: SeriesBar, - Style: chart.Style{ - StrokeColor: drawing.Color{ - R: 0, - G: 82, - B: 217, - A: 255, - }, - FillColor: drawing.Color{ - R: 0, - G: 82, - B: 217, - A: 255, - }, - }, - }, - { - Data: NewSeriesDataListFromFloat([]float64{ - 2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3, - }), - Type: SeriesLine, - YAxisIndex: 1, - }, - }, - }, options) -} - -func TestUnmarshalJSON(t *testing.T) { - assert := assert.New(t) - var lp Position - err := lp.UnmarshalJSON([]byte("123")) - assert.Nil(err) - assert.Equal("123", string(lp)) - - err = lp.UnmarshalJSON([]byte(`"234"`)) - assert.Nil(err) - assert.Equal("234", string(lp)) -} - -func BenchmarkEChartsRenderPNG(b *testing.B) { - for i := 0; i < b.N; i++ { - _, err := RenderEChartsToPNG(`{ - "title": { - "text": "Line" - }, - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "data": [150, 230, 224, 218, 135, 147, 260] - } - ] - }`) - if err != nil { - panic(err) - } - } -} - -func BenchmarkEChartsRenderSVG(b *testing.B) { - for i := 0; i < b.N; i++ { - _, err := RenderEChartsToSVG(`{ - "title": { - "text": "Line" - }, - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "data": [150, 230, 224, 218, 135, 147, 260] - } - ] - }`) - if err != nil { - panic(err) - } - } -} diff --git a/examples/basic/main.go b/examples/basic/main.go deleted file mode 100644 index 9efc745..0000000 --- a/examples/basic/main.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "os" - - charts "github.com/vicanso/go-charts" -) - -func main() { - buf, err := charts.RenderEChartsToPNG(`{ - "title": { - "text": "Line" - }, - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "data": [150, 230, 224, 218, 135, 147, 260] - } - ] - }`) - if err != nil { - panic(err) - } - file, err := os.Create("output.png") - if err != nil { - panic(err) - } - defer file.Close() - file.Write(buf) -} diff --git a/examples/charts/main.go b/examples/charts/main.go deleted file mode 100644 index 1828a52..0000000 --- a/examples/charts/main.go +++ /dev/null @@ -1,394 +0,0 @@ -package main - -import ( - "bytes" - "net/http" - - charts "github.com/vicanso/go-charts" -) - -var html = ` - - - - - - - - go-charts - - -
{{body}}
- - -` - -var chartOptions = []map[string]string{ - { - "title": "折线图", - "option": `{ - "title": { - "text": "Line\nHello World", - "left": "right", - "textStyle": { - "fontSize": 24, - "height": 40 - } - }, - "yAxis": { - "min": 0, - "max": 300 - }, - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "data": [150, 230, 224, 218, 135, 147, 260], - "type": "line" - } - ] -}`, - }, - { - "title": "多折线图", - "option": `{ - "title": { - "text": "Multi Line" - }, - "legend": { - "align": "left", - "right": 0, - "data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"] - }, - "xAxis": { - "type": "category", - "boundaryGap": false, - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "type": "line", - "data": [120, 132, 101, 134, 90, 230, 210] - }, - { - "data": [220, 182, 191, 234, 290, 330, 310] - }, - { - "data": [150, 232, 201, 154, 190, 330, 410] - }, - { - "data": [320, 332, 301, 334, 390, 330, 320] - }, - { - "data": [820, 932, 901, 934, 1290, 1330, 1320] - } - ] -}`, - }, - { - "title": "柱状图", - "option": `{ - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "data": [120, 200, 150, 80, 70, 110, 130], - "type": "bar" - } - ] -}`, - }, - { - "title": "柱状图(自定义颜色)", - "option": `{ - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "data": [ - 120, - { - "value": 200, - "itemStyle": { - "color": "#a90000" - } - }, - 150, - 80, - 70, - 110, - 130 - ], - "type": "bar" - } - ] -}`, - }, - { - "title": "多柱状图", - "option": `{ - "title": { - "text": "Rainfall vs Evaporation" - }, - "legend": { - "data": ["Rainfall", "Evaporation"] - }, - "xAxis": { - "type": "category", - "splitNumber": 12, - "data": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - }, - "series": [ - { - "name": "Rainfall", - "type": "bar", - "data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6, 162.2, 32.6, 20, 6.4, 3.3] - }, - { - "name": "Evaporation", - "type": "bar", - "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6, 2.3] - } - ] -}`, - }, - { - "title": "折柱混合", - "option": `{ - "legend": { - "data": [ - "Evaporation", - "Precipitation", - "Temperature" - ] - }, - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "yAxis": [ - { - "type": "value", - "name": "Precipitation", - "min": 0, - "max": 250, - "interval": 50, - "axisLabel": { - "formatter": "{value} ml" - } - }, - { - "type": "value", - "name": "Temperature", - "min": 0, - "max": 25, - "interval": 5, - "axisLabel": { - "formatter": "{value} °C" - } - } - ], - "series": [ - { - "name": "Evaporation", - "type": "bar", - "itemStyle": { - "color": "#0052d9" - }, - "data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6] - }, - { - "name": "Precipitation", - "type": "bar", - "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6] - }, - { - "name": "Temperature", - "type": "line", - "yAxisIndex": 1, - "data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3] - } - ] -}`, - }, - { - "title": "降雨量", - "option": `{ - "title": { - "text": "降雨量" - }, - "legend": { - "data": ["GZ", "SH"] - }, - "xAxis": { - "type": "category", - "splitNumber": 6, - "data": ["01-01","01-02","01-03","01-04","01-05","01-06","01-07","01-08","01-09","01-10","01-11","01-12","01-13","01-14","01-15","01-16","01-17","01-18","01-19","01-20","01-21","01-22","01-23","01-24","01-25","01-26","01-27","01-28","01-29","01-30","01-31"] - }, - "yAxis": { - "axisLabel": { - "formatter": "{value} mm" - } - }, - "series": [ - { - "type": "bar", - "data": [928,821,889,600,547,783,197,853,430,346,63,465,309,334,141,538,792,58,922,807,298,243,744,885,812,231,330,220,984,221,429] - }, - { - "type": "bar", - "data": [749,201,296,579,255,159,902,246,149,158,507,776,186,79,390,222,601,367,221,411,714,620,966,73,203,631,833,610,487,677,596] - } - ] -}`, - }, - { - "title": "饼图", - "option": `{ - "title": { - "text": "Referer of a Website" - }, - "series": [ - { - "name": "Access From", - "type": "pie", - "radius": "50%", - "data": [ - { - "value": 1048, - "name": "Search Engine" - }, - { - "value": 735, - "name": "Direct" - }, - { - "value": 580, - "name": "Email" - }, - { - "value": 484, - "name": "Union Ads" - }, - { - "value": 300, - "name": "Video Ads" - } - ] - } - ] -}`, - }, -} - -type renderOptions struct { - theme string - width int - height int - onlyCharts bool -} - -func render(opts renderOptions) ([]byte, error) { - data := bytes.Buffer{} - for _, m := range chartOptions { - chartHTML := []byte(`
-

{{title}}

-
{{option}}
- {{svg}} -
`) - if opts.onlyCharts { - chartHTML = []byte(`
- {{svg}} -
`) - } - o, err := charts.ParseECharsOptions(m["option"]) - if opts.width > 0 { - o.Width = opts.width - } - if opts.height > 0 { - o.Height = opts.height - } - - o.Theme = opts.theme - if err != nil { - return nil, err - } - g, err := charts.New(o) - if err != nil { - return nil, err - } - buf, err := charts.ToSVG(g) - if err != nil { - return nil, err - } - buf = bytes.ReplaceAll(chartHTML, []byte("{{svg}}"), buf) - buf = bytes.ReplaceAll(buf, []byte("{{title}}"), []byte(m["title"])) - buf = bytes.ReplaceAll(buf, []byte("{{option}}"), []byte(m["option"])) - data.Write(buf) - } - return data.Bytes(), nil -} - -func indexHandler(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - return - } - query := r.URL.Query() - opts := renderOptions{ - theme: query.Get("theme"), - } - if query.Get("view") == "grid" { - opts.width = 400 - opts.height = 200 - opts.onlyCharts = true - } - - buf, err := render(opts) - if err != nil { - w.WriteHeader(400) - w.Write([]byte(err.Error())) - return - } - - data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), buf) - w.Header().Set("Content-Type", "text/html") - w.Write(data) -} - -func main() { - http.HandleFunc("/", indexHandler) - http.ListenAndServe(":3012", nil) -} diff --git a/examples/demo/main.go b/examples/demo/main.go index 26866d9..f0e32c7 100644 --- a/examples/demo/main.go +++ b/examples/demo/main.go @@ -5,6 +5,7 @@ import ( "net/http" charts "github.com/vicanso/go-charts" + "github.com/wcharczuk/go-chart/v2" ) var html = ` @@ -18,6 +19,10 @@ var html = ` body { background-color: #e0e0e0; } + * { + margin: 0; + padding: 0; + } .charts { width: 810px; margin: 10px auto; @@ -56,321 +61,117 @@ var html = ` ` -var chartOptions = []map[string]string{ - { - "option": `{ - "title": { - "text": "Line", - "left": "center" - }, - "yAxis": { - "min": 0, - "max": 300 - }, - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "data": [150, 230, 224, 218, 135, 147, 260], - "type": "line", - "label": { - "show": true, - "distance": 5 - } - } - ] -}`, - }, - { - "option": `{ - "legend": { - "align": "left", - "left": 0, - "data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"] - }, - "xAxis": { - "type": "category", - "boundaryGap": false, - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "type": "line", - "data": [120, 132, 101, 134, 90, 230, 210] - }, - { - "data": [220, 182, 191, 234, 290, 330, 310] - }, - { - "data": [150, 232, 201, 154, 190, 330, 410] - }, - { - "data": [320, 332, 301, 334, 390, 330, 320] - }, - { - "data": [820, 932, 901, 934, 1290, 1330, 1320] - } - ] -}`, - }, - { - "title": "柱状图(自定义颜色)", - "option": `{ - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "series": [ - { - "data": [ - 120, - { - "value": 200, - "itemStyle": { - "color": "#a90000" - } - }, - 150, - 80, - 70, - 110, - 130 - ], - "type": "bar", - "label": { - "show": true, - "distance": 10 - } - } - ] -}`, - }, - { - "title": "多柱状图", - "option": `{ - "title": { - "text": "Rainfall vs Evaporation", - "top": 10 - }, - "legend": { - "data": ["Rainfall", "Evaporation"] - }, - "xAxis": { - "type": "category", - "splitNumber": 12, - "data": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - }, - "series": [ - { - "name": "Rainfall", - "type": "bar", - "data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6, 162.2, 32.6, 20, 6.4, 3.3] - }, - { - "name": "Evaporation", - "type": "bar", - "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6, 2.3] - } - ] -}`, - }, - { - "title": "折柱混合", - "option": `{ - "legend": { - "data": [ - "Evaporation", - "Precipitation", - "Temperature" - ] - }, - "xAxis": { - "type": "category", - "data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - }, - "yAxis": [ - { - "type": "value", - "name": "Precipitation", - "min": 0, - "max": 250, - "interval": 50, - "axisLabel": { - "formatter": "{value} ml" - } - }, - { - "type": "value", - "name": "Temperature", - "min": 0, - "max": 25, - "interval": 5, - "axisLabel": { - "formatter": "{value} °C" - } - } - ], - "series": [ - { - "name": "Evaporation", - "type": "bar", - "itemStyle": { - "color": "#0052d9" - }, - "data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6] - }, - { - "name": "Precipitation", - "type": "bar", - "data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6] - }, - { - "name": "Temperature", - "type": "line", - "yAxisIndex": 1, - "data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3] - } - ] -}`, - }, - { - "title": "降雨量", - "option": `{ - "title": { - "text": "Rainfall", - "left": "right" - }, - "legend": { - "data": ["GZ", "SH"] - }, - "xAxis": { - "type": "category", - "splitNumber": 6, - "data": ["01-01","01-02","01-03","01-04","01-05","01-06","01-07","01-08","01-09","01-10","01-11","01-12","01-13","01-14","01-15","01-16","01-17","01-18","01-19","01-20","01-21","01-22","01-23","01-24","01-25","01-26","01-27","01-28","01-29","01-30","01-31"] - }, - "yAxis": { - "axisLabel": { - "formatter": "{value} mm" - } - }, - "series": [ - { - "type": "bar", - "data": [928,821,889,600,547,783,197,853,430,346,63,465,309,334,141,538,792,58,922,807,298,243,744,885,812,231,330,220,984,221,429] - }, - { - "type": "bar", - "data": [749,201,296,579,255,159,902,246,149,158,507,776,186,79,390,222,601,367,221,411,714,620,966,73,203,631,833,610,487,677,596] - } - ] -}`, - }, - { - "title": "饼图", - "option": `{ - "title": { - "text": "Referer of a Website" - }, - "series": [ - { - "name": "Access From", - "type": "pie", - "radius": "50%", - "label": { - "show": true - }, - "data": [ - { - "value": 1048, - "name": "Search Engine" - }, - { - "value": 735, - "name": "Direct" - }, - { - "value": 580, - "name": "Email" - }, - { - "value": 484, - "name": "Union Ads" - }, - { - "value": 300, - "name": "Video Ads" - } - ] - } - ] -}`, - }, -} - -type renderOptions struct { - theme string - width int - height int - onlyCharts bool -} - -func render(opts renderOptions) ([]byte, error) { - data := bytes.Buffer{} - for _, m := range chartOptions { - chartHTML := []byte(`
- {{svg}} -
`) - o, err := charts.ParseECharsOptions(m["option"]) - if err != nil { - return nil, err - } - if opts.width > 0 { - o.Width = opts.width - } - if opts.height > 0 { - o.Height = opts.height - } - - for _, theme := range []string{ - charts.ThemeDark, - charts.ThemeLight, - } { - o.Theme = theme - g, err := charts.New(o) - if err != nil { - return nil, err - } - buf, err := charts.ToSVG(g) - if err != nil { - return nil, err - } - buf = bytes.ReplaceAll(chartHTML, []byte("{{svg}}"), buf) - data.Write(buf) - } - } - return data.Bytes(), nil -} - func indexHandler(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { return } - query := r.URL.Query() - opts := renderOptions{ - theme: query.Get("theme"), - width: 400, - height: 200, - onlyCharts: true, - } - buf, err := render(opts) + + d, err := charts.NewLineChart(charts.LineChartOption{ + ChartOption: charts.ChartOption{ + Theme: "dark", + Padding: chart.Box{ + Left: 5, + Top: 15, + Bottom: 5, + Right: 10, + }, + XAxis: charts.XAxisOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + // BoundaryGap: charts.FalseFlag(), + }, + SeriesList: []charts.Series{ + { + Data: []charts.SeriesData{ + { + Value: 150, + }, + { + Value: 230, + }, + { + Value: 224, + }, + { + Value: 218, + }, + { + Value: 135, + }, + { + Value: 147, + }, + { + Value: 260, + }, + }, + }, + { + Data: []charts.SeriesData{ + { + Value: 220, + }, + { + Value: 182, + }, + { + Value: 191, + }, + { + Value: 234, + }, + { + Value: 290, + }, + { + Value: 330, + }, + { + Value: 310, + }, + }, + }, + { + Data: []charts.SeriesData{ + { + Value: 150, + }, + { + Value: 232, + }, + { + Value: 201, + }, + { + Value: 154, + }, + { + Value: 190, + }, + { + Value: 330, + }, + { + Value: 410, + }, + }, + }, + }, + }, + }) if err != nil { - w.WriteHeader(400) - w.Write([]byte(err.Error())) - return + panic(err) } + buf, _ := d.Bytes() + data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), buf) w.Header().Set("Content-Type", "text/html") w.Write(data) diff --git a/label.go b/label.go deleted file mode 100644 index 6d77eb2..0000000 --- a/label.go +++ /dev/null @@ -1,77 +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/dustin/go-humanize" - "github.com/wcharczuk/go-chart/v2" -) - -type LabelRenderer struct { - Options SeriesLabel - Offset chart.Box -} -type LabelValue struct { - Left int - Top int - Text string -} - -func (l LabelRenderer) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, style chart.Style, vs chart.ValuesProvider) { - if !l.Options.Show { - return - } - r.SetFontColor(style.FontColor) - r.SetFontSize(style.FontSize) - r.SetFont(style.Font) - offsetX := l.Options.Offset.Left + l.Offset.Left - offsetY := l.Options.Offset.Top + l.Offset.Top - for i := 0; i < vs.Len(); i++ { - vx, vy := vs.GetValues(i) - x := canvasBox.Left + xrange.Translate(vx) + offsetX - y := canvasBox.Bottom - yrange.Translate(vy) + offsetY - - text := humanize.CommafWithDigits(vy, 2) - // 往左移一半距离 - x -= r.MeasureText(text).Width() >> 1 - r.Text(text, x, y) - } -} - -func (l LabelRenderer) CustomizeRender(r chart.Renderer, style chart.Style, values []LabelValue) { - if !l.Options.Show { - return - } - r.SetFont(style.Font) - r.SetFontColor(style.FontColor) - r.SetFontSize(style.FontSize) - offsetX := l.Options.Offset.Left + l.Offset.Left - offsetY := l.Options.Offset.Top + l.Offset.Top - for _, value := range values { - x := value.Left + offsetX - y := value.Top + offsetY - text := value.Text - x -= r.MeasureText(text).Width() >> 1 - r.Text(text, x, y) - } -} diff --git a/legend.go b/legend.go deleted file mode 100644 index c85564f..0000000 --- a/legend.go +++ /dev/null @@ -1,249 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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 LegendOption struct { - Style chart.Style - Padding chart.Box - Left string - Right string - Top string - Bottom string - Align string - Theme string - IconDraw LegendIconDraw -} - -type LegendIconDrawOption struct { - Box chart.Box - Style chart.Style - Index int - Theme string -} - -const ( - LegendAlignLeft = "left" - LegendAlignRight = "right" -) - -type LegendIconDraw func(r chart.Renderer, opt LegendIconDrawOption) - -func DefaultLegendIconDraw(r chart.Renderer, opt LegendIconDrawOption) { - if opt.Box.IsZero() { - return - } - r.SetStrokeColor(opt.Style.GetStrokeColor()) - strokeWidth := opt.Style.GetStrokeWidth() - r.SetStrokeWidth(strokeWidth) - height := opt.Box.Bottom - opt.Box.Top - ly := opt.Box.Top - (height / 2) + 2 - r.MoveTo(opt.Box.Left, ly) - r.LineTo(opt.Box.Right, ly) - r.Stroke() - r.SetFillColor(getBackgroundColor(opt.Theme)) - r.Circle(5, (opt.Box.Left+opt.Box.Right)/2, ly) - r.FillStroke() -} - -func convertPercent(value string) float64 { - if !strings.HasSuffix(value, "%") { - return -1 - } - v, err := strconv.Atoi(strings.ReplaceAll(value, "%", "")) - if err != nil { - return -1 - } - return float64(v) / 100 -} - -func getLegendLeft(canvasWidth, legendBoxWidth int, opt LegendOption) int { - left := (canvasWidth - legendBoxWidth) / 2 - leftValue := opt.Left - if leftValue == "auto" || leftValue == "center" { - leftValue = "" - } - if leftValue == "left" { - leftValue = "0" - } - - rightValue := opt.Right - if rightValue == "auto" || leftValue == "center" { - rightValue = "" - } - if rightValue == "right" { - rightValue = "0" - } - if leftValue == "" && rightValue == "" { - return left - } - if leftValue != "" { - percent := convertPercent(leftValue) - if percent >= 0 { - return int(float64(canvasWidth) * percent) - } - v, _ := strconv.Atoi(leftValue) - return v - } - if rightValue != "" { - percent := convertPercent(rightValue) - if percent >= 0 { - return canvasWidth - legendBoxWidth - int(float64(canvasWidth)*percent) - } - v, _ := strconv.Atoi(rightValue) - return canvasWidth - legendBoxWidth - v - } - return left -} - -func getLegendTop(height, legendBoxHeight int, opt LegendOption) int { - // TODO 支持top的处理 - return 0 -} - -func NewLegendCustomize(series []chart.Series, opt LegendOption) chart.Renderable { - return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) { - legendDefaults := chart.Style{ - FontColor: getTextColor(opt.Theme), - FontSize: 8.0, - StrokeColor: chart.DefaultAxisColor, - } - - legendStyle := opt.Style.InheritFrom(chartDefaults.InheritFrom(legendDefaults)) - - r.SetFont(legendStyle.GetFont()) - r.SetFontColor(legendStyle.GetFontColor()) - r.SetFontSize(legendStyle.GetFontSize()) - - var labels []string - var lines []chart.Style - // 计算label和lines - for _, s := range series { - if !s.GetStyle().Hidden { - if _, isAnnotationSeries := s.(chart.AnnotationSeries); !isAnnotationSeries { - labels = append(labels, s.GetName()) - lines = append(lines, s.GetStyle()) - } - } - } - - var textHeight int - var textBox chart.Box - labelWidth := 0 - // 计算文本宽度与高度(取最大值) - for x := 0; x < len(labels); x++ { - if len(labels[x]) > 0 { - textBox = r.MeasureText(labels[x]) - labelWidth += textBox.Width() - textHeight = chart.MaxInt(textBox.Height(), textHeight) - } - } - - legendBoxHeight := textHeight + legendStyle.Padding.Top + legendStyle.Padding.Bottom - chartPadding := cb.Top - legendYMargin := (chartPadding - legendBoxHeight) >> 1 - - iconWidth := 25 - lineTextGap := 5 - - iconAllWidth := iconWidth * len(labels) - spaceAllWidth := (chart.DefaultMinimumTickHorizontalSpacing + lineTextGap) * (len(labels) - 1) - - legendBoxWidth := labelWidth + iconAllWidth + spaceAllWidth - - left := getLegendLeft(cb.Width(), legendBoxWidth, opt) - top := getLegendTop(cb.Height(), legendBoxHeight, opt) - - left += (opt.Padding.Left + cb.Left) - top += (opt.Padding.Top + cb.Top) - - legendBox := chart.Box{ - Left: left, - Right: left + legendBoxWidth, - Top: top, - Bottom: top + legendBoxHeight, - } - - chart.Draw.Box(r, legendBox, legendDefaults) - - r.SetFont(legendStyle.GetFont()) - r.SetFontColor(legendStyle.GetFontColor()) - r.SetFontSize(legendStyle.GetFontSize()) - - startX := legendBox.Left + legendStyle.Padding.Left - ty := top + legendYMargin + legendStyle.Padding.Top + textHeight - var label string - var x int - iconDraw := opt.IconDraw - if iconDraw == nil { - iconDraw = DefaultLegendIconDraw - } - align := opt.Align - if align == "" { - align = LegendAlignLeft - } - for index := range labels { - label = labels[index] - if len(label) > 0 { - x = startX - - // 如果图例标记靠右展示 - if align == LegendAlignRight { - textBox = r.MeasureText(label) - r.Text(label, x, ty) - x = startX + textBox.Width() + lineTextGap - } - - // 图标 - iconDraw(r, LegendIconDrawOption{ - Theme: opt.Theme, - Index: index, - Style: lines[index], - Box: chart.Box{ - Left: x, - Top: ty, - Right: x + iconWidth, - Bottom: ty + textHeight, - }, - }) - x += (iconWidth + lineTextGap) - - // 如果图例标记靠左展示 - if align == LegendAlignLeft { - textBox = r.MeasureText(label) - r.Text(label, x, ty) - x += textBox.Width() - } - - // 计算下一个legend的位置 - startX = x + chart.DefaultMinimumTickHorizontalSpacing - } - } - } -} diff --git a/legend_test.go b/legend_test.go deleted file mode 100644 index 8f21210..0000000 --- a/legend_test.go +++ /dev/null @@ -1,113 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" -) - -func TestNewLegendCustomize(t *testing.T) { - assert := assert.New(t) - - series := GetSeries([]Series{ - { - Name: "chrome", - }, - { - Name: "edge", - }, - }, chart.TickPositionBetweenTicks, "") - - tests := []struct { - align string - svg string - }{ - { - align: LegendAlignLeft, - svg: "\\nchromeedge", - }, - { - align: LegendAlignRight, - svg: "\\nchromeedge", - }, - } - - for _, tt := range tests { - r, err := chart.SVG(800, 600) - assert.Nil(err) - fn := NewLegendCustomize(series, LegendOption{ - Align: tt.align, - IconDraw: DefaultLegendIconDraw, - Padding: chart.Box{ - Left: 100, - Top: 100, - }, - }) - fn(r, chart.NewBox(11, 47, 784, 373), chart.Style{ - Font: chart.StyleTextDefaults().Font, - }) - buf := bytes.Buffer{} - err = r.Save(&buf) - assert.Nil(err) - assert.Equal(tt.svg, buf.String()) - } -} - -func TestConvertPercent(t *testing.T) { - assert := assert.New(t) - - assert.Equal(-1.0, convertPercent("12")) - - assert.Equal(0.12, convertPercent("12%")) -} - -func TestGetLegendLeft(t *testing.T) { - assert := assert.New(t) - - assert.Equal(150, getLegendLeft(500, 200, LegendOption{})) - - assert.Equal(0, getLegendLeft(500, 200, LegendOption{ - Left: "left", - })) - assert.Equal(100, getLegendLeft(500, 200, LegendOption{ - Left: "20%", - })) - assert.Equal(20, getLegendLeft(500, 200, LegendOption{ - Left: "20", - })) - - assert.Equal(300, getLegendLeft(500, 200, LegendOption{ - Right: "right", - })) - assert.Equal(200, getLegendLeft(500, 200, LegendOption{ - Right: "20%", - })) - assert.Equal(280, getLegendLeft(500, 200, LegendOption{ - Right: "20", - })) - -} diff --git a/line.go b/line.go new file mode 100644 index 0000000..e1f3583 --- /dev/null +++ b/line.go @@ -0,0 +1,105 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/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 + } + 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) + } + } + height := d.Box.Height() + d.lineTo(x, height) + x0 := points[0].X + y0 := points[0].Y + d.lineTo(x0, height) + d.lineTo(x0, y0) + r.Fill() +} + +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 new file mode 100644 index 0000000..f1eea22 --- /dev/null +++ b/line_chart.go @@ -0,0 +1,174 @@ +// 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 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{ + Parent: opt.Parent, + Width: opt.getWidth(), + Height: opt.getHeight(), + }, + PaddingOption(opt.Padding), + ) + if err != nil { + return nil, err + } + + theme := Theme{ + mode: opt.Theme, + } + // 设置背景色 + bg := opt.BackgroundColor + if bg.IsZero() { + bg = theme.GetBackgroundColor() + } + if opt.Parent == nil { + d.setBackground(opt.getWidth(), opt.getHeight(), bg) + } + + xAxisHeight, xRange, err := drawXAxis(d, &opt.XAxis, &theme) + if err != nil { + return nil, err + } + // 暂时仅支持单一yaxis + yRange, err := drawYAxis(d, &opt.ChartOption, &theme, xAxisHeight) + if err != nil { + return nil, err + } + sd, err := NewDraw(DrawOption{ + Parent: d, + }, PaddingOption(chart.Box{ + Left: YAxisWidth, + })) + if err != nil { + return nil, err + } + for i, series := range opt.SeriesList { + points := make([]Point, 0) + for j, item := range series.Data { + y := yRange.getHeight(item.Value) + points = append(points, Point{ + Y: y, + X: xRange.getWidth(float64(j)), + }) + seriesColor := theme.GetSeriesColor(i) + dotFillColor := drawing.ColorWhite + if theme.IsDark() { + dotFillColor = seriesColor + } + sd.Line(points, LineStyle{ + StrokeColor: seriesColor, + StrokeWidth: 2, + DotColor: seriesColor, + DotWidth: 2, + DotFillColor: dotFillColor, + }) + } + + } + // fmt.Println(yRange) + + return d, nil +} diff --git a/line_series_test.go b/line_series_test.go deleted file mode 100644 index 27c9371..0000000 --- a/line_series_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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 TestLineSeries(t *testing.T) { - assert := assert.New(t) - - ls := LineSeries{} - - originalRange := &chart.ContinuousRange{} - xrange := ls.getXRange(originalRange) - assert.Equal(originalRange, xrange) - - ls.TickPosition = chart.TickPositionBetweenTicks - xrange = ls.getXRange(originalRange) - value, ok := xrange.(*Range) - assert.True(ok) - assert.Equal(originalRange, &value.ContinuousRange) -} diff --git a/line_test.go b/line_test.go new file mode 100644 index 0000000..e10b806 --- /dev/null +++ b/line_test.go @@ -0,0 +1,165 @@ +// 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/range.go b/range.go deleted file mode 100644 index 4e00c60..0000000 --- a/range.go +++ /dev/null @@ -1,93 +0,0 @@ -// MIT License - -// Copyright (c) 2021 Tree Xie - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package charts - -import ( - "math" - - "github.com/wcharczuk/go-chart/v2" -) - -type Range struct { - TickPosition chart.TickPosition - chart.ContinuousRange -} - -func wrapRange(r chart.Range, tickPosition chart.TickPosition) chart.Range { - xr, ok := r.(*chart.ContinuousRange) - if !ok { - return r - } - return &Range{ - TickPosition: tickPosition, - ContinuousRange: *xr, - } -} - -// Translate maps a given value into the ContinuousRange space. -func (r Range) Translate(value float64) int { - v := r.ContinuousRange.Translate(value) - if r.TickPosition == chart.TickPositionBetweenTicks { - v -= int(float64(r.Domain) / (r.GetDelta() * 2)) - } - return v -} - -type HiddenRange struct { - chart.ContinuousRange -} - -func (r HiddenRange) GetDelta() float64 { - return 0 -} - -// Y轴使用的continuous range -// min 与max只允许设置一次 -// 如果是计算得出的max,增加20%的值并取整 -type YContinuousRange struct { - chart.ContinuousRange -} - -func (m YContinuousRange) IsZero() bool { - // 默认返回true,允许修改 - return true -} - -func (m *YContinuousRange) SetMin(min float64) { - // 如果已修改,则忽略 - if m.Min != -math.MaxFloat64 { - return - } - m.Min = min -} - -func (m *YContinuousRange) SetMax(max float64) { - // 如果已修改,则忽略 - if m.Max != math.MaxFloat64 { - return - } - // 此处为计算得来的最大值,放大20% - v := int(max * 1.2) - // TODO 是否要取整十整百 - m.Max = float64(v) -} diff --git a/series.go b/series.go deleted file mode 100644 index f645749..0000000 --- a/series.go +++ /dev/null @@ -1,135 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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 SeriesData struct { - Value float64 - Style chart.Style -} - -type Series struct { - Type string - Name string - Data []SeriesData - XValues []float64 - YAxisIndex int - Style chart.Style - Label SeriesLabel -} - -const lineStrokeWidth = 2 -const dotWith = 2 - -const ( - SeriesBar = "bar" - SeriesLine = "line" - SeriesPie = "pie" -) - -func NewSeriesDataListFromFloat(values []float64) []SeriesData { - dataList := make([]SeriesData, len(values)) - for index, value := range values { - dataList[index] = SeriesData{ - Value: value, - } - } - return dataList -} -func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) []chart.Series { - arr := make([]chart.Series, len(series)) - barCount := 0 - barIndex := 0 - for _, item := range series { - if item.Type == SeriesBar { - barCount++ - } - } - - for index, item := range series { - style := chart.Style{ - StrokeWidth: lineStrokeWidth, - StrokeColor: getSeriesColor(theme, index), - // TODO 调整为通过dot with color 生成 - DotColor: getSeriesColor(theme, index), - DotWidth: dotWith, - FontColor: getAxisColor(theme), - } - if !item.Style.StrokeColor.IsZero() { - style.StrokeColor = item.Style.StrokeColor - style.DotColor = item.Style.StrokeColor - } - pointIndexOffset := 0 - // 如果居中,需要多增加一个点 - if tickPosition == chart.TickPositionBetweenTicks { - item.Data = append([]SeriesData{ - { - Value: 0.0, - }, - }, item.Data...) - pointIndexOffset = -1 - } - yValues := make([]float64, len(item.Data)) - barCustomStyles := make([]BarSeriesCustomStyle, 0) - for i, item := range item.Data { - yValues[i] = item.Value - if !item.Style.IsZero() { - barCustomStyles = append(barCustomStyles, BarSeriesCustomStyle{ - PointIndex: i + pointIndexOffset, - Index: barIndex, - Style: item.Style, - }) - } - } - baseSeries := BaseSeries{ - Name: item.Name, - XValues: item.XValues, - Style: style, - YValues: yValues, - TickPosition: tickPosition, - YAxis: chart.YAxisSecondary, - Label: item.Label, - } - if item.YAxisIndex != 0 { - baseSeries.YAxis = chart.YAxisPrimary - } - switch item.Type { - case SeriesBar: - arr[index] = BarSeries{ - Count: barCount, - Index: barIndex, - BaseSeries: baseSeries, - CustomStyles: barCustomStyles, - } - barIndex++ - default: - arr[index] = LineSeries{ - BaseSeries: baseSeries, - } - } - } - return arr -} diff --git a/series_test.go b/series_test.go deleted file mode 100644 index 5016aab..0000000 --- a/series_test.go +++ /dev/null @@ -1,125 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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 TestNewSeriesDataListFromFloat(t *testing.T) { - assert := assert.New(t) - - assert.Equal([]SeriesData{ - { - Value: 1, - }, - { - Value: 2, - }, - }, NewSeriesDataListFromFloat([]float64{ - 1, - 2, - })) -} - -func TestGetSeries(t *testing.T) { - assert := assert.New(t) - - xValues := []float64{ - 1, - 2, - 3, - 4, - 5, - } - - barData := NewSeriesDataListFromFloat([]float64{ - 10, - 20, - 30, - 40, - 50, - }) - barData[1].Style = chart.Style{ - FillColor: AxisColorDark, - } - seriesList := GetSeries([]Series{ - { - Type: SeriesBar, - Data: barData, - XValues: xValues, - YAxisIndex: 1, - }, - { - Data: NewSeriesDataListFromFloat([]float64{ - 11, - 21, - 31, - 41, - 51, - }), - XValues: xValues, - }, - }, chart.TickPositionBetweenTicks, "") - - assert.Equal(seriesList[0].GetYAxis(), chart.YAxisPrimary) - assert.Equal(seriesList[1].GetYAxis(), chart.YAxisSecondary) - - barSeries, ok := seriesList[0].(BarSeries) - assert.True(ok) - // 居中前置多插入一个点 - assert.Equal([]float64{ - 0, - 10, - 20, - 30, - 40, - 50, - }, barSeries.YValues) - assert.Equal(xValues, barSeries.XValues) - assert.Equal(1, barSeries.Count) - assert.Equal(0, barSeries.Index) - assert.Equal([]BarSeriesCustomStyle{ - { - PointIndex: 1, - Index: 0, - Style: barData[1].Style, - }, - }, barSeries.CustomStyles) - - lineSeries, ok := seriesList[1].(LineSeries) - assert.True(ok) - // 居中前置多插入一个点 - assert.Equal([]float64{ - 0, - 11, - 21, - 31, - 41, - 51, - }, lineSeries.YValues) - assert.Equal(xValues, lineSeries.XValues) -} diff --git a/theme.go b/theme.go index 63e000a..488e11c 100644 --- a/theme.go +++ b/theme.go @@ -1,6 +1,6 @@ // MIT License -// Copyright (c) 2021 Tree Xie +// 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 @@ -22,201 +22,101 @@ package charts -import ( - "regexp" - "strconv" - "strings" +import "github.com/wcharczuk/go-chart/v2/drawing" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) +const ThemeDark = "dark" +const ThemeLight = "light" -var hiddenColor = drawing.Color{R: 255, G: 255, B: 255, A: 0} - -var AxisColorLight = drawing.Color{ - R: 110, - G: 112, - B: 121, - A: 255, -} -var AxisColorDark = drawing.Color{ - R: 185, - G: 184, - B: 206, - A: 255, +type Theme struct { + mode string } -var GridColorDark = drawing.Color{ - R: 72, - G: 71, - B: 83, - A: 255, +func (t *Theme) IsDark() bool { + return t.mode == ThemeDark } -var GridColorLight = drawing.Color{ - R: 224, - G: 230, - B: 241, - A: 255, -} - -var BackgroundColorDark = drawing.Color{ - R: 16, - G: 12, - B: 42, - A: 255, -} - -var TextColorDark = drawing.Color{ - R: 204, - G: 204, - B: 204, - A: 255, -} - -func getAxisColor(theme string) drawing.Color { - if theme == ThemeDark { - return AxisColorDark - } - return AxisColorLight -} - -func getGridColor(theme string) drawing.Color { - if theme == ThemeDark { - return GridColorDark - } - return GridColorLight -} - -var SeriesColorsLight = []drawing.Color{ - { - R: 84, - G: 112, - B: 198, - A: 255, - }, - { - R: 145, - G: 204, - B: 117, - A: 255, - }, - { - R: 250, - G: 200, - B: 88, - A: 255, - }, - { - R: 238, - G: 102, - B: 102, - A: 255, - }, - { - R: 115, - G: 192, - B: 222, - A: 255, - }, -} - -func getBackgroundColor(theme string) drawing.Color { - if theme == ThemeDark { - return BackgroundColorDark - } - return chart.DefaultBackgroundColor -} - -func getTextColor(theme string) drawing.Color { - if theme == ThemeDark { - return TextColorDark - } - return chart.DefaultTextColor -} - -type ThemeColorPalette struct { - Theme string -} - -type PieThemeColorPalette struct { - ThemeColorPalette -} - -func (tp PieThemeColorPalette) TextColor() drawing.Color { - return getTextColor("") -} - -func (tp ThemeColorPalette) BackgroundColor() drawing.Color { - return getBackgroundColor(tp.Theme) -} - -func (tp ThemeColorPalette) BackgroundStrokeColor() drawing.Color { - return chart.DefaultBackgroundStrokeColor -} - -func (tp ThemeColorPalette) CanvasColor() drawing.Color { - if tp.Theme == ThemeDark { - return BackgroundColorDark - } - return chart.DefaultCanvasColor -} - -func (tp ThemeColorPalette) CanvasStrokeColor() drawing.Color { - return chart.DefaultCanvasStrokeColor -} - -func (tp ThemeColorPalette) AxisStrokeColor() drawing.Color { - if tp.Theme == ThemeDark { - return BackgroundColorDark - } - return chart.DefaultAxisColor -} - -func (tp ThemeColorPalette) TextColor() drawing.Color { - return getTextColor(tp.Theme) -} - -func (tp ThemeColorPalette) GetSeriesColor(index int) drawing.Color { - return getSeriesColor(tp.Theme, index) -} - -func getSeriesColor(theme string, index int) drawing.Color { - return SeriesColorsLight[index%len(SeriesColorsLight)] -} - -func parseColor(color string) drawing.Color { - c := drawing.Color{} - if color == "" { - return c - } - if strings.HasPrefix(color, "#") { - return drawing.ColorFromHex(color[1:]) - } - reg := regexp.MustCompile(`\((\S+)\)`) - result := reg.FindAllStringSubmatch(color, 1) - if len(result) == 0 || len(result[0]) != 2 { - return c - } - arr := strings.Split(result[0][1], ",") - if len(arr) < 3 { - return c - } - // 设置默认为255 - c.A = 255 - for index, v := range arr { - value, _ := strconv.Atoi(strings.TrimSpace(v)) - ui8 := uint8(value) - switch index { - case 0: - c.R = ui8 - case 1: - c.G = ui8 - case 2: - c.B = ui8 - default: - c.A = ui8 +func (t *Theme) GetAxisStrokeColor() drawing.Color { + if t.IsDark() { + return drawing.Color{ + R: 185, + G: 184, + B: 206, + A: 255, } } - return c + return drawing.Color{ + R: 110, + G: 112, + B: 121, + A: 255, + } +} + +func (t *Theme) GetAxisSplitLineColor() drawing.Color { + if t.IsDark() { + return drawing.Color{ + R: 72, + G: 71, + B: 83, + A: 255, + } + } + return drawing.Color{ + R: 224, + G: 230, + B: 242, + A: 255, + } +} + +func (t *Theme) GetSeriesColor(index int) drawing.Color { + colors := t.GetSeriesColors() + return colors[index%len(colors)] +} + +func (t *Theme) GetSeriesColors() []drawing.Color { + return []drawing.Color{ + { + R: 84, + G: 112, + B: 198, + A: 255, + }, + { + R: 145, + G: 204, + B: 117, + A: 255, + }, + { + R: 250, + G: 200, + B: 88, + A: 255, + }, + { + R: 238, + G: 102, + B: 102, + A: 255, + }, + { + R: 115, + G: 192, + B: 222, + A: 255, + }, + } +} + +func (t *Theme) GetBackgroundColor() drawing.Color { + if t.IsDark() { + return drawing.Color{ + R: 16, + G: 12, + B: 42, + A: 255, + } + } + return drawing.ColorWhite } diff --git a/theme_test.go b/theme_test.go deleted file mode 100644 index a25a2db..0000000 --- a/theme_test.go +++ /dev/null @@ -1,122 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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 TestThemeColors(t *testing.T) { - assert := assert.New(t) - - assert.Equal(AxisColorDark, getAxisColor(ThemeDark)) - assert.Equal(AxisColorLight, getAxisColor("")) - - assert.Equal(GridColorDark, getGridColor(ThemeDark)) - assert.Equal(GridColorLight, getGridColor("")) - - assert.Equal(BackgroundColorDark, getBackgroundColor(ThemeDark)) - assert.Equal(chart.DefaultBackgroundColor, getBackgroundColor("")) - - assert.Equal(TextColorDark, getTextColor(ThemeDark)) - assert.Equal(chart.DefaultTextColor, getTextColor("")) -} - -func TestThemeColorPalette(t *testing.T) { - assert := assert.New(t) - - dark := ThemeColorPalette{ - Theme: ThemeDark, - } - assert.Equal(BackgroundColorDark, dark.BackgroundColor()) - assert.Equal(chart.DefaultBackgroundStrokeColor, dark.BackgroundStrokeColor()) - assert.Equal(BackgroundColorDark, dark.CanvasColor()) - assert.Equal(chart.DefaultCanvasStrokeColor, dark.CanvasStrokeColor()) - assert.Equal(BackgroundColorDark, dark.AxisStrokeColor()) - assert.Equal(TextColorDark, dark.TextColor()) - // series 使用统一的color - assert.Equal(SeriesColorsLight[0], dark.GetSeriesColor(0)) - - light := ThemeColorPalette{} - assert.Equal(chart.DefaultBackgroundColor, light.BackgroundColor()) - assert.Equal(chart.DefaultBackgroundStrokeColor, light.BackgroundStrokeColor()) - assert.Equal(chart.DefaultCanvasColor, light.CanvasColor()) - assert.Equal(chart.DefaultCanvasStrokeColor, light.CanvasStrokeColor()) - assert.Equal(chart.DefaultAxisColor, light.AxisStrokeColor()) - assert.Equal(chart.DefaultTextColor, light.TextColor()) - // series 使用统一的color - assert.Equal(SeriesColorsLight[0], light.GetSeriesColor(0)) -} - -func TestPieThemeColorPalette(t *testing.T) { - assert := assert.New(t) - p := PieThemeColorPalette{} - - // pie无认哪种theme,文本的颜色都一样 - assert.Equal(chart.DefaultTextColor, p.TextColor()) - p.Theme = ThemeDark - assert.Equal(chart.DefaultTextColor, p.TextColor()) -} - -func TestParseColor(t *testing.T) { - assert := assert.New(t) - - c := parseColor("") - assert.True(c.IsZero()) - - c = parseColor("#333") - assert.Equal(drawing.Color{ - R: 51, - G: 51, - B: 51, - A: 255, - }, c) - - c = parseColor("#313233") - assert.Equal(drawing.Color{ - R: 49, - G: 50, - B: 51, - A: 255, - }, c) - - c = parseColor("rgb(31,32,33)") - assert.Equal(drawing.Color{ - R: 31, - G: 32, - B: 33, - A: 255, - }, c) - - c = parseColor("rgba(50,51,52,250)") - assert.Equal(drawing.Color{ - R: 50, - G: 51, - B: 52, - A: 250, - }, c) -} diff --git a/title.go b/title.go deleted file mode 100644 index 228b2c0..0000000 --- a/title.go +++ /dev/null @@ -1,103 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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 titleMeasureOption struct { - width int - height int - text string -} - -func NewTitleCustomize(title Title) chart.Renderable { - return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) { - if len(title.Text) == 0 || title.Style.Hidden { - return - } - font := title.Font - if font == nil { - font, _ = chart.GetDefaultFont() - } - r.SetFont(font) - r.SetFontColor(title.Style.FontColor) - titleFontSize := title.Style.GetFontSize(chart.DefaultTitleFontSize) - r.SetFontSize(titleFontSize) - - arr := strings.Split(title.Text, "\n") - textWidth := 0 - textHeight := 0 - measureOptions := make([]titleMeasureOption, len(arr)) - for index, str := range arr { - textBox := r.MeasureText(str) - - w := textBox.Width() - h := textBox.Height() - if w > textWidth { - textWidth = w - } - if h > textHeight { - textHeight = h - } - measureOptions[index] = titleMeasureOption{ - text: str, - width: w, - height: h, - } - } - - titleX := 0 - switch title.Left { - case "right": - titleX = cb.Left + cb.Width() - textWidth - case "center": - titleX = cb.Left + cb.Width()>>1 - (textWidth >> 1) - default: - if strings.HasSuffix(title.Left, "%") { - value, _ := strconv.Atoi(strings.ReplaceAll(title.Left, "%", "")) - titleX = cb.Left + cb.Width()*value/100 - } else { - value, _ := strconv.Atoi(title.Left) - titleX = cb.Left + value - } - } - - titleY := cb.Top + title.Style.Padding.GetTop(chart.DefaultTitleTop) + (textHeight >> 1) - // TOP 暂只支持数值 - if title.Top != "" { - value, _ := strconv.Atoi(title.Top) - titleY += value - } - - for _, item := range measureOptions { - x := titleX + (textWidth-item.width)>>1 - r.Text(item.text, x, titleY) - titleY += textHeight - } - } -} diff --git a/title_test.go b/title_test.go deleted file mode 100644 index 0fe8256..0000000 --- a/title_test.go +++ /dev/null @@ -1,85 +0,0 @@ -// MIT License - -// Copyright (c) 2021 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" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" - "github.com/wcharczuk/go-chart/v2/drawing" -) - -func TestTitleCustomize(t *testing.T) { - assert := assert.New(t) - tests := []struct { - title Title - svg string - }{ - // 单行标题 - { - title: Title{ - Text: "Hello World!", - Style: chart.Style{ - FontColor: drawing.ColorBlack, - }, - }, - svg: "\\nHello World!", - }, - // 多行标题,靠右 - { - title: Title{ - Text: "Hello World!\nHello World", - Style: chart.Style{ - FontColor: drawing.ColorBlack, - }, - Left: "right", - }, - svg: "\\nHello World!Hello World", - }, - // 标题居中 - { - title: Title{ - Text: "Hello World!", - Style: chart.Style{ - FontColor: drawing.ColorBlack, - }, - Left: "center", - }, - svg: "\\nHello World!", - }, - } - for _, tt := range tests { - r, err := chart.SVG(800, 600) - assert.Nil(err) - fn := NewTitleCustomize(tt.title) - fn(r, chart.NewBox(50, 50, 600, 400), chart.Style{ - Font: chart.StyleTextDefaults().Font, - }) - buf := bytes.Buffer{} - err = r.Save(&buf) - assert.Nil(err) - assert.Equal(tt.svg, buf.String()) - } -} diff --git a/util.go b/util.go index 81c7c04..11fc066 100644 --- a/util.go +++ b/util.go @@ -24,6 +24,16 @@ package charts import "github.com/wcharczuk/go-chart/v2" +func TrueFlag() *bool { + t := true + return &t +} + +func FalseFlag() *bool { + f := false + return &f +} + func getDefaultInt(value, defaultValue int) int { if value == 0 { return defaultValue diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..983efbc --- /dev/null +++ b/util_test.go @@ -0,0 +1,121 @@ +// 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 TestGetDefaultInt(t *testing.T) { + assert := assert.New(t) + + assert.Equal(1, getDefaultInt(0, 1)) + assert.Equal(10, getDefaultInt(10, 1)) +} + +func TestAutoDivide(t *testing.T) { + assert := assert.New(t) + + assert.Equal([]int{ + 0, + 86, + 172, + 258, + 344, + 430, + 515, + 600, + }, autoDivide(600, 7)) +} + +func TestMaxInt(t *testing.T) { + assert := assert.New(t) + + assert.Equal(5, maxInt(1, 3, 5, 2)) +} + +func TestMeasureTextMaxWidthHeight(t *testing.T) { + assert := assert.New(t) + r, err := chart.SVG(400, 300) + assert.Nil(err) + style := chart.Style{ + FontSize: 10, + } + style.Font, _ = chart.GetDefaultFont() + style.WriteToRenderer(r) + + maxWidth, maxHeight := measureTextMaxWidthHeight([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, r) + assert.Equal(26, maxWidth) + assert.Equal(12, maxHeight) +} + +func TestReverseSlice(t *testing.T) { + assert := assert.New(t) + + arr := []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + } + reverseStringSlice(arr) + assert.Equal([]string{ + "Sun", + "Sat", + "Fri", + "Thu", + "Wed", + "Tue", + "Mon", + }, arr) + + numbers := []int{ + 1, + 3, + 5, + 7, + 9, + } + reverseIntSlice(numbers) + assert.Equal([]int{ + 9, + 7, + 5, + 3, + 1, + }, numbers) +}