squid 0.0.0 → 1.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +11 -17
  3. data/.rspec +3 -0
  4. data/.travis.yml +7 -0
  5. data/CHANGELOG.md +11 -0
  6. data/MIT-LICENSE +21 -0
  7. data/README.md +146 -2
  8. data/Rakefile +26 -1
  9. data/bin/console +14 -0
  10. data/bin/setup +7 -0
  11. data/examples/manual.rb +7 -0
  12. data/examples/readme/01-basic.rb +5 -0
  13. data/examples/readme/02-type-point.rb +5 -0
  14. data/examples/readme/03-type-line.rb +5 -0
  15. data/examples/readme/04-color.rb +5 -0
  16. data/examples/readme/05-gridlines.rb +5 -0
  17. data/examples/readme/06-ticks.rb +5 -0
  18. data/examples/readme/07-baseline.rb +5 -0
  19. data/examples/readme/08-every.rb +5 -0
  20. data/examples/readme/09-legend.rb +5 -0
  21. data/examples/readme/10-legend-offset.rb +5 -0
  22. data/examples/readme/11-format.rb +5 -0
  23. data/examples/readme/12-labels.rb +5 -0
  24. data/examples/readme/13-border.rb +5 -0
  25. data/examples/readme/14-height.rb +5 -0
  26. data/examples/readme/15-multiple-columns.rb +5 -0
  27. data/examples/readme/16-multiple-stacks.rb +5 -0
  28. data/examples/readme/17-two-axis.rb +7 -0
  29. data/examples/readme/readme.rb +28 -0
  30. data/examples/readme.rb +7 -0
  31. data/examples/screenshots/readme_00.png +0 -0
  32. data/examples/screenshots/readme_01.png +0 -0
  33. data/examples/screenshots/readme_02.png +0 -0
  34. data/examples/screenshots/readme_03.png +0 -0
  35. data/examples/screenshots/readme_04.png +0 -0
  36. data/examples/screenshots/readme_05.png +0 -0
  37. data/examples/screenshots/readme_06.png +0 -0
  38. data/examples/screenshots/readme_07.png +0 -0
  39. data/examples/screenshots/readme_08.png +0 -0
  40. data/examples/screenshots/readme_09.png +0 -0
  41. data/examples/screenshots/readme_10.png +0 -0
  42. data/examples/screenshots/readme_11.png +0 -0
  43. data/examples/screenshots/readme_12.png +0 -0
  44. data/examples/screenshots/readme_13.png +0 -0
  45. data/examples/screenshots/readme_14.png +0 -0
  46. data/examples/screenshots/readme_15.png +0 -0
  47. data/examples/screenshots/readme_16.png +0 -0
  48. data/examples/screenshots/readme_17.png +0 -0
  49. data/examples/squid/baseline.rb +9 -0
  50. data/examples/squid/basic.rb +9 -0
  51. data/examples/squid/border.rb +9 -0
  52. data/examples/squid/columns.rb +9 -0
  53. data/examples/squid/every.rb +11 -0
  54. data/examples/squid/format.rb +9 -0
  55. data/examples/squid/gridlines.rb +9 -0
  56. data/examples/squid/height.rb +9 -0
  57. data/examples/squid/labels.rb +9 -0
  58. data/examples/squid/legend.rb +9 -0
  59. data/examples/squid/legend_offset.rb +9 -0
  60. data/examples/squid/line.rb +9 -0
  61. data/examples/squid/line_width.rb +9 -0
  62. data/examples/squid/lines.rb +11 -0
  63. data/examples/squid/point.rb +9 -0
  64. data/examples/squid/points.rb +9 -0
  65. data/examples/squid/squid.rb +48 -0
  66. data/examples/squid/stacks.rb +10 -0
  67. data/examples/squid/ticks.rb +9 -0
  68. data/examples/squid/two_axis.rb +10 -0
  69. data/lib/squid/axis.rb +61 -0
  70. data/lib/squid/axis_label.rb +15 -0
  71. data/lib/squid/config.rb +46 -0
  72. data/lib/squid/configuration.rb +80 -0
  73. data/lib/squid/format.rb +22 -0
  74. data/lib/squid/graph.rb +113 -0
  75. data/lib/squid/gridline.rb +16 -0
  76. data/lib/squid/plotter.rb +187 -0
  77. data/lib/squid/point.rb +40 -0
  78. data/lib/squid/settings.rb +15 -0
  79. data/lib/squid/version.rb +1 -1
  80. data/lib/squid.rb +35 -3
  81. data/squid.gemspec +22 -14
  82. metadata +176 -23
  83. data/HISTORY.md +0 -1
  84. data/LICENSE.txt +0 -22
  85. data/bin/squid +0 -8
@@ -0,0 +1,9 @@
1
+ # By default, <code>chart</code> uses a line width of 3 for line charts.
2
+ #
3
+ # You can use the <code>:line_width</code> option to customize this value.
4
+ #
5
+ filename = File.basename(__FILE__).gsub('.rb', '.pdf')
6
+ Prawn::ManualBuilder::Example.generate(filename) do
7
+ data = {views: {2013 => 182, 2014 => 46, 2015 => 802000000000000000000}}
8
+ chart data, type: :line, line_width: 10
9
+ end
@@ -0,0 +1,11 @@
1
+ # By default, <code>chart</code> plots a column for each series.
2
+ #
3
+ # You can use the <code>:type</code> option to plot a line chart instead.
4
+ filename = File.basename(__FILE__).gsub('.rb', '.pdf')
5
+ Prawn::ManualBuilder::Example.generate(filename) do
6
+ data = {views: {2013 => 182, 2014 => -46, 2015 => 88},
7
+ uniques: {2013 => 104, 2014 => 27, 2015 => 14}}
8
+ chart data, type: :line, labels: true
9
+ end
10
+
11
+
@@ -0,0 +1,9 @@
1
+ # By default, <code>chart</code> plots a column chart.
2
+ #
3
+ # You can use the <code>:type</code> option to plot a point chart instead.
4
+ #
5
+ filename = File.basename(__FILE__).gsub('.rb', '.pdf')
6
+ Prawn::ManualBuilder::Example.generate(filename) do
7
+ data = {views: {Safari: 45.20001, Firefox: 63.3999, Chrome: 21.4}}
8
+ chart data, type: :point
9
+ end
@@ -0,0 +1,9 @@
1
+ # By default, <code>chart</code> plots a column for each series.
2
+ #
3
+ # You can use the <code>:type</code> option to plot a point chart instead.
4
+ filename = File.basename(__FILE__).gsub('.rb', '.pdf')
5
+ Prawn::ManualBuilder::Example.generate(filename) do
6
+ data = {views: {2013 => 182, 2014 => 46, 2015 => 88},
7
+ uniques: {2013 => -104, 2014 => 27, 2015 => 14}}
8
+ chart data, type: :point, labels: true
9
+ end
@@ -0,0 +1,48 @@
1
+ Prawn::ManualBuilder::Example.generate 'squid.pdf' do
2
+ package 'squid' do |p|
3
+ p.name = 'Squid'
4
+
5
+ p.intro do
6
+ prose('
7
+ Prawn is a great library to generate PDF files from Ruby, but lacks high-level components to generate graphs.
8
+ Squid integrates Prawn by providing methods to plot charts in PDF files with few lines of code.
9
+ This manual shows:
10
+ ')
11
+
12
+ list(
13
+ 'How to create graphs',
14
+ )
15
+ end
16
+
17
+ p.section 'Basics' do |s|
18
+ s.example 'basic'
19
+ s.example 'legend'
20
+ end
21
+
22
+ p.section 'Chart types' do |s|
23
+ s.example 'point'
24
+ s.example 'line'
25
+ end
26
+
27
+ p.section 'Styling' do |s|
28
+ s.example 'height'
29
+ s.example 'baseline'
30
+ s.example 'ticks'
31
+ s.example 'every'
32
+ s.example 'gridlines'
33
+ s.example 'format'
34
+ s.example 'border'
35
+ s.example 'labels'
36
+ s.example 'line_width'
37
+ s.example 'legend_offset'
38
+ end
39
+
40
+ p.section 'Multiple series' do |s|
41
+ s.example 'columns'
42
+ s.example 'lines'
43
+ s.example 'points'
44
+ s.example 'stacks'
45
+ s.example 'two_axis'
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,10 @@
1
+ # By default, <code>chart</code> plots multiple columns next to each other.
2
+ #
3
+ # You can use the <code>:type</code> option to plot stacked columns instead.
4
+ filename = File.basename(__FILE__).gsub('.rb', '.pdf')
5
+ Prawn::ManualBuilder::Example.generate(filename) do
6
+ data = {views: {2013 => 196, 2014 => 66, 2015 => -282},
7
+ hits: {2013 => 100, 2014 => -12, 2015 => 82},
8
+ uniques: {2013 => -114, 2014 => 47, 2015 => -14}}
9
+ chart data, type: :stack, labels: true
10
+ end
@@ -0,0 +1,9 @@
1
+ # By default, <code>chart</code> plots ticks above the categories.
2
+ #
3
+ # You can use the <code>:ticks</code> option to disable this behavior.
4
+ #
5
+ filename = File.basename(__FILE__).gsub('.rb', '.pdf')
6
+ Prawn::ManualBuilder::Example.generate(filename) do
7
+ data = {views: {2013 => 182, 2014 => 46, 2015 => 802000000000000000000}}
8
+ chart data, ticks: false
9
+ end
@@ -0,0 +1,10 @@
1
+ # By default, ..
2
+ #
3
+ # You can use ..
4
+ filename = File.basename(__FILE__).gsub('.rb', '.pdf')
5
+ Prawn::ManualBuilder::Example.generate(filename) do
6
+
7
+ data = {views: {2013 => 182, 2014 => 46, 2015 => 88},
8
+ earnings: {2013 => 104_323, 2014 => 27_234, 2015 => 14_123}}
9
+ chart data, type: :two_axis, border: true
10
+ end
data/lib/squid/axis.rb ADDED
@@ -0,0 +1,61 @@
1
+ require 'squid/format'
2
+ require 'active_support/core_ext/enumerable' # for Array#sum
3
+
4
+ module Squid
5
+ # @private
6
+ class Axis
7
+ include Format
8
+ attr_reader :data
9
+
10
+ def initialize(data, steps:, stack:, format:, &block)
11
+ @data, @steps, @stack, @format = data, steps, stack, format
12
+ @width_proc = block if block_given?
13
+ end
14
+
15
+ def minmax
16
+ @minmax ||= [min, max].compact.map do |number|
17
+ approximate number
18
+ end
19
+ end
20
+
21
+ def labels
22
+ min, max = minmax
23
+ values = if @data.empty? || @steps.zero?
24
+ []
25
+ else
26
+ max.step(by: (min - max)/@steps.to_f, to: min)
27
+ end
28
+ @labels ||= values.map{|value| format_for value, @format}
29
+ end
30
+
31
+ def width
32
+ @width ||= labels.map{|label| label_width label}.max || 0
33
+ end
34
+
35
+ private
36
+
37
+ def label_width(label)
38
+ @width_proc.call label if @width_proc
39
+ end
40
+
41
+ def min
42
+ [values.first.min, 0].min if @data.any?
43
+ end
44
+
45
+ def max
46
+ [values.last.max, @steps].max if @data.any?
47
+ end
48
+
49
+ def values
50
+ @values ||= if @stack
51
+ @data.transpose.map{|a| a.compact.partition{|n| n < 0}.map(&:sum)}.transpose
52
+ else
53
+ [@data.flatten.compact]
54
+ end
55
+ end
56
+
57
+ def approximate(number)
58
+ number_to_rounded(number, significant: true, precision: 2).to_f
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,15 @@
1
+ module Squid
2
+ class AxisLabel
3
+ def self.for(axis, height:, align:)
4
+ height.step(0, -height/(axis.labels.size-1).to_f).map.with_index do |y, i|
5
+ new y: y, label: axis.labels[i], align: align, width: axis.width
6
+ end
7
+ end
8
+
9
+ attr_reader :label, :y, :align, :width
10
+
11
+ def initialize(label:, y:, align:, width:)
12
+ @label, @y, @align, @width = label, y, align, width
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,46 @@
1
+ require 'squid/configuration'
2
+
3
+ module Squid
4
+ # Provides methods to read and write global configuration settings.
5
+ #
6
+ # A typical usage is to set the default dimensions and colors for charts.
7
+ #
8
+ # @example Set the default height for Squid graphs:
9
+ # Squid.configure do |config|
10
+ # config.height = 150
11
+ # end
12
+ #
13
+ # Note that Squid.configure has precedence over values through with
14
+ # environment variables (see {Squid::Configuration}).
15
+ #
16
+ module Config
17
+ # Yields the global configuration to the given block.
18
+ #
19
+ # @example
20
+ # Squid.configure do |config|
21
+ # config.height = 150
22
+ # end
23
+ #
24
+ # @yield [Squid::Configuration] The global configuration.
25
+ def configure
26
+ yield configuration if block_given?
27
+ end
28
+
29
+ # Returns the global {Squid::Configuration} object.
30
+ #
31
+ # While this method _can_ be used to read and write configuration settings,
32
+ # it is easier to use {Squid::Config#configure} Squid.configure}.
33
+ #
34
+ # @example
35
+ # Squid.configuration.height = 150
36
+ #
37
+ # @return [Squid::Configuration] The global configuration.
38
+ def configuration
39
+ @configuration ||= Squid::Configuration.new
40
+ end
41
+ end
42
+
43
+ # @note Config is the only module auto-loaded in the Squid module,
44
+ # in order to have a syntax as easy as Squid.configure
45
+ extend Config
46
+ end
@@ -0,0 +1,80 @@
1
+ require 'ostruct'
2
+
3
+ module Squid
4
+ # Provides an object to store global configuration settings.
5
+ #
6
+ # This class is typically not used directly, but by calling
7
+ # {Squid::Config#configure Squid.configure}, which creates and updates a
8
+ # single instance of {Squid::Configuration}.
9
+ #
10
+ # @example Set the default height for Squid graphs:
11
+ # Squid.configure do |config|
12
+ # config.height = 150
13
+ # config.steps = 4
14
+ # end
15
+ #
16
+ # @see Squid::Config for more examples.
17
+ #
18
+ # An alternative way to set global configuration settings is by storing
19
+ # them in the following environment variables:
20
+ #
21
+ # * +SQUID_HEIGHT+ to store the default graph height
22
+ #
23
+ # In case both methods are used together,
24
+ # {Squid::Config#configure Squid.configure} takes precedence.
25
+ #
26
+ # @example Set the default graph height:
27
+ # ENV['SQUID_HEIGHT'] = '150'
28
+ # ENV['SQUID_GRIDLINES'] = '4'
29
+ #
30
+ class Configuration < OpenStruct
31
+ COLORS = '2e578c 5d9648 e7a13d bc2d30 6f3d79 7d807f'
32
+
33
+ def self.boolean
34
+ -> (value) { %w(1 t T true TRUE).include? value }
35
+ end
36
+
37
+ def self.integer
38
+ -> (value) { value.to_i }
39
+ end
40
+
41
+ def self.symbol
42
+ -> (value) { value.to_sym }
43
+ end
44
+
45
+ def self.float
46
+ -> (value) { value.to_f }
47
+ end
48
+
49
+ def self.array
50
+ -> (value) { value.split }
51
+ end
52
+
53
+ ATTRIBUTES = {
54
+ baseline: {default: 'true', as: boolean},
55
+ border: {default: 'false', as: boolean},
56
+ chart: {default: 'true', as: boolean},
57
+ colors: {default: COLORS, as: array},
58
+ every: {default: '1', as: integer},
59
+ format: {default: 'integer', as: symbol},
60
+ height: {default: '250', as: float},
61
+ labels: {default: 'false', as: boolean},
62
+ legend: {default: 'true', as: boolean},
63
+ line_widths: {default: '3', as: integer},
64
+ steps: {default: '4', as: integer},
65
+ ticks: {default: 'true', as: boolean},
66
+ type: {default: 'column', as: symbol},
67
+ }
68
+
69
+ attr_accessor *ATTRIBUTES.keys
70
+
71
+ # Initialize the global configuration settings.
72
+ def initialize
73
+ ATTRIBUTES.each do |key, options|
74
+ var = "squid_#{key}".upcase
75
+ value = ENV.fetch var, options[:default]
76
+ public_send "#{key}=", options[:as].call(value)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,22 @@
1
+ require 'active_support'
2
+ require 'active_support/number_helper' # for number_to_rounded
3
+
4
+ module Squid
5
+ module Format
6
+ include ActiveSupport::NumberHelper
7
+
8
+ def format_for(value, format)
9
+ case format
10
+ when :percentage then number_to_percentage value, precision: 1
11
+ when :currency then number_to_currency value
12
+ when :seconds then number_to_minutes_and_seconds value
13
+ when :float then number_to_delimited value
14
+ else number_to_delimited value.to_i
15
+ end.to_s
16
+ end
17
+
18
+ def number_to_minutes_and_seconds(value)
19
+ "#{value.round / 60}:#{(value.round % 60).to_s.rjust 2, '0'}"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,113 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext/string/inflections' # for titleize
3
+
4
+ require 'squid/axis'
5
+ require 'squid/axis_label'
6
+ require 'squid/gridline'
7
+ require 'squid/plotter'
8
+ require 'squid/point'
9
+ require 'squid/settings'
10
+
11
+ module Squid
12
+ # @private
13
+ class Graph
14
+ extend Settings
15
+ has_settings :baseline, :border, :chart, :colors, :every, :format, :height
16
+ has_settings :legend, :line_widths, :steps, :ticks, :type, :labels
17
+
18
+ def initialize(document, data = {}, settings = {})
19
+ @data, @settings = data, settings
20
+ @plot = Plotter.new document, bottom: bottom
21
+ @plot.paddings = {left: left.width, right: right.width} if @data.any?
22
+ end
23
+
24
+ def draw
25
+ @plot.box(h: height, border: border) { draw_graph if @data.any? }
26
+ end
27
+
28
+ private
29
+
30
+ def draw_graph
31
+ draw_legend if legend
32
+ draw_gridlines
33
+ draw_axis_labels
34
+ draw_charts if chart
35
+ draw_categories if baseline
36
+ end
37
+
38
+ def draw_legend
39
+ labels = @data.keys.reverse.map{|key| key.to_s.titleize}
40
+ offset = legend.is_a?(Hash) ? legend.fetch(:offset, 0) : 0
41
+ @plot.legend labels, offset: offset, colors: colors, height: legend_height
42
+ end
43
+
44
+ def draw_gridlines
45
+ options = {height: grid_height, count: steps, skip_baseline: baseline}
46
+ Gridline.for(options).each do |line|
47
+ @plot.horizontal_line line.y, line_width: 0.5, transparency: 0.25
48
+ end
49
+ end
50
+
51
+ def draw_axis_labels
52
+ @plot.axis_labels AxisLabel.for(left, align: :right, height: grid_height)
53
+ @plot.axis_labels AxisLabel.for(right, align: :left, height: grid_height)
54
+ end
55
+
56
+ def draw_categories
57
+ labels = @data.values.first.keys.map{|key| key.to_s}
58
+ @plot.categories labels, every: every, ticks: ticks
59
+ @plot.horizontal_line 0.0
60
+ end
61
+
62
+ def draw_charts
63
+ draw_chart right, type: :column, colors: colors[1..-1]
64
+ draw_chart left, colors: colors
65
+ end
66
+
67
+ def draw_chart(axis, options = {})
68
+ args = {minmax: axis.minmax, height: grid_height, stack: stack?, labels: labels, format: format}
69
+ points = Point.for axis.data, args
70
+ case options.delete(:type) {type}
71
+ when :point then @plot.points points, options
72
+ when :line, :two_axis then @plot.lines points, options.merge(line_widths: line_widths)
73
+ when :column then @plot.columns points, options
74
+ when :stack then @plot.stacks points, options
75
+ end
76
+ end
77
+
78
+ def left
79
+ @left ||= axis first: 0, last: (two_axis? ? 1 : @data.size)
80
+ end
81
+
82
+ def right
83
+ @right ||= axis first: 1, last: (two_axis? ? 1 : 0)
84
+ end
85
+
86
+ def axis(first:, last:)
87
+ series = @data.values[first, last].map(&:values)
88
+ Axis.new series, steps: steps, stack: stack?, format: format do |label|
89
+ @plot.width_of label
90
+ end
91
+ end
92
+
93
+ def bottom
94
+ baseline ? 20 : 0
95
+ end
96
+
97
+ def legend_height
98
+ 15
99
+ end
100
+
101
+ def grid_height
102
+ height - bottom - legend_height * (legend ? 2 : 1)
103
+ end
104
+
105
+ def stack?
106
+ type == :stack
107
+ end
108
+
109
+ def two_axis?
110
+ type == :two_axis
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,16 @@
1
+ module Squid
2
+ class Gridline
3
+ def self.for(count:, skip_baseline:, height:)
4
+ return [] if count.zero?
5
+ height.step(0, -height/count.to_f).map do |y|
6
+ new y: y unless skip_baseline && y.zero?
7
+ end.compact
8
+ end
9
+
10
+ attr_reader :y
11
+
12
+ def initialize(y:)
13
+ @y = y
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,187 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext/array/wrap' # for Array#wrap
3
+
4
+ module Squid
5
+ # A Plotter wraps a Prawn::Document object in order to provide new methods
6
+ # like `gridline` or `ticks` used by Squid::Graph to plot graph elements.
7
+ class Plotter
8
+ attr_accessor :paddings
9
+ # @param [Prawn::Document] a PDF document to wrap in a Plotter instance.
10
+ def initialize(pdf, bottom:)
11
+ @pdf = pdf
12
+ @bottom = bottom
13
+ end
14
+
15
+ # Draws a bounding box of the given height, rendering the block inside it.
16
+ def box(x: 0, y: @pdf.cursor, w: @pdf.bounds.width, h:, border: false)
17
+ @pdf.bounding_box [x, y], width: w, height: h do
18
+ @pdf.stroke_bounds if border
19
+ yield
20
+ end
21
+ end
22
+
23
+ # Draws the graph legend with the given labels.
24
+ # @param [Array<LegendItem>] The labels to write as part of the legend.
25
+ def legend(labels, height:, offset: 0, colors: [])
26
+ left = @pdf.bounds.width/2
27
+ box(x: left, y: @pdf.bounds.top, w: left, h: height) do
28
+ x = @pdf.bounds.right - offset
29
+ options = {size: 7, height: @pdf.bounds.height, valign: :center}
30
+ labels.each.with_index do |label, i|
31
+ color = Array.wrap(colors[labels.size - 1 - i]).first
32
+ x = legend_item label, x, color, options
33
+ end
34
+ end
35
+ end
36
+
37
+ # Draws a horizontal line.
38
+ def horizontal_line(y, options = {})
39
+ with options do
40
+ at = y + @bottom
41
+ @pdf.stroke_horizontal_line left, @pdf.bounds.right - right, at: at
42
+ end
43
+ end
44
+
45
+ def width_of(label)
46
+ @pdf.width_of(label, size: 8).ceil
47
+ end
48
+
49
+ def axis_labels(labels)
50
+ labels.each do |label|
51
+ x = (label.align == :right) ? 0 : @pdf.bounds.right - label.width
52
+ y = label.y + @bottom + text_options[:height] / 2
53
+ options = text_options.merge width: label.width, at: [x, y]
54
+ @pdf.text_box label.label, options.merge(align: label.align)
55
+ end
56
+ end
57
+
58
+ def categories(labels, every:, ticks:)
59
+ labels.each.with_index do |label, index|
60
+ w = width / labels.count.to_f
61
+ x = left + w * (index)
62
+ padding = 2
63
+ options = category_options.merge(width: every*w-2*padding, at: [x+padding-w*(every/2.0-0.5), @bottom])
64
+ @pdf.text_box label, options if (index % every).zero?
65
+ @pdf.stroke_vertical_line @bottom, @bottom - 2, at: x + w/2 if ticks
66
+ end
67
+ end
68
+
69
+ def points(series, colors: [])
70
+ items(series, colors: colors) do |point, w, i, padding|
71
+ x, y = (point.index + 0.5)*w + left, point.y + @bottom
72
+ @pdf.fill_circle [x, y], 5
73
+ end
74
+ end
75
+
76
+ def lines(series, colors: [], line_widths: [])
77
+ x, y = nil, nil
78
+ items(series, colors: colors) do |point, w, i, padding|
79
+ prev_x, prev_y = x, y
80
+ x, y = (point.index + 0.5)*w + left, point.y + @bottom
81
+ line_width = Array.wrap(line_widths).fetch(i, 1)
82
+ with line_width: line_width, cap_style: :round do
83
+ @pdf.line [prev_x, prev_y], [x,y] unless point.index.zero? || prev_y.nil? || prev_x > x
84
+ end
85
+ end
86
+ end
87
+
88
+ def stacks(series, colors: [])
89
+ items(series, colors: colors, fill: true) do |point, w, i, padding|
90
+ x, y = point.index*w + padding + left, point.y + @bottom
91
+ @pdf.fill_rectangle [x, y], w - 2*padding, point.height
92
+ end
93
+ end
94
+
95
+ def columns(series, colors: [])
96
+ items(series, colors: colors, fill: true, count: series.size) do |point, w, i, padding|
97
+ item_w = (w - 2 * padding)/ series.size
98
+ x, y = point.index*w + padding + left + i*item_w, point.y + @bottom
99
+ @pdf.fill_rectangle [x, y], item_w, point.height
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def left
106
+ @paddings[:left].zero? ? 0 : @paddings[:left] + 5
107
+ end
108
+
109
+ def right
110
+ @paddings[:right].zero? ? 0 : @paddings[:right] + 5
111
+ end
112
+
113
+ def width
114
+ @pdf.bounds.width - left - right
115
+ end
116
+
117
+ def category_options
118
+ text_options.merge align: :center, leading: -3, disable_wrap_by_char: true
119
+ end
120
+
121
+ def text_options
122
+ options = {}
123
+ options[:height] = 20
124
+ options[:size] = 8
125
+ options[:valign] = :center
126
+ options[:overflow] = :shrink_to_fit
127
+ options
128
+ end
129
+
130
+ def items(series, colors: [], fill: false, count: 1, &block)
131
+ series.reverse_each.with_index do |points, reverse_index|
132
+ index = series.size - reverse_index - 1
133
+ w = width / points.size.to_f
134
+ series_colors = Array.wrap(colors[index]).cycle
135
+ points.select(&:y).each do |point|
136
+ item point, series_colors.next, w, fill, index, count, &block
137
+ end
138
+ end
139
+ end
140
+
141
+ def item(point, color, w, fill, index, count)
142
+ padding = w / 8
143
+
144
+ with transparency: 0.95, fill_color: color, stroke_color: color do
145
+ yield point, w, index, padding
146
+ end
147
+
148
+ with fill_color: (point.negative && fill ? 'ffffff' : color) do
149
+ options = [{size: 10, styles: [:bold], text: point.label}]
150
+ position = {align: :center, valign: :bottom, height: 20}
151
+ position[:width] = (w - 2*padding) / count
152
+ x = left + point.index*w + padding
153
+ x += index * position[:width] if count > 1
154
+ position[:at] = [x, point.y + @bottom + 24]
155
+ @pdf.formatted_text_box options, position
156
+ end if point.label
157
+ end
158
+
159
+ # Draws a single item of the legend, which includes the label and the
160
+ # symbol with the matching color. Labels are written from right to left.
161
+ # @param
162
+ def legend_item(label, x, color, options)
163
+ size, symbol_padding, entry_padding = 5, 3, 12
164
+ x -= @pdf.width_of(label, size: 7).ceil
165
+ @pdf.text_box label, options.merge(at: [x, @pdf.bounds.height])
166
+ x -= (symbol_padding + size)
167
+ with fill_color: color do
168
+ @pdf.fill_rectangle [x, @pdf.bounds.height - size], size, size
169
+ end
170
+ x - entry_padding
171
+ end
172
+
173
+
174
+
175
+ # Convenience method to wrap a block by setting and unsetting a Prawn
176
+ # property such as line_width.
177
+ def with(new_values = {})
178
+ transparency = new_values.delete(:transparency) { 1.0 }
179
+ old_values = Hash[new_values.map{|k,_| [k,@pdf.public_send(k)]}]
180
+ new_values.each{|k, new_value| @pdf.public_send "#{k}=", new_value }
181
+ @pdf.transparent(transparency) do
182
+ @pdf.stroke { yield }
183
+ end
184
+ old_values.each{|k, old_value| @pdf.public_send "#{k}=", old_value }
185
+ end
186
+ end
187
+ end