refactor: change project structure and package name

This commit is contained in:
Rico 2022-08-27 17:21:43 +02:00
parent c1468e8ae4
commit ad10e9a062
No known key found for this signature in database
GPG key ID: 91F477359C5B7AD3
163 changed files with 104 additions and 135 deletions

View file

@ -0,0 +1,91 @@
package chart
import (
"fmt"
"math"
)
// Interface Assertions.
var (
_ Series = (*AnnotationSeries)(nil)
)
// AnnotationSeries is a series of labels on the chart.
type AnnotationSeries struct {
Name string
Style Style
YAxis YAxisType
Annotations []Value2
}
// GetName returns the name of the time series.
func (as AnnotationSeries) GetName() string {
return as.Name
}
// GetStyle returns the line style.
func (as AnnotationSeries) GetStyle() Style {
return as.Style
}
// GetYAxis returns which YAxis the series draws on.
func (as AnnotationSeries) GetYAxis() YAxisType {
return as.YAxis
}
func (as AnnotationSeries) annotationStyleDefaults(defaults Style) Style {
return Style{
FontColor: DefaultTextColor,
Font: defaults.Font,
FillColor: DefaultAnnotationFillColor,
FontSize: DefaultAnnotationFontSize,
StrokeColor: defaults.StrokeColor,
StrokeWidth: defaults.StrokeWidth,
Padding: DefaultAnnotationPadding,
}
}
// Measure returns a bounds box of the series.
func (as AnnotationSeries) Measure(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) Box {
box := Box{
Top: math.MaxInt32,
Left: math.MaxInt32,
Right: 0,
Bottom: 0,
}
if !as.Style.Hidden {
seriesStyle := as.Style.InheritFrom(as.annotationStyleDefaults(defaults))
for _, a := range as.Annotations {
style := a.Style.InheritFrom(seriesStyle)
lx := canvasBox.Left + xrange.Translate(a.XValue)
ly := canvasBox.Bottom - yrange.Translate(a.YValue)
ab := Draw.MeasureAnnotation(r, canvasBox, style, lx, ly, a.Label)
box.Top = MinInt(box.Top, ab.Top)
box.Left = MinInt(box.Left, ab.Left)
box.Right = MaxInt(box.Right, ab.Right)
box.Bottom = MaxInt(box.Bottom, ab.Bottom)
}
}
return box
}
// Render draws the series.
func (as AnnotationSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
if !as.Style.Hidden {
seriesStyle := as.Style.InheritFrom(as.annotationStyleDefaults(defaults))
for _, a := range as.Annotations {
style := a.Style.InheritFrom(seriesStyle)
lx := canvasBox.Left + xrange.Translate(a.XValue)
ly := canvasBox.Bottom - yrange.Translate(a.YValue)
Draw.Annotation(r, canvasBox, style, lx, ly, a.Label)
}
}
}
// Validate validates the series.
func (as AnnotationSeries) Validate() error {
if len(as.Annotations) == 0 {
return fmt.Errorf("annotation series requires annotations to be set and not empty")
}
return nil
}

View file

@ -0,0 +1,115 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
"image/color"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestAnnotationSeriesMeasure(t *testing.T) {
// replaced new assertions helper
as := AnnotationSeries{
Annotations: []Value2{
{XValue: 1.0, YValue: 1.0, Label: "1.0"},
{XValue: 2.0, YValue: 2.0, Label: "2.0"},
{XValue: 3.0, YValue: 3.0, Label: "3.0"},
{XValue: 4.0, YValue: 4.0, Label: "4.0"},
},
}
r, err := PNG(110, 110)
testutil.AssertNil(t, err)
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
xrange := &ContinuousRange{
Min: 1.0,
Max: 4.0,
Domain: 100,
}
yrange := &ContinuousRange{
Min: 1.0,
Max: 4.0,
Domain: 100,
}
cb := Box{
Top: 5,
Left: 5,
Right: 105,
Bottom: 105,
}
sd := Style{
FontSize: 10.0,
Font: f,
}
box := as.Measure(r, cb, xrange, yrange, sd)
testutil.AssertFalse(t, box.IsZero())
testutil.AssertEqual(t, -5.0, box.Top)
testutil.AssertEqual(t, 5.0, box.Left)
testutil.AssertEqual(t, 146.0, box.Right) //the top,left annotation sticks up 5px and out ~44px.
testutil.AssertEqual(t, 115.0, box.Bottom)
}
func TestAnnotationSeriesRender(t *testing.T) {
// replaced new assertions helper
as := AnnotationSeries{
Style: Style{
FillColor: drawing.ColorWhite,
StrokeColor: drawing.ColorBlack,
},
Annotations: []Value2{
{XValue: 1.0, YValue: 1.0, Label: "1.0"},
{XValue: 2.0, YValue: 2.0, Label: "2.0"},
{XValue: 3.0, YValue: 3.0, Label: "3.0"},
{XValue: 4.0, YValue: 4.0, Label: "4.0"},
},
}
r, err := PNG(110, 110)
testutil.AssertNil(t, err)
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
xrange := &ContinuousRange{
Min: 1.0,
Max: 4.0,
Domain: 100,
}
yrange := &ContinuousRange{
Min: 1.0,
Max: 4.0,
Domain: 100,
}
cb := Box{
Top: 5,
Left: 5,
Right: 105,
Bottom: 105,
}
sd := Style{
FontSize: 10.0,
Font: f,
}
as.Render(r, cb, xrange, yrange, sd)
rr, isRaster := r.(*rasterRenderer)
testutil.AssertTrue(t, isRaster)
testutil.AssertNotNil(t, rr)
c := rr.i.At(38, 70)
converted, isRGBA := color.RGBAModel.Convert(c).(color.RGBA)
testutil.AssertTrue(t, isRGBA)
testutil.AssertEqual(t, 0, converted.R)
testutil.AssertEqual(t, 0, converted.G)
testutil.AssertEqual(t, 0, converted.B)
}

24
pkg/chart/array.go Normal file
View file

@ -0,0 +1,24 @@
package chart
var (
_ Sequence = (*Array)(nil)
)
// NewArray returns a new array from a given set of values.
// Array implements Sequence, which allows it to be used with the sequence helpers.
func NewArray(values ...float64) Array {
return Array(values)
}
// Array is a wrapper for an array of floats that implements `ValuesProvider`.
type Array []float64
// Len returns the value provider length.
func (a Array) Len() int {
return len(a)
}
// GetValue returns the value at a given index.
func (a Array) GetValue(index int) float64 {
return a[index]
}

45
pkg/chart/axis.go Normal file
View file

@ -0,0 +1,45 @@
package chart
// TickPosition is an enumeration of possible tick drawing positions.
type TickPosition int
const (
// TickPositionUnset means to use the default tick position.
TickPositionUnset TickPosition = 0
// TickPositionBetweenTicks draws the labels for a tick between the previous and current tick.
TickPositionBetweenTicks TickPosition = 1
// TickPositionUnderTick draws the tick below the tick.
TickPositionUnderTick TickPosition = 2
)
// YAxisType is a type of y-axis; it can either be primary or secondary.
type YAxisType int
const (
// YAxisPrimary is the primary axis.
YAxisPrimary YAxisType = 0
// YAxisSecondary is the secondary axis.
YAxisSecondary YAxisType = 1
)
// Axis is a chart feature detailing what values happen where.
type Axis interface {
GetName() string
SetName(name string)
GetStyle() Style
SetStyle(style Style)
GetTicks() []Tick
GenerateTicks(r Renderer, ra Range, vf ValueFormatter) []Tick
// GenerateGridLines returns the gridlines for the axis.
GetGridLines(ticks []Tick) []GridLine
// Measure should return an absolute box for the axis.
// This is used when auto-fitting the canvas to the background.
Measure(r Renderer, canvasBox Box, ra Range, style Style, ticks []Tick) Box
// Render renders the axis.
Render(r Renderer, canvasBox Box, ra Range, style Style, ticks []Tick)
}

516
pkg/chart/bar_chart.go Normal file
View file

@ -0,0 +1,516 @@
package chart
import (
"errors"
"fmt"
"io"
"math"
"github.com/golang/freetype/truetype"
)
// BarChart is a chart that draws bars on a range.
type BarChart struct {
Title string
TitleStyle Style
ColorPalette ColorPalette
Width int
Height int
DPI float64
BarWidth int
Background Style
Canvas Style
XAxis Style
YAxis YAxis
BarSpacing int
UseBaseValue bool
BaseValue float64
Font *truetype.Font
defaultFont *truetype.Font
Bars []Value
Elements []Renderable
}
// GetDPI returns the dpi for the chart.
func (bc BarChart) GetDPI() float64 {
if bc.DPI == 0 {
return DefaultDPI
}
return bc.DPI
}
// GetFont returns the text font.
func (bc BarChart) GetFont() *truetype.Font {
if bc.Font == nil {
return bc.defaultFont
}
return bc.Font
}
// GetWidth returns the chart width or the default value.
func (bc BarChart) GetWidth() int {
if bc.Width == 0 {
return DefaultChartWidth
}
return bc.Width
}
// GetHeight returns the chart height or the default value.
func (bc BarChart) GetHeight() int {
if bc.Height == 0 {
return DefaultChartHeight
}
return bc.Height
}
// GetBarSpacing returns the spacing between bars.
func (bc BarChart) GetBarSpacing() int {
if bc.BarSpacing == 0 {
return DefaultBarSpacing
}
return bc.BarSpacing
}
// GetBarWidth returns the default bar width.
func (bc BarChart) GetBarWidth() int {
if bc.BarWidth == 0 {
return DefaultBarWidth
}
return bc.BarWidth
}
// Render renders the chart with the given renderer to the given io.Writer.
func (bc BarChart) Render(rp RendererProvider, w io.Writer) error {
if len(bc.Bars) == 0 {
return errors.New("please provide at least one bar")
}
r, err := rp(bc.GetWidth(), bc.GetHeight())
if err != nil {
return err
}
if bc.Font == nil {
defaultFont, err := GetDefaultFont()
if err != nil {
return err
}
bc.defaultFont = defaultFont
}
r.SetDPI(bc.GetDPI())
bc.drawBackground(r)
var canvasBox Box
var yt []Tick
var yr Range
var yf ValueFormatter
canvasBox = bc.getDefaultCanvasBox()
yr = bc.getRanges()
if yr.GetMax()-yr.GetMin() == 0 {
return fmt.Errorf("invalid data range; cannot be zero")
}
yr = bc.setRangeDomains(canvasBox, yr)
yf = bc.getValueFormatters()
if bc.hasAxes() {
yt = bc.getAxesTicks(r, yr, yf)
canvasBox = bc.getAdjustedCanvasBox(r, canvasBox, yr, yt)
yr = bc.setRangeDomains(canvasBox, yr)
}
bc.drawCanvas(r, canvasBox)
bc.drawBars(r, canvasBox, yr)
bc.drawXAxis(r, canvasBox)
bc.drawYAxis(r, canvasBox, yr, yt)
bc.drawTitle(r)
for _, a := range bc.Elements {
a(r, canvasBox, bc.styleDefaultsElements())
}
return r.Save(w)
}
func (bc BarChart) drawCanvas(r Renderer, canvasBox Box) {
Draw.Box(r, canvasBox, bc.getCanvasStyle())
}
func (bc BarChart) getRanges() Range {
var yrange Range
if bc.YAxis.Range != nil && !bc.YAxis.Range.IsZero() {
yrange = bc.YAxis.Range
} else {
yrange = &ContinuousRange{}
}
if !yrange.IsZero() {
return yrange
}
if len(bc.YAxis.Ticks) > 0 {
tickMin, tickMax := math.MaxFloat64, -math.MaxFloat64
for _, t := range bc.YAxis.Ticks {
tickMin = math.Min(tickMin, t.Value)
tickMax = math.Max(tickMax, t.Value)
}
yrange.SetMin(tickMin)
yrange.SetMax(tickMax)
return yrange
}
min, max := math.MaxFloat64, -math.MaxFloat64
for _, b := range bc.Bars {
min = math.Min(b.Value, min)
max = math.Max(b.Value, max)
}
yrange.SetMin(min)
yrange.SetMax(max)
return yrange
}
func (bc BarChart) drawBackground(r Renderer) {
Draw.Box(r, Box{
Right: bc.GetWidth(),
Bottom: bc.GetHeight(),
}, bc.getBackgroundStyle())
}
func (bc BarChart) drawBars(r Renderer, canvasBox Box, yr Range) {
xoffset := canvasBox.Left
width, spacing, _ := bc.calculateScaledTotalWidth(canvasBox)
bs2 := spacing >> 1
var barBox Box
var bxl, bxr, by int
for index, bar := range bc.Bars {
bxl = xoffset + bs2
bxr = bxl + width
by = canvasBox.Bottom - yr.Translate(bar.Value)
if bc.UseBaseValue {
barBox = Box{
Top: by,
Left: bxl,
Right: bxr,
Bottom: canvasBox.Bottom - yr.Translate(bc.BaseValue),
}
} else {
barBox = Box{
Top: by,
Left: bxl,
Right: bxr,
Bottom: canvasBox.Bottom,
}
}
Draw.Box(r, barBox, bar.Style.InheritFrom(bc.styleDefaultsBar(index)))
xoffset += width + spacing
}
}
func (bc BarChart) drawXAxis(r Renderer, canvasBox Box) {
if !bc.XAxis.Hidden {
axisStyle := bc.XAxis.InheritFrom(bc.styleDefaultsAxes())
axisStyle.WriteToRenderer(r)
width, spacing, _ := bc.calculateScaledTotalWidth(canvasBox)
r.MoveTo(canvasBox.Left, canvasBox.Bottom)
r.LineTo(canvasBox.Right, canvasBox.Bottom)
r.Stroke()
r.MoveTo(canvasBox.Left, canvasBox.Bottom)
r.LineTo(canvasBox.Left, canvasBox.Bottom+DefaultVerticalTickHeight)
r.Stroke()
cursor := canvasBox.Left
for index, bar := range bc.Bars {
barLabelBox := Box{
Top: canvasBox.Bottom + DefaultXAxisMargin,
Left: cursor,
Right: cursor + width + spacing,
Bottom: bc.GetHeight(),
}
if len(bar.Label) > 0 {
Draw.TextWithin(r, bar.Label, barLabelBox, axisStyle)
}
axisStyle.WriteToRenderer(r)
if index < len(bc.Bars)-1 {
r.MoveTo(barLabelBox.Right, canvasBox.Bottom)
r.LineTo(barLabelBox.Right, canvasBox.Bottom+DefaultVerticalTickHeight)
r.Stroke()
}
cursor += width + spacing
}
}
}
func (bc BarChart) drawYAxis(r Renderer, canvasBox Box, yr Range, ticks []Tick) {
if !bc.YAxis.Style.Hidden {
axisStyle := bc.YAxis.Style.InheritFrom(bc.styleDefaultsAxes())
axisStyle.WriteToRenderer(r)
r.MoveTo(canvasBox.Right, canvasBox.Top)
r.LineTo(canvasBox.Right, canvasBox.Bottom)
r.Stroke()
r.MoveTo(canvasBox.Right, canvasBox.Bottom)
r.LineTo(canvasBox.Right+DefaultHorizontalTickWidth, canvasBox.Bottom)
r.Stroke()
var ty int
var tb Box
for _, t := range ticks {
ty = canvasBox.Bottom - yr.Translate(t.Value)
axisStyle.GetStrokeOptions().WriteToRenderer(r)
r.MoveTo(canvasBox.Right, ty)
r.LineTo(canvasBox.Right+DefaultHorizontalTickWidth, ty)
r.Stroke()
axisStyle.GetTextOptions().WriteToRenderer(r)
tb = r.MeasureText(t.Label)
Draw.Text(r, t.Label, canvasBox.Right+DefaultYAxisMargin+5, ty+(tb.Height()>>1), axisStyle)
}
}
}
func (bc BarChart) drawTitle(r Renderer) {
if len(bc.Title) > 0 && !bc.TitleStyle.Hidden {
r.SetFont(bc.TitleStyle.GetFont(bc.GetFont()))
r.SetFontColor(bc.TitleStyle.GetFontColor(bc.GetColorPalette().TextColor()))
titleFontSize := bc.TitleStyle.GetFontSize(bc.getTitleFontSize())
r.SetFontSize(titleFontSize)
textBox := r.MeasureText(bc.Title)
textWidth := textBox.Width()
textHeight := textBox.Height()
titleX := (bc.GetWidth() >> 1) - (textWidth >> 1)
titleY := bc.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight
r.Text(bc.Title, titleX, titleY)
}
}
func (bc BarChart) getCanvasStyle() Style {
return bc.Canvas.InheritFrom(bc.styleDefaultsCanvas())
}
func (bc BarChart) styleDefaultsCanvas() Style {
return Style{
FillColor: bc.GetColorPalette().CanvasColor(),
StrokeColor: bc.GetColorPalette().CanvasStrokeColor(),
StrokeWidth: DefaultCanvasStrokeWidth,
}
}
func (bc BarChart) hasAxes() bool {
return !bc.YAxis.Style.Hidden
}
func (bc BarChart) setRangeDomains(canvasBox Box, yr Range) Range {
yr.SetDomain(canvasBox.Height())
return yr
}
func (bc BarChart) getDefaultCanvasBox() Box {
return bc.box()
}
func (bc BarChart) getValueFormatters() ValueFormatter {
if bc.YAxis.ValueFormatter != nil {
return bc.YAxis.ValueFormatter
}
return FloatValueFormatter
}
func (bc BarChart) getAxesTicks(r Renderer, yr Range, yf ValueFormatter) (yticks []Tick) {
if !bc.YAxis.Style.Hidden {
yticks = bc.YAxis.GetTicks(r, yr, bc.styleDefaultsAxes(), yf)
}
return
}
func (bc BarChart) calculateEffectiveBarSpacing(canvasBox Box) int {
totalWithBaseSpacing := bc.calculateTotalBarWidth(bc.GetBarWidth(), bc.GetBarSpacing())
if totalWithBaseSpacing > canvasBox.Width() {
lessBarWidths := canvasBox.Width() - (len(bc.Bars) * bc.GetBarWidth())
if lessBarWidths > 0 {
return int(math.Ceil(float64(lessBarWidths) / float64(len(bc.Bars))))
}
return 0
}
return bc.GetBarSpacing()
}
func (bc BarChart) calculateEffectiveBarWidth(canvasBox Box, spacing int) int {
totalWithBaseWidth := bc.calculateTotalBarWidth(bc.GetBarWidth(), spacing)
if totalWithBaseWidth > canvasBox.Width() {
totalLessBarSpacings := canvasBox.Width() - (len(bc.Bars) * spacing)
if totalLessBarSpacings > 0 {
return int(math.Ceil(float64(totalLessBarSpacings) / float64(len(bc.Bars))))
}
return 0
}
return bc.GetBarWidth()
}
func (bc BarChart) calculateTotalBarWidth(barWidth, spacing int) int {
return len(bc.Bars) * (barWidth + spacing)
}
func (bc BarChart) calculateScaledTotalWidth(canvasBox Box) (width, spacing, total int) {
spacing = bc.calculateEffectiveBarSpacing(canvasBox)
width = bc.calculateEffectiveBarWidth(canvasBox, spacing)
total = bc.calculateTotalBarWidth(width, spacing)
return
}
func (bc BarChart) getAdjustedCanvasBox(r Renderer, canvasBox Box, yrange Range, yticks []Tick) Box {
axesOuterBox := canvasBox.Clone()
_, _, totalWidth := bc.calculateScaledTotalWidth(canvasBox)
if !bc.XAxis.Hidden {
xaxisHeight := DefaultVerticalTickHeight
axisStyle := bc.XAxis.InheritFrom(bc.styleDefaultsAxes())
axisStyle.WriteToRenderer(r)
cursor := canvasBox.Left
for _, bar := range bc.Bars {
if len(bar.Label) > 0 {
barLabelBox := Box{
Top: canvasBox.Bottom + DefaultXAxisMargin,
Left: cursor,
Right: cursor + bc.GetBarWidth() + bc.GetBarSpacing(),
Bottom: bc.GetHeight(),
}
lines := Text.WrapFit(r, bar.Label, barLabelBox.Width(), axisStyle)
linesBox := Text.MeasureLines(r, lines, axisStyle)
xaxisHeight = MinInt(linesBox.Height()+(2*DefaultXAxisMargin), xaxisHeight)
}
}
xbox := Box{
Top: canvasBox.Top,
Left: canvasBox.Left,
Right: canvasBox.Left + totalWidth,
Bottom: bc.GetHeight() - xaxisHeight,
}
axesOuterBox = axesOuterBox.Grow(xbox)
}
if !bc.YAxis.Style.Hidden {
axesBounds := bc.YAxis.Measure(r, canvasBox, yrange, bc.styleDefaultsAxes(), yticks)
axesOuterBox = axesOuterBox.Grow(axesBounds)
}
return canvasBox.OuterConstrain(bc.box(), axesOuterBox)
}
// box returns the chart bounds as a box.
func (bc BarChart) box() Box {
dpr := bc.Background.Padding.GetRight(10)
dpb := bc.Background.Padding.GetBottom(50)
return Box{
Top: bc.Background.Padding.GetTop(20),
Left: bc.Background.Padding.GetLeft(20),
Right: bc.GetWidth() - dpr,
Bottom: bc.GetHeight() - dpb,
}
}
func (bc BarChart) getBackgroundStyle() Style {
return bc.Background.InheritFrom(bc.styleDefaultsBackground())
}
func (bc BarChart) styleDefaultsBackground() Style {
return Style{
FillColor: bc.GetColorPalette().BackgroundColor(),
StrokeColor: bc.GetColorPalette().BackgroundStrokeColor(),
StrokeWidth: DefaultStrokeWidth,
}
}
func (bc BarChart) styleDefaultsBar(index int) Style {
return Style{
StrokeColor: bc.GetColorPalette().GetSeriesColor(index),
StrokeWidth: 3.0,
FillColor: bc.GetColorPalette().GetSeriesColor(index),
}
}
func (bc BarChart) styleDefaultsTitle() Style {
return bc.TitleStyle.InheritFrom(Style{
FontColor: bc.GetColorPalette().TextColor(),
Font: bc.GetFont(),
FontSize: bc.getTitleFontSize(),
TextHorizontalAlign: TextHorizontalAlignCenter,
TextVerticalAlign: TextVerticalAlignTop,
TextWrap: TextWrapWord,
})
}
func (bc BarChart) getTitleFontSize() float64 {
effectiveDimension := MinInt(bc.GetWidth(), bc.GetHeight())
if effectiveDimension >= 2048 {
return 48
} else if effectiveDimension >= 1024 {
return 24
} else if effectiveDimension >= 512 {
return 18
} else if effectiveDimension >= 256 {
return 12
}
return 10
}
func (bc BarChart) styleDefaultsAxes() Style {
return Style{
StrokeColor: bc.GetColorPalette().AxisStrokeColor(),
Font: bc.GetFont(),
FontSize: DefaultAxisFontSize,
FontColor: bc.GetColorPalette().TextColor(),
TextHorizontalAlign: TextHorizontalAlignCenter,
TextVerticalAlign: TextVerticalAlignTop,
TextWrap: TextWrapWord,
}
}
func (bc BarChart) styleDefaultsElements() Style {
return Style{
Font: bc.GetFont(),
}
}
// GetColorPalette returns the color palette for the chart.
func (bc BarChart) GetColorPalette() ColorPalette {
if bc.ColorPalette != nil {
return bc.ColorPalette
}
return AlternateColorPalette
}

310
pkg/chart/bar_chart_test.go Normal file
View file

@ -0,0 +1,310 @@
package chart
import (
"bytes"
"math"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestBarChartRender(t *testing.T) {
// replaced new assertions helper
bc := BarChart{
Width: 1024,
Title: "Test Title",
Bars: []Value{
{Value: 1.0, Label: "One"},
{Value: 2.0, Label: "Two"},
{Value: 3.0, Label: "Three"},
{Value: 4.0, Label: "Four"},
{Value: 5.0, Label: "Five"},
},
}
buf := bytes.NewBuffer([]byte{})
err := bc.Render(PNG, buf)
testutil.AssertNil(t, err)
testutil.AssertNotZero(t, buf.Len())
}
func TestBarChartRenderZero(t *testing.T) {
// replaced new assertions helper
bc := BarChart{
Width: 1024,
Title: "Test Title",
Bars: []Value{
{Value: 0.0, Label: "One"},
{Value: 0.0, Label: "Two"},
},
}
buf := bytes.NewBuffer([]byte{})
err := bc.Render(PNG, buf)
testutil.AssertNotNil(t, err)
}
func TestBarChartProps(t *testing.T) {
// replaced new assertions helper
bc := BarChart{}
testutil.AssertEqual(t, DefaultDPI, bc.GetDPI())
bc.DPI = 100
testutil.AssertEqual(t, 100, bc.GetDPI())
testutil.AssertNil(t, bc.GetFont())
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
bc.Font = f
testutil.AssertNotNil(t, bc.GetFont())
testutil.AssertEqual(t, DefaultChartWidth, bc.GetWidth())
bc.Width = DefaultChartWidth - 1
testutil.AssertEqual(t, DefaultChartWidth-1, bc.GetWidth())
testutil.AssertEqual(t, DefaultChartHeight, bc.GetHeight())
bc.Height = DefaultChartHeight - 1
testutil.AssertEqual(t, DefaultChartHeight-1, bc.GetHeight())
testutil.AssertEqual(t, DefaultBarSpacing, bc.GetBarSpacing())
bc.BarSpacing = 150
testutil.AssertEqual(t, 150, bc.GetBarSpacing())
testutil.AssertEqual(t, DefaultBarWidth, bc.GetBarWidth())
bc.BarWidth = 75
testutil.AssertEqual(t, 75, bc.GetBarWidth())
}
func TestBarChartRenderNoBars(t *testing.T) {
// replaced new assertions helper
bc := BarChart{}
err := bc.Render(PNG, bytes.NewBuffer([]byte{}))
testutil.AssertNotNil(t, err)
}
func TestBarChartGetRanges(t *testing.T) {
// replaced new assertions helper
bc := BarChart{}
yr := bc.getRanges()
testutil.AssertNotNil(t, yr)
testutil.AssertFalse(t, yr.IsZero())
testutil.AssertEqual(t, -math.MaxFloat64, yr.GetMax())
testutil.AssertEqual(t, math.MaxFloat64, yr.GetMin())
}
func TestBarChartGetRangesBarsMinMax(t *testing.T) {
// replaced new assertions helper
bc := BarChart{
Bars: []Value{
{Value: 1.0},
{Value: 10.0},
},
}
yr := bc.getRanges()
testutil.AssertNotNil(t, yr)
testutil.AssertFalse(t, yr.IsZero())
testutil.AssertEqual(t, 10, yr.GetMax())
testutil.AssertEqual(t, 1, yr.GetMin())
}
func TestBarChartGetRangesMinMax(t *testing.T) {
// replaced new assertions helper
bc := BarChart{
YAxis: YAxis{
Range: &ContinuousRange{
Min: 5.0,
Max: 15.0,
},
Ticks: []Tick{
{Value: 7.0, Label: "Foo"},
{Value: 11.0, Label: "Foo2"},
},
},
Bars: []Value{
{Value: 1.0},
{Value: 10.0},
},
}
yr := bc.getRanges()
testutil.AssertNotNil(t, yr)
testutil.AssertFalse(t, yr.IsZero())
testutil.AssertEqual(t, 15, yr.GetMax())
testutil.AssertEqual(t, 5, yr.GetMin())
}
func TestBarChartGetRangesTicksMinMax(t *testing.T) {
// replaced new assertions helper
bc := BarChart{
YAxis: YAxis{
Ticks: []Tick{
{Value: 7.0, Label: "Foo"},
{Value: 11.0, Label: "Foo2"},
},
},
Bars: []Value{
{Value: 1.0},
{Value: 10.0},
},
}
yr := bc.getRanges()
testutil.AssertNotNil(t, yr)
testutil.AssertFalse(t, yr.IsZero())
testutil.AssertEqual(t, 11, yr.GetMax())
testutil.AssertEqual(t, 7, yr.GetMin())
}
func TestBarChartHasAxes(t *testing.T) {
// replaced new assertions helper
bc := BarChart{}
testutil.AssertTrue(t, bc.hasAxes())
bc.YAxis = YAxis{
Style: Hidden(),
}
testutil.AssertFalse(t, bc.hasAxes())
}
func TestBarChartGetDefaultCanvasBox(t *testing.T) {
// replaced new assertions helper
bc := BarChart{}
b := bc.getDefaultCanvasBox()
testutil.AssertFalse(t, b.IsZero())
}
func TestBarChartSetRangeDomains(t *testing.T) {
// replaced new assertions helper
bc := BarChart{}
cb := bc.box()
yr := bc.getRanges()
yr2 := bc.setRangeDomains(cb, yr)
testutil.AssertNotZero(t, yr2.GetDomain())
}
func TestBarChartGetValueFormatters(t *testing.T) {
// replaced new assertions helper
bc := BarChart{}
vf := bc.getValueFormatters()
testutil.AssertNotNil(t, vf)
testutil.AssertEqual(t, "1234.00", vf(1234.0))
bc.YAxis.ValueFormatter = func(_ interface{}) string { return "test" }
testutil.AssertEqual(t, "test", bc.getValueFormatters()(1234))
}
func TestBarChartGetAxesTicks(t *testing.T) {
// replaced new assertions helper
bc := BarChart{
Bars: []Value{
{Value: 1.0},
{Value: 2.0},
{Value: 3.0},
},
}
r, err := PNG(128, 128)
testutil.AssertNil(t, err)
yr := bc.getRanges()
yf := bc.getValueFormatters()
bc.YAxis.Style.Hidden = true
ticks := bc.getAxesTicks(r, yr, yf)
testutil.AssertEmpty(t, ticks)
bc.YAxis.Style.Hidden = false
ticks = bc.getAxesTicks(r, yr, yf)
testutil.AssertLen(t, ticks, 2)
}
func TestBarChartCalculateEffectiveBarSpacing(t *testing.T) {
// replaced new assertions helper
bc := BarChart{
Width: 1024,
BarWidth: 10,
Bars: []Value{
{Value: 1.0, Label: "One"},
{Value: 2.0, Label: "Two"},
{Value: 3.0, Label: "Three"},
{Value: 4.0, Label: "Four"},
{Value: 5.0, Label: "Five"},
},
}
spacing := bc.calculateEffectiveBarSpacing(bc.box())
testutil.AssertNotZero(t, spacing)
bc.BarWidth = 250
spacing = bc.calculateEffectiveBarSpacing(bc.box())
testutil.AssertZero(t, spacing)
}
func TestBarChartCalculateEffectiveBarWidth(t *testing.T) {
// replaced new assertions helper
bc := BarChart{
Width: 1024,
BarWidth: 10,
Bars: []Value{
{Value: 1.0, Label: "One"},
{Value: 2.0, Label: "Two"},
{Value: 3.0, Label: "Three"},
{Value: 4.0, Label: "Four"},
{Value: 5.0, Label: "Five"},
},
}
cb := bc.box()
spacing := bc.calculateEffectiveBarSpacing(bc.box())
testutil.AssertNotZero(t, spacing)
barWidth := bc.calculateEffectiveBarWidth(bc.box(), spacing)
testutil.AssertEqual(t, 10, barWidth)
bc.BarWidth = 250
spacing = bc.calculateEffectiveBarSpacing(bc.box())
testutil.AssertZero(t, spacing)
barWidth = bc.calculateEffectiveBarWidth(bc.box(), spacing)
testutil.AssertEqual(t, 199, barWidth)
testutil.AssertEqual(t, cb.Width()+1, bc.calculateTotalBarWidth(barWidth, spacing))
bw, bs, total := bc.calculateScaledTotalWidth(cb)
testutil.AssertEqual(t, spacing, bs)
testutil.AssertEqual(t, barWidth, bw)
testutil.AssertEqual(t, cb.Width()+1, total)
}
func TestBarChatGetTitleFontSize(t *testing.T) {
// replaced new assertions helper
size := BarChart{Width: 2049, Height: 2049}.getTitleFontSize()
testutil.AssertEqual(t, 48, size)
size = BarChart{Width: 1025, Height: 1025}.getTitleFontSize()
testutil.AssertEqual(t, 24, size)
size = BarChart{Width: 513, Height: 513}.getTitleFontSize()
testutil.AssertEqual(t, 18, size)
size = BarChart{Width: 257, Height: 257}.getTitleFontSize()
testutil.AssertEqual(t, 12, size)
size = BarChart{Width: 128, Height: 128}.getTitleFontSize()
testutil.AssertEqual(t, 10, size)
}

View file

@ -0,0 +1,135 @@
package chart
import (
"fmt"
)
// Interface Assertions.
var (
_ Series = (*BollingerBandsSeries)(nil)
)
// BollingerBandsSeries draws bollinger bands for an inner series.
// Bollinger bands are defined by two lines, one at SMA+k*stddev, one at SMA-k*stdev.
type BollingerBandsSeries struct {
Name string
Style Style
YAxis YAxisType
Period int
K float64
InnerSeries ValuesProvider
valueBuffer *ValueBuffer
}
// GetName returns the name of the time series.
func (bbs BollingerBandsSeries) GetName() string {
return bbs.Name
}
// GetStyle returns the line style.
func (bbs BollingerBandsSeries) GetStyle() Style {
return bbs.Style
}
// GetYAxis returns which YAxis the series draws on.
func (bbs BollingerBandsSeries) GetYAxis() YAxisType {
return bbs.YAxis
}
// GetPeriod returns the window size.
func (bbs BollingerBandsSeries) GetPeriod() int {
if bbs.Period == 0 {
return DefaultSimpleMovingAveragePeriod
}
return bbs.Period
}
// GetK returns the K value, or the number of standard deviations above and below
// to band the simple moving average with.
// Typical K value is 2.0.
func (bbs BollingerBandsSeries) GetK(defaults ...float64) float64 {
if bbs.K == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return 2.0
}
return bbs.K
}
// Len returns the number of elements in the series.
func (bbs BollingerBandsSeries) Len() int {
return bbs.InnerSeries.Len()
}
// GetBoundedValues gets the bounded value for the series.
func (bbs *BollingerBandsSeries) GetBoundedValues(index int) (x, y1, y2 float64) {
if bbs.InnerSeries == nil {
return
}
if bbs.valueBuffer == nil || index == 0 {
bbs.valueBuffer = NewValueBufferWithCapacity(bbs.GetPeriod())
}
if bbs.valueBuffer.Len() >= bbs.GetPeriod() {
bbs.valueBuffer.Dequeue()
}
px, py := bbs.InnerSeries.GetValues(index)
bbs.valueBuffer.Enqueue(py)
x = px
ay := Seq{bbs.valueBuffer}.Average()
std := Seq{bbs.valueBuffer}.StdDev()
y1 = ay + (bbs.GetK() * std)
y2 = ay - (bbs.GetK() * std)
return
}
// GetBoundedLastValues returns the last bounded value for the series.
func (bbs *BollingerBandsSeries) GetBoundedLastValues() (x, y1, y2 float64) {
if bbs.InnerSeries == nil {
return
}
period := bbs.GetPeriod()
seriesLength := bbs.InnerSeries.Len()
startAt := seriesLength - period
if startAt < 0 {
startAt = 0
}
vb := NewValueBufferWithCapacity(period)
for index := startAt; index < seriesLength; index++ {
xn, yn := bbs.InnerSeries.GetValues(index)
vb.Enqueue(yn)
x = xn
}
ay := Seq{vb}.Average()
std := Seq{vb}.StdDev()
y1 = ay + (bbs.GetK() * std)
y2 = ay - (bbs.GetK() * std)
return
}
// Render renders the series.
func (bbs *BollingerBandsSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
s := bbs.Style.InheritFrom(defaults.InheritFrom(Style{
StrokeWidth: 1.0,
StrokeColor: DefaultAxisColor.WithAlpha(64),
FillColor: DefaultAxisColor.WithAlpha(32),
}))
Draw.BoundedSeries(r, canvasBox, xrange, yrange, s, bbs, bbs.GetPeriod())
}
// Validate validates the series.
func (bbs BollingerBandsSeries) Validate() error {
if bbs.InnerSeries == nil {
return fmt.Errorf("bollinger bands series requires InnerSeries to be set")
}
return nil
}

View file

@ -0,0 +1,52 @@
package chart
import (
"fmt"
"math"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestBollingerBandSeries(t *testing.T) {
// replaced new assertions helper
s1 := mockValuesProvider{
X: LinearRange(1.0, 100.0),
Y: RandomValuesWithMax(100, 1024),
}
bbs := &BollingerBandsSeries{
InnerSeries: s1,
}
xvalues := make([]float64, 100)
y1values := make([]float64, 100)
y2values := make([]float64, 100)
for x := 0; x < 100; x++ {
xvalues[x], y1values[x], y2values[x] = bbs.GetBoundedValues(x)
}
for x := bbs.GetPeriod(); x < 100; x++ {
testutil.AssertTrue(t, y1values[x] > y2values[x], fmt.Sprintf("%v vs. %v", y1values[x], y2values[x]))
}
}
func TestBollingerBandLastValue(t *testing.T) {
// replaced new assertions helper
s1 := mockValuesProvider{
X: LinearRange(1.0, 100.0),
Y: LinearRange(1.0, 100.0),
}
bbs := &BollingerBandsSeries{
InnerSeries: s1,
}
x, y1, y2 := bbs.GetBoundedLastValues()
testutil.AssertEqual(t, 100.0, x)
testutil.AssertEqual(t, 101, math.Floor(y1))
testutil.AssertEqual(t, 83, math.Floor(y2))
}

View file

@ -0,0 +1,36 @@
package chart
import "fmt"
// BoundedLastValuesAnnotationSeries returns a last value annotation series for a bounded values provider.
func BoundedLastValuesAnnotationSeries(innerSeries FullBoundedValuesProvider, vfs ...ValueFormatter) AnnotationSeries {
lvx, lvy1, lvy2 := innerSeries.GetBoundedLastValues()
var vf ValueFormatter
if len(vfs) > 0 {
vf = vfs[0]
} else if typed, isTyped := innerSeries.(ValueFormatterProvider); isTyped {
_, vf = typed.GetValueFormatters()
} else {
vf = FloatValueFormatter
}
label1 := vf(lvy1)
label2 := vf(lvy2)
var seriesName string
var seriesStyle Style
if typed, isTyped := innerSeries.(Series); isTyped {
seriesName = fmt.Sprintf("%s - Last Values", typed.GetName())
seriesStyle = typed.GetStyle()
}
return AnnotationSeries{
Name: seriesName,
Style: seriesStyle,
Annotations: []Value2{
{XValue: lvx, YValue: lvy1, Label: label1},
{XValue: lvx, YValue: lvy2, Label: label2},
},
}
}

351
pkg/chart/box.go Normal file
View file

@ -0,0 +1,351 @@
package chart
import (
"fmt"
"math"
)
var (
// BoxZero is a preset box that represents an intentional zero value.
BoxZero = Box{IsSet: true}
)
// NewBox returns a new (set) box.
func NewBox(top, left, right, bottom int) Box {
return Box{
IsSet: true,
Top: top,
Left: left,
Right: right,
Bottom: bottom,
}
}
// Box represents the main 4 dimensions of a box.
type Box struct {
Top int
Left int
Right int
Bottom int
IsSet bool
}
// IsZero returns if the box is set or not.
func (b Box) IsZero() bool {
if b.IsSet {
return false
}
return b.Top == 0 && b.Left == 0 && b.Right == 0 && b.Bottom == 0
}
// String returns a string representation of the box.
func (b Box) String() string {
return fmt.Sprintf("box(%d,%d,%d,%d)", b.Top, b.Left, b.Right, b.Bottom)
}
// GetTop returns a coalesced value with a default.
func (b Box) GetTop(defaults ...int) int {
if !b.IsSet && b.Top == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return 0
}
return b.Top
}
// GetLeft returns a coalesced value with a default.
func (b Box) GetLeft(defaults ...int) int {
if !b.IsSet && b.Left == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return 0
}
return b.Left
}
// GetRight returns a coalesced value with a default.
func (b Box) GetRight(defaults ...int) int {
if !b.IsSet && b.Right == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return 0
}
return b.Right
}
// GetBottom returns a coalesced value with a default.
func (b Box) GetBottom(defaults ...int) int {
if !b.IsSet && b.Bottom == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return 0
}
return b.Bottom
}
// Width returns the width
func (b Box) Width() int {
return AbsInt(b.Right - b.Left)
}
// Height returns the height
func (b Box) Height() int {
return AbsInt(b.Bottom - b.Top)
}
// Center returns the center of the box
func (b Box) Center() (x, y int) {
w2, h2 := b.Width()>>1, b.Height()>>1
return b.Left + w2, b.Top + h2
}
// Aspect returns the aspect ratio of the box.
func (b Box) Aspect() float64 {
return float64(b.Width()) / float64(b.Height())
}
// Clone returns a new copy of the box.
func (b Box) Clone() Box {
return Box{
IsSet: b.IsSet,
Top: b.Top,
Left: b.Left,
Right: b.Right,
Bottom: b.Bottom,
}
}
// IsBiggerThan returns if a box is bigger than another box.
func (b Box) IsBiggerThan(other Box) bool {
return b.Top < other.Top ||
b.Bottom > other.Bottom ||
b.Left < other.Left ||
b.Right > other.Right
}
// IsSmallerThan returns if a box is smaller than another box.
func (b Box) IsSmallerThan(other Box) bool {
return b.Top > other.Top &&
b.Bottom < other.Bottom &&
b.Left > other.Left &&
b.Right < other.Right
}
// Equals returns if the box equals another box.
func (b Box) Equals(other Box) bool {
return b.Top == other.Top &&
b.Left == other.Left &&
b.Right == other.Right &&
b.Bottom == other.Bottom
}
// Grow grows a box based on another box.
func (b Box) Grow(other Box) Box {
return Box{
Top: MinInt(b.Top, other.Top),
Left: MinInt(b.Left, other.Left),
Right: MaxInt(b.Right, other.Right),
Bottom: MaxInt(b.Bottom, other.Bottom),
}
}
// Shift pushes a box by x,y.
func (b Box) Shift(x, y int) Box {
return Box{
Top: b.Top + y,
Left: b.Left + x,
Right: b.Right + x,
Bottom: b.Bottom + y,
}
}
// Corners returns the box as a set of corners.
func (b Box) Corners() BoxCorners {
return BoxCorners{
TopLeft: Point{b.Left, b.Top},
TopRight: Point{b.Right, b.Top},
BottomRight: Point{b.Right, b.Bottom},
BottomLeft: Point{b.Left, b.Bottom},
}
}
// Fit is functionally the inverse of grow.
// Fit maintains the original aspect ratio of the `other` box,
// but constrains it to the bounds of the target box.
func (b Box) Fit(other Box) Box {
ba := b.Aspect()
oa := other.Aspect()
if oa == ba {
return b.Clone()
}
bw, bh := float64(b.Width()), float64(b.Height())
bw2 := int(bw) >> 1
bh2 := int(bh) >> 1
if oa > ba { // ex. 16:9 vs. 4:3
var noh2 int
if oa > 1.0 {
noh2 = int(bw/oa) >> 1
} else {
noh2 = int(bh*oa) >> 1
}
return Box{
Top: (b.Top + bh2) - noh2,
Left: b.Left,
Right: b.Right,
Bottom: (b.Top + bh2) + noh2,
}
}
var now2 int
if oa > 1.0 {
now2 = int(bh/oa) >> 1
} else {
now2 = int(bw*oa) >> 1
}
return Box{
Top: b.Top,
Left: (b.Left + bw2) - now2,
Right: (b.Left + bw2) + now2,
Bottom: b.Bottom,
}
}
// Constrain is similar to `Fit` except that it will work
// more literally like the opposite of grow.
func (b Box) Constrain(other Box) Box {
newBox := b.Clone()
newBox.Top = MaxInt(newBox.Top, other.Top)
newBox.Left = MaxInt(newBox.Left, other.Left)
newBox.Right = MinInt(newBox.Right, other.Right)
newBox.Bottom = MinInt(newBox.Bottom, other.Bottom)
return newBox
}
// OuterConstrain is similar to `Constraint` with the difference
// that it applies corrections
func (b Box) OuterConstrain(bounds, other Box) Box {
newBox := b.Clone()
if other.Top < bounds.Top {
delta := bounds.Top - other.Top
newBox.Top = b.Top + delta
}
if other.Left < bounds.Left {
delta := bounds.Left - other.Left
newBox.Left = b.Left + delta
}
if other.Right > bounds.Right {
delta := other.Right - bounds.Right
newBox.Right = b.Right - delta
}
if other.Bottom > bounds.Bottom {
delta := other.Bottom - bounds.Bottom
newBox.Bottom = b.Bottom - delta
}
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: MinInt(bc.TopLeft.Y, bc.TopRight.Y),
Left: MinInt(bc.TopLeft.X, bc.BottomLeft.X),
Right: MaxInt(bc.TopRight.X, bc.BottomRight.X),
Bottom: MaxInt(bc.BottomLeft.Y, bc.BottomRight.Y),
}
}
// Width returns the width
func (bc BoxCorners) Width() int {
minLeft := MinInt(bc.TopLeft.X, bc.BottomLeft.X)
maxRight := MaxInt(bc.TopRight.X, bc.BottomRight.X)
return maxRight - minLeft
}
// Height returns the height
func (bc BoxCorners) Height() int {
minTop := MinInt(bc.TopLeft.Y, bc.TopRight.Y)
maxBottom := 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 := MeanInt(bc.TopLeft.X, bc.BottomLeft.X)
right := MeanInt(bc.TopRight.X, bc.BottomRight.X)
x = ((right - left) >> 1) + left
top := MeanInt(bc.TopLeft.Y, bc.TopRight.Y)
bottom := 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 := DegreesToRadians(thetaDegrees)
tlx, tly := RotateCoordinate(cx, cy, bc.TopLeft.X, bc.TopLeft.Y, thetaRadians)
trx, try := RotateCoordinate(cx, cy, bc.TopRight.X, bc.TopRight.Y, thetaRadians)
brx, bry := RotateCoordinate(cx, cy, bc.BottomRight.X, bc.BottomRight.Y, thetaRadians)
blx, bly := 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)
}

188
pkg/chart/box_test.go Normal file
View file

@ -0,0 +1,188 @@
package chart
import (
"math"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestBoxClone(t *testing.T) {
// replaced new assertions helper
a := Box{Top: 5, Left: 5, Right: 15, Bottom: 15}
b := a.Clone()
testutil.AssertTrue(t, a.Equals(b))
testutil.AssertTrue(t, b.Equals(a))
}
func TestBoxEquals(t *testing.T) {
// replaced new assertions helper
a := Box{Top: 5, Left: 5, Right: 15, Bottom: 15}
b := Box{Top: 10, Left: 10, Right: 30, Bottom: 30}
c := Box{Top: 5, Left: 5, Right: 15, Bottom: 15}
testutil.AssertTrue(t, a.Equals(a))
testutil.AssertTrue(t, a.Equals(c))
testutil.AssertTrue(t, c.Equals(a))
testutil.AssertFalse(t, a.Equals(b))
testutil.AssertFalse(t, c.Equals(b))
testutil.AssertFalse(t, b.Equals(a))
testutil.AssertFalse(t, b.Equals(c))
}
func TestBoxIsBiggerThan(t *testing.T) {
// replaced new assertions helper
a := Box{Top: 5, Left: 5, Right: 25, Bottom: 25}
b := Box{Top: 10, Left: 10, Right: 20, Bottom: 20} // only half bigger
c := Box{Top: 1, Left: 1, Right: 30, Bottom: 30} //bigger
testutil.AssertTrue(t, a.IsBiggerThan(b))
testutil.AssertFalse(t, a.IsBiggerThan(c))
testutil.AssertTrue(t, c.IsBiggerThan(a))
}
func TestBoxIsSmallerThan(t *testing.T) {
// replaced new assertions helper
a := Box{Top: 5, Left: 5, Right: 25, Bottom: 25}
b := Box{Top: 10, Left: 10, Right: 20, Bottom: 20} // only half bigger
c := Box{Top: 1, Left: 1, Right: 30, Bottom: 30} //bigger
testutil.AssertFalse(t, a.IsSmallerThan(b))
testutil.AssertTrue(t, a.IsSmallerThan(c))
testutil.AssertFalse(t, c.IsSmallerThan(a))
}
func TestBoxGrow(t *testing.T) {
// replaced new assertions helper
a := Box{Top: 1, Left: 2, Right: 15, Bottom: 15}
b := Box{Top: 4, Left: 5, Right: 30, Bottom: 35}
c := a.Grow(b)
testutil.AssertFalse(t, c.Equals(b))
testutil.AssertFalse(t, c.Equals(a))
testutil.AssertEqual(t, 1, c.Top)
testutil.AssertEqual(t, 2, c.Left)
testutil.AssertEqual(t, 30, c.Right)
testutil.AssertEqual(t, 35, c.Bottom)
}
func TestBoxFit(t *testing.T) {
// replaced new assertions helper
a := Box{Top: 64, Left: 64, Right: 192, Bottom: 192}
b := Box{Top: 16, Left: 16, Right: 256, Bottom: 170}
c := Box{Top: 16, Left: 16, Right: 170, Bottom: 256}
fab := a.Fit(b)
testutil.AssertEqual(t, a.Left, fab.Left)
testutil.AssertEqual(t, a.Right, fab.Right)
testutil.AssertTrue(t, fab.Top < fab.Bottom)
testutil.AssertTrue(t, fab.Left < fab.Right)
testutil.AssertTrue(t, math.Abs(b.Aspect()-fab.Aspect()) < 0.02)
fac := a.Fit(c)
testutil.AssertEqual(t, a.Top, fac.Top)
testutil.AssertEqual(t, a.Bottom, fac.Bottom)
testutil.AssertTrue(t, math.Abs(c.Aspect()-fac.Aspect()) < 0.02)
}
func TestBoxConstrain(t *testing.T) {
// replaced new assertions helper
a := Box{Top: 64, Left: 64, Right: 192, Bottom: 192}
b := Box{Top: 16, Left: 16, Right: 256, Bottom: 170}
c := Box{Top: 16, Left: 16, Right: 170, Bottom: 256}
cab := a.Constrain(b)
testutil.AssertEqual(t, 64, cab.Top)
testutil.AssertEqual(t, 64, cab.Left)
testutil.AssertEqual(t, 192, cab.Right)
testutil.AssertEqual(t, 170, cab.Bottom)
cac := a.Constrain(c)
testutil.AssertEqual(t, 64, cac.Top)
testutil.AssertEqual(t, 64, cac.Left)
testutil.AssertEqual(t, 170, cac.Right)
testutil.AssertEqual(t, 192, cac.Bottom)
}
func TestBoxOuterConstrain(t *testing.T) {
// replaced new assertions helper
box := NewBox(0, 0, 100, 100)
canvas := NewBox(5, 5, 95, 95)
taller := NewBox(-10, 5, 50, 50)
c := canvas.OuterConstrain(box, taller)
testutil.AssertEqual(t, 15, c.Top, c.String())
testutil.AssertEqual(t, 5, c.Left, c.String())
testutil.AssertEqual(t, 95, c.Right, c.String())
testutil.AssertEqual(t, 95, c.Bottom, c.String())
wider := NewBox(5, 5, 110, 50)
d := canvas.OuterConstrain(box, wider)
testutil.AssertEqual(t, 5, d.Top, d.String())
testutil.AssertEqual(t, 5, d.Left, d.String())
testutil.AssertEqual(t, 85, d.Right, d.String())
testutil.AssertEqual(t, 95, d.Bottom, d.String())
}
func TestBoxShift(t *testing.T) {
// replaced new assertions helper
b := Box{
Top: 5,
Left: 5,
Right: 10,
Bottom: 10,
}
shifted := b.Shift(1, 2)
testutil.AssertEqual(t, 7, shifted.Top)
testutil.AssertEqual(t, 6, shifted.Left)
testutil.AssertEqual(t, 11, shifted.Right)
testutil.AssertEqual(t, 12, shifted.Bottom)
}
func TestBoxCenter(t *testing.T) {
// replaced new assertions helper
b := Box{
Top: 10,
Left: 10,
Right: 20,
Bottom: 30,
}
cx, cy := b.Center()
testutil.AssertEqual(t, 15, cx)
testutil.AssertEqual(t, 20, cy)
}
func TestBoxCornersCenter(t *testing.T) {
// replaced new assertions helper
bc := BoxCorners{
TopLeft: Point{5, 5},
TopRight: Point{15, 5},
BottomRight: Point{15, 15},
BottomLeft: Point{5, 15},
}
cx, cy := bc.Center()
testutil.AssertEqual(t, 10, cx)
testutil.AssertEqual(t, 10, cy)
}
func TestBoxCornersRotate(t *testing.T) {
// replaced new assertions helper
bc := BoxCorners{
TopLeft: Point{5, 5},
TopRight: Point{15, 5},
BottomRight: Point{15, 15},
BottomLeft: Point{5, 15},
}
rotated := bc.Rotate(45)
testutil.AssertTrue(t, rotated.TopLeft.Equals(Point{10, 3}), rotated.String())
}

577
pkg/chart/chart.go Normal file
View file

@ -0,0 +1,577 @@
package chart
import (
"errors"
"fmt"
"io"
"math"
"github.com/golang/freetype/truetype"
)
// Chart is what we're drawing.
type Chart struct {
Title string
TitleStyle Style
ColorPalette ColorPalette
Width int
Height int
DPI float64
Background Style
Canvas Style
XAxis XAxis
YAxis YAxis
YAxisSecondary YAxis
Font *truetype.Font
defaultFont *truetype.Font
Series []Series
Elements []Renderable
Log Logger
}
// GetDPI returns the dpi for the chart.
func (c Chart) GetDPI(defaults ...float64) float64 {
if c.DPI == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return DefaultDPI
}
return c.DPI
}
// GetFont returns the text font.
func (c Chart) GetFont() *truetype.Font {
if c.Font == nil {
return c.defaultFont
}
return c.Font
}
// GetWidth returns the chart width or the default value.
func (c Chart) GetWidth() int {
if c.Width == 0 {
return DefaultChartWidth
}
return c.Width
}
// GetHeight returns the chart height or the default value.
func (c Chart) GetHeight() int {
if c.Height == 0 {
return DefaultChartHeight
}
return c.Height
}
// Render renders the chart with the given renderer to the given io.Writer.
func (c Chart) Render(rp RendererProvider, w io.Writer) error {
if len(c.Series) == 0 {
return errors.New("please provide at least one series")
}
if err := c.checkHasVisibleSeries(); err != nil {
return err
}
c.YAxisSecondary.AxisType = YAxisSecondary
r, err := rp(c.GetWidth(), c.GetHeight())
if err != nil {
return err
}
if c.Font == nil {
defaultFont, err := GetDefaultFont()
if err != nil {
return err
}
c.defaultFont = defaultFont
}
r.SetDPI(c.GetDPI(DefaultDPI))
c.drawBackground(r)
var xt, yt, yta []Tick
xr, yr, yra := c.getRanges()
canvasBox := c.getDefaultCanvasBox()
xf, yf, yfa := c.getValueFormatters()
Debugf(c.Log, "chart; canvas box: %v", canvasBox)
xr, yr, yra = c.setRangeDomains(canvasBox, xr, yr, yra)
err = c.checkRanges(xr, yr, yra)
if err != nil {
r.Save(w)
return err
}
if c.hasAxes() {
xt, yt, yta = c.getAxesTicks(r, xr, yr, yra, xf, yf, yfa)
canvasBox = c.getAxesAdjustedCanvasBox(r, canvasBox, xr, yr, yra, xt, yt, yta)
xr, yr, yra = c.setRangeDomains(canvasBox, xr, yr, yra)
Debugf(c.Log, "chart; axes adjusted canvas box: %v", canvasBox)
// do a second pass in case things haven't settled yet.
xt, yt, yta = c.getAxesTicks(r, xr, yr, yra, xf, yf, yfa)
canvasBox = c.getAxesAdjustedCanvasBox(r, canvasBox, xr, yr, yra, xt, yt, yta)
xr, yr, yra = c.setRangeDomains(canvasBox, xr, yr, yra)
}
if c.hasAnnotationSeries() {
canvasBox = c.getAnnotationAdjustedCanvasBox(r, canvasBox, xr, yr, yra, xf, yf, yfa)
xr, yr, yra = c.setRangeDomains(canvasBox, xr, yr, yra)
xt, yt, yta = c.getAxesTicks(r, xr, yr, yra, xf, yf, yfa)
Debugf(c.Log, "chart; annotation adjusted canvas box: %v", canvasBox)
}
c.drawCanvas(r, canvasBox)
c.drawAxes(r, canvasBox, xr, yr, yra, xt, yt, yta)
for index, series := range c.Series {
c.drawSeries(r, canvasBox, xr, yr, yra, series, index)
}
c.drawTitle(r)
for _, a := range c.Elements {
a(r, canvasBox, c.styleDefaultsElements())
}
return r.Save(w)
}
func (c Chart) checkHasVisibleSeries() error {
var style Style
for _, s := range c.Series {
style = s.GetStyle()
if !style.Hidden {
return nil
}
}
return fmt.Errorf("chart render; must have (1) visible series")
}
func (c Chart) validateSeries() error {
var err error
for _, s := range c.Series {
err = s.Validate()
if err != nil {
return err
}
}
return nil
}
func (c Chart) getRanges() (xrange, yrange, yrangeAlt Range) {
var minx, maxx float64 = math.MaxFloat64, -math.MaxFloat64
var miny, maxy float64 = math.MaxFloat64, -math.MaxFloat64
var minya, maxya float64 = math.MaxFloat64, -math.MaxFloat64
seriesMappedToSecondaryAxis := false
// note: a possible future optimization is to not scan the series values if
// all axis are represented by either custom ticks or custom ranges.
for _, s := range c.Series {
if !s.GetStyle().Hidden {
seriesAxis := s.GetYAxis()
if bvp, isBoundedValuesProvider := s.(BoundedValuesProvider); isBoundedValuesProvider {
seriesLength := bvp.Len()
for index := 0; index < seriesLength; index++ {
vx, vy1, vy2 := bvp.GetBoundedValues(index)
minx = math.Min(minx, vx)
maxx = math.Max(maxx, vx)
if seriesAxis == YAxisPrimary {
miny = math.Min(miny, vy1)
miny = math.Min(miny, vy2)
maxy = math.Max(maxy, vy1)
maxy = math.Max(maxy, vy2)
} else if seriesAxis == YAxisSecondary {
minya = math.Min(minya, vy1)
minya = math.Min(minya, vy2)
maxya = math.Max(maxya, vy1)
maxya = math.Max(maxya, vy2)
seriesMappedToSecondaryAxis = true
}
}
} else if vp, isValuesProvider := s.(ValuesProvider); isValuesProvider {
seriesLength := vp.Len()
for index := 0; index < seriesLength; index++ {
vx, vy := vp.GetValues(index)
minx = math.Min(minx, vx)
maxx = math.Max(maxx, vx)
if seriesAxis == YAxisPrimary {
miny = math.Min(miny, vy)
maxy = math.Max(maxy, vy)
} else if seriesAxis == YAxisSecondary {
minya = math.Min(minya, vy)
maxya = math.Max(maxya, vy)
seriesMappedToSecondaryAxis = true
}
}
}
}
}
if c.XAxis.Range == nil {
xrange = &ContinuousRange{}
} else {
xrange = c.XAxis.Range
}
if c.YAxis.Range == nil {
yrange = &ContinuousRange{}
} else {
yrange = c.YAxis.Range
}
if c.YAxisSecondary.Range == nil {
yrangeAlt = &ContinuousRange{}
} else {
yrangeAlt = c.YAxisSecondary.Range
}
if len(c.XAxis.Ticks) > 0 {
tickMin, tickMax := math.MaxFloat64, -math.MaxFloat64
for _, t := range c.XAxis.Ticks {
tickMin = math.Min(tickMin, t.Value)
tickMax = math.Max(tickMax, t.Value)
}
xrange.SetMin(tickMin)
xrange.SetMax(tickMax)
} else if xrange.IsZero() {
xrange.SetMin(minx)
xrange.SetMax(maxx)
}
if len(c.YAxis.Ticks) > 0 {
tickMin, tickMax := math.MaxFloat64, -math.MaxFloat64
for _, t := range c.YAxis.Ticks {
tickMin = math.Min(tickMin, t.Value)
tickMax = math.Max(tickMax, t.Value)
}
yrange.SetMin(tickMin)
yrange.SetMax(tickMax)
} else if yrange.IsZero() {
yrange.SetMin(miny)
yrange.SetMax(maxy)
if !c.YAxis.Style.Hidden {
delta := yrange.GetDelta()
roundTo := GetRoundToForDelta(delta)
rmin, rmax := RoundDown(yrange.GetMin(), roundTo), RoundUp(yrange.GetMax(), roundTo)
yrange.SetMin(rmin)
yrange.SetMax(rmax)
}
}
if len(c.YAxisSecondary.Ticks) > 0 {
tickMin, tickMax := math.MaxFloat64, -math.MaxFloat64
for _, t := range c.YAxis.Ticks {
tickMin = math.Min(tickMin, t.Value)
tickMax = math.Max(tickMax, t.Value)
}
yrangeAlt.SetMin(tickMin)
yrangeAlt.SetMax(tickMax)
} else if seriesMappedToSecondaryAxis && yrangeAlt.IsZero() {
yrangeAlt.SetMin(minya)
yrangeAlt.SetMax(maxya)
if !c.YAxisSecondary.Style.Hidden {
delta := yrangeAlt.GetDelta()
roundTo := GetRoundToForDelta(delta)
rmin, rmax := RoundDown(yrangeAlt.GetMin(), roundTo), RoundUp(yrangeAlt.GetMax(), roundTo)
yrangeAlt.SetMin(rmin)
yrangeAlt.SetMax(rmax)
}
}
return
}
func (c Chart) checkRanges(xr, yr, yra Range) error {
Debugf(c.Log, "checking xrange: %v", xr)
xDelta := xr.GetDelta()
if math.IsInf(xDelta, 0) {
return errors.New("infinite x-range delta")
}
if math.IsNaN(xDelta) {
return errors.New("nan x-range delta")
}
if xDelta == 0 {
return errors.New("zero x-range delta; there needs to be at least (2) values")
}
Debugf(c.Log, "checking yrange: %v", yr)
yDelta := yr.GetDelta()
if math.IsInf(yDelta, 0) {
return errors.New("infinite y-range delta")
}
if math.IsNaN(yDelta) {
return errors.New("nan y-range delta")
}
if c.hasSecondarySeries() {
Debugf(c.Log, "checking secondary yrange: %v", yra)
yraDelta := yra.GetDelta()
if math.IsInf(yraDelta, 0) {
return errors.New("infinite secondary y-range delta")
}
if math.IsNaN(yraDelta) {
return errors.New("nan secondary y-range delta")
}
}
return nil
}
func (c Chart) getDefaultCanvasBox() Box {
return c.Box()
}
func (c Chart) getValueFormatters() (x, y, ya ValueFormatter) {
for _, s := range c.Series {
if vfp, isVfp := s.(ValueFormatterProvider); isVfp {
sx, sy := vfp.GetValueFormatters()
if s.GetYAxis() == YAxisPrimary {
x = sx
y = sy
} else if s.GetYAxis() == YAxisSecondary {
x = sx
ya = sy
}
}
}
if c.XAxis.ValueFormatter != nil {
x = c.XAxis.GetValueFormatter()
}
if c.YAxis.ValueFormatter != nil {
y = c.YAxis.GetValueFormatter()
}
if c.YAxisSecondary.ValueFormatter != nil {
ya = c.YAxisSecondary.GetValueFormatter()
}
return
}
func (c Chart) hasAxes() bool {
return !c.XAxis.Style.Hidden || !c.YAxis.Style.Hidden || !c.YAxisSecondary.Style.Hidden
}
func (c Chart) getAxesTicks(r Renderer, xr, yr, yar Range, xf, yf, yfa ValueFormatter) (xticks, yticks, yticksAlt []Tick) {
if !c.XAxis.Style.Hidden {
xticks = c.XAxis.GetTicks(r, xr, c.styleDefaultsAxes(), xf)
}
if !c.YAxis.Style.Hidden {
yticks = c.YAxis.GetTicks(r, yr, c.styleDefaultsAxes(), yf)
}
if !c.YAxisSecondary.Style.Hidden {
yticksAlt = c.YAxisSecondary.GetTicks(r, yar, c.styleDefaultsAxes(), yfa)
}
return
}
func (c Chart) getAxesAdjustedCanvasBox(r Renderer, canvasBox Box, xr, yr, yra Range, xticks, yticks, yticksAlt []Tick) Box {
axesOuterBox := canvasBox.Clone()
if !c.XAxis.Style.Hidden {
axesBounds := c.XAxis.Measure(r, canvasBox, xr, c.styleDefaultsAxes(), xticks)
Debugf(c.Log, "chart; x-axis measured %v", axesBounds)
axesOuterBox = axesOuterBox.Grow(axesBounds)
}
if !c.YAxis.Style.Hidden {
axesBounds := c.YAxis.Measure(r, canvasBox, yr, c.styleDefaultsAxes(), yticks)
Debugf(c.Log, "chart; y-axis measured %v", axesBounds)
axesOuterBox = axesOuterBox.Grow(axesBounds)
}
if !c.YAxisSecondary.Style.Hidden && c.hasSecondarySeries() {
axesBounds := c.YAxisSecondary.Measure(r, canvasBox, yra, c.styleDefaultsAxes(), yticksAlt)
Debugf(c.Log, "chart; y-axis secondary measured %v", axesBounds)
axesOuterBox = axesOuterBox.Grow(axesBounds)
}
return canvasBox.OuterConstrain(c.Box(), axesOuterBox)
}
func (c Chart) setRangeDomains(canvasBox Box, xr, yr, yra Range) (Range, Range, Range) {
xr.SetDomain(canvasBox.Width())
yr.SetDomain(canvasBox.Height())
yra.SetDomain(canvasBox.Height())
return xr, yr, yra
}
func (c Chart) hasAnnotationSeries() bool {
for _, s := range c.Series {
if as, isAnnotationSeries := s.(AnnotationSeries); isAnnotationSeries {
if !as.GetStyle().Hidden {
return true
}
}
}
return false
}
func (c Chart) hasSecondarySeries() bool {
for _, s := range c.Series {
if s.GetYAxis() == YAxisSecondary {
return true
}
}
return false
}
func (c Chart) getAnnotationAdjustedCanvasBox(r Renderer, canvasBox Box, xr, yr, yra Range, xf, yf, yfa ValueFormatter) Box {
annotationSeriesBox := canvasBox.Clone()
for seriesIndex, s := range c.Series {
if as, isAnnotationSeries := s.(AnnotationSeries); isAnnotationSeries {
if !as.GetStyle().Hidden {
style := c.styleDefaultsSeries(seriesIndex)
var annotationBounds Box
if as.YAxis == YAxisPrimary {
annotationBounds = as.Measure(r, canvasBox, xr, yr, style)
} else if as.YAxis == YAxisSecondary {
annotationBounds = as.Measure(r, canvasBox, xr, yra, style)
}
annotationSeriesBox = annotationSeriesBox.Grow(annotationBounds)
}
}
}
return canvasBox.OuterConstrain(c.Box(), annotationSeriesBox)
}
func (c Chart) getBackgroundStyle() Style {
return c.Background.InheritFrom(c.styleDefaultsBackground())
}
func (c Chart) drawBackground(r Renderer) {
Draw.Box(r, Box{
Right: c.GetWidth(),
Bottom: c.GetHeight(),
}, c.getBackgroundStyle())
}
func (c Chart) getCanvasStyle() Style {
return c.Canvas.InheritFrom(c.styleDefaultsCanvas())
}
func (c Chart) drawCanvas(r Renderer, canvasBox Box) {
Draw.Box(r, canvasBox, c.getCanvasStyle())
}
func (c Chart) drawAxes(r Renderer, canvasBox Box, xrange, yrange, yrangeAlt Range, xticks, yticks, yticksAlt []Tick) {
if !c.XAxis.Style.Hidden {
c.XAxis.Render(r, canvasBox, xrange, c.styleDefaultsAxes(), xticks)
}
if !c.YAxis.Style.Hidden {
c.YAxis.Render(r, canvasBox, yrange, c.styleDefaultsAxes(), yticks)
}
if !c.YAxisSecondary.Style.Hidden {
c.YAxisSecondary.Render(r, canvasBox, yrangeAlt, c.styleDefaultsAxes(), yticksAlt)
}
}
func (c Chart) drawSeries(r Renderer, canvasBox Box, xrange, yrange, yrangeAlt Range, s Series, seriesIndex int) {
if !s.GetStyle().Hidden {
if s.GetYAxis() == YAxisPrimary {
s.Render(r, canvasBox, xrange, yrange, c.styleDefaultsSeries(seriesIndex))
} else if s.GetYAxis() == YAxisSecondary {
s.Render(r, canvasBox, xrange, yrangeAlt, c.styleDefaultsSeries(seriesIndex))
}
}
}
func (c Chart) drawTitle(r Renderer) {
if len(c.Title) > 0 && !c.TitleStyle.Hidden {
r.SetFont(c.TitleStyle.GetFont(c.GetFont()))
r.SetFontColor(c.TitleStyle.GetFontColor(c.GetColorPalette().TextColor()))
titleFontSize := c.TitleStyle.GetFontSize(DefaultTitleFontSize)
r.SetFontSize(titleFontSize)
textBox := r.MeasureText(c.Title)
textWidth := textBox.Width()
textHeight := textBox.Height()
titleX := (c.GetWidth() >> 1) - (textWidth >> 1)
titleY := c.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight
r.Text(c.Title, titleX, titleY)
}
}
func (c Chart) styleDefaultsBackground() Style {
return Style{
FillColor: c.GetColorPalette().BackgroundColor(),
StrokeColor: c.GetColorPalette().BackgroundStrokeColor(),
StrokeWidth: DefaultBackgroundStrokeWidth,
}
}
func (c Chart) styleDefaultsCanvas() Style {
return Style{
FillColor: c.GetColorPalette().CanvasColor(),
StrokeColor: c.GetColorPalette().CanvasStrokeColor(),
StrokeWidth: DefaultCanvasStrokeWidth,
}
}
func (c Chart) styleDefaultsSeries(seriesIndex int) Style {
return Style{
DotColor: c.GetColorPalette().GetSeriesColor(seriesIndex),
StrokeColor: c.GetColorPalette().GetSeriesColor(seriesIndex),
StrokeWidth: DefaultSeriesLineWidth,
Font: c.GetFont(),
FontSize: DefaultFontSize,
}
}
func (c Chart) styleDefaultsAxes() Style {
return Style{
Font: c.GetFont(),
FontColor: c.GetColorPalette().TextColor(),
FontSize: DefaultAxisFontSize,
StrokeColor: c.GetColorPalette().AxisStrokeColor(),
StrokeWidth: DefaultAxisLineWidth,
}
}
func (c Chart) styleDefaultsElements() Style {
return Style{
Font: c.GetFont(),
}
}
// GetColorPalette returns the color palette for the chart.
func (c Chart) GetColorPalette() ColorPalette {
if c.ColorPalette != nil {
return c.ColorPalette
}
return DefaultColorPalette
}
// Box returns the chart bounds as a box.
func (c Chart) Box() Box {
dpr := c.Background.Padding.GetRight(DefaultBackgroundPadding.Right)
dpb := c.Background.Padding.GetBottom(DefaultBackgroundPadding.Bottom)
return Box{
Top: c.Background.Padding.GetTop(DefaultBackgroundPadding.Top),
Left: c.Background.Padding.GetLeft(DefaultBackgroundPadding.Left),
Right: c.GetWidth() - dpr,
Bottom: c.GetHeight() - dpb,
}
}

575
pkg/chart/chart_test.go Normal file
View file

@ -0,0 +1,575 @@
package chart
import (
"bytes"
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
"image"
"image/png"
"math"
"testing"
"time"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestChartGetDPI(t *testing.T) {
// replaced new assertions helper
unset := Chart{}
testutil.AssertEqual(t, DefaultDPI, unset.GetDPI())
testutil.AssertEqual(t, 192, unset.GetDPI(192))
set := Chart{DPI: 128}
testutil.AssertEqual(t, 128, set.GetDPI())
testutil.AssertEqual(t, 128, set.GetDPI(192))
}
func TestChartGetFont(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
unset := Chart{}
testutil.AssertNil(t, unset.GetFont())
set := Chart{Font: f}
testutil.AssertNotNil(t, set.GetFont())
}
func TestChartGetWidth(t *testing.T) {
// replaced new assertions helper
unset := Chart{}
testutil.AssertEqual(t, DefaultChartWidth, unset.GetWidth())
set := Chart{Width: DefaultChartWidth + 10}
testutil.AssertEqual(t, DefaultChartWidth+10, set.GetWidth())
}
func TestChartGetHeight(t *testing.T) {
// replaced new assertions helper
unset := Chart{}
testutil.AssertEqual(t, DefaultChartHeight, unset.GetHeight())
set := Chart{Height: DefaultChartHeight + 10}
testutil.AssertEqual(t, DefaultChartHeight+10, set.GetHeight())
}
func TestChartGetRanges(t *testing.T) {
// replaced new assertions helper
c := Chart{
Series: []Series{
ContinuousSeries{
XValues: []float64{-2.0, -1.0, 0, 1.0, 2.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 4.5},
},
ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{-2.1, -1.0, 0, 1.0, 2.0},
},
ContinuousSeries{
YAxis: YAxisSecondary,
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{10.0, 11.0, 12.0, 13.0, 14.0},
},
},
}
xrange, yrange, yrangeAlt := c.getRanges()
testutil.AssertEqual(t, -2.0, xrange.GetMin())
testutil.AssertEqual(t, 5.0, xrange.GetMax())
testutil.AssertEqual(t, -2.1, yrange.GetMin())
testutil.AssertEqual(t, 4.5, yrange.GetMax())
testutil.AssertEqual(t, 10.0, yrangeAlt.GetMin())
testutil.AssertEqual(t, 14.0, yrangeAlt.GetMax())
cSet := Chart{
XAxis: XAxis{
Range: &ContinuousRange{Min: 9.8, Max: 19.8},
},
YAxis: YAxis{
Range: &ContinuousRange{Min: 9.9, Max: 19.9},
},
YAxisSecondary: YAxis{
Range: &ContinuousRange{Min: 9.7, Max: 19.7},
},
Series: []Series{
ContinuousSeries{
XValues: []float64{-2.0, -1.0, 0, 1.0, 2.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 4.5},
},
ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{-2.1, -1.0, 0, 1.0, 2.0},
},
ContinuousSeries{
YAxis: YAxisSecondary,
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{10.0, 11.0, 12.0, 13.0, 14.0},
},
},
}
xr2, yr2, yra2 := cSet.getRanges()
testutil.AssertEqual(t, 9.8, xr2.GetMin())
testutil.AssertEqual(t, 19.8, xr2.GetMax())
testutil.AssertEqual(t, 9.9, yr2.GetMin())
testutil.AssertEqual(t, 19.9, yr2.GetMax())
testutil.AssertEqual(t, 9.7, yra2.GetMin())
testutil.AssertEqual(t, 19.7, yra2.GetMax())
}
func TestChartGetRangesUseTicks(t *testing.T) {
// replaced new assertions helper
// this test asserts that ticks should supercede manual ranges when generating the overall ranges.
c := Chart{
YAxis: YAxis{
Ticks: []Tick{
{0.0, "Zero"},
{1.0, "1.0"},
{2.0, "2.0"},
{3.0, "3.0"},
{4.0, "4.0"},
{5.0, "Five"},
},
Range: &ContinuousRange{
Min: -5.0,
Max: 5.0,
},
},
Series: []Series{
ContinuousSeries{
XValues: []float64{-2.0, -1.0, 0, 1.0, 2.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 4.5},
},
},
}
xr, yr, yar := c.getRanges()
testutil.AssertEqual(t, -2.0, xr.GetMin())
testutil.AssertEqual(t, 2.0, xr.GetMax())
testutil.AssertEqual(t, 0.0, yr.GetMin())
testutil.AssertEqual(t, 5.0, yr.GetMax())
testutil.AssertTrue(t, yar.IsZero(), yar.String())
}
func TestChartGetRangesUseUserRanges(t *testing.T) {
// replaced new assertions helper
c := Chart{
YAxis: YAxis{
Range: &ContinuousRange{
Min: -5.0,
Max: 5.0,
},
},
Series: []Series{
ContinuousSeries{
XValues: []float64{-2.0, -1.0, 0, 1.0, 2.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 4.5},
},
},
}
xr, yr, yar := c.getRanges()
testutil.AssertEqual(t, -2.0, xr.GetMin())
testutil.AssertEqual(t, 2.0, xr.GetMax())
testutil.AssertEqual(t, -5.0, yr.GetMin())
testutil.AssertEqual(t, 5.0, yr.GetMax())
testutil.AssertTrue(t, yar.IsZero(), yar.String())
}
func TestChartGetBackgroundStyle(t *testing.T) {
// replaced new assertions helper
c := Chart{
Background: Style{
FillColor: drawing.ColorBlack,
},
}
bs := c.getBackgroundStyle()
testutil.AssertEqual(t, bs.FillColor.String(), drawing.ColorBlack.String())
}
func TestChartGetCanvasStyle(t *testing.T) {
// replaced new assertions helper
c := Chart{
Canvas: Style{
FillColor: drawing.ColorBlack,
},
}
bs := c.getCanvasStyle()
testutil.AssertEqual(t, bs.FillColor.String(), drawing.ColorBlack.String())
}
func TestChartGetDefaultCanvasBox(t *testing.T) {
// replaced new assertions helper
c := Chart{}
canvasBoxDefault := c.getDefaultCanvasBox()
testutil.AssertFalse(t, canvasBoxDefault.IsZero())
testutil.AssertEqual(t, DefaultBackgroundPadding.Top, canvasBoxDefault.Top)
testutil.AssertEqual(t, DefaultBackgroundPadding.Left, canvasBoxDefault.Left)
testutil.AssertEqual(t, c.GetWidth()-DefaultBackgroundPadding.Right, canvasBoxDefault.Right)
testutil.AssertEqual(t, c.GetHeight()-DefaultBackgroundPadding.Bottom, canvasBoxDefault.Bottom)
custom := Chart{
Background: Style{
Padding: Box{
Top: DefaultBackgroundPadding.Top + 1,
Left: DefaultBackgroundPadding.Left + 1,
Right: DefaultBackgroundPadding.Right + 1,
Bottom: DefaultBackgroundPadding.Bottom + 1,
},
},
}
canvasBoxCustom := custom.getDefaultCanvasBox()
testutil.AssertFalse(t, canvasBoxCustom.IsZero())
testutil.AssertEqual(t, DefaultBackgroundPadding.Top+1, canvasBoxCustom.Top)
testutil.AssertEqual(t, DefaultBackgroundPadding.Left+1, canvasBoxCustom.Left)
testutil.AssertEqual(t, c.GetWidth()-(DefaultBackgroundPadding.Right+1), canvasBoxCustom.Right)
testutil.AssertEqual(t, c.GetHeight()-(DefaultBackgroundPadding.Bottom+1), canvasBoxCustom.Bottom)
}
func TestChartGetValueFormatters(t *testing.T) {
// replaced new assertions helper
c := Chart{
Series: []Series{
ContinuousSeries{
XValues: []float64{-2.0, -1.0, 0, 1.0, 2.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 4.5},
},
ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{-2.1, -1.0, 0, 1.0, 2.0},
},
ContinuousSeries{
YAxis: YAxisSecondary,
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{10.0, 11.0, 12.0, 13.0, 14.0},
},
},
}
dxf, dyf, dyaf := c.getValueFormatters()
testutil.AssertNotNil(t, dxf)
testutil.AssertNotNil(t, dyf)
testutil.AssertNotNil(t, dyaf)
}
func TestChartHasAxes(t *testing.T) {
// replaced new assertions helper
testutil.AssertTrue(t, Chart{}.hasAxes())
testutil.AssertFalse(t, Chart{XAxis: XAxis{Style: Hidden()}, YAxis: YAxis{Style: Hidden()}, YAxisSecondary: YAxis{Style: Hidden()}}.hasAxes())
x := Chart{
XAxis: XAxis{
Style: Hidden(),
},
YAxis: YAxis{
Style: Shown(),
},
YAxisSecondary: YAxis{
Style: Hidden(),
},
}
testutil.AssertTrue(t, x.hasAxes())
y := Chart{
XAxis: XAxis{
Style: Shown(),
},
YAxis: YAxis{
Style: Hidden(),
},
YAxisSecondary: YAxis{
Style: Hidden(),
},
}
testutil.AssertTrue(t, y.hasAxes())
ya := Chart{
XAxis: XAxis{
Style: Hidden(),
},
YAxis: YAxis{
Style: Hidden(),
},
YAxisSecondary: YAxis{
Style: Shown(),
},
}
testutil.AssertTrue(t, ya.hasAxes())
}
func TestChartGetAxesTicks(t *testing.T) {
// replaced new assertions helper
r, err := PNG(1024, 1024)
testutil.AssertNil(t, err)
c := Chart{
XAxis: XAxis{
Range: &ContinuousRange{Min: 9.8, Max: 19.8},
},
YAxis: YAxis{
Range: &ContinuousRange{Min: 9.9, Max: 19.9},
},
YAxisSecondary: YAxis{
Range: &ContinuousRange{Min: 9.7, Max: 19.7},
},
}
xr, yr, yar := c.getRanges()
xt, yt, yat := c.getAxesTicks(r, xr, yr, yar, FloatValueFormatter, FloatValueFormatter, FloatValueFormatter)
testutil.AssertNotEmpty(t, xt)
testutil.AssertNotEmpty(t, yt)
testutil.AssertNotEmpty(t, yat)
}
func TestChartSingleSeries(t *testing.T) {
// replaced new assertions helper
now := time.Now()
c := Chart{
Title: "Hello!",
Width: 1024,
Height: 400,
YAxis: YAxis{
Range: &ContinuousRange{
Min: 0.0,
Max: 4.0,
},
},
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)
testutil.AssertNotEmpty(t, buffer.Bytes())
}
func TestChartRegressionBadRanges(t *testing.T) {
// replaced new assertions helper
c := Chart{
Series: []Series{
ContinuousSeries{
XValues: []float64{math.Inf(1), math.Inf(1), math.Inf(1), math.Inf(1), math.Inf(1)},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 4.5},
},
},
}
buffer := bytes.NewBuffer([]byte{})
c.Render(PNG, buffer)
testutil.AssertTrue(t, true, "Render needs to finish.")
}
func TestChartRegressionBadRangesByUser(t *testing.T) {
// replaced new assertions helper
c := Chart{
YAxis: YAxis{
Range: &ContinuousRange{
Min: math.Inf(-1),
Max: math.Inf(1), // this could really happen? eh.
},
},
Series: []Series{
ContinuousSeries{
XValues: LinearRange(1.0, 10.0),
YValues: LinearRange(1.0, 10.0),
},
},
}
buffer := bytes.NewBuffer([]byte{})
c.Render(PNG, buffer)
testutil.AssertTrue(t, true, "Render needs to finish.")
}
func TestChartValidatesSeries(t *testing.T) {
// replaced new assertions helper
c := Chart{
Series: []Series{
ContinuousSeries{
XValues: LinearRange(1.0, 10.0),
YValues: LinearRange(1.0, 10.0),
},
},
}
testutil.AssertNil(t, c.validateSeries())
c = Chart{
Series: []Series{
ContinuousSeries{
XValues: LinearRange(1.0, 10.0),
},
},
}
testutil.AssertNotNil(t, c.validateSeries())
}
func TestChartCheckRanges(t *testing.T) {
// replaced new assertions helper
c := Chart{
Series: []Series{
ContinuousSeries{
XValues: []float64{1.0, 2.0},
YValues: []float64{3.10, 3.14},
},
},
}
xr, yr, yra := c.getRanges()
testutil.AssertNil(t, c.checkRanges(xr, yr, yra))
}
func TestChartCheckRangesWithRanges(t *testing.T) {
// replaced new assertions helper
c := Chart{
XAxis: XAxis{
Range: &ContinuousRange{
Min: 0,
Max: 10,
},
},
YAxis: YAxis{
Range: &ContinuousRange{
Min: 0,
Max: 5,
},
},
Series: []Series{
ContinuousSeries{
XValues: []float64{1.0, 2.0},
YValues: []float64{3.14, 3.14},
},
},
}
xr, yr, yra := c.getRanges()
testutil.AssertNil(t, c.checkRanges(xr, yr, yra))
}
func at(i image.Image, x, y int) drawing.Color {
return drawing.ColorFromAlphaMixedRGBA(i.At(x, y).RGBA())
}
func TestChartE2ELine(t *testing.T) {
// replaced new assertions helper
c := Chart{
Height: 50,
Width: 50,
TitleStyle: Hidden(),
XAxis: HideXAxis(),
YAxis: HideYAxis(),
YAxisSecondary: HideYAxis(),
Canvas: Style{
Padding: BoxZero,
},
Background: Style{
Padding: BoxZero,
},
Series: []Series{
ContinuousSeries{
XValues: LinearRangeWithStep(0, 4, 1),
YValues: LinearRangeWithStep(0, 4, 1),
},
},
}
var buffer = &bytes.Buffer{}
err := c.Render(PNG, buffer)
testutil.AssertNil(t, err)
// do color tests ...
i, err := png.Decode(buffer)
testutil.AssertNil(t, err)
// test the bottom and top of the line
testutil.AssertEqual(t, drawing.ColorWhite, at(i, 0, 0))
testutil.AssertEqual(t, drawing.ColorWhite, at(i, 49, 49))
// test a line mid point
defaultSeriesColor := GetDefaultColor(0)
testutil.AssertEqual(t, defaultSeriesColor, at(i, 0, 49))
testutil.AssertEqual(t, defaultSeriesColor, at(i, 49, 0))
testutil.AssertEqual(t, drawing.ColorFromHex("bddbf6"), at(i, 24, 24))
}
func TestChartE2ELineWithFill(t *testing.T) {
// replaced new assertions helper
logBuffer := new(bytes.Buffer)
c := Chart{
Height: 50,
Width: 50,
Canvas: Style{
Padding: BoxZero,
},
Background: Style{
Padding: BoxZero,
},
TitleStyle: Hidden(),
XAxis: HideXAxis(),
YAxis: HideYAxis(),
YAxisSecondary: HideYAxis(),
Series: []Series{
ContinuousSeries{
Style: Style{
StrokeColor: drawing.ColorBlue,
FillColor: drawing.ColorRed,
},
XValues: LinearRangeWithStep(0, 4, 1),
YValues: LinearRangeWithStep(0, 4, 1),
},
},
Log: NewLogger(OptLoggerStdout(logBuffer), OptLoggerStderr(logBuffer)),
}
testutil.AssertEqual(t, 5, len(c.Series[0].(ContinuousSeries).XValues))
testutil.AssertEqual(t, 5, len(c.Series[0].(ContinuousSeries).YValues))
var buffer = &bytes.Buffer{}
err := c.Render(PNG, buffer)
testutil.AssertNil(t, err)
i, err := png.Decode(buffer)
testutil.AssertNil(t, err)
// test the bottom and top of the line
testutil.AssertEqual(t, drawing.ColorWhite, at(i, 0, 0))
testutil.AssertEqual(t, drawing.ColorRed, at(i, 49, 49))
// test a line mid point
defaultSeriesColor := drawing.ColorBlue
testutil.AssertEqual(t, defaultSeriesColor, at(i, 0, 49))
testutil.AssertEqual(t, defaultSeriesColor, at(i, 49, 0))
}

186
pkg/chart/colors.go Normal file
View file

@ -0,0 +1,186 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
)
var (
// ColorWhite is white.
ColorWhite = drawing.Color{R: 255, G: 255, B: 255, A: 255}
// ColorBlue is the basic theme blue color.
ColorBlue = drawing.Color{R: 0, G: 116, B: 217, A: 255}
// ColorCyan is the basic theme cyan color.
ColorCyan = drawing.Color{R: 0, G: 217, B: 210, A: 255}
// ColorGreen is the basic theme green color.
ColorGreen = drawing.Color{R: 0, G: 217, B: 101, A: 255}
// ColorRed is the basic theme red color.
ColorRed = drawing.Color{R: 217, G: 0, B: 116, A: 255}
// ColorOrange is the basic theme orange color.
ColorOrange = drawing.Color{R: 217, G: 101, B: 0, A: 255}
// ColorYellow is the basic theme yellow color.
ColorYellow = drawing.Color{R: 217, G: 210, B: 0, A: 255}
// ColorBlack is the basic theme black color.
ColorBlack = drawing.Color{R: 51, G: 51, B: 51, A: 255}
// ColorLightGray is the basic theme light gray color.
ColorLightGray = drawing.Color{R: 239, G: 239, B: 239, A: 255}
// ColorAlternateBlue is a alternate theme color.
ColorAlternateBlue = drawing.Color{R: 106, G: 195, B: 203, A: 255}
// ColorAlternateGreen is a alternate theme color.
ColorAlternateGreen = drawing.Color{R: 42, G: 190, B: 137, A: 255}
// ColorAlternateGray is a alternate theme color.
ColorAlternateGray = drawing.Color{R: 110, G: 128, B: 139, A: 255}
// ColorAlternateYellow is a alternate theme color.
ColorAlternateYellow = drawing.Color{R: 240, G: 174, B: 90, A: 255}
// ColorAlternateLightGray is a alternate theme color.
ColorAlternateLightGray = drawing.Color{R: 187, G: 190, B: 191, A: 255}
// ColorTransparent is a transparent (alpha zero) color.
ColorTransparent = drawing.Color{R: 1, G: 1, B: 1, A: 0}
)
var (
// DefaultBackgroundColor is the default chart background color.
// It is equivalent to css color:white.
DefaultBackgroundColor = ColorWhite
// DefaultBackgroundStrokeColor is the default chart border color.
// It is equivalent to color:white.
DefaultBackgroundStrokeColor = ColorWhite
// DefaultCanvasColor is the default chart canvas color.
// It is equivalent to css color:white.
DefaultCanvasColor = ColorWhite
// DefaultCanvasStrokeColor is the default chart canvas stroke color.
// It is equivalent to css color:white.
DefaultCanvasStrokeColor = ColorWhite
// DefaultTextColor is the default chart text color.
// It is equivalent to #333333.
DefaultTextColor = ColorBlack
// DefaultAxisColor is the default chart axis line color.
// It is equivalent to #333333.
DefaultAxisColor = ColorBlack
// DefaultStrokeColor is the default chart border color.
// It is equivalent to #efefef.
DefaultStrokeColor = ColorLightGray
// DefaultFillColor is the default fill color.
// It is equivalent to #0074d9.
DefaultFillColor = ColorBlue
// DefaultAnnotationFillColor is the default annotation background color.
DefaultAnnotationFillColor = ColorWhite
// DefaultGridLineColor is the default grid line color.
DefaultGridLineColor = ColorLightGray
)
var (
// DefaultColors are a couple default series colors.
DefaultColors = []drawing.Color{
ColorBlue,
ColorGreen,
ColorRed,
ColorCyan,
ColorOrange,
}
// DefaultAlternateColors are a couple alternate colors.
DefaultAlternateColors = []drawing.Color{
ColorAlternateBlue,
ColorAlternateGreen,
ColorAlternateGray,
ColorAlternateYellow,
ColorBlue,
ColorGreen,
ColorRed,
ColorCyan,
ColorOrange,
}
)
// GetDefaultColor returns a color from the default list by index.
// NOTE: the index will wrap around (using a modulo).
func GetDefaultColor(index int) drawing.Color {
finalIndex := index % len(DefaultColors)
return DefaultColors[finalIndex]
}
// GetAlternateColor returns a color from the default list by index.
// NOTE: the index will wrap around (using a modulo).
func GetAlternateColor(index int) drawing.Color {
finalIndex := index % len(DefaultAlternateColors)
return DefaultAlternateColors[finalIndex]
}
// ColorPalette is a set of colors that.
type ColorPalette interface {
BackgroundColor() drawing.Color
BackgroundStrokeColor() drawing.Color
CanvasColor() drawing.Color
CanvasStrokeColor() drawing.Color
AxisStrokeColor() drawing.Color
TextColor() drawing.Color
GetSeriesColor(index int) drawing.Color
}
// DefaultColorPalette represents the default palatte.
var DefaultColorPalette defaultColorPalette
type defaultColorPalette struct{}
func (dp defaultColorPalette) BackgroundColor() drawing.Color {
return DefaultBackgroundColor
}
func (dp defaultColorPalette) BackgroundStrokeColor() drawing.Color {
return DefaultBackgroundStrokeColor
}
func (dp defaultColorPalette) CanvasColor() drawing.Color {
return DefaultCanvasColor
}
func (dp defaultColorPalette) CanvasStrokeColor() drawing.Color {
return DefaultCanvasStrokeColor
}
func (dp defaultColorPalette) AxisStrokeColor() drawing.Color {
return DefaultAxisColor
}
func (dp defaultColorPalette) TextColor() drawing.Color {
return DefaultTextColor
}
func (dp defaultColorPalette) GetSeriesColor(index int) drawing.Color {
return GetDefaultColor(index)
}
// AlternateColorPalette represents the default palatte.
var AlternateColorPalette alternateColorPalette
type alternateColorPalette struct{}
func (ap alternateColorPalette) BackgroundColor() drawing.Color {
return DefaultBackgroundColor
}
func (ap alternateColorPalette) BackgroundStrokeColor() drawing.Color {
return DefaultBackgroundStrokeColor
}
func (ap alternateColorPalette) CanvasColor() drawing.Color {
return DefaultCanvasColor
}
func (ap alternateColorPalette) CanvasStrokeColor() drawing.Color {
return DefaultCanvasStrokeColor
}
func (ap alternateColorPalette) AxisStrokeColor() drawing.Color {
return DefaultAxisColor
}
func (ap alternateColorPalette) TextColor() drawing.Color {
return DefaultTextColor
}
func (ap alternateColorPalette) GetSeriesColor(index int) drawing.Color {
return GetAlternateColor(index)
}

View file

@ -0,0 +1,44 @@
package chart
// ConcatSeries is a special type of series that concatenates its `InnerSeries`.
type ConcatSeries []Series
// Len returns the length of the concatenated set of series.
func (cs ConcatSeries) Len() int {
total := 0
for _, s := range cs {
if typed, isValuesProvider := s.(ValuesProvider); isValuesProvider {
total += typed.Len()
}
}
return total
}
// GetValue returns the value at the (meta) index (i.e 0 => totalLen-1)
func (cs ConcatSeries) GetValue(index int) (x, y float64) {
cursor := 0
for _, s := range cs {
if typed, isValuesProvider := s.(ValuesProvider); isValuesProvider {
len := typed.Len()
if index < cursor+len {
x, y = typed.GetValues(index - cursor) //FENCEPOSTS.
return
}
cursor += typed.Len()
}
}
return
}
// Validate validates the series.
func (cs ConcatSeries) Validate() error {
var err error
for _, s := range cs {
err = s.Validate()
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,41 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestConcatSeries(t *testing.T) {
// replaced new assertions helper
s1 := ContinuousSeries{
XValues: LinearRange(1.0, 10.0),
YValues: LinearRange(1.0, 10.0),
}
s2 := ContinuousSeries{
XValues: LinearRange(11, 20.0),
YValues: LinearRange(10.0, 1.0),
}
s3 := ContinuousSeries{
XValues: LinearRange(21, 30.0),
YValues: LinearRange(1.0, 10.0),
}
cs := ConcatSeries([]Series{s1, s2, s3})
testutil.AssertEqual(t, 30, cs.Len())
x0, y0 := cs.GetValue(0)
testutil.AssertEqual(t, 1.0, x0)
testutil.AssertEqual(t, 1.0, y0)
xm, ym := cs.GetValue(19)
testutil.AssertEqual(t, 20.0, xm)
testutil.AssertEqual(t, 1.0, ym)
xn, yn := cs.GetValue(29)
testutil.AssertEqual(t, 30.0, xn)
testutil.AssertEqual(t, 10.0, yn)
}

View file

@ -0,0 +1,81 @@
package chart
import (
"fmt"
"math"
)
// ContinuousRange represents a boundary for a set of numbers.
type ContinuousRange struct {
Min float64
Max float64
Domain int
Descending bool
}
// IsDescending returns if the range is descending.
func (r ContinuousRange) IsDescending() bool {
return r.Descending
}
// IsZero returns if the ContinuousRange has been set or not.
func (r ContinuousRange) IsZero() bool {
return (r.Min == 0 || math.IsNaN(r.Min)) &&
(r.Max == 0 || math.IsNaN(r.Max)) &&
r.Domain == 0
}
// GetMin gets the min value for the continuous range.
func (r ContinuousRange) GetMin() float64 {
return r.Min
}
// SetMin sets the min value for the continuous range.
func (r *ContinuousRange) SetMin(min float64) {
r.Min = min
}
// GetMax returns the max value for the continuous range.
func (r ContinuousRange) GetMax() float64 {
return r.Max
}
// SetMax sets the max value for the continuous range.
func (r *ContinuousRange) SetMax(max float64) {
r.Max = max
}
// GetDelta returns the difference between the min and max value.
func (r ContinuousRange) GetDelta() float64 {
return r.Max - r.Min
}
// GetDomain returns the range domain.
func (r ContinuousRange) GetDomain() int {
return r.Domain
}
// SetDomain sets the range domain.
func (r *ContinuousRange) SetDomain(domain int) {
r.Domain = domain
}
// String returns a simple string for the ContinuousRange.
func (r ContinuousRange) String() string {
if r.GetDelta() == 0 {
return "ContinuousRange [empty]"
}
return fmt.Sprintf("ContinuousRange [%.2f,%.2f] => %d", r.Min, r.Max, r.Domain)
}
// Translate maps a given value into the ContinuousRange space.
func (r ContinuousRange) Translate(value float64) int {
normalized := value - r.Min
ratio := normalized / r.GetDelta()
if r.IsDescending() {
return r.Domain - int(math.Ceil(ratio*float64(r.Domain)))
}
return int(math.Ceil(ratio * float64(r.Domain)))
}

View file

@ -0,0 +1,22 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestRangeTranslate(t *testing.T) {
// replaced new assertions helper
values := []float64{1.0, 2.0, 2.5, 2.7, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0}
r := ContinuousRange{Domain: 1000}
r.Min, r.Max = MinMax(values...)
// delta = ~7.0
// value = ~5.0
// domain = ~1000
// 5/8 * 1000 ~=
testutil.AssertEqual(t, 0, r.Translate(1.0))
testutil.AssertEqual(t, 1000, r.Translate(8.0))
testutil.AssertEqual(t, 572, r.Translate(5.0))
}

View file

@ -0,0 +1,96 @@
package chart
import "fmt"
// Interface Assertions.
var (
_ Series = (*ContinuousSeries)(nil)
_ FirstValuesProvider = (*ContinuousSeries)(nil)
_ LastValuesProvider = (*ContinuousSeries)(nil)
)
// ContinuousSeries represents a line on a chart.
type ContinuousSeries struct {
Name string
Style Style
YAxis YAxisType
XValueFormatter ValueFormatter
YValueFormatter ValueFormatter
XValues []float64
YValues []float64
}
// GetName returns the name of the time series.
func (cs ContinuousSeries) GetName() string {
return cs.Name
}
// GetStyle returns the line style.
func (cs ContinuousSeries) GetStyle() Style {
return cs.Style
}
// Len returns the number of elements in the series.
func (cs ContinuousSeries) Len() int {
return len(cs.XValues)
}
// GetValues gets the x,y values at a given index.
func (cs ContinuousSeries) GetValues(index int) (float64, float64) {
return cs.XValues[index], cs.YValues[index]
}
// GetFirstValues gets the first x,y values.
func (cs ContinuousSeries) GetFirstValues() (float64, float64) {
return cs.XValues[0], cs.YValues[0]
}
// GetLastValues gets the last x,y values.
func (cs ContinuousSeries) GetLastValues() (float64, float64) {
return cs.XValues[len(cs.XValues)-1], cs.YValues[len(cs.YValues)-1]
}
// GetValueFormatters returns value formatter defaults for the series.
func (cs ContinuousSeries) GetValueFormatters() (x, y ValueFormatter) {
if cs.XValueFormatter != nil {
x = cs.XValueFormatter
} else {
x = FloatValueFormatter
}
if cs.YValueFormatter != nil {
y = cs.YValueFormatter
} else {
y = FloatValueFormatter
}
return
}
// GetYAxis returns which YAxis the series draws on.
func (cs ContinuousSeries) GetYAxis() YAxisType {
return cs.YAxis
}
// Render renders the series.
func (cs ContinuousSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := cs.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, cs)
}
// Validate validates the series.
func (cs ContinuousSeries) Validate() error {
if len(cs.XValues) == 0 {
return fmt.Errorf("continuous series; must have xvalues set")
}
if len(cs.YValues) == 0 {
return fmt.Errorf("continuous series; must have yvalues set")
}
if len(cs.XValues) != len(cs.YValues) {
return fmt.Errorf("continuous series; must have same length xvalues as yvalues")
}
return nil
}

View file

@ -0,0 +1,72 @@
package chart
import (
"fmt"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestContinuousSeries(t *testing.T) {
// replaced new assertions helper
cs := ContinuousSeries{
Name: "Test Series",
XValues: LinearRange(1.0, 10.0),
YValues: LinearRange(1.0, 10.0),
}
testutil.AssertEqual(t, "Test Series", cs.GetName())
testutil.AssertEqual(t, 10, cs.Len())
x0, y0 := cs.GetValues(0)
testutil.AssertEqual(t, 1.0, x0)
testutil.AssertEqual(t, 1.0, y0)
xn, yn := cs.GetValues(9)
testutil.AssertEqual(t, 10.0, xn)
testutil.AssertEqual(t, 10.0, yn)
xn, yn = cs.GetLastValues()
testutil.AssertEqual(t, 10.0, xn)
testutil.AssertEqual(t, 10.0, yn)
}
func TestContinuousSeriesValueFormatter(t *testing.T) {
// replaced new assertions helper
cs := ContinuousSeries{
XValueFormatter: func(v interface{}) string {
return fmt.Sprintf("%f foo", v)
},
YValueFormatter: func(v interface{}) string {
return fmt.Sprintf("%f bar", v)
},
}
xf, yf := cs.GetValueFormatters()
testutil.AssertEqual(t, "0.100000 foo", xf(0.1))
testutil.AssertEqual(t, "0.100000 bar", yf(0.1))
}
func TestContinuousSeriesValidate(t *testing.T) {
// replaced new assertions helper
cs := ContinuousSeries{
Name: "Test Series",
XValues: LinearRange(1.0, 10.0),
YValues: LinearRange(1.0, 10.0),
}
testutil.AssertNil(t, cs.Validate())
cs = ContinuousSeries{
Name: "Test Series",
XValues: LinearRange(1.0, 10.0),
}
testutil.AssertNotNil(t, cs.Validate())
cs = ContinuousSeries{
Name: "Test Series",
YValues: LinearRange(1.0, 10.0),
}
testutil.AssertNotNil(t, cs.Validate())
}

103
pkg/chart/defaults.go Normal file
View file

@ -0,0 +1,103 @@
package chart
const (
// DefaultChartHeight is the default chart height.
DefaultChartHeight = 400
// DefaultChartWidth is the default chart width.
DefaultChartWidth = 1024
// DefaultStrokeWidth is the default chart stroke width.
DefaultStrokeWidth = 0.0
// DefaultDotWidth is the default chart dot width.
DefaultDotWidth = 0.0
// DefaultSeriesLineWidth is the default line width.
DefaultSeriesLineWidth = 1.0
// DefaultAxisLineWidth is the line width of the axis lines.
DefaultAxisLineWidth = 1.0
//DefaultDPI is the default dots per inch for the chart.
DefaultDPI = 92.0
// DefaultMinimumFontSize is the default minimum font size.
DefaultMinimumFontSize = 8.0
// DefaultFontSize is the default font size.
DefaultFontSize = 10.0
// DefaultTitleFontSize is the default title font size.
DefaultTitleFontSize = 18.0
// DefaultAnnotationDeltaWidth is the width of the left triangle out of annotations.
DefaultAnnotationDeltaWidth = 10
// DefaultAnnotationFontSize is the font size of annotations.
DefaultAnnotationFontSize = 10.0
// DefaultAxisFontSize is the font size of the axis labels.
DefaultAxisFontSize = 10.0
// DefaultTitleTop is the default distance from the top of the chart to put the title.
DefaultTitleTop = 10
// DefaultBackgroundStrokeWidth is the default stroke on the chart background.
DefaultBackgroundStrokeWidth = 0.0
// DefaultCanvasStrokeWidth is the default stroke on the chart canvas.
DefaultCanvasStrokeWidth = 0.0
// DefaultLineSpacing is the default vertical distance between lines of text.
DefaultLineSpacing = 5
// DefaultYAxisMargin is the default distance from the right of the canvas to the y axis labels.
DefaultYAxisMargin = 10
// DefaultXAxisMargin is the default distance from bottom of the canvas to the x axis labels.
DefaultXAxisMargin = 10
//DefaultVerticalTickHeight is half the margin.
DefaultVerticalTickHeight = DefaultXAxisMargin >> 1
//DefaultHorizontalTickWidth is half the margin.
DefaultHorizontalTickWidth = DefaultYAxisMargin >> 1
// DefaultTickCount is the default number of ticks to show
DefaultTickCount = 10
// DefaultTickCountSanityCheck is a hard limit on number of ticks to prevent infinite loops.
DefaultTickCountSanityCheck = 1 << 10 //1024
// DefaultMinimumTickHorizontalSpacing is the minimum distance between horizontal ticks.
DefaultMinimumTickHorizontalSpacing = 20
// DefaultMinimumTickVerticalSpacing is the minimum distance between vertical ticks.
DefaultMinimumTickVerticalSpacing = 20
// DefaultDateFormat is the default date format.
DefaultDateFormat = "2006-01-02"
// DefaultDateHourFormat is the date format for hour timestamp formats.
DefaultDateHourFormat = "01-02 3PM"
// DefaultDateMinuteFormat is the date format for minute range timestamp formats.
DefaultDateMinuteFormat = "01-02 3:04PM"
// DefaultFloatFormat is the default float format.
DefaultFloatFormat = "%.2f"
// DefaultPercentValueFormat is the default percent format.
DefaultPercentValueFormat = "%0.2f%%"
// DefaultBarSpacing is the default pixel spacing between bars.
DefaultBarSpacing = 100
// DefaultBarWidth is the default pixel width of bars in a bar chart.
DefaultBarWidth = 50
)
var (
// DashArrayDots is a dash array that represents '....' style stroke dashes.
DashArrayDots = []int{1, 1}
// DashArrayDashesSmall is a dash array that represents '- - -' style stroke dashes.
DashArrayDashesSmall = []int{3, 3}
// DashArrayDashesMedium is a dash array that represents '-- -- --' style stroke dashes.
DashArrayDashesMedium = []int{5, 5}
// DashArrayDashesLarge is a dash array that represents '----- ----- -----' style stroke dashes.
DashArrayDashesLarge = []int{10, 10}
)
var (
// DefaultAnnotationPadding is the padding around an annotation.
DefaultAnnotationPadding = Box{Top: 5, Left: 5, Right: 5, Bottom: 5}
// DefaultBackgroundPadding is the default canvas padding config.
DefaultBackgroundPadding = Box{Top: 5, Left: 5, Right: 5, Bottom: 5}
)
const (
// ContentTypePNG is the png mime type.
ContentTypePNG = "image/png"
// ContentTypeSVG is the svg mime type.
ContentTypeSVG = "image/svg+xml"
)

315
pkg/chart/donut_chart.go Normal file
View file

@ -0,0 +1,315 @@
package chart
import (
"errors"
"fmt"
"io"
"github.com/golang/freetype/truetype"
)
// DonutChart is a chart that draws sections of a circle based on percentages with an hole.
type DonutChart struct {
Title string
TitleStyle Style
ColorPalette ColorPalette
Width int
Height int
DPI float64
Background Style
Canvas Style
SliceStyle Style
Font *truetype.Font
defaultFont *truetype.Font
Values []Value
Elements []Renderable
}
// GetDPI returns the dpi for the chart.
func (pc DonutChart) GetDPI(defaults ...float64) float64 {
if pc.DPI == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return DefaultDPI
}
return pc.DPI
}
// GetFont returns the text font.
func (pc DonutChart) GetFont() *truetype.Font {
if pc.Font == nil {
return pc.defaultFont
}
return pc.Font
}
// GetWidth returns the chart width or the default value.
func (pc DonutChart) GetWidth() int {
if pc.Width == 0 {
return DefaultChartWidth
}
return pc.Width
}
// GetHeight returns the chart height or the default value.
func (pc DonutChart) GetHeight() int {
if pc.Height == 0 {
return DefaultChartWidth
}
return pc.Height
}
// Render renders the chart with the given renderer to the given io.Writer.
func (pc DonutChart) Render(rp RendererProvider, w io.Writer) error {
if len(pc.Values) == 0 {
return errors.New("please provide at least one value")
}
r, err := rp(pc.GetWidth(), pc.GetHeight())
if err != nil {
return err
}
if pc.Font == nil {
defaultFont, err := GetDefaultFont()
if err != nil {
return err
}
pc.defaultFont = defaultFont
}
r.SetDPI(pc.GetDPI(DefaultDPI))
canvasBox := pc.getDefaultCanvasBox()
canvasBox = pc.getCircleAdjustedCanvasBox(canvasBox)
pc.drawBackground(r)
pc.drawCanvas(r, canvasBox)
finalValues, err := pc.finalizeValues(pc.Values)
if err != nil {
return err
}
pc.drawSlices(r, canvasBox, finalValues)
pc.drawTitle(r)
for _, a := range pc.Elements {
a(r, canvasBox, pc.styleDefaultsElements())
}
return r.Save(w)
}
func (pc DonutChart) drawBackground(r Renderer) {
Draw.Box(r, Box{
Right: pc.GetWidth(),
Bottom: pc.GetHeight(),
}, pc.getBackgroundStyle())
}
func (pc DonutChart) drawCanvas(r Renderer, canvasBox Box) {
Draw.Box(r, canvasBox, pc.getCanvasStyle())
}
func (pc DonutChart) drawTitle(r Renderer) {
if len(pc.Title) > 0 && !pc.TitleStyle.Hidden {
Draw.TextWithin(r, pc.Title, pc.Box(), pc.styleDefaultsTitle())
}
}
func (pc DonutChart) drawSlices(r Renderer, canvasBox Box, values []Value) {
cx, cy := canvasBox.Center()
diameter := MinInt(canvasBox.Width(), canvasBox.Height())
radius := float64(diameter>>1) / 1.1
labelRadius := (radius * 2.83) / 3.0
// draw the donut slices
var rads, delta, delta2, total float64
var lx, ly int
if len(values) == 1 {
pc.styleDonutChartValue(0).WriteToRenderer(r)
r.MoveTo(cx, cy)
r.Circle(radius, cx, cy)
} else {
for index, v := range values {
v.Style.InheritFrom(pc.styleDonutChartValue(index)).WriteToRenderer(r)
r.MoveTo(cx, cy)
rads = PercentToRadians(total)
delta = PercentToRadians(v.Value)
r.ArcTo(cx, cy, (radius / 1.25), (radius / 1.25), rads, delta)
r.LineTo(cx, cy)
r.Close()
r.FillStroke()
total = total + v.Value
}
}
//making the donut hole
v := Value{Value: 100, Label: "center"}
styletemp := pc.SliceStyle.InheritFrom(Style{
StrokeColor: ColorWhite, StrokeWidth: 4.0, FillColor: ColorWhite, FontColor: ColorWhite, //Font: pc.GetFont(),//FontSize: pc.getScaledFontSize(),
})
v.Style.InheritFrom(styletemp).WriteToRenderer(r)
r.MoveTo(cx, cy)
r.ArcTo(cx, cy, (radius / 3.5), (radius / 3.5), DegreesToRadians(0), DegreesToRadians(359))
r.LineTo(cx, cy)
r.Close()
r.FillStroke()
// draw the labels
total = 0
for index, v := range values {
v.Style.InheritFrom(pc.styleDonutChartValue(index)).WriteToRenderer(r)
if len(v.Label) > 0 {
delta2 = PercentToRadians(total + (v.Value / 2.0))
delta2 = RadianAdd(delta2, _pi2)
lx, ly = CirclePoint(cx, cy, labelRadius, delta2)
tb := r.MeasureText(v.Label)
lx = lx - (tb.Width() >> 1)
ly = ly + (tb.Height() >> 1)
r.Text(v.Label, lx, ly)
}
total = total + v.Value
}
}
func (pc DonutChart) finalizeValues(values []Value) ([]Value, error) {
finalValues := Values(values).Normalize()
if len(finalValues) == 0 {
return nil, fmt.Errorf("donut chart must contain at least (1) non-zero value")
}
return finalValues, nil
}
func (pc DonutChart) getDefaultCanvasBox() Box {
return pc.Box()
}
func (pc DonutChart) getCircleAdjustedCanvasBox(canvasBox Box) Box {
circleDiameter := MinInt(canvasBox.Width(), canvasBox.Height())
square := Box{
Right: circleDiameter,
Bottom: circleDiameter,
}
return canvasBox.Fit(square)
}
func (pc DonutChart) getBackgroundStyle() Style {
return pc.Background.InheritFrom(pc.styleDefaultsBackground())
}
func (pc DonutChart) getCanvasStyle() Style {
return pc.Canvas.InheritFrom(pc.styleDefaultsCanvas())
}
func (pc DonutChart) styleDefaultsCanvas() Style {
return Style{
FillColor: pc.GetColorPalette().CanvasColor(),
StrokeColor: pc.GetColorPalette().CanvasStrokeColor(),
StrokeWidth: DefaultStrokeWidth,
}
}
func (pc DonutChart) styleDefaultsDonutChartValue() Style {
return Style{
StrokeColor: pc.GetColorPalette().TextColor(),
StrokeWidth: 4.0,
FillColor: pc.GetColorPalette().TextColor(),
}
}
func (pc DonutChart) styleDonutChartValue(index int) Style {
return pc.SliceStyle.InheritFrom(Style{
StrokeColor: ColorWhite,
StrokeWidth: 4.0,
FillColor: pc.GetColorPalette().GetSeriesColor(index),
FontSize: pc.getScaledFontSize(),
FontColor: pc.GetColorPalette().TextColor(),
Font: pc.GetFont(),
})
}
func (pc DonutChart) getScaledFontSize() float64 {
effectiveDimension := MinInt(pc.GetWidth(), pc.GetHeight())
if effectiveDimension >= 2048 {
return 48.0
} else if effectiveDimension >= 1024 {
return 24.0
} else if effectiveDimension > 512 {
return 18.0
} else if effectiveDimension > 256 {
return 12.0
}
return 10.0
}
func (pc DonutChart) styleDefaultsBackground() Style {
return Style{
FillColor: pc.GetColorPalette().BackgroundColor(),
StrokeColor: pc.GetColorPalette().BackgroundStrokeColor(),
StrokeWidth: DefaultStrokeWidth,
}
}
func (pc DonutChart) styleDefaultsElements() Style {
return Style{
Font: pc.GetFont(),
}
}
func (pc DonutChart) styleDefaultsTitle() Style {
return pc.TitleStyle.InheritFrom(Style{
FontColor: pc.GetColorPalette().TextColor(),
Font: pc.GetFont(),
FontSize: pc.getTitleFontSize(),
TextHorizontalAlign: TextHorizontalAlignCenter,
TextVerticalAlign: TextVerticalAlignTop,
TextWrap: TextWrapWord,
})
}
func (pc DonutChart) getTitleFontSize() float64 {
effectiveDimension := MinInt(pc.GetWidth(), pc.GetHeight())
if effectiveDimension >= 2048 {
return 48
} else if effectiveDimension >= 1024 {
return 24
} else if effectiveDimension >= 512 {
return 18
} else if effectiveDimension >= 256 {
return 12
}
return 10
}
// GetColorPalette returns the color palette for the chart.
func (pc DonutChart) GetColorPalette() ColorPalette {
if pc.ColorPalette != nil {
return pc.ColorPalette
}
return AlternateColorPalette
}
// Box returns the chart bounds as a box.
func (pc DonutChart) Box() Box {
dpr := pc.Background.Padding.GetRight(DefaultBackgroundPadding.Right)
dpb := pc.Background.Padding.GetBottom(DefaultBackgroundPadding.Bottom)
return Box{
Top: pc.Background.Padding.GetTop(DefaultBackgroundPadding.Top),
Left: pc.Background.Padding.GetLeft(DefaultBackgroundPadding.Left),
Right: pc.GetWidth() - dpr,
Bottom: pc.GetHeight() - dpb,
}
}

View file

@ -0,0 +1,69 @@
package chart
import (
"bytes"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestDonutChart(t *testing.T) {
// replaced new assertions helper
pie := DonutChart{
Canvas: Style{
FillColor: ColorLightGray,
},
Values: []Value{
{Value: 10, Label: "Blue"},
{Value: 9, Label: "Green"},
{Value: 8, Label: "Gray"},
{Value: 7, Label: "Orange"},
{Value: 6, Label: "HEANG"},
{Value: 5, Label: "??"},
{Value: 2, Label: "!!"},
},
}
b := bytes.NewBuffer([]byte{})
pie.Render(PNG, b)
testutil.AssertNotZero(t, b.Len())
}
func TestDonutChartDropsZeroValues(t *testing.T) {
// replaced new assertions helper
pie := DonutChart{
Canvas: Style{
FillColor: ColorLightGray,
},
Values: []Value{
{Value: 5, Label: "Blue"},
{Value: 5, Label: "Green"},
{Value: 0, Label: "Gray"},
},
}
b := bytes.NewBuffer([]byte{})
err := pie.Render(PNG, b)
testutil.AssertNil(t, err)
}
func TestDonutChartAllZeroValues(t *testing.T) {
// replaced new assertions helper
pie := DonutChart{
Canvas: Style{
FillColor: ColorLightGray,
},
Values: []Value{
{Value: 0, Label: "Blue"},
{Value: 0, Label: "Green"},
{Value: 0, Label: "Gray"},
},
}
b := bytes.NewBuffer([]byte{})
err := pie.Render(PNG, b)
testutil.AssertNotNil(t, err)
}

325
pkg/chart/draw.go Normal file
View file

@ -0,0 +1,325 @@
package chart
import (
"math"
)
var (
// Draw contains helpers for drawing common objects.
Draw = &draw{}
)
type draw struct{}
// LineSeries draws a line series with a renderer.
func (d draw) LineSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, vs ValuesProvider) {
if vs.Len() == 0 {
return
}
cb := canvasBox.Bottom
cl := canvasBox.Left
v0x, v0y := vs.GetValues(0)
x0 := cl + xrange.Translate(v0x)
y0 := cb - yrange.Translate(v0y)
yv0 := yrange.Translate(0)
var vx, vy float64
var x, y int
if style.ShouldDrawStroke() && style.ShouldDrawFill() {
style.GetFillOptions().WriteDrawingOptionsToRenderer(r)
r.MoveTo(x0, y0)
for i := 1; i < vs.Len(); i++ {
vx, vy = vs.GetValues(i)
x = cl + xrange.Translate(vx)
y = cb - yrange.Translate(vy)
r.LineTo(x, y)
}
r.LineTo(x, MinInt(cb, cb-yv0))
r.LineTo(x0, MinInt(cb, cb-yv0))
r.LineTo(x0, y0)
r.Fill()
}
if style.ShouldDrawStroke() {
style.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
r.MoveTo(x0, y0)
for i := 1; i < vs.Len(); i++ {
vx, vy = vs.GetValues(i)
x = cl + xrange.Translate(vx)
y = cb - yrange.Translate(vy)
r.LineTo(x, y)
}
r.Stroke()
}
if style.ShouldDrawDot() {
defaultDotWidth := style.GetDotWidth()
style.GetDotOptions().WriteDrawingOptionsToRenderer(r)
for i := 0; i < vs.Len(); i++ {
vx, vy = vs.GetValues(i)
x = cl + xrange.Translate(vx)
y = cb - yrange.Translate(vy)
dotWidth := defaultDotWidth
if style.DotWidthProvider != nil {
dotWidth = style.DotWidthProvider(xrange, yrange, i, vx, vy)
}
if style.DotColorProvider != nil {
dotColor := style.DotColorProvider(xrange, yrange, i, vx, vy)
r.SetFillColor(dotColor)
r.SetStrokeColor(dotColor)
}
r.Circle(dotWidth, x, y)
r.FillStroke()
}
}
}
// BoundedSeries draws a series that implements BoundedValuesProvider.
func (d draw) BoundedSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, bbs BoundedValuesProvider, drawOffsetIndexes ...int) {
drawOffsetIndex := 0
if len(drawOffsetIndexes) > 0 {
drawOffsetIndex = drawOffsetIndexes[0]
}
cb := canvasBox.Bottom
cl := canvasBox.Left
v0x, v0y1, v0y2 := bbs.GetBoundedValues(0)
x0 := cl + xrange.Translate(v0x)
y0 := cb - yrange.Translate(v0y1)
var vx, vy1, vy2 float64
var x, y int
xvalues := make([]float64, bbs.Len())
xvalues[0] = v0x
y2values := make([]float64, bbs.Len())
y2values[0] = v0y2
style.GetFillAndStrokeOptions().WriteToRenderer(r)
r.MoveTo(x0, y0)
for i := 1; i < bbs.Len(); i++ {
vx, vy1, vy2 = bbs.GetBoundedValues(i)
xvalues[i] = vx
y2values[i] = vy2
x = cl + xrange.Translate(vx)
y = cb - yrange.Translate(vy1)
if i > drawOffsetIndex {
r.LineTo(x, y)
} else {
r.MoveTo(x, y)
}
}
y = cb - yrange.Translate(vy2)
r.LineTo(x, y)
for i := bbs.Len() - 1; i >= drawOffsetIndex; i-- {
vx, vy2 = xvalues[i], y2values[i]
x = cl + xrange.Translate(vx)
y = cb - yrange.Translate(vy2)
r.LineTo(x, y)
}
r.Close()
r.FillStroke()
}
// HistogramSeries draws a value provider as boxes from 0.
func (d draw) HistogramSeries(r Renderer, canvasBox Box, xrange, yrange Range, style Style, vs ValuesProvider, barWidths ...int) {
if vs.Len() == 0 {
return
}
//calculate bar width?
seriesLength := vs.Len()
barWidth := int(math.Floor(float64(xrange.GetDomain()) / float64(seriesLength)))
if len(barWidths) > 0 {
barWidth = barWidths[0]
}
cb := canvasBox.Bottom
cl := canvasBox.Left
//foreach datapoint, draw a box.
for index := 0; index < seriesLength; index++ {
vx, vy := vs.GetValues(index)
y0 := yrange.Translate(0)
x := cl + xrange.Translate(vx)
y := yrange.Translate(vy)
d.Box(r, Box{
Top: cb - y0,
Left: x - (barWidth >> 1),
Right: x + (barWidth >> 1),
Bottom: cb - y,
}, style)
}
}
// MeasureAnnotation measures how big an annotation would be.
func (d draw) MeasureAnnotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) Box {
style.WriteToRenderer(r)
defer r.ResetStyle()
textBox := r.MeasureText(label)
textWidth := textBox.Width()
textHeight := textBox.Height()
halfTextHeight := textHeight >> 1
pt := style.Padding.GetTop(DefaultAnnotationPadding.Top)
pl := style.Padding.GetLeft(DefaultAnnotationPadding.Left)
pr := style.Padding.GetRight(DefaultAnnotationPadding.Right)
pb := style.Padding.GetBottom(DefaultAnnotationPadding.Bottom)
strokeWidth := style.GetStrokeWidth()
top := ly - (pt + halfTextHeight)
right := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth + int(strokeWidth)
bottom := ly + (pb + halfTextHeight)
return Box{
Top: top,
Left: lx,
Right: right,
Bottom: bottom,
}
}
// Annotation draws an anotation with a renderer.
func (d draw) Annotation(r Renderer, canvasBox Box, style Style, lx, ly int, label string) {
style.GetTextOptions().WriteToRenderer(r)
defer r.ResetStyle()
textBox := r.MeasureText(label)
textWidth := textBox.Width()
halfTextHeight := textBox.Height() >> 1
style.GetFillAndStrokeOptions().WriteToRenderer(r)
pt := style.Padding.GetTop(DefaultAnnotationPadding.Top)
pl := style.Padding.GetLeft(DefaultAnnotationPadding.Left)
pr := style.Padding.GetRight(DefaultAnnotationPadding.Right)
pb := style.Padding.GetBottom(DefaultAnnotationPadding.Bottom)
textX := lx + pl + DefaultAnnotationDeltaWidth
textY := ly + halfTextHeight
ltx := lx + DefaultAnnotationDeltaWidth
lty := ly - (pt + halfTextHeight)
rtx := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth
rty := ly - (pt + halfTextHeight)
rbx := lx + pl + pr + textWidth + DefaultAnnotationDeltaWidth
rby := ly + (pb + halfTextHeight)
lbx := lx + DefaultAnnotationDeltaWidth
lby := ly + (pb + halfTextHeight)
r.MoveTo(lx, ly)
r.LineTo(ltx, lty)
r.LineTo(rtx, rty)
r.LineTo(rbx, rby)
r.LineTo(lbx, lby)
r.LineTo(lx, ly)
r.Close()
r.FillStroke()
style.GetTextOptions().WriteToRenderer(r)
r.Text(label, textX, textY)
}
// Box draws a box with a given style.
func (d draw) Box(r Renderer, b Box, s Style) {
s.GetFillAndStrokeOptions().WriteToRenderer(r)
defer r.ResetStyle()
r.MoveTo(b.Left, b.Top)
r.LineTo(b.Right, b.Top)
r.LineTo(b.Right, b.Bottom)
r.LineTo(b.Left, b.Bottom)
r.LineTo(b.Left, b.Top)
r.FillStroke()
}
func (d draw) BoxRotated(r Renderer, b Box, thetaDegrees float64, s Style) {
d.BoxCorners(r, b.Corners().Rotate(thetaDegrees), s)
}
func (d draw) BoxCorners(r Renderer, bc BoxCorners, s Style) {
s.GetFillAndStrokeOptions().WriteToRenderer(r)
defer r.ResetStyle()
r.MoveTo(bc.TopLeft.X, bc.TopLeft.Y)
r.LineTo(bc.TopRight.X, bc.TopRight.Y)
r.LineTo(bc.BottomRight.X, bc.BottomRight.Y)
r.LineTo(bc.BottomLeft.X, bc.BottomLeft.Y)
r.Close()
r.FillStroke()
}
// DrawText draws text with a given style.
func (d draw) Text(r Renderer, text string, x, y int, style Style) {
style.GetTextOptions().WriteToRenderer(r)
defer r.ResetStyle()
r.Text(text, x, y)
}
func (d draw) MeasureText(r Renderer, text string, style Style) Box {
style.GetTextOptions().WriteToRenderer(r)
defer r.ResetStyle()
return r.MeasureText(text)
}
// TextWithin draws the text within a given box.
func (d draw) TextWithin(r Renderer, text string, box Box, style Style) {
style.GetTextOptions().WriteToRenderer(r)
defer r.ResetStyle()
lines := Text.WrapFit(r, text, box.Width(), style)
linesBox := Text.MeasureLines(r, lines, style)
y := box.Top
switch style.GetTextVerticalAlign() {
case TextVerticalAlignBottom, TextVerticalAlignBaseline: // i have to build better baseline handling into measure text
y = y - linesBox.Height()
case TextVerticalAlignMiddle:
y = y + (box.Height() >> 1) - (linesBox.Height() >> 1)
case TextVerticalAlignMiddleBaseline:
y = y + (box.Height() >> 1) - linesBox.Height()
}
var tx, ty int
for _, line := range lines {
lineBox := r.MeasureText(line)
switch style.GetTextHorizontalAlign() {
case TextHorizontalAlignCenter:
tx = box.Left + ((box.Width() - lineBox.Width()) >> 1)
case TextHorizontalAlignRight:
tx = box.Right - lineBox.Width()
default:
tx = box.Left
}
if style.TextRotationDegrees == 0 {
ty = y + lineBox.Height()
} else {
ty = y
}
r.Text(line, tx, ty)
y += lineBox.Height() + style.GetTextLineSpacing()
}
}

131
pkg/chart/ema_series.go Normal file
View file

@ -0,0 +1,131 @@
package chart
import "fmt"
const (
// DefaultEMAPeriod is the default EMA period used in the sigma calculation.
DefaultEMAPeriod = 12
)
// Interface Assertions.
var (
_ Series = (*EMASeries)(nil)
_ FirstValuesProvider = (*EMASeries)(nil)
_ LastValuesProvider = (*EMASeries)(nil)
)
// EMASeries is a computed series.
type EMASeries struct {
Name string
Style Style
YAxis YAxisType
Period int
InnerSeries ValuesProvider
cache []float64
}
// GetName returns the name of the time series.
func (ema EMASeries) GetName() string {
return ema.Name
}
// GetStyle returns the line style.
func (ema EMASeries) GetStyle() Style {
return ema.Style
}
// GetYAxis returns which YAxis the series draws on.
func (ema EMASeries) GetYAxis() YAxisType {
return ema.YAxis
}
// GetPeriod returns the window size.
func (ema EMASeries) GetPeriod() int {
if ema.Period == 0 {
return DefaultEMAPeriod
}
return ema.Period
}
// Len returns the number of elements in the series.
func (ema EMASeries) Len() int {
return ema.InnerSeries.Len()
}
// GetSigma returns the smoothing factor for the serise.
func (ema EMASeries) GetSigma() float64 {
return 2.0 / (float64(ema.GetPeriod()) + 1)
}
// GetValues gets a value at a given index.
func (ema *EMASeries) GetValues(index int) (x, y float64) {
if ema.InnerSeries == nil {
return
}
if len(ema.cache) == 0 {
ema.ensureCachedValues()
}
vx, _ := ema.InnerSeries.GetValues(index)
x = vx
y = ema.cache[index]
return
}
// GetFirstValues computes the first moving average value.
func (ema *EMASeries) GetFirstValues() (x, y float64) {
if ema.InnerSeries == nil {
return
}
if len(ema.cache) == 0 {
ema.ensureCachedValues()
}
x, _ = ema.InnerSeries.GetValues(0)
y = ema.cache[0]
return
}
// GetLastValues computes the last moving average value but walking back window size samples,
// and recomputing the last moving average chunk.
func (ema *EMASeries) GetLastValues() (x, y float64) {
if ema.InnerSeries == nil {
return
}
if len(ema.cache) == 0 {
ema.ensureCachedValues()
}
lastIndex := ema.InnerSeries.Len() - 1
x, _ = ema.InnerSeries.GetValues(lastIndex)
y = ema.cache[lastIndex]
return
}
func (ema *EMASeries) ensureCachedValues() {
seriesLength := ema.InnerSeries.Len()
ema.cache = make([]float64, seriesLength)
sigma := ema.GetSigma()
for x := 0; x < seriesLength; x++ {
_, y := ema.InnerSeries.GetValues(x)
if x == 0 {
ema.cache[x] = y
continue
}
previousEMA := ema.cache[x-1]
ema.cache[x] = ((y - previousEMA) * sigma) + previousEMA
}
}
// Render renders the series.
func (ema *EMASeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := ema.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, ema)
}
// Validate validates the series.
func (ema *EMASeries) Validate() error {
if ema.InnerSeries == nil {
return fmt.Errorf("ema series requires InnerSeries to be set")
}
return nil
}

View file

@ -0,0 +1,105 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
var (
emaXValues = LinearRange(1.0, 50.0)
emaYValues = []float64{
1, 2, 3, 4, 5, 4, 3, 2,
1, 2, 3, 4, 5, 4, 3, 2,
1, 2, 3, 4, 5, 4, 3, 2,
1, 2, 3, 4, 5, 4, 3, 2,
1, 2, 3, 4, 5, 4, 3, 2,
1, 2, 3, 4, 5, 4, 3, 2,
1, 2,
}
emaExpected = []float64{
1,
1.074074074,
1.216735254,
1.422903013,
1.68787316,
1.859141815,
1.943649828,
1.947823915,
1.877614736,
1.886680311,
1.969148437,
2.119581886,
2.33294619,
2.456431658,
2.496695979,
2.459903685,
2.351762671,
2.325706177,
2.375653867,
2.495975803,
2.681459077,
2.779128775,
2.795489607,
2.73656445,
2.607930047,
2.562898191,
2.595276103,
2.699329725,
2.869749746,
2.953471987,
2.956918506,
2.886035654,
2.746329309,
2.691045657,
2.713931163,
2.809195522,
2.971477335,
3.047664199,
3.044133518,
2.966790294,
2.821102124,
2.760279745,
2.778036801,
2.868552593,
3.026437586,
3.098553321,
3.091253075,
3.010419514,
2.86149955,
2.797684768,
}
emaDelta = 0.0001
)
func TestEMASeries(t *testing.T) {
// replaced new assertions helper
mockSeries := mockValuesProvider{
emaXValues,
emaYValues,
}
testutil.AssertEqual(t, 50, mockSeries.Len())
ema := &EMASeries{
InnerSeries: mockSeries,
Period: 26,
}
sig := ema.GetSigma()
testutil.AssertEqual(t, 2.0/(26.0+1), sig)
var yvalues []float64
for x := 0; x < ema.Len(); x++ {
_, y := ema.GetValues(x)
yvalues = append(yvalues, y)
}
for index, yv := range yvalues {
testutil.AssertInDelta(t, yv, emaExpected[index], emaDelta)
}
lvx, lvy := ema.GetLastValues()
testutil.AssertEqual(t, 50.0, lvx)
testutil.AssertInDelta(t, lvy, emaExpected[49], emaDelta)
}

49
pkg/chart/fileutil.go Normal file
View file

@ -0,0 +1,49 @@
package chart
import (
"bufio"
"io"
"os"
)
// ReadLines reads a file and calls the handler for each line.
func ReadLines(filePath string, handler func(string) error) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
err = handler(line)
if err != nil {
return err
}
}
return nil
}
// ReadChunks reads a file in `chunkSize` pieces, dispatched to the handler.
func ReadChunks(filePath string, chunkSize int, handler func([]byte) error) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
chunk := make([]byte, chunkSize)
for {
readBytes, err := f.Read(chunk)
if err == io.EOF {
break
}
readData := chunk[:readBytes]
err = handler(readData)
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,37 @@
package chart
import "fmt"
// FirstValueAnnotation returns an annotation series of just the first value of a value provider as an annotation.
func FirstValueAnnotation(innerSeries ValuesProvider, vfs ...ValueFormatter) AnnotationSeries {
var vf ValueFormatter
if len(vfs) > 0 {
vf = vfs[0]
} else if typed, isTyped := innerSeries.(ValueFormatterProvider); isTyped {
_, vf = typed.GetValueFormatters()
} else {
vf = FloatValueFormatter
}
var firstValue Value2
if typed, isTyped := innerSeries.(FirstValuesProvider); isTyped {
firstValue.XValue, firstValue.YValue = typed.GetFirstValues()
firstValue.Label = vf(firstValue.YValue)
} else {
firstValue.XValue, firstValue.YValue = innerSeries.GetValues(0)
firstValue.Label = vf(firstValue.YValue)
}
var seriesName string
var seriesStyle Style
if typed, isTyped := innerSeries.(Series); isTyped {
seriesName = fmt.Sprintf("%s - First Value", typed.GetName())
seriesStyle = typed.GetStyle()
}
return AnnotationSeries{
Name: seriesName,
Style: seriesStyle,
Annotations: []Value2{firstValue},
}
}

View file

@ -0,0 +1,22 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestFirstValueAnnotation(t *testing.T) {
// replaced new assertions helper
series := ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{5.0, 3.0, 3.0, 2.0, 1.0},
}
fva := FirstValueAnnotation(series)
testutil.AssertNotEmpty(t, fva.Annotations)
fvaa := fva.Annotations[0]
testutil.AssertEqual(t, 1, fvaa.XValue)
testutil.AssertEqual(t, 5, fvaa.YValue)
}

29
pkg/chart/font.go Normal file
View file

@ -0,0 +1,29 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/roboto"
"sync"
"github.com/golang/freetype/truetype"
)
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.Roboto)
if err != nil {
return nil, err
}
_defaultFont = font
}
}
return _defaultFont, nil
}

72
pkg/chart/grid_line.go Normal file
View file

@ -0,0 +1,72 @@
package chart
// GridLineProvider is a type that provides grid lines.
type GridLineProvider interface {
GetGridLines(ticks []Tick, isVertical bool, majorStyle, minorStyle Style) []GridLine
}
// GridLine is a line on a graph canvas.
type GridLine struct {
IsMinor bool
Style Style
Value float64
}
// Major returns if the gridline is a `major` line.
func (gl GridLine) Major() bool {
return !gl.IsMinor
}
// Minor returns if the gridline is a `minor` line.
func (gl GridLine) Minor() bool {
return gl.IsMinor
}
// Render renders the gridline
func (gl GridLine) Render(r Renderer, canvasBox Box, ra Range, isVertical bool, defaults Style) {
r.SetStrokeColor(gl.Style.GetStrokeColor(defaults.GetStrokeColor()))
r.SetStrokeWidth(gl.Style.GetStrokeWidth(defaults.GetStrokeWidth()))
r.SetStrokeDashArray(gl.Style.GetStrokeDashArray(defaults.GetStrokeDashArray()))
if isVertical {
lineLeft := canvasBox.Left + ra.Translate(gl.Value)
lineBottom := canvasBox.Bottom
lineTop := canvasBox.Top
r.MoveTo(lineLeft, lineBottom)
r.LineTo(lineLeft, lineTop)
r.Stroke()
} else {
lineLeft := canvasBox.Left
lineRight := canvasBox.Right
lineHeight := canvasBox.Bottom - ra.Translate(gl.Value)
r.MoveTo(lineLeft, lineHeight)
r.LineTo(lineRight, lineHeight)
r.Stroke()
}
}
// GenerateGridLines generates grid lines.
func GenerateGridLines(ticks []Tick, majorStyle, minorStyle Style) []GridLine {
var gl []GridLine
isMinor := false
if len(ticks) < 3 {
return gl
}
for _, t := range ticks[1 : len(ticks)-1] {
s := majorStyle
if isMinor {
s = minorStyle
}
gl = append(gl, GridLine{
Style: s,
IsMinor: isMinor,
Value: t.Value,
})
isMinor = !isMinor
}
return gl
}

View file

@ -0,0 +1,24 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestGenerateGridLines(t *testing.T) {
// replaced new assertions helper
ticks := []Tick{
{Value: 1.0, Label: "1.0"},
{Value: 2.0, Label: "2.0"},
{Value: 3.0, Label: "3.0"},
{Value: 4.0, Label: "4.0"},
}
gl := GenerateGridLines(ticks, Style{}, Style{})
testutil.AssertLen(t, gl, 2)
testutil.AssertEqual(t, 2.0, gl[0].Value)
testutil.AssertEqual(t, 3.0, gl[1].Value)
}

View file

@ -0,0 +1,67 @@
package chart
import "fmt"
// HistogramSeries is a special type of series that draws as a histogram.
// Some peculiarities; it will always be lower bounded at 0 (at the very least).
// This may alter ranges a bit and generally you want to put a histogram series on it's own y-axis.
type HistogramSeries struct {
Name string
Style Style
YAxis YAxisType
InnerSeries ValuesProvider
}
// GetName implements Series.GetName.
func (hs HistogramSeries) GetName() string {
return hs.Name
}
// GetStyle implements Series.GetStyle.
func (hs HistogramSeries) GetStyle() Style {
return hs.Style
}
// GetYAxis returns which yaxis the series is mapped to.
func (hs HistogramSeries) GetYAxis() YAxisType {
return hs.YAxis
}
// Len implements BoundedValuesProvider.Len.
func (hs HistogramSeries) Len() int {
return hs.InnerSeries.Len()
}
// GetValues implements ValuesProvider.GetValues.
func (hs HistogramSeries) GetValues(index int) (x, y float64) {
return hs.InnerSeries.GetValues(index)
}
// GetBoundedValues implements BoundedValuesProvider.GetBoundedValue
func (hs HistogramSeries) GetBoundedValues(index int) (x, y1, y2 float64) {
vx, vy := hs.InnerSeries.GetValues(index)
x = vx
if vy > 0 {
y1 = vy
return
}
y2 = vy
return
}
// Render implements Series.Render.
func (hs HistogramSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := hs.Style.InheritFrom(defaults)
Draw.HistogramSeries(r, canvasBox, xrange, yrange, style, hs)
}
// Validate validates the series.
func (hs HistogramSeries) Validate() error {
if hs.InnerSeries == nil {
return fmt.Errorf("histogram series requires InnerSeries to be set")
}
return nil
}

View file

@ -0,0 +1,31 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestHistogramSeries(t *testing.T) {
// replaced new assertions helper
cs := ContinuousSeries{
Name: "Test Series",
XValues: LinearRange(1.0, 20.0),
YValues: LinearRange(10.0, -10.0),
}
hs := HistogramSeries{
InnerSeries: cs,
}
for x := 0; x < hs.Len(); x++ {
csx, csy := cs.GetValues(0)
hsx, hsy1, hsy2 := hs.GetBoundedValues(0)
testutil.AssertEqual(t, csx, hsx)
testutil.AssertTrue(t, hsy1 > 0)
testutil.AssertTrue(t, hsy2 <= 0)
testutil.AssertTrue(t, csy < 0 || (csy > 0 && csy == hsy1))
testutil.AssertTrue(t, csy > 0 || (csy < 0 && csy == hsy2))
}
}

42
pkg/chart/image_writer.go Normal file
View file

@ -0,0 +1,42 @@
package chart
import (
"bytes"
"errors"
"image"
"image/png"
)
// RGBACollector is a render target for a chart.
type RGBACollector interface {
SetRGBA(i *image.RGBA)
}
// ImageWriter is a special type of io.Writer that produces a final image.
type ImageWriter struct {
rgba *image.RGBA
contents *bytes.Buffer
}
func (ir *ImageWriter) Write(buffer []byte) (int, error) {
if ir.contents == nil {
ir.contents = bytes.NewBuffer([]byte{})
}
return ir.contents.Write(buffer)
}
// SetRGBA sets a raw version of the image.
func (ir *ImageWriter) SetRGBA(i *image.RGBA) {
ir.rgba = i
}
// Image returns an *image.Image for the result.
func (ir *ImageWriter) Image() (image.Image, error) {
if ir.rgba != nil {
return ir.rgba, nil
}
if ir.contents != nil && ir.contents.Len() > 0 {
return png.Decode(ir.contents)
}
return nil, errors.New("no valid sources for image data, cannot continue")
}

35
pkg/chart/jet.go Normal file
View file

@ -0,0 +1,35 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
)
// Jet is a color map provider based on matlab's jet color map.
func Jet(v, vmin, vmax float64) drawing.Color {
c := drawing.Color{R: 0xff, G: 0xff, B: 0xff, A: 0xff} // white
var dv float64
if v < vmin {
v = vmin
}
if v > vmax {
v = vmax
}
dv = vmax - vmin
if v < (vmin + 0.25*dv) {
c.R = 0
c.G = drawing.ColorChannelFromFloat(4 * (v - vmin) / dv)
} else if v < (vmin + 0.5*dv) {
c.R = 0
c.B = drawing.ColorChannelFromFloat(1 + 4*(vmin+0.25*dv-v)/dv)
} else if v < (vmin + 0.75*dv) {
c.R = drawing.ColorChannelFromFloat(4 * (v - vmin - 0.5*dv) / dv)
c.B = 0
} else {
c.G = drawing.ColorChannelFromFloat(1 + 4*(vmin+0.75*dv-v)/dv)
c.B = 0
}
return c
}

View file

@ -0,0 +1,37 @@
package chart
import "fmt"
// LastValueAnnotationSeries returns an annotation series of just the last value of a value provider.
func LastValueAnnotationSeries(innerSeries ValuesProvider, vfs ...ValueFormatter) AnnotationSeries {
var vf ValueFormatter
if len(vfs) > 0 {
vf = vfs[0]
} else if typed, isTyped := innerSeries.(ValueFormatterProvider); isTyped {
_, vf = typed.GetValueFormatters()
} else {
vf = FloatValueFormatter
}
var lastValue Value2
if typed, isTyped := innerSeries.(LastValuesProvider); isTyped {
lastValue.XValue, lastValue.YValue = typed.GetLastValues()
lastValue.Label = vf(lastValue.YValue)
} else {
lastValue.XValue, lastValue.YValue = innerSeries.GetValues(innerSeries.Len() - 1)
lastValue.Label = vf(lastValue.YValue)
}
var seriesName string
var seriesStyle Style
if typed, isTyped := innerSeries.(Series); isTyped {
seriesName = fmt.Sprintf("%s - Last Value", typed.GetName())
seriesStyle = typed.GetStyle()
}
return AnnotationSeries{
Name: seriesName,
Style: seriesStyle,
Annotations: []Value2{lastValue},
}
}

View file

@ -0,0 +1,22 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestLastValueAnnotationSeries(t *testing.T) {
// replaced new assertions helper
series := ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{5.0, 3.0, 3.0, 2.0, 1.0},
}
lva := LastValueAnnotationSeries(series)
testutil.AssertNotEmpty(t, lva.Annotations)
lvaa := lva.Annotations[0]
testutil.AssertEqual(t, 5, lvaa.XValue)
testutil.AssertEqual(t, 1, lvaa.YValue)
}

331
pkg/chart/legend.go Normal file
View file

@ -0,0 +1,331 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
)
// Legend returns a legend renderable function.
func Legend(c *Chart, userDefaults ...Style) Renderable {
return func(r Renderer, cb Box, chartDefaults Style) {
legendDefaults := Style{
FillColor: drawing.ColorWhite,
FontColor: DefaultTextColor,
FontSize: 8.0,
StrokeColor: DefaultAxisColor,
StrokeWidth: DefaultAxisLineWidth,
}
var legendStyle Style
if len(userDefaults) > 0 {
legendStyle = userDefaults[0].InheritFrom(chartDefaults.InheritFrom(legendDefaults))
} else {
legendStyle = chartDefaults.InheritFrom(legendDefaults)
}
// DEFAULTS
legendPadding := Box{
Top: 5,
Left: 5,
Right: 5,
Bottom: 5,
}
lineTextGap := 5
lineLengthMinimum := 25
var labels []string
var lines []Style
for index, s := range c.Series {
if !s.GetStyle().Hidden {
if _, isAnnotationSeries := s.(AnnotationSeries); !isAnnotationSeries {
labels = append(labels, s.GetName())
lines = append(lines, s.GetStyle().InheritFrom(c.styleDefaultsSeries(index)))
}
}
}
legend := Box{
Top: cb.Top,
Left: cb.Left,
// bottom and right will be sized by the legend content + relevant padding.
}
legendContent := Box{
Top: legend.Top + legendPadding.Top,
Left: legend.Left + legendPadding.Left,
Right: legend.Left + legendPadding.Left,
Bottom: legend.Top + legendPadding.Top,
}
legendStyle.GetTextOptions().WriteToRenderer(r)
// measure
labelCount := 0
for x := 0; x < len(labels); x++ {
if len(labels[x]) > 0 {
tb := r.MeasureText(labels[x])
if labelCount > 0 {
legendContent.Bottom += DefaultMinimumTickVerticalSpacing
}
legendContent.Bottom += tb.Height()
right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum
legendContent.Right = MaxInt(legendContent.Right, right)
labelCount++
}
}
legend = legend.Grow(legendContent)
legend.Right = legendContent.Right + legendPadding.Right
legend.Bottom = legendContent.Bottom + legendPadding.Bottom
Draw.Box(r, legend, legendStyle)
legendStyle.GetTextOptions().WriteToRenderer(r)
ycursor := legendContent.Top
tx := legendContent.Left
legendCount := 0
var label string
for x := 0; x < len(labels); x++ {
label = labels[x]
if len(label) > 0 {
if legendCount > 0 {
ycursor += DefaultMinimumTickVerticalSpacing
}
tb := r.MeasureText(label)
ty := ycursor + tb.Height()
r.Text(label, tx, ty)
th2 := tb.Height() >> 1
lx := tx + tb.Width() + lineTextGap
ly := ty - th2
lx2 := legendContent.Right - legendPadding.Right
r.SetStrokeColor(lines[x].GetStrokeColor())
r.SetStrokeWidth(lines[x].GetStrokeWidth())
r.SetStrokeDashArray(lines[x].GetStrokeDashArray())
r.MoveTo(lx, ly)
r.LineTo(lx2, ly)
r.Stroke()
ycursor += tb.Height()
legendCount++
}
}
}
}
// LegendThin is a legend that doesn't obscure the chart area.
func LegendThin(c *Chart, userDefaults ...Style) Renderable {
return func(r Renderer, cb Box, chartDefaults Style) {
legendDefaults := Style{
FillColor: drawing.ColorWhite,
FontColor: DefaultTextColor,
FontSize: 8.0,
StrokeColor: DefaultAxisColor,
StrokeWidth: DefaultAxisLineWidth,
Padding: Box{
Top: 2,
Left: 7,
Right: 7,
Bottom: 5,
},
}
var legendStyle Style
if len(userDefaults) > 0 {
legendStyle = userDefaults[0].InheritFrom(chartDefaults.InheritFrom(legendDefaults))
} else {
legendStyle = chartDefaults.InheritFrom(legendDefaults)
}
r.SetFont(legendStyle.GetFont())
r.SetFontColor(legendStyle.GetFontColor())
r.SetFontSize(legendStyle.GetFontSize())
var labels []string
var lines []Style
for index, s := range c.Series {
if !s.GetStyle().Hidden {
if _, isAnnotationSeries := s.(AnnotationSeries); !isAnnotationSeries {
labels = append(labels, s.GetName())
lines = append(lines, s.GetStyle().InheritFrom(c.styleDefaultsSeries(index)))
}
}
}
var textHeight int
var textWidth int
var textBox Box
for x := 0; x < len(labels); x++ {
if len(labels[x]) > 0 {
textBox = r.MeasureText(labels[x])
textHeight = MaxInt(textBox.Height(), textHeight)
textWidth = MaxInt(textBox.Width(), textWidth)
}
}
legendBoxHeight := textHeight + legendStyle.Padding.Top + legendStyle.Padding.Bottom
chartPadding := cb.Top
legendYMargin := (chartPadding - legendBoxHeight) >> 1
legendBox := Box{
Left: cb.Left,
Right: cb.Right,
Top: legendYMargin,
Bottom: legendYMargin + legendBoxHeight,
}
Draw.Box(r, legendBox, legendDefaults)
r.SetFont(legendStyle.GetFont())
r.SetFontColor(legendStyle.GetFontColor())
r.SetFontSize(legendStyle.GetFontSize())
lineTextGap := 5
lineLengthMinimum := 25
tx := legendBox.Left + legendStyle.Padding.Left
ty := legendYMargin + legendStyle.Padding.Top + textHeight
var label string
var lx, ly int
th2 := textHeight >> 1
for index := range labels {
label = labels[index]
if len(label) > 0 {
textBox = r.MeasureText(label)
r.Text(label, tx, ty)
lx = tx + textBox.Width() + lineTextGap
ly = ty - th2
r.SetStrokeColor(lines[index].GetStrokeColor())
r.SetStrokeWidth(lines[index].GetStrokeWidth())
r.SetStrokeDashArray(lines[index].GetStrokeDashArray())
r.MoveTo(lx, ly)
r.LineTo(lx+lineLengthMinimum, ly)
r.Stroke()
tx += textBox.Width() + DefaultMinimumTickHorizontalSpacing + lineTextGap + lineLengthMinimum
}
}
}
}
// LegendLeft is a legend that is designed for longer series lists.
func LegendLeft(c *Chart, userDefaults ...Style) Renderable {
return func(r Renderer, cb Box, chartDefaults Style) {
legendDefaults := Style{
FillColor: drawing.ColorWhite,
FontColor: DefaultTextColor,
FontSize: 8.0,
StrokeColor: DefaultAxisColor,
StrokeWidth: DefaultAxisLineWidth,
}
var legendStyle Style
if len(userDefaults) > 0 {
legendStyle = userDefaults[0].InheritFrom(chartDefaults.InheritFrom(legendDefaults))
} else {
legendStyle = chartDefaults.InheritFrom(legendDefaults)
}
// DEFAULTS
legendPadding := Box{
Top: 5,
Left: 5,
Right: 5,
Bottom: 5,
}
lineTextGap := 5
lineLengthMinimum := 25
var labels []string
var lines []Style
for index, s := range c.Series {
if !s.GetStyle().Hidden {
if _, isAnnotationSeries := s.(AnnotationSeries); !isAnnotationSeries {
labels = append(labels, s.GetName())
lines = append(lines, s.GetStyle().InheritFrom(c.styleDefaultsSeries(index)))
}
}
}
legend := Box{
Top: 5,
Left: 5,
// bottom and right will be sized by the legend content + relevant padding.
}
legendContent := Box{
Top: legend.Top + legendPadding.Top,
Left: legend.Left + legendPadding.Left,
Right: legend.Left + legendPadding.Left,
Bottom: legend.Top + legendPadding.Top,
}
legendStyle.GetTextOptions().WriteToRenderer(r)
// measure
labelCount := 0
for x := 0; x < len(labels); x++ {
if len(labels[x]) > 0 {
tb := r.MeasureText(labels[x])
if labelCount > 0 {
legendContent.Bottom += DefaultMinimumTickVerticalSpacing
}
legendContent.Bottom += tb.Height()
right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum
legendContent.Right = MaxInt(legendContent.Right, right)
labelCount++
}
}
legend = legend.Grow(legendContent)
legend.Right = legendContent.Right + legendPadding.Right
legend.Bottom = legendContent.Bottom + legendPadding.Bottom
Draw.Box(r, legend, legendStyle)
legendStyle.GetTextOptions().WriteToRenderer(r)
ycursor := legendContent.Top
tx := legendContent.Left
legendCount := 0
var label string
for x := 0; x < len(labels); x++ {
label = labels[x]
if len(label) > 0 {
if legendCount > 0 {
ycursor += DefaultMinimumTickVerticalSpacing
}
tb := r.MeasureText(label)
ty := ycursor + tb.Height()
r.Text(label, tx, ty)
th2 := tb.Height() >> 1
lx := tx + tb.Width() + lineTextGap
ly := ty - th2
lx2 := legendContent.Right - legendPadding.Right
r.SetStrokeColor(lines[x].GetStrokeColor())
r.SetStrokeWidth(lines[x].GetStrokeWidth())
r.SetStrokeDashArray(lines[x].GetStrokeDashArray())
r.MoveTo(lx, ly)
r.LineTo(lx2, ly)
r.Stroke()
ycursor += tb.Height()
legendCount++
}
}
}
}

31
pkg/chart/legend_test.go Normal file
View file

@ -0,0 +1,31 @@
package chart
import (
"bytes"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestLegend(t *testing.T) {
// replaced new assertions helper
graph := Chart{
Series: []Series{
ContinuousSeries{
Name: "A test series",
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
},
}
//note we have to do this as a separate step because we need a reference to graph
graph.Elements = []Renderable{
Legend(&graph),
}
buf := bytes.NewBuffer([]byte{})
err := graph.Render(PNG, buf)
testutil.AssertNil(t, err)
testutil.AssertNotZero(t, buf.Len())
}

View file

@ -0,0 +1,42 @@
package chart
// LinearCoefficientProvider is a type that returns linear cofficients.
type LinearCoefficientProvider interface {
Coefficients() (m, b, stdev, avg float64)
}
// LinearCoefficients returns a fixed linear coefficient pair.
func LinearCoefficients(m, b float64) LinearCoefficientSet {
return LinearCoefficientSet{
M: m,
B: b,
}
}
// NormalizedLinearCoefficients returns a fixed linear coefficient pair.
func NormalizedLinearCoefficients(m, b, stdev, avg float64) LinearCoefficientSet {
return LinearCoefficientSet{
M: m,
B: b,
StdDev: stdev,
Avg: avg,
}
}
// LinearCoefficientSet is the m and b values for the linear equation in the form:
// y = (m*x) + b
type LinearCoefficientSet struct {
M float64
B float64
StdDev float64
Avg float64
}
// Coefficients returns the coefficients.
func (lcs LinearCoefficientSet) Coefficients() (m, b, stdev, avg float64) {
m = lcs.M
b = lcs.B
stdev = lcs.StdDev
avg = lcs.Avg
return
}

View file

@ -0,0 +1,187 @@
package chart
import (
"fmt"
)
// Interface Assertions.
var (
_ Series = (*LinearRegressionSeries)(nil)
_ FirstValuesProvider = (*LinearRegressionSeries)(nil)
_ LastValuesProvider = (*LinearRegressionSeries)(nil)
_ LinearCoefficientProvider = (*LinearRegressionSeries)(nil)
)
// LinearRegressionSeries is a series that plots the n-nearest neighbors
// linear regression for the values.
type LinearRegressionSeries struct {
Name string
Style Style
YAxis YAxisType
Limit int
Offset int
InnerSeries ValuesProvider
m float64
b float64
avgx float64
stddevx float64
}
// Coefficients returns the linear coefficients for the series.
func (lrs LinearRegressionSeries) Coefficients() (m, b, stdev, avg float64) {
if lrs.IsZero() {
lrs.computeCoefficients()
}
m = lrs.m
b = lrs.b
stdev = lrs.stddevx
avg = lrs.avgx
return
}
// GetName returns the name of the time series.
func (lrs LinearRegressionSeries) GetName() string {
return lrs.Name
}
// GetStyle returns the line style.
func (lrs LinearRegressionSeries) GetStyle() Style {
return lrs.Style
}
// GetYAxis returns which YAxis the series draws on.
func (lrs LinearRegressionSeries) GetYAxis() YAxisType {
return lrs.YAxis
}
// Len returns the number of elements in the series.
func (lrs LinearRegressionSeries) Len() int {
return MinInt(lrs.GetLimit(), lrs.InnerSeries.Len()-lrs.GetOffset())
}
// GetLimit returns the window size.
func (lrs LinearRegressionSeries) GetLimit() int {
if lrs.Limit == 0 {
return lrs.InnerSeries.Len()
}
return lrs.Limit
}
// GetEndIndex returns the effective limit end.
func (lrs LinearRegressionSeries) GetEndIndex() int {
windowEnd := lrs.GetOffset() + lrs.GetLimit()
innerSeriesLastIndex := lrs.InnerSeries.Len() - 1
return MinInt(windowEnd, innerSeriesLastIndex)
}
// GetOffset returns the data offset.
func (lrs LinearRegressionSeries) GetOffset() int {
if lrs.Offset == 0 {
return 0
}
return lrs.Offset
}
// GetValues gets a value at a given index.
func (lrs *LinearRegressionSeries) GetValues(index int) (x, y float64) {
if lrs.InnerSeries == nil || lrs.InnerSeries.Len() == 0 {
return
}
if lrs.IsZero() {
lrs.computeCoefficients()
}
offset := lrs.GetOffset()
effectiveIndex := MinInt(index+offset, lrs.InnerSeries.Len())
x, y = lrs.InnerSeries.GetValues(effectiveIndex)
y = (lrs.m * lrs.normalize(x)) + lrs.b
return
}
// GetFirstValues computes the first linear regression value.
func (lrs *LinearRegressionSeries) GetFirstValues() (x, y float64) {
if lrs.InnerSeries == nil || lrs.InnerSeries.Len() == 0 {
return
}
if lrs.IsZero() {
lrs.computeCoefficients()
}
x, y = lrs.InnerSeries.GetValues(0)
y = (lrs.m * lrs.normalize(x)) + lrs.b
return
}
// GetLastValues computes the last linear regression value.
func (lrs *LinearRegressionSeries) GetLastValues() (x, y float64) {
if lrs.InnerSeries == nil || lrs.InnerSeries.Len() == 0 {
return
}
if lrs.IsZero() {
lrs.computeCoefficients()
}
endIndex := lrs.GetEndIndex()
x, y = lrs.InnerSeries.GetValues(endIndex)
y = (lrs.m * lrs.normalize(x)) + lrs.b
return
}
// Render renders the series.
func (lrs *LinearRegressionSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := lrs.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, lrs)
}
// Validate validates the series.
func (lrs *LinearRegressionSeries) Validate() error {
if lrs.InnerSeries == nil {
return fmt.Errorf("linear regression series requires InnerSeries to be set")
}
return nil
}
// IsZero returns if we've computed the coefficients or not.
func (lrs *LinearRegressionSeries) IsZero() bool {
return lrs.m == 0 && lrs.b == 0
}
//
// internal helpers
//
func (lrs *LinearRegressionSeries) normalize(xvalue float64) float64 {
return (xvalue - lrs.avgx) / lrs.stddevx
}
// computeCoefficients computes the `m` and `b` terms in the linear formula given by `y = mx+b`.
func (lrs *LinearRegressionSeries) computeCoefficients() {
startIndex := lrs.GetOffset()
endIndex := lrs.GetEndIndex()
p := float64(endIndex - startIndex)
xvalues := NewValueBufferWithCapacity(lrs.Len())
for index := startIndex; index < endIndex; index++ {
x, _ := lrs.InnerSeries.GetValues(index)
xvalues.Enqueue(x)
}
lrs.avgx = Seq{xvalues}.Average()
lrs.stddevx = Seq{xvalues}.StdDev()
var sumx, sumy, sumxx, sumxy float64
for index := startIndex; index < endIndex; index++ {
x, y := lrs.InnerSeries.GetValues(index)
x = lrs.normalize(x)
sumx += x
sumy += y
sumxx += x * x
sumxy += x * y
}
lrs.m = (p*sumxy - sumx*sumy) / (p*sumxx - sumx*sumx)
lrs.b = (sumy / p) - (lrs.m * sumx / p)
}

View file

@ -0,0 +1,77 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestLinearRegressionSeries(t *testing.T) {
// replaced new assertions helper
mainSeries := ContinuousSeries{
Name: "A test series",
XValues: LinearRange(1.0, 100.0),
YValues: LinearRange(1.0, 100.0),
}
linRegSeries := &LinearRegressionSeries{
InnerSeries: mainSeries,
}
lrx0, lry0 := linRegSeries.GetValues(0)
testutil.AssertInDelta(t, 1.0, lrx0, 0.0000001)
testutil.AssertInDelta(t, 1.0, lry0, 0.0000001)
lrxn, lryn := linRegSeries.GetLastValues()
testutil.AssertInDelta(t, 100.0, lrxn, 0.0000001)
testutil.AssertInDelta(t, 100.0, lryn, 0.0000001)
}
func TestLinearRegressionSeriesDesc(t *testing.T) {
// replaced new assertions helper
mainSeries := ContinuousSeries{
Name: "A test series",
XValues: LinearRange(100.0, 1.0),
YValues: LinearRange(100.0, 1.0),
}
linRegSeries := &LinearRegressionSeries{
InnerSeries: mainSeries,
}
lrx0, lry0 := linRegSeries.GetValues(0)
testutil.AssertInDelta(t, 100.0, lrx0, 0.0000001)
testutil.AssertInDelta(t, 100.0, lry0, 0.0000001)
lrxn, lryn := linRegSeries.GetLastValues()
testutil.AssertInDelta(t, 1.0, lrxn, 0.0000001)
testutil.AssertInDelta(t, 1.0, lryn, 0.0000001)
}
func TestLinearRegressionSeriesWindowAndOffset(t *testing.T) {
// replaced new assertions helper
mainSeries := ContinuousSeries{
Name: "A test series",
XValues: LinearRange(100.0, 1.0),
YValues: LinearRange(100.0, 1.0),
}
linRegSeries := &LinearRegressionSeries{
InnerSeries: mainSeries,
Offset: 10,
Limit: 10,
}
testutil.AssertEqual(t, 10, linRegSeries.Len())
lrx0, lry0 := linRegSeries.GetValues(0)
testutil.AssertInDelta(t, 90.0, lrx0, 0.0000001)
testutil.AssertInDelta(t, 90.0, lry0, 0.0000001)
lrxn, lryn := linRegSeries.GetLastValues()
testutil.AssertInDelta(t, 80.0, lrxn, 0.0000001)
testutil.AssertInDelta(t, 80.0, lryn, 0.0000001)
}

View file

@ -0,0 +1,73 @@
package chart
// LinearRange returns an array of values representing the range from start to end, incremented by 1.0.
func LinearRange(start, end float64) []float64 {
return Seq{NewLinearSequence().WithStart(start).WithEnd(end).WithStep(1.0)}.Values()
}
// LinearRangeWithStep returns the array values of a linear seq with a given start, end and optional step.
func LinearRangeWithStep(start, end, step float64) []float64 {
return Seq{NewLinearSequence().WithStart(start).WithEnd(end).WithStep(step)}.Values()
}
// NewLinearSequence returns a new linear generator.
func NewLinearSequence() *LinearSeq {
return &LinearSeq{step: 1.0}
}
// LinearSeq is a stepwise generator.
type LinearSeq struct {
start float64
end float64
step float64
}
// Start returns the start value.
func (lg LinearSeq) Start() float64 {
return lg.start
}
// End returns the end value.
func (lg LinearSeq) End() float64 {
return lg.end
}
// Step returns the step value.
func (lg LinearSeq) Step() float64 {
return lg.step
}
// Len returns the number of elements in the seq.
func (lg LinearSeq) Len() int {
if lg.start < lg.end {
return int((lg.end-lg.start)/lg.step) + 1
}
return int((lg.start-lg.end)/lg.step) + 1
}
// GetValue returns the value at a given index.
func (lg LinearSeq) GetValue(index int) float64 {
fi := float64(index)
if lg.start < lg.end {
return lg.start + (fi * lg.step)
}
return lg.start - (fi * lg.step)
}
// WithStart sets the start and returns the linear generator.
func (lg *LinearSeq) WithStart(start float64) *LinearSeq {
lg.start = start
return lg
}
// WithEnd sets the end and returns the linear generator.
func (lg *LinearSeq) WithEnd(end float64) *LinearSeq {
lg.end = end
return lg
}
// WithStep sets the step and returns the linear generator.
func (lg *LinearSeq) WithStep(step float64) *LinearSeq {
lg.step = step
return lg
}

119
pkg/chart/linear_series.go Normal file
View file

@ -0,0 +1,119 @@
package chart
import (
"fmt"
)
// Interface Assertions.
var (
_ Series = (*LinearSeries)(nil)
_ FirstValuesProvider = (*LinearSeries)(nil)
_ LastValuesProvider = (*LinearSeries)(nil)
)
// LinearSeries is a series that plots a line in a given domain.
type LinearSeries struct {
Name string
Style Style
YAxis YAxisType
XValues []float64
InnerSeries LinearCoefficientProvider
m float64
b float64
stdev float64
avg float64
}
// GetName returns the name of the time series.
func (ls LinearSeries) GetName() string {
return ls.Name
}
// GetStyle returns the line style.
func (ls LinearSeries) GetStyle() Style {
return ls.Style
}
// GetYAxis returns which YAxis the series draws on.
func (ls LinearSeries) GetYAxis() YAxisType {
return ls.YAxis
}
// Len returns the number of elements in the series.
func (ls LinearSeries) Len() int {
return len(ls.XValues)
}
// GetEndIndex returns the effective limit end.
func (ls LinearSeries) GetEndIndex() int {
return len(ls.XValues) - 1
}
// GetValues gets a value at a given index.
func (ls *LinearSeries) GetValues(index int) (x, y float64) {
if ls.InnerSeries == nil || len(ls.XValues) == 0 {
return
}
if ls.IsZero() {
ls.computeCoefficients()
}
x = ls.XValues[index]
y = (ls.m * ls.normalize(x)) + ls.b
return
}
// GetFirstValues computes the first linear regression value.
func (ls *LinearSeries) GetFirstValues() (x, y float64) {
if ls.InnerSeries == nil || len(ls.XValues) == 0 {
return
}
if ls.IsZero() {
ls.computeCoefficients()
}
x, y = ls.GetValues(0)
return
}
// GetLastValues computes the last linear regression value.
func (ls *LinearSeries) GetLastValues() (x, y float64) {
if ls.InnerSeries == nil || len(ls.XValues) == 0 {
return
}
if ls.IsZero() {
ls.computeCoefficients()
}
x, y = ls.GetValues(ls.GetEndIndex())
return
}
// Render renders the series.
func (ls *LinearSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
Draw.LineSeries(r, canvasBox, xrange, yrange, ls.Style.InheritFrom(defaults), ls)
}
// Validate validates the series.
func (ls LinearSeries) Validate() error {
if ls.InnerSeries == nil {
return fmt.Errorf("linear regression series requires InnerSeries to be set")
}
return nil
}
// IsZero returns if the linear series has computed coefficients or not.
func (ls LinearSeries) IsZero() bool {
return ls.m == 0 && ls.b == 0
}
// computeCoefficients computes the `m` and `b` terms in the linear formula given by `y = mx+b`.
func (ls *LinearSeries) computeCoefficients() {
ls.m, ls.b, ls.stdev, ls.avg = ls.InnerSeries.Coefficients()
}
func (ls *LinearSeries) normalize(xvalue float64) float64 {
if ls.avg > 0 && ls.stdev > 0 {
return (xvalue - ls.avg) / ls.stdev
}
return xvalue
}

148
pkg/chart/logger.go Normal file
View file

@ -0,0 +1,148 @@
package chart
import (
"fmt"
"io"
"os"
"time"
)
var (
_ Logger = (*StdoutLogger)(nil)
)
// NewLogger returns a new logger.
func NewLogger(options ...LoggerOption) Logger {
stl := &StdoutLogger{
TimeFormat: time.RFC3339Nano,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
for _, option := range options {
option(stl)
}
return stl
}
// Logger is a type that implements the logging interface.
type Logger interface {
Info(...interface{})
Infof(string, ...interface{})
Debug(...interface{})
Debugf(string, ...interface{})
Err(error)
FatalErr(error)
Error(...interface{})
Errorf(string, ...interface{})
}
// Info logs an info message if the logger is set.
func Info(log Logger, arguments ...interface{}) {
if log == nil {
return
}
log.Info(arguments...)
}
// Infof logs an info message if the logger is set.
func Infof(log Logger, format string, arguments ...interface{}) {
if log == nil {
return
}
log.Infof(format, arguments...)
}
// Debug logs an debug message if the logger is set.
func Debug(log Logger, arguments ...interface{}) {
if log == nil {
return
}
log.Debug(arguments...)
}
// Debugf logs an debug message if the logger is set.
func Debugf(log Logger, format string, arguments ...interface{}) {
if log == nil {
return
}
log.Debugf(format, arguments...)
}
// LoggerOption mutates a stdout logger.
type LoggerOption = func(*StdoutLogger)
//OptLoggerStdout sets the Stdout writer.
func OptLoggerStdout(wr io.Writer) LoggerOption {
return func(stl *StdoutLogger) {
stl.Stdout = wr
}
}
// OptLoggerStderr sets the Stdout writer.
func OptLoggerStderr(wr io.Writer) LoggerOption {
return func(stl *StdoutLogger) {
stl.Stderr = wr
}
}
// StdoutLogger is a basic logger.
type StdoutLogger struct {
TimeFormat string
Stdout io.Writer
Stderr io.Writer
}
// Info writes an info message.
func (l *StdoutLogger) Info(arguments ...interface{}) {
l.Println(append([]interface{}{"[INFO]"}, arguments...)...)
}
// Infof writes an info message.
func (l *StdoutLogger) Infof(format string, arguments ...interface{}) {
l.Println(append([]interface{}{"[INFO]"}, fmt.Sprintf(format, arguments...))...)
}
// Debug writes an debug message.
func (l *StdoutLogger) Debug(arguments ...interface{}) {
l.Println(append([]interface{}{"[DEBUG]"}, arguments...)...)
}
// Debugf writes an debug message.
func (l *StdoutLogger) Debugf(format string, arguments ...interface{}) {
l.Println(append([]interface{}{"[DEBUG]"}, fmt.Sprintf(format, arguments...))...)
}
// Error writes an error message.
func (l *StdoutLogger) Error(arguments ...interface{}) {
l.Println(append([]interface{}{"[ERROR]"}, arguments...)...)
}
// Errorf writes an error message.
func (l *StdoutLogger) Errorf(format string, arguments ...interface{}) {
l.Println(append([]interface{}{"[ERROR]"}, fmt.Sprintf(format, arguments...))...)
}
// Err writes an error message.
func (l *StdoutLogger) Err(err error) {
if err != nil {
l.Println(append([]interface{}{"[ERROR]"}, err.Error())...)
}
}
// FatalErr writes an error message and exits.
func (l *StdoutLogger) FatalErr(err error) {
if err != nil {
l.Println(append([]interface{}{"[FATAL]"}, err.Error())...)
os.Exit(1)
}
}
// Println prints a new message.
func (l *StdoutLogger) Println(arguments ...interface{}) {
fmt.Fprintln(l.Stdout, append([]interface{}{time.Now().UTC().Format(l.TimeFormat)}, arguments...)...)
}
// Errorln prints a new message.
func (l *StdoutLogger) Errorln(arguments ...interface{}) {
fmt.Fprintln(l.Stderr, append([]interface{}{time.Now().UTC().Format(l.TimeFormat)}, arguments...)...)
}

338
pkg/chart/macd_series.go Normal file
View file

@ -0,0 +1,338 @@
package chart
import "fmt"
const (
// DefaultMACDPeriodPrimary is the long window.
DefaultMACDPeriodPrimary = 26
// DefaultMACDPeriodSecondary is the short window.
DefaultMACDPeriodSecondary = 12
// DefaultMACDSignalPeriod is the signal period to compute for the MACD.
DefaultMACDSignalPeriod = 9
)
// MACDSeries computes the difference between the MACD line and the MACD Signal line.
// It is used in technical analysis and gives a lagging indicator of momentum.
type MACDSeries struct {
Name string
Style Style
YAxis YAxisType
InnerSeries ValuesProvider
PrimaryPeriod int
SecondaryPeriod int
SignalPeriod int
signal *MACDSignalSeries
macdl *MACDLineSeries
}
// Validate validates the series.
func (macd MACDSeries) Validate() error {
var err error
if macd.signal != nil {
err = macd.signal.Validate()
}
if err != nil {
return err
}
if macd.macdl != nil {
err = macd.macdl.Validate()
}
if err != nil {
return err
}
return nil
}
// GetPeriods returns the primary and secondary periods.
func (macd MACDSeries) GetPeriods() (w1, w2, sig int) {
if macd.PrimaryPeriod == 0 {
w1 = DefaultMACDPeriodPrimary
} else {
w1 = macd.PrimaryPeriod
}
if macd.SecondaryPeriod == 0 {
w2 = DefaultMACDPeriodSecondary
} else {
w2 = macd.SecondaryPeriod
}
if macd.SignalPeriod == 0 {
sig = DefaultMACDSignalPeriod
} else {
sig = macd.SignalPeriod
}
return
}
// GetName returns the name of the time series.
func (macd MACDSeries) GetName() string {
return macd.Name
}
// GetStyle returns the line style.
func (macd MACDSeries) GetStyle() Style {
return macd.Style
}
// GetYAxis returns which YAxis the series draws on.
func (macd MACDSeries) GetYAxis() YAxisType {
return macd.YAxis
}
// Len returns the number of elements in the series.
func (macd MACDSeries) Len() int {
if macd.InnerSeries == nil {
return 0
}
return macd.InnerSeries.Len()
}
// GetValues gets a value at a given index. For MACD it is the signal value.
func (macd *MACDSeries) GetValues(index int) (x float64, y float64) {
if macd.InnerSeries == nil {
return
}
if macd.signal == nil || macd.macdl == nil {
macd.ensureChildSeries()
}
_, lv := macd.macdl.GetValues(index)
_, sv := macd.signal.GetValues(index)
x, _ = macd.InnerSeries.GetValues(index)
y = lv - sv
return
}
func (macd *MACDSeries) ensureChildSeries() {
w1, w2, sig := macd.GetPeriods()
macd.signal = &MACDSignalSeries{
InnerSeries: macd.InnerSeries,
PrimaryPeriod: w1,
SecondaryPeriod: w2,
SignalPeriod: sig,
}
macd.macdl = &MACDLineSeries{
InnerSeries: macd.InnerSeries,
PrimaryPeriod: w1,
SecondaryPeriod: w2,
}
}
// MACDSignalSeries computes the EMA of the MACDLineSeries.
type MACDSignalSeries struct {
Name string
Style Style
YAxis YAxisType
InnerSeries ValuesProvider
PrimaryPeriod int
SecondaryPeriod int
SignalPeriod int
signal *EMASeries
}
// Validate validates the series.
func (macds MACDSignalSeries) Validate() error {
if macds.signal != nil {
return macds.signal.Validate()
}
return nil
}
// GetPeriods returns the primary and secondary periods.
func (macds MACDSignalSeries) GetPeriods() (w1, w2, sig int) {
if macds.PrimaryPeriod == 0 {
w1 = DefaultMACDPeriodPrimary
} else {
w1 = macds.PrimaryPeriod
}
if macds.SecondaryPeriod == 0 {
w2 = DefaultMACDPeriodSecondary
} else {
w2 = macds.SecondaryPeriod
}
if macds.SignalPeriod == 0 {
sig = DefaultMACDSignalPeriod
} else {
sig = macds.SignalPeriod
}
return
}
// GetName returns the name of the time series.
func (macds MACDSignalSeries) GetName() string {
return macds.Name
}
// GetStyle returns the line style.
func (macds MACDSignalSeries) GetStyle() Style {
return macds.Style
}
// GetYAxis returns which YAxis the series draws on.
func (macds MACDSignalSeries) GetYAxis() YAxisType {
return macds.YAxis
}
// Len returns the number of elements in the series.
func (macds *MACDSignalSeries) Len() int {
if macds.InnerSeries == nil {
return 0
}
return macds.InnerSeries.Len()
}
// GetValues gets a value at a given index. For MACD it is the signal value.
func (macds *MACDSignalSeries) GetValues(index int) (x float64, y float64) {
if macds.InnerSeries == nil {
return
}
if macds.signal == nil {
macds.ensureSignal()
}
x, _ = macds.InnerSeries.GetValues(index)
_, y = macds.signal.GetValues(index)
return
}
func (macds *MACDSignalSeries) ensureSignal() {
w1, w2, sig := macds.GetPeriods()
macds.signal = &EMASeries{
InnerSeries: &MACDLineSeries{
InnerSeries: macds.InnerSeries,
PrimaryPeriod: w1,
SecondaryPeriod: w2,
},
Period: sig,
}
}
// Render renders the series.
func (macds *MACDSignalSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := macds.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, macds)
}
// MACDLineSeries is a series that computes the inner ema1-ema2 value as a series.
type MACDLineSeries struct {
Name string
Style Style
YAxis YAxisType
InnerSeries ValuesProvider
PrimaryPeriod int
SecondaryPeriod int
ema1 *EMASeries
ema2 *EMASeries
Sigma float64
}
// Validate validates the series.
func (macdl MACDLineSeries) Validate() error {
var err error
if macdl.ema1 != nil {
err = macdl.ema1.Validate()
}
if err != nil {
return err
}
if macdl.ema2 != nil {
err = macdl.ema2.Validate()
}
if err != nil {
return err
}
if macdl.InnerSeries == nil {
return fmt.Errorf("MACDLineSeries: must provide an inner series")
}
return nil
}
// GetName returns the name of the time series.
func (macdl MACDLineSeries) GetName() string {
return macdl.Name
}
// GetStyle returns the line style.
func (macdl MACDLineSeries) GetStyle() Style {
return macdl.Style
}
// GetYAxis returns which YAxis the series draws on.
func (macdl MACDLineSeries) GetYAxis() YAxisType {
return macdl.YAxis
}
// GetPeriods returns the primary and secondary periods.
func (macdl MACDLineSeries) GetPeriods() (w1, w2 int) {
if macdl.PrimaryPeriod == 0 {
w1 = DefaultMACDPeriodPrimary
} else {
w1 = macdl.PrimaryPeriod
}
if macdl.SecondaryPeriod == 0 {
w2 = DefaultMACDPeriodSecondary
} else {
w2 = macdl.SecondaryPeriod
}
return
}
// Len returns the number of elements in the series.
func (macdl *MACDLineSeries) Len() int {
if macdl.InnerSeries == nil {
return 0
}
return macdl.InnerSeries.Len()
}
// GetValues gets a value at a given index. For MACD it is the signal value.
func (macdl *MACDLineSeries) GetValues(index int) (x float64, y float64) {
if macdl.InnerSeries == nil {
return
}
if macdl.ema1 == nil && macdl.ema2 == nil {
macdl.ensureEMASeries()
}
x, _ = macdl.InnerSeries.GetValues(index)
_, emav1 := macdl.ema1.GetValues(index)
_, emav2 := macdl.ema2.GetValues(index)
y = emav2 - emav1
return
}
func (macdl *MACDLineSeries) ensureEMASeries() {
w1, w2 := macdl.GetPeriods()
macdl.ema1 = &EMASeries{
InnerSeries: macdl.InnerSeries,
Period: w1,
}
macdl.ema2 = &EMASeries{
InnerSeries: macdl.InnerSeries,
Period: w2,
}
}
// Render renders the series.
func (macdl *MACDLineSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := macdl.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, macdl)
}

View file

@ -0,0 +1,88 @@
package chart
import (
"fmt"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
var (
macdExpected = []float64{
0,
0.06381766382,
0.1641441222,
0.2817201894,
0.4033023481,
0.3924673744,
0.2983093823,
0.1561821464,
-0.008916708129,
-0.05210332292,
-0.01649503993,
0.06667130899,
0.1751344574,
0.1657328378,
0.08257097469,
-0.04265109369,
-0.1875741257,
-0.2091853882,
-0.1518975486,
-0.04781419838,
0.08025242841,
0.08881960494,
0.02183529775,
-0.08904155476,
-0.2214141128,
-0.2321805992,
-0.1656331722,
-0.05373789678,
0.08083727586,
0.09475354363,
0.03209767112,
-0.07534076818,
-0.2050442354,
-0.2138010557,
-0.1458045181,
-0.03293263556,
0.1022243734,
0.1163957964,
0.05372761902,
-0.05393941791,
-0.1840438454,
-0.1933365048,
-0.1259788988,
-0.01382225715,
0.1205656194,
0.1339326478,
0.07044017167,
-0.03805851969,
-0.1689918111,
-0.1791024416,
}
)
func TestMACDSeries(t *testing.T) {
// replaced new assertions helper
mockSeries := mockValuesProvider{
emaXValues,
emaYValues,
}
testutil.AssertEqual(t, 50, mockSeries.Len())
mas := &MACDSeries{
InnerSeries: mockSeries,
}
var yvalues []float64
for x := 0; x < mas.Len(); x++ {
_, y := mas.GetValues(x)
yvalues = append(yvalues, y)
}
testutil.AssertNotEmpty(t, yvalues)
for index, vy := range yvalues {
testutil.AssertInDelta(t, vy, macdExpected[index], emaDelta, fmt.Sprintf("delta @ %d actual: %0.9f expected: %0.9f", index, vy, macdExpected[index]))
}
}

252
pkg/chart/mathutil.go Normal file
View file

@ -0,0 +1,252 @@
package chart
import "math"
const (
_pi = math.Pi
_2pi = 2 * math.Pi
_3pi4 = (3 * math.Pi) / 4.0
_4pi3 = (4 * math.Pi) / 3.0
_3pi2 = (3 * math.Pi) / 2.0
_5pi4 = (5 * math.Pi) / 4.0
_7pi4 = (7 * math.Pi) / 4.0
_pi2 = math.Pi / 2.0
_pi4 = math.Pi / 4.0
_d2r = (math.Pi / 180.0)
_r2d = (180.0 / math.Pi)
)
// MinMax returns the minimum and maximum of a given set of values.
func MinMax(values ...float64) (min, max float64) {
if len(values) == 0 {
return
}
max = values[0]
min = values[0]
var value float64
for index := 1; index < len(values); index++ {
value = values[index]
if value < min {
min = value
}
if value > max {
max = value
}
}
return
}
// MinInt returns the minimum int.
func MinInt(values ...int) (min int) {
if len(values) == 0 {
return
}
min = values[0]
var value int
for index := 1; index < len(values); index++ {
value = values[index]
if value < min {
min = value
}
}
return
}
// MaxInt returns the maximum int.
func MaxInt(values ...int) (max int) {
if len(values) == 0 {
return
}
max = values[0]
var value int
for index := 1; index < len(values); index++ {
value = values[index]
if value > max {
max = value
}
}
return
}
// AbsInt returns the absolute value of an int.
func AbsInt(value int) int {
if value < 0 {
return -value
}
return value
}
// DegreesToRadians returns degrees as radians.
func DegreesToRadians(degrees float64) float64 {
return degrees * _d2r
}
// RadiansToDegrees translates a radian value to a degree value.
func RadiansToDegrees(value float64) float64 {
return math.Mod(value, _2pi) * _r2d
}
// PercentToRadians converts a normalized value (0,1) to radians.
func PercentToRadians(pct float64) float64 {
return DegreesToRadians(360.0 * pct)
}
// RadianAdd adds a delta to a base in radians.
func RadianAdd(base, delta float64) float64 {
value := base + delta
if value > _2pi {
return math.Mod(value, _2pi)
} else if value < 0 {
return math.Mod(_2pi+value, _2pi)
}
return value
}
// DegreesAdd adds a delta to a base in radians.
func DegreesAdd(baseDegrees, deltaDegrees float64) float64 {
value := baseDegrees + deltaDegrees
if value > _2pi {
return math.Mod(value, 360.0)
} else if value < 0 {
return math.Mod(360.0+value, 360.0)
}
return value
}
// DegreesToCompass returns the degree value in compass / clock orientation.
func DegreesToCompass(deg float64) float64 {
return DegreesAdd(deg, -90.0)
}
// CirclePoint returns the absolute position of a circle diameter point given
// by the radius and the theta.
func CirclePoint(cx, cy int, radius, thetaRadians float64) (x, y int) {
x = cx + int(radius*math.Sin(thetaRadians))
y = cy - int(radius*math.Cos(thetaRadians))
return
}
// RotateCoordinate rotates a coordinate around a given center by a theta in radians.
func RotateCoordinate(cx, cy, x, y int, thetaRadians float64) (rx, ry int) {
tempX, tempY := float64(x-cx), float64(y-cy)
rotatedX := tempX*math.Cos(thetaRadians) - tempY*math.Sin(thetaRadians)
rotatedY := tempX*math.Sin(thetaRadians) + tempY*math.Cos(thetaRadians)
rx = int(rotatedX) + cx
ry = int(rotatedY) + cy
return
}
// RoundUp rounds up to a given roundTo value.
func RoundUp(value, roundTo float64) float64 {
if roundTo < 0.000000000000001 {
return value
}
d1 := math.Ceil(value / roundTo)
return d1 * roundTo
}
// RoundDown rounds down to a given roundTo value.
func RoundDown(value, roundTo float64) float64 {
if roundTo < 0.000000000000001 {
return value
}
d1 := math.Floor(value / roundTo)
return d1 * roundTo
}
// Normalize returns a set of numbers on the interval [0,1] for a given set of inputs.
// An example: 4,3,2,1 => 0.4, 0.3, 0.2, 0.1
// Caveat; the total may be < 1.0; there are going to be issues with irrational numbers etc.
func Normalize(values ...float64) []float64 {
var total float64
for _, v := range values {
total += v
}
output := make([]float64, len(values))
for x, v := range values {
output[x] = RoundDown(v/total, 0.0001)
}
return output
}
// Mean returns the mean of a set of values
func Mean(values ...float64) float64 {
return Sum(values...) / float64(len(values))
}
// MeanInt returns the mean of a set of integer values.
func MeanInt(values ...int) int {
return SumInt(values...) / len(values)
}
// Sum sums a set of values.
func Sum(values ...float64) float64 {
var total float64
for _, v := range values {
total += v
}
return total
}
// SumInt sums a set of values.
func SumInt(values ...int) int {
var total int
for _, v := range values {
total += v
}
return total
}
// PercentDifference computes the percentage difference between two values.
// The formula is (v2-v1)/v1.
func PercentDifference(v1, v2 float64) float64 {
if v1 == 0 {
return 0
}
return (v2 - v1) / v1
}
// GetRoundToForDelta returns a `roundTo` value for a given delta.
func GetRoundToForDelta(delta float64) float64 {
startingDeltaBound := math.Pow(10.0, 10.0)
for cursor := startingDeltaBound; cursor > 0; cursor /= 10.0 {
if delta > cursor {
return cursor / 10.0
}
}
return 0.0
}
// RoundPlaces rounds an input to a given places.
func RoundPlaces(input float64, places int) (rounded float64) {
if math.IsNaN(input) {
return 0.0
}
sign := 1.0
if input < 0 {
sign = -1
input *= -1
}
precision := math.Pow(10, float64(places))
digit := input * precision
_, decimal := math.Modf(digit)
if decimal >= 0.5 {
rounded = math.Ceil(digit)
} else {
rounded = math.Floor(digit)
}
return rounded / precision * sign
}
func f64i(value float64) int {
r := RoundPlaces(value, 0)
return int(r)
}

138
pkg/chart/min_max_series.go Normal file
View file

@ -0,0 +1,138 @@
package chart
import (
"fmt"
"math"
)
// MinSeries draws a horizontal line at the minimum value of the inner series.
type MinSeries struct {
Name string
Style Style
YAxis YAxisType
InnerSeries ValuesProvider
minValue *float64
}
// GetName returns the name of the time series.
func (ms MinSeries) GetName() string {
return ms.Name
}
// GetStyle returns the line style.
func (ms MinSeries) GetStyle() Style {
return ms.Style
}
// GetYAxis returns which YAxis the series draws on.
func (ms MinSeries) GetYAxis() YAxisType {
return ms.YAxis
}
// Len returns the number of elements in the series.
func (ms MinSeries) Len() int {
return ms.InnerSeries.Len()
}
// GetValues gets a value at a given index.
func (ms *MinSeries) GetValues(index int) (x, y float64) {
ms.ensureMinValue()
x, _ = ms.InnerSeries.GetValues(index)
y = *ms.minValue
return
}
// Render renders the series.
func (ms *MinSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := ms.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, ms)
}
func (ms *MinSeries) ensureMinValue() {
if ms.minValue == nil {
minValue := math.MaxFloat64
var y float64
for x := 0; x < ms.InnerSeries.Len(); x++ {
_, y = ms.InnerSeries.GetValues(x)
if y < minValue {
minValue = y
}
}
ms.minValue = &minValue
}
}
// Validate validates the series.
func (ms *MinSeries) Validate() error {
if ms.InnerSeries == nil {
return fmt.Errorf("min series requires InnerSeries to be set")
}
return nil
}
// MaxSeries draws a horizontal line at the maximum value of the inner series.
type MaxSeries struct {
Name string
Style Style
YAxis YAxisType
InnerSeries ValuesProvider
maxValue *float64
}
// GetName returns the name of the time series.
func (ms MaxSeries) GetName() string {
return ms.Name
}
// GetStyle returns the line style.
func (ms MaxSeries) GetStyle() Style {
return ms.Style
}
// GetYAxis returns which YAxis the series draws on.
func (ms MaxSeries) GetYAxis() YAxisType {
return ms.YAxis
}
// Len returns the number of elements in the series.
func (ms MaxSeries) Len() int {
return ms.InnerSeries.Len()
}
// GetValues gets a value at a given index.
func (ms *MaxSeries) GetValues(index int) (x, y float64) {
ms.ensureMaxValue()
x, _ = ms.InnerSeries.GetValues(index)
y = *ms.maxValue
return
}
// Render renders the series.
func (ms *MaxSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := ms.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, ms)
}
func (ms *MaxSeries) ensureMaxValue() {
if ms.maxValue == nil {
maxValue := -math.MaxFloat64
var y float64
for x := 0; x < ms.InnerSeries.Len(); x++ {
_, y = ms.InnerSeries.GetValues(x)
if y > maxValue {
maxValue = y
}
}
ms.maxValue = &maxValue
}
}
// Validate validates the series.
func (ms *MaxSeries) Validate() error {
if ms.InnerSeries == nil {
return fmt.Errorf("max series requires InnerSeries to be set")
}
return nil
}

40
pkg/chart/parse.go Normal file
View file

@ -0,0 +1,40 @@
package chart
import (
"strconv"
"strings"
"time"
)
// ParseFloats parses a list of floats.
func ParseFloats(values ...string) ([]float64, error) {
var output []float64
var parsedValue float64
var err error
var cleaned string
for _, value := range values {
cleaned = strings.TrimSpace(strings.Replace(value, ",", "", -1))
if cleaned == "" {
continue
}
if parsedValue, err = strconv.ParseFloat(cleaned, 64); err != nil {
return nil, err
}
output = append(output, parsedValue)
}
return output, nil
}
// ParseTimes parses a list of times with a given format.
func ParseTimes(layout string, values ...string) ([]time.Time, error) {
var output []time.Time
var parsedValue time.Time
var err error
for _, value := range values {
if parsedValue, err = time.Parse(layout, value); err != nil {
return nil, err
}
output = append(output, parsedValue)
}
return output, nil
}

View file

@ -0,0 +1,89 @@
package chart
// Interface Assertions.
var (
_ Series = (*PercentChangeSeries)(nil)
_ FirstValuesProvider = (*PercentChangeSeries)(nil)
_ LastValuesProvider = (*PercentChangeSeries)(nil)
_ ValueFormatterProvider = (*PercentChangeSeries)(nil)
)
// PercentChangeSeriesSource is a series that
// can be used with a PercentChangeSeries
type PercentChangeSeriesSource interface {
Series
FirstValuesProvider
LastValuesProvider
ValuesProvider
ValueFormatterProvider
}
// PercentChangeSeries applies a
// percentage difference function to a given continuous series.
type PercentChangeSeries struct {
Name string
Style Style
YAxis YAxisType
InnerSeries PercentChangeSeriesSource
}
// GetName returns the name of the time series.
func (pcs PercentChangeSeries) GetName() string {
return pcs.Name
}
// GetStyle returns the line style.
func (pcs PercentChangeSeries) GetStyle() Style {
return pcs.Style
}
// Len implements part of Series.
func (pcs PercentChangeSeries) Len() int {
return pcs.InnerSeries.Len()
}
// GetFirstValues implements FirstValuesProvider.
func (pcs PercentChangeSeries) GetFirstValues() (x, y float64) {
return pcs.InnerSeries.GetFirstValues()
}
// GetValues gets x, y values at a given index.
func (pcs PercentChangeSeries) GetValues(index int) (x, y float64) {
_, fy := pcs.InnerSeries.GetFirstValues()
x0, y0 := pcs.InnerSeries.GetValues(index)
x = x0
y = PercentDifference(fy, y0)
return
}
// GetValueFormatters returns value formatter defaults for the series.
func (pcs PercentChangeSeries) GetValueFormatters() (x, y ValueFormatter) {
x, _ = pcs.InnerSeries.GetValueFormatters()
y = PercentValueFormatter
return
}
// GetYAxis returns which YAxis the series draws on.
func (pcs PercentChangeSeries) GetYAxis() YAxisType {
return pcs.YAxis
}
// GetLastValues gets the last values.
func (pcs PercentChangeSeries) GetLastValues() (x, y float64) {
_, fy := pcs.InnerSeries.GetFirstValues()
x0, y0 := pcs.InnerSeries.GetLastValues()
x = x0
y = PercentDifference(fy, y0)
return
}
// Render renders the series.
func (pcs PercentChangeSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := pcs.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, pcs)
}
// Validate validates the series.
func (pcs PercentChangeSeries) Validate() error {
return pcs.InnerSeries.Validate()
}

View file

@ -0,0 +1,35 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestPercentageDifferenceSeries(t *testing.T) {
// replaced new assertions helper
cs := ContinuousSeries{
XValues: LinearRange(1.0, 10.0),
YValues: LinearRange(1.0, 10.0),
}
pcs := PercentChangeSeries{
Name: "Test Series",
InnerSeries: cs,
}
testutil.AssertEqual(t, "Test Series", pcs.GetName())
testutil.AssertEqual(t, 10, pcs.Len())
x0, y0 := pcs.GetValues(0)
testutil.AssertEqual(t, 1.0, x0)
testutil.AssertEqual(t, 0, y0)
xn, yn := pcs.GetValues(9)
testutil.AssertEqual(t, 10.0, xn)
testutil.AssertEqual(t, 9.0, yn)
xn, yn = pcs.GetLastValues()
testutil.AssertEqual(t, 10.0, xn)
testutil.AssertEqual(t, 9.0, yn)
}

311
pkg/chart/pie_chart.go Normal file
View file

@ -0,0 +1,311 @@
package chart
import (
"errors"
"fmt"
"io"
"github.com/golang/freetype/truetype"
)
// PieChart is a chart that draws sections of a circle based on percentages.
type PieChart struct {
Title string
TitleStyle Style
ColorPalette ColorPalette
Width int
Height int
DPI float64
Background Style
Canvas Style
SliceStyle Style
Font *truetype.Font
defaultFont *truetype.Font
Values []Value
Elements []Renderable
}
// GetDPI returns the dpi for the chart.
func (pc PieChart) GetDPI(defaults ...float64) float64 {
if pc.DPI == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return DefaultDPI
}
return pc.DPI
}
// GetFont returns the text font.
func (pc PieChart) GetFont() *truetype.Font {
if pc.Font == nil {
return pc.defaultFont
}
return pc.Font
}
// GetWidth returns the chart width or the default value.
func (pc PieChart) GetWidth() int {
if pc.Width == 0 {
return DefaultChartWidth
}
return pc.Width
}
// GetHeight returns the chart height or the default value.
func (pc PieChart) GetHeight() int {
if pc.Height == 0 {
return DefaultChartWidth
}
return pc.Height
}
// Render renders the chart with the given renderer to the given io.Writer.
func (pc PieChart) Render(rp RendererProvider, w io.Writer) error {
if len(pc.Values) == 0 {
return errors.New("please provide at least one value")
}
r, err := rp(pc.GetWidth(), pc.GetHeight())
if err != nil {
return err
}
if pc.Font == nil {
defaultFont, err := GetDefaultFont()
if err != nil {
return err
}
pc.defaultFont = defaultFont
}
r.SetDPI(pc.GetDPI(DefaultDPI))
canvasBox := pc.getDefaultCanvasBox()
canvasBox = pc.getCircleAdjustedCanvasBox(canvasBox)
pc.drawBackground(r)
pc.drawCanvas(r, canvasBox)
finalValues, err := pc.finalizeValues(pc.Values)
if err != nil {
return err
}
pc.drawSlices(r, canvasBox, finalValues)
pc.drawTitle(r)
for _, a := range pc.Elements {
a(r, canvasBox, pc.styleDefaultsElements())
}
return r.Save(w)
}
func (pc PieChart) drawBackground(r Renderer) {
Draw.Box(r, Box{
Right: pc.GetWidth(),
Bottom: pc.GetHeight(),
}, pc.getBackgroundStyle())
}
func (pc PieChart) drawCanvas(r Renderer, canvasBox Box) {
Draw.Box(r, canvasBox, pc.getCanvasStyle())
}
func (pc PieChart) drawTitle(r Renderer) {
if len(pc.Title) > 0 && !pc.TitleStyle.Hidden {
Draw.TextWithin(r, pc.Title, pc.Box(), pc.styleDefaultsTitle())
}
}
func (pc PieChart) drawSlices(r Renderer, canvasBox Box, values []Value) {
cx, cy := canvasBox.Center()
diameter := MinInt(canvasBox.Width(), canvasBox.Height())
radius := float64(diameter >> 1)
labelRadius := (radius * 2.0) / 3.0
// draw the pie slices
var rads, delta, delta2, total float64
var lx, ly int
if len(values) == 1 {
pc.stylePieChartValue(0).WriteToRenderer(r)
r.MoveTo(cx, cy)
r.Circle(radius, cx, cy)
} else {
for index, v := range values {
v.Style.InheritFrom(pc.stylePieChartValue(index)).WriteToRenderer(r)
r.MoveTo(cx, cy)
rads = PercentToRadians(total)
delta = PercentToRadians(v.Value)
r.ArcTo(cx, cy, radius, radius, rads, delta)
r.LineTo(cx, cy)
r.Close()
r.FillStroke()
total = total + v.Value
}
}
// draw the labels
total = 0
for index, v := range values {
v.Style.InheritFrom(pc.stylePieChartValue(index)).WriteToRenderer(r)
if len(v.Label) > 0 {
delta2 = PercentToRadians(total + (v.Value / 2.0))
delta2 = RadianAdd(delta2, _pi2)
lx, ly = CirclePoint(cx, cy, labelRadius, delta2)
tb := r.MeasureText(v.Label)
lx = lx - (tb.Width() >> 1)
ly = ly + (tb.Height() >> 1)
if lx < 0 {
lx = 0
}
if ly < 0 {
lx = 0
}
r.Text(v.Label, lx, ly)
}
total = total + v.Value
}
}
func (pc PieChart) finalizeValues(values []Value) ([]Value, error) {
finalValues := Values(values).Normalize()
if len(finalValues) == 0 {
return nil, fmt.Errorf("pie chart must contain at least (1) non-zero value")
}
return finalValues, nil
}
func (pc PieChart) getDefaultCanvasBox() Box {
return pc.Box()
}
func (pc PieChart) getCircleAdjustedCanvasBox(canvasBox Box) Box {
circleDiameter := MinInt(canvasBox.Width(), canvasBox.Height())
square := Box{
Right: circleDiameter,
Bottom: circleDiameter,
}
return canvasBox.Fit(square)
}
func (pc PieChart) getBackgroundStyle() Style {
return pc.Background.InheritFrom(pc.styleDefaultsBackground())
}
func (pc PieChart) getCanvasStyle() Style {
return pc.Canvas.InheritFrom(pc.styleDefaultsCanvas())
}
func (pc PieChart) styleDefaultsCanvas() Style {
return Style{
FillColor: pc.GetColorPalette().CanvasColor(),
StrokeColor: pc.GetColorPalette().CanvasStrokeColor(),
StrokeWidth: DefaultStrokeWidth,
}
}
func (pc PieChart) styleDefaultsPieChartValue() Style {
return Style{
StrokeColor: pc.GetColorPalette().TextColor(),
StrokeWidth: 5.0,
FillColor: pc.GetColorPalette().TextColor(),
}
}
func (pc PieChart) stylePieChartValue(index int) Style {
return pc.SliceStyle.InheritFrom(Style{
StrokeColor: ColorWhite,
StrokeWidth: 5.0,
FillColor: pc.GetColorPalette().GetSeriesColor(index),
FontSize: pc.getScaledFontSize(),
FontColor: pc.GetColorPalette().TextColor(),
Font: pc.GetFont(),
})
}
func (pc PieChart) getScaledFontSize() float64 {
effectiveDimension := MinInt(pc.GetWidth(), pc.GetHeight())
if effectiveDimension >= 2048 {
return 48.0
} else if effectiveDimension >= 1024 {
return 24.0
} else if effectiveDimension > 512 {
return 18.0
} else if effectiveDimension > 256 {
return 12.0
}
return 10.0
}
func (pc PieChart) styleDefaultsBackground() Style {
return Style{
FillColor: pc.GetColorPalette().BackgroundColor(),
StrokeColor: pc.GetColorPalette().BackgroundStrokeColor(),
StrokeWidth: DefaultStrokeWidth,
}
}
func (pc PieChart) styleDefaultsElements() Style {
return Style{
Font: pc.GetFont(),
}
}
func (pc PieChart) styleDefaultsTitle() Style {
return pc.TitleStyle.InheritFrom(Style{
FontColor: pc.GetColorPalette().TextColor(),
Font: pc.GetFont(),
FontSize: pc.getTitleFontSize(),
TextHorizontalAlign: TextHorizontalAlignCenter,
TextVerticalAlign: TextVerticalAlignTop,
TextWrap: TextWrapWord,
})
}
func (pc PieChart) getTitleFontSize() float64 {
effectiveDimension := MinInt(pc.GetWidth(), pc.GetHeight())
if effectiveDimension >= 2048 {
return 48
} else if effectiveDimension >= 1024 {
return 24
} else if effectiveDimension >= 512 {
return 18
} else if effectiveDimension >= 256 {
return 12
}
return 10
}
// GetColorPalette returns the color palette for the chart.
func (pc PieChart) GetColorPalette() ColorPalette {
if pc.ColorPalette != nil {
return pc.ColorPalette
}
return AlternateColorPalette
}
// Box returns the chart bounds as a box.
func (pc PieChart) Box() Box {
dpr := pc.Background.Padding.GetRight(DefaultBackgroundPadding.Right)
dpb := pc.Background.Padding.GetBottom(DefaultBackgroundPadding.Bottom)
return Box{
Top: pc.Background.Padding.GetTop(DefaultBackgroundPadding.Top),
Left: pc.Background.Padding.GetLeft(DefaultBackgroundPadding.Left),
Right: pc.GetWidth() - dpr,
Bottom: pc.GetHeight() - dpb,
}
}

View file

@ -0,0 +1,69 @@
package chart
import (
"bytes"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestPieChart(t *testing.T) {
// replaced new assertions helper
pie := PieChart{
Canvas: Style{
FillColor: ColorLightGray,
},
Values: []Value{
{Value: 10, Label: "Blue"},
{Value: 9, Label: "Green"},
{Value: 8, Label: "Gray"},
{Value: 7, Label: "Orange"},
{Value: 6, Label: "HEANG"},
{Value: 5, Label: "??"},
{Value: 2, Label: "!!"},
},
}
b := bytes.NewBuffer([]byte{})
pie.Render(PNG, b)
testutil.AssertNotZero(t, b.Len())
}
func TestPieChartDropsZeroValues(t *testing.T) {
// replaced new assertions helper
pie := PieChart{
Canvas: Style{
FillColor: ColorLightGray,
},
Values: []Value{
{Value: 5, Label: "Blue"},
{Value: 5, Label: "Green"},
{Value: 0, Label: "Gray"},
},
}
b := bytes.NewBuffer([]byte{})
err := pie.Render(PNG, b)
testutil.AssertNil(t, err)
}
func TestPieChartAllZeroValues(t *testing.T) {
// replaced new assertions helper
pie := PieChart{
Canvas: Style{
FillColor: ColorLightGray,
},
Values: []Value{
{Value: 0, Label: "Blue"},
{Value: 0, Label: "Green"},
{Value: 0, Label: "Gray"},
},
}
b := bytes.NewBuffer([]byte{})
err := pie.Render(PNG, b)
testutil.AssertNotNil(t, err)
}

View file

@ -0,0 +1,176 @@
package chart
import (
"fmt"
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/matrix"
"math"
)
// Interface Assertions.
var (
_ Series = (*PolynomialRegressionSeries)(nil)
_ FirstValuesProvider = (*PolynomialRegressionSeries)(nil)
_ LastValuesProvider = (*PolynomialRegressionSeries)(nil)
)
// PolynomialRegressionSeries implements a polynomial regression over a given
// inner series.
type PolynomialRegressionSeries struct {
Name string
Style Style
YAxis YAxisType
Limit int
Offset int
Degree int
InnerSeries ValuesProvider
coeffs []float64
}
// GetName returns the name of the time series.
func (prs PolynomialRegressionSeries) GetName() string {
return prs.Name
}
// GetStyle returns the line style.
func (prs PolynomialRegressionSeries) GetStyle() Style {
return prs.Style
}
// GetYAxis returns which YAxis the series draws on.
func (prs PolynomialRegressionSeries) GetYAxis() YAxisType {
return prs.YAxis
}
// Len returns the number of elements in the series.
func (prs PolynomialRegressionSeries) Len() int {
return MinInt(prs.GetLimit(), prs.InnerSeries.Len()-prs.GetOffset())
}
// GetLimit returns the window size.
func (prs PolynomialRegressionSeries) GetLimit() int {
if prs.Limit == 0 {
return prs.InnerSeries.Len()
}
return prs.Limit
}
// GetEndIndex returns the effective limit end.
func (prs PolynomialRegressionSeries) GetEndIndex() int {
windowEnd := prs.GetOffset() + prs.GetLimit()
innerSeriesLastIndex := prs.InnerSeries.Len() - 1
return MinInt(windowEnd, innerSeriesLastIndex)
}
// GetOffset returns the data offset.
func (prs PolynomialRegressionSeries) GetOffset() int {
if prs.Offset == 0 {
return 0
}
return prs.Offset
}
// Validate validates the series.
func (prs *PolynomialRegressionSeries) Validate() error {
if prs.InnerSeries == nil {
return fmt.Errorf("linear regression series requires InnerSeries to be set")
}
endIndex := prs.GetEndIndex()
if endIndex >= prs.InnerSeries.Len() {
return fmt.Errorf("invalid window; inner series has length %d but end index is %d", prs.InnerSeries.Len(), endIndex)
}
return nil
}
// GetValues returns the series value for a given index.
func (prs *PolynomialRegressionSeries) GetValues(index int) (x, y float64) {
if prs.InnerSeries == nil || prs.InnerSeries.Len() == 0 {
return
}
if prs.coeffs == nil {
coeffs, err := prs.computeCoefficients()
if err != nil {
panic(err)
}
prs.coeffs = coeffs
}
offset := prs.GetOffset()
effectiveIndex := MinInt(index+offset, prs.InnerSeries.Len())
x, y = prs.InnerSeries.GetValues(effectiveIndex)
y = prs.apply(x)
return
}
// GetFirstValues computes the first poly regression value.
func (prs *PolynomialRegressionSeries) GetFirstValues() (x, y float64) {
if prs.InnerSeries == nil || prs.InnerSeries.Len() == 0 {
return
}
if prs.coeffs == nil {
coeffs, err := prs.computeCoefficients()
if err != nil {
panic(err)
}
prs.coeffs = coeffs
}
x, y = prs.InnerSeries.GetValues(0)
y = prs.apply(x)
return
}
// GetLastValues computes the last poly regression value.
func (prs *PolynomialRegressionSeries) GetLastValues() (x, y float64) {
if prs.InnerSeries == nil || prs.InnerSeries.Len() == 0 {
return
}
if prs.coeffs == nil {
coeffs, err := prs.computeCoefficients()
if err != nil {
panic(err)
}
prs.coeffs = coeffs
}
endIndex := prs.GetEndIndex()
x, y = prs.InnerSeries.GetValues(endIndex)
y = prs.apply(x)
return
}
func (prs *PolynomialRegressionSeries) apply(v float64) (out float64) {
for index, coeff := range prs.coeffs {
out = out + (coeff * math.Pow(v, float64(index)))
}
return
}
func (prs *PolynomialRegressionSeries) computeCoefficients() ([]float64, error) {
xvalues, yvalues := prs.values()
return matrix.Poly(xvalues, yvalues, prs.Degree)
}
func (prs *PolynomialRegressionSeries) values() (xvalues, yvalues []float64) {
startIndex := prs.GetOffset()
endIndex := prs.GetEndIndex()
xvalues = make([]float64, endIndex-startIndex)
yvalues = make([]float64, endIndex-startIndex)
for index := startIndex; index < endIndex; index++ {
x, y := prs.InnerSeries.GetValues(index)
xvalues[index-startIndex] = x
yvalues[index-startIndex] = y
}
return
}
// Render renders the series.
func (prs *PolynomialRegressionSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := prs.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, prs)
}

View file

@ -0,0 +1,35 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/matrix"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestPolynomialRegression(t *testing.T) {
// replaced new assertions helper
var xv []float64
var yv []float64
for i := 0; i < 100; i++ {
xv = append(xv, float64(i))
yv = append(yv, float64(i*i))
}
values := ContinuousSeries{
XValues: xv,
YValues: yv,
}
poly := &PolynomialRegressionSeries{
InnerSeries: values,
Degree: 2,
}
for i := 0; i < 100; i++ {
_, y := poly.GetValues(i)
testutil.AssertInDelta(t, float64(i*i), y, matrix.DefaultEpsilon)
}
}

View file

@ -0,0 +1,92 @@
package chart
import (
"math"
"math/rand"
"time"
)
var (
_ Sequence = (*RandomSeq)(nil)
)
// RandomValues returns an array of random values.
func RandomValues(count int) []float64 {
return Seq{NewRandomSequence().WithLen(count)}.Values()
}
// RandomValuesWithMax returns an array of random values with a given average.
func RandomValuesWithMax(count int, max float64) []float64 {
return Seq{NewRandomSequence().WithMax(max).WithLen(count)}.Values()
}
// NewRandomSequence creates a new random seq.
func NewRandomSequence() *RandomSeq {
return &RandomSeq{
rnd: rand.New(rand.NewSource(time.Now().Unix())),
}
}
// RandomSeq is a random number seq generator.
type RandomSeq struct {
rnd *rand.Rand
max *float64
min *float64
len *int
}
// Len returns the number of elements that will be generated.
func (r *RandomSeq) Len() int {
if r.len != nil {
return *r.len
}
return math.MaxInt32
}
// GetValue returns the value.
func (r *RandomSeq) GetValue(_ int) float64 {
if r.min != nil && r.max != nil {
var delta float64
if *r.max > *r.min {
delta = *r.max - *r.min
} else {
delta = *r.min - *r.max
}
return *r.min + (r.rnd.Float64() * delta)
} else if r.max != nil {
return r.rnd.Float64() * *r.max
} else if r.min != nil {
return *r.min + (r.rnd.Float64())
}
return r.rnd.Float64()
}
// WithLen sets a maximum len
func (r *RandomSeq) WithLen(length int) *RandomSeq {
r.len = &length
return r
}
// Min returns the minimum value.
func (r RandomSeq) Min() *float64 {
return r.min
}
// WithMin sets the scale and returns the Random.
func (r *RandomSeq) WithMin(min float64) *RandomSeq {
r.min = &min
return r
}
// Max returns the maximum value.
func (r RandomSeq) Max() *float64 {
return r.max
}
// WithMax sets the average and returns the Random.
func (r *RandomSeq) WithMax(max float64) *RandomSeq {
r.max = &max
return r
}

43
pkg/chart/range.go Normal file
View file

@ -0,0 +1,43 @@
package chart
// NameProvider is a type that returns a name.
type NameProvider interface {
GetName() string
}
// StyleProvider is a type that returns a style.
type StyleProvider interface {
GetStyle() Style
}
// IsZeroable is a type that returns if it's been set or not.
type IsZeroable interface {
IsZero() bool
}
// Stringable is a type that has a string representation.
type Stringable interface {
String() string
}
// Range is a common interface for a range of values.
type Range interface {
Stringable
IsZeroable
GetMin() float64
SetMin(min float64)
GetMax() float64
SetMax(max float64)
GetDelta() float64
GetDomain() int
SetDomain(domain int)
IsDescending() bool
// Translate the range to the domain.
Translate(value float64) int
}

View file

@ -0,0 +1,230 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
"image"
"image/png"
"io"
"math"
"github.com/golang/freetype/truetype"
)
// PNG returns a new png/raster renderer.
func PNG(width, height int) (Renderer, error) {
i := image.NewRGBA(image.Rect(0, 0, width, height))
gc, err := drawing.NewRasterGraphicContext(i)
if err == nil {
return &rasterRenderer{
i: i,
gc: gc,
}, nil
}
return nil, err
}
// rasterRenderer renders chart commands to a bitmap.
type rasterRenderer struct {
i *image.RGBA
gc *drawing.RasterGraphicContext
rotateRadians *float64
s Style
}
func (rr *rasterRenderer) ResetStyle() {
rr.s = Style{Font: rr.s.Font}
rr.ClearTextRotation()
}
// GetDPI returns the dpi.
func (rr *rasterRenderer) GetDPI() float64 {
return rr.gc.GetDPI()
}
// SetDPI implements the interface method.
func (rr *rasterRenderer) SetDPI(dpi float64) {
rr.gc.SetDPI(dpi)
}
// SetClassName implements the interface method. However, PNGs have no classes.
func (rr *rasterRenderer) SetClassName(_ string) {}
// SetStrokeColor implements the interface method.
func (rr *rasterRenderer) SetStrokeColor(c drawing.Color) {
rr.s.StrokeColor = c
}
// SetLineWidth implements the interface method.
func (rr *rasterRenderer) SetStrokeWidth(width float64) {
rr.s.StrokeWidth = width
}
// StrokeDashArray sets the stroke dash array.
func (rr *rasterRenderer) SetStrokeDashArray(dashArray []float64) {
rr.s.StrokeDashArray = dashArray
}
// SetFillColor implements the interface method.
func (rr *rasterRenderer) SetFillColor(c drawing.Color) {
rr.s.FillColor = c
}
// 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))
}
// QuadCurveTo implements the interface method.
func (rr *rasterRenderer) QuadCurveTo(cx, cy, x, y int) {
rr.gc.QuadCurveTo(float64(cx), float64(cy), float64(x), float64(y))
}
// ArcTo implements the interface method.
func (rr *rasterRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) {
rr.gc.ArcTo(float64(cx), float64(cy), rx, ry, startAngle, delta)
}
// Close implements the interface method.
func (rr *rasterRenderer) Close() {
rr.gc.Close()
}
// Stroke implements the interface method.
func (rr *rasterRenderer) Stroke() {
rr.gc.SetStrokeColor(rr.s.StrokeColor)
rr.gc.SetLineWidth(rr.s.StrokeWidth)
rr.gc.SetLineDash(rr.s.StrokeDashArray, 0)
rr.gc.Stroke()
}
// Fill implements the interface method.
func (rr *rasterRenderer) Fill() {
rr.gc.SetFillColor(rr.s.FillColor)
rr.gc.Fill()
}
// FillStroke implements the interface method.
func (rr *rasterRenderer) FillStroke() {
rr.gc.SetFillColor(rr.s.FillColor)
rr.gc.SetStrokeColor(rr.s.StrokeColor)
rr.gc.SetLineWidth(rr.s.StrokeWidth)
rr.gc.SetLineDash(rr.s.StrokeDashArray, 0)
rr.gc.FillStroke()
}
// Circle fully draws a circle at a given point but does not apply the fill or stroke.
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-radius, yf-radius, xf, yf-radius) //12
rr.gc.QuadCurveTo(xf+radius, yf-radius, xf+radius, yf) //3
rr.gc.QuadCurveTo(xf+radius, yf+radius, xf, yf+radius) //6
rr.gc.QuadCurveTo(xf-radius, yf+radius, xf-radius, yf) //9
}
// SetFont implements the interface method.
func (rr *rasterRenderer) SetFont(f *truetype.Font) {
rr.s.Font = f
}
// SetFontSize implements the interface method.
func (rr *rasterRenderer) SetFontSize(size float64) {
rr.s.FontSize = size
}
// SetFontColor implements the interface method.
func (rr *rasterRenderer) SetFontColor(c drawing.Color) {
rr.s.FontColor = c
}
// Text implements the interface method.
func (rr *rasterRenderer) Text(body string, x, y int) {
xf, yf := rr.getCoords(x, y)
rr.gc.SetFont(rr.s.Font)
rr.gc.SetFontSize(rr.s.FontSize)
rr.gc.SetFillColor(rr.s.FontColor)
rr.gc.CreateStringPath(body, float64(xf), float64(yf))
rr.gc.Fill()
}
// MeasureText returns the height and width in pixels of a string.
func (rr *rasterRenderer) MeasureText(body string) Box {
rr.gc.SetFont(rr.s.Font)
rr.gc.SetFontSize(rr.s.FontSize)
rr.gc.SetFillColor(rr.s.FontColor)
l, t, r, b, err := rr.gc.GetStringBounds(body)
if err != nil {
return Box{}
}
if l < 0 {
r = r - l // equivalent to r+(-1*l)
l = 0
}
if t < 0 {
b = b - t
t = 0
}
if l > 0 {
r = r + l
l = 0
}
if t > 0 {
b = b + t
t = 0
}
textBox := Box{
Top: int(math.Ceil(t)),
Left: int(math.Ceil(l)),
Right: int(math.Ceil(r)),
Bottom: int(math.Ceil(b)),
}
if rr.rotateRadians == nil {
return textBox
}
return textBox.Corners().Rotate(RadiansToDegrees(*rr.rotateRadians)).Box()
}
// SetTextRotation sets a text rotation.
func (rr *rasterRenderer) SetTextRotation(radians float64) {
rr.rotateRadians = &radians
}
func (rr *rasterRenderer) getCoords(x, y int) (xf, yf int) {
if rr.rotateRadians == nil {
xf = x
yf = y
return
}
rr.gc.Translate(float64(x), float64(y))
rr.gc.Rotate(*rr.rotateRadians)
return
}
// ClearTextRotation clears text rotation.
func (rr *rasterRenderer) ClearTextRotation() {
rr.gc.SetMatrixTransform(drawing.NewIdentityMatrix())
rr.rotateRadians = nil
}
// Save implements the interface method.
func (rr *rasterRenderer) Save(w io.Writer) error {
if typed, isTyped := w.(RGBACollector); isTyped {
typed.SetRGBA(rr.i)
return nil
}
return png.Encode(w, rr.i)
}

4
pkg/chart/renderable.go Normal file
View file

@ -0,0 +1,4 @@
package chart
// Renderable is a function that can be called to render custom elements on the chart.
type Renderable func(r Renderer, canvasBox Box, defaults Style)

89
pkg/chart/renderer.go Normal file
View file

@ -0,0 +1,89 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
"io"
"github.com/golang/freetype/truetype"
)
// Renderer represents the basic methods required to draw a chart.
type Renderer interface {
// ResetStyle should reset any style related settings on the renderer.
ResetStyle()
// GetDPI gets the DPI for the renderer.
GetDPI() float64
// SetDPI sets the DPI for the renderer.
SetDPI(dpi float64)
// SetClassName sets the current class name.
SetClassName(string)
// SetStrokeColor sets the current stroke color.
SetStrokeColor(drawing.Color)
// SetFillColor sets the current fill color.
SetFillColor(drawing.Color)
// SetStrokeWidth sets the stroke width.
SetStrokeWidth(width float64)
// SetStrokeDashArray sets the stroke dash array.
SetStrokeDashArray(dashArray []float64)
// 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)
// QuadCurveTo draws a quad curve.
// cx and cy represent the bezier "control points".
QuadCurveTo(cx, cy, x, y int)
// ArcTo draws an arc with a given center (cx,cy)
// a given set of radii (rx,ry), a startAngle and delta (in radians).
ArcTo(cx, cy int, rx, ry, startAngle, delta float64)
// Close finalizes a shape as drawn by LineTo.
Close()
// Stroke strokes the path.
Stroke()
// Fill fills the path, but does not stroke.
Fill()
// FillStroke fills and strokes a path.
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(drawing.Color)
// 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) Box
// SetTextRotatation sets a rotation for drawing elements.
SetTextRotation(radians float64)
// ClearTextRotation clears rotation.
ClearTextRotation()
// Save writes the image to the given writer.
Save(w io.Writer) error
}

View file

@ -0,0 +1,4 @@
package chart
// RendererProvider is a function that returns a renderer.
type RendererProvider func(int, int) (Renderer, error)

275
pkg/chart/seq.go Normal file
View file

@ -0,0 +1,275 @@
package chart
import (
"math"
"sort"
)
// ValueSequence returns a sequence for a given values set.
func ValueSequence(values ...float64) Seq {
return Seq{NewArray(values...)}
}
// Sequence is a provider for values for a seq.
type Sequence interface {
Len() int
GetValue(int) float64
}
// Seq is a utility wrapper for seq providers.
type Seq struct {
Sequence
}
// Values enumerates the seq into a slice.
func (s Seq) Values() (output []float64) {
if s.Len() == 0 {
return
}
output = make([]float64, s.Len())
for i := 0; i < s.Len(); i++ {
output[i] = s.GetValue(i)
}
return
}
// Each applies the `mapfn` to all values in the value provider.
func (s Seq) Each(mapfn func(int, float64)) {
for i := 0; i < s.Len(); i++ {
mapfn(i, s.GetValue(i))
}
}
// Map applies the `mapfn` to all values in the value provider,
// returning a new seq.
func (s Seq) Map(mapfn func(i int, v float64) float64) Seq {
output := make([]float64, s.Len())
for i := 0; i < s.Len(); i++ {
mapfn(i, s.GetValue(i))
}
return Seq{Array(output)}
}
// FoldLeft collapses a seq from left to right.
func (s Seq) FoldLeft(mapfn func(i int, v0, v float64) float64) (v0 float64) {
if s.Len() == 0 {
return 0
}
if s.Len() == 1 {
return s.GetValue(0)
}
v0 = s.GetValue(0)
for i := 1; i < s.Len(); i++ {
v0 = mapfn(i, v0, s.GetValue(i))
}
return
}
// FoldRight collapses a seq from right to left.
func (s Seq) FoldRight(mapfn func(i int, v0, v float64) float64) (v0 float64) {
if s.Len() == 0 {
return 0
}
if s.Len() == 1 {
return s.GetValue(0)
}
v0 = s.GetValue(s.Len() - 1)
for i := s.Len() - 2; i >= 0; i-- {
v0 = mapfn(i, v0, s.GetValue(i))
}
return
}
// Min returns the minimum value in the seq.
func (s Seq) Min() float64 {
if s.Len() == 0 {
return 0
}
min := s.GetValue(0)
var value float64
for i := 1; i < s.Len(); i++ {
value = s.GetValue(i)
if value < min {
min = value
}
}
return min
}
// Max returns the maximum value in the seq.
func (s Seq) Max() float64 {
if s.Len() == 0 {
return 0
}
max := s.GetValue(0)
var value float64
for i := 1; i < s.Len(); i++ {
value = s.GetValue(i)
if value > max {
max = value
}
}
return max
}
// MinMax returns the minimum and the maximum in one pass.
func (s Seq) MinMax() (min, max float64) {
if s.Len() == 0 {
return
}
min = s.GetValue(0)
max = min
var value float64
for i := 1; i < s.Len(); i++ {
value = s.GetValue(i)
if value < min {
min = value
}
if value > max {
max = value
}
}
return
}
// Sort returns the seq sorted in ascending order.
// This fully enumerates the seq.
func (s Seq) Sort() Seq {
if s.Len() == 0 {
return s
}
values := s.Values()
sort.Float64s(values)
return Seq{Array(values)}
}
// Reverse reverses the sequence
func (s Seq) Reverse() Seq {
if s.Len() == 0 {
return s
}
values := s.Values()
valuesLen := len(values)
valuesLen1 := len(values) - 1
valuesLen2 := valuesLen >> 1
var i, j float64
for index := 0; index < valuesLen2; index++ {
i = values[index]
j = values[valuesLen1-index]
values[index] = j
values[valuesLen1-index] = i
}
return Seq{Array(values)}
}
// Median returns the median or middle value in the sorted seq.
func (s Seq) Median() (median float64) {
l := s.Len()
if l == 0 {
return
}
sorted := s.Sort()
if l%2 == 0 {
v0 := sorted.GetValue(l/2 - 1)
v1 := sorted.GetValue(l/2 + 1)
median = (v0 + v1) / 2
} else {
median = float64(sorted.GetValue(l << 1))
}
return
}
// Sum adds all the elements of a series together.
func (s Seq) Sum() (accum float64) {
if s.Len() == 0 {
return 0
}
for i := 0; i < s.Len(); i++ {
accum += s.GetValue(i)
}
return
}
// Average returns the float average of the values in the buffer.
func (s Seq) Average() float64 {
if s.Len() == 0 {
return 0
}
return s.Sum() / float64(s.Len())
}
// Variance computes the variance of the buffer.
func (s Seq) Variance() float64 {
if s.Len() == 0 {
return 0
}
m := s.Average()
var variance, v float64
for i := 0; i < s.Len(); i++ {
v = s.GetValue(i)
variance += (v - m) * (v - m)
}
return variance / float64(s.Len())
}
// StdDev returns the standard deviation.
func (s Seq) StdDev() float64 {
if s.Len() == 0 {
return 0
}
return math.Pow(s.Variance(), 0.5)
}
//Percentile finds the relative standing in a slice of floats.
// `percent` should be given on the interval [0,1.0).
func (s Seq) Percentile(percent float64) (percentile float64) {
l := s.Len()
if l == 0 {
return 0
}
if percent < 0 || percent > 1.0 {
panic("percent out of range [0.0, 1.0)")
}
sorted := s.Sort()
index := percent * float64(l)
if index == float64(int64(index)) {
i := f64i(index)
ci := sorted.GetValue(i - 1)
c := sorted.GetValue(i)
percentile = (ci + c) / 2.0
} else {
i := f64i(index)
percentile = sorted.GetValue(i)
}
return percentile
}
// Normalize maps every value to the interval [0, 1.0].
func (s Seq) Normalize() Seq {
min, max := s.MinMax()
delta := max - min
output := make([]float64, s.Len())
for i := 0; i < s.Len(); i++ {
output[i] = (s.GetValue(i) - min) / delta
}
return Seq{Array(output)}
}

136
pkg/chart/seq_test.go Normal file
View file

@ -0,0 +1,136 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestSeqEach(t *testing.T) {
// replaced new assertions helper
values := Seq{NewArray(1, 2, 3, 4)}
values.Each(func(i int, v float64) {
testutil.AssertEqual(t, i, v-1)
})
}
func TestSeqMap(t *testing.T) {
// replaced new assertions helper
values := Seq{NewArray(1, 2, 3, 4)}
mapped := values.Map(func(i int, v float64) float64 {
testutil.AssertEqual(t, i, v-1)
return v * 2
})
testutil.AssertEqual(t, 4, mapped.Len())
}
func TestSeqFoldLeft(t *testing.T) {
// replaced new assertions helper
values := Seq{NewArray(1, 2, 3, 4)}
ten := values.FoldLeft(func(_ int, vp, v float64) float64 {
return vp + v
})
testutil.AssertEqual(t, 10, ten)
orderTest := Seq{NewArray(10, 3, 2, 1)}
four := orderTest.FoldLeft(func(_ int, vp, v float64) float64 {
return vp - v
})
testutil.AssertEqual(t, 4, four)
}
func TestSeqFoldRight(t *testing.T) {
// replaced new assertions helper
values := Seq{NewArray(1, 2, 3, 4)}
ten := values.FoldRight(func(_ int, vp, v float64) float64 {
return vp + v
})
testutil.AssertEqual(t, 10, ten)
orderTest := Seq{NewArray(10, 3, 2, 1)}
notFour := orderTest.FoldRight(func(_ int, vp, v float64) float64 {
return vp - v
})
testutil.AssertEqual(t, -14, notFour)
}
func TestSeqSum(t *testing.T) {
// replaced new assertions helper
values := Seq{NewArray(1, 2, 3, 4)}
testutil.AssertEqual(t, 10, values.Sum())
}
func TestSeqAverage(t *testing.T) {
// replaced new assertions helper
values := Seq{NewArray(1, 2, 3, 4)}
testutil.AssertEqual(t, 2.5, values.Average())
valuesOdd := Seq{NewArray(1, 2, 3, 4, 5)}
testutil.AssertEqual(t, 3, valuesOdd.Average())
}
func TestSequenceVariance(t *testing.T) {
// replaced new assertions helper
values := Seq{NewArray(1, 2, 3, 4, 5)}
testutil.AssertEqual(t, 2, values.Variance())
}
func TestSequenceNormalize(t *testing.T) {
// replaced new assertions helper
normalized := ValueSequence(1, 2, 3, 4, 5).Normalize().Values()
testutil.AssertNotEmpty(t, normalized)
testutil.AssertLen(t, normalized, 5)
testutil.AssertEqual(t, 0, normalized[0])
testutil.AssertEqual(t, 0.25, normalized[1])
testutil.AssertEqual(t, 1, normalized[4])
}
func TestLinearRange(t *testing.T) {
// replaced new assertions helper
values := LinearRange(1, 100)
testutil.AssertLen(t, values, 100)
testutil.AssertEqual(t, 1, values[0])
testutil.AssertEqual(t, 100, values[99])
}
func TestLinearRangeWithStep(t *testing.T) {
// replaced new assertions helper
values := LinearRangeWithStep(0, 100, 5)
testutil.AssertEqual(t, 100, values[20])
testutil.AssertLen(t, values, 21)
}
func TestLinearRangeReversed(t *testing.T) {
// replaced new assertions helper
values := LinearRange(10.0, 1.0)
testutil.AssertEqual(t, 10, len(values))
testutil.AssertEqual(t, 10.0, values[0])
testutil.AssertEqual(t, 1.0, values[9])
}
func TestLinearSequenceRegression(t *testing.T) {
// replaced new assertions helper
// note; this assumes a 1.0 step is implicitly set in the constructor.
linearProvider := NewLinearSequence().WithStart(1.0).WithEnd(100.0)
testutil.AssertEqual(t, 1, linearProvider.Start())
testutil.AssertEqual(t, 100, linearProvider.End())
testutil.AssertEqual(t, 100, linearProvider.Len())
values := Seq{linearProvider}.Values()
testutil.AssertLen(t, values, 100)
testutil.AssertEqual(t, 1.0, values[0])
testutil.AssertEqual(t, 100, values[99])
}

10
pkg/chart/series.go Normal file
View file

@ -0,0 +1,10 @@
package chart
// Series is an alias to Renderable.
type Series interface {
GetName() string
GetYAxis() YAxisType
GetStyle() Style
Validate() error
Render(r Renderer, canvasBox Box, xrange, yrange Range, s Style)
}

120
pkg/chart/sma_series.go Normal file
View file

@ -0,0 +1,120 @@
package chart
import (
"fmt"
)
const (
// DefaultSimpleMovingAveragePeriod is the default number of values to average.
DefaultSimpleMovingAveragePeriod = 16
)
// Interface Assertions.
var (
_ Series = (*SMASeries)(nil)
_ FirstValuesProvider = (*SMASeries)(nil)
_ LastValuesProvider = (*SMASeries)(nil)
)
// SMASeries is a computed series.
type SMASeries struct {
Name string
Style Style
YAxis YAxisType
Period int
InnerSeries ValuesProvider
}
// GetName returns the name of the time series.
func (sma SMASeries) GetName() string {
return sma.Name
}
// GetStyle returns the line style.
func (sma SMASeries) GetStyle() Style {
return sma.Style
}
// GetYAxis returns which YAxis the series draws on.
func (sma SMASeries) GetYAxis() YAxisType {
return sma.YAxis
}
// Len returns the number of elements in the series.
func (sma SMASeries) Len() int {
return sma.InnerSeries.Len()
}
// GetPeriod returns the window size.
func (sma SMASeries) GetPeriod(defaults ...int) int {
if sma.Period == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return DefaultSimpleMovingAveragePeriod
}
return sma.Period
}
// GetValues gets a value at a given index.
func (sma SMASeries) GetValues(index int) (x, y float64) {
if sma.InnerSeries == nil || sma.InnerSeries.Len() == 0 {
return
}
px, _ := sma.InnerSeries.GetValues(index)
x = px
y = sma.getAverage(index)
return
}
// GetFirstValues computes the first moving average value.
func (sma SMASeries) GetFirstValues() (x, y float64) {
if sma.InnerSeries == nil || sma.InnerSeries.Len() == 0 {
return
}
px, _ := sma.InnerSeries.GetValues(0)
x = px
y = sma.getAverage(0)
return
}
// GetLastValues computes the last moving average value but walking back window size samples,
// and recomputing the last moving average chunk.
func (sma SMASeries) GetLastValues() (x, y float64) {
if sma.InnerSeries == nil || sma.InnerSeries.Len() == 0 {
return
}
seriesLen := sma.InnerSeries.Len()
px, _ := sma.InnerSeries.GetValues(seriesLen - 1)
x = px
y = sma.getAverage(seriesLen - 1)
return
}
func (sma SMASeries) getAverage(index int) float64 {
period := sma.GetPeriod()
floor := MaxInt(0, index-period)
var accum float64
var count float64
for x := index; x >= floor; x-- {
_, vy := sma.InnerSeries.GetValues(x)
accum += vy
count += 1.0
}
return accum / count
}
// Render renders the series.
func (sma SMASeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := sma.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, sma)
}
// Validate validates the series.
func (sma SMASeries) Validate() error {
if sma.InnerSeries == nil {
return fmt.Errorf("sma series requires InnerSeries to be set")
}
return nil
}

View file

@ -0,0 +1,111 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
type mockValuesProvider struct {
X []float64
Y []float64
}
func (m mockValuesProvider) Len() int {
return MinInt(len(m.X), len(m.Y))
}
func (m mockValuesProvider) GetValues(index int) (x, y float64) {
if index < 0 {
panic("negative index at GetValue()")
}
if index >= MinInt(len(m.X), len(m.Y)) {
panic("index is outside the length of m.X or m.Y")
}
x = m.X[index]
y = m.Y[index]
return
}
func TestSMASeriesGetValue(t *testing.T) {
// replaced new assertions helper
mockSeries := mockValuesProvider{
LinearRange(1.0, 10.0),
LinearRange(10, 1.0),
}
testutil.AssertEqual(t, 10, mockSeries.Len())
mas := &SMASeries{
InnerSeries: mockSeries,
Period: 10,
}
var yvalues []float64
for x := 0; x < mas.Len(); x++ {
_, y := mas.GetValues(x)
yvalues = append(yvalues, y)
}
testutil.AssertEqual(t, 10.0, yvalues[0])
testutil.AssertEqual(t, 9.5, yvalues[1])
testutil.AssertEqual(t, 9.0, yvalues[2])
testutil.AssertEqual(t, 8.5, yvalues[3])
testutil.AssertEqual(t, 8.0, yvalues[4])
testutil.AssertEqual(t, 7.5, yvalues[5])
testutil.AssertEqual(t, 7.0, yvalues[6])
testutil.AssertEqual(t, 6.5, yvalues[7])
testutil.AssertEqual(t, 6.0, yvalues[8])
}
func TestSMASeriesGetLastValueWindowOverlap(t *testing.T) {
// replaced new assertions helper
mockSeries := mockValuesProvider{
LinearRange(1.0, 10.0),
LinearRange(10, 1.0),
}
testutil.AssertEqual(t, 10, mockSeries.Len())
mas := &SMASeries{
InnerSeries: mockSeries,
Period: 15,
}
var yvalues []float64
for x := 0; x < mas.Len(); x++ {
_, y := mas.GetValues(x)
yvalues = append(yvalues, y)
}
lx, ly := mas.GetLastValues()
testutil.AssertEqual(t, 10.0, lx)
testutil.AssertEqual(t, 5.5, ly)
testutil.AssertEqual(t, yvalues[len(yvalues)-1], ly)
}
func TestSMASeriesGetLastValue(t *testing.T) {
// replaced new assertions helper
mockSeries := mockValuesProvider{
LinearRange(1.0, 100.0),
LinearRange(100, 1.0),
}
testutil.AssertEqual(t, 100, mockSeries.Len())
mas := &SMASeries{
InnerSeries: mockSeries,
Period: 10,
}
var yvalues []float64
for x := 0; x < mas.Len(); x++ {
_, y := mas.GetValues(x)
yvalues = append(yvalues, y)
}
lx, ly := mas.GetLastValues()
testutil.AssertEqual(t, 100.0, lx)
testutil.AssertEqual(t, 6, ly)
testutil.AssertEqual(t, yvalues[len(yvalues)-1], ly)
}

View file

@ -0,0 +1,610 @@
package chart
import (
"errors"
"fmt"
"io"
"math"
"github.com/golang/freetype/truetype"
)
// StackedBar is a bar within a StackedBarChart.
type StackedBar struct {
Name string
Width int
Values []Value
}
// GetWidth returns the width of the bar.
func (sb StackedBar) GetWidth() int {
if sb.Width == 0 {
return 50
}
return sb.Width
}
// StackedBarChart is a chart that draws sections of a bar based on percentages.
type StackedBarChart struct {
Title string
TitleStyle Style
ColorPalette ColorPalette
Width int
Height int
DPI float64
Background Style
Canvas Style
XAxis Style
YAxis Style
BarSpacing int
Font *truetype.Font
defaultFont *truetype.Font
IsHorizontal bool
Bars []StackedBar
Elements []Renderable
}
// GetDPI returns the dpi for the chart.
func (sbc StackedBarChart) GetDPI(defaults ...float64) float64 {
if sbc.DPI == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return DefaultDPI
}
return sbc.DPI
}
// GetFont returns the text font.
func (sbc StackedBarChart) GetFont() *truetype.Font {
if sbc.Font == nil {
return sbc.defaultFont
}
return sbc.Font
}
// GetWidth returns the chart width or the default value.
func (sbc StackedBarChart) GetWidth() int {
if sbc.Width == 0 {
return DefaultChartWidth
}
return sbc.Width
}
// GetHeight returns the chart height or the default value.
func (sbc StackedBarChart) GetHeight() int {
if sbc.Height == 0 {
return DefaultChartWidth
}
return sbc.Height
}
// GetBarSpacing returns the spacing between bars.
func (sbc StackedBarChart) GetBarSpacing() int {
if sbc.BarSpacing == 0 {
return 100
}
return sbc.BarSpacing
}
// Render renders the chart with the given renderer to the given io.Writer.
func (sbc StackedBarChart) Render(rp RendererProvider, w io.Writer) error {
if len(sbc.Bars) == 0 {
return errors.New("please provide at least one bar")
}
r, err := rp(sbc.GetWidth(), sbc.GetHeight())
if err != nil {
return err
}
if sbc.Font == nil {
defaultFont, err := GetDefaultFont()
if err != nil {
return err
}
sbc.defaultFont = defaultFont
}
r.SetDPI(sbc.GetDPI(DefaultDPI))
var canvasBox Box
if sbc.IsHorizontal {
canvasBox = sbc.getHorizontalAdjustedCanvasBox(r, sbc.getDefaultCanvasBox())
sbc.drawCanvas(r, canvasBox)
sbc.drawHorizontalBars(r, canvasBox)
sbc.drawHorizontalXAxis(r, canvasBox)
sbc.drawHorizontalYAxis(r, canvasBox)
} else {
canvasBox = sbc.getAdjustedCanvasBox(r, sbc.getDefaultCanvasBox())
sbc.drawCanvas(r, canvasBox)
sbc.drawBars(r, canvasBox)
sbc.drawXAxis(r, canvasBox)
sbc.drawYAxis(r, canvasBox)
}
sbc.drawTitle(r)
for _, a := range sbc.Elements {
a(r, canvasBox, sbc.styleDefaultsElements())
}
return r.Save(w)
}
func (sbc StackedBarChart) drawCanvas(r Renderer, canvasBox Box) {
Draw.Box(r, canvasBox, sbc.getCanvasStyle())
}
func (sbc StackedBarChart) drawBars(r Renderer, canvasBox Box) {
xoffset := canvasBox.Left
for _, bar := range sbc.Bars {
sbc.drawBar(r, canvasBox, xoffset, bar)
xoffset += (sbc.GetBarSpacing() + bar.GetWidth())
}
}
func (sbc StackedBarChart) drawHorizontalBars(r Renderer, canvasBox Box) {
yOffset := canvasBox.Top
for _, bar := range sbc.Bars {
sbc.drawHorizontalBar(r, canvasBox, yOffset, bar)
yOffset += sbc.GetBarSpacing() + bar.GetWidth()
}
}
func (sbc StackedBarChart) drawBar(r Renderer, canvasBox Box, xoffset int, bar StackedBar) int {
barSpacing2 := sbc.GetBarSpacing() >> 1
bxl := xoffset + barSpacing2
bxr := bxl + bar.GetWidth()
normalizedBarComponents := Values(bar.Values).Normalize()
yoffset := canvasBox.Top
for index, bv := range normalizedBarComponents {
barHeight := int(math.Ceil(bv.Value * float64(canvasBox.Height())))
barBox := Box{
Top: yoffset,
Left: bxl,
Right: bxr,
Bottom: MinInt(yoffset+barHeight, canvasBox.Bottom-DefaultStrokeWidth),
}
Draw.Box(r, barBox, bv.Style.InheritFrom(sbc.styleDefaultsStackedBarValue(index)))
yoffset += barHeight
}
// draw the labels
yoffset = canvasBox.Top
var lx, ly int
for index, bv := range normalizedBarComponents {
barHeight := int(math.Ceil(bv.Value * float64(canvasBox.Height())))
if len(bv.Label) > 0 {
lx = bxl + ((bxr - bxl) / 2)
ly = yoffset + (barHeight / 2)
bv.Style.InheritFrom(sbc.styleDefaultsStackedBarValue(index)).WriteToRenderer(r)
tb := r.MeasureText(bv.Label)
lx = lx - (tb.Width() >> 1)
ly = ly + (tb.Height() >> 1)
if lx < 0 {
lx = 0
}
if ly < 0 {
lx = 0
}
r.Text(bv.Label, lx, ly)
}
yoffset += barHeight
}
return bxr
}
func (sbc StackedBarChart) drawHorizontalBar(r Renderer, canvasBox Box, yoffset int, bar StackedBar) {
halfBarSpacing := sbc.GetBarSpacing() >> 1
boxTop := yoffset + halfBarSpacing
boxBottom := boxTop + bar.GetWidth()
normalizedBarComponents := Values(bar.Values).Normalize()
xOffset := canvasBox.Right
for index, bv := range normalizedBarComponents {
barHeight := int(math.Ceil(bv.Value * float64(canvasBox.Width())))
barBox := Box{
Top: boxTop,
Left: MinInt(xOffset-barHeight, canvasBox.Left+DefaultStrokeWidth),
Right: xOffset,
Bottom: boxBottom,
}
Draw.Box(r, barBox, bv.Style.InheritFrom(sbc.styleDefaultsStackedBarValue(index)))
xOffset -= barHeight
}
// draw the labels
xOffset = canvasBox.Right
var lx, ly int
for index, bv := range normalizedBarComponents {
barHeight := int(math.Ceil(bv.Value * float64(canvasBox.Width())))
if len(bv.Label) > 0 {
lx = xOffset - (barHeight / 2)
ly = boxTop + ((boxBottom - boxTop) / 2)
bv.Style.InheritFrom(sbc.styleDefaultsStackedBarValue(index)).WriteToRenderer(r)
tb := r.MeasureText(bv.Label)
lx = lx - (tb.Width() >> 1)
ly = ly + (tb.Height() >> 1)
if lx < 0 {
lx = 0
}
if ly < 0 {
lx = 0
}
r.Text(bv.Label, lx, ly)
}
xOffset -= barHeight
}
}
func (sbc StackedBarChart) drawXAxis(r Renderer, canvasBox Box) {
if !sbc.XAxis.Hidden {
axisStyle := sbc.XAxis.InheritFrom(sbc.styleDefaultsAxes())
axisStyle.WriteToRenderer(r)
r.MoveTo(canvasBox.Left, canvasBox.Bottom)
r.LineTo(canvasBox.Right, canvasBox.Bottom)
r.Stroke()
r.MoveTo(canvasBox.Left, canvasBox.Bottom)
r.LineTo(canvasBox.Left, canvasBox.Bottom+DefaultVerticalTickHeight)
r.Stroke()
cursor := canvasBox.Left
for _, bar := range sbc.Bars {
barLabelBox := Box{
Top: canvasBox.Bottom + DefaultXAxisMargin,
Left: cursor,
Right: cursor + bar.GetWidth() + sbc.GetBarSpacing(),
Bottom: sbc.GetHeight(),
}
if len(bar.Name) > 0 {
Draw.TextWithin(r, bar.Name, barLabelBox, axisStyle)
}
axisStyle.WriteToRenderer(r)
r.MoveTo(barLabelBox.Right, canvasBox.Bottom)
r.LineTo(barLabelBox.Right, canvasBox.Bottom+DefaultVerticalTickHeight)
r.Stroke()
cursor += bar.GetWidth() + sbc.GetBarSpacing()
}
}
}
func (sbc StackedBarChart) drawHorizontalXAxis(r Renderer, canvasBox Box) {
if !sbc.XAxis.Hidden {
axisStyle := sbc.XAxis.InheritFrom(sbc.styleDefaultsAxes())
axisStyle.WriteToRenderer(r)
r.MoveTo(canvasBox.Left, canvasBox.Bottom)
r.LineTo(canvasBox.Right, canvasBox.Bottom)
r.Stroke()
r.MoveTo(canvasBox.Left, canvasBox.Bottom)
r.LineTo(canvasBox.Left, canvasBox.Bottom+DefaultVerticalTickHeight)
r.Stroke()
ticks := LinearRangeWithStep(0.0, 1.0, 0.2)
for _, t := range ticks {
axisStyle.GetStrokeOptions().WriteToRenderer(r)
tx := canvasBox.Left + int(t*float64(canvasBox.Width()))
r.MoveTo(tx, canvasBox.Bottom)
r.LineTo(tx, canvasBox.Bottom+DefaultVerticalTickHeight)
r.Stroke()
axisStyle.GetTextOptions().WriteToRenderer(r)
text := fmt.Sprintf("%0.0f%%", t*100)
textBox := r.MeasureText(text)
textX := tx - (textBox.Width() >> 1)
textY := canvasBox.Bottom + DefaultXAxisMargin + 10
if t == 1 {
textX = canvasBox.Right - textBox.Width()
}
Draw.Text(r, text, textX, textY, axisStyle)
}
}
}
func (sbc StackedBarChart) drawYAxis(r Renderer, canvasBox Box) {
if !sbc.YAxis.Hidden {
axisStyle := sbc.YAxis.InheritFrom(sbc.styleDefaultsAxes())
axisStyle.WriteToRenderer(r)
r.MoveTo(canvasBox.Right, canvasBox.Top)
r.LineTo(canvasBox.Right, canvasBox.Bottom)
r.Stroke()
r.MoveTo(canvasBox.Right, canvasBox.Bottom)
r.LineTo(canvasBox.Right+DefaultHorizontalTickWidth, canvasBox.Bottom)
r.Stroke()
ticks := LinearRangeWithStep(0.0, 1.0, 0.2)
for _, t := range ticks {
axisStyle.GetStrokeOptions().WriteToRenderer(r)
ty := canvasBox.Bottom - int(t*float64(canvasBox.Height()))
r.MoveTo(canvasBox.Right, ty)
r.LineTo(canvasBox.Right+DefaultHorizontalTickWidth, ty)
r.Stroke()
axisStyle.GetTextOptions().WriteToRenderer(r)
text := fmt.Sprintf("%0.0f%%", t*100)
tb := r.MeasureText(text)
Draw.Text(r, text, canvasBox.Right+DefaultYAxisMargin+5, ty+(tb.Height()>>1), axisStyle)
}
}
}
func (sbc StackedBarChart) drawHorizontalYAxis(r Renderer, canvasBox Box) {
if !sbc.YAxis.Hidden {
axisStyle := sbc.YAxis.InheritFrom(sbc.styleDefaultsHorizontalAxes())
axisStyle.WriteToRenderer(r)
r.MoveTo(canvasBox.Left, canvasBox.Bottom)
r.LineTo(canvasBox.Left, canvasBox.Top)
r.Stroke()
r.MoveTo(canvasBox.Left, canvasBox.Bottom)
r.LineTo(canvasBox.Left-DefaultHorizontalTickWidth, canvasBox.Bottom)
r.Stroke()
cursor := canvasBox.Top
for _, bar := range sbc.Bars {
barLabelBox := Box{
Top: cursor,
Left: 0,
Right: canvasBox.Left - DefaultYAxisMargin,
Bottom: cursor + bar.GetWidth() + sbc.GetBarSpacing(),
}
if len(bar.Name) > 0 {
Draw.TextWithin(r, bar.Name, barLabelBox, axisStyle)
}
axisStyle.WriteToRenderer(r)
r.MoveTo(canvasBox.Left, barLabelBox.Bottom)
r.LineTo(canvasBox.Left-DefaultHorizontalTickWidth, barLabelBox.Bottom)
r.Stroke()
cursor += bar.GetWidth() + sbc.GetBarSpacing()
}
}
}
func (sbc StackedBarChart) drawTitle(r Renderer) {
if len(sbc.Title) > 0 && !sbc.TitleStyle.Hidden {
r.SetFont(sbc.TitleStyle.GetFont(sbc.GetFont()))
r.SetFontColor(sbc.TitleStyle.GetFontColor(sbc.GetColorPalette().TextColor()))
titleFontSize := sbc.TitleStyle.GetFontSize(DefaultTitleFontSize)
r.SetFontSize(titleFontSize)
textBox := r.MeasureText(sbc.Title)
textWidth := textBox.Width()
textHeight := textBox.Height()
titleX := (sbc.GetWidth() >> 1) - (textWidth >> 1)
titleY := sbc.TitleStyle.Padding.GetTop(DefaultTitleTop) + textHeight
r.Text(sbc.Title, titleX, titleY)
}
}
func (sbc StackedBarChart) getCanvasStyle() Style {
return sbc.Canvas.InheritFrom(sbc.styleDefaultsCanvas())
}
func (sbc StackedBarChart) styleDefaultsCanvas() Style {
return Style{
FillColor: sbc.GetColorPalette().CanvasColor(),
StrokeColor: sbc.GetColorPalette().CanvasStrokeColor(),
StrokeWidth: DefaultCanvasStrokeWidth,
}
}
// GetColorPalette returns the color palette for the chart.
func (sbc StackedBarChart) GetColorPalette() ColorPalette {
if sbc.ColorPalette != nil {
return sbc.ColorPalette
}
return AlternateColorPalette
}
func (sbc StackedBarChart) getDefaultCanvasBox() Box {
return sbc.Box()
}
func (sbc StackedBarChart) getAdjustedCanvasBox(r Renderer, canvasBox Box) Box {
var totalWidth int
for _, bar := range sbc.Bars {
totalWidth += bar.GetWidth() + sbc.GetBarSpacing()
}
if !sbc.XAxis.Hidden {
xaxisHeight := DefaultVerticalTickHeight
axisStyle := sbc.XAxis.InheritFrom(sbc.styleDefaultsAxes())
axisStyle.WriteToRenderer(r)
cursor := canvasBox.Left
for _, bar := range sbc.Bars {
if len(bar.Name) > 0 {
barLabelBox := Box{
Top: canvasBox.Bottom + DefaultXAxisMargin,
Left: cursor,
Right: cursor + bar.GetWidth() + sbc.GetBarSpacing(),
Bottom: sbc.GetHeight(),
}
lines := Text.WrapFit(r, bar.Name, barLabelBox.Width(), axisStyle)
linesBox := Text.MeasureLines(r, lines, axisStyle)
xaxisHeight = MaxInt(linesBox.Height()+(2*DefaultXAxisMargin), xaxisHeight)
}
}
return Box{
Top: canvasBox.Top,
Left: canvasBox.Left,
Right: canvasBox.Left + totalWidth,
Bottom: sbc.GetHeight() - xaxisHeight,
}
}
return Box{
Top: canvasBox.Top,
Left: canvasBox.Left,
Right: canvasBox.Left + totalWidth,
Bottom: canvasBox.Bottom,
}
}
func (sbc StackedBarChart) getHorizontalAdjustedCanvasBox(r Renderer, canvasBox Box) Box {
var totalHeight int
for _, bar := range sbc.Bars {
totalHeight += bar.GetWidth() + sbc.GetBarSpacing()
}
if !sbc.YAxis.Hidden {
yAxisWidth := DefaultHorizontalTickWidth
axisStyle := sbc.YAxis.InheritFrom(sbc.styleDefaultsHorizontalAxes())
axisStyle.WriteToRenderer(r)
cursor := canvasBox.Top
for _, bar := range sbc.Bars {
if len(bar.Name) > 0 {
barLabelBox := Box{
Top: cursor,
Left: 0,
Right: canvasBox.Left + DefaultYAxisMargin,
Bottom: cursor + bar.GetWidth() + sbc.GetBarSpacing(),
}
lines := Text.WrapFit(r, bar.Name, barLabelBox.Width(), axisStyle)
linesBox := Text.MeasureLines(r, lines, axisStyle)
yAxisWidth = MaxInt(linesBox.Height()+(2*DefaultXAxisMargin), yAxisWidth)
}
}
return Box{
Top: canvasBox.Top,
Left: canvasBox.Left + yAxisWidth,
Right: canvasBox.Right,
Bottom: canvasBox.Top + totalHeight,
}
}
return Box{
Top: canvasBox.Top,
Left: canvasBox.Left,
Right: canvasBox.Right,
Bottom: canvasBox.Top + totalHeight,
}
}
// Box returns the chart bounds as a box.
func (sbc StackedBarChart) Box() Box {
dpr := sbc.Background.Padding.GetRight(10)
dpb := sbc.Background.Padding.GetBottom(50)
return Box{
Top: sbc.Background.Padding.GetTop(20),
Left: sbc.Background.Padding.GetLeft(20),
Right: sbc.GetWidth() - dpr,
Bottom: sbc.GetHeight() - dpb,
}
}
func (sbc StackedBarChart) styleDefaultsStackedBarValue(index int) Style {
return Style{
StrokeColor: sbc.GetColorPalette().GetSeriesColor(index),
StrokeWidth: 3.0,
FillColor: sbc.GetColorPalette().GetSeriesColor(index),
FontSize: sbc.getScaledFontSize(),
FontColor: sbc.GetColorPalette().TextColor(),
Font: sbc.GetFont(),
}
}
func (sbc StackedBarChart) styleDefaultsTitle() Style {
return sbc.TitleStyle.InheritFrom(Style{
FontColor: DefaultTextColor,
Font: sbc.GetFont(),
FontSize: sbc.getTitleFontSize(),
TextHorizontalAlign: TextHorizontalAlignCenter,
TextVerticalAlign: TextVerticalAlignTop,
TextWrap: TextWrapWord,
})
}
func (sbc StackedBarChart) getScaledFontSize() float64 {
effectiveDimension := MinInt(sbc.GetWidth(), sbc.GetHeight())
if effectiveDimension >= 2048 {
return 48.0
} else if effectiveDimension >= 1024 {
return 24.0
} else if effectiveDimension > 512 {
return 18.0
} else if effectiveDimension > 256 {
return 12.0
}
return 10.0
}
func (sbc StackedBarChart) getTitleFontSize() float64 {
effectiveDimension := MinInt(sbc.GetWidth(), sbc.GetHeight())
if effectiveDimension >= 2048 {
return 48
} else if effectiveDimension >= 1024 {
return 24
} else if effectiveDimension >= 512 {
return 18
} else if effectiveDimension >= 256 {
return 12
}
return 10
}
func (sbc StackedBarChart) styleDefaultsAxes() Style {
return Style{
StrokeColor: DefaultAxisColor,
Font: sbc.GetFont(),
FontSize: DefaultAxisFontSize,
FontColor: DefaultAxisColor,
TextHorizontalAlign: TextHorizontalAlignCenter,
TextVerticalAlign: TextVerticalAlignTop,
TextWrap: TextWrapWord,
}
}
func (sbc StackedBarChart) styleDefaultsHorizontalAxes() Style {
return Style{
StrokeColor: DefaultAxisColor,
Font: sbc.GetFont(),
FontSize: DefaultAxisFontSize,
FontColor: DefaultAxisColor,
TextHorizontalAlign: TextHorizontalAlignCenter,
TextVerticalAlign: TextVerticalAlignMiddle,
TextWrap: TextWrapWord,
}
}
func (sbc StackedBarChart) styleDefaultsElements() Style {
return Style{
Font: sbc.GetFont(),
}
}

57
pkg/chart/stringutil.go Normal file
View file

@ -0,0 +1,57 @@
package chart
import "strings"
// SplitCSV splits a corpus by the `,`, dropping leading or trailing whitespace unless quoted.
func SplitCSV(text string) (output []string) {
if len(text) == 0 {
return
}
var state int
var word []rune
var opened rune
for _, r := range text {
switch state {
case 0: // word
if isQuote(r) {
opened = r
state = 1
} else if isCSVDelim(r) {
output = append(output, strings.TrimSpace(string(word)))
word = nil
} else {
word = append(word, r)
}
case 1: // we're in a quoted section
if matchesQuote(opened, r) {
state = 0
continue
}
word = append(word, r)
}
}
if len(word) > 0 {
output = append(output, strings.TrimSpace(string(word)))
}
return
}
func isCSVDelim(r rune) bool {
return r == rune(',')
}
func isQuote(r rune) bool {
return r == '"' || r == '\'' || r == '“' || r == '”' || r == '`'
}
func matchesQuote(a, b rune) bool {
if a == '“' && b == '”' {
return true
}
if a == '”' && b == '“' {
return true
}
return a == b
}

View file

@ -0,0 +1,22 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestSplitCSV(t *testing.T) {
// replaced new assertions helper
testutil.AssertEmpty(t, SplitCSV(""))
testutil.AssertEqual(t, []string{"foo"}, SplitCSV("foo"))
testutil.AssertEqual(t, []string{"foo", "bar"}, SplitCSV("foo,bar"))
testutil.AssertEqual(t, []string{"foo", "bar"}, SplitCSV("foo, bar"))
testutil.AssertEqual(t, []string{"foo", "bar"}, SplitCSV(" foo , bar "))
testutil.AssertEqual(t, []string{"foo", "bar", "baz"}, SplitCSV("foo,bar,baz"))
testutil.AssertEqual(t, []string{"foo", "bar", "baz,buzz"}, SplitCSV("foo,bar,\"baz,buzz\""))
testutil.AssertEqual(t, []string{"foo", "bar", "baz,'buzz'"}, SplitCSV("foo,bar,\"baz,'buzz'\""))
testutil.AssertEqual(t, []string{"foo", "bar", "baz,'buzz"}, SplitCSV("foo,bar,\"baz,'buzz\""))
testutil.AssertEqual(t, []string{"foo", "bar", "baz,\"buzz\""}, SplitCSV("foo,bar,'baz,\"buzz\"'"))
}

480
pkg/chart/style.go Normal file
View file

@ -0,0 +1,480 @@
package chart
import (
"fmt"
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
"strings"
"github.com/golang/freetype/truetype"
)
const (
// Disabled indicates if the value should be interpreted as set intentionally to zero.
// this is because golang optionals aren't here yet.
Disabled = -1
)
// Hidden is a prebuilt style with the `Hidden` property set to true.
func Hidden() Style {
return Style{
Hidden: true,
}
}
// Shown is a prebuilt style with the `Hidden` property set to false.
// You can also think of this as the default.
func Shown() Style {
return Style{
Hidden: false,
}
}
// StyleTextDefaults returns a style for drawing outside a
// chart context.
func StyleTextDefaults() Style {
font, _ := GetDefaultFont()
return Style{
Hidden: false,
Font: font,
FontColor: DefaultTextColor,
FontSize: DefaultTitleFontSize,
}
}
// Style is a simple style set.
type Style struct {
Hidden bool
Padding Box
ClassName string
StrokeWidth float64
StrokeColor drawing.Color
StrokeDashArray []float64
DotColor drawing.Color
DotWidth float64
DotWidthProvider SizeProvider
DotColorProvider DotColorProvider
FillColor drawing.Color
FontSize float64
FontColor drawing.Color
Font *truetype.Font
TextHorizontalAlign TextHorizontalAlign
TextVerticalAlign TextVerticalAlign
TextWrap TextWrap
TextLineSpacing int
TextRotationDegrees float64 //0 is unset or normal
}
// IsZero returns if the object is set or not.
func (s Style) IsZero() bool {
return !s.Hidden &&
s.StrokeColor.IsZero() &&
s.StrokeWidth == 0 &&
s.DotColor.IsZero() &&
s.DotWidth == 0 &&
s.FillColor.IsZero() &&
s.FontColor.IsZero() &&
s.FontSize == 0 &&
s.Font == nil &&
s.ClassName == ""
}
// String returns a text representation of the style.
func (s Style) String() string {
if s.IsZero() {
return "{}"
}
var output []string
if s.Hidden {
output = []string{"\"hidden\": true"}
} else {
output = []string{"\"hidden\": false"}
}
if s.ClassName != "" {
output = append(output, fmt.Sprintf("\"class_name\": %s", s.ClassName))
} else {
output = append(output, "\"class_name\": null")
}
if !s.Padding.IsZero() {
output = append(output, fmt.Sprintf("\"padding\": %s", s.Padding.String()))
} else {
output = append(output, "\"padding\": null")
}
if s.StrokeWidth >= 0 {
output = append(output, fmt.Sprintf("\"stroke_width\": %0.2f", s.StrokeWidth))
} else {
output = append(output, "\"stroke_width\": null")
}
if !s.StrokeColor.IsZero() {
output = append(output, fmt.Sprintf("\"stroke_color\": %s", s.StrokeColor.String()))
} else {
output = append(output, "\"stroke_color\": null")
}
if len(s.StrokeDashArray) > 0 {
var elements []string
for _, v := range s.StrokeDashArray {
elements = append(elements, fmt.Sprintf("%.2f", v))
}
dashArray := strings.Join(elements, ", ")
output = append(output, fmt.Sprintf("\"stroke_dash_array\": [%s]", dashArray))
} else {
output = append(output, "\"stroke_dash_array\": null")
}
if s.DotWidth >= 0 {
output = append(output, fmt.Sprintf("\"dot_width\": %0.2f", s.DotWidth))
} else {
output = append(output, "\"dot_width\": null")
}
if !s.DotColor.IsZero() {
output = append(output, fmt.Sprintf("\"dot_color\": %s", s.DotColor.String()))
} else {
output = append(output, "\"dot_color\": null")
}
if !s.FillColor.IsZero() {
output = append(output, fmt.Sprintf("\"fill_color\": %s", s.FillColor.String()))
} else {
output = append(output, "\"fill_color\": null")
}
if s.FontSize != 0 {
output = append(output, fmt.Sprintf("\"font_size\": \"%0.2fpt\"", s.FontSize))
} else {
output = append(output, "\"font_size\": null")
}
if !s.FontColor.IsZero() {
output = append(output, fmt.Sprintf("\"font_color\": %s", s.FontColor.String()))
} else {
output = append(output, "\"font_color\": null")
}
if s.Font != nil {
output = append(output, fmt.Sprintf("\"font\": \"%s\"", s.Font.Name(truetype.NameIDFontFamily)))
} else {
output = append(output, "\"font_color\": null")
}
return "{" + strings.Join(output, ", ") + "}"
}
// GetClassName returns the class name or a default.
func (s Style) GetClassName(defaults ...string) string {
if s.ClassName == "" {
if len(defaults) > 0 {
return defaults[0]
}
return ""
}
return s.ClassName
}
// GetStrokeColor returns the stroke color.
func (s Style) GetStrokeColor(defaults ...drawing.Color) drawing.Color {
if s.StrokeColor.IsZero() {
if len(defaults) > 0 {
return defaults[0]
}
return drawing.ColorTransparent
}
return s.StrokeColor
}
// GetFillColor returns the fill color.
func (s Style) GetFillColor(defaults ...drawing.Color) drawing.Color {
if s.FillColor.IsZero() {
if len(defaults) > 0 {
return defaults[0]
}
return drawing.ColorTransparent
}
return s.FillColor
}
// GetDotColor returns the stroke color.
func (s Style) GetDotColor(defaults ...drawing.Color) drawing.Color {
if s.DotColor.IsZero() {
if len(defaults) > 0 {
return defaults[0]
}
return drawing.ColorTransparent
}
return s.DotColor
}
// GetStrokeWidth returns the stroke width.
func (s Style) GetStrokeWidth(defaults ...float64) float64 {
if s.StrokeWidth == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return DefaultStrokeWidth
}
return s.StrokeWidth
}
// GetDotWidth returns the dot width for scatter plots.
func (s Style) GetDotWidth(defaults ...float64) float64 {
if s.DotWidth == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return DefaultDotWidth
}
return s.DotWidth
}
// GetStrokeDashArray returns the stroke dash array.
func (s Style) GetStrokeDashArray(defaults ...[]float64) []float64 {
if len(s.StrokeDashArray) == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return nil
}
return s.StrokeDashArray
}
// GetFontSize gets the font size.
func (s Style) GetFontSize(defaults ...float64) float64 {
if s.FontSize == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return DefaultFontSize
}
return s.FontSize
}
// GetFontColor gets the font size.
func (s Style) GetFontColor(defaults ...drawing.Color) drawing.Color {
if s.FontColor.IsZero() {
if len(defaults) > 0 {
return defaults[0]
}
return drawing.ColorTransparent
}
return s.FontColor
}
// GetFont returns the font face.
func (s Style) GetFont(defaults ...*truetype.Font) *truetype.Font {
if s.Font == nil {
if len(defaults) > 0 {
return defaults[0]
}
return nil
}
return s.Font
}
// GetPadding returns the padding.
func (s Style) GetPadding(defaults ...Box) Box {
if s.Padding.IsZero() {
if len(defaults) > 0 {
return defaults[0]
}
return Box{}
}
return s.Padding
}
// GetTextHorizontalAlign returns the horizontal alignment.
func (s Style) GetTextHorizontalAlign(defaults ...TextHorizontalAlign) TextHorizontalAlign {
if s.TextHorizontalAlign == TextHorizontalAlignUnset {
if len(defaults) > 0 {
return defaults[0]
}
return TextHorizontalAlignUnset
}
return s.TextHorizontalAlign
}
// GetTextVerticalAlign returns the vertical alignment.
func (s Style) GetTextVerticalAlign(defaults ...TextVerticalAlign) TextVerticalAlign {
if s.TextVerticalAlign == TextVerticalAlignUnset {
if len(defaults) > 0 {
return defaults[0]
}
return TextVerticalAlignUnset
}
return s.TextVerticalAlign
}
// GetTextWrap returns the word wrap.
func (s Style) GetTextWrap(defaults ...TextWrap) TextWrap {
if s.TextWrap == TextWrapUnset {
if len(defaults) > 0 {
return defaults[0]
}
return TextWrapUnset
}
return s.TextWrap
}
// GetTextLineSpacing returns the spacing in pixels between lines of text (vertically).
func (s Style) GetTextLineSpacing(defaults ...int) int {
if s.TextLineSpacing == 0 {
if len(defaults) > 0 {
return defaults[0]
}
return DefaultLineSpacing
}
return s.TextLineSpacing
}
// GetTextRotationDegrees returns the text rotation in degrees.
func (s Style) GetTextRotationDegrees(defaults ...float64) float64 {
if s.TextRotationDegrees == 0 {
if len(defaults) > 0 {
return defaults[0]
}
}
return s.TextRotationDegrees
}
// WriteToRenderer passes the style's options to a renderer.
func (s Style) WriteToRenderer(r Renderer) {
r.SetClassName(s.GetClassName())
r.SetStrokeColor(s.GetStrokeColor())
r.SetStrokeWidth(s.GetStrokeWidth())
r.SetStrokeDashArray(s.GetStrokeDashArray())
r.SetFillColor(s.GetFillColor())
r.SetFont(s.GetFont())
r.SetFontColor(s.GetFontColor())
r.SetFontSize(s.GetFontSize())
r.ClearTextRotation()
if s.GetTextRotationDegrees() != 0 {
r.SetTextRotation(DegreesToRadians(s.GetTextRotationDegrees()))
}
}
// WriteDrawingOptionsToRenderer passes just the drawing style options to a renderer.
func (s Style) WriteDrawingOptionsToRenderer(r Renderer) {
r.SetClassName(s.GetClassName())
r.SetStrokeColor(s.GetStrokeColor())
r.SetStrokeWidth(s.GetStrokeWidth())
r.SetStrokeDashArray(s.GetStrokeDashArray())
r.SetFillColor(s.GetFillColor())
}
// WriteTextOptionsToRenderer passes just the text style options to a renderer.
func (s Style) WriteTextOptionsToRenderer(r Renderer) {
r.SetClassName(s.GetClassName())
r.SetFont(s.GetFont())
r.SetFontColor(s.GetFontColor())
r.SetFontSize(s.GetFontSize())
}
// InheritFrom coalesces two styles into a new style.
func (s Style) InheritFrom(defaults Style) (final Style) {
final.ClassName = s.GetClassName(defaults.ClassName)
final.StrokeColor = s.GetStrokeColor(defaults.StrokeColor)
final.StrokeWidth = s.GetStrokeWidth(defaults.StrokeWidth)
final.StrokeDashArray = s.GetStrokeDashArray(defaults.StrokeDashArray)
final.DotColor = s.GetDotColor(defaults.DotColor)
final.DotWidth = s.GetDotWidth(defaults.DotWidth)
final.DotWidthProvider = s.DotWidthProvider
final.DotColorProvider = s.DotColorProvider
final.FillColor = s.GetFillColor(defaults.FillColor)
final.FontColor = s.GetFontColor(defaults.FontColor)
final.FontSize = s.GetFontSize(defaults.FontSize)
final.Font = s.GetFont(defaults.Font)
final.Padding = s.GetPadding(defaults.Padding)
final.TextHorizontalAlign = s.GetTextHorizontalAlign(defaults.TextHorizontalAlign)
final.TextVerticalAlign = s.GetTextVerticalAlign(defaults.TextVerticalAlign)
final.TextWrap = s.GetTextWrap(defaults.TextWrap)
final.TextLineSpacing = s.GetTextLineSpacing(defaults.TextLineSpacing)
final.TextRotationDegrees = s.GetTextRotationDegrees(defaults.TextRotationDegrees)
return
}
// GetStrokeOptions returns the stroke components.
func (s Style) GetStrokeOptions() Style {
return Style{
ClassName: s.ClassName,
StrokeDashArray: s.StrokeDashArray,
StrokeColor: s.StrokeColor,
StrokeWidth: s.StrokeWidth,
}
}
// GetFillOptions returns the fill components.
func (s Style) GetFillOptions() Style {
return Style{
ClassName: s.ClassName,
FillColor: s.FillColor,
}
}
// GetDotOptions returns the dot components.
func (s Style) GetDotOptions() Style {
return Style{
ClassName: s.ClassName,
StrokeDashArray: nil,
FillColor: s.DotColor,
StrokeColor: s.DotColor,
StrokeWidth: 1.0,
}
}
// GetFillAndStrokeOptions returns the fill and stroke components.
func (s Style) GetFillAndStrokeOptions() Style {
return Style{
ClassName: s.ClassName,
StrokeDashArray: s.StrokeDashArray,
FillColor: s.FillColor,
StrokeColor: s.StrokeColor,
StrokeWidth: s.StrokeWidth,
}
}
// GetTextOptions returns just the text components of the style.
func (s Style) GetTextOptions() Style {
return Style{
ClassName: s.ClassName,
FontColor: s.FontColor,
FontSize: s.FontSize,
Font: s.Font,
TextHorizontalAlign: s.TextHorizontalAlign,
TextVerticalAlign: s.TextVerticalAlign,
TextWrap: s.TextWrap,
TextLineSpacing: s.TextLineSpacing,
TextRotationDegrees: s.TextRotationDegrees,
}
}
// ShouldDrawStroke tells drawing functions if they should draw the stroke.
func (s Style) ShouldDrawStroke() bool {
return !s.StrokeColor.IsZero() && s.StrokeWidth > 0
}
// ShouldDrawDot tells drawing functions if they should draw the dot.
func (s Style) ShouldDrawDot() bool {
return (!s.DotColor.IsZero() && s.DotWidth > 0) || s.DotColorProvider != nil || s.DotWidthProvider != nil
}
// ShouldDrawFill tells drawing functions if they should draw the stroke.
func (s Style) ShouldDrawFill() bool {
return !s.FillColor.IsZero()
}

214
pkg/chart/style_test.go Normal file
View file

@ -0,0 +1,214 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
"testing"
"github.com/golang/freetype/truetype"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestStyleIsZero(t *testing.T) {
// replaced new assertions helper
zero := Style{}
testutil.AssertTrue(t, zero.IsZero())
strokeColor := Style{StrokeColor: drawing.ColorWhite}
testutil.AssertFalse(t, strokeColor.IsZero())
fillColor := Style{FillColor: drawing.ColorWhite}
testutil.AssertFalse(t, fillColor.IsZero())
strokeWidth := Style{StrokeWidth: 5.0}
testutil.AssertFalse(t, strokeWidth.IsZero())
fontSize := Style{FontSize: 12.0}
testutil.AssertFalse(t, fontSize.IsZero())
fontColor := Style{FontColor: drawing.ColorWhite}
testutil.AssertFalse(t, fontColor.IsZero())
font := Style{Font: &truetype.Font{}}
testutil.AssertFalse(t, font.IsZero())
}
func TestStyleGetStrokeColor(t *testing.T) {
// replaced new assertions helper
unset := Style{}
testutil.AssertEqual(t, drawing.ColorTransparent, unset.GetStrokeColor())
testutil.AssertEqual(t, drawing.ColorWhite, unset.GetStrokeColor(drawing.ColorWhite))
set := Style{StrokeColor: drawing.ColorWhite}
testutil.AssertEqual(t, drawing.ColorWhite, set.GetStrokeColor())
testutil.AssertEqual(t, drawing.ColorWhite, set.GetStrokeColor(drawing.ColorBlack))
}
func TestStyleGetFillColor(t *testing.T) {
// replaced new assertions helper
unset := Style{}
testutil.AssertEqual(t, drawing.ColorTransparent, unset.GetFillColor())
testutil.AssertEqual(t, drawing.ColorWhite, unset.GetFillColor(drawing.ColorWhite))
set := Style{FillColor: drawing.ColorWhite}
testutil.AssertEqual(t, drawing.ColorWhite, set.GetFillColor())
testutil.AssertEqual(t, drawing.ColorWhite, set.GetFillColor(drawing.ColorBlack))
}
func TestStyleGetStrokeWidth(t *testing.T) {
// replaced new assertions helper
unset := Style{}
testutil.AssertEqual(t, DefaultStrokeWidth, unset.GetStrokeWidth())
testutil.AssertEqual(t, DefaultStrokeWidth+1, unset.GetStrokeWidth(DefaultStrokeWidth+1))
set := Style{StrokeWidth: DefaultStrokeWidth + 2}
testutil.AssertEqual(t, DefaultStrokeWidth+2, set.GetStrokeWidth())
testutil.AssertEqual(t, DefaultStrokeWidth+2, set.GetStrokeWidth(DefaultStrokeWidth+1))
}
func TestStyleGetFontSize(t *testing.T) {
// replaced new assertions helper
unset := Style{}
testutil.AssertEqual(t, DefaultFontSize, unset.GetFontSize())
testutil.AssertEqual(t, DefaultFontSize+1, unset.GetFontSize(DefaultFontSize+1))
set := Style{FontSize: DefaultFontSize + 2}
testutil.AssertEqual(t, DefaultFontSize+2, set.GetFontSize())
testutil.AssertEqual(t, DefaultFontSize+2, set.GetFontSize(DefaultFontSize+1))
}
func TestStyleGetFontColor(t *testing.T) {
// replaced new assertions helper
unset := Style{}
testutil.AssertEqual(t, drawing.ColorTransparent, unset.GetFontColor())
testutil.AssertEqual(t, drawing.ColorWhite, unset.GetFontColor(drawing.ColorWhite))
set := Style{FontColor: drawing.ColorWhite}
testutil.AssertEqual(t, drawing.ColorWhite, set.GetFontColor())
testutil.AssertEqual(t, drawing.ColorWhite, set.GetFontColor(drawing.ColorBlack))
}
func TestStyleGetFont(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
unset := Style{}
testutil.AssertNil(t, unset.GetFont())
testutil.AssertEqual(t, f, unset.GetFont(f))
set := Style{Font: f}
testutil.AssertNotNil(t, set.GetFont())
}
func TestStyleGetPadding(t *testing.T) {
// replaced new assertions helper
unset := Style{}
testutil.AssertTrue(t, unset.GetPadding().IsZero())
testutil.AssertFalse(t, unset.GetPadding(DefaultBackgroundPadding).IsZero())
testutil.AssertEqual(t, DefaultBackgroundPadding, unset.GetPadding(DefaultBackgroundPadding))
set := Style{Padding: DefaultBackgroundPadding}
testutil.AssertFalse(t, set.GetPadding().IsZero())
testutil.AssertEqual(t, DefaultBackgroundPadding, set.GetPadding())
testutil.AssertEqual(t, DefaultBackgroundPadding, set.GetPadding(Box{
Top: DefaultBackgroundPadding.Top + 1,
Left: DefaultBackgroundPadding.Left + 1,
Right: DefaultBackgroundPadding.Right + 1,
Bottom: DefaultBackgroundPadding.Bottom + 1,
}))
}
func TestStyleWithDefaultsFrom(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
unset := Style{}
set := Style{
StrokeColor: drawing.ColorWhite,
StrokeWidth: 5.0,
FillColor: drawing.ColorWhite,
FontColor: drawing.ColorWhite,
Font: f,
Padding: DefaultBackgroundPadding,
}
coalesced := unset.InheritFrom(set)
testutil.AssertEqual(t, set, coalesced)
}
func TestStyleGetStrokeOptions(t *testing.T) {
// replaced new assertions helper
set := Style{
StrokeColor: drawing.ColorWhite,
StrokeWidth: 5.0,
FillColor: drawing.ColorWhite,
FontColor: drawing.ColorWhite,
Padding: DefaultBackgroundPadding,
}
svgStroke := set.GetStrokeOptions()
testutil.AssertFalse(t, svgStroke.StrokeColor.IsZero())
testutil.AssertNotZero(t, svgStroke.StrokeWidth)
testutil.AssertTrue(t, svgStroke.FillColor.IsZero())
testutil.AssertTrue(t, svgStroke.FontColor.IsZero())
}
func TestStyleGetFillOptions(t *testing.T) {
// replaced new assertions helper
set := Style{
StrokeColor: drawing.ColorWhite,
StrokeWidth: 5.0,
FillColor: drawing.ColorWhite,
FontColor: drawing.ColorWhite,
Padding: DefaultBackgroundPadding,
}
svgFill := set.GetFillOptions()
testutil.AssertFalse(t, svgFill.FillColor.IsZero())
testutil.AssertZero(t, svgFill.StrokeWidth)
testutil.AssertTrue(t, svgFill.StrokeColor.IsZero())
testutil.AssertTrue(t, svgFill.FontColor.IsZero())
}
func TestStyleGetFillAndStrokeOptions(t *testing.T) {
// replaced new assertions helper
set := Style{
StrokeColor: drawing.ColorWhite,
StrokeWidth: 5.0,
FillColor: drawing.ColorWhite,
FontColor: drawing.ColorWhite,
Padding: DefaultBackgroundPadding,
}
svgFillAndStroke := set.GetFillAndStrokeOptions()
testutil.AssertFalse(t, svgFillAndStroke.FillColor.IsZero())
testutil.AssertNotZero(t, svgFillAndStroke.StrokeWidth)
testutil.AssertFalse(t, svgFillAndStroke.StrokeColor.IsZero())
testutil.AssertTrue(t, svgFillAndStroke.FontColor.IsZero())
}
func TestStyleGetTextOptions(t *testing.T) {
// replaced new assertions helper
set := Style{
StrokeColor: drawing.ColorWhite,
StrokeWidth: 5.0,
FillColor: drawing.ColorWhite,
FontColor: drawing.ColorWhite,
Padding: DefaultBackgroundPadding,
}
svgStroke := set.GetTextOptions()
testutil.AssertTrue(t, svgStroke.StrokeColor.IsZero())
testutil.AssertZero(t, svgStroke.StrokeWidth)
testutil.AssertTrue(t, svgStroke.FillColor.IsZero())
testutil.AssertFalse(t, svgStroke.FontColor.IsZero())
}

166
pkg/chart/text.go Normal file
View file

@ -0,0 +1,166 @@
package chart
import (
"strings"
)
// TextHorizontalAlign is an enum for the horizontal alignment options.
type TextHorizontalAlign int
const (
// TextHorizontalAlignUnset is the unset state for text horizontal alignment.
TextHorizontalAlignUnset TextHorizontalAlign = 0
// TextHorizontalAlignLeft aligns a string horizontally so that it's left ligature starts at horizontal pixel 0.
TextHorizontalAlignLeft TextHorizontalAlign = 1
// TextHorizontalAlignCenter left aligns a string horizontally so that there are equal pixels
// to the left and to the right of a string within a box.
TextHorizontalAlignCenter TextHorizontalAlign = 2
// TextHorizontalAlignRight right aligns a string horizontally so that the right ligature ends at the right-most pixel
// of a box.
TextHorizontalAlignRight TextHorizontalAlign = 3
)
// TextWrap is an enum for the word wrap options.
type TextWrap int
const (
// TextWrapUnset is the unset state for text wrap options.
TextWrapUnset TextWrap = 0
// TextWrapNone will spill text past horizontal boundaries.
TextWrapNone TextWrap = 1
// TextWrapWord will split a string on words (i.e. spaces) to fit within a horizontal boundary.
TextWrapWord TextWrap = 2
// TextWrapRune will split a string on a rune (i.e. utf-8 codepage) to fit within a horizontal boundary.
TextWrapRune TextWrap = 3
)
// TextVerticalAlign is an enum for the vertical alignment options.
type TextVerticalAlign int
const (
// TextVerticalAlignUnset is the unset state for vertical alignment options.
TextVerticalAlignUnset TextVerticalAlign = 0
// TextVerticalAlignBaseline aligns text according to the "baseline" of the string, or where a normal ascender begins.
TextVerticalAlignBaseline TextVerticalAlign = 1
// TextVerticalAlignBottom aligns the text according to the lowers pixel of any of the ligatures (ex. g or q both extend below the baseline).
TextVerticalAlignBottom TextVerticalAlign = 2
// TextVerticalAlignMiddle aligns the text so that there is an equal amount of space above and below the top and bottom of the ligatures.
TextVerticalAlignMiddle TextVerticalAlign = 3
// TextVerticalAlignMiddleBaseline aligns the text vertically so that there is an equal number of pixels above and below the baseline of the string.
TextVerticalAlignMiddleBaseline TextVerticalAlign = 4
// TextVerticalAlignTop alignts the text so that the top of the ligatures are at y-pixel 0 in the container.
TextVerticalAlignTop TextVerticalAlign = 5
)
var (
// Text contains utilities for text.
Text = &text{}
)
// TextStyle encapsulates text style options.
type TextStyle struct {
HorizontalAlign TextHorizontalAlign
VerticalAlign TextVerticalAlign
Wrap TextWrap
}
type text struct{}
func (t text) WrapFit(r Renderer, value string, width int, style Style) []string {
switch style.TextWrap {
case TextWrapRune:
return t.WrapFitRune(r, value, width, style)
case TextWrapWord:
return t.WrapFitWord(r, value, width, style)
}
return []string{value}
}
func (t text) WrapFitWord(r Renderer, value string, width int, style Style) []string {
style.WriteToRenderer(r)
var output []string
var line string
var word string
var textBox Box
for _, c := range value {
if c == rune('\n') { // commit the line to output
output = append(output, t.Trim(line+word))
line = ""
word = ""
continue
}
textBox = r.MeasureText(line + word + string(c))
if textBox.Width() >= width {
output = append(output, t.Trim(line))
line = word
word = string(c)
continue
}
if c == rune(' ') || c == rune('\t') {
line = line + word + string(c)
word = ""
continue
}
word = word + string(c)
}
return append(output, t.Trim(line+word))
}
func (t text) WrapFitRune(r Renderer, value string, width int, style Style) []string {
style.WriteToRenderer(r)
var output []string
var line string
var textBox Box
for _, c := range value {
if c == rune('\n') {
output = append(output, line)
line = ""
continue
}
textBox = r.MeasureText(line + string(c))
if textBox.Width() >= width {
output = append(output, line)
line = string(c)
continue
}
line = line + string(c)
}
return t.appendLast(output, line)
}
func (t text) Trim(value string) string {
return strings.Trim(value, " \t\n\r")
}
func (t text) MeasureLines(r Renderer, lines []string, style Style) Box {
style.WriteTextOptionsToRenderer(r)
var output Box
for index, line := range lines {
lineBox := r.MeasureText(line)
output.Right = MaxInt(lineBox.Right, output.Right)
output.Bottom += lineBox.Height()
if index < len(lines)-1 {
output.Bottom += +style.GetTextLineSpacing()
}
}
return output
}
func (t text) appendLast(lines []string, text string) []string {
if len(lines) == 0 {
return []string{text}
}
lastLine := lines[len(lines)-1]
lines[len(lines)-1] = lastLine + text
return lines
}

60
pkg/chart/text_test.go Normal file
View file

@ -0,0 +1,60 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestTextWrapWord(t *testing.T) {
// replaced new assertions helper
r, err := PNG(1024, 1024)
testutil.AssertNil(t, err)
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
basicTextStyle := Style{Font: f, FontSize: 24}
output := Text.WrapFitWord(r, "this is a test string", 100, basicTextStyle)
testutil.AssertNotEmpty(t, output)
testutil.AssertLen(t, output, 3)
for _, line := range output {
basicTextStyle.WriteToRenderer(r)
lineBox := r.MeasureText(line)
testutil.AssertTrue(t, lineBox.Width() < 100, line+": "+lineBox.String())
}
testutil.AssertEqual(t, "this is", output[0])
testutil.AssertEqual(t, "a test", output[1])
testutil.AssertEqual(t, "string", output[2])
output = Text.WrapFitWord(r, "foo", 100, basicTextStyle)
testutil.AssertLen(t, output, 1)
testutil.AssertEqual(t, "foo", output[0])
// test that it handles newlines.
output = Text.WrapFitWord(r, "this\nis\na\ntest\nstring", 100, basicTextStyle)
testutil.AssertLen(t, output, 5)
// test that it handles newlines and long lines.
output = Text.WrapFitWord(r, "this\nis\na\ntest\nstring that is very long", 100, basicTextStyle)
testutil.AssertLen(t, output, 8)
}
func TestTextWrapRune(t *testing.T) {
// replaced new assertions helper
r, err := PNG(1024, 1024)
testutil.AssertNil(t, err)
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
basicTextStyle := Style{Font: f, FontSize: 24}
output := Text.WrapFitRune(r, "this is a test string", 150, basicTextStyle)
testutil.AssertNotEmpty(t, output)
testutil.AssertLen(t, output, 2)
testutil.AssertEqual(t, "this is a t", output[0])
testutil.AssertEqual(t, "est string", output[1])
}

115
pkg/chart/tick.go Normal file
View file

@ -0,0 +1,115 @@
package chart
import (
"fmt"
"math"
"strings"
)
// TicksProvider is a type that provides ticks.
type TicksProvider interface {
GetTicks(r Renderer, defaults Style, vf ValueFormatter) []Tick
}
// Tick represents a label on an axis.
type Tick struct {
Value float64
Label string
}
// Ticks is an array of ticks.
type Ticks []Tick
// Len returns the length of the ticks set.
func (t Ticks) Len() int {
return len(t)
}
// Swap swaps two elements.
func (t Ticks) Swap(i, j int) {
t[i], t[j] = t[j], t[i]
}
// Less returns if i's value is less than j's value.
func (t Ticks) Less(i, j int) bool {
return t[i].Value < t[j].Value
}
// String returns a string representation of the set of ticks.
func (t Ticks) String() string {
var values []string
for i, tick := range t {
values = append(values, fmt.Sprintf("[%d: %s]", i, tick.Label))
}
return strings.Join(values, ", ")
}
// GenerateContinuousTicks generates a set of ticks.
func GenerateContinuousTicks(r Renderer, ra Range, isVertical bool, style Style, vf ValueFormatter) []Tick {
if vf == nil {
vf = FloatValueFormatter
}
var ticks []Tick
min, max := ra.GetMin(), ra.GetMax()
if ra.IsDescending() {
ticks = append(ticks, Tick{
Value: max,
Label: vf(max),
})
} else {
ticks = append(ticks, Tick{
Value: min,
Label: vf(min),
})
}
minLabel := vf(min)
style.GetTextOptions().WriteToRenderer(r)
labelBox := r.MeasureText(minLabel)
var tickSize float64
if isVertical {
tickSize = float64(labelBox.Height() + DefaultMinimumTickVerticalSpacing)
} else {
tickSize = float64(labelBox.Width() + DefaultMinimumTickHorizontalSpacing)
}
domain := float64(ra.GetDomain())
domainRemainder := domain - (tickSize * 2)
intermediateTickCount := int(math.Floor(float64(domainRemainder) / float64(tickSize)))
rangeDelta := math.Abs(max - min)
tickStep := rangeDelta / float64(intermediateTickCount)
roundTo := GetRoundToForDelta(rangeDelta) / 10
intermediateTickCount = MinInt(intermediateTickCount, DefaultTickCountSanityCheck)
for x := 1; x < intermediateTickCount; x++ {
var tickValue float64
if ra.IsDescending() {
tickValue = max - RoundUp(tickStep*float64(x), roundTo)
} else {
tickValue = min + RoundUp(tickStep*float64(x), roundTo)
}
ticks = append(ticks, Tick{
Value: tickValue,
Label: vf(tickValue),
})
}
if ra.IsDescending() {
ticks = append(ticks, Tick{
Value: min,
Label: vf(min),
})
} else {
ticks = append(ticks, Tick{
Value: max,
Label: vf(max),
})
}
return ticks
}

60
pkg/chart/tick_test.go Normal file
View file

@ -0,0 +1,60 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestGenerateContinuousTicks(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
r, err := PNG(1024, 1024)
testutil.AssertNil(t, err)
r.SetFont(f)
ra := &ContinuousRange{
Min: 0.0,
Max: 10.0,
Domain: 256,
}
vf := FloatValueFormatter
ticks := GenerateContinuousTicks(r, ra, false, Style{}, vf)
testutil.AssertNotEmpty(t, ticks)
testutil.AssertLen(t, ticks, 11)
testutil.AssertEqual(t, 0.0, ticks[0].Value)
testutil.AssertEqual(t, 10, ticks[len(ticks)-1].Value)
}
func TestGenerateContinuousTicksDescending(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
r, err := PNG(1024, 1024)
testutil.AssertNil(t, err)
r.SetFont(f)
ra := &ContinuousRange{
Min: 0.0,
Max: 10.0,
Domain: 256,
Descending: true,
}
vf := FloatValueFormatter
ticks := GenerateContinuousTicks(r, ra, false, Style{}, vf)
testutil.AssertNotEmpty(t, ticks)
testutil.AssertLen(t, ticks, 11)
testutil.AssertEqual(t, 10.0, ticks[0].Value)
testutil.AssertEqual(t, 9.0, ticks[1].Value)
testutil.AssertEqual(t, 1.0, ticks[len(ticks)-2].Value)
testutil.AssertEqual(t, 0.0, ticks[len(ticks)-1].Value)
}

91
pkg/chart/time_series.go Normal file
View file

@ -0,0 +1,91 @@
package chart
import (
"fmt"
"time"
)
// Interface Assertions.
var (
_ Series = (*TimeSeries)(nil)
_ FirstValuesProvider = (*TimeSeries)(nil)
_ LastValuesProvider = (*TimeSeries)(nil)
_ ValueFormatterProvider = (*TimeSeries)(nil)
)
// TimeSeries is a line on a chart.
type TimeSeries struct {
Name string
Style Style
YAxis YAxisType
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)
}
// GetValues gets x, y values at a given index.
func (ts TimeSeries) GetValues(index int) (x, y float64) {
x = TimeToFloat64(ts.XValues[index])
y = ts.YValues[index]
return
}
// GetFirstValues gets the first values.
func (ts TimeSeries) GetFirstValues() (x, y float64) {
x = TimeToFloat64(ts.XValues[0])
y = ts.YValues[0]
return
}
// GetLastValues gets the last values.
func (ts TimeSeries) GetLastValues() (x, y float64) {
x = TimeToFloat64(ts.XValues[len(ts.XValues)-1])
y = ts.YValues[len(ts.YValues)-1]
return
}
// GetValueFormatters returns value formatter defaults for the series.
func (ts TimeSeries) GetValueFormatters() (x, y ValueFormatter) {
x = TimeValueFormatter
y = FloatValueFormatter
return
}
// GetYAxis returns which YAxis the series draws on.
func (ts TimeSeries) GetYAxis() YAxisType {
return ts.YAxis
}
// Render renders the series.
func (ts TimeSeries) Render(r Renderer, canvasBox Box, xrange, yrange Range, defaults Style) {
style := ts.Style.InheritFrom(defaults)
Draw.LineSeries(r, canvasBox, xrange, yrange, style, ts)
}
// Validate validates the series.
func (ts TimeSeries) Validate() error {
if len(ts.XValues) == 0 {
return fmt.Errorf("time series must have xvalues set")
}
if len(ts.YValues) == 0 {
return fmt.Errorf("time series must have yvalues set")
}
return nil
}

View file

@ -0,0 +1,69 @@
package chart
import (
"testing"
"time"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestTimeSeriesGetValue(t *testing.T) {
// replaced new assertions helper
ts := TimeSeries{
Name: "Test",
XValues: []time.Time{
time.Now().AddDate(0, 0, -5),
time.Now().AddDate(0, 0, -4),
time.Now().AddDate(0, 0, -3),
time.Now().AddDate(0, 0, -2),
time.Now().AddDate(0, 0, -1),
},
YValues: []float64{
1.0, 2.0, 3.0, 4.0, 5.0,
},
}
x0, y0 := ts.GetValues(0)
testutil.AssertNotZero(t, x0)
testutil.AssertEqual(t, 1.0, y0)
}
func TestTimeSeriesValidate(t *testing.T) {
// replaced new assertions helper
cs := TimeSeries{
Name: "Test Series",
XValues: []time.Time{
time.Now().AddDate(0, 0, -5),
time.Now().AddDate(0, 0, -4),
time.Now().AddDate(0, 0, -3),
time.Now().AddDate(0, 0, -2),
time.Now().AddDate(0, 0, -1),
},
YValues: []float64{
1.0, 2.0, 3.0, 4.0, 5.0,
},
}
testutil.AssertNil(t, cs.Validate())
cs = TimeSeries{
Name: "Test Series",
XValues: []time.Time{
time.Now().AddDate(0, 0, -5),
time.Now().AddDate(0, 0, -4),
time.Now().AddDate(0, 0, -3),
time.Now().AddDate(0, 0, -2),
time.Now().AddDate(0, 0, -1),
},
}
testutil.AssertNotNil(t, cs.Validate())
cs = TimeSeries{
Name: "Test Series",
YValues: []float64{
1.0, 2.0, 3.0, 4.0, 5.0,
},
}
testutil.AssertNotNil(t, cs.Validate())
}

46
pkg/chart/times.go Normal file
View file

@ -0,0 +1,46 @@
package chart
import (
"sort"
"time"
)
// Assert types implement interfaces.
var (
_ Sequence = (*Times)(nil)
_ sort.Interface = (*Times)(nil)
)
// Times are an array of times.
// It wraps the array with methods that implement `seq.Provider`.
type Times []time.Time
// Array returns the times to an array.
func (t Times) Array() []time.Time {
return []time.Time(t)
}
// Len returns the length of the array.
func (t Times) Len() int {
return len(t)
}
// GetValue returns a value at an index as a time.
func (t Times) GetValue(index int) float64 {
return ToFloat64(t[index])
}
// Swap implements sort.Interface.
func (t Times) Swap(i, j int) {
t[i], t[j] = t[j], t[i]
}
// Less implements sort.Interface.
func (t Times) Less(i, j int) bool {
return t[i].Before(t[j])
}
// ToFloat64 returns a float64 representation of a time.
func ToFloat64(t time.Time) float64 {
return float64(t.UnixNano())
}

150
pkg/chart/timeutil.go Normal file
View file

@ -0,0 +1,150 @@
package chart
import "time"
// SecondsPerXYZ
const (
SecondsPerHour = 60 * 60
SecondsPerDay = 60 * 60 * 24
)
// TimeMillis returns a duration as a float millis.
func TimeMillis(d time.Duration) float64 {
return float64(d) / float64(time.Millisecond)
}
// DiffHours returns the difference in hours between two times.
func DiffHours(t1, t2 time.Time) (hours int) {
t1n := t1.Unix()
t2n := t2.Unix()
var diff int64
if t1n > t2n {
diff = t1n - t2n
} else {
diff = t2n - t1n
}
return int(diff / (SecondsPerHour))
}
// TimeMin returns the minimum and maximum times in a given range.
func TimeMin(times ...time.Time) (min time.Time) {
if len(times) == 0 {
return
}
min = times[0]
for index := 1; index < len(times); index++ {
if times[index].Before(min) {
min = times[index]
}
}
return
}
// TimeMax returns the minimum and maximum times in a given range.
func TimeMax(times ...time.Time) (max time.Time) {
if len(times) == 0 {
return
}
max = times[0]
for index := 1; index < len(times); index++ {
if times[index].After(max) {
max = times[index]
}
}
return
}
// TimeMinMax returns the minimum and maximum times in a given range.
func TimeMinMax(times ...time.Time) (min, max time.Time) {
if len(times) == 0 {
return
}
min = times[0]
max = times[0]
for index := 1; index < len(times); index++ {
if times[index].Before(min) {
min = times[index]
}
if times[index].After(max) {
max = times[index]
}
}
return
}
// TimeToFloat64 returns a float64 representation of a time.
func TimeToFloat64(t time.Time) float64 {
return float64(t.UnixNano())
}
// TimeFromFloat64 returns a time from a float64.
func TimeFromFloat64(tf float64) time.Time {
return time.Unix(0, int64(tf))
}
// TimeDescending sorts a given list of times ascending, or min to max.
type TimeDescending []time.Time
// Len implements sort.Sorter
func (d TimeDescending) Len() int { return len(d) }
// Swap implements sort.Sorter
func (d TimeDescending) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
// Less implements sort.Sorter
func (d TimeDescending) Less(i, j int) bool { return d[i].After(d[j]) }
// TimeAscending sorts a given list of times ascending, or min to max.
type TimeAscending []time.Time
// Len implements sort.Sorter
func (a TimeAscending) Len() int { return len(a) }
// Swap implements sort.Sorter
func (a TimeAscending) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
// Less implements sort.Sorter
func (a TimeAscending) Less(i, j int) bool { return a[i].Before(a[j]) }
// Days generates a seq of timestamps by day, from -days to today.
func 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
}
// Hours returns a sequence of times by the hour for a given number of hours
// after a given start.
func Hours(start time.Time, totalHours int) []time.Time {
times := make([]time.Time, totalHours)
last := start
for i := 0; i < totalHours; i++ {
times[i] = last
last = last.Add(time.Hour)
}
return times
}
// HoursFilled adds zero values for the data bounded by the start and end of the xdata array.
func HoursFilled(xdata []time.Time, ydata []float64) ([]time.Time, []float64) {
start, end := TimeMinMax(xdata...)
totalHours := DiffHours(start, end)
finalTimes := Hours(start, totalHours+1)
finalValues := make([]float64, totalHours+1)
var hoursFromStart int
for i, xd := range xdata {
hoursFromStart = DiffHours(start, xd)
finalValues[hoursFromStart] = ydata[i]
}
return finalTimes, finalValues
}

53
pkg/chart/value.go Normal file
View file

@ -0,0 +1,53 @@
package chart
// Value is a chart value.
type Value struct {
Style Style
Label string
Value float64
}
// Values is an array of Value.
type Values []Value
// Values returns the values.
func (vs Values) Values() []float64 {
values := make([]float64, len(vs))
for index, v := range vs {
values[index] = v.Value
}
return values
}
// ValuesNormalized returns normalized values.
func (vs Values) ValuesNormalized() []float64 {
return Normalize(vs.Values()...)
}
// Normalize returns the values normalized.
func (vs Values) Normalize() []Value {
var output []Value
var total float64
for _, v := range vs {
total += v.Value
}
for _, v := range vs {
if v.Value > 0 {
output = append(output, Value{
Style: v.Style,
Label: v.Label,
Value: RoundDown(v.Value/total, 0.0001),
})
}
}
return output
}
// Value2 is a two axis value.
type Value2 struct {
Style Style
Label string
XValue, YValue float64
}

220
pkg/chart/value_buffer.go Normal file
View file

@ -0,0 +1,220 @@
package chart
import (
"fmt"
"strings"
)
const (
bufferMinimumGrow = 4
bufferShrinkThreshold = 32
bufferGrowFactor = 200
bufferDefaultCapacity = 4
)
// NewValueBuffer creates a new value buffer with an optional set of values.
func NewValueBuffer(values ...float64) *ValueBuffer {
var tail int
array := make([]float64, MaxInt(len(values), bufferDefaultCapacity))
if len(values) > 0 {
copy(array, values)
tail = len(values)
}
return &ValueBuffer{
array: array,
head: 0,
tail: tail,
size: len(values),
}
}
// NewValueBufferWithCapacity creates a new ValueBuffer pre-allocated with the given capacity.
func NewValueBufferWithCapacity(capacity int) *ValueBuffer {
return &ValueBuffer{
array: make([]float64, capacity),
head: 0,
tail: 0,
size: 0,
}
}
// ValueBuffer is a fifo datastructure that is backed by a pre-allocated array.
// Instead of allocating a whole new node object for each element, array elements are re-used (which saves GC churn).
// Enqueue can be O(n), Dequeue is generally O(1).
// Buffer implements `seq.Provider`
type ValueBuffer struct {
array []float64
head int
tail int
size int
}
// Len returns the length of the Buffer (as it is currently populated).
// Actual memory footprint may be different.
func (b *ValueBuffer) Len() int {
return b.size
}
// GetValue implements seq provider.
func (b *ValueBuffer) GetValue(index int) float64 {
effectiveIndex := (b.head + index) % len(b.array)
return b.array[effectiveIndex]
}
// Capacity returns the total size of the Buffer, including empty elements.
func (b *ValueBuffer) Capacity() int {
return len(b.array)
}
// SetCapacity sets the capacity of the Buffer.
func (b *ValueBuffer) SetCapacity(capacity int) {
newArray := make([]float64, capacity)
if b.size > 0 {
if b.head < b.tail {
arrayCopy(b.array, b.head, newArray, 0, b.size)
} else {
arrayCopy(b.array, b.head, newArray, 0, len(b.array)-b.head)
arrayCopy(b.array, 0, newArray, len(b.array)-b.head, b.tail)
}
}
b.array = newArray
b.head = 0
if b.size == capacity {
b.tail = 0
} else {
b.tail = b.size
}
}
// Clear removes all objects from the Buffer.
func (b *ValueBuffer) Clear() {
b.array = make([]float64, bufferDefaultCapacity)
b.head = 0
b.tail = 0
b.size = 0
}
// Enqueue adds an element to the "back" of the Buffer.
func (b *ValueBuffer) Enqueue(value float64) {
if b.size == len(b.array) {
newCapacity := int(len(b.array) * int(bufferGrowFactor/100))
if newCapacity < (len(b.array) + bufferMinimumGrow) {
newCapacity = len(b.array) + bufferMinimumGrow
}
b.SetCapacity(newCapacity)
}
b.array[b.tail] = value
b.tail = (b.tail + 1) % len(b.array)
b.size++
}
// Dequeue removes the first element from the RingBuffer.
func (b *ValueBuffer) Dequeue() float64 {
if b.size == 0 {
return 0
}
removed := b.array[b.head]
b.head = (b.head + 1) % len(b.array)
b.size--
return removed
}
// Peek returns but does not remove the first element.
func (b *ValueBuffer) Peek() float64 {
if b.size == 0 {
return 0
}
return b.array[b.head]
}
// PeekBack returns but does not remove the last element.
func (b *ValueBuffer) PeekBack() float64 {
if b.size == 0 {
return 0
}
if b.tail == 0 {
return b.array[len(b.array)-1]
}
return b.array[b.tail-1]
}
// TrimExcess resizes the capacity of the buffer to better fit the contents.
func (b *ValueBuffer) TrimExcess() {
threshold := float64(len(b.array)) * 0.9
if b.size < int(threshold) {
b.SetCapacity(b.size)
}
}
// Array returns the ring buffer, in order, as an array.
func (b *ValueBuffer) Array() Array {
newArray := make([]float64, b.size)
if b.size == 0 {
return newArray
}
if b.head < b.tail {
arrayCopy(b.array, b.head, newArray, 0, b.size)
} else {
arrayCopy(b.array, b.head, newArray, 0, len(b.array)-b.head)
arrayCopy(b.array, 0, newArray, len(b.array)-b.head, b.tail)
}
return Array(newArray)
}
// Each calls the consumer for each element in the buffer.
func (b *ValueBuffer) Each(mapfn func(int, float64)) {
if b.size == 0 {
return
}
var index int
if b.head < b.tail {
for cursor := b.head; cursor < b.tail; cursor++ {
mapfn(index, b.array[cursor])
index++
}
} else {
for cursor := b.head; cursor < len(b.array); cursor++ {
mapfn(index, b.array[cursor])
index++
}
for cursor := 0; cursor < b.tail; cursor++ {
mapfn(index, b.array[cursor])
index++
}
}
}
// String returns a string representation for value buffers.
func (b *ValueBuffer) String() string {
var values []string
for _, elem := range b.Array() {
values = append(values, fmt.Sprintf("%v", elem))
}
return strings.Join(values, " <= ")
}
// --------------------------------------------------------------------------------
// Util methods
// --------------------------------------------------------------------------------
func arrayClear(source []float64, index, length int) {
for x := 0; x < length; x++ {
absoluteIndex := x + index
source[absoluteIndex] = 0
}
}
func arrayCopy(source []float64, sourceIndex int, destination []float64, destinationIndex, length int) {
for x := 0; x < length; x++ {
from := sourceIndex + x
to := destinationIndex + x
destination[to] = source[from]
}
}

View file

@ -0,0 +1,192 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestBuffer(t *testing.T) {
// replaced new assertions helper
buffer := NewValueBuffer()
buffer.Enqueue(1)
testutil.AssertEqual(t, 1, buffer.Len())
testutil.AssertEqual(t, 1, buffer.Peek())
testutil.AssertEqual(t, 1, buffer.PeekBack())
buffer.Enqueue(2)
testutil.AssertEqual(t, 2, buffer.Len())
testutil.AssertEqual(t, 1, buffer.Peek())
testutil.AssertEqual(t, 2, buffer.PeekBack())
buffer.Enqueue(3)
testutil.AssertEqual(t, 3, buffer.Len())
testutil.AssertEqual(t, 1, buffer.Peek())
testutil.AssertEqual(t, 3, buffer.PeekBack())
buffer.Enqueue(4)
testutil.AssertEqual(t, 4, buffer.Len())
testutil.AssertEqual(t, 1, buffer.Peek())
testutil.AssertEqual(t, 4, buffer.PeekBack())
buffer.Enqueue(5)
testutil.AssertEqual(t, 5, buffer.Len())
testutil.AssertEqual(t, 1, buffer.Peek())
testutil.AssertEqual(t, 5, buffer.PeekBack())
buffer.Enqueue(6)
testutil.AssertEqual(t, 6, buffer.Len())
testutil.AssertEqual(t, 1, buffer.Peek())
testutil.AssertEqual(t, 6, buffer.PeekBack())
buffer.Enqueue(7)
testutil.AssertEqual(t, 7, buffer.Len())
testutil.AssertEqual(t, 1, buffer.Peek())
testutil.AssertEqual(t, 7, buffer.PeekBack())
buffer.Enqueue(8)
testutil.AssertEqual(t, 8, buffer.Len())
testutil.AssertEqual(t, 1, buffer.Peek())
testutil.AssertEqual(t, 8, buffer.PeekBack())
value := buffer.Dequeue()
testutil.AssertEqual(t, 1, value)
testutil.AssertEqual(t, 7, buffer.Len())
testutil.AssertEqual(t, 2, buffer.Peek())
testutil.AssertEqual(t, 8, buffer.PeekBack())
value = buffer.Dequeue()
testutil.AssertEqual(t, 2, value)
testutil.AssertEqual(t, 6, buffer.Len())
testutil.AssertEqual(t, 3, buffer.Peek())
testutil.AssertEqual(t, 8, buffer.PeekBack())
value = buffer.Dequeue()
testutil.AssertEqual(t, 3, value)
testutil.AssertEqual(t, 5, buffer.Len())
testutil.AssertEqual(t, 4, buffer.Peek())
testutil.AssertEqual(t, 8, buffer.PeekBack())
value = buffer.Dequeue()
testutil.AssertEqual(t, 4, value)
testutil.AssertEqual(t, 4, buffer.Len())
testutil.AssertEqual(t, 5, buffer.Peek())
testutil.AssertEqual(t, 8, buffer.PeekBack())
value = buffer.Dequeue()
testutil.AssertEqual(t, 5, value)
testutil.AssertEqual(t, 3, buffer.Len())
testutil.AssertEqual(t, 6, buffer.Peek())
testutil.AssertEqual(t, 8, buffer.PeekBack())
value = buffer.Dequeue()
testutil.AssertEqual(t, 6, value)
testutil.AssertEqual(t, 2, buffer.Len())
testutil.AssertEqual(t, 7, buffer.Peek())
testutil.AssertEqual(t, 8, buffer.PeekBack())
value = buffer.Dequeue()
testutil.AssertEqual(t, 7, value)
testutil.AssertEqual(t, 1, buffer.Len())
testutil.AssertEqual(t, 8, buffer.Peek())
testutil.AssertEqual(t, 8, buffer.PeekBack())
value = buffer.Dequeue()
testutil.AssertEqual(t, 8, value)
testutil.AssertEqual(t, 0, buffer.Len())
testutil.AssertZero(t, buffer.Peek())
testutil.AssertZero(t, buffer.PeekBack())
}
func TestBufferClear(t *testing.T) {
// replaced new assertions helper
buffer := NewValueBuffer()
buffer.Enqueue(1)
buffer.Enqueue(1)
buffer.Enqueue(1)
buffer.Enqueue(1)
buffer.Enqueue(1)
buffer.Enqueue(1)
buffer.Enqueue(1)
buffer.Enqueue(1)
testutil.AssertEqual(t, 8, buffer.Len())
buffer.Clear()
testutil.AssertEqual(t, 0, buffer.Len())
testutil.AssertZero(t, buffer.Peek())
testutil.AssertZero(t, buffer.PeekBack())
}
func TestBufferArray(t *testing.T) {
// replaced new assertions helper
buffer := NewValueBuffer()
buffer.Enqueue(1)
buffer.Enqueue(2)
buffer.Enqueue(3)
buffer.Enqueue(4)
buffer.Enqueue(5)
contents := buffer.Array()
testutil.AssertLen(t, contents, 5)
testutil.AssertEqual(t, 1, contents[0])
testutil.AssertEqual(t, 2, contents[1])
testutil.AssertEqual(t, 3, contents[2])
testutil.AssertEqual(t, 4, contents[3])
testutil.AssertEqual(t, 5, contents[4])
}
func TestBufferEach(t *testing.T) {
// replaced new assertions helper
buffer := NewValueBuffer()
for x := 1; x < 17; x++ {
buffer.Enqueue(float64(x))
}
called := 0
buffer.Each(func(_ int, v float64) {
if v == float64(called+1) {
called++
}
})
testutil.AssertEqual(t, 16, called)
}
func TestNewBuffer(t *testing.T) {
// replaced new assertions helper
empty := NewValueBuffer()
testutil.AssertNotNil(t, empty)
testutil.AssertZero(t, empty.Len())
testutil.AssertEqual(t, bufferDefaultCapacity, empty.Capacity())
testutil.AssertZero(t, empty.Peek())
testutil.AssertZero(t, empty.PeekBack())
}
func TestNewBufferWithValues(t *testing.T) {
// replaced new assertions helper
values := NewValueBuffer(1, 2, 3, 4, 5)
testutil.AssertNotNil(t, values)
testutil.AssertEqual(t, 5, values.Len())
testutil.AssertEqual(t, 1, values.Peek())
testutil.AssertEqual(t, 5, values.PeekBack())
}
func TestBufferGrowth(t *testing.T) {
// replaced new assertions helper
values := NewValueBuffer(1, 2, 3, 4, 5)
for i := 0; i < 1<<10; i++ {
values.Enqueue(float64(i))
}
testutil.AssertEqual(t, 1<<10-1, values.PeekBack())
}

View file

@ -0,0 +1,105 @@
package chart
import (
"fmt"
"strconv"
"time"
)
// ValueFormatter is a function that takes a value and produces a string.
type ValueFormatter func(v interface{}) string
// TimeValueFormatter is a ValueFormatter for timestamps.
func TimeValueFormatter(v interface{}) string {
return formatTime(v, DefaultDateFormat)
}
// TimeHourValueFormatter is a ValueFormatter for timestamps.
func TimeHourValueFormatter(v interface{}) string {
return formatTime(v, DefaultDateHourFormat)
}
// TimeMinuteValueFormatter is a ValueFormatter for timestamps.
func TimeMinuteValueFormatter(v interface{}) string {
return formatTime(v, DefaultDateMinuteFormat)
}
// TimeDateValueFormatter is a ValueFormatter for timestamps.
func TimeDateValueFormatter(v interface{}) string {
return formatTime(v, "2006-01-02")
}
// TimeValueFormatterWithFormat returns a time formatter with a given format.
func TimeValueFormatterWithFormat(format string) ValueFormatter {
return func(v interface{}) string {
return formatTime(v, format)
}
}
// TimeValueFormatterWithFormat is a ValueFormatter for timestamps with a given format.
func formatTime(v interface{}, dateFormat string) string {
if typed, isTyped := v.(time.Time); isTyped {
return typed.Format(dateFormat)
}
if typed, isTyped := v.(int64); isTyped {
return time.Unix(0, typed).Format(dateFormat)
}
if typed, isTyped := v.(float64); isTyped {
return time.Unix(0, int64(typed)).Format(dateFormat)
}
return ""
}
// IntValueFormatter is a ValueFormatter for float64.
func IntValueFormatter(v interface{}) string {
switch v.(type) {
case int:
return strconv.Itoa(v.(int))
case int64:
return strconv.FormatInt(v.(int64), 10)
case float32:
return strconv.FormatInt(int64(v.(float32)), 10)
case float64:
return strconv.FormatInt(int64(v.(float64)), 10)
default:
return ""
}
}
// FloatValueFormatter is a ValueFormatter for float64.
func FloatValueFormatter(v interface{}) string {
return FloatValueFormatterWithFormat(v, DefaultFloatFormat)
}
// PercentValueFormatter is a formatter for percent values.
// NOTE: it normalizes the values, i.e. multiplies by 100.0.
func PercentValueFormatter(v interface{}) string {
if typed, isTyped := v.(float64); isTyped {
return FloatValueFormatterWithFormat(typed*100.0, DefaultPercentValueFormat)
}
return ""
}
// FloatValueFormatterWithFormat is a ValueFormatter for float64 with a given format.
func FloatValueFormatterWithFormat(v interface{}, floatFormat string) string {
if typed, isTyped := v.(int); isTyped {
return fmt.Sprintf(floatFormat, float64(typed))
}
if typed, isTyped := v.(int64); isTyped {
return fmt.Sprintf(floatFormat, float64(typed))
}
if typed, isTyped := v.(float32); isTyped {
return fmt.Sprintf(floatFormat, typed)
}
if typed, isTyped := v.(float64); isTyped {
return fmt.Sprintf(floatFormat, typed)
}
return ""
}
// KValueFormatter is a formatter for K values.
func KValueFormatter(k float64, vf ValueFormatter) ValueFormatter {
return func(v interface{}) string {
return fmt.Sprintf("%0.0fσ %s", k, vf(v))
}
}

View file

@ -0,0 +1,6 @@
package chart
// ValueFormatterProvider is a series that has custom formatters.
type ValueFormatterProvider interface {
GetValueFormatters() (x, y ValueFormatter)
}

View file

@ -0,0 +1,58 @@
package chart
import (
"testing"
"time"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestTimeValueFormatterWithFormat(t *testing.T) {
// replaced new assertions helper
d := time.Now()
di := TimeToFloat64(d)
df := float64(di)
s := formatTime(d, DefaultDateFormat)
si := formatTime(di, DefaultDateFormat)
sf := formatTime(df, DefaultDateFormat)
testutil.AssertEqual(t, s, si)
testutil.AssertEqual(t, s, sf)
sd := TimeValueFormatter(d)
sdi := TimeValueFormatter(di)
sdf := TimeValueFormatter(df)
testutil.AssertEqual(t, s, sd)
testutil.AssertEqual(t, s, sdi)
testutil.AssertEqual(t, s, sdf)
}
func TestFloatValueFormatter(t *testing.T) {
// replaced new assertions helper
testutil.AssertEqual(t, "1234.00", FloatValueFormatter(1234.00))
}
func TestFloatValueFormatterWithFloat32Input(t *testing.T) {
// replaced new assertions helper
testutil.AssertEqual(t, "1234.00", FloatValueFormatter(float32(1234.00)))
}
func TestFloatValueFormatterWithIntegerInput(t *testing.T) {
// replaced new assertions helper
testutil.AssertEqual(t, "1234.00", FloatValueFormatter(1234))
}
func TestFloatValueFormatterWithInt64Input(t *testing.T) {
// replaced new assertions helper
testutil.AssertEqual(t, "1234.00", FloatValueFormatter(int64(1234)))
}
func TestFloatValueFormatterWithFormat(t *testing.T) {
// replaced new assertions helper
v := 123.456
sv := FloatValueFormatterWithFormat(v, "%.3f")
testutil.AssertEqual(t, "123.456", sv)
testutil.AssertEqual(t, "123.000", FloatValueFormatterWithFormat(123, "%.3f"))
}

View file

@ -0,0 +1,51 @@
package chart
import "github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
// ValuesProvider is a type that produces values.
type ValuesProvider interface {
Len() int
GetValues(index int) (float64, float64)
}
// BoundedValuesProvider allows series to return a range.
type BoundedValuesProvider interface {
Len() int
GetBoundedValues(index int) (x, y1, y2 float64)
}
// FirstValuesProvider is a special type of value provider that can return it's (potentially computed) first value.
type FirstValuesProvider interface {
GetFirstValues() (x, y float64)
}
// LastValuesProvider is a special type of value provider that can return it's (potentially computed) last value.
type LastValuesProvider interface {
GetLastValues() (x, y float64)
}
// BoundedLastValuesProvider is a special type of value provider that can return it's (potentially computed) bounded last value.
type BoundedLastValuesProvider interface {
GetBoundedLastValues() (x, y1, y2 float64)
}
// FullValuesProvider is an interface that combines `ValuesProvider` and `LastValuesProvider`
type FullValuesProvider interface {
ValuesProvider
LastValuesProvider
}
// FullBoundedValuesProvider is an interface that combines `BoundedValuesProvider` and `BoundedLastValuesProvider`
type FullBoundedValuesProvider interface {
BoundedValuesProvider
BoundedLastValuesProvider
}
// SizeProvider is a provider for integer size.
type SizeProvider func(xrange, yrange Range, index int, x, y float64) float64
// ColorProvider is a general provider for color ranges based on values.
type ColorProvider func(v, vmin, vmax float64) drawing.Color
// DotColorProvider is a provider for dot color.
type DotColorProvider func(xrange, yrange Range, index int, x, y float64) drawing.Color

69
pkg/chart/value_test.go Normal file
View file

@ -0,0 +1,69 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestValuesValues(t *testing.T) {
// replaced new assertions helper
vs := []Value{
{Value: 10, Label: "Blue"},
{Value: 9, Label: "Green"},
{Value: 8, Label: "Gray"},
{Value: 7, Label: "Orange"},
{Value: 6, Label: "HEANG"},
{Value: 5, Label: "??"},
{Value: 2, Label: "!!"},
}
values := Values(vs).Values()
testutil.AssertLen(t, values, 7)
testutil.AssertEqual(t, 10, values[0])
testutil.AssertEqual(t, 9, values[1])
testutil.AssertEqual(t, 8, values[2])
testutil.AssertEqual(t, 7, values[3])
testutil.AssertEqual(t, 6, values[4])
testutil.AssertEqual(t, 5, values[5])
testutil.AssertEqual(t, 2, values[6])
}
func TestValuesValuesNormalized(t *testing.T) {
// replaced new assertions helper
vs := []Value{
{Value: 10, Label: "Blue"},
{Value: 9, Label: "Green"},
{Value: 8, Label: "Gray"},
{Value: 7, Label: "Orange"},
{Value: 6, Label: "HEANG"},
{Value: 5, Label: "??"},
{Value: 2, Label: "!!"},
}
values := Values(vs).ValuesNormalized()
testutil.AssertLen(t, values, 7)
testutil.AssertEqual(t, 0.2127, values[0])
testutil.AssertEqual(t, 0.0425, values[6])
}
func TestValuesNormalize(t *testing.T) {
// replaced new assertions helper
vs := []Value{
{Value: 10, Label: "Blue"},
{Value: 9, Label: "Green"},
{Value: 8, Label: "Gray"},
{Value: 7, Label: "Orange"},
{Value: 6, Label: "HEANG"},
{Value: 5, Label: "??"},
{Value: 2, Label: "!!"},
}
values := Values(vs).Normalize()
testutil.AssertLen(t, values, 7)
testutil.AssertEqual(t, 0.2127, values[0].Value)
testutil.AssertEqual(t, 0.0425, values[6].Value)
}

View file

@ -0,0 +1,365 @@
package chart
import (
"bytes"
"fmt"
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
"io"
"math"
"strings"
"golang.org/x/image/font"
"github.com/golang/freetype/truetype"
)
// SVG returns a new png/raster renderer.
func SVG(width, height int) (Renderer, error) {
buffer := bytes.NewBuffer([]byte{})
canvas := newCanvas(buffer)
canvas.Start(width, height)
return &vectorRenderer{
b: buffer,
c: canvas,
s: &Style{},
p: []string{},
dpi: DefaultDPI,
}, nil
}
// SVGWithCSS returns a new png/raster renderer with attached custom CSS
// The optional nonce argument sets a CSP nonce.
func SVGWithCSS(css string, nonce string) func(width, height int) (Renderer, error) {
return func(width, height int) (Renderer, error) {
buffer := bytes.NewBuffer([]byte{})
canvas := newCanvas(buffer)
canvas.css = css
canvas.nonce = nonce
canvas.Start(width, height)
return &vectorRenderer{
b: buffer,
c: canvas,
s: &Style{},
p: []string{},
dpi: DefaultDPI,
}, nil
}
}
// vectorRenderer renders chart commands to a bitmap.
type vectorRenderer struct {
dpi float64
b *bytes.Buffer
c *canvas
s *Style
p []string
fc *font.Drawer
}
func (vr *vectorRenderer) ResetStyle() {
vr.s = &Style{Font: vr.s.Font}
vr.fc = nil
}
// GetDPI returns the dpi.
func (vr *vectorRenderer) GetDPI() float64 {
return vr.dpi
}
// SetDPI implements the interface method.
func (vr *vectorRenderer) SetDPI(dpi float64) {
vr.dpi = dpi
vr.c.dpi = dpi
}
// SetClassName implements the interface method.
func (vr *vectorRenderer) SetClassName(classname string) {
vr.s.ClassName = classname
}
// SetStrokeColor implements the interface method.
func (vr *vectorRenderer) SetStrokeColor(c drawing.Color) {
vr.s.StrokeColor = c
}
// SetFillColor implements the interface method.
func (vr *vectorRenderer) SetFillColor(c drawing.Color) {
vr.s.FillColor = c
}
// SetLineWidth implements the interface method.
func (vr *vectorRenderer) SetStrokeWidth(width float64) {
vr.s.StrokeWidth = width
}
// StrokeDashArray sets the stroke dash array.
func (vr *vectorRenderer) SetStrokeDashArray(dashArray []float64) {
vr.s.StrokeDashArray = dashArray
}
// MoveTo implements the interface method.
func (vr *vectorRenderer) MoveTo(x, y int) {
vr.p = append(vr.p, fmt.Sprintf("M %d %d", x, y))
}
// LineTo implements the interface method.
func (vr *vectorRenderer) LineTo(x, y int) {
vr.p = append(vr.p, fmt.Sprintf("L %d %d", x, y))
}
// QuadCurveTo draws a quad curve.
func (vr *vectorRenderer) QuadCurveTo(cx, cy, x, y int) {
vr.p = append(vr.p, fmt.Sprintf("Q%d,%d %d,%d", cx, cy, x, y))
}
func (vr *vectorRenderer) ArcTo(cx, cy int, rx, ry, startAngle, delta float64) {
startAngle = RadianAdd(startAngle, _pi2)
endAngle := RadianAdd(startAngle, delta)
startx := cx + int(rx*math.Sin(startAngle))
starty := cy - int(ry*math.Cos(startAngle))
if len(vr.p) > 0 {
vr.p = append(vr.p, fmt.Sprintf("L %d %d", startx, starty))
} else {
vr.p = append(vr.p, fmt.Sprintf("M %d %d", startx, starty))
}
endx := cx + int(rx*math.Sin(endAngle))
endy := cy - int(ry*math.Cos(endAngle))
dd := RadiansToDegrees(delta)
largeArcFlag := 0
if delta > _pi {
largeArcFlag = 1
}
vr.p = append(vr.p, fmt.Sprintf("A %d %d %0.2f %d 1 %d %d", int(rx), int(ry), dd, largeArcFlag, endx, endy))
}
// Close closes a shape.
func (vr *vectorRenderer) Close() {
vr.p = append(vr.p, fmt.Sprintf("Z"))
}
// Stroke draws the path with no fill.
func (vr *vectorRenderer) Stroke() {
vr.drawPath(vr.s.GetStrokeOptions())
}
// Fill draws the path with no stroke.
func (vr *vectorRenderer) Fill() {
vr.drawPath(vr.s.GetFillOptions())
}
// FillStroke draws the path with both fill and stroke.
func (vr *vectorRenderer) FillStroke() {
vr.drawPath(vr.s.GetFillAndStrokeOptions())
}
// drawPath draws a path.
func (vr *vectorRenderer) drawPath(s Style) {
vr.c.Path(strings.Join(vr.p, "\n"), vr.s.GetFillAndStrokeOptions())
vr.p = []string{} // clear the path
}
// Circle implements the interface method.
func (vr *vectorRenderer) Circle(radius float64, x, y int) {
vr.c.Circle(x, y, int(radius), vr.s.GetFillAndStrokeOptions())
}
// SetFont implements the interface method.
func (vr *vectorRenderer) SetFont(f *truetype.Font) {
vr.s.Font = f
}
// SetFontColor implements the interface method.
func (vr *vectorRenderer) SetFontColor(c drawing.Color) {
vr.s.FontColor = c
}
// SetFontSize implements the interface method.
func (vr *vectorRenderer) SetFontSize(size float64) {
vr.s.FontSize = size
}
// Text draws a text blob.
func (vr *vectorRenderer) Text(body string, x, y int) {
vr.c.Text(x, y, body, vr.s.GetTextOptions())
}
// MeasureText uses the truetype font drawer to measure the width of text.
func (vr *vectorRenderer) MeasureText(body string) (box Box) {
if vr.s.GetFont() != nil {
vr.fc = &font.Drawer{
Face: truetype.NewFace(vr.s.GetFont(), &truetype.Options{
DPI: vr.dpi,
Size: vr.s.FontSize,
}),
}
w := vr.fc.MeasureString(body).Ceil()
box.Right = w
box.Bottom = int(drawing.PointsToPixels(vr.dpi, vr.s.FontSize))
if vr.c.textTheta == nil {
return
}
box = box.Corners().Rotate(RadiansToDegrees(*vr.c.textTheta)).Box()
}
return
}
// SetTextRotation sets the text rotation.
func (vr *vectorRenderer) SetTextRotation(radians float64) {
vr.c.textTheta = &radians
}
// ClearTextRotation clears the text rotation.
func (vr *vectorRenderer) ClearTextRotation() {
vr.c.textTheta = nil
}
// Save saves the renderer's contents to a writer.
func (vr *vectorRenderer) Save(w io.Writer) error {
vr.c.End()
_, err := w.Write(vr.b.Bytes())
return err
}
func newCanvas(w io.Writer) *canvas {
return &canvas{
w: w,
dpi: DefaultDPI,
}
}
type canvas struct {
w io.Writer
dpi float64
textTheta *float64
width int
height int
css string
nonce string
}
func (c *canvas) Start(width, height int) {
c.width = width
c.height = height
c.w.Write([]byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%d" height="%d">\n`, c.width, c.height)))
if c.css != "" {
c.w.Write([]byte(`<style type="text/css"`))
if c.nonce != "" {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
c.w.Write([]byte(fmt.Sprintf(` nonce="%s"`, c.nonce)))
}
// To avoid compatibility issues between XML and CSS (f.e. with child selectors) we should encapsulate the CSS with CDATA.
c.w.Write([]byte(fmt.Sprintf(`><![CDATA[%s]]></style>`, c.css)))
}
}
func (c *canvas) Path(d string, style Style) {
var strokeDashArrayProperty string
if len(style.StrokeDashArray) > 0 {
strokeDashArrayProperty = c.getStrokeDashArray(style)
}
c.w.Write([]byte(fmt.Sprintf(`<path %s d="%s" %s/>`, strokeDashArrayProperty, d, c.styleAsSVG(style))))
}
func (c *canvas) Text(x, y int, body string, style Style) {
if c.textTheta == nil {
c.w.Write([]byte(fmt.Sprintf(`<text x="%d" y="%d" %s>%s</text>`, x, y, c.styleAsSVG(style), body)))
} else {
transform := fmt.Sprintf(` transform="rotate(%0.2f,%d,%d)"`, RadiansToDegrees(*c.textTheta), x, y)
c.w.Write([]byte(fmt.Sprintf(`<text x="%d" y="%d" %s%s>%s</text>`, x, y, c.styleAsSVG(style), transform, body)))
}
}
func (c *canvas) Circle(x, y, r int, style Style) {
c.w.Write([]byte(fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" %s/>`, x, y, r, c.styleAsSVG(style))))
}
func (c *canvas) End() {
c.w.Write([]byte("</svg>"))
}
// getStrokeDashArray returns the stroke-dasharray property of a style.
func (c *canvas) getStrokeDashArray(s Style) string {
if len(s.StrokeDashArray) > 0 {
var values []string
for _, v := range s.StrokeDashArray {
values = append(values, fmt.Sprintf("%0.1f", v))
}
return "stroke-dasharray=\"" + strings.Join(values, ", ") + "\""
}
return ""
}
// GetFontFace returns the font face for the style.
func (c *canvas) getFontFace(s Style) string {
family := "sans-serif"
if s.GetFont() != nil {
name := s.GetFont().Name(truetype.NameIDFontFamily)
if len(name) != 0 {
family = fmt.Sprintf(`'%s',%s`, name, family)
}
}
return fmt.Sprintf("font-family:%s", family)
}
// styleAsSVG returns the style as a svg style or class string.
func (c *canvas) styleAsSVG(s Style) string {
sw := s.StrokeWidth
sc := s.StrokeColor
fc := s.FillColor
fs := s.FontSize
fnc := s.FontColor
if s.ClassName != "" {
var classes []string
classes = append(classes, s.ClassName)
if !sc.IsZero() {
classes = append(classes, "stroke")
}
if !fc.IsZero() {
classes = append(classes, "fill")
}
if fs != 0 || s.Font != nil {
classes = append(classes, "text")
}
return fmt.Sprintf("class=\"%s\"", strings.Join(classes, " "))
}
var pieces []string
if sw != 0 {
pieces = append(pieces, "stroke-width:"+fmt.Sprintf("%d", int(sw)))
} else {
pieces = append(pieces, "stroke-width:0")
}
if !sc.IsZero() {
pieces = append(pieces, "stroke:"+sc.String())
} else {
pieces = append(pieces, "stroke:none")
}
if !fnc.IsZero() {
pieces = append(pieces, "fill:"+fnc.String())
} else if !fc.IsZero() {
pieces = append(pieces, "fill:"+fc.String())
} else {
pieces = append(pieces, "fill:none")
}
if fs != 0 {
pieces = append(pieces, "font-size:"+fmt.Sprintf("%.1fpx", drawing.PointsToPixels(c.dpi, fs)))
}
if s.Font != nil {
pieces = append(pieces, c.getFontFace(s))
}
return fmt.Sprintf("style=\"%s\"", strings.Join(pieces, ";"))
}

View file

@ -0,0 +1,117 @@
package chart
import (
"bytes"
"fmt"
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
"strings"
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestVectorRendererPath(t *testing.T) {
// replaced new assertions helper
vr, err := SVG(100, 100)
testutil.AssertNil(t, err)
typed, isTyped := vr.(*vectorRenderer)
testutil.AssertTrue(t, isTyped)
typed.MoveTo(0, 0)
typed.LineTo(100, 100)
typed.LineTo(0, 100)
typed.Close()
typed.FillStroke()
buffer := bytes.NewBuffer([]byte{})
err = typed.Save(buffer)
testutil.AssertNil(t, err)
raw := string(buffer.Bytes())
testutil.AssertTrue(t, strings.HasPrefix(raw, "<svg"))
testutil.AssertTrue(t, strings.HasSuffix(raw, "</svg>"))
}
func TestVectorRendererMeasureText(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
vr, err := SVG(100, 100)
testutil.AssertNil(t, err)
vr.SetDPI(DefaultDPI)
vr.SetFont(f)
vr.SetFontSize(12.0)
tb := vr.MeasureText("Ljp")
testutil.AssertEqual(t, 21, tb.Width())
testutil.AssertEqual(t, 15, tb.Height())
}
func TestCanvasStyleSVG(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
set := Style{
StrokeColor: drawing.ColorWhite,
StrokeWidth: 5.0,
FillColor: drawing.ColorWhite,
FontColor: drawing.ColorWhite,
Font: f,
Padding: DefaultBackgroundPadding,
}
canvas := &canvas{dpi: DefaultDPI}
svgString := canvas.styleAsSVG(set)
testutil.AssertNotEmpty(t, svgString)
testutil.AssertTrue(t, strings.HasPrefix(svgString, "style=\""))
testutil.AssertTrue(t, strings.Contains(svgString, "stroke:rgba(255,255,255,1.0)"))
testutil.AssertTrue(t, strings.Contains(svgString, "stroke-width:5"))
testutil.AssertTrue(t, strings.Contains(svgString, "fill:rgba(255,255,255,1.0)"))
testutil.AssertTrue(t, strings.HasSuffix(svgString, "\""))
}
func TestCanvasClassSVG(t *testing.T) {
set := Style{
ClassName: "test-class",
}
canvas := &canvas{dpi: DefaultDPI}
testutil.AssertEqual(t, "class=\"test-class\"", canvas.styleAsSVG(set))
}
func TestCanvasCustomInlineStylesheet(t *testing.T) {
b := strings.Builder{}
canvas := &canvas{
w: &b,
css: ".background { fill: red }",
}
canvas.Start(200, 200)
testutil.AssertContains(t, b.String(), fmt.Sprintf(`<style type="text/css"><![CDATA[%s]]></style>`, canvas.css))
}
func TestCanvasCustomInlineStylesheetWithNonce(t *testing.T) {
b := strings.Builder{}
canvas := &canvas{
w: &b,
css: ".background { fill: red }",
nonce: "RAND0MSTRING",
}
canvas.Start(200, 200)
testutil.AssertContains(t, b.String(), fmt.Sprintf(`<style type="text/css" nonce="%s"><![CDATA[%s]]></style>`, canvas.nonce, canvas.css))
}

271
pkg/chart/viridis.go Normal file
View file

@ -0,0 +1,271 @@
package chart
import (
"github.com/d-Rickyy-b/go-chart-x/v2/pkg/drawing"
)
var viridisColors = [256]drawing.Color{
drawing.Color{R: 0x44, G: 0x1, B: 0x54, A: 0xff},
drawing.Color{R: 0x44, G: 0x2, B: 0x55, A: 0xff},
drawing.Color{R: 0x45, G: 0x3, B: 0x57, A: 0xff},
drawing.Color{R: 0x45, G: 0x5, B: 0x58, A: 0xff},
drawing.Color{R: 0x45, G: 0x6, B: 0x5a, A: 0xff},
drawing.Color{R: 0x46, G: 0x8, B: 0x5b, A: 0xff},
drawing.Color{R: 0x46, G: 0x9, B: 0x5d, A: 0xff},
drawing.Color{R: 0x46, G: 0xb, B: 0x5e, A: 0xff},
drawing.Color{R: 0x46, G: 0xc, B: 0x60, A: 0xff},
drawing.Color{R: 0x47, G: 0xe, B: 0x61, A: 0xff},
drawing.Color{R: 0x47, G: 0xf, B: 0x62, A: 0xff},
drawing.Color{R: 0x47, G: 0x11, B: 0x64, A: 0xff},
drawing.Color{R: 0x47, G: 0x12, B: 0x65, A: 0xff},
drawing.Color{R: 0x47, G: 0x14, B: 0x66, A: 0xff},
drawing.Color{R: 0x48, G: 0x15, B: 0x68, A: 0xff},
drawing.Color{R: 0x48, G: 0x16, B: 0x69, A: 0xff},
drawing.Color{R: 0x48, G: 0x18, B: 0x6a, A: 0xff},
drawing.Color{R: 0x48, G: 0x19, B: 0x6c, A: 0xff},
drawing.Color{R: 0x48, G: 0x1a, B: 0x6d, A: 0xff},
drawing.Color{R: 0x48, G: 0x1c, B: 0x6e, A: 0xff},
drawing.Color{R: 0x48, G: 0x1d, B: 0x6f, A: 0xff},
drawing.Color{R: 0x48, G: 0x1e, B: 0x70, A: 0xff},
drawing.Color{R: 0x48, G: 0x20, B: 0x71, A: 0xff},
drawing.Color{R: 0x48, G: 0x21, B: 0x73, A: 0xff},
drawing.Color{R: 0x48, G: 0x22, B: 0x74, A: 0xff},
drawing.Color{R: 0x48, G: 0x24, B: 0x75, A: 0xff},
drawing.Color{R: 0x48, G: 0x25, B: 0x76, A: 0xff},
drawing.Color{R: 0x48, G: 0x26, B: 0x77, A: 0xff},
drawing.Color{R: 0x48, G: 0x27, B: 0x78, A: 0xff},
drawing.Color{R: 0x47, G: 0x29, B: 0x79, A: 0xff},
drawing.Color{R: 0x47, G: 0x2a, B: 0x79, A: 0xff},
drawing.Color{R: 0x47, G: 0x2b, B: 0x7a, A: 0xff},
drawing.Color{R: 0x47, G: 0x2c, B: 0x7b, A: 0xff},
drawing.Color{R: 0x47, G: 0x2e, B: 0x7c, A: 0xff},
drawing.Color{R: 0x46, G: 0x2f, B: 0x7d, A: 0xff},
drawing.Color{R: 0x46, G: 0x30, B: 0x7e, A: 0xff},
drawing.Color{R: 0x46, G: 0x31, B: 0x7e, A: 0xff},
drawing.Color{R: 0x46, G: 0x33, B: 0x7f, A: 0xff},
drawing.Color{R: 0x45, G: 0x34, B: 0x80, A: 0xff},
drawing.Color{R: 0x45, G: 0x35, B: 0x81, A: 0xff},
drawing.Color{R: 0x45, G: 0x36, B: 0x81, A: 0xff},
drawing.Color{R: 0x44, G: 0x38, B: 0x82, A: 0xff},
drawing.Color{R: 0x44, G: 0x39, B: 0x83, A: 0xff},
drawing.Color{R: 0x44, G: 0x3a, B: 0x83, A: 0xff},
drawing.Color{R: 0x43, G: 0x3b, B: 0x84, A: 0xff},
drawing.Color{R: 0x43, G: 0x3c, B: 0x84, A: 0xff},
drawing.Color{R: 0x43, G: 0x3e, B: 0x85, A: 0xff},
drawing.Color{R: 0x42, G: 0x3f, B: 0x85, A: 0xff},
drawing.Color{R: 0x42, G: 0x40, B: 0x86, A: 0xff},
drawing.Color{R: 0x41, G: 0x41, B: 0x86, A: 0xff},
drawing.Color{R: 0x41, G: 0x42, B: 0x87, A: 0xff},
drawing.Color{R: 0x41, G: 0x43, B: 0x87, A: 0xff},
drawing.Color{R: 0x40, G: 0x45, B: 0x88, A: 0xff},
drawing.Color{R: 0x40, G: 0x46, B: 0x88, A: 0xff},
drawing.Color{R: 0x3f, G: 0x47, B: 0x88, A: 0xff},
drawing.Color{R: 0x3f, G: 0x48, B: 0x89, A: 0xff},
drawing.Color{R: 0x3e, G: 0x49, B: 0x89, A: 0xff},
drawing.Color{R: 0x3e, G: 0x4a, B: 0x89, A: 0xff},
drawing.Color{R: 0x3d, G: 0x4b, B: 0x8a, A: 0xff},
drawing.Color{R: 0x3d, G: 0x4d, B: 0x8a, A: 0xff},
drawing.Color{R: 0x3c, G: 0x4e, B: 0x8a, A: 0xff},
drawing.Color{R: 0x3c, G: 0x4f, B: 0x8a, A: 0xff},
drawing.Color{R: 0x3b, G: 0x50, B: 0x8b, A: 0xff},
drawing.Color{R: 0x3b, G: 0x51, B: 0x8b, A: 0xff},
drawing.Color{R: 0x3a, G: 0x52, B: 0x8b, A: 0xff},
drawing.Color{R: 0x3a, G: 0x53, B: 0x8b, A: 0xff},
drawing.Color{R: 0x39, G: 0x54, B: 0x8c, A: 0xff},
drawing.Color{R: 0x39, G: 0x55, B: 0x8c, A: 0xff},
drawing.Color{R: 0x38, G: 0x56, B: 0x8c, A: 0xff},
drawing.Color{R: 0x38, G: 0x57, B: 0x8c, A: 0xff},
drawing.Color{R: 0x37, G: 0x58, B: 0x8c, A: 0xff},
drawing.Color{R: 0x37, G: 0x59, B: 0x8c, A: 0xff},
drawing.Color{R: 0x36, G: 0x5b, B: 0x8d, A: 0xff},
drawing.Color{R: 0x36, G: 0x5c, B: 0x8d, A: 0xff},
drawing.Color{R: 0x35, G: 0x5d, B: 0x8d, A: 0xff},
drawing.Color{R: 0x35, G: 0x5e, B: 0x8d, A: 0xff},
drawing.Color{R: 0x34, G: 0x5f, B: 0x8d, A: 0xff},
drawing.Color{R: 0x34, G: 0x60, B: 0x8d, A: 0xff},
drawing.Color{R: 0x33, G: 0x61, B: 0x8d, A: 0xff},
drawing.Color{R: 0x33, G: 0x62, B: 0x8d, A: 0xff},
drawing.Color{R: 0x33, G: 0x63, B: 0x8d, A: 0xff},
drawing.Color{R: 0x32, G: 0x64, B: 0x8e, A: 0xff},
drawing.Color{R: 0x32, G: 0x65, B: 0x8e, A: 0xff},
drawing.Color{R: 0x31, G: 0x66, B: 0x8e, A: 0xff},
drawing.Color{R: 0x31, G: 0x67, B: 0x8e, A: 0xff},
drawing.Color{R: 0x30, G: 0x68, B: 0x8e, A: 0xff},
drawing.Color{R: 0x30, G: 0x69, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2f, G: 0x6a, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2f, G: 0x6b, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2f, G: 0x6c, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2e, G: 0x6d, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2e, G: 0x6e, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2d, G: 0x6f, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2d, G: 0x70, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2d, G: 0x70, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2c, G: 0x71, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2c, G: 0x72, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2b, G: 0x73, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2b, G: 0x74, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2b, G: 0x75, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2a, G: 0x76, B: 0x8e, A: 0xff},
drawing.Color{R: 0x2a, G: 0x77, B: 0x8e, A: 0xff},
drawing.Color{R: 0x29, G: 0x78, B: 0x8e, A: 0xff},
drawing.Color{R: 0x29, G: 0x79, B: 0x8e, A: 0xff},
drawing.Color{R: 0x29, G: 0x7a, B: 0x8e, A: 0xff},
drawing.Color{R: 0x28, G: 0x7b, B: 0x8e, A: 0xff},
drawing.Color{R: 0x28, G: 0x7c, B: 0x8e, A: 0xff},
drawing.Color{R: 0x28, G: 0x7d, B: 0x8e, A: 0xff},
drawing.Color{R: 0x27, G: 0x7e, B: 0x8e, A: 0xff},
drawing.Color{R: 0x27, G: 0x7f, B: 0x8e, A: 0xff},
drawing.Color{R: 0x26, G: 0x80, B: 0x8e, A: 0xff},
drawing.Color{R: 0x26, G: 0x81, B: 0x8e, A: 0xff},
drawing.Color{R: 0x26, G: 0x82, B: 0x8e, A: 0xff},
drawing.Color{R: 0x25, G: 0x83, B: 0x8e, A: 0xff},
drawing.Color{R: 0x25, G: 0x83, B: 0x8e, A: 0xff},
drawing.Color{R: 0x25, G: 0x84, B: 0x8e, A: 0xff},
drawing.Color{R: 0x24, G: 0x85, B: 0x8e, A: 0xff},
drawing.Color{R: 0x24, G: 0x86, B: 0x8e, A: 0xff},
drawing.Color{R: 0x23, G: 0x87, B: 0x8e, A: 0xff},
drawing.Color{R: 0x23, G: 0x88, B: 0x8e, A: 0xff},
drawing.Color{R: 0x23, G: 0x89, B: 0x8e, A: 0xff},
drawing.Color{R: 0x22, G: 0x8a, B: 0x8d, A: 0xff},
drawing.Color{R: 0x22, G: 0x8b, B: 0x8d, A: 0xff},
drawing.Color{R: 0x22, G: 0x8c, B: 0x8d, A: 0xff},
drawing.Color{R: 0x21, G: 0x8d, B: 0x8d, A: 0xff},
drawing.Color{R: 0x21, G: 0x8e, B: 0x8d, A: 0xff},
drawing.Color{R: 0x21, G: 0x8f, B: 0x8d, A: 0xff},
drawing.Color{R: 0x20, G: 0x90, B: 0x8d, A: 0xff},
drawing.Color{R: 0x20, G: 0x91, B: 0x8c, A: 0xff},
drawing.Color{R: 0x20, G: 0x92, B: 0x8c, A: 0xff},
drawing.Color{R: 0x20, G: 0x93, B: 0x8c, A: 0xff},
drawing.Color{R: 0x1f, G: 0x93, B: 0x8c, A: 0xff},
drawing.Color{R: 0x1f, G: 0x94, B: 0x8c, A: 0xff},
drawing.Color{R: 0x1f, G: 0x95, B: 0x8b, A: 0xff},
drawing.Color{R: 0x1f, G: 0x96, B: 0x8b, A: 0xff},
drawing.Color{R: 0x1f, G: 0x97, B: 0x8b, A: 0xff},
drawing.Color{R: 0x1e, G: 0x98, B: 0x8b, A: 0xff},
drawing.Color{R: 0x1e, G: 0x99, B: 0x8a, A: 0xff},
drawing.Color{R: 0x1e, G: 0x9a, B: 0x8a, A: 0xff},
drawing.Color{R: 0x1e, G: 0x9b, B: 0x8a, A: 0xff},
drawing.Color{R: 0x1e, G: 0x9c, B: 0x89, A: 0xff},
drawing.Color{R: 0x1e, G: 0x9d, B: 0x89, A: 0xff},
drawing.Color{R: 0x1e, G: 0x9e, B: 0x89, A: 0xff},
drawing.Color{R: 0x1e, G: 0x9f, B: 0x88, A: 0xff},
drawing.Color{R: 0x1e, G: 0xa0, B: 0x88, A: 0xff},
drawing.Color{R: 0x1f, G: 0xa1, B: 0x88, A: 0xff},
drawing.Color{R: 0x1f, G: 0xa2, B: 0x87, A: 0xff},
drawing.Color{R: 0x1f, G: 0xa3, B: 0x87, A: 0xff},
drawing.Color{R: 0x1f, G: 0xa3, B: 0x86, A: 0xff},
drawing.Color{R: 0x20, G: 0xa4, B: 0x86, A: 0xff},
drawing.Color{R: 0x20, G: 0xa5, B: 0x86, A: 0xff},
drawing.Color{R: 0x21, G: 0xa6, B: 0x85, A: 0xff},
drawing.Color{R: 0x21, G: 0xa7, B: 0x85, A: 0xff},
drawing.Color{R: 0x22, G: 0xa8, B: 0x84, A: 0xff},
drawing.Color{R: 0x23, G: 0xa9, B: 0x83, A: 0xff},
drawing.Color{R: 0x23, G: 0xaa, B: 0x83, A: 0xff},
drawing.Color{R: 0x24, G: 0xab, B: 0x82, A: 0xff},
drawing.Color{R: 0x25, G: 0xac, B: 0x82, A: 0xff},
drawing.Color{R: 0x26, G: 0xad, B: 0x81, A: 0xff},
drawing.Color{R: 0x27, G: 0xae, B: 0x81, A: 0xff},
drawing.Color{R: 0x28, G: 0xaf, B: 0x80, A: 0xff},
drawing.Color{R: 0x29, G: 0xaf, B: 0x7f, A: 0xff},
drawing.Color{R: 0x2a, G: 0xb0, B: 0x7f, A: 0xff},
drawing.Color{R: 0x2b, G: 0xb1, B: 0x7e, A: 0xff},
drawing.Color{R: 0x2c, G: 0xb2, B: 0x7d, A: 0xff},
drawing.Color{R: 0x2e, G: 0xb3, B: 0x7c, A: 0xff},
drawing.Color{R: 0x2f, G: 0xb4, B: 0x7c, A: 0xff},
drawing.Color{R: 0x30, G: 0xb5, B: 0x7b, A: 0xff},
drawing.Color{R: 0x32, G: 0xb6, B: 0x7a, A: 0xff},
drawing.Color{R: 0x33, G: 0xb7, B: 0x79, A: 0xff},
drawing.Color{R: 0x35, G: 0xb7, B: 0x79, A: 0xff},
drawing.Color{R: 0x36, G: 0xb8, B: 0x78, A: 0xff},
drawing.Color{R: 0x38, G: 0xb9, B: 0x77, A: 0xff},
drawing.Color{R: 0x39, G: 0xba, B: 0x76, A: 0xff},
drawing.Color{R: 0x3b, G: 0xbb, B: 0x75, A: 0xff},
drawing.Color{R: 0x3d, G: 0xbc, B: 0x74, A: 0xff},
drawing.Color{R: 0x3e, G: 0xbd, B: 0x73, A: 0xff},
drawing.Color{R: 0x40, G: 0xbe, B: 0x72, A: 0xff},
drawing.Color{R: 0x42, G: 0xbe, B: 0x71, A: 0xff},
drawing.Color{R: 0x44, G: 0xbf, B: 0x70, A: 0xff},
drawing.Color{R: 0x46, G: 0xc0, B: 0x6f, A: 0xff},
drawing.Color{R: 0x48, G: 0xc1, B: 0x6e, A: 0xff},
drawing.Color{R: 0x49, G: 0xc2, B: 0x6d, A: 0xff},
drawing.Color{R: 0x4b, G: 0xc2, B: 0x6c, A: 0xff},
drawing.Color{R: 0x4d, G: 0xc3, B: 0x6b, A: 0xff},
drawing.Color{R: 0x4f, G: 0xc4, B: 0x6a, A: 0xff},
drawing.Color{R: 0x51, G: 0xc5, B: 0x69, A: 0xff},
drawing.Color{R: 0x53, G: 0xc6, B: 0x68, A: 0xff},
drawing.Color{R: 0x55, G: 0xc6, B: 0x66, A: 0xff},
drawing.Color{R: 0x58, G: 0xc7, B: 0x65, A: 0xff},
drawing.Color{R: 0x5a, G: 0xc8, B: 0x64, A: 0xff},
drawing.Color{R: 0x5c, G: 0xc9, B: 0x63, A: 0xff},
drawing.Color{R: 0x5e, G: 0xc9, B: 0x62, A: 0xff},
drawing.Color{R: 0x60, G: 0xca, B: 0x60, A: 0xff},
drawing.Color{R: 0x62, G: 0xcb, B: 0x5f, A: 0xff},
drawing.Color{R: 0x65, G: 0xcc, B: 0x5e, A: 0xff},
drawing.Color{R: 0x67, G: 0xcc, B: 0x5c, A: 0xff},
drawing.Color{R: 0x69, G: 0xcd, B: 0x5b, A: 0xff},
drawing.Color{R: 0x6c, G: 0xce, B: 0x5a, A: 0xff},
drawing.Color{R: 0x6e, G: 0xce, B: 0x58, A: 0xff},
drawing.Color{R: 0x70, G: 0xcf, B: 0x57, A: 0xff},
drawing.Color{R: 0x73, G: 0xd0, B: 0x55, A: 0xff},
drawing.Color{R: 0x75, G: 0xd0, B: 0x54, A: 0xff},
drawing.Color{R: 0x77, G: 0xd1, B: 0x52, A: 0xff},
drawing.Color{R: 0x7a, G: 0xd2, B: 0x51, A: 0xff},
drawing.Color{R: 0x7c, G: 0xd2, B: 0x4f, A: 0xff},
drawing.Color{R: 0x7f, G: 0xd3, B: 0x4e, A: 0xff},
drawing.Color{R: 0x81, G: 0xd4, B: 0x4c, A: 0xff},
drawing.Color{R: 0x84, G: 0xd4, B: 0x4b, A: 0xff},
drawing.Color{R: 0x86, G: 0xd5, B: 0x49, A: 0xff},
drawing.Color{R: 0x89, G: 0xd5, B: 0x48, A: 0xff},
drawing.Color{R: 0x8b, G: 0xd6, B: 0x46, A: 0xff},
drawing.Color{R: 0x8e, G: 0xd7, B: 0x44, A: 0xff},
drawing.Color{R: 0x90, G: 0xd7, B: 0x43, A: 0xff},
drawing.Color{R: 0x93, G: 0xd8, B: 0x41, A: 0xff},
drawing.Color{R: 0x95, G: 0xd8, B: 0x3f, A: 0xff},
drawing.Color{R: 0x98, G: 0xd9, B: 0x3e, A: 0xff},
drawing.Color{R: 0x9b, G: 0xd9, B: 0x3c, A: 0xff},
drawing.Color{R: 0x9d, G: 0xda, B: 0x3a, A: 0xff},
drawing.Color{R: 0xa0, G: 0xda, B: 0x39, A: 0xff},
drawing.Color{R: 0xa3, G: 0xdb, B: 0x37, A: 0xff},
drawing.Color{R: 0xa5, G: 0xdb, B: 0x35, A: 0xff},
drawing.Color{R: 0xa8, G: 0xdc, B: 0x33, A: 0xff},
drawing.Color{R: 0xab, G: 0xdc, B: 0x32, A: 0xff},
drawing.Color{R: 0xad, G: 0xdd, B: 0x30, A: 0xff},
drawing.Color{R: 0xb0, G: 0xdd, B: 0x2e, A: 0xff},
drawing.Color{R: 0xb3, G: 0xdd, B: 0x2d, A: 0xff},
drawing.Color{R: 0xb5, G: 0xde, B: 0x2b, A: 0xff},
drawing.Color{R: 0xb8, G: 0xde, B: 0x29, A: 0xff},
drawing.Color{R: 0xbb, G: 0xdf, B: 0x27, A: 0xff},
drawing.Color{R: 0xbd, G: 0xdf, B: 0x26, A: 0xff},
drawing.Color{R: 0xc0, G: 0xdf, B: 0x24, A: 0xff},
drawing.Color{R: 0xc3, G: 0xe0, B: 0x23, A: 0xff},
drawing.Color{R: 0xc5, G: 0xe0, B: 0x21, A: 0xff},
drawing.Color{R: 0xc8, G: 0xe1, B: 0x20, A: 0xff},
drawing.Color{R: 0xcb, G: 0xe1, B: 0x1e, A: 0xff},
drawing.Color{R: 0xcd, G: 0xe1, B: 0x1d, A: 0xff},
drawing.Color{R: 0xd0, G: 0xe2, B: 0x1c, A: 0xff},
drawing.Color{R: 0xd3, G: 0xe2, B: 0x1b, A: 0xff},
drawing.Color{R: 0xd5, G: 0xe2, B: 0x1a, A: 0xff},
drawing.Color{R: 0xd8, G: 0xe3, B: 0x19, A: 0xff},
drawing.Color{R: 0xdb, G: 0xe3, B: 0x18, A: 0xff},
drawing.Color{R: 0xdd, G: 0xe3, B: 0x18, A: 0xff},
drawing.Color{R: 0xe0, G: 0xe4, B: 0x18, A: 0xff},
drawing.Color{R: 0xe2, G: 0xe4, B: 0x18, A: 0xff},
drawing.Color{R: 0xe5, G: 0xe4, B: 0x18, A: 0xff},
drawing.Color{R: 0xe8, G: 0xe5, B: 0x19, A: 0xff},
drawing.Color{R: 0xea, G: 0xe5, B: 0x19, A: 0xff},
drawing.Color{R: 0xed, G: 0xe5, B: 0x1a, A: 0xff},
drawing.Color{R: 0xef, G: 0xe6, B: 0x1b, A: 0xff},
drawing.Color{R: 0xf2, G: 0xe6, B: 0x1c, A: 0xff},
drawing.Color{R: 0xf4, G: 0xe6, B: 0x1e, A: 0xff},
drawing.Color{R: 0xf7, G: 0xe6, B: 0x1f, A: 0xff},
drawing.Color{R: 0xf9, G: 0xe7, B: 0x21, A: 0xff},
drawing.Color{R: 0xfb, G: 0xe7, B: 0x23, A: 0xff},
drawing.Color{R: 0xfe, G: 0xe7, B: 0x24, A: 0xff},
}
// Viridis creates a color map provider.
func Viridis(v, vmin, vmax float64) drawing.Color {
normalized := (v - vmin) / (vmax - vmin)
index := uint8(normalized * 255)
return viridisColors[index]
}

208
pkg/chart/xaxis.go Normal file
View file

@ -0,0 +1,208 @@
package chart
import (
"math"
)
// HideXAxis hides the x-axis.
func HideXAxis() XAxis {
return XAxis{
Style: Hidden(),
}
}
// XAxis represents the horizontal axis.
type XAxis struct {
Name string
NameStyle Style
Style Style
ValueFormatter ValueFormatter
Range Range
TickStyle Style
Ticks []Tick
TickPosition TickPosition
GridLines []GridLine
GridMajorStyle Style
GridMinorStyle Style
}
// GetName returns the name.
func (xa XAxis) GetName() string {
return xa.Name
}
// GetStyle returns the style.
func (xa XAxis) GetStyle() Style {
return xa.Style
}
// GetValueFormatter returns the value formatter for the axis.
func (xa XAxis) GetValueFormatter() ValueFormatter {
if xa.ValueFormatter != nil {
return xa.ValueFormatter
}
return FloatValueFormatter
}
// GetTickPosition returns the tick position option for the axis.
func (xa XAxis) GetTickPosition(defaults ...TickPosition) TickPosition {
if xa.TickPosition == TickPositionUnset {
if len(defaults) > 0 {
return defaults[0]
}
return TickPositionUnderTick
}
return xa.TickPosition
}
// GetTicks returns the ticks for a series.
// The coalesce priority is:
// - User Supplied Ticks (i.e. Ticks array on the axis itself).
// - Range ticks (i.e. if the range provides ticks).
// - Generating continuous ticks based on minimum spacing and canvas width.
func (xa XAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter) []Tick {
if len(xa.Ticks) > 0 {
return xa.Ticks
}
if tp, isTickProvider := ra.(TicksProvider); isTickProvider {
return tp.GetTicks(r, defaults, vf)
}
tickStyle := xa.Style.InheritFrom(defaults)
return GenerateContinuousTicks(r, ra, false, tickStyle, vf)
}
// GetGridLines returns the gridlines for the axis.
func (xa XAxis) GetGridLines(ticks []Tick) []GridLine {
if len(xa.GridLines) > 0 {
return xa.GridLines
}
return GenerateGridLines(ticks, xa.GridMajorStyle, xa.GridMinorStyle)
}
// Measure returns the bounds of the axis.
func (xa XAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) Box {
tickStyle := xa.TickStyle.InheritFrom(xa.Style.InheritFrom(defaults))
tp := xa.GetTickPosition()
var ltx, rtx int
var tx, ty int
var left, right, bottom = math.MaxInt32, 0, 0
for index, t := range ticks {
v := t.Value
tb := Draw.MeasureText(r, t.Label, tickStyle.GetTextOptions())
tx = canvasBox.Left + ra.Translate(v)
ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height()
switch tp {
case TickPositionUnderTick, TickPositionUnset:
ltx = tx - tb.Width()>>1
rtx = tx + tb.Width()>>1
break
case TickPositionBetweenTicks:
if index > 0 {
ltx = ra.Translate(ticks[index-1].Value)
rtx = tx
}
break
}
left = MinInt(left, ltx)
right = MaxInt(right, rtx)
bottom = MaxInt(bottom, ty)
}
if !xa.NameStyle.Hidden && len(xa.Name) > 0 {
tb := Draw.MeasureText(r, xa.Name, xa.NameStyle.InheritFrom(defaults))
bottom += DefaultXAxisMargin + tb.Height()
}
return Box{
Top: canvasBox.Bottom,
Left: left,
Right: right,
Bottom: bottom,
}
}
// Render renders the axis
func (xa XAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) {
tickStyle := xa.TickStyle.InheritFrom(xa.Style.InheritFrom(defaults))
tickStyle.GetStrokeOptions().WriteToRenderer(r)
r.MoveTo(canvasBox.Left, canvasBox.Bottom)
r.LineTo(canvasBox.Right, canvasBox.Bottom)
r.Stroke()
tp := xa.GetTickPosition()
var tx, ty int
var maxTextHeight int
for index, t := range ticks {
v := t.Value
lx := ra.Translate(v)
tx = canvasBox.Left + lx
tickStyle.GetStrokeOptions().WriteToRenderer(r)
r.MoveTo(tx, canvasBox.Bottom)
r.LineTo(tx, canvasBox.Bottom+DefaultVerticalTickHeight)
r.Stroke()
tickWithAxisStyle := xa.TickStyle.InheritFrom(xa.Style.InheritFrom(defaults))
tb := Draw.MeasureText(r, t.Label, tickWithAxisStyle)
switch tp {
case TickPositionUnderTick, TickPositionUnset:
if tickStyle.TextRotationDegrees == 0 {
tx = tx - tb.Width()>>1
ty = canvasBox.Bottom + DefaultXAxisMargin + tb.Height()
} else {
ty = canvasBox.Bottom + (2 * DefaultXAxisMargin)
}
Draw.Text(r, t.Label, tx, ty, tickWithAxisStyle)
maxTextHeight = MaxInt(maxTextHeight, tb.Height())
break
case TickPositionBetweenTicks:
if index > 0 {
llx := ra.Translate(ticks[index-1].Value)
ltx := canvasBox.Left + llx
finalTickStyle := tickWithAxisStyle.InheritFrom(Style{TextHorizontalAlign: TextHorizontalAlignCenter})
Draw.TextWithin(r, t.Label, Box{
Left: ltx,
Right: tx,
Top: canvasBox.Bottom + DefaultXAxisMargin,
Bottom: canvasBox.Bottom + DefaultXAxisMargin,
}, finalTickStyle)
ftb := Text.MeasureLines(r, Text.WrapFit(r, t.Label, tx-ltx, finalTickStyle), finalTickStyle)
maxTextHeight = MaxInt(maxTextHeight, ftb.Height())
}
break
}
}
nameStyle := xa.NameStyle.InheritFrom(defaults)
if !xa.NameStyle.Hidden && len(xa.Name) > 0 {
tb := Draw.MeasureText(r, xa.Name, nameStyle)
tx := canvasBox.Right - (canvasBox.Width()>>1 + tb.Width()>>1)
ty := canvasBox.Bottom + DefaultXAxisMargin + maxTextHeight + DefaultXAxisMargin + tb.Height()
Draw.Text(r, xa.Name, tx, ty, nameStyle)
}
if !xa.GridMajorStyle.Hidden || !xa.GridMinorStyle.Hidden {
for _, gl := range xa.GetGridLines(ticks) {
if (gl.IsMinor && !xa.GridMinorStyle.Hidden) || (!gl.IsMinor && !xa.GridMajorStyle.Hidden) {
defaults := xa.GridMajorStyle
if gl.IsMinor {
defaults = xa.GridMinorStyle
}
gl.Render(r, canvasBox, ra, true, gl.Style.InheritFrom(defaults))
}
}
}
}

67
pkg/chart/xaxis_test.go Normal file
View file

@ -0,0 +1,67 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestXAxisGetTicks(t *testing.T) {
// replaced new assertions helper
r, err := PNG(1024, 1024)
testutil.AssertNil(t, err)
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
xa := XAxis{}
xr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024}
styleDefaults := Style{
Font: f,
FontSize: 10.0,
}
vf := FloatValueFormatter
ticks := xa.GetTicks(r, xr, styleDefaults, vf)
testutil.AssertLen(t, ticks, 16)
}
func TestXAxisGetTicksWithUserDefaults(t *testing.T) {
// replaced new assertions helper
r, err := PNG(1024, 1024)
testutil.AssertNil(t, err)
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
xa := XAxis{
Ticks: []Tick{{Value: 1.0, Label: "1.0"}},
}
xr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024}
styleDefaults := Style{
Font: f,
FontSize: 10.0,
}
vf := FloatValueFormatter
ticks := xa.GetTicks(r, xr, styleDefaults, vf)
testutil.AssertLen(t, ticks, 1)
}
func TestXAxisMeasure(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
style := Style{
Font: f,
FontSize: 10.0,
}
r, err := PNG(100, 100)
testutil.AssertNil(t, err)
ticks := []Tick{{Value: 1.0, Label: "1.0"}, {Value: 2.0, Label: "2.0"}, {Value: 3.0, Label: "3.0"}}
xa := XAxis{}
xab := xa.Measure(r, NewBox(0, 0, 100, 100), &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks)
testutil.AssertEqual(t, 122, xab.Width())
testutil.AssertEqual(t, 21, xab.Height())
}

234
pkg/chart/yaxis.go Normal file
View file

@ -0,0 +1,234 @@
package chart
import (
"math"
)
// HideYAxis hides a y-axis.
func HideYAxis() YAxis {
return YAxis{
Style: Hidden(),
}
}
// YAxis is a veritcal rule of the range.
// There can be (2) y-axes; a primary and secondary.
type YAxis struct {
Name string
NameStyle Style
Style Style
Zero GridLine
AxisType YAxisType
Ascending bool
ValueFormatter ValueFormatter
Range Range
TickStyle Style
Ticks []Tick
GridLines []GridLine
GridMajorStyle Style
GridMinorStyle Style
}
// GetName returns the name.
func (ya YAxis) GetName() string {
return ya.Name
}
// GetNameStyle returns the name style.
func (ya YAxis) GetNameStyle() Style {
return ya.NameStyle
}
// GetStyle returns the style.
func (ya YAxis) GetStyle() Style {
return ya.Style
}
// GetValueFormatter returns the value formatter for the axis.
func (ya YAxis) GetValueFormatter() ValueFormatter {
if ya.ValueFormatter != nil {
return ya.ValueFormatter
}
return FloatValueFormatter
}
// GetTickStyle returns the tick style.
func (ya YAxis) GetTickStyle() Style {
return ya.TickStyle
}
// GetTicks returns the ticks for a series.
// The coalesce priority is:
// - User Supplied Ticks (i.e. Ticks array on the axis itself).
// - Range ticks (i.e. if the range provides ticks).
// - Generating continuous ticks based on minimum spacing and canvas width.
func (ya YAxis) GetTicks(r Renderer, ra Range, defaults Style, vf ValueFormatter) []Tick {
if len(ya.Ticks) > 0 {
return ya.Ticks
}
if tp, isTickProvider := ra.(TicksProvider); isTickProvider {
return tp.GetTicks(r, defaults, vf)
}
tickStyle := ya.Style.InheritFrom(defaults)
return GenerateContinuousTicks(r, ra, true, tickStyle, vf)
}
// GetGridLines returns the gridlines for the axis.
func (ya YAxis) GetGridLines(ticks []Tick) []GridLine {
if len(ya.GridLines) > 0 {
return ya.GridLines
}
return GenerateGridLines(ticks, ya.GridMajorStyle, ya.GridMinorStyle)
}
// Measure returns the bounds of the axis.
func (ya YAxis) Measure(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) Box {
var tx int
if ya.AxisType == YAxisPrimary {
tx = canvasBox.Right + DefaultYAxisMargin
} else if ya.AxisType == YAxisSecondary {
tx = canvasBox.Left - DefaultYAxisMargin
}
ya.TickStyle.InheritFrom(ya.Style.InheritFrom(defaults)).WriteToRenderer(r)
var minx, maxx, miny, maxy = math.MaxInt32, 0, math.MaxInt32, 0
var maxTextHeight int
for _, t := range ticks {
v := t.Value
ly := canvasBox.Bottom - ra.Translate(v)
tb := r.MeasureText(t.Label)
tbh2 := tb.Height() >> 1
finalTextX := tx
if ya.AxisType == YAxisSecondary {
finalTextX = tx - tb.Width()
}
maxTextHeight = MaxInt(tb.Height(), maxTextHeight)
if ya.AxisType == YAxisPrimary {
minx = canvasBox.Right
maxx = MaxInt(maxx, tx+tb.Width())
} else if ya.AxisType == YAxisSecondary {
minx = MinInt(minx, finalTextX)
maxx = MaxInt(maxx, tx)
}
miny = MinInt(miny, ly-tbh2)
maxy = MaxInt(maxy, ly+tbh2)
}
if !ya.NameStyle.Hidden && len(ya.Name) > 0 {
maxx += (DefaultYAxisMargin + maxTextHeight)
}
return Box{
Top: miny,
Left: minx,
Right: maxx,
Bottom: maxy,
}
}
// Render renders the axis.
func (ya YAxis) Render(r Renderer, canvasBox Box, ra Range, defaults Style, ticks []Tick) {
tickStyle := ya.TickStyle.InheritFrom(ya.Style.InheritFrom(defaults))
tickStyle.WriteToRenderer(r)
sw := tickStyle.GetStrokeWidth(defaults.StrokeWidth)
var lx int
var tx int
if ya.AxisType == YAxisPrimary {
lx = canvasBox.Right + int(sw)
tx = lx + DefaultYAxisMargin
} else if ya.AxisType == YAxisSecondary {
lx = canvasBox.Left - int(sw)
tx = lx - DefaultYAxisMargin
}
r.MoveTo(lx, canvasBox.Bottom)
r.LineTo(lx, canvasBox.Top)
r.Stroke()
var maxTextWidth int
var finalTextX, finalTextY int
for _, t := range ticks {
v := t.Value
ly := canvasBox.Bottom - ra.Translate(v)
tb := Draw.MeasureText(r, t.Label, tickStyle)
if tb.Width() > maxTextWidth {
maxTextWidth = tb.Width()
}
if ya.AxisType == YAxisSecondary {
finalTextX = tx - tb.Width()
} else {
finalTextX = tx
}
if tickStyle.TextRotationDegrees == 0 {
finalTextY = ly + tb.Height()>>1
} else {
finalTextY = ly
}
tickStyle.WriteToRenderer(r)
r.MoveTo(lx, ly)
if ya.AxisType == YAxisPrimary {
r.LineTo(lx+DefaultHorizontalTickWidth, ly)
} else if ya.AxisType == YAxisSecondary {
r.LineTo(lx-DefaultHorizontalTickWidth, ly)
}
r.Stroke()
Draw.Text(r, t.Label, finalTextX, finalTextY, tickStyle)
}
nameStyle := ya.NameStyle.InheritFrom(defaults.InheritFrom(Style{TextRotationDegrees: 90}))
if !ya.NameStyle.Hidden && len(ya.Name) > 0 {
nameStyle.GetTextOptions().WriteToRenderer(r)
tb := Draw.MeasureText(r, ya.Name, nameStyle)
var tx int
if ya.AxisType == YAxisPrimary {
tx = canvasBox.Right + int(sw) + DefaultYAxisMargin + maxTextWidth + DefaultYAxisMargin
} else if ya.AxisType == YAxisSecondary {
tx = canvasBox.Left - (DefaultYAxisMargin + int(sw) + maxTextWidth + DefaultYAxisMargin)
}
var ty int
if nameStyle.TextRotationDegrees == 0 {
ty = canvasBox.Top + (canvasBox.Height()>>1 - tb.Width()>>1)
} else {
ty = canvasBox.Top + (canvasBox.Height()>>1 - tb.Height()>>1)
}
Draw.Text(r, ya.Name, tx, ty, nameStyle)
}
if !ya.Zero.Style.Hidden {
ya.Zero.Render(r, canvasBox, ra, false, Style{})
}
if !ya.GridMajorStyle.Hidden || !ya.GridMinorStyle.Hidden {
for _, gl := range ya.GetGridLines(ticks) {
if (gl.IsMinor && !ya.GridMinorStyle.Hidden) || (!gl.IsMinor && !ya.GridMajorStyle.Hidden) {
defaults := ya.GridMajorStyle
if gl.IsMinor {
defaults = ya.GridMinorStyle
}
gl.Render(r, canvasBox, ra, false, gl.Style.InheritFrom(defaults))
}
}
}
}

85
pkg/chart/yaxis_test.go Normal file
View file

@ -0,0 +1,85 @@
package chart
import (
"testing"
"github.com/d-Rickyy-b/go-chart-x/v2/testutil"
)
func TestYAxisGetTicks(t *testing.T) {
// replaced new assertions helper
r, err := PNG(1024, 1024)
testutil.AssertNil(t, err)
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
ya := YAxis{}
yr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024}
styleDefaults := Style{
Font: f,
FontSize: 10.0,
}
vf := FloatValueFormatter
ticks := ya.GetTicks(r, yr, styleDefaults, vf)
testutil.AssertLen(t, ticks, 32)
}
func TestYAxisGetTicksWithUserDefaults(t *testing.T) {
// replaced new assertions helper
r, err := PNG(1024, 1024)
testutil.AssertNil(t, err)
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
ya := YAxis{
Ticks: []Tick{{Value: 1.0, Label: "1.0"}},
}
yr := &ContinuousRange{Min: 10, Max: 100, Domain: 1024}
styleDefaults := Style{
Font: f,
FontSize: 10.0,
}
vf := FloatValueFormatter
ticks := ya.GetTicks(r, yr, styleDefaults, vf)
testutil.AssertLen(t, ticks, 1)
}
func TestYAxisMeasure(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
style := Style{
Font: f,
FontSize: 10.0,
}
r, err := PNG(100, 100)
testutil.AssertNil(t, err)
ticks := []Tick{{Value: 1.0, Label: "1.0"}, {Value: 2.0, Label: "2.0"}, {Value: 3.0, Label: "3.0"}}
ya := YAxis{}
yab := ya.Measure(r, NewBox(0, 0, 100, 100), &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks)
testutil.AssertEqual(t, 32, yab.Width())
testutil.AssertEqual(t, 110, yab.Height())
}
func TestYAxisSecondaryMeasure(t *testing.T) {
// replaced new assertions helper
f, err := GetDefaultFont()
testutil.AssertNil(t, err)
style := Style{
Font: f,
FontSize: 10.0,
}
r, err := PNG(100, 100)
testutil.AssertNil(t, err)
ticks := []Tick{{Value: 1.0, Label: "1.0"}, {Value: 2.0, Label: "2.0"}, {Value: 3.0, Label: "3.0"}}
ya := YAxis{AxisType: YAxisSecondary}
yab := ya.Measure(r, NewBox(0, 0, 100, 100), &ContinuousRange{Min: 1.0, Max: 3.0, Domain: 100}, style, ticks)
testutil.AssertEqual(t, 32, yab.Width())
testutil.AssertEqual(t, 110, yab.Height())
}