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 0000000..535e479 Binary files /dev/null and b/_examples/scatter/output.png differ diff --git a/box.go b/box.go index 98c1f17..291b429 100644 --- a/box.go +++ b/box.go @@ -5,16 +5,36 @@ import ( "math" ) +var ( + // BoxZero is a preset box that represents an intentional zero value. + BoxZero = Box{IsSet: true} +) + +// NewBox returns a new (set) box. +func NewBox(top, left, right, bottom int) Box { + return Box{ + IsSet: true, + Top: top, + Left: left, + Right: right, + Bottom: bottom, + } +} + // Box represents the main 4 dimensions of a box. type Box struct { Top int Left int Right int Bottom int + IsSet bool } // IsZero returns if the box is set or not. func (b Box) IsZero() bool { + if b.IsSet { + return false + } return b.Top == 0 && b.Left == 0 && b.Right == 0 && b.Bottom == 0 } @@ -25,7 +45,7 @@ func (b Box) String() string { // GetTop returns a coalesced value with a default. func (b Box) GetTop(defaults ...int) int { - if b.Top == 0 { + if !b.IsSet && b.Top == 0 { if len(defaults) > 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()) }