-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathmargaid.go
More file actions
382 lines (321 loc) · 8.57 KB
/
Copy pathmargaid.go
File metadata and controls
382 lines (321 loc) · 8.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
package margaid
import (
"fmt"
"io"
"math"
"github.com/erkkah/margaid/brackets"
"github.com/erkkah/margaid/svg"
)
// Margaid == diagraM
type Margaid struct {
g *svg.SVG
width float64
height float64
inset float64
padding float64 // padding [0..1]
projections map[Axis]Projection
ranges map[Axis]minmax
plots []string
background string
colorScheme int
titleFamily string
titleSize int
labelFamily string
labelSize int
}
const (
defaultPadding = 0
defaultInset = 64
tickDistance = 55
tickSize = 6
textSpacing = 4
)
// minmax is the range [min, max] of a chart axis
type minmax struct{ min, max float64 }
// Option is the base type for all series options
type Option func(*Margaid)
// New - Margaid constructor
func New(width, height int, options ...Option) *Margaid {
defaultRange := minmax{0, 100}
self := &Margaid{
inset: defaultInset,
width: float64(width),
height: float64(height),
padding: defaultPadding,
projections: map[Axis]Projection{
X1Axis: Lin,
X2Axis: Lin,
Y1Axis: Lin,
Y2Axis: Lin,
},
ranges: map[Axis]minmax{
X1Axis: defaultRange,
Y1Axis: defaultRange,
X2Axis: defaultRange,
Y2Axis: defaultRange,
},
background: "transparent",
colorScheme: 198,
titleFamily: "sans-serif",
titleSize: 18,
labelFamily: "sans-serif",
labelSize: 12,
}
for _, o := range options {
o(self)
}
self.g = svg.New(width, height, self.background)
return self
}
/// Options
// Projection is the type for the projection constants
type Projection int
// Projection constants
const (
Lin Projection = iota + 'p'
Log
)
// WithProjection sets the projection for a given axis
func WithProjection(axis Axis, proj Projection) Option {
return func(m *Margaid) {
m.projections[axis] = proj
}
}
// WithRange sets a fixed plotting range for a given axis
func WithRange(axis Axis, min, max float64) Option {
return func(m *Margaid) {
m.ranges[axis] = minmax{min, max}
}
}
// WithAutorange sets range for an axis from the values of one or more series
func WithAutorange(axis Axis, series ...*Series) Option {
return func(m *Margaid) {
var axisRange minmax
for idx, s := range series {
var newAxisRange minmax
if axis == X1Axis || axis == X2Axis {
newAxisRange = minmax{
s.MinX(),
s.MaxX(),
}
}
if axis == Y1Axis || axis == Y2Axis {
newAxisRange = minmax{
s.MinY(),
s.MaxY(),
}
}
if idx == 0 {
axisRange = newAxisRange
} else {
axisRange = minmax{
math.Min(axisRange.min, newAxisRange.min),
math.Max(axisRange.max, newAxisRange.max),
}
}
}
if axisRange.min == axisRange.max {
axisRange.min -= 1.0
axisRange.max += 1.0
}
m.ranges[axis] = axisRange
}
}
// WithInset sets the distance between the chart boundaries and the
// charting area.
func WithInset(inset float64) Option {
return func(m *Margaid) {
m.inset = inset
}
}
// WithPadding sets the padding inside the plotting area as a percentage
// [0..20] of the area width and height
func WithPadding(padding float64) Option {
return func(m *Margaid) {
factor := padding / 100
m.padding = math.Max(0, math.Min(0.20, factor))
}
}
// WithBackgroundColor sets the chart background color as a valid SVG
// color attribute string. Default is transparent.
func WithBackgroundColor(background string) Option {
return func(m *Margaid) {
m.background = background
}
}
// WithColorScheme sets the start color for selecting plot colors.
// The start color is selected as a hue value between 0 and 359.
func WithColorScheme(scheme int) Option {
return func(m *Margaid) {
m.colorScheme = scheme % 360
}
}
// WithTitleFont sets title font family and size in pixels
func WithTitleFont(family string, size int) Option {
return func(m *Margaid) {
m.titleFamily = family
m.titleSize = size
}
}
// WithLabelFont sets label font family and size in pixels
func WithLabelFont(family string, size int) Option {
return func(m *Margaid) {
m.labelFamily = family
m.labelSize = size
}
}
/// Drawing
// Title draws a title top center
func (m *Margaid) Title(title string) {
encoded := svg.EncodeText(title, svg.HAlignMiddle)
m.g.
Font(m.titleFamily, fmt.Sprintf("%dpx", m.titleSize)).
FontStyle(svg.StyleNormal, svg.WeightBold).
Alignment(svg.HAlignMiddle, svg.VAlignCentral).
Transform().
Fill("black").
Text(m.width/2, m.inset/2, encoded)
}
// LegendPosition decides where to draw the legend
type LegendPosition int
// LegendPosition constants
const (
RightTop LegendPosition = iota + 'l'
RightBottom
BottomLeft
)
// Legend draws a legend for named plots. If position is set to BottomLeft, it
// will grow the plot size to accommodate the number of legends displayed.
func (m *Margaid) Legend(position LegendPosition) {
type namedPlot struct {
name string
color string
}
var plots []namedPlot
for i, label := range m.plots {
if label != "" {
color := m.getPlotColor(i)
plots = append(plots, namedPlot{
name: label,
color: color,
})
}
}
boxSize := float64(m.labelSize)
lineHeight := float64(m.labelSize) * 1.5
listStartX := 0.0
listStartY := 0.0
switch position {
case RightTop:
listStartX = m.width - m.inset + boxSize + textSpacing
listStartY = m.inset + 0.5*boxSize
case RightBottom:
listStartX = m.width - m.inset + boxSize + textSpacing
listStartY = m.height - m.inset - lineHeight*float64(len(plots))
case BottomLeft:
listStartX = m.inset + 0.5*boxSize
listStartY = m.height - m.inset + lineHeight + boxSize + tickSize
}
style := func(color string) {
m.g.
Font(m.labelFamily, fmt.Sprintf("%dpx", m.labelSize)).
FontStyle(svg.StyleNormal, svg.WeightNormal).
Alignment(svg.HAlignStart, svg.VAlignTop).
Color(color).
StrokeWidth("1px")
}
for i, plot := range plots {
floatIndex := float64(i)
yPos := listStartY + floatIndex*lineHeight
xPos := listStartX
style(plot.color)
m.g.Rect(xPos, yPos, boxSize, boxSize)
style("black")
m.g.Text(xPos+boxSize+textSpacing, yPos, brackets.XMLEscape(plot.name))
}
if position == BottomLeft {
newHeight := int(m.height + lineHeight*float64(len(plots)))
m.g.SetSize(int(m.width), newHeight)
}
}
func (m *Margaid) error(message string) {
m.g.
Font(m.titleFamily, fmt.Sprintf("%dpx", m.titleSize)).
FontStyle(svg.StyleItalic, svg.WeightBold).
Alignment(svg.HAlignStart, svg.VAlignCentral).
Transform().
StrokeWidth("0").Fill("red").
Text(5, m.inset/2, brackets.XMLEscape(message))
}
// Frame draws a frame around the chart area
func (m *Margaid) Frame() {
m.g.Transform()
m.g.Fill("none").Stroke("black").StrokeWidth("2px")
m.g.Rect(m.inset, m.inset, m.width-m.inset*2, m.height-m.inset*2)
}
// Render renders the graph to the given destination.
func (m *Margaid) Render(writer io.Writer) error {
rendered := m.g.Render()
_, err := writer.Write([]byte(rendered))
return err
}
// Projects a value onto an axis using the current projection
// setting.
// The value returned is in user coordinates, [0..1] * width for the x-axis.
func (m *Margaid) project(value float64, axis Axis) (float64, error) {
min := m.ranges[axis].min
max := m.ranges[axis].max
projected := value
projection := m.projections[axis]
var axisLength float64
switch {
case axis == X1Axis || axis == X2Axis:
axisLength = m.width - 2*m.inset
case axis == Y1Axis || axis == Y2Axis:
axisLength = m.height - 2*m.inset
}
axisPadding := m.padding * axisLength
if projection == Log {
if value <= 0 {
return 0, fmt.Errorf("cannot draw values <= 0 on log scale")
}
if min <= 0 || max <= 0 {
return 0, fmt.Errorf("cannot have axis range <= 0 on log scale")
}
projected = math.Log10(value)
min = math.Log10(min)
max = math.Log10(max)
}
projected = axisPadding + (axisLength-2*axisPadding)*(projected-min)/(max-min)
return projected, nil
}
func (m *Margaid) getProjectedValues(series *Series, xAxis, yAxis Axis) (points []struct{ X, Y float64 }, err error) {
values := series.Values()
for values.Next() {
v := values.Get()
v.X, err = m.project(v.X, xAxis)
if err != nil {
return
}
v.Y, err = m.project(v.Y, yAxis)
if err != nil {
return
}
points = append(points, v)
}
return
}
// addPlot adds a named plot and returns its ID
func (m *Margaid) addPlot(name string) int {
id := len(m.plots)
m.plots = append(m.plots, name)
return id
}
// getPlotColor picks hues and saturations around the color wheel at prime indices.
// Kind of works for a quick selection of plotting colors.
func (m *Margaid) getPlotColor(id int) string {
color := 211*id + m.colorScheme
hue := color % 360
saturation := 47 + (id*41)%53
return fmt.Sprintf("hsl(%d, %d%%, 65%%)", hue, saturation)
}