├── .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 | ![Minimal plot](example/minimal.svg) 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 | ![Example plot](example/example.svg) 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(), `</head>`) 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 | <svg width="800" height="600" viewbox="0 0 800 600" style="background-color:white" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg"><defs><marker markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%" id="circle" viewBox="0 0 10 10 " refX="5" refY="5"><circle cx="5" cy="5" r="3" fill="none" stroke="black"/></marker><marker id="filled-circle" viewBox="0 0 10 10 " refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%"><circle fill="black" stroke="none" cx="5" cy="5" r="3"/></marker><marker refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%" id="square" viewBox="0 0 10 10 "><rect x="2" y="2" width="6" height="6" fill="none" stroke="black"/></marker><marker refY="5" markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%" id="filled-square" viewBox="0 0 10 10 " refX="5"><rect height="6" fill="black" stroke="none" x="2" y="2" width="6"/></marker></defs><g stroke-width="1px" stroke-linecap="round" stroke-linejoin="round" transform="translate(80 520 )scale(1 -1 )" fill="hsl(198, 47%, 65%)" stroke="hsl(198, 47%, 65%)"><rect x="8.770000e+01" y="0" width="2.750000e+01" height="5.505280e+01" vector-effect="non-scaling-stroke"/><rect height="8.624000e+01" vector-effect="non-scaling-stroke" x="2.105800e+02" y="0" width="2.750000e+01"/><rect height="3.741760e+02" vector-effect="non-scaling-stroke" x="4.973000e+02" y="0" width="2.750000e+01"/></g><g stroke="hsl(49, 88%, 65%)" stroke-width="1px" stroke-linecap="round" stroke-linejoin="round" transform="translate(80 520 )scale(1 -1 )" fill="hsl(49, 88%, 65%)"><rect x="1.152000e+02" y="0" width="2.750000e+01" height="4.618240e+01" vector-effect="non-scaling-stroke"/><rect y="0" width="2.750000e+01" height="1.953600e+02" vector-effect="non-scaling-stroke" x="2.380800e+02"/><rect vector-effect="non-scaling-stroke" x="5.248000e+02" y="0" width="2.750000e+01" height="3.541120e+02"/></g><g stroke-width="1px" dominant-baseline="hanging" font-weight="normal" stroke="hsl(198, 47%, 65%)" stroke-linejoin="round" font-size="12px" font-family="sans-serif" font-style="normal" text-anchor="start" fill="hsl(198, 47%, 65%)" stroke-linecap="round"><rect width="12" height="12" vector-effect="non-scaling-stroke" x="736" y="484"/><g stroke="black" fill="black"><text y="484" dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="752">Team A</text></g><g font-size="12px" fill="hsl(49, 88%, 65%)" stroke-linecap="round" text-anchor="start" stroke-width="1px" font-style="normal" stroke-linejoin="round" stroke="hsl(49, 88%, 65%)" dominant-baseline="hanging" font-family="sans-serif" font-weight="normal"><rect height="12" vector-effect="non-scaling-stroke" x="736" y="502" width="12"/><g stroke="black" fill="black"><text stroke="none" vector-effect="non-scaling-stroke" x="752" y="502" dominant-baseline="hanging">Team B</text><g text-anchor="start" stroke-width="2px" font-style="normal" dominant-baseline="hanging" font-family="sans-serif" stroke-linecap="round" font-size="12px" font-weight="normal" fill="none" stroke-linejoin="round"><rect x="80" y="80" width="640" height="440" vector-effect="non-scaling-stroke"/></g><g stroke-width="2px" font-family="sans-serif" font-style="normal" text-anchor="start" dominant-baseline="hanging" stroke="black" stroke-linejoin="round" font-size="12px" transform="translate(80 520 )scale(1 -1 )" fill="none" stroke-linecap="round" font-weight="normal"><path d="M1.152000e+02,0 L1.152000e+02,-6 M2.380800e+02,0 L2.380800e+02,-6 M5.248000e+02,0 L5.248000e+02,-6 " vector-effect="non-scaling-stroke"/></g><g text-anchor="middle" fill="black" stroke-linecap="round" font-size="12px" transform="translate(80 520 )scale(1 1 )" stroke-width="2px" font-family="sans-serif" font-style="normal" font-weight="normal" stroke="black" stroke-linejoin="round" dominant-baseline="hanging"><text dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="1.152000e+02" y="10">10.0</text><text y="10" dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="2.380800e+02">34.0</text><text dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="5.248000e+02" y="10">90.0</text></g><g stroke="black" dominant-baseline="baseline" transform="translate(80 520 )scale(1 1 )rotate(0 0 0 )" stroke-width="2px" font-size="12px" font-weight="bold" stroke-linejoin="round" font-family="sans-serif" font-style="normal" text-anchor="middle" fill="black" stroke-linecap="round"><text vector-effect="non-scaling-stroke" x="320" y="-6" dominant-baseline="baseline" stroke="none">Lemmings</text></g><g dominant-baseline="baseline" stroke-width="2px" font-family="sans-serif" font-style="normal" stroke-linejoin="round" font-size="12px" font-weight="bold" text-anchor="middle" fill="black" stroke="black" stroke-linecap="round" transform="translate(720 520 )scale(1 -1 )"><path vector-effect="non-scaling-stroke" d="M0,5.505280e+01 L6,5.505280e+01 M0,8.624000e+01 L6,8.624000e+01 M0,3.741760e+02 L6,3.741760e+02 "/></g><g font-size="12px" transform="translate(720 520 )scale(1 1 )" stroke-width="2px" fill="black" stroke-linecap="round" stroke-linejoin="round" font-style="normal" text-anchor="start" dominant-baseline="middle" font-weight="normal" stroke="black" font-family="sans-serif"><text dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke" x="10" y="-5.505280e+01">3.1</text><text stroke="none" vector-effect="non-scaling-stroke" x="10" y="-8.624000e+01" dominant-baseline="middle">12.0</text><text stroke="none" vector-effect="non-scaling-stroke" x="10" y="-3.741760e+02" dominant-baseline="middle">93.8</text></g><g stroke-linejoin="round" transform="translate(720 520 )scale(1 -1 )" font-family="sans-serif" font-style="normal" text-anchor="start" font-size="12px" font-weight="normal" stroke-linecap="round" stroke-width="0.5px" fill="black" stroke="gray" dominant-baseline="middle"><path vector-effect="non-scaling-stroke" d="M0,5.505280e+01 L-640,5.505280e+01 M0,8.624000e+01 L-640,8.624000e+01 M0,3.741760e+02 L-640,3.741760e+02 "/></g><g stroke="black" stroke-linecap="round" transform="translate(80 520 )scale(1 -1 )" font-style="normal" text-anchor="start" font-size="12px" fill="black" dominant-baseline="middle" stroke-width="2px" font-family="sans-serif" font-weight="normal" stroke-linejoin="round"><path vector-effect="non-scaling-stroke" d="M0,4.618240e+01 L-6,4.618240e+01 M0,1.953600e+02 L-6,1.953600e+02 M0,3.541120e+02 L-6,3.541120e+02 "/></g><g stroke-linecap="round" font-weight="normal" transform="translate(80 520 )scale(1 1 )" text-anchor="end" dominant-baseline="middle" stroke-width="2px" stroke-linejoin="round" font-size="12px" fill="black" stroke="black" font-style="normal" font-family="sans-serif"><text vector-effect="non-scaling-stroke" x="-10" y="-4.618240e+01" dominant-baseline="middle" stroke="none">0.6</text><text stroke="none" vector-effect="non-scaling-stroke" x="-10" y="-1.953600e+02" dominant-baseline="middle">43.0</text><text dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke" x="-10" y="-3.541120e+02">88.1</text></g><g stroke-linejoin="round" font-size="12px" fill="black" font-family="sans-serif" text-anchor="end" transform="translate(80 520 )scale(1 -1 )" stroke="gray" stroke-linecap="round" font-weight="normal" font-style="normal" dominant-baseline="middle" stroke-width="0.5px"><path vector-effect="non-scaling-stroke" d="M0,4.618240e+01 L640,4.618240e+01 M0,1.953600e+02 L640,1.953600e+02 M0,3.541120e+02 L640,3.541120e+02 "/></g></g></g></g></svg> -------------------------------------------------------------------------------- /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 | <svg preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewbox="0 0 800 600" style="background-color:white"><defs><marker viewBox="0 0 10 10 " refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%" id="circle"><circle stroke="black" cx="5" cy="5" r="3" fill="none"/></marker><marker viewBox="0 0 10 10 " refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%" id="filled-circle"><circle cx="5" cy="5" r="3" fill="black" stroke="none"/></marker><marker markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%" id="square" viewBox="0 0 10 10 " refX="5" refY="5"><rect width="6" height="6" fill="none" stroke="black" x="2" y="2"/></marker><marker viewBox="0 0 10 10 " refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%" id="filled-square"><rect stroke="none" x="2" y="2" width="6" height="6" fill="black"/></marker></defs><g stroke-width="1px" marker-end="url(#square)" fill="none" stroke="hsl(90, 47%, 65%)" stroke-linecap="round" stroke-linejoin="round" marker-start="url(#square)" transform="translate(70 530 )scale(1 -1 )" marker-mid="url(#square)"><path vector-effect="non-scaling-stroke" d="M1.320000e+01,9.200000e+00 L8.360000e+01,5.826667e+01 L154,1.073333e+02 L2.244000e+02,1.564000e+02 L2.948000e+02,2.054667e+02 L3.652000e+02,2.545333e+02 L4.356000e+02,3.036000e+02 L5.060000e+02,3.526667e+02 L5.764000e+02,4.017333e+02 L6.468000e+02,4.508000e+02 "/></g><g fill="none" stroke-linecap="round" stroke-linejoin="round" transform="translate(70 530 )scale(1 -1 )" stroke="hsl(301, 88%, 65%)" stroke-width="3.14px"><path vector-effect="non-scaling-stroke" d="M1.320000e+01,9.200000e+00 C2.493333e+01,9.302057e+00 6.013333e+01,9.495966e+00 8.360000e+01,9.812344e+00 C1.070667e+02,1.012872e+01 1.305333e+02,1.043387e+01 1.540000e+02,1.109827e+01 C1.774667e+02,1.176266e+01 2.009333e+02,1.240348e+01 2.244000e+02,1.379870e+01 C2.478667e+02,1.519393e+01 2.713333e+02,1.653964e+01 2.948000e+02,1.946962e+01 C3.182667e+02,2.239959e+01 3.417333e+02,2.522559e+01 3.652000e+02,3.137854e+01 C3.886667e+02,3.753148e+01 4.121333e+02,4.346609e+01 4.356000e+02,5.638727e+01 C4.590667e+02,6.930845e+01 4.825333e+02,8.177113e+01 5.060000e+02,1.089056e+02 C5.294667e+02,1.360401e+02 5.529333e+02,1.622117e+02 5.764000e+02,2.191941e+02 C5.998667e+02,2.761765e+02 6.350667e+02,4.121990e+02 6.468000e+02,4.508000e+02 "/></g><g stroke="hsl(152, 76%, 65%)" stroke-width="3px" marker-start="url(#filled-circle)" transform="translate(70 530 )scale(1 -1 )" marker-mid="url(#filled-circle)" marker-end="url(#filled-circle)" fill="none" stroke-linecap="round" stroke-linejoin="round"><path vector-effect="non-scaling-stroke" d="M1.320000e+01,3.256657e+02 C2.493333e+01,3.338016e+02 6.013333e+01,3.757549e+02 8.360000e+01,3.744809e+02 C1.070667e+02,3.732068e+02 1.305333e+02,3.188346e+02 1.540000e+02,3.180213e+02 C1.774667e+02,3.172081e+02 2.009333e+02,3.797784e+02 2.244000e+02,3.696016e+02 C2.478667e+02,3.594248e+02 2.713333e+02,2.564637e+02 2.948000e+02,2.569603e+02 C3.182667e+02,2.574570e+02 3.417333e+02,3.571327e+02 3.652000e+02,3.725814e+02 C3.886667e+02,3.880301e+02 4.121333e+02,3.598654e+02 4.356000e+02,3.496526e+02 C4.590667e+02,3.394398e+02 4.825333e+02,3.183902e+02 5.060000e+02,3.113047e+02 C5.294667e+02,3.042192e+02 5.529333e+02,2.988646e+02 5.764000e+02,3.071394e+02 C5.998667e+02,3.154143e+02 6.350667e+02,3.519849e+02 6.468000e+02,3.609540e+02 "/></g><g stroke-width="2px" fill="none" stroke-linecap="round" stroke-linejoin="round" transform="translate(70 530 )scale(1 -1 )" stroke="black"><path d="M1.320000e+01,0 L1.320000e+01,-6 M8.360000e+01,0 L8.360000e+01,-6 M154,0 L154,-6 M2.244000e+02,0 L2.244000e+02,-6 M2.948000e+02,0 L2.948000e+02,-6 M3.652000e+02,0 L3.652000e+02,-6 M4.356000e+02,0 L4.356000e+02,-6 M5.060000e+02,0 L5.060000e+02,-6 M5.764000e+02,0 L5.764000e+02,-6 M6.468000e+02,0 L6.468000e+02,-6 " vector-effect="non-scaling-stroke"/></g><g transform="translate(70 530 )scale(1 1 )" stroke="black" font-family="sans-serif" stroke-linejoin="round" font-size="12px" dominant-baseline="hanging" stroke-width="2px" font-style="normal" font-weight="normal" fill="black" stroke-linecap="round" text-anchor="middle"><text stroke="none" vector-effect="non-scaling-stroke" x="1.320000e+01" y="10" dominant-baseline="hanging">1</text><text dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="8.360000e+01" y="10">2</text><text stroke="none" vector-effect="non-scaling-stroke" x="154" y="10" dominant-baseline="hanging">3</text><text stroke="none" vector-effect="non-scaling-stroke" x="2.244000e+02" y="10" dominant-baseline="hanging">4</text><text y="10" dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="2.948000e+02">5</text><text vector-effect="non-scaling-stroke" x="3.652000e+02" y="10" dominant-baseline="hanging" stroke="none">6</text><text x="4.356000e+02" y="10" dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke">7</text><text y="10" dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="5.060000e+02">8</text><text dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="5.764000e+02" y="10">9</text><text vector-effect="non-scaling-stroke" x="6.468000e+02" y="10" dominant-baseline="hanging" stroke="none">10</text></g><g stroke-linecap="round" text-anchor="middle" dominant-baseline="baseline" stroke="black" font-family="sans-serif" font-size="12px" fill="black" font-style="normal" stroke-width="2px" transform="translate(70 530 )scale(1 1 )rotate(0 0 0 )" font-weight="bold" stroke-linejoin="round"><text stroke="none" vector-effect="non-scaling-stroke" x="330" y="-6" dominant-baseline="baseline">X</text></g><g stroke-width="2px" font-size="12px" fill="black" stroke-linecap="round" font-family="sans-serif" text-anchor="middle" dominant-baseline="baseline" transform="translate(70 530 )scale(1 -1 )" font-weight="bold" font-style="normal" stroke="black" stroke-linejoin="round"><path vector-effect="non-scaling-stroke" d="M0,3.345270e+01 L-6,3.345270e+01 M0,7.929272e+01 L-6,7.929272e+01 M0,1.251327e+02 L-6,1.251327e+02 M0,1.709728e+02 L-6,1.709728e+02 M0,2.168128e+02 L-6,2.168128e+02 M0,2.626528e+02 L-6,2.626528e+02 M0,3.084928e+02 L-6,3.084928e+02 M0,3.543328e+02 L-6,3.543328e+02 M0,4.001729e+02 L-6,4.001729e+02 M0,4.460129e+02 L-6,4.460129e+02 "/></g><g transform="translate(70 530 )scale(1 1 )" font-size="12px" font-weight="normal" fill="black" font-family="sans-serif" font-style="normal" dominant-baseline="middle" stroke-linejoin="round" stroke-linecap="round" text-anchor="end" stroke="black" stroke-width="2px"><text vector-effect="non-scaling-stroke" x="-10" y="-3.345270e+01" dominant-baseline="middle" stroke="none">1.0</text><text x="-10" y="-7.929272e+01" dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke">2.0</text><text x="-10" y="-1.251327e+02" dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke">4.0</text><text x="-10" y="-1.709728e+02" dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke">8.0</text><text x="-10" y="-2.168128e+02" dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke">16.0</text><text stroke="none" vector-effect="non-scaling-stroke" x="-10" y="-2.626528e+02" dominant-baseline="middle">32.0</text><text dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke" x="-10" y="-3.084928e+02">64.0</text><text dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke" x="-10" y="-3.543328e+02">128.0</text><text x="-10" y="-4.001729e+02" dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke">256.0</text><text x="-10" y="-4.460129e+02" dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke">512.0</text></g><g dominant-baseline="hanging" font-family="sans-serif" font-style="normal" stroke-linejoin="round" transform="translate(70 530 )scale(1 1 )rotate(-90 0 0 )" font-size="12px" stroke="black" stroke-width="2px" text-anchor="middle" font-weight="bold" fill="black" stroke-linecap="round"><text vector-effect="non-scaling-stroke" x="230" y="6" dominant-baseline="hanging" stroke="none">Y</text></g><g font-style="normal" text-anchor="middle" stroke="gray" font-weight="bold" fill="black" stroke-linecap="round" font-family="sans-serif" dominant-baseline="hanging" stroke-width="0.5px" stroke-linejoin="round" transform="translate(70 530 )scale(1 -1 )" font-size="12px"><path d="M0,3.345270e+01 L660,3.345270e+01 M0,7.929272e+01 L660,7.929272e+01 M0,1.251327e+02 L660,1.251327e+02 M0,1.709728e+02 L660,1.709728e+02 M0,2.168128e+02 L660,2.168128e+02 M0,2.626528e+02 L660,2.626528e+02 M0,3.084928e+02 L660,3.084928e+02 M0,3.543328e+02 L660,3.543328e+02 M0,4.001729e+02 L660,4.001729e+02 M0,4.460129e+02 L660,4.460129e+02 " vector-effect="non-scaling-stroke"/></g><g font-style="normal" font-family="sans-serif" text-anchor="middle" dominant-baseline="hanging" stroke="black" stroke-linecap="round" stroke-linejoin="round" font-size="12px" stroke-width="2px" fill="none" font-weight="bold"><rect vector-effect="non-scaling-stroke" x="70" y="70" width="660" height="460"/><g font-size="18px" fill="black" dominant-baseline="middle"><text x="400" y="35" dominant-baseline="middle" stroke="none" vector-effect="non-scaling-stroke">A diagram of sorts 📊 📈</text></g></g></svg> -------------------------------------------------------------------------------- /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 | <svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewbox="0 0 800 600" style="background-color:white" preserveAspectRatio="xMidYMid meet"><defs><marker refY="5" markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%" id="circle" viewBox="0 0 10 10 " refX="5"><circle cx="5" cy="5" r="3" fill="none" stroke="black"/></marker><marker id="filled-circle" viewBox="0 0 10 10 " refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%"><circle cx="5" cy="5" r="3" fill="black" stroke="none"/></marker><marker viewBox="0 0 10 10 " refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="2%" markerHeight="2%" id="square"><rect height="6" fill="none" stroke="black" x="2" y="2" width="6"/></marker><marker markerWidth="2%" markerHeight="2%" id="filled-square" viewBox="0 0 10 10 " refX="5" refY="5" markerUnits="userSpaceOnUse"><rect fill="black" stroke="none" x="2" y="2" width="6" height="6"/></marker></defs><g stroke-linejoin="round" transform="translate(64 536 )scale(1 -1 )" fill="none" stroke="hsl(198, 47%, 65%)" stroke-width="3px" stroke-linecap="round"><path vector-effect="non-scaling-stroke" d="M6.720000e+01,1.482080e+01 L6.048000e+02,4.427360e+02 "/></g><g stroke-width="2px" stroke-linecap="round" stroke-linejoin="round" fill="none" stroke="black"><rect x="64" y="64" width="672" height="472" vector-effect="non-scaling-stroke"/></g><g stroke-linejoin="round" transform="translate(64 536 )scale(1 -1 )" fill="none" stroke="black" stroke-width="2px" stroke-linecap="round"><path vector-effect="non-scaling-stroke" d="M0,0 L0,-6 M6.720000e+01,0 L6.720000e+01,-6 M1.344000e+02,0 L1.344000e+02,-6 M2.016000e+02,0 L2.016000e+02,-6 M2.688000e+02,0 L2.688000e+02,-6 M336,0 L336,-6 M4.032000e+02,0 L4.032000e+02,-6 M4.704000e+02,0 L4.704000e+02,-6 M5.376000e+02,0 L5.376000e+02,-6 M6.048000e+02,0 L6.048000e+02,-6 M672,0 L672,-6 "/></g><g font-family="sans-serif" font-size="12px" stroke-width="2px" font-style="normal" stroke-linecap="round" transform="translate(64 536 )scale(1 1 )" dominant-baseline="hanging" stroke="black" stroke-linejoin="round" font-weight="normal" text-anchor="middle" fill="black"><text dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="0" y="10">0.00</text><text stroke="none" vector-effect="non-scaling-stroke" x="6.720000e+01" y="10" dominant-baseline="hanging">10.00</text><text stroke="none" vector-effect="non-scaling-stroke" x="1.344000e+02" y="10" dominant-baseline="hanging">20.00</text><text x="2.016000e+02" y="10" dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke">30.00</text><text x="2.688000e+02" y="10" dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke">40.00</text><text x="336" y="10" dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke">50.00</text><text vector-effect="non-scaling-stroke" x="4.032000e+02" y="10" dominant-baseline="hanging" stroke="none">60.00</text><text dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="4.704000e+02" y="10">70.00</text><text x="5.376000e+02" y="10" dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke">80.00</text><text stroke="none" vector-effect="non-scaling-stroke" x="6.048000e+02" y="10" dominant-baseline="hanging">90.00</text><text dominant-baseline="hanging" stroke="none" vector-effect="non-scaling-stroke" x="672" y="10">100.00</text></g><g font-style="normal" stroke="black" stroke-linejoin="round" font-family="sans-serif" font-weight="bold" text-anchor="middle" transform="translate(64 536 )scale(1 1 )rotate(0 0 0 )" font-size="12px" dominant-baseline="baseline" stroke-width="2px" fill="black" stroke-linecap="round"><text stroke="none" vector-effect="non-scaling-stroke" x="336" y="-6" dominant-baseline="baseline">Values</text></g></svg> -------------------------------------------------------------------------------- /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 <text> 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(`<tspan dx="-%fex" dy="1em">%s</tspan>`, 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 | --------------------------------------------------------------------------------