timeline_setter 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. data/.document +4 -0
  2. data/LICENSE.txt +18 -0
  3. data/README +15 -0
  4. data/Rakefile +98 -0
  5. data/bin/timeline-setter +9 -0
  6. data/doc/doc.markdown +253 -0
  7. data/doc/doc_wrapper.erb +87 -0
  8. data/doc/docco.css +186 -0
  9. data/doc/timeline-setter.html +592 -0
  10. data/doc/todo.markdown +28 -0
  11. data/doc/twitter-demo.html +122 -0
  12. data/documentation/TimelineSetter/CLI.html +575 -0
  13. data/documentation/TimelineSetter/Parser.html +285 -0
  14. data/documentation/TimelineSetter/Timeline.html +513 -0
  15. data/documentation/TimelineSetter/Util.html +246 -0
  16. data/documentation/TimelineSetter.html +112 -0
  17. data/documentation/_index.html +132 -0
  18. data/documentation/class_list.html +36 -0
  19. data/documentation/css/common.css +1 -0
  20. data/documentation/css/full_list.css +53 -0
  21. data/documentation/css/style.css +318 -0
  22. data/documentation/file.README.html +70 -0
  23. data/documentation/file_list.html +38 -0
  24. data/documentation/frames.html +13 -0
  25. data/documentation/index.html +70 -0
  26. data/documentation/js/app.js +203 -0
  27. data/documentation/js/full_list.js +149 -0
  28. data/documentation/js/jquery.js +16 -0
  29. data/documentation/method_list.html +155 -0
  30. data/documentation/top-level-namespace.html +88 -0
  31. data/index.html +397 -0
  32. data/lib/timeline_setter/cli.rb +85 -0
  33. data/lib/timeline_setter/parser.rb +28 -0
  34. data/lib/timeline_setter/timeline.rb +44 -0
  35. data/lib/timeline_setter/version.rb +3 -0
  36. data/lib/timeline_setter.rb +22 -0
  37. data/public/javascripts/timeline-setter.js +822 -0
  38. data/public/javascripts/vendor/jquery-min.js +16 -0
  39. data/public/javascripts/vendor/underscore-min.js +26 -0
  40. data/public/stylesheets/timeline-setter.css +396 -0
  41. data/spec/spec_helper.rb +10 -0
  42. data/spec/test_data.csv +4 -0
  43. data/spec/timeline_setter_spec.rb +85 -0
  44. data/templates/timeline-markup.erb +61 -0
  45. data/templates/timeline-min.erb +1 -0
  46. data/templates/timeline.erb +12 -0
  47. data/timeline_setter.gemspec +104 -0
  48. 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
+ })();