timeline_setter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+ })();