diff --git a/_examples/scatter/main.go b/_examples/scatter/main.go new file mode 100644 index 0000000..6e94e6d --- /dev/null +++ b/_examples/scatter/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "log" + "net/http" + + "github.com/wcharczuk/go-chart" + "github.com/wcharczuk/go-chart/drawing" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + println("drawing scatter plot") + graph := chart.Chart{ + Series: []chart.Series{ + chart.ContinuousSeries{ + Style: chart.Style{ + Show: true, + StrokeWidth: chart.Disabled, + DotWidth: 3, + DotColor: drawing.ColorRed, + }, + XValues: chart.Sequence.Random(32, 1024), + YValues: chart.Sequence.Random(32, 1024), + }, + }, + } + + res.Header().Set("Content-Type", "image/png") + err := graph.Render(chart.PNG, res) + if err != nil { + log.Println(err.Error()) + } + +} + +func main() { + http.HandleFunc("/", drawChart) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/chart.go b/chart.go index 5c8ab4d..da11756 100644 --- a/chart.go +++ b/chart.go @@ -141,8 +141,10 @@ func (c Chart) Render(rp RendererProvider, w io.Writer) error { func (c Chart) checkHasVisibleSeries() error { hasVisibleSeries := false + var style Style for _, s := range c.Series { - hasVisibleSeries = hasVisibleSeries || (s.GetStyle().IsZero() || s.GetStyle().Show) + style = s.GetStyle() + hasVisibleSeries = hasVisibleSeries || (style.IsZero() || style.Show) } if !hasVisibleSeries { return fmt.Errorf("must have (1) visible series; make sure if you set a style, you set .Show = true") diff --git a/defaults.go b/defaults.go index 17e48cb..f4d773e 100644 --- a/defaults.go +++ b/defaults.go @@ -14,6 +14,8 @@ const ( DefaultChartWidth = 1024 // DefaultStrokeWidth is the default chart stroke width. DefaultStrokeWidth = 0.0 + // DefaultDotWidth is the default chart dot width. + DefaultDotWidth = 0.0 // DefaultSeriesLineWidth is the default line width. DefaultSeriesLineWidth = 1.0 // DefaultAxisLineWidth is the line width of the axis lines. diff --git a/draw.go b/draw.go index dfa05ba..be24733 100644 --- a/draw.go +++ b/draw.go @@ -27,9 +27,8 @@ func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, style var vx, vy float64 var x, y int - fill := style.GetFillColor() - if !fill.IsZero() { - style.GetFillOptions().WriteToRenderer(r) + if style.ShouldDrawStroke() && style.ShouldDrawFill() { + style.GetFillOptions().WriteDrawingOptionsToRenderer(r) r.MoveTo(x0, y0) for i := 1; i < vs.Len(); i++ { vx, vy = vs.GetValue(i) @@ -43,16 +42,32 @@ func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, style r.Fill() } - style.GetStrokeOptions().WriteToRenderer(r) + if style.ShouldDrawStroke() { + style.GetStrokeOptions().WriteDrawingOptionsToRenderer(r) - r.MoveTo(x0, y0) - for i := 1; i < vs.Len(); i++ { - vx, vy = vs.GetValue(i) - x = cl + xrange.Translate(vx) - y = cb - yrange.Translate(vy) - r.LineTo(x, y) + r.MoveTo(x0, y0) + for i := 1; i < vs.Len(); i++ { + vx, vy = vs.GetValue(i) + x = cl + xrange.Translate(vx) + y = cb - yrange.Translate(vy) + r.LineTo(x, y) + } + } + + if style.ShouldDrawDot() { + dotWidth := style.GetDotWidth() + + style.GetDotOptions().WriteDrawingOptionsToRenderer(r) + + for i := 0; i < vs.Len(); i++ { + vx, vy = vs.GetValue(i) + x = cl + xrange.Translate(vx) + y = cb - yrange.Translate(vy) + + r.Circle(dotWidth, x, y) + r.FillStroke() + } } - r.Stroke() } // BoundedSeries draws a series that implements BoundedValueProvider. diff --git a/drawing/curve.go b/drawing/curve.go index 304be1c..c33efcc 100644 --- a/drawing/curve.go +++ b/drawing/curve.go @@ -1,8 +1,6 @@ package drawing -import ( - "math" -) +import "math" const ( // CurveRecursionLimit represents the maximum recursion that is really necessary to subsivide a curve into straight lines @@ -98,31 +96,60 @@ func SubdivideQuad(c, c1, c2 []float64) { return } +func traceWindowIndices(i int) (startAt, endAt int) { + startAt = i * 6 + endAt = startAt + 6 + return +} + +func traceCalcDeltas(c []float64) (dx, dy, d float64) { + dx = c[4] - c[0] + dy = c[5] - c[1] + d = math.Abs(((c[2]-c[4])*dy - (c[3]-c[5])*dx)) + return +} + +func traceIsFlat(dx, dy, d, threshold float64) bool { + return (d * d) < threshold*(dx*dx+dy*dy) +} + +func traceGetWindow(curves []float64, i int) []float64 { + startAt, endAt := traceWindowIndices(i) + return curves[startAt:endAt] +} + // TraceQuad generate lines subdividing the curve using a Liner // flattening_threshold helps determines the flattening expectation of the curve func TraceQuad(t Liner, quad []float64, flatteningThreshold float64) { + const curveLen = CurveRecursionLimit * 6 + const curveEndIndex = curveLen - 1 + const lastIteration = CurveRecursionLimit - 1 + // Allocates curves stack - var curves [CurveRecursionLimit * 6]float64 + curves := make([]float64, curveLen) + + // copy 6 elements from the quad path to the stack copy(curves[0:6], quad[0:6]) - i := 0 - // current curve + + var i int var c []float64 var dx, dy, d float64 for i >= 0 { - c = curves[i*6:] - dx = c[4] - c[0] - dy = c[5] - c[1] + c = traceGetWindow(curves, i) + dx, dy, d = traceCalcDeltas(c) - d = math.Abs(((c[2]-c[4])*dy - (c[3]-c[5])*dx)) + // bail early if the distance is 0 + if d == 0 { + return + } // if it's flat then trace a line - if (d*d) < flatteningThreshold*(dx*dx+dy*dy) || i == len(curves)-1 { + if traceIsFlat(dx, dy, d, flatteningThreshold) || i == lastIteration { t.LineTo(c[4], c[5]) i-- } else { - // second half of bezier go lower onto the stack - SubdivideQuad(c, curves[(i+1)*6:], curves[i*6:]) + SubdivideQuad(c, traceGetWindow(curves, i+1), traceGetWindow(curves, i)) i++ } } diff --git a/drawing/curve_test.go b/drawing/curve_test.go new file mode 100644 index 0000000..5c22cc1 --- /dev/null +++ b/drawing/curve_test.go @@ -0,0 +1,35 @@ +package drawing + +import ( + "testing" + + assert "github.com/blendlabs/go-assert" +) + +type point struct { + X, Y float64 +} + +type mockLine struct { + inner []point +} + +func (ml *mockLine) LineTo(x, y float64) { + ml.inner = append(ml.inner, point{x, y}) +} + +func (ml mockLine) Len() int { + return len(ml.inner) +} + +func TestTraceQuad(t *testing.T) { + assert := assert.New(t) + + // Quad + // x1, y1, cpx1, cpy2, x2, y2 float64 + // do the 9->12 circle segment + quad := []float64{10, 20, 20, 20, 20, 10} + liner := &mockLine{} + TraceQuad(liner, quad, 0.5) + assert.NotZero(liner.Len()) +} diff --git a/drawing/flattener.go b/drawing/flattener.go index 61bfd07..7b34201 100644 --- a/drawing/flattener.go +++ b/drawing/flattener.go @@ -23,10 +23,10 @@ type Flattener interface { // Flatten convert curves into straight segments keeping join segments info func Flatten(path *Path, flattener Flattener, scale float64) { // First Point - var startX, startY float64 = 0, 0 + var startX, startY float64 // Current Point - var x, y float64 = 0, 0 - i := 0 + var x, y float64 + var i int for _, cmp := range path.Components { switch cmp { case MoveToComponent: @@ -43,6 +43,7 @@ func Flatten(path *Path, flattener Flattener, scale float64) { flattener.LineJoin() i += 2 case QuadCurveToComponent: + // we include the previous point for the start of the curve TraceQuad(flattener, path.Points[i-2:], 0.5) x, y = path.Points[i+2], path.Points[i+3] flattener.LineTo(x, y) diff --git a/raster_renderer.go b/raster_renderer.go index 326dcb5..99119d2 100644 --- a/raster_renderer.go +++ b/raster_renderer.go @@ -1,6 +1,7 @@ package chart import ( + "fmt" "image" "image/png" "io" @@ -116,17 +117,17 @@ func (rr *rasterRenderer) FillStroke() { rr.gc.FillStroke() } -// Circle implements the interface method. +// Circle fully draws and strokes a circle at a given point. func (rr *rasterRenderer) Circle(radius float64, x, y int) { + fmt.Printf("RasterRenderer.Circle(%f, %d, %d)\n", radius, x, y) xf := float64(x) yf := float64(y) - rr.gc.MoveTo(xf-radius, yf) //9 - rr.gc.QuadCurveTo(xf, yf, xf, yf-radius) //12 - rr.gc.QuadCurveTo(xf, yf, xf+radius, yf) //3 - rr.gc.QuadCurveTo(xf, yf, xf, yf+radius) //6 - rr.gc.QuadCurveTo(xf, yf, xf-radius, yf) //9 - rr.gc.Close() - rr.gc.FillStroke() + + rr.gc.MoveTo(xf-radius, yf) //9 + rr.gc.QuadCurveTo(xf-radius, yf-radius, xf, yf-radius) //12 + rr.gc.QuadCurveTo(xf+radius, yf-radius, xf+radius, yf) //3 + rr.gc.QuadCurveTo(xf+radius, yf+radius, xf, yf+radius) //6 + rr.gc.QuadCurveTo(xf-radius, yf+radius, xf-radius, yf) //9 } // SetFont implements the interface method. diff --git a/sequence.go b/sequence.go index cf8f55a..ab4125b 100644 --- a/sequence.go +++ b/sequence.go @@ -8,10 +8,14 @@ import ( var ( // Sequence contains some sequence utilities. // These utilities can be useful for generating test data. - Sequence = &sequence{} + Sequence = &sequence{ + rnd: rand.New(rand.NewSource(time.Now().Unix())), + } ) -type sequence struct{} +type sequence struct { + rnd *rand.Rand +} // Float64 produces an array of floats from [start,end] by optional steps. func (s sequence) Float64(start, end float64, steps ...float64) []float64 { @@ -35,11 +39,10 @@ func (s sequence) Float64(start, end float64, steps ...float64) []float64 { // Random generates a fixed length sequence of random values between (0, scale). func (s sequence) Random(samples int, scale float64) []float64 { - rnd := rand.New(rand.NewSource(time.Now().Unix())) values := make([]float64, samples) for x := 0; x < samples; x++ { - values[x] = rnd.Float64() * scale + values[x] = s.rnd.Float64() * scale } return values @@ -47,11 +50,10 @@ func (s sequence) Random(samples int, scale float64) []float64 { // Random generates a fixed length sequence of random values with a given average, above and below that average by (-scale, scale) func (s sequence) RandomWithAverage(samples int, average, scale float64) []float64 { - rnd := rand.New(rand.NewSource(time.Now().Unix())) values := make([]float64, samples) for x := 0; x < samples; x++ { - jitter := scale - (rnd.Float64() * (2 * scale)) + jitter := scale - (s.rnd.Float64() * (2 * scale)) values[x] = average + jitter } diff --git a/style.go b/style.go index d1db16b..abe5798 100644 --- a/style.go +++ b/style.go @@ -8,6 +8,12 @@ import ( "github.com/wcharczuk/go-chart/drawing" ) +const ( + // Disabled indicates if the value should be interpreted as set intentionally to zero. + // this is because golang optionals aren't here yet. + Disabled = -1 +) + // StyleShow is a prebuilt style with the `Show` property set to true. func StyleShow() Style { return Style{ @@ -24,7 +30,11 @@ type Style struct { StrokeColor drawing.Color StrokeDashArray []float64 + DotColor drawing.Color + DotWidth float64 + FillColor drawing.Color + FontSize float64 FontColor drawing.Color Font *truetype.Font @@ -38,7 +48,14 @@ type Style struct { // IsZero returns if the object is set or not. func (s Style) IsZero() bool { - return s.StrokeColor.IsZero() && s.FillColor.IsZero() && s.StrokeWidth == 0 && s.FontColor.IsZero() && s.FontSize == 0 && s.Font == nil + return s.StrokeColor.IsZero() && + s.StrokeWidth == 0 && + s.DotColor.IsZero() && + s.DotWidth == 0 && + s.FillColor.IsZero() && + s.FontColor.IsZero() && + s.FontSize == 0 && + s.Font == nil } // String returns a text representation of the style. @@ -83,6 +100,18 @@ func (s Style) String() string { output = append(output, "\"stroke_dash_array\": null") } + if s.DotWidth >= 0 { + output = append(output, fmt.Sprintf("\"dot_width\": %0.2f", s.DotWidth)) + } else { + output = append(output, "\"dot_width\": null") + } + + if !s.DotColor.IsZero() { + output = append(output, fmt.Sprintf("\"dot_color\": %s", s.DotColor.String())) + } else { + output = append(output, "\"dot_color\": null") + } + if !s.FillColor.IsZero() { output = append(output, fmt.Sprintf("\"fill_color\": %s", s.FillColor.String())) } else { @@ -132,6 +161,17 @@ func (s Style) GetFillColor(defaults ...drawing.Color) drawing.Color { return s.FillColor } +// GetDotColor returns the stroke color. +func (s Style) GetDotColor(defaults ...drawing.Color) drawing.Color { + if s.DotColor.IsZero() { + if len(defaults) > 0 { + return defaults[0] + } + return drawing.ColorTransparent + } + return s.DotColor +} + // GetStrokeWidth returns the stroke width. func (s Style) GetStrokeWidth(defaults ...float64) float64 { if s.StrokeWidth == 0 { @@ -143,6 +183,17 @@ func (s Style) GetStrokeWidth(defaults ...float64) float64 { return s.StrokeWidth } +// GetDotWidth returns the dot width for scatter plots. +func (s Style) GetDotWidth(defaults ...float64) float64 { + if s.DotWidth == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return DefaultDotWidth + } + return s.DotWidth +} + // GetStrokeDashArray returns the stroke dash array. func (s Style) GetStrokeDashArray(defaults ...[]float64) []float64 { if len(s.StrokeDashArray) == 0 { @@ -288,6 +339,10 @@ func (s Style) InheritFrom(defaults Style) (final Style) { final.StrokeColor = s.GetStrokeColor(defaults.StrokeColor) final.StrokeWidth = s.GetStrokeWidth(defaults.StrokeWidth) final.StrokeDashArray = s.GetStrokeDashArray(defaults.StrokeDashArray) + + final.DotColor = s.GetDotColor(defaults.DotColor) + final.DotWidth = s.GetDotWidth(defaults.DotWidth) + final.FillColor = s.GetFillColor(defaults.FillColor) final.FontColor = s.GetFontColor(defaults.FontColor) final.FontSize = s.GetFontSize(defaults.FontSize) @@ -298,6 +353,7 @@ func (s Style) InheritFrom(defaults Style) (final Style) { final.TextWrap = s.GetTextWrap(defaults.TextWrap) final.TextLineSpacing = s.GetTextLineSpacing(defaults.TextLineSpacing) final.TextRotationDegrees = s.GetTextRotationDegrees(defaults.TextRotationDegrees) + return } @@ -317,6 +373,16 @@ func (s Style) GetFillOptions() Style { } } +// GetDotOptions returns the dot components. +func (s Style) GetDotOptions() Style { + return Style{ + StrokeDashArray: nil, + FillColor: s.DotColor, + StrokeColor: s.DotColor, + StrokeWidth: 1.0, + } +} + // GetFillAndStrokeOptions returns the fill and stroke components. func (s Style) GetFillAndStrokeOptions() Style { return Style{ @@ -340,3 +406,18 @@ func (s Style) GetTextOptions() Style { TextRotationDegrees: s.TextRotationDegrees, } } + +// ShouldDrawStroke tells drawing functions if they should draw the stroke. +func (s Style) ShouldDrawStroke() bool { + return !s.StrokeColor.IsZero() && s.StrokeWidth > 0 +} + +// ShouldDrawDot tells drawing functions if they should draw the dot. +func (s Style) ShouldDrawDot() bool { + return !s.DotColor.IsZero() && s.DotWidth > 0 +} + +// ShouldDrawFill tells drawing functions if they should draw the stroke. +func (s Style) ShouldDrawFill() bool { + return !s.FillColor.IsZero() +}