diff --git a/tick.go b/tick.go index 72ff9c5..d5d5a7b 100644 --- a/tick.go +++ b/tick.go @@ -5,7 +5,7 @@ import ( "math" "strings" - util "github.com/wcharczuk/go-chart/util" + "github.com/wcharczuk/go-chart/util" ) // TicksProvider is a type that provides ticks. @@ -115,3 +115,187 @@ func GenerateContinuousTicks(r Renderer, ra Range, isVertical bool, style Style, return ticks } + +// GeneratePrettyContinuousTicks generates a set of ticks at visually pleasing intervals. +// Based on http://vis.stanford.edu/files/2010-TickLabels-InfoVis.pdf by Justin Talbot et. al. +func GeneratePrettyContinuousTicks(r Renderer, ra Range, isVertical bool, style Style, vf ValueFormatter) []Tick { + if vf == nil { + vf = FloatValueFormatter + } + + prettyStepsPriorityList := []float64{1, 5, 2, 2.5, 4, 3} + paramWeights := map[string]float64{ + "simplicity": 0.2, + "coverage": 0.25, + "density": 0.5, + "legibility": 0.05, + } + + rangeMin, rangeMax := ra.GetMin(), ra.GetMax() + + renderedLabelExample := vf(rangeMin) + style.GetTextOptions().WriteToRenderer(r) + renderedLabelSizePx := r.MeasureText(renderedLabelExample) + + var actualLabelSizePx, desiredPaddedLabelSizePx float64 + if isVertical { + actualLabelSizePx = math.Max(float64(renderedLabelSizePx.Height()), 1) + desiredPaddedLabelSizePx = actualLabelSizePx + DefaultMinimumTickVerticalSpacing + } else { + actualLabelSizePx = math.Max(float64(renderedLabelSizePx.Width()), 1) + desiredPaddedLabelSizePx = actualLabelSizePx + DefaultMinimumTickHorizontalSpacing + } + availableSpacePx := float64(ra.GetDomain()) + desiredTicksCount := math.Min( + math.Max(math.Floor(availableSpacePx/desiredPaddedLabelSizePx), 2), // less than 2 leads to incorrect density calculation + DefaultTickCountSanityCheck) + + prettyStepsCount := float64(len(prettyStepsPriorityList)) + + var bestTickMin, bestTickMax, bestTickStep float64 + bestScore := -2.0 + stepsToSkip := 1.0 + +OUTER: + for { + for prettyStepIndex, prettyStep := range prettyStepsPriorityList { + simplicityMax := calculateSimplicityMax(float64(prettyStepIndex), prettyStepsCount, stepsToSkip) + + if paramWeights["simplicity"]*simplicityMax+ + paramWeights["coverage"]+ + paramWeights["density"]+ + paramWeights["legibility"] < bestScore { + break OUTER + } + + ticksCount := 2.0 + for { + densityMax := calculateDensityMax(ticksCount, desiredTicksCount) + + if paramWeights["simplicity"]*simplicityMax+ + paramWeights["coverage"]+ + paramWeights["density"]*densityMax+ + paramWeights["legibility"] < bestScore { + break + } + + delta := (rangeMax - rangeMin) / (ticksCount + 1) / stepsToSkip / prettyStep + stepSizeMultiplierLog := math.Ceil(math.Log10(delta)) + + for { + tickStep := stepsToSkip * prettyStep * math.Pow(10, stepSizeMultiplierLog) + coverageMax := calculateCoverageMax(rangeMin, rangeMax, tickStep*(ticksCount-1)) + + if paramWeights["simplicity"]*simplicityMax+ + paramWeights["coverage"]*coverageMax+ + paramWeights["density"]*densityMax+ + paramWeights["legibility"] < bestScore { + break + } + + minStart := math.Floor(rangeMax/tickStep)*stepsToSkip - (ticksCount-1)*stepsToSkip + maxStart := math.Ceil(rangeMin/tickStep) * stepsToSkip + + if minStart > maxStart { + stepSizeMultiplierLog += 1 + continue + } + + for start := minStart; start <= maxStart; start++ { + tickMin := start * (tickStep / stepsToSkip) + tickMax := tickMin + tickStep*(ticksCount-1) + + coverage := calculateCoverage(rangeMin, rangeMax, tickMin, tickMax) + simplicity := calculateSimplicity(prettyStepsCount, float64(prettyStepIndex), stepsToSkip, tickMin, tickMax, tickStep) + density := calculateDensity(ticksCount, desiredTicksCount, rangeMin, rangeMax, tickMin, tickMax) + legibility := 1.0 // format is out of our control (provided by ValueFormatter) + // font size is out of our control (provided by Style) + // orientation is out of our control + if actualLabelSizePx*ticksCount > availableSpacePx { + legibility = math.Inf(-1) // overlap is unacceptable + } + + score := paramWeights["simplicity"]*simplicity + + paramWeights["coverage"]*coverage + + paramWeights["density"]*density + + paramWeights["legibility"]*legibility + + // original algorithm allows ticks outside value range, but it breaks rendering in this library + if score > bestScore && tickMin >= rangeMin && tickMax <= rangeMax { + bestTickMin = tickMin + bestTickMax = tickMax + bestTickStep = tickStep + bestScore = score + } + } + stepSizeMultiplierLog++ + } + ticksCount++ + } + } + stepsToSkip++ + } + + var ticks []Tick + if bestTickStep == 0 { + return ticks + } + + if ra.IsDescending() { + for tickValue := bestTickMax; tickValue > bestTickMin-bestTickStep/2; tickValue -= bestTickStep { + ticks = append(ticks, Tick{ + Value: tickValue, + Label: vf(tickValue), + }) + } + } else { + for tickValue := bestTickMin; tickValue < bestTickMax+bestTickStep/2; tickValue += bestTickStep { + ticks = append(ticks, Tick{ + Value: tickValue, + Label: vf(tickValue), + }) + } + } + + return ticks +} + +func calculateSimplicity(prettyStepsCount, prettyStepIndex, stepsToSkip, tickMin, tickMax, tickStep float64) float64 { + var hasZeroTick float64 + if tickMin <= 0 && tickMax >= 0 && math.Mod(tickMin, tickStep) < 10e-10 { + hasZeroTick = 1 + } + + return 1 - prettyStepIndex/(prettyStepsCount-1) - stepsToSkip + hasZeroTick +} + +func calculateSimplicityMax(prettyStepIndex, prettyStepsCount, stepsToSkip float64) float64 { + return 2 - prettyStepIndex/(prettyStepsCount-1) - stepsToSkip +} + +func calculateCoverage(rangeMin, rangeMax, tickMin, tickMax float64) float64 { + return 1 - 0.5*(math.Pow(rangeMax-tickMax, 2)+math.Pow(rangeMin-tickMin, 2))/math.Pow(0.1*(rangeMax-rangeMin), 2) +} + +func calculateCoverageMax(rangeMin, rangeMax, span float64) float64 { + if span <= rangeMax-rangeMin { + return 1 + } + + return 1 - math.Pow((rangeMax-rangeMin)/2, 2)/math.Pow(0.1*(rangeMax-rangeMin), 2) +} + +func calculateDensity(ticksCount, desiredTicksCount, rangeMin, rangeMax, tickMin, tickMax float64) float64 { + ticksDensity := (ticksCount - 1) / (tickMax - tickMin) + desiredTicksDensity := (desiredTicksCount - 1) / (math.Max(tickMax, rangeMax) - math.Min(tickMin, rangeMin)) + + return 2 - math.Max(ticksDensity/desiredTicksDensity, desiredTicksDensity/ticksDensity) +} + +func calculateDensityMax(ticksCount, desiredTicksCount float64) float64 { + if ticksCount >= desiredTicksCount { + return 2 - (ticksCount-1)/(desiredTicksCount-1) + } + + return 1 +} diff --git a/tick_test.go b/tick_test.go index 9802150..446a098 100644 --- a/tick_test.go +++ b/tick_test.go @@ -1,9 +1,10 @@ package chart import ( + "math" "testing" - assert "github.com/blend/go-sdk/assert" + "github.com/blend/go-sdk/assert" ) func TestGenerateContinuousTicks(t *testing.T) { @@ -58,3 +59,129 @@ func TestGenerateContinuousTicksDescending(t *testing.T) { assert.Equal(1.0, ticks[len(ticks)-2].Value) assert.Equal(0.0, ticks[len(ticks)-1].Value) } + +func TestGenerateContinuousPrettyTicks(t *testing.T) { + assert := assert.New(t) + + f, err := GetDefaultFont() + assert.Nil(err) + + r, err := PNG(1024, 1024) + assert.Nil(err) + r.SetFont(f) + + ra := &ContinuousRange{ + Min: 37.5, + Max: 60.1, + Domain: 256, + } + + vf := FloatValueFormatter + + ticks := GeneratePrettyContinuousTicks(r, ra, false, Style{}, vf) + assert.NotEmpty(ticks) + assert.Equal(ticks, []Tick{ + {Label: "38.00", Value: 38}, + {Label: "40.00", Value: 40}, + {Label: "42.00", Value: 42}, + {Label: "44.00", Value: 44}, + {Label: "46.00", Value: 46}, + {Label: "48.00", Value: 48}, + {Label: "50.00", Value: 50}, + {Label: "52.00", Value: 52}, + {Label: "54.00", Value: 54}, + {Label: "56.00", Value: 56}, + {Label: "58.00", Value: 58}, + {Label: "60.00", Value: 60}}) +} + +func TestGeneratePrettyTicksForVerySmallRange(t *testing.T) { + assert := assert.New(t) + + f, err := GetDefaultFont() + assert.Nil(err) + + r, err := PNG(1024, 1024) + assert.Nil(err) + r.SetFont(f) + + ra := &ContinuousRange{ + Min: 1e-100, + Max: 1e-99, + Domain: 256, + } + + vf := FloatValueFormatter + + ticks := GeneratePrettyContinuousTicks(r, ra, false, Style{}, vf) + assert.NotEmpty(ticks) + assert.Len(ticks, 9) +} + +func TestGeneratePrettyTicksForVeryLargeRange(t *testing.T) { + assert := assert.New(t) + + f, err := GetDefaultFont() + assert.Nil(err) + + r, err := PNG(1024, 1024) + assert.Nil(err) + r.SetFont(f) + + ra := &ContinuousRange{ + Min: 1e-100, + Max: 1e+100, + Domain: 256, + } + + vf := FloatValueFormatter + + ticks := GeneratePrettyContinuousTicks(r, ra, false, Style{}, vf) + assert.NotEmpty(ticks) + assert.Len(ticks, 10) +} + +func TestGeneratePrettyTicksForVerySmallDomain(t *testing.T) { + assert := assert.New(t) + + f, err := GetDefaultFont() + assert.Nil(err) + + r, err := PNG(1024, 1024) + assert.Nil(err) + r.SetFont(f) + + ra := &ContinuousRange{ + Min: 0.0, + Max: 10.0, + Domain: 1, + } + + vf := FloatValueFormatter + + ticks := GeneratePrettyContinuousTicks(r, ra, false, Style{}, vf) + assert.Empty(ticks) +} + +func TestGeneratePrettyTicksForVeryLargeDomain(t *testing.T) { + assert := assert.New(t) + + f, err := GetDefaultFont() + assert.Nil(err) + + r, err := PNG(1024, 1024) + assert.Nil(err) + r.SetFont(f) + + ra := &ContinuousRange{ + Min: 0.0, + Max: 10.0, + Domain: math.MaxInt32, + } + + vf := FloatValueFormatter + + ticks := GeneratePrettyContinuousTicks(r, ra, false, Style{}, vf) + assert.NotEmpty(ticks) + assert.Len(ticks, 1001) +} diff --git a/xaxis.go b/xaxis.go index d97616c..702bf9c 100644 --- a/xaxis.go +++ b/xaxis.go @@ -3,7 +3,7 @@ package chart import ( "math" - util "github.com/wcharczuk/go-chart/util" + "github.com/wcharczuk/go-chart/util" ) // XAxis represents the horizontal axis. @@ -15,9 +15,10 @@ type XAxis struct { ValueFormatter ValueFormatter Range Range - TickStyle Style - Ticks []Tick - TickPosition TickPosition + TickStyle Style + Ticks []Tick + TickPosition TickPosition + EnablePrettyTicks bool GridLines []GridLine GridMajorStyle Style @@ -66,6 +67,9 @@ func (xa XAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter return tp.GetTicks(r, defaults, vf) } tickStyle := xa.Style.InheritFrom(defaults) + if xa.EnablePrettyTicks { + return GeneratePrettyContinuousTicks(r, ra, false, tickStyle, vf) + } return GenerateContinuousTicks(r, ra, false, tickStyle, vf) } diff --git a/yaxis.go b/yaxis.go index 3549888..dc3ad93 100644 --- a/yaxis.go +++ b/yaxis.go @@ -3,7 +3,7 @@ package chart import ( "math" - util "github.com/wcharczuk/go-chart/util" + "github.com/wcharczuk/go-chart/util" ) // YAxis is a veritcal rule of the range. @@ -22,8 +22,9 @@ type YAxis struct { ValueFormatter ValueFormatter Range Range - TickStyle Style - Ticks []Tick + TickStyle Style + Ticks []Tick + EnablePrettyTicks bool GridLines []GridLine GridMajorStyle Style @@ -71,6 +72,9 @@ func (ya YAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter return tp.GetTicks(r, defaults, vf) } tickStyle := ya.Style.InheritFrom(defaults) + if ya.EnablePrettyTicks { + return GeneratePrettyContinuousTicks(r, ra, true, tickStyle, vf) + } return GenerateContinuousTicks(r, ra, true, tickStyle, vf) }