var util = require('./util'), events = require('./events'); /** * @constructor Range * A Range controls a numeric range with a start and end value. * The Range adjusts the range based on mouse events or programmatic changes, * and triggers events when the range is changing or has been changed. * @param {Object} [options] See description at Range.setOptions * @extends Controller */ function Range(options) { this.id = util.randomUUID(); this.start = 0; // Number this.end = 0; // Number this.options = { min: null, max: null, zoomMin: null, zoomMax: null }; this.setOptions(options); this.listeners = []; } /** * Set options for the range controller * @param {Object} options Available options: * {Number} start Set start value of the range * {Number} end Set end value of the range * {Number} min Minimum value for start * {Number} max Maximum value for end * {Number} zoomMin Set a minimum value for * (end - start). * {Number} zoomMax Set a maximum value for * (end - start). */ Range.prototype.setOptions = function (options) { util.extend(this.options, options); if (options.start != null || options.end != null) { this.setRange(options.start, options.end); } }; /** * Add listeners for mouse and touch events to the component * @param {Component} component * @param {String} event Available events: 'move', 'zoom' * @param {String} direction Available directions: 'horizontal', 'vertical' */ Range.prototype.subscribe = function (component, event, direction) { var me = this; var listener; if (direction != 'horizontal' && direction != 'vertical') { throw new TypeError('Unknown direction "' + direction + '". ' + 'Choose "horizontal" or "vertical".'); } //noinspection FallthroughInSwitchStatementJS if (event == 'move') { listener = { component: component, event: event, direction: direction, callback: function (event) { me._onMouseDown(event, listener); }, params: {} }; component.on('mousedown', listener.callback); me.listeners.push(listener); } else if (event == 'zoom') { listener = { component: component, event: event, direction: direction, callback: function (event) { me._onMouseWheel(event, listener); }, params: {} }; component.on('mousewheel', listener.callback); me.listeners.push(listener); } else { throw new TypeError('Unknown event "' + event + '". ' + 'Choose "move" or "zoom".'); } }; /** * Event handler * @param {String} event name of the event, for example 'click', 'mousemove' * @param {function} callback callback handler, invoked with the raw HTML Event * as parameter. */ Range.prototype.on = function (event, callback) { events.addListener(this, event, callback); }; /** * Trigger an event * @param {String} event name of the event, available events: 'rangechange', * 'rangechanged' * @private */ Range.prototype._trigger = function (event) { events.trigger(this, event, { start: this.start, end: this.end }); }; /** * Set a new start and end range * @param {Number} start * @param {Number} end */ Range.prototype.setRange = function(start, end) { var changed = this._applyRange(start, end); if (changed) { this._trigger('rangechange'); this._trigger('rangechanged'); } }; /** * Set a new start and end range. This method is the same as setRange, but * does not trigger a range change and range changed event, and it returns * true when the range is changed * @param {Number} start * @param {Number} end * @return {Boolean} changed * @private */ Range.prototype._applyRange = function(start, end) { var newStart = (start != null) ? util.cast(start, 'Number') : this.start; var newEnd = (end != null) ? util.cast(end, 'Number') : this.end; var diff; // check for valid number if (isNaN(newStart)) { throw new Error('Invalid start "' + start + '"'); } if (isNaN(newEnd)) { throw new Error('Invalid end "' + end + '"'); } // prevent start < end if (newEnd < newStart) { newEnd = newStart; } // prevent start < min if (this.options.min != null) { var min = this.options.min.valueOf(); if (newStart < min) { diff = (min - newStart); newStart += diff; newEnd += diff; } } // prevent end > max if (this.options.max != null) { var max = this.options.max.valueOf(); if (newEnd > max) { diff = (newEnd - max); newStart -= diff; newEnd -= diff; } } // prevent (end-start) > zoomMin if (this.options.zoomMin != null) { var zoomMin = this.options.zoomMin.valueOf(); if (zoomMin < 0) { zoomMin = 0; } if ((newEnd - newStart) < zoomMin) { if ((this.end - this.start) > zoomMin) { // zoom to the minimum diff = (zoomMin - (newEnd - newStart)); newStart -= diff / 2; newEnd += diff / 2; } else { // ingore this action, we are already zoomed to the minimum newStart = this.start; newEnd = this.end; } } } // prevent (end-start) > zoomMin if (this.options.zoomMax != null) { var zoomMax = this.options.zoomMax.valueOf(); if (zoomMax < 0) { zoomMax = 0; } if ((newEnd - newStart) > zoomMax) { if ((this.end - this.start) < zoomMax) { // zoom to the maximum diff = ((newEnd - newStart) - zoomMax); newStart += diff / 2; newEnd -= diff / 2; } else { // ingore this action, we are already zoomed to the maximum newStart = this.start; newEnd = this.end; } } } var changed = (this.start != newStart || this.end != newEnd); this.start = newStart; this.end = newEnd; return changed; }; /** * Retrieve the current range. * @return {Object} An object with start and end properties */ Range.prototype.getRange = function() { return { start: this.start, end: this.end }; }; /** * Calculate the conversion offset and factor for current range, based on * the provided width * @param {Number} width * @returns {{offset: number, factor: number}} conversion */ Range.prototype.conversion = function (width) { var start = this.start; var end = this.end; return Range.conversion(this.start, this.end, width); }; /** * Static method to calculate the conversion offset and factor for a range, * based on the provided start, end, and width * @param {Number} start * @param {Number} end * @param {Number} width * @returns {{offset: number, factor: number}} conversion */ Range.conversion = function (start, end, width) { if (width != 0 && (end - start != 0)) { return { offset: start, factor: width / (end - start) } } else { return { offset: 0, factor: 1 }; } }; /** * Start moving horizontally or vertically * @param {Event} event * @param {Object} listener Listener containing the component and params * @private */ Range.prototype._onMouseDown = function(event, listener) { event = event || window.event; var params = listener.params; // only react on left mouse button down var leftButtonDown = event.which ? (event.which == 1) : (event.button == 1); if (!leftButtonDown) { return; } // get mouse position params.mouseX = util.getPageX(event); params.mouseY = util.getPageY(event); params.previousLeft = 0; params.previousOffset = 0; params.moved = false; params.start = this.start; params.end = this.end; var frame = listener.component.frame; if (frame) { frame.style.cursor = 'move'; } // add event listeners to handle moving the contents // we store the function onmousemove and onmouseup in the timeaxis, // so we can remove the eventlisteners lateron in the function onmouseup var me = this; if (!params.onMouseMove) { params.onMouseMove = function (event) { me._onMouseMove(event, listener); }; util.addEventListener(document, "mousemove", params.onMouseMove); } if (!params.onMouseUp) { params.onMouseUp = function (event) { me._onMouseUp(event, listener); }; util.addEventListener(document, "mouseup", params.onMouseUp); } util.preventDefault(event); }; /** * Perform moving operating. * This function activated from within the funcion TimeAxis._onMouseDown(). * @param {Event} event * @param {Object} listener * @private */ Range.prototype._onMouseMove = function (event, listener) { event = event || window.event; var params = listener.params; // calculate change in mouse position var mouseX = util.getPageX(event); var mouseY = util.getPageY(event); if (params.mouseX == undefined) { params.mouseX = mouseX; } if (params.mouseY == undefined) { params.mouseY = mouseY; } var diffX = mouseX - params.mouseX; var diffY = mouseY - params.mouseY; var diff = (listener.direction == 'horizontal') ? diffX : diffY; // if mouse movement is big enough, register it as a "moved" event if (Math.abs(diff) >= 1) { params.moved = true; } var interval = (params.end - params.start); var width = (listener.direction == 'horizontal') ? listener.component.width : listener.component.height; var diffRange = -diff / width * interval; this._applyRange(params.start + diffRange, params.end + diffRange); // fire a rangechange event this._trigger('rangechange'); util.preventDefault(event); }; /** * Stop moving operating. * This function activated from within the function Range._onMouseDown(). * @param {event} event * @param {Object} listener * @private */ Range.prototype._onMouseUp = function (event, listener) { event = event || window.event; var params = listener.params; if (listener.component.frame) { listener.component.frame.style.cursor = 'auto'; } // remove event listeners here, important for Safari if (params.onMouseMove) { util.removeEventListener(document, "mousemove", params.onMouseMove); params.onMouseMove = null; } if (params.onMouseUp) { util.removeEventListener(document, "mouseup", params.onMouseUp); params.onMouseUp = null; } //util.preventDefault(event); if (params.moved) { // fire a rangechanged event this._trigger('rangechanged'); } }; /** * Event handler for mouse wheel event, used to zoom * Code from http://adomas.org/javascript-mouse-wheel/ * @param {Event} event * @param {Object} listener * @private */ Range.prototype._onMouseWheel = function(event, listener) { event = event || window.event; // retrieve delta var delta = 0; if (event.wheelDelta) { /* IE/Opera. */ delta = event.wheelDelta / 120; } else if (event.detail) { /* Mozilla case. */ // In Mozilla, sign of delta is different than in IE. // Also, delta is multiple of 3. delta = -event.detail / 3; } // If delta is nonzero, handle it. // Basically, delta is now positive if wheel was scrolled up, // and negative, if wheel was scrolled down. if (delta) { var me = this; var zoom = function () { // perform the zoom action. Delta is normally 1 or -1 var zoomFactor = delta / 5.0; var zoomAround = null; var frame = listener.component.frame; if (frame) { var size, conversion; if (listener.direction == 'horizontal') { size = listener.component.width; conversion = me.conversion(size); var frameLeft = util.getAbsoluteLeft(frame); var mouseX = util.getPageX(event); zoomAround = (mouseX - frameLeft) / conversion.factor + conversion.offset; } else { size = listener.component.height; conversion = me.conversion(size); var frameTop = util.getAbsoluteTop(frame); var mouseY = util.getPageY(event); zoomAround = ((frameTop + size - mouseY) - frameTop) / conversion.factor + conversion.offset; } } me.zoom(zoomFactor, zoomAround); }; zoom(); } // Prevent default actions caused by mouse wheel. // That might be ugly, but we handle scrolls somehow // anyway, so don't bother here... util.preventDefault(event); }; /** * Zoom the range the given zoomfactor in or out. Start and end date will * be adjusted, and the timeline will be redrawn. You can optionally give a * date around which to zoom. * For example, try zoomfactor = 0.1 or -0.1 * @param {Number} zoomFactor Zooming amount. Positive value will zoom in, * negative value will zoom out * @param {Number} zoomAround Value around which will be zoomed. Optional */ Range.prototype.zoom = function(zoomFactor, zoomAround) { // if zoomAroundDate is not provided, take it half between start Date and end Date if (zoomAround == null) { zoomAround = (this.start + this.end) / 2; } // prevent zoom factor larger than 1 or smaller than -1 (larger than 1 will // result in a start>=end ) if (zoomFactor >= 1) { zoomFactor = 0.9; } if (zoomFactor <= -1) { zoomFactor = -0.9; } // adjust a negative factor such that zooming in with 0.1 equals zooming // out with a factor -0.1 if (zoomFactor < 0) { zoomFactor = zoomFactor / (1 + zoomFactor); } // zoom start and end relative to the zoomAround value var startDiff = (this.start - zoomAround); var endDiff = (this.end - zoomAround); // calculate new start and end var newStart = this.start - startDiff * zoomFactor; var newEnd = this.end - endDiff * zoomFactor; this.setRange(newStart, newEnd); }; /** * Move the range with a given factor to the left or right. Start and end * value will be adjusted. For example, try moveFactor = 0.1 or -0.1 * @param {Number} moveFactor Moving amount. Positive value will move right, * negative value will move left */ Range.prototype.move = function(moveFactor) { // zoom start Date and end Date relative to the zoomAroundDate var diff = (this.end - this.start); // apply new values var newStart = this.start + diff * moveFactor; var newEnd = this.end + diff * moveFactor; // TODO: reckon with min and max range this.start = newStart; this.end = newEnd; }; // exports module.exports = exports = Range;