diff --git a/chart.go b/chart.go index f2d72bf..07a6d31 100644 --- a/chart.go +++ b/chart.go @@ -2,6 +2,7 @@ package chart import ( "io" + "math" "github.com/golang/freetype/truetype" ) @@ -11,9 +12,8 @@ type Chart struct { Title string TitleStyle Style - Width int - Height int - Padding Box + Width int + Height int Background Style Canvas Style @@ -29,32 +29,32 @@ type Chart struct { // GetCanvasTop gets the top corner pixel. func (c Chart) GetCanvasTop() int { - return c.Padding.GetTop(DefaultCanvasPadding.Top) + return c.Canvas.Padding.GetTop(DefaultCanvasPadding.Top) } // GetCanvasLeft gets the left corner pixel. func (c Chart) GetCanvasLeft() int { - return c.Padding.GetLeft(DefaultCanvasPadding.Left) + return c.Canvas.Padding.GetLeft(DefaultCanvasPadding.Left) } // GetCanvasBottom gets the bottom corner pixel. func (c Chart) GetCanvasBottom() int { - return c.Height - c.Padding.GetBottom(DefaultCanvasPadding.Bottom) + return c.Height - c.Canvas.Padding.GetBottom(DefaultCanvasPadding.Bottom) } // GetCanvasRight gets the right corner pixel. func (c Chart) GetCanvasRight() int { - return c.Width - c.Padding.GetRight(DefaultCanvasPadding.Right) + return c.Width - c.Canvas.Padding.GetRight(DefaultCanvasPadding.Right) } // GetCanvasWidth returns the width of the canvas. func (c Chart) GetCanvasWidth() int { - return c.Width - (c.Padding.GetLeft(DefaultCanvasPadding.Left) + c.Padding.GetRight(DefaultCanvasPadding.Right)) + return c.Width - (c.Canvas.Padding.GetLeft(DefaultCanvasPadding.Left) + c.Canvas.Padding.GetRight(DefaultCanvasPadding.Right)) } // GetCanvasHeight returns the height of the canvas. func (c Chart) GetCanvasHeight() int { - return c.Height - (c.Padding.GetTop(DefaultCanvasPadding.Top) + c.Padding.GetBottom(DefaultCanvasPadding.Bottom)) + return c.Height - (c.Canvas.Padding.GetTop(DefaultCanvasPadding.Top) + c.Canvas.Padding.GetBottom(DefaultCanvasPadding.Bottom)) } // GetFont returns the text font. @@ -68,21 +68,21 @@ 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 { xrange, yrange := c.initRanges() - println("xrange", xrange.String()) - println("yrange", yrange.String()) - r := provider(c.Width, c.Height) - c.drawBackground(r) - c.drawCanvas(r) - c.drawAxes(r) - - for _, series := range c.Series { - c.drawSeries(r, series, xrange, yrange) - } - err := c.drawTitle(r) + font, err := c.GetFont() if err != nil { return err } + + r := provider(c.Width, c.Height) + r.SetFont(font) + c.drawBackground(r) + c.drawCanvas(r) + c.drawAxes(r, xrange, yrange) + for _, series := range c.Series { + c.drawSeries(r, series, xrange, yrange) + } + c.drawTitle(r) return r.Save(w) } @@ -159,7 +159,7 @@ func (c Chart) drawCanvas(r Renderer) { r.Close() } -func (c Chart) drawAxes(r Renderer) { +func (c Chart) drawAxes(r Renderer, xrange, yrange Range) { if c.Axes.Show { r.SetStrokeColor(c.Axes.GetStrokeColor(DefaultAxisColor)) r.SetLineWidth(c.Axes.GetStrokeWidth(DefaultLineWidth)) @@ -167,9 +167,15 @@ func (c Chart) drawAxes(r Renderer) { r.LineTo(c.GetCanvasRight(), c.GetCanvasBottom()) r.LineTo(c.GetCanvasRight(), c.GetCanvasTop()) r.Stroke() + + c.drawAxesLabels(r, xrange, yrange) } } +func (c Chart) drawAxesLabels(r Renderer, xrange, yrange Range) { + +} + func (c Chart) drawSeries(r Renderer, s Series, xrange, yrange Range) { r.SetStrokeColor(s.GetStyle().GetStrokeColor(DefaultLineColor)) r.SetLineWidth(s.GetStyle().GetStrokeWidth(DefaultLineWidth)) @@ -178,8 +184,8 @@ func (c Chart) drawSeries(r Renderer, s Series, xrange, yrange Range) { return } - px := c.Padding.GetLeft(DefaultCanvasPadding.Left) - py := c.Padding.GetTop(DefaultCanvasPadding.Top) + px := c.Canvas.Padding.GetLeft(DefaultCanvasPadding.Left) + py := c.Canvas.Padding.GetTop(DefaultCanvasPadding.Top) cw := c.GetCanvasWidth() @@ -197,16 +203,67 @@ func (c Chart) drawSeries(r Renderer, s Series, xrange, yrange Range) { r.LineTo(x+px, y+py) } r.Stroke() + + c.drawFinalValueLabel(r, s, yrange) +} + +func (c Chart) drawFinalValueLabel(r Renderer, s Series, yrange Range) { + if c.FinalValueLabel.Show { + _, lv := s.GetValue(s.Len() - 1) + _, ll := s.GetLabel(s.Len() - 1) + + py := c.Canvas.Padding.GetTop(DefaultCanvasPadding.Top) + ly := yrange.Translate(lv) + py + + r.SetFontSize(c.FinalValueLabel.GetFontSize(DefaultFinalLabelFontSize)) + + textWidth := r.MeasureText(ll) + textHeight := int(math.Floor(DefaultFinalLabelFontSize)) + halfTextHeight := textHeight >> 1 + + cx := c.GetCanvasRight() + int(c.Axes.GetStrokeWidth(DefaultAxisLineWidth)) + + 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(DefaultLineColor))) + r.SetLineWidth(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) drawTitle(r Renderer) error { if len(c.Title) > 0 && c.TitleStyle.Show { - font, err := c.GetFont() - if err != nil { - return err - } r.SetFontColor(c.Canvas.GetFontColor(DefaultTextColor)) - r.SetFont(font) titleFontSize := c.Canvas.GetFontSize(DefaultTitleFontSize) r.SetFontSize(titleFontSize) textWidth := r.MeasureText(c.Title) diff --git a/defaults.go b/defaults.go index 7b5f7f0..8958443 100644 --- a/defaults.go +++ b/defaults.go @@ -28,6 +28,18 @@ const ( DefaultDateFormat = "2006-01-02" ) +const ( + // 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 +) + +var ( + // DefaultFinalLabelPadding is the padding around the final label. + DefaultFinalLabelPadding = Box{Top: 5, Left: 0, Right: 5, Bottom: 5} +) + var ( // DefaultBackgroundColor is the default chart background color. // It is equivalent to css color:white. @@ -41,15 +53,17 @@ var ( // DefaultAxisColor is the default chart axis line color. // It is equivalent to #333333. DefaultAxisColor = color.RGBA{R: 51, G: 51, B: 51, A: 255} - // DefaultBorderColor is the default chart border color. + // DefaultStrokeColor is the default chart border color. // It is equivalent to #efefef. - DefaultBorderColor = color.RGBA{R: 239, G: 239, B: 239, A: 255} + DefaultStrokeColor = color.RGBA{R: 239, G: 239, B: 239, A: 255} // DefaultLineColor is the default (1st) series line color. // It is equivalent to #0074d9. DefaultLineColor = color.RGBA{R: 0, G: 116, B: 217, A: 255} // DefaultFillColor is the default fill color. // It is equivalent to #0074d9. DefaultFillColor = color.RGBA{R: 0, G: 217, B: 116, A: 255} + // DefaultFinalLabelBackgroundColor is the default final label background color. + DefaultFinalLabelBackgroundColor = color.RGBA{R: 255, G: 255, B: 255, A: 255} ) var ( diff --git a/raster_renderer.go b/raster_renderer.go index bfc7b7d..152b4d0 100644 --- a/raster_renderer.go +++ b/raster_renderer.go @@ -1,7 +1,6 @@ package chart import ( - "fmt" "image" "image/color" "image/png" @@ -35,55 +34,46 @@ type rasterRenderer struct { // SetStrokeColor implements the interface method. func (rr *rasterRenderer) SetStrokeColor(c color.RGBA) { - println("RasterRenderer :: SetStrokeColor", ColorAsString(c)) rr.gc.SetStrokeColor(c) } // SetFillColor implements the interface method. func (rr *rasterRenderer) SetFillColor(c color.RGBA) { - println("RasterRenderer :: SetFillColor", ColorAsString(c)) rr.gc.SetFillColor(c) } // SetLineWidth implements the interface method. func (rr *rasterRenderer) SetLineWidth(width float64) { - println("RasterRenderer :: SetLineWidth", width) rr.gc.SetLineWidth(width) } // MoveTo implements the interface method. func (rr *rasterRenderer) MoveTo(x, y int) { - println("RasterRenderer :: MoveTo", x, y) rr.gc.MoveTo(float64(x), float64(y)) } // LineTo implements the interface method. func (rr *rasterRenderer) LineTo(x, y int) { - println("RasterRenderer :: LineTo", x, y) rr.gc.LineTo(float64(x), float64(y)) } // Close implements the interface method. func (rr *rasterRenderer) Close() { - println("RasterRenderer :: Close") rr.gc.Close() } // Stroke implements the interface method. func (rr *rasterRenderer) Stroke() { - println("RasterRenderer :: Stroke") rr.gc.Stroke() } // Fill implements the interface method. func (rr *rasterRenderer) Fill() { - println("RasterRenderer :: Fill") rr.gc.Fill() } // FillStroke implements the interface method. func (rr *rasterRenderer) FillStroke() { - println("RasterRenderer :: FillStroke") rr.gc.FillStroke() } @@ -108,14 +98,12 @@ func (rr *rasterRenderer) SetFont(f *truetype.Font) { // SetFontSize implements the interface method. func (rr *rasterRenderer) SetFontSize(size float64) { - println("RasterRenderer :: SetFontSize", fmt.Sprintf("%.2f", size)) rr.fontSize = size rr.gc.SetFontSize(size) } // SetFontColor implements the interface method. func (rr *rasterRenderer) SetFontColor(c color.RGBA) { - println("RasterRenderer :: SetFontColor", ColorAsString(c)) rr.fontColor = c rr.gc.SetFillColor(c) rr.gc.SetStrokeColor(c) @@ -123,7 +111,6 @@ func (rr *rasterRenderer) SetFontColor(c color.RGBA) { // Text implements the interface method. func (rr *rasterRenderer) Text(body string, x, y int) { - println("RasterRenderer :: Text", body, x, y) rr.gc.CreateStringPath(body, float64(x), float64(y)) rr.gc.Fill() } @@ -147,6 +134,6 @@ func (rr *rasterRenderer) MeasureText(body string) int { // Save implements the interface method. func (rr *rasterRenderer) Save(w io.Writer) error { - println("RasterRenderer :: Save") + return png.Encode(w, rr.i) } diff --git a/style.go b/style.go index 8a49fd4..30a238d 100644 --- a/style.go +++ b/style.go @@ -10,6 +10,7 @@ type Style struct { StrokeWidth float64 FontSize float64 FontColor color.RGBA + Padding Box } // IsZero returns if the object is set or not. diff --git a/testserver/main.go b/testserver/main.go index d5ac454..ae24d05 100644 --- a/testserver/main.go +++ b/testserver/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "image/color" "log" "time" @@ -17,15 +18,17 @@ func main() { rc.Response.Header().Set("Content-Type", "image/png") now := time.Now() c := chart.Chart{ - Title: "Hello!", + Title: "Stocks Bruh.", TitleStyle: chart.Style{ Show: true, }, Width: 1024, Height: 400, - Padding: chart.Box{ - Right: 40, - Bottom: 40, + Canvas: chart.Style{ + Padding: chart.Box{ + Right: 40, + Bottom: 40, + }, }, Axes: chart.Style{ Show: true, @@ -35,6 +38,9 @@ func main() { Min: 0.0, Max: 7.0, }, + FinalValueLabel: chart.Style{ + Show: true, + }, Series: []chart.Series{ chart.TimeSeries{ Name: "goog", @@ -44,6 +50,15 @@ func main() { XValues: []time.Time{now.AddDate(0, 0, -4), now.AddDate(0, 0, -3), now.AddDate(0, 0, -2), now.AddDate(0, 0, -1)}, YValues: []float64{2.5, 5.0, 2.0, 3.0}, }, + chart.TimeSeries{ + Name: "aapl", + Style: chart.Style{ + StrokeWidth: 1.0, + StrokeColor: color.RGBA{R: 0, G: 217, B: 116, A: 255}, + }, + XValues: []time.Time{now.AddDate(0, 0, -5), now.AddDate(0, 0, -4), now.AddDate(0, 0, -2), now.AddDate(0, 0, -1)}, + YValues: []float64{3.0, 2.7, 2.0, 1.1}, + }, }, } diff --git a/util.go b/util.go index 1b6a6bd..f2936b9 100644 --- a/util.go +++ b/util.go @@ -3,6 +3,7 @@ package chart import ( "fmt" "image/color" + "math" "time" ) @@ -54,3 +55,14 @@ func MinAndMaxOfTime(values ...time.Time) (min time.Time, max time.Time) { } return } + +// 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 { + values = append(values, cursor) + } + return values +} diff --git a/util_test.go b/util_test.go index c6ed4e7..6ae7abd 100644 --- a/util_test.go +++ b/util_test.go @@ -64,3 +64,14 @@ func TestMinAndMaxOfTimeEmpty(t *testing.T) { assert.Equal(time.Time{}, min) assert.Equal(time.Time{}, max) } + +func TestSlices(t *testing.T) { + assert := assert.New(t) + + s := Slices(10, 100) + assert.Len(s, 10) + assert.Equal(0, s[0]) + assert.Equal(10, s[1]) + assert.Equal(20, s[2]) + assert.Equal(90, s[9]) +}