|
| 1 | +require_relative 'line/geometry' |
| 2 | + |
| 3 | +# This module offers different rendering option for data points |
| 4 | +# to be used with line graph. For now we have circle and square as choices. |
| 5 | +# FIXME: refactor this module and make it a part of Line. |
| 6 | +module DotRenderers |
| 7 | + class Circle |
| 8 | + def render(d, new_x, new_y, circle_radius) |
| 9 | + d.circle(new_x, new_y, new_x - circle_radius, new_y) |
| 10 | + end |
| 11 | + end |
| 12 | + |
| 13 | + class Square |
| 14 | + def render(d, new_x, new_y, circle_radius) |
| 15 | + offset = (circle_radius * 0.8).to_i |
| 16 | + corner_1 = new_x - offset |
| 17 | + corner_2 = new_y - offset |
| 18 | + corner_3 = new_x + offset |
| 19 | + corner_4 = new_y + offset |
| 20 | + d.rectangle(corner_1, corner_2, corner_3, corner_4) |
| 21 | + end |
| 22 | + end |
| 23 | + |
| 24 | + def self.renderer(style) |
| 25 | + if style.to_s == 'square' |
| 26 | + Square.new |
| 27 | + else |
| 28 | + Circle.new |
| 29 | + end |
| 30 | + end |
| 31 | +end |
| 32 | + |
| 33 | +module Rubyplot |
| 34 | + module MagickWrapper |
| 35 | + module Plot |
| 36 | + class Line < Artist |
| 37 | + # Dimensions of lines and dots; calculated based on dataset size if left unspecified |
| 38 | + attr_accessor :line_width |
| 39 | + attr_accessor :dot_radius |
| 40 | + # Call with target pixel width of graph (800, 400, 300), and/or |
| 41 | + # 'false' to omit lines (points only). |
| 42 | + # g = Rubyplot::Line.new(400) # 400px wide with lines. |
| 43 | + # |
| 44 | + # g = Rubyplot::Line.new(400, false) # 400px wide, no lines |
| 45 | + # |
| 46 | + # g = Rubyplot::Line.new(false) # Defaults to 800px wide, no lines |
| 47 | + def initialize(*args) |
| 48 | + raise ArgumentError, 'Wrong number of arguments' if args.length > 2 |
| 49 | + if args.empty? || (!(Numeric === args.first) && !(String === args.first)) |
| 50 | + super |
| 51 | + else |
| 52 | + super args.shift # TODO: Figure out a better alternative here. |
| 53 | + end |
| 54 | + |
| 55 | + @geometry = Plot::Line::Geometry.new |
| 56 | + end |
| 57 | + |
| 58 | + # Get the value if somebody has defined it. |
| 59 | + def baseline_value |
| 60 | + @geometry.reference_lines[:baseline][:value] if @geometry.reference_lines.key?(:baseline) |
| 61 | + end |
| 62 | + |
| 63 | + # Set a value for a baseline reference line. |
| 64 | + def baseline_value=(new_value) |
| 65 | + @geometry.reference_lines[:baseline] ||= {} |
| 66 | + @geometry.reference_lines[:baseline][:value] = new_value |
| 67 | + end |
| 68 | + |
| 69 | + def draw_reference_line(reference_line, left, right, top, bottom) |
| 70 | + @d = @d.push |
| 71 | + @d.stroke_color(@reference_line_default_color) |
| 72 | + @d.fill_opacity 0.0 |
| 73 | + @d.stroke_dasharray(10, 20) |
| 74 | + @d.stroke_width(reference_line[:width] || @reference_line_default_width) |
| 75 | + @d.line(left, top, right, bottom) |
| 76 | + @d = @d.pop |
| 77 | + end |
| 78 | + |
| 79 | + def draw |
| 80 | + super |
| 81 | + return unless @geometry.has_data |
| 82 | + |
| 83 | + # Check to see if more than one datapoint was given. NaN can result otherwise. |
| 84 | + @x_increment = @geometry.column_count > 1 ? |
| 85 | + (@graph_width / (@geometry.column_count - 1).to_f) : @graph_width |
| 86 | + |
| 87 | + @geometry.norm_data.each_with_index do |data_row, row_num| |
| 88 | + # Initially the previous x,y points are nil and then |
| 89 | + # they are set with values. |
| 90 | + prev_x = prev_y = nil |
| 91 | + |
| 92 | + @one_point = contains_one_point_only?(data_row) |
| 93 | + |
| 94 | + @d = @d.fill @plot_colors[row_num] |
| 95 | + data_row[DATA_VALUES_INDEX].each_with_index do |data_point, index| |
| 96 | + x_data = data_row[DATA_VALUES_X_INDEX] |
| 97 | + if x_data.nil? |
| 98 | + new_x = @graph_left + (@x_increment * index) |
| 99 | + draw_label(new_x, index) |
| 100 | + else |
| 101 | + new_x = get_x_coord(x_data[index], @graph_width, @graph_left) |
| 102 | + @labels.each do |label_pos, _| |
| 103 | + draw_label(@graph_left + ((label_pos - |
| 104 | + @geometry.minimum_x_value) * |
| 105 | + @graph_width) / |
| 106 | + (@geometry.maximum_x_value - |
| 107 | + @geometry.minimum_x_value), label_pos) |
| 108 | + end |
| 109 | + end |
| 110 | + unless data_point |
| 111 | + # we can't draw a line for a null data point, we can still label the axis though |
| 112 | + prev_x = prev_y = nil |
| 113 | + next |
| 114 | + end |
| 115 | + new_y = @graph_top + (@graph_height - data_point * @graph_height) |
| 116 | + # Reset each time to avoid thin-line errors. |
| 117 | + # @d = @d.stroke data_row[DATA_COLOR_INDEX] |
| 118 | + # @d = @d.fill data_row[DATA_COLOR_INDEX] |
| 119 | + @d = @d.stroke_opacity 1.0 |
| 120 | + @d = @d.stroke_width line_width || |
| 121 | + clip_value_if_greater_than(@columns / (@geometry.norm_data.first[DATA_VALUES_INDEX].size * 4), 5.0) |
| 122 | + |
| 123 | + circle_radius = dot_radius || |
| 124 | + clip_value_if_greater_than(@columns / (@geometry.norm_data.first[DATA_VALUES_INDEX].size * 2.5), 5.0) |
| 125 | + |
| 126 | + if !@geometry.hide_lines && !prev_x.nil? && !prev_y.nil? |
| 127 | + @d = @d.line(prev_x, prev_y, new_x, new_y) |
| 128 | + elsif @one_point |
| 129 | + # Show a circle if there's just one_point |
| 130 | + @d = DotRenderers.renderer(@geometry.dot_style).render(@d, new_x, new_y, circle_radius) |
| 131 | + end |
| 132 | + |
| 133 | + unless @geometry.hide_dots |
| 134 | + @d = DotRenderers.renderer(@geometry.dot_style).render(@d, new_x, new_y, circle_radius) |
| 135 | + end |
| 136 | + |
| 137 | + prev_x = new_x |
| 138 | + prev_y = new_y |
| 139 | + end |
| 140 | + end |
| 141 | + |
| 142 | + @d.draw(@base_image) |
| 143 | + end |
| 144 | + |
| 145 | + # Returns the X co-ordinate of a given data point. |
| 146 | + def get_x_coord(x_data_point, width, offset) |
| 147 | + x_data_point * width + offset |
| 148 | + end |
| 149 | + |
| 150 | + # This method allows one to plot a dataset with both X and Y data. |
| 151 | + # |
| 152 | + # Parameters: |
| 153 | + # name: string, the title of the datasets. |
| 154 | + # x_data_points: an array containing the x data points for the graph. |
| 155 | + # y_data_points: an array containing the y data points for the graph. |
| 156 | + # |
| 157 | + # or |
| 158 | + # |
| 159 | + # name: string, the title of the dataset. |
| 160 | + # xy_data_points: an array containing both x and y data points for the graph. |
| 161 | + # |
| 162 | + # |
| 163 | + # Notes: |
| 164 | + # -if (x_data_points.length != y_data_points.length) an error is |
| 165 | + # returned. |
| 166 | + # -if you want to use a preset theme, you must set it before calling |
| 167 | + # dataxy(). |
| 168 | + # |
| 169 | + # Example: |
| 170 | + # g = Rubyplot::Line.new |
| 171 | + # g.title = "X/Y Dataset" |
| 172 | + # g.dataxy("Apples", [1,3,4,5,6,10], [1, 2, 3, 4, 4, 3]) |
| 173 | + # g.dataxy("Bapples", [1,3,4,5,7,9], [1, 1, 2, 2, 3, 3]) |
| 174 | + # g.dataxy("Capples", [[1,1],[2,3],[3,4],[4,5],[5,7],[6,9]]) |
| 175 | + # #you can still use the old data method too if you want: |
| 176 | + # g.data("Capples", [1, 1, 2, 2, 3, 3]) |
| 177 | + # #labels will be drawn at the x locations of the keys passed in. |
| 178 | + # In this example the lables are drawn at x positions 2, 4, and 6: |
| 179 | + # g.labels = {0 => '2003', 2 => '2004', 4 => '2005', 6 => '2006'} |
| 180 | + # The 0 => '2003' label will be ignored since it is outside the chart range. |
| 181 | + def dataxy(_name, x_data_points = [], y_data_points = [], _color = nil) |
| 182 | + raise ArgumentError, 'x_data_points is nil!' if x_data_points.empty? |
| 183 | + |
| 184 | + if x_data_points.all? { |p| p.is_a?(Array) && p.size == 2 } |
| 185 | + y_data_points = x_data_points.map { |p| p[1] } |
| 186 | + x_data_points = x_data_points.map { |p| p[0] } |
| 187 | + end |
| 188 | + |
| 189 | + raise ArgumentError, 'x_data_points.length != y_data_points.length!' if |
| 190 | + x_data_points.length != y_data_points.length |
| 191 | + |
| 192 | + # call the existing data routine for the y data. |
| 193 | + data(y_data_points, label: :name) |
| 194 | + |
| 195 | + x_data_points = Array(x_data_points) # make sure it's an array |
| 196 | + # append the x data to the last entry that was just added in the @data member |
| 197 | + @data.last[DATA_VALUES_X_INDEX] = x_data_points |
| 198 | + |
| 199 | + # Update the global min/max values for the x data |
| 200 | + x_data_points.each do |x_data_point| |
| 201 | + next if x_data_point.nil? |
| 202 | + |
| 203 | + # Setup max/min so spread starts at the low end of the data points |
| 204 | + if @geometry.maximum_x_value.nil? && @geometry.minimum_x_value.nil? |
| 205 | + @geometry.maximum_x_value = @geometry.minimum_x_value = x_data_point |
| 206 | + end |
| 207 | + |
| 208 | + @geometry.maximum_x_value = x_data_point > @geometry.maximum_x_value ? |
| 209 | + x_data_point : @geometry.maximum_x_value |
| 210 | + @geometry.minimum_x_value = x_data_point < @geometry.minimum_x_value ? |
| 211 | + x_data_point : @geometry.minimum_x_value |
| 212 | + end |
| 213 | + end |
| 214 | + |
| 215 | + def normalize |
| 216 | + # First call the standard math function to normalize the values based on spread. |
| 217 | + super |
| 218 | + # TODO: Take care of the reference_lines |
| 219 | + |
| 220 | + # normalize the x data if it is specified |
| 221 | + @data.each_with_index do |data_row, index| |
| 222 | + norm_x_data_points = [] |
| 223 | + next if data_row[DATA_VALUES_X_INDEX].nil? |
| 224 | + data_row[DATA_VALUES_X_INDEX].each do |x_data_point| |
| 225 | + norm_x_data_points << ((x_data_point.to_f - |
| 226 | + @geometry.minimum_x_value.to_f) / |
| 227 | + (@geometry.maximum_x_value.to_f - |
| 228 | + @geometry.minimum_x_value.to_f)) |
| 229 | + end |
| 230 | + @geometry.norm_data[index] << norm_x_data_points |
| 231 | + end |
| 232 | + end |
| 233 | + |
| 234 | + def sort_norm_data |
| 235 | + super unless @data.any? { |d| d[DATA_VALUES_X_INDEX] } |
| 236 | + end |
| 237 | + |
| 238 | + def contains_one_point_only?(data_row) |
| 239 | + # A helper function that acts as a sanity check for the data. |
| 240 | + # It spins through data to determine if there is just one_value present. |
| 241 | + one_point = false |
| 242 | + data_row[DATA_VALUES_INDEX].each do |data_point| |
| 243 | + next if data_point.nil? |
| 244 | + if one_point |
| 245 | + # more than one point, bail |
| 246 | + return false |
| 247 | + end |
| 248 | + # there is at least one data point |
| 249 | + one_point = true |
| 250 | + end |
| 251 | + one_point |
| 252 | + end |
| 253 | + end |
| 254 | + end |
| 255 | + end |
| 256 | +end |
0 commit comments