svg-graph-test 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,636 @@
1
+ require_relative 'Graph'
2
+ require_relative 'DataPoint'
3
+
4
+ module SVG
5
+ module Graph
6
+ # === For creating SVG plots of scalar data
7
+ #
8
+ # = Synopsis
9
+ #
10
+ # require 'SVG/Graph/Plot'
11
+ #
12
+ # # Data sets are x,y pairs
13
+ # # Note that multiple data sets can differ in length, and that the
14
+ # # data in the datasets needn't be in order; they will be ordered
15
+ # # by the plot along the X-axis.
16
+ # projection = [
17
+ # 6, 11, 0, 5, 18, 7, 1, 11, 13, 9, 1, 2, 19, 0, 3, 13,
18
+ # 7, 9
19
+ # ]
20
+ # actual = [
21
+ # 0, 18, 8, 15, 9, 4, 18, 14, 10, 2, 11, 6, 14, 12,
22
+ # 15, 6, 4, 17, 2, 12
23
+ # ]
24
+ #
25
+ # graph = SVG::Graph::Plot.new({
26
+ # :height => 500,
27
+ # :width => 300,
28
+ # :key => true,
29
+ # :scale_x_integers => true,
30
+ # :scale_y_integers => true,
31
+ # })
32
+ #
33
+ # graph.add_data({
34
+ # :data => projection
35
+ # :title => 'Projected',
36
+ # })
37
+ #
38
+ # graph.add_data({
39
+ # :data => actual,
40
+ # :title => 'Actual',
41
+ # })
42
+ #
43
+ # print graph.burn()
44
+ #
45
+ # = Description
46
+ #
47
+ # Produces a graph of scalar data.
48
+ #
49
+ # This object aims to allow you to easily create high quality
50
+ # SVG[http://www.w3c.org/tr/svg] scalar plots. You can either use the
51
+ # default style sheet or supply your own. Either way there are many options
52
+ # which can be configured to give you control over how the graph is
53
+ # generated - with or without a key, data elements at each point, title,
54
+ # subtitle etc.
55
+ #
56
+ # = Examples
57
+ #
58
+ # https://github.com/lumean/svg-graph2/blob/master/examples/plot.rb
59
+ #
60
+ # = Notes
61
+ #
62
+ # The default stylesheet handles upto 12 data sets, if you
63
+ # use more you must create your own stylesheet and add the
64
+ # additional settings for the extra data sets. You will know
65
+ # if you go over 12 data sets as they will have no style and
66
+ # be in black.
67
+ #
68
+ # Unlike the other types of charts, data sets must contain x,y pairs:
69
+ #
70
+ # [ 1,2 ] # A data set with 1 point: (1,2)
71
+ # [ 1,2, 5,6] # A data set with 2 points: (1,2) and (5,6)
72
+ # Additional possible notation
73
+ # [ [1,2], 5,6] # A data set with 2 points: (1,2) and (5,6), mixed notation
74
+ # [ [1,2], [5,6]] # A data set with 2 points: (1,2) and (5,6), nested array
75
+ #
76
+ # = See also
77
+ #
78
+ # * SVG::Graph::Graph
79
+ # * SVG::Graph::BarHorizontal
80
+ # * SVG::Graph::Bar
81
+ # * SVG::Graph::Line
82
+ # * SVG::Graph::Pie
83
+ # * SVG::Graph::TimeSeries
84
+ #
85
+ # == Author
86
+ #
87
+ # Sean E. Russell <serATgermaneHYPHENsoftwareDOTcom>
88
+ #
89
+ # Copyright 2004 Sean E. Russell
90
+ # This software is available under the Ruby license[LICENSE.txt]
91
+ #
92
+ class Plot < Graph
93
+
94
+ # In addition to the defaults set by Graph::initialize, sets
95
+ # [show_data_points] true
96
+ # [area_fill] false
97
+ # [stacked] false, will not have any effect if true
98
+ # [show_lines] true
99
+ # [round_popups] true
100
+ def set_defaults
101
+ init_with(
102
+ :show_data_points => true,
103
+ :area_fill => false,
104
+ :stacked => false,
105
+ :show_lines => true,
106
+ :round_popups => true,
107
+ :scale_x_integers => false,
108
+ :scale_y_integers => false,
109
+ )
110
+ end
111
+
112
+ # Determines the scaling for the X axis divisions.
113
+ #
114
+ # graph.scale_x_divisions = 2
115
+ #
116
+ # would cause the graph to attempt to generate labels stepped by 2; EG:
117
+ # 0,2,4,6,8...
118
+ # default is automatic such that there are 10 labels
119
+ attr_accessor :scale_x_divisions
120
+ # Determines the scaling for the Y axis divisions.
121
+ #
122
+ # graph.scale_y_divisions = 0.5
123
+ #
124
+ # would cause the graph to attempt to generate labels stepped by 0.5; EG:
125
+ # 0, 0.5, 1, 1.5, 2, ...
126
+ # default is automatic such that there are 10 labels
127
+ attr_accessor :scale_y_divisions
128
+ # Make the X axis labels integers, default: false
129
+ attr_accessor :scale_x_integers
130
+ # Make the Y axis labels integers, default: false
131
+ attr_accessor :scale_y_integers
132
+ # Fill the area under the line, default: false
133
+ attr_accessor :area_fill
134
+ # Show a small circle on the graph where the line
135
+ # goes from one point to the next. default: true
136
+ attr_accessor :show_data_points
137
+ # Set the minimum value of the X axis, if nil the minimum from data is chosen, default: nil
138
+ attr_accessor :min_x_value
139
+ # Set the maximum value of the X axis, if nil the maximum from data is chosen, default: nil
140
+ attr_accessor :max_x_value
141
+ # Set the minimum value of the Y axis, if nil the minimum from data is chosen, default: nil
142
+ attr_accessor :min_y_value
143
+ # Set the maximum value of the Y axis, if nil the maximum from data is chosen, default: nil
144
+ attr_accessor :max_y_value
145
+ # Show lines connecting data points, default: true
146
+ attr_accessor :show_lines
147
+ # Round value of data points in popups to integer, default: true
148
+ attr_accessor :round_popups
149
+
150
+
151
+ # Adds data to the plot. The data must be in X,Y pairs; EG
152
+ # data_set1 = [ 1, 2 ] # A data set with 1 point: (1,2)
153
+ # data_set2 = [ 1,2, 5,6] # A data set with 2 points: (1,2) and (5,6)
154
+ # It's also supported to supply nested array or a mix (flatten is applied to the array); EG
155
+ # data_set2 = [[1,2], 5,6]
156
+ # or
157
+ # data_set2 = [[1,2], [5,6]]
158
+ #
159
+ # graph.add_data({
160
+ # :data => data_set1,
161
+ # :title => 'single point'
162
+ # })
163
+ # graph.add_data({
164
+ # :data => data_set2,
165
+ # :title => 'two points'
166
+ # })
167
+ # @param conf [Hash] with keys
168
+ # :data [Array] of x,y pairs, one pair for each point
169
+ # :title [String] mandatory name of data series for legend of graph
170
+ # :description [Array<String>] (optional) if given, description for each datapoint (shown in popups)
171
+ # :shape [Array<String>] (optional) if given, DataPoint shape is chosen based on this string instead of description
172
+ # :url [Array<String>] (optional) if given, link will be added to each datapoint
173
+ def add_data(conf)
174
+ @data ||= []
175
+ raise "No data provided by #{conf.inspect}" unless conf[:data].is_a?(Array)
176
+ # support array of arrays and flatten it
177
+ conf[:data] = conf[:data].flatten
178
+ # check that we have pairs of values
179
+ raise "Data supplied must be x,y pairs! "+
180
+ "The data provided contained an odd set of "+
181
+ "data points" unless conf[:data].length % 2 == 0
182
+
183
+ # clear the min/max x/y range caches
184
+ clear_cache
185
+
186
+ # remove nil values
187
+ conf[:data] = conf[:data].compact
188
+
189
+ return if conf[:data].length.zero?
190
+
191
+ add_data_init_or_check_optional_keys(conf, conf[:data].size / 2)
192
+
193
+ x = []
194
+ y = []
195
+ conf[:data].each_index {|i|
196
+ (i%2 == 0 ? x : y) << conf[:data][i]
197
+ }
198
+ sort(x, y, conf[:description], conf[:shape], conf[:url])
199
+ conf[:data] = [x, y]
200
+ # at the end data looks like:
201
+ # [
202
+ # [all x values],
203
+ # [all y values]
204
+ # ]
205
+ @data << conf
206
+ end
207
+
208
+ protected
209
+
210
+ def keys
211
+ @data.collect{ |x| x[:title] }
212
+ end
213
+
214
+ def calculate_left_margin
215
+ super
216
+ label_left = get_x_labels[0].to_s.length / 2 * font_size * 0.6
217
+ @border_left = label_left if label_left > @border_left
218
+ end
219
+
220
+ def calculate_right_margin
221
+ super
222
+ label_right = get_x_labels[-1].to_s.length / 2 * font_size * 0.6
223
+ @border_right = label_right if label_right > @border_right
224
+ end
225
+
226
+ X = 0
227
+ Y = 1
228
+
229
+ # procedure to clear all the cached variables used in working out the
230
+ # max and min ranges for the chart
231
+ def clear_cache
232
+ @max_x_cache = @min_x_cache = @max_y_cache = @min_y_cache = nil
233
+ end
234
+
235
+ def max_x_range
236
+ return @max_x_cache unless @max_x_cache.nil?
237
+
238
+ # needs to be computed fresh when called, to cover the use-case:
239
+ # add_data -> burn -> add_data -> burn
240
+ # when values would be cached, the graph is not updated for second burning
241
+ max_value = @data.collect{|x| x[:data][X][-1] }.max
242
+ max_value = max_value > max_x_value ? max_value : max_x_value if max_x_value
243
+ @max_x_cache = max_value
244
+ @max_x_cache
245
+ end
246
+
247
+ def min_x_range
248
+ return @min_x_cache unless @min_x_cache.nil?
249
+
250
+ # needs to be computed fresh when called, to cover the use-case:
251
+ # add_data -> burn -> add_data -> burn
252
+ # when values would be cached, the graph is not updated for second burning
253
+ min_value = @data.collect{|x| x[:data][X][0] }.min
254
+ min_value = min_value < min_x_value ? min_value : min_x_value if min_x_value
255
+ @min_x_cache = min_value
256
+ @min_x_cache
257
+ end
258
+
259
+ # calculate the min and max x value as well as the scale division used for the x-axis
260
+ def x_label_range
261
+ max_value = max_x_range
262
+ min_value = min_x_range
263
+ range = max_value - min_value
264
+ # add some padding on right
265
+ if range == 0
266
+ max_value += 10
267
+ else
268
+ max_value += range / 20.0
269
+ end
270
+ scale_range = max_value - min_value
271
+
272
+ # either use the given step size or by default do 9 divisions.
273
+ scale_division = scale_x_divisions || (scale_range / 9.0)
274
+ @x_offset = 0
275
+
276
+ if scale_x_integers
277
+ scale_division = scale_division < 1 ? 1 : scale_division.ceil
278
+ @x_offset = min_value.to_f - min_value.floor
279
+ min_value = min_value.floor
280
+ end
281
+
282
+ [min_value, max_value, scale_division]
283
+ end
284
+
285
+ # get array of values for the x axis divisions, assuming left-most value starts
286
+ # exactly where the graph starts.
287
+ def get_x_values
288
+ min_value, max_value, @x_scale_division = x_label_range
289
+ x_times = ((max_value-min_value)/@x_scale_division).round + 1
290
+ rv = []
291
+ x_times.times{|v| rv << (min_value + (v * @x_scale_division))}
292
+ return rv
293
+ end
294
+ alias :get_x_labels :get_x_values
295
+
296
+ def field_width
297
+ # exclude values which are outside max_x_range
298
+ values = get_x_values
299
+ @graph_width.to_f / (values.length - 1 ) # -1 is to use entire x-axis
300
+ # otherwise there is always 1 division unused
301
+ end
302
+
303
+ def max_y_range
304
+ return @max_y_cache unless @max_y_cache.nil?
305
+
306
+ max_value = @data.collect{|x| x[:data][Y].max }.max
307
+ max_value = max_value > max_y_value ? max_value : max_y_value if max_y_value
308
+ @max_y_cache = max_value
309
+ @max_y_cache
310
+ end
311
+
312
+ def min_y_range
313
+ return @min_y_cache unless @min_y_cache.nil?
314
+
315
+ # needs to be computed fresh when called, to cover the use-case:
316
+ # add_data -> burn -> add_data -> burn
317
+ # when values would be cached, the graph is not updated for second burning
318
+ min_value = @data.collect{|x| x[:data][Y].min }.min
319
+ min_value = min_value < min_y_value ? min_value : min_y_value if min_y_value
320
+ @min_y_cache = min_value
321
+ @min_y_cache
322
+ end
323
+
324
+ def y_label_range
325
+ max_value = max_y_range
326
+ min_value = min_y_range
327
+ range = max_value - min_value
328
+ # add some padding on top
329
+ if range == 0
330
+ max_value += 10
331
+ else
332
+ max_value += range / 20.0
333
+ end
334
+ scale_range = max_value - min_value
335
+
336
+ scale_division = scale_y_divisions || (scale_range / 9.0)
337
+ @y_offset = 0
338
+
339
+ if scale_y_integers
340
+ scale_division = scale_division < 1 ? 1 : scale_division.ceil
341
+ @y_offset = (min_value.to_f - min_value.floor).to_f
342
+ min_value = min_value.floor
343
+ end
344
+
345
+ return [min_value, max_value, scale_division]
346
+ end
347
+
348
+ def get_y_values
349
+ min_value, max_value, @y_scale_division = y_label_range
350
+ if max_value != min_value
351
+ while (max_value - min_value) < @y_scale_division
352
+ @y_scale_division /= 9.0
353
+ end
354
+ end
355
+ rv = []
356
+ min_value.step( max_value + @y_scale_division, @y_scale_division ) {|v| rv << v}
357
+ rv << rv[0] + 1 if rv.length == 1
358
+ return rv
359
+ end
360
+ alias :get_y_labels :get_y_values
361
+
362
+ def field_height
363
+ # exclude values which are outside max_x_range
364
+ values = get_y_values
365
+ max = max_y_range
366
+ if values.length == 1
367
+ dx = values[-1]
368
+ else
369
+ dx = (max - values[-1]).to_f / (values[-1] - values[-2])
370
+ end
371
+ @graph_height.to_f / (values.length - 1)
372
+ end
373
+
374
+ # calculates the x,y coordinates of a datapoint in the plot area
375
+ def calc_coords(x, y)
376
+ coords = {:x => 0, :y => 0}
377
+ # scale the coordinates, use float division / multiplication
378
+ # otherwise the point will be place inaccurate
379
+ coords[:x] = (x + @x_offset)/@x_scale_division.to_f * field_width
380
+ coords[:y] = @graph_height - (y + @y_offset)/@y_scale_division.to_f * field_height
381
+ return coords
382
+ end
383
+
384
+ def draw_data
385
+ line = 1
386
+
387
+ x_min = min_x_range
388
+ y_min = min_y_range
389
+
390
+ for data in @data
391
+ x_points = data[:data][X]
392
+ y_points = data[:data][Y]
393
+
394
+ lpath = "L"
395
+ x_start = 0
396
+ y_start = 0
397
+ x_points.each_index { |idx|
398
+ c = calc_coords(x_points[idx] - x_min, y_points[idx] - y_min)
399
+ x_start, y_start = c[:x],c[:y] if idx == 0
400
+ lpath << "#{c[:x]} #{c[:y]} "
401
+ }
402
+
403
+ if area_fill
404
+ @graph.add_element( "path", {
405
+ "d" => "M#{x_start} #@graph_height #{lpath} V#@graph_height Z",
406
+ "class" => "fill#{line}"
407
+ })
408
+ end
409
+
410
+ if show_lines
411
+ @graph.add_element( "path", {
412
+ "d" => "M#{x_start} #{y_start} #{lpath}",
413
+ "class" => "line#{line}"
414
+ })
415
+ end
416
+
417
+ if show_data_points || show_data_values || add_popups
418
+ x_points.each_index { |idx|
419
+ c = calc_coords(x_points[idx] - x_min, y_points[idx] - y_min)
420
+ if show_data_points
421
+ shape_selection_string = data[:description][idx].to_s
422
+ if !data[:shape][idx].nil?
423
+ shape_selection_string = data[:shape][idx].to_s
424
+ end
425
+ DataPoint.new(c[:x], c[:y], line).shape(shape_selection_string).each{|s|
426
+ @graph.add_element( *s )
427
+ }
428
+ end
429
+ make_datapoint_text( c[:x], c[:y]-6, y_points[idx] )
430
+ add_popup(c[:x], c[:y], format( x_points[idx], y_points[idx], data[:description][idx].to_s), "", data[:url][idx].to_s)
431
+ }
432
+ end
433
+ line += 1
434
+ end
435
+ end
436
+
437
+ # returns the formatted string which is added as popup information
438
+ def format x, y, desc
439
+ info = []
440
+ info << (round_popups ? x.round : @number_format % x )
441
+ info << (round_popups ? y.round : @number_format % y )
442
+ info << desc if !desc.empty?
443
+ "(#{info.compact.join(', ')})"
444
+ end
445
+
446
+ def get_css
447
+ return <<EOL
448
+ /* default line styles */
449
+ .line1{
450
+ fill: none;
451
+ stroke: #ff0000;
452
+ stroke-width: 1px;
453
+ }
454
+ .line2{
455
+ fill: none;
456
+ stroke: #0000ff;
457
+ stroke-width: 1px;
458
+ }
459
+ .line3{
460
+ fill: none;
461
+ stroke: #00ff00;
462
+ stroke-width: 1px;
463
+ }
464
+ .line4{
465
+ fill: none;
466
+ stroke: #ffcc00;
467
+ stroke-width: 1px;
468
+ }
469
+ .line5{
470
+ fill: none;
471
+ stroke: #00ccff;
472
+ stroke-width: 1px;
473
+ }
474
+ .line6{
475
+ fill: none;
476
+ stroke: #ff00ff;
477
+ stroke-width: 1px;
478
+ }
479
+ .line7{
480
+ fill: none;
481
+ stroke: #00ffff;
482
+ stroke-width: 1px;
483
+ }
484
+ .line8{
485
+ fill: none;
486
+ stroke: #ffff00;
487
+ stroke-width: 1px;
488
+ }
489
+ .line9{
490
+ fill: none;
491
+ stroke: #cc6666;
492
+ stroke-width: 1px;
493
+ }
494
+ .line10{
495
+ fill: none;
496
+ stroke: #663399;
497
+ stroke-width: 1px;
498
+ }
499
+ .line11{
500
+ fill: none;
501
+ stroke: #339900;
502
+ stroke-width: 1px;
503
+ }
504
+ .line12{
505
+ fill: none;
506
+ stroke: #9966FF;
507
+ stroke-width: 1px;
508
+ }
509
+ /* default fill styles */
510
+ .fill1{
511
+ fill: #cc0000;
512
+ fill-opacity: 0.2;
513
+ stroke: none;
514
+ }
515
+ .fill2{
516
+ fill: #0000cc;
517
+ fill-opacity: 0.2;
518
+ stroke: none;
519
+ }
520
+ .fill3{
521
+ fill: #00cc00;
522
+ fill-opacity: 0.2;
523
+ stroke: none;
524
+ }
525
+ .fill4{
526
+ fill: #ffcc00;
527
+ fill-opacity: 0.2;
528
+ stroke: none;
529
+ }
530
+ .fill5{
531
+ fill: #00ccff;
532
+ fill-opacity: 0.2;
533
+ stroke: none;
534
+ }
535
+ .fill6{
536
+ fill: #ff00ff;
537
+ fill-opacity: 0.2;
538
+ stroke: none;
539
+ }
540
+ .fill7{
541
+ fill: #00ffff;
542
+ fill-opacity: 0.2;
543
+ stroke: none;
544
+ }
545
+ .fill8{
546
+ fill: #ffff00;
547
+ fill-opacity: 0.2;
548
+ stroke: none;
549
+ }
550
+ .fill9{
551
+ fill: #cc6666;
552
+ fill-opacity: 0.2;
553
+ stroke: none;
554
+ }
555
+ .fill10{
556
+ fill: #663399;
557
+ fill-opacity: 0.2;
558
+ stroke: none;
559
+ }
560
+ .fill11{
561
+ fill: #339900;
562
+ fill-opacity: 0.2;
563
+ stroke: none;
564
+ }
565
+ .fill12{
566
+ fill: #9966FF;
567
+ fill-opacity: 0.2;
568
+ stroke: none;
569
+ }
570
+ /* default line styles */
571
+ .key1,.dataPoint1{
572
+ fill: #ff0000;
573
+ stroke: none;
574
+ stroke-width: 1px;
575
+ }
576
+ .key2,.dataPoint2{
577
+ fill: #0000ff;
578
+ stroke: none;
579
+ stroke-width: 1px;
580
+ }
581
+ .key3,.dataPoint3{
582
+ fill: #00ff00;
583
+ stroke: none;
584
+ stroke-width: 1px;
585
+ }
586
+ .key4,.dataPoint4{
587
+ fill: #ffcc00;
588
+ stroke: none;
589
+ stroke-width: 1px;
590
+ }
591
+ .key5,.dataPoint5{
592
+ fill: #00ccff;
593
+ stroke: none;
594
+ stroke-width: 1px;
595
+ }
596
+ .key6,.dataPoint6{
597
+ fill: #ff00ff;
598
+ stroke: none;
599
+ stroke-width: 1px;
600
+ }
601
+ .key7,.dataPoint7{
602
+ fill: #00ffff;
603
+ stroke: none;
604
+ stroke-width: 1px;
605
+ }
606
+ .key8,.dataPoint8{
607
+ fill: #ffff00;
608
+ stroke: none;
609
+ stroke-width: 1px;
610
+ }
611
+ .key9,.dataPoint9{
612
+ fill: #cc6666;
613
+ stroke: none;
614
+ stroke-width: 1px;
615
+ }
616
+ .key10,.dataPoint10{
617
+ fill: #663399;
618
+ stroke: none;
619
+ stroke-width: 1px;
620
+ }
621
+ .key11,.dataPoint11{
622
+ fill: #339900;
623
+ stroke: none;
624
+ stroke-width: 1px;
625
+ }
626
+ .key12,.dataPoint12{
627
+ fill: #9966FF;
628
+ stroke: none;
629
+ stroke-width: 1px;
630
+ }
631
+ EOL
632
+ end
633
+
634
+ end
635
+ end
636
+ end