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.

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