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.

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