timeline-setter.js | |
---|---|
(function(){ | |
Expose | var TimelineSetter = window.TimelineSetter = (window.TimelineSetter || {}); |
MixinsEach mixin operates on an object's | |
The | var observable = function(obj){ |
Registers a callback function for notification at a later time. | obj.bind = function(cb){
this._callbacks = this._callbacks || [];
this._callbacks.push(cb);
}; |
Invoke all callbacks registered to the object with | obj.trigger = function(){
if (!this._callbacks) return;
for(var i = 0; callback = this._callbacks[i]; i++)
callback.apply(this, arguments);
};
}; |
Each | var transformable = function(obj){ |
Move the associated element a specified delta. Of note: because events aren't scoped by key, TimelineSetter uses plain old jQuery events for message passing. So each registered callback first checks to see if the event fired matches the event it is listening for. | obj.move = function(e){
if (!e.type === "move" || !e.deltaX) return;
if (_.isUndefined(this.currOffset)) this.currOffset = 0;
this.currOffset += e.deltaX;
this.el.css({"left" : this.currOffset});
}; |
The width for the | obj.zoom = function(e){
if (!e.type === "zoom") return;
this.el.css({ "width": e.width });
};
}; |
PluginsEach 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) jQuery.event.props.push("touches"); |
The | 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 | 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 | 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 | 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 | 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;
}; |
| var Intervals = function(bounds) {
this.max = bounds.max;
this.min = bounds.min;
this.setMaxInterval();
}; |
An object containing human translations for date indexes. | Intervals.HUMAN_DATES = {
months : ['Jan.', 'Feb.', 'March', 'April', 'May', 'June', 'July', 'Aug.', 'Sept.', 'Oct.', 'Nov.', 'Dec.']
}; |
A utility function to format dates in AP Style. | Intervals.dateStr = function(timestamp, interval) {
var d = new Date(timestamp);
var dYear = d.getFullYear();
var dMonth = Intervals.HUMAN_DATES.months[d.getMonth()];
var dDate = dMonth + " " + d.getDate() + ', ' + dYear;
var bigHours = d.getHours() > 12;
var isPM = d.getHours() >= 12;
var dHourWithMinutes = (bigHours ? d.getHours() - 12 : (d.getHours() > 0 ? d.getHours() : "12")) + ":" + padNumber(d.getMinutes()) + " " + (isPM ? 'p.m.' : 'a.m.');
var dHourMinuteSecond = dHourWithMinutes + ":" + padNumber(d.getSeconds());
switch (interval) {
case "FullYear":
return dYear;
case "Month":
return dMonth + ', ' + dYear;
case "Date":
return dDate;
case "Hours":
return dHourWithMinutes;
case "Minutes":
return dHourWithMinutes;
case "Seconds":
return dHourMinuteSecond;
}
};
Intervals.prototype = { |
Sane estimates of date ranges for the | INTERVALS : {
FullYear : 31536000000,
Month : 2592000000,
Date : 86400000,
Hours : 3600000,
Minutes : 60000,
Seconds : 1000
}, |
The order used when testing where exactly a timespan falls. | INTERVAL_ORDER : ['Seconds','Minutes','Hours','Date','Month','FullYear'], |
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 | isAtLeastA : function(interval) {
return ((this.max - this.min) > this.INTERVALS[interval]);
}, |
Find the maximum interval we should use based on the estimates in | setMaxInterval : function() {
for (var i = 0; i < this.INTERVAL_ORDER.length; i++)
if (!this.isAtLeastA(this.INTERVAL_ORDER[i])) break;
this.maxInterval = this.INTERVAL_ORDER[i - 1];
this.idx = i - 1;
}, |
Return the calculated | getMaxInterval : function() {
return this.INTERVALS[this.INTERVAL_ORDER[this.idx]];
}, |
Zero out a date from the current interval down to seconds. | floor : function(ts){
var idx = this.idx;
var date = new Date(ts);
while(idx--){
var intvl = this.INTERVAL_ORDER[idx];
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["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,
| var sync = function(origin, listener){
var events = Array.prototype.slice.call(arguments, 2);
_.each(events, function(ev){
origin.bind(function(e){
if (e.type === ev && listener[ev])
listener[ev](e);
});
});
}; |
Get a template from the DOM and return a compiled function. | var template = function(query) {
return _.template($(query).html());
}; |
Simple function to strip suffixes like | 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
| var hashStrip = /^#*/;
var history = {
get : function(){
return window.location.hash.replace(hashStrip, "");
},
set : function(url){
window.location.hash = url;
}
}; |
Every new | |
These colors can be styled like such in timeline-setter.css, where the numbers 1-9 cycle through in that order:
The default color will fall through to what is styled with
| var curColor = 1;
var color = function(){
var chosen;
if (curColor < 10) {
chosen = curColor;
curColor += 1;
} else {
chosen = "default";
}
return chosen;
}; |
Models | |
The main kickoff point for rendering the timeline. The | var Timeline = TimelineSetter.Timeline = function(data) {
data = data.sort(function(a, b){ return a.timestamp - b.timestamp; });
this.bySid = {};
this.series = [];
this.bounds = new Bounds();
this.bar = new Bar(this);
this.cardCont = new CardScroller(this);
this.createSeries(data);
var range = new Intervals(this.bounds);
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");
var e = $.Event("render");
this.trigger(e);
};
observable(Timeline.prototype);
Timeline.prototype = _.extend(Timeline.prototype, { |
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 | 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];
series.add(card);
this.bounds.extend(series.max());
this.bounds.extend(series.min());
}
}); |
Views | |
The main interactive element in the timeline is | var Bar = function(timeline) {
this.el = $(".TS-notchbar");
this.el.css({ "left": 0 });
this.timeline = timeline;
draggable(this);
wheel(this);
_.bindAll(this, "moving", "doZoom");
this.el.bind("dragging scrolled", this.moving);
this.el.bind("doZoom", this.doZoom);
this.template = template("#TS-year_notch_tmpl");
this.el.bind("dblclick doubletap", function(e){
e.preventDefault();
$(".TS-zoom_in").click();
});
};
observable(Bar.prototype);
transformable(Bar.prototype);
Bar.prototype = _.extend(Bar.prototype, { |
Every time the | 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;
e.type = "move";
this.trigger(e);
this.move(e);
}, |
As the timeline zooms, the | doZoom : function(e, width){
var that = this;
var notch = $(".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(e);
}
});
}, |
When asked to render the bar places the appropriate timestamp notches
inside | render : function(){
var intervals = this.timeline.intervals;
var bounds = this.timeline.bounds;
for (var i = 0; i < intervals.length; i++) {
var html = this.template({'timestamp' : intervals[i].timestamp, 'human' : intervals[i].human });
this.el.append($(html).css("left", bounds.project(intervals[i].timestamp, 100) + "%"));
}
}
}); |
The | var CardScroller = function(timeline){
this.el = $("#TS-card_scroller_inner");
};
observable(CardScroller.prototype);
transformable(CardScroller.prototype); |
Each | 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.template = template("#TS-series_legend_tmpl");
this.timeline.bind(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);
}, |
The comparing function for | _comparator : function(crd){
return crd.timestamp;
}, |
Inactivate this series legend item and trigger a | hideNotches : function(e){
e.preventDefault();
this.el.addClass("TS-series_legend_item_inactive");
this.trigger($.Event("hideNotch"));
}, |
Activate the legend item and trigger the | showNotches : function(e){
e.preventDefault();
this.el.removeClass("TS-series_legend_item_inactive");
this.trigger($.Event("showNotch"));
}, |
Create and append the label to | render : function(e){
if (!e.type === "render") return;
if (this.name.length === 0) return;
this.el = $(this.template(this));
$(".TS-series_nav_container").append(this.el);
this.el.toggle(this.hideNotches, this.showNotches);
}
}); |
Proxy to underscore for | _(["min", "max"]).each(function(key){
Series.prototype[key] = function() {
return _[key].call(_, this.cards, this._comparator).get("timestamp");
};
}); |
Every | var Card = function(card, series) {
this.series = series;
var card = _.clone(card);
this.timestamp = card.timestamp;
this.attributes = card;
this.attributes.topcolor = series.color;
this.template = template("#TS-card_tmpl");
this.ntemplate = template("#TS-notch_tmpl");
_.bindAll(this, "render", "activate", "flip", "setPermalink", "toggleNotch");
this.series.bind(this.toggleNotch);
this.series.timeline.bind(this.render);
this.series.timeline.bar.bind(this.flip);
this.id = [
this.get('timestamp'),
this.get('description').split(/ /)[0].replace(/[^a-zA-Z\-]/g,"")
].join("-");
};
Card.prototype = { |
Get a particular attribute by key. | get : function(key){
return this.attributes[key];
}, |
A version of | $ : function(query){
return $(query, this.el);
}, |
When each | render : function(e){
if (!e.type === "render") return;
this.offset = this.series.timeline.bounds.project(this.timestamp, 100);
var html = this.ntemplate(this.attributes);
this.notch = $(html).css({"left": this.offset + "%"});
$(".TS-notchbar").append(this.notch);
this.notch.click(this.activate);
if (history.get() === this.id) this.activate();
}, |
As the | flip : function(e) {
if (e.type !== "move" || !this.el || !this.el.is(":visible")) return;
var rightEdge = this.$(".TS-item").offset().left + this.$(".TS-item").width();
var tRightEdge = $("#timeline_setter").offset().left + $("#timeline_setter").width();
var margin = this.el.css("margin-left") === this.originalMargin;
var flippable = this.$(".TS-item").width() < $("#timeline_setter").width() / 2;
if (tRightEdge - rightEdge < 0 && margin) {
this.el.css({"margin-left": -(this.$(".TS-item").width() + 7)});
this.$(".TS-css_arrow").css({"left" : this.$(".TS-item").width()});
} else if (this.el.offset().left - $("#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 | activate : function(e){
this.hideActiveCard();
if (!this.el) {
this.el = $(this.template({card: this}));
this.el.css({"left": this.offset + "%"});
$("#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.$("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($.Event("move"));
this.move();
},
|
For Internet Explorer each card sets the width of | setWidth : function(){
var max = _.max(_.toArray(this.$(".TS-item_user_html").children()), function(el){ return $(el).width(); });
if ($(max).width() > this.$(".TS-item_year").width()) {
this.$(".TS-item_label").css("width", $(max).width());
} else {
this.$(".TS-item_label").css("width", this.$(".TS-item_year").width());
}
}, |
Move the | move : function() {
var e = $.Event('moving');
var offset = this.$(".TS-item").offset();
var toffset = $("#timeline_setter").offset();
if (offset.left < toffset.left) {
e.deltaX = toffset.left - offset.left + cleanNumber(this.$(".TS-item").css("padding-left"));
this.series.timeline.bar.moving(e);
} else if (offset.left + this.$(".TS-item").outerWidth() > toffset.left + $("#timeline_setter").width()) {
e.deltaX = toffset.left + $("#timeline_setter").width() - (offset.left + this.$(".TS-item").outerWidth());
this.series.timeline.bar.moving(e);
}
}, |
The click handler to set the current hash to the | setPermalink : function() {
history.set(this.id);
}, |
Globally hide any cards with | hideActiveCard : function() {
$(".TS-card_active").removeClass("TS-card_active").hide();
$(".TS-notch_active").removeClass("TS-notch_active");
}, |
An event listener to toggle this notche on and off via | toggleNotch : function(e){
switch (e.type) {
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 | 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){
this.direction = direction;
this.el = $(this.prefix + direction);
var that = this;
this.el.bind('click', function(e) { e.preventDefault(); that.click(e);});
}; |
Each | var curZoom = 100;
var Zoom = function(direction) {
Control.apply(this, arguments);
};
inherits(Zoom, Control);
Zoom.prototype = _.extend(Zoom.prototype, {
prefix : ".TS-zoom_", |
Adjust the | click : function() {
curZoom += (this.direction === "in" ? +100 : -100);
if (curZoom >= 100) {
$(".TS-notchbar").trigger('doZoom', [curZoom]);
} else {
curZoom = 100;
}
}
}); |
Each | var Chooser = function(direction) {
Control.apply(this, arguments);
this.notches = $(".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($(".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");
}
}); |
Finally, let's create the whole timeline. Boot is exposed globally via
In the default install of TimelineSetter, Boot is called in the generated
HTML. We'll kick everything off by creating a | Timeline.boot = function(data) {
$(function(){
TimelineSetter.timeline = new Timeline(data);
new Zoom("in");
new Zoom("out");
var chooseNext = new Chooser("next");
var choosePrev = new Chooser("prev");
if (!$(".TS-card_active").is("*")) chooseNext.click();
$(document).bind('keydown', function(e) {
if (e.keyCode === 39) {
chooseNext.click();
} else if (e.keyCode === 37) {
choosePrev.click();
} else {
return;
}
});
});
};
})();
|