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.
 
 
 

574 lines
16 KiB

/**
* @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 = null; // Number
this.end = null; // Number
this.options = options || {};
this.setOptions(options);
}
/**
* Set options for the range controller
* @param {Object} options Available options:
* {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);
// re-apply range with new limitations
if (this.start !== null && this.end !== null) {
this.setRange(this.start, this.end);
}
};
/**
* Test whether direction has a valid value
* @param {String} direction 'horizontal' or 'vertical'
*/
function validateDirection (direction) {
if (direction != 'horizontal' && direction != 'vertical') {
throw new TypeError('Unknown direction "' + direction + '". ' +
'Choose "horizontal" or "vertical".');
}
}
/**
* Add listeners for mouse and touch events to the component
* @param {Controller} controller
* @param {Component} component Should be a rootpanel
* @param {String} event Available events: 'move', 'zoom'
* @param {String} direction Available directions: 'horizontal', 'vertical'
*/
Range.prototype.subscribe = function (controller, component, event, direction) {
var me = this;
if (event == 'move') {
// drag start listener
controller.on('dragstart', function (event) {
me._onDragStart(event, component);
});
// drag listener
controller.on('drag', function (event) {
me._onDrag(event, component, direction);
});
// drag end listener
controller.on('dragend', function (event) {
me._onDragEnd(event, component);
});
// ignore dragging when holding
controller.on('hold', function (event) {
me._onHold();
});
}
else if (event == 'zoom') {
// mouse wheel
function mousewheel (event) {
me._onMouseWheel(event, component, direction);
}
controller.on('mousewheel', mousewheel);
controller.on('DOMMouseScroll', mousewheel); // For FF
// pinch
controller.on('touch', function (event) {
me._onTouch(event);
});
controller.on('pinch', function (event) {
me._onPinch(event, component, direction);
});
}
else {
throw new TypeError('Unknown event "' + event + '". ' +
'Choose "move" or "zoom".');
}
};
/**
* Add event listener
* @param {String} event Name of the event.
* Available events: 'rangechange', 'rangechanged'
* @param {function} callback Callback function, invoked as callback({start: Date, end: Date})
*/
Range.prototype.on = function on (event, callback) {
var available = ['rangechange', 'rangechanged'];
if (available.indexOf(event) == -1) {
throw new Error('Unknown event "' + event + '". Choose from ' + available.join());
}
events.addListener(this, event, callback);
};
/**
* Remove an event listener
* @param {String} event name of the event
* @param {function} callback callback handler
*/
Range.prototype.off = function off (event, callback) {
events.removeListener(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.convert(start, 'Date').valueOf() : this.start,
newEnd = (end != null) ? util.convert(end, 'Date').valueOf() : this.end,
max = (this.options.max != null) ? util.convert(this.options.max, 'Date').valueOf() : null,
min = (this.options.min != null) ? util.convert(this.options.min, 'Date').valueOf() : null,
diff;
// check for valid number
if (isNaN(newStart) || newStart === null) {
throw new Error('Invalid start "' + start + '"');
}
if (isNaN(newEnd) || newEnd === null) {
throw new Error('Invalid end "' + end + '"');
}
// prevent start < end
if (newEnd < newStart) {
newEnd = newStart;
}
// prevent start < min
if (min !== null) {
if (newStart < min) {
diff = (min - newStart);
newStart += diff;
newEnd += diff;
// prevent end > max
if (max != null) {
if (newEnd > max) {
newEnd = max;
}
}
}
}
// prevent end > max
if (max !== null) {
if (newEnd > max) {
diff = (newEnd - max);
newStart -= diff;
newEnd -= diff;
// prevent start < min
if (min != null) {
if (newStart < min) {
newStart = min;
}
}
}
}
// prevent (end-start) < zoomMin
if (this.options.zoomMin !== null) {
var zoomMin = parseFloat(this.options.zoomMin);
if (zoomMin < 0) {
zoomMin = 0;
}
if ((newEnd - newStart) < zoomMin) {
if ((this.end - this.start) === zoomMin) {
// ignore this action, we are already zoomed to the minimum
newStart = this.start;
newEnd = this.end;
}
else {
// zoom to the minimum
diff = (zoomMin - (newEnd - newStart));
newStart -= diff / 2;
newEnd += diff / 2;
}
}
}
// prevent (end-start) > zoomMax
if (this.options.zoomMax !== null) {
var zoomMax = parseFloat(this.options.zoomMax);
if (zoomMax < 0) {
zoomMax = 0;
}
if ((newEnd - newStart) > zoomMax) {
if ((this.end - this.start) === zoomMax) {
// ignore this action, we are already zoomed to the maximum
newStart = this.start;
newEnd = this.end;
}
else {
// zoom to the maximum
diff = ((newEnd - newStart) - zoomMax);
newStart += diff / 2;
newEnd -= diff / 2;
}
}
}
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 scale for current range, based on
* the provided width
* @param {Number} width
* @returns {{offset: number, scale: number}} conversion
*/
Range.prototype.conversion = function (width) {
return Range.conversion(this.start, this.end, width);
};
/**
* Static method to calculate the conversion offset and scale for a range,
* based on the provided start, end, and width
* @param {Number} start
* @param {Number} end
* @param {Number} width
* @returns {{offset: number, scale: number}} conversion
*/
Range.conversion = function (start, end, width) {
if (width != 0 && (end - start != 0)) {
return {
offset: start,
scale: width / (end - start)
}
}
else {
return {
offset: 0,
scale: 1
};
}
};
// global (private) object to store drag params
var touchParams = {};
/**
* Start dragging horizontally or vertically
* @param {Event} event
* @param {Object} component
* @private
*/
Range.prototype._onDragStart = function(event, component) {
// refuse to drag when we where pinching to prevent the timeline make a jump
// when releasing the fingers in opposite order from the touch screen
if (touchParams.ignore) return;
touchParams.start = this.start;
touchParams.end = this.end;
var frame = component.frame;
if (frame) {
frame.style.cursor = 'move';
}
};
/**
* Perform dragging operating.
* @param {Event} event
* @param {Component} component
* @param {String} direction 'horizontal' or 'vertical'
* @private
*/
Range.prototype._onDrag = function (event, component, direction) {
validateDirection(direction);
// refuse to drag when we where pinching to prevent the timeline make a jump
// when releasing the fingers in opposite order from the touch screen
if (touchParams.ignore) return;
var delta = (direction == 'horizontal') ? event.gesture.deltaX : event.gesture.deltaY,
interval = (touchParams.end - touchParams.start),
width = (direction == 'horizontal') ? component.width : component.height,
diffRange = -delta / width * interval;
this._applyRange(touchParams.start + diffRange, touchParams.end + diffRange);
// fire a rangechange event
this._trigger('rangechange');
};
/**
* Stop dragging operating.
* @param {event} event
* @param {Component} component
* @private
*/
Range.prototype._onDragEnd = function (event, component) {
// refuse to drag when we where pinching to prevent the timeline make a jump
// when releasing the fingers in opposite order from the touch screen
if (touchParams.ignore) return;
if (component.frame) {
component.frame.style.cursor = 'auto';
}
// 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 {Component} component
* @param {String} direction 'horizontal' or 'vertical'
* @private
*/
Range.prototype._onMouseWheel = function(event, component, direction) {
validateDirection(direction);
// 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) {
// perform the zoom action. Delta is normally 1 or -1
// adjust a negative delta such that zooming in with delta 0.1
// equals zooming out with a delta -0.1
var scale;
if (delta < 0) {
scale = 1 - (delta / 5);
}
else {
scale = 1 / (1 + (delta / 5)) ;
}
// calculate center, the date to zoom around
var gesture = util.fakeGesture(this, event),
pointer = getPointer(gesture.touches[0], component.frame),
pointerDate = this._pointerToDate(component, direction, pointer);
this.zoom(scale, pointerDate);
}
// Prevent default actions caused by mouse wheel
// (else the page and timeline both zoom and scroll)
util.preventDefault(event);
};
/**
* Start of a touch gesture
* @private
*/
Range.prototype._onTouch = function (event) {
touchParams.start = this.start;
touchParams.end = this.end;
touchParams.ignore = false;
touchParams.center = null;
// don't move the range when dragging a selected event
// TODO: it's not so neat to have to know about the state of the ItemSet
var item = ItemSet.itemFromTarget(event);
if (item && item.selected) {
touchParams.ignore = true;
}
};
/**
* On start of a hold gesture
* @private
*/
Range.prototype._onHold = function () {
touchParams.ignore = true;
};
/**
* Handle pinch event
* @param {Event} event
* @param {Component} component
* @param {String} direction 'horizontal' or 'vertical'
* @private
*/
Range.prototype._onPinch = function (event, component, direction) {
touchParams.ignore = true;
if (event.gesture.touches.length > 1) {
if (!touchParams.center) {
touchParams.center = getPointer(event.gesture.center, component.frame);
}
var scale = 1 / event.gesture.scale,
initDate = this._pointerToDate(component, direction, touchParams.center),
center = getPointer(event.gesture.center, component.frame),
date = this._pointerToDate(component, direction, center),
delta = date - initDate; // TODO: utilize delta
// calculate new start and end
var newStart = parseInt(initDate + (touchParams.start - initDate) * scale);
var newEnd = parseInt(initDate + (touchParams.end - initDate) * scale);
// apply new range
this.setRange(newStart, newEnd);
}
};
/**
* Helper function to calculate the center date for zooming
* @param {Component} component
* @param {{x: Number, y: Number}} pointer
* @param {String} direction 'horizontal' or 'vertical'
* @return {number} date
* @private
*/
Range.prototype._pointerToDate = function (component, direction, pointer) {
var conversion;
if (direction == 'horizontal') {
var width = component.width;
conversion = this.conversion(width);
return pointer.x / conversion.scale + conversion.offset;
}
else {
var height = component.height;
conversion = this.conversion(height);
return pointer.y / conversion.scale + conversion.offset;
}
};
/**
* Get the pointer location relative to the location of the dom element
* @param {{pageX: Number, pageY: Number}} touch
* @param {Element} element HTML DOM element
* @return {{x: Number, y: Number}} pointer
* @private
*/
function getPointer (touch, element) {
return {
x: touch.pageX - vis.util.getAbsoluteLeft(element),
y: touch.pageY - vis.util.getAbsoluteTop(element)
};
}
/**
* Zoom the range the given scale 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 scale = 0.9 or 1.1
* @param {Number} scale Scaling factor. Values above 1 will zoom out,
* values below 1 will zoom in.
* @param {Number} [center] Value representing a date around which will
* be zoomed.
*/
Range.prototype.zoom = function(scale, center) {
// if centerDate is not provided, take it half between start Date and end Date
if (center == null) {
center = (this.start + this.end) / 2;
}
// calculate new start and end
var newStart = center + (this.start - center) * scale;
var newEnd = center + (this.end - center) * scale;
this.setRange(newStart, newEnd);
};
/**
* Move the range with a given delta to the left or right. Start and end
* value will be adjusted. For example, try delta = 0.1 or -0.1
* @param {Number} delta Moving amount. Positive value will move right,
* negative value will move left
*/
Range.prototype.move = function(delta) {
// zoom start Date and end Date relative to the centerDate
var diff = (this.end - this.start);
// apply new values
var newStart = this.start + diff * delta;
var newEnd = this.end + diff * delta;
// TODO: reckon with min and max range
this.start = newStart;
this.end = newEnd;
};
/**
* Move the range to a new center point
* @param {Number} moveTo New center point of the range
*/
Range.prototype.moveTo = function(moveTo) {
var center = (this.start + this.end) / 2;
var diff = center - moveTo;
// calculate new start and end
var newStart = this.start - diff;
var newEnd = this.end - diff;
this.setRange(newStart, newEnd);
};