vis.js is a dynamic, browser-based visualization library
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

736 lines
23 KiB

11 years ago
11 years ago
11 years ago
8 years ago
8 years ago
11 years ago
11 years ago
11 years ago
11 years ago
  1. var moment = require('../module/moment');
  2. var util = require('../util');
  3. var DataSet = require('../DataSet');
  4. var DataView = require('../DataView');
  5. var Range = require('./Range');
  6. var Core = require('./Core');
  7. var TimeAxis = require('./component/TimeAxis');
  8. var CurrentTime = require('./component/CurrentTime');
  9. var CustomTime = require('./component/CustomTime');
  10. var ItemSet = require('./component/ItemSet');
  11. var printStyle = require('../shared/Validator').printStyle;
  12. var allOptions = require('./optionsTimeline').allOptions;
  13. var configureOptions = require('./optionsTimeline').configureOptions;
  14. var Configurator = require('../shared/Configurator').default;
  15. var Validator = require('../shared/Validator').default;
  16. /**
  17. * Create a timeline visualization
  18. * @param {HTMLElement} container
  19. * @param {vis.DataSet | vis.DataView | Array} [items]
  20. * @param {vis.DataSet | vis.DataView | Array} [groups]
  21. * @param {Object} [options] See Timeline.setOptions for the available options.
  22. * @constructor Timeline
  23. * @extends Core
  24. */
  25. function Timeline (container, items, groups, options) {
  26. if (!(this instanceof Timeline)) {
  27. throw new SyntaxError('Constructor must be called with the new operator');
  28. }
  29. // if the third element is options, the forth is groups (optionally);
  30. if (!(Array.isArray(groups) || groups instanceof DataSet || groups instanceof DataView) && groups instanceof Object) {
  31. var forthArgument = options;
  32. options = groups;
  33. groups = forthArgument;
  34. }
  35. // TODO: REMOVE THIS in the next MAJOR release
  36. // see https://github.com/almende/vis/issues/2511
  37. if (options && options.throttleRedraw) {
  38. console.warn("Timeline option \"throttleRedraw\" is DEPRICATED and no longer supported. It will be removed in the next MAJOR release.");
  39. }
  40. var me = this;
  41. this.defaultOptions = {
  42. start: null,
  43. end: null,
  44. autoResize: true,
  45. orientation: {
  46. axis: 'bottom', // axis orientation: 'bottom', 'top', or 'both'
  47. item: 'bottom' // not relevant
  48. },
  49. moment: moment,
  50. width: null,
  51. height: null,
  52. maxHeight: null,
  53. minHeight: null
  54. };
  55. this.options = util.deepExtend({}, this.defaultOptions);
  56. // Create the DOM, props, and emitter
  57. this._create(container);
  58. if (!options || (options && typeof options.rtl == "undefined")) {
  59. var directionFromDom, domNode = this.dom.root;
  60. while (!directionFromDom && domNode) {
  61. directionFromDom = window.getComputedStyle(domNode, null).direction;
  62. domNode = domNode.parentElement;
  63. }
  64. this.options.rtl = (directionFromDom && (directionFromDom.toLowerCase() == "rtl"));
  65. } else {
  66. this.options.rtl = options.rtl;
  67. }
  68. this.options.rollingMode = options && options.rollingMode;
  69. // all components listed here will be repainted automatically
  70. this.components = [];
  71. this.body = {
  72. dom: this.dom,
  73. domProps: this.props,
  74. emitter: {
  75. on: this.on.bind(this),
  76. off: this.off.bind(this),
  77. emit: this.emit.bind(this)
  78. },
  79. hiddenDates: [],
  80. util: {
  81. getScale: function () {
  82. return me.timeAxis.step.scale;
  83. },
  84. getStep: function () {
  85. return me.timeAxis.step.step;
  86. },
  87. toScreen: me._toScreen.bind(me),
  88. toGlobalScreen: me._toGlobalScreen.bind(me), // this refers to the root.width
  89. toTime: me._toTime.bind(me),
  90. toGlobalTime : me._toGlobalTime.bind(me)
  91. }
  92. };
  93. // range
  94. this.range = new Range(this.body, this.options);
  95. this.components.push(this.range);
  96. this.body.range = this.range;
  97. // time axis
  98. this.timeAxis = new TimeAxis(this.body, this.options);
  99. this.timeAxis2 = null; // used in case of orientation option 'both'
  100. this.components.push(this.timeAxis);
  101. // current time bar
  102. this.currentTime = new CurrentTime(this.body, this.options);
  103. this.components.push(this.currentTime);
  104. // item set
  105. this.itemSet = new ItemSet(this.body, this.options);
  106. this.components.push(this.itemSet);
  107. this.itemsData = null; // DataSet
  108. this.groupsData = null; // DataSet
  109. this.dom.root.onclick = function (event) {
  110. me.emit('click', me.getEventProperties(event))
  111. };
  112. this.dom.root.ondblclick = function (event) {
  113. me.emit('doubleClick', me.getEventProperties(event))
  114. };
  115. this.dom.root.oncontextmenu = function (event) {
  116. me.emit('contextmenu', me.getEventProperties(event))
  117. };
  118. this.dom.root.onmouseover = function (event) {
  119. me.emit('mouseOver', me.getEventProperties(event))
  120. };
  121. if(window.PointerEvent) {
  122. this.dom.root.onpointerdown = function (event) {
  123. me.emit('mouseDown', me.getEventProperties(event))
  124. };
  125. this.dom.root.onpointermove = function (event) {
  126. me.emit('mouseMove', me.getEventProperties(event))
  127. };
  128. this.dom.root.onpointerup = function (event) {
  129. me.emit('mouseUp', me.getEventProperties(event))
  130. };
  131. } else {
  132. this.dom.root.onmousemove = function (event) {
  133. me.emit('mouseMove', me.getEventProperties(event))
  134. };
  135. this.dom.root.onmousedown = function (event) {
  136. me.emit('mouseDown', me.getEventProperties(event))
  137. };
  138. this.dom.root.onmouseup = function (event) {
  139. me.emit('mouseUp', me.getEventProperties(event))
  140. };
  141. }
  142. //Single time autoscale/fit
  143. this.fitDone = false;
  144. this.on('changed', function (){
  145. if (this.itemsData == null || this.options.rollingMode) return;
  146. if (!me.fitDone) {
  147. me.fitDone = true;
  148. if (me.options.start != undefined || me.options.end != undefined) {
  149. if (me.options.start == undefined || me.options.end == undefined) {
  150. var range = me.getItemRange();
  151. }
  152. var start = me.options.start != undefined ? me.options.start : range.min;
  153. var end = me.options.end != undefined ? me.options.end : range.max;
  154. me.setWindow(start, end, {animation: false});
  155. }
  156. else {
  157. me.fit({animation: false});
  158. }
  159. }
  160. });
  161. // apply options
  162. if (options) {
  163. this.setOptions(options);
  164. }
  165. // IMPORTANT: THIS HAPPENS BEFORE SET ITEMS!
  166. if (groups) {
  167. this.setGroups(groups);
  168. }
  169. // create itemset
  170. if (items) {
  171. this.setItems(items);
  172. }
  173. // draw for the first time
  174. this._redraw();
  175. }
  176. // Extend the functionality from Core
  177. Timeline.prototype = new Core();
  178. /**
  179. * Load a configurator
  180. * @return {Object}
  181. * @private
  182. */
  183. Timeline.prototype._createConfigurator = function () {
  184. return new Configurator(this, this.dom.container, configureOptions);
  185. };
  186. /**
  187. * Force a redraw. The size of all items will be recalculated.
  188. * Can be useful to manually redraw when option autoResize=false and the window
  189. * has been resized, or when the items CSS has been changed.
  190. *
  191. * Note: this function will be overridden on construction with a trottled version
  192. */
  193. Timeline.prototype.redraw = function() {
  194. this.itemSet && this.itemSet.markDirty({refreshItems: true});
  195. this._redraw();
  196. };
  197. Timeline.prototype.setOptions = function (options) {
  198. // validate options
  199. let errorFound = Validator.validate(options, allOptions);
  200. if (errorFound === true) {
  201. console.log('%cErrors have been found in the supplied options object.', printStyle);
  202. }
  203. Core.prototype.setOptions.call(this, options);
  204. if ('type' in options) {
  205. if (options.type !== this.options.type) {
  206. this.options.type = options.type;
  207. // force recreation of all items
  208. var itemsData = this.itemsData;
  209. if (itemsData) {
  210. var selection = this.getSelection();
  211. this.setItems(null); // remove all
  212. this.setItems(itemsData); // add all
  213. this.setSelection(selection); // restore selection
  214. }
  215. }
  216. }
  217. };
  218. /**
  219. * Set items
  220. * @param {vis.DataSet | Array | null} items
  221. */
  222. Timeline.prototype.setItems = function(items) {
  223. // convert to type DataSet when needed
  224. var newDataSet;
  225. if (!items) {
  226. newDataSet = null;
  227. }
  228. else if (items instanceof DataSet || items instanceof DataView) {
  229. newDataSet = items;
  230. }
  231. else {
  232. // turn an array into a dataset
  233. newDataSet = new DataSet(items, {
  234. type: {
  235. start: 'Date',
  236. end: 'Date'
  237. }
  238. });
  239. }
  240. // set items
  241. this.itemsData = newDataSet;
  242. this.itemSet && this.itemSet.setItems(newDataSet);
  243. };
  244. /**
  245. * Set groups
  246. * @param {vis.DataSet | Array} groups
  247. */
  248. Timeline.prototype.setGroups = function(groups) {
  249. // convert to type DataSet when needed
  250. var newDataSet;
  251. if (!groups) {
  252. newDataSet = null;
  253. }
  254. else {
  255. var filter = function(group) {
  256. return group.visible !== false;
  257. }
  258. if (groups instanceof DataSet || groups instanceof DataView) {
  259. newDataSet = new DataView(groups,{filter: filter});
  260. }
  261. else {
  262. // turn an array into a dataset
  263. newDataSet = new DataSet(groups.filter(filter));
  264. }
  265. }
  266. this.groupsData = newDataSet;
  267. this.itemSet.setGroups(newDataSet);
  268. };
  269. /**
  270. * Set both items and groups in one go
  271. * @param {{items: (Array | vis.DataSet), groups: (Array | vis.DataSet)}} data
  272. */
  273. Timeline.prototype.setData = function (data) {
  274. if (data && data.groups) {
  275. this.setGroups(data.groups);
  276. }
  277. if (data && data.items) {
  278. this.setItems(data.items);
  279. }
  280. };
  281. /**
  282. * Set selected items by their id. Replaces the current selection
  283. * Unknown id's are silently ignored.
  284. * @param {string[] | string} [ids] An array with zero or more id's of the items to be
  285. * selected. If ids is an empty array, all items will be
  286. * unselected.
  287. * @param {Object} [options] Available options:
  288. * `focus: boolean`
  289. * If true, focus will be set to the selected item(s)
  290. * `animation: boolean | {duration: number, easingFunction: string}`
  291. * If true (default), the range is animated
  292. * smoothly to the new window. An object can be
  293. * provided to specify duration and easing function.
  294. * Default duration is 500 ms, and default easing
  295. * function is 'easeInOutQuad'.
  296. * Only applicable when option focus is true.
  297. */
  298. Timeline.prototype.setSelection = function(ids, options) {
  299. this.itemSet && this.itemSet.setSelection(ids);
  300. if (options && options.focus) {
  301. this.focus(ids, options);
  302. }
  303. };
  304. /**
  305. * Get the selected items by their id
  306. * @return {Array} ids The ids of the selected items
  307. */
  308. Timeline.prototype.getSelection = function() {
  309. return this.itemSet && this.itemSet.getSelection() || [];
  310. };
  311. /**
  312. * Adjust the visible window such that the selected item (or multiple items)
  313. * are centered on screen.
  314. * @param {string | String[]} id An item id or array with item ids
  315. * @param {Object} [options] Available options:
  316. * `animation: boolean | {duration: number, easingFunction: string}`
  317. * If true (default), the range is animated
  318. * smoothly to the new window. An object can be
  319. * provided to specify duration and easing function.
  320. * Default duration is 500 ms, and default easing
  321. * function is 'easeInOutQuad'.
  322. */
  323. Timeline.prototype.focus = function(id, options) {
  324. if (!this.itemsData || id == undefined) return;
  325. var ids = Array.isArray(id) ? id : [id];
  326. // get the specified item(s)
  327. var itemsData = this.itemsData.getDataSet().get(ids, {
  328. type: {
  329. start: 'Date',
  330. end: 'Date'
  331. }
  332. });
  333. // calculate minimum start and maximum end of specified items
  334. var start = null;
  335. var end = null;
  336. itemsData.forEach(function (itemData) {
  337. var s = itemData.start.valueOf();
  338. var e = 'end' in itemData ? itemData.end.valueOf() : itemData.start.valueOf();
  339. if (start === null || s < start) {
  340. start = s;
  341. }
  342. if (end === null || e > end) {
  343. end = e;
  344. }
  345. });
  346. if (start !== null && end !== null) {
  347. var me = this;
  348. // Use the first item for the vertical focus
  349. var item = this.itemSet.items[ids[0]];
  350. var startPos = this._getScrollTop() * -1;
  351. var initialVerticalScroll = null;
  352. // Setup a handler for each frame of the vertical scroll
  353. var verticalAnimationFrame = function(ease, willDraw, done) {
  354. var verticalScroll = getItemVerticalScroll(me, item);
  355. if(!initialVerticalScroll) {
  356. initialVerticalScroll = verticalScroll;
  357. }
  358. if(initialVerticalScroll.itemTop == verticalScroll.itemTop && !initialVerticalScroll.shouldScroll) {
  359. return; // We don't need to scroll, so do nothing
  360. }
  361. else if(initialVerticalScroll.itemTop != verticalScroll.itemTop && verticalScroll.shouldScroll) {
  362. // The redraw shifted elements, so reset the animation to correct
  363. initialVerticalScroll = verticalScroll;
  364. startPos = me._getScrollTop() * -1;
  365. }
  366. var from = startPos;
  367. var to = initialVerticalScroll.scrollOffset;
  368. var scrollTop = done ? to : (from + (to - from) * ease);
  369. me._setScrollTop(-scrollTop);
  370. if(!willDraw) {
  371. me._redraw();
  372. }
  373. };
  374. // Perform one last check at the end to make sure the final vertical
  375. // position is correct
  376. var finalVerticalCallback = function() {
  377. var finalVerticalScroll = getItemVerticalScroll(me, item);
  378. if(finalVerticalScroll.shouldScroll && finalVerticalScroll.itemTop != initialVerticalScroll.itemTop) {
  379. me._setScrollTop(-finalVerticalScroll.scrollOffset);
  380. me._redraw();
  381. }
  382. };
  383. // calculate the new middle and interval for the window
  384. var middle = (start + end) / 2;
  385. var interval = Math.max((this.range.end - this.range.start), (end - start) * 1.1);
  386. var animation = (options && options.animation !== undefined) ? options.animation : true;
  387. if(!animation) {
  388. // We aren't animating so set a default so that the final callback forces the vertical location
  389. initialVerticalScroll = {shouldScroll: false, scrollOffset: -1, itemTop: -1};
  390. }
  391. this.range.setRange(middle - interval / 2, middle + interval / 2, { animation: animation }, finalVerticalCallback, verticalAnimationFrame);
  392. // Let the redraw settle and finalize the position
  393. setTimeout(finalVerticalCallback, 100);
  394. }
  395. };
  396. /**
  397. * Set Timeline window such that it fits all items
  398. * @param {Object} [options] Available options:
  399. * `animation: boolean | {duration: number, easingFunction: string}`
  400. * If true (default), the range is animated
  401. * smoothly to the new window. An object can be
  402. * provided to specify duration and easing function.
  403. * Default duration is 500 ms, and default easing
  404. * function is 'easeInOutQuad'.
  405. */
  406. Timeline.prototype.fit = function (options) {
  407. var animation = (options && options.animation !== undefined) ? options.animation : true;
  408. var range;
  409. var dataset = this.itemsData && this.itemsData.getDataSet();
  410. if (dataset.length === 1 && dataset.get()[0].end === undefined) {
  411. // a single item -> don't fit, just show a range around the item from -4 to +3 days
  412. range = this.getDataRange();
  413. this.moveTo(range.min.valueOf(), {animation});
  414. }
  415. else {
  416. // exactly fit the items (plus a small margin)
  417. range = this.getItemRange();
  418. this.range.setRange(range.min, range.max, { animation: animation });
  419. }
  420. };
  421. /**
  422. *
  423. * @param {vis.Item} item
  424. * @returns {number}
  425. */
  426. function getStart(item) {
  427. return util.convert(item.data.start, 'Date').valueOf()
  428. }
  429. /**
  430. *
  431. * @param {vis.Item} item
  432. * @returns {number}
  433. */
  434. function getEnd(item) {
  435. var end = item.data.end != undefined ? item.data.end : item.data.start;
  436. return util.convert(end, 'Date').valueOf();
  437. }
  438. /**
  439. * @param {vis.Timeline} timeline
  440. * @param {vis.Item} item
  441. * @return {{shouldScroll: bool, scrollOffset: number, itemTop: number}}
  442. */
  443. function getItemVerticalScroll(timeline, item) {
  444. var leftHeight = timeline.props.leftContainer.height;
  445. var contentHeight = timeline.props.left.height;
  446. var group = item.parent;
  447. var offset = group.top;
  448. var shouldScroll = true;
  449. var orientation = timeline.timeAxis.options.orientation.axis;
  450. var itemTop = function () {
  451. if (orientation == "bottom") {
  452. return group.height - item.top - item.height;
  453. }
  454. else {
  455. return item.top;
  456. }
  457. };
  458. var currentScrollHeight = timeline._getScrollTop() * -1;
  459. var targetOffset = offset + itemTop();
  460. var height = item.height;
  461. if (targetOffset < currentScrollHeight) {
  462. if (offset + leftHeight <= offset + itemTop() + height) {
  463. offset += itemTop() - timeline.itemSet.options.margin.item.vertical;
  464. }
  465. }
  466. else if (targetOffset + height > currentScrollHeight + leftHeight) {
  467. offset += itemTop() + height - leftHeight + timeline.itemSet.options.margin.item.vertical;
  468. }
  469. else {
  470. shouldScroll = false;
  471. }
  472. offset = Math.min(offset, contentHeight - leftHeight);
  473. return { shouldScroll: shouldScroll, scrollOffset: offset, itemTop: targetOffset };
  474. }
  475. /**
  476. * Determine the range of the items, taking into account their actual width
  477. * and a margin of 10 pixels on both sides.
  478. *
  479. * @returns {{min: Date, max: Date}}
  480. */
  481. Timeline.prototype.getItemRange = function () {
  482. // get a rough approximation for the range based on the items start and end dates
  483. var range = this.getDataRange();
  484. var min = range.min !== null ? range.min.valueOf() : null;
  485. var max = range.max !== null ? range.max.valueOf() : null;
  486. var minItem = null;
  487. var maxItem = null;
  488. if (min != null && max != null) {
  489. var interval = (max - min); // ms
  490. if (interval <= 0) {
  491. interval = 10;
  492. }
  493. var factor = interval / this.props.center.width;
  494. var redrawQueue = {};
  495. var redrawQueueLength = 0;
  496. // collect redraw functions
  497. util.forEach(this.itemSet.items, function (item, key) {
  498. if (item.groupShowing) {
  499. var returnQueue = true;
  500. redrawQueue[key] = item.redraw(returnQueue);
  501. redrawQueueLength = redrawQueue[key].length;
  502. }
  503. })
  504. var needRedraw = redrawQueueLength > 0;
  505. if (needRedraw) {
  506. // redraw all regular items
  507. for (var i = 0; i < redrawQueueLength; i++) {
  508. util.forEach(redrawQueue, function (fns) {
  509. fns[i]();
  510. });
  511. }
  512. }
  513. // calculate the date of the left side and right side of the items given
  514. util.forEach(this.itemSet.items, function (item) {
  515. var start = getStart(item);
  516. var end = getEnd(item);
  517. var startSide;
  518. var endSide;
  519. if (this.options.rtl) {
  520. startSide = start - (item.getWidthRight() + 10) * factor;
  521. endSide = end + (item.getWidthLeft() + 10) * factor;
  522. } else {
  523. startSide = start - (item.getWidthLeft() + 10) * factor;
  524. endSide = end + (item.getWidthRight() + 10) * factor;
  525. }
  526. if (startSide < min) {
  527. min = startSide;
  528. minItem = item;
  529. }
  530. if (endSide > max) {
  531. max = endSide;
  532. maxItem = item;
  533. }
  534. }.bind(this));
  535. if (minItem && maxItem) {
  536. var lhs = minItem.getWidthLeft() + 10;
  537. var rhs = maxItem.getWidthRight() + 10;
  538. var delta = this.props.center.width - lhs - rhs; // px
  539. if (delta > 0) {
  540. if (this.options.rtl) {
  541. min = getStart(minItem) - rhs * interval / delta; // ms
  542. max = getEnd(maxItem) + lhs * interval / delta; // ms
  543. } else {
  544. min = getStart(minItem) - lhs * interval / delta; // ms
  545. max = getEnd(maxItem) + rhs * interval / delta; // ms
  546. }
  547. }
  548. }
  549. }
  550. return {
  551. min: min != null ? new Date(min) : null,
  552. max: max != null ? new Date(max) : null
  553. }
  554. };
  555. /**
  556. * Calculate the data range of the items start and end dates
  557. * @returns {{min: Date, max: Date}}
  558. */
  559. Timeline.prototype.getDataRange = function() {
  560. var min = null;
  561. var max = null;
  562. var dataset = this.itemsData && this.itemsData.getDataSet();
  563. if (dataset) {
  564. dataset.forEach(function (item) {
  565. var start = util.convert(item.start, 'Date').valueOf();
  566. var end = util.convert(item.end != undefined ? item.end : item.start, 'Date').valueOf();
  567. if (min === null || start < min) {
  568. min = start;
  569. }
  570. if (max === null || end > max) {
  571. max = end;
  572. }
  573. });
  574. }
  575. return {
  576. min: min != null ? new Date(min) : null,
  577. max: max != null ? new Date(max) : null
  578. }
  579. };
  580. /**
  581. * Generate Timeline related information from an event
  582. * @param {Event} event
  583. * @return {Object} An object with related information, like on which area
  584. * The event happened, whether clicked on an item, etc.
  585. */
  586. Timeline.prototype.getEventProperties = function (event) {
  587. var clientX = event.center ? event.center.x : event.clientX;
  588. var clientY = event.center ? event.center.y : event.clientY;
  589. var x;
  590. if (this.options.rtl) {
  591. x = util.getAbsoluteRight(this.dom.centerContainer) - clientX;
  592. } else {
  593. x = clientX - util.getAbsoluteLeft(this.dom.centerContainer);
  594. }
  595. var y = clientY - util.getAbsoluteTop(this.dom.centerContainer);
  596. var item = this.itemSet.itemFromTarget(event);
  597. var group = this.itemSet.groupFromTarget(event);
  598. var customTime = CustomTime.customTimeFromTarget(event);
  599. var snap = this.itemSet.options.snap || null;
  600. var scale = this.body.util.getScale();
  601. var step = this.body.util.getStep();
  602. var time = this._toTime(x);
  603. var snappedTime = snap ? snap(time, scale, step) : time;
  604. var element = util.getTarget(event);
  605. var what = null;
  606. if (item != null) {what = 'item';}
  607. else if (customTime != null) {what = 'custom-time';}
  608. else if (util.hasParent(element, this.timeAxis.dom.foreground)) {what = 'axis';}
  609. else if (this.timeAxis2 && util.hasParent(element, this.timeAxis2.dom.foreground)) {what = 'axis';}
  610. else if (util.hasParent(element, this.itemSet.dom.labelSet)) {what = 'group-label';}
  611. else if (util.hasParent(element, this.currentTime.bar)) {what = 'current-time';}
  612. else if (util.hasParent(element, this.dom.center)) {what = 'background';}
  613. return {
  614. event: event,
  615. item: item ? item.id : null,
  616. group: group ? group.groupId : null,
  617. what: what,
  618. pageX: event.srcEvent ? event.srcEvent.pageX : event.pageX,
  619. pageY: event.srcEvent ? event.srcEvent.pageY : event.pageY,
  620. x: x,
  621. y: y,
  622. time: time,
  623. snappedTime: snappedTime
  624. }
  625. };
  626. /**
  627. * Toggle Timeline rolling mode
  628. */
  629. Timeline.prototype.toggleRollingMode = function () {
  630. if (this.range.rolling) {
  631. this.range.stopRolling();
  632. } else {
  633. if (this.options.rollingMode == undefined) {
  634. this.setOptions(this.options)
  635. }
  636. this.range.startRolling();
  637. }
  638. }
  639. module.exports = Timeline;