add logarithmic axes support, with tests. Supports positive Y-values only. (#141)

Co-authored-by: Ton Wessling <twessling@ebay.com>
This commit is contained in:
twessling-icas 2023-05-22 22:37:57 +07:00 committed by GitHub
parent c1468e8ae4
commit 1ccfbb0172
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 195 additions and 1 deletions

3
.gitignore vendored
View file

@ -16,4 +16,5 @@
# Other
.vscode
.DS_Store
coverage.html
coverage.html
.idea

View file

@ -0,0 +1,41 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart"
)
func main() {
/*
In this example we set the primary YAxis to have logarithmic range.
*/
graph := chart.Chart{
Background: chart.Style{
Padding: chart.Box{
Top: 20,
Left: 20,
},
},
Series: []chart.Series{
chart.ContinuousSeries{
Name: "A test series",
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1, 10, 100, 1000, 10000},
},
},
YAxis: chart.YAxis{
Style: chart.Shown(),
NameStyle: chart.Shown(),
Range: &chart.LogarithmicRange{},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

94
logarithmic_range.go Normal file
View file

@ -0,0 +1,94 @@
package chart
import (
"fmt"
"math"
)
// LogarithmicRange represents a boundary for a set of numbers.
type LogarithmicRange struct {
Min float64
Max float64
Domain int
Descending bool
}
// IsDescending returns if the range is descending.
func (r LogarithmicRange) IsDescending() bool {
return r.Descending
}
// IsZero returns if the LogarithmicRange has been set or not.
func (r LogarithmicRange) IsZero() bool {
return (r.Min == 0 || math.IsNaN(r.Min)) &&
(r.Max == 0 || math.IsNaN(r.Max)) &&
r.Domain == 0
}
// GetMin gets the min value for the continuous range.
func (r LogarithmicRange) GetMin() float64 {
return r.Min
}
// SetMin sets the min value for the continuous range.
func (r *LogarithmicRange) SetMin(min float64) {
r.Min = min
}
// GetMax returns the max value for the continuous range.
func (r LogarithmicRange) GetMax() float64 {
return r.Max
}
// SetMax sets the max value for the continuous range.
func (r *LogarithmicRange) SetMax(max float64) {
r.Max = max
}
// GetDelta returns the difference between the min and max value.
func (r LogarithmicRange) GetDelta() float64 {
return r.Max - r.Min
}
// GetDomain returns the range domain.
func (r LogarithmicRange) GetDomain() int {
return r.Domain
}
// SetDomain sets the range domain.
func (r *LogarithmicRange) SetDomain(domain int) {
r.Domain = domain
}
// String returns a simple string for the LogarithmicRange.
func (r LogarithmicRange) String() string {
return fmt.Sprintf("LogarithmicRange [%.2f,%.2f] => %d", r.Min, r.Max, r.Domain)
}
// Translate maps a given value into the LogarithmicRange space. Modified version from ContinuousRange.
func (r LogarithmicRange) Translate(value float64) int {
if value < 1 {
return 0
}
normalized := math.Max(value-r.Min, 1)
ratio := math.Log10(normalized) / math.Log10(r.GetDelta())
if r.IsDescending() {
return r.Domain - int(math.Ceil(ratio*float64(r.Domain)))
}
return int(math.Ceil(ratio * float64(r.Domain)))
}
// GetTicks calculates the needed ticks for the axis, in log scale. Only supports Y values > 0.
func (r LogarithmicRange) GetTicks(render Renderer, defaults Style, vf ValueFormatter) []Tick {
var ticks []Tick
exponentStart := int64(math.Max(0, math.Floor(math.Log10(r.Min)))) // one below min
exponentEnd := int64(math.Max(0, math.Ceil(math.Log10(r.Max)))) // one above max
for exp:=exponentStart; exp<=exponentEnd; exp++ {
tickVal := math.Pow(10, float64(exp))
ticks = append(ticks, Tick{Value: tickVal, Label: vf(tickVal)})
}
return ticks
}

46
logarithmic_range_test.go Normal file
View file

@ -0,0 +1,46 @@
package chart
import (
"testing"
"github.com/blend/go-sdk/assert"
)
func TestLogRangeTranslate(t *testing.T) {
assert := assert.New(t)
values := []float64{1, 10, 100, 1000, 10000, 100000, 1000000}
r := LogarithmicRange{Domain: 1000}
r.Min, r.Max = MinMax(values...)
assert.Equal(0, r.Translate(0)) // goes to bottom
assert.Equal(0, r.Translate(1)) // goes to bottom
assert.Equal(160, r.Translate(10)) // roughly 1/6th of max
assert.Equal(500, r.Translate(1000)) // roughly 1/2 of max (1.0e6 / 1.0e3)
assert.Equal(1000, r.Translate(1000000)) // max value
}
func TestGetTicks(t *testing.T) {
assert := assert.New(t)
values := []float64{35, 512, 1525122}
r := LogarithmicRange{Domain: 1000}
r.Min, r.Max = MinMax(values...)
ticks := r.GetTicks(nil, Style{}, FloatValueFormatter)
assert.Equal(7, len(ticks))
assert.Equal(10, ticks[0].Value)
assert.Equal(100, ticks[1].Value)
assert.Equal(10000000, ticks[6].Value)
}
func TestGetTicksFromHigh(t *testing.T) {
assert := assert.New(t)
values := []float64{1412, 352144, 1525122} // min tick should be 1000
r := LogarithmicRange{}
r.Min, r.Max = MinMax(values...)
ticks := r.GetTicks(nil, Style{}, FloatValueFormatter)
assert.Equal(5, len(ticks))
assert.Equal(float64(1000), ticks[0].Value)
assert.Equal(float64(10000), ticks[1].Value)
assert.Equal(float64(10000000), ticks[4].Value)
}

View file

@ -103,3 +103,8 @@ func KValueFormatter(k float64, vf ValueFormatter) ValueFormatter {
return fmt.Sprintf("%0.0fσ %s", k, vf(v))
}
}
// FloatValueFormatter is a ValueFormatter for float64, exponential notation, e.g. 1.52e+08.
func ExponentialValueFormatter(v interface{}) string {
return FloatValueFormatterWithFormat(v, "%.2e")
}

View file

@ -56,3 +56,10 @@ func TestFloatValueFormatterWithFormat(t *testing.T) {
testutil.AssertEqual(t, "123.456", sv)
testutil.AssertEqual(t, "123.000", FloatValueFormatterWithFormat(123, "%.3f"))
}
func TestExponentialValueFormatter(t *testing.T) {
assert := assert.New(t)
assert.Equal("1.23e+02", ExponentialValueFormatter(123.456))
assert.Equal("1.24e+07", ExponentialValueFormatter(12421243.424))
assert.Equal("4.50e-01", ExponentialValueFormatter(0.45))
}