(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.1"; // 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 `sync`s 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){ 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){ 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); this.el.toggle(this.hideNotches, this.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_year`s // 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_year`s 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);