From: John Dong Date: Fri, 17 Aug 2007 23:35:41 +0000 (-0400) Subject: Merge head X-Git-Url: https://projects.mako.cc/source/selectricity/commitdiff_plain/25862916f9c97687bc1b30299fd9d544b0ba62ac?hp=5d314feb0e3ac7528dd6b7f0fb7112b737cc557e Merge head --- diff --git a/README b/README index 88fcd97..22f3eb7 100644 --- a/README +++ b/README @@ -6,7 +6,8 @@ To use Selectricity, you'll need to install the following gems in addition to rails and its dependencies: * rmagick - * gruff + * gruff (http://nubyonrails.com/pages/gruff) + * sparklines (http://nubyonrails.com/pages/sparklines) To use Selectricity in development mode, you'll need to install the following gems: diff --git a/app/controllers/graph_controller.rb b/app/controllers/graph_controller.rb index 8b617da..1bde09e 100644 --- a/app/controllers/graph_controller.rb +++ b/app/controllers/graph_controller.rb @@ -1,6 +1,5 @@ require 'date' -class GraphController < ApplicationController - +class GraphController < ApplicationController class GruffGraff def initialize(options) @@ -79,9 +78,6 @@ class GraphController < ApplicationController def borda_bar @election = Election.find(params[:id]) - #pref_tally = make_preference_tally(@election) - - #@borda_result = BordaVote.new(pref_tally).result @election.results unless @election.borda_result data, labels = get_borda_points(@election.borda_result) @@ -97,7 +93,8 @@ class GraphController < ApplicationController #Acording to Tufte, small, concomparitive, highly labeled data sets usually # belong in tables. The following is a bar graph...but would it be better #as a table? - def choices_positions + def choices_positions + @election = Election.find(params[:id]) pref_tally = make_preference_tally(@election) @@ -118,37 +115,40 @@ class GraphController < ApplicationController end private - def get_positions_info(election) buckets = Hash.new buckets2= Hash.new rank_labels = Hash.new - + election.candidates.each do |candidate| buckets[candidate.id] = [] buckets2[candidate.id] = [] end - + + #attach the ranking to the candidate's array to which is belongs election.votes.each do |vote| vote.rankings.each do |ranking| buckets[ranking.candidate_id] << ranking.rank end end - + + #count how many times each candidate has been ranked at a certain level buckets.each_pair do |id, array| (1..election.candidates.size).each do |i| buckets2[id] << (array.find_all {|rank| rank == i}).size end end + #sort by amount of 1st place votes + sorted_data = buckets2.values.sort {|a,b| b[0] <=> a[0]} + election.votes.each do |vote| vote.rankings.size.times do |i| rank_labels[i] = (i+1).to_s end end - return buckets2.values, rank_labels - + return sorted_data, rank_labels end # generate the data and labels for each graph @@ -256,7 +256,7 @@ class GraphController < ApplicationController #Populate points with an sorted array from election.votes hash #biggest to smallest will go from left to right - points = result.election.votes.sort do |a, b| + points = result.points.sort do |a, b| b[1] <=> a[1] end.collect {|i| i[1]} diff --git a/app/controllers/sparklines_controller.rb b/app/controllers/sparklines_controller.rb new file mode 100644 index 0000000..eb5e6a6 --- /dev/null +++ b/app/controllers/sparklines_controller.rb @@ -0,0 +1,43 @@ +class SparklinesController < ApplicationController + # Handles requests for sparkline graphs from views. + # + # Params are generated by the sparkline_tag helper method. + # + def index + # Make array from comma-delimited list of data values + ary = [] + if params.has_key?('results') && !params['results'].nil? + params['results'].split(',').each do |s| + ary << s.to_i + end + end + + send_data( Sparklines.plot( ary, params ), + :disposition => 'inline', + :type => 'image/png', + :filename => "spark_#{params[:type]}.png" ) + end + + def spark_pie + send_data(Sparklines.plot( )) + end + # Use this type of method for sparklines that can be cached. (Doesn't work with the helper.) + # + # To make caching easier, add a line like this to config/routes.rb: + # map.sparklines "sparklines/:action/:id/image.png", :controller => "sparklines" + # + # Then reference it with the named route: + # image_tag sparklines_url(:action => 'show', :id => 42) + def show + send_data(Sparklines.plot( + [42, 37, 89, 74, 70, 50, 40, 30, 40, 50], + :type => 'bar', :above_color => 'orange' + ), + :disposition => 'inline', + :type => 'image/png', + :filename => "sparkline.png") + end + + + +end \ No newline at end of file diff --git a/app/models/election.rb b/app/models/election.rb index 3baeef0..d76c13f 100644 --- a/app/models/election.rb +++ b/app/models/election.rb @@ -4,6 +4,12 @@ class Election < ActiveRecord::Base has_many :votes belongs_to :user validates_presence_of :name, :description + + attr_reader :plurality_result + attr_reader :approval_result + attr_reader :condorcet_result + attr_reader :ssd_result + attr_reader :borda_result require 'date' @@ -76,4 +82,45 @@ class Election < ActiveRecord::Base longdesc = description.split(/\n/)[1..-1].join("") longdesc.length > 0 ? longdesc : nil end + + #Calculate Election Results + def results + # initalize the tallies to empty arrays + preference_tally = Array.new + plurality_tally = Array.new + approval_tally = Array.new + + self.voters.each do |voter| + # skip if the voter has not voted or has an unconfirmed vote + next unless voter.voted? + + plurality_tally << voter.vote.rankings.sort[0].candidate.id + approval_tally << voter.vote.rankings.sort[0..1].collect \ + { |ranking| ranking.candidate.id } + preference_tally << voter.vote.rankings.sort.collect \ + { |ranking| ranking.candidate.id } + end + @plurality_result = PluralityVote.new(plurality_tally).result + @approval_result = ApprovalVote.new(approval_tally).result + @condorcet_result = PureCondorcetVote.new(preference_tally).result + @ssd_result = CloneproofSSDVote.new(preference_tally).result + @borda_result = BordaVote.new(preference_tally).result + #@runoff_result = InstantRunoffVote.new(preference_tally).result + + nil # to stay consistent + end + + def names_by_id + names = Hash.new + + competitors = self.candidates.sort.collect {|candidate| candidate.id} + competitors.each do |candidate| + names[candidate] = Candidate.find(candidate).name + end + + names + end + end + + diff --git a/app/models/quick_vote.rb b/app/models/quick_vote.rb index 698dac3..c0367d6 100644 --- a/app/models/quick_vote.rb +++ b/app/models/quick_vote.rb @@ -4,12 +4,7 @@ class QuickVote < Election validates_presence_of :name attr_accessor :raw_candidates attr_accessor :reviewed - attr_accessor :plurality_result - attr_accessor :approval_result - attr_accessor :condorcet_result - attr_accessor :ssd_result - attr_accessor :borda_result - + def validate if not @raw_candidates or @raw_candidates.length < 2 errors.add(nil, "You must list at least two candidates.") @@ -69,33 +64,6 @@ class QuickVote < Election end end - #Calculate Election Results - def results - # initalize the tallies to empty arrays - preference_tally = Array.new - plurality_tally = Array.new - approval_tally = Array.new - - self.voters.each do |voter| - # skip if the voter has not voted or has an unconfirmed vote - next unless voter.voted? - - plurality_tally << voter.vote.rankings.sort[0].candidate.id - approval_tally << voter.vote.rankings.sort[0..1].collect \ - { |ranking| ranking.candidate.id } - preference_tally << voter.vote.rankings.sort.collect \ - { |ranking| ranking.candidate.id } - end - @plurality_result = PluralityVote.new(plurality_tally).result - @approval_result = ApprovalVote.new(approval_tally).result - @condorcet_result = PureCondorcetVote.new(preference_tally).result - @ssd_result = CloneproofSSDVote.new(preference_tally).result - @borda_result = BordaVote.new(preference_tally).result - #@runoff_result = InstantRunoffVote.new(preference_tally).result - #@runoff_results = PluralityVote.new(preference_tally).result - - end - ### Convert a shortname or id into a QuickVote def self.ident_to_quickvote(ident) return nil unless ident diff --git a/app/views/quickvote/_defeats_list.rhtml b/app/views/quickvote/_defeats_list.rhtml deleted file mode 100644 index 248c294..0000000 --- a/app/views/quickvote/_defeats_list.rhtml +++ /dev/null @@ -1,15 +0,0 @@ -<% victories, tied =@election.condorcet_result.list_defeats -%> -<%candidates = @election.candidates.sort.collect {|candidate| candidate.id}%> -<% names = Hash.new -%> -<% candidates.each do |candidate| -%> - <%names[candidate] = Candidate.find(candidate).name -%> -<% end -%> - -<% victories.each do |victory| -%> -<%= names[victory[0]] %> beat <%= names[victory[1]] %> by <%= victory[2]%> -votes.
-<% end -%> - -<% tied.each do |tie| -%> -<%= names[tie[0]]%> tied with <%= names[tie[1]]%>
-<% end -%> diff --git a/app/views/quickvote/_pref_table.rhtml b/app/views/quickvote/_pref_table.rhtml index 32f5de4..4576a0f 100644 --- a/app/views/quickvote/_pref_table.rhtml +++ b/app/views/quickvote/_pref_table.rhtml @@ -1,4 +1,6 @@ <% candidates = @election.candidates.sort.collect {|candidate| candidate.id}-%> +<% voters = @election.voters.size %> + <% names = Hash.new -%> <% candidates.each do |candidate| -%> <%names[candidate] = Candidate.find(candidate).name -%> @@ -16,7 +18,11 @@ <% if winner == loser -%> -- <% else %> - <%= @election.condorcet_result.matrix[winner][loser] %> + <% wins = @election.condorcet_result.matrix[winner][loser]%> + <%= wins %> + <%= sparkline_tag [(wins.to_f/voters.to_f)*100.0], :type => 'pie', + :diameter => 25, :share_color => '#74ce00' %> + <% end -%> <% end -%> diff --git a/app/views/quickvote/_victories_ties.rhtml b/app/views/quickvote/_victories_ties.rhtml new file mode 100644 index 0000000..7c3506a --- /dev/null +++ b/app/views/quickvote/_victories_ties.rhtml @@ -0,0 +1,17 @@ +<% victories, tied = @election.condorcet_result.victories_and_ties %> +<% names = @election.names_by_id %> +<% %> + + <% victories.keys.each do |victor| %> + + + <% victories[victor].keys.each do |loser| %> + + <% end -%> + + <% end -%> +
<%= names[victor] %><%= names[loser] %> (<%= victories[victor][loser] %>)
+ + + + diff --git a/app/views/quickvote/results.rhtml b/app/views/quickvote/results.rhtml index 0cbd6d0..c49d682 100644 --- a/app/views/quickvote/results.rhtml +++ b/app/views/quickvote/results.rhtml @@ -1,3 +1,4 @@ +<% %> <%require 'whois/whois' %>

Results

@@ -175,7 +176,7 @@ by several other names.

<% end %> -<%= render :partial => 'defeats_list' %> +<%= render :partial => 'victories_ties' %> <%= render :partial => 'pref_table' %> <%= image_tag( graph_url( :action => 'votes_per_day', :id => @election ) ) %>
diff --git a/config/environment.rb b/config/environment.rb index ba92734..e071a14 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -65,6 +65,7 @@ require 'uniq_token' require 'randarray' require 'rubyvote' require 'gruff' +require 'sparklines' class String # alternate capitalization method that does not lowercase the rest of diff --git a/lib/rubyvote/condorcet.rb b/lib/rubyvote/condorcet.rb index f86e59d..7fda91b 100644 --- a/lib/rubyvote/condorcet.rb +++ b/lib/rubyvote/condorcet.rb @@ -157,6 +157,7 @@ class CondorcetResult < ElectionResult def victories_and_ties victors = Array.new ties = Array.new + victories = Hash.new candidates = @matrix.keys.sort candidates.each do |candidate| @@ -170,8 +171,15 @@ class CondorcetResult < ElectionResult end end end - - victories = victors.sort {|a,b| b[2] <=> a[2]} + + victors.each do |list| + if victories.has_key?(list[0]) + victories[list[0]][list[1]] = list[2] + else + victories[list[0]] = Hash.new + victories[list[0]][list[1]] = list[2] + end + end return victories, ties end diff --git a/test/functional/sparklines_controller_test.rb b/test/functional/sparklines_controller_test.rb new file mode 100644 index 0000000..7d58be4 --- /dev/null +++ b/test/functional/sparklines_controller_test.rb @@ -0,0 +1,30 @@ +require File.dirname(__FILE__) + '/../test_helper' +require 'sparklines_controller' + +# Re-raise errors caught by the controller. +class SparklinesController; def rescue_action(e) raise e end; end + +class SparklinesControllerTest < Test::Unit::TestCase + + #fixtures :data + + def setup + @controller = SparklinesController.new + @request = ActionController::TestRequest.new + @response = ActionController::TestResponse.new + end + + def test_index + get :index, :results => "1,2,3,4,5", :type => 'bar', :line_color => 'black' + assert_response :success + assert_equal 'image/png', @response.headers['Content-Type'] + 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 diff --git a/vendor/plugins/sparklines/MIT-LICENSE b/vendor/plugins/sparklines/MIT-LICENSE new file mode 100644 index 0000000..8d1b480 --- /dev/null +++ b/vendor/plugins/sparklines/MIT-LICENSE @@ -0,0 +1,20 @@ +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. diff --git a/vendor/plugins/sparklines/README b/vendor/plugins/sparklines/README new file mode 100644 index 0000000..800b508 --- /dev/null +++ b/vendor/plugins/sparklines/README @@ -0,0 +1,15 @@ +== Sparklines Plugin + +Make tiny graphs. + +Also has a "sparklines" generator for copying a controller and functional test to your app. + + ./script/generate sparklines + +See examples at http://nubyonrails.com/pages/sparklines + +== Author + +Geoffrey Grosenbach boss@topfunky.com http://nubyonrails.com + + diff --git a/vendor/plugins/sparklines/Rakefile b/vendor/plugins/sparklines/Rakefile new file mode 100644 index 0000000..d3456e4 --- /dev/null +++ b/vendor/plugins/sparklines/Rakefile @@ -0,0 +1,23 @@ +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 + diff --git a/vendor/plugins/sparklines/about.yml b/vendor/plugins/sparklines/about.yml new file mode 100644 index 0000000..f018110 --- /dev/null +++ b/vendor/plugins/sparklines/about.yml @@ -0,0 +1,7 @@ +author: topfunky +summary: A library for making small graphs, a la Edward Tufte. A generator and helper are also included. +homepage: http://nubyonrails.com/pages/sparklines +plugin: http://topfunky.net/svn/plugins/sparklines +license: MIT +version: 0.4.0 +rails_version: 1.1+ diff --git a/vendor/plugins/sparklines/generators/sparklines/sparklines_generator.rb b/vendor/plugins/sparklines/generators/sparklines/sparklines_generator.rb new file mode 100644 index 0000000..40e8dae --- /dev/null +++ b/vendor/plugins/sparklines/generators/sparklines/sparklines_generator.rb @@ -0,0 +1,13 @@ +class SparklinesGenerator < Rails::Generator::Base + + def manifest + record do |m| + m.directory File.join("app/controllers") + m.directory File.join("test/functional") + + m.file "controller.rb", File.join("app/controllers/sparklines_controller.rb") + m.file "functional_test.rb", File.join("test/functional/sparklines_controller_test.rb") + end + end + +end diff --git a/vendor/plugins/sparklines/generators/sparklines/templates/controller.rb b/vendor/plugins/sparklines/generators/sparklines/templates/controller.rb new file mode 100644 index 0000000..1c69f29 --- /dev/null +++ b/vendor/plugins/sparklines/generators/sparklines/templates/controller.rb @@ -0,0 +1,40 @@ +class SparklinesController < ApplicationController + + # Handles requests for sparkline graphs from views. + # + # Params are generated by the sparkline_tag helper method. + # + def index + # Make array from comma-delimited list of data values + ary = [] + if params.has_key?('results') && !params['results'].nil? + params['results'].split(',').each do |s| + ary << s.to_i + end + end + + send_data( Sparklines.plot( ary, params ), + :disposition => 'inline', + :type => 'image/png', + :filename => "spark_#{params[:type]}.png" ) + end + + + # Use this type of method for sparklines that can be cached. (Doesn't work with the helper.) + # + # To make caching easier, add a line like this to config/routes.rb: + # map.sparklines "sparklines/:action/:id/image.png", :controller => "sparklines" + # + # Then reference it with the named route: + # image_tag sparklines_url(:action => 'show', :id => 42) + def show + send_data(Sparklines.plot( + [42, 37, 89, 74, 70, 50, 40, 30, 40, 50], + :type => 'bar', :above_color => 'orange' + ), + :disposition => 'inline', + :type => 'image/png', + :filename => "sparkline.png") + end + +end diff --git a/vendor/plugins/sparklines/generators/sparklines/templates/functional_test.rb b/vendor/plugins/sparklines/generators/sparklines/templates/functional_test.rb new file mode 100644 index 0000000..7d58be4 --- /dev/null +++ b/vendor/plugins/sparklines/generators/sparklines/templates/functional_test.rb @@ -0,0 +1,30 @@ +require File.dirname(__FILE__) + '/../test_helper' +require 'sparklines_controller' + +# Re-raise errors caught by the controller. +class SparklinesController; def rescue_action(e) raise e end; end + +class SparklinesControllerTest < Test::Unit::TestCase + + #fixtures :data + + def setup + @controller = SparklinesController.new + @request = ActionController::TestRequest.new + @response = ActionController::TestResponse.new + end + + def test_index + get :index, :results => "1,2,3,4,5", :type => 'bar', :line_color => 'black' + assert_response :success + assert_equal 'image/png', @response.headers['Content-Type'] + 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 diff --git a/vendor/plugins/sparklines/init.rb b/vendor/plugins/sparklines/init.rb new file mode 100644 index 0000000..c9c2349 --- /dev/null +++ b/vendor/plugins/sparklines/init.rb @@ -0,0 +1 @@ +ActionView::Base.send :include, SparklinesHelper diff --git a/vendor/plugins/sparklines/lib/sparklines.rb b/vendor/plugins/sparklines/lib/sparklines.rb new file mode 100644 index 0000000..4c4e2de --- /dev/null +++ b/vendor/plugins/sparklines/lib/sparklines.rb @@ -0,0 +1,582 @@ + +require 'rubygems' +require 'RMagick' + +=begin rdoc + +A library for generating small unmarked graphs (sparklines). + +Can be used to write an image to a file or make a web service with Rails or other Ruby CGI apps. + +Idea and much of the outline for the source lifted directly from {Joe Gregorio's Python Sparklines web service script}[http://bitworking.org/projects/sparklines]. + +Requires the RMagick image library. + +==Authors + +{Dan Nugent}[mailto:nugend@gmail.com] Original port from Python Sparklines library. + +{Geoffrey Grosenbach}[mailto:boss@topfunky.com] -- http://nubyonrails.topfunky.com +-- Conversion to module and further maintenance. + +==General Usage and Defaults + +To use in a script: + + require 'rubygems' + require 'sparklines' + Sparklines.plot([1,25,33,46,89,90,85,77,42], + :type => 'discrete', + :height => 20) + +An image blob will be returned which you can print, write to STDOUT, etc. + +For use with Ruby on Rails, see the sparklines plugin: + + http://nubyonrails.com/pages/sparklines + +In your view, call it like this: + + <%= sparkline_tag [1,2,3,4,5,6] %> + +Or specify details: + + <%= sparkline_tag [1,2,3,4,5,6], + :type => 'discrete', + :height => 10, + :upper => 80, + :above_color => 'green', + :below_color => 'blue' %> + +Graph types: + + area + discrete + pie + smooth + bar + whisker + +General Defaults: + + :type => 'smooth' + :height => 14px + :upper => 50 + :above_color => 'red' + :below_color => 'grey' + :background_color => 'white' + :line_color => 'lightgrey' + +==License + +Licensed under the MIT license. + +=end +class Sparklines + + VERSION = '0.4.1' + + @@label_margin = 5.0 + @@pointsize = 10.0 + + class << self + + # Does the actual plotting of the graph. + # Calls the appropriate subclass based on the :type argument. + # Defaults to 'smooth' + def plot(data=[], options={}) + defaults = { + :type => 'smooth', + :height => 14, + :upper => 50, + :diameter => 20, + :step => 2, + :line_color => 'lightgrey', + + :above_color => 'red', + :below_color => 'grey', + :background_color => 'white', + :share_color => 'red', + :remain_color => 'lightgrey', + :min_color => 'blue', + :max_color => 'green', + :last_color => 'red', + + :has_min => false, + :has_max => false, + :has_last => false, + + :label => nil + } + + # HACK for HashWithIndifferentAccess + options_sym = Hash.new + options.keys.each do |key| + options_sym[key.to_sym] = options[key] + end + + options_sym = defaults.merge(options_sym) + + # Call the appropriate method for actual plotting. + sparkline = self.new(data, options_sym) + if %w(area bar pie smooth discrete whisker).include? options_sym[:type] + sparkline.send options_sym[:type] + else + sparkline.plot_error options_sym + end + end + + # Writes a graph to disk with the specified filename, or "sparklines.png" + def plot_to_file(filename="sparklines.png", data=[], options={}) + File.open( filename, 'wb' ) do |png| + png << self.plot( data, options) + end + end + + end # class methods + + def initialize(data=[], options={}) + @data = Array(data) + @options = options + normalize_data + end + + ## + # Creates a continuous area sparkline. Relevant options. + # + # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2. + # + # :height - An integer that determines what the height of the sparkline will be. Defaults to 14 + # + # :upper - An integer that determines the threshold for colorization purposes. Any value less than upper will be colored using below_color, anything above and equal to upper will use above_color. Defaults to 50. + # + # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaults to false. + # + # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaults to false. + # + # :has_last - Determines whether a dot will be drawn at the last value or not. Defaults to false. + # + # :min_color - A string or color code representing the color that the dot drawn at the smallest value will be displayed as. Defaults to blue. + # + # :max_color - A string or color code representing the color that the dot drawn at the largest value will be displayed as. Defaults to green. + # + # :last_color - A string or color code representing the color that the dot drawn at the last value will be displayed as. Defaults to red. + # + # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red. + # + # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray. + + def area + + step = @options[:step].to_i + height = @options[:height].to_i + background_color = @options[:background_color] + + create_canvas((@norm_data.size - 1) * step + 4, height, background_color) + + upper = @options[:upper].to_i + + has_min = @options[:has_min] + has_max = @options[:has_max] + has_last = @options[:has_last] + + min_color = @options[:min_color] + max_color = @options[:max_color] + last_color = @options[:last_color] + below_color = @options[:below_color] + above_color = @options[:above_color] + + + coords = [[0,(height - 3 - upper/(101.0/(height-4)))]] + i=0 + @norm_data.each do |r| + coords.push [(2 + i), (height - 3 - r/(101.0/(height-4)))] + i += step + end + coords.push [(@norm_data.size - 1) * step + 4, (height - 3 - upper/(101.0/(height-4)))] + + # TODO Refactor! Should take a block and do both. + # + # Block off the bottom half of the image and draw the sparkline + @draw.fill(above_color) + @draw.define_clip_path('top') do + @draw.rectangle(0,0,(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4)))) + end + @draw.clip_path('top') + @draw.polygon *coords.flatten + + # Block off the top half of the image and draw the sparkline + @draw.fill(below_color) + @draw.define_clip_path('bottom') do + @draw.rectangle(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,height) + end + @draw.clip_path('bottom') + @draw.polygon *coords.flatten + + # The sparkline looks kinda nasty if either the above_color or below_color gets the center line + @draw.fill('black') + @draw.line(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4)))) + + # After the parts have been masked, we need to let the whole canvas be drawable again + # so a max dot can be displayed + @draw.define_clip_path('all') do + @draw.rectangle(0,0,@canvas.columns,@canvas.rows) + end + @draw.clip_path('all') + + drawbox(coords[@norm_data.index(@norm_data.min)+1], 1, min_color) if has_min == true + drawbox(coords[@norm_data.index(@norm_data.max)+1], 1, max_color) if has_max == true + + drawbox(coords[-2], 1, last_color) if has_last == true + + @draw.draw(@canvas) + @canvas.to_blob + end + + ## + # A bar graph. + + def bar + step = @options[:step].to_i + height = @options[:height].to_f + background_color = @options[:background_color] + + create_canvas(@norm_data.length * step + 2, height, background_color) + + upper = @options[:upper].to_i + below_color = @options[:below_color] + above_color = @options[:above_color] + + i = 1 + @norm_data.each_with_index do |r, index| + color = (r >= upper) ? above_color : below_color + @draw.stroke('transparent') + @draw.fill(color) + @draw.rectangle( i, @canvas.rows, + i + step - 2, @canvas.rows - ( (r / @maximum_value) * @canvas.rows) ) + i += step + end + + @draw.draw(@canvas) + @canvas.to_blob + end + + + ## + # Creates a discretized sparkline + # + # :height - An integer that determines what the height of the sparkline will be. Defaults to 14 + # + # :upper - An integer that determines the threshold for colorization purposes. Any value less than upper will be colored using below_color, anything above and equal to upper will use above_color. Defaults to 50. + # + # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red. + # + # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray. + + def discrete + + height = @options[:height].to_i + upper = @options[:upper].to_i + background_color = @options[:background_color] + step = @options[:step].to_i + + create_canvas(@norm_data.size * step - 1, height, background_color) + + below_color = @options[:below_color] + above_color = @options[:above_color] + + i = 0 + @norm_data.each do |r| + color = (r >= upper) ? above_color : below_color + @draw.stroke(color) + @draw.line(i, (@canvas.rows - r/(101.0/(height-4))-4).to_i, + i, (@canvas.rows - r/(101.0/(height-4))).to_i) + i += step + end + + @draw.draw(@canvas) + @canvas.to_blob + end + + + ## + # Creates a pie-chart sparkline + # + # :diameter - An integer that determines what the size of the sparkline will be. Defaults to 20 + # + # :share_color - A string or color code representing the color to draw the share of the pie represented by percent. Defaults to red. + # + # :remain_color - A string or color code representing the color to draw the pie not taken by the share color. Defaults to lightgrey. + + def pie + diameter = @options[:diameter].to_i + background_color = @options[:background_color] + + create_canvas(diameter, diameter, background_color) + + share_color = @options[:share_color] + remain_color = @options[:remain_color] + percent = @norm_data[0] + + # Adjust the radius so there's some edge left in the pie + r = diameter/2.0 - 2 + @draw.fill(remain_color) + @draw.ellipse(r + 2, r + 2, r , r , 0, 360) + @draw.fill(share_color) + + # Special exceptions + if percent == 0 + # For 0% return blank + @draw.draw(@canvas) + return @canvas.to_blob + elsif percent == 100 + # For 100% just draw a full circle + @draw.ellipse(r + 2, r + 2, r , r , 0, 360) + @draw.draw(@canvas) + return @canvas.to_blob + end + + # Okay, this part is as confusing as hell, so pay attention: + # This line determines the horizontal portion of the point on the circle where the X-Axis + # should end. It's caculated by taking the center of the on-image circle and adding that + # to the radius multiplied by the formula for determinig the point on a unit circle that a + # angle corresponds to. 3.6 * percent gives us that angle, but it's in degrees, so we need to + # convert, hence the muliplication by Pi over 180 + arc_end_x = r + 2 + (r * Math.cos((3.6 * percent)*(Math::PI/180))) + + # The same goes for here, except it's the vertical point instead of the horizontal one + arc_end_y = r + 2 + (r * Math.sin((3.6 * percent)*(Math::PI/180))) + + # Because the SVG path format is seriously screwy, we need to set the large-arc-flag to 1 + # if the angle of an arc is greater than 180 degrees. I have no idea why this is, but it is. + percent > 50? large_arc_flag = 1: large_arc_flag = 0 + + # This is also confusing + # M tells us to move to an absolute point on the image. We're moving to the center of the pie + # h tells us to move to a relative point. We're moving to the right edge of the circle. + # A tells us to start an absolute elliptical arc. The first two values are the radii of the ellipse + # the third value is the x-axis-rotation (how to rotate the ellipse if we wanted to [could have some fun + # with randomizing that maybe), the fourth value is our large-arc-flag, the fifth is the sweep-flag, + # (again, confusing), the sixth and seventh values are the end point of the arc which we calculated previously + # More info on the SVG path string format at: http://www.w3.org/TR/SVG/paths.html + path = "M#{r + 2},#{r + 2} h#{r} A#{r},#{r} 0 #{large_arc_flag},1 #{arc_end_x},#{arc_end_y} z" + @draw.path(path) + + @draw.draw(@canvas) + @canvas.to_blob + end + + + ## + # Creates a smooth sparkline. + # + # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2. + # + # :height - An integer that determines what the height of the sparkline will be. Defaults to 14 + # + # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaults to false. + # + # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaults to false. + # + # :has_last - Determines whether a dot will be drawn at the last value or not. Defaults to false. + # + # :min_color - A string or color code representing the color that the dot drawn at the smallest value will be displayed as. Defaults to blue. + # + # :max_color - A string or color code representing the color that the dot drawn at the largest value will be displayed as. Defaults to green. + # + # :last_color - A string or color code representing the color that the dot drawn at the last value will be displayed as. Defaults to red. + + def smooth + + step = @options[:step].to_i + height = @options[:height].to_i + background_color = @options[:background_color] + + create_canvas((@norm_data.size - 1) * step + 4, height, background_color) + + min_color = @options[:min_color] + max_color = @options[:max_color] + last_color = @options[:last_color] + has_min = @options[:has_min] + has_max = @options[:has_max] + has_last = @options[:has_last] + line_color = @options[:line_color] + + @draw.stroke(line_color) + coords = [] + i=0 + @norm_data.each do |r| + coords.push [ 2 + i, (height - 3 - r/(101.0/(height-4))) ] + i += step + end + + open_ended_polyline(coords) + + drawbox(coords[@norm_data.index(@norm_data.min)], 2, min_color) if has_min == true + + drawbox(coords[@norm_data.index(@norm_data.max)], 2, max_color) if has_max == true + + drawbox(coords[-1], 2, last_color) if has_last == true + + @draw.draw(@canvas) + @canvas.to_blob + end + + + ## + # Creates a whisker sparkline to track on/off type data. There are five states: + # on, off, no value, exceptional on, exceptional off. On values create an up + # whisker and off values create a down whisker. Exceptional values may be + # colored differently than regular values to indicate, for example, a shut out. + # No value produces an empty row to indicate a tie. + # + # * results - an array of integer values between -2 and 2. -2 is exceptional + # down, 1 is regular down, 0 is no value, 1 is up, and 2 is exceptional up. + # * options - a hash that takes parameters + # + # :height - height of the sparkline + # + # :whisker_color - the color of regular whiskers; defaults to black + # + # :exception_color - the color of exceptional whiskers; defaults to red + + def whisker + + # step = @options[:step].to_i + height = @options[:height].to_i + background_color = @options[:background_color] + + create_canvas((@data.size - 1) * 2, height, background_color) + + whisker_color = @options[:whisker_color] || 'black' + exception_color = @options[:exception_color] || 'red' + + i = 0 + @data.each do |r| + color = whisker_color + + if ( (r == 2 || r == -2) && exception_color ) + color = exception_color + end + + y_mid_point = (r >= 1) ? (@canvas.rows/2.0 - 1).ceil : (@canvas.rows/2.0).floor + + y_end_point = y_mid_point + if ( r > 0) + y_end_point = 0 + end + + if ( r < 0 ) + y_end_point = @canvas.rows + end + + @draw.stroke( color ) + @draw.line( i, y_mid_point, i, y_end_point ) + i += 2 + end + + @draw.draw(@canvas) + @canvas.to_blob + end + + ## + # Draw the error Sparkline. + + def plot_error(options={}) + create_canvas(40, 15, 'white') + + @draw.fill('red') + @draw.line(0,0,40,15) + @draw.line(0,15,40,0) + + @draw.draw(@canvas) + @canvas.to_blob + end + +private + + def normalize_data + @maximum_value = @data.max + if @options[:type].to_s == 'pie' + @norm_data = @data + else + @norm_data = @data.map { |value| value = (value.to_f / @maximum_value) * 100.0 } + end + end + + ## + # * :arr - an array of points (represented as two element arrays) + + def open_ended_polyline(arr) + 0.upto(arr.length - 2) { |i| + @draw.line(arr[i][0], arr[i][1], arr[i+1][0], arr[i+1][1]) + } + end + + ## + # Create an image to draw on and a drawable to do the drawing with. + # + # TODO Refactor into smaller methods + + def create_canvas(w, h, bkg_col) + @draw = Magick::Draw.new + @draw.pointsize = @@pointsize # TODO Use height + @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col } + + # Make room for label and last value + unless @options[:label].nil? + @options[:has_last] = true + @label_width = calculate_width(@options[:label]) + @data_last_width = calculate_width(@data.last) + # HACK The 7.0 is a severe hack. Must figure out correct spacing + @label_and_data_last_width = @label_width + @data_last_width + @@label_margin * 7.0 + w += @label_and_data_last_width + end + + @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col } + @canvas.format = "PNG" + + # Draw label and last value + unless @options[:label].nil? + if ENV.has_key?('MAGICK_FONT_PATH') + vera_font_path = File.expand_path('Vera.ttf', ENV['MAGICK_FONT_PATH']) + @font = File.exists?(vera_font_path) ? vera_font_path : nil + else + @font = nil + end + + @draw.fill = 'black' + @draw.font = @font if @font + @draw.gravity = Magick::WestGravity + @draw.annotate( @canvas, + @label_width, 1.0, + w - @label_and_data_last_width + @@label_margin, h - calculate_caps_height/2.0, + @options[:label]) + + @draw.fill = 'red' + @draw.annotate( @canvas, + @data_last_width, 1.0, + w - @data_last_width - @@label_margin * 2.0, h - calculate_caps_height/2.0, + @data.last.to_s) + end + end + + ## + # Utility to draw a coloured box + # Centred on pt, offset off in each direction, fill color is col + + def drawbox(pt, offset, color) + @draw.stroke 'transparent' + @draw.fill(color) + @draw.rectangle(pt[0]-offset, pt[1]-offset, pt[0]+offset, pt[1]+offset) + end + + def calculate_width(text) + @draw.get_type_metrics(@canvas, text.to_s).width + end + + def calculate_caps_height + @draw.get_type_metrics(@canvas, 'X').height + end + +end diff --git a/vendor/plugins/sparklines/lib/sparklines_helper.rb b/vendor/plugins/sparklines/lib/sparklines_helper.rb new file mode 100644 index 0000000..c8958d3 --- /dev/null +++ b/vendor/plugins/sparklines/lib/sparklines_helper.rb @@ -0,0 +1,18 @@ +# Provides a tag for embedding sparklines graphs into your Rails app. +# +module SparklinesHelper + + # Call with an array of data and a hash of params for the Sparklines module. + # + # sparkline_tag [42, 37, 43, 182], :type => 'bar', :line_color => 'black' + # + # You can also pass :class => 'some_css_class' ('sparkline' by default). + def sparkline_tag(results=[], options={}) + url = { :controller => 'sparklines', + :results => results.join(',') } + options = url.merge(options) + + %(Sparkline Graph) + end + +end