From b713ff85cc7be83a7bb06d7581e0fb498c6693cf Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sun, 5 Mar 2017 16:54:40 -0800 Subject: [PATCH] Adds the ability to draw an XY scatter plot. (#27) * works more or less * updating comment * removing debugging printf * adding output * tweaks * missed a couple series validations * testing auto coloring * updated output.png * color tests etc. * sanity check tests. * should not use unkeyed fields anyway. --- _examples/request_timings/main.go | 5 +- _examples/scatter/main.go | 80 ++++++++++++++++++++++++++ _examples/scatter/output.png | Bin 0 -> 12268 bytes box.go | 29 ++++++++-- box_test.go | 8 +-- chart.go | 5 +- chart_test.go | 90 ++++++++++++++++++++++++++++++ defaults.go | 2 + draw.go | 38 +++++++++---- drawing/color.go | 34 +++++++++-- drawing/color_test.go | 12 ++++ drawing/curve.go | 53 +++++++++++++----- drawing/curve_test.go | 35 ++++++++++++ drawing/flattener.go | 7 ++- linear_regression_series.go | 8 ++- macd_series.go | 55 +++++++++++++++--- raster_renderer.go | 15 +++-- sequence.go | 14 +++-- sma_series.go | 4 +- style.go | 83 ++++++++++++++++++++++++++- xaxis_test.go | 2 +- yaxis_test.go | 4 +- 22 files changed, 511 insertions(+), 72 deletions(-) create mode 100644 _examples/scatter/main.go create mode 100644 _examples/scatter/output.png create mode 100644 drawing/curve_test.go diff --git a/_examples/request_timings/main.go b/_examples/request_timings/main.go index bcaf968..aed7493 100644 --- a/_examples/request_timings/main.go +++ b/_examples/request_timings/main.go @@ -23,7 +23,7 @@ func parseFloat64(str string) float64 { func readData() ([]time.Time, []float64) { var xvalues []time.Time var yvalues []float64 - chart.File.ReadByLines("requests.csv", func(line string) { + err := chart.File.ReadByLines("requests.csv", func(line string) { parts := strings.Split(line, ",") year := parseInt(parts[0]) month := parseInt(parts[1]) @@ -33,6 +33,9 @@ func readData() ([]time.Time, []float64) { xvalues = append(xvalues, time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.UTC)) yvalues = append(yvalues, elapsedMillis) }) + if err != nil { + fmt.Println(err.Error()) + } return xvalues, yvalues } diff --git a/_examples/scatter/main.go b/_examples/scatter/main.go new file mode 100644 index 0000000..9aed893 --- /dev/null +++ b/_examples/scatter/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "log" + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + graph := chart.Chart{ + Series: []chart.Series{ + chart.ContinuousSeries{ + Style: chart.Style{ + Show: true, + StrokeWidth: chart.Disabled, + DotWidth: 3, + }, + XValues: chart.Sequence.Random(32, 1024), + YValues: chart.Sequence.Random(32, 1024), + }, + chart.ContinuousSeries{ + Style: chart.Style{ + Show: true, + StrokeWidth: chart.Disabled, + DotWidth: 5, + }, + XValues: chart.Sequence.Random(16, 1024), + YValues: chart.Sequence.Random(16, 1024), + }, + chart.ContinuousSeries{ + Style: chart.Style{ + Show: true, + StrokeWidth: chart.Disabled, + DotWidth: 7, + }, + XValues: chart.Sequence.Random(8, 1024), + YValues: chart.Sequence.Random(8, 1024), + }, + }, + } + + res.Header().Set("Content-Type", "image/png") + err := graph.Render(chart.PNG, res) + if err != nil { + log.Println(err.Error()) + } + +} + +func unit(res http.ResponseWriter, req *http.Request) { + graph := chart.Chart{ + Height: 50, + Width: 50, + Canvas: chart.Style{ + Padding: chart.Box{IsSet: true}, + }, + Background: chart.Style{ + Padding: chart.Box{IsSet: true}, + }, + Series: []chart.Series{ + chart.ContinuousSeries{ + XValues: chart.Sequence.Float64(0, 4, 1), + YValues: chart.Sequence.Float64(0, 4, 1), + }, + }, + } + + 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) + http.HandleFunc("/unit", unit) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/_examples/scatter/output.png b/_examples/scatter/output.png new file mode 100644 index 0000000000000000000000000000000000000000..535e4791d42d99bb3f390736b6ddd23ea49a3eb2 GIT binary patch literal 12268 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 0 { return defaults[0] } @@ -36,7 +56,7 @@ func (b Box) GetTop(defaults ...int) int { // GetLeft returns a coalesced value with a default. func (b Box) GetLeft(defaults ...int) int { - if b.Left == 0 { + if !b.IsSet && b.Left == 0 { if len(defaults) > 0 { return defaults[0] } @@ -47,7 +67,7 @@ func (b Box) GetLeft(defaults ...int) int { // GetRight returns a coalesced value with a default. func (b Box) GetRight(defaults ...int) int { - if b.Right == 0 { + if !b.IsSet && b.Right == 0 { if len(defaults) > 0 { return defaults[0] } @@ -58,7 +78,7 @@ func (b Box) GetRight(defaults ...int) int { // GetBottom returns a coalesced value with a default. func (b Box) GetBottom(defaults ...int) int { - if b.Bottom == 0 { + if !b.IsSet && b.Bottom == 0 { if len(defaults) > 0 { return defaults[0] } @@ -91,6 +111,7 @@ func (b Box) Aspect() float64 { // Clone returns a new copy of the box. func (b Box) Clone() Box { return Box{ + IsSet: b.IsSet, Top: b.Top, Left: b.Left, Right: b.Right, diff --git a/box_test.go b/box_test.go index 89eafcf..3f3fa02 100644 --- a/box_test.go +++ b/box_test.go @@ -109,9 +109,9 @@ func TestBoxConstrain(t *testing.T) { func TestBoxOuterConstrain(t *testing.T) { assert := assert.New(t) - box := Box{0, 0, 100, 100} - canvas := Box{5, 5, 95, 95} - taller := Box{-10, 5, 50, 50} + box := NewBox(0, 0, 100, 100) + canvas := NewBox(5, 5, 95, 95) + taller := NewBox(-10, 5, 50, 50) c := canvas.OuterConstrain(box, taller) assert.Equal(15, c.Top, c.String()) @@ -119,7 +119,7 @@ func TestBoxOuterConstrain(t *testing.T) { assert.Equal(95, c.Right, c.String()) assert.Equal(95, c.Bottom, c.String()) - wider := Box{5, 5, 110, 50} + wider := NewBox(5, 5, 110, 50) d := canvas.OuterConstrain(box, wider) assert.Equal(5, d.Top, d.String()) assert.Equal(5, d.Left, d.String()) diff --git a/chart.go b/chart.go index 5c8ab4d..47597ed 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") @@ -511,6 +513,7 @@ func (c Chart) styleDefaultsCanvas() Style { func (c Chart) styleDefaultsSeries(seriesIndex int) Style { strokeColor := GetDefaultColor(seriesIndex) return Style{ + DotColor: strokeColor, StrokeColor: strokeColor, StrokeWidth: DefaultSeriesLineWidth, Font: c.GetFont(), diff --git a/chart_test.go b/chart_test.go index 87d00ed..4dead70 100644 --- a/chart_test.go +++ b/chart_test.go @@ -2,6 +2,8 @@ package chart import ( "bytes" + "image" + "image/png" "math" "testing" "time" @@ -483,3 +485,91 @@ func TestChartCheckRangesWithRanges(t *testing.T) { xr, yr, yra := c.getRanges() assert.Nil(c.checkRanges(xr, yr, yra)) } + +func at(i image.Image, x, y int) drawing.Color { + return drawing.ColorFromAlphaMixedRGBA(i.At(x, y).RGBA()) +} + +func TestChartE2ELine(t *testing.T) { + assert := assert.New(t) + + c := Chart{ + Height: 50, + Width: 50, + Canvas: Style{ + Padding: Box{IsSet: true}, + }, + Background: Style{ + Padding: Box{IsSet: true}, + }, + Series: []Series{ + ContinuousSeries{ + XValues: Sequence.Float64(0, 4, 1), + YValues: Sequence.Float64(0, 4, 1), + }, + }, + } + + var buffer = &bytes.Buffer{} + err := c.Render(PNG, buffer) + assert.Nil(err) + + // do color tests ... + + i, err := png.Decode(buffer) + assert.Nil(err) + + // test the bottom and top of the line + assert.Equal(drawing.ColorWhite, at(i, 0, 0)) + assert.Equal(drawing.ColorWhite, at(i, 49, 49)) + + // test a line mid point + defaultSeriesColor := GetDefaultColor(0) + assert.Equal(defaultSeriesColor, at(i, 0, 49)) + assert.Equal(defaultSeriesColor, at(i, 49, 0)) + assert.Equal(drawing.ColorFromHex("bddbf6"), at(i, 24, 24)) +} + +func TestChartE2ELineWithFill(t *testing.T) { + assert := assert.New(t) + + c := Chart{ + Height: 50, + Width: 50, + Canvas: Style{ + Padding: Box{IsSet: true}, + }, + Background: Style{ + Padding: Box{IsSet: true}, + }, + Series: []Series{ + ContinuousSeries{ + Style: Style{ + Show: true, + StrokeColor: drawing.ColorBlue, + FillColor: drawing.ColorRed, + }, + XValues: Sequence.Float64(0, 4, 1), + YValues: Sequence.Float64(0, 4, 1), + }, + }, + } + + var buffer = &bytes.Buffer{} + err := c.Render(PNG, buffer) + assert.Nil(err) + + // do color tests ... + + i, err := png.Decode(buffer) + assert.Nil(err) + + // test the bottom and top of the line + assert.Equal(drawing.ColorWhite, at(i, 0, 0)) + assert.Equal(drawing.ColorRed, at(i, 49, 49)) + + // test a line mid point + defaultSeriesColor := drawing.ColorBlue + assert.Equal(defaultSeriesColor, at(i, 0, 49)) + assert.Equal(defaultSeriesColor, at(i, 49, 0)) +} 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..6b8e3f7 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,33 @@ 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) + } + r.Stroke() + } + + 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/color.go b/drawing/color.go index 19b3a4f..e7099b4 100644 --- a/drawing/color.go +++ b/drawing/color.go @@ -46,12 +46,20 @@ func ColorFromHex(hex string) Color { return c } +// ColorFromAlphaMixedRGBA returns the system alpha mixed rgba values. +func ColorFromAlphaMixedRGBA(r, g, b, a uint32) Color { + fa := float64(a) / 255.0 + var c Color + c.R = uint8(float64(r) / fa) + c.G = uint8(float64(g) / fa) + c.B = uint8(float64(b) / fa) + c.A = uint8(a | (a >> 8)) + return c +} + // Color is our internal color type because color.Color is bullshit. type Color struct { - R uint8 - G uint8 - B uint8 - A uint8 + R, G, B, A uint8 } // RGBA returns the color as a pre-alpha mixed color set. @@ -88,6 +96,24 @@ func (c Color) WithAlpha(a uint8) Color { } } +// Equals returns true if the color equals another. +func (c Color) Equals(other Color) bool { + return c.R == other.R && + c.G == other.G && + c.B == other.B && + c.A == other.A +} + +// AverageWith averages two colors. +func (c Color) AverageWith(other Color) Color { + return Color{ + R: (c.R + other.R) >> 1, + G: (c.G + other.G) >> 1, + B: (c.B + other.B) >> 1, + A: c.A, + } +} + // String returns a css string representation of the color. func (c Color) String() string { fa := float64(c.A) / float64(255) diff --git a/drawing/color_test.go b/drawing/color_test.go index d0616e2..bdedd02 100644 --- a/drawing/color_test.go +++ b/drawing/color_test.go @@ -3,6 +3,8 @@ package drawing import ( "testing" + "image/color" + "github.com/blendlabs/go-assert" ) @@ -39,3 +41,13 @@ func TestColorFromHex(t *testing.T) { shortBlue := ColorFromHex("00F") assert.Equal(ColorBlue, shortBlue) } + +func TestColorFromAlphaMixedRGBA(t *testing.T) { + assert := assert.New(t) + + black := ColorFromAlphaMixedRGBA(color.Black.RGBA()) + assert.True(black.Equals(ColorBlack), black.String()) + + white := ColorFromAlphaMixedRGBA(color.White.RGBA()) + assert.True(white.Equals(ColorWhite), white.String()) +} 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/linear_regression_series.go b/linear_regression_series.go index bcd045b..142ea55 100644 --- a/linear_regression_series.go +++ b/linear_regression_series.go @@ -49,7 +49,9 @@ func (lrs LinearRegressionSeries) GetWindow() int { // GetEndIndex returns the effective window end. func (lrs LinearRegressionSeries) GetEndIndex() int { - return Math.MinInt(lrs.GetOffset()+(lrs.Len()), (lrs.InnerSeries.Len() - 1)) + offset := lrs.GetOffset() + lrs.Len() + innerSeriesLastIndex := lrs.InnerSeries.Len() - 1 + return Math.MinInt(offset, innerSeriesLastIndex) } // GetOffset returns the data offset. @@ -62,7 +64,7 @@ func (lrs LinearRegressionSeries) GetOffset() int { // GetValue gets a value at a given index. func (lrs *LinearRegressionSeries) GetValue(index int) (x, y float64) { - if lrs.InnerSeries == nil { + if lrs.InnerSeries == nil || lrs.InnerSeries.Len() == 0 { return } if lrs.m == 0 && lrs.b == 0 { @@ -78,7 +80,7 @@ func (lrs *LinearRegressionSeries) GetValue(index int) (x, y float64) { // GetLastValue computes the last moving average value but walking back window size samples, // and recomputing the last moving average chunk. func (lrs *LinearRegressionSeries) GetLastValue() (x, y float64) { - if lrs.InnerSeries == nil { + if lrs.InnerSeries == nil || lrs.InnerSeries.Len() == 0 { return } if lrs.m == 0 && lrs.b == 0 { diff --git a/macd_series.go b/macd_series.go index aa3b683..af51d9a 100644 --- a/macd_series.go +++ b/macd_series.go @@ -27,6 +27,24 @@ type MACDSeries struct { macdl *MACDLineSeries } +// Validate validates the series. +func (macd MACDSeries) Validate() error { + var err error + if macd.signal != nil { + err = macd.signal.Validate() + } + if err != nil { + return err + } + if macd.macdl != nil { + err = macd.macdl.Validate() + } + if err != nil { + return err + } + return nil +} + // GetPeriods returns the primary and secondary periods. func (macd MACDSeries) GetPeriods() (w1, w2, sig int) { if macd.PrimaryPeriod == 0 { @@ -121,6 +139,14 @@ type MACDSignalSeries struct { signal *EMASeries } +// Validate validates the series. +func (macds MACDSignalSeries) Validate() error { + if macds.signal != nil { + return macds.signal.Validate() + } + return nil +} + // GetPeriods returns the primary and secondary periods. func (macds MACDSignalSeries) GetPeriods() (w1, w2, sig int) { if macds.PrimaryPeriod == 0 { @@ -214,6 +240,27 @@ type MACDLineSeries struct { Sigma float64 } +// Validate validates the series. +func (macdl MACDLineSeries) Validate() error { + var err error + if macdl.ema1 != nil { + err = macdl.ema1.Validate() + } + if err != nil { + return err + } + if macdl.ema2 != nil { + err = macdl.ema2.Validate() + } + if err != nil { + return err + } + if macdl.InnerSeries == nil { + return fmt.Errorf("MACDLineSeries: must provide an inner series") + } + return nil +} + // GetName returns the name of the time series. func (macdl MACDLineSeries) GetName() string { return macdl.Name @@ -289,11 +336,3 @@ func (macdl *MACDLineSeries) Render(r Renderer, canvasBox Box, xrange, yrange Ra style := macdl.Style.InheritFrom(defaults) Draw.LineSeries(r, canvasBox, xrange, yrange, style, macdl) } - -// Validate validates the series. -func (macdl *MACDLineSeries) Validate() error { - if macdl.InnerSeries == nil { - return fmt.Errorf("macd line series requires InnerSeries to be set") - } - return nil -} diff --git a/raster_renderer.go b/raster_renderer.go index 326dcb5..3088658 100644 --- a/raster_renderer.go +++ b/raster_renderer.go @@ -116,17 +116,16 @@ func (rr *rasterRenderer) FillStroke() { rr.gc.FillStroke() } -// Circle implements the interface method. +// Circle fully draws a circle at a given point but does not apply the fill or stroke. func (rr *rasterRenderer) Circle(radius float64, x, y int) { 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/sma_series.go b/sma_series.go index d9f7f13..f68c60d 100644 --- a/sma_series.go +++ b/sma_series.go @@ -50,7 +50,7 @@ func (sma SMASeries) GetPeriod(defaults ...int) int { // GetValue gets a value at a given index. func (sma SMASeries) GetValue(index int) (x, y float64) { - if sma.InnerSeries == nil { + if sma.InnerSeries == nil || sma.InnerSeries.Len() == 0 { return } px, _ := sma.InnerSeries.GetValue(index) @@ -62,7 +62,7 @@ func (sma SMASeries) GetValue(index int) (x, y float64) { // GetLastValue computes the last moving average value but walking back window size samples, // and recomputing the last moving average chunk. func (sma SMASeries) GetLastValue() (x, y float64) { - if sma.InnerSeries == nil { + if sma.InnerSeries == nil || sma.InnerSeries.Len() == 0 { return } seriesLen := sma.InnerSeries.Len() 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() +} diff --git a/xaxis_test.go b/xaxis_test.go index 289290b..f55ea29 100644 --- a/xaxis_test.go +++ b/xaxis_test.go @@ -61,7 +61,7 @@ func TestXAxisMeasure(t *testing.T) { assert.Nil(err) ticks := []Tick{{Value: 1.0, Label: "1.0"}, {Value: 2.0, Label: "2.0"}, {Value: 3.0, Label: "3.0"}} xa := XAxis{} - xab := xa.Measure(r, Box{0, 0, 100, 100}, &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks) + xab := xa.Measure(r, NewBox(0, 0, 100, 100), &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks) assert.Equal(122, xab.Width()) assert.Equal(21, xab.Height()) } diff --git a/yaxis_test.go b/yaxis_test.go index b603733..86deae5 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -61,7 +61,7 @@ func TestYAxisMeasure(t *testing.T) { assert.Nil(err) ticks := []Tick{{Value: 1.0, Label: "1.0"}, {Value: 2.0, Label: "2.0"}, {Value: 3.0, Label: "3.0"}} ya := YAxis{} - yab := ya.Measure(r, Box{0, 0, 100, 100}, &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks) + yab := ya.Measure(r, NewBox(0, 0, 100, 100), &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks) assert.Equal(32, yab.Width()) assert.Equal(110, yab.Height()) } @@ -79,7 +79,7 @@ func TestYAxisSecondaryMeasure(t *testing.T) { assert.Nil(err) ticks := []Tick{{Value: 1.0, Label: "1.0"}, {Value: 2.0, Label: "2.0"}, {Value: 3.0, Label: "3.0"}} ya := YAxis{AxisType: YAxisSecondary} - yab := ya.Measure(r, Box{0, 0, 100, 100}, &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks) + yab := ya.Measure(r, NewBox(0, 0, 100, 100), &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks) assert.Equal(32, yab.Width()) assert.Equal(110, yab.Height()) }