lib/SVG/Graph/Graph.rb in svg-graph-2.0.2 vs lib/SVG/Graph/Graph.rb in svg-graph-2.1.0.beta1

- old
+ new

@@ -8,11 +8,11 @@ module SVG module Graph # === Base object for generating SVG Graphs - # + # # == Synopsis # # This class is only used as a superclass of specialized charts. Do not # attempt to use this class directly, unless creating a new chart type. # @@ -26,22 +26,22 @@ # # * file:test/plot.rb # * file:test/single.rb # * file:test/test.rb # * file:test/timeseries.rb - # + # # == Description - # + # # This package should be used as a base for creating SVG graphs. # # == Acknowledgements # # Leo Lapworth for creating the SVG::TT::Graph package which this Ruby # port is based on. # # Stephen Morgan for creating the TT template and SVG. - # + # # == See # # * SVG::Graph::BarHorizontal # * SVG::Graph::Bar # * SVG::Graph::Line @@ -61,10 +61,12 @@ # Initialize the graph object with the graph settings. You won't # instantiate this class directly; see the subclass for options. # [width] 500 # [height] 300 + # [x_axis_position] nil + # [y_axis_position] nil # [show_x_guidelines] false # [show_y_guidelines] true # [show_data_values] true # [min_scale_value] 0 # [show_x_labels] true @@ -96,11 +98,11 @@ # [y_label_font_size] 12 # [y_title_font_size] 14 # [key_font_size] 10 # [no_css] false # [add_popups] false - # [number_format] '%.2f' + # [number_format] '%.2f' def initialize( config ) @config = config @data = [] #self.top_align = self.top_font = 0 #self.right_align = self.right_font = 0 @@ -110,10 +112,13 @@ :height => 300, :show_x_guidelines => false, :show_y_guidelines => true, :show_data_values => true, + :x_axis_position => nil, + :y_axis_position => nil, + :min_scale_value => nil, :show_x_labels => true, :stagger_x_labels => false, :rotate_x_labels => false, @@ -136,42 +141,42 @@ :show_graph_title => false, :graph_title => 'Graph Title', :show_graph_subtitle => false, :graph_subtitle => 'Graph Sub Title', - :key => true, + :key => true, :key_position => :right, # bottom or right :font_size =>12, :title_font_size =>16, :subtitle_font_size =>14, :x_label_font_size =>12, :y_label_font_size =>12, :x_title_font_size =>14, :y_title_font_size =>14, :key_font_size =>10, - + :no_css =>false, :add_popups =>false, - :number_format => '%.2f' + :number_format => '%.2f' }) set_defaults if self.respond_to? :set_defaults init_with config end - + # This method allows you do add data to the graph object. # It can be called several times to add more data sets in. # # data_sales_02 = [12, 45, 21]; - # + # # graph.add_data({ # :data => data_sales_02, # :title => 'Sales 2002' # }) def add_data conf - @data = [] unless (defined? @data and !@data.nil?) + @data = [] unless (defined? @data and !@data.nil?) if conf[:data] and conf[:data].kind_of? Array @data << conf else raise "No data provided by #{conf.inspect}" @@ -181,26 +186,26 @@ # This method removes all data from the object so that you can # reuse it to create a new graph but with the same config options. # # graph.clear_data - def clear_data + def clear_data @data = [] end # This method processes the template with the data and # config which has been set and returns the resulting SVG. # # This method will croak unless at least one data set has # been added to the graph object. - # + # # print graph.burn - # + # def burn raise "No data available" unless @data.size > 0 - + start_svg calculate_graph_dimensions @foreground = Element.new( "g" ) draw_graph draw_titles @@ -221,18 +226,18 @@ data = inp.read else data << "<!-- Ruby Zlib not available for SVGZ -->"; end end - + return data end - - # Burns the graph but returns only the <svg> node as String without the + + # Burns the graph but returns only the <svg> node as String without the # Doctype and XML Declaration. This allows easy integration into # existing xml documents. - # + # # @return [String] the SVG node which represents the Graph def burn_svg_only # initialize all instance variables by burning the graph burn f = REXML::Formatters::Pretty.new(0) @@ -258,46 +263,59 @@ # default internal version and copy the stylesheet section to # an external file and edit from there. attr_accessor :style_sheet # (Bool) Show the value of each element of data on the graph attr_accessor :show_data_values + # By default (nil/undefined) the x-axis is at the bottom of the graph. + # With this property a custom position for the x-axis can be defined. + # Valid values are between :min_scale_value and maximum value of the + # data. + # Default: nil + attr_accessor :x_axis_position + # By default (nil/undefined) the y-axis is the left border of the graph. + # With this property a custom position for the y-axis can be defined. + # Valid values are any values in the range of x-values (in case of a + # Plot) or any of the :fields values (in case of Line/Bar Graphs, note + # the '==' operator is used to find at which value to draw the axis). + # Default: nil + attr_accessor :y_axis_position # The point at which the Y axis starts, defaults to nil, # if set to nil it will default to the minimum data value. attr_accessor :min_scale_value # Whether to show labels on the X axis or not, defaults # to true, set to false if you want to turn them off. attr_accessor :show_x_labels # This puts the X labels at alternative levels so if they # are long field names they will not overlap so easily. - # Default it false, to turn on set to true. + # Default is false, to turn on set to true. attr_accessor :stagger_x_labels # This puts the Y labels at alternative levels so if they # are long field names they will not overlap so easily. - # Default it false, to turn on set to true. + # Default is false, to turn on set to true. attr_accessor :stagger_y_labels # This turns the X axis labels by 90 degrees. - # Default it false, to turn on set to true. + # Default is false, to turn on set to true. attr_accessor :rotate_x_labels # This turns the Y axis labels by 90 degrees. - # Default it true, to turn on set to false. + # Default is true, to turn on set to false. attr_accessor :rotate_y_labels # How many "steps" to use between displayed X axis labels, # a step of one means display every label, a step of two results # in every other label being displayed (label <gap> label <gap> label), # a step of three results in every third label being displayed # (label <gap> <gap> label <gap> <gap> label) and so on. attr_accessor :step_x_labels - # Whether to (when taking "steps" between X axis labels) step from + # Whether to (when taking "steps" between X axis labels) step from # the first label (i.e. always include the first label) or step from # the X axis origin (i.e. start with a gap if step_x_labels is greater # than one). attr_accessor :step_include_first_x_label # Whether to show labels on the Y axis or not, defaults # to true, set to false if you want to turn them off. attr_accessor :show_y_labels # Ensures only whole numbers are used as the scale divisions. - # Default it false, to turn on set to true. This has no effect if + # Default is false, to turn on set to true. This has no effect if # scale divisions are less than 1. attr_accessor :scale_integers # This defines the gap between markers on the Y axis, # default is a 10th of the max_value, e.g. you will have # 10 markers on the Y axis. NOTE: do not set this too @@ -313,11 +331,11 @@ # at the :end of the axis. Defaults to :middle attr_accessor :x_title_location # Whether to show the title under the Y axis labels, # default is false, set to true to show. attr_accessor :show_y_title - # Aligns writing mode for Y axis label. + # Aligns writing mode for Y axis label. # Defaults to :bt (Bottom to Top). # Change to :tb (Top to Bottom) to reverse. attr_accessor :y_title_text_direction # What the title under Y axis should be, e.g. 'Sales in thousands'. attr_accessor :y_title @@ -381,11 +399,11 @@ # If you don't want to change the format in any way you can use "%s". Defaults to "%.2f" attr_accessor :number_format protected - + # implementation of quicksort # used for Schedule and Plot def sort( *arrys ) sort_multiple( arrys ) end @@ -409,11 +427,11 @@ def calculate_left_margin @border_left = 7 # Check size of Y labels @border_left += max_y_label_width_px if (show_y_title && (y_title_location ==:middle)) - @border_left += y_title_font_size + 5 + @border_left += y_title_font_size + 5 end end # Calculates the width of the widest Y label. This will be the # character height if the Y labels are rotated. Returns 0 if labels @@ -437,16 +455,16 @@ # or the maximum of this value or the tilte length (if title is placed at :end) def calculate_right_margin @border_right = 7 if key and key_position == :right val = keys.max { |a,b| a.length <=> b.length } - @border_right += val.length * key_font_size * 0.6 + @border_right += val.length * key_font_size * 0.6 @border_right += KEY_BOX_SIZE @border_right += 10 # Some padding around the box end if (x_title_location == :end) - @border_right = [@border_right, x_title.length * x_title_font_size * 0.6].max + @border_right = [@border_right, x_title.length * x_title_font_size * 0.6].max end end # Override this (and call super) to change the margin to the top @@ -481,38 +499,38 @@ }) t.attributes["style"] = "stroke-width: 2; fill: #000; #{style}"+ (x+txt_width > width ? "text-anchor: end;" : "text-anchor: start;") t.text = label.to_s t.attributes["id"] = t.object_id.to_s - + # add a circle to catch the mouseover @foreground.add_element( "circle", { "cx" => x.to_s, "cy" => y.to_s, "r" => "#{popup_radius}", "style" => "opacity: 0", - "onmouseover" => + "onmouseover" => "document.getElementById(#{t.object_id}).setAttribute('visibility', 'visible' )", - "onmouseout" => + "onmouseout" => "document.getElementById(#{t.object_id}).setAttribute('visibility', 'hidden' )", }) end # if add_popups end # add_popup # returns the longest label from an array of labels as string # each object in the array must support .to_s def get_longest_label(arry) - longest_label = arry.max{|a,b| + longest_label = arry.max{|a,b| # respect number_format - a = @number_format % a if numeric?(a) + a = @number_format % a if numeric?(a) b = @number_format % b if numeric?(b) a.to_s.length <=> b.to_s.length } longest_label = @number_format % longest_label if numeric?(longest_label) return longest_label end - + # Override this (and call super) to change the margin to the bottom # of the plot area. Results in @border_bottom being set. # # 7 + max label height(font size or string length, depending on rotate) + title height def calculate_bottom_margin @@ -524,17 +542,17 @@ @border_bottom += max_x_label_height_px if (show_x_title && (x_title_location ==:middle)) @border_bottom += x_title_font_size + 5 end end - + # returns the maximum height of the labels respect the rotation or 0 if # the labels are not shown def max_x_label_height_px return 0 if !show_x_labels - - if rotate_x_labels + + if rotate_x_labels max_height = get_longest_label(get_x_labels).to_s.length * x_label_font_size * 0.6 else max_height = x_label_font_size + 3 end max_height += 5 + x_label_font_size if stagger_x_labels @@ -555,36 +573,96 @@ "width" => @graph_width.to_s, "height" => @graph_height.to_s, "class" => "graphBackground" }) - # Axis + draw_x_axis + draw_y_axis + + draw_x_labels + draw_y_labels + end + + # draws the x-axis; can be overridden by child classes + def draw_x_axis + # relative position on y-axis (hence @graph_height is our axis length) + relative_position = calculate_rel_position(get_y_labels, field_height, @x_axis_position, @graph_height) + # X-Axis + y_offset = (1 - relative_position) * @graph_height @graph.add_element( "path", { - "d" => "M 0 0 v#@graph_height", + "d" => "M 0 #{y_offset} h#@graph_width", "class" => "axis", - "id" => "xAxis" + "id" => "yAxis" }) + end + + # draws the y-axis; can be overridden by child classes + def draw_y_axis + # relative position on x-axis (hence @graph_width is our axis length) + relative_position = calculate_rel_position(get_x_labels, field_width, @y_axis_position, @graph_width) + # Y-Axis + x_offset = relative_position * @graph_width @graph.add_element( "path", { - "d" => "M 0 #@graph_height h#@graph_width", + "d" => "M #{x_offset} 0 v#@graph_height", "class" => "axis", - "id" => "yAxis" + "id" => "xAxis" }) + end - draw_x_labels - draw_y_labels + # calculates the relative position betewen 0 and 1 of a value on the axis + # can be multiplied with either @graph_height or @graph_width to get the + # absolute position in pixels. + # If labels are strings, checks if one of label matches with the value + # and returns this position. + # If labels are numeric, compute relative position between first and last value + # If nothing else applies or the value is nil, the relative position is 0 + # @param labels [Array] the array of x or y labels, see {#get_x_labels} or {#get_y_labels} + # @param segment_px [Float] number of pixels per label, see {#field_width} or {#field_height} + # @param value [Numeric, String] the value for which the relative position is computed + # @param axis_length [Numeric] either @graph_width or @graph_height + # @return [Float] relative position between 0 and 1, returns 0 + def calculate_rel_position(labels, segment_px, value, axis_length) + # default value, y-axis on the left side, or x-axis at bottom + # puts "calculate_rel_position:" + # p labels + # p segment_px + # p value + # p axis_length + relative_position = 0 + if !value.nil? # only + if (labels[0].is_a? Numeric) && (labels[-1].is_a? Numeric) && (value.is_a? Numeric) + # labels are numeric, compute relative position between first and last value + range = labels[-1] - labels[0] + position = value - labels[0] + # compute how many segments long the offset is + relative_to_segemts = position/range * (labels.size - 1) + # convert from segments to relative position on the axis axis, + # the number of segments (i.e. relative_to_segemts >= 1) + relative_position = relative_to_segemts * segment_px / axis_length + elsif labels[0].is_a? String + # labels are strings, see if one of label matches with the position + # and place the axis there + index = labels.index(value) + if !index.nil? # index would be nil if label is not found + offset_px = segment_px * index + relative_position = offset_px/axis_length # between 0 and 1 + end + end + end # value.nil? + return relative_position end - # Where in the X area the label is drawn # Centered in the field, should be width/2. Start, 0. def x_label_offset( width ) 0 end - + # check if an object can be converted to float def numeric?(object) - true if Float(object) rescue false + # true if Float(object) rescue false + object.is_a? Numeric end # adds the datapoint text to the graph only if the config option is set def make_datapoint_text( x, y, value, style="" ) if show_data_values @@ -602,75 +680,75 @@ @foreground.add_element( "text", { "x" => x.to_s, "y" => y.to_s, "class" => "dataPointLabel", "style" => "#{style} stroke: #fff; stroke-width: 2;" - }).text = textStr + }).text = textStr # actual label text = @foreground.add_element( "text", { "x" => x.to_s, "y" => y.to_s, "class" => "dataPointLabel" }) text.text = textStr text.attributes["style"] = style if style.length > 0 end end - - # Draws the X axis labels + + # Draws the X axis labels. The x-axis (@graph_width) is diveded into + # {#get_x_labels.length} equal sections. The (center) x-coordinate for a + # label hence is label_index * width_of_section def draw_x_labels stagger = x_label_font_size + 5 - if show_x_labels - label_width = field_width - - count = 0 - for label in get_x_labels - if step_include_first_x_label == true then - step = count % step_x_labels - else - step = (count + 1) % step_x_labels + label_width = field_width + count = 0 + x_axis_already_drawn = false + for label in get_x_labels + if step_include_first_x_label == true then + step = count % step_x_labels + else + step = (count + 1) % step_x_labels + end + # only draw every n-th label as defined by step_x_labels + if step == 0 && show_x_labels then + textStr = label.to_s + if( numeric?(label) ) + textStr = @number_format % label end + text = @graph.add_element( "text" ) + text.attributes["class"] = "xAxisLabels" + text.text = textStr - if step == 0 then - label = label.to_s - if( numeric?(label) ) - label = @number_format % label - end - text = @graph.add_element( "text" ) - text.attributes["class"] = "xAxisLabels" - text.text = label.to_s + x = count * label_width + x_label_offset( label_width ) + y = @graph_height + x_label_font_size + 3 + #t = 0 - (font_size / 2) - x = count * label_width + x_label_offset( label_width ) - y = @graph_height + x_label_font_size + 3 - #t = 0 - (font_size / 2) + if stagger_x_labels and count % 2 == 1 + y += stagger + @graph.add_element( "path", { + "d" => "M#{x} #@graph_height v#{stagger}", + "class" => "staggerGuideLine" + }) + end - if stagger_x_labels and count % 2 == 1 - y += stagger - @graph.add_element( "path", { - "d" => "M#{x} #@graph_height v#{stagger}", - "class" => "staggerGuideLine" - }) - end - - text.attributes["x"] = x.to_s - text.attributes["y"] = y.to_s - if rotate_x_labels - text.attributes["transform"] = - "rotate( 90 #{x} #{y-x_label_font_size} )"+ - " translate( 0 -#{x_label_font_size/4} )" - text.attributes["style"] = "text-anchor: start" - else - text.attributes["style"] = "text-anchor: middle" - end + text.attributes["x"] = x.to_s + text.attributes["y"] = y.to_s + if rotate_x_labels + text.attributes["transform"] = + "rotate( 90 #{x} #{y-x_label_font_size} )"+ + " translate( 0 -#{x_label_font_size/4} )" + text.attributes["style"] = "text-anchor: start" + else + text.attributes["style"] = "text-anchor: middle" end + end # if step == 0 && show_x_labels - draw_x_guidelines( label_width, count ) if show_x_guidelines - count += 1 - end - end - end + draw_x_guidelines( label_width, count ) if show_x_guidelines + count += 1 + end # for label in get_x_labels + end # draw_x_labels # Where in the Y area the label is drawn # Centered in the field, should be width/2. Start, 0. def y_label_offset( height ) @@ -679,21 +757,21 @@ # override this method in child class # must return the array of labels for the x-axis def get_x_labels end - + # override this method in child class # must return the array of labels for the y-axis # this method defines @y_scale_division def get_y_labels end - + # space in px between x-labels def field_width # -1 is to use entire x-axis - # otherwise there is always 1 division unused + # otherwise there is always 1 division unused @graph_width.to_f / ( get_x_labels.length - 1 ) end # space in px between the y-labels def field_height @@ -702,25 +780,24 @@ @graph_height.to_f / get_y_labels.length end # Draws the Y axis labels, the Y-Axis (@graph_height) is divided equally into #get_y_labels.lenght sections - # So the y coordinate for an arbitrary value is calculated as follows: + # So the y coordinate for an arbitrary value is calculated as follows: # y = @graph_height equals the min_value - # #normalize value of a single scale_division: - # count = value /(@y_scale_division) - # y = @graph_height - count * field_height - # + # #normalize value of a single scale_division: + # count = value /(@y_scale_division) + # y = @graph_height - count * field_height + # def draw_y_labels stagger = y_label_font_size + 5 - if show_y_labels - label_height = field_height - - count = 0 - y_offset = @graph_height + y_label_offset( label_height ) - y_offset += font_size/1.2 unless rotate_y_labels - for label in get_y_labels + label_height = field_height + count = 0 + y_offset = @graph_height + y_label_offset( label_height ) + y_offset += font_size/1.2 unless rotate_y_labels + for label in get_y_labels + if show_y_labels y = y_offset - (label_height * count) x = rotate_y_labels ? 0 : -3 if stagger_y_labels and count % 2 == 1 x -= stagger @@ -746,29 +823,29 @@ text.attributes["style"] = "text-anchor: middle" else text.attributes["y"] = (y - (y_label_font_size/2)).to_s text.attributes["style"] = "text-anchor: end" end - draw_y_guidelines( label_height, count ) if show_y_guidelines - count += 1 - end - end - end + end # if show_y_labels + draw_y_guidelines( label_height, count ) if show_y_guidelines + count += 1 + end # for label in get_y_labels + end # draw_y_labels - # Draws the X axis guidelines + # Draws the X axis guidelines, parallel to the y-axis def draw_x_guidelines( label_height, count ) if count != 0 @graph.add_element( "path", { "d" => "M#{label_height*count} 0 v#@graph_height", "class" => "guideLines" }) end end - # Draws the Y axis guidelines + # Draws the Y axis guidelines, parallel to the x-axis def draw_y_guidelines( label_height, count ) if count != 0 @graph.add_element( "path", { "d" => "M0 #{@graph_height-(label_height*count)} h#@graph_width", "class" => "guideLines" @@ -786,11 +863,11 @@ "class" => "mainTitle" }).text = graph_title.to_s end if show_graph_subtitle - y_subtitle = show_graph_title ? + y_subtitle = show_graph_title ? title_font_size + subtitle_font_size + 5 : subtitle_font_size @root.add_element("text", { "x" => (width / 2).to_s, "y" => (y_subtitle).to_s, @@ -838,11 +915,11 @@ end end end end # draw_titles - def keys + def keys i = 0 return @data.collect{ |d| i+=1; d[:title] || "Serie #{i}" } end # Draws the legend on the graph @@ -891,11 +968,11 @@ if lo < hi p = partition(arrys,lo,hi) sort_multiple(arrys, lo, p-1) sort_multiple(arrys, p+1, hi) end - arrys + arrys end def partition( arrys, lo, hi ) p = arrys[0][lo] l = lo @@ -953,15 +1030,15 @@ # Override and place code to add defs here # @param defs [REXML::Element] def add_defs defs end - # Creates the XML document and adds the root svg element with + # Creates the XML document and adds the root svg element with # the width, height and viewBox attributes already set. - # The element is stored as @root. + # The element is stored as @root. # - # In addition a rectangle background of the same size as the + # In addition a rectangle background of the same size as the # svg is added. # def start_svg # Base document @doc = Document.new @@ -1094,10 +1171,10 @@ } .staggerGuideLine{ fill: none; stroke: #000000; - stroke-width: 0.5px; + stroke-width: 0.5px; } #{get_css} .keyText{