Monday 15 June 2009

Displaying Trends on a Graph Image

This builds on my last two posts on drawing images for Rails with Java.

My objective here was to display values from a series of records on a graph to show possible trends (specifically analytical results for samples submitted to a lab). There are two quite separate issue here involving firstly extracting data from the database table, and secondly creating an image of a line graph on-the-fly.

Getting the Data
Step one, then, is to grab the data. The easy way is to just use find all:
ary = Worksheet.find :all

However, that could involve a lot of data coming across from your database that you do not need. We are only interested in the contents of one column. Better to just extract out the data from that column.
ary = Worksheet.find :all, :select => 'result'

This gives us an array of Worksheet objects, but each object has only the specific column set to a value. We need to grab those values, and the map method gives an easy way to convert to an array of numbers. This can all go inside a class method in Worksheet (in this case the name of the column is in a variable, column_name, to keep it general).
def self.collect_data column_name
Worksheet.find(:all, :select => column_name).map do |e|
e.send(column_name)
end
end

Draw the Graph
Step two is drawing the graph (see also here for drawing with Java on Rails). I created a new Ruby class in its own file in the models directory, using class methods. I wanted the graph to be able to scale itself, but for the lower and upper bounds to be round numbers; the graph_limits methods handles that.

Here is the basis of the class. Java needs to be included, and for convenience I have imported all the Java classes I will be using. Then I set up some constants:
include Java

class Graph

java_import java.io.ByteArrayOutputStream
java_import java.awt.image.BufferedImage
java_import java.awt.Color
java_import java.awt.RenderingHints
java_import java.awt.geom.GeneralPath
java_import java.awt.geom.Line2D
java_import java.awt.Font
java_import java.awt.BasicStroke
java_import javax.imageio.ImageIO

WIDTH = 800
HEIGHT = 600
FOREGROUND = Color.new 0.2, 0.2, 0.2
BACKGROUND = Color.new 0.95, 0.95, 0.95
PLOT = Color.blue
FONT = Font.new("SansSerif", Font::PLAIN, 22)

end

I wrote three helper methods (all protected). Note that the second uses the find_best method I described on the blocks page;
  # Converts the data point to a vertical
# position on the image,
# scaled between min and max.
def self.calc y, min, max
19.0 * HEIGHT / 20 - (y - min) *
18 * HEIGHT / (max - min) / 20
end

# Determines the minimum and maximum
# values from the data, and rounds them
# down and up respectively to give lower
# and upper display limits.
def self.graph_limits data
# See my blocks page for find_best method
min = data.find_best { |x, y| x > y }
max = data.find_best { |x, y| x < y }
modifier = 10 **
-(Math.log10(max - min).floor)
min = (min * modifier).floor *
1.0 / modifier
max = (max * modifier).ceil *
1.0 / modifier
# Ruby allows a method to return
# multiple values
return min, max
end

# Draws a line and label for the y-axis
def self.graph_line g2, text, h
g2.drawString(text, 0, h)
# Note that Line2D uses inner
# classes, accessed via ::
g2.draw(Line2D::Double.new(WIDTH / 10,
h, 9 * WIDTH / 10, h));
end

Now that we have the foundations, we can draw the graph.
  def self.line_graph data
min, max = graph_limits data

# Set up image object
off_image = BufferedImage.new(WIDTH, HEIGHT,
BufferedImage::TYPE_INT_ARGB)
g2 = off_image.createGraphics()
g2.setRenderingHint(RenderingHints::KEY_ANTIALIASING,
RenderingHints::VALUE_ANTIALIAS_ON)

# Set up background
g2.setPaint(BACKGROUND)
g2.fillRect(0, 0, WIDTH - 1, HEIGHT - 1)
# Note: You get wierd results if you do not
# fill your background
g2.setPaint(FOREGROUND)
g2.drawRect(0, 0, WIDTH - 1, HEIGHT - 1)

# Set up the y-axis (no x-axis drawn)
g2.setFont(FONT)
graph_line g2, min.to_s[0..5], HEIGHT * 9 / 10
graph_line g2, max.to_s[0..5], HEIGHT / 10
graph_line g2, ((min + max) / 2).to_s[0..5],
HEIGHT / 2

# Convert data to image coordinates
x_points = Array.new(data.length) do |i|
(i * 8.0 * WIDTH / (data.length - 1) / 10) +
WIDTH / 10
end
y_points = Array.new(data.length) do |i|
calc(data[i], min, max)
end

# Generate a GeneralPath object from the
# coordinates
polygon = GeneralPath.new(
GeneralPath::WIND_EVEN_ODD,
data.length)
polygon.moveTo(x_points[0], y_points[0])
(1...x_points.length).each do |index|
polygon.lineTo(x_points[index],
y_points[index])
end

# Draw the GeneralPath object
g2.setPaint(PLOT)
g2.setStroke(BasicStroke.new(1.5))
g2.draw(polygon)

# Convert for web image
os = java.io.ByteArrayOutputStream.new
ImageIO.write(off_image, "gif", os)
String.from_java_bytes(os.toByteArray)
end
Ptting it Together
Step 3 is to put it all together. In my controller I put in a new method (this needs to be mentioned as a collection in your routes, as it has no specific record associated with it):
  def graph
respond_to do |format|
format.html
format.gif do
send_data Graph.line_graph(
Worksheet.collect_data('result')),
:type => "image/gif",
:disposition => "inline"
end
end
end

The important part is the bit starting with send_data. The collect_data method is invoked on Worksheet, returning an array of values from the "result" column of the database table. This is sent to the line_graph method of Graph, which generates the image.

Finally, we need a view. This must be called graph.html.erb, of course, and should include the following:
<%= image_tag "/Worksheet/graph/x.gif", :size => "800x600" %>

If this was an image for a specific record in your table, you would need the id of the record. As this image is for a collection of records, no id is applicable. Nevertheless, you need something there, so I have called in "x".

Struggling with Ruby: Contents Page

1 comment:

atlas245 said...

I thought the post made some good points on extracting data, For extracting data i use python for simple things, data extraction can be a time consuming process but for larger projects like documents, files, or the web i tried "extracting data from the web" which worked great, they build quick custom screen scrapers, extracting data, and data parsing programs