Skip to content

Commit

Permalink
Fix X-Axis label position when rotated
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jentfoo committed Jan 19, 2025
1 parent b32302f commit 8ee8483
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 20 deletions.
19 changes: 14 additions & 5 deletions axis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
}

Expand Down
68 changes: 67 additions & 1 deletion axis_test.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion chartdraw/donut_chart.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion chartdraw/mathutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions examples/line_chart-3/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions examples/line_chart-4/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 54 additions & 7 deletions painter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
161 changes: 160 additions & 1 deletion painter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 400 300\"><text x=\"8\" y=\"16\" style=\"stroke:none;fill:none;font-family:'Roboto Medium',sans-serif\" transform=\"rotate(90.00,8,16)\">hello world!</text></svg>",
},
Expand Down Expand Up @@ -261,6 +261,165 @@ func TestPainterExternal(t *testing.T) {
}
}

func TestTextRotationHeightAdjustment(t *testing.T) {
t.Parallel()

text := "hello world "
expectedTemplate := "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" viewBox=\"0 0 600 400\"><text x=\"200\" y=\"%d\" style=\"stroke:none;fill:black;font-size:40.9px;font-family:'Roboto Medium',sans-serif\" transform=\"rotate(%d.00,200,%d)\">hello world %s</text></svg>"
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()

Expand Down
12 changes: 12 additions & 0 deletions util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit 8ee8483

Please sign in to comment.