timeline_setter 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +4 -0
- data/LICENSE.txt +18 -0
- data/README +15 -0
- data/Rakefile +98 -0
- data/bin/timeline-setter +9 -0
- data/doc/doc.markdown +253 -0
- data/doc/doc_wrapper.erb +87 -0
- data/doc/docco.css +186 -0
- data/doc/timeline-setter.html +592 -0
- data/doc/todo.markdown +28 -0
- data/doc/twitter-demo.html +122 -0
- data/documentation/TimelineSetter/CLI.html +575 -0
- data/documentation/TimelineSetter/Parser.html +285 -0
- data/documentation/TimelineSetter/Timeline.html +513 -0
- data/documentation/TimelineSetter/Util.html +246 -0
- data/documentation/TimelineSetter.html +112 -0
- data/documentation/_index.html +132 -0
- data/documentation/class_list.html +36 -0
- data/documentation/css/common.css +1 -0
- data/documentation/css/full_list.css +53 -0
- data/documentation/css/style.css +318 -0
- data/documentation/file.README.html +70 -0
- data/documentation/file_list.html +38 -0
- data/documentation/frames.html +13 -0
- data/documentation/index.html +70 -0
- data/documentation/js/app.js +203 -0
- data/documentation/js/full_list.js +149 -0
- data/documentation/js/jquery.js +16 -0
- data/documentation/method_list.html +155 -0
- data/documentation/top-level-namespace.html +88 -0
- data/index.html +397 -0
- data/lib/timeline_setter/cli.rb +85 -0
- data/lib/timeline_setter/parser.rb +28 -0
- data/lib/timeline_setter/timeline.rb +44 -0
- data/lib/timeline_setter/version.rb +3 -0
- data/lib/timeline_setter.rb +22 -0
- data/public/javascripts/timeline-setter.js +822 -0
- data/public/javascripts/vendor/jquery-min.js +16 -0
- data/public/javascripts/vendor/underscore-min.js +26 -0
- data/public/stylesheets/timeline-setter.css +396 -0
- data/spec/spec_helper.rb +10 -0
- data/spec/test_data.csv +4 -0
- data/spec/timeline_setter_spec.rb +85 -0
- data/templates/timeline-markup.erb +61 -0
- data/templates/timeline-min.erb +1 -0
- data/templates/timeline.erb +12 -0
- data/timeline_setter.gemspec +104 -0
- metadata +189 -0
@@ -0,0 +1,822 @@
|
|
1
|
+
(function(){
|
2
|
+
|
3
|
+
// Expose `TimelineSetter` globally, so we can call `Timeline.Timeline.boot()`
|
4
|
+
// to kick off at any point.
|
5
|
+
var TimelineSetter = window.TimelineSetter = (window.TimelineSetter || {});
|
6
|
+
|
7
|
+
// Mixins
|
8
|
+
// ------
|
9
|
+
// Each mixin operates on an object's `prototype`.
|
10
|
+
|
11
|
+
// The `observable` mixin adds simple event notifications to the passed in
|
12
|
+
// object. Unlike other notification systems, when an event is triggered every
|
13
|
+
// callback bound to the object is invoked.
|
14
|
+
var observable = function(obj){
|
15
|
+
|
16
|
+
// Registers a callback function for notification at a later time.
|
17
|
+
obj.bind = function(cb){
|
18
|
+
this._callbacks = this._callbacks || [];
|
19
|
+
this._callbacks.push(cb);
|
20
|
+
};
|
21
|
+
|
22
|
+
// Invoke all callbacks registered to the object with `bind`.
|
23
|
+
obj.trigger = function(){
|
24
|
+
if(!this._callbacks) return;
|
25
|
+
for(var i = 0; callback = this._callbacks[i]; i++)
|
26
|
+
callback.apply(this, arguments);
|
27
|
+
};
|
28
|
+
};
|
29
|
+
|
30
|
+
|
31
|
+
// Each `transformable` contains two event listeners that handle moving associated
|
32
|
+
// DOM elements around the page.
|
33
|
+
var transformable = function(obj){
|
34
|
+
|
35
|
+
// Move the associated element a specified delta. Of note: because events
|
36
|
+
// aren't scoped by key, TimelineSetter uses plain old jQuery events for
|
37
|
+
// message passing. So each registered callback first checks to see if the
|
38
|
+
// event fired matches the event it is listening for.
|
39
|
+
obj.move = function(e){
|
40
|
+
if(!e.type === "move" || !e.deltaX) return;
|
41
|
+
|
42
|
+
if(_.isUndefined(this.currOffset)) this.currOffset = 0;
|
43
|
+
this.currOffset += e.deltaX;
|
44
|
+
this.el.css({"left" : this.currOffset});
|
45
|
+
};
|
46
|
+
|
47
|
+
// The width for the `Bar` and `CardContainer` objects is set in percentages,
|
48
|
+
// in order to zoom the Timeline all that's needed is to increase or decrease
|
49
|
+
// the percentage width.
|
50
|
+
obj.zoom = function(e){
|
51
|
+
if(!e.type === "zoom") return;
|
52
|
+
this.el.css({ "width": e.width });
|
53
|
+
};
|
54
|
+
};
|
55
|
+
|
56
|
+
|
57
|
+
// Plugins
|
58
|
+
// -------
|
59
|
+
// Each plugin operates on an instance of an object.
|
60
|
+
|
61
|
+
// Check to see if we're on a mobile device.
|
62
|
+
var touchInit = 'ontouchstart' in document;
|
63
|
+
if(touchInit) jQuery.event.props.push("touches");
|
64
|
+
|
65
|
+
// The `draggable` plugin tracks changes in X offsets due to mouse movement
|
66
|
+
// or finger gestures and proxies associated events on a particular element.
|
67
|
+
// Most of this is inspired by polymaps.
|
68
|
+
var draggable = function(obj){
|
69
|
+
var drag;
|
70
|
+
|
71
|
+
// Start tracking deltas due to a tap or single click.
|
72
|
+
function mousedown(e){
|
73
|
+
e.preventDefault();
|
74
|
+
drag = {x: e.pageX};
|
75
|
+
e.type = "dragstart";
|
76
|
+
obj.el.trigger(e);
|
77
|
+
};
|
78
|
+
|
79
|
+
// The user is interacting; capture the offset and trigger a `dragging` event.
|
80
|
+
function mousemove(e){
|
81
|
+
if(!drag) return;
|
82
|
+
e.preventDefault();
|
83
|
+
e.type = "dragging";
|
84
|
+
e = _.extend(e, {
|
85
|
+
deltaX: (e.pageX || e.touches[0].pageX) - drag.x
|
86
|
+
});
|
87
|
+
drag = { x: (e.pageX || e.touches[0].pageX) };
|
88
|
+
obj.el.trigger(e);
|
89
|
+
};
|
90
|
+
|
91
|
+
// We're done tracking the movement set drag back to `null` for the next event.
|
92
|
+
function mouseup(e){
|
93
|
+
if(!drag) return;
|
94
|
+
drag = null;
|
95
|
+
e.type = "dragend";
|
96
|
+
obj.el.trigger(e);
|
97
|
+
};
|
98
|
+
|
99
|
+
if(!touchInit) {
|
100
|
+
// Bind on mouse events if we have a mouse...
|
101
|
+
obj.el.bind("mousedown", mousedown);
|
102
|
+
|
103
|
+
$(document).bind("mousemove", mousemove);
|
104
|
+
$(document).bind("mouseup", mouseup);
|
105
|
+
} else {
|
106
|
+
// otherwise capture `touchstart` events in order to simulate `doubletap` events.
|
107
|
+
var last;
|
108
|
+
obj.el.bind("touchstart", function(e) {
|
109
|
+
var now = Date.now();
|
110
|
+
var delta = now - (last || now);
|
111
|
+
var type = delta > 0 && delta <= 250 ? "doubletap" : "tap";
|
112
|
+
drag = {x: e.touches[0].pageX};
|
113
|
+
last = now;
|
114
|
+
obj.el.trigger($.Event(type));
|
115
|
+
});
|
116
|
+
|
117
|
+
obj.el.bind("touchmove", mousemove);
|
118
|
+
obj.el.bind("touchend", mouseup);
|
119
|
+
};
|
120
|
+
};
|
121
|
+
|
122
|
+
|
123
|
+
// Older versions of safari fire incredibly huge mousewheel deltas. We'll need
|
124
|
+
// to dampen the effects.
|
125
|
+
var safari = /WebKit\/533/.test(navigator.userAgent);
|
126
|
+
|
127
|
+
// The `wheel` plugin captures events triggered by mousewheel, and dampen the
|
128
|
+
// `delta` if running in Safari.
|
129
|
+
var wheel = function(obj){
|
130
|
+
function mousewheel(e){
|
131
|
+
e.preventDefault();
|
132
|
+
var delta = (e.wheelDelta || -e.detail);
|
133
|
+
if(safari){
|
134
|
+
var negative = delta < 0 ? -1 : 1;
|
135
|
+
delta = Math.log(Math.abs(delta)) * negative * 2;
|
136
|
+
};
|
137
|
+
e.type = "scrolled";
|
138
|
+
e.deltaX = delta;
|
139
|
+
obj.el.trigger(e);
|
140
|
+
};
|
141
|
+
|
142
|
+
obj.el.bind("mousewheel DOMMouseScroll", mousewheel);
|
143
|
+
};
|
144
|
+
|
145
|
+
// Utilities
|
146
|
+
// -----
|
147
|
+
|
148
|
+
// A utility class for storing the extent of the timeline.
|
149
|
+
var Bounds = function(){
|
150
|
+
this.min = +Infinity;
|
151
|
+
this.max = -Infinity;
|
152
|
+
};
|
153
|
+
|
154
|
+
Bounds.prototype.extend = function(num){
|
155
|
+
this.min = Math.min(num, this.min);
|
156
|
+
this.max = Math.max(num, this.max);
|
157
|
+
};
|
158
|
+
|
159
|
+
Bounds.prototype.width = function(){
|
160
|
+
return this.max - this.min;
|
161
|
+
};
|
162
|
+
|
163
|
+
// Translate a particular number from the current bounds to a given range.
|
164
|
+
Bounds.prototype.project = function(num, max){
|
165
|
+
return (num - this.min) / this.width() * max;
|
166
|
+
};
|
167
|
+
|
168
|
+
|
169
|
+
// `Intervals` is a particularly focused class to calculate even breaks based
|
170
|
+
// on the passed in `Bounds`.
|
171
|
+
var Intervals = function(bounds) {
|
172
|
+
this.max = bounds.max;
|
173
|
+
this.min = bounds.min;
|
174
|
+
this.setMaxInterval();
|
175
|
+
};
|
176
|
+
|
177
|
+
// An object containing human translations for date indexes.
|
178
|
+
Intervals.HUMAN_DATES = {
|
179
|
+
months : ['Jan.', 'Feb.', 'March', 'April', 'May', 'June', 'July', 'Aug.', 'Sept.', 'Oct.', 'Nov.', 'Dec.']
|
180
|
+
};
|
181
|
+
|
182
|
+
// A utility function to format dates in AP Style.
|
183
|
+
Intervals.dateStr = function(timestamp, interval) {
|
184
|
+
var d = new Date(timestamp);
|
185
|
+
var dYear = d.getFullYear();
|
186
|
+
var dMonth = Intervals.HUMAN_DATES.months[d.getMonth()];
|
187
|
+
var dDate = dMonth + " " + d.getDate() + ', ' + dYear;
|
188
|
+
var bigHours = d.getHours() > 12;
|
189
|
+
var isPM = d.getHours() >= 12;
|
190
|
+
var dHourWithMinutes = (bigHours ? d.getHours() - 12 : (d.getHours() > 0 ? d.getHours() : "12")) + ":" + padNumber(d.getMinutes()) + " " + (isPM ? 'p.m.' : 'a.m.');
|
191
|
+
var dHourMinuteSecond = dHourWithMinutes + ":" + padNumber(d.getSeconds());
|
192
|
+
|
193
|
+
switch (interval) {
|
194
|
+
case "FullYear":
|
195
|
+
return dYear;
|
196
|
+
case "Month":
|
197
|
+
return dMonth + ', ' + dYear;
|
198
|
+
case "Date":
|
199
|
+
return dDate;
|
200
|
+
case "Hours":
|
201
|
+
return dHourWithMinutes;
|
202
|
+
case "Minutes":
|
203
|
+
return dHourWithMinutes;
|
204
|
+
case "Seconds":
|
205
|
+
return dHourMinuteSecond;
|
206
|
+
}
|
207
|
+
};
|
208
|
+
|
209
|
+
Intervals.prototype = {
|
210
|
+
// Sane estimates of date ranges for the `isAtLeastA` test.
|
211
|
+
INTERVALS : {
|
212
|
+
FullYear : 31536000000,
|
213
|
+
Month : 2592000000,
|
214
|
+
Date : 86400000,
|
215
|
+
Hours : 3600000,
|
216
|
+
Minutes : 60000,
|
217
|
+
Seconds : 1000
|
218
|
+
},
|
219
|
+
|
220
|
+
// The order used when testing where exactly a timespan falls.
|
221
|
+
INTERVAL_ORDER : ['Seconds','Minutes','Hours','Date','Month','FullYear'],
|
222
|
+
|
223
|
+
// A test to find the appropriate range of intervals, for example if a range of
|
224
|
+
// timestamps only spans hours this will return true when called with `"Hours"`.
|
225
|
+
isAtLeastA : function(interval) {
|
226
|
+
return ((this.max - this.min) > this.INTERVALS[interval]);
|
227
|
+
},
|
228
|
+
|
229
|
+
// Find the maximum interval we should use based on the estimates in `INTERVALS`.
|
230
|
+
setMaxInterval : function() {
|
231
|
+
for (var i = 0; i < this.INTERVAL_ORDER.length; i++)
|
232
|
+
if (!this.isAtLeastA(this.INTERVAL_ORDER[i])) break;
|
233
|
+
|
234
|
+
this.maxInterval = this.INTERVAL_ORDER[i - 1];
|
235
|
+
this.idx = i - 1;
|
236
|
+
},
|
237
|
+
|
238
|
+
// Return the calculated `maxInterval`.
|
239
|
+
getMaxInterval : function() {
|
240
|
+
return this.INTERVALS[this.INTERVAL_ORDER[this.idx]];
|
241
|
+
},
|
242
|
+
|
243
|
+
// Zero out a date from the current interval down to seconds.
|
244
|
+
floor : function(ts){
|
245
|
+
var idx = this.idx;
|
246
|
+
var date = new Date(ts);
|
247
|
+
while(idx--){
|
248
|
+
var intvl = this.INTERVAL_ORDER[idx];
|
249
|
+
date["set" + intvl](intvl === "Date" ? 1 : 0);
|
250
|
+
}
|
251
|
+
return date.getTime();
|
252
|
+
},
|
253
|
+
|
254
|
+
// Find the next date based on the past in timestamp.
|
255
|
+
ceil : function(ts){
|
256
|
+
var date = new Date(this.floor(ts));
|
257
|
+
var intvl = this.INTERVAL_ORDER[this.idx];
|
258
|
+
date["set" + intvl](date["get" + intvl]() + 1);
|
259
|
+
return date.getTime();
|
260
|
+
},
|
261
|
+
|
262
|
+
// The actual difference in timespans accounting for time oddities like
|
263
|
+
// different length months and leap years.
|
264
|
+
span : function(ts){
|
265
|
+
return this.ceil(ts) - this.floor(ts);
|
266
|
+
},
|
267
|
+
|
268
|
+
// Calculate and return a list of human formatted strings and raw timestamps.
|
269
|
+
getRanges : function() {
|
270
|
+
if (this.intervals) return this.intervals;
|
271
|
+
this.intervals = [];
|
272
|
+
for (var i = this.floor(this.min); i <= this.ceil(this.max); i += this.span(i)) {
|
273
|
+
this.intervals.push({
|
274
|
+
human : Intervals.dateStr(i, this.maxInterval),
|
275
|
+
timestamp : i
|
276
|
+
});
|
277
|
+
}
|
278
|
+
return this.intervals;
|
279
|
+
}
|
280
|
+
};
|
281
|
+
|
282
|
+
// Handy dandy function to bind a listener on multiple events. For example,
|
283
|
+
// `Bar` and `CardContainer` are bound like so on "move" and "zoom":
|
284
|
+
//
|
285
|
+
// sync(this.bar, this.cardCont, "move", "zoom");
|
286
|
+
//
|
287
|
+
var sync = function(origin, listener){
|
288
|
+
var events = Array.prototype.slice.call(arguments, 2);
|
289
|
+
_.each(events, function(ev){
|
290
|
+
origin.bind(function(e){
|
291
|
+
if(e.type === ev && listener[ev])
|
292
|
+
listener[ev](e);
|
293
|
+
});
|
294
|
+
});
|
295
|
+
};
|
296
|
+
|
297
|
+
// Get a template from the DOM and return a compiled function.
|
298
|
+
var template = function(query) {
|
299
|
+
return _.template($(query).html());
|
300
|
+
};
|
301
|
+
|
302
|
+
// Simple function to strip suffixes like `"px"` and return a clean integer for
|
303
|
+
// use.
|
304
|
+
var cleanNumber = function(str){
|
305
|
+
return parseInt(str.replace(/^[^+\-\d]?([+\-]\d+)?.*$/, "$1"), 10);
|
306
|
+
};
|
307
|
+
|
308
|
+
// Zero pad a number less than 10 and return a 2 digit value.
|
309
|
+
var padNumber = function(number) {
|
310
|
+
return (number < 10 ? '0' : '') + number;
|
311
|
+
};
|
312
|
+
|
313
|
+
// A quick and dirty hash manager for setting and getting values from
|
314
|
+
// `window.location.hash`
|
315
|
+
var hashStrip = /^#*/;
|
316
|
+
var history = {
|
317
|
+
get : function(){
|
318
|
+
return window.location.hash.replace(hashStrip, "");
|
319
|
+
},
|
320
|
+
|
321
|
+
set : function(url){
|
322
|
+
window.location.hash = url;
|
323
|
+
}
|
324
|
+
};
|
325
|
+
|
326
|
+
// Every new `Series` gets new color. If there are too many series
|
327
|
+
// the remaining series will be a simple gray.
|
328
|
+
|
329
|
+
// These colors can be styled like such in
|
330
|
+
// timeline-setter.css, where the numbers 1-9 cycle through in that order:
|
331
|
+
//
|
332
|
+
// .TS-notch_color_1,.TS-series_legend_swatch_1 {
|
333
|
+
// background: #065718 !important;
|
334
|
+
// }
|
335
|
+
// .TS-css_arrow_color_1 {
|
336
|
+
// border-bottom-color:#065718 !important;
|
337
|
+
// }
|
338
|
+
// .TS-item_color_1 {
|
339
|
+
// border-top:1px solid #065718 !important;
|
340
|
+
// }
|
341
|
+
//
|
342
|
+
// The default color will fall through to what is styled with
|
343
|
+
// `.TS-foo_color_default`
|
344
|
+
var curColor = 1;
|
345
|
+
var color = function(){
|
346
|
+
var chosen;
|
347
|
+
if (curColor < 10) {
|
348
|
+
chosen = curColor;
|
349
|
+
curColor += 1;
|
350
|
+
} else {
|
351
|
+
chosen = "default";
|
352
|
+
}
|
353
|
+
return chosen;
|
354
|
+
};
|
355
|
+
|
356
|
+
|
357
|
+
|
358
|
+
// Models
|
359
|
+
// ------
|
360
|
+
|
361
|
+
// The main kickoff point for rendering the timeline. The `Timeline` constructor
|
362
|
+
// takes a json array of card representations and then builds series, calculates
|
363
|
+
// intervals `sync`s the `Bar` and `CardContainer` objects and triggers the
|
364
|
+
// `render` event.
|
365
|
+
var Timeline = TimelineSetter.Timeline = function(data) {
|
366
|
+
data = data.sort(function(a, b){ return a.timestamp - b.timestamp; });
|
367
|
+
this.bySid = {};
|
368
|
+
this.series = [];
|
369
|
+
this.bounds = new Bounds();
|
370
|
+
this.bar = new Bar(this);
|
371
|
+
this.cardCont = new CardScroller(this);
|
372
|
+
this.createSeries(data);
|
373
|
+
|
374
|
+
var range = new Intervals(this.bounds);
|
375
|
+
this.intervals = range.getRanges();
|
376
|
+
this.bounds.extend(this.bounds.min - range.getMaxInterval() / 2);
|
377
|
+
this.bounds.extend(this.bounds.max + range.getMaxInterval() / 2);
|
378
|
+
this.bar.render();
|
379
|
+
|
380
|
+
sync(this.bar, this.cardCont, "move", "zoom");
|
381
|
+
var e = $.Event("render");
|
382
|
+
this.trigger(e);
|
383
|
+
};
|
384
|
+
observable(Timeline.prototype);
|
385
|
+
|
386
|
+
Timeline.prototype = _.extend(Timeline.prototype, {
|
387
|
+
// Loop through the JSON and add each element to a series.
|
388
|
+
createSeries : function(series){
|
389
|
+
for(var i = 0; i < series.length; i++)
|
390
|
+
this.add(series[i]);
|
391
|
+
},
|
392
|
+
|
393
|
+
// If a particular element in the JSON array mentions a series that's not
|
394
|
+
// in the `bySid` object add it. Then add a card to the `Series` and extend
|
395
|
+
// the global `bounds`.
|
396
|
+
add : function(card){
|
397
|
+
if(!(card.series in this.bySid)){
|
398
|
+
this.bySid[card.series] = new Series(card, this);
|
399
|
+
this.series.push(this.bySid[card.series]);
|
400
|
+
}
|
401
|
+
var series = this.bySid[card.series];
|
402
|
+
series.add(card);
|
403
|
+
this.bounds.extend(series.max());
|
404
|
+
this.bounds.extend(series.min());
|
405
|
+
}
|
406
|
+
});
|
407
|
+
|
408
|
+
|
409
|
+
|
410
|
+
|
411
|
+
// Views
|
412
|
+
// -----
|
413
|
+
|
414
|
+
// The main interactive element in the timeline is `.TS-notchbar`. Behind the
|
415
|
+
// scenes `Bar` handles the moving and zooming behaviours through the `draggable`
|
416
|
+
// and `wheel` plugins.
|
417
|
+
var Bar = function(timeline) {
|
418
|
+
this.el = $(".TS-notchbar");
|
419
|
+
this.el.css({ "left": 0 });
|
420
|
+
this.timeline = timeline;
|
421
|
+
draggable(this);
|
422
|
+
wheel(this);
|
423
|
+
_.bindAll(this, "moving", "doZoom");
|
424
|
+
this.el.bind("dragging scrolled", this.moving);
|
425
|
+
this.el.bind("doZoom", this.doZoom);
|
426
|
+
this.template = template("#TS-year_notch_tmpl");
|
427
|
+
this.el.bind("dblclick doubletap", function(e){
|
428
|
+
e.preventDefault();
|
429
|
+
$(".TS-zoom_in").click();
|
430
|
+
});
|
431
|
+
};
|
432
|
+
observable(Bar.prototype);
|
433
|
+
transformable(Bar.prototype);
|
434
|
+
|
435
|
+
Bar.prototype = _.extend(Bar.prototype, {
|
436
|
+
// Every time the `Bar` is moved, it calculates whether the proposed movement
|
437
|
+
// will move the `.TS-notchbar` off of its parent. If so, it recaculates
|
438
|
+
// `deltaX` to be a more correct value.
|
439
|
+
moving : function(e){
|
440
|
+
var parent = this.el.parent();
|
441
|
+
var pOffset = parent.offset().left;
|
442
|
+
var offset = this.el.offset().left;
|
443
|
+
var width = this.el.width();
|
444
|
+
if(_.isUndefined(e.deltaX)) e.deltaX = 0;
|
445
|
+
|
446
|
+
if(offset + width + e.deltaX < pOffset + parent.width())
|
447
|
+
e.deltaX = (pOffset + parent.width()) - (offset + width);
|
448
|
+
if(offset + e.deltaX > pOffset)
|
449
|
+
e.deltaX = pOffset - offset;
|
450
|
+
|
451
|
+
e.type = "move";
|
452
|
+
this.trigger(e);
|
453
|
+
this.move(e);
|
454
|
+
},
|
455
|
+
|
456
|
+
// As the timeline zooms, the `Bar` tries to keep the current notch (i.e.
|
457
|
+
// `.TS-notch_active`) as close to its original position as possible.
|
458
|
+
// There's a slight bug here because the timeline zooms and then moves the
|
459
|
+
// bar to correct for this behaviour, and in future versions we'll fix this.
|
460
|
+
doZoom : function(e, width){
|
461
|
+
var that = this;
|
462
|
+
var notch = $(".TS-notch_active");
|
463
|
+
var getCur = function() {
|
464
|
+
return notch.length > 0 ? notch.position().left : 0;
|
465
|
+
};
|
466
|
+
var curr = getCur();
|
467
|
+
|
468
|
+
this.el.animate({"width": width + "%"}, {
|
469
|
+
step: function(current, fx) {
|
470
|
+
var e = $.Event("dragging");
|
471
|
+
var delta = curr - getCur();
|
472
|
+
e.deltaX = delta;
|
473
|
+
that.moving(e);
|
474
|
+
curr = getCur();
|
475
|
+
e = $.Event("zoom");
|
476
|
+
e.width = current + "%";
|
477
|
+
that.trigger(e);
|
478
|
+
}
|
479
|
+
});
|
480
|
+
},
|
481
|
+
|
482
|
+
// When asked to render the bar places the appropriate timestamp notches
|
483
|
+
// inside `.TS-notchbar`.
|
484
|
+
render : function(){
|
485
|
+
var intervals = this.timeline.intervals;
|
486
|
+
var bounds = this.timeline.bounds;
|
487
|
+
|
488
|
+
for (var i = 0; i < intervals.length; i++) {
|
489
|
+
var html = this.template({'timestamp' : intervals[i].timestamp, 'human' : intervals[i].human });
|
490
|
+
this.el.append($(html).css("left", bounds.project(intervals[i].timestamp, 100) + "%"));
|
491
|
+
}
|
492
|
+
}
|
493
|
+
});
|
494
|
+
|
495
|
+
|
496
|
+
// The `CardScroller` mirrors the moving and zooming of the `Bar` and is the
|
497
|
+
// canvas where individual cards are rendered.
|
498
|
+
var CardScroller = function(timeline){
|
499
|
+
this.el = $("#TS-card_scroller_inner");
|
500
|
+
};
|
501
|
+
observable(CardScroller.prototype);
|
502
|
+
transformable(CardScroller.prototype);
|
503
|
+
|
504
|
+
|
505
|
+
// Each `Series` picks a unique color and keeps an array of `Cards`.
|
506
|
+
var Series = function(series, timeline) {
|
507
|
+
this.timeline = timeline;
|
508
|
+
this.name = series.series;
|
509
|
+
this.color = this.name.length > 0 ? color() : "default";
|
510
|
+
this.cards = [];
|
511
|
+
_.bindAll(this, "render", "showNotches", "hideNotches");
|
512
|
+
this.template = template("#TS-series_legend_tmpl");
|
513
|
+
this.timeline.bind(this.render);
|
514
|
+
};
|
515
|
+
observable(Series.prototype);
|
516
|
+
|
517
|
+
Series.prototype = _.extend(Series.prototype, {
|
518
|
+
// Create and add a particular card to the cards array.
|
519
|
+
add : function(card){
|
520
|
+
var crd = new Card(card, this);
|
521
|
+
this.cards.push(crd);
|
522
|
+
},
|
523
|
+
|
524
|
+
// The comparing function for `max` and `min`.
|
525
|
+
_comparator : function(crd){
|
526
|
+
return crd.timestamp;
|
527
|
+
},
|
528
|
+
|
529
|
+
// Inactivate this series legend item and trigger a `hideNotch` event.
|
530
|
+
hideNotches : function(e){
|
531
|
+
e.preventDefault();
|
532
|
+
this.el.addClass("TS-series_legend_item_inactive");
|
533
|
+
this.trigger($.Event("hideNotch"));
|
534
|
+
},
|
535
|
+
|
536
|
+
// Activate the legend item and trigger the `showNotch` event.
|
537
|
+
showNotches : function(e){
|
538
|
+
e.preventDefault();
|
539
|
+
this.el.removeClass("TS-series_legend_item_inactive");
|
540
|
+
this.trigger($.Event("showNotch"));
|
541
|
+
},
|
542
|
+
|
543
|
+
// Create and append the label to `.TS-series_nav_container` and bind up
|
544
|
+
// `hideNotches` and `showNotches`.
|
545
|
+
render : function(e){
|
546
|
+
if(!e.type === "render") return;
|
547
|
+
if(this.name.length === 0) return;
|
548
|
+
this.el = $(this.template(this));
|
549
|
+
$(".TS-series_nav_container").append(this.el);
|
550
|
+
this.el.toggle(this.hideNotches, this.showNotches);
|
551
|
+
}
|
552
|
+
});
|
553
|
+
|
554
|
+
// Proxy to underscore for `min` and `max`.
|
555
|
+
_(["min", "max"]).each(function(key){
|
556
|
+
Series.prototype[key] = function() {
|
557
|
+
return _[key].call(_, this.cards, this._comparator).get("timestamp");
|
558
|
+
};
|
559
|
+
});
|
560
|
+
|
561
|
+
|
562
|
+
// Every `Card` handles a notch div which is immediately appended to the `Bar`
|
563
|
+
// and a `.TS-card_container` which is lazily rendered.
|
564
|
+
var Card = function(card, series) {
|
565
|
+
this.series = series;
|
566
|
+
var card = _.clone(card);
|
567
|
+
this.timestamp = card.timestamp;
|
568
|
+
this.attributes = card;
|
569
|
+
this.attributes.topcolor = series.color;
|
570
|
+
this.template = template("#TS-card_tmpl");
|
571
|
+
this.ntemplate = template("#TS-notch_tmpl");
|
572
|
+
_.bindAll(this, "render", "activate", "position", "setPermalink", "toggleNotch");
|
573
|
+
this.series.bind(this.toggleNotch);
|
574
|
+
this.series.timeline.bind(this.render);
|
575
|
+
this.series.timeline.bar.bind(this.position);
|
576
|
+
this.id = [
|
577
|
+
this.get('timestamp'),
|
578
|
+
this.get('description').split(/ /)[0].replace(/[^a-zA-Z\-]/g,"")
|
579
|
+
].join("-");
|
580
|
+
};
|
581
|
+
|
582
|
+
Card.prototype = {
|
583
|
+
// Get a particular attribute by key.
|
584
|
+
get : function(key){
|
585
|
+
return this.attributes[key];
|
586
|
+
},
|
587
|
+
|
588
|
+
// A version of `jQuery` scoped to the `Card`'s element.
|
589
|
+
$ : function(query){
|
590
|
+
return $(query, this.el);
|
591
|
+
},
|
592
|
+
|
593
|
+
// When each `Card` is rendered via a render event, it appends a notch to the
|
594
|
+
// `Bar` and binds a click handler so it can be activated. if the `Card`'s id
|
595
|
+
// is currently selected via `window.location.hash` it's activated.
|
596
|
+
render : function(e){
|
597
|
+
if(!e.type === "render") return;
|
598
|
+
this.offset = this.series.timeline.bounds.project(this.timestamp, 100);
|
599
|
+
var html = this.ntemplate(this.attributes);
|
600
|
+
this.notch = $(html).css({"left": this.offset + "%"});
|
601
|
+
$(".TS-notchbar").append(this.notch);
|
602
|
+
this.notch.click(this.activate);
|
603
|
+
if (history.get() === this.id) this.activate();
|
604
|
+
},
|
605
|
+
|
606
|
+
// As the `Bar` moves each card checks to see if it's outside the viewport,
|
607
|
+
// if it is the card is flipped so as to be visible for the longest period
|
608
|
+
// of time.
|
609
|
+
position : function(e) {
|
610
|
+
if (e.type !== "move" || !this.el) return;
|
611
|
+
var onBarEdge = this.cardOffset().onBarEdge;
|
612
|
+
|
613
|
+
switch(onBarEdge) {
|
614
|
+
case 'right':
|
615
|
+
this.el.css({"margin-left": -(this.cardOffset().item.width() + 7)});
|
616
|
+
this.$(".TS-css_arrow").css("left", this.cardOffset().item.width());
|
617
|
+
break;
|
618
|
+
case 'default':
|
619
|
+
this.el.css({"margin-left": this.originalMargin});
|
620
|
+
this.$(".TS-css_arrow").css("left", 0);
|
621
|
+
}
|
622
|
+
},
|
623
|
+
|
624
|
+
// A utility function to suss out whether the card is fully viewable.
|
625
|
+
cardOffset : function() {
|
626
|
+
if (!this.el) return { onBarEdge : false };
|
627
|
+
|
628
|
+
var that = this;
|
629
|
+
var item = this.el.children(".TS-item");
|
630
|
+
var currentMargin = this.el.css("margin-left");
|
631
|
+
var timeline = $("#timeline_setter");
|
632
|
+
var right = (this.el.offset().left + item.width()) - (timeline.offset().left + timeline.width());
|
633
|
+
var left = (this.el.offset().left) - timeline.offset().left;
|
634
|
+
|
635
|
+
return {
|
636
|
+
item : item,
|
637
|
+
onBarEdge : (right > 0 && currentMargin === that.originalMargin) ?
|
638
|
+
'right' :
|
639
|
+
(left < 0 && that.el.css("margin-left") !== that.originalMargin) ?
|
640
|
+
'default' :
|
641
|
+
(left < 0 && that.el.css("margin-left") === that.originalMargin) ?
|
642
|
+
'left' :
|
643
|
+
false
|
644
|
+
};
|
645
|
+
},
|
646
|
+
|
647
|
+
// The first time a card is activated it renders its `template` and appends
|
648
|
+
// its element to the `Bar`. After doing so it moves the `Bar` if its
|
649
|
+
// element isn't currently visible. For ie each card sets the width of
|
650
|
+
// `.TS-item_label` to the maximum width of the card's children, or
|
651
|
+
// if that is less than the `.TS-item_year` element's width, `.TS-item_label`
|
652
|
+
// gets `.TS-item_year`s width. Which is a funny way of saying, if you'd
|
653
|
+
// like to set the width of the card as a whole, fiddle with `.TS-item_year`s
|
654
|
+
// width.
|
655
|
+
activate : function(e){
|
656
|
+
this.hideActiveCard();
|
657
|
+
if (!this.el) {
|
658
|
+
this.el = $(this.template({card: this}));
|
659
|
+
this.el.css({"left": this.offset + "%"});
|
660
|
+
$("#TS-card_scroller_inner").append(this.el);
|
661
|
+
this.originalMargin = this.el.css("margin-left");
|
662
|
+
this.el.delegate(".TS-permalink", "click", this.setPermalink);
|
663
|
+
}
|
664
|
+
|
665
|
+
this.el.show().addClass(("TS-card_active"));
|
666
|
+
|
667
|
+
var max = _.max(_.toArray(this.$(".TS-item_user_html").children()), function(el){ return $(el).width(); });
|
668
|
+
if($(max).width() > this.$(".TS-item_year").width()){
|
669
|
+
this.$(".TS-item_label").css("width", $(max).width());
|
670
|
+
} else {
|
671
|
+
this.$(".TS-item_label").css("width", this.$(".TS-item_year").width());
|
672
|
+
}
|
673
|
+
|
674
|
+
this.moveBarWithCard();
|
675
|
+
this.notch.addClass("TS-notch_active");
|
676
|
+
},
|
677
|
+
|
678
|
+
// Move the `Bar` if the `Card`'s element isn't visible.
|
679
|
+
moveBarWithCard : function() {
|
680
|
+
var e = $.Event('moving');
|
681
|
+
var onBarEdge = this.cardOffset().onBarEdge;
|
682
|
+
|
683
|
+
switch(onBarEdge) {
|
684
|
+
case 'right':
|
685
|
+
e.deltaX = -(this.cardOffset().item.width());
|
686
|
+
this.series.timeline.bar.moving(e);
|
687
|
+
break;
|
688
|
+
case 'left':
|
689
|
+
e.deltaX = (this.cardOffset().item.width());
|
690
|
+
this.series.timeline.bar.moving(e);
|
691
|
+
}
|
692
|
+
this.position($.Event('move'));
|
693
|
+
},
|
694
|
+
|
695
|
+
// The click handler to set the current hash to the `Card`'s id.
|
696
|
+
setPermalink : function() {
|
697
|
+
history.set(this.id);
|
698
|
+
},
|
699
|
+
|
700
|
+
// Globally hide any cards with `TS-card_active`.
|
701
|
+
hideActiveCard : function() {
|
702
|
+
$(".TS-card_active").removeClass("TS-card_active").hide();
|
703
|
+
$(".TS-notch_active").removeClass("TS-notch_active");
|
704
|
+
},
|
705
|
+
|
706
|
+
// An event listener to toggle this notche on and off via `Series`.
|
707
|
+
toggleNotch : function(e){
|
708
|
+
switch(e.type) {
|
709
|
+
case "hideNotch":
|
710
|
+
this.notch.hide().removeClass("TS-notch_active").addClass("TS-series_inactive");
|
711
|
+
if(this.el) this.el.hide();
|
712
|
+
return;
|
713
|
+
case "showNotch":
|
714
|
+
this.notch.removeClass("TS-series_inactive").show();
|
715
|
+
}
|
716
|
+
}
|
717
|
+
|
718
|
+
};
|
719
|
+
|
720
|
+
|
721
|
+
// Simple inheritance helper for `Controls`.
|
722
|
+
var ctor = function(){};
|
723
|
+
var inherits = function(child, parent){
|
724
|
+
ctor.prototype = parent.prototype;
|
725
|
+
child.prototype = new ctor();
|
726
|
+
child.prototype.constructor = child;
|
727
|
+
};
|
728
|
+
|
729
|
+
// Controls
|
730
|
+
// --------
|
731
|
+
|
732
|
+
// Each control is basically a callback wrapper for a given DOM element.
|
733
|
+
var Control = function(direction){
|
734
|
+
this.direction = direction;
|
735
|
+
this.el = $(this.prefix + direction);
|
736
|
+
var that = this;
|
737
|
+
this.el.bind('click', function(e) { e.preventDefault(); that.click(e);});
|
738
|
+
};
|
739
|
+
|
740
|
+
// Each `Zoom` control adjusts the `curZoom` when clicked.
|
741
|
+
var curZoom = 100;
|
742
|
+
var Zoom = function(direction) {
|
743
|
+
Control.apply(this, arguments);
|
744
|
+
};
|
745
|
+
inherits(Zoom, Control);
|
746
|
+
|
747
|
+
Zoom.prototype = _.extend(Zoom.prototype, {
|
748
|
+
prefix : ".TS-zoom_",
|
749
|
+
|
750
|
+
// Adjust the `curZoom` up or down by 100 and trigger a `doZoom` event on
|
751
|
+
// `.TS-notchbar`
|
752
|
+
click : function() {
|
753
|
+
curZoom += (this.direction === "in" ? +100 : -100);
|
754
|
+
if (curZoom >= 100) {
|
755
|
+
$(".TS-notchbar").trigger('doZoom', [curZoom]);
|
756
|
+
} else {
|
757
|
+
curZoom = 100;
|
758
|
+
}
|
759
|
+
}
|
760
|
+
});
|
761
|
+
|
762
|
+
|
763
|
+
// Each `Chooser` activates the next or previous notch.
|
764
|
+
var Chooser = function(direction) {
|
765
|
+
Control.apply(this, arguments);
|
766
|
+
this.notches = $(".TS-notch");
|
767
|
+
};
|
768
|
+
inherits(Chooser, Control);
|
769
|
+
|
770
|
+
Chooser.prototype = _.extend(Control.prototype, {
|
771
|
+
prefix: ".TS-choose_",
|
772
|
+
|
773
|
+
// Figure out which notch to activate and do so by triggering a click on
|
774
|
+
// that notch.
|
775
|
+
click: function(e){
|
776
|
+
var el;
|
777
|
+
var notches = this.notches.not(".TS-series_inactive");
|
778
|
+
var curCardIdx = notches.index($(".TS-notch_active"));
|
779
|
+
var numOfCards = notches.length;
|
780
|
+
if (this.direction === "next") {
|
781
|
+
el = (curCardIdx < numOfCards ? notches.eq(curCardIdx + 1) : false);
|
782
|
+
} else {
|
783
|
+
el = (curCardIdx > 0 ? notches.eq(curCardIdx - 1) : false);
|
784
|
+
}
|
785
|
+
if(!el) return;
|
786
|
+
el.trigger("click");
|
787
|
+
}
|
788
|
+
});
|
789
|
+
|
790
|
+
|
791
|
+
// Finally, let's create the whole timeline. Boot is exposed globally via
|
792
|
+
// `TimelineSetter.Timeline.boot()` which takes the JSON generated by
|
793
|
+
// the timeline-setter binary as an argument. This is handy if you want
|
794
|
+
// to be able to generate timelines at arbitrary times (say, for example,
|
795
|
+
// in an ajax callback).
|
796
|
+
//
|
797
|
+
// In the default install of TimelineSetter, Boot is called in the generated
|
798
|
+
// HTML. We'll kick everything off by creating a `Timeline`, some `Controls`
|
799
|
+
// and binding to `"keydown"`.
|
800
|
+
Timeline.boot = function(data) {
|
801
|
+
$(function(){
|
802
|
+
TimelineSetter.timeline = new Timeline(data);
|
803
|
+
new Zoom("in");
|
804
|
+
new Zoom("out");
|
805
|
+
var chooseNext = new Chooser("next");
|
806
|
+
var choosePrev = new Chooser("prev");
|
807
|
+
if (!$(".TS-card_active").is("*")) chooseNext.click();
|
808
|
+
|
809
|
+
$(document).bind('keydown', function(e) {
|
810
|
+
if (e.keyCode === 39) {
|
811
|
+
chooseNext.click();
|
812
|
+
} else if (e.keyCode === 37) {
|
813
|
+
choosePrev.click();
|
814
|
+
} else {
|
815
|
+
return;
|
816
|
+
}
|
817
|
+
});
|
818
|
+
});
|
819
|
+
|
820
|
+
};
|
821
|
+
|
822
|
+
})();
|