diff --git a/chart.go b/chart.go index d8a5a19..8afaef1 100644 --- a/chart.go +++ b/chart.go @@ -17,10 +17,9 @@ type Chart struct { Height int DPI float64 - Background Style - Canvas Style - Axes Style - FinalValueLabel Style + Background Style + Canvas Style + Axes Style XRange Range YRange Range @@ -53,21 +52,20 @@ 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 { +func (c *Chart) Render(rp RendererProvider, w io.Writer) error { if len(c.Series) == 0 { return errors.New("Please provide at least one series") } - r, err := provider(c.Width, c.Height) + r, err := rp(c.Width, c.Height) if err != nil { return err } - if c.hasText() { - font, err := c.GetFont() - if err != nil { - return err - } - r.SetFont(font) + + font, err := c.GetFont() + if err != nil { + return err } + r.SetFont(font) r.SetDPI(c.GetDPI(DefaultDPI)) canvasBox := c.calculateCanvasBox(r) @@ -77,16 +75,12 @@ func (c *Chart) Render(provider RendererProvider, w io.Writer) error { c.drawCanvas(r, canvasBox) c.drawAxes(r, canvasBox, xrange, yrange) for index, series := range c.Series { - c.drawSeries(r, canvasBox, index, series, xrange, yrange) + c.drawSeries(r, canvasBox, series, xrange, yrange) } c.drawTitle(r) return r.Save(w) } -func (c Chart) hasText() bool { - return c.TitleStyle.Show || c.Axes.Show || c.FinalValueLabel.Show -} - func (c Chart) getAxisWidth() int { asw := 0 if c.Axes.Show { @@ -119,21 +113,19 @@ func (c Chart) calculateCanvasBox(r Renderer) Box { } func (c Chart) calculateFinalLabelWidth(r Renderer) int { - if !c.FinalValueLabel.Show { - return 0 - } - var finalLabelText string for _, s := range c.Series { - _, lv := s.GetValue(s.Len() - 1) - var ll string - if c.YRange.Formatter != nil { - ll = c.YRange.Formatter(lv) - } else { - ll = s.GetYFormatter()(lv) - } - if len(finalLabelText) < len(ll) { - finalLabelText = ll + if vs, isValueProvider := s.(ValueProvider); isValueProvider { + _, lv := vs.GetValue(vs.Len() - 1) + var ll string + if c.YRange.Formatter != nil { + ll = c.YRange.Formatter(lv) + } else if fp, isFormatterProvider := s.(FormatterProvider); isFormatterProvider { + ll = fp.GetYFormatter()(lv) + } + if len(finalLabelText) < len(ll) { + finalLabelText = ll + } } } @@ -145,7 +137,7 @@ func (c Chart) calculateFinalLabelWidth(r Renderer) int { pr := c.FinalValueLabel.Padding.GetRight(DefaultFinalLabelPadding.Right) lsw := int(c.FinalValueLabel.GetStrokeWidth(DefaultAxisLineWidth)) - return DefaultFinalLabelDeltaWidth + + return DefaultYAxisMargin + pl + pr + textWidth + asw + 2*lsw } @@ -163,33 +155,37 @@ func (c Chart) initRanges(canvasBox Box) (xrange Range, yrange Range) { var globalMinY, globalMinX float64 var globalMaxY, globalMaxX float64 for _, s := range c.Series { - seriesLength := s.Len() - for index := 0; index < seriesLength; index++ { - vx, vy := s.GetValue(index) - if didSetFirstValues { - if globalMinX > vx { - globalMinX = vx + if vp, isValueProvider := s.(ValueProvider); isValueProvider { + seriesLength := vp.Len() + for index := 0; index < seriesLength; index++ { + vx, vy := vp.GetValue(index) + if didSetFirstValues { + if globalMinX > vx { + globalMinX = vx + } + if globalMinY > vy { + globalMinY = vy + } + if globalMaxX < vx { + globalMaxX = vx + } + if globalMaxY < vy { + globalMaxY = vy + } + } else { + globalMinX, globalMaxX = vx, vx + globalMinY, globalMaxY = vy, vy + didSetFirstValues = true } - if globalMinY > vy { - globalMinY = vy - } - if globalMaxX < vx { - globalMaxX = vx - } - if globalMaxY < vy { - globalMaxY = vy - } - } else { - globalMinX, globalMaxX = vx, vx - globalMinY, globalMaxY = vy, vy - didSetFirstValues = true } } - if xrange.Formatter == nil { - xrange.Formatter = s.GetXFormatter() - } - if yrange.Formatter == nil { - yrange.Formatter = s.GetYFormatter() + if fp, isFormatterProvider := s.(FormatterProvider); isFormatterProvider { + if xrange.Formatter == nil { + xrange.Formatter = fp.GetXFormatter() + } + if yrange.Formatter == nil { + yrange.Formatter = fp.GetYFormatter() + } } } @@ -281,7 +277,7 @@ func (c Chart) generateRangeTicks(r Range, tickCount int, offset float64) []Tick func (c Chart) drawYAxisLabels(r Renderer, canvasBox Box, yrange Range) { tickFontSize := c.Axes.GetFontSize(DefaultAxisFontSize) asw := c.getAxisWidth() - tx := canvasBox.Right + DefaultFinalLabelDeltaWidth + asw + tx := canvasBox.Right + DefaultYAxisMargin + asw r.SetFontColor(c.Axes.GetFontColor(DefaultAxisColor)) r.SetFontSize(tickFontSize) @@ -332,111 +328,8 @@ func (c Chart) drawXAxisLabels(r Renderer, canvasBox Box, xrange Range) { } } -func (c Chart) drawSeries(r Renderer, canvasBox Box, index int, s Series, xrange, yrange Range) { - if s.Len() == 0 { - return - } - - cx := canvasBox.Left - cy := canvasBox.Top - cb := canvasBox.Bottom - cw := canvasBox.Width - - v0x, v0y := s.GetValue(0) - x0 := cw - xrange.Translate(v0x) - y0 := yrange.Translate(v0y) - - var vx, vy float64 - var x, y int - - fill := s.GetStyle().GetFillColor() - if !fill.IsZero() { - r.SetFillColor(fill) - r.MoveTo(x0+cx, y0+cy) - for i := 1; i < s.Len(); i++ { - vx, vy = s.GetValue(i) - x = cw - xrange.Translate(vx) - y = yrange.Translate(vy) - r.LineTo(x+cx, y+cy) - } - r.LineTo(x+cx, cb) - r.LineTo(x0+cx, cb) - r.Close() - r.Fill() - } - - stroke := s.GetStyle().GetStrokeColor(GetDefaultSeriesStrokeColor(index)) - r.SetStrokeColor(stroke) - r.SetStrokeWidth(s.GetStyle().GetStrokeWidth(DefaultStrokeWidth)) - - r.MoveTo(x0+cx, y0+cy) - for i := 1; i < s.Len(); i++ { - vx, vy = s.GetValue(i) - x = cw - xrange.Translate(vx) - y = yrange.Translate(vy) - r.LineTo(x+cx, y+cy) - } - r.Stroke() - - c.drawFinalValueLabel(r, canvasBox, index, s, yrange) -} - -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 := yrange.Format(lv) - - py := canvasBox.Top - ly := yrange.Translate(lv) + py - - r.SetFontSize(c.FinalValueLabel.GetFontSize(DefaultFinalLabelFontSize)) - textWidth, _ := r.MeasureText(ll) - textHeight := int(math.Floor(DefaultFinalLabelFontSize)) - halfTextHeight := textHeight >> 1 - - 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) - pr := c.FinalValueLabel.Padding.GetRight(DefaultFinalLabelPadding.Right) - pb := c.FinalValueLabel.Padding.GetBottom(DefaultFinalLabelPadding.Bottom) - - textX := cx + pl + DefaultFinalLabelDeltaWidth - textY := ly + halfTextHeight - - ltlx := cx + pl + DefaultFinalLabelDeltaWidth - ltly := ly - (pt + halfTextHeight) - - ltrx := cx + pl + pr + textWidth - ltry := ly - (pt + halfTextHeight) - - lbrx := cx + pl + pr + textWidth - lbry := ly + (pb + halfTextHeight) - - lblx := cx + DefaultFinalLabelDeltaWidth - lbly := ly + (pb + halfTextHeight) - - //draw the shape... - r.SetFillColor(c.FinalValueLabel.GetFillColor(DefaultFinalLabelBackgroundColor)) - r.SetStrokeColor(c.FinalValueLabel.GetStrokeColor(s.GetStyle().GetStrokeColor(GetDefaultSeriesStrokeColor(index)))) - r.SetStrokeWidth(c.FinalValueLabel.GetStrokeWidth(DefaultAxisLineWidth)) - r.MoveTo(cx, ly) - r.LineTo(ltlx, ltly) - r.LineTo(ltrx, ltry) - r.LineTo(lbrx, lbry) - r.LineTo(lblx, lbly) - r.LineTo(cx, ly) - r.Close() - r.FillStroke() - - r.SetFontColor(c.FinalValueLabel.GetFontColor(DefaultTextColor)) - r.Text(ll, textX, textY) - } +func (c Chart) drawSeries(r Renderer, canvasBox Box, s Series, xrange, yrange Range) { + return s.Render(&c, r, canvasBox, xrange, yrange) } func (c Chart) drawTitle(r Renderer) error { diff --git a/continuous_series.go b/continuous_series.go new file mode 100644 index 0000000..f64ffb8 --- /dev/null +++ b/continuous_series.go @@ -0,0 +1,70 @@ +package chart + +import ( + "fmt" + + "github.com/blendlabs/go-util" +) + +// ContinuousSeries represents a line on a chart. +type ContinuousSeries struct { + Name string + Style Style + FinalValueLabel Style + + XValues []float64 + YValues []float64 +} + +// GetName returns the name of the time series. +func (cs ContinuousSeries) GetName() string { + return cs.Name +} + +// GetStyle returns the line style. +func (cs ContinuousSeries) GetStyle() Style { + return cs.Style +} + +// Len returns the number of elements in the series. +func (cs ContinuousSeries) Len() int { + return len(cs.XValues) +} + +// GetValue gets a value at a given index. +func (cs ContinuousSeries) GetValue(index int) (float64, float64) { + return cs.XValues[index], cs.YValues[index] +} + +// GetXFormatter returns the xs value formatter. +func (cs ContinuousSeries) 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 ContinuousSeries) GetYFormatter() Formatter { + return cs.GetXFormatter() +} + +// Render renders the series. +func (cs ContinuousSeries) Render(c *Chart, r Renderer, canvasBox Box, xrange, yrange Range) error { + DrawLineSeries(c, r, canvasBox, xrange, yrange, cs) + + if cs.FinalValueLabel.Show { + asw := 0 + if c.Axes.Show { + asw = int(c.Axes.GetStrokeWidth(DefaultAxisLineWidth)) + } + + _, lv := cs.GetValue(cs.Len() - 1) + ll := yrange.Format(lv) + lx := canvasBox.Right + asw + ly := yrange.Translate(lv) + canvasBox.Top + DrawAnnotation(c, r, canvasBox, xrange, yrange, cs.FinalValueLabel, lx, ly, ll) + } +} diff --git a/defaults.go b/defaults.go index 0910ddf..b51df5e 100644 --- a/defaults.go +++ b/defaults.go @@ -24,14 +24,16 @@ const ( DefaultFontSize = 10.0 // DefaultTitleFontSize is the default title font size. DefaultTitleFontSize = 18.0 - // DefaultFinalLabelDeltaWidth is the width of the left triangle out of the final label. - DefaultFinalLabelDeltaWidth = 10 - // DefaultFinalLabelFontSize is the font size of the final label. - DefaultFinalLabelFontSize = 10.0 + // DefaultAnnotationDeltaWidth is the width of the left triangle out of annotations. + DefaultAnnotationDeltaWidth = 10 + // DefaultAnnotationFontSize is the font size of annotations. + DefaultAnnotationFontSize = 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 + // DefaultYAxisMargin is the default distance from the right of the canvas to the y axis labels. + DefaultYAxisMargin = 5 // 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. @@ -69,8 +71,8 @@ var ( // DefaultFillColor is the default fill color. // It is equivalent to #0074d9. DefaultFillColor = drawing.Color{R: 0, G: 217, B: 116, A: 255} - // DefaultFinalLabelBackgroundColor is the default final label background color. - DefaultFinalLabelBackgroundColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} + // DefaultAnnotationFillColor is the default annotation background color. + DefaultAnnotationFillColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} ) var ( @@ -90,8 +92,8 @@ func GetDefaultSeriesStrokeColor(index int) drawing.Color { } var ( - // DefaultFinalLabelPadding is the padding around the final label. - DefaultFinalLabelPadding = Box{Top: 5, Left: 0, Right: 7, Bottom: 5} + // DefaultAnnotationPadding is the padding around an annotation. + DefaultAnnotationPadding = Box{Top: 5, Left: 0, Right: 7, Bottom: 5} // DefaultBackgroundPadding is the default canvas padding config. DefaultBackgroundPadding = Box{Top: 5, Left: 5, Right: 5, Bottom: 5} ) diff --git a/series.go b/series.go index 2851993..0b4d04c 100644 --- a/series.go +++ b/series.go @@ -1,121 +1,117 @@ package chart -import ( - "fmt" - "time" +import "math" - "github.com/blendlabs/go-util" -) - -// Series is a entity data set. +// Series is a entity data set. It constitutes an item to draw on the chart. +// The series interface is the bare minimum you need to implement to draw something on a chart. type Series interface { GetName() string GetStyle() Style + Render(c *Chart, r Renderer, canvasBox Box, xrange, yrange Range) error +} + +// ValueProvider is a series that is a set of values. +type ValueProvider interface { Len() int - GetValue(index int) (float64, float64) +} +// FormatterProvider is a series that has custom formatters. +type FormatterProvider interface { GetXFormatter() Formatter GetYFormatter() Formatter } -// TimeSeries is a line on a chart. -type TimeSeries struct { - Name string - Style Style - - XValues []time.Time - YValues []float64 -} - -// GetName returns the name of the time series. -func (ts TimeSeries) GetName() string { - return ts.Name -} - -// GetStyle returns the line style. -func (ts TimeSeries) GetStyle() Style { - return ts.Style -} - -// Len returns the number of elements in the series. -func (ts TimeSeries) Len() int { - return len(ts.XValues) -} - -// GetValue gets a value at a given index. -func (ts TimeSeries) GetValue(index int) (x float64, y float64) { - x = float64(ts.XValues[index].Unix()) - y = 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 +// DrawLineSeries draws a line series with a renderer. +func DrawLineSeries(c *Chart, r Renderer, canvasBox Box, xrange, yrange Range, vs ValueProvider) error { + if vs.Len() == 0 { + return } -} -// 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) + cx := canvasBox.Left + cy := canvasBox.Top + cb := canvasBox.Bottom + cw := canvasBox.Width + + v0x, v0y := vs.GetValue(0) + x0 := cw - xrange.Translate(v0x) + y0 := yrange.Translate(v0y) + + var vx, vy float64 + var x, y int + + fill := s.GetStyle().GetFillColor() + if !fill.IsZero() { + r.SetFillColor(fill) + r.MoveTo(x0+cx, y0+cy) + for i := 1; i < vs.Len(); i++ { + vx, vy = vs.GetValue(i) + x = cw - xrange.Translate(vx) + y = yrange.Translate(vy) + r.LineTo(x+cx, y+cy) } - return util.StringEmpty + r.LineTo(x+cx, cb) + r.LineTo(x0+cx, cb) + r.Close() + r.Fill() } -} -// ContinuousSeries represents a line on a chart. -type ContinuousSeries struct { - Name string - Style Style + stroke := s.GetStyle().GetStrokeColor(GetDefaultSeriesStrokeColor(index)) + r.SetStrokeColor(stroke) + r.SetStrokeWidth(s.GetStyle().GetStrokeWidth(DefaultStrokeWidth)) - XValues []float64 - YValues []float64 -} - -// GetName returns the name of the time series. -func (cs ContinuousSeries) GetName() string { - return cs.Name -} - -// GetStyle returns the line style. -func (cs ContinuousSeries) GetStyle() Style { - return cs.Style -} - -// Len returns the number of elements in the series. -func (cs ContinuousSeries) Len() int { - return len(cs.XValues) -} - -// GetValue gets a value at a given index. -func (cs ContinuousSeries) GetValue(index int) (float64, float64) { - return cs.XValues[index], cs.YValues[index] -} - -// GetXFormatter returns the xs value formatter. -func (cs ContinuousSeries) GetXFormatter() Formatter { - return func(v interface{}) string { - if typed, isTyped := v.(float64); isTyped { - return fmt.Sprintf("%0.2f", typed) - } - return util.StringEmpty + r.MoveTo(x0+cx, y0+cy) + for i := 1; i < vs.Len(); i++ { + vx, vy = vs.GetValue(i) + x = cw - xrange.Translate(vx) + y = yrange.Translate(vy) + r.LineTo(x+cx, y+cy) } + r.Stroke() } -// GetYFormatter returns the y value formatter. -func (cs ContinuousSeries) GetYFormatter() Formatter { - return cs.GetXFormatter() +// DrawAnnotation draws an anotation with a renderer. +func DrawAnnotation(c *Chart, r Renderer, canvasBox Box, xrange, yrange, s Style, lx, ly int, lv string) { + py := canvasBox.Top + + r.SetFontSize(s.GetFontSize(DefaultFinalLabelFontSize)) + textWidth, _ := r.MeasureText(ll) + textHeight := int(math.Floor(DefaultFinalLabelFontSize)) + halfTextHeight := textHeight >> 1 + + pt := s.Padding.GetTop(DefaultFinalLabelPadding.Top) + pl := s.Padding.GetLeft(DefaultFinalLabelPadding.Left) + pr := s.Padding.GetRight(DefaultFinalLabelPadding.Right) + pb := s.Padding.GetBottom(DefaultFinalLabelPadding.Bottom) + + textX := lx + pl + DefaultFinalLabelDeltaWidth + textY := ly + halfTextHeight + + ltlx := lx + pl + DefaultFinalLabelDeltaWidth + ltly := ly - (pt + halfTextHeight) + + ltrx := lx + pl + pr + textWidth + ltry := ly - (pt + halfTextHeight) + + lbrx := lx + pl + pr + textWidth + lbry := ly + (pb + halfTextHeight) + + lblx := lx + DefaultFinalLabelDeltaWidth + lbly := ly + (pb + halfTextHeight) + + //draw the shape... + r.SetFillColor(s.GetFillColor(DefaultAnnotationFillColor)) + r.SetStrokeColor(s.GetStrokeColor()) + r.SetStrokeWidth(s.GetStrokeWidth()) + r.MoveTo(lx, ly) + r.LineTo(ltlx, ltly) + r.LineTo(ltrx, ltry) + r.LineTo(lbrx, lbry) + r.LineTo(lblx, lbly) + r.LineTo(cx, ly) + r.Close() + r.FillStroke() + + r.SetFontColor(s.GetFontColor(DefaultTextColor)) + r.Text(ll, textX, textY) } diff --git a/time_series.go b/time_series.go new file mode 100644 index 0000000..fd02b29 --- /dev/null +++ b/time_series.go @@ -0,0 +1,66 @@ +package chart + +import ( + "fmt" + "time" + + "github.com/blendlabs/go-util" +) + +// TimeSeries is a line on a chart. +type TimeSeries struct { + Name string + Style Style + FinalValueLabel Style + + XValues []time.Time + YValues []float64 +} + +// GetName returns the name of the time series. +func (ts TimeSeries) GetName() string { + return ts.Name +} + +// GetStyle returns the line style. +func (ts TimeSeries) GetStyle() Style { + return ts.Style +} + +// Len returns the number of elements in the series. +func (ts TimeSeries) Len() int { + return len(ts.XValues) +} + +// GetValue gets a value at a given index. +func (ts TimeSeries) GetValue(index int) (x float64, y float64) { + x = float64(ts.XValues[index].Unix()) + y = 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 + } +} diff --git a/time_series_test.go b/time_series_test.go new file mode 100644 index 0000000..3194997 --- /dev/null +++ b/time_series_test.go @@ -0,0 +1,30 @@ +package chart + +import ( + "testing" + "time" + + "github.com/blendlabs/go-assert" +) + +func TestTimeSeriesGetValue(t *testing.T) { + assert := assert.New(t) + + ts := TimeSeries{ + Name: "Test", + XValues: []time.Time{ + time.Now().AddDate(0, 0, -5), + time.Now().AddDate(0, 0, -4), + time.Now().AddDate(0, 0, -3), + time.Now().AddDate(0, 0, -2), + time.Now().AddDate(0, 0, -1), + }, + YValues: []float64{ + 1.0, 2.0, 3.0, 4.0, 5.0, + }, + } + + x0, y0 := ts.GetValue(0) + assert.NotZero(x0) + assert.Equal(1.0, y0) +}