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.

611 lines
17 KiB

12 years ago
12 years ago
12 years ago
12 years ago
12 years ago
12 years ago
12 years ago
12 years ago
12 years ago
12 years ago
12 years ago
  1. /**
  2. * Create a timeline visualization
  3. * @param {HTMLElement} container
  4. * @param {vis.DataSet | Array | google.visualization.DataTable} [items]
  5. * @param {Object} [options] See Timeline.setOptions for the available options.
  6. * @constructor
  7. */
  8. function Timeline (container, items, options) {
  9. var me = this;
  10. var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0);
  11. this.options = {
  12. orientation: 'bottom',
  13. autoResize: true,
  14. editable: false,
  15. selectable: true,
  16. snap: null, // will be specified after timeaxis is created
  17. min: null,
  18. max: null,
  19. zoomMin: 10, // milliseconds
  20. zoomMax: 1000 * 60 * 60 * 24 * 365 * 10000, // milliseconds
  21. // moveable: true, // TODO: option moveable
  22. // zoomable: true, // TODO: option zoomable
  23. showMinorLabels: true,
  24. showMajorLabels: true,
  25. showCurrentTime: false,
  26. showCustomTime: false,
  27. onAdd: function (item, callback) {
  28. callback(item);
  29. },
  30. onUpdate: function (item, callback) {
  31. callback(item);
  32. },
  33. onMove: function (item, callback) {
  34. callback(item);
  35. },
  36. onRemove: function (item, callback) {
  37. callback(item);
  38. }
  39. };
  40. // controller
  41. this.controller = new Controller();
  42. // root panel
  43. if (!container) {
  44. throw new Error('No container element provided');
  45. }
  46. var rootOptions = Object.create(this.options);
  47. rootOptions.height = function () {
  48. // TODO: change to height
  49. if (me.options.height) {
  50. // fixed height
  51. return me.options.height;
  52. }
  53. else {
  54. // auto height
  55. return (me.timeaxis.height + me.content.height) + 'px';
  56. }
  57. };
  58. this.rootPanel = new RootPanel(container, rootOptions);
  59. this.controller.add(this.rootPanel);
  60. // single select (or unselect) when tapping an item
  61. this.controller.on('tap', this._onSelectItem.bind(this));
  62. // multi select when holding mouse/touch, or on ctrl+click
  63. this.controller.on('hold', this._onMultiSelectItem.bind(this));
  64. // add item on doubletap
  65. this.controller.on('doubletap', this._onAddItem.bind(this));
  66. // item panel
  67. var itemOptions = Object.create(this.options);
  68. itemOptions.left = function () {
  69. return me.labelPanel.width;
  70. };
  71. itemOptions.width = function () {
  72. return me.rootPanel.width - me.labelPanel.width;
  73. };
  74. itemOptions.top = null;
  75. itemOptions.height = null;
  76. this.itemPanel = new Panel(this.rootPanel, [], itemOptions);
  77. this.controller.add(this.itemPanel);
  78. // label panel
  79. var labelOptions = Object.create(this.options);
  80. labelOptions.top = null;
  81. labelOptions.left = null;
  82. labelOptions.height = null;
  83. labelOptions.width = function () {
  84. if (me.content && typeof me.content.getLabelsWidth === 'function') {
  85. return me.content.getLabelsWidth();
  86. }
  87. else {
  88. return 0;
  89. }
  90. };
  91. this.labelPanel = new Panel(this.rootPanel, [], labelOptions);
  92. this.controller.add(this.labelPanel);
  93. // range
  94. var rangeOptions = Object.create(this.options);
  95. this.range = new Range(rangeOptions);
  96. this.range.setRange(
  97. now.clone().add('days', -3).valueOf(),
  98. now.clone().add('days', 4).valueOf()
  99. );
  100. this.range.subscribe(this.controller, this.rootPanel, 'move', 'horizontal');
  101. this.range.subscribe(this.controller, this.rootPanel, 'zoom', 'horizontal');
  102. this.range.on('rangechange', function (properties) {
  103. var force = true;
  104. me.controller.emit('rangechange', properties);
  105. me.controller.emit('request-reflow', force);
  106. });
  107. this.range.on('rangechanged', function (properties) {
  108. var force = true;
  109. me.controller.emit('rangechanged', properties);
  110. me.controller.emit('request-reflow', force);
  111. });
  112. // time axis
  113. var timeaxisOptions = Object.create(rootOptions);
  114. timeaxisOptions.range = this.range;
  115. timeaxisOptions.left = null;
  116. timeaxisOptions.top = null;
  117. timeaxisOptions.width = '100%';
  118. timeaxisOptions.height = null;
  119. this.timeaxis = new TimeAxis(this.itemPanel, [], timeaxisOptions);
  120. this.timeaxis.setRange(this.range);
  121. this.controller.add(this.timeaxis);
  122. this.options.snap = this.timeaxis.snap.bind(this.timeaxis);
  123. // current time bar
  124. this.currenttime = new CurrentTime(this.timeaxis, [], rootOptions);
  125. this.controller.add(this.currenttime);
  126. // custom time bar
  127. this.customtime = new CustomTime(this.timeaxis, [], rootOptions);
  128. this.controller.add(this.customtime);
  129. // create groupset
  130. this.setGroups(null);
  131. this.itemsData = null; // DataSet
  132. this.groupsData = null; // DataSet
  133. // apply options
  134. if (options) {
  135. this.setOptions(options);
  136. }
  137. // create itemset and groupset
  138. if (items) {
  139. this.setItems(items);
  140. }
  141. }
  142. /**
  143. * Add an event listener to the timeline
  144. * @param {String} event Available events: select, rangechange, rangechanged,
  145. * timechange, timechanged
  146. * @param {function} callback
  147. */
  148. Timeline.prototype.on = function on (event, callback) {
  149. this.controller.on(event, callback);
  150. };
  151. /**
  152. * Add an event listener from the timeline
  153. * @param {String} event
  154. * @param {function} callback
  155. */
  156. Timeline.prototype.off = function off (event, callback) {
  157. this.controller.off(event, callback);
  158. };
  159. /**
  160. * Set options
  161. * @param {Object} options TODO: describe the available options
  162. */
  163. Timeline.prototype.setOptions = function (options) {
  164. util.extend(this.options, options);
  165. // force update of range (apply new min/max etc.)
  166. // both start and end are optional
  167. this.range.setRange(options.start, options.end);
  168. if ('editable' in options || 'selectable' in options) {
  169. if (this.options.selectable) {
  170. // force update of selection
  171. this.setSelection(this.getSelection());
  172. }
  173. else {
  174. // remove selection
  175. this.setSelection([]);
  176. }
  177. }
  178. // validate the callback functions
  179. var validateCallback = (function (fn) {
  180. if (!(this.options[fn] instanceof Function) || this.options[fn].length != 2) {
  181. throw new Error('option ' + fn + ' must be a function ' + fn + '(item, callback)');
  182. }
  183. }).bind(this);
  184. ['onAdd', 'onUpdate', 'onRemove', 'onMove'].forEach(validateCallback);
  185. this.controller.reflow();
  186. this.controller.repaint();
  187. };
  188. /**
  189. * Set a custom time bar
  190. * @param {Date} time
  191. */
  192. Timeline.prototype.setCustomTime = function (time) {
  193. if (!this.customtime) {
  194. throw new Error('Cannot get custom time: Custom time bar is not enabled');
  195. }
  196. this.customtime.setCustomTime(time);
  197. };
  198. /**
  199. * Retrieve the current custom time.
  200. * @return {Date} customTime
  201. */
  202. Timeline.prototype.getCustomTime = function() {
  203. if (!this.customtime) {
  204. throw new Error('Cannot get custom time: Custom time bar is not enabled');
  205. }
  206. return this.customtime.getCustomTime();
  207. };
  208. /**
  209. * Set items
  210. * @param {vis.DataSet | Array | google.visualization.DataTable | null} items
  211. */
  212. Timeline.prototype.setItems = function(items) {
  213. var initialLoad = (this.itemsData == null);
  214. // convert to type DataSet when needed
  215. var newDataSet;
  216. if (!items) {
  217. newDataSet = null;
  218. }
  219. else if (items instanceof DataSet) {
  220. newDataSet = items;
  221. }
  222. if (!(items instanceof DataSet)) {
  223. newDataSet = new DataSet({
  224. convert: {
  225. start: 'Date',
  226. end: 'Date'
  227. }
  228. });
  229. newDataSet.add(items);
  230. }
  231. // set items
  232. this.itemsData = newDataSet;
  233. this.content.setItems(newDataSet);
  234. if (initialLoad && (this.options.start == undefined || this.options.end == undefined)) {
  235. // apply the data range as range
  236. var dataRange = this.getItemRange();
  237. // add 5% space on both sides
  238. var start = dataRange.min;
  239. var end = dataRange.max;
  240. if (start != null && end != null) {
  241. var interval = (end.valueOf() - start.valueOf());
  242. if (interval <= 0) {
  243. // prevent an empty interval
  244. interval = 24 * 60 * 60 * 1000; // 1 day
  245. }
  246. start = new Date(start.valueOf() - interval * 0.05);
  247. end = new Date(end.valueOf() + interval * 0.05);
  248. }
  249. // override specified start and/or end date
  250. if (this.options.start != undefined) {
  251. start = util.convert(this.options.start, 'Date');
  252. }
  253. if (this.options.end != undefined) {
  254. end = util.convert(this.options.end, 'Date');
  255. }
  256. // skip range set if there is no start and end date
  257. if (start === null && end === null) {
  258. return;
  259. }
  260. // if start and end dates are set but cannot be satisfyed due to zoom restrictions — correct end date
  261. if (start != null && end != null) {
  262. var diff = end.valueOf() - start.valueOf();
  263. if (this.options.zoomMax != undefined && this.options.zoomMax < diff) {
  264. end = new Date(start.valueOf() + this.options.zoomMax);
  265. }
  266. if (this.options.zoomMin != undefined && this.options.zoomMin > diff) {
  267. end = new Date(start.valueOf() + this.options.zoomMin);
  268. }
  269. }
  270. this.range.setRange(start, end);
  271. }
  272. };
  273. /**
  274. * Set groups
  275. * @param {vis.DataSet | Array | google.visualization.DataTable} groups
  276. */
  277. Timeline.prototype.setGroups = function(groups) {
  278. var me = this;
  279. this.groupsData = groups;
  280. // switch content type between ItemSet or GroupSet when needed
  281. var Type = this.groupsData ? GroupSet : ItemSet;
  282. if (!(this.content instanceof Type)) {
  283. // remove old content set
  284. if (this.content) {
  285. this.content.hide();
  286. if (this.content.setItems) {
  287. this.content.setItems(); // disconnect from items
  288. }
  289. if (this.content.setGroups) {
  290. this.content.setGroups(); // disconnect from groups
  291. }
  292. this.controller.remove(this.content);
  293. }
  294. // create new content set
  295. var options = Object.create(this.options);
  296. util.extend(options, {
  297. top: function () {
  298. if (me.options.orientation == 'top') {
  299. return me.timeaxis.height;
  300. }
  301. else {
  302. return me.itemPanel.height - me.timeaxis.height - me.content.height;
  303. }
  304. },
  305. left: null,
  306. width: '100%',
  307. height: function () {
  308. if (me.options.height) {
  309. // fixed height
  310. return me.itemPanel.height - me.timeaxis.height;
  311. }
  312. else {
  313. // auto height
  314. return null;
  315. }
  316. },
  317. maxHeight: function () {
  318. // TODO: change maxHeight to be a css string like '100%' or '300px'
  319. if (me.options.maxHeight) {
  320. if (!util.isNumber(me.options.maxHeight)) {
  321. throw new TypeError('Number expected for property maxHeight');
  322. }
  323. return me.options.maxHeight - me.timeaxis.height;
  324. }
  325. else {
  326. return null;
  327. }
  328. },
  329. labelContainer: function () {
  330. return me.labelPanel.getContainer();
  331. }
  332. });
  333. this.content = new Type(this.itemPanel, [this.timeaxis], options);
  334. if (this.content.setRange) {
  335. this.content.setRange(this.range);
  336. }
  337. if (this.content.setItems) {
  338. this.content.setItems(this.itemsData);
  339. }
  340. if (this.content.setGroups) {
  341. this.content.setGroups(this.groupsData);
  342. }
  343. this.controller.add(this.content);
  344. }
  345. };
  346. /**
  347. * Get the data range of the item set.
  348. * @returns {{min: Date, max: Date}} range A range with a start and end Date.
  349. * When no minimum is found, min==null
  350. * When no maximum is found, max==null
  351. */
  352. Timeline.prototype.getItemRange = function getItemRange() {
  353. // calculate min from start filed
  354. var itemsData = this.itemsData,
  355. min = null,
  356. max = null;
  357. if (itemsData) {
  358. // calculate the minimum value of the field 'start'
  359. var minItem = itemsData.min('start');
  360. min = minItem ? minItem.start.valueOf() : null;
  361. // calculate maximum value of fields 'start' and 'end'
  362. var maxStartItem = itemsData.max('start');
  363. if (maxStartItem) {
  364. max = maxStartItem.start.valueOf();
  365. }
  366. var maxEndItem = itemsData.max('end');
  367. if (maxEndItem) {
  368. if (max == null) {
  369. max = maxEndItem.end.valueOf();
  370. }
  371. else {
  372. max = Math.max(max, maxEndItem.end.valueOf());
  373. }
  374. }
  375. }
  376. return {
  377. min: (min != null) ? new Date(min) : null,
  378. max: (max != null) ? new Date(max) : null
  379. };
  380. };
  381. /**
  382. * Set selected items by their id. Replaces the current selection
  383. * Unknown id's are silently ignored.
  384. * @param {Array} [ids] An array with zero or more id's of the items to be
  385. * selected. If ids is an empty array, all items will be
  386. * unselected.
  387. */
  388. Timeline.prototype.setSelection = function setSelection (ids) {
  389. if (this.content) this.content.setSelection(ids);
  390. };
  391. /**
  392. * Get the selected items by their id
  393. * @return {Array} ids The ids of the selected items
  394. */
  395. Timeline.prototype.getSelection = function getSelection() {
  396. return this.content ? this.content.getSelection() : [];
  397. };
  398. /**
  399. * Set the visible window. Both parameters are optional, you can change only
  400. * start or only end.
  401. * @param {Date | Number | String} [start] Start date of visible window
  402. * @param {Date | Number | String} [end] End date of visible window
  403. */
  404. Timeline.prototype.setWindow = function setWindow(start, end) {
  405. this.range.setRange(start, end);
  406. };
  407. /**
  408. * Get the visible window
  409. * @return {{start: Date, end: Date}} Visible range
  410. */
  411. Timeline.prototype.getWindow = function setWindow() {
  412. var range = this.range.getRange();
  413. return {
  414. start: new Date(range.start),
  415. end: new Date(range.end)
  416. };
  417. };
  418. /**
  419. * Handle selecting/deselecting an item when tapping it
  420. * @param {Event} event
  421. * @private
  422. */
  423. // TODO: move this function to ItemSet
  424. Timeline.prototype._onSelectItem = function (event) {
  425. if (!this.options.selectable) return;
  426. var ctrlKey = event.gesture.srcEvent && event.gesture.srcEvent.ctrlKey;
  427. var shiftKey = event.gesture.srcEvent && event.gesture.srcEvent.shiftKey;
  428. if (ctrlKey || shiftKey) {
  429. this._onMultiSelectItem(event);
  430. return;
  431. }
  432. var item = ItemSet.itemFromTarget(event);
  433. var selection = item ? [item.id] : [];
  434. this.setSelection(selection);
  435. this.controller.emit('select', {
  436. items: this.getSelection()
  437. });
  438. event.stopPropagation();
  439. };
  440. /**
  441. * Handle creation and updates of an item on double tap
  442. * @param event
  443. * @private
  444. */
  445. Timeline.prototype._onAddItem = function (event) {
  446. if (!this.options.selectable) return;
  447. if (!this.options.editable) return;
  448. var me = this,
  449. item = ItemSet.itemFromTarget(event);
  450. if (item) {
  451. // update item
  452. // execute async handler to update the item (or cancel it)
  453. var itemData = me.itemsData.get(item.id); // get a clone of the data from the dataset
  454. this.options.onUpdate(itemData, function (itemData) {
  455. if (itemData) {
  456. me.itemsData.update(itemData);
  457. }
  458. });
  459. }
  460. else {
  461. // add item
  462. var xAbs = vis.util.getAbsoluteLeft(this.rootPanel.frame);
  463. var x = event.gesture.center.pageX - xAbs;
  464. var newItem = {
  465. start: this.timeaxis.snap(this._toTime(x)),
  466. content: 'new item'
  467. };
  468. var id = util.randomUUID();
  469. newItem[this.itemsData.fieldId] = id;
  470. var group = GroupSet.groupFromTarget(event);
  471. if (group) {
  472. newItem.group = group.groupId;
  473. }
  474. // execute async handler to customize (or cancel) adding an item
  475. this.options.onAdd(newItem, function (item) {
  476. if (item) {
  477. me.itemsData.add(newItem);
  478. // select the created item after it is repainted
  479. me.controller.once('repaint', function () {
  480. me.setSelection([id]);
  481. me.controller.emit('select', {
  482. items: me.getSelection()
  483. });
  484. }.bind(me));
  485. }
  486. });
  487. }
  488. };
  489. /**
  490. * Handle selecting/deselecting multiple items when holding an item
  491. * @param {Event} event
  492. * @private
  493. */
  494. // TODO: move this function to ItemSet
  495. Timeline.prototype._onMultiSelectItem = function (event) {
  496. if (!this.options.selectable) return;
  497. var selection,
  498. item = ItemSet.itemFromTarget(event);
  499. if (item) {
  500. // multi select items
  501. selection = this.getSelection(); // current selection
  502. var index = selection.indexOf(item.id);
  503. if (index == -1) {
  504. // item is not yet selected -> select it
  505. selection.push(item.id);
  506. }
  507. else {
  508. // item is already selected -> deselect it
  509. selection.splice(index, 1);
  510. }
  511. this.setSelection(selection);
  512. this.controller.emit('select', {
  513. items: this.getSelection()
  514. });
  515. event.stopPropagation();
  516. }
  517. };
  518. /**
  519. * Convert a position on screen (pixels) to a datetime
  520. * @param {int} x Position on the screen in pixels
  521. * @return {Date} time The datetime the corresponds with given position x
  522. * @private
  523. */
  524. Timeline.prototype._toTime = function _toTime(x) {
  525. var conversion = this.range.conversion(this.content.width);
  526. return new Date(x / conversion.scale + conversion.offset);
  527. };
  528. /**
  529. * Convert a datetime (Date object) into a position on the screen
  530. * @param {Date} time A date
  531. * @return {int} x The position on the screen in pixels which corresponds
  532. * with the given date.
  533. * @private
  534. */
  535. Timeline.prototype._toScreen = function _toScreen(time) {
  536. var conversion = this.range.conversion(this.content.width);
  537. return (time.valueOf() - conversion.offset) * conversion.scale;
  538. };