diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 4680624..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 722d5e7..a0f0e53 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .vscode +.DS_Store diff --git a/.travis.yml b/.travis.yml index f2e55e9..fdd7948 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: go go: - - 1.6.2 + - 1.8.1 sudo: false diff --git a/_examples/candlestick_series/main.go b/_examples/candlestick_series/main.go new file mode 100644 index 0000000..c143029 --- /dev/null +++ b/_examples/candlestick_series/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "math/rand" + "net/http" + "time" + + chart "github.com/wcharczuk/go-chart" + util "github.com/wcharczuk/go-chart/util" +) + +func stockData() (times []time.Time, prices []float64) { + start := time.Date(2017, 05, 15, 9, 30, 0, 0, util.Date.Eastern()) + price := 256.0 + for day := 0; day < 60; day++ { + cursor := start.AddDate(0, 0, day) + + if util.Date.IsNYSEHoliday(cursor) { + continue + } + + for minute := 0; minute < ((6 * 60) + 30); minute++ { + cursor = cursor.Add(time.Minute) + + if rand.Float64() >= 0.5 { + price = price + (rand.Float64() * (price * 0.01)) + } else { + price = price - (rand.Float64() * (price * 0.01)) + } + + times = append(times, cursor) + prices = append(prices, price) + } + } + return +} + +func drawChart(res http.ResponseWriter, req *http.Request) { + xv, yv := stockData() + + priceSeries := chart.TimeSeries{ + Name: "SPY", + Style: chart.Style{ + Show: false, + StrokeColor: chart.GetDefaultColor(0), + }, + XValues: xv, + YValues: yv, + } + + candleSeries := chart.CandlestickSeries{ + Name: "SPY", + XValues: xv, + YValues: yv, + } + + graph := chart.Chart{ + XAxis: chart.XAxis{ + Style: chart.Style{Show: true, FontSize: 8, TextRotationDegrees: 45}, + TickPosition: chart.TickPositionUnderTick, + Range: &chart.MarketHoursRange{}, + }, + YAxis: chart.YAxis{ + Style: chart.Style{Show: true}, + }, + Series: []chart.Series{ + candleSeries, + priceSeries, + }, + } + + res.Header().Set("Content-Type", "image/png") + err := graph.Render(chart.PNG, res) + if err != nil { + panic(err) + } +} + +func main() { + http.HandleFunc("/", drawChart) + http.ListenAndServe(":8080", nil) +} diff --git a/_examples/overlap/main.go b/_examples/overlap/main.go new file mode 100644 index 0000000..249e8cf --- /dev/null +++ b/_examples/overlap/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "net/http" + + chart "github.com/wcharczuk/go-chart" + "github.com/wcharczuk/go-chart/drawing" +) + +func conditionalColor(condition bool, trueColor drawing.Color, falseColor drawing.Color) drawing.Color { + if condition { + return trueColor + } + return falseColor +} + +func drawChart(res http.ResponseWriter, req *http.Request) { + r, _ := chart.PNG(1024, 1024) + + b0 := chart.Box{Left: 100, Top: 100, Right: 400, Bottom: 200} + b1 := chart.Box{Left: 500, Top: 100, Right: 900, Bottom: 200} + b0r := b0.Corners().Rotate(45).Shift(0, 200) + + chart.Draw.Box(r, b0, chart.Style{ + StrokeColor: drawing.ColorRed, + StrokeWidth: 2, + FillColor: conditionalColor(b0.Corners().Overlaps(b1.Corners()), drawing.ColorRed, drawing.ColorTransparent), + }) + + chart.Draw.Box(r, b1, chart.Style{ + StrokeColor: drawing.ColorBlue, + StrokeWidth: 2, + FillColor: conditionalColor(b1.Corners().Overlaps(b0.Corners()), drawing.ColorRed, drawing.ColorTransparent), + }) + + chart.Draw.Box2d(r, b0r, chart.Style{ + StrokeColor: drawing.ColorGreen, + StrokeWidth: 2, + FillColor: conditionalColor(b0r.Overlaps(b0.Corners()), drawing.ColorRed, drawing.ColorTransparent), + }) + + res.Header().Set("Content-Type", "image/png") + r.Save(res) +} + +func main() { + http.HandleFunc("/", drawChart) + http.ListenAndServe(":8080", nil) +} diff --git a/_examples/text_rotation/main.go b/_examples/text_rotation/main.go index 76bb2b0..53ca040 100644 --- a/_examples/text_rotation/main.go +++ b/_examples/text_rotation/main.go @@ -32,11 +32,17 @@ func drawChart(res http.ResponseWriter, req *http.Request) { tbc := tb.Corners().Rotate(45) - chart.Draw.BoxCorners(r, tbc, chart.Style{ + chart.Draw.Box2d(r, tbc, chart.Style{ StrokeColor: drawing.ColorRed, StrokeWidth: 2, }) + tbc2 := tbc.Shift(tbc.Height(), 0) + chart.Draw.Box2d(r, tbc2, chart.Style{ + StrokeColor: drawing.ColorGreen, + StrokeWidth: 2, + }) + tbcb := tbc.Box() chart.Draw.Box(r, tbcb, chart.Style{ StrokeColor: drawing.ColorBlue, diff --git a/bar_chart.go b/bar_chart.go index d6a8f7b..1269678 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -261,7 +261,7 @@ func (bc BarChart) drawYAxis(r Renderer, canvasBox Box, yr Range, ticks []Tick) r.Stroke() var ty int - var tb Box + var tb Box2d for _, t := range ticks { ty = canvasBox.Bottom - yr.Translate(t.Value) @@ -272,7 +272,7 @@ func (bc BarChart) drawYAxis(r Renderer, canvasBox Box, yr Range, ticks []Tick) axisStyle.GetTextOptions().WriteToRenderer(r) tb = r.MeasureText(t.Label) - Draw.Text(r, t.Label, canvasBox.Right+DefaultYAxisMargin+5, ty+(tb.Height()>>1), axisStyle) + Draw.Text(r, t.Label, canvasBox.Right+DefaultYAxisMargin+5, ty+(int(tb.Height())>>1), axisStyle) } } @@ -369,7 +369,7 @@ func (bc BarChart) getAdjustedCanvasBox(r Renderer, canvasBox Box, yrange Range, lines := Text.WrapFit(r, bar.Label, barLabelBox.Width(), axisStyle) linesBox := Text.MeasureLines(r, lines, axisStyle) - xaxisHeight = util.Math.MinInt(linesBox.Height()+(2*DefaultXAxisMargin), xaxisHeight) + xaxisHeight = util.Math.MinInt(int(linesBox.Height())+(2*DefaultXAxisMargin), xaxisHeight) } } diff --git a/box.go b/box.go index c59ab69..c6c0baa 100644 --- a/box.go +++ b/box.go @@ -2,7 +2,6 @@ package chart import ( "fmt" - "math" util "github.com/wcharczuk/go-chart/util" ) @@ -166,12 +165,12 @@ 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}, +func (b Box) Corners() Box2d { + return Box2d{ + TopLeft: Point{float64(b.Left), float64(b.Top)}, + TopRight: Point{float64(b.Right), float64(b.Top)}, + BottomRight: Point{float64(b.Right), float64(b.Bottom)}, + BottomLeft: Point{float64(b.Left), float64(b.Bottom)}, } } @@ -255,99 +254,3 @@ 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: util.Math.MinInt(bc.TopLeft.Y, bc.TopRight.Y), - Left: util.Math.MinInt(bc.TopLeft.X, bc.BottomLeft.X), - Right: util.Math.MaxInt(bc.TopRight.X, bc.BottomRight.X), - Bottom: util.Math.MaxInt(bc.BottomLeft.Y, bc.BottomRight.Y), - } -} - -// Width returns the width -func (bc BoxCorners) Width() int { - minLeft := util.Math.MinInt(bc.TopLeft.X, bc.BottomLeft.X) - maxRight := util.Math.MaxInt(bc.TopRight.X, bc.BottomRight.X) - return maxRight - minLeft -} - -// Height returns the height -func (bc BoxCorners) Height() int { - minTop := util.Math.MinInt(bc.TopLeft.Y, bc.TopRight.Y) - maxBottom := util.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 := util.Math.MeanInt(bc.TopLeft.X, bc.BottomLeft.X) - right := util.Math.MeanInt(bc.TopRight.X, bc.BottomRight.X) - x = ((right - left) >> 1) + left - - top := util.Math.MeanInt(bc.TopLeft.Y, bc.TopRight.Y) - bottom := util.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 := util.Math.DegreesToRadians(thetaDegrees) - - tlx, tly := util.Math.RotateCoordinate(cx, cy, bc.TopLeft.X, bc.TopLeft.Y, thetaRadians) - trx, try := util.Math.RotateCoordinate(cx, cy, bc.TopRight.X, bc.TopRight.Y, thetaRadians) - brx, bry := util.Math.RotateCoordinate(cx, cy, bc.BottomRight.X, bc.BottomRight.Y, thetaRadians) - blx, bly := util.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_2d.go b/box_2d.go new file mode 100644 index 0000000..04759d6 --- /dev/null +++ b/box_2d.go @@ -0,0 +1,183 @@ +package chart + +import ( + "fmt" + "math" + + util "github.com/wcharczuk/go-chart/util" +) + +// Box2d is a box with (4) independent corners. +// It is used when dealing with ~rotated~ boxes. +type Box2d struct { + TopLeft, TopRight, BottomRight, BottomLeft Point +} + +// Points returns the constituent points of the box. +func (bc Box2d) Points() []Point { + return []Point{ + bc.TopRight, + bc.BottomRight, + bc.BottomLeft, + bc.TopLeft, + } +} + +// Box return the Box2d as a regular box. +func (bc Box2d) Box() Box { + return Box{ + Top: int(bc.Top()), + Left: int(bc.Left()), + Right: int(bc.Right()), + Bottom: int(bc.Bottom()), + } +} + +// Top returns the top-most corner y value. +func (bc Box2d) Top() float64 { + return math.Min(bc.TopLeft.Y, bc.TopRight.Y) +} + +// Left returns the left-most corner x value. +func (bc Box2d) Left() float64 { + return math.Min(bc.TopLeft.X, bc.BottomLeft.X) +} + +// Right returns the right-most corner x value. +func (bc Box2d) Right() float64 { + return math.Max(bc.TopRight.X, bc.BottomRight.X) +} + +// Bottom returns the bottom-most corner y value. +func (bc Box2d) Bottom() float64 { + return math.Max(bc.BottomLeft.Y, bc.BottomLeft.Y) +} + +// Width returns the width +func (bc Box2d) Width() float64 { + minLeft := math.Min(bc.TopLeft.X, bc.BottomLeft.X) + maxRight := math.Max(bc.TopRight.X, bc.BottomRight.X) + return maxRight - minLeft +} + +// Height returns the height +func (bc Box2d) Height() float64 { + minTop := math.Min(bc.TopLeft.Y, bc.TopRight.Y) + maxBottom := math.Max(bc.BottomLeft.Y, bc.BottomRight.Y) + return maxBottom - minTop +} + +// Center returns the center of the box +func (bc Box2d) Center() (x, y float64) { + left := util.Math.Mean(bc.TopLeft.X, bc.BottomLeft.X) + right := util.Math.Mean(bc.TopRight.X, bc.BottomRight.X) + x = ((right - left) / 2.0) + left + + top := util.Math.Mean(bc.TopLeft.Y, bc.TopRight.Y) + bottom := util.Math.Mean(bc.BottomLeft.Y, bc.BottomRight.Y) + y = ((bottom - top) / 2.0) + top + + return +} + +// Rotate rotates the box. +func (bc Box2d) Rotate(thetaDegrees float64) Box2d { + cx, cy := bc.Center() + + thetaRadians := util.Math.DegreesToRadians(thetaDegrees) + + tlx, tly := util.Math.RotateCoordinate(int(cx), int(cy), int(bc.TopLeft.X), int(bc.TopLeft.Y), thetaRadians) + trx, try := util.Math.RotateCoordinate(int(cx), int(cy), int(bc.TopRight.X), int(bc.TopRight.Y), thetaRadians) + brx, bry := util.Math.RotateCoordinate(int(cx), int(cy), int(bc.BottomRight.X), int(bc.BottomRight.Y), thetaRadians) + blx, bly := util.Math.RotateCoordinate(int(cx), int(cy), int(bc.BottomLeft.X), int(bc.BottomLeft.Y), thetaRadians) + + return Box2d{ + TopLeft: Point{float64(tlx), float64(tly)}, + TopRight: Point{float64(trx), float64(try)}, + BottomRight: Point{float64(brx), float64(bry)}, + BottomLeft: Point{float64(blx), float64(bly)}, + } +} + +// Shift shifts a box by a given x and y value. +func (bc Box2d) Shift(x, y float64) Box2d { + return Box2d{ + TopLeft: bc.TopLeft.Shift(x, y), + TopRight: bc.TopRight.Shift(x, y), + BottomRight: bc.BottomRight.Shift(x, y), + BottomLeft: bc.BottomLeft.Shift(x, y), + } +} + +// Equals returns if the box equals another box. +func (bc Box2d) Equals(other Box2d) bool { + return bc.TopLeft.Equals(other.TopLeft) && + bc.TopRight.Equals(other.TopRight) && + bc.BottomRight.Equals(other.BottomRight) && + bc.BottomLeft.Equals(other.BottomLeft) +} + +// Overlaps returns if two boxes overlap. +func (bc Box2d) Overlaps(other Box2d) bool { + pa := bc.Points() + pb := other.Points() + for i := 0; i < 4; i++ { + for j := 0; j < 4; j++ { + pa0 := pa[i] + pa1 := pa[(i+1)%4] + + pb0 := pb[j] + pb1 := pb[(j+1)%4] + + if util.Math.LinesIntersect(pa0.X, pa0.Y, pa1.X, pa1.Y, pb0.X, pb0.Y, pb1.X, pb1.Y) { + return true + } + } + } + return false +} + +// Grow grows a box by a given set of dimensions. +func (bc Box2d) Grow(by Box) Box2d { + top, left, right, bottom := float64(by.Top), float64(by.Left), float64(by.Right), float64(by.Bottom) + return Box2d{ + TopLeft: Point{X: bc.TopLeft.X - left, Y: bc.TopLeft.Y - top}, + TopRight: Point{X: bc.TopRight.X + right, Y: bc.TopRight.Y - top}, + BottomRight: Point{X: bc.BottomRight.X + right, Y: bc.BottomRight.Y + bottom}, + BottomLeft: Point{X: bc.BottomLeft.X - left, Y: bc.BottomLeft.Y + bottom}, + } +} + +func (bc Box2d) String() string { + return fmt.Sprintf("Box2d{%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 float64 +} + +// Shift shifts a point. +func (p Point) Shift(x, y float64) Point { + return Point{ + X: p.X + x, + Y: p.Y + y, + } +} + +// DistanceTo calculates the distance to another point. +func (p Point) DistanceTo(other Point) float64 { + dx := math.Pow(p.X-other.X, 2) + dy := math.Pow(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("(%.2f,%.2f)", p.X, p.Y) +} diff --git a/box_2d_test.go b/box_2d_test.go new file mode 100644 index 0000000..7cb541a --- /dev/null +++ b/box_2d_test.go @@ -0,0 +1,66 @@ +package chart + +import ( + "fmt" + "testing" + + assert "github.com/blendlabs/go-assert" +) + +func TestBox2dCenter(t *testing.T) { + assert := assert.New(t) + + bc := Box2d{ + 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 TestBox2dRotate(t *testing.T) { + assert := assert.New(t) + + bc := Box2d{ + 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()) +} + +func TestBox2dOverlaps(t *testing.T) { + assert := assert.New(t) + + bc := Box2d{ + TopLeft: Point{5, 5}, + TopRight: Point{15, 5}, + BottomRight: Point{15, 15}, + BottomLeft: Point{5, 15}, + } + + // shift meaningfully the full width of bc right. + bc2 := bc.Shift(bc.Width()+1, 0) + assert.False(bc.Overlaps(bc2), fmt.Sprintf("%v\n\t\tshould not overlap\n\t%v", bc, bc2)) + + // shift meaningfully the full height of bc down. + bc3 := bc.Shift(0, bc.Height()+1) + assert.False(bc.Overlaps(bc3), fmt.Sprintf("%v\n\t\tshould not overlap\n\t%v", bc, bc3)) + + bc4 := bc.Shift(5, 0) + assert.True(bc.Overlaps(bc4)) + + bc5 := bc.Shift(0, 5) + assert.True(bc.Overlaps(bc5)) + + bcr := bc.Rotate(45) + bcr2 := bc.Rotate(45).Shift(bc.Width()/2.0, 0) + assert.True(bcr.Overlaps(bcr2), fmt.Sprintf("%v\n\t\tshould overlap\n\t%v", bcr, bcr2)) +} diff --git a/box_test.go b/box_test.go index 3f3fa02..4c0b18a 100644 --- a/box_test.go +++ b/box_test.go @@ -157,32 +157,3 @@ func TestBoxCenter(t *testing.T) { 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/candlestick_series.go b/candlestick_series.go new file mode 100644 index 0000000..83b5d80 --- /dev/null +++ b/candlestick_series.go @@ -0,0 +1,157 @@ +package chart + +import ( + "fmt" + "time" + + "math" + + "github.com/wcharczuk/go-chart/util" +) + +// CandleValue is a day's data for a candlestick plot. +type CandleValue struct { + Timestamp time.Time + High float64 + Low float64 + Open float64 + Close float64 +} + +// String returns a string value for the candle value. +func (cv CandleValue) String() string { + return fmt.Sprintf("candle %s high: %.2f low: %.2f open: %.2f close: %.2f", cv.Timestamp.Format("2006-01-02"), cv.High, cv.Low, cv.Open, cv.Close) +} + +// IsZero returns if the value is zero or not. +func (cv CandleValue) IsZero() bool { + return cv.Timestamp.IsZero() +} + +// CandlestickSeries is a special type of series that takes a norma value provider +// and maps it to day value stats (high, low, open, close). +type CandlestickSeries struct { + Name string + Style Style + YAxis YAxisType + + // CandleValues will be used in place of creating them from the `InnerSeries`. + CandleValues []CandleValue + + // InnerSeries is used if the `CandleValues` are not set. + InnerSeries ValuesProvider +} + +// GetName implements Series.GetName. +func (cs *CandlestickSeries) GetName() string { + return cs.Name +} + +// GetStyle implements Series.GetStyle. +func (cs *CandlestickSeries) GetStyle() Style { + return cs.Style +} + +// GetYAxis returns which yaxis the series is mapped to. +func (cs *CandlestickSeries) GetYAxis() YAxisType { + return cs.YAxis +} + +// Len returns the length of the series. +func (cs *CandlestickSeries) Len() int { + return len(cs.GetCandleValues()) +} + +// GetBoundedValues returns the bounded values at a given index. +func (cs *CandlestickSeries) GetBoundedValues(index int) (x, y0, y1 float64) { + value := cs.GetCandleValues()[index] + return util.Time.ToFloat64(value.Timestamp), value.Low, value.High +} + +// GetCandleValues returns the candle values. +func (cs CandlestickSeries) GetCandleValues() []CandleValue { + if cs.CandleValues == nil { + cs.CandleValues = cs.GenerateCandleValues() + } + return cs.CandleValues +} + +// GenerateCandleValues returns the candlestick values for each day represented by the inner series. +func (cs CandlestickSeries) GenerateCandleValues() []CandleValue { + if cs.InnerSeries == nil { + return nil + } + + totalValues := cs.InnerSeries.Len() + if totalValues == 0 { + return nil + } + + var values []CandleValue + var lastYear, lastMonth, lastDay int + var year, month, day int + + var t time.Time + var tv, lv, v float64 + + tv, v = cs.InnerSeries.GetValues(0) + t = util.Time.FromFloat64(tv) + year, month, day = t.Year(), int(t.Month()), t.Day() + + lastYear, lastMonth, lastDay = year, month, day + + value := CandleValue{ + Timestamp: cs.newTimestamp(year, month, day), + Open: v, + Low: v, + High: v, + } + lv = v + + for i := 1; i < totalValues; i++ { + tv, v = cs.InnerSeries.GetValues(i) + t = util.Time.FromFloat64(tv) + year, month, day = t.Year(), int(t.Month()), t.Day() + + // if we've transitioned to a new day or we're on the last value + if lastYear != year || lastMonth != month || lastDay != day || i == (totalValues-1) { + value.Close = lv + values = append(values, value) + + value = CandleValue{ + Timestamp: cs.newTimestamp(year, month, day), + Open: v, + High: v, + Low: v, + } + + lastYear = year + lastMonth = month + lastDay = day + } else { + value.Low = math.Min(value.Low, v) + value.High = math.Max(value.High, v) + } + lv = v + } + + return values +} + +func (cs CandlestickSeries) newTimestamp(year, month, day int) time.Time { + return time.Date(year, time.Month(month), day, 12, 0, 0, 0, util.Date.Eastern()) +} + +// Render implements Series.Render. +func (cs CandlestickSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) { + style := cs.Style.InheritFrom(defaults) + Draw.CandlestickSeries(r, canvasBox, xrange, yrange, style, cs) +} + +// Validate validates the series. +func (cs CandlestickSeries) Validate() error { + if cs.CandleValues == nil && cs.InnerSeries == nil { + return fmt.Errorf("candlestick series requires either `CandleValues` or `InnerSeries` to be set") + } + return nil +} diff --git a/candlestick_series_test.go b/candlestick_series_test.go new file mode 100644 index 0000000..c9ac536 --- /dev/null +++ b/candlestick_series_test.go @@ -0,0 +1,52 @@ +package chart + +import ( + "math/rand" + "testing" + "time" + + assert "github.com/blendlabs/go-assert" + "github.com/wcharczuk/go-chart/util" +) + +func generateDummyStockData() (times []time.Time, prices []float64) { + start := util.Date.On(util.NYSEOpen(), time.Date(2017, 05, 15, 0, 0, 0, 0, util.Date.Eastern())) + cursor := start + for day := 0; day < 60; day++ { + + if util.Date.IsWeekendDay(cursor.Weekday()) { + cursor = start.AddDate(0, 0, day) + continue + } + + for hour := 0; hour < 7; hour++ { + for minute := 0; minute < 60; minute++ { + times = append(times, cursor) + prices = append(prices, rand.Float64()*256) + cursor = cursor.Add(time.Minute) + } + + cursor = cursor.Add(time.Hour) + } + + cursor = start.AddDate(0, 0, day) + } + + return +} + +func TestCandlestickSeriesCandleValues(t *testing.T) { + assert := assert.New(t) + + xdata, ydata := generateDummyStockData() + + candleSeries := &CandlestickSeries{ + InnerSeries: TimeSeries{ + XValues: xdata, + YValues: ydata, + }, + } + + values := candleSeries.GetCandleValues() + assert.Len(values, 43) // should be 60 days per the generator. +} diff --git a/chart.go b/chart.go index 83b70ec..c81f4a3 100644 --- a/chart.go +++ b/chart.go @@ -502,8 +502,8 @@ func (c Chart) drawTitle(r Renderer) { textWidth := textBox.Width() textHeight := textBox.Height() - titleX := (c.GetWidth() >> 1) - (textWidth >> 1) - titleY := c.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight + titleX := (int(c.GetWidth()) >> 1) - (int(textWidth) >> 1) + titleY := c.TitleStyle.Padding.GetTop(DefaultTitleTop) + int(textHeight) r.Text(c.Title, titleX, titleY) } diff --git a/debug.test b/debug.test new file mode 100755 index 0000000..8ac0d3f Binary files /dev/null and b/debug.test differ diff --git a/draw.go b/draw.go index ef79dc6..70b9576 100644 --- a/draw.go +++ b/draw.go @@ -168,14 +168,73 @@ func (d draw) HistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, s } } +func (d draw) CandlestickSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, cs CandlestickSeries) { + if cs.Len() == 0 { + return + } + + candleValues := cs.GetCandleValues() + + cb := canvasBox.Bottom + cl := canvasBox.Left + + var cv CandleValue + for index := 0; index < len(candleValues); index++ { + cv = candleValues[index] + + y0 := yrange.Translate(cv.Open) + y1 := yrange.Translate(cv.Close) + + x0 := cl + xrange.Translate(util.Time.ToFloat64(util.Date.On(util.NYSEOpen(), cv.Timestamp))) + x1 := cl + xrange.Translate(util.Time.ToFloat64(util.Date.On(util.NYSEClose(), cv.Timestamp))) + + x := x0 + ((x1 - x0) >> 1) + + // draw open / close box. + if cv.Open < cv.Close { + d.Box(r, Box{ + Top: cb - y0, + Left: x0, + Right: x1, + Bottom: cb - y1, + }, style.InheritFrom(Style{FillColor: ColorAlternateGreen})) + } else { + d.Box(r, Box{ + Top: cb - y1, + Left: x0, + Right: x1, + Bottom: cb - y0, + }, style.InheritFrom(Style{FillColor: ColorRed})) + } + + // draw high / low t bars + y0 = yrange.Translate(cv.High) + y1 = yrange.Translate(cv.Low) + + style.InheritFrom(Style{StrokeColor: DefaultStrokeColor}).WriteToRenderer(r) + + r.MoveTo(x0, cb-y0) + r.LineTo(x1, cb-y0) + r.Stroke() + + r.MoveTo(x, cb-y0) + r.LineTo(x, cb-y1) + r.Stroke() + + r.MoveTo(x0, cb-y1) + r.LineTo(x1, cb-y1) + r.Stroke() + } +} + // 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() - textHeight := textBox.Height() + textWidth := int(textBox.Width()) + textHeight := int(textBox.Height()) halfTextHeight := textHeight >> 1 pt := style.Padding.GetTop(DefaultAnnotationPadding.Top) @@ -203,8 +262,8 @@ func (d draw) Annotation(r Renderer, canvasBox Box, style Style, lx, ly int, lab defer r.ResetStyle() textBox := r.MeasureText(label) - textWidth := textBox.Width() - halfTextHeight := textBox.Height() >> 1 + textWidth := int(textBox.Width()) + halfTextHeight := int(textBox.Height()) >> 1 style.GetFillAndStrokeOptions().WriteToRenderer(r) @@ -255,17 +314,17 @@ func (d draw) Box(r Renderer, b Box, s Style) { } func (d draw) BoxRotated(r Renderer, b Box, thetaDegrees float64, s Style) { - d.BoxCorners(r, b.Corners().Rotate(thetaDegrees), s) + d.Box2d(r, b.Corners().Rotate(thetaDegrees), s) } -func (d draw) BoxCorners(r Renderer, bc BoxCorners, s Style) { +func (d draw) Box2d(r Renderer, bc Box2d, 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.MoveTo(int(bc.TopLeft.X), int(bc.TopLeft.Y)) + r.LineTo(int(bc.TopRight.X), int(bc.TopRight.Y)) + r.LineTo(int(bc.BottomRight.X), int(bc.BottomRight.Y)) + r.LineTo(int(bc.BottomLeft.X), int(bc.BottomLeft.Y)) r.Close() r.FillStroke() } @@ -278,7 +337,7 @@ func (d draw) Text(r Renderer, text string, x, y int, style Style) { r.Text(text, x, y) } -func (d draw) MeasureText(r Renderer, text string, style Style) Box { +func (d draw) MeasureText(r Renderer, text string, style Style) Box2d { style.GetTextOptions().WriteToRenderer(r) defer r.ResetStyle() @@ -297,9 +356,9 @@ func (d draw) TextWithin(r Renderer, text string, box Box, style Style) { switch style.GetTextVerticalAlign() { case TextVerticalAlignBottom, TextVerticalAlignBaseline: // i have to build better baseline handling into measure text - y = y - linesBox.Height() + y = y - int(linesBox.Height()) case TextVerticalAlignMiddle, TextVerticalAlignMiddleBaseline: - y = (y - linesBox.Height()) >> 1 + y = (y - int(linesBox.Height())) >> 1 } var tx, ty int @@ -307,19 +366,19 @@ func (d draw) TextWithin(r Renderer, text string, box Box, style Style) { lineBox := r.MeasureText(line) switch style.GetTextHorizontalAlign() { case TextHorizontalAlignCenter: - tx = box.Left + ((box.Width() - lineBox.Width()) >> 1) + tx = box.Left + ((int(box.Width()) - int(lineBox.Width())) >> 1) case TextHorizontalAlignRight: - tx = box.Right - lineBox.Width() + tx = box.Right - int(lineBox.Width()) default: tx = box.Left } if style.TextRotationDegrees == 0 { - ty = y + lineBox.Height() + ty = y + int(lineBox.Height()) } else { ty = y } r.Text(line, tx, ty) - y += lineBox.Height() + style.GetTextLineSpacing() + y += int(lineBox.Height()) + style.GetTextLineSpacing() } } diff --git a/legend.go b/legend.go index 42c11a3..5cac0a1 100644 --- a/legend.go +++ b/legend.go @@ -67,8 +67,8 @@ func Legend(c *Chart, userDefaults ...Style) Renderable { if labelCount > 0 { legendContent.Bottom += DefaultMinimumTickVerticalSpacing } - legendContent.Bottom += tb.Height() - right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum + legendContent.Bottom += int(tb.Height()) + right := legendContent.Left + int(tb.Width()) + lineTextGap + lineLengthMinimum legendContent.Right = util.Math.MaxInt(legendContent.Right, right) labelCount++ } @@ -95,12 +95,12 @@ func Legend(c *Chart, userDefaults ...Style) Renderable { tb := r.MeasureText(label) - ty := ycursor + tb.Height() + ty := ycursor + int(tb.Height()) r.Text(label, tx, ty) - th2 := tb.Height() >> 1 + th2 := int(tb.Height()) >> 1 - lx := tx + tb.Width() + lineTextGap + lx := tx + int(tb.Width()) + lineTextGap ly := ty - th2 lx2 := legendContent.Right - legendPadding.Right @@ -112,7 +112,7 @@ func Legend(c *Chart, userDefaults ...Style) Renderable { r.LineTo(lx2, ly) r.Stroke() - ycursor += tb.Height() + ycursor += int(tb.Height()) legendCount++ } } @@ -160,12 +160,12 @@ func LegendThin(c *Chart, userDefaults ...Style) Renderable { var textHeight int var textWidth int - var textBox Box + var textBox Box2d for x := 0; x < len(labels); x++ { if len(labels[x]) > 0 { textBox = r.MeasureText(labels[x]) - textHeight = util.Math.MaxInt(textBox.Height(), textHeight) - textWidth = util.Math.MaxInt(textBox.Width(), textWidth) + textHeight = util.Math.MaxInt(int(textBox.Height()), textHeight) + textWidth = util.Math.MaxInt(int(textBox.Width()), textWidth) } } @@ -200,7 +200,7 @@ func LegendThin(c *Chart, userDefaults ...Style) Renderable { textBox = r.MeasureText(label) r.Text(label, tx, ty) - lx = tx + textBox.Width() + lineTextGap + lx = tx + int(textBox.Width()) + lineTextGap ly = ty - th2 r.SetStrokeColor(lines[index].GetStrokeColor()) @@ -211,7 +211,7 @@ func LegendThin(c *Chart, userDefaults ...Style) Renderable { r.LineTo(lx+lineLengthMinimum, ly) r.Stroke() - tx += textBox.Width() + DefaultMinimumTickHorizontalSpacing + lineTextGap + lineLengthMinimum + tx += int(textBox.Width()) + DefaultMinimumTickHorizontalSpacing + lineTextGap + lineLengthMinimum } } } @@ -279,8 +279,8 @@ func LegendLeft(c *Chart, userDefaults ...Style) Renderable { if labelCount > 0 { legendContent.Bottom += DefaultMinimumTickVerticalSpacing } - legendContent.Bottom += tb.Height() - right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum + legendContent.Bottom += int(tb.Height()) + right := legendContent.Left + int(tb.Width()) + lineTextGap + lineLengthMinimum legendContent.Right = util.Math.MaxInt(legendContent.Right, right) labelCount++ } @@ -307,12 +307,12 @@ func LegendLeft(c *Chart, userDefaults ...Style) Renderable { tb := r.MeasureText(label) - ty := ycursor + tb.Height() + ty := ycursor + int(tb.Height()) r.Text(label, tx, ty) - th2 := tb.Height() >> 1 + th2 := int(tb.Height()) >> 1 - lx := tx + tb.Width() + lineTextGap + lx := tx + int(tb.Width()) + lineTextGap ly := ty - th2 lx2 := legendContent.Right - legendPadding.Right @@ -324,7 +324,7 @@ func LegendLeft(c *Chart, userDefaults ...Style) Renderable { r.LineTo(lx2, ly) r.Stroke() - ycursor += tb.Height() + ycursor += int(tb.Height()) legendCount++ } } diff --git a/market_hours_range.go b/market_hours_range.go index de32e8c..ebe4609 100644 --- a/market_hours_range.go +++ b/market_hours_range.go @@ -91,7 +91,7 @@ func (mhr *MarketHoursRange) SetDomain(domain int) { // GetHolidayProvider coalesces a userprovided holiday provider and the date.DefaultHolidayProvider. func (mhr MarketHoursRange) GetHolidayProvider() util.HolidayProvider { if mhr.HolidayProvider == nil { - return func(_ time.Time) bool { return false } + return util.Date.IsNYSEHoliday } return mhr.HolidayProvider } @@ -115,38 +115,37 @@ func (mhr MarketHoursRange) GetMarketClose() time.Time { // GetTicks returns the ticks for the range. // This is to override the default continous ticks that would be generated for the range. func (mhr *MarketHoursRange) GetTicks(r Renderer, defaults Style, vf ValueFormatter) []Tick { - times := seq.Time.MarketHours(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) + times := seq.TimeUtil.MarketHours(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) timesWidth := mhr.measureTimes(r, defaults, vf, times) if timesWidth <= mhr.Domain { return mhr.makeTicks(vf, times) } - times = seq.Time.MarketHourQuarters(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) + times = seq.TimeUtil.MarketHourQuarters(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) timesWidth = mhr.measureTimes(r, defaults, vf, times) if timesWidth <= mhr.Domain { return mhr.makeTicks(vf, times) } - times = seq.Time.MarketDayCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) + times = seq.TimeUtil.MarketDayCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) timesWidth = mhr.measureTimes(r, defaults, vf, times) if timesWidth <= mhr.Domain { return mhr.makeTicks(vf, times) } - times = seq.Time.MarketDayAlternateCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) + times = seq.TimeUtil.MarketDayAlternateCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) timesWidth = mhr.measureTimes(r, defaults, vf, times) if timesWidth <= mhr.Domain { return mhr.makeTicks(vf, times) } - times = seq.Time.MarketDayMondayCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) + times = seq.TimeUtil.MarketDayMondayCloses(mhr.Min, mhr.Max, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) timesWidth = mhr.measureTimes(r, defaults, vf, times) if timesWidth <= mhr.Domain { return mhr.makeTicks(vf, times) } return GenerateContinuousTicks(r, mhr, false, defaults, vf) - } func (mhr *MarketHoursRange) measureTimes(r Renderer, defaults Style, vf ValueFormatter, times []time.Time) int { @@ -156,7 +155,7 @@ func (mhr *MarketHoursRange) measureTimes(r Renderer, defaults Style, vf ValueFo timeLabel := vf(t) labelBox := r.MeasureText(timeLabel) - total += labelBox.Width() + total += int(labelBox.Width()) if index > 0 { total += DefaultMinimumTickHorizontalSpacing } @@ -183,8 +182,8 @@ func (mhr MarketHoursRange) String() string { func (mhr MarketHoursRange) Translate(value float64) int { valueTime := util.Time.FromFloat64(value) valueTimeEastern := valueTime.In(util.Date.Eastern()) - totalSeconds := util.Date.CalculateMarketSecondsBetween(mhr.Min, mhr.GetEffectiveMax(), mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.HolidayProvider) - valueDelta := util.Date.CalculateMarketSecondsBetween(mhr.Min, valueTimeEastern, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.HolidayProvider) + totalSeconds := util.Date.CalculateMarketSecondsBetween(mhr.Min, mhr.GetEffectiveMax(), mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) + valueDelta := util.Date.CalculateMarketSecondsBetween(mhr.Min, valueTimeEastern, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider()) translated := int((float64(valueDelta) / float64(totalSeconds)) * float64(mhr.Domain)) if mhr.IsDescending() { diff --git a/pie_chart.go b/pie_chart.go index d0f1260..3a9a523 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -162,8 +162,8 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) { lx, ly = util.Math.CirclePoint(cx, cy, labelRadius, delta2) tb := r.MeasureText(v.Label) - lx = lx - (tb.Width() >> 1) - ly = ly + (tb.Height() >> 1) + lx = lx - (int(tb.Width()) >> 1) + ly = ly + (int(tb.Height()) >> 1) r.Text(v.Label, lx, ly) } diff --git a/raster_renderer.go b/raster_renderer.go index dacc939..10be4b4 100644 --- a/raster_renderer.go +++ b/raster_renderer.go @@ -155,13 +155,13 @@ 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) Box { +func (rr *rasterRenderer) MeasureText(body string) Box2d { rr.gc.SetFont(rr.s.Font) rr.gc.SetFontSize(rr.s.FontSize) rr.gc.SetFillColor(rr.s.FontColor) l, t, r, b, err := rr.gc.GetStringBounds(body) if err != nil { - return Box{} + return Box2d{} } if l < 0 { r = r - l // equivalent to r+(-1*l) @@ -189,10 +189,10 @@ func (rr *rasterRenderer) MeasureText(body string) Box { Bottom: int(math.Ceil(b)), } if rr.rotateRadians == nil { - return textBox + return textBox.Corners() } - return textBox.Corners().Rotate(util.Math.RadiansToDegrees(*rr.rotateRadians)).Box() + return textBox.Corners().Rotate(util.Math.RadiansToDegrees(*rr.rotateRadians)) } // SetTextRotation sets a text rotation. diff --git a/renderer.go b/renderer.go index 7eb06bb..dc1949d 100644 --- a/renderer.go +++ b/renderer.go @@ -73,7 +73,7 @@ type Renderer interface { Text(body string, x, y int) // MeasureText measures text. - MeasureText(body string) Box + MeasureText(body string) Box2d // SetTextRotatation sets a rotation for drawing elements. SetTextRotation(radians float64) diff --git a/seq/array.go b/seq/array.go index 08479c2..01ca630 100644 --- a/seq/array.go +++ b/seq/array.go @@ -1,5 +1,7 @@ package seq +import "time" + // NewArray creates a new array. func NewArray(values ...float64) Array { return Array(values) @@ -17,3 +19,16 @@ func (a Array) Len() int { func (a Array) GetValue(index int) float64 { return a[index] } + +// ArrayOfTimes wraps an array of times as a sequence provider. +type ArrayOfTimes []time.Time + +// Len returns the length of the array. +func (aot ArrayOfTimes) Len() int { + return len(aot) +} + +// GetValue returns the time at the given index as a time.Time. +func (aot ArrayOfTimes) GetValue(index int) time.Time { + return aot[index] +} diff --git a/seq/provider.go b/seq/provider.go new file mode 100644 index 0000000..ce96d1f --- /dev/null +++ b/seq/provider.go @@ -0,0 +1,15 @@ +package seq + +import "time" + +// Provider is a provider for values for a seq. +type Provider interface { + Len() int + GetValue(int) float64 +} + +// TimeProvider is a provider for values for a seq. +type TimeProvider interface { + Len() int + GetValue(int) time.Time +} diff --git a/seq/random.go b/seq/random.go index 3d0768f..ea65084 100644 --- a/seq/random.go +++ b/seq/random.go @@ -11,7 +11,7 @@ func RandomValues(count int) []float64 { return Seq{NewRandom().WithLen(count)}.Array() } -// RandomValuesWithAverage returns an array of random values with a given average. +// RandomValuesWithMax returns an array of random values with a given average. func RandomValuesWithMax(count int, max float64) []float64 { return Seq{NewRandom().WithMax(max).WithLen(count)}.Array() } diff --git a/seq/sequence.go b/seq/seq.go similarity index 82% rename from seq/sequence.go rename to seq/seq.go index dfc369a..606a118 100644 --- a/seq/sequence.go +++ b/seq/seq.go @@ -15,12 +15,6 @@ func Values(values ...float64) Seq { return Seq{Provider: Array(values)} } -// Provider is a provider for values for a seq. -type Provider interface { - Len() int - GetValue(int) float64 -} - // Seq is a utility wrapper for seq providers. type Seq struct { Provider @@ -28,12 +22,13 @@ type Seq struct { // Array enumerates the seq into a slice. func (s Seq) Array() (output []float64) { - if s.Len() == 0 { + slen := s.Len() + if slen == 0 { return } - output = make([]float64, s.Len()) - for i := 0; i < s.Len(); i++ { + output = make([]float64, slen) + for i := 0; i < slen; i++ { output[i] = s.GetValue(i) } return @@ -142,6 +137,22 @@ func (s Seq) MinMax() (min, max float64) { return } +// First returns the value at index 0. +func (s Seq) First() float64 { + if s.Len() == 0 { + return 0 + } + return s.GetValue(0) +} + +// Last returns the value at index (len)-1. +func (s Seq) Last() float64 { + if s.Len() == 0 { + return 0 + } + return s.GetValue(s.Len() - 1) +} + // Sort returns the seq sorted in ascending order. // This fully enumerates the seq. func (s Seq) Sort() Seq { @@ -149,7 +160,43 @@ func (s Seq) Sort() Seq { return s } values := s.Array() - sort.Float64s(values) + sort.Slice(values, func(i, j int) bool { + return values[i] < values[j] + }) + return Seq{Provider: Array(values)} +} + +// SortDescending returns the seq sorted in descending order. +// This fully enumerates the seq. +func (s Seq) SortDescending() Seq { + if s.Len() == 0 { + return s + } + values := s.Array() + sort.Slice(values, func(i, j int) bool { + return values[i] > values[j] + }) + return Seq{Provider: Array(values)} +} + +// Reverse reverses the sequence's order. +func (s Seq) Reverse() Seq { + slen := s.Len() + if slen == 0 { + return s + } + + slen2 := slen >> 1 + values := s.Array() + + i := 0 + j := slen - 1 + for i < slen2 { + values[i], values[j] = values[j], values[i] + i++ + j-- + } + return Seq{Provider: Array(values)} } diff --git a/seq/sequence_test.go b/seq/seq_test.go similarity index 100% rename from seq/sequence_test.go rename to seq/seq_test.go diff --git a/seq/time.go b/seq/time.go index 79ef02a..65c9bac 100644 --- a/seq/time.go +++ b/seq/time.go @@ -6,21 +6,12 @@ import ( "github.com/wcharczuk/go-chart/util" ) -// Time is a utility singleton with helper functions for time seq generation. -var Time timeSequence +// TimeUtil is a utility singleton with helper functions for time seq generation. +var TimeUtil timeUtil -type timeSequence struct{} +type timeUtil struct{} -// Days generates a seq of timestamps by day, from -days to today. -func (ts timeSequence) Days(days int) []time.Time { - var values []time.Time - for day := days; day >= 0; day-- { - values = append(values, time.Now().AddDate(0, 0, -day)) - } - return values -} - -func (ts timeSequence) MarketHours(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { +func (tu timeUtil) MarketHours(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { var times []time.Time cursor := util.Date.On(marketOpen, from) toClose := util.Date.On(marketClose, to) @@ -41,7 +32,7 @@ func (ts timeSequence) MarketHours(from, to time.Time, marketOpen, marketClose t return times } -func (ts timeSequence) MarketHourQuarters(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { +func (tu timeUtil) MarketHourQuarters(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { var times []time.Time cursor := util.Date.On(marketOpen, from) toClose := util.Date.On(marketClose, to) @@ -62,15 +53,15 @@ func (ts timeSequence) MarketHourQuarters(from, to time.Time, marketOpen, market return times } -func (ts timeSequence) MarketDayCloses(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { +func (tu timeUtil) MarketDayCloses(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { var times []time.Time cursor := util.Date.On(marketOpen, from) toClose := util.Date.On(marketClose, to) for cursor.Before(toClose) || cursor.Equal(toClose) { isValidTradingDay := !isHoliday(cursor) && util.Date.IsWeekDay(cursor.Weekday()) if isValidTradingDay { - todayClose := util.Date.On(marketClose, cursor) - times = append(times, todayClose) + newValue := util.Date.NoonOn(cursor) + times = append(times, newValue) } cursor = util.Date.NextDay(cursor) @@ -78,7 +69,7 @@ func (ts timeSequence) MarketDayCloses(from, to time.Time, marketOpen, marketClo return times } -func (ts timeSequence) MarketDayAlternateCloses(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { +func (tu timeUtil) MarketDayAlternateCloses(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { var times []time.Time cursor := util.Date.On(marketOpen, from) toClose := util.Date.On(marketClose, to) @@ -94,7 +85,7 @@ func (ts timeSequence) MarketDayAlternateCloses(from, to time.Time, marketOpen, return times } -func (ts timeSequence) MarketDayMondayCloses(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { +func (tu timeUtil) MarketDayMondayCloses(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time { var times []time.Time cursor := util.Date.On(marketClose, from) toClose := util.Date.On(marketClose, to) @@ -109,7 +100,7 @@ func (ts timeSequence) MarketDayMondayCloses(from, to time.Time, marketOpen, mar return times } -func (ts timeSequence) Hours(start time.Time, totalHours int) []time.Time { +func (tu timeUtil) Hours(start time.Time, totalHours int) []time.Time { times := make([]time.Time, totalHours) last := start @@ -122,13 +113,12 @@ func (ts timeSequence) Hours(start time.Time, totalHours int) []time.Time { } // HoursFilled adds zero values for the data bounded by the start and end of the xdata array. -func (ts timeSequence) HoursFilled(xdata []time.Time, ydata []float64) ([]time.Time, []float64) { - start := Time.Start(xdata) - end := Time.End(xdata) +func (tu timeUtil) HoursFilled(xdata []time.Time, ydata []float64) ([]time.Time, []float64) { + start, end := Times(xdata...).MinAndMax() totalHours := util.Math.AbsInt(util.Date.DiffHours(start, end)) - finalTimes := ts.Hours(start, totalHours+1) + finalTimes := tu.Hours(start, totalHours+1) finalValues := make([]float64, totalHours+1) var hoursFromStart int @@ -139,33 +129,3 @@ func (ts timeSequence) HoursFilled(xdata []time.Time, ydata []float64) ([]time.T return finalTimes, finalValues } - -// Start returns the earliest (min) time in a list of times. -func (ts timeSequence) Start(times []time.Time) time.Time { - if len(times) == 0 { - return time.Time{} - } - - start := times[0] - for _, t := range times[1:] { - if t.Before(start) { - start = t - } - } - return start -} - -// Start returns the earliest (min) time in a list of times. -func (ts timeSequence) End(times []time.Time) time.Time { - if len(times) == 0 { - return time.Time{} - } - - end := times[0] - for _, t := range times[1:] { - if t.After(end) { - end = t - } - } - return end -} diff --git a/seq/time_seq.go b/seq/time_seq.go new file mode 100644 index 0000000..a7f0613 --- /dev/null +++ b/seq/time_seq.go @@ -0,0 +1,261 @@ +package seq + +import ( + "sort" + "time" +) + +var ( + // TimeZero is the zero time. + TimeZero = time.Time{} +) + +// Times returns a new time sequence. +func Times(values ...time.Time) TimeSeq { + return TimeSeq{TimeProvider: ArrayOfTimes(values)} +} + +// TimeSeq is a sequence of times. +type TimeSeq struct { + TimeProvider +} + +// Array converts the sequence to times. +func (ts TimeSeq) Array() (output []time.Time) { + slen := ts.Len() + if slen == 0 { + return + } + + output = make([]time.Time, slen) + for i := 0; i < slen; i++ { + output[i] = ts.GetValue(i) + } + return +} + +// Each applies the `mapfn` to all values in the value provider. +func (ts TimeSeq) Each(mapfn func(int, time.Time)) { + for i := 0; i < ts.Len(); i++ { + mapfn(i, ts.GetValue(i)) + } +} + +// Map applies the `mapfn` to all values in the value provider, +// returning a new seq. +func (ts TimeSeq) Map(mapfn func(int, time.Time) time.Time) TimeSeq { + output := make([]time.Time, ts.Len()) + for i := 0; i < ts.Len(); i++ { + mapfn(i, ts.GetValue(i)) + } + return TimeSeq{ArrayOfTimes(output)} +} + +// FoldLeft collapses a seq from left to right. +func (ts TimeSeq) FoldLeft(mapfn func(i int, v0, v time.Time) time.Time) (v0 time.Time) { + tslen := ts.Len() + if tslen == 0 { + return TimeZero + } + + if tslen == 1 { + return ts.GetValue(0) + } + + v0 = ts.GetValue(0) + for i := 1; i < tslen; i++ { + v0 = mapfn(i, v0, ts.GetValue(i)) + } + return +} + +// FoldRight collapses a seq from right to left. +func (ts TimeSeq) FoldRight(mapfn func(i int, v0, v time.Time) time.Time) (v0 time.Time) { + tslen := ts.Len() + if tslen == 0 { + return TimeZero + } + + if tslen == 1 { + return ts.GetValue(0) + } + + v0 = ts.GetValue(tslen - 1) + for i := tslen - 2; i >= 0; i-- { + v0 = mapfn(i, v0, ts.GetValue(i)) + } + return +} + +// Sort returns the seq in ascending order. +func (ts TimeSeq) Sort() TimeSeq { + if ts.Len() == 0 { + return ts + } + + values := ts.Array() + sort.Slice(values, func(i, j int) bool { + return values[i].Before(values[j]) + }) + return TimeSeq{TimeProvider: ArrayOfTimes(values)} +} + +// SortDescending returns the seq in descending order. +func (ts TimeSeq) SortDescending() TimeSeq { + if ts.Len() == 0 { + return ts + } + + values := ts.Array() + sort.Slice(values, func(i, j int) bool { + return values[i].After(values[j]) + }) + return TimeSeq{TimeProvider: ArrayOfTimes(values)} +} + +// Min returns the minimum (or earliest) time in the sequence. +func (ts TimeSeq) Min() (min time.Time) { + tslen := ts.Len() + if tslen == 0 { + return + } + min = ts.GetValue(0) + var tv time.Time + for i := 1; i < tslen; i++ { + tv = ts.GetValue(i) + if tv.Before(min) { + min = tv + } + } + return +} + +// Start is an alias to `Min`. +func (ts TimeSeq) Start() time.Time { + return ts.Min() +} + +// Max returns the maximum (or latest) time in the sequence. +func (ts TimeSeq) Max() (max time.Time) { + tslen := ts.Len() + if tslen == 0 { + return + } + max = ts.GetValue(0) + var tv time.Time + for i := 1; i < tslen; i++ { + tv = ts.GetValue(i) + if tv.After(max) { + max = tv + } + } + return +} + +// End is an alias to `Max`. +func (ts TimeSeq) End() time.Time { + return ts.Max() +} + +// First returns the first value in the sequence. +func (ts TimeSeq) First() time.Time { + if ts.Len() == 0 { + return TimeZero + } + + return ts.GetValue(0) +} + +// Last returns the last value in the sequence. +func (ts TimeSeq) Last() time.Time { + if ts.Len() == 0 { + return TimeZero + } + + return ts.GetValue(ts.Len() - 1) +} + +// MinAndMax returns both the earliest and latest value from a sequence in one pass. +func (ts TimeSeq) MinAndMax() (min, max time.Time) { + tslen := ts.Len() + if tslen == 0 { + return + } + min = ts.GetValue(0) + max = ts.GetValue(0) + var tv time.Time + for i := 1; i < tslen; i++ { + tv = ts.GetValue(i) + if tv.Before(min) { + min = tv + } + if tv.After(max) { + max = tv + } + } + return +} + +// MapDistinct maps values given a map function to their distinct outputs. +func (ts TimeSeq) MapDistinct(mapFn func(time.Time) time.Time) TimeSeq { + tslen := ts.Len() + if tslen == 0 { + return TimeSeq{} + } + + var output []time.Time + hourLookup := SetOfTime{} + + // add the initial value + tv := ts.GetValue(0) + tvh := mapFn(tv) + hourLookup.Add(tvh) + output = append(output, tvh) + + for i := 1; i < tslen; i++ { + tv = ts.GetValue(i) + tvh = mapFn(tv) + if !hourLookup.Has(tvh) { + hourLookup.Add(tvh) + output = append(output, tvh) + } + } + + return TimeSeq{ArrayOfTimes(output)} +} + +// Hours returns times in each distinct hour represented by the sequence. +func (ts TimeSeq) Hours() TimeSeq { + return ts.MapDistinct(ts.trimToHour) +} + +// Days returns times in each distinct day represented by the sequence. +func (ts TimeSeq) Days() TimeSeq { + return ts.MapDistinct(ts.trimToDay) +} + +// Months returns times in each distinct months represented by the sequence. +func (ts TimeSeq) Months() TimeSeq { + return ts.MapDistinct(ts.trimToMonth) +} + +// Years returns times in each distinc year represented by the sequence. +func (ts TimeSeq) Years() TimeSeq { + return ts.MapDistinct(ts.trimToYear) +} + +func (ts TimeSeq) trimToHour(tv time.Time) time.Time { + return time.Date(tv.Year(), tv.Month(), tv.Day(), tv.Hour(), 0, 0, 0, tv.Location()) +} + +func (ts TimeSeq) trimToDay(tv time.Time) time.Time { + return time.Date(tv.Year(), tv.Month(), tv.Day(), 0, 0, 0, 0, tv.Location()) +} + +func (ts TimeSeq) trimToMonth(tv time.Time) time.Time { + return time.Date(tv.Year(), tv.Month(), 1, 0, 0, 0, 0, tv.Location()) +} + +func (ts TimeSeq) trimToYear(tv time.Time) time.Time { + return time.Date(tv.Year(), 1, 1, 0, 0, 0, 0, tv.Location()) +} diff --git a/seq/time_seq_test.go b/seq/time_seq_test.go new file mode 100644 index 0000000..b9cf0b9 --- /dev/null +++ b/seq/time_seq_test.go @@ -0,0 +1,81 @@ +package seq + +import ( + "testing" + "time" + + assert "github.com/blendlabs/go-assert" +) + +func TestTimeSeqTimes(t *testing.T) { + assert := assert.New(t) + + seq := Times(time.Now(), time.Now(), time.Now()) + assert.Equal(3, seq.Len()) +} + +func parseTime(str string) time.Time { + tv, _ := time.Parse("2006-01-02 15:04:05", str) + return tv +} + +func TestTimeSeqSort(t *testing.T) { + assert := assert.New(t) + + seq := Times( + parseTime("2016-05-14 12:00:00"), + parseTime("2017-05-14 12:00:00"), + parseTime("2015-05-14 12:00:00"), + parseTime("2017-05-13 12:00:00"), + ) + + sorted := seq.Sort() + assert.Equal(4, sorted.Len()) + min, max := sorted.MinAndMax() + assert.Equal(parseTime("2015-05-14 12:00:00"), min) + assert.Equal(parseTime("2017-05-14 12:00:00"), max) + + first, last := sorted.First(), sorted.Last() + assert.Equal(min, first) + assert.Equal(max, last) +} + +func TestTimeSeqSortDescending(t *testing.T) { + assert := assert.New(t) + + seq := Times( + parseTime("2016-05-14 12:00:00"), + parseTime("2017-05-14 12:00:00"), + parseTime("2015-05-14 12:00:00"), + parseTime("2017-05-13 12:00:00"), + ) + + sorted := seq.SortDescending() + assert.Equal(4, sorted.Len()) + min, max := sorted.MinAndMax() + assert.Equal(parseTime("2015-05-14 12:00:00"), min) + assert.Equal(parseTime("2017-05-14 12:00:00"), max) + + first, last := sorted.First(), sorted.Last() + assert.Equal(max, first) + assert.Equal(min, last) +} + +func TestTimeSeqDays(t *testing.T) { + assert := assert.New(t) + + seq := Times( + parseTime("2017-05-10 12:00:00"), + parseTime("2017-05-10 16:00:00"), + parseTime("2017-05-11 12:00:00"), + parseTime("2015-05-12 12:00:00"), + parseTime("2015-05-12 16:00:00"), + parseTime("2017-05-13 12:00:00"), + parseTime("2017-05-14 12:00:00"), + ) + + days := seq.Days() + assert.Equal(5, days.Len()) + assert.Equal(10, days.First().Day()) + assert.Equal(14, days.Last().Day()) +} diff --git a/seq/time_test.go b/seq/time_test.go index 31da051..40bd83f 100644 --- a/seq/time_test.go +++ b/seq/time_test.go @@ -12,7 +12,7 @@ func TestTimeMarketHours(t *testing.T) { assert := assert.New(t) today := time.Date(2016, 07, 01, 12, 0, 0, 0, util.Date.Eastern()) - mh := Time.MarketHours(today, today, util.NYSEOpen(), util.NYSEClose(), util.Date.IsNYSEHoliday) + mh := TimeUtil.MarketHours(today, today, util.NYSEOpen(), util.NYSEClose(), util.Date.IsNYSEHoliday) assert.Len(mh, 8) assert.Equal(util.Date.Eastern(), mh[0].Location()) } @@ -20,7 +20,7 @@ func TestTimeMarketHours(t *testing.T) { func TestTimeMarketHourQuarters(t *testing.T) { assert := assert.New(t) today := time.Date(2016, 07, 01, 12, 0, 0, 0, util.Date.Eastern()) - mh := Time.MarketHourQuarters(today, today, util.NYSEOpen(), util.NYSEClose(), util.Date.IsNYSEHoliday) + mh := TimeUtil.MarketHourQuarters(today, today, util.NYSEOpen(), util.NYSEClose(), util.Date.IsNYSEHoliday) assert.Len(mh, 4) assert.Equal(9, mh[0].Hour()) assert.Equal(30, mh[0].Minute()) @@ -39,9 +39,9 @@ func TestTimeHours(t *testing.T) { assert := assert.New(t) today := time.Date(2016, 07, 01, 12, 0, 0, 0, time.UTC) - seq := Time.Hours(today, 24) + seq := TimeUtil.Hours(today, 24) - end := Time.End(seq) + end := Times(seq...).Max() assert.Len(seq, 24) assert.Equal(2016, end.Year()) assert.Equal(07, int(end.Month())) @@ -72,8 +72,8 @@ func TestSequenceHoursFill(t *testing.T) { 0.6, } - filledTimes, filledValues := Time.HoursFilled(xdata, ydata) - assert.Len(filledTimes, util.Date.DiffHours(Time.Start(xdata), Time.End(xdata))+1) + filledTimes, filledValues := TimeUtil.HoursFilled(xdata, ydata) + assert.Len(filledTimes, util.Date.DiffHours(Times(xdata...).Start(), Times(xdata...).End())+1) assert.Equal(len(filledValues), len(filledTimes)) assert.NotZero(filledValues[0]) @@ -93,7 +93,7 @@ func TestTimeStart(t *testing.T) { time.Now().AddDate(0, 0, -5), } - assert.InTimeDelta(Time.Start(times), times[4], time.Millisecond) + assert.InTimeDelta(Times(times...).Start(), times[4], time.Millisecond) } func TestTimeEnd(t *testing.T) { @@ -107,5 +107,5 @@ func TestTimeEnd(t *testing.T) { time.Now().AddDate(0, 0, -5), } - assert.InTimeDelta(Time.End(times), times[2], time.Millisecond) + assert.InTimeDelta(Times(times...).End(), times[2], time.Millisecond) } diff --git a/seq/util.go b/seq/util.go index 685a408..238537b 100644 --- a/seq/util.go +++ b/seq/util.go @@ -1,6 +1,11 @@ package seq -import "math" +import ( + "math" + "time" + + "github.com/wcharczuk/go-chart/util" +) func round(input float64, places int) (rounded float64) { if math.IsNaN(input) { @@ -30,3 +35,22 @@ func f64i(value float64) int { r := round(value, 0) return int(r) } + +// SetOfTime is a simple hash set for timestamps as float64s. +type SetOfTime map[float64]bool + +// Add adds the value to the hash set. +func (sot SetOfTime) Add(tv time.Time) { + sot[util.Time.ToFloat64(tv)] = true +} + +// Has returns if the set contains a given time. +func (sot SetOfTime) Has(tv time.Time) bool { + _, hasValue := sot[util.Time.ToFloat64(tv)] + return hasValue +} + +// Remove removes the value from the set. +func (sot SetOfTime) Remove(tv time.Time) { + delete(sot, util.Time.ToFloat64(tv)) +} diff --git a/stacked_bar_chart.go b/stacked_bar_chart.go index 49e4739..b7f24f2 100644 --- a/stacked_bar_chart.go +++ b/stacked_bar_chart.go @@ -214,7 +214,7 @@ func (sbc StackedBarChart) drawYAxis(r Renderer, canvasBox Box) { text := fmt.Sprintf("%0.0f%%", t*100) tb := r.MeasureText(text) - Draw.Text(r, text, canvasBox.Right+DefaultYAxisMargin+5, ty+(tb.Height()>>1), axisStyle) + Draw.Text(r, text, canvasBox.Right+DefaultYAxisMargin+5, ty+(int(tb.Height())>>1), axisStyle) } } @@ -254,7 +254,7 @@ func (sbc StackedBarChart) getAdjustedCanvasBox(r Renderer, canvasBox Box) Box { lines := Text.WrapFit(r, bar.Name, barLabelBox.Width(), axisStyle) linesBox := Text.MeasureLines(r, lines, axisStyle) - xaxisHeight = util.Math.MaxInt(linesBox.Height()+(2*DefaultXAxisMargin), xaxisHeight) + xaxisHeight = util.Math.MaxInt(int(linesBox.Height())+(2*DefaultXAxisMargin), xaxisHeight) } } return Box{ diff --git a/text.go b/text.go index a312c5b..e855374 100644 --- a/text.go +++ b/text.go @@ -85,7 +85,7 @@ func (t text) WrapFitWord(r Renderer, value string, width int, style Style) []st var line string var word string - var textBox Box + var textBox Box2d for _, c := range value { if c == rune('\n') { // commit the line to output @@ -97,7 +97,7 @@ func (t text) WrapFitWord(r Renderer, value string, width int, style Style) []st textBox = r.MeasureText(line + word + string(c)) - if textBox.Width() >= width { + if int(textBox.Width()) >= width { output = append(output, t.Trim(line)) line = word word = string(c) @@ -120,7 +120,7 @@ func (t text) WrapFitRune(r Renderer, value string, width int, style Style) []st var output []string var line string - var textBox Box + var textBox Box2d for _, c := range value { if c == rune('\n') { output = append(output, line) @@ -130,7 +130,7 @@ func (t text) WrapFitRune(r Renderer, value string, width int, style Style) []st textBox = r.MeasureText(line + string(c)) - if textBox.Width() >= width { + if int(textBox.Width()) >= width { output = append(output, line) line = string(c) continue @@ -144,18 +144,18 @@ func (t text) Trim(value string) string { return strings.Trim(value, " \t\n\r") } -func (t text) MeasureLines(r Renderer, lines []string, style Style) Box { +func (t text) MeasureLines(r Renderer, lines []string, style Style) Box2d { style.WriteTextOptionsToRenderer(r) var output Box for index, line := range lines { lineBox := r.MeasureText(line) - output.Right = util.Math.MaxInt(lineBox.Right, output.Right) - output.Bottom += lineBox.Height() + output.Right = util.Math.MaxInt(int(lineBox.Right()), output.Right) + output.Bottom += int(lineBox.Height()) if index < len(lines)-1 { output.Bottom += +style.GetTextLineSpacing() } } - return output + return output.Corners() } func (t text) appendLast(lines []string, text string) []string { diff --git a/util/math.go b/util/math.go index 73f4976..294fb2c 100644 --- a/util/math.go +++ b/util/math.go @@ -251,3 +251,17 @@ func (m mathUtil) RotateCoordinate(cx, cy, x, y int, thetaRadians float64) (rx, ry = int(rotatedY) + cy return } + +func (m mathUtil) LinesIntersect(l0x0, l0y0, l0x1, l0y1, l1x0, l1y0, l1x1, l1y1 float64) bool { + var s0x, s0y, s1x, s1y float64 + s0x = l0x1 - l0x0 + s0y = l0y1 - l0y0 + s1x = l1x1 - l1x0 + s1y = l1y1 - l1y0 + + var s, t float64 + s = (-s0y*(l0x0-l1x0) + s0x*(l0y0-l1y0)) / (-s1x*s0y + s0x*s1y) + t = (s1x*(l0y0-l1y0) - s1y*(l0x0-l1x0)) / (-s1x*s0y + s0x*s1y) + + return s >= 0 && s <= 1 && t >= 0 && t <= 1 +} diff --git a/util/math_test.go b/util/math_test.go index af6750a..af44e15 100644 --- a/util/math_test.go +++ b/util/math_test.go @@ -182,3 +182,27 @@ func TestRotateCoordinate45(t *testing.T) { assert.Equal(7, rx) assert.Equal(7, ry) } + +func TestLinesIntersect(t *testing.T) { + assert := assert.New(t) + + p0x := 1.0 + p0y := 1.0 + + p1x := 3.0 + p1y := 1.0 + + p2x := 2.0 + p2y := 2.0 + + p3x := 2.0 + p3y := 0.0 + + p4x := 2.0 + p4y := 2.0 + p5x := 3.0 + p5y := 2.0 + + assert.True(Math.LinesIntersect(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y)) + assert.False(Math.LinesIntersect(p0x, p0y, p1x, p1y, p4x, p4y, p5x, p5y)) +} diff --git a/util/time_test.go b/util/time_test.go new file mode 100644 index 0000000..dff108b --- /dev/null +++ b/util/time_test.go @@ -0,0 +1,16 @@ +package util + +import ( + "testing" + "time" + + assert "github.com/blendlabs/go-assert" +) + +func TestTimeFromFloat64(t *testing.T) { + assert := assert.New(t) + + now := time.Now() + + assert.InTimeDelta(now, Time.FromFloat64(Time.ToFloat64(now)), time.Microsecond) +} diff --git a/value_provider.go b/value_provider.go index e93c30d..c141a07 100644 --- a/value_provider.go +++ b/value_provider.go @@ -5,7 +5,7 @@ import "github.com/wcharczuk/go-chart/drawing" // ValuesProvider is a type that produces values. type ValuesProvider interface { Len() int - GetValues(index int) (float64, float64) + GetValues(index int) (x float64, y float64) } // BoundedValuesProvider allows series to return a range. diff --git a/vector_renderer.go b/vector_renderer.go index 17d7b73..e418efe 100644 --- a/vector_renderer.go +++ b/vector_renderer.go @@ -7,11 +7,10 @@ import ( "math" "strings" - "golang.org/x/image/font" - - util "github.com/blendlabs/go-util" "github.com/golang/freetype/truetype" "github.com/wcharczuk/go-chart/drawing" + "github.com/wcharczuk/go-chart/util" + "golang.org/x/image/font" ) // SVG returns a new png/raster renderer. @@ -162,7 +161,8 @@ 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) (box Box) { +func (vr *vectorRenderer) MeasureText(body string) Box2d { + var box Box if vr.s.GetFont() != nil { vr.fc = &font.Drawer{ Face: truetype.NewFace(vr.s.GetFont(), &truetype.Options{ @@ -175,11 +175,11 @@ 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 + return box.Corners() } - box = box.Corners().Rotate(util.Math.RadiansToDegrees(*vr.c.textTheta)).Box() + return box.Corners().Rotate(util.Math.RadiansToDegrees(*vr.c.textTheta)) } - return + return box.Corners() } // SetTextRotation sets the text rotation. diff --git a/xaxis.go b/xaxis.go index d97616c..ac8b2ad 100644 --- a/xaxis.go +++ b/xaxis.go @@ -91,11 +91,11 @@ func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic tb := Draw.MeasureText(r, t.Label, tickStyle.GetTextOptions()) tx = canvasBox.Left + ra.Translate(v) - ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height() + ty = canvasBox.Bottom + DefaultXAxisMargin + int(tb.Height()) switch tp { case TickPositionUnderTick, TickPositionUnset: - ltx = tx - tb.Width()>>1 - rtx = tx + tb.Width()>>1 + ltx = tx - int(tb.Width())>>1 + rtx = tx + int(tb.Width())>>1 break case TickPositionBetweenTicks: if index > 0 { @@ -112,7 +112,7 @@ func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic if xa.NameStyle.Show && len(xa.Name) > 0 { tb := Draw.MeasureText(r, xa.Name, xa.NameStyle.InheritFrom(defaults)) - bottom += DefaultXAxisMargin + tb.Height() + bottom += DefaultXAxisMargin + int(tb.Height()) } return Box{ @@ -153,13 +153,13 @@ func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick switch tp { case TickPositionUnderTick, TickPositionUnset: if tickStyle.TextRotationDegrees == 0 { - tx = tx - tb.Width()>>1 - ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height() + tx = tx - int(tb.Width())>>1 + ty = canvasBox.Bottom + DefaultXAxisMargin + int(tb.Height()) } else { - ty = canvasBox.Bottom + (2 * DefaultXAxisMargin) + ty = canvasBox.Bottom + (1.5 * DefaultXAxisMargin) } Draw.Text(r, t.Label, tx, ty, tickWithAxisStyle) - maxTextHeight = util.Math.MaxInt(maxTextHeight, tb.Height()) + maxTextHeight = util.Math.MaxInt(maxTextHeight, int(tb.Height())) break case TickPositionBetweenTicks: if index > 0 { @@ -175,7 +175,7 @@ func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick }, finalTickStyle) ftb := Text.MeasureLines(r, Text.WrapFit(r, t.Label, tx-ltx, finalTickStyle), finalTickStyle) - maxTextHeight = util.Math.MaxInt(maxTextHeight, ftb.Height()) + maxTextHeight = util.Math.MaxInt(maxTextHeight, int(ftb.Height())) } break } @@ -184,8 +184,8 @@ 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 { tb := Draw.MeasureText(r, xa.Name, nameStyle) - tx := canvasBox.Right - (canvasBox.Width()>>1 + tb.Width()>>1) - ty := canvasBox.Bottom + DefaultXAxisMargin + maxTextHeight + DefaultXAxisMargin + tb.Height() + tx := canvasBox.Right - (canvasBox.Width()>>1 + int(tb.Width())>>1) + ty := canvasBox.Bottom + DefaultXAxisMargin + maxTextHeight + DefaultXAxisMargin + int(tb.Height()) Draw.Text(r, xa.Name, tx, ty, nameStyle) } diff --git a/yaxis.go b/yaxis.go index 3549888..9de4fb6 100644 --- a/yaxis.go +++ b/yaxis.go @@ -99,17 +99,17 @@ 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 + tbh2 := int(tb.Height()) >> 1 finalTextX := tx if ya.AxisType == YAxisSecondary { - finalTextX = tx - tb.Width() + finalTextX = tx - int(tb.Width()) } - maxTextHeight = util.Math.MaxInt(tb.Height(), maxTextHeight) + maxTextHeight = util.Math.MaxInt(int(tb.Height()), maxTextHeight) if ya.AxisType == YAxisPrimary { minx = canvasBox.Right - maxx = util.Math.MaxInt(maxx, tx+tb.Width()) + maxx = util.Math.MaxInt(maxx, tx+int(tb.Width())) } else if ya.AxisType == YAxisSecondary { minx = util.Math.MinInt(minx, finalTextX) maxx = util.Math.MaxInt(maxx, tx) @@ -160,18 +160,18 @@ func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick tb := Draw.MeasureText(r, t.Label, tickStyle) - if tb.Width() > maxTextWidth { - maxTextWidth = tb.Width() + if int(tb.Width()) > maxTextWidth { + maxTextWidth = int(tb.Width()) } if ya.AxisType == YAxisSecondary { - finalTextX = tx - tb.Width() + finalTextX = tx - int(tb.Width()) } else { finalTextX = tx } if tickStyle.TextRotationDegrees == 0 { - finalTextY = ly + tb.Height()>>1 + finalTextY = ly + int(tb.Height())>>1 } else { finalTextY = ly } @@ -203,9 +203,9 @@ func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick var ty int if nameStyle.TextRotationDegrees == 0 { - ty = canvasBox.Top + (canvasBox.Height()>>1 - tb.Width()>>1) + ty = canvasBox.Top + (canvasBox.Height()>>1 - int(tb.Width())>>1) } else { - ty = canvasBox.Top + (canvasBox.Height()>>1 - tb.Height()>>1) + ty = canvasBox.Top + (canvasBox.Height()>>1 - int(tb.Height())>>1) } Draw.Text(r, ya.Name, tx, ty, nameStyle)