From 19ddb418aff75f4ff539bfbec0cbb3198e9119bb Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Fri, 2 Feb 2024 11:56:11 -0700 Subject: [PATCH 1/8] Improvements to X-Axis rendering This still has test failures and an introduced issue with y-axis rendering that will be fixed in the next commit --- axis.go | 76 ++++++++++++++++++++++-------------- charts.go | 14 ++++--- echarts.go | 2 +- painter.go | 112 +++++++++++++++++++++++++++++++---------------------- xaxis.go | 12 +++--- yaxis.go | 11 ++++-- 6 files changed, 135 insertions(+), 92 deletions(-) diff --git a/axis.go b/axis.go index 91a5ce9..3cbd38a 100644 --- a/axis.go +++ b/axis.go @@ -1,10 +1,10 @@ package charts import ( + "math" "strings" "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/v2" ) type axisPainter struct { @@ -33,8 +33,6 @@ type AxisOption struct { Show *bool // The position of axis, it can be 'left', 'top', 'right' or 'bottom' Position string - // Number of segments that the axis is split into. Note that this number serves only as a recommendation. - SplitNumber int // The line color of axis StrokeColor Color // The line width @@ -59,7 +57,10 @@ type AxisOption struct { TextRotation float64 // The offset of label LabelOffset Box - Unit int + // 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. This value takes priority over Unit. + LabelCount int } func (a *axisPainter) Render() (Box, error) { @@ -106,7 +107,6 @@ func (a *axisPainter) Render() (Box, error) { } } dataCount := len(data) - tickCount := dataCount boundaryGap := true if isFalse(opt.BoundaryGap) { @@ -116,12 +116,10 @@ func (a *axisPainter) Render() (Box, error) { opt.Position == PositionRight labelPosition := "" - if !boundaryGap { - tickCount-- - labelPosition = PositionLeft - } if isVertical && boundaryGap { labelPosition = PositionCenter + } else if !boundaryGap { + labelPosition = PositionLeft } // if less than zero, it means not processing @@ -147,21 +145,6 @@ func (a *axisPainter) Render() (Box, error) { top.ClearTextRotation() } - // Add 30px to calculate text display area - textFillWidth := float64(textMaxWidth + 20) - // Calculate more suitable display item based on text width - fitTextCount := ceilFloatToInt(float64(top.Width()) / textFillWidth) - - unit := opt.Unit - if unit <= 0 { - unit = ceilFloatToInt(float64(dataCount) / float64(fitTextCount)) - unit = chart.MaxInt(unit, opt.SplitNumber) - // even number - if unit%2 == 0 && dataCount%(unit+1) == 0 { - unit++ - } - } - width := 0 height := 0 if isVertical { @@ -224,16 +207,47 @@ func (a *axisPainter) Render() (Box, error) { orient = OrientHorizontal } + labelCount := opt.LabelCount + if labelCount <= 0 { + var maxLabelCount int + // Add 20px for some minimal extra padding and calculate more suitable display item count on text width + if orient == OrientVertical { + maxLabelCount = top.Height() / (textMaxHeight + 20) + } else { + maxLabelCount = top.Width() / (textMaxWidth + 20) + } + if opt.Unit > 0 { + multiplier := 1.0 + for { + labelCount = int(math.Ceil(float64(dataCount) / (opt.Unit * multiplier))) + if labelCount > maxLabelCount { + multiplier++ + } else { + break + } + } + } else { + labelCount = maxLabelCount + if ((dataCount/(labelCount-1))%5 == 0) || ((dataCount/(labelCount-1))%2 == 0) { + // prefer %5 or %2 units if reasonable + labelCount-- + } + } + } + if labelCount > dataCount { + labelCount = dataCount + } + if strokeWidth > 0 { p.Child(PainterPaddingOption(Box{ Top: ticksPaddingTop, Left: ticksPaddingLeft, })).Ticks(TicksOption{ - Count: tickCount, - Length: tickLength, - Unit: unit, - Orient: orient, - First: opt.FirstAxis, + LabelCount: labelCount, + DataCount: dataCount, + Length: tickLength, + Orient: orient, + First: opt.FirstAxis, }) p.LineStroke([]Point{ { @@ -256,12 +270,14 @@ func (a *axisPainter) Render() (Box, error) { Align: textAlign, TextList: data, Orient: orient, - Unit: unit, + LabelCount: labelCount, Position: labelPosition, TextRotation: opt.TextRotation, Offset: opt.LabelOffset, }) if opt.SplitLineShow { // show auxiliary lines + // TODO - test if this is working correct + tickCount := labelCount + 1 // always one more tick than labels style.StrokeColor = opt.SplitLineColor style.StrokeWidth = 1 top.OverrideDrawingStyle(style) diff --git a/charts.go b/charts.go index 742826d..9a84f7b 100644 --- a/charts.go +++ b/charts.go @@ -159,11 +159,16 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e if len(opt.YAxisOptions) > index { yAxisOption = opt.YAxisOptions[index] } - divideCount := yAxisOption.DivideCount + max, min := opt.SeriesList.GetMaxMin(index) + span := max - min + divideCount := yAxisOption.LabelCount if divideCount <= 0 { - divideCount = defaultAxisDivideCount + if yAxisOption.Unit > 0 { + divideCount = int(span / yAxisOption.Unit) + } else { + divideCount = int(span / defaultAxisDivideCount) + } } - max, min := opt.SeriesList.GetMaxMin(index) r := NewRange(AxisRangeOption{ Painter: p, Min: min, @@ -188,7 +193,7 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e yAxisOption.Data = r.Values() } else { yAxisOption.isCategoryAxis = true - // since the x-axis is the value part, it's label is calculated and processed seperately + // since the x-axis is the value part, it's label is calculated and processed separately opt.XAxis.Data = NewRange(AxisRangeOption{ Painter: p, Min: min, @@ -327,7 +332,6 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { } } if len(horizontalBarSeriesList) != 0 { - renderOpt.YAxisOptions[0].DivideCount = len(renderOpt.YAxisOptions[0].Data) renderOpt.YAxisOptions[0].Unit = 1 } diff --git a/echarts.go b/echarts.go index 9f759e7..c60c28d 100644 --- a/echarts.go +++ b/echarts.go @@ -455,7 +455,7 @@ func (eo *EChartsOption) ToOption() ChartOption { o.XAxis = XAxisOption{ BoundaryGap: xAxisData.BoundaryGap, Data: xAxisData.Data, - SplitNumber: xAxisData.SplitNumber, + LabelCount: xAxisData.SplitNumber, } } yAxisOptions := make([]YAxisOption, len(eo.YAxis.Data)) diff --git a/painter.go b/painter.go index 5389be9..7b68a6d 100644 --- a/painter.go +++ b/painter.go @@ -3,6 +3,7 @@ package charts import ( "bytes" "errors" + "fmt" "math" "github.com/golang/freetype/truetype" @@ -36,24 +37,24 @@ type PainterOptions struct { type PainterOption func(*Painter) type TicksOption struct { - // the first tick - First int - Length int - Orient string - Count int - Unit int + // the first tick index + First int + Length int + Orient string + LabelCount int + DataCount int } type MultiTextOption struct { TextList []string Orient string - Unit int Position string Align string TextRotation float64 Offset Box // The first text index - First int + First int + LabelCount int } type GridOption struct { @@ -587,12 +588,19 @@ func (p *Painter) TextFit(body string, x, y, width int, textAligns ...string) ch return output } -func isTick(totalRange int, unit int, index int) bool { - numTicks := (totalRange / unit) + 1 +func isTick(totalRange int, numTicks int, index int) bool { + if numTicks >= totalRange { + return true + } else if index == 0 || index == totalRange-1 { + return true // shortcut to always define tick at start and end of range + } step := float64(totalRange-1) / float64(numTicks-1) for i := int(float64(index) / step); i < numTicks; i++ { value := int((float64(i) * step) + 0.5) if value == index { + if nextValue := int((float64(i+1) * step) + 0.5); nextValue > totalRange { + break // rare rounding condition where we need to just wait for the last index instead + } return true } else if value > index { break @@ -602,28 +610,20 @@ func isTick(totalRange int, unit int, index int) bool { } func (p *Painter) Ticks(opt TicksOption) *Painter { - if opt.Count <= 0 || opt.Length <= 0 { + if opt.LabelCount <= 0 || opt.Length <= 0 { return p } - count := opt.Count - first := opt.First - width := p.Width() - height := p.Height() - unit := 1 - if opt.Unit > 1 { - unit = opt.Unit - } var values []int isVertical := opt.Orient == OrientVertical if isVertical { - values = autoDivide(height, count) + values = autoDivide(p.Height(), opt.DataCount) } else { - values = autoDivide(width, count) + values = autoDivide(p.Width(), opt.DataCount) } for index, value := range values { - if index < first { + if index < opt.First { continue - } else if !isTick(len(values), unit, index) { + } else if !isTick(len(values)-opt.First, opt.LabelCount+1, index-opt.First) { continue } if isVertical { @@ -658,45 +658,49 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { return p } count := len(opt.TextList) - positionCenter := true - tickLimit := true - if containsString([]string{ + positionCenter := !containsString([]string{ PositionLeft, PositionTop, - }, opt.Position) { - positionCenter = false - count-- - } + }, opt.Position) + tickLimit := true width := p.Width() height := p.Height() - var values []int + var positions []int isVertical := opt.Orient == OrientVertical if isVertical { - values = autoDivide(height, count) + // TODO - y axis labels appear shifted up, using exact size results in them missing a line + positions = autoDivide(height, count) tickLimit = false + fmt.Printf("label c: %v, len: %v\n", count, len(positions)) } else { - values = autoDivide(width, count) + positions = autoDivide(width, count) } isTextRotation := opt.TextRotation != 0 - offset := opt.Offset - for index, text := range opt.TextList { - if index < opt.First { + positionCount := len(positions) + fmt.Printf("labl") + for index, start := range positions { + if index == positionCount-1 { + // values has one item more than we can map to text + break + } else if index < opt.First { continue - } else if opt.Unit != 0 && tickLimit && !isTick(len(opt.TextList)-opt.First, opt.Unit, index-opt.First) { + } else if tickLimit && index != count-1 /* one off case for last label due to values and label qty difference */ && + !isTick(positionCount-opt.First, opt.LabelCount+1, index-opt.First) { continue } + fmt.Printf(" %d - %v", index, start) if isTextRotation { p.ClearTextRotation() p.SetTextRotation(opt.TextRotation) } + text := opt.TextList[index] box := p.MeasureText(text) - start := values[index] - if positionCenter { - start = (values[index] + values[index+1]) >> 1 - } x := 0 y := 0 if isVertical { + if positionCenter { + start = (positions[index] + positions[index+1]) >> 1 + } y = start + box.Height()>>1 switch opt.Align { case AlignRight: @@ -707,16 +711,32 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { x = 0 } } else { - if index == len(opt.TextList)-1 { - x = start - box.Width() + 10 + if positionCenter { + // 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 + if !exactLabels && index == 0 { + x = start // align to the actual start (left side of tick space) + } else if !exactLabels && index == count-1 { + x = width - box.Width() // align to the right side of tick space + } else { + start = (positions[index] + positions[index+1]) >> 1 + x = start - box.Width()>>1 // align to center of tick space + } } else { - x = start - box.Width()>>1 + if index == count-1 { + x = width - box.Width() // align to the right side of tick space + } else { + x = start // align to the left side of the tick space + } } } - x += offset.Left - y += offset.Top + x += opt.Offset.Left + y += opt.Offset.Top p.Text(text, x, y) } + fmt.Printf("\n") if isTextRotation { p.ClearTextRotation() } diff --git a/xaxis.go b/xaxis.go index 10496fc..109e0f3 100644 --- a/xaxis.go +++ b/xaxis.go @@ -18,8 +18,6 @@ type XAxisOption struct { FontSize float64 // The flag for show axis, set this to *false will hide axis Show *bool - // Number of segments that the axis is split into. Note that this number serves only as a recommendation to avoid writing overlap. - SplitNumber int // The position of axis, it can be 'top' or 'bottom' Position string // The line color of axis @@ -33,8 +31,10 @@ type XAxisOption struct { // The offset of label LabelOffset Box isValueAxis bool - // This value overrides SplitNumber, specifying directly the frequency at which the axis is split into, higher numbers result in less ticks - Unit int + // 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 int } const defaultXAxisHeight = 30 @@ -60,17 +60,17 @@ func (opt *XAxisOption) ToAxisOption() AxisOption { Data: opt.Data, BoundaryGap: opt.BoundaryGap, Position: position, - SplitNumber: opt.SplitNumber, StrokeColor: opt.StrokeColor, FontSize: opt.FontSize, Font: opt.Font, FontColor: opt.FontColor, Show: opt.Show, + Unit: opt.Unit, + LabelCount: opt.LabelCount, SplitLineColor: opt.Theme.GetAxisSplitLineColor(), TextRotation: opt.TextRotation, LabelOffset: opt.LabelOffset, FirstAxis: opt.FirstAxis, - Unit: opt.Unit, } if opt.isValueAxis { axisOpt.SplitLineShow = true diff --git a/yaxis.go b/yaxis.go index f559054..effd7c1 100644 --- a/yaxis.go +++ b/yaxis.go @@ -24,9 +24,11 @@ type YAxisOption struct { // Color for y-axis Color Color // The flag for show axis, set this to *false will hide axis - Show *bool - DivideCount int - Unit int + Show *bool + // 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 int isCategoryAxis bool } @@ -64,10 +66,11 @@ func (opt *YAxisOption) ToAxisOption(p *Painter) AxisOption { Font: opt.Font, FontColor: opt.FontColor, BoundaryGap: FalseFlag(), + Unit: opt.Unit, + LabelCount: opt.LabelCount, SplitLineShow: true, SplitLineColor: theme.GetAxisSplitLineColor(), Show: opt.Show, - Unit: opt.Unit, } if !opt.Color.IsZero() { axisOpt.FontColor = opt.Color From 5d545bbcd9178461dd094ce44bfe9bbf58755914 Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Sat, 3 Feb 2024 15:18:43 -0700 Subject: [PATCH 2/8] Fixes to yAxis rendering and cleanup from prior commit This improves the Y axis rendering, fixing issues introduced in the previous commit as well as providing better range calculation logic. Tests are still failing as part of this commit, although these changes are believed to be good more validation is needed. The next commit should cleanup any remaining issues and fix the unit tests. --- axis.go | 31 ++++--- bar_chart.go | 6 +- charts.go | 37 +++----- examples/painter/main.go | 23 +++-- examples/time_line_chart/main.go | 3 +- horizontal_bar_chart.go | 8 +- mark_line_test.go | 8 +- painter.go | 46 +++++----- range.go | 139 ++++++++++++++++++------------- util.go | 9 -- yaxis.go | 4 +- 11 files changed, 145 insertions(+), 169 deletions(-) diff --git a/axis.go b/axis.go index 3cbd38a..d9d6170 100644 --- a/axis.go +++ b/axis.go @@ -108,20 +108,10 @@ func (a *axisPainter) Render() (Box, error) { } dataCount := len(data) - boundaryGap := true - if isFalse(opt.BoundaryGap) { - boundaryGap = false - } + centerLabels := !isFalse(opt.BoundaryGap) isVertical := opt.Position == PositionLeft || opt.Position == PositionRight - labelPosition := "" - if isVertical && boundaryGap { - labelPosition = PositionCenter - } else if !boundaryGap { - labelPosition = PositionLeft - } - // if less than zero, it means not processing tickLength := getDefaultInt(opt.TickLength, 5) labelMargin := getDefaultInt(opt.LabelMargin, 5) @@ -237,6 +227,13 @@ func (a *axisPainter) Render() (Box, error) { if labelCount > dataCount { labelCount = dataCount } + tickSpaces := dataCount + if !centerLabels { + // 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 + tickSpaces-- + } if strokeWidth > 0 { p.Child(PainterPaddingOption(Box{ @@ -244,7 +241,7 @@ func (a *axisPainter) Render() (Box, error) { Left: ticksPaddingLeft, })).Ticks(TicksOption{ LabelCount: labelCount, - DataCount: dataCount, + TickSpaces: tickSpaces, Length: tickLength, Orient: orient, First: opt.FirstAxis, @@ -271,13 +268,12 @@ func (a *axisPainter) Render() (Box, error) { TextList: data, Orient: orient, LabelCount: labelCount, - Position: labelPosition, + CenterLabels: centerLabels, TextRotation: opt.TextRotation, Offset: opt.LabelOffset, }) + if opt.SplitLineShow { // show auxiliary lines - // TODO - test if this is working correct - tickCount := labelCount + 1 // always one more tick than labels style.StrokeColor = opt.SplitLineColor style.StrokeWidth = 1 top.OverrideDrawingStyle(style) @@ -288,7 +284,7 @@ func (a *axisPainter) Render() (Box, error) { x0 = 0 x1 = top.Width() - p.Width() } - yValues := autoDivide(height, tickCount) + yValues := autoDivide(height, tickSpaces) yValues = yValues[0 : len(yValues)-1] for _, y := range yValues { top.LineStroke([]Point{ @@ -305,7 +301,8 @@ func (a *axisPainter) Render() (Box, error) { } else { y0 := p.Height() - defaultXAxisHeight y1 := top.Height() - defaultXAxisHeight - for index, x := range autoDivide(width, tickCount) { + xValues := autoDivide(width, tickSpaces) + for index, x := range xValues { if index == 0 { continue } diff --git a/bar_chart.go b/bar_chart.go index 32e1eb2..1dafe15 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -48,11 +48,7 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B opt := b.opt seriesPainter := result.seriesPainter - xRange := NewRange(AxisRangeOption{ - Painter: b.p, - DivideCount: len(opt.XAxis.Data), - Size: seriesPainter.Width(), - }) + xRange := NewRange(b.p, seriesPainter.Width(), len(opt.XAxis.Data), 0.0, 0.0, false) // TODO - verify range x0, x1 := xRange.GetRange(0) width := int(x1 - x0) // margin between each block diff --git a/charts.go b/charts.go index 9a84f7b..f5c92ec 100644 --- a/charts.go +++ b/charts.go @@ -159,31 +159,26 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e if len(opt.YAxisOptions) > index { yAxisOption = opt.YAxisOptions[index] } + padRange := true max, min := opt.SeriesList.GetMaxMin(index) + if yAxisOption.Min != nil && *yAxisOption.Min < min { + padRange = false + min = *yAxisOption.Min + } + if yAxisOption.Max != nil && *yAxisOption.Max > max { + padRange = false + max = *yAxisOption.Max + } span := max - min divideCount := yAxisOption.LabelCount if divideCount <= 0 { if yAxisOption.Unit > 0 { divideCount = int(span / yAxisOption.Unit) } else { - divideCount = int(span / defaultAxisDivideCount) + divideCount = defaultAxisDivideCount } } - r := NewRange(AxisRangeOption{ - Painter: p, - Min: min, - Max: max, - // the height needs to be subtracted from the height of the x-axis - Size: rangeHeight, - // separate quantity - DivideCount: divideCount, - }) - if yAxisOption.Min != nil && *yAxisOption.Min <= min { - r.min = *yAxisOption.Min - } - if yAxisOption.Max != nil && *yAxisOption.Max >= max { - r.max = *yAxisOption.Max - } + r := NewRange(p, rangeHeight, divideCount, min, max, padRange) result.axisRanges[index] = r if yAxisOption.Theme == nil { @@ -194,15 +189,7 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e } else { yAxisOption.isCategoryAxis = true // since the x-axis is the value part, it's label is calculated and processed separately - opt.XAxis.Data = NewRange(AxisRangeOption{ - Painter: p, - Min: min, - Max: max, - // the height needs to be subtracted from the height of the x-axis - Size: rangeHeight, - // separate quantities - DivideCount: defaultAxisDivideCount, - }).Values() + opt.XAxis.Data = NewRange(p, rangeHeight, defaultAxisDivideCount, min, max, padRange).Values() opt.XAxis.isValueAxis = true } reverseStringSlice(yAxisOption.Data) diff --git a/examples/painter/main.go b/examples/painter/main.go index 0e4d906..20ecb5d 100644 --- a/examples/painter/main.go +++ b/examples/painter/main.go @@ -160,8 +160,8 @@ func main() { FillColor: drawing.ColorBlack, StrokeWidth: 1, }).Ticks(charts.TicksOption{ - Count: 7, - Length: 5, + LabelCount: 7, + Length: 5, }) // points on the coordinate axis, displayed every 2 grids @@ -178,9 +178,8 @@ func main() { FillColor: drawing.ColorBlack, StrokeWidth: 1, }).Ticks(charts.TicksOption{ - Unit: 2, - Count: 7, - Length: 5, + LabelCount: 7, + Length: 5, }) // the point of the coordinate axis, verticle @@ -197,9 +196,9 @@ func main() { FillColor: drawing.ColorBlack, StrokeWidth: 1, }).Ticks(charts.TicksOption{ - Orient: charts.OrientVertical, - Count: 7, - Length: 5, + Orient: charts.OrientVertical, + LabelCount: 7, + Length: 5, }) // display text horizontally @@ -239,7 +238,7 @@ func main() { FontColor: drawing.ColorBlack, FontSize: 10, }).MultiText(charts.MultiTextOption{ - Position: charts.PositionLeft, + CenterLabels: false, TextList: []string{ "Mon", "Tue", @@ -314,9 +313,9 @@ func main() { FontColor: drawing.ColorBlack, FontSize: 10, }).MultiText(charts.MultiTextOption{ - Orient: charts.OrientVertical, - Position: charts.PositionTop, - Align: charts.AlignCenter, + Orient: charts.OrientVertical, + CenterLabels: false, + Align: charts.AlignCenter, TextList: []string{ "Mon", "Tue", diff --git a/examples/time_line_chart/main.go b/examples/time_line_chart/main.go index 7f6b742..c594816 100644 --- a/examples/time_line_chart/main.go +++ b/examples/time_line_chart/main.go @@ -47,8 +47,7 @@ func main() { }, "50"), func(opt *charts.ChartOption) { opt.XAxis.FirstAxis = firstAxis - // 必须要比计算得来的最小值更大(每60分钟) - opt.XAxis.SplitNumber = 60 + opt.XAxis.Unit = 10 opt.Legend.Padding = charts.Box{ Top: 5, Bottom: 10, diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go index 9147df6..3ef5ba2 100644 --- a/horizontal_bar_chart.go +++ b/horizontal_bar_chart.go @@ -69,13 +69,7 @@ func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList Seri theme := opt.Theme max, min := seriesList.GetMaxMin(0) - xRange := NewRange(AxisRangeOption{ - Painter: p, - Min: min, - Max: max, - DivideCount: defaultAxisDivideCount, - Size: seriesPainter.Width(), - }) + xRange := NewRange(p, seriesPainter.Width(), defaultAxisDivideCount, min, max, false) // TODO - pad range? seriesNames := seriesList.Names() rendererList := []Renderer{} diff --git a/mark_line_test.go b/mark_line_test.go index 931f450..66fa50b 100644 --- a/mark_line_test.go +++ b/mark_line_test.go @@ -32,13 +32,7 @@ func TestMarkLine(t *testing.T) { FontColor: drawing.ColorBlack, StrokeColor: drawing.ColorBlack, Series: series, - Range: NewRange(AxisRangeOption{ - Painter: p, - Min: 0, - Max: 5, - Size: p.Height(), - DivideCount: 6, - }), + Range: NewRange(p, p.Height(), 6, 0.0, 5.0, false), }) if _, err := markLine.Render(); err != nil { return nil, err diff --git a/painter.go b/painter.go index 7b68a6d..9c32705 100644 --- a/painter.go +++ b/painter.go @@ -3,7 +3,6 @@ package charts import ( "bytes" "errors" - "fmt" "math" "github.com/golang/freetype/truetype" @@ -42,13 +41,13 @@ type TicksOption struct { Length int Orient string LabelCount int - DataCount int + TickSpaces int } type MultiTextOption struct { TextList []string Orient string - Position string + CenterLabels bool Align string TextRotation float64 Offset Box @@ -616,9 +615,9 @@ func (p *Painter) Ticks(opt TicksOption) *Painter { var values []int isVertical := opt.Orient == OrientVertical if isVertical { - values = autoDivide(p.Height(), opt.DataCount) + values = autoDivide(p.Height(), opt.TickSpaces) } else { - values = autoDivide(p.Width(), opt.DataCount) + values = autoDivide(p.Width(), opt.TickSpaces) } for index, value := range values { if index < opt.First { @@ -658,37 +657,35 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { return p } count := len(opt.TextList) - positionCenter := !containsString([]string{ - PositionLeft, - PositionTop, - }, opt.Position) - tickLimit := true width := p.Width() height := p.Height() var positions []int isVertical := opt.Orient == OrientVertical if isVertical { - // TODO - y axis labels appear shifted up, using exact size results in them missing a line - positions = autoDivide(height, count) - tickLimit = false - fmt.Printf("label c: %v, len: %v\n", count, len(positions)) + if opt.CenterLabels { + positions = autoDivide(height, count) + } else { + positions = autoDivide(height, count-1) + } } else { - positions = autoDivide(width, count) + if opt.CenterLabels { + positions = autoDivide(width, count) + } else { + positions = autoDivide(width, count-1) + } } isTextRotation := opt.TextRotation != 0 positionCount := len(positions) - fmt.Printf("labl") for index, start := range positions { - if index == positionCount-1 { - // values has one item more than we can map to text - break + 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 { continue - } else if tickLimit && index != count-1 /* one off case for last label due to values and label qty difference */ && + } else if !isVertical && + index != count-1 /* one off case for last label due to values and label qty difference */ && !isTick(positionCount-opt.First, opt.LabelCount+1, index-opt.First) { continue } - fmt.Printf(" %d - %v", index, start) if isTextRotation { p.ClearTextRotation() p.SetTextRotation(opt.TextRotation) @@ -698,8 +695,10 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { x := 0 y := 0 if isVertical { - if positionCenter { + if opt.CenterLabels { start = (positions[index] + positions[index+1]) >> 1 + } else { + start = positions[index] } y = start + box.Height()>>1 switch opt.Align { @@ -711,7 +710,7 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { x = 0 } } else { - if positionCenter { + 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 @@ -736,7 +735,6 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { y += opt.Offset.Top p.Text(text, x, y) } - fmt.Printf("\n") if isTextRotation { p.ClearTextRotation() } diff --git a/range.go b/range.go index 0263f98..9fa3da7 100644 --- a/range.go +++ b/range.go @@ -12,88 +12,107 @@ type axisRange struct { min float64 max float64 size int - boundary bool } -type AxisRangeOption struct { - Painter *Painter - // The min value of axis - Min float64 - // The max value of axis - Max float64 - // The size of axis - Size int - // Boundary gap - Boundary bool - // The count of divide - DivideCount int -} - -// NewRange returns a axis range -func NewRange(opt AxisRangeOption) axisRange { - max := opt.Max - min := opt.Min - - max += math.Abs(max * 0.1) - min -= math.Abs(min * 0.1) - divideCount := opt.DivideCount - r := math.Abs(max - min) +const rangeMinPaddingPercent = 2.0 +const rangeMaxPaddingPercent = 10.0 // max padding percent per-side - // minimum unit calculation - unit := 1 - if r > 5 { - unit = 2 +// 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 float64, addPadding bool) axisRange { + if addPadding { + min, max = padRange(min, max) } - if r > 10 { - unit = 4 - } - if r > 30 { - unit = 5 + return axisRange{ + p: painter, + divideCount: divideCount, + min: min, + max: max, + size: size, } - if r > 100 { - unit = 10 +} + +func padRange(min, max float64) (float64, float64) { + span := max - min + spanIncrement := span * 0.01 // must be 1% of the span + var spanIncrementMultiplier float64 + targetFound := false +targetLoop: + for expo := -1.0; expo < 6; expo++ { + // use 10^expo so that we prefer 0, 10, 100, etc numbers + targetVal := math.Floor(math.Pow(10, expo)) // Math.Floor so -1 expo will convert 0.1 to 0 + if min < 0 { + targetVal *= -1 + } + // set default and then check if we can even look for this target within our padding range + spanIncrementMultiplier = rangeMinPaddingPercent + if targetVal > min-(spanIncrement*rangeMinPaddingPercent) { + continue targetLoop // we need to get further from zero to get into our minimum padding + } else if targetVal < min-(spanIncrement*rangeMaxPaddingPercent) { + break targetLoop // no match possible, use the min padding as a default + } + + for ; spanIncrementMultiplier < rangeMaxPaddingPercent; spanIncrementMultiplier++ { + adjustedMin := min - (spanIncrement * spanIncrementMultiplier) + if adjustedMin <= targetVal { // we found our target value between the min and adjustedMin + // set the min to the target value and the multiplier so the max can be set + targetFound = true + spanIncrementMultiplier = (min - targetVal) / spanIncrement + min = targetVal + break targetLoop + } + } } - if r > 200 { - unit = 20 + if !targetFound { + min, spanIncrementMultiplier = roundLimit(min, spanIncrement, rangeMinPaddingPercent, false) } - unit = int((r/float64(divideCount))/float64(unit))*unit + unit + // update max and match based off the ideal padding + max, _ = roundLimit(max, spanIncrement, spanIncrementMultiplier, true) + return min, max +} + +func roundLimit(val, increment, multiplier float64, add bool) (float64, float64) { + for orderOfMagnitude := math.Floor(math.Log10(val)); orderOfMagnitude > 0; orderOfMagnitude-- { + roundValue := math.Pow(10, orderOfMagnitude) + var proposedVal float64 + var proposedMultiplier float64 + for roundAdjust := 0.0; roundAdjust < 9.0; roundAdjust++ { + if add { + proposedVal = (math.Ceil(val/roundValue) * roundValue) + (roundValue * roundAdjust) + proposedMultiplier = (proposedVal - val) / increment + } else { + proposedVal = (math.Floor(val/roundValue) * roundValue) - (roundValue * roundAdjust) + proposedMultiplier = (val - proposedVal) / increment + } - if min != 0 { - isLessThanZero := min < 0 - min = float64(int(min/float64(unit)) * unit) - // if less than zero, int is rounded up, so adjust - if min < 0 || - (isLessThanZero && min == 0) { - min -= float64(unit) + if proposedMultiplier > rangeMaxPaddingPercent { + break // shortcut inner loop as multiplier will only go up + } else if proposedMultiplier >= rangeMinPaddingPercent { + return proposedVal, proposedMultiplier + } + } + if proposedMultiplier < rangeMinPaddingPercent { + break // shortcut outer loop if multiplier is below the min after adjust check, as this will only get smaller } } - max = min + float64(unit*divideCount) - expectMax := opt.Max * 2 - if max > expectMax { - max = float64(ceilFloatToInt(expectMax)) - } - return axisRange{ - p: opt.Painter, - divideCount: divideCount, - min: min, - max: max, - size: opt.Size, - boundary: opt.Boundary, + // no rounder alternative found, just adjust based off initial multiplier + if add { + return val + (increment * multiplier), multiplier + } else { + return val - (increment * multiplier), multiplier } } // Values returns values of range func (r axisRange) Values() []string { offset := (r.max - r.min) / float64(r.divideCount) - values := make([]string, 0) formatter := commafWithDigits if r.p != nil && r.p.valueFormatter != nil { formatter = r.p.valueFormatter } + values := make([]string, r.divideCount+1) for i := 0; i <= r.divideCount; i++ { v := r.min + float64(i)*offset - value := formatter(v) - values = append(values, value) + values[i] = formatter(v) } return values } diff --git a/util.go b/util.go index e4badae..f54b603 100644 --- a/util.go +++ b/util.go @@ -37,15 +37,6 @@ func containsInt(values []int, value int) bool { return false } -func containsString(values []string, value string) bool { - for _, v := range values { - if v == value { - return true - } - } - return false -} - func ceilFloatToInt(value float64) int { // TODO - why not use math.Ceil? // TODO - check usage for overflow risks diff --git a/yaxis.go b/yaxis.go index effd7c1..16a13cd 100644 --- a/yaxis.go +++ b/yaxis.go @@ -1,6 +1,8 @@ package charts -import "github.com/golang/freetype/truetype" +import ( + "github.com/golang/freetype/truetype" +) type YAxisOption struct { // The minimun value of axis. From d5619c7cab4dbb06dca16d9fa91ea8aa6f7444c6 Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Sat, 3 Feb 2024 16:12:15 -0700 Subject: [PATCH 3/8] Testing update to better debug SVG differences In addition axis asserts were updated as the updated SVG's match the expected rendering changes. Other tests show areas for potential improvement --- assert_test.go | 38 ++++++++++++++++++++++++++++++++++++ axis_test.go | 9 ++++----- bar_chart_test.go | 5 ++--- chart_option_test.go | 12 ++++++------ echarts_test.go | 2 +- horizontal_bar_chart_test.go | 5 ++--- line_chart_test.go | 7 +++---- mark_line_test.go | 5 ++--- mark_point_test.go | 3 +-- painter_test.go | 4 ++-- pie_chart_test.go | 3 +-- radar_chart_test.go | 3 +-- table_test.go | 3 +-- title_test.go | 3 +-- yaxis_test.go | 3 +-- 15 files changed, 66 insertions(+), 39 deletions(-) create mode 100644 assert_test.go diff --git a/assert_test.go b/assert_test.go new file mode 100644 index 0000000..935de5c --- /dev/null +++ b/assert_test.go @@ -0,0 +1,38 @@ +package charts + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func assertEqualSVG(t *testing.T, expected, actual string) { + t.Helper() + + if expected != actual { + expectedFile, err := writeTempFile(expected, t.Name()+"-expected", "svg") + require.NoError(t, err) + actualFile, err := writeTempFile(actual, t.Name()+"-actual", "svg") + require.NoError(t, err) + + t.Fatalf("SVG content does not match. Expected file: %s, Actual file: %s", + expectedFile, actualFile) + } +} + +func writeTempFile(content, prefix, extension string) (string, error) { + tmpFile, err := os.CreateTemp("", strings.ReplaceAll(prefix, string(os.PathSeparator), ".")+"-*."+extension) + if err != nil { + return "", err + } + defer tmpFile.Close() + + if _, err := tmpFile.WriteString(content); err != nil { + return "", err + } + + return filepath.Abs(tmpFile.Name()) +} diff --git a/axis_test.go b/axis_test.go index 8969256..581ea4c 100644 --- a/axis_test.go +++ b/axis_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wcharczuk/go-chart/v2/drawing" ) @@ -32,7 +31,7 @@ func TestAxis(t *testing.T) { }).Render() return p.Bytes() }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, // the bottom x-axis is on the left { @@ -51,7 +50,7 @@ func TestAxis(t *testing.T) { }).Render() return p.Bytes() }, - result: "\\nMonTueWedThuFriSatSun", + result: "\\nMonTueWedThuFriSatSun", }, // left y-axis { @@ -134,7 +133,7 @@ func TestAxis(t *testing.T) { }).Render() return p.Bytes() }, - result: "\\nMon --Tue --Wed --Thu --Fri --Sat --Sun --", + result: "\\nMon --Tue --Wed --Thu --Fri --Sat --Sun --", }, } @@ -148,7 +147,7 @@ func TestAxis(t *testing.T) { require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/bar_chart_test.go b/bar_chart_test.go index 6dd9de4..fb80091 100644 --- a/bar_chart_test.go +++ b/bar_chart_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -80,7 +79,7 @@ func TestBarChart(t *testing.T) { } return p.Bytes() }, - result: "\\n24020016012080400JanAprJulSepDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + result: "\\n200166.39132.7999.1965.5931.99-1.60JanFebMarMayJunJulAugSepNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, } @@ -94,7 +93,7 @@ func TestBarChart(t *testing.T) { require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/chart_option_test.go b/chart_option_test.go index b627623..9555807 100644 --- a/chart_option_test.go +++ b/chart_option_test.go @@ -176,7 +176,7 @@ func TestLineRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assert.Equal(t, "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", string(data)) + assertEqualSVG(t, "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.4k1.16k933.33700466.66233.330MonTueWedThuFriSatSun", string(data)) } func TestBarRender(t *testing.T) { @@ -248,7 +248,7 @@ func TestBarRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assert.Equal(t, "\\nRainfallEvaporation24020016012080400JanAprJulSepDec162.22182.22.341.6248.07", string(data)) + assertEqualSVG(t, "\\nRainfallEvaporation24020016012080400JanAprJulSepDec162.22182.22.341.6248.07", string(data)) } func TestHorizontalBarRender(t *testing.T) { @@ -296,7 +296,7 @@ func TestHorizontalBarRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assert.Equal(t, "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0122.28k244.56k366.84k489.12k611.4k733.68k", string(data)) + assertEqualSVG(t, "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0122.28k244.56k366.84k489.12k611.4k733.68k", string(data)) } func TestPieRender(t *testing.T) { @@ -337,7 +337,7 @@ func TestPieRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assert.Equal(t, "\\nSearch EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", string(data)) + assertEqualSVG(t, "\\nSearch EngineDirectEmailUnion AdsVideo AdsRainfall vs EvaporationFake DataSearch Engine: 33.3%Direct: 23.35%Email: 18.43%Union Ads: 15.37%Video Ads: 9.53%", string(data)) } func TestRadarRender(t *testing.T) { @@ -386,7 +386,7 @@ func TestRadarRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - require.Equal(t, "\\nAllocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", string(data)) + assertEqualSVG(t, "\\nAllocated BudgetActual SpendingBasic Radar ChartSalesAdministrationInformation TechnologyCustomer SupportDevelopmentMarketing", string(data)) } func TestFunnelRender(t *testing.T) { @@ -412,5 +412,5 @@ func TestFunnelRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assert.Equal(t, "\\nShowClickVisitInquiryOrderFunnelShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", string(data)) + assertEqualSVG(t, "\\nShowClickVisitInquiryOrderFunnelShow(100%)Click(80%)Visit(60%)Inquiry(40%)Order(20%)", string(data)) } diff --git a/echarts_test.go b/echarts_test.go index 37cdd3e..7aa4e7b 100644 --- a/echarts_test.go +++ b/echarts_test.go @@ -529,5 +529,5 @@ func TestRenderEChartsToSVG(t *testing.T) { ] }`) require.NoError(t, err) - assert.Equal(t, "\\nRainfallEvaporationRainfall vs EvaporationFake Data24020016012080400JanAprJulSepDec162.22182.22.341.6248.07", string(data)) + assertEqualSVG(t, "\\nRainfallEvaporationRainfall vs EvaporationFake Data200166.39132.7999.1965.5931.99-1.60JanFebMarMayJunJulAugSepNovDec162.22182.22.341.6248.07", string(data)) } diff --git a/horizontal_bar_chart_test.go b/horizontal_bar_chart_test.go index d83ac60..8d3b59b 100644 --- a/horizontal_bar_chart_test.go +++ b/horizontal_bar_chart_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -61,7 +60,7 @@ func TestHorizontalBarChart(t *testing.T) { } return p.Bytes() }, - result: "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0122.28k244.56k366.84k489.12k611.4k733.68k", + result: "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0116.66k233.33k350k466.66k583.33k700k", }, } for i, tt := range tests { @@ -74,7 +73,7 @@ func TestHorizontalBarChart(t *testing.T) { require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/line_chart_test.go b/line_chart_test.go index f4afcce..171a78d 100644 --- a/line_chart_test.go +++ b/line_chart_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -95,7 +94,7 @@ func TestLineChart(t *testing.T) { } return p.Bytes() }, - result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", + result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.4k1.16k933.33700466.66233.330MonTueWedThuFriSatSun", }, { render: func(p *Painter) ([]byte, error) { @@ -179,7 +178,7 @@ func TestLineChart(t *testing.T) { } return p.Bytes() }, - result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.2k9607204802400MonTueWedThuFriSatSun", + result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.4k1.16k933.33700466.66233.330MonTueWedThuFriSatSun", }, } @@ -193,7 +192,7 @@ func TestLineChart(t *testing.T) { require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/mark_line_test.go b/mark_line_test.go index 66fa50b..75dbbc9 100644 --- a/mark_line_test.go +++ b/mark_line_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wcharczuk/go-chart/v2/drawing" ) @@ -39,7 +38,7 @@ func TestMarkLine(t *testing.T) { } return p.Bytes() }, - result: "\\n321", + result: "\\n321", }, } for i, tt := range tests { @@ -57,7 +56,7 @@ func TestMarkLine(t *testing.T) { Bottom: 20, }))) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/mark_point_test.go b/mark_point_test.go index ea08ad3..769f961 100644 --- a/mark_point_test.go +++ b/mark_point_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wcharczuk/go-chart/v2/drawing" ) @@ -65,7 +64,7 @@ func TestMarkPoint(t *testing.T) { Bottom: 20, }))) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/painter_test.go b/painter_test.go index f261daa..843d42b 100644 --- a/painter_test.go +++ b/painter_test.go @@ -316,7 +316,7 @@ func TestPainter(t *testing.T) { tt.fn(d) data, err := d.Bytes() require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } @@ -349,5 +349,5 @@ func TestPainterTextFit(t *testing.T) { buf, err := p.Bytes() require.NoError(t, err) - assert.Equal(t, `\nHelloWorld!Hello World!`, string(buf)) + assertEqualSVG(t, "\\nHelloWorld!Hello World!", string(buf)) } diff --git a/pie_chart_test.go b/pie_chart_test.go index 6135956..d34e0ed 100644 --- a/pie_chart_test.go +++ b/pie_chart_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -74,7 +73,7 @@ func TestPieChart(t *testing.T) { Bottom: 20, }))) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/radar_chart_test.go b/radar_chart_test.go index 82bf328..4fd2105 100644 --- a/radar_chart_test.go +++ b/radar_chart_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -81,7 +80,7 @@ func TestRadarChart(t *testing.T) { Bottom: 20, }))) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/table_test.go b/table_test.go index b281b31..cbe6b2c 100644 --- a/table_test.go +++ b/table_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -114,7 +113,7 @@ func TestTableChart(t *testing.T) { require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/title_test.go b/title_test.go index b5ed5a3..2c80900 100644 --- a/title_test.go +++ b/title_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -68,7 +67,7 @@ func TestTitleRenderer(t *testing.T) { require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } diff --git a/yaxis_test.go b/yaxis_test.go index dc5fa0a..6809e37 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -4,7 +4,6 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -45,7 +44,7 @@ func TestRightYAxis(t *testing.T) { require.NoError(t, err) data, err := tt.render(p) require.NoError(t, err) - assert.Equal(t, tt.result, string(data)) + assertEqualSVG(t, tt.result, string(data)) }) } } From 7f716db4c3081066ae36ef2fc69dceeadcfe7c74 Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Mon, 5 Feb 2024 23:31:10 -0700 Subject: [PATCH 4/8] Improve yAxis range determiniation and verified line chart tests This improves the y axis by picking a max value that results in more round intervals. In addition the label counts were better defined in code, and the line chart tests were updated --- axis.go | 6 +- bar_chart.go | 2 +- bar_chart_test.go | 2 +- chart_option_test.go | 2 +- charts.go | 13 ++-- echarts_test.go | 2 +- horizontal_bar_chart.go | 2 +- line_chart_test.go | 4 +- mark_line_test.go | 4 +- range.go | 146 ++++++++++++++++++++++------------- range_test.go | 166 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 279 insertions(+), 70 deletions(-) create mode 100644 range_test.go diff --git a/axis.go b/axis.go index d9d6170..8e4ce74 100644 --- a/axis.go +++ b/axis.go @@ -200,11 +200,11 @@ func (a *axisPainter) Render() (Box, error) { labelCount := opt.LabelCount if labelCount <= 0 { var maxLabelCount int - // Add 20px for some minimal extra padding and calculate more suitable display item count on text width + // Add 10px for some minimal extra padding and calculate more suitable display item count on text width if orient == OrientVertical { - maxLabelCount = top.Height() / (textMaxHeight + 20) + maxLabelCount = top.Height() / (textMaxHeight + 10) } else { - maxLabelCount = top.Width() / (textMaxWidth + 20) + maxLabelCount = top.Width() / (textMaxWidth + 10) } if opt.Unit > 0 { multiplier := 1.0 diff --git a/bar_chart.go b/bar_chart.go index 1dafe15..a51b806 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -48,7 +48,7 @@ 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, false) // TODO - verify range + xRange := NewRange(b.p, seriesPainter.Width(), len(opt.XAxis.Data), 0.0, 0.0, false) x0, x1 := xRange.GetRange(0) width := int(x1 - x0) // margin between each block diff --git a/bar_chart_test.go b/bar_chart_test.go index fb80091..c86e09d 100644 --- a/bar_chart_test.go +++ b/bar_chart_test.go @@ -79,7 +79,7 @@ func TestBarChart(t *testing.T) { } return p.Bytes() }, - result: "\\n200166.39132.7999.1965.5931.99-1.60JanFebMarMayJunJulAugSepNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + result: "\\n207184161138115926946230JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, } diff --git a/chart_option_test.go b/chart_option_test.go index 9555807..cc91d65 100644 --- a/chart_option_test.go +++ b/chart_option_test.go @@ -176,7 +176,7 @@ func TestLineRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.4k1.16k933.33700466.66233.330MonTueWedThuFriSatSun", string(data)) + assertEqualSVG(t, "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", string(data)) } func TestBarRender(t *testing.T) { diff --git a/charts.go b/charts.go index f5c92ec..1a1a981 100644 --- a/charts.go +++ b/charts.go @@ -12,6 +12,7 @@ const labelFontSize = 10 const smallLabelFontSize = 8 const defaultDotWidth = 2.0 const defaultStrokeWidth = 2.0 +const defaultAxisLabelCount = 10 var defaultChartWidth = 600 var defaultChartHeight = 400 @@ -170,15 +171,15 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e max = *yAxisOption.Max } span := max - min - divideCount := yAxisOption.LabelCount - if divideCount <= 0 { + labelCount := yAxisOption.LabelCount + if labelCount <= 0 { if yAxisOption.Unit > 0 { - divideCount = int(span / yAxisOption.Unit) + labelCount = int(span / yAxisOption.Unit) } else { - divideCount = defaultAxisDivideCount + labelCount = defaultAxisLabelCount } } - r := NewRange(p, rangeHeight, divideCount, min, max, padRange) + r := NewRange(p, rangeHeight, labelCount, min, max, padRange) result.axisRanges[index] = r if yAxisOption.Theme == nil { @@ -189,7 +190,7 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e } else { yAxisOption.isCategoryAxis = true // since the x-axis is the value part, it's label is calculated and processed separately - opt.XAxis.Data = NewRange(p, rangeHeight, defaultAxisDivideCount, min, max, padRange).Values() + opt.XAxis.Data = NewRange(p, rangeHeight, defaultAxisLabelCount, min, max, padRange).Values() opt.XAxis.isValueAxis = true } reverseStringSlice(yAxisOption.Data) diff --git a/echarts_test.go b/echarts_test.go index 7aa4e7b..6f130c5 100644 --- a/echarts_test.go +++ b/echarts_test.go @@ -529,5 +529,5 @@ func TestRenderEChartsToSVG(t *testing.T) { ] }`) require.NoError(t, err) - assertEqualSVG(t, "\\nRainfallEvaporationRainfall vs EvaporationFake Data200166.39132.7999.1965.5931.99-1.60JanFebMarMayJunJulAugSepNovDec162.22182.22.341.6248.07", string(data)) + assertEqualSVG(t, "\\nRainfallEvaporationRainfall vs EvaporationFake Data207184161138115926946230JanFebMarAprMayJunJulAugSepOctNovDec162.22182.22.341.6248.07", string(data)) } diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go index 3ef5ba2..09182e8 100644 --- a/horizontal_bar_chart.go +++ b/horizontal_bar_chart.go @@ -69,7 +69,7 @@ func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList Seri theme := opt.Theme max, min := seriesList.GetMaxMin(0) - xRange := NewRange(p, seriesPainter.Width(), defaultAxisDivideCount, min, max, false) // TODO - pad range? + xRange := NewRange(p, seriesPainter.Width(), defaultAxisLabelCount, min, max, false) seriesNames := seriesList.Names() rendererList := []Renderer{} diff --git a/line_chart_test.go b/line_chart_test.go index 171a78d..1f5cc54 100644 --- a/line_chart_test.go +++ b/line_chart_test.go @@ -94,7 +94,7 @@ func TestLineChart(t *testing.T) { } return p.Bytes() }, - result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.4k1.16k933.33700466.66233.330MonTueWedThuFriSatSun", + result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", }, { render: func(p *Painter) ([]byte, error) { @@ -178,7 +178,7 @@ func TestLineChart(t *testing.T) { } return p.Bytes() }, - result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.4k1.16k933.33700466.66233.330MonTueWedThuFriSatSun", + result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", }, } diff --git a/mark_line_test.go b/mark_line_test.go index 75dbbc9..8df111d 100644 --- a/mark_line_test.go +++ b/mark_line_test.go @@ -31,14 +31,14 @@ func TestMarkLine(t *testing.T) { FontColor: drawing.ColorBlack, StrokeColor: drawing.ColorBlack, Series: series, - Range: NewRange(p, p.Height(), 6, 0.0, 5.0, false), + Range: NewRange(p, p.Height(), 6, 0.0, 5.0, true), }) if _, err := markLine.Render(); err != nil { return nil, err } return p.Bytes() }, - result: "\\n321", + result: "\\n321", }, } for i, tt := range tests { diff --git a/range.go b/range.go index 9fa3da7..e0787bf 100644 --- a/range.go +++ b/range.go @@ -4,7 +4,11 @@ import ( "math" ) -const defaultAxisDivideCount = 6 +const rangeMinPaddingPercentMin = 0.0 // increasing could result in forced negative y-axis minimum +const rangeMinPaddingPercentMax = 10.0 +const rangeMaxPaddingPercentMin = 5.0 // set minimum spacing at the top of the graph +const rangeMaxPaddingPercentMax = 20.0 // larger to allow better chances of finding a good interval +const rangeDefaultPaddingPercent = 5.0 type axisRange struct { p *Painter @@ -14,110 +18,148 @@ type axisRange struct { size int } -const rangeMinPaddingPercent = 2.0 -const rangeMaxPaddingPercent = 10.0 // max padding percent per-side - // 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 float64, addPadding bool) axisRange { +func NewRange(painter *Painter, size, labelCount int, min, max float64, addPadding bool) axisRange { if addPadding { - min, max = padRange(min, max) + min, max = padRange(labelCount, min, max) } return axisRange{ p: painter, - divideCount: divideCount, + divideCount: labelCount, min: min, max: max, size: size, } } -func padRange(min, max float64) (float64, float64) { +func padRange(labelCount int, min, max float64) (float64, float64) { + minResult := min + maxResult := max span := max - min spanIncrement := span * 0.01 // must be 1% of the span var spanIncrementMultiplier float64 - targetFound := false -targetLoop: - for expo := -1.0; expo < 6; expo++ { - // use 10^expo so that we prefer 0, 10, 100, etc numbers - targetVal := math.Floor(math.Pow(10, expo)) // Math.Floor so -1 expo will convert 0.1 to 0 - if min < 0 { - targetVal *= -1 - } - // set default and then check if we can even look for this target within our padding range - spanIncrementMultiplier = rangeMinPaddingPercent - if targetVal > min-(spanIncrement*rangeMinPaddingPercent) { - continue targetLoop // we need to get further from zero to get into our minimum padding - } else if targetVal < min-(spanIncrement*rangeMaxPaddingPercent) { - break targetLoop // no match possible, use the min padding as a default - } + // find a min value to start our range from + // we prefer (in order, negative if necessary), 0, 1, 10, 100, ..., 2, 20, ..., 5, 50, ... +rootLoop: + for _, multiple := range []float64{1.0, 2.0, 5.0} { + expoLoop: + for expo := -1.0; expo < 6; expo++ { + // use 10^expo so that we prefer 0, 10, 100, etc numbers + targetVal := math.Floor(math.Pow(10, expo)) * multiple // Math.Floor so -1 expo will convert 0.1 to 0 + if targetVal == 0 && multiple != 1 { + continue expoLoop // we already tested this value + } + if min < 0 { + targetVal *= -1 + } + // set default and then check if we can even look for this target within our padding range + if targetVal > min-(spanIncrement*rangeMinPaddingPercentMin) { + continue expoLoop // we need to get further from zero to get into our minimum padding + } else if targetVal < min-(spanIncrement*rangeMinPaddingPercentMax) { + break expoLoop // no match possible, use the min padding as a default + } - for ; spanIncrementMultiplier < rangeMaxPaddingPercent; spanIncrementMultiplier++ { - adjustedMin := min - (spanIncrement * spanIncrementMultiplier) - if adjustedMin <= targetVal { // we found our target value between the min and adjustedMin - // set the min to the target value and the multiplier so the max can be set - targetFound = true - spanIncrementMultiplier = (min - targetVal) / spanIncrement - min = targetVal - break targetLoop + spanIncrementMultiplier = rangeMinPaddingPercentMin + for ; spanIncrementMultiplier < rangeMinPaddingPercentMax; spanIncrementMultiplier++ { + adjustedMin := min - (spanIncrement * spanIncrementMultiplier) + if adjustedMin <= targetVal { // we found our target value between the min and adjustedMin + // set the min to the target value and the multiplier so the max can be set + spanIncrementMultiplier = (min - targetVal) / spanIncrement + minResult = targetVal + break rootLoop + } } } } - if !targetFound { - min, spanIncrementMultiplier = roundLimit(min, spanIncrement, rangeMinPaddingPercent, false) + if minResult == min { + minResult, spanIncrementMultiplier = + friendlyRound(min, spanIncrement, rangeDefaultPaddingPercent, + rangeMinPaddingPercentMin, rangeMinPaddingPercentMax, false) } // update max and match based off the ideal padding - max, _ = roundLimit(max, spanIncrement, spanIncrementMultiplier, true) - return min, max + maxResult, _ = + friendlyRound(max, spanIncrement, spanIncrementMultiplier, + rangeMaxPaddingPercentMin, rangeMaxPaddingPercentMax, true) + // adjust max so that the intervals and labels are also round if possible + interval := (maxResult - minResult) / float64(labelCount-1) + maxIntervalIncrease := ((rangeMaxPaddingPercentMax * spanIncrement) - (maxResult - max)) / float64(labelCount-1) + roundedInterval, _ := friendlyRound(interval, 1.0, 0.0, 0.0, maxIntervalIncrease, true) + + if roundedInterval != interval { + maxResult = minResult + (roundedInterval * float64(labelCount-1)) + } + + return minResult, maxResult } -func roundLimit(val, increment, multiplier float64, add bool) (float64, float64) { - for orderOfMagnitude := math.Floor(math.Log10(val)); orderOfMagnitude > 0; orderOfMagnitude-- { +func friendlyRound(val, increment, defaultMultiplier, minMultiplier, maxMultiplier float64, add bool) (float64, float64) { + absVal := math.Abs(val) + for orderOfMagnitude := math.Floor(math.Log10(absVal)); orderOfMagnitude > 0; orderOfMagnitude-- { roundValue := math.Pow(10, orderOfMagnitude) var proposedVal float64 var proposedMultiplier float64 for roundAdjust := 0.0; roundAdjust < 9.0; roundAdjust++ { if add { - proposedVal = (math.Ceil(val/roundValue) * roundValue) + (roundValue * roundAdjust) + proposedVal = (math.Ceil(absVal/roundValue) * roundValue) + (roundValue * roundAdjust) + } else { + proposedVal = (math.Floor(absVal/roundValue) * roundValue) + (roundValue * roundAdjust) + } + if val < 0 { // Apply the original sign back to proposedVal + proposedVal = -proposedVal + } + if add { proposedMultiplier = (proposedVal - val) / increment } else { - proposedVal = (math.Floor(val/roundValue) * roundValue) - (roundValue * roundAdjust) proposedMultiplier = (val - proposedVal) / increment } - if proposedMultiplier > rangeMaxPaddingPercent { + if proposedMultiplier > maxMultiplier { break // shortcut inner loop as multiplier will only go up - } else if proposedMultiplier >= rangeMinPaddingPercent { + } else if proposedMultiplier > minMultiplier { return proposedVal, proposedMultiplier } } - if proposedMultiplier < rangeMinPaddingPercent { + if proposedMultiplier <= minMultiplier { break // shortcut outer loop if multiplier is below the min after adjust check, as this will only get smaller } } - // no rounder alternative found, just adjust based off initial multiplier + // No match found, let's see if we can just round to the next whole number + if (increment*maxMultiplier) >= 1.0 && val != math.Trunc(val) { + var proposedVal float64 + var proposedMultiplier float64 + if add { + proposedVal = math.Ceil(val) + proposedMultiplier = (proposedVal - val) / increment + } else { + proposedVal = math.Floor(val) + proposedMultiplier = (val - proposedVal) / increment + } + return proposedVal, proposedMultiplier + } + // No rounder alternative found, just adjust based off default multiplier if add { - return val + (increment * multiplier), multiplier + return val + (increment * defaultMultiplier), defaultMultiplier } else { - return val - (increment * multiplier), multiplier + return val - (increment * defaultMultiplier), defaultMultiplier } } // Values returns values of range func (r axisRange) Values() []string { - offset := (r.max - r.min) / float64(r.divideCount) + offset := (r.max - r.min) / float64(r.divideCount-1) formatter := commafWithDigits if r.p != nil && r.p.valueFormatter != nil { formatter = r.p.valueFormatter } - values := make([]string, r.divideCount+1) - for i := 0; i <= r.divideCount; i++ { + values := make([]string, r.divideCount) + for i := 0; i < r.divideCount; i++ { v := r.min + float64(i)*offset values[i] = formatter(v) } return values } -func (r *axisRange) getHeight(value float64) int { +func (r axisRange) getHeight(value float64) int { if r.max <= r.min { return 0 } @@ -125,17 +167,17 @@ func (r *axisRange) getHeight(value float64) int { return int(v * float64(r.size)) } -func (r *axisRange) getRestHeight(value float64) int { +func (r axisRange) getRestHeight(value float64) int { return r.size - r.getHeight(value) } // GetRange returns a range of index -func (r *axisRange) GetRange(index int) (float64, float64) { +func (r axisRange) GetRange(index int) (float64, float64) { unit := float64(r.size) / float64(r.divideCount) return unit * float64(index), unit * float64(index+1) } // AutoDivide divides the axis -func (r *axisRange) AutoDivide() []int { +func (r axisRange) AutoDivide() []int { return autoDivide(r.size, r.divideCount) } diff --git a/range_test.go b/range_test.go new file mode 100644 index 0000000..7f5fca8 --- /dev/null +++ b/range_test.go @@ -0,0 +1,166 @@ +package charts + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFriendlyRound(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + expectedValue float64 + value float64 + minMultiplier float64 + maxMultiplier float64 + add bool + }{ + { + name: "OriginalZeroSub", + expectedValue: 0.0, + value: 0.0, + minMultiplier: 0.0, + maxMultiplier: 2.0, + add: false, + }, + { + name: "OriginalZeroAdd", + expectedValue: 0.0, + value: 0.0, + minMultiplier: 0.0, + maxMultiplier: 2.0, + add: true, + }, + { + name: "RoundFractionSub", + expectedValue: -2.0, + value: -1.2, + minMultiplier: 0.0, + maxMultiplier: 2.0, + add: false, + }, + { + name: "RoundFractionAdd", + expectedValue: 2.0, + value: 1.2, + minMultiplier: 0.0, + maxMultiplier: 2.0, + add: true, + }, + { + name: "RoundVeryCloseToZeroSub", + expectedValue: -1.0, + value: -0.01, + minMultiplier: 0.0, + maxMultiplier: 2.0, + add: false, + }, + { + name: "RoundVeryCloseToZeroAdd", + expectedValue: 0.0, + value: -0.01, + minMultiplier: 0.0, + maxMultiplier: 2.0, + add: true, + }, + { + name: "OriginalLargeSub", + expectedValue: 1337, + value: 1337, + minMultiplier: 0.0, + maxMultiplier: 2.0, + add: false, + }, + { + name: "OriginalLargeAdd", + expectedValue: 1337, + value: 1337, + minMultiplier: 0.0, + maxMultiplier: 2.0, + add: true, + }, + { + name: "RoundThousandLargeSub", + expectedValue: 1000, + value: 1337, + minMultiplier: 0.0, + maxMultiplier: 1000.0, + add: false, + }, + { + name: "RoundThousandLargeAdd", + expectedValue: 2000, + value: 1337, + minMultiplier: 0.0, + maxMultiplier: 1000.0, + add: true, + }, + { + name: "RoundHundredLargeSub", + expectedValue: 1300, + value: 1337, + minMultiplier: 0.0, + maxMultiplier: 100.0, + add: false, + }, + { + name: "RoundHundredLargeAdd", + expectedValue: 1400, + value: 1337, + minMultiplier: 0.0, + maxMultiplier: 100.0, + add: true, + }, + { + name: "RoundNegativeSmallSub", + expectedValue: -1.0, + value: -0.5, + minMultiplier: 0.0, + maxMultiplier: 2.0, + add: false, + }, + { + name: "RoundHalfwayPointSub", + expectedValue: 100.0, + value: 150.0, + minMultiplier: 0.0, + maxMultiplier: 100.0, + add: false, + }, + { + name: "RoundHalfwayPointAdd", + expectedValue: 200.0, + value: 150.0, + minMultiplier: 0.0, + maxMultiplier: 100.0, + add: true, + }, + { + name: "RoundThousandsNegativeLargeSub", + expectedValue: -2000.0, + value: -1337.0, + minMultiplier: 0.0, + maxMultiplier: 1000.0, + add: false, + }, + { + name: "RoundHundredsNegativeLargeSub", + expectedValue: -1400.0, + value: -1337.0, + minMultiplier: 0.0, + maxMultiplier: 100.0, + add: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + val, _ := friendlyRound(tc.value, 1.0, 0.0, + tc.minMultiplier, tc.maxMultiplier, tc.add) + + assert.Equal(t, tc.expectedValue, val, "Unexpected value rounding %v", tc.value) + }) + } +} From 28ad59eeabb15d69d8f4ee5adc8074c3b68821b3 Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Sun, 4 Feb 2024 11:37:16 -0700 Subject: [PATCH 5/8] More range testing and improvements This change includes performance improvements by reducing the looping for min padding calculation, and only calculating the padded max value once. There remains an issue with bar chart rendering that needs to be addressed. --- mark_line_test.go | 2 +- range.go | 77 +++++++++++++++++++++-------------------------- range_test.go | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 43 deletions(-) diff --git a/mark_line_test.go b/mark_line_test.go index 8df111d..bbe18e3 100644 --- a/mark_line_test.go +++ b/mark_line_test.go @@ -38,7 +38,7 @@ func TestMarkLine(t *testing.T) { } return p.Bytes() }, - result: "\\n321", + result: "\\n321", }, } for i, tt := range tests { diff --git a/range.go b/range.go index e0787bf..626c68e 100644 --- a/range.go +++ b/range.go @@ -5,9 +5,9 @@ import ( ) const rangeMinPaddingPercentMin = 0.0 // increasing could result in forced negative y-axis minimum -const rangeMinPaddingPercentMax = 10.0 -const rangeMaxPaddingPercentMin = 5.0 // set minimum spacing at the top of the graph -const rangeMaxPaddingPercentMax = 20.0 // larger to allow better chances of finding a good interval +const rangeMinPaddingPercentMax = 20.0 +const rangeMaxPaddingPercentMin = 5.0 // set minimum spacing at the top of the graph +const rangeMaxPaddingPercentMax = 20.0 const rangeDefaultPaddingPercent = 5.0 type axisRange struct { @@ -35,58 +35,51 @@ func NewRange(painter *Painter, size, labelCount int, min, max float64, addPaddi func padRange(labelCount int, min, max float64) (float64, float64) { minResult := min maxResult := max - span := max - min - spanIncrement := span * 0.01 // must be 1% of the span + spanIncrement := (max - min) * 0.01 // must be 1% of the span var spanIncrementMultiplier float64 // find a min value to start our range from // we prefer (in order, negative if necessary), 0, 1, 10, 100, ..., 2, 20, ..., 5, 50, ... + updatedMin := false rootLoop: for _, multiple := range []float64{1.0, 2.0, 5.0} { + if min < 0 { + multiple *= -1 // convert multiple sign to adjust targetVal correctly + } expoLoop: for expo := -1.0; expo < 6; expo++ { - // use 10^expo so that we prefer 0, 10, 100, etc numbers - targetVal := math.Floor(math.Pow(10, expo)) * multiple // Math.Floor so -1 expo will convert 0.1 to 0 - if targetVal == 0 && multiple != 1 { - continue expoLoop // we already tested this value - } - if min < 0 { - targetVal *= -1 - } - // set default and then check if we can even look for this target within our padding range - if targetVal > min-(spanIncrement*rangeMinPaddingPercentMin) { - continue expoLoop // we need to get further from zero to get into our minimum padding - } else if targetVal < min-(spanIncrement*rangeMinPaddingPercentMax) { - break expoLoop // no match possible, use the min padding as a default - } - - spanIncrementMultiplier = rangeMinPaddingPercentMin - for ; spanIncrementMultiplier < rangeMinPaddingPercentMax; spanIncrementMultiplier++ { - adjustedMin := min - (spanIncrement * spanIncrementMultiplier) - if adjustedMin <= targetVal { // we found our target value between the min and adjustedMin - // set the min to the target value and the multiplier so the max can be set - spanIncrementMultiplier = (min - targetVal) / spanIncrement - minResult = targetVal - break rootLoop - } + if expo == -1.0 && multiple != 1.0 { + continue expoLoop // we only want to test targetVal 0 once } + // use 10^expo so that we prefer 0, 10, 100, etc numbers + targetVal := math.Floor(math.Pow(10, expo)) * multiple // Math.Floor to convert 0.1 from -1 expo into 0 + if targetVal < min-(spanIncrement*rangeMinPaddingPercentMax) { + break expoLoop // no match possible, target value will only get further from start + } else if targetVal <= min-(spanIncrement*rangeMinPaddingPercentMin) { + // targetVal can be between our span increment increases, calculate and set result + updatedMin = true + spanIncrementMultiplier = (min - targetVal) / spanIncrement + minResult = targetVal + break rootLoop + } // else try again to meet minimum padding requirements } } - if minResult == min { + if !updatedMin { minResult, spanIncrementMultiplier = - friendlyRound(min, spanIncrement, rangeDefaultPaddingPercent, + friendlyRound(min, spanIncrement, rangeMinPaddingPercentMin, rangeMinPaddingPercentMin, rangeMinPaddingPercentMax, false) } - // update max and match based off the ideal padding - maxResult, _ = - friendlyRound(max, spanIncrement, spanIncrementMultiplier, - rangeMaxPaddingPercentMin, rangeMaxPaddingPercentMax, true) - // adjust max so that the intervals and labels are also round if possible - interval := (maxResult - minResult) / float64(labelCount-1) - maxIntervalIncrease := ((rangeMaxPaddingPercentMax * spanIncrement) - (maxResult - max)) / float64(labelCount-1) - roundedInterval, _ := friendlyRound(interval, 1.0, 0.0, 0.0, maxIntervalIncrease, true) + if minTrunk := math.Trunc(minResult); minTrunk <= min-(spanIncrement*rangeMinPaddingPercentMin) { + minResult = minTrunk // remove possible float multiplication inaccuracies + } - if roundedInterval != interval { - maxResult = minResult + (roundedInterval * float64(labelCount-1)) + // update max to provide ideal padding and human friendly intervals + interval := (max - minResult) / float64(labelCount-1) + roundedInterval, _ := friendlyRound(interval, spanIncrement/float64(labelCount-1), + math.Max(spanIncrementMultiplier, rangeMaxPaddingPercentMin), + rangeMaxPaddingPercentMin, rangeMaxPaddingPercentMax, true) + maxResult = minResult + (roundedInterval * float64(labelCount-1)) + if maxTrunk := math.Trunc(maxResult); maxTrunk >= max+(spanIncrement*rangeMaxPaddingPercentMin) { + maxResult = maxTrunk // remove possible float multiplication inaccuracies } return minResult, maxResult @@ -105,7 +98,7 @@ func friendlyRound(val, increment, defaultMultiplier, minMultiplier, maxMultipli proposedVal = (math.Floor(absVal/roundValue) * roundValue) + (roundValue * roundAdjust) } if val < 0 { // Apply the original sign back to proposedVal - proposedVal = -proposedVal + proposedVal *= -1 } if add { proposedMultiplier = (proposedVal - val) / increment diff --git a/range_test.go b/range_test.go index 7f5fca8..b18ccb8 100644 --- a/range_test.go +++ b/range_test.go @@ -6,6 +6,69 @@ import ( "github.com/stretchr/testify/assert" ) +func TestPadRange(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + expectedMinValue float64 + expectedMaxValue float64 + minValue float64 + maxValue float64 + labelCount int + }{ + { + name: "PadMaxOnly", + expectedMinValue: 0.0, + expectedMaxValue: 10.5, + minValue: 0.0, + maxValue: 10.0, + labelCount: 10, + }, + { + name: "PadMinToZero", + expectedMinValue: 0.0, + expectedMaxValue: 21.0, + minValue: 1.0, + maxValue: 20.0, + labelCount: 10, + }, + { + name: "PadNegativeMinPositiveMax", + expectedMinValue: -5.0, + expectedMaxValue: 12.0, + minValue: -3.0, + maxValue: 10.0, + labelCount: 10, + }, + { + name: "PadNegativeMinNegativeMax", + expectedMinValue: -20.0, + expectedMaxValue: -9.0, + minValue: -20.0, + maxValue: -10.0, + labelCount: 10, + }, + { + name: "PadPositiveMinPositiveMax", + expectedMinValue: 100.0, + expectedMaxValue: 214.0, + minValue: 100.0, + maxValue: 200.0, + labelCount: 20, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + min, max := padRange(tc.labelCount, tc.minValue, tc.maxValue) + + assert.Equal(t, tc.expectedMinValue, min, "Unexpected value rounding %v", tc.minValue) + assert.Equal(t, tc.expectedMaxValue, max, "Unexpected value rounding %v", tc.maxValue) + }) + } +} + func TestFriendlyRound(t *testing.T) { t.Parallel() From 18f11b4cac8fd1f1d49e7276a1b5d891f07e70ce Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Sun, 4 Feb 2024 20:53:29 -0700 Subject: [PATCH 6/8] Allow the axis range padding to be configured, by default increase if mark points are used --- bar_chart.go | 4 ++-- bar_chart_test.go | 2 +- chart_option.go | 25 ++++++++++++++++++++++--- chart_option_test.go | 5 ++--- charts.go | 18 +++++++++--------- echarts_test.go | 2 +- horizontal_bar_chart.go | 8 ++++---- mark_line_test.go | 2 +- mark_point.go | 11 +++++------ mark_point_test.go | 2 +- range.go | 34 ++++++++++++++++++++-------------- range_test.go | 2 +- series.go | 6 +++--- series_test.go | 2 +- yaxis.go | 2 ++ 15 files changed, 75 insertions(+), 50 deletions(-) diff --git a/bar_chart.go b/bar_chart.go index a51b806..4c0669b 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -48,7 +48,7 @@ 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, false) + xRange := NewRange(b.p, seriesPainter.Width(), len(opt.XAxis.Data), 0.0, 0.0, 0.0) x0, x1 := xRange.GetRange(0) width := int(x1 - x0) // margin between each block @@ -108,7 +108,7 @@ func (b *barChart) render(result *defaultRenderResult, seriesList SeriesList) (B x += index * (barWidth + barMargin) } - h := int(yRange.getHeight(item.Value)) + h := yRange.getHeight(item.Value) fillColor := seriesColor if !item.Style.FillColor.IsZero() { fillColor = item.Style.FillColor diff --git a/bar_chart_test.go b/bar_chart_test.go index c86e09d..2f2bf0a 100644 --- a/bar_chart_test.go +++ b/bar_chart_test.go @@ -79,7 +79,7 @@ func TestBarChart(t *testing.T) { } return p.Bytes() }, - result: "\\n207184161138115926946230JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", + result: "\\n189168147126105846342210JanFebMarAprMayJunJulAugSepOctNovDec24.9723.225.676.7135.6162.232.6206.43.32.65.9926.428.770.7175.6182.248.718.862.3", }, } diff --git a/chart_option.go b/chart_option.go index df0de14..6aab652 100644 --- a/chart_option.go +++ b/chart_option.go @@ -242,16 +242,35 @@ func (o *ChartOption) fillDefault() { } o.Width = getDefaultInt(o.Width, defaultChartWidth) o.Height = getDefaultInt(o.Height, defaultChartHeight) + yAxisOptions := make([]YAxisOption, axisCount) copy(yAxisOptions, o.YAxisOptions) o.YAxisOptions = yAxisOptions - o.font, _ = GetFont(o.FontFamily) + // TODO - this is a hack, we need to update the yaxis based on the markpoint state + // TODO - but can't do this earlier due to needing the axis initialized + // TODO - we should reconsider the API for configuration + hasMarkpoint := false + for _, sl := range o.SeriesList { + if len(sl.MarkPoint.Data) > 0 { + hasMarkpoint = true + break + } + } + if hasMarkpoint { + for i := range o.YAxisOptions { + if o.YAxisOptions[i].RangeValuePaddingScale == nil { + defaultPadding := 2.5 // default a larger padding to give space for the mark point + o.YAxisOptions[i].RangeValuePaddingScale = &defaultPadding + } + } + } + o.font, _ = GetFont(o.FontFamily) if o.font == nil { o.font, _ = GetDefaultFont() - } else { - t.SetFont(o.font) } + t.SetFont(o.font) + if o.BackgroundColor.IsZero() { o.BackgroundColor = t.GetBackgroundColor() } diff --git a/chart_option_test.go b/chart_option_test.go index cc91d65..dc757ce 100644 --- a/chart_option_test.go +++ b/chart_option_test.go @@ -232,8 +232,7 @@ func TestBarRender(t *testing.T) { "Evaporation", }, PositionRight), MarkLineOptionFunc(0, SeriesMarkDataTypeAverage), - MarkPointOptionFunc(0, SeriesMarkDataTypeMax, - SeriesMarkDataTypeMin), + MarkPointOptionFunc(0, SeriesMarkDataTypeMax, SeriesMarkDataTypeMin), // custom option func func(opt *ChartOption) { opt.SeriesList[1].MarkPoint = NewMarkPoint( @@ -248,7 +247,7 @@ func TestBarRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "\\nRainfallEvaporation24020016012080400JanAprJulSepDec162.22182.22.341.6248.07", string(data)) + assertEqualSVG(t, "\\nRainfallEvaporation2702402101801501209060300JanFebMarAprMayJunJulAugSepOctNovDec162.22182.22.341.6248.07", string(data)) } func TestHorizontalBarRender(t *testing.T) { diff --git a/charts.go b/charts.go index 1a1a981..47d8342 100644 --- a/charts.go +++ b/charts.go @@ -160,21 +160,23 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e if len(opt.YAxisOptions) > index { yAxisOption = opt.YAxisOptions[index] } - padRange := true - max, min := opt.SeriesList.GetMaxMin(index) + padRange := 1.0 + min, max := opt.SeriesList.GetMinMax(index) if yAxisOption.Min != nil && *yAxisOption.Min < min { - padRange = false + padRange = 0.0 min = *yAxisOption.Min } if yAxisOption.Max != nil && *yAxisOption.Max > max { - padRange = false + padRange = 0.0 max = *yAxisOption.Max } - span := max - min + if yAxisOption.RangeValuePaddingScale != nil { + padRange = *yAxisOption.RangeValuePaddingScale + } labelCount := yAxisOption.LabelCount if labelCount <= 0 { if yAxisOption.Unit > 0 { - labelCount = int(span / yAxisOption.Unit) + labelCount = int((max - min) / yAxisOption.Unit) } else { labelCount = defaultAxisLabelCount } @@ -205,7 +207,6 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e } else { yAxis = NewRightYAxis(child, yAxisOption) } - if yAxisBox, err := yAxis.Render(); err != nil { return nil, err } else if index == 0 { @@ -276,8 +277,6 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { seriesList := opt.SeriesList seriesList.init() - seriesCount := len(seriesList) - // line chart lineSeriesList := seriesList.Filter(ChartTypeLine) barSeriesList := seriesList.Filter(ChartTypeBar) @@ -286,6 +285,7 @@ func Render(opt ChartOption, opts ...OptionFunc) (*Painter, error) { radarSeriesList := seriesList.Filter(ChartTypeRadar) funnelSeriesList := seriesList.Filter(ChartTypeFunnel) + seriesCount := len(seriesList) if len(horizontalBarSeriesList) != 0 && len(horizontalBarSeriesList) != seriesCount { return nil, errors.New("horizontal bar can not mix other charts") } else if len(pieSeriesList) != 0 && len(pieSeriesList) != seriesCount { diff --git a/echarts_test.go b/echarts_test.go index 6f130c5..15317ea 100644 --- a/echarts_test.go +++ b/echarts_test.go @@ -529,5 +529,5 @@ func TestRenderEChartsToSVG(t *testing.T) { ] }`) require.NoError(t, err) - assertEqualSVG(t, "\\nRainfallEvaporationRainfall vs EvaporationFake Data207184161138115926946230JanFebMarAprMayJunJulAugSepOctNovDec162.22182.22.341.6248.07", string(data)) + assertEqualSVG(t, "\\nRainfallEvaporationRainfall vs EvaporationFake Data2702402101801501209060300JanFebMarAprMayJunJulAugSepOctNovDec162.22182.22.341.6248.07", string(data)) } diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go index 09182e8..3ee5e03 100644 --- a/horizontal_bar_chart.go +++ b/horizontal_bar_chart.go @@ -17,11 +17,11 @@ type HorizontalBarChartOption struct { Font *truetype.Font // The data series list SeriesList SeriesList - // The x-axis option + // The x-axis options XAxis XAxisOption // The padding of line chart Padding Box - // The y-axis option + // The y-axis options YAxisOptions []YAxisOption // The option of title Title TitleOption @@ -68,8 +68,8 @@ func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList Seri theme := opt.Theme - max, min := seriesList.GetMaxMin(0) - xRange := NewRange(p, seriesPainter.Width(), defaultAxisLabelCount, min, max, false) + min, max := seriesList.GetMinMax(0) + xRange := NewRange(p, seriesPainter.Width(), defaultAxisLabelCount, min, max, 0.0) seriesNames := seriesList.Names() rendererList := []Renderer{} diff --git a/mark_line_test.go b/mark_line_test.go index bbe18e3..8aed23a 100644 --- a/mark_line_test.go +++ b/mark_line_test.go @@ -31,7 +31,7 @@ func TestMarkLine(t *testing.T) { FontColor: drawing.ColorBlack, StrokeColor: drawing.ColorBlack, Series: series, - Range: NewRange(p, p.Height(), 6, 0.0, 5.0, true), + Range: NewRange(p, p.Height(), 6, 0.0, 5.0, 1.0), }) if _, err := markLine.Render(); err != nil { return nil, err diff --git a/mark_point.go b/mark_point.go index e4931dc..173d2f1 100644 --- a/mark_point.go +++ b/mark_point.go @@ -44,15 +44,14 @@ func NewMarkPointPainter(p *Painter) *markPointPainter { func (m *markPointPainter) Render() (Box, error) { painter := m.p for _, opt := range m.options { - s := opt.Series - if len(s.MarkPoint.Data) == 0 { + if len(opt.Series.MarkPoint.Data) == 0 { continue } points := opt.Points - summary := s.Summary() - symbolSize := s.MarkPoint.SymbolSize + summary := opt.Series.Summary() + symbolSize := opt.Series.MarkPoint.SymbolSize if symbolSize == 0 { - symbolSize = 30 + symbolSize = 28 } textStyle := Style{ FontSize: labelFontSize, @@ -67,7 +66,7 @@ func (m *markPointPainter) Render() (Box, error) { painter.OverrideDrawingStyle(Style{ FillColor: opt.FillColor, }).OverrideTextStyle(textStyle) - for _, markPointData := range s.MarkPoint.Data { + for _, markPointData := range opt.Series.MarkPoint.Data { textStyle.FontSize = labelFontSize painter.OverrideTextStyle(textStyle) p := points[summary.MinIndex] diff --git a/mark_point_test.go b/mark_point_test.go index 769f961..9380beb 100644 --- a/mark_point_test.go +++ b/mark_point_test.go @@ -45,7 +45,7 @@ func TestMarkPoint(t *testing.T) { } return p.Bytes() }, - result: "\\n3", + result: "\\n3", }, } diff --git a/range.go b/range.go index 626c68e..7d379a7 100644 --- a/range.go +++ b/range.go @@ -8,7 +8,6 @@ const rangeMinPaddingPercentMin = 0.0 // increasing could result in forced negat const rangeMinPaddingPercentMax = 20.0 const rangeMaxPaddingPercentMin = 5.0 // set minimum spacing at the top of the graph const rangeMaxPaddingPercentMax = 20.0 -const rangeDefaultPaddingPercent = 5.0 type axisRange struct { p *Painter @@ -19,10 +18,8 @@ type axisRange struct { } // NewRange returns a range of data for an axis, this range will have padding to better present the data. -func NewRange(painter *Painter, size, labelCount int, min, max float64, addPadding bool) axisRange { - if addPadding { - min, max = padRange(labelCount, min, max) - } +func NewRange(painter *Painter, size, labelCount int, min, max float64, paddingScale float64) axisRange { + min, max = padRange(labelCount, min, max, paddingScale) return axisRange{ p: painter, divideCount: labelCount, @@ -32,7 +29,16 @@ func NewRange(painter *Painter, size, labelCount int, min, max float64, addPaddi } } -func padRange(labelCount int, min, max float64) (float64, float64) { +func padRange(labelCount int, min, max, paddingScale float64) (float64, float64) { + if paddingScale <= 0 { + return min, max + } + // scale percents for min value + scaledMinPadPercentMin := rangeMinPaddingPercentMin * paddingScale + scaledMinPadPercentMax := rangeMinPaddingPercentMax * paddingScale + // scale percents for max value + scaledMaxPadPercentMin := rangeMaxPaddingPercentMin * paddingScale + scaledMaxPadPercentMax := rangeMaxPaddingPercentMax * paddingScale minResult := min maxResult := max spanIncrement := (max - min) * 0.01 // must be 1% of the span @@ -52,9 +58,9 @@ rootLoop: } // use 10^expo so that we prefer 0, 10, 100, etc numbers targetVal := math.Floor(math.Pow(10, expo)) * multiple // Math.Floor to convert 0.1 from -1 expo into 0 - if targetVal < min-(spanIncrement*rangeMinPaddingPercentMax) { + if targetVal < min-(spanIncrement*scaledMinPadPercentMax) { break expoLoop // no match possible, target value will only get further from start - } else if targetVal <= min-(spanIncrement*rangeMinPaddingPercentMin) { + } else if targetVal <= min-(spanIncrement*scaledMinPadPercentMin) { // targetVal can be between our span increment increases, calculate and set result updatedMin = true spanIncrementMultiplier = (min - targetVal) / spanIncrement @@ -65,20 +71,20 @@ rootLoop: } if !updatedMin { minResult, spanIncrementMultiplier = - friendlyRound(min, spanIncrement, rangeMinPaddingPercentMin, - rangeMinPaddingPercentMin, rangeMinPaddingPercentMax, false) + friendlyRound(min, spanIncrement, scaledMinPadPercentMin, + scaledMinPadPercentMin, scaledMinPadPercentMax, false) } - if minTrunk := math.Trunc(minResult); minTrunk <= min-(spanIncrement*rangeMinPaddingPercentMin) { + if minTrunk := math.Trunc(minResult); minTrunk <= min-(spanIncrement*scaledMinPadPercentMin) { minResult = minTrunk // remove possible float multiplication inaccuracies } // update max to provide ideal padding and human friendly intervals interval := (max - minResult) / float64(labelCount-1) roundedInterval, _ := friendlyRound(interval, spanIncrement/float64(labelCount-1), - math.Max(spanIncrementMultiplier, rangeMaxPaddingPercentMin), - rangeMaxPaddingPercentMin, rangeMaxPaddingPercentMax, true) + math.Max(spanIncrementMultiplier, scaledMaxPadPercentMin), + scaledMaxPadPercentMin, scaledMaxPadPercentMax, true) maxResult = minResult + (roundedInterval * float64(labelCount-1)) - if maxTrunk := math.Trunc(maxResult); maxTrunk >= max+(spanIncrement*rangeMaxPaddingPercentMin) { + if maxTrunk := math.Trunc(maxResult); maxTrunk >= max+(spanIncrement*scaledMaxPadPercentMin) { maxResult = maxTrunk // remove possible float multiplication inaccuracies } diff --git a/range_test.go b/range_test.go index b18ccb8..38b2961 100644 --- a/range_test.go +++ b/range_test.go @@ -61,7 +61,7 @@ func TestPadRange(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - min, max := padRange(tc.labelCount, tc.minValue, tc.maxValue) + min, max := padRange(tc.labelCount, tc.minValue, tc.maxValue, 1.0) assert.Equal(t, tc.expectedMinValue, min, "Unexpected value rounding %v", tc.minValue) assert.Equal(t, tc.expectedMaxValue, max, "Unexpected value rounding %v", tc.maxValue) diff --git a/series.go b/series.go index 1161a71..ce231fc 100644 --- a/series.go +++ b/series.go @@ -141,8 +141,8 @@ func (sl SeriesList) Filter(chartType string) SeriesList { return arr } -// GetMaxMin get max and min value of series list -func (sl SeriesList) GetMaxMin(axisIndex int) (float64, float64) { +// GetMinMax get max and min value of series list +func (sl SeriesList) GetMinMax(axisIndex int) (float64, float64) { min := math.MaxFloat64 max := -math.MaxFloat64 for _, series := range sl { @@ -161,7 +161,7 @@ func (sl SeriesList) GetMaxMin(axisIndex int) (float64, float64) { } } } - return max, min + return min, max } type PieSeriesOption struct { diff --git a/series_test.go b/series_test.go index 6f452a6..c5e38e9 100644 --- a/series_test.go +++ b/series_test.go @@ -37,7 +37,7 @@ func TestSeriesLists(t *testing.T) { assert.Equal(t, 2, len(seriesList.Filter(ChartTypeBar))) assert.Equal(t, 0, len(seriesList.Filter(ChartTypeLine))) - max, min := seriesList.GetMaxMin(0) + min, max := seriesList.GetMinMax(0) assert.Equal(t, float64(10), max) assert.Equal(t, float64(1), min) diff --git a/yaxis.go b/yaxis.go index 16a13cd..2c2b102 100644 --- a/yaxis.go +++ b/yaxis.go @@ -9,6 +9,8 @@ type YAxisOption struct { Min *float64 // The maximum value of axis. Max *float64 + // RangeValuePaddingScale suggest a scale of padding added to the max and min values + RangeValuePaddingScale *float64 // The font of y-axis Font *truetype.Font // The data value of y-axis From a1b18d2fa299d3f84843e87a4e06cf8b396f8da3 Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Mon, 5 Feb 2024 21:52:20 -0700 Subject: [PATCH 7/8] Fix for horizonal bar chart y-axis spacing These tests looked good only by chance due to the prior default of 6 for the axis labels. We need to set the divide count based on the categories rather than just use the default. --- chart_option_test.go | 2 +- charts.go | 5 ++++- horizontal_bar_chart.go | 2 +- horizontal_bar_chart_test.go | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/chart_option_test.go b/chart_option_test.go index dc757ce..3937f1e 100644 --- a/chart_option_test.go +++ b/chart_option_test.go @@ -295,7 +295,7 @@ func TestHorizontalBarRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0122.28k244.56k366.84k489.12k611.4k733.68k", string(data)) + assertEqualSVG(t, "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0142.99k285.99k428.99k571.98k714.98k", string(data)) } func TestPieRender(t *testing.T) { diff --git a/charts.go b/charts.go index 47d8342..3024c28 100644 --- a/charts.go +++ b/charts.go @@ -191,8 +191,11 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e 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 + 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 = NewRange(p, rangeHeight, defaultAxisLabelCount, min, max, padRange).Values() + opt.XAxis.Data = r.Values() opt.XAxis.isValueAxis = true } reverseStringSlice(yAxisOption.Data) diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go index 3ee5e03..abfd830 100644 --- a/horizontal_bar_chart.go +++ b/horizontal_bar_chart.go @@ -69,7 +69,7 @@ func (h *horizontalBarChart) render(result *defaultRenderResult, seriesList Seri theme := opt.Theme min, max := seriesList.GetMinMax(0) - xRange := NewRange(p, seriesPainter.Width(), defaultAxisLabelCount, min, max, 0.0) + xRange := NewRange(p, seriesPainter.Width(), len(seriesList[0].Data), min, max, 1.0) seriesNames := seriesList.Names() rendererList := []Renderer{} diff --git a/horizontal_bar_chart_test.go b/horizontal_bar_chart_test.go index 8d3b59b..a2a093b 100644 --- a/horizontal_bar_chart_test.go +++ b/horizontal_bar_chart_test.go @@ -60,7 +60,7 @@ func TestHorizontalBarChart(t *testing.T) { } return p.Bytes() }, - result: "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0116.66k233.33k350k466.66k583.33k700k", + result: "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0144k288k432k576k720k", }, } for i, tt := range tests { From 0b35ded23e14c305c5673a78bc08ed0f32782bde Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Tue, 6 Feb 2024 23:15:47 -0700 Subject: [PATCH 8/8] Add `LabelSkipCount` to the YAxisOption, plus more tweaks This adds the `LabelSkipCount` configuration as a way to reduce the amount of y axis labels, while maintaining the same `LabelCount` number of horizontal lines. As part of testing around this change it was found that the Unit parameter was not handled in the best way possible. A long comment in charts.go decribes the circular relationship between the axis range and label count (particularly if a unit is configured). --- axis.go | 45 +++-- bar_chart.go | 2 +- chart_option_test.go | 2 +- charts.go | 53 +++++- horizontal_bar_chart.go | 2 +- line_chart_test.go | 394 +++++++++++++++++++++++++--------------- mark_line_test.go | 2 +- painter.go | 16 +- range.go | 29 +-- range_test.go | 2 +- util.go | 5 + xaxis.go | 4 +- yaxis.go | 5 +- 13 files changed, 356 insertions(+), 205 deletions(-) diff --git a/axis.go b/axis.go index 8e4ce74..cf87f74 100644 --- a/axis.go +++ b/axis.go @@ -61,18 +61,21 @@ type AxisOption struct { Unit float64 // LabelCount is the number of labels to show on the axis. Specify a smaller number to reduce writing collisions. This value takes priority over Unit. LabelCount int + // LabelSkipCount specifies a number of lines between labels where there will be no label, + // but a horizontal line will still be drawn. + LabelSkipCount int } func (a *axisPainter) Render() (Box, error) { opt := a.opt + if isFalse(opt.Show) { + return BoxZero, nil + } top := a.p theme := opt.Theme if theme == nil { theme = top.theme } - if isFalse(opt.Show) { - return BoxZero, nil - } strokeWidth := opt.StrokeWidth if strokeWidth == 0 { @@ -99,14 +102,13 @@ func (a *axisPainter) Render() (Box, error) { strokeColor = theme.GetAxisStrokeColor() } - data := opt.Data formatter := opt.Formatter if len(formatter) != 0 { - for index, text := range data { - data[index] = strings.ReplaceAll(formatter, "{value}", text) + for index, text := range opt.Data { + opt.Data[index] = strings.ReplaceAll(formatter, "{value}", text) } } - dataCount := len(data) + dataCount := len(opt.Data) centerLabels := !isFalse(opt.BoundaryGap) isVertical := opt.Position == PositionLeft || @@ -130,7 +132,7 @@ func (a *axisPainter) Render() (Box, error) { if isTextRotation { top.SetTextRotation(opt.TextRotation) } - textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(data) + textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(opt.Data) if isTextRotation { top.ClearTextRotation() } @@ -200,11 +202,11 @@ func (a *axisPainter) Render() (Box, error) { labelCount := opt.LabelCount if labelCount <= 0 { var maxLabelCount int - // Add 10px for some minimal extra padding and calculate more suitable display item count on text width + // Add 10px and remove one for some minimal extra padding so that letters don't collide if orient == OrientVertical { - maxLabelCount = top.Height() / (textMaxHeight + 10) + maxLabelCount = (top.Height() / (textMaxHeight + 10)) - 1 } else { - maxLabelCount = top.Width() / (textMaxWidth + 10) + maxLabelCount = (top.Width() / (textMaxWidth + 10)) - 1 } if opt.Unit > 0 { multiplier := 1.0 @@ -218,10 +220,6 @@ func (a *axisPainter) Render() (Box, error) { } } else { labelCount = maxLabelCount - if ((dataCount/(labelCount-1))%5 == 0) || ((dataCount/(labelCount-1))%2 == 0) { - // prefer %5 or %2 units if reasonable - labelCount-- - } } } if labelCount > dataCount { @@ -263,14 +261,15 @@ func (a *axisPainter) Render() (Box, error) { Top: labelPaddingTop, Right: labelPaddingRight, })).MultiText(MultiTextOption{ - First: opt.FirstAxis, - Align: textAlign, - TextList: data, - Orient: orient, - LabelCount: labelCount, - CenterLabels: centerLabels, - TextRotation: opt.TextRotation, - Offset: opt.LabelOffset, + First: opt.FirstAxis, + Align: textAlign, + TextList: opt.Data, + Orient: orient, + LabelCount: labelCount, + LabelSkipCount: opt.LabelSkipCount, + CenterLabels: centerLabels, + TextRotation: opt.TextRotation, + Offset: opt.LabelOffset, }) if opt.SplitLineShow { // show auxiliary lines diff --git a/bar_chart.go b/bar_chart.go index 4c0669b..203183d 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -48,7 +48,7 @@ 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) + xRange := NewRange(b.p, 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 diff --git a/chart_option_test.go b/chart_option_test.go index 3937f1e..913c682 100644 --- a/chart_option_test.go +++ b/chart_option_test.go @@ -295,7 +295,7 @@ func TestHorizontalBarRender(t *testing.T) { require.NoError(t, err) data, err := p.Bytes() require.NoError(t, err) - assertEqualSVG(t, "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0142.99k285.99k428.99k571.98k714.98k", string(data)) + assertEqualSVG(t, "\\n20112012World PopulationWorldChinaIndiaUSAIndonesiaBrazil0142.99k285.99k428.99k571.99k714.98k", string(data)) } func TestPieRender(t *testing.T) { diff --git a/charts.go b/charts.go index 3024c28..74b98e6 100644 --- a/charts.go +++ b/charts.go @@ -12,7 +12,7 @@ const labelFontSize = 10 const smallLabelFontSize = 8 const defaultDotWidth = 2.0 const defaultStrokeWidth = 2.0 -const defaultAxisLabelCount = 10 +const defaultYAxisLabelCount = 10 var defaultChartWidth = 600 var defaultChartHeight = 400 @@ -160,28 +160,61 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e if len(opt.YAxisOptions) > index { yAxisOption = opt.YAxisOptions[index] } - padRange := 1.0 + minPadRange, maxPadRange := 1.0, 1.0 + if yAxisOption.RangeValuePaddingScale != nil { + minPadRange = *yAxisOption.RangeValuePaddingScale + maxPadRange = *yAxisOption.RangeValuePaddingScale + } min, max := opt.SeriesList.GetMinMax(index) if yAxisOption.Min != nil && *yAxisOption.Min < min { - padRange = 0.0 min = *yAxisOption.Min + minPadRange = 0.0 } if yAxisOption.Max != nil && *yAxisOption.Max > max { - padRange = 0.0 max = *yAxisOption.Max + maxPadRange = 0.0 } - if yAxisOption.RangeValuePaddingScale != nil { - padRange = *yAxisOption.RangeValuePaddingScale - } + + // 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. + // 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. + // + // In order to accomplish this, we estimate the label count (if necessary), pad the range, then precisely + // calculate the label count. + // TODO - label counts are also calculated in axis.go, for the X axis, ideally we unify these implementations labelCount := yAxisOption.LabelCount + padLabelCount := labelCount + if padLabelCount < 1 { + if yAxisOption.Unit > 0 { + padLabelCount = int((max-min)/yAxisOption.Unit) + 1 + } else { + padLabelCount = defaultYAxisLabelCount + } + } + // we call padRange directly because we need to do this padding before we can calculate the final labelCount for the axisRange + min, max = padRange(padLabelCount, min, max, minPadRange, maxPadRange) if labelCount <= 0 { if yAxisOption.Unit > 0 { - labelCount = int((max - min) / yAxisOption.Unit) + if yAxisOption.Max == nil { + max = math.Trunc(math.Ceil(max/yAxisOption.Unit) * yAxisOption.Unit) + } + labelCount = int((max-min)/yAxisOption.Unit) + 1 } else { - labelCount = defaultAxisLabelCount + labelCount = defaultYAxisLabelCount } + yAxisOption.LabelCount = labelCount + } + r := axisRange{ + p: p, + divideCount: labelCount, + min: min, + max: max, + size: rangeHeight, } - r := NewRange(p, rangeHeight, labelCount, min, max, padRange) result.axisRanges[index] = r if yAxisOption.Theme == nil { diff --git a/horizontal_bar_chart.go b/horizontal_bar_chart.go index abfd830..8f2e5c2 100644 --- a/horizontal_bar_chart.go +++ b/horizontal_bar_chart.go @@ -69,7 +69,7 @@ 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) + xRange := NewRange(p, seriesPainter.Width(), len(seriesList[0].Data), min, max, 1.0, 1.0) seriesNames := seriesList.Names() rendererList := []Renderer{} diff --git a/line_chart_test.go b/line_chart_test.go index 1f5cc54..30ab3c6 100644 --- a/line_chart_test.go +++ b/line_chart_test.go @@ -1,196 +1,294 @@ package charts import ( - "strconv" "testing" "github.com/stretchr/testify/require" ) +func makeBasicLineChartOption() LineChartOption { + 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, + }, + } + return LineChartOption{ + Title: TitleOption{ + Text: "Line", + }, + Padding: Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + Legend: NewLegendOption([]string{ + "Email", + "Union Ads", + "Video Ads", + "Direct", + "Search Engine", + }, PositionCenter), + SeriesList: NewSeriesListDataFromValues(values), + } +} + +func makeMinimalLneChartOption() LineChartOption { + values := [][]float64{ + { + 120, + 132, + 101, + 134, + 90, + 230, + 210, + }, + { + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320, + }, + } + return LineChartOption{ + Padding: Box{ + Top: 10, + Right: 10, + Bottom: 10, + Left: 10, + }, + XAxis: XAxisOption{ + Data: []string{ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + }, + Show: FalseFlag(), + }, + SeriesList: NewSeriesListDataFromValues(values), + } +} + func TestLineChart(t *testing.T) { tests := []struct { - render func(*Painter) ([]byte, error) - result string + name string + makeOptions func() LineChartOption + result string }{ { - render: func(p *Painter) ([]byte, error) { - values := [][]float64{ - { - 120, - 132, - 101, - 134, - 90, - 230, - 210, - }, - { - 220, - 182, - 191, - 234, - 290, - 330, - 310, - }, - { - 150, - 232, - 201, - 154, - 190, - 330, - 410, - }, + name: "Basic", + makeOptions: makeBasicLineChartOption, + result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", + }, + { + name: "BasicWithoutBoundary", + makeOptions: func() LineChartOption { + opt := makeBasicLineChartOption() + opt.XAxis.BoundaryGap = FalseFlag() + return opt + }, + result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", + }, + { + name: "08YSkip1", + makeOptions: func() LineChartOption { + opt := makeMinimalLneChartOption() + opt.YAxisOptions = []YAxisOption{ { - 320, - 332, - 301, - 334, - 390, - 330, - 320, + LabelCount: 8, + LabelSkipCount: 1, }, + } + return opt + }, + result: "\\n1.4k1k6002000", + }, + { + name: "09YSkip1", + makeOptions: func() LineChartOption { + opt := makeMinimalLneChartOption() + opt.YAxisOptions = []YAxisOption{ { - 820, - 932, - 901, - 934, - 1290, - 1330, - 1320, + LabelCount: 9, + LabelSkipCount: 1, }, } - _, err := NewLineChart(p, LineChartOption{ - Title: TitleOption{ - Text: "Line", - }, - Padding: Box{ - Top: 10, - Right: 10, - Bottom: 10, - Left: 10, + return opt + }, + result: "\\n1.44k1.08k7203600", + }, + { + name: "08YSkip2", + makeOptions: func() LineChartOption { + opt := makeMinimalLneChartOption() + opt.YAxisOptions = []YAxisOption{ + { + LabelCount: 8, + LabelSkipCount: 2, }, - XAxis: NewXAxisOption([]string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", - }), - Legend: NewLegendOption([]string{ - "Email", - "Union Ads", - "Video Ads", - "Direct", - "Search Engine", - }, PositionCenter), - SeriesList: NewSeriesListDataFromValues(values), - }).Render() - if err != nil { - return nil, err } - return p.Bytes() + return opt }, - result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", + result: "\\n1.4k8002000", }, { - render: func(p *Painter) ([]byte, error) { - values := [][]float64{ + name: "09YSkip2", + makeOptions: func() LineChartOption { + opt := makeMinimalLneChartOption() + opt.YAxisOptions = []YAxisOption{ { - 120, - 132, - 101, - 134, - 90, - 230, - 210, + LabelCount: 9, + LabelSkipCount: 2, }, + } + return opt + }, + result: "\\n1.44k9003600", + }, + { + name: "10YSkip2", + makeOptions: func() LineChartOption { + opt := makeMinimalLneChartOption() + opt.YAxisOptions = []YAxisOption{ { - 220, - 182, - 191, - 234, - 290, - 330, - 310, + LabelCount: 10, + LabelSkipCount: 2, }, + } + return opt + }, + result: "\\n1.44k9604800", + }, + { + name: "08YSkip3", + makeOptions: func() LineChartOption { + opt := makeMinimalLneChartOption() + opt.YAxisOptions = []YAxisOption{ { - 150, - 232, - 201, - 154, - 190, - 330, - 410, + LabelCount: 8, + LabelSkipCount: 3, }, + } + return opt + }, + result: "\\n1.4k6000", + }, + { + name: "09YSkip3", + makeOptions: func() LineChartOption { + opt := makeMinimalLneChartOption() + opt.YAxisOptions = []YAxisOption{ { - 320, - 332, - 301, - 334, - 390, - 330, - 320, + LabelCount: 9, + LabelSkipCount: 3, }, + } + return opt + }, + result: "\\n1.44k7200", + }, + { + name: "10YSkip3", + makeOptions: func() LineChartOption { + opt := makeMinimalLneChartOption() + opt.YAxisOptions = []YAxisOption{ { - 820, - 932, - 901, - 934, - 1290, - 1330, - 1320, + LabelCount: 10, + LabelSkipCount: 3, }, } - _, err := NewLineChart(p, LineChartOption{ - Title: TitleOption{ - Text: "Line", - }, - Padding: Box{ - Top: 10, - Right: 10, - Bottom: 10, - Left: 10, + return opt + }, + result: "\\n1.44k8001600", + }, + { + name: "11YSkip3", + makeOptions: func() LineChartOption { + opt := makeMinimalLneChartOption() + opt.YAxisOptions = []YAxisOption{ + { + LabelCount: 11, + LabelSkipCount: 3, }, - XAxis: NewXAxisOption([]string{ - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat", - "Sun", - }, FalseFlag()), - Legend: NewLegendOption([]string{ - "Email", - "Union Ads", - "Video Ads", - "Direct", - "Search Engine", - }, PositionCenter), - SeriesList: NewSeriesListDataFromValues(values), - }).Render() - if err != nil { - return nil, err } - return p.Bytes() + return opt }, - result: "\\nEmailUnion AdsVideo AdsDirectSearch EngineLine1.44k1.28k1.12k9608006404803201600MonTueWedThuFriSatSun", + result: "\\n1.4k8402800", }, } - for i, tt := range tests { - t.Run(strconv.Itoa(i), func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { p, err := NewPainter(PainterOptions{ Type: ChartOutputSVG, Width: 600, Height: 400, }, PainterThemeOption(defaultTheme)) require.NoError(t, err) - data, err := tt.render(p) + opt := tt.makeOptions() + + _, err = NewLineChart(p, opt).Render() + require.NoError(t, err) + data, err := p.Bytes() require.NoError(t, err) assertEqualSVG(t, tt.result, string(data)) }) diff --git a/mark_line_test.go b/mark_line_test.go index 8aed23a..2a95b0c 100644 --- a/mark_line_test.go +++ b/mark_line_test.go @@ -31,7 +31,7 @@ func TestMarkLine(t *testing.T) { FontColor: drawing.ColorBlack, StrokeColor: drawing.ColorBlack, Series: series, - Range: NewRange(p, p.Height(), 6, 0.0, 5.0, 1.0), + Range: NewRange(p, p.Height(), 6, 0.0, 5.0, 1.0, 1.0), }) if _, err := markLine.Render(); err != nil { return nil, err diff --git a/painter.go b/painter.go index 9c32705..42e468e 100644 --- a/painter.go +++ b/painter.go @@ -52,8 +52,9 @@ type MultiTextOption struct { TextRotation float64 Offset Box // The first text index - First int - LabelCount int + First int + LabelCount int + LabelSkipCount int } type GridOption struct { @@ -676,16 +677,25 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { } isTextRotation := opt.TextRotation != 0 positionCount := len(positions) + + 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 { 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 { continue } else if !isVertical && - index != count-1 /* one off case for last label due to values and label qty difference */ && + index != count-1 && // one off case for last label due to values and label qty difference !isTick(positionCount-opt.First, opt.LabelCount+1, index-opt.First) { continue + } else if index != count-1 && // ensure the bottom value is always printed + skippedLabels < opt.LabelSkipCount { + skippedLabels++ + continue + } else { + skippedLabels = 0 } + if isTextRotation { p.ClearTextRotation() p.SetTextRotation(opt.TextRotation) diff --git a/range.go b/range.go index 7d379a7..3574d68 100644 --- a/range.go +++ b/range.go @@ -18,29 +18,28 @@ type axisRange struct { } // NewRange returns a range of data for an axis, this range will have padding to better present the data. -func NewRange(painter *Painter, size, labelCount int, min, max float64, paddingScale float64) axisRange { - min, max = padRange(labelCount, min, max, paddingScale) +func NewRange(painter *Painter, size, divideCount int, min, max, minPaddingScale, maxPaddingScale float64) axisRange { + min, max = padRange(divideCount, min, max, minPaddingScale, maxPaddingScale) return axisRange{ p: painter, - divideCount: labelCount, + divideCount: divideCount, min: min, max: max, size: size, } } -func padRange(labelCount int, min, max, paddingScale float64) (float64, float64) { - if paddingScale <= 0 { +func padRange(divideCount int, min, max, minPaddingScale, maxPaddingScale float64) (float64, float64) { + if minPaddingScale <= 0.0 && maxPaddingScale <= 0.0 { return min, max } // scale percents for min value - scaledMinPadPercentMin := rangeMinPaddingPercentMin * paddingScale - scaledMinPadPercentMax := rangeMinPaddingPercentMax * paddingScale + scaledMinPadPercentMin := rangeMinPaddingPercentMin * minPaddingScale + scaledMinPadPercentMax := rangeMinPaddingPercentMax * minPaddingScale // scale percents for max value - scaledMaxPadPercentMin := rangeMaxPaddingPercentMin * paddingScale - scaledMaxPadPercentMax := rangeMaxPaddingPercentMax * paddingScale + scaledMaxPadPercentMin := rangeMaxPaddingPercentMin * maxPaddingScale + scaledMaxPadPercentMax := rangeMaxPaddingPercentMax * maxPaddingScale minResult := min - maxResult := max spanIncrement := (max - min) * 0.01 // must be 1% of the span var spanIncrementMultiplier float64 // find a min value to start our range from @@ -78,12 +77,16 @@ rootLoop: minResult = minTrunk // remove possible float multiplication inaccuracies } + if maxPaddingScale <= 0.0 { + return minResult, max + } + // update max to provide ideal padding and human friendly intervals - interval := (max - minResult) / float64(labelCount-1) - roundedInterval, _ := friendlyRound(interval, spanIncrement/float64(labelCount-1), + interval := (max - minResult) / float64(divideCount-1) + roundedInterval, _ := friendlyRound(interval, spanIncrement/float64(divideCount-1), math.Max(spanIncrementMultiplier, scaledMaxPadPercentMin), scaledMaxPadPercentMin, scaledMaxPadPercentMax, true) - maxResult = minResult + (roundedInterval * float64(labelCount-1)) + maxResult := minResult + (roundedInterval * float64(divideCount-1)) if maxTrunk := math.Trunc(maxResult); maxTrunk >= max+(spanIncrement*scaledMaxPadPercentMin) { maxResult = maxTrunk // remove possible float multiplication inaccuracies } diff --git a/range_test.go b/range_test.go index 38b2961..085240c 100644 --- a/range_test.go +++ b/range_test.go @@ -61,7 +61,7 @@ func TestPadRange(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - min, max := padRange(tc.labelCount, tc.minValue, tc.maxValue, 1.0) + min, max := padRange(tc.labelCount, tc.minValue, tc.maxValue, 1.0, 1.0) assert.Equal(t, tc.expectedMinValue, min, "Unexpected value rounding %v", tc.minValue) assert.Equal(t, tc.expectedMaxValue, max, "Unexpected value rounding %v", tc.maxValue) diff --git a/util.go b/util.go index f54b603..96acdda 100644 --- a/util.go +++ b/util.go @@ -21,6 +21,11 @@ func FalseFlag() *bool { return &f } +func ZeroFloat() *float64 { + v := 0.0 + return &v +} + func isFalse(flag *bool) bool { if flag != nil && !*flag { return true diff --git a/xaxis.go b/xaxis.go index 109e0f3..5849fcc 100644 --- a/xaxis.go +++ b/xaxis.go @@ -30,11 +30,11 @@ type XAxisOption struct { FirstAxis int // The offset of label LabelOffset Box - isValueAxis bool // 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 int + LabelCount int + isValueAxis bool } const defaultXAxisHeight = 30 diff --git a/yaxis.go b/yaxis.go index 2c2b102..56f42bb 100644 --- a/yaxis.go +++ b/yaxis.go @@ -32,7 +32,9 @@ type YAxisOption struct { // 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 int + LabelCount int + // LabelSkipCount specifies a number of lines between labels where there will be no label and instead just a horizontal line. + LabelSkipCount int isCategoryAxis bool } @@ -72,6 +74,7 @@ func (opt *YAxisOption) ToAxisOption(p *Painter) AxisOption { BoundaryGap: FalseFlag(), Unit: opt.Unit, LabelCount: opt.LabelCount, + LabelSkipCount: opt.LabelSkipCount, SplitLineShow: true, SplitLineColor: theme.GetAxisSplitLineColor(), Show: opt.Show,