From b0934ee2e36ce288df997ce8d989bff26ddc14dd Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 21 Jul 2016 14:11:27 -0700 Subject: [PATCH 01/55] introduces the `Range` interface (instead of a concrete type). --- annotation_series.go | 4 +- annotation_series_test.go | 8 +- axis.go | 14 ++- bollinger_band_series.go | 2 +- chart.go | 101 +++++++++++++--------- chart_test.go | 60 ++++++------- concat_series.go | 32 +++++++ concat_series_test.go | 41 +++++++++ continuous_range.go | 67 ++++++++++++++ range_test.go => continuous_range_test.go | 2 +- continuous_series.go | 2 +- drawing_helpers.go | 8 +- ema_series.go | 2 +- examples/custom_ranges/main.go | 2 +- histogram_series.go | 2 +- macd_series.go | 4 +- range.go | 61 +++++++------ sma_series.go | 2 +- style.go | 15 +++- style_test.go | 2 +- tick.go | 2 +- tick_test.go | 2 +- time_series.go | 2 +- xaxis.go | 23 ++--- xaxis_test.go | 10 +-- yaxis.go | 23 ++--- yaxis_test.go | 10 +-- 27 files changed, 331 insertions(+), 172 deletions(-) create mode 100644 concat_series.go create mode 100644 concat_series_test.go create mode 100644 continuous_range.go rename range_test.go => continuous_range_test.go (92%) diff --git a/annotation_series.go b/annotation_series.go index 8a9b891..405fe55 100644 --- a/annotation_series.go +++ b/annotation_series.go @@ -40,7 +40,7 @@ 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{ + style := as.Style.InheritFrom(Style{ Font: defaults.Font, FillColor: DefaultAnnotationFillColor, FontSize: DefaultAnnotationFontSize, @@ -64,7 +64,7 @@ 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{ + style := as.Style.InheritFrom(Style{ Font: defaults.Font, FontColor: DefaultTextColor, FillColor: DefaultAnnotationFillColor, diff --git a/annotation_series_test.go b/annotation_series_test.go index d25f4e9..26953d9 100644 --- a/annotation_series_test.go +++ b/annotation_series_test.go @@ -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, @@ -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..1c6d272 100644 --- a/axis.go +++ b/axis.go @@ -14,7 +14,17 @@ const ( 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..c0fe7a6 100644 --- a/bollinger_band_series.go +++ b/bollinger_band_series.go @@ -108,7 +108,7 @@ 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), diff --git a/chart.go b/chart.go index 1eb4bdf..4427592 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,40 @@ 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 xrange == nil { + xrange = &ContinuousRange{} + } + + if yrange == nil { + yrange = &ContinuousRange{} + } + + if yrangeAlt == nil { + yrangeAlt = &ContinuousRange{} + } + 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 + + xrange.SetMin(tickMin) + xrange.SetMax(tickMax) + } else if c.XAxis.Range != nil && !c.XAxis.Range.IsZero() { + xrange.SetMin(c.XAxis.Range.GetMin()) + xrange.SetMax(c.XAxis.Range.GetMax()) } else { - xrange.Min = minx - xrange.Max = maxx + xrange.SetMin(minx) + xrange.SetMax(maxx) } if len(c.YAxis.Ticks) > 0 { @@ -208,15 +221,20 @@ 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 + yrange.SetMin(tickMin) + yrange.SetMax(tickMax) + } else if c.YAxis.Range != nil && !c.YAxis.Range.IsZero() { + yrange.SetMin(c.YAxis.Range.GetMin()) + yrange.SetMax(c.YAxis.Range.GetMax()) } else { - yrange.Min = miny - yrange.Max = maxy - yrange.Min, yrange.Max = yrange.GetRoundedRangeBounds() + yrange.SetMin(miny) + yrange.SetMax(maxy) + + delta := yrange.GetDelta() + roundTo := GetRoundToForDelta(delta) + rmin, rmax := RoundDown(yrange.GetMin(), roundTo), RoundUp(yrange.GetMax(), roundTo) + yrange.SetMin(rmin) + yrange.SetMax(rmax) } if len(c.YAxisSecondary.Ticks) > 0 { @@ -225,30 +243,34 @@ 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 c.YAxisSecondary.Range != nil && !c.YAxisSecondary.Range.IsZero() { + yrangeAlt.SetMin(c.YAxisSecondary.Range.GetMin()) + yrangeAlt.SetMax(c.YAxisSecondary.Range.GetMax()) + } else if seriesMappedToSecondaryAxis { + yrangeAlt.SetMin(minya) + yrangeAlt.SetMax(maxya) + + delta := yrangeAlt.GetDelta() + roundTo := GetRoundToForDelta(delta) + rmin, rmax := RoundDown(yrangeAlt.GetMin(), roundTo), 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 +342,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,7 +391,7 @@ 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) { @@ -383,7 +402,7 @@ func (c Chart) drawBackground(r Renderer) { } 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) { diff --git a/chart_test.go b/chart_test.go index add643e..eca6f98 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,7 +377,7 @@ 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. }, 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..f9f93cd --- /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: Seq(1.0, 10.0), + YValues: Seq(1.0, 10.0), + } + + s2 := ContinuousSeries{ + XValues: Seq(11, 20.0), + YValues: Seq(10.0, 1.0), + } + + s3 := ContinuousSeries{ + XValues: Seq(21, 30.0), + YValues: Seq(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 92% rename from range_test.go rename to continuous_range_test.go index f71f955..4400366 100644 --- a/range_test.go +++ b/continuous_range_test.go @@ -9,7 +9,7 @@ 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 := ContinuousRange{Domain: 1000} r.Min, r.Max = MinAndMax(values...) // delta = ~7.0 diff --git a/continuous_series.go b/continuous_series.go index fe0581d..0a122d0 100644 --- a/continuous_series.go +++ b/continuous_series.go @@ -50,6 +50,6 @@ func (cs ContinuousSeries) GetYAxis() YAxisType { // Render renders the series. func (cs ContinuousSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - style := cs.Style.WithDefaultsFrom(defaults) + style := cs.Style.InheritFrom(defaults) DrawLineSeries(r, canvasBox, xrange, yrange, style, cs) } diff --git a/drawing_helpers.go b/drawing_helpers.go index 276e3bd..beada8e 100644 --- a/drawing_helpers.go +++ b/drawing_helpers.go @@ -114,7 +114,7 @@ func DrawHistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Styl //calculate bar width? seriesLength := vs.Len() - barWidth := int(math.Floor(float64(xrange.Domain) / float64(seriesLength))) + barWidth := int(math.Floor(float64(xrange.GetDomain()) / float64(seriesLength))) if len(barWidths) > 0 { barWidth = barWidths[0] } @@ -271,9 +271,9 @@ func CreateLegend(c *Chart, userDefaults ...Style) Renderable { var legendStyle Style if len(userDefaults) > 0 { - legendStyle = userDefaults[0].WithDefaultsFrom(chartDefaults.WithDefaultsFrom(legendDefaults)) + legendStyle = userDefaults[0].InheritFrom(chartDefaults.InheritFrom(legendDefaults)) } else { - legendStyle = chartDefaults.WithDefaultsFrom(legendDefaults) + legendStyle = chartDefaults.InheritFrom(legendDefaults) } // DEFAULTS @@ -292,7 +292,7 @@ func CreateLegend(c *Chart, userDefaults ...Style) Renderable { 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))) + lines = append(lines, s.GetStyle().InheritFrom(c.styleDefaultsSeries(index))) } } } diff --git a/ema_series.go b/ema_series.go index 2c0bc64..2bd808d 100644 --- a/ema_series.go +++ b/ema_series.go @@ -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) + style := ema.Style.InheritFrom(defaults) DrawLineSeries(r, canvasBox, xrange, yrange, style, ema) } diff --git a/examples/custom_ranges/main.go b/examples/custom_ranges/main.go index b6a6657..ad8cb6a 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/histogram_series.go b/histogram_series.go index 3f0b34c..08ba1b9 100644 --- a/histogram_series.go +++ b/histogram_series.go @@ -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) + style := hs.Style.InheritFrom(defaults) DrawHistogramSeries(r, canvasBox, xrange, yrange, style, hs) } diff --git a/macd_series.go b/macd_series.go index b5ee558..b174b74 100644 --- a/macd_series.go +++ b/macd_series.go @@ -192,7 +192,7 @@ 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) + style := macds.Style.InheritFrom(defaults) DrawLineSeries(r, canvasBox, xrange, yrange, style, macds) } @@ -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) + style := macdl.Style.InheritFrom(defaults) DrawLineSeries(r, canvasBox, xrange, yrange, style, macdl) } 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/sma_series.go b/sma_series.go index ca43e2b..245f8c9 100644 --- a/sma_series.go +++ b/sma_series.go @@ -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) + style := sma.Style.InheritFrom(defaults) DrawLineSeries(r, canvasBox, xrange, yrange, style, sma) } diff --git a/style.go b/style.go index dbfb4fb..51ac94d 100644 --- a/style.go +++ b/style.go @@ -28,6 +28,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 "{}" @@ -184,8 +185,18 @@ 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) { +// PersistToRenderer passes the style onto a renderer. +func (s Style) PersistToRenderer(r Renderer) { + r.SetStrokeColor(s.GetStrokeColor()) + r.SetStrokeWidth(s.GetStrokeWidth()) + r.SetStrokeDashArray(s.GetStrokeDashArray()) + 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) diff --git a/style_test.go b/style_test.go index cb7ffdd..520692e 100644 --- a/style_test.go +++ b/style_test.go @@ -142,7 +142,7 @@ func TestStyleWithDefaultsFrom(t *testing.T) { Padding: DefaultBackgroundPadding, } - coalesced := unset.WithDefaultsFrom(set) + coalesced := unset.InheritFrom(set) assert.Equal(set, coalesced) } diff --git a/tick.go b/tick.go index 850adb7..a372ce3 100644 --- a/tick.go +++ b/tick.go @@ -3,7 +3,7 @@ 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 + min, max := ra.GetMin(), ra.GetMax() for cursor := min; cursor <= max; cursor += step { ticks = append(ticks, Tick{ Value: cursor, diff --git a/tick_test.go b/tick_test.go index 8d75f01..66fb237 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 := GenerateTicksWithStep(&ContinuousRange{Min: 1.0, Max: 10.0, Domain: 100}, 1.0, FloatValueFormatter) assert.Len(ticks, 10) } diff --git a/time_series.go b/time_series.go index 37ba6b9..5287cf7 100644 --- a/time_series.go +++ b/time_series.go @@ -56,6 +56,6 @@ func (ts TimeSeries) GetYAxis() YAxisType { // Render renders the series. func (ts TimeSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { - style := ts.Style.WithDefaultsFrom(defaults) + style := ts.Style.InheritFrom(defaults) DrawLineSeries(r, canvasBox, xrange, yrange, style, ts) } diff --git a/xaxis.go b/xaxis.go index 0f3650e..60a1882 100644 --- a/xaxis.go +++ b/xaxis.go @@ -44,7 +44,7 @@ func (xa XAxis) generateTicks(r Renderer, ra Range, defaults Style, vf ValueForm 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) + step := ra.GetDelta() / float64(tickCount) return step } @@ -53,8 +53,8 @@ func (xa XAxis) getTickCount(r Renderer, ra Range, defaults Style, vf ValueForma r.SetFontSize(xa.Style.GetFontSize(defaults.GetFontSize(DefaultFontSize))) // take a cut at determining the 'widest' value. - l0 := vf(ra.Min) - ln := vf(ra.Max) + l0 := vf(ra.GetMin()) + ln := vf(ra.GetMax()) ll := l0 if len(ln) > len(l0) { ll = ln @@ -62,7 +62,7 @@ func (xa XAxis) getTickCount(r Renderer, ra Range, defaults Style, vf ValueForma llb := r.MeasureText(ll) textWidth := llb.Width() width := textWidth + DefaultMinimumTickHorizontalSpacing - count := int(math.Ceil(float64(ra.Domain) / float64(width))) + count := int(math.Ceil(float64(ra.GetDomain()) / float64(width))) return count } @@ -76,13 +76,7 @@ func (xa XAxis) GetGridLines(ticks []Tick) []GridLine { // 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).PersistToRenderer(r) sort.Sort(Ticks(ticks)) var left, right, top, bottom = math.MaxInt32, 0, math.MaxInt32, 0 @@ -110,12 +104,7 @@ 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())) + xa.Style.InheritFrom(defaults).PersistToRenderer(r) r.MoveTo(canvasBox.Left, canvasBox.Bottom) r.LineTo(canvasBox.Right, canvasBox.Bottom) diff --git a/xaxis_test.go b/xaxis_test.go index 933388b..cfc05c5 100644 --- a/xaxis_test.go +++ b/xaxis_test.go @@ -16,7 +16,7 @@ func TestXAxisGetTickCount(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, @@ -36,14 +36,14 @@ func TestXAxisGetTickStep(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, } vf := FloatValueFormatter step := xa.getTickStep(r, xr, styleDefaults, vf) - assert.Equal(xr.Delta()/16.0, step) + assert.Equal(xr.GetDelta()/16.0, step) } func TestXAxisGetTicks(t *testing.T) { @@ -56,7 +56,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 +78,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, diff --git a/yaxis.go b/yaxis.go index c4557e2..5d7e8cd 100644 --- a/yaxis.go +++ b/yaxis.go @@ -17,9 +17,10 @@ type YAxis struct { ValueFormatter ValueFormatter Range Range - Ticks []Tick - GridLines []GridLine + Ticks []Tick + GridLines []GridLine + GridMajorStyle Style GridMinorStyle Style } @@ -51,7 +52,7 @@ func (ya YAxis) generateTicks(r Renderer, ra Range, defaults Style, vf ValueForm 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) + step := ra.GetDelta() / float64(tickCount) return step } @@ -59,9 +60,9 @@ func (ya YAxis) getTickCount(r Renderer, ra Range, defaults Style, vf ValueForma 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) + label := vf(ra.GetMin()) tb := r.MeasureText(label) - count := int(math.Ceil(float64(ra.Domain) / float64(tb.Height()+DefaultMinimumTickVerticalSpacing))) + count := int(math.Ceil(float64(ra.GetDomain()) / float64(tb.Height()+DefaultMinimumTickVerticalSpacing))) return count } @@ -75,11 +76,7 @@ func (ya YAxis) GetGridLines(ticks []Tick) []GridLine { // 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).PersistToRenderer(r) sort.Sort(Ticks(ticks)) @@ -122,11 +119,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).PersistToRenderer(r) sort.Sort(Ticks(ticks)) diff --git a/yaxis_test.go b/yaxis_test.go index fdd7a72..c7c19de 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -16,7 +16,7 @@ func TestYAxisGetTickCount(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, @@ -36,14 +36,14 @@ func TestYAxisGetTickStep(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 step := ya.getTickStep(r, yr, styleDefaults, vf) - assert.Equal(yr.Delta()/34.0, step) + assert.Equal(yr.GetDelta()/34.0, step) } func TestYAxisGetTicks(t *testing.T) { @@ -56,7 +56,7 @@ 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, @@ -78,7 +78,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, From c2f7c99c3f4dca406b9d26506545966b94b35995 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 21 Jul 2016 14:14:28 -0700 Subject: [PATCH 02/55] fixing build bust. --- chart.go | 1 + examples/custom_ranges/main.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/chart.go b/chart.go index 4427592..7132079 100644 --- a/chart.go +++ b/chart.go @@ -479,6 +479,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/examples/custom_ranges/main.go b/examples/custom_ranges/main.go index ad8cb6a..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.ContinuousRange{ + Range: &chart.ContinuousRange{ Min: 0.0, Max: 10.0, }, From c4066176cf3eeb5d6641d9af31eaa7f2047492a6 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 21 Jul 2016 22:09:09 -0700 Subject: [PATCH 03/55] date, nyse market hours range. --- date/date.go | 305 +++++++++++++++++++++++++++++++++++++ date/date_test.go | 87 +++++++++++ nyse_market_hours_range.go | 65 ++++++++ util.go | 5 + 4 files changed, 462 insertions(+) create mode 100644 date/date.go create mode 100644 date/date_test.go create mode 100644 nyse_market_hours_range.go diff --git a/date/date.go b/date/date.go new file mode 100644 index 0000000..697f345 --- /dev/null +++ b/date/date.go @@ -0,0 +1,305 @@ +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() +} + +// IsNYSEHoliday returns if a date was/is on a nyse holiday day. +func IsNYSEHoliday(t time.Time) bool { + te := t.In(Eastern()) + if te.Year() == 2013 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 21 + } else if te.Month() == 2 { + return te.Day() == 18 + } else if te.Month() == 3 { + return te.Day() == 29 + } else if te.Month() == 5 { + return te.Day() == 27 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 2 + } else if te.Month() == 11 { + return te.Day() == 28 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } else if te.Year() == 2014 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 20 + } else if te.Month() == 2 { + return te.Day() == 17 + } else if te.Month() == 4 { + return te.Day() == 18 + } else if te.Month() == 5 { + return te.Day() == 26 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 1 + } else if te.Month() == 11 { + return te.Day() == 27 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } else if te.Year() == 2015 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 19 + } else if te.Month() == 2 { + return te.Day() == 16 + } else if te.Month() == 4 { + return te.Day() == 3 + } else if te.Month() == 5 { + return te.Day() == 25 + } else if te.Month() == 7 { + return te.Day() == 3 + } else if te.Month() == 9 { + return te.Day() == 7 + } else if te.Month() == 11 { + return te.Day() == 26 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } else if te.Year() == 2016 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 18 + } else if te.Month() == 2 { + return te.Day() == 15 + } else if te.Month() == 3 { + return te.Day() == 25 + } else if te.Month() == 5 { + return te.Day() == 30 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 5 + } else if te.Month() == 11 { + return te.Day() == 24 || te.Day() == 25 + } else if te.Month() == 12 { + return te.Day() == 26 + } + } else if te.Year() == 2017 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 16 + } else if te.Month() == 2 { + return te.Day() == 20 + } else if te.Month() == 4 { + return te.Day() == 15 + } else if te.Month() == 5 { + return te.Day() == 29 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 4 + } else if te.Month() == 11 { + return te.Day() == 23 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } else if te.Year() == 2018 { + if te.Month() == 1 { + return te.Day() == 1 || te.Day() == 15 + } else if te.Month() == 2 { + return te.Day() == 19 + } else if te.Month() == 3 { + return te.Day() == 30 + } else if te.Month() == 5 { + return te.Day() == 28 + } else if te.Month() == 7 { + return te.Day() == 4 + } else if te.Month() == 9 { + return te.Day() == 3 + } else if te.Month() == 11 { + return te.Day() == 22 + } else if te.Month() == 12 { + return te.Day() == 25 + } + } + return false +} + +// MarketOpen returns 0930 on a given day. +func MarketOpen(on time.Time) time.Time { + onEastern := on.In(Eastern()) + return time.Date(onEastern.Year(), onEastern.Month(), onEastern.Day(), 9, 30, 0, 0, Eastern()) +} + +// MarketClose returns 1600 on a given day. +func MarketClose(on time.Time) time.Time { + onEastern := on.In(Eastern()) + return time.Date(onEastern.Year(), onEastern.Month(), onEastern.Day(), 16, 0, 0, 0, Eastern()) +} + +// NextMarketOpen returns the next market open after a given time. +func NextMarketOpen(after time.Time) time.Time { + afterEastern := after.In(Eastern()) + todaysOpen := MarketOpen(afterEastern) + + if afterEastern.Before(todaysOpen) && IsWeekDay(todaysOpen.Weekday()) && !IsNYSEHoliday(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()) && !IsNYSEHoliday(afterEastern) { + return time.Date(newDay.Year(), newDay.Month(), newDay.Day(), 9, 30, 0, 0, Eastern()) + } + } + return Epoch //we should never reach this. +} + +// NextMarketClose returns the next market close after a given time. +func NextMarketClose(after time.Time) time.Time { + afterEastern := after.In(Eastern()) + + todaysClose := MarketClose(afterEastern) + if afterEastern.Before(todaysClose) && IsWeekDay(todaysClose.Weekday()) && !IsNYSEHoliday(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()) && !IsNYSEHoliday(newDay) { + return time.Date(newDay.Year(), newDay.Month(), newDay.Day(), 16, 0, 0, 0, Eastern()) + } + } + return Epoch //we should never reach this. +} + +// CalculateMarketSecondsBetween calculates the number of seconds the market was open between two dates. +func CalculateMarketSecondsBetween(start, end time.Time) (seconds int64) { + se := start.In(Eastern()) + ee := end.In(Eastern()) + + startMarketOpen := NextMarketOpen(se) + startMarketClose := NextMarketClose(se) + + if (se.Equal(startMarketOpen) || se.After(startMarketOpen)) && se.Before(startMarketClose) { + seconds += int64(startMarketClose.Sub(se) / time.Second) + } + + cursor := NextMarketOpen(startMarketClose) + for BeforeDate(cursor, ee) { + if IsWeekDay(cursor.Weekday()) && !IsNYSEHoliday(cursor) { + close := NextMarketClose(cursor) + seconds += int64(close.Sub(cursor) / time.Second) + } + cursor = cursor.AddDate(0, 0, 1) + } + + finalMarketOpen := NextMarketOpen(cursor) + finalMarketClose := NextMarketClose(cursor) + if end.After(finalMarketOpen) { + if end.Before(finalMarketClose) { + seconds += int64(end.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/date_test.go b/date/date_test.go new file mode 100644 index 0000000..9f9da52 --- /dev/null +++ b/date/date_test.go @@ -0,0 +1,87 @@ +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))) + assert.True(tomorrowOpen.Equal(NextMarketOpen(afterOpen))) + assert.True(mondayOpen.Equal(NextMarketOpen(afterFriday))) + assert.True(mondayOpen.Equal(NextMarketOpen(weekend))) + + 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))) +} + +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))) + assert.True(tomorrowClose.Equal(NextMarketClose(afterClose))) + assert.True(mondayClose.Equal(NextMarketClose(afterFriday))) + assert.True(mondayClose.Equal(NextMarketClose(weekend))) +} + +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)) +} + +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)) +} diff --git a/nyse_market_hours_range.go b/nyse_market_hours_range.go new file mode 100644 index 0000000..23b64fe --- /dev/null +++ b/nyse_market_hours_range.go @@ -0,0 +1,65 @@ +package chart + +import ( + "fmt" + "time" + + "github.com/wcharczuk/go-chart/date" +) + +// NYSEMarketHoursRange is a special type of range that compresses a time range into just the +// market (i.e. NYSE operating hours and days) range. +type NYSEMarketHoursRange struct { + Min time.Time + Max time.Time + Domain int +} + +// GetMin returns the min value. +func (mhr NYSEMarketHoursRange) GetMin() float64 { + return TimeToFloat64(mhr.Min) +} + +// GetMax returns the max value. +func (mhr NYSEMarketHoursRange) GetMax() float64 { + return TimeToFloat64(mhr.Max) +} + +// SetMin sets the min value. +func (mhr *NYSEMarketHoursRange) SetMin(min float64) { + mhr.Min = Float64ToTime(min) +} + +// SetMax sets the max value. +func (mhr *NYSEMarketHoursRange) SetMax(max float64) { + mhr.Max = Float64ToTime(max) +} + +// GetDelta gets the delta. +func (mhr NYSEMarketHoursRange) GetDelta() float64 { + min := TimeToFloat64(mhr.Min) + max := TimeToFloat64(mhr.Min) + return max - min +} + +// GetDomain gets the domain. +func (mhr NYSEMarketHoursRange) GetDomain() int { + return mhr.Domain +} + +// SetDomain sets the domain. +func (mhr *NYSEMarketHoursRange) SetDomain(domain int) { + mhr.Domain = domain +} + +func (mhr NYSEMarketHoursRange) String() string { + return fmt.Sprintf("MarketHoursRange [%s, %s] => %d", mhr.Min.Format(DefaultDateFormat), mhr.Max.Format(DefaultDateFormat), mhr.Domain) +} + +// Translate maps a given value into the ContinuousRange space. +func (mhr NYSEMarketHoursRange) Translate(value float64) int { + valueTime := Float64ToTime(value) + deltaSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.Max) + valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTime) + return int(float64(valueDelta) / float64(deltaSeconds)) +} diff --git a/util.go b/util.go index 77489c2..70d093c 100644 --- a/util.go +++ b/util.go @@ -20,6 +20,11 @@ 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)) +} + // MinAndMax returns both the min and max in one pass. func MinAndMax(values ...float64) (min float64, max float64) { if len(values) == 0 { From dea8e71d6fa5ee994b8fced04faddaad1a060d69 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 21 Jul 2016 22:22:22 -0700 Subject: [PATCH 04/55] making this pass range interface. --- nyse_market_hours_range.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nyse_market_hours_range.go b/nyse_market_hours_range.go index 23b64fe..d73cc65 100644 --- a/nyse_market_hours_range.go +++ b/nyse_market_hours_range.go @@ -15,6 +15,11 @@ type NYSEMarketHoursRange struct { Domain int } +// IsZero returns if the range is setup or not. +func (mhr NYSEMarketHoursRange) IsZero() bool { + return mhr.Min.IsZero() && mhr.Max.IsZero() +} + // GetMin returns the min value. func (mhr NYSEMarketHoursRange) GetMin() float64 { return TimeToFloat64(mhr.Min) From 859c573d3dc2a1abd9c23607c88cfb4e9acc4e94 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 22 Jul 2016 22:43:27 -0700 Subject: [PATCH 05/55] refactors --- Makefile | 4 + chart.go | 28 +++--- date/{date.go => util.go} | 141 +++++++++++++++++----------- date/{date_test.go => util_test.go} | 0 examples/custom_ticks/main.go | 2 +- nyse_market_hours_range.go | 7 +- nyse_market_hours_range_test.go | 20 ++++ 7 files changed, 129 insertions(+), 73 deletions(-) create mode 100644 Makefile rename date/{date.go => util.go} (75%) rename date/{date_test.go => util_test.go} (100%) create mode 100644 nyse_market_hours_range_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3836c65 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +all: test + +test: + @go test ./... diff --git a/chart.go b/chart.go index 7132079..c837019 100644 --- a/chart.go +++ b/chart.go @@ -186,16 +186,22 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } } - if xrange == nil { + if c.XAxis.Range == nil { xrange = &ContinuousRange{} + } else { + xrange = c.XAxis.Range } - if yrange == nil { + if c.YAxis.Range == nil { yrange = &ContinuousRange{} + } else { + yrange = c.YAxis.Range } - if yrangeAlt == nil { + if c.YAxisSecondary.Range == nil { yrangeAlt = &ContinuousRange{} + } else { + yrangeAlt = c.YAxisSecondary.Range } if len(c.XAxis.Ticks) > 0 { @@ -204,13 +210,9 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { tickMin = math.Min(tickMin, t.Value) tickMax = math.Max(tickMax, t.Value) } - xrange.SetMin(tickMin) xrange.SetMax(tickMax) - } else if c.XAxis.Range != nil && !c.XAxis.Range.IsZero() { - xrange.SetMin(c.XAxis.Range.GetMin()) - xrange.SetMax(c.XAxis.Range.GetMax()) - } else { + } else if xrange.IsZero() { xrange.SetMin(minx) xrange.SetMax(maxx) } @@ -223,10 +225,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } yrange.SetMin(tickMin) yrange.SetMax(tickMax) - } else if c.YAxis.Range != nil && !c.YAxis.Range.IsZero() { - yrange.SetMin(c.YAxis.Range.GetMin()) - yrange.SetMax(c.YAxis.Range.GetMax()) - } else { + } else if yrange.IsZero() { yrange.SetMin(miny) yrange.SetMax(maxy) @@ -245,10 +244,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } yrangeAlt.SetMin(tickMin) yrangeAlt.SetMax(tickMax) - } else if c.YAxisSecondary.Range != nil && !c.YAxisSecondary.Range.IsZero() { - yrangeAlt.SetMin(c.YAxisSecondary.Range.GetMin()) - yrangeAlt.SetMax(c.YAxisSecondary.Range.GetMax()) - } else if seriesMappedToSecondaryAxis { + } else if seriesMappedToSecondaryAxis && yrangeAlt.IsZero() { yrangeAlt.SetMin(minya) yrangeAlt.SetMax(maxya) diff --git a/date/date.go b/date/util.go similarity index 75% rename from date/date.go rename to date/util.go index 697f345..5dfbe45 100644 --- a/date/date.go +++ b/date/util.go @@ -50,43 +50,28 @@ var ( _eastern *time.Location ) -// Eastern returns the eastern timezone. -func Eastern() *time.Location { - if _eastern == nil { - _easternLock.Lock() - defer _easternLock.Unlock() - if _eastern == nil { - _eastern, _ = time.LoadLocation("America/New_York") - } - } - return _eastern -} +var ( + // NYSEOpen is when the NYSE opens. + NYSEOpen = ClockTime(9, 30, 0, 0, Eastern()) -// Optional returns a pointer reference to a given time. -func Optional(t time.Time) *time.Time { - return &t -} + // NYSEClose is when the NYSE closes. + NYSEClose = ClockTime(16, 0, 0, 0, Eastern()) -// IsWeekDay returns if the day is a monday->friday. -func IsWeekDay(day time.Weekday) bool { - return !IsWeekendDay(day) -} + // NASDAQOpen is when NASDAQ opens. + NASDAQOpen = ClockTime(9, 30, 0, 0, Eastern()) -// IsWeekendDay returns if the day is a monday->friday. -func IsWeekendDay(day time.Weekday) bool { - return day == time.Saturday || day == time.Sunday -} + // NASDAQClose is when NASDAQ closes. + NASDAQClose = ClockTime(16, 0, 0, 0, Eastern()) -// 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() -} + // NYSEArcaOpen is when NYSEARCA opens. + NYSEArcaOpen = ClockTime(4, 0, 0, 0, Eastern()) + + // NYSEArcaClose is when NYSEARCA closes. + NYSEArcaClose = ClockTime(20, 0, 0, 0, Eastern()) +) + +// HolidayChecker is a function that returns if a given time falls on a holiday. +type HolidayChecker func(time.Time) bool // IsNYSEHoliday returns if a date was/is on a nyse holiday day. func IsNYSEHoliday(t time.Time) bool { @@ -203,24 +188,62 @@ func IsNYSEHoliday(t time.Time) bool { return false } +// Eastern returns the eastern timezone. +func Eastern() *time.Location { + if _eastern == nil { + _easternLock.Lock() + defer _easternLock.Unlock() + if _eastern == nil { + _eastern, _ = time.LoadLocation("America/New_York") + } + } + return _eastern +} + +// Optional returns a pointer reference to a given time. +func Optional(t time.Time) *time.Time { + return &t +} + +// IsWeekDay returns if the day is a monday->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() +} + // MarketOpen returns 0930 on a given day. -func MarketOpen(on time.Time) time.Time { +func MarketOpen(on, openTime time.Time) time.Time { onEastern := on.In(Eastern()) - return time.Date(onEastern.Year(), onEastern.Month(), onEastern.Day(), 9, 30, 0, 0, Eastern()) + return On(openTime, onEastern) } // MarketClose returns 1600 on a given day. -func MarketClose(on time.Time) time.Time { +func MarketClose(on, closeTime time.Time) time.Time { onEastern := on.In(Eastern()) return time.Date(onEastern.Year(), onEastern.Month(), onEastern.Day(), 16, 0, 0, 0, Eastern()) } // NextMarketOpen returns the next market open after a given time. -func NextMarketOpen(after time.Time) time.Time { +func NextMarketOpen(after, openTime time.Time, isHoliday HolidayChecker) time.Time { afterEastern := after.In(Eastern()) - todaysOpen := MarketOpen(afterEastern) + todaysOpen := MarketOpen(afterEastern, openTime) - if afterEastern.Before(todaysOpen) && IsWeekDay(todaysOpen.Weekday()) && !IsNYSEHoliday(todaysOpen) { + if afterEastern.Before(todaysOpen) && IsWeekDay(todaysOpen.Weekday()) && !isHoliday(todaysOpen) { return todaysOpen } @@ -230,19 +253,19 @@ func NextMarketOpen(after time.Time) time.Time { for cursorDay := 1; cursorDay < 6; cursorDay++ { newDay := todaysOpen.AddDate(0, 0, cursorDay) - if IsWeekDay(newDay.Weekday()) && !IsNYSEHoliday(afterEastern) { - return time.Date(newDay.Year(), newDay.Month(), newDay.Day(), 9, 30, 0, 0, Eastern()) + 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 time.Time) time.Time { +func NextMarketClose(after, closeTime time.Time, isHoliday HolidayChecker) time.Time { afterEastern := after.In(Eastern()) - todaysClose := MarketClose(afterEastern) - if afterEastern.Before(todaysClose) && IsWeekDay(todaysClose.Weekday()) && !IsNYSEHoliday(todaysClose) { + todaysClose := MarketClose(afterEastern, closeTime) + if afterEastern.Before(todaysClose) && IsWeekDay(todaysClose.Weekday()) && !isHoliday(todaysClose) { return todaysClose } @@ -252,36 +275,36 @@ func NextMarketClose(after time.Time) time.Time { for cursorDay := 1; cursorDay < 6; cursorDay++ { newDay := todaysClose.AddDate(0, 0, cursorDay) - if IsWeekDay(newDay.Weekday()) && !IsNYSEHoliday(newDay) { - return time.Date(newDay.Year(), newDay.Month(), newDay.Day(), 16, 0, 0, 0, Eastern()) + 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 time.Time) (seconds int64) { +func CalculateMarketSecondsBetween(start, end, marketOpen, marketClose time.Time, isHoliday HolidayChecker) (seconds int64) { se := start.In(Eastern()) ee := end.In(Eastern()) - startMarketOpen := NextMarketOpen(se) - startMarketClose := NextMarketClose(se) + startMarketOpen := NextMarketOpen(se, marketOpen, isHoliday) + startMarketClose := NextMarketClose(se, marketClose, isHoliday) if (se.Equal(startMarketOpen) || se.After(startMarketOpen)) && se.Before(startMarketClose) { seconds += int64(startMarketClose.Sub(se) / time.Second) } - cursor := NextMarketOpen(startMarketClose) + cursor := NextMarketOpen(startMarketClose, marketClose, isHoliday) for BeforeDate(cursor, ee) { - if IsWeekDay(cursor.Weekday()) && !IsNYSEHoliday(cursor) { - close := NextMarketClose(cursor) + 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) - finalMarketClose := NextMarketClose(cursor) + finalMarketOpen := NextMarketOpen(cursor, marketOpen, isHoliday) + finalMarketClose := NextMarketClose(cursor, marketClose, isHoliday) if end.After(finalMarketOpen) { if end.Before(finalMarketClose) { seconds += int64(end.Sub(finalMarketOpen) / time.Second) @@ -293,6 +316,16 @@ func CalculateMarketSecondsBetween(start, end time.Time) (seconds int64) { return } +// ClockTime returns a new time.Time for the given clock components. +func ClockTime(hour, min, sec, nsec int, loc *time.Location) time.Time { + return time.Date(0, 0, 0, hour, min, sec, nsec, loc) +} + +// On returns the clock components of clock (hour,minute,second) on the date components of d. +func On(clock, d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), clock.Hour(), clock.Minute(), clock.Second(), clock.Nanosecond(), clock.Location()) +} + // Format returns a string representation of a date. func format(t time.Time) string { return t.Format("2006-01-02") diff --git a/date/date_test.go b/date/util_test.go similarity index 100% rename from date/date_test.go rename to date/util_test.go 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/nyse_market_hours_range.go b/nyse_market_hours_range.go index d73cc65..2c62163 100644 --- a/nyse_market_hours_range.go +++ b/nyse_market_hours_range.go @@ -43,7 +43,7 @@ func (mhr *NYSEMarketHoursRange) SetMax(max float64) { // GetDelta gets the delta. func (mhr NYSEMarketHoursRange) GetDelta() float64 { min := TimeToFloat64(mhr.Min) - max := TimeToFloat64(mhr.Min) + max := TimeToFloat64(mhr.Max) return max - min } @@ -66,5 +66,8 @@ func (mhr NYSEMarketHoursRange) Translate(value float64) int { valueTime := Float64ToTime(value) deltaSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.Max) valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTime) - return int(float64(valueDelta) / float64(deltaSeconds)) + + translated := int((float64(valueDelta) / float64(deltaSeconds)) * float64(mhr.Domain)) + fmt.Printf("nyse translating: %s to %d ~= %d", valueTime.Format(time.RFC3339), deltaSeconds, valueDelta) + return translated } diff --git a/nyse_market_hours_range_test.go b/nyse_market_hours_range_test.go new file mode 100644 index 0000000..2af1545 --- /dev/null +++ b/nyse_market_hours_range_test.go @@ -0,0 +1,20 @@ +package chart + +import ( + "testing" + "time" + + assert "github.com/blendlabs/go-assert" + "github.com/wcharczuk/go-chart/date" +) + +func TestNYSEMarketHoursDelta(t *testing.T) { + assert := assert.New(t) + + r := &NYSEMarketHoursRange{ + Min: time.Date(2016, 07, 19, 9, 30, 0, 0, date.Eastern()), + Max: time.Date(2016, 07, 22, 16, 00, 0, 0, date.Eastern()), + } + + assert.NotZero(r.GetDelta()) +} From fd2bfe14f0d5916e80042b58c92adb7c9c595089 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 11:50:30 -0700 Subject: [PATCH 06/55] refactoring things a bit. --- date/util.go | 36 +++++++++--------- ...et_hours_range.go => market_hours_range.go | 38 +++++++++++-------- market_hours_range_test.go | 23 +++++++++++ nyse_market_hours_range_test.go | 20 ---------- 4 files changed, 62 insertions(+), 55 deletions(-) rename nyse_market_hours_range.go => market_hours_range.go (60%) create mode 100644 market_hours_range_test.go delete mode 100644 nyse_market_hours_range_test.go diff --git a/date/util.go b/date/util.go index 5dfbe45..0432681 100644 --- a/date/util.go +++ b/date/util.go @@ -70,8 +70,8 @@ var ( NYSEArcaClose = ClockTime(20, 0, 0, 0, Eastern()) ) -// HolidayChecker is a function that returns if a given time falls on a holiday. -type HolidayChecker func(time.Time) bool +// HolidayProvider is a function that returns if a given time falls on a holiday. +type HolidayProvider func(time.Time) bool // IsNYSEHoliday returns if a date was/is on a nyse holiday day. func IsNYSEHoliday(t time.Time) bool { @@ -188,6 +188,16 @@ func IsNYSEHoliday(t time.Time) bool { return false } +// IsNYSEArcaHoliday returns that returns if a given time falls on a holiday. +func IsNYSEArcaHoliday(t time.Time) bool { + return IsNYSEHoliday(t) +} + +// IsNASDAQHoliday returns if a date was a NASDAQ holiday day. +func IsNASDAQHoliday(t time.Time) bool { + return IsNYSEHoliday(t) +} + // Eastern returns the eastern timezone. func Eastern() *time.Location { if _eastern == nil { @@ -226,22 +236,10 @@ func BeforeDate(before, reference time.Time) bool { return before.Year() == reference.Year() && before.Month() == reference.Month() && before.Day() < reference.Day() } -// MarketOpen returns 0930 on a given day. -func MarketOpen(on, openTime time.Time) time.Time { - onEastern := on.In(Eastern()) - return On(openTime, onEastern) -} - -// MarketClose returns 1600 on a given day. -func MarketClose(on, closeTime time.Time) time.Time { - onEastern := on.In(Eastern()) - return time.Date(onEastern.Year(), onEastern.Month(), onEastern.Day(), 16, 0, 0, 0, Eastern()) -} - // NextMarketOpen returns the next market open after a given time. -func NextMarketOpen(after, openTime time.Time, isHoliday HolidayChecker) time.Time { +func NextMarketOpen(after, openTime time.Time, isHoliday HolidayProvider) time.Time { afterEastern := after.In(Eastern()) - todaysOpen := MarketOpen(afterEastern, openTime) + todaysOpen := On(openTime, afterEastern) if afterEastern.Before(todaysOpen) && IsWeekDay(todaysOpen.Weekday()) && !isHoliday(todaysOpen) { return todaysOpen @@ -261,10 +259,10 @@ func NextMarketOpen(after, openTime time.Time, isHoliday HolidayChecker) time.Ti } // NextMarketClose returns the next market close after a given time. -func NextMarketClose(after, closeTime time.Time, isHoliday HolidayChecker) time.Time { +func NextMarketClose(after, closeTime time.Time, isHoliday HolidayProvider) time.Time { afterEastern := after.In(Eastern()) - todaysClose := MarketClose(afterEastern, closeTime) + todaysClose := On(closeTime, afterEastern) if afterEastern.Before(todaysClose) && IsWeekDay(todaysClose.Weekday()) && !isHoliday(todaysClose) { return todaysClose } @@ -283,7 +281,7 @@ func NextMarketClose(after, closeTime time.Time, isHoliday HolidayChecker) time. } // CalculateMarketSecondsBetween calculates the number of seconds the market was open between two dates. -func CalculateMarketSecondsBetween(start, end, marketOpen, marketClose time.Time, isHoliday HolidayChecker) (seconds int64) { +func CalculateMarketSecondsBetween(start, end, marketOpen, marketClose time.Time, isHoliday HolidayProvider) (seconds int64) { se := start.In(Eastern()) ee := end.In(Eastern()) diff --git a/nyse_market_hours_range.go b/market_hours_range.go similarity index 60% rename from nyse_market_hours_range.go rename to market_hours_range.go index 2c62163..3cb9400 100644 --- a/nyse_market_hours_range.go +++ b/market_hours_range.go @@ -7,65 +7,71 @@ import ( "github.com/wcharczuk/go-chart/date" ) -// NYSEMarketHoursRange is a special type of range that compresses a time range into just the +// 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 NYSEMarketHoursRange struct { - Min time.Time - Max time.Time +type MarketHoursRange struct { + Min time.Time + Max time.Time + + MarketOpen time.Time + MarketClose time.Time + + HolidayProvider date.HolidayProvider + Domain int } // IsZero returns if the range is setup or not. -func (mhr NYSEMarketHoursRange) IsZero() bool { +func (mhr MarketHoursRange) IsZero() bool { return mhr.Min.IsZero() && mhr.Max.IsZero() } // GetMin returns the min value. -func (mhr NYSEMarketHoursRange) GetMin() float64 { +func (mhr MarketHoursRange) GetMin() float64 { return TimeToFloat64(mhr.Min) } // GetMax returns the max value. -func (mhr NYSEMarketHoursRange) GetMax() float64 { +func (mhr MarketHoursRange) GetMax() float64 { return TimeToFloat64(mhr.Max) } // SetMin sets the min value. -func (mhr *NYSEMarketHoursRange) SetMin(min float64) { +func (mhr *MarketHoursRange) SetMin(min float64) { mhr.Min = Float64ToTime(min) } // SetMax sets the max value. -func (mhr *NYSEMarketHoursRange) SetMax(max float64) { +func (mhr *MarketHoursRange) SetMax(max float64) { mhr.Max = Float64ToTime(max) } // GetDelta gets the delta. -func (mhr NYSEMarketHoursRange) GetDelta() float64 { +func (mhr MarketHoursRange) GetDelta() float64 { min := TimeToFloat64(mhr.Min) max := TimeToFloat64(mhr.Max) return max - min } // GetDomain gets the domain. -func (mhr NYSEMarketHoursRange) GetDomain() int { +func (mhr MarketHoursRange) GetDomain() int { return mhr.Domain } // SetDomain sets the domain. -func (mhr *NYSEMarketHoursRange) SetDomain(domain int) { +func (mhr *MarketHoursRange) SetDomain(domain int) { mhr.Domain = domain } -func (mhr NYSEMarketHoursRange) String() string { +func (mhr MarketHoursRange) String() string { return fmt.Sprintf("MarketHoursRange [%s, %s] => %d", mhr.Min.Format(DefaultDateFormat), mhr.Max.Format(DefaultDateFormat), mhr.Domain) } // Translate maps a given value into the ContinuousRange space. -func (mhr NYSEMarketHoursRange) Translate(value float64) int { +func (mhr MarketHoursRange) Translate(value float64) int { valueTime := Float64ToTime(value) - deltaSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.Max) - valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTime) + deltaSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.Max, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) + valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTime, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) translated := int((float64(valueDelta) / float64(deltaSeconds)) * float64(mhr.Domain)) fmt.Printf("nyse translating: %s to %d ~= %d", valueTime.Format(time.RFC3339), deltaSeconds, valueDelta) diff --git a/market_hours_range_test.go b/market_hours_range_test.go new file mode 100644 index 0000000..8f0cbd2 --- /dev/null +++ b/market_hours_range_test.go @@ -0,0 +1,23 @@ +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()) +} diff --git a/nyse_market_hours_range_test.go b/nyse_market_hours_range_test.go deleted file mode 100644 index 2af1545..0000000 --- a/nyse_market_hours_range_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package chart - -import ( - "testing" - "time" - - assert "github.com/blendlabs/go-assert" - "github.com/wcharczuk/go-chart/date" -) - -func TestNYSEMarketHoursDelta(t *testing.T) { - assert := assert.New(t) - - r := &NYSEMarketHoursRange{ - Min: time.Date(2016, 07, 19, 9, 30, 0, 0, date.Eastern()), - Max: time.Date(2016, 07, 22, 16, 00, 0, 0, date.Eastern()), - } - - assert.NotZero(r.GetDelta()) -} From d41c9313aa2aa9a49556407c7c4dc79a64ba03e8 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 12:58:37 -0700 Subject: [PATCH 07/55] tests pass post refactor. --- date/util.go | 12 +++++++++++- date/util_test.go | 22 +++++++++++----------- examples/stock_analysis/main.go | 2 +- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/date/util.go b/date/util.go index 0432681..6646146 100644 --- a/date/util.go +++ b/date/util.go @@ -73,6 +73,8 @@ var ( // HolidayProvider is a function that returns if a given time falls on a holiday. type HolidayProvider func(time.Time) bool +func DefaultHolidayProvider(_ time.Time) bool { return false } + // IsNYSEHoliday returns if a date was/is on a nyse holiday day. func IsNYSEHoliday(t time.Time) bool { te := t.In(Eastern()) @@ -241,6 +243,10 @@ func NextMarketOpen(after, openTime time.Time, isHoliday HolidayProvider) time.T afterEastern := after.In(Eastern()) todaysOpen := On(openTime, afterEastern) + if isHoliday == nil { + isHoliday = DefaultHolidayProvider + } + if afterEastern.Before(todaysOpen) && IsWeekDay(todaysOpen.Weekday()) && !isHoliday(todaysOpen) { return todaysOpen } @@ -262,6 +268,10 @@ func NextMarketOpen(after, openTime time.Time, isHoliday HolidayProvider) time.T 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 @@ -292,7 +302,7 @@ func CalculateMarketSecondsBetween(start, end, marketOpen, marketClose time.Time seconds += int64(startMarketClose.Sub(se) / time.Second) } - cursor := NextMarketOpen(startMarketClose, marketClose, isHoliday) + cursor := NextMarketOpen(startMarketClose, marketOpen, isHoliday) for BeforeDate(cursor, ee) { if IsWeekDay(cursor.Weekday()) && !isHoliday(cursor) { close := NextMarketClose(cursor, marketClose, isHoliday) diff --git a/date/util_test.go b/date/util_test.go index 9f9da52..7310cec 100644 --- a/date/util_test.go +++ b/date/util_test.go @@ -34,15 +34,15 @@ func TestNextMarketOpen(t *testing.T) { weekend := time.Date(2016, 07, 23, 9, 31, 0, 0, Eastern()) - assert.True(todayOpen.Equal(NextMarketOpen(beforeOpen))) - assert.True(tomorrowOpen.Equal(NextMarketOpen(afterOpen))) - assert.True(mondayOpen.Equal(NextMarketOpen(afterFriday))) - assert.True(mondayOpen.Equal(NextMarketOpen(weekend))) + 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))) + assert.True(shouldbe.Equal(NextMarketOpen(testRegression, NYSEOpen, IsNYSEHoliday))) } func TestNextMarketClose(t *testing.T) { @@ -59,10 +59,10 @@ func TestNextMarketClose(t *testing.T) { weekend := time.Date(2016, 07, 23, 9, 31, 0, 0, Eastern()) - assert.True(todayClose.Equal(NextMarketClose(beforeClose))) - assert.True(tomorrowClose.Equal(NextMarketClose(afterClose))) - assert.True(mondayClose.Equal(NextMarketClose(afterFriday))) - assert.True(mondayClose.Equal(NextMarketClose(weekend))) + 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) { @@ -73,7 +73,7 @@ func TestCalculateMarketSecondsBetween(t *testing.T) { shouldbe := 5 * 6.5 * 60 * 60 - assert.Equal(shouldbe, CalculateMarketSecondsBetween(start, end)) + assert.Equal(shouldbe, CalculateMarketSecondsBetween(start, end, NYSEOpen, NYSEClose, IsNYSEHoliday)) } func TestCalculateMarketSecondsBetweenLTM(t *testing.T) { @@ -83,5 +83,5 @@ func TestCalculateMarketSecondsBetweenLTM(t *testing.T) { 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)) + assert.Equal(shouldbe, CalculateMarketSecondsBetween(start, end, NYSEOpen, NYSEClose, IsNYSEHoliday)) } diff --git a/examples/stock_analysis/main.go b/examples/stock_analysis/main.go index 89fc527..69a4501 100644 --- a/examples/stock_analysis/main.go +++ b/examples/stock_analysis/main.go @@ -47,7 +47,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { }, YAxis: chart.YAxis{ Style: chart.Style{Show: true}, - Range: chart.Range{ + Range: &chart.ContinuousRange{ Max: 220.0, Min: 180.0, }, From 78645130e4efd4fbcf846ae8aff0d312db851b98 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 13:01:38 -0700 Subject: [PATCH 08/55] refinements. --- date/util.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/date/util.go b/date/util.go index 6646146..c2f8e81 100644 --- a/date/util.go +++ b/date/util.go @@ -292,18 +292,18 @@ func NextMarketClose(after, closeTime time.Time, isHoliday HolidayProvider) time // 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) { - se := start.In(Eastern()) - ee := end.In(Eastern()) + startEastern := start.In(Eastern()) + endEastern := end.In(Eastern()) - startMarketOpen := NextMarketOpen(se, marketOpen, isHoliday) - startMarketClose := NextMarketClose(se, marketClose, isHoliday) + startMarketOpen := NextMarketOpen(startEastern, marketOpen, isHoliday) + startMarketClose := NextMarketClose(startEastern, marketClose, isHoliday) - if (se.Equal(startMarketOpen) || se.After(startMarketOpen)) && se.Before(startMarketClose) { - seconds += int64(startMarketClose.Sub(se) / time.Second) + if (startEastern.Equal(startMarketOpen) || startEastern.After(startMarketOpen)) && startEastern.Before(startMarketClose) { + seconds += int64(startMarketClose.Sub(startEastern) / time.Second) } cursor := NextMarketOpen(startMarketClose, marketOpen, isHoliday) - for BeforeDate(cursor, ee) { + for BeforeDate(cursor, endEastern) { if IsWeekDay(cursor.Weekday()) && !isHoliday(cursor) { close := NextMarketClose(cursor, marketClose, isHoliday) seconds += int64(close.Sub(cursor) / time.Second) @@ -313,9 +313,9 @@ func CalculateMarketSecondsBetween(start, end, marketOpen, marketClose time.Time finalMarketOpen := NextMarketOpen(cursor, marketOpen, isHoliday) finalMarketClose := NextMarketClose(cursor, marketClose, isHoliday) - if end.After(finalMarketOpen) { - if end.Before(finalMarketClose) { - seconds += int64(end.Sub(finalMarketOpen) / time.Second) + if endEastern.After(finalMarketOpen) { + if endEastern.Before(finalMarketClose) { + seconds += int64(endEastern.Sub(finalMarketOpen) / time.Second) } else { seconds += int64(finalMarketClose.Sub(finalMarketOpen) / time.Second) } From a6b6097c20843c7d058b8dc43d6057c2684c1b60 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 15:35:49 -0700 Subject: [PATCH 09/55] ticks refactor. --- date/util.go | 21 ++++++++------- grid_line.go | 57 +++++++++++++++++++++------------------ market_hours_range.go | 32 ++++++++++++++++++++++ tick.go | 62 ++++++++++++++++++++++++++++++++----------- tick_test.go | 2 +- xaxis.go | 33 +++-------------------- xaxis_test.go | 40 ---------------------------- yaxis.go | 28 ++++--------------- yaxis_test.go | 40 ---------------------------- 9 files changed, 131 insertions(+), 184 deletions(-) diff --git a/date/util.go b/date/util.go index c2f8e81..13f50d4 100644 --- a/date/util.go +++ b/date/util.go @@ -73,6 +73,7 @@ var ( // HolidayProvider is a function that returns if a given time falls on a holiday. type HolidayProvider func(time.Time) bool +// DefaultHolidayProvider implements `HolidayProvider` and just returns false. func DefaultHolidayProvider(_ time.Time) bool { return false } // IsNYSEHoliday returns if a date was/is on a nyse holiday day. @@ -212,6 +213,16 @@ func Eastern() *time.Location { return _eastern } +// ClockTime returns a new time.Time for the given clock components. +func ClockTime(hour, min, sec, nsec int, loc *time.Location) time.Time { + return time.Date(0, 0, 0, hour, min, sec, nsec, loc) +} + +// On returns the clock components of clock (hour,minute,second) on the date components of d. +func On(clock, d time.Time) time.Time { + return time.Date(d.Year(), d.Month(), d.Day(), clock.Hour(), clock.Minute(), clock.Second(), clock.Nanosecond(), clock.Location()) +} + // Optional returns a pointer reference to a given time. func Optional(t time.Time) *time.Time { return &t @@ -324,16 +335,6 @@ func CalculateMarketSecondsBetween(start, end, marketOpen, marketClose time.Time return } -// ClockTime returns a new time.Time for the given clock components. -func ClockTime(hour, min, sec, nsec int, loc *time.Location) time.Time { - return time.Date(0, 0, 0, hour, min, sec, nsec, loc) -} - -// On returns the clock components of clock (hour,minute,second) on the date components of d. -func On(clock, d time.Time) time.Time { - return time.Date(d.Year(), d.Month(), d.Day(), clock.Hour(), clock.Minute(), clock.Second(), clock.Nanosecond(), clock.Location()) -} - // Format returns a string representation of a date. func format(t time.Time) string { return t.Format("2006-01-02") diff --git a/grid_line.go b/grid_line.go index fe49cba..c906314 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) []GridLine } // GridLine is a line on a graph canvas. @@ -82,3 +59,31 @@ func (gl GridLine) Render(r Renderer, canvasBox Box, ra Range) { r.Stroke() } } + +// 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 +} diff --git a/market_hours_range.go b/market_hours_range.go index 3cb9400..072a121 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -63,6 +63,38 @@ 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 { + // return one tick per day + // figure out how to advance one ticke per market day. + var ticks []Tick + + cursor := date.On(mhr.MarketOpen, mhr.Min) + maxClose := date.On(mhr.MarketClose, mhr.Max) + + 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) + } + + return ticks +} + func (mhr MarketHoursRange) String() string { return fmt.Sprintf("MarketHoursRange [%s, %s] => %d", mhr.Min.Format(DefaultDateFormat), mhr.Max.Format(DefaultDateFormat), mhr.Domain) } diff --git a/tick.go b/tick.go index a372ce3..2428bec 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.GetMin(), ra.GetMax() - 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,46 @@ 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) []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 + } + } + 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 66fb237..ed04595 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(&ContinuousRange{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) assert.Len(ticks, 10) } diff --git a/xaxis.go b/xaxis.go index 60a1882..a14a42f 100644 --- a/xaxis.go +++ b/xaxis.go @@ -34,36 +34,11 @@ func (xa XAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter 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.GetDelta() / 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.GetMin()) - ln := vf(ra.GetMax()) - 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.GetDomain()) / float64(width))) - return count + step := CalculateContinuousTickStep(r, ra, false, xa.Style.InheritFrom(defaults), vf) + return GenerateContinuousTicksWithStep(ra, step, vf) } // GetGridLines returns the gridlines for the axis. diff --git a/xaxis_test.go b/xaxis_test.go index cfc05c5..407d02d 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 := &ContinuousRange{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 := &ContinuousRange{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.GetDelta()/16.0, step) -} - func TestXAxisGetTicks(t *testing.T) { assert := assert.New(t) diff --git a/yaxis.go b/yaxis.go index 5d7e8cd..cb84147 100644 --- a/yaxis.go +++ b/yaxis.go @@ -41,29 +41,11 @@ func (ya YAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter 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.GetDelta() / 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.GetMin()) - tb := r.MeasureText(label) - count := int(math.Ceil(float64(ra.GetDomain()) / 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) } // GetGridLines returns the gridlines for the axis. diff --git a/yaxis_test.go b/yaxis_test.go index c7c19de..51bb5d4 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 := &ContinuousRange{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 := &ContinuousRange{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.GetDelta()/34.0, step) -} - func TestYAxisGetTicks(t *testing.T) { assert := assert.New(t) From b766bc2127b3b6be2372f36972173437f2fe79dc Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 16:50:07 -0700 Subject: [PATCH 10/55] travis got weird. --- .travis.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index b523396..4938423 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,8 +6,11 @@ go: sudo: false before_script: - - go build -i ./... + - go build -i + - go build -i ./drawing/ + - go build -i ./date/ script: - go test - - go test ./drawing/ \ No newline at end of file + - go test ./drawing/ + - go test ./date/ \ No newline at end of file From 02d9ccaed9221feaf412308528b2cd8bd0b23c8f Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 16:51:17 -0700 Subject: [PATCH 11/55] things. --- .travis.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4938423..41130d6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,12 +5,9 @@ go: sudo: false -before_script: - - go build -i - - go build -i ./drawing/ - - go build -i ./date/ - script: - - go test - - go test ./drawing/ - - go test ./date/ \ No newline at end of file + - go test -i ./drawing/ + - go test -i ./date/ + - go test -i + + \ No newline at end of file From 4424c340630d9306b7616f561f62f532a32d05b8 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 16:53:46 -0700 Subject: [PATCH 12/55] explicit deps. --- .travis.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.travis.yml b/.travis.yml index 41130d6..71cd2c0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,16 @@ go: sudo: false +before: + - go get github.com/golang/freetype/raster + - go get github.com/golang/freetype/truetype + - go get golang.org/x/image/draw + - go get golang.org/x/image/font + - go get golang.org/x/image/math/f64 + - go get golang.org/x/image/math/f64 + - go get golang.org/x/image/math/fixed + - go get github.com/blendlabs/go-assert + script: - go test -i ./drawing/ - go test -i ./date/ From 1bc681e52af28095eee93c5f49ac934a88c26d89 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 16:55:16 -0700 Subject: [PATCH 13/55] sadface re: build. --- .travis.yml | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index 71cd2c0..95e3e7b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,18 +6,16 @@ go: sudo: false before: - - go get github.com/golang/freetype/raster - - go get github.com/golang/freetype/truetype - - go get golang.org/x/image/draw - - go get golang.org/x/image/font - - go get golang.org/x/image/math/f64 - - go get golang.org/x/image/math/f64 - - go get golang.org/x/image/math/fixed - - go get github.com/blendlabs/go-assert + - go get -u github.com/golang/freetype/raster + - go get -u github.com/golang/freetype/truetype + - go get -u golang.org/x/image/draw + - go get -u golang.org/x/image/font + - go get -u golang.org/x/image/math/f64 + - go get -u golang.org/x/image/math/f64 + - go get -u golang.org/x/image/math/fixed + - go get -u github.com/blendlabs/go-assert script: - - go test -i ./drawing/ - - go test -i ./date/ - - go test -i - - \ No newline at end of file + - go test ./drawing/ + - go test ./date/ + - go test \ No newline at end of file From bf307047968c9ea941c536d098b1f3def6c7471f Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 16:56:39 -0700 Subject: [PATCH 14/55] jfc --- .travis.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 95e3e7b..a75bc48 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ go: sudo: false -before: +script: - go get -u github.com/golang/freetype/raster - go get -u github.com/golang/freetype/truetype - go get -u golang.org/x/image/draw @@ -14,8 +14,6 @@ before: - go get -u golang.org/x/image/math/f64 - go get -u golang.org/x/image/math/fixed - go get -u github.com/blendlabs/go-assert - -script: - go test ./drawing/ - go test ./date/ - go test \ No newline at end of file From e3ae7fd78f2d5bc1e0a89ba56510ba2192f9746b Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 23 Jul 2016 16:58:00 -0700 Subject: [PATCH 15/55] something broke with before: --- .travis.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index a75bc48..902c4bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,14 +6,7 @@ go: sudo: false script: - - go get -u github.com/golang/freetype/raster - - go get -u github.com/golang/freetype/truetype - - go get -u golang.org/x/image/draw - - go get -u golang.org/x/image/font - - go get -u golang.org/x/image/math/f64 - - go get -u golang.org/x/image/math/f64 - - go get -u golang.org/x/image/math/fixed - - go get -u github.com/blendlabs/go-assert + - go build -i ./... - go test ./drawing/ - go test ./date/ - go test \ No newline at end of file From 8fbb8d9775fd5443dfba5315558e4d3121d6d4a4 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sun, 24 Jul 2016 09:04:07 -0700 Subject: [PATCH 16/55] tests. --- date/util.go | 6 +++++- market_hours_range.go | 1 - market_hours_range_test.go | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/date/util.go b/date/util.go index 13f50d4..f1a524c 100644 --- a/date/util.go +++ b/date/util.go @@ -310,7 +310,11 @@ func CalculateMarketSecondsBetween(start, end, marketOpen, marketClose time.Time startMarketClose := NextMarketClose(startEastern, marketClose, isHoliday) if (startEastern.Equal(startMarketOpen) || startEastern.After(startMarketOpen)) && startEastern.Before(startMarketClose) { - seconds += int64(startMarketClose.Sub(startEastern) / time.Second) + if endEastern.Before(startMarketClose) { + seconds += int64(endEastern.Sub(startEastern) / time.Second) + } else { + seconds += int64(startMarketClose.Sub(startEastern) / time.Second) + } } cursor := NextMarketOpen(startMarketClose, marketOpen, isHoliday) diff --git a/market_hours_range.go b/market_hours_range.go index 072a121..93a203c 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -106,6 +106,5 @@ func (mhr MarketHoursRange) Translate(value float64) int { valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTime, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) translated := int((float64(valueDelta) / float64(deltaSeconds)) * float64(mhr.Domain)) - fmt.Printf("nyse translating: %s to %d ~= %d", valueTime.Format(time.RFC3339), deltaSeconds, valueDelta) return translated } diff --git a/market_hours_range_test.go b/market_hours_range_test.go index 8f0cbd2..5bf5edd 100644 --- a/market_hours_range_test.go +++ b/market_hours_range_test.go @@ -21,3 +21,40 @@ func TestMarketHoursRangeGetDelta(t *testing.T) { 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.Equal(TimeToFloat64(r.Min), ticks[0].Value) + assert.NotEmpty(ticks[0].Label) +} From 67e291ef9b97156e608fac1861c28010a9526643 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sun, 24 Jul 2016 11:45:30 -0700 Subject: [PATCH 17/55] dep resolution --- .travis.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 902c4bc..19db2a2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,8 @@ go: sudo: false +before: + - go get ./... + script: - - go build -i ./... - - go test ./drawing/ - - go test ./date/ - - go test \ No newline at end of file + - go test ./... From b22d565d4496496766644ec9dccb8c005bab74e1 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sun, 24 Jul 2016 13:48:10 -0700 Subject: [PATCH 18/55] ?? --- market_hours_range.go | 10 ++++++++-- market_hours_range_test.go | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/market_hours_range.go b/market_hours_range.go index 93a203c..7fbc098 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -78,9 +78,8 @@ func (mhr *MarketHoursRange) GetTicks(vf ValueFormatter) []Tick { // figure out how to advance one ticke per market day. var ticks []Tick - cursor := date.On(mhr.MarketOpen, mhr.Min) + cursor := date.On(mhr.MarketClose, mhr.Min) maxClose := date.On(mhr.MarketClose, mhr.Max) - for date.BeforeDate(cursor, maxClose) { if date.IsWeekDay(cursor.Weekday()) && !mhr.GetHolidayProvider()(cursor) { ticks = append(ticks, Tick{ @@ -92,6 +91,13 @@ func (mhr *MarketHoursRange) GetTicks(vf ValueFormatter) []Tick { 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 } diff --git a/market_hours_range_test.go b/market_hours_range_test.go index 5bf5edd..8698b36 100644 --- a/market_hours_range_test.go +++ b/market_hours_range_test.go @@ -55,6 +55,7 @@ func TestMarketHoursRangeGetTicks(t *testing.T) { ticks := r.GetTicks(TimeValueFormatter) assert.NotEmpty(ticks) - assert.Equal(TimeToFloat64(r.Min), ticks[0].Value) + assert.Len(ticks, 5) + assert.NotEqual(TimeToFloat64(r.Min), ticks[0].Value) assert.NotEmpty(ticks[0].Label) } From 50a798f67febee2c0ba18354f75e36a2ca2a0b71 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sun, 24 Jul 2016 20:27:19 -0700 Subject: [PATCH 19/55] style handling on gridlines. --- grid_line.go | 22 ++++++---------------- xaxis.go | 11 +++++++---- yaxis.go | 13 ++++++++----- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/grid_line.go b/grid_line.go index c906314..d7d7781 100644 --- a/grid_line.go +++ b/grid_line.go @@ -34,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() @@ -51,9 +52,6 @@ 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() @@ -61,17 +59,9 @@ func (gl GridLine) Render(r Renderer, canvasBox Box, ra Range) { } // GenerateGridLines generates grid lines. -func GenerateGridLines(ticks []Tick, isVertical bool) []GridLine { +func GenerateGridLines(ticks []Tick, majorStyle, minorStyle Style, 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 { diff --git a/xaxis.go b/xaxis.go index a14a42f..bf8522c 100644 --- a/xaxis.go +++ b/xaxis.go @@ -46,7 +46,7 @@ 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. @@ -102,9 +102,12 @@ func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick 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/yaxis.go b/yaxis.go index cb84147..afad3ce 100644 --- a/yaxis.go +++ b/yaxis.go @@ -53,7 +53,7 @@ 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. @@ -145,14 +145,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) } } } From 3bf31e45d37356d42d2fd3a95c4f94ad2b6bc831 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sun, 24 Jul 2016 22:54:03 -0700 Subject: [PATCH 20/55] market hours range not quite working yet. --- grid_line.go | 9 +++++++-- market_hours_range.go | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/grid_line.go b/grid_line.go index d7d7781..63fafb6 100644 --- a/grid_line.go +++ b/grid_line.go @@ -2,7 +2,7 @@ package chart // GridLineProvider is a type that provides grid lines. type GridLineProvider interface { - GetGridLines(ticks []Tick, isVertical bool) []GridLine + GetGridLines(ticks []Tick, isVertical bool, majorStyle, minorStyle Style) []GridLine } // GridLine is a line on a graph canvas. @@ -62,7 +62,12 @@ func (gl GridLine) Render(r Renderer, canvasBox Box, ra Range, defaults Style) { func GenerateGridLines(ticks []Tick, majorStyle, minorStyle Style, isVertical bool) []GridLine { var gl []GridLine isMinor := false - for _, t := range ticks { + + if len(ticks) < 3 { + return gl + } + + for _, t := range ticks[1 : len(ticks)-1] { s := majorStyle if isMinor { s = minorStyle diff --git a/market_hours_range.go b/market_hours_range.go index 7fbc098..e063435 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -108,9 +108,9 @@ func (mhr MarketHoursRange) String() string { // Translate maps a given value into the ContinuousRange space. func (mhr MarketHoursRange) Translate(value float64) int { valueTime := Float64ToTime(value) + valueTimeEastern := valueTime.In(date.Eastern()) deltaSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.Max, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) - valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTime, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) - + valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTimeEastern, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) translated := int((float64(valueDelta) / float64(deltaSeconds)) * float64(mhr.Domain)) return translated } From 05caeb41eec950a3e369bfa911c1a109a9c65a61 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Tue, 26 Jul 2016 23:55:31 -0700 Subject: [PATCH 21/55] fixing test. --- grid_line_test.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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) } From 865ba96eb5e6f17adf45f9e2e27d09f461fc1799 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Wed, 27 Jul 2016 00:20:43 -0700 Subject: [PATCH 22/55] switching the formatter --- market_hours_range.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/market_hours_range.go b/market_hours_range.go index e063435..baadc39 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -18,6 +18,8 @@ type MarketHoursRange struct { HolidayProvider date.HolidayProvider + ValueFormatter ValueFormatter + Domain int } @@ -102,7 +104,7 @@ func (mhr *MarketHoursRange) GetTicks(vf ValueFormatter) []Tick { } func (mhr MarketHoursRange) String() string { - return fmt.Sprintf("MarketHoursRange [%s, %s] => %d", mhr.Min.Format(DefaultDateFormat), mhr.Max.Format(DefaultDateFormat), mhr.Domain) + 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. From 0c049db317bdb76e4ea8d95d95e0b1ef90118309 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Wed, 27 Jul 2016 00:34:10 -0700 Subject: [PATCH 23/55] fixing calculate on day. --- date/util.go | 16 +++++++++------- date/util_test.go | 11 +++++++++++ defaults.go | 3 +-- market_hours_range.go | 4 ++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/date/util.go b/date/util.go index f1a524c..48fb5a4 100644 --- a/date/util.go +++ b/date/util.go @@ -306,14 +306,16 @@ func CalculateMarketSecondsBetween(start, end, marketOpen, marketClose time.Time startEastern := start.In(Eastern()) endEastern := end.In(Eastern()) - startMarketOpen := NextMarketOpen(startEastern, marketOpen, isHoliday) - startMarketClose := NextMarketClose(startEastern, marketClose, isHoliday) + startMarketOpen := On(marketOpen, startEastern) + startMarketClose := On(marketClose, startEastern) - 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) + 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) + } } } diff --git a/date/util_test.go b/date/util_test.go index 7310cec..8538053 100644 --- a/date/util_test.go +++ b/date/util_test.go @@ -76,6 +76,17 @@ func TestCalculateMarketSecondsBetween(t *testing.T) { 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) diff --git a/defaults.go b/defaults.go index fc69d0e..88dbd03 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" @@ -59,7 +58,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. diff --git a/market_hours_range.go b/market_hours_range.go index baadc39..86a7317 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -111,8 +111,8 @@ func (mhr MarketHoursRange) String() string { func (mhr MarketHoursRange) Translate(value float64) int { valueTime := Float64ToTime(value) valueTimeEastern := valueTime.In(date.Eastern()) - deltaSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.Max, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) + totalSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.Max, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) valueDelta := date.CalculateMarketSecondsBetween(mhr.Min, valueTimeEastern, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) - translated := int((float64(valueDelta) / float64(deltaSeconds)) * float64(mhr.Domain)) + translated := int((float64(valueDelta) / float64(totalSeconds)) * float64(mhr.Domain)) return translated } From 40ff878b79397f1fe57f54b4a078b1597a07ad0e Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Wed, 27 Jul 2016 00:40:22 -0700 Subject: [PATCH 24/55] things. --- market_hours_range.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/market_hours_range.go b/market_hours_range.go index 86a7317..f1777e6 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -76,12 +76,18 @@ func (mhr MarketHoursRange) GetHolidayProvider() date.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 { - // return one tick per day - // figure out how to advance one ticke per market day. 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{ From ef1c38a641d8dfd471b5f2ed893600a9abaffe8a Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Wed, 27 Jul 2016 08:21:05 -0700 Subject: [PATCH 25/55] fixing day of market results. --- market_hours_range.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/market_hours_range.go b/market_hours_range.go index e063435..e05fed7 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -33,7 +33,16 @@ func (mhr MarketHoursRange) GetMin() float64 { // GetMax returns the max value. func (mhr MarketHoursRange) GetMax() float64 { - return TimeToFloat64(mhr.Max) + 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. @@ -48,8 +57,8 @@ func (mhr *MarketHoursRange) SetMax(max float64) { // GetDelta gets the delta. func (mhr MarketHoursRange) GetDelta() float64 { - min := TimeToFloat64(mhr.Min) - max := TimeToFloat64(mhr.Max) + min := mhr.GetMin() + max := mhr.GetMax() return max - min } @@ -109,7 +118,7 @@ func (mhr MarketHoursRange) String() string { func (mhr MarketHoursRange) Translate(value float64) int { valueTime := Float64ToTime(value) valueTimeEastern := valueTime.In(date.Eastern()) - deltaSeconds := date.CalculateMarketSecondsBetween(mhr.Min, mhr.Max, mhr.MarketOpen, mhr.MarketClose, mhr.HolidayProvider) + deltaSeconds := 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(deltaSeconds)) * float64(mhr.Domain)) return translated From 6533e951e798b3f2e66921b57a4d9fdfd5097429 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Wed, 27 Jul 2016 12:34:15 -0700 Subject: [PATCH 26/55] tests. --- examples/linear_regression/main.go | 42 +++++++++ linear_regression_series.go | 135 +++++++++++++++++++++++++++++ linear_regression_series_test.go | 75 ++++++++++++++++ market_hours_range_test.go | 2 +- ring_buffer.go | 35 ++++++++ sma_series.go | 22 ++--- xaxis.go | 7 +- yaxis.go | 7 +- 8 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 examples/linear_regression/main.go create mode 100644 linear_regression_series.go create mode 100644 linear_regression_series_test.go diff --git a/examples/linear_regression/main.go b/examples/linear_regression/main.go new file mode 100644 index 0000000..402a91a --- /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.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. + } + + // 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/linear_regression_series.go b/linear_regression_series.go new file mode 100644 index 0000000..e430c96 --- /dev/null +++ b/linear_regression_series.go @@ -0,0 +1,135 @@ +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 lrs.InnerSeries.Len() +} + +// GetWindow returns the window size. +func (lrs LinearRegressionSeries) GetWindow() int { + if lrs.Window == 0 { + return lrs.InnerSeries.Len() + } + return lrs.Window +} + +// GetEffectiveWindowEnd returns the effective window end. +func (lrs LinearRegressionSeries) GetEffectiveWindowEnd() int { + offset := lrs.GetOffset() + windowEnd := offset + lrs.GetWindow() + return MinInt(windowEnd, lrs.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() + x, y = lrs.InnerSeries.GetValue(index + offset) + 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.GetEffectiveWindowEnd() + 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.GetEffectiveWindowEnd() + + valueCount := endIndex - startIndex + + p := float64(endIndex - startIndex) + + xvalues := NewRingBufferWithCapacity(valueCount) + 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) + DrawLineSeries(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..0956e11 --- /dev/null +++ b/linear_regression_series_test.go @@ -0,0 +1,75 @@ +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: Seq(1.0, 100.0), + YValues: Seq(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: Seq(100.0, 1.0), + YValues: Seq(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: Seq(100.0, 1.0), + YValues: Seq(100.0, 1.0), + } + + linRegSeries := &LinearRegressionSeries{ + InnerSeries: mainSeries, + Offset: 10, + Window: 10, + } + + 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/market_hours_range_test.go b/market_hours_range_test.go index 8698b36..c2eaa3d 100644 --- a/market_hours_range_test.go +++ b/market_hours_range_test.go @@ -55,7 +55,7 @@ func TestMarketHoursRangeGetTicks(t *testing.T) { ticks := r.GetTicks(TimeValueFormatter) assert.NotEmpty(ticks) - assert.Len(ticks, 5) + assert.Len(ticks, 6) assert.NotEqual(TimeToFloat64(r.Min), ticks[0].Value) assert.NotEmpty(ticks[0].Label) } 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/sma_series.go b/sma_series.go index 245f8c9..63a8708 100644 --- a/sma_series.go +++ b/sma_series.go @@ -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,17 +70,6 @@ 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) diff --git a/xaxis.go b/xaxis.go index bf8522c..5ebe2cb 100644 --- a/xaxis.go +++ b/xaxis.go @@ -28,8 +28,11 @@ func (xa XAxis) GetStyle() Style { return xa.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 (xa XAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter) []Tick { if len(xa.Ticks) > 0 { return xa.Ticks diff --git a/yaxis.go b/yaxis.go index afad3ce..0d81f56 100644 --- a/yaxis.go +++ b/yaxis.go @@ -35,8 +35,11 @@ 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 From fc0f274f512e8be9494c02c932915e9c53b06e21 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Wed, 27 Jul 2016 12:44:02 -0700 Subject: [PATCH 27/55] fixing handling of offset and window --- linear_regression_series.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/linear_regression_series.go b/linear_regression_series.go index e430c96..270a948 100644 --- a/linear_regression_series.go +++ b/linear_regression_series.go @@ -40,7 +40,7 @@ func (lrs LinearRegressionSeries) Len() int { // GetWindow returns the window size. func (lrs LinearRegressionSeries) GetWindow() int { if lrs.Window == 0 { - return lrs.InnerSeries.Len() + return lrs.InnerSeries.Len() - lrs.GetOffset() } return lrs.Window } @@ -69,7 +69,8 @@ func (lrs *LinearRegressionSeries) GetValue(index int) (x, y float64) { lrs.computeCoefficients() } offset := lrs.GetOffset() - x, y = lrs.InnerSeries.GetValue(index + offset) + effectiveIndex := MinInt(index+offset, lrs.InnerSeries.Len()) + x, y = lrs.InnerSeries.GetValue(effectiveIndex) y = (lrs.m * lrs.normalize(x)) + lrs.b return } From ec4d92fc5e6d3d06c861688793b716fdd5f76823 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Wed, 27 Jul 2016 12:54:40 -0700 Subject: [PATCH 28/55] FENCEPOSTS. --- linear_regression_series.go | 22 +++++++++------------- linear_regression_series_test.go | 2 ++ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/linear_regression_series.go b/linear_regression_series.go index 270a948..41739b4 100644 --- a/linear_regression_series.go +++ b/linear_regression_series.go @@ -34,22 +34,20 @@ func (lrs LinearRegressionSeries) GetYAxis() YAxisType { // Len returns the number of elements in the series. func (lrs LinearRegressionSeries) Len() int { - return lrs.InnerSeries.Len() + return 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() - lrs.GetOffset() + return lrs.InnerSeries.Len() } return lrs.Window } -// GetEffectiveWindowEnd returns the effective window end. -func (lrs LinearRegressionSeries) GetEffectiveWindowEnd() int { - offset := lrs.GetOffset() - windowEnd := offset + lrs.GetWindow() - return MinInt(windowEnd, lrs.Len()-1) +// GetEndIndex returns the effective window end. +func (lrs LinearRegressionSeries) GetEndIndex() int { + return MinInt(lrs.GetOffset()+(lrs.Len()), (lrs.InnerSeries.Len() - 1)) } // GetOffset returns the data offset. @@ -84,7 +82,7 @@ func (lrs *LinearRegressionSeries) GetLastValue() (x, y float64) { if lrs.m == 0 && lrs.b == 0 { lrs.computeCoefficients() } - endIndex := lrs.GetEffectiveWindowEnd() + endIndex := lrs.GetEndIndex() x, y = lrs.InnerSeries.GetValue(endIndex) y = (lrs.m * lrs.normalize(x)) + lrs.b return @@ -96,16 +94,14 @@ func (lrs *LinearRegressionSeries) normalize(xvalue float64) float64 { // 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.GetEffectiveWindowEnd() - - valueCount := endIndex - startIndex + endIndex := lrs.GetEndIndex() p := float64(endIndex - startIndex) - xvalues := NewRingBufferWithCapacity(valueCount) + xvalues := NewRingBufferWithCapacity(lrs.Len()) for index := startIndex; index < endIndex; index++ { + x, _ := lrs.InnerSeries.GetValue(index) xvalues.Enqueue(x) } diff --git a/linear_regression_series_test.go b/linear_regression_series_test.go index 0956e11..9ff890e 100644 --- a/linear_regression_series_test.go +++ b/linear_regression_series_test.go @@ -65,6 +65,8 @@ func TestLinearRegressionSeriesWindowAndOffset(t *testing.T) { 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) From c17c9a4bb4c9653e929369abb85c564a024c8ec5 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 02:34:44 -0700 Subject: [PATCH 29/55] pie charts! --- defaults.go | 122 +++++++++---- drawing/path.go | 8 +- drawing/stack_graphic_context.go | 4 +- examples/pie_chart/main.go | 35 ++++ pie_chart.go | 284 +++++++++++++++++++++++++++++++ raster_renderer.go | 10 ++ renderer.go | 8 + style.go | 7 +- util.go | 43 +++++ vector_renderer.go | 9 + 10 files changed, 485 insertions(+), 45 deletions(-) create mode 100644 examples/pie_chart/main.go create mode 100644 pie_chart.go diff --git a/defaults.go b/defaults.go index 88dbd03..770958a 100644 --- a/defaults.go +++ b/defaults.go @@ -66,42 +66,85 @@ 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, + ColorAlternateLightGray, } ) @@ -117,10 +160,17 @@ var ( ) // GetDefaultSeriesStrokeColor returns a color from the default list by index. -// NOTE: the index will wrap around (using a modulo).g +// NOTE: the index will wrap around (using a modulo). func GetDefaultSeriesStrokeColor(index int) drawing.Color { - finalIndex := index % len(DefaultSeriesStrokeColors) - return DefaultSeriesStrokeColors[finalIndex] + finalIndex := index % len(DefaultColors) + return DefaultColors[finalIndex] +} + +// GetDefaultPieChartValueColor returns a color from the default list by index. +// NOTE: the index will wrap around (using a modulo). +func GetDefaultPieChartValueColor(index int) drawing.Color { + finalIndex := index % len(DefaultAlternateColors) + return DefaultAlternateColors[finalIndex] } var ( 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/examples/pie_chart/main.go b/examples/pie_chart/main.go new file mode 100644 index 0000000..323734c --- /dev/null +++ b/examples/pie_chart/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + pie := chart.PieChart{ + Canvas: chart.Style{ + FillColor: chart.ColorLightGray, + }, + Values: []chart.PieChartValue{ + {Value: 0.3, Label: "Blue"}, + {Value: 0.2, Label: "Green"}, + {Value: 0.2, Label: "Gray"}, + {Value: 0.1, Label: "Orange"}, + {Value: 0.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/pie_chart.go b/pie_chart.go new file mode 100644 index 0000000..f84f95c --- /dev/null +++ b/pie_chart.go @@ -0,0 +1,284 @@ +package chart + +import ( + "errors" + "io" + "math" + + "github.com/golang/freetype/truetype" +) + +// PieChartValue is a slice of a pie-chart. +type PieChartValue struct { + Style Style + Label string + Value float64 +} + +// 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 + + Font *truetype.Font + defaultFont *truetype.Font + + Values []PieChartValue + 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) + + valuesWithPlaceholder, err := pc.finalizeValues(pc.Values) + if err != nil { + return err + } + pc.drawSlices(r, canvasBox, valuesWithPlaceholder) + pc.drawTitle(r) + for _, a := range pc.Elements { + a(r, canvasBox, pc.styleDefaultsElements()) + } + + return r.Save(w) +} + +func (pc PieChart) drawBackground(r Renderer) { + DrawBox(r, Box{ + Right: pc.GetWidth(), + Bottom: pc.GetHeight(), + }, pc.getBackgroundStyle()) +} + +func (pc PieChart) drawCanvas(r Renderer, canvasBox Box) { + DrawBox(r, canvasBox, pc.getCanvasStyle()) +} + +func (pc PieChart) drawTitle(r Renderer) { + if len(pc.Title) > 0 && pc.TitleStyle.Show { + r.SetFont(pc.TitleStyle.GetFont(pc.GetFont())) + r.SetFontColor(pc.TitleStyle.GetFontColor(DefaultTextColor)) + titleFontSize := pc.TitleStyle.GetFontSize(DefaultTitleFontSize) + r.SetFontSize(titleFontSize) + + textBox := r.MeasureText(pc.Title) + + textWidth := textBox.Width() + textHeight := textBox.Height() + + titleX := (pc.GetWidth() >> 1) - (textWidth >> 1) + titleY := pc.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight + + r.Text(pc.Title, titleX, titleY) + } +} + +func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []PieChartValue) { + cx, cy := canvasBox.Center() + diameter := MinInt(canvasBox.Width(), canvasBox.Height()) + radius := float64(diameter >> 1) + radius2 := (radius * 2.0) / 3.0 + + var rads, delta, delta2, total float64 + var lx, ly int + for index, v := range values { + v.Style.InheritFrom(pc.stylePieChartValue(index)).PersistToRenderer(r) + r.MoveTo(cx, cy) + + rads = PercentToRadians(total) + delta = PercentToRadians(v.Value) + + r.ArcTo(cx, cy, radius, radius, rads, delta) + + r.LineTo(cx, cy) + r.Close() + r.FillStroke() + total = total + v.Value + } + + total = 0 + for index, v := range values { + v.Style.InheritFrom(pc.stylePieChartValue(index)).PersistToRenderer(r) + if len(v.Label) > 0 { + delta2 = RadianAdd(PercentToRadians(total+(v.Value/2.0)), _pi2) + lx = cx + int(radius2*math.Sin(delta2)) + ly = cy - int(radius2*math.Cos(delta2)) + + tb := r.MeasureText(v.Label) + lx = lx - (tb.Width() >> 1) + + r.Text(v.Label, lx, ly) + } + total = total + v.Value + } +} + +func (pc PieChart) finalizeValues(values []PieChartValue) ([]PieChartValue, error) { + var total float64 + for _, v := range values { + total += v.Value + if total > 1.0 { + return nil, errors.New("Values total exceeded 1.0; please normalize pie chart values to [0,1.0)") + } + } + remainder := 1.0 - total + if RoundDown(remainder, 0.0001) > 0 { + return append(values, PieChartValue{ + Style: pc.styleDefaultsPieChartValue(), + Value: remainder, + }), nil + } + return values, nil +} + +func (pc PieChart) getDefaultCanvasBox() Box { + return pc.Box() +} + +func (pc PieChart) getCircleAdjustedCanvasBox(canvasBox Box) Box { + circleDiameter := 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 Style{ + StrokeColor: ColorWhite, + StrokeWidth: 5.0, + FillColor: GetDefaultPieChartValueColor(index), + FontSize: 24.0, + FontColor: ColorWhite, + Font: pc.GetFont(), + } +} + +func (pc PieChart) styleDefaultsBackground() Style { + return Style{ + FillColor: DefaultBackgroundColor, + StrokeColor: DefaultBackgroundStrokeColor, + StrokeWidth: DefaultStrokeWidth, + } +} + +func (pc PieChart) styleDefaultsSeries(seriesIndex int) Style { + strokeColor := GetDefaultSeriesStrokeColor(seriesIndex) + return Style{ + StrokeColor: strokeColor, + StrokeWidth: DefaultStrokeWidth, + Font: pc.GetFont(), + FontSize: DefaultFontSize, + } +} + +func (pc PieChart) styleDefaultsElements() Style { + return Style{ + Font: pc.GetFont(), + } +} + +// 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/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/style.go b/style.go index 51ac94d..6742554 100644 --- a/style.go +++ b/style.go @@ -79,11 +79,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") } @@ -190,6 +190,7 @@ func (s Style) PersistToRenderer(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()) diff --git a/util.go b/util.go index 70d093c..8b1fcf2 100644 --- a/util.go +++ b/util.go @@ -99,6 +99,21 @@ func RoundDown(value, roundTo float64) float64 { 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 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] = RoundDown(v/total, 0.001) + } + return output +} + // MinInt returns the minimum of a set of integers. func MinInt(values ...int) int { min := math.MaxInt32 @@ -175,3 +190,31 @@ func SeqDays(days int) []time.Time { func PercentDifference(v1, v2 float64) float64 { return (v2 - v1) / v1 } + +// DegreesToRadians returns degrees as radians. +func DegreesToRadians(degrees float64) float64 { + return degrees * (math.Pi / 180.0) +} + +const ( + _2pi = 2 * math.Pi + _3pi4 = (3 * math.Pi) / 4.0 + _pi2 = math.Pi / 2.0 + _pi4 = math.Pi / 4.0 +) + +// PercentToRadians converts a normalized value (0,1) to radians. +func PercentToRadians(pct float64) float64 { + return DegreesToRadians(360.0 * pct) +} + +// RadianAdd adds a delta to a base in radians. +func RadianAdd(base, delta float64) float64 { + value := base + delta + if value > _2pi { + return math.Mod(value, _2pi) + } else if value < 0 { + return _2pi + value + } + return value +} diff --git a/vector_renderer.go b/vector_renderer.go index 606c7e2..35e8c5e 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -76,6 +76,15 @@ 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) { + vr.p = append(vr.p, fmt.Sprintf("A %d %d %0.2f 1 1 %d %d", int(rx), int(ry), delta, cx, cy)) +} + // Close closes a shape. func (vr *vectorRenderer) Close() { vr.p = append(vr.p, fmt.Sprintf("Z")) From b600cb19947b418361d5bf9d8918194020fb10c9 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 02:43:28 -0700 Subject: [PATCH 30/55] examples --- examples/pie_chart/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go index 323734c..1b75aa0 100644 --- a/examples/pie_chart/main.go +++ b/examples/pie_chart/main.go @@ -19,6 +19,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { {Value: 0.2, Label: "Gray"}, {Value: 0.1, Label: "Orange"}, {Value: 0.1, Label: "??"}, + {Value: 0.1, Label: "!!"}, }, } From 3d9cf0da0c604fb04c8c24b0bd23ec65dcf111eb Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 13:22:18 -0700 Subject: [PATCH 31/55] vector renderer works --- examples/pie_chart/main.go | 7 +++-- pie_chart.go | 13 +++++---- util.go | 48 ++++++++++++++++++++++++++++---- util_test.go | 57 ++++++++++++++++++++++++++++++++++++++ vector_renderer.go | 20 ++++++++++++- 5 files changed, 129 insertions(+), 16 deletions(-) diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go index 1b75aa0..80ea608 100644 --- a/examples/pie_chart/main.go +++ b/examples/pie_chart/main.go @@ -14,17 +14,18 @@ func drawChart(res http.ResponseWriter, req *http.Request) { FillColor: chart.ColorLightGray, }, Values: []chart.PieChartValue{ - {Value: 0.3, Label: "Blue"}, + {Value: 0.2, Label: "Blue"}, {Value: 0.2, Label: "Green"}, {Value: 0.2, Label: "Gray"}, {Value: 0.1, Label: "Orange"}, + {Value: 0.1, Label: "HEANG"}, {Value: 0.1, Label: "??"}, {Value: 0.1, Label: "!!"}, }, } - res.Header().Set("Content-Type", "image/png") - err := pie.Render(chart.PNG, res) + res.Header().Set("Content-Type", "image/svg+xml") + err := pie.Render(chart.SVG, res) if err != nil { fmt.Printf("Error rendering pie chart: %v\n", err) } diff --git a/pie_chart.go b/pie_chart.go index f84f95c..a9dfd4a 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -3,7 +3,6 @@ package chart import ( "errors" "io" - "math" "github.com/golang/freetype/truetype" ) @@ -142,14 +141,15 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []PieChartValue) cx, cy := canvasBox.Center() diameter := MinInt(canvasBox.Width(), canvasBox.Height()) radius := float64(diameter >> 1) - radius2 := (radius * 2.0) / 3.0 + 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)).PersistToRenderer(r) - r.MoveTo(cx, cy) + r.MoveTo(cx, cy) rads = PercentToRadians(total) delta = PercentToRadians(v.Value) @@ -161,13 +161,14 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []PieChartValue) total = total + v.Value } + // draw the labels total = 0 for index, v := range values { v.Style.InheritFrom(pc.stylePieChartValue(index)).PersistToRenderer(r) if len(v.Label) > 0 { - delta2 = RadianAdd(PercentToRadians(total+(v.Value/2.0)), _pi2) - lx = cx + int(radius2*math.Sin(delta2)) - ly = cy - int(radius2*math.Cos(delta2)) + delta2 = PercentToRadians(total + (v.Value / 2.0)) + delta2 = RadianAdd(delta2, _pi2) + lx, ly = CirclePoint(cx, cy, labelRadius, delta2) tb := r.MeasureText(v.Label) lx = lx - (tb.Width() >> 1) diff --git a/util.go b/util.go index 8b1fcf2..6039142 100644 --- a/util.go +++ b/util.go @@ -191,18 +191,30 @@ func PercentDifference(v1, v2 float64) float64 { return (v2 - v1) / v1 } -// DegreesToRadians returns degrees as radians. -func DegreesToRadians(degrees float64) float64 { - return degrees * (math.Pi / 180.0) -} - 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) ) +// DegreesToRadians returns degrees as radians. +func DegreesToRadians(degrees float64) float64 { + return degrees * _d2r +} + +// RadiansToDegrees translates a radian value to a degree value. +func RadiansToDegrees(value float64) float64 { + return math.Mod(value, _2pi) * _r2d +} + // PercentToRadians converts a normalized value (0,1) to radians. func PercentToRadians(pct float64) float64 { return DegreesToRadians(360.0 * pct) @@ -214,7 +226,31 @@ func RadianAdd(base, delta float64) float64 { if value > _2pi { return math.Mod(value, _2pi) } else if value < 0 { - return _2pi + value + return math.Mod(_2pi+value, _2pi) } return value } + +// DegreesAdd adds a delta to a base in radians. +func 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 DegreesToCompass(deg float64) float64 { + return DegreesAdd(deg, -90.0) +} + +// CirclePoint returns the absolute position of a circle diameter point given +// by the radius and the angle. +func 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/util_test.go b/util_test.go index aff684e..c50ffa7 100644 --- a/util_test.go +++ b/util_test.go @@ -100,3 +100,60 @@ func TestPercentDifference(t *testing.T) { assert.Equal(0.5, PercentDifference(1.0, 1.5)) assert.Equal(-0.5, PercentDifference(2.0, 1.0)) } + +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, DegreesToRadians(d)) + } +} + +func TestPercentToRadians(t *testing.T) { + assert := assert.New(t) + + for d, r := range _degreesToRadians { + assert.Equal(r, PercentToRadians(d/360.0)) + } +} + +func TestRadiansToDegrees(t *testing.T) { + assert := assert.New(t) + + for d, r := range _degreesToRadians { + assert.Equal(d, RadiansToDegrees(r)) + } +} + +func TestRadianAdd(t *testing.T) { + assert := assert.New(t) + + assert.Equal(_pi, RadianAdd(_pi2, _pi2)) + assert.Equal(_3pi2, RadianAdd(_pi2, _pi)) + assert.Equal(_pi, RadianAdd(_pi, _2pi)) + assert.Equal(_pi, RadianAdd(_pi, -_2pi)) +} diff --git a/vector_renderer.go b/vector_renderer.go index 35e8c5e..1f030be 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "math" "strings" "golang.org/x/image/font" @@ -82,7 +83,24 @@ func (vr *vectorRenderer) QuadCurveTo(cx, cy, x, y int) { } func (vr *vectorRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) { - vr.p = append(vr.p, fmt.Sprintf("A %d %d %0.2f 1 1 %d %d", int(rx), int(ry), delta, cx, cy)) + startAngle = RadianAdd(startAngle, _pi2) + endAngle := 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 := 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. From 84df29b1c6608985e910909febd557f2e68be5c3 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 14:30:00 -0700 Subject: [PATCH 32/55] need to flesh out this test more. --- .travis.yml | 1 + annotation_series.go | 48 ++++++++++++++++---------------------- annotation_series_test.go | 20 ++++++++-------- examples/pie_chart/main.go | 16 ++++++------- pie_chart.go | 37 ++++++----------------------- pie_chart_test.go | 31 ++++++++++++++++++++++++ util.go | 18 ++++++++++++++ value.go | 46 ++++++++++++++++++++++++++++++++++++ 8 files changed, 141 insertions(+), 76 deletions(-) create mode 100644 pie_chart_test.go create mode 100644 value.go diff --git a/.travis.yml b/.travis.yml index 19db2a2..82e7d59 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ go: sudo: false before: + - go get -u github.com/blendlabs/go-assert - go get ./... script: diff --git a/annotation_series.go b/annotation_series.go index 405fe55..1b2c3b0 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 + Annotations []Value2 } // GetName returns the name of the time series. @@ -31,6 +25,17 @@ 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,17 +45,11 @@ func (as AnnotationSeries) Measure(r Renderer, canvasBox Box, xrange, yrange Ran Bottom: 0, } if as.Style.IsZero() || as.Style.Show { - style := as.Style.InheritFrom(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) + style := a.Style.InheritFrom(seriesStyle) + lx := canvasBox.Left + xrange.Translate(a.XValue) + ly := canvasBox.Bottom - yrange.Translate(a.YValue) ab := MeasureAnnotation(r, canvasBox, style, lx, ly, a.Label) box.Top = MinInt(box.Top, ab.Top) box.Left = MinInt(box.Left, ab.Left) @@ -64,18 +63,11 @@ 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.InheritFrom(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) + style := a.Style.InheritFrom(seriesStyle) + lx := canvasBox.Left + xrange.Translate(a.XValue) + ly := canvasBox.Bottom - yrange.Translate(a.YValue) DrawAnnotation(r, canvasBox, style, lx, ly, a.Label) } } diff --git a/annotation_series_test.go b/annotation_series_test.go index 26953d9..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"}, }, } @@ -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"}, }, } diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go index 80ea608..4054ca5 100644 --- a/examples/pie_chart/main.go +++ b/examples/pie_chart/main.go @@ -13,14 +13,14 @@ func drawChart(res http.ResponseWriter, req *http.Request) { Canvas: chart.Style{ FillColor: chart.ColorLightGray, }, - Values: []chart.PieChartValue{ - {Value: 0.2, Label: "Blue"}, - {Value: 0.2, Label: "Green"}, - {Value: 0.2, Label: "Gray"}, - {Value: 0.1, Label: "Orange"}, - {Value: 0.1, Label: "HEANG"}, - {Value: 0.1, Label: "??"}, - {Value: 0.1, Label: "!!"}, + Values: []chart.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: "!!"}, }, } diff --git a/pie_chart.go b/pie_chart.go index a9dfd4a..9203099 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -7,13 +7,6 @@ import ( "github.com/golang/freetype/truetype" ) -// PieChartValue is a slice of a pie-chart. -type PieChartValue struct { - Style Style - Label string - Value float64 -} - // PieChart is a chart that draws sections of a circle based on percentages. type PieChart struct { Title string @@ -29,7 +22,7 @@ type PieChart struct { Font *truetype.Font defaultFont *truetype.Font - Values []PieChartValue + Values []Value Elements []Renderable } @@ -94,11 +87,8 @@ func (pc PieChart) Render(rp RendererProvider, w io.Writer) error { pc.drawBackground(r) pc.drawCanvas(r, canvasBox) - valuesWithPlaceholder, err := pc.finalizeValues(pc.Values) - if err != nil { - return err - } - pc.drawSlices(r, canvasBox, valuesWithPlaceholder) + finalValues := pc.finalizeValues(pc.Values) + pc.drawSlices(r, canvasBox, finalValues) pc.drawTitle(r) for _, a := range pc.Elements { a(r, canvasBox, pc.styleDefaultsElements()) @@ -137,7 +127,7 @@ func (pc PieChart) drawTitle(r Renderer) { } } -func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []PieChartValue) { +func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) { cx, cy := canvasBox.Center() diameter := MinInt(canvasBox.Width(), canvasBox.Height()) radius := float64(diameter >> 1) @@ -172,6 +162,7 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []PieChartValue) tb := r.MeasureText(v.Label) lx = lx - (tb.Width() >> 1) + ly = ly + (tb.Height() >> 1) r.Text(v.Label, lx, ly) } @@ -179,22 +170,8 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []PieChartValue) } } -func (pc PieChart) finalizeValues(values []PieChartValue) ([]PieChartValue, error) { - var total float64 - for _, v := range values { - total += v.Value - if total > 1.0 { - return nil, errors.New("Values total exceeded 1.0; please normalize pie chart values to [0,1.0)") - } - } - remainder := 1.0 - total - if RoundDown(remainder, 0.0001) > 0 { - return append(values, PieChartValue{ - Style: pc.styleDefaultsPieChartValue(), - Value: remainder, - }), nil - } - return values, nil +func (pc PieChart) finalizeValues(values []Value) []Value { + return Values(values).Normalize() } func (pc PieChart) getDefaultCanvasBox() Box { 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/util.go b/util.go index 6039142..f391d7e 100644 --- a/util.go +++ b/util.go @@ -176,6 +176,24 @@ func SeqRand(samples int, scale float64) []float64 { return values } +// Sum sums a set of values. +func Sum(values ...float64) float64 { + var total float64 + for _, v := range values { + total += v + } + return total +} + +// SumInt sums a set of values. +func SumInt(values ...int) int { + var total int + for _, v := range values { + total += v + } + return total +} + // SeqDays generates a sequence of timestamps by day, from -days to today. func SeqDays(days int) []time.Time { var values []time.Time diff --git a/value.go b/value.go new file mode 100644 index 0000000..0a2a27d --- /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 Normalize(vs.Values()...) +} + +// Normalize returns the values normalized. +func (vs Values) Normalize() []Value { + output := make([]Value, len(vs)) + total := Sum(vs.Values()...) + for index, v := range vs { + output[index] = Value{ + Style: v.Style, + Label: v.Label, + Value: (v.Value / total), + } + } + return output +} + +// Value2 is a two axis value. +type Value2 struct { + Style Style + Label string + XValue, YValue float64 +} From 4d41de533f10db4fa91668241d128a3b89769182 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 14:32:23 -0700 Subject: [PATCH 33/55] travis. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 82e7d59..f2e55e9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ go: sudo: false -before: +before_script: - go get -u github.com/blendlabs/go-assert - go get ./... From d088213b1e87bb02ae545cce58b412ccd925d48a Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 14:35:17 -0700 Subject: [PATCH 34/55] fixing travis bust. --- examples/annotations/main.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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"}, }, }, }, From bff8e074fdd37a7201c84bcc46069723bb404d1e Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 16:36:30 -0700 Subject: [PATCH 35/55] testing histogram series. --- Makefile | 5 +++ box.go | 21 +++--------- box_test.go | 20 ++++++++++++ continuous_series_test.go | 31 ++++++++++++++++++ histogram_series_test.go | 31 ++++++++++++++++++ util_test.go | 24 ++++++++++++++ value.go | 2 +- value_test.go | 69 +++++++++++++++++++++++++++++++++++++++ xaxis_test.go | 18 ++++++++++ 9 files changed, 203 insertions(+), 18 deletions(-) create mode 100644 continuous_series_test.go create mode 100644 histogram_series_test.go create mode 100644 value_test.go diff --git a/Makefile b/Makefile index 3836c65..cc16258 100644 --- a/Makefile +++ b/Makefile @@ -2,3 +2,8 @@ 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/box.go b/box.go index 3af4e79..5df34e0 100644 --- a/box.go +++ b/box.go @@ -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 = MaxInt(newBox.Top, other.Top) + newBox.Left = MaxInt(newBox.Left, other.Left) + newBox.Right = MinInt(newBox.Right, other.Right) + newBox.Bottom = 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/continuous_series_test.go b/continuous_series_test.go new file mode 100644 index 0000000..df2e3b8 --- /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: Seq(1.0, 10.0), + YValues: Seq(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/histogram_series_test.go b/histogram_series_test.go new file mode 100644 index 0000000..80a2fec --- /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: Seq(1.0, 20.0), + YValues: Seq(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/util_test.go b/util_test.go index c50ffa7..d3cd9c3 100644 --- a/util_test.go +++ b/util_test.go @@ -84,6 +84,20 @@ func TestGetRoundToForDelta(t *testing.T) { assert.Equal(1.0, GetRoundToForDelta(11.00)) } +func TestRoundUp(t *testing.T) { + assert := assert.New(t) + assert.Equal(0.5, RoundUp(0.49, 0.1)) + assert.Equal(1.0, RoundUp(0.51, 1.0)) + assert.Equal(0.4999, RoundUp(0.49988, 0.0001)) +} + +func TestRoundDown(t *testing.T) { + assert := assert.New(t) + assert.Equal(0.5, RoundDown(0.51, 0.1)) + assert.Equal(1.0, RoundDown(1.01, 1.0)) + assert.Equal(0.5001, RoundDown(0.50011, 0.0001)) +} + func TestSeq(t *testing.T) { assert := assert.New(t) @@ -101,6 +115,16 @@ func TestPercentDifference(t *testing.T) { assert.Equal(-0.5, PercentDifference(2.0, 1.0)) } +func TestNormalize(t *testing.T) { + assert := assert.New(t) + + values := []float64{10, 9, 8, 7, 6} + normalized := Normalize(values...) + assert.Len(normalized, 5) + assert.Equal(0.25, normalized[0]) + assert.Equal(0.15, normalized[4]) +} + var ( _degreesToRadians = map[float64]float64{ 0: 0, // !_2pi b/c no irrational nums in floats. diff --git a/value.go b/value.go index 0a2a27d..acae226 100644 --- a/value.go +++ b/value.go @@ -32,7 +32,7 @@ func (vs Values) Normalize() []Value { output[index] = Value{ Style: v.Style, Label: v.Label, - Value: (v.Value / total), + Value: RoundDown(v.Value/total, 0.001), } } return output diff --git a/value_test.go b/value_test.go new file mode 100644 index 0000000..f13a916 --- /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.212, values[0]) + assert.Equal(0.042, 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.212, values[0].Value) + assert.Equal(0.042, values[6].Value) +} diff --git a/xaxis_test.go b/xaxis_test.go index 407d02d..8b749da 100644 --- a/xaxis_test.go +++ b/xaxis_test.go @@ -47,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()) +} From 020ec8f4a4dc3bbd6ec959fd27897f796280a2f4 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 16:40:29 -0700 Subject: [PATCH 36/55] coverage at 65.4% --- yaxis_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/yaxis_test.go b/yaxis_test.go index 51bb5d4..1941df5 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -47,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()) +} From a1fb2847977d920cdfa5625e4fff7f21d64c7237 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 18:51:55 -0700 Subject: [PATCH 37/55] basics of stacked bar. --- chart.go | 2 +- defaults.go | 8 +- examples/pie_chart/main.go | 14 +-- examples/stacked_bar/main.go | 48 ++++++++ pie_chart.go | 12 +- stacked_bar_chart.go | 209 +++++++++++++++++++++++++++++++++++ util.go | 2 +- value.go | 2 +- 8 files changed, 272 insertions(+), 25 deletions(-) create mode 100644 examples/stacked_bar/main.go create mode 100644 stacked_bar_chart.go diff --git a/chart.go b/chart.go index c837019..c4e7526 100644 --- a/chart.go +++ b/chart.go @@ -463,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, diff --git a/defaults.go b/defaults.go index 770958a..a1fb01c 100644 --- a/defaults.go +++ b/defaults.go @@ -159,16 +159,16 @@ var ( DashArrayDashesLarge = []int{10, 10} ) -// GetDefaultSeriesStrokeColor returns a color from the default list by index. +// GetDefaultColor returns a color from the default list by index. // NOTE: the index will wrap around (using a modulo). -func GetDefaultSeriesStrokeColor(index int) drawing.Color { +func GetDefaultColor(index int) drawing.Color { finalIndex := index % len(DefaultColors) return DefaultColors[finalIndex] } -// GetDefaultPieChartValueColor returns a color from the default list by index. +// GetAlternateColor returns a color from the default list by index. // NOTE: the index will wrap around (using a modulo). -func GetDefaultPieChartValueColor(index int) drawing.Color { +func GetAlternateColor(index int) drawing.Color { finalIndex := index % len(DefaultAlternateColors) return DefaultAlternateColors[finalIndex] } diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go index 4054ca5..d5c9bcd 100644 --- a/examples/pie_chart/main.go +++ b/examples/pie_chart/main.go @@ -14,13 +14,13 @@ func drawChart(res http.ResponseWriter, req *http.Request) { FillColor: chart.ColorLightGray, }, Values: []chart.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: "!!"}, + {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: "!!"}, }, } diff --git a/examples/stacked_bar/main.go b/examples/stacked_bar/main.go new file mode 100644 index 0000000..2bdbb9b --- /dev/null +++ b/examples/stacked_bar/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + sbc := chart.StackedBarChart{ + Background: chart.Style{ + Padding: chart.Box{Top: 50, Left: 50, Right: 50, Bottom: 50}, + }, + Bars: []chart.StackedBar{ + { + 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: "!!"}, + }, + }, + { + Values: []chart.Value{ + {Value: 10, Label: "Blue"}, + {Value: 5, Label: "Green"}, + {Value: 1, Label: "Gray"}, + }, + }, + }, + } + + res.Header().Set("Content-Type", "image/svg+xml") + err := sbc.Render(chart.SVG, 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/pie_chart.go b/pie_chart.go index 9203099..614167e 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -217,7 +217,7 @@ func (pc PieChart) stylePieChartValue(index int) Style { return Style{ StrokeColor: ColorWhite, StrokeWidth: 5.0, - FillColor: GetDefaultPieChartValueColor(index), + FillColor: GetAlternateColor(index), FontSize: 24.0, FontColor: ColorWhite, Font: pc.GetFont(), @@ -232,16 +232,6 @@ func (pc PieChart) styleDefaultsBackground() Style { } } -func (pc PieChart) styleDefaultsSeries(seriesIndex int) Style { - strokeColor := GetDefaultSeriesStrokeColor(seriesIndex) - return Style{ - StrokeColor: strokeColor, - StrokeWidth: DefaultStrokeWidth, - Font: pc.GetFont(), - FontSize: DefaultFontSize, - } -} - func (pc PieChart) styleDefaultsElements() Style { return Style{ Font: pc.GetFont(), diff --git a/stacked_bar_chart.go b/stacked_bar_chart.go new file mode 100644 index 0000000..c479cf4 --- /dev/null +++ b/stacked_bar_chart.go @@ -0,0 +1,209 @@ +package chart + +import ( + "errors" + "io" + + "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 20 + } + 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 + + 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(sbc.getDefaultCanvasBox()) + sbc.drawBars(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() + } +} + +func (sbc StackedBarChart) drawBar(r Renderer, canvasBox Box, xoffset int, bar StackedBar) int { + bxl := xoffset + bxr := xoffset + bar.GetWidth() + + normalizedBarComponents := Values(bar.Values).Normalize() + yoffset := canvasBox.Top + for index, bv := range normalizedBarComponents { + barHeight := int(bv.Value * float64(canvasBox.Height())) + barBox := Box{Top: yoffset, Left: bxl, Right: bxr, Bottom: yoffset + barHeight} + DrawBox(r, barBox, bv.Style.InheritFrom(sbc.styleDefaultsStackedBarValue(index))) + yoffset += barHeight + } + + return bxr +} + +func (sbc StackedBarChart) drawTitle(r Renderer) { + if len(sbc.Title) > 0 && sbc.TitleStyle.Show { + r.SetFont(sbc.TitleStyle.GetFont(sbc.GetFont())) + r.SetFontColor(sbc.TitleStyle.GetFontColor(DefaultTextColor)) + titleFontSize := sbc.TitleStyle.GetFontSize(DefaultTitleFontSize) + r.SetFontSize(titleFontSize) + + textBox := r.MeasureText(sbc.Title) + + textWidth := textBox.Width() + textHeight := textBox.Height() + + titleX := (sbc.GetWidth() >> 1) - (textWidth >> 1) + titleY := sbc.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight + + r.Text(sbc.Title, titleX, titleY) + } +} + +func (sbc StackedBarChart) getDefaultCanvasBox() Box { + return sbc.Box() +} + +func (sbc StackedBarChart) getAdjustedCanvasBox(canvasBox Box) Box { + var totalWidth int + for index, bar := range sbc.Bars { + totalWidth += bar.GetWidth() + if index < len(sbc.Bars)-1 { + totalWidth += sbc.GetBarSpacing() + } + } + + return canvasBox.OuterConstrain(sbc.Box(), 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(DefaultBackgroundPadding.Right) + dpb := sbc.Background.Padding.GetBottom(DefaultBackgroundPadding.Bottom) + + return Box{ + Top: sbc.Background.Padding.GetTop(DefaultBackgroundPadding.Top), + Left: sbc.Background.Padding.GetLeft(DefaultBackgroundPadding.Left), + 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) styleDefaultsElements() Style { + return Style{ + Font: sbc.GetFont(), + } +} diff --git a/util.go b/util.go index f391d7e..dfb9877 100644 --- a/util.go +++ b/util.go @@ -109,7 +109,7 @@ func Normalize(values ...float64) []float64 { } output := make([]float64, len(values)) for x, v := range values { - output[x] = RoundDown(v/total, 0.001) + output[x] = RoundDown(v/total, 0.00001) } return output } diff --git a/value.go b/value.go index acae226..a1ac67d 100644 --- a/value.go +++ b/value.go @@ -32,7 +32,7 @@ func (vs Values) Normalize() []Value { output[index] = Value{ Style: v.Style, Label: v.Label, - Value: RoundDown(v.Value/total, 0.001), + Value: RoundDown(v.Value/total, 0.00001), } } return output From 490d7dae384187419fa72fec87ba8ed4eabb0fb3 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 18:56:45 -0700 Subject: [PATCH 38/55] test updates. --- util.go | 2 +- util_test.go | 2 +- value.go | 2 +- value_test.go | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/util.go b/util.go index dfb9877..af3467f 100644 --- a/util.go +++ b/util.go @@ -109,7 +109,7 @@ func Normalize(values ...float64) []float64 { } output := make([]float64, len(values)) for x, v := range values { - output[x] = RoundDown(v/total, 0.00001) + output[x] = RoundDown(v/total, 0.0001) } return output } diff --git a/util_test.go b/util_test.go index d3cd9c3..8bf6b15 100644 --- a/util_test.go +++ b/util_test.go @@ -122,7 +122,7 @@ func TestNormalize(t *testing.T) { normalized := Normalize(values...) assert.Len(normalized, 5) assert.Equal(0.25, normalized[0]) - assert.Equal(0.15, normalized[4]) + assert.Equal(0.1499, normalized[4]) } var ( diff --git a/value.go b/value.go index a1ac67d..be436b5 100644 --- a/value.go +++ b/value.go @@ -32,7 +32,7 @@ func (vs Values) Normalize() []Value { output[index] = Value{ Style: v.Style, Label: v.Label, - Value: RoundDown(v.Value/total, 0.00001), + Value: RoundDown(v.Value/total, 0.0001), } } return output diff --git a/value_test.go b/value_test.go index f13a916..0b3b0b5 100644 --- a/value_test.go +++ b/value_test.go @@ -45,8 +45,8 @@ func TestValuesValuesNormalized(t *testing.T) { values := Values(vs).ValuesNormalized() assert.Len(values, 7) - assert.Equal(0.212, values[0]) - assert.Equal(0.042, values[6]) + assert.Equal(0.2127, values[0]) + assert.Equal(0.0425, values[6]) } func TestValuesNormalize(t *testing.T) { @@ -64,6 +64,6 @@ func TestValuesNormalize(t *testing.T) { values := Values(vs).Normalize() assert.Len(values, 7) - assert.Equal(0.212, values[0].Value) - assert.Equal(0.042, values[6].Value) + assert.Equal(0.2127, values[0].Value) + assert.Equal(0.0425, values[6].Value) } From fded346ae7a9df992c2f008789d84c81e50fbfb1 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 18:58:45 -0700 Subject: [PATCH 39/55] fixing example. --- examples/axes/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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}, From df971b61d160c9dc2c5266e65a5c05a9ceb5823b Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 19:00:48 -0700 Subject: [PATCH 40/55] pie chart image && build fix. --- examples/pie_chart/main.go | 4 ++-- examples/stock_analysis/main.go | 2 +- images/pie_chart.png | Bin 0 -> 83153 bytes 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 images/pie_chart.png diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go index d5c9bcd..9062311 100644 --- a/examples/pie_chart/main.go +++ b/examples/pie_chart/main.go @@ -24,8 +24,8 @@ func drawChart(res http.ResponseWriter, req *http.Request) { }, } - res.Header().Set("Content-Type", "image/svg+xml") - err := pie.Render(chart.SVG, res) + 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) } diff --git a/examples/stock_analysis/main.go b/examples/stock_analysis/main.go index 69a4501..fd131ae 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, diff --git a/images/pie_chart.png b/images/pie_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..2117e0147d6750a5b5c13399dfd535da44f34be3 GIT binary patch literal 83153 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 aU@%;_4h4Kf0 Date: Thu, 28 Jul 2016 19:17:35 -0700 Subject: [PATCH 41/55] adds SliceStyle for pie chart. --- README.md | 8 +++----- examples/pie_chart/main.go | 2 ++ images/pie_chart.png | Bin 83153 -> 37157 bytes pie_chart.go | 19 +++++++++++++++++-- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e1180e2..02f262d 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,11 @@ 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/spy_ltm_bbs.png) +![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/images/pie_chart.png) # Code Examples diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go index 9062311..a1e2097 100644 --- a/examples/pie_chart/main.go +++ b/examples/pie_chart/main.go @@ -10,6 +10,8 @@ import ( func drawChart(res http.ResponseWriter, req *http.Request) { pie := chart.PieChart{ + Width: 512, + Height: 512, Canvas: chart.Style{ FillColor: chart.ColorLightGray, }, diff --git a/images/pie_chart.png b/images/pie_chart.png index 2117e0147d6750a5b5c13399dfd535da44f34be3..14a171d18bb1cf765d0b62d93acc6e02f48f0f03 100644 GIT binary patch literal 37157 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%0AvYppPj`n z4RxT^z&|@KnVpb>6BoQtyQMB6(|dSzVCW~GT=M6QN|KzNk~}}nn5^*S?LitJ>3y5c z2EP6%0t`TGg3JI(lE@5@J}Ur^IPC|!8vlOh{PEsq*eH%|wq@3RvuLsR(CKzRe&m$z zx8&(ac3js1iFd2Xy}j7}!4GbSRvpQBOxz7BYMVg@h(aRk+G z0GWaDii~=3VeaF-&40gh;XvnQ*d&M)DNH`FBQ(UiYfubZ$P zAb5LX3@`xk2uYGMnG6|&2^R#gNf1k9D)n#8e26}BYuId5%6+@|DX1wwcza?DFaYrj zwID#2!1qQmMVD>(gPx{CUDuBG+!%2}LRqx(#*96Aszm>P9!>V`b~|``Vihm|z-R&i zWeFy94I%7q=c&Hd_q&@8bzf~B>5YJRz~sqN>Kl>@8j=g>`M^FvGkr?$LpXnI0|o#X z0~r9yHRL;oz)Q2a7g>V$yRV(-yJ>fUb3j~3aiU(IH9x6feo}sp(%{cqsHUXg>WLq~ z002Drzezwb?6e*0ZGNxoDzXRAo=jNM6gs5w=OyLOG~_68sXuQ)k32QscQ_p377q{= zU;uz|p*9I9{D0TQq4q;v*G~4go*QU`@^g_vNl2@7Y4Z~Ek?Jp0C;R_Kuigh2e|!Q4 z0GJpmub8p~ey#~HPb}%x;jWMSZ=LCHL$57n*f0ns*-FEdg!CF+dQCz`k;X`1{{CO6 z`M$#gxcK87FaSVc5ClP40)KXl>2%vi&i3CrJ8=6<|E*64Zd+a8Z3qlB&9Bs@P1R-8 zB&1jC(hX8&KyRn^b&=9X-@tqypa25^1PPAg)Up7*`oAooaJXF;hdR)*0DYbAztv$K z43m)2PSDeQ3iK>fahx#O2Y3Mk08AF7{k_WqKf{Mu^x4f#BR$tgx~~m)UmNLa8t%Dm z9f-qOYKmN&r%uXOCFZF{Uy;U7lB$BZ4w~ardLJhHfN#J60HK6RDM0oBEenD(1{5Ps zJF*AYM|zqlt1#T%V(xXiV`mO4#fcoHAzz)8r%KFMjlLqKuOb52Y-fQc_+GsalYPKX zzyJW@0vQ0x5+DNIFyP{FyPD0t$P(Nb>FKl$b=ihTzpTR@wjqm%s};#`GFhfhmaC2E zRi@5X8Yo?#sZ78F^yhG)G(P3l2c-`L0T=)v5>Rd;uRREK&%if>4(n(3!1L8%9YPkO zbJRSH+KB;&)fKj-11^yyN>!AKAAOZ+Jg;M4T5^Jl$^qe><~u!rdk2^#U;qFCpf->x zdocOM!Gs`9X>%bvfWDj-m)-2N+gwi1p~E&dNPX`hB)A+WNsuD5**?n=L#qMY1F{6vYsgIkA(1Evsd5q?_J~0_ zbLg)y%?Bb0dj&v$RskR~P*y?mIg{B>7BiH7rvP0KL>`$;#(KEe1^@s60EiP}bE*XZ z002w~7ytkOfH(yV0000$oB{>_001CP`F{Zb0RR7=0$KQ_^d+kR0000g&>GPMOi@?g3!UQ=nyt4_;D24J_SKANJT+T z-|yqzB9_;)Ie%8aHT(_6xKv}YWCP|lp`?BrnjaaI)$4P$zCPb~SF?|=Mn-<@1^hS| zxf-Em6&r~Ct6W!Um}Z*R+Bz*)X{h8~-X?Bf;A=!UVhyP{RYQ+F#v ze}?$19rE@Cf|pr^(HW*sA6sx1cJ&<_8n-j&PH#1_!33+TYw(Y=7TTPPUVa_;;u2(@ z&=qw1b*oiWOzt+3R7Nm;X&a@y&$e;S<=0K97hH^uytcxyytQ7EnV!zN6@siED}zN& zQdT+}W#O^{Hl|qN^sjEv79#H0?^ZB$=vM>IKUl(*`)#=4@Yxi*Zp~Y>%hIFeqYCmAYXi>^FA942An%;OjR7|^&VIJ_+9=hIhRJp-1%Q}0NlFBLoqv5YAfn;V-0Wn>nQBW68S_FC)W&*>s14OdsS^<681-&!ETfUGMA%HDr8OMi|GBS zLiC_=?z&43dQxKfjztgQ0v7s(?tCs(MspWnOqsJb=g7VnE;7&<>K!ISz$9a1r zSm|)z%jpxN3wDmI(e>zS{o7EbRK5LP#HLTc#!Jv%nLg6nK7&1tRnaqU!T|doo2wg-rbC) zHz$`dB|+G9viuPAt}qA_0=FSZR%EMXfQfis-32H6ZdW6H(!b%}hPVxqmQ5hqn}4jP zx@0yz2pQs{z~0xOV?$Ir?s(O8?T&#FA?y!H^(JjSI6}=bUR=+L43cw){MtM%p3K;Z z#K%;K+7^ptYGxR2@T4_18%7;BSCXE%t(2Rr}oU&)?8gv#U&v7TD7IrAQftW2j{<3 zz|ZSBz472zaaB!bf@t>l;buy0Zj;wBsWKrYlg=D_42FbZEOb*GP7sVASW4bT%4c@F zalMM{It*eSZmfYkE9JK_!^w5st|#ucwmtuNx6htk2btaef_7~wSX5mj&!4QhwSB;E zfFrWZzZXx{Gse8;8fGH6haPrAK8Xrxzd)|ak`jj)`VuTmt@^}1EAwX{-&^1Q9D$qh8gJGT5o_N@~zmwm(X6d)N%#QH_JS4 zuMd^I3etWWuzWk1CA!tE-M)bD(xG)n4Twr78nZMX52^sM78e!y!$!66zL;~LkSbHj zCZQmgq(m+$HS{Nh|7`i@i`7uy>z=+420oU!AdOx#x)`~|hWVFvrQdkpS{_3#Zwj2B zGYEFwyRtUTD{pJdvcuSwiCvhpfT=@}FdER7zHHR>(E07C>rN^LgEYrC*G+P`U7^r> z*Lo%u-k6VrJ8XY}`6`3x@t_1lJsHK`G_S3^yu98Mp--Iwl#K~)&$w~gy=#OCzO{ez zCDh%RY|AvdnSei=_xdVMBX_Ks!fJw#7WQ)XdjrIe0vTh0FZ^EV@=#v6c`W40WQqhN z<0JY~tt+3+Gq~ITDprMF(eerkoj1T{cK7ES%d36K;nvyF^du0>103Bno&R7tNQ#b+ z@;76_Vpw1DdYavOZ70z zpO7q#i~mAyCi~m#<>b;0Gs$P}SBS1FQtz>>Lvt2+x{g06rP)C&<7NZslDqb(imEKK zzdzVO&pb&>19{P)agGs(`nn&E$*UpG%*vREwO^uH0nKu@0xTwi8Aj>Ca75?&`lt5{ zC{xNIn$}SZ4yo$azHZfyi|Tozta;Qd^w_BUC@``xbg26>h4}WH!;jt6y2P9F+eqI& z^-{vx>97%|(Ssb@$wOd%m_{S$L2&iFbSAW2N7SK|JSLT)*H9WJ3I#3DVXrPXTq66& zsLOt;2cLa*{1<+s=-C|=^Ni+H7DyZuO zT?$eeMZ&0q9yi?0PbGnXSX(K144AkqDr8OVP;UY}&2P}kAq^gRF)(grOgP0VOoNm7uvnnWz$Ku= z^5pdE2TW7FCYT*+NSE8#f#fw+R(NbR>nta}wIsOCx7`l)V%eH)i=AH)Okwd0za4pS zj;||EEx8>SnbsLrGi>9>HZ4q4b}5Kq2mBd(ek{CVLLKELj#_sI2USQeKz(Dw** zC@+Q`>w1#aNjw z2@WKyXvfy|Q9UVZQFv}jr|jty~!sONCO@GmFMglM`*^_@#*2K zdN+#d%uUUsM6>tL=>Cn&gL%A`D8_g^*m(K8Er~AvK#s-W;1?a24_MJt1fls|luT4& z2o}`@H@*Ecfh`gkLu8e#*eP8rbf1B*$~B20Zf13G!y}*%ZJdjLZtwC_oza&-t};3$iJUNQ__Tp#w0W@eej@LLKFef_r{39hgLPqK zmpHk?%o>xPrDPfhMsy8BX)6C>G`s4PENHD28rmlm3+}HaE&n4M7xohbFwt0Wge|vZ zJVen5ssx;|>lH@%O|zVFyQ9n*XWxc+qXuN1%$7-;i?2R9a8f`+c3 zqu+#OZE+8FfLx-?FVY23m-A$g@S&fMal2J>_gkemONJsZg|p_@3n-sf`$72f_g=Ta zBjJ(bRXGRXG$5Pkw`Wa1KlhWgJF9jf>TacnXh6Cg$MWaV|9e^G%bK{lv9wVf!sYPf zJa)JS8EY#BTd&YAu0gDYK1aUYBVScpdzxdyL@>KJ?^vtoo0L&JkkLS_&kD_ALfR74{i7q8 z5uT^P9c_er^M@BkOFrN@vj2Ml8xr|YlQ#=?C6>g03eWM%`>>B~<~wuQg%ND%=-SVc zm=p{6Ja)I7WNO^34y!Ck4~svcAZ-ll3x{?naPnk%F8tWUQiRGRn|@y$U!$-1th^8kL+xo%Hg0%@}f_Nt>77dtqpknz6y4J({5 zWCK3ujdb2CVd^WjIc$kr`H+>*TsEUGEC$sl_Dn2Z|Gog%Rw|r`R_Mg4xKwu>$Ju9Hb6lPK~@ zqVnBAX9ODBfvO`;@{B?COx*u`x;OP*{_YG_;BUjd&ZzvZg8{d%UK`D^8u9(|A?|F_ z*m*ojn6G;hUoo||RUeLjMTwuc2+6gj%HzWA8D{U+1l~2E^}ncRwd0?xp}| zaOnqvq*{k;(Y1u-hZZKby#2os_gzH0LlQN8)0f${^3CzD5z^JoH_~Krk)KdBOOlJ7 z^>I;WF@gHbp4HDSxQayGj;am4uG~R~fOk_7`6J#w{to;=YH=}|kWhc2TFWvzRaK6O zp!@nz$YfQjW_=x>fTqDkud>Oh<%dt}H@^5zC|9F0$b?KG1hCNlBfL%@yw|M*d z(n$N&bW~Q}NDF7Z`*cj*&xij=rL+pOh8=iOHK9ZFIc zBD$6X(Z98}le_0h%RI#|ASx5aT6I^8>}(*AmycJ<@>iYiIxZEoBQMO^dmtE|C*(8u z>oF9Dp2+n{0JVC(R_L~4T#dl$(vIu+N8iYQVV91t&+9D~ z)TF>p(fZ}fm(XRTbqQzvZKSdfhkiu^XF#VsYcsDB^1X`Xl7NHI+0IpS{toZ6in^xR z6b_6I+)ZXYn@YW>XTI{=U`F?esl#;xel>(uZcY$WI)E||_>N(5&x37nwITnH~Q;j0v+aou+G6)$gEz2(`a zAcFLVbr}e_?Zc@Fu@rS?DWJz712z;8{5T_}@Hr9lIZ-rGz!B?BG7M+N zf&QYPQM}!nx4Sn>#N<$PkS}L}6L<0Q zI;42^o3u5wI#;8f;PHw=V^Wk4G9>^XFzi;t?-z({XlECqS{x<5;GXQ_5k6go z+Ok*u0sI3q)lS6`UzllAJ2gon^^>G!Cg_f(ccKABJ@W(dV8fdVVDbl299-{yiE)f4 ze$!!5;WaU|LPLav+WMRHCqQ{Vxc1>u@+&`*`{NVucWu-QXAy9Q=M{kvvifNCwsGfX zAm}}1$j97{*G4yFOadzPj^`eE%g7Se+GTift!<`?ZnpXGC_jQMPVv;F0z3Q{D1hIN zJa0_O?;16PA4-PS%p5%<&MNcNAE!q~&TPo~AtZ~Jfcp@wATEyeJW%NCM*-W$R~DNu4+;>`vdj2c(o&XgkCX0WN<$a0?EN?Nv9J_v{PgoaAsN`O+7xB3 zu5?Mi-`!us&`6EY6MacCi~Uoupk5GmqUu7zLK%YIbzWG+s7{G-IH?npiVP+VwqI2p zUchxZOCh}RCm?#~WWW1n3xwa_KKK=s)?VV*VJUa7h7lVd69=MrJ?G*ho?TU!VIHa8 zw=EHW^m870ijO1qBR~Fv`dz%+F9Rj?yg*!N|z0OieXehzRiUsb{T@r+C{IHFZ+xv0GnbX)A(D zZ+WTxJJk7gC*ma^NfppI*Xttp7h~K1R;Y0#ihR6MG%-)LsAYal&y~R|`fQKkK_74- zv{J9W2YD*9`+u97nqmt>Gc+b==zeq}j3^Mj;;*+fyg`gKl;lB$OjX|(xGr^iy7_(j zr&p=V>j7#|&ZyccpnMgOc`5fpHoqY4PX~WX?U)9fr2M zjMavk=g)uwbEsR23=Ok+n)AEZD%HO1z+H|cxn9R}Gtri09)e(Bcm+Rgn+*I!(55$f zrP3Fp=Ba>>ifc8?=omtBJK;hPE~8)GOGb&~Ksf@5-^m|Trsn}tJ2`>FWz}!d{{~2P zdEWfKozTkQC51Gwvtao+Y~;6t0~I}6R(2T@%f;NGRE43lw9-CCi{J1e*|_>S7hnMf z7c~WEh%&JgIX0!>gTGszUu%qgYOOMG>D>(2m>>-l0cd>dSIZF(%lFcUR;eCw=kd7s z^5*T;u1Q@YCuEwF4ItRTI)4!4OymCveSptAhs8RWkbgIPx)M0d4m73Liw_A_opax) zNqQc(wKU6w{gl~DV8UTZG`OhHyF!EBR+hB2)fdVy3M3D9Q2y-vY0Uv;(trS>ZCIY# z9wnar<3uZFQ14Hq=XNyBbW+Ha$wRVisbE^IFLP-sZM+tD@t z$7bot*q;i>3BY7|D<4K3Kuoq2S0T(pd}QM7jbC57@uWV)FkmWnnEG#-63v|P_QQ6n z*htUa$JpQWAwl-F;&J1FxN2a_yk|UbC$=71-Jiq*M}MnfOx<*N*YdAP;xTI7+;&QP zYkjn_%ACKnCGo+K73cxSjRnUYW|X+nKNyXFc1F}lui#diwMG#5PQDnWyXFTG!Seua z%<%Sxxd?mVkGQ2@3P@#nKA^y&WG(4BEZzF>?sNmfT8~Lb83|w%GI>7Eo17^1i48lNB6!Dryj~gaWXFKpRf4PR(v~4mHUd7Eo(?h z$C(lZLcVfPeee}sISew5o?Hyb?(_GU9evUMWCoT|ZYNKtY9miz}x!1n_5) zLS12XtGn2QkDo*J$HTv+ie;5F{};cwKR%o|la!Lu+P5C; z(s__H-EbX#Z(ah;7PbJCm(_?DJY@d6Xm2<4^-r0pcQt~aT^WJ#-VeOG7D^3`PUf8X zV<2iO{jpFw(6QkLA4SimP-p#-E3|_V8k9!_S_AjFcy*r5rCZLcr3<^nNoHOrji4`f zbT$2}wFdtrqC!ufkcq*u{W9}Rx#6-%rzS~4(C=TFiwzdwCt z37Lg6%WAs~!8=LIE3ki>oxQ78HgCGsk&7G7#b8lmIo%PupSzr{TDe6*3{Cy>drly8 z_-G8PcNpJn@z8@c@#FAD$i=snM=zQ62-X(Ich5bjl_mw#(B!fKJ+Y(OvY}&h#uGQv zp;YF3hgldp9ygMPe^HBz-96hXA<@>>Iwj?4vZE_%7#Fw|LES9*li?TvJ9 zwe5Y$fA4LI$Uo!MuOam(c1En;%&jE=Eo7KrjO-*$t>SS2_kg|&`~7{}%D#?wH>N7o zMy%BdP>gXu4uqQ)^}(x@iW?p7cRCOH+>Nhsy~8P?Qmp#9J$AOclzSmEr&n~gXGE1VpTyTs%x}iI&yr?H0epF(DH0 zrx8Nw2xUpnYqOS%PPS8_&{2%pgnJ!ju2=1rQ(F)()5+e_atr+P(keL8a_BFXc4YuyTw_UvYVyjaKW-bTZ++rFBQCP#RL4*4RVC zV{nkR1W=}u-p*wJeM zPXpW0CPSph-xj4>|DB)%0ZxPAZo{-y0t3|FZ*8{rC`Wsy$Tyz2x32gUE?Q{@|NY_g zVZze4I}Ix3W`eXah6?UZa8zBdruVYw$m{Q06o6JMCbm3F8YsvqBs-f1v{gGfk<`yF%ZG&}h|K@%Z7hNg3RzA^C4W3|uCtCU~eIv}CeuJ3Yhhpt9@t z#?nt_4p{a*vgm0IeN+r59}rcV=hb{9JMUZKI{;X!UZsxf&QJOzH4%Coo2ix9Y;m#? zM-BO);s9VDl*;g+b=I`F;p92BP;*gz(mXhJ;=`Td-j!FjJv4dF{!%%?ffuELX(k6P z*2>j>O&nd!zyCTlxK#I;ULQchIAE3NMORiHsrA%bcuTW~TjU8L!F5^E*il?4XP zV&RWB{F^&yiO}cjUhKScNpw&qeC23z{r$5GPU)-PEZr>hD6q!>M6|3nn&(s-={VFC z>Euugr!=HTC-}4bgU`y?fPz^9?wYRuuRd%j)-;H z8Uw&D;iY(XDx>CYr^C$;h8$aESh2DjFm0i{Mw6oZfSlb8k9k_B(bMEY6$Rd5=iIxIt zyj_NfkoA%K`>YCS0e5jDTY6FcQW)CFa}BLA2C|Tb-O-uzXpj&kCr{erEa&HIqZe@N ztrvxQ6>QGZMIV2}JVvPjEiq`Chuz?1tNe{A!KY)FOi8&N0 zDuF^zWFAQXFo7b>K*TLU3xav=^h_2=Qlxy>KQ0GZ@AcT8kFNJneWZ}r9NQZ_{FV}Ic@NvMVa z(8Qt3phAjW1-0Qesg{&V-HoyJ-?_TbJ=0`31PZj*y(^`>SL9m@>ss{!)!PpqzzFTQ zOI&S1mle_W%2j#t^xG8mqlR(Ca-)q$@#-UQiu%L`9*X;$Dfw%UgO;m2<)wV4W`>Hx z@)aI~bb*>O6p-kl<6S?B4E*nQ0S1tHQ7sWOm93y#?RVz!lJEH9f_1BIGF*rDfgsRn z&YX)OZnX1NkviM;Mt|)(xoN@|YHbl6oCkR}b1qHKl-`#H4q#ti>&bQe`8o4Vb;Ir8 zJ+q1CWc*299*ESm53YH-gy~tn*SjJ7Z?&0VzaA;Lea<2s>p_D(eHG%QeR>w;Bizt) z9MD(Tru_bPA-gt`x^f!`&-Mg$d?&^BZoM2Wa^M^s|3#V{S_7fGv;Ja(X{BqrTD3J8 zTIi6H0*vfVPMO!=Y$x+-KdNuI{lT{fvrZh9+pKOEYZ`Q75Iu^M^NVRL&{yOyU59f$ zXT67F8qK7_lE1d4jSQ)RYZiMW+j={+I%&YxMT>H*6^@5{la0t5Cok7Dx@?QwoY*hV zo2wdQ5dyvOKYcqRjI^K_j+QrXg1e!28+&DXYaQx~r$bK!Rh`p28@Q4PgWYU5`dVs= zty*qM;6LTm8esTQOTE66$IBwUJLlGq76RLE*LdN1mK>K!dv`tCrWcvF{7Z^zr z>_)a6U9dO`l`tfuK!YgSv<>zQC{Btm9$Y(m7)cM0b`O`OL^E#aXc;L$;ChFf6=0><$>79;6U-->oo-9CPpwVyI%I*{3STfY)i zd#4|lrjBVG$HdE0wl+$Pg!{sV*QrEntQNsg#n=ef7O7Uf@qkWln*bH}{3AI`Om2O# z7kpraNdBv8?Y&Tc{)>%VwW%cFSAwD$mWCn-WaNgp@d=y*az5r1V#uXiXR0Nw-(D{s z$JCV(8-;N*gmEW84e0Bo)hFH|Pu89uWNWFK8>W{+RJj`zE}+#JU+nA%>{v>Qthh+i zYfeiF=$rIXEq=<#Zi)>`EE3d&Cr_fOyN|#Ul>Vqd$djKk zo`-=WvjVNJ(Z|i1Se%m-y@^;*ptFg|#l^Xw`3D(;YfEprc7Pg_?cvvYUuT~1X~+zs z@Dd^K0bh+0k#1FKcMz0C(EbT{^Qf}Jf$Di0Fv;I`Ieo4*dni5xbD z<8hnj_Xkkk65zo2D*3L`x6P~^OF|2@)S*!o)$}Pc(CfmuHM1pc8cnCAs-tXIJ~kCF zyYzxG)iHuWgLM#PKo(UDro({3OMl`8k~rzpJCV+z(awD2-fGHk%d(JsM7-qT)%Des zlzUWg*4#XJ718FLoCLE_+jwS|RwaSV*{Z?6Xkyp!8$ zOu78l(M!w!EokGy=y~$rD+*cQ_R|ue3*miMCX~e_1@dFLJ&StsojLXC9pm{ec}rwD za)pK0ss1Ni9Kxq!O*7>npjK`mJ3l?H>hHuPWuWt>m`!!MNj#=36U3p9z~yh~jwFis zT0JSs5r4}Z7I1i)sT&2DevfS%e_(^!BAU^w))?uLh#NW-cXB;vN7%aYUfhR%6;9GpfH;aZMwakQhgf51=04kf3 z`p6_DO*+MyV<=FY1i!u}d28%J%t+~Si3r)ZedhE;8Ok(UxA&J!ZM#tcd?JV-Pako= z`<%7rzv=RP5zb8l}Y z5tFU&b*o}GpheVk0+fWI$~u@y_T2g8Ig-iD6bn9iy}^4uBmWc5Rb(Vtqby+6E|SBY zh1D(gbreLu)3Ogf!y_J`kbN+Thm_l;=Z_Rff`>Dp(R+64-vJu#X(^njKpXA~Bgs5^ zvF_zXy0O#)EbTPLM_5izf%eq|MqNvEG@AJ~90F9?>M=c*6E_eb3V0A#oFERq_qu>F z98DXG-@xPMUK0wWpWeG78nl(y%F2qZr7^bqfKXpJ^+%AmAbpDKz`x#~iAN0Ekj>Fw zD=VTws0AJDSm6yiXNRLYJ^nNFu{Gw2EHv$Hp~gc0R(LJA`~cwpf@5X1`d1x&uPu+e zuMSvrJHkftPu3NwC4j6q#U&&HTHB_j@~IL3JkhWAvsSYt>KoE>F7?|drottL zeK&A_OapjE;iCeuE$jo%zoE~^b!)`LTTeKHmS7~d4j5030nd*}D*|VdCe`xv{4i^j z7ZJJ~&z;3vwv9d{vU3l)_^TEZAJ^=X~vyzUi+3wq}l-T4#d4(P*1#Z5zd;~o%TBMbmQ~AlS}u5WEm7( zMYRyafb%*w<|0$+p)6wl$h`?b*7?A_KNG~nuwQaHa+&7t8S6QCQDAMKKQY^4(bFf1 zOG(q_Ojrr_8A^|5z(hvgN&-leKb|n$NC%(p@)s+hcrjOukQ=f|vKhDvX9Y{UiD&;5 z;h1f?T@3EC-#RW*5R9sy-d(vF&nwi29)sxrVRVJlaRJW%Zf8b z)gjl3i&}I9rsr1CNL#~IWPj0^o zl*C2sDKqg4^O-Dv$qSoxX)25+Dhp(j3wll!d6$*LHoQ!ceI+wSE%B?fRWTOdC50No zV|j@684`-^F-6_qvNFZls0z{~<1kl4gDl<*qj-}54{uuOX<-<>UPQKYgq(fvVCkQD z;?XziQd%c%YZIttPt~w!Y%uc}*amAiC?2ps9JIJ?7(qr?X#H|aqyMMXV_`&OJI%G{ z^Vpl$Qm!I{jX{<~y>)Xg++Cmi3fIwHo98#rld%4w@kz6H&K$I>&umm#lh$J7JDfS4~_k2vcWClgev{3iZ$7LyyR{0McVLWiNxxmT{Z z`@~Aki!1q+qf5KIKe4Fk!`^F0`2hXZkfHS9dfg-N)#M<8a}?|E{$iI~?uKjI^_34f zZQDonrS{+w@jLuW$baagw72f!BjuAm*?SRBB2+wYdV86$M7Ntv!7}$U0lP7#x5ZYDT8aARZY!}*$*Yl)j#_9czOBwFeS6E=PX)S^pP9z zzYo1YyfDw7E%l#Rn4b2xaVov+YikoD`6)Y^*=bLB|8*+k`W7pUGPCW2g&?s1xszXyVe z`rr5+ug?DYGqCl6|BBUcV!JFOQ~K@RlEM3Q`52#P2#}4yI&X{JT&@TSM0^b zc>Rb!&VcRtcf7I{s~q-uc-6w2XRAl}?GJ_FF?mC_)-^FO!Z?PN)3?AHUMbmL~#p@YJf8F=di|h%H zQ3xs0@-<4ISe|K%TR@jHf7{=isQg?pOIbFUdbUfeeq~>i(VI1Kij#I6<54V8y!!3e z7CoBJt(L-BkMC|2pICwsrJd1r4`-3C&A5;dLt9;>Z<(=Fjm%BqKn~F7p?!C9nI_VT z(9rbVulO*gVc$cdTu~n{Un{HQo~}^`FtL=i;PC*AZYT>DSHaO5pN0hYzo*u@7%#X) zl;v3-!`|z1T^t>)h0=&`H&k(lzY?WRVk*N6lB2Q4(YDCnVukTxA72AlIvPDMv%+kF zs?_ocee9os*-NwZ>bLK;9$XM`w$l<(#@sH91%Ykdyip@HL}dg^O;(ThT%0fhc})!f zQTNP6RS#a)6Q->F>;p<^?vSF4TPU8Q3`!;;UFU}QU0|Psjl#9STcRXF|Y9vhF-Yeb;Wrq5lXYnk2Q0{e~4m z*bbN(?28}Vbp*pmvA*@Dl{+&A&GU%yXl^!3wuqt26>($PEUF?>)kwjl6HQlNcZFcnd1HR zfETy|kkBV;H_&&)wOn-NnTUZABjpi=0ln5)F6tNYc;BZ5r6eTyg`0l7m*qd8qou+7 zQCYRO>391BVPk5b@aF9`LOM6#Y^-Llvh4yr|C1fbAS2l-n}!|@clwLp-67sunVY#8 za*Z!aU1Dsyq@*iNh%Gt;P(R}dK#c`1H`87d&7JIAgvijZpZ$M|xWXEB@5o0#*X?#@ z6Cd^MUV#B#&TTLLR8v#cPXhnY`H5$DUfl}uL&$2N&AnO$hIb#su!k)&lV4NNQr?+hvd?(`{I@^Q9Ua^k8x;L`9M!qJW`v$^&b^STNHJwzrE&riR{x`tEGO4+y>E z>9JeP3AK8&|C=t?CUI>x6SyKLSsg*df$j-vq-S9PA@eWF%mYeXC!Yh?4F>Qir_Ln& zeC5e3U=()0)fVp18m4s02$dK~j+@`w*cKlyl!c7#h#Jh@tFWBHnhuwOrZ?HNF9uv< z1Mb}HaU9Jc3S7Dtz$pev>sLn@`nVodY_J~{TC)w>aiQG5R4bihkVp5DXjR! zvqwz~;{g6n8(;EY!^XxZ%J`t$>GiiStr^wUm2($FNfJckJ4^P6T{Lv&R!ACf^kp-ZJX6qo;g;-~8nTrsZ9*s{m;xVK1CD zxMfw({f;IuDt>7KSu8(PAyzLU2?pJ1W$*SYYAVsZgTbR70L>v+*l=Y$p9oTzj2VRt z!qBZthRXGDYUjZS87Kvz9rXf0MmM3z?tb}yp(RZGZ2%A>){{t}Q;hUs?)Dx-29(+R zfU7fk&!Q7IbsnvAiJ#}KTt?|lvWX%eH#@(4c$%u;Nvl#^-A4kfGfDwdAGGE?{1vUq z_#HJ2e$fe#OYW$H=Lg}Bw%PJMqYY%jJL^$$EzK&`L#ebr0)%Ia2$&Rz znHX+>rit^nLO*=`>1E3>4n|pL-^V@-1+8%ld>Cmc0g1D3Rmi5LLI5r=zk9H^cd(;- zF=Ah)X7ivkf~vJ6CCHxP^MQ^q8WLreN3Ee*cMJhfs{oU?LrMf~sW~L=uo$9*)e-&BEFc6x`{#eU7SKZ<|NrCko2fT8F zAz7wG2&MyXUjq4OCkK64N``!blh#QHC#r*D{6MJR0rpZBwMK~P=afsJEo;uq6odjZ ze3Wv4_T41aT7Pp4z~8iZ=q(c`T#%6Wqs|~9L*18Y?Mr{K|5iRht-*MY>K*^#qk~c| zsNNu_d$ugL{Ha{9UsFn0ys%y@Ec2q>%4)#gCv$=ee!LsA-J+PObCHrejJ^xhWI`&O zg@+%EIIKORgVf<2vd-Ql<*;sRYQp~f2&`!-F*(2I0hT4N@`~IGE=P}@^SzeEC_g#> za_Sa8O8)0T{hWFJ&fVhS8xwsgG?*ki2@*DkeKn_`n&PSt`QMPyV^;<>#48`UMZH=D zi3$vy?qbnw&CyuPkQEN{AT=;+pZD|4Agnb83=n?msvbMMC=dJ&IC?UqNm_HH~=@OM{Wrsx?5Q!7Q&JU^mW zi_CaYyb{+VmA*R)DvjGPj5~mPm4)XJ``nm}N=QMnmfPMaU0@<8Z`<3)=giBuJn)Iw zRbyl+h)7OvOD3w*ZsiP1|3S4+fGYY|UO9bM1$MZ%HHj4Wo%&E}JC4mmh@yKuqkgB@ zgKV)tZaCW zE9-e2APFYmbS}8+7d*cv0HG@$eXWVda1hF##jhb`f>I4iuf;+$vbq3d1&v?6-g@ah z=}_O|E-ocl`7aWGs5M&4_@nbRrngk*jjOY>DSv|JilQHZGQ`1Fea)^Dcb4t83!F|6 z0|Yq6@`JC^)=s(rlKHae>OD4Ew1!v*?gP1eC(-ka^BRn+0SKPKIOwOH42*9cshu@O z+vgyQC?_i7#!pB(Q}7WOA2~Z>@Frq@qorARBqJzp_@GNjx@hjx`$$<*fC_Qk$=mdY zvTUrGjr*`-xZz$^5rmMY=rxh7#JCWgx#doq$JGh^!jXTtr@h%m>}joj2&7& zzOV143Xz93e|=fg8l##w?&Wjwx2dtQERe;B-{_edxVjpH>gZ=-UWSVik+SRHW&3gKh5%&oWVEA3ZvZDACH+zraok;=7lkb4izp1@}ZOt#R=HO9}uV zdbF5l$`D@tc$7)n0YSS7Un*3Hr=*ZMY=h75s$t{kI(_3&oFG!3d6JTh&CQy7PEyJc zEt`y><{NXRMJ;HSBSYmgaw1{0;QtR2ShE6T(xIL?`j-tu0u?b#5;$n*WpwAe)?BE@ z-ZnrEIfLLtwuw3RIje5)%*om?rREYVGl^+s z6I*rU+@oM5N&oxj&#l`@-i@Ukd_s5P)`<-Pp!mVpCwYuaU@)djFfR1lC9g=J3euy? za8_RI6!)+Gq~;_XcUs_{xedOal0x>=d*R^r^@p{{3Or3zk?M>!)Pgv;Zb5?iCE|sP z1!d-Zc$cB777Y)w1laHF+yB!UhnK4H0(_m619ioz#-lO^0(=4)U6-GT9F;}tN4`g%O=c|xE1O9KLB z3=8}jFxX;wWKf9us-*e@?QcLw<&7{JK{)^hlmgpY9XYatC8%aNX<`4E4Cq#KDNIY* z-J2c43o{i&q3`n6pkZT|>esvh6ERS?@@s`WZAAnMnC+T=@DLh*2(Op)+)4SbHO%7z zE<-V?cAAK7;8Eet$%Fp94?XY6I-N;@TZl!P_Az*zB)_4CI|fGpn|iknSnJIEF;2Xn zY>L2(Wh-%#&N>DuHNxlXz}|c)pfS%hl5*W)W)Q1HDoxUZyV&(A7|>({pGFT4_ZN!} zVkSn0i&Ax=w~oTefsbED%KAS}hXC+Ax@5&pZvQj~WIr(ZRtx+E=<5HH)WpxNVaFgL z_d9W+Kkq=7ZM*EZ10u?k=^#aniC@$aaen{_z6TfM^rNfme<*3Jyc}G-Fb5&LFhGWz z$-ZEQ);DE51**Zw3{tsInZF+ZE-SU)!W01ExQv}n4Og`+5HhJ< znL8uQ3*jP*D?S0^lYrC*T*FlIoNZ$Rk+B4!={%9C1g1J8Pq9Pa)Y~t_4<`LKBX*LR zzB1LZ?CwnHb}cIjyag{s?~z9#!5q!8$?!a2vH@_NS2i`}9sP}0#NhdYe~w!=hwrGw|f` zw(m0O{V}vR-!`-R*aR!kP0U-M6KT8wD+)26I`59pe%caIGA4yFYBUek)EJ~esagj| zr|IymmmPn?THWz)+7HQP@+lGJb3Rr_h=~#xsS@xirUBL6-VhlZ#>xHZB;EH)Tcb&f zK%57shjkwClpH*+E)5)teG$#&JH%ec%lN1T?oh*Qoj&%n! zbAQbdED)=~hVbs{A8A<+Mz5H31pipQYoATYc;LN%`0d-5x}GtY+PN#4H8^_`Et`b= z$idfTvVvy2#2I!=UMhwF`9&}BY(nrr;us54d~5sQU~fzN0=>RP!5Cv~a5NES>WqruBXU*{@ zxP3q2m3`?g?N=r$Vb^3_nN5^7GQ_BMpnjXi^%BdaO*7*qt+a=Q`A_cL#;Xg{n?SB5 z%lclPNi|(z7A7>(i%c`>&Od}YF_vkiUnZRQMeX(_6*`+g;Kaq?#Hdu>@ibofm`TJZ zxN)r8+H$bjli8^>e35vZvxIf`bfR~iL)sATllESIZJD`vvoF=38NZY)LnTwHe6#Wj z(fN&E*!)RHwzR*l0Kx5ZHnJfdq*_J&R02E#4(>#!J^mM2Zy8lpxJ3}8v z5D-PAk#10s?ndGqKtLMl?(XiAZbZ7fyPNZEy!VcGeDC{%Ka6qCK6~$Htu^Od^I1=$ z7iJ0&m3lllqDP4Pgxwt1h_Aa=dlb!7e`1$9%7cBislFxd8T4ol3#+@uu(GjI##GL^ zw~MK;F%&%-vLW5u_eT=2GC^ z-6YjXa+zIU2G#)X1h;_khxPs$a5o1wiGUqBT_C}tD`v;CG_-2#5e>s0mLXRzs-xlF z7(`jxx++ab%zA#fi_q*raAf4<(oA_%IvMfxU186rAl5^~QS4)Z`*|uPZDCL7q16R) z88#Thb7olx3A@^Q{2+Kc0%_k7I4e!+@hrZlzFZ)3Z#_0n7=;LX!gQC|JlhvxW9A;E z_Xc-Wm8pqo= zJaoOq)nwDX&qQw)^^ET0F^QC+9O~|cYc3pQIk}d64TaEH8~JkCA~kWhNse-nN^8{L?JR{Hgj$Xos6&?vO0!!jUC+d z!YnKDd27Q_a<*g@zxd=&)XU9`-8cP~fVJ&wH;+?SnlkX*EtlW8@z0A~P!=EDP2HlE zx&GIqZkkML{|EJnR>vc^l1j^D{e4<&aWTY~bm+A2krIcDYUeHjbFnZQ5%ZePbE86R zykeh{QJ;l#B86pFj<)GVYl2KE>wvB~^78Mu@loN#c(J)M74)xjqvfV{Af}umz1hCo zEW5X{QQ|P8!~rmgQAj{kZlc!yl(Jv3m1unXl$K|%hr8Objzy@J>#-!EhKF1@)^Z0g zc&2&MaK$K~iR5q_vhF%jNrbi8 zaM&Hp$Ky!i#eJsS%)6|9|W?JoxDIfL;Lf9ExR#1uqOa6KyhF*dB_ZG^)%N@md*C4~!JtgeAc z$NLV-KSIC5j(XGLlW)t*gd_#}>SktL>s%;})&oHMqK5u-z}DHCi0@2>Fj399G(_QpgOA5b9#hR!1CS9$Gw8K7a69TbdPXl3?zR>-X;|xW~e% z3UG8%>(4R{L3?!qXT-;^gCx+?=2o$DqZ{E*?2JM77pEd5_yeoRNFLu<3%{2wSX{-N zyHkOxDyN<*-}}|Ek%z_ISjOfywsNM`zb3W^;nfGwxC9nh|7w2`4&|r=+ONdWOzi#$ z%_gu7(tLEky8($DWNRw)(6`g<|HyKHu(8zJ0;41n*?cucTVqHo+QMzNU*d5jqL@&V zGB%CbWUu(zP=%$gofpUpYkmOLPqhWE2pPQ~Yjz5>KmDM$bludh!QK(|%kn~7cM{wm zs|C(zt|RExXTAktR7a}-uB8Sn_DwRhB!unSg1h16`;VwTs7@8d92`MfEyPSPQkcBqnB)GD<175AZ)}Unfcl|3(9fXPkihPO52b_`G zTaUXeHJq_ANJeB!Q5c65C@_?}meg{W?}IlCKPf0*RdhGJzpw!1i8-+!G%TNKv3)}Z zX05-}2&gQfJ`*KC9ZejLgk?;he{ZIt<=M?pG{JfAW6Tm0fldhuHqUZS`$YkI&8+y; zA75dHOJ>yXzVWeJ*S(()iFqn>(L0EOkDylw%4!$^^u8n+WR&G>iRDcOKFv(?wq&j? zv(=A0PWp5pnhmZVGI%n-eqDPkDqs4YZ#RJqm(kRa)#;2j$|#gz5Q0+ea<F z@>M*slYf|vor+sD3ThXvB0NUkAELwVcDMC#JF`E7+q5@_oqJ^qsx>e}ht>X$Kk^`}Z3*+GsO}Tz18WKMo~J1cH06ptDbY zCZp&3*$NiIjlRiZfQ>oBn*^!UUG)V_8NXQa9!X?@cOwdF*HvUZh5;>SKZVnwi=EjM zT_>RkB3g<>c~Ql8an)-I<@xwm^Fro5D-#39@I%2OjYiop#?QI*lPQsjkThoYf>+N6 z*Y#L(h$b5?&3W*nz=;=*W8h{>vyQEc!<{3wD;o8~oBAj9fV-3~rwT0ns<`9)U`dI$ zoF6PS!~yAR8k^H?PhpRn#{Mu~tL}-FQRR&5zCl+GPAePp;?iHfih`2EE%ck%iS?N} z1;937oW9aLsDxws7>lUCbGFJw z!|_7whj7!Z2u(Mu75Y@z!3k+une!o$fXUUP9f{GoDM%g{`rrpJM070#Lhre*{v4Tf zHyRZ#Jp4JDyM+{7Q*ADGu|Vi1ereS#zO&yEaNA1PD?f1kbiq=4zcFw!s2ahhrNF8*-e8xO`A>Bl zi2A3x@jH>~CweyF^u@eLGR}&%^4W)@mmi2*IRd5BQkS&n$LMP05|05FKGjp9uV7K` z%*=~;czCZ9$7Wr}Tke{&EicNnJRd9SDmDaWzS`wnm(Q%6xv#f^Ufr(ssJH~jlw>ibI6wKwuqJ>Hq+b55(98H+b}qNqq2BxwR5}*r`3ASQPp(zyMM3j&Ep2qJ<9<~ z%?V3CXdD2m*V0*Z=QLklWSoV>7&ysz=p%SY>97T`k(67|@e?BsOmgbBskgC$} zhY78LxwI;HWGjfJ+c6lF(cAx|8Tp-|aG`5-sIa2sRo1>b(VHKe($U1_j_lheR;(ue z@+`b#?3bM_fQ-9W=4mS8-~cRAxaOVefB@^Rox{AoQ=hr5mOFu-aD_<^@y3 z)n7Gr^PqC@WIl;g%AX;6sY_rW1(zBwhN$q@lY`rE!n3w~Xy>+zRSS)JOe4*15!3EZ zTX85CcZKEH*)?!??!rE+33vD}vM`)5NGlq7r9$YgCiN;J+4gA+qT-^#%1psxEn0N3d$6CB@&=J+;TzWemoSRm;&E{q&wJhVymIP5G3OXKijiO zXm1UpwTF$mqfr;jeTzuNrgZmdIU(5HUFwC$!YqFM#rB`%Kk>C(9W(`}AFj~el()sG zalwmpHIpM_A40bWbVzdQ*fl`_Prh0xVNX#? zK7dWdM_hDp%T`)&>AYs5WZ}Dd0d>dh`I~_vcI^@T_=SQ~{@KYdO%Gen^^xvfCE?Wj zlf(nvZ!-}ey84Q*89*bA?KzwMj^jsIPWwGH4(peeY$j4b<|*EXn27*`)}`1_MfHm4 z`dpwxkUJBm5bA09#XOe;zU}s7B-0vGJl;Ve`azWa?}YEwuJ&wjFI%Az8pIl3Kc@U) zBZuDFrrgUeu|Z}h9hJwdYu+quL29S*mDHd8q30&!DnmncI-H-W1QqIFAy6YubX9== z6X(gVWLKhmgS4G9*2x7tZM`4c+FHcrin~Hwt30|f-RgsU=_lH|$FsV0$Fe4Z?}V>C=r_i$9>P6r|H0LU zOf7}#8X@4rOE?-xVShGUV*Pp z&PH2dp@$`hi{RgGf}Iv?s)|!|Sj9jC^PE6_2k469NA;#cU2Qp?m-vf}$Kd_)6A-X= z7obMqE-!hi@sewaZ|P)6#F*R3m9?ySGQvtD0de`pPPR&|8xG1%$2B6l$)-RX#kg8% zzrm5T?JnijFBMU%t0!TEpg^@hZ`ouk(SQaHKtXMmo0=b|v5J=;ZMALF6b8^Xbb7|~ zCW>%bghoxcHxfs)wPS&NWkzqRPL^|@uqld+%w}T*qGzLl@0Fdw_DKB zKnAdlzkFJNv0ylhRzG)P<$0uL4D=0d`A(bTXD=k36pqHww?UhQj0O*cb9qxW2ncqv z*v7c6c`21V0t#|meFXRHE;JreB zZ?~SG{4%vAsDXnz5>t>y(NNz;doapy*w5V*5N&A zMGQP3MY-2XS3-(`T>l-uU-`XG@~8=Z?L4-4%kgmE1SL=)8@MKH^R21>osVK)MO1}? zCmfXK@9J7*)dOC=05~qyyk|H#pfE~TuZ65v^dvns=CMWB7M%&Tr-$NOatJE4!rrNy zFLu!7h`0Fi*qR0mJaK_xMR@9P;6>oAoqm_U()k0b}5pYQo{jeMQjSS?8 zxm$eH_VsAf5Bcba+@!NfC^RY z_7=9uwy}!=07ckq&8#J)^aV{uA@5Fhs%9r+2yLaqjXaZYE}sJm?v!<$lgn?DvJ0Z3 zB>?D)@QAv=`dS|ZmrCZ%+%rQ^<8X0UwrER zcl@Bc`?qquv~dIgUtCfN*1iV;MBf;^jqOtLrsCd+5yQnN_4|V0;jv06hZ7nmhzN)E zh>}*7w$vZiZ-e{vF-iO`fAWa;=ip$@rehiM2l;uiryDdaE0sSz-OB`xjxJ(PH?L|t z$|n*l6QCspp%axv0kHiQ`rMBCC%{pry+qU;Y$ffCR8&UX#Jjr|t1&Yl6f1^_0R;h< zch@eAuLc@5i(@*Evvab*>)m%sye+zG(oP1Ktouqkt1SVz=cjyhg<5jp#1>`Xtik~q zC?ni5dUz*bU{>4d3?F3$93N{Ggf0xu~RgnJIFx96in=EimfN3nlS)Sg%Mwg+o}{!#5{&_C?L{ETA7&>)czpZREtys z;|eHqN*%g>iNR#Wu#pVVk^=g~uU(NMNFnme8voX|YbPB7nipQJLX>XFF!XP^ql^0A z({2PcZ(n0d;2U)1Mt+_IMDg@YeTFImQdL?JzDn>E=?8TG*7tgHI9gC4SRJcsxc4pu zYzJb`ese4}J*}Aa-QXS~11{aesC_x8A_nln^FdzToxY6WKUeLM$mvo+*heU_;tzFp zb_~!`B!|uZc*=&rBz2c#oTvg$SSXh} zC(Tn3q}kBeCzfHR30z!VN@1bHrVku@R{y=6cc1;<2(IDP;viCMtkE_86>!zOC89ab zziW*y6$FRgih@~7ocZXEn>5cfEO-Hqlbtxv?^eJzh%NrM?F|@V1%&DCPE!*#3Ib&L z>EkX`;+c@2CkpHR+EjD7#Lq5LZfB8MOvfV3FwW?dS`1GUM6RjGS&T;BnchA`T`3qO z@9uH1kj@uOm?f~L>%67K2ZZi#Q{!*F!+wK^K=j+{W{*nD1^~#x7mH=Se;*OEwubfE z{sf?}@(FPqso^1+7(rrAA z8b_iZD{t>eFQ=g$K7#~r$vETW}U6iJq(w^5YsE=2_GebL`P+5@6ptD9Cqh1(}- zEE7>2R7c#1qviRs^SIxpYw2|CE^-ANiSwq87yJZ!@GZ3o{&~h#ps6Rtyk-ffcN{M)@z3(8Iq7 zA)d~H*L!hJhT-S(lG-3vbku@^#-+qS0p=z65)_fp@GMR5sEp7ZTBGCZY?H2!OsZpr5#!Frm9HWU(qpG{Od zkM@J@_8z=*v+CX0k;S)L$y%fjuac}aVDU}!##Z&FR&L)P=M37(Q8{bUD zE3P3|H>4$_WLpo4P-c+WO7FasLWs(Bh`0>8vQMnT3*Y@}nT}jHIcDbzmeX_n`UU!vp@l*sK0X0R5m~FcvIvYLf z&1P5XYH;@-laUYZJcp%IzkcB;O~U|%S$9g%${4%1jq*LJw=5x7Pa<#~8M_toQ{v=BE!R}aLxM>6tkT)Z!%=fbZz(^@* zuc9rEjU2M>i<-wgnUFvlBt8%RR(UzMja}^CkEPvn7zuJ+wz++bEO+Po;3+vF7Ph{UG?k?LxNTA01dshYKfm<+ew!3cCuL5rk+FAiU|( z*tm85WjF>NpuqCuL-zpnQ{vTrXnq@k0+XzrA_xJ~XZTQ9O_epT=S3p%mubEEMD2&~ zAyOA2m(KC6x(`Re-Hup!F$-bu{0i;86a}%Bhk@mlF%hN6RwBeYGI((4 z2A7AV_Jmn=c9{_Vx|;FbYtA!Q$S5B~5E$r`4ho=ptjj&y8`MWPM)cUhE_q}~3>oK} z)rt|`MCuY*e32UPo>hdZ>sdRA{KrWmZa|eW+U`Pv+8UsG2-_m*_LQnw1SepTUmJ>2 z8x|2lC820OFPy+_@2fGy11inrFbCF^upFVQ_rKa5 zv7@Rhxr9}#7GNu}%i^!?0jd(tk`dS2(H;vpK-Bkn1c|lx7fKu7837?0*NLmkb^N5~ z8r}&+V-Ktv24*!mrLm7Frke;vf2>!Q{v8{T#%TMC z!Ir=XV3aiQXkIs&YE1tmkjPBW5YkWz55*ZOqUA}{c%Oy+=q$d*jBOtG+f44;FvDAS z9664@%Me;yY(7XT`s}|_+#7JV)U_Am{8J22;P`{kQd%o9-+tWAm~ZG6JWkA;_-UAx zbvadcPQcTLRGtpCkqnKf#5#>LjsE#VTb6vB3Nx0UMoe@zIQkFRPRUa(>|(LGr32E; z%2NgsT0~^jz^PyjlBdVK<@cD_Sf8-&+?IF_q(uwv6g!Xa^|V_f$8yt7v`e1ClVPNY zA5a&-R*q?G7Ltb26vBL!f7|XSsE^YJbK&|=m zPCBJ0;sj7Nn%jHnnmkb;gU%aw4bSan&ARcMT1) ze(Xa{Z+9Y+PT<=Dg%ji5T8bqvEDt`WzQ*DcSTDn`o0~T{ghAn;O8J=DI3<8rrk@GTE+;35C1vg8S;Ji3ZcFaiaMFY`=6X7ZhExF283RU zr)LS;%6Zl~%A_IP)qcI?LvChm)&u`D#CEQ;k6c`Rcq8#$$5!Vgzydr z4nY z7uxjvmg+qUAql9okjsQ1KHRl$Q?Bm@4LU3rho+bUfYg+VqpDU{Cz4$%FvEhzUV4DXJ5#&=_y^Nv4bAMkha z4<9ppY6R#1lJD=w?#hPc7Jl@3S5pF=|3W;uG{t1~1!?8F4q?(40Z?_Yaw-WilUDl- zgv-1Ra_XU91a}=(1az*}9=`pgW^mGJ-g>hwav$6JxWfi{yP;t@!}O6S0%3_u=nQYc z01`MQNv8z|q{)E?(9z{fM?jRlmZ{Ak+JD}#2j-5k#jtAw>8(}gF z>;=mG;j^Fsa4PoGm>dgp4(RXd{gYM~n4m zDy`yAWwvxh$H9KEH79vAC>%iTZ0U%xMuP*?TfEW|B@5{BS_?#AXCg#XGvYT7FQl>+ zbH@UR_F#vDL?xz2v#sz0wzWM6sqRiIEeocVnu8zT*JQ(_s+|1l-D)vEf7gjY=VwK6Wl>mk2-Vns%p8LUDn$+YhsG< z;IqWX&M;+1r1$qje6!k92H>%;z5${EtC4WNZ46NZf;seoXo$-{d~hcwXEVmkx>!I# zqR<}hiN4YcRtBtp;Ac!hq(aTWg@^GZ(*ndq;c)_b$^;)=d3MS6C znDLHwS-@cwW9;M?$v&W~-_Q(FxDBkD2NDo06=NAE5{O|aM59u|rKyAqPW?qmE3;45K z|Ht{U{hw0uDBAl*r>W~oHx))1)*2$rU*&d(*I271(`}(vP_&?_><5u)6wag*S!plH z0J}#(=X9CdiAi1QWDI?@RN!C#j_0m!NS(-cw>0@IA_)oc0d!CLRN8!qf94~;p@iK&mf`mLko!y?*yH*|E+!39RU8)AIJKO1xEXj;2G0f&>6r-;A1gyvUU&Qox z?Y(?y?g8jmJjLhPMmN5`UKvyXJ;m$D@{-}#R`IelA`Y(qt^$whZL=tNrLCpZfP|So zzypMTdl+GKKl6foKbYF~@tBiHnQk0YH5hiv?*E%F{%za;e4Gef5D^|ba9S1+tzI+W zDi{PryN7`Q5WoD>pwP|1Lt)75J)c&Wau9!sg8vi|`x8l!ShKzwv;dR9tcTMLDI;Oz zNp_|`vRlJ=o!Q2oO@s*qoXv*6`Mbe zcRut)-2@pX8BeTC{nr!K&w*Op;j9CoCLh~mr<8IbGJ`0#lG=JhcK*9$^!KYf%EHP? z!$w@L>d03R87$A#cmWAPxr)a-@%`68+Acen!G|gPodU0nK%3XxE;JD|eAOU$YsWY% z?E5Dr<*}=6!bgA-OCc1E;7Q$@c}074o2ru3`W=BF(o4YUeEE}gt!DDZ(TD8M)&f7! zD4kg3P?HF@)otC-z!7>yM>TbUR=EPjyo6yO4JtF-I|gOe5R{S+D|k*IWk5s&LM_eY z)J8=cY>eRu7=iF2*vatC+e&++TV2=Zfgkid?;c_aj;twn-FDj!krR35qT|Y=$sal1 zT)*x1@Ty7G-;&}k1B(jB>kIJQ@?kwl;;JWsf~2u1POl%yiuw0hwZ}1%EWki$9l77V@L&~#4v&H#iM8Z@dV$>kgf*f632TfK(e4*mzM}fEmjqdV3mx(@Ts0bzB>&Is+F8&@g0U{X1wa#N||ppsFN=o zEpn&uD@=4wZ|`ZUdca@fAIcT$I$!|O?8+9+|Dg5Cx) z;SjEqPMMc8X|!W3X4L{J&?!u$0LHAFR;!qqJ(z!2WFOxW>;ZCL3RdMc>o>W^s&9f3 z#-<_>mUx8j@E%HFDhuprc<=z!QU9&MYJp}*59UJ9uG(p zEisgX|Fl@JCH-g&#BdV*)GC>$Ps9Ym9PS=z7-`-KU9Gnyzw!~_9C)RX-=64{@v?Kv zq=#YFHbFjWj~o?9yLL%Yv8fWPc3e!W~>`|@y zkqQU_lIs~!!`X3xV-vCV(yW+NIOP82#Iu3vRK_3PCI3Hl5CA6HD1t^_D>-JU4f?7h zDIWuA!NV{O|GUWH*88nB{!kHnlaUn5j;QVR91JN8>uJBhaEw2pWAwPoYqTEpjDV<< zc5grs0s!vlb@-nEn8R*thX9DqM%TBEv0(0)fx13+DU6}<3^M8UZDz^yl!5VpMV9Tw zo3i?1+Pxou>FBxp`>``z!8_WwuHMNcc-LaIuE-!qnRG_5UW^9&Bd9=NL1Cp*Wc|_V zd=X5So%u;MomlIiZ{i2(tI$y)^pqU-+dSv+{< zH(12+QmV;{n1GrfgTmP4?wK$$iWC5FvX2qpXr<@LP}w< zvxWn*O{r_so)Ck50lqjA{5z6H<+orLgqFox*rto>7VIVss6duK*~6*9ZEyiD{95mj z$bp9EN-vIZC|Z+Z39Bvi1is7zd%o8R@Vpsoo?&&k_7pEd+%<)HSZDVocw zg+#%@LtmO$S!B^S*KMd$xsRdVzsHVJwa0nU^vBVc(O_;9i7zaKrkUb?R!Rsgo>=2jW?C6UoRD-?Kug`1izMkM& zl|BJ`yE0+39*WtiE@)CwnV5)gZ}0IC_#BSx+ZE1j4LQv00GK^8{whI@J}yIX}x z&)+;Cv?K4BS`Z8pCy&=W5zf$8xgkxGAe+U)mkfY`g$*GZZEJKR2G4> z{%77c6O=AZwr?n1-yi~4bY~aQg`Mu~_h)<%AK_^p0T%}tYvM%2fz;r*T<9aLH=UAQ zX$y=Grx%x+fI|88c9j%9K#Uos)xD_9DphK2W`SROt#odkd>o4jgpWFlP(%9nSxhy_+^#C1(%?cNB}d)O9D7WKcVE#{09Rxq@v{1j+3F=~^^6(M9o$ zleHsvX#Xe36&K1C_rzR*fX^T?V7K+vj4dI-0AKj2 z$zE@nq2k6tUp~DINQk65iU(&tajy>#xc&jZpCuS%`!L=;5-omClS}D19I(%7<7I0Li2fE{7W&OzRNAQZqzV?U*?1!XX;t%Be$uRf zIs+U#IiM-DZB6z+uKVj9MZU(3KsK87Y(OARY9>XlNsp;8hu+f?ntaxEup9ac1c98r z)A3TALj2ILfbGCtiK!m^X(0ee4IGH7M_}r&>Dgz(fde_f;XOSEWzGfD5!?@#{&b&b z{*GSWN8ZgPCRR}3WsFal-L@3(JP__ROUb9`H%}qL4c8;2WtDS*dR8?log^n)Vp~Cc z*VmG2%`TQh_0`CHl7BtRnR#?^^>=O20~#x%$BAooA?`yV|0u@QYYG`*IXa|0d&u9L zSeSbf)v+=5ft!Ux_+L}C7P){G%sFtH)>+jWH>j%gd>LW_Xal!2kR+It2gp2wK~D%2 zlaz!UUkwzsqiqFIm}tOv;J;ZHU{lpoCG=3JTKEkM&_@t`mSK9C944RncV_amedQs! zcxVO-OU=xjyLoB++|wud!46~=Ml#EM=VyojJ)-bRPu_-5HNN=JEtQF0YfFk%-(2BX ziiQND9Rrw($BRWjkpmV&WNm)JkZc6eFW?1Smy>SyAUCw32J#fsmCkoR-<1y7rQ?>8 zeH8zMY`R4PVvn#P!s<)_s&evM(iY5wP(HEJF&;ji0If}>^G2uIrz8uN5QA&uo74KM zf)CI~_lJ=0Rip!=xRAl?doP?M9t}J77zk|LIIoI#l2Y(HxDyL=fR|b{foGvd;fn_! zQ7A@23(av^8ai;3t|!^_%^EZkwS5f6!j{z3O|;43<&DPz03;p?X++a*=6>WL8b!mwAsugwx657hyrtzh9J^bN5EdIL_h zU)Mg~IYiS)I@~Sv7Pp#NnwgK8aq&uo3?W$!qCBNnplGyM)Wq7uvZ3L3ZX3w_vlX9_ z0sYVaOIr<^52kAYcK8ZMiSeyp-r;W3@>! z#_xr)vqjREYDGrHEgA5zWLo6}1E zx4h1I${Mc`$G>NsnJU+{UAO;$-X*o=1kP%yO)EAep9;A92~Oh?C$2HobNcj>)E zy8JTF0EHL0%z`ALR=liB_qOD*)Sw9XcK$_e>7BHf;_}4^?0iBm9sBZ#+{5lju5c#m zKq@+0I;j5`+jMFR6e@^*4Bi0vFTw?2W z_CJnbV2?s*NE0tQ%*F#(u@NDcw}RU~Z|D55!J)AK;oI}+6c2a@f3V~8*$68FkX}DA z?gAvD6XwlA9o=@h&$l>9O-;x~k$tLKOjakIk*$fRy`W72yg|@uM`|WvHpmjiosLHu zUbzqMAkZ@gs5D?)D?fD73HE}@_wwRG#>+GP{oHEvDh~wIOve*nNcdt4?({`g?G?3k zJ{2WK&{oF8Ghlv(fsX{&rWuTZ8CXak`2ByrCWQu6j5!@^vg(963<1lUYT_XO#6A)r zSda?Hv-xdvPGsHeFFY+gel_m3fXQoK!}L^EPbJouufXMtjtBqP_(Z`$#agD269FI* zRAT*J;~*JY1U6d}F(m$lhoR4x>Co8(3SQ|(ufJ;=C=Xu?_RD|oPU3-qFSUaKAOb3D zr?Iu4ITRX*uqkH+{>T~%YsCJqz$VtDeoZoVAx)_REKrGv)g~^p!PbRPgj}JerRnq9 zA10eqPA>XzfK>0~Y(lal4+U~F$6OqvAU;rJO$I%;j|ftQk#pbXe!xy~;k-$GoLg$w zK$|eFYQ5X>aZA{>466$@j@t>M$JO`X3;N3jFydGpj8fi<{GIUSavyO=yING)Uuc$& zjla6eb$(qk{5zBffDYa==PX=Q>wN70kQYsMr#NbI{;Lq$E4~_YG5AD5L~I&Xo+($C zEH?ND%1N}iTrbyn=_mMu9usYbA<%mMM)_3It z%*`!-1wWSF_<$V*pf|hY(?N0R0SAfMC)pAoPZ2y3BAX;^epX~{nAX&qgjsDC3>9-= zsvMIF8#JfD;7(jltC!q=^y^=38dEM!{Z)@WkP0@VLTQn>e4t*dADou2Xd%OS3t#30 zkEc{{u~YC{^c_(4@;qs_!nLxFw$B#yO8x_l0z5m8D5Mt6Mg}bTzwO8`{HND%pl4tI z1av>(aX=b8nEAoO6(&c^lSGWOF#sF@S~_3H=Y=MTGeWu8YVRT{ZKm3S^-o@ZzA8Lx z8(5RN8u=SUtI!IX&L$~);|8-d`mlB;1Ble*_A$b?cV+fn*$f3pNp1mFH{?3xG zxXHU4W_zBUh8}V^ng+8PtX-AW6&he0oNb@(}Z-DGjDGcF0B741kb^fN$-%A<`bzj7i$ zmZW}=^268vn#zGU7cZ~<|92%MHjI0Gp>`s{9z^0^ekoc ztKI?e#|}Gg{t+HF!(lk6Va9J^o}n}LuLw+yd^`6+kng_#YW-Zbqe5!^3+H&;4zil` zmo)cat+y2EquYE6(|!cOyX=OwPYq_=#D@DarefIbUM<45=ya$(M{{4QsR6212kD#o zqL|mss*&;L_rKJ=S_xBxDQ%L_`AhwCNre4Z1aIf}zKpQiYk?}@a1aF;!`k(pND7uF z2_9Q(V|flW)lD`o5M%R7&Vk@KMe~VsPwBBc*-1AFUpUZ76FyLi2oQvUvO!4(Sw##D zUYI~baRJ8Vh2&(uCpXX9e-+IL9R8YGD{2unrffQudd%B5zBXJ!_FrseVbS4&zd7~} zyVY%^aDgnv{G^ulkDw#~TD1~~9H8?oCG+hELHT99#WXOk4S=5+2!C_ImNH|y$G zle%oc?u^|ApJGg)EtSsZ^BgqeK>^7R@02wcbqv@QaDc3MneK*$V5IOFnuK>^+ad53Yp z77IK}VF_3HP0Y6?!4n2X%b^f(s@-%`KEeM02KNG6MI|4o);Qfpw3X5=@c_|3!bZOM zZ8}6}^E&tZ6rOK`EdRZH%5nydllLy@Qd|Xq8n`UWnWlIuPQ_pDo5_uoflu{9bcQ64 zxhV7;d|kS~5}M@7EJj&0|7YH?|sZY-H))BkkxB8oaCJ z2YMX|6o#dtohqn+=nGjl4+yN(I%5=;t}};quxQ0$R+7aGC5ijn4?C8&uF#16XZpGG(8C=;1{4SdV3%x;T8r|bfwAj0Ss%D*KeM6VIWLSH)J zffnRGsA8@AWQbS6F6ewtY-nugWMN2wShoK|h}oHbj3Z^ayEB7Fxwn_Euep1C zN7#(n;7p0KhCp>5#v?T4*1INE^hES|<1B;6|@kq=EGrwbzCzZ~SDOsB)+fy&QQscEw z*%@x{^8W>z9_rSfYzB<(y>fH%;KN2lR`l{l`z@!ZjN#6v10HqH7|l2(bW8xejYht^ z>tt!7YO-7Uj5c5o2l)CKOdo#V<29E}e3v_Pf%xJ8xUVJXIzA*DD>w z?Q_5+De!3+7LV;~5UxSdOGg~~xLSPm2PG^7gZyn-;jm`pe{`37uo-~R?GXw#10Jq| zK%9u?9rT?5zEpv=H7cFngC0MoT)JZW=pb4s(evoC!Qf`39_%tcr{7=ttX0`zWXh*- zOCEXpa;>~R`yDJhjj1#0L0|>tKnfhV1Dr}b?ns3jXHy#@ZoIkhMKfkK!G*o@%Eqa ztc~kBABF=8Hvn3_R~hFtHRVLYfR{eZBN9tTaP$4oQJkczoxpLqKInvBb1}1&pek}h z25Qv36df)=x#Rzs7jQcJr@oju4(KXh=lMG&eI+-(R(5QDi?k7n9$aV^8{0}&XhCVC zW}N#m^LcGj;>8kryEO-}QT%^lFbWYO?N9w~9LsjE3pNbb_f~>UN2<3Fbio}3%8cM_ zI{H`TF021RVI!L$%QrVI8gQchXJ&YH_ZWVntqYFkI5Yfnu?XDj!#mLFB)Ycco6fG zazesvB@fq&xksr+|A*zVFqgJrXV0<0q=@lwFtY=+)bUZ)eU+I>ng%lc^Uddx%TmCzd;#cbMAlBNFu#}7 z(k7?|%}>pahaVXE?CfzrERCLfBqrBal)Z0r)1faPgaBF3WVG`qa|@9XR50O0?G0Xu z?{u-$!mk9in7N;!h`Ms%b+&w*QwYg_SE#aeT_Dm`8FW*hwB;R|xI%*UV(6uF#^OsP zP=Z2<%AdoF%)|L{JM-Sn*^*-1yqZZbxAVCB0GuTza=;H3w9|l_Clo4y_f1sCF?WZWPC+D3175O6h0{%U zUAH9w9!gYzCChc>?oJv#ToXeSM!s!gs%R7sQ8p`1ShP0%-uio3TPqGHt{&_!r8(Gm zJ(D2^h#4W2$H#xtXlOUwhD{-MvJ$!g5a`oZ-t1WOxv)O^Z)5=&(TqP5SK`5HZbQvr z9bter-Y4Y^57_cp!?j5XLRV0!6rR1{&wa8{ zPbeW}e4wH-mz53adTC|bwMsXCEQSW$tPs}}4hv|Sn@WWJ1@riSz?7nA30Owx(WyPG ztR%8P26+i5A^>)Rtpd96ycnhV5E#h@Dvpw`@uOvjLY~?=u1ZWXmSWHx7RY_0xmtZ7@0)Wet~e}3-7ni*swS6VBF+aKTrBHVZXCVVaCfB zB6%`cwOiJYcYE7Yo4t=ZpnO5f#YY-Qf6_p${6qPtMjQ@*pSW@0>}P=RM5u6C5pCjB z_W$GSt;4GN)~?~T0VPBll@bI51nH7S6iI1B8kCUk2HAi}NJ=+KcXx+$hjdGKH~X9T zJLfs)eXjTYu8V&+Ypogg9QPRGzUQ1yGCe1>w!0~c8&tV`%a}JO!{8rC{x_IPyV6+o z>Z?*J#hJ;GaY!lQ>&;uIC$3%v9*p}?a`8StVlakT&d4ZN$o|iKP{id#4A0L6dYuyEsHeYUN<3Nu#u z2lqn(giz&mHrE~4t^7BZ8m&2$gZ4UPoos<9xQ-+SdC+@&9zpCZa}TT``~>*01j|QF zIrM52l&3QCwyAum0uHxCxQaS#SufGEi5?ak0+?To?P&Yjc@M!1JhP~L2Qa#1F0apk z!77;e^pvw|`;Ri6r}X002kU$rSOyhV`x@bN!l$-*er#A{3m+U@0jWfoxGHqMg+zbM zwJ9ZreQa*7q92Dtf%$r@B-8T7YX4aYm1j#w>L6t&tirA=al_B*6kl-Y zpDZ3$w7x$HMBU@NBRo425{rDl{PVZ0mtu=NSQq&GHasuN zY~lUez7M&UyYGl<@fF1*s{i?gr~ZXYM4F_GG7YH2RkJvo)3{x_BaR{HqzuqtOc>MkT=~L~)D}fO485(VDME(Y;PI-yhhDFhydodYE|Jp)s`!8}I3*>CVJk6}84s~{MW$C@i!}*LaO^FL|KpHL8?b7DC z^J2@G!K`WpxeqXFz*hSX2hH34X2s(F4wc*tHb}Q$bYAQ}1apm4WW&ARX0~n&p85DO zX^5%FUnj7VK_oJCfOfsOm6NkJArt6}cT5fQ{*4 zxORy9w~)XA(3HX*qc2L`=XZ^Z#@3W)7E`Yo7UO&YHlxz*UD?l27I=ew$p zj`$3|hk71i6Uaxum7#5U`{w}H`_rTTVpRtd{ttJBHI6S9j)kDAD(gcmdgp`_K%tal zpBSTkSl(nQtF|MjcO7{(CCk>w?uudFu9Jtt`*S3ENZIR0EixQP2NFnKf2D);4(R=W zZtBVJv6E;_&QzMQmkmilfHjHKI+1%74BUT@8DjxAVomWBz;lgq+qb$$%L+?=;Bp#cV9;*i&tFeJBgf*z1z?Lz8&uTjGd|p%`qQi*X5{n%w z!GWL4Eu3?)*K+Jl-%UK!h;29Ot^v$939Ib3P8! z5Ip{--nN3oz4GMZ&S>*%oJ$fjuSoAJjSW)m;|#|W>rci;1MUoq%P1ZF%68^rY<(N8 z@80f5azgJVsh*pAfGUxL&6Cz!V@fiM&gvdqMk?PmLBX4Q`?aWV|DY5;68d-ua%9oH ziRA%HM9uVll);HXCUXhPX%g&sHak=}<>10u{nts{TVyo)CptWe z@XTbvh+`4(l3LiZ?EF0u%4O?%kFLe%wUf|)XoPR;%l(Fg*)Fg}e{$$)qb5WD7TjvH zmcjbsRf`Q*HE@ZjR(m^F!C5!=p-#oE8`^bjr1S`S=C>Rn^afek9@e-vko<=h zF&3t%OPt7;jD!oAs6H-Z&I*MKjj#i2j0FQn1Kj~~fXw6m%sZgCdgD7fu$4-fNK_;@ zxt1&b7$b&+flPp5*35dWBIH8YzMp};Gf`vaJGA-k-vZos*rXM)D3Ysdex<;jr#`{G znZC<)+WHmu!7=r3zjTt*0xF3T7HJDedCRAaCkJ~4+OIS`MXb1aYr?}%Z;7XLj1dXB zop?VuC^>k_C=I^9*iPp`A9N>~?tP@&|BuTw3iu%G4W!}cbH=|=X_P=!9h@GBmo0_= zDWzVn41__fMZho=!Qnc6FF2W2l zNq_;60ujXg%Ma6DNQG*@JH`4T^+=UTY0<7F6Ds|hykEe= zg)=A`Hh?G~Fx=1k-5oD%l+=hffrp_o4XAk59A6YZ4%s&NVsJZ{Jc*0G{MpztIbhs$ z3k|JE$BfJTUK7=_al}cNPX;JWBsv~ADBhBNjTg2tm@b?DO)w>@gK)-#qA}db*0+eN zw>s_!?F|qm7owm+Ja3(9-A@ll)*c@I+n4$k*ffkU%gV0*`cgH4z!$@0Ams5J_^=8k z?$o=6gbxZwh(auE9CY~@0Fo{N_3M{VGG?j5h1>nII)jvmkw4{ODnFtP7r|jQfRLJ< zL@G4z>p|(yNkw|k14>JH=rr|t%mvY&JcACFoNoU5YDz~RyKKnGuF@z+o@q%u^36At zU$}Dkbos2!O>2IQ^7H`#KKZF`+iyTE((UHTw%z24_D3?6@qP?!ogkXidlV@EA5#9` zl#*Ic$0q+nOX=3@!tOhN68MM~36V~Yvj%eFQL3#W3cp8J9u(NSA0fJ@kLRX^Y7ZV< z%n$5r%WeUV?SBNqKYPKC##EgTsQNR>|6=}R0Z{wPzGuZA*sK=^k)Kml%?#+TL+-U;AdZGn*d$j=Z9FWYk? zr)v-P&Sr^s$<%URoZaip#;ZfZqdHiH9x__0i0q^-3vg3Dj`aD$nC$p>Z*|z?t;K?s zh7Lhb`?$%^GR8g!3XWH}{pob$1ANi{&6txMG9J^tnEfW@37TiRlZYNf#cMwuQ5VaLd{D z;bR=gkRcpNj!Ce_^?n2~C_D>&+rguU_-OY#)>P#~y1n(E;LfI9_J15apKE<*P@HI}S0XMhY)6G7K7o}}=jkITd9_vhVD?F=7B{zwUf{i;G{A0p)gzEE#k zI~of~j=%3U&=KK65H1T8vEDXgb?X(H4!d-~JT>BsjVNfbv^z9cI*WGD6=}5cm-PnH zRpC1k`c!I0V7#FRbf=!B+>o{FG)yej%dX`6pQbk;aJ(;gYV)>>WNA(6hoElx9NP5OJ zfiU~cUTuobm8@|+Vegb_qg$2d_DV;na=$`f;7>vs@B+01@vE{#&uFv#^uwWB|+ zZ(rHn-;miSJ@W2#1*Mh@d>&tk#!$lToGryCd?EH*bp24Jx3_H zcFodN`_;DZ=uqmIv9)wpH^x=j9tZJ$(myI){g?7qNR9$K38gw6n*TurIle*`CX^UD znj%kptuG}ubtt=IVPUSZ55Zn%<%}M=sSjT={&ulj=%Co;gw1Al1||>6|CbPA81Jo=MZtj7e)4YL7ch!nY^kYdap@oG}jubS1C zrKuK-`KkXkrn8u!q*`K5>E=wB=NM%iRhz2zln4tQ0ZJ~-=7JeDq(`~hnq6Z9B&PC5Jr(^|1(@OFH7`)_Dk1LTu`}DuvO7IDL9yD8NiRaAM z+>hvpK%);~`@iiV{^eSSD|mgSQO`5rSli{0`)V44QF2Qg{XWvhtQNtHr@hzcN!|W> zJRH_v9ON)ag#?$Q#)!KD9t-5v5p6^_vlS^Yd zrsbJrdCq?uECg3A&!F-HeBhK@YvRzKF~4nE_8+2XqBvAXjSdPRhP4eB-c+lM^0h`-zkJm?F(T1;>Xuk z0~a=E7iZx@g}lIS@wti00BQ~ua}|V=V*w+!KXI7J@p~$WjAwkfN+M!8MDVY!cxx>F zL3^t!qP&PrVRUQ4OcFaJ(RQ@;ksebg@>+?Sx zk&mSnnSJcki8^1TAQp>X5+8FaA~TfZCT=BfeM5-1-nYoM=n~am1P^M&qkx28{=YrQ z5-Yc8hydME_XVb4{{yURy)cY;>54~~N}weN7MFDg8d$yBzPgWp)D8kvKw^I!OA-y0ov$^#49?j{%9X$k1|tDy5g=)3Tr z@EH8@Plp&P2VSHQZD-EIek{xxWwX1K&TSPEIuW)JkKB6#9ku%;r zyDC$8PJSN{ILT|ZH|I(97Ys*DHwh`SJ2OU{amBaPk&!&n&6Dj5S6v_B0r^(*ch8yh z9-=~Ri#y0W8(Slwbw&~&_h!RG%!eh|$-ojgr)WV|rlxkp%O>TRbf0+nzc3Pp%pE$+ z{bX%CJ0T_Suvmsjixw$3$OWL>xqrEtwRtbj0DHu*{zmp+LZi%27PyrgQ(=gQ-u3%k zJG)H}+p=g{kMG29FVnfKq{4OQ5^V|dkSTUSn9;PDF~~Dw{}Ue`rGi#Lb?>~Y50&iW zONV$b!qqz!lbDU!%5oBqokVu1WMCLApt@40WyNd3-DyelZz)mArNcySBtV8eWRrtJ zgIL!gF&7`6#J$(~={u84nrkSn@f1zu70$EjM;{9Y7eEQ2dRYD=`w=IC}5S+nE^k4w)>zY zN&l^Q55qBV1bO_tx`PE-Mr3EWe5>?cIag`dRTRtGWQ{u+QLmA{%lC@&!im5yOdv` zGN1hHZF2@xwl*ZZUC zIw2H+_8`?=2YD@k2;QUFusHgX1zB$0HEWIcuk8e)^AzX!e3gJLN3_2Cd1XE^-B}&j z*Iu#n93flJ^Yt8w5}+5x5EZ~5U353y;zjRSRMWhytRg+rZOdCtWY+VuVo2DVI&=3| zW=RvDs3dOHw~)6QXqAyN+wlP{vH9Q2+XNuX=N7->)Q{gz7{j1=7zRX_Zc?%H0~9ko z#3BdAIWc2j0Gs})m>wjWwEAupzo2N@no6)#FOG$fuLfOptVj*-mFJm&5+e;!s&6Vg zQ%_RDwsKQfbUuQ*<@XuAJVGh~Da>~?r9gpdn`@zNsYY%wDjeVOs322gJy>`-bPGB1 zZ&Vq=lcmvi-~Fg`QDMt}+IsW5?E)kOO2|BW0>ucv+|`3-EZE%rM=7cMop2<9`G{^G zE`@NxN0@|*F2|?Gi!rFb;%!|t<;U;RV^h7{vdASXjwZ2FvkNM?2;=8_5UWOfH0STg zBTk_F_1y$=;fRP%HX#B{DH;@s{^&4H0$JD{yG>C3^as&C>jtl|OK@s#s9dYVr5?`_ zm!}@(%hECxDSYksHSzEqJ?_b&pwA=CBTlCcAD&K2%`1PU>2vAl*~RsHtfgJ0 zr&T3QiS?O0n7QMn z87t+!?l{uIG&oAciYx17MAlg^dfqy%-+ahRo2d~Ri}1iYx`#j0dg!`_u=$22=z$9- z(%C?7oW}ump!4ItE&DzVkADO&Ud3;dyj#{w_i;|`3?phyll3>;wYD1#!a}pvJm}e4 zjT+J6ONxPB8UogUeLR>DDPAY!IEYYDPabO(bUceDwY%mWow&FWQY2@Xg?jg35@l!3&*M^eTF{YMuAC_kx^u&>bxLq%p zZ-(H9Nl>OpXJ|#nDr72rYMu+PnE36U?p~_5%42ibIt)>qpD`n072k!sfjQ91u)Dek zf2xNxZ^=t_mW&2b5Nj}vC1JaZl25D~@p0!db#*^^bstTfV{tKIagi*4CnqR9CI95` zF})fm>yl5q?y_H-;SF%Ui$8_d*W2KAcQ&4TWz;xR9;~px^H5WbiDZ+?8}Soe*y$hL zendPKUa_8AmF9+0QfR;wH>LWqssf`S@Ho!WD7kKoRqaQP`KNS#jF=(~g3;YlBk!h+ z?yu6&-WfTBCoUy6Hq~(LOWhf$-2IL*ra+bn7-cGfjh!Iesvae)z{Vd0P8 zitP_XMe7wK6b5H;Ju1==4or+p2IZA>{qEDo{*CJW{1X^XX{`F?Z_=WCTNvhE;kfnb zT6D@>Ak4~RQt-&BW1mF1IrO^x$iER9Ly&gsz}1oSaj60MeMRS=EokpA$ToxthUQsk z|C3kYumAXk|NLoow6IgP@QHuRRA^PAO@wZzUfp%u5eX;j8osI=j(UZC85L)x$Kr&O$S_G@X@*C|22Zu(>OUXV$*orFw{w z9gt;Xw}DxuM6hzBffaoDGp(1eZgoT0=bk8uu63&Gf>6YE?hv8v1S(=IuLbq(aZipA zo!X^zo2&m`?UdSK7@xKkA$4op9swQ%`d2~{DLECptB8Hp>kr36-Xv>IGc~GMB4YevA!Xc2?DpiE^3~4^nI?Jzq>!hhwb6AW zmltW%%jBh+@s$eH)Vw(iS16DPsE|4*wVpUEgoimEuAj?79jh3OmUv8Z2c1qpG{jHYH~;fDM<;J6m4 zV^V(-=3OQ9P}DL1zJ1{6x<&rFMclj}S)(4B(^7*6r9xx;G3|VyVJdY~NKpV~o~^{aj6ah) z4HOGlzBdwMkg=Ut(;m|e5&K*UekyR|cVn6nZ}?PpZ1ae=wb62I@xc=nFSnClDn3C; zernc`* zPW^pIziHMwE8@UlQUCQY2eW96o~fdi-Pxth4A~AJw_0Pyxv9I&+PdvP#`T?ElMVBs#LM&cqby1t^^9eCF* zlTX4fUvGAd8Vt~s%y0}mr{c+1U766Fl1L|dJik`T&>7rWe*B9JjDy2yMy`s>6u#bJ zZ00B7pGFvljzo>q-cVyYxaVVCHjdwD^cwc|YMwM@wxTu;39{jAD7Y{3H^7aWiGep5 zwZ?M!t{WX;F&WA4=X4AY&|mQ{E`R&Nle6wcY)V3g^XaB*$#+ht$L8byQM`N*hnvLVd&k2cLX~V0 z^ulz=7!dpL{J`FgtWZYqxs88Cu2yKM0h%3cR!_LnR%E!qlBp0kFNIDuZubk?{*Rl> zoq_d=X$ph*GDteoZN~q?V6$c6e85O5l4{aVwKw3PT{$PMoUs>OUh;q7!T8K0DWjYgy*dV9K)Uks+oyP=$6}J1(SZ-_I3QiN`a3?ANY)r z_idyI4VI0fRz|y~iP7m!zny+^3sG({1|(b?(?tw{0|+t&CYD_xA!+ho=R8x~xm}&o z*vC*zc!vKf|5GNn=20grTW{epeQYt=Z&|qP3s(36+aBQ<-c^Vuqt04?>y<1YirJdc zwe-ag??1A=MzXGIDx?q%%L|csWp?qvGj30VWQ|m_V`@&r#E1l4AmRZ;A?PiDBrLB~ zDY^(j2H_Vqy@;!Cr>`j&UYWdOQkP?jlJuHur@FKLZLQ_wrX7dd`X_#oiHi>;>va9T z-n66VG7(*LP*y~WV~Jgk5slvH6$$G|9m?&IHu&~y;VQL=lfFr`*^^-A`^eyRRd{nD2B4OsSLypRcvX5Ro}9r z_0Oo&iYILHx#=LWxX(_*Be8cmf-4tIc3fg)>Yj6^$#GUcv#7kZ;hehYKjL<}cXrge z&jpJ46jxHGnXsSE-%&j#;D<%`EV4ilnlNJj>Tsc8jf$Jyt-VdzyJ|u&69)5pr%|h@ zQQI==W@js$yyQt6DH}7cVm7K^TWiGIe|^jDl;o@DGzb^ zWTtcWeVOU#_j7u0{#)Q_e8%l4j7`Uf$h8&Als%mZNm2;cyhmQQ-8iFh@I_ecE zaC+#U>y-yo&X7-=2fa}W3(pHtAyzanQBoWaujuF5Vw{jbH1w8NcIX1y(4b=*jS7Z# z{2mR0Xo7Fl)!eoS-1Jh3?9J5_zbC!=N+;ZjpCejQKI~Jo`V|}lCD-w^L;8mNK~ECJ z*ZGv`318yF&utixB3WH0#|`|PRDG4QQt4+{#ByXJe9v`v`Z?!|lgMV0asb3UKe4ok z-)e&(9G4&T9+ZGH@~u8$8s^)*3EH_XP%m;k9=byu!8Vd`6|P_;nbpek$5e2q^3vO- zP(XoI?iHCU8FmRrG8 z3U`MKjf^TY&^wPiWXsVx@;@%I(biJyI-O(8sV*z|_~JmH^(j)5KgYFe9hVeS*cWjR zksmJSQK-DNaR*w(rF?YZxnRBeQF;nBHGye_41*H#egqTb8LXyr>i@dqDOWR!q5sqK z?a>0)91%Fa1lG~rgXtam@2@Xm=53axq0SJ`E(hXp#GYUnLYYQa0l5ENo?K#M-j=7|-`gU$=dQgN}%dy_m zhyGd7W49o&tQM@FB7WENl=#@6!!1-T3*B9D7NC&zBHru2DCC&|83Z*x*dd^}U8JOZ zF6+bqeH@UHykq_ceaNkT^R6;k^CJF4M1u2T$LhV;?Ak^>Wsl}2pYk)Y9M8TaZtnT= z;2jQZ*$+Zzq-nMEy>hwoGP>?eVJ*DnJSxLRHtbx>cOYN90Pq0n)TSq!|ARR4Rbvuk z@it@>A{b+8#1w9FOSW^tKF?V-XuO}_N4%0HNB6v^Kdf7?r!B`Vl2}nYGgv%IDktEp zLSPp~=oi(jq&0`hzM$T9p^xVEeA6Ve*q}4ZS+f*0{T>CB?im^vj1Bu}(9G29j{ZED z_<&HoToV7mQuAFf^9L4~D8ob6jhkBHu4Rg8EKi{uuiK2Byi9QfwIU9AR+breIN^pZ zf%h#v?w6`(yCG9n=D9LLGrJarCpuuG!QVF%P(W}!9kOz_zaS%uK(I9ZKOo~KUawEF zFX&#%Q!nYUwVR6e-`$F-J{O_=v6B~DDaZCol=8~XlHTvanC`hK&f<0{e>6I_zbL@A z_jbNPfuO+$+4MY%}M9O1R|zMix7|VHB^C26Ral4jt!``1qur z;DZyipk=9E&*=2QUKQKGLhAGa==cm#EQ&- ztE1U+@%b`a7Iikct$txidc!aOzdsu1KBV_5zx_o*-ud;a&p*$S(lrNEO3pzEo1-q% z)Ex+(`j8*?!ug`P9W|&`5Wrtw3J>8z{bT=#dkh5DL}Nw3YEz4r2)SAvOp{}OCc69t zzJl1AeK^TYPpbOKpT}UMu-Kp!9Kz{^L0Ur%5T0nx0xt&mNB3^=ZAgzEUMD*rcRvGT z;{4;TSJRIl1*n2Og+o#@(v`g3gbyLPa3Vp}^`7y$0yX7E%1sTBD=EP-v&zN?d@wkz zJ?x@YIRSU=UG|;{yTXLJ_Q4Ag>^s)xnY?p#nH%1kZRmbgH{<9zLAekX^ZqF&6k>b! z1f%JWEcC?_hYILaIHw%+%N3`B*8`K1-;XmIIt{cO4l;!5Tz*dc?l+zLPUh;O1G0Jz z&q6#%P^k9pJPe|cQ3@xhJKvaXo_>Ckb~@smYF_AcR$UQJ^d$WxE1>M7v{VriKA2$k3{+oGs z6Z?yXvAIC!Ugt$GC0SPbnilObmrKl zhZgmhR+F%yF1v9hdkxU*SRC!pXSbF1CX*LANo8 z_2Dc5g%S)2@^!}#KNr%;zPuTgrM=l#lTr_EiKYC0saCF+=5AT@tXUrXL$PKCWctS2 zefZ+d_dl;pRPHh!?jFg_HJr4gp4|8i-iHgo%HS*OoS^|=I$u0RGr_=l13}PYW|Dfz1=^ZQRX`E0S3>0vQ#9iKwm0y-@zd^gXAe#J1`jYnO+i(BD@gy z5hNUsi*cuaYy4_ak#C|7p<8hX9=UM)imJjR3yF6Px~~YfOMjw3@Jf%{wT%8;fKz`C zpD*C9z@@?f8zRhMK&gSjEq_;u0Z;M{Qk;qZZEM|qWaZnbR|X(0*i5UPsb3g35n;F4 z(NGbe)EAe;#eKheOS0)Kv$-v|w4Zn?{V)+sAhz7?tnYWEez)MiUJJvv&_k$#uwIdH z{)@u}!9lw$IOOteqL^BgyZ02lkK^CHg`UpBZPLFq#nIGtVxFoH_PHo7a# zaG0?1^h1%bPWy8(4=C-}MQATtiJ{v$oX3**u0EDKUTe$~%;~&&RH@x?Gx1~R0`2qk zetivB*R!YI^^tb#S?*>=!SiBZZ*A>+VMAY{kzOa`(Sg}5H6E=v6{yoc#<1T-oLKHo zh6x3l?j{*H)C3*R>Xtu$`|I|RU)pI~h+q_LcVW}!4kL?zJf#533=tP;gSh-1O6O*7 z=P>i&mESH0h~y!u-+ul)vI4_kM$O!clI({E!RCVMC}7YBQ96RH$?DI)nya>?-c>B6 zNs((6zqXR;i>j*-ql@}NicokT7J4CSVt6Eo3&54+W9PRP9nXZ{qk@x$oyh3xnD!)D z>8X3ukec09UgpV-5dedFbD9R-{-xoWpD97WjF5h0E$H5%C3qXx$KThPE>Ok&F3e$; z`?5+-g4HnP{De!Fl!G%@HfEWRQ}QVQ^wD=vppa<2s{wX55lx(LqJ!vQ)^|d?Q~L#q z%W5k_+_KtrXWXpZ4-0t=nn>4;ES>2VuAimwd%llJ7n;@eR|RVw=jG7ArL%WD5_Od1 zPkbO82E;~0U9aT0c%9q`?bcVVv`!UmT@T;YJQ-dA%0a{+PB-`!g(t|^aSl8&f%+kA z8$x|PFc}e-4Xm_oy!<=lZ>oC`f0P}@@`eb{WZ0M4WSYSjt;d0^v`U&dK?kIU90_mA zR`<8hCy2JBDnM{GIl-=c^MO2KY#~wIUCi;UwXXCFwePagGr$d3`lCWnx-_x#ePJ-5 z7`H$qv^+WYPoTieZx6FX14KH1aFsAK!AEe*Tib=ANsHF!HC`rt%mu_G0~d7%OHYYY^t=a>hUFNkHZU0)FF2hlmQSnHY{j^T4l|eW*Af%XW@Z zz|OY&v7)xSY*JND#_mDj-Hh&Ec5sNr*pm$*Wc=dj^QNfGPmHv7-MUXk>ZyGPWx2iL zb>qYBy5RU>xufv=G)T7NmKR*qka)RsFIXT~p(7@chv?1jx7p9#8KYv%GoR)uIiN~I z@c(Gq2M#$er%bPlf;T*{VmD&Yh3ggibU~uX!fE{^9eRo@n1oncDVv+V))U@Mk|>aG zI2)nTcCn_d>7amG+j6hs<@9faHmmDTT4k3?PGtjnkv=3IIMuQx#O8p>(lNeeXWSsX z{;|YydDa@=i4Pm>6{QYU&$^O__0lrV4TyV^6l)D8no}~EEpcfYdw$V&`i!ZVy{`&S zfb)U%pmDjRtEds&XBU8}L9~@CN9@M9$v%{Stoq`1{WNn%a6)W;U#KPgfBwYBlOD{i zB{kS>NK4s_B6s~zj(MSm$-7;`S3$5cOHw~qWMv2BpA#n26>JqlTAfBDwG?S=T zxqo)ND`-3HLV8szFedgJMkB{Pg`tkn;35% zIr8^RjYxWHxmZN9xUHK@g~Wd44V1J&@ce99H{M>u*i6F4>3=Prao@zp30T1*6hjGNbxhsJ4yN$!>{{noLB=#KmbxtxLQ-(#;s;GQ z>&M(8J=Pm^tv<^%e#IWSM$rMOZO?4OD3|rk!SPgYUQb*_zN)H1^s3uiL#)+;={?QZ zP3;WdcM8!Al?}vh1H97%fwP+un@pYHK%j>q(t{+)dOo|k)BmZoy{=H3eZf6TXD7U3&>(7pXU1Xfr%=Y4s zOPK%n{qW=yXEISIN+*^=dD_{z+cOQjX0-_tPk5Bw{z^*M3FCBJKT}M}eR%u{y|*PiUah z01mHR8Q#!OyE~oYZ}$@;qX-gTxOZO0$`D4Igfg?VVL*6&uk~-9ou1~q9q9L)F)jMn zzDjpCK%xL}+FCWvF1jTIgeSICMJ;4FF1(Tw_gi#^OccZXR}ks3%XXP(a5G$1w@xBY z@`YSKN6&mdU&$lCTGOsA5FjAjE3H3qo*ACvb`p}xQILH4VXj)Y+Ayg|H{$dd+-B&C zE-dqPklXcT2F^9~T8{(K9@DztXnJV>)ZVq1a)hXLd8bOoyAs zKVmpUl%y*PRUH!2%8M!k0P~GU$}Pi&6*|!e(0n-Vfr0Ykb;HC_Vl_cLH(g}_}AEX1YOqWXhLYY!x+{amNU3^Y{mV1KBlehIO@=^CG49DgA9brwI2$vB}jRv0m~`aQOBkKCWF=ilnBn!WyGv$8%tAxdfe^N+;`0IQ1~cSz^5?8fbL zveJsmT53+__Zc_E*&Odrd>h+%JXf74WN#-U?*Z>O>_b#J-Va7R2^~P+t0w$bQJ^iF z(F%*gs{??BjE!@vPwfvM<8U?)CIDKG#f86PEGRvMeG{czxsaLHaIi>XhDAq29fAgy z@)ybHHj>ebWV09BJ{833`~jm#!RA^d#`n;9l`EZUicE7?f?q$U@9Jh^cswV2JMJEI z%f+kYZkb0nC!^8{CruBHXEz`DHQJNz?K=R58A(w6cAibU1;)OpAy&AzwonI#l!U6dV{iZ<>5w zSYNs36tL>S*gGRzJoa+p*;r1L`>D2tYoCOU#t;6)+uhvF?-sI*1Ar)PDP@DiyrMW%h)EGN zPSz)z9eg44FtYdaLXnSmQ@L}x%U(TOg5vyUJ^sf_CsyV`^3%mIKHASVKEqCFJCUcH z>?)17iQzNe-cv1C^OmlsTTn+5MZx|ys4m3O8OEV= zE`uJyd~G;YEZ#B2s)R>v3uv-A$h6ItgWpi7&l>^k=AR9 zUVEP+|9Li@oAV`&{V(;4{zbmGv~$%q@nM;ZO*}Rwzc_zB2(Z8^dB(7f?V$D-8HqT} z6D0cq3krBcsa>~=^LkqQ6$9eBjF`5Oo=XO=2|E9a77r>rhuG!|U;I^OWyVD22;cQF zBtdtOrySvKtORf%yBN;JRgmPhaIL(ley4IXg+chyl+IG=u|KcpD;b>QRyX?ZU1@aBt3MeJ7 zFQ)ypi*THq>B6_@-J5L~^Du7^sI~hL$}xWR3Yd#D#;G6>zHrt+8`%bayS#;+>g8{2 zfmvD4c!%6I%|Du4R&|dtYvq+pnkruUGT2qz%(-9oE~$^P<|Gg7+OM5WP-XN9)#eX< zs#Ni!qv8}t5)SA?s+KQvtUu*UV8>L;Q~ZWP;Y>q+xVwLKdV${_o;Yl`$JLmK5{Ge=cA7mQ)G2Gx))!NUdFyGP=1D* z*K)+FM^?^9`R=DWHNg=2SIH+9yKF_P7qZE@m3zyEtlVG>{$Lo`w}5vgfw7}mwV3pBROtZK4^-1Z48vp7w!!yg*+y8|yZ6(?B1;^E?2haaU6goq%SWeD>^}aQA}Z zYPCymJPI<7PG@Z~E%_|n38Gs(AmZG8b7$XySk@X(`9s!D{zn?fNWl+It$CZ<;c%x5 zA~o`hK9!3uzVT?<_?S@@gai-q6_qugDy{q(?$)_TJfU()e(aS#E*9YnI0P#>b6g%l z$yyq!<8QxZZjT&@hKvC=e?I0EGN+lrd{FjWc9iN?U?Sgo3(-VCh2=PagXx3u&_fF+!iM6E(AF%K*+W+;nWh;@<5Xl z=_Ur6q_$_+Bbb~n3Ph7rGZsLEz@b&bI0w0-hC)bp{fHpj-qo+uYWWp+$)F>S7XslS z4!fIQ#BdmfkJ|ZIe1Hhggc1(Be*vdqd7?8%i-*3Hvj zw;8i9cip@GhYnypv!4gC`ysVx`{)Y7s=?tA-E7ygfNVPmFQ@4*ZsOG zxej6Cm5?J->pkoKnsld16nANk?&x9X&sEdHWi#*$37raHs6CyIF}a z2uB5^1#`y(cf-$vVI?2WG~Bz{-Ub!u+f^@^I$--e0Y79d7r1xGq2&El;wjWdpqbhX z&W;y;@2`$d-rnmrEMfl9;~uW$Fvf}xC%tn^^jY@5sKSNME5ea>WZ2iJ2p>Sgddmjs z4f`93;2oJ=EL0L4@6{nbz0k`x>_b{SbiB8hi3Y(~didfj6h0~#sCDE(CE zK>eB<>JP4ejlHEwc|SPaG`k1{{TLVHOdxeo)LDD3{+*b#*zs0yYaL`+sHjB<#cH z(Tsn;AneWvbiWLOM^nI(!|wjDtc^{?r2NS<>j-*+Zd%nld2VExSfQjKCGUA1Yn#Ma%$V-DmIWtgi^*wFh;G zalTL4hKU#wv(HUQ(V<4%6RyS|JMo`D$RN8vzKjc1-p1R{8D-*1|{_xoql2 zm7a5-sc{()kv#YM`?4S3T9fE}SmcCy6uqVCqa^ZL%GXg*Ar>c)7`Y^k=Z~SNYC5N` z{&vWBV{5|4^7Q~I(BjoT3Ix6gikI{IqR@7r`JovX9-;{6e~f**WPCuL1r)i4)5Vg1 zv;e22{<-P{)T0}ohjRDCrN8rJqGCWeHeuS3uyhDi=#B>hUTc6_*4fGqZAC%B2aAJI z-VYO&2V|xPgtX@BF2M80rVbcx8$wKgz4gP-N1=o>m@1&fP%yyr#JZ&X6J7LjrV+Yd z$56M+5Gnb*Cr_GC0Bd#6M&(S#&_s#!E;7Wz?#+qBB9GJ3(yaA{*;Dvsv>$RurDY6wp-hAGPaeUSksL}5QfTZazqH{wDo&)$HwmOZ3! zbSn>p8t7w6vwqRkQ83VWyY@x}FclSpAAo3icKTIU^(^DdQC*OtDFl)C|nk3a2A5f%zU;4*< zzw|`e@a7gHqBUqQZzsmmna53<@YAAl)I-C|H=P)c`@ zCFITWE7|v?s6ygE<6bqP*cdxIz>g(c&GRxt=!7=rJ|f6smPd9it(_FCqDwykZ$(|0 z$Dw43xdGzx_+`|Q>xi2g(d)dUZ$Gzuf8C$9d#lz$VBkcR^(mmB^PJ$&1WITiU6!7GuT)79u%G$64xNtp9O^4DQYZ4q zEC7haSMEe+NSKXZ1cBJpsJA5t^>GTtUTbT&?yR36@S7HFj<^ehztPt^V`}b1fY=d; zS3mNj=}K(vBVZ6IJb;UJYHLt5tW@Htv?k@mej0yrESC&FH}ud;IuHP3f!)1-S<;P% zuH+HMrt*;o9Bo3%&3E^?eKQP0X>)5Cwe?}?wd0PC)DKg)Q?B3hd;?c+4U4J|vwyn3 zhYrpLe50zQa#K6rvC3zbPPUbp9tsen;Fh45tA%JPyT0$5qP!%0qJ%=WPSeI8rZsH| ziXSNx+4S@>jgXdE(Km_!+MM4Uh9TC2=s^|WA|ed(?UQgN4k4L}-Mr^XZvrrZ%?)a+ z2ck`fZo3BHbqBfn ztx0ULKg&rtBA@Ez$X)9%TPZ88?0K^0Nv&11fYyP9wRe-D;D4IQWHEKiKY6o^#cdTn zP(kseXIysOD+@-yzHoTGvcjdXuJo>=?)>nJioziB0d4hef{FxHZwz=;f`~%cJ4Em$ z4hu45JtL;oG8^)ePQ@6=G6SuI%uc(@3BdSI6)|g5sLzE?lbvZ9*t9Rn(I?$g<2|to zJa>u$!1u(xYeK$)cK|R{5MmWw(kunN(bmx11DrgCYuWF(spkz}*Y|{axOdZj5&~w< zxnO;HwUs&$z`wo`-=v6JGG zavfGYnKxea&E)!>Up|4OrTmO#Xuy_-wa-}vHW9_g&y&hxEQmnD8nO58J&OvXh9Zp? zX1&6m8?+1VDbc2aGj#VPdY5holUKU{81VfusDq-E;T(SxTWfCtGnYOtF8Gc5xL~OZ zUcn=%eieQ6{zh9(!UI73%MuWt_0pnO*7r3PU0^97%zCfhO=LZ4Ly5gF1dimko8f0iV#h;*z{v*Zs^O zBKDgO@Z3(`hmtYiJ#svRes9LV2BAjuGLWlug{JGh6~2n#HzbPjm)^?Q04D6%?U;Sq z?%gA0eBUFNX8_M|zBVC#8Hv389gdK?djZu2v*s@}tgsoW3|M<~kTu@Y;3K%P>G9iw zjj=W6`rQj^)HmiQFWY1fwZ_;T&t2to{7eKXvT%gO-AL;qH@DaUkfV~3h8+IjwSOd% z0_wChwcAcA-nal#xoUS!ij9Bp6!Hf^WTK0l1c3(gZ~0s%oQ#okuea6;eYk{2X~7ic zc^YA5-+AloDvuchDhwq%1G)QilO~H9sOiy|UTYrh&_>ZXdDk>MzmvFUZ>DVaN z&c#>2VNk2z4NIU}We4N?NM${>(D6Bc^LW@l{g`>FhPw(OW`H)iBl$bXtGHxke_2Ra7PUNNv6CtOTz42Y17_*L}7IM$pTHC#IkF~hKEO;FGQ zZ0oOcDw5n5_r?pCfFuy_Gq~kdPr`GjF9WzC(~)uwdZ~)&_LtdYkOkqD#KiG86(fB; zO$d^Gp#~4Jpyb_+P1BQkAa~Al2rKe)p8UvyHGRM4FsK2TIP=<&VgKpACjd`m;qW&cm$rm&tXu~$1;`4wmZem=|3cxE01_hS-Rs|r;Dv8oy-uum zPKqyjF6Og@b@rsN(LZ1Kv{1qXz0n~GB9(xpBhh`r@#C+0yZY}Dfje?1; z0?xV?yGd^2MhgvH)4D4PrJ0E2yVT;*Y3tQ6KkabK#CN^>x>_Z^^|=1FM{@$*2m)B=$o(q>A+p0Ec+bs9 zB@RG;{Q6k*V){IETY^u>2Rk?ZvkRePAhTMFO9LNUI=FVP6hH^QJ~nkIM2r~#fcTJq z*9(($)KQ{G1AsV47Rb>FN-LaozZQUIEpvc#3mZ5I>>$r2;o$7Nl9fzQzUW5wEkr~kHC|70i)8d zQIwGE@9ZW3enBJ90T<(6?L z-}4k1Yi;Iwt3lH^Go!Mb1O}Os)&Ss90hl?8^%sn|Rn#c~U(R46E7f%>_g=^2jfg~xS5GIb>`JOy1y4GBv_x7k7=A4gZ( ziaTSu1n)E1+sFF@JBeV8;K%T)kR zIKC|9y%s#tT0)%=W>x5zA^L@F-+{TR;adbVkbdkq4>l6~0Vyctf-VEm3@~Umqo*zh zS)lsLr$^Sv{JwOO3k8sKpUbBuTlmHkn+b}wYm0{?s;LCz+GQo%kgS#yesleN9jIe1 z)^`-7#EY1=j$rGK-*!JAzh)~RcA;aG<^G4of(#Mc70>#V@rRBS1mA0|AvXa{%PAhA zAhWxBCu*~&_FX2D)n^d-+ueT`_dC`NfSEYI7yn91YUhsiPgE!UM27Jh3k2|dm=hHw zYN=@*I`lWW@3I;nEWPbe%}bi~9B8S?u0~*cM-F0odCkpO1a?t7({~=614$8Md1X)N zTt0nhCJ}gIh_q+|g$o;B|Mx73WKc5;oSUK&P{#7HRXunKGf&N|@bkv^6HtnzImd}# z9M6|o`B}5*fsoo-vlG;Zzm}Z_!6{{3?+bRzDcGQM6ViL|O=2k%ZW|KZyVSw#pt`un&J;0p z22Ki8lzF*{Oowv83KxCUJo;{2m6sJr2VlKUV13Bf2qHx=1*dv?whVe%t*HP*b`r>rXZRsMCm$S!=j3T5L{X>)63x!5)T zdRk?aGc?@G5!+A5!S_L!x$R$t`t?Atl|c%dc=$-;8*rG-aUoYaX3QSQ{7&KwCu&+* zbdel624h4JBEgc+(mo=5ROAc!R{PJdT0DVZsfsyGvvY*j=o1+AU^S$4pcw?v=YDg;4Ot3D zYbtCWrlX!aDMb;@0LjtZ(~X;xPn+gXk7;8dz~LY{7>Vrlox`-C4hDn;KTEKsuNF2S znLj~W0!ppd zW`9zIe}^OlIC*uFrmO)sK@vOPBJc*zT6! z8mS1~?7x!l!S~SpqmP8}R{gbUr9q4)RLqgG0thsZzP8438=sGh$a~m2NWoNUM8rXn zfLtHNSzD`EvBZKD^xonk^x>z}Lu+!Ho8lbTm_q$>$|!b^d%ee!b zsKR{-Jd}(G*!X^#_3Cd*=Fm-d5OAe?n?c`I{B}kagbuPNO4lk7`5H@scthY33FT`K z{^5F@c#3zm1w%eBW<}xFIF5A^CPgWp?Fk+t{TZ6fmu42C_uWhdw|a zuJlM?Kct0}(EUrB6V=`zj;*O&k6ty>!@bBazPWf0_9Aaw#?vm{uaUQ9q*VOwzGheg zK_b4bJ%hw^rT@Y0DFBWTte;>Er+|b0{|H88&!gc)tVfhpE4qD={pznpG+=q0Q%OecwhO24;Q9)FvqEiS)v*nqElllWeJv4?T zxj*pN6zjR5HQJC;i)Un%!iLNq0IisM?p-TUWPSk%XRp)K;%PhSPVn-aGoD)h88hc9 zvwCW6o=*u0)ANfg$LG*aK)<=Zfw0%5vDlZ&xE%&5@=IQ7V{5FpaaQ0t87yg((&3uQ zXKU5GgdWJk^l1ItcRP&`K#RVR_ji~5Nso>Ukbe7!Wm_GlNGi?hB)2fQ=?6wzXsmE% zb$e3LN9VWhnAdhnGe%gxGVCN&56OyA%0$Z79{S>_X+OX*>+Ygh9)!}=X=!8-id^K453;Do95fc$5QJ3wodtAzV0!4jg8tnwe6TG*+YUvV_uRA9K8LiO zhqYb<9ZVq66w5TJ@K8_mz64})!@+C3hF&jtZ$KW!$6_oZdo;QTEGD9@l%e!i-kVXn z487xx+v*bh+!%+7Ss zCVqiF297pg;Jde)25}@DzXyrel+#EslEM1*9wP@!tfO6myVINHcfB_xnr!b7mQ1#U#(y-|l$j3FbOC2* z&+{>QmjsQ{Wq}3n(Lo1^R{SLIEMt-qa6p}9itV>HyEEwOFx*+?bm3DE5@R{Zyk>v3 zMA)d(I)}gn%J%T9?faZ=y3}OoaZ4K#GGwJb&>h?76gKA|i!fZwRJr{T&_s2MHO?Em zc;!ksogM+FL5tv_nLG1M44xmwx0-~&RJq)aCyWBhz=B|FPeemgrCZ3`(fNL=9o8BT zQtiRM4o5)`YBk8JrPBlV)9XVN2BI?o(WC}l92NBM-Jc5?R?d2ic@{hc08>60q@W=f z7fJx!;kOohcs+M3{62M^I!W%Av9>>6`%I)r6?bl|Zp|N0^1SyO2pWpkYdiQJKey|G zAq0arVd~+T*##Xr@@QN7ts}q?;=5eK`1C3035LVIT-*TIAJ=`Be`!KoMyYSaAwfVR z1urKr1tSeI<&5bO>v=^p)xeWS-|Y^c8i|7%_S}3F~SXJ1^22FQ^c*iIoC+fK}z<{&zA5s z18P9+;7_l)pcGB&m{! zdF)f|IJ~#QUD%5n;~tI&+zi@1f*}WiMDjm?c{MHD>AUW%*ZrCTSuA{ys1p7w=#2}W z4s`!0rQa{6740G$oLoe+-x;9UhyPovs_`QJ!z${yFD8tzROP<-B!MDY>u&3xAPZ!c zaV&zSHD-caA)dP5W3BTgm#x>}V$ClQ{m`1nY`ozkqf3HwHo| z$z*5TB0jkD{$R(fSf2xOtV&Ag3g>Y0H&CLKxoF-6DCa5P=Vs3SR40Qz&&8DcTQ66w^xsODRgWq4%<(7WmOHTGCja?NM}j)+>FP|wQ|A` z`eo>yQW4Ga-@_!cYK9@W^V6RHT5eP-h}U)6Q7(NGSAeTq^PgY1Gv<>tTKY46i2zMI zx^%ikPi?-pr{lu(Km#}sb-(Jmj0Wud_PvGSDAd1=-H-FI$-G1~cK6Lv+NST(>06J7 zx%%(BV2~Ct`q%RO76RRXCy<*Nih_Zwa@tZwvZfA22qW)iE+MtzJWLaPr z-<>TA>Jq5^WaHfY)T&6yb!Lb|IvCCN6(2Y07&e;Y8VltzxI80;wh>6FIndrJNLnd; z6$G2x60`1ul2DnIUH(TxX~%Bm9HKUFhC_<)qv|c!AwJ}GX4lz!h_$WAM&<$uQTChI93bY zb*1z!%p1AV%?#E5RQ|rvUe(*xZEa>>zTfb;Z2JBEG-!Lqc2gj*U0`;*AqTV(KfiV5 z`R1%RFNEgl*6na%5V2O_2R>191(sf8T!dcA<74XkUFfB>PF9$pq2W_7WAbP%JkLMH-*;3WaBqu>fc>z!c$_(`Kh|h^@{0Ja z3~Y8VB6Zx6`uo!4<%&hx))4KpU^V(GjeEvCzjBquUQyK6KbY9?uWMO*;eH`Z|30?- z7W=0SeX=*a46*@~g0GaImy+51M|2q;f z$H5E5Q-%(?XE%9eiT96a+C2ia%p(%z?~7l~M+fcGw8k{zgvM$e4bDBKXM`^6)!{|H z-vK@CxRf4vI=6(BfWq|fYvoWH0g>SOgp{PE7^X6z{X2!Aty<#3(;IhW>j9j6$_b5< zOL~;ZX3WyI8mGLIk#YKasHJszWCrsnnkR07=M3lj9k-37om0S=u4Q4s=*D*$%J^$Q zy`S=Y?(|~{4=D7NIj7H9>TnvW{acS)59LIT#~qaNQ(HU{sWsB6{((at)%sz2Y+Y(* z@c|cZpow8(loO5;lQ%#5F<*HPYW6IQ%2EuN=f9alKU91z63An*!`)S143t3LiA(-3 zv;LvR1|n-&V{ZI0T`MnYTJNChy;9@&o>h*<6SpOMgA zgLti>p>_?luw3xre5v+F5Llg~AUHosta2-uB2UX}=u#36j|Q_gsqIyai`FGT+(pU; z+yP20Cj`?ehuwFsp42-z{`=a$VFkD65c4>lDVS5Ao|49yV$YB#4R8nsWRMTfR+oZn z1SFdYCUEXzJ9HDYzy0r=$H6riW=|(+1;6{{LL=>6nJ~RiBgTK%mqa)fx!a{46)>*~ zl`)E_Ouw!(k9vYbxtq@_PUzqxgENr01=wN{2H7X#NqqRH3f@#zyGQP(!1&Sp&me=q z9=NZGo5$7NatwZ)!Q3KL^JU{y)9Z|t zmd781v7d|=hlgo@UXas(m&s2X3R=8V^J{vZD24|Ss+j+!kztVvCqWBskM;5_>MvKx zh_1gI{q{tWCXj>3ztP3_CAW#7z(_vOQ5)Cw>K?A8iMLri@{G7Av5LDLGpPzD*8N5Y zCZ#NL)Gub*VwsjG+TW4zet5U?tIuCbmxHTNfyb~0As7qHGXa86GBT4vZ?X_v4*f;X z4Pzqx{_buh6`P$rU6Uh1=K=x~7^eaNrTn8VqpQ2tfwkUQ#d!R$>57_(0V=tkrOht3 zVx#?$UmsUusiyyaXLbFgTqAfrhWTD(VO=l;zEHs%KtHIdY#NpKR0+Ip!JcQ|vv4k-gB5-0fAlof zrS5bjw3|oP6l5{Z%hNqp&I*6i8V~KwJG#KeEiJ|{A?sX2wdv-a7Mu+G&Nhu6TCmYU zt%KLJ`~b_G>|S#2I`k)SLtoT`DEm@oJ17 zsZ^qY{dbg!eqZCmmnegP@cAcCOEaSdbg*O5U@B(ITs&^|+~)(-TQ&OOF#2~j zgJ=#Su8R8^TETBrwA^}_>L&Zvcg%z~K-a}nyiw34t?5jMf zTep8bInFOTd4+u_#v0Tx{5Q|H4MBa9C!sgX4om0eO=n#mTL=wHX;A!b8Aui6Dv~!3 z+J*V7FO~=;T#>62+HktG0R#dFQ%KC&1efhvx@WewYSLZ}4DYVI_ zOHI-q`{lW>dJ>t32dUiuS-`kUe7@?MB)__91jQ+9MSBat%$`aR7(By72JQ330Uo-Q z7+|X|P6yP&D0jK+2$R}7W_gEo-x)8L1l{<%s`ketGbcc^l1XtE0k{!!1|7+52R+!^Y$p1|^4hlH*10fYKlLT{d&KlFMlJF_n z)V@Zu%rPs|lW{KKai~JUpq5|G%i;#(96?RK1ogWDR7)jPLzYt?a9J5rju|I>-fW%S z+M#dayieRLG*Xot1+S~}QJFThD`~Jz+2IzG4W@=94To2Em5~ni4y>iM-0F>*rn$O~ zeIn$Z_3b;QtsF;7*3qSktb4`D%}o|hPNi{Nv%G}r2vi*X%%tOwYZ%BdlnnVjO@Rpi zQQyV(s-}5>n}Q_%m5_3F7H^BK9CVLyMjs{=;M?pv?EcxpHUXEHg`&+yCZBBfFh-Bh z9XY79_f7cpXAWjY=1{n%rf!}2fiWa|?;g-YFg0ZScFoADtR5!ZPGrog9?Hn4AKCQ) ze}TNA!MG^kNoX7hGO!vArF)LniLXGmI{1(1CT+AU2#n^>+_q=q@^%u*Ej_l${Ey*R zKq7K7DRlZusfK$@_Fv!00#)K}cqyP;D1(B7-BBPr5$uK-284<<_8|o@AU+_N25dvC=>}G;DA-2J zA7Jv|fZ0Zk$?P!JL(R-rFy~%>plGYc$~5FvO>cMDe(W3Ai(pOp zyt%aVUCcYPWb-!b-ndsrT!v=m?zV5VG5d%k`IM78a^*+nL zsQ2czbTs4Q-J_1hu^qZ?txw|kxD?x6YDjK$$|V^utdRASkG`Om5sD0qwX%7bYPL{W zK^DJu!M#K+Wo^fTMd`t!XlhOtv(Oh?0r*QfT2&ZDIC z%@8_zRJ`Dhi}sb#*|cU33nEaD!%+siITAPHqqwfy@nrWutFh^vxD;*6(M9GnerlrL z#3~8oS99S;^FM6sIfs0?K&WA74l%@Bmi?sob;is$I3D7b?~Uwv&=OtCtJ#ccwf~a$ z`=DbnqyAa5_v258+eaR{O;}ONBO~&TE|EhNo2d0b2O5 z6jYD+6UB6VE0{ACvb3k_T>i#ZR5=WPle^j=gRS*zI&4m;3Ib(yWM$o4@+6z{^kHl= zrDiiY=xMexuWIq(Pc|2$(Kjr(?{Vp5==;&4qk{rESqhl}ueQ7U9^q%@u)Vfl=#dpU zyQj&M$irN$;VEdpxU>cL#oHo#%@UZNXMpdvuH7=wdOjAUn?&YwWERmuhAw79EyRv$ zDUlG*-E#6Cx+qg}b10EhAIldoeY@nW`E^rExDdn~cD0bUS9VCuo-w7G?FEDoWl#|4 z56gYdwE2`?`M#c#O9Fj&!5qIp$DDu49aMz5qOhTNiu>8s?hNtc`i+grulS>aqSQkW zmPK}TPcG?Uaz34x5m~gcsrM$sGQtq7t#SCyzTvC7_stO7-VFhthq^EJaxzlBzh|I< zGk=c5d!Dr@IN?d_P*6OLT9b!fy8*6i?-5^~IfTel+}LSiZ{CEsk9WCJ)Gqq#N2VER z7{55}@d~)stSEe|!FM3p$Xb;?ZQ=lankKc=em8FVU`Q}lmy)>j^6$Cwk!fTpGv;hZb}v7ynMoU*ZN|&p;u|o1!djt7%5!E}D_7qDEm6&6_rvBb;H%qXRW2Avve?{+=Q$CjmhgUX%KRK0x zLU7mY?JE+IWS-x@jk2Xk%L#J?DQ9UHnfQ0_DOJqoc>8Ufr@~Qvkbxnx-OE$soi5#e z8C^I;2WEUM!?JUYb~8P|o*#NZ_u)0#j=I(+Ry=v;hr{B&o|NWxNIlVlZVzU9zv z=70Eug5cB6Ki1tE$K75vi3stfXbXe$lLs44i^G+P(rK);M-%==<#z*PdvY%2;M2Z? zu8>K`j(z$4qFNuO&7*As-N;wQD=b;fl1T2Z8tq9VDV_n1HDJEm3|rXs$M>bv`l+ z=tX_whyu>+rIRgIm{%F1Ev)qM)8Fx=B|n!r6V8EQF%Jy3D7l zSo}>GO^$)eVxPIe1U!f_B!j6p@%WJV^5D=L?2ne`IAVeo1kVq5z|mQ`A=;wAM`gvC z|LWlb6@_4rgG34$qMU!tpVMsaNYW_w@jOeEV(PbHoGc)Q#N5#k(Uk%KhM}Q!MQMn_l{a)v!G=Ia&870&xRst&p@%t%vL4r+d_ zog9VrH>eWKsbqAD3w2}#BUUC9~hM0JSt zTn$}{Ab&7;|2Zz_mtJU^ciCJ|@}zpo^79WUZ3vok&M4lmFN zos*~t`fuoTl43P!4P-mkaWz-a+S2s*$H>s^WBBt0aRT$=6`8XUvDd~<&7Al7r*^?C z(cMRcCnDRnH)_2J3A9))-y#@F=M!#wRgKn|1zE(OZAF}P<~%**h!x_7cXf9q|9ULP z%Sx8VE8x8|AfPdR+Vo=K$E=6{L;zte&JVqdOx&bFr#J zxgat^bfvfYvu!TnLVot+-gRDsJ(gGL04|mo65G$Nb=f}E(^kt-H{1MfE~!7h$B5u; zJFdA)$^|)LO{J7NvXJnV&<70&DT}80ZO=@pBNbUr0$hmVF*rC_JOaT+mZf5)QH)Q# zs8*+tj_IMBS4zQw40dRDlLl}Zi0pvoKMsXlM|^Hfgs>&N!QEqZJ*lf>5xAck8G3s2 zVmUm;*vKG0NtHMCO=Q}0pV?2US}Xaj>g&&>#IWl*>ieI1k%ZjZ5rN^%8oX*Rng&|U z(Mt07w7C;KLBEBrrO2bo>>a)8$)@DmjBYOQfDUA})}!||n>k2BG8_}`uDenf0r<3w zpC4$FaX&=O?@@zqzddsr`L5>Fs!@_vBTj%tvqnbI?Wm6DLL_<#S!%juXis4y&?h}_ z8`lu^rZXM&a&9c9cbD1l5Vdzh4=dn=KNK4I`0p80iJCDVJ6=H_B1R5CuQ6v%4kOF{ zDdVL_IJ$5hfH{$A*Q!%{pQB=wW3{E>Ix$SoeJouSoL?R0O`Awo@1>-bE2M^i9>$WZ z5jwIwyje0lNEXQ4mS44)H+p7X2v!54bZj~g=NyO$ks*OWpT};<+1j{6ZP3U!QTJ$U1~!kN1}BPXm>2)L9H% z(QIxV0_fm%#SwlgL1J?hOK}mPX4%rQDY~1qJ>ZZ(1Hv|=W8T&na$4-fnXB+z-gyMI z3f#*P|2c2dedH*Fy;phEH~wq7i@@y^^GwGgEv!MeWjN`uu4dmm^uN_uu;sYAU>`WEbssQR5ks<;%ot0c8{b z<}y9?Rm!XdfD+DcC>Qgk5iME#rX&f&;!%bFNUiY&`!&fpyvl{RSQ)= zH*a+wqFad-RhU7a4kwZS`W>AC19N6dD?Ycbm;r;sW@-@5J(ZfEBe7E#FEU?q-Szv9YaS%u<)By|J=^Kk7b-+w?jI zN*VM`pTd1h*_$u=!cO>;lT#lEvqC{R6$)M~oEFslCvA8lrD%vd;{!=N2)H_i^1P|J z`mrW;r?Ki2Wy;0Fq^bWriELo8gl)O8$1EZ4EifacPj`kVEL9hZ!6BDvLN&+SoO(p* zL4Ylr46$~0xT+~q7%TK_QrN@(^I_iEWZb|a8)TkA>J4MOq(K5lpm^Uk=9HtGA~`4e|L9fuFff=qfxvxVXp;&z0?hq*)B$38F*RfIcWdW=+~5g|GJ8{@=VE^ zd+}MNN1ETpH&cSmbm)ZEVPEv?;JPx_9ucDb70bWny^^%Z#m=EaGsGo&kdiCgQHn#i zs*X6tn9{k0d;ki(?7$09;GnnfR8da9)SWq?t)kZ&#=HaYUps6Oj6k+D%-u%P*NoCX zBaM0($qD&4nLXZ&Nqe3o>ZY08@&_j%m>@AHYP6v$ngmz(di|=O?PzETdHOV24zx$I zjvHP72;;WP?|H@nx>Z_#etF~&%){QfR2eYwE?isM@`fD|#<^}XAhLLzsaLFhdxsip z<7Yoq%Sf^GqkOBcMbf7ITNUb1xx1tqG!Y-BA|6!C5kTn_4y}tMMb#ca97n5rY8|;pUqV zqw2+34p3(Ccl2?G^5bSIQ31`5J2~^fD=t6X+!$wSFQVy)mC;VZkJkCWNI%hhkFy1n zvQn!cfZ8q)=?1-YeVH-PuhWaa!qeA6`Bb8GH(BrWNY^mORUt#4&Pyzcm#soLRW znw(a*KG1tXXJIynaAR~eynY_rqj)0<9f}=K&lbkQlcQkT$n(T z-g}6+(d}s5keuDVkCoH-S$ZUjY*^UPj`~{oMq(Mi7J*kFn7cgUHo5UzP!|S2we!~U zibx;06N35{4o%GOdy`y!Meg!UWl@c7*D2mY#NT^&vPv*sHS`>1ntQpB=SiRxyH7u8 zrR0%fr-!a~XNIy=z|e2L@ON|$Dx5NWJ`AKL!$3JhJj@p=Urv2FT`MVnom;@|w-eEO zBO<3yF14=OW1$W|yr6V2w**cHn|skeT(H54>krG2mxDr`_gDS~x#FOeKS#ttc zc^{bgL_FpqS-#NY)9YXeFQ9*kzUOV<*X+V)sS@9usgj#Q1vjEi7C`DRfIIny40NdBlA4zz@d}Zwwofoh7x&| z$pg>YaN_vOWg=e!_R;Kzg;B-N$M6n>sz*`(e4S90*V<_eSk^g&n3&z7WM$ARm5FS% z7tb#;C_=4Z!;>#~@YBcby}2;3n09j8V0)visdf+73g%HEdM+AD@{dF#Ia%Srz1n1p zEhS1yzRV+reBIcAaG26*R*a4I_X3{%3@&9ga z`5(MGoR$SJT6oX{hq36rAljnY*C{mb;E3AC*TVm#4gllUl9^h~NmQNqg&I9IAi#M9 z^(*r~`;}3rF%KMg2{)gA#JkXtb#xk&%Qy+HHGlj{R`xMz=5VgW7sCe{+HyKaQDM&n z`?_g;DdGfA*S`7u`xlLX2n+zHYA>rPAiCb;!TWymVfmib^jjJMp%X>^nHfs5-VLUg z4AjNrWtwb2^~?6)vpPtPnu9$?Wo)9Q#1~}E8ei`BFA36vS-^MVNU5hL&aDfm;t_|c zai#B3=v$!dsU{Yt6nuT9b7tG4POxWcKkfG!F{GF%@n?F}Kcq=yIFV5HGV3LECU@EbS!xXWgE%U&!W4ber$TCslN zx4y3a)MZ?p0Z$1$bgSO9xP318Si2y{X*AWw-{-F^AvU9#M<<>8X0SVYj-8!=D26DX z$QgVRbFq-9dNNn}98{1r9ME;TeeOzWEg!g!qfZ&&GK|nuPaH3uylI09~9gVi?4%-@nVBd=#f^z zAs_b{nu74-9`_ z-ihJOU|(PFbERhUzJ~t75E=PVV>td<%)9sGa^49tDJM6p|E$&jS(aX1{ypIR?W&|P z8KT)h=QyPdsl<2Aowc7;$~C?4wYehHxj3_;d2bucbv)Sa&$T_?*85RyU0;m~c+|Aj z_2uWc_K?NXrGzTd2PCqsJ>}m}g@(=R35TI>4~&*WRf;w?lgD^$i>bmp#-tC3C*!!1 z&f8NnS=u1Lyg*WJ7QR~hu0J=7r8rl|mYT80*jC^MBc1FikKJPM;M@LNU+C9xM=oNE zng+LrQ+G_uL*dr0?i=n)z)A}?+q@=a_k8V&R9xaDQuCkiUt z{PkrbQX;0Q7`eH2YScY|G(oz2T?*m4{Ug&$$@_cTMCW!GjrlY8a)pU93-0{BNZ=pn z`6s%0tVO&Ee^Kw#;kFi@4W@D3-;0YyL{`8OYO!Z#HK(QluVAgsk#ORG2rk>gV}Ja`4k3S(&r2pv#VOY`EiwB8nvH z(|1iLOUWKD{bxGTq+carVcHC?9tWA#??{EI0(jH^!(%F0_}SfibuD9p3RpCS2gLa( zSFqRi*5ID3p_`HQ{!D5CNFOrI7|6n2>48Fuv-#=wD)sf3ho5__p6&j|W5;IP*fvj= z43rr{^ji82wT}bBR2Hk}XF>`dwtS81sZm|Lk~4ksEk@2yrs|KxPvI_Qxd#&3)`D8-7Yb=bgacw*EVi2)1luB^i`;Kf8El1 ze2km{d!G4%3S%~*Tf1dDy^pOEj0hn|WFLMEPOfeZh{l% zFaetuUBXKH%$PV~kiXf;MKD>YiE+=EYohhvo<9a5tM&eK!>+3l=TVXqi{ykIBE!IU zPtK?U<#LAJ>M@7lu!&N?HcmEmjd|dbUBgsYf=+$92dQDD zlWjm-{K;2u6yKK;s~XuMFKyX-zfvd0$CcpP=xU#HsEYxgPYa9d(b9Z>c-bb4sqwbk z?i?F#8(^v2YmCc-{!^anQ3i(znc1VjgI^%$)KezK*6@Pi<$6ZyTl8qnvIuiw$i-^N zZnK!1ZZFvNPD%is12Zxi7VpU#*1rCcTkmWB-gH6D>#Xgn3nI}4Hv^j*SqzSm;-Z=q zgVA?z%7>N3iBHl}%0D)pVBx2M-bH&m8`KkVQb!wCW?-+S%2uml1m`z#{ZdXnoD?}s z2KGZ*ZVuJcNP9&sM~3T@zt4OEa6=#6n^l+3@8m3O;pk*z;k$f4Z>5C!r$hA0#g=S^ zg2TeJads1DAoxA4CZ?eWJ?vHnxR+laxb{^JDojh4yf)Vc5xkrG=mVbXD+2eX!3{N% zHFuzU_w&lZB!Ay%BJytiu;cFUw5D?W+k(J5cO??HrqYn>6gPJBaKVLX9ckOMG1Ss* z>Q}{i#KJC8GrVXTbzW=|+87^l{&;dn<*9e=MjyFcZ3a@qEbi{#xQkeY5A8EpnWF(S zcaLPZ^S(?m{z?zN>vbi{spr|bYj}^EPm}Jv7@O)_@V`;}s&+oB#-RVoT?JtdNb(cU zN^=)o!Gm72y(8b-hnKHcb6%y_P%n7aVOPtQ69EE$N%&Q-1)mH$w3*pd({V!@em7VT zg*y7vFH}FW-mnb|FHrRoMm;zbBS@P$OVecQILV1_ckJe0$xHSvV{6A-d`PWZ68m!j z8Bqt7fU2%un$t6N+xOi+oNg(er=xU~5c=4Dk<#~|1@G6eo>_;?v&H^Kx!U1*e2flB zs)@rX;SFeBtw)*cu<$gg&|O}3zEz@E5EZVCY6EIjUS=z5rWp{}=w9g6GrsFona0To zj#d~fhV3j{GV7@}sH8=mr8r5b2(hETN9BKgH?m>R+zGg07|Y zFTK1|G4`tLJpdJsz3Z0`kYTin2%JJ+NR{^J&%xevMC2Tf;L;G z9A=6Ic%xr;vtb+knnz>$rS=;+gjSc@U)(!yZmitf=62&KKq0VY2K%|rkP<9SplZBV z_`R~&ix}%!5)U#<&S$ypnp~iC>523`Q>Tm{qr~A2R|~mVJJOi^^$ECYyTtZqS}rfGF_%k? zas1(B2Sv!D@9)dOTfL>ksA2Jn?Z35RAm6n8ZEtc4ucQiV*}yI*B=I=LwsBO~?T@Uo z4b*!a7>VYetGg!r#rL`W;p*CqvSW4l`!CreOT2&l;u5XD%R%sWXDQbbo;%&`Q-@yi zM->g1-*Hx?HRu0HxvS`w_v*v%P;Y70E2kfbIlyu8Zueh&7&rid7I`wmmJxdgQtTkH zv8hJZ9`di@C6D!REg;_-K9HWi3 z6mxTE&ZF-p$Sf+I)NBnYFO0J_cDLc^&E%5|@J=@zD1sfaBg^;?X0XZS6Gm}Xf zW3PZuYqod)Ur%2d7v=N4eJ_oGAR#5PDj*cyZ5t&&-)QbIx_HbH?3Z=igrIBp)9Vhwd}Y`2?B84c1t& ze-&k+MHWldls+{|#W8_v8(Jh#U}y9dYn)=k?zdgwhpAEHx+zns|NG?_9C zy#4zuZFdp>TjA4ntx>`m&389$vjqRAkRjZjtQ_^XHss9_5R18k$7G?qwqJWggNV&9 zDV85BABCX(rNi@E*Ns}MxMKwd%BiV>2@NH#Rt|hOnUmjVQ;*yyG4EU>7w&rYwh1*t zs!_nr{i4>s^1$QRgXlXn$sxcg{!j3GlJ0B3reO8ZHdp@Mze}yz6iCs~iPjUCABxE{Oo*yG+3V{U z&k}m8r;A=l=O)>5ovXMXHjYmlnT$^RoVPY2+-y$OU2WGsJmNi1w$R<_vgjSpdP4oy zMt1d`XM;s+Vp&?(%5<_qP3LdRjWnW?>oDg;r)7_-+Kp3-dVTImbQ`mc zI~B4c1uhD1n6C6GeyeR>r%j@4@p~RYcJj`LXRkP0-zR%%`aLGf;H?Mdsjl;Jr}-CJ zE%_<~@lPe-x}5>%v56aq-zY8b!?rJ&0$nDLIA7(!l~4H8;=%{Iij%rb2)$2!Mq8UT zf0vydEX>_ZEsD0=d1-;M8F3cHhXR{UfzkeJ`|jzpTi3z96P<>^;ZFT;?S@!K=%Qpu zBqFjni+0F%MKL22Z_x|0ozLpN5R#)2R(*a|y+()Vxhr(HI67_Ke5Ewsa|yi~ex$d= z-7xENfW~dCrWC4pU2^Gj(o$<6bm6*>G=g9|%w7M*aOB3+0KzGYQ(5m`QkwCXPI(Ji zA&R$f9isQ=CqPMb`2in6j)w>Nc*#XtRQU8HVcY!avO+GO3HACzzaJOCrn@?y{2HtK z#o4`cc4cDW^lENt%E0#7v0ZbogQSNWfp%@@&*IC)hN92KWw*kG!`uKeq3-E(QoVTo zYxvq>IY;V5JwkdfeRgx#^{rR!()7-CC&$3Ai|Dw*iqTKZS$R=9G0qoWc#=4>CZ{ns zM}j?8(zeOPqu~2kcS@nuNLyo&F6!_Yd^M=9i5M(dY|*=D8Dv? zYVfSb#Dw?Q~%;9OaQ!Nkms$g_VmQO{Lv>&bw z&+oisgU^fOL{L;&X(cV$-F-XBOXf=N7RClaaPhi7h)J5DD)(oijOt<$ zTM|o%zOPFN6hc%7eV>{IB4(i=3UWj~kX#Ah7`LQj&CLOg9sqyE#iX+$E=~h1Ll(cB zB}DZ$nF}VEfLQF^f{Ka#^+A*V^YBUahkX$t098x#C_iSw+^O761p$LD%M$xWy0d8H zo=CKvL}eG=XUE|Xbh#zIg0hqZ7)XvwMbZ%gq07^0&!d9ZB+YIRQA?EpF_^F*FOg(H z!YcmsgZ$-JL2Up^P(Hqx1_91}&6U4H#O*j@LqPn$q*(2ycjk6afw~R=PN6tpe+0JP z(b9{Z*>mgiy1NfR88r|G6!Ivu@z3BD;9MH3d)R!pGVDf~U|^ds9vob!c-#JZ3BS+9 zm><2=B0yb#j!(Tgrvd!ts_;>I!E2;lA1?{R?HeQg@!+Y_OmX{e`*pRB48hnQ;k?yf z1-s@dx;h8PFXJ&+Yw6DdiX{XqA+cE665^xYqgccJ{N>NKJq2u(vW@v3_A~a=bNx;npliFAk9*W>MLZD z@`%gygXv=`p*TLiMnJ{`a)h%^hu=KKW&56MWX3O=;3VJxz%Rd|8jtIz`b)(RyZb?? zL@ds4wee>PN1|NS&*oC}|6bh-nJ z$OCQmWv-VLaj?ez*W@+8rd%O0f)Is(mmj*LoZ7y8okBS#P;8+yNr;gjrTbt`p&Qx9 zt_liW_h@K9eJ4saSgwUIGfGUnJLGkM;%HPpkRKC}-%WnP!6)6!0@M8W%8~!Qd%dh4 zRhFIv$TPI*I%0X(LN7WkyZHdu_Q^*)v4gY~h#?+jgqjqJxvQcK>IK~w=T7OSrUC-% zxHp%Aak8!sFgpAE7u7>R2?-G`Lw@H##^>TQ@%Uqu|24P+B06M}=IiAkMnaeH-2clI z>VtHxPvS*184L8Jc^O)yH+m3qj@aHOZmyVc&wBLQAE|^l#qkyVgO*WOE9g!GK z_sz*Ig8#J{*HJuR2Ej1O>4U2+=k{~IcBKJku;_@1ko=5`Qj0v>ARKSfq-!zX|g znA>LzB44x=Eb#cWr!h2-y8EdM91{qguA&53Acqdrn((db;@6wp(@YkCqpdfj2nhbr z1PPwMpD#ofLk&JC)jo7I)*VStf3vx!b7|ozIglwMB^1Z_knnG!!17$4MhTzff2};r zxfbb=+L?TL*qM-M}`Q&;&g&4HFGa6{7G*J0T_1)HVh9B~z=5 zAg)MrQwO3jQYuX@%x4#1#NT`r+q3RONld&*N-U-Vme1|*mn9;+<@2;*n}rWvdL-EDWOls}R30PriJF5lR{==Sn|Q_^pZs>Vag%2N2qLXpz!gcKMnO@=2wlIwmd z#mZoNO7F?)z+xA#lWD3A252`_9zQUyIVtsRy^6_iYMYIck^<=CYN`6#*!;($b7iTs zAFtB@0NFxu^<3YeEh~>+T}hZM=Mw*`6T#3>Tz!>A)^Or(-DeT>yjlO^*Jt7gDRRCvEl`;DP&SfDM zsHdU3{p?%{m<5*}xhAXUkvJ$Gwv3te;Cit@caC zV(?g@2&=u@QCoh^DPYE!p_+D`69Y2Wgy@zxMgT&80APlFyt|Le`v>|#V1y+Y2=wrn z=GdY|yiu71d58E844EK=tb*C43$1D@FqZPO-35y0w}0#I#Eg4P0Qi+9a3`3l z2*`V0Z#NPPmaE7PK9?9=2-`Lhh4;B#xTpBO?L&bKDq>~=+^)DceikcEXBOx$Wj zS4G6Yz(RxLzty*3XLw3}#|_sH2C`v1T`3-B<&!qP*AwL|jF6IoAcvA)+Zh6aN>1V& z)W4qCQ$cLATtXFrdGhUrX4T}bk6>_0N)|R2h@yFEKj(Wf^=#14>-xHae7Gj8=Bbw0@l zpisy<8m9p>YAGBHF5K{SPF!MgD9tw?%-f@DPp3o;?(<#9F5LsNIfZ;8$U{ zO2+1Kg(|GQ=@ZS)u&Yi$wBd6&#O3Y3;@%z!8j4WOpIs$|sSjS=-?Rt!Ae^Y5r^U`(AZswFq+O*R#okKub+$dG+{>gC8uie)^iGLJ?v z>QM$q2$pRY`78Yxukrs|8Dc{*tLCCg%Do{XpVIZKaVf-bpw^{X`L<3!A>D&4bP4(m zZay~M1X)(*zC~DDyr+57kc9_iEe|JjfBv6-3}2s})!%>b13d;Ey_@<=3eYG@L<9=Y zD!=V=jx5~02{rAxNS)w3;Md|-$N>zO zTB6gheHbq+^dj)s6=0HK*f~r=ctDcw zhX=`oy@IOv#7bm2bGTz3o@pFk&-7~mC-1t1?^`x z!*k=~fuOffL2pAPR>Ru9wOEE#%qdnE_eV(sg^9#-{WIa-GTUun(ck=DI;iMR{Dp~R zxdLb6A^+c(JWWJkd;MgzQqijE-us+&oL{;T1STkA9>gG*IjV3W2~w;QsVpy*>LaSO zOr4zu!&~yjgF0CGIC>ZCh{n{Z54oc&(XU1w$nHHa0`=woD2S2ufZ~dB(gm`Pm^MRdL;r_n4xLcMvI=# z-;G0=V#V5-FQv@i#Xwi@+S@9AK`ds2qpzOF)G_nUJv7!}ichZ}=dP~#pWzTyS9P@8 zC8w@1rq(R%;Vl6yL}c*lzFXQGV)l_NwAq2T>+0*fV6lry+&IF?lj8>g!{70VKPs$M z)Y-uEw}1q`6V+P>_xP&u9hM)k&gE9W4R-+bU2KXgq}5>ST5NC2@bTP@*)6T@(YQ42 zmJIbGcZz_@C?Hk7UiLhFqn$%HFHD&s5%eq=YtqzSo^if%czokwuonMD=O!nu*~{Ee z>0-qoexTHJ{iAW0Kd6Eu)!y5N3aes*mMUDqnsm0{0C_%w312NxD#i+5-=lFs=e)pR zzB88H3VPR|H+F@ZRF}EwS`UNM*Ha4J=*8MK?BP*DOg$o6+71TVscvoDpv^RB<3^Wu zD@UmQDl<1>^Ywcl7$mnUcAEJ#JgE_|mk%_4yt{Bv4Xc_8#{olW07JR@*zMak{?vhQ z#ems`YLCQM94&s#p?n<=l9S@O(OaOxp!N`?pU42}5EYqvpU~4kp~VTY9fO-xz&L^0 zbgOR3dZjz2W*&tP-~MjQ!3Od|#IEZ9zhxs%6H(AR(0`UKH(Kqnkg$%i2Nj4ziERdKA58bVTUGbuhE4`(9g9jhRcv=osz=2kpedv`ex+rhw_-KVi{(EHB zE(e!eSObPX;NJgPITcxe zB-X((kaC&bu~A*&w67|-OA?Gr46%G1l)eGzLWn%2gp;d(WFV@gIrRDp7nFwd-v$ij zb%t_Ke5$NXiMm{H{6tDJT7lEboR*Ye+vQpRrNm;>B?$`!s2Ss|1Qn)KtSDkoQ3~oZ z(>DnrdJn9zGrwH?u=gkS+i>Z^3n%OIycWHH5X9{G<}oEPDf|Ho6rm+?-~a8S*t1** z?+e@ebEmm!6QrMfDj_(hP2+Cg3GY?(I(-gKWN2L67`MQf+V`-Kz!tjb^Em*M$MnG)gVMEJu=a^Z#mTe%XwuCY3!o@-1f~={{cdQ!STU0plD$Uk zw^MwEkykw;!C;T(0!AbrP?nUMXBx<}mD~`s2Bjw$UXp$XYSS@+eHuU{>r=Co%J?An zYA6Rso@e@tkEzklH!^j1kEF8+CCv|sr=QvaU2jGp2kT05R&)6HE|*JJ%o)d)->1LU z1!_7X$@+eMv#+a=eCqe;;KMh>8NF6lD#m?7dI?of^rc(rX{)$wm2L5R zWx39V%Lthtd=J)!LGZUAp|Cb-^_Gv^MQO{xsJhAf(M5B~7X|hU%q)1s zkZjjrC60*h^HZNJo4ELRMV6-!a)0W^1PS|noh0HmZGYC|e1-B~=}cWjia({=MP*4q zHqDR0LMXiR9wJZ6+}&pA#ay2L*Q}OoHb{2hOT{-}3~5AOM*N8PeE@$4NK0dRk-&EA zeUJ%;op5dE9tYYN77EB;5gCR&Yj%QLE}kQwA93=2KtJ)`1O~6*EmCM`Xx)BmyXLn+ zTynJdgYPT?yu%lxS(7raqVye8cc{UBA;G|vWg)SaI(=J3Ma2a(n0VZ4BH3G@6vi#!34^w(|PPavh)PAyp@?6emAK!zs_)w^f!}$+q=t$=MtJpb_>iI01x~v{@%)N#8`_|+O{jVUG0lb)OZ8$gC1g1C*N%yutMwMM!IA?oS8SG5Tw)|6rU_&!E#~E~ z+U@eAX#qtoUerp^f*NpFrNfq9S>Rr|&~hN7elrfxjM3Q@!iU?Lob~ zN_3h%s!@$gQQ2~d8B9kBpm&BS48-9Ih6f^&#!`3Dcq;J@Ppm1__b$y>}A4 zr99WBIbn%;x@*k)-h<~XQZt!zW25;EGv8Xt3nXd6UML3!M6Wv!TpeK z7ym$p_^Aw*xdscm5t7Eo5T?VlEKj}~nvR-NZ2b9aTP-mi2cm0X&YCeOVqU{IR;6Ed zGAFUng^$=X_-3v0%lq)Ex=H)rWcP(rrFZuV%>A^qg+do&Egx zy--IrYTQI2?7|98F<*feTqikTYrsX~L6J8UhII-xazjOK|7F_491Jxi|ClpAtzNoC z2SIZNS+-n5WT7yT!P)bzKf`!SUvIK1aCen+_4la^DHolwUz|Rt+a&1{ON8dP?y;nc z!3`^?E-x<9X7|*Gd&jQUxd+bLUI;RiLQ=#YFm;({mDiN{Gqb&|9ku~@l2Z$4epAE4 zt*p>rl%zuo6C~>*pQ(~RI^-Wp(0XnN!E?I5pD#xBT}AuM`uvM;m3weo?lk zk~}m5*NqfiNR|TYkS8Zq_Y=ZP5d5-#@tpr&D|};s@RDNvRC~C}_buK4M?;$!LHOvo}2j_%W?7 zOhM^u?r477-`3Yew~jB+>ofj%OLyM1^Gr813Uy`~ryFF6SBpZRlne3?{fXC}#mmd^ z2m+9?pzx;aQhKTWDw=y2mC}l+_5Woo)LL!cCEY2+$vSZb~MT zYrx4Mq*fZoM%YBMyy`{^r>(vclxa1H|g%J>O+pvtZ^-Ork=VeuHA4k9+`FS;wf))J$s7BK|I%cscJ9;w=Ry zZKxea>M>TJAu13vYHE`NU$WZ9Zd3MfeFzqUli=( zsmwEzqece!b+Crot_=a}n}eorC;ssJ5BfFV!^+Q^7!NdvOR4v(Pfr*XU^3>TSNb;N%&k zHJrwNrM1MO31kI0(w?B#CvUhy0;Mp+jQHW6MuXar#)Gq|%NIjdNZ_hfdt;^xoVcjG z-*>QMfNs(E}uRB;zmFKQD&O`opBT?%p}bEdbNHP zt@%j*4&4x)R{2J|Pz8OQhD!wTW=J>**k8^pybWW+uYn_ zyh{eq1xsC~(@j9H#CY597M=XsC#Qrmu<67IoQTxXLB6CjU|QlnW2zjI3}q!#*exSj zP3?;cn^Kq;O|fZb7wN^KAe4YdgnSNiP4YnnPlurKc{iXDR&p=+Nw_i>0%Mu_ZFfW^ zcz)!lk-lJ^ad`D63oPnI(;Y;x41}`_Ku}6S(=k>|iz$wpv{nSsq4dcOc*2zHac;b5#7#9r?n;6wtnYl&FiRv(bm|+xAr8Y5M?(o zyg)%r1_YfFyGZ>9gR55d$MmbFnF2bham}yjX;6KG61tcwd;-V|bcn@}r4zR*dCFCI zpx$ZOA7o{D;J#0SMd+!h*9e&VglLe~Nk4a8+?tI!IlEf742X*{iu>?t;LnFZ1ncA_ z#DW8Q6ZyDh36`Y4np^~ePaCPcCxYH5xMFwDw3l8Cs~)tH51dS5Fo9n>F39^vW(4P- z-D^!VB6u;MFO~{w-8I8`pe`cku6YshK#|)Kc_TLc2`#9}Iq&mNoPqilFIG;LK0?SV zke}782py2s{6%T=N%L!|R2BMEPY51y0;<7j5wPq^jtON=z<~`XBW=abFhaX{00o#k z^>)XDKWAgkKnA^(NlsYtfJc-8+u5*jHhH=8o2}$`DjchShs>vjRMjMt6c$e?Q!SI* z)@80iM(i-0h^O&c>ZpJslEquwwP?Tr^{GaG1;>w~6+~e^pg1eo6~%;bORGJ-3=CKN zy<3p6?(wSoi1d|uNZjoWFK&-ZNEQNN<2br5L8C9c^WN3Ve?M!A2>$gJ67hwJO!|cz2kaiJo)(W2D)4I<(u2vTen+nqjQJtF44K`78a5x|9c4@(6tfw zV@xG8#U&ei8IKxgT{glsMW<)n`vX@}p`p`Djs(llioMqG8jm07y|&+tZ;MT`GqM#1 zN~r0MS{wdu9y{pzB$HbcKr94rk$uS2QSf zw0<$|ix;qr>#Sms+{Y;tXSDnyGCHdOXwKOc%Zl^)Aao&q8ZFpH0PZdzAKu{}&kkh1 zy|aeie|aCjSZ>@h%UmlfD_fg?!^lU{rjvQ{!g!7xP7Y)*dpDKwH1X>+#cwsnot~s# zRL#E)1Q6g{G4qSDKsk`)UQ)m8=KeTK#b9UOW;zmP#41!}gar82@ZPR#j2ms40C$BV*V zt6N#(bi48}1`nLyH1bGj-poSjx2(QTLGkh~S0{Sb;i%N{;Wj=DK86PNHvZ(u9|W!Z z6)X}DtTj5GJV~5Hz`fRAAHXZBCV26V2(s5O?e((nUisR1n-vXp5rDhgq;4>jE9tSJ^O&L6$3Z$iX>H7K3$TEYsi9A))_{~j zVnd|wTPE|Gdbdp|i0iYw=$uO3ppGzaq0+1HoR8sCJAo%C_I*NG9iU(gr6l5-D0tiP za6;w*J>=4sBvJU!IK$>16e*v1$A9W*pfYBR!MZi{0_QVP<~G;Y5K$7t9xJH9@FY_GOz8?p9I zbi)f012`2Fs+l7kWIA!fMNnp8QVNmUdQN(uI%|wm)1G|XE&7s+%&zCii5F?&5;U-XIWV{h$ zU-hucK~~m3#{>i`|4?#@oqglnZYSus42Uj3ZFsFbsjm$`Zu?7YAqISRdh|p}0Nn@7 zo;OU58=08BtfL6BBIfk059O*|%(?TPyKp5H^BsOnW|q zj;v9nx3(Jo9o(abIE*Z6W}{y4v)by&qgfoknDHeb@I$;nrYZO?g!4ZBx+zY&u)wWF zvJ3K4Jx`3x6FYmKanry#s?S8Q4!Kr#PBMB`o2b)&{Be%Xy=&-P$K>|ZYs}Z#KxC{9 z@Jb+-Pqrj>RopV__uBUHabI5QoH)NdD_NZktRDwimG7wUH2o^9N5(qF60N6CbEqO)iJ24*0ghaYxBc780Xj752=^9-^B@J zG~T9F#;kF*3%~mMsBrRoT+x%zbJx}^W(b>Q2St=9#VN5f(^-E|h8ghK{h4s?pRk|( zSIahw18es#ZhB6*^7S+b*(o}G*P<76$r_aWV*8Px05;5H6?#J&96 znB7~mJLv}ZAz6HEZ?d)i;ofSz>jsEm6{4)bKyvKw|8;WmG~8PP;rN~1EVCo)Db3v9 zwQQQ1sv294k*wS``;(XgHnPOLwd~ZSB^iL&qM=M5TGIqR`#KI=S_5G z5n*_zW|QmA)4haxdLo14i|f`42Y9Z#>YChUbm4fCf8tP=mkG6A<&RYzjboDwAIEh} zMwq{!zA5TtA$n!7bp{hN0|J7kDdOdBbzfUUEIn6${;+nAszKRqTgGE>YTNPnY`R`V zvv_=F7~?E978X20ngHTJ1(O9MS?v5~8m-=cFmsFscC}Xz!~UcV4W}n&KYQR;FY;&) zSL_LPpHVQ4h9}dst;sY~BqD5M2S&G@PfqR=pw`9DNN7)w!?^1Nm`~1MmZ& z5(3xW|JZ94oO_f)6XmPSvFM?mRHD@WMhtKK7zW8mYvHujfAe@Qm`echSf=73DTzh# z?@@=cmAw%LuHL$Gt{R`hq2@=R^RU0)bPB@%`!9$YNEG{%1F$CcCq@Y{1ne(D3f&0) aU@%;_4h4Kf0= 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 { From b3386853bb640e1732f46e5d9c56b19760489647 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Thu, 28 Jul 2016 19:19:26 -0700 Subject: [PATCH 42/55] readme. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 02f262d..b6ab99c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ Pie Chart: ![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/images/pie_chart.png) +The code for this chart can be found in `examples/pie_chart/main.go`. + # Code Examples Actual chart configurations and examples can be found in the `./examples/` directory. They are web servers, so start them with `go run main.go` then access `http://localhost:8080` to see the output. From d84d6790c0e926449d3f1eba2cb2074b5c7e8eda Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 29 Jul 2016 16:36:29 -0700 Subject: [PATCH 43/55] text options! --- annotation_series.go | 4 +- bollinger_band_series.go | 2 +- chart.go | 4 +- continuous_series.go | 2 +- drawing_helpers.go => draw.go | 161 +++++----------------------------- drawing/text.go | 3 - ema_series.go | 2 +- examples/pie_chart/main.go | 8 +- histogram_series.go | 2 +- images/pie_chart.png | Bin 37157 -> 32994 bytes legend.go | 116 ++++++++++++++++++++++++ linear_regression_series.go | 2 +- macd_series.go | 4 +- pie_chart.go | 8 +- sma_series.go | 2 +- stacked_bar_chart.go | 2 +- style.go | 143 +++++++++++++++--------------- style_test.go | 39 ++------ text.go | 153 ++++++++++++++++++++++++++++++++ text_test.go | 32 +++++++ time_series.go | 2 +- vector_renderer.go | 89 +++++++++++++++---- vector_renderer_test.go | 25 ++++++ xaxis.go | 4 +- yaxis.go | 4 +- 25 files changed, 526 insertions(+), 287 deletions(-) rename drawing_helpers.go => draw.go (55%) create mode 100644 legend.go create mode 100644 text.go create mode 100644 text_test.go diff --git a/annotation_series.go b/annotation_series.go index 1b2c3b0..f622b8a 100644 --- a/annotation_series.go +++ b/annotation_series.go @@ -50,7 +50,7 @@ func (as AnnotationSeries) Measure(r Renderer, canvasBox Box, xrange, yrange Ran style := a.Style.InheritFrom(seriesStyle) lx := canvasBox.Left + xrange.Translate(a.XValue) ly := canvasBox.Bottom - yrange.Translate(a.YValue) - ab := MeasureAnnotation(r, canvasBox, style, lx, ly, a.Label) + ab := Draw.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) @@ -68,7 +68,7 @@ func (as AnnotationSeries) Render(r Renderer, canvasBox Box, xrange, yrange Rang style := a.Style.InheritFrom(seriesStyle) lx := canvasBox.Left + xrange.Translate(a.XValue) ly := canvasBox.Bottom - yrange.Translate(a.YValue) - DrawAnnotation(r, canvasBox, style, lx, ly, a.Label) + Draw.Annotation(r, canvasBox, style, lx, ly, a.Label) } } } diff --git a/bollinger_band_series.go b/bollinger_band_series.go index c0fe7a6..f74b489 100644 --- a/bollinger_band_series.go +++ b/bollinger_band_series.go @@ -114,7 +114,7 @@ func (bbs *BollingerBandsSeries) Render(r Renderer, canvasBox Box, xrange, yrang 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/chart.go b/chart.go index c4e7526..0a77b07 100644 --- a/chart.go +++ b/chart.go @@ -391,7 +391,7 @@ func (c Chart) getBackgroundStyle() Style { } func (c Chart) drawBackground(r Renderer) { - DrawBox(r, Box{ + Draw.Box(r, Box{ Right: c.GetWidth(), Bottom: c.GetHeight(), }, c.getBackgroundStyle()) @@ -402,7 +402,7 @@ func (c Chart) getCanvasStyle() Style { } 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) { diff --git a/continuous_series.go b/continuous_series.go index 0a122d0..fe8d068 100644 --- a/continuous_series.go +++ b/continuous_series.go @@ -51,5 +51,5 @@ func (cs ContinuousSeries) GetYAxis() YAxisType { // Render renders the series. func (cs ContinuousSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { style := cs.Style.InheritFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, cs) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, cs) } diff --git a/drawing_helpers.go b/draw.go similarity index 55% rename from drawing_helpers.go rename to draw.go index beada8e..c669791 100644 --- a/drawing_helpers.go +++ b/draw.go @@ -1,13 +1,16 @@ package chart -import ( - "math" +import "math" - "github.com/wcharczuk/go-chart/drawing" +var ( + // Draw contains helpers for drawing common objects. + Draw = &draw{} ) -// DrawLineSeries draws a line series with a renderer. -func DrawLineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs ValueProvider) { +type draw struct{} + +// LineSeries draws a line series with a renderer. +func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs ValueProvider) { if vs.Len() == 0 { return } @@ -52,8 +55,8 @@ func DrawLineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs r.Stroke() } -// DrawBoundedSeries draws a series that implements BoundedValueProvider. -func DrawBoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, bbs BoundedValueProvider, drawOffsetIndexes ...int) { +// BoundedSeries draws a series that implements BoundedValueProvider. +func (d draw) BoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, bbs BoundedValueProvider, drawOffsetIndexes ...int) { drawOffsetIndex := 0 if len(drawOffsetIndexes) > 0 { drawOffsetIndex = drawOffsetIndexes[0] @@ -106,8 +109,8 @@ func DrawBoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, 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) { +// HistogramSeries draws a value provider as boxes from 0. +func (d draw) HistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs ValueProvider, barWidths ...int) { if vs.Len() == 0 { return } @@ -129,7 +132,7 @@ func DrawHistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Styl x := cl + xrange.Translate(vx) y := yrange.Translate(vy) - DrawBox(r, Box{ + d.Box(r, Box{ Top: cb - y0, Left: x - (barWidth >> 1), Right: x + (barWidth >> 1), @@ -139,7 +142,7 @@ func DrawHistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Styl } // MeasureAnnotation measures how big an annotation would be. -func MeasureAnnotation(r Renderer, canvasBox Box, s Style, lx, ly int, label string) Box { +func (d draw) 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()) @@ -171,8 +174,8 @@ func MeasureAnnotation(r Renderer, canvasBox Box, s Style, lx, ly int, label str } } -// DrawAnnotation draws an anotation with a renderer. -func DrawAnnotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) { +// Annotation draws an anotation with a renderer. +func (d draw) Annotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) { r.SetFillColor(style.GetFillColor()) r.SetStrokeColor(style.GetStrokeColor()) r.SetStrokeWidth(style.GetStrokeWidth()) @@ -218,8 +221,8 @@ func DrawAnnotation(r Renderer, canvasBox Box, style Style, lx, ly int, label st r.Text(label, textX, textY) } -// DrawBox draws a box with a given style. -func DrawBox(r Renderer, b Box, s Style) { +// Box draws a box with a given style. +func (d draw) Box(r Renderer, b Box, s Style) { r.SetFillColor(s.GetFillColor()) r.SetStrokeColor(s.GetStrokeColor()) r.SetStrokeWidth(s.GetStrokeWidth(DefaultStrokeWidth)) @@ -234,7 +237,7 @@ func DrawBox(r Renderer, b Box, s Style) { } // DrawText draws text with a given style. -func DrawText(r Renderer, text string, x, y int, s Style) { +func (d draw) Text(r Renderer, text string, x, y int, s Style) { r.SetFontColor(s.GetFontColor(DefaultTextColor)) r.SetStrokeColor(s.GetStrokeColor()) r.SetStrokeWidth(s.GetStrokeWidth()) @@ -244,129 +247,7 @@ func DrawText(r Renderer, text string, x, y int, s Style) { 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()) +// TextWithin draws the text within a given box. +func (d draw) TextWithin(r Renderer, text string, box Box, s Style) { - 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].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 = 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/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/ema_series.go b/ema_series.go index 2bd808d..affadc1 100644 --- a/ema_series.go +++ b/ema_series.go @@ -97,5 +97,5 @@ func (ema *EMASeries) ensureCachedValues() { // Render renders the series. func (ema *EMASeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { style := ema.Style.InheritFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, ema) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, ema) } diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go index a1e2097..e4b3967 100644 --- a/examples/pie_chart/main.go +++ b/examples/pie_chart/main.go @@ -10,11 +10,13 @@ import ( func drawChart(res http.ResponseWriter, req *http.Request) { pie := chart.PieChart{ + Title: "test\nchart", + TitleStyle: chart.Style{ + Show: true, + FontSize: 32, + }, Width: 512, Height: 512, - Canvas: chart.Style{ - FillColor: chart.ColorLightGray, - }, Values: []chart.Value{ {Value: 5, Label: "Blue"}, {Value: 5, Label: "Green"}, diff --git a/histogram_series.go b/histogram_series.go index 08ba1b9..0542c1a 100644 --- a/histogram_series.go +++ b/histogram_series.go @@ -53,5 +53,5 @@ 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.InheritFrom(defaults) - DrawHistogramSeries(r, canvasBox, xrange, yrange, style, hs) + Draw.HistogramSeries(r, canvasBox, xrange, yrange, style, hs) } diff --git a/images/pie_chart.png b/images/pie_chart.png index 14a171d18bb1cf765d0b62d93acc6e02f48f0f03..4b2740eb3806344a8cca0af8d6886d8a64a44456 100644 GIT binary patch literal 32994 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%0AvYppPj`n z4RxT^z&|@KnVpb>6BoQtyQMB6(|dSzVCW~GT=M6QN|KzNk~}}nn5^*S?LitJ>3y5c z2EP6%0t`TGg3JI(lE@5@J}Ur^IPC|!8vlOh{PEsq*eH%|wq@3RvuLsR(CKzRe&m$z zx8&(ac3js1iFd2Xy}j7}!4GbSRvpQBOxz7BYMVg@h(aRk+G z0GWaDii~=3VeaF-&40gh;XvnQ*d&M)DNH`FBQ(UiYfubZ$P zAb5LX3@`xk2uYGMnG6|&2^R#gNf1k9D)n#8e26}BYuId5%6+@|DX1wwcza?DFaYrj zwID#2!1qQmMVD>(gPx{CUDuBG+!%2}LRqx(#*96Aszm>P9!>V`b~|``Vihm|z-R&i zWeFy94I%7q=c&Hd_q&@8bzf~B>5YJRz~sqN>Kl>@8j=g>`M^FvGkr?$LpXnI0|o#X z0~r9yHRL;oz)Q2a7g>V$yRV(-yJ>fUb3j~3aiU(IH9x6feo}sp(%{cqsHUXg>WLq~ z002Drzezwb?6e*0ZGNxoDzXRAo=jNM6gs5w=OyLOG~_68sXuQ)k32QscQ_p377q{= zU;uz|p*9I9{D0TQq4q;v*G~4go*QU`@^g_vNl2@7Y4Z~Ek?Jp0C;R_Kuigh2e|!Q4 z0GJpmub8p~ey#~HPb}%x;jWMSZ=LCHL$57n*f0ns*-FEdg!CF+dQCz`k;X`1{{CO6 z`M$#gxcK87FaSVc5ClP40)KXl>2%vi&i3CrJ8=6<|E*64Zd+a8Z3qlB&9Bs@P1R-8 zB&1jC(hX8&KyRn^b&=9X-@tqypa25^1PPAg)Up7*`oAooaJXF;hdR)*0DYbAztv$K z43m)2PSDeQ3iK>fahx#O2Y3Mk08AF7{k_WqKf{Mu^x4f#BR$tgx~~m)UmNLa8t%Dm z9f-qOYKmN&r%uXOCFZF{Uy;U7lB$BZ4w~ardLJhHfN#J60HK6RDM0oBEenD(1{5Ps zJF*AYM|zqlt1#T%V(xXiV`mO4#fcoHAzz)8r%KFMjlLqKuOb52Y-fQc_+GsalYPKX zzyJW@0vQ0x5+DNIFyP{FyPD0t$P(Nb>FKl$b=ihTzpTR@wjqm%s};#`GFhfhmaC2E zRi@5X8Yo?#sZ78F^yhG)G(P3l2c-`L0T=)v5>Rd;uRREK&%if>4(n(3!1L8%9YPkO zbJRSH+KB;&)fKj-11^yyN>!AKAAOZ+Jg;M4T5^Jl$^qe><~u!rdk2^#U;qFCpf->x zdocOM!Gs`9X>%bvfWDj-m)-2N+gwi1p~E&dNPX`hB)A+WNsuD5**?n=L#qMY1F{6vYsgIkA(1Evsd5q?_J~0_ zbLg)y%?Bb0dj&v$RskR~P*y?mIg{B>7BiH7rvP0KL>`$;#(KEe1^@s60EiP}bE*XZ z002w~7ytkOfH(yV0000$oB{>_001CP`F{Zb0RR7=0$KQ_^d+kR0000 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 = 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 index 41739b4..9c9756f 100644 --- a/linear_regression_series.go +++ b/linear_regression_series.go @@ -128,5 +128,5 @@ func (lrs *LinearRegressionSeries) computeCoefficients() { // Render renders the series. func (lrs *LinearRegressionSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { style := lrs.Style.InheritFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, lrs) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, lrs) } diff --git a/macd_series.go b/macd_series.go index b174b74..b3b80c0 100644 --- a/macd_series.go +++ b/macd_series.go @@ -193,7 +193,7 @@ func (macds *MACDSignalSeries) ensureSignal() { // Render renders the series. func (macds *MACDSignalSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { style := macds.Style.InheritFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, macds) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, macds) } // MACDLineSeries is a series that computes the inner ema1-ema2 value as a series. @@ -285,5 +285,5 @@ func (macdl *MACDLineSeries) ensureEMASeries() { // Render renders the series. func (macdl *MACDLineSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { style := macdl.Style.InheritFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, macdl) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, macdl) } diff --git a/pie_chart.go b/pie_chart.go index 363f4c3..4f3ce4a 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -99,14 +99,14 @@ func (pc PieChart) Render(rp RendererProvider, w io.Writer) error { } func (pc PieChart) drawBackground(r Renderer) { - DrawBox(r, Box{ + Draw.Box(r, Box{ Right: pc.GetWidth(), Bottom: pc.GetHeight(), }, pc.getBackgroundStyle()) } func (pc PieChart) drawCanvas(r Renderer, canvasBox Box) { - DrawBox(r, canvasBox, pc.getCanvasStyle()) + Draw.Box(r, canvasBox, pc.getCanvasStyle()) } func (pc PieChart) drawTitle(r Renderer) { @@ -138,7 +138,7 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) { var rads, delta, delta2, total float64 var lx, ly int for index, v := range values { - v.Style.InheritFrom(pc.stylePieChartValue(index)).PersistToRenderer(r) + v.Style.InheritFrom(pc.stylePieChartValue(index)).WriteToRenderer(r) r.MoveTo(cx, cy) rads = PercentToRadians(total) @@ -155,7 +155,7 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) { // draw the labels total = 0 for index, v := range values { - v.Style.InheritFrom(pc.stylePieChartValue(index)).PersistToRenderer(r) + v.Style.InheritFrom(pc.stylePieChartValue(index)).WriteToRenderer(r) if len(v.Label) > 0 { delta2 = PercentToRadians(total + (v.Value / 2.0)) delta2 = RadianAdd(delta2, _pi2) diff --git a/sma_series.go b/sma_series.go index 63a8708..9538d3b 100644 --- a/sma_series.go +++ b/sma_series.go @@ -86,5 +86,5 @@ 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.InheritFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, sma) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, sma) } diff --git a/stacked_bar_chart.go b/stacked_bar_chart.go index c479cf4..f15e21b 100644 --- a/stacked_bar_chart.go +++ b/stacked_bar_chart.go @@ -134,7 +134,7 @@ func (sbc StackedBarChart) drawBar(r Renderer, canvasBox Box, xoffset int, bar S for index, bv := range normalizedBarComponents { barHeight := int(bv.Value * float64(canvasBox.Height())) barBox := Box{Top: yoffset, Left: bxl, Right: bxr, Bottom: yoffset + barHeight} - DrawBox(r, barBox, bv.Style.InheritFrom(sbc.styleDefaultsStackedBarValue(index))) + Draw.Box(r, barBox, bv.Style.InheritFrom(sbc.styleDefaultsStackedBarValue(index))) yoffset += barHeight } diff --git a/style.go b/style.go index 6742554..1f506d5 100644 --- a/style.go +++ b/style.go @@ -21,6 +21,10 @@ type Style struct { FontSize float64 FontColor drawing.Color Font *truetype.Font + + TextHorizontalAlign textHorizontalAlign + TextVerticalAlign textVerticalAlign + TextWrap textWrap } // IsZero returns if the object is set or not. @@ -185,8 +189,41 @@ func (s Style) GetPadding(defaults ...Box) Box { return s.Padding } -// PersistToRenderer passes the style onto a renderer. -func (s Style) PersistToRenderer(r Renderer) { +// GetTextHorizontalAlign returns the horizontal alignment. +func (s Style) GetTextHorizontalAlign(defaults ...textHorizontalAlign) textHorizontalAlign { + if s.TextHorizontalAlign == TextHorizontalAlignUnset { + if len(defaults) > 0 { + return defaults[0] + } + return TextHorizontalAlignLeft + } + 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 TextVerticalAlignBaseline + } + 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 TextWrapWord + } + return s.TextWrap +} + +// 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()) @@ -196,6 +233,21 @@ func (s Style) PersistToRenderer(r Renderer) { 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) @@ -206,47 +258,14 @@ func (s Style) InheritFrom(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) 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, @@ -254,15 +273,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, @@ -271,34 +290,14 @@ 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, } } - -// 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 520692e..4fe8303 100644 --- a/style_test.go +++ b/style_test.go @@ -1,7 +1,6 @@ package chart import ( - "strings" "testing" "github.com/blendlabs/go-assert" @@ -146,29 +145,7 @@ func TestStyleWithDefaultsFrom(t *testing.T) { 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..7011095 --- /dev/null +++ b/text.go @@ -0,0 +1,153 @@ +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, wrapOption textWrap) []string { + valueBox := r.MeasureText(value) + if valueBox.Width() > width { + switch wrapOption { + 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) 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..b1f577b --- /dev/null +++ b/text_test.go @@ -0,0 +1,32 @@ +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()) + } + + output = Text.WrapFitWord(r, "foo", 100, basicTextStyle) + assert.Len(output, 1) + assert.Equal("foo", output[0]) +} diff --git a/time_series.go b/time_series.go index 5287cf7..df779eb 100644 --- a/time_series.go +++ b/time_series.go @@ -57,5 +57,5 @@ func (ts TimeSeries) GetYAxis() YAxisType { // Render renders the series. func (ts TimeSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { style := ts.Style.InheritFrom(defaults) - DrawLineSeries(r, canvasBox, xrange, yrange, style, ts) + Draw.LineSeries(r, canvasBox, xrange, yrange, style, ts) } diff --git a/vector_renderer.go b/vector_renderer.go index 1f030be..3f3f359 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -110,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. @@ -153,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. @@ -200,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 5ebe2cb..5a1ddd4 100644 --- a/xaxis.go +++ b/xaxis.go @@ -54,7 +54,7 @@ func (xa XAxis) GetGridLines(ticks []Tick) []GridLine { // Measure returns the bounds of the axis. func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) Box { - xa.Style.InheritFrom(defaults).PersistToRenderer(r) + xa.Style.InheritFrom(defaults).WriteToRenderer(r) sort.Sort(Ticks(ticks)) var left, right, top, bottom = math.MaxInt32, 0, math.MaxInt32, 0 @@ -82,7 +82,7 @@ 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) { - xa.Style.InheritFrom(defaults).PersistToRenderer(r) + xa.Style.InheritFrom(defaults).WriteToRenderer(r) r.MoveTo(canvasBox.Left, canvasBox.Bottom) r.LineTo(canvasBox.Right, canvasBox.Bottom) diff --git a/yaxis.go b/yaxis.go index 0d81f56..7c1e26f 100644 --- a/yaxis.go +++ b/yaxis.go @@ -61,7 +61,7 @@ func (ya YAxis) GetGridLines(ticks []Tick) []GridLine { // Measure returns the bounds of the axis. func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) Box { - ya.Style.InheritFrom(defaults).PersistToRenderer(r) + ya.Style.InheritFrom(defaults).WriteToRenderer(r) sort.Sort(Ticks(ticks)) @@ -104,7 +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) { - ya.Style.InheritFrom(defaults).PersistToRenderer(r) + ya.Style.InheritFrom(defaults).WriteToRenderer(r) sort.Sort(Ticks(ticks)) From cbc0002d2acc8fb4028e7785d5c8045a95111628 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 29 Jul 2016 18:24:25 -0700 Subject: [PATCH 44/55] big api overhauls. --- annotation_series.go | 8 +- bollinger_band_series_test.go | 8 +- box.go | 20 ++-- chart.go | 8 +- chart_test.go | 4 +- concat_series_test.go | 12 +-- continuous_range_test.go | 2 +- continuous_series_test.go | 4 +- defaults.go | 3 + draw.go | 94 ++++++++-------- ema_series_test.go | 2 +- examples/custom_padding/main.go | 8 +- examples/legend/main.go | 2 +- examples/linear_regression/main.go | 4 +- examples/simple_moving_average/main.go | 4 +- histogram_series_test.go | 4 +- legend.go | 2 +- linear_regression_series.go | 6 +- linear_regression_series_test.go | 12 +-- util.go => math.go | 142 ++++++++----------------- util_test.go => math_test.go | 71 +++++-------- pie_chart.go | 42 ++++---- sequence.go | 55 ++++++++++ sequence_test.go | 17 +++ sma_series.go | 2 +- sma_series_test.go | 16 +-- style.go | 14 +++ text.go | 29 +++-- text_test.go | 28 +++++ value.go | 6 +- vector_renderer.go | 6 +- xaxis.go | 8 +- yaxis.go | 10 +- 33 files changed, 356 insertions(+), 297 deletions(-) rename util.go => math.go (59%) rename util_test.go => math_test.go (64%) create mode 100644 sequence.go create mode 100644 sequence_test.go diff --git a/annotation_series.go b/annotation_series.go index f622b8a..9b383c9 100644 --- a/annotation_series.go +++ b/annotation_series.go @@ -51,10 +51,10 @@ func (as AnnotationSeries) Measure(r Renderer, canvasBox Box, xrange, yrange Ran 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 = 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) + 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 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 5df34e0..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), } } @@ -186,10 +186,10 @@ func (b Box) Fit(other Box) Box { func (b Box) Constrain(other Box) Box { newBox := b.Clone() - newBox.Top = MaxInt(newBox.Top, other.Top) - newBox.Left = MaxInt(newBox.Left, other.Left) - newBox.Right = MinInt(newBox.Right, other.Right) - newBox.Bottom = MinInt(newBox.Bottom, other.Bottom) + 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) return newBox } diff --git a/chart.go b/chart.go index 0a77b07..67c1061 100644 --- a/chart.go +++ b/chart.go @@ -230,8 +230,8 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { yrange.SetMax(maxy) delta := yrange.GetDelta() - roundTo := GetRoundToForDelta(delta) - rmin, rmax := RoundDown(yrange.GetMin(), roundTo), RoundUp(yrange.GetMax(), roundTo) + roundTo := Math.GetRoundToForDelta(delta) + rmin, rmax := Math.RoundDown(yrange.GetMin(), roundTo), Math.RoundUp(yrange.GetMax(), roundTo) yrange.SetMin(rmin) yrange.SetMax(rmax) } @@ -249,8 +249,8 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { yrangeAlt.SetMax(maxya) delta := yrangeAlt.GetDelta() - roundTo := GetRoundToForDelta(delta) - rmin, rmax := RoundDown(yrangeAlt.GetMin(), roundTo), RoundUp(yrangeAlt.GetMax(), roundTo) + roundTo := Math.GetRoundToForDelta(delta) + rmin, rmax := Math.RoundDown(yrangeAlt.GetMin(), roundTo), Math.RoundUp(yrangeAlt.GetMax(), roundTo) yrangeAlt.SetMin(rmin) yrangeAlt.SetMax(rmax) } diff --git a/chart_test.go b/chart_test.go index eca6f98..e313fdf 100644 --- a/chart_test.go +++ b/chart_test.go @@ -384,8 +384,8 @@ func TestChartRegressionBadRangesByUser(t *testing.T) { }, 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_test.go b/concat_series_test.go index f9f93cd..f72eb23 100644 --- a/concat_series_test.go +++ b/concat_series_test.go @@ -10,18 +10,18 @@ func TestConcatSeries(t *testing.T) { assert := assert.New(t) s1 := 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), } s2 := ContinuousSeries{ - XValues: Seq(11, 20.0), - YValues: Seq(10.0, 1.0), + XValues: Sequence.Float64(11, 20.0), + YValues: Sequence.Float64(10.0, 1.0), } s3 := ContinuousSeries{ - XValues: Seq(21, 30.0), - YValues: Seq(1.0, 10.0), + XValues: Sequence.Float64(21, 30.0), + YValues: Sequence.Float64(1.0, 10.0), } cs := ConcatSeries([]Series{s1, s2, s3}) diff --git a/continuous_range_test.go b/continuous_range_test.go index 4400366..114ecbe 100644 --- a/continuous_range_test.go +++ b/continuous_range_test.go @@ -10,7 +10,7 @@ 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 := ContinuousRange{Domain: 1000} - r.Min, r.Max = MinAndMax(values...) + r.Min, r.Max = Math.MinAndMax(values...) // delta = ~7.0 // value = ~5.0 diff --git a/continuous_series_test.go b/continuous_series_test.go index df2e3b8..171db37 100644 --- a/continuous_series_test.go +++ b/continuous_series_test.go @@ -11,8 +11,8 @@ func TestContinuousSeries(t *testing.T) { cs := ContinuousSeries{ Name: "Test Series", - 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), } assert.Equal("Test Series", cs.GetName()) diff --git a/defaults.go b/defaults.go index a1fb01c..e1cdb69 100644 --- a/defaults.go +++ b/defaults.go @@ -33,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. diff --git a/draw.go b/draw.go index c669791..de61c1d 100644 --- a/draw.go +++ b/draw.go @@ -10,7 +10,7 @@ var ( type draw struct{} // LineSeries draws a line series with a renderer. -func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs ValueProvider) { +func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, vs ValueProvider) { if vs.Len() == 0 { return } @@ -25,7 +25,7 @@ func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Styl var vx, vy float64 var x, y int - fill := s.GetFillColor() + fill := style.GetFillColor() if !fill.IsZero() { r.SetFillColor(fill) r.MoveTo(x0, y0) @@ -41,9 +41,9 @@ func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Styl r.Fill() } - r.SetStrokeColor(s.GetStrokeColor()) - r.SetStrokeDashArray(s.GetStrokeDashArray()) - r.SetStrokeWidth(s.GetStrokeWidth(DefaultStrokeWidth)) + r.SetStrokeColor(style.GetStrokeColor()) + r.SetStrokeDashArray(style.GetStrokeDashArray()) + r.SetStrokeWidth(style.GetStrokeWidth()) r.MoveTo(x0, y0) for i := 1; i < vs.Len(); i++ { @@ -56,16 +56,13 @@ func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Styl } // BoundedSeries draws a series that implements BoundedValueProvider. -func (d draw) BoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, bbs BoundedValueProvider, drawOffsetIndexes ...int) { +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] } - r.SetStrokeColor(s.GetStrokeColor()) - r.SetStrokeDashArray(s.GetStrokeDashArray()) - r.SetStrokeWidth(s.GetStrokeWidth()) - r.SetFillColor(s.GetFillColor()) + style.WriteToRenderer(r) cb := canvasBox.Bottom cl := canvasBox.Left @@ -110,7 +107,7 @@ func (d draw) BoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, s S } // HistogramSeries draws a value provider as boxes from 0. -func (d draw) HistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs ValueProvider, barWidths ...int) { +func (d draw) HistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, vs ValueProvider, barWidths ...int) { if vs.Len() == 0 { return } @@ -137,30 +134,25 @@ func (d draw) HistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Left: x - (barWidth >> 1), Right: x + (barWidth >> 1), Bottom: cb - y, - }, s) + }, style) } } // MeasureAnnotation measures how big an annotation would be. -func (d draw) 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)) +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 := s.Padding.GetTop(DefaultAnnotationPadding.Top) - pl := s.Padding.GetLeft(DefaultAnnotationPadding.Left) - pr := s.Padding.GetRight(DefaultAnnotationPadding.Right) - pb := s.Padding.GetBottom(DefaultAnnotationPadding.Bottom) + 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 := s.GetStrokeWidth() + strokeWidth := style.GetStrokeWidth() top := ly - (pt + halfTextHeight) right := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth + int(strokeWidth) @@ -176,14 +168,7 @@ func (d draw) MeasureAnnotation(r Renderer, canvasBox Box, s Style, lx, ly int, // Annotation draws an anotation with a renderer. func (d draw) Annotation(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)) + style.WriteToRenderer(r) textBox := r.MeasureText(label) textWidth := textBox.Width() @@ -223,10 +208,7 @@ func (d draw) Annotation(r Renderer, canvasBox Box, style Style, lx, ly int, lab // Box draws a box with a given style. func (d draw) Box(r Renderer, b Box, s Style) { - r.SetFillColor(s.GetFillColor()) - r.SetStrokeColor(s.GetStrokeColor()) - r.SetStrokeWidth(s.GetStrokeWidth(DefaultStrokeWidth)) - r.SetStrokeDashArray(s.GetStrokeDashArray()) + s.WriteToRenderer(r) r.MoveTo(b.Left, b.Top) r.LineTo(b.Right, b.Top) @@ -237,17 +219,41 @@ func (d draw) Box(r Renderer, b Box, s Style) { } // DrawText draws text with a given style. -func (d draw) Text(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()) - +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, s Style) { +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 + ((lineBox.Width() - box.Left) >> 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/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/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/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 index 402a91a..c397ca9 100644 --- a/examples/linear_regression/main.go +++ b/examples/linear_regression/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 LinearRegressionSeries series by assignin the inner series. 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/histogram_series_test.go b/histogram_series_test.go index 80a2fec..3e51833 100644 --- a/histogram_series_test.go +++ b/histogram_series_test.go @@ -11,8 +11,8 @@ func TestHistogramSeries(t *testing.T) { cs := ContinuousSeries{ Name: "Test Series", - XValues: Seq(1.0, 20.0), - YValues: Seq(10.0, -10.0), + XValues: Sequence.Float64(1.0, 20.0), + YValues: Sequence.Float64(10.0, -10.0), } hs := HistogramSeries{ diff --git a/legend.go b/legend.go index 82b5286..9425c9a 100644 --- a/legend.go +++ b/legend.go @@ -68,7 +68,7 @@ func Legend(c *Chart, userDefaults ...Style) Renderable { } legendContent.Bottom += tb.Height() right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum - legendContent.Right = MaxInt(legendContent.Right, right) + legendContent.Right = Math.MaxInt(legendContent.Right, right) labelCount++ } } diff --git a/linear_regression_series.go b/linear_regression_series.go index 9c9756f..a33a0b1 100644 --- a/linear_regression_series.go +++ b/linear_regression_series.go @@ -34,7 +34,7 @@ func (lrs LinearRegressionSeries) GetYAxis() YAxisType { // Len returns the number of elements in the series. func (lrs LinearRegressionSeries) Len() int { - return MinInt(lrs.GetWindow(), lrs.InnerSeries.Len()-lrs.GetOffset()) + return Math.MinInt(lrs.GetWindow(), lrs.InnerSeries.Len()-lrs.GetOffset()) } // GetWindow returns the window size. @@ -47,7 +47,7 @@ func (lrs LinearRegressionSeries) GetWindow() int { // GetEndIndex returns the effective window end. func (lrs LinearRegressionSeries) GetEndIndex() int { - return MinInt(lrs.GetOffset()+(lrs.Len()), (lrs.InnerSeries.Len() - 1)) + return Math.MinInt(lrs.GetOffset()+(lrs.Len()), (lrs.InnerSeries.Len() - 1)) } // GetOffset returns the data offset. @@ -67,7 +67,7 @@ func (lrs *LinearRegressionSeries) GetValue(index int) (x, y float64) { lrs.computeCoefficients() } offset := lrs.GetOffset() - effectiveIndex := MinInt(index+offset, lrs.InnerSeries.Len()) + effectiveIndex := Math.MinInt(index+offset, lrs.InnerSeries.Len()) x, y = lrs.InnerSeries.GetValue(effectiveIndex) y = (lrs.m * lrs.normalize(x)) + lrs.b return diff --git a/linear_regression_series_test.go b/linear_regression_series_test.go index 9ff890e..4a72669 100644 --- a/linear_regression_series_test.go +++ b/linear_regression_series_test.go @@ -11,8 +11,8 @@ func TestLinearRegressionSeries(t *testing.T) { mainSeries := ContinuousSeries{ Name: "A test series", - XValues: Seq(1.0, 100.0), - YValues: Seq(1.0, 100.0), + XValues: Sequence.Float64(1.0, 100.0), + YValues: Sequence.Float64(1.0, 100.0), } linRegSeries := &LinearRegressionSeries{ @@ -33,8 +33,8 @@ func TestLinearRegressionSeriesDesc(t *testing.T) { mainSeries := ContinuousSeries{ Name: "A test series", - XValues: Seq(100.0, 1.0), - YValues: Seq(100.0, 1.0), + XValues: Sequence.Float64(100.0, 1.0), + YValues: Sequence.Float64(100.0, 1.0), } linRegSeries := &LinearRegressionSeries{ @@ -55,8 +55,8 @@ func TestLinearRegressionSeriesWindowAndOffset(t *testing.T) { mainSeries := ContinuousSeries{ Name: "A test series", - XValues: Seq(100.0, 1.0), - YValues: Seq(100.0, 1.0), + XValues: Sequence.Float64(100.0, 1.0), + YValues: Sequence.Float64(100.0, 1.0), } linRegSeries := &LinearRegressionSeries{ diff --git a/util.go b/math.go similarity index 59% rename from util.go rename to math.go index af3467f..77180e8 100644 --- a/util.go +++ b/math.go @@ -1,19 +1,23 @@ 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) -} +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 { @@ -25,8 +29,15 @@ 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 MinAndMax(values ...float64) (min float64, max float64) { +func (m mathUtil) MinAndMax(values ...float64) (min float64, max float64) { if len(values) == 0 { return } @@ -45,7 +56,7 @@ func MinAndMax(values ...float64) (min float64, max float64) { // 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) { +func (m mathUtil) MinAndMaxOfTime(values ...time.Time) (min time.Time, max time.Time) { if len(values) == 0 { return } @@ -64,19 +75,8 @@ func MinAndMaxOfTime(values ...time.Time) (min time.Time, max time.Time) { 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 { +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 { @@ -88,13 +88,13 @@ func GetRoundToForDelta(delta float64) float64 { } // RoundUp rounds up to a given roundTo value. -func RoundUp(value, roundTo float64) float64 { +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 RoundDown(value, roundTo float64) float64 { +func (m mathUtil) RoundDown(value, roundTo float64) float64 { d1 := math.Floor(value / roundTo) return d1 * roundTo } @@ -102,20 +102,20 @@ func RoundDown(value, roundTo float64) float64 { // 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 Normalize(values ...float64) []float64 { +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] = RoundDown(v/total, 0.0001) + output[x] = m.RoundDown(v/total, 0.0001) } return output } // MinInt returns the minimum of a set of integers. -func MinInt(values ...int) int { +func (m mathUtil) MinInt(values ...int) int { min := math.MaxInt32 for _, v := range values { if v < min { @@ -126,7 +126,7 @@ func MinInt(values ...int) int { } // MaxInt returns the maximum of a set of integers. -func MaxInt(values ...int) int { +func (m mathUtil) MaxInt(values ...int) int { max := math.MinInt32 for _, v := range values { if v > max { @@ -137,47 +137,15 @@ func MaxInt(values ...int) int { } // AbsInt returns the absolute value of an integer. -func AbsInt(value int) int { +func (m mathUtil) 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 -} - // Sum sums a set of values. -func Sum(values ...float64) float64 { +func (m mathUtil) Sum(values ...float64) float64 { var total float64 for _, v := range values { total += v @@ -186,7 +154,7 @@ func Sum(values ...float64) float64 { } // SumInt sums a set of values. -func SumInt(values ...int) int { +func (m mathUtil) SumInt(values ...int) int { var total int for _, v := range values { total += v @@ -194,52 +162,32 @@ func SumInt(values ...int) int { return total } -// 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 { +func (m mathUtil) PercentDifference(v1, v2 float64) float64 { + if v1 == 0 { + return 0 + } return (v2 - v1) / v1 } -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) -) - // DegreesToRadians returns degrees as radians. -func DegreesToRadians(degrees float64) float64 { +func (m mathUtil) DegreesToRadians(degrees float64) float64 { return degrees * _d2r } // RadiansToDegrees translates a radian value to a degree value. -func RadiansToDegrees(value float64) float64 { +func (m mathUtil) RadiansToDegrees(value float64) float64 { return math.Mod(value, _2pi) * _r2d } // PercentToRadians converts a normalized value (0,1) to radians. -func PercentToRadians(pct float64) float64 { - return DegreesToRadians(360.0 * pct) +func (m mathUtil) PercentToRadians(pct float64) float64 { + return m.DegreesToRadians(360.0 * pct) } // RadianAdd adds a delta to a base in radians. -func RadianAdd(base, delta float64) float64 { +func (m mathUtil) RadianAdd(base, delta float64) float64 { value := base + delta if value > _2pi { return math.Mod(value, _2pi) @@ -250,7 +198,7 @@ func RadianAdd(base, delta float64) float64 { } // DegreesAdd adds a delta to a base in radians. -func DegreesAdd(baseDegrees, deltaDegrees float64) float64 { +func (m mathUtil) DegreesAdd(baseDegrees, deltaDegrees float64) float64 { value := baseDegrees + deltaDegrees if value > _2pi { return math.Mod(value, 360.0) @@ -261,13 +209,13 @@ func DegreesAdd(baseDegrees, deltaDegrees float64) float64 { } // DegreesToCompass returns the degree value in compass / clock orientation. -func DegreesToCompass(deg float64) float64 { - return DegreesAdd(deg, -90.0) +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 CirclePoint(cx, cy int, radius, angleRadians float64) (x, y int) { +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/util_test.go b/math_test.go similarity index 64% rename from util_test.go rename to math_test.go index 8bf6b15..a4c4006 100644 --- a/util_test.go +++ b/math_test.go @@ -10,7 +10,7 @@ import ( func TestMinAndMax(t *testing.T) { assert := assert.New(t) values := []float64{1.0, 2.0, 3.0, 4.0} - min, max := MinAndMax(values...) + min, max := Math.MinAndMax(values...) assert.Equal(1.0, min) assert.Equal(4.0, max) } @@ -18,7 +18,7 @@ func TestMinAndMax(t *testing.T) { func TestMinAndMaxReversed(t *testing.T) { assert := assert.New(t) values := []float64{4.0, 2.0, 3.0, 1.0} - min, max := MinAndMax(values...) + min, max := Math.MinAndMax(values...) assert.Equal(1.0, min) assert.Equal(4.0, max) } @@ -26,7 +26,7 @@ func TestMinAndMaxReversed(t *testing.T) { func TestMinAndMaxEmpty(t *testing.T) { assert := assert.New(t) values := []float64{} - min, max := MinAndMax(values...) + min, max := Math.MinAndMax(values...) assert.Equal(0.0, min) assert.Equal(0.0, max) } @@ -39,7 +39,7 @@ func TestMinAndMaxOfTime(t *testing.T) { time.Now().AddDate(0, 0, -3), time.Now().AddDate(0, 0, -4), } - min, max := MinAndMaxOfTime(values...) + min, max := Math.MinAndMaxOfTime(values...) assert.Equal(values[3], min) assert.Equal(values[0], max) } @@ -52,7 +52,7 @@ func TestMinAndMaxOfTimeReversed(t *testing.T) { time.Now().AddDate(0, 0, -3), time.Now().AddDate(0, 0, -1), } - min, max := MinAndMaxOfTime(values...) + min, max := Math.MinAndMaxOfTime(values...) assert.Equal(values[0], min) assert.Equal(values[3], max) } @@ -60,66 +60,45 @@ func TestMinAndMaxOfTimeReversed(t *testing.T) { func TestMinAndMaxOfTimeEmpty(t *testing.T) { assert := assert.New(t) values := []time.Time{} - min, max := MinAndMaxOfTime(values...) + min, max := Math.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)) + 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, RoundUp(0.49, 0.1)) - assert.Equal(1.0, RoundUp(0.51, 1.0)) - assert.Equal(0.4999, RoundUp(0.49988, 0.0001)) + 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, RoundDown(0.51, 0.1)) - assert.Equal(1.0, RoundDown(1.01, 1.0)) - assert.Equal(0.5001, RoundDown(0.50011, 0.0001)) -} - -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) + 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, PercentDifference(1.0, 1.5)) - assert.Equal(-0.5, PercentDifference(2.0, 1.0)) + 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 := Normalize(values...) + normalized := Math.Normalize(values...) assert.Len(normalized, 5) assert.Equal(0.25, normalized[0]) assert.Equal(0.1499, normalized[4]) @@ -153,7 +132,7 @@ func TestDegreesToRadians(t *testing.T) { assert := assert.New(t) for d, r := range _degreesToRadians { - assert.Equal(r, DegreesToRadians(d)) + assert.Equal(r, Math.DegreesToRadians(d)) } } @@ -161,7 +140,7 @@ func TestPercentToRadians(t *testing.T) { assert := assert.New(t) for d, r := range _degreesToRadians { - assert.Equal(r, PercentToRadians(d/360.0)) + assert.Equal(r, Math.PercentToRadians(d/360.0)) } } @@ -169,15 +148,15 @@ func TestRadiansToDegrees(t *testing.T) { assert := assert.New(t) for d, r := range _degreesToRadians { - assert.Equal(d, RadiansToDegrees(r)) + assert.Equal(d, Math.RadiansToDegrees(r)) } } func TestRadianAdd(t *testing.T) { assert := assert.New(t) - assert.Equal(_pi, RadianAdd(_pi2, _pi2)) - assert.Equal(_3pi2, RadianAdd(_pi2, _pi)) - assert.Equal(_pi, RadianAdd(_pi, _2pi)) - assert.Equal(_pi, RadianAdd(_pi, -_2pi)) + 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 index 4f3ce4a..d67bac6 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -111,26 +111,13 @@ func (pc PieChart) drawCanvas(r Renderer, canvasBox Box) { func (pc PieChart) drawTitle(r Renderer) { if len(pc.Title) > 0 && pc.TitleStyle.Show { - r.SetFont(pc.TitleStyle.GetFont(pc.GetFont())) - r.SetFontColor(pc.TitleStyle.GetFontColor(DefaultTextColor)) - titleFontSize := pc.TitleStyle.GetFontSize(DefaultTitleFontSize) - r.SetFontSize(titleFontSize) - - textBox := r.MeasureText(pc.Title) - - textWidth := textBox.Width() - textHeight := textBox.Height() - - titleX := (pc.GetWidth() >> 1) - (textWidth >> 1) - titleY := pc.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight - - r.Text(pc.Title, titleX, titleY) + Draw.TextWithin(r, pc.Title, pc.Box(), pc.styleDefaultsTitle()) } } func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) { cx, cy := canvasBox.Center() - diameter := MinInt(canvasBox.Width(), canvasBox.Height()) + diameter := Math.MinInt(canvasBox.Width(), canvasBox.Height()) radius := float64(diameter >> 1) labelRadius := (radius * 2.0) / 3.0 @@ -141,8 +128,8 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) { v.Style.InheritFrom(pc.stylePieChartValue(index)).WriteToRenderer(r) r.MoveTo(cx, cy) - rads = PercentToRadians(total) - delta = PercentToRadians(v.Value) + rads = Math.PercentToRadians(total) + delta = Math.PercentToRadians(v.Value) r.ArcTo(cx, cy, radius, radius, rads, delta) @@ -157,9 +144,9 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) { for index, v := range values { v.Style.InheritFrom(pc.stylePieChartValue(index)).WriteToRenderer(r) if len(v.Label) > 0 { - delta2 = PercentToRadians(total + (v.Value / 2.0)) - delta2 = RadianAdd(delta2, _pi2) - lx, ly = CirclePoint(cx, cy, labelRadius, delta2) + 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) @@ -180,7 +167,7 @@ func (pc PieChart) getDefaultCanvasBox() Box { } func (pc PieChart) getCircleAdjustedCanvasBox(canvasBox Box) Box { - circleDiameter := MinInt(canvasBox.Width(), canvasBox.Height()) + circleDiameter := Math.MinInt(canvasBox.Width(), canvasBox.Height()) square := Box{ Right: circleDiameter, @@ -226,7 +213,7 @@ func (pc PieChart) stylePieChartValue(index int) Style { } func (pc PieChart) getScaledFontSize() float64 { - effectiveDimension := MinInt(pc.GetWidth(), pc.GetHeight()) + effectiveDimension := Math.MinInt(pc.GetWidth(), pc.GetHeight()) if effectiveDimension >= 2048 { return 48.0 } else if effectiveDimension >= 1024 { @@ -253,6 +240,17 @@ func (pc PieChart) styleDefaultsElements() Style { } } +func (pc PieChart) styleDefaultsTitle() Style { + return pc.TitleStyle.InheritFrom(Style{ + FontColor: DefaultTextColor, + Font: pc.GetFont(), + FontSize: 24.0, + TextHorizontalAlign: TextHorizontalAlignCenter, + TextVerticalAlign: TextVerticalAlignTop, + TextWrap: TextWrapNone, + }) +} + // Box returns the chart bounds as a box. func (pc PieChart) Box() Box { dpr := pc.Background.Padding.GetRight(DefaultBackgroundPadding.Right) 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/sma_series.go b/sma_series.go index 9538d3b..a7e0034 100644 --- a/sma_series.go +++ b/sma_series.go @@ -72,7 +72,7 @@ func (sma SMASeries) GetLastValue() (x, y float64) { 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-- { 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/style.go b/style.go index 1f506d5..78fca60 100644 --- a/style.go +++ b/style.go @@ -25,6 +25,7 @@ type Style struct { TextHorizontalAlign textHorizontalAlign TextVerticalAlign textVerticalAlign TextWrap textWrap + TextLineSpacing int } // IsZero returns if the object is set or not. @@ -222,6 +223,17 @@ func (s Style) GetTextWrap(defaults ...textWrap) textWrap { 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()) @@ -261,6 +273,7 @@ func (s Style) InheritFrom(defaults Style) (final Style) { 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 } @@ -299,5 +312,6 @@ func (s Style) GetTextOptions() Style { TextHorizontalAlign: s.TextHorizontalAlign, TextVerticalAlign: s.TextVerticalAlign, TextWrap: s.TextWrap, + TextLineSpacing: s.TextLineSpacing, } } diff --git a/text.go b/text.go index 7011095..6e45c1c 100644 --- a/text.go +++ b/text.go @@ -64,15 +64,12 @@ type TextStyle struct { type text struct{} -func (t text) WrapFit(r Renderer, value string, width int, style Style, wrapOption textWrap) []string { - valueBox := r.MeasureText(value) - if valueBox.Width() > width { - switch wrapOption { - case TextWrapRune: - return t.WrapFitRune(r, value, width, style) - case TextWrapWord: - return t.WrapFitWord(r, value, width, style) - } +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} } @@ -143,6 +140,20 @@ 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} diff --git a/text_test.go b/text_test.go index b1f577b..78c0e9b 100644 --- a/text_test.go +++ b/text_test.go @@ -25,8 +25,36 @@ func TestTextWrapWord(t *testing.T) { 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/value.go b/value.go index be436b5..8a1da0d 100644 --- a/value.go +++ b/value.go @@ -21,18 +21,18 @@ func (vs Values) Values() []float64 { // ValuesNormalized returns normalized values. func (vs Values) ValuesNormalized() []float64 { - return Normalize(vs.Values()...) + return Math.Normalize(vs.Values()...) } // Normalize returns the values normalized. func (vs Values) Normalize() []Value { output := make([]Value, len(vs)) - total := Sum(vs.Values()...) + total := Math.Sum(vs.Values()...) for index, v := range vs { output[index] = Value{ Style: v.Style, Label: v.Label, - Value: RoundDown(v.Value/total, 0.0001), + Value: Math.RoundDown(v.Value/total, 0.0001), } } return output diff --git a/vector_renderer.go b/vector_renderer.go index 3f3f359..7e50dc8 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -83,8 +83,8 @@ func (vr *vectorRenderer) QuadCurveTo(cx, cy, x, y int) { } func (vr *vectorRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) { - startAngle = RadianAdd(startAngle, _pi2) - endAngle := RadianAdd(startAngle, delta) + startAngle = Math.RadianAdd(startAngle, _pi2) + endAngle := Math.RadianAdd(startAngle, delta) startx := cx + int(rx*math.Sin(startAngle)) starty := cy - int(ry*math.Cos(startAngle)) @@ -98,7 +98,7 @@ func (vr *vectorRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) { endx := cx + int(rx*math.Sin(endAngle)) endy := cy - int(ry*math.Cos(endAngle)) - dd := RadiansToDegrees(delta) + 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)) } diff --git a/xaxis.go b/xaxis.go index 5a1ddd4..b229008 100644 --- a/xaxis.go +++ b/xaxis.go @@ -66,10 +66,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{ diff --git a/yaxis.go b/yaxis.go index 7c1e26f..b9c16d3 100644 --- a/yaxis.go +++ b/yaxis.go @@ -85,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{ From a008ebe30e0177dcf1fe6d479e538504c34998ef Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 29 Jul 2016 18:40:43 -0700 Subject: [PATCH 45/55] tweaks. --- draw.go | 2 +- examples/pie_chart/main.go | 3 +-- pie_chart.go | 20 +++++++++++++++++--- style.go | 6 +++--- text.go | 6 +++++- 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/draw.go b/draw.go index de61c1d..21d58df 100644 --- a/draw.go +++ b/draw.go @@ -245,7 +245,7 @@ func (d draw) TextWithin(r Renderer, text string, box Box, style Style) { lineBox := r.MeasureText(line) switch style.GetTextHorizontalAlign() { case TextHorizontalAlignCenter: - tx = box.Left + ((lineBox.Width() - box.Left) >> 1) + tx = box.Left + ((box.Width() - lineBox.Width()) >> 1) case TextHorizontalAlignRight: tx = box.Right - lineBox.Width() default: diff --git a/examples/pie_chart/main.go b/examples/pie_chart/main.go index e4b3967..782bac1 100644 --- a/examples/pie_chart/main.go +++ b/examples/pie_chart/main.go @@ -12,8 +12,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { pie := chart.PieChart{ Title: "test\nchart", TitleStyle: chart.Style{ - Show: true, - FontSize: 32, + Show: true, }, Width: 512, Height: 512, diff --git a/pie_chart.go b/pie_chart.go index d67bac6..720f61d 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -242,15 +242,29 @@ func (pc PieChart) styleDefaultsElements() Style { func (pc PieChart) styleDefaultsTitle() Style { return pc.TitleStyle.InheritFrom(Style{ - FontColor: DefaultTextColor, + FontColor: ColorWhite, Font: pc.GetFont(), - FontSize: 24.0, + FontSize: pc.getTitleFontSize(), TextHorizontalAlign: TextHorizontalAlignCenter, TextVerticalAlign: TextVerticalAlignTop, - TextWrap: TextWrapNone, + 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) diff --git a/style.go b/style.go index 78fca60..a9eb1c5 100644 --- a/style.go +++ b/style.go @@ -196,7 +196,7 @@ func (s Style) GetTextHorizontalAlign(defaults ...textHorizontalAlign) textHoriz if len(defaults) > 0 { return defaults[0] } - return TextHorizontalAlignLeft + return TextHorizontalAlignUnset } return s.TextHorizontalAlign } @@ -207,7 +207,7 @@ func (s Style) GetTextVerticalAlign(defaults ...textVerticalAlign) textVerticalA if len(defaults) > 0 { return defaults[0] } - return TextVerticalAlignBaseline + return TextVerticalAlignUnset } return s.TextVerticalAlign } @@ -218,7 +218,7 @@ func (s Style) GetTextWrap(defaults ...textWrap) textWrap { if len(defaults) > 0 { return defaults[0] } - return TextWrapWord + return TextWrapUnset } return s.TextWrap } diff --git a/text.go b/text.go index 6e45c1c..f1d73d2 100644 --- a/text.go +++ b/text.go @@ -1,6 +1,9 @@ package chart -import "strings" +import ( + "fmt" + "strings" +) // TextHorizontalAlign is an enum for the horizontal alignment options. type textHorizontalAlign int @@ -71,6 +74,7 @@ func (t text) WrapFit(r Renderer, value string, width int, style Style) []string case TextWrapWord: return t.WrapFitWord(r, value, width, style) } + fmt.Printf("text wrap: %#v\n", style.TextWrap) return []string{value} } From e44cdd5600d5d316c11e1bb8e4c0ea8ec8889f65 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 29 Jul 2016 19:35:18 -0700 Subject: [PATCH 46/55] where did my ticks go. --- examples/stacked_bar/main.go | 8 ++ stacked_bar_chart.go | 137 +++++++++++++++++++++++++++++------ text.go | 6 +- 3 files changed, 123 insertions(+), 28 deletions(-) diff --git a/examples/stacked_bar/main.go b/examples/stacked_bar/main.go index 2bdbb9b..5096f61 100644 --- a/examples/stacked_bar/main.go +++ b/examples/stacked_bar/main.go @@ -13,8 +13,15 @@ func drawChart(res http.ResponseWriter, req *http.Request) { Background: chart.Style{ Padding: chart.Box{Top: 50, Left: 50, Right: 50, Bottom: 50}, }, + XAxis: chart.Style{ + Show: true, + }, + YAxis: chart.Style{ + Show: true, + }, Bars: []chart.StackedBar{ { + Name: "Funnel", Values: []chart.Value{ {Value: 5, Label: "Blue"}, {Value: 5, Label: "Green"}, @@ -26,6 +33,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { }, }, { + Name: "Test", Values: []chart.Value{ {Value: 10, Label: "Blue"}, {Value: 5, Label: "Green"}, diff --git a/stacked_bar_chart.go b/stacked_bar_chart.go index f15e21b..1fe22e2 100644 --- a/stacked_bar_chart.go +++ b/stacked_bar_chart.go @@ -2,6 +2,7 @@ package chart import ( "errors" + "fmt" "io" "github.com/golang/freetype/truetype" @@ -17,7 +18,7 @@ type StackedBar struct { // GetWidth returns the width of the bar. func (sb StackedBar) GetWidth() int { if sb.Width == 0 { - return 20 + return 50 } return sb.Width } @@ -34,6 +35,9 @@ type StackedBarChart struct { Background Style Canvas Style + XAxis Style + YAxis Style + BarSpacing int Font *truetype.Font @@ -108,6 +112,8 @@ func (sbc StackedBarChart) Render(rp RendererProvider, w io.Writer) error { canvasBox := sbc.getAdjustedCanvasBox(sbc.getDefaultCanvasBox()) sbc.drawBars(r, canvasBox) + sbc.drawXAxis(r, canvasBox) + sbc.drawYAxis(r, canvasBox) sbc.drawTitle(r) for _, a := range sbc.Elements { @@ -126,8 +132,8 @@ func (sbc StackedBarChart) drawBars(r Renderer, canvasBox Box) { } func (sbc StackedBarChart) drawBar(r Renderer, canvasBox Box, xoffset int, bar StackedBar) int { - bxl := xoffset - bxr := xoffset + bar.GetWidth() + bxl := xoffset + Math.AbsInt(bar.GetWidth()>>1-sbc.GetBarSpacing()>>1) + bxr := bxl + bar.GetWidth() normalizedBarComponents := Values(bar.Values).Normalize() yoffset := canvasBox.Top @@ -141,22 +147,71 @@ func (sbc StackedBarChart) drawBar(r Renderer, canvasBox Box, xoffset int, bar S 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 { + + spacing := (sbc.GetBarSpacing() >> 1) + + barLabelBox := Box{ + Top: canvasBox.Bottom + DefaultXAxisMargin, + Left: cursor, + Right: cursor + bar.GetWidth() + spacing, + 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() + spacing + } + } +} + +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 { + ty := canvasBox.Bottom - int(t*float64(canvasBox.Height())) + r.MoveTo(canvasBox.Right, ty) + r.LineTo(canvasBox.Right+DefaultHorizontalTickWidth, ty) + r.Stroke() + + text := fmt.Sprintf("%0.0f%%", t*100) + Draw.Text(r, text, canvasBox.Right+DefaultYAxisMargin, ty, axisStyle) + } + + } +} + func (sbc StackedBarChart) drawTitle(r Renderer) { if len(sbc.Title) > 0 && sbc.TitleStyle.Show { - r.SetFont(sbc.TitleStyle.GetFont(sbc.GetFont())) - r.SetFontColor(sbc.TitleStyle.GetFontColor(DefaultTextColor)) - titleFontSize := sbc.TitleStyle.GetFontSize(DefaultTitleFontSize) - r.SetFontSize(titleFontSize) - - textBox := r.MeasureText(sbc.Title) - - textWidth := textBox.Width() - textHeight := textBox.Height() - - titleX := (sbc.GetWidth() >> 1) - (textWidth >> 1) - titleY := sbc.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight - - r.Text(sbc.Title, titleX, titleY) + Draw.TextWithin(r, sbc.Title, sbc.Box(), sbc.styleDefaultsTitle()) } } @@ -173,22 +228,22 @@ func (sbc StackedBarChart) getAdjustedCanvasBox(canvasBox Box) Box { } } - return canvasBox.OuterConstrain(sbc.Box(), Box{ + 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(DefaultBackgroundPadding.Right) - dpb := sbc.Background.Padding.GetBottom(DefaultBackgroundPadding.Bottom) + dpr := sbc.Background.Padding.GetRight(10) + dpb := sbc.Background.Padding.GetBottom(50) return Box{ - Top: sbc.Background.Padding.GetTop(DefaultBackgroundPadding.Top), - Left: sbc.Background.Padding.GetLeft(DefaultBackgroundPadding.Left), + Top: 20, + Left: 20, Right: sbc.GetWidth() - dpr, Bottom: sbc.GetHeight() - dpb, } @@ -202,6 +257,42 @@ func (sbc StackedBarChart) styleDefaultsStackedBarValue(index int) Style { } } +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/text.go b/text.go index f1d73d2..6e45c1c 100644 --- a/text.go +++ b/text.go @@ -1,9 +1,6 @@ package chart -import ( - "fmt" - "strings" -) +import "strings" // TextHorizontalAlign is an enum for the horizontal alignment options. type textHorizontalAlign int @@ -74,7 +71,6 @@ func (t text) WrapFit(r Renderer, value string, width int, style Style) []string case TextWrapWord: return t.WrapFitWord(r, value, width, style) } - fmt.Printf("text wrap: %#v\n", style.TextWrap) return []string{value} } From 1269397821ad4a7cd81df957ead800c93f076de5 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 29 Jul 2016 19:38:37 -0700 Subject: [PATCH 47/55] sbc looking alright, need to debug bar gaps at the bottom --- examples/stacked_bar/main.go | 4 ++-- stacked_bar_chart.go | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/stacked_bar/main.go b/examples/stacked_bar/main.go index 5096f61..ddc3700 100644 --- a/examples/stacked_bar/main.go +++ b/examples/stacked_bar/main.go @@ -26,9 +26,9 @@ func drawChart(res http.ResponseWriter, req *http.Request) { {Value: 5, Label: "Blue"}, {Value: 5, Label: "Green"}, {Value: 4, Label: "Gray"}, - {Value: 4, Label: "Orange"}, + {Value: 3, Label: "Orange"}, {Value: 3, Label: "Test"}, - {Value: 3, Label: "??"}, + {Value: 2, Label: "??"}, {Value: 1, Label: "!!"}, }, }, diff --git a/stacked_bar_chart.go b/stacked_bar_chart.go index 1fe22e2..cbf093e 100644 --- a/stacked_bar_chart.go +++ b/stacked_bar_chart.go @@ -197,13 +197,17 @@ func (sbc StackedBarChart) drawYAxis(r Renderer, canvasBox Box) { 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) - Draw.Text(r, text, canvasBox.Right+DefaultYAxisMargin, ty, axisStyle) + + tb := r.MeasureText(text) + Draw.Text(r, text, canvasBox.Right+DefaultYAxisMargin+5, ty+(tb.Height()>>1), axisStyle) } } From e5cc7f9e9cfde743bfa365a1250905c8d5d590c8 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 29 Jul 2016 19:40:41 -0700 Subject: [PATCH 48/55] looking spiffy. --- stacked_bar_chart.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/stacked_bar_chart.go b/stacked_bar_chart.go index cbf093e..b719e0c 100644 --- a/stacked_bar_chart.go +++ b/stacked_bar_chart.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "math" "github.com/golang/freetype/truetype" ) @@ -138,8 +139,8 @@ func (sbc StackedBarChart) drawBar(r Renderer, canvasBox Box, xoffset int, bar S normalizedBarComponents := Values(bar.Values).Normalize() yoffset := canvasBox.Top for index, bv := range normalizedBarComponents { - barHeight := int(bv.Value * float64(canvasBox.Height())) - barBox := Box{Top: yoffset, Left: bxl, Right: bxr, Bottom: yoffset + barHeight} + 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 } From 8b6afae778136376ab7b28fe3c0827945e42841c Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 29 Jul 2016 23:13:33 -0700 Subject: [PATCH 49/55] fixed spacing issues. --- examples/stacked_bar/main.go | 8 ++++++++ stacked_bar_chart.go | 25 +++++++++++++------------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/examples/stacked_bar/main.go b/examples/stacked_bar/main.go index ddc3700..bbf61a8 100644 --- a/examples/stacked_bar/main.go +++ b/examples/stacked_bar/main.go @@ -40,6 +40,14 @@ func drawChart(res http.ResponseWriter, req *http.Request) { {Value: 1, Label: "Gray"}, }, }, + { + Name: "Test 2", + Values: []chart.Value{ + {Value: 10, Label: "Blue"}, + {Value: 5, Label: "Green"}, + {Value: 1, Label: "Gray"}, + }, + }, }, } diff --git a/stacked_bar_chart.go b/stacked_bar_chart.go index b719e0c..94a7d9f 100644 --- a/stacked_bar_chart.go +++ b/stacked_bar_chart.go @@ -128,19 +128,25 @@ 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() + xoffset += (sbc.GetBarSpacing() + bar.GetWidth()) } } func (sbc StackedBarChart) drawBar(r Renderer, canvasBox Box, xoffset int, bar StackedBar) int { - bxl := xoffset + Math.AbsInt(bar.GetWidth()>>1-sbc.GetBarSpacing()>>1) + 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)} + 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 } @@ -164,12 +170,10 @@ func (sbc StackedBarChart) drawXAxis(r Renderer, canvasBox Box) { cursor := canvasBox.Left for _, bar := range sbc.Bars { - spacing := (sbc.GetBarSpacing() >> 1) - barLabelBox := Box{ Top: canvasBox.Bottom + DefaultXAxisMargin, Left: cursor, - Right: cursor + bar.GetWidth() + spacing, + Right: cursor + bar.GetWidth() + sbc.GetBarSpacing(), Bottom: sbc.GetHeight(), } if len(bar.Name) > 0 { @@ -179,7 +183,7 @@ func (sbc StackedBarChart) drawXAxis(r Renderer, canvasBox Box) { r.MoveTo(barLabelBox.Right, canvasBox.Bottom) r.LineTo(barLabelBox.Right, canvasBox.Bottom+DefaultVerticalTickHeight) r.Stroke() - cursor += bar.GetWidth() + spacing + cursor += bar.GetWidth() + sbc.GetBarSpacing() } } } @@ -226,11 +230,8 @@ func (sbc StackedBarChart) getDefaultCanvasBox() Box { func (sbc StackedBarChart) getAdjustedCanvasBox(canvasBox Box) Box { var totalWidth int - for index, bar := range sbc.Bars { - totalWidth += bar.GetWidth() - if index < len(sbc.Bars)-1 { - totalWidth += sbc.GetBarSpacing() - } + for _, bar := range sbc.Bars { + totalWidth += bar.GetWidth() + sbc.GetBarSpacing() } return Box{ From 12d115cca5b47db0fe49f0247a225351e9119c85 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 29 Jul 2016 23:31:49 -0700 Subject: [PATCH 50/55] examples. --- defaults.go | 6 +++++- examples/stacked_bar/main.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/defaults.go b/defaults.go index e1cdb69..4c9fd1f 100644 --- a/defaults.go +++ b/defaults.go @@ -147,7 +147,11 @@ var ( ColorAlternateGreen, ColorAlternateGray, ColorAlternateYellow, - ColorAlternateLightGray, + ColorBlue, + ColorGreen, + ColorRed, + ColorCyan, + ColorOrange, } ) diff --git a/examples/stacked_bar/main.go b/examples/stacked_bar/main.go index bbf61a8..78cff79 100644 --- a/examples/stacked_bar/main.go +++ b/examples/stacked_bar/main.go @@ -21,7 +21,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { }, Bars: []chart.StackedBar{ { - Name: "Funnel", + Name: "Katrina like animals that are very fat and furry.", Values: []chart.Value{ {Value: 5, Label: "Blue"}, {Value: 5, Label: "Green"}, From c2d680968d32bd808209bec3a128512b2acf31ec Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 29 Jul 2016 23:39:58 -0700 Subject: [PATCH 51/55] tests --- examples/stacked_bar/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/stacked_bar/main.go b/examples/stacked_bar/main.go index 78cff79..f3893aa 100644 --- a/examples/stacked_bar/main.go +++ b/examples/stacked_bar/main.go @@ -44,8 +44,8 @@ func drawChart(res http.ResponseWriter, req *http.Request) { Name: "Test 2", Values: []chart.Value{ {Value: 10, Label: "Blue"}, - {Value: 5, Label: "Green"}, - {Value: 1, Label: "Gray"}, + {Value: 6, Label: "Green"}, + {Value: 4, Label: "Gray"}, }, }, }, From 9b4307e186a0deaab337286a76357d9bb533b712 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 30 Jul 2016 09:12:03 -0700 Subject: [PATCH 52/55] axis tick position --- annotation_series.go | 4 +-- axis.go | 17 +++++++++--- bollinger_band_series.go | 4 +-- continuous_series.go | 4 +-- draw.go | 3 +-- ema_series.go | 4 +-- examples/stock_analysis/main.go | 3 ++- histogram_series.go | 4 +-- linear_regression_series.go | 4 +-- macd_series.go | 12 ++++----- series.go | 2 +- sma_series.go | 4 +-- time_series.go | 4 +-- xaxis.go | 46 +++++++++++++++++++++++++++++---- yaxis.go | 2 +- 15 files changed, 82 insertions(+), 35 deletions(-) diff --git a/annotation_series.go b/annotation_series.go index 9b383c9..3e97d82 100644 --- a/annotation_series.go +++ b/annotation_series.go @@ -6,7 +6,7 @@ import "math" type AnnotationSeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType Annotations []Value2 } @@ -21,7 +21,7 @@ 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 } diff --git a/axis.go b/axis.go index 1c6d272..e607cea 100644 --- a/axis.go +++ b/axis.go @@ -1,13 +1,24 @@ 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. diff --git a/bollinger_band_series.go b/bollinger_band_series.go index f74b489..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 } diff --git a/continuous_series.go b/continuous_series.go index fe8d068..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,7 +44,7 @@ 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 } diff --git a/draw.go b/draw.go index 21d58df..eb9ec6a 100644 --- a/draw.go +++ b/draw.go @@ -62,8 +62,6 @@ func (d draw) BoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, sty drawOffsetIndex = drawOffsetIndexes[0] } - style.WriteToRenderer(r) - cb := canvasBox.Bottom cl := canvasBox.Left @@ -79,6 +77,7 @@ func (d draw) BoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, sty 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) diff --git a/ema_series.go b/ema_series.go index affadc1..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 } diff --git a/examples/stock_analysis/main.go b/examples/stock_analysis/main.go index fd131ae..3f17025 100644 --- a/examples/stock_analysis/main.go +++ b/examples/stock_analysis/main.go @@ -43,7 +43,8 @@ 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}, diff --git a/histogram_series.go b/histogram_series.go index 0542c1a..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 } diff --git a/linear_regression_series.go b/linear_regression_series.go index a33a0b1..06c8c4e 100644 --- a/linear_regression_series.go +++ b/linear_regression_series.go @@ -5,7 +5,7 @@ package chart type LinearRegressionSeries struct { Name string Style Style - YAxis YAxisType + YAxis yAxisType Window int Offset int @@ -28,7 +28,7 @@ func (lrs LinearRegressionSeries) GetStyle() Style { } // GetYAxis returns which YAxis the series draws on. -func (lrs LinearRegressionSeries) GetYAxis() YAxisType { +func (lrs LinearRegressionSeries) GetYAxis() yAxisType { return lrs.YAxis } diff --git a/macd_series.go b/macd_series.go index b3b80c0..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 } @@ -200,7 +200,7 @@ func (macds *MACDSignalSeries) Render(r Renderer, canvasBox Box, xrange, yrange 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 } 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 a7e0034..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 } diff --git a/time_series.go b/time_series.go index df779eb..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,7 +50,7 @@ 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 } diff --git a/xaxis.go b/xaxis.go index b229008..31013e7 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,6 +30,17 @@ func (xa XAxis) GetStyle() Style { return xa.Style } +// 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). @@ -82,25 +95,48 @@ 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) { - xa.Style.InheritFrom(defaults).WriteToRenderer(r) + 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 { diff --git a/yaxis.go b/yaxis.go index b9c16d3..de9cc3a 100644 --- a/yaxis.go +++ b/yaxis.go @@ -13,7 +13,7 @@ type YAxis struct { Zero GridLine - AxisType YAxisType + AxisType yAxisType ValueFormatter ValueFormatter Range Range From 9c96af2a22af34762b9e4f5ddbc501adac792d45 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 30 Jul 2016 09:35:44 -0700 Subject: [PATCH 53/55] fixing fit issues on the xaxis labels for stacked bar. --- draw.go | 4 +--- examples/stacked_bar/main.go | 5 +---- stacked_bar_chart.go | 33 +++++++++++++++++++++++++++++++-- tick.go | 8 +++++++- xaxis.go | 2 +- yaxis.go | 2 +- 6 files changed, 42 insertions(+), 12 deletions(-) diff --git a/draw.go b/draw.go index eb9ec6a..ed6039b 100644 --- a/draw.go +++ b/draw.go @@ -41,9 +41,7 @@ func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, style r.Fill() } - r.SetStrokeColor(style.GetStrokeColor()) - r.SetStrokeDashArray(style.GetStrokeDashArray()) - r.SetStrokeWidth(style.GetStrokeWidth()) + style.GetStrokeOptions().WriteToRenderer(r) r.MoveTo(x0, y0) for i := 1; i < vs.Len(); i++ { diff --git a/examples/stacked_bar/main.go b/examples/stacked_bar/main.go index f3893aa..ef65ca2 100644 --- a/examples/stacked_bar/main.go +++ b/examples/stacked_bar/main.go @@ -10,9 +10,6 @@ import ( func drawChart(res http.ResponseWriter, req *http.Request) { sbc := chart.StackedBarChart{ - Background: chart.Style{ - Padding: chart.Box{Top: 50, Left: 50, Right: 50, Bottom: 50}, - }, XAxis: chart.Style{ Show: true, }, @@ -21,7 +18,7 @@ func drawChart(res http.ResponseWriter, req *http.Request) { }, Bars: []chart.StackedBar{ { - Name: "Katrina like animals that are very fat and furry.", + Name: "This is a very long string to test word break wrapping.", Values: []chart.Value{ {Value: 5, Label: "Blue"}, {Value: 5, Label: "Green"}, diff --git a/stacked_bar_chart.go b/stacked_bar_chart.go index 94a7d9f..511be46 100644 --- a/stacked_bar_chart.go +++ b/stacked_bar_chart.go @@ -111,7 +111,7 @@ func (sbc StackedBarChart) Render(rp RendererProvider, w io.Writer) error { } r.SetDPI(sbc.GetDPI(DefaultDPI)) - canvasBox := sbc.getAdjustedCanvasBox(sbc.getDefaultCanvasBox()) + canvasBox := sbc.getAdjustedCanvasBox(r, sbc.getDefaultCanvasBox()) sbc.drawBars(r, canvasBox) sbc.drawXAxis(r, canvasBox) sbc.drawYAxis(r, canvasBox) @@ -228,18 +228,47 @@ func (sbc StackedBarChart) getDefaultCanvasBox() Box { return sbc.Box() } -func (sbc StackedBarChart) getAdjustedCanvasBox(canvasBox Box) 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. diff --git a/tick.go b/tick.go index 2428bec..f50f7f2 100644 --- a/tick.go +++ b/tick.go @@ -32,7 +32,7 @@ func (t Ticks) Less(i, j int) bool { } // GenerateContinuousTicksWithStep generates a set of ticks. -func GenerateContinuousTicksWithStep(ra Range, step float64, vf ValueFormatter) []Tick { +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 { @@ -46,6 +46,12 @@ func GenerateContinuousTicksWithStep(ra Range, step float64, vf ValueFormatter) return ticks } } + if includeMax { + ticks = append(ticks, Tick{ + Value: ra.GetMax(), + Label: vf(ra.GetMax()), + }) + } return ticks } diff --git a/xaxis.go b/xaxis.go index 31013e7..80162cc 100644 --- a/xaxis.go +++ b/xaxis.go @@ -54,7 +54,7 @@ func (xa XAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter return tp.GetTicks(vf) } step := CalculateContinuousTickStep(r, ra, false, xa.Style.InheritFrom(defaults), vf) - return GenerateContinuousTicksWithStep(ra, step, vf) + return GenerateContinuousTicksWithStep(ra, step, vf, xa.TickPosition == TickPositionBetweenTicks) } // GetGridLines returns the gridlines for the axis. diff --git a/yaxis.go b/yaxis.go index de9cc3a..4200b04 100644 --- a/yaxis.go +++ b/yaxis.go @@ -48,7 +48,7 @@ func (ya YAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter return tp.GetTicks(vf) } step := CalculateContinuousTickStep(r, ra, true, ya.Style.InheritFrom(defaults), vf) - return GenerateContinuousTicksWithStep(ra, step, vf) + return GenerateContinuousTicksWithStep(ra, step, vf, true) } // GetGridLines returns the gridlines for the axis. From bf38f3e71813ad4d0ec59e5950e98b514e641318 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 30 Jul 2016 09:38:43 -0700 Subject: [PATCH 54/55] tests are pretty helpful --- examples/stacked_bar/main.go | 5 +++-- images/stacked_bar.png | Bin 0 -> 16957 bytes tick_test.go | 2 +- yaxis_test.go | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 images/stacked_bar.png diff --git a/examples/stacked_bar/main.go b/examples/stacked_bar/main.go index ef65ca2..07304bc 100644 --- a/examples/stacked_bar/main.go +++ b/examples/stacked_bar/main.go @@ -10,6 +10,7 @@ import ( func drawChart(res http.ResponseWriter, req *http.Request) { sbc := chart.StackedBarChart{ + Height: 512, XAxis: chart.Style{ Show: true, }, @@ -48,8 +49,8 @@ func drawChart(res http.ResponseWriter, req *http.Request) { }, } - res.Header().Set("Content-Type", "image/svg+xml") - err := sbc.Render(chart.SVG, res) + res.Header().Set("Content-Type", "image/png") + err := sbc.Render(chart.PNG, res) if err != nil { fmt.Printf("Error rendering chart: %v\n", err) } diff --git a/images/stacked_bar.png b/images/stacked_bar.png new file mode 100644 index 0000000000000000000000000000000000000000..4b0e9ae32c4af3349c69196ec706a83ba6324b3a GIT binary patch literal 16957 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 I1;?oW0@1$<@c;k- literal 0 HcmV?d00001 diff --git a/tick_test.go b/tick_test.go index ed04595..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 := GenerateContinuousTicksWithStep(&ContinuousRange{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/yaxis_test.go b/yaxis_test.go index 1941df5..7415b5c 100644 --- a/yaxis_test.go +++ b/yaxis_test.go @@ -23,7 +23,7 @@ func TestYAxisGetTicks(t *testing.T) { } vf := FloatValueFormatter ticks := ya.GetTicks(r, yr, styleDefaults, vf) - assert.Len(ticks, 35) + assert.Len(ticks, 36) } func TestYAxisGetTicksWithUserDefaults(t *testing.T) { From 60a9a3d6fcd4d1695a271dda5e0f4b6a7df76e15 Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Sat, 30 Jul 2016 09:39:50 -0700 Subject: [PATCH 55/55] readme. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index b6ab99c..c932208 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ Pie Chart: 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 Actual chart configurations and examples can be found in the `./examples/` directory. They are web servers, so start them with `go run main.go` then access `http://localhost:8080` to see the output.