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.

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