diff --git a/axis.go b/axis.go index cb4edc1..1086f7a 100644 --- a/axis.go +++ b/axis.go @@ -3,23 +3,21 @@ package charts import ( "math" "strings" - - "github.com/go-analyze/charts/chartdraw" ) type axisPainter struct { p *Painter - opt *AxisOption + opt *axisOption } -func NewAxisPainter(p *Painter, opt AxisOption) *axisPainter { +func newAxisPainter(p *Painter, opt axisOption) *axisPainter { return &axisPainter{ p: p, opt: &opt, } } -type AxisOption struct { +type axisOption struct { // Show specifies if the axis should be rendered, set this to *false (through False()) to hide the axis. Show *bool // Theme specifies the colors used for the axis. @@ -97,22 +95,7 @@ func (a *axisPainter) Render() (Box, error) { tickLength := getDefaultInt(opt.TickLength, 5) labelMargin := getDefaultInt(opt.LabelMargin, 5) - style := chartdraw.Style{ - StrokeColor: theme.GetAxisStrokeColor(), - StrokeWidth: strokeWidth, - FontStyle: fontStyle, - } - top.SetDrawingStyle(style).OverrideFontStyle(style.FontStyle) - - isTextRotation := opt.TextRotation != 0 - - if isTextRotation { - top.SetTextRotation(opt.TextRotation) - } - textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(opt.Data) - if isTextRotation { - top.ClearTextRotation() - } + textMaxWidth, textMaxHeight := top.measureTextMaxWidthHeight(opt.Data, opt.TextRotation, fontStyle) width := 0 height := 0 @@ -221,27 +204,30 @@ func (a *axisPainter) Render() (Box, error) { } else { // there is always one more tick than data sample, and if we are centering labels we use that extra tick to // center the label against, if not centering then we need one less tick spacing - // passing the tickSpaces reduces the need to copy the logic from painter.go:MultiText + // passing the tickSpaces reduces the need to copy the logic from painter.go:multiText tickSpaces-- } if strokeWidth > 0 { + strokeColor := theme.GetAxisStrokeColor() p.Child(PainterPaddingOption(Box{ Top: ticksPaddingTop, Left: ticksPaddingLeft, IsSet: true, - })).Ticks(TicksOption{ - LabelCount: labelCount, - TickCount: tickCount, - TickSpaces: tickSpaces, - Length: tickLength, - Vertical: isVertical, - First: opt.DataStartIndex, + })).ticks(ticksOption{ + labelCount: labelCount, + tickCount: tickCount, + tickSpaces: tickSpaces, + length: tickLength, + vertical: isVertical, + firstIndex: opt.DataStartIndex, + strokeWidth: strokeWidth, + strokeColor: strokeColor, }) p.LineStroke([]Point{ {X: x0, Y: y0}, {X: x1, Y: y1}, - }) + }, strokeColor, strokeWidth) } p.Child(PainterPaddingOption(Box{ @@ -249,23 +235,21 @@ func (a *axisPainter) Render() (Box, error) { Top: labelPaddingTop, Right: labelPaddingRight, IsSet: true, - })).MultiText(MultiTextOption{ - First: opt.DataStartIndex, - Align: textAlign, - TextList: opt.Data, - Vertical: isVertical, - LabelCount: labelCount, - TickCount: tickCount, - LabelSkipCount: opt.LabelSkipCount, - CenterLabels: centerLabels, - TextRotation: opt.TextRotation, - Offset: opt.LabelOffset, + })).multiText(multiTextOption{ + firstIndex: opt.DataStartIndex, + align: textAlign, + textList: opt.Data, + fontStyle: fontStyle, + vertical: isVertical, + labelCount: labelCount, + tickCount: tickCount, + labelSkipCount: opt.LabelSkipCount, + centerLabels: centerLabels, + textRotation: opt.TextRotation, + offset: opt.LabelOffset, }) if opt.SplitLineShow { // show auxiliary lines - style.StrokeColor = theme.GetAxisSplitLineColor() - style.StrokeWidth = 1 - top.OverrideDrawingStyle(style) if isVertical { x0 := p.Width() x1 := top.Width() @@ -279,7 +263,7 @@ func (a *axisPainter) Render() (Box, error) { top.LineStroke([]Point{ {X: x0, Y: y}, {X: x1, Y: y}, - }) + }, theme.GetAxisSplitLineColor(), 1) } } else { y0 := p.Height() - defaultXAxisHeight @@ -292,7 +276,7 @@ func (a *axisPainter) Render() (Box, error) { top.LineStroke([]Point{ {X: x, Y: y0}, {X: x, Y: y1}, - }) + }, theme.GetAxisSplitLineColor(), 1) } } } diff --git a/axis_test.go b/axis_test.go index 35ad4be..8007c89 100644 --- a/axis_test.go +++ b/axis_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/go-analyze/charts/chartdraw" "github.com/go-analyze/charts/chartdraw/drawing" ) @@ -13,151 +14,152 @@ func TestAxis(t *testing.T) { t.Parallel() dayLabels := []string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", } letterLabels := []string{"A", "B", "C", "D", "E", "F", "G"} tests := []struct { - name string - render func(*Painter) ([]byte, error) - result string + name string + optionFactory func() axisOption + padPainter bool + result string }{ { - name: "x-axis_bottom", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + name: "x-axis_bottom", + padPainter: true, + optionFactory: func() axisOption { + opt := XAxisOption{ + Data: dayLabels, + BoundaryGap: True(), + FontStyle: FontStyle{ + FontSize: 18, + }, + } + return opt.toAxisOption() + }, + result: "MonTueWedThuFriSatSun", + }, + { + name: "x-axis_bottom_splitline", + optionFactory: func() axisOption { + return axisOption{ Data: dayLabels, SplitLineShow: true, - }).Render() - return p.Bytes() + } }, - result: "MonTueWedThuFriSatSun", + result: "MonTueWedThuFriSatSun", }, { name: "x-axis_bottom_left", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + optionFactory: func() axisOption { + return axisOption{ Data: dayLabels, BoundaryGap: False(), - }).Render() - return p.Bytes() + } }, - result: "MonTueWedThuFriSatSun", + result: "MonTueWedThuFriSatSun", }, { name: "y-axis_left", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ - Data: dayLabels, - Position: PositionLeft, - }).Render() - return p.Bytes() + optionFactory: func() axisOption { + opt := YAxisOption{ + Data: dayLabels, + Position: PositionLeft, + isCategoryAxis: true, + } + return opt.toAxisOption(nil) }, - result: "MonTueWedThuFriSatSun", + result: "MonTueWedThuFriSatSun", }, { name: "y-axis_center", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + optionFactory: func() axisOption { + return axisOption{ Data: dayLabels, Position: PositionLeft, BoundaryGap: False(), SplitLineShow: true, - }).Render() - return p.Bytes() + } }, - result: "MonTueWedThuFriSatSun", + result: "MonTueWedThuFriSatSun", }, { name: "y-axis_right", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + optionFactory: func() axisOption { + return axisOption{ Data: dayLabels, Position: PositionRight, BoundaryGap: False(), SplitLineShow: true, - }).Render() - return p.Bytes() + } }, - result: "MonTueWedThuFriSatSun", + result: "MonTueWedThuFriSatSun", }, { name: "top", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + optionFactory: func() axisOption { + return axisOption{ Data: dayLabels, Formatter: "{value} --", Position: PositionTop, - }).Render() - return p.Bytes() + } }, - result: "Mon --Tue --Wed --Thu --Fri --Sat --Sun --", + result: "Mon --Tue --Wed --Thu --Fri --Sat --Sun --", }, { name: "reduced_label_count", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + optionFactory: func() axisOption { + return axisOption{ Data: letterLabels, SplitLineShow: false, LabelCountAdjustment: -1, - }).Render() - return p.Bytes() + } }, - result: "ABCEFG", + result: "ABCEFG", }, { name: "custom_unit", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + optionFactory: func() axisOption { + return axisOption{ Data: letterLabels, SplitLineShow: false, Unit: 10, - }).Render() - return p.Bytes() + } }, - result: "AEG", + result: "AEG", }, { name: "custom_font", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + optionFactory: func() axisOption { + return axisOption{ Data: letterLabels, FontStyle: FontStyle{ FontSize: 40.0, FontColor: drawing.ColorBlue, }, - }).Render() - return p.Bytes() + } }, - result: "ABCDEFG", + result: "ABCDEFG", }, { name: "boundary_gap_disable", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + optionFactory: func() axisOption { + return axisOption{ Data: letterLabels, BoundaryGap: False(), - }).Render() - return p.Bytes() + } }, - result: "ABCDEFG", + result: "ABCDEFG", }, { name: "boundary_gap_enable", - render: func(p *Painter) ([]byte, error) { - _, _ = NewAxisPainter(p, AxisOption{ + optionFactory: func() axisOption { + return axisOption{ Data: letterLabels, BoundaryGap: True(), - }).Render() - return p.Bytes() + } }, - result: "ABCDEFG", + result: "ABCDEFG", }, } @@ -170,13 +172,18 @@ func TestAxis(t *testing.T) { }) for i, tt := range tests { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 600, Height: 400, }, PainterThemeOption(axisTheme)) + if tt.padPainter { + p = p.Child(PainterPaddingOption(chartdraw.NewBox(50, 50, 50, 50))) + } + + _, err := newAxisPainter(p, tt.optionFactory()).Render() require.NoError(t, err) - data, err := tt.render(p) + data, err := p.Bytes() require.NoError(t, err) assertEqualSVG(t, tt.result, data) }) diff --git a/bar_chart.go b/bar_chart.go index 7a690ac..83cbdce 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -5,8 +5,6 @@ import ( "math" "github.com/golang/freetype/truetype" - - "github.com/go-analyze/charts/chartdraw" ) type barChart struct { @@ -14,14 +12,27 @@ type barChart struct { opt *BarChartOption } -// NewBarChart returns a bar chart renderer -func NewBarChart(p *Painter, opt BarChartOption) *barChart { +// newBarChart returns a bar chart renderer +func newBarChart(p *Painter, opt BarChartOption) *barChart { return &barChart{ p: p, opt: &opt, } } +// NewBarChartOptionWithData returns an initialized BarChartOption with the SeriesList set for the provided data slice. +func NewBarChartOptionWithData(data [][]float64) BarChartOption { + sl := NewSeriesListBar(data) + return BarChartOption{ + SeriesList: sl, + Padding: defaultPadding, + Theme: GetDefaultTheme(), + Font: GetDefaultFont(), + YAxis: make([]YAxisOption, sl.getYAxisCount()), + ValueFormatter: defaultValueFormatter, + } +} + type BarChartOption struct { // Theme specifies the colors used for the bar chart. Theme ColorPalette @@ -43,6 +54,8 @@ type BarChartOption struct { BarWidth int // RoundedBarCaps set to `true` to produce a bar graph where the bars have rounded tops. RoundedBarCaps *bool + // ValueFormatter defines how float values should be rendered to strings, notably for numeric axis labels. + ValueFormatter ValueFormatter } func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { @@ -50,7 +63,8 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B opt := b.opt seriesPainter := result.seriesPainter - xRange := NewRange(b.p, seriesPainter.Width(), len(opt.XAxis.Data), 0.0, 0.0, 0.0, 0.0) + xRange := newRange(b.p, getPreferredValueFormatter(opt.XAxis.ValueFormatter, opt.ValueFormatter), + seriesPainter.Width(), len(opt.XAxis.Data), 0.0, 0.0, 0.0, 0.0) x0, x1 := xRange.GetRange(0) width := int(x1 - x0) // margin between each block @@ -78,9 +92,9 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B theme := opt.Theme seriesNames := seriesList.Names() - markPointPainter := NewMarkPointPainter(seriesPainter) - markLinePainter := NewMarkLinePainter(seriesPainter) - rendererList := []Renderer{ + markPointPainter := newMarkPointPainter(seriesPainter) + markLinePainter := newMarkLinePainter(seriesPainter) + rendererList := []renderer{ markPointPainter, markLinePainter, } @@ -91,15 +105,9 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B divideValues := xRange.AutoDivide() points := make([]Point, len(series.Data)) - var labelPainter *SeriesLabelPainter - if series.Label.Show { - labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{ - P: seriesPainter, - SeriesNames: seriesNames, - Label: series.Label, - Theme: opt.Theme, - Font: opt.Font, - }) + var labelPainter *seriesLabelPainter + if flagIs(true, series.Label.Show) { + labelPainter = newSeriesLabelPainter(seriesPainter, seriesNames, series.Label, opt.Theme, opt.Font) rendererList = append(rendererList, labelPainter) } @@ -113,29 +121,26 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B x += index * (barWidth + barMargin) } - h := yRange.getHeight(item.Value) - fillColor := seriesColor + h := yRange.getHeight(item) top := barMaxHeight - h - seriesPainter.OverrideDrawingStyle(chartdraw.Style{ - FillColor: fillColor, - }) if flagIs(true, opt.RoundedBarCaps) { - seriesPainter.RoundedRect(Box{ + seriesPainter.roundedRect(Box{ Top: top, Left: x, Right: x + barWidth, Bottom: barMaxHeight - 1, IsSet: true, - }, barWidth, true, false) + }, barWidth, true, false, + seriesColor, seriesColor, 0.0) } else { - seriesPainter.Rect(Box{ + seriesPainter.filledRect(Box{ Top: top, Left: x, Right: x + barWidth, Bottom: barMaxHeight - 1, IsSet: true, - }) + }, seriesColor, seriesColor, 0.0) } // generate marker point by hand points[j] = Point{ @@ -153,23 +158,22 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B y = barMaxHeight radians = -math.Pi / 2 if fontStyle.FontColor.IsZero() { - if isLightColor(fillColor) { + if isLightColor(seriesColor) { fontStyle.FontColor = defaultLightFontColor } else { fontStyle.FontColor = defaultDarkFontColor } } } - labelPainter.Add(LabelValue{ - Vertical: true, // label is above bar - Index: index, - Value: item.Value, - FontStyle: fontStyle, - X: x + barWidth>>1, - Y: y, - // rotate - Radians: radians, - Offset: series.Label.Offset, + labelPainter.Add(labelValue{ + vertical: true, // label is above bar + index: index, + value: item, + fontStyle: fontStyle, + x: x + (barWidth >> 1), + y: y, + radians: radians, + offset: series.Label.Offset, }) } @@ -203,13 +207,14 @@ func (b *barChart) Render() (Box, error) { opt.Theme = getPreferredTheme(p.theme) } renderResult, err := defaultRender(p, defaultRenderOption{ - Theme: opt.Theme, - Padding: opt.Padding, - SeriesList: opt.SeriesList, - XAxis: opt.XAxis, - YAxis: opt.YAxis, - Title: opt.Title, - Legend: opt.Legend, + theme: opt.Theme, + padding: opt.Padding, + seriesList: opt.SeriesList, + xAxis: &b.opt.XAxis, + yAxis: opt.YAxis, + title: opt.Title, + legend: &b.opt.Legend, + valueFormatter: opt.ValueFormatter, }) if err != nil { return BoxZero, err diff --git a/bar_chart_test.go b/bar_chart_test.go index 30b9286..f09e92d 100644 --- a/bar_chart_test.go +++ b/bar_chart_test.go @@ -4,18 +4,19 @@ import ( "strconv" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/go-analyze/charts/chartdraw/drawing" ) func makeBasicBarChartOption() BarChartOption { - seriesList := NewSeriesListDataFromValues([][]float64{ + seriesList := NewSeriesListBar([][]float64{ {2.0, 4.9, 7.0, 23.2, 25.6, 76.7, 135.6, 162.2, 32.6, 20.0, 6.4, 3.3}, {2.6, 5.9, 9.0, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6.0, 2.3}, - }, ChartTypeBar) + }) for index := range seriesList { - seriesList[index].Label.Show = true + seriesList[index].Label.Show = True() } return BarChartOption{ Padding: Box{ @@ -40,6 +41,23 @@ func makeBasicBarChartOption() BarChartOption { } } +func TestNewBarChartOptionWithData(t *testing.T) { + t.Parallel() + + opt := NewBarChartOptionWithData([][]float64{ + {12, 24}, + {24, 48}, + }) + + assert.Len(t, opt.SeriesList, 2) + assert.Equal(t, ChartTypeBar, opt.SeriesList[0].Type) + assert.Len(t, opt.YAxis, 1) + assert.Equal(t, defaultPadding, opt.Padding) + + p := NewPainter(PainterOptions{}) + assert.NoError(t, p.BarChart(opt)) +} + func TestBarChart(t *testing.T) { t.Parallel() @@ -53,13 +71,13 @@ func TestBarChart(t *testing.T) { name: "default", defaultTheme: true, makeOptions: makeBasicBarChartOption, - result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, { name: "themed", defaultTheme: false, makeOptions: makeBasicBarChartOption, - result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, { name: "rounded_caps", @@ -69,7 +87,7 @@ func TestBarChart(t *testing.T) { opt.RoundedBarCaps = True() return opt }, - result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, { name: "custom_font", @@ -85,7 +103,7 @@ func TestBarChart(t *testing.T) { opt.Title.FontStyle = customFont return opt }, - result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, { name: "boundary_gap_enable", @@ -95,7 +113,7 @@ func TestBarChart(t *testing.T) { opt.XAxis.BoundaryGap = True() return opt }, - result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, { name: "boundary_gap_disable", @@ -105,7 +123,19 @@ func TestBarChart(t *testing.T) { opt.XAxis.BoundaryGap = False() return opt }, - result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + result: "189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + }, + { + name: "value_formatter", + defaultTheme: true, + makeOptions: func() BarChartOption { + opt := makeBasicBarChartOption() + opt.ValueFormatter = func(f float64) string { + return "f" + } + return opt + }, + result: "ffffffffffJanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, } @@ -117,21 +147,18 @@ func TestBarChart(t *testing.T) { } if tt.defaultTheme { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) validateBarChartRender(t, p, tt.makeOptions(), tt.result) }) } else { t.Run(strconv.Itoa(i)+"-"+tt.name+"-painter", func(t *testing.T) { - p, err := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) - require.NoError(t, err) + p := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) validateBarChartRender(t, p, tt.makeOptions(), tt.result) }) t.Run(strconv.Itoa(i)+"-"+tt.name+"-options", func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) opt := tt.makeOptions() opt.Theme = GetTheme(ThemeVividDark) @@ -144,7 +171,7 @@ func TestBarChart(t *testing.T) { func validateBarChartRender(t *testing.T, p *Painter, opt BarChartOption, expectedResult string) { t.Helper() - _, err := NewBarChart(p, opt).Render() + err := p.BarChart(opt) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) diff --git a/chart_option.go b/chart_option.go index 6009497..dd11cef 100644 --- a/chart_option.go +++ b/chart_option.go @@ -2,7 +2,6 @@ package charts import ( "fmt" - "sort" "github.com/golang/freetype/truetype" ) @@ -47,7 +46,7 @@ type ChartOption struct { BarWidth int // BarHeight is the height of the bars for horizontal bar charts. BarHeight int - // Children are child charts to render together. + // Children are Child charts to render together. Children []ChartOption parent *Painter // ValueFormatter to format numeric values into labels. @@ -57,18 +56,18 @@ type ChartOption struct { // OptionFunc option function type OptionFunc func(opt *ChartOption) -// SVGOutputOption set svg type of chart's output. -func SVGOutputOption() OptionFunc { - return OutputFormatOptionFunc(ChartOutputSVG) +// SVGOutputOptionFunc set svg type of chart's output. +func SVGOutputOptionFunc() OptionFunc { + return outputFormatOptionFunc(ChartOutputSVG) } -// PNGOutputOption set png type of chart's output. -func PNGOutputOption() OptionFunc { - return OutputFormatOptionFunc(ChartOutputPNG) +// PNGOutputOptionFunc set png type of chart's output. +func PNGOutputOptionFunc() OptionFunc { + return outputFormatOptionFunc(ChartOutputPNG) } -// OutputFormatOptionFunc set type of chart's output. -func OutputFormatOptionFunc(t string) OptionFunc { +// outputFormatOptionFunc set type of chart's output. +func outputFormatOptionFunc(t string) OptionFunc { return func(opt *ChartOption) { opt.OutputFormat = t } @@ -162,16 +161,10 @@ func YAxisDataOptionFunc(data []string) OptionFunc { } } -// WidthOptionFunc set width of chart -func WidthOptionFunc(width int) OptionFunc { +// DimensionsOptionFunc sets the width and height dimensions of the chart. +func DimensionsOptionFunc(width, height int) OptionFunc { return func(opt *ChartOption) { opt.Width = width - } -} - -// HeightOptionFunc set height of chart -func HeightOptionFunc(height int) OptionFunc { - return func(opt *ChartOption) { opt.Height = height } } @@ -183,23 +176,16 @@ func PaddingOptionFunc(padding Box) OptionFunc { } } -// BoxOptionFunc set box of chart -func BoxOptionFunc(box Box) OptionFunc { - return func(opt *ChartOption) { - opt.Box = box - } -} - -// PieSeriesShowLabel set pie series show label -func PieSeriesShowLabel() OptionFunc { +// SeriesShowLabel set the series show label state for all series. +func SeriesShowLabel(show bool) OptionFunc { return func(opt *ChartOption) { for index := range opt.SeriesList { - opt.SeriesList[index].Label.Show = true + opt.SeriesList[index].Label.Show = BoolPointer(show) } } } -// ChildOptionFunc add child chart +// ChildOptionFunc adds a Child chart on top of the current one. Use Padding and Box for positioning. func ChildOptionFunc(child ...ChartOption) OptionFunc { return func(opt *ChartOption) { if opt.Children == nil { @@ -240,14 +226,9 @@ func (o *ChartOption) fillDefault() error { o.Width = getDefaultInt(o.Width, defaultChartWidth) o.Height = getDefaultInt(o.Height, defaultChartHeight) - yaxisCount := 1 - for _, series := range o.SeriesList { - if series.YAxisIndex == 1 { - yaxisCount++ - break - } else if series.YAxisIndex > 1 { - return fmt.Errorf("series '%s' specified invalid y-axis index: %v", series.Name, series.YAxisIndex) - } + yaxisCount := o.SeriesList.getYAxisCount() + if yaxisCount < 0 { + return fmt.Errorf("series specified invalid y-axis index") } if len(o.YAxis) < yaxisCount { yAxisOptions := make([]YAxisOption, yaxisCount) @@ -278,31 +259,7 @@ func (o *ChartOption) fillDefault() error { fillThemeDefaults(o.Theme, &o.Title, &o.Legend, &o.XAxis) if o.Padding.IsZero() { - o.Padding = Box{ - Top: 20, - Right: 20, - Bottom: 20, - Left: 20, - } - } - // association between legend and series name - if len(o.Legend.Data) == 0 { - o.Legend.Data = o.SeriesList.Names() - } else { - seriesCount := len(o.SeriesList) - for index, name := range o.Legend.Data { - if index < seriesCount && len(o.SeriesList[index].Name) == 0 { - o.SeriesList[index].Name = name - } - } - nameIndexDict := map[string]int{} - for index, name := range o.Legend.Data { - nameIndexDict[name] = index - } - // ensure order of series is consistent with legend - sort.Slice(o.SeriesList, func(i, j int) bool { - return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name] - }) + o.Padding = defaultPadding } return nil } @@ -322,100 +279,41 @@ func fillThemeDefaults(defaultTheme ColorPalette, title *TitleOption, legend *Le // LineRender line chart render func LineRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { return Render(ChartOption{ - SeriesList: NewSeriesListDataFromValues(values, ChartTypeLine), + SeriesList: NewSeriesListLine(values), }, opts...) } // BarRender bar chart render func BarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { return Render(ChartOption{ - SeriesList: NewSeriesListDataFromValues(values, ChartTypeBar), + SeriesList: NewSeriesListBar(values), }, opts...) } // HorizontalBarRender horizontal bar chart render func HorizontalBarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { return Render(ChartOption{ - SeriesList: NewSeriesListDataFromValues(values, ChartTypeHorizontalBar), + SeriesList: NewSeriesListHorizontalBar(values), }, opts...) } // PieRender pie chart render func PieRender(values []float64, opts ...OptionFunc) (*Painter, error) { return Render(ChartOption{ - SeriesList: NewPieSeriesList(values), + SeriesList: NewSeriesListPie(values), }, opts...) } // RadarRender radar chart render func RadarRender(values [][]float64, opts ...OptionFunc) (*Painter, error) { return Render(ChartOption{ - SeriesList: NewSeriesListDataFromValues(values, ChartTypeRadar), + SeriesList: NewSeriesListRadar(values), }, opts...) } // FunnelRender funnel chart render func FunnelRender(values []float64, opts ...OptionFunc) (*Painter, error) { return Render(ChartOption{ - SeriesList: NewFunnelSeriesList(values), + SeriesList: NewSeriesListFunnel(values), }, opts...) } - -// TableRender table chart render -func TableRender(header []string, data [][]string, spanMaps ...map[int]int) (*Painter, error) { - opt := TableChartOption{ - Header: header, - Data: data, - } - if len(spanMaps) != 0 { - spanMap := spanMaps[0] - spans := make([]int, len(opt.Header)) - for index := range spans { - v, ok := spanMap[index] - if !ok { - v = 1 - } - spans[index] = v - } - opt.Spans = spans - } - return TableOptionRender(opt) -} - -// TableOptionRender table render with option -func TableOptionRender(opt TableChartOption) (*Painter, error) { - if opt.OutputFormat == "" { - opt.OutputFormat = chartDefaultOutputFormat - } - if opt.Width <= 0 { - opt.Width = defaultChartWidth - } - - p, err := NewPainter(PainterOptions{ - OutputFormat: opt.OutputFormat, - Width: opt.Width, - Height: 100, // is only used to calculate the height of the table - Font: opt.FontStyle.Font, - }) - if err != nil { - return nil, err - } - info, err := NewTableChart(p, opt).render() - if err != nil { - return nil, err - } - - p, err = NewPainter(PainterOptions{ - OutputFormat: opt.OutputFormat, - Width: info.width, - Height: info.height, - Font: opt.FontStyle.Font, - }) - if err != nil { - return nil, err - } - if _, err = NewTableChart(p, opt).renderWithInfo(info); err != nil { - return nil, err - } - return p, nil -} diff --git a/chart_option_test.go b/chart_option_test.go index f65ea61..78d20c8 100644 --- a/chart_option_test.go +++ b/chart_option_test.go @@ -1,6 +1,7 @@ package charts import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -13,15 +14,14 @@ func TestChartOption(t *testing.T) { t.Parallel() fns := []OptionFunc{ - SVGOutputOption(), + SVGOutputOptionFunc(), FontOptionFunc(GetDefaultFont()), ThemeNameOptionFunc(ThemeVividDark), TitleTextOptionFunc("title"), LegendLabelsOptionFunc([]string{"label"}), XAxisDataOptionFunc([]string{"xaxis"}), YAxisDataOptionFunc([]string{"yaxis"}), - WidthOptionFunc(800), - HeightOptionFunc(600), + DimensionsOptionFunc(800, 600), PaddingOptionFunc(Box{ Left: 10, Top: 10, @@ -62,21 +62,29 @@ func TestChartOption(t *testing.T) { }, opt) } -func TestChartOptionPieSeriesShowLabel(t *testing.T) { +func TestChartOptionSeriesShowLabel(t *testing.T) { t.Parallel() opt := ChartOption{ - SeriesList: NewPieSeriesList([]float64{1, 2}), + SeriesList: NewSeriesListPie([]float64{1, 2}), } - PieSeriesShowLabel()(&opt) - assert.True(t, opt.SeriesList[0].Label.Show) + SeriesShowLabel(true)(&opt) + assert.True(t, flagIs(true, opt.SeriesList[0].Label.Show)) + + SeriesShowLabel(false)(&opt) + assert.True(t, flagIs(false, opt.SeriesList[0].Label.Show)) +} + +func newNoTypeSeriesListFromValues(values [][]float64) SeriesList { + return newSeriesListFromValues(values, "", + SeriesLabel{}, nil, "", SeriesMarkPoint{}, SeriesMarkLine{}) } func TestChartOptionMarkLine(t *testing.T) { t.Parallel() opt := ChartOption{ - SeriesList: NewSeriesListDataFromValues([][]float64{{1, 2}}), + SeriesList: newNoTypeSeriesListFromValues([][]float64{{1, 2}}), } MarkLineOptionFunc(0, "min", "max")(&opt) assert.Equal(t, NewMarkLine("min", "max"), opt.SeriesList[0].MarkLine) @@ -86,7 +94,7 @@ func TestChartOptionMarkPoint(t *testing.T) { t.Parallel() opt := ChartOption{ - SeriesList: NewSeriesListDataFromValues([][]float64{{1, 2}}), + SeriesList: newNoTypeSeriesListFromValues([][]float64{{1, 2}}), } MarkPointOptionFunc(0, "min", "max")(&opt) assert.Equal(t, NewMarkPoint("min", "max"), opt.SeriesList[0].MarkPoint) @@ -104,7 +112,7 @@ func TestLineRender(t *testing.T) { } p, err := LineRender( values, - SVGOutputOption(), + SVGOutputOptionFunc(), TitleTextOptionFunc("Line"), XAxisDataOptionFunc([]string{ "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", @@ -112,11 +120,16 @@ func TestLineRender(t *testing.T) { LegendLabelsOptionFunc([]string{ "Email", "Union Ads", "Video Ads", "Direct", "Search Engine", }), + func(opt *ChartOption) { + opt.ValueFormatter = func(f float64) string { + return fmt.Sprintf("%.0f", f) + } + }, ) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", data) + assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1440128011209608006404803201600MonTueWedThuFriSatSun", data) } func TestBarRender(t *testing.T) { @@ -128,7 +141,7 @@ func TestBarRender(t *testing.T) { } p, err := BarRender( values, - SVGOutputOption(), + SVGOutputOptionFunc(), XAxisDataOptionFunc([]string{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", }), @@ -153,7 +166,7 @@ func TestBarRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "RainfallEvaporation2702402101801501209060300JanFebMarAprMayJunJulAugSepOctNovDec162.22182.22.341.6348.07", data) + assertEqualSVG(t, "RainfallEvaporation2702402101801501209060300JanFebMarAprMayJunJulAugSepOctNovDec162.22182.22.341.6348.07", data) } func TestHorizontalBarRender(t *testing.T) { @@ -165,7 +178,7 @@ func TestHorizontalBarRender(t *testing.T) { } p, err := HorizontalBarRender( values, - SVGOutputOption(), + SVGOutputOptionFunc(), TitleTextOptionFunc("World Population"), PaddingOptionFunc(Box{ Top: 20, @@ -183,7 +196,7 @@ func TestHorizontalBarRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0143k286k428.99k571.99k714.99k", data) + assertEqualSVG(t, "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0143k286k428.99k571.99k714.99k", data) } func TestPieRender(t *testing.T) { @@ -192,7 +205,7 @@ func TestPieRender(t *testing.T) { values := []float64{1048, 735, 580, 484, 300} p, err := PieRender( values, - SVGOutputOption(), + SVGOutputOptionFunc(), TitleOptionFunc(TitleOption{ Text: "Rainfall vs Evaporation", Subtext: "Fake Data", @@ -205,18 +218,17 @@ func TestPieRender(t *testing.T) { Left: 20, }), LegendOptionFunc(LegendOption{ - Vertical: true, + Vertical: True(), Data: []string{ "Search Engine", "Direct", "Email", "Union Ads", "Video Ads", }, Offset: OffsetLeft, }), - PieSeriesShowLabel(), ) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", data) + assertEqualSVG(t, "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake Data: 33.3%: 23.35%: 18.43%: 15.37%: 9.53%", data) } func TestRadarRender(t *testing.T) { @@ -228,7 +240,7 @@ func TestRadarRender(t *testing.T) { } p, err := RadarRender( values, - SVGOutputOption(), + SVGOutputOptionFunc(), TitleTextOptionFunc("Basic Radar Chart"), LegendLabelsOptionFunc([]string{ "Allocated Budget", "Actual Spending", @@ -247,7 +259,7 @@ func TestRadarRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "Allocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", data) + assertEqualSVG(t, "Allocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", data) } func TestFunnelRender(t *testing.T) { @@ -258,7 +270,7 @@ func TestFunnelRender(t *testing.T) { } p, err := FunnelRender( values, - SVGOutputOption(), + SVGOutputOptionFunc(), TitleTextOptionFunc("Funnel"), LegendLabelsOptionFunc([]string{ "Show", "Click", "Visit", "Inquiry", "Order", @@ -267,7 +279,7 @@ func TestFunnelRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "ShowClickVisitInquiryOrderFunnelShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", data) + assertEqualSVG(t, "ShowClickVisitInquiryOrderFunnel(100%)(80%)(60%)(40%)(20%)", data) } func TestChildRender(t *testing.T) { @@ -277,16 +289,16 @@ func TestChildRender(t *testing.T) { {150, 232, 201, 154, 190, 330, 410}, {320, 332, 301, 334, 390, 330, 320}, }, - SVGOutputOption(), + SVGOutputOptionFunc(), XAxisDataOptionFunc([]string{ "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", }), ChildOptionFunc(ChartOption{ Box: chartdraw.NewBox(10, 200, 500, 200), - SeriesList: NewSeriesListDataFromValues([][]float64{ + SeriesList: NewSeriesListHorizontalBar([][]float64{ {70, 90, 110, 130}, {80, 100, 120, 140}, - }, ChartTypeHorizontalBar), + }), Legend: LegendOption{ Data: []string{ "2011", "2012", @@ -304,5 +316,5 @@ func TestChildRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "45041037033029025021017013090MonTueWedThuFriSatSun20112012WorldChinaIndiaUSA70144", data) + assertEqualSVG(t, "45041037033029025021017013090MonTueWedThuFriSatSun20112012WorldChinaIndiaUSA70144", data) } diff --git a/chartdraw/annotation_series_test.go b/chartdraw/annotation_series_test.go index 8abb1f0..ce878b5 100644 --- a/chartdraw/annotation_series_test.go +++ b/chartdraw/annotation_series_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/go-analyze/charts/chartdraw/drawing" ) @@ -22,8 +21,7 @@ func TestAnnotationSeriesMeasure(t *testing.T) { }, } - r, err := PNG(110, 110) - require.NoError(t, err) + r := PNG(110, 110) xrange := &ContinuousRange{ Min: 1.0, @@ -73,8 +71,7 @@ func TestAnnotationSeriesRender(t *testing.T) { }, } - r, err := PNG(110, 110) - require.NoError(t, err) + r := PNG(110, 110) xrange := &ContinuousRange{ Min: 1.0, diff --git a/chartdraw/bar_chart.go b/chartdraw/bar_chart.go index 9fb6b51..2d1707c 100644 --- a/chartdraw/bar_chart.go +++ b/chartdraw/bar_chart.go @@ -94,10 +94,7 @@ func (bc BarChart) Render(rp RendererProvider, w io.Writer) error { return errors.New("please provide at least one bar") } - r, err := rp(bc.GetWidth(), bc.GetHeight()) - if err != nil { - return err - } + r := rp(bc.GetWidth(), bc.GetHeight()) if bc.Font == nil { bc.defaultFont = GetDefaultFont() diff --git a/chartdraw/bar_chart_test.go b/chartdraw/bar_chart_test.go index 6d1dc26..f0c233b 100644 --- a/chartdraw/bar_chart_test.go +++ b/chartdraw/bar_chart_test.go @@ -217,8 +217,7 @@ func TestBarChartGetAxesTicks(t *testing.T) { }, } - r, err := PNG(128, 128) - require.NoError(t, err) + r := PNG(128, 128) yr := bc.getRanges() yf := bc.getValueFormatters() diff --git a/chartdraw/chart.go b/chartdraw/chart.go index 5031de6..b5452e1 100644 --- a/chartdraw/chart.go +++ b/chartdraw/chart.go @@ -80,10 +80,7 @@ func (c Chart) Render(rp RendererProvider, w io.Writer) error { c.YAxisSecondary.AxisType = YAxisSecondary - r, err := rp(c.GetWidth(), c.GetHeight()) - if err != nil { - return err - } + r := rp(c.GetWidth(), c.GetHeight()) if c.Font == nil { c.defaultFont = GetDefaultFont() @@ -99,8 +96,7 @@ func (c Chart) Render(rp RendererProvider, w io.Writer) error { xr, yr, yra = c.setRangeDomains(canvasBox, xr, yr, yra) - err = c.checkRanges(xr, yr, yra) - if err != nil { + if err := c.checkRanges(xr, yr, yra); err != nil { _ = r.Save(w) return err } diff --git a/chartdraw/chart_test.go b/chartdraw/chart_test.go index ca8e592..99effff 100644 --- a/chartdraw/chart_test.go +++ b/chartdraw/chart_test.go @@ -316,8 +316,7 @@ func TestChartHasAxes(t *testing.T) { func TestChartGetAxesTicks(t *testing.T) { t.Parallel() - r, err := PNG(1024, 1024) - require.NoError(t, err) + r := PNG(1024, 1024) c := Chart{ XAxis: XAxis{ diff --git a/chartdraw/donut_chart.go b/chartdraw/donut_chart.go index 4d2a453..351c8e0 100644 --- a/chartdraw/donut_chart.go +++ b/chartdraw/donut_chart.go @@ -71,10 +71,7 @@ func (pc DonutChart) Render(rp RendererProvider, w io.Writer) error { return errors.New("please provide at least one value") } - r, err := rp(pc.GetWidth(), pc.GetHeight()) - if err != nil { - return err - } + r := rp(pc.GetWidth(), pc.GetHeight()) if pc.Font == nil { pc.defaultFont = GetDefaultFont() diff --git a/chartdraw/drawing/color.go b/chartdraw/drawing/color.go index 7c2c32d..8e23c6d 100644 --- a/chartdraw/drawing/color.go +++ b/chartdraw/drawing/color.go @@ -12,36 +12,71 @@ import ( var ( // ColorTransparent is a fully transparent color. ColorTransparent = Color{R: 255, G: 255, B: 255, A: 0} - // ColorWhite is white. + // ColorWhite is R: 255, G: 255, B: 255. ColorWhite = Color{R: 255, G: 255, B: 255, A: 255} - // ColorBlack is black. + // ColorBlack is R: 0, G: 0, B: 0. ColorBlack = Color{R: 0, G: 0, B: 0, A: 255} - // ColorRed is red. + // ColorGray is R: 128, G: 128, B: 128, + ColorGray = Color{R: 128, G: 128, B: 128, A: 255} + // ColorRed is R: 255, G: 0, B: 0. ColorRed = Color{R: 255, G: 0, B: 0, A: 255} - // ColorGreen is green. + // ColorGreen is R: 0, G: 128, B: 0. ColorGreen = Color{R: 0, G: 128, B: 0, A: 255} - // ColorBlue is blue. + // ColorBlue is R: 0, G: 0, B: 255. ColorBlue = Color{R: 0, G: 0, B: 255, A: 255} - // ColorSilver is a known color. + // ColorSilver is R: 192, G: 192, B: 192. ColorSilver = Color{R: 192, G: 192, B: 192, A: 255} - // ColorMaroon is a known color. + // ColorMaroon is R: 128, G: 0, B: 0. ColorMaroon = Color{R: 128, G: 0, B: 0, A: 255} - // ColorPurple is a known color. + // ColorPurple is R: 128, G: 0, B: 128. ColorPurple = Color{R: 128, G: 0, B: 128, A: 255} - // ColorFuchsia is a known color. + // ColorFuchsia is R: 255, G: 0, B: 255. ColorFuchsia = Color{R: 255, G: 0, B: 255, A: 255} - // ColorLime is a known color. + // ColorLime is R: 0, G: 255, B: 0. ColorLime = Color{R: 0, G: 255, B: 0, A: 255} - // ColorOlive is a known color. + // ColorOlive is R: 128, G: 128, B: 0. ColorOlive = Color{R: 128, G: 128, B: 0, A: 255} - // ColorYellow is a known color. + // ColorYellow is R: 255, G: 255, B: 0. ColorYellow = Color{R: 255, G: 255, B: 0, A: 255} - // ColorNavy is a known color. + // ColorNavy is R: 0, G: 0, B: 128. ColorNavy = Color{R: 0, G: 0, B: 128, A: 255} - // ColorTeal is a known color. + // ColorTeal is R: 0, G: 128, B: 128. ColorTeal = Color{R: 0, G: 128, B: 128, A: 255} - // ColorAqua is a known color. + // ColorAqua is R: 0, G: 255, B: 255. ColorAqua = Color{R: 0, G: 255, B: 255, A: 255} + + // select extended colors + + // ColorAzure is R: 240, G: 255, B: 255. + ColorAzure = Color{R: 240, G: 255, B: 255, A: 255} + // ColorBeige is R: 245, G: 245, B: 220. + ColorBeige = Color{R: 245, G: 245, B: 220, A: 255} + // ColorBrown is R: 165, G: 42, B: 42. + ColorBrown = Color{R: 165, G: 42, B: 42, A: 255} + // ColorChocolate is R: 210, G: 105, B: 30. + ColorChocolate = Color{R: 210, G: 105, B: 30, A: 255} + // ColorCoral is R: 255, G: 127, B: 80. + ColorCoral = Color{R: 255, G: 127, B: 80, A: 255} + // ColorGold is R: 255, G: 215, B: 0. + ColorGold = Color{R: 255, G: 215, B: 0, A: 255} + // ColorIndigo is R: 75, G: 0, B: 130. + ColorIndigo = Color{R: 75, G: 0, B: 130, A: 255} + // ColorIvory is R: 255, G: 255, B: 250. + ColorIvory = Color{R: 255, G: 255, B: 250, A: 255} + // ColorOrange is R: 255, G: 165, B: 0. + ColorOrange = Color{R: 255, G: 165, B: 0, A: 255} + // ColorPink is R: 255, G: 192, B: 203. + ColorPink = Color{R: 255, G: 192, B: 203, A: 255} + // ColorPlum is R: 221, G: 160, B: 221. + ColorPlum = Color{R: 221, G: 160, B: 221, A: 255} + // ColorSalmon is R: 250, G: 128, B: 114. + ColorSalmon = Color{R: 250, G: 128, B: 114, A: 255} + // ColorTan is R: 210, G: 180, B: 140. + ColorTan = Color{R: 210, G: 180, B: 140, A: 255} + // ColorTurquoise is R: 64, G: 224, B: 208. + ColorTurquoise = Color{R: 64, G: 224, B: 208, A: 255} + // ColorViolet is R: 238, G: 130, B: 238. + ColorViolet = Color{R: 238, G: 130, B: 238, A: 255} ) // ParseColor parses a color from a string. @@ -129,6 +164,8 @@ func ColorFromKnown(known string) Color { return ColorWhite case "black": return ColorBlack + case "grey", "gray": + return ColorGray case "red": return ColorRed case "blue": @@ -153,8 +190,38 @@ func ColorFromKnown(known string) Color { return ColorNavy case "teal": return ColorTeal - case "aqua": + case "cyan", "aqua": return ColorAqua + case "azure": + return ColorAzure + case "beige": + return ColorBeige + case "brown": + return ColorBrown + case "chocolate": + return ColorChocolate + case "coral": + return ColorCoral + case "gold": + return ColorGold + case "indigo": + return ColorIndigo + case "ivory": + return ColorIvory + case "orange": + return ColorOrange + case "pink": + return ColorPink + case "plum": + return ColorPlum + case "salmon": + return ColorSalmon + case "tan": + return ColorTan + case "turquoise": + return ColorTurquoise + case "violet": + return ColorViolet default: return Color{} } @@ -235,6 +302,53 @@ func (c Color) AverageWith(other Color) Color { // String returns a css string representation of the color. func (c Color) String() string { + switch c { + case ColorWhite: + return "white" + case ColorBlack: + return "black" + case ColorRed: + return "red" + case ColorBlue: + return "blue" + case ColorGreen: + return "green" + case ColorSilver: + return "silver" + case ColorMaroon: + return "maroon" + case ColorPurple: + return "purple" + case ColorFuchsia: + return "fuchsia" + case ColorLime: + return "lime" + case ColorOlive: + return "olive" + case ColorYellow: + return "yellow" + case ColorNavy: + return "navy" + case ColorTeal: + return "teal" + case ColorAqua: + return "aqua" + default: + if c.A == 255 { + return c.StringRGB() + } else { + return c.StringRGBA() + } + } +} + +// StringRGB returns a css RGB string representation of the color. +func (c Color) StringRGB() string { + return fmt.Sprintf("rgb(%v,%v,%v)", c.R, c.G, c.B) +} + +// StringRGBA returns a css RGBA string representation of the color. +func (c Color) StringRGBA() string { fa := float64(c.A) / float64(255) return fmt.Sprintf("rgba(%v,%v,%v,%.1f)", c.R, c.G, c.B, fa) } diff --git a/chartdraw/drawing/raster_graphic_context.go b/chartdraw/drawing/raster_graphic_context.go index f400bed..37ad521 100644 --- a/chartdraw/drawing/raster_graphic_context.go +++ b/chartdraw/drawing/raster_graphic_context.go @@ -14,15 +14,9 @@ import ( ) // NewRasterGraphicContext creates a new Graphic context from an image. -func NewRasterGraphicContext(img draw.Image) (*RasterGraphicContext, error) { - var painter Painter - switch selectImage := img.(type) { - case *image.RGBA: - painter = raster.NewRGBAPainter(selectImage) - default: - return nil, errors.New("NewRasterGraphicContext() :: invalid image type") - } - return NewRasterGraphicContextWithPainter(img, painter), nil +func NewRasterGraphicContext(img *image.RGBA) *RasterGraphicContext { + painter := raster.NewRGBAPainter(img) + return NewRasterGraphicContextWithPainter(img, painter) } // NewRasterGraphicContextWithPainter creates a new Graphic context from an image and a Painter (see Freetype-go) diff --git a/chartdraw/examples/text_rotation/main.go b/chartdraw/examples/text_rotation/main.go index 8a37aa7..e070bc6 100644 --- a/chartdraw/examples/text_rotation/main.go +++ b/chartdraw/examples/text_rotation/main.go @@ -11,7 +11,7 @@ import ( func main() { f := chartdraw.GetDefaultFont() - r, _ := chartdraw.PNG(1024, 1024) + r := chartdraw.PNG(1024, 1024) chartdraw.Draw.Text(r, "Test", 64, 64, chartdraw.Style{ FontStyle: chartdraw.FontStyle{ diff --git a/chartdraw/pie_chart.go b/chartdraw/pie_chart.go index fe855fd..e07d9ee 100644 --- a/chartdraw/pie_chart.go +++ b/chartdraw/pie_chart.go @@ -71,10 +71,7 @@ func (pc PieChart) Render(rp RendererProvider, w io.Writer) error { return errors.New("please provide at least one value") } - r, err := rp(pc.GetWidth(), pc.GetHeight()) - if err != nil { - return err - } + r := rp(pc.GetWidth(), pc.GetHeight()) if pc.Font == nil { pc.defaultFont = GetDefaultFont() diff --git a/chartdraw/raster_renderer.go b/chartdraw/raster_renderer.go index 86f061f..0dc549b 100644 --- a/chartdraw/raster_renderer.go +++ b/chartdraw/raster_renderer.go @@ -12,16 +12,13 @@ import ( ) // PNG returns a new png/raster renderer. -func PNG(width, height int) (Renderer, error) { +func PNG(width, height int) Renderer { i := image.NewRGBA(image.Rect(0, 0, width, height)) - gc, err := drawing.NewRasterGraphicContext(i) - if err == nil { - return &rasterRenderer{ - i: i, - gc: gc, - }, nil + gc := drawing.NewRasterGraphicContext(i) + return &rasterRenderer{ + i: i, + gc: gc, } - return nil, err } // rasterRenderer renders chart commands to a bitmap. @@ -153,6 +150,9 @@ func (rr *rasterRenderer) SetFontColor(c drawing.Color) { // Text implements the interface method. func (rr *rasterRenderer) Text(body string, x, y int) { + if body == "" { + return + } xf, yf := rr.getCoords(x, y) rr.gc.SetFont(rr.s.Font) rr.gc.SetFontSize(rr.s.FontSize) diff --git a/chartdraw/renderer_provider.go b/chartdraw/renderer_provider.go index 2290ead..dd75974 100644 --- a/chartdraw/renderer_provider.go +++ b/chartdraw/renderer_provider.go @@ -1,4 +1,4 @@ package chartdraw // RendererProvider is a function that returns a renderer. -type RendererProvider func(int, int) (Renderer, error) +type RendererProvider func(int, int) Renderer diff --git a/chartdraw/stacked_bar_chart.go b/chartdraw/stacked_bar_chart.go index f6acafc..e064521 100644 --- a/chartdraw/stacked_bar_chart.go +++ b/chartdraw/stacked_bar_chart.go @@ -101,10 +101,7 @@ func (sbc StackedBarChart) Render(rp RendererProvider, w io.Writer) error { return errors.New("please provide at least one bar") } - r, err := rp(sbc.GetWidth(), sbc.GetHeight()) - if err != nil { - return err - } + r := rp(sbc.GetWidth(), sbc.GetHeight()) if sbc.Font == nil { sbc.defaultFont = GetDefaultFont() diff --git a/chartdraw/style.go b/chartdraw/style.go index 9049d63..409f55e 100644 --- a/chartdraw/style.go +++ b/chartdraw/style.go @@ -385,9 +385,7 @@ func (s Style) WriteToRenderer(r Renderer) { r.SetStrokeWidth(s.GetStrokeWidth()) r.SetStrokeDashArray(s.GetStrokeDashArray()) r.SetFillColor(s.GetFillColor()) - r.SetFont(s.GetFont()) - r.SetFontColor(s.GetFontColor()) - r.SetFontSize(s.GetFontSize()) + s.FontStyle.WriteTextOptionsToRenderer(r) r.ClearTextRotation() if s.GetTextRotationDegrees() != 0 { diff --git a/chartdraw/text_test.go b/chartdraw/text_test.go index 4919e8b..30ed6f9 100644 --- a/chartdraw/text_test.go +++ b/chartdraw/text_test.go @@ -10,8 +10,7 @@ import ( func TestTextWrapWord(t *testing.T) { t.Parallel() - r, err := PNG(1024, 1024) - require.NoError(t, err) + r := PNG(1024, 1024) basicTextStyle := Style{FontStyle: FontStyle{Font: GetDefaultFont(), FontSize: 24}} @@ -44,8 +43,7 @@ func TestTextWrapWord(t *testing.T) { func TestTextWrapRune(t *testing.T) { t.Parallel() - r, err := PNG(1024, 1024) - require.NoError(t, err) + r := PNG(1024, 1024) basicTextStyle := Style{FontStyle: FontStyle{Font: GetDefaultFont(), FontSize: 24}} diff --git a/chartdraw/tick_test.go b/chartdraw/tick_test.go index 0e59ae0..851bbb1 100644 --- a/chartdraw/tick_test.go +++ b/chartdraw/tick_test.go @@ -10,8 +10,7 @@ import ( func TestGenerateContinuousTicks(t *testing.T) { t.Parallel() - r, err := PNG(1024, 1024) - require.NoError(t, err) + r := PNG(1024, 1024) r.SetFont(GetDefaultFont()) ra := &ContinuousRange{ @@ -32,8 +31,7 @@ func TestGenerateContinuousTicks(t *testing.T) { func TestGenerateContinuousTicksDescending(t *testing.T) { t.Parallel() - r, err := PNG(1024, 1024) - require.NoError(t, err) + r := PNG(1024, 1024) r.SetFont(GetDefaultFont()) ra := &ContinuousRange{ diff --git a/chartdraw/vector_renderer.go b/chartdraw/vector_renderer.go index cf19e95..58f028d 100644 --- a/chartdraw/vector_renderer.go +++ b/chartdraw/vector_renderer.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "math" + "strconv" "strings" "golang.org/x/image/font" @@ -15,7 +16,7 @@ import ( ) // SVG returns a new png/raster renderer. -func SVG(width, height int) (Renderer, error) { +func SVG(width, height int) Renderer { buffer := bytes.NewBuffer([]byte{}) canvas := newCanvas(buffer) canvas.Start(width, height) @@ -25,13 +26,13 @@ func SVG(width, height int) (Renderer, error) { s: &Style{}, p: []string{}, dpi: DefaultDPI, - }, nil + } } // SVGWithCSS returns a new png/raster renderer with attached custom CSS // The optional nonce argument sets a CSP nonce. -func SVGWithCSS(css string, nonce string) func(width, height int) (Renderer, error) { - return func(width, height int) (Renderer, error) { +func SVGWithCSS(css string, nonce string) func(width, height int) Renderer { + return func(width, height int) Renderer { buffer := bytes.NewBuffer([]byte{}) canvas := newCanvas(buffer) canvas.css = css @@ -43,7 +44,7 @@ func SVGWithCSS(css string, nonce string) func(width, height int) (Renderer, err s: &Style{}, p: []string{}, dpi: DefaultDPI, - }, nil + } } } @@ -207,6 +208,7 @@ func (vr *vectorRenderer) MeasureText(body string) (box Box) { box.Right = w box.Bottom = int(drawing.PointsToPixels(vr.dpi, vr.s.FontSize)) + box.IsSet = true if vr.c.textTheta == nil { return } @@ -266,24 +268,30 @@ func (c *canvas) Start(width, height int) { } func (c *canvas) Path(d string, style Style) { + if d == "" { + return + } var strokeDashArrayProperty string if len(style.StrokeDashArray) > 0 { strokeDashArrayProperty = c.getStrokeDashArray(style) } - _, _ = c.w.Write([]byte(fmt.Sprintf(``, strokeDashArrayProperty, d, c.styleAsSVG(style)))) + _, _ = c.w.Write([]byte(fmt.Sprintf(``, strokeDashArrayProperty, d, c.styleAsSVG(style, false)))) } func (c *canvas) Text(x, y int, body string, style Style) { + if body == "" { + return + } if c.textTheta == nil { - _, _ = c.w.Write([]byte(fmt.Sprintf(`%s`, x, y, c.styleAsSVG(style), body))) + _, _ = c.w.Write([]byte(fmt.Sprintf(`%s`, x, y, c.styleAsSVG(style, true), body))) } else { transform := fmt.Sprintf(` transform="rotate(%0.2f,%d,%d)"`, RadiansToDegrees(*c.textTheta), x, y) - _, _ = c.w.Write([]byte(fmt.Sprintf(`%s`, x, y, c.styleAsSVG(style), transform, body))) + _, _ = c.w.Write([]byte(fmt.Sprintf(`%s`, x, y, c.styleAsSVG(style, true), transform, body))) } } func (c *canvas) Circle(x, y, r int, style Style) { - _, _ = c.w.Write([]byte(fmt.Sprintf(``, x, y, r, c.styleAsSVG(style)))) + _, _ = c.w.Write([]byte(fmt.Sprintf(``, x, y, r, c.styleAsSVG(style, false)))) } func (c *canvas) End() { @@ -315,7 +323,7 @@ func (c *canvas) getFontFace(s Style) string { } // styleAsSVG returns the style as a svg style or class string. -func (c *canvas) styleAsSVG(s Style) string { +func (c *canvas) styleAsSVG(s Style, applyText bool) string { sw := s.StrokeWidth sc := s.StrokeColor fc := s.FillColor @@ -331,7 +339,7 @@ func (c *canvas) styleAsSVG(s Style) string { if !fc.IsZero() { classes = append(classes, "fill") } - if fs != 0 || s.Font != nil { + if applyText && (fs != 0 || s.Font != nil) { classes = append(classes, "text") } @@ -340,19 +348,14 @@ func (c *canvas) styleAsSVG(s Style) string { var pieces []string - if sw != 0 { - pieces = append(pieces, "stroke-width:"+fmt.Sprintf("%d", int(sw))) - } else { - pieces = append(pieces, "stroke-width:0") - } - - if !sc.IsTransparent() { + if sw != 0 && !sc.IsTransparent() { + pieces = append(pieces, "stroke-width:"+formatFloatMinimized(sw)) pieces = append(pieces, "stroke:"+sc.String()) } else { pieces = append(pieces, "stroke:none") } - if !fnc.IsTransparent() { + if applyText && !fnc.IsTransparent() { pieces = append(pieces, "fill:"+fnc.String()) } else if !fc.IsTransparent() { pieces = append(pieces, "fill:"+fc.String()) @@ -360,12 +363,29 @@ func (c *canvas) styleAsSVG(s Style) string { pieces = append(pieces, "fill:none") } - if fs != 0 { - pieces = append(pieces, "font-size:"+fmt.Sprintf("%.1fpx", drawing.PointsToPixels(c.dpi, fs))) + if applyText { + if fs != 0 { + pieces = append(pieces, "font-size:"+formatFloatMinimized(drawing.PointsToPixels(c.dpi, fs))+"px") + } + if s.Font != nil { + pieces = append(pieces, c.getFontFace(s)) + } } - if s.Font != nil { - pieces = append(pieces, c.getFontFace(s)) + if len(pieces) == 0 { + return "" } + return fmt.Sprintf("style=\"%s\"", strings.Join(pieces, ";")) } + +// formatFloatNoTrailingZero formats a float without trailing zeros, so it is as small as possible. +func formatFloatMinimized(val float64) string { + if val == float64(int(val)) { + return strconv.Itoa(int(val)) + } + str := fmt.Sprintf("%.1f", val) // e.g. "1.20" + str = strings.TrimRight(str, "0") // e.g. "1.2" + str = strings.TrimRight(str, ".") // a rounding condition where an int is acceptable + return str +} diff --git a/chartdraw/vector_renderer_test.go b/chartdraw/vector_renderer_test.go index 2f1be7a..3405b1f 100644 --- a/chartdraw/vector_renderer_test.go +++ b/chartdraw/vector_renderer_test.go @@ -15,8 +15,7 @@ import ( func TestVectorRendererPath(t *testing.T) { t.Parallel() - vr, err := SVG(100, 100) - require.NoError(t, err) + vr := SVG(100, 100) typed, isTyped := vr.(*vectorRenderer) assert.True(t, isTyped) @@ -38,8 +37,7 @@ func TestVectorRendererPath(t *testing.T) { func TestVectorRendererMeasureText(t *testing.T) { t.Parallel() - vr, err := SVG(100, 100) - require.NoError(t, err) + vr := SVG(100, 100) vr.SetDPI(DefaultDPI) vr.SetFont(GetDefaultFont()) @@ -60,18 +58,30 @@ func TestCanvasStyleSVG(t *testing.T) { FontStyle: FontStyle{ FontColor: drawing.ColorWhite, Font: GetDefaultFont(), + FontSize: 12, }, Padding: DefaultBackgroundPadding, } canvas := &canvas{dpi: DefaultDPI} - svgString := canvas.styleAsSVG(set) + svgString := canvas.styleAsSVG(set, false) assert.NotEmpty(t, svgString) assert.True(t, strings.HasPrefix(svgString, "style=\"")) - assert.True(t, strings.Contains(svgString, "stroke:rgba(255,255,255,1.0)")) + assert.True(t, strings.Contains(svgString, "stroke:white")) assert.True(t, strings.Contains(svgString, "stroke-width:5")) - assert.True(t, strings.Contains(svgString, "fill:rgba(255,255,255,1.0)")) + assert.True(t, strings.Contains(svgString, "fill:white")) + assert.False(t, strings.Contains(svgString, "font-size")) + assert.False(t, strings.Contains(svgString, "font-family")) + assert.True(t, strings.HasSuffix(svgString, "\"")) + + svgString = canvas.styleAsSVG(set, true) + assert.True(t, strings.HasPrefix(svgString, "style=\"")) + assert.True(t, strings.Contains(svgString, "stroke:white")) + assert.True(t, strings.Contains(svgString, "stroke-width:5")) + assert.True(t, strings.Contains(svgString, "fill:white")) + assert.True(t, strings.Contains(svgString, "font-size")) + assert.True(t, strings.Contains(svgString, "font-family")) assert.True(t, strings.HasSuffix(svgString, "\"")) } @@ -84,7 +94,7 @@ func TestCanvasClassSVG(t *testing.T) { canvas := &canvas{dpi: DefaultDPI} - assert.Equal(t, "class=\"test-class\"", canvas.styleAsSVG(set)) + assert.Equal(t, "class=\"test-class\"", canvas.styleAsSVG(set, false)) } func TestCanvasCustomInlineStylesheet(t *testing.T) { diff --git a/chartdraw/xaxis_test.go b/chartdraw/xaxis_test.go index 4f6e331..ef78a72 100644 --- a/chartdraw/xaxis_test.go +++ b/chartdraw/xaxis_test.go @@ -4,14 +4,12 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestXAxisGetTicks(t *testing.T) { t.Parallel() - r, err := PNG(1024, 1024) - require.NoError(t, err) + r := PNG(1024, 1024) xa := XAxis{} xr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024} @@ -29,8 +27,7 @@ func TestXAxisGetTicks(t *testing.T) { func TestXAxisGetTicksWithUserDefaults(t *testing.T) { t.Parallel() - r, err := PNG(1024, 1024) - require.NoError(t, err) + r := PNG(1024, 1024) xa := XAxis{ Ticks: []Tick{{Value: 1.0, Label: "1.0"}}, @@ -56,8 +53,7 @@ func TestXAxisMeasure(t *testing.T) { FontSize: 10.0, }, } - r, err := PNG(100, 100) - require.NoError(t, err) + r := PNG(100, 100) ticks := []Tick{{Value: 1.0, Label: "1.0"}, {Value: 2.0, Label: "2.0"}, {Value: 3.0, Label: "3.0"}} xa := XAxis{} xab := xa.Measure(r, NewBox(0, 0, 100, 100), &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks) diff --git a/chartdraw/yaxis_test.go b/chartdraw/yaxis_test.go index 398a99c..ef54abb 100644 --- a/chartdraw/yaxis_test.go +++ b/chartdraw/yaxis_test.go @@ -4,14 +4,12 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestYAxisGetTicks(t *testing.T) { t.Parallel() - r, err := PNG(1024, 1024) - require.NoError(t, err) + r := PNG(1024, 1024) ya := YAxis{} yr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024} @@ -29,8 +27,7 @@ func TestYAxisGetTicks(t *testing.T) { func TestYAxisGetTicksWithUserDefaults(t *testing.T) { t.Parallel() - r, err := PNG(1024, 1024) - require.NoError(t, err) + r := PNG(1024, 1024) ya := YAxis{ Ticks: []Tick{{Value: 1.0, Label: "1.0"}}, @@ -56,8 +53,7 @@ func TestYAxisMeasure(t *testing.T) { FontSize: 10.0, }, } - r, err := PNG(100, 100) - require.NoError(t, err) + r := PNG(100, 100) ticks := []Tick{{Value: 1.0, Label: "1.0"}, {Value: 2.0, Label: "2.0"}, {Value: 3.0, Label: "3.0"}} ya := YAxis{} yab := ya.Measure(r, NewBox(0, 0, 100, 100), &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks) @@ -74,8 +70,7 @@ func TestYAxisSecondaryMeasure(t *testing.T) { FontSize: 10.0, }, } - r, err := PNG(100, 100) - require.NoError(t, err) + r := PNG(100, 100) ticks := []Tick{{Value: 1.0, Label: "1.0"}, {Value: 2.0, Label: "2.0"}, {Value: 3.0, Label: "3.0"}} ya := YAxis{AxisType: YAxisSecondary} yab := ya.Measure(r, NewBox(0, 0, 100, 100), &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks) diff --git a/charts.go b/charts.go index 1708db0..701a1e5 100644 --- a/charts.go +++ b/charts.go @@ -17,16 +17,13 @@ const defaultYAxisLabelCountLow = 3 var defaultChartWidth = 600 var defaultChartHeight = 400 +var defaultPadding = chartdraw.NewBox(20, 20, 20, 20) -// SetDefaultWidth sets default width of chart -func SetDefaultWidth(width int) { +// SetDefaultChartDimensions sets default width and height of charts if not otherwise specified in their configuration. +func SetDefaultChartDimensions(width, height int) { if width > 0 { defaultChartWidth = width } -} - -// SetDefaultHeight sets default height of chart -func SetDefaultHeight(height int) { if height > 0 { defaultChartHeight = height } @@ -46,7 +43,7 @@ func defaultYAxisLabelCount(span float64, decimalData bool) int { return int(result) } -type Renderer interface { +type renderer interface { Render() (Box, error) } @@ -71,25 +68,27 @@ func (rh *renderHandler) Do() error { return nil } -type defaultRenderOption struct { // TODO - change names to be lower case for consistency - // Theme specifies the colors used for the chart. - Theme ColorPalette - // Padding specifies the padding of chart. - Padding Box - // SeriesList provides the data series. - SeriesList SeriesList - // XAxis are options for the x-axis. - XAxis XAxisOption - // YAxis are options for the y-axis (at most two). - YAxis []YAxisOption - // Title are options for rendering the title. - Title TitleOption - // Legend are options for the data legend. - Legend LegendOption +type defaultRenderOption struct { + // theme specifies the colors used for the chart. + theme ColorPalette + // padding specifies the padding of chart. + padding Box + // seriesList provides the data series. + seriesList SeriesList + // xAxis are options for the x-axis. + xAxis *XAxisOption + // yAxis are options for the y-axis (at most two). + yAxis []YAxisOption + // title are options for rendering the title. + title TitleOption + // legend are options for the data legend. + legend *LegendOption // backgroundIsFilled is true if the background is filled. backgroundIsFilled bool - // axisReversed is true if the x y axis is reversed. + // axisReversed is true if the x y-axis is reversed. axisReversed bool + // valueFormatter to format numeric values into labels. + valueFormatter ValueFormatter } type defaultRenderResult struct { @@ -99,25 +98,44 @@ type defaultRenderResult struct { } func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, error) { - fillThemeDefaults(getPreferredTheme(opt.Theme), &opt.Title, &opt.Legend, &opt.XAxis) + fillThemeDefaults(getPreferredTheme(opt.theme, p.theme), &opt.title, opt.legend, opt.xAxis) - seriesList := opt.SeriesList + seriesList := opt.seriesList seriesList.init() if !opt.backgroundIsFilled { - p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor()) + p.SetBackground(p.Width(), p.Height(), opt.theme.GetBackgroundColor()) + } + if !opt.padding.IsZero() { + p = p.Child(PainterPaddingOption(opt.padding)) } - if !opt.Padding.IsZero() { - p = p.Child(PainterPaddingOption(opt.Padding)) + // association between legend and series name + if len(opt.legend.Data) == 0 { + opt.legend.Data = opt.seriesList.Names() + } else { + seriesCount := len(opt.seriesList) + for index, name := range opt.legend.Data { + if index < seriesCount && len(opt.seriesList[index].Name) == 0 { + opt.seriesList[index].Name = name + } + } + nameIndexDict := map[string]int{} + for index, name := range opt.legend.Data { + nameIndexDict[name] = index + } + // ensure order of series is consistent with legend + sort.Slice(opt.seriesList, func(i, j int) bool { + return nameIndexDict[opt.seriesList[i].Name] < nameIndexDict[opt.seriesList[j].Name] + }) } const legendTitlePadding = 15 legendTopSpacing := 0 - legendResult, err := NewLegendPainter(p, opt.Legend).Render() + legendResult, err := newLegendPainter(p, *opt.legend).Render() if err != nil { return nil, err } - if !legendResult.IsZero() && !opt.Legend.Vertical && !flagIs(true, opt.Legend.OverlayChart) { + if !legendResult.IsZero() && !flagIs(true, opt.legend.Vertical) && !flagIs(true, opt.legend.OverlayChart) { legendHeight := legendResult.Height() if legendResult.Bottom < p.Height()/2 { // horizontal legend at the top, set the spacing based on the height @@ -131,7 +149,7 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e } } - titleBox, err := NewTitlePainter(p, opt.Title).Render() + titleBox, err := newTitlePainter(p, opt.title).Render() if err != nil { return nil, err } @@ -162,7 +180,7 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e } axisIndexList := make([]int, 0) - for _, series := range opt.SeriesList { + for _, series := range opt.seriesList { if containsInt(axisIndexList, series.YAxisIndex) { continue } @@ -173,20 +191,20 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e rangeWidthLeft := 0 rangeWidthRight := 0 - sort.Sort(sort.Reverse(sort.IntSlice(axisIndexList))) + reverseIntSlice(axisIndexList) // calculate the axis range for _, index := range axisIndexList { yAxisOption := YAxisOption{} - if len(opt.YAxis) > index { - yAxisOption = opt.YAxis[index] + if len(opt.yAxis) > index { + yAxisOption = opt.yAxis[index] } minPadRange, maxPadRange := 1.0, 1.0 if yAxisOption.RangeValuePaddingScale != nil { minPadRange = *yAxisOption.RangeValuePaddingScale maxPadRange = *yAxisOption.RangeValuePaddingScale } - min, max := opt.SeriesList.GetMinMax(index) + min, max := opt.seriesList.GetMinMax(index) decimalData := min != math.Floor(min) || (max-min) != math.Floor(max-min) if yAxisOption.Min != nil && *yAxisOption.Min < min { min = *yAxisOption.Min @@ -199,8 +217,8 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e // Label counts and y-axis padding are linked together to produce a user-friendly graph. // First when considering padding we want to prefer a zero axis start if reasonable, and add a slight - // padding to the max so there is a little space at the top of the graph. In addition, we want to pick - // a max value that will result in round intervals on the axis. These details are in range.go. + // padding to the max so there is a little space at the top of the graph. In addition, we want to pick + // a max value that will result in round intervals on the axis. These details are in range.go. // But in order to produce round intervals we need to have an idea of how many intervals there are. // In addition, if the user specified a `Unit` value we may need to adjust our label count calculation // based on the padded range. @@ -232,28 +250,23 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e yAxisOption.LabelCount = labelCount } labelCount = chartdraw.MaxInt(labelCount+yAxisOption.LabelCountAdjustment, 2) - r := axisRange{ - p: p, - divideCount: labelCount, - min: min, - max: max, - size: rangeHeight, - } + r := newRange(p, getPreferredValueFormatter(yAxisOption.ValueFormatter, opt.valueFormatter), + rangeHeight, labelCount, min, max, 0, 0) result.axisRanges[index] = r if yAxisOption.Theme == nil { - yAxisOption.Theme = opt.Theme + yAxisOption.Theme = opt.theme } if !opt.axisReversed { yAxisOption.Data = r.Values() } else { yAxisOption.isCategoryAxis = true - // we need to update the range labels or the bars wont be aligned to the Y axis + // we need to update the range labels or the bars won't be aligned to the Y axis r.divideCount = len(seriesList[0].Data) result.axisRanges[index] = r // since the x-axis is the value part, it's label is calculated and processed separately - opt.XAxis.Data = r.Values() - opt.XAxis.isValueAxis = true + opt.xAxis.Data = r.Values() + opt.xAxis.isValueAxis = true } reverseStringSlice(yAxisOption.Data) child := p.Child(PainterPaddingOption(Box{ @@ -263,9 +276,9 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e })) var yAxis *axisPainter if index == 0 { - yAxis = NewLeftYAxis(child, yAxisOption) + yAxis = newLeftYAxis(child, yAxisOption) } else { - yAxis = NewRightYAxis(child, yAxisOption) + yAxis = newRightYAxis(child, yAxisOption) } if yAxisBox, err := yAxis.Render(); err != nil { return nil, err @@ -276,11 +289,11 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e } } - xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{ + xAxis := newBottomXAxis(p.Child(PainterPaddingOption(Box{ Left: rangeWidthLeft, Right: rangeWidthRight, IsSet: true, - })), opt.XAxis) + })), *opt.xAxis) if _, err := xAxis.Render(); err != nil { return nil, err } @@ -294,7 +307,7 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e return &result, nil } -func doRender(renderers ...Renderer) error { +func doRender(renderers ...renderer) error { for _, r := range renderers { if _, err := r.Render(); err != nil { return err @@ -313,21 +326,14 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { isChild := opt.parent != nil if !isChild { - p, err := NewPainter(PainterOptions{ + opt.parent = NewPainter(PainterOptions{ OutputFormat: opt.OutputFormat, Width: opt.Width, Height: opt.Height, Font: opt.Font, }) - if err != nil { - return nil, err - } - opt.parent = p } p := opt.parent - if opt.ValueFormatter != nil { - p.valueFormatter = opt.ValueFormatter - } if !opt.Box.IsZero() { p = p.Child(PainterBoxOption(opt.Box)) } @@ -357,29 +363,30 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { axisReversed := len(horizontalBarSeriesList) != 0 renderOpt := defaultRenderOption{ - Theme: opt.Theme, - Padding: opt.Padding, - SeriesList: opt.SeriesList, - XAxis: opt.XAxis, - YAxis: opt.YAxis, - Title: opt.Title, - Legend: opt.Legend, - axisReversed: axisReversed, + theme: opt.Theme, + padding: opt.Padding, + seriesList: opt.SeriesList, + xAxis: &opt.XAxis, + yAxis: opt.YAxis, + title: opt.Title, + legend: &opt.Legend, + axisReversed: axisReversed, + valueFormatter: opt.ValueFormatter, // the background color has been set backgroundIsFilled: true, } if len(pieSeriesList) != 0 || len(radarSeriesList) != 0 || len(funnelSeriesList) != 0 { - renderOpt.XAxis.Show = False() - renderOpt.YAxis = []YAxisOption{ + renderOpt.xAxis.Show = False() + renderOpt.yAxis = []YAxisOption{ { Show: False(), }, } } if len(horizontalBarSeriesList) != 0 { - renderOpt.YAxis[0].Unit = 1 + renderOpt.yAxis[0].Unit = 1 } renderResult, err := defaultRender(p, renderOpt) @@ -392,7 +399,7 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { // bar chart if len(barSeriesList) != 0 { handler.Add(func() error { - _, err := NewBarChart(p, BarChartOption{ + _, err := newBarChart(p, BarChartOption{ Theme: opt.Theme, Font: opt.Font, XAxis: opt.XAxis, @@ -405,7 +412,7 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { // horizontal bar chart if len(horizontalBarSeriesList) != 0 { handler.Add(func() error { - _, err := NewHorizontalBarChart(p, HorizontalBarChartOption{ + _, err := newHorizontalBarChart(p, HorizontalBarChartOption{ Theme: opt.Theme, Font: opt.Font, BarHeight: opt.BarHeight, @@ -418,7 +425,7 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { // pie chart if len(pieSeriesList) != 0 { handler.Add(func() error { - _, err := NewPieChart(p, PieChartOption{ + _, err := newPieChart(p, PieChartOption{ Theme: opt.Theme, Font: opt.Font, }).render(renderResult, pieSeriesList) @@ -429,14 +436,14 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { // line chart if len(lineSeriesList) != 0 { handler.Add(func() error { - _, err := NewLineChart(p, LineChartOption{ - Theme: opt.Theme, - Font: opt.Font, - XAxis: opt.XAxis, - SymbolShow: opt.SymbolShow, - StrokeWidth: opt.LineStrokeWidth, - FillArea: opt.FillArea, - FillOpacity: opt.FillOpacity, + _, err := newLineChart(p, LineChartOption{ + Theme: opt.Theme, + Font: opt.Font, + XAxis: opt.XAxis, + SymbolShow: opt.SymbolShow, + LineStrokeWidth: opt.LineStrokeWidth, + FillArea: opt.FillArea, + FillOpacity: opt.FillOpacity, }).render(renderResult, lineSeriesList) return err }) @@ -445,7 +452,7 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { // radar chart if len(radarSeriesList) != 0 { handler.Add(func() error { - _, err := NewRadarChart(p, RadarChartOption{ + _, err := newRadarChart(p, RadarChartOption{ Theme: opt.Theme, Font: opt.Font, RadarIndicators: opt.RadarIndicators, @@ -457,7 +464,7 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { // funnel chart if len(funnelSeriesList) != 0 { handler.Add(func() error { - _, err := NewFunnelChart(p, FunnelChartOption{ + _, err := newFunnelChart(p, FunnelChartOption{ Theme: opt.Theme, Font: opt.Font, }).render(renderResult, funnelSeriesList) diff --git a/charts_test.go b/charts_test.go index b543540..e98a03e 100644 --- a/charts_test.go +++ b/charts_test.go @@ -16,10 +16,7 @@ func BenchmarkMultiChartPNGRender(b *testing.B) { Top: "-90", }, Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", + "Milk Tea", "Matcha Latte", "Cheese Cocoa", "Walnut Brownie", }, }, Padding: chartdraw.Box{ @@ -30,12 +27,7 @@ func BenchmarkMultiChartPNGRender(b *testing.B) { }, XAxis: XAxisOption{ Data: []string{ - "2012", - "2013", - "2014", - "2015", - "2016", - "2017", + "2012", "2013", "2014", "2015", "2016", "2017", }, }, YAxis: []YAxisOption{ @@ -45,49 +37,21 @@ func BenchmarkMultiChartPNGRender(b *testing.B) { Max: FloatPointer(90), }, }, - SeriesList: []Series{ - NewSeriesFromValues([]float64{ - 56.5, - 82.1, - 88.7, - 70.1, - 53.4, - 85.1, + SeriesList: append( + NewSeriesListLine([][]float64{ + {56.5, 82.1, 88.7, 70.1, 53.4, 85.1}, + {51.1, 51.4, 55.1, 53.3, 73.8, 68.7}, }), - 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), - }, + NewSeriesListBar([][]float64{ + {40.1, 62.2, 69.5, 36.4, 45.2, 32.5}, + {25.2, 37.1, 41.2, 18, 33.9, 49.1}, + })...), Children: []ChartOption{ { Legend: LegendOption{ Show: False(), Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", + "Milk Tea", "Matcha Latte", "Cheese Cocoa", "Walnut Brownie", }, }, Box: chartdraw.Box{ @@ -96,15 +60,9 @@ func BenchmarkMultiChartPNGRender(b *testing.B) { Right: 500, Bottom: 120, }, - SeriesList: NewPieSeriesList([]float64{ - 435.9, - 354.3, - 285.9, - 204.5, + SeriesList: NewSeriesListPie([]float64{ + 435.9, 354.3, 285.9, 204.5, }, PieSeriesOption{ - Label: SeriesLabel{ - Show: true, - }, Radius: "35%", }), }, @@ -129,10 +87,7 @@ func BenchmarkMultiChartSVGRender(b *testing.B) { Top: "-90", }, Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", + "Milk Tea", "Matcha Latte", "Cheese Cocoa", "Walnut Brownie", }, }, Padding: chartdraw.Box{ @@ -157,49 +112,21 @@ func BenchmarkMultiChartSVGRender(b *testing.B) { Max: FloatPointer(90), }, }, - SeriesList: []Series{ - NewSeriesFromValues([]float64{ - 56.5, - 82.1, - 88.7, - 70.1, - 53.4, - 85.1, + SeriesList: append( + NewSeriesListLine([][]float64{ + {56.5, 82.1, 88.7, 70.1, 53.4, 85.1}, + {51.1, 51.4, 55.1, 53.3, 73.8, 68.7}, }), - 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), - }, + NewSeriesListBar([][]float64{ + {40.1, 62.2, 69.5, 36.4, 45.2, 32.5}, + {25.2, 37.1, 41.2, 18, 33.9, 49.1}, + })...), Children: []ChartOption{ { Legend: LegendOption{ Show: False(), Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", + "Milk Tea", "Matcha Latte", "Cheese Cocoa", "Walnut Brownie", }, }, Box: chartdraw.Box{ @@ -208,15 +135,9 @@ func BenchmarkMultiChartSVGRender(b *testing.B) { Right: 500, Bottom: 120, }, - SeriesList: NewPieSeriesList([]float64{ - 435.9, - 354.3, - 285.9, - 204.5, + SeriesList: NewSeriesListPie([]float64{ + 435.9, 354.3, 285.9, 204.5, }, PieSeriesOption{ - Label: SeriesLabel{ - Show: true, - }, Radius: "35%", }), }, diff --git a/echarts.go b/echarts.go index b804944..823e6d7 100644 --- a/echarts.go +++ b/echarts.go @@ -47,17 +47,13 @@ func (value *EChartsSeriesDataValue) UnmarshalJSON(data []byte) error { data = convertToArray(data) return json.Unmarshal(data, &value.values) } + func (value *EChartsSeriesDataValue) First() float64 { if len(value.values) == 0 { return 0 } return value.values[0] } -func NewEChartsSeriesDataValue(values ...float64) EChartsSeriesDataValue { - return EChartsSeriesDataValue{ - values: values, - } -} type EChartsSeriesData struct { Value EChartsSeriesDataValue `json:"value"` @@ -275,14 +271,10 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { Type: item.Type, Name: dataItem.Name, Label: SeriesLabel{ - Show: true, + Show: True(), }, Radius: item.Radius, - Data: []SeriesData{ - { - Value: dataItem.Value.First(), - }, - }, + Data: []float64{dataItem.Value.First()}, }) } continue @@ -293,25 +285,21 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { seriesList = append(seriesList, Series{ Name: dataItem.Name, Type: item.Type, - Data: NewSeriesDataFromValues(dataItem.Value.values), - Max: item.Max, - Min: item.Min, + Data: dataItem.Value.values, Label: SeriesLabel{ FontStyle: FontStyle{ FontColor: ParseColor(item.Label.Color), }, - Show: item.Label.Show, + Show: BoolPointer(item.Label.Show), Distance: item.Label.Distance, }, }) } continue } - data := make([]SeriesData, len(item.Data)) + data := make([]float64, len(item.Data)) for j, dataItem := range item.Data { - data[j] = SeriesData{ - Value: dataItem.Value.First(), - } + data[j] = dataItem.Value.First() } seriesList = append(seriesList, Series{ Type: item.Type, @@ -321,7 +309,7 @@ func (esList EChartsSeriesList) ToSeriesList() SeriesList { FontStyle: FontStyle{ FontColor: ParseColor(item.Label.Color), }, - Show: item.Label.Show, + Show: BoolPointer(item.Label.Show), Distance: item.Label.Distance, }, Name: item.Name, @@ -417,7 +405,7 @@ func (eo *EChartsOption) ToOption() ChartOption { Top: string(eo.Legend.Top), }, Align: eo.Legend.Align, - Vertical: strings.EqualFold(eo.Legend.Orient, "vertical"), + Vertical: BoolPointer(strings.EqualFold(eo.Legend.Orient, "vertical")), }, RadarIndicators: eo.Radar.Indicator, Width: eo.Width, diff --git a/echarts_test.go b/echarts_test.go index fd11843..8ef9f7f 100644 --- a/echarts_test.go +++ b/echarts_test.go @@ -34,7 +34,7 @@ func TestEChartsSeriesDataValue(t *testing.T) { assert.Equal(t, EChartsSeriesDataValue{ values: []float64{1, 2}, }, es) - assert.Equal(t, NewEChartsSeriesDataValue(1, 2), es) + assert.Equal(t, EChartsSeriesDataValue{values: []float64{1, 2}}, es) assert.Equal(t, 1.0, es.First()) } @@ -525,5 +525,5 @@ func TestRenderEChartsToSVG(t *testing.T) { ] }`) require.NoError(t, err) - assertEqualSVG(t, "RainfallEvaporationRainfall vs EvaporationFake Data2702402101801501209060300JanFebMarAprMayJunJulAugSepOctNovDec162.22182.22.341.6348.07", data) + assertEqualSVG(t, "RainfallEvaporationRainfall vs EvaporationFake Data2702402101801501209060300JanFebMarAprMayJunJulAugSepOctNovDec162.22182.22.341.6348.07", data) } diff --git a/examples/bar_chart-2/main.go b/examples/bar_chart-2/main.go index 6302bcf..e7b8b12 100644 --- a/examples/bar_chart-2/main.go +++ b/examples/bar_chart-2/main.go @@ -27,8 +27,7 @@ func main() { {2.6, 5.9, 9.0, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6.0, 2.3}, } - opt := charts.BarChartOption{} - opt.SeriesList = charts.NewSeriesListDataFromValues(values, charts.ChartTypeBar) + opt := charts.NewBarChartOptionWithData(values) opt.XAxis.Data = []string{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", } @@ -49,15 +48,12 @@ func main() { charts.SeriesMarkDataTypeMin, ) - p, err := charts.NewPainter(charts.PainterOptions{ + p := charts.NewPainter(charts.PainterOptions{ OutputFormat: charts.ChartOutputPNG, Width: 600, Height: 400, }) - if err != nil { - panic(err) - } - if _, err = charts.NewBarChart(p, opt).Render(); err != nil { + if err := p.BarChart(opt); err != nil { panic(err) } diff --git a/examples/chinese/main.go b/examples/chinese/main.go index 21ffc1b..37760c6 100644 --- a/examples/chinese/main.go +++ b/examples/chinese/main.go @@ -28,54 +28,16 @@ func main() { } else if err = charts.InstallFont("noto", buf); err != nil { panic(err) } + // in this example we just change the global default font charts.SetDefaultFont("noto") + // It's also possible to specify the font on the chart configuration (for example on the title, or legend specifically) values := [][]float64{ - { - 120, - 132, - 101, - 134, - 90, - 230, - 210, - }, - { - 220, - 182, - 191, - 234, - 290, - 330, - 310, - }, - { - 150, - 232, - 201, - 154, - 190, - 330, - 410, - }, - { - 320, - 332, - 301, - 334, - 390, - 330, - 320, - }, - { - 820, - 932, - 901, - 934, - 1290, - 1330, - 1320, - }, + {120, 132, 101, 134, 90, 230, 210}, + {220, 182, 191, 234, 290, 330, 310}, + {150, 232, 201, 154, 190, 330, 410}, + {320, 332, 301, 334, 390, 330, 320}, + {820, 932, 901, 934, 1290, 1330, 1320}, } p, err := charts.LineRender( values, diff --git a/examples/funnel_chart-2/main.go b/examples/funnel_chart-2/main.go index 124da94..c36583e 100644 --- a/examples/funnel_chart-2/main.go +++ b/examples/funnel_chart-2/main.go @@ -24,23 +24,19 @@ func writeFile(buf []byte) error { func main() { values := []float64{100, 80, 60, 40, 20, 10, 2} - opt := charts.FunnelChartOption{} - opt.SeriesList = charts.NewFunnelSeriesList(values) + opt := charts.NewFunnelChartOptionWithData(values) opt.Title.Text = "Funnel" opt.Legend.Data = []string{ "Show", "Click", "Visit", "Inquiry", "Order", "Pay", "Cancel", } opt.Legend.Padding = charts.Box{Left: 100} - p, err := charts.NewPainter(charts.PainterOptions{ + p := charts.NewPainter(charts.PainterOptions{ OutputFormat: charts.ChartOutputPNG, Width: 600, Height: 400, }) - if err != nil { - panic(err) - } - if _, err = charts.NewFunnelChart(p, opt).Render(); err != nil { + if err := p.FunnelChart(opt); err != nil { panic(err) } diff --git a/examples/horizontal_bar_chart-2/main.go b/examples/horizontal_bar_chart-2/main.go index 20f50bb..02962a2 100644 --- a/examples/horizontal_bar_chart-2/main.go +++ b/examples/horizontal_bar_chart-2/main.go @@ -27,8 +27,7 @@ func main() { {20, 40, 60, 80, 100, 120, 140}, } - opt := charts.HorizontalBarChartOption{} - opt.SeriesList = charts.NewSeriesListDataFromValues(values, charts.ChartTypeHorizontalBar) + opt := charts.NewHorizontalBarChartOptionWithData(values) opt.Title.Text = "World Population" opt.Padding = charts.Box{ Top: 20, @@ -47,15 +46,12 @@ func main() { }, } - p, err := charts.NewPainter(charts.PainterOptions{ + p := charts.NewPainter(charts.PainterOptions{ OutputFormat: charts.ChartOutputPNG, Width: 600, Height: 400, }) - if err != nil { - panic(err) - } - if _, err = charts.NewHorizontalBarChart(p, opt).Render(); err != nil { + if err := p.HorizontalBarChart(opt); err != nil { panic(err) } diff --git a/examples/line_chart-2/main.go b/examples/line_chart-2/main.go index 1a22b20..439331f 100644 --- a/examples/line_chart-2/main.go +++ b/examples/line_chart-2/main.go @@ -30,11 +30,9 @@ func main() { {820, 932, 901, 934, 1290, 1330, 1320}, } - opt := charts.LineChartOption{} - opt.SeriesList = charts.NewSeriesListDataFromValues(values, charts.ChartTypeLine) + opt := charts.NewLineChartOptionWithData(values) opt.Title.Text = "Line" opt.Title.FontStyle.FontSize = 16 - opt.XAxis.Data = []string{ "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", } @@ -45,17 +43,14 @@ func main() { Left: 100, } opt.SymbolShow = charts.True() - opt.StrokeWidth = 1.2 + opt.LineStrokeWidth = 1.2 - p, err := charts.NewPainter(charts.PainterOptions{ + p := charts.NewPainter(charts.PainterOptions{ OutputFormat: charts.ChartOutputPNG, Width: 600, Height: 400, }) - if err != nil { - panic(err) - } - if _, err = charts.NewLineChart(p, opt).Render(); err != nil { + if err := p.LineChart(opt); err != nil { panic(err) } diff --git a/examples/line_chart-3/main.go b/examples/line_chart-3/main.go index 7acac2e..ae5a7cc 100644 --- a/examples/line_chart-3/main.go +++ b/examples/line_chart-3/main.go @@ -36,8 +36,7 @@ func main() { p, err := charts.LineRender( values, charts.ThemeNameOptionFunc(charts.ThemeVividLight), // custom color theme - charts.WidthOptionFunc(800), - charts.HeightOptionFunc(600), + charts.DimensionsOptionFunc(800, 600), charts.TitleOptionFunc(charts.TitleOption{ Text: "Line Chart Demo", Offset: charts.OffsetCenter, @@ -45,7 +44,7 @@ func main() { charts.LegendOptionFunc(charts.LegendOption{ Data: []string{"Critical", "High", "Medium", "Low"}, // Legend Vertical, on the right, and with smaller font to give more space for data - Vertical: true, + Vertical: charts.True(), Offset: charts.OffsetRight, Align: charts.AlignRight, FontStyle: charts.FontStyle{ diff --git a/examples/line_chart-4/main.go b/examples/line_chart-4/main.go index 780307b..b57eddf 100644 --- a/examples/line_chart-4/main.go +++ b/examples/line_chart-4/main.go @@ -46,7 +46,7 @@ func main() { panic(err) } - opt := charts.LineChartOption{} + opt := charts.NewLineChartOptionWithData(values) opt.Theme = charts.GetTheme(charts.ThemeAnt) opt.Padding = charts.Box{ Top: 20, @@ -54,7 +54,6 @@ func main() { Right: 20, Bottom: 10, } - opt.SeriesList = charts.NewSeriesListDataFromValues(values, charts.ChartTypeLine) opt.Title.Text = "Canon RF Zoom Lenses" opt.Title.Offset = charts.OffsetCenter opt.Title.FontStyle.FontSize = 16 @@ -80,18 +79,15 @@ func main() { } opt.Legend.Show = charts.False() opt.SymbolShow = charts.False() - opt.StrokeWidth = 1.5 + opt.LineStrokeWidth = 1.5 - p, err := charts.NewPainter(charts.PainterOptions{ + p := charts.NewPainter(charts.PainterOptions{ OutputFormat: charts.ChartOutputPNG, // positions drawn below depend on the canvas size set here Width: 600, Height: 400, }) - if err != nil { - panic(err) - } - if _, err = charts.NewLineChart(p, opt).Render(); err != nil { + if err = p.LineChart(opt); err != nil { panic(err) } @@ -101,44 +97,35 @@ func main() { FontColor: drawing.ColorBlack, Font: charts.GetDefaultFont(), } - p.SetFontStyle(fontStyle) - //p.TextRotation("f/stop", 10, 170, chartdraw.DegreesToRadians(90)) + //p.Text("f/stop", 10, 170, chartdraw.DegreesToRadians(90), fontStyle) fontStyle.FontColor = opt.Theme.GetSeriesColor(0) - p.SetFontStyle(fontStyle) - p.Text(labels[0], 420, 84) + p.Text(labels[0], 420, 84, 0, fontStyle) fontStyle.FontColor = opt.Theme.GetSeriesColor(1) - p.SetFontStyle(fontStyle) - p.Text(labels[1], 45, 284) + p.Text(labels[1], 45, 284, 0, fontStyle) fontStyle.FontColor = opt.Theme.GetSeriesColor(2) - p.SetFontStyle(fontStyle) - p.Text(labels[2], 140, 230) + p.Text(labels[2], 140, 230, 0, fontStyle) fontStyle.FontColor = opt.Theme.GetSeriesColor(3) - p.SetFontStyle(fontStyle) - p.Text(labels[3], 160, 155) + p.Text(labels[3], 160, 155, 0, fontStyle) fontStyle.FontSize = 8 fontStyle.FontColor = opt.Theme.GetSeriesColor(0) - p.SetFontStyle(fontStyle) - p.Text("f/4.5", 42, 220) - p.Text("f/5.0", 105, 196) - p.Text("f/6.3", 370, 137) - p.Text("f/7.1", 570, 100) + p.Text("f/4.5", 42, 220, 0, fontStyle) + p.Text("f/5.0", 105, 196, 0, fontStyle) + p.Text("f/6.3", 370, 137, 0, fontStyle) + p.Text("f/7.1", 570, 100, 0, fontStyle) fontStyle.FontColor = opt.Theme.GetSeriesColor(1) - p.SetFontStyle(fontStyle) - p.Text("f/2.8", 5, 298) + p.Text("f/2.8", 5, 298, 0, fontStyle) fontStyle.FontColor = opt.Theme.GetSeriesColor(2) - p.SetFontStyle(fontStyle) - p.Text("f/4.0", 40, 244) + p.Text("f/4.0", 40, 244, 0, fontStyle) fontStyle.FontColor = opt.Theme.GetSeriesColor(3) - p.SetFontStyle(fontStyle) - p.Text("f/5.6", 92, 168) + p.Text("f/5.6", 92, 168, 0, fontStyle) if buf, err := p.Bytes(); err != nil { panic(err) diff --git a/examples/multiple_charts-1/main.go b/examples/multiple_charts-1/main.go index f2254ee..58da72a 100644 --- a/examples/multiple_charts-1/main.go +++ b/examples/multiple_charts-1/main.go @@ -24,14 +24,11 @@ func writeFile(buf []byte) error { } func main() { - p, err := charts.NewPainter(charts.PainterOptions{ + p := charts.NewPainter(charts.PainterOptions{ OutputFormat: charts.ChartOutputPNG, Width: 800, Height: 600, }) - if err != nil { - panic(err) - } p.SetBackground(800, 600, drawing.ColorWhite) // set the space and theme for each chart topCenterPainter := p.Child(charts.PainterBoxOption(chartdraw.NewBox(0, 0, 800, 300)), @@ -59,7 +56,7 @@ func main() { "Email", "Union Ads", "Video Ads", "Direct", "Search Engine", }, }, - SeriesList: charts.NewSeriesListDataFromValues([][]float64{ + SeriesList: charts.NewSeriesListLine([][]float64{ {120, 132, 101, 134, 90, 230, 210}, {220, 182, 191, 234, 290, 330, 310}, {150, 232, 201, 154, 190, 330, 410}, @@ -69,13 +66,13 @@ func main() { } // render the same chart in each spot for the demo - if _, err = charts.NewLineChart(bottomLeftPainter, lineOpt).Render(); err != nil { + if err := bottomLeftPainter.LineChart(lineOpt); err != nil { panic(err) } - if _, err = charts.NewLineChart(bottomRightPainter, lineOpt).Render(); err != nil { + if err := bottomRightPainter.LineChart(lineOpt); err != nil { panic(err) } - if _, err = charts.NewLineChart(topCenterPainter, lineOpt).Render(); err != nil { + if err := topCenterPainter.LineChart(lineOpt); err != nil { panic(err) } diff --git a/examples/multiple_charts-2/main.go b/examples/multiple_charts-2/main.go index 091eda3..c610ec5 100644 --- a/examples/multiple_charts-2/main.go +++ b/examples/multiple_charts-2/main.go @@ -78,13 +78,13 @@ func main() { }, }, }, - SeriesList: charts.NewSeriesListDataFromValues([][]float64{ + SeriesList: charts.NewSeriesListHorizontalBar([][]float64{ {70, 90, 110, 130}, {80, 100, 120, 140}, - }, charts.ChartTypeHorizontalBar), + }), } p = p.Child(charts.PainterBoxOption(chartdraw.NewBox(0, 200, 600, 200))) - if _, err = charts.NewHorizontalBarChart(p, hBarOpt).Render(); err != nil { + if err = p.HorizontalBarChart(hBarOpt); err != nil { panic(err) } diff --git a/examples/multiple_charts-3/main.go b/examples/multiple_charts-3/main.go index 6ed8d22..87277d9 100644 --- a/examples/multiple_charts-3/main.go +++ b/examples/multiple_charts-3/main.go @@ -59,10 +59,10 @@ func main() { opt.Children = []charts.ChartOption{ { Box: chartdraw.NewBox(10, 200, 500, 200), - SeriesList: charts.NewSeriesListDataFromValues([][]float64{ + SeriesList: charts.NewSeriesListHorizontalBar([][]float64{ {70, 90, 110, 130}, {80, 100, 120, 140}, - }, charts.ChartTypeHorizontalBar), + }), Legend: charts.LegendOption{ Data: []string{ "2011", "2012", diff --git a/examples/pie_chart-1/main.go b/examples/pie_chart-1/main.go index 2cea2e1..7a45735 100644 --- a/examples/pie_chart-1/main.go +++ b/examples/pie_chart-1/main.go @@ -48,7 +48,7 @@ func main() { Data: []string{ "Search Engine", "Direct", "Email", "Union Ads", "Video Ads", }, - Vertical: true, + Vertical: charts.True(), Offset: charts.OffsetStr{ Left: "80%", Top: charts.PositionBottom, @@ -57,7 +57,6 @@ func main() { FontSize: 10, }, }), - charts.PieSeriesShowLabel(), ) if err != nil { panic(err) diff --git a/examples/pie_chart-2/main.go b/examples/pie_chart-2/main.go index 9277297..419412b 100644 --- a/examples/pie_chart-2/main.go +++ b/examples/pie_chart-2/main.go @@ -26,8 +26,7 @@ func main() { 1048, 735, 580, 484, 300, } - opt := charts.PieChartOption{} - opt.SeriesList = charts.NewPieSeriesList(values) + opt := charts.NewPieChartOptionWithData(values) opt.Title = charts.TitleOption{ Text: "Rainfall vs Evaporation", Subtext: "(Fake Data)", @@ -49,7 +48,7 @@ func main() { Data: []string{ "Search Engine", "Direct", "Email", "Union Ads", "Video Ads", }, - Vertical: true, + Vertical: charts.True(), Offset: charts.OffsetStr{ Left: "80%", Top: charts.PositionBottom, @@ -58,19 +57,13 @@ func main() { FontSize: 10, }, } - for index := range opt.SeriesList { - opt.SeriesList[index].Label.Show = true - } - p, err := charts.NewPainter(charts.PainterOptions{ + p := charts.NewPainter(charts.PainterOptions{ OutputFormat: charts.ChartOutputPNG, Width: 600, Height: 400, }) - if err != nil { - panic(err) - } - if _, err = charts.NewPieChart(p, opt).Render(); err != nil { + if err := p.PieChart(opt); err != nil { panic(err) } diff --git a/examples/radar_chart-2/main.go b/examples/radar_chart-2/main.go index 95875cc..9b55d4f 100644 --- a/examples/radar_chart-2/main.go +++ b/examples/radar_chart-2/main.go @@ -27,8 +27,23 @@ func main() { {5000, 14000, 28000, 26000, 42000, 21000}, } - opt := charts.RadarChartOption{} - opt.SeriesList = charts.NewSeriesListDataFromValues(values, charts.ChartTypeRadar) + opt := charts.NewRadarChartOptionWithData(values, + []string{ + "Sales", + "Administration", + "Information Technology", + "Customer Support", + "Development", + "Marketing", + }, + []float64{ + 6500, + 16000, + 30000, + 38000, + 52000, + 25000, + }) opt.Title = charts.TitleOption{ Text: "Basic Radar Chart", FontStyle: charts.FontStyle{ @@ -41,31 +56,13 @@ func main() { }, Offset: charts.OffsetRight, } - opt.RadarIndicators = charts.NewRadarIndicators([]string{ - "Sales", - "Administration", - "Information Technology", - "Customer Support", - "Development", - "Marketing", - }, []float64{ - 6500, - 16000, - 30000, - 38000, - 52000, - 25000, - }) - p, err := charts.NewPainter(charts.PainterOptions{ + p := charts.NewPainter(charts.PainterOptions{ OutputFormat: charts.ChartOutputPNG, Width: 600, Height: 400, }) - if err != nil { - panic(err) - } - if _, err = charts.NewRadarChart(p, opt).Render(); err != nil { + if err := p.RadarChart(opt); err != nil { panic(err) } diff --git a/examples/table-1/main.go b/examples/table-1/main.go index 4fcf44a..cafa685 100644 --- a/examples/table-1/main.go +++ b/examples/table-1/main.go @@ -25,7 +25,7 @@ func writeFile(buf []byte, filename string) error { } func main() { - charts.SetDefaultWidth(810) + charts.SetDefaultChartDimensions(810, 0) // 0 for height will leave it unchanged header := []string{ "Name", "Age", @@ -64,7 +64,7 @@ func main() { 3: 2, 4: 2, } - p, err := charts.TableRender( + p, err := charts.TableRenderValues( header, data, spans, @@ -80,7 +80,7 @@ func main() { } bgColor := charts.Color{R: 28, G: 28, B: 32, A: 255} - p, err = charts.TableOptionRender(charts.TableChartOption{ + p, err = charts.TableOptionRenderDirect(charts.TableChartOption{ Header: []string{"Name", "Price", "Change"}, BackgroundColor: bgColor, HeaderBackgroundColor: charts.Color{R: 80, G: 80, B: 80, A: 255}, diff --git a/examples/web-1/main.go b/examples/web-1/main.go index 971b76f..b91d9c6 100644 --- a/examples/web-1/main.go +++ b/examples/web-1/main.go @@ -73,8 +73,7 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha theme := query.Get("theme") width, _ := strconv.Atoi(query.Get("width")) height, _ := strconv.Atoi(query.Get("height")) - charts.SetDefaultWidth(width) - charts.SetDefaultWidth(height) + charts.SetDefaultChartDimensions(width, height) bytesList := make([][]byte, 0) for _, opt := range chartOptions { opt.Theme = charts.GetTheme(theme) @@ -97,14 +96,10 @@ func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.Cha bytesList = append(bytesList, buf) } - p, err := charts.TableOptionRender(charts.TableChartOption{ + p, err := charts.TableOptionRenderDirect(charts.TableChartOption{ OutputFormat: charts.ChartOutputSVG, Header: []string{ - "Name", - "Age", - "Address", - "Tag", - "Action", + "Name", "Age", "Address", "Tag", "Action", }, Data: [][]string{ { @@ -155,72 +150,22 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, Legend: charts.LegendOption{ Data: []string{ - "Email", - "Union Ads", - "Video Ads", - "Direct", - "Search Engine", + "Email", "Union Ads", "Video Ads", "Direct", "Search Engine", }, Padding: charts.Box{Left: 100}, }, XAxis: charts.XAxisOption{ Data: []string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", + "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, - }), - }, + SeriesList: charts.NewSeriesListLine([][]float64{ + {120, 132, 101, 134, 90, 230, 210}, + {220, 182, 191, 234, 290, 330, 310}, + {150, 232, 201, 154, 190, 330, 410}, + {320, 332, 301, 334, 390, 330, 320}, + {820, 932, 901, 934, 1290, 1330, 1320}, + }), }, // temperature line chart { @@ -235,46 +180,29 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, Legend: charts.LegendOption{ Data: []string{ - "Highest", - "Lowest", + "Highest", "Lowest", }, }, XAxis: charts.XAxisOption{ Data: []string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", }, BoundaryGap: charts.False(), }, SeriesList: []charts.Series{ { - Data: charts.NewSeriesDataFromValues([]float64{ - 14, - 11, - 13, - 11, - 12, - 12, - 7, - }), + Data: []float64{ + 14, 11, 13, 11, 12, 12, 7, + }, + Type: charts.ChartTypeLine, MarkPoint: charts.NewMarkPoint(charts.SeriesMarkDataTypeMax, charts.SeriesMarkDataTypeMin), MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage), }, { - Data: charts.NewSeriesDataFromValues([]float64{ - 1, - -2, - 2, - 5, - 3, - 2, - 0, - }), + Data: []float64{ + 1, -2, 2, 5, 3, 2, 0, + }, + Type: charts.ChartTypeLine, MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage), }, }, @@ -291,29 +219,15 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, XAxis: charts.XAxisOption{ Data: []string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", }, }, YAxis: []charts.YAxisOption{{ Min: charts.FloatPointer(0.0), // ensure y-axis starts at 0 }}, - SeriesList: []charts.Series{ - charts.NewSeriesFromValues([]float64{ - 120, - 132, - 101, - 134, - 90, - 230, - 210, - }), - }, + SeriesList: charts.NewSeriesListLine([][]float64{ + {120, 132, 101, 134, 90, 230, 210}, + }), FillArea: true, }, // histogram @@ -326,59 +240,29 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, XAxis: charts.XAxisOption{ Data: []string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", + "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", }, }, Legend: charts.LegendOption{ Data: []string{ - "Rainfall", - "Evaporation", + "Rainfall", "Evaporation", }, Icon: charts.IconRect, }, SeriesList: []charts.Series{ - charts.NewSeriesFromValues([]float64{ - 120, - 200, - 150, - 80, - 70, - 110, - 130, - }, charts.ChartTypeBar), { + Data: []float64{ + 120, 200, 150, 80, 70, 110, 130, + }, Type: charts.ChartTypeBar, - Data: []charts.SeriesData{ - { - Value: 100, - }, - { - Value: 190, - }, - { - Value: 230, - }, - { - Value: 140, - }, - { - Value: 100, - }, - { - Value: 200, - }, - { - Value: 180, - }, + }, + { + Data: []float64{ + 100, 190, 230, 140, 100, 200, 180, }, + Type: charts.ChartTypeBar, Label: charts.SeriesLabel{ - Show: true, + Show: charts.True(), Position: charts.PositionBottom, }, }, @@ -400,46 +284,20 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, Legend: charts.LegendOption{ Data: []string{ - "2011", - "2012", + "2011", "2012", }, }, YAxis: []charts.YAxisOption{ { Data: []string{ - "Brazil", - "Indonesia", - "USA", - "India", - "China", - "World", + "Brazil", "Indonesia", "USA", "India", "China", "World", }, }, }, - SeriesList: []charts.Series{ - { - Type: charts.ChartTypeHorizontalBar, - Data: charts.NewSeriesDataFromValues([]float64{ - 18203, - 23489, - 29034, - 104970, - 131744, - 630230, - }), - }, - { - Type: charts.ChartTypeHorizontalBar, - Data: charts.NewSeriesDataFromValues([]float64{ - 19325, - 23438, - 31000, - 121594, - 134141, - 681807, - }), - }, - }, + SeriesList: charts.NewSeriesListHorizontalBar([][]float64{ + {18203, 23489, 29034, 104970, 131744, 630230}, + {19325, 23438, 31000, 121594, 134141, 681807}, + }), }, // histogram+marker { @@ -461,43 +319,20 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, XAxis: charts.XAxisOption{ Data: []string{ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", }, }, Legend: charts.LegendOption{ Data: []string{ - "Rainfall", - "Evaporation", + "Rainfall", "Evaporation", }, }, 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, - }), + Data: []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, @@ -508,20 +343,9 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, { 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, - }), + Data: []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, @@ -542,25 +366,12 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, XAxis: charts.XAxisOption{ Data: []string{ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", }, }, Legend: charts.LegendOption{ Data: []string{ - "Evaporation", - "Precipitation", - "Temperature", + "Evaporation", "Precipitation", "Temperature", }, Padding: charts.Box{Left: 100}, }, @@ -574,59 +385,16 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { AxisColor: charts.Color{R: 250, G: 200, B: 88, 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, - }), - }, - { - 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, - }), - }, - { - 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, - }), - YAxisIndex: 1, + SeriesList: append(charts.NewSeriesListBar([][]float64{ + {2.0, 4.9, 7.0, 23.2, 25.6, 76.7, 135.6, 162.2, 32.6, 20.0, 6.4, 3.3}, + {2.6, 5.9, 9.0, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6.0, 2.3}, + }), charts.Series{ + Type: charts.ChartTypeLine, + Data: []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, }, - }, + YAxisIndex: 1, + }), }, // pie chart { @@ -642,26 +410,15 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, }, Legend: charts.LegendOption{ - Vertical: true, + Vertical: charts.True(), Data: []string{ - "Search Engine", - "Direct", - "Email", - "Union Ads", - "Video Ads", + "Search Engine", "Direct", "Email", "Union Ads", "Video Ads", }, Offset: charts.OffsetLeft, }, - SeriesList: charts.NewPieSeriesList([]float64{ - 1048, - 735, - 580, - 484, - 300, + SeriesList: charts.NewSeriesListPie([]float64{ + 1048, 735, 580, 484, 300, }, charts.PieSeriesOption{ - Label: charts.SeriesLabel{ - Show: true, - }, Radius: "35%", }), }, @@ -675,8 +432,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, Legend: charts.LegendOption{ Data: []string{ - "Allocated Budget", - "Actual Spending", + "Allocated Budget", "Actual Spending", }, Padding: charts.Box{Left: 100}, }, @@ -706,30 +462,10 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Max: 25000, }, }, - SeriesList: charts.SeriesList{ - { - Type: charts.ChartTypeRadar, - Data: charts.NewSeriesDataFromValues([]float64{ - 4200, - 3000, - 20000, - 35000, - 50000, - 18000, - }), - }, - { - Type: charts.ChartTypeRadar, - Data: charts.NewSeriesDataFromValues([]float64{ - 5000, - 14000, - 28000, - 26000, - 42000, - 21000, - }), - }, - }, + SeriesList: charts.NewSeriesListRadar([][]float64{ + {4200, 3000, 20000, 35000, 50000, 18000}, + {5000, 14000, 28000, 26000, 42000, 21000}, + }), }, // funnel chart { @@ -738,48 +474,34 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, Legend: charts.LegendOption{ Data: []string{ - "Show", - "Click", - "Visit", - "Inquiry", - "Order", + "Show", "Click", "Visit", "Inquiry", "Order", }, }, SeriesList: []charts.Series{ { Type: charts.ChartTypeFunnel, Name: "Show", - Data: charts.NewSeriesDataFromValues([]float64{ - 100, - }), + Data: []float64{100}, }, { Type: charts.ChartTypeFunnel, Name: "Click", - Data: charts.NewSeriesDataFromValues([]float64{ - 80, - }), + Data: []float64{80}, }, { Type: charts.ChartTypeFunnel, Name: "Visit", - Data: charts.NewSeriesDataFromValues([]float64{ - 60, - }), + Data: []float64{60}, }, { Type: charts.ChartTypeFunnel, Name: "Inquiry", - Data: charts.NewSeriesDataFromValues([]float64{ - 40, - }), + Data: []float64{40}, }, { Type: charts.ChartTypeFunnel, Name: "Order", - Data: charts.NewSeriesDataFromValues([]float64{ - 20, - }), + Data: []float64{20}, }, }, }, @@ -790,10 +512,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Top: "-90", }, Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", + "Milk Tea", "Matcha Latte", "Cheese Cocoa", "Walnut Brownie", }, }, Padding: charts.Box{ @@ -804,12 +523,7 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { }, XAxis: charts.XAxisOption{ Data: []string{ - "2012", - "2013", - "2014", - "2015", - "2016", - "2017", + "2012", "2013", "2014", "2015", "2016", "2017", }, }, YAxis: []charts.YAxisOption{ @@ -819,49 +533,21 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Max: charts.FloatPointer(90), }, }, - SeriesList: []charts.Series{ - charts.NewSeriesFromValues([]float64{ - 56.5, - 82.1, - 88.7, - 70.1, - 53.4, - 85.1, + SeriesList: append( + charts.NewSeriesListLine([][]float64{ + {56.5, 82.1, 88.7, 70.1, 53.4, 85.1}, + {51.1, 51.4, 55.1, 53.3, 73.8, 68.7}, }), - 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), - }, + charts.NewSeriesListBar([][]float64{ + {40.1, 62.2, 69.5, 36.4, 45.2, 32.5}, + {25.2, 37.1, 41.2, 18, 33.9, 49.1}, + })...), Children: []charts.ChartOption{ { Legend: charts.LegendOption{ Show: charts.False(), Data: []string{ - "Milk Tea", - "Matcha Latte", - "Cheese Cocoa", - "Walnut Brownie", + "Milk Tea", "Matcha Latte", "Cheese Cocoa", "Walnut Brownie", }, }, Box: charts.Box{ @@ -870,15 +556,9 @@ func indexHandler(w http.ResponseWriter, req *http.Request) { Right: 500, Bottom: 120, }, - SeriesList: charts.NewPieSeriesList([]float64{ - 435.9, - 354.3, - 285.9, - 204.5, + SeriesList: charts.NewSeriesListPie([]float64{ + 435.9, 354.3, 285.9, 204.5, }, charts.PieSeriesOption{ - Label: charts.SeriesLabel{ - Show: true, - }, Radius: "35%", }), }, diff --git a/font_test.go b/font_test.go index fb9ed60..75892e3 100644 --- a/font_test.go +++ b/font_test.go @@ -31,12 +31,11 @@ func TestGetPreferredFont(t *testing.T) { func TestCustomFontSizeRender(t *testing.T) { t.Parallel() - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 600, Height: 400, }, PainterThemeOption(GetTheme(ThemeLight))) - require.NoError(t, err) opt := makeBasicLineChartOption() opt.XAxis.FontStyle.FontSize = 4.0 @@ -50,9 +49,9 @@ func TestCustomFontSizeRender(t *testing.T) { opt.Title.FontStyle.FontSize = 4.0 opt.Legend.FontStyle.FontSize = 4.0 - _, err = NewLineChart(p, opt).Render() + err := p.LineChart(opt) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", data) + assertEqualSVG(t, "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", data) } diff --git a/funnel_chart.go b/funnel_chart.go index a4505db..6636219 100644 --- a/funnel_chart.go +++ b/funnel_chart.go @@ -4,8 +4,6 @@ import ( "errors" "github.com/golang/freetype/truetype" - - "github.com/go-analyze/charts/chartdraw" ) type funnelChart struct { @@ -13,25 +11,24 @@ type funnelChart struct { opt *FunnelChartOption } -// NewFunnelSeriesList returns a series list for funnel -func NewFunnelSeriesList(values []float64) SeriesList { - seriesList := make(SeriesList, len(values)) - for index, value := range values { - seriesList[index] = NewSeriesFromValues([]float64{ - value, - }, ChartTypeFunnel) - } - return seriesList -} - -// NewFunnelChart returns a funnel chart renderer -func NewFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart { +// newFunnelChart returns a funnel chart renderer +func newFunnelChart(p *Painter, opt FunnelChartOption) *funnelChart { return &funnelChart{ p: p, opt: &opt, } } +// NewFunnelChartOptionWithData returns an initialized FunnelChartOption with the SeriesList set for the provided data slice. +func NewFunnelChartOptionWithData(data []float64) FunnelChartOption { + return FunnelChartOption{ + SeriesList: NewSeriesListFunnel(data), + Padding: defaultPadding, + Theme: GetDefaultTheme(), + Font: GetDefaultFont(), + } +} + type FunnelChartOption struct { // Theme specifies the colors used for the chart. Theme ColorPalette @@ -50,16 +47,8 @@ type FunnelChartOption struct { func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) (Box, error) { opt := f.opt seriesPainter := result.seriesPainter - max := seriesList[0].Data[0].Value + max := seriesList[0].Data[0] min := float64(0) - for _, item := range seriesList { - if item.Max != nil { - max = *item.Max - } - if item.Min != nil { - min = *item.Min - } - } theme := opt.Theme gap := 2 height := seriesPainter.Height() @@ -77,7 +66,7 @@ func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) seriesNames := seriesList.Names() offset := max - min for index, item := range seriesList { - value := item.Data[0].Value + value := item.Data[0] // if the maximum and minimum are consistent it's 100% widthPercent := 100.0 if offset != 0 { @@ -90,7 +79,7 @@ func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) if max != 0 { percent = value / max } - textList[index] = NewFunnelLabelFormatter(seriesNames, item.Label.Formatter)(index, value, percent) + textList[index] = labelFormatFunnel(seriesNames, item.Label.Formatter, index, value, percent) } for index, w := range widthList { @@ -125,23 +114,19 @@ func (f *funnelChart) render(result *defaultRenderResult, seriesList SeriesList) Y: y, }, } - color := theme.GetSeriesColor(series.index) - seriesPainter.OverrideDrawingStyle(chartdraw.Style{ - FillColor: color, - }).FillArea(points) + seriesPainter.FillArea(points, theme.GetSeriesColor(series.index)) - // text text := textList[index] - seriesPainter.OverrideFontStyle(FontStyle{ + fontStyle := FontStyle{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, - }) - textBox := seriesPainter.MeasureText(text) + } + textBox := seriesPainter.MeasureText(text, 0, fontStyle) textX := width>>1 - textBox.Width()>>1 textY := y + h>>1 - seriesPainter.Text(text, textX, textY) + seriesPainter.Text(text, textX, textY, 0, fontStyle) y += h + gap } @@ -156,19 +141,19 @@ func (f *funnelChart) Render() (Box, error) { } renderResult, err := defaultRender(p, defaultRenderOption{ - Theme: opt.Theme, - Padding: opt.Padding, - SeriesList: opt.SeriesList, - XAxis: XAxisOption{ + theme: opt.Theme, + padding: opt.Padding, + seriesList: opt.SeriesList, + xAxis: &XAxisOption{ Show: False(), }, - YAxis: []YAxisOption{ + yAxis: []YAxisOption{ { Show: False(), }, }, - Title: opt.Title, - Legend: opt.Legend, + title: opt.Title, + legend: &f.opt.Legend, }) if err != nil { return BoxZero, err diff --git a/funnel_chart_test.go b/funnel_chart_test.go index be86313..9808cf5 100644 --- a/funnel_chart_test.go +++ b/funnel_chart_test.go @@ -4,12 +4,13 @@ import ( "strconv" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func makeBasicFunnelChartOption() FunnelChartOption { return FunnelChartOption{ - SeriesList: NewFunnelSeriesList([]float64{ + SeriesList: NewSeriesListFunnel([]float64{ 100, 80, 60, 40, 20, }), Legend: LegendOption{ @@ -23,6 +24,19 @@ func makeBasicFunnelChartOption() FunnelChartOption { } } +func TestNewFunnelChartOptionWithData(t *testing.T) { + t.Parallel() + + opt := NewFunnelChartOptionWithData([]float64{12, 24, 48}) + + assert.Len(t, opt.SeriesList, 3) + assert.Equal(t, ChartTypeFunnel, opt.SeriesList[0].Type) + assert.Equal(t, defaultPadding, opt.Padding) + + p := NewPainter(PainterOptions{}) + assert.NoError(t, p.FunnelChart(opt)) +} + func TestFunnelChart(t *testing.T) { t.Parallel() @@ -36,13 +50,13 @@ func TestFunnelChart(t *testing.T) { name: "default", defaultTheme: true, makeOptions: makeBasicFunnelChartOption, - result: "ShowClickVisitInquiryOrderFunnel(100%)(80%)(60%)(40%)(20%)", + result: "ShowClickVisitInquiryOrderFunnelShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", }, { name: "themed", defaultTheme: false, makeOptions: makeBasicFunnelChartOption, - result: "ShowClickVisitInquiryOrderFunnel(100%)(80%)(60%)(40%)(20%)", + result: "ShowClickVisitInquiryOrderFunnelShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", }, } @@ -54,21 +68,18 @@ func TestFunnelChart(t *testing.T) { } if tt.defaultTheme { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) validateFunnelChartRender(t, p, tt.makeOptions(), tt.result) }) } else { t.Run(strconv.Itoa(i)+"-"+tt.name+"-painter", func(t *testing.T) { - p, err := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) - require.NoError(t, err) + p := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) validateFunnelChartRender(t, p, tt.makeOptions(), tt.result) }) t.Run(strconv.Itoa(i)+"-"+tt.name+"-options", func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) opt := tt.makeOptions() opt.Theme = GetTheme(ThemeVividDark) @@ -81,7 +92,7 @@ func TestFunnelChart(t *testing.T) { func validateFunnelChartRender(t *testing.T, p *Painter, opt FunnelChartOption, expectedResult string) { t.Helper() - _, err := NewFunnelChart(p, opt).Render() + err := p.FunnelChart(opt) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) diff --git a/grid.go b/grid.go deleted file mode 100644 index 2a752af..0000000 --- a/grid.go +++ /dev/null @@ -1,74 +0,0 @@ -package charts - -import ( - "github.com/go-analyze/charts/chartdraw" -) - -type gridPainter struct { - p *Painter - opt *GridPainterOption -} - -type GridPainterOption struct { - // StrokeWidth is the grid line width. - StrokeWidth float64 - // StrokeColor is the grid line color. - StrokeColor Color - // ColumnSpans specifies the span for each column. - ColumnSpans []int - // Columns is the count of columns in the grid. - Columns int - // Rows are the count of rows in the grid. - Rows int - // IgnoreFirstRow can be set to true to ignore the first row. - IgnoreFirstRow bool - // IgnoreLastRow can be set to true to ignore the last row. - IgnoreLastRow bool - // IgnoreFirstColumn can be set to true to ignore the first colum. - IgnoreFirstColumn bool - // IgnoreLastColumn can be set to true to ignore the last columns. - IgnoreLastColumn bool -} - -// NewGridPainter returns new a grid renderer -func NewGridPainter(p *Painter, opt GridPainterOption) *gridPainter { - return &gridPainter{ - p: p, - opt: &opt, - } -} - -func (g *gridPainter) Render() (Box, error) { - opt := g.opt - ignoreColumnLines := make([]int, 0) - if opt.IgnoreFirstColumn { - ignoreColumnLines = append(ignoreColumnLines, 0) - } - if opt.IgnoreLastColumn { - ignoreColumnLines = append(ignoreColumnLines, opt.Columns) - } - ignoreRowLines := make([]int, 0) - if opt.IgnoreFirstRow { - ignoreRowLines = append(ignoreRowLines, 0) - } - if opt.IgnoreLastRow { - ignoreRowLines = append(ignoreRowLines, opt.Rows) - } - strokeWidth := opt.StrokeWidth - if strokeWidth <= 0 { - strokeWidth = 1 - } - - g.p.SetDrawingStyle(chartdraw.Style{ - StrokeWidth: strokeWidth, - StrokeColor: opt.StrokeColor, - }) - g.p.Grid(GridOption{ - Columns: opt.Columns, - ColumnSpans: opt.ColumnSpans, - Rows: opt.Rows, - IgnoreColumnLines: ignoreColumnLines, - IgnoreRowLines: ignoreRowLines, - }) - return g.p.box, nil -} diff --git a/grid_test.go b/grid_test.go deleted file mode 100644 index ffcf45a..0000000 --- a/grid_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package charts - -import ( - "strconv" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/go-analyze/charts/chartdraw/drawing" -) - -func TestGrid(t *testing.T) { - t.Parallel() - - tests := []struct { - render func(*Painter) ([]byte, error) - result string - }{ - { - render: func(p *Painter) ([]byte, error) { - _, err := NewGridPainter(p, GridPainterOption{ - StrokeColor: drawing.ColorBlack, - Columns: 6, - Rows: 6, - IgnoreFirstRow: true, - IgnoreLastRow: true, - IgnoreFirstColumn: true, - IgnoreLastColumn: true, - }).Render() - if err != nil { - return nil, err - } - return p.Bytes() - }, - result: "", - }, - { - render: func(p *Painter) ([]byte, error) { - _, err := NewGridPainter(p, GridPainterOption{ - StrokeColor: drawing.ColorBlack, - ColumnSpans: []int{2, 5, 3}, - Rows: 6, - }).Render() - if err != nil { - return nil, err - } - return p.Bytes() - }, - result: "", - }, - } - for i, tt := range tests { - t.Run(strconv.Itoa(i), func(t *testing.T) { - p, err := NewPainter(PainterOptions{ - OutputFormat: ChartOutputSVG, - Width: 600, - Height: 400, - }, PainterThemeOption(GetTheme(ThemeLight))) - require.NoError(t, err) - data, err := tt.render(p) - require.NoError(t, err) - assertEqualSVG(t, tt.result, data) - }) - } -} diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go index fc3e186..a868812 100644 --- a/horizontal_bar_chart.go +++ b/horizontal_bar_chart.go @@ -13,6 +13,19 @@ type horizontalBarChart struct { opt *HorizontalBarChartOption } +// NewHorizontalBarChartOptionWithData returns an initialized HorizontalBarChartOption with the SeriesList set for the provided data slice. +func NewHorizontalBarChartOptionWithData(data [][]float64) HorizontalBarChartOption { + sl := NewSeriesListHorizontalBar(data) + return HorizontalBarChartOption{ + SeriesList: sl, + Padding: defaultPadding, + Theme: GetDefaultTheme(), + Font: GetDefaultFont(), + YAxis: make([]YAxisOption, sl.getYAxisCount()), + ValueFormatter: defaultValueFormatter, + } +} + type HorizontalBarChartOption struct { // Theme specifies the colors used for the chart. Theme ColorPalette @@ -32,10 +45,12 @@ type HorizontalBarChartOption struct { Legend LegendOption // BarHeight specifies the height of each horizontal bar. BarHeight int + // ValueFormatter defines how float values should be rendered to strings, notably for numeric axis labels. + ValueFormatter ValueFormatter } -// NewHorizontalBarChart returns a horizontal bar chart renderer -func NewHorizontalBarChart(p *Painter, opt HorizontalBarChartOption) *horizontalBarChart { +// newHorizontalBarChart returns a horizontal bar chart renderer +func newHorizontalBarChart(p *Painter, opt HorizontalBarChartOption) *horizontalBarChart { return &horizontalBarChart{ p: p, opt: &opt, @@ -73,24 +88,19 @@ func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList Seri theme := opt.Theme min, max := seriesList.GetMinMax(0) - xRange := NewRange(p, seriesPainter.Width(), len(seriesList[0].Data), min, max, 1.0, 1.0) + xRange := newRange(p, getPreferredValueFormatter(opt.XAxis.ValueFormatter, opt.ValueFormatter), + seriesPainter.Width(), len(seriesList[0].Data), min, max, 1.0, 1.0) seriesNames := seriesList.Names() - var rendererList []Renderer + var rendererList []renderer for index := range seriesList { series := seriesList[index] seriesColor := theme.GetSeriesColor(series.index) divideValues := yRange.AutoDivide() - var labelPainter *SeriesLabelPainter - if series.Label.Show { - labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{ - P: seriesPainter, - SeriesNames: seriesNames, - Label: series.Label, - Theme: opt.Theme, - Font: opt.Font, - }) + var labelPainter *seriesLabelPainter + if flagIs(true, series.Label.Show) { + labelPainter = newSeriesLabelPainter(seriesPainter, seriesNames, series.Label, opt.Theme, opt.Font) rendererList = append(rendererList, labelPainter) } for j, item := range series.Data { @@ -105,18 +115,16 @@ func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList Seri y += index * (barHeight + barMargin) } - w := xRange.getHeight(item.Value) + w := xRange.getHeight(item) fillColor := seriesColor right := w - seriesPainter.OverrideDrawingStyle(chartdraw.Style{ - FillColor: fillColor, - }).Rect(chartdraw.Box{ + seriesPainter.filledRect(chartdraw.Box{ Top: y, Left: 0, Right: right, Bottom: y + barHeight, IsSet: true, - }) + }, fillColor, fillColor, 0.0) // if the label does not need to be displayed, return if labelPainter == nil { continue @@ -129,17 +137,17 @@ func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList Seri fontStyle.FontColor = defaultDarkFontColor } } - labelValue := LabelValue{ - Vertical: false, - Index: index, - Value: item.Value, - X: right, - Y: y + barHeight>>1, - Offset: series.Label.Offset, - FontStyle: fontStyle, + labelValue := labelValue{ + vertical: false, // label beside bar + index: index, + value: item, + x: right, + y: y + (barHeight >> 1), + offset: series.Label.Offset, + fontStyle: fontStyle, } if series.Label.Position == PositionLeft { - labelValue.X = 0 + labelValue.x = 0 } labelPainter.Add(labelValue) } @@ -158,14 +166,15 @@ func (h *horizontalBarChart) Render() (Box, error) { } renderResult, err := defaultRender(p, defaultRenderOption{ - Theme: opt.Theme, - Padding: opt.Padding, - SeriesList: opt.SeriesList, - XAxis: opt.XAxis, - YAxis: opt.YAxis, - Title: opt.Title, - Legend: opt.Legend, - axisReversed: true, + theme: opt.Theme, + padding: opt.Padding, + seriesList: opt.SeriesList, + xAxis: &h.opt.XAxis, + yAxis: opt.YAxis, + title: opt.Title, + legend: &h.opt.Legend, + valueFormatter: opt.ValueFormatter, + axisReversed: true, }) if err != nil { return BoxZero, err diff --git a/horizontal_bar_chart_test.go b/horizontal_bar_chart_test.go index d155458..4297347 100644 --- a/horizontal_bar_chart_test.go +++ b/horizontal_bar_chart_test.go @@ -4,6 +4,7 @@ import ( "strconv" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/go-analyze/charts/chartdraw/drawing" @@ -17,10 +18,10 @@ func makeBasicHorizontalBarChartOption() HorizontalBarChartOption { Bottom: 10, Left: 10, }, - SeriesList: NewSeriesListDataFromValues([][]float64{ + SeriesList: NewSeriesListHorizontalBar([][]float64{ {18203, 23489, 29034, 104970, 131744, 630230}, {19325, 23438, 31000, 121594, 134141, 681807}, - }, ChartTypeHorizontalBar), + }), Title: TitleOption{ Text: "World Population", }, @@ -39,6 +40,23 @@ func makeBasicHorizontalBarChartOption() HorizontalBarChartOption { } } +func TestNewHorizontalBarChartOptionWithData(t *testing.T) { + t.Parallel() + + opt := NewHorizontalBarChartOptionWithData([][]float64{ + {12, 24}, + {24, 48}, + }) + + assert.Len(t, opt.SeriesList, 2) + assert.Equal(t, ChartTypeHorizontalBar, opt.SeriesList[0].Type) + assert.Len(t, opt.YAxis, 1) + assert.Equal(t, defaultPadding, opt.Padding) + + p := NewPainter(PainterOptions{}) + assert.NoError(t, p.HorizontalBarChart(opt)) +} + func TestHorizontalBarChart(t *testing.T) { t.Parallel() @@ -52,13 +70,13 @@ func TestHorizontalBarChart(t *testing.T) { name: "default", defaultTheme: true, makeOptions: makeBasicHorizontalBarChartOption, - result: "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0144k288k432k576k720k", + result: "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0144k288k432k576k720k", }, { name: "themed", defaultTheme: false, makeOptions: makeBasicHorizontalBarChartOption, - result: "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0144k288k432k576k720k", + result: "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0144k288k432k576k720k", }, { name: "custom_fonts", @@ -74,7 +92,7 @@ func TestHorizontalBarChart(t *testing.T) { opt.Title.FontStyle = customFont return opt }, - result: "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0144k288k432k576k720k", + result: "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0144k288k432k576k720k", }, { name: "value_labels", @@ -83,11 +101,27 @@ func TestHorizontalBarChart(t *testing.T) { opt := makeBasicHorizontalBarChartOption() series := opt.SeriesList for i := range series { - series[i].Label.Show = true + series[i].Label.Show = True() + } + return opt + }, + result: "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0144k288k432k576k720k182032348929034104970131744630230193252343831000121594134141681807", + }, + { + name: "value_formatter", + defaultTheme: true, + makeOptions: func() HorizontalBarChartOption { + opt := makeBasicHorizontalBarChartOption() + series := opt.SeriesList + for i := range series { + series[i].Label.Show = True() + } + opt.ValueFormatter = func(f float64) string { + return "f" } return opt }, - result: "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0144k288k432k576k720k182032348929034104970131744630230193252343831000121594134141681807", + result: "20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazilffffff182032348929034104970131744630230193252343831000121594134141681807", }, } @@ -99,21 +133,18 @@ func TestHorizontalBarChart(t *testing.T) { } if tt.defaultTheme { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) validateHorizontalBarChartRender(t, p, tt.makeOptions(), tt.result) }) } else { t.Run(strconv.Itoa(i)+"-"+tt.name+"-painter", func(t *testing.T) { - p, err := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) - require.NoError(t, err) + p := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) validateHorizontalBarChartRender(t, p, tt.makeOptions(), tt.result) }) t.Run(strconv.Itoa(i)+"-"+tt.name+"-options", func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) opt := tt.makeOptions() opt.Theme = GetTheme(ThemeVividDark) @@ -126,7 +157,7 @@ func TestHorizontalBarChart(t *testing.T) { func validateHorizontalBarChartRender(t *testing.T, p *Painter, opt HorizontalBarChartOption, expectedResult string) { t.Helper() - _, err := NewHorizontalBarChart(p, opt).Render() + err := p.HorizontalBarChart(opt) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) diff --git a/legend.go b/legend.go index 7f1cc6a..50eb27c 100644 --- a/legend.go +++ b/legend.go @@ -2,8 +2,6 @@ package charts import ( "fmt" - - "github.com/go-analyze/charts/chartdraw" ) type legendPainter struct { @@ -29,8 +27,8 @@ type LegendOption struct { Offset OffsetStr // Align is the legend marker and text alignment, it can be 'left', 'right' or 'center', default is 'left'. Align string - // Vertical can be set to true to set the orientation to be vertical. - Vertical bool + // Vertical can be set to *true to set the legend orientation to be vertical. + Vertical *bool // Icon to show next to the labels. Can be 'rect' or 'dot'. Icon string // OverlayChart can be set to *true to render the legend over the chart. Ignored if Vertical is set to true (always overlapped). @@ -47,8 +45,8 @@ func (opt *LegendOption) IsEmpty() bool { return true } -// NewLegendPainter returns a legend renderer -func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter { +// newLegendPainter returns a legend renderer +func newLegendPainter(p *Painter, opt LegendOption) *legendPainter { return &legendPainter{ p: p, opt: &opt, @@ -72,9 +70,10 @@ func (l *legendPainter) Render() (Box, error) { if fontStyle.FontColor.IsZero() { fontStyle.FontColor = theme.GetTextColor() } + vertical := flagIs(true, opt.Vertical) offset := opt.Offset if offset.Left == "" { - if opt.Vertical { + if vertical { // in the vertical orientation it's more visually appealing to default to the right side or left side if opt.Align != "" { offset.Left = opt.Align @@ -90,7 +89,6 @@ func (l *legendPainter) Render() (Box, error) { padding.Top = 5 } p := l.p.Child(PainterPaddingOption(padding)) - p.SetFontStyle(fontStyle) // calculate the width and height of the display measureList := make([]Box, len(opt.Data)) @@ -103,14 +101,14 @@ func (l *legendPainter) Render() (Box, error) { maxTextWidth := 0 itemMaxHeight := 0 for index, text := range opt.Data { - b := p.MeasureText(text) + b := p.MeasureText(text, 0, fontStyle) if b.Width() > maxTextWidth { maxTextWidth = b.Width() } if b.Height() > itemMaxHeight { itemMaxHeight = b.Height() } - if opt.Vertical { + if flagIs(true, opt.Vertical) { height += b.Height() } else { width += b.Width() @@ -119,7 +117,7 @@ func (l *legendPainter) Render() (Box, error) { } // add padding - if opt.Vertical { + if vertical { width = maxTextWidth + textOffset + legendWidth height = builtInSpacing * len(opt.Data) } else { @@ -169,27 +167,27 @@ func (l *legendPainter) Render() (Box, error) { x0 := startX y0 := y - var drawIcon func(top, left int) int + var drawIcon func(top, left int, color Color) int if opt.Icon == IconRect { - drawIcon = func(top, left int) int { - p.Rect(Box{ + drawIcon = func(top, left int, color Color) int { + p.filledRect(Box{ Top: top - legendHeight + 8, Left: left, Right: left + legendWidth, Bottom: top + 1, IsSet: true, - }) + }, color, color, 0) return left + legendWidth } } else { - drawIcon = func(top, left int) int { - p.LegendLineDot(Box{ + drawIcon = func(top, left int, color Color) int { + p.legendLineDot(Box{ Top: top + 1, Left: left, Right: left + legendWidth, Bottom: top + legendHeight + 1, IsSet: true, - }) + }, color, 3, color) return left + legendWidth } } @@ -197,11 +195,7 @@ func (l *legendPainter) Render() (Box, error) { lastIndex := len(opt.Data) - 1 for index, text := range opt.Data { color := theme.GetSeriesColor(index) - p.SetDrawingStyle(chartdraw.Style{ - FillColor: color, - StrokeColor: color, - }) - if opt.Vertical { + if vertical { if opt.Align == AlignRight { // adjust x0 so that the text will start with a right alignment to the longest line x0 += maxTextWidth - measureList[index].Width() @@ -218,7 +212,7 @@ func (l *legendPainter) Render() (Box, error) { // recalculate width and center based off remaining width var remainingWidth int for i2 := index; i2 < len(opt.Data); i2++ { - b := p.MeasureText(opt.Data[i2]) + b := p.MeasureText(opt.Data[i2], 0, fontStyle) remainingWidth += b.Width() } remainingCount := len(opt.Data) - index @@ -237,17 +231,17 @@ func (l *legendPainter) Render() (Box, error) { } if opt.Align != AlignRight { - x0 = drawIcon(y0, x0) + x0 = drawIcon(y0, x0, color) x0 += textOffset } - p.Text(text, x0, y0) + p.Text(text, x0, y0, 0, fontStyle) x0 += measureList[index].Width() if opt.Align == AlignRight { x0 += textOffset - x0 = drawIcon(y0, x0) + x0 = drawIcon(y0, x0, color) } - if opt.Vertical { + if vertical { y0 += builtInSpacing x0 = startX } else { diff --git a/legend_test.go b/legend_test.go index bd5e24a..b0345b9 100644 --- a/legend_test.go +++ b/legend_test.go @@ -21,7 +21,7 @@ func TestNewLegend(t *testing.T) { { name: "basic", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two", "Three"}, }).Render() if err != nil { @@ -29,12 +29,12 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwoThree", + result: "OneTwoThree", }, { name: "position_left", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two", "Three"}, Offset: OffsetLeft, }).Render() @@ -43,14 +43,14 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwoThree", + result: "OneTwoThree", }, { name: "position_vertical_with_rect", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two", "Three"}, - Vertical: true, + Vertical: True(), Icon: IconRect, Offset: OffsetStr{ Left: "10%", @@ -61,12 +61,12 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwoThree", + result: "OneTwoThree", }, { name: "custom_padding_and_font", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two", "Three"}, FontStyle: FontStyle{ FontSize: 20.0, @@ -79,12 +79,12 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwoThree", + result: "OneTwoThree", }, { name: "hidden", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"A", "B", "C"}, Show: False(), }).Render() @@ -98,7 +98,7 @@ func TestNewLegend(t *testing.T) { { name: "bottom_position", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer"}, Offset: OffsetStr{ Top: PositionBottom, @@ -109,14 +109,14 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is Longer", + result: "OneTwo WordThree Word ItemFour Words Is Longer", }, { name: "vertical_right_position", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer"}, - Vertical: true, + Vertical: True(), Offset: OffsetRight, }).Render() if err != nil { @@ -124,14 +124,14 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is Longer", + result: "OneTwo WordThree Word ItemFour Words Is Longer", }, { name: "vertical_bottom_position", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer"}, - Vertical: true, + Vertical: True(), Offset: OffsetStr{ Top: PositionBottom, }, @@ -141,14 +141,14 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is Longer", + result: "OneTwo WordThree Word ItemFour Words Is Longer", }, { name: "vertical_right_bottom_position", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer"}, - Vertical: true, + Vertical: True(), Offset: OffsetStr{ Left: PositionRight, Top: PositionBottom, @@ -159,14 +159,14 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is Longer", + result: "OneTwo WordThree Word ItemFour Words Is Longer", }, { name: "vertical_right_position_custom_font_size", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer"}, - Vertical: true, + Vertical: True(), Offset: OffsetRight, FontStyle: FontStyle{ FontSize: 6.0, @@ -177,14 +177,14 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is Longer", + result: "OneTwo WordThree Word ItemFour Words Is Longer", }, { name: "vertical_right_position_with_padding", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer"}, - Vertical: true, + Vertical: True(), Offset: OffsetRight, Padding: Box{Top: 120, Left: 120, Right: 120, Bottom: 120}, }).Render() @@ -193,12 +193,12 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is Longer", + result: "OneTwo WordThree Word ItemFour Words Is Longer", }, { name: "left_position_overflow", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer", "Five Words Is Even Longer", "Six Words Is The Longest Tested"}, Offset: OffsetLeft, @@ -208,12 +208,12 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", + result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", }, { name: "center_position_overflow", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer", "Five Words Is Even Longer", "Six Words Is The Longest Tested"}, Offset: OffsetCenter, @@ -223,12 +223,12 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", + result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", }, { name: "center_position_center_align_overflow", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer", "Five Words Is Even Longer", "Six Words Is The Longest Tested"}, Offset: OffsetCenter, @@ -239,12 +239,12 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", + result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", }, { name: "50%_position_overflow", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer", "Five Words Is Even Longer", "Six Words Is The Longest Tested"}, Offset: OffsetStr{ @@ -256,15 +256,15 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", + result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", }, { name: "vertical_right_position_overflow", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer", "Five Words Is Even Longer", "Six Words Is The Longest Tested"}, - Vertical: true, + Vertical: True(), Offset: OffsetStr{ Left: "440", }, @@ -274,12 +274,12 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", + result: "OneTwo WordThree Word ItemFour Words Is LongerFive Words Is Even LongerSix Words Is The Longest Tested", }, { name: "right_alignment", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer"}, Align: AlignRight, }).Render() @@ -288,14 +288,14 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is Longer", + result: "OneTwo WordThree Word ItemFour Words Is Longer", }, { name: "vertical_right_alignment", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer"}, - Vertical: true, + Vertical: True(), Align: AlignRight, }).Render() if err != nil { @@ -303,14 +303,14 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is Longer", + result: "OneTwo WordThree Word ItemFour Words Is Longer", }, { name: "vertical_right_alignment_left_position", render: func(p *Painter) ([]byte, error) { - _, err := NewLegendPainter(p, LegendOption{ + _, err := newLegendPainter(p, LegendOption{ Data: []string{"One", "Two Word", "Three Word Item", "Four Words Is Longer"}, - Vertical: true, + Vertical: True(), Offset: OffsetLeft, Align: AlignRight, }).Render() @@ -319,17 +319,17 @@ func TestNewLegend(t *testing.T) { } return p.Bytes() }, - result: "OneTwo WordThree Word ItemFour Words Is Longer", + result: "OneTwo WordThree Word ItemFour Words Is Longer", }, } + for i, tt := range tests { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 600, Height: 400, }, PainterThemeOption(GetTheme(ThemeLight))) - require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) assertEqualSVG(t, tt.result, data) diff --git a/line_chart.go b/line_chart.go index 20f663c..6dbfe18 100644 --- a/line_chart.go +++ b/line_chart.go @@ -5,7 +5,6 @@ import ( "github.com/golang/freetype/truetype" - "github.com/go-analyze/charts/chartdraw" "github.com/go-analyze/charts/chartdraw/drawing" ) @@ -14,14 +13,30 @@ type lineChart struct { opt *LineChartOption } -// NewLineChart returns a line chart render -func NewLineChart(p *Painter, opt LineChartOption) *lineChart { +// newLineChart returns a line chart render +func newLineChart(p *Painter, opt LineChartOption) *lineChart { return &lineChart{ p: p, opt: &opt, } } +// NewLineChartOptionWithData returns an initialized LineChartOption with the SeriesList set for the provided data slice. +func NewLineChartOptionWithData(data [][]float64) LineChartOption { + sl := NewSeriesListLine(data) + return LineChartOption{ + SeriesList: sl, + Padding: defaultPadding, + Theme: GetDefaultTheme(), + Font: GetDefaultFont(), + XAxis: XAxisOption{ + Data: make([]string, len(data[0])), + }, + YAxis: make([]YAxisOption, sl.getYAxisCount()), + ValueFormatter: defaultValueFormatter, + } +} + type LineChartOption struct { // Theme specifies the colors used for the line chart. Theme ColorPalette @@ -41,9 +56,8 @@ type LineChartOption struct { Legend LegendOption // SymbolShow set this to *false or *true (using False() or True()) to force if the symbols should be shown or hidden. SymbolShow *bool - // TODO - rename to `LineStrokeWidth` to be more similar to ChartOptions? - // StrokeWidth is the width of the rendered line. - StrokeWidth float64 + // LineStrokeWidth is the width of the rendered line. + LineStrokeWidth float64 // StrokeSmoothingTension should be between 0 and 1. At 0 perfectly straight lines will be used with 1 providing // smoother lines. Because the tension smooths out the line, the line will no longer hit the data points exactly. // The more variable the points, and the higher the tension, the more the line will be moved from the points. @@ -52,6 +66,8 @@ type LineChartOption struct { FillArea bool // FillOpacity is the opacity (alpha) of the area fill. FillOpacity uint8 + // ValueFormatter defines how float values should be rendered to strings, notably for numeric axis labels. + ValueFormatter ValueFormatter // backgroundIsFilled is set to true if the background is filled. backgroundIsFilled bool } @@ -85,13 +101,13 @@ func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) ( } else { xValues = xDivideValues } - markPointPainter := NewMarkPointPainter(seriesPainter) - markLinePainter := NewMarkLinePainter(seriesPainter) - rendererList := []Renderer{ + markPointPainter := newMarkPointPainter(seriesPainter) + markLinePainter := newMarkLinePainter(seriesPainter) + rendererList := []renderer{ markPointPainter, markLinePainter, } - strokeWidth := opt.StrokeWidth + strokeWidth := opt.LineStrokeWidth if strokeWidth == 0 { strokeWidth = defaultStrokeWidth } @@ -113,27 +129,17 @@ func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) ( for index := range seriesList { series := seriesList[index] seriesColor := opt.Theme.GetSeriesColor(series.index) - drawingStyle := chartdraw.Style{ - StrokeColor: seriesColor, - StrokeWidth: strokeWidth, - } yRange := result.axisRanges[series.YAxisIndex] points := make([]Point, 0) - var labelPainter *SeriesLabelPainter - if series.Label.Show { - labelPainter = NewSeriesLabelPainter(SeriesLabelPainterParams{ - P: seriesPainter, - SeriesNames: seriesNames, - Label: series.Label, - Theme: opt.Theme, - Font: opt.Font, - }) + var labelPainter *seriesLabelPainter + if flagIs(true, series.Label.Show) { + labelPainter = newSeriesLabelPainter(seriesPainter, seriesNames, series.Label, opt.Theme, opt.Font) rendererList = append(rendererList, labelPainter) } for i, item := range series.Data { - h := yRange.getRestHeight(item.Value) - if item.Value == GetNullValue() { + h := yRange.getRestHeight(item) + if item == GetNullValue() { h = math.MaxInt32 } p := Point{ @@ -146,12 +152,12 @@ func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) ( if labelPainter == nil { continue } - labelPainter.Add(LabelValue{ - Index: index, - Value: item.Value, - X: p.X, - Y: p.Y, - FontStyle: series.Label.FontStyle, + labelPainter.Add(labelValue{ + index: index, + value: item, + x: p.X, + y: p.Y, + fontStyle: series.Label.FontStyle, }) } if opt.FillArea { @@ -169,34 +175,28 @@ func (l *lineChart) render(result *defaultRenderResult, seriesList SeriesList) ( X: areaPoints[0].X, Y: bottomY, }, areaPoints[0]) - seriesPainter.SetDrawingStyle(chartdraw.Style{ - FillColor: seriesColor.WithAlpha(opacity), - }) + fillColor := seriesColor.WithAlpha(opacity) if opt.StrokeSmoothingTension > 0 { - seriesPainter.smoothFillChartArea(areaPoints, opt.StrokeSmoothingTension) + seriesPainter.smoothFillChartArea(areaPoints, opt.StrokeSmoothingTension, fillColor) } else { - seriesPainter.FillArea(areaPoints) + seriesPainter.FillArea(areaPoints, fillColor) } } - seriesPainter.SetDrawingStyle(drawingStyle) // draw line if opt.StrokeSmoothingTension > 0 { - seriesPainter.smoothLineStroke(points, opt.StrokeSmoothingTension) + seriesPainter.SmoothLineStroke(points, opt.StrokeSmoothingTension, seriesColor, strokeWidth) } else { - seriesPainter.LineStroke(points) + seriesPainter.LineStroke(points, seriesColor, strokeWidth) } // draw dots - if opt.Theme.IsDark() { - drawingStyle.FillColor = drawingStyle.StrokeColor - } else { - drawingStyle.FillColor = drawing.ColorWhite - } - drawingStyle.StrokeWidth = 1 - seriesPainter.SetDrawingStyle(drawingStyle) if showSymbol { - seriesPainter.Dots(points) + dotFillColor := drawing.ColorWhite + if opt.Theme.IsDark() { + dotFillColor = seriesColor + } + seriesPainter.Dots(points, dotFillColor, seriesColor, 1, 2) } markPointPainter.Add(markPointRenderOption{ FillColor: seriesColor, @@ -234,13 +234,14 @@ func (l *lineChart) Render() (Box, error) { } renderResult, err := defaultRender(p, defaultRenderOption{ - Theme: opt.Theme, - Padding: opt.Padding, - SeriesList: opt.SeriesList, - XAxis: opt.XAxis, - YAxis: opt.YAxis, - Title: opt.Title, - Legend: opt.Legend, + theme: opt.Theme, + padding: opt.Padding, + seriesList: opt.SeriesList, + xAxis: &l.opt.XAxis, + yAxis: opt.YAxis, + title: opt.Title, + legend: &l.opt.Legend, + valueFormatter: opt.ValueFormatter, backgroundIsFilled: opt.backgroundIsFilled, }) if err != nil { diff --git a/line_chart_test.go b/line_chart_test.go index e38f5e3..5f1f193 100644 --- a/line_chart_test.go +++ b/line_chart_test.go @@ -4,6 +4,7 @@ import ( "strconv" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/go-analyze/charts/chartdraw/drawing" @@ -32,12 +33,13 @@ func makeFullLineChartOption() LineChartOption { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", }, }, + YAxis: make([]YAxisOption, 1), Legend: LegendOption{ Data: []string{ "Email", "Union Ads", "Video Ads", "Direct", "Search Engine", }, }, - SeriesList: NewSeriesListDataFromValues(values), + SeriesList: NewSeriesListLine(values), } } @@ -61,10 +63,11 @@ func makeBasicLineChartOption() LineChartOption { "A", "B", "C", "D", "E", "F", "G", }, }, + YAxis: make([]YAxisOption, 1), Legend: LegendOption{ Data: []string{"1", "2"}, }, - SeriesList: NewSeriesListDataFromValues(values), + SeriesList: NewSeriesListLine(values), } } @@ -86,11 +89,29 @@ func makeMinimalLineChartOption() LineChartOption { }, Show: False(), }, + YAxis: make([]YAxisOption, 1), SymbolShow: False(), - SeriesList: NewSeriesListDataFromValues(values), + SeriesList: NewSeriesListLine(values), } } +func TestNewLineChartOptionWithData(t *testing.T) { + t.Parallel() + + opt := NewLineChartOptionWithData([][]float64{ + {12, 24}, + {24, 48}, + }) + + assert.Len(t, opt.SeriesList, 2) + assert.Equal(t, ChartTypeLine, opt.SeriesList[0].Type) + assert.Len(t, opt.YAxis, 1) + assert.Equal(t, defaultPadding, opt.Padding) + + p := NewPainter(PainterOptions{}) + assert.NoError(t, p.LineChart(opt)) +} + func TestLineChart(t *testing.T) { t.Parallel() @@ -104,13 +125,13 @@ func TestLineChart(t *testing.T) { name: "basic_default", defaultTheme: true, makeOptions: makeFullLineChartOption, - result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", + result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", }, { name: "basic_themed", defaultTheme: false, makeOptions: makeFullLineChartOption, - result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", + result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", }, { name: "boundary_gap_disable", @@ -120,7 +141,7 @@ func TestLineChart(t *testing.T) { opt.XAxis.BoundaryGap = False() return opt }, - result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", + result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", }, { name: "boundary_gap_enable", @@ -130,7 +151,7 @@ func TestLineChart(t *testing.T) { opt.XAxis.BoundaryGap = True() return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", }, { name: "08Y_skip1", @@ -145,7 +166,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.4k1k6002000", + result: "1.4k1k6002000", }, { name: "09Y_skip1", @@ -160,7 +181,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.44k1.08k7203600", + result: "1.44k1.08k7203600", }, { name: "08Y_skip2", @@ -175,7 +196,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.4k8002000", + result: "1.4k8002000", }, { name: "09Y_skip2", @@ -190,7 +211,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.44k9003600", + result: "1.44k9003600", }, { name: "10Y_skip2", @@ -205,7 +226,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.44k9604800", + result: "1.44k9604800", }, { name: "08Y_skip3", @@ -220,7 +241,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.4k6000", + result: "1.4k6000", }, { name: "09Y_skip3", @@ -235,7 +256,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.44k7200", + result: "1.44k7200", }, { name: "10Y_skip3", @@ -250,7 +271,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.44k8001600", + result: "1.44k8001600", }, { name: "11Y_skip3", @@ -265,7 +286,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.4k8402800", + result: "1.4k8402800", }, { name: "no_yaxis_split_line", @@ -280,7 +301,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.5k1k5000", + result: "1.5k1k5000", }, { name: "yaxis_spine_line_show", @@ -295,7 +316,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "1.5k1k5000", + result: "1.5k1k5000", }, { name: "zero_data", @@ -306,10 +327,10 @@ func TestLineChart(t *testing.T) { {0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0}, } - opt.SeriesList = NewSeriesListDataFromValues(values) + opt.SeriesList = NewSeriesListLine(values) return opt }, - result: "210", + result: "210", }, { name: "tiny_range", @@ -320,10 +341,10 @@ func TestLineChart(t *testing.T) { {0.1, 0.2, 0.1, 0.2, 0.4, 0.2, 0.1}, {0.2, 0.4, 0.8, 0.4, 0.2, 0.1, 0.2}, } - opt.SeriesList = NewSeriesListDataFromValues(values) + opt.SeriesList = NewSeriesListLine(values) return opt }, - result: "21.61.20.80.40", + result: "21.61.20.80.40", }, { name: "hidden_legend_and_x-axis", @@ -334,7 +355,7 @@ func TestLineChart(t *testing.T) { opt.XAxis.Show = False() return opt }, - result: "Line1.44k1.28k1.12k9608006404803201600", + result: "Line1.44k1.28k1.12k9608006404803201600", }, { name: "custom_font", @@ -350,7 +371,7 @@ func TestLineChart(t *testing.T) { opt.Title.FontStyle = customFont return opt }, - result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", + result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", }, { name: "title_offset_center_legend_right", @@ -361,7 +382,7 @@ func TestLineChart(t *testing.T) { opt.Legend.Offset = OffsetRight return opt }, - result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", + result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", }, { name: "title_offset_right", @@ -371,7 +392,7 @@ func TestLineChart(t *testing.T) { opt.Title.Offset = OffsetRight return opt }, - result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", + result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", }, { name: "title_offset_bottom_center", @@ -384,7 +405,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", + result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", }, { name: "legend_offset_bottom", @@ -396,7 +417,7 @@ func TestLineChart(t *testing.T) { } return opt }, - result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", + result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", }, { name: "title_and_legend_offset_bottom", @@ -411,18 +432,18 @@ func TestLineChart(t *testing.T) { opt.Legend.Offset = bottomOffset return opt }, - result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", + result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", }, { name: "vertical_legend_offset_right", defaultTheme: true, makeOptions: func() LineChartOption { opt := makeBasicLineChartOption() - opt.Legend.Vertical = true + opt.Legend.Vertical = True() opt.Legend.Offset = OffsetRight return opt }, - result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", + result: "12Line1.44k1.28k1.12k9608006404803201600ABCDEFG", }, { name: "legend_overlap_chart", @@ -434,7 +455,7 @@ func TestLineChart(t *testing.T) { opt.Legend.OverlayChart = True() return opt }, - result: "121.44k1.28k1.12k9608006404803201600ABCDEFG", + result: "121.44k1.28k1.12k9608006404803201600ABCDEFG", }, { name: "curved_line", @@ -444,39 +465,39 @@ func TestLineChart(t *testing.T) { opt.StrokeSmoothingTension = 0.8 return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", }, { name: "line_gap", defaultTheme: true, makeOptions: func() LineChartOption { opt := makeMinimalLineChartOption() - opt.SeriesList[0].Data[3].Value = GetNullValue() + opt.SeriesList[0].Data[3] = GetNullValue() return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", }, { name: "line_gap_dot", defaultTheme: true, makeOptions: func() LineChartOption { opt := makeMinimalLineChartOption() - opt.SeriesList[0].Data[3].Value = GetNullValue() - opt.SeriesList[0].Data[5].Value = GetNullValue() + opt.SeriesList[0].Data[3] = GetNullValue() + opt.SeriesList[0].Data[5] = GetNullValue() return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", }, { name: "line_gap_fill_area", defaultTheme: true, makeOptions: func() LineChartOption { opt := makeMinimalLineChartOption() - opt.SeriesList[0].Data[3].Value = GetNullValue() + opt.SeriesList[0].Data[3] = GetNullValue() opt.FillArea = true return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", }, { name: "curved_line_gap", @@ -484,10 +505,10 @@ func TestLineChart(t *testing.T) { makeOptions: func() LineChartOption { opt := makeMinimalLineChartOption() opt.StrokeSmoothingTension = 0.8 - opt.SeriesList[0].Data[3].Value = GetNullValue() + opt.SeriesList[0].Data[3] = GetNullValue() return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", }, { name: "curved_line_gap_fill_area", @@ -495,11 +516,11 @@ func TestLineChart(t *testing.T) { makeOptions: func() LineChartOption { opt := makeMinimalLineChartOption() opt.StrokeSmoothingTension = 0.8 - opt.SeriesList[0].Data[3].Value = GetNullValue() + opt.SeriesList[0].Data[3] = GetNullValue() opt.FillArea = true return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", }, { name: "fill_area", @@ -510,7 +531,7 @@ func TestLineChart(t *testing.T) { opt.FillOpacity = 100 return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", }, { name: "fill_area_boundary_gap", @@ -522,7 +543,7 @@ func TestLineChart(t *testing.T) { opt.XAxis.BoundaryGap = True() return opt }, - result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", + result: "EmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", }, { name: "fill_area_curved_boundary_gap", @@ -534,7 +555,7 @@ func TestLineChart(t *testing.T) { opt.XAxis.BoundaryGap = True() return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", }, { name: "fill_area_curved_no_gap", @@ -546,7 +567,19 @@ func TestLineChart(t *testing.T) { opt.XAxis.BoundaryGap = False() return opt }, - result: "1.44k1.28k1.12k9608006404803201600", + result: "1.44k1.28k1.12k9608006404803201600", + }, + { + name: "value_formatter", + defaultTheme: true, + makeOptions: func() LineChartOption { + opt := makeMinimalLineChartOption() + opt.YAxis[0].ValueFormatter = func(f float64) string { + return "f" + } + return opt + }, + result: "ffffffffff", }, } @@ -558,21 +591,18 @@ func TestLineChart(t *testing.T) { } if tt.defaultTheme { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) validateLineChartRender(t, p, tt.makeOptions(), tt.result) }) } else { t.Run(strconv.Itoa(i)+"-"+tt.name+"-theme_painter", func(t *testing.T) { - p, err := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) - require.NoError(t, err) + p := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) validateLineChartRender(t, p, tt.makeOptions(), tt.result) }) t.Run(strconv.Itoa(i)+"-"+tt.name+"-theme_opt", func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) opt := tt.makeOptions() opt.Theme = GetTheme(ThemeVividDark) @@ -585,7 +615,7 @@ func TestLineChart(t *testing.T) { func validateLineChartRender(t *testing.T, p *Painter, opt LineChartOption, expectedResult string) { t.Helper() - _, err := NewLineChart(p, opt).Render() + err := p.LineChart(opt) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) diff --git a/mark_line.go b/mark_line.go index 2b42189..fb0d226 100644 --- a/mark_line.go +++ b/mark_line.go @@ -2,8 +2,6 @@ package charts import ( "github.com/golang/freetype/truetype" - - "github.com/go-analyze/charts/chartdraw" ) // NewMarkLine returns a series mark line @@ -28,8 +26,8 @@ func (m *markLinePainter) Add(opt markLineRenderOption) { m.options = append(m.options, opt) } -// NewMarkLinePainter returns a mark line renderer -func NewMarkLinePainter(p *Painter) *markLinePainter { +// newMarkLinePainter returns a mark line renderer +func newMarkLinePainter(p *Painter) *markLinePainter { return &markLinePainter{ p: p, options: make([]markLineRenderOption, 0), @@ -52,41 +50,28 @@ func (m *markLinePainter) Render() (Box, error) { if len(s.MarkLine.Data) == 0 { continue } - font := opt.Font - if font == nil { - font = GetDefaultFont() - } summary := s.Summary() + fontStyle := FontStyle{ + Font: getPreferredFont(opt.Font), + FontColor: opt.FontColor, + FontSize: labelFontSize, + } for _, markLine := range s.MarkLine.Data { - // since the mark line will modify the style, it must be reset every time - painter.OverrideDrawingStyle(chartdraw.Style{ - FillColor: opt.FillColor, - StrokeColor: opt.StrokeColor, - StrokeWidth: 1, - StrokeDashArray: []float64{ - 4, - 2, - }, - }).OverrideFontStyle(FontStyle{ - Font: font, - FontColor: opt.FontColor, - FontSize: labelFontSize, - }) value := float64(0) switch markLine.Type { case SeriesMarkDataTypeMax: - value = summary.MaxValue + value = summary.Max case SeriesMarkDataTypeMin: - value = summary.MinValue + value = summary.Min default: - value = summary.AverageValue + value = summary.Average } y := opt.Range.getRestHeight(value) width := painter.Width() text := defaultValueFormatter(value) - textBox := painter.MeasureText(text) - painter.MarkLine(0, y, width-2) - painter.Text(text, width, y+textBox.Height()>>1-2) + textBox := painter.MeasureText(text, 0, fontStyle) + painter.MarkLine(0, y, width-2, opt.FillColor, opt.StrokeColor, 1, []float64{4, 2}) + painter.Text(text, width, y+textBox.Height()>>1-2, 0, fontStyle) } } return BoxZero, nil diff --git a/mark_line_test.go b/mark_line_test.go index 75fb9f3..8ca6efc 100644 --- a/mark_line_test.go +++ b/mark_line_test.go @@ -18,8 +18,10 @@ func TestMarkLine(t *testing.T) { }{ { render: func(p *Painter) ([]byte, error) { - markLine := NewMarkLinePainter(p) - series := NewSeriesFromValues([]float64{1, 2, 3}) + markLine := newMarkLinePainter(p) + series := Series{ + Data: []float64{1, 2, 3}, + } series.MarkLine = NewMarkLine( SeriesMarkDataTypeMax, SeriesMarkDataTypeAverage, @@ -30,24 +32,25 @@ func TestMarkLine(t *testing.T) { FontColor: drawing.ColorBlack, StrokeColor: drawing.ColorBlack, Series: series, - Range: NewRange(p, p.Height(), 6, 0.0, 5.0, 0.0, 0.0), + Range: newRange(p, nil, + p.Height(), 6, 0.0, 5.0, 0.0, 0.0), }) if _, err := markLine.Render(); err != nil { return nil, err } return p.Bytes() }, - result: "321", + result: "321", }, } + for i, tt := range tests { t.Run(strconv.Itoa(i), func(t *testing.T) { - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 600, Height: 400, }, PainterThemeOption(GetTheme(ThemeLight))) - require.NoError(t, err) data, err := tt.render(p.Child(PainterPaddingOption(Box{ Left: 20, Top: 20, diff --git a/mark_point.go b/mark_point.go index 27e3f84..a6ee134 100644 --- a/mark_point.go +++ b/mark_point.go @@ -35,8 +35,8 @@ type markPointRenderOption struct { Points []Point } -// NewMarkPointPainter returns a mark point renderer -func NewMarkPointPainter(p *Painter) *markPointPainter { +// newMarkPointPainter returns a mark point renderer +func newMarkPointPainter(p *Painter) *markPointPainter { return &markPointPainter{ p: p, options: make([]markPointRenderOption, 0), @@ -67,29 +67,24 @@ func (m *markPointPainter) Render() (Box, error) { } else { textStyle.FontColor = defaultDarkFontColor } - painter.OverrideDrawingStyle(chartdraw.Style{ - FillColor: opt.FillColor, - }).OverrideFontStyle(textStyle.FontStyle) for _, markPointData := range opt.Series.MarkPoint.Data { textStyle.FontSize = labelFontSize - painter.OverrideFontStyle(textStyle.FontStyle) p := points[summary.MinIndex] - value := summary.MinValue + value := summary.Min switch markPointData.Type { case SeriesMarkDataTypeMax: p = points[summary.MaxIndex] - value = summary.MaxValue + value = summary.Max } - painter.Pin(p.X, p.Y-symbolSize>>1, symbolSize) + painter.Pin(p.X, p.Y-symbolSize>>1, symbolSize, opt.FillColor, opt.FillColor, 0.0) text := defaultValueFormatter(value) - textBox := painter.MeasureText(text) + textBox := painter.MeasureText(text, 0, textStyle.FontStyle) if textBox.Width() > symbolSize { textStyle.FontSize = smallLabelFontSize - painter.OverrideFontStyle(textStyle.FontStyle) - textBox = painter.MeasureText(text) + textBox = painter.MeasureText(text, 0, textStyle.FontStyle) } - painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2) + painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2, 0, textStyle.FontStyle) } } return BoxZero, nil diff --git a/mark_point_test.go b/mark_point_test.go index 685466b..f8a74ac 100644 --- a/mark_point_test.go +++ b/mark_point_test.go @@ -18,9 +18,11 @@ func TestMarkPoint(t *testing.T) { }{ { render: func(p *Painter) ([]byte, error) { - series := NewSeriesFromValues([]float64{1, 2, 3}) + series := Series{ + Data: []float64{1, 2, 3}, + } series.MarkPoint = NewMarkPoint(SeriesMarkDataTypeMax) - markPoint := NewMarkPointPainter(p) + markPoint := newMarkPointPainter(p) markPoint.Add(markPointRenderOption{ FillColor: drawing.ColorBlack, Series: series, @@ -35,18 +37,17 @@ func TestMarkPoint(t *testing.T) { } return p.Bytes() }, - result: "3", + result: "3", }, } for i, tt := range tests { t.Run(strconv.Itoa(i), func(t *testing.T) { - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 600, Height: 400, }, PainterThemeOption(GetTheme(ThemeLight))) - require.NoError(t, err) data, err := tt.render(p.Child(PainterPaddingOption(Box{ Left: 20, Top: 20, diff --git a/painter.go b/painter.go index e1c11ee..d177908 100644 --- a/painter.go +++ b/painter.go @@ -16,15 +16,22 @@ var defaultValueFormatter = func(val float64) string { return FormatValueHumanizeShort(val, 2, false) } +func getPreferredValueFormatter(t ...ValueFormatter) ValueFormatter { + for _, vf := range t { + if vf != nil { + return vf + } + } + return defaultValueFormatter +} + // Painter is the primary struct for drawing charts/graphs. type Painter struct { - render chartdraw.Renderer - box Box - style chartdraw.Style - theme ColorPalette - font *truetype.Font - outputFormat string - valueFormatter ValueFormatter + render chartdraw.Renderer + outputFormat string + box Box + theme ColorPalette + font *truetype.Font } // PainterOptions contains parameters for creating a new Painter. @@ -35,51 +42,42 @@ type PainterOptions struct { Width int // Height is the height of the draw painter. Height int - // Font is the font used for rendering text. + // Font is the default font used for rendering text. Font *truetype.Font -} - -type PainterOption func(*Painter) - -type TicksOption struct { - // the first tick index - First int - Length int - Vertical bool - LabelCount int - TickCount int - TickSpaces int -} - -type MultiTextOption struct { - TextList []string - Vertical bool - CenterLabels bool - Align string - TextRotation float64 - Offset OffsetInt - // The first text index - First int - LabelCount int - TickCount int - LabelSkipCount int -} - -type GridOption struct { - // Columns is the count of columns in the grid. - Columns int - // Rows are the count of rows in the grid. - Rows int - // ColumnSpans specifies the span for each column. - ColumnSpans []int - // IgnoreColumnLines specifies index for columns to not display. - IgnoreColumnLines []int - // IgnoreRowLines specifies index for rows to not display. - IgnoreRowLines []int -} - -// PainterPaddingOption sets the padding of draw painter -func PainterPaddingOption(padding Box) PainterOption { + // Theme is the default theme to be used if the chart does not specify a theme. + Theme ColorPalette +} + +// PainterOptionFunc defines a function that can modify a Painter after creation. +type PainterOptionFunc func(*Painter) + +type ticksOption struct { + firstIndex int + length int + vertical bool + labelCount int + tickCount int + tickSpaces int + strokeWidth float64 + strokeColor Color +} + +type multiTextOption struct { + textList []string + fontStyle FontStyle + vertical bool + centerLabels bool + align string + textRotation float64 + offset OffsetInt + firstIndex int + labelCount int + tickCount int + labelSkipCount int +} + +// PainterPaddingOption sets the padding of the draw painter. +func PainterPaddingOption(padding Box) PainterOptionFunc { return func(p *Painter) { p.box.Left += padding.Left p.box.Top += padding.Top @@ -89,7 +87,7 @@ func PainterPaddingOption(padding Box) PainterOption { } // PainterBoxOption sets a specific box for the Painter to draw within. -func PainterBoxOption(box Box) PainterOption { +func PainterBoxOption(box Box) PainterOptionFunc { return func(p *Painter) { if box.IsZero() { return @@ -98,181 +96,69 @@ func PainterBoxOption(box Box) PainterOption { } } -// PainterFontOption sets the font of draw painter -func PainterFontOption(font *truetype.Font) PainterOption { - return func(p *Painter) { - if font == nil { - return - } - p.font = font - } -} - -// PainterStyleOption sets the style of draw painter -func PainterStyleOption(style chartdraw.Style) PainterOption { - return func(p *Painter) { - p.SetStyle(style) - } -} - // PainterThemeOption sets a color palette theme default for the Painter. // This theme is used if the specific chart options don't have a theme set. -func PainterThemeOption(theme ColorPalette) PainterOption { +func PainterThemeOption(theme ColorPalette) PainterOptionFunc { return func(p *Painter) { - if theme == nil { - return - } - p.theme = theme + p.theme = getPreferredTheme(theme) } } -// PainterWidthHeightOption set width or height of draw painter -func PainterWidthHeightOption(width, height int) PainterOption { +// PainterFontOption sets the default font face for the Painter. +// This font is used if the FontStyle specified in chart configs does not specify another face. +func PainterFontOption(font *truetype.Font) PainterOptionFunc { return func(p *Painter) { - if width > 0 { - p.box.Right = p.box.Left + width - } - if height > 0 { - p.box.Bottom = p.box.Top + height - } + p.font = getPreferredFont(font) } } -// TODO - try to remove the error return -// NewPainter creates a painter -func NewPainter(opts PainterOptions, opt ...PainterOption) (*Painter, error) { +// NewPainter creates a painter which can be used to render charts to (using for example newLineChart). +func NewPainter(opts PainterOptions, opt ...PainterOptionFunc) *Painter { if opts.Width <= 0 { opts.Width = defaultChartWidth } if opts.Height <= 0 { opts.Height = defaultChartHeight } - if opts.Font == nil { - opts.Font = GetDefaultFont() - } fn := chartdraw.PNG if opts.OutputFormat == ChartOutputSVG { fn = chartdraw.SVG } - width := opts.Width - height := opts.Height - r, err := fn(width, height) - if err != nil { - return nil, err - } - r.SetFont(opts.Font) p := &Painter{ - render: r, + outputFormat: opts.OutputFormat, + render: fn(opts.Width, opts.Height), box: Box{ Right: opts.Width, Bottom: opts.Height, IsSet: true, }, - font: opts.Font, - outputFormat: opts.OutputFormat, + font: opts.Font, + theme: opts.Theme, } p.setOptions(opt...) - if p.theme == nil { - p.theme = GetDefaultTheme() - } - return p, nil + return p } -func (p *Painter) setOptions(opts ...PainterOption) { + +func (p *Painter) setOptions(opts ...PainterOptionFunc) { for _, fn := range opts { fn(p) } } -func (p *Painter) Child(opt ...PainterOption) *Painter { +// Child returns a painter with the passed-in options applied to it. Useful when you want to render relative to only a portion of the canvas via PainterBoxOption. +func (p *Painter) Child(opt ...PainterOptionFunc) *Painter { child := &Painter{ - render: p.render, - box: p.box.Clone(), - style: p.style, - theme: p.theme, - font: p.font, - outputFormat: p.outputFormat, - valueFormatter: p.valueFormatter, + render: p.render, + box: p.box.Clone(), + theme: p.theme, + font: p.font, } child.setOptions(opt...) return child } -func (p *Painter) SetStyle(style chartdraw.Style) { - if style.Font == nil { - style.Font = p.font - } - p.style = style - style.WriteToRenderer(p.render) -} - -func overrideStyle(defaultStyle chartdraw.Style, style chartdraw.Style) chartdraw.Style { - if style.StrokeWidth == 0 { - style.StrokeWidth = defaultStyle.StrokeWidth - } - if style.StrokeColor.IsZero() { - style.StrokeColor = defaultStyle.StrokeColor - } - if style.StrokeDashArray == nil { - style.StrokeDashArray = defaultStyle.StrokeDashArray - } - if style.DotColor.IsZero() { - style.DotColor = defaultStyle.DotColor - } - if style.DotWidth == 0 { - style.DotWidth = defaultStyle.DotWidth - } - if style.FillColor.IsZero() { - style.FillColor = defaultStyle.FillColor - } - if style.FontSize == 0 { - style.FontSize = defaultStyle.FontSize - } - if style.FontColor.IsZero() { - style.FontColor = defaultStyle.FontColor - } - if style.Font == nil { - style.Font = defaultStyle.Font - } - return style -} - -func (p *Painter) OverrideDrawingStyle(style chartdraw.Style) *Painter { - s := overrideStyle(p.style, style) - p.SetDrawingStyle(s) - return p -} - -func (p *Painter) SetDrawingStyle(style chartdraw.Style) *Painter { - style.WriteDrawingOptionsToRenderer(p.render) - return p -} - -func (p *Painter) SetFontStyle(style chartdraw.FontStyle) *Painter { - if style.Font == nil { - style.Font = p.font - } - if style.FontColor.IsZero() { - style.FontColor = p.style.FontColor - } - if style.FontSize == 0 { - style.FontSize = p.style.FontSize - } - style.WriteTextOptionsToRenderer(p.render) - return p -} -func (p *Painter) OverrideFontStyle(style chartdraw.FontStyle) *Painter { - s := overrideStyle(p.style, chartdraw.Style{FontStyle: style}) - p.SetFontStyle(s.FontStyle) - return p -} - -func (p *Painter) ResetStyle() *Painter { - p.style.WriteToRenderer(p.render) - return p -} - -// Bytes returns the data of draw canvas +// Bytes returns the final rendered data as a byte slice. func (p *Painter) Bytes() ([]byte, error) { buffer := bytes.Buffer{} if err := p.render.Save(&buffer); err != nil { @@ -281,137 +167,53 @@ func (p *Painter) Bytes() ([]byte, error) { return buffer.Bytes(), nil } -// MoveTo moves the cursor to a given point -func (p *Painter) MoveTo(x, y int) *Painter { +// moveTo sets the current path cursor to a given point. +func (p *Painter) moveTo(x, y int) { p.render.MoveTo(x+p.box.Left, y+p.box.Top) - return p } -func (p *Painter) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) *Painter { +// arcTo renders an arc from the current cursor. +func (p *Painter) arcTo(cx, cy int, rx, ry, startAngle, delta float64) { p.render.ArcTo(cx+p.box.Left, cy+p.box.Top, rx, ry, startAngle, delta) - return p -} - -func (p *Painter) LineTo(x, y int) *Painter { - p.render.LineTo(x+p.box.Left, y+p.box.Top) - return p } -func (p *Painter) QuadCurveTo(cx, cy, x, y int) *Painter { +// quadCurveTo draws a quadratic curve from the current cursor using a control point (cx, cy) and ending at (x, y). +func (p *Painter) quadCurveTo(cx, cy, x, y int) { p.render.QuadCurveTo(cx+p.box.Left, cy+p.box.Top, x+p.box.Left, y+p.box.Top) - return p -} - -func (p *Painter) Pin(x, y, width int) *Painter { - r := float64(width) / 2 - y -= width / 4 - angle := chartdraw.DegreesToRadians(15) - box := p.box - - startAngle := math.Pi/2 + angle - delta := 2*math.Pi - 2*angle - p.ArcTo(x, y, r, r, startAngle, delta) - p.LineTo(x, y) - p.Close() - p.FillStroke() - - startX := x - int(r) - startY := y - endX := x + int(r) - endY := y - p.MoveTo(startX, startY) - - left := box.Left - top := box.Top - cx := x - cy := y + int(r*2.5) - p.render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top) - p.Close() - p.Fill() - return p } -func (p *Painter) arrow(x, y, width, height int, direction string) *Painter { - halfWidth := width >> 1 - halfHeight := height >> 1 - if direction == PositionTop || direction == PositionBottom { - x0 := x - halfWidth - x1 := x0 + width - dy := -height / 3 - y0 := y - y1 := y0 - height - if direction == PositionBottom { - y0 = y - height - y1 = y - dy = 2 * dy - } - p.MoveTo(x0, y0) - p.LineTo(x0+halfWidth, y1) - p.LineTo(x1, y0) - p.LineTo(x0+halfWidth, y+dy) - p.LineTo(x0, y0) - } else { - x0 := x + width - x1 := x0 - width - y0 := y - halfHeight - dx := -width / 3 - if direction == PositionRight { - x0 = x - width - dx = -dx - x1 = x0 + width - } - p.MoveTo(x0, y0) - p.LineTo(x1, y0+halfHeight) - p.LineTo(x0, y0+height) - p.LineTo(x0+dx, y0+halfHeight) - p.LineTo(x0, y0) - } - p.FillStroke() - return p -} - -func (p *Painter) ArrowLeft(x, y, width, height int) *Painter { - p.arrow(x, y, width, height, PositionLeft) - return p -} - -func (p *Painter) ArrowRight(x, y, width, height int) *Painter { - p.arrow(x, y, width, height, PositionRight) - return p -} - -func (p *Painter) ArrowTop(x, y, width, height int) *Painter { - p.arrow(x, y, width, height, PositionTop) - return p -} -func (p *Painter) ArrowBottom(x, y, width, height int) *Painter { - p.arrow(x, y, width, height, PositionBottom) - return p +// lineTo draws a line from the current path cursor to the given point. +func (p *Painter) lineTo(x, y int) { + p.render.LineTo(x+p.box.Left, y+p.box.Top) } -func (p *Painter) Circle(radius float64, x, y int) *Painter { - p.render.Circle(radius, x+p.box.Left, y+p.box.Top) - return p +// close finalizes a shape as drawn by the current path. +func (p *Painter) close() { + p.render.Close() } -func (p *Painter) Stroke() *Painter { +// stroke performs a stroke using the provided color and width, then resets style. +func (p *Painter) stroke(strokeColor Color, strokeWidth float64) { + defer p.render.ResetStyle() + p.render.SetStrokeColor(strokeColor) + p.render.SetStrokeWidth(strokeWidth) p.render.Stroke() - return p } -func (p *Painter) Close() *Painter { - p.render.Close() - return p +// fill performs a fill with the given color, then resets style. +func (p *Painter) fill(fillColor Color) { + defer p.render.ResetStyle() + p.render.SetFillColor(fillColor) + p.render.Fill() } -func (p *Painter) FillStroke() *Painter { +// fillStroke performs a fill+stroke with the given colors and stroke width, then resets style. +func (p *Painter) fillStroke(fillColor, strokeColor Color, strokeWidth float64) { + defer p.render.ResetStyle() + p.render.SetFillColor(fillColor) + p.render.SetStrokeColor(strokeColor) + p.render.SetStrokeWidth(strokeWidth) p.render.FillStroke() - return p -} - -func (p *Painter) Fill() *Painter { - p.render.Fill() - return p } // Width returns the drawable width of the painter's box. @@ -425,15 +227,33 @@ func (p *Painter) Height() int { } // MeasureText will provide the rendered size of the text for the provided font style. -func (p *Painter) MeasureText(text string) Box { - return p.render.MeasureText(text) +func (p *Painter) MeasureText(text string, textRotation float64, fontStyle FontStyle) Box { + if fontStyle.Font == nil { + fontStyle.Font = getPreferredFont(p.font) + } + if fontStyle.Font == nil || fontStyle.FontSize == 0 || fontStyle.FontColor.IsTransparent() { + return BoxZero + } + if textRotation != 0 { + defer p.render.ClearTextRotation() + p.render.SetTextRotation(textRotation) + } + defer p.render.ResetStyle() + p.render.SetFont(fontStyle.Font) + p.render.SetFontSize(fontStyle.FontSize) + p.render.SetFontColor(fontStyle.FontColor) + box := p.render.MeasureText(text) + return box } -func (p *Painter) MeasureTextMaxWidthHeight(textList []string) (int, int) { +func (p *Painter) measureTextMaxWidthHeight(textList []string, textRotation float64, fontStyle FontStyle) (int, int) { + if fontStyle.Font == nil { + fontStyle.Font = getPreferredFont(p.font) + } maxWidth := 0 maxHeight := 0 for _, text := range textList { - box := p.MeasureText(text) + box := p.MeasureText(text, textRotation, fontStyle) if maxWidth < box.Width() { maxWidth = box.Width() } @@ -444,25 +264,36 @@ func (p *Painter) MeasureTextMaxWidthHeight(textList []string) (int, int) { return maxWidth, maxHeight } +// Circle draws a circle at the given coords with a given radius. +func (p *Painter) Circle(radius float64, x, y int, fillColor, strokeColor Color, strokeWidth float64) { + // This function has a slight behavior difference between png and svg. + // We need to set the style attributes before the `Circle` call for SVG. + defer p.render.ResetStyle() + p.render.SetFillColor(fillColor) + p.render.SetStrokeColor(strokeColor) + p.render.SetStrokeWidth(strokeWidth) + p.render.Circle(radius, x+p.box.Left, y+p.box.Top) + p.render.FillStroke() +} + // LineStroke draws a line in the graph from point to point with the specified stroke color/width. // Points with values of math.MaxInt32 will be skipped, resulting in a gap. // Single or isolated points will result in just a dot being drawn at the point. -func (p *Painter) LineStroke(points []Point) *Painter { +func (p *Painter) LineStroke(points []Point, strokeColor Color, strokeWidth float64) { var valid []Point for _, pt := range points { if pt.Y == math.MaxInt32 { // If we encounter a break, draw the accumulated segment p.drawStraightPath(valid, true) - p.Stroke() + p.stroke(strokeColor, strokeWidth) valid = valid[:0] // reset continue } valid = append(valid, pt) } - - // Draw the last segment if there is one + // Draw the last segment p.drawStraightPath(valid, true) - return p.Stroke() + p.stroke(strokeColor, strokeWidth) } // drawStraightPath draws a simple (non-curved) path for the given points. @@ -473,22 +304,24 @@ func (p *Painter) drawStraightPath(points []Point, dotForSinglePoint bool) { return } else if pointCount == 1 { if dotForSinglePoint { - p.Dots(points) + p.render.Circle(2.0, points[0].X+p.box.Left, points[0].Y+p.box.Top) } + return } - p.MoveTo(points[0].X, points[0].Y) + p.moveTo(points[0].X, points[0].Y) for i := 1; i < pointCount; i++ { - p.LineTo(points[i].X, points[i].Y) + p.lineTo(points[i].X, points[i].Y) } } -// smoothLineStroke draws a smooth curve through the given points using Quadratic Bézier segments and a +// SmoothLineStroke draws a smooth curve through the given points using Quadratic Bézier segments and a // `tension` parameter in [0..1] with 0 providing straight lines between midpoints and 1 providing a smoother line. // Because the tension smooths out the line, the line will no longer hit the provided points exactly. The more variable -// the points, and the higher the tension, the more the line will be -func (p *Painter) smoothLineStroke(points []Point, tension float64) *Painter { +// the points, and the higher the tension, the more the line will be. +func (p *Painter) SmoothLineStroke(points []Point, tension float64, strokeColor Color, strokeWidth float64) { if tension <= 0 { - return p.LineStroke(points) + p.LineStroke(points, strokeColor, strokeWidth) + return } else if tension > 1 { tension = 1 } @@ -498,28 +331,26 @@ func (p *Painter) smoothLineStroke(points []Point, tension float64) *Painter { if pt.Y == math.MaxInt32 { // When a line break is found, draw the curve for the accumulated valid points if any p.drawSmoothCurve(valid, tension, true) - p.Stroke() + p.stroke(strokeColor, strokeWidth) valid = valid[:0] // reset continue } - valid = append(valid, pt) } // draw any remaining points collected p.drawSmoothCurve(valid, tension, true) - return p.Stroke() + p.stroke(strokeColor, strokeWidth) } // drawSmoothCurve handles the actual path drawing (MoveTo/LineTo/QuadCurveTo) -// but does NOT call Stroke() or Fill(). This allows us to reuse this path -// logic for either smoothLineStroke or smoothFillArea. +// but does NOT call Stroke() or Fill(), letting caller do it. func (p *Painter) drawSmoothCurve(points []Point, tension float64, dotForSinglePoint bool) { if len(points) < 3 { // Not enough points to form a curve, draw a line p.drawStraightPath(points, dotForSinglePoint) return } - p.MoveTo(points[0].X, points[0].Y) // Start from the first valid point + p.moveTo(points[0].X, points[0].Y) // Start from the first valid point // Handle each segment between points with quadratic Bézier curves for i := 1; i < len(points)-1; i++ { @@ -532,96 +363,156 @@ func (p *Painter) drawSmoothCurve(points []Point, tension float64, dotForSingleP cx := float64(x1) + tension*(mx-float64(x1)) cy := float64(y1) + tension*(my-float64(y1)) - p.QuadCurveTo(x1, y1, int(cx), int(cy)) + p.quadCurveTo(x1, y1, int(cx), int(cy)) } // Connect the second-to-last point to the last point n := len(points) - p.QuadCurveTo(points[n-2].X, points[n-2].Y, points[n-1].X, points[n-1].Y) -} - -// SmoothLineStroke is Deprecated. This implementation produced sharp joints at the point, and will be removed in v0.4.0. -func (p *Painter) SmoothLineStroke(points []Point) *Painter { - prevX := 0 - prevY := 0 - for index, point := range points { - x := point.X - y := point.Y - if index == 0 { - p.MoveTo(x, y) - } else { - cx := prevX + (x-prevX)/5 - cy := y + (y-prevY)/2 - p.QuadCurveTo(cx, cy, x, y) - } - prevX = x - prevY = y - } - p.Stroke() - return p + p.quadCurveTo(points[n-2].X, points[n-2].Y, points[n-1].X, points[n-1].Y) } -func (p *Painter) SetBackground(width, height int, color Color, inside ...bool) *Painter { - r := p.render - s := chartdraw.Style{ - FillColor: color, - } - // background color - p.SetDrawingStyle(s) - defer p.ResetStyle() - if len(inside) != 0 && inside[0] { - p.MoveTo(0, 0) - p.LineTo(width, 0) - p.LineTo(width, height) - p.LineTo(0, height) - p.LineTo(0, 0) - } else { - // setting the background color does not use boxes - r.MoveTo(0, 0) - r.LineTo(width, 0) - r.LineTo(width, height) - r.LineTo(0, height) - r.LineTo(0, 0) - } - p.FillStroke() - return p +// SetBackground fills the entire painter area with the given color. +func (p *Painter) SetBackground(width, height int, color Color) { + p.moveTo(0, 0) + p.lineTo(width, 0) + p.lineTo(width, height) + p.lineTo(0, height) + p.lineTo(0, 0) + p.fill(color) } // MarkLine draws a horizontal line with a small circle and arrow at the right. -func (p *Painter) MarkLine(x, y, width int) *Painter { +func (p *Painter) MarkLine(x, y, width int, fillColor, strokeColor Color, strokeWidth float64, strokeDashArray []float64) { arrowWidth := 16 arrowHeight := 10 endX := x + width radius := 3 - p.Circle(3, x+radius, y) - p.render.Fill() - p.MoveTo(x+radius*3, y) - p.LineTo(endX-arrowWidth, y) - p.Stroke() - p.ArrowRight(endX, y, arrowWidth, arrowHeight) - return p + + // Set up stroke style before drawing + defer p.render.ResetStyle() + p.render.SetStrokeColor(strokeColor) + p.render.SetStrokeWidth(strokeWidth) + p.render.SetStrokeDashArray(strokeDashArray) + p.render.SetFillColor(fillColor) + + // Draw the circle at the starting point + p.render.Circle(float64(radius), x+radius+p.box.Left, y+p.box.Top) + p.render.Fill() // only fill the circle, do not stroke + + // Draw the line from the end of the circle to near the arrow start + p.moveTo(x+radius*3, y) + p.lineTo(endX-arrowWidth, y) + p.render.Stroke() // apply stroke with the dash array + + p.ArrowRight(endX, y, arrowWidth, arrowHeight, fillColor, strokeColor, strokeWidth) } // Polygon draws a polygon with the specified center, radius, and number of sides. -func (p *Painter) Polygon(center Point, radius float64, sides int) *Painter { +func (p *Painter) Polygon(center Point, radius float64, sides int, strokeColor Color, strokeWidth float64) { points := getPolygonPoints(center, radius, sides) - for i, item := range points { - if i == 0 { - p.MoveTo(item.X, item.Y) - } else { - p.LineTo(item.X, item.Y) + p.drawStraightPath(points, false) + p.lineTo(points[0].X, points[0].Y) + p.stroke(strokeColor, strokeWidth) +} + +// Pin draws a pin shape (circle + curved tail). +func (p *Painter) Pin(x, y, width int, fillColor, strokeColor Color, strokeWidth float64) { + r := float64(width) / 2 + y -= width / 4 + angle := chartdraw.DegreesToRadians(15) + + // Draw the pin head with fill and stroke + startAngle := math.Pi/2 + angle + delta := 2*math.Pi - 2*angle + p.arcTo(x, y, r, r, startAngle, delta) + p.lineTo(x, y) + p.close() + p.fillStroke(fillColor, strokeColor, strokeWidth) + + // The curved tail + startX := x - int(r) + startY := y + endX := x + int(r) + endY := y + p.moveTo(startX, startY) + cx := x + cy := y + int(r*2.5) + p.quadCurveTo(cx, cy, endX, endY) + p.close() + + // Apply both fill and stroke to the tail + p.fillStroke(fillColor, strokeColor, strokeWidth) +} + +// arrow draws an arrow shape in the given direction, then fill+stroke with the given style. +func (p *Painter) arrow(x, y, width, height int, direction string, + fillColor, strokeColor Color, strokeWidth float64) { + halfWidth := width >> 1 + halfHeight := height >> 1 + if direction == PositionTop || direction == PositionBottom { + x0 := x - halfWidth + x1 := x0 + width + dy := -height / 3 + y0 := y + y1 := y0 - height + if direction == PositionBottom { + y0 = y - height + y1 = y + dy = 2 * dy } + p.moveTo(x0, y0) + p.lineTo(x0+halfWidth, y1) + p.lineTo(x1, y0) + p.lineTo(x0+halfWidth, y+dy) + p.lineTo(x0, y0) + } else { + x0 := x + width + x1 := x0 - width + y0 := y - halfHeight + dx := -width / 3 + if direction == PositionRight { + x0 = x - width + dx = -dx + x1 = x0 + width + } + p.moveTo(x0, y0) + p.lineTo(x1, y0+halfHeight) + p.lineTo(x0, y0+height) + p.lineTo(x0+dx, y0+halfHeight) + p.lineTo(x0, y0) } - p.LineTo(points[0].X, points[0].Y) - p.Stroke() - return p + p.fillStroke(fillColor, strokeColor, strokeWidth) +} + +// ArrowLeft draws an arrow at the given point and dimensions pointing left. +func (p *Painter) ArrowLeft(x, y, width, height int, + fillColor, strokeColor Color, strokeWidth float64) { + p.arrow(x, y, width, height, PositionLeft, fillColor, strokeColor, strokeWidth) +} + +// ArrowRight draws an arrow at the given point and dimensions pointing right. +func (p *Painter) ArrowRight(x, y, width, height int, + fillColor, strokeColor Color, strokeWidth float64) { + p.arrow(x, y, width, height, PositionRight, fillColor, strokeColor, strokeWidth) } -// FillArea draws a filled polygon through the given points, skipping "null" (MaxInt32) break values (filling the area -// flat between them). -func (p *Painter) FillArea(points []Point) *Painter { +// ArrowUp draws an arrow at the given point and dimensions pointing up. +func (p *Painter) ArrowUp(x, y, width, height int, + fillColor, strokeColor Color, strokeWidth float64) { + p.arrow(x, y, width, height, PositionTop, fillColor, strokeColor, strokeWidth) +} + +// ArrowDown draws an arrow at the given point and dimensions pointing down. +func (p *Painter) ArrowDown(x, y, width, height int, + fillColor, strokeColor Color, strokeWidth float64) { + p.arrow(x, y, width, height, PositionBottom, fillColor, strokeColor, strokeWidth) +} + +// FillArea draws a filled polygon through the given points, skipping "null" (MaxInt32) break values +// (filling the area flat between them). +func (p *Painter) FillArea(points []Point, fillColor Color) { if len(points) == 0 { - return p + return } var valid []Point @@ -629,7 +520,7 @@ func (p *Painter) FillArea(points []Point) *Painter { if pt.Y == math.MaxInt32 { // If we encounter a break, fill the accumulated segment p.drawStraightPath(valid, false) - p.Fill() + p.fill(fillColor) valid = valid[:0] // reset continue } @@ -638,16 +529,15 @@ func (p *Painter) FillArea(points []Point) *Painter { // Fill the last segment if there is one p.drawStraightPath(valid, false) - p.Fill() - - return p + p.fill(fillColor) } -// smoothFillArea draws a smooth curve for the "top" portion of points but uses straight lines for the bottom corners, -// producing a fill with sharp corners. -func (p *Painter) smoothFillChartArea(points []Point, tension float64) *Painter { +// smoothFillChartArea draws a smooth curve for the "top" portion of points but uses straight lines for +// the bottom corners, producing a fill with sharp corners. +func (p *Painter) smoothFillChartArea(points []Point, tension float64, fillColor Color) { if tension <= 0 { - return p.FillArea(points) + p.FillArea(points, fillColor) + return } else if tension > 1 { tension = 1 } @@ -657,16 +547,18 @@ func (p *Painter) smoothFillChartArea(points []Point, tension float64) *Painter // We'll separate them: if len(points) < 3 { // Not enough to separate top from bottom - return p.FillArea(points) + p.FillArea(points, fillColor) + return } // The final 3 points are the corners + repeated first point top := points[:len(points)-3] - bottom := points[len(points)-3:] // [ corner1, corner2, firstTopAgain ] + bottom := points[len(points)-3:] // [corner1, corner2, firstTopAgain] // If top portion is empty or 1 point, just fill straight if len(top) < 2 { - return p.FillArea(points) + p.FillArea(points, fillColor) + return } // Build the smooth path for the top portion @@ -692,50 +584,59 @@ func (p *Painter) smoothFillChartArea(points []Point, tension float64) *Painter } if !firstPointSet { - return p.FillArea(points) // No actual top segment was drawn, fallback to straight fill + p.FillArea(points, fillColor) // No actual top segment was drawn, fallback to straight fill + return } // Add sharp lines to close the shape at the bottom // The path is currently at the last top point we drew. Now we need to draw to corner1 -> corner2 -> firstTopAgain for i := 0; i < len(bottom); i++ { - p.LineTo(bottom[i].X, bottom[i].Y) + p.lineTo(bottom[i].X, bottom[i].Y) } - - p.Fill() - return p + p.fill(fillColor) } -func (p *Painter) Text(body string, x, y int) *Painter { - p.render.Text(body, x+p.box.Left, y+p.box.Top) - return p -} +// Text draws the given string at the position specified, using the given font style. Specifying radians will rotate +// the text. +func (p *Painter) Text(body string, x, y int, radians float64, fontStyle FontStyle) { + if fontStyle.Font == nil { + fontStyle.Font = getPreferredFont(p.font) + } + defer p.render.ResetStyle() + p.render.SetFont(fontStyle.Font) + p.render.SetFontSize(fontStyle.FontSize) + p.render.SetFontColor(fontStyle.FontColor) -func (p *Painter) TextRotation(body string, x, y int, radians float64) { - p.render.SetTextRotation(radians) + if radians != 0 { + defer p.render.ClearTextRotation() + p.render.SetTextRotation(radians) + } p.render.Text(body, x+p.box.Left, y+p.box.Top) - p.render.ClearTextRotation() } -func (p *Painter) SetTextRotation(radians float64) { - p.render.SetTextRotation(radians) -} -func (p *Painter) ClearTextRotation() { - p.render.ClearTextRotation() -} - -func (p *Painter) TextFit(body string, x, y, width int, textAligns ...string) chartdraw.Box { - style := p.style - textWarp := style.TextWrap - style.TextWrap = chartdraw.TextWrapWord +// TextFit draws multi-line text constrained to a given width. +func (p *Painter) TextFit(body string, x, y, width int, fontStyle FontStyle, textAligns ...string) chartdraw.Box { + if fontStyle.Font == nil { + fontStyle.Font = getPreferredFont(p.font) + } + style := chartdraw.Style{ + FontStyle: fontStyle, + TextWrap: chartdraw.TextWrapWord, + } r := p.render + defer r.ResetStyle() + r.SetFont(fontStyle.Font) + r.SetFontSize(fontStyle.FontSize) + r.SetFontColor(fontStyle.FontColor) + lines := chartdraw.Text.WrapFit(r, body, width, style) - p.SetFontStyle(style.FontStyle) - var output chartdraw.Box + var output chartdraw.Box textAlign := "" if len(textAligns) != 0 { textAlign = textAligns[0] } + for index, line := range lines { if line == "" { continue @@ -749,17 +650,19 @@ func (p *Painter) TextFit(body string, x, y, width int, textAligns ...string) ch case AlignCenter: x0 += (width - lineBox.Width()) >> 1 } - p.Text(line, x0, y0) + + p.render.Text(line, x0+p.box.Left, y0+p.box.Top) output.Right = chartdraw.MaxInt(lineBox.Right, output.Right) output.Bottom += lineBox.Height() if index < len(lines)-1 { - output.Bottom += +style.GetTextLineSpacing() + output.Bottom += style.GetTextLineSpacing() } } - p.style.TextWrap = textWarp + output.IsSet = true return output } +// isTick determines whether the given index is a "tick" mark out of numTicks. func isTick(totalRange int, numTicks int, index int) bool { if numTicks >= totalRange { return true @@ -779,95 +682,95 @@ func isTick(totalRange int, numTicks int, index int) bool { return actualTickIndex == index } -func (p *Painter) Ticks(opt TicksOption) *Painter { - if opt.LabelCount <= 0 || opt.Length <= 0 { - return p +// ticks draws small lines to indicate tick marks, using a fixed stroke color/width. +func (p *Painter) ticks(opt ticksOption) { + if opt.labelCount <= 0 || opt.length <= 0 { + return } var values []int - if opt.Vertical { - values = autoDivide(p.Height(), opt.TickSpaces) + if opt.vertical { + values = autoDivide(p.Height(), opt.tickSpaces) } else { - values = autoDivide(p.Width(), opt.TickSpaces) + values = autoDivide(p.Width(), opt.tickSpaces) } for index, value := range values { - if index < opt.First { + if index < opt.firstIndex { continue - } else if !isTick(len(values)-opt.First, opt.TickCount, index-opt.First) { + } else if !isTick(len(values)-opt.firstIndex, opt.tickCount, index-opt.firstIndex) { continue } - if opt.Vertical { + if opt.vertical { p.LineStroke([]Point{ {X: 0, Y: value}, - {X: opt.Length, Y: value}, - }) + {X: opt.length, Y: value}, + }, opt.strokeColor, opt.strokeWidth) } else { p.LineStroke([]Point{ - {X: value, Y: opt.Length}, + {X: value, Y: opt.length}, {X: value, Y: 0}, - }) + }, opt.strokeColor, opt.strokeWidth) } } - return p } -func (p *Painter) MultiText(opt MultiTextOption) *Painter { - if len(opt.TextList) == 0 { - return p +// multiText prints multiple lines of text for axis labels. +func (p *Painter) multiText(opt multiTextOption) { + if len(opt.textList) == 0 { + return } - count := len(opt.TextList) + count := len(opt.textList) width := p.Width() height := p.Height() var positions []int - if opt.Vertical { - if opt.CenterLabels { + if opt.vertical { + if opt.centerLabels { positions = autoDivide(height, count) } else { positions = autoDivide(height, count-1) } } else { - if opt.CenterLabels { + if opt.centerLabels { positions = autoDivide(width, count) } else { positions = autoDivide(width, count-1) } } - isTextRotation := opt.TextRotation != 0 + if opt.textRotation != 0 { + defer p.render.ClearTextRotation() + p.render.SetTextRotation(opt.textRotation) + } positionCount := len(positions) - skippedLabels := opt.LabelSkipCount // specify the skip count to ensure the top value is listed + skippedLabels := opt.labelSkipCount // specify the skip count to ensure the top value is listed for index, start := range positions { - if opt.CenterLabels && index == positionCount-1 { + if opt.centerLabels && index == positionCount-1 { break // positions have one item more than we can map to text, this extra value is used to center against - } else if index < opt.First { + } else if index < opt.firstIndex { continue - } else if !opt.Vertical && + } else if !opt.vertical && index != count-1 && // one off case for last label due to values and label qty difference - !isTick(positionCount-opt.First, opt.TickCount, index-opt.First) { + !isTick(positionCount-opt.firstIndex, opt.tickCount, index-opt.firstIndex) { continue } else if index != count-1 && // ensure the bottom value is always printed - skippedLabels < opt.LabelSkipCount { + skippedLabels < opt.labelSkipCount { skippedLabels++ continue } else { skippedLabels = 0 } - if isTextRotation { - p.ClearTextRotation() - p.SetTextRotation(opt.TextRotation) - } - text := opt.TextList[index] - box := p.MeasureText(text) + text := opt.textList[index] + box := p.MeasureText(text, opt.textRotation, opt.fontStyle) x := 0 y := 0 - if opt.Vertical { - if opt.CenterLabels { + if opt.vertical { + if opt.centerLabels { start = (positions[index] + positions[index+1]) >> 1 } else { start = positions[index] } y = start + box.Height()>>1 - switch opt.Align { + switch opt.align { case AlignRight: x = width - box.Width() case AlignCenter: @@ -876,11 +779,11 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { x = 0 } } else { - if opt.CenterLabels { + if opt.centerLabels { // graphs with limited data samples generally look better with the samples directly below the label // for that reason we will exactly center these graphs, but graphs with higher sample counts will // attempt to space the labels better rather than line up directly to the graph points - exactLabels := count == opt.LabelCount + exactLabels := count == opt.labelCount if !exactLabels && index == 0 { x = start - 1 // align to the actual start (left side of tick space) } else if !exactLabels && index == count-1 { @@ -897,77 +800,37 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { } } } - x += opt.Offset.Left - y += opt.Offset.Top - p.Text(text, x, y) - } - if isTextRotation { - p.ClearTextRotation() + x += opt.offset.Left + y += opt.offset.Top + p.Text(text, x, y, opt.textRotation, opt.fontStyle) } - return p } -func (p *Painter) Grid(opt GridOption) *Painter { - width := p.Width() - height := p.Height() - drawLines := func(values []int, ignoreIndexList []int, isVertical bool) { - for index, v := range values { - if containsInt(ignoreIndexList, index) { - continue - } - x0 := 0 - y0 := 0 - x1 := 0 - y1 := 0 - if isVertical { - x0 = v - x1 = v - y1 = height - } else { - x1 = width - y0 = v - y1 = v - } - p.LineStroke([]Point{ - {X: x0, Y: y0}, - {X: x1, Y: y1}, - }) - } - } - columnCount := sumInt(opt.ColumnSpans) - if columnCount == 0 { - columnCount = opt.Columns - } - if columnCount > 0 { - values := autoDivideSpans(width, columnCount, opt.ColumnSpans) - drawLines(values, opt.IgnoreColumnLines, true) - } - if opt.Rows > 0 { - values := autoDivide(height, opt.Rows) - drawLines(values, opt.IgnoreRowLines, false) - } - return p -} - -func (p *Painter) Dots(points []Point) *Painter { +// Dots prints filled circles for the given points. +func (p *Painter) Dots(points []Point, fillColor, strokeColor Color, strokeWidth float64, dotRadius float64) { + defer p.render.ResetStyle() + p.render.SetFillColor(fillColor) + p.render.SetStrokeColor(strokeColor) + p.render.SetStrokeWidth(strokeWidth) for _, item := range points { - p.Circle(2, item.X, item.Y) + p.render.Circle(dotRadius, item.X+p.box.Left, item.Y+p.box.Top) } - p.FillStroke() - return p + p.render.FillStroke() } -func (p *Painter) Rect(box Box) *Painter { - p.MoveTo(box.Left, box.Top) - p.LineTo(box.Right, box.Top) - p.LineTo(box.Right, box.Bottom) - p.LineTo(box.Left, box.Bottom) - p.LineTo(box.Left, box.Top) - p.FillStroke() - return p +// filledRect will draw a filled box with the given coordinates. +func (p *Painter) filledRect(box Box, fillColor, strokeColor Color, strokeWidth float64) { + p.moveTo(box.Left, box.Top) + p.lineTo(box.Right, box.Top) + p.lineTo(box.Right, box.Bottom) + p.lineTo(box.Left, box.Bottom) + p.lineTo(box.Left, box.Top) + p.fillStroke(fillColor, strokeColor, strokeWidth) } -func (p *Painter) RoundedRect(box Box, radius int, roundTop, roundBottom bool) *Painter { +// roundedRect is similar to filledRect except the top and bottom will be rounded. +func (p *Painter) roundedRect(box Box, radius int, roundTop, roundBottom bool, + fillColor, strokeColor Color, strokeWidth float64) { r := (box.Right - box.Left) / 2 if radius > r { radius = r @@ -977,65 +840,105 @@ func (p *Painter) RoundedRect(box Box, radius int, roundTop, roundBottom bool) * if roundTop { // Start at the appropriate point depending on rounding at the top - p.MoveTo(box.Left+radius, box.Top) - p.LineTo(box.Right-radius, box.Top) + p.moveTo(box.Left+radius, box.Top) + p.lineTo(box.Right-radius, box.Top) // right top cx := box.Right - radius cy := box.Top + radius - p.ArcTo(cx, cy, rx, ry, -math.Pi/2, math.Pi/2) + p.arcTo(cx, cy, rx, ry, -math.Pi/2, math.Pi/2) } else { - p.MoveTo(box.Left, box.Top) - p.LineTo(box.Right, box.Top) + p.moveTo(box.Left, box.Top) + p.lineTo(box.Right, box.Top) } if roundBottom { - p.LineTo(box.Right, box.Bottom-radius) + p.lineTo(box.Right, box.Bottom-radius) // right bottom cx := box.Right - radius cy := box.Bottom - radius - p.ArcTo(cx, cy, rx, ry, 0, math.Pi/2) + p.arcTo(cx, cy, rx, ry, 0, math.Pi/2) - p.LineTo(box.Left+radius, box.Bottom) + p.lineTo(box.Left+radius, box.Bottom) // left bottom cx = box.Left + radius cy = box.Bottom - radius - p.ArcTo(cx, cy, rx, ry, math.Pi/2, math.Pi/2) + p.arcTo(cx, cy, rx, ry, math.Pi/2, math.Pi/2) } else { - p.LineTo(box.Right, box.Bottom) - p.LineTo(box.Left, box.Bottom) + p.lineTo(box.Right, box.Bottom) + p.lineTo(box.Left, box.Bottom) } if roundTop { // left top - p.LineTo(box.Left, box.Top+radius) + p.lineTo(box.Left, box.Top+radius) cx := box.Left + radius cy := box.Top + radius - p.ArcTo(cx, cy, rx, ry, math.Pi, math.Pi/2) + p.arcTo(cx, cy, rx, ry, math.Pi, math.Pi/2) } else { - p.LineTo(box.Left, box.Top) + p.lineTo(box.Left, box.Top) } - p.Close() - p.FillStroke() - p.Fill() - return p + p.close() + p.fillStroke(fillColor, strokeColor, strokeWidth) } -func (p *Painter) LegendLineDot(box Box) *Painter { - width := box.Width() - height := box.Height() - strokeWidth := 3 - dotHeight := 5 - - p.render.SetStrokeWidth(float64(strokeWidth)) - center := (height-strokeWidth)>>1 - 1 - p.MoveTo(box.Left, box.Top-center) - p.LineTo(box.Right, box.Top-center) - p.Stroke() - p.Circle(float64(dotHeight), box.Left+width>>1, box.Top-center) - p.FillStroke() - return p +// legendLineDot draws a small horizontal line with a dot in the middle, often used in legends. +func (p *Painter) legendLineDot(box Box, strokeColor Color, strokeWidth float64, dotColor Color) { + center := (box.Height()-int(strokeWidth))>>1 - 1 + + defer p.render.ResetStyle() + p.render.SetStrokeColor(strokeColor) + p.render.SetStrokeWidth(strokeWidth) + p.moveTo(box.Left, box.Top-center) + p.lineTo(box.Right, box.Top-center) + p.render.Stroke() + + // draw dot in the middle + midX := box.Left + (box.Width() >> 1) + p.Circle(5, midX, box.Top-center, dotColor, dotColor, 3) +} + +// BarChart renders a bar chart with the provided configuration to the painter. +func (p *Painter) BarChart(opt BarChartOption) error { + _, err := newBarChart(p, opt).Render() + return err +} + +// HorizontalBarChart renders a horizontal bar chart with the provided configuration to the painter. +func (p *Painter) HorizontalBarChart(opt HorizontalBarChartOption) error { + _, err := newHorizontalBarChart(p, opt).Render() + return err +} + +// FunnelChart renders a funnel chart with the provided configuration to the painter. +func (p *Painter) FunnelChart(opt FunnelChartOption) error { + _, err := newFunnelChart(p, opt).Render() + return err +} + +// LineChart renders a line chart with the provided configuration to the painter. +func (p *Painter) LineChart(opt LineChartOption) error { + _, err := newLineChart(p, opt).Render() + return err +} + +// PieChart renders a pie chart with the provided configuration to the painter. +func (p *Painter) PieChart(opt PieChartOption) error { + _, err := newPieChart(p, opt).Render() + return err +} + +// RadarChart renders a radar chart with the provided configuration to the painter. +func (p *Painter) RadarChart(opt RadarChartOption) error { + _, err := newRadarChart(p, opt).Render() + return err +} + +// TableChart renders a table with the provided configuration to the painter. +func (p *Painter) TableChart(opt TableChartOption) error { + _, err := newTableChart(p, opt).Render() + return err } diff --git a/painter_test.go b/painter_test.go index 44f43a6..d435a19 100644 --- a/painter_test.go +++ b/painter_test.go @@ -1,11 +1,11 @@ package charts import ( + "fmt" "math" "strconv" "testing" - "github.com/golang/freetype/truetype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,29 +16,49 @@ import ( func TestPainterOption(t *testing.T) { t.Parallel() - font := &truetype.Font{} - d, err := NewPainter(PainterOptions{ - OutputFormat: ChartOutputSVG, - Width: 800, - Height: 600, - }, - PainterBoxOption(Box{Right: 400, Bottom: 300}), - PainterPaddingOption(Box{Left: 1, Top: 2, Right: 3, Bottom: 4}), - PainterFontOption(font), - PainterStyleOption(chartdraw.Style{ClassName: "test"}), - ) - require.NoError(t, err) - assert.Equal(t, Box{ - Left: 1, - Top: 2, - Right: 397, - Bottom: 296, - }, d.box) - assert.Equal(t, font, d.font) - assert.Equal(t, "test", d.style.ClassName) + t.Run("box", func(t *testing.T) { + p := NewPainter(PainterOptions{ + OutputFormat: ChartOutputSVG, + Width: 800, + Height: 600, + }, + PainterBoxOption(Box{Right: 400, Bottom: 300}), + PainterPaddingOption(Box{Left: 1, Top: 2, Right: 3, Bottom: 4}), + ) + + assert.Equal(t, Box{ + Left: 1, + Top: 2, + Right: 397, + Bottom: 296, + }, p.box) + }) + t.Run("theme", func(t *testing.T) { + theme := GetTheme(ThemeAnt) + p := NewPainter(PainterOptions{ + OutputFormat: ChartOutputSVG, + Width: 800, + Height: 600, + }, PainterThemeOption(theme)) + + assert.Equal(t, theme, p.theme) + }) + t.Run("font", func(t *testing.T) { + font := GetDefaultFont() + p := NewPainter(PainterOptions{ + OutputFormat: ChartOutputSVG, + Width: 800, + Height: 600, + }) + require.Nil(t, p.font) + + p = p.Child(PainterFontOption(font)) + + assert.Equal(t, font, p.font) + }) } -func TestPainter(t *testing.T) { +func TestPainterInternal(t *testing.T) { t.Parallel() tests := []struct { @@ -46,192 +66,202 @@ func TestPainter(t *testing.T) { fn func(*Painter) result string }{ + { + name: "circle", + fn: func(p *Painter) { + p.Circle(5, 2, 3, drawing.ColorTransparent, drawing.ColorTransparent, 1.0) + }, + result: "", + }, { name: "moveTo_lineTo", fn: func(p *Painter) { - p.MoveTo(1, 1) - p.LineTo(2, 2) - p.Stroke() + p.moveTo(1, 1) + p.lineTo(2, 2) + p.stroke(drawing.ColorTransparent, 1.0) }, - result: "", + result: "", }, { - name: "circle", + name: "arc", fn: func(p *Painter) { - p.Circle(5, 2, 3) + p.arcTo(100, 100, 100, 100, 0, math.Pi/2) + p.close() + p.fillStroke(drawing.ColorBlue, drawing.ColorBlack, 1) }, - result: "", + result: "", }, + } + + for i, tt := range tests { + t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { + p := NewPainter(PainterOptions{ + OutputFormat: ChartOutputSVG, + Width: 400, + Height: 300, + }, PainterPaddingOption(chartdraw.Box{Left: 5, Top: 10})) + tt.fn(p) + data, err := p.Bytes() + require.NoError(t, err) + assertEqualSVG(t, tt.result, data) + }) + } +} + +func TestPainterExternal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fn func(*Painter) + result string + }{ { name: "text", fn: func(p *Painter) { - p.Text("hello world!", 3, 6) + p.Text("hello world!", 3, 6, 0, FontStyle{}) + }, + result: "hello world!", + }, + { + name: "text_rotated", + fn: func(p *Painter) { + p.Text("hello world!", 3, 6, chartdraw.DegreesToRadians(90), FontStyle{}) }, - result: "hello world!", + result: "hello world!", }, { - name: "line", + name: "line_stroke", fn: func(p *Painter) { - p.SetDrawingStyle(chartdraw.Style{ - StrokeColor: drawing.ColorBlack, - StrokeWidth: 1, - }) p.LineStroke([]Point{ - {X: 1, Y: 2}, - {X: 3, Y: 4}, - }) + {X: 10, Y: 20}, + {X: 30, Y: 40}, + {X: 50, Y: 20}, + }, drawing.ColorBlack, 1) }, - result: "", + result: "", }, { - name: "background", + name: "smooth_line_stroke", fn: func(p *Painter) { - p.SetBackground(400, 300, chartdraw.ColorWhite) + p.SmoothLineStroke([]Point{ + {X: 10, Y: 20}, + {X: 20, Y: 40}, + {X: 30, Y: 60}, + {X: 40, Y: 50}, + {X: 50, Y: 40}, + {X: 60, Y: 80}, + }, 0.5, drawing.ColorBlack, 1) }, - result: "", + result: "", }, { - name: "arc", + name: "background", fn: func(p *Painter) { - p.SetStyle(chartdraw.Style{ - StrokeWidth: 1, - StrokeColor: drawing.ColorBlack, - FillColor: drawing.ColorBlue, - }) - p.ArcTo(100, 100, 100, 100, 0, math.Pi/2) - p.Close() - p.FillStroke() + p.SetBackground(400, 300, chartdraw.ColorWhite) }, - result: "", + result: "", }, { name: "pin", fn: func(p *Painter) { - p.SetStyle(chartdraw.Style{ - StrokeWidth: 1, - StrokeColor: Color{R: 84, G: 112, B: 198, A: 255}, - FillColor: Color{R: 84, G: 112, B: 198, A: 255}, - }) - p.Pin(30, 30, 30) + c := Color{R: 84, G: 112, B: 198, A: 255} + p.Pin(30, 30, 30, c, c, 1) }, - result: "", + result: "", }, { name: "arrow_left", fn: func(p *Painter) { - p.SetStyle(chartdraw.Style{ - StrokeWidth: 1, - StrokeColor: Color{R: 84, G: 112, B: 198, A: 255}, - FillColor: Color{R: 84, G: 112, B: 198, A: 255}, - }) - p.ArrowLeft(30, 30, 16, 10) + c := Color{R: 84, G: 112, B: 198, A: 255} + p.ArrowLeft(30, 30, 16, 10, c, c, 1) }, - result: "", + result: "", }, { name: "arrow_right", fn: func(p *Painter) { - p.SetStyle(chartdraw.Style{ - StrokeWidth: 1, - StrokeColor: Color{R: 84, G: 112, B: 198, A: 255}, - FillColor: Color{R: 84, G: 112, B: 198, A: 255}, - }) - p.ArrowRight(30, 30, 16, 10) + c := Color{R: 84, G: 112, B: 198, A: 255} + p.ArrowRight(30, 30, 16, 10, c, c, 1) }, - result: "", + result: "", }, { - name: "arrow_top", + name: "arrow_up", fn: func(p *Painter) { - p.SetStyle(chartdraw.Style{ - StrokeWidth: 1, - StrokeColor: Color{R: 84, G: 112, B: 198, A: 255}, - FillColor: Color{R: 84, G: 112, B: 198, A: 255}, - }) - p.ArrowTop(30, 30, 10, 16) + c := Color{R: 84, G: 112, B: 198, A: 255} + p.ArrowUp(30, 30, 10, 16, c, c, 1) }, - result: "", + result: "", }, { - name: "arrow_bottom", + name: "arrow_down", fn: func(p *Painter) { - p.SetStyle(chartdraw.Style{ - StrokeWidth: 1, - StrokeColor: Color{R: 84, G: 112, B: 198, A: 255}, - FillColor: Color{R: 84, G: 112, B: 198, A: 255}, - }) - p.ArrowBottom(30, 30, 10, 16) + c := Color{R: 84, G: 112, B: 198, A: 255} + p.ArrowDown(30, 30, 10, 16, c, c, 1) }, - result: "", + result: "", }, { name: "mark_line", fn: func(p *Painter) { - p.SetStyle(chartdraw.Style{ - StrokeWidth: 1, - StrokeColor: Color{R: 84, G: 112, B: 198, A: 255}, - FillColor: Color{R: 84, G: 112, B: 198, A: 255}, - StrokeDashArray: []float64{4, 2}, - }) - p.MarkLine(0, 20, 300) + c := Color{R: 84, G: 112, B: 198, A: 255} + p.MarkLine(0, 20, 300, c, c, 1, []float64{4, 2}) }, - result: "", + result: "", }, { name: "polygon", fn: func(p *Painter) { - p.SetStyle(chartdraw.Style{ - StrokeWidth: 1, - StrokeColor: Color{R: 84, G: 112, B: 198, A: 255}, - }) - p.Polygon(Point{X: 100, Y: 100}, 50, 6) + p.Polygon(Point{X: 100, Y: 100}, 50, 6, Color{R: 84, G: 112, B: 198, A: 255}, 1) }, - result: "", + result: "", }, { name: "fill_area", fn: func(p *Painter) { - p.SetDrawingStyle(chartdraw.Style{ - FillColor: Color{R: 84, G: 112, B: 198, A: 255}, - }) p.FillArea([]Point{ {X: 0, Y: 0}, {X: 0, Y: 100}, {X: 100, Y: 100}, {X: 0, Y: 0}, - }) + }, Color{R: 84, G: 112, B: 198, A: 255}) }, - result: "", + result: "", }, { name: "child_chart", fn: func(p *Painter) { - _, _ = NewLineChart(p, makeMinimalLineChartOption()).Render() - p = p.Child(PainterBoxOption(chartdraw.NewBox(0, 200, 400, 200))) opt := makeMinimalLineChartOption() + opt.ValueFormatter = func(f float64) string { + return fmt.Sprintf("%.0f", f) + } + _ = p.LineChart(opt) + p = p.Child(PainterBoxOption(chartdraw.NewBox(0, 200, 400, 200))) + opt = makeMinimalLineChartOption() opt.Theme = GetDefaultTheme().WithBackgroundColor(drawing.ColorFromAlphaMixedRGBA(0, 0, 0, 0)) - _, _ = NewLineChart(p, opt).Render() + _ = p.LineChart(opt) }, - result: "1.44k1.28k1.12k96080064048032016001.44k1.28k1.12k9608006404803201600", + result: "14401280112096080064048032016001.44k1.28k1.12k9608006404803201600", }, } + for i, tt := range tests { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - d, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 400, Height: 300, }, PainterPaddingOption(chartdraw.Box{Left: 5, Top: 10})) - require.NoError(t, err) - tt.fn(d) - data, err := d.Bytes() + tt.fn(p) + data, err := p.Bytes() require.NoError(t, err) assertEqualSVG(t, tt.result, data) }) } } -func TestRoundedRect(t *testing.T) { +func TestPainterRoundedRect(t *testing.T) { t.Parallel() tests := []struct { @@ -242,54 +272,48 @@ func TestRoundedRect(t *testing.T) { { name: "round_fully", fn: func(p *Painter) { - p.RoundedRect(Box{ + p.roundedRect(Box{ Left: 10, Right: 30, Bottom: 150, Top: 10, - }, 5, true, true) + }, 5, true, true, drawing.ColorBlue, drawing.ColorBlue, 1) }, - result: "", + result: "", }, { name: "square_top", fn: func(p *Painter) { - p.RoundedRect(Box{ + p.roundedRect(Box{ Left: 10, Right: 30, Bottom: 150, Top: 10, - }, 5, false, true) + }, 5, false, true, drawing.ColorBlue, drawing.ColorBlue, 1) }, - result: "", + result: "", }, { name: "square_bottom", fn: func(p *Painter) { - p.RoundedRect(Box{ + p.roundedRect(Box{ Left: 10, Right: 30, Bottom: 150, Top: 10, - }, 5, true, false) + }, 5, true, false, drawing.ColorBlue, drawing.ColorBlue, 1) }, - result: "", + result: "", }, } for i, tc := range tests { t.Run(strconv.Itoa(i)+"-"+tc.name, func(t *testing.T) { - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ Width: 400, Height: 300, OutputFormat: ChartOutputSVG, }) - require.NoError(t, err) - p.OverrideDrawingStyle(chartdraw.Style{ - FillColor: drawing.ColorBlue, - StrokeWidth: 1, - StrokeColor: drawing.ColorBlue, - }) tc.fn(p) buf, err := p.Bytes() require.NoError(t, err) @@ -298,41 +322,64 @@ func TestRoundedRect(t *testing.T) { } } -func TestPainterTextFit(t *testing.T) { +func TestPainterMeasureText(t *testing.T) { t.Parallel() - p, err := NewPainter(PainterOptions{ + svgP := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 400, Height: 300, }) - require.NoError(t, err) + pngP := NewPainter(PainterOptions{ + OutputFormat: ChartOutputPNG, + Width: 400, + Height: 300, + }) style := FontStyle{ FontSize: 12, FontColor: chartdraw.ColorBlack, Font: GetDefaultFont(), } - p.SetStyle(chartdraw.Style{FontStyle: style}) - box := p.TextFit("Hello World!", 0, 20, 80) - assert.Equal(t, chartdraw.Box{Right: 45, Bottom: 35}, box) - box = p.TextFit("Hello World!", 0, 100, 200) - assert.Equal(t, chartdraw.Box{Right: 84, Bottom: 15}, box) + assert.Equal(t, chartdraw.Box{Right: 84, Bottom: 15, IsSet: true}, + svgP.MeasureText("Hello World!", 0, style)) + assert.Equal(t, chartdraw.Box{Right: 99, Bottom: 14, IsSet: true}, + pngP.MeasureText("Hello World!", 0, style)) +} + +func TestPainterTextFit(t *testing.T) { + t.Parallel() + + p := NewPainter(PainterOptions{ + OutputFormat: ChartOutputSVG, + Width: 400, + Height: 300, + }) + fontStyle := FontStyle{ + FontSize: 12, + FontColor: chartdraw.ColorBlack, + Font: GetDefaultFont(), + } + + box := p.TextFit("Hello World!", 0, 20, 80, fontStyle) + assert.Equal(t, chartdraw.Box{Right: 45, Bottom: 35, IsSet: true}, box) + + box = p.TextFit("Hello World!", 0, 100, 200, fontStyle) + assert.Equal(t, chartdraw.Box{Right: 84, Bottom: 15, IsSet: true}, box) buf, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "HelloWorld!Hello World!", buf) + assertEqualSVG(t, "HelloWorld!Hello World!", buf) } func TestMultipleChartsOnPainter(t *testing.T) { t.Parallel() - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 800, Height: 600, }) - require.NoError(t, err) p.SetBackground(800, 600, drawing.ColorWhite) // set the space and theme for each chart topCenterPainter := p.Child(PainterBoxOption(chartdraw.NewBox(0, 0, 800, 300)), @@ -344,14 +391,14 @@ func TestMultipleChartsOnPainter(t *testing.T) { pieOpt := makeBasicPieChartOption() pieOpt.Legend.Show = False() - _, err = NewPieChart(bottomLeftPainter, pieOpt).Render() + err := bottomLeftPainter.PieChart(pieOpt) require.NoError(t, err) - _, err = NewBarChart(bottomRightPainter, makeBasicBarChartOption()).Render() + err = bottomRightPainter.BarChart(makeBasicBarChartOption()) require.NoError(t, err) - _, err = NewLineChart(topCenterPainter, makeBasicLineChartOption()).Render() + err = topCenterPainter.LineChart(makeBasicLineChartOption()) require.NoError(t, err) buf, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "Rainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%189168147126105846342210JanMarMayJulAugOctDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.312Line1.44k1.28k1.12k9608006404803201600ABCDEFG", buf) + assertEqualSVG(t, "Rainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%189168147126105846342210JanMarMayJulAugOctDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.312Line1.44k1.28k1.12k9608006404803201600ABCDEFG", buf) } diff --git a/pie_chart.go b/pie_chart.go index 96a9f94..bb48072 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -14,6 +14,16 @@ type pieChart struct { opt *PieChartOption } +// NewPieChartOptionWithData returns an initialized PieChartOption with the SeriesList set for the provided data slice. +func NewPieChartOptionWithData(data []float64) PieChartOption { + return PieChartOption{ + SeriesList: NewSeriesListPie(data), + Padding: defaultPadding, + Theme: GetDefaultTheme(), + Font: GetDefaultFont(), + } +} + type PieChartOption struct { // Theme specifies the colors used for the pie chart. Theme ColorPalette @@ -31,8 +41,8 @@ type PieChartOption struct { backgroundIsFilled bool } -// NewPieChart returns a pie chart renderer -func NewPieChart(p *Painter, opt PieChartOption) *pieChart { +// newPieChart returns a pie chart renderer. +func newPieChart(p *Painter, opt PieChartOption) *pieChart { return &pieChart{ p: p, opt: &opt, @@ -56,13 +66,12 @@ type sector struct { lineBranchY int lineEndX int lineEndY int - showLabel bool label string series Series color Color } -func NewSector(cx int, cy int, radius float64, labelRadius float64, value float64, currentValue float64, totalValue float64, labelLineLength int, label string, series Series, color Color) sector { +func newSector(cx int, cy int, radius float64, labelRadius float64, value float64, currentValue float64, totalValue float64, labelLineLength int, label string, series Series, color Color) sector { s := sector{} s.value = value s.percent = value / totalValue @@ -95,8 +104,9 @@ func NewSector(cx int, cy int, radius float64, labelRadius float64, value float6 s.lineEndY = s.lineBranchY s.series = series s.color = color - s.showLabel = series.Label.Show - s.label = NewPieLabelFormatter([]string{label}, series.Label.Formatter)(0, s.value, s.percent) + if !flagIs(false, series.Label.Show) { // only set the label if it's being rendered + s.label = labelFormatPie([]string{label}, series.Label.Formatter, 0, s.value, s.percent) + } return s } @@ -138,10 +148,7 @@ func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (B if len(series.Radius) != 0 { radiusValue = series.Radius } - value := float64(0) - for _, item := range series.Data { - value += item.Value - } + value := chartdraw.SumFloat64(series.Data...) values[index] = value total += value } @@ -177,7 +184,7 @@ func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (B color = theme.GetSeriesColor(1) } } - s := NewSector(cx, cy, radius, labelRadius, v, currentValue, total, labelLineWidth, seriesNames[index], series, color) + s := newSector(cx, cy, radius, labelRadius, v, currentValue, total, labelLineWidth, seriesNames[index], series, color) switch quadrant := s.quadrant; quadrant { case 1: quadrant1 = append([]sector{s}, quadrant1...) @@ -199,14 +206,12 @@ func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (B maxY := 0 minY := 0 for _, s := range sectors { - seriesPainter.OverrideDrawingStyle(chartdraw.Style{ - StrokeWidth: 1, - StrokeColor: s.color, - FillColor: s.color, - }) - seriesPainter.MoveTo(s.cx, s.cy) - seriesPainter.ArcTo(s.cx, s.cy, s.rx, s.ry, s.start, s.delta).LineTo(s.cx, s.cy).Close().FillStroke() - if !s.showLabel { + seriesPainter.moveTo(s.cx, s.cy) + seriesPainter.arcTo(s.cx, s.cy, s.rx, s.ry, s.start, s.delta) + seriesPainter.lineTo(s.cx, s.cy) + seriesPainter.close() + seriesPainter.fillStroke(s.color, s.color, 1) + if s.label == "" { continue } if currentQuadrant != s.quadrant { @@ -235,11 +240,11 @@ func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (B if prevY < minY { minY = prevY } - seriesPainter.MoveTo(s.lineStartX, s.lineStartY) - seriesPainter.LineTo(s.lineBranchX, s.lineBranchY) - seriesPainter.MoveTo(s.lineBranchX, s.lineBranchY) - seriesPainter.LineTo(s.lineEndX, s.lineEndY) - seriesPainter.Stroke() + seriesPainter.moveTo(s.lineStartX, s.lineStartY) + seriesPainter.lineTo(s.lineBranchX, s.lineBranchY) + seriesPainter.moveTo(s.lineBranchX, s.lineBranchY) + seriesPainter.lineTo(s.lineEndX, s.lineEndY) + seriesPainter.stroke(s.color, 1) textStyle := FontStyle{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, @@ -248,9 +253,8 @@ func (p *pieChart) render(result *defaultRenderResult, seriesList SeriesList) (B if !s.series.Label.FontStyle.FontColor.IsZero() { textStyle.FontColor = s.series.Label.FontStyle.FontColor } - seriesPainter.OverrideFontStyle(textStyle) - x, y := s.calculateTextXY(seriesPainter.MeasureText(s.label)) - seriesPainter.Text(s.label, x, y) + x, y := s.calculateTextXY(seriesPainter.MeasureText(s.label, 0, textStyle)) + seriesPainter.Text(s.label, x, y, 0, textStyle) } return p.p.box, nil } @@ -262,19 +266,19 @@ func (p *pieChart) Render() (Box, error) { } renderResult, err := defaultRender(p.p, defaultRenderOption{ - Theme: opt.Theme, - Padding: opt.Padding, - SeriesList: opt.SeriesList, - XAxis: XAxisOption{ + theme: opt.Theme, + padding: opt.Padding, + seriesList: opt.SeriesList, + xAxis: &XAxisOption{ Show: False(), }, - YAxis: []YAxisOption{ + yAxis: []YAxisOption{ { Show: False(), }, }, - Title: opt.Title, - Legend: opt.Legend, + title: opt.Title, + legend: &p.opt.Legend, backgroundIsFilled: opt.backgroundIsFilled, }) if err != nil { diff --git a/pie_chart_test.go b/pie_chart_test.go index 8ff602f..e5e00b2 100644 --- a/pie_chart_test.go +++ b/pie_chart_test.go @@ -4,6 +4,7 @@ import ( "strconv" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/go-analyze/charts/chartdraw/drawing" @@ -14,9 +15,7 @@ func makeBasicPieChartOption() PieChartOption { 1048, 735, 580, 484, 300, } return PieChartOption{ - SeriesList: NewPieSeriesList(values, PieSeriesOption{ - Label: SeriesLabel{Show: true}, - }), + SeriesList: NewSeriesListPie(values), Title: TitleOption{ Text: "Rainfall vs Evaporation", Subtext: "Fake Data", @@ -29,7 +28,7 @@ func makeBasicPieChartOption() PieChartOption { Left: 20, }, Legend: LegendOption{ - Vertical: true, + Vertical: True(), Data: []string{ "Search Engine", "Direct", "Email", "Union Ads", "Video Ads", }, @@ -38,6 +37,19 @@ func makeBasicPieChartOption() PieChartOption { } } +func TestNewPieChartOptionWithData(t *testing.T) { + t.Parallel() + + opt := NewPieChartOptionWithData([]float64{12, 24, 48}) + + assert.Len(t, opt.SeriesList, 3) + assert.Equal(t, ChartTypePie, opt.SeriesList[0].Type) + assert.Equal(t, defaultPadding, opt.Padding) + + p := NewPainter(PainterOptions{}) + assert.NoError(t, p.PieChart(opt)) +} + func TestPieChart(t *testing.T) { t.Parallel() @@ -53,13 +65,13 @@ func TestPieChart(t *testing.T) { name: "default", defaultTheme: true, makeOptions: makeBasicPieChartOption, - result: "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", + result: "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", }, { name: "themed", defaultTheme: false, makeOptions: makeBasicPieChartOption, - result: "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", + result: "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", }, { name: "lots_labels-sortedDescending", @@ -75,9 +87,9 @@ func TestPieChart(t *testing.T) { } return PieChartOption{ - SeriesList: NewPieSeriesList(values, PieSeriesOption{ + SeriesList: NewSeriesListPie(values, PieSeriesOption{ Label: SeriesLabel{ - Show: true, + Show: True(), Formatter: "{b} ({c} ≅ {d})", }, Radius: "200", @@ -122,7 +134,7 @@ func TestPieChart(t *testing.T) { }, } }, - result: "Germany (84358845 ≅ 18.8%)France (68070697 ≅ 15.17%)Italy (58850717 ≅ 13.12%)Netherlands (17947406 ≅ 4%)Romania (19051562 ≅ 4.24%)Poland (36753736 ≅ 8.19%)Spain (48059777 ≅ 10.71%)Belgium (11754004 ≅ 2.62%)Czech Republic (10827529 ≅ 2.41%)Sweden (10521556 ≅ 2.34%)Portugal (10467366 ≅ 2.33%)Greece (10394055 ≅ 2.31%)Hungary (9597085 ≅ 2.13%)Austria (9104772 ≅ 2.02%)Bulgaria (6447710 ≅ 1.43%)Denmark (5932654 ≅ 1.32%)Finland (5563970 ≅ 1.24%)Slovakia (5428792 ≅ 1.21%)Ireland (5194336 ≅ 1.15%)Croatia (3850894 ≅ 0.85%)Lithuania (2857279 ≅ 0.63%)Slovenia (2116792 ≅ 0.47%)Latvia (1883008 ≅ 0.41%)Estonia (1373101 ≅ 0.3%)Cyprus (920701 ≅ 0.2%)Luxembourg (660809 ≅ 0.14%)Malta (542051 ≅ 0.12%)", + result: "Germany (84358845 ≅ 18.8%)France (68070697 ≅ 15.17%)Italy (58850717 ≅ 13.12%)Netherlands (17947406 ≅ 4%)Romania (19051562 ≅ 4.24%)Poland (36753736 ≅ 8.19%)Spain (48059777 ≅ 10.71%)Belgium (11754004 ≅ 2.62%)Czech Republic (10827529 ≅ 2.41%)Sweden (10521556 ≅ 2.34%)Portugal (10467366 ≅ 2.33%)Greece (10394055 ≅ 2.31%)Hungary (9597085 ≅ 2.13%)Austria (9104772 ≅ 2.02%)Bulgaria (6447710 ≅ 1.43%)Denmark (5932654 ≅ 1.32%)Finland (5563970 ≅ 1.24%)Slovakia (5428792 ≅ 1.21%)Ireland (5194336 ≅ 1.15%)Croatia (3850894 ≅ 0.85%)Lithuania (2857279 ≅ 0.63%)Slovenia (2116792 ≅ 0.47%)Latvia (1883008 ≅ 0.41%)Estonia (1373101 ≅ 0.3%)Cyprus (920701 ≅ 0.2%)Luxembourg (660809 ≅ 0.14%)Malta (542051 ≅ 0.12%)", }, { name: "lots_labels-unsorted", @@ -138,9 +150,9 @@ func TestPieChart(t *testing.T) { } return PieChartOption{ - SeriesList: NewPieSeriesList(values, PieSeriesOption{ + SeriesList: NewSeriesListPie(values, PieSeriesOption{ Label: SeriesLabel{ - Show: true, + Show: True(), Formatter: "{b} ({c} ≅ {d})", }, Radius: "200", @@ -185,7 +197,7 @@ func TestPieChart(t *testing.T) { }, } }, - result: "France (68070697 ≅ 15.17%)Finland (5563970 ≅ 1.24%)Estonia (1373101 ≅ 0.3%)Denmark (5932654 ≅ 1.32%)Czech Republic (10827529 ≅ 2.41%)Cyprus (920701 ≅ 0.2%)Croatia (3850894 ≅ 0.85%)Bulgaria (6447710 ≅ 1.43%)Belgium (11754004 ≅ 2.62%)Austria (9104772 ≅ 2.02%)Germany (84358845 ≅ 18.8%)Greece (10394055 ≅ 2.31%)Hungary (9597085 ≅ 2.13%)Poland (36753736 ≅ 8.19%)Netherlands (17947406 ≅ 4%)Malta (542051 ≅ 0.12%)Luxembourg (660809 ≅ 0.14%)Lithuania (2857279 ≅ 0.63%)Latvia (1883008 ≅ 0.41%)Italy (58850717 ≅ 13.12%)Ireland (5194336 ≅ 1.15%)Portugal (10467366 ≅ 2.33%)Romania (19051562 ≅ 4.24%)Slovakia (5428792 ≅ 1.21%)Slovenia (2116792 ≅ 0.47%)Spain (48059777 ≅ 10.71%)Sweden (10521556 ≅ 2.34%)", + result: "France (68070697 ≅ 15.17%)Finland (5563970 ≅ 1.24%)Estonia (1373101 ≅ 0.3%)Denmark (5932654 ≅ 1.32%)Czech Republic (10827529 ≅ 2.41%)Cyprus (920701 ≅ 0.2%)Croatia (3850894 ≅ 0.85%)Bulgaria (6447710 ≅ 1.43%)Belgium (11754004 ≅ 2.62%)Austria (9104772 ≅ 2.02%)Germany (84358845 ≅ 18.8%)Greece (10394055 ≅ 2.31%)Hungary (9597085 ≅ 2.13%)Poland (36753736 ≅ 8.19%)Netherlands (17947406 ≅ 4%)Malta (542051 ≅ 0.12%)Luxembourg (660809 ≅ 0.14%)Lithuania (2857279 ≅ 0.63%)Latvia (1883008 ≅ 0.41%)Italy (58850717 ≅ 13.12%)Ireland (5194336 ≅ 1.15%)Portugal (10467366 ≅ 2.33%)Romania (19051562 ≅ 4.24%)Slovakia (5428792 ≅ 1.21%)Slovenia (2116792 ≅ 0.47%)Spain (48059777 ≅ 10.71%)Sweden (10521556 ≅ 2.34%)", }, { name: "100labels-sorted", @@ -201,10 +213,7 @@ func TestPieChart(t *testing.T) { } return PieChartOption{ - SeriesList: NewPieSeriesList(values, PieSeriesOption{ - Label: SeriesLabel{ - Show: true, - }, + SeriesList: NewSeriesListPie(values, PieSeriesOption{ Radius: "200", }), Padding: Box{ @@ -219,7 +228,7 @@ func TestPieChart(t *testing.T) { }, } }, - result: "Label 25: 1%Label 24: 1%Label 23: 1%Label 22: 1%Label 21: 1%Label 20: 1%Label 19: 1%Label 18: 1%Label 17: 1%Label 16: 1%Label 15: 1%Label 14: 1%Label 13: 1%Label 12: 1%Label 11: 1%Label 10: 1%Label 9: 1%Label 8: 1%Label 7: 1%Label 6: 1%Label 5: 1%Label 4: 1%Label 3: 1%Label 2: 1%Label 1: 1%Label 26: 1%Label 27: 1%Label 28: 1%Label 29: 1%Label 30: 1%Label 31: 1%Label 32: 1%Label 33: 1%Label 34: 1%Label 35: 1%Label 36: 1%Label 37: 1%Label 38: 1%Label 39: 1%Label 40: 1%Label 41: 1%Label 42: 1%Label 43: 1%Label 44: 1%Label 45: 1%Label 46: 1%Label 47: 1%Label 48: 1%Label 49: 1%Label 50: 1%Label 75: 1%Label 74: 1%Label 73: 1%Label 72: 1%Label 71: 1%Label 70: 1%Label 69: 1%Label 68: 1%Label 67: 1%Label 66: 1%Label 65: 1%Label 64: 1%Label 63: 1%Label 62: 1%Label 61: 1%Label 60: 1%Label 59: 1%Label 58: 1%Label 57: 1%Label 56: 1%Label 55: 1%Label 54: 1%Label 53: 1%Label 52: 1%Label 51: 1%Label 76: 1%Label 77: 1%Label 78: 1%Label 79: 1%Label 80: 1%Label 81: 1%Label 82: 1%Label 83: 1%Label 84: 1%Label 85: 1%Label 86: 1%Label 87: 1%Label 88: 1%Label 89: 1%Label 90: 1%Label 91: 1%Label 92: 1%Label 93: 1%Label 94: 1%Label 95: 1%Label 96: 1%Label 97: 1%Label 98: 1%Label 99: 1%Label 100: 1%", + result: "Label 25: 1%Label 24: 1%Label 23: 1%Label 22: 1%Label 21: 1%Label 20: 1%Label 19: 1%Label 18: 1%Label 17: 1%Label 16: 1%Label 15: 1%Label 14: 1%Label 13: 1%Label 12: 1%Label 11: 1%Label 10: 1%Label 9: 1%Label 8: 1%Label 7: 1%Label 6: 1%Label 5: 1%Label 4: 1%Label 3: 1%Label 2: 1%Label 1: 1%Label 26: 1%Label 27: 1%Label 28: 1%Label 29: 1%Label 30: 1%Label 31: 1%Label 32: 1%Label 33: 1%Label 34: 1%Label 35: 1%Label 36: 1%Label 37: 1%Label 38: 1%Label 39: 1%Label 40: 1%Label 41: 1%Label 42: 1%Label 43: 1%Label 44: 1%Label 45: 1%Label 46: 1%Label 47: 1%Label 48: 1%Label 49: 1%Label 50: 1%Label 75: 1%Label 74: 1%Label 73: 1%Label 72: 1%Label 71: 1%Label 70: 1%Label 69: 1%Label 68: 1%Label 67: 1%Label 66: 1%Label 65: 1%Label 64: 1%Label 63: 1%Label 62: 1%Label 61: 1%Label 60: 1%Label 59: 1%Label 58: 1%Label 57: 1%Label 56: 1%Label 55: 1%Label 54: 1%Label 53: 1%Label 52: 1%Label 51: 1%Label 76: 1%Label 77: 1%Label 78: 1%Label 79: 1%Label 80: 1%Label 81: 1%Label 82: 1%Label 83: 1%Label 84: 1%Label 85: 1%Label 86: 1%Label 87: 1%Label 88: 1%Label 89: 1%Label 90: 1%Label 91: 1%Label 92: 1%Label 93: 1%Label 94: 1%Label 95: 1%Label 96: 1%Label 97: 1%Label 98: 1%Label 99: 1%Label 100: 1%", }, { name: "fix_label_pos", @@ -235,9 +244,9 @@ func TestPieChart(t *testing.T) { 25804, 25730, 24438, 23782, 22896, 21404, 428978, } return PieChartOption{ - SeriesList: NewPieSeriesList(values, PieSeriesOption{ + SeriesList: NewSeriesListPie(values, PieSeriesOption{ Label: SeriesLabel{ - Show: true, + Show: True(), Formatter: "{b} ({c} ≅ {d})", }, Radius: "150", @@ -264,7 +273,7 @@ func TestPieChart(t *testing.T) { }, } }, - result: "Fix label K (72586)C (149086 ≅ 5.04%)B (185596 ≅ 6.28%)A (397594 ≅ 13.45%)D (144258 ≅ 4.88%)E (120194 ≅ 4.06%)F (117514 ≅ 3.97%)G (99412 ≅ 3.36%)H (91135 ≅ 3.08%)I (87282 ≅ 2.95%)J (76790 ≅ 2.59%)Z (29608 ≅ 1%)Y (32566 ≅ 1.1%)X (32788 ≅ 1.1%)W (33784 ≅ 1.14%)V (36644 ≅ 1.24%)U (37414 ≅ 1.26%)T (39476 ≅ 1.33%)S (41242 ≅ 1.39%)R (51460 ≅ 1.74%)Q (53746 ≅ 1.81%)P (54792 ≅ 1.85%)O (55486 ≅ 1.87%)N (56306 ≅ 1.9%)M (58270 ≅ 1.97%)L (58818 ≅ 1.99%)K (72586 ≅ 2.45%)AA (29558 ≅ 1%)AB (29384 ≅ 0.99%)AC (28166 ≅ 0.95%)AD (26998 ≅ 0.91%)AE (26948 ≅ 0.91%)AF (26054 ≅ 0.88%)AG (25804 ≅ 0.87%)AH (25730 ≅ 0.87%)AI (24438 ≅ 0.82%)AJ (23782 ≅ 0.8%)AK (22896 ≅ 0.77%)AL (21404 ≅ 0.72%)AM (428978 ≅ 14.52%)", + result: "Fix label K (72586)C (149086 ≅ 5.04%)B (185596 ≅ 6.28%)A (397594 ≅ 13.45%)D (144258 ≅ 4.88%)E (120194 ≅ 4.06%)F (117514 ≅ 3.97%)G (99412 ≅ 3.36%)H (91135 ≅ 3.08%)I (87282 ≅ 2.95%)J (76790 ≅ 2.59%)Z (29608 ≅ 1%)Y (32566 ≅ 1.1%)X (32788 ≅ 1.1%)W (33784 ≅ 1.14%)V (36644 ≅ 1.24%)U (37414 ≅ 1.26%)T (39476 ≅ 1.33%)S (41242 ≅ 1.39%)R (51460 ≅ 1.74%)Q (53746 ≅ 1.81%)P (54792 ≅ 1.85%)O (55486 ≅ 1.87%)N (56306 ≅ 1.9%)M (58270 ≅ 1.97%)L (58818 ≅ 1.99%)K (72586 ≅ 2.45%)AA (29558 ≅ 1%)AB (29384 ≅ 0.99%)AC (28166 ≅ 0.95%)AD (26998 ≅ 0.91%)AE (26948 ≅ 0.91%)AF (26054 ≅ 0.88%)AG (25804 ≅ 0.87%)AH (25730 ≅ 0.87%)AI (24438 ≅ 0.82%)AJ (23782 ≅ 0.8%)AK (22896 ≅ 0.77%)AL (21404 ≅ 0.72%)AM (428978 ≅ 14.52%)", }, { name: "custom_fonts", @@ -279,7 +288,7 @@ func TestPieChart(t *testing.T) { opt.Title.FontStyle = customFont return opt }, - result: "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", + result: "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", }, { name: "legend_bottom_right", @@ -292,7 +301,7 @@ func TestPieChart(t *testing.T) { } return opt }, - result: "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", + result: "Search EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", }, } @@ -310,8 +319,7 @@ func TestPieChart(t *testing.T) { } if tt.defaultTheme { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) validatePieChartRender(t, p.Child(PainterPaddingOption(Box{ Left: 20, @@ -322,14 +330,12 @@ func TestPieChart(t *testing.T) { }) } else { t.Run(strconv.Itoa(i)+"-"+tt.name+"-painter", func(t *testing.T) { - p, err := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) - require.NoError(t, err) + p := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) validatePieChartRender(t, p, tt.makeOptions(), tt.result) }) t.Run(strconv.Itoa(i)+"-"+tt.name+"-options", func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) opt := tt.makeOptions() opt.Theme = GetTheme(ThemeVividDark) @@ -342,7 +348,7 @@ func TestPieChart(t *testing.T) { func validatePieChartRender(t *testing.T, p *Painter, opt PieChartOption, expectedResult string) { t.Helper() - _, err := NewPieChart(p, opt).Render() + err := p.PieChart(opt) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) diff --git a/radar_chart.go b/radar_chart.go index 0e684fe..3b9d7c8 100644 --- a/radar_chart.go +++ b/radar_chart.go @@ -24,6 +24,17 @@ type RadarIndicator struct { Min float64 } +// NewRadarChartOptionWithData returns an initialized RadarChartOption with the SeriesList set for the provided data slice. +func NewRadarChartOptionWithData(data [][]float64, names []string, values []float64) RadarChartOption { + return RadarChartOption{ + SeriesList: NewSeriesListRadar(data), + RadarIndicators: NewRadarIndicators(names, values), + Padding: defaultPadding, + Theme: GetDefaultTheme(), + Font: GetDefaultFont(), + } +} + type RadarChartOption struct { // Theme specifies the colors used for the pie chart. Theme ColorPalette @@ -58,8 +69,8 @@ func NewRadarIndicators(names []string, values []float64) []RadarIndicator { return indicators } -// NewRadarChart returns a radar chart renderer -func NewRadarChart(p *Painter, opt RadarChartOption) *radarChart { +// newRadarChart returns a radar chart renderer. +func newRadarChart(p *Painter, opt RadarChartOption) *radarChart { return &radarChart{ p: p, opt: &opt, @@ -76,8 +87,8 @@ func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) maxValues := make([]float64, len(indicators)) for _, series := range seriesList { for index, item := range series.Data { - if index < len(maxValues) && item.Value > maxValues[index] { - maxValues[index] = item.Value + if index < len(maxValues) && item > maxValues[index] { + maxValues[index] = item } } } @@ -106,30 +117,26 @@ func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) divideRadius := float64(int(radius / float64(divideCount))) radius = divideRadius * float64(divideCount) - seriesPainter.OverrideDrawingStyle(chartdraw.Style{ - StrokeColor: theme.GetAxisSplitLineColor(), - StrokeWidth: 1, - }) center := Point{X: cx, Y: cy} for i := 0; i < divideCount; i++ { - seriesPainter.Polygon(center, divideRadius*float64(i+1), sides) + seriesPainter.Polygon(center, divideRadius*float64(i+1), sides, theme.GetAxisSplitLineColor(), 1) } points := getPolygonPoints(center, radius, sides) for _, p := range points { - seriesPainter.MoveTo(center.X, center.Y) - seriesPainter.LineTo(p.X, p.Y) - seriesPainter.Stroke() + seriesPainter.moveTo(center.X, center.Y) + seriesPainter.lineTo(p.X, p.Y) + seriesPainter.stroke(theme.GetAxisSplitLineColor(), 1) } - seriesPainter.OverrideFontStyle(FontStyle{ + fontStyle := FontStyle{ FontColor: theme.GetTextColor(), FontSize: labelFontSize, Font: opt.Font, - }) + } offset := 5 // text generation for index, p := range points { name := indicators[index].Name - b := seriesPainter.MeasureText(name) + b := seriesPainter.MeasureText(name, 0, fontStyle) isXCenter := p.X == center.X isYCenter := p.Y == center.Y isRight := p.X > center.X @@ -161,7 +168,7 @@ func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) if isLeft { x -= b.Width() + offset } - seriesPainter.Text(name, x, y) + seriesPainter.Text(name, x, y, 0, fontStyle) } // radar chart @@ -177,7 +184,7 @@ func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) var percent float64 offset := indicator.Max - indicator.Min if offset > 0 { - percent = (item.Value - indicator.Min) / offset + percent = (item - indicator.Min) / offset } r := percent * radius p := getPolygonPoint(center, r, angles[j]) @@ -189,28 +196,15 @@ func (r *radarChart) render(result *defaultRenderResult, seriesList SeriesList) dotFillColor = color } linePoints = append(linePoints, linePoints[0]) - seriesPainter.OverrideDrawingStyle(chartdraw.Style{ - StrokeColor: color, - StrokeWidth: defaultStrokeWidth, - DotWidth: defaultDotWidth, - DotColor: color, - FillColor: color.WithAlpha(20), - }) - seriesPainter.LineStroke(linePoints). - FillArea(linePoints) - dotWith := 2.0 - seriesPainter.OverrideDrawingStyle(chartdraw.Style{ - StrokeWidth: defaultStrokeWidth, - StrokeColor: color, - FillColor: dotFillColor, - }) + seriesPainter.LineStroke(linePoints, color, defaultStrokeWidth) + seriesPainter.FillArea(linePoints, color.WithAlpha(20)) + dotWith := defaultDotWidth for index, point := range linePoints { - seriesPainter.Circle(dotWith, point.X, point.Y) - seriesPainter.FillStroke() - if series.Label.Show && index < len(series.Data) { - value := humanize.FtoaWithDigits(series.Data[index].Value, 2) - b := seriesPainter.MeasureText(value) - seriesPainter.Text(value, point.X-b.Width()/2, point.Y) + seriesPainter.Circle(dotWith, point.X, point.Y, dotFillColor, color, defaultStrokeWidth) + if flagIs(true, series.Label.Show) && index < len(series.Data) { + value := humanize.FtoaWithDigits(series.Data[index], 2) + b := seriesPainter.MeasureText(value, 0, fontStyle) + seriesPainter.Text(value, point.X-b.Width()/2, point.Y, 0, fontStyle) } } } @@ -226,19 +220,19 @@ func (r *radarChart) Render() (Box, error) { } renderResult, err := defaultRender(p, defaultRenderOption{ - Theme: opt.Theme, - Padding: opt.Padding, - SeriesList: opt.SeriesList, - XAxis: XAxisOption{ + theme: opt.Theme, + padding: opt.Padding, + seriesList: opt.SeriesList, + xAxis: &XAxisOption{ Show: False(), }, - YAxis: []YAxisOption{ + yAxis: []YAxisOption{ { Show: False(), }, }, - Title: opt.Title, - Legend: opt.Legend, + title: opt.Title, + legend: &r.opt.Legend, backgroundIsFilled: opt.backgroundIsFilled, }) if err != nil { diff --git a/radar_chart_test.go b/radar_chart_test.go index b5fae3f..f9ab8e1 100644 --- a/radar_chart_test.go +++ b/radar_chart_test.go @@ -4,6 +4,7 @@ import ( "strconv" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -13,7 +14,7 @@ func makeBasicRadarChartOption() RadarChartOption { {5000, 14000, 28000, 26000, 42000, 21000}, } return RadarChartOption{ - SeriesList: NewSeriesListDataFromValues(values, ChartTypeRadar), + SeriesList: NewSeriesListRadar(values), Title: TitleOption{ Text: "Basic Radar Chart", }, @@ -35,6 +36,31 @@ func makeBasicRadarChartOption() RadarChartOption { } } +func TestNewRadarChartOptionWithData(t *testing.T) { + t.Parallel() + + opt := NewRadarChartOptionWithData([][]float64{ + {4200, 3000, 20000, 35000, 50000, 18000}, + {5000, 14000, 28000, 26000, 42000, 21000}, + }, []string{ + "Sales", + "Administration", + "Information Technology", + "Customer Support", + "Development", + "Marketing", + }, []float64{ + 6500, 16000, 30000, 38000, 52000, 25000, + }) + + assert.Len(t, opt.SeriesList, 2) + assert.Equal(t, ChartTypeRadar, opt.SeriesList[0].Type) + assert.Equal(t, defaultPadding, opt.Padding) + + p := NewPainter(PainterOptions{}) + assert.NoError(t, p.RadarChart(opt)) +} + func TestRadarChart(t *testing.T) { t.Parallel() @@ -48,13 +74,13 @@ func TestRadarChart(t *testing.T) { name: "default", defaultTheme: true, makeOptions: makeBasicRadarChartOption, - result: "Allocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", + result: "Allocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", }, { name: "themed", defaultTheme: false, makeOptions: makeBasicRadarChartOption, - result: "Allocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", + result: "Allocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", }, } @@ -66,8 +92,7 @@ func TestRadarChart(t *testing.T) { } if tt.defaultTheme { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) validateRadarChartRender(t, p.Child(PainterPaddingOption(Box{ Left: 20, @@ -78,14 +103,12 @@ func TestRadarChart(t *testing.T) { }) } else { t.Run(strconv.Itoa(i)+"-"+tt.name+"-painter", func(t *testing.T) { - p, err := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) - require.NoError(t, err) + p := NewPainter(painterOptions, PainterThemeOption(GetTheme(ThemeVividDark))) validateRadarChartRender(t, p, tt.makeOptions(), tt.result) }) t.Run(strconv.Itoa(i)+"-"+tt.name+"-options", func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) opt := tt.makeOptions() opt.Theme = GetTheme(ThemeVividDark) @@ -98,7 +121,7 @@ func TestRadarChart(t *testing.T) { func validateRadarChartRender(t *testing.T, p *Painter, opt RadarChartOption, expectedResult string) { t.Helper() - _, err := NewRadarChart(p, opt).Render() + err := p.RadarChart(opt) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) diff --git a/range.go b/range.go index a892cda..4dab171 100644 --- a/range.go +++ b/range.go @@ -11,22 +11,28 @@ const rangeMaxPaddingPercentMax = 20.0 const zeroSpanAdjustment = 1 // Adjustment type axisRange struct { - p *Painter - divideCount int - min float64 - max float64 - size int + p *Painter + valueFormatter ValueFormatter + divideCount int + min float64 + max float64 + size int } -// NewRange returns a range of data for an axis, this range will have padding to better present the data. -func NewRange(painter *Painter, size, divideCount int, min, max, minPaddingScale, maxPaddingScale float64) axisRange { +// newRange returns a range of data for an axis, this range will have padding to better present the data. +func newRange(painter *Painter, valueFormatter ValueFormatter, + size, divideCount int, min, max, minPaddingScale, maxPaddingScale float64) axisRange { min, max = padRange(divideCount, min, max, minPaddingScale, maxPaddingScale) + if valueFormatter == nil { + valueFormatter = defaultValueFormatter + } return axisRange{ - p: painter, - divideCount: divideCount, - min: min, - max: max, - size: size, + p: painter, + valueFormatter: valueFormatter, + divideCount: divideCount, + min: min, + max: max, + size: size, } } @@ -159,14 +165,10 @@ func friendlyRound(val, increment, defaultMultiplier, minMultiplier, maxMultipli // Values returns values of range func (r axisRange) Values() []string { offset := (r.max - r.min) / float64(r.divideCount-1) - formatter := defaultValueFormatter - if r.p != nil && r.p.valueFormatter != nil { - formatter = r.p.valueFormatter - } values := make([]string, r.divideCount) for i := 0; i < r.divideCount; i++ { v := r.min + float64(i)*offset - values[i] = formatter(v) + values[i] = r.valueFormatter(v) } return values } diff --git a/series.go b/series.go index 39a8231..a135372 100644 --- a/series.go +++ b/series.go @@ -2,46 +2,32 @@ package charts import ( "math" + "sort" "strings" "github.com/dustin/go-humanize" ) -// TODO - reconsider struct for v0.4.0 -type SeriesData struct { - // Value is the retained value for the data. - Value float64 -} - -// NewSeriesListDataFromValues returns a series list -func NewSeriesListDataFromValues(values [][]float64, chartType ...string) SeriesList { +// newSeriesListFromValues returns a series list for the given values and chart type. +func newSeriesListFromValues(values [][]float64, chartType string, label SeriesLabel, names []string, + radius string, markPoint SeriesMarkPoint, markLine SeriesMarkLine) SeriesList { seriesList := make(SeriesList, len(values)) for index, value := range values { - seriesList[index] = NewSeriesFromValues(value, chartType...) - } - return seriesList -} - -// NewSeriesFromValues returns a series -func NewSeriesFromValues(values []float64, chartType ...string) Series { - s := Series{ - Data: NewSeriesDataFromValues(values), - } - if len(chartType) != 0 { - s.Type = chartType[0] - } - return s -} - -// NewSeriesDataFromValues return a series data -func NewSeriesDataFromValues(values []float64) []SeriesData { - data := make([]SeriesData, len(values)) - for index, value := range values { - data[index] = SeriesData{ - Value: value, + s := Series{ + index: index, + Data: value, + Type: chartType, + Label: label, + Radius: radius, + MarkPoint: markPoint, + MarkLine: markLine, + } + if index < len(names) { + s.Name = names[index] } + seriesList[index] = s } - return data + return seriesList } type SeriesLabel struct { @@ -52,8 +38,8 @@ type SeriesLabel struct { Formatter string // FontStyle specifies the font and style for the label. FontStyle FontStyle - // Show flag for label - Show bool + // Show flag for label, if unset the behavior will be defaulted based on the chart. + Show *bool // Distance to the host graphic element. Distance int // TODO - do we want to replace with just Offset? // Position defines the label position. @@ -91,7 +77,7 @@ type Series struct { // Type is the type of series, it can be "line", "bar" or "pie". Default value is "line". Type string // Data provides the series data list. - Data []SeriesData + Data []float64 // YAxisIndex is the index for the axis, it must be 0 or 1. YAxisIndex int // Label provides the series labels. @@ -104,10 +90,6 @@ type Series struct { MarkPoint SeriesMarkPoint // MarkLine provides a series for mark lines. MarkLine SeriesMarkLine - // Max value of series - Min *float64 - // Min value of series - Max *float64 } // SeriesList is a list of series to be rendered on the chart, typically constructed using NewSeriesListLine, @@ -134,36 +116,98 @@ func (sl SeriesList) Filter(chartType string) SeriesList { return arr } -// GetMinMax get max and min value of series list -func (sl SeriesList) GetMinMax(axisIndex int) (float64, float64) { +func (sl SeriesList) getYAxisCount() int { + for _, series := range sl { + if series.YAxisIndex == 1 { + return 2 + } else if series.YAxisIndex != 0 { + return -1 + } + } + return 1 +} + +// GetMinMax get max and min value of series list for a given y-axis index (either 0 or 1). +func (sl SeriesList) GetMinMax(yaxisIndex int) (float64, float64) { min := math.MaxFloat64 max := -math.MaxFloat64 for _, series := range sl { - if series.YAxisIndex != axisIndex { + if series.YAxisIndex != yaxisIndex { continue } for _, item := range series.Data { - if item.Value == GetNullValue() { + if item == GetNullValue() { continue } - if item.Value > max { - max = item.Value + if item > max { + max = item } - if item.Value < min { - min = item.Value + if item < min { + min = item } } } return min, max } +// LineSeriesOption provides series customization for NewSeriesListLine. +type LineSeriesOption struct { + Label SeriesLabel + Names []string + MarkPoint SeriesMarkPoint + MarkLine SeriesMarkLine +} + +// NewSeriesListLine builds a SeriesList for a line chart. The first dimension of the values indicates the population +// of the data, while the second dimension provides the samples for the population. +func NewSeriesListLine(values [][]float64, opts ...LineSeriesOption) SeriesList { + var opt LineSeriesOption + if len(opts) != 0 { + opt = opts[0] + } + return newSeriesListFromValues(values, ChartTypeLine, + opt.Label, opt.Names, "", opt.MarkPoint, opt.MarkLine) +} + +// BarSeriesOption provides series customization for NewSeriesListBar or NewSeriesListHorizontalBar. +type BarSeriesOption struct { + Label SeriesLabel + Names []string + MarkPoint SeriesMarkPoint + MarkLine SeriesMarkLine +} + +// NewSeriesListBar builds a SeriesList for a bar chart. The first dimension of the values indicates the population +// of the data, while the second dimension provides the samples for the population (on the X-Axis). +func NewSeriesListBar(values [][]float64, opts ...BarSeriesOption) SeriesList { + var opt BarSeriesOption + if len(opts) != 0 { + opt = opts[0] + } + return newSeriesListFromValues(values, ChartTypeBar, + opt.Label, opt.Names, "", opt.MarkPoint, opt.MarkLine) +} + +// NewSeriesListHorizontalBar builds a SeriesList for a horizontal bar chart. Horizontal bar charts are unique in that +// these Series can not be combined with any other chart type. +func NewSeriesListHorizontalBar(values [][]float64, opts ...BarSeriesOption) SeriesList { + var opt BarSeriesOption + if len(opts) != 0 { + opt = opts[0] + } + return newSeriesListFromValues(values, ChartTypeHorizontalBar, + opt.Label, opt.Names, "", opt.MarkPoint, opt.MarkLine) +} + +// PieSeriesOption provides series customization for NewSeriesListPie. type PieSeriesOption struct { Radius string Label SeriesLabel Names []string } -func NewPieSeriesList(values []float64, opts ...PieSeriesOption) SeriesList { +// NewSeriesListPie builds a SeriesList for a pie chart. +func NewSeriesListPie(values []float64, opts ...PieSeriesOption) SeriesList { result := make([]Series, len(values)) var opt PieSeriesOption if len(opts) != 0 { @@ -175,12 +219,8 @@ func NewPieSeriesList(values []float64, opts ...PieSeriesOption) SeriesList { name = opt.Names[index] } s := Series{ - Type: ChartTypePie, - Data: []SeriesData{ - { - Value: v, - }, - }, + Type: ChartTypePie, + Data: []float64{v}, Radius: opt.Radius, Label: opt.Label, Name: name, @@ -190,48 +230,170 @@ func NewPieSeriesList(values []float64, opts ...PieSeriesOption) SeriesList { return result } -// TODO - lower case field names +// RadarSeriesOption provides series customization for NewSeriesListRadar. +type RadarSeriesOption struct { + Label SeriesLabel + Names []string +} + +// NewSeriesListRadar builds a SeriesList for a Radar chart. +func NewSeriesListRadar(values [][]float64, opts ...RadarSeriesOption) SeriesList { + var opt RadarSeriesOption + if len(opts) != 0 { + opt = opts[0] + } + return newSeriesListFromValues(values, ChartTypeRadar, + opt.Label, opt.Names, "", SeriesMarkPoint{}, SeriesMarkLine{}) +} + +// FunnelSeriesOption provides series customization for NewSeriesListFunnel. +type FunnelSeriesOption struct { + Label SeriesLabel + Names []string +} + +// NewSeriesListFunnel builds a series list for funnel charts. +func NewSeriesListFunnel(values []float64, opts ...FunnelSeriesOption) SeriesList { + var opt FunnelSeriesOption + if len(opts) != 0 { + opt = opts[0] + } + seriesList := make(SeriesList, len(values)) + for index, value := range values { + name := "" + if index < len(opt.Names) { + name = opt.Names[index] + } + seriesList[index] = Series{ + Data: []float64{value}, + Type: ChartTypeFunnel, + Label: opt.Label, + Name: name, + } + } + return seriesList +} + type seriesSummary struct { - // The index of max value + // Max is the maximum value in the series. + Max float64 + // MaxIndex is the index of the maximum value in the series. If the series is empty this value will be -1. MaxIndex int - // The max value - MaxValue float64 - // The index of min value + // Min is the minimum value in the series. + Min float64 + // MinIndex is the index of the minimum value in the series. If the series is empty this value will be -1. MinIndex int - // The min value - MinValue float64 - // THe average value - AverageValue float64 + // Average is the mean of all values in the series. + Average float64 + // Median is the middle value of the series when it is sorted in ascending order. + Median float64 + // StandardDeviation is a measure of the amount of variation or dispersion of a set of values. A low standard + // deviation indicates that the values tend to be close to the mean of the set, while a high standard deviation + // indicates that the values are spread out over a wider range. + StandardDeviation float64 + // Skewness measures the asymmetry of the distribution of values in the series around the mean. If skewness is zero, + // the data are perfectly symmetrical, although not necessarily normal. If skewness is positive, the data is skewed + // right, meaning that the right tail is longer or fatter than the left. If skewness is negative, the data is skewed + // left, meaning that the left tail is longer or fatter than the right. + Skewness float64 + // Kurtosis is a measure of the "tailedness" of the probability distribution of a real-valued random variable. + // High kurtosis in a data set is an indicator of substantial outliers. A negative kurtosis indicates a relatively flat distribution. + Kurtosis float64 } // Summary returns numeric summary of series values (population statistics). func (s *Series) Summary() seriesSummary { - minIndex := -1 - maxIndex := -1 + n := float64(len(s.Data)) + if n == 0 { + return seriesSummary{ + MinIndex: -1, + MaxIndex: -1, + } + } + + // Initialize tracking variables + var minIndex, maxIndex int minValue := math.MaxFloat64 maxValue := -math.MaxFloat64 - sum := float64(0) - for j, item := range s.Data { - if item.Value < minValue { - minIndex = j - minValue = item.Value + // For sums of powers: + var sum, sumSq, sumCu, sumQd float64 + + // Single pass to gather everything we need + for i, x := range s.Data { + if x < minValue { + minValue = x + minIndex = i } - if item.Value > maxValue { - maxIndex = j - maxValue = item.Value + if x > maxValue { + maxValue = x + maxIndex = i } - sum += item.Value + + sum += x + sumSq += x * x + sumCu += x * x * x + sumQd += x * x * x * x + } + + // Compute average (mean) + mean := sum / n + // Compute population variance = E[X^2] - (E[X])^2 + variance := sumSq/n - mean*mean + stdDev := math.Sqrt(variance) + // Compute median: copy the data and sort + sortedData := make([]float64, len(s.Data)) + copy(sortedData, s.Data) + sort.Float64s(sortedData) + var median float64 + mid := len(sortedData) / 2 + if len(sortedData)%2 == 0 { + median = (sortedData[mid-1] + sortedData[mid]) / 2.0 + } else { + median = sortedData[mid] + } + + // Compute population skewness: + // thirdCentral = Σ x^3 - 3μΣ x^2 + 3μ^2Σ x - nμ^3 + // skewness = thirdCentral / (n * σ^3) + var skewness float64 + if stdDev != 0 { // zero stdDev will result in a divide by zero + thirdCentral := sumCu - 3*mean*sumSq + 3*mean*mean*sum - n*mean*mean*mean + skewness = thirdCentral / (n * stdDev * stdDev * stdDev) } + + // Compute population excess kurtosis: + // fourthCentral = Σ x^4 + // - 4μΣ x^3 + // + 6μ^2Σ x^2 + // - 4μ^3Σ x + // + nμ^4 + // kurtosis = (fourthCentral / (n * σ^4)) + // We don't subtract 3 (excess kurtosis) in our implementation. + var kurtosis float64 + if variance != 0 { + fourthCentral := sumQd - + 4*mean*sumCu + + 6*mean*mean*sumSq - + 4*mean*mean*mean*sum + + n*mean*mean*mean*mean + + kurtosis = fourthCentral / (n * variance * variance) + } // else, all points might be the same => kurtosis is undefined + return seriesSummary{ - MaxIndex: maxIndex, - MaxValue: maxValue, - MinIndex: minIndex, - MinValue: minValue, - AverageValue: sum / float64(len(s.Data)), + Max: maxValue, + MaxIndex: maxIndex, + Min: minValue, + MinIndex: minIndex, + Average: mean, + Median: median, + StandardDeviation: stdDev, + Skewness: skewness, + Kurtosis: kurtosis, } } -// Names returns the names of series list +// Names returns the names of series list. func (sl SeriesList) Names() []string { names := make([]string, len(sl)) for index, s := range sl { @@ -240,35 +402,32 @@ func (sl SeriesList) Names() []string { return names } -// LabelFormatter label formatter -type LabelFormatter func(index int, value float64, percent float64) string - -// NewPieLabelFormatter returns a pie label formatter -func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter { +// labelFormatPie formats the value for a pie chart label. +func labelFormatPie(seriesNames []string, layout string, index int, value float64, percent float64) string { if len(layout) == 0 { layout = "{b}: {d}" } - return NewLabelFormatter(seriesNames, layout) + return newLabelFormatter(seriesNames, layout)(index, value, percent) } -// NewFunnelLabelFormatter returns a funner label formatter -func NewFunnelLabelFormatter(seriesNames []string, layout string) LabelFormatter { +// labelFormatFunnel formats the value for a funnel chart label. +func labelFormatFunnel(seriesNames []string, layout string, index int, value float64, percent float64) string { if len(layout) == 0 { layout = "{b}({d})" } - return NewLabelFormatter(seriesNames, layout) + return newLabelFormatter(seriesNames, layout)(index, value, percent) } -// NewValueLabelFormatter returns a value formatter -func NewValueLabelFormatter(seriesNames []string, layout string) LabelFormatter { +// labelFormatValue returns a formatted value. +func labelFormatValue(seriesNames []string, layout string, index int, value float64, percent float64) string { if len(layout) == 0 { layout = "{c}" } - return NewLabelFormatter(seriesNames, layout) + return newLabelFormatter(seriesNames, layout)(index, value, percent) } -// NewLabelFormatter returns a label formatter -func NewLabelFormatter(seriesNames []string, layout string) LabelFormatter { +// newLabelFormatter returns a label formatter. +func newLabelFormatter(seriesNames []string, layout string) func(index int, value float64, percent float64) string { return func(index int, value, percent float64) string { percentText := "" if percent >= 0 { diff --git a/series_label.go b/series_label.go index b9b86a1..583bb00 100644 --- a/series_label.go +++ b/series_label.go @@ -14,18 +14,18 @@ type labelRenderValue struct { Radians float64 } -type LabelValue struct { - Index int - Value float64 - X int - Y int - Radians float64 - FontStyle FontStyle - Vertical bool - Offset OffsetInt +type labelValue struct { + index int + value float64 + x int + y int + radians float64 + fontStyle FontStyle + vertical bool + offset OffsetInt } -type SeriesLabelPainter struct { +type seriesLabelPainter struct { p *Painter seriesNames []string label *SeriesLabel @@ -34,62 +34,50 @@ type SeriesLabelPainter struct { values []labelRenderValue } -type SeriesLabelPainterParams struct { - P *Painter - SeriesNames []string - Label SeriesLabel - Theme ColorPalette - Font *truetype.Font -} - -func NewSeriesLabelPainter(params SeriesLabelPainterParams) *SeriesLabelPainter { - return &SeriesLabelPainter{ - p: params.P, - seriesNames: params.SeriesNames, - label: ¶ms.Label, - theme: params.Theme, - font: params.Font, +func newSeriesLabelPainter(p *Painter, seriesNames []string, label SeriesLabel, + theme ColorPalette, font *truetype.Font) *seriesLabelPainter { + return &seriesLabelPainter{ + p: p, + seriesNames: seriesNames, + label: &label, + theme: theme, + font: font, values: make([]labelRenderValue, 0), } } -func (o *SeriesLabelPainter) Add(value LabelValue) { +func (o *seriesLabelPainter) Add(value labelValue) { label := o.label distance := label.Distance if distance == 0 { distance = 5 } - text := NewValueLabelFormatter(o.seriesNames, label.Formatter)(value.Index, value.Value, -1) + text := labelFormatValue(o.seriesNames, label.Formatter, value.index, value.value, -1) labelStyle := FontStyle{ FontColor: o.theme.GetTextColor(), FontSize: labelFontSize, - Font: getPreferredFont(label.FontStyle.Font, value.FontStyle.Font, o.font), + Font: getPreferredFont(label.FontStyle.Font, value.fontStyle.Font, o.font), } if label.FontStyle.FontSize != 0 { labelStyle.FontSize = label.FontStyle.FontSize - } else if value.FontStyle.FontSize != 0 { - labelStyle.FontSize = value.FontStyle.FontSize + } else if value.fontStyle.FontSize != 0 { + labelStyle.FontSize = value.fontStyle.FontSize } if !label.FontStyle.FontColor.IsZero() { labelStyle.FontColor = label.FontStyle.FontColor - } else if !value.FontStyle.FontColor.IsZero() { - labelStyle.FontColor = value.FontStyle.FontColor + } else if !value.fontStyle.FontColor.IsZero() { + labelStyle.FontColor = value.fontStyle.FontColor } p := o.p - p.OverrideDrawingStyle(chartdraw.Style{FontStyle: labelStyle}) - rotated := value.Radians != 0 - if rotated { - p.SetTextRotation(value.Radians) - } - textBox := p.MeasureText(text) + textBox := p.MeasureText(text, value.radians, labelStyle) renderValue := labelRenderValue{ Text: text, FontStyle: labelStyle, - X: value.X, - Y: value.Y, - Radians: value.Radians, + X: value.x, + Y: value.y, + Radians: value.radians, } - if value.Vertical { + if value.vertical { renderValue.X -= textBox.Width() >> 1 renderValue.Y -= distance } else { @@ -97,25 +85,17 @@ func (o *SeriesLabelPainter) Add(value LabelValue) { renderValue.Y += textBox.Height() >> 1 renderValue.Y -= 2 } - if rotated { - renderValue.X = value.X + textBox.Width()>>1 - 1 - p.ClearTextRotation() - } else if textBox.Width()%2 != 0 { - renderValue.X++ + if value.radians != 0 { + renderValue.X = value.x + (textBox.Width() >> 1) - 1 } - renderValue.X += value.Offset.Left - renderValue.Y += value.Offset.Top + renderValue.X += value.offset.Left + renderValue.Y += value.offset.Top o.values = append(o.values, renderValue) } -func (o *SeriesLabelPainter) Render() (Box, error) { +func (o *seriesLabelPainter) Render() (Box, error) { for _, item := range o.values { - o.p.OverrideFontStyle(item.FontStyle) - if item.Radians != 0 { - o.p.TextRotation(item.Text, item.X, item.Y, item.Radians) - } else { - o.p.Text(item.Text, item.X, item.Y) - } + o.p.Text(item.Text, item.X, item.Y, item.Radians, item.FontStyle) } return chartdraw.BoxZero, nil } diff --git a/series_test.go b/series_test.go index 3454ab1..2ebddd6 100644 --- a/series_test.go +++ b/series_test.go @@ -12,26 +12,24 @@ func TestNewSeriesListDataFromValues(t *testing.T) { assert.Equal(t, SeriesList{ { Type: ChartTypeBar, - Data: []SeriesData{ - { - Value: 1.0, - }, + Data: []float64{ + 1.0, }, }, - }, NewSeriesListDataFromValues([][]float64{ + }, NewSeriesListBar([][]float64{ { 1, }, - }, ChartTypeBar)) + })) } func TestSeriesLists(t *testing.T) { t.Parallel() - seriesList := NewSeriesListDataFromValues([][]float64{ + seriesList := NewSeriesListBar([][]float64{ {1, 2}, {10}, - }, ChartTypeBar) + }) assert.Equal(t, 2, len(seriesList.Filter(ChartTypeBar))) assert.Equal(t, 0, len(seriesList.Filter(ChartTypeLine))) @@ -39,26 +37,102 @@ func TestSeriesLists(t *testing.T) { min, max := seriesList.GetMinMax(0) assert.Equal(t, float64(10), max) assert.Equal(t, float64(1), min) +} + +func TestSeriesSummary(t *testing.T) { + t.Parallel() + + seriesList := NewSeriesListLine([][]float64{ + {10}, + {1, 2}, + {1, 2, 3}, + {1, 2, 3, 4}, + {3, 7, 11, 13}, + }) - assert.Equal(t, seriesSummary{ - MaxIndex: 1, - MaxValue: 2, - MinIndex: 0, - MinValue: 1, - AverageValue: 1.5, - }, seriesList[0].Summary()) + t.Run("empty_series", func(t *testing.T) { + assert.Equal(t, seriesSummary{ + MaxIndex: -1, + MinIndex: -1, + }, (&Series{}).Summary()) + }) + t.Run("one_value", func(t *testing.T) { + assert.Equal(t, seriesSummary{ + Max: 10, + MaxIndex: 0, + Min: 10, + MinIndex: 0, + Average: 10, + Median: 10, + StandardDeviation: 0.0, + Skewness: 0.0, + Kurtosis: 0.0, + }, seriesList[0].Summary()) + }) + t.Run("two_values", func(t *testing.T) { + assert.Equal(t, seriesSummary{ + Max: 2, + MaxIndex: 1, + Min: 1, + MinIndex: 0, + Average: 1.5, + Median: 1.5, + StandardDeviation: 0.5, + Skewness: 0.0, + Kurtosis: 1.0, + }, seriesList[1].Summary()) + }) + t.Run("three_values", func(t *testing.T) { + assert.Equal(t, seriesSummary{ + Max: 3, + MaxIndex: 2, + Min: 1, + MinIndex: 0, + Average: 2, + Median: 2, + StandardDeviation: 0.8164965809277263, + Skewness: 0.0, + Kurtosis: 1.4999999999999987, + }, seriesList[2].Summary()) + }) + t.Run("four_values", func(t *testing.T) { + assert.Equal(t, seriesSummary{ + Max: 4, + MaxIndex: 3, + Min: 1, + MinIndex: 0, + Average: 2.5, + Median: 2.5, + StandardDeviation: 1.118033988749895, + Skewness: 0.0, + Kurtosis: 1.64, + }, seriesList[3].Summary()) + }) + t.Run("prime_values", func(t *testing.T) { + assert.Equal(t, seriesSummary{ + Max: 13, + MaxIndex: 3, + Min: 3, + MinIndex: 0, + Average: 8.5, + Median: 9, + StandardDeviation: 3.840572873934304, + Skewness: -0.2780305556539629, + Kurtosis: 1.5733984487216317, + }, seriesList[4].Summary()) + }) } func TestFormatter(t *testing.T) { t.Parallel() - assert.Equal(t, "a: 12%", NewPieLabelFormatter([]string{ + assert.Equal(t, "a: 12%", labelFormatPie([]string{ "a", "b", - }, "")(0, 10, 0.12)) + }, "", 0, 10, 0.12)) - assert.Equal(t, "10", NewValueLabelFormatter([]string{ + assert.Equal(t, "10", labelFormatValue([]string{ "a", "b", - }, "")(0, 10, 0.12)) + }, "", 0, 10, 0.12)) } diff --git a/table.go b/table.go index ba0f128..52201f7 100644 --- a/table.go +++ b/table.go @@ -7,13 +7,68 @@ import ( "github.com/go-analyze/charts/chartdraw/drawing" ) +// TableOptionRenderDirect table render with the provided options directly to an image. Table options are different +// from other charts as they include the state for initializing the Painter, where other charts accept the Painter. If +// you want to write a Table on an existing Painter use TableOptionRender +func TableOptionRenderDirect(opt TableChartOption) (*Painter, error) { + if opt.OutputFormat == "" { + opt.OutputFormat = chartDefaultOutputFormat + } + if opt.Width <= 0 { + opt.Width = defaultChartWidth + } + + p := NewPainter(PainterOptions{ + OutputFormat: opt.OutputFormat, + Width: opt.Width, + Height: 100, // is only used to calculate the height of the table + Font: opt.FontStyle.Font, + }) + info, err := newTableChart(p, opt).render() + if err != nil { + return nil, err + } + + p = NewPainter(PainterOptions{ + OutputFormat: opt.OutputFormat, + Width: info.width, + Height: info.height, + Font: opt.FontStyle.Font, + }) + if _, err = newTableChart(p, opt).renderWithInfo(info); err != nil { + return nil, err + } + return p, nil +} + +// TableRenderValues renders a table chart with the simple header and data values provided. +func TableRenderValues(header []string, data [][]string, spanMaps ...map[int]int) (*Painter, error) { + opt := TableChartOption{ + Header: header, + Data: data, + } + if len(spanMaps) != 0 { + spanMap := spanMaps[0] + spans := make([]int, len(opt.Header)) + for index := range spans { + v, ok := spanMap[index] + if !ok { + v = 1 + } + spans[index] = v + } + opt.Spans = spans + } + return TableOptionRenderDirect(opt) +} + type tableChart struct { p *Painter opt *TableChartOption } -// NewTableChart returns a table chart render -func NewTableChart(p *Painter, opt TableChartOption) *tableChart { +// newTableChart returns a table chart render. +func newTableChart(p *Painter, opt TableChartOption) *tableChart { return &tableChart{ p: p, opt: &opt, @@ -163,13 +218,10 @@ func (t *tableChart) render() (*renderInfo, error) { info.columnWidths = columnWidths height := 0 - style := chartdraw.Style{ - FontStyle: FontStyle{ - FontSize: fontStyle.FontSize, - FontColor: opt.HeaderFontColor, - Font: fontStyle.Font, - }, - FillColor: opt.HeaderFontColor, + headerFontStyle := FontStyle{ + FontSize: fontStyle.FontSize, + FontColor: opt.HeaderFontColor, + Font: fontStyle.Font, } // textAligns := opt.TextAligns @@ -182,7 +234,8 @@ func (t *tableChart) render() (*renderInfo, error) { // processing of the table cells renderTableCells := func( - style chartdraw.Style, + fontStyle FontStyle, + fillColor Color, rowIndex int, textList []string, currentHeight int, @@ -197,23 +250,22 @@ func (t *tableChart) render() (*renderInfo, error) { Text: text, Row: rowIndex, Column: index, - FontStyle: style.FontStyle, - FillColor: style.FillColor, + FontStyle: fontStyle, + FillColor: fillColor, } if opt.CellModifier != nil { tc = opt.CellModifier(tc) // Update style values to capture any changes - style.FontStyle = tc.FontStyle - style.FillColor = tc.FillColor + fontStyle = tc.FontStyle + fillColor = tc.FillColor } cells[index] = tc - p.SetStyle(style) x := values[index] y := currentHeight + cellPadding.Top width := values[index+1] - x x += cellPadding.Left width -= paddingWidth - box := p.TextFit(text, x, y+int(fontStyle.FontSize), width, getTextAlign(index)) + box := p.TextFit(text, x, y+int(fontStyle.FontSize), width, fontStyle, getTextAlign(index)) // calculate the highest height if box.Height()+paddingHeight > cellMaxHeight { cellMaxHeight = box.Height() + paddingHeight @@ -225,16 +277,15 @@ func (t *tableChart) render() (*renderInfo, error) { info.tableCells = make([][]TableCell, len(opt.Data)+1) // processing of the table headers - headerCells, headerHeight := renderTableCells(style, 0, opt.Header, height, opt.Padding) + headerCells, headerHeight := renderTableCells(headerFontStyle, opt.HeaderFontColor, + 0, opt.Header, height, opt.Padding) info.tableCells[0] = headerCells height += headerHeight info.headerHeight = headerHeight // processing of the table contents - style.FontColor = fontStyle.FontColor - style.FillColor = fontStyle.FontColor for index, textList := range opt.Data { - newCells, cellHeight := renderTableCells(style, index+1, textList, height, opt.Padding) + newCells, cellHeight := renderTableCells(fontStyle, fontStyle.FontColor, index+1, textList, height, opt.Padding) info.tableCells[index+1] = newCells info.rowHeights = append(info.rowHeights, cellHeight) height += cellHeight @@ -262,7 +313,7 @@ func (t *tableChart) renderWithInfo(info *renderInfo) (Box, error) { opt.HeaderBackgroundColor = tableLightThemeSetting.headerColor } } - p.SetBackground(info.width, info.headerHeight, opt.HeaderBackgroundColor, true) + p.SetBackground(info.width, info.headerHeight, opt.HeaderBackgroundColor) if opt.RowBackgroundColors == nil { if opt.Theme.IsDark() { @@ -279,7 +330,7 @@ func (t *tableChart) renderWithInfo(info *renderInfo) (Box, error) { Top: currentHeight, IsSet: true, })) - child.SetBackground(p.Width(), h, color, true) + child.SetBackground(p.Width(), h, color) currentHeight += h } // adjust the background color according to the set table style @@ -304,7 +355,7 @@ func (t *tableChart) renderWithInfo(info *renderInfo) (Box, error) { })) w := info.columnWidths[j] - padding.Left - padding.Top h := heights[i] - padding.Top - padding.Bottom - child.SetBackground(w, h, tc.FillColor, true) + child.SetBackground(w, h, tc.FillColor) } left += info.columnWidths[j] } @@ -334,11 +385,7 @@ func (t *tableChart) Render() (Box, error) { if p.outputFormat == ChartOutputSVG { fn = chartdraw.SVG } - newRender, err := fn(p.Width(), 100) - if err != nil { - return BoxZero, err - } - p.render = newRender + p.render = fn(p.Width(), 100) info, err := t.render() if err != nil { return BoxZero, err diff --git a/table_test.go b/table_test.go index 78a5f41..877e011 100644 --- a/table_test.go +++ b/table_test.go @@ -71,13 +71,13 @@ func TestTableChart(t *testing.T) { } return opt }, - result: "NameAgeAddressTagActionJohnBrown32New York No. 1 Lake Parknice,developerSend MailJim Green42London No. 1 Lake ParkwowSend MailJoe Black32Sidney No. 1 Lake Parkcool,teacherSend Mail", + result: "NameAgeAddressTagActionJohnBrown32New York No. 1 Lake Parknice,developerSend MailJim Green42London No. 1 Lake ParkwowSend MailJoe Black32Sidney No. 1 Lake Parkcool,teacherSend Mail", }, { name: "dark_theme", theme: GetTheme(ThemeVividDark), makeOptions: makeDefaultTableChartOptions, - result: "NameAgeAddressTagActionJohn Brown32New York No.1 Lake Parknice,developerSend MailJim Green42London No. 1Lake ParkwowSend MailJoe Black32Sidney No. 1Lake Parkcool, teacherSend Mail", + result: "NameAgeAddressTagActionJohn Brown32New York No.1 Lake Parknice,developerSend MailJim Green42London No. 1Lake ParkwowSend MailJoe Black32Sidney No. 1Lake Parkcool, teacherSend Mail", }, { name: "cell_modified", @@ -98,7 +98,7 @@ func TestTableChart(t *testing.T) { } return opt }, - result: "NameAgeAddressTagActionJohn Brown32New York No.1 Lake Parknice,developerSend MailJim Green42London No. 1Lake ParkwowSend MailJoe Black32Sidney No. 1Lake Parkcool, teacherSend Mail", + result: "NameAgeAddressTagActionJohn Brown32New York No.1 Lake Parknice,developerSend MailJim Green42London No. 1Lake ParkwowSend MailJoe Black32Sidney No. 1Lake Parkcool, teacherSend Mail", }, { name: "error_no_header", @@ -117,11 +117,10 @@ func TestTableChart(t *testing.T) { Width: 600, Height: 400, } - runName := strconv.Itoa(i) + runName := strconv.Itoa(i) + "-" + tt.name if tt.theme != nil { t.Run(runName+"-theme_painter", func(t *testing.T) { - p, err := NewPainter(painterOptions, PainterThemeOption(tt.theme)) - require.NoError(t, err) + p := NewPainter(painterOptions, PainterThemeOption(tt.theme)) opt := tt.makeOptions() validateTableChartRender(t, p, opt, tt.result, tt.errorExpected) @@ -129,8 +128,7 @@ func TestTableChart(t *testing.T) { runName += "-theme_opt" } t.Run(runName, func(t *testing.T) { - p, err := NewPainter(painterOptions) - require.NoError(t, err) + p := NewPainter(painterOptions) opt := tt.makeOptions() opt.Theme = tt.theme @@ -143,7 +141,7 @@ func validateTableChartRender(t *testing.T, p *Painter, opt TableChartOption, expectedResult string, errorExpected bool) { t.Helper() - _, err := NewTableChart(p, opt).Render() + err := p.TableChart(opt) if errorExpected { require.Error(t, err) return diff --git a/theme_test.go b/theme_test.go index 6385111..f0a4b76 100644 --- a/theme_test.go +++ b/theme_test.go @@ -55,12 +55,11 @@ func renderTestLineChartWithThemeName(t *testing.T, fullChart bool, themeName st func renderTestLineChartWithTheme(t *testing.T, fullChart bool, theme ColorPalette) []byte { t.Helper() - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 600, Height: 400, }) - require.NoError(t, err) opt := makeFullLineChartOption() if len(opt.YAxis) == 0 { opt.YAxis = []YAxisOption{{}} @@ -73,7 +72,7 @@ func renderTestLineChartWithTheme(t *testing.T, fullChart bool, theme ColorPalet } opt.Theme = theme - _, err = NewLineChart(p, opt).Render() + err := p.LineChart(opt) require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) @@ -84,42 +83,42 @@ func TestThemeLight(t *testing.T) { t.Parallel() svg := renderTestLineChartWithThemeName(t, true, ThemeLight) - assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) + assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) } func TestThemeDark(t *testing.T) { t.Parallel() svg := renderTestLineChartWithThemeName(t, true, ThemeDark) - assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) + assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) } func TestThemeVividLight(t *testing.T) { t.Parallel() svg := renderTestLineChartWithThemeName(t, true, ThemeVividLight) - assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) + assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) } func TestThemeVividDark(t *testing.T) { t.Parallel() svg := renderTestLineChartWithThemeName(t, true, ThemeVividDark) - assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) + assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) } func TestThemeAnt(t *testing.T) { t.Parallel() svg := renderTestLineChartWithThemeName(t, true, ThemeAnt) - assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) + assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) } func TestThemeGrafana(t *testing.T) { t.Parallel() svg := renderTestLineChartWithThemeName(t, true, ThemeGrafana) - assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) + assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch EngineLine1.4k7000MonTueWedThuFriSatSun", svg) } func TestLightThemeSeriesRepeat(t *testing.T) { @@ -138,7 +137,7 @@ func TestLightThemeSeriesRepeat(t *testing.T) { {R: 200, G: 50, B: 50, A: 255}, }, }) - assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch Engine1.4k7000MonTueWedThuFriSatSun", svg) + assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch Engine1.4k7000MonTueWedThuFriSatSun", svg) } func TestDarkThemeSeriesRepeat(t *testing.T) { @@ -157,7 +156,7 @@ func TestDarkThemeSeriesRepeat(t *testing.T) { {R: 200, G: 50, B: 50, A: 255}, }, }) - assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch Engine1.4k7000MonTueWedThuFriSatSun", svg) + assertEqualSVG(t, "EmailUnion AdsVideo AdsDirectSearch Engine1.4k7000MonTueWedThuFriSatSun", svg) } func TestWithAxisColor(t *testing.T) { diff --git a/title.go b/title.go index 7f4e485..e36f629 100644 --- a/title.go +++ b/title.go @@ -47,8 +47,8 @@ type titlePainter struct { opt *TitleOption } -// NewTitlePainter returns a title renderer -func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter { +// newTitlePainter returns a title renderer +func newTitlePainter(p *Painter, opt TitleOption) *titlePainter { return &titlePainter{ p: p, opt: &opt, @@ -111,8 +111,7 @@ func (t *titlePainter) Render() (Box, error) { textMaxHeight := 0 textTotalHeight := 0 for index, item := range measureOptions { - p.OverrideFontStyle(item.style) - textBox := p.MeasureText(item.text) + textBox := p.MeasureText(item.text, 0, item.style) w := textBox.Width() h := textBox.Height() @@ -159,10 +158,9 @@ func (t *titlePainter) Render() (Box, error) { } startY := titleY for _, item := range measureOptions { - p.OverrideFontStyle(item.style) x := titleX + (textMaxWidth-item.width)>>1 y := titleY + item.height - p.Text(item.text, x, y) + p.Text(item.text, x, y, 0, item.style) titleY = y } diff --git a/title_test.go b/title_test.go index be51c15..6bfea5b 100644 --- a/title_test.go +++ b/title_test.go @@ -20,7 +20,7 @@ func TestTitleRenderer(t *testing.T) { { name: "no_content", render: func(p *Painter) ([]byte, error) { - _, err := NewTitlePainter(p, TitleOption{ + _, err := newTitlePainter(p, TitleOption{ Text: "", Subtext: "", }).Render() @@ -34,7 +34,7 @@ func TestTitleRenderer(t *testing.T) { { name: "offset_number", render: func(p *Painter) ([]byte, error) { - _, err := NewTitlePainter(p, TitleOption{ + _, err := newTitlePainter(p, TitleOption{ Text: "title", Subtext: "subTitle", Offset: OffsetStr{ @@ -47,12 +47,12 @@ func TestTitleRenderer(t *testing.T) { } return p.Bytes() }, - result: "titlesubTitle", + result: "titlesubTitle", }, { name: "offset_percent", render: func(p *Painter) ([]byte, error) { - _, err := NewTitlePainter(p, TitleOption{ + _, err := newTitlePainter(p, TitleOption{ Text: "title", Subtext: "subTitle", Offset: OffsetStr{ @@ -65,12 +65,12 @@ func TestTitleRenderer(t *testing.T) { } return p.Bytes() }, - result: "titlesubTitle", + result: "titlesubTitle", }, { name: "offset_right", render: func(p *Painter) ([]byte, error) { - _, err := NewTitlePainter(p, TitleOption{ + _, err := newTitlePainter(p, TitleOption{ Text: "title", Subtext: "subTitle", Offset: OffsetRight, @@ -80,12 +80,12 @@ func TestTitleRenderer(t *testing.T) { } return p.Bytes() }, - result: "titlesubTitle", + result: "titlesubTitle", }, { name: "offset_center", render: func(p *Painter) ([]byte, error) { - _, err := NewTitlePainter(p, TitleOption{ + _, err := newTitlePainter(p, TitleOption{ Text: "title", Subtext: "subTitle", Offset: OffsetCenter, @@ -95,12 +95,12 @@ func TestTitleRenderer(t *testing.T) { } return p.Bytes() }, - result: "titlesubTitle", + result: "titlesubTitle", }, { name: "offset_bottom", render: func(p *Painter) ([]byte, error) { - _, err := NewTitlePainter(p, TitleOption{ + _, err := newTitlePainter(p, TitleOption{ Text: "title", Subtext: "subTitle", Offset: OffsetStr{ @@ -112,12 +112,12 @@ func TestTitleRenderer(t *testing.T) { } return p.Bytes() }, - result: "titlesubTitle", + result: "titlesubTitle", }, { name: "offset_bottom_right", render: func(p *Painter) ([]byte, error) { - _, err := NewTitlePainter(p, TitleOption{ + _, err := newTitlePainter(p, TitleOption{ Text: "title", Subtext: "subTitle", Offset: OffsetStr{ @@ -130,12 +130,12 @@ func TestTitleRenderer(t *testing.T) { } return p.Bytes() }, - result: "titlesubTitle", + result: "titlesubTitle", }, { name: "offset_bottom_center", render: func(p *Painter) ([]byte, error) { - _, err := NewTitlePainter(p, TitleOption{ + _, err := newTitlePainter(p, TitleOption{ Text: "title", Subtext: "subTitle", Offset: OffsetStr{ @@ -148,12 +148,12 @@ func TestTitleRenderer(t *testing.T) { } return p.Bytes() }, - result: "titlesubTitle", + result: "titlesubTitle", }, { name: "custom_font", render: func(p *Painter) ([]byte, error) { - _, err := NewTitlePainter(p, TitleOption{ + _, err := newTitlePainter(p, TitleOption{ Text: "title", Subtext: "subTitle", FontStyle: FontStyle{ @@ -170,18 +170,17 @@ func TestTitleRenderer(t *testing.T) { } return p.Bytes() }, - result: "titlesubTitle", + result: "titlesubTitle", }, } for i, tt := range tests { t.Run(strconv.Itoa(i)+"-"+tt.name, func(t *testing.T) { - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 600, Height: 400, }, PainterThemeOption(GetTheme(ThemeLight))) - require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) assertEqualSVG(t, tt.result, data) diff --git a/util.go b/util.go index 36824ce..b88be22 100644 --- a/util.go +++ b/util.go @@ -37,6 +37,7 @@ func flagIs(is bool, flag *bool) bool { return *flag == is } +// TODO - replace when we support a newer version of go func containsInt(values []int, value int) bool { for _, v := range values { if v == value { @@ -111,12 +112,14 @@ func sumInt(values []int) int { return sum } +// TODO - replace when we support a newer version of go 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] } } +// TODO - replace when we support a newer version of go 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] @@ -152,7 +155,7 @@ const gValue = mValue * kValue const tValue = gValue * kValue // FormatValueHumanizeShort takes in a value and a specified precision, rounding to the specified precision and -// returning a human friendly number string including commas. If the value is over 1,000 it will be reduced to a +// returning a human friendly number string including commas. If the value is over 1,000 it will be reduced to a // shorter version with the appropriate k, M, G, T suffix. func FormatValueHumanizeShort(value float64, decimals int, ensureTrailingZeros bool) string { if value >= tValue { diff --git a/xaxis.go b/xaxis.go index baaa684..86c9d86 100644 --- a/xaxis.go +++ b/xaxis.go @@ -21,9 +21,11 @@ type XAxisOption struct { TextRotation float64 // LabelOffset is the offset of each label. LabelOffset OffsetInt + // ValueFormatter defines how float values should be rendered to strings, notably for numeric axis labels. + ValueFormatter ValueFormatter // Unit is a suggestion for how large the axis step is, this is a recommendation only. Larger numbers result in fewer labels. Unit float64 - // LabelCount is the number of labels to show on the axis. Specify a smaller number to reduce writing collisions. + // LabelCount is the number of labels to show on the axis. Specify a smaller number to reduce writing collisions. LabelCount int // LabelCountAdjustment specifies a relative influence on how many labels should be rendered. // Typically, this is negative to result in cleaner graphs, positive values may result in text collisions. @@ -34,12 +36,12 @@ type XAxisOption struct { const defaultXAxisHeight = 30 const boundaryGapDefaultThreshold = 40 -func (opt *XAxisOption) ToAxisOption() AxisOption { +func (opt *XAxisOption) toAxisOption() axisOption { position := PositionBottom if opt.Position == PositionTop { position = PositionTop } - axisOpt := AxisOption{ + axisOpt := axisOption{ Theme: opt.Theme, Data: opt.Data, DataStartIndex: opt.DataStartIndex, @@ -61,7 +63,7 @@ func (opt *XAxisOption) ToAxisOption() AxisOption { return axisOpt } -// NewBottomXAxis returns a bottom x axis renderer -func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter { - return NewAxisPainter(p, opt.ToAxisOption()) +// newBottomXAxis returns a bottom x-axis renderer. +func newBottomXAxis(p *Painter, opt XAxisOption) *axisPainter { + return newAxisPainter(p, opt.toAxisOption()) } diff --git a/yaxis.go b/yaxis.go index 783961a..3398500 100644 --- a/yaxis.go +++ b/yaxis.go @@ -23,7 +23,7 @@ type YAxisOption struct { Formatter string // Unit is a suggestion for how large the axis step is, this is a recommendation only. Larger numbers result in fewer labels. Unit float64 - // LabelCount is the number of labels to show on the axis. Specify a smaller number to reduce writing collisions. + // LabelCount is the number of labels to show on the axis. Specify a smaller number to reduce writing collisions. LabelCount int // LabelCountAdjustment specifies a relative influence on how many labels should be rendered. // Typically, this is negative to result in cleaner graphs, positive values may result in text collisions. @@ -36,18 +36,17 @@ type YAxisOption struct { // SpineLineShow can be set to enforce if the vertical spine on the axis should be shown or not. // By default, not shown unless a category axis. SpineLineShow *bool + // ValueFormatter defines how float values should be rendered to strings, notably for numeric axis labels. + ValueFormatter ValueFormatter } -func (opt *YAxisOption) ToAxisOption(p *Painter) AxisOption { +func (opt *YAxisOption) toAxisOption(fallbackTheme ColorPalette) axisOption { position := PositionLeft if opt.Position == PositionRight { position = PositionRight } - theme := opt.Theme - if theme == nil { - theme = p.theme - } - axisOpt := AxisOption{ + theme := getPreferredTheme(opt.Theme, fallbackTheme) + axisOpt := axisOption{ Formatter: opt.Formatter, Theme: theme, Data: opt.Data, @@ -86,21 +85,21 @@ func (opt *YAxisOption) ToAxisOption(p *Painter) AxisOption { return axisOpt } -// NewLeftYAxis returns a left y axis renderer -func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter { +// newLeftYAxis returns a left y-axis renderer. +func newLeftYAxis(p *Painter, opt YAxisOption) *axisPainter { p = p.Child(PainterPaddingOption(Box{ Bottom: defaultXAxisHeight, })) - return NewAxisPainter(p, opt.ToAxisOption(p)) + return newAxisPainter(p, opt.toAxisOption(p.theme)) } -// NewRightYAxis returns a right y axis renderer -func NewRightYAxis(p *Painter, opt YAxisOption) *axisPainter { +// newRightYAxis returns a right y-axis renderer. +func newRightYAxis(p *Painter, opt YAxisOption) *axisPainter { p = p.Child(PainterPaddingOption(Box{ Bottom: defaultXAxisHeight, })) - axisOpt := opt.ToAxisOption(p) + axisOpt := opt.toAxisOption(p.theme) axisOpt.Position = PositionRight axisOpt.SplitLineShow = false - return NewAxisPainter(p, axisOpt) + return newAxisPainter(p, axisOpt) } diff --git a/yaxis_test.go b/yaxis_test.go index f01aca2..3d900e4 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -19,18 +19,18 @@ func TestRightYAxis(t *testing.T) { opt := YAxisOption{ Data: []string{"a", "b", "c", "d"}, } - _, err := NewRightYAxis(p, opt).Render() + _, err := newRightYAxis(p, opt).Render() if err != nil { return nil, err } return p.Bytes() }, - result: "abcd", + result: "abcd", }, } for i, tt := range tests { t.Run(strconv.Itoa(i), func(t *testing.T) { - p, err := NewPainter(PainterOptions{ + p := NewPainter(PainterOptions{ OutputFormat: ChartOutputSVG, Width: 600, Height: 400, @@ -40,7 +40,6 @@ func TestRightYAxis(t *testing.T) { Bottom: 10, Left: 10, })) - require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) assertEqualSVG(t, tt.result, data)