Compare commits

...

12 Commits

Author SHA1 Message Date
Will Charczuk
de6df027fc updates 2017-05-17 16:01:23 -07:00
Will Charczuk
e3e851d2d1 ds store. 2017-05-17 12:44:24 -07:00
Will Charczuk
b537fd02cb bumping travis version. 2017-05-17 12:44:04 -07:00
Will Charczuk
5cf4f5f0d7 updates 2017-05-16 19:16:26 -07:00
Will Charczuk
04a4edcb46 stuff. 2017-05-16 17:50:17 -07:00
Will Charczuk
5936b89e89 updates 2017-05-16 13:31:36 -07:00
Will Charczuk
51f3cca5d7 need to fix market hours range tick size estimation 2017-05-14 18:58:10 -07:00
Will Charczuk
7ba2992824 wip 2017-05-14 16:33:48 -07:00
Will Charczuk
7d1401898a candle series, candle series tests. 2017-05-14 13:34:46 -07:00
Will Charczuk
e39acdfb76 time sequence stuff 2017-05-14 12:26:41 -07:00
Will Charczuk
566d798b32 time sequence stuff 2017-05-14 12:21:30 -07:00
Will Charczuk
73e3e439c5 helps to add the file. 2017-05-13 13:00:35 -07:00
40 changed files with 1288 additions and 303 deletions

BIN
.DS_Store vendored

Binary file not shown.

1
.gitignore vendored
View File

@ -1 +1,2 @@
.vscode .vscode
.DS_Store

View File

@ -1,7 +1,7 @@
language: go language: go
go: go:
- 1.6.2 - 1.8.1
sudo: false sudo: false

View File

@ -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)
}

49
_examples/overlap/main.go Normal file
View File

@ -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)
}

View File

@ -32,11 +32,17 @@ func drawChart(res http.ResponseWriter, req *http.Request) {
tbc := tb.Corners().Rotate(45) tbc := tb.Corners().Rotate(45)
chart.Draw.BoxCorners(r, tbc, chart.Style{ chart.Draw.Box2d(r, tbc, chart.Style{
StrokeColor: drawing.ColorRed, StrokeColor: drawing.ColorRed,
StrokeWidth: 2, StrokeWidth: 2,
}) })
tbc2 := tbc.Shift(tbc.Height(), 0)
chart.Draw.Box2d(r, tbc2, chart.Style{
StrokeColor: drawing.ColorGreen,
StrokeWidth: 2,
})
tbcb := tbc.Box() tbcb := tbc.Box()
chart.Draw.Box(r, tbcb, chart.Style{ chart.Draw.Box(r, tbcb, chart.Style{
StrokeColor: drawing.ColorBlue, StrokeColor: drawing.ColorBlue,

View File

@ -261,7 +261,7 @@ func (bc BarChart) drawYAxis(r Renderer, canvasBox Box, yr Range, ticks []Tick)
r.Stroke() r.Stroke()
var ty int var ty int
var tb Box var tb Box2d
for _, t := range ticks { for _, t := range ticks {
ty = canvasBox.Bottom - yr.Translate(t.Value) 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) axisStyle.GetTextOptions().WriteToRenderer(r)
tb = r.MeasureText(t.Label) 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) lines := Text.WrapFit(r, bar.Label, barLabelBox.Width(), axisStyle)
linesBox := Text.MeasureLines(r, lines, 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)
} }
} }

109
box.go
View File

@ -2,7 +2,6 @@ package chart
import ( import (
"fmt" "fmt"
"math"
util "github.com/wcharczuk/go-chart/util" 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. // Corners returns the box as a set of corners.
func (b Box) Corners() BoxCorners { func (b Box) Corners() Box2d {
return BoxCorners{ return Box2d{
TopLeft: Point{b.Left, b.Top}, TopLeft: Point{float64(b.Left), float64(b.Top)},
TopRight: Point{b.Right, b.Top}, TopRight: Point{float64(b.Right), float64(b.Top)},
BottomRight: Point{b.Right, b.Bottom}, BottomRight: Point{float64(b.Right), float64(b.Bottom)},
BottomLeft: Point{b.Left, b.Bottom}, BottomLeft: Point{float64(b.Left), float64(b.Bottom)},
} }
} }
@ -255,99 +254,3 @@ func (b Box) OuterConstrain(bounds, other Box) Box {
} }
return newBox 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)
}

183
box_2d.go Normal file
View File

@ -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)
}

66
box_2d_test.go Normal file
View File

@ -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))
}

View File

@ -157,32 +157,3 @@ func TestBoxCenter(t *testing.T) {
assert.Equal(15, cx) assert.Equal(15, cx)
assert.Equal(20, cy) 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())
}

157
candlestick_series.go Normal file
View File

@ -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
}

View File

@ -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.
}

View File

@ -502,8 +502,8 @@ func (c Chart) drawTitle(r Renderer) {
textWidth := textBox.Width() textWidth := textBox.Width()
textHeight := textBox.Height() textHeight := textBox.Height()
titleX := (c.GetWidth() >> 1) - (textWidth >> 1) titleX := (int(c.GetWidth()) >> 1) - (int(textWidth) >> 1)
titleY := c.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight titleY := c.TitleStyle.Padding.GetTop(DefaultTitleTop) + int(textHeight)
r.Text(c.Title, titleX, titleY) r.Text(c.Title, titleX, titleY)
} }

BIN
debug.test Executable file

Binary file not shown.

93
draw.go
View File

@ -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. // MeasureAnnotation measures how big an annotation would be.
func (d draw) MeasureAnnotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) Box { func (d draw) MeasureAnnotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) Box {
style.WriteToRenderer(r) style.WriteToRenderer(r)
defer r.ResetStyle() defer r.ResetStyle()
textBox := r.MeasureText(label) textBox := r.MeasureText(label)
textWidth := textBox.Width() textWidth := int(textBox.Width())
textHeight := textBox.Height() textHeight := int(textBox.Height())
halfTextHeight := textHeight >> 1 halfTextHeight := textHeight >> 1
pt := style.Padding.GetTop(DefaultAnnotationPadding.Top) 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() defer r.ResetStyle()
textBox := r.MeasureText(label) textBox := r.MeasureText(label)
textWidth := textBox.Width() textWidth := int(textBox.Width())
halfTextHeight := textBox.Height() >> 1 halfTextHeight := int(textBox.Height()) >> 1
style.GetFillAndStrokeOptions().WriteToRenderer(r) 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) { 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) s.GetFillAndStrokeOptions().WriteToRenderer(r)
defer r.ResetStyle() defer r.ResetStyle()
r.MoveTo(bc.TopLeft.X, bc.TopLeft.Y) r.MoveTo(int(bc.TopLeft.X), int(bc.TopLeft.Y))
r.LineTo(bc.TopRight.X, bc.TopRight.Y) r.LineTo(int(bc.TopRight.X), int(bc.TopRight.Y))
r.LineTo(bc.BottomRight.X, bc.BottomRight.Y) r.LineTo(int(bc.BottomRight.X), int(bc.BottomRight.Y))
r.LineTo(bc.BottomLeft.X, bc.BottomLeft.Y) r.LineTo(int(bc.BottomLeft.X), int(bc.BottomLeft.Y))
r.Close() r.Close()
r.FillStroke() r.FillStroke()
} }
@ -278,7 +337,7 @@ func (d draw) Text(r Renderer, text string, x, y int, style Style) {
r.Text(text, x, y) 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) style.GetTextOptions().WriteToRenderer(r)
defer r.ResetStyle() defer r.ResetStyle()
@ -297,9 +356,9 @@ func (d draw) TextWithin(r Renderer, text string, box Box, style Style) {
switch style.GetTextVerticalAlign() { switch style.GetTextVerticalAlign() {
case TextVerticalAlignBottom, TextVerticalAlignBaseline: // i have to build better baseline handling into measure text 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: case TextVerticalAlignMiddle, TextVerticalAlignMiddleBaseline:
y = (y - linesBox.Height()) >> 1 y = (y - int(linesBox.Height())) >> 1
} }
var tx, ty int var tx, ty int
@ -307,19 +366,19 @@ func (d draw) TextWithin(r Renderer, text string, box Box, style Style) {
lineBox := r.MeasureText(line) lineBox := r.MeasureText(line)
switch style.GetTextHorizontalAlign() { switch style.GetTextHorizontalAlign() {
case TextHorizontalAlignCenter: case TextHorizontalAlignCenter:
tx = box.Left + ((box.Width() - lineBox.Width()) >> 1) tx = box.Left + ((int(box.Width()) - int(lineBox.Width())) >> 1)
case TextHorizontalAlignRight: case TextHorizontalAlignRight:
tx = box.Right - lineBox.Width() tx = box.Right - int(lineBox.Width())
default: default:
tx = box.Left tx = box.Left
} }
if style.TextRotationDegrees == 0 { if style.TextRotationDegrees == 0 {
ty = y + lineBox.Height() ty = y + int(lineBox.Height())
} else { } else {
ty = y ty = y
} }
r.Text(line, tx, ty) r.Text(line, tx, ty)
y += lineBox.Height() + style.GetTextLineSpacing() y += int(lineBox.Height()) + style.GetTextLineSpacing()
} }
} }

View File

@ -67,8 +67,8 @@ func Legend(c *Chart, userDefaults ...Style) Renderable {
if labelCount > 0 { if labelCount > 0 {
legendContent.Bottom += DefaultMinimumTickVerticalSpacing legendContent.Bottom += DefaultMinimumTickVerticalSpacing
} }
legendContent.Bottom += tb.Height() legendContent.Bottom += int(tb.Height())
right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum right := legendContent.Left + int(tb.Width()) + lineTextGap + lineLengthMinimum
legendContent.Right = util.Math.MaxInt(legendContent.Right, right) legendContent.Right = util.Math.MaxInt(legendContent.Right, right)
labelCount++ labelCount++
} }
@ -95,12 +95,12 @@ func Legend(c *Chart, userDefaults ...Style) Renderable {
tb := r.MeasureText(label) tb := r.MeasureText(label)
ty := ycursor + tb.Height() ty := ycursor + int(tb.Height())
r.Text(label, tx, ty) 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 ly := ty - th2
lx2 := legendContent.Right - legendPadding.Right lx2 := legendContent.Right - legendPadding.Right
@ -112,7 +112,7 @@ func Legend(c *Chart, userDefaults ...Style) Renderable {
r.LineTo(lx2, ly) r.LineTo(lx2, ly)
r.Stroke() r.Stroke()
ycursor += tb.Height() ycursor += int(tb.Height())
legendCount++ legendCount++
} }
} }
@ -160,12 +160,12 @@ func LegendThin(c *Chart, userDefaults ...Style) Renderable {
var textHeight int var textHeight int
var textWidth int var textWidth int
var textBox Box var textBox Box2d
for x := 0; x < len(labels); x++ { for x := 0; x < len(labels); x++ {
if len(labels[x]) > 0 { if len(labels[x]) > 0 {
textBox = r.MeasureText(labels[x]) textBox = r.MeasureText(labels[x])
textHeight = util.Math.MaxInt(textBox.Height(), textHeight) textHeight = util.Math.MaxInt(int(textBox.Height()), textHeight)
textWidth = util.Math.MaxInt(textBox.Width(), textWidth) textWidth = util.Math.MaxInt(int(textBox.Width()), textWidth)
} }
} }
@ -200,7 +200,7 @@ func LegendThin(c *Chart, userDefaults ...Style) Renderable {
textBox = r.MeasureText(label) textBox = r.MeasureText(label)
r.Text(label, tx, ty) r.Text(label, tx, ty)
lx = tx + textBox.Width() + lineTextGap lx = tx + int(textBox.Width()) + lineTextGap
ly = ty - th2 ly = ty - th2
r.SetStrokeColor(lines[index].GetStrokeColor()) r.SetStrokeColor(lines[index].GetStrokeColor())
@ -211,7 +211,7 @@ func LegendThin(c *Chart, userDefaults ...Style) Renderable {
r.LineTo(lx+lineLengthMinimum, ly) r.LineTo(lx+lineLengthMinimum, ly)
r.Stroke() 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 { if labelCount > 0 {
legendContent.Bottom += DefaultMinimumTickVerticalSpacing legendContent.Bottom += DefaultMinimumTickVerticalSpacing
} }
legendContent.Bottom += tb.Height() legendContent.Bottom += int(tb.Height())
right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum right := legendContent.Left + int(tb.Width()) + lineTextGap + lineLengthMinimum
legendContent.Right = util.Math.MaxInt(legendContent.Right, right) legendContent.Right = util.Math.MaxInt(legendContent.Right, right)
labelCount++ labelCount++
} }
@ -307,12 +307,12 @@ func LegendLeft(c *Chart, userDefaults ...Style) Renderable {
tb := r.MeasureText(label) tb := r.MeasureText(label)
ty := ycursor + tb.Height() ty := ycursor + int(tb.Height())
r.Text(label, tx, ty) 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 ly := ty - th2
lx2 := legendContent.Right - legendPadding.Right lx2 := legendContent.Right - legendPadding.Right
@ -324,7 +324,7 @@ func LegendLeft(c *Chart, userDefaults ...Style) Renderable {
r.LineTo(lx2, ly) r.LineTo(lx2, ly)
r.Stroke() r.Stroke()
ycursor += tb.Height() ycursor += int(tb.Height())
legendCount++ legendCount++
} }
} }

View File

@ -91,7 +91,7 @@ func (mhr *MarketHoursRange) SetDomain(domain int) {
// GetHolidayProvider coalesces a userprovided holiday provider and the date.DefaultHolidayProvider. // GetHolidayProvider coalesces a userprovided holiday provider and the date.DefaultHolidayProvider.
func (mhr MarketHoursRange) GetHolidayProvider() util.HolidayProvider { func (mhr MarketHoursRange) GetHolidayProvider() util.HolidayProvider {
if mhr.HolidayProvider == nil { if mhr.HolidayProvider == nil {
return func(_ time.Time) bool { return false } return util.Date.IsNYSEHoliday
} }
return mhr.HolidayProvider return mhr.HolidayProvider
} }
@ -115,38 +115,37 @@ func (mhr MarketHoursRange) GetMarketClose() time.Time {
// GetTicks returns the ticks for the range. // GetTicks returns the ticks for the range.
// This is to override the default continous ticks that would be generated 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 { 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) timesWidth := mhr.measureTimes(r, defaults, vf, times)
if timesWidth <= mhr.Domain { if timesWidth <= mhr.Domain {
return mhr.makeTicks(vf, times) 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) timesWidth = mhr.measureTimes(r, defaults, vf, times)
if timesWidth <= mhr.Domain { if timesWidth <= mhr.Domain {
return mhr.makeTicks(vf, times) 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) timesWidth = mhr.measureTimes(r, defaults, vf, times)
if timesWidth <= mhr.Domain { if timesWidth <= mhr.Domain {
return mhr.makeTicks(vf, times) 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) timesWidth = mhr.measureTimes(r, defaults, vf, times)
if timesWidth <= mhr.Domain { if timesWidth <= mhr.Domain {
return mhr.makeTicks(vf, times) 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) timesWidth = mhr.measureTimes(r, defaults, vf, times)
if timesWidth <= mhr.Domain { if timesWidth <= mhr.Domain {
return mhr.makeTicks(vf, times) return mhr.makeTicks(vf, times)
} }
return GenerateContinuousTicks(r, mhr, false, defaults, vf) return GenerateContinuousTicks(r, mhr, false, defaults, vf)
} }
func (mhr *MarketHoursRange) measureTimes(r Renderer, defaults Style, vf ValueFormatter, times []time.Time) int { 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) timeLabel := vf(t)
labelBox := r.MeasureText(timeLabel) labelBox := r.MeasureText(timeLabel)
total += labelBox.Width() total += int(labelBox.Width())
if index > 0 { if index > 0 {
total += DefaultMinimumTickHorizontalSpacing total += DefaultMinimumTickHorizontalSpacing
} }
@ -183,8 +182,8 @@ func (mhr MarketHoursRange) String() string {
func (mhr MarketHoursRange) Translate(value float64) int { func (mhr MarketHoursRange) Translate(value float64) int {
valueTime := util.Time.FromFloat64(value) valueTime := util.Time.FromFloat64(value)
valueTimeEastern := valueTime.In(util.Date.Eastern()) valueTimeEastern := valueTime.In(util.Date.Eastern())
totalSeconds := util.Date.CalculateMarketSecondsBetween(mhr.Min, mhr.GetEffectiveMax(), 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.HolidayProvider) valueDelta := util.Date.CalculateMarketSecondsBetween(mhr.Min, valueTimeEastern, mhr.GetMarketOpen(), mhr.GetMarketClose(), mhr.GetHolidayProvider())
translated := int((float64(valueDelta) / float64(totalSeconds)) * float64(mhr.Domain)) translated := int((float64(valueDelta) / float64(totalSeconds)) * float64(mhr.Domain))
if mhr.IsDescending() { if mhr.IsDescending() {

View File

@ -162,8 +162,8 @@ func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) {
lx, ly = util.Math.CirclePoint(cx, cy, labelRadius, delta2) lx, ly = util.Math.CirclePoint(cx, cy, labelRadius, delta2)
tb := r.MeasureText(v.Label) tb := r.MeasureText(v.Label)
lx = lx - (tb.Width() >> 1) lx = lx - (int(tb.Width()) >> 1)
ly = ly + (tb.Height() >> 1) ly = ly + (int(tb.Height()) >> 1)
r.Text(v.Label, lx, ly) r.Text(v.Label, lx, ly)
} }

View File

@ -155,13 +155,13 @@ func (rr *rasterRenderer) Text(body string, x, y int) {
} }
// MeasureText returns the height and width in pixels of a string. // 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.SetFont(rr.s.Font)
rr.gc.SetFontSize(rr.s.FontSize) rr.gc.SetFontSize(rr.s.FontSize)
rr.gc.SetFillColor(rr.s.FontColor) rr.gc.SetFillColor(rr.s.FontColor)
l, t, r, b, err := rr.gc.GetStringBounds(body) l, t, r, b, err := rr.gc.GetStringBounds(body)
if err != nil { if err != nil {
return Box{} return Box2d{}
} }
if l < 0 { if l < 0 {
r = r - l // equivalent to r+(-1*l) r = r - l // equivalent to r+(-1*l)
@ -189,10 +189,10 @@ func (rr *rasterRenderer) MeasureText(body string) Box {
Bottom: int(math.Ceil(b)), Bottom: int(math.Ceil(b)),
} }
if rr.rotateRadians == nil { 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. // SetTextRotation sets a text rotation.

View File

@ -73,7 +73,7 @@ type Renderer interface {
Text(body string, x, y int) Text(body string, x, y int)
// MeasureText measures text. // MeasureText measures text.
MeasureText(body string) Box MeasureText(body string) Box2d
// SetTextRotatation sets a rotation for drawing elements. // SetTextRotatation sets a rotation for drawing elements.
SetTextRotation(radians float64) SetTextRotation(radians float64)

View File

@ -1,5 +1,7 @@
package seq package seq
import "time"
// NewArray creates a new array. // NewArray creates a new array.
func NewArray(values ...float64) Array { func NewArray(values ...float64) Array {
return Array(values) return Array(values)
@ -17,3 +19,16 @@ func (a Array) Len() int {
func (a Array) GetValue(index int) float64 { func (a Array) GetValue(index int) float64 {
return a[index] 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]
}

15
seq/provider.go Normal file
View File

@ -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
}

View File

@ -11,7 +11,7 @@ func RandomValues(count int) []float64 {
return Seq{NewRandom().WithLen(count)}.Array() 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 { func RandomValuesWithMax(count int, max float64) []float64 {
return Seq{NewRandom().WithMax(max).WithLen(count)}.Array() return Seq{NewRandom().WithMax(max).WithLen(count)}.Array()
} }

View File

@ -15,12 +15,6 @@ func Values(values ...float64) Seq {
return Seq{Provider: Array(values)} 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. // Seq is a utility wrapper for seq providers.
type Seq struct { type Seq struct {
Provider Provider
@ -28,12 +22,13 @@ type Seq struct {
// Array enumerates the seq into a slice. // Array enumerates the seq into a slice.
func (s Seq) Array() (output []float64) { func (s Seq) Array() (output []float64) {
if s.Len() == 0 { slen := s.Len()
if slen == 0 {
return return
} }
output = make([]float64, s.Len()) output = make([]float64, slen)
for i := 0; i < s.Len(); i++ { for i := 0; i < slen; i++ {
output[i] = s.GetValue(i) output[i] = s.GetValue(i)
} }
return return
@ -142,6 +137,22 @@ func (s Seq) MinMax() (min, max float64) {
return 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. // Sort returns the seq sorted in ascending order.
// This fully enumerates the seq. // This fully enumerates the seq.
func (s Seq) Sort() Seq { func (s Seq) Sort() Seq {
@ -149,7 +160,43 @@ func (s Seq) Sort() Seq {
return s return s
} }
values := s.Array() 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)} return Seq{Provider: Array(values)}
} }

View File

@ -6,21 +6,12 @@ import (
"github.com/wcharczuk/go-chart/util" "github.com/wcharczuk/go-chart/util"
) )
// Time is a utility singleton with helper functions for time seq generation. // TimeUtil is a utility singleton with helper functions for time seq generation.
var Time timeSequence var TimeUtil timeUtil
type timeSequence struct{} type timeUtil struct{}
// Days generates a seq of timestamps by day, from -days to today. func (tu timeUtil) MarketHours(from, to time.Time, marketOpen, marketClose time.Time, isHoliday util.HolidayProvider) []time.Time {
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 {
var times []time.Time var times []time.Time
cursor := util.Date.On(marketOpen, from) cursor := util.Date.On(marketOpen, from)
toClose := util.Date.On(marketClose, to) toClose := util.Date.On(marketClose, to)
@ -41,7 +32,7 @@ func (ts timeSequence) MarketHours(from, to time.Time, marketOpen, marketClose t
return times 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 var times []time.Time
cursor := util.Date.On(marketOpen, from) cursor := util.Date.On(marketOpen, from)
toClose := util.Date.On(marketClose, to) toClose := util.Date.On(marketClose, to)
@ -62,15 +53,15 @@ func (ts timeSequence) MarketHourQuarters(from, to time.Time, marketOpen, market
return times 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 var times []time.Time
cursor := util.Date.On(marketOpen, from) cursor := util.Date.On(marketOpen, from)
toClose := util.Date.On(marketClose, to) toClose := util.Date.On(marketClose, to)
for cursor.Before(toClose) || cursor.Equal(toClose) { for cursor.Before(toClose) || cursor.Equal(toClose) {
isValidTradingDay := !isHoliday(cursor) && util.Date.IsWeekDay(cursor.Weekday()) isValidTradingDay := !isHoliday(cursor) && util.Date.IsWeekDay(cursor.Weekday())
if isValidTradingDay { if isValidTradingDay {
todayClose := util.Date.On(marketClose, cursor) newValue := util.Date.NoonOn(cursor)
times = append(times, todayClose) times = append(times, newValue)
} }
cursor = util.Date.NextDay(cursor) cursor = util.Date.NextDay(cursor)
@ -78,7 +69,7 @@ func (ts timeSequence) MarketDayCloses(from, to time.Time, marketOpen, marketClo
return times 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 var times []time.Time
cursor := util.Date.On(marketOpen, from) cursor := util.Date.On(marketOpen, from)
toClose := util.Date.On(marketClose, to) toClose := util.Date.On(marketClose, to)
@ -94,7 +85,7 @@ func (ts timeSequence) MarketDayAlternateCloses(from, to time.Time, marketOpen,
return times 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 var times []time.Time
cursor := util.Date.On(marketClose, from) cursor := util.Date.On(marketClose, from)
toClose := util.Date.On(marketClose, to) toClose := util.Date.On(marketClose, to)
@ -109,7 +100,7 @@ func (ts timeSequence) MarketDayMondayCloses(from, to time.Time, marketOpen, mar
return times 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) times := make([]time.Time, totalHours)
last := start 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. // 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) { func (tu timeUtil) HoursFilled(xdata []time.Time, ydata []float64) ([]time.Time, []float64) {
start := Time.Start(xdata) start, end := Times(xdata...).MinAndMax()
end := Time.End(xdata)
totalHours := util.Math.AbsInt(util.Date.DiffHours(start, end)) 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) finalValues := make([]float64, totalHours+1)
var hoursFromStart int var hoursFromStart int
@ -139,33 +129,3 @@ func (ts timeSequence) HoursFilled(xdata []time.Time, ydata []float64) ([]time.T
return finalTimes, finalValues 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
}

261
seq/time_seq.go Normal file
View File

@ -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())
}

81
seq/time_seq_test.go Normal file
View File

@ -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())
}

View File

@ -12,7 +12,7 @@ func TestTimeMarketHours(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
today := time.Date(2016, 07, 01, 12, 0, 0, 0, util.Date.Eastern()) 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.Len(mh, 8)
assert.Equal(util.Date.Eastern(), mh[0].Location()) assert.Equal(util.Date.Eastern(), mh[0].Location())
} }
@ -20,7 +20,7 @@ func TestTimeMarketHours(t *testing.T) {
func TestTimeMarketHourQuarters(t *testing.T) { func TestTimeMarketHourQuarters(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
today := time.Date(2016, 07, 01, 12, 0, 0, 0, util.Date.Eastern()) 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.Len(mh, 4)
assert.Equal(9, mh[0].Hour()) assert.Equal(9, mh[0].Hour())
assert.Equal(30, mh[0].Minute()) assert.Equal(30, mh[0].Minute())
@ -39,9 +39,9 @@ func TestTimeHours(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
today := time.Date(2016, 07, 01, 12, 0, 0, 0, time.UTC) 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.Len(seq, 24)
assert.Equal(2016, end.Year()) assert.Equal(2016, end.Year())
assert.Equal(07, int(end.Month())) assert.Equal(07, int(end.Month()))
@ -72,8 +72,8 @@ func TestSequenceHoursFill(t *testing.T) {
0.6, 0.6,
} }
filledTimes, filledValues := Time.HoursFilled(xdata, ydata) filledTimes, filledValues := TimeUtil.HoursFilled(xdata, ydata)
assert.Len(filledTimes, util.Date.DiffHours(Time.Start(xdata), Time.End(xdata))+1) assert.Len(filledTimes, util.Date.DiffHours(Times(xdata...).Start(), Times(xdata...).End())+1)
assert.Equal(len(filledValues), len(filledTimes)) assert.Equal(len(filledValues), len(filledTimes))
assert.NotZero(filledValues[0]) assert.NotZero(filledValues[0])
@ -93,7 +93,7 @@ func TestTimeStart(t *testing.T) {
time.Now().AddDate(0, 0, -5), 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) { func TestTimeEnd(t *testing.T) {
@ -107,5 +107,5 @@ func TestTimeEnd(t *testing.T) {
time.Now().AddDate(0, 0, -5), time.Now().AddDate(0, 0, -5),
} }
assert.InTimeDelta(Time.End(times), times[2], time.Millisecond) assert.InTimeDelta(Times(times...).End(), times[2], time.Millisecond)
} }

View File

@ -1,6 +1,11 @@
package seq package seq
import "math" import (
"math"
"time"
"github.com/wcharczuk/go-chart/util"
)
func round(input float64, places int) (rounded float64) { func round(input float64, places int) (rounded float64) {
if math.IsNaN(input) { if math.IsNaN(input) {
@ -30,3 +35,22 @@ func f64i(value float64) int {
r := round(value, 0) r := round(value, 0)
return int(r) 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))
}

View File

@ -214,7 +214,7 @@ func (sbc StackedBarChart) drawYAxis(r Renderer, canvasBox Box) {
text := fmt.Sprintf("%0.0f%%", t*100) text := fmt.Sprintf("%0.0f%%", t*100)
tb := r.MeasureText(text) 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) lines := Text.WrapFit(r, bar.Name, barLabelBox.Width(), axisStyle)
linesBox := Text.MeasureLines(r, lines, 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{ return Box{

16
text.go
View File

@ -85,7 +85,7 @@ func (t text) WrapFitWord(r Renderer, value string, width int, style Style) []st
var line string var line string
var word string var word string
var textBox Box var textBox Box2d
for _, c := range value { for _, c := range value {
if c == rune('\n') { // commit the line to output 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)) textBox = r.MeasureText(line + word + string(c))
if textBox.Width() >= width { if int(textBox.Width()) >= width {
output = append(output, t.Trim(line)) output = append(output, t.Trim(line))
line = word line = word
word = string(c) word = string(c)
@ -120,7 +120,7 @@ func (t text) WrapFitRune(r Renderer, value string, width int, style Style) []st
var output []string var output []string
var line string var line string
var textBox Box var textBox Box2d
for _, c := range value { for _, c := range value {
if c == rune('\n') { if c == rune('\n') {
output = append(output, line) 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)) textBox = r.MeasureText(line + string(c))
if textBox.Width() >= width { if int(textBox.Width()) >= width {
output = append(output, line) output = append(output, line)
line = string(c) line = string(c)
continue continue
@ -144,18 +144,18 @@ func (t text) Trim(value string) string {
return strings.Trim(value, " \t\n\r") 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) style.WriteTextOptionsToRenderer(r)
var output Box var output Box
for index, line := range lines { for index, line := range lines {
lineBox := r.MeasureText(line) lineBox := r.MeasureText(line)
output.Right = util.Math.MaxInt(lineBox.Right, output.Right) output.Right = util.Math.MaxInt(int(lineBox.Right()), output.Right)
output.Bottom += lineBox.Height() output.Bottom += int(lineBox.Height())
if index < len(lines)-1 { if index < len(lines)-1 {
output.Bottom += +style.GetTextLineSpacing() output.Bottom += +style.GetTextLineSpacing()
} }
} }
return output return output.Corners()
} }
func (t text) appendLast(lines []string, text string) []string { func (t text) appendLast(lines []string, text string) []string {

View File

@ -251,3 +251,17 @@ func (m mathUtil) RotateCoordinate(cx, cy, x, y int, thetaRadians float64) (rx,
ry = int(rotatedY) + cy ry = int(rotatedY) + cy
return 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
}

View File

@ -182,3 +182,27 @@ func TestRotateCoordinate45(t *testing.T) {
assert.Equal(7, rx) assert.Equal(7, rx)
assert.Equal(7, ry) 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))
}

16
util/time_test.go Normal file
View File

@ -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)
}

View File

@ -5,7 +5,7 @@ import "github.com/wcharczuk/go-chart/drawing"
// ValuesProvider is a type that produces values. // ValuesProvider is a type that produces values.
type ValuesProvider interface { type ValuesProvider interface {
Len() int Len() int
GetValues(index int) (float64, float64) GetValues(index int) (x float64, y float64)
} }
// BoundedValuesProvider allows series to return a range. // BoundedValuesProvider allows series to return a range.

View File

@ -7,11 +7,10 @@ import (
"math" "math"
"strings" "strings"
"golang.org/x/image/font"
util "github.com/blendlabs/go-util"
"github.com/golang/freetype/truetype" "github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/drawing" "github.com/wcharczuk/go-chart/drawing"
"github.com/wcharczuk/go-chart/util"
"golang.org/x/image/font"
) )
// SVG returns a new png/raster renderer. // 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. // 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 { if vr.s.GetFont() != nil {
vr.fc = &font.Drawer{ vr.fc = &font.Drawer{
Face: truetype.NewFace(vr.s.GetFont(), &truetype.Options{ Face: truetype.NewFace(vr.s.GetFont(), &truetype.Options{
@ -175,11 +175,11 @@ func (vr *vectorRenderer) MeasureText(body string) (box Box) {
box.Right = w box.Right = w
box.Bottom = int(drawing.PointsToPixels(vr.dpi, vr.s.FontSize)) box.Bottom = int(drawing.PointsToPixels(vr.dpi, vr.s.FontSize))
if vr.c.textTheta == nil { 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. // SetTextRotation sets the text rotation.

View File

@ -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()) tb := Draw.MeasureText(r, t.Label, tickStyle.GetTextOptions())
tx = canvasBox.Left + ra.Translate(v) tx = canvasBox.Left + ra.Translate(v)
ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height() ty = canvasBox.Bottom + DefaultXAxisMargin + int(tb.Height())
switch tp { switch tp {
case TickPositionUnderTick, TickPositionUnset: case TickPositionUnderTick, TickPositionUnset:
ltx = tx - tb.Width()>>1 ltx = tx - int(tb.Width())>>1
rtx = tx + tb.Width()>>1 rtx = tx + int(tb.Width())>>1
break break
case TickPositionBetweenTicks: case TickPositionBetweenTicks:
if index > 0 { 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 { if xa.NameStyle.Show && len(xa.Name) > 0 {
tb := Draw.MeasureText(r, xa.Name, xa.NameStyle.InheritFrom(defaults)) tb := Draw.MeasureText(r, xa.Name, xa.NameStyle.InheritFrom(defaults))
bottom += DefaultXAxisMargin + tb.Height() bottom += DefaultXAxisMargin + int(tb.Height())
} }
return Box{ return Box{
@ -153,13 +153,13 @@ func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick
switch tp { switch tp {
case TickPositionUnderTick, TickPositionUnset: case TickPositionUnderTick, TickPositionUnset:
if tickStyle.TextRotationDegrees == 0 { if tickStyle.TextRotationDegrees == 0 {
tx = tx - tb.Width()>>1 tx = tx - int(tb.Width())>>1
ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height() ty = canvasBox.Bottom + DefaultXAxisMargin + int(tb.Height())
} else { } else {
ty = canvasBox.Bottom + (2 * DefaultXAxisMargin) ty = canvasBox.Bottom + (1.5 * DefaultXAxisMargin)
} }
Draw.Text(r, t.Label, tx, ty, tickWithAxisStyle) Draw.Text(r, t.Label, tx, ty, tickWithAxisStyle)
maxTextHeight = util.Math.MaxInt(maxTextHeight, tb.Height()) maxTextHeight = util.Math.MaxInt(maxTextHeight, int(tb.Height()))
break break
case TickPositionBetweenTicks: case TickPositionBetweenTicks:
if index > 0 { if index > 0 {
@ -175,7 +175,7 @@ func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick
}, finalTickStyle) }, finalTickStyle)
ftb := Text.MeasureLines(r, Text.WrapFit(r, t.Label, tx-ltx, finalTickStyle), 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 break
} }
@ -184,8 +184,8 @@ func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick
nameStyle := xa.NameStyle.InheritFrom(defaults) nameStyle := xa.NameStyle.InheritFrom(defaults)
if xa.NameStyle.Show && len(xa.Name) > 0 { if xa.NameStyle.Show && len(xa.Name) > 0 {
tb := Draw.MeasureText(r, xa.Name, nameStyle) tb := Draw.MeasureText(r, xa.Name, nameStyle)
tx := canvasBox.Right - (canvasBox.Width()>>1 + tb.Width()>>1) tx := canvasBox.Right - (canvasBox.Width()>>1 + int(tb.Width())>>1)
ty := canvasBox.Bottom + DefaultXAxisMargin + maxTextHeight + DefaultXAxisMargin + tb.Height() ty := canvasBox.Bottom + DefaultXAxisMargin + maxTextHeight + DefaultXAxisMargin + int(tb.Height())
Draw.Text(r, xa.Name, tx, ty, nameStyle) Draw.Text(r, xa.Name, tx, ty, nameStyle)
} }

View File

@ -99,17 +99,17 @@ func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, tic
ly := canvasBox.Bottom - ra.Translate(v) ly := canvasBox.Bottom - ra.Translate(v)
tb := r.MeasureText(t.Label) tb := r.MeasureText(t.Label)
tbh2 := tb.Height() >> 1 tbh2 := int(tb.Height()) >> 1
finalTextX := tx finalTextX := tx
if ya.AxisType == YAxisSecondary { 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 { if ya.AxisType == YAxisPrimary {
minx = canvasBox.Right 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 { } else if ya.AxisType == YAxisSecondary {
minx = util.Math.MinInt(minx, finalTextX) minx = util.Math.MinInt(minx, finalTextX)
maxx = util.Math.MaxInt(maxx, tx) 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) tb := Draw.MeasureText(r, t.Label, tickStyle)
if tb.Width() > maxTextWidth { if int(tb.Width()) > maxTextWidth {
maxTextWidth = tb.Width() maxTextWidth = int(tb.Width())
} }
if ya.AxisType == YAxisSecondary { if ya.AxisType == YAxisSecondary {
finalTextX = tx - tb.Width() finalTextX = tx - int(tb.Width())
} else { } else {
finalTextX = tx finalTextX = tx
} }
if tickStyle.TextRotationDegrees == 0 { if tickStyle.TextRotationDegrees == 0 {
finalTextY = ly + tb.Height()>>1 finalTextY = ly + int(tb.Height())>>1
} else { } else {
finalTextY = ly finalTextY = ly
} }
@ -203,9 +203,9 @@ func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, tick
var ty int var ty int
if nameStyle.TextRotationDegrees == 0 { if nameStyle.TextRotationDegrees == 0 {
ty = canvasBox.Top + (canvasBox.Height()>>1 - tb.Width()>>1) ty = canvasBox.Top + (canvasBox.Height()>>1 - int(tb.Width())>>1)
} else { } 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) Draw.Text(r, ya.Name, tx, ty, nameStyle)