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.

658 lines
20 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
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
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
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
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
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
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. var util = require('../util');
  2. var hammerUtil = require('../hammerUtil');
  3. var moment = require('../module/moment');
  4. var Component = require('./component/Component');
  5. var DateUtil = require('./DateUtil');
  6. /**
  7. * @constructor Range
  8. * A Range controls a numeric range with a start and end value.
  9. * The Range adjusts the range based on mouse events or programmatic changes,
  10. * and triggers events when the range is changing or has been changed.
  11. * @param {{dom: Object, domProps: Object, emitter: Emitter}} body
  12. * @param {Object} [options] See description at Range.setOptions
  13. */
  14. function Range(body, options) {
  15. var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0);
  16. this.start = now.clone().add(-3, 'days').valueOf(); // Number
  17. this.end = now.clone().add(4, 'days').valueOf(); // Number
  18. this.body = body;
  19. this.deltaDifference = 0;
  20. // default options
  21. this.defaultOptions = {
  22. start: null,
  23. end: null,
  24. direction: 'horizontal', // 'horizontal' or 'vertical'
  25. moveable: true,
  26. zoomable: true,
  27. min: null,
  28. max: null,
  29. zoomMin: 10, // milliseconds
  30. zoomMax: 1000 * 60 * 60 * 24 * 365 * 10000 // milliseconds
  31. };
  32. this.options = util.extend({}, this.defaultOptions);
  33. this.props = {
  34. touch: {}
  35. };
  36. this.animateTimer = null;
  37. // drag listeners for dragging
  38. this.body.emitter.on('dragstart', this._onDragStart.bind(this));
  39. this.body.emitter.on('drag', this._onDrag.bind(this));
  40. this.body.emitter.on('dragend', this._onDragEnd.bind(this));
  41. // ignore dragging when holding
  42. this.body.emitter.on('hold', this._onHold.bind(this));
  43. // mouse wheel for zooming
  44. this.body.emitter.on('mousewheel', this._onMouseWheel.bind(this));
  45. this.body.emitter.on('DOMMouseScroll', this._onMouseWheel.bind(this)); // For FF
  46. // pinch to zoom
  47. this.body.emitter.on('touch', this._onTouch.bind(this));
  48. this.body.emitter.on('pinch', this._onPinch.bind(this));
  49. this.setOptions(options);
  50. }
  51. Range.prototype = new Component();
  52. /**
  53. * Set options for the range controller
  54. * @param {Object} options Available options:
  55. * {Number | Date | String} start Start date for the range
  56. * {Number | Date | String} end End date for the range
  57. * {Number} min Minimum value for start
  58. * {Number} max Maximum value for end
  59. * {Number} zoomMin Set a minimum value for
  60. * (end - start).
  61. * {Number} zoomMax Set a maximum value for
  62. * (end - start).
  63. * {Boolean} moveable Enable moving of the range
  64. * by dragging. True by default
  65. * {Boolean} zoomable Enable zooming of the range
  66. * by pinching/scrolling. True by default
  67. */
  68. Range.prototype.setOptions = function (options) {
  69. if (options) {
  70. // copy the options that we know
  71. var fields = ['direction', 'min', 'max', 'zoomMin', 'zoomMax', 'moveable', 'zoomable', 'activate', 'hiddenDates'];
  72. util.selectiveExtend(fields, this.options, options);
  73. if ('start' in options || 'end' in options) {
  74. // apply a new range. both start and end are optional
  75. this.setRange(options.start, options.end);
  76. }
  77. }
  78. };
  79. /**
  80. * Test whether direction has a valid value
  81. * @param {String} direction 'horizontal' or 'vertical'
  82. */
  83. function validateDirection (direction) {
  84. if (direction != 'horizontal' && direction != 'vertical') {
  85. throw new TypeError('Unknown direction "' + direction + '". ' +
  86. 'Choose "horizontal" or "vertical".');
  87. }
  88. }
  89. /**
  90. * Set a new start and end range
  91. * @param {Date | Number | String} [start]
  92. * @param {Date | Number | String} [end]
  93. * @param {boolean | number} [animate=false] If true, the range is animated
  94. * smoothly to the new window.
  95. * If animate is a number, the
  96. * number is taken as duration
  97. * Default duration is 500 ms.
  98. *
  99. */
  100. Range.prototype.setRange = function(start, end, animate) {
  101. var _start = start != undefined ? util.convert(start, 'Date').valueOf() : null;
  102. var _end = end != undefined ? util.convert(end, 'Date').valueOf() : null;
  103. this._cancelAnimation();
  104. if (animate) {
  105. var me = this;
  106. var initStart = this.start;
  107. var initEnd = this.end;
  108. var duration = typeof animate === 'number' ? animate : 500;
  109. var initTime = new Date().valueOf();
  110. var anyChanged = false;
  111. function next() {
  112. if (!me.props.touch.dragging) {
  113. var now = new Date().valueOf();
  114. var time = now - initTime;
  115. var done = time > duration;
  116. var s = (done || _start === null) ? _start : util.easeInOutQuad(time, initStart, _start, duration);
  117. var e = (done || _end === null) ? _end : util.easeInOutQuad(time, initEnd, _end, duration);
  118. changed = me._applyRange(s, e);
  119. DateUtil.updateHiddenDates(me.body, me.options.hiddenDates);
  120. anyChanged = anyChanged || changed;
  121. if (changed) {
  122. me.body.emitter.emit('rangechange', {start: new Date(me.start), end: new Date(me.end)});
  123. }
  124. if (done) {
  125. if (anyChanged) {
  126. me.body.emitter.emit('rangechanged', {start: new Date(me.start), end: new Date(me.end)});
  127. }
  128. }
  129. else {
  130. // animate with as high as possible frame rate, leave 20 ms in between
  131. // each to prevent the browser from blocking
  132. me.animateTimer = setTimeout(next, 20);
  133. }
  134. }
  135. }
  136. return next();
  137. }
  138. else {
  139. var changed = this._applyRange(_start, _end);
  140. DateUtil.updateHiddenDates(this.body, this.options.hiddenDates);
  141. if (changed) {
  142. var params = {start: new Date(this.start), end: new Date(this.end)};
  143. this.body.emitter.emit('rangechange', params);
  144. this.body.emitter.emit('rangechanged', params);
  145. }
  146. }
  147. };
  148. /**
  149. * Stop an animation
  150. * @private
  151. */
  152. Range.prototype._cancelAnimation = function () {
  153. if (this.animateTimer) {
  154. clearTimeout(this.animateTimer);
  155. this.animateTimer = null;
  156. }
  157. };
  158. /**
  159. * Set a new start and end range. This method is the same as setRange, but
  160. * does not trigger a range change and range changed event, and it returns
  161. * true when the range is changed
  162. * @param {Number} [start]
  163. * @param {Number} [end]
  164. * @return {Boolean} changed
  165. * @private
  166. */
  167. Range.prototype._applyRange = function(start, end) {
  168. var newStart = (start != null) ? util.convert(start, 'Date').valueOf() : this.start,
  169. newEnd = (end != null) ? util.convert(end, 'Date').valueOf() : this.end,
  170. max = (this.options.max != null) ? util.convert(this.options.max, 'Date').valueOf() : null,
  171. min = (this.options.min != null) ? util.convert(this.options.min, 'Date').valueOf() : null,
  172. diff;
  173. // check for valid number
  174. if (isNaN(newStart) || newStart === null) {
  175. throw new Error('Invalid start "' + start + '"');
  176. }
  177. if (isNaN(newEnd) || newEnd === null) {
  178. throw new Error('Invalid end "' + end + '"');
  179. }
  180. // prevent start < end
  181. if (newEnd < newStart) {
  182. newEnd = newStart;
  183. }
  184. // prevent start < min
  185. if (min !== null) {
  186. if (newStart < min) {
  187. diff = (min - newStart);
  188. newStart += diff;
  189. newEnd += diff;
  190. // prevent end > max
  191. if (max != null) {
  192. if (newEnd > max) {
  193. newEnd = max;
  194. }
  195. }
  196. }
  197. }
  198. // prevent end > max
  199. if (max !== null) {
  200. if (newEnd > max) {
  201. diff = (newEnd - max);
  202. newStart -= diff;
  203. newEnd -= diff;
  204. // prevent start < min
  205. if (min != null) {
  206. if (newStart < min) {
  207. newStart = min;
  208. }
  209. }
  210. }
  211. }
  212. // prevent (end-start) < zoomMin
  213. if (this.options.zoomMin !== null) {
  214. var zoomMin = parseFloat(this.options.zoomMin);
  215. if (zoomMin < 0) {
  216. zoomMin = 0;
  217. }
  218. if ((newEnd - newStart) < zoomMin) {
  219. if ((this.end - this.start) === zoomMin) {
  220. // ignore this action, we are already zoomed to the minimum
  221. newStart = this.start;
  222. newEnd = this.end;
  223. }
  224. else {
  225. // zoom to the minimum
  226. diff = (zoomMin - (newEnd - newStart));
  227. newStart -= diff / 2;
  228. newEnd += diff / 2;
  229. }
  230. }
  231. }
  232. // prevent (end-start) > zoomMax
  233. if (this.options.zoomMax !== null) {
  234. var zoomMax = parseFloat(this.options.zoomMax);
  235. if (zoomMax < 0) {
  236. zoomMax = 0;
  237. }
  238. if ((newEnd - newStart) > zoomMax) {
  239. if ((this.end - this.start) === zoomMax) {
  240. // ignore this action, we are already zoomed to the maximum
  241. newStart = this.start;
  242. newEnd = this.end;
  243. }
  244. else {
  245. // zoom to the maximum
  246. diff = ((newEnd - newStart) - zoomMax);
  247. newStart += diff / 2;
  248. newEnd -= diff / 2;
  249. }
  250. }
  251. }
  252. var changed = (this.start != newStart || this.end != newEnd);
  253. this.start = newStart;
  254. this.end = newEnd;
  255. return changed;
  256. };
  257. /**
  258. * Retrieve the current range.
  259. * @return {Object} An object with start and end properties
  260. */
  261. Range.prototype.getRange = function() {
  262. return {
  263. start: this.start,
  264. end: this.end
  265. };
  266. };
  267. /**
  268. * Calculate the conversion offset and scale for current range, based on
  269. * the provided width
  270. * @param {Number} width
  271. * @returns {{offset: number, scale: number}} conversion
  272. */
  273. Range.prototype.conversion = function (width, totalHidden) {
  274. return Range.conversion(this.start, this.end, width, totalHidden);
  275. };
  276. /**
  277. * Static method to calculate the conversion offset and scale for a range,
  278. * based on the provided start, end, and width
  279. * @param {Number} start
  280. * @param {Number} end
  281. * @param {Number} width
  282. * @returns {{offset: number, scale: number}} conversion
  283. */
  284. Range.conversion = function (start, end, width, totalHidden) {
  285. if (totalHidden === undefined) {
  286. totalHidden = 0;
  287. }
  288. if (width != 0 && (end - start != 0)) {
  289. return {
  290. offset: start,
  291. scale: width / (end - start - totalHidden)
  292. }
  293. }
  294. else {
  295. return {
  296. offset: 0,
  297. scale: 1
  298. };
  299. }
  300. };
  301. /**
  302. * Start dragging horizontally or vertically
  303. * @param {Event} event
  304. * @private
  305. */
  306. Range.prototype._onDragStart = function(event) {
  307. this.deltaDifference = 0;
  308. this.previousDelta = 0;
  309. // only allow dragging when configured as movable
  310. if (!this.options.moveable) return;
  311. // refuse to drag when we where pinching to prevent the timeline make a jump
  312. // when releasing the fingers in opposite order from the touch screen
  313. if (!this.props.touch.allowDragging) return;
  314. this.props.touch.start = this.start;
  315. this.props.touch.end = this.end;
  316. this.props.touch.dragging = true;
  317. if (this.body.dom.root) {
  318. this.body.dom.root.style.cursor = 'move';
  319. }
  320. };
  321. /**
  322. * Perform dragging operation
  323. * @param {Event} event
  324. * @private
  325. */
  326. Range.prototype._onDrag = function (event) {
  327. // only allow dragging when configured as movable
  328. if (!this.options.moveable) return;
  329. var direction = this.options.direction;
  330. validateDirection(direction);
  331. // refuse to drag when we where pinching to prevent the timeline make a jump
  332. // when releasing the fingers in opposite order from the touch screen
  333. if (!this.props.touch.allowDragging) return;
  334. var delta = (direction == 'horizontal') ? event.gesture.deltaX : event.gesture.deltaY;
  335. delta -= this.deltaDifference;
  336. var interval = (this.props.touch.end - this.props.touch.start);
  337. // normalize dragging speed if cutout is in between.
  338. var duration = DateUtil.getHiddenDuration(this.body.hiddenDates, this);
  339. interval -= duration;
  340. var width = (direction == 'horizontal') ? this.body.domProps.center.width : this.body.domProps.center.height;
  341. var diffRange = -delta / width * interval;
  342. var newStart = this.props.touch.start + diffRange;
  343. var newEnd = this.props.touch.end + diffRange;
  344. // snapping times away from hidden zones
  345. var safeDates = DateUtil.snapAwayFromHidden(this.body.hiddenDates, this, newStart, newEnd, delta);
  346. if (safeDates !== false) {
  347. this.props.touch.start = safeDates.newStart;
  348. this.props.touch.end = safeDates.newEnd;
  349. this._onDrag(event);
  350. return;
  351. }
  352. this.previousDelta = delta;
  353. this._applyRange(newStart, newEnd);
  354. // fire a rangechange event
  355. this.body.emitter.emit('rangechange', {
  356. start: new Date(this.start),
  357. end: new Date(this.end)
  358. });
  359. };
  360. /**
  361. * Stop dragging operation
  362. * @param {event} event
  363. * @private
  364. */
  365. Range.prototype._onDragEnd = function (event) {
  366. // only allow dragging when configured as movable
  367. if (!this.options.moveable) return;
  368. // refuse to drag when we where pinching to prevent the timeline make a jump
  369. // when releasing the fingers in opposite order from the touch screen
  370. if (!this.props.touch.allowDragging) return;
  371. this.props.touch.dragging = false;
  372. if (this.body.dom.root) {
  373. this.body.dom.root.style.cursor = 'auto';
  374. }
  375. // fire a rangechanged event
  376. this.body.emitter.emit('rangechanged', {
  377. start: new Date(this.start),
  378. end: new Date(this.end)
  379. });
  380. };
  381. /**
  382. * Event handler for mouse wheel event, used to zoom
  383. * Code from http://adomas.org/javascript-mouse-wheel/
  384. * @param {Event} event
  385. * @private
  386. */
  387. Range.prototype._onMouseWheel = function(event) {
  388. // only allow zooming when configured as zoomable and moveable
  389. if (!(this.options.zoomable && this.options.moveable)) return;
  390. // retrieve delta
  391. var delta = 0;
  392. if (event.wheelDelta) { /* IE/Opera. */
  393. delta = event.wheelDelta / 120;
  394. } else if (event.detail) { /* Mozilla case. */
  395. // In Mozilla, sign of delta is different than in IE.
  396. // Also, delta is multiple of 3.
  397. delta = -event.detail / 3;
  398. }
  399. // If delta is nonzero, handle it.
  400. // Basically, delta is now positive if wheel was scrolled up,
  401. // and negative, if wheel was scrolled down.
  402. if (delta) {
  403. // perform the zoom action. Delta is normally 1 or -1
  404. // adjust a negative delta such that zooming in with delta 0.1
  405. // equals zooming out with a delta -0.1
  406. var scale;
  407. if (delta < 0) {
  408. scale = 1 - (delta / 5);
  409. }
  410. else {
  411. scale = 1 / (1 + (delta / 5)) ;
  412. }
  413. // calculate center, the date to zoom around
  414. var gesture = hammerUtil.fakeGesture(this, event),
  415. pointer = getPointer(gesture.center, this.body.dom.center),
  416. pointerDate = this._pointerToDate(pointer);
  417. this.zoom(scale, pointerDate, delta);
  418. }
  419. // Prevent default actions caused by mouse wheel
  420. // (else the page and timeline both zoom and scroll)
  421. event.preventDefault();
  422. };
  423. /**
  424. * Start of a touch gesture
  425. * @private
  426. */
  427. Range.prototype._onTouch = function (event) {
  428. this.props.touch.start = this.start;
  429. this.props.touch.end = this.end;
  430. this.props.touch.allowDragging = true;
  431. this.props.touch.center = null;
  432. };
  433. /**
  434. * On start of a hold gesture
  435. * @private
  436. */
  437. Range.prototype._onHold = function () {
  438. this.props.touch.allowDragging = false;
  439. };
  440. /**
  441. * Handle pinch event
  442. * @param {Event} event
  443. * @private
  444. */
  445. Range.prototype._onPinch = function (event) {
  446. // only allow zooming when configured as zoomable and moveable
  447. if (!(this.options.zoomable && this.options.moveable)) return;
  448. this.props.touch.allowDragging = false;
  449. if (event.gesture.touches.length > 1) {
  450. if (!this.props.touch.center) {
  451. this.props.touch.center = getPointer(event.gesture.center, this.body.dom.center);
  452. }
  453. var scale = 1 / event.gesture.scale;
  454. var center = this._pointerToDate(this.props.touch.center);
  455. var hiddenDuration = DateUtil.getHiddenDuration(this.body.hiddenDates, this);
  456. // calculate new start and end
  457. var newStart = center + (this.props.touch.start - center) * scale;
  458. var newEnd = (center+hiddenDuration) + (this.props.touch.end - (center+hiddenDuration)) * scale;
  459. this.previousDelta = 1;
  460. var safeDates = DateUtil.snapAwayFromHidden(this.body.hiddenDates, this, newStart, newEnd, event.gesture.scale, true);
  461. if (safeDates !== false) {
  462. this.props.touch.start = safeDates.newStart;
  463. this.props.touch.end = safeDates.newEnd;
  464. newStart = safeDates.newStart;
  465. newEnd = safeDates.newEnd;
  466. }
  467. // apply new range
  468. this.setRange(newStart, newEnd);
  469. }
  470. };
  471. /**
  472. * Helper function to calculate the center date for zooming
  473. * @param {{x: Number, y: Number}} pointer
  474. * @return {number} date
  475. * @private
  476. */
  477. Range.prototype._pointerToDate = function (pointer) {
  478. var conversion;
  479. var direction = this.options.direction;
  480. validateDirection(direction);
  481. if (direction == 'horizontal') {
  482. var width = this.body.domProps.center.width;
  483. var duration = DateUtil.getHiddenDuration(this.body.hiddenDates, this);
  484. //return DateUtil.toTime(this.body, this, pointer.x, width);
  485. conversion = this.conversion(width, duration);
  486. //console.log(new Date(pointer.x / conversion.scale + conversion.offset + duration));
  487. return pointer.x / conversion.scale + conversion.offset;
  488. }
  489. else {
  490. var height = this.body.domProps.center.height;
  491. conversion = this.conversion(height);
  492. return pointer.y / conversion.scale + conversion.offset;
  493. }
  494. };
  495. /**
  496. * Get the pointer location relative to the location of the dom element
  497. * @param {{pageX: Number, pageY: Number}} touch
  498. * @param {Element} element HTML DOM element
  499. * @return {{x: Number, y: Number}} pointer
  500. * @private
  501. */
  502. function getPointer (touch, element) {
  503. return {
  504. x: touch.pageX - util.getAbsoluteLeft(element),
  505. y: touch.pageY - util.getAbsoluteTop(element)
  506. };
  507. }
  508. /**
  509. * Zoom the range the given scale in or out. Start and end date will
  510. * be adjusted, and the timeline will be redrawn. You can optionally give a
  511. * date around which to zoom.
  512. * For example, try scale = 0.9 or 1.1
  513. * @param {Number} scale Scaling factor. Values above 1 will zoom out,
  514. * values below 1 will zoom in.
  515. * @param {Number} [center] Value representing a date around which will
  516. * be zoomed.
  517. */
  518. Range.prototype.zoom = function(scale, center, delta) {
  519. // if centerDate is not provided, take it half between start Date and end Date
  520. if (center == null) {
  521. center = (this.start + this.end) / 2;
  522. }
  523. var hiddenDuration = DateUtil.getHiddenDuration(this.body.hiddenDates, this);
  524. // calculate new start and end
  525. var newStart = center + (this.start - center) * scale;
  526. var newEnd = (center+hiddenDuration) + (this.end - (center+hiddenDuration)) * scale;
  527. this.previousDelta = 0;
  528. // snapping times away from hidden zones
  529. var safeDates = DateUtil.snapAwayFromHidden(this.body.hiddenDates, this, newStart, newEnd, delta, true);
  530. //console.log(new Date(this.start), new Date(this.end), new Date(newStart), new Date(newEnd),new Date(safeDates.newStart), new Date(safeDates.newEnd));
  531. if (safeDates !== false) {
  532. newStart = safeDates.newStart;
  533. newEnd = safeDates.newEnd;
  534. }
  535. this.setRange(newStart, newEnd);
  536. };
  537. /**
  538. * Move the range with a given delta to the left or right. Start and end
  539. * value will be adjusted. For example, try delta = 0.1 or -0.1
  540. * @param {Number} delta Moving amount. Positive value will move right,
  541. * negative value will move left
  542. */
  543. Range.prototype.move = function(delta) {
  544. // zoom start Date and end Date relative to the centerDate
  545. var diff = (this.end - this.start);
  546. // apply new values
  547. var newStart = this.start + diff * delta;
  548. var newEnd = this.end + diff * delta;
  549. // TODO: reckon with min and max range
  550. this.start = newStart;
  551. this.end = newEnd;
  552. };
  553. /**
  554. * Move the range to a new center point
  555. * @param {Number} moveTo New center point of the range
  556. */
  557. Range.prototype.moveTo = function(moveTo) {
  558. var center = (this.start + this.end) / 2;
  559. var diff = center - moveTo;
  560. // calculate new start and end
  561. var newStart = this.start - diff;
  562. var newEnd = this.end - diff;
  563. this.setRange(newStart, newEnd);
  564. };
  565. module.exports = Range;