From 7bb3fbf81081b20d23f6e381711e255d9ff6fe5c Mon Sep 17 00:00:00 2001 From: Will Charczuk Date: Fri, 5 Aug 2016 16:55:55 -0700 Subject: [PATCH] bar charts! --- bar_chart.go | 464 ++++++++++++++++++++++++++++++++++ chart.go | 32 +-- examples/bar_chart/main.go | 54 ++++ examples/market_hours/main.go | 2 +- sequence.go | 2 - tick.go | 4 + 6 files changed, 539 insertions(+), 19 deletions(-) create mode 100644 bar_chart.go create mode 100644 examples/bar_chart/main.go diff --git a/bar_chart.go b/bar_chart.go new file mode 100644 index 0000000..9dc59a3 --- /dev/null +++ b/bar_chart.go @@ -0,0 +1,464 @@ +package chart + +import ( + "errors" + "io" + "math" + + "github.com/golang/freetype/truetype" +) + +// BarChart is a chart that draws bars on a range. +type BarChart struct { + Title string + TitleStyle Style + + Width int + Height int + DPI float64 + + BarWidth int + + Background Style + Canvas Style + + XAxis Style + YAxis YAxis + + BarSpacing int + + Font *truetype.Font + defaultFont *truetype.Font + + Bars []Value + Elements []Renderable +} + +// GetDPI returns the dpi for the chart. +func (bc BarChart) GetDPI(defaults ...float64) float64 { + if bc.DPI == 0 { + if len(defaults) > 0 { + return defaults[0] + } + return DefaultDPI + } + return bc.DPI +} + +// GetFont returns the text font. +func (bc BarChart) GetFont() *truetype.Font { + if bc.Font == nil { + return bc.defaultFont + } + return bc.Font +} + +// GetWidth returns the chart width or the default value. +func (bc BarChart) GetWidth() int { + if bc.Width == 0 { + return DefaultChartWidth + } + return bc.Width +} + +// GetHeight returns the chart height or the default value. +func (bc BarChart) GetHeight() int { + if bc.Height == 0 { + return DefaultChartWidth + } + return bc.Height +} + +// GetBarSpacing returns the spacing between bars. +func (bc BarChart) GetBarSpacing() int { + if bc.BarSpacing == 0 { + return 100 + } + return bc.BarSpacing +} + +// GetBarWidth returns the default bar width. +func (bc BarChart) GetBarWidth() int { + if bc.BarWidth == 0 { + return 40 + } + return bc.BarWidth +} + +// Render renders the chart with the given renderer to the given io.Writer. +func (bc BarChart) Render(rp RendererProvider, w io.Writer) error { + if len(bc.Bars) == 0 { + return errors.New("Please provide at least one bar.") + } + + r, err := rp(bc.GetWidth(), bc.GetHeight()) + if err != nil { + return err + } + + if bc.Font == nil { + defaultFont, err := GetDefaultFont() + if err != nil { + return err + } + bc.defaultFont = defaultFont + } + r.SetDPI(bc.GetDPI(DefaultDPI)) + + bc.drawBackground(r) + + var canvasBox Box + var yt []Tick + var yr Range + var yf ValueFormatter + + canvasBox = bc.getDefaultCanvasBox() + yr = bc.getRanges() + yr = bc.setRangeDomains(canvasBox, yr) + yf = bc.getValueFormatters() + + if bc.hasAxes() { + yt = bc.getAxesTicks(r, yr, yf) + canvasBox = bc.getAdjustedCanvasBox(r, canvasBox, yr, yt) + yr = bc.setRangeDomains(canvasBox, yr) + } + + bc.drawBars(r, canvasBox, yr) + bc.drawXAxis(r, canvasBox) + bc.drawYAxis(r, canvasBox, yr, yt) + + bc.drawTitle(r) + for _, a := range bc.Elements { + a(r, canvasBox, bc.styleDefaultsElements()) + } + + return r.Save(w) +} + +func (bc BarChart) getRanges() Range { + var yrange Range + if bc.YAxis.Range != nil && !bc.YAxis.Range.IsZero() { + yrange = bc.YAxis.Range + } else { + yrange = &ContinuousRange{} + } + + if !yrange.IsZero() { + return yrange + } + + if len(bc.YAxis.Ticks) > 0 { + tickMin, tickMax := math.MaxFloat64, -math.MaxFloat64 + for _, t := range bc.YAxis.Ticks { + tickMin = math.Min(tickMin, t.Value) + tickMax = math.Max(tickMax, t.Value) + } + yrange.SetMin(tickMin) + yrange.SetMax(tickMax) + return yrange + } + + min, max := math.MaxFloat64, -math.MaxFloat64 + for _, b := range bc.Bars { + min = math.Min(b.Value, min) + max = math.Max(b.Value, max) + } + + yrange.SetMin(min) + yrange.SetMax(max) + + return yrange +} + +func (bc BarChart) drawBackground(r Renderer) { + Draw.Box(r, Box{ + Right: bc.GetWidth(), + Bottom: bc.GetHeight(), + }, bc.getBackgroundStyle()) +} + +func (bc BarChart) drawBars(r Renderer, canvasBox Box, yr Range) { + xoffset := canvasBox.Left + + width, spacing, _ := bc.calculateScaledTotalWidth(canvasBox) + bs2 := spacing >> 1 + + var barBox Box + var bxl, bxr, by int + for index, bar := range bc.Bars { + bxl = xoffset + bs2 + bxr = bxl + width + + by = canvasBox.Bottom - yr.Translate(bar.Value) + + barBox = Box{ + Top: by, + Left: bxl, + Right: bxr, + Bottom: canvasBox.Bottom, + } + + Draw.Box(r, barBox, bar.Style.InheritFrom(bc.styleDefaultsBar(index))) + + xoffset += width + spacing + } +} + +func (bc BarChart) drawXAxis(r Renderer, canvasBox Box) { + if bc.XAxis.Show { + axisStyle := bc.XAxis.InheritFrom(bc.styleDefaultsAxes()) + axisStyle.WriteToRenderer(r) + + width, spacing, _ := bc.calculateScaledTotalWidth(canvasBox) + + 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 index, bar := range bc.Bars { + barLabelBox := Box{ + Top: canvasBox.Bottom + DefaultXAxisMargin, + Left: cursor, + Right: cursor + width + spacing, + Bottom: bc.GetHeight(), + } + + if len(bar.Label) > 0 { + Draw.TextWithin(r, bar.Label, barLabelBox, axisStyle) + } + + axisStyle.WriteToRenderer(r) + if index < len(bc.Bars)-1 { + r.MoveTo(barLabelBox.Right, canvasBox.Bottom) + r.LineTo(barLabelBox.Right, canvasBox.Bottom+DefaultVerticalTickHeight) + r.Stroke() + } + cursor += width + spacing + } + } +} + +func (bc BarChart) drawYAxis(r Renderer, canvasBox Box, yr Range, ticks []Tick) { + if bc.YAxis.Style.Show { + axisStyle := bc.YAxis.Style.InheritFrom(bc.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() + + var ty int + var tb Box + for _, t := range ticks { + ty = canvasBox.Bottom - yr.Translate(t.Value) + + axisStyle.GetStrokeOptions().WriteToRenderer(r) + r.MoveTo(canvasBox.Right, ty) + r.LineTo(canvasBox.Right+DefaultHorizontalTickWidth, ty) + r.Stroke() + + axisStyle.GetTextOptions().WriteToRenderer(r) + tb = r.MeasureText(t.Label) + Draw.Text(r, t.Label, canvasBox.Right+DefaultYAxisMargin+5, ty+(tb.Height()>>1), axisStyle) + } + + } +} + +func (bc BarChart) drawTitle(r Renderer) { + if len(bc.Title) > 0 && bc.TitleStyle.Show { + Draw.TextWithin(r, bc.Title, bc.box(), bc.styleDefaultsTitle()) + } +} + +func (bc BarChart) hasAxes() bool { + return bc.YAxis.Style.Show +} + +func (bc BarChart) setRangeDomains(canvasBox Box, yr Range) Range { + yr.SetDomain(canvasBox.Height()) + return yr +} + +func (bc BarChart) getDefaultCanvasBox() Box { + return bc.box() +} + +func (bc BarChart) getValueFormatters() ValueFormatter { + if bc.YAxis.ValueFormatter != nil { + return bc.YAxis.ValueFormatter + } + return FloatValueFormatter +} + +func (bc BarChart) getAxesTicks(r Renderer, yr Range, yf ValueFormatter) (yticks []Tick) { + if bc.YAxis.Style.Show { + yticks = bc.YAxis.GetTicks(r, yr, bc.styleDefaultsAxes(), yf) + } + return +} + +func (bc BarChart) calculateEffectiveBarSpacing(canvasBox Box) int { + totalWithBaseSpacing := bc.calculateTotalBarWidth(bc.GetBarWidth(), bc.GetBarSpacing()) + if totalWithBaseSpacing > canvasBox.Width() { + lessBarWidths := canvasBox.Width() - (len(bc.Bars) * bc.GetBarWidth()) + if lessBarWidths > 0 { + return int(math.Floor(float64(lessBarWidths) / float64(len(bc.Bars)))) + } + return 0 + } + return bc.GetBarSpacing() +} + +func (bc BarChart) calculateEffectiveBarWidth(canvasBox Box, spacing int) int { + totalWithBaseWidth := bc.calculateTotalBarWidth(bc.GetBarWidth(), spacing) + if totalWithBaseWidth > canvasBox.Width() { + totalLessBarSpacings := canvasBox.Width() - (len(bc.Bars) * spacing) + if totalLessBarSpacings > 0 { + return int(math.Floor(float64(totalLessBarSpacings) / float64(len(bc.Bars)))) + } + return 0 + } + return bc.GetBarWidth() +} + +func (bc BarChart) calculateTotalBarWidth(barWidth, spacing int) int { + return len(bc.Bars) * (bc.GetBarWidth() + spacing) +} + +func (bc BarChart) calculateScaledTotalWidth(canvasBox Box) (width, spacing, total int) { + spacing = bc.calculateEffectiveBarSpacing(canvasBox) + width = bc.calculateEffectiveBarWidth(canvasBox, spacing) + total = bc.calculateTotalBarWidth(width, spacing) + return +} + +func (bc BarChart) getAdjustedCanvasBox(r Renderer, canvasBox Box, yrange Range, yticks []Tick) Box { + axesOuterBox := canvasBox.Clone() + + _, _, totalWidth := bc.calculateScaledTotalWidth(canvasBox) + + if bc.XAxis.Show { + xaxisHeight := DefaultVerticalTickHeight + + axisStyle := bc.XAxis.InheritFrom(bc.styleDefaultsAxes()) + axisStyle.WriteToRenderer(r) + + cursor := canvasBox.Left + for _, bar := range bc.Bars { + if len(bar.Label) > 0 { + barLabelBox := Box{ + Top: canvasBox.Bottom + DefaultXAxisMargin, + Left: cursor, + Right: cursor + bc.GetBarWidth() + bc.GetBarSpacing(), + Bottom: bc.GetHeight(), + } + lines := Text.WrapFit(r, bar.Label, barLabelBox.Width(), axisStyle) + linesBox := Text.MeasureLines(r, lines, axisStyle) + + xaxisHeight = Math.MaxInt(linesBox.Height()+(2*DefaultXAxisMargin), xaxisHeight) + } + } + + xbox := Box{ + Top: canvasBox.Top, + Left: canvasBox.Left, + Right: canvasBox.Left + totalWidth, + Bottom: bc.GetHeight() - xaxisHeight, + } + + axesOuterBox = axesOuterBox.Grow(xbox) + } + + if bc.YAxis.Style.Show { + axesBounds := bc.YAxis.Measure(r, canvasBox, yrange, bc.styleDefaultsAxes(), yticks) + axesOuterBox = axesOuterBox.Grow(axesBounds) + } + + return canvasBox.OuterConstrain(bc.box(), axesOuterBox) +} + +// box returns the chart bounds as a box. +func (bc BarChart) box() Box { + dpr := bc.Background.Padding.GetRight(10) + dpb := bc.Background.Padding.GetBottom(50) + + return Box{ + Top: 20, + Left: 20, + Right: bc.GetWidth() - dpr, + Bottom: bc.GetHeight() - dpb, + } +} + +func (bc BarChart) getBackgroundStyle() Style { + return bc.Background.InheritFrom(bc.styleDefaultsBackground()) +} + +func (bc BarChart) styleDefaultsBackground() Style { + return Style{ + FillColor: DefaultBackgroundColor, + StrokeColor: DefaultBackgroundStrokeColor, + StrokeWidth: DefaultStrokeWidth, + } +} + +func (bc BarChart) styleDefaultsBar(index int) Style { + return Style{ + StrokeColor: GetAlternateColor(index), + StrokeWidth: 3.0, + FillColor: GetAlternateColor(index), + } +} + +func (bc BarChart) styleDefaultsTitle() Style { + return bc.TitleStyle.InheritFrom(Style{ + FontColor: DefaultTextColor, + Font: bc.GetFont(), + FontSize: bc.getTitleFontSize(), + TextHorizontalAlign: TextHorizontalAlignCenter, + TextVerticalAlign: TextVerticalAlignTop, + TextWrap: TextWrapWord, + }) +} + +func (bc BarChart) getTitleFontSize() float64 { + effectiveDimension := Math.MinInt(bc.GetWidth(), bc.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 (bc BarChart) styleDefaultsAxes() Style { + return Style{ + StrokeColor: DefaultAxisColor, + Font: bc.GetFont(), + FontSize: DefaultAxisFontSize, + FontColor: DefaultAxisColor, + TextHorizontalAlign: TextHorizontalAlignCenter, + TextVerticalAlign: TextVerticalAlignTop, + TextWrap: TextWrapWord, + } +} + +func (bc BarChart) styleDefaultsElements() Style { + return Style{ + Font: bc.GetFont(), + } +} diff --git a/chart.go b/chart.go index 67c1061..d53003d 100644 --- a/chart.go +++ b/chart.go @@ -133,9 +133,9 @@ func (c Chart) Render(rp RendererProvider, w io.Writer) error { } func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { - var minx, maxx float64 = math.MaxFloat64, 0 - var miny, maxy float64 = math.MaxFloat64, 0 - var minya, maxya float64 = math.MaxFloat64, 0 + var minx, maxx float64 = math.MaxFloat64, -math.MaxFloat64 + var miny, maxy float64 = math.MaxFloat64, -math.MaxFloat64 + var minya, maxya float64 = math.MaxFloat64, -math.MaxFloat64 seriesMappedToSecondaryAxis := false @@ -205,7 +205,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } if len(c.XAxis.Ticks) > 0 { - tickMin, tickMax := math.MaxFloat64, 0.0 + tickMin, tickMax := math.MaxFloat64, -math.MaxFloat64 for _, t := range c.XAxis.Ticks { tickMin = math.Min(tickMin, t.Value) tickMax = math.Max(tickMax, t.Value) @@ -218,7 +218,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } if len(c.YAxis.Ticks) > 0 { - tickMin, tickMax := math.MaxFloat64, 0.0 + tickMin, tickMax := math.MaxFloat64, -math.MaxFloat64 for _, t := range c.YAxis.Ticks { tickMin = math.Min(tickMin, t.Value) tickMax = math.Max(tickMax, t.Value) @@ -237,7 +237,7 @@ func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) { } if len(c.YAxisSecondary.Ticks) > 0 { - tickMin, tickMax := math.MaxFloat64, 0.0 + tickMin, tickMax := math.MaxFloat64, -math.MaxFloat64 for _, t := range c.YAxis.Ticks { tickMin = math.Min(tickMin, t.Value) tickMax = math.Max(tickMax, t.Value) @@ -309,13 +309,13 @@ func (c Chart) hasAxes() bool { func (c Chart) getAxesTicks(r Renderer, xr, yr, yar Range, xf, yf, yfa ValueFormatter) (xticks, yticks, yticksAlt []Tick) { if c.XAxis.Style.Show { - xticks = c.XAxis.GetTicks(r, xr, c.styleDefaultsAxis(), xf) + xticks = c.XAxis.GetTicks(r, xr, c.styleDefaultsAxes(), xf) } if c.YAxis.Style.Show { - yticks = c.YAxis.GetTicks(r, yr, c.styleDefaultsAxis(), yf) + yticks = c.YAxis.GetTicks(r, yr, c.styleDefaultsAxes(), yf) } if c.YAxisSecondary.Style.Show { - yticksAlt = c.YAxisSecondary.GetTicks(r, yar, c.styleDefaultsAxis(), yfa) + yticksAlt = c.YAxisSecondary.GetTicks(r, yar, c.styleDefaultsAxes(), yfa) } return } @@ -323,15 +323,15 @@ func (c Chart) getAxesTicks(r Renderer, xr, yr, yar Range, xf, yf, yfa ValueForm func (c Chart) getAxisAdjustedCanvasBox(r Renderer, canvasBox Box, xr, yr, yra Range, xticks, yticks, yticksAlt []Tick) Box { axesOuterBox := canvasBox.Clone() if c.XAxis.Style.Show { - axesBounds := c.XAxis.Measure(r, canvasBox, xr, c.styleDefaultsAxis(), xticks) + axesBounds := c.XAxis.Measure(r, canvasBox, xr, c.styleDefaultsAxes(), xticks) axesOuterBox = axesOuterBox.Grow(axesBounds) } if c.YAxis.Style.Show { - axesBounds := c.YAxis.Measure(r, canvasBox, yr, c.styleDefaultsAxis(), yticks) + axesBounds := c.YAxis.Measure(r, canvasBox, yr, c.styleDefaultsAxes(), yticks) axesOuterBox = axesOuterBox.Grow(axesBounds) } if c.YAxisSecondary.Style.Show { - axesBounds := c.YAxisSecondary.Measure(r, canvasBox, yra, c.styleDefaultsAxis(), yticksAlt) + axesBounds := c.YAxisSecondary.Measure(r, canvasBox, yra, c.styleDefaultsAxes(), yticksAlt) axesOuterBox = axesOuterBox.Grow(axesBounds) } @@ -407,13 +407,13 @@ func (c Chart) drawCanvas(r Renderer, canvasBox Box) { func (c Chart) drawAxes(r Renderer, canvasBox Box, xrange, yrange, yrangeAlt Range, xticks, yticks, yticksAlt []Tick) { if c.XAxis.Style.Show { - c.XAxis.Render(r, canvasBox, xrange, c.styleDefaultsAxis(), xticks) + c.XAxis.Render(r, canvasBox, xrange, c.styleDefaultsAxes(), xticks) } if c.YAxis.Style.Show { - c.YAxis.Render(r, canvasBox, yrange, c.styleDefaultsAxis(), yticks) + c.YAxis.Render(r, canvasBox, yrange, c.styleDefaultsAxes(), yticks) } if c.YAxisSecondary.Style.Show { - c.YAxisSecondary.Render(r, canvasBox, yrangeAlt, c.styleDefaultsAxis(), yticksAlt) + c.YAxisSecondary.Render(r, canvasBox, yrangeAlt, c.styleDefaultsAxes(), yticksAlt) } } @@ -472,7 +472,7 @@ func (c Chart) styleDefaultsSeries(seriesIndex int) Style { } } -func (c Chart) styleDefaultsAxis() Style { +func (c Chart) styleDefaultsAxes() Style { return Style{ Font: c.GetFont(), FontColor: DefaultAxisColor, diff --git a/examples/bar_chart/main.go b/examples/bar_chart/main.go new file mode 100644 index 0000000..613deac --- /dev/null +++ b/examples/bar_chart/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/wcharczuk/go-chart" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + sbc := chart.BarChart{ + Height: 512, + BarWidth: 60, + XAxis: chart.Style{ + Show: true, + }, + YAxis: chart.YAxis{ + Style: chart.Style{ + Show: true, + }, + }, + Bars: []chart.Value{ + {Value: 5, Label: "Blue"}, + {Value: 5, Label: "Green"}, + {Value: 4, Label: "Gray"}, + {Value: 3, Label: "Orange"}, + {Value: 3, Label: "Test"}, + {Value: 2, Label: "??"}, + {Value: 1, Label: "!!"}, + }, + } + + res.Header().Set("Content-Type", "image/png") + err := sbc.Render(chart.PNG, res) + if err != nil { + fmt.Printf("Error rendering chart: %v\n", err) + } +} + +func port() string { + if len(os.Getenv("PORT")) > 0 { + return os.Getenv("PORT") + } + return "8080" +} + +func main() { + listenPort := fmt.Sprintf(":%s", port()) + fmt.Printf("Listening on %s\n", listenPort) + http.HandleFunc("/", drawChart) + log.Fatal(http.ListenAndServe(listenPort, nil)) +} diff --git a/examples/market_hours/main.go b/examples/market_hours/main.go index 7b0bb36..2dd1b37 100644 --- a/examples/market_hours/main.go +++ b/examples/market_hours/main.go @@ -7,7 +7,7 @@ import ( ) func drawChart(res http.ResponseWriter, req *http.Request) { - start := chart.Date.Date(2016, 6, 20, chart.Date.Eastern()) + start := chart.Date.Date(2016, 7, 01, chart.Date.Eastern()) end := chart.Date.Date(2016, 07, 21, chart.Date.Eastern()) xv := chart.Sequence.MarketHours(start, end, chart.NYSEOpen, chart.NYSEClose, chart.Date.IsNYSEHoliday) yv := chart.Sequence.RandomWithAverage(len(xv), 200, 10) diff --git a/sequence.go b/sequence.go index 3daa990..6801d64 100644 --- a/sequence.go +++ b/sequence.go @@ -151,9 +151,7 @@ func (s sequence) MarketDayMondayCloses(from, to time.Time, marketOpen, marketCl if isValidTradingDay { times = append(times, cursor) } - println("advance to next monday", cursor.Format(DefaultDateFormat)) cursor = Date.NextDayOfWeek(cursor, time.Monday) - println(cursor.Format(DefaultDateFormat)) } return times } diff --git a/tick.go b/tick.go index 274d070..c00cba0 100644 --- a/tick.go +++ b/tick.go @@ -33,6 +33,10 @@ func (t Ticks) Less(i, j int) bool { // GenerateContinuousTicks generates a set of ticks. func GenerateContinuousTicks(r Renderer, ra Range, isVertical bool, style Style, vf ValueFormatter) []Tick { + if vf == nil { + panic("vf is nil; did you remember to set a default value formatter?") + } + var ticks []Tick min, max := ra.GetMin(), ra.GetMax()