From 8ee8483adb373594bbe4bec3388f8dd37da2b912 Mon Sep 17 00:00:00 2001 From: Mike Jensen Date: Sat, 18 Jan 2025 19:54:45 -0700 Subject: [PATCH] Fix X-Axis label position when rotated This change fixes issue #32. Prior logic would place labels off when rotation was specified, resulting in users needing to make manual padding adjustements to get labels positioned correctly. This fixes the issue by creating a function which provides a calculation so that rotation results in text always positioning upwards (similar to how it's written without rotation). This makes logic easier to implement, and also fixes the discovered issue. Additionally `DegreesToRadians` is now available in the charts package since all our configuration accepts radians. --- axis.go | 19 ++-- axis_test.go | 68 +++++++++++++- chartdraw/donut_chart.go | 2 +- chartdraw/mathutil.go | 2 +- examples/line_chart-3/main.go | 9 +- examples/line_chart-4/main.go | 1 + painter.go | 61 +++++++++++-- painter_test.go | 161 +++++++++++++++++++++++++++++++++- util.go | 12 +++ 9 files changed, 315 insertions(+), 20 deletions(-) diff --git a/axis.go b/axis.go index 1086f7a..15974f1 100644 --- a/axis.go +++ b/axis.go @@ -133,11 +133,15 @@ func (a *axisPainter) Render() (Box, error) { switch opt.Position { case PositionTop: - labelPaddingTop = 0 + if opt.TextRotation != 0 { + flatWidth, flatHeight := top.measureTextMaxWidthHeight(opt.Data, 0, fontStyle) + labelPaddingTop = flatHeight - textRotationHeightAdjustment(flatWidth, flatHeight, opt.TextRotation) + } else { + labelPaddingTop = 0 + } x1 = p.Width() - // TODO - should this reference opt.FontStyle or fontStyle with defaults set - y0 = labelMargin + int(opt.FontStyle.FontSize) - ticksPaddingTop = int(opt.FontStyle.FontSize) + y0 = labelMargin + int(fontStyle.FontSize) + ticksPaddingTop = int(fontStyle.FontSize) y1 = y0 case PositionLeft: x0 = p.Width() @@ -151,7 +155,12 @@ func (a *axisPainter) Render() (Box, error) { y1 = p.Height() labelPaddingLeft = width - textMaxWidth default: - labelPaddingTop = height + if opt.TextRotation != 0 { + flatWidth, flatHeight := top.measureTextMaxWidthHeight(opt.Data, 0, fontStyle) + labelPaddingTop = tickLength<<1 + (textMaxHeight - textRotationHeightAdjustment(flatWidth, flatHeight, opt.TextRotation)) + } else { + labelPaddingTop = height + } x1 = p.Width() } diff --git a/axis_test.go b/axis_test.go index 8007c89..95812b5 100644 --- a/axis_test.go +++ b/axis_test.go @@ -39,6 +39,72 @@ func TestAxis(t *testing.T) { }, result: "MonTueWedThuFriSatSun", }, + { + name: "x-axis_bottom_rotation45", + padPainter: true, + optionFactory: func() axisOption { + opt := XAxisOption{ + Data: dayLabels, + BoundaryGap: True(), + FontStyle: FontStyle{ + FontSize: 18, + }, + TextRotation: DegreesToRadians(45), + } + return opt.toAxisOption() + }, + result: "MonTueWedThuFriSatSun", + }, + { + name: "x-axis_bottom_rotation90", + padPainter: true, + optionFactory: func() axisOption { + opt := XAxisOption{ + Data: dayLabels, + BoundaryGap: True(), + FontStyle: FontStyle{ + FontSize: 18, + }, + TextRotation: DegreesToRadians(90), + } + return opt.toAxisOption() + }, + result: "MonTueWedThuFriSatSun", + }, + { + name: "x-axis_top_rotation45", + padPainter: true, + optionFactory: func() axisOption { + opt := XAxisOption{ + Data: dayLabels, + BoundaryGap: True(), + FontStyle: FontStyle{ + FontSize: 18, + }, + Position: PositionTop, + TextRotation: DegreesToRadians(45), + } + return opt.toAxisOption() + }, + result: "MonTueWedThuFriSatSun", + }, + { + name: "x-axis_top_rotation90", + padPainter: true, + optionFactory: func() axisOption { + opt := XAxisOption{ + Data: dayLabels, + BoundaryGap: True(), + FontStyle: FontStyle{ + FontSize: 18, + }, + Position: PositionTop, + TextRotation: DegreesToRadians(90), + } + return opt.toAxisOption() + }, + result: "MonTueWedThuFriSatSun", + }, { name: "x-axis_bottom_splitline", optionFactory: func() axisOption { @@ -104,7 +170,7 @@ func TestAxis(t *testing.T) { Position: PositionTop, } }, - result: "Mon --Tue --Wed --Thu --Fri --Sat --Sun --", + result: "Mon --Tue --Wed --Thu --Fri --Sat --Sun --", }, { name: "reduced_label_count", diff --git a/chartdraw/donut_chart.go b/chartdraw/donut_chart.go index 351c8e0..c5a0679 100644 --- a/chartdraw/donut_chart.go +++ b/chartdraw/donut_chart.go @@ -154,7 +154,7 @@ func (pc DonutChart) drawSlices(r Renderer, canvasBox Box, values []Value) { }) v.Style.InheritFrom(styletemp).WriteToRenderer(r) r.MoveTo(cx, cy) - r.ArcTo(cx, cy, radius/3.5, radius/3.5, DegreesToRadians(0), DegreesToRadians(359)) + r.ArcTo(cx, cy, radius/3.5, radius/3.5, 0, DegreesToRadians(359)) r.LineTo(cx, cy) r.Close() r.FillStroke() diff --git a/chartdraw/mathutil.go b/chartdraw/mathutil.go index 0f6b0e5..fa7d402 100644 --- a/chartdraw/mathutil.go +++ b/chartdraw/mathutil.go @@ -101,7 +101,7 @@ func RadianAdd(base, delta float64) float64 { return math.Mod(math.Mod(value, _2pi)+_2pi, _2pi) } -// DegreesAdd adds a delta to a base in radians. +// DegreesAdd adds a delta to a base in degrees. func DegreesAdd(baseDegrees, deltaDegrees float64) float64 { value := baseDegrees + deltaDegrees return math.Mod(math.Mod(value, 360.0)+360.0, 360.0) diff --git a/examples/line_chart-3/main.go b/examples/line_chart-3/main.go index ae5a7cc..63a0679 100644 --- a/examples/line_chart-3/main.go +++ b/examples/line_chart-3/main.go @@ -65,10 +65,11 @@ func main() { LabelSkipCount: 1, }), charts.XAxisOptionFunc(charts.XAxisOption{ - Data: xAxisLabels, - FontStyle: axisFont, - BoundaryGap: charts.True(), - LabelCount: 10, + Data: xAxisLabels, + FontStyle: axisFont, + BoundaryGap: charts.True(), + LabelCount: 10, + TextRotation: charts.DegreesToRadians(45), }), func(opt *charts.ChartOption) { // disable the symbols and reduce the stroke width to give more fidelity on the line diff --git a/examples/line_chart-4/main.go b/examples/line_chart-4/main.go index b57eddf..139ded9 100644 --- a/examples/line_chart-4/main.go +++ b/examples/line_chart-4/main.go @@ -61,6 +61,7 @@ func main() { opt.XAxis.Data = xAxisLabels opt.XAxis.Unit = 40 opt.XAxis.LabelCount = 10 + opt.XAxis.TextRotation = charts.DegreesToRadians(45) opt.XAxis.BoundaryGap = charts.True() opt.XAxis.FontStyle = charts.FontStyle{ FontSize: 6.0, diff --git a/painter.go b/painter.go index d177908..704e2ff 100644 --- a/painter.go +++ b/painter.go @@ -415,15 +415,21 @@ func (p *Painter) Polygon(center Point, radius float64, sides int, strokeColor C p.stroke(strokeColor, strokeWidth) } +const ( + _pi2 = math.Pi / 2.0 + _2pi = 2 * math.Pi + _3pi2 = (3 * math.Pi) / 2.0 +) + // Pin draws a pin shape (circle + curved tail). func (p *Painter) Pin(x, y, width int, fillColor, strokeColor Color, strokeWidth float64) { r := float64(width) / 2 y -= width / 4 - angle := chartdraw.DegreesToRadians(15) + angle := DegreesToRadians(15) // Draw the pin head with fill and stroke - startAngle := math.Pi/2 + angle - delta := 2*math.Pi - 2*angle + startAngle := _pi2 + angle + delta := _2pi - 2*angle p.arcTo(x, y, r, r, startAngle, delta) p.lineTo(x, y) p.close() @@ -806,6 +812,47 @@ func (p *Painter) multiText(opt multiTextOption) { } } +// textRotationHeightAdjustment calculates how much vertical adjustment is needed +// after rotating the text around the bottom-right corner. +// +// The caller will then typically subtract this returned value from the existing y-position so that the text will +// stay aligned with the bottom position. In order to do this calculation the provided text dimensions should be +// WITHOUT rotation applied. +func textRotationHeightAdjustment(textWidth, textHeight int, radians float64) int { + r := normalizeAngle(radians) + + switch { + // Very close to 0 radians: no vertical adjustment needed + case r < 1e-9: + return 0 + // 0 to π (0 to 180 degrees) + case r < math.Pi: + // Compute vertical displacement needed to maintain alignment at the bottom + // sin(r) gives the vertical component of the text width as it rotates + return int(math.Round(float64(textWidth) * math.Sin(r))) + // π to 3π/2 (180 to 270 degrees) + case r >= math.Pi && r < _3pi2: + // Adjust the text downward as it rotates past 180 degrees + // cos(angle) gives the horizontal overlap, subtract from height to get adjustment + return textHeight - int(math.Round(float64(textHeight)*math.Cos(r-_3pi2))) + // 3π/2 to 2π (270 to 360 degrees) + default: + // No adjustment needed as the text aligns back towards zero position + return 0 + } +} + +// normalizeAngle brings the angle into the range [0, 2π). +func normalizeAngle(radians float64) float64 { + if radians < 0 { + for radians < 0 { + radians += _2pi + } + return radians + } + return math.Mod(radians, _2pi) +} + // Dots prints filled circles for the given points. func (p *Painter) Dots(points []Point, fillColor, strokeColor Color, strokeWidth float64, dotRadius float64) { defer p.render.ResetStyle() @@ -846,7 +893,7 @@ func (p *Painter) roundedRect(box Box, radius int, roundTop, roundBottom bool, // right top cx := box.Right - radius cy := box.Top + radius - p.arcTo(cx, cy, rx, ry, -math.Pi/2, math.Pi/2) + p.arcTo(cx, cy, rx, ry, -_pi2, _pi2) } else { p.moveTo(box.Left, box.Top) p.lineTo(box.Right, box.Top) @@ -858,14 +905,14 @@ func (p *Painter) roundedRect(box Box, radius int, roundTop, roundBottom bool, // right bottom cx := box.Right - radius cy := box.Bottom - radius - p.arcTo(cx, cy, rx, ry, 0, math.Pi/2) + p.arcTo(cx, cy, rx, ry, 0, _pi2) p.lineTo(box.Left+radius, box.Bottom) // left bottom cx = box.Left + radius cy = box.Bottom - radius - p.arcTo(cx, cy, rx, ry, math.Pi/2, math.Pi/2) + p.arcTo(cx, cy, rx, ry, _pi2, _pi2) } else { p.lineTo(box.Right, box.Bottom) p.lineTo(box.Left, box.Bottom) @@ -876,7 +923,7 @@ func (p *Painter) roundedRect(box Box, radius int, roundTop, roundBottom bool, p.lineTo(box.Left, box.Top+radius) cx := box.Left + radius cy := box.Top + radius - p.arcTo(cx, cy, rx, ry, math.Pi, math.Pi/2) + p.arcTo(cx, cy, rx, ry, math.Pi, _pi2) } else { p.lineTo(box.Left, box.Top) } diff --git a/painter_test.go b/painter_test.go index d435a19..a516539 100644 --- a/painter_test.go +++ b/painter_test.go @@ -126,7 +126,7 @@ func TestPainterExternal(t *testing.T) { { name: "text_rotated", fn: func(p *Painter) { - p.Text("hello world!", 3, 6, chartdraw.DegreesToRadians(90), FontStyle{}) + p.Text("hello world!", 3, 6, DegreesToRadians(90), FontStyle{}) }, result: "hello world!", }, @@ -261,6 +261,165 @@ func TestPainterExternal(t *testing.T) { } } +func TestTextRotationHeightAdjustment(t *testing.T) { + t.Parallel() + + text := "hello world " + expectedTemplate := "hello world %s" + fontStyle := FontStyle{ + Font: GetDefaultFont(), + FontSize: 32, + FontColor: drawing.ColorBlack, + } + drawDebugBox := false + + tests := []struct { + degrees int + expectedY int + }{ + { + degrees: 15, + expectedY: 127, + }, + { + degrees: 30, + expectedY: 60, + }, + { + degrees: 45, + expectedY: 1, + }, + { + degrees: 60, + expectedY: -43, + }, + { + degrees: 75, + expectedY: -71, + }, + { + degrees: 90, + expectedY: -81, + }, + { + degrees: 105, + expectedY: -71, + }, + { + degrees: 120, + expectedY: -43, + }, + { + degrees: 135, + expectedY: 1, + }, + { + degrees: 150, + expectedY: 60, + }, + { + degrees: 165, + expectedY: 127, + }, + { + degrees: 180, + expectedY: 160, + }, + { + degrees: 195, + expectedY: 170, + }, + { + degrees: 210, + expectedY: 180, + }, + { + degrees: 225, + expectedY: 188, + }, + { + degrees: 240, + expectedY: 195, + }, + { + degrees: 255, + expectedY: 199, + }, + { + degrees: 270, + expectedY: 200, + }, + { + degrees: 285, + expectedY: 200, + }, + { + degrees: 300, + expectedY: 200, + }, + { + degrees: 315, + expectedY: 200, + }, + { + degrees: 330, + expectedY: 200, + }, + { + degrees: 345, + expectedY: 200, + }, + { + degrees: 360, + expectedY: 200, + }, + } + + for _, tt := range tests { + name := strconv.Itoa(tt.degrees) + for len(name) < 3 { + name = "0" + name + } + t.Run(name, func(t *testing.T) { + padding := 200 + p := NewPainter(PainterOptions{ + OutputFormat: ChartOutputSVG, + Width: 600, + Height: 400, + }, PainterPaddingOption(chartdraw.Box{Left: padding, Top: padding})) + + radians := DegreesToRadians(float64(tt.degrees)) + testText := text + name + textBox := p.MeasureText(testText, 0, fontStyle) + + if drawDebugBox { + debugBox := []Point{ + {X: 0, Y: 0}, + {X: 0, Y: -textBox.Height()}, + {X: textBox.Width(), Y: -textBox.Height()}, + {X: textBox.Width(), Y: 0}, + {X: 0, Y: 0}, + } + p.LineStroke(debugBox, drawing.ColorBlue, 1) + } + + assert.Equal(t, tt.expectedY, padding-textRotationHeightAdjustment(textBox.Width(), textBox.Height(), radians)) + + p.Text(testText, 0, -textRotationHeightAdjustment(textBox.Width(), textBox.Height(), radians), radians, fontStyle) + + data, err := p.Bytes() + require.NoError(t, err) + + if drawDebugBox { + assertEqualSVG(t, "", data) + } else { + expectedResult := fmt.Sprintf(expectedTemplate, tt.expectedY, tt.degrees%360, tt.expectedY, name) + assertEqualSVG(t, expectedResult, data) + } + }) + } +} + func TestPainterRoundedRect(t *testing.T) { t.Parallel() diff --git a/util.go b/util.go index b88be22..fecff0b 100644 --- a/util.go +++ b/util.go @@ -7,6 +7,8 @@ import ( "strings" "github.com/dustin/go-humanize" + + "github.com/go-analyze/charts/chartdraw" ) // True returns a pointer to a true bool, useful for configuration. @@ -154,6 +156,16 @@ const mValue = kValue * kValue const gValue = mValue * kValue const tValue = gValue * kValue +// DegreesToRadians returns degrees as radians. +func DegreesToRadians(degrees float64) float64 { + return chartdraw.DegreesToRadians(degrees) +} + +// RadiansToDegrees translates a radian value to a degree value. +func RadiansToDegrees(value float64) float64 { + return chartdraw.RadiansToDegrees(value) +} + // FormatValueHumanizeShort takes in a value and a specified precision, rounding to the specified precision and // returning a human friendly number string including commas. If the value is over 1,000 it will be reduced to a // shorter version with the appropriate k, M, G, T suffix.