diff --git a/drawing_helpers.go b/drawing_helpers.go index c73d822..8c16704 100644 --- a/drawing_helpers.go +++ b/drawing_helpers.go @@ -33,6 +33,9 @@ func DrawLineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs } r.SetStrokeColor(s.GetStrokeColor()) + if len(s.GetStrokeDashArray()) > 0 { + r.SetStrokeDashArray(s.GetStrokeDashArray()) + } r.SetStrokeWidth(s.GetStrokeWidth(DefaultStrokeWidth)) r.MoveTo(x0, y0) diff --git a/moving_average_series.go b/moving_average_series.go new file mode 100644 index 0000000..b408af4 --- /dev/null +++ b/moving_average_series.go @@ -0,0 +1,81 @@ +package chart + +const ( + // DefaultMovingAverageWindowSize is the default number of values to average. + DefaultMovingAverageWindowSize = 5 +) + +// MovingAverageSeries is a computed series. +type MovingAverageSeries struct { + Name string + Style Style + YAxis YAxisType + + WindowSize int + InnerSeries ValueProvider + valueBuffer *RingBuffer +} + +// GetName returns the name of the time series. +func (mas MovingAverageSeries) GetName() string { + return mas.Name +} + +// GetStyle returns the line style. +func (mas MovingAverageSeries) GetStyle() Style { + return mas.Style +} + +// GetYAxis returns which YAxis the series draws on. +func (mas MovingAverageSeries) GetYAxis() YAxisType { + return mas.YAxis +} + +// Len returns the number of elements in the series. +func (mas *MovingAverageSeries) Len() int { + return mas.InnerSeries.Len() +} + +// GetValue gets a value at a given index. +func (mas *MovingAverageSeries) GetValue(index int) (x float64, y float64) { + if mas.valueBuffer == nil { + mas.valueBuffer = NewRingBufferWithCapacity(mas.GetWindowSize()) + } + if mas.valueBuffer.Len() >= mas.GetWindowSize() { + mas.valueBuffer.Dequeue() + } + x, y = mas.InnerSeries.GetValue(index) + mas.valueBuffer.Enqueue(y) + if mas.valueBuffer.Len() < mas.GetWindowSize() { + return + } + y = mas.getAverage() + return +} + +// GetWindowSize returns the window size. +func (mas MovingAverageSeries) GetWindowSize(defaults ...int) int { + if mas.WindowSize == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return DefaultMovingAverageWindowSize + } + return mas.WindowSize +} + +func (mas MovingAverageSeries) getAverage() float64 { + var accum float64 + mas.valueBuffer.Each(func(v interface{}) { + if typed, isTyped := v.(float64); isTyped { + accum += typed + } + }) + return accum / float64(mas.valueBuffer.Len()) +} + +// Render renders the series. +func (mas *MovingAverageSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { + style := mas.Style.WithDefaultsFrom(defaults) + DrawLineSeries(r, canvasBox, xrange, yrange, style, mas) +} diff --git a/ring_buffer.go b/ring_buffer.go new file mode 100644 index 0000000..a5e91cd --- /dev/null +++ b/ring_buffer.go @@ -0,0 +1,217 @@ +package chart + +import ( + "fmt" + "strings" +) + +const ( + ringBufferMinimumGrow = 4 + ringBufferShrinkThreshold = 32 + ringBufferGrowFactor = 200 + ringBufferDefaultCapacity = 4 +) + +var ( + emptyArray = make([]interface{}, 0) +) + +// NewRingBuffer creates a new, empty, RingBuffer. +func NewRingBuffer() *RingBuffer { + return &RingBuffer{ + array: make([]interface{}, ringBufferDefaultCapacity), + head: 0, + tail: 0, + size: 0, + } +} + +// NewRingBufferWithCapacity creates a new RingBuffer pre-allocated with the given capacity. +func NewRingBufferWithCapacity(capacity int) *RingBuffer { + return &RingBuffer{ + array: make([]interface{}, capacity), + head: 0, + tail: 0, + size: 0, + } +} + +// NewRingBufferFromSlice createsa ring buffer out of a slice. +func NewRingBufferFromSlice(values []interface{}) *RingBuffer { + return &RingBuffer{ + array: values, + head: 0, + tail: len(values) - 1, + size: len(values), + } +} + +// RingBuffer is a fifo buffer that is backed by a pre-allocated array, instead of allocating +// a whole new node object for each element (which saves GC churn). +// Enqueue can be O(n), Dequeue can be O(1). +type RingBuffer struct { + array []interface{} + head int + tail int + size int +} + +// Len returns the length of the ring buffer (as it is currently populated). +// Actual memory footprint may be different. +func (rb *RingBuffer) Len() int { + return rb.size +} + +// TotalLen returns the total size of the ring bufffer, including empty elements. +func (rb *RingBuffer) TotalLen() int { + return len(rb.array) +} + +// Clear removes all objects from the RingBuffer. +func (rb *RingBuffer) Clear() { + if rb.head < rb.tail { + arrayClear(rb.array, rb.head, rb.size) + } else { + arrayClear(rb.array, rb.head, len(rb.array)-rb.head) + arrayClear(rb.array, 0, rb.tail) + } + + rb.head = 0 + rb.tail = 0 + rb.size = 0 +} + +// Enqueue adds an element to the "back" of the RingBuffer. +func (rb *RingBuffer) Enqueue(object interface{}) { + if rb.size == len(rb.array) { + newCapacity := int(len(rb.array) * int(ringBufferGrowFactor/100)) + if newCapacity < (len(rb.array) + ringBufferMinimumGrow) { + newCapacity = len(rb.array) + ringBufferMinimumGrow + } + rb.setCapacity(newCapacity) + } + + rb.array[rb.tail] = object + rb.tail = (rb.tail + 1) % len(rb.array) + rb.size++ +} + +// Dequeue removes the first element from the RingBuffer. +func (rb *RingBuffer) Dequeue() interface{} { + if rb.size == 0 { + return nil + } + + removed := rb.array[rb.head] + rb.head = (rb.head + 1) % len(rb.array) + rb.size-- + return removed +} + +// Peek returns but does not remove the first element. +func (rb *RingBuffer) Peek() interface{} { + if rb.size == 0 { + return nil + } + return rb.array[rb.head] +} + +// PeekBack returns but does not remove the last element. +func (rb *RingBuffer) PeekBack() interface{} { + if rb.size == 0 { + return nil + } + if rb.tail == 0 { + return rb.array[len(rb.array)-1] + } + return rb.array[rb.tail-1] +} + +func (rb *RingBuffer) setCapacity(capacity int) { + newArray := make([]interface{}, capacity) + if rb.size > 0 { + if rb.head < rb.tail { + arrayCopy(rb.array, rb.head, newArray, 0, rb.size) + } else { + arrayCopy(rb.array, rb.head, newArray, 0, len(rb.array)-rb.head) + arrayCopy(rb.array, 0, newArray, len(rb.array)-rb.head, rb.tail) + } + } + rb.array = newArray + rb.head = 0 + if rb.size == capacity { + rb.tail = 0 + } else { + rb.tail = rb.size + } +} + +// TrimExcess resizes the buffer to better fit the contents. +func (rb *RingBuffer) TrimExcess() { + threshold := float64(len(rb.array)) * 0.9 + if rb.size < int(threshold) { + rb.setCapacity(rb.size) + } +} + +// AsSlice returns the ring buffer, in order, as a slice. +func (rb *RingBuffer) AsSlice() []interface{} { + newArray := make([]interface{}, rb.size) + + if rb.size == 0 { + return newArray + } + + if rb.head < rb.tail { + arrayCopy(rb.array, rb.head, newArray, 0, rb.size) + } else { + arrayCopy(rb.array, rb.head, newArray, 0, len(rb.array)-rb.head) + arrayCopy(rb.array, 0, newArray, len(rb.array)-rb.head, rb.tail) + } + + return newArray +} + +// Each calls the consumer for each element in the buffer. +func (rb *RingBuffer) Each(consumer func(value interface{})) { + if rb.size == 0 { + return + } + + if rb.head < rb.tail { + for cursor := rb.head; cursor < rb.tail; cursor++ { + consumer(rb.array[cursor]) + } + } else { + for cursor := rb.head; cursor < len(rb.array); cursor++ { + consumer(rb.array[cursor]) + } + for cursor := 0; cursor < rb.tail; cursor++ { + consumer(rb.array[cursor]) + } + } +} + +func (rb *RingBuffer) String() string { + var values []string + for _, elem := range rb.AsSlice() { + values = append(values, fmt.Sprintf("%v", elem)) + } + return strings.Join(values, " <= ") +} + +func arrayClear(source []interface{}, index, length int) { + for x := 0; x < length; x++ { + absoluteIndex := x + index + source[absoluteIndex] = nil + } +} + +func arrayCopy(source []interface{}, sourceIndex int, destination []interface{}, destinationIndex, length int) { + for x := 0; x < length; x++ { + from := sourceIndex + x + to := destinationIndex + x + + destination[to] = source[from] + } +} diff --git a/ring_buffer_test.go b/ring_buffer_test.go new file mode 100644 index 0000000..cc35074 --- /dev/null +++ b/ring_buffer_test.go @@ -0,0 +1,162 @@ +package chart + +import ( + "testing" + + "github.com/blendlabs/go-assert" +) + +func TestRingBuffer(t *testing.T) { + assert := assert.New(t) + + buffer := NewRingBuffer() + + buffer.Enqueue(1) + assert.Equal(1, buffer.Len()) + assert.Equal(1, buffer.Peek()) + assert.Equal(1, buffer.PeekBack()) + + buffer.Enqueue(2) + assert.Equal(2, buffer.Len()) + assert.Equal(1, buffer.Peek()) + assert.Equal(2, buffer.PeekBack()) + + buffer.Enqueue(3) + assert.Equal(3, buffer.Len()) + assert.Equal(1, buffer.Peek()) + assert.Equal(3, buffer.PeekBack()) + + buffer.Enqueue(4) + assert.Equal(4, buffer.Len()) + assert.Equal(1, buffer.Peek()) + assert.Equal(4, buffer.PeekBack()) + + buffer.Enqueue(5) + assert.Equal(5, buffer.Len()) + assert.Equal(1, buffer.Peek()) + assert.Equal(5, buffer.PeekBack()) + + buffer.Enqueue(6) + assert.Equal(6, buffer.Len()) + assert.Equal(1, buffer.Peek()) + assert.Equal(6, buffer.PeekBack()) + + buffer.Enqueue(7) + assert.Equal(7, buffer.Len()) + assert.Equal(1, buffer.Peek()) + assert.Equal(7, buffer.PeekBack()) + + buffer.Enqueue(8) + assert.Equal(8, buffer.Len()) + assert.Equal(1, buffer.Peek()) + assert.Equal(8, buffer.PeekBack()) + + value := buffer.Dequeue() + assert.Equal(1, value) + assert.Equal(7, buffer.Len()) + assert.Equal(2, buffer.Peek()) + assert.Equal(8, buffer.PeekBack()) + + value = buffer.Dequeue() + assert.Equal(2, value) + assert.Equal(6, buffer.Len()) + assert.Equal(3, buffer.Peek()) + assert.Equal(8, buffer.PeekBack()) + + value = buffer.Dequeue() + assert.Equal(3, value) + assert.Equal(5, buffer.Len()) + assert.Equal(4, buffer.Peek()) + assert.Equal(8, buffer.PeekBack()) + + value = buffer.Dequeue() + assert.Equal(4, value) + assert.Equal(4, buffer.Len()) + assert.Equal(5, buffer.Peek()) + assert.Equal(8, buffer.PeekBack()) + + value = buffer.Dequeue() + assert.Equal(5, value) + assert.Equal(3, buffer.Len()) + assert.Equal(6, buffer.Peek()) + assert.Equal(8, buffer.PeekBack()) + + value = buffer.Dequeue() + assert.Equal(6, value) + assert.Equal(2, buffer.Len()) + assert.Equal(7, buffer.Peek()) + assert.Equal(8, buffer.PeekBack()) + + value = buffer.Dequeue() + assert.Equal(7, value) + assert.Equal(1, buffer.Len()) + assert.Equal(8, buffer.Peek()) + assert.Equal(8, buffer.PeekBack()) + + value = buffer.Dequeue() + assert.Equal(8, value) + assert.Equal(0, buffer.Len()) + assert.Nil(buffer.Peek()) + assert.Nil(buffer.PeekBack()) +} + +func TestRingBufferClear(t *testing.T) { + assert := assert.New(t) + + buffer := NewRingBuffer() + buffer.Enqueue(1) + buffer.Enqueue(1) + buffer.Enqueue(1) + buffer.Enqueue(1) + buffer.Enqueue(1) + buffer.Enqueue(1) + buffer.Enqueue(1) + buffer.Enqueue(1) + + assert.Equal(8, buffer.Len()) + + buffer.Clear() + assert.Equal(0, buffer.Len()) + assert.Nil(buffer.Peek()) + assert.Nil(buffer.PeekBack()) +} + +func TestRingBufferAsSlice(t *testing.T) { + assert := assert.New(t) + + buffer := NewRingBuffer() + buffer.Enqueue(1) + buffer.Enqueue(2) + buffer.Enqueue(3) + buffer.Enqueue(4) + buffer.Enqueue(5) + + contents := buffer.AsSlice() + assert.Len(contents, 5) + assert.Equal(1, contents[0]) + assert.Equal(2, contents[1]) + assert.Equal(3, contents[2]) + assert.Equal(4, contents[3]) + assert.Equal(5, contents[4]) +} + +func TestRingBufferEach(t *testing.T) { + assert := assert.New(t) + + buffer := NewRingBuffer() + + for x := 1; x < 17; x++ { + buffer.Enqueue(x) + } + + called := 0 + buffer.Each(func(v interface{}) { + if typed, isTyped := v.(int); isTyped { + if typed == (called + 1) { + called++ + } + } + }) + + assert.Equal(16, called) +} diff --git a/style.go b/style.go index 3445ce8..ed2889e 100644 --- a/style.go +++ b/style.go @@ -120,6 +120,7 @@ func (s Style) GetPadding(defaults ...Box) Box { func (s Style) WithDefaultsFrom(defaults Style) (final Style) { final.StrokeColor = s.GetStrokeColor(defaults.StrokeColor) final.StrokeWidth = s.GetStrokeWidth(defaults.StrokeWidth) + final.StrokeDashArray = s.GetStrokeDashArray(defaults.StrokeDashArray) final.FillColor = s.GetFillColor(defaults.FillColor) final.FontColor = s.GetFontColor(defaults.FontColor) final.Font = s.GetFont(defaults.Font) diff --git a/testserver/main.go b/testserver/main.go index 3918733..68bcae2 100644 --- a/testserver/main.go +++ b/testserver/main.go @@ -35,6 +35,16 @@ func chartHandler(rc *web.RequestContext) web.ControllerResult { s1y = append(s1y, rnd.Float64()*1024) } + s1 := chart.TimeSeries{ + Name: "a", + XValues: s1x, + YValues: s1y, + Style: chart.Style{ + Show: true, + FillColor: chart.GetDefaultSeriesStrokeColor(0).WithAlpha(64), + }, + } + c := chart.Chart{ Title: "A Test Chart", TitleStyle: chart.Style{ @@ -65,17 +75,19 @@ func chartHandler(rc *web.RequestContext) web.ControllerResult { }, }, Series: []chart.Series{ - chart.TimeSeries{ - Name: "a", - XValues: s1x, - YValues: s1y, + s1, + &chart.MovingAverageSeries{ + Name: "Average", Style: chart.Style{ - Show: true, - FillColor: chart.GetDefaultSeriesStrokeColor(0).WithAlpha(64), + Show: true, + StrokeColor: drawing.ColorRed, + StrokeDashArray: []float64{5, 1, 1}, }, + WindowSize: 10, + InnerSeries: s1, }, chart.AnnotationSeries{ - Name: fmt.Sprintf("%s - Last Value", "Test"), + Name: fmt.Sprintf("Last Value"), Style: chart.Style{ Show: true, StrokeColor: chart.GetDefaultSeriesStrokeColor(0),