best site for documentation regarding acts_as_authenticaed. Also, currently
it only stores the user_id in the session, but i just found a guide to help
me make it store the entire user object, so I'll do that while my battery
-charges.
\ No newline at end of file
+charges.
+
+08/03/07
+Handy trick: use the command 'gem_server' from a shell to create a server at
+http://localhost:8008 that is an easy to navigate locally-hosted website with
+all the documentation on local gems you have in a easy to read format.
+
+jlsharps: I added the Gruff plug-in today, which is viewable under the folder
+vender/plugins/gruff. I installed it directly using the Gruff plug-in and
+included controller generate utility. The version 0.1.2, which doesn't seem to
+be the latest version. I've looked into it and it see and it seems that the
+latest version is 0.2.8. However, I wasn't sure how including a gem w/o a plugin
+would function in end-game rails so I just what I used for now. If you guys
+(mako of john) know how to do it, it'd probably be better to upgrade, but it
+didn't seem like the best use of my time right now. I got the plug-in here:
+http://topfunky.net/svn/plugins/gruff. You can get the gruff gem v 0.2.8 by
+typing "sudo gem install gruff", I believe it's also hosted on RubyForge.
+
+I created the GraphsController for Gruff methods to use. In Pollarize I put them
+in the ApplicationContorller file, so they would be accessible to all. While
+that it also an option here, it would also mean there wouldn't be much room for
+playing around because everything in the Application file has to be perfect or
+it seems to throw Error Code 500 (basically everything breaks). The show()
+is a sample sample provided with Gruff.
+
+Documentation is here:http://gruff.rubyforge.org/ Alternately, if you have the
+gem installed, you can use the ri command, or the above mentioned gem_server.
+
+If you guys want more helpful stuff here, let me know.
+
+
+
--- /dev/null
+class AccountController < ApplicationController
+ layout 'hc'
+
+ # Be sure to include AuthenticationSystem in Application Controller instead
+ include AuthenticatedSystem
+ # If you want "remember me" functionality, add this before_filter to Application Controller
+ before_filter :login_from_cookie
+
+ # say something nice, you goof! something sweet.
+ def index
+ redirect_to(:action => 'signup') unless logged_in? || User.count > 0
+ end
+
+ def login
+ return unless request.post?
+ self.current_user = User.authenticate(params[:login], params[:password])
+ if logged_in?
+ if params[:remember_me] == "1"
+ self.current_user.remember_me
+ cookies[:auth_token] = { :value => self.current_user.remember_token , :expires => self.current_user.remember_token_expires_at }
+ end
+ redirect_back_or_default(:controller => '/account', :action => 'index')
+ flash[:notice] = "Logged in successfully"
+ end
+ end
+
+ def signup
+ @user = User.new(params[:user])
+ return unless request.post?
+ @user.save!
+ self.current_user = @user
+ redirect_back_or_default(:controller => '/account', :action => 'index')
+ flash[:notice] = "Thanks for signing up!"
+ rescue ActiveRecord::RecordInvalid
+ render :action => 'signup'
+ end
+
+ def logout
+ self.current_user.forget_me if logged_in?
+ cookies.delete :auth_token
+ reset_session
+ flash[:notice] = "You have been logged out."
+ redirect_back_or_default(:controller => '/account', :action => 'index')
+ end
+end
# Likewise, all the methods added will be available for all controllers.
class ApplicationController < ActionController::Base
+ include AuthenticatedSystem
helper :user
model :user
end
--- /dev/null
+class GraphsController < ApplicationController
+
+ # To make caching easier, add a line like this to config/routes.rb:
+ # map.graph "graph/:action/:id/image.png", :controller => "graph"
+ #
+ # Then reference it with the named route:
+ # image_tag graph_url(:action => 'show', :id => 42)
+
+ def show
+ g = Gruff::Line.new
+ # Uncomment to use your own theme or font
+ # See http://colourlovers.com or http://www.firewheeldesign.com/widgets/ for color ideas
+# g.theme = {
+# :colors => ['#663366', '#cccc99', '#cc6633', '#cc9966', '#99cc99'],
+# :marker_color => 'white',
+# :background_colors => ['black', '#333333']
+# }
+# g.font = File.expand_path('artwork/fonts/VeraBd.ttf', RAILS_ROOT)
+
+ g.title = "Gruff-o-Rama"
+
+ g.data("Apples", [1, 2, 3, 4, 4, 3])
+ g.data("Oranges", [4, 8, 7, 9, 8, 9])
+ g.data("Watermelon", [2, 3, 1, 5, 6, 8])
+ g.data("Peaches", [9, 9, 10, 8, 7, 9])
+
+ g.labels = {0 => '2004', 2 => '2005', 4 => '2006'}
+
+ send_data(g.to_blob, :disposition => 'inline', :type => 'image/png', :filename => "gruff.png")
+ end
+
+ #The following section has been pasted directly fromworking pollarize graphs
+ #and hasn't been adopted to fit Selectricity yet
+
+ def day_votes
+ @poll = Poll.find(params[:id])
+ line = Gruff::Line.new
+ line.title = "Voters Per Day"
+ line.font = File.expand_path('/usr/X11R6/lib/X11/fonts/TTF/Vera.ttf', RAILS_ROOT)
+ line.data("#{@poll.name}", voter_days["voters_per_day"] )
+ line.labels = voter_days["days_hash"]
+ line.x_axis_label = "Date"
+ line.y_axis_label = "Number of Votes"
+ line.minimum_value = 0.0
+ line.draw
+ send_data(line.to_blob,
+ :disposition => 'inline',
+ :type => 'image/png',
+ :filename => "dayvotes#{@poll.id}.png")
+ end
+
+ def voter_days
+ @poll = Poll.find(params[:id])
+ voter_times = Array.new
+ unique_days = Array.new
+ voters_per_day = Array.new
+ days_hash = Hash.new
+
+ @poll.questions.each do |qstn|
+ qstn.votes.each do |vote|
+ voter_times << vote.time unless voter_times.any? {|utime| utime == vote.time}
+ end
+ end
+ voter_times.sort!
+ #find all times in voter_times with the same date, and then concatenate
+ #that number onto votes_per_day
+ #
+ #doesn't work jsut yet
+ voter_times.each_with_index do |time, index|
+ count = 1
+ unless unique_days.any? { |d1| d1.eql?(time.mon.to_s+"/"+time.day.to_s) }
+ unique_days << (time.mon.to_s+"/"+time.day.to_s)
+ count += (voter_times[(index+1)..-1].find_all {|t| t.mon == time.mon && t.day == time.day}).size
+ voters_per_day << count
+ end
+ end
+
+ unique_days.each_with_index do |fmtdate, index|
+ days_hash[index] = fmtdate
+ end
+ return { "voters_per_day" => voters_per_day, "days_hash" => days_hash }
+
+ end
+ #end copy/pasted section
+
+
+end
#############################################################
def create
+ breakpoint
if params[:quickvote]
@quickvote = QuickVote.new(params[:quickvote])
# check to see that we actually have record of them
if User.find_all(["id = ?", session[:user].id]).length == 1
# if we have record of them, grab the list of their elections
- session[:user] = User.find(session[:user].id)
+ session[:user] = User.find(session[:user])
@current_elections = session[:user].elections.sort do |a,b|
b.enddate <=> a.enddate
end
--- /dev/null
+module AccountHelper
+end
\ No newline at end of file
--- /dev/null
+<h1>In the Caboose.</h1>
+
+<% content_for 'poem' do -%>
+"Train delayed? and what's to say?"
+"Blocked by last night's snow they say."
+Seven hours or so to wait;
+Well, that's pleasant! but there's the freight.
+Depot loafing no one fancies,
+We'll try the caboose and take our chances.
+
+Cool this morning in Watertown,
+Somewhat frosty___mercury down;
+Enter caboose___roaring fire,
+With never an air-hole; heat so dire
+That we shrivel and pant; we are roasted through-
+Outside, thermometer thirty-two.
+
+We start with a jerk and suddenly stop.
+"What's broke?" says one; another "What's up?",
+"Oh, nothing," they answer, "That's our way:
+You must stand the jerking, sorry to say."
+We "stand it" with oft this painful thought:
+Are our heads on yet, or are they not?
+
+Comrades in misery___let me see;
+Girl like a statue opposite me;
+Back and forth the others jostle___
+She never winks, nor moves a muscle;
+See her, as she sits there now;
+She's "well balanced," anyhow.
+
+Woman in trouble, tearful eyes,
+Sits by the window, softly cries,
+Pity___for griefs we may not know,
+For breasts that ache, for tears that flow,
+Though we know not why. Her eyelids red
+Tell a sorrowful tale___some hope is dead.
+
+Man who follows the Golden Rule,
+And lends his papers___a pocket full,
+Has a blank book___once in a minute
+Has an idea, and writes it in it.
+Guess him? Yes, of course I can,
+He's a___well___a newspaper man.
+
+Blue-eyed fairy, wrapped in fur;
+Sweet young mother tending her.
+Fairy thinks it's "awful far,"
+Wants to get off this "naughty car."
+So do we, young golden-hair;
+All this crowd are with you there!
+<% end -%>
+
+<%= simple_format @content_for_poem %>
+
+<p><a href="http://skyways.lib.ks.us/poetry/walls/caboose.html">-- Ellen P. Allerton.</a></p>
\ No newline at end of file
--- /dev/null
+<% form_tag do -%>
+<p><label for="login">Login</label><br/>
+<%= text_field_tag 'login' %></p>
+
+<p><label for="password">Password</label><br/>
+<%= password_field_tag 'password' %></p>
+
+<!-- Uncomment this if you want this functionality
+<p><label for="remember_me">Remember me:</label>
+<%= check_box_tag 'remember_me' %></p>
+-->
+
+<p><%= submit_tag 'Log in' %></p>
+<% end -%>
--- /dev/null
+<%= error_messages_for :user %>
+<% form_for :user do |f| -%>
+<p><label for="login">Login</label><br/>
+<%= f.text_field :login %></p>
+
+<p><label for="email">Email</label><br/>
+<%= f.text_field :email %></p>
+
+<p><label for="password">Password</label><br/>
+<%= f.password_field :password %></p>
+
+<p><label for="password_confirmation">Confirm Password</label><br/>
+<%= f.password_field :password_confirmation %></p>
+
+<p><%= submit_tag 'Sign up' %></p>
+<% end -%>
<div id="links">
<% if session[:user] %>
-<% breakpoint%>
+<% breakpoint %>
Welcome <strong><%= User.find(session[:user]).login.capitalize %></strong>
<% else %>
<%= link_to("Login", :controller => "account", :action => "login")
id int NOT NULL auto_increment,
voter_id int DEFAULT NULL,
confirmed tinyint NOT NULL DEFAULT 0,
+ time datetime DEFAULT NULL,
constraint fk_vote_voter foreign key (voter_id) references voters(id),
primary key (id)
);
--- /dev/null
+class CreateUsers < ActiveRecord::Migration
+ def self.up
+ create_table "users", :force => true do |t|
+ t.column :login, :string
+ t.column :email, :string
+ t.column :crypted_password, :string, :limit => 40
+ t.column :salt, :string, :limit => 40
+ t.column :created_at, :datetime
+ t.column :updated_at, :datetime
+ t.column :remember_token, :string
+ t.column :remember_token_expires_at, :datetime
+ end
+ end
+
+ def self.down
+ drop_table "users"
+ end
+end
--- /dev/null
+module AuthenticatedSystem
+ protected
+ # Returns true or false if the user is logged in.
+ # Preloads @current_user with the user model if they're logged in.
+ def logged_in?
+ current_user != :false
+ end
+
+ # Accesses the current user from the session.
+ def current_user
+ @current_user ||= (session[:user] && User.find_by_id(session[:user])) || :false
+ end
+
+ # Store the given user in the session.
+ def current_user=(new_user)
+ session[:user] = (new_user.nil? || new_user.is_a?(Symbol)) ? nil : new_user.id
+ @current_user = new_user
+ end
+
+ # Check if the user is authorized.
+ #
+ # Override this method in your controllers if you want to restrict access
+ # to only a few actions or if you want to check if the user
+ # has the correct rights.
+ #
+ # Example:
+ #
+ # # only allow nonbobs
+ # def authorize?
+ # current_user.login != "bob"
+ # end
+ def authorized?
+ true
+ end
+
+ # Filter method to enforce a login requirement.
+ #
+ # To require logins for all actions, use this in your controllers:
+ #
+ # before_filter :login_required
+ #
+ # To require logins for specific actions, use this in your controllers:
+ #
+ # before_filter :login_required, :only => [ :edit, :update ]
+ #
+ # To skip this in a subclassed controller:
+ #
+ # skip_before_filter :login_required
+ #
+ def login_required
+ username, passwd = get_auth_data
+ self.current_user ||= User.authenticate(username, passwd) || :false if username && passwd
+ logged_in? && authorized? ? true : access_denied
+ end
+
+ # Redirect as appropriate when an access request fails.
+ #
+ # The default action is to redirect to the login screen.
+ #
+ # Override this method in your controllers if you want to have special
+ # behavior in case the user is not authorized
+ # to access the requested action. For example, a popup window might
+ # simply close itself.
+ def access_denied
+ respond_to do |accepts|
+ accepts.html do
+ store_location
+ redirect_to :controller => '/account', :action => 'login'
+ end
+ accepts.xml do
+ headers["Status"] = "Unauthorized"
+ headers["WWW-Authenticate"] = %(Basic realm="Web Password")
+ render :text => "Could't authenticate you", :status => '401 Unauthorized'
+ end
+ end
+ false
+ end
+
+ # Store the URI of the current request in the session.
+ #
+ # We can return to this location by calling #redirect_back_or_default.
+ def store_location
+ session[:return_to] = request.request_uri
+ end
+
+ # Redirect to the URI stored by the most recent store_location call or
+ # to the passed default.
+ def redirect_back_or_default(default)
+ session[:return_to] ? redirect_to_url(session[:return_to]) : redirect_to(default)
+ session[:return_to] = nil
+ end
+
+ # Inclusion hook to make #current_user and #logged_in?
+ # available as ActionView helper methods.
+ def self.included(base)
+ base.send :helper_method, :current_user, :logged_in?
+ end
+
+ # When called with before_filter :login_from_cookie will check for an :auth_token
+ # cookie and log the user back in if apropriate
+ def login_from_cookie
+ return unless cookies[:auth_token] && !logged_in?
+ user = User.find_by_remember_token(cookies[:auth_token])
+ if user && user.remember_token?
+ user.remember_me
+ self.current_user = user
+ cookies[:auth_token] = { :value => self.current_user.remember_token , :expires => self.current_user.remember_token_expires_at }
+ flash[:notice] = "Logged in successfully"
+ end
+ end
+
+ private
+ @@http_auth_headers = %w(X-HTTP_AUTHORIZATION HTTP_AUTHORIZATION Authorization)
+ # gets BASIC auth info
+ def get_auth_data
+ auth_key = @@http_auth_headers.detect { |h| request.env.has_key?(h) }
+ auth_data = request.env[auth_key].to_s.split unless auth_key.blank?
+ return auth_data && auth_data[0] == 'Basic' ? Base64.decode64(auth_data[1]).split(':')[0..1] : [nil, nil]
+ end
+end
--- /dev/null
+module AuthenticatedTestHelper
+ # Sets the current user in the session from the user fixtures.
+ def login_as(user)
+ @request.session[:user] = user ? users(user).id : nil
+ end
+
+ def content_type(type)
+ @request.env['Content-Type'] = type
+ end
+
+ def accept(accept)
+ @request.env["HTTP_ACCEPT"] = accept
+ end
+
+ def authorize_as(user)
+ if user
+ @request.env["HTTP_AUTHORIZATION"] = "Basic #{Base64.encode64("#{users(user).login}:test")}"
+ accept 'application/xml'
+ content_type 'application/xml'
+ else
+ @request.env["HTTP_AUTHORIZATION"] = nil
+ accept nil
+ content_type nil
+ end
+ end
+
+ # http://project.ioni.st/post/217#post-217
+ #
+ # def test_new_publication
+ # assert_difference(Publication, :count) do
+ # post :create, :publication => {...}
+ # # ...
+ # end
+ # end
+ #
+ def assert_difference(object, method = nil, difference = 1)
+ initial_value = object.send(method)
+ yield
+ assert_equal initial_value + difference, object.send(method), "#{object}##{method}"
+ end
+
+ def assert_no_difference(object, method, &block)
+ assert_difference object, method, 0, &block
+ end
+
+ # Assert the block redirects to the login
+ #
+ # assert_requires_login(:bob) { |c| c.get :edit, :id => 1 }
+ #
+ def assert_requires_login(login = nil)
+ yield HttpLoginProxy.new(self, login)
+ end
+
+ def assert_http_authentication_required(login = nil)
+ yield XmlLoginProxy.new(self, login)
+ end
+
+ def reset!(*instance_vars)
+ instance_vars = [:controller, :request, :response] unless instance_vars.any?
+ instance_vars.collect! { |v| "@#{v}".to_sym }
+ instance_vars.each do |var|
+ instance_variable_set(var, instance_variable_get(var).class.new)
+ end
+ end
+end
+
+class BaseLoginProxy
+ attr_reader :controller
+ attr_reader :options
+ def initialize(controller, login)
+ @controller = controller
+ @login = login
+ end
+
+ private
+ def authenticated
+ raise NotImplementedError
+ end
+
+ def check
+ raise NotImplementedError
+ end
+
+ def method_missing(method, *args)
+ @controller.reset!
+ authenticate
+ @controller.send(method, *args)
+ check
+ end
+end
+
+class HttpLoginProxy < BaseLoginProxy
+ protected
+ def authenticate
+ @controller.login_as @login if @login
+ end
+
+ def check
+ @controller.assert_redirected_to :controller => 'account', :action => 'login'
+ end
+end
+
+class XmlLoginProxy < BaseLoginProxy
+ protected
+ def authenticate
+ @controller.accept 'application/xml'
+ @controller.authorize_as @login if @login
+ end
+
+ def check
+ @controller.assert_response 401
+ end
+end
\ No newline at end of file
--- /dev/null
+quentin:
+ id: 1
+ login: quentin
+ email: quentin@example.com
+ salt: 7e3041ebc2fc05a40c60028e2c4901a81035d3cd
+ crypted_password: 00742970dc9e6319f8019fd54864d3ea740f04b1 # test
+ #crypted_password: "ce2/iFrNtQ8=\n" # quentin, use only if you're using 2-way encryption
+ created_at: <%= 5.days.ago.to_s :db %>
+ # activated_at: <%= 5.days.ago.to_s :db %> # only if you're activating new signups
+aaron:
+ id: 2
+ login: aaron
+ email: aaron@example.com
+ salt: 7e3041ebc2fc05a40c60028e2c4901a81035d3cd
+ crypted_password: 00742970dc9e6319f8019fd54864d3ea740f04b1 # test
+ # activation_code: aaronscode # only if you're activating new signups
+ created_at: <%= 1.days.ago.to_s :db %>
\ No newline at end of file
--- /dev/null
+require File.dirname(__FILE__) + '/../test_helper'
+require 'account_controller'
+
+# Re-raise errors caught by the controller.
+class AccountController; def rescue_action(e) raise e end; end
+
+class AccountControllerTest < Test::Unit::TestCase
+ # Be sure to include AuthenticatedTestHelper in test/test_helper.rb instead
+ # Then, you can remove it from this and the units test.
+ include AuthenticatedTestHelper
+
+ fixtures :users
+
+ def setup
+ @controller = AccountController.new
+ @request = ActionController::TestRequest.new
+ @response = ActionController::TestResponse.new
+ end
+
+ def test_should_login_and_redirect
+ post :login, :login => 'quentin', :password => 'test'
+ assert session[:user]
+ assert_response :redirect
+ end
+
+ def test_should_fail_login_and_not_redirect
+ post :login, :login => 'quentin', :password => 'bad password'
+ assert_nil session[:user]
+ assert_response :success
+ end
+
+ def test_should_allow_signup
+ assert_difference User, :count do
+ create_user
+ assert_response :redirect
+ end
+ end
+
+ def test_should_require_login_on_signup
+ assert_no_difference User, :count do
+ create_user(:login => nil)
+ assert assigns(:user).errors.on(:login)
+ assert_response :success
+ end
+ end
+
+ def test_should_require_password_on_signup
+ assert_no_difference User, :count do
+ create_user(:password => nil)
+ assert assigns(:user).errors.on(:password)
+ assert_response :success
+ end
+ end
+
+ def test_should_require_password_confirmation_on_signup
+ assert_no_difference User, :count do
+ create_user(:password_confirmation => nil)
+ assert assigns(:user).errors.on(:password_confirmation)
+ assert_response :success
+ end
+ end
+
+ def test_should_require_email_on_signup
+ assert_no_difference User, :count do
+ create_user(:email => nil)
+ assert assigns(:user).errors.on(:email)
+ assert_response :success
+ end
+ end
+
+ def test_should_logout
+ login_as :quentin
+ get :logout
+ assert_nil session[:user]
+ assert_response :redirect
+ end
+
+ def test_should_remember_me
+ post :login, :login => 'quentin', :password => 'test', :remember_me => "1"
+ assert_not_nil @response.cookies["auth_token"]
+ end
+
+ def test_should_not_remember_me
+ post :login, :login => 'quentin', :password => 'test', :remember_me => "0"
+ assert_nil @response.cookies["auth_token"]
+ end
+
+ def test_should_delete_token_on_logout
+ login_as :quentin
+ get :logout
+ assert_equal @response.cookies["auth_token"], []
+ end
+
+ def test_should_login_with_cookie
+ users(:quentin).remember_me
+ @request.cookies["auth_token"] = cookie_for(:quentin)
+ get :index
+ assert @controller.send(:logged_in?)
+ end
+
+ def test_should_fail_expired_cookie_login
+ users(:quentin).remember_me
+ users(:quentin).update_attribute :remember_token_expires_at, 5.minutes.ago
+ @request.cookies["auth_token"] = cookie_for(:quentin)
+ get :index
+ assert !@controller.send(:logged_in?)
+ end
+
+ def test_should_fail_cookie_login
+ users(:quentin).remember_me
+ @request.cookies["auth_token"] = auth_token('invalid_auth_token')
+ get :index
+ assert !@controller.send(:logged_in?)
+ end
+
+ protected
+ def create_user(options = {})
+ post :signup, :user => { :login => 'quire', :email => 'quire@example.com',
+ :password => 'quire', :password_confirmation => 'quire' }.merge(options)
+ end
+
+ def auth_token(token)
+ CGI::Cookie.new('name' => 'auth_token', 'value' => token)
+ end
+
+ def cookie_for(user)
+ auth_token users(user).remember_token
+ end
+end
--- /dev/null
+require File.dirname(__FILE__) + '/../test_helper'
+require 'graphs_controller'
+
+# Re-raise errors caught by the controller.
+class GraphsController; def rescue_action(e) raise e end; end
+
+class GraphsControllerTest < Test::Unit::TestCase
+
+ #fixtures :data
+
+ def setup
+ @controller = GraphsController.new
+ @request = ActionController::TestRequest.new
+ @response = ActionController::TestResponse.new
+ end
+
+ # TODO Replace this with your actual tests
+ def test_show
+ get :show
+ assert_response :success
+ assert_equal 'image/png', @response.headers['Content-Type']
+ end
+
+end
--- /dev/null
+require File.dirname(__FILE__) + '/../test_helper'
+
+class UserTest < Test::Unit::TestCase
+ # Be sure to include AuthenticatedTestHelper in test/test_helper.rb instead.
+ # Then, you can remove it from this and the functional test.
+ include AuthenticatedTestHelper
+ fixtures :users
+
+ def test_should_create_user
+ assert_difference User, :count do
+ user = create_user
+ assert !user.new_record?, "#{user.errors.full_messages.to_sentence}"
+ end
+ end
+
+ def test_should_require_login
+ assert_no_difference User, :count do
+ u = create_user(:login => nil)
+ assert u.errors.on(:login)
+ end
+ end
+
+ def test_should_require_password
+ assert_no_difference User, :count do
+ u = create_user(:password => nil)
+ assert u.errors.on(:password)
+ end
+ end
+
+ def test_should_require_password_confirmation
+ assert_no_difference User, :count do
+ u = create_user(:password_confirmation => nil)
+ assert u.errors.on(:password_confirmation)
+ end
+ end
+
+ def test_should_require_email
+ assert_no_difference User, :count do
+ u = create_user(:email => nil)
+ assert u.errors.on(:email)
+ end
+ end
+
+ def test_should_reset_password
+ users(:quentin).update_attributes(:password => 'new password', :password_confirmation => 'new password')
+ assert_equal users(:quentin), User.authenticate('quentin', 'new password')
+ end
+
+ def test_should_not_rehash_password
+ users(:quentin).update_attributes(:login => 'quentin2')
+ assert_equal users(:quentin), User.authenticate('quentin2', 'test')
+ end
+
+ def test_should_authenticate_user
+ assert_equal users(:quentin), User.authenticate('quentin', 'test')
+ end
+
+ def test_should_set_remember_token
+ users(:quentin).remember_me
+ assert_not_nil users(:quentin).remember_token
+ assert_not_nil users(:quentin).remember_token_expires_at
+ end
+
+ def test_should_unset_remember_token
+ users(:quentin).remember_me
+ assert_not_nil users(:quentin).remember_token
+ users(:quentin).forget_me
+ assert_nil users(:quentin).remember_token
+ end
+
+ protected
+ def create_user(options = {})
+ User.create({ :login => 'quire', :email => 'quire@example.com', :password => 'quire', :password_confirmation => 'quire' }.merge(options))
+ end
+end
--- /dev/null
+* (1 Aug 2006)
+
+ Add error_messages_for to signup.rhtml
+ Favor save! over save in #signup
+ Refactor Authenticated System, actually use access_denied again
+ Change arthur fixture to aaron stack, the machine man!
+
+* (3 July 2006)
+
+ Add cookie token support [cnf]
+
+* (12 June 2006)
+
+ Fixed logged_in? so it does not keep hitting the database on every call if the user is not logged in.
+ Removed useless protect? method
+ Removed required 'user' argument of authorized?. Access action_name and current_user to determine authorization.
+
+* (28 March 2006)
+
+ Removed "extra credit" documentation into wiki: http://technoweenie.stikipad.com/plugins/show/Acts+as+Authenticated
+ Stop validating the user's salt since they can't even fix that if they wanted to.
+
+* (25 March 2006)
+
+ Added account_location plugin hooks
+
+* (20 March 2006)
+
+ New for Rails 1.1!
+
+ Added #assert_difference and #assert_no_difference test helpers
+ Changed signup form to use #form_for
+
+* (22 Jan 2006)
+
+ Fixed user validations
+ Fixed Migration table name
+
+* (19 Jan 2006)
+
+ Fixed generator erb bug [Kyle Maxwell]
+
+* (18 Jan 2006)
+
+ Fixed bug with password field adding validation errors [Chris Nolan]
+ Added persistent login example [Chris Nolan]
+
+* (17 Dec 2005)
+
+ Various typos fixed. Examples for using an activation code added [Ben Bleything]
+
+* (6 Dec 2005)
+
+ Add some docs to README, add install.rb script
+
+* (20 Nov 2005)
+
+ Redirect to signup page if no users have been created [Bousquet]
+
+ Experimental generator for creating the users table. Requires edge rails and does not let you set the table name:
+
+ ./script/generate authenticated_migration
+
+* (18 Nov 2005)
+
+ Allow changing of the login model name when generating the observer [Bill Katz]
+
+* (15 Nov 2005)
+
+ Commented out the activate action in the controller [tobyjoe]
+ fixed notifier.rb's erb variables [tobyjoe]
+ removed last old instance of set_current_user [tobyjoe]
+
+
+* (13 Nov 2005)
+
+ fixed assert_login_required bug with :controller => '/account'
+ tweak logged_in? method to check for valid current_user, not just any old id value
+ comment out user activation by default
+ added index template, updated login_required comments [corp]
+ removed troublesome login_required class method. use the filter instead
+
+* (12 Nov 2005)
+
+ renaming generators to authentication and authentication_mailer
+ store user ID in session by default
\ No newline at end of file
--- /dev/null
+acts_as_authenticated generator
+====
+
+This is a basic authentication generator for rails, very much in the spirit of xal's original Login Generator.
+
+To use:
+
+ ./script/generate authenticated user account
+
+This generates a basic user model, a controller, some basic views, and tests. Extra functionality can be unlocked by
+removing the comments for them. I have a few examples such as user activation and reversible encrypted passwords.
+
+The user migration is also generated unless you pass --skip-migration.
+
+Generate your mailer:
+
+ ./script/generate authenticated_mailer user
+
+Consult the Acts As Authenticated wiki for more: http://technoweenie.stikipad.com/plugins/show/Acts+as+Authenticated
+
\ No newline at end of file
--- /dev/null
+./script/generate authenticated USERMODEL CONTROLLERNAME
\ No newline at end of file
--- /dev/null
+class AuthenticatedGenerator < Rails::Generator::NamedBase
+ attr_reader :controller_name,
+ :controller_class_path,
+ :controller_file_path,
+ :controller_class_nesting,
+ :controller_class_nesting_depth,
+ :controller_class_name,
+ :controller_singular_name,
+ :controller_plural_name
+ alias_method :controller_file_name, :controller_singular_name
+ alias_method :controller_table_name, :controller_plural_name
+
+ def initialize(runtime_args, runtime_options = {})
+ super
+
+ # Take controller name from the next argument. Default to the pluralized model name.
+ @controller_name = args.shift
+ @controller_name ||= ActiveRecord::Base.pluralize_table_names ? @name.pluralize : @name
+
+ base_name, @controller_class_path, @controller_file_path, @controller_class_nesting, @controller_class_nesting_depth = extract_modules(@controller_name)
+ @controller_class_name_without_nesting, @controller_singular_name, @controller_plural_name = inflect_names(base_name)
+
+ if @controller_class_nesting.empty?
+ @controller_class_name = @controller_class_name_without_nesting
+ else
+ @controller_class_name = "#{@controller_class_nesting}::#{@controller_class_name_without_nesting}"
+ end
+ end
+
+ def manifest
+ record do |m|
+ # Check for class naming collisions.
+ m.class_collisions controller_class_path, "#{controller_class_name}Controller",
+ #"#{controller_class_name}ControllerTest",
+ "#{controller_class_name}Helper"
+ m.class_collisions class_path, "#{class_name}"
+ #"#{class_name}Test"
+ m.class_collisions [], 'AuthenticatedSystem', 'AuthenticatedTestHelper'
+
+ # Controller, helper, views, and test directories.
+ m.directory File.join('app/models', class_path)
+ m.directory File.join('app/controllers', controller_class_path)
+ m.directory File.join('app/helpers', controller_class_path)
+ m.directory File.join('app/views', controller_class_path, controller_file_name)
+ m.directory File.join('test/functional', controller_class_path)
+ m.directory File.join('test/unit', class_path)
+
+ m.template 'model.rb',
+ File.join('app/models',
+ class_path,
+ "#{file_name}.rb")
+
+ m.template 'controller.rb',
+ File.join('app/controllers',
+ controller_class_path,
+ "#{controller_file_name}_controller.rb")
+
+ m.template 'authenticated_system.rb',
+ File.join('lib', 'authenticated_system.rb')
+
+ m.template 'authenticated_test_helper.rb',
+ File.join('lib', 'authenticated_test_helper.rb')
+
+ m.template 'functional_test.rb',
+ File.join('test/functional',
+ controller_class_path,
+ "#{controller_file_name}_controller_test.rb")
+
+ m.template 'helper.rb',
+ File.join('app/helpers',
+ controller_class_path,
+ "#{controller_file_name}_helper.rb")
+
+ m.template 'unit_test.rb',
+ File.join('test/unit',
+ class_path,
+ "#{file_name}_test.rb")
+
+ m.template 'fixtures.yml',
+ File.join('test/fixtures',
+ "#{table_name}.yml")
+
+ # Controller templates
+ %w( index login signup ).each do |action|
+ m.template "#{action}.rhtml",
+ File.join('app/views', controller_class_path, controller_file_name, "#{action}.rhtml")
+ end
+
+ unless options[:skip_migration]
+ m.migration_template 'migration.rb', 'db/migrate', :assigns => {
+ :migration_name => "Create#{class_name.pluralize.gsub(/::/, '')}"
+ }, :migration_file_name => "create_#{file_path.gsub(/\//, '_').pluralize}"
+ end
+ end
+ end
+
+ protected
+ # Override with your own usage banner.
+ def banner
+ "Usage: #{$0} authenticated ModelName [ControllerName]"
+ end
+end
--- /dev/null
+module AuthenticatedSystem
+ protected
+ # Returns true or false if the user is logged in.
+ # Preloads @current_<%= file_name %> with the user model if they're logged in.
+
+ def logged_in?
+ current_<%= file_name %> != :false
+ end
+
+ # Accesses the current <%= file_name %> from the session.
+ def current_<%= file_name %>
+ @current_<%= file_name %> ||= (session[:<%= file_name %>] && <%= class_name %>.find_by_id(session[:<%= file_name %>])) || :false
+ end
+
+ # Store the given <%= file_name %> in the session.
+ def current_<%= file_name %>=(new_<%= file_name %>)
+ session[:<%= file_name %>] = (new_<%= file_name %>.nil? || new_<%= file_name %>.is_a?(Symbol)) ? nil : new_<%= file_name %>.id
+ @current_<%= file_name %> = new_<%= file_name %>
+ end
+
+ # Check if the <%= file_name %> is authorized.
+ #
+ # Override this method in your controllers if you want to restrict access
+ # to only a few actions or if you want to check if the <%= file_name %>
+ # has the correct rights.
+ #
+ # Example:
+ #
+ # # only allow nonbobs
+ # def authorize?
+ # current_<%= file_name %>.login != "bob"
+ # end
+ def authorized?
+ true
+ end
+
+ # Filter method to enforce a login requirement.
+ #
+ # To require logins for all actions, use this in your controllers:
+ #
+ # before_filter :login_required
+ #
+ # To require logins for specific actions, use this in your controllers:
+ #
+ # before_filter :login_required, :only => [ :edit, :update ]
+ #
+ # To skip this in a subclassed controller:
+ #
+ # skip_before_filter :login_required
+ #
+ def login_required
+ username, passwd = get_auth_data
+ self.current_<%= file_name %> ||= <%= class_name %>.authenticate(username, passwd) || :false if username && passwd
+ logged_in? && authorized? ? true : access_denied
+ end
+
+ # Redirect as appropriate when an access request fails.
+ #
+ # The default action is to redirect to the login screen.
+ #
+ # Override this method in your controllers if you want to have special
+ # behavior in case the <%= file_name %> is not authorized
+ # to access the requested action. For example, a popup window might
+ # simply close itself.
+ def access_denied
+ respond_to do |accepts|
+ accepts.html do
+ store_location
+ redirect_to :controller => '/<%= controller_file_name %>', :action => 'login'
+ end
+ accepts.xml do
+ headers["Status"] = "Unauthorized"
+ headers["WWW-Authenticate"] = %(Basic realm="Web Password")
+ render :text => "Could't authenticate you", :status => '401 Unauthorized'
+ end
+ end
+ false
+ end
+
+ # Store the URI of the current request in the session.
+ #
+ # We can return to this location by calling #redirect_back_or_default.
+ def store_location
+ session[:return_to] = request.request_uri
+ end
+
+ # Redirect to the URI stored by the most recent store_location call or
+ # to the passed default.
+ def redirect_back_or_default(default)
+ session[:return_to] ? redirect_to_url(session[:return_to]) : redirect_to(default)
+ session[:return_to] = nil
+ end
+
+ # Inclusion hook to make #current_<%= file_name %> and #logged_in?
+ # available as ActionView helper methods.
+ def self.included(base)
+ base.send :helper_method, :current_<%= file_name %>, :logged_in?
+ end
+
+ # When called with before_filter :login_from_cookie will check for an :auth_token
+ # cookie and log the user back in if apropriate
+ def login_from_cookie
+ return unless cookies[:auth_token] && !logged_in?
+ user = <%= class_name %>.find_by_remember_token(cookies[:auth_token])
+ if user && user.remember_token?
+ user.remember_me
+ self.current_<%= file_name %> = user
+ cookies[:auth_token] = { :value => self.current_<%= file_name %>.remember_token , :expires => self.current_<%= file_name %>.remember_token_expires_at }
+ flash[:notice] = "Logged in successfully"
+ end
+ end
+
+ private
+ @@http_auth_headers = %w(X-HTTP_AUTHORIZATION HTTP_AUTHORIZATION Authorization)
+ # gets BASIC auth info
+ def get_auth_data
+ auth_key = @@http_auth_headers.detect { |h| request.env.has_key?(h) }
+ auth_data = request.env[auth_key].to_s.split unless auth_key.blank?
+ return auth_data && auth_data[0] == 'Basic' ? Base64.decode64(auth_data[1]).split(':')[0..1] : [nil, nil]
+ end
+end
--- /dev/null
+module AuthenticatedTestHelper
+ # Sets the current <%= file_name %> in the session from the <%= file_name %> fixtures.
+ def login_as(<%= file_name %>)
+ @request.session[:<%= file_name %>] = <%= file_name %> ? <%= table_name %>(<%= file_name %>).id : nil
+ end
+
+ def content_type(type)
+ @request.env['Content-Type'] = type
+ end
+
+ def accept(accept)
+ @request.env["HTTP_ACCEPT"] = accept
+ end
+
+ def authorize_as(user)
+ if user
+ @request.env["HTTP_AUTHORIZATION"] = "Basic #{Base64.encode64("#{users(user).login}:test")}"
+ accept 'application/xml'
+ content_type 'application/xml'
+ else
+ @request.env["HTTP_AUTHORIZATION"] = nil
+ accept nil
+ content_type nil
+ end
+ end
+
+ # http://project.ioni.st/post/217#post-217
+ #
+ # def test_new_publication
+ # assert_difference(Publication, :count) do
+ # post :create, :publication => {...}
+ # # ...
+ # end
+ # end
+ #
+ def assert_difference(object, method = nil, difference = 1)
+ initial_value = object.send(method)
+ yield
+ assert_equal initial_value + difference, object.send(method), "#{object}##{method}"
+ end
+
+ def assert_no_difference(object, method, &block)
+ assert_difference object, method, 0, &block
+ end
+
+ # Assert the block redirects to the login
+ #
+ # assert_requires_login(:bob) { |c| c.get :edit, :id => 1 }
+ #
+ def assert_requires_login(login = nil)
+ yield HttpLoginProxy.new(self, login)
+ end
+
+ def assert_http_authentication_required(login = nil)
+ yield XmlLoginProxy.new(self, login)
+ end
+
+ def reset!(*instance_vars)
+ instance_vars = [:controller, :request, :response] unless instance_vars.any?
+ instance_vars.collect! { |v| "@#{v}".to_sym }
+ instance_vars.each do |var|
+ instance_variable_set(var, instance_variable_get(var).class.new)
+ end
+ end
+end
+
+class BaseLoginProxy
+ attr_reader :controller
+ attr_reader :options
+ def initialize(controller, login)
+ @controller = controller
+ @login = login
+ end
+
+ private
+ def authenticated
+ raise NotImplementedError
+ end
+
+ def check
+ raise NotImplementedError
+ end
+
+ def method_missing(method, *args)
+ @controller.reset!
+ authenticate
+ @controller.send(method, *args)
+ check
+ end
+end
+
+class HttpLoginProxy < BaseLoginProxy
+ protected
+ def authenticate
+ @controller.login_as @login if @login
+ end
+
+ def check
+ @controller.assert_redirected_to :controller => 'account', :action => 'login'
+ end
+end
+
+class XmlLoginProxy < BaseLoginProxy
+ protected
+ def authenticate
+ @controller.accept 'application/xml'
+ @controller.authorize_as @login if @login
+ end
+
+ def check
+ @controller.assert_response 401
+ end
+end
\ No newline at end of file
--- /dev/null
+class <%= controller_class_name %>Controller < ApplicationController
+ # Be sure to include AuthenticationSystem in Application Controller instead
+ include AuthenticatedSystem
+ # If you want "remember me" functionality, add this before_filter to Application Controller
+ before_filter :login_from_cookie
+
+ # say something nice, you goof! something sweet.
+ def index
+ redirect_to(:action => 'signup') unless logged_in? || <%= class_name %>.count > 0
+ end
+
+ def login
+ return unless request.post?
+ self.current_<%= file_name %> = <%= class_name %>.authenticate(params[:login], params[:password])
+ if logged_in?
+ if params[:remember_me] == "1"
+ self.current_<%= file_name %>.remember_me
+ cookies[:auth_token] = { :value => self.current_<%= file_name %>.remember_token , :expires => self.current_<%= file_name %>.remember_token_expires_at }
+ end
+ redirect_back_or_default(:controller => '/<%= controller_file_name %>', :action => 'index')
+ flash[:notice] = "Logged in successfully"
+ end
+ end
+
+ def signup
+ @<%= file_name %> = <%= class_name %>.new(params[:<%= file_name %>])
+ return unless request.post?
+ @<%= file_name %>.save!
+ self.current_<%= file_name %> = @<%= file_name %>
+ redirect_back_or_default(:controller => '/<%= controller_file_name %>', :action => 'index')
+ flash[:notice] = "Thanks for signing up!"
+ rescue ActiveRecord::RecordInvalid
+ render :action => 'signup'
+ end
+
+ def logout
+ self.current_<%= file_name %>.forget_me if logged_in?
+ cookies.delete :auth_token
+ reset_session
+ flash[:notice] = "You have been logged out."
+ redirect_back_or_default(:controller => '/<%= controller_file_name %>', :action => 'index')
+ end
+end
--- /dev/null
+quentin:
+ id: 1
+ login: quentin
+ email: quentin@example.com
+ salt: 7e3041ebc2fc05a40c60028e2c4901a81035d3cd
+ crypted_password: 00742970dc9e6319f8019fd54864d3ea740f04b1 # test
+ #crypted_password: "ce2/iFrNtQ8=\n" # quentin, use only if you're using 2-way encryption
+ created_at: <%%= 5.days.ago.to_s :db %>
+ # activated_at: <%%= 5.days.ago.to_s :db %> # only if you're activating new signups
+aaron:
+ id: 2
+ login: aaron
+ email: aaron@example.com
+ salt: 7e3041ebc2fc05a40c60028e2c4901a81035d3cd
+ crypted_password: 00742970dc9e6319f8019fd54864d3ea740f04b1 # test
+ # activation_code: aaronscode # only if you're activating new signups
+ created_at: <%%= 1.days.ago.to_s :db %>
\ No newline at end of file
--- /dev/null
+require File.dirname(__FILE__) + '/../test_helper'
+require '<%= controller_file_name %>_controller'
+
+# Re-raise errors caught by the controller.
+class <%= controller_class_name %>Controller; def rescue_action(e) raise e end; end
+
+class <%= controller_class_name %>ControllerTest < Test::Unit::TestCase
+ # Be sure to include AuthenticatedTestHelper in test/test_helper.rb instead
+ # Then, you can remove it from this and the units test.
+ include AuthenticatedTestHelper
+
+ fixtures :<%= table_name %>
+
+ def setup
+ @controller = <%= controller_class_name %>Controller.new
+ @request = ActionController::TestRequest.new
+ @response = ActionController::TestResponse.new
+ end
+
+ def test_should_login_and_redirect
+ post :login, :login => 'quentin', :password => 'test'
+ assert session[:<%= file_name %>]
+ assert_response :redirect
+ end
+
+ def test_should_fail_login_and_not_redirect
+ post :login, :login => 'quentin', :password => 'bad password'
+ assert_nil session[:<%= file_name %>]
+ assert_response :success
+ end
+
+ def test_should_allow_signup
+ assert_difference <%= class_name %>, :count do
+ create_<%= file_name %>
+ assert_response :redirect
+ end
+ end
+
+ def test_should_require_login_on_signup
+ assert_no_difference <%= class_name %>, :count do
+ create_<%= file_name %>(:login => nil)
+ assert assigns(:<%= file_name %>).errors.on(:login)
+ assert_response :success
+ end
+ end
+
+ def test_should_require_password_on_signup
+ assert_no_difference <%= class_name %>, :count do
+ create_<%= file_name %>(:password => nil)
+ assert assigns(:<%= file_name %>).errors.on(:password)
+ assert_response :success
+ end
+ end
+
+ def test_should_require_password_confirmation_on_signup
+ assert_no_difference <%= class_name %>, :count do
+ create_<%= file_name %>(:password_confirmation => nil)
+ assert assigns(:<%= file_name %>).errors.on(:password_confirmation)
+ assert_response :success
+ end
+ end
+
+ def test_should_require_email_on_signup
+ assert_no_difference <%= class_name %>, :count do
+ create_<%= file_name %>(:email => nil)
+ assert assigns(:<%= file_name %>).errors.on(:email)
+ assert_response :success
+ end
+ end
+
+ def test_should_logout
+ login_as :quentin
+ get :logout
+ assert_nil session[:<%= file_name %>]
+ assert_response :redirect
+ end
+
+ def test_should_remember_me
+ post :login, :login => 'quentin', :password => 'test', :remember_me => "1"
+ assert_not_nil @response.cookies["auth_token"]
+ end
+
+ def test_should_not_remember_me
+ post :login, :login => 'quentin', :password => 'test', :remember_me => "0"
+ assert_nil @response.cookies["auth_token"]
+ end
+
+ def test_should_delete_token_on_logout
+ login_as :quentin
+ get :logout
+ assert_equal @response.cookies["auth_token"], []
+ end
+
+ def test_should_login_with_cookie
+ <%= table_name %>(:quentin).remember_me
+ @request.cookies["auth_token"] = cookie_for(:quentin)
+ get :index
+ assert @controller.send(:logged_in?)
+ end
+
+ def test_should_fail_expired_cookie_login
+ <%= table_name %>(:quentin).remember_me
+ users(:quentin).update_attribute :remember_token_expires_at, 5.minutes.ago
+ @request.cookies["auth_token"] = cookie_for(:quentin)
+ get :index
+ assert !@controller.send(:logged_in?)
+ end
+
+ def test_should_fail_cookie_login
+ <%= table_name %>(:quentin).remember_me
+ @request.cookies["auth_token"] = auth_token('invalid_auth_token')
+ get :index
+ assert !@controller.send(:logged_in?)
+ end
+
+ protected
+ def create_<%= file_name %>(options = {})
+ post :signup, :<%= file_name %> => { :login => 'quire', :email => 'quire@example.com',
+ :password => 'quire', :password_confirmation => 'quire' }.merge(options)
+ end
+
+ def auth_token(token)
+ CGI::Cookie.new('name' => 'auth_token', 'value' => token)
+ end
+
+ def cookie_for(<%= file_name %>)
+ auth_token <%= table_name %>(<%= file_name %>).remember_token
+ end
+end
--- /dev/null
+module <%= controller_class_name %>Helper
+end
\ No newline at end of file
--- /dev/null
+<h1>In the Caboose.</h1>
+
+<%% content_for 'poem' do -%>
+"Train delayed? and what's to say?"
+"Blocked by last night's snow they say."
+Seven hours or so to wait;
+Well, that's pleasant! but there's the freight.
+Depot loafing no one fancies,
+We'll try the caboose and take our chances.
+
+Cool this morning in Watertown,
+Somewhat frosty___mercury down;
+Enter caboose___roaring fire,
+With never an air-hole; heat so dire
+That we shrivel and pant; we are roasted through-
+Outside, thermometer thirty-two.
+
+We start with a jerk and suddenly stop.
+"What's broke?" says one; another "What's up?",
+"Oh, nothing," they answer, "That's our way:
+You must stand the jerking, sorry to say."
+We "stand it" with oft this painful thought:
+Are our heads on yet, or are they not?
+
+Comrades in misery___let me see;
+Girl like a statue opposite me;
+Back and forth the others jostle___
+She never winks, nor moves a muscle;
+See her, as she sits there now;
+She's "well balanced," anyhow.
+
+Woman in trouble, tearful eyes,
+Sits by the window, softly cries,
+Pity___for griefs we may not know,
+For breasts that ache, for tears that flow,
+Though we know not why. Her eyelids red
+Tell a sorrowful tale___some hope is dead.
+
+Man who follows the Golden Rule,
+And lends his papers___a pocket full,
+Has a blank book___once in a minute
+Has an idea, and writes it in it.
+Guess him? Yes, of course I can,
+He's a___well___a newspaper man.
+
+Blue-eyed fairy, wrapped in fur;
+Sweet young mother tending her.
+Fairy thinks it's "awful far,"
+Wants to get off this "naughty car."
+So do we, young golden-hair;
+All this crowd are with you there!
+<%% end -%>
+
+<%%= simple_format @content_for_poem %>
+
+<p><a href="http://skyways.lib.ks.us/poetry/walls/caboose.html">-- Ellen P. Allerton.</a></p>
\ No newline at end of file
--- /dev/null
+<%% form_tag do -%>
+<p><label for="login">Login</label><br/>
+<%%= text_field_tag 'login' %></p>
+
+<p><label for="password">Password</label><br/>
+<%%= password_field_tag 'password' %></p>
+
+<!-- Uncomment this if you want this functionality
+<p><label for="remember_me">Remember me:</label>
+<%%= check_box_tag 'remember_me' %></p>
+-->
+
+<p><%%= submit_tag 'Log in' %></p>
+<%% end -%>
--- /dev/null
+class <%= migration_name %> < ActiveRecord::Migration
+ def self.up
+ create_table "<%= table_name %>", :force => true do |t|
+ t.column :login, :string
+ t.column :email, :string
+ t.column :crypted_password, :string, :limit => 40
+ t.column :salt, :string, :limit => 40
+ t.column :created_at, :datetime
+ t.column :updated_at, :datetime
+ t.column :remember_token, :string
+ t.column :remember_token_expires_at, :datetime
+ end
+ end
+
+ def self.down
+ drop_table "<%= table_name %>"
+ end
+end
--- /dev/null
+require 'digest/sha1'
+class <%= class_name %> < ActiveRecord::Base
+ # Virtual attribute for the unencrypted password
+ attr_accessor :password
+
+ validates_presence_of :login, :email
+ validates_presence_of :password, :if => :password_required?
+ validates_presence_of :password_confirmation, :if => :password_required?
+ validates_length_of :password, :within => 4..40, :if => :password_required?
+ validates_confirmation_of :password, :if => :password_required?
+ validates_length_of :login, :within => 3..40
+ validates_length_of :email, :within => 3..100
+ validates_uniqueness_of :login, :email, :case_sensitive => false
+ before_save :encrypt_password
+
+ # Authenticates a user by their login name and unencrypted password. Returns the user or nil.
+ def self.authenticate(login, password)
+ u = find_by_login(login) # need to get the salt
+ u && u.authenticated?(password) ? u : nil
+ end
+
+ # Encrypts some data with the salt.
+ def self.encrypt(password, salt)
+ Digest::SHA1.hexdigest("--#{salt}--#{password}--")
+ end
+
+ # Encrypts the password with the user salt
+ def encrypt(password)
+ self.class.encrypt(password, salt)
+ end
+
+ def authenticated?(password)
+ crypted_password == encrypt(password)
+ end
+
+ def remember_token?
+ remember_token_expires_at && Time.now.utc < remember_token_expires_at
+ end
+
+ # These create and unset the fields required for remembering users between browser closes
+ def remember_me
+ self.remember_token_expires_at = 2.weeks.from_now.utc
+ self.remember_token = encrypt("#{email}--#{remember_token_expires_at}")
+ save(false)
+ end
+
+ def forget_me
+ self.remember_token_expires_at = nil
+ self.remember_token = nil
+ save(false)
+ end
+
+ protected
+ # before filter
+ def encrypt_password
+ return if password.blank?
+ self.salt = Digest::SHA1.hexdigest("--#{Time.now.to_s}--#{login}--") if new_record?
+ self.crypted_password = encrypt(password)
+ end
+
+ def password_required?
+ crypted_password.blank? || !password.blank?
+ end
+end
--- /dev/null
+<%%= error_messages_for :<%= file_name %> %>
+<%% form_for :<%= file_name %> do |f| -%>
+<p><label for="login">Login</label><br/>
+<%%= f.text_field :login %></p>
+
+<p><label for="email">Email</label><br/>
+<%%= f.text_field :email %></p>
+
+<p><label for="password">Password</label><br/>
+<%%= f.password_field :password %></p>
+
+<p><label for="password_confirmation">Confirm Password</label><br/>
+<%%= f.password_field :password_confirmation %></p>
+
+<p><%%= submit_tag 'Sign up' %></p>
+<%% end -%>
--- /dev/null
+require File.dirname(__FILE__) + '/../test_helper'
+
+class <%= class_name %>Test < Test::Unit::TestCase
+ # Be sure to include AuthenticatedTestHelper in test/test_helper.rb instead.
+ # Then, you can remove it from this and the functional test.
+ include AuthenticatedTestHelper
+ fixtures :<%= table_name %>
+
+ def test_should_create_<%= file_name %>
+ assert_difference <%= class_name %>, :count do
+ <%= file_name %> = create_<%= file_name %>
+ assert !<%= file_name %>.new_record?, "#{<%= file_name %>.errors.full_messages.to_sentence}"
+ end
+ end
+
+ def test_should_require_login
+ assert_no_difference <%= class_name %>, :count do
+ u = create_<%= file_name %>(:login => nil)
+ assert u.errors.on(:login)
+ end
+ end
+
+ def test_should_require_password
+ assert_no_difference <%= class_name %>, :count do
+ u = create_<%= file_name %>(:password => nil)
+ assert u.errors.on(:password)
+ end
+ end
+
+ def test_should_require_password_confirmation
+ assert_no_difference <%= class_name %>, :count do
+ u = create_<%= file_name %>(:password_confirmation => nil)
+ assert u.errors.on(:password_confirmation)
+ end
+ end
+
+ def test_should_require_email
+ assert_no_difference <%= class_name %>, :count do
+ u = create_<%= file_name %>(:email => nil)
+ assert u.errors.on(:email)
+ end
+ end
+
+ def test_should_reset_password
+ <%= table_name %>(:quentin).update_attributes(:password => 'new password', :password_confirmation => 'new password')
+ assert_equal <%= table_name %>(:quentin), <%= class_name %>.authenticate('quentin', 'new password')
+ end
+
+ def test_should_not_rehash_password
+ <%= table_name %>(:quentin).update_attributes(:login => 'quentin2')
+ assert_equal <%= table_name %>(:quentin), <%= class_name %>.authenticate('quentin2', 'test')
+ end
+
+ def test_should_authenticate_<%= file_name %>
+ assert_equal <%= table_name %>(:quentin), <%= class_name %>.authenticate('quentin', 'test')
+ end
+
+ def test_should_set_remember_token
+ <%= table_name %>(:quentin).remember_me
+ assert_not_nil <%= table_name %>(:quentin).remember_token
+ assert_not_nil <%= table_name %>(:quentin).remember_token_expires_at
+ end
+
+ def test_should_unset_remember_token
+ <%= table_name %>(:quentin).remember_me
+ assert_not_nil <%= table_name %>(:quentin).remember_token
+ <%= table_name %>(:quentin).forget_me
+ assert_nil <%= table_name %>(:quentin).remember_token
+ end
+
+ protected
+ def create_<%= file_name %>(options = {})
+ <%= class_name %>.create({ :login => 'quire', :email => 'quire@example.com', :password => 'quire', :password_confirmation => 'quire' }.merge(options))
+ end
+end
--- /dev/null
+./script/generate authenticated_mailer USERMODEL
\ No newline at end of file
--- /dev/null
+class AuthenticatedMailerGenerator < Rails::Generator::NamedBase
+ def manifest
+ record do |m|
+ # Check for class naming collisions.
+ m.class_collisions class_path, "#{class_name}Notifier", "#{class_name}NotifierTest", "#{class_name}Observer"
+
+ # Controller, helper, views, and test directories.
+ m.directory File.join('app/models', class_path)
+ m.directory File.join('app/views', class_path, "#{file_name}_notifier")
+ m.directory File.join('test/unit', class_path)
+
+ %w( notifier observer ).each do |model_type|
+ m.template "#{model_type}.rb", File.join('app/models',
+ class_path,
+ "#{file_name}_#{model_type}.rb")
+ end
+
+ m.template 'notifier_test.rb', File.join('test/unit', class_path, "#{file_name}_notifier_test.rb")
+
+ # Mailer templates
+ %w( activation signup_notification ).each do |action|
+ m.template "#{action}.rhtml",
+ File.join('app/views', "#{file_name}_notifier", "#{action}.rhtml")
+ end
+ end
+ end
+end
--- /dev/null
+<%%= @<%= file_name %>.login %>, your account has been activated. You may now start adding your plugins:
+
+ <%%= @url %>
\ No newline at end of file
--- /dev/null
+class <%= class_name %>Notifier < ActionMailer::Base
+ def signup_notification(<%= file_name %>)
+ setup_email(<%= file_name %>)
+ @subject += 'Please activate your new account'
+ @body[:url] = "http://YOURSITE/account/activate/#{<%= file_name %>.activation_code}"
+ end
+
+ def activation(<%= file_name %>)
+ setup_email(<%= file_name %>)
+ @subject += 'Your account has been activated!'
+ @body[:url] = "http://YOURSITE/"
+ end
+
+ protected
+ def setup_email(<%= file_name %>)
+ @recipients = "#{<%= file_name %>.email}"
+ @from = "ADMINEMAIL"
+ @subject = "[YOURSITE] "
+ @sent_on = Time.now
+ @body[:<%= file_name %>] = <%= file_name %>
+ end
+end
--- /dev/null
+require File.dirname(__FILE__) + '/../test_helper'
+require '<%= file_name %>_notifier'
+
+class <%= class_name %>NotifierTest < Test::Unit::TestCase
+ FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures'
+ CHARSET = "utf-8"
+
+ include ActionMailer::Quoting
+
+ def setup
+ ActionMailer::Base.delivery_method = :test
+ ActionMailer::Base.perform_deliveries = true
+ ActionMailer::Base.deliveries = []
+
+ @expected = TMail::Mail.new
+ @expected.set_content_type "text", "plain", { "charset" => CHARSET }
+ end
+
+ private
+ def read_fixture(action)
+ IO.readlines("#{FIXTURES_PATH}/<%= file_name %>_notifier/#{action}")
+ end
+
+ def encode(subject)
+ quoted_printable(subject, CHARSET)
+ end
+end
--- /dev/null
+class <%= class_name %>Observer < ActiveRecord::Observer
+ def after_create(<%= file_name %>)
+ <%= class_name %>Notifier.deliver_signup_notification(<%= file_name %>)
+ end
+
+ def after_save(<%= file_name %>)
+ <%= class_name %>Notifier.deliver_activation(<%= file_name %>) if <%= file_name %>.recently_activated?
+ end
+end
\ No newline at end of file
--- /dev/null
+Your account has been created.
+
+ Username: <%%= @<%= file_name %>.login %>
+ Password: <%%= @<%= file_name %>.password %>
+
+Visit this url to activate your account:
+
+ <%%= @url %>
\ No newline at end of file
--- /dev/null
+puts IO.read(File.join(File.dirname(__FILE__), 'README'))
\ No newline at end of file
--- /dev/null
+Copyright (c) 2006 Geoffrey Grosenbach
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--- /dev/null
+== Gruff Plugin
+
+Make pretty graphs.
+
+Also has a "gruff" generator for copying a controller and functional test to your app.
+
+ ./script/generate gruff Reports
+
+See examples at http://nubyonrails.com/pages/gruff
+
+== Author
+
+Geoffrey Grosenbach boss@topfunky.com http://nubyonrails.com
+
+
--- /dev/null
+require 'rake'
+require 'rake/testtask'
+require 'rake/rdoctask'
+
+desc 'Default: run unit tests.'
+task :default => :test
+
+desc 'Test the plugin.'
+Rake::TestTask.new(:test) do |t|
+ t.libs << 'lib'
+ t.pattern = 'test/**/test_*.rb'
+ t.verbose = true
+end
+
+desc 'Generate documentation for the plugin.'
+Rake::RDocTask.new(:rdoc) do |rdoc|
+ rdoc.rdoc_dir = 'rdoc'
+ rdoc.title = 'CalendarHelper'
+ rdoc.options << '--line-numbers' << '--inline-source'
+ rdoc.rdoc_files.include('README')
+ rdoc.rdoc_files.include('lib/**/*.rb')
+end
+
--- /dev/null
+author: topfunky
+summary: Make graphs. Requires RMagick. Includes a generator for a graphics controller, and tests.
+homepage: http://nubyonrails.com/pages/gruff
+plugin: http://topfunky.net/svn/plugins/gruff
+license: MIT
+version: 0.1.2
+rails_version: 1.0+
--- /dev/null
+class GruffGenerator < Rails::Generator::NamedBase
+
+ attr_reader :controller_name,
+ :controller_class_path,
+ :controller_file_path,
+ :controller_class_nesting,
+ :controller_class_nesting_depth,
+ :controller_class_name,
+ :controller_singular_name,
+ :controller_plural_name,
+ :parent_folder_for_require
+ alias_method :controller_file_name, :controller_singular_name
+ alias_method :controller_table_name, :controller_plural_name
+
+ def initialize(runtime_args, runtime_options = {})
+ super
+
+ # Take controller name from the next argument.
+ @controller_name = runtime_args.shift
+
+ base_name, @controller_class_path, @controller_file_path, @controller_class_nesting, @controller_class_nesting_depth = extract_modules(@controller_name)
+ @controller_class_name_without_nesting, @controller_singular_name, @controller_plural_name = inflect_names(base_name)
+
+ if @controller_class_nesting.empty?
+ @controller_class_name = @controller_class_name_without_nesting
+ else
+ @controller_class_name = "#{@controller_class_nesting}::#{@controller_class_name_without_nesting}"
+ end
+ end
+
+ def manifest
+ record do |m|
+ # Check for class naming collisions.
+ m.class_collisions controller_class_path, "#{controller_class_name}Controller",
+ "#{controller_class_name}ControllerTest"
+
+ # Controller, helper, views, and test directories.
+ m.directory File.join('app/controllers', controller_class_path)
+ m.directory File.join('test/functional', controller_class_path)
+
+ m.template 'controller.rb',
+ File.join('app/controllers',
+ controller_class_path,
+ "#{controller_file_name}_controller.rb")
+
+ # For some reason this doesn't take effect if done in initialize()
+ @parent_folder_for_require = @controller_class_path.join('/').gsub(%r%app/controllers/?%, '')
+ @parent_folder_for_require += @parent_folder_for_require.blank? ? '' : '/'
+
+ m.template 'functional_test.rb',
+ File.join('test/functional',
+ controller_class_path,
+ "#{controller_file_name}_controller_test.rb")
+
+ end
+ end
+
+ protected
+ # Override with your own usage banner.
+ def banner
+ "Usage: #{$0} gruff ControllerName"
+ end
+end
--- /dev/null
+class <%= controller_class_name %>Controller < ApplicationController
+
+ # To make caching easier, add a line like this to config/routes.rb:
+ # map.graph "graph/:action/:id/image.png", :controller => "graph"
+ #
+ # Then reference it with the named route:
+ # image_tag graph_url(:action => 'show', :id => 42)
+
+ def show
+ g = Gruff::Line.new
+ # Uncomment to use your own theme or font
+ # See http://colourlovers.com or http://www.firewheeldesign.com/widgets/ for color ideas
+# g.theme = {
+# :colors => ['#663366', '#cccc99', '#cc6633', '#cc9966', '#99cc99'],
+# :marker_color => 'white',
+# :background_colors => ['black', '#333333']
+# }
+# g.font = File.expand_path('artwork/fonts/VeraBd.ttf', RAILS_ROOT)
+
+ g.title = "Gruff-o-Rama"
+
+ g.data("Apples", [1, 2, 3, 4, 4, 3])
+ g.data("Oranges", [4, 8, 7, 9, 8, 9])
+ g.data("Watermelon", [2, 3, 1, 5, 6, 8])
+ g.data("Peaches", [9, 9, 10, 8, 7, 9])
+
+ g.labels = {0 => '2004', 2 => '2005', 4 => '2006'}
+
+ send_data(g.to_blob, :disposition => 'inline', :type => 'image/png', :filename => "gruff.png")
+ end
+
+end
--- /dev/null
+require File.dirname(__FILE__) + '<%= '/..' * controller_class_name.split("::").length %>/test_helper'
+require '<%= parent_folder_for_require %><%= controller_file_name %>_controller'
+
+# Re-raise errors caught by the controller.
+class <%= controller_class_name %>Controller; def rescue_action(e) raise e end; end
+
+class <%= controller_class_name %>ControllerTest < Test::Unit::TestCase
+
+ #fixtures :data
+
+ def setup
+ @controller = <%= controller_class_name %>Controller.new
+ @request = ActionController::TestRequest.new
+ @response = ActionController::TestResponse.new
+ end
+
+ # TODO Replace this with your actual tests
+ def test_show
+ get :show
+ assert_response :success
+ assert_equal 'image/png', @response.headers['Content-Type']
+ end
+
+end
--- /dev/null
+# Extra full path added to fix loading errors on some installations.
+
+%w(
+ base
+ area
+ bar
+ line
+ pie
+ spider
+ net
+ stacked_bar
+ side_stacked_bar
+ side_bar
+ accumulator_bar
+
+ scene
+
+ mini/legend
+ mini/bar
+ mini/pie
+ mini/side_bar
+).each do |filename|
+ require File.dirname(__FILE__) + "/gruff/#{filename}"
+end
--- /dev/null
+require File.dirname(__FILE__) + '/base'
+
+##
+# A special bar graph that shows a single dataset as a set of
+# stacked bars. The bottom bar shows the running total and
+# the top bar shows the new value being added to the array.
+
+class Gruff::AccumulatorBar < Gruff::StackedBar
+
+ def draw
+ raise(Gruff::IncorrectNumberOfDatasetsException) unless @data.length == 1
+
+ accumulator_array = []
+ index = 0
+
+ increment_array = @data.first[DATA_VALUES_INDEX].inject([]) {|memo, value|
+ memo << ((index > 0) ? (value + memo.max) : value)
+ accumulator_array << memo[index] - value
+ index += 1
+ memo
+ }
+ data "Accumulator", accumulator_array
+
+ super
+ end
+
+end
--- /dev/null
+
+require File.dirname(__FILE__) + '/base'
+
+class Gruff::Area < Gruff::Base
+
+ def draw
+ super
+
+ return unless @has_data
+
+ @x_increment = @graph_width / (@column_count - 1).to_f
+ @d = @d.stroke 'transparent'
+
+ @norm_data.each do |data_row|
+ poly_points = Array.new
+ prev_x = prev_y = 0.0
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
+
+ data_row[1].each_with_index do |data_point, index|
+ # Use incremented x and scaled y
+ new_x = @graph_left + (@x_increment * index)
+ new_y = @graph_top + (@graph_height - data_point * @graph_height)
+
+ if prev_x > 0 and prev_y > 0 then
+ poly_points << new_x
+ poly_points << new_y
+
+ #@d = @d.polyline(prev_x, prev_y, new_x, new_y)
+ else
+ poly_points << @graph_left
+ poly_points << @graph_bottom - 1
+ poly_points << new_x
+ poly_points << new_y
+
+ #@d = @d.polyline(@graph_left, @graph_bottom, new_x, new_y)
+ end
+
+ draw_label(new_x, index)
+
+ prev_x = new_x
+ prev_y = new_y
+ end
+
+ # Add closing points, draw polygon
+ poly_points << @graph_right
+ poly_points << @graph_bottom - 1
+ poly_points << @graph_left
+ poly_points << @graph_bottom - 1
+
+ @d = @d.polyline(*poly_points)
+
+ end
+
+ @d.draw(@base_image)
+ end
+
+
+end
--- /dev/null
+
+require File.dirname(__FILE__) + '/base'
+require File.dirname(__FILE__) + '/bar_conversion'
+
+class Gruff::Bar < Gruff::Base
+
+ def draw
+ # Labels will be centered over the left of the bar if
+ # there are more labels than columns. This is basically the same
+ # as where it would be for a line graph.
+ @center_labels_over_point = (@labels.keys.length > @column_count ? true : false)
+
+ super
+ return unless @has_data
+
+ draw_bars
+ end
+
+protected
+
+ def draw_bars
+ # Setup spacing.
+ #
+ # Columns sit side-by-side.
+ spacing_factor = 0.9 # space between the bars
+ @bar_width = @graph_width / (@column_count * @data.length).to_f
+
+ @d = @d.stroke_opacity 0.0
+
+ # Setup the BarConversion Object
+ conversion = Gruff::BarConversion.new()
+ conversion.graph_height = @graph_height
+ conversion.graph_top = @graph_top
+
+ # Set up the right mode [1,2,3] see BarConversion for further explanation
+ if @minimum_value >= 0 then
+ # all bars go from zero to positiv
+ conversion.mode = 1
+ else
+ # all bars go from 0 to negativ
+ if @maximum_value <= 0 then
+ conversion.mode = 2
+ else
+ # bars either go from zero to negativ or to positiv
+ conversion.mode = 3
+ conversion.spread = @spread
+ conversion.minimum_value = @minimum_value
+ conversion.zero = -@minimum_value/@spread
+ end
+ end
+
+ # iterate over all normalised data
+ @norm_data.each_with_index do |data_row, row_index|
+
+ data_row[1].each_with_index do |data_point, point_index|
+ # Use incremented x and scaled y
+ # x
+ left_x = @graph_left + (@bar_width * (row_index + point_index + ((@data.length - 1) * point_index)))
+ right_x = left_x + @bar_width * spacing_factor
+ # y
+ conv = []
+ conversion.getLeftYRightYscaled( data_point, conv )
+
+ # create new bar
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
+ @d = @d.rectangle(left_x, conv[0], right_x, conv[1])
+
+ # Calculate center based on bar_width and current row
+ label_center = @graph_left +
+ (@data.length * @bar_width * point_index) +
+ (@data.length * @bar_width / 2.0)
+ # Subtract half a bar width to center left if requested
+ draw_label(label_center - (@center_labels_over_point ? @bar_width / 2.0 : 0.0), point_index)
+ end
+
+ end
+
+ # Draw the last label if requested
+ draw_label(@graph_right, @column_count) if @center_labels_over_point
+
+ @d.draw(@base_image)
+ end
+
+end
--- /dev/null
+##
+# Original Author: David Stokar
+#
+# This class perfoms the y coordinats conversion for the bar class.
+#
+# There are three cases:
+#
+# 1. Bars all go from zero in positive direction
+# 2. Bars all go from zero to negative direction
+# 3. Bars either go from zero to positive or from zero to negative
+#
+class Gruff::BarConversion
+ attr_writer :mode
+ attr_writer :zero
+ attr_writer :graph_top
+ attr_writer :graph_height
+ attr_writer :minimum_value
+ attr_writer :spread
+
+ def getLeftYRightYscaled( data_point, result )
+ case @mode
+ when 1 then # Case one
+ # minimum value >= 0 ( only positiv values )
+ result[0] = @graph_top + @graph_height*(1 - data_point) + 1
+ result[1] = @graph_top + @graph_height - 1
+ when 2 then # Case two
+ # only negativ values
+ result[0] = @graph_top + 1
+ result[1] = @graph_top + @graph_height*(1 - data_point) - 1
+ when 3 then # Case three
+ # positiv and negativ values
+ val = data_point-@minimum_value/@spread
+ if ( data_point >= @zero ) then
+ result[0] = @graph_top + @graph_height*(1 - (val-@zero)) + 1
+ result[1] = @graph_top + @graph_height*(1 - @zero) - 1
+ else
+ result[0] = @graph_top + @graph_height*(1 - (val-@zero)) + 1
+ result[1] = @graph_top + @graph_height*(1 - @zero) - 1
+ end
+ else
+ result[0] = 0.0
+ result[1] = 0.0
+ end
+ end
+
+end
--- /dev/null
+#
+# = Gruff. Graphs.
+#
+# Author:: Geoffrey Grosenbach boss@topfunky.com
+#
+# Originally Created:: October 23, 2005
+#
+# Extra thanks to Tim Hunter for writing RMagick,
+# and also contributions by
+# Jarkko Laine, Mike Perham, Andreas Schwarz,
+# Alun Eyre, Guillaume Theoret, David Stokar,
+# Paul Rogers, Dave Woodward, Frank Oxener,
+# Kevin Clark, Cies Breijs, Richard Cowin,
+# and a cast of thousands.
+#
+
+require 'rubygems'
+require 'RMagick'
+require File.dirname(__FILE__) + '/deprecated'
+
+module Gruff
+
+ VERSION = '0.2.9'
+
+ class Base
+
+ include Magick
+ include Deprecated
+
+ # Draw extra lines showing where the margins and text centers are
+ DEBUG = false
+
+ # Used for navigating the array of data to plot
+ DATA_LABEL_INDEX = 0
+ DATA_VALUES_INDEX = 1
+ DATA_COLOR_INDEX = 2
+
+ # Blank space around the edges of the graph
+ TOP_MARGIN = BOTTOM_MARGIN = RIGHT_MARGIN = LEFT_MARGIN = 20.0
+
+ # Space around text elements. Mostly used for vertical spacing
+ LEGEND_MARGIN = TITLE_MARGIN = LABEL_MARGIN = 10.0
+
+ DEFAULT_TARGET_WIDTH = 800
+
+ # A hash of names for the individual columns, where the key is the array index for the column this label represents.
+ #
+ # Not all columns need to be named.
+ #
+ # Example: 0 => 2005, 3 => 2006, 5 => 2007, 7 => 2008
+ attr_accessor :labels
+
+ # Used internally for spacing.
+ #
+ # By default, labels are centered over the point they represent.
+ attr_accessor :center_labels_over_point
+
+ # Used internally for horizontal graph types.
+ attr_accessor :has_left_labels
+
+ # A label for the bottom of the graph
+ attr_accessor :x_axis_label
+
+ # A label for the left side of the graph
+ attr_accessor :y_axis_label
+
+ # attr_accessor :x_axis_increment
+
+ # Manually set increment of the horizontal marking lines
+ attr_accessor :y_axis_increment
+
+ # Get or set the list of colors that will be used to draw the bars or lines.
+ attr_accessor :colors
+
+ # The large title of the graph displayed at the top
+ attr_accessor :title
+
+ # Font used for titles, labels, etc. Works best if you provide the full path to the TTF font file.
+ # RMagick must be built with the Freetype libraries for this to work properly.
+ #
+ # Tries to find Bitstream Vera (Vera.ttf) in the location specified by
+ # ENV['MAGICK_FONT_PATH']. Uses default RMagick font otherwise.
+ #
+ # The font= method below fulfills the role of the writer, so we only need
+ # a reader here.
+ attr_reader :font
+
+ attr_accessor :font_color
+
+ # Hide various elements
+ attr_accessor :hide_line_markers, :hide_legend, :hide_title, :hide_line_numbers
+
+ # Message shown when there is no data. Fits up to 20 characters. Defaults to "No Data."
+ attr_accessor :no_data_message
+
+ # The font size of the large title at the top of the graph
+ attr_accessor :title_font_size
+
+ # Optionally set the size of the font. Based on an 800x600px graph. Default is 20.
+ #
+ # Will be scaled down if graph is smaller than 800px wide.
+ attr_accessor :legend_font_size
+
+ # The font size of the labels around the graph
+ attr_accessor :marker_font_size
+
+ # The color of the auxiliary lines
+ attr_accessor :marker_color
+
+ # The number of horizontal lines shown for reference
+ attr_accessor :marker_count
+
+
+ # You can manually set a minimum value instead of having the values guessed for you.
+ #
+ # Set it after you have given all your data to the graph object.
+ attr_accessor :minimum_value
+
+ # You can manually set a maximum value, such as a percentage-based graph that always goes to 100.
+ #
+ # If you use this, you must set it after you have given all your data to the graph object.
+ attr_accessor :maximum_value
+
+ # Set to false if you don't want the data to be sorted with largest avg values at the back.
+ attr_accessor :sort
+
+ # Experimental
+ attr_accessor :additional_line_values
+
+ # Experimental
+ attr_accessor :stacked
+
+
+ # Optionally set the size of the colored box by each item in the legend. Default is 20.0
+ #
+ # Will be scaled down if graph is smaller than 800px wide.
+ attr_accessor :legend_box_size
+
+
+ # If one numerical argument is given, the graph is drawn at 4/3 ratio according to the given width (800 results in 800x600, 400 gives 400x300, etc.).
+ #
+ # Or, send a geometry string for other ratios ('800x400', '400x225').
+ #
+ # Looks for Bitstream Vera as the default font. Expects an environment var of MAGICK_FONT_PATH to be set. (Uses RMagick's default font otherwise.)
+ def initialize(target_width=DEFAULT_TARGET_WIDTH)
+
+ if not Numeric === target_width
+ geometric_width, geometric_height = target_width.split('x')
+ @columns = geometric_width.to_f
+ @rows = geometric_height.to_f
+ else
+ @columns = target_width.to_f
+ @rows = target_width.to_f * 0.75
+ end
+
+ initialize_ivars
+
+ reset_themes
+ theme_keynote
+ end
+
+ ##
+ # Set instance variables for this object.
+ #
+ # Subclasses can override this, call super, then set values separately.
+ #
+ # This makes it possible to set defaults in a subclass but still allow
+ # developers to change this values in their program.
+
+ def initialize_ivars
+ # Internal for calculations
+ @raw_columns = 800.0
+ @raw_rows = 800.0 * (@rows/@columns)
+ @column_count = 0
+ @marker_count = nil
+ @maximum_value = @minimum_value = nil
+ @has_data = false
+ @data = Array.new
+ @labels = Hash.new
+ @labels_seen = Hash.new
+ @sort = true
+ @title = nil
+
+ @scale = @columns / @raw_columns
+
+ vera_font_path = File.expand_path('Vera.ttf', ENV['MAGICK_FONT_PATH'])
+ @font = File.exists?(vera_font_path) ? vera_font_path : nil
+
+ @marker_font_size = 21.0
+ @legend_font_size = 20.0
+ @title_font_size = 36.0
+
+ @legend_box_size = 20.0
+
+ @no_data_message = "No Data"
+
+ @hide_line_markers = @hide_legend = @hide_title = @hide_line_numbers = false
+ @center_labels_over_point = true
+ @has_left_labels = false
+
+ @additional_line_values = []
+ @additional_line_colors = []
+ @theme_options = {}
+
+ @x_axis_label = @y_axis_label = nil
+ @y_axis_increment = nil
+ @stacked = nil
+ @norm_data = nil
+ end
+
+ def font=(font_path)
+ @font = font_path
+ @d.font = @font
+ end
+
+ # Add a color to the list of available colors for lines.
+ #
+ # Example:
+ # add_color('#c0e9d3')
+ def add_color(colorname)
+ @colors << colorname
+ end
+
+
+ # Replace the entire color list with a new array of colors. You need to have one more color
+ # than the number of datasets you intend to draw. Also aliased as the colors= setter method.
+ #
+ # Example:
+ # replace_colors('#cc99cc', '#d9e043', '#34d8a2')
+ def replace_colors(color_list=[])
+ @colors = color_list
+ end
+
+
+ # You can set a theme manually. Assign a hash to this method before you send your data.
+ #
+ # graph.theme = {
+ # :colors => %w(orange purple green white red),
+ # :marker_color => 'blue',
+ # :background_colors => %w(black grey)
+ # }
+ #
+ # :background_image => 'squirrel.png' is also possible.
+ #
+ # (Or hopefully something better looking than that.)
+ #
+ def theme=(options)
+ reset_themes()
+
+ defaults = {
+ :colors => ['black', 'white'],
+ :additional_line_colors => [],
+ :marker_color => 'white',
+ :font_color => 'black',
+ :background_colors => nil,
+ :background_image => nil
+ }
+ @theme_options = defaults.merge options
+
+ @colors = @theme_options[:colors]
+ @marker_color = @theme_options[:marker_color]
+ @font_color = @theme_options[:font_color] || @marker_color
+ @additional_line_colors = @theme_options[:additional_line_colors]
+
+ render_background
+ end
+
+ # A color scheme similar to the popular presentation software.
+ def theme_keynote
+ # Colors
+ @blue = '#6886B4'
+ @yellow = '#FDD84E'
+ @green = '#72AE6E'
+ @red = '#D1695E'
+ @purple = '#8A6EAF'
+ @orange = '#EFAA43'
+ @white = 'white'
+ @colors = [@yellow, @blue, @green, @red, @purple, @orange, @white]
+
+ self.theme = {
+ :colors => @colors,
+ :marker_color => 'white',
+ :font_color => 'white',
+ :background_colors => ['black', '#4a465a']
+ }
+ end
+
+ # A color scheme plucked from the colors on the popular usability blog.
+ def theme_37signals
+ # Colors
+ @green = '#339933'
+ @purple = '#cc99cc'
+ @blue = '#336699'
+ @yellow = '#FFF804'
+ @red = '#ff0000'
+ @orange = '#cf5910'
+ @black = 'black'
+ @colors = [@yellow, @blue, @green, @red, @purple, @orange, @black]
+
+ self.theme = {
+ :colors => @colors,
+ :marker_color => 'black',
+ :font_color => 'black',
+ :background_colors => ['#d1edf5', 'white']
+ }
+ end
+
+ # A color scheme from the colors used on the 2005 Rails keynote presentation at RubyConf.
+ def theme_rails_keynote
+ # Colors
+ @green = '#00ff00'
+ @grey = '#333333'
+ @orange = '#ff5d00'
+ @red = '#f61100'
+ @white = 'white'
+ @light_grey = '#999999'
+ @black = 'black'
+ @colors = [@green, @grey, @orange, @red, @white, @light_grey, @black]
+
+ self.theme = {
+ :colors => @colors,
+ :marker_color => 'white',
+ :font_color => 'white',
+ :background_colors => ['#0083a3', '#0083a3']
+ }
+ end
+
+ # A color scheme similar to that used on the popular podcast site.
+ def theme_odeo
+ # Colors
+ @grey = '#202020'
+ @white = 'white'
+ @dark_pink = '#a21764'
+ @green = '#8ab438'
+ @light_grey = '#999999'
+ @dark_blue = '#3a5b87'
+ @black = 'black'
+ @colors = [@grey, @white, @dark_blue, @dark_pink, @green, @light_grey, @black]
+
+ self.theme = {
+ :colors => @colors,
+ :marker_color => 'white',
+ :font_color => 'white',
+ :background_colors => ['#ff47a4', '#ff1f81']
+ }
+ end
+
+ # A pastel theme
+ def theme_pastel
+ # Colors
+ @colors = [
+ '#a9dada', # blue
+ '#aedaa9', # green
+ '#daaea9', # peach
+ '#dadaa9', # yellow
+ '#a9a9da', # dk purple
+ '#daaeda', # purple
+ '#dadada' # grey
+ ]
+
+ self.theme = {
+ :colors => @colors,
+ :marker_color => '#aea9a9', # Grey
+ :font_color => 'black',
+ :background_colors => 'white'
+ }
+ end
+
+ # A greyscale theme
+ def theme_greyscale
+ # Colors
+ @colors = [
+ '#282828', #
+ '#383838', #
+ '#686868', #
+ '#989898', #
+ '#c8c8c8', #
+ '#e8e8e8', #
+ ]
+
+ self.theme = {
+ :colors => @colors,
+ :marker_color => '#aea9a9', # Grey
+ :font_color => 'black',
+ :background_colors => 'white'
+ }
+ end
+
+
+ # Parameters are an array where the first element is the name of the dataset
+ # and the value is an array of values to plot.
+ #
+ # Can be called multiple times with different datasets for a multi-valued graph.
+ #
+ # If the color argument is nil, the next color from the default theme will be used.
+ #
+ # NOTE: If you want to use a preset theme, you must set it before calling data().
+ #
+ # Example:
+ #
+ # data("Bart S.", [95, 45, 78, 89, 88, 76], '#ffcc00')
+ #
+ def data(name, data_points=[], color=nil)
+ data_points = Array(data_points) # make sure it's an array
+ @data << [name, data_points, (color || increment_color)]
+ # Set column count if this is larger than previous counts
+ @column_count = (data_points.length > @column_count) ? data_points.length : @column_count
+
+ # Pre-normalize
+ data_points.each_with_index do |data_point, index|
+ next if data_point.nil?
+
+ # Setup max/min so spread starts at the low end of the data points
+ if @maximum_value.nil? && @minimum_value.nil?
+ @maximum_value = @minimum_value = data_point
+ end
+
+ # TODO Doesn't work with stacked bar graphs
+ # Original: @maximum_value = larger_than_max?(data_point, index) ? max(data_point, index) : @maximum_value
+ @maximum_value = larger_than_max?(data_point) ? data_point : @maximum_value
+ @has_data = true if @maximum_value > 0
+
+ @minimum_value = less_than_min?(data_point) ? data_point : @minimum_value
+ @has_data = true if @minimum_value < 0
+ end
+ end
+
+ # Writes the graph to a file. Defaults to 'graph.png'
+ #
+ # Example: write('graphs/my_pretty_graph.png')
+ def write(filename="graph.png")
+ draw()
+ @base_image.write(filename)
+ end
+
+ # Return the graph as a rendered binary blob.
+ def to_blob(fileformat='PNG')
+ draw()
+ return @base_image.to_blob do
+ self.format = fileformat
+ end
+ end
+
+protected
+
+ # Overridden by subclasses to do the actual plotting of the graph.
+ #
+ # Subclasses should start by calling super() for this method.
+ def draw
+ make_stacked if @stacked
+ setup_drawing
+
+ debug {
+ # Outer margin
+ @d.rectangle( LEFT_MARGIN, TOP_MARGIN,
+ @raw_columns - RIGHT_MARGIN, @raw_rows - BOTTOM_MARGIN)
+ # Graph area box
+ @d.rectangle( @graph_left, @graph_top, @graph_right, @graph_bottom)
+ }
+ end
+
+ ##
+ # Calculates size of drawable area and draws the decorations.
+ #
+ # * line markers
+ # * legend
+ # * title
+
+ def setup_drawing
+ # Maybe should be done in one of the following functions for more granularity.
+ unless @has_data
+ draw_no_data()
+ return
+ end
+
+ normalize()
+ setup_graph_measurements()
+ sort_norm_data() if @sort # Sort norm_data with avg largest values set first (for display)
+
+ draw_legend()
+ draw_line_markers()
+ draw_axis_labels()
+ draw_title
+ end
+
+ # Make copy of data with values scaled between 0-100
+ def normalize(force=false)
+ if @norm_data.nil? || force
+ @norm_data = []
+ return unless @has_data
+
+ calculate_spread
+
+ @data.each do |data_row|
+ norm_data_points = []
+ data_row[DATA_VALUES_INDEX].each do |data_point|
+ if data_point.nil?
+ norm_data_points << nil
+ else
+ norm_data_points << ((data_point.to_f - @minimum_value.to_f ) / @spread)
+ end
+ end
+ @norm_data << [data_row[DATA_LABEL_INDEX], norm_data_points, data_row[DATA_COLOR_INDEX]]
+ end
+ end
+ end
+
+ def calculate_spread
+ @spread = @maximum_value.to_f - @minimum_value.to_f
+ @spread = @spread > 0 ? @spread : 1
+ end
+
+ ##
+ # Calculates size of drawable area, general font dimensions, etc.
+
+ def setup_graph_measurements
+ @marker_caps_height = calculate_caps_height(@marker_font_size)
+ @title_caps_height = calculate_caps_height(@title_font_size)
+ @legend_caps_height = calculate_caps_height(@legend_font_size)
+
+ if @hide_line_markers
+ (@graph_left,
+ @graph_right_margin,
+ @graph_bottom_margin) = [LEFT_MARGIN, RIGHT_MARGIN, BOTTOM_MARGIN]
+ else
+ longest_left_label_width = 0
+ if @has_left_labels
+ longest_left_label_width = calculate_width(@marker_font_size,
+ labels.values.inject('') { |value, memo| (value.to_s.length > memo.to_s.length) ? value : memo }) * 1.25
+ else
+ longest_left_label_width = calculate_width(@marker_font_size,
+ label(@maximum_value.to_f))
+ end
+
+ # Shift graph if left line numbers are hidden
+ line_number_width = @hide_line_numbers && !@has_left_labels ?
+ 0.0 :
+ (longest_left_label_width + LABEL_MARGIN * 2)
+
+ @graph_left = LEFT_MARGIN +
+ line_number_width +
+ (@y_axis_label.nil? ? 0.0 : @marker_caps_height + LABEL_MARGIN * 2)
+ # Make space for half the width of the rightmost column label.
+ # Might be greater than the number of columns if between-style bar markers are used.
+ last_label = @labels.keys.sort.last.to_i
+ extra_room_for_long_label = (last_label >= (@column_count-1) && @center_labels_over_point) ?
+ calculate_width(@marker_font_size, @labels[last_label])/2.0 :
+ 0
+ @graph_right_margin = RIGHT_MARGIN + extra_room_for_long_label
+
+ @graph_bottom_margin = BOTTOM_MARGIN +
+ @marker_caps_height + LABEL_MARGIN
+ end
+
+ @graph_right = @raw_columns - @graph_right_margin
+ @graph_width = @raw_columns - @graph_left - @graph_right_margin
+
+ # When @hide title, leave a TITLE_MARGIN space for aesthetics.
+ # Same with @hide_legend
+ @graph_top = TOP_MARGIN +
+ (@hide_title ? TITLE_MARGIN : @title_caps_height + TITLE_MARGIN * 2) +
+ (@hide_legend ? LEGEND_MARGIN : @legend_caps_height + LEGEND_MARGIN * 2)
+
+ @graph_bottom = @raw_rows - @graph_bottom_margin -
+ (@x_axis_label.nil? ? 0.0 : @marker_caps_height + LABEL_MARGIN)
+
+ @graph_height = @graph_bottom - @graph_top
+ end
+
+ # Draw the optional labels for the x axis and y axis.
+ def draw_axis_labels
+ unless @x_axis_label.nil?
+ # X Axis
+ # Centered vertically and horizontally by setting the
+ # height to 1.0 and the width to the width of the graph.
+ x_axis_label_y_coordinate = @graph_bottom + LABEL_MARGIN * 2 + @marker_caps_height
+
+ # TODO Center between graph area
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.stroke('transparent')
+ @d.pointsize = scale_fontsize(@marker_font_size)
+ @d.gravity = NorthGravity
+ @d = @d.annotate_scaled( @base_image,
+ @raw_columns, 1.0,
+ 0.0, x_axis_label_y_coordinate,
+ @x_axis_label, @scale)
+ debug { @d.line 0.0, x_axis_label_y_coordinate, @raw_columns, x_axis_label_y_coordinate }
+ end
+
+ unless @y_axis_label.nil?
+ # Y Axis, rotated vertically
+ @d.rotation = 90.0
+ @d.gravity = CenterGravity
+ @d = @d.annotate_scaled( @base_image,
+ 1.0, @raw_rows,
+ LEFT_MARGIN + @marker_caps_height / 2.0, 0.0,
+ @y_axis_label, @scale)
+ @d.rotation = -90.0
+ end
+ end
+
+ # Draws horizontal background lines and labels
+ def draw_line_markers
+ return if @hide_line_markers
+
+ @d = @d.stroke_antialias false
+
+ if @y_axis_increment.nil?
+ # Try to use a number of horizontal lines that will come out even.
+ #
+ # TODO Do the same for larger numbers...100, 75, 50, 25
+ if @marker_count.nil?
+ (3..7).each do |lines|
+ if @spread % lines == 0.0
+ @marker_count = lines
+ break
+ end
+ end
+ @marker_count ||= 4
+ end
+ @increment = (@spread > 0) ? significant(@spread / @marker_count) : 1
+ else
+ # TODO Make this work for negative values
+ @maximum_value = [@maximum_value.ceil, @y_axis_increment].max
+ @minimum_value = @minimum_value.floor
+ calculate_spread
+ normalize(true)
+
+ @marker_count = (@spread / @y_axis_increment).to_i
+ @increment = @y_axis_increment
+ end
+ @increment_scaled = @graph_height.to_f / (@spread / @increment)
+
+ # Draw horizontal line markers and annotate with numbers
+ (0..@marker_count).each do |index|
+ y = @graph_top + @graph_height - index.to_f * @increment_scaled
+
+ @d = @d.stroke(@marker_color)
+ @d = @d.stroke_width 1
+ @d = @d.line(@graph_left, y, @graph_right, y)
+
+ marker_label = index * @increment + @minimum_value.to_f
+
+ unless @hide_line_numbers
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.stroke('transparent')
+ @d.pointsize = scale_fontsize(@marker_font_size)
+ @d.gravity = EastGravity
+
+ # Vertically center with 1.0 for the height
+ @d = @d.annotate_scaled( @base_image,
+ @graph_left - LABEL_MARGIN, 1.0,
+ 0.0, y,
+ label(marker_label), @scale)
+ end
+ end
+
+ # # Submitted by a contibutor...the utility escapes me
+ # i = 0
+ # @additional_line_values.each do |value|
+ # @increment_scaled = @graph_height.to_f / (@maximum_value.to_f / value)
+ #
+ # y = @graph_top + @graph_height - @increment_scaled
+ #
+ # @d = @d.stroke(@additional_line_colors[i])
+ # @d = @d.line(@graph_left, y, @graph_right, y)
+ #
+ #
+ # @d.fill = @additional_line_colors[i]
+ # @d.font = @font if @font
+ # @d.stroke('transparent')
+ # @d.pointsize = scale_fontsize(@marker_font_size)
+ # @d.gravity = EastGravity
+ # @d = @d.annotate_scaled( @base_image,
+ # 100, 20,
+ # -10, y - (@marker_font_size/2.0),
+ # "", @scale)
+ # i += 1
+ # end
+
+ @d = @d.stroke_antialias true
+ end
+
+ # Draws a legend with the names of the datasets
+ # matched to the colors used to draw them.
+ def draw_legend
+ return if @hide_legend
+
+ @legend_labels = @data.collect {|item| item[DATA_LABEL_INDEX] }
+
+ legend_square_width = @legend_box_size # small square with color of this item
+
+ # May fix legend drawing problem at small sizes
+ @d.font = @font if @font
+ @d.pointsize = @legend_font_size
+
+ metrics = @d.get_type_metrics(@base_image, @legend_labels.join(''))
+ legend_text_width = metrics.width
+ legend_width = legend_text_width +
+ (@legend_labels.length * legend_square_width * 2.7)
+ legend_left = (@raw_columns - legend_width) / 2
+ legend_increment = legend_width / @legend_labels.length.to_f
+
+ current_x_offset = legend_left
+ current_y_offset = @hide_title ?
+ TOP_MARGIN + LEGEND_MARGIN :
+ TOP_MARGIN +
+ TITLE_MARGIN + @title_caps_height +
+ LEGEND_MARGIN
+
+ debug { @d.line 0.0, current_y_offset, @raw_columns, current_y_offset }
+
+ @legend_labels.each_with_index do |legend_label, index|
+
+ # Draw label
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.pointsize = scale_fontsize(@legend_font_size)
+ @d.stroke('transparent')
+ @d.font_weight = NormalWeight
+ @d.gravity = WestGravity
+ @d = @d.annotate_scaled( @base_image,
+ @raw_columns, 1.0,
+ current_x_offset + (legend_square_width * 1.7), current_y_offset,
+ legend_label.to_s, @scale)
+
+ # Now draw box with color of this dataset
+ @d = @d.stroke('transparent')
+ @d = @d.fill @data[index][DATA_COLOR_INDEX]
+ @d = @d.rectangle(current_x_offset,
+ current_y_offset - legend_square_width / 2.0,
+ current_x_offset + legend_square_width,
+ current_y_offset + legend_square_width / 2.0)
+
+ @d.pointsize = @legend_font_size
+ metrics = @d.get_type_metrics(@base_image, legend_label.to_s)
+ current_string_offset = metrics.width + (legend_square_width * 2.7)
+ current_x_offset += current_string_offset
+ end
+ @color_index = 0
+ end
+
+ def draw_title
+ return if (@hide_title || @title.nil?)
+
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.stroke('transparent')
+ @d.pointsize = scale_fontsize(@title_font_size)
+ @d.font_weight = BoldWeight
+ @d.gravity = NorthGravity
+ @d = @d.annotate_scaled( @base_image,
+ @raw_columns, 1.0,
+ 0, TOP_MARGIN,
+ @title, @scale)
+ end
+
+ ##
+ # Draws column labels below graph, centered over x_offset
+ #
+ # TODO Allow WestGravity as an option
+
+ def draw_label(x_offset, index)
+ return if @hide_line_markers
+
+ if !@labels[index].nil? && @labels_seen[index].nil?
+ y_offset = @graph_bottom + LABEL_MARGIN
+
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.stroke('transparent')
+ @d.font_weight = NormalWeight
+ @d.pointsize = scale_fontsize(@marker_font_size)
+ @d.gravity = NorthGravity
+ @d = @d.annotate_scaled(@base_image,
+ 1.0, 1.0,
+ x_offset, y_offset,
+ @labels[index], @scale)
+ @labels_seen[index] = 1
+ debug { @d.line 0.0, y_offset, @raw_columns, y_offset }
+ end
+ end
+
+ def draw_no_data
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.stroke('transparent')
+ @d.font_weight = NormalWeight
+ @d.pointsize = scale_fontsize(80)
+ @d.gravity = CenterGravity
+ @d = @d.annotate_scaled( @base_image,
+ @raw_columns, @raw_rows/2.0,
+ 0, 10,
+ @no_data_message, @scale)
+ end
+
+ ##
+ # Finds the best background to render based on the provided theme options.
+ #
+ # Creates a @base_image to draw on.
+ #
+ def render_background
+ case @theme_options[:background_colors]
+ when Array
+ @base_image = render_gradiated_background(*@theme_options[:background_colors])
+ when String
+ @base_image = render_solid_background(@theme_options[:background_colors])
+ else
+ @base_image = render_image_background(*@theme_options[:background_image])
+ end
+ end
+
+ ##
+ # Make a new image at the current size with a solid +color+.
+
+ def render_solid_background(color)
+ Image.new(@columns, @rows) {
+ self.background_color = color
+ }
+ end
+
+ # Use with a theme definition method to draw a gradiated background.
+ def render_gradiated_background(top_color, bottom_color)
+ Image.new(@columns, @rows,
+ GradientFill.new(0, 0, 100, 0, top_color, bottom_color))
+ end
+
+ # Use with a theme to use an image (800x600 original) background.
+ def render_image_background(image_path)
+ image = Image.read(image_path)
+ if @scale != 1.0
+ image[0].resize!(@scale) # TODO Resize with new scale (crop if necessary for wide graph)
+ end
+ image[0]
+ end
+
+ # Use with a theme to make a transparent background
+ def render_transparent_background
+ Image.new(@columns, @rows) do
+ self.background_color = 'transparent'
+ end
+ end
+
+ def reset_themes
+ @color_index = 0
+ @labels_seen = {}
+ @theme_options = {}
+
+ @d = Draw.new
+ # Scale down from 800x600 used to calculate drawing.
+ @d = @d.scale(@scale, @scale)
+ end
+
+ def scale(value)
+ value * @scale
+ end
+
+ # Return a comparable fontsize for the current graph.
+ def scale_fontsize(value)
+ new_fontsize = value * @scale
+ # return new_fontsize < 10.0 ? 10.0 : new_fontsize
+ return new_fontsize
+ end
+
+ def clip_value_if_greater_than(value, max_value)
+ (value > max_value) ? max_value : value
+ end
+
+ # Overridden by subclasses such as stacked bar.
+ def larger_than_max?(data_point, index=0)
+ data_point > @maximum_value
+ end
+
+ def less_than_min?(data_point, index=0)
+ data_point < @minimum_value
+ end
+
+ ##
+ # Overridden by subclasses that need it.
+ def max(data_point, index)
+ data_point
+ end
+
+ ##
+ # Overridden by subclasses that need it.
+ def min(data_point, index)
+ data_point
+ end
+
+ def significant(inc)
+ return 1.0 if inc == 0 # Keep from going into infinite loop
+ factor = 1.0
+ while (inc < 10)
+ inc *= 10
+ factor /= 10
+ end
+
+ while (inc > 100)
+ inc /= 10
+ factor *= 10
+ end
+
+ res = inc.floor * factor
+ if (res.to_i.to_f == res)
+ res.to_i
+ else
+ res
+ end
+ end
+
+ # Sort with largest overall summed value at front of array
+ # so it shows up correctly in the drawn graph.
+ def sort_norm_data
+ @norm_data.sort! { |a,b| sums(b[1]) <=> sums(a[1]) }
+ end
+
+ def sums(data_set)
+ total_sum = 0
+ data_set.collect {|num| total_sum += num.to_f }
+ total_sum
+ end
+
+ ##
+ # Used by StackedBar and child classes.
+ #
+ # May need to be moved to the StackedBar class.
+
+ def get_maximum_by_stack
+ # Get sum of each stack
+ max_hash = {}
+ @data.each do |data_set|
+ data_set[DATA_VALUES_INDEX].each_with_index do |data_point, i|
+ max_hash[i] = 0.0 unless max_hash[i]
+ max_hash[i] += data_point.to_f
+ end
+ end
+
+ # @maximum_value = 0
+ max_hash.keys.each do |key|
+ @maximum_value = max_hash[key] if max_hash[key] > @maximum_value
+ end
+ @minimum_value = 0
+ end
+
+ def make_stacked
+ stacked_values = Array.new(@column_count, 0)
+ @data.each do |value_set|
+ value_set[1].each_with_index do |value, index|
+ stacked_values[index] += value
+ end
+ value_set[1] = stacked_values.dup
+ end
+ end
+
+private
+
+ # Takes a block and draws it if DEBUG is true.
+ #
+ # debug { @d.rectangle x1, y1, x2, y2 }
+ #
+ def debug
+ if DEBUG
+ @d = @d.fill 'transparent'
+ @d = @d.stroke 'turquoise'
+ @d = yield
+ end
+ end
+
+ def increment_color
+ if @color_index == 0
+ @color_index += 1
+ return @colors[0]
+ else
+ if @color_index < @colors.length
+ @color_index += 1
+ return @colors[@color_index - 1]
+ else
+ # Start over
+ @color_index = 0
+ return @colors[-1]
+ end
+ end
+ end
+
+ ##
+ # Return a formatted string representing a number value that should be printed as a label.
+
+ def label(value)
+ if (@spread.to_f % @marker_count.to_f == 0) || !@y_axis_increment.nil?
+ return value.to_i.to_s
+ end
+
+ if @spread > 10.0
+ sprintf("%0i", value)
+ elsif @spread >= 3.0
+ sprintf("%0.2f", value)
+ else
+ value.to_s
+ end
+ end
+
+ ##
+ # Returns the height of the capital letter 'X' for the current font and size.
+ #
+ # Not scaled since it deals with dimensions that the regular
+ # scaling will handle.
+ #
+ def calculate_caps_height(font_size)
+ @d.pointsize = font_size
+ @d.get_type_metrics(@base_image, 'X').height
+ end
+
+ ##
+ # Returns the width of a string at this pointsize.
+ #
+ # Not scaled since it deals with dimensions that the regular
+ # scaling will handle.
+ #
+ def calculate_width(font_size, text)
+ @d.pointsize = font_size
+ @d.get_type_metrics(@base_image, text.to_s).width
+ end
+
+ end # Gruff::Base
+
+ class IncorrectNumberOfDatasetsException < StandardError; end
+
+end # Gruff
+
+
+module Magick
+
+ class Draw
+
+ # Additional method since Draw.scale doesn't affect annotations.
+ def annotate_scaled(img, width, height, x, y, text, scale)
+ scaled_width = (width * scale) >= 1 ? (width * scale) : 1
+ scaled_height = (height * scale) >= 1 ? (height * scale) : 1
+
+ self.annotate( img,
+ scaled_width, scaled_height,
+ x * scale, y * scale,
+ text)
+ end
+
+ end
+
+end # Magick
+
--- /dev/null
+
+##
+# A mixin for methods that need to be deleted or have been
+# replaced by cleaner code.
+
+module Gruff
+ module Deprecated
+
+ def scale_measurements
+ setup_graph_measurements
+ end
+
+ def total_height
+ @rows + 10
+ end
+
+ def graph_top
+ @graph_top * @scale
+ end
+
+ def graph_height
+ @graph_height * @scale
+ end
+
+ def graph_left
+ @graph_left * @scale
+ end
+
+ def graph_width
+ @graph_width * @scale
+ end
+
+ # TODO Should be calculate_graph_height
+ # def setup_graph_height
+ # @graph_height = @graph_bottom - @graph_top
+ # end
+
+ end
+end
--- /dev/null
+
+require File.dirname(__FILE__) + '/base'
+
+class Gruff::Line < Gruff::Base
+
+ # Draw a dashed line at the given value
+ attr_accessor :baseline_value
+
+ # Color of the baseline
+ attr_accessor :baseline_color
+
+ # Hide parts of the graph to fit more datapoints, or for a different appearance.
+ attr_accessor :hide_dots, :hide_lines
+
+ # Call with target pixel width of graph (800, 400, 300), and/or 'false' to omit lines (points only).
+ #
+ # g = Gruff::Line.new(400) # 400px wide with lines
+ #
+ # g = Gruff::Line.new(400, false) # 400px wide, no lines (for backwards compatibility)
+ #
+ # g = Gruff::Line.new(false) # Defaults to 800px wide, no lines (for backwards compatibility)
+ #
+ # The preferred way is to call hide_dots or hide_lines instead.
+ def initialize(*args)
+ raise ArgumentError, "Wrong number of arguments" if args.length > 2
+ if args.empty? or ((not Numeric === args.first) && (not String === args.first)) then
+ super()
+ else
+ super args.shift
+ end
+
+ @hide_dots = @hide_lines = false
+ @baseline_color = 'red'
+ @baseline_value = nil
+ end
+
+ def draw
+ super
+
+ return unless @has_data
+
+ # Check to see if more than one datapoint was given. NaN can result otherwise.
+ @x_increment = (@column_count > 1) ? (@graph_width / (@column_count - 1).to_f) : @graph_width
+
+ if (defined?(@norm_baseline)) then
+ level = @graph_top + (@graph_height - @norm_baseline * @graph_height)
+ @d = @d.push
+ @d.stroke_color @baseline_color
+ @d.fill_opacity 0.0
+ @d.stroke_dasharray(10, 20)
+ @d.stroke_width 5
+ @d.line(@graph_left, level, @graph_left + @graph_width, level)
+ @d = @d.pop
+ end
+
+ @norm_data.each do |data_row|
+ prev_x = prev_y = nil
+
+ data_row[1].each_with_index do |data_point, index|
+ new_x = @graph_left + (@x_increment * index)
+ next if data_point.nil?
+
+ draw_label(new_x, index)
+
+ new_y = @graph_top + (@graph_height - data_point * @graph_height)
+
+ # Reset each time to avoid thin-line errors
+ @d = @d.stroke data_row[DATA_COLOR_INDEX]
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
+ @d = @d.stroke_opacity 1.0
+ @d = @d.stroke_width clip_value_if_greater_than(@columns / (@norm_data.first[1].size * 4), 5.0)
+
+ if !@hide_lines and !prev_x.nil? and !prev_y.nil? then
+ @d = @d.line(prev_x, prev_y, new_x, new_y)
+ end
+ circle_radius = clip_value_if_greater_than(@columns / (@norm_data.first[1].size * 2.5), 5.0)
+ @d = @d.circle(new_x, new_y, new_x - circle_radius, new_y) unless @hide_dots
+
+ prev_x = new_x
+ prev_y = new_y
+ end
+
+ end
+
+ @d.draw(@base_image)
+ end
+
+ def normalize
+ @maximum_value = [@maximum_value.to_f, @baseline_value.to_f].max
+ super
+ @norm_baseline = (@baseline_value.to_f / @maximum_value.to_f) if @baseline_value
+ end
+
+end
--- /dev/null
+##
+#
+# Makes a small bar graph suitable for display at 200px or even smaller.
+#
+module Gruff
+ module Mini
+
+ class Bar < Gruff::Bar
+
+ include Gruff::Mini::Legend
+
+ def draw
+ @hide_legend = true
+ @hide_title = true
+ @hide_line_numbers = true
+
+ @marker_font_size = 50.0
+ @minimum_value = 0.0
+ @legend_font_size = 60.0
+
+ expand_canvas_for_vertical_legend
+
+ super
+
+ draw_vertical_legend
+ @d.draw(@base_image)
+ end
+
+ end
+
+ end
+end
--- /dev/null
+module Gruff
+ module Mini
+ module Legend
+
+ ##
+ # The canvas needs to be bigger so we can put the legend beneath it.
+
+ def expand_canvas_for_vertical_legend
+ @original_rows = @raw_rows
+ @rows += @data.length * calculate_caps_height(scale_fontsize(@legend_font_size)) * 1.7
+ render_background
+ end
+
+ ##
+ # Draw the legend beneath the existing graph.
+
+ def draw_vertical_legend
+
+ @legend_labels = @data.collect {|item| item[Gruff::Base::DATA_LABEL_INDEX] }
+
+ legend_square_width = 40.0 # small square with color of this item
+ legend_square_margin = 10.0
+ @legend_left_margin = 40.0
+ legend_top_margin = 40.0
+
+ # May fix legend drawing problem at small sizes
+ @d.font = @font if @font
+ @d.pointsize = @legend_font_size
+
+ current_x_offset = @graph_left + @legend_left_margin
+ current_y_offset = @original_rows + legend_top_margin
+
+ debug { @d.line 0.0, current_y_offset, @raw_columns, current_y_offset }
+
+ @legend_labels.each_with_index do |legend_label, index|
+
+ # Draw label
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.pointsize = scale_fontsize(@legend_font_size)
+ @d.stroke = 'transparent'
+ @d.font_weight = Magick::NormalWeight
+ @d.gravity = Magick::WestGravity
+ @d = @d.annotate_scaled( @base_image,
+ @raw_columns, 1.0,
+ current_x_offset + (legend_square_width * 1.7), current_y_offset,
+ truncate_legend_label(legend_label), @scale)
+
+ # Now draw box with color of this dataset
+ @d = @d.stroke 'transparent'
+ @d = @d.fill @data[index][Gruff::Base::DATA_COLOR_INDEX]
+ @d = @d.rectangle(current_x_offset,
+ current_y_offset - legend_square_width / 2.0,
+ current_x_offset + legend_square_width,
+ current_y_offset + legend_square_width / 2.0)
+
+ current_y_offset += calculate_caps_height(@legend_font_size) * 1.7
+ end
+ @color_index = 0
+ end
+
+ ##
+ # Shorten long labels so they will fit on the canvas.
+ #
+ # Department of Hu...
+
+ def truncate_legend_label(label)
+ truncated_label = label.to_s
+ while calculate_width(scale_fontsize(@legend_font_size), truncated_label) > (@columns - @legend_left_margin - Gruff::Base::RIGHT_MARGIN) && (truncated_label.length > 1)
+ truncated_label = truncated_label[0..truncated_label.length-2]
+ end
+ truncated_label + (truncated_label.length < label.to_s.length ? "…" : '')
+ end
+
+ end
+ end
+end
--- /dev/null
+##
+#
+# Makes a small pie graph suitable for display at 200px or even smaller.
+#
+module Gruff
+ module Mini
+
+ class Pie < Gruff::Pie
+
+ include Gruff::Mini::Legend
+
+ def initialize_ivars
+ super
+
+ @hide_legend = true
+ @hide_title = true
+ @hide_line_numbers = true
+
+ @marker_font_size = 60.0
+ @legend_font_size = 60.0
+ end
+
+ def draw
+ expand_canvas_for_vertical_legend
+
+ super
+
+ draw_vertical_legend
+
+ @d.draw(@base_image)
+ end # def draw
+
+ end # class Pie
+
+ end
+end
--- /dev/null
+##
+#
+# Makes a small pie graph suitable for display at 200px or even smaller.
+#
+module Gruff
+ module Mini
+
+ class SideBar < Gruff::SideBar
+
+ def initialize_ivars
+ super
+ @hide_legend = true
+ @hide_title = true
+ @hide_line_numbers = true
+
+ @marker_font_size = 50.0
+ end
+
+ end
+
+ end
+end
--- /dev/null
+
+require File.dirname(__FILE__) + '/base'
+
+# Experimental!!! See also the Spider graph.
+class Gruff::Net < Gruff::Base
+
+ # Hide parts of the graph to fit more datapoints, or for a different appearance.
+ attr_accessor :hide_dots
+
+ def initialize(*args)
+ super
+
+ @hide_dots = false
+ end
+
+ def draw
+
+ super
+
+ return unless @has_data
+
+ @radius = @graph_height / 2.0
+ @center_x = @graph_left + (@graph_width / 2.0)
+ @center_y = @graph_top + (@graph_height / 2.0) - 10 # Move graph up a bit
+
+ @x_increment = @graph_width / (@column_count - 1).to_f
+ circle_radius = clip_value_if_greater_than(@columns / (@norm_data.first[1].size * 2.5), 5.0)
+
+ @d = @d.stroke_opacity 1.0
+ @d = @d.stroke_width clip_value_if_greater_than(@columns / (@norm_data.first[1].size * 4), 5.0)
+
+ if (defined?(@norm_baseline)) then
+ level = @graph_top + (@graph_height - @norm_baseline * @graph_height)
+ @d = @d.push
+ @d.stroke_color @baseline_color
+ @d.fill_opacity 0.0
+ @d.stroke_dasharray(10, 20)
+ @d.stroke_width 5
+ @d.line(@graph_left, level, @graph_left + @graph_width, level)
+ @d = @d.pop
+ end
+
+ @norm_data.each do |data_row|
+ prev_x = prev_y = nil
+ @d = @d.stroke data_row[DATA_COLOR_INDEX]
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
+
+ data_row[1].each_with_index do |data_point, index|
+ next if data_point.nil?
+
+ rad_pos = index * Math::PI * 2 / @column_count
+ point_distance = data_point * @radius
+ start_x = @center_x + Math::sin(rad_pos) * point_distance
+ start_y = @center_y - Math::cos(rad_pos) * point_distance
+
+ next_index = index + 1 < data_row[1].length ? index + 1 : 0
+
+ next_rad_pos = next_index * Math::PI * 2 / @column_count
+ next_point_distance = data_row[1][next_index] * @radius
+ end_x = @center_x + Math::sin(next_rad_pos) * next_point_distance
+ end_y = @center_y - Math::cos(next_rad_pos) * next_point_distance
+
+ @d = @d.line(start_x, start_y, end_x, end_y)
+
+ @d = @d.circle(start_x, start_y, start_x - circle_radius, start_y) unless @hide_dots
+ end
+
+ end
+
+ @d.draw(@base_image)
+ end
+
+
+ # the lines connecting in the center, with the first line vertical
+ def draw_line_markers
+ return if @hide_line_markers
+
+
+ # have to do this here (AGAIN)... see draw() in this class
+ # because this funtion is called before the @radius, @center_x and @center_y are set
+ @radius = @graph_height / 2.0
+ @center_x = @graph_left + (@graph_width / 2.0)
+ @center_y = @graph_top + (@graph_height / 2.0) - 10 # Move graph up a bit
+
+
+ # Draw horizontal line markers and annotate with numbers
+ @d = @d.stroke(@marker_color)
+ @d = @d.stroke_width 1
+
+
+ (0..@column_count-1).each do |index|
+ rad_pos = index * Math::PI * 2 / @column_count
+
+ @d = @d.line(@center_x, @center_y, @center_x + Math::sin(rad_pos) * @radius, @center_y - Math::cos(rad_pos) * @radius)
+
+
+ marker_label = labels[index] ? labels[index].to_s : '000'
+
+ draw_label(@center_x, @center_y, rad_pos * 360 / (2 * Math::PI), @radius, marker_label)
+ end
+ end
+
+private
+
+ def draw_label(center_x, center_y, angle, radius, amount)
+ r_offset = 1.1
+ x_offset = center_x # + 15 # The label points need to be tweaked slightly
+ y_offset = center_y # + 0 # This one doesn't though
+ x = x_offset + (radius * r_offset * Math.sin(angle.deg2rad))
+ y = y_offset - (radius * r_offset * Math.cos(angle.deg2rad))
+
+ # Draw label
+ @d.fill = @marker_color
+ @d.font = @font if @font
+ @d.pointsize = scale_fontsize(20)
+ @d.stroke = 'transparent'
+ @d.font_weight = BoldWeight
+ s = angle.deg2rad / (2*Math::PI)
+ @d.gravity = SouthGravity if s >= 0.96 or s < 0.04
+ @d.gravity = SouthWestGravity if s >= 0.04 or s < 0.21
+ @d.gravity = WestGravity if s >= 0.21 or s < 0.29
+ @d.gravity = NorthWestGravity if s >= 0.29 or s < 0.46
+ @d.gravity = NorthGravity if s >= 0.46 or s < 0.54
+ @d.gravity = NorthEastGravity if s >= 0.54 or s < 0.71
+ @d.gravity = EastGravity if s >= 0.71 or s < 0.79
+ @d.gravity = SouthEastGravity if s >= 0.79 or s < 0.96
+# @d.gravity = NorthGravity
+ @d.annotate_scaled(@base_image, 0, 0, x, y, amount, @scale)
+ end
+
+end
+
+# # This method is already in Float
+# class Float
+# # Used for degree => radian conversions
+# def deg2rad
+# self * (Math::PI/180.0)
+# end
+# end
+
+
+
--- /dev/null
+require File.dirname(__FILE__) + '/base'
+
+# EXPERIMENTAL!
+#
+# Doesn't work yet.
+#
+class Gruff::PhotoBar < Gruff::Base
+
+# TODO
+#
+# define base and cap in yml
+# allow for image directory to be located elsewhere
+# more exact measurements for bar heights (go all the way to the bottom of the graph)
+# option to tile images instead of use a single image
+# drop base label a few px lower so photo bar graphs can have a base dropping over the lower marker line
+#
+
+ # The name of a pre-packaged photo-based theme.
+ attr_reader :theme
+
+# def initialize(target_width=800)
+# super
+# init_photo_bar_graphics()
+# end
+
+ def draw
+ super
+ return unless @has_data
+
+ return # TODO Remove for further development
+
+ init_photo_bar_graphics()
+
+ #Draw#define_clip_path()
+ #Draw#clip_path(pathname)
+ #Draw#composite....with bar graph image OverCompositeOp
+ #
+ # See also
+ #
+ # Draw.pattern # define an image to tile as the filling of a draw object
+ #
+
+ # Setup spacing.
+ #
+ # Columns sit side-by-side.
+ spacing_factor = 0.9
+ @bar_width = @norm_data[0][DATA_COLOR_INDEX].columns
+
+ @norm_data.each_with_index do |data_row, row_index|
+
+ data_row[1].each_with_index do |data_point, point_index|
+ data_point = 0 if data_point.nil?
+ # Use incremented x and scaled y
+ left_x = @graph_left + (@bar_width * (row_index + point_index + ((@data.length - 1) * point_index)))
+ left_y = @graph_top + (@graph_height - data_point * @graph_height) + 1
+ right_x = left_x + @bar_width * spacing_factor
+ right_y = @graph_top + @graph_height - 1
+
+ bar_image_width = data_row[DATA_COLOR_INDEX].columns
+ bar_image_height = right_y.to_f - left_y.to_f
+
+ # Crop to scale for data
+ bar_image = data_row[DATA_COLOR_INDEX].crop(0, 0, bar_image_width, bar_image_height)
+
+ @d.gravity = NorthWestGravity
+ @d = @d.composite(left_x, left_y, bar_image_width, bar_image_height, bar_image)
+
+ # Calculate center based on bar_width and current row
+ label_center = @graph_left + (@data.length * @bar_width * point_index) + (@data.length * @bar_width / 2.0)
+ draw_label(label_center, point_index)
+ end
+
+ end
+
+ @d.draw(@base_image)
+ end
+
+
+ # Return the chosen theme or the default
+ def theme
+ @theme || 'plastik'
+ end
+
+protected
+
+ # Sets up colors with a list of images that will be used.
+ # Images should be 340px tall
+ def init_photo_bar_graphics
+ color_list = Array.new
+ theme_dir = File.dirname(__FILE__) + '/../../assets/' + theme
+
+ Dir.open(theme_dir).each do |file|
+ next unless /\.png$/.match(file)
+ color_list << Image.read("#{theme_dir}/#{file}").first
+ end
+ @colors = color_list
+ end
+
+end
+
--- /dev/null
+
+require File.dirname(__FILE__) + '/base'
+
+class Gruff::Pie < Gruff::Base
+
+ TEXT_OFFSET_PERCENTAGE = 0.15
+
+ # Can be used to make the pie start cutting slices at the top (-90.0)
+ # or at another angle. Default is 0.0, which starts at 3 o'clock.
+ attr_accessor :zero_degree
+
+ def initialize_ivars
+ super
+ @zero_degree = 0.0
+ end
+
+ def draw
+ @hide_line_markers = true
+
+ super
+
+ return unless @has_data
+
+ diameter = @graph_height
+ radius = ([@graph_width, @graph_height].min / 2.0) * 0.8
+ top_x = @graph_left + (@graph_width - diameter) / 2.0
+ center_x = @graph_left + (@graph_width / 2.0)
+ center_y = @graph_top + (@graph_height / 2.0) - 10 # Move graph up a bit
+ total_sum = sums_for_pie()
+ prev_degrees = @zero_degree
+
+ # Use full data since we can easily calculate percentages
+ @data.sort{ |a, b| a[DATA_VALUES_INDEX][0] <=> b[DATA_VALUES_INDEX][0] }.each do |data_row|
+ if data_row[DATA_VALUES_INDEX][0] > 0
+ @d = @d.stroke data_row[DATA_COLOR_INDEX]
+ @d = @d.fill 'transparent'
+ @d.stroke_width(radius) # stroke width should be equal to radius. we'll draw centered on (radius / 2)
+
+ current_degrees = (data_row[DATA_VALUES_INDEX][0] / total_sum) * 360.0
+
+ # ellipse will draw the the stroke centered on the first two parameters offset by the second two.
+ # therefore, in order to draw a circle of the proper diameter we must center the stroke at
+ # half the radius for both x and y
+ @d = @d.ellipse(center_x, center_y,
+ radius / 2.0, radius / 2.0,
+ prev_degrees, prev_degrees + current_degrees + 0.5) # <= +0.5 'fudge factor' gets rid of the ugly gaps
+
+ half_angle = prev_degrees + ((prev_degrees + current_degrees) - prev_degrees) / 2
+
+ # End the string with %% to escape the single %.
+ # RMagick must use sprintf with the string and % has special significance.
+ label_string = ((data_row[DATA_VALUES_INDEX][0] / total_sum) * 100.0).round.to_s + '%%'
+ @d = draw_label(center_x,center_y,
+ half_angle, radius + (radius * TEXT_OFFSET_PERCENTAGE),
+ label_string)
+
+ prev_degrees += current_degrees
+ end
+ end
+
+ # TODO debug a circle where the text is drawn...
+
+ @d.draw(@base_image)
+ end
+
+private
+
+ ##
+ # Labels are drawn around a slightly wider ellipse to give room for
+ # labels on the left and right.
+ def draw_label(center_x, center_y, angle, radius, amount)
+ # TODO Don't use so many hard-coded numbers
+ r_offset = 20.0 # The distance out from the center of the pie to get point
+ x_offset = center_x # + 15.0 # The label points need to be tweaked slightly
+ y_offset = center_y # This one doesn't though
+ radius_offset = (radius + r_offset)
+ ellipse_factor = radius_offset * 0.15
+ x = x_offset + ((radius_offset + ellipse_factor) * Math.cos(angle.deg2rad))
+ y = y_offset + (radius_offset * Math.sin(angle.deg2rad))
+
+ # Draw label
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.pointsize = scale_fontsize(@marker_font_size)
+ @d.stroke = 'transparent'
+ @d.font_weight = BoldWeight
+ @d.gravity = CenterGravity
+ @d.annotate_scaled( @base_image,
+ 0, 0,
+ x, y,
+ amount, @scale)
+ end
+
+ def sums_for_pie
+ total_sum = 0.0
+ @data.collect {|data_row| total_sum += data_row[DATA_VALUES_INDEX][0] }
+ total_sum
+ end
+
+end
+
+
+class Float
+ # Used for degree => radian conversions
+ def deg2rad
+ self * (Math::PI/180.0)
+ end
+end
--- /dev/null
+
+require "observer"
+require File.dirname(__FILE__) + '/base'
+
+##
+# A scene is a non-linear graph that assembles layers together to tell a story.
+# Layers are folders with appropriately named files (see below). You can group
+# layers and control them together or just set their values individually.
+#
+# Examples:
+#
+# * A city scene that changes with the time of day and the weather conditions.
+# * A traffic map that shows red lines on streets that are crowded and green on free-flowing ones.
+#
+# Usage:
+#
+# g = Gruff::Scene.new("500x100", "artwork/city_scene")
+# g.layers = %w(background haze sky clouds)
+# g.weather_group = %w(clouds)
+# g.time_group = %w(background sky)
+# g.weather = "cloudy"
+# g.time = Time.now
+# g.haze = true
+# g.write "hazy_daytime_city_scene.png"
+#
+#
+#
+# If there is a file named 'default.png', it will be selected (unless other values are provided to override it).
+#
+class Gruff::Scene < Gruff::Base
+
+ # An array listing the foldernames that will be rendered, from back to front.
+ #
+ # g.layers = %w(sky clouds buildings street people)
+ #
+ attr_reader :layers
+
+ def initialize(target_width, base_dir)
+ @base_dir = base_dir
+ @groups = {}
+ @layers = []
+ super target_width
+ end
+
+ def draw
+ # Join all the custom paths and filter out the empty ones
+ image_paths = @layers.map { |layer| layer.path }.select { |path| !path.empty? }
+ images = Magick::ImageList.new(*image_paths)
+ @base_image = images.flatten_images
+ end
+
+ def layers=(ordered_list)
+ ordered_list.each do |layer_name|
+ @layers << Gruff::Layer.new(@base_dir, layer_name)
+ end
+ end
+
+ # Group layers to input values
+ #
+ # g.weather_group = ["sky", "sea", "clouds"]
+ #
+ # Set input values
+ #
+ # g.weather = "cloudy"
+ #
+ def method_missing(method_name, *args)
+ case method_name.to_s
+ when /^(\w+)_group=$/
+ add_group $1, *args
+ return
+ when /^(\w+)=$/
+ set_input $1, args.first
+ return
+ end
+ super
+ end
+
+private
+
+ def add_group(input_name, layer_names)
+ @groups[input_name] = Gruff::Group.new(input_name, @layers.select { |layer| layer_names.include?(layer.name) })
+ end
+
+ def set_input(input_name, input_value)
+ if not @groups[input_name].nil?
+ @groups[input_name].send_updates(input_value)
+ else
+ if chosen_layer = @layers.detect { |layer| layer.name == input_name }
+ chosen_layer.update input_value
+ end
+ end
+ end
+
+end
+
+
+class Gruff::Group
+
+ include Observable
+ attr_reader :name
+
+ def initialize(folder_name, layers)
+ @name = folder_name
+ layers.each do |layer|
+ layer.observe self
+ end
+ end
+
+ def send_updates(value)
+ changed
+ notify_observers value
+ end
+
+end
+
+
+class Gruff::Layer
+
+ attr_reader :name
+
+ def initialize(base_dir, folder_name)
+ @base_dir = base_dir.to_s
+ @name = folder_name.to_s
+ @filenames = Dir.open(File.join(base_dir, folder_name)).entries.select { |file| file =~ /^[^.]+\.png$/ }
+ @selected_filename = select_default
+ end
+
+ # Register this layer so it receives updates from the group
+ def observe(obj)
+ obj.add_observer self
+ end
+
+ # Choose the appropriate filename for this layer, based on the input
+ def update(value)
+ @selected_filename = case value.to_s
+ when /^(true|false)$/
+ select_boolean value
+ when /^(\w|\s)+$/
+ select_string value
+ when /^-?(\d+\.)?\d+$/
+ select_numeric value
+ when /(\d\d):(\d\d):\d\d/
+ select_time "#{$1}#{$2}"
+ else
+ select_default
+ end
+ # Finally, try to use 'default' if we're still blank
+ @selected_filename ||= select_default
+ end
+
+ # Returns the full path to the selected image, or a blank string
+ def path
+ unless @selected_filename.nil? || @selected_filename.empty?
+ return File.join(@base_dir, @name, @selected_filename)
+ end
+ ''
+ end
+
+private
+
+ # Match "true.png" or "false.png"
+ def select_boolean(value)
+ file_exists_or_blank value.to_s
+ end
+
+ # Match -5 to _5.png
+ def select_numeric(value)
+ file_exists_or_blank value.to_s.gsub('-', '_')
+ end
+
+ def select_time(value)
+ times = @filenames.map { |filename| filename.gsub('.png', '') }
+ times.each_with_index do |time, index|
+ if (time > value) && (index > 0)
+ return "#{times[index - 1]}.png"
+ end
+ end
+ return "#{times.last}.png"
+ end
+
+ # Match "partly cloudy" to "partly_cloudy.png"
+ def select_string(value)
+ file_exists_or_blank value.to_s.gsub(' ', '_')
+ end
+
+ def select_default
+ @filenames.include?("default.png") ? "default.png" : ''
+ end
+
+ # Returns the string "#{filename}.png", if it exists.
+ #
+ # Failing that, it returns default.png, or '' if that doesn't exist.
+ def file_exists_or_blank(filename)
+ @filenames.include?("#{filename}.png") ? "#{filename}.png" : select_default
+ end
+
+end
--- /dev/null
+require File.dirname(__FILE__) + '/base'
+
+##
+# Graph with individual horizontal bars instead of vertical bars.
+
+class Gruff::SideBar < Gruff::Base
+
+ def draw
+ @has_left_labels = true
+ super
+
+ return unless @has_data
+
+ # Setup spacing.
+ #
+ # Columns sit stacked.
+ spacing_factor = 0.9
+
+ @bar_width = @graph_height / @column_count.to_f
+ @d = @d.stroke_opacity 0.0
+ height = Array.new(@column_count, 0)
+ length = Array.new(@column_count, @graph_left)
+
+ @norm_data.each_with_index do |data_row, row_index|
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
+
+ data_row[1].each_with_index do |data_point, point_index|
+
+ # Using the original calcs from the stacked bar chart
+ # to get the difference between
+ # part of the bart chart we wish to stack.
+ temp1 = @graph_left + (@graph_width -
+ data_point * @graph_width -
+ height[point_index]) + 1
+ temp2 = @graph_left + @graph_width - height[point_index] - 1
+ difference = temp2 - temp1
+
+ left_x = length[point_index] #+ 1
+ left_y = @graph_top + (@bar_width * point_index)
+ right_x = left_x + difference
+ right_y = left_y + @bar_width * spacing_factor
+
+ length[point_index] += difference
+ height[point_index] += (data_point * @graph_width - 2)
+
+ @d = @d.rectangle(left_x, left_y, right_x, right_y)
+
+ # Calculate center based on bar_width and current row
+ label_center = @graph_top + (@bar_width * point_index) + (@bar_width * spacing_factor / 2.0)
+ draw_label(label_center, point_index)
+ end
+
+ end
+
+ @d.draw(@base_image)
+ end
+
+protected
+
+ # Instead of base class version, draws vertical background lines and label
+ def draw_line_markers
+
+ return if @hide_line_markers
+
+ @d = @d.stroke_antialias false
+
+ # Draw horizontal line markers and annotate with numbers
+ @d = @d.stroke(@marker_color)
+ @d = @d.stroke_width 1
+ number_of_lines = 5
+
+ # TODO Round maximum marker value to a round number like 100, 0.1, 0.5, etc.
+ increment = significant(@maximum_value.to_f / number_of_lines)
+ (0..number_of_lines).each do |index|
+
+ line_diff = (@graph_right - @graph_left) / number_of_lines
+ x = @graph_right - (line_diff * index) - 1
+ @d = @d.line(x, @graph_bottom, x, @graph_top)
+ diff = index - number_of_lines
+ marker_label = diff.abs * increment
+
+ unless @hide_line_numbers
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.stroke = 'transparent'
+ @d.pointsize = scale_fontsize(@marker_font_size)
+ @d.gravity = CenterGravity
+ # TODO Center text over line
+ @d = @d.annotate_scaled( @base_image,
+ 0, 0, # Width of box to draw text in
+ x, @graph_bottom + (LABEL_MARGIN * 2.0), # Coordinates of text
+ marker_label.to_s, @scale)
+ end # unless
+ @d = @d.stroke_antialias true
+ end
+ end
+
+ ##
+ # Draw on the Y axis instead of the X
+
+ def draw_label(y_offset, index)
+ if !@labels[index].nil? && @labels_seen[index].nil?
+ @d.fill = @font_color
+ @d.font = @font if @font
+ @d.stroke = 'transparent'
+ @d.font_weight = NormalWeight
+ @d.pointsize = scale_fontsize(@marker_font_size)
+ @d.gravity = EastGravity
+ @d = @d.annotate_scaled(@base_image,
+ 1, 1,
+ -@graph_left + LABEL_MARGIN * 2.0, y_offset,
+ @labels[index], @scale)
+ @labels_seen[index] = 1
+ end
+ end
+
+end
--- /dev/null
+require File.dirname(__FILE__) + '/base'
+require File.dirname(__FILE__) + '/side_bar'
+
+##
+# New gruff graph type added to enable sideways stacking bar charts
+# (basically looks like a x/y flip of a standard stacking bar chart)
+#
+# alun.eyre@googlemail.com
+
+class Gruff::SideStackedBar < Gruff::SideBar
+
+ def draw
+ @has_left_labels = true
+ get_maximum_by_stack
+ super
+
+ return unless @has_data
+
+ # Setup spacing.
+ #
+ # Columns sit stacked.
+ spacing_factor = 0.9
+
+ @bar_width = @graph_height / @column_count.to_f
+ @d = @d.stroke_opacity 0.0
+ height = Array.new(@column_count, 0)
+ length = Array.new(@column_count, @graph_left)
+
+ @norm_data.each_with_index do |data_row, row_index|
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
+
+ data_row[1].each_with_index do |data_point, point_index|
+
+ ## using the original calcs from the stacked bar chart to get the difference between
+ ## part of the bart chart we wish to stack.
+ temp1 = @graph_left + (@graph_width -
+ data_point * @graph_width -
+ height[point_index]) + 1
+ temp2 = @graph_left + @graph_width - height[point_index] - 1
+ difference = temp2 - temp1
+
+ left_x = length[point_index] #+ 1
+ left_y = @graph_top + (@bar_width * point_index)
+ right_x = left_x + difference
+ right_y = left_y + @bar_width * spacing_factor
+ length[point_index] += difference
+ height[point_index] += (data_point * @graph_width - 2)
+
+ @d = @d.rectangle(left_x, left_y, right_x, right_y)
+
+ # Calculate center based on bar_width and current row
+ label_center = @graph_top + (@bar_width * point_index) + (@bar_width * spacing_factor / 2.0)
+ draw_label(label_center, point_index)
+ end
+
+ end
+
+ @d.draw(@base_image)
+ end
+
+ protected
+
+ def larger_than_max?(data_point, index=0)
+ max(data_point, index) > @maximum_value
+ end
+
+ def max(data_point, index)
+ @data.inject(0) {|sum, item| sum + item[1][index]}
+ end
+
+end
--- /dev/null
+
+require File.dirname(__FILE__) + '/base'
+
+# Experimental!!! See also the Net graph.
+#
+# Submitted by Kevin Clark http://glu.ttono.us/
+class Gruff::Spider < Gruff::Base
+
+ # Hide all text
+ attr_reader :hide_text
+ attr_accessor :hide_axes
+ attr_reader :transparent_background
+
+ def transparent_background=(value)
+ @transparent_background = value
+ @base_image = render_transparent_background if value
+ end
+
+ def hide_text=(value)
+ @hide_title = @hide_text = value
+ end
+
+ def initialize(max_value, target_width = 800)
+ super(target_width)
+ @max_value = max_value
+ @hide_legend = true;
+ end
+
+ def draw
+ @hide_line_markers = true
+
+ super
+
+ return unless @has_data
+
+ # Setup basic positioning
+ diameter = @graph_height
+ radius = @graph_height / 2.0
+ top_x = @graph_left + (@graph_width - diameter) / 2.0
+ center_x = @graph_left + (@graph_width / 2.0)
+ center_y = @graph_top + (@graph_height / 2.0) - 25 # Move graph up a bit
+
+ @unit_length = radius / @max_value
+
+
+ total_sum = sums_for_spider
+ prev_degrees = 0.0
+ additive_angle = (2 * Math::PI)/ @data.size
+
+ current_angle = 0.0
+
+ # Draw axes
+ draw_axes(center_x, center_y, radius, additive_angle) unless hide_axes
+
+ # Draw polygon
+ draw_polygon(center_x, center_y, additive_angle)
+
+
+ @d.draw(@base_image)
+ end
+
+private
+
+ def normalize_points(value)
+ value * @unit_length
+ end
+
+ def draw_label(center_x, center_y, angle, radius, amount)
+ r_offset = 50 # The distance out from the center of the pie to get point
+ x_offset = center_x # The label points need to be tweaked slightly
+ y_offset = center_y + 0 # This one doesn't though
+ x = x_offset + ((radius + r_offset) * Math.cos(angle))
+ y = y_offset + ((radius + r_offset) * Math.sin(angle))
+
+ # Draw label
+ @d.fill = @marker_color
+ @d.font = @font if @font
+ @d.pointsize = scale_fontsize(legend_font_size)
+ @d.stroke = 'transparent'
+ @d.font_weight = BoldWeight
+ @d.gravity = CenterGravity
+ @d.annotate_scaled( @base_image,
+ 0, 0,
+ x, y,
+ amount, @scale)
+ end
+
+ def draw_axes(center_x, center_y, radius, additive_angle, line_color = nil)
+ return if hide_axes
+
+ current_angle = 0.0
+
+ @data.each do |data_row|
+ @d.stroke(line_color || data_row[DATA_COLOR_INDEX])
+ @d.stroke_width 5.0
+
+ x_offset = radius * Math.cos(current_angle)
+ y_offset = radius * Math.sin(current_angle)
+
+ @d.line(center_x, center_y,
+ center_x + x_offset,
+ center_y + y_offset)
+
+ draw_label(center_x, center_y, current_angle, radius, data_row[0].to_s) unless hide_text
+
+ current_angle += additive_angle
+ end
+ end
+
+ def draw_polygon(center_x, center_y, additive_angle, color = nil)
+ points = []
+ current_angle = 0.0
+ @data.each do |data_row|
+ points << center_x + normalize_points(data_row[1][0]) * Math.cos(current_angle)
+ points << center_y + normalize_points(data_row[1][0]) * Math.sin(current_angle)
+ current_angle += additive_angle
+ end
+
+ @d.stroke_width 1.0
+ @d.stroke(color || @marker_color)
+ @d.fill(color || @marker_color)
+ @d.fill_opacity 0.4
+ @d.polygon(*points)
+ end
+
+ def sums_for_spider
+ @data.inject(0.0) {|sum, data_row| sum += data_row[1][0]}
+ end
+
+end
--- /dev/null
+
+require File.dirname(__FILE__) + '/base'
+
+class Gruff::StackedBar < Gruff::Base
+
+ # Draws a bar graph, but multiple sets are stacked on top of each other.
+ def draw
+ get_maximum_by_stack
+ super
+ return unless @has_data
+
+ # Setup spacing.
+ #
+ # Columns sit stacked.
+ spacing_factor = 0.9
+ @bar_width = @graph_width / @column_count.to_f
+
+ @d = @d.stroke_opacity 0.0
+
+ height = Array.new(@column_count, 0)
+
+ @norm_data.each_with_index do |data_row, row_index|
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
+
+ data_row[1].each_with_index do |data_point, point_index|
+ # Use incremented x and scaled y
+ left_x = @graph_left + (@bar_width * point_index)
+ left_y = @graph_top + (@graph_height -
+ data_point * @graph_height -
+ height[point_index]) + 1
+ right_x = left_x + @bar_width * spacing_factor
+ right_y = @graph_top + @graph_height - height[point_index] - 1
+
+ # update the total height of the current stacked bar
+ height[point_index] += (data_point * @graph_height - 2)
+
+ @d = @d.rectangle(left_x, left_y, right_x, right_y)
+
+ # Calculate center based on bar_width and current row
+ label_center = @graph_left + (@bar_width * point_index) + (@bar_width * spacing_factor / 2.0)
+ draw_label(label_center, point_index)
+ end
+
+ end
+
+ @d.draw(@base_image)
+ end
+
+end