Rails Against The Machine

Just a mind dump. Why are you even reading this?

Monday, 7 July 2008

 

Open flash chart v2

ruby class for generating the json maybe roll this into a plug in at some point

[http://teethgrinder.co.uk/open-flash-chart/ Open Flash Chart 1.x] was great and there is an [http://code.google.com/p/rails-open-flash-chart-plugin/ existing rails plugin].
However [http://teethgrinder.co.uk/open-flash-chart-2/ Open Flash Chart 2.x] is better and uses json. so most of the existing rails plugin will not work and indeed most of the code is redundant.

This plugin provides (or will provide)
* Helper method for embedding open chart objects in views
* Object(s) representing the chart which can be serialised to json
* Higher level helper functions to assist people in easily producing good looking charts

*example 1: Simple line graph from array data*

In your view you insert the following

<%=javascript_include_tag('swfobject.js')%>
<%
= flash_chart "target_div",800,600, :controller => 'chart_controller', :action => 'chart_data' %>
<div id="target_div"
></div>


The corresponding controller action would be

def chart_data
chart=FlashChart::Chart.new
chart.add_title('Existential Boredom level')
chart.line_graph(['1','2','3''4','5','6','7','8','9','10'],[1.5,1.69,1.88,2.06,2.21,2.34,nil,2.35,2.23,2.08])
render :json=>chart.to_json
end


*example 2: Line graph from monthly scatter data*


include FlashChart

def line_from_xy_scatter

#get your data from somewhere
points=PointList.new(:monthly, data.map{|e| Point.new( e.date, e.value)})

#make some labels for the x axis
start_date=3.years.ago
end_date=Date.today
labels=make_labels(:monthly,start_date,end_date)

#make the chart
chart=Chart.new true
chart.add_title "sunspot data", "{font-size: 12px; color:#736AFF;}"
chart.line_graph( labels,points)
render :json=>chart.to_json
end



OpenFlash.rb

#This module defines libraries to produce the json consumed by open flash chart
module FlashChart

#this is the main class which represents the overall chart
# and is serialised to json
class Chart
attr_accessor :elements, :title, :x_axis, :y_axis

def initialize(defaults=false)
if(defaults)
@bg_colour = '#FFFFFF'
end
end

def add_series series
@elements=[] unless elements
@elements<< series

end

def add_title(text, style='{color: #7E97A6; font-size: 20; text-align: center}')
@title= {:text=>text}
@title.merge! :style=>style
end

#first series added using this method defines the x and y axis scales
def line_graph(x_labels,y_data)
#accept only pointlists and arrays
if y_data.is_a?(PointList)
y_data=y_data.to_line_series(x_labels)
elsif !y_data.is_a?(Array)
return
end
@x_axis=XAxis.new(x_labels) unless @x_axis
non_nil_values=y_data.select{|e|e!=nil}

if @y_axis
add_to_max(non_nil_values.min,non_nil_values.max)
else
@y_axis=YAxis.new(non_nil_values.min,non_nil_values.max)
end
add_series LineSeries.new(y_data)
end

def to_json(options = {})
json_string= super
options.merge! :replace=>{:dot_size=>'dot-size',:grid_colour=>'grid-colour',:font_size=>'font-size', :tick_length=>'tick-length', :tick_height=>'tick-height'}
options[:replace].each_pair {|key, value| json_string.gsub!(/#{key.to_s}/, value.to_s) }
json_string
end

def add_to_max(min,max)
if(min&&max)
@y_axis.max=[@y_axis.max,max].max
@y_axis.min=[@y_axis.min,min].min
end
end

end

#represents a x axis label
class Labels
attr_accessor :labels, :steps
def initialize labels=nil
if(labels)
@labels=labels
#Number of steps should be calculated so that there is a lable width's space between each label
#thus it should depend on the number of labels, size of chart the font size and the width of each label
max_labels=8
@steps=(labels.length/max_labels).ceil if(labels.length>max_labels)
end
end
end

#Axis definitions
class XAxis
attr_accessor :stroke, :tick_height, :colour, :grid_colour, :steps
def initialize labels=nil
@labels= Labels.new(labels)
@steps=(@labels.steps/2).floor if @labels.steps && @labels.steps>1
@colour='#818D9D'
@grid_colour='#F0F0F0'
end

def labels
@labels.labels
end

def labels= val
@labels.labels= val
end

end


class YAxis
attr_accessor :stroke, :tick_length, :colour, :grid_colour, :offset,:min, :max, :steps
def initialize(min,max)
#defaults
auto_set_range(min,max)
@colour='#818D9D'
@grid_colour='#F0F0F0'
end
#modulo % is not the same as remainder for negative numbers
def auto_set_range(min,max)
# work out the approximate tick size
@steps= reduce_significant_figures((max-min)/5,1)
#max and min should be nearest step above and below max
@max=max+(@steps-max.remainder(@steps))
@min=min-min.remainder(@steps)
end

private
#round to form xxy0000 where xx is of length equal to
#the number of significant figures and y is 0 or 5
def reduce_significant_figures(num,sf)
n=BigDecimal( num.to_s)
scale=10**n.exponent
mantisa=(n/scale).round(sf+1) # 2dp initialy but ..
mantisa=(mantisa/0.5).round(sf)*0.5#round to nearest 0.5
mantisa*scale
end
end
#-----------------------------------------------------------------------------------------------------------------------------------------------
#series and chart type definitions

class LineSeries
attr_accessor :type, :colour, :dot_size,:width,:values, :text, :font_size

def initialize data
@type= "line_dot"
@colour='#EE0000'
@width= 4
@values= data
@dot_size= 4
end


end

class BarChart
attr_accessor :type, :alpha, :colour,:width,:values, :text, :font_size
def initialize values
@type="bar"
@values=values
end

end

#-----------------------------------------------------------------------------------------------------------------------------------------------

#holds x ,y scatter points and adds an x_label
#which should correspond to the labels on the x axis allowing us
#to look up the value at each point on the x axis
class Point
attr_accessor :x, :y, :x_label
def initialize x,y
@x=x
@y=y
end

def make_label(frequency)
@x_label=date_label(@x, frequency)
end


private

def date_label x_value, frequency
case frequency
# when 'day'
# when 'week'
when :monthly
return x_value.strftime("%m/%Y")
# when 'quarter'
# when 'year'
end
end

end

#data values container to allow transforming between between
# x y scatter and line series
class PointList <Array

def initialize frequency, points=[]
@frequency=frequency
points.each { |item| add_point item}
end

def add_point point
self << point
self.last.make_label(@frequency)
end


def to_line_series labels
labels.collect{|label| at(label)}
end

def at label
value=self.select {|p| p.x_label==label}
return nil if value.blank?
return value[0].y
end

end

#ugly ugly ugly need to do better and make more general
def make_labels(frequency,start_val,end_val)
case frequency
when :monthly
delta_months=(end_val.month-start_val.month)+12* (end_val.year-start_val.year)
labels=["#{"%02d" %start_val.month}/#{start_val.year}"]
month=start_val.month
year=start_val.year;

delta_months.times do |i|
if month <12
month=month+1
else
month=1
year=year+1
end
labels<<"#{"%02d" %month}/#{year}"
end
labels
end
end


end


the tests

require 'rubygems'
require 'test/unit'
gem 'activerecord', '>= 1.15.4.7794'
require 'active_record'

require File.dirname(__FILE__) + '/../lib/flash_chart'

class FlashChartTest < Test::Unit::TestCase
include FlashChart

#so we don't fail for stupid white space issues
def assert_json_equal a, b, message=''
assert_equal ActiveSupport::JSON.decode(a),ActiveSupport::JSON.decode(b), message
end

def setup
@c=Chart.new
end


# Begin Json y methods-------------------------------------------------------------------------------------------------------------------------
# The most primative functionality of the library
#The chart should be serialisable to the json defined in openflash chart
# http://teethgrinder.co.uk/open-flash-chart-2/json-format.php but watch out the definitions are slightly wrong!

def test_should_json_title
expected_json=%&{"title": {"text": "Many data lines", "style": "{font-size: 20px; color:#0000ff; font-family: Verdana; text-align: center;}"}}&
text="Many data lines"
style="{font-size: 20px; color:#0000ff; font-family: Verdana; text-align: center;}"
@c.add_title(text,style)
assert_json_equal expected_json, @c.to_json
end

def test_should_json_x_axis
expected_json=%&{"x_axis": {"stroke": 1, "tick-height": 10, "colour": "#d000d0", "grid-colour": "#00ff00", "labels": { "labels": ["January","February"]}}}&
x_axis=XAxis.new(["January","February"])
x_axis.stroke=1
x_axis.tick_height=10
x_axis.colour="#d000d0"
x_axis.grid_colour="#00ff00"

@c.x_axis=x_axis
assert_json_equal expected_json, @c.to_json
end

def test_should_json_y_axis
expected_json=%&{"y_axis":{"stroke": 4, "tick-length": 3, "steps": 5, "colour": "#d000d0", "grid-colour": "#00ff00", "offset": 0, "min": 0 , "max": 20 }}&
y_axis=YAxis.new(0,20)
y_axis.stroke=4
y_axis.min=0
y_axis.max=20
y_axis.steps=5
y_axis.tick_length=3
y_axis.colour="#d000d0"
y_axis.grid_colour="#00ff00"
y_axis.offset=0

@c.y_axis=y_axis
assert_json_equal expected_json, @c.to_json
end

def test_should_json_line_series
line_series=LineSeries.new [1.5,1.69,1.88,2.06,2.21,2.34,nil,2.35,2.23,2.08]
expected_json=%&
{
"elements":[
{
"type": "line_dot",
"colour": "#736AFF",
"text": "Avg. wave height (cm)",
"font-size": 10,
"width": 2,
"dot-size": 4,
"values" : [1.5,1.69,1.88,2.06,2.21,2.34,null,2.35,2.23,2.08]
}
]
}
&
line_series.type="line_dot"
line_series.colour="#736AFF"
line_series.text="Avg. wave height (cm)"
line_series.font_size=10
line_series.dot_size=4
line_series.width=2

@c. add_series line_series
assert_json_equal expected_json, @c.to_json
end

def test_should_json_bar_chart
bar=BarChart.new [9,6,7,9,5,7,6,9,7]
expected_json=%&
{
"elements":[
{
"type": "bar",
"alpha": 0.5,
"colour": "#9933CC",
"text": "Page views",
"font-size": 10,
"values" : [9,6,7,9,5,7,6,9,7]
}
]
}
&
bar.alpha=0.5
bar.colour="#9933CC"
bar.text= "Page views"
bar.font_size= 10
@c. add_series bar
assert_json_equal expected_json, @c.to_json

end

#----------------end json methods------------------------------------------------------------------------------------------------------------------

#---------------------------Methods to convert x y scatter data into line series and generate x labels-----------------------------
def test_point
b=Point.new(Date.parse('Thu, 01 Jun 2000'),3)
b.make_label(:monthly)
assert_equal '06/2000', b.x_label
end

def test_monthly_point_list
p=PointList.new(:monthly,[Point.new(Date.parse('Thu, 01 Jun 2000'),1),Point.new(Date.parse('Thu, 01 Jun 2005'),3)])
p.add_point Point.new(Date.parse('Thu, 01 Jun 2008'),9)
assert_equal 3, p.at('06/2005')
assert_equal nil, p.at('06/2004')
end

def test_make_labels
labels=make_labels(:monthly,Date.parse('Thu, 01 Jun 2000'),Date.parse('Fri, 22 Apr 2005'))
assert_equal '06/2000',labels[0]
assert_equal '07/2000',labels[1]
assert_equal '08/2000',labels[2]
assert_equal 59,labels.size
assert_equal '04/2005', labels[labels.size-1]
end

def test_point_list_to_line_series
labels=make_labels(:monthly,Date.parse('Thu, 01 Jun 2000'),Date.parse('Fri, 22 Apr 2005'))
points=PointList.new(:monthly,[Point.new(Date.parse('Thu, 01 July 2000'),1.1),Point.new(Date.parse('Thu, 01 aug 2000'),1.3),Point.new(Date.parse('Thu, 01 july 2006'),1.4)])

values=points.to_line_series(labels)
assert_equal labels.size,values.size
assert_equal nil, values[0]
assert_equal 1.1, values[1]
assert_equal 1.3, values[2]
assert_equal nil, values[3]
#all the rest should be nil
end

#---------------------------Methods to auto scale the y axis-----------------------------
def test_auto_set_range_large_integers
#ok a bit more vague than usual we want reasonable values for x y and ticks
#but its a bit of an open question what that means!
1000.times do
r=[rand(100000000)/1000.0,rand(100000000)/1000.0]
max=r.max
min=r.min
y_axis=YAxis.new(min,max)
assertions_for_y_axis y_axis, max,min
end
end

def assertions_for_y_axis y_axis, max, min
assert y_axis.max>y_axis.min, "max(#{y_axis.max}) should be greater than min#{y_axis.min}"
assert y_axis.max>=max, "y max should be greater than the maximum value"
assert y_axis.min<=min, "y min should be less than the minimum value"
distance=(y_axis.max-y_axis.min)
#god this is retarded! ruby seems to think 0.1*10^-9 >0.0001 !
assert_stupid_ruby_doesnt_do_maths(y_axis.max,y_axis.steps)
assert_stupid_ruby_doesnt_do_maths(y_axis.min,y_axis.steps)
assert_stupid_ruby_doesnt_do_maths(distance,y_axis.steps)
assert (distance/y_axis.steps)<=10,"too many steps on y axis should be less than 10 but was#{distance/y_axis.steps}"
end

#ooh floading point errors isn't it nice that with duck typing you don't know whether you are going to encounter these or not
def assert_stupid_ruby_doesnt_do_maths(a,b)
tmpA=BigDecimal.new(a.to_s)
tmpB=BigDecimal.new(b.to_s)
rem=tmpA.remainder(tmpB)
assert rem==0, "ruby is stupid #{tmpA}%#{tmpB} apparently gives #{rem}"
end


#------------------------------------------------------------------------------------------------
#higher level functions to easily build charts with defaults

def test_line_graph
vals=[1.5,1.69,1.88,2.06,2.21,2.34,nil,2.35,2.23,2.08]
@c.line_graph(['1','2','3''4','5','6','7','8','9','10'],vals)
expected= %&{"y_axis": {"steps": 0.15, "max": 2.4, "colour": "#818D9D", "min": 1.5, "grid-colour": "#F0F0F0"}, "elements": [{"type": "line_dot", "colour": "#EE0000", "dot-size": 4, "values": [1.5, 1.69, 1.88, 2.06, 2.21, 2.34, null, 2.35, 2.23, 2.08], "width": 4}], "x_axis": {"colour": "#818D9D", "grid-colour": "#F0F0F0", "labels": {"steps":1, "labels": ["1", "2", "34", "5", "6", "7", "8", "9", "10"]}}}&
assert_json_equal expected, @c.to_json
end

end


the helper

module OpenFlashChartHelper
def flash_chart div_name,width,height,options={}
url= url_for options
flash_url= "#{request[:host_with_port]}/swf/open-flash-chart-2.swf"
%&
<script type="text/javascript">
swfobject.embedSWF("#{flash_url}", "#{div_name}", "#{width}", "#{height}", "9.0.0", "expressInstall.swf", {"data-file":"#{url}"} );
</script>
&
end

end

init
ActionView::Base.send :include, OpenFlashChartHelper


install

# Install hook code here
require 'fileutils'

def copy_asset(source, destination)
unless File.exists?(destination)
FileUtils.cp source, destination
else
puts destination +'exists keeping existing file'
end
end

#copy the resources to the public directory
swfobject_source= File.dirname(__FILE__) + '/public/javascripts/' + 'swfobject.js'
swfobject_destination= File.dirname(__FILE__) + '/../../../public/javascripts/' + 'swfobject.js'
copy_asset(swfobject_source, swfobject_destination)


open_flash_source = File.dirname(__FILE__) + '/public/swf/' + 'open-flash-chart-2.swf'
open_flash_destination = File.dirname(__FILE__) + '/../../../public/swf/' + 'open-flash-chart-2.swf'
copy_asset(open_flash_source, open_flash_destination )

Comments: Post a Comment

Subscribe to Post Comments [Atom]





<< Home

Archives

July 2007   August 2007   September 2007   December 2007   January 2008   February 2008   March 2008   April 2008   June 2008   July 2008   August 2008   October 2008   November 2008   January 2009  

This page is powered by Blogger. Isn't yours?

Subscribe to Comments [Atom]