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.

589 lines
16 KiB

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