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.

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