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.
- 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
|
+
})();
|