diff --git a/chart.go b/chart.go index cd12a0c..d4439e1 100644 --- a/chart.go +++ b/chart.go @@ -1,6 +1,7 @@ package chart import ( + "errors" "io" "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. 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) if c.hasText() { font, err := c.GetFont() @@ -60,7 +64,15 @@ func (c *Chart) Render(provider RendererProvider, w io.Writer) error { } 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 { @@ -69,7 +81,11 @@ func (c Chart) calculateCanvasBox(r Renderer) Box { if finalLabelWidth > dpr { dpr = finalLabelWidth } + axisBottomHeight := c.calculateBottomLabelHeight() dpb := DefaultBackgroundPadding.Bottom + if dpb < axisBottomHeight { + dpb = axisBottomHeight + } cb := Box{ Top: c.Background.Padding.GetTop(DefaultBackgroundPadding.Top), @@ -88,7 +104,8 @@ func (c Chart) calculateFinalLabelWidth(r Renderer) int { } var finalLabelText string 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) { finalLabelText = ll } @@ -96,11 +113,10 @@ func (c Chart) calculateFinalLabelWidth(r Renderer) int { r.SetFontSize(c.FinalValueLabel.GetFontSize(DefaultFinalLabelFontSize)) textWidth := r.MeasureText(finalLabelText) + asw := c.getAxisWidth() pl := c.FinalValueLabel.Padding.GetLeft(DefaultFinalLabelPadding.Left) pr := c.FinalValueLabel.Padding.GetRight(DefaultFinalLabelPadding.Right) - - asw := int(c.Axes.GetStrokeWidth(DefaultAxisLineWidth)) lsw := int(c.FinalValueLabel.GetStrokeWidth(DefaultAxisLineWidth)) return DefaultFinalLabelDeltaWidth + @@ -108,6 +124,13 @@ func (c Chart) calculateFinalLabelWidth(r Renderer) int { 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) { //iterate over each series, pull out the min/max for x,y var didSetFirstValues bool @@ -136,6 +159,8 @@ func (c Chart) initRanges(canvasBox Box) (xrange Range, yrange Range) { didSetFirstValues = true } } + xrange.Formatter = s.GetXFormatter() + yrange.Formatter = s.GetYFormatter() } 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.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) { - // do x axis - // do y axis +func (c Chart) drawYAxisLabels(r Renderer, canvasBox Box, yrange Range) { + tickFontSize := c.Axes.GetFontSize(DefaultAxisFontSize) + + 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) { @@ -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) { if c.FinalValueLabel.Show { _, lv := s.GetValue(s.Len() - 1) - _, ll := s.GetLabel(s.Len() - 1) + ll := s.GetYFormatter()(lv) py := canvasBox.Top 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)) 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) pl := c.FinalValueLabel.Padding.GetLeft(DefaultFinalLabelPadding.Left) diff --git a/defaults.go b/defaults.go index 06f431f..a3d1e8b 100644 --- a/defaults.go +++ b/defaults.go @@ -28,10 +28,20 @@ const ( DefaultFinalLabelDeltaWidth = 10 // DefaultFinalLabelFontSize is the font size of the final label. 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 = 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 = "2006-01-02" + // DefaultMaxTickCount is the maximum number of ticks to draw + DefaultMaxTickCount = 7 ) var ( diff --git a/formatter.go b/formatter.go new file mode 100644 index 0000000..b1eda07 --- /dev/null +++ b/formatter.go @@ -0,0 +1,4 @@ +package chart + +// Formatter is a function that takes a value and produces a string. +type Formatter func(v interface{}) string diff --git a/range.go b/range.go index 65c5243..ad8a7ce 100644 --- a/range.go +++ b/range.go @@ -3,13 +3,16 @@ package chart import ( "fmt" "math" + + "github.com/blendlabs/go-util" ) // Range represents a continuous range, type Range struct { - Min float64 - Max float64 - Domain int + Min float64 + Max float64 + Domain int + Formatter Formatter } // 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) } +// 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. // 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 diff --git a/series.go b/series.go index a9d0bc8..b9a4228 100644 --- a/series.go +++ b/series.go @@ -3,6 +3,8 @@ package chart import ( "fmt" "time" + + "github.com/blendlabs/go-util" ) // Series is a entity data set. @@ -10,8 +12,11 @@ type Series interface { GetName() string GetStyle() Style Len() int + GetValue(index int) (float64, float64) - GetLabel(index int) (string, string) + + GetXFormatter() Formatter + GetYFormatter() Formatter } // TimeSeries is a line on a chart. @@ -45,11 +50,30 @@ func (ts TimeSeries) GetValue(index int) (x float64, y float64) { return } -// GetLabel gets a label for the values at a given index. -func (ts TimeSeries) GetLabel(index int) (xLabel string, yLabel string) { - xLabel = ts.XValues[index].Format(DefaultDateFormat) - yLabel = fmt.Sprintf("%0.2f", ts.YValues[index]) - return +// GetXFormatter returns the x value formatter. +func (ts TimeSeries) GetXFormatter() Formatter { + return func(v interface{}) string { + if typed, isTyped := v.(time.Time); isTyped { + 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. @@ -81,9 +105,17 @@ func (cs ContinousSeries) GetValue(index int) (interface{}, float64) { return cs.XValues[index], cs.YValues[index] } -// GetLabel gets a label for the values at a given index. -func (cs ContinousSeries) GetLabel(index int) (xLabel string, yLabel string) { - xLabel = fmt.Sprintf("%0.2f", cs.XValues[index]) - yLabel = fmt.Sprintf("%0.2f", cs.YValues[index]) - return +// GetXFormatter returns the xs value formatter. +func (cs ContinousSeries) GetXFormatter() Formatter { + return func(v interface{}) string { + if typed, isTyped := v.(float64); isTyped { + return fmt.Sprintf("%0.2f", typed) + } + return util.StringEmpty + } +} + +// GetYFormatter returns the y value formatter. +func (cs ContinousSeries) GetYFormatter() Formatter { + return cs.GetXFormatter() } diff --git a/testserver/main.go b/testserver/main.go index b9a12e9..5e79bd8 100644 --- a/testserver/main.go +++ b/testserver/main.go @@ -21,8 +21,8 @@ func main() { TitleStyle: chart.Style{ Show: true, }, - Width: 800, - Height: 380, + Width: 640, + Height: 480, Axes: chart.Style{ Show: true, StrokeWidth: 1.0, diff --git a/util.go b/util.go index f2936b9..bda14b6 100644 --- a/util.go +++ b/util.go @@ -3,7 +3,6 @@ package chart import ( "fmt" "image/color" - "math" "time" ) @@ -58,10 +57,10 @@ func MinAndMaxOfTime(values ...time.Time) (min time.Time, max time.Time) { // Slices generates N slices that span the total. // The resulting array will be intermediate indexes until total. -func Slices(count, total int) []int { - var values []int - sliceWidth := int(math.Floor(float64(total) / float64(count))) - for cursor := 0; cursor < total; cursor += sliceWidth { +func Slices(count int, total float64) []float64 { + var values []float64 + sliceWidth := float64(total) / float64(count) + for cursor := 0.0; cursor < total; cursor += sliceWidth { values = append(values, cursor) } return values