diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ae3f4cb --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch", + "type": "go", + "request": "launch", + "mode": "test", + "remotePath": "", + "port": 2345, + "host": "127.0.0.1", + "program": "${workspaceRoot}", + "env": {}, + "args": [], + "showLog": true + } + ] +} \ No newline at end of file diff --git a/_examples/request_timings/main.go b/_examples/request_timings/main.go index dac7e28..7306062 100644 --- a/_examples/request_timings/main.go +++ b/_examples/request_timings/main.go @@ -2,23 +2,33 @@ package main import ( "net/http" + "strconv" "strings" "time" - util "github.com/blendlabs/go-util" "github.com/wcharczuk/go-chart" ) +func parseInt(str string) int { + v, _ := strconv.Atoi(str) + return v +} + +func parseFloat64(str string) float64 { + v, _ := strconv.ParseFloat(str, 64) + return v +} + func readData() ([]time.Time, []float64) { var xvalues []time.Time var yvalues []float64 - util.ReadFileByLines("requests.csv", func(line string) { + chart.File.ReadByLines("requests.csv", func(line string) { parts := strings.Split(line, ",") - year := util.ParseInt(parts[0]) - month := util.ParseInt(parts[1]) - day := util.ParseInt(parts[2]) - hour := util.ParseInt(parts[3]) - elapsedMillis := util.ParseFloat64(parts[4]) + year := parseInt(parts[0]) + month := parseInt(parts[1]) + day := parseInt(parts[2]) + hour := parseInt(parts[3]) + elapsedMillis := parseFloat64(parts[4]) xvalues = append(xvalues, time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.UTC)) yvalues = append(yvalues, elapsedMillis) }) @@ -27,12 +37,12 @@ func readData() ([]time.Time, []float64) { func releases() []chart.GridLine { return []chart.GridLine{ - {Value: chart.TimeToFloat64(time.Date(2016, 8, 1, 9, 30, 0, 0, time.UTC))}, - {Value: chart.TimeToFloat64(time.Date(2016, 8, 2, 9, 30, 0, 0, time.UTC))}, - {Value: chart.TimeToFloat64(time.Date(2016, 8, 3, 9, 30, 0, 0, time.UTC))}, - {Value: chart.TimeToFloat64(time.Date(2016, 8, 4, 9, 30, 0, 0, time.UTC))}, - {Value: chart.TimeToFloat64(time.Date(2016, 8, 5, 9, 30, 0, 0, time.UTC))}, - {Value: chart.TimeToFloat64(time.Date(2016, 8, 6, 9, 30, 0, 0, time.UTC))}, + {Value: chart.Time.ToFloat64(time.Date(2016, 8, 1, 9, 30, 0, 0, time.UTC))}, + {Value: chart.Time.ToFloat64(time.Date(2016, 8, 2, 9, 30, 0, 0, time.UTC))}, + {Value: chart.Time.ToFloat64(time.Date(2016, 8, 3, 9, 30, 0, 0, time.UTC))}, + {Value: chart.Time.ToFloat64(time.Date(2016, 8, 4, 9, 30, 0, 0, time.UTC))}, + {Value: chart.Time.ToFloat64(time.Date(2016, 8, 5, 9, 30, 0, 0, time.UTC))}, + {Value: chart.Time.ToFloat64(time.Date(2016, 8, 6, 9, 30, 0, 0, time.UTC))}, } } @@ -81,9 +91,14 @@ func drawChart(res http.ResponseWriter, req *http.Request) { Name: "Elapsed Millis", NameStyle: chart.StyleShow(), Style: chart.StyleShow(), + TickStyle: chart.Style{ + TextRotationDegrees: 45.0, + }, }, XAxis: chart.XAxis{ - Style: chart.StyleShow(), + Style: chart.Style{ + Show: true, + }, ValueFormatter: chart.TimeHourValueFormatter, GridMajorStyle: chart.Style{ Show: true, diff --git a/_examples/stock_analysis/main.go b/_examples/stock_analysis/main.go index 3f17025..ddfa4b3 100644 --- a/_examples/stock_analysis/main.go +++ b/_examples/stock_analysis/main.go @@ -60,8 +60,8 @@ func drawChart(res http.ResponseWriter, req *http.Request) { }, } - res.Header().Set("Content-Type", "image/svg+xml") - graph.Render(chart.SVG, res) + res.Header().Set("Content-Type", "image/png") + graph.Render(chart.PNG, res) } func xvalues() []time.Time { diff --git a/_examples/text_rotation/main.go b/_examples/text_rotation/main.go new file mode 100644 index 0000000..76bb2b0 --- /dev/null +++ b/_examples/text_rotation/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "net/http" + + "github.com/wcharczuk/go-chart" + "github.com/wcharczuk/go-chart/drawing" +) + +func drawChart(res http.ResponseWriter, req *http.Request) { + f, _ := chart.GetDefaultFont() + r, _ := chart.PNG(1024, 1024) + + chart.Draw.Text(r, "Test", 64, 64, chart.Style{ + FontColor: drawing.ColorBlack, + FontSize: 18, + Font: f, + }) + + chart.Draw.Text(r, "Test", 64, 64, chart.Style{ + FontColor: drawing.ColorBlack, + FontSize: 18, + Font: f, + TextRotationDegrees: 45.0, + }) + + tb := chart.Draw.MeasureText(r, "Test", chart.Style{ + FontColor: drawing.ColorBlack, + FontSize: 18, + Font: f, + }).Shift(64, 64) + + tbc := tb.Corners().Rotate(45) + + chart.Draw.BoxCorners(r, tbc, chart.Style{ + StrokeColor: drawing.ColorRed, + StrokeWidth: 2, + }) + + tbcb := tbc.Box() + chart.Draw.Box(r, tbcb, chart.Style{ + StrokeColor: drawing.ColorBlue, + StrokeWidth: 2, + }) + + res.Header().Set("Content-Type", "image/png") + r.Save(res) +} + +func main() { + http.HandleFunc("/", drawChart) + http.ListenAndServe(":8080", nil) +} diff --git a/box.go b/box.go index 0705749..98c1f17 100644 --- a/box.go +++ b/box.go @@ -1,6 +1,9 @@ package chart -import "fmt" +import ( + "fmt" + "math" +) // Box represents the main 4 dimensions of a box. type Box struct { @@ -76,8 +79,8 @@ func (b Box) Height() int { // Center returns the center of the box func (b Box) Center() (x, y int) { - w, h := b.Width(), b.Height() - return b.Left + w>>1, b.Top + h>>1 + w2, h2 := b.Width()>>1, b.Height()>>1 + return b.Left + w2, b.Top + h2 } // Aspect returns the aspect ratio of the box. @@ -139,6 +142,16 @@ func (b Box) Shift(x, y int) Box { } } +// Corners returns the box as a set of corners. +func (b Box) Corners() BoxCorners { + return BoxCorners{ + TopLeft: Point{b.Left, b.Top}, + TopRight: Point{b.Right, b.Top}, + BottomRight: Point{b.Right, b.Bottom}, + BottomLeft: Point{b.Left, b.Bottom}, + } +} + // Fit is functionally the inverse of grow. // Fit maintains the original aspect ratio of the `other` box, // but constrains it to the bounds of the target box. @@ -219,3 +232,99 @@ func (b Box) OuterConstrain(bounds, other Box) Box { } return newBox } + +// BoxCorners is a box with independent corners. +type BoxCorners struct { + TopLeft, TopRight, BottomRight, BottomLeft Point +} + +// Box return the BoxCorners as a regular box. +func (bc BoxCorners) Box() Box { + return Box{ + Top: Math.MinInt(bc.TopLeft.Y, bc.TopRight.Y), + Left: Math.MinInt(bc.TopLeft.X, bc.BottomLeft.X), + Right: Math.MaxInt(bc.TopRight.X, bc.BottomRight.X), + Bottom: Math.MaxInt(bc.BottomLeft.Y, bc.BottomRight.Y), + } +} + +// Width returns the width +func (bc BoxCorners) Width() int { + minLeft := Math.MinInt(bc.TopLeft.X, bc.BottomLeft.X) + maxRight := Math.MaxInt(bc.TopRight.X, bc.BottomRight.X) + return maxRight - minLeft +} + +// Height returns the height +func (bc BoxCorners) Height() int { + minTop := Math.MinInt(bc.TopLeft.Y, bc.TopRight.Y) + maxBottom := Math.MaxInt(bc.BottomLeft.Y, bc.BottomRight.Y) + return maxBottom - minTop +} + +// Center returns the center of the box +func (bc BoxCorners) Center() (x, y int) { + + left := Math.MeanInt(bc.TopLeft.X, bc.BottomLeft.X) + right := Math.MeanInt(bc.TopRight.X, bc.BottomRight.X) + x = ((right - left) >> 1) + left + + top := Math.MeanInt(bc.TopLeft.Y, bc.TopRight.Y) + bottom := Math.MeanInt(bc.BottomLeft.Y, bc.BottomRight.Y) + y = ((bottom - top) >> 1) + top + + return +} + +// Rotate rotates the box. +func (bc BoxCorners) Rotate(thetaDegrees float64) BoxCorners { + cx, cy := bc.Center() + + thetaRadians := Math.DegreesToRadians(thetaDegrees) + + tlx, tly := Math.RotateCoordinate(cx, cy, bc.TopLeft.X, bc.TopLeft.Y, thetaRadians) + trx, try := Math.RotateCoordinate(cx, cy, bc.TopRight.X, bc.TopRight.Y, thetaRadians) + brx, bry := Math.RotateCoordinate(cx, cy, bc.BottomRight.X, bc.BottomRight.Y, thetaRadians) + blx, bly := Math.RotateCoordinate(cx, cy, bc.BottomLeft.X, bc.BottomLeft.Y, thetaRadians) + + return BoxCorners{ + TopLeft: Point{tlx, tly}, + TopRight: Point{trx, try}, + BottomRight: Point{brx, bry}, + BottomLeft: Point{blx, bly}, + } +} + +// Equals returns if the box equals another box. +func (bc BoxCorners) Equals(other BoxCorners) bool { + return bc.TopLeft.Equals(other.TopLeft) && + bc.TopRight.Equals(other.TopRight) && + bc.BottomRight.Equals(other.BottomRight) && + bc.BottomLeft.Equals(other.BottomLeft) +} + +func (bc BoxCorners) String() string { + return fmt.Sprintf("BoxC{%s,%s,%s,%s}", bc.TopLeft.String(), bc.TopRight.String(), bc.BottomRight.String(), bc.BottomLeft.String()) +} + +// Point is an X,Y pair +type Point struct { + X, Y int +} + +// DistanceTo calculates the distance to another point. +func (p Point) DistanceTo(other Point) float64 { + dx := math.Pow(float64(p.X-other.X), 2) + dy := math.Pow(float64(p.Y-other.Y), 2) + return math.Pow(dx+dy, 0.5) +} + +// Equals returns if a point equals another point. +func (p Point) Equals(other Point) bool { + return p.X == other.X && p.Y == other.Y +} + +// String returns a string representation of the point. +func (p Point) String() string { + return fmt.Sprintf("P{%d,%d}", p.X, p.Y) +} diff --git a/box_test.go b/box_test.go index ba3b1ed..89eafcf 100644 --- a/box_test.go +++ b/box_test.go @@ -143,3 +143,46 @@ func TestBoxShift(t *testing.T) { assert.Equal(11, shifted.Right) assert.Equal(12, shifted.Bottom) } + +func TestBoxCenter(t *testing.T) { + assert := assert.New(t) + + b := Box{ + Top: 10, + Left: 10, + Right: 20, + Bottom: 30, + } + cx, cy := b.Center() + assert.Equal(15, cx) + assert.Equal(20, cy) +} + +func TestBoxCornersCenter(t *testing.T) { + assert := assert.New(t) + + bc := BoxCorners{ + TopLeft: Point{5, 5}, + TopRight: Point{15, 5}, + BottomRight: Point{15, 15}, + BottomLeft: Point{5, 15}, + } + + cx, cy := bc.Center() + assert.Equal(10, cx) + assert.Equal(10, cy) +} + +func TestBoxCornersRotate(t *testing.T) { + assert := assert.New(t) + + bc := BoxCorners{ + TopLeft: Point{5, 5}, + TopRight: Point{15, 5}, + BottomRight: Point{15, 15}, + BottomLeft: Point{5, 15}, + } + + rotated := bc.Rotate(45) + assert.True(rotated.TopLeft.Equals(Point{10, 3}), rotated.String()) +} diff --git a/chart.go b/chart.go index d53003d..236d714 100644 --- a/chart.go +++ b/chart.go @@ -102,12 +102,12 @@ 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, xr, yr, yra, xt, yt, yta) + canvasBox = c.getAxesAdjustedCanvasBox(r, canvasBox, xr, yr, yra, xt, yt, yta) xr, yr, yra = c.setRangeDomains(canvasBox, xr, yr, yra) // do a second pass in case things haven't settled yet. xt, yt, yta = c.getAxesTicks(r, xr, yr, yra, xf, yf, yfa) - canvasBox = c.getAxisAdjustedCanvasBox(r, canvasBox, xr, yr, yra, xt, yt, yta) + canvasBox = c.getAxesAdjustedCanvasBox(r, canvasBox, xr, yr, yra, xt, yt, yta) xr, yr, yra = c.setRangeDomains(canvasBox, xr, yr, yra) } @@ -320,7 +320,7 @@ func (c Chart) getAxesTicks(r Renderer, xr, yr, yar Range, xf, yf, yfa ValueForm return } -func (c Chart) getAxisAdjustedCanvasBox(r Renderer, canvasBox Box, xr, yr, yra Range, xticks, yticks, yticksAlt []Tick) Box { +func (c Chart) getAxesAdjustedCanvasBox(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.styleDefaultsAxes(), xticks) diff --git a/draw.go b/draw.go index 25ca38c..e06462e 100644 --- a/draw.go +++ b/draw.go @@ -140,6 +140,7 @@ func (d draw) HistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s // MeasureAnnotation measures how big an annotation would be. func (d draw) MeasureAnnotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) Box { style.WriteToRenderer(r) + defer r.ResetStyle() textBox := r.MeasureText(label) textWidth := textBox.Width() @@ -168,6 +169,8 @@ func (d draw) MeasureAnnotation(r Renderer, canvasBox Box, style Style, lx, ly i // Annotation draws an anotation with a renderer. func (d draw) Annotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) { style.GetTextOptions().WriteToRenderer(r) + defer r.ResetStyle() + textBox := r.MeasureText(label) textWidth := textBox.Width() halfTextHeight := textBox.Height() >> 1 @@ -209,7 +212,8 @@ func (d draw) Annotation(r Renderer, canvasBox Box, style Style, lx, ly int, lab // Box draws a box with a given style. func (d draw) Box(r Renderer, b Box, s Style) { - s.WriteToRenderer(r) + s.GetFillAndStrokeOptions().WriteToRenderer(r) + defer r.ResetStyle() r.MoveTo(b.Left, b.Top) r.LineTo(b.Right, b.Top) @@ -219,19 +223,45 @@ func (d draw) Box(r Renderer, b Box, s Style) { r.FillStroke() } +func (d draw) BoxRotated(r Renderer, b Box, thetaDegrees float64, s Style) { + d.BoxCorners(r, b.Corners().Rotate(thetaDegrees), s) +} + +func (d draw) BoxCorners(r Renderer, bc BoxCorners, s Style) { + s.GetFillAndStrokeOptions().WriteToRenderer(r) + defer r.ResetStyle() + + r.MoveTo(bc.TopLeft.X, bc.TopLeft.Y) + r.LineTo(bc.TopRight.X, bc.TopRight.Y) + r.LineTo(bc.BottomRight.X, bc.BottomRight.Y) + r.LineTo(bc.BottomLeft.X, bc.BottomLeft.Y) + r.Close() + r.FillStroke() +} + // DrawText draws text with a given style. func (d draw) Text(r Renderer, text string, x, y int, style Style) { style.GetTextOptions().WriteToRenderer(r) + defer r.ResetStyle() + r.Text(text, x, y) } +func (d draw) MeasureText(r Renderer, text string, style Style) Box { + style.GetTextOptions().WriteToRenderer(r) + defer r.ResetStyle() + + return r.MeasureText(text) +} + // TextWithin draws the text within a given box. func (d draw) TextWithin(r Renderer, text string, box Box, style Style) { + style.GetTextOptions().WriteToRenderer(r) + defer r.ResetStyle() + lines := Text.WrapFit(r, text, box.Width(), style) linesBox := Text.MeasureLines(r, lines, style) - style.GetTextOptions().WriteToRenderer(r) - y := box.Top switch style.GetTextVerticalAlign() { @@ -252,7 +282,11 @@ func (d draw) TextWithin(r Renderer, text string, box Box, style Style) { default: tx = box.Left } - ty = y + lineBox.Height() + if style.TextRotationDegrees == 0 { + ty = y + lineBox.Height() + } else { + ty = y + } d.Text(r, line, tx, ty, style) y += lineBox.Height() + style.GetTextLineSpacing() diff --git a/file_util.go b/file_util.go new file mode 100644 index 0000000..7fd66bf --- /dev/null +++ b/file_util.go @@ -0,0 +1,52 @@ +package chart + +import ( + "bufio" + "io" + "os" + + exception "github.com/blendlabs/go-exception" +) + +var ( + // File contains file utility functions + File = fileUtil{} +) + +type fileUtil struct{} + +// ReadByLines reads a file and calls the handler for each line. +func (fu fileUtil) ReadByLines(filePath string, handler func(line string)) error { + if f, err := os.Open(filePath); err == nil { + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + handler(line) + } + } else { + return exception.Wrap(err) + } + return nil +} + +// ReadByChunks reads a file in `chunkSize` pieces, dispatched to the handler. +func (fu fileUtil) ReadByChunks(filePath string, chunkSize int, handler func(line []byte)) error { + if f, err := os.Open(filePath); err == nil { + defer f.Close() + + chunk := make([]byte, chunkSize) + for { + readBytes, err := f.Read(chunk) + if err == io.EOF { + break + } + readData := chunk[:readBytes] + handler(readData) + } + } else { + return exception.Wrap(err) + } + return nil +} diff --git a/legend.go b/legend.go index 6722a6d..20d4a04 100644 --- a/legend.go +++ b/legend.go @@ -54,9 +54,7 @@ func Legend(c *Chart, userDefaults ...Style) Renderable { Bottom: legend.Top + legendPadding.Top, } - r.SetFont(legendStyle.GetFont()) - r.SetFontColor(legendStyle.GetFontColor()) - r.SetFontSize(legendStyle.GetFontSize()) + legendStyle.GetTextOptions().WriteToRenderer(r) // measure labelCount := 0 @@ -79,6 +77,8 @@ func Legend(c *Chart, userDefaults ...Style) Renderable { Draw.Box(r, legend, legendStyle) + legendStyle.GetTextOptions().WriteToRenderer(r) + ycursor := legendContent.Top tx := legendContent.Left legendCount := 0 diff --git a/market_hours_range.go b/market_hours_range.go index 3b7c570..265cffa 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -33,12 +33,12 @@ func (mhr MarketHoursRange) IsZero() bool { // GetMin returns the min value. func (mhr MarketHoursRange) GetMin() float64 { - return TimeToFloat64(mhr.Min) + return Time.ToFloat64(mhr.Min) } // GetMax returns the max value. func (mhr MarketHoursRange) GetMax() float64 { - return TimeToFloat64(mhr.GetEffectiveMax()) + return Time.ToFloat64(mhr.GetEffectiveMax()) } // GetEffectiveMax gets either the close on the max, or the max itself. @@ -52,13 +52,13 @@ func (mhr MarketHoursRange) GetEffectiveMax() time.Time { // SetMin sets the min value. func (mhr *MarketHoursRange) SetMin(min float64) { - mhr.Min = Float64ToTime(min) + mhr.Min = Time.FromFloat64(min) mhr.Min = mhr.Min.In(mhr.GetTimezone()) } // SetMax sets the max value. func (mhr *MarketHoursRange) SetMax(max float64) { - mhr.Max = Float64ToTime(max) + mhr.Max = Time.FromFloat64(max) mhr.Max = mhr.Max.In(mhr.GetTimezone()) } @@ -159,7 +159,7 @@ func (mhr *MarketHoursRange) makeTicks(vf ValueFormatter, times []time.Time) []T ticks := make([]Tick, len(times)) for index, t := range times { ticks[index] = Tick{ - Value: TimeToFloat64(t), + Value: Time.ToFloat64(t), Label: vf(t), } } @@ -172,7 +172,7 @@ func (mhr MarketHoursRange) String() string { // Translate maps a given value into the ContinuousRange space. func (mhr MarketHoursRange) Translate(value float64) int { - valueTime := Float64ToTime(value) + valueTime := Time.FromFloat64(value) valueTimeEastern := valueTime.In(Date.Eastern()) totalSeconds := Date.CalculateMarketSecondsBetween(mhr.Min, mhr.GetEffectiveMax(), mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.HolidayProvider) valueDelta := Date.CalculateMarketSecondsBetween(mhr.Min, valueTimeEastern, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.HolidayProvider) diff --git a/market_hours_range_test.go b/market_hours_range_test.go index 14b20fb..45458c4 100644 --- a/market_hours_range_test.go +++ b/market_hours_range_test.go @@ -35,9 +35,9 @@ func TestMarketHoursRangeTranslate(t *testing.T) { weds := time.Date(2016, 07, 20, 9, 30, 0, 0, Date.Eastern()) - assert.Equal(0, r.Translate(TimeToFloat64(r.Min))) - assert.Equal(400, r.Translate(TimeToFloat64(weds))) - assert.Equal(1000, r.Translate(TimeToFloat64(r.Max))) + assert.Equal(0, r.Translate(Time.ToFloat64(r.Min))) + assert.Equal(400, r.Translate(Time.ToFloat64(weds))) + assert.Equal(1000, r.Translate(Time.ToFloat64(r.Max))) } func TestMarketHoursRangeGetTicks(t *testing.T) { @@ -67,6 +67,6 @@ func TestMarketHoursRangeGetTicks(t *testing.T) { ticks := ra.GetTicks(r, defaults, TimeValueFormatter) assert.NotEmpty(ticks) assert.Len(ticks, 5) - assert.NotEqual(TimeToFloat64(ra.Min), ticks[0].Value) + assert.NotEqual(Time.ToFloat64(ra.Min), ticks[0].Value) assert.NotEmpty(ticks[0].Label) } diff --git a/math.go b/math_util.go similarity index 84% rename from math.go rename to math_util.go index 77180e8..8c3f5da 100644 --- a/math.go +++ b/math_util.go @@ -19,16 +19,6 @@ const ( _r2d = (180.0 / math.Pi) ) -// TimeToFloat64 returns a float64 representation of a time. -func TimeToFloat64(t time.Time) float64 { - return float64(t.UnixNano()) -} - -// Float64ToTime returns a time from a float64. -func Float64ToTime(tf float64) time.Time { - return time.Unix(0, int64(tf)) -} - var ( // Math contains helper methods for common math operations. Math = &mathUtil{} @@ -144,6 +134,16 @@ func (m mathUtil) AbsInt(value int) int { return value } +// Mean returns the mean of a set of values +func (m mathUtil) Mean(values ...float64) float64 { + return m.Sum(values...) / float64(len(values)) +} + +// MeanInt returns the mean of a set of integer values. +func (m mathUtil) MeanInt(values ...int) int { + return m.SumInt(values...) / len(values) +} + // Sum sums a set of values. func (m mathUtil) Sum(values ...float64) float64 { var total float64 @@ -214,9 +214,18 @@ func (m mathUtil) DegreesToCompass(deg float64) float64 { } // CirclePoint returns the absolute position of a circle diameter point given -// by the radius and the angle. -func (m mathUtil) CirclePoint(cx, cy int, radius, angleRadians float64) (x, y int) { - x = cx + int(radius*math.Sin(angleRadians)) - y = cy - int(radius*math.Cos(angleRadians)) +// by the radius and the theta. +func (m mathUtil) CirclePoint(cx, cy int, radius, thetaRadians float64) (x, y int) { + x = cx + int(radius*math.Sin(thetaRadians)) + y = cy - int(radius*math.Cos(thetaRadians)) + return +} + +func (m mathUtil) RotateCoordinate(cx, cy, x, y int, thetaRadians float64) (rx, ry int) { + tempX, tempY := float64(x-cx), float64(y-cy) + rotatedX := tempX*math.Cos(thetaRadians) - tempY*math.Sin(thetaRadians) + rotatedY := tempX*math.Sin(thetaRadians) + tempY*math.Cos(thetaRadians) + rx = int(rotatedX) + cx + ry = int(rotatedY) + cy return } diff --git a/math_test.go b/math_util_test.go similarity index 89% rename from math_test.go rename to math_util_test.go index a4c4006..c629af4 100644 --- a/math_test.go +++ b/math_util_test.go @@ -160,3 +160,25 @@ func TestRadianAdd(t *testing.T) { assert.Equal(_pi, Math.RadianAdd(_pi, _2pi)) assert.Equal(_pi, Math.RadianAdd(_pi, -_2pi)) } + +func TestRotateCoordinate90(t *testing.T) { + assert := assert.New(t) + + cx, cy := 10, 10 + x, y := 5, 10 + + rx, ry := Math.RotateCoordinate(cx, cy, x, y, Math.DegreesToRadians(90)) + assert.Equal(10, rx) + assert.Equal(5, ry) +} + +func TestRotateCoordinate45(t *testing.T) { + assert := assert.New(t) + + cx, cy := 10, 10 + x, y := 5, 10 + + rx, ry := Math.RotateCoordinate(cx, cy, x, y, Math.DegreesToRadians(45)) + assert.Equal(7, rx) + assert.Equal(7, ry) +} diff --git a/raster_renderer.go b/raster_renderer.go index 1b0cf96..326dcb5 100644 --- a/raster_renderer.go +++ b/raster_renderer.go @@ -28,11 +28,16 @@ type rasterRenderer struct { i *image.RGBA gc *drawing.RasterGraphicContext - rotateRadians float64 + rotateRadians *float64 s Style } +func (rr *rasterRenderer) ResetStyle() { + rr.s = Style{Font: rr.s.Font} + rr.ClearTextRotation() +} + // GetDPI returns the dpi. func (rr *rasterRenderer) GetDPI() float64 { return rr.gc.GetDPI() @@ -177,35 +182,40 @@ func (rr *rasterRenderer) MeasureText(body string) Box { t = 0 } - return Box{ + textBox := Box{ Top: int(math.Ceil(t)), Left: int(math.Ceil(l)), Right: int(math.Ceil(r)), Bottom: int(math.Ceil(b)), } + if rr.rotateRadians == nil { + return textBox + } + + return textBox.Corners().Rotate(Math.RadiansToDegrees(*rr.rotateRadians)).Box() } // SetTextRotation sets a text rotation. func (rr *rasterRenderer) SetTextRotation(radians float64) { - rr.rotateRadians = radians + rr.rotateRadians = &radians } func (rr *rasterRenderer) getCoords(x, y int) (xf, yf int) { - if rr.rotateRadians == 0 { + if rr.rotateRadians == nil { xf = x yf = y return } rr.gc.Translate(float64(x), float64(y)) - rr.gc.Rotate(rr.rotateRadians) + rr.gc.Rotate(*rr.rotateRadians) return } // ClearTextRotation clears text rotation. func (rr *rasterRenderer) ClearTextRotation() { rr.gc.SetMatrixTransform(drawing.NewIdentityMatrix()) - rr.rotateRadians = 0 + rr.rotateRadians = nil } // Save implements the interface method. diff --git a/renderer.go b/renderer.go index 2047a40..7eb06bb 100644 --- a/renderer.go +++ b/renderer.go @@ -9,6 +9,9 @@ import ( // Renderer represents the basic methods required to draw a chart. type Renderer interface { + // ResetStyle should reset any style related settings on the renderer. + ResetStyle() + // GetDPI gets the DPI for the renderer. GetDPI() float64 diff --git a/style.go b/style.go index 9563066..d1db16b 100644 --- a/style.go +++ b/style.go @@ -33,6 +33,7 @@ type Style struct { TextVerticalAlign TextVerticalAlign TextWrap TextWrap TextLineSpacing int + TextRotationDegrees float64 //0 is unset or normal } // IsZero returns if the object is set or not. @@ -241,6 +242,16 @@ func (s Style) GetTextLineSpacing(defaults ...int) int { return s.TextLineSpacing } +// GetTextRotationDegrees returns the text rotation in degrees. +func (s Style) GetTextRotationDegrees(defaults ...float64) float64 { + if s.TextRotationDegrees == 0 { + if len(defaults) > 0 { + return defaults[0] + } + } + return s.TextRotationDegrees +} + // WriteToRenderer passes the style's options to a renderer. func (s Style) WriteToRenderer(r Renderer) { r.SetStrokeColor(s.GetStrokeColor()) @@ -250,6 +261,11 @@ func (s Style) WriteToRenderer(r Renderer) { r.SetFont(s.GetFont()) r.SetFontColor(s.GetFontColor()) r.SetFontSize(s.GetFontSize()) + + r.ClearTextRotation() + if s.GetTextRotationDegrees() != 0 { + r.SetTextRotation(Math.DegreesToRadians(s.GetTextRotationDegrees())) + } } // WriteDrawingOptionsToRenderer passes just the drawing style options to a renderer. @@ -281,6 +297,7 @@ func (s Style) InheritFrom(defaults Style) (final Style) { final.TextVerticalAlign = s.GetTextVerticalAlign(defaults.TextVerticalAlign) final.TextWrap = s.GetTextWrap(defaults.TextWrap) final.TextLineSpacing = s.GetTextLineSpacing(defaults.TextLineSpacing) + final.TextRotationDegrees = s.GetTextRotationDegrees(defaults.TextRotationDegrees) return } @@ -320,5 +337,6 @@ func (s Style) GetTextOptions() Style { TextVerticalAlign: s.TextVerticalAlign, TextWrap: s.TextWrap, TextLineSpacing: s.TextLineSpacing, + TextRotationDegrees: s.TextRotationDegrees, } } diff --git a/time_series.go b/time_series.go index df779eb..025e86a 100644 --- a/time_series.go +++ b/time_series.go @@ -30,14 +30,14 @@ func (ts TimeSeries) Len() int { // GetValue gets a value at a given index. func (ts TimeSeries) GetValue(index int) (x, y float64) { - x = TimeToFloat64(ts.XValues[index]) + x = Time.ToFloat64(ts.XValues[index]) y = ts.YValues[index] return } // GetLastValue gets the last value. func (ts TimeSeries) GetLastValue() (x, y float64) { - x = TimeToFloat64(ts.XValues[len(ts.XValues)-1]) + x = Time.ToFloat64(ts.XValues[len(ts.XValues)-1]) y = ts.YValues[len(ts.YValues)-1] return } diff --git a/time_util.go b/time_util.go new file mode 100644 index 0000000..8937546 --- /dev/null +++ b/time_util.go @@ -0,0 +1,20 @@ +package chart + +import "time" + +var ( + // Time contains time utility functions. + Time = timeUtil{} +) + +type timeUtil struct{} + +// TimeToFloat64 returns a float64 representation of a time. +func (tu timeUtil) ToFloat64(t time.Time) float64 { + return float64(t.UnixNano()) +} + +// Float64ToTime returns a time from a float64. +func (tu timeUtil) FromFloat64(tf float64) time.Time { + return time.Unix(0, int64(tf)) +} diff --git a/value_formatter_test.go b/value_formatter_test.go index 0675914..3bd8409 100644 --- a/value_formatter_test.go +++ b/value_formatter_test.go @@ -11,7 +11,7 @@ func TestTimeValueFormatterWithFormat(t *testing.T) { assert := assert.New(t) d := time.Now() - di := TimeToFloat64(d) + di := Time.ToFloat64(d) df := float64(di) s := TimeValueFormatterWithFormat(d, DefaultDateFormat) diff --git a/vector_renderer.go b/vector_renderer.go index d65e497..c36f065 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -32,11 +32,15 @@ type vectorRenderer struct { b *bytes.Buffer c *canvas s *Style - r float64 p []string fc *font.Drawer } +func (vr *vectorRenderer) ResetStyle() { + vr.s = &Style{Font: vr.s.Font} + vr.fc = nil +} + // GetDPI returns the dpi. func (vr *vectorRenderer) GetDPI() float64 { return vr.dpi @@ -168,18 +172,22 @@ func (vr *vectorRenderer) MeasureText(body string) (box Box) { box.Right = w box.Bottom = int(drawing.PointsToPixels(vr.dpi, vr.s.FontSize)) + if vr.c.textTheta == nil { + return + } + box = box.Corners().Rotate(Math.RadiansToDegrees(*vr.c.textTheta)).Box() } return } // SetTextRotation sets the text rotation. func (vr *vectorRenderer) SetTextRotation(radians float64) { - vr.c.r = radians + vr.c.textTheta = &radians } // ClearTextRotation clears the text rotation. func (vr *vectorRenderer) ClearTextRotation() { - vr.c.r = 0 + vr.c.textTheta = nil } // Save saves the renderer's contents to a writer. @@ -196,11 +204,11 @@ func newCanvas(w io.Writer) *canvas { } type canvas struct { - w io.Writer - dpi float64 - r float64 - width int - height int + w io.Writer + dpi float64 + textTheta *float64 + width int + height int } func (c *canvas) Start(width, height int) { @@ -218,10 +226,10 @@ func (c *canvas) Path(d string, style Style) { } func (c *canvas) Text(x, y int, body string, style Style) { - if c.r == 0 { + if c.textTheta == nil { c.w.Write([]byte(fmt.Sprintf(`%s`, x, y, c.styleAsSVG(style), body))) } else { - transform := fmt.Sprintf(` transform="rotate(%0.2f,%d,%d)"`, Math.RadiansToDegrees(c.r), x, y) + transform := fmt.Sprintf(` transform="rotate(%0.2f,%d,%d)"`, Math.RadiansToDegrees(*c.textTheta), x, y) c.w.Write([]byte(fmt.Sprintf(`%s`, x, y, c.styleAsSVG(style), transform, body))) } } diff --git a/xaxis.go b/xaxis.go index f1326db..0728e9b 100644 --- a/xaxis.go +++ b/xaxis.go @@ -7,13 +7,15 @@ import ( // XAxis represents the horizontal axis. type XAxis struct { - Name string - NameStyle Style + Name string + NameStyle Style + Style Style ValueFormatter ValueFormatter Range Range - Ticks []Tick + TickStyle Style + Ticks []Tick TickPosition TickPosition GridLines []GridLine @@ -68,20 +70,20 @@ 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 { - tickStyle := xa.Style.InheritFrom(defaults) + tickStyle := xa.TickStyle.InheritFrom(xa.Style.InheritFrom(defaults)) sort.Sort(Ticks(ticks)) tp := xa.GetTickPosition() + var ltx, rtx int + var tx, ty int var left, right, bottom = math.MaxInt32, 0, 0 for index, t := range ticks { v := t.Value - tickStyle.GetTextOptions().WriteToRenderer(r) - tb := r.MeasureText(t.Label) + tb := Draw.MeasureText(r, t.Label, tickStyle.GetTextOptions()) - var ltx, rtx int - tx := canvasBox.Left + ra.Translate(v) - ty := canvasBox.Bottom + DefaultXAxisMargin + tb.Height() + tx = canvasBox.Left + ra.Translate(v) + ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height() switch tp { case TickPositionUnderTick, TickPositionUnset: ltx = tx - tb.Width()>>1 @@ -101,7 +103,7 @@ func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic } if xa.NameStyle.Show && len(xa.Name) > 0 { - tb := r.MeasureText(xa.Name) + tb := Draw.MeasureText(r, xa.Name, xa.NameStyle.InheritFrom(defaults)) bottom += DefaultXAxisMargin + tb.Height() } @@ -115,7 +117,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) { - tickStyle := xa.Style.InheritFrom(defaults) + tickStyle := xa.TickStyle.InheritFrom(xa.Style.InheritFrom(defaults)) tickStyle.GetStrokeOptions().WriteToRenderer(r) r.MoveTo(canvasBox.Left, canvasBox.Bottom) @@ -139,25 +141,31 @@ func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick r.LineTo(tx, canvasBox.Bottom+DefaultVerticalTickHeight) r.Stroke() - tickStyle.GetTextOptions().WriteToRenderer(r) - tb := r.MeasureText(t.Label) + tickWithAxisStyle := xa.TickStyle.InheritFrom(xa.Style.InheritFrom(defaults)) + tb := Draw.MeasureText(r, t.Label, tickWithAxisStyle) switch tp { case TickPositionUnderTick, TickPositionUnset: - ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height() - r.Text(t.Label, tx-tb.Width()>>1, ty) + if tickStyle.TextRotationDegrees == 0 { + tx = tx - tb.Width()>>1 + ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height() + } else { + ty = canvasBox.Bottom + (2 * DefaultXAxisMargin) + } + Draw.Text(r, t.Label, tx, ty, tickWithAxisStyle) maxTextHeight = Math.MaxInt(maxTextHeight, tb.Height()) break case TickPositionBetweenTicks: if index > 0 { llx := ra.Translate(ticks[index-1].Value) ltx := canvasBox.Left + llx - finalTickStyle := tickStyle.InheritFrom(Style{TextHorizontalAlign: TextHorizontalAlignCenter}) + finalTickStyle := tickWithAxisStyle.InheritFrom(Style{TextHorizontalAlign: TextHorizontalAlignCenter}) + Draw.TextWithin(r, t.Label, Box{ Left: ltx, Right: tx, Top: canvasBox.Bottom + DefaultXAxisMargin, - Bottom: canvasBox.Bottom + DefaultXAxisMargin + tb.Height(), + Bottom: canvasBox.Bottom + DefaultXAxisMargin, }, finalTickStyle) ftb := Text.MeasureLines(r, Text.WrapFit(r, t.Label, tx-ltx, finalTickStyle), finalTickStyle) @@ -169,11 +177,10 @@ func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick nameStyle := xa.NameStyle.InheritFrom(defaults) if xa.NameStyle.Show && len(xa.Name) > 0 { - nameStyle.GetTextOptions().WriteToRenderer(r) - tb := r.MeasureText(xa.Name) + tb := Draw.MeasureText(r, xa.Name, nameStyle) tx := canvasBox.Right - (canvasBox.Width()>>1 + tb.Width()>>1) ty := canvasBox.Bottom + DefaultXAxisMargin + maxTextHeight + DefaultXAxisMargin + tb.Height() - r.Text(xa.Name, tx, ty) + Draw.Text(r, xa.Name, tx, ty, nameStyle) } if xa.GridMajorStyle.Show || xa.GridMinorStyle.Show { diff --git a/yaxis.go b/yaxis.go index fe7ab44..0695276 100644 --- a/yaxis.go +++ b/yaxis.go @@ -20,9 +20,10 @@ type YAxis struct { ValueFormatter ValueFormatter Range Range + TickStyle Style Ticks []Tick - GridLines []GridLine + GridLines []GridLine GridMajorStyle Style GridMinorStyle Style } @@ -42,6 +43,11 @@ func (ya YAxis) GetStyle() Style { return ya.Style } +// GetTickStyle returns the tick style. +func (ya YAxis) GetTickStyle() Style { + return ya.TickStyle +} + // GetTicks returns the ticks for a series. // The coalesce priority is: // - User Supplied Ticks (i.e. Ticks array on the axis itself). @@ -68,8 +74,6 @@ 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).WriteToRenderer(r) - sort.Sort(Ticks(ticks)) var tx int @@ -79,6 +83,7 @@ func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic tx = canvasBox.Left - DefaultYAxisMargin } + ya.TickStyle.InheritFrom(ya.Style.InheritFrom(defaults)).WriteToRenderer(r) var minx, maxx, miny, maxy = math.MaxInt32, 0, math.MaxInt32, 0 var maxTextHeight int for _, t := range ticks { @@ -86,14 +91,13 @@ func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic ly := canvasBox.Bottom - ra.Translate(v) tb := r.MeasureText(t.Label) + tbh2 := tb.Height() >> 1 finalTextX := tx if ya.AxisType == YAxisSecondary { finalTextX = tx - tb.Width() } - if tb.Height() > maxTextHeight { - maxTextHeight = tb.Height() - } + maxTextHeight = Math.MaxInt(tb.Height(), maxTextHeight) if ya.AxisType == YAxisPrimary { minx = canvasBox.Right @@ -102,8 +106,9 @@ func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic minx = Math.MinInt(minx, finalTextX) maxx = Math.MaxInt(maxx, tx) } - miny = Math.MinInt(miny, ly-tb.Height()>>1) - maxy = Math.MaxInt(maxy, ly+tb.Height()>>1) + + miny = Math.MinInt(miny, ly-tbh2) + maxy = Math.MaxInt(maxy, ly+tbh2) } if ya.NameStyle.Show && len(ya.Name) > 0 { @@ -120,11 +125,12 @@ 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).WriteToRenderer(r) + tickStyle := ya.TickStyle.InheritFrom(ya.Style.InheritFrom(defaults)) + tickStyle.WriteToRenderer(r) sort.Sort(Ticks(ticks)) - sw := ya.Style.GetStrokeWidth(defaults.StrokeWidth) + sw := tickStyle.GetStrokeWidth(defaults.StrokeWidth) var lx int var tx int @@ -141,23 +147,30 @@ func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick r.Stroke() var maxTextWidth int + var finalTextX, finalTextY int for _, t := range ticks { v := t.Value ly := canvasBox.Bottom - ra.Translate(v) - tb := r.MeasureText(t.Label) + tb := Draw.MeasureText(r, t.Label, tickStyle) if tb.Width() > maxTextWidth { maxTextWidth = tb.Width() } - finalTextX := tx - finalTextY := ly + tb.Height()>>1 if ya.AxisType == YAxisSecondary { finalTextX = tx - tb.Width() + } else { + finalTextX = tx } - r.Text(t.Label, finalTextX, finalTextY) + if tickStyle.TextRotationDegrees == 0 { + finalTextY = ly + tb.Height()>>1 + } else { + finalTextY = ly + } + + tickStyle.WriteToRenderer(r) r.MoveTo(lx, ly) if ya.AxisType == YAxisPrimary { @@ -166,15 +179,14 @@ func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick r.LineTo(lx-DefaultHorizontalTickWidth, ly) } r.Stroke() + + Draw.Text(r, t.Label, finalTextX, finalTextY, tickStyle) } - nameStyle := ya.NameStyle.InheritFrom(defaults) + nameStyle := ya.NameStyle.InheritFrom(defaults.InheritFrom(Style{TextRotationDegrees: 90})) if ya.NameStyle.Show && len(ya.Name) > 0 { nameStyle.GetTextOptions().WriteToRenderer(r) - - r.SetTextRotation(Math.DegreesToRadians(90)) - - tb := r.MeasureText(ya.Name) + tb := Draw.MeasureText(r, ya.Name, nameStyle) var tx int if ya.AxisType == YAxisPrimary { @@ -183,10 +195,9 @@ func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick tx = canvasBox.Left - (DefaultYAxisMargin + int(sw) + maxTextWidth + DefaultYAxisMargin) } - ty := canvasBox.Bottom - (canvasBox.Height()>>1 + tb.Width()>>1) + ty := canvasBox.Top + (canvasBox.Height()>>1 + tb.Width()>>1) - r.Text(ya.Name, tx, ty) - r.ClearTextRotation() + Draw.Text(r, ya.Name, tx, ty, nameStyle) } if ya.Zero.Style.Show {