diff --git a/_examples/css_classes/main.go b/_examples/css_classes/main.go index b1dda8c..6cff1e4 100644 --- a/_examples/css_classes/main.go +++ b/_examples/css_classes/main.go @@ -18,7 +18,8 @@ func inlineSVGWithClasses(res http.ResponseWriter, req *http.Request) { "")) pie := chart.PieChart{ - // Note that setting ClassName will cause all other inline styles to be dropped! + // Notes: * Setting ClassName will cause all other inline styles to be dropped! + // * The following type classes may be added additionally: stroke, fill, text Background: chart.Style{ClassName: "background"}, Canvas: chart.Style{ ClassName: "canvas", @@ -43,12 +44,12 @@ func css(res http.ResponseWriter, req *http.Request) { res.Header().Set("Content-Type", "text/css") res.Write([]byte("svg .background { fill: white; }" + "svg .canvas { fill: white; }" + - "svg path.blue { fill: blue; stroke: lightblue; }" + - "svg path.green { fill: green; stroke: lightgreen; }" + - "svg path.gray { fill: gray; stroke: lightgray; }" + - "svg text.blue { fill: white; }" + - "svg text.green { fill: white; }" + - "svg text.gray { fill: white; }")) + "svg .blue.fill.stroke { fill: blue; stroke: lightblue; }" + + "svg .green.fill.stroke { fill: green; stroke: lightgreen; }" + + "svg .gray.fill.stroke { fill: gray; stroke: lightgray; }" + + "svg .blue.text { fill: white; }" + + "svg .green.text { fill: white; }" + + "svg .gray.text { fill: white; }")) } func main() { diff --git a/_examples/custom_stylesheets/main.go b/_examples/custom_stylesheets/main.go index 35a11ce..e5138cb 100644 --- a/_examples/custom_stylesheets/main.go +++ b/_examples/custom_stylesheets/main.go @@ -5,17 +5,17 @@ import ( "log" "net/http" - chart "github.com/wcharczuk/go-chart" + "github.com/wcharczuk/go-chart" ) const style = "svg .background { fill: white; }" + "svg .canvas { fill: white; }" + - "svg path.blue { fill: blue; stroke: lightblue; }" + - "svg path.green { fill: green; stroke: lightgreen; }" + - "svg path.gray { fill: gray; stroke: lightgray; }" + - "svg text.blue { fill: white; }" + - "svg text.green { fill: white; }" + - "svg text.gray { fill: white; }" + "svg .blue.fill.stroke { fill: blue; stroke: lightblue; }" + + "svg .green.fill.stroke { fill: green; stroke: lightgreen; }" + + "svg .gray.fill.stroke { fill: gray; stroke: lightgray; }" + + "svg .blue.text { fill: white; }" + + "svg .green.text { fill: white; }" + + "svg .gray.text { fill: white; }" func svgWithCustomInlineCSS(res http.ResponseWriter, _ *http.Request) { res.Header().Set("Content-Type", chart.ContentTypeSVG) diff --git a/_examples/donut_chart/main.go b/_examples/donut_chart/main.go new file mode 100644 index 0000000..010dbec --- /dev/null +++ b/_examples/donut_chart/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + pie := chart.DonutChart{ + Width: 512, + Height: 512, + Values: []chart.Value{ + {Value: 5, Label: "Blue"}, + {Value: 5, Label: "Green"}, + {Value: 4, Label: "Gray"}, + {Value: 4, Label: "Orange"}, + {Value: 3, Label: "Deep Blue"}, + {Value: 3, Label: "test"}, + }, + } + + 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 drawChartRegression(res http.ResponseWriter, req *http.Request) { + pie := chart.DonutChart{ + Width: 512, + Height: 512, + Values: []chart.Value{ + {Value: 5, Label: "Blue"}, + {Value: 2, Label: "Two"}, + {Value: 1, Label: "One"}, + }, + } + + res.Header().Set("Content-Type", chart.ContentTypeSVG) + err := pie.Render(chart.SVG, res) + if err != nil { + fmt.Printf("Error rendering pie chart: %v\n", err) + } +} + +func main() { + http.HandleFunc("/", drawChart) + http.HandleFunc("/reg", drawChartRegression) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/_examples/donut_chart/output.png b/_examples/donut_chart/output.png new file mode 100644 index 0000000..e682501 Binary files /dev/null and b/_examples/donut_chart/output.png differ diff --git a/_examples/donut_chart/reg.svg b/_examples/donut_chart/reg.svg new file mode 100644 index 0000000..f14c2af --- /dev/null +++ b/_examples/donut_chart/reg.svg @@ -0,0 +1,25 @@ +\nBlueTwoOne \ No newline at end of file diff --git a/donut_chart.go b/donut_chart.go new file mode 100644 index 0000000..f5a7854 --- /dev/null +++ b/donut_chart.go @@ -0,0 +1,315 @@ +package chart + +import ( + "errors" + "fmt" + "io" + + "github.com/golang/freetype/truetype" +) + +// DonutChart is a chart that draws sections of a circle based on percentages with an hole. +type DonutChart struct { + Title string + TitleStyle Style + + ColorPalette ColorPalette + + Width int + Height int + DPI float64 + + Background Style + Canvas Style + SliceStyle Style + + Font *truetype.Font + defaultFont *truetype.Font + + Values []Value + Elements []Renderable +} + +// GetDPI returns the dpi for the chart. +func (pc DonutChart) 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 DonutChart) GetFont() *truetype.Font { + if pc.Font == nil { + return pc.defaultFont + } + return pc.Font +} + +// GetWidth returns the chart width or the default value. +func (pc DonutChart) GetWidth() int { + if pc.Width == 0 { + return DefaultChartWidth + } + return pc.Width +} + +// GetHeight returns the chart height or the default value. +func (pc DonutChart) 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 DonutChart) 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) + + finalValues, err := pc.finalizeValues(pc.Values) + if err != nil { + return err + } + pc.drawSlices(r, canvasBox, finalValues) + pc.drawTitle(r) + for _, a := range pc.Elements { + a(r, canvasBox, pc.styleDefaultsElements()) + } + + return r.Save(w) +} + +func (pc DonutChart) drawBackground(r Renderer) { + Draw.Box(r, Box{ + Right: pc.GetWidth(), + Bottom: pc.GetHeight(), + }, pc.getBackgroundStyle()) +} + +func (pc DonutChart) drawCanvas(r Renderer, canvasBox Box) { + Draw.Box(r, canvasBox, pc.getCanvasStyle()) +} + +func (pc DonutChart) drawTitle(r Renderer) { + if len(pc.Title) > 0 && !pc.TitleStyle.Hidden { + Draw.TextWithin(r, pc.Title, pc.Box(), pc.styleDefaultsTitle()) + } +} + +func (pc DonutChart) drawSlices(r Renderer, canvasBox Box, values []Value) { + cx, cy := canvasBox.Center() + diameter := MinInt(canvasBox.Width(), canvasBox.Height()) + radius := float64(diameter>>1) / 1.1 + labelRadius := (radius * 2.83) / 3.0 + + // draw the donut slices + var rads, delta, delta2, total float64 + var lx, ly int + + if len(values) == 1 { + pc.styleDonutChartValue(0).WriteToRenderer(r) + r.MoveTo(cx, cy) + r.Circle(radius, cx, cy) + } else { + for index, v := range values { + v.Style.InheritFrom(pc.styleDonutChartValue(index)).WriteToRenderer(r) + r.MoveTo(cx, cy) + rads = PercentToRadians(total) + delta = PercentToRadians(v.Value) + + r.ArcTo(cx, cy, (radius / 1.25), (radius / 1.25), rads, delta) + + r.LineTo(cx, cy) + r.Close() + r.FillStroke() + total = total + v.Value + } + } + + //making the donut hole + v := Value{Value: 100, Label: "center"} + styletemp := pc.SliceStyle.InheritFrom(Style{ + StrokeColor: ColorWhite, StrokeWidth: 4.0, FillColor: ColorWhite, FontColor: ColorWhite, //Font: pc.GetFont(),//FontSize: pc.getScaledFontSize(), + }) + v.Style.InheritFrom(styletemp).WriteToRenderer(r) + r.MoveTo(cx, cy) + r.ArcTo(cx, cy, (radius / 3.5), (radius / 3.5), DegreesToRadians(0), DegreesToRadians(359)) + r.LineTo(cx, cy) + r.Close() + r.FillStroke() + + // draw the labels + total = 0 + for index, v := range values { + v.Style.InheritFrom(pc.styleDonutChartValue(index)).WriteToRenderer(r) + if len(v.Label) > 0 { + delta2 = PercentToRadians(total + (v.Value / 2.0)) + delta2 = RadianAdd(delta2, _pi2) + lx, ly = CirclePoint(cx, cy, labelRadius, delta2) + + tb := r.MeasureText(v.Label) + lx = lx - (tb.Width() >> 1) + ly = ly + (tb.Height() >> 1) + + r.Text(v.Label, lx, ly) + } + total = total + v.Value + } +} + +func (pc DonutChart) finalizeValues(values []Value) ([]Value, error) { + finalValues := Values(values).Normalize() + if len(finalValues) == 0 { + return nil, fmt.Errorf("donut chart must contain at least (1) non-zero value") + } + return finalValues, nil +} + +func (pc DonutChart) getDefaultCanvasBox() Box { + return pc.Box() +} + +func (pc DonutChart) getCircleAdjustedCanvasBox(canvasBox Box) Box { + circleDiameter := MinInt(canvasBox.Width(), canvasBox.Height()) + + square := Box{ + Right: circleDiameter, + Bottom: circleDiameter, + } + + return canvasBox.Fit(square) +} + +func (pc DonutChart) getBackgroundStyle() Style { + return pc.Background.InheritFrom(pc.styleDefaultsBackground()) +} + +func (pc DonutChart) getCanvasStyle() Style { + return pc.Canvas.InheritFrom(pc.styleDefaultsCanvas()) +} + +func (pc DonutChart) styleDefaultsCanvas() Style { + return Style{ + FillColor: pc.GetColorPalette().CanvasColor(), + StrokeColor: pc.GetColorPalette().CanvasStrokeColor(), + StrokeWidth: DefaultStrokeWidth, + } +} + +func (pc DonutChart) styleDefaultsDonutChartValue() Style { + return Style{ + StrokeColor: pc.GetColorPalette().TextColor(), + StrokeWidth: 4.0, + FillColor: pc.GetColorPalette().TextColor(), + } +} + +func (pc DonutChart) styleDonutChartValue(index int) Style { + return pc.SliceStyle.InheritFrom(Style{ + StrokeColor: ColorWhite, + StrokeWidth: 4.0, + FillColor: pc.GetColorPalette().GetSeriesColor(index), + FontSize: pc.getScaledFontSize(), + FontColor: pc.GetColorPalette().TextColor(), + Font: pc.GetFont(), + }) +} + +func (pc DonutChart) getScaledFontSize() float64 { + effectiveDimension := MinInt(pc.GetWidth(), pc.GetHeight()) + if effectiveDimension >= 2048 { + return 48.0 + } else if effectiveDimension >= 1024 { + return 24.0 + } else if effectiveDimension > 512 { + return 18.0 + } else if effectiveDimension > 256 { + return 12.0 + } + return 10.0 +} + +func (pc DonutChart) styleDefaultsBackground() Style { + return Style{ + FillColor: pc.GetColorPalette().BackgroundColor(), + StrokeColor: pc.GetColorPalette().BackgroundStrokeColor(), + StrokeWidth: DefaultStrokeWidth, + } +} + +func (pc DonutChart) styleDefaultsElements() Style { + return Style{ + Font: pc.GetFont(), + } +} + +func (pc DonutChart) styleDefaultsTitle() Style { + return pc.TitleStyle.InheritFrom(Style{ + FontColor: pc.GetColorPalette().TextColor(), + Font: pc.GetFont(), + FontSize: pc.getTitleFontSize(), + TextHorizontalAlign: TextHorizontalAlignCenter, + TextVerticalAlign: TextVerticalAlignTop, + TextWrap: TextWrapWord, + }) +} + +func (pc DonutChart) getTitleFontSize() float64 { + effectiveDimension := MinInt(pc.GetWidth(), pc.GetHeight()) + if effectiveDimension >= 2048 { + return 48 + } else if effectiveDimension >= 1024 { + return 24 + } else if effectiveDimension >= 512 { + return 18 + } else if effectiveDimension >= 256 { + return 12 + } + return 10 +} + +// GetColorPalette returns the color palette for the chart. +func (pc DonutChart) GetColorPalette() ColorPalette { + if pc.ColorPalette != nil { + return pc.ColorPalette + } + return AlternateColorPalette +} + +// Box returns the chart bounds as a box. +func (pc DonutChart) 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/donut_chart_test.go b/donut_chart_test.go new file mode 100644 index 0000000..db84560 --- /dev/null +++ b/donut_chart_test.go @@ -0,0 +1,69 @@ +package chart + +import ( + "bytes" + "testing" + + "github.com/blend/go-sdk/assert" +) + +func TestDonutChart(t *testing.T) { + assert := assert.New(t) + + pie := DonutChart{ + Canvas: Style{ + FillColor: ColorLightGray, + }, + Values: []Value{ + {Value: 10, Label: "Blue"}, + {Value: 9, Label: "Green"}, + {Value: 8, Label: "Gray"}, + {Value: 7, Label: "Orange"}, + {Value: 6, Label: "HEANG"}, + {Value: 5, Label: "??"}, + {Value: 2, Label: "!!"}, + }, + } + + b := bytes.NewBuffer([]byte{}) + pie.Render(PNG, b) + assert.NotZero(b.Len()) +} + +func TestDonutChartDropsZeroValues(t *testing.T) { + assert := assert.New(t) + + pie := DonutChart{ + Canvas: Style{ + FillColor: ColorLightGray, + }, + Values: []Value{ + {Value: 5, Label: "Blue"}, + {Value: 5, Label: "Green"}, + {Value: 0, Label: "Gray"}, + }, + } + + b := bytes.NewBuffer([]byte{}) + err := pie.Render(PNG, b) + assert.Nil(err) +} + +func TestDonutChartAllZeroValues(t *testing.T) { + assert := assert.New(t) + + pie := DonutChart{ + Canvas: Style{ + FillColor: ColorLightGray, + }, + Values: []Value{ + {Value: 0, Label: "Blue"}, + {Value: 0, Label: "Green"}, + {Value: 0, Label: "Gray"}, + }, + } + + b := bytes.NewBuffer([]byte{}) + err := pie.Render(PNG, b) + assert.NotNil(err) +} diff --git a/vector_renderer.go b/vector_renderer.go index d70fcc5..c9ba458 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -310,15 +310,28 @@ func (c *canvas) getFontFace(s Style) string { // styleAsSVG returns the style as a svg style or class string. func (c *canvas) styleAsSVG(s Style) string { - if s.ClassName != "" { - return fmt.Sprintf("class=\"%s\"", s.ClassName) - } sw := s.StrokeWidth sc := s.StrokeColor fc := s.FillColor fs := s.FontSize fnc := s.FontColor + if s.ClassName != "" { + var classes []string + classes = append(classes, s.ClassName) + if !sc.IsZero() { + classes = append(classes, "stroke") + } + if !fc.IsZero() { + classes = append(classes, "fill") + } + if fs != 0 || s.Font != nil { + classes = append(classes, "text") + } + + return fmt.Sprintf("class=\"%s\"", strings.Join(classes, " ")) + } + var pieces []string if sw != 0 {