Jump To …

timeline-setter.js

(function($, undefined){

Expose TimelineSetter globally, so we can call Timeline.Timeline.boot() to kick off at any point.

  var TimelineSetter = window.TimelineSetter = (window.TimelineSetter || {});

Current version of TimelineSetter

  TimelineSetter.VERSION = "0.3.2";

Mixins

Each mixin operates on an object's prototype.

The observable mixin adds simple event notifications to the passed in object. Unlike other notification systems, when an event is triggered every callback bound to the object is invoked.

  var observable = function(obj){

Registers a callback function for notification at a later time.

    obj.bind = function(e, cb){
      var callbacks = (this._callbacks = this._callbacks || {});
      var list = (callbacks[e] = callbacks[e] || []);
      list.push(cb);
    };

Invoke all callbacks registered to the object with bind.

    obj.trigger = function(e){
      if(!this._callbacks) return;
      var list = this._callbacks[e];
      if(!list) return;
      for(var i = 0; i < list.length; i++) list[i].apply(this, arguments);
    };
  };

Each transformable contains two event listeners that handle moving associated DOM elements around the page.

  var transformable = function(obj){

Move the associated element a specified delta.

    obj.move = function(evtName, e){
      if (!e.deltaX) return;
      if (_.isUndefined(this.currOffset)) this.currOffset = 0;
      this.currOffset += e.deltaX;
      this.el.css({"left" : this.currOffset});
    };

The width for the Bar and CardContainer objects is set in percentages, in order to zoom the Timeline all that's needed is to increase or decrease the percentage width.

    obj.zoom = function(evtName, e){
      if (!e.width) return;
      this.el.css({ "width": e.width });
    };
  };

The queryable mixin scopes jQuery to a given container.

  var queryable = function(obj, container) {
    obj.$ = function(query) {
      return window.$(query, container);
    };
  };

Plugins

Each plugin operates on an instance of an object.

Check to see if we're on a mobile device.

  var touchInit = 'ontouchstart' in document;
  if (touchInit) $.event.props.push("touches");

The draggable plugin tracks changes in X offsets due to mouse movement or finger gestures and proxies associated events on a particular element. Most of this is inspired by polymaps.

  var draggable = function(obj){
    var drag;

Start tracking deltas due to a tap or single click.

    function mousedown(e){
      e.preventDefault();
      drag = {x: e.pageX};
      e.type = "dragstart";
      obj.el.trigger(e);
    }

The user is interacting; capture the offset and trigger a dragging event.

    function mousemove(e){
      if (!drag) return;
      e.preventDefault();
      e.type = "dragging";
      e = _.extend(e, {
        deltaX: (e.pageX || e.touches[0].pageX) - drag.x
      });
      drag = { x: (e.pageX || e.touches[0].pageX) };
      obj.el.trigger(e);
    }

We're done tracking the movement set drag back to null for the next event.

    function mouseup(e){
      if (!drag) return;
      drag = null;
      e.type = "dragend";
      obj.el.trigger(e);
    }

    if (!touchInit) {

Bind on mouse events if we have a mouse...

      obj.el.bind("mousedown", mousedown);

      $(document).bind("mousemove", mousemove);
      $(document).bind("mouseup", mouseup);
    } else {

otherwise capture touchstart events in order to simulate doubletap events.

      var last;
      obj.el.bind("touchstart", function(e) {
        var now = Date.now();
        var delta = now - (last || now);
        var type = delta > 0 && delta <= 250 ? "doubletap" : "tap";
        drag = {x: e.touches[0].pageX};
        last = now;
        obj.el.trigger($.Event(type));
      });

      obj.el.bind("touchmove", mousemove);
      obj.el.bind("touchend", mouseup);
    }
  };

Older versions of safari fire incredibly huge mousewheel deltas. We'll need to dampen the effects.

  var safari = /WebKit\/533/.test(navigator.userAgent);

The wheel plugin captures events triggered by mousewheel, and dampen the delta if running in Safari.

  var wheel = function(obj){
    function mousewheel(e){
      e.preventDefault();
      var delta = (e.wheelDelta || -e.detail);
      if (safari){
        var negative = delta < 0 ? -1 : 1;
        delta = Math.log(Math.abs(delta)) * negative * 2;
      }
      e.type = "scrolled";
      e.deltaX = delta;
      obj.el.trigger(e);
    }

    obj.el.bind("mousewheel DOMMouseScroll", mousewheel);
  };

Utilities

A utility class for storing the extent of the timeline.

  var Bounds = function(){
    this.min = +Infinity;
    this.max = -Infinity;
  };

  Bounds.prototype.extend = function(num){
    this.min = Math.min(num, this.min);
    this.max = Math.max(num, this.max);
  };

  Bounds.prototype.width = function(){
    return this.max - this.min;
  };

Translate a particular number from the current bounds to a given range.

  Bounds.prototype.project = function(num, max){
    return (num - this.min) / this.width() * max;
  };

Intervals is a particularly focused class to calculate even breaks based on the passed in Bounds.

  var Intervals = function(bounds, interval) {
    this.max = bounds.max;
    this.min = bounds.min;

    if(!interval || !this.INTERVALS[interval]) {
      var i = this.computeMaxInterval();
      this.maxInterval = this.INTERVAL_ORDER[i];
      this.idx = i;
    } else {
      this.maxInterval = interval;
      this.idx = _.indexOf(this.INTERVAL_ORDER, interval);
    }
  };

Format dates based for AP style. Pass an override function in the config object to override.

  Intervals.dateFormats = function(timestamp) {
    var d = new Date(timestamp);
    var defaults = {};
    var months   = ['Jan.', 'Feb.', 'March', 'April', 'May', 'June', 'July', 'Aug.', 'Sept.', 'Oct.', 'Nov.', 'Dec.'];
    var bigHours = d.getHours() > 12;
    var ampm     = " " + (d.getHours() >= 12 ? 'p.m.' : 'a.m.');


    defaults.month = months[d.getMonth()];
    defaults.year  = d.getFullYear();
    defaults.date  = defaults.month + " " + d.getDate() + ', ' + defaults.year;

    var hours;
    if(bigHours) {
      hours = d.getHours() - 12;
    } else {
      hours = d.getHours() > 0 ? d.getHours() : "12";
    }

    hours += ":" + padNumber(d.getMinutes());
    defaults.hourWithMinutes = hours + ampm;
    defaults.hourWithMinutesAndSeconds = hours + ":" + padNumber(d.getSeconds()) + ampm;

If we have user overrides, set them to defaults.

    return Intervals.formatter(d, defaults) || defaults;
  };

A utility function to format dates in AP Style.

  Intervals.dateStr = function(timestamp, interval) {
    var d = new Intervals.dateFormats(timestamp);
    switch (interval) {
      case "Millennium":
        return d.year;
      case "Quincentenary":
        return d.year;
      case "Century":
        return d.year;
      case "HalfCentury":
        return d.year;
      case "Decade":
        return d.year;
      case "Lustrum":
        return d.year;
      case "FullYear":
        return d.year;
      case "Month":
        return d.month + ', ' + d.year;
      case "Week":
        return d.date;
      case "Date":
        return d.date;
      case "Hours":
        return d.hourWithMinutes;
      case "HalfHour":
        return d.hourWithMinutes;
      case "QuarterHour":
        return d.hourWithMinutes;
      case "Minutes":
        return d.hourWithMinutes;
      case "Seconds":
        return d.hourWithMinutesAndSeconds;
    }
  };

  Intervals.prototype = {

Sane estimates of date ranges for the isAtLeastA test.

    INTERVALS : {
      Millennium    : 69379200000000,  // 2200 years is the trigger
      Quincentenary : 34689600000000, // 1100 years is the trigger
      Century       : 9460800000000,  // 300 years is the trigger
      HalfCentury   : 3153600000000,  // 100 years is the trigger
      Decade        : 315360000000,
      Lustrum       : 157680000000,
      FullYear      : 31536000000,
      Month         : 2592000000,
      Week          : 604800000,
      Date          : 86400000,
      Hours         : 3600000,
      HalfHour      : 1800000,
      QuarterHour   : 900000,
      Minutes       : 60000,
      Seconds       : 1000 // 1,000 millliseconds equals on second
    },

The order used when testing where exactly a timespan falls.

    INTERVAL_ORDER : [
        'Seconds',
        'Minutes',
        'QuarterHour',
        'HalfHour',
        'Hours',
        'Date',
        'Week',
        'Month',
        'FullYear',
        'Lustrum',
        'Decade',
        'HalfCentury',
        'Century',
        'Quincentenary',
        'Millennium'
    ],

The year adjustment used for supra-year intervals.

    YEAR_FRACTIONS : {
      Millenium     : 1000,
      Quincentenary : 500,
      Century       : 100,
      HalfCentury   : 50,
      Decade        : 10,
      Lustrum       : 5
    },

A test to find the appropriate range of intervals, for example if a range of timestamps only spans hours this will return true when called with "Hours".

    isAtLeastA : function(interval) {
      return ((this.max - this.min) > this.INTERVALS[interval]);
    },

Find the maximum interval we should use based on the estimates in INTERVALS.

    computeMaxInterval : function() {
      for (var i = 0; i < this.INTERVAL_ORDER.length; i++) {
        if (!this.isAtLeastA(this.INTERVAL_ORDER[i])) break;
      }
      return i - 1;
    },

Return the calculated maxInterval.

    getMaxInterval : function() {
      return this.INTERVALS[this.INTERVAL_ORDER[this.idx]];
    },

Floor the year to a given epoch.

    getYearFloor : function(date, intvl){
      var fudge = this.YEAR_FRACTIONS[intvl] || 1;
      return (date.getFullYear() / fudge | 0) * fudge;
    },

Return a date with the year set to the next interval in a given epoch.

    getYearCeil : function(date, intvl){
      if(this.YEAR_FRACTIONS[intvl]) return this.getYearFloor(date, intvl) + this.YEAR_FRACTIONS[intvl];
      return date.getFullYear();
    },

Return a Date object rounded down to the previous Sunday, a.k.a. the first day of the week.

    getWeekFloor: function(date) {
      thisDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
      thisDate.setDate(date.getDate() - date.getDay());
      return thisDate;
    },

Return a Date object rounded up to the next Sunday, a.k.a. the start of the next week.

    getWeekCeil: function(date) {
      thisDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
      thisDate.setDate(thisDate.getDate() + (7 - date.getDay()));
      return thisDate;
    },

Return the half of the hour this date belongs to. Anything before 30 min. past the hour comes back as zero. Anything after comes back as 30.

    getHalfHour: function(date) {
      return date.getMinutes() > 30 ? 30 : 0;
    },

Return the quarter of the hour this date belongs to. Anything before 15 min. past the hour comes back as zero; 15-30 comes back as 15; 30-45 as 30; 45-60 as 45.

    getQuarterHour: function(date) {
      var minutes = date.getMinutes();
      if (minutes < 15) return 0;
      if (minutes < 30) return 15;
      if (minutes < 45) return 30;
      return 45;
    },

Zero out a date from the current interval down to seconds.

    floor : function(ts){
      var date  = new Date(ts);
      var intvl = this.INTERVAL_ORDER[this.idx];
      var idx   = this.idx > _.indexOf(this.INTERVAL_ORDER,'FullYear') ?
                  _.indexOf(this.INTERVAL_ORDER,'FullYear') :
                  idx;

Zero the special extensions, and adjust as idx necessary.

      date.setFullYear(this.getYearFloor(date, intvl));
      switch(intvl){
        case 'Week':
          date.setDate(this.getWeekFloor(date).getDate());
          idx = _.indexOf(this.INTERVAL_ORDER, 'Week');
        case 'HalfHour':
          date.setMinutes(this.getHalfHour(date));
        case 'QuarterHour':
          date.setMinutes(this.getQuarterHour(date));
      }

Zero out the rest

      while(idx--){
        intvl = this.INTERVAL_ORDER[idx];
        if (!(_.include(['Week', 'HalfHour', 'QuarterHour'].concat(_.keys(this.YEAR_FRACTIONS)), intvl)))
          date["set" + intvl](intvl === "Date" ? 1 : 0);
      }

      return date.getTime();
    },

Find the next date based on the past in timestamp.

    ceil : function(ts){
      var date = new Date(this.floor(ts));
      var intvl = this.INTERVAL_ORDER[this.idx];

      date.setFullYear(this.getYearCeil(date, intvl));
      switch(intvl){
        case 'Week':
          date.setTime(this.getWeekCeil(date).getTime());
          break;
        case 'HalfHour':
          date.setMinutes(this.getHalfHour(date) + 30);
          break;
        case 'QuarterHour':
          date.setMinutes(this.getQuarterHour(date) + 15);
          break;
        default:
          if (!(_.include(['Week', 'HalfHour', 'QuarterHour'].concat(_.keys(this.YEAR_FRACTIONS)), intvl)))
            date["set" + intvl](date["get" + intvl]() + 1);
      }
      return date.getTime();
    },

The actual difference in timespans accounting for time oddities like different length months and leap years.

    span : function(ts){
      return this.ceil(ts) - this.floor(ts);
    },

Calculate and return a list of human formatted strings and raw timestamps.

    getRanges : function() {
      if (this.intervals) return this.intervals;
      this.intervals = [];
      for (var i = this.floor(this.min); i <= this.ceil(this.max); i += this.span(i)) {
        this.intervals.push({
          human     : Intervals.dateStr(i, this.maxInterval),
          timestamp : i
        });
      }
      return this.intervals;
    }
  };

Handy dandy function to bind a listener on multiple events. For example, Bar and CardContainer are bound like so on "move" and "zoom":

 sync(this.bar, this.cardCont, "move", "zoom");
  var sync = function(origin, listener){
    var events = Array.prototype.slice.call(arguments, 2);
    _.each(events, function(ev){
      origin.bind(ev, function(){ listener[ev].apply(listener, arguments); });
    });
  };

Simple function to strip suffixes like "px" and return a clean integer for use.

  var cleanNumber = function(str){
    return parseInt(str.replace(/^[^+\-\d]?([+\-]?\d+)?.*$/, "$1"), 10);
  };

Zero pad a number less than 10 and return a 2 digit value.

  var padNumber = function(number) {
    return (number < 10 ? '0' : '') + number;
  };

A quick and dirty hash manager for setting and getting values from window.location.hash

  var hashStrip = /^#*/;
  var history = {
    get : function(){
      return window.location.hash.replace(hashStrip, "");
    },

    set : function(url){
      window.location.hash = url;
    }
  };

Every new Series gets new color. If there are too many series the remaining series will be a simple gray.

These colors can be styled like such in timeline-setter.css, where the numbers 1-9 cycle through in that order:

 .TS-notch_color_1,.TS-series_legend_swatch_1 {
   background: #065718 !important;
 }
 .TS-css_arrow_color_1 {
   border-bottom-color:#065718 !important;
 }
 .TS-item_color_1 {
  border-top:1px solid #065718 !important;
 }

The default color will fall through to what is styled with .TS-foo_color_default

  var curColor = 1;
  var color = function(){
    var chosen;
    if (curColor < 10) {
      chosen = curColor;
      curColor += 1;
    } else {
      chosen = "default";
    }
    return chosen;
  };

Models

Initialize a Timeline object in a container element specified in the config object.

  var Timeline = TimelineSetter.Timeline = function(data, config) {
    _.bindAll(this, 'render', 'setCurrentTimeline');
    this.data = data.sort(function(a, b){ return a.timestamp - b.timestamp; });
    this.bySid    = {};
    this.cards    = [];
    this.series   = [];
    this.config   = config;
    this.config.container = this.config.container || "#timeline";

Override default date formats by writing a formatter function that returns an object containing all the formats you'd like to override. Pass in d which is a date object, and defaults, which are the formatters we override.

 formatter : function(d, defaults) {
   defaults.months = ['enero', 'febrero', 'marzo',
                     'abril', 'mayo', 'junio', 'julio',
                     'agosto', 'septiembre', 'octubre',
                     'noviembre', 'diciembre'];
   return defaults;
 }
    Intervals.formatter = this.config.formatter || function(d, defaults) { return defaults; };
  };
  observable(Timeline.prototype);

  Timeline.prototype = _.extend(Timeline.prototype, {

The main kickoff point for rendering the timeline. The Timeline constructor takes a JSON array of card representations and then builds series, calculates intervals syncs the Bar and CardContainer objects.

    render : function() {
      var that = this;

create this.$ from queryable mixin.

      queryable(this, this.config.container);

Stick the barebones HTML structure in the dom, so we can play with it.

      $(this.config.container).html(JST.timeline());

      this.bounds   = new Bounds();
      this.bar      = new Bar(this);
      this.cardCont = new CardScroller(this);
      this.createSeries(this.data);
      var range = new Intervals(this.bounds, this.config.interval);
      this.intervals = range.getRanges();
      this.bounds.extend(this.bounds.min - range.getMaxInterval() / 2);
      this.bounds.extend(this.bounds.max + range.getMaxInterval() / 2);
      this.bar.render();
      sync(this.bar, this.cardCont, "move", "zoom");
      this.trigger('render');

      new Zoom("in", this);
      new Zoom("out", this);
      this.chooseNext = new Chooser("next", this);
      this.choosePrev = new Chooser("prev", this);
      if (!this.$(".TS-card_active").is("*")) this.chooseNext.click();

Bind a click handler to this timeline container that sets it as as the global current timeline for key presses.

      $(this.config.container).bind('click', this.setCurrentTimeline);

      this.trigger('load');
    },

Set a global with the current timeline, mostly for key presses.

    setCurrentTimeline : function() {
      TimelineSetter.currentTimeline = this;
    },

Loop through the JSON and add each element to a series.

    createSeries : function(series){
      for(var i = 0; i < series.length; i++)
        this.add(series[i]);
    },

If a particular element in the JSON array mentions a series that's not in the bySid object add it. Then add a card to the Series and extend the global bounds.

    add : function(card){
      if (!(card.series in this.bySid)){
        this.bySid[card.series] = new Series(card, this);
        this.series.push(this.bySid[card.series]);
      }
      var series = this.bySid[card.series];
      var crd = series.add(card);

      this.bounds.extend(series.max());
      this.bounds.extend(series.min());

      this.trigger('cardAdd', card, crd);
    }
  });

Views

The main interactive element in the timeline is .TS-notchbar. Behind the scenes Bar handles the moving and zooming behaviours through the draggable and wheel plugins.

  var Bar = function(timeline) {
    var that = this;
    this.timeline = timeline;

    this.el = this.timeline.$(".TS-notchbar");
    this.el.css({ "left": 0 });
    draggable(this);
    wheel(this);
    _.bindAll(this, "moving", "doZoom");
    this.el.bind("dragging scrolled", this.moving);
    this.el.bind("doZoom", this.doZoom);
    this.el.bind("dblclick doubletap", function(e){
      e.preventDefault();
      that.timeline.$(".TS-zoom_in").click();
    });
  };
  observable(Bar.prototype);
  transformable(Bar.prototype);

  Bar.prototype = _.extend(Bar.prototype, {

Every time the Bar is moved, it calculates whether the proposed movement will move the .TS-notchbar off of its parent. If so, it recaculates deltaX to be a more correct value.

    moving : function(e){
      var parent  = this.el.parent();
      var pOffset = parent.offset().left;
      var offset  = this.el.offset().left;
      var width   = this.el.width();
      if (_.isUndefined(e.deltaX)) e.deltaX = 0;

      if (offset + width + e.deltaX < pOffset + parent.width())
        e.deltaX = (pOffset + parent.width()) - (offset + width);
      if (offset + e.deltaX > pOffset)
        e.deltaX = pOffset - offset;

      this.trigger("move", e);
      this.timeline.trigger("move", e); // for API
      this.move("move", e);
    },

As the timeline zooms, the Bar tries to keep the current notch (i.e. .TS-notch_active) as close to its original position as possible. There's a slight bug here because the timeline zooms and then moves the bar to correct for this behaviour, and in future versions we'll fix this.

    doZoom : function(e, width){
      var that = this;
      var notch = this.timeline.$(".TS-notch_active");
      var getCur = function() {
        return notch.length > 0 ? notch.position().left : 0;
      };
      var curr = getCur();

      this.el.animate({"width": width + "%"}, {
        step: function(current, fx) {
          var e = $.Event("dragging");
          var delta = curr - getCur();
          e.deltaX = delta;
          that.moving(e);
          curr = getCur();
          e = $.Event("zoom");
          e.width = current + "%";
          that.trigger("zoom", e);
        }
      });
    },

When asked to render the bar places the appropriate timestamp notches inside .TS-notchbar.

    render : function(){
      var intervals = this.timeline.intervals;
      var bounds    = this.timeline.bounds;

      for (var i = 0; i < intervals.length; i++) {
        var html = JST.year_notch({'timestamp' : intervals[i].timestamp, 'human' : intervals[i].human });
        this.el.append($(html).css("left", bounds.project(intervals[i].timestamp, 100) + "%"));
      }
    }
  });

The CardScroller mirrors the moving and zooming of the Bar and is the canvas where individual cards are rendered.

  var CardScroller = function(timeline){
    this.el = timeline.$(".TS-card_scroller_inner");
  };
  observable(CardScroller.prototype);
  transformable(CardScroller.prototype);

Each Series picks a unique color and keeps an array of Cards.

  var Series = function(series, timeline) {
    this.timeline = timeline;
    this.name     = series.series;
    this.color    = this.name.length > 0 ? color() : "default";
    this.cards    = [];
    _.bindAll(this, "render", "showNotches", "hideNotches");
    this.timeline.bind("render", this.render);
  };
  observable(Series.prototype);

  Series.prototype = _.extend(Series.prototype, {

Create and add a particular card to the cards array.

    add : function(card){
      var crd = new Card(card, this);
      this.cards.push(crd);
      return crd;
    },

The comparing function for max and min.

    _comparator : function(crd){
      return crd.timestamp;
    },

Inactivate this series legend item and trigger a hideNotch event.

    hideNotches : function(e){
      if(e) e.preventDefault();
      this.el.addClass("TS-series_legend_item_inactive");
      this.trigger("hideNotch");
    },

Activate the legend item and trigger the showNotch event.

    showNotches : function(e){
      if(e) e.preventDefault();
      this.el.removeClass("TS-series_legend_item_inactive");
      this.trigger("showNotch");
    },

Create and append the label to .TS-series_nav_container and bind up hideNotches and showNotches.

    render : function(e){
      if (this.name.length === 0) return;
      this.el = $(JST.series_legend(this));
      this.timeline.$(".TS-series_nav_container").append(this.el);
      var counter = 0, that = this;
      this.el.on("click", function(){
        counter++;
        if(counter % 2)
          that.hideNotches();
        else
          that.showNotches();
      });
    }
  });

Proxy to underscore for min and max.

  _(["min", "max"]).each(function(key){
    Series.prototype[key] = function() {
      return _[key].call(_, this.cards, this._comparator).get("timestamp");
    };
  });

Every Card handles a notch div which is immediately appended to the Bar and a .TS-card_container which is lazily rendered.

  var Card = function(card, series) {
    this.series = series;
    this.timeline = this.series.timeline;
    card = _.clone(card);
    this.timestamp = card.timestamp;
    this.attributes = card;
    this.attributes.topcolor = series.color;
    _.bindAll(this, "render", "activate", "flip", "setPermalink", "toggleNotch");
    this.series.bind("hideNotch", this.toggleNotch);
    this.series.bind("showNotch", this.toggleNotch);
    this.timeline.bind("render", this.render);
    this.timeline.bar.bind("move", this.flip);
    this.id = [
      this.get('timestamp'),
      this.get('description').split(/ /)[0].replace(/[^a-zA-Z\-]/g,"")
    ].join("-");
    this.timeline.cards.push(this);
    this.template = window.JST.card;
  };

  Card.prototype = _.extend(Card.prototype, {

Get a particular attribute by key.

    get : function(key){
      return this.attributes[key];
    },

When each Card is rendered via a render event, it appends a notch to the Bar and binds a click handler so it can be activated. if the Card's id is currently selected via window.location.hash it's activated.

    render : function(){
      this.offset = this.timeline.bounds.project(this.timestamp, 100);
      var html = JST.notch(this.attributes);
      this.notch = $(html).css({"left": this.offset + "%"});
      this.timeline.$(".TS-notchbar").append(this.notch);
      this.notch.click(this.activate);
      if (history.get() === this.id) this.activate();
    },

As the Bar moves the current card checks to see if it's outside the viewport, if it is the card is flipped so as to be visible for the longest period of time. The magic number here (7) is half the width of the css arrow.

    flip : function() {
      if (!this.el || !this.el.is(":visible")) return;
      var rightEdge   = this.$(".TS-item").offset().left + this.$(".TS-item").width();
      var tRightEdge  = this.timeline.$(".timeline_setter").offset().left + this.timeline.$(".timeline_setter").width();
      var margin      = this.el.css("margin-left") === this.originalMargin;
      var flippable   = this.$(".TS-item").width() < this.timeline.$(".timeline_setter").width() / 2;
      var offTimeline = (this.el.offset().left - this.el.parent().offset().left) - this.$(".TS-item").width() < 0;

If the card's right edge is more than the timeline's right edge and it's never been flipped before and it won't go off the timeline when flipped. We'll flip it.

      if (tRightEdge - rightEdge < 0 && margin && !offTimeline) {
        this.el.css({"margin-left": -(this.$(".TS-item").width() + 7)});
        this.$(".TS-css_arrow").css({"left" : this.$(".TS-item").width()});

Otherwise, if the card is off the left side of the timeline and we have flipped it before and the card's width is less than half of the width of the whole timeline, we'll flip it to the default position.

      } else if (this.el.offset().left - this.timeline.$(".timeline_setter").offset().left < 0 && !margin && flippable) {
        this.el.css({"margin-left": this.originalMargin});
        this.$(".TS-css_arrow").css({"left": 0});
      }
    },

The first time a card is activated it renders its template and appends its element to the Bar. After doing so it sets the width if .TS-item_label and moves the Bar if its element outside the visible portion of the timeline.

    activate : function(e){
      var that = this;
      this.hideActiveCard();
      if (!this.el) {
        this.el = $(this.template({card: this}));

create a this.$ scoped to its card.

        queryable(this, this.el);

        this.el.css({"left": this.offset + "%"});
        this.timeline.$(".TS-card_scroller_inner").append(this.el);
        this.originalMargin = this.el.css("margin-left");
        this.el.delegate(".TS-permalink", "click", this.setPermalink);

Reactivate if there are images in the html so we can recalculate widths and position accordingly.

        this.timeline.$("img").load(this.activate);
      }

      this.el.show().addClass(("TS-card_active"));
      this.notch.addClass("TS-notch_active");
      this.setWidth();

In the case that the card is outside the bounds the wrong way when it's flipped, we'll take care of it here before we move the actual card.

      this.flip();
      this.move();
      this.series.timeline.trigger("cardActivate", this.attributes);
    },

For Internet Explorer each card sets the width of.TS-item_label to the maximum width of the card's children, or if that is less than the .TS-item_year element's width, .TS-item_label gets .TS-item_years width. Which is a funny way of saying, if you'd like to set the width of the card as a whole, fiddle with .TS-item_years width.

    setWidth : function(){
      var that = this;
      var max = _.max(_.toArray(this.$(".TS-item_user_html").children()), function(el){ return that.$(el).width(); });
      if (this.$(max).width() > this.$(".TS-item_year").width()) {
        this.$(".TS-item_label").css("width", this.$(max).width());
      } else {
        this.$(".TS-item_label").css("width", this.$(".TS-item_year").width());
      }
    },

Move the Bar if the card is outside the visible region on activation.

    move : function() {
      var e = $.Event('moving');
      var offset  = this.$(".TS-item").offset();
      var toffset = this.timeline.$(".timeline_setter").offset();
      if (offset.left < toffset.left) {
        e.deltaX = toffset.left - offset.left + cleanNumber(this.$(".TS-item").css("padding-left"));
        this.timeline.bar.moving(e);
      } else if (offset.left + this.$(".TS-item").outerWidth() > toffset.left + this.timeline.$(".timeline_setter").width()) {
        e.deltaX = toffset.left + this.timeline.$(".timeline_setter").width() - (offset.left + this.$(".TS-item").outerWidth());
        this.timeline.bar.moving(e);
      }
    },

The click handler to set the current hash to the Card's id.

    setPermalink : function() {
      history.set(this.id);
    },

Globally hide any cards with TS-card_active.

    hideActiveCard : function() {
      this.timeline.$(".TS-card_active").removeClass("TS-card_active").hide();
      this.timeline.$(".TS-notch_active").removeClass("TS-notch_active");
    },

An event listener to toggle this notch on and off via Series.

    toggleNotch : function(e){
      switch (e) {
        case "hideNotch":
          this.notch.hide().removeClass("TS-notch_active").addClass("TS-series_inactive");
          if (this.el) this.el.hide();
          return;
        case "showNotch":
          this.notch.removeClass("TS-series_inactive").show();
      }
    }

  });

Simple inheritance helper for Controls.

  var ctor = function(){};
  var inherits = function(child, parent){
    ctor.prototype  = parent.prototype;
    child.prototype = new ctor();
    child.prototype.constructor = child;
  };

Controls

Each control is basically a callback wrapper for a given DOM element.

  var Control = function(direction, timeline){
    this.timeline = timeline;
    this.direction = direction;
    this.el = this.timeline.$(this.prefix + direction);
    var that = this;
    this.el.bind('click', function(e) { e.preventDefault(); that.click(e);});
  };

Each Zoom control adjusts the curZoom when clicked.

  var curZoom = 100;
  var Zoom = function(direction, timeline) {
    Control.apply(this, arguments);
  };
  inherits(Zoom, Control);

  Zoom.prototype = _.extend(Zoom.prototype, {
    prefix : ".TS-zoom_",

Adjust the curZoom up or down by 100 and trigger a doZoom event on .TS-notchbar

    click : function() {
      curZoom += (this.direction === "in" ? +100 : -100);
      if (curZoom >= 100) {
        this.timeline.$(".TS-notchbar").trigger('doZoom', [curZoom]);
      } else {
        curZoom = 100;
      }
    }
  });

Each Chooser activates the next or previous notch.

  var Chooser = function(direction, timeline) {
    Control.apply(this, arguments);
    this.notches = this.timeline.$(".TS-notch");
  };
  inherits(Chooser, Control);

  Chooser.prototype = _.extend(Control.prototype, {
    prefix: ".TS-choose_",

Figure out which notch to activate and do so by triggering a click on that notch.

    click: function(e){
      var el;
      var notches    = this.notches.not(".TS-series_inactive");
      var curCardIdx = notches.index(this.timeline.$(".TS-notch_active"));
      var numOfCards = notches.length;
      if (this.direction === "next") {
        el = (curCardIdx < numOfCards ? notches.eq(curCardIdx + 1) : false);
      } else {
        el = (curCardIdx > 0 ? notches.eq(curCardIdx - 1) : false);
      }
      if (!el) return;
      el.trigger("click");
    }
  });

JS API

The TimelineSetter JS API allows you to listen to certain timeline events, and activate cards programmatically. To take advantage of it, assign the timeline boot function to a variable like so:

var currentTimeline = TimelineSetter.Timeline.boot(
  [data], {config}
);

then call methods on the currentTimeline.api object

currentTimeline.api.onLoad(function() {
  console.log("I'm ready");
});
  TimelineSetter.Api = function(timeline) {
    this.timeline = timeline;
  };

  TimelineSetter.Api.prototype = _.extend(TimelineSetter.Api.prototype, {

Register a callback for when the timeline is loaded

    onLoad : function(cb) {
      this.timeline.bind('load', cb);
    },

Register a callback for when a card is added to the timeline Callback has access to the event name and the card object

    onCardAdd : function(cb) {
      this.timeline.bind('cardAdd', cb);
    },

Register a callback for when a card is activated. Callback has access to the event name and the card object

    onCardActivate : function(cb) {
      this.timeline.bind('cardActivate', cb);
    },

Register a callback for when the bar is moved or zoomed. Be careful with this one: Bar move events can be fast and furious, especially with scroll wheels in Safari.

    onBarMove : function(cb) {

Bind a 'move' event to the timeline, because at this point, timeline.bar isn't available yet. To get around this, we'll trigger the bar's timeline's move event when the bar is moved.

      this.timeline.bind('move', cb);
    },

Show the card matching a given timestamp Right now, timelines only support one card per timestamp

    activateCard : function(timestamp) {
      _(this.timeline.cards).detect(function(card) { return card.timestamp === timestamp; }).activate();
    }
  });

Global TS keydown function to bind key events to the current global currentTimeline.

  TimelineSetter.bindKeydowns = function() {
    $(document).bind('keydown', function(e) {
      if (e.keyCode === 39) {
         TimelineSetter.currentTimeline.chooseNext.click();
      } else if (e.keyCode === 37) {
        TimelineSetter.currentTimeline.choosePrev.click();
      } else {
        return;
      }
    });
  };

Finally, let's create the whole timeline. Boot is exposed globally via TimelineSetter.Timeline.boot() which takes the JSON generated by the timeline-setter binary as its first argument, and a config hash as its second. The config hash looks for a container element, an interval for interval notches and a formatter function for dates. All of these are optional.

We also initialize a new API object for each timeline, accessible via the timeline variable's api method (e.g. currentTimeline.api) and look for how many timelines are globally on the page for keydown purposes. We'll only bind keydowns globally if there's only one timeline on the page.

  Timeline.boot = function(data, config) {
    var timeline = TimelineSetter.timeline = new Timeline(data, config || {});
    var api      = new TimelineSetter.Api(timeline);

    if (!TimelineSetter.pageTimelines) {
      TimelineSetter.currentTimeline = timeline;
      TimelineSetter.bindKeydowns();
    }

    TimelineSetter.pageTimelines = TimelineSetter.pageTimelines ? TimelineSetter.pageTimelines += 1 : 1;

    $(timeline.render);

    return {
      timeline : timeline,
      api      : api
    };
  };

})(jQuery);