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.

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