axis labels!

This commit is contained in:
Will Charczuk 2016-07-07 20:26:07 -07:00
parent 5c8836f9bd
commit 4bbc7978a2
7 changed files with 178 additions and 31 deletions

111
chart.go
View file

@ -1,6 +1,7 @@
package chart package chart
import ( import (
"errors"
"io" "io"
"math" "math"
@ -37,6 +38,9 @@ func (c Chart) GetFont() (*truetype.Font, error) {
// Render renders the chart with the given renderer to the given io.Writer. // Render renders the chart with the given renderer to the given io.Writer.
func (c *Chart) Render(provider RendererProvider, w io.Writer) error { func (c *Chart) Render(provider RendererProvider, w io.Writer) error {
if len(c.Series) == 0 {
return errors.New("Please provide at least one series")
}
r := provider(c.Width, c.Height) r := provider(c.Width, c.Height)
if c.hasText() { if c.hasText() {
font, err := c.GetFont() font, err := c.GetFont()
@ -60,7 +64,15 @@ func (c *Chart) Render(provider RendererProvider, w io.Writer) error {
} }
func (c Chart) hasText() bool { func (c Chart) hasText() bool {
return c.TitleStyle.Show || c.Axes.Show return c.TitleStyle.Show || c.Axes.Show || c.FinalValueLabel.Show
}
func (c Chart) getAxisWidth() int {
asw := 0
if c.Axes.Show {
asw = int(c.Axes.GetStrokeWidth(DefaultAxisLineWidth))
}
return asw
} }
func (c Chart) calculateCanvasBox(r Renderer) Box { func (c Chart) calculateCanvasBox(r Renderer) Box {
@ -69,7 +81,11 @@ func (c Chart) calculateCanvasBox(r Renderer) Box {
if finalLabelWidth > dpr { if finalLabelWidth > dpr {
dpr = finalLabelWidth dpr = finalLabelWidth
} }
axisBottomHeight := c.calculateBottomLabelHeight()
dpb := DefaultBackgroundPadding.Bottom dpb := DefaultBackgroundPadding.Bottom
if dpb < axisBottomHeight {
dpb = axisBottomHeight
}
cb := Box{ cb := Box{
Top: c.Background.Padding.GetTop(DefaultBackgroundPadding.Top), Top: c.Background.Padding.GetTop(DefaultBackgroundPadding.Top),
@ -88,7 +104,8 @@ func (c Chart) calculateFinalLabelWidth(r Renderer) int {
} }
var finalLabelText string var finalLabelText string
for _, s := range c.Series { for _, s := range c.Series {
_, ll := s.GetLabel(s.Len() - 1) _, lv := s.GetValue(s.Len() - 1)
ll := s.GetYFormatter()(lv)
if len(finalLabelText) < len(ll) { if len(finalLabelText) < len(ll) {
finalLabelText = ll finalLabelText = ll
} }
@ -96,11 +113,10 @@ func (c Chart) calculateFinalLabelWidth(r Renderer) int {
r.SetFontSize(c.FinalValueLabel.GetFontSize(DefaultFinalLabelFontSize)) r.SetFontSize(c.FinalValueLabel.GetFontSize(DefaultFinalLabelFontSize))
textWidth := r.MeasureText(finalLabelText) textWidth := r.MeasureText(finalLabelText)
asw := c.getAxisWidth()
pl := c.FinalValueLabel.Padding.GetLeft(DefaultFinalLabelPadding.Left) pl := c.FinalValueLabel.Padding.GetLeft(DefaultFinalLabelPadding.Left)
pr := c.FinalValueLabel.Padding.GetRight(DefaultFinalLabelPadding.Right) pr := c.FinalValueLabel.Padding.GetRight(DefaultFinalLabelPadding.Right)
asw := int(c.Axes.GetStrokeWidth(DefaultAxisLineWidth))
lsw := int(c.FinalValueLabel.GetStrokeWidth(DefaultAxisLineWidth)) lsw := int(c.FinalValueLabel.GetStrokeWidth(DefaultAxisLineWidth))
return DefaultFinalLabelDeltaWidth + return DefaultFinalLabelDeltaWidth +
@ -108,6 +124,13 @@ func (c Chart) calculateFinalLabelWidth(r Renderer) int {
textWidth + asw + 2*lsw textWidth + asw + 2*lsw
} }
func (c Chart) calculateBottomLabelHeight() int {
if c.Axes.Show {
return c.getAxisWidth() + int(math.Ceil(c.Axes.GetFontSize(DefaultAxisFontSize))) + DefaultXAxisMargin
}
return 0
}
func (c Chart) initRanges(canvasBox Box) (xrange Range, yrange Range) { func (c Chart) initRanges(canvasBox Box) (xrange Range, yrange Range) {
//iterate over each series, pull out the min/max for x,y //iterate over each series, pull out the min/max for x,y
var didSetFirstValues bool var didSetFirstValues bool
@ -136,6 +159,8 @@ func (c Chart) initRanges(canvasBox Box) (xrange Range, yrange Range) {
didSetFirstValues = true didSetFirstValues = true
} }
} }
xrange.Formatter = s.GetXFormatter()
yrange.Formatter = s.GetYFormatter()
} }
if c.XRange.IsZero() { if c.XRange.IsZero() {
@ -194,13 +219,74 @@ func (c Chart) drawAxes(r Renderer, canvasBox Box, xrange, yrange Range) {
r.LineTo(canvasBox.Right, canvasBox.Top) r.LineTo(canvasBox.Right, canvasBox.Top)
r.Stroke() r.Stroke()
c.drawAxesLabels(r, xrange, yrange) c.drawXAxisLabels(r, canvasBox, xrange)
c.drawYAxisLabels(r, canvasBox, yrange)
} }
} }
func (c Chart) drawAxesLabels(r Renderer, xrange, yrange Range) { func (c Chart) drawYAxisLabels(r Renderer, canvasBox Box, yrange Range) {
// do x axis tickFontSize := c.Axes.GetFontSize(DefaultAxisFontSize)
// do y axis
r.SetFontColor(c.Axes.GetFontColor(DefaultAxisColor))
r.SetFontSize(tickFontSize)
minimumTickHeight := tickFontSize + DefaultMinimumTickVerticalSpacing
tickCount := int(math.Floor(float64(yrange.Domain) / float64(minimumTickHeight)))
if tickCount > DefaultMaxTickCount {
tickCount = DefaultMaxTickCount
}
rangeTicks := Slices(tickCount, yrange.Max-yrange.Min)
domainTicks := Slices(tickCount, float64(yrange.Domain))
asw := c.getAxisWidth()
tx := canvasBox.Right + DefaultFinalLabelDeltaWidth + asw
count := len(rangeTicks)
if len(domainTicks) < count {
count = len(domainTicks) //guard against mismatched array sizes.
}
for index := 0; index < count; index++ {
v := rangeTicks[index]
y := domainTicks[index]
ty := canvasBox.Bottom - int(y)
r.Text(yrange.Format(v), tx, ty)
}
}
func (c Chart) drawXAxisLabels(r Renderer, canvasBox Box, xrange Range) {
tickFontSize := c.Axes.GetFontSize(DefaultAxisFontSize)
r.SetFontColor(c.Axes.GetFontColor(DefaultAxisColor))
r.SetFontSize(tickFontSize)
maxLabelWidth := 60
minimumTickWidth := maxLabelWidth + DefaultMinimumTickHorizontalSpacing
tickCount := int(math.Floor(float64(xrange.Domain) / float64(minimumTickWidth)))
if tickCount > DefaultMaxTickCount {
tickCount = DefaultMaxTickCount
}
rangeTicks := Slices(tickCount, xrange.Max-xrange.Min)
domainTicks := Slices(tickCount, float64(xrange.Domain))
ty := canvasBox.Bottom + DefaultXAxisMargin + int(tickFontSize)
count := len(rangeTicks)
if len(domainTicks) < count {
count = len(domainTicks) //guard against mismatched array sizes.
}
for index := 0; index < count; index++ {
v := rangeTicks[index] + xrange.Min
x := domainTicks[index]
tx := canvasBox.Left + int(x)
r.Text(xrange.Format(v), tx, ty)
}
} }
func (c Chart) drawSeries(r Renderer, canvasBox Box, index int, s Series, xrange, yrange Range) { func (c Chart) drawSeries(r Renderer, canvasBox Box, index int, s Series, xrange, yrange Range) {
@ -236,7 +322,7 @@ func (c Chart) drawSeries(r Renderer, canvasBox Box, index int, s Series, xrange
func (c Chart) drawFinalValueLabel(r Renderer, canvasBox Box, index int, s Series, yrange Range) { func (c Chart) drawFinalValueLabel(r Renderer, canvasBox Box, index int, s Series, yrange Range) {
if c.FinalValueLabel.Show { if c.FinalValueLabel.Show {
_, lv := s.GetValue(s.Len() - 1) _, lv := s.GetValue(s.Len() - 1)
_, ll := s.GetLabel(s.Len() - 1) ll := s.GetYFormatter()(lv)
py := canvasBox.Top py := canvasBox.Top
ly := yrange.Translate(lv) + py ly := yrange.Translate(lv) + py
@ -246,7 +332,12 @@ func (c Chart) drawFinalValueLabel(r Renderer, canvasBox Box, index int, s Serie
textHeight := int(math.Floor(DefaultFinalLabelFontSize)) textHeight := int(math.Floor(DefaultFinalLabelFontSize))
halfTextHeight := textHeight >> 1 halfTextHeight := textHeight >> 1
cx := canvasBox.Right + int(c.Axes.GetStrokeWidth(DefaultAxisLineWidth)) asw := 0
if c.Axes.Show {
asw = int(c.Axes.GetStrokeWidth(DefaultAxisLineWidth))
}
cx := canvasBox.Right + asw
pt := c.FinalValueLabel.Padding.GetTop(DefaultFinalLabelPadding.Top) pt := c.FinalValueLabel.Padding.GetTop(DefaultFinalLabelPadding.Top)
pl := c.FinalValueLabel.Padding.GetLeft(DefaultFinalLabelPadding.Left) pl := c.FinalValueLabel.Padding.GetLeft(DefaultFinalLabelPadding.Left)

View file

@ -28,10 +28,20 @@ const (
DefaultFinalLabelDeltaWidth = 10 DefaultFinalLabelDeltaWidth = 10
// DefaultFinalLabelFontSize is the font size of the final label. // DefaultFinalLabelFontSize is the font size of the final label.
DefaultFinalLabelFontSize = 10.0 DefaultFinalLabelFontSize = 10.0
// DefaultAxisFontSize is the font size of the axis labels.
DefaultAxisFontSize = 10.0
// DefaultTitleTop is the default distance from the top of the chart to put the title. // DefaultTitleTop is the default distance from the top of the chart to put the title.
DefaultTitleTop = 10 DefaultTitleTop = 10
// DefaultXAxisMargin is the default distance from bottom of the canvas to the x axis labels.
DefaultXAxisMargin = 10
// DefaultMinimumTickHorizontalSpacing is the minimum distance between horizontal ticks.
DefaultMinimumTickHorizontalSpacing = 20
// DefaultMinimumTickVerticalSpacing is the minimum distance between vertical ticks.
DefaultMinimumTickVerticalSpacing = 20
// DefaultDateFormat is the default date format. // DefaultDateFormat is the default date format.
DefaultDateFormat = "2006-01-02" DefaultDateFormat = "2006-01-02"
// DefaultMaxTickCount is the maximum number of ticks to draw
DefaultMaxTickCount = 7
) )
var ( var (

4
formatter.go Normal file
View file

@ -0,0 +1,4 @@
package chart
// Formatter is a function that takes a value and produces a string.
type Formatter func(v interface{}) string

View file

@ -3,6 +3,8 @@ package chart
import ( import (
"fmt" "fmt"
"math" "math"
"github.com/blendlabs/go-util"
) )
// Range represents a continuous range, // Range represents a continuous range,
@ -10,6 +12,7 @@ type Range struct {
Min float64 Min float64
Max float64 Max float64
Domain int Domain int
Formatter Formatter
} }
// IsZero returns if the range has been set or not. // IsZero returns if the range has been set or not.
@ -27,6 +30,14 @@ func (r Range) String() string {
return fmt.Sprintf("Range [%.2f,%.2f] => %d", r.Min, r.Max, r.Domain) return fmt.Sprintf("Range [%.2f,%.2f] => %d", r.Min, r.Max, r.Domain)
} }
// Format formats the value based on the range's formatter.
func (r Range) Format(v interface{}) string {
if r.Formatter != nil {
return r.Formatter(v)
}
return util.StringEmpty
}
// Translate maps a given value into the range space. // Translate maps a given value into the range space.
// An example would be a 600 px image, with a min of 10 and a max of 100. // An example would be a 600 px image, with a min of 10 and a max of 100.
// Translate(50) would yield (50.0/90.0)*600 ~= 333.33 // Translate(50) would yield (50.0/90.0)*600 ~= 333.33

View file

@ -3,6 +3,8 @@ package chart
import ( import (
"fmt" "fmt"
"time" "time"
"github.com/blendlabs/go-util"
) )
// Series is a entity data set. // Series is a entity data set.
@ -10,8 +12,11 @@ type Series interface {
GetName() string GetName() string
GetStyle() Style GetStyle() Style
Len() int Len() int
GetValue(index int) (float64, float64) GetValue(index int) (float64, float64)
GetLabel(index int) (string, string)
GetXFormatter() Formatter
GetYFormatter() Formatter
} }
// TimeSeries is a line on a chart. // TimeSeries is a line on a chart.
@ -45,11 +50,30 @@ func (ts TimeSeries) GetValue(index int) (x float64, y float64) {
return return
} }
// GetLabel gets a label for the values at a given index. // GetXFormatter returns the x value formatter.
func (ts TimeSeries) GetLabel(index int) (xLabel string, yLabel string) { func (ts TimeSeries) GetXFormatter() Formatter {
xLabel = ts.XValues[index].Format(DefaultDateFormat) return func(v interface{}) string {
yLabel = fmt.Sprintf("%0.2f", ts.YValues[index]) if typed, isTyped := v.(time.Time); isTyped {
return return typed.Format(DefaultDateFormat)
}
if typed, isTyped := v.(int64); isTyped {
return time.Unix(typed, 0).Format(DefaultDateFormat)
}
if typed, isTyped := v.(float64); isTyped {
return time.Unix(int64(typed), 0).Format(DefaultDateFormat)
}
return util.StringEmpty
}
}
// GetYFormatter returns the y value formatter.
func (ts TimeSeries) GetYFormatter() Formatter {
return func(v interface{}) string {
if typed, isTyped := v.(float64); isTyped {
return fmt.Sprintf("%0.2f", typed)
}
return util.StringEmpty
}
} }
// ContinousSeries represents a line on a chart. // ContinousSeries represents a line on a chart.
@ -81,9 +105,17 @@ func (cs ContinousSeries) GetValue(index int) (interface{}, float64) {
return cs.XValues[index], cs.YValues[index] return cs.XValues[index], cs.YValues[index]
} }
// GetLabel gets a label for the values at a given index. // GetXFormatter returns the xs value formatter.
func (cs ContinousSeries) GetLabel(index int) (xLabel string, yLabel string) { func (cs ContinousSeries) GetXFormatter() Formatter {
xLabel = fmt.Sprintf("%0.2f", cs.XValues[index]) return func(v interface{}) string {
yLabel = fmt.Sprintf("%0.2f", cs.YValues[index]) if typed, isTyped := v.(float64); isTyped {
return return fmt.Sprintf("%0.2f", typed)
}
return util.StringEmpty
}
}
// GetYFormatter returns the y value formatter.
func (cs ContinousSeries) GetYFormatter() Formatter {
return cs.GetXFormatter()
} }

View file

@ -21,8 +21,8 @@ func main() {
TitleStyle: chart.Style{ TitleStyle: chart.Style{
Show: true, Show: true,
}, },
Width: 800, Width: 640,
Height: 380, Height: 480,
Axes: chart.Style{ Axes: chart.Style{
Show: true, Show: true,
StrokeWidth: 1.0, StrokeWidth: 1.0,

View file

@ -3,7 +3,6 @@ package chart
import ( import (
"fmt" "fmt"
"image/color" "image/color"
"math"
"time" "time"
) )
@ -58,10 +57,10 @@ func MinAndMaxOfTime(values ...time.Time) (min time.Time, max time.Time) {
// Slices generates N slices that span the total. // Slices generates N slices that span the total.
// The resulting array will be intermediate indexes until total. // The resulting array will be intermediate indexes until total.
func Slices(count, total int) []int { func Slices(count int, total float64) []float64 {
var values []int var values []float64
sliceWidth := int(math.Floor(float64(total) / float64(count))) sliceWidth := float64(total) / float64(count)
for cursor := 0; cursor < total; cursor += sliceWidth { for cursor := 0.0; cursor < total; cursor += sliceWidth {
values = append(values, cursor) values = append(values, cursor)
} }
return values return values