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 14a171d..4b2740e 100644 Binary files a/images/pie_chart.png and b/images/pie_chart.png differ diff --git a/legend.go b/legend.go new file mode 100644 index 0000000..82b5286 --- /dev/null +++ b/legend.go @@ -0,0 +1,116 @@ +package chart + +import "github.com/wcharczuk/go-chart/drawing" + +// Legend returns a legend renderable function. +func Legend(c *Chart, userDefaults ...Style) Renderable { + return func(r Renderer, cb Box, chartDefaults Style) { + legendDefaults := Style{ + FillColor: drawing.ColorWhite, + FontColor: DefaultTextColor, + FontSize: 8.0, + StrokeColor: DefaultAxisColor, + StrokeWidth: DefaultAxisLineWidth, + } + + var legendStyle Style + if len(userDefaults) > 0 { + legendStyle = userDefaults[0].InheritFrom(chartDefaults.InheritFrom(legendDefaults)) + } else { + legendStyle = chartDefaults.InheritFrom(legendDefaults) + } + + // DEFAULTS + legendPadding := Box{ + Top: 5, + Left: 5, + Right: 5, + Bottom: 5, + } + lineTextGap := 5 + lineLengthMinimum := 25 + + var labels []string + var lines []Style + for index, s := range c.Series { + if s.GetStyle().IsZero() || s.GetStyle().Show { + if _, isAnnotationSeries := s.(AnnotationSeries); !isAnnotationSeries { + labels = append(labels, s.GetName()) + lines = append(lines, s.GetStyle().InheritFrom(c.styleDefaultsSeries(index))) + } + } + } + + legend := Box{ + Top: cb.Top, + Left: cb.Left, + // bottom and right will be sized by the legend content + relevant padding. + } + + legendContent := Box{ + Top: legend.Top + legendPadding.Top, + Left: legend.Left + legendPadding.Left, + Right: legend.Left + legendPadding.Left, + Bottom: legend.Top + legendPadding.Top, + } + + r.SetFont(legendStyle.GetFont()) + r.SetFontColor(legendStyle.GetFontColor()) + r.SetFontSize(legendStyle.GetFontSize()) + + // measure + labelCount := 0 + for x := 0; x < len(labels); x++ { + if len(labels[x]) > 0 { + tb := r.MeasureText(labels[x]) + if labelCount > 0 { + legendContent.Bottom += DefaultMinimumTickVerticalSpacing + } + legendContent.Bottom += tb.Height() + right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum + legendContent.Right = 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))