diff --git a/assets/go-charts.png b/assets/go-charts.png index f556abf..12d0cad 100644 Binary files a/assets/go-charts.png and b/assets/go-charts.png differ diff --git a/assets/go-line-chart.png b/assets/go-line-chart.png new file mode 100644 index 0000000..71c9eb1 Binary files /dev/null and b/assets/go-line-chart.png differ diff --git a/axis.go b/axis.go index c632815..46292e4 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 @@ -25,184 +25,444 @@ 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 AxisOption struct { + // The boundary gap on both sides of a coordinate axis. + // Nil or *true means the center part of two axis ticks + BoundaryGap *bool + // The flag for show axis, set this to *false will hide axis + Show *bool + // The position of axis, it can be 'left', 'top', 'right' or 'bottom' + Position string + // Number of segments that the axis is split into. Note that this number serves only as a recommendation. + SplitNumber int + ClassName string + // The line color of axis + StrokeColor drawing.Color + // The line width + StrokeWidth float64 + // The length of the axis tick + TickLength int + // The flag for show axis tick, set this to *false will hide axis tick + TickShow *bool + // The margin value of label + LabelMargin int + // The font size of label + FontSize float64 + // The font of label + Font *truetype.Font + // The color of label + FontColor drawing.Color + // The flag for show axis split line, set this to true will show axis split line + SplitLineShow bool + // The color of split line + SplitLineColor drawing.Color } -const axisStrokeWidth = 1 +type axis struct { + d *Draw + data *AxisDataList + option *AxisOption +} +type axisMeasurement struct { + Width int + Height int +} -func maxInt(values ...int) int { - result := 0 - for _, v := range values { - if v > result { - result = v +// NewAxis creates a new axis with data and style options +func NewAxis(d *Draw, data AxisDataList, option AxisOption) *axis { + return &axis{ + d: d, + data: &data, + option: &option, + } + +} + +// GetLabelMargin returns the label margin value +func (as *AxisOption) GetLabelMargin() int { + return getDefaultInt(as.LabelMargin, 8) +} + +// GetTickLength returns the tick length value +func (as *AxisOption) GetTickLength() int { + return getDefaultInt(as.TickLength, 5) +} + +// Style returns the style of axis +func (as *AxisOption) Style(f *truetype.Font) chart.Style { + s := chart.Style{ + ClassName: as.ClassName, + StrokeColor: as.StrokeColor, + StrokeWidth: as.StrokeWidth, + FontSize: as.FontSize, + FontColor: as.FontColor, + Font: as.Font, + } + if s.FontSize == 0 { + s.FontSize = chart.DefaultFontSize + } + if s.Font == nil { + s.Font = f + } + return s +} + +type AxisData struct { + // The text value of axis + Text string +} +type AxisDataList []AxisData + +// TextList returns the text list of axis data +func (l AxisDataList) TextList() []string { + textList := make([]string, len(l)) + for index, item := range l { + textList[index] = item.Text + } + return textList +} + +type axisRenderOption struct { + textMaxWith int + textMaxHeight int + boundaryGap bool + unitCount int + modValue int +} + +// NewAxisDataListFromStringList creates a new axis data list from string list +func NewAxisDataListFromStringList(textList []string) AxisDataList { + list := make(AxisDataList, len(textList)) + for index, text := range textList { + list[index] = AxisData{ + Text: text, } } - return result + return list } -// 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...) - } - - 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++ - } - unitSize := minUnitSize - // 尽可能选择一格展示更多的块 - for i := minUnitSize; i < 2*minUnitSize; i++ { - if originalSize%i == 0 { - unitSize = i - } - } - - 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 -} - -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 (a *axis) axisLabel(renderOpt *axisRenderOption) { + option := a.option + data := *a.data + d := a.d + if option.FontColor.IsZero() || len(data) == 0 { return } - if option.Formater != nil { - yAxis.ValueFormatter = option.Formater + r := d.Render + + s := option.Style(d.Font) + s.GetTextOptions().WriteTextOptionsToRenderer(r) + + width := d.Box.Width() + height := d.Box.Height() + textList := data.TextList() + count := len(textList) + + boundaryGap := renderOpt.boundaryGap + if !boundaryGap { + count-- + } + + unitCount := renderOpt.unitCount + modValue := renderOpt.modValue + labelMargin := option.GetLabelMargin() + + // 轴线 + labelHeight := labelMargin + renderOpt.textMaxHeight + labelWidth := labelMargin + renderOpt.textMaxWith + + // 坐标轴文本 + position := option.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 - renderOpt.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() { + if unitCount != 0 && index%unitCount != modValue { + continue + } + x := values[index] + leftOffset := 0 + b := r.MeasureText(text) + if boundaryGap { + width := values[index+1] - x + leftOffset = (width - b.Width()) >> 1 + } else { + // 左移文本长度 + leftOffset = -b.Width() >> 1 + } + d.text(text, x+leftOffset, y0) + } } } -// 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 (a *axis) axisLine(renderOpt *axisRenderOption) { + d := a.d + r := d.Render + option := a.option + s := option.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 := option.GetLabelMargin() + + // 轴线 + labelHeight := labelMargin + renderOpt.textMaxHeight + labelWidth := labelMargin + renderOpt.textMaxWith + tickLength := option.GetTickLength() + switch option.Position { + case PositionLeft: + x0 = tickLength + labelWidth + x1 = x0 + y0 = 0 + y1 = height + case PositionRight: + x0 = width - labelWidth + x1 = x0 + y0 = 0 + y1 = height + case PositionTop: + 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 (a *axis) axisTick(renderOpt *axisRenderOption) { + d := a.d + r := d.Render + + option := a.option + s := option.Style(d.Font) + s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) + + width := d.Box.Width() + height := d.Box.Height() + data := *a.data + tickCount := len(data) + if tickCount == 0 { + return + } + if !renderOpt.boundaryGap { + tickCount-- + } + labelMargin := option.GetLabelMargin() + tickShow := true + if isFalse(option.TickShow) { + tickShow = false + } + unitCount := renderOpt.unitCount + + tickLengthValue := option.GetTickLength() + labelHeight := labelMargin + renderOpt.textMaxHeight + labelWidth := labelMargin + renderOpt.textMaxWith + position := option.Position + switch position { + case PositionLeft: + fallthrough + case PositionRight: + values := autoDivide(height, tickCount) + // 左右仅是x0的位置不一样 + x0 := width - labelWidth + if option.Position == PositionLeft { + x0 = labelWidth + } + if tickShow { + for _, v := range values { + x := x0 + y := v + d.moveTo(x, y) + d.lineTo(x+tickLengthValue, y) + r.Stroke() + } + } + // 辅助线 + if option.SplitLineShow && !option.SplitLineColor.IsZero() { + r.SetStrokeColor(option.SplitLineColor) + splitLineWidth := width - labelWidth - tickLengthValue + x0 = labelWidth + tickLengthValue + if position == PositionRight { + 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 + } + if tickShow { + for index, v := range values { + if index%unitCount != 0 { + continue + } + x := v + y := y0 + d.moveTo(x, y-tickLengthValue) + d.lineTo(x, y) + r.Stroke() + } + } + // 辅助线 + if option.SplitLineShow && !option.SplitLineColor.IsZero() { + r.SetStrokeColor(option.SplitLineColor) + y0 = 0 + splitLineHeight := height - labelHeight - tickLengthValue + if position == PositionTop { + y0 = labelHeight + splitLineHeight = height - labelHeight + } + + for index, v := range values { + if index%unitCount != 0 { + continue + } + x := v + y := y0 + + d.moveTo(x, y) + d.lineTo(x, y0+splitLineHeight) + r.Stroke() + } + } + } +} + +func (a *axis) measureTextMaxWidthHeight() (int, int) { + d := a.d + r := d.Render + s := a.option.Style(d.Font) + data := a.data + s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) + s.GetTextOptions().WriteTextOptionsToRenderer(r) + return measureTextMaxWidthHeight(data.TextList(), r) +} + +// measure returns the measurement of axis. +// Width will be textMaxWidth + labelMargin + tickLength for position left or right. +// Height will be textMaxHeight + labelMargin + tickLength for position top or bottom. +func (a *axis) measure() axisMeasurement { + option := a.option + value := option.GetLabelMargin() + option.GetTickLength() + textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight() + info := axisMeasurement{} + if option.Position == PositionLeft || + option.Position == PositionRight { + info.Width = textMaxWidth + value + } else { + info.Height = textMaxHeight + value + } + return info +} + +// Render renders the axis for chart +func (a *axis) Render() { + option := a.option + if isFalse(option.Show) { + return + } + textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight() + opt := &axisRenderOption{ + textMaxWith: textMaxWidth, + textMaxHeight: textMaxHeight, + boundaryGap: true, + } + if isFalse(option.BoundaryGap) { + opt.boundaryGap = false + } + + unitCount := chart.MaxInt(option.SplitNumber, 1) + width := a.d.Box.Width() + textList := a.data.TextList() + count := len(textList) + + position := option.Position + switch position { + case PositionLeft: + fallthrough + case PositionRight: + default: + maxCount := width / (opt.textMaxWith + 10) + // 可以显示所有 + if maxCount >= count { + unitCount = 1 + } else if maxCount < count/unitCount { + unitCount = int(math.Ceil(float64(count) / float64(maxCount))) + } + } + + boundaryGap := opt.boundaryGap + modValue := 0 + if boundaryGap && unitCount > 1 { + // 如果是居中,unit count需要设置为奇数 + if unitCount%2 == 0 { + unitCount++ + } + modValue = unitCount / 2 + } + opt.modValue = modValue + opt.unitCount = unitCount + + // 坐标轴线 + a.axisLine(opt) + a.axisTick(opt) + // 坐标文本 + a.axisLabel(opt) } diff --git a/axis_test.go b/axis_test.go index 43779e9..37c8314 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,237 @@ 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 TestAxisOption(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 := AxisOption{} - 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, + axisData := NewAxisDataListFromStringList([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", }) + getDefaultOption := func() AxisOption { + return AxisOption{ + StrokeColor: drawing.ColorBlack, + StrokeWidth: 1, + FontColor: drawing.ColorBlack, + Show: TrueFlag(), + TickShow: TrueFlag(), + SplitLineShow: true, + SplitLineColor: drawing.ColorBlack.WithAlpha(60), + } + } + tests := []struct { + newOption func() AxisOption + newData func() AxisDataList + result string + }{ + // 文本按起始位置展示 + // axis位于bottom + { + newOption: func() AxisOption { + opt := getDefaultOption() + opt.BoundaryGap = FalseFlag() + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本居中展示 + // axis位于bottom + { + newOption: func() AxisOption { + opt := getDefaultOption() + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本按起始位置展示 + // axis位于top + { + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionTop + opt.BoundaryGap = FalseFlag() + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本居中展示 + // axis位于top + { + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionTop + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本按起始位置展示 + // axis位于left + { + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionLeft + opt.BoundaryGap = FalseFlag() + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本居中展示 + // axis位于left + { + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionLeft + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本按起始位置展示 + // axis位于right + { + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionRight + opt.BoundaryGap = FalseFlag() + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // 文本居中展示 + // axis位于right + { + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionRight + return opt + }, + result: "\\nMonTueWedThuFriSatSun", + }, + // text较多,仅展示部分 + { + newOption: func() AxisOption { + opt := getDefaultOption() + opt.Position = PositionBottom + return opt + }, + newData: func() AxisDataList { + return NewAxisDataListFromStringList([]string{ + "01-01", + "01-02", + "01-03", + "01-04", + "01-05", + "01-06", + "01-07", + "01-08", + "01-09", + "01-10", + "01-11", + "01-12", + "01-13", + "01-14", + "01-15", + "01-16", + "01-17", + "01-18", + "01-19", + "01-20", + "01-21", + }) + }, + result: "\\n01-0201-0501-0801-1101-1401-1701-20", + }, + } + for _, tt := range tests { + d, err := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }, PaddingOption(chart.Box{ + Left: 5, + Top: 5, + Right: 5, + Bottom: 5, + })) + assert.Nil(err) + style := tt.newOption() + data := axisData + if tt.newData != nil { + data = tt.newData() + } + NewAxis(d, data, style).Render() - assert.True(yAxis.GridMajorStyle.Hidden) - assert.True(yAxis.GridMajorStyle.Hidden) - assert.True(yAxis.Style.Hidden) - - // 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 := NewAxis(d, data, AxisOption{ + FontSize: 12, + Font: f, + Position: PositionLeft, + }).measure().Width + assert.Equal(44, width) + + height := NewAxis(d, data, AxisOption{ + FontSize: 12, + Font: f, + Position: PositionTop, + }).measure().Height + assert.Equal(28, height) } diff --git a/line_series.go b/bar.go similarity index 61% rename from line_series.go rename to bar.go index 93a1479..1090f6b 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,35 @@ 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) +// Bar renders bar for chart +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_chart.go b/bar_chart.go new file mode 100644 index 0000000..e008baf --- /dev/null +++ b/bar_chart.go @@ -0,0 +1,163 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" +) + +type barChartOption struct { + // The series list fo bar chart + SeriesList SeriesList + // The theme + Theme string + // The font + Font *truetype.Font +} + +func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointRenderOption, error) { + d, err := NewDraw(DrawOption{ + Parent: result.d, + }, PaddingOption(chart.Box{ + Top: result.titleBox.Height(), + // TODO 后续考虑是否需要根据左侧是否展示y轴再生成对应的left + Left: YAxisWidth, + })) + if err != nil { + return nil, err + } + xRange := result.xRange + x0, x1 := xRange.GetRange(0) + width := int(x1 - x0) + // 每一块之间的margin + margin := 10 + // 每一个bar之间的margin + barMargin := 5 + if width < 20 { + margin = 2 + barMargin = 2 + } else if width < 50 { + margin = 5 + barMargin = 3 + } + + seriesCount := len(opt.SeriesList) + // 总的宽度-两个margin-(总数-1)的barMargin + barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / len(opt.SeriesList) + + barMaxHeight := result.getYRange(0).Size + theme := NewTheme(opt.Theme) + + seriesNames := opt.SeriesList.Names() + + r := d.Render + + markPointRenderOptions := make([]markPointRenderOption, 0) + + for i, s := range opt.SeriesList { + // 由于series是for range,为同一个数据,因此需要clone + // 后续需要使用,如mark point + series := s + yRange := result.getYRange(series.YAxisIndex) + points := make([]Point, len(series.Data)) + index := series.index + if index == 0 { + index = i + } + seriesColor := theme.GetSeriesColor(index) + // mark line + markLineRender(markLineRenderOption{ + Draw: d, + FillColor: seriesColor, + FontColor: theme.GetTextColor(), + StrokeColor: seriesColor, + Font: opt.Font, + Series: &series, + Range: yRange, + }) + divideValues := xRange.AutoDivide() + for j, item := range series.Data { + if j >= len(divideValues) { + continue + } + x := divideValues[j] + x += margin + if i != 0 { + x += i * (barWidth + barMargin) + } + + h := int(yRange.getHeight(item.Value)) + fillColor := seriesColor + if !item.Style.FillColor.IsZero() { + fillColor = item.Style.FillColor + } + top := barMaxHeight - h + d.Bar(chart.Box{ + Top: top, + Left: x, + Right: x + barWidth, + Bottom: barMaxHeight - 1, + }, BarStyle{ + FillColor: fillColor, + }) + // 用于生成marker point + points[j] = Point{ + // 居中的位置 + X: x + barWidth>>1, + Y: top, + } + // 如果label不需要展示,则返回 + if !series.Label.Show { + continue + } + distance := series.Label.Distance + if distance == 0 { + distance = 5 + } + text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) + labelStyle := chart.Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + } + if !series.Label.Color.IsZero() { + labelStyle.FontColor = series.Label.Color + } + labelStyle.GetTextOptions().WriteToRenderer(r) + textBox := r.MeasureText(text) + d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance) + } + + // 生成mark point的参数 + markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{ + Draw: d, + FillColor: seriesColor, + Font: opt.Font, + Points: points, + Series: &series, + }) + } + + return markPointRenderOptions, nil +} diff --git a/bar_chart_test.go b/bar_chart_test.go new file mode 100644 index 0000000..f10a1bc --- /dev/null +++ b/bar_chart_test.go @@ -0,0 +1,131 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func TestBarChartRender(t *testing.T) { + assert := assert.New(t) + + width := 400 + height := 300 + d, err := NewDraw(DrawOption{ + Width: width, + Height: height, + }) + assert.Nil(err) + + result := basicRenderResult{ + xRange: &Range{ + Min: 0, + Max: 4, + divideCount: 4, + Size: width, + Boundary: true, + }, + yRangeList: []*Range{ + { + divideCount: 6, + Max: 100, + Min: 0, + Size: height, + }, + }, + d: d, + } + f, _ := chart.GetDefaultFont() + + markPointOptions, err := barChartRender(barChartOption{ + Font: f, + SeriesList: SeriesList{ + { + Label: SeriesLabel{ + Show: true, + Color: drawing.ColorBlue, + }, + MarkLine: NewMarkLine( + SeriesMarkDataTypeMin, + ), + Data: []SeriesData{ + { + Value: 20, + }, + { + Value: 60, + Style: chart.Style{ + FillColor: drawing.ColorRed, + }, + }, + { + Value: 90, + }, + }, + }, + NewSeriesFromValues([]float64{ + 80, + 30, + 70, + }), + }, + }, &result) + assert.Nil(err) + assert.Equal(2, len(markPointOptions)) + assert.Equal([]Point{ + { + X: 28, + Y: 240, + }, + { + X: 128, + Y: 120, + }, + { + X: 228, + Y: 30, + }, + }, markPointOptions[0].Points) + assert.Equal([]Point{ + { + X: 70, + Y: 60, + }, + { + X: 170, + Y: 210, + }, + { + X: 270, + Y: 90, + }, + }, markPointOptions[1].Points) + + data, err := d.Bytes() + assert.Nil(err) + assert.Equal("\\n20206090", string(data)) +} diff --git a/bar_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/bar_test.go b/bar_test.go new file mode 100644 index 0000000..01b6d3c --- /dev/null +++ b/bar_test.go @@ -0,0 +1,78 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func TestBarStyle(t *testing.T) { + assert := assert.New(t) + + bs := BarStyle{ + ClassName: "test", + StrokeDashArray: []float64{ + 1.0, + }, + FillColor: drawing.ColorBlack, + } + + assert.Equal(chart.Style{ + ClassName: "test", + StrokeDashArray: []float64{ + 1.0, + }, + StrokeWidth: 1, + FillColor: drawing.ColorBlack, + StrokeColor: drawing.ColorBlack, + }, bs.Style()) +} + +func TestDrawBar(t *testing.T) { + assert := assert.New(t) + d, err := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }, PaddingOption(chart.Box{ + Left: 10, + Top: 20, + Right: 30, + Bottom: 40, + })) + assert.Nil(err) + d.Bar(chart.Box{ + Left: 0, + Top: 0, + Right: 20, + Bottom: 200, + }, BarStyle{ + FillColor: drawing.ColorBlack, + }) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal("\\n", string(data)) +} diff --git a/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..5178b04 --- /dev/null +++ b/chart.go @@ -0,0 +1,450 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "errors" + "math" + "strings" + + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +const ( + ChartTypeLine = "line" + ChartTypeBar = "bar" + ChartTypePie = "pie" +) + +const ( + ChartOutputSVG = "svg" + ChartOutputPNG = "png" +) + +type Point struct { + X int + Y int +} + +const labelFontSize = 10 + +var defaultChartWidth = 600 +var defaultChartHeight = 400 + +type ChartOption struct { + // The output type of chart, "svg" or "png", default value is "svg" + Type string + // The font family, which should be installed first + FontFamily string + // The font of chart, the default font is "roboto" + Font *truetype.Font + // The theme of chart, "light" and "dark". + // The default theme is "light" + Theme string + // The title option + Title TitleOption + // The legend option + Legend LegendOption + // The x axis option + XAxis XAxisOption + // The y axis option list + YAxisList []YAxisOption + // The width of chart, default width is 600 + Width int + // The height of chart, default height is 400 + Height int + Parent *Draw + // The padding for chart, default padding is [20, 10, 10, 10] + Padding chart.Box + // The canvas box for chart + Box chart.Box + // The series list + SeriesList SeriesList + // The background color of chart + BackgroundColor drawing.Color + // The child charts + Children []ChartOption +} + +// FillDefault fills the default value for chart option +func (o *ChartOption) FillDefault(theme string) { + t := NewTheme(theme) + // 如果为空,初始化 + yAxisCount := 1 + for _, series := range o.SeriesList { + if series.YAxisIndex >= yAxisCount { + yAxisCount++ + } + } + yAxisList := make([]YAxisOption, yAxisCount) + copy(yAxisList, o.YAxisList) + o.YAxisList = yAxisList + + if o.Font == nil { + o.Font, _ = chart.GetDefaultFont() + } + if o.BackgroundColor.IsZero() { + o.BackgroundColor = t.GetBackgroundColor() + } + if o.Padding.IsZero() { + o.Padding = chart.Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + } + } + + // 标题的默认值 + if o.Title.Style.FontColor.IsZero() { + o.Title.Style.FontColor = t.GetTextColor() + } + if o.Title.Style.FontSize == 0 { + o.Title.Style.FontSize = 14 + } + if o.Title.Style.Font == nil { + o.Title.Style.Font = o.Font + } + if o.Title.Style.Padding.IsZero() { + o.Title.Style.Padding = chart.Box{ + Bottom: 10, + } + } + // 副标题 + if o.Title.SubtextStyle.FontColor.IsZero() { + o.Title.SubtextStyle.FontColor = o.Title.Style.FontColor.WithAlpha(180) + } + if o.Title.SubtextStyle.FontSize == 0 { + o.Title.SubtextStyle.FontSize = labelFontSize + } + if o.Title.SubtextStyle.Font == nil { + o.Title.SubtextStyle.Font = o.Font + } + + o.Legend.theme = theme + if o.Legend.Style.FontSize == 0 { + o.Legend.Style.FontSize = labelFontSize + } + if o.Legend.Left == "" { + o.Legend.Left = PositionCenter + } + // legend与series name的关联 + if len(o.Legend.Data) == 0 { + o.Legend.Data = o.SeriesList.Names() + } else { + seriesCount := len(o.SeriesList) + for index, name := range o.Legend.Data { + if index < seriesCount { + o.SeriesList[index].Name = name + } + } + } + // 如果无legend数据,则隐藏 + if len(strings.Join(o.Legend.Data, "")) == 0 { + o.Legend.Show = FalseFlag() + } + if o.Legend.Style.Font == nil { + o.Legend.Style.Font = o.Font + } + if o.Legend.Style.FontColor.IsZero() { + o.Legend.Style.FontColor = t.GetTextColor() + } + if o.XAxis.Theme == "" { + o.XAxis.Theme = theme + } +} + +func (o *ChartOption) getWidth() int { + if o.Width != 0 { + return o.Width + } + if o.Parent != nil { + return o.Parent.Box.Width() + } + return defaultChartWidth +} + +func SetDefaultWidth(width int) { + if width > 0 { + defaultChartWidth = width + } +} +func SetDefaultHeight(height int) { + if height > 0 { + defaultChartHeight = height + } +} + +func (o *ChartOption) getHeight() int { + + if o.Height != 0 { + return o.Height + } + if o.Parent != nil { + return o.Parent.Box.Height() + } + return defaultChartHeight +} + +func (o *ChartOption) newYRange(axisIndex int) Range { + min := math.MaxFloat64 + max := -math.MaxFloat64 + if axisIndex >= len(o.YAxisList) { + axisIndex = 0 + } + yAxis := o.YAxisList[axisIndex] + + for _, series := range o.SeriesList { + if series.YAxisIndex != axisIndex { + continue + } + for _, item := range series.Data { + if item.Value > max { + max = item.Value + } + if item.Value < min { + min = item.Value + } + } + } + min = min * 0.9 + max = max * 1.1 + if yAxis.Min != nil { + min = *yAxis.Min + } + if yAxis.Max != nil { + max = *yAxis.Max + } + divideCount := 6 + // y轴分设置默认划分为6块 + r := NewRange(min, max, divideCount) + + // 由于NewRange会重新计算min max + if yAxis.Min != nil { + r.Min = min + } + if yAxis.Max != nil { + r.Max = max + } + + return r +} + +type basicRenderResult struct { + xRange *Range + yRangeList []*Range + d *Draw + titleBox chart.Box +} + +func (r *basicRenderResult) getYRange(index int) *Range { + if index >= len(r.yRangeList) { + index = 0 + } + return r.yRangeList[index] +} + +// Render renders the chart by option +func Render(opt ChartOption) (*Draw, error) { + if len(opt.SeriesList) == 0 { + return nil, errors.New("series can not be nil") + } + if len(opt.FontFamily) != 0 { + f, err := GetFont(opt.FontFamily) + if err != nil { + return nil, err + } + opt.Font = f + } + opt.FillDefault(opt.Theme) + + lineSeries := make([]Series, 0) + barSeries := make([]Series, 0) + isPieChart := false + for index, item := range opt.SeriesList { + item.index = index + switch item.Type { + case ChartTypePie: + isPieChart = true + case ChartTypeBar: + barSeries = append(barSeries, item) + default: + lineSeries = append(lineSeries, item) + } + } + // 如果指定了pie,则以pie的形式处理,pie不支持多类型图表 + // pie不需要axis + if isPieChart { + opt.XAxis.Hidden = true + for index := range opt.YAxisList { + opt.YAxisList[index].Hidden = true + } + } + result, err := chartBasicRender(&opt) + if err != nil { + return nil, err + } + markPointRenderOptions := make([]markPointRenderOption, 0) + fns := []func() error{ + // pie render + func() error { + if !isPieChart { + return nil + } + err := pieChartRender(pieChartOption{ + SeriesList: opt.SeriesList, + Theme: opt.Theme, + Font: opt.Font, + }, result) + return err + }, + // bar render + func() error { + // 如果是pie或者无bar类型的series + if isPieChart || len(barSeries) == 0 { + return nil + } + options, err := barChartRender(barChartOption{ + SeriesList: barSeries, + Theme: opt.Theme, + Font: opt.Font, + }, result) + if err != nil { + return err + } + markPointRenderOptions = append(markPointRenderOptions, options...) + return nil + }, + // line render + func() error { + // 如果是pie或者无line类型的series + if isPieChart || len(lineSeries) == 0 { + return nil + } + options, err := lineChartRender(lineChartOption{ + Theme: opt.Theme, + SeriesList: lineSeries, + Font: opt.Font, + }, result) + if err != nil { + return err + } + markPointRenderOptions = append(markPointRenderOptions, options...) + return nil + }, + // legend需要在顶层,因此此处render + func() error { + _, err := NewLegend(result.d, opt.Legend).Render() + return err + }, + // mark point最后render + func() error { + // mark point render不会出错 + for _, opt := range markPointRenderOptions { + markPointRender(opt) + } + return nil + }, + } + + for _, fn := range fns { + err = fn() + if err != nil { + return nil, err + } + } + for _, child := range opt.Children { + child.Parent = result.d + if len(child.Theme) == 0 { + child.Theme = opt.Theme + } + _, err = Render(child) + if err != nil { + return nil, err + } + } + return result.d, nil +} + +func chartBasicRender(opt *ChartOption) (*basicRenderResult, error) { + d, err := NewDraw( + DrawOption{ + Type: opt.Type, + Parent: opt.Parent, + Width: opt.getWidth(), + Height: opt.getHeight(), + }, + PaddingOption(opt.Padding), + BoxOption(opt.Box), + ) + if err != nil { + return nil, err + } + + if len(opt.YAxisList) > 2 { + return nil, errors.New("y axis should not be gt 2") + } + if opt.Parent == nil { + d.setBackground(opt.getWidth(), opt.getHeight(), opt.BackgroundColor) + } + + // 标题 + titleBox, err := drawTitle(d, &opt.Title) + if err != nil { + return nil, err + } + + xAxisHeight := 0 + var xRange *Range + + if !opt.XAxis.Hidden { + // xAxis + xAxisHeight, xRange, err = drawXAxis(d, &opt.XAxis, len(opt.YAxisList)) + if err != nil { + return nil, err + } + } + + yRangeList := make([]*Range, len(opt.YAxisList)) + + for index, yAxis := range opt.YAxisList { + var yRange *Range + if !yAxis.Hidden { + yRange, err = drawYAxis(d, opt, index, xAxisHeight, chart.Box{ + Top: titleBox.Height(), + }) + if err != nil { + return nil, err + } + yRangeList[index] = yRange + } + } + return &basicRenderResult{ + xRange: xRange, + yRangeList: yRangeList, + d: d, + titleBox: titleBox, + }, nil +} diff --git a/chart_test.go b/chart_test.go new file mode 100644 index 0000000..4fc3d20 --- /dev/null +++ b/chart_test.go @@ -0,0 +1,504 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func TestChartSetDefaultWidthHeight(t *testing.T) { + assert := assert.New(t) + + width := defaultChartWidth + height := defaultChartHeight + defer SetDefaultWidth(width) + defer SetDefaultHeight(height) + + SetDefaultWidth(60) + assert.Equal(60, defaultChartWidth) + SetDefaultHeight(40) + assert.Equal(40, defaultChartHeight) +} + +func TestChartFillDefault(t *testing.T) { + assert := assert.New(t) + // default value + opt := ChartOption{} + opt.FillDefault("") + // padding + assert.Equal(chart.Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + }, opt.Padding) + // background color + assert.Equal(drawing.ColorWhite, opt.BackgroundColor) + // title font color + assert.Equal(drawing.Color{ + R: 70, + G: 70, + B: 70, + A: 255, + }, opt.Title.Style.FontColor) + // title font size + assert.Equal(float64(14), opt.Title.Style.FontSize) + // sub title font color + assert.Equal(drawing.Color{ + R: 70, + G: 70, + B: 70, + A: 180, + }, opt.Title.SubtextStyle.FontColor) + // sub title font size + assert.Equal(float64(10), opt.Title.SubtextStyle.FontSize) + // legend font size + assert.Equal(float64(10), opt.Legend.Style.FontSize) + // legend position + assert.Equal("center", opt.Legend.Left) + assert.Equal(drawing.Color{ + R: 70, + G: 70, + B: 70, + A: 255, + }, opt.Legend.Style.FontColor) + + // y axis + opt = ChartOption{ + SeriesList: SeriesList{ + { + YAxisIndex: 1, + }, + }, + } + opt.FillDefault("") + assert.Equal([]YAxisOption{ + {}, + {}, + }, opt.YAxisList) + opt = ChartOption{} + opt.FillDefault("") + assert.Equal([]YAxisOption{ + {}, + }, opt.YAxisList) + + // legend get from series's name + + opt = ChartOption{ + SeriesList: SeriesList{ + { + Name: "a", + }, + { + Name: "b", + }, + }, + } + opt.FillDefault("") + assert.Equal([]string{ + "a", + "b", + }, opt.Legend.Data) + // series name set by legend + opt = ChartOption{ + Legend: LegendOption{ + Data: []string{ + "a", + "b", + }, + }, + SeriesList: SeriesList{ + {}, + {}, + }, + } + opt.FillDefault("") + assert.Equal("a", opt.SeriesList[0].Name) + assert.Equal("b", opt.SeriesList[1].Name) +} + +func TestChartGetWidthHeight(t *testing.T) { + assert := assert.New(t) + + opt := ChartOption{ + Width: 10, + } + assert.Equal(10, opt.getWidth()) + opt.Width = 0 + assert.Equal(600, opt.getWidth()) + opt.Parent = &Draw{ + Box: chart.Box{ + Left: 10, + Right: 50, + }, + } + assert.Equal(40, opt.getWidth()) + + opt = ChartOption{ + Height: 20, + } + assert.Equal(20, opt.getHeight()) + opt.Height = 0 + assert.Equal(400, opt.getHeight()) + opt.Parent = &Draw{ + Box: chart.Box{ + Top: 20, + Bottom: 80, + }, + } + assert.Equal(60, opt.getHeight()) +} + +func TestChartRender(t *testing.T) { + assert := assert.New(t) + + d, err := Render(ChartOption{ + Width: 800, + Height: 600, + Legend: LegendOption{ + Top: "-90", + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Padding: chart.Box{ + Top: 100, + }, + XAxis: NewXAxisOption([]string{ + "2012", + "2013", + "2014", + "2015", + "2016", + "2017", + }), + YAxisList: []YAxisOption{ + { + + Min: NewFloatPoint(0), + Max: NewFloatPoint(90), + }, + }, + SeriesList: []Series{ + NewSeriesFromValues([]float64{ + 56.5, + 82.1, + 88.7, + 70.1, + 53.4, + 85.1, + }), + NewSeriesFromValues([]float64{ + 51.1, + 51.4, + 55.1, + 53.3, + 73.8, + 68.7, + }), + NewSeriesFromValues([]float64{ + 40.1, + 62.2, + 69.5, + 36.4, + 45.2, + 32.5, + }, ChartTypeBar), + NewSeriesFromValues([]float64{ + 25.2, + 37.1, + 41.2, + 18, + 33.9, + 49.1, + }, ChartTypeBar), + }, + Children: []ChartOption{ + { + Legend: LegendOption{ + Show: FalseFlag(), + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Box: chart.Box{ + Top: 20, + Left: 400, + Right: 500, + Bottom: 120, + }, + SeriesList: NewPieSeriesList([]float64{ + 435.9, + 354.3, + 285.9, + 204.5, + }, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }, + }, + }) + assert.Nil(err) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal("\\n2012201320142015201620170153045607590Milk TeaMatcha LatteCheese CocoaWalnut BrownieMilk Tea: 34.03%Matcha Latte: 27.66%Cheese Cocoa: 22.32%Walnut Brownie: 15.96%", string(data)) +} + +func BenchmarkMultiChartPNGRender(b *testing.B) { + for i := 0; i < b.N; i++ { + opt := ChartOption{ + Type: ChartOutputPNG, + Legend: LegendOption{ + Top: "-90", + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Padding: chart.Box{ + Top: 100, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: NewXAxisOption([]string{ + "2012", + "2013", + "2014", + "2015", + "2016", + "2017", + }), + YAxisList: []YAxisOption{ + { + + Min: NewFloatPoint(0), + Max: NewFloatPoint(90), + }, + }, + SeriesList: []Series{ + NewSeriesFromValues([]float64{ + 56.5, + 82.1, + 88.7, + 70.1, + 53.4, + 85.1, + }), + NewSeriesFromValues([]float64{ + 51.1, + 51.4, + 55.1, + 53.3, + 73.8, + 68.7, + }), + NewSeriesFromValues([]float64{ + 40.1, + 62.2, + 69.5, + 36.4, + 45.2, + 32.5, + }, ChartTypeBar), + NewSeriesFromValues([]float64{ + 25.2, + 37.1, + 41.2, + 18, + 33.9, + 49.1, + }, ChartTypeBar), + }, + Children: []ChartOption{ + { + Legend: LegendOption{ + Show: FalseFlag(), + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Box: chart.Box{ + Top: 20, + Left: 400, + Right: 500, + Bottom: 120, + }, + SeriesList: NewPieSeriesList([]float64{ + 435.9, + 354.3, + 285.9, + 204.5, + }, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }, + }, + } + d, err := Render(opt) + if err != nil { + panic(err) + } + buf, err := d.Bytes() + if err != nil { + panic(err) + } + if len(buf) == 0 { + panic(errors.New("data is nil")) + } + } +} + +func BenchmarkMultiChartSVGRender(b *testing.B) { + for i := 0; i < b.N; i++ { + opt := ChartOption{ + Legend: LegendOption{ + Top: "-90", + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Padding: chart.Box{ + Top: 100, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: NewXAxisOption([]string{ + "2012", + "2013", + "2014", + "2015", + "2016", + "2017", + }), + YAxisList: []YAxisOption{ + { + + Min: NewFloatPoint(0), + Max: NewFloatPoint(90), + }, + }, + SeriesList: []Series{ + NewSeriesFromValues([]float64{ + 56.5, + 82.1, + 88.7, + 70.1, + 53.4, + 85.1, + }), + NewSeriesFromValues([]float64{ + 51.1, + 51.4, + 55.1, + 53.3, + 73.8, + 68.7, + }), + NewSeriesFromValues([]float64{ + 40.1, + 62.2, + 69.5, + 36.4, + 45.2, + 32.5, + }, ChartTypeBar), + NewSeriesFromValues([]float64{ + 25.2, + 37.1, + 41.2, + 18, + 33.9, + 49.1, + }, ChartTypeBar), + }, + Children: []ChartOption{ + { + Legend: LegendOption{ + Show: FalseFlag(), + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Box: chart.Box{ + Top: 20, + Left: 400, + Right: 500, + Bottom: 120, + }, + SeriesList: NewPieSeriesList([]float64{ + 435.9, + 354.3, + 285.9, + 204.5, + }, PieSeriesOption{ + Label: SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }, + }, + } + d, err := Render(opt) + if err != nil { + panic(err) + } + buf, err := d.Bytes() + if err != nil { + panic(err) + } + if len(buf) == 0 { + panic(errors.New("data is nil")) + } + } +} diff --git a/charts.go b/charts.go 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 new file mode 100644 index 0000000..bc3d9e8 --- /dev/null +++ b/draw.go @@ -0,0 +1,311 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "bytes" + "errors" + "math" + + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +const ( + PositionLeft = "left" + PositionRight = "right" + PositionCenter = "center" + PositionTop = "top" + PositionBottom = "bottom" +) + +const ( + OrientHorizontal = "horizontal" + OrientVertical = "vertical" +) + +type Draw struct { + // Render + Render chart.Renderer + // The canvas box + Box chart.Box + // The font for draw + Font *truetype.Font + // The parent of draw + parent *Draw +} + +type DrawOption struct { + // Draw type, "svg" or "png", default type is "svg" + Type string + // Parent of draw + Parent *Draw + // The width of draw canvas + Width int + // The height of draw canvas + Height int +} + +type Option func(*Draw) error + +// PaddingOption sets the padding of draw canvas +func PaddingOption(padding chart.Box) Option { + return func(d *Draw) error { + d.Box.Left += padding.Left + d.Box.Top += padding.Top + d.Box.Right -= padding.Right + d.Box.Bottom -= padding.Bottom + return nil + } +} + +// BoxOption set the box of draw canvas +func BoxOption(box chart.Box) Option { + return func(d *Draw) error { + if box.IsZero() { + return nil + } + d.Box = box + return nil + } +} + +// NewDraw returns a new draw canvas +func NewDraw(opt DrawOption, opts ...Option) (*Draw, error) { + if opt.Parent == nil && (opt.Width <= 0 || opt.Height <= 0) { + return nil, errors.New("parent and width/height can not be nil") + } + font, _ := chart.GetDefaultFont() + d := &Draw{ + Font: font, + } + width := opt.Width + height := opt.Height + if opt.Parent != nil { + d.parent = opt.Parent + d.Render = d.parent.Render + d.Box = opt.Parent.Box.Clone() + } + if width != 0 && height != 0 { + d.Box.Right = width + d.Box.Left + d.Box.Bottom = height + d.Box.Top + } + // 创建render + if d.parent == nil { + fn := chart.SVG + if opt.Type == ChartOutputPNG { + fn = chart.PNG + } + r, err := fn(d.Box.Right, d.Box.Bottom) + if err != nil { + return nil, err + } + d.Render = r + } + + for _, o := range opts { + err := o(d) + if err != nil { + return nil, err + } + } + return d, nil +} + +// Parent returns the parent of draw +func (d *Draw) Parent() *Draw { + return d.parent +} + +// Top returns the top parent of draw +func (d *Draw) Top() *Draw { + if d.parent == nil { + return nil + } + t := d.parent + // 限制最多查询次数,避免嵌套引用 + for i := 50; i > 0; i-- { + if t.parent == nil { + break + } + t = t.parent + } + return t +} + +// Bytes returns the data of draw canvas +func (d *Draw) Bytes() ([]byte, error) { + buffer := bytes.Buffer{} + err := d.Render.Save(&buffer) + if err != nil { + return nil, err + } + return buffer.Bytes(), err +} + +func (d *Draw) moveTo(x, y int) { + d.Render.MoveTo(x+d.Box.Left, y+d.Box.Top) +} + +func (d *Draw) arcTo(cx, cy int, rx, ry, startAngle, delta float64) { + d.Render.ArcTo(cx+d.Box.Left, cy+d.Box.Top, rx, ry, startAngle, delta) +} + +func (d *Draw) lineTo(x, y int) { + d.Render.LineTo(x+d.Box.Left, y+d.Box.Top) +} + +func (d *Draw) pin(x, y, width int) { + r := float64(width) / 2 + y -= width / 4 + angle := chart.DegreesToRadians(15) + + startAngle := math.Pi/2 + angle + delta := 2*math.Pi - 2*angle + d.arcTo(x, y, r, r, startAngle, delta) + d.lineTo(x, y) + d.Render.Close() + d.Render.FillStroke() + + startX := x - int(r) + startY := y + endX := x + int(r) + endY := y + d.moveTo(startX, startY) + + left := d.Box.Left + top := d.Box.Top + cx := x + cy := y + int(r*2.5) + d.Render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top) + d.Render.Close() + d.Render.Fill() +} + +func (d *Draw) arrowLeft(x, y, width, height int) { + d.arrow(x, y, width, height, PositionLeft) +} + +func (d *Draw) arrowRight(x, y, width, height int) { + d.arrow(x, y, width, height, PositionRight) +} + +func (d *Draw) arrowTop(x, y, width, height int) { + d.arrow(x, y, width, height, PositionTop) +} +func (d *Draw) arrowBottom(x, y, width, height int) { + d.arrow(x, y, width, height, PositionBottom) +} + +func (d *Draw) arrow(x, y, width, height int, direction string) { + halfWidth := width >> 1 + halfHeight := height >> 1 + if direction == PositionTop || direction == PositionBottom { + x0 := x - halfWidth + x1 := x0 + width + dy := -height / 3 + y0 := y + y1 := y0 - height + if direction == PositionBottom { + y0 = y - height + y1 = y + dy = 2 * dy + } + d.moveTo(x0, y0) + d.lineTo(x0+halfWidth, y1) + d.lineTo(x1, y0) + d.lineTo(x0+halfWidth, y+dy) + d.lineTo(x0, y0) + } else { + x0 := x + width + x1 := x0 - width + y0 := y - halfHeight + dx := -width / 3 + if direction == PositionRight { + x0 = x - width + dx = -dx + x1 = x0 + width + } + d.moveTo(x0, y0) + d.lineTo(x1, y0+halfHeight) + d.lineTo(x0, y0+height) + d.lineTo(x0+dx, y0+halfHeight) + d.lineTo(x0, y0) + } + d.Render.FillStroke() +} + +func (d *Draw) makeLine(x, y, width int) { + arrowWidth := 16 + arrowHeight := 10 + endX := x + width + d.circle(3, x, y) + d.Render.Fill() + d.moveTo(x+5, y) + d.lineTo(endX-arrowWidth, y) + d.Render.Stroke() + d.Render.SetStrokeDashArray([]float64{}) + d.arrowRight(endX, y, arrowWidth, arrowHeight) +} + +func (d *Draw) circle(radius float64, x, y int) { + d.Render.Circle(radius, x+d.Box.Left, y+d.Box.Top) +} + +func (d *Draw) text(body string, x, y int) { + d.Render.Text(body, x+d.Box.Left, y+d.Box.Top) +} + +func (d *Draw) lineStroke(points []Point, style LineStyle) { + s := style.Style() + if !s.ShouldDrawStroke() { + return + } + r := d.Render + s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) + for index, point := range points { + x := point.X + y := point.Y + if index == 0 { + d.moveTo(x, y) + } else { + d.lineTo(x, y) + } + } + r.Stroke() +} + +func (d *Draw) setBackground(width, height int, color drawing.Color) { + r := d.Render + s := chart.Style{ + FillColor: color, + } + s.WriteToRenderer(r) + r.MoveTo(0, 0) + r.LineTo(width, 0) + r.LineTo(width, height) + r.LineTo(0, height) + r.LineTo(0, 0) + r.FillStroke() +} diff --git a/draw_test.go b/draw_test.go new file mode 100644 index 0000000..712641a --- /dev/null +++ b/draw_test.go @@ -0,0 +1,427 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "math" + "testing" + + "github.com/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 TestBoxOption(t *testing.T) { + assert := assert.New(t) + + d, err := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }) + assert.Nil(err) + + err = BoxOption(chart.Box{ + Left: 10, + Top: 20, + Right: 50, + Bottom: 100, + })(d) + assert.Nil(err) + assert.Equal(chart.Box{ + Left: 10, + Top: 20, + Right: 50, + Bottom: 100, + }, d.Box) + + // zero box will be ignored + err = BoxOption(chart.Box{})(d) + assert.Nil(err) + assert.Equal(chart.Box{ + Left: 10, + Top: 20, + Right: 50, + Bottom: 100, + }, d.Box) +} + +func TestPaddingOption(t *testing.T) { + assert := assert.New(t) + + d, err := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }) + assert.Nil(err) + + // 默认的box + assert.Equal(chart.Box{ + Right: 400, + Bottom: 300, + }, d.Box) + + // 设置padding之后的box + d, err = NewDraw(DrawOption{ + Width: 400, + Height: 300, + }, PaddingOption(chart.Box{ + Left: 1, + Top: 2, + Right: 3, + Bottom: 4, + })) + assert.Nil(err) + assert.Equal(chart.Box{ + Top: 2, + Left: 1, + Right: 397, + Bottom: 296, + }, d.Box) + + p := d + // 设置父元素之后的box + d, err = NewDraw( + DrawOption{ + Parent: p, + }, + PaddingOption(chart.Box{ + Left: 1, + 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", + }, + // arcTo + { + fn: func(d *Draw) { + chart.Style{ + StrokeWidth: 1, + StrokeColor: drawing.ColorBlack, + FillColor: drawing.ColorBlue, + }.WriteToRenderer(d.Render) + d.arcTo(100, 100, 100, 100, 0, math.Pi/2) + d.Render.Close() + d.Render.FillStroke() + }, + result: "\\n", + }, + // pin + { + fn: func(d *Draw) { + chart.Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }.WriteToRenderer(d.Render) + d.pin(30, 30, 30) + }, + result: "\\n", + }, + // arrow left + { + fn: func(d *Draw) { + chart.Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }.WriteToRenderer(d.Render) + d.arrowLeft(30, 30, 16, 10) + }, + result: "\\n", + }, + // arrow right + { + fn: func(d *Draw) { + chart.Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }.WriteToRenderer(d.Render) + d.arrowRight(30, 30, 16, 10) + }, + result: "\\n", + }, + // arrow top + { + fn: func(d *Draw) { + chart.Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }.WriteToRenderer(d.Render) + d.arrowTop(30, 30, 10, 16) + }, + result: "\\n", + }, + // arrow bottom + { + fn: func(d *Draw) { + chart.Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }.WriteToRenderer(d.Render) + d.arrowBottom(30, 30, 10, 16) + }, + result: "\\n", + }, + // mark line + { + fn: func(d *Draw) { + chart.Style{ + StrokeWidth: 1, + StrokeColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + FillColor: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + StrokeDashArray: []float64{ + 4, + 2, + }, + }.WriteToRenderer(d.Render) + d.makeLine(0, 20, 300) + }, + 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 index 7e1884c..dc2e761 100644 --- a/echarts.go +++ b/echarts.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 @@ -28,21 +28,10 @@ import ( "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 { @@ -54,12 +43,47 @@ func convertToArray(data []byte) []byte { return data } -func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error { - data = bytes.TrimSpace(data) +type EChartsPosition string + +func (p *EChartsPosition) 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)(p) + return json.Unmarshal(data, s) +} + +type EChartStyle struct { + Color string `json:"color"` +} + +func (es *EChartStyle) ToStyle() chart.Style { + color := parseColor(es.Color) + return chart.Style{ + FillColor: color, + FontColor: color, + StrokeColor: color, + } +} + +type EChartsSeriesData struct { + Value float64 `json:"value"` + Name string `json:"name"` + ItemStyle EChartStyle `json:"itemStyle"` +} +type _EChartsSeriesData EChartsSeriesData + +var numericRep = regexp.MustCompile("^[-+]?[0-9]+(?:\\.[0-9]+)?$") + +func (es *EChartsSeriesData) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return nil + } + if numericRep.Match(data) { v, err := strconv.ParseFloat(string(data), 64) if err != nil { return err @@ -67,7 +91,7 @@ func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error { es.Value = v return nil } - v := _ECharsSeriesData{} + v := _EChartsSeriesData{} err := json.Unmarshal(data, &v) if err != nil { return err @@ -78,24 +102,53 @@ func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error { return nil } -type EChartsPadding struct { - box chart.Box +type EChartsXAxisData struct { + BoundaryGap *bool `json:"boundaryGap"` + SplitNumber int `json:"splitNumber"` + Data []string `json:"data"` +} +type EChartsXAxis struct { + Data []EChartsXAxisData } -type Position string - -func (lp *Position) UnmarshalJSON(data []byte) error { +func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error { + data = convertToArray(data) 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) + return json.Unmarshal(data, &ex.Data) } -func (ep *EChartsPadding) UnmarshalJSON(data []byte) error { +type EChartsAxisLabel struct { + Formatter string `json:"formatter"` +} +type EChartsYAxisData struct { + Min *float64 `json:"min"` + Max *float64 `json:"max"` + AxisLabel EChartsAxisLabel `json:"axisLabel"` + AxisLine struct { + LineStyle struct { + Color string + } + } +} +type EChartsYAxis struct { + Data []EChartsYAxisData `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 EChartsPadding struct { + Box chart.Box +} + +func (eb *EChartsPadding) UnmarshalJSON(data []byte) error { data = convertToArray(data) if len(data) == 0 { return nil @@ -110,14 +163,14 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error { } switch len(arr) { case 1: - ep.box = chart.Box{ + eb.Box = chart.Box{ Left: arr[0], Top: arr[0], Bottom: arr[0], Right: arr[0], } case 2: - ep.box = chart.Box{ + eb.Box = chart.Box{ Top: arr[0], Bottom: arr[0], Left: arr[1], @@ -130,7 +183,7 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error { result[3] = result[1] } // 上右下左 - ep.box = chart.Box{ + eb.Box = chart.Box{ Top: result[0], Right: result[1], Bottom: result[2], @@ -140,264 +193,247 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error { 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"` +type EChartsLabelOption struct { + Show bool `json:"show"` + Distance int `json:"distance"` + Color string `json:"color"` +} +type EChartsLegend struct { + Show *bool `json:"show"` + Data []string `json:"data"` + Align string `json:"align"` + Orient string `json:"orient"` + Padding EChartsPadding `json:"padding"` + Left EChartsPosition `json:"left"` + Top EChartsPosition `json:"top"` + TextStyle EChartsTextStyle `json:"textStyle"` +} + +type EChartsMarkPoint struct { + SymbolSize int `json:"symbolSize"` + Data []struct { + Type string `json:"type"` } `json:"data"` } -func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error { - data = convertToArray(data) - if len(data) == 0 { - return nil +func (emp *EChartsMarkPoint) ToSeriesMarkPoint() SeriesMarkPoint { + sp := SeriesMarkPoint{ + SymbolSize: emp.SymbolSize, } - return json.Unmarshal(data, &ey.Data) + if len(emp.Data) == 0 { + return sp + } + data := make([]SeriesMarkData, len(emp.Data)) + for index, item := range emp.Data { + data[index] = SeriesMarkData{ + Type: item.Type, + } + } + sp.Data = data + return sp } -type EChartsXAxis struct { +type EChartsMarkLine struct { Data []struct { - // Type string `json:"type"` - BoundaryGap *bool `json:"boundaryGap"` - SplitNumber int `json:"splitNumber"` - Data []string `json:"data"` - } + Type string `json:"type"` + } `json:"data"` } -func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error { - data = convertToArray(data) - if len(data) == 0 { - return nil +func (eml *EChartsMarkLine) ToSeriesMarkLine() SeriesMarkLine { + sl := SeriesMarkLine{} + if len(eml.Data) == 0 { + return sl } - return json.Unmarshal(data, &ex.Data) + data := make([]SeriesMarkData, len(eml.Data)) + for index, item := range eml.Data { + data[index] = SeriesMarkData{ + Type: item.Type, + } + } + sl.Data = data + return sl } -type EChartsLabelOption struct { - Show bool `json:"show"` - Distance int `json:"distance"` +type EChartsSeries struct { + Data []EChartsSeriesData `json:"data"` + Name string `json:"name"` + Type string `json:"type"` + Radius string `json:"radius"` + YAxisIndex int `json:"yAxisIndex"` + ItemStyle EChartStyle `json:"itemStyle"` + // label的配置 + Label EChartsLabelOption `json:"label"` + MarkPoint EChartsMarkPoint `json:"markPoint"` + MarkLine EChartsMarkLine `json:"markLine"` } +type EChartsSeriesList []EChartsSeries -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, +func (esList EChartsSeriesList) ToSeriesList() SeriesList { + seriesList := make(SeriesList, 0, len(esList)) + for _, item := range esList { + // 如果是pie,则每个子荐生成一个series + if item.Type == ChartTypePie { + for _, dataItem := range item.Data { + seriesList = append(seriesList, Series{ + Type: ChartTypePie, + Name: dataItem.Name, + Label: SeriesLabel{ + Show: true, }, - }, - Type: seriesType, - Name: item.Name, - Label: label, + Radius: item.Radius, + Data: []SeriesData{ + { + Value: dataItem.Value, + }, + }, + }) } - } - 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 + continue } data := make([]SeriesData, len(item.Data)) - for j, itemData := range item.Data { - sd := SeriesData{ - Value: itemData.Value, + for j, dataItem := range item.Data { + data[j] = SeriesData{ + Value: dataItem.Value, + Style: dataItem.ItemStyle.ToStyle(), } - 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, + seriesList = append(seriesList, Series{ Type: item.Type, - Label: item.Label.ToLabel(), - } + Data: data, + YAxisIndex: item.YAxisIndex, + Style: item.ItemStyle.ToStyle(), + Label: SeriesLabel{ + Color: parseColor(item.Label.Color), + Show: item.Label.Show, + Distance: item.Label.Distance, + }, + Name: item.Name, + MarkPoint: item.MarkPoint.ToSeriesMarkPoint(), + MarkLine: item.MarkLine.ToSeriesMarkLine(), + }) } - return series, tickPosition + return seriesList } -func (e *ECharsOptions) ToOptions() Options { - o := Options{ - Theme: e.Theme, - Padding: e.Padding.box, - } +type EChartsTextStyle struct { + Color string `json:"color"` + FontFamily string `json:"fontFamily"` + FontSize float64 `json:"fontSize"` +} - 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, +func (et *EChartsTextStyle) ToStyle() chart.Style { + s := chart.Style{ + FontSize: et.FontSize, + FontColor: parseColor(et.Color), + } + if et.FontFamily != "" { + s.Font, _ = GetFont(et.FontFamily) + } + return s +} + +type EChartsOption struct { + Type string `json:"type"` + Theme string `json:"theme"` + FontFamily string `json:"fontFamily"` + Padding EChartsPadding `json:"padding"` + Box chart.Box `json:"box"` + Width int `json:"width"` + Height int `json:"height"` + Title struct { + Text string `json:"text"` + Subtext string `json:"subtext"` + Left EChartsPosition `json:"left"` + Top EChartsPosition `json:"top"` + TextStyle EChartsTextStyle `json:"textStyle"` + SubtextStyle EChartsTextStyle `json:"subtextStyle"` + } `json:"title"` + XAxis EChartsXAxis `json:"xAxis"` + YAxis EChartsYAxis `json:"yAxis"` + Legend EChartsLegend `json:"legend"` + Series EChartsSeriesList `json:"series"` + Children []EChartsOption `json:"children"` +} + +func (eo *EChartsOption) ToOption() ChartOption { + fontFamily := eo.FontFamily + if len(fontFamily) == 0 { + fontFamily = eo.Title.TextStyle.FontFamily + } + o := ChartOption{ + Type: eo.Type, + FontFamily: fontFamily, + Theme: eo.Theme, + Title: TitleOption{ + Text: eo.Title.Text, + Subtext: eo.Title.Subtext, + Style: eo.Title.TextStyle.ToStyle(), + SubtextStyle: eo.Title.SubtextStyle.ToStyle(), + Left: string(eo.Title.Left), + Top: string(eo.Title.Top), }, + Legend: LegendOption{ + Show: eo.Legend.Show, + Style: eo.Legend.TextStyle.ToStyle(), + Data: eo.Legend.Data, + Left: string(eo.Legend.Left), + Top: string(eo.Legend.Top), + Align: eo.Legend.Align, + Orient: eo.Legend.Orient, + }, + Width: eo.Width, + Height: eo.Height, + Padding: eo.Padding.Box, + Box: eo.Box, + SeriesList: eo.Series.ToSeriesList(), } - 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 + if len(eo.XAxis.Data) != 0 { + xAxisData := eo.XAxis.Data[0] + o.XAxis = XAxisOption{ + BoundaryGap: xAxisData.BoundaryGap, + Data: xAxisData.Data, + SplitNumber: xAxisData.SplitNumber, } } - - 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 + yAxisOptions := make([]YAxisOption, len(eo.YAxis.Data)) + for index, item := range eo.YAxis.Data { + yAxisOptions[index] = YAxisOption{ + Min: item.Min, + Max: item.Max, + Formatter: item.AxisLabel.Formatter, + Color: parseColor(item.AxisLine.LineStyle.Color), } - o.YAxisOptions = yAxisOptions } + o.YAxisList = yAxisOptions - series, tickPosition := convertEChartsSeries(e) - - o.Series = series - - if boundaryGap { - tickPosition = chart.TickPositionBetweenTicks + if len(eo.Children) != 0 { + o.Children = make([]ChartOption, len(eo.Children)) + for index, item := range eo.Children { + o.Children[index] = item.ToOption() + } } - 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) +func renderEcharts(options, outputType string) ([]byte, error) { + o := EChartsOption{} + err := json.Unmarshal([]byte(options), &o) if err != nil { return nil, err } - g, err := New(o) + opt := o.ToOption() + opt.Type = outputType + d, err := Render(opt) if err != nil { return nil, err } - return render(g, rp) + return d.Bytes() } func RenderEChartsToPNG(options string) ([]byte, error) { - return echartsRender(options, chart.PNG) + return renderEcharts(options, "png") } func RenderEChartsToSVG(options string) ([]byte, error) { - return echartsRender(options, chart.SVG) + return renderEcharts(options, "svg") } diff --git a/echarts_test.go b/echarts_test.go index 6dcc4d0..d80ecbb 100644 --- a/echarts_test.go +++ b/echarts_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 @@ -31,425 +31,529 @@ import ( "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestConvertToArray(t *testing.T) { +func TestEChartsPosition(t *testing.T) { assert := assert.New(t) - assert.Nil(convertToArray([]byte(" "))) + var p EChartsPosition + err := p.UnmarshalJSON([]byte("12")) + assert.Nil(err) + assert.Equal("12", string(p)) - assert.Equal([]byte("[{}]"), convertToArray([]byte("{}"))) - assert.Equal([]byte("[{}]"), convertToArray([]byte("[{}]"))) + err = p.UnmarshalJSON([]byte(`"12%"`)) + assert.Nil(err) + assert.Equal("12%", string(p)) +} +func TestEChartStyle(t *testing.T) { + assert := assert.New(t) + + s := EChartStyle{ + Color: "#aaa", + } + r := drawing.Color{ + R: 170, + G: 170, + B: 170, + A: 255, + } + assert.Equal(chart.Style{ + FillColor: r, + FontColor: r, + StrokeColor: r, + }, s.ToStyle()) } -func TestECharsSeriesData(t *testing.T) { +func TestEChartsXAxis(t *testing.T) { assert := assert.New(t) - - es := ECharsSeriesData{} - err := es.UnmarshalJSON([]byte(" ")) + ex := EChartsXAxis{} + err := ex.UnmarshalJSON([]byte(`{ + "boundaryGap": false, + "splitNumber": 5, + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }`)) assert.Nil(err) - assert.Equal(ECharsSeriesData{}, es) + assert.Equal(EChartsXAxis{ + Data: []EChartsXAxisData{ + { + BoundaryGap: FalseFlag(), + SplitNumber: 5, + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + }, + }, + }, ex) +} - es = ECharsSeriesData{} - err = es.UnmarshalJSON([]byte("12.1")) - assert.Nil(err) - assert.Equal(ECharsSeriesData{ - Value: 12.1, - }, es) +func TestEChartsYAxis(t *testing.T) { + assert := assert.New(t) + ey := EChartsYAxis{} - es = ECharsSeriesData{} - err = es.UnmarshalJSON([]byte(`{ - "value": 12.1, - "name": "test", - "itemStyle": { - "color": "#333" + err := ey.UnmarshalJSON([]byte(`{ + "min": 1, + "max": 10, + "axisLabel": { + "formatter": "ab" } }`)) assert.Nil(err) - assert.Equal(ECharsSeriesData{ - Value: 12.1, - Name: "test", - ItemStyle: EChartStyle{ - Color: "#333", + assert.Equal(EChartsYAxis{ + Data: []EChartsYAxisData{ + { + Min: NewFloatPoint(1), + Max: NewFloatPoint(10), + AxisLabel: EChartsAxisLabel{ + Formatter: "ab", + }, + }, }, - }, es) + }, ey) + + ey = EChartsYAxis{} + err = ey.UnmarshalJSON([]byte(`[ + { + "min": 1, + "max": 10, + "axisLabel": { + "formatter": "ab" + } + }, + { + "min": 2, + "max": 20, + "axisLabel": { + "formatter": "cd" + } + } + ]`)) + assert.Nil(err) + assert.Equal(EChartsYAxis{ + Data: []EChartsYAxisData{ + { + Min: NewFloatPoint(1), + Max: NewFloatPoint(10), + AxisLabel: EChartsAxisLabel{ + Formatter: "ab", + }, + }, + { + Min: NewFloatPoint(2), + Max: NewFloatPoint(20), + AxisLabel: EChartsAxisLabel{ + Formatter: "cd", + }, + }, + }, + }, ey) } func TestEChartsPadding(t *testing.T) { assert := assert.New(t) ep := EChartsPadding{} - err := ep.UnmarshalJSON([]byte(" ")) - assert.Nil(err) - assert.Equal(EChartsPadding{}, ep) - ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte("1")) - assert.Nil(err) + ep.UnmarshalJSON([]byte(`10`)) assert.Equal(EChartsPadding{ - box: chart.Box{ - Top: 1, - Left: 1, - Right: 1, - Bottom: 1, + Box: chart.Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, }, }, ep) ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte("[1, 2]")) - assert.Nil(err) + ep.UnmarshalJSON([]byte(`[10, 20]`)) assert.Equal(EChartsPadding{ - box: chart.Box{ - Top: 1, - Left: 2, - Right: 2, - Bottom: 1, + Box: chart.Box{ + Top: 10, + Right: 20, + Bottom: 10, + Left: 20, }, }, ep) ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte("[1, 2, 3]")) - assert.Nil(err) + ep.UnmarshalJSON([]byte(`[10, 20, 30]`)) assert.Equal(EChartsPadding{ - box: chart.Box{ - Top: 1, - Right: 2, - Bottom: 3, - Left: 2, + Box: chart.Box{ + Top: 10, + Right: 20, + Bottom: 30, + Left: 20, }, }, ep) ep = EChartsPadding{} - err = ep.UnmarshalJSON([]byte("[1, 2, 3, 4]")) - assert.Nil(err) + ep.UnmarshalJSON([]byte(`[10, 20, 30, 40]`)) assert.Equal(EChartsPadding{ - box: chart.Box{ - Top: 1, - Right: 2, - Bottom: 3, - Left: 4, + Box: chart.Box{ + Top: 10, + Right: 20, + Bottom: 30, + Left: 40, }, }, ep) + } - -func TestConvertEChartsSeries(t *testing.T) { +func TestEChartsLegend(t *testing.T) { assert := assert.New(t) - seriesList, tickPosition := convertEChartsSeries(&ECharsOptions{}) - assert.Empty(seriesList) - assert.Equal(chart.TickPositionUnset, tickPosition) + el := EChartsLegend{} - e := ECharsOptions{} err := json.Unmarshal([]byte(`{ - "title": { - "text": "Referer of a Website" + "data": ["a", "b", "c"], + "align": "right", + "padding": [10], + "left": "20%", + "top": 10 + }`), &el) + assert.Nil(err) + assert.Equal(EChartsLegend{ + Data: []string{ + "a", + "b", + "c", }, - "series": [ + Align: "right", + Padding: EChartsPadding{ + Box: chart.Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, + }, + Left: EChartsPosition("20%"), + Top: EChartsPosition("10"), + }, el) +} + +func TestEChartsSeriesData(t *testing.T) { + assert := assert.New(t) + + esd := EChartsSeriesData{} + err := esd.UnmarshalJSON([]byte(`123`)) + assert.Nil(err) + assert.Equal(EChartsSeriesData{ + Value: 123, + }, esd) + + esd = EChartsSeriesData{} + err = esd.UnmarshalJSON([]byte(`{ + "value": 123.12, + "name": "test", + "itemStyle": { + "color": "#aaa" + } + }`)) + assert.Nil(err) + assert.Equal(EChartsSeriesData{ + Value: 123.12, + Name: "test", + ItemStyle: EChartStyle{ + Color: "#aaa", + }, + }, esd) +} + +func TestEChartsSeries(t *testing.T) { + assert := assert.New(t) + + esList := make([]EChartsSeries, 0) + err := json.Unmarshal([]byte(`[ + { + "name": "Email", + "data": [ + 120, + 132 + ] + }, + { + "name": "Union Ads", + "type": "bar", + "data": [ + 220, + 182 + ] + } + ]`), &esList) + assert.Nil(err) + assert.Equal([]EChartsSeries{ + { + Name: "Email", + Data: []EChartsSeriesData{ + { + Value: 120, + }, + { + Value: 132, + }, + }, + }, + { + Name: "Union Ads", + Type: "bar", + Data: []EChartsSeriesData{ + { + Value: 220, + }, + { + Value: 182, + }, + }, + }, + }, esList) +} + +func TestEChartsMarkPoint(t *testing.T) { + assert := assert.New(t) + + p := EChartsMarkPoint{} + + err := json.Unmarshal([]byte(`{ + "symbolSize": 30, + "data": [ { - "name": "Access From", - "type": "pie", - "radius": "50%", - "data": [ - { - "value": 1048, - "name": "Search Engine" + "type": "max" + }, + { + "type": "min" + } + ] + }`), &p) + assert.Nil(err) + assert.Equal(SeriesMarkPoint{ + SymbolSize: 30, + Data: []SeriesMarkData{ + { + Type: "max", + }, + { + Type: "min", + }, + }, + }, p.ToSeriesMarkPoint()) +} + +func TestEChartsMarkLine(t *testing.T) { + assert := assert.New(t) + l := EChartsMarkLine{} + + err := json.Unmarshal([]byte(`{ + "data": [ + { + "type": "max" + }, + { + "type": "min" + } + ] + }`), &l) + assert.Nil(err) + assert.Equal(SeriesMarkLine{ + Data: []SeriesMarkData{ + { + Type: "max", + }, + { + Type: "min", + }, + }, + }, l.ToSeriesMarkLine()) +} + +func TestEChartsTextStyle(t *testing.T) { + assert := assert.New(t) + + s := EChartsTextStyle{ + Color: "#aaa", + FontFamily: "test", + FontSize: 14, + } + assert.Equal(chart.Style{ + FontColor: drawing.Color{ + R: 170, + G: 170, + B: 170, + A: 255, + }, + FontSize: 14, + }, s.ToStyle()) +} + +func TestEChartsSeriesList(t *testing.T) { + assert := assert.New(t) + + // pie + es := EChartsSeriesList{ + { + Type: ChartTypePie, + Radius: "30%", + Data: []EChartsSeriesData{ + { + Name: "1", + Value: 1, + }, + { + Name: "2", + Value: 2, + }, + }, + }, + } + assert.Equal(SeriesList{ + { + Type: ChartTypePie, + Name: "1", + Label: SeriesLabel{ + Show: true, + }, + Radius: "30%", + Data: []SeriesData{ + { + Value: 1, + }, + }, + }, + { + Type: ChartTypePie, + Name: "2", + Label: SeriesLabel{ + Show: true, + }, + Radius: "30%", + Data: []SeriesData{ + { + Value: 2, + }, + }, + }, + }, es.ToSeriesList()) + + es = EChartsSeriesList{ + { + Type: ChartTypeBar, + Data: []EChartsSeriesData{ + { + Value: 1, + ItemStyle: EChartStyle{ + Color: "#aaa", }, - { - "value": 735, - "name": "Direct" - } - ] - } - ] - }`), &e) - assert.Nil(err) - seriesList, tickPosition = convertEChartsSeries(&e) - assert.Equal(chart.TickPositionUnset, tickPosition) - assert.Equal([]Series{ - { - Data: []SeriesData{ + }, { - Value: 1048, + Value: 2, }, }, - 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: []EChartsSeriesData{ + { + Value: 3, + }, + { + Value: 4, }, - "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, + ItemStyle: EChartStyle{ + Color: "#ccc", + }, + Label: EChartsLabelOption{ + Color: "#ddd", + Show: true, + Distance: 5, + }, }, - Title: Title{ - Text: "Multi Line", + } + assert.Equal(SeriesList{ + { + Type: ChartTypeBar, + Data: []SeriesData{ + { + Value: 1, + Style: chart.Style{ + FontColor: drawing.Color{ + R: 170, + G: 170, + B: 170, + A: 255, + }, + StrokeColor: drawing.Color{ + R: 170, + G: 170, + B: 170, + A: 255, + }, + FillColor: drawing.Color{ + R: 170, + G: 170, + B: 170, + A: 255, + }, + }, + }, + { + Value: 2, + }, + }, + YAxisIndex: 1, + }, + { + Data: []SeriesData{ + { + Value: 3, + }, + { + Value: 4, + }, + }, Style: chart.Style{ - FontColor: parseColor("#333"), - FontSize: 24, - Padding: chart.Box{ - Top: 8, - Bottom: 8, + FontColor: drawing.Color{ + R: 204, + G: 204, + B: 204, + A: 255, + }, + StrokeColor: drawing.Color{ + R: 204, + G: 204, + B: 204, + A: 255, + }, + FillColor: drawing.Color{ + R: 204, + G: 204, + B: 204, + A: 255, }, }, - }, - 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, - }, + Label: SeriesLabel{ + Color: drawing.Color{ + R: 221, + G: 221, + B: 221, + A: 255, }, + Show: true, + Distance: 5, }, - { - Data: NewSeriesDataListFromFloat([]float64{ - 2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3, - }), - Type: SeriesLine, - YAxisIndex: 1, - }, + MarkPoint: SeriesMarkPoint{}, + MarkLine: SeriesMarkLine{}, }, - }, options) -} + }, es.ToSeriesList()) -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..e10c7c0 100644 --- a/examples/demo/main.go +++ b/examples/demo/main.go @@ -3,8 +3,11 @@ package main import ( "bytes" "net/http" + "strconv" charts "github.com/vicanso/go-charts" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) var html = ` @@ -12,14 +15,16 @@ var html = ` - @@ -56,327 +61,1479 @@ 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"]) +func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.ChartOption, echartsOptions []string) { + if req.URL.Path != "/" && + req.URL.Path != "/echarts" { + return + } + query := req.URL.Query() + theme := query.Get("theme") + width, _ := strconv.Atoi(query.Get("width")) + height, _ := strconv.Atoi(query.Get("height")) + charts.SetDefaultWidth(width) + charts.SetDefaultWidth(height) + bytesList := make([][]byte, 0) + for _, opt := range chartOptions { + opt.Theme = theme + d, err := charts.Render(opt) if err != nil { - return nil, err + panic(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) + buf, err := d.Bytes() + if err != nil { + panic(err) } + bytesList = append(bytesList, 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) - if err != nil { - w.WriteHeader(400) - w.Write([]byte(err.Error())) - return + for _, opt := range echartsOptions { + buf, err := charts.RenderEChartsToSVG(opt) + if err != nil { + panic(err) + } + bytesList = append(bytesList, buf) } - data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), buf) + data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), bytes.Join(bytesList, []byte(""))) w.Header().Set("Content-Type", "text/html") w.Write(data) } +func indexHandler(w http.ResponseWriter, req *http.Request) { + chartOptions := []charts.ChartOption{ + // 普通折线图 + { + Title: charts.TitleOption{ + Text: "Line", + }, + Legend: charts.NewLegendOption([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }), + XAxis: charts.NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + SeriesList: []charts.Series{ + charts.NewSeriesFromValues([]float64{ + 120, + 132, + 101, + 134, + 90, + 230, + 210, + }), + charts.NewSeriesFromValues([]float64{ + 220, + 182, + 191, + 234, + 290, + 330, + 310, + }), + charts.NewSeriesFromValues([]float64{ + 150, + 232, + 201, + 154, + 190, + 330, + 410, + }), + charts.NewSeriesFromValues([]float64{ + 320, + 332, + 301, + 334, + 390, + 330, + 320, + }), + charts.NewSeriesFromValues([]float64{ + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320, + }), + }, + }, + // 温度折线图 + { + Title: charts.TitleOption{ + Text: "Temperature Change in the Coming Week", + }, + Padding: chart.Box{ + Top: 20, + Left: 20, + Right: 30, + Bottom: 20, + }, + Legend: charts.NewLegendOption([]string{ + "Highest", + "Lowest", + }, charts.PositionRight), + XAxis: charts.NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, charts.FalseFlag()), + SeriesList: []charts.Series{ + { + Data: charts.NewSeriesDataFromValues([]float64{ + 14, + 11, + 13, + 11, + 12, + 12, + 7, + }), + MarkPoint: charts.NewMarkPoint(charts.SeriesMarkDataTypeMax, charts.SeriesMarkDataTypeMin), + MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage), + }, + { + Data: charts.NewSeriesDataFromValues([]float64{ + 1, + -2, + 2, + 5, + 3, + 2, + 0, + }), + MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage), + }, + }, + }, + // 柱状图 + { + Title: charts.TitleOption{ + Text: "Bar", + }, + XAxis: charts.NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + Legend: charts.NewLegendOption([]string{ + "Rainfall", + "Evaporation", + }), + SeriesList: []charts.Series{ + charts.NewSeriesFromValues([]float64{ + 120, + 200, + 150, + 80, + 70, + 110, + 130, + }, charts.ChartTypeBar), + { + Type: charts.ChartTypeBar, + Data: []charts.SeriesData{ + { + Value: 100, + }, + { + Value: 190, + Style: chart.Style{ + FillColor: drawing.Color{ + R: 169, + G: 0, + B: 0, + A: 255, + }, + }, + }, + { + Value: 230, + }, + { + Value: 140, + }, + { + Value: 100, + }, + { + Value: 200, + }, + { + Value: 180, + }, + }, + }, + }, + }, + // 柱状图+mark + { + Title: charts.TitleOption{ + Text: "Rainfall vs Evaporation", + Subtext: "Fake Data", + }, + Padding: chart.Box{ + Top: 20, + Right: 20, + Bottom: 20, + Left: 20, + }, + XAxis: charts.NewXAxisOption([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + Legend: charts.NewLegendOption([]string{ + "Rainfall", + "Evaporation", + }, charts.PositionRight), + SeriesList: []charts.Series{ + { + Type: charts.ChartTypeBar, + Data: charts.NewSeriesDataFromValues([]float64{ + 2.0, + 4.9, + 7.0, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20.0, + 6.4, + 3.3, + }), + MarkPoint: charts.NewMarkPoint( + charts.SeriesMarkDataTypeMax, + charts.SeriesMarkDataTypeMin, + ), + MarkLine: charts.NewMarkLine( + charts.SeriesMarkDataTypeAverage, + ), + }, + { + Type: charts.ChartTypeBar, + Data: charts.NewSeriesDataFromValues([]float64{ + 2.6, + 5.9, + 9.0, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6.0, + 2.3, + }), + MarkPoint: charts.NewMarkPoint( + charts.SeriesMarkDataTypeMax, + charts.SeriesMarkDataTypeMin, + ), + MarkLine: charts.NewMarkLine( + charts.SeriesMarkDataTypeAverage, + ), + }, + }, + }, + // 双Y轴示例 + { + XAxis: charts.NewXAxisOption([]string{ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + }), + Legend: charts.NewLegendOption([]string{ + "Evaporation", + "Precipitation", + "Temperature", + }), + YAxisList: []charts.YAxisOption{ + { + Formatter: "{value}°C", + Color: drawing.Color{ + R: 250, + G: 200, + B: 88, + A: 255, + }, + }, + { + Formatter: "{value}ml", + Color: drawing.Color{ + R: 84, + G: 112, + B: 198, + A: 255, + }, + }, + }, + SeriesList: []charts.Series{ + { + Type: charts.ChartTypeBar, + Data: charts.NewSeriesDataFromValues([]float64{ + 2.0, + 4.9, + 7.0, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20.0, + 6.4, + 3.3, + }), + YAxisIndex: 1, + }, + { + Type: charts.ChartTypeBar, + Data: charts.NewSeriesDataFromValues([]float64{ + 2.6, + 5.9, + 9.0, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6.0, + 2.3, + }), + YAxisIndex: 1, + }, + { + Data: charts.NewSeriesDataFromValues([]float64{ + 2.0, + 2.2, + 3.3, + 4.5, + 6.3, + 10.2, + 20.3, + 23.4, + 23.0, + 16.5, + 12.0, + 6.2, + }), + }, + }, + }, + // 饼图 + { + Title: charts.TitleOption{ + Text: "Referer of a Website", + Subtext: "Fake Data", + Left: charts.PositionCenter, + }, + Legend: charts.LegendOption{ + Orient: charts.OrientVertical, + Data: []string{ + "Search Engine", + "Direct", + "Email", + "Union Ads", + "Video Ads", + }, + Left: charts.PositionLeft, + }, + SeriesList: charts.NewPieSeriesList([]float64{ + 1048, + 735, + 580, + 484, + 300, + }, charts.PieSeriesOption{ + Label: charts.SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }, + // 多图展示 + { + Legend: charts.LegendOption{ + Top: "-90", + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Padding: chart.Box{ + Top: 100, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: charts.NewXAxisOption([]string{ + "2012", + "2013", + "2014", + "2015", + "2016", + "2017", + }), + YAxisList: []charts.YAxisOption{ + { + + Min: charts.NewFloatPoint(0), + Max: charts.NewFloatPoint(90), + }, + }, + SeriesList: []charts.Series{ + charts.NewSeriesFromValues([]float64{ + 56.5, + 82.1, + 88.7, + 70.1, + 53.4, + 85.1, + }), + charts.NewSeriesFromValues([]float64{ + 51.1, + 51.4, + 55.1, + 53.3, + 73.8, + 68.7, + }), + charts.NewSeriesFromValues([]float64{ + 40.1, + 62.2, + 69.5, + 36.4, + 45.2, + 32.5, + }, charts.ChartTypeBar), + charts.NewSeriesFromValues([]float64{ + 25.2, + 37.1, + 41.2, + 18, + 33.9, + 49.1, + }, charts.ChartTypeBar), + }, + Children: []charts.ChartOption{ + { + Legend: charts.LegendOption{ + Show: charts.FalseFlag(), + Data: []string{ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie", + }, + }, + Box: chart.Box{ + Top: 20, + Left: 400, + Right: 500, + Bottom: 120, + }, + SeriesList: charts.NewPieSeriesList([]float64{ + 435.9, + 354.3, + 285.9, + 204.5, + }, charts.PieSeriesOption{ + Label: charts.SeriesLabel{ + Show: true, + }, + Radius: "35%", + }), + }, + }, + }, + } + handler(w, req, chartOptions, nil) +} + +func echartsHandler(w http.ResponseWriter, req *http.Request) { + echartsOptions := []string{ + `{ + "xAxis": { + "type": "category", + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "type": "value" + }, + "series": [ + { + "data": [ + 150, + 230, + 224, + 218, + 135, + 147, + 260 + ], + "type": "line" + } + ] + }`, + `{ + "title": { + "text": "Multiple Line" + }, + "tooltip": { + "trigger": "axis" + }, + "legend": { + "left": "right", + "data": [ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine" + ] + }, + "grid": { + "left": "3%", + "right": "4%", + "bottom": "3%", + "containLabel": true + }, + "toolbox": { + "feature": { + "saveAsImage": {} + } + }, + "xAxis": { + "type": "category", + "boundaryGap": false, + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "type": "value" + }, + "series": [ + { + "name": "Email", + "type": "line", + "data": [ + 120, + 132, + 101, + 134, + 90, + 230, + 210 + ] + }, + { + "name": "Union Ads", + "type": "line", + "data": [ + 220, + 182, + 191, + 234, + 290, + 330, + 310 + ] + }, + { + "name": "Video Ads", + "type": "line", + "data": [ + 150, + 232, + 201, + 154, + 190, + 330, + 410 + ] + }, + { + "name": "Direct", + "type": "line", + "data": [ + 320, + 332, + 301, + 334, + 390, + 330, + 320 + ] + }, + { + "name": "Search Engine", + "type": "line", + "data": [ + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320 + ] + } + ] + }`, + `{ + "title": { + "text": "Temperature Change in the Coming Week" + }, + "legend": { + "left": "right" + }, + "padding": [10, 30, 10, 10], + "xAxis": { + "type": "category", + "boundaryGap": false, + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "axisLabel": { + "formatter": "{value} °C" + } + }, + "series": [ + { + "name": "Highest", + "type": "line", + "data": [ + 10, + 11, + 13, + 11, + 12, + 12, + 9 + ], + "markPoint": { + "data": [ + { + "type": "max" + }, + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + } + ] + } + }, + { + "name": "Lowest", + "type": "line", + "data": [ + 1, + -2, + 2, + 5, + 3, + 2, + 0 + ], + "markPoint": { + "data": [ + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + }, + { + "type": "max" + } + ] + } + } + ] + }`, + `{ + "xAxis": { + "type": "category", + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "type": "value" + }, + "series": [ + { + "data": [ + 120, + 200, + 150, + 80, + 70, + 110, + 130 + ], + "type": "bar" + } + ] + }`, + `{ + "xAxis": { + "type": "category", + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ] + }, + "yAxis": { + "type": "value" + }, + "series": [ + { + "data": [ + 120, + { + "value": 200, + "itemStyle": { + "color": "#a90000" + } + }, + 150, + 80, + 70, + 110, + 130 + ], + "type": "bar" + } + ] + }`, + `{ + "title": { + "text": "Rainfall vs Evaporation", + "subtext": "Fake Data" + }, + "legend": { + "data": [ + "Rainfall", + "Evaporation" + ] + }, + "padding": [10, 30, 10, 10], + "xAxis": [ + { + "type": "category", + "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 + ], + "markPoint": { + "data": [ + { + "type": "max" + }, + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + } + ] + } + }, + { + "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 + ], + "markPoint": { + "data": [ + { + "type": "max" + }, + { + "type": "min" + } + ] + }, + "markLine": { + "data": [ + { + "type": "average" + } + ] + } + } + ] + }`, + `{ + "legend": { + "data": [ + "Evaporation", + "Precipitation", + "Temperature" + ] + }, + "xAxis": [ + { + "type": "category", + "data": [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun" + ], + "axisPointer": { + "type": "shadow" + } + } + ], + "yAxis": [ + { + "type": "value", + "name": "Precipitation", + "min": 0, + "max": 240, + "axisLabel": { + "formatter": "{value} ml" + } + }, + { + "type": "value", + "name": "Temperature", + "min": 0, + "max": 24, + "axisLabel": { + "formatter": "{value} °C" + } + } + ], + "series": [ + { + "name": "Evaporation", + "type": "bar", + "tooltip": {}, + "data": [ + 2, + 4.9, + 7, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20, + 6.4, + 3.3 + ] + }, + { + "name": "Precipitation", + "type": "bar", + "tooltip": {}, + "data": [ + 2.6, + 5.9, + 9, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6, + 2.3 + ] + }, + { + "name": "Temperature", + "type": "line", + "yAxisIndex": 1, + "tooltip": {}, + "data": [ + 2, + 2.2, + 3.3, + 4.5, + 6.3, + 10.2, + 20.3, + 23.4, + 23, + 16.5, + 12, + 6.2 + ] + } + ] + }`, + `{ + "tooltip": { + "trigger": "axis", + "axisPointer": { + "type": "cross" + } + }, + "grid": { + "right": "20%" + }, + "toolbox": { + "feature": { + "dataView": { + "show": true, + "readOnly": false + }, + "restore": { + "show": true + }, + "saveAsImage": { + "show": true + } + } + }, + "legend": { + "data": [ + "Evaporation", + "Precipitation", + "Temperature" + ] + }, + "xAxis": [ + { + "type": "category", + "axisTick": { + "alignWithLabel": true + }, + "data": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + } + ], + "yAxis": [ + { + "type": "value", + "name": "温度", + "position": "left", + "alignTicks": true, + "axisLine": { + "show": true, + "lineStyle": { + "color": "#EE6666" + } + }, + "axisLabel": { + "formatter": "{value} °C" + } + }, + { + "type": "value", + "name": "Evaporation", + "position": "right", + "alignTicks": true, + "axisLine": { + "show": true, + "lineStyle": { + "color": "#5470C6" + } + }, + "axisLabel": { + "formatter": "{value} ml" + } + } + ], + "series": [ + { + "name": "Evaporation", + "type": "bar", + "yAxisIndex": 1, + "data": [ + 2, + 4.9, + 7, + 23.2, + 25.6, + 76.7, + 135.6, + 162.2, + 32.6, + 20, + 6.4, + 3.3 + ] + }, + { + "name": "Precipitation", + "type": "bar", + "yAxisIndex": 1, + "data": [ + 2.6, + 5.9, + 9, + 26.4, + 28.7, + 70.7, + 175.6, + 182.2, + 48.7, + 18.8, + 6, + 2.3 + ] + }, + { + "name": "Temperature", + "type": "line", + "data": [ + 2, + 2.2, + 3.3, + 4.5, + 6.3, + 10.2, + 20.3, + 23.4, + 23, + 16.5, + 12, + 6.2 + ] + } + ] + }`, + `{ + "title": { + "text": "Referer of a Website", + "subtext": "Fake Data", + "left": "center" + }, + "tooltip": { + "trigger": "item" + }, + "legend": { + "orient": "vertical", + "left": "left" + }, + "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" + } + ] + } + ] + }`, + `{ + "title": { + "text": "Rainfall" + }, + "padding": [10, 10, 10, 30], + "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 + ] + } + ] + }`, + `{ + "legend": { + "top": "-140", + "data": [ + "Milk Tea", + "Matcha Latte", + "Cheese Cocoa", + "Walnut Brownie" + ] + }, + "padding": [ + 150, + 10, + 10, + 10 + ], + "xAxis": [ + { + "data": [ + "2012", + "2013", + "2014", + "2015", + "2016", + "2017" + ] + } + ], + "series": [ + { + "data": [ + 56.5, + 82.1, + 88.7, + 70.1, + 53.4, + 85.1 + ] + }, + { + "data": [ + 51.1, + 51.4, + 55.1, + 53.3, + 73.8, + 68.7 + ] + }, + { + "data": [ + 40.1, + 62.2, + 69.5, + 36.4, + 45.2, + 32.5 + ] + }, + { + "data": [ + 25.2, + 37.1, + 41.2, + 18, + 33.9, + 49.1 + ] + } + ], + "children": [ + { + "box": { + "left": 0, + "top": 30, + "right": 600, + "bottom": 150 + }, + "legend": { + "show": false + }, + "series": [ + { + "type": "pie", + "radius": "50%", + "data": [ + { + "value": 435.9, + "name": "Milk Tea" + }, + { + "value": 354.3, + "name": "Matcha Latte" + }, + { + "value": 285.9, + "name": "Cheese Cocoa" + }, + { + "value": 204.5, + "name": "Walnut Brownie" + } + ] + } + ] + } + ] + }`, + } + handler(w, req, nil, echartsOptions) +} + func main() { http.HandleFunc("/", indexHandler) + http.HandleFunc("/echarts", echartsHandler) http.ListenAndServe(":3012", nil) } diff --git a/font.go b/font.go new file mode 100644 index 0000000..c40b51e --- /dev/null +++ b/font.go @@ -0,0 +1,61 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "errors" + "sync" + + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2/roboto" +) + +var fonts = sync.Map{} +var ErrFontNotExists = errors.New("font is not exists") + +func init() { + _ = InstallFont("roboto", roboto.Roboto) +} + +// 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 get the font by 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 +} diff --git a/line_series_test.go b/font_test.go similarity index 73% rename from line_series_test.go rename to font_test.go index 27c9371..9dc731c 100644 --- a/line_series_test.go +++ b/font_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 @@ -26,21 +26,17 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/roboto" ) -func TestLineSeries(t *testing.T) { +func TestInstallFont(t *testing.T) { assert := assert.New(t) - ls := LineSeries{} + fontFamily := "test" + err := InstallFont(fontFamily, roboto.Roboto) + assert.Nil(err) - 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) + font, err := GetFont(fontFamily) + assert.Nil(err) + assert.NotNil(font) } 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 index c85564f..7eb33b3 100644 --- a/legend.go +++ b/legend.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 @@ -30,220 +30,179 @@ import ( ) 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 + theme string + // Legend show flag, if nil or true, the legend will be shown + Show *bool + // Legend text style Style chart.Style - Index int - Theme string + // Text array of legend + Data []string + // Distance between legend component and the left side of the container. + // It can be pixel value: 20, percentage value: 20%, + // or position value: right, center. + Left string + // Distance between legend component and the top side of the container. + // It can be pixel value: 20. + Top string + // Legend marker and text aligning, it can be left or right, default is left. + Align string + // The layout orientation of legend, it can be horizontal or vertical, default is horizontal. + Orient 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 +// NewLegendOption creates a new legend option by legend text list +func NewLegendOption(data []string, position ...string) LegendOption { + opt := LegendOption{ + Data: data, } - 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() + if len(position) != 0 { + opt.Left = position[0] + } + return opt } -func convertPercent(value string) float64 { - if !strings.HasSuffix(value, "%") { - return -1 +type legend struct { + d *Draw + opt *LegendOption +} + +func NewLegend(d *Draw, opt LegendOption) *legend { + return &legend{ + d: d, + opt: &opt, } - v, err := strconv.Atoi(strings.ReplaceAll(value, "%", "")) +} + +func (l *legend) Render() (chart.Box, error) { + d := l.d + opt := l.opt + if len(opt.Data) == 0 || isFalse(opt.Show) { + return chart.BoxZero, nil + } + theme := NewTheme(opt.theme) + padding := opt.Style.Padding + legendDraw, err := NewDraw(DrawOption{ + Parent: d, + }, PaddingOption(padding)) if err != nil { - return -1 + return chart.BoxZero, err } - return float64(v) / 100 -} + r := legendDraw.Render + opt.Style.GetTextOptions().WriteToRenderer(r) -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" + x := 0 + y := 0 + top := 0 + // TODO TOP 暂只支持数值 + if opt.Top != "" { + top, _ = strconv.Atoi(opt.Top) + y += top } + legendWidth := 30 + legendDotHeight := 5 + textPadding := 5 + legendMargin := 10 + // 往下移2倍dot的高度 + y += 2 * legendDotHeight - 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) + widthCount := 0 + maxTextWidth := 0 + // 文本宽度 + for _, text := range opt.Data { + b := r.MeasureText(text) + if b.Width() > maxTextWidth { + maxTextWidth = b.Width() } - v, _ := strconv.Atoi(leftValue) - return v + widthCount += b.Width() } - if rightValue != "" { - percent := convertPercent(rightValue) - if percent >= 0 { - return canvasWidth - legendBoxWidth - int(float64(canvasWidth)*percent) - } - v, _ := strconv.Atoi(rightValue) - return canvasWidth - legendBoxWidth - v + if opt.Orient == OrientVertical { + widthCount = maxTextWidth + legendWidth + textPadding + } else { + // 加上标记 + widthCount += legendWidth * len(opt.Data) + // 文本的padding + widthCount += 2 * textPadding * len(opt.Data) + // margin的宽度 + widthCount += legendMargin * (len(opt.Data) - 1) } - 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, + left := 0 + switch opt.Left { + case PositionRight: + left = legendDraw.Box.Width() - widthCount + case PositionCenter: + left = (legendDraw.Box.Width() - widthCount) >> 1 + default: + if strings.HasSuffix(opt.Left, "%") { + value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) + left = legendDraw.Box.Width() * value / 100 + } else { + value, _ := strconv.Atoi(opt.Left) + left = value } + } + x = left + for index, text := range opt.Data { + seriesColor := theme.GetSeriesColor(index) + fillColor := seriesColor + if !theme.IsDark() { + fillColor = theme.GetBackgroundColor() + } + style := chart.Style{ + StrokeColor: seriesColor, + FillColor: fillColor, + StrokeWidth: 3, + } + style.GetFillAndStrokeOptions().WriteDrawingOptionsToRenderer(r) - legendStyle := opt.Style.InheritFrom(chartDefaults.InheritFrom(legendDefaults)) + textBox := r.MeasureText(text) + var renderText func() + if opt.Orient == OrientVertical { + // 垂直 + // 重置x的位置 + x = left + renderText = func() { + x += textPadding + legendDraw.text(text, x, y+legendDotHeight) + x += textBox.Width() + y += (2*legendDotHeight + legendMargin) + } - 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()) - } + } else { + // 水平 + if index != 0 { + x += legendMargin + } + renderText = func() { + x += textPadding + legendDraw.text(text, x, y+legendDotHeight) + x += textBox.Width() + x += textPadding } } - - 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) - } + if opt.Align == PositionRight { + renderText() } - legendBoxHeight := textHeight + legendStyle.Padding.Top + legendStyle.Padding.Bottom - chartPadding := cb.Top - legendYMargin := (chartPadding - legendBoxHeight) >> 1 + legendDraw.moveTo(x, y) + legendDraw.lineTo(x+legendWidth, y) + r.Stroke() + legendDraw.circle(float64(legendDotHeight), x+legendWidth>>1, y) + r.FillStroke() + x += legendWidth - 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 - } + if opt.Align != PositionRight { + renderText() } } + legendBox := padding.Clone() + // 计算展示区域 + if opt.Orient == OrientVertical { + legendBox.Right = legendBox.Left + left + maxTextWidth + legendWidth + textPadding + legendBox.Bottom = legendBox.Top + y + } else { + legendBox.Right = legendBox.Left + x + legendBox.Bottom = legendBox.Top + 2*legendDotHeight + top + textPadding + } + return legendBox, nil } diff --git a/legend_test.go b/legend_test.go index 8f21210..c5d7e50 100644 --- a/legend_test.go +++ b/legend_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,91 +23,163 @@ package charts import ( - "bytes" "testing" "github.com/stretchr/testify/assert" "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestNewLegendCustomize(t *testing.T) { +func TestNewLegendOption(t *testing.T) { assert := assert.New(t) - series := GetSeries([]Series{ - { - Name: "chrome", + opt := NewLegendOption([]string{ + "a", + "b", + }, PositionRight) + assert.Equal(LegendOption{ + Data: []string{ + "a", + "b", }, - { - Name: "edge", - }, - }, chart.TickPositionBetweenTicks, "") + Left: PositionRight, + }, opt) +} + +func TestLegendRender(t *testing.T) { + assert := assert.New(t) + + newDraw := func() *Draw { + d, _ := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }) + return d + } + style := chart.Style{ + FontSize: 10, + FontColor: drawing.ColorBlack, + } + style.Font, _ = chart.GetDefaultFont() tests := []struct { - align string - svg string + newDraw func() *Draw + newLegend func(*Draw) *legend + box chart.Box + result string }{ { - align: LegendAlignLeft, - svg: "\\nchromeedge", + newDraw: newDraw, + newLegend: func(d *Draw) *legend { + return NewLegend(d, LegendOption{ + Top: "10", + Data: []string{ + "Mon", + "Tue", + "Wed", + }, + Style: style, + }) + }, + result: "\\nMonTueWed", + box: chart.Box{ + Right: 214, + Bottom: 25, + }, }, { - align: LegendAlignRight, - svg: "\\nchromeedge", + newDraw: newDraw, + newLegend: func(d *Draw) *legend { + return NewLegend(d, LegendOption{ + Top: "10", + Left: PositionRight, + Align: PositionRight, + Data: []string{ + "Mon", + "Tue", + "Wed", + }, + Style: style, + }) + }, + result: "\\nMonTueWed", + box: chart.Box{ + Right: 400, + Bottom: 25, + }, + }, + { + newDraw: newDraw, + newLegend: func(d *Draw) *legend { + return NewLegend(d, LegendOption{ + Top: "10", + Left: PositionCenter, + Data: []string{ + "Mon", + "Tue", + "Wed", + }, + Style: style, + }) + }, + result: "\\nMonTueWed", + box: chart.Box{ + Right: 307, + Bottom: 25, + }, + }, + { + newDraw: newDraw, + newLegend: func(d *Draw) *legend { + return NewLegend(d, LegendOption{ + Top: "10", + Left: PositionLeft, + Data: []string{ + "Mon", + "Tue", + "Wed", + }, + Style: style, + Orient: OrientVertical, + }) + }, + result: "\\nMonTueWed", + box: chart.Box{ + Right: 61, + Bottom: 80, + }, + }, + { + newDraw: newDraw, + newLegend: func(d *Draw) *legend { + return NewLegend(d, LegendOption{ + Top: "10", + Left: "10%", + Data: []string{ + "Mon", + "Tue", + "Wed", + }, + Style: style, + Orient: OrientVertical, + }) + }, + box: chart.Box{ + Right: 101, + Bottom: 80, + }, + result: "\\nMonTueWed", }, } for _, tt := range tests { - r, err := chart.SVG(800, 600) + d := tt.newDraw() + b, err := tt.newLegend(d).Render() 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.Equal(tt.box, b) + data, err := d.Bytes() assert.Nil(err) - assert.Equal(tt.svg, buf.String()) + assert.NotEmpty(data) + assert.Equal(tt.result, string(data)) } } - -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..78790d3 --- /dev/null +++ b/line_chart.go @@ -0,0 +1,128 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +type lineChartOption struct { + Theme string + SeriesList SeriesList + Font *truetype.Font +} + +func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPointRenderOption, error) { + + theme := NewTheme(opt.Theme) + + d, err := NewDraw(DrawOption{ + Parent: result.d, + }, PaddingOption(chart.Box{ + Top: result.titleBox.Height(), + Left: YAxisWidth, + })) + if err != nil { + return nil, err + } + seriesNames := opt.SeriesList.Names() + + r := d.Render + xRange := result.xRange + markPointRenderOptions := make([]markPointRenderOption, 0) + for i, s := range opt.SeriesList { + // 由于series是for range,为同一个数据,因此需要clone + // 后续需要使用,如mark point + series := s + index := series.index + if index == 0 { + index = i + } + seriesColor := theme.GetSeriesColor(index) + + yRange := result.getYRange(series.YAxisIndex) + points := make([]Point, len(series.Data)) + // mark line + markLineRender(markLineRenderOption{ + Draw: d, + FillColor: seriesColor, + FontColor: theme.GetTextColor(), + StrokeColor: seriesColor, + Font: opt.Font, + Series: &series, + Range: yRange, + }) + + for j, item := range series.Data { + y := yRange.getRestHeight(item.Value) + x := xRange.getWidth(float64(j)) + points[j] = Point{ + Y: y, + X: x, + } + if !series.Label.Show { + continue + } + distance := series.Label.Distance + if distance == 0 { + distance = 5 + } + text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1) + labelStyle := chart.Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + } + if !series.Label.Color.IsZero() { + labelStyle.FontColor = series.Label.Color + } + labelStyle.GetTextOptions().WriteToRenderer(r) + textBox := r.MeasureText(text) + d.text(text, x-textBox.Width()>>1, y-distance) + } + + dotFillColor := drawing.ColorWhite + if theme.IsDark() { + dotFillColor = seriesColor + } + d.Line(points, LineStyle{ + StrokeColor: seriesColor, + StrokeWidth: 2, + DotColor: seriesColor, + DotWidth: 2, + DotFillColor: dotFillColor, + }) + // draw mark point + markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{ + Draw: d, + FillColor: seriesColor, + Font: opt.Font, + Points: points, + Series: &series, + }) + } + + return markPointRenderOptions, nil +} diff --git a/line_chart_test.go b/line_chart_test.go new file mode 100644 index 0000000..62d0a40 --- /dev/null +++ b/line_chart_test.go @@ -0,0 +1,96 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func TestLineChartRender(t *testing.T) { + assert := assert.New(t) + + width := 400 + height := 300 + d, err := NewDraw(DrawOption{ + Width: width, + Height: height, + }) + assert.Nil(err) + + result := basicRenderResult{ + xRange: &Range{ + Min: 0, + Max: 4, + divideCount: 4, + Size: width, + Boundary: true, + }, + yRangeList: []*Range{ + { + divideCount: 6, + Max: 100, + Min: 0, + Size: height, + }, + }, + d: d, + } + f, _ := chart.GetDefaultFont() + lineChartRender(lineChartOption{ + Font: f, + SeriesList: SeriesList{ + { + Label: SeriesLabel{ + Show: true, + Color: drawing.ColorBlue, + }, + MarkLine: NewMarkLine( + SeriesMarkDataTypeAverage, + ), + Data: []SeriesData{ + { + Value: 20, + }, + { + Value: 60, + }, + { + Value: 90, + }, + }, + }, + NewSeriesFromValues([]float64{ + 40, + 60, + 70, + }), + }, + }, &result) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal("\\n56.66206090", string(data)) +} 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/mark_line.go b/mark_line.go new file mode 100644 index 0000000..464fe71 --- /dev/null +++ b/mark_line.go @@ -0,0 +1,92 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func NewMarkLine(markLineTypes ...string) SeriesMarkLine { + data := make([]SeriesMarkData, len(markLineTypes)) + for index, t := range markLineTypes { + data[index] = SeriesMarkData{ + Type: t, + } + } + return SeriesMarkLine{ + Data: data, + } +} + +type markLineRenderOption struct { + Draw *Draw + FillColor drawing.Color + FontColor drawing.Color + StrokeColor drawing.Color + Font *truetype.Font + Series *Series + Range *Range +} + +func markLineRender(opt markLineRenderOption) { + d := opt.Draw + s := opt.Series + if len(s.MarkLine.Data) == 0 { + return + } + r := d.Render + summary := s.Summary() + for _, markLine := range s.MarkLine.Data { + // 由于mark line会修改style,因此每次重新设置 + chart.Style{ + FillColor: opt.FillColor, + FontColor: opt.FontColor, + FontSize: labelFontSize, + StrokeColor: opt.StrokeColor, + StrokeWidth: 1, + Font: opt.Font, + StrokeDashArray: []float64{ + 4, + 2, + }, + }.WriteToRenderer(r) + value := float64(0) + switch markLine.Type { + case SeriesMarkDataTypeMax: + value = summary.MaxValue + case SeriesMarkDataTypeMin: + value = summary.MinValue + default: + value = summary.AverageValue + } + y := opt.Range.getRestHeight(value) + width := d.Box.Width() + text := commafWithDigits(value) + textBox := r.MeasureText(text) + d.makeLine(0, y, width-2) + d.text(text, width, y+textBox.Height()>>1-2) + } + +} diff --git a/mark_line_test.go b/mark_line_test.go new file mode 100644 index 0000000..abb3308 --- /dev/null +++ b/mark_line_test.go @@ -0,0 +1,99 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func TestNewMarkLine(t *testing.T) { + assert := assert.New(t) + + markLine := NewMarkLine( + SeriesMarkDataTypeMax, + SeriesMarkDataTypeMin, + SeriesMarkDataTypeAverage, + ) + + assert.Equal(SeriesMarkLine{ + Data: []SeriesMarkData{ + { + Type: SeriesMarkDataTypeMax, + }, + { + Type: SeriesMarkDataTypeMin, + }, + { + Type: SeriesMarkDataTypeAverage, + }, + }, + }, markLine) +} + +func TestMarkLineRender(t *testing.T) { + assert := assert.New(t) + + d, err := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }, PaddingOption(chart.Box{ + Left: 20, + Right: 20, + })) + assert.Nil(err) + f, _ := chart.GetDefaultFont() + + markLineRender(markLineRenderOption{ + Draw: d, + FillColor: drawing.ColorBlack, + FontColor: drawing.ColorBlack, + StrokeColor: drawing.ColorBlack, + Font: f, + Series: &Series{ + MarkLine: NewMarkLine( + SeriesMarkDataTypeMax, + SeriesMarkDataTypeMin, + SeriesMarkDataTypeAverage, + ), + Data: NewSeriesDataFromValues([]float64{ + 1, + 3, + 5, + 7, + 9, + }), + }, + Range: &Range{ + Min: 0, + Max: 10, + Size: 200, + }, + }) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal("\\n915", string(data)) +} diff --git a/mark_point.go b/mark_point.go new file mode 100644 index 0000000..5fd34c4 --- /dev/null +++ b/mark_point.go @@ -0,0 +1,89 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint { + data := make([]SeriesMarkData, len(markPointTypes)) + for index, t := range markPointTypes { + data[index] = SeriesMarkData{ + Type: t, + } + } + return SeriesMarkPoint{ + Data: data, + } +} + +type markPointRenderOption struct { + Draw *Draw + FillColor drawing.Color + Font *truetype.Font + Series *Series + Points []Point +} + +func markPointRender(opt markPointRenderOption) { + d := opt.Draw + s := opt.Series + if len(s.MarkPoint.Data) == 0 { + return + } + points := opt.Points + summary := s.Summary() + symbolSize := s.MarkPoint.SymbolSize + if symbolSize == 0 { + symbolSize = 30 + } + r := d.Render + // 设置填充样式 + chart.Style{ + FillColor: opt.FillColor, + }.WriteToRenderer(r) + // 设置文本样式 + chart.Style{ + FontColor: NewTheme(ThemeDark).GetTextColor(), + FontSize: labelFontSize, + StrokeWidth: 1, + Font: opt.Font, + }.WriteTextOptionsToRenderer(r) + for _, markPointData := range s.MarkPoint.Data { + p := points[summary.MinIndex] + value := summary.MinValue + switch markPointData.Type { + case SeriesMarkDataTypeMax: + p = points[summary.MaxIndex] + value = summary.MaxValue + } + + d.pin(p.X, p.Y-symbolSize>>1, symbolSize) + text := commafWithDigits(value) + textBox := r.MeasureText(text) + d.text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2) + } +} diff --git a/mark_point_test.go b/mark_point_test.go new file mode 100644 index 0000000..2cd8fdd --- /dev/null +++ b/mark_point_test.go @@ -0,0 +1,103 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func TestNewMarkPoint(t *testing.T) { + assert := assert.New(t) + + markPoint := NewMarkPoint( + SeriesMarkDataTypeMax, + SeriesMarkDataTypeMin, + SeriesMarkDataTypeAverage, + ) + + assert.Equal(SeriesMarkPoint{ + Data: []SeriesMarkData{ + { + Type: SeriesMarkDataTypeMax, + }, + { + Type: SeriesMarkDataTypeMin, + }, + { + Type: SeriesMarkDataTypeAverage, + }, + }, + }, markPoint) +} + +func TestMarkPointRender(t *testing.T) { + assert := assert.New(t) + + d, err := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }, PaddingOption(chart.Box{ + Left: 20, + Right: 20, + })) + assert.Nil(err) + f, _ := chart.GetDefaultFont() + + markPointRender(markPointRenderOption{ + Draw: d, + FillColor: drawing.ColorBlack, + Font: f, + Series: &Series{ + MarkPoint: NewMarkPoint( + SeriesMarkDataTypeMax, + SeriesMarkDataTypeMin, + ), + Data: NewSeriesDataFromValues([]float64{ + 1, + 3, + 5, + }), + }, + Points: []Point{ + { + X: 1, + Y: 50, + }, + { + X: 100, + Y: 100, + }, + { + X: 200, + Y: 200, + }, + }, + }) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal("\\n51", string(data)) +} diff --git a/pie_chart.go b/pie_chart.go new file mode 100644 index 0000000..f581273 --- /dev/null +++ b/pie_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 ( + "math" + "strconv" + + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" +) + +const defaultRadiusPercent = 0.4 + +func getPieStyle(theme *Theme, index int) chart.Style { + seriesColor := theme.GetSeriesColor(index) + return chart.Style{ + StrokeColor: seriesColor, + StrokeWidth: 1, + FillColor: seriesColor, + } +} + +type pieChartOption struct { + Theme string + Font *truetype.Font + SeriesList SeriesList +} + +func getPieRadius(diameter float64, radiusValue string) float64 { + var radius float64 + if len(radiusValue) != 0 { + v := convertPercent(radiusValue) + if v != -1 { + radius = float64(diameter) * v + } else { + radius, _ = strconv.ParseFloat(radiusValue, 64) + } + } + if radius <= 0 { + radius = float64(diameter) * defaultRadiusPercent + } + return radius +} + +func pieChartRender(opt pieChartOption, result *basicRenderResult) error { + d, err := NewDraw(DrawOption{ + Parent: result.d, + }, PaddingOption(chart.Box{ + Top: result.titleBox.Height(), + })) + if err != nil { + return err + } + + values := make([]float64, len(opt.SeriesList)) + total := float64(0) + radiusValue := "" + for index, series := range opt.SeriesList { + if len(series.Radius) != 0 { + radiusValue = series.Radius + } + value := float64(0) + for _, item := range series.Data { + value += item.Value + } + values[index] = value + total += value + } + r := d.Render + theme := NewTheme(opt.Theme) + + box := d.Box + cx := box.Width() >> 1 + cy := box.Height() >> 1 + + diameter := chart.MinInt(box.Width(), box.Height()) + radius := getPieRadius(float64(diameter), radiusValue) + + labelLineWidth := 15 + if radius < 50 { + labelLineWidth = 10 + } + labelRadius := radius + float64(labelLineWidth) + + seriesNames := opt.SeriesList.Names() + + if len(values) == 1 { + getPieStyle(theme, 0).WriteToRenderer(r) + d.moveTo(cx, cy) + d.circle(radius, cx, cy) + } else { + currentValue := float64(0) + for index, v := range values { + + pieStyle := getPieStyle(theme, index) + pieStyle.WriteToRenderer(r) + d.moveTo(cx, cy) + start := chart.PercentToRadians(currentValue/total) - math.Pi/2 + currentValue += v + percent := (v / total) + delta := chart.PercentToRadians(percent) + d.arcTo(cx, cy, radius, radius, start, delta) + d.lineTo(cx, cy) + r.Close() + r.FillStroke() + + series := opt.SeriesList[index] + // 是否显示label + showLabel := series.Label.Show + if !showLabel { + continue + } + + // label的角度为饼块中间 + angle := start + delta/2 + startx := cx + int(radius*math.Cos(angle)) + starty := cy + int(radius*math.Sin(angle)) + + endx := cx + int(labelRadius*math.Cos(angle)) + endy := cy + int(labelRadius*math.Sin(angle)) + d.moveTo(startx, starty) + d.lineTo(endx, endy) + offset := labelLineWidth + if endx < cx { + offset *= -1 + } + d.moveTo(endx, endy) + endx += offset + d.lineTo(endx, endy) + r.Stroke() + textStyle := chart.Style{ + FontColor: theme.GetTextColor(), + FontSize: labelFontSize, + Font: opt.Font, + } + if !series.Label.Color.IsZero() { + textStyle.FontColor = series.Label.Color + } + textStyle.GetTextOptions().WriteToRenderer(r) + text := NewPieLabelFormatter(seriesNames, series.Label.Formatter)(index, v, percent) + textBox := r.MeasureText(text) + textMargin := 3 + x := endx + textMargin + y := endy + textBox.Height()>>1 - 1 + if offset < 0 { + textWidth := textBox.Width() + x = endx - textWidth - textMargin + } + d.text(text, x, y) + } + } + return nil +} diff --git a/pie_chart_test.go b/pie_chart_test.go new file mode 100644 index 0000000..92ef6d0 --- /dev/null +++ b/pie_chart_test.go @@ -0,0 +1,75 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func TestGetPieRadius(t *testing.T) { + assert := assert.New(t) + + assert.Equal(50.0, getPieRadius(100, "50%")) + assert.Equal(30.0, getPieRadius(100, "30")) + assert.Equal(40.0, getPieRadius(100, "")) +} + +func TestPieChartRender(t *testing.T) { + assert := assert.New(t) + + d, err := NewDraw(DrawOption{ + Width: 250, + Height: 150, + }) + assert.Nil(err) + + f, _ := chart.GetDefaultFont() + + err = pieChartRender(pieChartOption{ + Font: f, + SeriesList: NewPieSeriesList([]float64{ + 5, + 10, + }, PieSeriesOption{ + Names: []string{ + "a", + "b", + }, + Label: SeriesLabel{ + Show: true, + Color: drawing.ColorRed, + }, + Radius: "20%", + }), + }, &basicRenderResult{ + d: d, + }) + assert.Nil(err) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal("\\na: 33.33%b: 66.66%", string(data)) +} diff --git a/range.go b/range.go index 4e00c60..255a51b 100644 --- a/range.go +++ b/range.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,70 +24,86 @@ package charts import ( "math" - - "github.com/wcharczuk/go-chart/v2" ) type Range struct { - TickPosition chart.TickPosition - chart.ContinuousRange + divideCount int + Min float64 + Max float64 + Size int + Boundary bool } -func wrapRange(r chart.Range, tickPosition chart.TickPosition) chart.Range { - xr, ok := r.(*chart.ContinuousRange) - if !ok { - return r +func NewRange(min, max float64, divideCount int) Range { + r := math.Abs(max - min) + + // 最小单位计算 + unit := 2 + if r > 10 { + unit = 4 } - return &Range{ - TickPosition: tickPosition, - ContinuousRange: *xr, + if r > 30 { + unit = 5 + } + if r > 100 { + unit = 10 + } + if r > 200 { + unit = 20 + } + unit = int((r/float64(divideCount))/float64(unit))*unit + unit + + if min != 0 { + isLessThanZero := min < 0 + min = float64(int(min/float64(unit)) * unit) + // 如果是小于0,int的时候向上取整了,因此调整 + if min < 0 || + (isLessThanZero && min == 0) { + min -= float64(unit) + } + } + max = min + float64(unit*divideCount) + return Range{ + Min: min, + Max: max, + divideCount: divideCount, } } -// 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)) +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 := commafWithDigits(v) + values = append(values, value) } - return v + return values } -type HiddenRange struct { - chart.ContinuousRange +func (r *Range) getHeight(value float64) int { + v := (value - r.Min) / (r.Max - r.Min) + return int(v * float64(r.Size)) } -func (r HiddenRange) GetDelta() float64 { - return 0 +func (r *Range) getRestHeight(value float64) int { + return r.Size - r.getHeight(value) } -// Y轴使用的continuous range -// min 与max只允许设置一次 -// 如果是计算得出的max,增加20%的值并取整 -type YContinuousRange struct { - chart.ContinuousRange +func (r *Range) GetRange(index int) (float64, float64) { + unit := float64(r.Size) / float64(r.divideCount) + return unit * float64(index), unit * float64(index+1) +} +func (r *Range) AutoDivide() []int { + return autoDivide(r.Size, r.divideCount) } -func (m YContinuousRange) IsZero() bool { - // 默认返回true,允许修改 - return true -} - -func (m *YContinuousRange) SetMin(min float64) { - // 如果已修改,则忽略 - if m.Min != -math.MaxFloat64 { - return +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) } - 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) + return int(v * float64(r.Size)) } diff --git a/range_test.go b/range_test.go index 33937bf..d1aea8f 100644 --- a/range_test.go +++ b/range_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,72 @@ package charts import ( - "math" "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" ) func TestRange(t *testing.T) { assert := assert.New(t) + r := NewRange(0, 8, 6) + assert.Equal(0.0, r.Min) + assert.Equal(12.0, r.Max) + + r = NewRange(0, 12, 6) + assert.Equal(0.0, r.Min) + assert.Equal(24.0, r.Max) + + r = NewRange(-13, 18, 6) + assert.Equal(-20.0, r.Min) + assert.Equal(40.0, r.Max) + + r = NewRange(0, 150, 6) + assert.Equal(0.0, r.Min) + assert.Equal(180.0, r.Max) + + r = NewRange(0, 400, 6) + assert.Equal(0.0, r.Min) + assert.Equal(480.0, r.Max) +} + +func TestRangeHeightWidth(t *testing.T) { + assert := assert.New(t) + r := NewRange(0, 8, 6) + r.Size = 100 + + assert.Equal(33, r.getHeight(4)) + assert.Equal(67, r.getRestHeight(4)) + + assert.Equal(33, r.getWidth(4)) + r.Boundary = true + assert.Equal(41, r.getWidth(4)) +} + +func TestRangeGetRange(t *testing.T) { + assert := assert.New(t) + r := NewRange(0, 8, 6) + r.Size = 120 + + f1, f2 := r.GetRange(0) + assert.Equal(0.0, f1) + assert.Equal(20.0, f2) + + f1, f2 = r.GetRange(2) + assert.Equal(40.0, f1) + assert.Equal(60.0, f2) +} + +func TestRangeAutoDivide(t *testing.T) { + assert := assert.New(t) + r := Range{ - ContinuousRange: chart.ContinuousRange{ - Min: 0, - Max: 5, - Domain: 500, - }, + Size: 120, + divideCount: 6, } - assert.Equal(100, r.Translate(1)) + assert.Equal([]int{0, 20, 40, 60, 80, 100, 120}, r.AutoDivide()) - r.TickPosition = chart.TickPositionBetweenTicks - assert.Equal(50, r.Translate(1)) -} - -func TestHiddenRange(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()) + r.Size = 130 + assert.Equal([]int{0, 22, 44, 66, 88, 109, 130}, r.AutoDivide()) } diff --git a/series.go b/series.go index f645749..8a9ba73 100644 --- a/series.go +++ b/series.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,113 +23,207 @@ package charts import ( + "math" + "strings" + + "github.com/dustin/go-humanize" "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" ) type SeriesData struct { + // The value of series data Value float64 + // The style of series data Style chart.Style } -type Series struct { - Type string - Name string - Data []SeriesData - XValues []float64 - YAxisIndex int - Style chart.Style - Label SeriesLabel +func NewSeriesFromValues(values []float64, chartType ...string) Series { + s := Series{ + Data: NewSeriesDataFromValues(values), + } + if len(chartType) != 0 { + s.Type = chartType[0] + } + return s } -const lineStrokeWidth = 2 -const dotWith = 2 - -const ( - SeriesBar = "bar" - SeriesLine = "line" - SeriesPie = "pie" -) - -func NewSeriesDataListFromFloat(values []float64) []SeriesData { - dataList := make([]SeriesData, len(values)) +func NewSeriesDataFromValues(values []float64) []SeriesData { + data := make([]SeriesData, len(values)) for index, value := range values { - dataList[index] = SeriesData{ + data[index] = SeriesData{ Value: value, } } - return dataList + return data } -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 +type SeriesLabel struct { + // Data label formatter, which supports string template. + // {b}: the name of a data item. + // {c}: the value of a data item. + // {d}: the percent of a data item(pie chart). + Formatter string + // The color for label + Color drawing.Color + // Show flag for label + Show bool + // Distance to the host graphic element. + Distance int +} + +const ( + SeriesMarkDataTypeMax = "max" + SeriesMarkDataTypeMin = "min" + SeriesMarkDataTypeAverage = "average" +) + +type SeriesMarkData struct { + // The mark data type, it can be "max", "min", "average". + // The "average" is only for mark line + Type string +} +type SeriesMarkPoint struct { + // The width of symbol, default value is 30 + SymbolSize int + // The mark data of series mark point + Data []SeriesMarkData +} +type SeriesMarkLine struct { + // The mark data of series mark line + Data []SeriesMarkData +} +type Series struct { + index int + // The type of series, it can be "line", "bar" or "pie". + // Default value is "line" + Type string + // The data list of series + Data []SeriesData + // The Y axis index, it should be 0 or 1. + // Default value is 1 + YAxisIndex int + // The style for series + Style chart.Style + // The label for series + Label SeriesLabel + // The name of series + Name string + // Radius for Pie chart, e.g.: 40%, default is "40%" + Radius string + // Mark point for series + MarkPoint SeriesMarkPoint + // Make line for series + MarkLine SeriesMarkLine +} +type SeriesList []Series + +type PieSeriesOption struct { + Radius string + Label SeriesLabel + Names []string +} + +func NewPieSeriesList(values []float64, opts ...PieSeriesOption) []Series { + result := make([]Series, len(values)) + var opt PieSeriesOption + if len(opts) != 0 { + opt = opts[0] + } + for index, v := range values { + name := "" + if index < len(opt.Names) { + name = opt.Names[index] + } + s := Series{ + Type: ChartTypePie, + Data: []SeriesData{ + { + Value: v, + }, + }, + Radius: opt.Radius, + Label: opt.Label, + Name: name, + } + result[index] = s + } + return result +} + +type seriesSummary struct { + MaxIndex int + MaxValue float64 + MinIndex int + MinValue float64 + AverageValue float64 +} + +func (s *Series) Summary() seriesSummary { + minIndex := -1 + maxIndex := -1 + minValue := math.MaxFloat64 + maxValue := -math.MaxFloat64 + sum := float64(0) + for j, item := range s.Data { + if item.Value < minValue { + minIndex = j + minValue = item.Value + } + if item.Value > maxValue { + maxIndex = j + maxValue = item.Value + } + sum += item.Value + } + return seriesSummary{ + MaxIndex: maxIndex, + MaxValue: maxValue, + MinIndex: minIndex, + MinValue: minValue, + AverageValue: sum / float64(len(s.Data)), + } +} + +func (sl SeriesList) Names() []string { + names := make([]string, len(sl)) + for index, s := range sl { + names[index] = s.Name + } + return names +} + +type LabelFormatter func(index int, value float64, percent float64) string + +func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter { + if len(layout) == 0 { + layout = "{b}: {d}" + } + return NewLabelFormatter(seriesNames, layout) +} + +func NewValueLabelFormater(seriesNames []string, layout string) LabelFormatter { + if len(layout) == 0 { + layout = "{c}" + } + return NewLabelFormatter(seriesNames, layout) +} + +func NewLabelFormatter(seriesNames []string, layout string) LabelFormatter { + return func(index int, value, percent float64) string { + // 如果无percent的则设置为<0 + percentText := "" + if percent >= 0 { + percentText = humanize.FtoaWithDigits(percent*100, 2) + "%" + } + valueText := humanize.FtoaWithDigits(value, 2) + name := "" + if len(seriesNames) > index { + name = seriesNames[index] + } + text := strings.ReplaceAll(layout, "{c}", valueText) + text = strings.ReplaceAll(text, "{d}", percentText) + text = strings.ReplaceAll(text, "{b}", name) + return text + } } diff --git a/series_test.go b/series_test.go index 5016aab..aae83de 100644 --- a/series_test.go +++ b/series_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 @@ -26,10 +26,28 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/wcharczuk/go-chart/v2" ) -func TestNewSeriesDataListFromFloat(t *testing.T) { +func TestNewSeriesFromValues(t *testing.T) { + assert := assert.New(t) + + assert.Equal(Series{ + Data: []SeriesData{ + { + Value: 1, + }, + { + Value: 2, + }, + }, + Type: ChartTypeBar, + }, NewSeriesFromValues([]float64{ + 1, + 2, + }, ChartTypeBar)) +} + +func TestNewSeriesDataFromValues(t *testing.T) { assert := assert.New(t) assert.Equal([]SeriesData{ @@ -39,87 +57,110 @@ func TestNewSeriesDataListFromFloat(t *testing.T) { { Value: 2, }, - }, NewSeriesDataListFromFloat([]float64{ + }, NewSeriesDataFromValues([]float64{ 1, 2, })) } -func TestGetSeries(t *testing.T) { +func TestNewPieSeriesList(t *testing.T) { assert := assert.New(t) - xValues := []float64{ + assert.Equal([]Series{ + { + Type: ChartTypePie, + Name: "a", + Label: SeriesLabel{ + Show: true, + }, + Radius: "30%", + Data: []SeriesData{ + { + Value: 1, + }, + }, + }, + { + Type: ChartTypePie, + Name: "b", + Label: SeriesLabel{ + Show: true, + }, + Radius: "30%", + Data: []SeriesData{ + { + Value: 2, + }, + }, + }, + }, NewPieSeriesList([]float64{ 1, 2, - 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, + }, PieSeriesOption{ + Radius: "30%", + Label: SeriesLabel{ + Show: true, }, - { - Data: NewSeriesDataListFromFloat([]float64{ - 11, - 21, - 31, - 41, - 51, - }), - XValues: xValues, + Names: []string{ + "a", + "b", }, - }, 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) + })) +} + +func TestSeriesSummary(t *testing.T) { + assert := assert.New(t) + + s := Series{ + Data: NewSeriesDataFromValues([]float64{ + 1, + 3, + 5, + 7, + 9, + }), + } + assert.Equal(seriesSummary{ + MaxIndex: 4, + MaxValue: 9, + MinIndex: 0, + MinValue: 1, + AverageValue: 5, + }, s.Summary()) +} + +func TestGetSeriesNames(t *testing.T) { + assert := assert.New(t) + + sl := SeriesList{ + { + Name: "a", + }, + { + Name: "b", + }, + } + assert.Equal([]string{ + "a", + "b", + }, sl.Names()) +} + +func TestNewPieLabelFormatter(t *testing.T) { + assert := assert.New(t) + + fn := NewPieLabelFormatter([]string{ + "a", + "b", + }, "") + assert.Equal("a: 35%", fn(0, 1.2, 0.35)) +} + +func TestNewValueLabelFormater(t *testing.T) { + assert := assert.New(t) + fn := NewValueLabelFormater([]string{ + "a", + "b", + }, "") + assert.Equal("1.2", fn(0, 1.2, 0.35)) } diff --git a/theme.go b/theme.go index 63e000a..d5d51cd 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 @@ -23,200 +23,177 @@ package charts import ( - "regexp" - "strconv" - "strings" - - "github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2/drawing" ) -var hiddenColor = drawing.Color{R: 255, G: 255, B: 255, A: 0} +const ThemeDark = "dark" +const ThemeLight = "light" +const ThemeGrafana = "grafana" -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 { + palette *themeColorPalette } -var GridColorDark = drawing.Color{ - R: 72, - G: 71, - B: 83, - A: 255, +type themeColorPalette struct { + isDarkMode bool + axisStrokeColor drawing.Color + axisSplitLineColor drawing.Color + backgroundColor drawing.Color + textColor drawing.Color + seriesColors []drawing.Color } -var GridColorLight = drawing.Color{ - R: 224, - G: 230, - B: 241, - A: 255, -} +var palettes = map[string]*themeColorPalette{} -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 +func init() { + echartSeriesColors := []drawing.Color{ + parseColor("#5470c6"), + parseColor("#91cc75"), + parseColor("#fac858"), + parseColor("#ee6666"), + parseColor("#73c0de"), + parseColor("#3ba272"), + parseColor("#fc8452"), + parseColor("#9a60b4"), + parseColor("#ea7ccc"), } - return AxisColorLight -} - -func getGridColor(theme string) drawing.Color { - if theme == ThemeDark { - return GridColorDark + grafanaSeriesColors := []drawing.Color{ + parseColor("#7EB26D"), + parseColor("#EAB839"), + parseColor("#6ED0E0"), + parseColor("#EF843C"), + parseColor("#E24D42"), + parseColor("#1F78C1"), + parseColor("#705DA0"), + parseColor("#508642"), } - return GridColorLight + AddTheme( + ThemeDark, + true, + drawing.Color{ + R: 185, + G: 184, + B: 206, + A: 255, + }, + drawing.Color{ + R: 72, + G: 71, + B: 83, + A: 255, + }, + drawing.Color{ + R: 16, + G: 12, + B: 42, + A: 255, + }, + drawing.Color{ + R: 238, + G: 238, + B: 238, + A: 255, + }, + echartSeriesColors, + ) + + AddTheme( + ThemeLight, + false, + drawing.Color{ + R: 110, + G: 112, + B: 121, + A: 255, + }, + drawing.Color{ + R: 224, + G: 230, + B: 242, + A: 255, + }, + drawing.ColorWhite, + drawing.Color{ + R: 70, + G: 70, + B: 70, + A: 255, + }, + echartSeriesColors, + ) + AddTheme( + ThemeGrafana, + true, + drawing.Color{ + R: 185, + G: 184, + B: 206, + A: 255, + }, + drawing.Color{ + R: 68, + G: 67, + B: 67, + A: 255, + }, + drawing.Color{ + R: 31, + G: 29, + B: 29, + A: 255, + }, + drawing.Color{ + R: 216, + G: 217, + B: 218, + A: 255, + }, + grafanaSeriesColors, + ) } -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 +func AddTheme(name string, isDarkMode bool, axisStrokeColor, axisSplitLineColor, backgroundColor, textColor drawing.Color, seriesColors []drawing.Color) { + palettes[name] = &themeColorPalette{ + isDarkMode: isDarkMode, + axisStrokeColor: axisStrokeColor, + axisSplitLineColor: axisSplitLineColor, + backgroundColor: backgroundColor, + textColor: textColor, + seriesColors: seriesColors, } - return chart.DefaultBackgroundColor } -func getTextColor(theme string) drawing.Color { - if theme == ThemeDark { - return TextColorDark +func NewTheme(name string) *Theme { + p, ok := palettes[name] + if !ok { + p = palettes[ThemeLight] } - 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 &Theme{ + palette: p, } - return chart.DefaultCanvasColor } -func (tp ThemeColorPalette) CanvasStrokeColor() drawing.Color { - return chart.DefaultCanvasStrokeColor +func (t *Theme) IsDark() bool { + return t.palette.isDarkMode } -func (tp ThemeColorPalette) AxisStrokeColor() drawing.Color { - if tp.Theme == ThemeDark { - return BackgroundColorDark - } - return chart.DefaultAxisColor +func (t *Theme) GetAxisStrokeColor() drawing.Color { + return t.palette.axisStrokeColor } -func (tp ThemeColorPalette) TextColor() drawing.Color { - return getTextColor(tp.Theme) +func (t *Theme) GetAxisSplitLineColor() drawing.Color { + return t.palette.axisSplitLineColor } -func (tp ThemeColorPalette) GetSeriesColor(index int) drawing.Color { - return getSeriesColor(tp.Theme, index) +func (t *Theme) GetSeriesColor(index int) drawing.Color { + colors := t.palette.seriesColors + return colors[index%len(colors)] } -func getSeriesColor(theme string, index int) drawing.Color { - return SeriesColorsLight[index%len(SeriesColorsLight)] +func (t *Theme) GetBackgroundColor() drawing.Color { + return t.palette.backgroundColor } -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 - } - } - return c +func (t *Theme) GetTextColor() drawing.Color { + return t.palette.textColor } diff --git a/theme_test.go b/theme_test.go index a25a2db..bf22afd 100644 --- a/theme_test.go +++ b/theme_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 @@ -26,97 +26,62 @@ 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) { +func TestTheme(t *testing.T) { assert := assert.New(t) - assert.Equal(AxisColorDark, getAxisColor(ThemeDark)) - assert.Equal(AxisColorLight, getAxisColor("")) + darkTheme := NewTheme(ThemeDark) + lightTheme := NewTheme(ThemeLight) - assert.Equal(GridColorDark, getGridColor(ThemeDark)) - assert.Equal(GridColorLight, getGridColor("")) + assert.True(darkTheme.IsDark()) + assert.False(lightTheme.IsDark()) - 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, + R: 185, + G: 184, + B: 206, A: 255, - }, c) - - c = parseColor("#313233") + }, darkTheme.GetAxisStrokeColor()) assert.Equal(drawing.Color{ - R: 49, - G: 50, - B: 51, + R: 110, + G: 112, + B: 121, A: 255, - }, c) + }, lightTheme.GetAxisStrokeColor()) - c = parseColor("rgb(31,32,33)") assert.Equal(drawing.Color{ - R: 31, - G: 32, - B: 33, + R: 72, + G: 71, + B: 83, A: 255, - }, c) - - c = parseColor("rgba(50,51,52,250)") + }, darkTheme.GetAxisSplitLineColor()) assert.Equal(drawing.Color{ - R: 50, - G: 51, - B: 52, - A: 250, - }, c) + R: 224, + G: 230, + B: 242, + A: 255, + }, lightTheme.GetAxisSplitLineColor()) + + assert.Equal(drawing.Color{ + R: 16, + G: 12, + B: 42, + A: 255, + }, darkTheme.GetBackgroundColor()) + assert.Equal(drawing.ColorWhite, lightTheme.GetBackgroundColor()) + + assert.Equal(drawing.Color{ + R: 238, + G: 238, + B: 238, + A: 255, + }, darkTheme.GetTextColor()) + assert.Equal(drawing.Color{ + R: 70, + G: 70, + B: 70, + A: 255, + }, lightTheme.GetTextColor()) } diff --git a/title.go b/title.go index 228b2c0..07a2eef 100644 --- a/title.go +++ b/title.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 @@ -29,75 +29,127 @@ import ( "github.com/wcharczuk/go-chart/v2" ) +type TitleOption struct { + // Title text, support \n for new line + Text string + // Subtitle text, support \n for new line + Subtext string + // Title style + Style chart.Style + // Subtitle style + SubtextStyle chart.Style + // Distance between title component and the left side of the container. + // It can be pixel value: 20, percentage value: 20%, + // or position value: right, center. + Left string + // Distance between title component and the top side of the container. + // It can be pixel value: 20. + Top string +} type titleMeasureOption struct { width int height int text string + style chart.Style } -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 +func splitTitleText(text string) []string { + arr := strings.Split(text, "\n") + result := make([]string, 0) + for _, v := range arr { + v = strings.TrimSpace(v) + if v == "" { + continue } - font := title.Font - if font == nil { - font, _ = chart.GetDefaultFont() + result = append(result, v) + } + return result +} + +func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) { + if len(opt.Text) == 0 { + return chart.BoxZero, nil + } + + padding := opt.Style.Padding + d, err := NewDraw(DrawOption{ + Parent: p, + }, PaddingOption(padding)) + if err != nil { + return chart.BoxZero, err + } + + r := d.Render + + measureOptions := make([]titleMeasureOption, 0) + + // 主标题 + for _, v := range splitTitleText(opt.Text) { + measureOptions = append(measureOptions, titleMeasureOption{ + text: v, + style: opt.Style.GetTextOptions(), + }) + } + // 副标题 + for _, v := range splitTitleText(opt.Subtext) { + measureOptions = append(measureOptions, titleMeasureOption{ + text: v, + style: opt.SubtextStyle.GetTextOptions(), + }) + } + + textMaxWidth := 0 + textMaxHeight := 0 + width := 0 + for index, item := range measureOptions { + item.style.WriteTextOptionsToRenderer(r) + textBox := r.MeasureText(item.text) + + w := textBox.Width() + h := textBox.Height() + if w > textMaxWidth { + textMaxWidth = w } - 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, - } + if h > textMaxHeight { + textMaxHeight = 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 + measureOptions[index].height = h + measureOptions[index].width = w + } + width = textMaxWidth + titleX := 0 + b := d.Box + switch opt.Left { + case PositionRight: + titleX = b.Width() - textMaxWidth + case PositionCenter: + titleX = b.Width()>>1 - (textMaxWidth >> 1) + default: + if strings.HasSuffix(opt.Left, "%") { + value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) + titleX = b.Width() * value / 100 + } else { + value, _ := strconv.Atoi(opt.Left) + titleX = value } } + titleY := 0 + // TODO TOP 暂只支持数值 + if opt.Top != "" { + value, _ := strconv.Atoi(opt.Top) + titleY += value + } + for _, item := range measureOptions { + item.style.WriteTextOptionsToRenderer(r) + x := titleX + (textMaxWidth-item.width)>>1 + y := titleY + item.height + d.text(item.text, x, y) + titleY += item.height + } + height := titleY + padding.Top + padding.Bottom + box := padding.Clone() + box.Right = box.Left + titleX + width + box.Bottom = box.Top + height + + return box, nil } diff --git a/title_test.go b/title_test.go index 0fe8256..23573c3 100644 --- a/title_test.go +++ b/title_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,7 +23,6 @@ package charts import ( - "bytes" "testing" "github.com/stretchr/testify/assert" @@ -31,55 +30,113 @@ import ( "github.com/wcharczuk/go-chart/v2/drawing" ) -func TestTitleCustomize(t *testing.T) { +func TestSplitTitleText(t *testing.T) { assert := assert.New(t) + + assert.Equal([]string{ + "a", + "b", + }, splitTitleText("a\nb")) + assert.Equal([]string{ + "a", + }, splitTitleText("a\n ")) +} + +func TestDrawTitle(t *testing.T) { + assert := assert.New(t) + + newOption := func() *TitleOption { + f, _ := chart.GetDefaultFont() + return &TitleOption{ + Text: "title\nHello", + Subtext: "subtitle\nWorld!", + Style: chart.Style{ + FontSize: 14, + Font: f, + FontColor: drawing.ColorBlack, + }, + SubtextStyle: chart.Style{ + FontSize: 10, + Font: f, + FontColor: drawing.ColorBlue, + }, + } + } + newDraw := func() *Draw { + d, _ := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }) + return d + } + tests := []struct { - title Title - svg string + newDraw func() *Draw + newOption func() *TitleOption + result string + box chart.Box }{ - // 单行标题 { - title: Title{ - Text: "Hello World!", - Style: chart.Style{ - FontColor: drawing.ColorBlack, - }, + newDraw: newDraw, + newOption: newOption, + result: "\\ntitleHellosubtitleWorld!", + box: chart.Box{ + Right: 43, + Bottom: 58, }, - svg: "\\nHello World!", }, - // 多行标题,靠右 { - title: Title{ - Text: "Hello World!\nHello World", - Style: chart.Style{ - FontColor: drawing.ColorBlack, - }, - Left: "right", + newDraw: newDraw, + newOption: func() *TitleOption { + opt := newOption() + opt.Left = PositionRight + opt.Top = "50" + return opt + }, + result: "\\ntitleHellosubtitleWorld!", + box: chart.Box{ + Right: 400, + Bottom: 108, }, - svg: "\\nHello World!Hello World", }, - // 标题居中 { - title: Title{ - Text: "Hello World!", - Style: chart.Style{ - FontColor: drawing.ColorBlack, - }, - Left: "center", + newDraw: newDraw, + newOption: func() *TitleOption { + opt := newOption() + opt.Left = PositionCenter + opt.Top = "10" + return opt + }, + result: "\\ntitleHellosubtitleWorld!", + box: chart.Box{ + Right: 222, + Bottom: 68, + }, + }, + { + newDraw: newDraw, + newOption: func() *TitleOption { + opt := newOption() + opt.Left = "10%" + opt.Top = "10" + return opt + }, + result: "\\ntitleHellosubtitleWorld!", + box: chart.Box{ + Right: 83, + Bottom: 68, }, - svg: "\\nHello World!", }, } for _, tt := range tests { - r, err := chart.SVG(800, 600) + d := tt.newDraw() + o := tt.newOption() + b, err := drawTitle(d, o) 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.Equal(tt.box, b) + data, err := d.Bytes() assert.Nil(err) - assert.Equal(tt.svg, buf.String()) + assert.NotEmpty(data) + assert.Equal(tt.result, string(data)) } } diff --git a/util.go b/util.go new file mode 100644 index 0000000..2adaba8 --- /dev/null +++ b/util.go @@ -0,0 +1,170 @@ +// 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 ( + "regexp" + "strconv" + "strings" + + "github.com/dustin/go-humanize" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +func TrueFlag() *bool { + t := true + return &t +} + +func FalseFlag() *bool { + f := false + return &f +} + +func ceilFloatToInt(value float64) int { + i := int(value) + if value == float64(i) { + return i + } + return i + 1 +} + +func getDefaultInt(value, defaultValue int) int { + if value == 0 { + return defaultValue + } + return value +} + +func autoDivide(max, size int) []int { + unit := max / size + + rest := max - unit*size + values := make([]int, size+1) + value := 0 + for i := 0; i < size; i++ { + values[i] = value + if i < rest { + value++ + } + value += unit + } + values[size] = max + return values +} + +// measureTextMaxWidthHeight returns maxWidth and maxHeight of text list +func measureTextMaxWidthHeight(textList []string, r chart.Renderer) (int, int) { + maxWidth := 0 + maxHeight := 0 + for _, text := range textList { + box := r.MeasureText(text) + maxWidth = chart.MaxInt(maxWidth, box.Width()) + maxHeight = chart.MaxInt(maxHeight, box.Height()) + } + return maxWidth, maxHeight +} + +func reverseStringSlice(stringList []string) { + for i, j := 0, len(stringList)-1; i < j; i, j = i+1, j-1 { + stringList[i], stringList[j] = stringList[j], stringList[i] + } +} + +func reverseIntSlice(intList []int) { + for i, j := 0, len(intList)-1; i < j; i, j = i+1, j-1 { + intList[i], intList[j] = intList[j], intList[i] + } +} + +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 isFalse(flag *bool) bool { + if flag != nil && !*flag { + return true + } + return false +} + +func NewFloatPoint(f float64) *float64 { + v := f + return &v +} +func commafWithDigits(value float64) string { + decimals := 2 + m := float64(1000 * 1000) + if value >= m { + return humanize.CommafWithDigits(value/m, decimals) + "M" + } + k := float64(1000) + if value >= k { + return humanize.CommafWithDigits(value/k, decimals) + "k" + } + return humanize.CommafWithDigits(value, decimals) +} + +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 + } + } + return c +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..dc5d98e --- /dev/null +++ b/util_test.go @@ -0,0 +1,181 @@ +// 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 TestGetDefaultInt(t *testing.T) { + assert := assert.New(t) + + assert.Equal(1, getDefaultInt(0, 1)) + assert.Equal(10, getDefaultInt(10, 1)) +} + +func TestCeilFloatToInt(t *testing.T) { + assert := assert.New(t) + + assert.Equal(1, ceilFloatToInt(0.8)) + assert.Equal(1, ceilFloatToInt(1.0)) + assert.Equal(2, ceilFloatToInt(1.2)) +} + +func TestCommafWithDigits(t *testing.T) { + assert := assert.New(t) + + assert.Equal("1.2", commafWithDigits(1.2)) + assert.Equal("1.21", commafWithDigits(1.21231)) + + assert.Equal("1.20k", commafWithDigits(1200.121)) + assert.Equal("1.20M", commafWithDigits(1200000.121)) +} + +func TestAutoDivide(t *testing.T) { + assert := assert.New(t) + + assert.Equal([]int{ + 0, + 86, + 172, + 258, + 344, + 430, + 515, + 600, + }, autoDivide(600, 7)) +} + +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) +} + +func TestConvertPercent(t *testing.T) { + assert := assert.New(t) + + assert.Equal(-1.0, convertPercent("1")) + assert.Equal(-1.0, convertPercent("a%")) + assert.Equal(0.1, convertPercent("10%")) +} + +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/xaxis.go b/xaxis.go new file mode 100644 index 0000000..1dca7bb --- /dev/null +++ b/xaxis.go @@ -0,0 +1,97 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/wcharczuk/go-chart/v2" +) + +type XAxisOption struct { + // The boundary gap on both sides of a coordinate axis. + // Nil or *true means the center part of two axis ticks + BoundaryGap *bool + // The data value of x axis + Data []string + // The theme of chart + Theme string + // Hidden x axis + Hidden bool + // Number of segments that the axis is split into. Note that this number serves only as a recommendation. + SplitNumber int +} + +func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption { + opt := XAxisOption{ + Data: data, + } + if len(boundaryGap) != 0 { + opt.BoundaryGap = boundaryGap[0] + } + return opt +} + +// drawXAxis draws x axis, and returns the height, range of if. +func drawXAxis(p *Draw, opt *XAxisOption, yAxisCount int) (int, *Range, error) { + if opt.Hidden { + return 0, nil, nil + } + left := YAxisWidth + right := (yAxisCount - 1) * YAxisWidth + dXAxis, err := NewDraw( + DrawOption{ + Parent: p, + }, + PaddingOption(chart.Box{ + Left: left, + Right: right, + }), + ) + if err != nil { + return 0, nil, err + } + theme := NewTheme(opt.Theme) + data := NewAxisDataListFromStringList(opt.Data) + style := AxisOption{ + BoundaryGap: opt.BoundaryGap, + StrokeColor: theme.GetAxisStrokeColor(), + FontColor: theme.GetAxisStrokeColor(), + StrokeWidth: 1, + SplitNumber: opt.SplitNumber, + } + + boundary := true + max := float64(len(opt.Data)) + if isFalse(opt.BoundaryGap) { + boundary = false + max-- + } + axis := NewAxis(dXAxis, data, style) + axis.Render() + return axis.measure().Height, &Range{ + divideCount: len(opt.Data), + Min: 0, + Max: max, + Size: dXAxis.Box.Width(), + Boundary: boundary, + }, nil +} diff --git a/xaxis_test.go b/xaxis_test.go new file mode 100644 index 0000000..267cdb1 --- /dev/null +++ b/xaxis_test.go @@ -0,0 +1,108 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewXAxisOption(t *testing.T) { + assert := assert.New(t) + + opt := NewXAxisOption([]string{ + "a", + "b", + }, FalseFlag()) + + assert.Equal(XAxisOption{ + Data: []string{ + "a", + "b", + }, + BoundaryGap: FalseFlag(), + }, opt) + +} +func TestDrawXAxis(t *testing.T) { + assert := assert.New(t) + + newDraw := func() *Draw { + d, _ := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }) + return d + } + + tests := []struct { + newDraw func() *Draw + newOption func() *XAxisOption + result string + }{ + { + newDraw: newDraw, + newOption: func() *XAxisOption { + return &XAxisOption{ + BoundaryGap: FalseFlag(), + Data: []string{ + "Mon", + "Tue", + }, + } + }, + result: "\\nMonTue", + }, + { + newDraw: newDraw, + newOption: func() *XAxisOption { + return &XAxisOption{ + Data: []string{ + "01-01", + "01-02", + "01-03", + "01-04", + "01-05", + "01-06", + "01-07", + "01-08", + "01-09", + }, + SplitNumber: 3, + } + }, + result: "\\n01-0201-0501-08", + }, + } + + for _, tt := range tests { + d := tt.newDraw() + height, _, err := drawXAxis(d, tt.newOption(), 1) + assert.Nil(err) + assert.Equal(25, height) + data, err := d.Bytes() + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +} diff --git a/yaxis.go b/yaxis.go new file mode 100644 index 0000000..99093ec --- /dev/null +++ b/yaxis.go @@ -0,0 +1,102 @@ +// 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 ( + "strings" + + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +type YAxisOption struct { + // The minimun value of axis. + Min *float64 + // The maximum value of axis. + Max *float64 + // Hidden y axis + Hidden bool + // Formatter for y axis text value + Formatter string + // Color for y axis + Color drawing.Color +} + +// TODO 长度是否可以变化 +const YAxisWidth = 40 + +func drawYAxis(p *Draw, opt *ChartOption, axisIndex, xAxisHeight int, padding chart.Box) (*Range, error) { + theme := NewTheme(opt.Theme) + yRange := opt.newYRange(axisIndex) + values := yRange.Values() + yAxis := opt.YAxisList[axisIndex] + formatter := yAxis.Formatter + if len(formatter) != 0 { + for index, text := range values { + values[index] = strings.ReplaceAll(formatter, "{value}", text) + } + } + + data := NewAxisDataListFromStringList(values) + style := AxisOption{ + Position: PositionLeft, + BoundaryGap: FalseFlag(), + FontColor: theme.GetAxisStrokeColor(), + TickShow: FalseFlag(), + StrokeWidth: 1, + SplitLineColor: theme.GetAxisSplitLineColor(), + SplitLineShow: true, + } + if !yAxis.Color.IsZero() { + style.FontColor = yAxis.Color + style.StrokeColor = yAxis.Color + } + width := NewAxis(p, data, style).measure().Width + + yAxisCount := len(opt.YAxisList) + boxWidth := p.Box.Width() + if axisIndex > 0 { + style.SplitLineShow = false + style.Position = PositionRight + padding.Right += (axisIndex - 1) * YAxisWidth + } else { + boxWidth = p.Box.Width() - (yAxisCount-1)*YAxisWidth + padding.Left += (YAxisWidth - width) + } + + dYAxis, err := NewDraw( + DrawOption{ + Parent: p, + Width: boxWidth, + // 减去x轴的高 + Height: p.Box.Height() - xAxisHeight, + }, + PaddingOption(padding), + ) + if err != nil { + return nil, err + } + NewAxis(dYAxis, data, style).Render() + yRange.Size = dYAxis.Box.Height() + return &yRange, nil +} diff --git a/yaxis_test.go b/yaxis_test.go new file mode 100644 index 0000000..0bbef7a --- /dev/null +++ b/yaxis_test.go @@ -0,0 +1,119 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wcharczuk/go-chart/v2" +) + +func TestDrawYAxis(t *testing.T) { + assert := assert.New(t) + newDraw := func() *Draw { + d, _ := NewDraw(DrawOption{ + Width: 400, + Height: 300, + }) + return d + } + + tests := []struct { + newDraw func() *Draw + newOption func() *ChartOption + axisIndex int + xAxisHeight int + result string + }{ + { + newDraw: newDraw, + newOption: func() *ChartOption { + return &ChartOption{ + YAxisList: []YAxisOption{ + { + Max: NewFloatPoint(20), + }, + }, + SeriesList: []Series{ + { + Data: []SeriesData{ + { + Value: 1, + }, + { + Value: 2, + }, + }, + }, + }, + } + }, + result: "\\n03.336.661013.3316.6620", + }, + { + newDraw: newDraw, + newOption: func() *ChartOption { + return &ChartOption{ + YAxisList: []YAxisOption{ + {}, + { + Max: NewFloatPoint(20), + Formatter: "{value} C", + }, + }, + SeriesList: []Series{ + { + YAxisIndex: 1, + Data: []SeriesData{ + { + Value: 1, + }, + { + Value: 2, + }, + }, + }, + }, + } + }, + axisIndex: 1, + result: "\\n0 C3.33 C6.66 C10 C13.33 C16.66 C20 C", + }, + } + + for _, tt := range tests { + d := tt.newDraw() + r, err := drawYAxis(d, tt.newOption(), tt.axisIndex, tt.xAxisHeight, chart.NewBox(10, 10, 10, 10)) + assert.Nil(err) + assert.Equal(&Range{ + divideCount: 6, + Max: 20, + Size: 280, + }, r) + + data, err := d.Bytes() + assert.Nil(err) + assert.Equal(tt.result, string(data)) + } +}