diff --git a/.travis.yml b/.travis.yml index b523396..f2e55e9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,8 +6,8 @@ go: sudo: false before_script: - - go build -i ./... + - go get -u github.com/blendlabs/go-assert + - go get ./... script: - - go test - - go test ./drawing/ \ No newline at end of file + - go test ./... diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cc16258 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +all: test + +test: + @go test ./... + +cover: + @go test -short -covermode=set -coverprofile=profile.cov + @go tool cover -html=profile.cov + @rm profile.cov \ No newline at end of file diff --git a/README.md b/README.md index e1180e2..c932208 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,19 @@ Two axis: ![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/images/two_axis.png) -Simple Moving Average: +# Other Chart Types -![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/images/ma_goog_ltm.png) +Pie Chart: -Bollinger Bounds: +![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/images/pie_chart.png) -![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/images/spy_ltm_bbs.png) +The code for this chart can be found in `examples/pie_chart/main.go`. + +Stacked Bar: + +![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/images/stacked_bar.png) + +The code for this chart can be found in `examples/stacked_bar/main.go`. # Code Examples diff --git a/annotation_series.go b/annotation_series.go index 8a9b891..3e97d82 100644 --- a/annotation_series.go +++ b/annotation_series.go @@ -2,18 +2,12 @@ package chart import "math" -// Annotation is a label on the chart. -type Annotation struct { - X, Y float64 - Label string -} - // AnnotationSeries is a series of labels on the chart. type AnnotationSeries struct { Name string Style Style - YAxis YAxisType - Annotations []Annotation + YAxis yAxisType + Annotations []Value2 } // GetName returns the name of the time series. @@ -27,10 +21,21 @@ func (as AnnotationSeries) GetStyle() Style { } // GetYAxis returns which YAxis the series draws on. -func (as AnnotationSeries) GetYAxis() YAxisType { +func (as AnnotationSeries) GetYAxis() yAxisType { return as.YAxis } +func (as AnnotationSeries) annotationStyleDefaults(defaults Style) Style { + return Style{ + Font: defaults.Font, + FillColor: DefaultAnnotationFillColor, + FontSize: DefaultAnnotationFontSize, + StrokeColor: defaults.StrokeColor, + StrokeWidth: defaults.StrokeWidth, + Padding: DefaultAnnotationPadding, + } +} + // Measure returns a bounds box of the series. func (as AnnotationSeries) Measure(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) Box { box := Box{ @@ -40,22 +45,16 @@ func (as AnnotationSeries) Measure(r Renderer, canvasBox Box, xrange, yrange Ran Bottom: 0, } if as.Style.IsZero() || as.Style.Show { - style := as.Style.WithDefaultsFrom(Style{ - Font: defaults.Font, - FillColor: DefaultAnnotationFillColor, - FontSize: DefaultAnnotationFontSize, - StrokeColor: defaults.StrokeColor, - StrokeWidth: defaults.StrokeWidth, - Padding: DefaultAnnotationPadding, - }) + seriesStyle := as.Style.InheritFrom(as.annotationStyleDefaults(defaults)) for _, a := range as.Annotations { - lx := canvasBox.Left + xrange.Translate(a.X) - ly := canvasBox.Bottom - yrange.Translate(a.Y) - ab := MeasureAnnotation(r, canvasBox, style, lx, ly, a.Label) - box.Top = MinInt(box.Top, ab.Top) - box.Left = MinInt(box.Left, ab.Left) - box.Right = MaxInt(box.Right, ab.Right) - box.Bottom = MaxInt(box.Bottom, ab.Bottom) + style := a.Style.InheritFrom(seriesStyle) + lx := canvasBox.Left + xrange.Translate(a.XValue) + ly := canvasBox.Bottom - yrange.Translate(a.YValue) + ab := Draw.MeasureAnnotation(r, canvasBox, style, lx, ly, a.Label) + box.Top = Math.MinInt(box.Top, ab.Top) + box.Left = Math.MinInt(box.Left, ab.Left) + box.Right = Math.MaxInt(box.Right, ab.Right) + box.Bottom = Math.MaxInt(box.Bottom, ab.Bottom) } } return box @@ -64,19 +63,12 @@ func (as AnnotationSeries) Measure(r Renderer, canvasBox Box, xrange, yrange Ran // Render draws the series. func (as AnnotationSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { if as.Style.IsZero() || as.Style.Show { - style := as.Style.WithDefaultsFrom(Style{ - Font: defaults.Font, - FontColor: DefaultTextColor, - FillColor: DefaultAnnotationFillColor, - FontSize: DefaultAnnotationFontSize, - StrokeColor: defaults.StrokeColor, - StrokeWidth: defaults.StrokeWidth, - Padding: DefaultAnnotationPadding, - }) + seriesStyle := as.Style.InheritFrom(as.annotationStyleDefaults(defaults)) for _, a := range as.Annotations { - lx := canvasBox.Left + xrange.Translate(a.X) - ly := canvasBox.Bottom - yrange.Translate(a.Y) - DrawAnnotation(r, canvasBox, style, lx, ly, a.Label) + style := a.Style.InheritFrom(seriesStyle) + lx := canvasBox.Left + xrange.Translate(a.XValue) + ly := canvasBox.Bottom - yrange.Translate(a.YValue) + Draw.Annotation(r, canvasBox, style, lx, ly, a.Label) } } } diff --git a/annotation_series_test.go b/annotation_series_test.go index d25f4e9..4437f82 100644 --- a/annotation_series_test.go +++ b/annotation_series_test.go @@ -15,11 +15,11 @@ func TestAnnotationSeriesMeasure(t *testing.T) { Style: Style{ Show: true, }, - Annotations: []Annotation{ - {X: 1.0, Y: 1.0, Label: "1.0"}, - {X: 2.0, Y: 2.0, Label: "2.0"}, - {X: 3.0, Y: 3.0, Label: "3.0"}, - {X: 4.0, Y: 4.0, Label: "4.0"}, + Annotations: []Value2{ + {XValue: 1.0, YValue: 1.0, Label: "1.0"}, + {XValue: 2.0, YValue: 2.0, Label: "2.0"}, + {XValue: 3.0, YValue: 3.0, Label: "3.0"}, + {XValue: 4.0, YValue: 4.0, Label: "4.0"}, }, } @@ -29,12 +29,12 @@ func TestAnnotationSeriesMeasure(t *testing.T) { f, err := GetDefaultFont() assert.Nil(err) - xrange := Range{ + xrange := &ContinuousRange{ Min: 1.0, Max: 4.0, Domain: 100, } - yrange := Range{ + yrange := &ContinuousRange{ Min: 1.0, Max: 4.0, Domain: 100, @@ -68,11 +68,11 @@ func TestAnnotationSeriesRender(t *testing.T) { FillColor: drawing.ColorWhite, StrokeColor: drawing.ColorBlack, }, - Annotations: []Annotation{ - {X: 1.0, Y: 1.0, Label: "1.0"}, - {X: 2.0, Y: 2.0, Label: "2.0"}, - {X: 3.0, Y: 3.0, Label: "3.0"}, - {X: 4.0, Y: 4.0, Label: "4.0"}, + Annotations: []Value2{ + {XValue: 1.0, YValue: 1.0, Label: "1.0"}, + {XValue: 2.0, YValue: 2.0, Label: "2.0"}, + {XValue: 3.0, YValue: 3.0, Label: "3.0"}, + {XValue: 4.0, YValue: 4.0, Label: "4.0"}, }, } @@ -82,12 +82,12 @@ func TestAnnotationSeriesRender(t *testing.T) { f, err := GetDefaultFont() assert.Nil(err) - xrange := Range{ + xrange := &ContinuousRange{ Min: 1.0, Max: 4.0, Domain: 100, } - yrange := Range{ + yrange := &ContinuousRange{ Min: 1.0, Max: 4.0, Domain: 100, diff --git a/axis.go b/axis.go index f018df6..e607cea 100644 --- a/axis.go +++ b/axis.go @@ -1,20 +1,41 @@ package chart +type tickPosition int + +const ( + // TickPositionUnset means to use the default tick position. + TickPositionUnset tickPosition = 0 + // TickPositionBetweenTicks draws the labels for a tick between the previous and current tick. + TickPositionBetweenTicks tickPosition = 1 + // TickPositionUnderTick draws the tick below the tick. + TickPositionUnderTick tickPosition = 2 +) + // YAxisType is a type of y-axis; it can either be primary or secondary. -type YAxisType int +type yAxisType int const ( // YAxisPrimary is the primary axis. - YAxisPrimary YAxisType = 0 + YAxisPrimary yAxisType = 0 // YAxisSecondary is the secondary axis. - YAxisSecondary YAxisType = 1 + YAxisSecondary yAxisType = 1 ) // Axis is a chart feature detailing what values happen where. type Axis interface { GetName() string GetStyle() Style - GetTicks(r Renderer, ra Range, vf ValueFormatter) []Tick + + GetTicks() []Tick + GenerateTicks(r Renderer, ra Range, vf ValueFormatter) []Tick + + // GetGridLines returns the gridlines for the axis. GetGridLines(ticks []Tick) []GridLine - Render(c *Chart, r Renderer, canvasBox Box, ra Range, ticks []Tick) + + // Measure should return an absolute box for the axis. + // This is used when auto-fitting the canvas to the background. + Measure(r Renderer, canvasBox Box, ra Range, style Style, ticks []Tick) Box + + // Render renders the axis. + Render(r Renderer, canvasBox Box, ra Range, style Style, ticks []Tick) } diff --git a/bollinger_band_series.go b/bollinger_band_series.go index 739c0ad..6981d11 100644 --- a/bollinger_band_series.go +++ b/bollinger_band_series.go @@ -7,7 +7,7 @@ import "math" type BollingerBandsSeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType Period int K float64 @@ -27,7 +27,7 @@ func (bbs BollingerBandsSeries) GetStyle() Style { } // GetYAxis returns which YAxis the series draws on. -func (bbs BollingerBandsSeries) GetYAxis() YAxisType { +func (bbs BollingerBandsSeries) GetYAxis() yAxisType { return bbs.YAxis } @@ -108,13 +108,13 @@ func (bbs *BollingerBandsSeries) GetBoundedLastValue() (x, y1, y2 float64) { // Render renders the series. func (bbs *BollingerBandsSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - s := bbs.Style.WithDefaultsFrom(defaults.WithDefaultsFrom(Style{ + s := bbs.Style.InheritFrom(defaults.InheritFrom(Style{ StrokeWidth: 1.0, StrokeColor: DefaultAxisColor.WithAlpha(64), FillColor: DefaultAxisColor.WithAlpha(32), })) - DrawBoundedSeries(r, canvasBox, xrange, yrange, s, bbs, bbs.GetPeriod()) + Draw.BoundedSeries(r, canvasBox, xrange, yrange, s, bbs, bbs.GetPeriod()) } func (bbs BollingerBandsSeries) getAverage(valueBuffer *RingBuffer) float64 { diff --git a/bollinger_band_series_test.go b/bollinger_band_series_test.go index f1a6693..28d5564 100644 --- a/bollinger_band_series_test.go +++ b/bollinger_band_series_test.go @@ -11,8 +11,8 @@ func TestBollingerBandSeries(t *testing.T) { assert := assert.New(t) s1 := mockValueProvider{ - X: Seq(1.0, 100.0), - Y: SeqRand(100, 1024), + X: Sequence.Float64(1.0, 100.0), + Y: Sequence.Random(100, 1024), } bbs := &BollingerBandsSeries{ @@ -36,8 +36,8 @@ func TestBollingerBandLastValue(t *testing.T) { assert := assert.New(t) s1 := mockValueProvider{ - X: Seq(1.0, 100.0), - Y: Seq(1.0, 100.0), + X: Sequence.Float64(1.0, 100.0), + Y: Sequence.Float64(1.0, 100.0), } bbs := &BollingerBandsSeries{ diff --git a/box.go b/box.go index 3af4e79..0705749 100644 --- a/box.go +++ b/box.go @@ -66,12 +66,12 @@ func (b Box) GetBottom(defaults ...int) int { // Width returns the width func (b Box) Width() int { - return AbsInt(b.Right - b.Left) + return Math.AbsInt(b.Right - b.Left) } // Height returns the height func (b Box) Height() int { - return AbsInt(b.Bottom - b.Top) + return Math.AbsInt(b.Bottom - b.Top) } // Center returns the center of the box @@ -122,10 +122,10 @@ func (b Box) Equals(other Box) bool { // Grow grows a box based on another box. func (b Box) Grow(other Box) Box { return Box{ - Top: MinInt(b.Top, other.Top), - Left: MinInt(b.Left, other.Left), - Right: MaxInt(b.Right, other.Right), - Bottom: MaxInt(b.Bottom, other.Bottom), + Top: Math.MinInt(b.Top, other.Top), + Left: Math.MinInt(b.Left, other.Left), + Right: Math.MaxInt(b.Right, other.Right), + Bottom: Math.MaxInt(b.Bottom, other.Bottom), } } @@ -185,25 +185,12 @@ func (b Box) Fit(other Box) Box { // more literally like the opposite of grow. func (b Box) Constrain(other Box) Box { newBox := b.Clone() - if other.Top < b.Top { - delta := b.Top - other.Top - newBox.Top = other.Top + delta - } - if other.Left < b.Left { - delta := b.Left - other.Left - newBox.Left = other.Left + delta - } + newBox.Top = Math.MaxInt(newBox.Top, other.Top) + newBox.Left = Math.MaxInt(newBox.Left, other.Left) + newBox.Right = Math.MinInt(newBox.Right, other.Right) + newBox.Bottom = Math.MinInt(newBox.Bottom, other.Bottom) - if other.Right > b.Right { - delta := other.Right - b.Right - newBox.Right = other.Right - delta - } - - if other.Bottom > b.Bottom { - delta := other.Bottom - b.Bottom - newBox.Bottom = other.Bottom - delta - } return newBox } diff --git a/box_test.go b/box_test.go index 84787e8..fa95468 100644 --- a/box_test.go +++ b/box_test.go @@ -86,6 +86,26 @@ func TestBoxFit(t *testing.T) { assert.True(math.Abs(c.Aspect()-fac.Aspect()) < 0.02) } +func TestBoxConstrain(t *testing.T) { + assert := assert.New(t) + + a := Box{Top: 64, Left: 64, Right: 192, Bottom: 192} + b := Box{Top: 16, Left: 16, Right: 256, Bottom: 170} + c := Box{Top: 16, Left: 16, Right: 170, Bottom: 256} + + cab := a.Constrain(b) + assert.Equal(64, cab.Top) + assert.Equal(64, cab.Left) + assert.Equal(192, cab.Right) + assert.Equal(170, cab.Bottom) + + cac := a.Constrain(c) + assert.Equal(64, cac.Top) + assert.Equal(64, cac.Left) + assert.Equal(170, cac.Right) + assert.Equal(192, cac.Bottom) +} + func TestBoxOuterConstrain(t *testing.T) { assert := assert.New(t) diff --git a/chart.go b/chart.go index 1eb4bdf..67c1061 100644 --- a/chart.go +++ b/chart.go @@ -137,7 +137,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { var miny, maxy float64 = math.MaxFloat64, 0 var minya, maxya float64 = math.MaxFloat64, 0 - hasSecondaryAxis := false + seriesMappedToSecondaryAxis := false // note: a possible future optimization is to not scan the series values if // all axis are represented by either custom ticks or custom ranges. @@ -162,7 +162,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { minya = math.Min(minya, vy2) maxya = math.Max(maxya, vy1) maxya = math.Max(maxya, vy2) - hasSecondaryAxis = true + seriesMappedToSecondaryAxis = true } } } else if vp, isValueProvider := s.(ValueProvider); isValueProvider { @@ -179,27 +179,42 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } else if seriesAxis == YAxisSecondary { minya = math.Min(minya, vy) maxya = math.Max(maxya, vy) - hasSecondaryAxis = true + seriesMappedToSecondaryAxis = true } } } } } + if c.XAxis.Range == nil { + xrange = &ContinuousRange{} + } else { + xrange = c.XAxis.Range + } + + if c.YAxis.Range == nil { + yrange = &ContinuousRange{} + } else { + yrange = c.YAxis.Range + } + + if c.YAxisSecondary.Range == nil { + yrangeAlt = &ContinuousRange{} + } else { + yrangeAlt = c.YAxisSecondary.Range + } + if len(c.XAxis.Ticks) > 0 { tickMin, tickMax := math.MaxFloat64, 0.0 for _, t := range c.XAxis.Ticks { tickMin = math.Min(tickMin, t.Value) tickMax = math.Max(tickMax, t.Value) } - xrange.Min = tickMin - xrange.Max = tickMax - } else if !c.XAxis.Range.IsZero() { - xrange.Min = c.XAxis.Range.Min - xrange.Max = c.XAxis.Range.Max - } else { - xrange.Min = minx - xrange.Max = maxx + xrange.SetMin(tickMin) + xrange.SetMax(tickMax) + } else if xrange.IsZero() { + xrange.SetMin(minx) + xrange.SetMax(maxx) } if len(c.YAxis.Ticks) > 0 { @@ -208,15 +223,17 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { tickMin = math.Min(tickMin, t.Value) tickMax = math.Max(tickMax, t.Value) } - yrange.Min = tickMin - yrange.Max = tickMax - } else if !c.YAxis.Range.IsZero() { - yrange.Min = c.YAxis.Range.Min - yrange.Max = c.YAxis.Range.Max - } else { - yrange.Min = miny - yrange.Max = maxy - yrange.Min, yrange.Max = yrange.GetRoundedRangeBounds() + yrange.SetMin(tickMin) + yrange.SetMax(tickMax) + } else if yrange.IsZero() { + yrange.SetMin(miny) + yrange.SetMax(maxy) + + delta := yrange.GetDelta() + roundTo := Math.GetRoundToForDelta(delta) + rmin, rmax := Math.RoundDown(yrange.GetMin(), roundTo), Math.RoundUp(yrange.GetMax(), roundTo) + yrange.SetMin(rmin) + yrange.SetMax(rmax) } if len(c.YAxisSecondary.Ticks) > 0 { @@ -225,30 +242,31 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { tickMin = math.Min(tickMin, t.Value) tickMax = math.Max(tickMax, t.Value) } - yrangeAlt.Min = tickMin - yrangeAlt.Max = tickMax - } else if !c.YAxisSecondary.Range.IsZero() { - yrangeAlt.Min = c.YAxisSecondary.Range.Min - yrangeAlt.Max = c.YAxisSecondary.Range.Max - } else if hasSecondaryAxis { - yrangeAlt.Min = minya - yrangeAlt.Max = maxya - yrangeAlt.Min, yrangeAlt.Max = yrangeAlt.GetRoundedRangeBounds() + yrangeAlt.SetMin(tickMin) + yrangeAlt.SetMax(tickMax) + } else if seriesMappedToSecondaryAxis && yrangeAlt.IsZero() { + yrangeAlt.SetMin(minya) + yrangeAlt.SetMax(maxya) + + delta := yrangeAlt.GetDelta() + roundTo := Math.GetRoundToForDelta(delta) + rmin, rmax := Math.RoundDown(yrangeAlt.GetMin(), roundTo), Math.RoundUp(yrangeAlt.GetMax(), roundTo) + yrangeAlt.SetMin(rmin) + yrangeAlt.SetMax(rmax) } return } func (c Chart) checkRanges(xr, yr, yra Range) error { - - if math.IsInf(xr.Delta(), 0) || math.IsNaN(xr.Delta()) { + if math.IsInf(xr.GetDelta(), 0) || math.IsNaN(xr.GetDelta()) || xr.GetDelta() == 0 { return errors.New("Invalid (infinite or NaN) x-range delta") } - if math.IsInf(yr.Delta(), 0) || math.IsNaN(yr.Delta()) { + if math.IsInf(yr.GetDelta(), 0) || math.IsNaN(yr.GetDelta()) || yr.GetDelta() == 0 { return errors.New("Invalid (infinite or NaN) y-range delta") } if c.hasSecondarySeries() { - if math.IsInf(yra.Delta(), 0) || math.IsNaN(yra.Delta()) { + if math.IsInf(yra.GetDelta(), 0) || math.IsNaN(yra.GetDelta()) || yra.GetDelta() == 0 { return errors.New("Invalid (infinite or NaN) y-secondary-range delta") } } @@ -320,14 +338,11 @@ func (c Chart) getAxisAdjustedCanvasBox(r Renderer, canvasBox Box, xr, yr, yra R return canvasBox.OuterConstrain(c.Box(), axesOuterBox) } -func (c Chart) setRangeDomains(canvasBox Box, xr, yr, yra Range) (xr2, yr2, yra2 Range) { - xr2.Min, xr2.Max = xr.Min, xr.Max - xr2.Domain = canvasBox.Width() - yr2.Min, yr2.Max = yr.Min, yr.Max - yr2.Domain = canvasBox.Height() - yra2.Min, yra2.Max = yra.Min, yra.Max - yra2.Domain = canvasBox.Height() - return +func (c Chart) setRangeDomains(canvasBox Box, xr, yr, yra Range) (Range, Range, Range) { + xr.SetDomain(canvasBox.Width()) + yr.SetDomain(canvasBox.Height()) + yra.SetDomain(canvasBox.Height()) + return xr, yr, yra } func (c Chart) hasAnnotationSeries() bool { @@ -372,22 +387,22 @@ func (c Chart) getAnnotationAdjustedCanvasBox(r Renderer, canvasBox Box, xr, yr, } func (c Chart) getBackgroundStyle() Style { - return c.Background.WithDefaultsFrom(c.styleDefaultsBackground()) + return c.Background.InheritFrom(c.styleDefaultsBackground()) } func (c Chart) drawBackground(r Renderer) { - DrawBox(r, Box{ + Draw.Box(r, Box{ Right: c.GetWidth(), Bottom: c.GetHeight(), }, c.getBackgroundStyle()) } func (c Chart) getCanvasStyle() Style { - return c.Canvas.WithDefaultsFrom(c.styleDefaultsCanvas()) + return c.Canvas.InheritFrom(c.styleDefaultsCanvas()) } func (c Chart) drawCanvas(r Renderer, canvasBox Box) { - DrawBox(r, canvasBox, c.getCanvasStyle()) + Draw.Box(r, canvasBox, c.getCanvasStyle()) } func (c Chart) drawAxes(r Renderer, canvasBox Box, xrange, yrange, yrangeAlt Range, xticks, yticks, yticksAlt []Tick) { @@ -448,7 +463,7 @@ func (c Chart) styleDefaultsCanvas() Style { } func (c Chart) styleDefaultsSeries(seriesIndex int) Style { - strokeColor := GetDefaultSeriesStrokeColor(seriesIndex) + strokeColor := GetDefaultColor(seriesIndex) return Style{ StrokeColor: strokeColor, StrokeWidth: DefaultStrokeWidth, @@ -460,6 +475,7 @@ func (c Chart) styleDefaultsSeries(seriesIndex int) Style { func (c Chart) styleDefaultsAxis() Style { return Style{ Font: c.GetFont(), + FontColor: DefaultAxisColor, FontSize: DefaultAxisFontSize, StrokeColor: DefaultAxisColor, StrokeWidth: DefaultAxisLineWidth, diff --git a/chart_test.go b/chart_test.go index add643e..e313fdf 100644 --- a/chart_test.go +++ b/chart_test.go @@ -77,24 +77,24 @@ func TestChartGetRanges(t *testing.T) { } xrange, yrange, yrangeAlt := c.getRanges() - assert.Equal(-2.0, xrange.Min) - assert.Equal(5.0, xrange.Max) + assert.Equal(-2.0, xrange.GetMin()) + assert.Equal(5.0, xrange.GetMax()) - assert.Equal(-2.1, yrange.Min) - assert.Equal(4.5, yrange.Max) + assert.Equal(-2.1, yrange.GetMin()) + assert.Equal(4.5, yrange.GetMax()) - assert.Equal(10.0, yrangeAlt.Min) - assert.Equal(14.0, yrangeAlt.Max) + assert.Equal(10.0, yrangeAlt.GetMin()) + assert.Equal(14.0, yrangeAlt.GetMax()) cSet := Chart{ XAxis: XAxis{ - Range: Range{Min: 9.8, Max: 19.8}, + Range: &ContinuousRange{Min: 9.8, Max: 19.8}, }, YAxis: YAxis{ - Range: Range{Min: 9.9, Max: 19.9}, + Range: &ContinuousRange{Min: 9.9, Max: 19.9}, }, YAxisSecondary: YAxis{ - Range: Range{Min: 9.7, Max: 19.7}, + Range: &ContinuousRange{Min: 9.7, Max: 19.7}, }, Series: []Series{ ContinuousSeries{ @@ -114,14 +114,14 @@ func TestChartGetRanges(t *testing.T) { } xr2, yr2, yra2 := cSet.getRanges() - assert.Equal(9.8, xr2.Min) - assert.Equal(19.8, xr2.Max) + assert.Equal(9.8, xr2.GetMin()) + assert.Equal(19.8, xr2.GetMax()) - assert.Equal(9.9, yr2.Min) - assert.Equal(19.9, yr2.Max) + assert.Equal(9.9, yr2.GetMin()) + assert.Equal(19.9, yr2.GetMax()) - assert.Equal(9.7, yra2.Min) - assert.Equal(19.7, yra2.Max) + assert.Equal(9.7, yra2.GetMin()) + assert.Equal(19.7, yra2.GetMax()) } func TestChartGetRangesUseTicks(t *testing.T) { @@ -139,7 +139,7 @@ func TestChartGetRangesUseTicks(t *testing.T) { {4.0, "4.0"}, {5.0, "Five"}, }, - Range: Range{ + Range: &ContinuousRange{ Min: -5.0, Max: 5.0, }, @@ -153,10 +153,10 @@ func TestChartGetRangesUseTicks(t *testing.T) { } xr, yr, yar := c.getRanges() - assert.Equal(-2.0, xr.Min) - assert.Equal(2.0, xr.Max) - assert.Equal(0.0, yr.Min) - assert.Equal(5.0, yr.Max) + assert.Equal(-2.0, xr.GetMin()) + assert.Equal(2.0, xr.GetMax()) + assert.Equal(0.0, yr.GetMin()) + assert.Equal(5.0, yr.GetMax()) assert.True(yar.IsZero(), yar.String()) } @@ -165,7 +165,7 @@ func TestChartGetRangesUseUserRanges(t *testing.T) { c := Chart{ YAxis: YAxis{ - Range: Range{ + Range: &ContinuousRange{ Min: -5.0, Max: 5.0, }, @@ -179,10 +179,10 @@ func TestChartGetRangesUseUserRanges(t *testing.T) { } xr, yr, yar := c.getRanges() - assert.Equal(-2.0, xr.Min) - assert.Equal(2.0, xr.Max) - assert.Equal(-5.0, yr.Min) - assert.Equal(5.0, yr.Max) + assert.Equal(-2.0, xr.GetMin()) + assert.Equal(2.0, xr.GetMax()) + assert.Equal(-5.0, yr.GetMin()) + assert.Equal(5.0, yr.GetMax()) assert.True(yar.IsZero(), yar.String()) } @@ -310,15 +310,15 @@ func TestChartGetAxesTicks(t *testing.T) { c := Chart{ XAxis: XAxis{ Style: Style{Show: true}, - Range: Range{Min: 9.8, Max: 19.8}, + Range: &ContinuousRange{Min: 9.8, Max: 19.8}, }, YAxis: YAxis{ Style: Style{Show: true}, - Range: Range{Min: 9.9, Max: 19.9}, + Range: &ContinuousRange{Min: 9.9, Max: 19.9}, }, YAxisSecondary: YAxis{ Style: Style{Show: true}, - Range: Range{Min: 9.7, Max: 19.7}, + Range: &ContinuousRange{Min: 9.7, Max: 19.7}, }, } xr, yr, yar := c.getRanges() @@ -337,7 +337,7 @@ func TestChartSingleSeries(t *testing.T) { Width: 1024, Height: 400, YAxis: YAxis{ - Range: Range{ + Range: &ContinuousRange{ Min: 0.0, Max: 4.0, }, @@ -377,15 +377,15 @@ func TestChartRegressionBadRangesByUser(t *testing.T) { c := Chart{ YAxis: YAxis{ - Range: Range{ + Range: &ContinuousRange{ Min: math.Inf(-1), Max: math.Inf(1), // this could really happen? eh. }, }, Series: []Series{ ContinuousSeries{ - XValues: Seq(1.0, 10.0), - YValues: Seq(1.0, 10.0), + XValues: Sequence.Float64(1.0, 10.0), + YValues: Sequence.Float64(1.0, 10.0), }, }, } diff --git a/concat_series.go b/concat_series.go new file mode 100644 index 0000000..2c2098f --- /dev/null +++ b/concat_series.go @@ -0,0 +1,32 @@ +package chart + +// ConcatSeries is a special type of series that concatenates its `InnerSeries`. +type ConcatSeries []Series + +// Len returns the length of the concatenated set of series. +func (cs ConcatSeries) Len() int { + total := 0 + for _, s := range cs { + if typed, isValueProvider := s.(ValueProvider); isValueProvider { + total += typed.Len() + } + } + + return total +} + +// GetValue returns the value at the (meta) index (i.e 0 => totalLen-1) +func (cs ConcatSeries) GetValue(index int) (x, y float64) { + cursor := 0 + for _, s := range cs { + if typed, isValueProvider := s.(ValueProvider); isValueProvider { + len := typed.Len() + if index < cursor+len { + x, y = typed.GetValue(index - cursor) //FENCEPOSTS. + return + } + cursor += typed.Len() + } + } + return +} diff --git a/concat_series_test.go b/concat_series_test.go new file mode 100644 index 0000000..f72eb23 --- /dev/null +++ b/concat_series_test.go @@ -0,0 +1,41 @@ +package chart + +import ( + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestConcatSeries(t *testing.T) { + assert := assert.New(t) + + s1 := ContinuousSeries{ + XValues: Sequence.Float64(1.0, 10.0), + YValues: Sequence.Float64(1.0, 10.0), + } + + s2 := ContinuousSeries{ + XValues: Sequence.Float64(11, 20.0), + YValues: Sequence.Float64(10.0, 1.0), + } + + s3 := ContinuousSeries{ + XValues: Sequence.Float64(21, 30.0), + YValues: Sequence.Float64(1.0, 10.0), + } + + cs := ConcatSeries([]Series{s1, s2, s3}) + assert.Equal(30, cs.Len()) + + x0, y0 := cs.GetValue(0) + assert.Equal(1.0, x0) + assert.Equal(1.0, y0) + + xm, ym := cs.GetValue(19) + assert.Equal(20.0, xm) + assert.Equal(1.0, ym) + + xn, yn := cs.GetValue(29) + assert.Equal(30.0, xn) + assert.Equal(10.0, yn) +} diff --git a/continuous_range.go b/continuous_range.go new file mode 100644 index 0000000..d119649 --- /dev/null +++ b/continuous_range.go @@ -0,0 +1,67 @@ +package chart + +import ( + "fmt" + "math" +) + +// ContinuousRange represents a boundary for a set of numbers. +type ContinuousRange struct { + Min float64 + Max float64 + Domain int +} + +// IsZero returns if the ContinuousRange has been set or not. +func (r ContinuousRange) IsZero() bool { + return (r.Min == 0 || math.IsNaN(r.Min)) && + (r.Max == 0 || math.IsNaN(r.Max)) && + r.Domain == 0 +} + +// GetMin gets the min value for the continuous range. +func (r ContinuousRange) GetMin() float64 { + return r.Min +} + +// SetMin sets the min value for the continuous range. +func (r *ContinuousRange) SetMin(min float64) { + r.Min = min +} + +// GetMax returns the max value for the continuous range. +func (r ContinuousRange) GetMax() float64 { + return r.Max +} + +// SetMax sets the max value for the continuous range. +func (r *ContinuousRange) SetMax(max float64) { + r.Max = max +} + +// GetDelta returns the difference between the min and max value. +func (r ContinuousRange) GetDelta() float64 { + return r.Max - r.Min +} + +// GetDomain returns the range domain. +func (r ContinuousRange) GetDomain() int { + return r.Domain +} + +// SetDomain sets the range domain. +func (r *ContinuousRange) SetDomain(domain int) { + r.Domain = domain +} + +// String returns a simple string for the ContinuousRange. +func (r ContinuousRange) String() string { + return fmt.Sprintf("ContinuousRange [%.2f,%.2f] => %d", r.Min, r.Max, r.Domain) +} + +// Translate maps a given value into the ContinuousRange space. +func (r ContinuousRange) Translate(value float64) int { + normalized := value - r.Min + ratio := normalized / r.GetDelta() + return int(math.Ceil(ratio * float64(r.Domain))) +} diff --git a/range_test.go b/continuous_range_test.go similarity index 83% rename from range_test.go rename to continuous_range_test.go index f71f955..114ecbe 100644 --- a/range_test.go +++ b/continuous_range_test.go @@ -9,8 +9,8 @@ import ( func TestRangeTranslate(t *testing.T) { assert := assert.New(t) values := []float64{1.0, 2.0, 2.5, 2.7, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0} - r := Range{Domain: 1000} - r.Min, r.Max = MinAndMax(values...) + r := ContinuousRange{Domain: 1000} + r.Min, r.Max = Math.MinAndMax(values...) // delta = ~7.0 // value = ~5.0 diff --git a/continuous_series.go b/continuous_series.go index fe0581d..f3a00cf 100644 --- a/continuous_series.go +++ b/continuous_series.go @@ -5,7 +5,7 @@ type ContinuousSeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType XValues []float64 YValues []float64 @@ -44,12 +44,12 @@ func (cs ContinuousSeries) GetValueFormatters() (x, y ValueFormatter) { } // GetYAxis returns which YAxis the series draws on. -func (cs ContinuousSeries) GetYAxis() YAxisType { +func (cs ContinuousSeries) GetYAxis() yAxisType { return cs.YAxis } // Render renders the series. func (cs ContinuousSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - style := cs.Style.WithDefaultsFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, cs) + style := cs.Style.InheritFrom(defaults) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, cs) } diff --git a/continuous_series_test.go b/continuous_series_test.go new file mode 100644 index 0000000..171db37 --- /dev/null +++ b/continuous_series_test.go @@ -0,0 +1,31 @@ +package chart + +import ( + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestContinuousSeries(t *testing.T) { + assert := assert.New(t) + + cs := ContinuousSeries{ + Name: "Test Series", + XValues: Sequence.Float64(1.0, 10.0), + YValues: Sequence.Float64(1.0, 10.0), + } + + assert.Equal("Test Series", cs.GetName()) + assert.Equal(10, cs.Len()) + x0, y0 := cs.GetValue(0) + assert.Equal(1.0, x0) + assert.Equal(1.0, y0) + + xn, yn := cs.GetValue(9) + assert.Equal(10.0, xn) + assert.Equal(10.0, yn) + + xn, yn = cs.GetLastValue() + assert.Equal(10.0, xn) + assert.Equal(10.0, yn) +} diff --git a/date/util.go b/date/util.go new file mode 100644 index 0000000..48fb5a4 --- /dev/null +++ b/date/util.go @@ -0,0 +1,353 @@ +package date + +import ( + "sync" + "time" +) + +const ( + // AllDaysMask is a bitmask of all the days of the week. + AllDaysMask = 1<friday. +func IsWeekDay(day time.Weekday) bool { + return !IsWeekendDay(day) +} + +// IsWeekendDay returns if the day is a monday->friday. +func IsWeekendDay(day time.Weekday) bool { + return day == time.Saturday || day == time.Sunday +} + +// BeforeDate returns if a timestamp is strictly before another date (ignoring hours, minutes etc.) +func BeforeDate(before, reference time.Time) bool { + if before.Year() < reference.Year() { + return true + } + if before.Month() < reference.Month() { + return true + } + return before.Year() == reference.Year() && before.Month() == reference.Month() && before.Day() < reference.Day() +} + +// NextMarketOpen returns the next market open after a given time. +func NextMarketOpen(after, openTime time.Time, isHoliday HolidayProvider) time.Time { + afterEastern := after.In(Eastern()) + todaysOpen := On(openTime, afterEastern) + + if isHoliday == nil { + isHoliday = DefaultHolidayProvider + } + + if afterEastern.Before(todaysOpen) && IsWeekDay(todaysOpen.Weekday()) && !isHoliday(todaysOpen) { + return todaysOpen + } + + if afterEastern.Equal(todaysOpen) { //rare but it might happen. + return todaysOpen + } + + for cursorDay := 1; cursorDay < 6; cursorDay++ { + newDay := todaysOpen.AddDate(0, 0, cursorDay) + if IsWeekDay(newDay.Weekday()) && !isHoliday(afterEastern) { + return On(openTime, newDay) + } + } + return Epoch //we should never reach this. +} + +// NextMarketClose returns the next market close after a given time. +func NextMarketClose(after, closeTime time.Time, isHoliday HolidayProvider) time.Time { + afterEastern := after.In(Eastern()) + + if isHoliday == nil { + isHoliday = DefaultHolidayProvider + } + + todaysClose := On(closeTime, afterEastern) + if afterEastern.Before(todaysClose) && IsWeekDay(todaysClose.Weekday()) && !isHoliday(todaysClose) { + return todaysClose + } + + if afterEastern.Equal(todaysClose) { //rare but it might happen. + return todaysClose + } + + for cursorDay := 1; cursorDay < 6; cursorDay++ { + newDay := todaysClose.AddDate(0, 0, cursorDay) + if IsWeekDay(newDay.Weekday()) && !isHoliday(newDay) { + return On(closeTime, newDay) + } + } + return Epoch //we should never reach this. +} + +// CalculateMarketSecondsBetween calculates the number of seconds the market was open between two dates. +func CalculateMarketSecondsBetween(start, end, marketOpen, marketClose time.Time, isHoliday HolidayProvider) (seconds int64) { + startEastern := start.In(Eastern()) + endEastern := end.In(Eastern()) + + startMarketOpen := On(marketOpen, startEastern) + startMarketClose := On(marketClose, startEastern) + + if !IsWeekendDay(startMarketOpen.Weekday()) && !isHoliday(startMarketOpen) { + if (startEastern.Equal(startMarketOpen) || startEastern.After(startMarketOpen)) && startEastern.Before(startMarketClose) { + if endEastern.Before(startMarketClose) { + seconds += int64(endEastern.Sub(startEastern) / time.Second) + } else { + seconds += int64(startMarketClose.Sub(startEastern) / time.Second) + } + } + } + + cursor := NextMarketOpen(startMarketClose, marketOpen, isHoliday) + for BeforeDate(cursor, endEastern) { + if IsWeekDay(cursor.Weekday()) && !isHoliday(cursor) { + close := NextMarketClose(cursor, marketClose, isHoliday) + seconds += int64(close.Sub(cursor) / time.Second) + } + cursor = cursor.AddDate(0, 0, 1) + } + + finalMarketOpen := NextMarketOpen(cursor, marketOpen, isHoliday) + finalMarketClose := NextMarketClose(cursor, marketClose, isHoliday) + if endEastern.After(finalMarketOpen) { + if endEastern.Before(finalMarketClose) { + seconds += int64(endEastern.Sub(finalMarketOpen) / time.Second) + } else { + seconds += int64(finalMarketClose.Sub(finalMarketOpen) / time.Second) + } + } + + return +} + +// Format returns a string representation of a date. +func format(t time.Time) string { + return t.Format("2006-01-02") +} + +// Parse parses a date from a string. +func parse(str string) time.Time { + res, _ := time.Parse("2006-01-02", str) + return res +} diff --git a/date/util_test.go b/date/util_test.go new file mode 100644 index 0000000..8538053 --- /dev/null +++ b/date/util_test.go @@ -0,0 +1,98 @@ +package date + +import ( + "testing" + "time" + + assert "github.com/blendlabs/go-assert" +) + +func TestBeforeDate(t *testing.T) { + assert := assert.New(t) + + assert.True(BeforeDate(parse("2015-07-02"), parse("2016-07-01"))) + assert.True(BeforeDate(parse("2016-06-01"), parse("2016-07-01"))) + assert.True(BeforeDate(parse("2016-07-01"), parse("2016-07-02"))) + + assert.False(BeforeDate(parse("2016-07-01"), parse("2016-07-01"))) + assert.False(BeforeDate(parse("2016-07-03"), parse("2016-07-01"))) + assert.False(BeforeDate(parse("2016-08-03"), parse("2016-07-01"))) + assert.False(BeforeDate(parse("2017-08-03"), parse("2016-07-01"))) +} + +func TestNextMarketOpen(t *testing.T) { + assert := assert.New(t) + + beforeOpen := time.Date(2016, 07, 18, 9, 0, 0, 0, Eastern()) + todayOpen := time.Date(2016, 07, 18, 9, 30, 0, 0, Eastern()) + + afterOpen := time.Date(2016, 07, 18, 9, 31, 0, 0, Eastern()) + tomorrowOpen := time.Date(2016, 07, 19, 9, 30, 0, 0, Eastern()) + + afterFriday := time.Date(2016, 07, 22, 9, 31, 0, 0, Eastern()) + mondayOpen := time.Date(2016, 07, 25, 9, 30, 0, 0, Eastern()) + + weekend := time.Date(2016, 07, 23, 9, 31, 0, 0, Eastern()) + + assert.True(todayOpen.Equal(NextMarketOpen(beforeOpen, NYSEOpen, IsNYSEHoliday))) + assert.True(tomorrowOpen.Equal(NextMarketOpen(afterOpen, NYSEOpen, IsNYSEHoliday))) + assert.True(mondayOpen.Equal(NextMarketOpen(afterFriday, NYSEOpen, IsNYSEHoliday))) + assert.True(mondayOpen.Equal(NextMarketOpen(weekend, NYSEOpen, IsNYSEHoliday))) + + testRegression := time.Date(2016, 07, 18, 16, 0, 0, 0, Eastern()) + shouldbe := time.Date(2016, 07, 19, 9, 30, 0, 0, Eastern()) + + assert.True(shouldbe.Equal(NextMarketOpen(testRegression, NYSEOpen, IsNYSEHoliday))) +} + +func TestNextMarketClose(t *testing.T) { + assert := assert.New(t) + + beforeClose := time.Date(2016, 07, 18, 15, 0, 0, 0, Eastern()) + todayClose := time.Date(2016, 07, 18, 16, 00, 0, 0, Eastern()) + + afterClose := time.Date(2016, 07, 18, 16, 1, 0, 0, Eastern()) + tomorrowClose := time.Date(2016, 07, 19, 16, 00, 0, 0, Eastern()) + + afterFriday := time.Date(2016, 07, 22, 16, 1, 0, 0, Eastern()) + mondayClose := time.Date(2016, 07, 25, 16, 0, 0, 0, Eastern()) + + weekend := time.Date(2016, 07, 23, 9, 31, 0, 0, Eastern()) + + assert.True(todayClose.Equal(NextMarketClose(beforeClose, NYSEClose, IsNYSEHoliday))) + assert.True(tomorrowClose.Equal(NextMarketClose(afterClose, NYSEClose, IsNYSEHoliday))) + assert.True(mondayClose.Equal(NextMarketClose(afterFriday, NYSEClose, IsNYSEHoliday))) + assert.True(mondayClose.Equal(NextMarketClose(weekend, NYSEClose, IsNYSEHoliday))) +} + +func TestCalculateMarketSecondsBetween(t *testing.T) { + assert := assert.New(t) + + start := time.Date(2016, 07, 18, 9, 30, 0, 0, Eastern()) + end := time.Date(2016, 07, 22, 16, 00, 0, 0, Eastern()) + + shouldbe := 5 * 6.5 * 60 * 60 + + assert.Equal(shouldbe, CalculateMarketSecondsBetween(start, end, NYSEOpen, NYSEClose, IsNYSEHoliday)) +} + +func TestCalculateMarketSecondsBetween1D(t *testing.T) { + assert := assert.New(t) + + start := time.Date(2016, 07, 22, 9, 45, 0, 0, Eastern()) + end := time.Date(2016, 07, 22, 15, 45, 0, 0, Eastern()) + + shouldbe := 6 * 60 * 60 + + assert.Equal(shouldbe, CalculateMarketSecondsBetween(start, end, NYSEOpen, NYSEClose, IsNYSEHoliday)) +} + +func TestCalculateMarketSecondsBetweenLTM(t *testing.T) { + assert := assert.New(t) + + start := time.Date(2015, 07, 01, 9, 30, 0, 0, Eastern()) + end := time.Date(2016, 07, 01, 9, 30, 0, 0, Eastern()) + + shouldbe := 253 * 6.5 * 60 * 60 //253 full market days since this date last year. + assert.Equal(shouldbe, CalculateMarketSecondsBetween(start, end, NYSEOpen, NYSEClose, IsNYSEHoliday)) +} diff --git a/defaults.go b/defaults.go index fc69d0e..4c9fd1f 100644 --- a/defaults.go +++ b/defaults.go @@ -2,7 +2,6 @@ package chart import ( "sync" - "time" "github.com/golang/freetype/truetype" "github.com/wcharczuk/go-chart/drawing" @@ -34,6 +33,9 @@ const ( // DefaultTitleTop is the default distance from the top of the chart to put the title. DefaultTitleTop = 10 + // DefaultLineSpacing is the default vertical distance between lines of text. + DefaultLineSpacing = 5 + // DefaultYAxisMargin is the default distance from the right of the canvas to the y axis labels. DefaultYAxisMargin = 10 // DefaultXAxisMargin is the default distance from bottom of the canvas to the x axis labels. @@ -59,7 +61,7 @@ const ( // DefaultDateHourFormat is the date format for hour timestamp formats. DefaultDateHourFormat = "01-02 3PM" // DefaultDateMinuteFormat is the date format for minute range timestamp formats. - DefaultDateMinuteFormat = time.Kitchen + DefaultDateMinuteFormat = "01-02 3:04PM" // DefaultFloatFormat is the default float format. DefaultFloatFormat = "%.2f" // DefaultPercentValueFormat is the default percent format. @@ -67,42 +69,89 @@ const ( ) var ( - // DefaultBackgroundColor is the default chart background color. - // It is equivalent to css color:white. - DefaultBackgroundColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultBackgroundStrokeColor is the default chart border color. - // It is equivalent to color:white. - DefaultBackgroundStrokeColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultCanvasColor is the default chart canvas color. - // It is equivalent to css color:white. - DefaultCanvasColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultCanvasStrokeColor is the default chart canvas stroke color. - // It is equivalent to css color:white. - DefaultCanvasStrokeColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultTextColor is the default chart text color. - // It is equivalent to #333333. - DefaultTextColor = drawing.Color{R: 51, G: 51, B: 51, A: 255} - // DefaultAxisColor is the default chart axis line color. - // It is equivalent to #333333. - DefaultAxisColor = drawing.Color{R: 51, G: 51, B: 51, A: 255} - // DefaultStrokeColor is the default chart border color. - // It is equivalent to #efefef. - DefaultStrokeColor = drawing.Color{R: 239, G: 239, B: 239, A: 255} - // DefaultFillColor is the default fill color. - // It is equivalent to #0074d9. - DefaultFillColor = drawing.Color{R: 0, G: 217, B: 116, A: 255} - // DefaultAnnotationFillColor is the default annotation background color. - DefaultAnnotationFillColor = drawing.Color{R: 255, G: 255, B: 255, A: 255} - // DefaultGridLineColor is the default grid line color. - DefaultGridLineColor = drawing.Color{R: 239, G: 239, B: 239, A: 255} + // ColorWhite is white. + ColorWhite = drawing.Color{R: 255, G: 255, B: 255, A: 255} + // ColorBlue is the basic theme blue color. + ColorBlue = drawing.Color{R: 0, G: 116, B: 217, A: 255} + // ColorCyan is the basic theme cyan color. + ColorCyan = drawing.Color{R: 0, G: 217, B: 210, A: 255} + // ColorGreen is the basic theme green color. + ColorGreen = drawing.Color{R: 0, G: 217, B: 101, A: 255} + // ColorRed is the basic theme red color. + ColorRed = drawing.Color{R: 217, G: 0, B: 116, A: 255} + // ColorOrange is the basic theme orange color. + ColorOrange = drawing.Color{R: 217, G: 101, B: 0, A: 255} + // ColorYellow is the basic theme yellow color. + ColorYellow = drawing.Color{R: 217, G: 210, B: 0, A: 255} + // ColorBlack is the basic theme black color. + ColorBlack = drawing.Color{R: 51, G: 51, B: 51, A: 255} + // ColorLightGray is the basic theme light gray color. + ColorLightGray = drawing.Color{R: 239, G: 239, B: 239, A: 255} + + // ColorAlternateBlue is a alternate theme color. + ColorAlternateBlue = drawing.Color{R: 106, G: 195, B: 203, A: 255} + // ColorAlternateGreen is a alternate theme color. + ColorAlternateGreen = drawing.Color{R: 42, G: 190, B: 137, A: 255} + // ColorAlternateGray is a alternate theme color. + ColorAlternateGray = drawing.Color{R: 110, G: 128, B: 139, A: 255} + // ColorAlternateYellow is a alternate theme color. + ColorAlternateYellow = drawing.Color{R: 240, G: 174, B: 90, A: 255} + // ColorAlternateLightGray is a alternate theme color. + ColorAlternateLightGray = drawing.Color{R: 187, G: 190, B: 191, A: 255} ) var ( - // DefaultSeriesStrokeColors are a couple default series colors. - DefaultSeriesStrokeColors = []drawing.Color{ - {R: 0, G: 116, B: 217, A: 255}, - {R: 0, G: 217, B: 116, A: 255}, - {R: 217, G: 0, B: 116, A: 255}, + // DefaultBackgroundColor is the default chart background color. + // It is equivalent to css color:white. + DefaultBackgroundColor = ColorWhite + // DefaultBackgroundStrokeColor is the default chart border color. + // It is equivalent to color:white. + DefaultBackgroundStrokeColor = ColorWhite + // DefaultCanvasColor is the default chart canvas color. + // It is equivalent to css color:white. + DefaultCanvasColor = ColorWhite + // DefaultCanvasStrokeColor is the default chart canvas stroke color. + // It is equivalent to css color:white. + DefaultCanvasStrokeColor = ColorWhite + // DefaultTextColor is the default chart text color. + // It is equivalent to #333333. + DefaultTextColor = ColorBlack + // DefaultAxisColor is the default chart axis line color. + // It is equivalent to #333333. + DefaultAxisColor = ColorBlack + // DefaultStrokeColor is the default chart border color. + // It is equivalent to #efefef. + DefaultStrokeColor = ColorLightGray + // DefaultFillColor is the default fill color. + // It is equivalent to #0074d9. + DefaultFillColor = ColorBlue + // DefaultAnnotationFillColor is the default annotation background color. + DefaultAnnotationFillColor = ColorWhite + // DefaultGridLineColor is the default grid line color. + DefaultGridLineColor = ColorLightGray +) + +var ( + // DefaultColors are a couple default series colors. + DefaultColors = []drawing.Color{ + ColorBlue, + ColorGreen, + ColorRed, + ColorCyan, + ColorOrange, + } + + // DefaultAlternateColors are a couple alternate colors. + DefaultAlternateColors = []drawing.Color{ + ColorAlternateBlue, + ColorAlternateGreen, + ColorAlternateGray, + ColorAlternateYellow, + ColorBlue, + ColorGreen, + ColorRed, + ColorCyan, + ColorOrange, } ) @@ -117,11 +166,18 @@ var ( DashArrayDashesLarge = []int{10, 10} ) -// GetDefaultSeriesStrokeColor returns a color from the default list by index. -// NOTE: the index will wrap around (using a modulo).g -func GetDefaultSeriesStrokeColor(index int) drawing.Color { - finalIndex := index % len(DefaultSeriesStrokeColors) - return DefaultSeriesStrokeColors[finalIndex] +// GetDefaultColor returns a color from the default list by index. +// NOTE: the index will wrap around (using a modulo). +func GetDefaultColor(index int) drawing.Color { + finalIndex := index % len(DefaultColors) + return DefaultColors[finalIndex] +} + +// GetAlternateColor returns a color from the default list by index. +// NOTE: the index will wrap around (using a modulo). +func GetAlternateColor(index int) drawing.Color { + finalIndex := index % len(DefaultAlternateColors) + return DefaultAlternateColors[finalIndex] } var ( diff --git a/draw.go b/draw.go new file mode 100644 index 0000000..ed6039b --- /dev/null +++ b/draw.go @@ -0,0 +1,256 @@ +package chart + +import "math" + +var ( + // Draw contains helpers for drawing common objects. + Draw = &draw{} +) + +type draw struct{} + +// LineSeries draws a line series with a renderer. +func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, vs ValueProvider) { + if vs.Len() == 0 { + return + } + + cb := canvasBox.Bottom + cl := canvasBox.Left + + v0x, v0y := vs.GetValue(0) + x0 := cl + xrange.Translate(v0x) + y0 := cb - yrange.Translate(v0y) + + var vx, vy float64 + var x, y int + + fill := style.GetFillColor() + if !fill.IsZero() { + r.SetFillColor(fill) + 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.LineTo(x, cb) + r.LineTo(x0, cb) + r.Close() + r.Fill() + } + + style.GetStrokeOptions().WriteToRenderer(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.Stroke() +} + +// BoundedSeries draws a series that implements BoundedValueProvider. +func (d draw) BoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, bbs BoundedValueProvider, drawOffsetIndexes ...int) { + drawOffsetIndex := 0 + if len(drawOffsetIndexes) > 0 { + drawOffsetIndex = drawOffsetIndexes[0] + } + + cb := canvasBox.Bottom + cl := canvasBox.Left + + v0x, v0y1, v0y2 := bbs.GetBoundedValue(0) + x0 := cl + xrange.Translate(v0x) + y0 := cb - yrange.Translate(v0y1) + + var vx, vy1, vy2 float64 + var x, y int + + xvalues := make([]float64, bbs.Len()) + xvalues[0] = v0x + y2values := make([]float64, bbs.Len()) + y2values[0] = v0y2 + + style.GetFillAndStrokeOptions().WriteToRenderer(r) + r.MoveTo(x0, y0) + for i := 1; i < bbs.Len(); i++ { + vx, vy1, vy2 = bbs.GetBoundedValue(i) + + xvalues[i] = vx + y2values[i] = vy2 + + x = cl + xrange.Translate(vx) + y = cb - yrange.Translate(vy1) + if i > drawOffsetIndex { + r.LineTo(x, y) + } else { + r.MoveTo(x, y) + } + } + y = cb - yrange.Translate(vy2) + r.LineTo(x, y) + for i := bbs.Len() - 1; i >= drawOffsetIndex; i-- { + vx, vy2 = xvalues[i], y2values[i] + x = cl + xrange.Translate(vx) + y = cb - yrange.Translate(vy2) + r.LineTo(x, y) + } + r.Close() + r.FillStroke() +} + +// HistogramSeries draws a value provider as boxes from 0. +func (d draw) HistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, vs ValueProvider, barWidths ...int) { + if vs.Len() == 0 { + return + } + + //calculate bar width? + seriesLength := vs.Len() + barWidth := int(math.Floor(float64(xrange.GetDomain()) / float64(seriesLength))) + if len(barWidths) > 0 { + barWidth = barWidths[0] + } + + cb := canvasBox.Bottom + cl := canvasBox.Left + + //foreach datapoint, draw a box. + for index := 0; index < seriesLength; index++ { + vx, vy := vs.GetValue(index) + y0 := yrange.Translate(0) + x := cl + xrange.Translate(vx) + y := yrange.Translate(vy) + + d.Box(r, Box{ + Top: cb - y0, + Left: x - (barWidth >> 1), + Right: x + (barWidth >> 1), + Bottom: cb - y, + }, style) + } +} + +// MeasureAnnotation measures how big an annotation would be. +func (d draw) MeasureAnnotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) Box { + style.WriteToRenderer(r) + + textBox := r.MeasureText(label) + textWidth := textBox.Width() + textHeight := textBox.Height() + halfTextHeight := textHeight >> 1 + + pt := style.Padding.GetTop(DefaultAnnotationPadding.Top) + pl := style.Padding.GetLeft(DefaultAnnotationPadding.Left) + pr := style.Padding.GetRight(DefaultAnnotationPadding.Right) + pb := style.Padding.GetBottom(DefaultAnnotationPadding.Bottom) + + strokeWidth := style.GetStrokeWidth() + + top := ly - (pt + halfTextHeight) + right := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth + int(strokeWidth) + bottom := ly + (pb + halfTextHeight) + + return Box{ + Top: top, + Left: lx, + Right: right, + Bottom: bottom, + } +} + +// Annotation draws an anotation with a renderer. +func (d draw) Annotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) { + style.WriteToRenderer(r) + + textBox := r.MeasureText(label) + textWidth := textBox.Width() + halfTextHeight := textBox.Height() >> 1 + + pt := style.Padding.GetTop(DefaultAnnotationPadding.Top) + pl := style.Padding.GetLeft(DefaultAnnotationPadding.Left) + pr := style.Padding.GetRight(DefaultAnnotationPadding.Right) + pb := style.Padding.GetBottom(DefaultAnnotationPadding.Bottom) + + textX := lx + pl + DefaultAnnotationDeltaWidth + textY := ly + halfTextHeight + + ltx := lx + DefaultAnnotationDeltaWidth + lty := ly - (pt + halfTextHeight) + + rtx := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth + rty := ly - (pt + halfTextHeight) + + rbx := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth + rby := ly + (pb + halfTextHeight) + + lbx := lx + DefaultAnnotationDeltaWidth + lby := ly + (pb + halfTextHeight) + + r.MoveTo(lx, ly) + r.LineTo(ltx, lty) + r.LineTo(rtx, rty) + r.LineTo(rbx, rby) + r.LineTo(lbx, lby) + r.LineTo(lx, ly) + r.Close() + r.FillStroke() + + r.Text(label, textX, textY) +} + +// Box draws a box with a given style. +func (d draw) Box(r Renderer, b Box, s Style) { + s.WriteToRenderer(r) + + r.MoveTo(b.Left, b.Top) + r.LineTo(b.Right, b.Top) + r.LineTo(b.Right, b.Bottom) + r.LineTo(b.Left, b.Bottom) + r.LineTo(b.Left, b.Top) + r.FillStroke() +} + +// DrawText draws text with a given style. +func (d draw) Text(r Renderer, text string, x, y int, style Style) { + style.GetTextOptions().WriteToRenderer(r) + r.Text(text, x, y) +} + +// TextWithin draws the text within a given box. +func (d draw) TextWithin(r Renderer, text string, box Box, style Style) { + lines := Text.WrapFit(r, text, box.Width(), style) + linesBox := Text.MeasureLines(r, lines, style) + + style.GetTextOptions().WriteToRenderer(r) + + y := box.Top + + switch style.GetTextVerticalAlign() { + case TextVerticalAlignBottom, TextVerticalAlignBaseline: // i have to build better baseline handling into measure text + y = y - linesBox.Height() + case TextVerticalAlignMiddle, TextVerticalAlignMiddleBaseline: + y = (y - linesBox.Height()) >> 1 + } + + var tx, ty int + for _, line := range lines { + lineBox := r.MeasureText(line) + switch style.GetTextHorizontalAlign() { + case TextHorizontalAlignCenter: + tx = box.Left + ((box.Width() - lineBox.Width()) >> 1) + case TextHorizontalAlignRight: + tx = box.Right - lineBox.Width() + default: + tx = box.Left + } + ty = y + lineBox.Height() + + d.Text(r, line, tx, ty, style) + y += lineBox.Height() + style.GetTextLineSpacing() + } +} diff --git a/drawing/path.go b/drawing/path.go index 979a0d5..20f2d2e 100644 --- a/drawing/path.go +++ b/drawing/path.go @@ -101,10 +101,10 @@ func (p *Path) CubicCurveTo(cx1, cy1, cx2, cy2, x, y float64) { } // ArcTo adds an arc to the path -func (p *Path) ArcTo(cx, cy, rx, ry, startAngle, angle float64) { - endAngle := startAngle + angle +func (p *Path) ArcTo(cx, cy, rx, ry, startAngle, delta float64) { + endAngle := startAngle + delta clockWise := true - if angle < 0 { + if delta < 0 { clockWise = false } // normalize @@ -124,7 +124,7 @@ func (p *Path) ArcTo(cx, cy, rx, ry, startAngle, angle float64) { } else { p.MoveTo(startX, startY) } - p.appendToPath(ArcToComponent, cx, cy, rx, ry, startAngle, angle) + p.appendToPath(ArcToComponent, cx, cy, rx, ry, startAngle, delta) p.x = cx + math.Cos(endAngle)*rx p.y = cy + math.Sin(endAngle)*ry } diff --git a/drawing/stack_graphic_context.go b/drawing/stack_graphic_context.go index d353438..c3243c9 100644 --- a/drawing/stack_graphic_context.go +++ b/drawing/stack_graphic_context.go @@ -171,8 +171,8 @@ func (gc *StackGraphicContext) CubicCurveTo(cx1, cy1, cx2, cy2, x, y float64) { } // ArcTo draws an arc. -func (gc *StackGraphicContext) ArcTo(cx, cy, rx, ry, startAngle, angle float64) { - gc.current.Path.ArcTo(cx, cy, rx, ry, startAngle, angle) +func (gc *StackGraphicContext) ArcTo(cx, cy, rx, ry, startAngle, delta float64) { + gc.current.Path.ArcTo(cx, cy, rx, ry, startAngle, delta) } // Close closes a path. diff --git a/drawing/text.go b/drawing/text.go index 52f6349..e1b40f2 100644 --- a/drawing/text.go +++ b/drawing/text.go @@ -1,6 +1,3 @@ -// Copyright 2010 The draw2d Authors. All rights reserved. -// created: 13/12/2010 by Laurent Le Goff - package drawing import ( diff --git a/drawing_helpers.go b/drawing_helpers.go deleted file mode 100644 index 276e3bd..0000000 --- a/drawing_helpers.go +++ /dev/null @@ -1,372 +0,0 @@ -package chart - -import ( - "math" - - "github.com/wcharczuk/go-chart/drawing" -) - -// DrawLineSeries draws a line series with a renderer. -func DrawLineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs ValueProvider) { - if vs.Len() == 0 { - return - } - - cb := canvasBox.Bottom - cl := canvasBox.Left - - v0x, v0y := vs.GetValue(0) - x0 := cl + xrange.Translate(v0x) - y0 := cb - yrange.Translate(v0y) - - var vx, vy float64 - var x, y int - - fill := s.GetFillColor() - if !fill.IsZero() { - r.SetFillColor(fill) - 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.LineTo(x, cb) - r.LineTo(x0, cb) - r.Close() - r.Fill() - } - - r.SetStrokeColor(s.GetStrokeColor()) - r.SetStrokeDashArray(s.GetStrokeDashArray()) - r.SetStrokeWidth(s.GetStrokeWidth(DefaultStrokeWidth)) - - 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() -} - -// DrawBoundedSeries draws a series that implements BoundedValueProvider. -func DrawBoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, bbs BoundedValueProvider, drawOffsetIndexes ...int) { - drawOffsetIndex := 0 - if len(drawOffsetIndexes) > 0 { - drawOffsetIndex = drawOffsetIndexes[0] - } - - r.SetStrokeColor(s.GetStrokeColor()) - r.SetStrokeDashArray(s.GetStrokeDashArray()) - r.SetStrokeWidth(s.GetStrokeWidth()) - r.SetFillColor(s.GetFillColor()) - - cb := canvasBox.Bottom - cl := canvasBox.Left - - v0x, v0y1, v0y2 := bbs.GetBoundedValue(0) - x0 := cl + xrange.Translate(v0x) - y0 := cb - yrange.Translate(v0y1) - - var vx, vy1, vy2 float64 - var x, y int - - xvalues := make([]float64, bbs.Len()) - xvalues[0] = v0x - y2values := make([]float64, bbs.Len()) - y2values[0] = v0y2 - - r.MoveTo(x0, y0) - for i := 1; i < bbs.Len(); i++ { - vx, vy1, vy2 = bbs.GetBoundedValue(i) - - xvalues[i] = vx - y2values[i] = vy2 - - x = cl + xrange.Translate(vx) - y = cb - yrange.Translate(vy1) - if i > drawOffsetIndex { - r.LineTo(x, y) - } else { - r.MoveTo(x, y) - } - } - y = cb - yrange.Translate(vy2) - r.LineTo(x, y) - for i := bbs.Len() - 1; i >= drawOffsetIndex; i-- { - vx, vy2 = xvalues[i], y2values[i] - x = cl + xrange.Translate(vx) - y = cb - yrange.Translate(vy2) - r.LineTo(x, y) - } - r.Close() - r.FillStroke() -} - -// DrawHistogramSeries draws a value provider as boxes from 0. -func DrawHistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs ValueProvider, barWidths ...int) { - if vs.Len() == 0 { - return - } - - //calculate bar width? - seriesLength := vs.Len() - barWidth := int(math.Floor(float64(xrange.Domain) / float64(seriesLength))) - if len(barWidths) > 0 { - barWidth = barWidths[0] - } - - cb := canvasBox.Bottom - cl := canvasBox.Left - - //foreach datapoint, draw a box. - for index := 0; index < seriesLength; index++ { - vx, vy := vs.GetValue(index) - y0 := yrange.Translate(0) - x := cl + xrange.Translate(vx) - y := yrange.Translate(vy) - - DrawBox(r, Box{ - Top: cb - y0, - Left: x - (barWidth >> 1), - Right: x + (barWidth >> 1), - Bottom: cb - y, - }, s) - } -} - -// MeasureAnnotation measures how big an annotation would be. -func MeasureAnnotation(r Renderer, canvasBox Box, s Style, lx, ly int, label string) Box { - r.SetFillColor(s.GetFillColor(DefaultAnnotationFillColor)) - r.SetStrokeColor(s.GetStrokeColor()) - r.SetStrokeWidth(s.GetStrokeWidth()) - r.SetFont(s.GetFont()) - r.SetFontColor(s.GetFontColor(DefaultTextColor)) - r.SetFontSize(s.GetFontSize(DefaultAnnotationFontSize)) - - textBox := r.MeasureText(label) - textWidth := textBox.Width() - textHeight := textBox.Height() - halfTextHeight := textHeight >> 1 - - pt := s.Padding.GetTop(DefaultAnnotationPadding.Top) - pl := s.Padding.GetLeft(DefaultAnnotationPadding.Left) - pr := s.Padding.GetRight(DefaultAnnotationPadding.Right) - pb := s.Padding.GetBottom(DefaultAnnotationPadding.Bottom) - - strokeWidth := s.GetStrokeWidth() - - top := ly - (pt + halfTextHeight) - right := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth + int(strokeWidth) - bottom := ly + (pb + halfTextHeight) - - return Box{ - Top: top, - Left: lx, - Right: right, - Bottom: bottom, - } -} - -// DrawAnnotation draws an anotation with a renderer. -func DrawAnnotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) { - r.SetFillColor(style.GetFillColor()) - r.SetStrokeColor(style.GetStrokeColor()) - r.SetStrokeWidth(style.GetStrokeWidth()) - r.SetStrokeDashArray(style.GetStrokeDashArray()) - - r.SetFont(style.GetFont()) - r.SetFontColor(style.GetFontColor(DefaultTextColor)) - r.SetFontSize(style.GetFontSize(DefaultAnnotationFontSize)) - - textBox := r.MeasureText(label) - textWidth := textBox.Width() - halfTextHeight := textBox.Height() >> 1 - - pt := style.Padding.GetTop(DefaultAnnotationPadding.Top) - pl := style.Padding.GetLeft(DefaultAnnotationPadding.Left) - pr := style.Padding.GetRight(DefaultAnnotationPadding.Right) - pb := style.Padding.GetBottom(DefaultAnnotationPadding.Bottom) - - textX := lx + pl + DefaultAnnotationDeltaWidth - textY := ly + halfTextHeight - - ltx := lx + DefaultAnnotationDeltaWidth - lty := ly - (pt + halfTextHeight) - - rtx := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth - rty := ly - (pt + halfTextHeight) - - rbx := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth - rby := ly + (pb + halfTextHeight) - - lbx := lx + DefaultAnnotationDeltaWidth - lby := ly + (pb + halfTextHeight) - - r.MoveTo(lx, ly) - r.LineTo(ltx, lty) - r.LineTo(rtx, rty) - r.LineTo(rbx, rby) - r.LineTo(lbx, lby) - r.LineTo(lx, ly) - r.Close() - r.FillStroke() - - r.Text(label, textX, textY) -} - -// DrawBox draws a box with a given style. -func DrawBox(r Renderer, b Box, s Style) { - r.SetFillColor(s.GetFillColor()) - r.SetStrokeColor(s.GetStrokeColor()) - r.SetStrokeWidth(s.GetStrokeWidth(DefaultStrokeWidth)) - r.SetStrokeDashArray(s.GetStrokeDashArray()) - - r.MoveTo(b.Left, b.Top) - r.LineTo(b.Right, b.Top) - r.LineTo(b.Right, b.Bottom) - r.LineTo(b.Left, b.Bottom) - r.LineTo(b.Left, b.Top) - r.FillStroke() -} - -// DrawText draws text with a given style. -func DrawText(r Renderer, text string, x, y int, s Style) { - r.SetFontColor(s.GetFontColor(DefaultTextColor)) - r.SetStrokeColor(s.GetStrokeColor()) - r.SetStrokeWidth(s.GetStrokeWidth()) - r.SetFont(s.GetFont()) - r.SetFontSize(s.GetFontSize()) - - r.Text(text, x, y) -} - -// DrawTextCentered draws text with a given style centered. -func DrawTextCentered(r Renderer, text string, x, y int, s Style) { - r.SetFontColor(s.GetFontColor(DefaultTextColor)) - r.SetStrokeColor(s.GetStrokeColor()) - r.SetStrokeWidth(s.GetStrokeWidth()) - r.SetFont(s.GetFont()) - r.SetFontSize(s.GetFontSize()) - - tb := r.MeasureText(text) - tx := x - (tb.Width() >> 1) - ty := y - (tb.Height() >> 1) - r.Text(text, tx, ty) -} - -// CreateLegend returns a legend renderable function. -func CreateLegend(c *Chart, userDefaults ...Style) Renderable { - return func(r Renderer, cb Box, chartDefaults Style) { - legendDefaults := Style{ - FillColor: drawing.ColorWhite, - FontColor: DefaultTextColor, - FontSize: 8.0, - StrokeColor: DefaultAxisColor, - StrokeWidth: DefaultAxisLineWidth, - } - - var legendStyle Style - if len(userDefaults) > 0 { - legendStyle = userDefaults[0].WithDefaultsFrom(chartDefaults.WithDefaultsFrom(legendDefaults)) - } else { - legendStyle = chartDefaults.WithDefaultsFrom(legendDefaults) - } - - // DEFAULTS - legendPadding := Box{ - Top: 5, - Left: 5, - Right: 5, - Bottom: 5, - } - lineTextGap := 5 - lineLengthMinimum := 25 - - var labels []string - var lines []Style - for index, s := range c.Series { - if s.GetStyle().IsZero() || s.GetStyle().Show { - if _, isAnnotationSeries := s.(AnnotationSeries); !isAnnotationSeries { - labels = append(labels, s.GetName()) - lines = append(lines, s.GetStyle().WithDefaultsFrom(c.styleDefaultsSeries(index))) - } - } - } - - legend := Box{ - Top: cb.Top, - Left: cb.Left, - // bottom and right will be sized by the legend content + relevant padding. - } - - legendContent := Box{ - Top: legend.Top + legendPadding.Top, - Left: legend.Left + legendPadding.Left, - Right: legend.Left + legendPadding.Left, - Bottom: legend.Top + legendPadding.Top, - } - - r.SetFont(legendStyle.GetFont()) - r.SetFontColor(legendStyle.GetFontColor()) - r.SetFontSize(legendStyle.GetFontSize()) - - // measure - labelCount := 0 - for x := 0; x < len(labels); x++ { - if len(labels[x]) > 0 { - tb := r.MeasureText(labels[x]) - if labelCount > 0 { - legendContent.Bottom += DefaultMinimumTickVerticalSpacing - } - legendContent.Bottom += tb.Height() - right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum - legendContent.Right = MaxInt(legendContent.Right, right) - labelCount++ - } - } - - legend = legend.Grow(legendContent) - legend.Right = legendContent.Right + legendPadding.Right - legend.Bottom = legendContent.Bottom + legendPadding.Bottom - - DrawBox(r, legend, legendStyle) - - ycursor := legendContent.Top - tx := legendContent.Left - legendCount := 0 - for x := 0; x < len(labels); x++ { - if len(labels[x]) > 0 { - - if legendCount > 0 { - ycursor += DefaultMinimumTickVerticalSpacing - } - - tb := r.MeasureText(labels[x]) - - ty := ycursor + tb.Height() - r.Text(labels[x], tx, ty) - - th2 := tb.Height() >> 1 - - lx := tx + tb.Width() + lineTextGap - ly := ty - th2 - lx2 := legendContent.Right - legendPadding.Right - - r.SetStrokeColor(lines[x].GetStrokeColor()) - r.SetStrokeWidth(lines[x].GetStrokeWidth()) - r.SetStrokeDashArray(lines[x].GetStrokeDashArray()) - - r.MoveTo(lx, ly) - r.LineTo(lx2, ly) - r.Stroke() - - ycursor += tb.Height() - legendCount++ - } - } - } -} diff --git a/ema_series.go b/ema_series.go index 2c0bc64..991359f 100644 --- a/ema_series.go +++ b/ema_series.go @@ -9,7 +9,7 @@ const ( type EMASeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType Period int InnerSeries ValueProvider @@ -28,7 +28,7 @@ func (ema EMASeries) GetStyle() Style { } // GetYAxis returns which YAxis the series draws on. -func (ema EMASeries) GetYAxis() YAxisType { +func (ema EMASeries) GetYAxis() yAxisType { return ema.YAxis } @@ -96,6 +96,6 @@ func (ema *EMASeries) ensureCachedValues() { // Render renders the series. func (ema *EMASeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - style := ema.Style.WithDefaultsFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, ema) + style := ema.Style.InheritFrom(defaults) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, ema) } diff --git a/ema_series_test.go b/ema_series_test.go index 42025da..ad74d72 100644 --- a/ema_series_test.go +++ b/ema_series_test.go @@ -7,7 +7,7 @@ import ( ) var ( - emaXValues = Seq(1.0, 50.0) + emaXValues = Sequence.Float64(1.0, 50.0) emaYValues = []float64{ 1, 2, 3, 4, 5, 4, 3, 2, 1, 2, 3, 4, 5, 4, 3, 2, diff --git a/examples/annotations/main.go b/examples/annotations/main.go index 74242e4..84220b1 100644 --- a/examples/annotations/main.go +++ b/examples/annotations/main.go @@ -26,12 +26,12 @@ func drawChart(res http.ResponseWriter, req *http.Request) { YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, }, chart.AnnotationSeries{ - Annotations: []chart.Annotation{ - {X: 1.0, Y: 1.0, Label: "One"}, - {X: 2.0, Y: 2.0, Label: "Two"}, - {X: 3.0, Y: 3.0, Label: "Three"}, - {X: 4.0, Y: 4.0, Label: "Four"}, - {X: 5.0, Y: 5.0, Label: "Five"}, + Annotations: []chart.Value2{ + {XValue: 1.0, YValue: 1.0, Label: "One"}, + {XValue: 2.0, YValue: 2.0, Label: "Two"}, + {XValue: 3.0, YValue: 3.0, Label: "Three"}, + {XValue: 4.0, YValue: 4.0, Label: "Four"}, + {XValue: 5.0, YValue: 5.0, Label: "Five"}, }, }, }, diff --git a/examples/axes/main.go b/examples/axes/main.go index 325a632..ee0a88c 100644 --- a/examples/axes/main.go +++ b/examples/axes/main.go @@ -28,8 +28,8 @@ func drawChart(res http.ResponseWriter, req *http.Request) { chart.ContinuousSeries{ Style: chart.Style{ Show: true, - StrokeColor: chart.GetDefaultSeriesStrokeColor(0).WithAlpha(64), - FillColor: chart.GetDefaultSeriesStrokeColor(0).WithAlpha(64), + StrokeColor: chart.GetDefaultColor(0).WithAlpha(64), + FillColor: chart.GetDefaultColor(0).WithAlpha(64), }, XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, diff --git a/examples/custom_padding/main.go b/examples/custom_padding/main.go index 8fff64e..199a8e7 100644 --- a/examples/custom_padding/main.go +++ b/examples/custom_padding/main.go @@ -30,8 +30,8 @@ func drawChart(res http.ResponseWriter, req *http.Request) { }, Series: []chart.Series{ chart.ContinuousSeries{ - XValues: chart.Seq(1.0, 100.0), - YValues: chart.SeqRand(100.0, 256.0), + XValues: chart.Sequence.Float64(1.0, 100.0), + YValues: chart.Sequence.Random(100.0, 256.0), }, }, } @@ -57,8 +57,8 @@ func drawChartDefault(res http.ResponseWriter, req *http.Request) { }, Series: []chart.Series{ chart.ContinuousSeries{ - XValues: chart.Seq(1.0, 100.0), - YValues: chart.SeqRand(100.0, 256.0), + XValues: chart.Sequence.Float64(1.0, 100.0), + YValues: chart.Sequence.Random(100.0, 256.0), }, }, } diff --git a/examples/custom_ranges/main.go b/examples/custom_ranges/main.go index b6a6657..4529684 100644 --- a/examples/custom_ranges/main.go +++ b/examples/custom_ranges/main.go @@ -17,7 +17,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { Style: chart.Style{ Show: true, }, - Range: chart.Range{ + Range: &chart.ContinuousRange{ Min: 0.0, Max: 10.0, }, diff --git a/examples/custom_ticks/main.go b/examples/custom_ticks/main.go index 34bb712..21b9d31 100644 --- a/examples/custom_ticks/main.go +++ b/examples/custom_ticks/main.go @@ -17,7 +17,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { Style: chart.Style{ Show: true, }, - Range: chart.Range{ + Range: &chart.ContinuousRange{ Min: 0.0, Max: 4.0, }, diff --git a/examples/legend/main.go b/examples/legend/main.go index edad885..41cff72 100644 --- a/examples/legend/main.go +++ b/examples/legend/main.go @@ -38,7 +38,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { //note we have to do this as a separate step because we need a reference to graph graph.Elements = []chart.Renderable{ - chart.CreateLegend(&graph), + chart.Legend(&graph), } res.Header().Set("Content-Type", "image/png") diff --git a/examples/linear_regression/main.go b/examples/linear_regression/main.go new file mode 100644 index 0000000..c397ca9 --- /dev/null +++ b/examples/linear_regression/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + + /* + In this example we add a new type of series, a `SimpleMovingAverageSeries` that takes another series as a required argument. + InnerSeries only needs to implement `ValueProvider`, so really you could chain `SimpleMovingAverageSeries` together if you wanted. + */ + + mainSeries := chart.ContinuousSeries{ + Name: "A test series", + XValues: chart.Sequence.Float64(1.0, 100.0), //generates a []float64 from 1.0 to 100.0 in 1.0 step increments, or 100 elements. + YValues: chart.Sequence.Random(100, 100), //generates a []float64 randomly from 0 to 100 with 100 elements. + } + + // note we create a LinearRegressionSeries series by assignin the inner series. + // we need to use a reference because `.Render()` needs to modify state within the series. + linRegSeries := &chart.LinearRegressionSeries{ + InnerSeries: mainSeries, + } // we can optionally set the `WindowSize` property which alters how the moving average is calculated. + + graph := chart.Chart{ + Series: []chart.Series{ + mainSeries, + linRegSeries, + }, + } + + res.Header().Set("Content-Type", "image/png") + graph.Render(chart.PNG, res) +} + +func main() { + http.HandleFunc("/", drawChart) + http.ListenAndServe(":8080", nil) +} diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go new file mode 100644 index 0000000..782bac1 --- /dev/null +++ b/examples/pie_chart/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + pie := chart.PieChart{ + Title: "test\nchart", + TitleStyle: chart.Style{ + Show: true, + }, + 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: "Test"}, + {Value: 3, Label: "??"}, + {Value: 1, Label: "!!"}, + }, + } + + 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 main() { + http.HandleFunc("/", drawChart) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/examples/simple_moving_average/main.go b/examples/simple_moving_average/main.go index e5da665..216599c 100644 --- a/examples/simple_moving_average/main.go +++ b/examples/simple_moving_average/main.go @@ -15,8 +15,8 @@ func drawChart(res http.ResponseWriter, req *http.Request) { mainSeries := chart.ContinuousSeries{ Name: "A test series", - XValues: chart.Seq(1.0, 100.0), //generates a []float64 from 1.0 to 100.0 in 1.0 step increments, or 100 elements. - YValues: chart.SeqRand(100, 100), //generates a []float64 randomly from 0 to 100 with 100 elements. + XValues: chart.Sequence.Float64(1.0, 100.0), //generates a []float64 from 1.0 to 100.0 in 1.0 step increments, or 100 elements. + YValues: chart.Sequence.Random(100, 100), //generates a []float64 randomly from 0 to 100 with 100 elements. } // note we create a SimpleMovingAverage series by assignin the inner series. diff --git a/examples/stacked_bar/main.go b/examples/stacked_bar/main.go new file mode 100644 index 0000000..07304bc --- /dev/null +++ b/examples/stacked_bar/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + sbc := chart.StackedBarChart{ + Height: 512, + XAxis: chart.Style{ + Show: true, + }, + YAxis: chart.Style{ + Show: true, + }, + Bars: []chart.StackedBar{ + { + Name: "This is a very long string to test word break wrapping.", + Values: []chart.Value{ + {Value: 5, Label: "Blue"}, + {Value: 5, Label: "Green"}, + {Value: 4, Label: "Gray"}, + {Value: 3, Label: "Orange"}, + {Value: 3, Label: "Test"}, + {Value: 2, Label: "??"}, + {Value: 1, Label: "!!"}, + }, + }, + { + Name: "Test", + Values: []chart.Value{ + {Value: 10, Label: "Blue"}, + {Value: 5, Label: "Green"}, + {Value: 1, Label: "Gray"}, + }, + }, + { + Name: "Test 2", + Values: []chart.Value{ + {Value: 10, Label: "Blue"}, + {Value: 6, Label: "Green"}, + {Value: 4, Label: "Gray"}, + }, + }, + }, + } + + res.Header().Set("Content-Type", "image/png") + err := sbc.Render(chart.PNG, res) + if err != nil { + fmt.Printf("Error rendering chart: %v\n", err) + } +} + +func main() { + http.HandleFunc("/", drawChart) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/examples/stock_analysis/main.go b/examples/stock_analysis/main.go index 89fc527..3f17025 100644 --- a/examples/stock_analysis/main.go +++ b/examples/stock_analysis/main.go @@ -15,7 +15,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { Name: "SPY", Style: chart.Style{ Show: true, - StrokeColor: chart.GetDefaultSeriesStrokeColor(0), + StrokeColor: chart.GetDefaultColor(0), }, XValues: xv, YValues: yv, @@ -43,11 +43,12 @@ func drawChart(res http.ResponseWriter, req *http.Request) { graph := chart.Chart{ XAxis: chart.XAxis{ - Style: chart.Style{Show: true}, + Style: chart.Style{Show: true}, + TickPosition: chart.TickPositionBetweenTicks, }, YAxis: chart.YAxis{ Style: chart.Style{Show: true}, - Range: chart.Range{ + Range: &chart.ContinuousRange{ Max: 220.0, Min: 180.0, }, diff --git a/grid_line.go b/grid_line.go index fe49cba..63fafb6 100644 --- a/grid_line.go +++ b/grid_line.go @@ -1,31 +1,8 @@ package chart -// GenerateGridLines generates grid lines. -func GenerateGridLines(ticks []Tick, isVertical bool) []GridLine { - var gl []GridLine - isMinor := false - minorStyle := Style{ - StrokeColor: DefaultGridLineColor.WithAlpha(100), - StrokeWidth: 1.0, - } - majorStyle := Style{ - StrokeColor: DefaultGridLineColor, - StrokeWidth: 1.0, - } - for _, t := range ticks { - s := majorStyle - if isMinor { - s = minorStyle - } - gl = append(gl, GridLine{ - Style: s, - IsMinor: isMinor, - IsVertical: isVertical, - Value: t.Value, - }) - isMinor = !isMinor - } - return gl +// GridLineProvider is a type that provides grid lines. +type GridLineProvider interface { + GetGridLines(ticks []Tick, isVertical bool, majorStyle, minorStyle Style) []GridLine } // GridLine is a line on a graph canvas. @@ -57,15 +34,16 @@ func (gl GridLine) Horizontal() bool { } // Render renders the gridline -func (gl GridLine) Render(r Renderer, canvasBox Box, ra Range) { +func (gl GridLine) Render(r Renderer, canvasBox Box, ra Range, defaults Style) { + r.SetStrokeColor(gl.Style.GetStrokeColor(defaults.GetStrokeColor())) + r.SetStrokeWidth(gl.Style.GetStrokeWidth(defaults.GetStrokeWidth())) + r.SetStrokeDashArray(gl.Style.GetStrokeDashArray(defaults.GetStrokeDashArray())) + if gl.IsVertical { lineLeft := canvasBox.Left + ra.Translate(gl.Value) lineBottom := canvasBox.Bottom lineTop := canvasBox.Top - r.SetStrokeColor(gl.Style.GetStrokeColor(DefaultAxisColor)) - r.SetStrokeWidth(gl.Style.GetStrokeWidth(DefaultAxisLineWidth)) - r.MoveTo(lineLeft, lineBottom) r.LineTo(lineLeft, lineTop) r.Stroke() @@ -74,11 +52,33 @@ func (gl GridLine) Render(r Renderer, canvasBox Box, ra Range) { lineRight := canvasBox.Right lineHeight := canvasBox.Bottom - ra.Translate(gl.Value) - r.SetStrokeColor(gl.Style.GetStrokeColor(DefaultAxisColor)) - r.SetStrokeWidth(gl.Style.GetStrokeWidth(DefaultAxisLineWidth)) - r.MoveTo(lineLeft, lineHeight) r.LineTo(lineRight, lineHeight) r.Stroke() } } + +// GenerateGridLines generates grid lines. +func GenerateGridLines(ticks []Tick, majorStyle, minorStyle Style, isVertical bool) []GridLine { + var gl []GridLine + isMinor := false + + if len(ticks) < 3 { + return gl + } + + for _, t := range ticks[1 : len(ticks)-1] { + s := majorStyle + if isMinor { + s = minorStyle + } + gl = append(gl, GridLine{ + Style: s, + IsMinor: isMinor, + IsVertical: isVertical, + Value: t.Value, + }) + isMinor = !isMinor + } + return gl +} diff --git a/grid_line_test.go b/grid_line_test.go index d6e1fe6..d9b464e 100644 --- a/grid_line_test.go +++ b/grid_line_test.go @@ -16,12 +16,11 @@ func TestGenerateGridLines(t *testing.T) { {Value: 4.0, Label: "4.0"}, } - gl := GenerateGridLines(ticks, true) - assert.Len(gl, 4) - assert.Equal(1.0, gl[0].Value) - assert.Equal(2.0, gl[1].Value) - assert.Equal(3.0, gl[2].Value) - assert.Equal(4.0, gl[3].Value) + gl := GenerateGridLines(ticks, Style{}, Style{}, true) + assert.Len(gl, 2) + + assert.Equal(2.0, gl[0].Value) + assert.Equal(3.0, gl[1].Value) assert.True(gl[0].IsVertical) } diff --git a/histogram_series.go b/histogram_series.go index 3f0b34c..351fe94 100644 --- a/histogram_series.go +++ b/histogram_series.go @@ -6,7 +6,7 @@ package chart type HistogramSeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType InnerSeries ValueProvider } @@ -21,7 +21,7 @@ func (hs HistogramSeries) GetStyle() Style { } // GetYAxis returns which yaxis the series is mapped to. -func (hs HistogramSeries) GetYAxis() YAxisType { +func (hs HistogramSeries) GetYAxis() yAxisType { return hs.YAxis } @@ -52,6 +52,6 @@ func (hs HistogramSeries) GetBoundedValue(index int) (x, y1, y2 float64) { // Render implements Series.Render. func (hs HistogramSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - style := hs.Style.WithDefaultsFrom(defaults) - DrawHistogramSeries(r, canvasBox, xrange, yrange, style, hs) + style := hs.Style.InheritFrom(defaults) + Draw.HistogramSeries(r, canvasBox, xrange, yrange, style, hs) } diff --git a/histogram_series_test.go b/histogram_series_test.go new file mode 100644 index 0000000..3e51833 --- /dev/null +++ b/histogram_series_test.go @@ -0,0 +1,31 @@ +package chart + +import ( + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestHistogramSeries(t *testing.T) { + assert := assert.New(t) + + cs := ContinuousSeries{ + Name: "Test Series", + XValues: Sequence.Float64(1.0, 20.0), + YValues: Sequence.Float64(10.0, -10.0), + } + + hs := HistogramSeries{ + InnerSeries: cs, + } + + for x := 0; x < hs.Len(); x++ { + csx, csy := cs.GetValue(0) + hsx, hsy1, hsy2 := hs.GetBoundedValue(0) + assert.Equal(csx, hsx) + assert.True(hsy1 > 0) + assert.True(hsy2 <= 0) + assert.True(csy < 0 || (csy > 0 && csy == hsy1)) + assert.True(csy > 0 || (csy < 0 && csy == hsy2)) + } +} diff --git a/images/pie_chart.png b/images/pie_chart.png new file mode 100644 index 0000000..4b2740e Binary files /dev/null and b/images/pie_chart.png differ diff --git a/images/stacked_bar.png b/images/stacked_bar.png new file mode 100644 index 0000000..4b0e9ae Binary files /dev/null and b/images/stacked_bar.png differ diff --git a/legend.go b/legend.go new file mode 100644 index 0000000..9425c9a --- /dev/null +++ b/legend.go @@ -0,0 +1,116 @@ +package chart + +import "github.com/wcharczuk/go-chart/drawing" + +// Legend returns a legend renderable function. +func Legend(c *Chart, userDefaults ...Style) Renderable { + return func(r Renderer, cb Box, chartDefaults Style) { + legendDefaults := Style{ + FillColor: drawing.ColorWhite, + FontColor: DefaultTextColor, + FontSize: 8.0, + StrokeColor: DefaultAxisColor, + StrokeWidth: DefaultAxisLineWidth, + } + + var legendStyle Style + if len(userDefaults) > 0 { + legendStyle = userDefaults[0].InheritFrom(chartDefaults.InheritFrom(legendDefaults)) + } else { + legendStyle = chartDefaults.InheritFrom(legendDefaults) + } + + // DEFAULTS + legendPadding := Box{ + Top: 5, + Left: 5, + Right: 5, + Bottom: 5, + } + lineTextGap := 5 + lineLengthMinimum := 25 + + var labels []string + var lines []Style + for index, s := range c.Series { + if s.GetStyle().IsZero() || s.GetStyle().Show { + if _, isAnnotationSeries := s.(AnnotationSeries); !isAnnotationSeries { + labels = append(labels, s.GetName()) + lines = append(lines, s.GetStyle().InheritFrom(c.styleDefaultsSeries(index))) + } + } + } + + legend := Box{ + Top: cb.Top, + Left: cb.Left, + // bottom and right will be sized by the legend content + relevant padding. + } + + legendContent := Box{ + Top: legend.Top + legendPadding.Top, + Left: legend.Left + legendPadding.Left, + Right: legend.Left + legendPadding.Left, + Bottom: legend.Top + legendPadding.Top, + } + + r.SetFont(legendStyle.GetFont()) + r.SetFontColor(legendStyle.GetFontColor()) + r.SetFontSize(legendStyle.GetFontSize()) + + // measure + labelCount := 0 + for x := 0; x < len(labels); x++ { + if len(labels[x]) > 0 { + tb := r.MeasureText(labels[x]) + if labelCount > 0 { + legendContent.Bottom += DefaultMinimumTickVerticalSpacing + } + legendContent.Bottom += tb.Height() + right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum + legendContent.Right = Math.MaxInt(legendContent.Right, right) + labelCount++ + } + } + + legend = legend.Grow(legendContent) + legend.Right = legendContent.Right + legendPadding.Right + legend.Bottom = legendContent.Bottom + legendPadding.Bottom + + Draw.Box(r, legend, legendStyle) + + ycursor := legendContent.Top + tx := legendContent.Left + legendCount := 0 + for x := 0; x < len(labels); x++ { + if len(labels[x]) > 0 { + + if legendCount > 0 { + ycursor += DefaultMinimumTickVerticalSpacing + } + + tb := r.MeasureText(labels[x]) + + ty := ycursor + tb.Height() + r.Text(labels[x], tx, ty) + + th2 := tb.Height() >> 1 + + lx := tx + tb.Width() + lineTextGap + ly := ty - th2 + lx2 := legendContent.Right - legendPadding.Right + + r.SetStrokeColor(lines[x].GetStrokeColor()) + r.SetStrokeWidth(lines[x].GetStrokeWidth()) + r.SetStrokeDashArray(lines[x].GetStrokeDashArray()) + + r.MoveTo(lx, ly) + r.LineTo(lx2, ly) + r.Stroke() + + ycursor += tb.Height() + legendCount++ + } + } + } +} diff --git a/linear_regression_series.go b/linear_regression_series.go new file mode 100644 index 0000000..06c8c4e --- /dev/null +++ b/linear_regression_series.go @@ -0,0 +1,132 @@ +package chart + +// LinearRegressionSeries is a series that plots the n-nearest neighbors +// linear regression for the values. +type LinearRegressionSeries struct { + Name string + Style Style + YAxis yAxisType + + Window int + Offset int + InnerSeries ValueProvider + + m float64 + b float64 + avgx float64 + stddevx float64 +} + +// GetName returns the name of the time series. +func (lrs LinearRegressionSeries) GetName() string { + return lrs.Name +} + +// GetStyle returns the line style. +func (lrs LinearRegressionSeries) GetStyle() Style { + return lrs.Style +} + +// GetYAxis returns which YAxis the series draws on. +func (lrs LinearRegressionSeries) GetYAxis() yAxisType { + return lrs.YAxis +} + +// Len returns the number of elements in the series. +func (lrs LinearRegressionSeries) Len() int { + return Math.MinInt(lrs.GetWindow(), lrs.InnerSeries.Len()-lrs.GetOffset()) +} + +// GetWindow returns the window size. +func (lrs LinearRegressionSeries) GetWindow() int { + if lrs.Window == 0 { + return lrs.InnerSeries.Len() + } + return lrs.Window +} + +// GetEndIndex returns the effective window end. +func (lrs LinearRegressionSeries) GetEndIndex() int { + return Math.MinInt(lrs.GetOffset()+(lrs.Len()), (lrs.InnerSeries.Len() - 1)) +} + +// GetOffset returns the data offset. +func (lrs LinearRegressionSeries) GetOffset() int { + if lrs.Offset == 0 { + return 0 + } + return lrs.Offset +} + +// GetValue gets a value at a given index. +func (lrs *LinearRegressionSeries) GetValue(index int) (x, y float64) { + if lrs.InnerSeries == nil { + return + } + if lrs.m == 0 && lrs.b == 0 { + lrs.computeCoefficients() + } + offset := lrs.GetOffset() + effectiveIndex := Math.MinInt(index+offset, lrs.InnerSeries.Len()) + x, y = lrs.InnerSeries.GetValue(effectiveIndex) + y = (lrs.m * lrs.normalize(x)) + lrs.b + return +} + +// 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 { + return + } + if lrs.m == 0 && lrs.b == 0 { + lrs.computeCoefficients() + } + endIndex := lrs.GetEndIndex() + x, y = lrs.InnerSeries.GetValue(endIndex) + y = (lrs.m * lrs.normalize(x)) + lrs.b + return +} + +func (lrs *LinearRegressionSeries) normalize(xvalue float64) float64 { + return (xvalue - lrs.avgx) / lrs.stddevx +} + +// computeCoefficients computes the `m` and `b` terms in the linear formula given by `y = mx+b`. +func (lrs *LinearRegressionSeries) computeCoefficients() { + startIndex := lrs.GetOffset() + endIndex := lrs.GetEndIndex() + + p := float64(endIndex - startIndex) + + xvalues := NewRingBufferWithCapacity(lrs.Len()) + for index := startIndex; index < endIndex; index++ { + + x, _ := lrs.InnerSeries.GetValue(index) + xvalues.Enqueue(x) + } + + lrs.avgx = xvalues.Average() + lrs.stddevx = xvalues.StdDev() + + var sumx, sumy, sumxx, sumxy float64 + for index := startIndex; index < endIndex; index++ { + x, y := lrs.InnerSeries.GetValue(index) + + x = lrs.normalize(x) + + sumx += x + sumy += y + sumxx += x * x + sumxy += x * y + } + + lrs.m = (p*sumxy - sumx*sumy) / (p*sumxx - sumx*sumx) + lrs.b = (sumy / p) - (lrs.m * sumx / p) +} + +// Render renders the series. +func (lrs *LinearRegressionSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { + style := lrs.Style.InheritFrom(defaults) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, lrs) +} diff --git a/linear_regression_series_test.go b/linear_regression_series_test.go new file mode 100644 index 0000000..4a72669 --- /dev/null +++ b/linear_regression_series_test.go @@ -0,0 +1,77 @@ +package chart + +import ( + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestLinearRegressionSeries(t *testing.T) { + assert := assert.New(t) + + mainSeries := ContinuousSeries{ + Name: "A test series", + XValues: Sequence.Float64(1.0, 100.0), + YValues: Sequence.Float64(1.0, 100.0), + } + + linRegSeries := &LinearRegressionSeries{ + InnerSeries: mainSeries, + } + + lrx0, lry0 := linRegSeries.GetValue(0) + assert.InDelta(1.0, lrx0, 0.0000001) + assert.InDelta(1.0, lry0, 0.0000001) + + lrxn, lryn := linRegSeries.GetLastValue() + assert.InDelta(100.0, lrxn, 0.0000001) + assert.InDelta(100.0, lryn, 0.0000001) +} + +func TestLinearRegressionSeriesDesc(t *testing.T) { + assert := assert.New(t) + + mainSeries := ContinuousSeries{ + Name: "A test series", + XValues: Sequence.Float64(100.0, 1.0), + YValues: Sequence.Float64(100.0, 1.0), + } + + linRegSeries := &LinearRegressionSeries{ + InnerSeries: mainSeries, + } + + lrx0, lry0 := linRegSeries.GetValue(0) + assert.InDelta(100.0, lrx0, 0.0000001) + assert.InDelta(100.0, lry0, 0.0000001) + + lrxn, lryn := linRegSeries.GetLastValue() + assert.InDelta(1.0, lrxn, 0.0000001) + assert.InDelta(1.0, lryn, 0.0000001) +} + +func TestLinearRegressionSeriesWindowAndOffset(t *testing.T) { + assert := assert.New(t) + + mainSeries := ContinuousSeries{ + Name: "A test series", + XValues: Sequence.Float64(100.0, 1.0), + YValues: Sequence.Float64(100.0, 1.0), + } + + linRegSeries := &LinearRegressionSeries{ + InnerSeries: mainSeries, + Offset: 10, + Window: 10, + } + + assert.Equal(10, linRegSeries.Len()) + + lrx0, lry0 := linRegSeries.GetValue(0) + assert.InDelta(90.0, lrx0, 0.0000001) + assert.InDelta(90.0, lry0, 0.0000001) + + lrxn, lryn := linRegSeries.GetLastValue() + assert.InDelta(80.0, lrxn, 0.0000001) + assert.InDelta(80.0, lryn, 0.0000001) +} diff --git a/macd_series.go b/macd_series.go index b5ee558..d0aa51c 100644 --- a/macd_series.go +++ b/macd_series.go @@ -14,7 +14,7 @@ const ( type MACDSeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType InnerSeries ValueProvider PrimaryPeriod int @@ -56,7 +56,7 @@ func (macd MACDSeries) GetStyle() Style { } // GetYAxis returns which YAxis the series draws on. -func (macd MACDSeries) GetYAxis() YAxisType { +func (macd MACDSeries) GetYAxis() yAxisType { return macd.YAxis } @@ -109,7 +109,7 @@ func (macd *MACDSeries) ensureChildSeries() { type MACDSignalSeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType InnerSeries ValueProvider PrimaryPeriod int @@ -150,7 +150,7 @@ func (macds MACDSignalSeries) GetStyle() Style { } // GetYAxis returns which YAxis the series draws on. -func (macds MACDSignalSeries) GetYAxis() YAxisType { +func (macds MACDSignalSeries) GetYAxis() yAxisType { return macds.YAxis } @@ -192,15 +192,15 @@ func (macds *MACDSignalSeries) ensureSignal() { // Render renders the series. func (macds *MACDSignalSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - style := macds.Style.WithDefaultsFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, macds) + style := macds.Style.InheritFrom(defaults) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, macds) } // MACDLineSeries is a series that computes the inner ema1-ema2 value as a series. type MACDLineSeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType InnerSeries ValueProvider PrimaryPeriod int @@ -223,7 +223,7 @@ func (macdl MACDLineSeries) GetStyle() Style { } // GetYAxis returns which YAxis the series draws on. -func (macdl MACDLineSeries) GetYAxis() YAxisType { +func (macdl MACDLineSeries) GetYAxis() yAxisType { return macdl.YAxis } @@ -284,6 +284,6 @@ func (macdl *MACDLineSeries) ensureEMASeries() { // Render renders the series. func (macdl *MACDLineSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - style := macdl.Style.WithDefaultsFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, macdl) + style := macdl.Style.InheritFrom(defaults) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, macdl) } diff --git a/market_hours_range.go b/market_hours_range.go new file mode 100644 index 0000000..ee24c72 --- /dev/null +++ b/market_hours_range.go @@ -0,0 +1,133 @@ +package chart + +import ( + "fmt" + "time" + + "github.com/wcharczuk/go-chart/date" +) + +// MarketHoursRange is a special type of range that compresses a time range into just the +// market (i.e. NYSE operating hours and days) range. +type MarketHoursRange struct { + Min time.Time + Max time.Time + + MarketOpen time.Time + MarketClose time.Time + + HolidayProvider date.HolidayProvider + + ValueFormatter ValueFormatter + + Domain int +} + +// IsZero returns if the range is setup or not. +func (mhr MarketHoursRange) IsZero() bool { + return mhr.Min.IsZero() && mhr.Max.IsZero() +} + +// GetMin returns the min value. +func (mhr MarketHoursRange) GetMin() float64 { + return TimeToFloat64(mhr.Min) +} + +// GetMax returns the max value. +func (mhr MarketHoursRange) GetMax() float64 { + return TimeToFloat64(mhr.GetEffectiveMax()) +} + +// GetEffectiveMax gets either the close on the max, or the max itself. +func (mhr MarketHoursRange) GetEffectiveMax() time.Time { + maxClose := date.On(mhr.MarketClose, mhr.Max) + if maxClose.After(mhr.Max) { + return maxClose + } + return mhr.Max +} + +// SetMin sets the min value. +func (mhr *MarketHoursRange) SetMin(min float64) { + mhr.Min = Float64ToTime(min) +} + +// SetMax sets the max value. +func (mhr *MarketHoursRange) SetMax(max float64) { + mhr.Max = Float64ToTime(max) +} + +// GetDelta gets the delta. +func (mhr MarketHoursRange) GetDelta() float64 { + min := mhr.GetMin() + max := mhr.GetMax() + return max - min +} + +// GetDomain gets the domain. +func (mhr MarketHoursRange) GetDomain() int { + return mhr.Domain +} + +// SetDomain sets the domain. +func (mhr *MarketHoursRange) SetDomain(domain int) { + mhr.Domain = domain +} + +// GetHolidayProvider coalesces a userprovided holiday provider and the date.DefaultHolidayProvider. +func (mhr MarketHoursRange) GetHolidayProvider() date.HolidayProvider { + if mhr.HolidayProvider == nil { + return date.DefaultHolidayProvider + } + return mhr.HolidayProvider +} + +// GetTicks returns the ticks for the range. +// This is to override the default continous ticks that would be generated for the range. +func (mhr *MarketHoursRange) GetTicks(vf ValueFormatter) []Tick { + var ticks []Tick + + cursor := date.On(mhr.MarketClose, mhr.Min) + maxClose := date.On(mhr.MarketClose, mhr.Max) + + if mhr.Min.Before(cursor) { + ticks = append(ticks, Tick{ + Value: TimeToFloat64(cursor), + Label: vf(cursor), + }) + } + + for date.BeforeDate(cursor, maxClose) { + if date.IsWeekDay(cursor.Weekday()) && !mhr.GetHolidayProvider()(cursor) { + ticks = append(ticks, Tick{ + Value: TimeToFloat64(cursor), + Label: vf(cursor), + }) + } + + cursor = cursor.AddDate(0, 0, 1) + } + + endMarketClose := date.On(mhr.MarketClose, cursor) + if date.IsWeekDay(endMarketClose.Weekday()) && !mhr.GetHolidayProvider()(endMarketClose) { + ticks = append(ticks, Tick{ + Value: TimeToFloat64(endMarketClose), + Label: vf(endMarketClose), + }) + } + return ticks +} + +func (mhr MarketHoursRange) String() string { + return fmt.Sprintf("MarketHoursRange [%s, %s] => %d", mhr.Min.Format(DefaultDateMinuteFormat), mhr.Max.Format(DefaultDateMinuteFormat), mhr.Domain) +} + +// Translate maps a given value into the ContinuousRange space. +func (mhr MarketHoursRange) Translate(value float64) int { + valueTime := Float64ToTime(value) + valueTimeEastern := valueTime.In(date.Eastern()) + totalSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.GetEffectiveMax(), mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) + valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTimeEastern, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) + translated := int((float64(valueDelta) / float64(totalSeconds)) * float64(mhr.Domain)) + return translated +} diff --git a/market_hours_range_test.go b/market_hours_range_test.go new file mode 100644 index 0000000..c2eaa3d --- /dev/null +++ b/market_hours_range_test.go @@ -0,0 +1,61 @@ +package chart + +import ( + "testing" + "time" + + assert "github.com/blendlabs/go-assert" + "github.com/wcharczuk/go-chart/date" +) + +func TestMarketHoursRangeGetDelta(t *testing.T) { + assert := assert.New(t) + + r := &MarketHoursRange{ + Min: time.Date(2016, 07, 19, 9, 30, 0, 0, date.Eastern()), + Max: time.Date(2016, 07, 22, 16, 00, 0, 0, date.Eastern()), + MarketOpen: date.NYSEOpen, + MarketClose: date.NYSEClose, + HolidayProvider: date.IsNYSEHoliday, + } + + assert.NotZero(r.GetDelta()) +} + +func TestMarketHoursRangeTranslate(t *testing.T) { + assert := assert.New(t) + + r := &MarketHoursRange{ + Min: time.Date(2016, 07, 18, 9, 30, 0, 0, date.Eastern()), + Max: time.Date(2016, 07, 22, 16, 00, 0, 0, date.Eastern()), + MarketOpen: date.NYSEOpen, + MarketClose: date.NYSEClose, + HolidayProvider: date.IsNYSEHoliday, + Domain: 1000, + } + + weds := time.Date(2016, 07, 20, 9, 30, 0, 0, date.Eastern()) + + assert.Equal(0, r.Translate(TimeToFloat64(r.Min))) + assert.Equal(400, r.Translate(TimeToFloat64(weds))) + assert.Equal(1000, r.Translate(TimeToFloat64(r.Max))) +} + +func TestMarketHoursRangeGetTicks(t *testing.T) { + assert := assert.New(t) + + r := &MarketHoursRange{ + Min: time.Date(2016, 07, 18, 9, 30, 0, 0, date.Eastern()), + Max: time.Date(2016, 07, 22, 16, 00, 0, 0, date.Eastern()), + MarketOpen: date.NYSEOpen, + MarketClose: date.NYSEClose, + HolidayProvider: date.IsNYSEHoliday, + Domain: 1000, + } + + ticks := r.GetTicks(TimeValueFormatter) + assert.NotEmpty(ticks) + assert.Len(ticks, 6) + assert.NotEqual(TimeToFloat64(r.Min), ticks[0].Value) + assert.NotEmpty(ticks[0].Label) +} diff --git a/math.go b/math.go new file mode 100644 index 0000000..77180e8 --- /dev/null +++ b/math.go @@ -0,0 +1,222 @@ +package chart + +import ( + "math" + "time" +) + +const ( + _pi = math.Pi + _2pi = 2 * math.Pi + _3pi4 = (3 * math.Pi) / 4.0 + _4pi3 = (4 * math.Pi) / 3.0 + _3pi2 = (3 * math.Pi) / 2.0 + _5pi4 = (5 * math.Pi) / 4.0 + _7pi4 = (7 * math.Pi) / 4.0 + _pi2 = math.Pi / 2.0 + _pi4 = math.Pi / 4.0 + _d2r = (math.Pi / 180.0) + _r2d = (180.0 / math.Pi) +) + +// TimeToFloat64 returns a float64 representation of a time. +func TimeToFloat64(t time.Time) float64 { + return float64(t.UnixNano()) +} + +// Float64ToTime returns a time from a float64. +func Float64ToTime(tf float64) time.Time { + return time.Unix(0, int64(tf)) +} + +var ( + // Math contains helper methods for common math operations. + Math = &mathUtil{} +) + +type mathUtil struct{} + +// MinAndMax returns both the min and max in one pass. +func (m mathUtil) MinAndMax(values ...float64) (min float64, max float64) { + if len(values) == 0 { + return + } + min = values[0] + max = values[0] + for _, v := range values { + if max < v { + max = v + } + if min > v { + min = v + } + } + return +} + +// MinAndMaxOfTime returns the min and max of a given set of times +// in one pass. +func (m mathUtil) MinAndMaxOfTime(values ...time.Time) (min time.Time, max time.Time) { + if len(values) == 0 { + return + } + + min = values[0] + max = values[0] + + for _, v := range values { + if max.Before(v) { + max = v + } + if min.After(v) { + min = v + } + } + return +} + +// GetRoundToForDelta returns a `roundTo` value for a given delta. +func (m mathUtil) GetRoundToForDelta(delta float64) float64 { + startingDeltaBound := math.Pow(10.0, 10.0) + for cursor := startingDeltaBound; cursor > 0; cursor /= 10.0 { + if delta > cursor { + return cursor / 10.0 + } + } + + return 0.0 +} + +// RoundUp rounds up to a given roundTo value. +func (m mathUtil) RoundUp(value, roundTo float64) float64 { + d1 := math.Ceil(value / roundTo) + return d1 * roundTo +} + +// RoundDown rounds down to a given roundTo value. +func (m mathUtil) RoundDown(value, roundTo float64) float64 { + d1 := math.Floor(value / roundTo) + return d1 * roundTo +} + +// Normalize returns a set of numbers on the interval [0,1] for a given set of inputs. +// An example: 4,3,2,1 => 0.4, 0.3, 0.2, 0.1 +// Caveat; the total may be < 1.0; there are going to be issues with irrational numbers etc. +func (m mathUtil) Normalize(values ...float64) []float64 { + var total float64 + for _, v := range values { + total += v + } + output := make([]float64, len(values)) + for x, v := range values { + output[x] = m.RoundDown(v/total, 0.0001) + } + return output +} + +// MinInt returns the minimum of a set of integers. +func (m mathUtil) MinInt(values ...int) int { + min := math.MaxInt32 + for _, v := range values { + if v < min { + min = v + } + } + return min +} + +// MaxInt returns the maximum of a set of integers. +func (m mathUtil) MaxInt(values ...int) int { + max := math.MinInt32 + for _, v := range values { + if v > max { + max = v + } + } + return max +} + +// AbsInt returns the absolute value of an integer. +func (m mathUtil) AbsInt(value int) int { + if value < 0 { + return -value + } + return value +} + +// Sum sums a set of values. +func (m mathUtil) Sum(values ...float64) float64 { + var total float64 + for _, v := range values { + total += v + } + return total +} + +// SumInt sums a set of values. +func (m mathUtil) SumInt(values ...int) int { + var total int + for _, v := range values { + total += v + } + return total +} + +// PercentDifference computes the percentage difference between two values. +// The formula is (v2-v1)/v1. +func (m mathUtil) PercentDifference(v1, v2 float64) float64 { + if v1 == 0 { + return 0 + } + return (v2 - v1) / v1 +} + +// DegreesToRadians returns degrees as radians. +func (m mathUtil) DegreesToRadians(degrees float64) float64 { + return degrees * _d2r +} + +// RadiansToDegrees translates a radian value to a degree value. +func (m mathUtil) RadiansToDegrees(value float64) float64 { + return math.Mod(value, _2pi) * _r2d +} + +// PercentToRadians converts a normalized value (0,1) to radians. +func (m mathUtil) PercentToRadians(pct float64) float64 { + return m.DegreesToRadians(360.0 * pct) +} + +// RadianAdd adds a delta to a base in radians. +func (m mathUtil) RadianAdd(base, delta float64) float64 { + value := base + delta + if value > _2pi { + return math.Mod(value, _2pi) + } else if value < 0 { + return math.Mod(_2pi+value, _2pi) + } + return value +} + +// DegreesAdd adds a delta to a base in radians. +func (m mathUtil) DegreesAdd(baseDegrees, deltaDegrees float64) float64 { + value := baseDegrees + deltaDegrees + if value > _2pi { + return math.Mod(value, 360.0) + } else if value < 0 { + return math.Mod(360.0+value, 360.0) + } + return value +} + +// DegreesToCompass returns the degree value in compass / clock orientation. +func (m mathUtil) DegreesToCompass(deg float64) float64 { + return m.DegreesAdd(deg, -90.0) +} + +// CirclePoint returns the absolute position of a circle diameter point given +// by the radius and the angle. +func (m mathUtil) CirclePoint(cx, cy int, radius, angleRadians float64) (x, y int) { + x = cx + int(radius*math.Sin(angleRadians)) + y = cy - int(radius*math.Cos(angleRadians)) + return +} diff --git a/math_test.go b/math_test.go new file mode 100644 index 0000000..a4c4006 --- /dev/null +++ b/math_test.go @@ -0,0 +1,162 @@ +package chart + +import ( + "testing" + "time" + + "github.com/blendlabs/go-assert" +) + +func TestMinAndMax(t *testing.T) { + assert := assert.New(t) + values := []float64{1.0, 2.0, 3.0, 4.0} + min, max := Math.MinAndMax(values...) + assert.Equal(1.0, min) + assert.Equal(4.0, max) +} + +func TestMinAndMaxReversed(t *testing.T) { + assert := assert.New(t) + values := []float64{4.0, 2.0, 3.0, 1.0} + min, max := Math.MinAndMax(values...) + assert.Equal(1.0, min) + assert.Equal(4.0, max) +} + +func TestMinAndMaxEmpty(t *testing.T) { + assert := assert.New(t) + values := []float64{} + min, max := Math.MinAndMax(values...) + assert.Equal(0.0, min) + assert.Equal(0.0, max) +} + +func TestMinAndMaxOfTime(t *testing.T) { + assert := assert.New(t) + values := []time.Time{ + time.Now().AddDate(0, 0, -1), + time.Now().AddDate(0, 0, -2), + time.Now().AddDate(0, 0, -3), + time.Now().AddDate(0, 0, -4), + } + min, max := Math.MinAndMaxOfTime(values...) + assert.Equal(values[3], min) + assert.Equal(values[0], max) +} + +func TestMinAndMaxOfTimeReversed(t *testing.T) { + assert := assert.New(t) + values := []time.Time{ + time.Now().AddDate(0, 0, -4), + time.Now().AddDate(0, 0, -2), + time.Now().AddDate(0, 0, -3), + time.Now().AddDate(0, 0, -1), + } + min, max := Math.MinAndMaxOfTime(values...) + assert.Equal(values[0], min) + assert.Equal(values[3], max) +} + +func TestMinAndMaxOfTimeEmpty(t *testing.T) { + assert := assert.New(t) + values := []time.Time{} + min, max := Math.MinAndMaxOfTime(values...) + assert.Equal(time.Time{}, min) + assert.Equal(time.Time{}, max) +} + +func TestGetRoundToForDelta(t *testing.T) { + assert := assert.New(t) + + assert.Equal(100.0, Math.GetRoundToForDelta(1001.00)) + assert.Equal(10.0, Math.GetRoundToForDelta(101.00)) + assert.Equal(1.0, Math.GetRoundToForDelta(11.00)) +} + +func TestRoundUp(t *testing.T) { + assert := assert.New(t) + assert.Equal(0.5, Math.RoundUp(0.49, 0.1)) + assert.Equal(1.0, Math.RoundUp(0.51, 1.0)) + assert.Equal(0.4999, Math.RoundUp(0.49988, 0.0001)) +} + +func TestRoundDown(t *testing.T) { + assert := assert.New(t) + assert.Equal(0.5, Math.RoundDown(0.51, 0.1)) + assert.Equal(1.0, Math.RoundDown(1.01, 1.0)) + assert.Equal(0.5001, Math.RoundDown(0.50011, 0.0001)) +} + +func TestPercentDifference(t *testing.T) { + assert := assert.New(t) + + assert.Equal(0.5, Math.PercentDifference(1.0, 1.5)) + assert.Equal(-0.5, Math.PercentDifference(2.0, 1.0)) +} + +func TestNormalize(t *testing.T) { + assert := assert.New(t) + + values := []float64{10, 9, 8, 7, 6} + normalized := Math.Normalize(values...) + assert.Len(normalized, 5) + assert.Equal(0.25, normalized[0]) + assert.Equal(0.1499, normalized[4]) +} + +var ( + _degreesToRadians = map[float64]float64{ + 0: 0, // !_2pi b/c no irrational nums in floats. + 45: _pi4, + 90: _pi2, + 135: _3pi4, + 180: _pi, + 225: _5pi4, + 270: _3pi2, + 315: _7pi4, + } + + _compassToRadians = map[float64]float64{ + 0: _pi2, + 45: _pi4, + 90: 0, // !_2pi b/c no irrational nums in floats. + 135: _7pi4, + 180: _3pi2, + 225: _5pi4, + 270: _pi, + 315: _3pi4, + } +) + +func TestDegreesToRadians(t *testing.T) { + assert := assert.New(t) + + for d, r := range _degreesToRadians { + assert.Equal(r, Math.DegreesToRadians(d)) + } +} + +func TestPercentToRadians(t *testing.T) { + assert := assert.New(t) + + for d, r := range _degreesToRadians { + assert.Equal(r, Math.PercentToRadians(d/360.0)) + } +} + +func TestRadiansToDegrees(t *testing.T) { + assert := assert.New(t) + + for d, r := range _degreesToRadians { + assert.Equal(d, Math.RadiansToDegrees(r)) + } +} + +func TestRadianAdd(t *testing.T) { + assert := assert.New(t) + + assert.Equal(_pi, Math.RadianAdd(_pi2, _pi2)) + assert.Equal(_3pi2, Math.RadianAdd(_pi2, _pi)) + assert.Equal(_pi, Math.RadianAdd(_pi, _2pi)) + assert.Equal(_pi, Math.RadianAdd(_pi, -_2pi)) +} diff --git a/pie_chart.go b/pie_chart.go new file mode 100644 index 0000000..720f61d --- /dev/null +++ b/pie_chart.go @@ -0,0 +1,279 @@ +package chart + +import ( + "errors" + "io" + + "github.com/golang/freetype/truetype" +) + +// PieChart is a chart that draws sections of a circle based on percentages. +type PieChart struct { + Title string + TitleStyle Style + + 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 PieChart) 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 PieChart) GetFont() *truetype.Font { + if pc.Font == nil { + return pc.defaultFont + } + return pc.Font +} + +// GetWidth returns the chart width or the default value. +func (pc PieChart) GetWidth() int { + if pc.Width == 0 { + return DefaultChartWidth + } + return pc.Width +} + +// GetHeight returns the chart height or the default value. +func (pc PieChart) 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 PieChart) 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 := pc.finalizeValues(pc.Values) + pc.drawSlices(r, canvasBox, finalValues) + pc.drawTitle(r) + for _, a := range pc.Elements { + a(r, canvasBox, pc.styleDefaultsElements()) + } + + return r.Save(w) +} + +func (pc PieChart) drawBackground(r Renderer) { + Draw.Box(r, Box{ + Right: pc.GetWidth(), + Bottom: pc.GetHeight(), + }, pc.getBackgroundStyle()) +} + +func (pc PieChart) drawCanvas(r Renderer, canvasBox Box) { + Draw.Box(r, canvasBox, pc.getCanvasStyle()) +} + +func (pc PieChart) drawTitle(r Renderer) { + if len(pc.Title) > 0 && pc.TitleStyle.Show { + Draw.TextWithin(r, pc.Title, pc.Box(), pc.styleDefaultsTitle()) + } +} + +func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) { + cx, cy := canvasBox.Center() + diameter := Math.MinInt(canvasBox.Width(), canvasBox.Height()) + radius := float64(diameter >> 1) + labelRadius := (radius * 2.0) / 3.0 + + // draw the pie slices + var rads, delta, delta2, total float64 + var lx, ly int + for index, v := range values { + v.Style.InheritFrom(pc.stylePieChartValue(index)).WriteToRenderer(r) + + r.MoveTo(cx, cy) + rads = Math.PercentToRadians(total) + delta = Math.PercentToRadians(v.Value) + + r.ArcTo(cx, cy, radius, radius, rads, delta) + + r.LineTo(cx, cy) + r.Close() + r.FillStroke() + total = total + v.Value + } + + // draw the labels + total = 0 + for index, v := range values { + v.Style.InheritFrom(pc.stylePieChartValue(index)).WriteToRenderer(r) + if len(v.Label) > 0 { + delta2 = Math.PercentToRadians(total + (v.Value / 2.0)) + delta2 = Math.RadianAdd(delta2, _pi2) + lx, ly = Math.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 PieChart) finalizeValues(values []Value) []Value { + return Values(values).Normalize() +} + +func (pc PieChart) getDefaultCanvasBox() Box { + return pc.Box() +} + +func (pc PieChart) getCircleAdjustedCanvasBox(canvasBox Box) Box { + circleDiameter := Math.MinInt(canvasBox.Width(), canvasBox.Height()) + + square := Box{ + Right: circleDiameter, + Bottom: circleDiameter, + } + + return canvasBox.Fit(square) +} + +func (pc PieChart) getBackgroundStyle() Style { + return pc.Background.InheritFrom(pc.styleDefaultsBackground()) +} + +func (pc PieChart) getCanvasStyle() Style { + return pc.Canvas.InheritFrom(pc.styleDefaultsCanvas()) +} + +func (pc PieChart) styleDefaultsCanvas() Style { + return Style{ + FillColor: DefaultCanvasColor, + StrokeColor: DefaultCanvasStrokeColor, + StrokeWidth: DefaultStrokeWidth, + } +} + +func (pc PieChart) styleDefaultsPieChartValue() Style { + return Style{ + StrokeColor: ColorWhite, + StrokeWidth: 5.0, + FillColor: ColorWhite, + } +} + +func (pc PieChart) stylePieChartValue(index int) Style { + return pc.SliceStyle.InheritFrom(Style{ + StrokeColor: ColorWhite, + StrokeWidth: 5.0, + FillColor: GetAlternateColor(index), + FontSize: pc.getScaledFontSize(), + FontColor: ColorWhite, + Font: pc.GetFont(), + }) +} + +func (pc PieChart) getScaledFontSize() float64 { + effectiveDimension := Math.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 PieChart) styleDefaultsBackground() Style { + return Style{ + FillColor: DefaultBackgroundColor, + StrokeColor: DefaultBackgroundStrokeColor, + StrokeWidth: DefaultStrokeWidth, + } +} + +func (pc PieChart) styleDefaultsElements() Style { + return Style{ + Font: pc.GetFont(), + } +} + +func (pc PieChart) styleDefaultsTitle() Style { + return pc.TitleStyle.InheritFrom(Style{ + FontColor: ColorWhite, + Font: pc.GetFont(), + FontSize: pc.getTitleFontSize(), + TextHorizontalAlign: TextHorizontalAlignCenter, + TextVerticalAlign: TextVerticalAlignTop, + TextWrap: TextWrapWord, + }) +} + +func (pc PieChart) getTitleFontSize() float64 { + effectiveDimension := Math.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 +} + +// Box returns the chart bounds as a box. +func (pc PieChart) 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/pie_chart_test.go b/pie_chart_test.go new file mode 100644 index 0000000..2f536cd --- /dev/null +++ b/pie_chart_test.go @@ -0,0 +1,31 @@ +package chart + +import ( + "bytes" + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestPieChart(t *testing.T) { + assert := assert.New(t) + + pie := PieChart{ + 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()) +} diff --git a/range.go b/range.go index ddea1ee..9cea1fb 100644 --- a/range.go +++ b/range.go @@ -1,44 +1,41 @@ package chart -import ( - "fmt" - "math" -) - -// Range represents a boundary for a set of numbers. -type Range struct { - Min float64 - Max float64 - Domain int +// NameProvider is a type that returns a name. +type NameProvider interface { + GetName() string } -// IsZero returns if the range has been set or not. -func (r Range) IsZero() bool { - return (r.Min == 0 || math.IsNaN(r.Min)) && - (r.Max == 0 || math.IsNaN(r.Max)) && - r.Domain == 0 +// StyleProvider is a type that returns a style. +type StyleProvider interface { + GetStyle() Style } -// Delta returns the difference between the min and max value. -func (r Range) Delta() float64 { - return r.Max - r.Min +// IsZeroable is a type that returns if it's been set or not. +type IsZeroable interface { + IsZero() bool } -// String returns a simple string for the range. -func (r Range) String() string { - return fmt.Sprintf("Range [%.2f,%.2f] => %d", r.Min, r.Max, r.Domain) +// Stringable is a type that has a string representation. +type Stringable interface { + String() string } -// Translate maps a given value into the range space. -func (r Range) Translate(value float64) int { - normalized := value - r.Min - ratio := normalized / r.Delta() - return int(math.Ceil(ratio * float64(r.Domain))) -} +// Range is a +type Range interface { + Stringable + IsZeroable -// GetRoundedRangeBounds returns some `prettified` range bounds. -func (r Range) GetRoundedRangeBounds() (min, max float64) { - delta := r.Max - r.Min - roundTo := GetRoundToForDelta(delta) - return RoundDown(r.Min, roundTo), RoundUp(r.Max, roundTo) + GetMin() float64 + SetMin(min float64) + + GetMax() float64 + SetMax(max float64) + + GetDelta() float64 + + GetDomain() int + SetDomain(domain int) + + // Translate the range to the domain. + Translate(value float64) int } diff --git a/raster_renderer.go b/raster_renderer.go index d2ba356..347bb64 100644 --- a/raster_renderer.go +++ b/raster_renderer.go @@ -71,6 +71,16 @@ func (rr *rasterRenderer) LineTo(x, y int) { rr.gc.LineTo(float64(x), float64(y)) } +// QuadCurveTo implements the interface method. +func (rr *rasterRenderer) QuadCurveTo(cx, cy, x, y int) { + rr.gc.QuadCurveTo(float64(cx), float64(cy), float64(x), float64(y)) +} + +// ArcTo implements the interface method. +func (rr *rasterRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) { + rr.gc.ArcTo(float64(cx), float64(cy), rx, ry, startAngle, delta) +} + // Close implements the interface method. func (rr *rasterRenderer) Close() { rr.gc.Close() diff --git a/renderer.go b/renderer.go index 6308dda..9d4a9ea 100644 --- a/renderer.go +++ b/renderer.go @@ -34,6 +34,14 @@ type Renderer interface { // from the previous point. LineTo(x, y int) + // QuadCurveTo draws a quad curve. + // cx and cy represent the bezier "control points". + QuadCurveTo(cx, cy, x, y int) + + // ArcTo draws an arc with a given center (cx,cy) + // a given set of radii (rx,ry), a startAngle and delta (in radians). + ArcTo(cx, cy int, rx, ry, startAngle, delta float64) + // Close finalizes a shape as drawn by LineTo. Close() diff --git a/ring_buffer.go b/ring_buffer.go index a5e91cd..6fb4288 100644 --- a/ring_buffer.go +++ b/ring_buffer.go @@ -2,6 +2,7 @@ package chart import ( "fmt" + "math" "strings" ) @@ -200,6 +201,40 @@ func (rb *RingBuffer) String() string { return strings.Join(values, " <= ") } +// Average returns the float average of the values in the buffer. +func (rb *RingBuffer) Average() float64 { + var accum float64 + rb.Each(func(v interface{}) { + if typed, isTyped := v.(float64); isTyped { + accum += typed + } + }) + return accum / float64(rb.Len()) +} + +// Variance computes the variance of the buffer. +func (rb *RingBuffer) Variance() float64 { + if rb.Len() == 0 { + return 0 + } + + var variance float64 + m := rb.Average() + + rb.Each(func(v interface{}) { + if n, isTyped := v.(float64); isTyped { + variance += (float64(n) - m) * (float64(n) - m) + } + }) + + return variance / float64(rb.Len()) +} + +// StdDev returns the standard deviation. +func (rb *RingBuffer) StdDev() float64 { + return math.Pow(rb.Variance(), 0.5) +} + func arrayClear(source []interface{}, index, length int) { for x := 0; x < length; x++ { absoluteIndex := x + index diff --git a/sequence.go b/sequence.go new file mode 100644 index 0000000..c04ec4d --- /dev/null +++ b/sequence.go @@ -0,0 +1,55 @@ +package chart + +import ( + "math/rand" + "time" +) + +var ( + // Sequence contains some sequence utilities. + // These utilities can be useful for generating test data. + Sequence = &sequence{} +) + +type sequence struct{} + +// Float64 produces an array of floats from [start,end] by optional steps. +func (s sequence) Float64(start, end float64, steps ...float64) []float64 { + var values []float64 + step := 1.0 + if len(steps) > 0 { + step = steps[0] + } + + if start < end { + for x := start; x <= end; x += step { + values = append(values, x) + } + } else { + for x := start; x >= end; x = x - step { + values = append(values, x) + } + } + return values +} + +// 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 + } + + return values +} + +// Days generates a sequence of timestamps by day, from -days to today. +func (s sequence) Days(days int) []time.Time { + var values []time.Time + for day := days; day >= 0; day-- { + values = append(values, time.Now().AddDate(0, 0, -day)) + } + return values +} diff --git a/sequence_test.go b/sequence_test.go new file mode 100644 index 0000000..91e4965 --- /dev/null +++ b/sequence_test.go @@ -0,0 +1,17 @@ +package chart + +import ( + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestSequenceFloat64(t *testing.T) { + assert := assert.New(t) + + asc := Sequence.Float64(1.0, 10.0) + assert.Len(asc, 10) + + desc := Sequence.Float64(10.0, 1.0) + assert.Len(desc, 10) +} diff --git a/series.go b/series.go index 6145dcb..879f24b 100644 --- a/series.go +++ b/series.go @@ -3,7 +3,7 @@ package chart // Series is an alias to Renderable. type Series interface { GetName() string - GetYAxis() YAxisType + GetYAxis() yAxisType GetStyle() Style Render(r Renderer, canvasBox Box, xrange, yrange Range, s Style) } diff --git a/sma_series.go b/sma_series.go index ca43e2b..dd8aed9 100644 --- a/sma_series.go +++ b/sma_series.go @@ -9,7 +9,7 @@ const ( type SMASeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType Period int InnerSeries ValueProvider @@ -26,7 +26,7 @@ func (sma SMASeries) GetStyle() Style { } // GetYAxis returns which YAxis the series draws on. -func (sma SMASeries) GetYAxis() YAxisType { +func (sma SMASeries) GetYAxis() yAxisType { return sma.YAxis } @@ -35,6 +35,17 @@ func (sma SMASeries) Len() int { return sma.InnerSeries.Len() } +// GetPeriod returns the window size. +func (sma SMASeries) GetPeriod(defaults ...int) int { + if sma.Period == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return DefaultSimpleMovingAveragePeriod + } + return sma.Period +} + // GetValue gets a value at a given index. func (sma SMASeries) GetValue(index int) (x, y float64) { if sma.InnerSeries == nil { @@ -59,20 +70,9 @@ func (sma SMASeries) GetLastValue() (x, y float64) { return } -// GetPeriod returns the window size. -func (sma SMASeries) GetPeriod(defaults ...int) int { - if sma.Period == 0 { - if len(defaults) > 0 { - return defaults[0] - } - return DefaultSimpleMovingAveragePeriod - } - return sma.Period -} - func (sma SMASeries) getAverage(index int) float64 { period := sma.GetPeriod() - floor := MaxInt(0, index-period) + floor := Math.MaxInt(0, index-period) var accum float64 var count float64 for x := index; x >= floor; x-- { @@ -85,6 +85,6 @@ func (sma SMASeries) getAverage(index int) float64 { // Render renders the series. func (sma SMASeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - style := sma.Style.WithDefaultsFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, sma) + style := sma.Style.InheritFrom(defaults) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, sma) } diff --git a/sma_series_test.go b/sma_series_test.go index e2f5e4f..7a715cf 100644 --- a/sma_series_test.go +++ b/sma_series_test.go @@ -12,14 +12,14 @@ type mockValueProvider struct { } func (m mockValueProvider) Len() int { - return MinInt(len(m.X), len(m.Y)) + return Math.MinInt(len(m.X), len(m.Y)) } func (m mockValueProvider) GetValue(index int) (x, y float64) { if index < 0 { panic("negative index at GetValue()") } - if index > MinInt(len(m.X), len(m.Y)) { + if index > Math.MinInt(len(m.X), len(m.Y)) { panic("index is outside the length of m.X or m.Y") } x = m.X[index] @@ -31,8 +31,8 @@ func TestSMASeriesGetValue(t *testing.T) { assert := assert.New(t) mockSeries := mockValueProvider{ - Seq(1.0, 10.0), - Seq(10, 1.0), + Sequence.Float64(1.0, 10.0), + Sequence.Float64(10, 1.0), } assert.Equal(10, mockSeries.Len()) @@ -62,8 +62,8 @@ func TestSMASeriesGetLastValueWindowOverlap(t *testing.T) { assert := assert.New(t) mockSeries := mockValueProvider{ - Seq(1.0, 10.0), - Seq(10, 1.0), + Sequence.Float64(1.0, 10.0), + Sequence.Float64(10, 1.0), } assert.Equal(10, mockSeries.Len()) @@ -88,8 +88,8 @@ func TestSMASeriesGetLastValue(t *testing.T) { assert := assert.New(t) mockSeries := mockValueProvider{ - Seq(1.0, 100.0), - Seq(100, 1.0), + Sequence.Float64(1.0, 100.0), + Sequence.Float64(100, 1.0), } assert.Equal(100, mockSeries.Len()) diff --git a/stacked_bar_chart.go b/stacked_bar_chart.go new file mode 100644 index 0000000..511be46 --- /dev/null +++ b/stacked_bar_chart.go @@ -0,0 +1,335 @@ +package chart + +import ( + "errors" + "fmt" + "io" + "math" + + "github.com/golang/freetype/truetype" +) + +// StackedBar is a bar within a StackedBarChart. +type StackedBar struct { + Name string + Width int + Values []Value +} + +// GetWidth returns the width of the bar. +func (sb StackedBar) GetWidth() int { + if sb.Width == 0 { + return 50 + } + return sb.Width +} + +// StackedBarChart is a chart that draws sections of a bar based on percentages. +type StackedBarChart struct { + Title string + TitleStyle Style + + Width int + Height int + DPI float64 + + Background Style + Canvas Style + + XAxis Style + YAxis Style + + BarSpacing int + + Font *truetype.Font + defaultFont *truetype.Font + + Bars []StackedBar + Elements []Renderable +} + +// GetDPI returns the dpi for the chart. +func (sbc StackedBarChart) GetDPI(defaults ...float64) float64 { + if sbc.DPI == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return DefaultDPI + } + return sbc.DPI +} + +// GetFont returns the text font. +func (sbc StackedBarChart) GetFont() *truetype.Font { + if sbc.Font == nil { + return sbc.defaultFont + } + return sbc.Font +} + +// GetWidth returns the chart width or the default value. +func (sbc StackedBarChart) GetWidth() int { + if sbc.Width == 0 { + return DefaultChartWidth + } + return sbc.Width +} + +// GetHeight returns the chart height or the default value. +func (sbc StackedBarChart) GetHeight() int { + if sbc.Height == 0 { + return DefaultChartWidth + } + return sbc.Height +} + +// GetBarSpacing returns the spacing between bars. +func (sbc StackedBarChart) GetBarSpacing() int { + if sbc.BarSpacing == 0 { + return 100 + } + return sbc.BarSpacing +} + +// Render renders the chart with the given renderer to the given io.Writer. +func (sbc StackedBarChart) Render(rp RendererProvider, w io.Writer) error { + if len(sbc.Bars) == 0 { + return errors.New("Please provide at least one bar.") + } + + r, err := rp(sbc.GetWidth(), sbc.GetHeight()) + if err != nil { + return err + } + + if sbc.Font == nil { + defaultFont, err := GetDefaultFont() + if err != nil { + return err + } + sbc.defaultFont = defaultFont + } + r.SetDPI(sbc.GetDPI(DefaultDPI)) + + canvasBox := sbc.getAdjustedCanvasBox(r, sbc.getDefaultCanvasBox()) + sbc.drawBars(r, canvasBox) + sbc.drawXAxis(r, canvasBox) + sbc.drawYAxis(r, canvasBox) + + sbc.drawTitle(r) + for _, a := range sbc.Elements { + a(r, canvasBox, sbc.styleDefaultsElements()) + } + + return r.Save(w) +} + +func (sbc StackedBarChart) drawBars(r Renderer, canvasBox Box) { + xoffset := canvasBox.Left + for _, bar := range sbc.Bars { + sbc.drawBar(r, canvasBox, xoffset, bar) + xoffset += (sbc.GetBarSpacing() + bar.GetWidth()) + } +} + +func (sbc StackedBarChart) drawBar(r Renderer, canvasBox Box, xoffset int, bar StackedBar) int { + barSpacing2 := sbc.GetBarSpacing() >> 1 + bxl := xoffset + barSpacing2 + bxr := bxl + bar.GetWidth() + + normalizedBarComponents := Values(bar.Values).Normalize() + yoffset := canvasBox.Top + for index, bv := range normalizedBarComponents { + barHeight := int(math.Ceil(bv.Value * float64(canvasBox.Height()))) + barBox := Box{ + Top: yoffset, + Left: bxl, + Right: bxr, + Bottom: Math.MinInt(yoffset+barHeight, canvasBox.Bottom-DefaultStrokeWidth), + } + Draw.Box(r, barBox, bv.Style.InheritFrom(sbc.styleDefaultsStackedBarValue(index))) + yoffset += barHeight + } + + return bxr +} + +func (sbc StackedBarChart) drawXAxis(r Renderer, canvasBox Box) { + if sbc.XAxis.Show { + axisStyle := sbc.XAxis.InheritFrom(sbc.styleDefaultsAxes()) + axisStyle.WriteToRenderer(r) + + r.MoveTo(canvasBox.Left, canvasBox.Bottom) + r.LineTo(canvasBox.Right, canvasBox.Bottom) + r.Stroke() + + r.MoveTo(canvasBox.Left, canvasBox.Bottom) + r.LineTo(canvasBox.Left, canvasBox.Bottom+DefaultVerticalTickHeight) + r.Stroke() + + cursor := canvasBox.Left + for _, bar := range sbc.Bars { + + barLabelBox := Box{ + Top: canvasBox.Bottom + DefaultXAxisMargin, + Left: cursor, + Right: cursor + bar.GetWidth() + sbc.GetBarSpacing(), + Bottom: sbc.GetHeight(), + } + if len(bar.Name) > 0 { + Draw.TextWithin(r, bar.Name, barLabelBox, axisStyle) + } + axisStyle.WriteToRenderer(r) + r.MoveTo(barLabelBox.Right, canvasBox.Bottom) + r.LineTo(barLabelBox.Right, canvasBox.Bottom+DefaultVerticalTickHeight) + r.Stroke() + cursor += bar.GetWidth() + sbc.GetBarSpacing() + } + } +} + +func (sbc StackedBarChart) drawYAxis(r Renderer, canvasBox Box) { + if sbc.YAxis.Show { + axisStyle := sbc.YAxis.InheritFrom(sbc.styleDefaultsAxes()) + axisStyle.WriteToRenderer(r) + r.MoveTo(canvasBox.Right, canvasBox.Top) + r.LineTo(canvasBox.Right, canvasBox.Bottom) + r.Stroke() + + r.MoveTo(canvasBox.Right, canvasBox.Bottom) + r.LineTo(canvasBox.Right+DefaultHorizontalTickWidth, canvasBox.Bottom) + r.Stroke() + + ticks := Sequence.Float64(1.0, 0.0, 0.2) + for _, t := range ticks { + axisStyle.GetStrokeOptions().WriteToRenderer(r) + ty := canvasBox.Bottom - int(t*float64(canvasBox.Height())) + r.MoveTo(canvasBox.Right, ty) + r.LineTo(canvasBox.Right+DefaultHorizontalTickWidth, ty) + r.Stroke() + + axisStyle.GetTextOptions().WriteToRenderer(r) + text := fmt.Sprintf("%0.0f%%", t*100) + + tb := r.MeasureText(text) + Draw.Text(r, text, canvasBox.Right+DefaultYAxisMargin+5, ty+(tb.Height()>>1), axisStyle) + } + + } +} + +func (sbc StackedBarChart) drawTitle(r Renderer) { + if len(sbc.Title) > 0 && sbc.TitleStyle.Show { + Draw.TextWithin(r, sbc.Title, sbc.Box(), sbc.styleDefaultsTitle()) + } +} + +func (sbc StackedBarChart) getDefaultCanvasBox() Box { + return sbc.Box() +} + +func (sbc StackedBarChart) getAdjustedCanvasBox(r Renderer, canvasBox Box) Box { + var totalWidth int + for _, bar := range sbc.Bars { + totalWidth += bar.GetWidth() + sbc.GetBarSpacing() + } + + if sbc.XAxis.Show { + xaxisHeight := DefaultVerticalTickHeight + + axisStyle := sbc.XAxis.InheritFrom(sbc.styleDefaultsAxes()) + axisStyle.WriteToRenderer(r) + + cursor := canvasBox.Left + for _, bar := range sbc.Bars { + if len(bar.Name) > 0 { + barLabelBox := Box{ + Top: canvasBox.Bottom + DefaultXAxisMargin, + Left: cursor, + Right: cursor + bar.GetWidth() + sbc.GetBarSpacing(), + Bottom: sbc.GetHeight(), + } + lines := Text.WrapFit(r, bar.Name, barLabelBox.Width(), axisStyle) + linesBox := Text.MeasureLines(r, lines, axisStyle) + + xaxisHeight = Math.MaxInt(linesBox.Height()+(2*DefaultXAxisMargin), xaxisHeight) + } + } + return Box{ + Top: canvasBox.Top, + Left: canvasBox.Left, + Right: canvasBox.Left + totalWidth, + Bottom: sbc.GetHeight() - xaxisHeight, + } + } + return Box{ + Top: canvasBox.Top, + Left: canvasBox.Left, + Right: canvasBox.Left + totalWidth, + Bottom: canvasBox.Bottom, + } + +} + +// Box returns the chart bounds as a box. +func (sbc StackedBarChart) Box() Box { + dpr := sbc.Background.Padding.GetRight(10) + dpb := sbc.Background.Padding.GetBottom(50) + + return Box{ + Top: 20, + Left: 20, + Right: sbc.GetWidth() - dpr, + Bottom: sbc.GetHeight() - dpb, + } +} + +func (sbc StackedBarChart) styleDefaultsStackedBarValue(index int) Style { + return Style{ + StrokeColor: GetAlternateColor(index), + StrokeWidth: 3.0, + FillColor: GetAlternateColor(index), + } +} + +func (sbc StackedBarChart) styleDefaultsTitle() Style { + return sbc.TitleStyle.InheritFrom(Style{ + FontColor: DefaultTextColor, + Font: sbc.GetFont(), + FontSize: sbc.getTitleFontSize(), + TextHorizontalAlign: TextHorizontalAlignCenter, + TextVerticalAlign: TextVerticalAlignTop, + TextWrap: TextWrapWord, + }) +} + +func (sbc StackedBarChart) getTitleFontSize() float64 { + effectiveDimension := Math.MinInt(sbc.GetWidth(), sbc.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 +} + +func (sbc StackedBarChart) styleDefaultsAxes() Style { + return Style{ + StrokeColor: DefaultAxisColor, + Font: sbc.GetFont(), + FontSize: DefaultAxisFontSize, + FontColor: DefaultAxisColor, + TextHorizontalAlign: TextHorizontalAlignCenter, + TextVerticalAlign: TextVerticalAlignTop, + TextWrap: TextWrapWord, + } +} +func (sbc StackedBarChart) styleDefaultsElements() Style { + return Style{ + Font: sbc.GetFont(), + } +} diff --git a/style.go b/style.go index dbfb4fb..a9eb1c5 100644 --- a/style.go +++ b/style.go @@ -21,6 +21,11 @@ type Style struct { FontSize float64 FontColor drawing.Color Font *truetype.Font + + TextHorizontalAlign textHorizontalAlign + TextVerticalAlign textVerticalAlign + TextWrap textWrap + TextLineSpacing int } // IsZero returns if the object is set or not. @@ -28,6 +33,7 @@ func (s Style) IsZero() bool { return s.StrokeColor.IsZero() && s.FillColor.IsZero() && s.StrokeWidth == 0 && s.FontColor.IsZero() && s.FontSize == 0 && s.Font == nil } +// String returns a text representation of the style. func (s Style) String() string { if s.IsZero() { return "{}" @@ -78,11 +84,11 @@ func (s Style) String() string { if s.FontSize != 0 { output = append(output, fmt.Sprintf("\"font_size\": \"%0.2fpt\"", s.FontSize)) } else { - output = append(output, "\"fill_color\": null") + output = append(output, "\"font_size\": null") } - if !s.FillColor.IsZero() { - output = append(output, fmt.Sprintf("\"font_color\": %s", s.FillColor.String())) + if !s.FontColor.IsZero() { + output = append(output, fmt.Sprintf("\"font_color\": %s", s.FontColor.String())) } else { output = append(output, "\"font_color\": null") } @@ -184,8 +190,78 @@ func (s Style) GetPadding(defaults ...Box) Box { return s.Padding } -// WithDefaultsFrom coalesces two styles into a new style. -func (s Style) WithDefaultsFrom(defaults Style) (final Style) { +// GetTextHorizontalAlign returns the horizontal alignment. +func (s Style) GetTextHorizontalAlign(defaults ...textHorizontalAlign) textHorizontalAlign { + if s.TextHorizontalAlign == TextHorizontalAlignUnset { + if len(defaults) > 0 { + return defaults[0] + } + return TextHorizontalAlignUnset + } + return s.TextHorizontalAlign +} + +// GetTextVerticalAlign returns the vertical alignment. +func (s Style) GetTextVerticalAlign(defaults ...textVerticalAlign) textVerticalAlign { + if s.TextVerticalAlign == TextVerticalAlignUnset { + if len(defaults) > 0 { + return defaults[0] + } + return TextVerticalAlignUnset + } + return s.TextVerticalAlign +} + +// GetTextWrap returns the word wrap. +func (s Style) GetTextWrap(defaults ...textWrap) textWrap { + if s.TextWrap == TextWrapUnset { + if len(defaults) > 0 { + return defaults[0] + } + return TextWrapUnset + } + return s.TextWrap +} + +// GetTextLineSpacing returns the spacing in pixels between lines of text (vertically). +func (s Style) GetTextLineSpacing(defaults ...int) int { + if s.TextLineSpacing == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return DefaultLineSpacing + } + return s.TextLineSpacing +} + +// WriteToRenderer passes the style's options to a renderer. +func (s Style) WriteToRenderer(r Renderer) { + r.SetStrokeColor(s.GetStrokeColor()) + r.SetStrokeWidth(s.GetStrokeWidth()) + r.SetStrokeDashArray(s.GetStrokeDashArray()) + r.SetFillColor(s.GetFillColor()) + r.SetFont(s.GetFont()) + r.SetFontColor(s.GetFontColor()) + r.SetFontSize(s.GetFontSize()) +} + +// WriteDrawingOptionsToRenderer passes just the drawing style options to a renderer. +func (s Style) WriteDrawingOptionsToRenderer(r Renderer) { + r.SetStrokeColor(s.GetStrokeColor()) + r.SetStrokeWidth(s.GetStrokeWidth()) + r.SetStrokeDashArray(s.GetStrokeDashArray()) + r.SetFillColor(s.GetFillColor()) +} + +// WriteTextOptionsToRenderer passes just the text style options to a renderer. +func (s Style) WriteTextOptionsToRenderer(r Renderer) { + r.SetFont(s.GetFont()) + r.SetFontColor(s.GetFontColor()) + r.SetFontSize(s.GetFontSize()) +} + +// InheritFrom coalesces two styles into a new style. +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) @@ -194,47 +270,15 @@ func (s Style) WithDefaultsFrom(defaults Style) (final Style) { final.FontSize = s.GetFontSize(defaults.FontSize) final.Font = s.GetFont(defaults.Font) final.Padding = s.GetPadding(defaults.Padding) + final.TextHorizontalAlign = s.GetTextHorizontalAlign(defaults.TextHorizontalAlign) + final.TextVerticalAlign = s.GetTextVerticalAlign(defaults.TextVerticalAlign) + final.TextWrap = s.GetTextWrap(defaults.TextWrap) + final.TextLineSpacing = s.GetTextLineSpacing(defaults.TextLineSpacing) return } -// SVG returns the style as a svg style string. -func (s Style) SVG(dpi float64) string { - sw := s.StrokeWidth - sc := s.StrokeColor - fc := s.FillColor - fs := s.FontSize - fnc := s.FontColor - - strokeWidthText := "stroke-width:0" - if sw != 0 { - strokeWidthText = "stroke-width:" + fmt.Sprintf("%d", int(sw)) - } - - strokeText := "stroke:none" - if !sc.IsZero() { - strokeText = "stroke:" + sc.String() - } - - fillText := "fill:none" - if !fc.IsZero() { - fillText = "fill:" + fc.String() - } - - fontSizeText := "" - if fs != 0 { - fontSizeText = "font-size:" + fmt.Sprintf("%.1fpx", drawing.PointsToPixels(dpi, fs)) - } - - if !fnc.IsZero() { - fillText = "fill:" + fnc.String() - } - - fontText := s.SVGFontFace() - return strings.Join([]string{strokeWidthText, strokeText, fillText, fontSizeText, fontText}, ";") -} - -// SVGStroke returns the stroke components. -func (s Style) SVGStroke() Style { +// GetStrokeOptions returns the stroke components. +func (s Style) GetStrokeOptions() Style { return Style{ StrokeDashArray: s.StrokeDashArray, StrokeColor: s.StrokeColor, @@ -242,15 +286,15 @@ func (s Style) SVGStroke() Style { } } -// SVGFill returns the fill components. -func (s Style) SVGFill() Style { +// GetFillOptions returns the fill components. +func (s Style) GetFillOptions() Style { return Style{ FillColor: s.FillColor, } } -// SVGFillAndStroke returns the fill and stroke components. -func (s Style) SVGFillAndStroke() Style { +// GetFillAndStrokeOptions returns the fill and stroke components. +func (s Style) GetFillAndStrokeOptions() Style { return Style{ StrokeDashArray: s.StrokeDashArray, FillColor: s.FillColor, @@ -259,34 +303,15 @@ func (s Style) SVGFillAndStroke() Style { } } -// SVGText returns just the text components of the style. -func (s Style) SVGText() Style { +// GetTextOptions returns just the text components of the style. +func (s Style) GetTextOptions() Style { return Style{ - FontColor: s.FontColor, - FontSize: s.FontSize, + FontColor: s.FontColor, + FontSize: s.FontSize, + Font: s.Font, + TextHorizontalAlign: s.TextHorizontalAlign, + TextVerticalAlign: s.TextVerticalAlign, + TextWrap: s.TextWrap, + TextLineSpacing: s.TextLineSpacing, } } - -// SVGFontFace returns the font face for the style. -func (s Style) SVGFontFace() string { - family := "sans-serif" - if s.GetFont() != nil { - name := s.GetFont().Name(truetype.NameIDFontFamily) - if len(name) != 0 { - family = fmt.Sprintf(`'%s',%s`, name, family) - } - } - return fmt.Sprintf("font-family:%s", family) -} - -// SVGStrokeDashArray returns the stroke-dasharray property of a style. -func (s Style) SVGStrokeDashArray() string { - if len(s.StrokeDashArray) > 0 { - var values []string - for _, v := range s.StrokeDashArray { - values = append(values, fmt.Sprintf("%0.1f", v)) - } - return "stroke-dasharray=\"" + strings.Join(values, ", ") + "\"" - } - return "" -} diff --git a/style_test.go b/style_test.go index cb7ffdd..4fe8303 100644 --- a/style_test.go +++ b/style_test.go @@ -1,7 +1,6 @@ package chart import ( - "strings" "testing" "github.com/blendlabs/go-assert" @@ -142,33 +141,11 @@ func TestStyleWithDefaultsFrom(t *testing.T) { Padding: DefaultBackgroundPadding, } - coalesced := unset.WithDefaultsFrom(set) + coalesced := unset.InheritFrom(set) assert.Equal(set, coalesced) } -func TestStyleSVG(t *testing.T) { - assert := assert.New(t) - - f, err := GetDefaultFont() - assert.Nil(err) - - set := Style{ - StrokeColor: drawing.ColorWhite, - StrokeWidth: 5.0, - FillColor: drawing.ColorWhite, - FontColor: drawing.ColorWhite, - Font: f, - Padding: DefaultBackgroundPadding, - } - - svgString := set.SVG(DefaultDPI) - assert.NotEmpty(svgString) - assert.True(strings.Contains(svgString, "stroke:rgba(255,255,255,1.0)")) - assert.True(strings.Contains(svgString, "stroke-width:5")) - assert.True(strings.Contains(svgString, "fill:rgba(255,255,255,1.0)")) -} - -func TestStyleSVGStroke(t *testing.T) { +func TestStyleGetStrokeOptions(t *testing.T) { assert := assert.New(t) set := Style{ @@ -178,14 +155,14 @@ func TestStyleSVGStroke(t *testing.T) { FontColor: drawing.ColorWhite, Padding: DefaultBackgroundPadding, } - svgStroke := set.SVGStroke() + svgStroke := set.GetStrokeOptions() assert.False(svgStroke.StrokeColor.IsZero()) assert.NotZero(svgStroke.StrokeWidth) assert.True(svgStroke.FillColor.IsZero()) assert.True(svgStroke.FontColor.IsZero()) } -func TestStyleSVGFill(t *testing.T) { +func TestStyleGetFillOptions(t *testing.T) { assert := assert.New(t) set := Style{ @@ -195,14 +172,14 @@ func TestStyleSVGFill(t *testing.T) { FontColor: drawing.ColorWhite, Padding: DefaultBackgroundPadding, } - svgFill := set.SVGFill() + svgFill := set.GetFillOptions() assert.False(svgFill.FillColor.IsZero()) assert.Zero(svgFill.StrokeWidth) assert.True(svgFill.StrokeColor.IsZero()) assert.True(svgFill.FontColor.IsZero()) } -func TestStyleSVGFillAndStroke(t *testing.T) { +func TestStyleGetFillAndStrokeOptions(t *testing.T) { assert := assert.New(t) set := Style{ @@ -212,14 +189,14 @@ func TestStyleSVGFillAndStroke(t *testing.T) { FontColor: drawing.ColorWhite, Padding: DefaultBackgroundPadding, } - svgFillAndStroke := set.SVGFillAndStroke() + svgFillAndStroke := set.GetFillAndStrokeOptions() assert.False(svgFillAndStroke.FillColor.IsZero()) assert.NotZero(svgFillAndStroke.StrokeWidth) assert.False(svgFillAndStroke.StrokeColor.IsZero()) assert.True(svgFillAndStroke.FontColor.IsZero()) } -func TestStyleSVGText(t *testing.T) { +func TestStyleGetTextOptions(t *testing.T) { assert := assert.New(t) set := Style{ @@ -229,7 +206,7 @@ func TestStyleSVGText(t *testing.T) { FontColor: drawing.ColorWhite, Padding: DefaultBackgroundPadding, } - svgStroke := set.SVGText() + svgStroke := set.GetTextOptions() assert.True(svgStroke.StrokeColor.IsZero()) assert.Zero(svgStroke.StrokeWidth) assert.True(svgStroke.FillColor.IsZero()) diff --git a/text.go b/text.go new file mode 100644 index 0000000..6e45c1c --- /dev/null +++ b/text.go @@ -0,0 +1,164 @@ +package chart + +import "strings" + +// TextHorizontalAlign is an enum for the horizontal alignment options. +type textHorizontalAlign int + +const ( + // TextHorizontalAlignUnset is the unset state for text horizontal alignment. + TextHorizontalAlignUnset textHorizontalAlign = 0 + // TextHorizontalAlignLeft aligns a string horizontally so that it's left ligature starts at horizontal pixel 0. + TextHorizontalAlignLeft textHorizontalAlign = 1 + // TextHorizontalAlignCenter left aligns a string horizontally so that there are equal pixels + // to the left and to the right of a string within a box. + TextHorizontalAlignCenter textHorizontalAlign = 2 + // TextHorizontalAlignRight right aligns a string horizontally so that the right ligature ends at the right-most pixel + // of a box. + TextHorizontalAlignRight textHorizontalAlign = 3 +) + +// TextWrap is an enum for the word wrap options. +type textWrap int + +const ( + // TextWrapUnset is the unset state for text wrap options. + TextWrapUnset textWrap = 0 + // TextWrapNone will spill text past horizontal boundaries. + TextWrapNone textWrap = 1 + // TextWrapWord will split a string on words (i.e. spaces) to fit within a horizontal boundary. + TextWrapWord textWrap = 2 + // TextWrapRune will split a string on a rune (i.e. utf-8 codepage) to fit within a horizontal boundary. + TextWrapRune textWrap = 3 +) + +// TextVerticalAlign is an enum for the vertical alignment options. +type textVerticalAlign int + +const ( + // TextVerticalAlignUnset is the unset state for vertical alignment options. + TextVerticalAlignUnset textVerticalAlign = 0 + // TextVerticalAlignBaseline aligns text according to the "baseline" of the string, or where a normal ascender begins. + TextVerticalAlignBaseline textVerticalAlign = 1 + // TextVerticalAlignBottom aligns the text according to the lowers pixel of any of the ligatures (ex. g or q both extend below the baseline). + TextVerticalAlignBottom textVerticalAlign = 2 + // TextVerticalAlignMiddle aligns the text so that there is an equal amount of space above and below the top and bottom of the ligatures. + TextVerticalAlignMiddle textVerticalAlign = 3 + // TextVerticalAlignMiddleBaseline aligns the text veritcally so that there is an equal number of pixels above and below the baseline of the string. + TextVerticalAlignMiddleBaseline textVerticalAlign = 4 + // TextVerticalAlignTop alignts the text so that the top of the ligatures are at y-pixel 0 in the container. + TextVerticalAlignTop textVerticalAlign = 5 +) + +var ( + // Text contains utilities for text. + Text = &text{} +) + +// TextStyle encapsulates text style options. +type TextStyle struct { + HorizontalAlign textHorizontalAlign + VerticalAlign textVerticalAlign + Wrap textWrap +} + +type text struct{} + +func (t text) WrapFit(r Renderer, value string, width int, style Style) []string { + switch style.TextWrap { + case TextWrapRune: + return t.WrapFitRune(r, value, width, style) + case TextWrapWord: + return t.WrapFitWord(r, value, width, style) + } + return []string{value} +} + +func (t text) WrapFitWord(r Renderer, value string, width int, style Style) []string { + style.WriteToRenderer(r) + + var output []string + var line string + var word string + + var textBox Box + + for _, c := range value { + if c == rune('\n') { // commit the line to output + output = append(output, t.Trim(line+word)) + line = "" + word = "" + continue + } + + textBox = r.MeasureText(line + word + string(c)) + + if textBox.Width() >= width { + output = append(output, t.Trim(line)) + line = word + word = string(c) + continue + } + + if c == rune(' ') || c == rune('\t') { + line = line + word + string(c) + word = "" + continue + } + word = word + string(c) + } + + return append(output, t.Trim(line+word)) +} + +func (t text) WrapFitRune(r Renderer, value string, width int, style Style) []string { + style.WriteToRenderer(r) + + var output []string + var line string + var textBox Box + for _, c := range value { + if c == rune('\n') { + output = append(output, line) + line = "" + continue + } + + textBox = r.MeasureText(line + string(c)) + + if textBox.Width() >= width { + output = append(output, line) + line = string(c) + continue + } + line = line + string(c) + } + return t.appendLast(output, line) +} + +func (t text) Trim(value string) string { + return strings.Trim(value, " \t\n\r") +} + +func (t text) MeasureLines(r Renderer, lines []string, style Style) Box { + style.WriteTextOptionsToRenderer(r) + var output Box + for index, line := range lines { + lineBox := r.MeasureText(line) + output.Right = Math.MaxInt(lineBox.Right, output.Right) + output.Bottom += lineBox.Height() + if index < len(lines)-1 { + output.Bottom += +style.GetTextLineSpacing() + } + } + return output +} + +func (t text) appendLast(lines []string, text string) []string { + if len(lines) == 0 { + return []string{text} + } + lastLine := lines[len(lines)-1] + lines[len(lines)-1] = lastLine + text + return lines +} diff --git a/text_test.go b/text_test.go new file mode 100644 index 0000000..78c0e9b --- /dev/null +++ b/text_test.go @@ -0,0 +1,60 @@ +package chart + +import ( + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestTextWrapWord(t *testing.T) { + assert := assert.New(t) + + r, err := PNG(1024, 1024) + assert.Nil(err) + f, err := GetDefaultFont() + assert.Nil(err) + + basicTextStyle := Style{Font: f, FontSize: 24} + + output := Text.WrapFitWord(r, "this is a test string", 100, basicTextStyle) + assert.NotEmpty(output) + assert.Len(output, 3) + + for _, line := range output { + basicTextStyle.WriteToRenderer(r) + lineBox := r.MeasureText(line) + assert.True(lineBox.Width() < 100, line+": "+lineBox.String()) + } + assert.Equal("this is", output[0]) + assert.Equal("a test", output[1]) + assert.Equal("string", output[2]) + + output = Text.WrapFitWord(r, "foo", 100, basicTextStyle) + assert.Len(output, 1) + assert.Equal("foo", output[0]) + + // test that it handles newlines. + output = Text.WrapFitWord(r, "this\nis\na\ntest\nstring", 100, basicTextStyle) + assert.Len(output, 5) + + // test that it handles newlines and long lines. + output = Text.WrapFitWord(r, "this\nis\na\ntest\nstring that is very long", 100, basicTextStyle) + assert.Len(output, 8) +} + +func TestTextWrapRune(t *testing.T) { + assert := assert.New(t) + + r, err := PNG(1024, 1024) + assert.Nil(err) + f, err := GetDefaultFont() + assert.Nil(err) + + basicTextStyle := Style{Font: f, FontSize: 24} + + output := Text.WrapFitRune(r, "this is a test string", 150, basicTextStyle) + assert.NotEmpty(output) + assert.Len(output, 2) + assert.Equal("this is a t", output[0]) + assert.Equal("est string", output[1]) +} diff --git a/tick.go b/tick.go index 850adb7..f50f7f2 100644 --- a/tick.go +++ b/tick.go @@ -1,21 +1,10 @@ package chart -// GenerateTicksWithStep generates a set of ticks. -func GenerateTicksWithStep(ra Range, step float64, vf ValueFormatter) []Tick { - var ticks []Tick - min, max := ra.Min, ra.Max - for cursor := min; cursor <= max; cursor += step { - ticks = append(ticks, Tick{ - Value: cursor, - Label: vf(cursor), - }) +import "math" - // this guard is in place in case step is super, super small. - if len(ticks) > DefaultTickCountSanityCheck { - return ticks - } - } - return ticks +// TicksProvider is a type that provides ticks. +type TicksProvider interface { + GetTicks(vf ValueFormatter) []Tick } // Tick represents a label on an axis. @@ -41,3 +30,52 @@ func (t Ticks) Swap(i, j int) { func (t Ticks) Less(i, j int) bool { return t[i].Value < t[j].Value } + +// GenerateContinuousTicksWithStep generates a set of ticks. +func GenerateContinuousTicksWithStep(ra Range, step float64, vf ValueFormatter, includeMax bool) []Tick { + var ticks []Tick + min, max := ra.GetMin(), ra.GetMax() + for cursor := min; cursor <= max; cursor += step { + ticks = append(ticks, Tick{ + Value: cursor, + Label: vf(cursor), + }) + + // this guard is in place in case step is super, super small. + if len(ticks) > DefaultTickCountSanityCheck { + return ticks + } + } + if includeMax { + ticks = append(ticks, Tick{ + Value: ra.GetMax(), + Label: vf(ra.GetMax()), + }) + } + return ticks +} + +// CalculateContinuousTickStep calculates the continous range interval between ticks. +func CalculateContinuousTickStep(r Renderer, ra Range, isVertical bool, style Style, vf ValueFormatter) float64 { + r.SetFont(style.GetFont()) + r.SetFontSize(style.GetFontSize()) + if isVertical { + label := vf(ra.GetMin()) + tb := r.MeasureText(label) + count := int(math.Ceil(float64(ra.GetDomain()) / float64(tb.Height()+DefaultMinimumTickVerticalSpacing))) + return ra.GetDelta() / float64(count) + } + + // take a cut at determining the 'widest' value. + l0 := vf(ra.GetMin()) + ln := vf(ra.GetMax()) + ll := l0 + if len(ln) > len(l0) { + ll = ln + } + llb := r.MeasureText(ll) + textWidth := llb.Width() + width := textWidth + DefaultMinimumTickHorizontalSpacing + count := int(math.Ceil(float64(ra.GetDomain()) / float64(width))) + return ra.GetDelta() / float64(count) +} diff --git a/tick_test.go b/tick_test.go index 8d75f01..e1fd2f4 100644 --- a/tick_test.go +++ b/tick_test.go @@ -9,6 +9,6 @@ import ( func TestGenerateTicksWithStep(t *testing.T) { assert := assert.New(t) - ticks := GenerateTicksWithStep(Range{Min: 1.0, Max: 10.0, Domain: 100}, 1.0, FloatValueFormatter) + ticks := GenerateContinuousTicksWithStep(&ContinuousRange{Min: 1.0, Max: 10.0, Domain: 100}, 1.0, FloatValueFormatter, false) assert.Len(ticks, 10) } diff --git a/time_series.go b/time_series.go index 37ba6b9..8869cac 100644 --- a/time_series.go +++ b/time_series.go @@ -7,7 +7,7 @@ type TimeSeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType XValues []time.Time YValues []float64 @@ -50,12 +50,12 @@ func (ts TimeSeries) GetValueFormatters() (x, y ValueFormatter) { } // GetYAxis returns which YAxis the series draws on. -func (ts TimeSeries) GetYAxis() YAxisType { +func (ts TimeSeries) GetYAxis() yAxisType { return ts.YAxis } // Render renders the series. func (ts TimeSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - style := ts.Style.WithDefaultsFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, ts) + style := ts.Style.InheritFrom(defaults) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, ts) } diff --git a/util.go b/util.go deleted file mode 100644 index 77489c2..0000000 --- a/util.go +++ /dev/null @@ -1,172 +0,0 @@ -package chart - -import ( - "fmt" - "math" - "math/rand" - "time" -) - -// Float is an alias for float64 that provides a better .String() method. -type Float float64 - -// String returns the string representation of a float. -func (f Float) String() string { - return fmt.Sprintf("%.2f", f) -} - -// TimeToFloat64 returns a float64 representation of a time. -func TimeToFloat64(t time.Time) float64 { - return float64(t.UnixNano()) -} - -// MinAndMax returns both the min and max in one pass. -func MinAndMax(values ...float64) (min float64, max float64) { - if len(values) == 0 { - return - } - min = values[0] - max = values[0] - for _, v := range values { - if max < v { - max = v - } - if min > v { - min = v - } - } - return -} - -// MinAndMaxOfTime returns the min and max of a given set of times -// in one pass. -func MinAndMaxOfTime(values ...time.Time) (min time.Time, max time.Time) { - if len(values) == 0 { - return - } - - min = values[0] - max = values[0] - - for _, v := range values { - if max.Before(v) { - max = v - } - if min.After(v) { - min = v - } - } - return -} - -// Slices generates N slices that span the total. -// The resulting array will be intermediate indexes until total. -func Slices(count int, total float64) []float64 { - var values []float64 - sliceWidth := float64(total) / float64(count) - for cursor := 0.0; cursor < total; cursor += sliceWidth { - values = append(values, cursor) - } - return values -} - -// GetRoundToForDelta returns a `roundTo` value for a given delta. -func GetRoundToForDelta(delta float64) float64 { - startingDeltaBound := math.Pow(10.0, 10.0) - for cursor := startingDeltaBound; cursor > 0; cursor /= 10.0 { - if delta > cursor { - return cursor / 10.0 - } - } - - return 0.0 -} - -// RoundUp rounds up to a given roundTo value. -func RoundUp(value, roundTo float64) float64 { - d1 := math.Ceil(value / roundTo) - return d1 * roundTo -} - -// RoundDown rounds down to a given roundTo value. -func RoundDown(value, roundTo float64) float64 { - d1 := math.Floor(value / roundTo) - return d1 * roundTo -} - -// MinInt returns the minimum of a set of integers. -func MinInt(values ...int) int { - min := math.MaxInt32 - for _, v := range values { - if v < min { - min = v - } - } - return min -} - -// MaxInt returns the maximum of a set of integers. -func MaxInt(values ...int) int { - max := math.MinInt32 - for _, v := range values { - if v > max { - max = v - } - } - return max -} - -// AbsInt returns the absolute value of an integer. -func AbsInt(value int) int { - if value < 0 { - return -value - } - return value -} - -// Seq produces an array of floats from [start,end] by optional steps. -func Seq(start, end float64, steps ...float64) []float64 { - var values []float64 - step := 1.0 - if len(steps) > 0 { - step = steps[0] - } - - if start < end { - for x := start; x <= end; x += step { - values = append(values, x) - } - } else { - for x := start; x >= end; x = x - step { - values = append(values, x) - } - } - return values -} - -// SeqRand generates a random sequence. -func SeqRand(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 - } - - return values -} - -// SeqDays generates a sequence of timestamps by day, from -days to today. -func SeqDays(days int) []time.Time { - var values []time.Time - for day := days; day >= 0; day-- { - values = append(values, time.Now().AddDate(0, 0, -day)) - } - return values -} - -// PercentDifference computes the percentage difference between two values. -// The formula is (v2-v1)/v1. -func PercentDifference(v1, v2 float64) float64 { - return (v2 - v1) / v1 -} diff --git a/util_test.go b/util_test.go deleted file mode 100644 index aff684e..0000000 --- a/util_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package chart - -import ( - "testing" - "time" - - "github.com/blendlabs/go-assert" -) - -func TestMinAndMax(t *testing.T) { - assert := assert.New(t) - values := []float64{1.0, 2.0, 3.0, 4.0} - min, max := MinAndMax(values...) - assert.Equal(1.0, min) - assert.Equal(4.0, max) -} - -func TestMinAndMaxReversed(t *testing.T) { - assert := assert.New(t) - values := []float64{4.0, 2.0, 3.0, 1.0} - min, max := MinAndMax(values...) - assert.Equal(1.0, min) - assert.Equal(4.0, max) -} - -func TestMinAndMaxEmpty(t *testing.T) { - assert := assert.New(t) - values := []float64{} - min, max := MinAndMax(values...) - assert.Equal(0.0, min) - assert.Equal(0.0, max) -} - -func TestMinAndMaxOfTime(t *testing.T) { - assert := assert.New(t) - values := []time.Time{ - time.Now().AddDate(0, 0, -1), - time.Now().AddDate(0, 0, -2), - time.Now().AddDate(0, 0, -3), - time.Now().AddDate(0, 0, -4), - } - min, max := MinAndMaxOfTime(values...) - assert.Equal(values[3], min) - assert.Equal(values[0], max) -} - -func TestMinAndMaxOfTimeReversed(t *testing.T) { - assert := assert.New(t) - values := []time.Time{ - time.Now().AddDate(0, 0, -4), - time.Now().AddDate(0, 0, -2), - time.Now().AddDate(0, 0, -3), - time.Now().AddDate(0, 0, -1), - } - min, max := MinAndMaxOfTime(values...) - assert.Equal(values[0], min) - assert.Equal(values[3], max) -} - -func TestMinAndMaxOfTimeEmpty(t *testing.T) { - assert := assert.New(t) - values := []time.Time{} - min, max := MinAndMaxOfTime(values...) - assert.Equal(time.Time{}, min) - assert.Equal(time.Time{}, max) -} - -func TestSlices(t *testing.T) { - assert := assert.New(t) - - s := Slices(10, 100) - assert.Len(s, 10) - assert.Equal(0, s[0]) - assert.Equal(10, s[1]) - assert.Equal(20, s[2]) - assert.Equal(90, s[9]) -} - -func TestGetRoundToForDelta(t *testing.T) { - assert := assert.New(t) - - assert.Equal(100.0, GetRoundToForDelta(1001.00)) - assert.Equal(10.0, GetRoundToForDelta(101.00)) - assert.Equal(1.0, GetRoundToForDelta(11.00)) -} - -func TestSeq(t *testing.T) { - assert := assert.New(t) - - asc := Seq(1.0, 10.0) - assert.Len(asc, 10) - - desc := Seq(10.0, 1.0) - assert.Len(desc, 10) -} - -func TestPercentDifference(t *testing.T) { - assert := assert.New(t) - - assert.Equal(0.5, PercentDifference(1.0, 1.5)) - assert.Equal(-0.5, PercentDifference(2.0, 1.0)) -} diff --git a/value.go b/value.go new file mode 100644 index 0000000..8a1da0d --- /dev/null +++ b/value.go @@ -0,0 +1,46 @@ +package chart + +// Value is a chart value. +type Value struct { + Style Style + Label string + Value float64 +} + +// Values is an array of Value. +type Values []Value + +// Values returns the values. +func (vs Values) Values() []float64 { + values := make([]float64, len(vs)) + for index, v := range vs { + values[index] = v.Value + } + return values +} + +// ValuesNormalized returns normalized values. +func (vs Values) ValuesNormalized() []float64 { + return Math.Normalize(vs.Values()...) +} + +// Normalize returns the values normalized. +func (vs Values) Normalize() []Value { + output := make([]Value, len(vs)) + total := Math.Sum(vs.Values()...) + for index, v := range vs { + output[index] = Value{ + Style: v.Style, + Label: v.Label, + Value: Math.RoundDown(v.Value/total, 0.0001), + } + } + return output +} + +// Value2 is a two axis value. +type Value2 struct { + Style Style + Label string + XValue, YValue float64 +} diff --git a/value_test.go b/value_test.go new file mode 100644 index 0000000..0b3b0b5 --- /dev/null +++ b/value_test.go @@ -0,0 +1,69 @@ +package chart + +import ( + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestValuesValues(t *testing.T) { + assert := assert.New(t) + + vs := []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: "!!"}, + } + + values := Values(vs).Values() + assert.Len(values, 7) + assert.Equal(10, values[0]) + assert.Equal(9, values[1]) + assert.Equal(8, values[2]) + assert.Equal(7, values[3]) + assert.Equal(6, values[4]) + assert.Equal(5, values[5]) + assert.Equal(2, values[6]) +} + +func TestValuesValuesNormalized(t *testing.T) { + assert := assert.New(t) + + vs := []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: "!!"}, + } + + values := Values(vs).ValuesNormalized() + assert.Len(values, 7) + assert.Equal(0.2127, values[0]) + assert.Equal(0.0425, values[6]) +} + +func TestValuesNormalize(t *testing.T) { + assert := assert.New(t) + + vs := []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: "!!"}, + } + + values := Values(vs).Normalize() + assert.Len(values, 7) + assert.Equal(0.2127, values[0].Value) + assert.Equal(0.0425, values[6].Value) +} diff --git a/vector_renderer.go b/vector_renderer.go index 606c7e2..7e50dc8 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "math" "strings" "golang.org/x/image/font" @@ -76,6 +77,32 @@ func (vr *vectorRenderer) LineTo(x, y int) { vr.p = append(vr.p, fmt.Sprintf("L %d %d", x, y)) } +// QuadCurveTo draws a quad curve. +func (vr *vectorRenderer) QuadCurveTo(cx, cy, x, y int) { + vr.p = append(vr.p, fmt.Sprintf("Q%d,%d %d,%d", cx, cy, x, y)) +} + +func (vr *vectorRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) { + startAngle = Math.RadianAdd(startAngle, _pi2) + endAngle := Math.RadianAdd(startAngle, delta) + + startx := cx + int(rx*math.Sin(startAngle)) + starty := cy - int(ry*math.Cos(startAngle)) + + if len(vr.p) > 0 { + vr.p = append(vr.p, fmt.Sprintf("L %d %d", startx, starty)) + } else { + vr.p = append(vr.p, fmt.Sprintf("M %d %d", startx, starty)) + } + + endx := cx + int(rx*math.Sin(endAngle)) + endy := cy - int(ry*math.Cos(endAngle)) + + dd := Math.RadiansToDegrees(delta) + + vr.p = append(vr.p, fmt.Sprintf("A %d %d %0.2f 0 1 %d %d", int(rx), int(ry), dd, endx, endy)) +} + // Close closes a shape. func (vr *vectorRenderer) Close() { vr.p = append(vr.p, fmt.Sprintf("Z")) @@ -83,30 +110,28 @@ func (vr *vectorRenderer) Close() { // Stroke draws the path with no fill. func (vr *vectorRenderer) Stroke() { - vr.drawPath(vr.s.SVGStroke()) + vr.drawPath(vr.s.GetStrokeOptions()) } // Fill draws the path with no stroke. func (vr *vectorRenderer) Fill() { - vr.drawPath(vr.s.SVGFill()) + vr.drawPath(vr.s.GetFillOptions()) } // FillStroke draws the path with both fill and stroke. func (vr *vectorRenderer) FillStroke() { - s := vr.s.SVGFillAndStroke() - vr.drawPath(s) + vr.drawPath(vr.s.GetFillAndStrokeOptions()) } // drawPath draws a path. func (vr *vectorRenderer) drawPath(s Style) { - vr.c.Path(strings.Join(vr.p, "\n"), &s) + vr.c.Path(strings.Join(vr.p, "\n"), vr.s.GetFillAndStrokeOptions()) vr.p = []string{} // clear the path } // Circle implements the interface method. func (vr *vectorRenderer) Circle(radius float64, x, y int) { - style := vr.s.SVGFillAndStroke() - vr.c.Circle(x, y, int(radius), &style) + vr.c.Circle(x, y, int(radius), vr.s.GetFillAndStrokeOptions()) } // SetFont implements the interface method. @@ -126,8 +151,7 @@ func (vr *vectorRenderer) SetFontSize(size float64) { // Text draws a text blob. func (vr *vectorRenderer) Text(body string, x, y int) { - style := vr.s.SVGText() - vr.c.Text(x, y, body, &style) + vr.c.Text(x, y, body, vr.s.GetTextOptions()) } // MeasureText uses the truetype font drawer to measure the width of text. @@ -173,22 +197,82 @@ func (c *canvas) Start(width, height int) { c.w.Write([]byte(fmt.Sprintf(`\n`, c.width, c.height))) } -func (c *canvas) Path(d string, style *Style) { +func (c *canvas) Path(d string, style Style) { var strokeDashArrayProperty string if len(style.StrokeDashArray) > 0 { - strokeDashArrayProperty = style.SVGStrokeDashArray() + strokeDashArrayProperty = c.getStrokeDashArray(style) } - c.w.Write([]byte(fmt.Sprintf(`\n`, strokeDashArrayProperty, d, style.SVG(c.dpi)))) + c.w.Write([]byte(fmt.Sprintf(`\n`, strokeDashArrayProperty, d, c.styleAsSVG(style)))) } -func (c *canvas) Text(x, y int, body string, style *Style) { - c.w.Write([]byte(fmt.Sprintf(`%s`, x, y, style.SVG(c.dpi), body))) +func (c *canvas) Text(x, y int, body string, style Style) { + c.w.Write([]byte(fmt.Sprintf(`%s`, x, y, c.styleAsSVG(style), body))) } -func (c *canvas) Circle(x, y, r int, style *Style) { - c.w.Write([]byte(fmt.Sprintf(``, x, y, r, style.SVG(c.dpi)))) +func (c *canvas) Circle(x, y, r int, style Style) { + c.w.Write([]byte(fmt.Sprintf(``, x, y, r, c.styleAsSVG(style)))) } func (c *canvas) End() { c.w.Write([]byte("")) } + +// getStrokeDashArray returns the stroke-dasharray property of a style. +func (c *canvas) getStrokeDashArray(s Style) string { + if len(s.StrokeDashArray) > 0 { + var values []string + for _, v := range s.StrokeDashArray { + values = append(values, fmt.Sprintf("%0.1f", v)) + } + return "stroke-dasharray=\"" + strings.Join(values, ", ") + "\"" + } + return "" +} + +// GetFontFace returns the font face for the style. +func (c *canvas) getFontFace(s Style) string { + family := "sans-serif" + if s.GetFont() != nil { + name := s.GetFont().Name(truetype.NameIDFontFamily) + if len(name) != 0 { + family = fmt.Sprintf(`'%s',%s`, name, family) + } + } + return fmt.Sprintf("font-family:%s", family) +} + +// styleAsSVG returns the style as a svg style string. +func (c *canvas) styleAsSVG(s Style) string { + sw := s.StrokeWidth + sc := s.StrokeColor + fc := s.FillColor + fs := s.FontSize + fnc := s.FontColor + + strokeWidthText := "stroke-width:0" + if sw != 0 { + strokeWidthText = "stroke-width:" + fmt.Sprintf("%d", int(sw)) + } + + strokeText := "stroke:none" + if !sc.IsZero() { + strokeText = "stroke:" + sc.String() + } + + fillText := "fill:none" + if !fc.IsZero() { + fillText = "fill:" + fc.String() + } + + fontSizeText := "" + if fs != 0 { + fontSizeText = "font-size:" + fmt.Sprintf("%.1fpx", drawing.PointsToPixels(c.dpi, fs)) + } + + if !fnc.IsZero() { + fillText = "fill:" + fnc.String() + } + + fontText := c.getFontFace(s) + return strings.Join([]string{strokeWidthText, strokeText, fillText, fontSizeText, fontText}, ";") +} diff --git a/vector_renderer_test.go b/vector_renderer_test.go index a9020ef..f802970 100644 --- a/vector_renderer_test.go +++ b/vector_renderer_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/blendlabs/go-assert" + "github.com/wcharczuk/go-chart/drawing" ) func TestVectorRendererPath(t *testing.T) { @@ -50,3 +51,27 @@ func TestVectorRendererMeasureText(t *testing.T) { assert.Equal(21, tb.Width()) assert.Equal(15, tb.Height()) } + +func TestCanvasStyleSVG(t *testing.T) { + assert := assert.New(t) + + f, err := GetDefaultFont() + assert.Nil(err) + + set := Style{ + StrokeColor: drawing.ColorWhite, + StrokeWidth: 5.0, + FillColor: drawing.ColorWhite, + FontColor: drawing.ColorWhite, + Font: f, + Padding: DefaultBackgroundPadding, + } + + canvas := &canvas{dpi: DefaultDPI} + + svgString := canvas.styleAsSVG(set) + assert.NotEmpty(svgString) + assert.True(strings.Contains(svgString, "stroke:rgba(255,255,255,1.0)")) + assert.True(strings.Contains(svgString, "stroke-width:5")) + assert.True(strings.Contains(svgString, "fill:rgba(255,255,255,1.0)")) +} diff --git a/xaxis.go b/xaxis.go index 0f3650e..80162cc 100644 --- a/xaxis.go +++ b/xaxis.go @@ -13,6 +13,8 @@ type XAxis struct { Range Range Ticks []Tick + TickPosition tickPosition + GridLines []GridLine GridMajorStyle Style GridMinorStyle Style @@ -28,42 +30,31 @@ func (xa XAxis) GetStyle() Style { return xa.Style } -// GetTicks returns the ticks for a series. It coalesces between user provided ticks and -// generated ticks. +// GetTickPosition returns the tick position option for the axis. +func (xa XAxis) GetTickPosition(defaults ...tickPosition) tickPosition { + if xa.TickPosition == TickPositionUnset { + if len(defaults) > 0 { + return defaults[0] + } + return TickPositionUnderTick + } + return xa.TickPosition +} + +// GetTicks returns the ticks for a series. +// The coalesce priority is: +// - User Supplied Ticks (i.e. Ticks array on the axis itself). +// - Range ticks (i.e. if the range provides ticks). +// - Generating continuous ticks based on minimum spacing and canvas width. func (xa XAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter) []Tick { if len(xa.Ticks) > 0 { return xa.Ticks } - return xa.generateTicks(r, ra, defaults, vf) -} - -func (xa XAxis) generateTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter) []Tick { - step := xa.getTickStep(r, ra, defaults, vf) - return GenerateTicksWithStep(ra, step, vf) -} - -func (xa XAxis) getTickStep(r Renderer, ra Range, defaults Style, vf ValueFormatter) float64 { - tickCount := xa.getTickCount(r, ra, defaults, vf) - step := ra.Delta() / float64(tickCount) - return step -} - -func (xa XAxis) getTickCount(r Renderer, ra Range, defaults Style, vf ValueFormatter) int { - r.SetFont(xa.Style.GetFont(defaults.GetFont())) - r.SetFontSize(xa.Style.GetFontSize(defaults.GetFontSize(DefaultFontSize))) - - // take a cut at determining the 'widest' value. - l0 := vf(ra.Min) - ln := vf(ra.Max) - ll := l0 - if len(ln) > len(l0) { - ll = ln + if tp, isTickProvider := ra.(TicksProvider); isTickProvider { + return tp.GetTicks(vf) } - llb := r.MeasureText(ll) - textWidth := llb.Width() - width := textWidth + DefaultMinimumTickHorizontalSpacing - count := int(math.Ceil(float64(ra.Domain) / float64(width))) - return count + step := CalculateContinuousTickStep(r, ra, false, xa.Style.InheritFrom(defaults), vf) + return GenerateContinuousTicksWithStep(ra, step, vf, xa.TickPosition == TickPositionBetweenTicks) } // GetGridLines returns the gridlines for the axis. @@ -71,18 +62,12 @@ func (xa XAxis) GetGridLines(ticks []Tick) []GridLine { if len(xa.GridLines) > 0 { return xa.GridLines } - return GenerateGridLines(ticks, true) + return GenerateGridLines(ticks, xa.GridMajorStyle, xa.GridMinorStyle, true) } // Measure returns the bounds of the axis. func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) Box { - r.SetStrokeColor(xa.Style.GetStrokeColor(defaults.StrokeColor)) - r.SetStrokeWidth(xa.Style.GetStrokeWidth(defaults.StrokeWidth)) - r.SetStrokeDashArray(xa.Style.GetStrokeDashArray()) - r.SetFont(xa.Style.GetFont(defaults.GetFont())) - r.SetFontColor(xa.Style.GetFontColor(DefaultAxisColor)) - r.SetFontSize(xa.Style.GetFontSize(defaults.GetFontSize())) - + xa.Style.InheritFrom(defaults).WriteToRenderer(r) sort.Sort(Ticks(ticks)) var left, right, top, bottom = math.MaxInt32, 0, math.MaxInt32, 0 @@ -94,10 +79,10 @@ func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic tx := canvasBox.Left + lx ty := canvasBox.Bottom + DefaultXAxisMargin + tb.Height() - top = MinInt(top, canvasBox.Bottom) - left = MinInt(left, tx-(tb.Width()>>1)) - right = MaxInt(right, tx+(tb.Width()>>1)) - bottom = MaxInt(bottom, ty) + top = Math.MinInt(top, canvasBox.Bottom) + left = Math.MinInt(left, tx-(tb.Width()>>1)) + right = Math.MaxInt(right, tx+(tb.Width()>>1)) + bottom = Math.MaxInt(bottom, ty) } return Box{ @@ -110,37 +95,58 @@ func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic // Render renders the axis func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) { - r.SetStrokeColor(xa.Style.GetStrokeColor(defaults.StrokeColor)) - r.SetStrokeWidth(xa.Style.GetStrokeWidth(defaults.StrokeWidth)) - r.SetStrokeDashArray(xa.Style.GetStrokeDashArray()) - r.SetFont(xa.Style.GetFont(defaults.GetFont())) - r.SetFontColor(xa.Style.GetFontColor(DefaultAxisColor)) - r.SetFontSize(xa.Style.GetFontSize(defaults.GetFontSize())) + tickStyle := xa.Style.InheritFrom(defaults) + tickStyle.GetStrokeOptions().WriteToRenderer(r) r.MoveTo(canvasBox.Left, canvasBox.Bottom) r.LineTo(canvasBox.Right, canvasBox.Bottom) r.Stroke() sort.Sort(Ticks(ticks)) - for _, t := range ticks { + tp := xa.GetTickPosition() + + var tx, ty int + for index, t := range ticks { v := t.Value lx := ra.Translate(v) tb := r.MeasureText(t.Label) - tx := canvasBox.Left + lx - ty := canvasBox.Bottom + DefaultXAxisMargin + tb.Height() - r.Text(t.Label, tx-tb.Width()>>1, ty) + tx = canvasBox.Left + lx + ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height() + + tickStyle.GetStrokeOptions().WriteToRenderer(r) r.MoveTo(tx, canvasBox.Bottom) r.LineTo(tx, canvasBox.Bottom+DefaultVerticalTickHeight) r.Stroke() + + switch tp { + case TickPositionUnderTick: + tickStyle.GetTextOptions().WriteToRenderer(r) + r.Text(t.Label, tx-tb.Width()>>1, ty) + case TickPositionBetweenTicks: + if index > 0 { + llx := ra.Translate(ticks[index-1].Value) + ltx := canvasBox.Left + llx + Draw.TextWithin(r, t.Label, Box{ + Left: ltx, + Right: tx, + Top: canvasBox.Bottom + DefaultXAxisMargin, + Bottom: canvasBox.Bottom + DefaultXAxisMargin + tb.Height(), + }, tickStyle.InheritFrom(Style{TextHorizontalAlign: TextHorizontalAlignCenter})) + } + } + } if xa.GridMajorStyle.Show || xa.GridMinorStyle.Show { for _, gl := range xa.GetGridLines(ticks) { - if (gl.IsMinor && xa.GridMinorStyle.Show) || - (!gl.IsMinor && xa.GridMajorStyle.Show) { - gl.Render(r, canvasBox, ra) + if (gl.IsMinor && xa.GridMinorStyle.Show) || (!gl.IsMinor && xa.GridMajorStyle.Show) { + defaults := xa.GridMajorStyle + if gl.IsMinor { + defaults = xa.GridMinorStyle + } + gl.Render(r, canvasBox, ra, defaults) } } } diff --git a/xaxis_test.go b/xaxis_test.go index 933388b..8b749da 100644 --- a/xaxis_test.go +++ b/xaxis_test.go @@ -6,46 +6,6 @@ import ( "github.com/blendlabs/go-assert" ) -func TestXAxisGetTickCount(t *testing.T) { - assert := assert.New(t) - - r, err := PNG(1024, 1024) - assert.Nil(err) - - f, err := GetDefaultFont() - assert.Nil(err) - - xa := XAxis{} - xr := Range{Min: 10, Max: 100, Domain: 1024} - styleDefaults := Style{ - Font: f, - FontSize: 10.0, - } - vf := FloatValueFormatter - count := xa.getTickCount(r, xr, styleDefaults, vf) - assert.Equal(16, count) -} - -func TestXAxisGetTickStep(t *testing.T) { - assert := assert.New(t) - - r, err := PNG(1024, 1024) - assert.Nil(err) - - f, err := GetDefaultFont() - assert.Nil(err) - - xa := XAxis{} - xr := Range{Min: 10, Max: 100, Domain: 1024} - styleDefaults := Style{ - Font: f, - FontSize: 10.0, - } - vf := FloatValueFormatter - step := xa.getTickStep(r, xr, styleDefaults, vf) - assert.Equal(xr.Delta()/16.0, step) -} - func TestXAxisGetTicks(t *testing.T) { assert := assert.New(t) @@ -56,7 +16,7 @@ func TestXAxisGetTicks(t *testing.T) { assert.Nil(err) xa := XAxis{} - xr := Range{Min: 10, Max: 100, Domain: 1024} + xr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024} styleDefaults := Style{ Font: f, FontSize: 10.0, @@ -78,7 +38,7 @@ func TestXAxisGetTicksWithUserDefaults(t *testing.T) { xa := XAxis{ Ticks: []Tick{{Value: 1.0, Label: "1.0"}}, } - xr := Range{Min: 10, Max: 100, Domain: 1024} + xr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024} styleDefaults := Style{ Font: f, FontSize: 10.0, @@ -87,3 +47,21 @@ func TestXAxisGetTicksWithUserDefaults(t *testing.T) { ticks := xa.GetTicks(r, xr, styleDefaults, vf) assert.Len(ticks, 1) } + +func TestXAxisMeasure(t *testing.T) { + assert := assert.New(t) + + f, err := GetDefaultFont() + assert.Nil(err) + style := Style{ + Font: f, + FontSize: 10.0, + } + r, err := PNG(100, 100) + 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) + assert.Equal(122, xab.Width()) + assert.Equal(21, xab.Height()) +} diff --git a/yaxis.go b/yaxis.go index c4557e2..4200b04 100644 --- a/yaxis.go +++ b/yaxis.go @@ -13,13 +13,14 @@ type YAxis struct { Zero GridLine - AxisType YAxisType + AxisType yAxisType ValueFormatter ValueFormatter Range Range - Ticks []Tick - GridLines []GridLine + Ticks []Tick + GridLines []GridLine + GridMajorStyle Style GridMinorStyle Style } @@ -34,35 +35,20 @@ func (ya YAxis) GetStyle() Style { return ya.Style } -// GetTicks returns the ticks for a series. It coalesces between user provided ticks and -// generated ticks. +// GetTicks returns the ticks for a series. +// The coalesce priority is: +// - User Supplied Ticks (i.e. Ticks array on the axis itself). +// - Range ticks (i.e. if the range provides ticks). +// - Generating continuous ticks based on minimum spacing and canvas width. func (ya YAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter) []Tick { if len(ya.Ticks) > 0 { return ya.Ticks } - return ya.generateTicks(r, ra, defaults, vf) -} - -func (ya YAxis) generateTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter) []Tick { - step := ya.getTickStep(r, ra, defaults, vf) - ticks := GenerateTicksWithStep(ra, step, vf) - return ticks -} - -func (ya YAxis) getTickStep(r Renderer, ra Range, defaults Style, vf ValueFormatter) float64 { - tickCount := ya.getTickCount(r, ra, defaults, vf) - step := ra.Delta() / float64(tickCount) - return step -} - -func (ya YAxis) getTickCount(r Renderer, ra Range, defaults Style, vf ValueFormatter) int { - r.SetFont(ya.Style.GetFont(defaults.GetFont())) - r.SetFontSize(ya.Style.GetFontSize(defaults.GetFontSize(DefaultFontSize))) - //given the domain, figure out how many ticks we can draw ... - label := vf(ra.Min) - tb := r.MeasureText(label) - count := int(math.Ceil(float64(ra.Domain) / float64(tb.Height()+DefaultMinimumTickVerticalSpacing))) - return count + if tp, isTickProvider := ra.(TicksProvider); isTickProvider { + return tp.GetTicks(vf) + } + step := CalculateContinuousTickStep(r, ra, true, ya.Style.InheritFrom(defaults), vf) + return GenerateContinuousTicksWithStep(ra, step, vf, true) } // GetGridLines returns the gridlines for the axis. @@ -70,16 +56,12 @@ func (ya YAxis) GetGridLines(ticks []Tick) []GridLine { if len(ya.GridLines) > 0 { return ya.GridLines } - return GenerateGridLines(ticks, false) + return GenerateGridLines(ticks, ya.GridMajorStyle, ya.GridMinorStyle, false) } // Measure returns the bounds of the axis. func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) Box { - r.SetStrokeColor(ya.Style.GetStrokeColor(defaults.StrokeColor)) - r.SetStrokeWidth(ya.Style.GetStrokeWidth(defaults.StrokeWidth)) - r.SetFont(ya.Style.GetFont(defaults.GetFont())) - r.SetFontColor(ya.Style.GetFontColor(DefaultAxisColor)) - r.SetFontSize(ya.Style.GetFontSize(defaults.GetFontSize())) + ya.Style.InheritFrom(defaults).WriteToRenderer(r) sort.Sort(Ticks(ticks)) @@ -103,13 +85,13 @@ func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic if ya.AxisType == YAxisPrimary { minx = canvasBox.Right - maxx = MaxInt(maxx, tx+tb.Width()) + maxx = Math.MaxInt(maxx, tx+tb.Width()) } else if ya.AxisType == YAxisSecondary { - minx = MinInt(minx, finalTextX) - maxx = MaxInt(maxx, tx) + minx = Math.MinInt(minx, finalTextX) + maxx = Math.MaxInt(maxx, tx) } - miny = MinInt(miny, ly-tb.Height()>>1) - maxy = MaxInt(maxy, ly+tb.Height()>>1) + miny = Math.MinInt(miny, ly-tb.Height()>>1) + maxy = Math.MaxInt(maxy, ly+tb.Height()>>1) } return Box{ @@ -122,11 +104,7 @@ func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic // Render renders the axis. func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) { - r.SetStrokeColor(ya.Style.GetStrokeColor(defaults.StrokeColor)) - r.SetStrokeWidth(ya.Style.GetStrokeWidth(defaults.StrokeWidth)) - r.SetFont(ya.Style.GetFont(defaults.GetFont())) - r.SetFontColor(ya.Style.GetFontColor(DefaultAxisColor)) - r.SetFontSize(ya.Style.GetFontSize(defaults.GetFontSize(DefaultFontSize))) + ya.Style.InheritFrom(defaults).WriteToRenderer(r) sort.Sort(Ticks(ticks)) @@ -170,14 +148,17 @@ func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick } if ya.Zero.Style.Show { - ya.Zero.Render(r, canvasBox, ra) + ya.Zero.Render(r, canvasBox, ra, Style{}) } if ya.GridMajorStyle.Show || ya.GridMinorStyle.Show { for _, gl := range ya.GetGridLines(ticks) { - if (gl.IsMinor && ya.GridMinorStyle.Show) || - (!gl.IsMinor && ya.GridMajorStyle.Show) { - gl.Render(r, canvasBox, ra) + if (gl.IsMinor && ya.GridMinorStyle.Show) || (!gl.IsMinor && ya.GridMajorStyle.Show) { + defaults := ya.GridMajorStyle + if gl.IsMinor { + defaults = ya.GridMinorStyle + } + gl.Render(r, canvasBox, ra, defaults) } } } diff --git a/yaxis_test.go b/yaxis_test.go index fdd7a72..7415b5c 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -6,46 +6,6 @@ import ( "github.com/blendlabs/go-assert" ) -func TestYAxisGetTickCount(t *testing.T) { - assert := assert.New(t) - - r, err := PNG(1024, 1024) - assert.Nil(err) - - f, err := GetDefaultFont() - assert.Nil(err) - - ya := YAxis{} - yr := Range{Min: 10, Max: 100, Domain: 1024} - styleDefaults := Style{ - Font: f, - FontSize: 10.0, - } - vf := FloatValueFormatter - count := ya.getTickCount(r, yr, styleDefaults, vf) - assert.Equal(34, count) -} - -func TestYAxisGetTickStep(t *testing.T) { - assert := assert.New(t) - - r, err := PNG(1024, 1024) - assert.Nil(err) - - f, err := GetDefaultFont() - assert.Nil(err) - - ya := YAxis{} - yr := Range{Min: 10, Max: 100, Domain: 1024} - styleDefaults := Style{ - Font: f, - FontSize: 10.0, - } - vf := FloatValueFormatter - step := ya.getTickStep(r, yr, styleDefaults, vf) - assert.Equal(yr.Delta()/34.0, step) -} - func TestYAxisGetTicks(t *testing.T) { assert := assert.New(t) @@ -56,14 +16,14 @@ func TestYAxisGetTicks(t *testing.T) { assert.Nil(err) ya := YAxis{} - yr := Range{Min: 10, Max: 100, Domain: 1024} + yr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024} styleDefaults := Style{ Font: f, FontSize: 10.0, } vf := FloatValueFormatter ticks := ya.GetTicks(r, yr, styleDefaults, vf) - assert.Len(ticks, 35) + assert.Len(ticks, 36) } func TestYAxisGetTicksWithUserDefaults(t *testing.T) { @@ -78,7 +38,7 @@ func TestYAxisGetTicksWithUserDefaults(t *testing.T) { ya := YAxis{ Ticks: []Tick{{Value: 1.0, Label: "1.0"}}, } - yr := Range{Min: 10, Max: 100, Domain: 1024} + yr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024} styleDefaults := Style{ Font: f, FontSize: 10.0, @@ -87,3 +47,39 @@ func TestYAxisGetTicksWithUserDefaults(t *testing.T) { ticks := ya.GetTicks(r, yr, styleDefaults, vf) assert.Len(ticks, 1) } + +func TestYAxisMeasure(t *testing.T) { + assert := assert.New(t) + + f, err := GetDefaultFont() + assert.Nil(err) + style := Style{ + Font: f, + FontSize: 10.0, + } + r, err := PNG(100, 100) + 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) + assert.Equal(32, yab.Width()) + assert.Equal(110, yab.Height()) +} + +func TestYAxisSecondaryMeasure(t *testing.T) { + assert := assert.New(t) + + f, err := GetDefaultFont() + assert.Nil(err) + style := Style{ + Font: f, + FontSize: 10.0, + } + r, err := PNG(100, 100) + 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) + assert.Equal(32, yab.Width()) + assert.Equal(110, yab.Height()) +}