initial commit.
This commit is contained in:
commit
2dd44d3675
14 changed files with 930 additions and 0 deletions
210
chart.go
Normal file
210
chart.go
Normal file
|
@ -0,0 +1,210 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chart is what we're drawing.
|
||||||
|
/*
|
||||||
|
The chart box model is as follows:
|
||||||
|
0,0 width,0
|
||||||
|
cl,ct cr,ct
|
||||||
|
cl,cb cr,cb
|
||||||
|
0, height width,height
|
||||||
|
*/
|
||||||
|
type Chart struct {
|
||||||
|
Title string
|
||||||
|
TitleFontSize float64
|
||||||
|
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Padding int
|
||||||
|
|
||||||
|
BackgroundColor color.RGBA
|
||||||
|
CanvasBackgroundColor color.RGBA
|
||||||
|
|
||||||
|
AxisShow bool
|
||||||
|
AxisStyle Style
|
||||||
|
AxisFontSize float64
|
||||||
|
|
||||||
|
CanvasBorderShow bool
|
||||||
|
CanvasBorderStyle Style
|
||||||
|
|
||||||
|
FinalValueLabelShow bool
|
||||||
|
FinalValueStyle Style
|
||||||
|
|
||||||
|
FontColor color.RGBA
|
||||||
|
Font *truetype.Font
|
||||||
|
|
||||||
|
Series []Series
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTitleFontSize calculates or returns the title font size.
|
||||||
|
func (c Chart) GetTitleFontSize() float64 {
|
||||||
|
if c.TitleFontSize != 0 {
|
||||||
|
if c.TitleFontSize > DefaultMinimumFontSize {
|
||||||
|
return c.TitleFontSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fontSize := float64(c.Height >> 3)
|
||||||
|
if fontSize > DefaultMinimumFontSize {
|
||||||
|
return fontSize
|
||||||
|
}
|
||||||
|
return DefaultMinimumFontSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCanvasTop gets the top corner pixel.
|
||||||
|
func (c Chart) GetCanvasTop() int {
|
||||||
|
return c.Padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCanvasLeft gets the left corner pixel.
|
||||||
|
func (c Chart) GetCanvasLeft() int {
|
||||||
|
return c.Padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCanvasBottom gets the bottom corner pixel.
|
||||||
|
func (c Chart) GetCanvasBottom() int {
|
||||||
|
return c.Height - c.Padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCanvasRight gets the right corner pixel.
|
||||||
|
func (c Chart) GetCanvasRight() int {
|
||||||
|
return c.Width - c.Padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCanvasWidth returns the width of the canvas.
|
||||||
|
func (c Chart) GetCanvasWidth() int {
|
||||||
|
if c.Padding > 0 {
|
||||||
|
return c.Width - (c.Padding << 1)
|
||||||
|
}
|
||||||
|
return c.Width
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCanvasHeight returns the height of the canvas.
|
||||||
|
func (c Chart) GetCanvasHeight() int {
|
||||||
|
if c.Padding > 0 {
|
||||||
|
return c.Height - (c.Padding << 1)
|
||||||
|
}
|
||||||
|
return c.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBackgroundColor returns the chart background color.
|
||||||
|
func (c Chart) GetBackgroundColor() color.RGBA {
|
||||||
|
if ColorIsZero(c.BackgroundColor) {
|
||||||
|
c.BackgroundColor = DefaultBackgroundColor
|
||||||
|
}
|
||||||
|
return c.BackgroundColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCanvasBackgroundColor returns the canvas background color.
|
||||||
|
func (c Chart) GetCanvasBackgroundColor() color.RGBA {
|
||||||
|
if ColorIsZero(c.CanvasBackgroundColor) {
|
||||||
|
c.CanvasBackgroundColor = DefaultCanvasColor
|
||||||
|
}
|
||||||
|
return c.CanvasBackgroundColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTextFont returns the text font.
|
||||||
|
func (c Chart) GetTextFont() (*truetype.Font, error) {
|
||||||
|
if c.Font != nil {
|
||||||
|
return c.Font, nil
|
||||||
|
}
|
||||||
|
return GetDefaultFont()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTextFontColor returns the text font color.
|
||||||
|
func (c Chart) GetTextFontColor() color.RGBA {
|
||||||
|
if ColorIsZero(c.FontColor) {
|
||||||
|
c.FontColor = DefaultTextColor
|
||||||
|
}
|
||||||
|
return c.FontColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render renders the chart with the given renderer to the given io.Writer.
|
||||||
|
func (c Chart) Render(provider RendererProvider, w io.Writer) error {
|
||||||
|
r := provider(c.Width, c.Height)
|
||||||
|
c.drawBackground(r)
|
||||||
|
c.drawCanvas(r)
|
||||||
|
c.drawAxes(r)
|
||||||
|
|
||||||
|
for _, series := range c.Series {
|
||||||
|
c.drawSeries(r, series)
|
||||||
|
}
|
||||||
|
err := c.drawTitle(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return r.Save(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Chart) drawBackground(r Renderer) {
|
||||||
|
r.SetStrokeColor(c.GetBackgroundColor())
|
||||||
|
r.SetFillColor(c.GetBackgroundColor())
|
||||||
|
r.SetLineWidth(0)
|
||||||
|
r.MoveTo(0, 0)
|
||||||
|
r.LineTo(c.Width, 0)
|
||||||
|
r.LineTo(c.Width, c.Height)
|
||||||
|
r.LineTo(0, c.Height)
|
||||||
|
r.LineTo(0, 0)
|
||||||
|
r.FillStroke()
|
||||||
|
r.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Chart) drawCanvas(r Renderer) {
|
||||||
|
if !c.CanvasBorderStyle.IsZero() {
|
||||||
|
r.SetStrokeColor(c.CanvasBorderStyle.GetStrokeColor())
|
||||||
|
r.SetLineWidth(c.CanvasBorderStyle.GetStrokeWidth())
|
||||||
|
} else {
|
||||||
|
r.SetStrokeColor(c.GetCanvasBackgroundColor())
|
||||||
|
r.SetLineWidth(0)
|
||||||
|
}
|
||||||
|
r.SetFillColor(c.GetCanvasBackgroundColor())
|
||||||
|
r.MoveTo(c.GetCanvasLeft(), c.GetCanvasTop())
|
||||||
|
r.LineTo(c.GetCanvasRight(), c.GetCanvasTop())
|
||||||
|
r.LineTo(c.GetCanvasRight(), c.GetCanvasBottom())
|
||||||
|
r.LineTo(c.GetCanvasLeft(), c.GetCanvasBottom())
|
||||||
|
r.LineTo(c.GetCanvasLeft(), c.GetCanvasTop())
|
||||||
|
r.FillStroke()
|
||||||
|
r.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Chart) drawAxes(r Renderer) {
|
||||||
|
if c.AxisShow {
|
||||||
|
if !c.AxisStyle.IsZero() {
|
||||||
|
r.SetStrokeColor(c.AxisStyle.GetStrokeColor())
|
||||||
|
r.SetLineWidth(c.AxisStyle.GetStrokeWidth())
|
||||||
|
} else {
|
||||||
|
r.SetStrokeColor(DefaultAxisColor)
|
||||||
|
r.SetLineWidth(DefaultLineWidth)
|
||||||
|
}
|
||||||
|
r.MoveTo(c.GetCanvasLeft(), c.GetCanvasBottom())
|
||||||
|
r.LineTo(c.GetCanvasRight(), c.GetCanvasBottom())
|
||||||
|
r.LineTo(c.GetCanvasRight(), c.GetCanvasTop())
|
||||||
|
r.Stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Chart) drawSeries(r Renderer, s Series) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Chart) drawTitle(r Renderer) error {
|
||||||
|
if len(c.Title) > 0 {
|
||||||
|
font, err := c.GetTextFont()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.SetFontColor(c.GetTextFontColor())
|
||||||
|
r.SetFont(font)
|
||||||
|
r.SetFontSize(c.GetTitleFontSize())
|
||||||
|
textWidth := r.MeasureText(c.Title)
|
||||||
|
titleX := (c.Width >> 1) - (textWidth >> 1)
|
||||||
|
titleY := c.GetCanvasTop() + int(c.GetTitleFontSize()/2.0)
|
||||||
|
r.Text(c.Title, titleX, titleY)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
30
chart_test.go
Normal file
30
chart_test.go
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blendlabs/go-assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChartSingleSeries(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
now := time.Now()
|
||||||
|
c := Chart{
|
||||||
|
Title: "Hello!",
|
||||||
|
Width: 1024,
|
||||||
|
Height: 400,
|
||||||
|
Series: []Series{
|
||||||
|
TimeSeries{
|
||||||
|
Name: "Goog",
|
||||||
|
XValues: []time.Time{now.AddDate(0, 0, -3), now.AddDate(0, 0, -2), now.AddDate(0, 0, -1)},
|
||||||
|
YValues: []float64{1.0, 2.0, 3.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := bytes.NewBuffer([]byte{})
|
||||||
|
c.Render(PNG, buffer)
|
||||||
|
assert.NotEmpty(buffer.Bytes())
|
||||||
|
}
|
69
defaults.go
Normal file
69
defaults.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultChartHeight is the default chart height.
|
||||||
|
DefaultChartHeight = 400
|
||||||
|
// DefaultChartWidth is the default chart width.
|
||||||
|
DefaultChartWidth = 200
|
||||||
|
// DefaultPadding is the default gap between the image border and
|
||||||
|
// chart content (referred to as the "canvas").
|
||||||
|
DefaultPadding = 10
|
||||||
|
// DefaultLineWidth is the default chart line width.
|
||||||
|
DefaultLineWidth = 2.0
|
||||||
|
//DefaultDPI is the default dots per inch for the chart.
|
||||||
|
DefaultDPI = 120.0
|
||||||
|
// DefaultMinimumFontSize is the default minimum font size.
|
||||||
|
DefaultMinimumFontSize = 8.0
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// DefaultBackgroundColor is the default chart background color.
|
||||||
|
// It is equivalent to css color:white.
|
||||||
|
DefaultBackgroundColor = color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
// DefaultCanvasColor is the default chart canvas color.
|
||||||
|
// It is equivalent to css color:white.
|
||||||
|
DefaultCanvasColor = color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
// DefaultTextColor is the default chart text color.
|
||||||
|
// It is equivalent to #333333.
|
||||||
|
DefaultTextColor = color.RGBA{R: 51, G: 51, B: 51, A: 255}
|
||||||
|
// DefaultAxisColor is the default chart axis line color.
|
||||||
|
// It is equivalent to #333333.
|
||||||
|
DefaultAxisColor = color.RGBA{R: 51, G: 51, B: 51, A: 255}
|
||||||
|
// DefaultBorderColor is the default chart border color.
|
||||||
|
// It is equivalent to #efefef.
|
||||||
|
DefaultBorderColor = color.RGBA{R: 239, G: 239, B: 239, A: 255}
|
||||||
|
// DefaultLineColor is the default (1st) series line color.
|
||||||
|
// It is equivalent to #0074d9.
|
||||||
|
DefaultLineColor = color.RGBA{R: 0, G: 217, B: 116, A: 255}
|
||||||
|
// DefaultFillColor is the default fill color.
|
||||||
|
// It is equivalent to #0074d9.
|
||||||
|
DefaultFillColor = color.RGBA{R: 0, G: 217, B: 116, A: 255}
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_defaultFontLock sync.Mutex
|
||||||
|
_defaultFont *truetype.Font
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDefaultFont returns the default font (Roboto-Medium).
|
||||||
|
func GetDefaultFont() (*truetype.Font, error) {
|
||||||
|
if _defaultFont == nil {
|
||||||
|
_defaultFontLock.Lock()
|
||||||
|
defer _defaultFontLock.Unlock()
|
||||||
|
if _defaultFont == nil {
|
||||||
|
font, err := truetype.Parse(roboto)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_defaultFont = font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _defaultFont, nil
|
||||||
|
}
|
41
point.go
Normal file
41
point.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
// Points are an array of points.
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Point represents a x,y coordinate.
|
||||||
|
type Point struct {
|
||||||
|
X float64
|
||||||
|
Y float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Points represents a group of points.
|
||||||
|
type Points []Point
|
||||||
|
|
||||||
|
// String returns a string representation of the points.
|
||||||
|
func (p Points) String() string {
|
||||||
|
var values []string
|
||||||
|
for _, v := range p {
|
||||||
|
values = append(values, fmt.Sprintf("%d,%d", int(v.X), int(v.Y)))
|
||||||
|
}
|
||||||
|
return strings.Join(values, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the length of the points set.
|
||||||
|
func (p Points) Len() int {
|
||||||
|
return len(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap swaps two elments.
|
||||||
|
func (p Points) Swap(i, j int) {
|
||||||
|
p[i], p[j] = p[j], p[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Less returns if the X value of one element is less than another.
|
||||||
|
// This is the default sort for charts where you plot by x values in order.
|
||||||
|
func (p Points) Less(i, j int) bool {
|
||||||
|
return p[i].X < p[j].X
|
||||||
|
}
|
97
range.go
Normal file
97
range.go
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Range is a type that translates values from a range to a domain.
|
||||||
|
type Range interface {
|
||||||
|
GetMin() interface{}
|
||||||
|
GetMax() interface{}
|
||||||
|
Translate(value interface{}) int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRangeOfFloat64 returns a new Range
|
||||||
|
func NewRangeOfFloat64(domain int, values ...float64) Range {
|
||||||
|
min, max := MinAndMax(values...)
|
||||||
|
return &RangeOfFloat64{
|
||||||
|
MinValue: min,
|
||||||
|
MaxValue: max,
|
||||||
|
MinMaxDelta: max - min,
|
||||||
|
Domain: domain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RangeOfFloat64 represents a continuous range
|
||||||
|
// of float64 values mapped to a [0...WindowMaxValue]
|
||||||
|
// interval.
|
||||||
|
type RangeOfFloat64 struct {
|
||||||
|
MinValue float64
|
||||||
|
MaxValue float64
|
||||||
|
MinMaxDelta float64
|
||||||
|
Domain int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMin implements the interface method.
|
||||||
|
func (r RangeOfFloat64) GetMin() interface{} {
|
||||||
|
return r.MinValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMax implements the interface method.
|
||||||
|
func (r RangeOfFloat64) GetMax() interface{} {
|
||||||
|
return r.MaxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate maps a given value into the range space.
|
||||||
|
// An example would be a 600 px image, with a min of 10 and a max of 100.
|
||||||
|
// Translate(50) would yield (50.0/90.0)*600 ~= 333.33
|
||||||
|
func (r RangeOfFloat64) Translate(value interface{}) int {
|
||||||
|
if typedValue, isTyped := value.(float64); isTyped {
|
||||||
|
finalValue := ((r.MaxValue - typedValue) / r.MinMaxDelta) * float64(r.Domain)
|
||||||
|
return int(math.Floor(finalValue))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRangeOfTime makes a new range of time with the given time values.
|
||||||
|
func NewRangeOfTime(domain int, values ...time.Time) Range {
|
||||||
|
min, max := MinAndMaxOfTime(values...)
|
||||||
|
r := &RangeOfTime{
|
||||||
|
MinValue: min,
|
||||||
|
MaxValue: max,
|
||||||
|
MinMaxDelta: max.Unix() - min.Unix(),
|
||||||
|
Domain: domain,
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// RangeOfTime represents a timeseries.
|
||||||
|
type RangeOfTime struct {
|
||||||
|
MinValue time.Time
|
||||||
|
MaxValue time.Time
|
||||||
|
MinMaxDelta int64 //unix time difference
|
||||||
|
Domain int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMin implements the interface method.
|
||||||
|
func (r RangeOfTime) GetMin() interface{} {
|
||||||
|
return r.MinValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMax implements the interface method.
|
||||||
|
func (r RangeOfTime) GetMax() interface{} {
|
||||||
|
return r.MaxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate maps a given value into the range space (of time).
|
||||||
|
// An example would be a 600 px image, with a min of jan-01-2016 and a max of jun-01-2016.
|
||||||
|
// Translate(may-01-2016) would yield ... something.
|
||||||
|
func (r RangeOfTime) Translate(value interface{}) int {
|
||||||
|
if typed, isTyped := value.(time.Time); isTyped {
|
||||||
|
valueDelta := r.MaxValue.Unix() - typed.Unix()
|
||||||
|
finalValue := (float64(valueDelta) / float64(r.MinMaxDelta)) * float64(r.Domain)
|
||||||
|
return int(math.Floor(finalValue))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
39
range_test.go
Normal file
39
range_test.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blendlabs/go-assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRangeTranslate(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
values := []float64{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0}
|
||||||
|
r := NewRange(1000, values...)
|
||||||
|
assert.Equal(1.0, r.MinValue)
|
||||||
|
assert.Equal(8.0, r.MaxValue)
|
||||||
|
assert.Equal(7.0, r.MinMaxDelta)
|
||||||
|
assert.Equal(1000, r.Domain)
|
||||||
|
assert.Equal(428, r.Translate(5.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRangeOfTimeTranslate(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
values := []time.Time{
|
||||||
|
time.Now().AddDate(0, 0, -1),
|
||||||
|
time.Now().AddDate(0, 0, -2),
|
||||||
|
time.Now().AddDate(0, 0, -3),
|
||||||
|
time.Now().AddDate(0, 0, -4),
|
||||||
|
time.Now().AddDate(0, 0, -5),
|
||||||
|
time.Now().AddDate(0, 0, -6),
|
||||||
|
time.Now().AddDate(0, 0, -7),
|
||||||
|
time.Now().AddDate(0, 0, -8),
|
||||||
|
}
|
||||||
|
r := NewRangeOfTime(1000, values...)
|
||||||
|
assert.Equal(values[7], r.MinValue)
|
||||||
|
assert.Equal(values[0], r.MaxValue)
|
||||||
|
assert.Equal(values[0].Unix()-values[7].Unix(), r.MinMaxDelta)
|
||||||
|
assert.Equal(1000, r.Domain)
|
||||||
|
assert.Equal(571, r.Translate(time.Now().AddDate(0, 0, -5)))
|
||||||
|
}
|
131
raster_renderer.go
Normal file
131
raster_renderer.go
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
drawing "github.com/llgcode/draw2d/draw2dimg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PNG returns a new png/raster renderer.
|
||||||
|
func PNG(width, height int) Renderer {
|
||||||
|
i := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
return &rasterRenderer{
|
||||||
|
i: i,
|
||||||
|
gc: drawing.NewGraphicContext(i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RasterRenderer renders chart commands to a bitmap.
|
||||||
|
type rasterRenderer struct {
|
||||||
|
i *image.RGBA
|
||||||
|
gc *drawing.GraphicContext
|
||||||
|
fc *font.Drawer
|
||||||
|
|
||||||
|
font *truetype.Font
|
||||||
|
fontColor color.RGBA
|
||||||
|
fontSize float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStrokeColor implements the interface method.
|
||||||
|
func (rr *rasterRenderer) SetStrokeColor(c color.RGBA) {
|
||||||
|
rr.gc.SetStrokeColor(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFillColor implements the interface method.
|
||||||
|
func (rr *rasterRenderer) SetFillColor(c color.RGBA) {
|
||||||
|
rr.gc.SetFillColor(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLineWidth implements the interface method.
|
||||||
|
func (rr *rasterRenderer) SetLineWidth(width int) {
|
||||||
|
rr.gc.SetLineWidth(float64(width))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveTo implements the interface method.
|
||||||
|
func (rr *rasterRenderer) MoveTo(x, y int) {
|
||||||
|
rr.gc.MoveTo(float64(x), float64(y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LineTo implements the interface method.
|
||||||
|
func (rr *rasterRenderer) LineTo(x, y int) {
|
||||||
|
rr.gc.LineTo(float64(x), float64(y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close implements the interface method.
|
||||||
|
func (rr *rasterRenderer) Close() {
|
||||||
|
rr.gc.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stroke implements the interface method.
|
||||||
|
func (rr *rasterRenderer) Stroke() {
|
||||||
|
rr.gc.Stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillStroke implements the interface method.
|
||||||
|
func (rr *rasterRenderer) FillStroke() {
|
||||||
|
rr.gc.FillStroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circle implements the interface method.
|
||||||
|
func (rr *rasterRenderer) Circle(radius float64, x, y int) {
|
||||||
|
xf := float64(x)
|
||||||
|
yf := float64(y)
|
||||||
|
rr.gc.MoveTo(xf-radius, yf) //9
|
||||||
|
rr.gc.QuadCurveTo(xf, yf, xf, yf-radius) //12
|
||||||
|
rr.gc.QuadCurveTo(xf, yf, xf+radius, yf) //3
|
||||||
|
rr.gc.QuadCurveTo(xf, yf, xf, yf+radius) //6
|
||||||
|
rr.gc.QuadCurveTo(xf, yf, xf-radius, yf) //9
|
||||||
|
rr.gc.Close()
|
||||||
|
rr.gc.FillStroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFont implements the interface method.
|
||||||
|
func (rr *rasterRenderer) SetFont(f *truetype.Font) {
|
||||||
|
rr.font = f
|
||||||
|
rr.gc.SetFont(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFontSize implements the interface method.
|
||||||
|
func (rr *rasterRenderer) SetFontSize(size float64) {
|
||||||
|
rr.fontSize = size
|
||||||
|
rr.gc.SetFontSize(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFontColor implements the interface method.
|
||||||
|
func (rr *rasterRenderer) SetFontColor(c color.RGBA) {
|
||||||
|
rr.fontColor = c
|
||||||
|
rr.gc.SetStrokeColor(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text implements the interface method.
|
||||||
|
func (rr *rasterRenderer) Text(body string, x, y int) {
|
||||||
|
rr.gc.CreateStringPath(body, float64(x), float64(y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeasureText implements the interface method.
|
||||||
|
func (rr *rasterRenderer) MeasureText(body string) int {
|
||||||
|
if rr.fc == nil && rr.font != nil {
|
||||||
|
rr.fc = &font.Drawer{
|
||||||
|
Face: truetype.NewFace(rr.font, &truetype.Options{
|
||||||
|
DPI: DefaultDPI,
|
||||||
|
Size: rr.fontSize,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rr.fc != nil {
|
||||||
|
dimensions := rr.fc.MeasureString(body)
|
||||||
|
return dimensions.Floor()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save implements the interface method.
|
||||||
|
func (rr *rasterRenderer) Save(w io.Writer) error {
|
||||||
|
return png.Encode(w, rr.i)
|
||||||
|
}
|
60
renderer.go
Normal file
60
renderer.go
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RendererProvider is a function that returns a renderer.
|
||||||
|
type RendererProvider func(int, int) Renderer
|
||||||
|
|
||||||
|
// Renderer represents the basic methods required to draw a chart.
|
||||||
|
type Renderer interface {
|
||||||
|
// SetStrokeColor sets the current stroke color.
|
||||||
|
SetStrokeColor(color.RGBA)
|
||||||
|
|
||||||
|
// SetFillColor sets the current fill color.
|
||||||
|
SetFillColor(color.RGBA)
|
||||||
|
|
||||||
|
// SetLineWidth sets the stroke line width.
|
||||||
|
SetLineWidth(width int)
|
||||||
|
|
||||||
|
// MoveTo moves the cursor to a given point.
|
||||||
|
MoveTo(x, y int)
|
||||||
|
|
||||||
|
// LineTo both starts a shape and draws a line to a given point
|
||||||
|
// from the previous point.
|
||||||
|
LineTo(x, y int)
|
||||||
|
|
||||||
|
// Close finalizes a shape as drawn by LineTo.
|
||||||
|
Close()
|
||||||
|
|
||||||
|
// Stroke draws the 'stroke' or line component of a shape.
|
||||||
|
Stroke()
|
||||||
|
|
||||||
|
// FillStroke draws the 'stroke' and 'fills' a shape.
|
||||||
|
FillStroke()
|
||||||
|
|
||||||
|
// Circle draws a circle at the given coords with a given radius.
|
||||||
|
Circle(radius float64, x, y int)
|
||||||
|
|
||||||
|
// SetFont sets a font for a text field.
|
||||||
|
SetFont(*truetype.Font)
|
||||||
|
|
||||||
|
// SetFontColor sets a font's color
|
||||||
|
SetFontColor(color.RGBA)
|
||||||
|
|
||||||
|
// SetFontSize sets the font size for a text field.
|
||||||
|
SetFontSize(size float64)
|
||||||
|
|
||||||
|
// Text draws a text blob.
|
||||||
|
Text(body string, x, y int)
|
||||||
|
|
||||||
|
// MeasureText measures text.
|
||||||
|
MeasureText(body string) int
|
||||||
|
|
||||||
|
// Save writes the image to the given writer.
|
||||||
|
Save(w io.Writer) error
|
||||||
|
}
|
5
roboto.go
Normal file
5
roboto.go
Normal file
File diff suppressed because one or more lines are too long
92
series.go
Normal file
92
series.go
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Series is a entity data set.
|
||||||
|
type Series interface {
|
||||||
|
GetName() string
|
||||||
|
GetStyle() Style
|
||||||
|
Len() int
|
||||||
|
GetValue(index int) Point
|
||||||
|
|
||||||
|
GetXRange(domain int) Range
|
||||||
|
GetYRange(domain int) Range
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeSeries is a line on a chart.
|
||||||
|
type TimeSeries struct {
|
||||||
|
Name string
|
||||||
|
Style Style
|
||||||
|
|
||||||
|
XValues []time.Time
|
||||||
|
YValues []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetName returns the name of the time series.
|
||||||
|
func (ts TimeSeries) GetName() string {
|
||||||
|
return ts.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStyle returns the line style.
|
||||||
|
func (ts TimeSeries) GetStyle() Style {
|
||||||
|
return ts.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of elements in the series.
|
||||||
|
func (ts TimeSeries) Len() int {
|
||||||
|
return len(ts.XValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetXRange returns the x range.
|
||||||
|
func (ts TimeSeries) GetXRange(domain int) Range {
|
||||||
|
return NewRangeOfTime(domain, ts.XValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetYRange returns the x range.
|
||||||
|
func (ts TimeSeries) GetYRange(domain int) Range {
|
||||||
|
return NewRangeOfFloat64(domain, ts.YValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue gets a value at a given index.
|
||||||
|
func (ts TimeSeries) GetValue(index int) Point {
|
||||||
|
return Point{X: float64(ts.XValues[index].Unix()), Y: ts.YValues[index]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContinousSeries represents a line on a chart.
|
||||||
|
type ContinousSeries struct {
|
||||||
|
Name string
|
||||||
|
Style Style
|
||||||
|
|
||||||
|
XValues []float64
|
||||||
|
YValues []float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetName returns the name of the time series.
|
||||||
|
func (cs ContinousSeries) GetName() string {
|
||||||
|
return cs.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStyle returns the line style.
|
||||||
|
func (cs ContinousSeries) GetStyle() Style {
|
||||||
|
return cs.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of elements in the series.
|
||||||
|
func (cs ContinousSeries) Len() int {
|
||||||
|
return len(cs.XValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue gets a value at a given index.
|
||||||
|
func (cs ContinousSeries) GetValue(index int) Point {
|
||||||
|
return Point{X: cs.XValues[index], Y: cs.YValues[index]}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetXRange returns the x range.
|
||||||
|
func (cs ContinousSeries) GetXRange(domain int) Range {
|
||||||
|
return NewRangeOfFloat64(domain, cs.XValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetYRange returns the x range.
|
||||||
|
func (cs ContinousSeries) GetYRange(domain int) Range {
|
||||||
|
return NewRangeOfFloat64(domain, cs.YValues...)
|
||||||
|
}
|
1
series_test.go
Normal file
1
series_test.go
Normal file
|
@ -0,0 +1 @@
|
||||||
|
package chart
|
39
style.go
Normal file
39
style.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import "image/color"
|
||||||
|
|
||||||
|
// Style is a simple style set.
|
||||||
|
type Style struct {
|
||||||
|
StrokeColor color.RGBA
|
||||||
|
FillColor color.RGBA
|
||||||
|
StrokeWidth int
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsZero returns if the object is set or not.
|
||||||
|
func (s Style) IsZero() bool {
|
||||||
|
return ColorIsZero(s.StrokeColor) && ColorIsZero(s.FillColor) && s.StrokeWidth == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStrokeColor returns the stroke color.
|
||||||
|
func (s Style) GetStrokeColor() color.RGBA {
|
||||||
|
if ColorIsZero(s.StrokeColor) {
|
||||||
|
return DefaultLineColor
|
||||||
|
}
|
||||||
|
return s.StrokeColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFillColor returns the fill color.
|
||||||
|
func (s Style) GetFillColor() color.RGBA {
|
||||||
|
if ColorIsZero(s.FillColor) {
|
||||||
|
return DefaultFillColor
|
||||||
|
}
|
||||||
|
return s.FillColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStrokeWidth returns the stroke width.
|
||||||
|
func (s Style) GetStrokeWidth() int {
|
||||||
|
if s.StrokeWidth == 0 {
|
||||||
|
return DefaultLineWidth
|
||||||
|
}
|
||||||
|
return s.StrokeWidth
|
||||||
|
}
|
50
util.go
Normal file
50
util.go
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ColorIsZero returns if a color.RGBA is unset or not.
|
||||||
|
func ColorIsZero(c color.RGBA) bool {
|
||||||
|
return c.R == 0 && c.G == 0 && c.B == 0 && c.A == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinAndMax returns both the min and max in one pass.
|
||||||
|
func MinAndMax(values ...float64) (min float64, max float64) {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
min = values[0]
|
||||||
|
max = values[0]
|
||||||
|
for _, v := range values {
|
||||||
|
if max < v {
|
||||||
|
max = v
|
||||||
|
}
|
||||||
|
if min > v {
|
||||||
|
min = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// MinAndMaxOfTime returns the min and max of a given set of times
|
||||||
|
// in one pass.
|
||||||
|
func MinAndMaxOfTime(values ...time.Time) (min time.Time, max time.Time) {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
min = values[0]
|
||||||
|
max = values[0]
|
||||||
|
|
||||||
|
for _, v := range values {
|
||||||
|
if max.Before(v) {
|
||||||
|
max = v
|
||||||
|
}
|
||||||
|
if min.After(v) {
|
||||||
|
min = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
66
util_test.go
Normal file
66
util_test.go
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
package chart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blendlabs/go-assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMinAndMax(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
values := []float64{1.0, 2.0, 3.0, 4.0}
|
||||||
|
min, max := MinAndMax(values...)
|
||||||
|
assert.Equal(1.0, min)
|
||||||
|
assert.Equal(4.0, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinAndMaxReversed(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
values := []float64{4.0, 2.0, 3.0, 1.0}
|
||||||
|
min, max := MinAndMax(values...)
|
||||||
|
assert.Equal(1.0, min)
|
||||||
|
assert.Equal(4.0, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinAndMaxEmpty(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
values := []float64{}
|
||||||
|
min, max := MinAndMax(values...)
|
||||||
|
assert.Equal(0.0, min)
|
||||||
|
assert.Equal(0.0, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinAndMaxOfTime(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
values := []time.Time{
|
||||||
|
time.Now().AddDate(0, 0, -1),
|
||||||
|
time.Now().AddDate(0, 0, -2),
|
||||||
|
time.Now().AddDate(0, 0, -3),
|
||||||
|
time.Now().AddDate(0, 0, -4),
|
||||||
|
}
|
||||||
|
min, max := MinAndMaxOfTime(values...)
|
||||||
|
assert.Equal(values[3], min)
|
||||||
|
assert.Equal(values[0], max)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinAndMaxOfTimeReversed(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
values := []time.Time{
|
||||||
|
time.Now().AddDate(0, 0, -4),
|
||||||
|
time.Now().AddDate(0, 0, -2),
|
||||||
|
time.Now().AddDate(0, 0, -3),
|
||||||
|
time.Now().AddDate(0, 0, -1),
|
||||||
|
}
|
||||||
|
min, max := MinAndMaxOfTime(values...)
|
||||||
|
assert.Equal(values[0], min)
|
||||||
|
assert.Equal(values[3], max)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinAndMaxOfTimeEmpty(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
values := []time.Time{}
|
||||||
|
min, max := MinAndMaxOfTime(values...)
|
||||||
|
assert.Equal(time.Time{}, min)
|
||||||
|
assert.Equal(time.Time{}, max)
|
||||||
|
}
|
Loading…
Reference in a new issue