$:.unshift(File.dirname(__FILE__)) require 'gchart_mod' require 'uri' require 'fastercsv' require 'httparty' module GoogleOtg include HTTParty @@DEFAULT_INCREMENT = 1 # 1 day def date_range tr = self.setup_time_range tr[:lower_bound].strftime("%m/%d/%Y") + " - " + tr[:upper_bound].strftime("%m/%d/%Y") end def over_time_graph(hits, args = {}) tr = self.setup_time_range args[:time_zone] = tr[:time_zone] unless args.has_key?(:time_zone) args[:range] = tr[:range] unless args.has_key?(:range) height = args.has_key?(:height) ? args[:height] : 125 src = args.has_key?(:src) ? args[:src] : "http://www.google.com/analytics/static/flash/OverTimeGraph.swf" if hits.is_a?(Array) and hits[0].is_a?(Array) range = hits.map{|h| hits_to_otg_range(h, args) } else range = [hits_to_otg_range(hits, args)] end vars = range_to_flashvars(range) html = <<-eos eos return html end def over_time_graph_download(results, type, title, legend) tr = self.setup_time_range if type == :csv csv_data = data_to_csv(results, :legend => legend, :x_label_format => "%m/%d/%Y", :time_zone => tr[:time_zone], :range => tr[:range]) time_label = tr[:time_zone].now.strftime("%Y-%m-%d") file_data_type = legend.join(" ").downcase.gsub(" ", "_") outfile = "#{request.env['HTTP_HOST']}-#{file_data_type}_#{time_label}.csv" send_data csv_data, :type => 'text/csv; charset=iso-8859-1; header=present', :disposition => "attachment; filename=#{outfile}" else graph_url = google_line_graph( results , :x_label_format => "%a, %b %d", :time_zone => tr[:time_zone], :title => title, :range => tr[:range], :max_x_label_count => 4, :legend => legend) if params.has_key?(:send_file) send_data HTTParty::get(graph_url), :filename => "graph.png" else url = url_for({:send_file => 1, :controller => controller_name, :action => action_name}.merge(params)) render :inline => "" end end end def grouped_query(class_to_query, args = []) date_field = args.has_key?(:date_field) ? args[:date_field] : "created_at" where_args = args.has_key?(:conditions) ? args[:conditions] : ["TRUE"] where_query = where_args.shift tr = self.setup_time_range return class_to_query.find_by_sql([" SELECT DAYOFYEAR(TIMESTAMPADD(SECOND, ?, #{date_field})) as d, DATE(TIMESTAMPADD(SECOND, ?, #{date_field})) as created_at, count(*) as count FROM #{class_to_query.table_name} WHERE #{where_query} AND #{date_field} >= TIMESTAMPADD(SECOND, -1 * ?, DATE(?)) AND #{date_field} <= TIMESTAMPADD(SECOND, -1 * ?, DATE(?)) + INTERVAL 1 DAY GROUP BY d ORDER BY created_at ", tr[:time_zone].utc_offset, tr[:time_zone].utc_offset, where_args, tr[:time_zone].utc_offset, tr[:lower_bound].strftime("%Y-%m-%d"), tr[:time_zone].utc_offset, tr[:upper_bound].strftime("%Y-%m-%d")].flatten) end protected def google_line_graph(hits, args = {}) raise ArgumentError, "Invalid hits" unless hits size = args.has_key?(:size) ? args[:size] : '800x200' title = args.has_key?(:title) ? args[:title] : "Graph" title_color = args.has_key?(:title_color) ? args[:title_color] : '000000' title_size = args.has_key?(:title_size) ? args[:title_size] : '20' grid_lines = args.has_key?(:grid_lines) ? args[:grid_lines] : [25,50] legend = args.has_key?(:legend) ? args[:legend] : nil x_labels = [] y_labels = [0] data = [] if hits[0].is_a?(Array) shape_markers = [['D','6699CC',0,'-1.0',4],['D','FF9933',1,'-1.0',2],['o','0000ff',0,'-1.0',8],['o','FF6600',1,'-1.0',8]] line_colors = ['6699CC','FF9933'] hits.map{|h| converted = hits_to_gchart_range(h, args) data.push(converted[:points]) x_labels = converted[:x_labels] if converted[:x_labels].length > x_labels.length y_labels = converted[:y_labels] if converted[:y_labels].max > y_labels.max } else shape_markers = [['D','6699CC',0,'-1.0',4],['o','0000ff',0,'-1.0',8]] line_colors = ['6699CC'] converted = hits_to_gchart_range(hits, args) data.push(converted[:points]) x_labels = converted[:x_labels] y_labels = converted[:y_labels] end axis_with_labels = 'x,y' axis_labels = [x_labels,y_labels] return Gchart.line( :size => size, :title => title, :title_color => title_color, :title_size => title_size, :grid_lines => grid_lines, :shape_markers => shape_markers, :data => data, :axis_with_labels => axis_with_labels, :max_value => y_labels[y_labels.length - 1], :legend => legend, :axis_labels => axis_labels, :line_colors => line_colors) end def data_to_csv(hits, args={}) if hits.is_a?(Array) and hits[0].is_a?(Array) range = hits.map{|h| hits_to_otg_range(h, args) } else range = [hits_to_otg_range(hits, args)] end legend = [] legend.concat(args[:legend]) csv_data = FasterCSV.generate do |csv| x_labels = range[0][:x_labels].map{|x_label| x_label[1]} x_labels.unshift("") csv << x_labels raise ArgumentError, "Mismatched array lengths" unless range.length == args[:legend].length for data in range points = data[:points].map{|point| point[:Value][0]} points.unshift(legend.shift) csv << points end end return csv_data end def google_pie(hits, label_fn, args = {}) height = args.has_key?(:height) ? args[:height] : 125 width = args.has_key?(:width) ? args[:width] : 125 pie_values = extract_pct_values(hits, label_fn, args) vars = pie_to_flashvars(pie_values, args) src = args.has_key?(:src) ? args[:src] : "http://www.google.com/analytics/static/flash/pie.swf" html = <<-eos eos return html end # override this if you have a better way of retrieving time zones def get_time_zone return current_user.time_zone if defined?(current_user) && current_user.respond_to?(:time_zone) return @current_user.time_zone if defined?(@current_user) && @current_user.respond_to?(:time_zone) return ActiveSupport::TimeZone["UTC"] end def setup_time_range() time_zone = get_time_zone now_days = time_zone.now # use this get the right year, month and day now_minutes = time_zone.at((now_days.to_i/(60*(1 * 1440)))*(60*(1 * 1440))).gmtime now_floored = time_zone.local(now_days.year, now_days.month, now_days.day, now_minutes.hour, now_minutes.min, now_minutes.sec) default_upper = now_floored default_lower = default_upper - 7.days lower_bound = nil upper_bound = nil if params[:range] dates = params[:range].split("-") sql_dates = dates.map{|d| time_zone.parse(d) } if sql_dates.length == 2 lower_bound = sql_dates[0] upper_bound = sql_dates[1] elsif sql_dates.length == 1 lower_bound = sql_dates[0] upper_bound = default_upper end end lower_bound = default_lower unless lower_bound && lower_bound < upper_bound upper_bound = default_upper unless upper_bound && upper_bound < Time.now + 1.day range = {:lower_bound => lower_bound, :upper_bound => upper_bound} return {:time_zone => time_zone, :lower_bound => lower_bound, :upper_bound => upper_bound, :range => range} end def pie_to_flashvars(args = {}) labels = args[:labels] raw_values = args[:raw_values] percent_values = args[:percent_values] options = { :Pie => { :Id => "Pie", :Compare => false, :HasOtherSlice => false, :RawValues => raw_values, :Format => "DASHBOARD", :PercentValues => percent_values } } return URI::encode(options.to_json) end def extract_pct_values(hits, label_fn, args = {}) limit = args.has_key?(:limit) ? args[:limit] : 0.0 total = 0.0 other = 0.0 percent_values = [] raw_values = [] labels = [] values = [] hits.each{|hit| total += hit.count.to_f } hits.each{|hit| ct = hit.count.to_f pct = (ct / total) if pct > limit percent_values.push([pct, sprintf("%.2f%%", pct * 100)]) raw_values.push([ct, ct]) label = label_fn.call(hit) meta = args.has_key?(:meta) ? args[:meta].call(hit) : nil labels.push(label) values.push({:label => label, :meta => meta, :percent_value => [pct, sprintf("%.2f%%", pct * 100)], :raw_value => ct}) else other += ct end } if other > 0.0 pct = other / total percent_values.push([pct, sprintf("%.2f%%", pct * 100)]) raw_values.push([other, other]) labels.push("Other") values.push({:label => "Other", :percent_value => [pct, sprintf("%.2f%%", pct * 100)], :raw_value => other}) end return {:labels => labels, :raw_values => raw_values, :percent_values => percent_values, :values => values} end def flto10(val) return ((val / 10) * 10).to_i end def hits_to_otg_range(hits, args = {}) return hits_to_range(hits, lambda {|count, date_key, date_value| {:Value => [count, count], :Label => [date_key, date_value]} }, lambda{|mid, top| [[mid,mid],[top,top]] }, lambda{|hit, hit_date_key, hit_date_value| [hit_date_key, hit_date_value] },args) end def hits_to_gchart_range(hits, args = {}) return hits_to_range(hits, lambda {|count, date_key, date_value| count }, lambda {|mid, top| [0,top/2,top] },lambda{|hit, hit_date_key, hit_date_value| hit_date_value }, args) end def hits_to_range(hits, points_fn, y_label_fn, x_label_fn, args = {}) return nil unless hits hits.map{|h| if !h.respond_to?("created_at") || !h.respond_to?("count") raise ArgumentError, "Invalid object type. All objects must respond to 'count' and 'created_at'" end } tz = args.has_key?(:time_zone) ? args[:time_zone] : ActiveSupport::TimeZone['UTC'] label = args.has_key?(:label) ? args[:label] : "Value" time_fn = args.has_key?(:time_fn) ? args[:time_fn] : lambda {|h| return tz.parse(h.created_at) if h.created_at.class == String return tz.local(h.created_at.year, h.created_at.month, h.created_at.day, h.created_at.hour, h.created_at.min, h.created_at.sec) # create zoned time } increment = args.has_key?(:increment) ? args[:increment] : @@DEFAULT_INCREMENT x_label_format = args.has_key?(:x_label_format) ? args[:x_label_format] : "%A, %B %d" max_y = 0 hits_dict = {} hits.each { |h| hits_dict[time_fn.call(h)] = h } total = 0 points = [] point_dates = [] if args[:range] && args[:range][:lower_bound] current = args[:range][:lower_bound] else current = hits.length > 0 ? time_fn.call(hits[0]) : now_floored end if args[:range] && args[:range][:upper_bound] now_floored = args[:range][:upper_bound] else now_days = tz.now # use this get the right year, month and day now_minutes = tz.at((now_days.to_i/(60*(increment * 1440)))*(60*(increment * 1440))).gmtime now_floored = tz.local(now_days.year, now_days.month, now_days.day, now_minutes.hour, now_minutes.min, now_minutes.sec) end while (current < now_floored + increment.days && increment > 0) do if hits_dict[current] count = hits_dict[current].count.to_i max_y = count if count > max_y date = time_fn.call(hits_dict[current]) date_key = date.to_i date_value = date.strftime(x_label_format) points.push(points_fn.call(count, date_key, date_value)) total += count else date = current date_key = date.to_i date_value = date.strftime(x_label_format) points.push(points_fn.call(0, date_key, date_value)) end # Save the date for the x labels later point_dates.push({:key => date_key, :value => date_value}) current = current + increment.days break if points.length > 365 # way too long dudes - no data fetching over 1 yr end if points.length > 100 points = points[points.length - 100..points.length - 1] end ## Setup Y axis labels ## max_y = args.has_key?(:max_y) ? (args[:max_y] > max_y ? args[:max_y] : max_y) : max_y top_y = self.flto10(max_y) + 10 mid_y = self.flto10(top_y / 2) y_labels = y_label_fn.call(mid_y, top_y) ## end y axis labels ## ## Setup X axis labels x_labels = [] max_x_label_count = args.has_key?(:max_x_label_count) ? args[:max_x_label_count] : points.length if points.length > 0 step = [points.length / max_x_label_count, 1].max idx = 0 while idx < points.length if idx + step >= points.length idx = points.length - 1 end point = points[idx] date = point_dates[idx] x_labels.push(x_label_fn.call(point, date[:key], date[:value])) idx += step end end ## End x axis labels ## return {:x_labels => x_labels, :y_labels => y_labels, :label => label, :points => points, :total => total} end PRIMARY_STYLE = { :PointShape => "CIRCLE", :PointRadius => 9, :FillColor => 30668, :FillAlpha => 10, :LineThickness => 4, :ActiveColor => 30668, :InactiveColor => 11654895 } COMPARE_STYLE = { :PointShape => "CIRCLE", :PointRadius => 6, :FillAlpha => 10, :LineThickness => 2, :ActiveColor => 16750848, :InactiveColor => 16750848 } def setup_series(args, style) raise ArgumentError unless args[:label] raise ArgumentError unless args[:points] raise ArgumentError unless args[:y_labels] return { :SelectionStartIndex => 0, :SelectionEndIndex => args[:points].length, :Style => style, :Label => args[:label], :Id => "primary", :YLabels => args[:y_labels], :ValueCategory => "visits", :Points => args[:points] } # end graph end def range_to_flashvars(args = {}) raise ArgumentError unless args.length > 0 x_labels = args[0][:x_labels] raise ArgumentError unless x_labels ct = 0 # this is the structure necessary to support the Google Analytics OTG options = {:Graph => { :Id => "Graph", :ShowHover => true, :Format => "NORMAL", :XAxisTitle => "Day", :Compare => false, :XAxisLabels => x_labels, :HoverType => "primary_compare", :SelectedSeries => ["primary", "compare"], :Series => args.map {|arg| ct += 1 setup_series(arg, ct == 1 ? PRIMARY_STYLE : COMPARE_STYLE) } } # end graph } # end options return URI::encode(options.to_json) end end