#!/usr/local/bin/ruby -w
# Author: js3104@cs.nyu.edu (Jack Shutzman) 4/13/2010  PS4 - DSL
#
# THE IDEA IS TO ENABLE USERS TO BUILD A 'URL' WHICH CAN BE INTERPRETED BY
# THE GOOGLE CHART API (http://chart.apis.google.com/chart?) AS A REQUEST
# FOR A CHART, BUT WITH A SIMPLE SYNTAX USING ENGLISH NOUNS AND SIMPLE
# REPRESENTATION OF A 'LANGUAGE' SPECIFIC TO THE DOMAIN OF GOOGLE CHARTS.
# THIS MODULE UNDERSTANDS A DSL (DOMAIN SPECIFIC LANGUAGE). THE LANGUAGE IS
# MADE UP BY THE AUTHOR OF THIS PROGRAM, AND DOES NOT PRETEND TO BE THE   
# 'ULTIMATE' SOLUTION, BUT ONE OUT OF MANY POSSIBLE SOLUTIONS. IT IS FAIRLY 
# SIMPLE TO EXPLAIN, UNDERSTAND AND USE.
#
# DSL DESCRIPTION
# IN ORDER TO CREATE A CHART, THE USER TAKES THE TEMPLATE GIVEN IN THE SAME
# DIRECTORY, CALLED: template.rb, AND CHANGE IT AS FOLLOWS:
# THERE ARE TWO TYPES OF CHARTS POSSIBLE: (1) BAR CHART, (2) LINE CHART
# TO DRAW A BAR CHART, THE USER TYPES (ON THE SEOND LINE):  
#
# gchart.bar() AND FOR LINE: gchart.line()  
# ONE OF THOSE IS SUFFICIENT TO DRAW A DEFAULT CHART. IF THE USER WANTS 
# MORE SPECIFIC CHART, HE/SHE WILL NEED TO PROVIDE MORE INFORMATION IN
# THE PARENTHESIS THAT FOLLOW THE NAME OF THE CHART IN A CONSISTENT MANNER
# IF THE USER STARTS TYPING THE OPTIONS ON SAME LINE AS THE gchar, THERE IS
# NO NEED FOR PARENTHESIS SURROUNDING THE ENTIRE OPTION LIST. 
#
# THE MANNER WILL ALWAYS BE:  :key=>'value'  , WHERE KEY IS THE COMPONENT
# OF THE CHART AND VALUE IS THE DATA WE WANT FOR THAT COMPONENT. THE KEY 
# IS ALWAYS PRECEDED WITH A COLON, FOR EXAMPLE: 
#
# gchart.line :title => 'My brilliant chart' 
# NO SPACES BETWEEN THE EQUAL SIGN, THE GREATER THAN SIGN. IF THE USER 
# WANTS TO ADD MORE SPECIFIC REQUEST IT IS NOT POSSIBEL (JUST KIDDING...)
# MORE SPECIFICS CAN BE REQUESTED, SEPARATED BY COMMAS (WE'LL CALL THEM 
# OPTIONS). FOR EXAMPLE: 
#
#                       gchart.bar :subtype => 'horizontal_stacked',
#                                  :title => 'Nice chart',
#                                  :legend_text =>'data1,data2'
# AN ALTERNATE WAY WOULD BE:
#                       gchart.bar (
#                                  :subtype => 'horizontal_stacked',
#                                  :title => 'Nice chart',
#                                  :legend_text =>'data1,data2'
#                                 )
#
# (THE ORDER OF THE OPTIONS IS NOT IMPORTANT)
#
# RE-ITERATING AN IMPORTANT ELEMENT:  START THE OPTIONS ON THE SAME LINE 
# AS THE gchar OR SURROUND ALL THE OPTIONS WITH PARENTHESIS.
#
# THE MINIMUM REQUIREMENTS FOR THE GOOGLE CHART API, TO CREATE A CHART OF
# YOUR LIKINGS ARE 3: 1) TYPE, 2) DATA, 3) SIZE
# WE ALREADY TOOK CARE OF (1) BY LETTING THE USER SELECT BAR OR LINE
# LET'S NOW COVER THE OTHER TWO BEFORE WE GO INTO MORE ADVANCED FEATURES
#
# CHART DATA
# THE USER WILL HAVE THE OPTION TO SPECIFY SIMPLE DATA WITH VALUES OF
# 0,100 (FLOATING POINTS). EXAMPLE
#                               :data=>'a,b,c : d,e,f : g,h,i'
#
# THE EXAMPLE SHOWS 3 SERIES OF DATA POINTS. WITHIN THE SERIES THERE ARE
# COMMA SEPARATORS AND BETWEEN SERIES COLON SEPARATORS. YOU CAN OMIT
# A DATA POINT BY SPECIFYING AN UNDERSCORE, I.E  :data=>'20,_,30'
#
# CHART SIZE
# THIS IS PLAIN AND SIMPLE     :size=>'axb'  WHERE a AND b ARE IN PIXELS
# DIMENSIONS. a IS WIDTH AND b IS HEIGHT. FOR EXAMPLE: :size=>300x400 
#
# CHART LEGEND TEXT - legend_text
# legend_text THIS OPTION SHOULD HAVE CORRESPONDING LABELS TO DATA, IN THE
# ORDER OF APPEARANCE. THE LABELS: data1, data2 ETC. SHOULD NOT CONTAIN 
# THREE CONSECUTIVE BACKWARD SPLASHES, BUT CAN CONTAIN 'ESCAPED' COMMAS.
# ESCAPED COMMAS ARE COMMAS PRECEDED BY FORWARD SLASH, I.E /,
# THE VALUES (data1, data2 ETC.) SHOULD BE SEPARATED BY COMMAS.
#
# USAGE EXAMPLE: :legend_text => 'first/, set,second set,Jumbalaya'
#
# CHART LEGEND LOCATION, ORIENTATION AND ORDER - legend_position_order
#
# AN OPTIONAL ACCOMPANYING ADDITION TO LEGENDS IS THE FOLLOWING: 
# legend_position_order WHICH SPECIFIES THE POSITION OF THE LEGEND ON THE
# CHART (AND ORIENTATION): bottom_horizontal, bottom_vertical, top_horizontal,
# top_vertical, right and left. THE ORDER POSSIBILITIES ARE: asis (ORDER
# SPECIFIED IN legend_text ), reversed (OPPOSITE SEQUENCE OF legend_text),
# length_ordered (BY LEGEND TEXT'S LENGTH, SHORT->LONG) AND: custom(2,1,0)
# THE NUMBER LIST SEPARATED BY COMMAS, ARE THE POSITION INDEX OF THE 
# legend_text (ZERO BASED). USE skip TO SKIP A LEGEND, AFTER THE ORDER, AS
# A THIRD PARAMETER OR THERE IS NO ORDER USE position::skip  - SO SKIP WILL
# ALWAYS BE THIRD. USE A COLON TO SEPARATE POSITION FROM ORDER AND FROM SKIP
#
# USAGE EXAMPLE:  
#           :legend_position_order=>'bottom_vertical:custom(1,0)'
#           THE LEGEND TEXT ON THE BOTTOM, VERTICALLY ORIENTED,  THE SECOND 
#           APPEARS FIRST AND THE FIRST SECOND  
# 
# CHART TITLE - title
# TO SPECIFY THE CHART'S TITLE, SIMPLY PROVIDE :title=>'Nice Chart'
# TO CREATE LINE BREAKES, USE COMMAS LIKE THIS :title=>'First line , Second line'
# TO ADD COLOR AND FONT SIZE, PLEASE PROVIDE THE FOLLOWING:
# :title_color_font=>'blue,20'  MEANS BLUE COLOR AND 20 PT SIZE FONT.             
#
# LINE STYLE FOR LINE CHARTS :lines_style=>'4,6,8 : 5,2,1'
# THERE ARE THREE POSSIBLE NUMBERS, ALL ARE GIVEN NUMBER OF PIXELS. THE FIRST IS
# THE THICKNESS OF A SOLID LINE, THE SECOND AND THIRD ARE FOR 'DASHED' LINES AND
# THEY REPRESENT THE LENGTH OF THE 'DASH' AND SPACE BETWEEN DASHES RESPECTIVELY
#
# SERIES COLORS FOR LINE CHARTS  :line_colors=>'red,green,magenta'. THE COLOR 
# WILL CORRESPOND TO THE LINE DESCRIBED BY THE DATA POINTS (EACH LINE IS MADE
# FROM A SERIES OF DATA POINTS)
# THE AVAILABLE COLORS ARE: blue,brown,aqua,darkblue,coral,gold,green,yellow,
#                           red,pink,purple,orange,olive,navy,magenta,maroon 
#                           and black. 
#
# BAR CHART - BAR LABELS
# 3 TYPES ARE AVAILABLE FOR THE DSL USER. standard, custom_x AND 
# even_distribution
#     :bar_label_standard=>'x' STANDARD LABELS ACROSS THE x-AXIS, WE CAN HAVE
#                              ALSO 'y' OR 'xy' WITH CORRESPONDING LOGIC. 
#     :bar_label_custom_x=>'val1,val2,.....,valn' THE vals ARE THE LABELS TO 
#                         BE ADDED UNDER THE BARS (SAME ORDER AS THE BARS)
#     :bar_label_even=>'true' THE LABELS UNDER THE BARS ARE ASSIGNED EVENLY
#                       ACROSS THE X AXIS (NOT LABLE PER BAR NECESSARILY)  
#
# BAR CHART - BAR COLORS
# TWO POSSIBLE COLORING SCHEMES. BOTH ARE REPRESENTED WITHIN THE SAME OPTION:
# :bar_colors=>'col1_series1,col2_series1... : col1_series2, col2_series2:....'
#
# EXAMPLE: :bar_colors=>'red,green : blue,orange,magenta : yellow, gold '
# THIS YIELDS THE FIRST TWO BARS OF THE FIRST SERIES RED AND GREEN, THE NEXT 3
# BARS OF THE SECOND SERIES ARE: BLUE,ORANGE AND MAGENTA AND THE 3RD SERIES ARE
# TWO BARS PAINTED IN YELLOW AND GOLD.
#
# SUMMARY: TWO IMPORTANT THINGS TO REMEMBER. 1. USE ALL LOWER CASE (UNLIKE THIS
#          COMMENT), EXCEPT FOR NAMES LIKE TITLE CONTENT OR LEGEND TEXT AND 2.
#          ADD A COMMA BETWEEN OPTIONS: :size=>100x200, :data=>'3,4,10:9,2.1'   
#------------------------------------------------------------------------------- 
module GCHARTS
  class Chartbuilder
    attr_reader :result
    private
    def initialize
      @result="http://chart.apis.google.com/chart?"
      @no_chart = nil
    end

    def to_s
      @result
    end
#-------------------------------------------------------------------------------
    def method_missing(sym, *args)
      # REQUESTS FOR CHARTS OTHER THAN BAR OR LINE ARE REJECTED GRACEFULY.
      # METHOD_MISSING CAPTURES THE BAR & LINE MISSING 'METHOD' NAMES AND
      # DOES SOME SIMPLE VALIDATIONS. IF A URL IS STILL WITHIN REACH, IT
      # CALLS THE APPROPRIATE METHODS. @result IS THE BUILT URL OR ERROR.
      @ctype= "#{sym}" 
      if args.length > 0
        set_options(args[0])         
        if @ctype == 'line'
          if !@subtype || !valid_chart_type? 
            @subtype ? message = "Not with: " + @subtype : message=''
            @result=
            "Sorry, Line chart is only valid with 'axis' 'noaxis' or" +
            " 'xy' as subtype. " + message
            @no_chart=1
          else   
            @result += "cht=l" + @subtype 
            if @lines_style
              set_lines_style
            end
            if @line_colors
              set_line_colors
            end  
          end  
        else
          if @ctype == 'bar' 
            if !@subtype || !valid_chart_type? 
              @subtype ? message = "Not with: " + @subtype : message='' 
              @result=
              "Sorry, Bar chart is only valid with 'horizontal_stacked', " +
              "'horizontal_grouped',' vertical_stacked ', " +
              "'vertical_stacked_overlapped' or 'vertical_grouped'" + 
              " as subtype. " + message
              @no_chart=1
            else
              @result += "cht=b" + @subtype
              if @bar_label_standard
                set_bar_labels
              end 
              if @bar_colors
                set_bar_colors
              end   
            end  
          else # CHART BASIC TYPE WAS NOT SPECIDIED CORRECTLY (bar or line)
            @result = "Sorry, your chart type: #{sym}" + 
            ", cannot be deciphered, please select gchart.line or gchart.bar" 
            @no_chart=1 
          end
        end 
      else
        # WILL DISPLAY A MEANINGLESS DEFAULT CHART IF NO ARGUMENTS GIVEN
        @result += "cht=bvs&chd=t:10,20&chs=100x200"
      end 
      if !@no_chart 
        # OPTIONS THAT PERTAIN TO BOTH BAR AND LINE CHART ARE BEING SET HERE     
        set_mutual_parms
      end      
    end
#-------------------------------------------------------------------------------
    def set_mutual_parms
      # This method sets the elements that are common for both bar and line chts
      if @legend_text 
        set_legend
      end  
      if @legend_position_order
        set_legend_position_order
      end   
      if @title
        set_title
      end 
      if @data
        set_data
      end 
      if @size
        set_size
      end  
    end
#-------------------------------------------------------------------------------
    def set_options(opt_hash)
      # This method assigns the values of the instance variables according to
      # the input parameters of the user that were captured in the hush: opt_hash
      @data                  = opt_hash[:data]
      @size                  = opt_hash[:size]
      @lines_style           = opt_hash[:lines_style]
      @line_colors           = opt_hash[:line_colors]
      @bar_label_standard    = opt_hash[:bar_label_standard]
      @bar_label_custom_x    = opt_hash[:bar_label_custom_x]
      @bar_label_even        = opt_hash[:bar_label_even]
      @bar_colors            = opt_hash[:bar_colors]
      @title                 = opt_hash[:title]
      @title_color_font      = opt_hash[:title_color_font]
      @legend_text           = opt_hash[:legend_text]
      @legend_position_order = opt_hash[:legend_position_order]
      subtype                = opt_hash[:subtype]
      if subtype
        subtype.gsub!(" ","")
        @subtype= convert_ct(subtype)
      end                                       
    end
#-------------------------------------------------------------------------------
    def valid_chart_type?
      # Validating our acceptable sub-types for line & bar charts
      (@ctype == 'line' && ("sc".index(@subtype) || @subtype == 'xy')) ||
      (@ctype == 'bar'  && bar_types? ) 
    end  
   
    def bar_types?
      # Validating acceptable sub-types of bar-charts
      @subtype == 'hs' || @subtype == 'hg' || @subtype == 'vs' ||
      @subtype == 'vo' || @subtype == 'vg'
    end  
#-------------------------------------------------------------------------------
    def set_data # chd
      #Assigns data for chart (one of the mandatory inputs)
      data_collection = extract(@data)   
      @result += '&chd=t:' + data_collection  
    end  
#-------------------------------------------------------------------------------
    def set_size # chs
      #Size is taken as is, since it is intuitive to specify axb as dimensions
      if @size
        @result += '&chs=' + @size
      end  
    end  
#-------------------------------------------------------------------------------
   def set_lines_style
     # Using the extract method to map from the user's more intuitive colon
     # separated values to the Google vertical-bar separated
     data_collection = extract(@lines_style)
     @result += '&chls=' + data_collection 
   end   
#-------------------------------------------------------------------------------
   def set_line_colors
     # Colors get interpreted from known names to hex values
     color_str=""
     color_array=@line_colors.split(",")
     color_array.each do |co|
       color_str += translate_color(co) + ","
     end   
     color_str[-1]=""
     @result += '&chco=' + color_str
   end   
#-------------------------------------------------------------------------------
   def set_bar_colors # chco
     # Two nested loops for the color translation, potentially multiple series
     # and several colors within a series 
     bar_col_arr=""
     @bar_colors.gsub!(" ","")
     series_arr=@bar_colors.split(":")
     series_arr.each do |co_a|
       color_arr=co_a.split(",")
       hex_col_arr=""
       color_arr.each do |col|
         hex_col_arr += translate_color(col) +','
       end
       hex_col_arr[-1]="" 
       bar_col_arr += hex_col_arr + "|"
     end
     bar_col_arr[-1]=""  
     @result += '&chco=' +  bar_col_arr       
   end   
#-------------------------------------------------------------------------------
   def set_bar_labels  # chxt, chxl AND chxp
     # Google's flexibility of positioning the labels under the bars (for bar 
     # charts), makes this method work harder to interpret the various options
     bar_str="&chxt="
     bar_vals="&chxl=0:"
     bar_even="&chxp=0,0"
     @bar_label_standard.gsub!(" ","")
     @bar_label_standard=='xy' ? bar_str +='x,y' : bar_str +=@bar_label_standard
     @result += bar_str
     if @bar_label_custom_x
       bar_arr=@bar_label_custom_x.gsub!(" ","").split(",")
       bar_arr.each do |br|
         bar_vals += '|' + br 
       end
       @result += bar_vals
     end
     if @bar_label_even && @bar_label_even.gsub!(" ","") == 'true'      
       @result += bar_even
     end   
   end   
#-------------------------------------------------------------------------------
    def set_legend  # chdl
      # A common method for bar and line charts to interpret the legend text
      # Also allows inclusion of commas as those could be common among legends, 
      # i.e  "Dollar, Yen exhange rate"
      @legend_str="chdl="
      @legend_text.gsub!("/,", "///")  
      @legend_text.gsub!(",",",|")
      legend_arr= @legend_text.split(',')
      legend_arr.each do |leg|
        leg.gsub!(" ","+")
        leg.gsub!("///", ",")
        @legend_str += leg
      end 
      @result += '&' + @legend_str
    end  
#-------------------------------------------------------------------------------
    def set_legend_position_order # chdlp
      # Since legend position and order is flexible (including custom option)
      # this method turn out to be more involved, parsing the different 
      # possibilities that Google allows. 
      pos=""; order=""
      @legend_position_order.gsub!(' ','') # GET RID OF SPACES
      pos_order_arr= @legend_position_order.split(":")  
      pos1= case pos_order_arr[0]
              when 'bottom_vertical';   'bv'
              when 'bottom_horizontal'; 'b' 
              when 'top_horizontal';    't'
              when 'left';              'l'
              when 'right';             'r'  
              else 'r'      #CORRECT THE USER IF MISTAKEN HERE, CHOOSE DEFAULT          
            end      
      if pos_order_arr[2]   # WE HAVE 'skip' OPTION AS 3RD PARAMETER
        pos1 += 's'
      end
      @legend_position_order= "chdlp=" + pos1  
      if pos_order_arr[1] && pos_order_arr[1] !~ /\w/ # ORDERING WAS REQUESTED
        ord1=""
        ord1= case pos_order_arr[1]
                when 'asis';           'l'
                when 'reversed';       'r' 
                when 'length_ordered'; 'a'           
              end   
        if !ord1 || ord1 =~ /\w/         
          custom_try= pos_order_arr[1]
          custom_try.slice!(/custom\(/)
          if custom_try
            ord1= custom_try.gsub!(")","")
          else   #WRONG CODE, SET VALUE ACCORDING TO STANDARD DEFAULTS
            if pos1 == 'bv'
              ord1 = 'l'
            else
              ord1 = 'a'
            end 
          end        
                  
        end 
        if ord1
          @legend_position_order += "|" + ord1  
        end              
      end  
      @result += '&' + @legend_position_order 
    end
#------------------------------------------------------------------------------- 
    def set_title # chtt, chts
      # This method interprets the requested font and color for the chart tile
      color_font_str=""
      @title.gsub!(" ","+")
      @title.gsub!(",","|")
      @result += '&chtt=' + @title
      if @title_color_font
        color_font=@title_color_font.split(',')
        if color_font[0] != "" && color_font[0] != " "
          @color = translate_color(color_font[0])
        else
          @color="000000" # BLACK IS THE DEFAULT
        end    
        color_font_str += @color
        if color_font[1] != "" && color_font[1] != " "
          @font = color_font[1]
        else
          @font= '12'     # DEFAULT 
        end
        @result += '&chts=' + @color + ',' + @font       
      end  
    end  
#------------------------------------------------------------------------------- 
    def translate_color(col)   
      # TRANSLATING HUMAN PRONNOUCIBLE COLORS TO CRYPTIC HEX VALUES OF RGB  
      color_value = Hash[ 'blue'=>'0000ff', 'brown'=>'a52a2a','aqua'=>'00ffff',  
      'darkblue'=>'00008b','coral'=>'ff7f50','gold'=>'ffd700','green'=>'008000',
      'yellow'=>'ffff00','red'=>'ff0000','pink'=>'ffc0cb','purple'=>'800080',
      'orange'=>'ffa500','olive'=>'808000','navy'=>'000080','magenta'=>'ff00ff',
      'maroon'=>'800000']       
       color=color_value[col]
       color='000000' if !color
       return color                                      
    end  
#------------------------------------------------------------------------------- 
    def convert_ct(st)
      #This method converts human understandable chart sub-type descriptions
      #to the Google corresponding codes
      subtype_value= Hash['horizontal_stacked'=>'hs','horizontal_grouped'=>'hg',   
      'vertical_stacked'=>'vs','vertical_stacked_overlapped'=>'vo',   
      'vertical_grouped'=>'vg','axis'=>'c','noaxis'=>'s','xy'=>'xy']
      subtype_str = subtype_value[st]
      subtype_str=st if !subtype_str
      return subtype_str
    end  
#-------------------------------------------------------------------------------
    def extract(d)
      # A common extract theme, is defined as a method to be re-used
      d.gsub!(" ","") # TRIM SPACES
      str_res=""
      data_arr= d.split(":")
      data_arr.each do |da|
        str_res += da + '|'
      end
      str_res[-1]=''
      return str_res
    end    
#-------------------------------------------------------------------------------  
    def self.instance
      @@instance ||= new
    end
  end
end

def gchart
  GCHARTS::Chartbuilder.instance
end   
#-------------------------------------------------------------------------------     
    