diff --git a/README.md b/README.md index 96b8c2d..bda5050 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ go-chart Package `chart` is a very simple golang native charting library that supports timeseries and continuous line charts. +The API is still in a bit of flux, so it is adviseable to wait until I tag a v1.0 release before using +in a production capacity. + # Installation To install `chart` run the following: diff --git a/annotation_series.go b/annotation_series.go index 22ed729..873d1d2 100644 --- a/annotation_series.go +++ b/annotation_series.go @@ -49,21 +49,13 @@ func (as AnnotationSeries) Measure(r Renderer, canvasBox Box, xrange, yrange Ran Padding: DefaultAnnotationPadding, }) for _, a := range as.Annotations { - lx := canvasBox.Right - xrange.Translate(a.X) - ly := yrange.Translate(a.Y) + canvasBox.Top - ab := MeasureAnnotation(r, canvasBox, xrange, yrange, style, lx, ly, a.Label) - if ab.Top < box.Top { - box.Top = ab.Top - } - if ab.Left < box.Left { - box.Left = ab.Left - } - if ab.Right > box.Right { - box.Right = ab.Right - } - if ab.Bottom > box.Bottom { - box.Bottom = ab.Bottom - } + lx := canvasBox.Left - xrange.Translate(a.X) + ly := canvasBox.Bottom - yrange.Translate(a.Y) + ab := MeasureAnnotation(r, canvasBox, style, lx, ly, a.Label) + box.Top = MinInt(box.Top, ab.Top) + box.Left = MinInt(box.Left, ab.Left) + box.Right = MaxInt(box.Right, ab.Right) + box.Bottom = MaxInt(box.Bottom, ab.Bottom) } } return box @@ -81,9 +73,9 @@ func (as AnnotationSeries) Render(r Renderer, canvasBox Box, xrange, yrange Rang Padding: DefaultAnnotationPadding, }) for _, a := range as.Annotations { - lx := canvasBox.Right - xrange.Translate(a.X) - ly := yrange.Translate(a.Y) + canvasBox.Top - DrawAnnotation(r, canvasBox, xrange, yrange, style, lx, ly, a.Label) + lx := canvasBox.Left + xrange.Translate(a.X) + ly := canvasBox.Bottom - yrange.Translate(a.Y) + DrawAnnotation(r, canvasBox, style, lx, ly, a.Label) } } } diff --git a/chart.go b/chart.go index e7478ed..f1eb630 100644 --- a/chart.go +++ b/chart.go @@ -6,7 +6,6 @@ import ( "math" "github.com/golang/freetype/truetype" - "github.com/wcharczuk/go-chart/drawing" ) // Chart is what we're drawing. @@ -57,6 +56,8 @@ func (c Chart) Render(rp RendererProvider, w io.Writer) error { if len(c.Series) == 0 { return errors.New("Please provide at least one series") } + c.YAxisSecondary.AxisType = YAxisSecondary + r, err := rp(c.Width, c.Height) if err != nil { return err @@ -80,7 +81,7 @@ func (c Chart) Render(rp RendererProvider, w io.Writer) error { if c.hasAxes() { xt, yt, yta = c.getAxesTicks(r, xr, yr, yra, xf, yf, yfa) - canvasBox = c.getAxisAdjustedCanvasBox(r, canvasBox, xt, yt, yta) + canvasBox = c.getAxisAdjustedCanvasBox(r, canvasBox, xr, yr, yra, xt, yt, yta) xr, yr, yra = c.setRangeDomains(canvasBox, xr, yr, yra) xt, yt, yta = c.getAxesTicks(r, xr, yr, yra, xf, yf, yfa) } @@ -143,6 +144,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } else { xrange.Min = globalMinX xrange.Max = globalMaxX + xrange.Min, xrange.Max = xrange.GetRoundedRangeBounds() } if !c.YAxis.Range.IsZero() { @@ -151,6 +153,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } else { yrange.Min = globalMinY yrange.Max = globalMaxY + yrange.Min, yrange.Max = yrange.GetRoundedRangeBounds() } if !c.YAxisSecondary.Range.IsZero() { @@ -159,6 +162,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } else { yrangeAlt.Min = globalMinYA yrangeAlt.Max = globalMaxYA + yrangeAlt.Min, yrangeAlt.Max = yrangeAlt.GetRoundedRangeBounds() } return @@ -222,84 +226,63 @@ func (c Chart) getAxesTicks(r Renderer, xr, yr, yar Range, xf, yf, yfa ValueForm return } -func (c Chart) getAxisAdjustedCanvasBox(r Renderer, defaults Box, xticks, yticks, yticksAlt []Tick) Box { - canvasBox := Box{} - - var dpl, dpr, dpb int +func (c Chart) getAxisAdjustedCanvasBox(r Renderer, canvasBox Box, xr, yr, yra Range, xticks, yticks, yticksAlt []Tick) Box { + axesMinX, axesMaxX, axesMinY, axesMaxY := math.MaxInt32, 0, math.MaxInt32, 0 if c.XAxis.Style.Show { - dpb = c.getXAxisHeight(r, xticks) + axesBounds := c.XAxis.Measure(r, canvasBox, xr, xticks) + axesMinY = MinInt(axesMinX, axesBounds.Top) + axesMinX = MinInt(axesMinY, axesBounds.Left) + axesMaxX = MaxInt(axesMaxX, axesBounds.Right) + axesMaxY = MaxInt(axesMaxY, axesBounds.Bottom) } if c.YAxis.Style.Show { - dpr = c.getYAxisWidth(r, yticks) + axesBounds := c.YAxis.Measure(r, canvasBox, yr, yticks) + axesMinY = MinInt(axesMinX, axesBounds.Top) + axesMinX = MinInt(axesMinY, axesBounds.Left) + axesMaxX = MaxInt(axesMaxX, axesBounds.Right) + axesMaxY = MaxInt(axesMaxY, axesBounds.Bottom) } if c.YAxisSecondary.Style.Show { - dpl = c.getYAxisSecondaryWidth(r, yticksAlt) + axesBounds := c.YAxisSecondary.Measure(r, canvasBox, yra, yticksAlt) + axesMinY = MinInt(axesMinX, axesBounds.Top) + axesMinX = MinInt(axesMinY, axesBounds.Left) + axesMaxX = MaxInt(axesMaxX, axesBounds.Right) + axesMaxY = MaxInt(axesMaxY, axesBounds.Bottom) + } + newBox := Box{ + Top: canvasBox.Top, + Left: canvasBox.Left, + Right: canvasBox.Right, + Bottom: canvasBox.Bottom, } - canvasBox.Top = defaults.Top - if dpl != 0 { - canvasBox.Left = c.Canvas.Padding.GetLeft(dpl) - } else { - canvasBox.Left = defaults.Left - } - if dpr != 0 { - canvasBox.Right = c.Width - c.Canvas.Padding.GetRight(dpr) - } else { - canvasBox.Right = defaults.Right - } - if dpb != 0 { - canvasBox.Bottom = c.Height - c.Canvas.Padding.GetBottom(dpb) - } else { - canvasBox.Bottom = defaults.Bottom + if axesMinY < 0 { + // figure out how much top padding to add + delta := -1 * axesMinY + newBox.Top = canvasBox.Top + delta } - canvasBox.Width = canvasBox.Right - canvasBox.Left - canvasBox.Height = canvasBox.Bottom - canvasBox.Top - return canvasBox -} - -func (c Chart) getXAxisHeight(r Renderer, ticks []Tick) int { - r.SetFontSize(c.XAxis.Style.GetFontSize(DefaultFontSize)) - r.SetFont(c.XAxis.Style.GetFont(c.Font)) - - var textHeight int - for _, t := range ticks { - _, th := r.MeasureText(t.Label) - if th > textHeight { - textHeight = th - } - } - return textHeight + (2 * DefaultXAxisMargin) // top and bottom. -} - -func (c Chart) getYAxisWidth(r Renderer, ticks []Tick) int { - r.SetFontSize(c.YAxis.Style.GetFontSize(DefaultFontSize)) - r.SetFont(c.YAxis.Style.GetFont(c.Font)) - - var textWidth int - for _, t := range ticks { - tw, _ := r.MeasureText(t.Label) - if tw > textWidth { - textWidth = tw - } + if axesMinX < 0 { + // figure out how much left padding to add + delta := -1 * axesMinX + newBox.Left = canvasBox.Left + delta } - return textWidth + DefaultYAxisMargin -} - -func (c Chart) getYAxisSecondaryWidth(r Renderer, ticks []Tick) int { - r.SetFontSize(c.YAxisSecondary.Style.GetFontSize(DefaultFontSize)) - r.SetFont(c.YAxisSecondary.Style.GetFont(c.Font)) - - var textWidth int - for _, t := range ticks { - tw, _ := r.MeasureText(t.Label) - if tw > textWidth { - textWidth = tw - } + if axesMaxX > c.Width { + // figure out how much right padding to add + delta := axesMaxX - c.Width + newBox.Right = canvasBox.Right - delta } - return textWidth + DefaultYAxisMargin + if axesMaxY > c.Height { + //figure out how much bottom padding to add + delta := axesMaxY - c.Height + newBox.Bottom = canvasBox.Bottom - delta + } + + newBox.Height = newBox.Bottom - newBox.Top + newBox.Width = newBox.Right - newBox.Left + return newBox } func (c Chart) setRangeDomains(canvasBox Box, xrange, yrange, yrangeAlt Range) (Range, Range, Range) { @@ -333,21 +316,10 @@ func (c Chart) getAnnotationAdjustedCanvasBox(r Renderer, canvasBox Box, xr, yr, annotationBounds = as.Measure(r, canvasBox, xr, yra, style) } - if annotationMinY > annotationBounds.Top { - annotationMinY = annotationBounds.Top - } - - if annotationMinX > annotationBounds.Left { - annotationMinX = annotationBounds.Left - } - - if annotationMaxX < annotationBounds.Right { - annotationMaxX = annotationBounds.Right - } - - if annotationMaxY < annotationBounds.Bottom { - annotationMaxY = annotationBounds.Bottom - } + annotationMinY = MinInt(annotationMinY, annotationBounds.Top) + annotationMinX = MinInt(annotationMinX, annotationBounds.Left) + annotationMaxX = MaxInt(annotationMaxX, annotationBounds.Right) + annotationMaxY = MaxInt(annotationMaxY, annotationBounds.Bottom) } } } @@ -364,22 +336,21 @@ func (c Chart) getAnnotationAdjustedCanvasBox(r Renderer, canvasBox Box, xr, yr, newBox.Top = canvasBox.Top + delta } - if annotationMaxX > c.Width { - // figure out how much right padding to add - delta := annotationMaxX - c.Width - newBox.Right = canvasBox.Right - delta - } - if annotationMinX < 0 { // figure out how much left padding to add delta := -1 * annotationMinX newBox.Left = canvasBox.Left + delta } + if annotationMaxX > c.Width { + // figure out how much right padding to add + delta := annotationMaxX - c.Width + newBox.Right = canvasBox.Right - delta + } + if annotationMaxY > c.Height { //figure out how much bottom padding to add delta := annotationMaxY - c.Height - println("bottom delta", annotationMaxY, c.Height) newBox.Bottom = canvasBox.Bottom - delta } @@ -420,10 +391,10 @@ func (c Chart) drawAxes(r Renderer, canvasBox Box, xrange, yrange, yrangeAlt Ran c.XAxis.Render(r, canvasBox, xrange, xticks) } if c.YAxis.Style.Show { - c.YAxis.Render(r, canvasBox, yrange, YAxisPrimary, yticks) + c.YAxis.Render(r, canvasBox, yrange, yticks) } if c.YAxisSecondary.Style.Show { - c.YAxisSecondary.Render(r, canvasBox, yrangeAlt, YAxisSecondary, yticksAlt) + c.YAxisSecondary.Render(r, canvasBox, yrangeAlt, yticksAlt) } } @@ -453,10 +424,10 @@ func (c Chart) drawTitle(r Renderer) { titleFontSize := c.TitleStyle.GetFontSize(DefaultTitleFontSize) r.SetFontSize(titleFontSize) - textWidthPoints, textHeightPoints := r.MeasureText(c.Title) + textBox := r.MeasureText(c.Title) - textWidth := int(drawing.PointsToPixels(r.GetDPI(), float64(textWidthPoints))) - textHeight := int(drawing.PointsToPixels(r.GetDPI(), float64(textHeightPoints))) + textWidth := textBox.Width + textHeight := textBox.Height titleX := (c.Width >> 1) - (textWidth >> 1) titleY := c.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight diff --git a/defaults.go b/defaults.go index b529928..8fc8f21 100644 --- a/defaults.go +++ b/defaults.go @@ -38,11 +38,13 @@ const ( // DefaultXAxisMargin is the default distance from bottom of the canvas to the x axis labels. DefaultXAxisMargin = 10 - //DefaultVerticalTickWidth is half the margin. - DefaultVerticalTickWidth = DefaultYAxisMargin >> 1 - + //DefaultVerticalTickHeight is half the margin. + DefaultVerticalTickHeight = DefaultXAxisMargin >> 1 //DefaultHorizontalTickWidth is half the margin. - DefaultHorizontalTickWidth = DefaultXAxisMargin >> 1 + DefaultHorizontalTickWidth = DefaultYAxisMargin >> 1 + + // DefaultTickCount is the defautl number of ticks to show + DefaultTickCount = 10 // DefaultMinimumTickHorizontalSpacing is the minimum distance between horizontal ticks. DefaultMinimumTickHorizontalSpacing = 20 @@ -91,6 +93,17 @@ var ( } ) +var ( + // DashArrayDots is a dash array that represents '....' style stroke dashes. + DashArrayDots = []int{1, 1} + // DashArrayDashesSmall is a dash array that represents '- - -' style stroke dashes. + DashArrayDashesSmall = []int{3, 3} + // DashArrayDashesMedium is a dash array that represents '-- -- --' style stroke dashes. + DashArrayDashesMedium = []int{5, 5} + // DashArrayDashesLarge is a dash array that represents '----- ----- -----' style stroke dashes. + DashArrayDashesLarge = []int{10, 10} +) + // GetDefaultSeriesStrokeColor returns a color from the default list by index. // NOTE: the index will wrap around (using a modulo).g func GetDefaultSeriesStrokeColor(index int) drawing.Color { @@ -100,7 +113,7 @@ func GetDefaultSeriesStrokeColor(index int) drawing.Color { var ( // DefaultAnnotationPadding is the padding around an annotation. - DefaultAnnotationPadding = Box{Top: 5, Left: 5, Right: 5, Bottom: 5} + DefaultAnnotationPadding = Box{Top: 3, Left: 5, Right: 5, Bottom: 5} // DefaultBackgroundPadding is the default canvas padding config. DefaultBackgroundPadding = Box{Top: 5, Left: 5, Right: 5, Bottom: 5} ) diff --git a/drawing_helpers.go b/drawing_helpers.go index a4e5702..981f5f7 100644 --- a/drawing_helpers.go +++ b/drawing_helpers.go @@ -6,13 +6,12 @@ func DrawLineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs return } - ct := canvasBox.Top cb := canvasBox.Bottom - cr := canvasBox.Right + cl := canvasBox.Left v0x, v0y := vs.GetValue(0) - x0 := cr - xrange.Translate(v0x) - y0 := yrange.Translate(v0y) + ct + x0 := cl + xrange.Translate(v0x) + y0 := cb - yrange.Translate(v0y) var vx, vy float64 var x, y int @@ -23,8 +22,8 @@ func DrawLineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs r.MoveTo(x0, y0) for i := 1; i < vs.Len(); i++ { vx, vy = vs.GetValue(i) - x = cr - xrange.Translate(vx) - y = yrange.Translate(vy) + ct + x = cl + xrange.Translate(vx) + y = cb - yrange.Translate(vy) r.LineTo(x, y) } r.LineTo(x, cb) @@ -40,19 +39,19 @@ func DrawLineSeries(r Renderer, canvasBox Box, xrange, yrange Range, s Style, vs r.MoveTo(x0, y0) for i := 1; i < vs.Len(); i++ { vx, vy = vs.GetValue(i) - x = cr - xrange.Translate(vx) - y = yrange.Translate(vy) + ct + x = cl + xrange.Translate(vx) + y = cb - yrange.Translate(vy) r.LineTo(x, y) } r.Stroke() } // MeasureAnnotation measures how big an annotation would be. -func MeasureAnnotation(r Renderer, canvasBox Box, xrange, yrange Range, s Style, lx, ly int, label string) Box { +func MeasureAnnotation(r Renderer, canvasBox Box, s Style, lx, ly int, label string) Box { r.SetFont(s.GetFont()) r.SetFontSize(s.GetFontSize(DefaultAnnotationFontSize)) - textWidth, textHeight := r.MeasureText(label) - halfTextHeight := textHeight >> 1 + textBox := r.MeasureText(label) + halfTextHeight := textBox.Height >> 1 pt := s.Padding.GetTop(DefaultAnnotationPadding.Top) pl := s.Padding.GetLeft(DefaultAnnotationPadding.Left) @@ -62,7 +61,7 @@ func MeasureAnnotation(r Renderer, canvasBox Box, xrange, yrange Range, s Style, strokeWidth := s.GetStrokeWidth() top := ly - (pt + halfTextHeight) - right := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth + int(strokeWidth) + right := lx + pl + pr + textBox.Width + DefaultAnnotationDeltaWidth + int(strokeWidth) bottom := ly + (pb + halfTextHeight) return Box{ @@ -76,11 +75,11 @@ func MeasureAnnotation(r Renderer, canvasBox Box, xrange, yrange Range, s Style, } // DrawAnnotation draws an anotation with a renderer. -func DrawAnnotation(r Renderer, canvasBox Box, xrange, yrange Range, s Style, lx, ly int, label string) { +func DrawAnnotation(r Renderer, canvasBox Box, s Style, lx, ly int, label string) { r.SetFont(s.GetFont()) r.SetFontSize(s.GetFontSize(DefaultAnnotationFontSize)) - textWidth, textHeight := r.MeasureText(label) - halfTextHeight := textHeight >> 1 + textBox := r.MeasureText(label) + halfTextHeight := textBox.Height >> 1 pt := s.Padding.GetTop(DefaultAnnotationPadding.Top) pl := s.Padding.GetLeft(DefaultAnnotationPadding.Left) @@ -93,10 +92,10 @@ func DrawAnnotation(r Renderer, canvasBox Box, xrange, yrange Range, s Style, lx ltx := lx + DefaultAnnotationDeltaWidth lty := ly - (pt + halfTextHeight) - rtx := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth + rtx := lx + pl + pr + textBox.Width + DefaultAnnotationDeltaWidth rty := ly - (pt + halfTextHeight) - rbx := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth + rbx := lx + pl + pr + textBox.Width + DefaultAnnotationDeltaWidth rby := ly + (pb + halfTextHeight) lbx := lx + DefaultAnnotationDeltaWidth @@ -119,3 +118,16 @@ func DrawAnnotation(r Renderer, canvasBox Box, xrange, yrange Range, s Style, lx r.SetFontColor(s.GetFontColor(DefaultTextColor)) r.Text(label, textX, textY) } + +// DrawBox draws a box with a given style. +func DrawBox(r Renderer, b Box, s Style) { + r.SetFillColor(s.GetFillColor()) + r.SetStrokeColor(s.GetStrokeColor(DefaultStrokeColor)) + r.SetStrokeWidth(s.GetStrokeWidth(DefaultStrokeWidth)) + r.MoveTo(b.Left, b.Top) + r.LineTo(b.Right, b.Top) + r.LineTo(b.Right, b.Bottom) + r.LineTo(b.Left, b.Bottom) + r.LineTo(b.Left, b.Top) + r.FillStroke() +} diff --git a/grid_line.go b/grid_line.go new file mode 100644 index 0000000..d692eba --- /dev/null +++ b/grid_line.go @@ -0,0 +1,43 @@ +package chart + +// GridLine is a line on a graph canvas. +type GridLine struct { + IsMinor bool + IsVertical bool + Style Style + Value float64 +} + +// Major returns if the gridline is a `major` line. +func (gl GridLine) Major() bool { + return !gl.IsMinor +} + +// Minor returns if the gridline is a `minor` line. +func (gl GridLine) Minor() bool { + return gl.IsMinor +} + +// Vertical returns if the line is vertical line or not. +func (gl GridLine) Vertical() bool { + return gl.IsVertical +} + +// Horizontal returns if the line is horizontal line or not. +func (gl GridLine) Horizontal() bool { + return !gl.IsVertical +} + +// Render renders the gridline +func (gl GridLine) Render(r Renderer, canvasBox Box, ra Range) { + lineleft := canvasBox.Left + 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() +} diff --git a/range.go b/range.go index 513427d..b8ba59d 100644 --- a/range.go +++ b/range.go @@ -28,9 +28,15 @@ func (r Range) String() string { } // Translate maps a given value into the range space. -// An example would be a 600 px image, with a min of 10 and a max of 100. -// Translate(50) would yield (50.0/90.0)*600 ~= 333.33 func (r Range) Translate(value float64) int { - finalValue := ((r.Max - value) / r.Delta()) * float64(r.Domain) - return int(math.Floor(finalValue)) + normalized := value - r.Min + ratio := normalized / r.Delta() + return int(math.Ceil(ratio * float64(r.Domain))) +} + +// 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) } diff --git a/raster_renderer.go b/raster_renderer.go index fa94050..387cc52 100644 --- a/raster_renderer.go +++ b/raster_renderer.go @@ -5,6 +5,7 @@ import ( "image/color" "image/png" "io" + "math" "github.com/golang/freetype/truetype" "github.com/wcharczuk/go-chart/drawing" @@ -48,16 +49,21 @@ func (rr *rasterRenderer) SetStrokeColor(c drawing.Color) { rr.gc.SetStrokeColor(c) } -// SetFillColor implements the interface method. -func (rr *rasterRenderer) SetFillColor(c drawing.Color) { - rr.gc.SetFillColor(c) -} - // SetLineWidth implements the interface method. func (rr *rasterRenderer) SetStrokeWidth(width float64) { rr.gc.SetLineWidth(width) } +// StrokeDashArray sets the stroke dash array. +func (rr *rasterRenderer) SetStrokeDashArray(dashArray []float64) { + rr.gc.SetLineDash(dashArray, 0.0) +} + +// SetFillColor implements the interface method. +func (rr *rasterRenderer) SetFillColor(c drawing.Color) { + rr.gc.SetFillColor(c) +} + // MoveTo implements the interface method. func (rr *rasterRenderer) MoveTo(x, y int) { rr.gc.MoveTo(float64(x), float64(y)) @@ -127,14 +133,38 @@ func (rr *rasterRenderer) Text(body string, x, y int) { } // MeasureText returns the height and width in pixels of a string. -func (rr *rasterRenderer) MeasureText(body string) (width int, height int) { +func (rr *rasterRenderer) MeasureText(body string) Box { l, t, r, b, err := rr.gc.GetStringBounds(body) if err != nil { - return + return Box{} + } + if l < 0 { + r = r - l // equivalent to r+(-1*l) + l = 0 + } + if t < 0 { + b = b - t + t = 0 + } + + if l > 0 { + r = r + l + l = 0 + } + + if t > 0 { + b = b + t + t = 0 + } + + return Box{ + Top: int(math.Ceil(t)), + Left: int(math.Ceil(l)), + Right: int(math.Ceil(r)), + Bottom: int(math.Ceil(b)), + Width: int(math.Ceil(r - l)), + Height: int(math.Ceil(b - t)), } - width = int(r - l) - height = int(b - t) - return } // Save implements the interface method. diff --git a/renderer.go b/renderer.go index fa6d64a..6308dda 100644 --- a/renderer.go +++ b/renderer.go @@ -24,6 +24,9 @@ type Renderer interface { // SetStrokeWidth sets the stroke width. SetStrokeWidth(width float64) + // SetStrokeDashArray sets the stroke dash array. + SetStrokeDashArray(dashArray []float64) + // MoveTo moves the cursor to a given point. MoveTo(x, y int) @@ -59,7 +62,7 @@ type Renderer interface { Text(body string, x, y int) // MeasureText measures text. - MeasureText(body string) (width int, height int) + MeasureText(body string) Box // Save writes the image to the given writer. Save(w io.Writer) error diff --git a/style.go b/style.go index 0274535..3445ce8 100644 --- a/style.go +++ b/style.go @@ -13,12 +13,14 @@ type Style struct { Show bool Padding Box - StrokeWidth float64 - StrokeColor drawing.Color - FillColor drawing.Color - FontSize float64 - FontColor drawing.Color - Font *truetype.Font + StrokeWidth float64 + StrokeColor drawing.Color + StrokeDashArray []float64 + + FillColor drawing.Color + FontSize float64 + FontColor drawing.Color + Font *truetype.Font } // IsZero returns if the object is set or not. @@ -59,6 +61,17 @@ func (s Style) GetStrokeWidth(defaults ...float64) float64 { return s.StrokeWidth } +// GetStrokeDashArray returns the stroke dash array. +func (s Style) GetStrokeDashArray(defaults ...[]float64) []float64 { + if len(s.StrokeDashArray) == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return nil + } + return s.StrokeDashArray +} + // GetFontSize gets the font size. func (s Style) GetFontSize(defaults ...float64) float64 { if s.FontSize == 0 { diff --git a/testserver/main.go b/testserver/main.go index cd9eff2..86eddca 100644 --- a/testserver/main.go +++ b/testserver/main.go @@ -2,14 +2,16 @@ package main import ( "log" + "math/rand" "net/http" + "time" "github.com/wcharczuk/go-chart" - "github.com/wcharczuk/go-chart/drawing" "github.com/wcharczuk/go-web" ) func chartHandler(rc *web.RequestContext) web.ControllerResult { + rnd := rand.New(rand.NewSource(0)) format, err := rc.RouteParameter("format") if err != nil { format = "png" @@ -21,11 +23,14 @@ func chartHandler(rc *web.RequestContext) web.ControllerResult { rc.Response.Header().Set("Content-Type", "image/svg+xml") } - s1x := []float64{2.0, 3.0, 4.0, 5.0} - s1y := []float64{2.5, 5.0, 2.0, 3.3} - - s2x := []float64{0.0, 0.5, 1.0, 1.5} - s2y := []float64{1.1, 1.2, 1.0, 1.3} + var s1x []time.Time + for x := 0; x < 100; x++ { + s1x = append([]time.Time{time.Now().AddDate(0, 0, -1*x)}, s1x...) + } + var s1y []float64 + for x := 0; x < 100; x++ { + s1y = append(s1y, rnd.Float64()*1024) + } c := chart.Chart{ Title: "A Test Chart", @@ -43,32 +48,19 @@ func chartHandler(rc *web.RequestContext) web.ControllerResult { Style: chart.Style{ Show: true, }, - Range: chart.Range{ - Min: 0.0, - Max: 7.0, - }, - }, - YAxisSecondary: chart.YAxis{ - Style: chart.Style{ - Show: true, - }, - Range: chart.Range{ - Min: 0.8, - Max: 1.5, + Zero: chart.GridLine{ + Style: chart.Style{ + Show: true, + StrokeWidth: 1.0, + }, }, }, Series: []chart.Series{ - chart.ContinuousSeries{ + chart.TimeSeries{ Name: "a", XValues: s1x, YValues: s1y, }, - chart.ContinuousSeries{ - Name: "b", - YAxis: chart.YAxisSecondary, - XValues: s2x, - YValues: s2y, - }, }, } @@ -84,56 +76,6 @@ func chartHandler(rc *web.RequestContext) web.ControllerResult { return nil } -func measureTestHandler(rc *web.RequestContext) web.ControllerResult { - format, err := rc.RouteParameter("format") - if err != nil { - format = "png" - } - - if format == "png" { - rc.Response.Header().Set("Content-Type", "image/png") - } else { - rc.Response.Header().Set("Content-Type", "image/svg+xml") - } - - var r chart.Renderer - if format == "png" { - r, err = chart.PNG(512, 512) - } else { - r, err = chart.SVG(512, 512) - } - if err != nil { - return rc.API().InternalError(err) - } - - f, err := chart.GetDefaultFont() - if err != nil { - return rc.API().InternalError(err) - } - r.SetDPI(96) - r.SetFont(f) - r.SetFontSize(10.0) - r.SetFontColor(drawing.ColorBlack) - r.SetStrokeColor(drawing.ColorBlack) - - label := "goog - 702.23" - - tx, ty := 64, 64 - - tw, th := r.MeasureText(label) - r.MoveTo(tx, ty) - r.LineTo(tx+tw, ty) - r.LineTo(tx+tw, ty-th) - r.LineTo(tx, ty-th) - r.LineTo(tx, ty) - r.Stroke() - - r.Text(label, tx, ty) - - r.Save(rc.Response) - return nil -} - func main() { app := web.New() app.SetName("Chart Test Server") @@ -143,6 +85,5 @@ func main() { app.GET("/favico.ico", func(rc *web.RequestContext) web.ControllerResult { return rc.Raw([]byte{}) }) - app.GET("/measure", measureTestHandler) log.Fatal(app.Start()) } diff --git a/tick.go b/tick.go index b9f7a78..1947bb4 100644 --- a/tick.go +++ b/tick.go @@ -3,7 +3,8 @@ package chart // GenerateTicksWithStep generates a set of ticks. func GenerateTicksWithStep(ra Range, step float64, vf ValueFormatter) []Tick { var ticks []Tick - for cursor := ra.Min; cursor < ra.Max; cursor += step { + min, max := ra.GetRoundedRangeBounds() + for cursor := min; cursor <= max; cursor += step { ticks = append(ticks, Tick{ Value: cursor, Label: vf(cursor), diff --git a/util.go b/util.go index 1f8fc7c..2ba9a69 100644 --- a/util.go +++ b/util.go @@ -2,9 +2,18 @@ package chart import ( "fmt" + "math" "time" ) +// Float is an alias for float64 that provides a better .String() method. +type Float float64 + +// String returns the string representation of a float. +func (f Float) String() string { + return fmt.Sprintf("%.2f", f) +} + // TimeToFloat64 returns a float64 representation of a time. func TimeToFloat64(t time.Time) float64 { return float64(t.Unix()) @@ -60,10 +69,48 @@ func Slices(count int, total float64) []float64 { return values } -// Float is an alias for float64 that provides a better .String() method. -type Float float64 +// GetRoundToForDelta returns a `roundTo` value for a given delta. +func GetRoundToForDelta(delta float64) float64 { + startingDeltaBound := math.Pow(10.0, 10.0) + for cursor := startingDeltaBound; cursor > 0; cursor /= 10.0 { + if delta > cursor { + return cursor / 10.0 + } + } -// String returns the string representation of a float. -func (f Float) String() string { - return fmt.Sprintf("%.2f", f) + return 0.0 +} + +// RoundUp rounds up to a given roundTo value. +func RoundUp(value, roundTo float64) float64 { + d1 := math.Ceil(value / roundTo) + return d1 * roundTo +} + +// RoundDown rounds down to a given roundTo value. +func RoundDown(value, roundTo float64) float64 { + d1 := math.Floor(value / roundTo) + return d1 * roundTo +} + +// MinInt returns the minimum of a set of integers. +func MinInt(values ...int) int { + min := math.MaxInt32 + for _, v := range values { + if v < min { + min = v + } + } + return min +} + +// MaxInt returns the maximum of a set of integers. +func MaxInt(values ...int) int { + max := math.MinInt32 + for _, v := range values { + if v > max { + max = v + } + } + return max } diff --git a/util_test.go b/util_test.go index 6ae7abd..d81c8be 100644 --- a/util_test.go +++ b/util_test.go @@ -75,3 +75,11 @@ func TestSlices(t *testing.T) { 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)) +} diff --git a/vector_renderer.go b/vector_renderer.go index 89aa20d..a26b82b 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -61,6 +61,11 @@ func (vr *vectorRenderer) SetStrokeWidth(width float64) { vr.s.StrokeWidth = width } +// StrokeDashArray sets the stroke dash array. +func (vr *vectorRenderer) SetStrokeDashArray(dashArray []float64) { + vr.s.StrokeDashArray = dashArray +} + // MoveTo implements the interface method. func (vr *vectorRenderer) MoveTo(x, y int) { vr.p = append(vr.p, fmt.Sprintf("M %d %d", x, y)) @@ -71,6 +76,7 @@ func (vr *vectorRenderer) LineTo(x, y int) { vr.p = append(vr.p, fmt.Sprintf("L %d %d", x, y)) } +// Close closes a shape. func (vr *vectorRenderer) Close() { vr.p = append(vr.p, fmt.Sprintf("Z")) } @@ -90,6 +96,7 @@ func (vr *vectorRenderer) FillStroke() { vr.drawPath(vr.s.SVGFillAndStroke()) } +// drawPath draws a path. func (vr *vectorRenderer) drawPath(s Style) { vr.c.Path(strings.Join(vr.p, "\n"), s.SVG(vr.dpi)) vr.p = []string{} // clear the path @@ -115,6 +122,7 @@ func (vr *vectorRenderer) SetFontSize(size float64) { vr.s.FontSize = size } +// svgFontFace returns the font face component of an svg element style. func (vr *vectorRenderer) svgFontFace() string { family := "sans-serif" if vr.f != nil { @@ -133,7 +141,7 @@ func (vr *vectorRenderer) Text(body string, x, y int) { } // MeasureText uses the truetype font drawer to measure the width of text. -func (vr *vectorRenderer) MeasureText(body string) (width, height int) { +func (vr *vectorRenderer) MeasureText(body string) (box Box) { if vr.f != nil { vr.fc = &font.Drawer{ Face: truetype.NewFace(vr.f, &truetype.Options{ @@ -143,12 +151,15 @@ func (vr *vectorRenderer) MeasureText(body string) (width, height int) { } w := vr.fc.MeasureString(body).Ceil() - width = w - height = int(drawing.PointsToPixels(vr.dpi, vr.s.FontSize)) + box.Right = w + box.Bottom = int(drawing.PointsToPixels(vr.dpi, vr.s.FontSize)) + box.Width = w + box.Height = int(drawing.PointsToPixels(vr.dpi, vr.s.FontSize)) } return } +// Save saves the renderer's contents to a writer. func (vr *vectorRenderer) Save(w io.Writer) error { vr.c.End() _, err := w.Write(vr.b.Bytes()) diff --git a/xaxis.go b/xaxis.go index 09fd350..135641f 100644 --- a/xaxis.go +++ b/xaxis.go @@ -3,8 +3,6 @@ package chart import ( "math" "sort" - - "github.com/wcharczuk/go-chart/drawing" ) // XAxis represents the horizontal axis. @@ -57,13 +55,46 @@ func (xa XAxis) getTickCount(r Renderer, ra Range, vf ValueFormatter) int { if len(ln) > len(l0) { ll = ln } - llw, _ := r.MeasureText(ll) - textWidth := drawing.PointsToPixels(r.GetDPI(), float64(llw)) + llb := r.MeasureText(ll) + textWidth := llb.Width width := textWidth + DefaultMinimumTickHorizontalSpacing count := int(math.Ceil(float64(ra.Domain) / float64(width))) return count } +// Measure returns the bounds of the axis. +func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, ticks []Tick) Box { + defaultFont, _ := GetDefaultFont() + r.SetFont(xa.Style.GetFont(defaultFont)) + r.SetFontSize(xa.Style.GetFontSize(DefaultFontSize)) + + sort.Sort(Ticks(ticks)) + + var left, right, top, bottom = math.MaxInt32, 0, math.MaxInt32, 0 + for _, 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 + + top = MinInt(top, canvasBox.Bottom) + left = MinInt(left, tx-(tb.Width>>1)) + right = MaxInt(right, tx+(tb.Width>>1)) + bottom = MaxInt(bottom, ty) + } + + return Box{ + Top: top, + Left: left, + Right: right, + Bottom: bottom, + Width: right - left, + Height: bottom - top, + } +} + // Render renders the axis func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, ticks []Tick) { tickFontSize := xa.Style.GetFontSize(DefaultFontSize) @@ -80,18 +111,16 @@ func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, ticks []Tick) { sort.Sort(Ticks(ticks)) - var textHeight int - for _, t := range ticks { - _, th := r.MeasureText(t.Label) - if th > textHeight { - textHeight = th - } - } - ty := canvasBox.Bottom + DefaultXAxisMargin + int(textHeight) for _, t := range ticks { v := t.Value - x := ra.Translate(v) - tx := canvasBox.Right - x - r.Text(t.Label, tx, ty) + 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) + + r.MoveTo(tx, canvasBox.Bottom) + r.LineTo(tx, canvasBox.Bottom+DefaultVerticalTickHeight) + r.Stroke() } } diff --git a/yaxis.go b/yaxis.go index 544b22a..8d1008a 100644 --- a/yaxis.go +++ b/yaxis.go @@ -8,8 +8,13 @@ import ( // YAxis is a veritcal rule of the range. // There can be (2) y-axes; a primary and secondary. type YAxis struct { - Name string - Style Style + Name string + Style Style + + Zero GridLine + + AxisType YAxisType + ValueFormatter ValueFormatter Range Range Ticks []Tick @@ -28,31 +33,81 @@ func (ya YAxis) GetStyle() Style { // GetTicks returns the ticks for a series. It coalesces between user provided ticks and // generated ticks. func (ya YAxis) GetTicks(r Renderer, ra Range, vf ValueFormatter) []Tick { + var ticks []Tick if len(ya.Ticks) > 0 { - return ya.Ticks + ticks = ya.Ticks + } else { + ticks = ya.generateTicks(r, ra, vf) } - return ya.generateTicks(r, ra, vf) + + return ticks } func (ya YAxis) generateTicks(r Renderer, ra Range, vf ValueFormatter) []Tick { step := ya.getTickStep(r, ra, vf) - return GenerateTicksWithStep(ra, step, vf) + ticks := GenerateTicksWithStep(ra, step, vf) + return ticks } func (ya YAxis) getTickStep(r Renderer, ra Range, vf ValueFormatter) float64 { tickCount := ya.getTickCount(r, ra, vf) - return ra.Delta() / float64(tickCount) + step := ra.Delta() / float64(tickCount) + return step } func (ya YAxis) getTickCount(r Renderer, ra Range, vf ValueFormatter) int { - textHeight := int(ya.Style.GetFontSize(DefaultFontSize)) - height := textHeight + DefaultMinimumTickVerticalSpacing - count := int(math.Ceil(float64(ra.Domain) / float64(height))) - return count + return DefaultTickCount +} + +// Measure returns the bounds of the axis. +func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, ticks []Tick) Box { + defaultFont, _ := GetDefaultFont() + r.SetFont(ya.Style.GetFont(defaultFont)) + r.SetFontSize(ya.Style.GetFontSize(DefaultFontSize)) + + sort.Sort(Ticks(ticks)) + + var tx int + if ya.AxisType == YAxisPrimary { + tx = canvasBox.Right + DefaultYAxisMargin + } else if ya.AxisType == YAxisSecondary { + tx = canvasBox.Left - DefaultYAxisMargin + } + + var minx, maxx, miny, maxy = math.MaxInt32, 0, math.MaxInt32, 0 + for _, t := range ticks { + v := t.Value + ly := canvasBox.Bottom - ra.Translate(v) + + tb := r.MeasureText(t.Label) + finalTextX := tx + if ya.AxisType == YAxisSecondary { + finalTextX = tx - tb.Width + } + + if ya.AxisType == YAxisPrimary { + minx = canvasBox.Right + maxx = MaxInt(maxx, tx+tb.Width) + } else if ya.AxisType == YAxisSecondary { + minx = MinInt(minx, finalTextX) + maxx = MaxInt(maxx, tx) + } + miny = MinInt(miny, ly-tb.Height>>1) + maxy = MaxInt(maxy, ly+tb.Height>>1) + } + + return Box{ + Top: miny, + Left: minx, + Right: maxx, + Bottom: maxy, + Width: maxx - minx, + Height: maxy - miny, + } } // Render renders the axis. -func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, axisType YAxisType, ticks []Tick) { +func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, ticks []Tick) { r.SetStrokeColor(ya.Style.GetStrokeColor(DefaultAxisColor)) r.SetStrokeWidth(ya.Style.GetStrokeWidth(DefaultAxisLineWidth)) @@ -65,51 +120,42 @@ func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, axisType YAxisType, var lx int var tx int - if axisType == YAxisPrimary { + if ya.AxisType == YAxisPrimary { lx = canvasBox.Right tx = canvasBox.Right + DefaultYAxisMargin - - r.MoveTo(lx, canvasBox.Bottom) - r.LineTo(lx, canvasBox.Top) - r.Stroke() - - for _, t := range ticks { - v := t.Value - ly := ra.Translate(v) + canvasBox.Top - - _, pth := r.MeasureText(t.Label) - ty := ly + pth>>1 - - r.Text(t.Label, tx, ty) - - r.MoveTo(lx, ly) - r.LineTo(lx+DefaultVerticalTickWidth, ly) - r.Stroke() - } - } else if axisType == YAxisSecondary { + } else if ya.AxisType == YAxisSecondary { lx = canvasBox.Left - - r.MoveTo(lx, canvasBox.Bottom) - r.LineTo(lx, canvasBox.Top) - r.Stroke() - - for _, t := range ticks { - v := t.Value - ly := ra.Translate(v) + canvasBox.Top - - ptw, pth := r.MeasureText(t.Label) - - tw := ptw - - tx = lx - (tw + DefaultYAxisMargin) - ty := ly + pth>>1 - - r.Text(t.Label, tx, ty) - - r.MoveTo(lx, ly) - r.LineTo(lx-DefaultVerticalTickWidth, ly) - r.Stroke() - } + tx = canvasBox.Left - DefaultYAxisMargin } + r.MoveTo(lx, canvasBox.Bottom) + r.LineTo(lx, canvasBox.Top) + r.Stroke() + + for _, t := range ticks { + v := t.Value + ly := canvasBox.Bottom - ra.Translate(v) + + tb := r.MeasureText(t.Label) + + finalTextX := tx + finalTextY := ly + tb.Height>>1 + if ya.AxisType == YAxisSecondary { + finalTextX = tx - tb.Width + } + + r.Text(t.Label, finalTextX, finalTextY) + + r.MoveTo(lx, ly) + if ya.AxisType == YAxisPrimary { + r.LineTo(lx+DefaultHorizontalTickWidth, ly) + } else if ya.AxisType == YAxisSecondary { + r.LineTo(lx-DefaultHorizontalTickWidth, ly) + } + r.Stroke() + } + + if ya.Zero.Style.Show { + ya.Zero.Render(r, canvasBox, ra) + } }