// chartviz 0.1 - javascript charting/visualization library - http://jekor.com/chartviz/
// 
// Copyright 2009 Chris Forno
// Licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) license.

// I wrote this library because I couldn't find anything that replicated the
// intuitive beauty and simplicity of the charts found in Google Analytics
// without using flash.
//
// Naïve graphing tools make charts that are understandable to
// mathematicians/statisticians, but that are not friendly to
// non-mathematicians.
//
// This is built on top of the Raphaël library (for vector graphics), and it
// uses jQuery for event handling.
//
// Each chart of chartviz is designed to be independent. They are contained in
// seperate files so you can use just what you need.

var CHARTVIZ = CHARTVIZ || {};

// This time series chart is based on reverse-engineering how the
// Google Analytics time series chart looks and behaves.
//
// - g is Raphael graph object
// - graphDiv is the jQuery object in which to render the chart.
// 
// We require the div due to oddities in the DOM (we can't retrieve it once
// it's been converted into an SVG object, and we can't use the SVG object
// itself for much of anything).
// TODO: There's got to be a way to get rid of the need for this.

CHARTVIZ.timeSeries = function (g, graphDiv) {
  return {
    // We begin with a number of customizable properties. These affect the
    // appearance of charts.

    ///////////////////////////////////////////////////////////////////////////
    //                       VISUAL CUSTOMIZATION                            //
    ///////////////////////////////////////////////////////////////////////////

    // the color of the data line, points, and area
    dataColor: '#07C',

    // the size of the data points
    dataPointRadius: 4.5,

    // the size of the data point with focus
    dataPointHoverRadius: 6,

    // the default color for textual labels
    labelColor: '#333',

    // In case you need to change the text height, here it is. By default,
    // Raphaël will use 10px fonts.
    textHeight: 14,

    // Using no text stroke will give us nice sharp text. But adding just a hint
    // of a stroke (usually in a different color) makes the text look more like
    // what we see in Google Analytics.
    textStrokeWidth: 0.2,

    style: {
      // the baseline (line at the bottom of the graph) and x-axis tick lines
      baseLine: {
       'stroke': '#666'
      },

      // the line at the top of the graph
      topLine: {
        'stroke': '#AAA'
      },

      // each vertical and horizontal plot area grid line
      gridLine: {
        'stroke': '#CCC',
        'stroke-dasharray': ['.']
      },

      // the line representing the data
      dataLine: {
        'stroke-width': 4
      },

      // the area under the data line
      dataArea: {
        'stroke-width': 0,
        'opacity': 0.1
      },

      // y-axis labels
      yAxisLabel: {
        'fill': '#222'
      },

      // x-axis date labels
      xAxisLabel: {
        'fill': 'black'
      },

      tooltip: {
        cornerRadius: 5,

        box: {
          'fill':         'white',
          'stroke':       '#474747',
          'stroke-width': 2
        },

        // the date printed at the top of the tooltip
        date: {
          'fill': '#07C',
          'stroke': '#07C'
        },

        unit: {
          'font-size': '12px'
        },

        // the value for the date
        value: {
          'font-size':   '13px',
          'font-weight': 'bold'
        }
      }
    },

    monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July',
                 'August', 'September', 'October', 'November', 'December'],

    dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday',
               'Saturday'],

    // For generating pleasing vertical axis labels, single-digit integer
    // maximums are special. I came up with these values by looking at what
    // Google Analytics does.
    singleDigitTicks: [
      [],    // 0: (no labels)
      [1],   // 1: 1 (special case, only maximum tick)
      [1,2], // 2: 1, 2
      [1,3], // 3: 1, 3 (yes, some are not evenly spaced)
      [2,4], // 4: 2, 4
      [2,5], // 5: 2, 5
      [3,6], // 6: 3, 6
      [3,7], // 7: 3, 7
      [4,8], // 8: 4, 8
      [4,9]  // 9: 4, 9
    ],

    ///////////////////////////////////////////////////////////////////////////
    //                         HELPER FUNCTIONS                              //
    ///////////////////////////////////////////////////////////////////////////

    // We could just use array reduction for this, but I want to keep
    // dependencies to a minimum, and jQuery doesn't have any reduce function
    // that I know of.
    max: function (array) {
      var m = 0;
      for (var i = 0; i < array.length; i++) {
        if (array[i] > m) {
          m = array[i];
        }
      }
      return m;
    },

    // Reverse the characters of a string.
    reverse: function (s) {
      return s.split('').reverse().join('');
    },

    // Unfortunately, not every browser supports toLocaleString() or some
    // equivalent, so we'll need to implement our own without regard for the
    // user's locale.
    formatNumber: function (x) {
      return this.reverse(this.reverse(x.toString()).replace(/(\d{3})(?=\d)/g, '$1,'));
    },

    // We need to save some room at the bottom of the graph for labels.
    plotHeight: function () {
      return g.height - this.textHeight;
    },

    // We're going to need to convert y values into graph units often. The
    // conversion should just be val/max * plotHeight. However, we need to take
    // the dataline width into account so that we don't get ugly intersections
    // with the baseline. Additionally, we don't use the whole upper area of the
    // chart either.
    // - plotHeight is the height of the plotting area (not the canvas) in
    //   Raphael units.
    graphYHelper: function (maxVal) {
      var that = this;
      return function (val) {
        // Basically, we make unusable enough area so that the data points do not
        // touch the top line nor does the data line intersect the bottom line.
        var y = (val / maxVal) *
                (that.plotHeight() - that.dataPointHoverRadius - 1);
        // Now add the baseline offset and invert the number, because we start from
        // 0 on the top. We shift down by 1 because it looks just a little bit more
        // like Google Analytics that way.
        return Math.round(that.plotHeight() - (y + 1));      
      };
    },

    // One pleasing aspect of the Google Analytics chart layout is how the points
    // are arranged along the x-axis.
    //
    // - numPoints is the number of points that will be displayed in the chart.
    graphXHelper: function (numPoints) {
      // The points begin at the middle of the graph. For 1 point, the graph is
      // split in half and the point is at 1/2. For each point added, the
      // denominator increases by 2.
      // 1 point: middle
      // 2 points: 1/4 and 3/4
      // 3 points: 1/6, 3/6, 5/6
      // 4 points: 1/8, 3/8, 5/8, 7/8
      // ...
      // This continues until the points get close enough together that it just
      // makes more sense to draw a solid line.
      return function (pointIndex) {
        // We can rephrase and simplify the algorithm a bit:
        // Step 1: Double the number of points. This will be the denominator.
        // Step 2: Find the nth odd-numbered numerator.
        return Math.round(((pointIndex * 2 + 1) / (numPoints * 2)) * g.width);
      };
    },

    // Another interesting aspect of Google Analytic's time-series line charts is
    // their aspect ratio: they're very wide but not very tall. This makes
    // sense for the type of data you'd analyze in Analytics.
    // 
    // Additionally, charts have only a couple vertical-axis tick marks (and
    // lines), which gives it a nice clean appearance.
    // 
    // This determines optimal Y-Axis (numeric) labels based on the values in the
    // points array.
    // 
    // For simplicity, this assumes:
    //  - a linear scale
    //  - the baseline is at 0
    //  - you want 2 vertical ticks at most (not including the baseline)
    //  - we're working with positive integers
    // 
    // This is based on my observations of how Google Analytics picks vertical
    // ticks.
    //
    // This returns an array of fractional tick values. You might receive
    // an empty array (in the case of 0 being the maximum) or a single value
    // (for 1). All other sets of values currently return 2 ticks.
    // 
    // Note: The ticks returned might not be evenly spaced, so do not naively
    // place them at 50% and 100%. Instead, you must calculate the appropriate
    // unit to place them at.
    // 
    // Examples for 2+ digits:
    // 10: 5, 10
    // 12: 10, 20
    // 19: 10, 20
    // 25: 15, 30
    // 30: 15, 30
    // 34: 20, 40
    // 47: 25, 50
    // 60: 30, 60
    // 70: 35, 70
    // 82: 45, 90
    // 100: 50, 100
    // 110: 100, 200
    // 148: 100, 200
    // 162: 100, 200
    // 289: 150, 300
    // 200, 400
    // 250, 500
    // 300, 600
    // 350, 700
    // 400, 800
    // 450, 900
    // 500, 1000
    // 1000, 2000
    // 1500, 3000
    // 2000, 4000
    // 2500, 5000
    // ...
    getYAxisTicks: function (maxVal) {
      if (maxVal === 0) {
        // Let's not break the digits calculation with -∞.
        return [];
      }
      // Determine the number of digits of the maximum value.
      var digits = Math.floor(Math.log(maxVal)/Math.log(10)) + 1;
      if (digits === 1) {
        // Single-digit maximums are a special case. It's easiest to just use a
        // lookup table.
        return this.singleDigitTicks[maxVal];
      } else {
        // For values 10 to 99, we can divide the axis evenly using a multiple
        // of 5. Then, for values 100 and above we do the same thing with extra
        // 0s.
        // 
        // Here's the algorithm:
        // 
        // 1. Convert the value into a 2-digit number.
        // 2. Is the value <= 5*2? If so, your number is 10. Use 5 and 5*2 as
        //    your tick marks.
        // 3. If not, add 5 and try again. For example, is the value <= 10*2?
        //    If so, use 10 and 10*2 as the tick marks. Etc.
        // 4. Scale the tick marks back up to the number of digits in the
        //    original value.
        // 
        // Step 1. Convert the value into a 2-digit number.
        var val = maxVal / Math.pow(10, digits - 2);
        // Steps 2 and 3. The last number we try, 50, will always work and
        // terminate the loop since val < 100.
        for (var i = 5; val > i*2; i += 5) {}
        // Step 4. Scale up.
        i *= Math.pow(10, digits - 2);
        return [i, i*2];
      }
    },

    // x-axis ticks are based on the width of the labels, but only to a point.
    // 
    // This accepts a number of points and returns an array of points at which to
    // place major x-axis ticks. Unlike with getXAxisPoints(), these are not
    // fractional units, but instead are point indeces. For example, if you
    // pass in 16 for numPoints, you might receive an array like [1, 14], which
    // would mean to mark a tick at point #2 and point #15 respectively (the
    // points are 0-indexed for convenience).
    getXAxisTicks: function (numPoints) {
      // 1-6 points:    all labels get used until space runs out and the last
      //                gets removed
      // 7-87 points:   starting from the day before today, each 7th day gets
      //                a tick
      // 88-98 points:  same as before, but each 8th day gets a tick. The
      //                transition does not seem to be dependent on window
      //                width. Instead, it seems to be designed to get
      //                11 ticks onto the x-axis.
      // 99-109 points: same as before, but each 9th day gets a tick.
      // ...and so on...
      // 
      // Also, ticks should be marked with dotted/solid lines, even when the text
      // is not drawn due to space constraints.
      //
      var ticks = [];
      if (numPoints < 7) {
        for (var i = 0; i < numPoints; i++) {
          ticks.push(i);
        }
      } else {
        // Let's find spacing such that we get 11 ticks on the x axis.
        // 
        // We never want fewer than 7 spaces between ticks. In Google Analytics,
        // this was probably chosen since much analysis is done at the
        // monthly level, at which weeks make sense.
        var tickSpacing = Math.max(Math.ceil(numPoints / 11), 7);
        // Now, beginning from the 2nd-to-last point, walk backwards by
        // tickSpacing.
        // 
        // TODO: Google Analytics chooses the points regardless of what day it
        //       is. However, I have not figured out how yet.
        for (var i = numPoints; i > 0; i -= tickSpacing) {
          // We're building the list backwards, so we'll use unshift.
          ticks.unshift(i - 1); // 0-indexed
        }
      }
      return ticks;
    },

    // Return the date associated with a given point.
    pointDate: function (startDate, pointIndex) {
      return new Date(startDate.getTime() + (86400000 * pointIndex));    
    },

    // We could print the date in the browser's locale, but again, we're
    // emulating Google Analytics.
    dateLabel: function (date) {
      return this.monthNames[date.getMonth()] + " " +
             date.getDate() + ", " + date.getFullYear();
    },

    // Drawing a y-axis label actually involves drawing 2 labels: one on the
    // left side and another on the right.
    // - val is the y value of the label (as text)
    // - y is the y coordinate at which to draw the label. It is not the y value
    //   itself.
    drawYAxisLabel: function (val, y) {
      // left-align on the left
      g.text(0, y, this.formatNumber(val))
       .attr(this.style.yAxisLabel).attr({'text-anchor': 'start',
                                          'stroke':       this.gridLineColor,
                                          'stroke-width': this.textStrokeWidth});
      // right-align the right
      g.text(g.width, y, this.formatNumber(val))
       .attr(this.style.yAxisLabel).attr({'text-anchor': 'end',
                                          'stroke':       this.gridLineColor,
                                          'stroke-width': this.textStrokeWidth});
    },

    // Draw the grid on which to plot the data.
    drawGrid: function (values, startDate, graphX, graphY) {
      var plotHeight = this.plotHeight();
      // We begin by drawing the horizontal (y-axis) lines and labels.
      var yTicks = this.getYAxisTicks(this.max(values));
      if (yTicks.length > 0) {
        if (yTicks.length > 1) {
          // Draw the first tick line and its label.
          var val = yTicks.shift();
          g.path("M0,{1}h{0}", g.width, graphY(val)).attr(this.style.gridLine);
          this.drawYAxisLabel(val, graphY(val) + this.textHeight / 2 + 2);
        }
        // Now, draw the label for the top tick. We already have the chart's
        // upper line, so we don't need to draw another line. We need a
        // slightly larger vertical shift down on this one.
        this.drawYAxisLabel(yTicks.shift(), this.textHeight);
      }
      // Once we've finished with the y-axis, we can begin on the x-axis.
      var xTicks = this.getXAxisTicks(values.length);
      // Now, in xTicks we have a list of point positions at which to place
      // markers. We convert the positions into x coordinates by looking them up.
      var xs = $.map(xTicks, function (pointIndex) {
        return graphX(pointIndex);
      });
      // We have to draw 2 lines for each x tick. One is a dotted line in the
      // plotting area. The other is a solid line in the x-axis label area.
      // To save on DOM weight, we can draw all of the lines using 2 paths.
      var plotLines = $.map(xs, function (x) {
        return "M" + x + ",0v" + plotHeight;
      });
      var labelLines = $.map(xs, function (x) {
        return "M" + x + "," + plotHeight + "v" + (this.textHeight - 3);
      });
      g.path(plotLines.join('')).attr(this.style.gridLine);
      g.path(labelLines.join('')).attr(this.style.baseLine);
      // Now that we have major tick marks, let's add some labels.
      for (var t = 0; t < xTicks.length; t++) {
        // We place labels at x tick points only when space permits. We'll choose
        // 100 units as the width considered sufficient for label printing.
        if (g.width - graphX(xTicks[t]) > 100) {
          g.text(graphX(xTicks[t]) + 4,
                 plotHeight + this.textHeight / 2 + 1,
                 this.dateLabel(this.pointDate(startDate, xTicks[t])))
           .attr(this.style.xAxisLabel).attr({'text-anchor':  'start',
                                              'stroke':       this.labelColor,
                                              'stroke-width': this.textStrokeWidth});
        }
      }
      // Finally, the baseline. We save this until now so that they are above the
      // grid lines.
      g.path("M0,{1}h{0}", g.width, plotHeight).attr(this.style.baseLine);
      // Notice that the upper line is missing. We don't plot it as part of the
      // grid.
    },

    plotPoints: function (values, graphX, graphY) {
      // We don't want to include the first point, because it doesn't generate
      // a line segment. Instead, we'll move to the first point.
      var pointsLine = "M" + graphX(0) + "," + graphY(values[0]);
      for (var i = 1; i < values.length; i++) {
        pointsLine += "L" + graphX(i) + "," + graphY(values[i]);
      }
      // We'll draw the area under the line first. To do so, we have to close
      // the path.
      g.path(pointsLine + "L" + graphX(values.length - 1) + "," + this.plotHeight() +
                          "L" + graphX(0) + "," + this.plotHeight() + "Z")
       .attr(this.style.dataArea).attr('fill', this.dataColor);
      g.path(pointsLine).attr(this.style.dataLine).attr('stroke', this.dataColor);
      // Time to plot the actual data points.
      // There is a point at which we no longer draw the points by default
      // because they'd be too numerous. We do so based on the point width and
      // usable width.
      var showPoints = g.width / (values.length * this.dataPointRadius * 2) > 0.5;
      var segmentLength = g.width / values.length;
      var points = [];
      for (var j = 0; j < values.length; j++) {
        var x = graphX(j);
        var y = graphY(values[j]);
        var p = g.circle(x, y, this.dataPointRadius);
        p.attr({'fill': this.dataColor, 'stroke': 'white'});
        if (!showPoints) {
          p.hide();
        }
        points.push({x: x, y: y, p: p});
      }
      return points;
    },

    // Expand a point (such as when it receives focus).
    dilate: function (point) {
      point.p.attr({'r':            this.dataPointHoverRadius,
                    'stroke-width': 2});      
    },

    // Return an dilated point to normal size.
    contract: function (point) {
      point.p.attr({'r':            this.dataPointRadius,
                    'stroke-width': 1});      
    },

    tooltip: function (values, startDate, unit) {
      // Create the elements that will make up the tooltip, but don't display
      // them yet.
      return {
        chart: this,

        box:  g.rect(0, 0, 0, 0, this.style.tooltip.cornerRadius)
               .attr(this.style.tooltip.box).hide(),

        date: g.text(0, 0, '')
               .attr(this.style.tooltip.date)
               .attr({'stroke-width': this.textStrokeWidth,
                      'text-anchor':  'start'}).hide(),

        unit: g.text(0, 0, '')
               .attr(this.style.tooltip.unit)
               .attr({'fill':         this.labelColor,
                      'stroke':       this.labelColor,
                      'stroke-width': this.textStrokeWidth,
                      'text-anchor':  'start'}).hide(),

        value: g.text(0, 0, '')
                .attr(this.style.tooltip.value)
                .attr({'fill':         this.labelColor,
                       'stroke':       this.labelColor,
                       'stroke-width': this.textStrokeWidth,
                       'text-anchor':  'start'}).hide(),

        update: function (pointIndex) {
          var date = this.chart.pointDate(startDate, pointIndex);
          this.date.attr('text', this.chart.dayNames[date.getDay()] + ", " +
                                 this.chart.dateLabel(date));
          this.unit.attr('text', unit);
          this.value.attr('text', this.chart.formatNumber(values[pointIndex]));
        },

        width: function () {
          return Math.max(this.date.getBBox().width,
                          this.unit.getBBox().width +
                            this.value.getBBox().width + 5) + 14;
        },

        height: function () {
          return this.date.getBBox().height + this.value.getBBox().height + 10;
        },

        // In order to know the tooltip's x and y coordinates, we need to
        // supply the point index.
        x: function (pointX) {
          // By default, Google Analytics places the the tooltip to the left of
          // the point.
          var x = pointX - this.width() - 12;
          // However, if there's no room to place the tooltip to the left, it
          // places it to the right.
          if (x + this.chart.style.tooltip.box['stroke-width'] < 0) {
            x = pointX + 12;
          }
          return x;
        },

        y: function (pointY) {
          // By default, Google Analytics will align the tooltip's value text
          // with the point.
          var y = pointY - (2/3) * this.height();
          // However, if there's not enough room, the tooltip will shift up or
          // down to butt up against the baseline or top line.
          if (y + this.chart.style.tooltip.box['stroke-width'] < 0) {
            y = this.chart.style.tooltip.box['stroke-width'];
          } else if (y + this.height() + this.chart.style.tooltip.box['stroke-width'] > this.chart.plotHeight()) {
            y = this.chart.plotHeight() - this.height();
          }
          return y;
        },

        hide: function () {
          this.value.hide();
          this.unit.hide();
          this.date.hide();
          this.box.hide();
        },

        show: function () {
          this.value.show();
          this.unit.show();
          this.date.show();
          this.box.show();
        },

        moveTo: function (point, smooth) {
          var pos = {
            x: this.x(point.x),
            y: this.y(point.y),
            width: this.width(),
            height: this.height()        
          };
          var datePos  = {'x': pos.x + 7,
                          'y': pos.y + this.date.getBBox().height + 1};
          var unitPos  = {'x': pos.x + 7,
                          'y': pos.y + this.date.getBBox().height + this.value.getBBox().height - 1};
          var valuePos = {'x': pos.x + this.unit.getBBox().width + 10,
                          'y': pos.y + this.date.getBBox().height + this.value.getBBox().height - 1};
          if (smooth) { // glide smoothly to the target position
            this.box.animate(pos, 100);
            this.date.animate(datePos, 100);
            this.unit.animate(unitPos, 100);
            this.value.animate(valuePos, 100);
          } else { // just do a normal move
            this.box.attr(pos);
            this.date.attr(datePos);
            this.unit.attr(unitPos);
            this.value.attr(valuePos);
          }
        }
      };
    },

    connectEvents: function (graphDiv, tooltip, points) {
      var that = this;
      var xCoord = graphDiv.offset().left;
      var lastIndex = null;

      // Now, we want the points to be called out when we get near them.
      // Google Analytics will call out a point once you're closer to that point
      // than any other point.
      // 
      // I originally tried to achieve this effect with transparent boxes
      // centered on the point. It worked perfectly until I introduced the
      // tooltips. Since we don't have fine z-index control, the tooltips obscure
      // the invisible rectangles. This causes strange event problems. However,
      // there's an easier way that doesn't require extra elements. Which point
      // is selected is a simple function of the mouse's x position.
      //
      // This actually also makes creating and moving tooltips around much easier
      // because we don't have to deal with 2 functions (for mouseenter and
      // mouseleave) for each point. Instead, we can maintain all state in this
      // function.
      graphDiv.mousemove(function (e) {
        // First, determine which point we're closest to.
        // The simple way to do so is to convert the mouse's x value from graph
        // units into point units and then just round down.
        var pointIndex = Math.floor((e.pageX - xCoord) * (points.length / g.width));
        // The graphDiv might be wider than us and we might be receiving mouse
        // data we don't want to use. I'm not going to deal with the case of
        // the mouse data coming from above or below us, I want to find a way
        // to hook the event handler onto the SVG object instead.
        if (pointIndex >= 0 && pointIndex < points.length) {
          // We also don't want to do anything if we were already on this point.
          if (pointIndex !== lastIndex) {
            if (lastIndex !== null) {
              that.contract(points[lastIndex]);
            }
            that.dilate(points[pointIndex]);
            // Change the tooltip and bring it to this point.
            tooltip.update(pointIndex);
            if (lastIndex === null) {
              // Just make the tooltip appear.
              tooltip.moveTo(points[pointIndex], false);
              tooltip.show();
            } else {
              // Animate the tooltip's movement.
              tooltip.moveTo(points[pointIndex], true);
            }
            lastIndex = pointIndex;
          }
        } else {
          if (lastIndex !== null) {
            that.contract(points[lastIndex]);
            tooltip.hide();
          }
          lastIndex = null;
        }
      });
      graphDiv.mouseleave(function () {
        if (lastIndex !== null) {
          that.contract(points[lastIndex]);
          tooltip.hide();
        }
        lastIndex = null;
      });
    },

    // - values must be an array of values to plot
    // - startDate is the date that the values begin from. Note that there must be
    //   no holes in values.
    // - units is a description of the values given ("days", "views", etc.)
    plot: function (values, startDate, unit) {
      if (!(values.length > 0)) {
        throw "You must provide at least 1 value to plot.";
      }

      // Most helper functions need to know how to translate y values into
      // y coordinates on the canvas.
      var graphY = this.graphYHelper(this.max(values));

      // Additionally, we'll need to be able to translate value indexes into
      // x coordinates.
      var graphX = this.graphXHelper(values.length);

      // Now it's time to draw the time series chart.
      this.drawGrid(values, startDate, graphX, graphY);
      var points = this.plotPoints(values, graphX, graphY);

      // Finally, draw an upper line for the chart,
      g.path("M0,0h{0}", g.width).attr(this.style.topLine);

      // and hookup tooltip events
      var tooltip = this.tooltip(values, startDate, unit);
      this.connectEvents(graphDiv, tooltip, points);
    }
  };
};

// Things that could be improved:
//
// If we had a way to align text using its baseline, that would be great.
// Unfortunately, I've not found a way to do that. In the meantime, I've hacked
// around using the text's font size.
//
// Fix the Firefox warning "Unexpected value -Infinity parsing y attribute"