1
0
Fork 0

Adding upstream version 2.1.2.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-18 07:17:02 +02:00
parent c8c64afc61
commit 41a2f19f12
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
220 changed files with 19814 additions and 0 deletions

33
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: "Continuous Integration"
on:
workflow_dispatch:
push:
branches: [ main ]
paths: [ "*.go" ]
pull_request:
branches: [ main ]
paths: [ "*.go" ]
jobs:
ci:
name: "Tests"
runs-on: ubuntu-latest
env:
GOOS: "linux"
GOARCH: "amd64"
GO111MODULE: "on"
CGO_ENABLED: "0"
steps:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.21
- name: Check out go-incr
uses: actions/checkout@v3
- name: Run all tests
run: go test ./...

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# Binaries for programs and plugins
*.exe
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
.glide/
# Other
.vscode
.DS_Store
coverage.html
.idea

1
COVERAGE Normal file
View file

@ -0,0 +1 @@
29.02

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016 William Charczuk.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

10
Makefile Normal file
View file

@ -0,0 +1,10 @@
all: new-install test
new-install:
@go get -v -u ./...
generate:
@go generate ./...
test:
@go test ./...

4
PROFANITY_RULES.yml Normal file
View file

@ -0,0 +1,4 @@
go-sdk:
excludeFiles: [ "*_test.go" ]
importsContain: [ github.com/blend/go-sdk/* ]
description: "please don't use go-sdk in this repo"

95
README.md Normal file
View file

@ -0,0 +1,95 @@
go-chart
========
[![Continuous Integration](https://github.com/wcharczuk/go-chart/actions/workflows/ci.yml/badge.svg)](https://github.com/wcharczuk/go-chart/actions/workflows/ci.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/wcharczuk/go-chart)](https://goreportcard.com/report/github.com/wcharczuk/go-chart)
Package `chart` is a very simple golang native charting library that supports timeseries and continuous line charts.
Master should now be on the v3.x codebase, which overhauls the api significantly. Per usual, see `examples` for more information.
# Installation
To install `chart` run the following:
```bash
> go get github.com/wcharczuk/go-chart/v2@latest
```
Most of the components are interchangeable so feel free to crib whatever you want.
# Output Examples
Spark Lines:
![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/_images/tvix_ltm.png)
Single axis:
![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/_images/goog_ltm.png)
Two axis:
![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/_images/two_axis.png)
# Other Chart Types
Pie Chart:
![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/_images/pie_chart.png)
The code for this chart can be found in `examples/pie_chart/main.go`.
Stacked Bar:
![](https://raw.githubusercontent.com/wcharczuk/go-chart/master/_images/stacked_bar.png)
The code for this chart can be found in `examples/stacked_bar/main.go`.
# Code Examples
Actual chart configurations and examples can be found in the `./examples/` directory. They are simple CLI programs that write to `output.png` (they are also updated with `go generate`.
# Usage
Everything starts with the `chart.Chart` object. The bare minimum to draw a chart would be the following:
```golang
import (
...
"bytes"
...
"github.com/wcharczuk/go-chart/v2" //exposes "chart"
)
graph := chart.Chart{
Series: []chart.Series{
chart.ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0},
},
},
}
buffer := bytes.NewBuffer([]byte{})
err := graph.Render(chart.PNG, buffer)
```
Explanation of the above: A `chart` can have many `Series`, a `Series` is a collection of things that need to be drawn according to the X range and the Y range(s).
Here, we have a single series with x range values as float64s, rendered to a PNG. Note; we can pass any type of `io.Writer` into `Render(...)`, meaning that we can render the chart to a file or a resonse or anything else that implements `io.Writer`.
# API Overview
Everything on the `chart.Chart` object has defaults that can be overriden. Whenever a developer sets a property on the chart object, it is to be assumed that value will be used instead of the default.
The best way to see the api in action is to look at the examples in the `./_examples/` directory.
# Design Philosophy
I wanted to make a charting library that used only native golang, that could be stood up on a server (i.e. it had built in fonts).
The goal with the API itself is to have the "zero value be useful", and to require the user to not code more than they absolutely needed.
# Contributions
Contributions are welcome though this library is in a holding pattern for the forseable future.

147
_colors/colors_extended.txt Normal file
View file

@ -0,0 +1,147 @@
aliceblue #f0f8ff 240,248,255
antiquewhite #faebd7 250,235,215
aqua #00ffff 0,255,255
aquamarine #7fffd4 127,255,212
azure #f0ffff 240,255,255
beige #f5f5dc 245,245,220
bisque #ffe4c4 255,228,196
black #000000 0,0,0
blanchedalmond #ffebcd 255,235,205
blue #0000ff 0,0,255
blueviolet #8a2be2 138,43,226
brown #a52a2a 165,42,42
burlywood #deb887 222,184,135
cadetblue #5f9ea0 95,158,160
chartreuse #7fff00 127,255,0
chocolate #d2691e 210,105,30
coral #ff7f50 255,127,80
cornflowerblue #6495ed 100,149,237
cornsilk #fff8dc 255,248,220
crimson #dc143c 220,20,60
cyan #00ffff 0,255,255
darkblue #00008b 0,0,139
darkcyan #008b8b 0,139,139
darkgoldenrod #b8860b 184,134,11
darkgray #a9a9a9 169,169,169
darkgreen #006400 0,100,0
darkgrey #a9a9a9 169,169,169
darkkhaki #bdb76b 189,183,107
darkmagenta #8b008b 139,0,139
darkolivegreen #556b2f 85,107,47
darkorange #ff8c00 255,140,0
darkorchid #9932cc 153,50,204
darkred #8b0000 139,0,0
darksalmon #e9967a 233,150,122
darkseagreen #8fbc8f 143,188,143
darkslateblue #483d8b 72,61,139
darkslategray #2f4f4f 47,79,79
darkslategrey #2f4f4f 47,79,79
darkturquoise #00ced1 0,206,209
darkviolet #9400d3 148,0,211
deeppink #ff1493 255,20,147
deepskyblue #00bfff 0,191,255
dimgray #696969 105,105,105
dimgrey #696969 105,105,105
dodgerblue #1e90ff 30,144,255
firebrick #b22222 178,34,34
floralwhite #fffaf0 255,250,240
forestgreen #228b22 34,139,34
fuchsia #ff00ff 255,0,255
gainsboro #dcdcdc 220,220,220
ghostwhite #f8f8ff 248,248,255
gold #ffd700 255,215,0
goldenrod #daa520 218,165,32
gray #808080 128,128,128
green #008000 0,128,0
greenyellow #adff2f 173,255,47
grey #808080 128,128,128
honeydew #f0fff0 240,255,240
hotpink #ff69b4 255,105,180
indianred #cd5c5c 205,92,92
indigo #4b0082 75,0,130
ivory #fffff0 255,255,240
khaki #f0e68c 240,230,140
lavender #e6e6fa 230,230,250
lavenderblush #fff0f5 255,240,245
lawngreen #7cfc00 124,252,0
lemonchiffon #fffacd 255,250,205
lightblue #add8e6 173,216,230
lightcoral #f08080 240,128,128
lightcyan #e0ffff 224,255,255
lightgoldenrodyellow #fafad2 250,250,210
lightgray #d3d3d3 211,211,211
lightgreen #90ee90 144,238,144
lightgrey #d3d3d3 211,211,211
lightpink #ffb6c1 255,182,193
lightsalmon #ffa07a 255,160,122
lightseagreen #20b2aa 32,178,170
lightskyblue #87cefa 135,206,250
lightslategray #778899 119,136,153
lightslategrey #778899 119,136,153
lightsteelblue #b0c4de 176,196,222
lightyellow #ffffe0 255,255,224
lime #00ff00 0,255,0
limegreen #32cd32 50,205,50
linen #faf0e6 250,240,230
magenta #ff00ff 255,0,255
maroon #800000 128,0,0
mediumaquamarine #66cdaa 102,205,170
mediumblue #0000cd 0,0,205
mediumorchid #ba55d3 186,85,211
mediumpurple #9370db 147,112,219
mediumseagreen #3cb371 60,179,113
mediumslateblue #7b68ee 123,104,238
mediumspringgreen #00fa9a 0,250,154
mediumturquoise #48d1cc 72,209,204
mediumvioletred #c71585 199,21,133
midnightblue #191970 25,25,112
mintcream #f5fffa 245,255,250
mistyrose #ffe4e1 255,228,225
moccasin #ffe4b5 255,228,181
navajowhite #ffdead 255,222,173
navy #000080 0,0,128
oldlace #fdf5e6 253,245,230
olive #808000 128,128,0
olivedrab #6b8e23 107,142,35
orange #ffa500 255,165,0
orangered #ff4500 255,69,0
orchid #da70d6 218,112,214
palegoldenrod #eee8aa 238,232,170
palegreen #98fb98 152,251,152
paleturquoise #afeeee 175,238,238
palevioletred #db7093 219,112,147
papayawhip #ffefd5 255,239,213
peachpuff #ffdab9 255,218,185
peru #cd853f 205,133,63
pink #ffc0cb 255,192,203
plum #dda0dd 221,160,221
powderblue #b0e0e6 176,224,230
purple #800080 128,0,128
red #ff0000 255,0,0
rosybrown #bc8f8f 188,143,143
royalblue #4169e1 65,105,225
saddlebrown #8b4513 139,69,19
salmon #fa8072 250,128,114
sandybrown #f4a460 244,164,96
seagreen #2e8b57 46,139,87
seashell #fff5ee 255,245,238
sienna #a0522d 160,82,45
silver #c0c0c0 192,192,192
skyblue #87ceeb 135,206,235
slateblue #6a5acd 106,90,205
slategray #708090 112,128,144
slategrey #708090 112,128,144
snow #fffafa 255,250,250
springgreen #00ff7f 0,255,127
steelblue #4682b4 70,130,180
tan #d2b48c 210,180,140
teal #008080 0,128,128
thistle #d8bfd8 216,191,216
tomato #ff6347 255,99,71
turquoise #40e0d0 64,224,208
violet #ee82ee 238,130,238
wheat #f5deb3 245,222,179
white #ffffff 255,255,255
whitesmoke #f5f5f5 245,245,245
yellow #ffff00 255,255,0
yellowgreen #9acd32 154,205,50

BIN
_images/bar_chart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
_images/goog_ltm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
_images/ma_goog_ltm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
_images/pie_chart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
_images/spy_ltm_bbs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
_images/stacked_bar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
_images/tvix_ltm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
_images/two_axis.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

91
annotation_series.go Normal file
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
}

115
annotation_series_test.go Normal file
View file

@ -0,0 +1,115 @@
package chart
import (
"image/color"
"testing"
"github.com/wcharczuk/go-chart/v2/drawing"
"github.com/wcharczuk/go-chart/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
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
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)
}

491
bar_chart.go Normal file
View file

@ -0,0 +1,491 @@
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 {
bc.YAxis.Render(r, canvasBox, yr, bc.styleDefaultsAxes(), ticks)
}
}
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
bar_chart_test.go Normal file
View file

@ -0,0 +1,310 @@
package chart
import (
"bytes"
"math"
"testing"
"github.com/wcharczuk/go-chart/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)
}

135
bollinger_band_series.go Normal file
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/wcharczuk/go-chart/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},
},
}
}

367
box.go Normal file
View file

@ -0,0 +1,367 @@
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
}
func (b Box) Validate() error {
if b.Left < 0 {
return fmt.Errorf("invalid left; must be >= 0")
}
if b.Right < 0 {
return fmt.Errorf("invalid right; must be > 0")
}
if b.Top < 0 {
return fmt.Errorf("invalid top; must be > 0")
}
if b.Bottom < 0 {
return fmt.Errorf("invalid bottom; must be > 0")
}
return nil
}
// 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
box_test.go Normal file
View file

@ -0,0 +1,188 @@
package chart
import (
"math"
"testing"
"github.com/wcharczuk/go-chart/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
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,
}
}

594
chart_test.go Normal file
View file

@ -0,0 +1,594 @@
package chart
import (
"bytes"
"image"
"image/png"
"math"
"testing"
"time"
"github.com/wcharczuk/go-chart/v2/drawing"
"github.com/wcharczuk/go-chart/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))
}
func Test_Chart_cve(t *testing.T) {
poc := StackedBarChart{
Title: "poc",
Bars: []StackedBar{
{
Name: "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111",
Values: []Value{
{Value: 1, Label: "infinite"},
{Value: 1, Label: "loop"},
},
},
},
}
var imgContent bytes.Buffer
err := poc.Render(PNG, &imgContent)
testutil.AssertNotNil(t, err)
}

148
cmd/chart/main.go Normal file
View file

@ -0,0 +1,148 @@
package main
import (
"flag"
"fmt"
"io/ioutil"
"os"
"strings"
"github.com/wcharczuk/go-chart/v2"
)
var (
outputPath = flag.String("output", "", "The output file")
inputFormat = flag.String("format", "csv", "The input format, either 'csv' or 'tsv' (defaults to 'csv')")
inputPath = flag.String("f", "", "The input file")
reverse = flag.Bool("reverse", false, "If we should reverse the inputs")
hideLegend = flag.Bool("hide-legend", false, "If we should omit the chart legend")
hideSMA = flag.Bool("hide-sma", false, "If we should omit simple moving average")
hideLinreg = flag.Bool("hide-linreg", false, "If we should omit linear regressions")
hideLastValues = flag.Bool("hide-last-values", false, "If we should omit last values")
)
func main() {
flag.Parse()
log := chart.NewLogger()
var rawData []byte
var err error
if *inputPath != "" {
if *inputPath == "-" {
rawData, err = ioutil.ReadAll(os.Stdin)
if err != nil {
log.FatalErr(err)
}
} else {
rawData, err = ioutil.ReadFile(*inputPath)
if err != nil {
log.FatalErr(err)
}
}
} else if len(flag.Args()) > 0 {
rawData = []byte(flag.Args()[0])
} else {
flag.Usage()
os.Exit(1)
}
var parts []string
switch *inputFormat {
case "csv":
parts = chart.SplitCSV(string(rawData))
case "tsv":
parts = strings.Split(string(rawData), "\t")
default:
log.FatalErr(fmt.Errorf("invalid format; must be 'csv' or 'tsv'"))
}
yvalues, err := chart.ParseFloats(parts...)
if err != nil {
log.FatalErr(err)
}
if *reverse {
yvalues = chart.ValueSequence(yvalues...).Reverse().Values()
}
var series []chart.Series
mainSeries := chart.ContinuousSeries{
Name: "Values",
XValues: chart.LinearRange(1, float64(len(yvalues))),
YValues: yvalues,
}
series = append(series, mainSeries)
smaSeries := &chart.SMASeries{
Name: "SMA",
Style: chart.Style{
Hidden: *hideSMA,
StrokeColor: chart.ColorRed,
StrokeDashArray: []float64{5.0, 5.0},
},
InnerSeries: mainSeries,
}
series = append(series, smaSeries)
linRegSeries := &chart.LinearRegressionSeries{
Name: "Values - Lin. Reg.",
Style: chart.Style{
Hidden: *hideLinreg,
},
InnerSeries: mainSeries,
}
series = append(series, linRegSeries)
mainLastValue := chart.LastValueAnnotationSeries(mainSeries)
mainLastValue.Style = chart.Style{
Hidden: *hideLastValues,
}
series = append(series, mainLastValue)
linregLastValue := chart.LastValueAnnotationSeries(linRegSeries)
linregLastValue.Style = chart.Style{
Hidden: (*hideLastValues || *hideLinreg),
}
series = append(series, linregLastValue)
smaLastValue := chart.LastValueAnnotationSeries(smaSeries)
smaLastValue.Style = chart.Style{
Hidden: (*hideLastValues || *hideSMA),
}
series = append(series, smaLastValue)
graph := chart.Chart{
Background: chart.Style{
Padding: chart.Box{
Top: 50,
},
},
Series: series,
}
if !*hideLegend {
graph.Elements = []chart.Renderable{chart.LegendThin(&graph)}
}
var output *os.File
if *outputPath != "" {
output, err = os.Create(*outputPath)
if err != nil {
log.FatalErr(err)
}
} else {
output, err = ioutil.TempFile("", "*.png")
if err != nil {
log.FatalErr(err)
}
}
if err := graph.Render(chart.PNG, output); err != nil {
log.FatalErr(err)
}
fmt.Fprintln(os.Stdout, output.Name())
os.Exit(0)
}

184
colors.go Normal file
View file

@ -0,0 +1,184 @@
package chart
import "github.com/wcharczuk/go-chart/v2/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)
}

44
concat_series.go Normal file
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
}

41
concat_series_test.go Normal file
View file

@ -0,0 +1,41 @@
package chart
import (
"testing"
"github.com/wcharczuk/go-chart/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)
}

81
continuous_range.go Normal file
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)))
}

22
continuous_range_test.go Normal file
View file

@ -0,0 +1,22 @@
package chart
import (
"testing"
"github.com/wcharczuk/go-chart/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))
}

96
continuous_series.go Normal file
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
}

72
continuous_series_test.go Normal file
View file

@ -0,0 +1,72 @@
package chart
import (
"fmt"
"testing"
"github.com/wcharczuk/go-chart/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
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
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,
}
}

69
donut_chart_test.go Normal file
View file

@ -0,0 +1,69 @@
package chart
import (
"bytes"
"testing"
"github.com/wcharczuk/go-chart/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
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()
}
}

5
drawing/README.md Normal file
View file

@ -0,0 +1,5 @@
go-chart > drawing
==================
The bulk of the code in this package is based on [draw2d](https://github.com/llgcode/draw2d), but
with significant modifications to make the APIs more golang friendly and careful about units (points vs. pixels).

274
drawing/color.go Normal file
View file

@ -0,0 +1,274 @@
package drawing
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// Basic Colors from:
// https://www.w3.org/wiki/CSS/Properties/color/keywords
var (
// ColorTransparent is a fully transparent color.
ColorTransparent = Color{R: 255, G: 255, B: 255, A: 0}
// ColorWhite is white.
ColorWhite = Color{R: 255, G: 255, B: 255, A: 255}
// ColorBlack is black.
ColorBlack = Color{R: 0, G: 0, B: 0, A: 255}
// ColorRed is red.
ColorRed = Color{R: 255, G: 0, B: 0, A: 255}
// ColorGreen is green.
ColorGreen = Color{R: 0, G: 128, B: 0, A: 255}
// ColorBlue is blue.
ColorBlue = Color{R: 0, G: 0, B: 255, A: 255}
// ColorSilver is a known color.
ColorSilver = Color{R: 192, G: 192, B: 192, A: 255}
// ColorMaroon is a known color.
ColorMaroon = Color{R: 128, G: 0, B: 0, A: 255}
// ColorPurple is a known color.
ColorPurple = Color{R: 128, G: 0, B: 128, A: 255}
// ColorFuchsia is a known color.
ColorFuchsia = Color{R: 255, G: 0, B: 255, A: 255}
// ColorLime is a known color.
ColorLime = Color{R: 0, G: 255, B: 0, A: 255}
// ColorOlive is a known color.
ColorOlive = Color{R: 128, G: 128, B: 0, A: 255}
// ColorYellow is a known color.
ColorYellow = Color{R: 255, G: 255, B: 0, A: 255}
// ColorNavy is a known color.
ColorNavy = Color{R: 0, G: 0, B: 128, A: 255}
// ColorTeal is a known color.
ColorTeal = Color{R: 0, G: 128, B: 128, A: 255}
// ColorAqua is a known color.
ColorAqua = Color{R: 0, G: 255, B: 255, A: 255}
)
func parseHex(hex string) uint8 {
v, _ := strconv.ParseInt(hex, 16, 16)
return uint8(v)
}
// ParseColor parses a color from a string.
func ParseColor(rawColor string) Color {
if strings.HasPrefix(rawColor, "rgba") {
return ColorFromRGBA(rawColor)
}
if strings.HasPrefix(rawColor, "rgb") {
return ColorFromRGB(rawColor)
}
if strings.HasPrefix(rawColor, "#") {
return ColorFromHex(rawColor)
}
return ColorFromKnown(rawColor)
}
var rgbaexpr = regexp.MustCompile(`rgba\((?P<R>.+),(?P<G>.+),(?P<B>.+),(?P<A>.+)\)`)
// ColorFromRGBA returns a color from an `rgba()` css function.
func ColorFromRGBA(rgba string) (output Color) {
values := rgbaexpr.FindStringSubmatch(rgba)
for i, name := range rgbaexpr.SubexpNames() {
if i == 0 {
continue
}
if i >= len(values) {
break
}
switch name {
case "R":
value := strings.TrimSpace(values[i])
parsed, _ := strconv.ParseInt(value, 10, 16)
output.R = uint8(parsed)
case "G":
value := strings.TrimSpace(values[i])
parsed, _ := strconv.ParseInt(value, 10, 16)
output.G = uint8(parsed)
case "B":
value := strings.TrimSpace(values[i])
parsed, _ := strconv.ParseInt(value, 10, 16)
output.B = uint8(parsed)
case "A":
value := strings.TrimSpace(values[i])
parsed, _ := strconv.ParseFloat(value, 32)
if parsed > 1 {
parsed = 1
} else if parsed < 0 {
parsed = 0
}
output.A = uint8(parsed * 255)
}
}
return
}
var rgbexpr = regexp.MustCompile(`rgb\((?P<R>.+),(?P<G>.+),(?P<B>.+)\)`)
// ColorFromRGB returns a color from an `rgb()` css function.
func ColorFromRGB(rgb string) (output Color) {
output.A = 255
values := rgbexpr.FindStringSubmatch(rgb)
for i, name := range rgbaexpr.SubexpNames() {
if i == 0 {
continue
}
if i >= len(values) {
break
}
switch name {
case "R":
value := strings.TrimSpace(values[i])
parsed, _ := strconv.ParseInt(value, 10, 16)
output.R = uint8(parsed)
case "G":
value := strings.TrimSpace(values[i])
parsed, _ := strconv.ParseInt(value, 10, 16)
output.G = uint8(parsed)
case "B":
value := strings.TrimSpace(values[i])
parsed, _ := strconv.ParseInt(value, 10, 16)
output.B = uint8(parsed)
}
}
return
}
// ColorFromHex returns a color from a css hex code.
//
// NOTE: it will trim a leading '#' character if present.
func ColorFromHex(hex string) Color {
if strings.HasPrefix(hex, "#") {
hex = strings.TrimPrefix(hex, "#")
}
var c Color
if len(hex) == 3 {
c.R = parseHex(string(hex[0])) * 0x11
c.G = parseHex(string(hex[1])) * 0x11
c.B = parseHex(string(hex[2])) * 0x11
} else {
c.R = parseHex(string(hex[0:2]))
c.G = parseHex(string(hex[2:4]))
c.B = parseHex(string(hex[4:6]))
}
c.A = 255
return c
}
// ColorFromKnown returns an internal color from a known (basic) color name.
func ColorFromKnown(known string) Color {
switch strings.ToLower(known) {
case "transparent":
return ColorTransparent
case "white":
return ColorWhite
case "black":
return ColorBlack
case "red":
return ColorRed
case "blue":
return ColorBlue
case "green":
return ColorGreen
case "silver":
return ColorSilver
case "maroon":
return ColorMaroon
case "purple":
return ColorPurple
case "fuchsia":
return ColorFuchsia
case "lime":
return ColorLime
case "olive":
return ColorOlive
case "yellow":
return ColorYellow
case "navy":
return ColorNavy
case "teal":
return ColorTeal
case "aqua":
return ColorAqua
default:
return Color{}
}
}
// ColorFromAlphaMixedRGBA returns the system alpha mixed rgba values.
func ColorFromAlphaMixedRGBA(r, g, b, a uint32) Color {
fa := float64(a) / 255.0
var c Color
c.R = uint8(float64(r) / fa)
c.G = uint8(float64(g) / fa)
c.B = uint8(float64(b) / fa)
c.A = uint8(a | (a >> 8))
return c
}
// ColorChannelFromFloat returns a normalized byte from a given float value.
func ColorChannelFromFloat(v float64) uint8 {
return uint8(v * 255)
}
// Color is our internal color type because color.Color is bullshit.
type Color struct {
R, G, B, A uint8
}
// RGBA returns the color as a pre-alpha mixed color set.
func (c Color) RGBA() (r, g, b, a uint32) {
fa := float64(c.A) / 255.0
r = uint32(float64(uint32(c.R)) * fa)
r |= r << 8
g = uint32(float64(uint32(c.G)) * fa)
g |= g << 8
b = uint32(float64(uint32(c.B)) * fa)
b |= b << 8
a = uint32(c.A)
a |= a << 8
return
}
// IsZero returns if the color has been set or not.
func (c Color) IsZero() bool {
return c.R == 0 && c.G == 0 && c.B == 0 && c.A == 0
}
// IsTransparent returns if the colors alpha channel is zero.
func (c Color) IsTransparent() bool {
return c.A == 0
}
// WithAlpha returns a copy of the color with a given alpha.
func (c Color) WithAlpha(a uint8) Color {
return Color{
R: c.R,
G: c.G,
B: c.B,
A: a,
}
}
// Equals returns true if the color equals another.
func (c Color) Equals(other Color) bool {
return c.R == other.R &&
c.G == other.G &&
c.B == other.B &&
c.A == other.A
}
// AverageWith averages two colors.
func (c Color) AverageWith(other Color) Color {
return Color{
R: (c.R + other.R) >> 1,
G: (c.G + other.G) >> 1,
B: (c.B + other.B) >> 1,
A: c.A,
}
}
// String returns a css string representation of the color.
func (c Color) String() string {
fa := float64(c.A) / float64(255)
return fmt.Sprintf("rgba(%v,%v,%v,%.1f)", c.R, c.G, c.B, fa)
}

114
drawing/color_test.go Normal file
View file

@ -0,0 +1,114 @@
package drawing
import (
"fmt"
"testing"
"image/color"
"github.com/wcharczuk/go-chart/v2/testutil"
)
func TestColorFromHex(t *testing.T) {
white := ColorFromHex("FFFFFF")
testutil.AssertEqual(t, ColorWhite, white)
shortWhite := ColorFromHex("FFF")
testutil.AssertEqual(t, ColorWhite, shortWhite)
black := ColorFromHex("000000")
testutil.AssertEqual(t, ColorBlack, black)
shortBlack := ColorFromHex("000")
testutil.AssertEqual(t, ColorBlack, shortBlack)
red := ColorFromHex("FF0000")
testutil.AssertEqual(t, ColorRed, red)
shortRed := ColorFromHex("F00")
testutil.AssertEqual(t, ColorRed, shortRed)
green := ColorFromHex("008000")
testutil.AssertEqual(t, ColorGreen, green)
// shortGreen := ColorFromHex("0F0")
// testutil.AssertEqual(t, ColorGreen, shortGreen)
blue := ColorFromHex("0000FF")
testutil.AssertEqual(t, ColorBlue, blue)
shortBlue := ColorFromHex("00F")
testutil.AssertEqual(t, ColorBlue, shortBlue)
}
func TestColorFromHex_handlesHash(t *testing.T) {
withHash := ColorFromHex("#FF0000")
testutil.AssertEqual(t, ColorRed, withHash)
withoutHash := ColorFromHex("#FF0000")
testutil.AssertEqual(t, ColorRed, withoutHash)
}
func TestColorFromAlphaMixedRGBA(t *testing.T) {
black := ColorFromAlphaMixedRGBA(color.Black.RGBA())
testutil.AssertTrue(t, black.Equals(ColorBlack), black.String())
white := ColorFromAlphaMixedRGBA(color.White.RGBA())
testutil.AssertTrue(t, white.Equals(ColorWhite), white.String())
}
func Test_ColorFromRGBA(t *testing.T) {
value := "rgba(192, 192, 192, 1.0)"
parsed := ColorFromRGBA(value)
testutil.AssertEqual(t, ColorSilver, parsed)
value = "rgba(192,192,192,1.0)"
parsed = ColorFromRGBA(value)
testutil.AssertEqual(t, ColorSilver, parsed)
value = "rgba(192,192,192,1.5)"
parsed = ColorFromRGBA(value)
testutil.AssertEqual(t, ColorSilver, parsed)
}
func TestParseColor(t *testing.T) {
testCases := [...]struct {
Input string
Expected Color
}{
{"", Color{}},
{"white", ColorWhite},
{"WHITE", ColorWhite}, // caps!
{"black", ColorBlack},
{"red", ColorRed},
{"green", ColorGreen},
{"blue", ColorBlue},
{"silver", ColorSilver},
{"maroon", ColorMaroon},
{"purple", ColorPurple},
{"fuchsia", ColorFuchsia},
{"lime", ColorLime},
{"olive", ColorOlive},
{"yellow", ColorYellow},
{"navy", ColorNavy},
{"teal", ColorTeal},
{"aqua", ColorAqua},
{"rgba(192, 192, 192, 1.0)", ColorSilver},
{"rgba(192,192,192,1.0)", ColorSilver},
{"rgb(192, 192, 192)", ColorSilver},
{"rgb(192,192,192)", ColorSilver},
{"#FF0000", ColorRed},
{"#008000", ColorGreen},
{"#0000FF", ColorBlue},
{"#F00", ColorRed},
{"#080", Color{0, 136, 0, 255}},
{"#00F", ColorBlue},
}
for index, tc := range testCases {
actual := ParseColor(tc.Input)
testutil.AssertEqual(t, tc.Expected, actual, fmt.Sprintf("test case: %d -> %s", index, tc.Input))
}
}

6
drawing/constants.go Normal file
View file

@ -0,0 +1,6 @@
package drawing
const (
// DefaultDPI is the default image DPI.
DefaultDPI = 96.0
)

185
drawing/curve.go Normal file
View file

@ -0,0 +1,185 @@
package drawing
import "math"
const (
// CurveRecursionLimit represents the maximum recursion that is really necessary to subsivide a curve into straight lines
CurveRecursionLimit = 32
)
// Cubic
// x1, y1, cpx1, cpy1, cpx2, cpy2, x2, y2 float64
// SubdivideCubic a Bezier cubic curve in 2 equivalents Bezier cubic curves.
// c1 and c2 parameters are the resulting curves
func SubdivideCubic(c, c1, c2 []float64) {
// First point of c is the first point of c1
c1[0], c1[1] = c[0], c[1]
// Last point of c is the last point of c2
c2[6], c2[7] = c[6], c[7]
// Subdivide segment using midpoints
c1[2] = (c[0] + c[2]) / 2
c1[3] = (c[1] + c[3]) / 2
midX := (c[2] + c[4]) / 2
midY := (c[3] + c[5]) / 2
c2[4] = (c[4] + c[6]) / 2
c2[5] = (c[5] + c[7]) / 2
c1[4] = (c1[2] + midX) / 2
c1[5] = (c1[3] + midY) / 2
c2[2] = (midX + c2[4]) / 2
c2[3] = (midY + c2[5]) / 2
c1[6] = (c1[4] + c2[2]) / 2
c1[7] = (c1[5] + c2[3]) / 2
// Last Point of c1 is equal to the first point of c2
c2[0], c2[1] = c1[6], c1[7]
}
// TraceCubic generate lines subdividing the cubic curve using a Liner
// flattening_threshold helps determines the flattening expectation of the curve
func TraceCubic(t Liner, cubic []float64, flatteningThreshold float64) {
// Allocation curves
var curves [CurveRecursionLimit * 8]float64
copy(curves[0:8], cubic[0:8])
i := 0
// current curve
var c []float64
var dx, dy, d2, d3 float64
for i >= 0 {
c = curves[i*8:]
dx = c[6] - c[0]
dy = c[7] - c[1]
d2 = math.Abs((c[2]-c[6])*dy - (c[3]-c[7])*dx)
d3 = math.Abs((c[4]-c[6])*dy - (c[5]-c[7])*dx)
// if it's flat then trace a line
if (d2+d3)*(d2+d3) < flatteningThreshold*(dx*dx+dy*dy) || i == len(curves)-1 {
t.LineTo(c[6], c[7])
i--
} else {
// second half of bezier go lower onto the stack
SubdivideCubic(c, curves[(i+1)*8:], curves[i*8:])
i++
}
}
}
// Quad
// x1, y1, cpx1, cpy2, x2, y2 float64
// SubdivideQuad a Bezier quad curve in 2 equivalents Bezier quad curves.
// c1 and c2 parameters are the resulting curves
func SubdivideQuad(c, c1, c2 []float64) {
// First point of c is the first point of c1
c1[0], c1[1] = c[0], c[1]
// Last point of c is the last point of c2
c2[4], c2[5] = c[4], c[5]
// Subdivide segment using midpoints
c1[2] = (c[0] + c[2]) / 2
c1[3] = (c[1] + c[3]) / 2
c2[2] = (c[2] + c[4]) / 2
c2[3] = (c[3] + c[5]) / 2
c1[4] = (c1[2] + c2[2]) / 2
c1[5] = (c1[3] + c2[3]) / 2
c2[0], c2[1] = c1[4], c1[5]
return
}
func traceWindowIndices(i int) (startAt, endAt int) {
startAt = i * 6
endAt = startAt + 6
return
}
func traceCalcDeltas(c []float64) (dx, dy, d float64) {
dx = c[4] - c[0]
dy = c[5] - c[1]
d = math.Abs(((c[2]-c[4])*dy - (c[3]-c[5])*dx))
return
}
func traceIsFlat(dx, dy, d, threshold float64) bool {
return (d * d) < threshold*(dx*dx+dy*dy)
}
func traceGetWindow(curves []float64, i int) []float64 {
startAt, endAt := traceWindowIndices(i)
return curves[startAt:endAt]
}
// TraceQuad generate lines subdividing the curve using a Liner
// flattening_threshold helps determines the flattening expectation of the curve
func TraceQuad(t Liner, quad []float64, flatteningThreshold float64) {
const curveLen = CurveRecursionLimit * 6
const curveEndIndex = curveLen - 1
const lastIteration = CurveRecursionLimit - 1
// Allocates curves stack
curves := make([]float64, curveLen)
// copy 6 elements from the quad path to the stack
copy(curves[0:6], quad[0:6])
var i int
var c []float64
var dx, dy, d float64
for i >= 0 {
c = traceGetWindow(curves, i)
dx, dy, d = traceCalcDeltas(c)
// bail early if the distance is 0
if d == 0 {
return
}
// if it's flat then trace a line
if traceIsFlat(dx, dy, d, flatteningThreshold) || i == lastIteration {
t.LineTo(c[4], c[5])
i--
} else {
SubdivideQuad(c, traceGetWindow(curves, i+1), traceGetWindow(curves, i))
i++
}
}
}
// TraceArc trace an arc using a Liner
func TraceArc(t Liner, x, y, rx, ry, start, angle, scale float64) (lastX, lastY float64) {
end := start + angle
clockWise := true
if angle < 0 {
clockWise = false
}
ra := (math.Abs(rx) + math.Abs(ry)) / 2
da := math.Acos(ra/(ra+0.125/scale)) * 2
//normalize
if !clockWise {
da = -da
}
angle = start + da
var curX, curY float64
for {
if (angle < end-da/4) != clockWise {
curX = x + math.Cos(end)*rx
curY = y + math.Sin(end)*ry
return curX, curY
}
curX = x + math.Cos(angle)*rx
curY = y + math.Sin(angle)*ry
angle += da
t.LineTo(curX, curY)
}
}

35
drawing/curve_test.go Normal file
View file

@ -0,0 +1,35 @@
package drawing
import (
"testing"
"github.com/wcharczuk/go-chart/v2/testutil"
)
type point struct {
X, Y float64
}
type mockLine struct {
inner []point
}
func (ml *mockLine) LineTo(x, y float64) {
ml.inner = append(ml.inner, point{x, y})
}
func (ml mockLine) Len() int {
return len(ml.inner)
}
func TestTraceQuad(t *testing.T) {
// replaced new assertions helper
// Quad
// x1, y1, cpx1, cpy2, x2, y2 float64
// do the 9->12 circle segment
quad := []float64{10, 20, 20, 20, 20, 10}
liner := &mockLine{}
TraceQuad(liner, quad, 0.5)
testutil.AssertNotZero(t, liner.Len())
}

89
drawing/dasher.go Normal file
View file

@ -0,0 +1,89 @@
package drawing
// NewDashVertexConverter creates a new dash converter.
func NewDashVertexConverter(dash []float64, dashOffset float64, flattener Flattener) *DashVertexConverter {
var dasher DashVertexConverter
dasher.dash = dash
dasher.currentDash = 0
dasher.dashOffset = dashOffset
dasher.next = flattener
return &dasher
}
// DashVertexConverter is a converter for dash vertexes.
type DashVertexConverter struct {
next Flattener
x, y, distance float64
dash []float64
currentDash int
dashOffset float64
}
// LineTo implements the pathbuilder interface.
func (dasher *DashVertexConverter) LineTo(x, y float64) {
dasher.lineTo(x, y)
}
// MoveTo implements the pathbuilder interface.
func (dasher *DashVertexConverter) MoveTo(x, y float64) {
dasher.next.MoveTo(x, y)
dasher.x, dasher.y = x, y
dasher.distance = dasher.dashOffset
dasher.currentDash = 0
}
// LineJoin implements the pathbuilder interface.
func (dasher *DashVertexConverter) LineJoin() {
dasher.next.LineJoin()
}
// Close implements the pathbuilder interface.
func (dasher *DashVertexConverter) Close() {
dasher.next.Close()
}
// End implements the pathbuilder interface.
func (dasher *DashVertexConverter) End() {
dasher.next.End()
}
func (dasher *DashVertexConverter) lineTo(x, y float64) {
rest := dasher.dash[dasher.currentDash] - dasher.distance
for rest < 0 {
dasher.distance = dasher.distance - dasher.dash[dasher.currentDash]
dasher.currentDash = (dasher.currentDash + 1) % len(dasher.dash)
rest = dasher.dash[dasher.currentDash] - dasher.distance
}
d := distance(dasher.x, dasher.y, x, y)
for d >= rest {
k := rest / d
lx := dasher.x + k*(x-dasher.x)
ly := dasher.y + k*(y-dasher.y)
if dasher.currentDash%2 == 0 {
// line
dasher.next.LineTo(lx, ly)
} else {
// gap
dasher.next.End()
dasher.next.MoveTo(lx, ly)
}
d = d - rest
dasher.x, dasher.y = lx, ly
dasher.currentDash = (dasher.currentDash + 1) % len(dasher.dash)
rest = dasher.dash[dasher.currentDash]
}
dasher.distance = d
if dasher.currentDash%2 == 0 {
// line
dasher.next.LineTo(x, y)
} else {
// gap
dasher.next.End()
dasher.next.MoveTo(x, y)
}
if dasher.distance >= dasher.dash[dasher.currentDash] {
dasher.distance = dasher.distance - dasher.dash[dasher.currentDash]
dasher.currentDash = (dasher.currentDash + 1) % len(dasher.dash)
}
dasher.x, dasher.y = x, y
}

View file

@ -0,0 +1,41 @@
package drawing
// DemuxFlattener is a flattener
type DemuxFlattener struct {
Flatteners []Flattener
}
// MoveTo implements the path builder interface.
func (dc DemuxFlattener) MoveTo(x, y float64) {
for _, flattener := range dc.Flatteners {
flattener.MoveTo(x, y)
}
}
// LineTo implements the path builder interface.
func (dc DemuxFlattener) LineTo(x, y float64) {
for _, flattener := range dc.Flatteners {
flattener.LineTo(x, y)
}
}
// LineJoin implements the path builder interface.
func (dc DemuxFlattener) LineJoin() {
for _, flattener := range dc.Flatteners {
flattener.LineJoin()
}
}
// Close implements the path builder interface.
func (dc DemuxFlattener) Close() {
for _, flattener := range dc.Flatteners {
flattener.Close()
}
}
// End implements the path builder interface.
func (dc DemuxFlattener) End() {
for _, flattener := range dc.Flatteners {
flattener.End()
}
}

148
drawing/drawing.go Normal file
View file

@ -0,0 +1,148 @@
package drawing
import (
"image/color"
"github.com/golang/freetype/truetype"
)
// FillRule defines the type for fill rules
type FillRule int
const (
// FillRuleEvenOdd determines the "insideness" of a point in the shape
// by drawing a ray from that point to infinity in any direction
// and counting the number of path segments from the given shape that the ray crosses.
// If this number is odd, the point is inside; if even, the point is outside.
FillRuleEvenOdd FillRule = iota
// FillRuleWinding determines the "insideness" of a point in the shape
// by drawing a ray from that point to infinity in any direction
// and then examining the places where a segment of the shape crosses the ray.
// Starting with a count of zero, add one each time a path segment crosses
// the ray from left to right and subtract one each time
// a path segment crosses the ray from right to left. After counting the crossings,
// if the result is zero then the point is outside the path. Otherwise, it is inside.
FillRuleWinding
)
// LineCap is the style of line extremities
type LineCap int
const (
// RoundCap defines a rounded shape at the end of the line
RoundCap LineCap = iota
// ButtCap defines a squared shape exactly at the end of the line
ButtCap
// SquareCap defines a squared shape at the end of the line
SquareCap
)
// LineJoin is the style of segments joint
type LineJoin int
const (
// BevelJoin represents cut segments joint
BevelJoin LineJoin = iota
// RoundJoin represents rounded segments joint
RoundJoin
// MiterJoin represents peaker segments joint
MiterJoin
)
// StrokeStyle keeps stroke style attributes
// that is used by the Stroke method of a Drawer
type StrokeStyle struct {
// Color defines the color of stroke
Color color.Color
// Line width
Width float64
// Line cap style rounded, butt or square
LineCap LineCap
// Line join style bevel, round or miter
LineJoin LineJoin
// offset of the first dash
DashOffset float64
// array represented dash length pair values are plain dash and impair are space between dash
// if empty display plain line
Dash []float64
}
// SolidFillStyle define style attributes for a solid fill style
type SolidFillStyle struct {
// Color defines the line color
Color color.Color
// FillRule defines the file rule to used
FillRule FillRule
}
// Valign Vertical Alignment of the text
type Valign int
const (
// ValignTop top align text
ValignTop Valign = iota
// ValignCenter centered text
ValignCenter
// ValignBottom bottom aligned text
ValignBottom
// ValignBaseline align text with the baseline of the font
ValignBaseline
)
// Halign Horizontal Alignment of the text
type Halign int
const (
// HalignLeft Horizontally align to left
HalignLeft = iota
// HalignCenter Horizontally align to center
HalignCenter
// HalignRight Horizontally align to right
HalignRight
)
// TextStyle describe text property
type TextStyle struct {
// Color defines the color of text
Color color.Color
// Size font size
Size float64
// The font to use
Font *truetype.Font
// Horizontal Alignment of the text
Halign Halign
// Vertical Alignment of the text
Valign Valign
}
// ScalingPolicy is a constant to define how to scale an image
type ScalingPolicy int
const (
// ScalingNone no scaling applied
ScalingNone ScalingPolicy = iota
// ScalingStretch the image is stretched so that its width and height are exactly the given width and height
ScalingStretch
// ScalingWidth the image is scaled so that its width is exactly the given width
ScalingWidth
// ScalingHeight the image is scaled so that its height is exactly the given height
ScalingHeight
// ScalingFit the image is scaled to the largest scale that allow the image to fit within a rectangle width x height
ScalingFit
// ScalingSameArea the image is scaled so that its area is exactly the area of the given rectangle width x height
ScalingSameArea
// ScalingFill the image is scaled to the smallest scale that allow the image to fully cover a rectangle width x height
ScalingFill
)
// ImageScaling style attributes used to display the image
type ImageScaling struct {
// Horizontal Alignment of the image
Halign Halign
// Vertical Alignment of the image
Valign Valign
// Width Height used by scaling policy
Width, Height float64
// ScalingPolicy defines the scaling policy to applied to the image
ScalingPolicy ScalingPolicy
}

97
drawing/flattener.go Normal file
View file

@ -0,0 +1,97 @@
package drawing
// Liner receive segment definition
type Liner interface {
// LineTo Draw a line from the current position to the point (x, y)
LineTo(x, y float64)
}
// Flattener receive segment definition
type Flattener interface {
// MoveTo Start a New line from the point (x, y)
MoveTo(x, y float64)
// LineTo Draw a line from the current position to the point (x, y)
LineTo(x, y float64)
// LineJoin add the most recent starting point to close the path to create a polygon
LineJoin()
// Close add the most recent starting point to close the path to create a polygon
Close()
// End mark the current line as finished so we can draw caps
End()
}
// Flatten convert curves into straight segments keeping join segments info
func Flatten(path *Path, flattener Flattener, scale float64) {
// First Point
var startX, startY float64
// Current Point
var x, y float64
var i int
for _, cmp := range path.Components {
switch cmp {
case MoveToComponent:
x, y = path.Points[i], path.Points[i+1]
startX, startY = x, y
if i != 0 {
flattener.End()
}
flattener.MoveTo(x, y)
i += 2
case LineToComponent:
x, y = path.Points[i], path.Points[i+1]
flattener.LineTo(x, y)
flattener.LineJoin()
i += 2
case QuadCurveToComponent:
// we include the previous point for the start of the curve
TraceQuad(flattener, path.Points[i-2:], 0.5)
x, y = path.Points[i+2], path.Points[i+3]
flattener.LineTo(x, y)
i += 4
case CubicCurveToComponent:
TraceCubic(flattener, path.Points[i-2:], 0.5)
x, y = path.Points[i+4], path.Points[i+5]
flattener.LineTo(x, y)
i += 6
case ArcToComponent:
x, y = TraceArc(flattener, path.Points[i], path.Points[i+1], path.Points[i+2], path.Points[i+3], path.Points[i+4], path.Points[i+5], scale)
flattener.LineTo(x, y)
i += 6
case CloseComponent:
flattener.LineTo(startX, startY)
flattener.Close()
}
}
flattener.End()
}
// SegmentedPath is a path of disparate point sectinos.
type SegmentedPath struct {
Points []float64
}
// MoveTo implements the path interface.
func (p *SegmentedPath) MoveTo(x, y float64) {
p.Points = append(p.Points, x, y)
// TODO need to mark this point as moveto
}
// LineTo implements the path interface.
func (p *SegmentedPath) LineTo(x, y float64) {
p.Points = append(p.Points, x, y)
}
// LineJoin implements the path interface.
func (p *SegmentedPath) LineJoin() {
// TODO need to mark the current point as linejoin
}
// Close implements the path interface.
func (p *SegmentedPath) Close() {
// TODO Close
}
// End implements the path interface.
func (p *SegmentedPath) End() {
// Nothing to do
}

30
drawing/free_type_path.go Normal file
View file

@ -0,0 +1,30 @@
package drawing
import (
"github.com/golang/freetype/raster"
"golang.org/x/image/math/fixed"
)
// FtLineBuilder is a builder for freetype raster glyphs.
type FtLineBuilder struct {
Adder raster.Adder
}
// MoveTo implements the path builder interface.
func (liner FtLineBuilder) MoveTo(x, y float64) {
liner.Adder.Start(fixed.Point26_6{X: fixed.Int26_6(x * 64), Y: fixed.Int26_6(y * 64)})
}
// LineTo implements the path builder interface.
func (liner FtLineBuilder) LineTo(x, y float64) {
liner.Adder.Add1(fixed.Point26_6{X: fixed.Int26_6(x * 64), Y: fixed.Int26_6(y * 64)})
}
// LineJoin implements the path builder interface.
func (liner FtLineBuilder) LineJoin() {}
// Close implements the path builder interface.
func (liner FtLineBuilder) Close() {}
// End implements the path builder interface.
func (liner FtLineBuilder) End() {}

View file

@ -0,0 +1,82 @@
package drawing
import (
"image"
"image/color"
"github.com/golang/freetype/truetype"
)
// GraphicContext describes the interface for the various backends (images, pdf, opengl, ...)
type GraphicContext interface {
// PathBuilder describes the interface for path drawing
PathBuilder
// BeginPath creates a new path
BeginPath()
// GetMatrixTransform returns the current transformation matrix
GetMatrixTransform() Matrix
// SetMatrixTransform sets the current transformation matrix
SetMatrixTransform(tr Matrix)
// ComposeMatrixTransform composes the current transformation matrix with tr
ComposeMatrixTransform(tr Matrix)
// Rotate applies a rotation to the current transformation matrix. angle is in radian.
Rotate(angle float64)
// Translate applies a translation to the current transformation matrix.
Translate(tx, ty float64)
// Scale applies a scale to the current transformation matrix.
Scale(sx, sy float64)
// SetStrokeColor sets the current stroke color
SetStrokeColor(c color.Color)
// SetFillColor sets the current fill color
SetFillColor(c color.Color)
// SetFillRule sets the current fill rule
SetFillRule(f FillRule)
// SetLineWidth sets the current line width
SetLineWidth(lineWidth float64)
// SetLineCap sets the current line cap
SetLineCap(cap LineCap)
// SetLineJoin sets the current line join
SetLineJoin(join LineJoin)
// SetLineDash sets the current dash
SetLineDash(dash []float64, dashOffset float64)
// SetFontSize sets the current font size
SetFontSize(fontSize float64)
// GetFontSize gets the current font size
GetFontSize() float64
// SetFont sets the font for the context
SetFont(f *truetype.Font)
// GetFont returns the current font
GetFont() *truetype.Font
// DrawImage draws the raster image in the current canvas
DrawImage(image image.Image)
// Save the context and push it to the context stack
Save()
// Restore remove the current context and restore the last one
Restore()
// Clear fills the current canvas with a default transparent color
Clear()
// ClearRect fills the specified rectangle with a default transparent color
ClearRect(x1, y1, x2, y2 int)
// SetDPI sets the current DPI
SetDPI(dpi int)
// GetDPI gets the current DPI
GetDPI() int
// GetStringBounds gets pixel bounds(dimensions) of given string
GetStringBounds(s string) (left, top, right, bottom float64)
// CreateStringPath creates a path from the string s at x, y
CreateStringPath(text string, x, y float64) (cursor float64)
// FillString draws the text at point (0, 0)
FillString(text string) (cursor float64)
// FillStringAt draws the text at the specified point (x, y)
FillStringAt(text string, x, y float64) (cursor float64)
// StrokeString draws the contour of the text at point (0, 0)
StrokeString(text string) (cursor float64)
// StrokeStringAt draws the contour of the text at point (x, y)
StrokeStringAt(text string, x, y float64) (cursor float64)
// Stroke strokes the paths with the color specified by SetStrokeColor
Stroke(paths ...*Path)
// Fill fills the paths with the color specified by SetFillColor
Fill(paths ...*Path)
// FillStroke first fills the paths and than strokes them
FillStroke(paths ...*Path)
}

13
drawing/image_filter.go Normal file
View file

@ -0,0 +1,13 @@
package drawing
// ImageFilter defines the type of filter to use
type ImageFilter int
const (
// LinearFilter defines a linear filter
LinearFilter ImageFilter = iota
// BilinearFilter defines a bilinear filter
BilinearFilter
// BicubicFilter defines a bicubic filter
BicubicFilter
)

48
drawing/line.go Normal file
View file

@ -0,0 +1,48 @@
package drawing
import (
"image/color"
"image/draw"
)
// PolylineBresenham draws a polyline to an image
func PolylineBresenham(img draw.Image, c color.Color, s ...float64) {
for i := 2; i < len(s); i += 2 {
Bresenham(img, c, int(s[i-2]+0.5), int(s[i-1]+0.5), int(s[i]+0.5), int(s[i+1]+0.5))
}
}
// Bresenham draws a line between (x0, y0) and (x1, y1)
func Bresenham(img draw.Image, color color.Color, x0, y0, x1, y1 int) {
dx := abs(x1 - x0)
dy := abs(y1 - y0)
var sx, sy int
if x0 < x1 {
sx = 1
} else {
sx = -1
}
if y0 < y1 {
sy = 1
} else {
sy = -1
}
err := dx - dy
var e2 int
for {
img.Set(x0, y0, color)
if x0 == x1 && y0 == y1 {
return
}
e2 = 2 * err
if e2 > -dy {
err = err - dy
x0 = x0 + sx
}
if e2 < dx {
err = err + dx
y0 = y0 + sy
}
}
}

220
drawing/matrix.go Normal file
View file

@ -0,0 +1,220 @@
package drawing
import (
"math"
)
// Matrix represents an affine transformation
type Matrix [6]float64
const (
epsilon = 1e-6
)
// Determinant compute the determinant of the matrix
func (tr Matrix) Determinant() float64 {
return tr[0]*tr[3] - tr[1]*tr[2]
}
// Transform applies the transformation matrix to points. It modify the points passed in parameter.
func (tr Matrix) Transform(points []float64) {
for i, j := 0, 1; j < len(points); i, j = i+2, j+2 {
x := points[i]
y := points[j]
points[i] = x*tr[0] + y*tr[2] + tr[4]
points[j] = x*tr[1] + y*tr[3] + tr[5]
}
}
// TransformPoint applies the transformation matrix to point. It returns the point the transformed point.
func (tr Matrix) TransformPoint(x, y float64) (xres, yres float64) {
xres = x*tr[0] + y*tr[2] + tr[4]
yres = x*tr[1] + y*tr[3] + tr[5]
return xres, yres
}
func minMax(x, y float64) (min, max float64) {
if x > y {
return y, x
}
return x, y
}
// TransformRectangle applies the transformation matrix to the rectangle represented by the min and the max point of the rectangle
func (tr Matrix) TransformRectangle(x0, y0, x2, y2 float64) (nx0, ny0, nx2, ny2 float64) {
points := []float64{x0, y0, x2, y0, x2, y2, x0, y2}
tr.Transform(points)
points[0], points[2] = minMax(points[0], points[2])
points[4], points[6] = minMax(points[4], points[6])
points[1], points[3] = minMax(points[1], points[3])
points[5], points[7] = minMax(points[5], points[7])
nx0 = math.Min(points[0], points[4])
ny0 = math.Min(points[1], points[5])
nx2 = math.Max(points[2], points[6])
ny2 = math.Max(points[3], points[7])
return nx0, ny0, nx2, ny2
}
// InverseTransform applies the transformation inverse matrix to the rectangle represented by the min and the max point of the rectangle
func (tr Matrix) InverseTransform(points []float64) {
d := tr.Determinant() // matrix determinant
for i, j := 0, 1; j < len(points); i, j = i+2, j+2 {
x := points[i]
y := points[j]
points[i] = ((x-tr[4])*tr[3] - (y-tr[5])*tr[2]) / d
points[j] = ((y-tr[5])*tr[0] - (x-tr[4])*tr[1]) / d
}
}
// InverseTransformPoint applies the transformation inverse matrix to point. It returns the point the transformed point.
func (tr Matrix) InverseTransformPoint(x, y float64) (xres, yres float64) {
d := tr.Determinant() // matrix determinant
xres = ((x-tr[4])*tr[3] - (y-tr[5])*tr[2]) / d
yres = ((y-tr[5])*tr[0] - (x-tr[4])*tr[1]) / d
return xres, yres
}
// VectorTransform applies the transformation matrix to points without using the translation parameter of the affine matrix.
// It modify the points passed in parameter.
func (tr Matrix) VectorTransform(points []float64) {
for i, j := 0, 1; j < len(points); i, j = i+2, j+2 {
x := points[i]
y := points[j]
points[i] = x*tr[0] + y*tr[2]
points[j] = x*tr[1] + y*tr[3]
}
}
// NewIdentityMatrix creates an identity transformation matrix.
func NewIdentityMatrix() Matrix {
return Matrix{1, 0, 0, 1, 0, 0}
}
// NewTranslationMatrix creates a transformation matrix with a translation tx and ty translation parameter
func NewTranslationMatrix(tx, ty float64) Matrix {
return Matrix{1, 0, 0, 1, tx, ty}
}
// NewScaleMatrix creates a transformation matrix with a sx, sy scale factor
func NewScaleMatrix(sx, sy float64) Matrix {
return Matrix{sx, 0, 0, sy, 0, 0}
}
// NewRotationMatrix creates a rotation transformation matrix. angle is in radian
func NewRotationMatrix(angle float64) Matrix {
c := math.Cos(angle)
s := math.Sin(angle)
return Matrix{c, s, -s, c, 0, 0}
}
// NewMatrixFromRects creates a transformation matrix, combining a scale and a translation, that transform rectangle1 into rectangle2.
func NewMatrixFromRects(rectangle1, rectangle2 [4]float64) Matrix {
xScale := (rectangle2[2] - rectangle2[0]) / (rectangle1[2] - rectangle1[0])
yScale := (rectangle2[3] - rectangle2[1]) / (rectangle1[3] - rectangle1[1])
xOffset := rectangle2[0] - (rectangle1[0] * xScale)
yOffset := rectangle2[1] - (rectangle1[1] * yScale)
return Matrix{xScale, 0, 0, yScale, xOffset, yOffset}
}
// Inverse computes the inverse matrix
func (tr *Matrix) Inverse() {
d := tr.Determinant() // matrix determinant
tr0, tr1, tr2, tr3, tr4, tr5 := tr[0], tr[1], tr[2], tr[3], tr[4], tr[5]
tr[0] = tr3 / d
tr[1] = -tr1 / d
tr[2] = -tr2 / d
tr[3] = tr0 / d
tr[4] = (tr2*tr5 - tr3*tr4) / d
tr[5] = (tr1*tr4 - tr0*tr5) / d
}
// Copy copies the matrix.
func (tr Matrix) Copy() Matrix {
var result Matrix
copy(result[:], tr[:])
return result
}
// Compose multiplies trToConcat x tr
func (tr *Matrix) Compose(trToCompose Matrix) {
tr0, tr1, tr2, tr3, tr4, tr5 := tr[0], tr[1], tr[2], tr[3], tr[4], tr[5]
tr[0] = trToCompose[0]*tr0 + trToCompose[1]*tr2
tr[1] = trToCompose[1]*tr3 + trToCompose[0]*tr1
tr[2] = trToCompose[2]*tr0 + trToCompose[3]*tr2
tr[3] = trToCompose[3]*tr3 + trToCompose[2]*tr1
tr[4] = trToCompose[4]*tr0 + trToCompose[5]*tr2 + tr4
tr[5] = trToCompose[5]*tr3 + trToCompose[4]*tr1 + tr5
}
// Scale adds a scale to the matrix
func (tr *Matrix) Scale(sx, sy float64) {
tr[0] = sx * tr[0]
tr[1] = sx * tr[1]
tr[2] = sy * tr[2]
tr[3] = sy * tr[3]
}
// Translate adds a translation to the matrix
func (tr *Matrix) Translate(tx, ty float64) {
tr[4] = tx*tr[0] + ty*tr[2] + tr[4]
tr[5] = ty*tr[3] + tx*tr[1] + tr[5]
}
// Rotate adds a rotation to the matrix.
func (tr *Matrix) Rotate(radians float64) {
c := math.Cos(radians)
s := math.Sin(radians)
t0 := c*tr[0] + s*tr[2]
t1 := s*tr[3] + c*tr[1]
t2 := c*tr[2] - s*tr[0]
t3 := c*tr[3] - s*tr[1]
tr[0] = t0
tr[1] = t1
tr[2] = t2
tr[3] = t3
}
// GetTranslation gets the matrix traslation.
func (tr Matrix) GetTranslation() (x, y float64) {
return tr[4], tr[5]
}
// GetScaling gets the matrix scaling.
func (tr Matrix) GetScaling() (x, y float64) {
return tr[0], tr[3]
}
// GetScale computes a scale for the matrix
func (tr Matrix) GetScale() float64 {
x := 0.707106781*tr[0] + 0.707106781*tr[1]
y := 0.707106781*tr[2] + 0.707106781*tr[3]
return math.Sqrt(x*x + y*y)
}
// ******************** Testing ********************
// Equals tests if a two transformation are equal. A tolerance is applied when comparing matrix elements.
func (tr Matrix) Equals(tr2 Matrix) bool {
for i := 0; i < 6; i = i + 1 {
if !fequals(tr[i], tr2[i]) {
return false
}
}
return true
}
// IsIdentity tests if a transformation is the identity transformation. A tolerance is applied when comparing matrix elements.
func (tr Matrix) IsIdentity() bool {
return fequals(tr[4], 0) && fequals(tr[5], 0) && tr.IsTranslation()
}
// IsTranslation tests if a transformation is is a pure translation. A tolerance is applied when comparing matrix elements.
func (tr Matrix) IsTranslation() bool {
return fequals(tr[0], 1) && fequals(tr[1], 0) && fequals(tr[2], 0) && fequals(tr[3], 1)
}
// fequals compares two floats. return true if the distance between the two floats is less than epsilon, false otherwise
func fequals(float1, float2 float64) bool {
return math.Abs(float1-float2) <= epsilon
}

31
drawing/painter.go Normal file
View file

@ -0,0 +1,31 @@
package drawing
import (
"image"
"image/color"
"golang.org/x/image/draw"
"golang.org/x/image/math/f64"
"github.com/golang/freetype/raster"
)
// Painter implements the freetype raster.Painter and has a SetColor method like the RGBAPainter
type Painter interface {
raster.Painter
SetColor(color color.Color)
}
// DrawImage draws an image into dest using an affine transformation matrix, an op and a filter
func DrawImage(src image.Image, dest draw.Image, tr Matrix, op draw.Op, filter ImageFilter) {
var transformer draw.Transformer
switch filter {
case LinearFilter:
transformer = draw.NearestNeighbor
case BilinearFilter:
transformer = draw.BiLinear
case BicubicFilter:
transformer = draw.CatmullRom
}
transformer.Transform(dest, f64.Aff3{tr[0], tr[1], tr[4], tr[2], tr[3], tr[5]}, src, src.Bounds(), op, nil)
}

186
drawing/path.go Normal file
View file

@ -0,0 +1,186 @@
package drawing
import (
"fmt"
"math"
)
// PathBuilder describes the interface for path drawing.
type PathBuilder interface {
// LastPoint returns the current point of the current sub path
LastPoint() (x, y float64)
// MoveTo creates a new subpath that start at the specified point
MoveTo(x, y float64)
// LineTo adds a line to the current subpath
LineTo(x, y float64)
// QuadCurveTo adds a quadratic Bézier curve to the current subpath
QuadCurveTo(cx, cy, x, y float64)
// CubicCurveTo adds a cubic Bézier curve to the current subpath
CubicCurveTo(cx1, cy1, cx2, cy2, x, y float64)
// ArcTo adds an arc to the current subpath
ArcTo(cx, cy, rx, ry, startAngle, angle float64)
// Close creates a line from the current point to the last MoveTo
// point (if not the same) and mark the path as closed so the
// first and last lines join nicely.
Close()
}
// PathComponent represents component of a path
type PathComponent int
const (
// MoveToComponent is a MoveTo component in a Path
MoveToComponent PathComponent = iota
// LineToComponent is a LineTo component in a Path
LineToComponent
// QuadCurveToComponent is a QuadCurveTo component in a Path
QuadCurveToComponent
// CubicCurveToComponent is a CubicCurveTo component in a Path
CubicCurveToComponent
// ArcToComponent is a ArcTo component in a Path
ArcToComponent
// CloseComponent is a ArcTo component in a Path
CloseComponent
)
// Path stores points
type Path struct {
// Components is a slice of PathComponent in a Path and mark the role of each points in the Path
Components []PathComponent
// Points are combined with Components to have a specific role in the path
Points []float64
// Last Point of the Path
x, y float64
}
func (p *Path) appendToPath(cmd PathComponent, points ...float64) {
p.Components = append(p.Components, cmd)
p.Points = append(p.Points, points...)
}
// LastPoint returns the current point of the current path
func (p *Path) LastPoint() (x, y float64) {
return p.x, p.y
}
// MoveTo starts a new path at (x, y) position
func (p *Path) MoveTo(x, y float64) {
p.appendToPath(MoveToComponent, x, y)
p.x = x
p.y = y
}
// LineTo adds a line to the current path
func (p *Path) LineTo(x, y float64) {
if len(p.Components) == 0 { //special case when no move has been done
p.MoveTo(0, 0)
}
p.appendToPath(LineToComponent, x, y)
p.x = x
p.y = y
}
// QuadCurveTo adds a quadratic bezier curve to the current path
func (p *Path) QuadCurveTo(cx, cy, x, y float64) {
if len(p.Components) == 0 { //special case when no move has been done
p.MoveTo(0, 0)
}
p.appendToPath(QuadCurveToComponent, cx, cy, x, y)
p.x = x
p.y = y
}
// CubicCurveTo adds a cubic bezier curve to the current path
func (p *Path) CubicCurveTo(cx1, cy1, cx2, cy2, x, y float64) {
if len(p.Components) == 0 { //special case when no move has been done
p.MoveTo(0, 0)
}
p.appendToPath(CubicCurveToComponent, cx1, cy1, cx2, cy2, x, y)
p.x = x
p.y = y
}
// ArcTo adds an arc to the path
func (p *Path) ArcTo(cx, cy, rx, ry, startAngle, delta float64) {
endAngle := startAngle + delta
clockWise := true
if delta < 0 {
clockWise = false
}
// normalize
if clockWise {
for endAngle < startAngle {
endAngle += math.Pi * 2.0
}
} else {
for startAngle < endAngle {
startAngle += math.Pi * 2.0
}
}
startX := cx + math.Cos(startAngle)*rx
startY := cy + math.Sin(startAngle)*ry
if len(p.Components) > 0 {
p.LineTo(startX, startY)
} else {
p.MoveTo(startX, startY)
}
p.appendToPath(ArcToComponent, cx, cy, rx, ry, startAngle, delta)
p.x = cx + math.Cos(endAngle)*rx
p.y = cy + math.Sin(endAngle)*ry
}
// Close closes the current path
func (p *Path) Close() {
p.appendToPath(CloseComponent)
}
// Copy make a clone of the current path and return it
func (p *Path) Copy() (dest *Path) {
dest = new(Path)
dest.Components = make([]PathComponent, len(p.Components))
copy(dest.Components, p.Components)
dest.Points = make([]float64, len(p.Points))
copy(dest.Points, p.Points)
dest.x, dest.y = p.x, p.y
return dest
}
// Clear reset the path
func (p *Path) Clear() {
p.Components = p.Components[0:0]
p.Points = p.Points[0:0]
return
}
// IsEmpty returns true if the path is empty
func (p *Path) IsEmpty() bool {
return len(p.Components) == 0
}
// String returns a debug text view of the path
func (p *Path) String() string {
s := ""
j := 0
for _, cmd := range p.Components {
switch cmd {
case MoveToComponent:
s += fmt.Sprintf("MoveTo: %f, %f\n", p.Points[j], p.Points[j+1])
j = j + 2
case LineToComponent:
s += fmt.Sprintf("LineTo: %f, %f\n", p.Points[j], p.Points[j+1])
j = j + 2
case QuadCurveToComponent:
s += fmt.Sprintf("QuadCurveTo: %f, %f, %f, %f\n", p.Points[j], p.Points[j+1], p.Points[j+2], p.Points[j+3])
j = j + 4
case CubicCurveToComponent:
s += fmt.Sprintf("CubicCurveTo: %f, %f, %f, %f, %f, %f\n", p.Points[j], p.Points[j+1], p.Points[j+2], p.Points[j+3], p.Points[j+4], p.Points[j+5])
j = j + 6
case ArcToComponent:
s += fmt.Sprintf("ArcTo: %f, %f, %f, %f, %f, %f\n", p.Points[j], p.Points[j+1], p.Points[j+2], p.Points[j+3], p.Points[j+4], p.Points[j+5])
j = j + 6
case CloseComponent:
s += "Close\n"
}
}
return s
}

View file

@ -0,0 +1,283 @@
package drawing
import (
"errors"
"image"
"image/color"
"math"
"github.com/golang/freetype/raster"
"github.com/golang/freetype/truetype"
"golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
)
// NewRasterGraphicContext creates a new Graphic context from an image.
func NewRasterGraphicContext(img draw.Image) (*RasterGraphicContext, error) {
var painter Painter
switch selectImage := img.(type) {
case *image.RGBA:
painter = raster.NewRGBAPainter(selectImage)
default:
return nil, errors.New("NewRasterGraphicContext() :: invalid image type")
}
return NewRasterGraphicContextWithPainter(img, painter), nil
}
// NewRasterGraphicContextWithPainter creates a new Graphic context from an image and a Painter (see Freetype-go)
func NewRasterGraphicContextWithPainter(img draw.Image, painter Painter) *RasterGraphicContext {
width, height := img.Bounds().Dx(), img.Bounds().Dy()
return &RasterGraphicContext{
NewStackGraphicContext(),
img,
painter,
raster.NewRasterizer(width, height),
raster.NewRasterizer(width, height),
&truetype.GlyphBuf{},
DefaultDPI,
}
}
// RasterGraphicContext is the implementation of GraphicContext for a raster image
type RasterGraphicContext struct {
*StackGraphicContext
img draw.Image
painter Painter
fillRasterizer *raster.Rasterizer
strokeRasterizer *raster.Rasterizer
glyphBuf *truetype.GlyphBuf
DPI float64
}
// SetDPI sets the screen resolution in dots per inch.
func (rgc *RasterGraphicContext) SetDPI(dpi float64) {
rgc.DPI = dpi
rgc.recalc()
}
// GetDPI returns the resolution of the Image GraphicContext
func (rgc *RasterGraphicContext) GetDPI() float64 {
return rgc.DPI
}
// Clear fills the current canvas with a default transparent color
func (rgc *RasterGraphicContext) Clear() {
width, height := rgc.img.Bounds().Dx(), rgc.img.Bounds().Dy()
rgc.ClearRect(0, 0, width, height)
}
// ClearRect fills the current canvas with a default transparent color at the specified rectangle
func (rgc *RasterGraphicContext) ClearRect(x1, y1, x2, y2 int) {
imageColor := image.NewUniform(rgc.current.FillColor)
draw.Draw(rgc.img, image.Rect(x1, y1, x2, y2), imageColor, image.ZP, draw.Over)
}
// DrawImage draws the raster image in the current canvas
func (rgc *RasterGraphicContext) DrawImage(img image.Image) {
DrawImage(img, rgc.img, rgc.current.Tr, draw.Over, BilinearFilter)
}
// FillString draws the text at point (0, 0)
func (rgc *RasterGraphicContext) FillString(text string) (cursor float64, err error) {
cursor, err = rgc.FillStringAt(text, 0, 0)
return
}
// FillStringAt draws the text at the specified point (x, y)
func (rgc *RasterGraphicContext) FillStringAt(text string, x, y float64) (cursor float64, err error) {
cursor, err = rgc.CreateStringPath(text, x, y)
rgc.Fill()
return
}
// StrokeString draws the contour of the text at point (0, 0)
func (rgc *RasterGraphicContext) StrokeString(text string) (cursor float64, err error) {
cursor, err = rgc.StrokeStringAt(text, 0, 0)
return
}
// StrokeStringAt draws the contour of the text at point (x, y)
func (rgc *RasterGraphicContext) StrokeStringAt(text string, x, y float64) (cursor float64, err error) {
cursor, err = rgc.CreateStringPath(text, x, y)
rgc.Stroke()
return
}
func (rgc *RasterGraphicContext) drawGlyph(glyph truetype.Index, dx, dy float64) error {
if err := rgc.glyphBuf.Load(rgc.current.Font, fixed.Int26_6(rgc.current.Scale), glyph, font.HintingNone); err != nil {
return err
}
e0 := 0
for _, e1 := range rgc.glyphBuf.Ends {
DrawContour(rgc, rgc.glyphBuf.Points[e0:e1], dx, dy)
e0 = e1
}
return nil
}
// CreateStringPath creates a path from the string s at x, y, and returns the string width.
// The text is placed so that the left edge of the em square of the first character of s
// and the baseline intersect at x, y. The majority of the affected pixels will be
// above and to the right of the point, but some may be below or to the left.
// For example, drawing a string that starts with a 'J' in an italic font may
// affect pixels below and left of the point.
func (rgc *RasterGraphicContext) CreateStringPath(s string, x, y float64) (cursor float64, err error) {
f := rgc.GetFont()
if f == nil {
err = errors.New("No font loaded, cannot continue")
return
}
rgc.recalc()
startx := x
prev, hasPrev := truetype.Index(0), false
for _, rc := range s {
index := f.Index(rc)
if hasPrev {
x += fUnitsToFloat64(f.Kern(fixed.Int26_6(rgc.current.Scale), prev, index))
}
err = rgc.drawGlyph(index, x, y)
if err != nil {
cursor = x - startx
return
}
x += fUnitsToFloat64(f.HMetric(fixed.Int26_6(rgc.current.Scale), index).AdvanceWidth)
prev, hasPrev = index, true
}
cursor = x - startx
return
}
// GetStringBounds returns the approximate pixel bounds of a string.
func (rgc *RasterGraphicContext) GetStringBounds(s string) (left, top, right, bottom float64, err error) {
f := rgc.GetFont()
if f == nil {
err = errors.New("No font loaded, cannot continue")
return
}
rgc.recalc()
left = math.MaxFloat64
top = math.MaxFloat64
cursor := 0.0
prev, hasPrev := truetype.Index(0), false
for _, rc := range s {
index := f.Index(rc)
if hasPrev {
cursor += fUnitsToFloat64(f.Kern(fixed.Int26_6(rgc.current.Scale), prev, index))
}
if err = rgc.glyphBuf.Load(rgc.current.Font, fixed.Int26_6(rgc.current.Scale), index, font.HintingNone); err != nil {
return
}
e0 := 0
for _, e1 := range rgc.glyphBuf.Ends {
ps := rgc.glyphBuf.Points[e0:e1]
for _, p := range ps {
x, y := pointToF64Point(p)
top = math.Min(top, y)
bottom = math.Max(bottom, y)
left = math.Min(left, x+cursor)
right = math.Max(right, x+cursor)
}
e0 = e1
}
cursor += fUnitsToFloat64(f.HMetric(fixed.Int26_6(rgc.current.Scale), index).AdvanceWidth)
prev, hasPrev = index, true
}
return
}
// recalc recalculates scale and bounds values from the font size, screen
// resolution and font metrics, and invalidates the glyph cache.
func (rgc *RasterGraphicContext) recalc() {
rgc.current.Scale = rgc.current.FontSizePoints * float64(rgc.DPI)
}
// SetFont sets the font used to draw text.
func (rgc *RasterGraphicContext) SetFont(font *truetype.Font) {
rgc.current.Font = font
}
// GetFont returns the font used to draw text.
func (rgc *RasterGraphicContext) GetFont() *truetype.Font {
return rgc.current.Font
}
// SetFontSize sets the font size in points (as in ``a 12 point font'').
func (rgc *RasterGraphicContext) SetFontSize(fontSizePoints float64) {
rgc.current.FontSizePoints = fontSizePoints
rgc.recalc()
}
func (rgc *RasterGraphicContext) paint(rasterizer *raster.Rasterizer, color color.Color) {
rgc.painter.SetColor(color)
rasterizer.Rasterize(rgc.painter)
rasterizer.Clear()
rgc.current.Path.Clear()
}
// Stroke strokes the paths with the color specified by SetStrokeColor
func (rgc *RasterGraphicContext) Stroke(paths ...*Path) {
paths = append(paths, rgc.current.Path)
rgc.strokeRasterizer.UseNonZeroWinding = true
stroker := NewLineStroker(rgc.current.Cap, rgc.current.Join, Transformer{Tr: rgc.current.Tr, Flattener: FtLineBuilder{Adder: rgc.strokeRasterizer}})
stroker.HalfLineWidth = rgc.current.LineWidth / 2
var liner Flattener
if rgc.current.Dash != nil && len(rgc.current.Dash) > 0 {
liner = NewDashVertexConverter(rgc.current.Dash, rgc.current.DashOffset, stroker)
} else {
liner = stroker
}
for _, p := range paths {
Flatten(p, liner, rgc.current.Tr.GetScale())
}
rgc.paint(rgc.strokeRasterizer, rgc.current.StrokeColor)
}
// Fill fills the paths with the color specified by SetFillColor
func (rgc *RasterGraphicContext) Fill(paths ...*Path) {
paths = append(paths, rgc.current.Path)
rgc.fillRasterizer.UseNonZeroWinding = rgc.current.FillRule == FillRuleWinding
flattener := Transformer{Tr: rgc.current.Tr, Flattener: FtLineBuilder{Adder: rgc.fillRasterizer}}
for _, p := range paths {
Flatten(p, flattener, rgc.current.Tr.GetScale())
}
rgc.paint(rgc.fillRasterizer, rgc.current.FillColor)
}
// FillStroke first fills the paths and than strokes them
func (rgc *RasterGraphicContext) FillStroke(paths ...*Path) {
paths = append(paths, rgc.current.Path)
rgc.fillRasterizer.UseNonZeroWinding = rgc.current.FillRule == FillRuleWinding
rgc.strokeRasterizer.UseNonZeroWinding = true
flattener := Transformer{Tr: rgc.current.Tr, Flattener: FtLineBuilder{Adder: rgc.fillRasterizer}}
stroker := NewLineStroker(rgc.current.Cap, rgc.current.Join, Transformer{Tr: rgc.current.Tr, Flattener: FtLineBuilder{Adder: rgc.strokeRasterizer}})
stroker.HalfLineWidth = rgc.current.LineWidth / 2
var liner Flattener
if rgc.current.Dash != nil && len(rgc.current.Dash) > 0 {
liner = NewDashVertexConverter(rgc.current.Dash, rgc.current.DashOffset, stroker)
} else {
liner = stroker
}
demux := DemuxFlattener{Flatteners: []Flattener{flattener, liner}}
for _, p := range paths {
Flatten(p, demux, rgc.current.Tr.GetScale())
}
// Fill
rgc.paint(rgc.fillRasterizer, rgc.current.FillColor)
// Stroke
rgc.paint(rgc.strokeRasterizer, rgc.current.StrokeColor)
}

View file

@ -0,0 +1,211 @@
package drawing
import (
"image"
"image/color"
"github.com/golang/freetype/truetype"
)
// StackGraphicContext is a context that does thngs.
type StackGraphicContext struct {
current *ContextStack
}
// ContextStack is a graphic context implementation.
type ContextStack struct {
Tr Matrix
Path *Path
LineWidth float64
Dash []float64
DashOffset float64
StrokeColor color.Color
FillColor color.Color
FillRule FillRule
Cap LineCap
Join LineJoin
FontSizePoints float64
Font *truetype.Font
Scale float64
Previous *ContextStack
}
// NewStackGraphicContext Create a new Graphic context from an image
func NewStackGraphicContext() *StackGraphicContext {
gc := &StackGraphicContext{}
gc.current = new(ContextStack)
gc.current.Tr = NewIdentityMatrix()
gc.current.Path = new(Path)
gc.current.LineWidth = 1.0
gc.current.StrokeColor = image.Black
gc.current.FillColor = image.White
gc.current.Cap = RoundCap
gc.current.FillRule = FillRuleEvenOdd
gc.current.Join = RoundJoin
gc.current.FontSizePoints = 10
return gc
}
// GetMatrixTransform returns the matrix transform.
func (gc *StackGraphicContext) GetMatrixTransform() Matrix {
return gc.current.Tr
}
// SetMatrixTransform sets the matrix transform.
func (gc *StackGraphicContext) SetMatrixTransform(tr Matrix) {
gc.current.Tr = tr
}
// ComposeMatrixTransform composes a transform into the current transform.
func (gc *StackGraphicContext) ComposeMatrixTransform(tr Matrix) {
gc.current.Tr.Compose(tr)
}
// Rotate rotates the matrix transform by an angle in degrees.
func (gc *StackGraphicContext) Rotate(angle float64) {
gc.current.Tr.Rotate(angle)
}
// Translate translates a transform.
func (gc *StackGraphicContext) Translate(tx, ty float64) {
gc.current.Tr.Translate(tx, ty)
}
// Scale scales a transform.
func (gc *StackGraphicContext) Scale(sx, sy float64) {
gc.current.Tr.Scale(sx, sy)
}
// SetStrokeColor sets the stroke color.
func (gc *StackGraphicContext) SetStrokeColor(c color.Color) {
gc.current.StrokeColor = c
}
// SetFillColor sets the fill color.
func (gc *StackGraphicContext) SetFillColor(c color.Color) {
gc.current.FillColor = c
}
// SetFillRule sets the fill rule.
func (gc *StackGraphicContext) SetFillRule(f FillRule) {
gc.current.FillRule = f
}
// SetLineWidth sets the line width.
func (gc *StackGraphicContext) SetLineWidth(lineWidth float64) {
gc.current.LineWidth = lineWidth
}
// SetLineCap sets the line cap.
func (gc *StackGraphicContext) SetLineCap(cap LineCap) {
gc.current.Cap = cap
}
// SetLineJoin sets the line join.
func (gc *StackGraphicContext) SetLineJoin(join LineJoin) {
gc.current.Join = join
}
// SetLineDash sets the line dash.
func (gc *StackGraphicContext) SetLineDash(dash []float64, dashOffset float64) {
gc.current.Dash = dash
gc.current.DashOffset = dashOffset
}
// SetFontSize sets the font size.
func (gc *StackGraphicContext) SetFontSize(fontSizePoints float64) {
gc.current.FontSizePoints = fontSizePoints
}
// GetFontSize gets the font size.
func (gc *StackGraphicContext) GetFontSize() float64 {
return gc.current.FontSizePoints
}
// SetFont sets the current font.
func (gc *StackGraphicContext) SetFont(f *truetype.Font) {
gc.current.Font = f
}
// GetFont returns the font.
func (gc *StackGraphicContext) GetFont() *truetype.Font {
return gc.current.Font
}
// BeginPath starts a new path.
func (gc *StackGraphicContext) BeginPath() {
gc.current.Path.Clear()
}
// IsEmpty returns if the path is empty.
func (gc *StackGraphicContext) IsEmpty() bool {
return gc.current.Path.IsEmpty()
}
// LastPoint returns the last point on the path.
func (gc *StackGraphicContext) LastPoint() (x float64, y float64) {
return gc.current.Path.LastPoint()
}
// MoveTo moves the cursor for a path.
func (gc *StackGraphicContext) MoveTo(x, y float64) {
gc.current.Path.MoveTo(x, y)
}
// LineTo draws a line.
func (gc *StackGraphicContext) LineTo(x, y float64) {
gc.current.Path.LineTo(x, y)
}
// QuadCurveTo draws a quad curve.
func (gc *StackGraphicContext) QuadCurveTo(cx, cy, x, y float64) {
gc.current.Path.QuadCurveTo(cx, cy, x, y)
}
// CubicCurveTo draws a cubic curve.
func (gc *StackGraphicContext) CubicCurveTo(cx1, cy1, cx2, cy2, x, y float64) {
gc.current.Path.CubicCurveTo(cx1, cy1, cx2, cy2, x, y)
}
// ArcTo draws an arc.
func (gc *StackGraphicContext) ArcTo(cx, cy, rx, ry, startAngle, delta float64) {
gc.current.Path.ArcTo(cx, cy, rx, ry, startAngle, delta)
}
// Close closes a path.
func (gc *StackGraphicContext) Close() {
gc.current.Path.Close()
}
// Save pushes a context onto the stack.
func (gc *StackGraphicContext) Save() {
context := new(ContextStack)
context.FontSizePoints = gc.current.FontSizePoints
context.Font = gc.current.Font
context.LineWidth = gc.current.LineWidth
context.StrokeColor = gc.current.StrokeColor
context.FillColor = gc.current.FillColor
context.FillRule = gc.current.FillRule
context.Dash = gc.current.Dash
context.DashOffset = gc.current.DashOffset
context.Cap = gc.current.Cap
context.Join = gc.current.Join
context.Path = gc.current.Path.Copy()
context.Font = gc.current.Font
context.Scale = gc.current.Scale
copy(context.Tr[:], gc.current.Tr[:])
context.Previous = gc.current
gc.current = context
}
// Restore restores the previous context.
func (gc *StackGraphicContext) Restore() {
if gc.current.Previous != nil {
oldContext := gc.current
gc.current = gc.current.Previous
oldContext.Previous = nil
}
}

85
drawing/stroker.go Normal file
View file

@ -0,0 +1,85 @@
// Copyright 2010 The draw2d Authors. All rights reserved.
// created: 13/12/2010 by Laurent Le Goff
package drawing
// NewLineStroker creates a new line stroker.
func NewLineStroker(c LineCap, j LineJoin, flattener Flattener) *LineStroker {
l := new(LineStroker)
l.Flattener = flattener
l.HalfLineWidth = 0.5
l.Cap = c
l.Join = j
return l
}
// LineStroker draws the stroke portion of a line.
type LineStroker struct {
Flattener Flattener
HalfLineWidth float64
Cap LineCap
Join LineJoin
vertices []float64
rewind []float64
x, y, nx, ny float64
}
// MoveTo implements the path builder interface.
func (l *LineStroker) MoveTo(x, y float64) {
l.x, l.y = x, y
}
// LineTo implements the path builder interface.
func (l *LineStroker) LineTo(x, y float64) {
l.line(l.x, l.y, x, y)
}
// LineJoin implements the path builder interface.
func (l *LineStroker) LineJoin() {}
func (l *LineStroker) line(x1, y1, x2, y2 float64) {
dx := (x2 - x1)
dy := (y2 - y1)
d := vectorDistance(dx, dy)
if d != 0 {
nx := dy * l.HalfLineWidth / d
ny := -(dx * l.HalfLineWidth / d)
l.appendVertex(x1+nx, y1+ny, x2+nx, y2+ny, x1-nx, y1-ny, x2-nx, y2-ny)
l.x, l.y, l.nx, l.ny = x2, y2, nx, ny
}
}
// Close implements the path builder interface.
func (l *LineStroker) Close() {
if len(l.vertices) > 1 {
l.appendVertex(l.vertices[0], l.vertices[1], l.rewind[0], l.rewind[1])
}
}
// End implements the path builder interface.
func (l *LineStroker) End() {
if len(l.vertices) > 1 {
l.Flattener.MoveTo(l.vertices[0], l.vertices[1])
for i, j := 2, 3; j < len(l.vertices); i, j = i+2, j+2 {
l.Flattener.LineTo(l.vertices[i], l.vertices[j])
}
}
for i, j := len(l.rewind)-2, len(l.rewind)-1; j > 0; i, j = i-2, j-2 {
l.Flattener.LineTo(l.rewind[i], l.rewind[j])
}
if len(l.vertices) > 1 {
l.Flattener.LineTo(l.vertices[0], l.vertices[1])
}
l.Flattener.End()
// reinit vertices
l.vertices = l.vertices[0:0]
l.rewind = l.rewind[0:0]
l.x, l.y, l.nx, l.ny = 0, 0, 0, 0
}
func (l *LineStroker) appendVertex(vertices ...float64) {
s := len(vertices) / 2
l.vertices = append(l.vertices, vertices[:s]...)
l.rewind = append(l.rewind, vertices[s:]...)
}

67
drawing/text.go Normal file
View file

@ -0,0 +1,67 @@
package drawing
import (
"github.com/golang/freetype/truetype"
"golang.org/x/image/math/fixed"
)
// DrawContour draws the given closed contour at the given sub-pixel offset.
func DrawContour(path PathBuilder, ps []truetype.Point, dx, dy float64) {
if len(ps) == 0 {
return
}
startX, startY := pointToF64Point(ps[0])
path.MoveTo(startX+dx, startY+dy)
q0X, q0Y, on0 := startX, startY, true
for _, p := range ps[1:] {
qX, qY := pointToF64Point(p)
on := p.Flags&0x01 != 0
if on {
if on0 {
path.LineTo(qX+dx, qY+dy)
} else {
path.QuadCurveTo(q0X+dx, q0Y+dy, qX+dx, qY+dy)
}
} else if !on0 {
midX := (q0X + qX) / 2
midY := (q0Y + qY) / 2
path.QuadCurveTo(q0X+dx, q0Y+dy, midX+dx, midY+dy)
}
q0X, q0Y, on0 = qX, qY, on
}
// Close the curve.
if on0 {
path.LineTo(startX+dx, startY+dy)
} else {
path.QuadCurveTo(q0X+dx, q0Y+dy, startX+dx, startY+dy)
}
}
// FontExtents contains font metric information.
type FontExtents struct {
// Ascent is the distance that the text
// extends above the baseline.
Ascent float64
// Descent is the distance that the text
// extends below the baseline. The descent
// is given as a negative value.
Descent float64
// Height is the distance from the lowest
// descending point to the highest ascending
// point.
Height float64
}
// Extents returns the FontExtents for a font.
// TODO needs to read this https://developer.apple.com/fonts/TrueType-Reference-Manual/RM02/Chap2.html#intro
func Extents(font *truetype.Font, size float64) FontExtents {
bounds := font.Bounds(fixed.Int26_6(font.FUnitsPerEm()))
scale := size / float64(font.FUnitsPerEm())
return FontExtents{
Ascent: float64(bounds.Max.Y) * scale,
Descent: float64(bounds.Min.Y) * scale,
Height: float64(bounds.Max.Y-bounds.Min.Y) * scale,
}
}

39
drawing/transformer.go Normal file
View file

@ -0,0 +1,39 @@
// Copyright 2010 The draw2d Authors. All rights reserved.
// created: 13/12/2010 by Laurent Le Goff
package drawing
// Transformer apply the Matrix transformation tr
type Transformer struct {
Tr Matrix
Flattener Flattener
}
// MoveTo implements the path builder interface.
func (t Transformer) MoveTo(x, y float64) {
u := x*t.Tr[0] + y*t.Tr[2] + t.Tr[4]
v := x*t.Tr[1] + y*t.Tr[3] + t.Tr[5]
t.Flattener.MoveTo(u, v)
}
// LineTo implements the path builder interface.
func (t Transformer) LineTo(x, y float64) {
u := x*t.Tr[0] + y*t.Tr[2] + t.Tr[4]
v := x*t.Tr[1] + y*t.Tr[3] + t.Tr[5]
t.Flattener.LineTo(u, v)
}
// LineJoin implements the path builder interface.
func (t Transformer) LineJoin() {
t.Flattener.LineJoin()
}
// Close implements the path builder interface.
func (t Transformer) Close() {
t.Flattener.Close()
}
// End implements the path builder interface.
func (t Transformer) End() {
t.Flattener.End()
}

68
drawing/util.go Normal file
View file

@ -0,0 +1,68 @@
package drawing
import (
"math"
"golang.org/x/image/math/fixed"
"github.com/golang/freetype/raster"
"github.com/golang/freetype/truetype"
)
// PixelsToPoints returns the points for a given number of pixels at a DPI.
func PixelsToPoints(dpi, pixels float64) (points float64) {
points = (pixels * 72.0) / dpi
return
}
// PointsToPixels returns the pixels for a given number of points at a DPI.
func PointsToPixels(dpi, points float64) (pixels float64) {
pixels = (points * dpi) / 72.0
return
}
func abs(i int) int {
if i < 0 {
return -i
}
return i
}
func distance(x1, y1, x2, y2 float64) float64 {
return vectorDistance(x2-x1, y2-y1)
}
func vectorDistance(dx, dy float64) float64 {
return float64(math.Sqrt(dx*dx + dy*dy))
}
func toFtCap(c LineCap) raster.Capper {
switch c {
case RoundCap:
return raster.RoundCapper
case ButtCap:
return raster.ButtCapper
case SquareCap:
return raster.SquareCapper
}
return raster.RoundCapper
}
func toFtJoin(j LineJoin) raster.Joiner {
switch j {
case RoundJoin:
return raster.RoundJoiner
case BevelJoin:
return raster.BevelJoiner
}
return raster.RoundJoiner
}
func pointToF64Point(p truetype.Point) (x, y float64) {
return fUnitsToFloat64(p.X), -fUnitsToFloat64(p.Y)
}
func fUnitsToFloat64(x fixed.Int26_6) float64 {
scaled := x << 2
return float64(scaled/256) + float64(scaled%256)/256.0
}

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

105
ema_series_test.go Normal file
View file

@ -0,0 +1,105 @@
package chart
import (
"testing"
"github.com/wcharczuk/go-chart/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)
}

View file

@ -0,0 +1,44 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
)
func main() {
/*
In this example we add an `Annotation` series, which is a special type of series that
draws annotation labels at given X and Y values (as translated by their respective ranges).
It is important to not that the chart automatically sizes the canvas box to fit the annotations,
As well as automatically assign a series color for the `Stroke` or border component of the series.
The annotation series is most often used by the original author to show the last value of another series, but
they can be used in other capacities as well.
*/
graph := chart.Chart{
Series: []chart.Series{
chart.ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
chart.AnnotationSeries{
Annotations: []chart.Value2{
{XValue: 1.0, YValue: 1.0, Label: "One"},
{XValue: 2.0, YValue: 2.0, Label: "Two"},
{XValue: 3.0, YValue: 3.0, Label: "Three"},
{XValue: 4.0, YValue: 4.0, Label: "Four"},
{XValue: 5.0, YValue: 5.0, Label: "Five"},
},
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

34
examples/axes/main.go Normal file
View file

@ -0,0 +1,34 @@
package main
//go:generate go run main.go
import (
"os"
chart "github.com/wcharczuk/go-chart/v2"
)
func main() {
/*
The below will draw the same chart as the `basic` example, except with both the x and y axes turned on.
In this case, both the x and y axis ticks are generated automatically, the x and y ranges are established automatically, the canvas "box" is adjusted to fit the space the axes occupy so as not to clip.
*/
graph := chart.Chart{
Series: []chart.Series{
chart.ContinuousSeries{
Style: chart.Style{
StrokeColor: chart.GetDefaultColor(0).WithAlpha(64),
FillColor: chart.GetDefaultColor(0).WithAlpha(64),
},
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

BIN
examples/axes/output.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,40 @@
package main
//go:generate go run main.go
import (
"os"
chart "github.com/wcharczuk/go-chart/v2"
)
func main() {
/*
The below will draw the same chart as the `basic` example, except with both the x and y axes turned on.
In this case, both the x and y axis ticks are generated automatically, the x and y ranges are established automatically, the canvas "box" is adjusted to fit the space the axes occupy so as not to clip.
*/
graph := chart.Chart{
XAxis: chart.XAxis{
Name: "The XAxis",
},
YAxis: chart.YAxis{
Name: "The YAxis",
},
Series: []chart.Series{
chart.ContinuousSeries{
Style: chart.Style{
StrokeColor: chart.GetDefaultColor(0).WithAlpha(64),
FillColor: chart.GetDefaultColor(0).WithAlpha(64),
},
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,35 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
)
func main() {
graph := chart.BarChart{
Title: "Test Bar Chart",
Background: chart.Style{
Padding: chart.Box{
Top: 40,
},
},
Height: 512,
BarWidth: 60,
Bars: []chart.Value{
{Value: 5.25, Label: "Blue"},
{Value: 4.88, Label: "Green"},
{Value: 4.74, Label: "Gray"},
{Value: 3.22, Label: "Orange"},
{Value: 3, Label: "Test"},
{Value: 2.27, Label: "??"},
{Value: 1, Label: "!!"},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,62 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func main() {
profitStyle := chart.Style{
FillColor: drawing.ColorFromHex("13c158"),
StrokeColor: drawing.ColorFromHex("13c158"),
StrokeWidth: 0,
}
lossStyle := chart.Style{
FillColor: drawing.ColorFromHex("c11313"),
StrokeColor: drawing.ColorFromHex("c11313"),
StrokeWidth: 0,
}
sbc := chart.BarChart{
Title: "Bar Chart Using BaseValue",
Background: chart.Style{
Padding: chart.Box{
Top: 40,
},
},
Height: 512,
BarWidth: 60,
YAxis: chart.YAxis{
Ticks: []chart.Tick{
{Value: -4.0, Label: "-4"},
{Value: -2.0, Label: "-2"},
{Value: 0, Label: "0"},
{Value: 2.0, Label: "2"},
{Value: 4.0, Label: "4"},
{Value: 6.0, Label: "6"},
{Value: 8.0, Label: "8"},
{Value: 10.0, Label: "10"},
{Value: 12.0, Label: "12"},
},
},
UseBaseValue: true,
BaseValue: 0.0,
Bars: []chart.Value{
{Value: 10.0, Style: profitStyle, Label: "Profit"},
{Value: 12.0, Style: profitStyle, Label: "More Profit"},
{Value: 8.0, Style: profitStyle, Label: "Still Profit"},
{Value: -4.0, Style: lossStyle, Label: "Loss!"},
{Value: 3.0, Style: profitStyle, Label: "Phew Ok"},
{Value: -2.0, Style: lossStyle, Label: "Oh No!"},
},
}
f, _ := os.Create("output.png")
defer f.Close()
sbc.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

23
examples/basic/main.go Normal file
View file

@ -0,0 +1,23 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
)
func main() {
graph := chart.Chart{
Series: []chart.Series{
chart.ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

BIN
examples/basic/output.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,52 @@
package main
//go:generate go run main.go
import (
"fmt"
"math/rand"
"os"
"time"
"github.com/wcharczuk/go-chart/v2"
)
func random(min, max float64) float64 {
return rand.Float64()*(max-min) + min
}
func main() {
numValues := 1024
numSeries := 100
series := make([]chart.Series, numSeries)
for i := 0; i < numSeries; i++ {
xValues := make([]time.Time, numValues)
yValues := make([]float64, numValues)
for j := 0; j < numValues; j++ {
xValues[j] = time.Now().AddDate(0, 0, (numValues-j)*-1)
yValues[j] = random(float64(-500), float64(500))
}
series[i] = chart.TimeSeries{
Name: fmt.Sprintf("aaa.bbb.hostname-%v.ccc.ddd.eee.fff.ggg.hhh.iii.jjj.kkk.lll.mmm.nnn.value", i),
XValues: xValues,
YValues: yValues,
}
}
graph := chart.Chart{
XAxis: chart.XAxis{
Name: "Time",
},
YAxis: chart.YAxis{
Name: "Value",
},
Series: series,
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

View file

@ -0,0 +1,59 @@
package main
import (
"fmt"
"log"
"net/http"
"github.com/wcharczuk/go-chart/v2"
)
// Note: Additional examples on how to add Stylesheets are in the custom_stylesheets example
func inlineSVGWithClasses(res http.ResponseWriter, req *http.Request) {
res.Write([]byte(
"<!DOCTYPE html><html><head>" +
"<link rel=\"stylesheet\" type=\"text/css\" href=\"/main.css\">" +
"</head>" +
"<body>"))
pie := chart.PieChart{
// Notes: * Setting ClassName will cause all other inline styles to be dropped!
// * The following type classes may be added additionally: stroke, fill, text
Background: chart.Style{ClassName: "background"},
Canvas: chart.Style{
ClassName: "canvas",
},
Width: 512,
Height: 512,
Values: []chart.Value{
{Value: 5, Label: "Blue", Style: chart.Style{ClassName: "blue"}},
{Value: 5, Label: "Green", Style: chart.Style{ClassName: "green"}},
{Value: 4, Label: "Gray", Style: chart.Style{ClassName: "gray"}},
},
}
err := pie.Render(chart.SVG, res)
if err != nil {
fmt.Printf("Error rendering pie chart: %v\n", err)
}
res.Write([]byte("</body>"))
}
func css(res http.ResponseWriter, req *http.Request) {
res.Header().Set("Content-Type", "text/css")
res.Write([]byte("svg .background { fill: white; }" +
"svg .canvas { fill: white; }" +
"svg .blue.fill.stroke { fill: blue; stroke: lightblue; }" +
"svg .green.fill.stroke { fill: green; stroke: lightgreen; }" +
"svg .gray.fill.stroke { fill: gray; stroke: lightgray; }" +
"svg .blue.text { fill: white; }" +
"svg .green.text { fill: white; }" +
"svg .gray.text { fill: white; }"))
}
func main() {
http.HandleFunc("/", inlineSVGWithClasses)
http.HandleFunc("/main.css", css)
log.Fatal(http.ListenAndServe(":8080", nil))
}

View file

@ -0,0 +1,38 @@
package main
//go:generate go run main.go
import (
"fmt"
"os"
"github.com/wcharczuk/go-chart/v2"
)
func main() {
/*
In this example we use a custom `ValueFormatter` for the y axis, letting us specify how to format text of the y-axis ticks.
You can also do this for the x-axis, or the secondary y-axis.
This example also shows what the chart looks like with the x-axis left off or not shown.
*/
graph := chart.Chart{
YAxis: chart.YAxis{
ValueFormatter: func(v interface{}) string {
if vf, isFloat := v.(float64); isFloat {
return fmt.Sprintf("%0.6f", vf)
}
return ""
},
},
Series: []chart.Series{
chart.ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,34 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func main() {
graph := chart.Chart{
Background: chart.Style{
Padding: chart.Box{
Top: 50,
Left: 25,
Right: 25,
Bottom: 10,
},
FillColor: drawing.ColorFromHex("efefef"),
},
Series: []chart.Series{
chart.ContinuousSeries{
XValues: chart.Seq{Sequence: chart.NewLinearSequence().WithStart(1.0).WithEnd(100.0)}.Values(),
YValues: chart.Seq{Sequence: chart.NewRandomSequence().WithLen(100).WithMin(100).WithMax(512)}.Values(),
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View file

@ -0,0 +1,34 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
)
func main() {
/*
In this example we set a custom range for the y-axis, overriding the automatic range generation.
Note: the chart will still generate the ticks automatically based on the custom range, so the intervals may be a bit weird.
*/
graph := chart.Chart{
YAxis: chart.YAxis{
Range: &chart.ContinuousRange{
Min: 0.0,
Max: 10.0,
},
},
Series: []chart.Series{
chart.ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,38 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func main() {
/*
In this example we set some custom colors for the series and the chart background and canvas.
*/
graph := chart.Chart{
Background: chart.Style{
FillColor: drawing.ColorBlue,
},
Canvas: chart.Style{
FillColor: drawing.ColorFromHex("efefef"),
},
Series: []chart.Series{
chart.ContinuousSeries{
Style: chart.Style{
StrokeColor: drawing.ColorRed, // will supercede defaults
FillColor: drawing.ColorRed.WithAlpha(64), // will supercede defaults
},
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512">\n<style type="text/css"><![CDATA[svg .background { fill: white; }svg .canvas { fill: white; }svg path.blue { fill: blue; stroke: lightblue; }svg path.green { fill: green; stroke: lightgreen; }svg path.gray { fill: gray; stroke: lightgray; }svg text.blue { fill: white; }svg text.green { fill: white; }svg text.gray { fill: white; }]]></style><path d="M 0 0
L 512 0
L 512 512
L 0 512
L 0 0" class="background"/><path d="M 5 5
L 507 5
L 507 507
L 5 507
L 5 5" class="canvas"/><path d="M 256 256
L 507 256
A 251 251 128.56 0 1 100 452
L 256 256
Z" class="blue"/><path d="M 256 256
L 100 452
A 251 251 128.56 0 1 201 12
L 256 256
Z" class="green"/><path d="M 256 256
L 201 12
A 251 251 102.85 0 1 506 256
L 256 256
Z" class="gray"/><text x="313" y="413" class="blue">Blue</text><text x="73" y="226" class="green">Green</text><text x="344" y="133" class="gray">Gray</text></svg>

After

Width:  |  Height:  |  Size: 987 B

View file

@ -0,0 +1,88 @@
package main
import (
"fmt"
"log"
"net/http"
"github.com/wcharczuk/go-chart/v2"
)
const style = "svg .background { fill: white; }" +
"svg .canvas { fill: white; }" +
"svg .blue.fill.stroke { fill: blue; stroke: lightblue; }" +
"svg .green.fill.stroke { fill: green; stroke: lightgreen; }" +
"svg .gray.fill.stroke { fill: gray; stroke: lightgray; }" +
"svg .blue.text { fill: white; }" +
"svg .green.text { fill: white; }" +
"svg .gray.text { fill: white; }"
func svgWithCustomInlineCSS(res http.ResponseWriter, _ *http.Request) {
res.Header().Set("Content-Type", chart.ContentTypeSVG)
// Render the CSS with custom css
err := pieChart().Render(chart.SVGWithCSS(style, ""), res)
if err != nil {
fmt.Printf("Error rendering pie chart: %v\n", err)
}
}
func svgWithCustomInlineCSSNonce(res http.ResponseWriter, _ *http.Request) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/style-src
// This should be randomly generated on every request!
const nonce = "RAND0MBASE64"
res.Header().Set("Content-Security-Policy", fmt.Sprintf("style-src 'nonce-%s'", nonce))
res.Header().Set("Content-Type", chart.ContentTypeSVG)
// Render the CSS with custom css and a nonce.
// Try changing the nonce to a different string - your browser should block the CSS.
err := pieChart().Render(chart.SVGWithCSS(style, nonce), res)
if err != nil {
fmt.Printf("Error rendering pie chart: %v\n", err)
}
}
func svgWithCustomExternalCSS(res http.ResponseWriter, _ *http.Request) {
// Add external CSS
res.Write([]byte(
`<?xml version="1.0" standalone="no"?>` +
`<?xml-stylesheet href="/main.css" type="text/css"?>` +
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`))
res.Header().Set("Content-Type", chart.ContentTypeSVG)
err := pieChart().Render(chart.SVG, res)
if err != nil {
fmt.Printf("Error rendering pie chart: %v\n", err)
}
}
func pieChart() chart.PieChart {
return chart.PieChart{
// Note that setting ClassName will cause all other inline styles to be dropped!
Background: chart.Style{ClassName: "background"},
Canvas: chart.Style{
ClassName: "canvas",
},
Width: 512,
Height: 512,
Values: []chart.Value{
{Value: 5, Label: "Blue", Style: chart.Style{ClassName: "blue"}},
{Value: 5, Label: "Green", Style: chart.Style{ClassName: "green"}},
{Value: 4, Label: "Gray", Style: chart.Style{ClassName: "gray"}},
},
}
}
func css(res http.ResponseWriter, req *http.Request) {
res.Header().Set("Content-Type", "text/css")
res.Write([]byte(style))
}
func main() {
http.HandleFunc("/", svgWithCustomInlineCSS)
http.HandleFunc("/nonce", svgWithCustomInlineCSSNonce)
http.HandleFunc("/external", svgWithCustomExternalCSS)
http.HandleFunc("/main.css", css)
log.Fatal(http.ListenAndServe(":8080", nil))
}

View file

@ -0,0 +1,42 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
)
func main() {
/*
In this example we set a custom set of ticks to use for the y-axis. It can be (almost) whatever you want, including some custom labels for ticks.
Custom ticks will supercede a custom range, which will supercede automatic generation based on series values.
*/
graph := chart.Chart{
YAxis: chart.YAxis{
Range: &chart.ContinuousRange{
Min: 0.0,
Max: 4.0,
},
Ticks: []chart.Tick{
{Value: 0.0, Label: "0.00"},
{Value: 2.0, Label: "2.00"},
{Value: 4.0, Label: "4.00"},
{Value: 6.0, Label: "6.00"},
{Value: 8.0, Label: "Eight"},
{Value: 10.0, Label: "Ten"},
},
},
Series: []chart.Series{
chart.ContinuousSeries{
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,49 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
)
func main() {
/*
The below will draw the same chart as the `basic` example, except with both the x and y axes turned on.
In this case, both the x and y axis ticks are generated automatically, the x and y ranges are established automatically,
the canvas "box" is adjusted to fit the space the axes occupy so as not to clip.
Additionally, it shows how you can use the "Descending" property of continuous ranges to change the ordering of
how values (including ticks) are drawn.
*/
graph := chart.Chart{
Height: 500,
Width: 500,
XAxis: chart.XAxis{
/*Range: &chart.ContinuousRange{
Descending: true,
},*/
},
YAxis: chart.YAxis{
Range: &chart.ContinuousRange{
Descending: true,
},
},
Series: []chart.Series{
chart.ContinuousSeries{
Style: chart.Style{
StrokeColor: chart.GetDefaultColor(0).WithAlpha(64),
FillColor: chart.GetDefaultColor(0).WithAlpha(64),
},
XValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
YValues: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
},
},
}
f, _ := os.Create("output.png")
defer f.Close()
graph.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,28 @@
package main
//go:generate go run main.go
import (
"os"
"github.com/wcharczuk/go-chart/v2"
)
func main() {
pie := chart.DonutChart{
Width: 512,
Height: 512,
Values: []chart.Value{
{Value: 5, Label: "Blue"},
{Value: 5, Label: "Green"},
{Value: 4, Label: "Gray"},
{Value: 4, Label: "Orange"},
{Value: 3, Label: "Deep Blue"},
{Value: 3, Label: "test"},
},
}
f, _ := os.Create("output.png")
defer f.Close()
pie.Render(chart.PNG, f)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512">\n<path d="M 0 0
L 512 0
L 512 512
L 0 512
L 0 0" style="stroke-width:0;stroke:rgba(255,255,255,1.0);fill:rgba(255,255,255,1.0)"/><path d="M 5 5
L 507 5
L 507 507
L 5 507
L 5 5" style="stroke-width:0;stroke:rgba(255,255,255,1.0);fill:rgba(255,255,255,1.0)"/><path d="M 256 256
L 438 256
A 182 182 225.00 1 1 127 127
L 256 256
Z" style="stroke-width:4;stroke:rgba(255,255,255,1.0);fill:rgba(106,195,203,1.0)"/><path d="M 256 256
L 127 127
A 182 182 90.00 0 1 385 127
L 256 256
Z" style="stroke-width:4;stroke:rgba(255,255,255,1.0);fill:rgba(42,190,137,1.0)"/><path d="M 256 256
L 385 127
A 182 182 45.00 0 1 438 256
L 256 256
Z" style="stroke-width:4;stroke:rgba(255,255,255,1.0);fill:rgba(110,128,139,1.0)"/><path d="M 256 256
L 321 256
A 65 65 359.00 1 1 321 255
L 256 256
Z" style="stroke-width:4;stroke:rgba(255,255,255,1.0);fill:rgba(255,255,255,1.0)"/><text x="159" y="461" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">Blue</text><text x="241" y="48" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">Two</text><text x="440" y="181" style="stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:15.3px;font-family:'Roboto Medium',sans-serif">One</text></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,222 @@
package main
import (
"os"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
func main() {
chart.DefaultBackgroundColor = chart.ColorTransparent
chart.DefaultCanvasColor = chart.ColorTransparent
barWidth := 80
var (
colorWhite = drawing.Color{R: 241, G: 241, B: 241, A: 255}
colorMariner = drawing.Color{R: 60, G: 100, B: 148, A: 255}
colorLightSteelBlue = drawing.Color{R: 182, G: 195, B: 220, A: 255}
colorPoloBlue = drawing.Color{R: 126, G: 155, B: 200, A: 255}
colorSteelBlue = drawing.Color{R: 73, G: 120, B: 177, A: 255}
)
stackedBarChart := chart.StackedBarChart{
Title: "Quarterly Sales",
TitleStyle: chart.Shown(),
Background: chart.Style{
Padding: chart.Box{
Top: 75,
},
},
Width: 800,
Height: 600,
XAxis: chart.Shown(),
YAxis: chart.Shown(),
BarSpacing: 40,
IsHorizontal: true,
Bars: []chart.StackedBar{
{
Name: "Q1",
Width: barWidth,
Values: []chart.Value{
{
Label: "32K",
Value: 32,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorMariner,
FontColor: colorWhite,
},
},
{
Label: "46K",
Value: 46,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorLightSteelBlue,
FontColor: colorWhite,
},
},
{
Label: "48K",
Value: 48,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorPoloBlue,
FontColor: colorWhite,
},
},
{
Label: "42K",
Value: 42,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorSteelBlue,
FontColor: colorWhite,
},
},
},
},
{
Name: "Q2",
Width: barWidth,
Values: []chart.Value{
{
Label: "45K",
Value: 45,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorMariner,
FontColor: colorWhite,
},
},
{
Label: "60K",
Value: 60,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorLightSteelBlue,
FontColor: colorWhite,
},
},
{
Label: "62K",
Value: 62,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorPoloBlue,
FontColor: colorWhite,
},
},
{
Label: "53K",
Value: 53,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorSteelBlue,
FontColor: colorWhite,
},
},
},
},
{
Name: "Q3",
Width: barWidth,
Values: []chart.Value{
{
Label: "54K",
Value: 54,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorMariner,
FontColor: colorWhite,
},
},
{
Label: "58K",
Value: 58,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorLightSteelBlue,
FontColor: colorWhite,
},
},
{
Label: "55K",
Value: 55,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorPoloBlue,
FontColor: colorWhite,
},
},
{
Label: "47K",
Value: 47,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorSteelBlue,
FontColor: colorWhite,
},
},
},
},
{
Name: "Q4",
Width: barWidth,
Values: []chart.Value{
{
Label: "46K",
Value: 46,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorMariner,
FontColor: colorWhite,
},
},
{
Label: "70K",
Value: 70,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorLightSteelBlue,
FontColor: colorWhite,
},
},
{
Label: "74K",
Value: 74,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorPoloBlue,
FontColor: colorWhite,
},
},
{
Label: "60K",
Value: 60,
Style: chart.Style{
StrokeWidth: .01,
FillColor: colorSteelBlue,
FontColor: colorWhite,
},
},
},
},
},
}
pngFile, err := os.Create("output.png")
if err != nil {
panic(err)
}
if err := stackedBarChart.Render(chart.PNG, pngFile); err != nil {
panic(err)
}
if err := pngFile.Close(); err != nil {
panic(err)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Some files were not shown because too many files have changed in this diff Show more