├── .gitignore
├── LICENSE
├── README.md
├── axes.go
├── brackets
├── brackets.go
└── brackets_test.go
├── example
├── bars.go
├── bars.svg
├── example.go
├── example.svg
├── minimal.go
└── minimal.svg
├── go.mod
├── margaid.go
├── plots.go
├── series.go
├── series_test.go
├── svg
└── svg.go
├── tickers.go
├── tickers_test.go
└── xt
└── testing.go
/.gitignore:
--------------------------------------------------------------------------------
1 | junk
2 |
3 | # IDE files
4 | .idea
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | ISC License
2 |
3 | Copyright (c) 2020, Erik Agsjö
4 |
5 | Permission to use, copy, modify, and/or distribute this software for any
6 | purpose with or without fee is hereby granted, provided that the above
7 | copyright notice and this permission notice appear in all copies.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Margaid ⇄ diagraM
2 |
3 | The world surely doesn't need another plotting library.
4 | But I did, and that's why Margaid was born.
5 |
6 | Margaid is a small, no dependencies Golang library for plotting 2D data to SVG images. The idea is to create nice charts with a few lines of code and not having to bring in heavy machinery.
7 |
8 | "Margaid" is an old name meaning "pearl", which seemed fitting for something shiny and small.
9 | It's also the word "diagraM" spelled backwards.
10 |
11 | ## Features
12 |
13 | Margaid plots series of data to an SVG image. Series can be capped by size or time to simplify realtime data collection.
14 |
15 | Plots are drawn using straight lines, smooth lines or bars.
16 |
17 | Each axis has a fixed or automatic range, linear or log projection, configurable labels and optional grid lines.
18 |
19 | Plot colors are automatically picked for each new plot, trying to spread them in hue and saturation to get a good mix.
20 |
21 | There is no clever layout or layering going on. Each new command draws on top of the results from previous commands.
22 |
23 | ## Getting started
24 |
25 | ### Minimal example
26 |
27 | 
28 |
29 | These are the minimal steps needed to create a Margaid plot:
30 | * Import the library
31 | ```go
32 | import "github.com/erkkah/margaid"
33 | ```
34 | * Create a series and add some values
35 | ```go
36 | series := margaid.NewSeries()
37 | series.Add(margaid.MakeValue(10, 3.14), margaid.MakeValue(90, 93.8))
38 | // et.c.
39 | ```
40 |
41 | * Create the diagram:
42 | ```go
43 | diagram := margaid.New(800, 600)
44 | ```
45 |
46 | * Plot the series
47 | ```go
48 | diagram.Line(series)
49 | ```
50 |
51 | * Add a frame and X axis
52 | ```go
53 | diagram.Frame()
54 | diagram.Axis(series, margaid.XAxis, diagram.ValueTicker('f', 2, 10), false, "Values")
55 | ```
56 |
57 | * Render to stdout
58 | ```go
59 | diagram.Render(os.Stdout)
60 | ```
61 |
62 | ### Example showing more features
63 |
64 | 
65 |
66 | To generate the diagram above from the code shown below:
67 | ```sh
68 | > go run -tags example ./example > example.svg
69 | ```
70 |
71 | ```go
72 | // example/example.go
73 | package main
74 |
75 | import (
76 | "math/rand"
77 | "os"
78 | "time"
79 |
80 | m "github.com/erkkah/margaid"
81 | )
82 |
83 | func main() {
84 |
85 | randomSeries := m.NewSeries()
86 | rand.Seed(time.Now().Unix())
87 | for i := float64(0); i < 10; i++ {
88 | randomSeries.Add(m.MakeValue(i+1, 200*rand.Float64()))
89 | }
90 |
91 | testSeries := m.NewSeries()
92 | multiplier := 2.1
93 | v := 0.33
94 | for i := float64(0); i < 10; i++ {
95 | v *= multiplier
96 | testSeries.Add(m.MakeValue(i+1, v))
97 | }
98 |
99 | diagram := m.New(800, 600,
100 | m.WithAutorange(m.XAxis, testSeries),
101 | m.WithAutorange(m.YAxis, testSeries),
102 | m.WithAutorange(m.Y2Axis, testSeries),
103 | m.WithProjection(m.YAxis, m.Log),
104 | m.WithInset(70),
105 | m.WithPadding(2),
106 | m.WithColorScheme(90),
107 | )
108 |
109 | diagram.Line(testSeries, m.UsingAxes(m.XAxis, m.YAxis), m.UsingMarker("square"), m.UsingStrokeWidth(1))
110 | diagram.Smooth(testSeries, m.UsingAxes(m.XAxis, m.Y2Axis), m.UsingStrokeWidth(3.14))
111 | diagram.Smooth(randomSeries, m.UsingAxes(m.XAxis, m.YAxis), m.UsingMarker("filled-circle"))
112 | diagram.Axis(testSeries, m.XAxis, diagram.ValueTicker('f', 0, 10), false, "X")
113 | diagram.Axis(testSeries, m.YAxis, diagram.ValueTicker('f', 1, 2), true, "Y")
114 |
115 | diagram.Frame()
116 | diagram.Title("A diagram of sorts 📊 📈")
117 |
118 | diagram.Render(os.Stdout)
119 | }
120 | ```
121 |
122 | ## Documentation
123 | For more details, check the [reference documentation](https://pkg.go.dev/github.com/erkkah/margaid).
124 |
--------------------------------------------------------------------------------
/axes.go:
--------------------------------------------------------------------------------
1 | package margaid
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/erkkah/margaid/svg"
7 | )
8 |
9 | // Axis is the type for all axis constants
10 | type Axis int
11 |
12 | // Axis constants
13 | const (
14 | XAxis Axis = iota + 'x'
15 | YAxis
16 | X2Axis
17 | Y2Axis
18 |
19 | X1Axis = XAxis
20 | Y1Axis = YAxis
21 | )
22 |
23 | // Axis draws tick marks and labels using the specified ticker
24 | func (m *Margaid) Axis(series *Series, axis Axis, ticker Ticker, grid bool, title string) {
25 | var xOffset = m.inset
26 | var yOffset = m.inset
27 | var axisLength float64
28 | var crossLength float64
29 | var xMult float64
30 | var yMult float64
31 | var tickSign float64 = 1
32 | var vAlignment svg.VAlignment
33 | var hAlignment svg.HAlignment
34 | var axisLabelRotation = 0.0
35 | var axisLabelAlignment svg.VAlignment
36 | var axisLabelSign float64 = 1
37 |
38 | max := m.ranges[axis].max
39 |
40 | xAttributes := func() {
41 | axisLength = m.width - 2*m.inset
42 | crossLength = m.height - 2*m.inset
43 | xMult = 1
44 | hAlignment = svg.HAlignMiddle
45 | }
46 |
47 | yAttributes := func() {
48 | yOffset = m.height - m.inset
49 | axisLength = m.height - 2*m.inset
50 | crossLength = m.width - 2*m.inset
51 | yMult = 1
52 | vAlignment = svg.VAlignCentral
53 | axisLabelRotation = -90.0
54 | }
55 |
56 | switch axis {
57 | case X1Axis:
58 | xAttributes()
59 | yOffset = m.height - m.inset
60 | tickSign = -1
61 | vAlignment = svg.VAlignTop
62 | axisLabelAlignment = svg.VAlignBottom
63 | axisLabelSign = -1
64 | case X2Axis:
65 | xAttributes()
66 | vAlignment = svg.VAlignBottom
67 | axisLabelAlignment = svg.VAlignTop
68 | case Y1Axis:
69 | yAttributes()
70 | tickSign = -1
71 | hAlignment = svg.HAlignEnd
72 | axisLabelAlignment = svg.VAlignTop
73 | case Y2Axis:
74 | yAttributes()
75 | xOffset = m.width - m.inset
76 | hAlignment = svg.HAlignStart
77 | axisLabelAlignment = svg.VAlignBottom
78 | axisLabelSign = -1
79 | }
80 |
81 | steps := axisLength / tickDistance
82 | start := ticker.start(axis, series, int(steps))
83 |
84 | m.g.Transform(
85 | svg.Translation(xOffset, yOffset),
86 | svg.Scaling(1, -1),
87 | ).
88 | StrokeWidth("2px").
89 | Stroke("black")
90 |
91 | var tick float64
92 | var hasMore = true
93 |
94 | for tick = start; tick <= max && hasMore; tick, hasMore = ticker.next(tick) {
95 | value, err := m.project(tick, axis)
96 |
97 | if err == nil {
98 | m.g.Polyline([]struct{ X, Y float64 }{
99 | {value * xMult, value * yMult},
100 | {value*xMult + tickSign*tickSize*(yMult), value*yMult + tickSign*(xMult)*tickSize},
101 | }...)
102 | }
103 | }
104 |
105 | m.g.Transform(
106 | svg.Translation(xOffset, yOffset),
107 | svg.Scaling(1, 1),
108 | ).
109 | Font(m.labelFamily, fmt.Sprintf("%dpx", m.labelSize)).
110 | FontStyle(svg.StyleNormal, svg.WeightNormal).
111 | Alignment(hAlignment, vAlignment).
112 | Fill("black")
113 |
114 | lastLabel := -m.inset
115 | textOffset := float64(tickSize + textSpacing)
116 | hasMore = true
117 |
118 | for tick = start; tick <= max && hasMore; tick, hasMore = ticker.next(tick) {
119 | value, err := m.project(tick, axis)
120 |
121 | if err == nil {
122 | if value-lastLabel > float64(m.labelSize) {
123 | m.g.Text(
124 | value*xMult+(tickSign)*textOffset*(yMult),
125 | -value*yMult+(-tickSign)*textOffset*(xMult),
126 | ticker.label(tick))
127 | lastLabel = value
128 | }
129 | }
130 | }
131 |
132 | if title != "" {
133 | m.g.Transform(
134 | svg.Translation(xOffset, yOffset),
135 | svg.Scaling(1, 1),
136 | svg.Rotation(axisLabelRotation, 0, 0),
137 | ).
138 | Font(m.labelFamily, fmt.Sprintf("%dpx", m.labelSize)).
139 | FontStyle(svg.StyleNormal, svg.WeightBold).
140 | Alignment(svg.HAlignMiddle, axisLabelAlignment).
141 | Fill("black")
142 |
143 | x := axisLength / 2
144 | y := float64(tickSize * axisLabelSign)
145 | m.g.Text(
146 | x, y, title,
147 | )
148 | }
149 |
150 | if grid {
151 | m.g.Transform(
152 | svg.Translation(xOffset, yOffset),
153 | svg.Scaling(1, -1),
154 | ).
155 | StrokeWidth("0.5px").Stroke("gray")
156 |
157 | hasMore = true
158 |
159 | for tick = start; tick <= max && hasMore; tick, hasMore = ticker.next(tick) {
160 | value, err := m.project(tick, axis)
161 |
162 | if err == nil {
163 | m.g.Polyline([]struct{ X, Y float64 }{
164 | {value * xMult, value * yMult},
165 | {value*xMult - tickSign*crossLength*(yMult), value*yMult - tickSign*(xMult)*crossLength},
166 | }...)
167 | }
168 | }
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/brackets/brackets.go:
--------------------------------------------------------------------------------
1 | package brackets
2 |
3 | import (
4 | "bytes"
5 | "container/list"
6 | "encoding/xml"
7 | "fmt"
8 | "strings"
9 | )
10 |
11 | // Brackets is a simple xml-ish structure builder
12 | type Brackets struct {
13 | elementStack *list.List
14 | elements *list.List
15 | }
16 |
17 | // New - Brackets constructor
18 | func New() *Brackets {
19 | return &Brackets{
20 | elementStack: list.New(),
21 | elements: list.New(),
22 | }
23 | }
24 |
25 | // Attributes represents bracket attributes
26 | type Attributes map[string]string
27 |
28 | // Clone creates a deep copy of a map
29 | func (am Attributes) Clone() Attributes {
30 | clone := Attributes{}
31 | for k, v := range am {
32 | clone[k] = v
33 | }
34 | return clone
35 | }
36 |
37 | func (am Attributes) String() string {
38 | attributes := []string{}
39 |
40 | for k, v := range am {
41 | attributes = append(attributes, fmt.Sprintf("%s=%q", k, v))
42 | }
43 |
44 | return strings.Join(attributes, " ")
45 | }
46 |
47 | // Size calculates the total size in bytes of all keys + values
48 | func (am Attributes) Size() (size int) {
49 | for k, v := range am {
50 | size += len([]byte(k)) + len([]byte(v))
51 | }
52 | return
53 | }
54 |
55 | type elementKind int
56 |
57 | const (
58 | openingKind elementKind = iota
59 | closingKind
60 | selfClosingKind
61 | textKind
62 | )
63 |
64 | // Element represents an xml element
65 | type Element struct {
66 | name string
67 | attributes Attributes
68 | hasChildren bool
69 | kind elementKind
70 | }
71 |
72 | func (e *Element) String() string {
73 | if e.kind == textKind {
74 | return e.name
75 | }
76 |
77 | var builder strings.Builder
78 | builder.WriteRune('<')
79 | if e.kind == closingKind {
80 | builder.WriteRune('/')
81 | }
82 | builder.WriteString(e.name)
83 |
84 | if e.kind == selfClosingKind || e.kind == openingKind {
85 | if len(e.attributes) != 0 {
86 | builder.WriteRune(' ')
87 | builder.WriteString(e.attributes.String())
88 | }
89 | if e.kind == selfClosingKind {
90 | builder.WriteRune('/')
91 | }
92 | }
93 | builder.WriteRune('>')
94 |
95 | return builder.String()
96 | }
97 |
98 | // Attributes returns a copy of the element attributes
99 | func (e *Element) Attributes() Attributes {
100 | return e.attributes.Clone()
101 | }
102 |
103 | // Name returns the element name
104 | func (e *Element) Name() string {
105 | return e.name
106 | }
107 |
108 | // SetAttribute resets an attribute to a new value
109 | func (e *Element) SetAttribute(key, value string) {
110 | e.attributes[key] = value
111 | }
112 |
113 | func (b *Brackets) topElement() *Element {
114 | if b.elementStack.Len() != 0 {
115 | top := b.elementStack.Back()
116 | return top.Value.(*Element)
117 | }
118 | return nil
119 | }
120 |
121 | func (b *Brackets) popElement() *Element {
122 | top := b.elementStack.Back()
123 | b.elementStack.Remove(top)
124 | return top.Value.(*Element)
125 | }
126 |
127 | // First returns the first element of the underlying list of elements.
128 | func (b *Brackets) First() *Element {
129 | return b.elements.Front().Value.(*Element)
130 | }
131 |
132 | // Open adds a new opening element with optional attributes.
133 | func (b *Brackets) Open(name string, attrs ...Attributes) *Brackets {
134 | if top := b.topElement(); top != nil {
135 | top.hasChildren = true
136 | }
137 | newElement := &Element{
138 | name: name,
139 | kind: openingKind,
140 | }
141 | if len(attrs) > 0 {
142 | newElement.attributes = attrs[0].Clone()
143 | }
144 | b.elementStack.PushBack(newElement)
145 | b.elements.PushBack(newElement)
146 | return b
147 | }
148 |
149 | // Add adds a self-closing element
150 | func (b *Brackets) Add(name string, attrs ...Attributes) *Brackets {
151 | b.Open(name, attrs...)
152 | b.Close()
153 | return b
154 | }
155 |
156 | // Text adds text content to the current element.
157 | // Note that the text is not automatically XML-escaped.
158 | // Use the XMLEscape function if that is needed.
159 | func (b *Brackets) Text(txt string) *Brackets {
160 | if top := b.topElement(); top != nil {
161 | top.hasChildren = true
162 | }
163 | newElement := &Element{
164 | name: txt,
165 | kind: textKind,
166 | }
167 | b.elements.PushBack(newElement)
168 | return b
169 | }
170 |
171 | // Close closes the current element. If there are no
172 | // children (elements or text), the current element will
173 | // be self-closed. Otherwise, a matching close-element
174 | // will be added.
175 | func (b *Brackets) Close() *Brackets {
176 | top := b.popElement()
177 | if top.hasChildren {
178 | b.elements.PushBack(&Element{
179 | name: top.name,
180 | kind: closingKind,
181 | })
182 | } else {
183 | top.kind = selfClosingKind
184 | }
185 | return b
186 | }
187 |
188 | // CloseAll closes all elements to get a complete, matched
189 | // structure.
190 | func (b *Brackets) CloseAll() *Brackets {
191 | for b.elementStack.Len() > 0 {
192 | b.Close()
193 | }
194 | return b
195 | }
196 |
197 | // Current returns a pointer to the currently open element, or
198 | // nil if there is none.
199 | func (b *Brackets) Current() *Element {
200 | return b.topElement()
201 | }
202 |
203 | // Last returns a pointer to the most recently added element, or
204 | // nil if there is none.
205 | func (b *Brackets) Last() *Element {
206 | if b.elements.Len() != 0 {
207 | last := b.elements.Back()
208 | return last.Value.(*Element)
209 | }
210 | return nil
211 | }
212 |
213 | // Append adds all elements from another Brackets instance
214 | func (b *Brackets) Append(other *Brackets) *Brackets {
215 | b.elements.PushBackList(other.elements)
216 | return b
217 | }
218 |
219 | func (b *Brackets) String() string {
220 | var builder strings.Builder
221 |
222 | for e := b.elements.Front(); e != nil; e = e.Next() {
223 | builder.WriteString(e.Value.(*Element).String())
224 | }
225 |
226 | return builder.String()
227 | }
228 |
229 | // XMLEscape returns properly escaped XML equivalent of the provided string
230 | func XMLEscape(s string) string {
231 | var buf bytes.Buffer
232 | xml.EscapeText(&buf, []byte(s))
233 | return buf.String()
234 | }
235 |
--------------------------------------------------------------------------------
/brackets/brackets_test.go:
--------------------------------------------------------------------------------
1 | package brackets
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/erkkah/margaid/xt"
7 | )
8 |
9 | func TestBracketConstruction(t *testing.T) {
10 | x := xt.X(t)
11 | b := New()
12 | x.Equal(b.String(), "", "Should be empty string")
13 | }
14 |
15 | func TestEmptySelfClosingBracket(t *testing.T) {
16 | x := xt.X(t)
17 | b := New()
18 | b.Add("tag")
19 | x.Equal(b.String(), "")
20 | }
21 |
22 | func TestSelfClosingBracket(t *testing.T) {
23 | x := xt.X(t)
24 | b := New()
25 | b.Add("tag", Attributes{
26 | "size": "22",
27 | })
28 | x.Equal(b.String(), ``)
29 | }
30 |
31 | func TestOpenCloseBracket(t *testing.T) {
32 | x := xt.X(t)
33 | b := New()
34 | b.Open("tag")
35 | b.Close()
36 | x.Equal(b.String(), "")
37 | }
38 |
39 | func TestNestedBrackets(t *testing.T) {
40 | x := xt.X(t)
41 | b := New()
42 | b.Open("head", Attributes{
43 | "alpha": "beta",
44 | })
45 | b.Open("title", Attributes{
46 | "text": "Hej",
47 | })
48 | b.CloseAll()
49 | x.Equal(b.String(), `
`)
50 | }
51 |
--------------------------------------------------------------------------------
/example/bars.go:
--------------------------------------------------------------------------------
1 | //go:build bars
2 | // +build bars
3 |
4 | package main
5 |
6 | import (
7 | "os"
8 | "strconv"
9 |
10 | "github.com/erkkah/margaid"
11 | )
12 |
13 | func main() {
14 | // Create a series object and add some values
15 | seriesA := margaid.NewSeries(margaid.Titled("Team A"))
16 | seriesA.Add(
17 | margaid.MakeValue(10, 3.14),
18 | margaid.MakeValue(34, 12),
19 | margaid.MakeValue(90, 93.8),
20 | )
21 |
22 | seriesB := margaid.NewSeries(margaid.Titled("Team B"))
23 | seriesB.Add(
24 | margaid.MakeValue(10, 0.62),
25 | margaid.MakeValue(34, 43),
26 | margaid.MakeValue(90, 88.1),
27 | )
28 |
29 | labeler := func(value float64) string {
30 | return strconv.FormatFloat(value, 'f', 1, 64)
31 | }
32 |
33 | // Create the diagram object,
34 | // add some padding for the bars and extra inset for the legend
35 | diagram := margaid.New(800, 600, margaid.WithPadding(10), margaid.WithInset(80), margaid.WithBackgroundColor("white"))
36 |
37 | // Plot the series
38 | diagram.Bar([]*margaid.Series{seriesA, seriesB})
39 |
40 | // Add a legend
41 | diagram.Legend(margaid.RightBottom)
42 |
43 | // Add a frame and X axis
44 | diagram.Frame()
45 | diagram.Axis(seriesA, margaid.XAxis, diagram.LabeledTicker(labeler), false, "Lemmings")
46 | diagram.Axis(seriesA, margaid.Y2Axis, diagram.LabeledTicker(labeler), true, "")
47 | diagram.Axis(seriesB, margaid.YAxis, diagram.LabeledTicker(labeler), true, "")
48 |
49 | // Render to stdout
50 | diagram.Render(os.Stdout)
51 | }
52 |
--------------------------------------------------------------------------------
/example/bars.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/example.go:
--------------------------------------------------------------------------------
1 | //go:build example
2 | // +build example
3 |
4 | package main
5 |
6 | import (
7 | "math/rand"
8 | "os"
9 | "time"
10 |
11 | m "github.com/erkkah/margaid"
12 | )
13 |
14 | func main() {
15 |
16 | randomSeries := m.NewSeries()
17 | rand.Seed(time.Now().Unix())
18 | for i := float64(0); i < 10; i++ {
19 | randomSeries.Add(m.MakeValue(i+1, 1000*rand.Float64()))
20 | }
21 |
22 | testSeries := m.NewSeries()
23 | multiplier := 2.1
24 | v := 0.33
25 | for i := float64(0); i < 10; i++ {
26 | v *= multiplier
27 | testSeries.Add(m.MakeValue(i+1, v))
28 | }
29 |
30 | diagram := m.New(800, 600,
31 | m.WithAutorange(m.XAxis, testSeries),
32 | m.WithAutorange(m.YAxis, testSeries, randomSeries),
33 | m.WithAutorange(m.Y2Axis, testSeries),
34 | m.WithProjection(m.YAxis, m.Log),
35 | m.WithInset(70),
36 | m.WithPadding(2),
37 | m.WithColorScheme(90),
38 | m.WithBackgroundColor("white"),
39 | )
40 |
41 | diagram.Line(testSeries, m.UsingAxes(m.XAxis, m.YAxis), m.UsingMarker("square"), m.UsingStrokeWidth(1))
42 | diagram.Smooth(testSeries, m.UsingAxes(m.XAxis, m.Y2Axis), m.UsingStrokeWidth(3.14))
43 | diagram.Smooth(randomSeries, m.UsingAxes(m.XAxis, m.YAxis), m.UsingMarker("filled-circle"))
44 | diagram.Axis(testSeries, m.XAxis, diagram.ValueTicker('f', 0, 10), false, "X")
45 | diagram.Axis(testSeries, m.YAxis, diagram.ValueTicker('f', 1, 2), true, "Y")
46 |
47 | diagram.Frame()
48 | diagram.Title("A diagram of sorts 📊 📈")
49 |
50 | diagram.Render(os.Stdout)
51 | }
52 |
--------------------------------------------------------------------------------
/example/example.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/minimal.go:
--------------------------------------------------------------------------------
1 | //go:build minimal
2 | // +build minimal
3 |
4 | package main
5 |
6 | import (
7 | "os"
8 |
9 | "github.com/erkkah/margaid"
10 | )
11 |
12 | func main() {
13 | // Create a series object and add some values
14 | series := margaid.NewSeries()
15 | series.Add(margaid.MakeValue(10, 3.14), margaid.MakeValue(90, 93.8))
16 |
17 | // Create the diagram object:
18 | diagram := margaid.New(800, 600, margaid.WithBackgroundColor("white"))
19 |
20 | // Plot the series
21 | diagram.Line(series)
22 |
23 | // Add a frame and X axis
24 | diagram.Frame()
25 | diagram.Axis(series, margaid.XAxis, diagram.ValueTicker('f', 2, 10), false, "Values")
26 |
27 | // Render to stdout
28 | diagram.Render(os.Stdout)
29 | }
30 |
--------------------------------------------------------------------------------
/example/minimal.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/erkkah/margaid
2 |
3 | go 1.13
4 |
--------------------------------------------------------------------------------
/margaid.go:
--------------------------------------------------------------------------------
1 | package margaid
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "math"
7 |
8 | "github.com/erkkah/margaid/brackets"
9 | "github.com/erkkah/margaid/svg"
10 | )
11 |
12 | // Margaid == diagraM
13 | type Margaid struct {
14 | g *svg.SVG
15 |
16 | width float64
17 | height float64
18 | inset float64
19 | padding float64 // padding [0..1]
20 |
21 | projections map[Axis]Projection
22 | ranges map[Axis]minmax
23 |
24 | plots []string
25 | background string
26 | colorScheme int
27 |
28 | titleFamily string
29 | titleSize int
30 | labelFamily string
31 | labelSize int
32 | }
33 |
34 | const (
35 | defaultPadding = 0
36 | defaultInset = 64
37 | tickDistance = 55
38 | tickSize = 6
39 | textSpacing = 4
40 | )
41 |
42 | // minmax is the range [min, max] of a chart axis
43 | type minmax struct{ min, max float64 }
44 |
45 | // Option is the base type for all series options
46 | type Option func(*Margaid)
47 |
48 | // New - Margaid constructor
49 | func New(width, height int, options ...Option) *Margaid {
50 | defaultRange := minmax{0, 100}
51 |
52 | self := &Margaid{
53 | inset: defaultInset,
54 | width: float64(width),
55 | height: float64(height),
56 | padding: defaultPadding,
57 |
58 | projections: map[Axis]Projection{
59 | X1Axis: Lin,
60 | X2Axis: Lin,
61 | Y1Axis: Lin,
62 | Y2Axis: Lin,
63 | },
64 |
65 | ranges: map[Axis]minmax{
66 | X1Axis: defaultRange,
67 | Y1Axis: defaultRange,
68 | X2Axis: defaultRange,
69 | Y2Axis: defaultRange,
70 | },
71 |
72 | background: "transparent",
73 | colorScheme: 198,
74 | titleFamily: "sans-serif",
75 | titleSize: 18,
76 | labelFamily: "sans-serif",
77 | labelSize: 12,
78 | }
79 |
80 | for _, o := range options {
81 | o(self)
82 | }
83 |
84 | self.g = svg.New(width, height, self.background)
85 |
86 | return self
87 | }
88 |
89 | /// Options
90 |
91 | // Projection is the type for the projection constants
92 | type Projection int
93 |
94 | // Projection constants
95 | const (
96 | Lin Projection = iota + 'p'
97 | Log
98 | )
99 |
100 | // WithProjection sets the projection for a given axis
101 | func WithProjection(axis Axis, proj Projection) Option {
102 | return func(m *Margaid) {
103 | m.projections[axis] = proj
104 | }
105 | }
106 |
107 | // WithRange sets a fixed plotting range for a given axis
108 | func WithRange(axis Axis, min, max float64) Option {
109 | return func(m *Margaid) {
110 | m.ranges[axis] = minmax{min, max}
111 | }
112 | }
113 |
114 | // WithAutorange sets range for an axis from the values of one or more series
115 | func WithAutorange(axis Axis, series ...*Series) Option {
116 | return func(m *Margaid) {
117 | var axisRange minmax
118 |
119 | for idx, s := range series {
120 | var newAxisRange minmax
121 | if axis == X1Axis || axis == X2Axis {
122 | newAxisRange = minmax{
123 | s.MinX(),
124 | s.MaxX(),
125 | }
126 | }
127 | if axis == Y1Axis || axis == Y2Axis {
128 | newAxisRange = minmax{
129 | s.MinY(),
130 | s.MaxY(),
131 | }
132 | }
133 | if idx == 0 {
134 | axisRange = newAxisRange
135 | } else {
136 | axisRange = minmax{
137 | math.Min(axisRange.min, newAxisRange.min),
138 | math.Max(axisRange.max, newAxisRange.max),
139 | }
140 | }
141 | }
142 |
143 | if axisRange.min == axisRange.max {
144 | axisRange.min -= 1.0
145 | axisRange.max += 1.0
146 | }
147 |
148 | m.ranges[axis] = axisRange
149 | }
150 | }
151 |
152 | // WithInset sets the distance between the chart boundaries and the
153 | // charting area.
154 | func WithInset(inset float64) Option {
155 | return func(m *Margaid) {
156 | m.inset = inset
157 | }
158 | }
159 |
160 | // WithPadding sets the padding inside the plotting area as a percentage
161 | // [0..20] of the area width and height
162 | func WithPadding(padding float64) Option {
163 | return func(m *Margaid) {
164 | factor := padding / 100
165 | m.padding = math.Max(0, math.Min(0.20, factor))
166 | }
167 | }
168 |
169 | // WithBackgroundColor sets the chart background color as a valid SVG
170 | // color attribute string. Default is transparent.
171 | func WithBackgroundColor(background string) Option {
172 | return func(m *Margaid) {
173 | m.background = background
174 | }
175 | }
176 |
177 | // WithColorScheme sets the start color for selecting plot colors.
178 | // The start color is selected as a hue value between 0 and 359.
179 | func WithColorScheme(scheme int) Option {
180 | return func(m *Margaid) {
181 | m.colorScheme = scheme % 360
182 | }
183 | }
184 |
185 | // WithTitleFont sets title font family and size in pixels
186 | func WithTitleFont(family string, size int) Option {
187 | return func(m *Margaid) {
188 | m.titleFamily = family
189 | m.titleSize = size
190 | }
191 | }
192 |
193 | // WithLabelFont sets label font family and size in pixels
194 | func WithLabelFont(family string, size int) Option {
195 | return func(m *Margaid) {
196 | m.labelFamily = family
197 | m.labelSize = size
198 | }
199 | }
200 |
201 | /// Drawing
202 |
203 | // Title draws a title top center
204 | func (m *Margaid) Title(title string) {
205 | encoded := svg.EncodeText(title, svg.HAlignMiddle)
206 | m.g.
207 | Font(m.titleFamily, fmt.Sprintf("%dpx", m.titleSize)).
208 | FontStyle(svg.StyleNormal, svg.WeightBold).
209 | Alignment(svg.HAlignMiddle, svg.VAlignCentral).
210 | Transform().
211 | Fill("black").
212 | Text(m.width/2, m.inset/2, encoded)
213 | }
214 |
215 | // LegendPosition decides where to draw the legend
216 | type LegendPosition int
217 |
218 | // LegendPosition constants
219 | const (
220 | RightTop LegendPosition = iota + 'l'
221 | RightBottom
222 | BottomLeft
223 | )
224 |
225 | // Legend draws a legend for named plots. If position is set to BottomLeft, it
226 | // will grow the plot size to accommodate the number of legends displayed.
227 | func (m *Margaid) Legend(position LegendPosition) {
228 | type namedPlot struct {
229 | name string
230 | color string
231 | }
232 |
233 | var plots []namedPlot
234 |
235 | for i, label := range m.plots {
236 | if label != "" {
237 | color := m.getPlotColor(i)
238 | plots = append(plots, namedPlot{
239 | name: label,
240 | color: color,
241 | })
242 | }
243 | }
244 |
245 | boxSize := float64(m.labelSize)
246 | lineHeight := float64(m.labelSize) * 1.5
247 |
248 | listStartX := 0.0
249 | listStartY := 0.0
250 |
251 | switch position {
252 | case RightTop:
253 | listStartX = m.width - m.inset + boxSize + textSpacing
254 | listStartY = m.inset + 0.5*boxSize
255 | case RightBottom:
256 | listStartX = m.width - m.inset + boxSize + textSpacing
257 | listStartY = m.height - m.inset - lineHeight*float64(len(plots))
258 | case BottomLeft:
259 | listStartX = m.inset + 0.5*boxSize
260 | listStartY = m.height - m.inset + lineHeight + boxSize + tickSize
261 | }
262 |
263 | style := func(color string) {
264 | m.g.
265 | Font(m.labelFamily, fmt.Sprintf("%dpx", m.labelSize)).
266 | FontStyle(svg.StyleNormal, svg.WeightNormal).
267 | Alignment(svg.HAlignStart, svg.VAlignTop).
268 | Color(color).
269 | StrokeWidth("1px")
270 | }
271 |
272 | for i, plot := range plots {
273 | floatIndex := float64(i)
274 | yPos := listStartY + floatIndex*lineHeight
275 | xPos := listStartX
276 | style(plot.color)
277 | m.g.Rect(xPos, yPos, boxSize, boxSize)
278 | style("black")
279 | m.g.Text(xPos+boxSize+textSpacing, yPos, brackets.XMLEscape(plot.name))
280 | }
281 |
282 | if position == BottomLeft {
283 | newHeight := int(m.height + lineHeight*float64(len(plots)))
284 | m.g.SetSize(int(m.width), newHeight)
285 | }
286 | }
287 |
288 | func (m *Margaid) error(message string) {
289 | m.g.
290 | Font(m.titleFamily, fmt.Sprintf("%dpx", m.titleSize)).
291 | FontStyle(svg.StyleItalic, svg.WeightBold).
292 | Alignment(svg.HAlignStart, svg.VAlignCentral).
293 | Transform().
294 | StrokeWidth("0").Fill("red").
295 | Text(5, m.inset/2, brackets.XMLEscape(message))
296 | }
297 |
298 | // Frame draws a frame around the chart area
299 | func (m *Margaid) Frame() {
300 | m.g.Transform()
301 | m.g.Fill("none").Stroke("black").StrokeWidth("2px")
302 | m.g.Rect(m.inset, m.inset, m.width-m.inset*2, m.height-m.inset*2)
303 | }
304 |
305 | // Render renders the graph to the given destination.
306 | func (m *Margaid) Render(writer io.Writer) error {
307 | rendered := m.g.Render()
308 | _, err := writer.Write([]byte(rendered))
309 | return err
310 | }
311 |
312 | // Projects a value onto an axis using the current projection
313 | // setting.
314 | // The value returned is in user coordinates, [0..1] * width for the x-axis.
315 | func (m *Margaid) project(value float64, axis Axis) (float64, error) {
316 | min := m.ranges[axis].min
317 | max := m.ranges[axis].max
318 |
319 | projected := value
320 | projection := m.projections[axis]
321 |
322 | var axisLength float64
323 | switch {
324 | case axis == X1Axis || axis == X2Axis:
325 | axisLength = m.width - 2*m.inset
326 | case axis == Y1Axis || axis == Y2Axis:
327 | axisLength = m.height - 2*m.inset
328 | }
329 |
330 | axisPadding := m.padding * axisLength
331 |
332 | if projection == Log {
333 | if value <= 0 {
334 | return 0, fmt.Errorf("cannot draw values <= 0 on log scale")
335 | }
336 |
337 | if min <= 0 || max <= 0 {
338 | return 0, fmt.Errorf("cannot have axis range <= 0 on log scale")
339 | }
340 |
341 | projected = math.Log10(value)
342 |
343 | min = math.Log10(min)
344 | max = math.Log10(max)
345 | }
346 |
347 | projected = axisPadding + (axisLength-2*axisPadding)*(projected-min)/(max-min)
348 | return projected, nil
349 | }
350 |
351 | func (m *Margaid) getProjectedValues(series *Series, xAxis, yAxis Axis) (points []struct{ X, Y float64 }, err error) {
352 | values := series.Values()
353 | for values.Next() {
354 | v := values.Get()
355 | v.X, err = m.project(v.X, xAxis)
356 | if err != nil {
357 | return
358 | }
359 | v.Y, err = m.project(v.Y, yAxis)
360 | if err != nil {
361 | return
362 | }
363 | points = append(points, v)
364 | }
365 | return
366 | }
367 |
368 | // addPlot adds a named plot and returns its ID
369 | func (m *Margaid) addPlot(name string) int {
370 | id := len(m.plots)
371 | m.plots = append(m.plots, name)
372 | return id
373 | }
374 |
375 | // getPlotColor picks hues and saturations around the color wheel at prime indices.
376 | // Kind of works for a quick selection of plotting colors.
377 | func (m *Margaid) getPlotColor(id int) string {
378 | color := 211*id + m.colorScheme
379 | hue := color % 360
380 | saturation := 47 + (id*41)%53
381 | return fmt.Sprintf("hsl(%d, %d%%, 65%%)", hue, saturation)
382 | }
383 |
--------------------------------------------------------------------------------
/plots.go:
--------------------------------------------------------------------------------
1 | package margaid
2 |
3 | import (
4 | "fmt"
5 | "math"
6 | "strings"
7 |
8 | "github.com/erkkah/margaid/svg"
9 | )
10 |
11 | type plotOptions struct {
12 | xAxis Axis
13 | yAxis Axis
14 | marker string
15 | strokeWidth float32
16 | }
17 |
18 | // Using is the base type for plotting options
19 | type Using func(*plotOptions)
20 |
21 | func getPlotOptions(using []Using) plotOptions {
22 | options := plotOptions{
23 | xAxis: XAxis,
24 | yAxis: YAxis,
25 | strokeWidth: 3,
26 | }
27 |
28 | for _, u := range using {
29 | u(&options)
30 | }
31 |
32 | return options
33 | }
34 |
35 | // UsingAxes selects the x and y axis for plotting
36 | func UsingAxes(x, y Axis) Using {
37 | return func(o *plotOptions) {
38 | o.xAxis = x
39 | o.yAxis = y
40 | }
41 | }
42 |
43 | // UsingMarker selects a marker for highlighting plotted values.
44 | // See svg.Marker for valid marker types.
45 | func UsingMarker(marker string) Using {
46 | return func(o *plotOptions) {
47 | o.marker = marker
48 | }
49 | }
50 |
51 | func UsingStrokeWidth(width float32) Using {
52 | return func(o *plotOptions) {
53 | o.strokeWidth = width
54 | }
55 | }
56 |
57 | // Line draws a series using straight lines
58 | func (m *Margaid) Line(series *Series, using ...Using) {
59 | options := getPlotOptions(using)
60 |
61 | points, err := m.getProjectedValues(series, options.xAxis, options.yAxis)
62 | if err != nil {
63 | m.error(err.Error())
64 | return
65 | }
66 |
67 | id := m.addPlot(series.title)
68 | color := m.getPlotColor(id)
69 | m.g.
70 | StrokeWidth(fmt.Sprintf("%vpx", options.strokeWidth)).
71 | Fill("none").
72 | Stroke(color).
73 | Marker(options.marker).
74 | Transform(
75 | svg.Translation(m.inset, m.height-m.inset),
76 | svg.Scaling(1, -1),
77 | ).
78 | Polyline(points...).
79 | Marker("").
80 | Transform()
81 | }
82 |
83 | // Smooth draws one series as a smooth curve
84 | func (m *Margaid) Smooth(series *Series, using ...Using) {
85 | options := getPlotOptions(using)
86 |
87 | points, err := m.getProjectedValues(series, options.xAxis, options.yAxis)
88 | if err != nil {
89 | m.error(err.Error())
90 | return
91 | }
92 |
93 | id := m.addPlot(series.title)
94 | color := m.getPlotColor(id)
95 | m.g.
96 | StrokeWidth(fmt.Sprintf("%vpx", options.strokeWidth)).
97 | Fill("none").
98 | Stroke(color).
99 | Marker(options.marker).
100 | Transform(
101 | svg.Translation(m.inset, m.height-m.inset),
102 | svg.Scaling(1, -1),
103 | )
104 |
105 | var path strings.Builder
106 |
107 | path.WriteString(fmt.Sprintf("M%e,%e ", points[0].X, points[0].Y))
108 | catmull := catmullRom2bezier(points)
109 |
110 | for _, p := range catmull {
111 | path.WriteString(fmt.Sprintf("C%e,%e %e,%e %e,%e ",
112 | p[0].X, p[0].Y,
113 | p[1].X, p[1].Y,
114 | p[2].X, p[2].Y,
115 | ))
116 | }
117 | m.g.Path(path.String()).Marker("").Transform()
118 | }
119 |
120 | // Bar draws bars for the specified group of series.
121 | func (m *Margaid) Bar(series []*Series, using ...Using) {
122 | if len(series) == 0 {
123 | return
124 | }
125 |
126 | options := getPlotOptions(using)
127 |
128 | maxSize := 0
129 | for _, s := range series {
130 | if s.Size() > maxSize {
131 | maxSize = s.Size()
132 | }
133 | }
134 |
135 | plotWidth := (m.width - 2*m.inset)
136 | barWidth := plotWidth / float64(maxSize)
137 | barWidth /= 1.5
138 | barWidth = math.Min(barWidth, tickDistance)
139 | barWidth /= float64(len(series))
140 | barOffset := -(barWidth / 2) * float64(len(series)-1)
141 |
142 | for i, s := range series {
143 | points, err := m.getProjectedValues(s, options.xAxis, options.yAxis)
144 |
145 | if err != nil {
146 | m.error(err.Error())
147 | return
148 | }
149 | id := m.addPlot(s.title)
150 | color := m.getPlotColor(id)
151 | m.g.
152 | StrokeWidth("1px").
153 | Color(color).
154 | Transform(
155 | svg.Translation(m.inset, m.height-m.inset),
156 | svg.Scaling(1, -1),
157 | )
158 |
159 | for _, p := range points {
160 | m.g.Rect(barOffset+float64(i)*barWidth+p.X-barWidth/2, 0, barWidth, p.Y)
161 | }
162 | }
163 | m.g.Transform()
164 |
165 | }
166 |
167 | // BezierPoint is one Bezier curve control point
168 | type BezierPoint [3]struct{ X, Y float64 }
169 |
170 | // Pulled from:
171 | // https://advancedweb.hu/plotting-charts-with-svg/
172 |
173 | func catmullRom2bezier(points []struct{ X, Y float64 }) []BezierPoint {
174 | var result = []BezierPoint{}
175 |
176 | for i := range points[0 : len(points)-1] {
177 | p := []struct{ X, Y float64 }{}
178 |
179 | idx := int(math.Max(float64(i-1), 0))
180 | p = append(p, struct{ X, Y float64 }{
181 | X: points[idx].X,
182 | Y: points[idx].Y,
183 | })
184 |
185 | p = append(p, struct{ X, Y float64 }{
186 | X: points[i].X,
187 | Y: points[i].Y,
188 | })
189 |
190 | p = append(p, struct{ X, Y float64 }{
191 | X: points[i+1].X,
192 | Y: points[i+1].Y,
193 | })
194 |
195 | idx = int(math.Min(float64(i+2), float64(len(points)-1)))
196 | p = append(p, struct{ X, Y float64 }{
197 | X: points[idx].X,
198 | Y: points[idx].Y,
199 | })
200 |
201 | // Catmull-Rom to Cubic Bezier conversion matrix
202 | // 0 1 0 0
203 | // -1/6 1 1/6 0
204 | // 0 1/6 1 -1/6
205 | // 0 0 1 0
206 |
207 | bp := BezierPoint{}
208 |
209 | bp[0] = struct{ X, Y float64 }{
210 | X: ((-p[0].X + 6*p[1].X + p[2].X) / 6),
211 | Y: ((-p[0].Y + 6*p[1].Y + p[2].Y) / 6),
212 | }
213 |
214 | bp[1] = struct{ X, Y float64 }{
215 | X: ((p[1].X + 6*p[2].X - p[3].X) / 6),
216 | Y: ((p[1].Y + 6*p[2].Y - p[3].Y) / 6),
217 | }
218 |
219 | bp[2] = struct{ X, Y float64 }{
220 | X: p[2].X,
221 | Y: p[2].Y,
222 | }
223 |
224 | result = append(result, bp)
225 | }
226 |
227 | return result
228 | }
229 |
--------------------------------------------------------------------------------
/series.go:
--------------------------------------------------------------------------------
1 | package margaid
2 |
3 | import (
4 | "container/list"
5 | "math"
6 | "time"
7 | )
8 |
9 | // Series is the plottable type in Margaid
10 | type Series struct {
11 | values *list.List
12 | minX float64
13 | maxX float64
14 | minY float64
15 | maxY float64
16 |
17 | title string
18 |
19 | capper Capper
20 | aggregator Aggregator
21 | interval time.Duration
22 | buffer []Value
23 | at time.Time
24 | }
25 |
26 | // SeriesOption is the base type for all series options
27 | type SeriesOption func(s *Series)
28 |
29 | // NewSeries - series constructor
30 | func NewSeries(options ...SeriesOption) *Series {
31 | self := &Series{
32 | values: list.New(),
33 | }
34 |
35 | for _, option := range options {
36 | option(self)
37 | }
38 |
39 | return self
40 | }
41 |
42 | // Size returns the current series value count
43 | func (s *Series) Size() int {
44 | return s.values.Len()
45 | }
46 |
47 | // MinX returns the series smallest x value, or 0.0 if
48 | // the series is empty
49 | func (s *Series) MinX() float64 {
50 | return s.minX
51 | }
52 |
53 | // MaxX returns the series largest x value, or 0.0 if
54 | // the series is empty
55 | func (s *Series) MaxX() float64 {
56 | return s.maxX
57 | }
58 |
59 | // MinY returns the series smallest y value, or 0.0 if
60 | // the series is empty
61 | func (s *Series) MinY() float64 {
62 | return s.minY
63 | }
64 |
65 | // MaxY returns the series largest y value, or 0.0 if
66 | // the series is empty
67 | func (s *Series) MaxY() float64 {
68 | return s.maxY
69 | }
70 |
71 | // SeriesIterator helps iterating series values
72 | type SeriesIterator struct {
73 | list *list.List
74 | element *list.Element
75 | }
76 |
77 | // Get returns the iterator current value.
78 | // A newly created iterator has no current value.
79 | func (si *SeriesIterator) Get() Value {
80 | return si.element.Value.(Value)
81 | }
82 |
83 | // Next steps to the next value.
84 | // A newly created iterator has no current value.
85 | func (si *SeriesIterator) Next() bool {
86 | if si.element == nil {
87 | si.element = si.list.Front()
88 | } else {
89 | si.element = si.element.Next()
90 | }
91 | return si.element != nil
92 | }
93 |
94 | // Values returns an iterator to the series values
95 | func (s *Series) Values() SeriesIterator {
96 | return SeriesIterator{
97 | list: s.values,
98 | }
99 | }
100 |
101 | // Value is the type of each series element.
102 | // The X part represents a position on the X axis, which could
103 | // be time or a regular value.
104 | type Value struct {
105 | X float64
106 | Y float64
107 | }
108 |
109 | // MakeValue creates a Value from x and y values.
110 | func MakeValue(x float64, y float64) Value {
111 | return Value{X: x, Y: y}
112 | }
113 |
114 | // Add appends one or more values, optionally
115 | // peforming aggregation.
116 | // If the series is capped, capping will be applied
117 | // after aggregation.
118 | func (s *Series) Add(values ...Value) {
119 | if len(values) == 0 {
120 | return
121 | }
122 |
123 | if s.aggregator != nil {
124 |
125 | if s.values.Len() == 0 && len(s.buffer) == 0 {
126 | s.at = TimeFromSeconds(values[0].X).Truncate(s.interval)
127 | }
128 |
129 | var aggregated []Value
130 |
131 | for _, v := range values {
132 | at := TimeFromSeconds(v.X)
133 | if s.at.Add(s.interval).Before(at) {
134 | agg := s.aggregator(s.buffer, s.at)
135 | aggregated = append(aggregated, agg)
136 | s.at = at.Truncate(s.interval)
137 | s.buffer = append(s.buffer[0:0], v)
138 | } else {
139 | s.buffer = append(s.buffer, v)
140 | }
141 | }
142 |
143 | values = aggregated
144 | }
145 |
146 | for _, v := range values {
147 | if s.values.Len() == 0 {
148 | s.minX = v.X
149 | s.maxX = v.X
150 | s.minY = v.Y
151 | s.maxY = v.Y
152 | } else {
153 | s.minX = math.Min(s.minX, v.X)
154 | s.maxX = math.Max(s.maxX, v.X)
155 | s.minY = math.Min(s.minY, v.Y)
156 | s.maxY = math.Max(s.maxY, v.Y)
157 | }
158 | s.values.PushBack(v)
159 | if s.capper != nil {
160 | s.capper(s.values)
161 | }
162 | }
163 | }
164 |
165 | // Zip merges two slices of floats into pairs and adds
166 | // them to the series. It is assumed that the two slices
167 | // have the same length.
168 | func (s *Series) Zip(xValues, yValues []float64) {
169 | valueCount := len(xValues)
170 | if len(yValues) < valueCount {
171 | valueCount = len(yValues)
172 | }
173 |
174 | zipped := make([]Value, valueCount)
175 | for i := range zipped {
176 | zipped[i] = MakeValue(xValues[i], yValues[i])
177 | }
178 | s.Add(zipped...)
179 | }
180 |
181 | func (s *Series) updateMinMax() {
182 | if s.values.Len() == 0 {
183 | return
184 | }
185 |
186 | values := s.Values()
187 | values.Next()
188 | current := values.Get()
189 | s.minX = current.X
190 | s.maxX = current.X
191 | s.minY = current.Y
192 | s.maxY = current.Y
193 |
194 | for values.Next() {
195 | current = values.Get()
196 | s.minX = math.Min(s.minX, current.X)
197 | s.maxX = math.Max(s.maxX, current.X)
198 | s.minY = math.Min(s.minY, current.Y)
199 | s.maxY = math.Max(s.maxY, current.Y)
200 | }
201 | }
202 |
203 | // Capper is the capping function type
204 | type Capper func(values *list.List)
205 |
206 | // Aggregator is the aggregating function type
207 | type Aggregator func(values []Value, at time.Time) Value
208 |
209 | // CappedBySize caps a series to at most cap values.
210 | func CappedBySize(cap int) SeriesOption {
211 | return func(s *Series) {
212 | s.capper = func(values *list.List) {
213 | removed := false
214 | for values.Len() > cap {
215 | values.Remove(values.Front())
216 | removed = true
217 | }
218 | if removed {
219 | s.updateMinMax()
220 | }
221 | }
222 | }
223 | }
224 |
225 | // CappedByAge caps a series by removing values older than cap
226 | // in relation to the current value of the reference funcion.
227 | func CappedByAge(cap time.Duration, reference func() time.Time) SeriesOption {
228 | return func(s *Series) {
229 | s.capper = func(values *list.List) {
230 | removed := false
231 | for values.Len() > 0 {
232 | first := values.Front()
233 | val := first.Value.(Value)
234 | xTime := TimeFromSeconds(val.X)
235 | if !xTime.Before(reference().Add(-cap)) {
236 | break
237 | }
238 | values.Remove(first)
239 | removed = true
240 | }
241 | if removed {
242 | s.updateMinMax()
243 | }
244 | }
245 | }
246 | }
247 |
248 | // Avg calculates the Y average of a list of values,
249 | // reported as observed at the given time.
250 | func Avg(values []Value, at time.Time) Value {
251 | if len(values) == 0 {
252 | return Value{}
253 | }
254 |
255 | var sum float64
256 | for _, v := range values {
257 | sum += v.Y
258 | }
259 | avg := sum / float64(len(values))
260 | return Value{SecondsFromTime(at), avg}
261 | }
262 |
263 | // Sum calculates the Y sum of a list of values,
264 | // reported as observed at the given time.
265 | func Sum(values []Value, at time.Time) Value {
266 | var sum float64
267 | for _, v := range values {
268 | sum += v.Y
269 | }
270 | return Value{SecondsFromTime(at), sum}
271 | }
272 |
273 | // Delta calculates the Y difference between the first and
274 | // last value in the list, reported as observed at the given time.
275 | func Delta(values []Value, at time.Time) Value {
276 | if len(values) < 2 {
277 | return Value{}
278 | }
279 |
280 | first := values[0].Y
281 | last := values[len(values)-1].Y
282 | return Value{SecondsFromTime(at), last - first}
283 | }
284 |
285 | // AggregatedBy sets the series aggregator
286 | func AggregatedBy(f Aggregator, interval time.Duration) SeriesOption {
287 | return func(s *Series) {
288 | s.aggregator = f
289 | s.interval = interval
290 | }
291 | }
292 |
293 | // Titled sets the series title
294 | func Titled(title string) SeriesOption {
295 | return func(s *Series) {
296 | s.title = title
297 | }
298 | }
299 |
300 | // TimeFromSeconds converts from seconds since the epoch to time.Time
301 | func TimeFromSeconds(seconds float64) time.Time {
302 | wholeSecs := int64(seconds)
303 | nanos := int64((seconds - float64(wholeSecs)) * 1E9)
304 | return time.Unix(wholeSecs, nanos)
305 | }
306 |
307 | // SecondsFromTime converts from time.Time to seconds since the epoch
308 | func SecondsFromTime(time time.Time) float64 {
309 | return float64(time.UnixNano()) / 1E9
310 | }
311 |
--------------------------------------------------------------------------------
/series_test.go:
--------------------------------------------------------------------------------
1 | package margaid
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/erkkah/margaid/xt"
8 | )
9 |
10 | func TestConstruction(t *testing.T) {
11 | x := xt.X(t)
12 | s := NewSeries()
13 | s.Add(MakeValue(0, 1))
14 |
15 | x.Equal(s.Size(), 1)
16 | }
17 |
18 | func TestCapBySize(t *testing.T) {
19 | x := xt.X(t)
20 | s := NewSeries(CappedBySize(3))
21 | for i := 0; i < 10; i++ {
22 | s.Add(MakeValue(float64(i*2), float64(i)))
23 | }
24 | x.Equal(s.Size(), 3)
25 | }
26 |
27 | func TestCapByTime(t *testing.T) {
28 | x := xt.X(t)
29 | now := time.Now()
30 | reference := func() time.Time {
31 | return now
32 | }
33 | s := NewSeries(CappedByAge(5*time.Millisecond, reference))
34 | for i := 0; i < 10; i++ {
35 | s.Add(MakeValue(SecondsFromTime(now.Add(-2*time.Duration(10-i)*time.Millisecond)), float64(i)))
36 | }
37 | x.Equal(s.Size(), 2)
38 | x.Assert(TimeFromSeconds(s.MinX()).After(now.Add(-5 * time.Millisecond)))
39 | }
40 |
41 | func TestMinMax(t *testing.T) {
42 | x := xt.X(t)
43 |
44 | s := NewSeries()
45 | s.Add(MakeValue(0, 1))
46 | s.Add(MakeValue(1000, 2))
47 | s.Add(MakeValue(-10, 3))
48 |
49 | x.Equal(s.MinX(), -10.0)
50 | x.Equal(s.MaxX(), 1000.0)
51 | }
52 |
53 | func TestAggregateAvg(t *testing.T) {
54 | x := xt.X(t)
55 |
56 | s := NewSeries(AggregatedBy(Avg, time.Second))
57 |
58 | now := time.Now().Truncate(time.Second).Add(time.Millisecond * 50)
59 | later := now.Add(time.Millisecond * 100)
60 | tooLate := now.Add(time.Second)
61 |
62 | s.Add(
63 | MakeValue(SecondsFromTime(now), 10),
64 | MakeValue(SecondsFromTime(later), 20),
65 | MakeValue(SecondsFromTime(tooLate), 30),
66 | )
67 |
68 | values := s.Values()
69 | x.True(values.Next())
70 |
71 | v := values.Get()
72 | x.Equal(v.Y, 15.0)
73 | x.Equal(v.X, SecondsFromTime(now.Truncate(time.Second)))
74 |
75 | x.False(values.Next(), "Series should be empty")
76 | }
77 |
78 | func TestAggregateSum(t *testing.T) {
79 | x := xt.X(t)
80 |
81 | s := NewSeries(AggregatedBy(Sum, time.Second))
82 |
83 | now := time.Now().Truncate(time.Second).Add(time.Millisecond * 50)
84 | later := now.Add(time.Millisecond * 100)
85 | tooLate := now.Add(time.Second)
86 |
87 | s.Add(
88 | MakeValue(SecondsFromTime(now), 10),
89 | MakeValue(SecondsFromTime(later), 20),
90 | MakeValue(SecondsFromTime(tooLate), 30),
91 | )
92 |
93 | values := s.Values()
94 | x.True(values.Next())
95 |
96 | v := values.Get()
97 | x.Equal(v.Y, 30.0)
98 | x.Equal(v.X, SecondsFromTime(now.Truncate(time.Second)))
99 |
100 | x.False(values.Next(), "Series should be empty")
101 | }
102 |
103 | func TestAggregateDelta(t *testing.T) {
104 | x := xt.X(t)
105 |
106 | s := NewSeries(AggregatedBy(Delta, time.Second))
107 |
108 | now := time.Now().Truncate(time.Second).Add(time.Millisecond * 50)
109 | later := now.Add(time.Millisecond * 100)
110 | tooLate := now.Add(time.Second)
111 |
112 | s.Add(
113 | MakeValue(SecondsFromTime(now), 10),
114 | MakeValue(SecondsFromTime(later), 20),
115 | MakeValue(SecondsFromTime(tooLate), 30),
116 | )
117 |
118 | values := s.Values()
119 | x.True(values.Next())
120 |
121 | v := values.Get()
122 | x.Equal(v.Y, 10.0)
123 | x.Equal(v.X, SecondsFromTime(now.Truncate(time.Second)))
124 |
125 | x.False(values.Next(), "Series should be empty")
126 | }
127 |
--------------------------------------------------------------------------------
/svg/svg.go:
--------------------------------------------------------------------------------
1 | package svg
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "strings"
7 |
8 | br "github.com/erkkah/margaid/brackets"
9 | )
10 |
11 | // SVG builds SVG format images using a small subset of the standard
12 | type SVG struct {
13 | brackets *br.Brackets
14 |
15 | transforms []Transform
16 | attributes br.Attributes
17 | styleInSync bool
18 |
19 | parent *SVG
20 | }
21 |
22 | // Transform represents a transform function
23 | type Transform struct {
24 | function string
25 | arguments []float64
26 | }
27 |
28 | // New - SVG constructor
29 | func New(width int, height int, background string) *SVG {
30 | self := makeSVG()
31 | self.brackets.Open("svg", br.Attributes{
32 | "width": strconv.Itoa(width),
33 | "height": strconv.Itoa(height),
34 | "viewbox": fmt.Sprintf("0 0 %d %d", width, height),
35 | "preserveAspectRatio": "xMidYMid meet",
36 | "style": fmt.Sprintf("background-color:%s", background),
37 | "xmlns": "http://www.w3.org/2000/svg",
38 | })
39 | self.addMarkers()
40 | return &self
41 | }
42 |
43 | // SetSize of SVG, redefining the value given during construction.
44 | func (svg *SVG) SetSize(width, height int) {
45 | elem := svg.brackets.First()
46 | elem.SetAttribute("width", strconv.Itoa(width))
47 | elem.SetAttribute("height", strconv.Itoa(height))
48 | }
49 |
50 | func (svg *SVG) addMarkers() {
51 | svg.brackets.Open("defs").
52 | Open("marker", br.Attributes{
53 | "id": "circle",
54 | "viewBox": "0 0 10 10 ",
55 | "refX": "5",
56 | "refY": "5",
57 | "markerUnits": "userSpaceOnUse",
58 | "markerWidth": "2%",
59 | "markerHeight": "2%",
60 | }).
61 | Add("circle", br.Attributes{
62 | "cx": "5",
63 | "cy": "5",
64 | "r": "3",
65 | "fill": "none",
66 | "stroke": "black",
67 | }).
68 | Close().
69 | Open("marker", br.Attributes{
70 | "id": "filled-circle",
71 | "viewBox": "0 0 10 10 ",
72 | "refX": "5",
73 | "refY": "5",
74 | "markerUnits": "userSpaceOnUse",
75 | "markerWidth": "2%",
76 | "markerHeight": "2%",
77 | }).
78 | Add("circle", br.Attributes{
79 | "cx": "5",
80 | "cy": "5",
81 | "r": "3",
82 | "fill": "black",
83 | "stroke": "none",
84 | }).
85 | Close().
86 | Open("marker", br.Attributes{
87 | "id": "square",
88 | "viewBox": "0 0 10 10 ",
89 | "refX": "5",
90 | "refY": "5",
91 | "markerUnits": "userSpaceOnUse",
92 | "markerWidth": "2%",
93 | "markerHeight": "2%",
94 | }).
95 | Add("rect", br.Attributes{
96 | "x": "2",
97 | "y": "2",
98 | "width": "6",
99 | "height": "6",
100 | "fill": "none",
101 | "stroke": "black",
102 | }).
103 | Close().
104 | Open("marker", br.Attributes{
105 | "id": "filled-square",
106 | "viewBox": "0 0 10 10 ",
107 | "refX": "5",
108 | "refY": "5",
109 | "markerUnits": "userSpaceOnUse",
110 | "markerWidth": "2%",
111 | "markerHeight": "2%",
112 | }).
113 | Add("rect", br.Attributes{
114 | "x": "2",
115 | "y": "2",
116 | "width": "6",
117 | "height": "6",
118 | "fill": "black",
119 | "stroke": "none",
120 | }).
121 | Close().
122 | Close()
123 | }
124 |
125 | func makeSVG() SVG {
126 | return SVG{
127 | brackets: br.New(),
128 | attributes: br.Attributes{
129 | "fill": "green",
130 | "stroke": "black",
131 | "stroke-width": "1px",
132 | "stroke-linecap": "round",
133 | "stroke-linejoin": "round",
134 | },
135 | }
136 | }
137 |
138 | // Child adds a sub-SVG at x, y
139 | func (svg *SVG) Child(x, y float64) *SVG {
140 | self := makeSVG()
141 | self.parent = svg
142 | self.brackets.Open("svg", br.Attributes{
143 | "x": ftos(x),
144 | "y": ftos(y),
145 | })
146 | return &self
147 | }
148 |
149 | // Close closes a child SVG and returns the parent.
150 | // If the current SVG is not a child, this is a noop.
151 | func (svg *SVG) Close() *SVG {
152 | if svg.parent != nil {
153 | svg.brackets.CloseAll()
154 | svg.parent.brackets.Append(svg.brackets)
155 | return svg.parent
156 | }
157 | return svg
158 | }
159 |
160 | // Render generates SVG code for the current image, and clears the canvas
161 | func (svg *SVG) Render() string {
162 | svg.brackets.CloseAll()
163 | result := svg.brackets.String()
164 | svg.brackets = br.New()
165 | return result
166 | }
167 |
168 | func attributeDiff(old, new br.Attributes) (diff br.Attributes, extendable bool) {
169 | diff = br.Attributes{}
170 | extendable = true
171 |
172 | for k, newValue := range new {
173 | if k == "transform" {
174 | extendable = false
175 | return
176 | }
177 | if oldValue, found := old[k]; found {
178 | if oldValue != newValue {
179 | diff[k] = newValue
180 | }
181 | } else {
182 | diff[k] = newValue
183 | }
184 | }
185 |
186 | for k := range old {
187 | if _, found := new[k]; !found {
188 | extendable = false
189 | return
190 | }
191 | }
192 | return
193 | }
194 |
195 | func shouldExtendParentStyle(old, new, diff br.Attributes) bool {
196 | return diff.Size() < new.Size()
197 | }
198 |
199 | func (svg *SVG) updateStyle() {
200 | if !svg.styleInSync {
201 | current := svg.brackets.Current()
202 | nextAttributes := svg.attributes
203 | if current != nil && current.Name() == "g" {
204 | diff, extendable := attributeDiff(current.Attributes(), svg.attributes)
205 | if extendable && shouldExtendParentStyle(current.Attributes(), svg.attributes, diff) {
206 | nextAttributes = diff
207 | } else {
208 | svg.brackets.Close()
209 | }
210 | }
211 | svg.brackets.Open("g", nextAttributes)
212 | svg.styleInSync = true
213 | }
214 | }
215 |
216 | func (svg *SVG) setAttribute(attr string, value string) {
217 | if svg.attributes[attr] != value {
218 | if value == "" {
219 | delete(svg.attributes, attr)
220 | } else {
221 | svg.attributes[attr] = value
222 | }
223 | svg.styleInSync = false
224 | }
225 | }
226 |
227 | /// Drawing
228 |
229 | // Path adds a SVG style path
230 | func (svg *SVG) Path(path string) *SVG {
231 | svg.updateStyle()
232 | top := svg.brackets.Last()
233 | if top.Name() == "path" && strings.TrimSpace(path)[0] == 'M' {
234 | commands := top.Attributes()["d"]
235 | commands += path
236 | top.SetAttribute("d", commands)
237 | } else {
238 | svg.brackets.Add("path", br.Attributes{
239 | "d": path,
240 | "vector-effect": "non-scaling-stroke",
241 | })
242 | }
243 | return svg
244 | }
245 |
246 | // Polyline adds a polyline from a list of points
247 | func (svg *SVG) Polyline(points ...struct{ X, Y float64 }) *SVG {
248 | if len(points) < 2 {
249 | return svg
250 | }
251 |
252 | var path strings.Builder
253 | first := points[0]
254 | path.WriteString(fmt.Sprintf("M%s,%s ", ftos(first.X), ftos(first.Y)))
255 |
256 | for _, p := range points[1:] {
257 | path.WriteString(fmt.Sprintf("L%s,%s ", ftos(p.X), ftos(p.Y)))
258 | }
259 | return svg.Path(path.String())
260 | }
261 |
262 | // Rect adds a rect defined by x, y, width and height
263 | func (svg *SVG) Rect(x, y, width, height float64) *SVG {
264 | svg.updateStyle()
265 | svg.brackets.Add("rect", br.Attributes{
266 | "x": ftos(x),
267 | "y": ftos(y),
268 | "width": ftos(width),
269 | "height": ftos(height),
270 | "vector-effect": "non-scaling-stroke",
271 | })
272 | return svg
273 | }
274 |
275 | // Text draws text at x, y
276 | func (svg *SVG) Text(x, y float64, txt string) *SVG {
277 | svg.updateStyle()
278 | attributes := br.Attributes{
279 | "x": ftos(x),
280 | "y": ftos(y),
281 | "stroke": "none",
282 | "vector-effect": "non-scaling-stroke",
283 | }
284 | if alignment, ok := svg.attributes["dominant-baseline"]; ok {
285 | attributes["dominant-baseline"] = alignment
286 | }
287 | svg.brackets.Open("text", attributes)
288 | svg.brackets.Text(txt)
289 | svg.brackets.Close()
290 | return svg
291 | }
292 |
293 | /// Transformations
294 |
295 | // Rotation rotates by angle degrees clockwise around (x, y)
296 | func Rotation(angle, x, y float64) Transform {
297 | return Transform{
298 | "rotate",
299 | []float64{angle, x, y},
300 | }
301 | }
302 |
303 | // Translation moves by (x, y)
304 | func Translation(x, y float64) Transform {
305 | return Transform{
306 | "translate",
307 | []float64{x, y},
308 | }
309 | }
310 |
311 | // Scaling scales by (xScale, yScale)
312 | func Scaling(xScale, yScale float64) Transform {
313 | return Transform{
314 | "scale",
315 | []float64{xScale, yScale},
316 | }
317 | }
318 |
319 | // Transform sets the current list of transforms
320 | // that will be used by the next set of drawing operations.
321 | // Specifying no transforms resets the transformation matrix
322 | // to identity.
323 | func (svg *SVG) Transform(transforms ...Transform) *SVG {
324 | var builder strings.Builder
325 |
326 | for _, t := range transforms {
327 | builder.WriteString(t.function)
328 | builder.WriteRune('(')
329 | for _, a := range t.arguments {
330 | builder.WriteString(ftos(a))
331 | builder.WriteRune(' ')
332 | }
333 | builder.WriteRune(')')
334 | }
335 |
336 | svg.setAttribute("transform", builder.String())
337 | return svg
338 | }
339 |
340 | /// Style
341 |
342 | // Fill sets current fill style
343 | func (svg *SVG) Fill(fill string) *SVG {
344 | svg.setAttribute("fill", fill)
345 | return svg
346 | }
347 |
348 | // Stroke sets current stroke
349 | func (svg *SVG) Stroke(stroke string) *SVG {
350 | svg.setAttribute("stroke", stroke)
351 | return svg
352 | }
353 |
354 | // Color sets current stroke and fill
355 | func (svg *SVG) Color(color string) *SVG {
356 | svg.Stroke(color)
357 | svg.Fill(color)
358 | return svg
359 | }
360 |
361 | // StrokeWidth sets current stroke width
362 | func (svg *SVG) StrokeWidth(width string) *SVG {
363 | svg.setAttribute("stroke-width", width)
364 | return svg
365 | }
366 |
367 | // Marker adds start, mid and end markers to all following strokes.
368 | // The specified marker has to be one of "circle" and "square".
369 | // Setting the marker to the empty string clears the marker.
370 | func (svg *SVG) Marker(marker string) *SVG {
371 | reference := ""
372 | if marker != "" {
373 | reference = fmt.Sprintf("url(#%s)", marker)
374 | }
375 | svg.setAttribute("marker-start", reference)
376 | svg.setAttribute("marker-mid", reference)
377 | svg.setAttribute("marker-end", reference)
378 | return svg
379 | }
380 |
381 | // Font sets current font family and size
382 | func (svg *SVG) Font(font string, size string) *SVG {
383 | svg.setAttribute("font-family", font)
384 | svg.setAttribute("font-size", size)
385 | return svg
386 | }
387 |
388 | // Style is the type for the text style constants
389 | type Style string
390 |
391 | // Weight is the type for the text weight constants
392 | type Weight string
393 |
394 | // Text style constants
395 | const (
396 | StyleNormal Style = "normal"
397 | StyleItalic Style = "italic"
398 | )
399 |
400 | // Text weight constants
401 | const (
402 | WeightNormal Weight = "normal"
403 | WeightBold Weight = "bold"
404 | WeightLighter Weight = "lighter"
405 | )
406 |
407 | // FontStyle sets the current font style and weight
408 | func (svg *SVG) FontStyle(style Style, weight Weight) *SVG {
409 | svg.setAttribute("font-style", string(style))
410 | svg.setAttribute("font-weight", string(weight))
411 | return svg
412 | }
413 |
414 | // VAlignment is the type for the vertical alignment constants
415 | type VAlignment string
416 |
417 | // HAlignment is the type for the horizontal alignment constants
418 | type HAlignment string
419 |
420 | // Horizontal text alignment constants
421 | const (
422 | HAlignStart HAlignment = "start"
423 | HAlignMiddle HAlignment = "middle"
424 | HAlignEnd HAlignment = "end"
425 | )
426 |
427 | // Vertical text alignment constants
428 | const (
429 | VAlignTop VAlignment = "hanging"
430 | VAlignCentral VAlignment = "middle"
431 | VAlignBottom VAlignment = "baseline"
432 | )
433 |
434 | // Alignment sets current text alignment
435 | func (svg *SVG) Alignment(horizontal HAlignment, vertical VAlignment) *SVG {
436 | svg.setAttribute("text-anchor", string(horizontal))
437 | svg.setAttribute("dominant-baseline", string(vertical))
438 | return svg
439 | }
440 |
441 | /// Utilities
442 |
443 | func ftos(value float64) string {
444 | if float64(int(value)) == value {
445 | return strconv.Itoa(int(value))
446 | }
447 | return fmt.Sprintf("%e", value)
448 | }
449 |
450 | // EncodeText applies proper xml escaping and svg line breaking
451 | // at each newline in the raw text and returns a section ready
452 | // for inclusion in a element.
453 | // NOTE: Line breaking is kind of hacky, since we cannot actually
454 | // measure text in SVG, and assume that all characters are 1ex wide.
455 | func EncodeText(raw string, alignment HAlignment) string {
456 | chunks := strings.Split(raw, "\n")
457 | if len(chunks) > 1 {
458 | var lines strings.Builder
459 |
460 | lines.WriteString(br.XMLEscape(chunks[0]))
461 | previousLength := float64(len(chunks[0]))
462 |
463 | for _, chunk := range chunks[1:] {
464 | chunk = br.XMLEscape(chunk)
465 | currentLength := float64(len(chunk))
466 |
467 | carriageReturn := 0.0
468 | switch alignment {
469 | case HAlignStart:
470 | carriageReturn = previousLength
471 | case HAlignMiddle:
472 | carriageReturn = (previousLength + currentLength) / 2
473 | case HAlignEnd:
474 | carriageReturn = currentLength
475 | }
476 |
477 | lines.WriteString(fmt.Sprintf(`%s`, carriageReturn, chunk))
478 | previousLength = float64(len(chunk))
479 | }
480 | return lines.String()
481 | }
482 | return br.XMLEscape(raw)
483 | }
484 |
--------------------------------------------------------------------------------
/tickers.go:
--------------------------------------------------------------------------------
1 | package margaid
2 |
3 | import (
4 | "math"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/erkkah/margaid/svg"
9 | )
10 |
11 | // Ticker provides tick marks and labels for axes
12 | type Ticker interface {
13 | label(value float64) string
14 | start(axis Axis, series *Series, steps int) float64
15 | next(previous float64) (next float64, hasMore bool)
16 | }
17 |
18 | // TimeTicker returns time valued tick labels in the specified time format.
19 | // TimeTicker assumes that time is linear.
20 | func (m *Margaid) TimeTicker(format string) Ticker {
21 | return &timeTicker{m, format, 1}
22 | }
23 |
24 | type timeTicker struct {
25 | m *Margaid
26 | format string
27 | step float64
28 | }
29 |
30 | func (t *timeTicker) label(value float64) string {
31 | formatted := TimeFromSeconds(value).Format(t.format)
32 | return svg.EncodeText(formatted, svg.HAlignMiddle)
33 | }
34 |
35 | func (t *timeTicker) start(axis Axis, _ *Series, steps int) float64 {
36 | minmax := t.m.ranges[axis]
37 | scaleRange := minmax.max - minmax.min
38 | scaleDuration := TimeFromSeconds(scaleRange).Sub(time.Unix(0, 0))
39 |
40 | t.step = math.Pow(10.0, math.Trunc(math.Log10(scaleDuration.Seconds()/float64(steps))))
41 | base := t.step
42 | for int(scaleRange/t.step) > steps {
43 | t.step += base
44 | }
45 |
46 | durationStep := time.Duration(t.step)
47 | durationStart := time.Duration(minmax.min)
48 | start := (durationStart * time.Second).Truncate(durationStep).Seconds()
49 | if start < minmax.min {
50 | start += t.step
51 | }
52 | return start
53 | }
54 |
55 | func (t *timeTicker) next(previous float64) (float64, bool) {
56 | return previous + t.step, true
57 | }
58 |
59 | // ValueTicker returns tick labels by converting floats using strconv.FormatFloat
60 | func (m *Margaid) ValueTicker(style byte, precision int, base int) Ticker {
61 | return &valueTicker{
62 | m: m,
63 | step: 1,
64 | style: style,
65 | precision: precision,
66 | base: base,
67 | }
68 | }
69 |
70 | type valueTicker struct {
71 | m *Margaid
72 | projection Projection
73 | step float64
74 | style byte
75 | precision int
76 | base int
77 | }
78 |
79 | func (t *valueTicker) label(value float64) string {
80 | return strconv.FormatFloat(value, t.style, t.precision, 64)
81 | }
82 |
83 | func (t *valueTicker) start(axis Axis, _ *Series, steps int) float64 {
84 | t.projection = t.m.projections[axis]
85 | minmax := t.m.ranges[axis]
86 | scaleRange := minmax.max - minmax.min
87 |
88 | startValue := 0.0
89 | floatBase := float64(t.base)
90 |
91 | if t.projection == Lin {
92 | roundedLog := math.Round(math.Log(scaleRange/float64(steps)) / math.Log(floatBase))
93 | t.step = math.Pow(floatBase, roundedLog)
94 | base := t.step
95 | for int(scaleRange/t.step) > steps {
96 | t.step += base
97 | }
98 | wholeSteps := math.Ceil(minmax.min / t.step)
99 | startValue = wholeSteps * t.step
100 | return startValue
101 | }
102 |
103 | t.step = 0
104 | startValue = math.Pow(floatBase, math.Round(math.Log(minmax.min)/math.Log(floatBase)))
105 | for startValue < minmax.min {
106 | startValue, _ = t.next(startValue)
107 | }
108 | return startValue
109 | }
110 |
111 | func (t *valueTicker) next(previous float64) (float64, bool) {
112 | if t.projection == Lin {
113 | return previous + t.step, true
114 | }
115 |
116 | floatBase := float64(t.base)
117 | log := math.Log(previous) / math.Log(floatBase)
118 | if log < 0 {
119 | log = -math.Ceil(-log)
120 | } else {
121 | log = math.Floor(log)
122 | }
123 | increment := math.Pow(floatBase, log)
124 | next := previous + increment
125 | next /= increment
126 | next = math.Round(next) * increment
127 | return next, true
128 | }
129 |
130 | // LabeledTicker places tick marks and labels for all values
131 | // of a series. The labels are provided by the labeler function.
132 | func (m *Margaid) LabeledTicker(labeler func(float64) string) Ticker {
133 | return &labeledTicker{
134 | labeler: labeler,
135 | }
136 | }
137 |
138 | type labeledTicker struct {
139 | labeler func(float64) string
140 | values []float64
141 | index int
142 | }
143 |
144 | func (t *labeledTicker) label(value float64) string {
145 | return svg.EncodeText(t.labeler(value), svg.HAlignMiddle)
146 | }
147 |
148 | func (t *labeledTicker) start(axis Axis, series *Series, _ int) float64 {
149 | var values []float64
150 |
151 | var get func(v Value) float64
152 |
153 | if axis == X1Axis || axis == X2Axis {
154 | get = func(v Value) float64 {
155 | return v.X
156 | }
157 | }
158 | if axis == Y1Axis || axis == Y2Axis {
159 | get = func(v Value) float64 {
160 | return v.Y
161 | }
162 | }
163 |
164 | v := series.Values()
165 | for v.Next() {
166 | val := v.Get()
167 | values = append(values, get(val))
168 | }
169 |
170 | t.values = values
171 | return values[0]
172 | }
173 |
174 | func (t *labeledTicker) next(previous float64) (float64, bool) {
175 | // Tickers are not supposed to have state.
176 | // LabeledTicker breaks this, assuming a strict linear calling order.
177 |
178 | if previous != t.values[t.index] {
179 | return previous, false
180 | }
181 |
182 | t.index++
183 | if t.index < len(t.values) {
184 | return t.values[t.index], true
185 | }
186 | t.index = 0
187 | return 0, false
188 | }
189 |
--------------------------------------------------------------------------------
/tickers_test.go:
--------------------------------------------------------------------------------
1 | package margaid
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/erkkah/margaid/xt"
8 | )
9 |
10 | func TestTimeTickerStart(t *testing.T) {
11 | x := xt.X(t)
12 |
13 | min := SecondsFromTime(time.Date(2020, time.September, 4, 9, 10, 0, 0, time.Local))
14 | max := SecondsFromTime(time.Date(2020, time.September, 4, 9, 11, 0, 0, time.Local))
15 |
16 | m := New(100, 100, WithRange(
17 | XAxis,
18 | min,
19 | max,
20 | ))
21 | ticker := m.TimeTicker(time.Kitchen)
22 | s := NewSeries()
23 |
24 | timestamp := time.Date(2020, time.September, 4, 9, 10, 30, 0, time.Local)
25 | s.Add(MakeValue(SecondsFromTime(timestamp), 123))
26 | start := ticker.start(XAxis, s, 10)
27 |
28 | // 60 secs in 10 steps leads to 6s step size
29 | x.Equal(start, min)
30 | }
31 |
32 | func TestTimeTickerStep(t *testing.T) {
33 | x := xt.X(t)
34 |
35 | min := SecondsFromTime(time.Date(2020, time.September, 4, 9, 10, 0, 0, time.Local))
36 | max := SecondsFromTime(time.Date(2020, time.September, 4, 9, 11, 0, 0, time.Local))
37 |
38 | m := New(100, 100, WithRange(
39 | XAxis,
40 | min,
41 | max,
42 | ))
43 | ticker := m.TimeTicker(time.Kitchen)
44 | s := NewSeries()
45 |
46 | timestamp := time.Date(2020, time.September, 4, 9, 10, 30, 0, time.Local)
47 | s.Add(MakeValue(SecondsFromTime(timestamp), 123))
48 | step := ticker.start(XAxis, s, 10)
49 |
50 | more := true
51 | count := 0
52 | for ; step <= max && more; step, more = ticker.next(step) {
53 | count++
54 | }
55 |
56 | x.Equal(count, 11)
57 | x.Assert(more)
58 | }
59 |
60 | func TestValueTickerStart_Lin(t *testing.T) {
61 | x := xt.X(t)
62 |
63 | min := 12.34
64 | max := 56.78
65 |
66 | m := New(100, 100, WithRange(
67 | XAxis,
68 | min,
69 | max,
70 | ))
71 | ticker := m.ValueTicker('f', 0, 10)
72 | s := NewSeries()
73 |
74 | s.Add(MakeValue(30, 123))
75 | start := ticker.start(XAxis, s, 10)
76 |
77 | x.Assert(start > min, "start > min")
78 | x.Assert(start < max, "start < max")
79 | }
80 |
81 | func TestValueTickerStep_Lin(t *testing.T) {
82 | x := xt.X(t)
83 |
84 | min := 12.34
85 | max := 56.78
86 |
87 | m := New(100, 100, WithRange(
88 | XAxis,
89 | min,
90 | max,
91 | ))
92 | ticker := m.ValueTicker('f', 0, 10)
93 | s := NewSeries()
94 |
95 | s.Add(MakeValue(30, 123))
96 | step := ticker.start(XAxis, s, 10)
97 |
98 | more := true
99 | count := 0
100 | for ; step <= max && more; step, more = ticker.next(step) {
101 | count++
102 | }
103 |
104 | // There are four marks in the range [10.0, 50.0]
105 | x.Equal(count, 4)
106 | x.Assert(more)
107 | }
108 |
109 | func TestValueTickerStart_Log(t *testing.T) {
110 | x := xt.X(t)
111 |
112 | min := 12.34
113 | max := 56.78
114 |
115 | m := New(100, 100, WithRange(
116 | XAxis,
117 | min,
118 | max,
119 | ), WithProjection(XAxis, Log))
120 |
121 | ticker := m.ValueTicker('f', 0, 10)
122 | s := NewSeries()
123 |
124 | s.Add(MakeValue(30, 123))
125 | start := ticker.start(XAxis, s, 10)
126 |
127 | x.Assert(start > min, "start > min")
128 | x.Assert(start < max, "start < max")
129 | }
130 |
131 | func TestValueTickerStep_Log(t *testing.T) {
132 | x := xt.X(t)
133 |
134 | min := 12.34
135 | max := 56.78
136 |
137 | m := New(100, 100, WithRange(
138 | XAxis,
139 | min,
140 | max,
141 | ), WithProjection(XAxis, Log))
142 | ticker := m.ValueTicker('f', 0, 10)
143 | s := NewSeries()
144 |
145 | s.Add(MakeValue(30, 123))
146 | step := ticker.start(XAxis, s, 10)
147 |
148 | more := true
149 | count := 0
150 | for ; step <= max && more; step, more = ticker.next(step) {
151 | count++
152 | }
153 |
154 | // There are 4 base 10 marks in the range [20.0, 50.0]
155 | x.Equal(count, 4)
156 | x.Assert(more)
157 | }
158 |
159 | func TestValueTickerSimpleRange(t *testing.T) {
160 | x := xt.X(t)
161 |
162 | s := NewSeries()
163 | s.Add(MakeValue(1, 0))
164 | s.Add(MakeValue(2, 0))
165 | s.Add(MakeValue(3, 0))
166 |
167 | max := 1.0
168 | m := New(100, 100, WithAutorange(XAxis, s), WithAutorange(YAxis, s))
169 | ticker := m.ValueTicker('f', 0, 10)
170 |
171 | step := ticker.start(YAxis, s, 10)
172 |
173 | more := true
174 | count := 0
175 | for ; step <= max && more; step, more = ticker.next(step) {
176 | count++
177 | }
178 |
179 | // There are 5+1+5 base 10 marks in the range [-1.0, 1.0]
180 | x.Equal(count, 11)
181 | x.Assert(more)
182 | }
183 |
--------------------------------------------------------------------------------
/xt/testing.go:
--------------------------------------------------------------------------------
1 | package xt
2 |
3 | import "testing"
4 |
5 | // XT is a testing.T - extension, adding a tiny bit
6 | // of convenience, making tests more fun to write.
7 | type XT struct {
8 | *testing.T
9 | }
10 |
11 | // X wraps a *testing.T and extends its functionality
12 | func X(t *testing.T) XT {
13 | return XT{t}
14 | }
15 |
16 | // Assert verifies that a condition is true
17 | func (x XT) Assert(cond bool, msg ...interface{}) {
18 | if !cond {
19 | x.Log(msg...)
20 | x.Fail()
21 | }
22 | }
23 |
24 | // True verifies that a condition is true
25 | func (x XT) True(cond bool, msg ...interface{}) {
26 | if !cond {
27 | x.Log(msg...)
28 | x.Fail()
29 | }
30 | }
31 |
32 | // False verifies that a condition is false
33 | func (x XT) False(cond bool, msg ...interface{}) {
34 | if cond {
35 | x.Log(msg...)
36 | x.Fail()
37 | }
38 | }
39 |
40 | // Equal verifies that the two arguments are equal
41 | func (x XT) Equal(a interface{}, b interface{}, msg ...interface{}) {
42 | if a != b {
43 | x.Logf("%v should equal %v", a, b)
44 | x.Log(msg...)
45 | x.Fail()
46 | }
47 | }
48 |
49 | // NotEqual verifies that the two arguments are not equal
50 | func (x XT) NotEqual(a interface{}, b interface{}, msg ...interface{}) {
51 | if a == b {
52 | x.Logf("%v should not equal %v", a, b)
53 | x.Log(msg...)
54 | x.Fail()
55 | }
56 | }
57 |
58 | // Nil verifies that the argument is nil
59 | func (x XT) Nil(a interface{}, msg ...interface{}) {
60 | if a != nil {
61 | x.Log(msg...)
62 | x.Fail()
63 | }
64 | }
65 |
66 | // NotNil verifies that the argument is not nil
67 | func (x XT) NotNil(a interface{}, msg ...interface{}) {
68 | if a == nil {
69 | x.Log(msg...)
70 | x.Fail()
71 | }
72 | }
73 |
--------------------------------------------------------------------------------