From c17c9a4bb4c9653e929369abb85c564a024c8ec5 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 02:34:44 -0700 Subject: [PATCH] pie charts! --- defaults.go | 122 +++++++++---- drawing/path.go | 8 +- drawing/stack_graphic_context.go | 4 +- examples/pie_chart/main.go | 35 ++++ pie_chart.go | 284 +++++++++++++++++++++++++++++++ raster_renderer.go | 10 ++ renderer.go | 8 + style.go | 7 +- util.go | 43 +++++ vector_renderer.go | 9 + 10 files changed, 485 insertions(+), 45 deletions(-) create mode 100644 examples/pie_chart/main.go create mode 100644 pie_chart.go diff --git a/defaults.go b/defaults.go index 88dbd03..770958a 100644 --- a/defaults.go +++ b/defaults.go @@ -66,42 +66,85 @@ const ( ) var ( - // DefaultBackgroundColor is the default chart background color. - // It is equivalent to css color:white. - DefaultBackgroundColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultBackgroundStrokeColor is the default chart border color. - // It is equivalent to color:white. - DefaultBackgroundStrokeColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultCanvasColor is the default chart canvas color. - // It is equivalent to css color:white. - DefaultCanvasColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultCanvasStrokeColor is the default chart canvas stroke color. - // It is equivalent to css color:white. - DefaultCanvasStrokeColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultTextColor is the default chart text color. - // It is equivalent to #333333. - DefaultTextColor = drawing.Color{R: 51, G: 51, B: 51, A: 255} - // DefaultAxisColor is the default chart axis line color. - // It is equivalent to #333333. - DefaultAxisColor = drawing.Color{R: 51, G: 51, B: 51, A: 255} - // DefaultStrokeColor is the default chart border color. - // It is equivalent to #efefef. - DefaultStrokeColor = drawing.Color{R: 239, G: 239, B: 239, A: 255} - // DefaultFillColor is the default fill color. - // It is equivalent to #0074d9. - DefaultFillColor = drawing.Color{R: 0, G: 217, B: 116, A: 255} - // DefaultAnnotationFillColor is the default annotation background color. - DefaultAnnotationFillColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultGridLineColor is the default grid line color. - DefaultGridLineColor = drawing.Color{R: 239, G: 239, B: 239, A: 255} + // ColorWhite is white. + ColorWhite = drawing.Color{R: 255, G: 255, B: 255, A: 255} + // ColorBlue is the basic theme blue color. + ColorBlue = drawing.Color{R: 0, G: 116, B: 217, A: 255} + // ColorCyan is the basic theme cyan color. + ColorCyan = drawing.Color{R: 0, G: 217, B: 210, A: 255} + // ColorGreen is the basic theme green color. + ColorGreen = drawing.Color{R: 0, G: 217, B: 101, A: 255} + // ColorRed is the basic theme red color. + ColorRed = drawing.Color{R: 217, G: 0, B: 116, A: 255} + // ColorOrange is the basic theme orange color. + ColorOrange = drawing.Color{R: 217, G: 101, B: 0, A: 255} + // ColorYellow is the basic theme yellow color. + ColorYellow = drawing.Color{R: 217, G: 210, B: 0, A: 255} + // ColorBlack is the basic theme black color. + ColorBlack = drawing.Color{R: 51, G: 51, B: 51, A: 255} + // ColorLightGray is the basic theme light gray color. + ColorLightGray = drawing.Color{R: 239, G: 239, B: 239, A: 255} + + // ColorAlternateBlue is a alternate theme color. + ColorAlternateBlue = drawing.Color{R: 106, G: 195, B: 203, A: 255} + // ColorAlternateGreen is a alternate theme color. + ColorAlternateGreen = drawing.Color{R: 42, G: 190, B: 137, A: 255} + // ColorAlternateGray is a alternate theme color. + ColorAlternateGray = drawing.Color{R: 110, G: 128, B: 139, A: 255} + // ColorAlternateYellow is a alternate theme color. + ColorAlternateYellow = drawing.Color{R: 240, G: 174, B: 90, A: 255} + // ColorAlternateLightGray is a alternate theme color. + ColorAlternateLightGray = drawing.Color{R: 187, G: 190, B: 191, A: 255} ) var ( - // DefaultSeriesStrokeColors are a couple default series colors. - DefaultSeriesStrokeColors = []drawing.Color{ - {R: 0, G: 116, B: 217, A: 255}, - {R: 0, G: 217, B: 116, A: 255}, - {R: 217, G: 0, B: 116, A: 255}, + // DefaultBackgroundColor is the default chart background color. + // It is equivalent to css color:white. + DefaultBackgroundColor = ColorWhite + // DefaultBackgroundStrokeColor is the default chart border color. + // It is equivalent to color:white. + DefaultBackgroundStrokeColor = ColorWhite + // DefaultCanvasColor is the default chart canvas color. + // It is equivalent to css color:white. + DefaultCanvasColor = ColorWhite + // DefaultCanvasStrokeColor is the default chart canvas stroke color. + // It is equivalent to css color:white. + DefaultCanvasStrokeColor = ColorWhite + // DefaultTextColor is the default chart text color. + // It is equivalent to #333333. + DefaultTextColor = ColorBlack + // DefaultAxisColor is the default chart axis line color. + // It is equivalent to #333333. + DefaultAxisColor = ColorBlack + // DefaultStrokeColor is the default chart border color. + // It is equivalent to #efefef. + DefaultStrokeColor = ColorLightGray + // DefaultFillColor is the default fill color. + // It is equivalent to #0074d9. + DefaultFillColor = ColorBlue + // DefaultAnnotationFillColor is the default annotation background color. + DefaultAnnotationFillColor = ColorWhite + // DefaultGridLineColor is the default grid line color. + DefaultGridLineColor = ColorLightGray +) + +var ( + // DefaultColors are a couple default series colors. + DefaultColors = []drawing.Color{ + ColorBlue, + ColorGreen, + ColorRed, + ColorCyan, + ColorOrange, + } + + // DefaultAlternateColors are a couple alternate colors. + DefaultAlternateColors = []drawing.Color{ + ColorAlternateBlue, + ColorAlternateGreen, + ColorAlternateGray, + ColorAlternateYellow, + ColorAlternateLightGray, } ) @@ -117,10 +160,17 @@ var ( ) // GetDefaultSeriesStrokeColor returns a color from the default list by index. -// NOTE: the index will wrap around (using a modulo).g +// NOTE: the index will wrap around (using a modulo). func GetDefaultSeriesStrokeColor(index int) drawing.Color { - finalIndex := index % len(DefaultSeriesStrokeColors) - return DefaultSeriesStrokeColors[finalIndex] + finalIndex := index % len(DefaultColors) + return DefaultColors[finalIndex] +} + +// GetDefaultPieChartValueColor returns a color from the default list by index. +// NOTE: the index will wrap around (using a modulo). +func GetDefaultPieChartValueColor(index int) drawing.Color { + finalIndex := index % len(DefaultAlternateColors) + return DefaultAlternateColors[finalIndex] } var ( diff --git a/drawing/path.go b/drawing/path.go index 979a0d5..20f2d2e 100644 --- a/drawing/path.go +++ b/drawing/path.go @@ -101,10 +101,10 @@ func (p *Path) CubicCurveTo(cx1, cy1, cx2, cy2, x, y float64) { } // ArcTo adds an arc to the path -func (p *Path) ArcTo(cx, cy, rx, ry, startAngle, angle float64) { - endAngle := startAngle + angle +func (p *Path) ArcTo(cx, cy, rx, ry, startAngle, delta float64) { + endAngle := startAngle + delta clockWise := true - if angle < 0 { + if delta < 0 { clockWise = false } // normalize @@ -124,7 +124,7 @@ func (p *Path) ArcTo(cx, cy, rx, ry, startAngle, angle float64) { } else { p.MoveTo(startX, startY) } - p.appendToPath(ArcToComponent, cx, cy, rx, ry, startAngle, angle) + p.appendToPath(ArcToComponent, cx, cy, rx, ry, startAngle, delta) p.x = cx + math.Cos(endAngle)*rx p.y = cy + math.Sin(endAngle)*ry } diff --git a/drawing/stack_graphic_context.go b/drawing/stack_graphic_context.go index d353438..c3243c9 100644 --- a/drawing/stack_graphic_context.go +++ b/drawing/stack_graphic_context.go @@ -171,8 +171,8 @@ func (gc *StackGraphicContext) CubicCurveTo(cx1, cy1, cx2, cy2, x, y float64) { } // ArcTo draws an arc. -func (gc *StackGraphicContext) ArcTo(cx, cy, rx, ry, startAngle, angle float64) { - gc.current.Path.ArcTo(cx, cy, rx, ry, startAngle, angle) +func (gc *StackGraphicContext) ArcTo(cx, cy, rx, ry, startAngle, delta float64) { + gc.current.Path.ArcTo(cx, cy, rx, ry, startAngle, delta) } // Close closes a path. diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go new file mode 100644 index 0000000..323734c --- /dev/null +++ b/examples/pie_chart/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + pie := chart.PieChart{ + Canvas: chart.Style{ + FillColor: chart.ColorLightGray, + }, + Values: []chart.PieChartValue{ + {Value: 0.3, Label: "Blue"}, + {Value: 0.2, Label: "Green"}, + {Value: 0.2, Label: "Gray"}, + {Value: 0.1, Label: "Orange"}, + {Value: 0.1, Label: "??"}, + }, + } + + res.Header().Set("Content-Type", "image/png") + err := pie.Render(chart.PNG, res) + if err != nil { + fmt.Printf("Error rendering pie chart: %v\n", err) + } +} + +func main() { + http.HandleFunc("/", drawChart) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/pie_chart.go b/pie_chart.go new file mode 100644 index 0000000..f84f95c --- /dev/null +++ b/pie_chart.go @@ -0,0 +1,284 @@ +package chart + +import ( + "errors" + "io" + "math" + + "github.com/golang/freetype/truetype" +) + +// PieChartValue is a slice of a pie-chart. +type PieChartValue struct { + Style Style + Label string + Value float64 +} + +// PieChart is a chart that draws sections of a circle based on percentages. +type PieChart struct { + Title string + TitleStyle Style + + Width int + Height int + DPI float64 + + Background Style + Canvas Style + + Font *truetype.Font + defaultFont *truetype.Font + + Values []PieChartValue + Elements []Renderable +} + +// GetDPI returns the dpi for the chart. +func (pc PieChart) GetDPI(defaults ...float64) float64 { + if pc.DPI == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return DefaultDPI + } + return pc.DPI +} + +// GetFont returns the text font. +func (pc PieChart) GetFont() *truetype.Font { + if pc.Font == nil { + return pc.defaultFont + } + return pc.Font +} + +// GetWidth returns the chart width or the default value. +func (pc PieChart) GetWidth() int { + if pc.Width == 0 { + return DefaultChartWidth + } + return pc.Width +} + +// GetHeight returns the chart height or the default value. +func (pc PieChart) GetHeight() int { + if pc.Height == 0 { + return DefaultChartWidth + } + return pc.Height +} + +// Render renders the chart with the given renderer to the given io.Writer. +func (pc PieChart) Render(rp RendererProvider, w io.Writer) error { + if len(pc.Values) == 0 { + return errors.New("Please provide at least one value.") + } + + r, err := rp(pc.GetWidth(), pc.GetHeight()) + if err != nil { + return err + } + + if pc.Font == nil { + defaultFont, err := GetDefaultFont() + if err != nil { + return err + } + pc.defaultFont = defaultFont + } + r.SetDPI(pc.GetDPI(DefaultDPI)) + + canvasBox := pc.getDefaultCanvasBox() + canvasBox = pc.getCircleAdjustedCanvasBox(canvasBox) + + pc.drawBackground(r) + pc.drawCanvas(r, canvasBox) + + valuesWithPlaceholder, err := pc.finalizeValues(pc.Values) + if err != nil { + return err + } + pc.drawSlices(r, canvasBox, valuesWithPlaceholder) + pc.drawTitle(r) + for _, a := range pc.Elements { + a(r, canvasBox, pc.styleDefaultsElements()) + } + + return r.Save(w) +} + +func (pc PieChart) drawBackground(r Renderer) { + DrawBox(r, Box{ + Right: pc.GetWidth(), + Bottom: pc.GetHeight(), + }, pc.getBackgroundStyle()) +} + +func (pc PieChart) drawCanvas(r Renderer, canvasBox Box) { + DrawBox(r, canvasBox, pc.getCanvasStyle()) +} + +func (pc PieChart) drawTitle(r Renderer) { + if len(pc.Title) > 0 && pc.TitleStyle.Show { + r.SetFont(pc.TitleStyle.GetFont(pc.GetFont())) + r.SetFontColor(pc.TitleStyle.GetFontColor(DefaultTextColor)) + titleFontSize := pc.TitleStyle.GetFontSize(DefaultTitleFontSize) + r.SetFontSize(titleFontSize) + + textBox := r.MeasureText(pc.Title) + + textWidth := textBox.Width() + textHeight := textBox.Height() + + titleX := (pc.GetWidth() >> 1) - (textWidth >> 1) + titleY := pc.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight + + r.Text(pc.Title, titleX, titleY) + } +} + +func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []PieChartValue) { + cx, cy := canvasBox.Center() + diameter := MinInt(canvasBox.Width(), canvasBox.Height()) + radius := float64(diameter >> 1) + radius2 := (radius * 2.0) / 3.0 + + var rads, delta, delta2, total float64 + var lx, ly int + for index, v := range values { + v.Style.InheritFrom(pc.stylePieChartValue(index)).PersistToRenderer(r) + r.MoveTo(cx, cy) + + rads = PercentToRadians(total) + delta = PercentToRadians(v.Value) + + r.ArcTo(cx, cy, radius, radius, rads, delta) + + r.LineTo(cx, cy) + r.Close() + r.FillStroke() + total = total + v.Value + } + + total = 0 + for index, v := range values { + v.Style.InheritFrom(pc.stylePieChartValue(index)).PersistToRenderer(r) + if len(v.Label) > 0 { + delta2 = RadianAdd(PercentToRadians(total+(v.Value/2.0)), _pi2) + lx = cx + int(radius2*math.Sin(delta2)) + ly = cy - int(radius2*math.Cos(delta2)) + + tb := r.MeasureText(v.Label) + lx = lx - (tb.Width() >> 1) + + r.Text(v.Label, lx, ly) + } + total = total + v.Value + } +} + +func (pc PieChart) finalizeValues(values []PieChartValue) ([]PieChartValue, error) { + var total float64 + for _, v := range values { + total += v.Value + if total > 1.0 { + return nil, errors.New("Values total exceeded 1.0; please normalize pie chart values to [0,1.0)") + } + } + remainder := 1.0 - total + if RoundDown(remainder, 0.0001) > 0 { + return append(values, PieChartValue{ + Style: pc.styleDefaultsPieChartValue(), + Value: remainder, + }), nil + } + return values, nil +} + +func (pc PieChart) getDefaultCanvasBox() Box { + return pc.Box() +} + +func (pc PieChart) getCircleAdjustedCanvasBox(canvasBox Box) Box { + circleDiameter := MinInt(canvasBox.Width(), canvasBox.Height()) + + square := Box{ + Right: circleDiameter, + Bottom: circleDiameter, + } + + return canvasBox.Fit(square) +} + +func (pc PieChart) getBackgroundStyle() Style { + return pc.Background.InheritFrom(pc.styleDefaultsBackground()) +} + +func (pc PieChart) getCanvasStyle() Style { + return pc.Canvas.InheritFrom(pc.styleDefaultsCanvas()) +} + +func (pc PieChart) styleDefaultsCanvas() Style { + return Style{ + FillColor: DefaultCanvasColor, + StrokeColor: DefaultCanvasStrokeColor, + StrokeWidth: DefaultStrokeWidth, + } +} + +func (pc PieChart) styleDefaultsPieChartValue() Style { + return Style{ + StrokeColor: ColorWhite, + StrokeWidth: 5.0, + FillColor: ColorWhite, + } +} + +func (pc PieChart) stylePieChartValue(index int) Style { + return Style{ + StrokeColor: ColorWhite, + StrokeWidth: 5.0, + FillColor: GetDefaultPieChartValueColor(index), + FontSize: 24.0, + FontColor: ColorWhite, + Font: pc.GetFont(), + } +} + +func (pc PieChart) styleDefaultsBackground() Style { + return Style{ + FillColor: DefaultBackgroundColor, + StrokeColor: DefaultBackgroundStrokeColor, + StrokeWidth: DefaultStrokeWidth, + } +} + +func (pc PieChart) styleDefaultsSeries(seriesIndex int) Style { + strokeColor := GetDefaultSeriesStrokeColor(seriesIndex) + return Style{ + StrokeColor: strokeColor, + StrokeWidth: DefaultStrokeWidth, + Font: pc.GetFont(), + FontSize: DefaultFontSize, + } +} + +func (pc PieChart) styleDefaultsElements() Style { + return Style{ + Font: pc.GetFont(), + } +} + +// Box returns the chart bounds as a box. +func (pc PieChart) Box() Box { + dpr := pc.Background.Padding.GetRight(DefaultBackgroundPadding.Right) + dpb := pc.Background.Padding.GetBottom(DefaultBackgroundPadding.Bottom) + + return Box{ + Top: pc.Background.Padding.GetTop(DefaultBackgroundPadding.Top), + Left: pc.Background.Padding.GetLeft(DefaultBackgroundPadding.Left), + Right: pc.GetWidth() - dpr, + Bottom: pc.GetHeight() - dpb, + } +} diff --git a/raster_renderer.go b/raster_renderer.go index d2ba356..347bb64 100644 --- a/raster_renderer.go +++ b/raster_renderer.go @@ -71,6 +71,16 @@ func (rr *rasterRenderer) LineTo(x, y int) { rr.gc.LineTo(float64(x), float64(y)) } +// QuadCurveTo implements the interface method. +func (rr *rasterRenderer) QuadCurveTo(cx, cy, x, y int) { + rr.gc.QuadCurveTo(float64(cx), float64(cy), float64(x), float64(y)) +} + +// ArcTo implements the interface method. +func (rr *rasterRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) { + rr.gc.ArcTo(float64(cx), float64(cy), rx, ry, startAngle, delta) +} + // Close implements the interface method. func (rr *rasterRenderer) Close() { rr.gc.Close() diff --git a/renderer.go b/renderer.go index 6308dda..9d4a9ea 100644 --- a/renderer.go +++ b/renderer.go @@ -34,6 +34,14 @@ type Renderer interface { // from the previous point. LineTo(x, y int) + // QuadCurveTo draws a quad curve. + // cx and cy represent the bezier "control points". + QuadCurveTo(cx, cy, x, y int) + + // ArcTo draws an arc with a given center (cx,cy) + // a given set of radii (rx,ry), a startAngle and delta (in radians). + ArcTo(cx, cy int, rx, ry, startAngle, delta float64) + // Close finalizes a shape as drawn by LineTo. Close() diff --git a/style.go b/style.go index 51ac94d..6742554 100644 --- a/style.go +++ b/style.go @@ -79,11 +79,11 @@ func (s Style) String() string { if s.FontSize != 0 { output = append(output, fmt.Sprintf("\"font_size\": \"%0.2fpt\"", s.FontSize)) } else { - output = append(output, "\"fill_color\": null") + output = append(output, "\"font_size\": null") } - if !s.FillColor.IsZero() { - output = append(output, fmt.Sprintf("\"font_color\": %s", s.FillColor.String())) + if !s.FontColor.IsZero() { + output = append(output, fmt.Sprintf("\"font_color\": %s", s.FontColor.String())) } else { output = append(output, "\"font_color\": null") } @@ -190,6 +190,7 @@ func (s Style) PersistToRenderer(r Renderer) { r.SetStrokeColor(s.GetStrokeColor()) r.SetStrokeWidth(s.GetStrokeWidth()) r.SetStrokeDashArray(s.GetStrokeDashArray()) + r.SetFillColor(s.GetFillColor()) r.SetFont(s.GetFont()) r.SetFontColor(s.GetFontColor()) r.SetFontSize(s.GetFontSize()) diff --git a/util.go b/util.go index 70d093c..8b1fcf2 100644 --- a/util.go +++ b/util.go @@ -99,6 +99,21 @@ func RoundDown(value, roundTo float64) float64 { return d1 * roundTo } +// Normalize returns a set of numbers on the interval [0,1] for a given set of inputs. +// An example: 4,3,2,1 => 0.4, 0.3, 0.2, 0.1 +// Caveat; the total may be < 1.0; there are going to be issues with irrational numbers etc. +func Normalize(values ...float64) []float64 { + var total float64 + for _, v := range values { + total += v + } + output := make([]float64, len(values)) + for x, v := range values { + output[x] = RoundDown(v/total, 0.001) + } + return output +} + // MinInt returns the minimum of a set of integers. func MinInt(values ...int) int { min := math.MaxInt32 @@ -175,3 +190,31 @@ func SeqDays(days int) []time.Time { func PercentDifference(v1, v2 float64) float64 { return (v2 - v1) / v1 } + +// DegreesToRadians returns degrees as radians. +func DegreesToRadians(degrees float64) float64 { + return degrees * (math.Pi / 180.0) +} + +const ( + _2pi = 2 * math.Pi + _3pi4 = (3 * math.Pi) / 4.0 + _pi2 = math.Pi / 2.0 + _pi4 = math.Pi / 4.0 +) + +// PercentToRadians converts a normalized value (0,1) to radians. +func PercentToRadians(pct float64) float64 { + return DegreesToRadians(360.0 * pct) +} + +// RadianAdd adds a delta to a base in radians. +func RadianAdd(base, delta float64) float64 { + value := base + delta + if value > _2pi { + return math.Mod(value, _2pi) + } else if value < 0 { + return _2pi + value + } + return value +} diff --git a/vector_renderer.go b/vector_renderer.go index 606c7e2..35e8c5e 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -76,6 +76,15 @@ func (vr *vectorRenderer) LineTo(x, y int) { vr.p = append(vr.p, fmt.Sprintf("L %d %d", x, y)) } +// QuadCurveTo draws a quad curve. +func (vr *vectorRenderer) QuadCurveTo(cx, cy, x, y int) { + vr.p = append(vr.p, fmt.Sprintf("Q%d,%d %d,%d", cx, cy, x, y)) +} + +func (vr *vectorRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) { + vr.p = append(vr.p, fmt.Sprintf("A %d %d %0.2f 1 1 %d %d", int(rx), int(ry), delta, cx, cy)) +} + // Close closes a shape. func (vr *vectorRenderer) Close() { vr.p = append(vr.p, fmt.Sprintf("Z"))