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{