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.
 
 
 

17864 lines
505 KiB

/**
* vis.js
* https://github.com/almende/vis
*
* A dynamic, browser-based visualization library.
*
* @version @@version
* @date @@date
*
* @license
* Copyright (C) 2011-2013 Almende B.V, http://almende.com
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
!function(e){if("object"==typeof exports)module.exports=e();else if("function"==typeof define&&define.amd)define(e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.vis=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);throw new Error("Cannot find module '"+o+"'")}var f=n[o]={exports:{}};t[o][0].call(f.exports,function(e){var n=t[o][1][e];return s(n?n:e)},f,f.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
/*! Hammer.JS - v1.0.5 - 2013-04-07
* http://eightmedia.github.com/hammer.js
*
* Copyright (c) 2013 Jorik Tangelder <j.tangelder@gmail.com>;
* Licensed under the MIT license */
(function(window, undefined) {
'use strict';
/**
* Hammer
* use this to create instances
* @param {HTMLElement} element
* @param {Object} options
* @returns {Hammer.Instance}
* @constructor
*/
var Hammer = function(element, options) {
return new Hammer.Instance(element, options || {});
};
// default settings
Hammer.defaults = {
// add styles and attributes to the element to prevent the browser from doing
// its native behavior. this doesnt prevent the scrolling, but cancels
// the contextmenu, tap highlighting etc
// set to false to disable this
stop_browser_behavior: {
// this also triggers onselectstart=false for IE
userSelect: 'none',
// this makes the element blocking in IE10 >, you could experiment with the value
// see for more options this issue; https://github.com/EightMedia/hammer.js/issues/241
touchAction: 'none',
touchCallout: 'none',
contentZooming: 'none',
userDrag: 'none',
tapHighlightColor: 'rgba(0,0,0,0)'
}
// more settings are defined per gesture at gestures.js
};
// detect touchevents
Hammer.HAS_POINTEREVENTS = navigator.pointerEnabled || navigator.msPointerEnabled;
Hammer.HAS_TOUCHEVENTS = ('ontouchstart' in window);
// dont use mouseevents on mobile devices
Hammer.MOBILE_REGEX = /mobile|tablet|ip(ad|hone|od)|android/i;
Hammer.NO_MOUSEEVENTS = Hammer.HAS_TOUCHEVENTS && navigator.userAgent.match(Hammer.MOBILE_REGEX);
// eventtypes per touchevent (start, move, end)
// are filled by Hammer.event.determineEventTypes on setup
Hammer.EVENT_TYPES = {};
// direction defines
Hammer.DIRECTION_DOWN = 'down';
Hammer.DIRECTION_LEFT = 'left';
Hammer.DIRECTION_UP = 'up';
Hammer.DIRECTION_RIGHT = 'right';
// pointer type
Hammer.POINTER_MOUSE = 'mouse';
Hammer.POINTER_TOUCH = 'touch';
Hammer.POINTER_PEN = 'pen';
// touch event defines
Hammer.EVENT_START = 'start';
Hammer.EVENT_MOVE = 'move';
Hammer.EVENT_END = 'end';
// hammer document where the base events are added at
Hammer.DOCUMENT = document;
// plugins namespace
Hammer.plugins = {};
// if the window events are set...
Hammer.READY = false;
/**
* setup events to detect gestures on the document
*/
function setup() {
if(Hammer.READY) {
return;
}
// find what eventtypes we add listeners to
Hammer.event.determineEventTypes();
// Register all gestures inside Hammer.gestures
for(var name in Hammer.gestures) {
if(Hammer.gestures.hasOwnProperty(name)) {
Hammer.detection.register(Hammer.gestures[name]);
}
}
// Add touch events on the document
Hammer.event.onTouch(Hammer.DOCUMENT, Hammer.EVENT_MOVE, Hammer.detection.detect);
Hammer.event.onTouch(Hammer.DOCUMENT, Hammer.EVENT_END, Hammer.detection.detect);
// Hammer is ready...!
Hammer.READY = true;
}
/**
* create new hammer instance
* all methods should return the instance itself, so it is chainable.
* @param {HTMLElement} element
* @param {Object} [options={}]
* @returns {Hammer.Instance}
* @constructor
*/
Hammer.Instance = function(element, options) {
var self = this;
// setup HammerJS window events and register all gestures
// this also sets up the default options
setup();
this.element = element;
// start/stop detection option
this.enabled = true;
// merge options
this.options = Hammer.utils.extend(
Hammer.utils.extend({}, Hammer.defaults),
options || {});
// add some css to the element to prevent the browser from doing its native behavoir
if(this.options.stop_browser_behavior) {
Hammer.utils.stopDefaultBrowserBehavior(this.element, this.options.stop_browser_behavior);
}
// start detection on touchstart
Hammer.event.onTouch(element, Hammer.EVENT_START, function(ev) {
if(self.enabled) {
Hammer.detection.startDetect(self, ev);
}
});
// return instance
return this;
};
Hammer.Instance.prototype = {
/**
* bind events to the instance
* @param {String} gesture
* @param {Function} handler
* @returns {Hammer.Instance}
*/
on: function onEvent(gesture, handler){
var gestures = gesture.split(' ');
for(var t=0; t<gestures.length; t++) {
this.element.addEventListener(gestures[t], handler, false);
}
return this;
},
/**
* unbind events to the instance
* @param {String} gesture
* @param {Function} handler
* @returns {Hammer.Instance}
*/
off: function offEvent(gesture, handler){
var gestures = gesture.split(' ');
for(var t=0; t<gestures.length; t++) {
this.element.removeEventListener(gestures[t], handler, false);
}
return this;
},
/**
* trigger gesture event
* @param {String} gesture
* @param {Object} eventData
* @returns {Hammer.Instance}
*/
trigger: function triggerEvent(gesture, eventData){
// create DOM event
var event = Hammer.DOCUMENT.createEvent('Event');
event.initEvent(gesture, true, true);
event.gesture = eventData;
// trigger on the target if it is in the instance element,
// this is for event delegation tricks
var element = this.element;
if(Hammer.utils.hasParent(eventData.target, element)) {
element = eventData.target;
}
element.dispatchEvent(event);
return this;
},
/**
* enable of disable hammer.js detection
* @param {Boolean} state
* @returns {Hammer.Instance}
*/
enable: function enable(state) {
this.enabled = state;
return this;
}
};
/**
* this holds the last move event,
* used to fix empty touchend issue
* see the onTouch event for an explanation
* @type {Object}
*/
var last_move_event = null;
/**
* when the mouse is hold down, this is true
* @type {Boolean}
*/
var enable_detect = false;
/**
* when touch events have been fired, this is true
* @type {Boolean}
*/
var touch_triggered = false;
Hammer.event = {
/**
* simple addEventListener
* @param {HTMLElement} element
* @param {String} type
* @param {Function} handler
*/
bindDom: function(element, type, handler) {
var types = type.split(' ');
for(var t=0; t<types.length; t++) {
element.addEventListener(types[t], handler, false);
}
},
/**
* touch events with mouse fallback
* @param {HTMLElement} element
* @param {String} eventType like Hammer.EVENT_MOVE
* @param {Function} handler
*/
onTouch: function onTouch(element, eventType, handler) {
var self = this;
this.bindDom(element, Hammer.EVENT_TYPES[eventType], function bindDomOnTouch(ev) {
var sourceEventType = ev.type.toLowerCase();
// onmouseup, but when touchend has been fired we do nothing.
// this is for touchdevices which also fire a mouseup on touchend
if(sourceEventType.match(/mouse/) && touch_triggered) {
return;
}
// mousebutton must be down or a touch event
else if( sourceEventType.match(/touch/) || // touch events are always on screen
sourceEventType.match(/pointerdown/) || // pointerevents touch
(sourceEventType.match(/mouse/) && ev.which === 1) // mouse is pressed
){
enable_detect = true;
}
// we are in a touch event, set the touch triggered bool to true,
// this for the conflicts that may occur on ios and android
if(sourceEventType.match(/touch|pointer/)) {
touch_triggered = true;
}
// count the total touches on the screen
var count_touches = 0;
// when touch has been triggered in this detection session
// and we are now handling a mouse event, we stop that to prevent conflicts
if(enable_detect) {
// update pointerevent
if(Hammer.HAS_POINTEREVENTS && eventType != Hammer.EVENT_END) {
count_touches = Hammer.PointerEvent.updatePointer(eventType, ev);
}
// touch
else if(sourceEventType.match(/touch/)) {
count_touches = ev.touches.length;
}
// mouse
else if(!touch_triggered) {
count_touches = sourceEventType.match(/up/) ? 0 : 1;
}
// if we are in a end event, but when we remove one touch and
// we still have enough, set eventType to move
if(count_touches > 0 && eventType == Hammer.EVENT_END) {
eventType = Hammer.EVENT_MOVE;
}
// no touches, force the end event
else if(!count_touches) {
eventType = Hammer.EVENT_END;
}
// because touchend has no touches, and we often want to use these in our gestures,
// we send the last move event as our eventData in touchend
if(!count_touches && last_move_event !== null) {
ev = last_move_event;
}
// store the last move event
else {
last_move_event = ev;
}
// trigger the handler
handler.call(Hammer.detection, self.collectEventData(element, eventType, ev));
// remove pointerevent from list
if(Hammer.HAS_POINTEREVENTS && eventType == Hammer.EVENT_END) {
count_touches = Hammer.PointerEvent.updatePointer(eventType, ev);
}
}
//debug(sourceEventType +" "+ eventType);
// on the end we reset everything
if(!count_touches) {
last_move_event = null;
enable_detect = false;
touch_triggered = false;
Hammer.PointerEvent.reset();
}
});
},
/**
* we have different events for each device/browser
* determine what we need and set them in the Hammer.EVENT_TYPES constant
*/
determineEventTypes: function determineEventTypes() {
// determine the eventtype we want to set
var types;
// pointerEvents magic
if(Hammer.HAS_POINTEREVENTS) {
types = Hammer.PointerEvent.getEvents();
}
// on Android, iOS, blackberry, windows mobile we dont want any mouseevents
else if(Hammer.NO_MOUSEEVENTS) {
types = [
'touchstart',
'touchmove',
'touchend touchcancel'];
}
// for non pointer events browsers and mixed browsers,
// like chrome on windows8 touch laptop
else {
types = [
'touchstart mousedown',
'touchmove mousemove',
'touchend touchcancel mouseup'];
}
Hammer.EVENT_TYPES[Hammer.EVENT_START] = types[0];
Hammer.EVENT_TYPES[Hammer.EVENT_MOVE] = types[1];
Hammer.EVENT_TYPES[Hammer.EVENT_END] = types[2];
},
/**
* create touchlist depending on the event
* @param {Object} ev
* @param {String} eventType used by the fakemultitouch plugin
*/
getTouchList: function getTouchList(ev/*, eventType*/) {
// get the fake pointerEvent touchlist
if(Hammer.HAS_POINTEREVENTS) {
return Hammer.PointerEvent.getTouchList();
}
// get the touchlist
else if(ev.touches) {
return ev.touches;
}
// make fake touchlist from mouse position
else {
return [{
identifier: 1,
pageX: ev.pageX,
pageY: ev.pageY,
target: ev.target
}];
}
},
/**
* collect event data for Hammer js
* @param {HTMLElement} element
* @param {String} eventType like Hammer.EVENT_MOVE
* @param {Object} eventData
*/
collectEventData: function collectEventData(element, eventType, ev) {
var touches = this.getTouchList(ev, eventType);
// find out pointerType
var pointerType = Hammer.POINTER_TOUCH;
if(ev.type.match(/mouse/) || Hammer.PointerEvent.matchType(Hammer.POINTER_MOUSE, ev)) {
pointerType = Hammer.POINTER_MOUSE;
}
return {
center : Hammer.utils.getCenter(touches),
timeStamp : new Date().getTime(),
target : ev.target,
touches : touches,
eventType : eventType,
pointerType : pointerType,
srcEvent : ev,
/**
* prevent the browser default actions
* mostly used to disable scrolling of the browser
*/
preventDefault: function() {
if(this.srcEvent.preventManipulation) {
this.srcEvent.preventManipulation();
}
if(this.srcEvent.preventDefault) {
this.srcEvent.preventDefault();
}
},
/**
* stop bubbling the event up to its parents
*/
stopPropagation: function() {
this.srcEvent.stopPropagation();
},
/**
* immediately stop gesture detection
* might be useful after a swipe was detected
* @return {*}
*/
stopDetect: function() {
return Hammer.detection.stopDetect();
}
};
}
};
Hammer.PointerEvent = {
/**
* holds all pointers
* @type {Object}
*/
pointers: {},
/**
* get a list of pointers
* @returns {Array} touchlist
*/
getTouchList: function() {
var self = this;
var touchlist = [];
// we can use forEach since pointerEvents only is in IE10
Object.keys(self.pointers).sort().forEach(function(id) {
touchlist.push(self.pointers[id]);
});
return touchlist;
},
/**
* update the position of a pointer
* @param {String} type Hammer.EVENT_END
* @param {Object} pointerEvent
*/
updatePointer: function(type, pointerEvent) {
if(type == Hammer.EVENT_END) {
this.pointers = {};
}
else {
pointerEvent.identifier = pointerEvent.pointerId;
this.pointers[pointerEvent.pointerId] = pointerEvent;
}
return Object.keys(this.pointers).length;
},
/**
* check if ev matches pointertype
* @param {String} pointerType Hammer.POINTER_MOUSE
* @param {PointerEvent} ev
*/
matchType: function(pointerType, ev) {
if(!ev.pointerType) {
return false;
}
var types = {};
types[Hammer.POINTER_MOUSE] = (ev.pointerType == ev.MSPOINTER_TYPE_MOUSE || ev.pointerType == Hammer.POINTER_MOUSE);
types[Hammer.POINTER_TOUCH] = (ev.pointerType == ev.MSPOINTER_TYPE_TOUCH || ev.pointerType == Hammer.POINTER_TOUCH);
types[Hammer.POINTER_PEN] = (ev.pointerType == ev.MSPOINTER_TYPE_PEN || ev.pointerType == Hammer.POINTER_PEN);
return types[pointerType];
},
/**
* get events
*/
getEvents: function() {
return [
'pointerdown MSPointerDown',
'pointermove MSPointerMove',
'pointerup pointercancel MSPointerUp MSPointerCancel'
];
},
/**
* reset the list
*/
reset: function() {
this.pointers = {};
}
};
Hammer.utils = {
/**
* extend method,
* also used for cloning when dest is an empty object
* @param {Object} dest
* @param {Object} src
* @parm {Boolean} merge do a merge
* @returns {Object} dest
*/
extend: function extend(dest, src, merge) {
for (var key in src) {
if(dest[key] !== undefined && merge) {
continue;
}
dest[key] = src[key];
}
return dest;
},
/**
* find if a node is in the given parent
* used for event delegation tricks
* @param {HTMLElement} node
* @param {HTMLElement} parent
* @returns {boolean} has_parent
*/
hasParent: function(node, parent) {
while(node){
if(node == parent) {
return true;
}
node = node.parentNode;
}
return false;
},
/**
* get the center of all the touches
* @param {Array} touches
* @returns {Object} center
*/
getCenter: function getCenter(touches) {
var valuesX = [], valuesY = [];
for(var t= 0,len=touches.length; t<len; t++) {
valuesX.push(touches[t].pageX);
valuesY.push(touches[t].pageY);
}
return {
pageX: ((Math.min.apply(Math, valuesX) + Math.max.apply(Math, valuesX)) / 2),
pageY: ((Math.min.apply(Math, valuesY) + Math.max.apply(Math, valuesY)) / 2)
};
},
/**
* calculate the velocity between two points
* @param {Number} delta_time
* @param {Number} delta_x
* @param {Number} delta_y
* @returns {Object} velocity
*/
getVelocity: function getVelocity(delta_time, delta_x, delta_y) {
return {
x: Math.abs(delta_x / delta_time) || 0,
y: Math.abs(delta_y / delta_time) || 0
};
},
/**
* calculate the angle between two coordinates
* @param {Touch} touch1
* @param {Touch} touch2
* @returns {Number} angle
*/
getAngle: function getAngle(touch1, touch2) {
var y = touch2.pageY - touch1.pageY,
x = touch2.pageX - touch1.pageX;
return Math.atan2(y, x) * 180 / Math.PI;
},
/**
* angle to direction define
* @param {Touch} touch1
* @param {Touch} touch2
* @returns {String} direction constant, like Hammer.DIRECTION_LEFT
*/
getDirection: function getDirection(touch1, touch2) {
var x = Math.abs(touch1.pageX - touch2.pageX),
y = Math.abs(touch1.pageY - touch2.pageY);
if(x >= y) {
return touch1.pageX - touch2.pageX > 0 ? Hammer.DIRECTION_LEFT : Hammer.DIRECTION_RIGHT;
}
else {
return touch1.pageY - touch2.pageY > 0 ? Hammer.DIRECTION_UP : Hammer.DIRECTION_DOWN;
}
},
/**
* calculate the distance between two touches
* @param {Touch} touch1
* @param {Touch} touch2
* @returns {Number} distance
*/
getDistance: function getDistance(touch1, touch2) {
var x = touch2.pageX - touch1.pageX,
y = touch2.pageY - touch1.pageY;
return Math.sqrt((x*x) + (y*y));
},
/**
* calculate the scale factor between two touchLists (fingers)
* no scale is 1, and goes down to 0 when pinched together, and bigger when pinched out
* @param {Array} start
* @param {Array} end
* @returns {Number} scale
*/
getScale: function getScale(start, end) {
// need two fingers...
if(start.length >= 2 && end.length >= 2) {
return this.getDistance(end[0], end[1]) /
this.getDistance(start[0], start[1]);
}
return 1;
},
/**
* calculate the rotation degrees between two touchLists (fingers)
* @param {Array} start
* @param {Array} end
* @returns {Number} rotation
*/
getRotation: function getRotation(start, end) {
// need two fingers
if(start.length >= 2 && end.length >= 2) {
return this.getAngle(end[1], end[0]) -
this.getAngle(start[1], start[0]);
}
return 0;
},
/**
* boolean if the direction is vertical
* @param {String} direction
* @returns {Boolean} is_vertical
*/
isVertical: function isVertical(direction) {
return (direction == Hammer.DIRECTION_UP || direction == Hammer.DIRECTION_DOWN);
},
/**
* stop browser default behavior with css props
* @param {HtmlElement} element
* @param {Object} css_props
*/
stopDefaultBrowserBehavior: function stopDefaultBrowserBehavior(element, css_props) {
var prop,
vendors = ['webkit','khtml','moz','ms','o',''];
if(!css_props || !element.style) {
return;
}
// with css properties for modern browsers
for(var i = 0; i < vendors.length; i++) {
for(var p in css_props) {
if(css_props.hasOwnProperty(p)) {
prop = p;
// vender prefix at the property
if(vendors[i]) {
prop = vendors[i] + prop.substring(0, 1).toUpperCase() + prop.substring(1);
}
// set the style
element.style[prop] = css_props[p];
}
}
}
// also the disable onselectstart
if(css_props.userSelect == 'none') {
element.onselectstart = function() {
return false;
};
}
}
};
Hammer.detection = {
// contains all registred Hammer.gestures in the correct order
gestures: [],
// data of the current Hammer.gesture detection session
current: null,
// the previous Hammer.gesture session data
// is a full clone of the previous gesture.current object
previous: null,
// when this becomes true, no gestures are fired
stopped: false,
/**
* start Hammer.gesture detection
* @param {Hammer.Instance} inst
* @param {Object} eventData
*/
startDetect: function startDetect(inst, eventData) {
// already busy with a Hammer.gesture detection on an element
if(this.current) {
return;
}
this.stopped = false;
this.current = {
inst : inst, // reference to HammerInstance we're working for
startEvent : Hammer.utils.extend({}, eventData), // start eventData for distances, timing etc
lastEvent : false, // last eventData
name : '' // current gesture we're in/detected, can be 'tap', 'hold' etc
};
this.detect(eventData);
},
/**
* Hammer.gesture detection
* @param {Object} eventData
* @param {Object} eventData
*/
detect: function detect(eventData) {
if(!this.current || this.stopped) {
return;
}
// extend event data with calculations about scale, distance etc
eventData = this.extendEventData(eventData);
// instance options
var inst_options = this.current.inst.options;
// call Hammer.gesture handlers
for(var g=0,len=this.gestures.length; g<len; g++) {
var gesture = this.gestures[g];
// only when the instance options have enabled this gesture
if(!this.stopped && inst_options[gesture.name] !== false) {
// if a handler returns false, we stop with the detection
if(gesture.handler.call(gesture, eventData, this.current.inst) === false) {
this.stopDetect();
break;
}
}
}
// store as previous event event
if(this.current) {
this.current.lastEvent = eventData;
}
// endevent, but not the last touch, so dont stop
if(eventData.eventType == Hammer.EVENT_END && !eventData.touches.length-1) {
this.stopDetect();
}
return eventData;
},
/**
* clear the Hammer.gesture vars
* this is called on endDetect, but can also be used when a final Hammer.gesture has been detected
* to stop other Hammer.gestures from being fired
*/
stopDetect: function stopDetect() {
// clone current data to the store as the previous gesture
// used for the double tap gesture, since this is an other gesture detect session
this.previous = Hammer.utils.extend({}, this.current);
// reset the current
this.current = null;
// stopped!
this.stopped = true;
},
/**
* extend eventData for Hammer.gestures
* @param {Object} ev
* @returns {Object} ev
*/
extendEventData: function extendEventData(ev) {
var startEv = this.current.startEvent;
// if the touches change, set the new touches over the startEvent touches
// this because touchevents don't have all the touches on touchstart, or the
// user must place his fingers at the EXACT same time on the screen, which is not realistic
// but, sometimes it happens that both fingers are touching at the EXACT same time
if(startEv && (ev.touches.length != startEv.touches.length || ev.touches === startEv.touches)) {
// extend 1 level deep to get the touchlist with the touch objects
startEv.touches = [];
for(var i=0,len=ev.touches.length; i<len; i++) {
startEv.touches.push(Hammer.utils.extend({}, ev.touches[i]));
}
}
var delta_time = ev.timeStamp - startEv.timeStamp,
delta_x = ev.center.pageX - startEv.center.pageX,
delta_y = ev.center.pageY - startEv.center.pageY,
velocity = Hammer.utils.getVelocity(delta_time, delta_x, delta_y);
Hammer.utils.extend(ev, {
deltaTime : delta_time,
deltaX : delta_x,
deltaY : delta_y,
velocityX : velocity.x,
velocityY : velocity.y,
distance : Hammer.utils.getDistance(startEv.center, ev.center),
angle : Hammer.utils.getAngle(startEv.center, ev.center),
direction : Hammer.utils.getDirection(startEv.center, ev.center),
scale : Hammer.utils.getScale(startEv.touches, ev.touches),
rotation : Hammer.utils.getRotation(startEv.touches, ev.touches),
startEvent : startEv
});
return ev;
},
/**
* register new gesture
* @param {Object} gesture object, see gestures.js for documentation
* @returns {Array} gestures
*/
register: function register(gesture) {
// add an enable gesture options if there is no given
var options = gesture.defaults || {};
if(options[gesture.name] === undefined) {
options[gesture.name] = true;
}
// extend Hammer default options with the Hammer.gesture options
Hammer.utils.extend(Hammer.defaults, options, true);
// set its index
gesture.index = gesture.index || 1000;
// add Hammer.gesture to the list
this.gestures.push(gesture);
// sort the list by index
this.gestures.sort(function(a, b) {
if (a.index < b.index) {
return -1;
}
if (a.index > b.index) {
return 1;
}
return 0;
});
return this.gestures;
}
};
Hammer.gestures = Hammer.gestures || {};
/**
* Custom gestures
* ==============================
*
* Gesture object
* --------------------
* The object structure of a gesture:
*
* { name: 'mygesture',
* index: 1337,
* defaults: {
* mygesture_option: true
* }
* handler: function(type, ev, inst) {
* // trigger gesture event
* inst.trigger(this.name, ev);
* }
* }
* @param {String} name
* this should be the name of the gesture, lowercase
* it is also being used to disable/enable the gesture per instance config.
*
* @param {Number} [index=1000]
* the index of the gesture, where it is going to be in the stack of gestures detection
* like when you build an gesture that depends on the drag gesture, it is a good
* idea to place it after the index of the drag gesture.
*
* @param {Object} [defaults={}]
* the default settings of the gesture. these are added to the instance settings,
* and can be overruled per instance. you can also add the name of the gesture,
* but this is also added by default (and set to true).
*
* @param {Function} handler
* this handles the gesture detection of your custom gesture and receives the
* following arguments:
*
* @param {Object} eventData
* event data containing the following properties:
* timeStamp {Number} time the event occurred
* target {HTMLElement} target element
* touches {Array} touches (fingers, pointers, mouse) on the screen
* pointerType {String} kind of pointer that was used. matches Hammer.POINTER_MOUSE|TOUCH
* center {Object} center position of the touches. contains pageX and pageY
* deltaTime {Number} the total time of the touches in the screen
* deltaX {Number} the delta on x axis we haved moved
* deltaY {Number} the delta on y axis we haved moved
* velocityX {Number} the velocity on the x
* velocityY {Number} the velocity on y
* angle {Number} the angle we are moving
* direction {String} the direction we are moving. matches Hammer.DIRECTION_UP|DOWN|LEFT|RIGHT
* distance {Number} the distance we haved moved
* scale {Number} scaling of the touches, needs 2 touches
* rotation {Number} rotation of the touches, needs 2 touches *
* eventType {String} matches Hammer.EVENT_START|MOVE|END
* srcEvent {Object} the source event, like TouchStart or MouseDown *
* startEvent {Object} contains the same properties as above,
* but from the first touch. this is used to calculate
* distances, deltaTime, scaling etc
*
* @param {Hammer.Instance} inst
* the instance we are doing the detection for. you can get the options from
* the inst.options object and trigger the gesture event by calling inst.trigger
*
*
* Handle gestures
* --------------------
* inside the handler you can get/set Hammer.detection.current. This is the current
* detection session. It has the following properties
* @param {String} name
* contains the name of the gesture we have detected. it has not a real function,
* only to check in other gestures if something is detected.
* like in the drag gesture we set it to 'drag' and in the swipe gesture we can
* check if the current gesture is 'drag' by accessing Hammer.detection.current.name
*
* @readonly
* @param {Hammer.Instance} inst
* the instance we do the detection for
*
* @readonly
* @param {Object} startEvent
* contains the properties of the first gesture detection in this session.
* Used for calculations about timing, distance, etc.
*
* @readonly
* @param {Object} lastEvent
* contains all the properties of the last gesture detect in this session.
*
* after the gesture detection session has been completed (user has released the screen)
* the Hammer.detection.current object is copied into Hammer.detection.previous,
* this is usefull for gestures like doubletap, where you need to know if the
* previous gesture was a tap
*
* options that have been set by the instance can be received by calling inst.options
*
* You can trigger a gesture event by calling inst.trigger("mygesture", event).
* The first param is the name of your gesture, the second the event argument
*
*
* Register gestures
* --------------------
* When an gesture is added to the Hammer.gestures object, it is auto registered
* at the setup of the first Hammer instance. You can also call Hammer.detection.register
* manually and pass your gesture object as a param
*
*/
/**
* Hold
* Touch stays at the same place for x time
* @events hold
*/
Hammer.gestures.Hold = {
name: 'hold',
index: 10,
defaults: {
hold_timeout : 500,
hold_threshold : 1
},
timer: null,
handler: function holdGesture(ev, inst) {
switch(ev.eventType) {
case Hammer.EVENT_START:
// clear any running timers
clearTimeout(this.timer);
// set the gesture so we can check in the timeout if it still is
Hammer.detection.current.name = this.name;
// set timer and if after the timeout it still is hold,
// we trigger the hold event
this.timer = setTimeout(function() {
if(Hammer.detection.current.name == 'hold') {
inst.trigger('hold', ev);
}
}, inst.options.hold_timeout);
break;
// when you move or end we clear the timer
case Hammer.EVENT_MOVE:
if(ev.distance > inst.options.hold_threshold) {
clearTimeout(this.timer);
}
break;
case Hammer.EVENT_END:
clearTimeout(this.timer);
break;
}
}
};
/**
* Tap/DoubleTap
* Quick touch at a place or double at the same place
* @events tap, doubletap
*/
Hammer.gestures.Tap = {
name: 'tap',
index: 100,
defaults: {
tap_max_touchtime : 250,
tap_max_distance : 10,
tap_always : true,
doubletap_distance : 20,
doubletap_interval : 300
},
handler: function tapGesture(ev, inst) {
if(ev.eventType == Hammer.EVENT_END) {
// previous gesture, for the double tap since these are two different gesture detections
var prev = Hammer.detection.previous,
did_doubletap = false;
// when the touchtime is higher then the max touch time
// or when the moving distance is too much
if(ev.deltaTime > inst.options.tap_max_touchtime ||
ev.distance > inst.options.tap_max_distance) {
return;
}
// check if double tap
if(prev && prev.name == 'tap' &&
(ev.timeStamp - prev.lastEvent.timeStamp) < inst.options.doubletap_interval &&
ev.distance < inst.options.doubletap_distance) {
inst.trigger('doubletap', ev);
did_doubletap = true;
}
// do a single tap
if(!did_doubletap || inst.options.tap_always) {
Hammer.detection.current.name = 'tap';
inst.trigger(Hammer.detection.current.name, ev);
}
}
}
};
/**
* Swipe
* triggers swipe events when the end velocity is above the threshold
* @events swipe, swipeleft, swiperight, swipeup, swipedown
*/
Hammer.gestures.Swipe = {
name: 'swipe',
index: 40,
defaults: {
// set 0 for unlimited, but this can conflict with transform
swipe_max_touches : 1,
swipe_velocity : 0.7
},
handler: function swipeGesture(ev, inst) {
if(ev.eventType == Hammer.EVENT_END) {
// max touches
if(inst.options.swipe_max_touches > 0 &&
ev.touches.length > inst.options.swipe_max_touches) {
return;
}
// when the distance we moved is too small we skip this gesture
// or we can be already in dragging
if(ev.velocityX > inst.options.swipe_velocity ||
ev.velocityY > inst.options.swipe_velocity) {
// trigger swipe events
inst.trigger(this.name, ev);
inst.trigger(this.name + ev.direction, ev);
}
}
}
};
/**
* Drag
* Move with x fingers (default 1) around on the page. Blocking the scrolling when
* moving left and right is a good practice. When all the drag events are blocking
* you disable scrolling on that area.
* @events drag, drapleft, dragright, dragup, dragdown
*/
Hammer.gestures.Drag = {
name: 'drag',
index: 50,
defaults: {
drag_min_distance : 10,
// set 0 for unlimited, but this can conflict with transform
drag_max_touches : 1,
// prevent default browser behavior when dragging occurs
// be careful with it, it makes the element a blocking element
// when you are using the drag gesture, it is a good practice to set this true
drag_block_horizontal : false,
drag_block_vertical : false,
// drag_lock_to_axis keeps the drag gesture on the axis that it started on,
// It disallows vertical directions if the initial direction was horizontal, and vice versa.
drag_lock_to_axis : false,
// drag lock only kicks in when distance > drag_lock_min_distance
// This way, locking occurs only when the distance has become large enough to reliably determine the direction
drag_lock_min_distance : 25
},
triggered: false,
handler: function dragGesture(ev, inst) {
// current gesture isnt drag, but dragged is true
// this means an other gesture is busy. now call dragend
if(Hammer.detection.current.name != this.name && this.triggered) {
inst.trigger(this.name +'end', ev);
this.triggered = false;
return;
}
// max touches
if(inst.options.drag_max_touches > 0 &&
ev.touches.length > inst.options.drag_max_touches) {
return;
}
switch(ev.eventType) {
case Hammer.EVENT_START:
this.triggered = false;
break;
case Hammer.EVENT_MOVE:
// when the distance we moved is too small we skip this gesture
// or we can be already in dragging
if(ev.distance < inst.options.drag_min_distance &&
Hammer.detection.current.name != this.name) {
return;
}
// we are dragging!
Hammer.detection.current.name = this.name;
// lock drag to axis?
if(Hammer.detection.current.lastEvent.drag_locked_to_axis || (inst.options.drag_lock_to_axis && inst.options.drag_lock_min_distance<=ev.distance)) {
ev.drag_locked_to_axis = true;
}
var last_direction = Hammer.detection.current.lastEvent.direction;
if(ev.drag_locked_to_axis && last_direction !== ev.direction) {
// keep direction on the axis that the drag gesture started on
if(Hammer.utils.isVertical(last_direction)) {
ev.direction = (ev.deltaY < 0) ? Hammer.DIRECTION_UP : Hammer.DIRECTION_DOWN;
}
else {
ev.direction = (ev.deltaX < 0) ? Hammer.DIRECTION_LEFT : Hammer.DIRECTION_RIGHT;
}
}
// first time, trigger dragstart event
if(!this.triggered) {
inst.trigger(this.name +'start', ev);
this.triggered = true;
}
// trigger normal event
inst.trigger(this.name, ev);
// direction event, like dragdown
inst.trigger(this.name + ev.direction, ev);
// block the browser events
if( (inst.options.drag_block_vertical && Hammer.utils.isVertical(ev.direction)) ||
(inst.options.drag_block_horizontal && !Hammer.utils.isVertical(ev.direction))) {
ev.preventDefault();
}
break;
case Hammer.EVENT_END:
// trigger dragend
if(this.triggered) {
inst.trigger(this.name +'end', ev);
}
this.triggered = false;
break;
}
}
};
/**
* Transform
* User want to scale or rotate with 2 fingers
* @events transform, pinch, pinchin, pinchout, rotate
*/
Hammer.gestures.Transform = {
name: 'transform',
index: 45,
defaults: {
// factor, no scale is 1, zoomin is to 0 and zoomout until higher then 1
transform_min_scale : 0.01,
// rotation in degrees
transform_min_rotation : 1,
// prevent default browser behavior when two touches are on the screen
// but it makes the element a blocking element
// when you are using the transform gesture, it is a good practice to set this true
transform_always_block : false
},
triggered: false,
handler: function transformGesture(ev, inst) {
// current gesture isnt drag, but dragged is true
// this means an other gesture is busy. now call dragend
if(Hammer.detection.current.name != this.name && this.triggered) {
inst.trigger(this.name +'end', ev);
this.triggered = false;
return;
}
// atleast multitouch
if(ev.touches.length < 2) {
return;
}
// prevent default when two fingers are on the screen
if(inst.options.transform_always_block) {
ev.preventDefault();
}
switch(ev.eventType) {
case Hammer.EVENT_START:
this.triggered = false;
break;
case Hammer.EVENT_MOVE:
var scale_threshold = Math.abs(1-ev.scale);
var rotation_threshold = Math.abs(ev.rotation);
// when the distance we moved is too small we skip this gesture
// or we can be already in dragging
if(scale_threshold < inst.options.transform_min_scale &&
rotation_threshold < inst.options.transform_min_rotation) {
return;
}
// we are transforming!
Hammer.detection.current.name = this.name;
// first time, trigger dragstart event
if(!this.triggered) {
inst.trigger(this.name +'start', ev);
this.triggered = true;
}
inst.trigger(this.name, ev); // basic transform event
// trigger rotate event
if(rotation_threshold > inst.options.transform_min_rotation) {
inst.trigger('rotate', ev);
}
// trigger pinch event
if(scale_threshold > inst.options.transform_min_scale) {
inst.trigger('pinch', ev);
inst.trigger('pinch'+ ((ev.scale < 1) ? 'in' : 'out'), ev);
}
break;
case Hammer.EVENT_END:
// trigger dragend
if(this.triggered) {
inst.trigger(this.name +'end', ev);
}
this.triggered = false;
break;
}
}
};
/**
* Touch
* Called as first, tells the user has touched the screen
* @events touch
*/
Hammer.gestures.Touch = {
name: 'touch',
index: -Infinity,
defaults: {
// call preventDefault at touchstart, and makes the element blocking by
// disabling the scrolling of the page, but it improves gestures like
// transforming and dragging.
// be careful with using this, it can be very annoying for users to be stuck
// on the page
prevent_default: false,
// disable mouse events, so only touch (or pen!) input triggers events
prevent_mouseevents: false
},
handler: function touchGesture(ev, inst) {
if(inst.options.prevent_mouseevents && ev.pointerType == Hammer.POINTER_MOUSE) {
ev.stopDetect();
return;
}
if(inst.options.prevent_default) {
ev.preventDefault();
}
if(ev.eventType == Hammer.EVENT_START) {
inst.trigger(this.name, ev);
}
}
};
/**
* Release
* Called as last, tells the user has released the screen
* @events release
*/
Hammer.gestures.Release = {
name: 'release',
index: Infinity,
handler: function releaseGesture(ev, inst) {
if(ev.eventType == Hammer.EVENT_END) {
inst.trigger(this.name, ev);
}
}
};
// node export
if(typeof module === 'object' && typeof module.exports === 'object'){
module.exports = Hammer;
}
// just window export
else {
window.Hammer = Hammer;
// requireJS module definition
if(typeof window.define === 'function' && window.define.amd) {
window.define('hammer', [], function() {
return Hammer;
});
}
}
})(this);
},{}],2:[function(require,module,exports){
//! moment.js
//! version : 2.5.0
//! authors : Tim Wood, Iskren Chernev, Moment.js contributors
//! license : MIT
//! momentjs.com
(function (undefined) {
/************************************
Constants
************************************/
var moment,
VERSION = "2.5.0",
global = this,
round = Math.round,
i,
YEAR = 0,
MONTH = 1,
DATE = 2,
HOUR = 3,
MINUTE = 4,
SECOND = 5,
MILLISECOND = 6,
// internal storage for language config files
languages = {},
// check for nodeJS
hasModule = (typeof module !== 'undefined' && module.exports && typeof require !== 'undefined'),
// ASP.NET json date format regex
aspNetJsonRegex = /^\/?Date\((\-?\d+)/i,
aspNetTimeSpanJsonRegex = /(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/,
// from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html
// somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere
isoDurationRegex = /^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/,
// format tokens
formattingTokens = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,4}|X|zz?|ZZ?|.)/g,
localFormattingTokens = /(\[[^\[]*\])|(\\)?(LT|LL?L?L?|l{1,4})/g,
// parsing token regexes
parseTokenOneOrTwoDigits = /\d\d?/, // 0 - 99
parseTokenOneToThreeDigits = /\d{1,3}/, // 0 - 999
parseTokenOneToFourDigits = /\d{1,4}/, // 0 - 9999
parseTokenOneToSixDigits = /[+\-]?\d{1,6}/, // -999,999 - 999,999
parseTokenDigits = /\d+/, // nonzero number of digits
parseTokenWord = /[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i, // any word (or two) characters or numbers including two/three word month in arabic.
parseTokenTimezone = /Z|[\+\-]\d\d:?\d\d/gi, // +00:00 -00:00 +0000 -0000 or Z
parseTokenT = /T/i, // T (ISO separator)
parseTokenTimestampMs = /[\+\-]?\d+(\.\d{1,3})?/, // 123456789 123456789.123
//strict parsing regexes
parseTokenOneDigit = /\d/, // 0 - 9
parseTokenTwoDigits = /\d\d/, // 00 - 99
parseTokenThreeDigits = /\d{3}/, // 000 - 999
parseTokenFourDigits = /\d{4}/, // 0000 - 9999
parseTokenSixDigits = /[+\-]?\d{6}/, // -999,999 - 999,999
// iso 8601 regex
// 0000-00-00 0000-W00 or 0000-W00-0 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 or +00)
isoRegex = /^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,
isoFormat = 'YYYY-MM-DDTHH:mm:ssZ',
isoDates = [
'YYYY-MM-DD',
'GGGG-[W]WW',
'GGGG-[W]WW-E',
'YYYY-DDD'
],
// iso time formats and regexes
isoTimes = [
['HH:mm:ss.SSSS', /(T| )\d\d:\d\d:\d\d\.\d{1,3}/],
['HH:mm:ss', /(T| )\d\d:\d\d:\d\d/],
['HH:mm', /(T| )\d\d:\d\d/],
['HH', /(T| )\d\d/]
],
// timezone chunker "+10:00" > ["10", "00"] or "-1530" > ["-15", "30"]
parseTimezoneChunker = /([\+\-]|\d\d)/gi,
// getter and setter names
proxyGettersAndSetters = 'Date|Hours|Minutes|Seconds|Milliseconds'.split('|'),
unitMillisecondFactors = {
'Milliseconds' : 1,
'Seconds' : 1e3,
'Minutes' : 6e4,
'Hours' : 36e5,
'Days' : 864e5,
'Months' : 2592e6,
'Years' : 31536e6
},
unitAliases = {
ms : 'millisecond',
s : 'second',
m : 'minute',
h : 'hour',
d : 'day',
D : 'date',
w : 'week',
W : 'isoWeek',
M : 'month',
y : 'year',
DDD : 'dayOfYear',
e : 'weekday',
E : 'isoWeekday',
gg: 'weekYear',
GG: 'isoWeekYear'
},
camelFunctions = {
dayofyear : 'dayOfYear',
isoweekday : 'isoWeekday',
isoweek : 'isoWeek',
weekyear : 'weekYear',
isoweekyear : 'isoWeekYear'
},
// format function strings
formatFunctions = {},
// tokens to ordinalize and pad
ordinalizeTokens = 'DDD w W M D d'.split(' '),
paddedTokens = 'M D H h m s w W'.split(' '),
formatTokenFunctions = {
M : function () {
return this.month() + 1;
},
MMM : function (format) {
return this.lang().monthsShort(this, format);
},
MMMM : function (format) {
return this.lang().months(this, format);
},
D : function () {
return this.date();
},
DDD : function () {
return this.dayOfYear();
},
d : function () {
return this.day();
},
dd : function (format) {
return this.lang().weekdaysMin(this, format);
},
ddd : function (format) {
return this.lang().weekdaysShort(this, format);
},
dddd : function (format) {
return this.lang().weekdays(this, format);
},
w : function () {
return this.week();
},
W : function () {
return this.isoWeek();
},
YY : function () {
return leftZeroFill(this.year() % 100, 2);
},
YYYY : function () {
return leftZeroFill(this.year(), 4);
},
YYYYY : function () {
return leftZeroFill(this.year(), 5);
},
YYYYYY : function () {
var y = this.year(), sign = y >= 0 ? '+' : '-';
return sign + leftZeroFill(Math.abs(y), 6);
},
gg : function () {
return leftZeroFill(this.weekYear() % 100, 2);
},
gggg : function () {
return this.weekYear();
},
ggggg : function () {
return leftZeroFill(this.weekYear(), 5);
},
GG : function () {
return leftZeroFill(this.isoWeekYear() % 100, 2);
},
GGGG : function () {
return this.isoWeekYear();
},
GGGGG : function () {
return leftZeroFill(this.isoWeekYear(), 5);
},
e : function () {
return this.weekday();
},
E : function () {
return this.isoWeekday();
},
a : function () {
return this.lang().meridiem(this.hours(), this.minutes(), true);
},
A : function () {
return this.lang().meridiem(this.hours(), this.minutes(), false);
},
H : function () {
return this.hours();
},
h : function () {
return this.hours() % 12 || 12;
},
m : function () {
return this.minutes();
},
s : function () {
return this.seconds();
},
S : function () {
return toInt(this.milliseconds() / 100);
},
SS : function () {
return leftZeroFill(toInt(this.milliseconds() / 10), 2);
},
SSS : function () {
return leftZeroFill(this.milliseconds(), 3);
},
SSSS : function () {
return leftZeroFill(this.milliseconds(), 3);
},
Z : function () {
var a = -this.zone(),
b = "+";
if (a < 0) {
a = -a;
b = "-";
}
return b + leftZeroFill(toInt(a / 60), 2) + ":" + leftZeroFill(toInt(a) % 60, 2);
},
ZZ : function () {
var a = -this.zone(),
b = "+";
if (a < 0) {
a = -a;
b = "-";
}
return b + leftZeroFill(toInt(a / 60), 2) + leftZeroFill(toInt(a) % 60, 2);
},
z : function () {
return this.zoneAbbr();
},
zz : function () {
return this.zoneName();
},
X : function () {
return this.unix();
},
Q : function () {
return this.quarter();
}
},
lists = ['months', 'monthsShort', 'weekdays', 'weekdaysShort', 'weekdaysMin'];
function padToken(func, count) {
return function (a) {
return leftZeroFill(func.call(this, a), count);
};
}
function ordinalizeToken(func, period) {
return function (a) {
return this.lang().ordinal(func.call(this, a), period);
};
}
while (ordinalizeTokens.length) {
i = ordinalizeTokens.pop();
formatTokenFunctions[i + 'o'] = ordinalizeToken(formatTokenFunctions[i], i);
}
while (paddedTokens.length) {
i = paddedTokens.pop();
formatTokenFunctions[i + i] = padToken(formatTokenFunctions[i], 2);
}
formatTokenFunctions.DDDD = padToken(formatTokenFunctions.DDD, 3);
/************************************
Constructors
************************************/
function Language() {
}
// Moment prototype object
function Moment(config) {
checkOverflow(config);
extend(this, config);
}
// Duration Constructor
function Duration(duration) {
var normalizedInput = normalizeObjectUnits(duration),
years = normalizedInput.year || 0,
months = normalizedInput.month || 0,
weeks = normalizedInput.week || 0,
days = normalizedInput.day || 0,
hours = normalizedInput.hour || 0,
minutes = normalizedInput.minute || 0,
seconds = normalizedInput.second || 0,
milliseconds = normalizedInput.millisecond || 0;
// representation for dateAddRemove
this._milliseconds = +milliseconds +
seconds * 1e3 + // 1000
minutes * 6e4 + // 1000 * 60
hours * 36e5; // 1000 * 60 * 60
// Because of dateAddRemove treats 24 hours as different from a
// day when working around DST, we need to store them separately
this._days = +days +
weeks * 7;
// It is impossible translate months into days without knowing
// which months you are are talking about, so we have to store
// it separately.
this._months = +months +
years * 12;
this._data = {};
this._bubble();
}
/************************************
Helpers
************************************/
function extend(a, b) {
for (var i in b) {
if (b.hasOwnProperty(i)) {
a[i] = b[i];
}
}
if (b.hasOwnProperty("toString")) {
a.toString = b.toString;
}
if (b.hasOwnProperty("valueOf")) {
a.valueOf = b.valueOf;
}
return a;
}
function absRound(number) {
if (number < 0) {
return Math.ceil(number);
} else {
return Math.floor(number);
}
}
// left zero fill a number
// see http://jsperf.com/left-zero-filling for performance comparison
function leftZeroFill(number, targetLength, forceSign) {
var output = Math.abs(number) + '',
sign = number >= 0;
while (output.length < targetLength) {
output = '0' + output;
}
return (sign ? (forceSign ? '+' : '') : '-') + output;
}
// helper function for _.addTime and _.subtractTime
function addOrSubtractDurationFromMoment(mom, duration, isAdding, ignoreUpdateOffset) {
var milliseconds = duration._milliseconds,
days = duration._days,
months = duration._months,
minutes,
hours;
if (milliseconds) {
mom._d.setTime(+mom._d + milliseconds * isAdding);
}
// store the minutes and hours so we can restore them
if (days || months) {
minutes = mom.minute();
hours = mom.hour();
}
if (days) {
mom.date(mom.date() + days * isAdding);
}
if (months) {
mom.month(mom.month() + months * isAdding);
}
if (milliseconds && !ignoreUpdateOffset) {
moment.updateOffset(mom);
}
// restore the minutes and hours after possibly changing dst
if (days || months) {
mom.minute(minutes);
mom.hour(hours);
}
}
// check if is an array
function isArray(input) {
return Object.prototype.toString.call(input) === '[object Array]';
}
function isDate(input) {
return Object.prototype.toString.call(input) === '[object Date]' ||
input instanceof Date;
}
// compare two arrays, return the number of differences
function compareArrays(array1, array2, dontConvert) {
var len = Math.min(array1.length, array2.length),
lengthDiff = Math.abs(array1.length - array2.length),
diffs = 0,
i;
for (i = 0; i < len; i++) {
if ((dontConvert && array1[i] !== array2[i]) ||
(!dontConvert && toInt(array1[i]) !== toInt(array2[i]))) {
diffs++;
}
}
return diffs + lengthDiff;
}
function normalizeUnits(units) {
if (units) {
var lowered = units.toLowerCase().replace(/(.)s$/, '$1');
units = unitAliases[units] || camelFunctions[lowered] || lowered;
}
return units;
}
function normalizeObjectUnits(inputObject) {
var normalizedInput = {},
normalizedProp,
prop;
for (prop in inputObject) {
if (inputObject.hasOwnProperty(prop)) {
normalizedProp = normalizeUnits(prop);
if (normalizedProp) {
normalizedInput[normalizedProp] = inputObject[prop];
}
}
}
return normalizedInput;
}
function makeList(field) {
var count, setter;
if (field.indexOf('week') === 0) {
count = 7;
setter = 'day';
}
else if (field.indexOf('month') === 0) {
count = 12;
setter = 'month';
}
else {
return;
}
moment[field] = function (format, index) {
var i, getter,
method = moment.fn._lang[field],
results = [];
if (typeof format === 'number') {
index = format;
format = undefined;
}
getter = function (i) {
var m = moment().utc().set(setter, i);
return method.call(moment.fn._lang, m, format || '');
};
if (index != null) {
return getter(index);
}
else {
for (i = 0; i < count; i++) {
results.push(getter(i));
}
return results;
}
};
}
function toInt(argumentForCoercion) {
var coercedNumber = +argumentForCoercion,
value = 0;
if (coercedNumber !== 0 && isFinite(coercedNumber)) {
if (coercedNumber >= 0) {
value = Math.floor(coercedNumber);
} else {
value = Math.ceil(coercedNumber);
}
}
return value;
}
function daysInMonth(year, month) {
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
}
function daysInYear(year) {
return isLeapYear(year) ? 366 : 365;
}
function isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
function checkOverflow(m) {
var overflow;
if (m._a && m._pf.overflow === -2) {
overflow =
m._a[MONTH] < 0 || m._a[MONTH] > 11 ? MONTH :
m._a[DATE] < 1 || m._a[DATE] > daysInMonth(m._a[YEAR], m._a[MONTH]) ? DATE :
m._a[HOUR] < 0 || m._a[HOUR] > 23 ? HOUR :
m._a[MINUTE] < 0 || m._a[MINUTE] > 59 ? MINUTE :
m._a[SECOND] < 0 || m._a[SECOND] > 59 ? SECOND :
m._a[MILLISECOND] < 0 || m._a[MILLISECOND] > 999 ? MILLISECOND :
-1;
if (m._pf._overflowDayOfYear && (overflow < YEAR || overflow > DATE)) {
overflow = DATE;
}
m._pf.overflow = overflow;
}
}
function initializeParsingFlags(config) {
config._pf = {
empty : false,
unusedTokens : [],
unusedInput : [],
overflow : -2,
charsLeftOver : 0,
nullInput : false,
invalidMonth : null,
invalidFormat : false,
userInvalidated : false,
iso: false
};
}
function isValid(m) {
if (m._isValid == null) {
m._isValid = !isNaN(m._d.getTime()) &&
m._pf.overflow < 0 &&
!m._pf.empty &&
!m._pf.invalidMonth &&
!m._pf.nullInput &&
!m._pf.invalidFormat &&
!m._pf.userInvalidated;
if (m._strict) {
m._isValid = m._isValid &&
m._pf.charsLeftOver === 0 &&
m._pf.unusedTokens.length === 0;
}
}
return m._isValid;
}
function normalizeLanguage(key) {
return key ? key.toLowerCase().replace('_', '-') : key;
}
// Return a moment from input, that is local/utc/zone equivalent to model.
function makeAs(input, model) {
return model._isUTC ? moment(input).zone(model._offset || 0) :
moment(input).local();
}
/************************************
Languages
************************************/
extend(Language.prototype, {
set : function (config) {
var prop, i;
for (i in config) {
prop = config[i];
if (typeof prop === 'function') {
this[i] = prop;
} else {
this['_' + i] = prop;
}
}
},
_months : "January_February_March_April_May_June_July_August_September_October_November_December".split("_"),
months : function (m) {
return this._months[m.month()];
},
_monthsShort : "Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),
monthsShort : function (m) {
return this._monthsShort[m.month()];
},
monthsParse : function (monthName) {
var i, mom, regex;
if (!this._monthsParse) {
this._monthsParse = [];
}
for (i = 0; i < 12; i++) {
// make the regex if we don't have it already
if (!this._monthsParse[i]) {
mom = moment.utc([2000, i]);
regex = '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, '');
this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i');
}
// test the regex
if (this._monthsParse[i].test(monthName)) {
return i;
}
}
},
_weekdays : "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),
weekdays : function (m) {
return this._weekdays[m.day()];
},
_weekdaysShort : "Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),
weekdaysShort : function (m) {
return this._weekdaysShort[m.day()];
},
_weekdaysMin : "Su_Mo_Tu_We_Th_Fr_Sa".split("_"),
weekdaysMin : function (m) {
return this._weekdaysMin[m.day()];
},
weekdaysParse : function (weekdayName) {
var i, mom, regex;
if (!this._weekdaysParse) {
this._weekdaysParse = [];
}
for (i = 0; i < 7; i++) {
// make the regex if we don't have it already
if (!this._weekdaysParse[i]) {
mom = moment([2000, 1]).day(i);
regex = '^' + this.weekdays(mom, '') + '|^' + this.weekdaysShort(mom, '') + '|^' + this.weekdaysMin(mom, '');
this._weekdaysParse[i] = new RegExp(regex.replace('.', ''), 'i');
}
// test the regex
if (this._weekdaysParse[i].test(weekdayName)) {
return i;
}
}
},
_longDateFormat : {
LT : "h:mm A",
L : "MM/DD/YYYY",
LL : "MMMM D YYYY",
LLL : "MMMM D YYYY LT",
LLLL : "dddd, MMMM D YYYY LT"
},
longDateFormat : function (key) {
var output = this._longDateFormat[key];
if (!output && this._longDateFormat[key.toUpperCase()]) {
output = this._longDateFormat[key.toUpperCase()].replace(/MMMM|MM|DD|dddd/g, function (val) {
return val.slice(1);
});
this._longDateFormat[key] = output;
}
return output;
},
isPM : function (input) {
// IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays
// Using charAt should be more compatible.
return ((input + '').toLowerCase().charAt(0) === 'p');
},
_meridiemParse : /[ap]\.?m?\.?/i,
meridiem : function (hours, minutes, isLower) {
if (hours > 11) {
return isLower ? 'pm' : 'PM';
} else {
return isLower ? 'am' : 'AM';
}
},
_calendar : {
sameDay : '[Today at] LT',
nextDay : '[Tomorrow at] LT',
nextWeek : 'dddd [at] LT',
lastDay : '[Yesterday at] LT',
lastWeek : '[Last] dddd [at] LT',
sameElse : 'L'
},
calendar : function (key, mom) {
var output = this._calendar[key];
return typeof output === 'function' ? output.apply(mom) : output;
},
_relativeTime : {
future : "in %s",
past : "%s ago",
s : "a few seconds",
m : "a minute",
mm : "%d minutes",
h : "an hour",
hh : "%d hours",
d : "a day",
dd : "%d days",
M : "a month",
MM : "%d months",
y : "a year",
yy : "%d years"
},
relativeTime : function (number, withoutSuffix, string, isFuture) {
var output = this._relativeTime[string];
return (typeof output === 'function') ?
output(number, withoutSuffix, string, isFuture) :
output.replace(/%d/i, number);
},
pastFuture : function (diff, output) {
var format = this._relativeTime[diff > 0 ? 'future' : 'past'];
return typeof format === 'function' ? format(output) : format.replace(/%s/i, output);
},
ordinal : function (number) {
return this._ordinal.replace("%d", number);
},
_ordinal : "%d",
preparse : function (string) {
return string;
},
postformat : function (string) {
return string;
},
week : function (mom) {
return weekOfYear(mom, this._week.dow, this._week.doy).week;
},
_week : {
dow : 0, // Sunday is the first day of the week.
doy : 6 // The week that contains Jan 1st is the first week of the year.
},
_invalidDate: 'Invalid date',
invalidDate: function () {
return this._invalidDate;
}
});
// Loads a language definition into the `languages` cache. The function
// takes a key and optionally values. If not in the browser and no values
// are provided, it will load the language file module. As a convenience,
// this function also returns the language values.
function loadLang(key, values) {
values.abbr = key;
if (!languages[key]) {
languages[key] = new Language();
}
languages[key].set(values);
return languages[key];
}
// Remove a language from the `languages` cache. Mostly useful in tests.
function unloadLang(key) {
delete languages[key];
}
// Determines which language definition to use and returns it.
//
// With no parameters, it will return the global language. If you
// pass in a language key, such as 'en', it will return the
// definition for 'en', so long as 'en' has already been loaded using
// moment.lang.
function getLangDefinition(key) {
var i = 0, j, lang, next, split,
get = function (k) {
if (!languages[k] && hasModule) {
try {
require('./lang/' + k);
} catch (e) { }
}
return languages[k];
};
if (!key) {
return moment.fn._lang;
}
if (!isArray(key)) {
//short-circuit everything else
lang = get(key);
if (lang) {
return lang;
}
key = [key];
}
//pick the language from the array
//try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each
//substring from most specific to least, but move to the next array item if it's a more specific variant than the current root
while (i < key.length) {
split = normalizeLanguage(key[i]).split('-');
j = split.length;
next = normalizeLanguage(key[i + 1]);
next = next ? next.split('-') : null;
while (j > 0) {
lang = get(split.slice(0, j).join('-'));
if (lang) {
return lang;
}
if (next && next.length >= j && compareArrays(split, next, true) >= j - 1) {
//the next array item is better than a shallower substring of this one
break;
}
j--;
}
i++;
}
return moment.fn._lang;
}
/************************************
Formatting
************************************/
function removeFormattingTokens(input) {
if (input.match(/\[[\s\S]/)) {
return input.replace(/^\[|\]$/g, "");
}
return input.replace(/\\/g, "");
}
function makeFormatFunction(format) {
var array = format.match(formattingTokens), i, length;
for (i = 0, length = array.length; i < length; i++) {
if (formatTokenFunctions[array[i]]) {
array[i] = formatTokenFunctions[array[i]];
} else {
array[i] = removeFormattingTokens(array[i]);
}
}
return function (mom) {
var output = "";
for (i = 0; i < length; i++) {
output += array[i] instanceof Function ? array[i].call(mom, format) : array[i];
}
return output;
};
}
// format date using native date object
function formatMoment(m, format) {
if (!m.isValid()) {
return m.lang().invalidDate();
}
format = expandFormat(format, m.lang());
if (!formatFunctions[format]) {
formatFunctions[format] = makeFormatFunction(format);
}
return formatFunctions[format](m);
}
function expandFormat(format, lang) {
var i = 5;
function replaceLongDateFormatTokens(input) {
return lang.longDateFormat(input) || input;
}
localFormattingTokens.lastIndex = 0;
while (i >= 0 && localFormattingTokens.test(format)) {
format = format.replace(localFormattingTokens, replaceLongDateFormatTokens);
localFormattingTokens.lastIndex = 0;
i -= 1;
}
return format;
}
/************************************
Parsing
************************************/
// get the regex to find the next token
function getParseRegexForToken(token, config) {
var a, strict = config._strict;
switch (token) {
case 'DDDD':
return parseTokenThreeDigits;
case 'YYYY':
case 'GGGG':
case 'gggg':
return strict ? parseTokenFourDigits : parseTokenOneToFourDigits;
case 'YYYYYY':
case 'YYYYY':
case 'GGGGG':
case 'ggggg':
return strict ? parseTokenSixDigits : parseTokenOneToSixDigits;
case 'S':
if (strict) { return parseTokenOneDigit; }
/* falls through */
case 'SS':
if (strict) { return parseTokenTwoDigits; }
/* falls through */
case 'SSS':
case 'DDD':
return strict ? parseTokenThreeDigits : parseTokenOneToThreeDigits;
case 'MMM':
case 'MMMM':
case 'dd':
case 'ddd':
case 'dddd':
return parseTokenWord;
case 'a':
case 'A':
return getLangDefinition(config._l)._meridiemParse;
case 'X':
return parseTokenTimestampMs;
case 'Z':
case 'ZZ':
return parseTokenTimezone;
case 'T':
return parseTokenT;
case 'SSSS':
return parseTokenDigits;
case 'MM':
case 'DD':
case 'YY':
case 'GG':
case 'gg':
case 'HH':
case 'hh':
case 'mm':
case 'ss':
case 'ww':
case 'WW':
return strict ? parseTokenTwoDigits : parseTokenOneOrTwoDigits;
case 'M':
case 'D':
case 'd':
case 'H':
case 'h':
case 'm':
case 's':
case 'w':
case 'W':
case 'e':
case 'E':
return strict ? parseTokenOneDigit : parseTokenOneOrTwoDigits;
default :
a = new RegExp(regexpEscape(unescapeFormat(token.replace('\\', '')), "i"));
return a;
}
}
function timezoneMinutesFromString(string) {
string = string || "";
var possibleTzMatches = (string.match(parseTokenTimezone) || []),
tzChunk = possibleTzMatches[possibleTzMatches.length - 1] || [],
parts = (tzChunk + '').match(parseTimezoneChunker) || ['-', 0, 0],
minutes = +(parts[1] * 60) + toInt(parts[2]);
return parts[0] === '+' ? -minutes : minutes;
}
// function to convert string input to date
function addTimeToArrayFromToken(token, input, config) {
var a, datePartArray = config._a;
switch (token) {
// MONTH
case 'M' : // fall through to MM
case 'MM' :
if (input != null) {
datePartArray[MONTH] = toInt(input) - 1;
}
break;
case 'MMM' : // fall through to MMMM
case 'MMMM' :
a = getLangDefinition(config._l).monthsParse(input);
// if we didn't find a month name, mark the date as invalid.
if (a != null) {
datePartArray[MONTH] = a;
} else {
config._pf.invalidMonth = input;
}
break;
// DAY OF MONTH
case 'D' : // fall through to DD
case 'DD' :
if (input != null) {
datePartArray[DATE] = toInt(input);
}
break;
// DAY OF YEAR
case 'DDD' : // fall through to DDDD
case 'DDDD' :
if (input != null) {
config._dayOfYear = toInt(input);
}
break;
// YEAR
case 'YY' :
datePartArray[YEAR] = toInt(input) + (toInt(input) > 68 ? 1900 : 2000);
break;
case 'YYYY' :
case 'YYYYY' :
case 'YYYYYY' :
datePartArray[YEAR] = toInt(input);
break;
// AM / PM
case 'a' : // fall through to A
case 'A' :
config._isPm = getLangDefinition(config._l).isPM(input);
break;
// 24 HOUR
case 'H' : // fall through to hh
case 'HH' : // fall through to hh
case 'h' : // fall through to hh
case 'hh' :
datePartArray[HOUR] = toInt(input);
break;
// MINUTE
case 'm' : // fall through to mm
case 'mm' :
datePartArray[MINUTE] = toInt(input);
break;
// SECOND
case 's' : // fall through to ss
case 'ss' :
datePartArray[SECOND] = toInt(input);
break;
// MILLISECOND
case 'S' :
case 'SS' :
case 'SSS' :
case 'SSSS' :
datePartArray[MILLISECOND] = toInt(('0.' + input) * 1000);
break;
// UNIX TIMESTAMP WITH MS
case 'X':
config._d = new Date(parseFloat(input) * 1000);
break;
// TIMEZONE
case 'Z' : // fall through to ZZ
case 'ZZ' :
config._useUTC = true;
config._tzm = timezoneMinutesFromString(input);
break;
case 'w':
case 'ww':
case 'W':
case 'WW':
case 'd':
case 'dd':
case 'ddd':
case 'dddd':
case 'e':
case 'E':
token = token.substr(0, 1);
/* falls through */
case 'gg':
case 'gggg':
case 'GG':
case 'GGGG':
case 'GGGGG':
token = token.substr(0, 2);
if (input) {
config._w = config._w || {};
config._w[token] = input;
}
break;
}
}
// convert an array to a date.
// the array should mirror the parameters below
// note: all values past the year are optional and will default to the lowest possible value.
// [year, month, day , hour, minute, second, millisecond]
function dateFromConfig(config) {
var i, date, input = [], currentDate,
yearToUse, fixYear, w, temp, lang, weekday, week;
if (config._d) {
return;
}
currentDate = currentDateArray(config);
//compute day of the year from weeks and weekdays
if (config._w && config._a[DATE] == null && config._a[MONTH] == null) {
fixYear = function (val) {
var int_val = parseInt(val, 10);
return val ?
(val.length < 3 ? (int_val > 68 ? 1900 + int_val : 2000 + int_val) : int_val) :
(config._a[YEAR] == null ? moment().weekYear() : config._a[YEAR]);
};
w = config._w;
if (w.GG != null || w.W != null || w.E != null) {
temp = dayOfYearFromWeeks(fixYear(w.GG), w.W || 1, w.E, 4, 1);
}
else {
lang = getLangDefinition(config._l);
weekday = w.d != null ? parseWeekday(w.d, lang) :
(w.e != null ? parseInt(w.e, 10) + lang._week.dow : 0);
week = parseInt(w.w, 10) || 1;
//if we're parsing 'd', then the low day numbers may be next week
if (w.d != null && weekday < lang._week.dow) {
week++;
}
temp = dayOfYearFromWeeks(fixYear(w.gg), week, weekday, lang._week.doy, lang._week.dow);
}
config._a[YEAR] = temp.year;
config._dayOfYear = temp.dayOfYear;
}
//if the day of the year is set, figure out what it is
if (config._dayOfYear) {
yearToUse = config._a[YEAR] == null ? currentDate[YEAR] : config._a[YEAR];
if (config._dayOfYear > daysInYear(yearToUse)) {
config._pf._overflowDayOfYear = true;
}
date = makeUTCDate(yearToUse, 0, config._dayOfYear);
config._a[MONTH] = date.getUTCMonth();
config._a[DATE] = date.getUTCDate();
}
// Default to current date.
// * if no year, month, day of month are given, default to today
// * if day of month is given, default month and year
// * if month is given, default only year
// * if year is given, don't default anything
for (i = 0; i < 3 && config._a[i] == null; ++i) {
config._a[i] = input[i] = currentDate[i];
}
// Zero out whatever was not defaulted, including time
for (; i < 7; i++) {
config._a[i] = input[i] = (config._a[i] == null) ? (i === 2 ? 1 : 0) : config._a[i];
}
// add the offsets to the time to be parsed so that we can have a clean array for checking isValid
input[HOUR] += toInt((config._tzm || 0) / 60);
input[MINUTE] += toInt((config._tzm || 0) % 60);
config._d = (config._useUTC ? makeUTCDate : makeDate).apply(null, input);
}
function dateFromObject(config) {
var normalizedInput;
if (config._d) {
return;
}
normalizedInput = normalizeObjectUnits(config._i);
config._a = [
normalizedInput.year,
normalizedInput.month,
normalizedInput.day,
normalizedInput.hour,
normalizedInput.minute,
normalizedInput.second,
normalizedInput.millisecond
];
dateFromConfig(config);
}
function currentDateArray(config) {
var now = new Date();
if (config._useUTC) {
return [
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate()
];
} else {
return [now.getFullYear(), now.getMonth(), now.getDate()];
}
}
// date from string and format string
function makeDateFromStringAndFormat(config) {
config._a = [];
config._pf.empty = true;
// This array is used to make a Date, either with `new Date` or `Date.UTC`
var lang = getLangDefinition(config._l),
string = '' + config._i,
i, parsedInput, tokens, token, skipped,
stringLength = string.length,
totalParsedInputLength = 0;
tokens = expandFormat(config._f, lang).match(formattingTokens) || [];
for (i = 0; i < tokens.length; i++) {
token = tokens[i];
parsedInput = (string.match(getParseRegexForToken(token, config)) || [])[0];
if (parsedInput) {
skipped = string.substr(0, string.indexOf(parsedInput));
if (skipped.length > 0) {
config._pf.unusedInput.push(skipped);
}
string = string.slice(string.indexOf(parsedInput) + parsedInput.length);
totalParsedInputLength += parsedInput.length;
}
// don't parse if it's not a known token
if (formatTokenFunctions[token]) {
if (parsedInput) {
config._pf.empty = false;
}
else {
config._pf.unusedTokens.push(token);
}
addTimeToArrayFromToken(token, parsedInput, config);
}
else if (config._strict && !parsedInput) {
config._pf.unusedTokens.push(token);
}
}
// add remaining unparsed input length to the string
config._pf.charsLeftOver = stringLength - totalParsedInputLength;
if (string.length > 0) {
config._pf.unusedInput.push(string);
}
// handle am pm
if (config._isPm && config._a[HOUR] < 12) {
config._a[HOUR] += 12;
}
// if is 12 am, change hours to 0
if (config._isPm === false && config._a[HOUR] === 12) {
config._a[HOUR] = 0;
}
dateFromConfig(config);
checkOverflow(config);
}
function unescapeFormat(s) {
return s.replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, function (matched, p1, p2, p3, p4) {
return p1 || p2 || p3 || p4;
});
}
// Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript
function regexpEscape(s) {
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
// date from string and array of format strings
function makeDateFromStringAndArray(config) {
var tempConfig,
bestMoment,
scoreToBeat,
i,
currentScore;
if (config._f.length === 0) {
config._pf.invalidFormat = true;
config._d = new Date(NaN);
return;
}
for (i = 0; i < config._f.length; i++) {
currentScore = 0;
tempConfig = extend({}, config);
initializeParsingFlags(tempConfig);
tempConfig._f = config._f[i];
makeDateFromStringAndFormat(tempConfig);
if (!isValid(tempConfig)) {
continue;
}
// if there is any input that was not parsed add a penalty for that format
currentScore += tempConfig._pf.charsLeftOver;
//or tokens
currentScore += tempConfig._pf.unusedTokens.length * 10;
tempConfig._pf.score = currentScore;
if (scoreToBeat == null || currentScore < scoreToBeat) {
scoreToBeat = currentScore;
bestMoment = tempConfig;
}
}
extend(config, bestMoment || tempConfig);
}
// date from iso format
function makeDateFromString(config) {
var i,
string = config._i,
match = isoRegex.exec(string);
if (match) {
config._pf.iso = true;
for (i = 4; i > 0; i--) {
if (match[i]) {
// match[5] should be "T" or undefined
config._f = isoDates[i - 1] + (match[6] || " ");
break;
}
}
for (i = 0; i < 4; i++) {
if (isoTimes[i][1].exec(string)) {
config._f += isoTimes[i][0];
break;
}
}
if (string.match(parseTokenTimezone)) {
config._f += "Z";
}
makeDateFromStringAndFormat(config);
}
else {
config._d = new Date(string);
}
}
function makeDateFromInput(config) {
var input = config._i,
matched = aspNetJsonRegex.exec(input);
if (input === undefined) {
config._d = new Date();
} else if (matched) {
config._d = new Date(+matched[1]);
} else if (typeof input === 'string') {
makeDateFromString(config);
} else if (isArray(input)) {
config._a = input.slice(0);
dateFromConfig(config);
} else if (isDate(input)) {
config._d = new Date(+input);
} else if (typeof(input) === 'object') {
dateFromObject(config);
} else {
config._d = new Date(input);
}
}
function makeDate(y, m, d, h, M, s, ms) {
//can't just apply() to create a date:
//http://stackoverflow.com/questions/181348/instantiating-a-javascript-object-by-calling-prototype-constructor-apply
var date = new Date(y, m, d, h, M, s, ms);
//the date constructor doesn't accept years < 1970
if (y < 1970) {
date.setFullYear(y);
}
return date;
}
function makeUTCDate(y) {
var date = new Date(Date.UTC.apply(null, arguments));
if (y < 1970) {
date.setUTCFullYear(y);
}
return date;
}
function parseWeekday(input, language) {
if (typeof input === 'string') {
if (!isNaN(input)) {
input = parseInt(input, 10);
}
else {
input = language.weekdaysParse(input);
if (typeof input !== 'number') {
return null;
}
}
}
return input;
}
/************************************
Relative Time
************************************/
// helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize
function substituteTimeAgo(string, number, withoutSuffix, isFuture, lang) {
return lang.relativeTime(number || 1, !!withoutSuffix, string, isFuture);
}
function relativeTime(milliseconds, withoutSuffix, lang) {
var seconds = round(Math.abs(milliseconds) / 1000),
minutes = round(seconds / 60),
hours = round(minutes / 60),
days = round(hours / 24),
years = round(days / 365),
args = seconds < 45 && ['s', seconds] ||
minutes === 1 && ['m'] ||
minutes < 45 && ['mm', minutes] ||
hours === 1 && ['h'] ||
hours < 22 && ['hh', hours] ||
days === 1 && ['d'] ||
days <= 25 && ['dd', days] ||
days <= 45 && ['M'] ||
days < 345 && ['MM', round(days / 30)] ||
years === 1 && ['y'] || ['yy', years];
args[2] = withoutSuffix;
args[3] = milliseconds > 0;
args[4] = lang;
return substituteTimeAgo.apply({}, args);
}
/************************************
Week of Year
************************************/
// firstDayOfWeek 0 = sun, 6 = sat
// the day of the week that starts the week
// (usually sunday or monday)
// firstDayOfWeekOfYear 0 = sun, 6 = sat
// the first week is the week that contains the first
// of this day of the week
// (eg. ISO weeks use thursday (4))
function weekOfYear(mom, firstDayOfWeek, firstDayOfWeekOfYear) {
var end = firstDayOfWeekOfYear - firstDayOfWeek,
daysToDayOfWeek = firstDayOfWeekOfYear - mom.day(),
adjustedMoment;
if (daysToDayOfWeek > end) {
daysToDayOfWeek -= 7;
}
if (daysToDayOfWeek < end - 7) {
daysToDayOfWeek += 7;
}
adjustedMoment = moment(mom).add('d', daysToDayOfWeek);
return {
week: Math.ceil(adjustedMoment.dayOfYear() / 7),
year: adjustedMoment.year()
};
}
//http://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday
function dayOfYearFromWeeks(year, week, weekday, firstDayOfWeekOfYear, firstDayOfWeek) {
// The only solid way to create an iso date from year is to use
// a string format (Date.UTC handles only years > 1900). Don't ask why
// it doesn't need Z at the end.
var d = new Date(leftZeroFill(year, 6, true) + '-01-01').getUTCDay(),
daysToAdd, dayOfYear;
weekday = weekday != null ? weekday : firstDayOfWeek;
daysToAdd = firstDayOfWeek - d + (d > firstDayOfWeekOfYear ? 7 : 0);
dayOfYear = 7 * (week - 1) + (weekday - firstDayOfWeek) + daysToAdd + 1;
return {
year: dayOfYear > 0 ? year : year - 1,
dayOfYear: dayOfYear > 0 ? dayOfYear : daysInYear(year - 1) + dayOfYear
};
}
/************************************
Top Level Functions
************************************/
function makeMoment(config) {
var input = config._i,
format = config._f;
if (typeof config._pf === 'undefined') {
initializeParsingFlags(config);
}
if (input === null) {
return moment.invalid({nullInput: true});
}
if (typeof input === 'string') {
config._i = input = getLangDefinition().preparse(input);
}
if (moment.isMoment(input)) {
config = extend({}, input);
config._d = new Date(+input._d);
} else if (format) {
if (isArray(format)) {
makeDateFromStringAndArray(config);
} else {
makeDateFromStringAndFormat(config);
}
} else {
makeDateFromInput(config);
}
return new Moment(config);
}
moment = function (input, format, lang, strict) {
if (typeof(lang) === "boolean") {
strict = lang;
lang = undefined;
}
return makeMoment({
_i : input,
_f : format,
_l : lang,
_strict : strict,
_isUTC : false
});
};
// creating with utc
moment.utc = function (input, format, lang, strict) {
var m;
if (typeof(lang) === "boolean") {
strict = lang;
lang = undefined;
}
m = makeMoment({
_useUTC : true,
_isUTC : true,
_l : lang,
_i : input,
_f : format,
_strict : strict
}).utc();
return m;
};
// creating with unix timestamp (in seconds)
moment.unix = function (input) {
return moment(input * 1000);
};
// duration
moment.duration = function (input, key) {
var duration = input,
// matching against regexp is expensive, do it on demand
match = null,
sign,
ret,
parseIso;
if (moment.isDuration(input)) {
duration = {
ms: input._milliseconds,
d: input._days,
M: input._months
};
} else if (typeof input === 'number') {
duration = {};
if (key) {
duration[key] = input;
} else {
duration.milliseconds = input;
}
} else if (!!(match = aspNetTimeSpanJsonRegex.exec(input))) {
sign = (match[1] === "-") ? -1 : 1;
duration = {
y: 0,
d: toInt(match[DATE]) * sign,
h: toInt(match[HOUR]) * sign,
m: toInt(match[MINUTE]) * sign,
s: toInt(match[SECOND]) * sign,
ms: toInt(match[MILLISECOND]) * sign
};
} else if (!!(match = isoDurationRegex.exec(input))) {
sign = (match[1] === "-") ? -1 : 1;
parseIso = function (inp) {
// We'd normally use ~~inp for this, but unfortunately it also
// converts floats to ints.
// inp may be undefined, so careful calling replace on it.
var res = inp && parseFloat(inp.replace(',', '.'));
// apply sign while we're at it
return (isNaN(res) ? 0 : res) * sign;
};
duration = {
y: parseIso(match[2]),
M: parseIso(match[3]),
d: parseIso(match[4]),
h: parseIso(match[5]),
m: parseIso(match[6]),
s: parseIso(match[7]),
w: parseIso(match[8])
};
}
ret = new Duration(duration);
if (moment.isDuration(input) && input.hasOwnProperty('_lang')) {
ret._lang = input._lang;
}
return ret;
};
// version number
moment.version = VERSION;
// default format
moment.defaultFormat = isoFormat;
// This function will be called whenever a moment is mutated.
// It is intended to keep the offset in sync with the timezone.
moment.updateOffset = function () {};
// This function will load languages and then set the global language. If
// no arguments are passed in, it will simply return the current global
// language key.
moment.lang = function (key, values) {
var r;
if (!key) {
return moment.fn._lang._abbr;
}
if (values) {
loadLang(normalizeLanguage(key), values);
} else if (values === null) {
unloadLang(key);
key = 'en';
} else if (!languages[key]) {
getLangDefinition(key);
}
r = moment.duration.fn._lang = moment.fn._lang = getLangDefinition(key);
return r._abbr;
};
// returns language data
moment.langData = function (key) {
if (key && key._lang && key._lang._abbr) {
key = key._lang._abbr;
}
return getLangDefinition(key);
};
// compare moment object
moment.isMoment = function (obj) {
return obj instanceof Moment;
};
// for typechecking Duration objects
moment.isDuration = function (obj) {
return obj instanceof Duration;
};
for (i = lists.length - 1; i >= 0; --i) {
makeList(lists[i]);
}
moment.normalizeUnits = function (units) {
return normalizeUnits(units);
};
moment.invalid = function (flags) {
var m = moment.utc(NaN);
if (flags != null) {
extend(m._pf, flags);
}
else {
m._pf.userInvalidated = true;
}
return m;
};
moment.parseZone = function (input) {
return moment(input).parseZone();
};
/************************************
Moment Prototype
************************************/
extend(moment.fn = Moment.prototype, {
clone : function () {
return moment(this);
},
valueOf : function () {
return +this._d + ((this._offset || 0) * 60000);
},
unix : function () {
return Math.floor(+this / 1000);
},
toString : function () {
return this.clone().lang('en').format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ");
},
toDate : function () {
return this._offset ? new Date(+this) : this._d;
},
toISOString : function () {
var m = moment(this).utc();
if (0 < m.year() && m.year() <= 9999) {
return formatMoment(m, 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]');
} else {
return formatMoment(m, 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]');
}
},
toArray : function () {
var m = this;
return [
m.year(),
m.month(),
m.date(),
m.hours(),
m.minutes(),
m.seconds(),
m.milliseconds()
];
},
isValid : function () {
return isValid(this);
},
isDSTShifted : function () {
if (this._a) {
return this.isValid() && compareArrays(this._a, (this._isUTC ? moment.utc(this._a) : moment(this._a)).toArray()) > 0;
}
return false;
},
parsingFlags : function () {
return extend({}, this._pf);
},
invalidAt: function () {
return this._pf.overflow;
},
utc : function () {
return this.zone(0);
},
local : function () {
this.zone(0);
this._isUTC = false;
return this;
},
format : function (inputString) {
var output = formatMoment(this, inputString || moment.defaultFormat);
return this.lang().postformat(output);
},
add : function (input, val) {
var dur;
// switch args to support add('s', 1) and add(1, 's')
if (typeof input === 'string') {
dur = moment.duration(+val, input);
} else {
dur = moment.duration(input, val);
}
addOrSubtractDurationFromMoment(this, dur, 1);
return this;
},
subtract : function (input, val) {
var dur;
// switch args to support subtract('s', 1) and subtract(1, 's')
if (typeof input === 'string') {
dur = moment.duration(+val, input);
} else {
dur = moment.duration(input, val);
}
addOrSubtractDurationFromMoment(this, dur, -1);
return this;
},
diff : function (input, units, asFloat) {
var that = makeAs(input, this),
zoneDiff = (this.zone() - that.zone()) * 6e4,
diff, output;
units = normalizeUnits(units);
if (units === 'year' || units === 'month') {
// average number of days in the months in the given dates
diff = (this.daysInMonth() + that.daysInMonth()) * 432e5; // 24 * 60 * 60 * 1000 / 2
// difference in months
output = ((this.year() - that.year()) * 12) + (this.month() - that.month());
// adjust by taking difference in days, average number of days
// and dst in the given months.
output += ((this - moment(this).startOf('month')) -
(that - moment(that).startOf('month'))) / diff;
// same as above but with zones, to negate all dst
output -= ((this.zone() - moment(this).startOf('month').zone()) -
(that.zone() - moment(that).startOf('month').zone())) * 6e4 / diff;
if (units === 'year') {
output = output / 12;
}
} else {
diff = (this - that);
output = units === 'second' ? diff / 1e3 : // 1000
units === 'minute' ? diff / 6e4 : // 1000 * 60
units === 'hour' ? diff / 36e5 : // 1000 * 60 * 60
units === 'day' ? (diff - zoneDiff) / 864e5 : // 1000 * 60 * 60 * 24, negate dst
units === 'week' ? (diff - zoneDiff) / 6048e5 : // 1000 * 60 * 60 * 24 * 7, negate dst
diff;
}
return asFloat ? output : absRound(output);
},
from : function (time, withoutSuffix) {
return moment.duration(this.diff(time)).lang(this.lang()._abbr).humanize(!withoutSuffix);
},
fromNow : function (withoutSuffix) {
return this.from(moment(), withoutSuffix);
},
calendar : function () {
// We want to compare the start of today, vs this.
// Getting start-of-today depends on whether we're zone'd or not.
var sod = makeAs(moment(), this).startOf('day'),
diff = this.diff(sod, 'days', true),
format = diff < -6 ? 'sameElse' :
diff < -1 ? 'lastWeek' :
diff < 0 ? 'lastDay' :
diff < 1 ? 'sameDay' :
diff < 2 ? 'nextDay' :
diff < 7 ? 'nextWeek' : 'sameElse';
return this.format(this.lang().calendar(format, this));
},
isLeapYear : function () {
return isLeapYear(this.year());
},
isDST : function () {
return (this.zone() < this.clone().month(0).zone() ||
this.zone() < this.clone().month(5).zone());
},
day : function (input) {
var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay();
if (input != null) {
input = parseWeekday(input, this.lang());
return this.add({ d : input - day });
} else {
return day;
}
},
month : function (input) {
var utc = this._isUTC ? 'UTC' : '',
dayOfMonth;
if (input != null) {
if (typeof input === 'string') {
input = this.lang().monthsParse(input);
if (typeof input !== 'number') {
return this;
}
}
dayOfMonth = this.date();
this.date(1);
this._d['set' + utc + 'Month'](input);
this.date(Math.min(dayOfMonth, this.daysInMonth()));
moment.updateOffset(this);
return this;
} else {
return this._d['get' + utc + 'Month']();
}
},
startOf: function (units) {
units = normalizeUnits(units);
// the following switch intentionally omits break keywords
// to utilize falling through the cases.
switch (units) {
case 'year':
this.month(0);
/* falls through */
case 'month':
this.date(1);
/* falls through */
case 'week':
case 'isoWeek':
case 'day':
this.hours(0);
/* falls through */
case 'hour':
this.minutes(0);
/* falls through */
case 'minute':
this.seconds(0);
/* falls through */
case 'second':
this.milliseconds(0);
/* falls through */
}
// weeks are a special case
if (units === 'week') {
this.weekday(0);
} else if (units === 'isoWeek') {
this.isoWeekday(1);
}
return this;
},
endOf: function (units) {
units = normalizeUnits(units);
return this.startOf(units).add((units === 'isoWeek' ? 'week' : units), 1).subtract('ms', 1);
},
isAfter: function (input, units) {
units = typeof units !== 'undefined' ? units : 'millisecond';
return +this.clone().startOf(units) > +moment(input).startOf(units);
},
isBefore: function (input, units) {
units = typeof units !== 'undefined' ? units : 'millisecond';
return +this.clone().startOf(units) < +moment(input).startOf(units);
},
isSame: function (input, units) {
units = units || 'ms';
return +this.clone().startOf(units) === +makeAs(input, this).startOf(units);
},
min: function (other) {
other = moment.apply(null, arguments);
return other < this ? this : other;
},
max: function (other) {
other = moment.apply(null, arguments);
return other > this ? this : other;
},
zone : function (input) {
var offset = this._offset || 0;
if (input != null) {
if (typeof input === "string") {
input = timezoneMinutesFromString(input);
}
if (Math.abs(input) < 16) {
input = input * 60;
}
this._offset = input;
this._isUTC = true;
if (offset !== input) {
addOrSubtractDurationFromMoment(this, moment.duration(offset - input, 'm'), 1, true);
}
} else {
return this._isUTC ? offset : this._d.getTimezoneOffset();
}
return this;
},
zoneAbbr : function () {
return this._isUTC ? "UTC" : "";
},
zoneName : function () {
return this._isUTC ? "Coordinated Universal Time" : "";
},
parseZone : function () {
if (this._tzm) {
this.zone(this._tzm);
} else if (typeof this._i === 'string') {
this.zone(this._i);
}
return this;
},
hasAlignedHourOffset : function (input) {
if (!input) {
input = 0;
}
else {
input = moment(input).zone();
}
return (this.zone() - input) % 60 === 0;
},
daysInMonth : function () {
return daysInMonth(this.year(), this.month());
},
dayOfYear : function (input) {
var dayOfYear = round((moment(this).startOf('day') - moment(this).startOf('year')) / 864e5) + 1;
return input == null ? dayOfYear : this.add("d", (input - dayOfYear));
},
quarter : function () {
return Math.ceil((this.month() + 1.0) / 3.0);
},
weekYear : function (input) {
var year = weekOfYear(this, this.lang()._week.dow, this.lang()._week.doy).year;
return input == null ? year : this.add("y", (input - year));
},
isoWeekYear : function (input) {
var year = weekOfYear(this, 1, 4).year;
return input == null ? year : this.add("y", (input - year));
},
week : function (input) {
var week = this.lang().week(this);
return input == null ? week : this.add("d", (input - week) * 7);
},
isoWeek : function (input) {
var week = weekOfYear(this, 1, 4).week;
return input == null ? week : this.add("d", (input - week) * 7);
},
weekday : function (input) {
var weekday = (this.day() + 7 - this.lang()._week.dow) % 7;
return input == null ? weekday : this.add("d", input - weekday);
},
isoWeekday : function (input) {
// behaves the same as moment#day except
// as a getter, returns 7 instead of 0 (1-7 range instead of 0-6)
// as a setter, sunday should belong to the previous week.
return input == null ? this.day() || 7 : this.day(this.day() % 7 ? input : input - 7);
},
get : function (units) {
units = normalizeUnits(units);
return this[units]();
},
set : function (units, value) {
units = normalizeUnits(units);
if (typeof this[units] === 'function') {
this[units](value);
}
return this;
},
// If passed a language key, it will set the language for this
// instance. Otherwise, it will return the language configuration
// variables for this instance.
lang : function (key) {
if (key === undefined) {
return this._lang;
} else {
this._lang = getLangDefinition(key);
return this;
}
}
});
// helper for adding shortcuts
function makeGetterAndSetter(name, key) {
moment.fn[name] = moment.fn[name + 's'] = function (input) {
var utc = this._isUTC ? 'UTC' : '';
if (input != null) {
this._d['set' + utc + key](input);
moment.updateOffset(this);
return this;
} else {
return this._d['get' + utc + key]();
}
};
}
// loop through and add shortcuts (Month, Date, Hours, Minutes, Seconds, Milliseconds)
for (i = 0; i < proxyGettersAndSetters.length; i ++) {
makeGetterAndSetter(proxyGettersAndSetters[i].toLowerCase().replace(/s$/, ''), proxyGettersAndSetters[i]);
}
// add shortcut for year (uses different syntax than the getter/setter 'year' == 'FullYear')
makeGetterAndSetter('year', 'FullYear');
// add plural methods
moment.fn.days = moment.fn.day;
moment.fn.months = moment.fn.month;
moment.fn.weeks = moment.fn.week;
moment.fn.isoWeeks = moment.fn.isoWeek;
// add aliased format methods
moment.fn.toJSON = moment.fn.toISOString;
/************************************
Duration Prototype
************************************/
extend(moment.duration.fn = Duration.prototype, {
_bubble : function () {
var milliseconds = this._milliseconds,
days = this._days,
months = this._months,
data = this._data,
seconds, minutes, hours, years;
// The following code bubbles up values, see the tests for
// examples of what that means.
data.milliseconds = milliseconds % 1000;
seconds = absRound(milliseconds / 1000);
data.seconds = seconds % 60;
minutes = absRound(seconds / 60);
data.minutes = minutes % 60;
hours = absRound(minutes / 60);
data.hours = hours % 24;
days += absRound(hours / 24);
data.days = days % 30;
months += absRound(days / 30);
data.months = months % 12;
years = absRound(months / 12);
data.years = years;
},
weeks : function () {
return absRound(this.days() / 7);
},
valueOf : function () {
return this._milliseconds +
this._days * 864e5 +
(this._months % 12) * 2592e6 +
toInt(this._months / 12) * 31536e6;
},
humanize : function (withSuffix) {
var difference = +this,
output = relativeTime(difference, !withSuffix, this.lang());
if (withSuffix) {
output = this.lang().pastFuture(difference, output);
}
return this.lang().postformat(output);
},
add : function (input, val) {
// supports only 2.0-style add(1, 's') or add(moment)
var dur = moment.duration(input, val);
this._milliseconds += dur._milliseconds;
this._days += dur._days;
this._months += dur._months;
this._bubble();
return this;
},
subtract : function (input, val) {
var dur = moment.duration(input, val);
this._milliseconds -= dur._milliseconds;
this._days -= dur._days;
this._months -= dur._months;
this._bubble();
return this;
},
get : function (units) {
units = normalizeUnits(units);
return this[units.toLowerCase() + 's']();
},
as : function (units) {
units = normalizeUnits(units);
return this['as' + units.charAt(0).toUpperCase() + units.slice(1) + 's']();
},
lang : moment.fn.lang,
toIsoString : function () {
// inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js
var years = Math.abs(this.years()),
months = Math.abs(this.months()),
days = Math.abs(this.days()),
hours = Math.abs(this.hours()),
minutes = Math.abs(this.minutes()),
seconds = Math.abs(this.seconds() + this.milliseconds() / 1000);
if (!this.asSeconds()) {
// this is the same as C#'s (Noda) and python (isodate)...
// but not other JS (goog.date)
return 'P0D';
}
return (this.asSeconds() < 0 ? '-' : '') +
'P' +
(years ? years + 'Y' : '') +
(months ? months + 'M' : '') +
(days ? days + 'D' : '') +
((hours || minutes || seconds) ? 'T' : '') +
(hours ? hours + 'H' : '') +
(minutes ? minutes + 'M' : '') +
(seconds ? seconds + 'S' : '');
}
});
function makeDurationGetter(name) {
moment.duration.fn[name] = function () {
return this._data[name];
};
}
function makeDurationAsGetter(name, factor) {
moment.duration.fn['as' + name] = function () {
return +this / factor;
};
}
for (i in unitMillisecondFactors) {
if (unitMillisecondFactors.hasOwnProperty(i)) {
makeDurationAsGetter(i, unitMillisecondFactors[i]);
makeDurationGetter(i.toLowerCase());
}
}
makeDurationAsGetter('Weeks', 6048e5);
moment.duration.fn.asMonths = function () {
return (+this - this.years() * 31536e6) / 2592e6 + this.years() * 12;
};
/************************************
Default Lang
************************************/
// Set default language, other languages will inherit from English.
moment.lang('en', {
ordinal : function (number) {
var b = number % 10,
output = (toInt(number % 100 / 10) === 1) ? 'th' :
(b === 1) ? 'st' :
(b === 2) ? 'nd' :
(b === 3) ? 'rd' : 'th';
return number + output;
}
});
/* EMBED_LANGUAGES */
/************************************
Exposing Moment
************************************/
function makeGlobal(deprecate) {
var warned = false, local_moment = moment;
/*global ender:false */
if (typeof ender !== 'undefined') {
return;
}
// here, `this` means `window` in the browser, or `global` on the server
// add `moment` as a global object via a string identifier,
// for Closure Compiler "advanced" mode
if (deprecate) {
global.moment = function () {
if (!warned && console && console.warn) {
warned = true;
console.warn(
"Accessing Moment through the global scope is " +
"deprecated, and will be removed in an upcoming " +
"release.");
}
return local_moment.apply(null, arguments);
};
extend(global.moment, local_moment);
} else {
global['moment'] = moment;
}
}
// CommonJS module is defined
if (hasModule) {
module.exports = moment;
makeGlobal(true);
} else if (typeof define === "function" && define.amd) {
define("moment", function (require, exports, module) {
if (module.config && module.config() && module.config().noGlobal !== true) {
// If user provided noGlobal, he is aware of global
makeGlobal(module.config().noGlobal === undefined);
}
return moment;
});
} else {
makeGlobal();
}
}).call(this);
},{}],3:[function(require,module,exports){
/**
* Copyright 2012 Craig Campbell
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Mousetrap is a simple keyboard shortcut library for Javascript with
* no external dependencies
*
* @version 1.1.2
* @url craig.is/killing/mice
*/
/**
* mapping of special keycodes to their corresponding keys
*
* everything in this dictionary cannot use keypress events
* so it has to be here to map to the correct keycodes for
* keyup/keydown events
*
* @type {Object}
*/
var _MAP = {
8: 'backspace',
9: 'tab',
13: 'enter',
16: 'shift',
17: 'ctrl',
18: 'alt',
20: 'capslock',
27: 'esc',
32: 'space',
33: 'pageup',
34: 'pagedown',
35: 'end',
36: 'home',
37: 'left',
38: 'up',
39: 'right',
40: 'down',
45: 'ins',
46: 'del',
91: 'meta',
93: 'meta',
224: 'meta'
},
/**
* mapping for special characters so they can support
*
* this dictionary is only used incase you want to bind a
* keyup or keydown event to one of these keys
*
* @type {Object}
*/
_KEYCODE_MAP = {
106: '*',
107: '+',
109: '-',
110: '.',
111 : '/',
186: ';',
187: '=',
188: ',',
189: '-',
190: '.',
191: '/',
192: '`',
219: '[',
220: '\\',
221: ']',
222: '\''
},
/**
* this is a mapping of keys that require shift on a US keypad
* back to the non shift equivelents
*
* this is so you can use keyup events with these keys
*
* note that this will only work reliably on US keyboards
*
* @type {Object}
*/
_SHIFT_MAP = {
'~': '`',
'!': '1',
'@': '2',
'#': '3',
'$': '4',
'%': '5',
'^': '6',
'&': '7',
'*': '8',
'(': '9',
')': '0',
'_': '-',
'+': '=',
':': ';',
'\"': '\'',
'<': ',',
'>': '.',
'?': '/',
'|': '\\'
},
/**
* this is a list of special strings you can use to map
* to modifier keys when you specify your keyboard shortcuts
*
* @type {Object}
*/
_SPECIAL_ALIASES = {
'option': 'alt',
'command': 'meta',
'return': 'enter',
'escape': 'esc'
},
/**
* variable to store the flipped version of _MAP from above
* needed to check if we should use keypress or not when no action
* is specified
*
* @type {Object|undefined}
*/
_REVERSE_MAP,
/**
* a list of all the callbacks setup via Mousetrap.bind()
*
* @type {Object}
*/
_callbacks = {},
/**
* direct map of string combinations to callbacks used for trigger()
*
* @type {Object}
*/
_direct_map = {},
/**
* keeps track of what level each sequence is at since multiple
* sequences can start out with the same sequence
*
* @type {Object}
*/
_sequence_levels = {},
/**
* variable to store the setTimeout call
*
* @type {null|number}
*/
_reset_timer,
/**
* temporary state where we will ignore the next keyup
*
* @type {boolean|string}
*/
_ignore_next_keyup = false,
/**
* are we currently inside of a sequence?
* type of action ("keyup" or "keydown" or "keypress") or false
*
* @type {boolean|string}
*/
_inside_sequence = false;
/**
* loop through the f keys, f1 to f19 and add them to the map
* programatically
*/
for (var i = 1; i < 20; ++i) {
_MAP[111 + i] = 'f' + i;
}
/**
* loop through to map numbers on the numeric keypad
*/
for (i = 0; i <= 9; ++i) {
_MAP[i + 96] = i;
}
/**
* cross browser add event method
*
* @param {Element|HTMLDocument} object
* @param {string} type
* @param {Function} callback
* @returns void
*/
function _addEvent(object, type, callback) {
if (object.addEventListener) {
return object.addEventListener(type, callback, false);
}
object.attachEvent('on' + type, callback);
}
/**
* takes the event and returns the key character
*
* @param {Event} e
* @return {string}
*/
function _characterFromEvent(e) {
// for keypress events we should return the character as is
if (e.type == 'keypress') {
return String.fromCharCode(e.which);
}
// for non keypress events the special maps are needed
if (_MAP[e.which]) {
return _MAP[e.which];
}
if (_KEYCODE_MAP[e.which]) {
return _KEYCODE_MAP[e.which];
}
// if it is not in the special map
return String.fromCharCode(e.which).toLowerCase();
}
/**
* should we stop this event before firing off callbacks
*
* @param {Event} e
* @return {boolean}
*/
function _stop(e) {
var element = e.target || e.srcElement,
tag_name = element.tagName;
// if the element has the class "mousetrap" then no need to stop
if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) {
return false;
}
// stop for input, select, and textarea
return tag_name == 'INPUT' || tag_name == 'SELECT' || tag_name == 'TEXTAREA' || (element.contentEditable && element.contentEditable == 'true');
}
/**
* checks if two arrays are equal
*
* @param {Array} modifiers1
* @param {Array} modifiers2
* @returns {boolean}
*/
function _modifiersMatch(modifiers1, modifiers2) {
return modifiers1.sort().join(',') === modifiers2.sort().join(',');
}
/**
* resets all sequence counters except for the ones passed in
*
* @param {Object} do_not_reset
* @returns void
*/
function _resetSequences(do_not_reset) {
do_not_reset = do_not_reset || {};
var active_sequences = false,
key;
for (key in _sequence_levels) {
if (do_not_reset[key]) {
active_sequences = true;
continue;
}
_sequence_levels[key] = 0;
}
if (!active_sequences) {
_inside_sequence = false;
}
}
/**
* finds all callbacks that match based on the keycode, modifiers,
* and action
*
* @param {string} character
* @param {Array} modifiers
* @param {string} action
* @param {boolean=} remove - should we remove any matches
* @param {string=} combination
* @returns {Array}
*/
function _getMatches(character, modifiers, action, remove, combination) {
var i,
callback,
matches = [];
// if there are no events related to this keycode
if (!_callbacks[character]) {
return [];
}
// if a modifier key is coming up on its own we should allow it
if (action == 'keyup' && _isModifier(character)) {
modifiers = [character];
}
// loop through all callbacks for the key that was pressed
// and see if any of them match
for (i = 0; i < _callbacks[character].length; ++i) {
callback = _callbacks[character][i];
// if this is a sequence but it is not at the right level
// then move onto the next match
if (callback.seq && _sequence_levels[callback.seq] != callback.level) {
continue;
}
// if the action we are looking for doesn't match the action we got
// then we should keep going
if (action != callback.action) {
continue;
}
// if this is a keypress event that means that we need to only
// look at the character, otherwise check the modifiers as
// well
if (action == 'keypress' || _modifiersMatch(modifiers, callback.modifiers)) {
// remove is used so if you change your mind and call bind a
// second time with a new function the first one is overwritten
if (remove && callback.combo == combination) {
_callbacks[character].splice(i, 1);
}
matches.push(callback);
}
}
return matches;
}
/**
* takes a key event and figures out what the modifiers are
*
* @param {Event} e
* @returns {Array}
*/
function _eventModifiers(e) {
var modifiers = [];
if (e.shiftKey) {
modifiers.push('shift');
}
if (e.altKey) {
modifiers.push('alt');
}
if (e.ctrlKey) {
modifiers.push('ctrl');
}
if (e.metaKey) {
modifiers.push('meta');
}
return modifiers;
}
/**
* actually calls the callback function
*
* if your callback function returns false this will use the jquery
* convention - prevent default and stop propogation on the event
*
* @param {Function} callback
* @param {Event} e
* @returns void
*/
function _fireCallback(callback, e) {
if (callback(e) === false) {
if (e.preventDefault) {
e.preventDefault();
}
if (e.stopPropagation) {
e.stopPropagation();
}
e.returnValue = false;
e.cancelBubble = true;
}
}
/**
* handles a character key event
*
* @param {string} character
* @param {Event} e
* @returns void
*/
function _handleCharacter(character, e) {
// if this event should not happen stop here
if (_stop(e)) {
return;
}
var callbacks = _getMatches(character, _eventModifiers(e), e.type),
i,
do_not_reset = {},
processed_sequence_callback = false;
// loop through matching callbacks for this key event
for (i = 0; i < callbacks.length; ++i) {
// fire for all sequence callbacks
// this is because if for example you have multiple sequences
// bound such as "g i" and "g t" they both need to fire the
// callback for matching g cause otherwise you can only ever
// match the first one
if (callbacks[i].seq) {
processed_sequence_callback = true;
// keep a list of which sequences were matches for later
do_not_reset[callbacks[i].seq] = 1;
_fireCallback(callbacks[i].callback, e);
continue;
}
// if there were no sequence matches but we are still here
// that means this is a regular match so we should fire that
if (!processed_sequence_callback && !_inside_sequence) {
_fireCallback(callbacks[i].callback, e);
}
}
// if you are inside of a sequence and the key you are pressing
// is not a modifier key then we should reset all sequences
// that were not matched by this key event
if (e.type == _inside_sequence && !_isModifier(character)) {
_resetSequences(do_not_reset);
}
}
/**
* handles a keydown event
*
* @param {Event} e
* @returns void
*/
function _handleKey(e) {
// normalize e.which for key events
// @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion
e.which = typeof e.which == "number" ? e.which : e.keyCode;
var character = _characterFromEvent(e);
// no character found then stop
if (!character) {
return;
}
if (e.type == 'keyup' && _ignore_next_keyup == character) {
_ignore_next_keyup = false;
return;
}
_handleCharacter(character, e);
}
/**
* determines if the keycode specified is a modifier key or not
*
* @param {string} key
* @returns {boolean}
*/
function _isModifier(key) {
return key == 'shift' || key == 'ctrl' || key == 'alt' || key == 'meta';
}
/**
* called to set a 1 second timeout on the specified sequence
*
* this is so after each key press in the sequence you have 1 second
* to press the next key before you have to start over
*
* @returns void
*/
function _resetSequenceTimer() {
clearTimeout(_reset_timer);
_reset_timer = setTimeout(_resetSequences, 1000);
}
/**
* reverses the map lookup so that we can look for specific keys
* to see what can and can't use keypress
*
* @return {Object}
*/
function _getReverseMap() {
if (!_REVERSE_MAP) {
_REVERSE_MAP = {};
for (var key in _MAP) {
// pull out the numeric keypad from here cause keypress should
// be able to detect the keys from the character
if (key > 95 && key < 112) {
continue;
}
if (_MAP.hasOwnProperty(key)) {
_REVERSE_MAP[_MAP[key]] = key;
}
}
}
return _REVERSE_MAP;
}
/**
* picks the best action based on the key combination
*
* @param {string} key - character for key
* @param {Array} modifiers
* @param {string=} action passed in
*/
function _pickBestAction(key, modifiers, action) {
// if no action was picked in we should try to pick the one
// that we think would work best for this key
if (!action) {
action = _getReverseMap()[key] ? 'keydown' : 'keypress';
}
// modifier keys don't work as expected with keypress,
// switch to keydown
if (action == 'keypress' && modifiers.length) {
action = 'keydown';
}
return action;
}
/**
* binds a key sequence to an event
*
* @param {string} combo - combo specified in bind call
* @param {Array} keys
* @param {Function} callback
* @param {string=} action
* @returns void
*/
function _bindSequence(combo, keys, callback, action) {
// start off by adding a sequence level record for this combination
// and setting the level to 0
_sequence_levels[combo] = 0;
// if there is no action pick the best one for the first key
// in the sequence
if (!action) {
action = _pickBestAction(keys[0], []);
}
/**
* callback to increase the sequence level for this sequence and reset
* all other sequences that were active
*
* @param {Event} e
* @returns void
*/
var _increaseSequence = function(e) {
_inside_sequence = action;
++_sequence_levels[combo];
_resetSequenceTimer();
},
/**
* wraps the specified callback inside of another function in order
* to reset all sequence counters as soon as this sequence is done
*
* @param {Event} e
* @returns void
*/
_callbackAndReset = function(e) {
_fireCallback(callback, e);
// we should ignore the next key up if the action is key down
// or keypress. this is so if you finish a sequence and
// release the key the final key will not trigger a keyup
if (action !== 'keyup') {
_ignore_next_keyup = _characterFromEvent(e);
}
// weird race condition if a sequence ends with the key
// another sequence begins with
setTimeout(_resetSequences, 10);
},
i;
// loop through keys one at a time and bind the appropriate callback
// function. for any key leading up to the final one it should
// increase the sequence. after the final, it should reset all sequences
for (i = 0; i < keys.length; ++i) {
_bindSingle(keys[i], i < keys.length - 1 ? _increaseSequence : _callbackAndReset, action, combo, i);
}
}
/**
* binds a single keyboard combination
*
* @param {string} combination
* @param {Function} callback
* @param {string=} action
* @param {string=} sequence_name - name of sequence if part of sequence
* @param {number=} level - what part of the sequence the command is
* @returns void
*/
function _bindSingle(combination, callback, action, sequence_name, level) {
// make sure multiple spaces in a row become a single space
combination = combination.replace(/\s+/g, ' ');
var sequence = combination.split(' '),
i,
key,
keys,
modifiers = [];
// if this pattern is a sequence of keys then run through this method
// to reprocess each pattern one key at a time
if (sequence.length > 1) {
return _bindSequence(combination, sequence, callback, action);
}
// take the keys from this pattern and figure out what the actual
// pattern is all about
keys = combination === '+' ? ['+'] : combination.split('+');
for (i = 0; i < keys.length; ++i) {
key = keys[i];
// normalize key names
if (_SPECIAL_ALIASES[key]) {
key = _SPECIAL_ALIASES[key];
}
// if this is not a keypress event then we should
// be smart about using shift keys
// this will only work for US keyboards however
if (action && action != 'keypress' && _SHIFT_MAP[key]) {
key = _SHIFT_MAP[key];
modifiers.push('shift');
}
// if this key is a modifier then add it to the list of modifiers
if (_isModifier(key)) {
modifiers.push(key);
}
}
// depending on what the key combination is
// we will try to pick the best event for it
action = _pickBestAction(key, modifiers, action);
// make sure to initialize array if this is the first time
// a callback is added for this key
if (!_callbacks[key]) {
_callbacks[key] = [];
}
// remove an existing match if there is one
_getMatches(key, modifiers, action, !sequence_name, combination);
// add this call back to the array
// if it is a sequence put it at the beginning
// if not put it at the end
//
// this is important because the way these are processed expects
// the sequence ones to come first
_callbacks[key][sequence_name ? 'unshift' : 'push']({
callback: callback,
modifiers: modifiers,
action: action,
seq: sequence_name,
level: level,
combo: combination
});
}
/**
* binds multiple combinations to the same callback
*
* @param {Array} combinations
* @param {Function} callback
* @param {string|undefined} action
* @returns void
*/
function _bindMultiple(combinations, callback, action) {
for (var i = 0; i < combinations.length; ++i) {
_bindSingle(combinations[i], callback, action);
}
}
// start!
_addEvent(document, 'keypress', _handleKey);
_addEvent(document, 'keydown', _handleKey);
_addEvent(document, 'keyup', _handleKey);
var mousetrap = {
/**
* binds an event to mousetrap
*
* can be a single key, a combination of keys separated with +,
* a comma separated list of keys, an array of keys, or
* a sequence of keys separated by spaces
*
* be sure to list the modifier keys first to make sure that the
* correct key ends up getting bound (the last key in the pattern)
*
* @param {string|Array} keys
* @param {Function} callback
* @param {string=} action - 'keypress', 'keydown', or 'keyup'
* @returns void
*/
bind: function(keys, callback, action) {
_bindMultiple(keys instanceof Array ? keys : [keys], callback, action);
_direct_map[keys + ':' + action] = callback;
return this;
},
/**
* unbinds an event to mousetrap
*
* the unbinding sets the callback function of the specified key combo
* to an empty function and deletes the corresponding key in the
* _direct_map dict.
*
* the keycombo+action has to be exactly the same as
* it was defined in the bind method
*
* TODO: actually remove this from the _callbacks dictionary instead
* of binding an empty function
*
* @param {string|Array} keys
* @param {string} action
* @returns void
*/
unbind: function(keys, action) {
if (_direct_map[keys + ':' + action]) {
delete _direct_map[keys + ':' + action];
this.bind(keys, function() {}, action);
}
return this;
},
/**
* triggers an event that has already been bound
*
* @param {string} keys
* @param {string=} action
* @returns void
*/
trigger: function(keys, action) {
_direct_map[keys + ':' + action]();
return this;
},
/**
* resets the library back to its initial state. this is useful
* if you want to clear out the current keyboard shortcuts and bind
* new ones - for example if you switch to another page
*
* @returns void
*/
reset: function() {
_callbacks = {};
_direct_map = {};
return this;
}
};
module.exports = mousetrap;
},{}],4:[function(require,module,exports){
/**
* vis.js module imports
*/
// Try to load dependencies from the global window object.
// If not available there, load via require.
var moment = (typeof window !== 'undefined') && window['moment'] || require('moment');
var Hammer;
if (typeof window !== 'undefined') {
// load hammer.js only when running in a browser (where window is available)
Hammer = window['Hammer'] || require('hammerjs');
}
else {
Hammer = function () {
throw Error('hammer.js is only available in a browser, not in node.js.');
}
}
var mouseTrap;
if (typeof window !== 'undefined') {
// load mousetrap.js only when running in a browser (where window is available)
mouseTrap = window['mouseTrap'] || require('mouseTrap');
}
else {
mouseTrap = function () {
throw Error('mouseTrap is only available in a browser, not in node.js.');
}
}
// Internet Explorer 8 and older does not support Array.indexOf, so we define
// it here in that case.
// http://soledadpenades.com/2007/05/17/arrayindexof-in-internet-explorer/
if(!Array.prototype.indexOf) {
Array.prototype.indexOf = function(obj){
for(var i = 0; i < this.length; i++){
if(this[i] == obj){
return i;
}
}
return -1;
};
try {
console.log("Warning: Ancient browser detected. Please update your browser");
}
catch (err) {
}
}
// Internet Explorer 8 and older does not support Array.forEach, so we define
// it here in that case.
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/forEach
if (!Array.prototype.forEach) {
Array.prototype.forEach = function(fn, scope) {
for(var i = 0, len = this.length; i < len; ++i) {
fn.call(scope || this, this[i], i, this);
}
}
}
// Internet Explorer 8 and older does not support Array.map, so we define it
// here in that case.
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/map
// Production steps of ECMA-262, Edition 5, 15.4.4.19
// Reference: http://es5.github.com/#x15.4.4.19
if (!Array.prototype.map) {
Array.prototype.map = function(callback, thisArg) {
var T, A, k;
if (this == null) {
throw new TypeError(" this is null or not defined");
}
// 1. Let O be the result of calling ToObject passing the |this| value as the argument.
var O = Object(this);
// 2. Let lenValue be the result of calling the Get internal method of O with the argument "length".
// 3. Let len be ToUint32(lenValue).
var len = O.length >>> 0;
// 4. If IsCallable(callback) is false, throw a TypeError exception.
// See: http://es5.github.com/#x9.11
if (typeof callback !== "function") {
throw new TypeError(callback + " is not a function");
}
// 5. If thisArg was supplied, let T be thisArg; else let T be undefined.
if (thisArg) {
T = thisArg;
}
// 6. Let A be a new array created as if by the expression new Array(len) where Array is
// the standard built-in constructor with that name and len is the value of len.
A = new Array(len);
// 7. Let k be 0
k = 0;
// 8. Repeat, while k < len
while(k < len) {
var kValue, mappedValue;
// a. Let Pk be ToString(k).
// This is implicit for LHS operands of the in operator
// b. Let kPresent be the result of calling the HasProperty internal method of O with argument Pk.
// This step can be combined with c
// c. If kPresent is true, then
if (k in O) {
// i. Let kValue be the result of calling the Get internal method of O with argument Pk.
kValue = O[ k ];
// ii. Let mappedValue be the result of calling the Call internal method of callback
// with T as the this value and argument list containing kValue, k, and O.
mappedValue = callback.call(T, kValue, k, O);
// iii. Call the DefineOwnProperty internal method of A with arguments
// Pk, Property Descriptor {Value: mappedValue, : true, Enumerable: true, Configurable: true},
// and false.
// In browsers that support Object.defineProperty, use the following:
// Object.defineProperty(A, Pk, { value: mappedValue, writable: true, enumerable: true, configurable: true });
// For best browser support, use the following:
A[ k ] = mappedValue;
}
// d. Increase k by 1.
k++;
}
// 9. return A
return A;
};
}
// Internet Explorer 8 and older does not support Array.filter, so we define it
// here in that case.
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/filter
if (!Array.prototype.filter) {
Array.prototype.filter = function(fun /*, thisp */) {
"use strict";
if (this == null) {
throw new TypeError();
}
var t = Object(this);
var len = t.length >>> 0;
if (typeof fun != "function") {
throw new TypeError();
}
var res = [];
var thisp = arguments[1];
for (var i = 0; i < len; i++) {
if (i in t) {
var val = t[i]; // in case fun mutates this
if (fun.call(thisp, val, i, t))
res.push(val);
}
}
return res;
};
}
// Internet Explorer 8 and older does not support Object.keys, so we define it
// here in that case.
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/keys
if (!Object.keys) {
Object.keys = (function () {
var hasOwnProperty = Object.prototype.hasOwnProperty,
hasDontEnumBug = !({toString: null}).propertyIsEnumerable('toString'),
dontEnums = [
'toString',
'toLocaleString',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'constructor'
],
dontEnumsLength = dontEnums.length;
return function (obj) {
if (typeof obj !== 'object' && typeof obj !== 'function' || obj === null) {
throw new TypeError('Object.keys called on non-object');
}
var result = [];
for (var prop in obj) {
if (hasOwnProperty.call(obj, prop)) result.push(prop);
}
if (hasDontEnumBug) {
for (var i=0; i < dontEnumsLength; i++) {
if (hasOwnProperty.call(obj, dontEnums[i])) result.push(dontEnums[i]);
}
}
return result;
}
})()
}
// Internet Explorer 8 and older does not support Array.isArray,
// so we define it here in that case.
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/isArray
if(!Array.isArray) {
Array.isArray = function (vArg) {
return Object.prototype.toString.call(vArg) === "[object Array]";
};
}
// Internet Explorer 8 and older does not support Function.bind,
// so we define it here in that case.
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind
if (!Function.prototype.bind) {
Function.prototype.bind = function (oThis) {
if (typeof this !== "function") {
// closest thing possible to the ECMAScript 5 internal IsCallable function
throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
}
var aArgs = Array.prototype.slice.call(arguments, 1),
fToBind = this,
fNOP = function () {},
fBound = function () {
return fToBind.apply(this instanceof fNOP && oThis
? this
: oThis,
aArgs.concat(Array.prototype.slice.call(arguments)));
};
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
};
}
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/create
if (!Object.create) {
Object.create = function (o) {
if (arguments.length > 1) {
throw new Error('Object.create implementation only accepts the first parameter.');
}
function F() {}
F.prototype = o;
return new F();
};
}
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
if (!Function.prototype.bind) {
Function.prototype.bind = function (oThis) {
if (typeof this !== "function") {
// closest thing possible to the ECMAScript 5 internal IsCallable function
throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
}
var aArgs = Array.prototype.slice.call(arguments, 1),
fToBind = this,
fNOP = function () {},
fBound = function () {
return fToBind.apply(this instanceof fNOP && oThis
? this
: oThis,
aArgs.concat(Array.prototype.slice.call(arguments)));
};
fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();
return fBound;
};
}
/**
* utility functions
*/
var util = {};
/**
* Test whether given object is a number
* @param {*} object
* @return {Boolean} isNumber
*/
util.isNumber = function isNumber(object) {
return (object instanceof Number || typeof object == 'number');
};
/**
* Test whether given object is a string
* @param {*} object
* @return {Boolean} isString
*/
util.isString = function isString(object) {
return (object instanceof String || typeof object == 'string');
};
/**
* Test whether given object is a Date, or a String containing a Date
* @param {Date | String} object
* @return {Boolean} isDate
*/
util.isDate = function isDate(object) {
if (object instanceof Date) {
return true;
}
else if (util.isString(object)) {
// test whether this string contains a date
var match = ASPDateRegex.exec(object);
if (match) {
return true;
}
else if (!isNaN(Date.parse(object))) {
return true;
}
}
return false;
};
/**
* Test whether given object is an instance of google.visualization.DataTable
* @param {*} object
* @return {Boolean} isDataTable
*/
util.isDataTable = function isDataTable(object) {
return (typeof (google) !== 'undefined') &&
(google.visualization) &&
(google.visualization.DataTable) &&
(object instanceof google.visualization.DataTable);
};
/**
* Create a semi UUID
* source: http://stackoverflow.com/a/105074/1262753
* @return {String} uuid
*/
util.randomUUID = function randomUUID () {
var S4 = function () {
return Math.floor(
Math.random() * 0x10000 /* 65536 */
).toString(16);
};
return (
S4() + S4() + '-' +
S4() + '-' +
S4() + '-' +
S4() + '-' +
S4() + S4() + S4()
);
};
/**
* Extend object a with the properties of object b or a series of objects
* Only properties with defined values are copied
* @param {Object} a
* @param {... Object} b
* @return {Object} a
*/
util.extend = function (a, b) {
for (var i = 1, len = arguments.length; i < len; i++) {
var other = arguments[i];
for (var prop in other) {
if (other.hasOwnProperty(prop) && other[prop] !== undefined) {
a[prop] = other[prop];
}
}
}
return a;
};
/**
* Convert an object to another type
* @param {Boolean | Number | String | Date | Moment | Null | undefined} object
* @param {String | undefined} type Name of the type. Available types:
* 'Boolean', 'Number', 'String',
* 'Date', 'Moment', ISODate', 'ASPDate'.
* @return {*} object
* @throws Error
*/
util.convert = function convert(object, type) {
var match;
if (object === undefined) {
return undefined;
}
if (object === null) {
return null;
}
if (!type) {
return object;
}
if (!(typeof type === 'string') && !(type instanceof String)) {
throw new Error('Type must be a string');
}
//noinspection FallthroughInSwitchStatementJS
switch (type) {
case 'boolean':
case 'Boolean':
return Boolean(object);
case 'number':
case 'Number':
return Number(object.valueOf());
case 'string':
case 'String':
return String(object);
case 'Date':
if (util.isNumber(object)) {
return new Date(object);
}
if (object instanceof Date) {
return new Date(object.valueOf());
}
else if (moment.isMoment(object)) {
return new Date(object.valueOf());
}
if (util.isString(object)) {
match = ASPDateRegex.exec(object);
if (match) {
// object is an ASP date
return new Date(Number(match[1])); // parse number
}
else {
return moment(object).toDate(); // parse string
}
}
else {
throw new Error(
'Cannot convert object of type ' + util.getType(object) +
' to type Date');
}
case 'Moment':
if (util.isNumber(object)) {
return moment(object);
}
if (object instanceof Date) {
return moment(object.valueOf());
}
else if (moment.isMoment(object)) {
return moment(object);
}
if (util.isString(object)) {
match = ASPDateRegex.exec(object);
if (match) {
// object is an ASP date
return moment(Number(match[1])); // parse number
}
else {
return moment(object); // parse string
}
}
else {
throw new Error(
'Cannot convert object of type ' + util.getType(object) +
' to type Date');
}
case 'ISODate':
if (util.isNumber(object)) {
return new Date(object);
}
else if (object instanceof Date) {
return object.toISOString();
}
else if (moment.isMoment(object)) {
return object.toDate().toISOString();
}
else if (util.isString(object)) {
match = ASPDateRegex.exec(object);
if (match) {
// object is an ASP date
return new Date(Number(match[1])).toISOString(); // parse number
}
else {
return new Date(object).toISOString(); // parse string
}
}
else {
throw new Error(
'Cannot convert object of type ' + util.getType(object) +
' to type ISODate');
}
case 'ASPDate':
if (util.isNumber(object)) {
return '/Date(' + object + ')/';
}
else if (object instanceof Date) {
return '/Date(' + object.valueOf() + ')/';
}
else if (util.isString(object)) {
match = ASPDateRegex.exec(object);
var value;
if (match) {
// object is an ASP date
value = new Date(Number(match[1])).valueOf(); // parse number
}
else {
value = new Date(object).valueOf(); // parse string
}
return '/Date(' + value + ')/';
}
else {
throw new Error(
'Cannot convert object of type ' + util.getType(object) +
' to type ASPDate');
}
default:
throw new Error('Cannot convert object of type ' + util.getType(object) +
' to type "' + type + '"');
}
};
// parse ASP.Net Date pattern,
// for example '/Date(1198908717056)/' or '/Date(1198908717056-0700)/'
// code from http://momentjs.com/
var ASPDateRegex = /^\/?Date\((\-?\d+)/i;
/**
* Get the type of an object, for example util.getType([]) returns 'Array'
* @param {*} object
* @return {String} type
*/
util.getType = function getType(object) {
var type = typeof object;
if (type == 'object') {
if (object == null) {
return 'null';
}
if (object instanceof Boolean) {
return 'Boolean';
}
if (object instanceof Number) {
return 'Number';
}
if (object instanceof String) {
return 'String';
}
if (object instanceof Array) {
return 'Array';
}
if (object instanceof Date) {
return 'Date';
}
return 'Object';
}
else if (type == 'number') {
return 'Number';
}
else if (type == 'boolean') {
return 'Boolean';
}
else if (type == 'string') {
return 'String';
}
return type;
};
/**
* Retrieve the absolute left value of a DOM element
* @param {Element} elem A dom element, for example a div
* @return {number} left The absolute left position of this element
* in the browser page.
*/
util.getAbsoluteLeft = function getAbsoluteLeft (elem) {
var doc = document.documentElement;
var body = document.body;
var left = elem.offsetLeft;
var e = elem.offsetParent;
while (e != null && e != body && e != doc) {
left += e.offsetLeft;
left -= e.scrollLeft;
e = e.offsetParent;
}
return left;
};
/**
* Retrieve the absolute top value of a DOM element
* @param {Element} elem A dom element, for example a div
* @return {number} top The absolute top position of this element
* in the browser page.
*/
util.getAbsoluteTop = function getAbsoluteTop (elem) {
var doc = document.documentElement;
var body = document.body;
var top = elem.offsetTop;
var e = elem.offsetParent;
while (e != null && e != body && e != doc) {
top += e.offsetTop;
top -= e.scrollTop;
e = e.offsetParent;
}
return top;
};
/**
* Get the absolute, vertical mouse position from an event.
* @param {Event} event
* @return {Number} pageY
*/
util.getPageY = function getPageY (event) {
if ('pageY' in event) {
return event.pageY;
}
else {
var clientY;
if (('targetTouches' in event) && event.targetTouches.length) {
clientY = event.targetTouches[0].clientY;
}
else {
clientY = event.clientY;
}
var doc = document.documentElement;
var body = document.body;
return clientY +
( doc && doc.scrollTop || body && body.scrollTop || 0 ) -
( doc && doc.clientTop || body && body.clientTop || 0 );
}
};
/**
* Get the absolute, horizontal mouse position from an event.
* @param {Event} event
* @return {Number} pageX
*/
util.getPageX = function getPageX (event) {
if ('pageY' in event) {
return event.pageX;
}
else {
var clientX;
if (('targetTouches' in event) && event.targetTouches.length) {
clientX = event.targetTouches[0].clientX;
}
else {
clientX = event.clientX;
}
var doc = document.documentElement;
var body = document.body;
return clientX +
( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) -
( doc && doc.clientLeft || body && body.clientLeft || 0 );
}
};
/**
* add a className to the given elements style
* @param {Element} elem
* @param {String} className
*/
util.addClassName = function addClassName(elem, className) {
var classes = elem.className.split(' ');
if (classes.indexOf(className) == -1) {
classes.push(className); // add the class to the array
elem.className = classes.join(' ');
}
};
/**
* add a className to the given elements style
* @param {Element} elem
* @param {String} className
*/
util.removeClassName = function removeClassname(elem, className) {
var classes = elem.className.split(' ');
var index = classes.indexOf(className);
if (index != -1) {
classes.splice(index, 1); // remove the class from the array
elem.className = classes.join(' ');
}
};
/**
* For each method for both arrays and objects.
* In case of an array, the built-in Array.forEach() is applied.
* In case of an Object, the method loops over all properties of the object.
* @param {Object | Array} object An Object or Array
* @param {function} callback Callback method, called for each item in
* the object or array with three parameters:
* callback(value, index, object)
*/
util.forEach = function forEach (object, callback) {
var i,
len;
if (object instanceof Array) {
// array
for (i = 0, len = object.length; i < len; i++) {
callback(object[i], i, object);
}
}
else {
// object
for (i in object) {
if (object.hasOwnProperty(i)) {
callback(object[i], i, object);
}
}
}
};
/**
* Update a property in an object
* @param {Object} object
* @param {String} key
* @param {*} value
* @return {Boolean} changed
*/
util.updateProperty = function updateProp (object, key, value) {
if (object[key] !== value) {
object[key] = value;
return true;
}
else {
return false;
}
};
/**
* Add and event listener. Works for all browsers
* @param {Element} element An html element
* @param {string} action The action, for example "click",
* without the prefix "on"
* @param {function} listener The callback function to be executed
* @param {boolean} [useCapture]
*/
util.addEventListener = function addEventListener(element, action, listener, useCapture) {
if (element.addEventListener) {
if (useCapture === undefined)
useCapture = false;
if (action === "mousewheel" && navigator.userAgent.indexOf("Firefox") >= 0) {
action = "DOMMouseScroll"; // For Firefox
}
element.addEventListener(action, listener, useCapture);
} else {
element.attachEvent("on" + action, listener); // IE browsers
}
};
/**
* Remove an event listener from an element
* @param {Element} element An html dom element
* @param {string} action The name of the event, for example "mousedown"
* @param {function} listener The listener function
* @param {boolean} [useCapture]
*/
util.removeEventListener = function removeEventListener(element, action, listener, useCapture) {
if (element.removeEventListener) {
// non-IE browsers
if (useCapture === undefined)
useCapture = false;
if (action === "mousewheel" && navigator.userAgent.indexOf("Firefox") >= 0) {
action = "DOMMouseScroll"; // For Firefox
}
element.removeEventListener(action, listener, useCapture);
} else {
// IE browsers
element.detachEvent("on" + action, listener);
}
};
/**
* Get HTML element which is the target of the event
* @param {Event} event
* @return {Element} target element
*/
util.getTarget = function getTarget(event) {
// code from http://www.quirksmode.org/js/events_properties.html
if (!event) {
event = window.event;
}
var target;
if (event.target) {
target = event.target;
}
else if (event.srcElement) {
target = event.srcElement;
}
if (target.nodeType != undefined && target.nodeType == 3) {
// defeat Safari bug
target = target.parentNode;
}
return target;
};
/**
* Stop event propagation
*/
util.stopPropagation = function stopPropagation(event) {
if (!event)
event = window.event;
if (event.stopPropagation) {
event.stopPropagation(); // non-IE browsers
}
else {
event.cancelBubble = true; // IE browsers
}
};
/**
* Fake a hammer.js gesture. Event can be a ScrollEvent or MouseMoveEvent
* @param {Element} element
* @param {Event} event
*/
util.fakeGesture = function fakeGesture (element, event) {
var eventType = null;
// for hammer.js 1.0.5
return Hammer.event.collectEventData(this, eventType, event);
// for hammer.js 1.0.6
//var touches = Hammer.event.getTouchList(event, eventType);
//return Hammer.event.collectEventData(this, eventType, touches, event);
};
/**
* Cancels the event if it is cancelable, without stopping further propagation of the event.
*/
util.preventDefault = function preventDefault (event) {
if (!event)
event = window.event;
if (event.preventDefault) {
event.preventDefault(); // non-IE browsers
}
else {
event.returnValue = false; // IE browsers
}
};
util.option = {};
/**
* Convert a value into a boolean
* @param {Boolean | function | undefined} value
* @param {Boolean} [defaultValue]
* @returns {Boolean} bool
*/
util.option.asBoolean = function (value, defaultValue) {
if (typeof value == 'function') {
value = value();
}
if (value != null) {
return (value != false);
}
return defaultValue || null;
};
/**
* Convert a value into a number
* @param {Boolean | function | undefined} value
* @param {Number} [defaultValue]
* @returns {Number} number
*/
util.option.asNumber = function (value, defaultValue) {
if (typeof value == 'function') {
value = value();
}
if (value != null) {
return Number(value) || defaultValue || null;
}
return defaultValue || null;
};
/**
* Convert a value into a string
* @param {String | function | undefined} value
* @param {String} [defaultValue]
* @returns {String} str
*/
util.option.asString = function (value, defaultValue) {
if (typeof value == 'function') {
value = value();
}
if (value != null) {
return String(value);
}
return defaultValue || null;
};
/**
* Convert a size or location into a string with pixels or a percentage
* @param {String | Number | function | undefined} value
* @param {String} [defaultValue]
* @returns {String} size
*/
util.option.asSize = function (value, defaultValue) {
if (typeof value == 'function') {
value = value();
}
if (util.isString(value)) {
return value;
}
else if (util.isNumber(value)) {
return value + 'px';
}
else {
return defaultValue || null;
}
};
/**
* Convert a value into a DOM element
* @param {HTMLElement | function | undefined} value
* @param {HTMLElement} [defaultValue]
* @returns {HTMLElement | null} dom
*/
util.option.asElement = function (value, defaultValue) {
if (typeof value == 'function') {
value = value();
}
return value || defaultValue || null;
};
/**
* Event listener (singleton)
*/
// TODO: replace usage of the event listener for the EventBus
var events = {
'listeners': [],
/**
* Find a single listener by its object
* @param {Object} object
* @return {Number} index -1 when not found
*/
'indexOf': function (object) {
var listeners = this.listeners;
for (var i = 0, iMax = this.listeners.length; i < iMax; i++) {
var listener = listeners[i];
if (listener && listener.object == object) {
return i;
}
}
return -1;
},
/**
* Add an event listener
* @param {Object} object
* @param {String} event The name of an event, for example 'select'
* @param {function} callback The callback method, called when the
* event takes place
*/
'addListener': function (object, event, callback) {
var index = this.indexOf(object);
var listener = this.listeners[index];
if (!listener) {
listener = {
'object': object,
'events': {}
};
this.listeners.push(listener);
}
var callbacks = listener.events[event];
if (!callbacks) {
callbacks = [];
listener.events[event] = callbacks;
}
// add the callback if it does not yet exist
if (callbacks.indexOf(callback) == -1) {
callbacks.push(callback);
}
},
/**
* Remove an event listener
* @param {Object} object
* @param {String} event The name of an event, for example 'select'
* @param {function} callback The registered callback method
*/
'removeListener': function (object, event, callback) {
var index = this.indexOf(object);
var listener = this.listeners[index];
if (listener) {
var callbacks = listener.events[event];
if (callbacks) {
index = callbacks.indexOf(callback);
if (index != -1) {
callbacks.splice(index, 1);
}
// remove the array when empty
if (callbacks.length == 0) {
delete listener.events[event];
}
}
// count the number of registered events. remove listener when empty
var count = 0;
var events = listener.events;
for (var e in events) {
if (events.hasOwnProperty(e)) {
count++;
}
}
if (count == 0) {
delete this.listeners[index];
}
}
},
/**
* Remove all registered event listeners
*/
'removeAllListeners': function () {
this.listeners = [];
},
/**
* Trigger an event. All registered event handlers will be called
* @param {Object} object
* @param {String} event
* @param {Object} properties (optional)
*/
'trigger': function (object, event, properties) {
var index = this.indexOf(object);
var listener = this.listeners[index];
if (listener) {
var callbacks = listener.events[event];
if (callbacks) {
for (var i = 0, iMax = callbacks.length; i < iMax; i++) {
callbacks[i](properties);
}
}
}
}
};
/**
* An event bus can be used to emit events, and to subscribe to events
* @constructor EventBus
*/
function EventBus() {
this.subscriptions = [];
}
/**
* Subscribe to an event
* @param {String | RegExp} event The event can be a regular expression, or
* a string with wildcards, like 'server.*'.
* @param {function} callback. Callback are called with three parameters:
* {String} event, {*} [data], {*} [source]
* @param {*} [target]
* @returns {String} id A subscription id
*/
EventBus.prototype.on = function (event, callback, target) {
var regexp = (event instanceof RegExp) ?
event :
new RegExp(event.replace('*', '\\w+'));
var subscription = {
id: util.randomUUID(),
event: event,
regexp: regexp,
callback: (typeof callback === 'function') ? callback : null,
target: target
};
this.subscriptions.push(subscription);
return subscription.id;
};
/**
* Unsubscribe from an event
* @param {String | Object} filter Filter for subscriptions to be removed
* Filter can be a string containing a
* subscription id, or an object containing
* one or more of the fields id, event,
* callback, and target.
*/
EventBus.prototype.off = function (filter) {
var i = 0;
while (i < this.subscriptions.length) {
var subscription = this.subscriptions[i];
var match = true;
if (filter instanceof Object) {
// filter is an object. All fields must match
for (var prop in filter) {
if (filter.hasOwnProperty(prop)) {
if (filter[prop] !== subscription[prop]) {
match = false;
}
}
}
}
else {
// filter is a string, filter on id
match = (subscription.id == filter);
}
if (match) {
this.subscriptions.splice(i, 1);
}
else {
i++;
}
}
};
/**
* Emit an event
* @param {String} event
* @param {*} [data]
* @param {*} [source]
*/
EventBus.prototype.emit = function (event, data, source) {
for (var i =0; i < this.subscriptions.length; i++) {
var subscription = this.subscriptions[i];
if (subscription.regexp.test(event)) {
if (subscription.callback) {
subscription.callback(event, data, source);
}
}
}
};
/**
* DataSet
*
* Usage:
* var dataSet = new DataSet({
* fieldId: '_id',
* convert: {
* // ...
* }
* });
*
* dataSet.add(item);
* dataSet.add(data);
* dataSet.update(item);
* dataSet.update(data);
* dataSet.remove(id);
* dataSet.remove(ids);
* var data = dataSet.get();
* var data = dataSet.get(id);
* var data = dataSet.get(ids);
* var data = dataSet.get(ids, options, data);
* dataSet.clear();
*
* A data set can:
* - add/remove/update data
* - gives triggers upon changes in the data
* - can import/export data in various data formats
*
* @param {Object} [options] Available options:
* {String} fieldId Field name of the id in the
* items, 'id' by default.
* {Object.<String, String} convert
* A map with field names as key,
* and the field type as value.
* @constructor DataSet
*/
// TODO: add a DataSet constructor DataSet(data, options)
function DataSet (options) {
this.id = util.randomUUID();
this.options = options || {};
this.data = {}; // map with data indexed by id
this.fieldId = this.options.fieldId || 'id'; // name of the field containing id
this.convert = {}; // field types by field name
this.showInternalIds = this.options.showInternalIds || false; // show internal ids with the get function
if (this.options.convert) {
for (var field in this.options.convert) {
if (this.options.convert.hasOwnProperty(field)) {
var value = this.options.convert[field];
if (value == 'Date' || value == 'ISODate' || value == 'ASPDate') {
this.convert[field] = 'Date';
}
else {
this.convert[field] = value;
}
}
}
}
// event subscribers
this.subscribers = {};
this.internalIds = {}; // internally generated id's
}
/**
* Subscribe to an event, add an event listener
* @param {String} event Event name. Available events: 'put', 'update',
* 'remove'
* @param {function} callback Callback method. Called with three parameters:
* {String} event
* {Object | null} params
* {String | Number} senderId
*/
DataSet.prototype.subscribe = function (event, callback) {
var subscribers = this.subscribers[event];
if (!subscribers) {
subscribers = [];
this.subscribers[event] = subscribers;
}
subscribers.push({
callback: callback
});
};
/**
* Unsubscribe from an event, remove an event listener
* @param {String} event
* @param {function} callback
*/
DataSet.prototype.unsubscribe = function (event, callback) {
var subscribers = this.subscribers[event];
if (subscribers) {
this.subscribers[event] = subscribers.filter(function (listener) {
return (listener.callback != callback);
});
}
};
/**
* Trigger an event
* @param {String} event
* @param {Object | null} params
* @param {String} [senderId] Optional id of the sender.
* @private
*/
DataSet.prototype._trigger = function (event, params, senderId) {
if (event == '*') {
throw new Error('Cannot trigger event *');
}
var subscribers = [];
if (event in this.subscribers) {
subscribers = subscribers.concat(this.subscribers[event]);
}
if ('*' in this.subscribers) {
subscribers = subscribers.concat(this.subscribers['*']);
}
for (var i = 0; i < subscribers.length; i++) {
var subscriber = subscribers[i];
if (subscriber.callback) {
subscriber.callback(event, params, senderId || null);
}
}
};
/**
* Add data.
* Adding an item will fail when there already is an item with the same id.
* @param {Object | Array | DataTable} data
* @param {String} [senderId] Optional sender id
* @return {Array} addedIds Array with the ids of the added items
*/
DataSet.prototype.add = function (data, senderId) {
var addedIds = [],
id,
me = this;
if (data instanceof Array) {
// Array
for (var i = 0, len = data.length; i < len; i++) {
id = me._addItem(data[i]);
addedIds.push(id);
}
}
else if (util.isDataTable(data)) {
// Google DataTable
var columns = this._getColumnNames(data);
for (var row = 0, rows = data.getNumberOfRows(); row < rows; row++) {
var item = {};
for (var col = 0, cols = columns.length; col < cols; col++) {
var field = columns[col];
item[field] = data.getValue(row, col);
}
id = me._addItem(item);
addedIds.push(id);
}
}
else if (data instanceof Object) {
// Single item
id = me._addItem(data);
addedIds.push(id);
}
else {
throw new Error('Unknown dataType');
}
if (addedIds.length) {
this._trigger('add', {items: addedIds}, senderId);
}
return addedIds;
};
/**
* Update existing items. When an item does not exist, it will be created
* @param {Object | Array | DataTable} data
* @param {String} [senderId] Optional sender id
* @return {Array} updatedIds The ids of the added or updated items
*/
DataSet.prototype.update = function (data, senderId) {
var addedIds = [],
updatedIds = [],
me = this,
fieldId = me.fieldId;
var addOrUpdate = function (item) {
var id = item[fieldId];
if (me.data[id]) {
// update item
id = me._updateItem(item);
updatedIds.push(id);
}
else {
// add new item
id = me._addItem(item);
addedIds.push(id);
}
};
if (data instanceof Array) {
// Array
for (var i = 0, len = data.length; i < len; i++) {
addOrUpdate(data[i]);
}
}
else if (util.isDataTable(data)) {
// Google DataTable
var columns = this._getColumnNames(data);
for (var row = 0, rows = data.getNumberOfRows(); row < rows; row++) {
var item = {};
for (var col = 0, cols = columns.length; col < cols; col++) {
var field = columns[col];
item[field] = data.getValue(row, col);
}
addOrUpdate(item);
}
}
else if (data instanceof Object) {
// Single item
addOrUpdate(data);
}
else {
throw new Error('Unknown dataType');
}
if (addedIds.length) {
this._trigger('add', {items: addedIds}, senderId);
}
if (updatedIds.length) {
this._trigger('update', {items: updatedIds}, senderId);
}
return addedIds.concat(updatedIds);
};
/**
* Get a data item or multiple items.
*
* Usage:
*
* get()
* get(options: Object)
* get(options: Object, data: Array | DataTable)
*
* get(id: Number | String)
* get(id: Number | String, options: Object)
* get(id: Number | String, options: Object, data: Array | DataTable)
*
* get(ids: Number[] | String[])
* get(ids: Number[] | String[], options: Object)
* get(ids: Number[] | String[], options: Object, data: Array | DataTable)
*
* Where:
*
* {Number | String} id The id of an item
* {Number[] | String{}} ids An array with ids of items
* {Object} options An Object with options. Available options:
* {String} [type] Type of data to be returned. Can
* be 'DataTable' or 'Array' (default)
* {Object.<String, String>} [convert]
* {String[]} [fields] field names to be returned
* {function} [filter] filter items
* {String | function} [order] Order the items by
* a field name or custom sort function.
* {Array | DataTable} [data] If provided, items will be appended to this
* array or table. Required in case of Google
* DataTable.
*
* @throws Error
*/
DataSet.prototype.get = function (args) {
var me = this;
var globalShowInternalIds = this.showInternalIds;
// parse the arguments
var id, ids, options, data;
var firstType = util.getType(arguments[0]);
if (firstType == 'String' || firstType == 'Number') {
// get(id [, options] [, data])
id = arguments[0];
options = arguments[1];
data = arguments[2];
}
else if (firstType == 'Array') {
// get(ids [, options] [, data])
ids = arguments[0];
options = arguments[1];
data = arguments[2];
}
else {
// get([, options] [, data])
options = arguments[0];
data = arguments[1];
}
// determine the return type
var type;
if (options && options.type) {
type = (options.type == 'DataTable') ? 'DataTable' : 'Array';
if (data && (type != util.getType(data))) {
throw new Error('Type of parameter "data" (' + util.getType(data) + ') ' +
'does not correspond with specified options.type (' + options.type + ')');
}
if (type == 'DataTable' && !util.isDataTable(data)) {
throw new Error('Parameter "data" must be a DataTable ' +
'when options.type is "DataTable"');
}
}
else if (data) {
type = (util.getType(data) == 'DataTable') ? 'DataTable' : 'Array';
}
else {
type = 'Array';
}
// we allow the setting of this value for a single get request.
if (options != undefined) {
if (options.showInternalIds != undefined) {
this.showInternalIds = options.showInternalIds;
}
}
// build options
var convert = options && options.convert || this.options.convert;
var filter = options && options.filter;
var items = [], item, itemId, i, len;
// convert items
if (id != undefined) {
// return a single item
item = me._getItem(id, convert);
if (filter && !filter(item)) {
item = null;
}
}
else if (ids != undefined) {
// return a subset of items
for (i = 0, len = ids.length; i < len; i++) {
item = me._getItem(ids[i], convert);
if (!filter || filter(item)) {
items.push(item);
}
}
}
else {
// return all items
for (itemId in this.data) {
if (this.data.hasOwnProperty(itemId)) {
item = me._getItem(itemId, convert);
if (!filter || filter(item)) {
items.push(item);
}
}
}
}
// restore the global value of showInternalIds
this.showInternalIds = globalShowInternalIds;
// order the results
if (options && options.order && id == undefined) {
this._sort(items, options.order);
}
// filter fields of the items
if (options && options.fields) {
var fields = options.fields;
if (id != undefined) {
item = this._filterFields(item, fields);
}
else {
for (i = 0, len = items.length; i < len; i++) {
items[i] = this._filterFields(items[i], fields);
}
}
}
// return the results
if (type == 'DataTable') {
var columns = this._getColumnNames(data);
if (id != undefined) {
// append a single item to the data table
me._appendRow(data, columns, item);
}
else {
// copy the items to the provided data table
for (i = 0, len = items.length; i < len; i++) {
me._appendRow(data, columns, items[i]);
}
}
return data;
}
else {
// return an array
if (id != undefined) {
// a single item
return item;
}
else {
// multiple items
if (data) {
// copy the items to the provided array
for (i = 0, len = items.length; i < len; i++) {
data.push(items[i]);
}
return data;
}
else {
// just return our array
return items;
}
}
}
};
/**
* Get ids of all items or from a filtered set of items.
* @param {Object} [options] An Object with options. Available options:
* {function} [filter] filter items
* {String | function} [order] Order the items by
* a field name or custom sort function.
* @return {Array} ids
*/
DataSet.prototype.getIds = function (options) {
var data = this.data,
filter = options && options.filter,
order = options && options.order,
convert = options && options.convert || this.options.convert,
i,
len,
id,
item,
items,
ids = [];
if (filter) {
// get filtered items
if (order) {
// create ordered list
items = [];
for (id in data) {
if (data.hasOwnProperty(id)) {
item = this._getItem(id, convert);
if (filter(item)) {
items.push(item);
}
}
}
this._sort(items, order);
for (i = 0, len = items.length; i < len; i++) {
ids[i] = items[i][this.fieldId];
}
}
else {
// create unordered list
for (id in data) {
if (data.hasOwnProperty(id)) {
item = this._getItem(id, convert);
if (filter(item)) {
ids.push(item[this.fieldId]);
}
}
}
}
}
else {
// get all items
if (order) {
// create an ordered list
items = [];
for (id in data) {
if (data.hasOwnProperty(id)) {
items.push(data[id]);
}
}
this._sort(items, order);
for (i = 0, len = items.length; i < len; i++) {
ids[i] = items[i][this.fieldId];
}
}
else {
// create unordered list
for (id in data) {
if (data.hasOwnProperty(id)) {
item = data[id];
ids.push(item[this.fieldId]);
}
}
}
}
return ids;
};
/**
* Execute a callback function for every item in the dataset.
* The order of the items is not determined.
* @param {function} callback
* @param {Object} [options] Available options:
* {Object.<String, String>} [convert]
* {String[]} [fields] filter fields
* {function} [filter] filter items
* {String | function} [order] Order the items by
* a field name or custom sort function.
*/
DataSet.prototype.forEach = function (callback, options) {
var filter = options && options.filter,
convert = options && options.convert || this.options.convert,
data = this.data,
item,
id;
if (options && options.order) {
// execute forEach on ordered list
var items = this.get(options);
for (var i = 0, len = items.length; i < len; i++) {
item = items[i];
id = item[this.fieldId];
callback(item, id);
}
}
else {
// unordered
for (id in data) {
if (data.hasOwnProperty(id)) {
item = this._getItem(id, convert);
if (!filter || filter(item)) {
callback(item, id);
}
}
}
}
};
/**
* Map every item in the dataset.
* @param {function} callback
* @param {Object} [options] Available options:
* {Object.<String, String>} [convert]
* {String[]} [fields] filter fields
* {function} [filter] filter items
* {String | function} [order] Order the items by
* a field name or custom sort function.
* @return {Object[]} mappedItems
*/
DataSet.prototype.map = function (callback, options) {
var filter = options && options.filter,
convert = options && options.convert || this.options.convert,
mappedItems = [],
data = this.data,
item;
// convert and filter items
for (var id in data) {
if (data.hasOwnProperty(id)) {
item = this._getItem(id, convert);
if (!filter || filter(item)) {
mappedItems.push(callback(item, id));
}
}
}
// order items
if (options && options.order) {
this._sort(mappedItems, options.order);
}
return mappedItems;
};
/**
* Filter the fields of an item
* @param {Object} item
* @param {String[]} fields Field names
* @return {Object} filteredItem
* @private
*/
DataSet.prototype._filterFields = function (item, fields) {
var filteredItem = {};
for (var field in item) {
if (item.hasOwnProperty(field) && (fields.indexOf(field) != -1)) {
filteredItem[field] = item[field];
}
}
return filteredItem;
};
/**
* Sort the provided array with items
* @param {Object[]} items
* @param {String | function} order A field name or custom sort function.
* @private
*/
DataSet.prototype._sort = function (items, order) {
if (util.isString(order)) {
// order by provided field name
var name = order; // field name
items.sort(function (a, b) {
var av = a[name];
var bv = b[name];
return (av > bv) ? 1 : ((av < bv) ? -1 : 0);
});
}
else if (typeof order === 'function') {
// order by sort function
items.sort(order);
}
// TODO: extend order by an Object {field:String, direction:String}
// where direction can be 'asc' or 'desc'
else {
throw new TypeError('Order must be a function or a string');
}
};
/**
* Remove an object by pointer or by id
* @param {String | Number | Object | Array} id Object or id, or an array with
* objects or ids to be removed
* @param {String} [senderId] Optional sender id
* @return {Array} removedIds
*/
DataSet.prototype.remove = function (id, senderId) {
var removedIds = [],
i, len, removedId;
if (id instanceof Array) {
for (i = 0, len = id.length; i < len; i++) {
removedId = this._remove(id[i]);
if (removedId != null) {
removedIds.push(removedId);
}
}
}
else {
removedId = this._remove(id);
if (removedId != null) {
removedIds.push(removedId);
}
}
if (removedIds.length) {
this._trigger('remove', {items: removedIds}, senderId);
}
return removedIds;
};
/**
* Remove an item by its id
* @param {Number | String | Object} id id or item
* @returns {Number | String | null} id
* @private
*/
DataSet.prototype._remove = function (id) {
if (util.isNumber(id) || util.isString(id)) {
if (this.data[id]) {
delete this.data[id];
delete this.internalIds[id];
return id;
}
}
else if (id instanceof Object) {
var itemId = id[this.fieldId];
if (itemId && this.data[itemId]) {
delete this.data[itemId];
delete this.internalIds[itemId];
return itemId;
}
}
return null;
};
/**
* Clear the data
* @param {String} [senderId] Optional sender id
* @return {Array} removedIds The ids of all removed items
*/
DataSet.prototype.clear = function (senderId) {
var ids = Object.keys(this.data);
this.data = {};
this.internalIds = {};
this._trigger('remove', {items: ids}, senderId);
return ids;
};
/**
* Find the item with maximum value of a specified field
* @param {String} field
* @return {Object | null} item Item containing max value, or null if no items
*/
DataSet.prototype.max = function (field) {
var data = this.data,
max = null,
maxField = null;
for (var id in data) {
if (data.hasOwnProperty(id)) {
var item = data[id];
var itemField = item[field];
if (itemField != null && (!max || itemField > maxField)) {
max = item;
maxField = itemField;
}
}
}
return max;
};
/**
* Find the item with minimum value of a specified field
* @param {String} field
* @return {Object | null} item Item containing max value, or null if no items
*/
DataSet.prototype.min = function (field) {
var data = this.data,
min = null,
minField = null;
for (var id in data) {
if (data.hasOwnProperty(id)) {
var item = data[id];
var itemField = item[field];
if (itemField != null && (!min || itemField < minField)) {
min = item;
minField = itemField;
}
}
}
return min;
};
/**
* Find all distinct values of a specified field
* @param {String} field
* @return {Array} values Array containing all distinct values. If the data
* items do not contain the specified field, an array
* containing a single value undefined is returned.
* The returned array is unordered.
*/
DataSet.prototype.distinct = function (field) {
var data = this.data,
values = [],
fieldType = this.options.convert[field],
count = 0;
for (var prop in data) {
if (data.hasOwnProperty(prop)) {
var item = data[prop];
var value = util.convert(item[field], fieldType);
var exists = false;
for (var i = 0; i < count; i++) {
if (values[i] == value) {
exists = true;
break;
}
}
if (!exists) {
values[count] = value;
count++;
}
}
}
return values;
};
/**
* Add a single item. Will fail when an item with the same id already exists.
* @param {Object} item
* @return {String} id
* @private
*/
DataSet.prototype._addItem = function (item) {
var id = item[this.fieldId];
if (id != undefined) {
// check whether this id is already taken
if (this.data[id]) {
// item already exists
throw new Error('Cannot add item: item with id ' + id + ' already exists');
}
}
else {
// generate an id
id = util.randomUUID();
item[this.fieldId] = id;
this.internalIds[id] = item;
}
var d = {};
for (var field in item) {
if (item.hasOwnProperty(field)) {
var fieldType = this.convert[field]; // type may be undefined
d[field] = util.convert(item[field], fieldType);
}
}
this.data[id] = d;
return id;
};
/**
* Get an item. Fields can be converted to a specific type
* @param {String} id
* @param {Object.<String, String>} [convert] field types to convert
* @return {Object | null} item
* @private
*/
DataSet.prototype._getItem = function (id, convert) {
var field, value;
// get the item from the dataset
var raw = this.data[id];
if (!raw) {
return null;
}
// convert the items field types
var converted = {},
fieldId = this.fieldId,
internalIds = this.internalIds;
if (convert) {
for (field in raw) {
if (raw.hasOwnProperty(field)) {
value = raw[field];
// output all fields, except internal ids
if ((field != fieldId) || (!(value in internalIds) || this.showInternalIds)) {
converted[field] = util.convert(value, convert[field]);
}
}
}
}
else {
// no field types specified, no converting needed
for (field in raw) {
if (raw.hasOwnProperty(field)) {
value = raw[field];
// output all fields, except internal ids
if ((field != fieldId) || (!(value in internalIds) || this.showInternalIds)) {
converted[field] = value;
}
}
}
}
return converted;
};
/**
* Update a single item: merge with existing item.
* Will fail when the item has no id, or when there does not exist an item
* with the same id.
* @param {Object} item
* @return {String} id
* @private
*/
DataSet.prototype._updateItem = function (item) {
var id = item[this.fieldId];
if (id == undefined) {
throw new Error('Cannot update item: item has no id (item: ' + JSON.stringify(item) + ')');
}
var d = this.data[id];
if (!d) {
// item doesn't exist
throw new Error('Cannot update item: no item with id ' + id + ' found');
}
// merge with current item
for (var field in item) {
if (item.hasOwnProperty(field)) {
var fieldType = this.convert[field]; // type may be undefined
d[field] = util.convert(item[field], fieldType);
}
}
return id;
};
/**
* check if an id is an internal or external id
* @param id
* @returns {boolean}
* @private
*/
DataSet.prototype.isInternalId = function(id) {
return (id in this.internalIds);
};
/**
* Get an array with the column names of a Google DataTable
* @param {DataTable} dataTable
* @return {String[]} columnNames
* @private
*/
DataSet.prototype._getColumnNames = function (dataTable) {
var columns = [];
for (var col = 0, cols = dataTable.getNumberOfColumns(); col < cols; col++) {
columns[col] = dataTable.getColumnId(col) || dataTable.getColumnLabel(col);
}
return columns;
};
/**
* Append an item as a row to the dataTable
* @param dataTable
* @param columns
* @param item
* @private
*/
DataSet.prototype._appendRow = function (dataTable, columns, item) {
var row = dataTable.addRow();
for (var col = 0, cols = columns.length; col < cols; col++) {
var field = columns[col];
dataTable.setValue(row, col, item[field]);
}
};
/**
* DataView
*
* a dataview offers a filtered view on a dataset or an other dataview.
*
* @param {DataSet | DataView} data
* @param {Object} [options] Available options: see method get
*
* @constructor DataView
*/
function DataView (data, options) {
this.id = util.randomUUID();
this.data = null;
this.ids = {}; // ids of the items currently in memory (just contains a boolean true)
this.options = options || {};
this.fieldId = 'id'; // name of the field containing id
this.subscribers = {}; // event subscribers
var me = this;
this.listener = function () {
me._onEvent.apply(me, arguments);
};
this.setData(data);
}
// TODO: implement a function .config() to dynamically update things like configured filter
// and trigger changes accordingly
/**
* Set a data source for the view
* @param {DataSet | DataView} data
*/
DataView.prototype.setData = function (data) {
var ids, dataItems, i, len;
if (this.data) {
// unsubscribe from current dataset
if (this.data.unsubscribe) {
this.data.unsubscribe('*', this.listener);
}
// trigger a remove of all items in memory
ids = [];
for (var id in this.ids) {
if (this.ids.hasOwnProperty(id)) {
ids.push(id);
}
}
this.ids = {};
this._trigger('remove', {items: ids});
}
this.data = data;
if (this.data) {
// update fieldId
this.fieldId = this.options.fieldId ||
(this.data && this.data.options && this.data.options.fieldId) ||
'id';
// trigger an add of all added items
ids = this.data.getIds({filter: this.options && this.options.filter});
for (i = 0, len = ids.length; i < len; i++) {
id = ids[i];
this.ids[id] = true;
}
this._trigger('add', {items: ids});
// subscribe to new dataset
if (this.data.subscribe) {
this.data.subscribe('*', this.listener);
}
}
};
/**
* Get data from the data view
*
* Usage:
*
* get()
* get(options: Object)
* get(options: Object, data: Array | DataTable)
*
* get(id: Number)
* get(id: Number, options: Object)
* get(id: Number, options: Object, data: Array | DataTable)
*
* get(ids: Number[])
* get(ids: Number[], options: Object)
* get(ids: Number[], options: Object, data: Array | DataTable)
*
* Where:
*
* {Number | String} id The id of an item
* {Number[] | String{}} ids An array with ids of items
* {Object} options An Object with options. Available options:
* {String} [type] Type of data to be returned. Can
* be 'DataTable' or 'Array' (default)
* {Object.<String, String>} [convert]
* {String[]} [fields] field names to be returned
* {function} [filter] filter items
* {String | function} [order] Order the items by
* a field name or custom sort function.
* {Array | DataTable} [data] If provided, items will be appended to this
* array or table. Required in case of Google
* DataTable.
* @param args
*/
DataView.prototype.get = function (args) {
var me = this;
// parse the arguments
var ids, options, data;
var firstType = util.getType(arguments[0]);
if (firstType == 'String' || firstType == 'Number' || firstType == 'Array') {
// get(id(s) [, options] [, data])
ids = arguments[0]; // can be a single id or an array with ids
options = arguments[1];
data = arguments[2];
}
else {
// get([, options] [, data])
options = arguments[0];
data = arguments[1];
}
// extend the options with the default options and provided options
var viewOptions = util.extend({}, this.options, options);
// create a combined filter method when needed
if (this.options.filter && options && options.filter) {
viewOptions.filter = function (item) {
return me.options.filter(item) && options.filter(item);
}
}
// build up the call to the linked data set
var getArguments = [];
if (ids != undefined) {
getArguments.push(ids);
}
getArguments.push(viewOptions);
getArguments.push(data);
return this.data && this.data.get.apply(this.data, getArguments);
};
/**
* Get ids of all items or from a filtered set of items.
* @param {Object} [options] An Object with options. Available options:
* {function} [filter] filter items
* {String | function} [order] Order the items by
* a field name or custom sort function.
* @return {Array} ids
*/
DataView.prototype.getIds = function (options) {
var ids;
if (this.data) {
var defaultFilter = this.options.filter;
var filter;
if (options && options.filter) {
if (defaultFilter) {
filter = function (item) {
return defaultFilter(item) && options.filter(item);
}
}
else {
filter = options.filter;
}
}
else {
filter = defaultFilter;
}
ids = this.data.getIds({
filter: filter,
order: options && options.order
});
}
else {
ids = [];
}
return ids;
};
/**
* Event listener. Will propagate all events from the connected data set to
* the subscribers of the DataView, but will filter the items and only trigger
* when there are changes in the filtered data set.
* @param {String} event
* @param {Object | null} params
* @param {String} senderId
* @private
*/
DataView.prototype._onEvent = function (event, params, senderId) {
var i, len, id, item,
ids = params && params.items,
data = this.data,
added = [],
updated = [],
removed = [];
if (ids && data) {
switch (event) {
case 'add':
// filter the ids of the added items
for (i = 0, len = ids.length; i < len; i++) {
id = ids[i];
item = this.get(id);
if (item) {
this.ids[id] = true;
added.push(id);
}
}
break;
case 'update':
// determine the event from the views viewpoint: an updated
// item can be added, updated, or removed from this view.
for (i = 0, len = ids.length; i < len; i++) {
id = ids[i];
item = this.get(id);
if (item) {
if (this.ids[id]) {
updated.push(id);
}
else {
this.ids[id] = true;
added.push(id);
}
}
else {
if (this.ids[id]) {
delete this.ids[id];
removed.push(id);
}
else {
// nothing interesting for me :-(
}
}
}
break;
case 'remove':
// filter the ids of the removed items
for (i = 0, len = ids.length; i < len; i++) {
id = ids[i];
if (this.ids[id]) {
delete this.ids[id];
removed.push(id);
}
}
break;
}
if (added.length) {
this._trigger('add', {items: added}, senderId);
}
if (updated.length) {
this._trigger('update', {items: updated}, senderId);
}
if (removed.length) {
this._trigger('remove', {items: removed}, senderId);
}
}
};
// copy subscription functionality from DataSet
DataView.prototype.subscribe = DataSet.prototype.subscribe;
DataView.prototype.unsubscribe = DataSet.prototype.unsubscribe;
DataView.prototype._trigger = DataSet.prototype._trigger;
/**
* @constructor TimeStep
* The class TimeStep is an iterator for dates. You provide a start date and an
* end date. The class itself determines the best scale (step size) based on the
* provided start Date, end Date, and minimumStep.
*
* If minimumStep is provided, the step size is chosen as close as possible
* to the minimumStep but larger than minimumStep. If minimumStep is not
* provided, the scale is set to 1 DAY.
* The minimumStep should correspond with the onscreen size of about 6 characters
*
* Alternatively, you can set a scale by hand.
* After creation, you can initialize the class by executing first(). Then you
* can iterate from the start date to the end date via next(). You can check if
* the end date is reached with the function hasNext(). After each step, you can
* retrieve the current date via getCurrent().
* The TimeStep has scales ranging from milliseconds, seconds, minutes, hours,
* days, to years.
*
* Version: 1.2
*
* @param {Date} [start] The start date, for example new Date(2010, 9, 21)
* or new Date(2010, 9, 21, 23, 45, 00)
* @param {Date} [end] The end date
* @param {Number} [minimumStep] Optional. Minimum step size in milliseconds
*/
TimeStep = function(start, end, minimumStep) {
// variables
this.current = new Date();
this._start = new Date();
this._end = new Date();
this.autoScale = true;
this.scale = TimeStep.SCALE.DAY;
this.step = 1;
// initialize the range
this.setRange(start, end, minimumStep);
};
/// enum scale
TimeStep.SCALE = {
MILLISECOND: 1,
SECOND: 2,
MINUTE: 3,
HOUR: 4,
DAY: 5,
WEEKDAY: 6,
MONTH: 7,
YEAR: 8
};
/**
* Set a new range
* If minimumStep is provided, the step size is chosen as close as possible
* to the minimumStep but larger than minimumStep. If minimumStep is not
* provided, the scale is set to 1 DAY.
* The minimumStep should correspond with the onscreen size of about 6 characters
* @param {Date} [start] The start date and time.
* @param {Date} [end] The end date and time.
* @param {int} [minimumStep] Optional. Minimum step size in milliseconds
*/
TimeStep.prototype.setRange = function(start, end, minimumStep) {
if (!(start instanceof Date) || !(end instanceof Date)) {
throw "No legal start or end date in method setRange";
}
this._start = (start != undefined) ? new Date(start.valueOf()) : new Date();
this._end = (end != undefined) ? new Date(end.valueOf()) : new Date();
if (this.autoScale) {
this.setMinimumStep(minimumStep);
}
};
/**
* Set the range iterator to the start date.
*/
TimeStep.prototype.first = function() {
this.current = new Date(this._start.valueOf());
this.roundToMinor();
};
/**
* Round the current date to the first minor date value
* This must be executed once when the current date is set to start Date
*/
TimeStep.prototype.roundToMinor = function() {
// round to floor
// IMPORTANT: we have no breaks in this switch! (this is no bug)
//noinspection FallthroughInSwitchStatementJS
switch (this.scale) {
case TimeStep.SCALE.YEAR:
this.current.setFullYear(this.step * Math.floor(this.current.getFullYear() / this.step));
this.current.setMonth(0);
case TimeStep.SCALE.MONTH: this.current.setDate(1);
case TimeStep.SCALE.DAY: // intentional fall through
case TimeStep.SCALE.WEEKDAY: this.current.setHours(0);
case TimeStep.SCALE.HOUR: this.current.setMinutes(0);
case TimeStep.SCALE.MINUTE: this.current.setSeconds(0);
case TimeStep.SCALE.SECOND: this.current.setMilliseconds(0);
//case TimeStep.SCALE.MILLISECOND: // nothing to do for milliseconds
}
if (this.step != 1) {
// round down to the first minor value that is a multiple of the current step size
switch (this.scale) {
case TimeStep.SCALE.MILLISECOND: this.current.setMilliseconds(this.current.getMilliseconds() - this.current.getMilliseconds() % this.step); break;
case TimeStep.SCALE.SECOND: this.current.setSeconds(this.current.getSeconds() - this.current.getSeconds() % this.step); break;
case TimeStep.SCALE.MINUTE: this.current.setMinutes(this.current.getMinutes() - this.current.getMinutes() % this.step); break;
case TimeStep.SCALE.HOUR: this.current.setHours(this.current.getHours() - this.current.getHours() % this.step); break;
case TimeStep.SCALE.WEEKDAY: // intentional fall through
case TimeStep.SCALE.DAY: this.current.setDate((this.current.getDate()-1) - (this.current.getDate()-1) % this.step + 1); break;
case TimeStep.SCALE.MONTH: this.current.setMonth(this.current.getMonth() - this.current.getMonth() % this.step); break;
case TimeStep.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() - this.current.getFullYear() % this.step); break;
default: break;
}
}
};
/**
* Check if the there is a next step
* @return {boolean} true if the current date has not passed the end date
*/
TimeStep.prototype.hasNext = function () {
return (this.current.valueOf() <= this._end.valueOf());
};
/**
* Do the next step
*/
TimeStep.prototype.next = function() {
var prev = this.current.valueOf();
// Two cases, needed to prevent issues with switching daylight savings
// (end of March and end of October)
if (this.current.getMonth() < 6) {
switch (this.scale) {
case TimeStep.SCALE.MILLISECOND:
this.current = new Date(this.current.valueOf() + this.step); break;
case TimeStep.SCALE.SECOND: this.current = new Date(this.current.valueOf() + this.step * 1000); break;
case TimeStep.SCALE.MINUTE: this.current = new Date(this.current.valueOf() + this.step * 1000 * 60); break;
case TimeStep.SCALE.HOUR:
this.current = new Date(this.current.valueOf() + this.step * 1000 * 60 * 60);
// in case of skipping an hour for daylight savings, adjust the hour again (else you get: 0h 5h 9h ... instead of 0h 4h 8h ...)
var h = this.current.getHours();
this.current.setHours(h - (h % this.step));
break;
case TimeStep.SCALE.WEEKDAY: // intentional fall through
case TimeStep.SCALE.DAY: this.current.setDate(this.current.getDate() + this.step); break;
case TimeStep.SCALE.MONTH: this.current.setMonth(this.current.getMonth() + this.step); break;
case TimeStep.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() + this.step); break;
default: break;
}
}
else {
switch (this.scale) {
case TimeStep.SCALE.MILLISECOND: this.current = new Date(this.current.valueOf() + this.step); break;
case TimeStep.SCALE.SECOND: this.current.setSeconds(this.current.getSeconds() + this.step); break;
case TimeStep.SCALE.MINUTE: this.current.setMinutes(this.current.getMinutes() + this.step); break;
case TimeStep.SCALE.HOUR: this.current.setHours(this.current.getHours() + this.step); break;
case TimeStep.SCALE.WEEKDAY: // intentional fall through
case TimeStep.SCALE.DAY: this.current.setDate(this.current.getDate() + this.step); break;
case TimeStep.SCALE.MONTH: this.current.setMonth(this.current.getMonth() + this.step); break;
case TimeStep.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() + this.step); break;
default: break;
}
}
if (this.step != 1) {
// round down to the correct major value
switch (this.scale) {
case TimeStep.SCALE.MILLISECOND: if(this.current.getMilliseconds() < this.step) this.current.setMilliseconds(0); break;
case TimeStep.SCALE.SECOND: if(this.current.getSeconds() < this.step) this.current.setSeconds(0); break;
case TimeStep.SCALE.MINUTE: if(this.current.getMinutes() < this.step) this.current.setMinutes(0); break;
case TimeStep.SCALE.HOUR: if(this.current.getHours() < this.step) this.current.setHours(0); break;
case TimeStep.SCALE.WEEKDAY: // intentional fall through
case TimeStep.SCALE.DAY: if(this.current.getDate() < this.step+1) this.current.setDate(1); break;
case TimeStep.SCALE.MONTH: if(this.current.getMonth() < this.step) this.current.setMonth(0); break;
case TimeStep.SCALE.YEAR: break; // nothing to do for year
default: break;
}
}
// safety mechanism: if current time is still unchanged, move to the end
if (this.current.valueOf() == prev) {
this.current = new Date(this._end.valueOf());
}
};
/**
* Get the current datetime
* @return {Date} current The current date
*/
TimeStep.prototype.getCurrent = function() {
return this.current;
};
/**
* Set a custom scale. Autoscaling will be disabled.
* For example setScale(SCALE.MINUTES, 5) will result
* in minor steps of 5 minutes, and major steps of an hour.
*
* @param {TimeStep.SCALE} newScale
* A scale. Choose from SCALE.MILLISECOND,
* SCALE.SECOND, SCALE.MINUTE, SCALE.HOUR,
* SCALE.WEEKDAY, SCALE.DAY, SCALE.MONTH,
* SCALE.YEAR.
* @param {Number} newStep A step size, by default 1. Choose for
* example 1, 2, 5, or 10.
*/
TimeStep.prototype.setScale = function(newScale, newStep) {
this.scale = newScale;
if (newStep > 0) {
this.step = newStep;
}
this.autoScale = false;
};
/**
* Enable or disable autoscaling
* @param {boolean} enable If true, autoascaling is set true
*/
TimeStep.prototype.setAutoScale = function (enable) {
this.autoScale = enable;
};
/**
* Automatically determine the scale that bests fits the provided minimum step
* @param {Number} [minimumStep] The minimum step size in milliseconds
*/
TimeStep.prototype.setMinimumStep = function(minimumStep) {
if (minimumStep == undefined) {
return;
}
var stepYear = (1000 * 60 * 60 * 24 * 30 * 12);
var stepMonth = (1000 * 60 * 60 * 24 * 30);
var stepDay = (1000 * 60 * 60 * 24);
var stepHour = (1000 * 60 * 60);
var stepMinute = (1000 * 60);
var stepSecond = (1000);
var stepMillisecond= (1);
// find the smallest step that is larger than the provided minimumStep
if (stepYear*1000 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 1000;}
if (stepYear*500 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 500;}
if (stepYear*100 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 100;}
if (stepYear*50 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 50;}
if (stepYear*10 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 10;}
if (stepYear*5 > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 5;}
if (stepYear > minimumStep) {this.scale = TimeStep.SCALE.YEAR; this.step = 1;}
if (stepMonth*3 > minimumStep) {this.scale = TimeStep.SCALE.MONTH; this.step = 3;}
if (stepMonth > minimumStep) {this.scale = TimeStep.SCALE.MONTH; this.step = 1;}
if (stepDay*5 > minimumStep) {this.scale = TimeStep.SCALE.DAY; this.step = 5;}
if (stepDay*2 > minimumStep) {this.scale = TimeStep.SCALE.DAY; this.step = 2;}
if (stepDay > minimumStep) {this.scale = TimeStep.SCALE.DAY; this.step = 1;}
if (stepDay/2 > minimumStep) {this.scale = TimeStep.SCALE.WEEKDAY; this.step = 1;}
if (stepHour*4 > minimumStep) {this.scale = TimeStep.SCALE.HOUR; this.step = 4;}
if (stepHour > minimumStep) {this.scale = TimeStep.SCALE.HOUR; this.step = 1;}
if (stepMinute*15 > minimumStep) {this.scale = TimeStep.SCALE.MINUTE; this.step = 15;}
if (stepMinute*10 > minimumStep) {this.scale = TimeStep.SCALE.MINUTE; this.step = 10;}
if (stepMinute*5 > minimumStep) {this.scale = TimeStep.SCALE.MINUTE; this.step = 5;}
if (stepMinute > minimumStep) {this.scale = TimeStep.SCALE.MINUTE; this.step = 1;}
if (stepSecond*15 > minimumStep) {this.scale = TimeStep.SCALE.SECOND; this.step = 15;}
if (stepSecond*10 > minimumStep) {this.scale = TimeStep.SCALE.SECOND; this.step = 10;}
if (stepSecond*5 > minimumStep) {this.scale = TimeStep.SCALE.SECOND; this.step = 5;}
if (stepSecond > minimumStep) {this.scale = TimeStep.SCALE.SECOND; this.step = 1;}
if (stepMillisecond*200 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 200;}
if (stepMillisecond*100 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 100;}
if (stepMillisecond*50 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 50;}
if (stepMillisecond*10 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 10;}
if (stepMillisecond*5 > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 5;}
if (stepMillisecond > minimumStep) {this.scale = TimeStep.SCALE.MILLISECOND; this.step = 1;}
};
/**
* Snap a date to a rounded value. The snap intervals are dependent on the
* current scale and step.
* @param {Date} date the date to be snapped
*/
TimeStep.prototype.snap = function(date) {
if (this.scale == TimeStep.SCALE.YEAR) {
var year = date.getFullYear() + Math.round(date.getMonth() / 12);
date.setFullYear(Math.round(year / this.step) * this.step);
date.setMonth(0);
date.setDate(0);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
}
else if (this.scale == TimeStep.SCALE.MONTH) {
if (date.getDate() > 15) {
date.setDate(1);
date.setMonth(date.getMonth() + 1);
// important: first set Date to 1, after that change the month.
}
else {
date.setDate(1);
}
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
}
else if (this.scale == TimeStep.SCALE.DAY ||
this.scale == TimeStep.SCALE.WEEKDAY) {
//noinspection FallthroughInSwitchStatementJS
switch (this.step) {
case 5:
case 2:
date.setHours(Math.round(date.getHours() / 24) * 24); break;
default:
date.setHours(Math.round(date.getHours() / 12) * 12); break;
}
date.setMinutes(0);
date.setSeconds(0);
date.setMilliseconds(0);
}
else if (this.scale == TimeStep.SCALE.HOUR) {
switch (this.step) {
case 4:
date.setMinutes(Math.round(date.getMinutes() / 60) * 60); break;
default:
date.setMinutes(Math.round(date.getMinutes() / 30) * 30); break;
}
date.setSeconds(0);
date.setMilliseconds(0);
} else if (this.scale == TimeStep.SCALE.MINUTE) {
//noinspection FallthroughInSwitchStatementJS
switch (this.step) {
case 15:
case 10:
date.setMinutes(Math.round(date.getMinutes() / 5) * 5);
date.setSeconds(0);
break;
case 5:
date.setSeconds(Math.round(date.getSeconds() / 60) * 60); break;
default:
date.setSeconds(Math.round(date.getSeconds() / 30) * 30); break;
}
date.setMilliseconds(0);
}
else if (this.scale == TimeStep.SCALE.SECOND) {
//noinspection FallthroughInSwitchStatementJS
switch (this.step) {
case 15:
case 10:
date.setSeconds(Math.round(date.getSeconds() / 5) * 5);
date.setMilliseconds(0);
break;
case 5:
date.setMilliseconds(Math.round(date.getMilliseconds() / 1000) * 1000); break;
default:
date.setMilliseconds(Math.round(date.getMilliseconds() / 500) * 500); break;
}
}
else if (this.scale == TimeStep.SCALE.MILLISECOND) {
var step = this.step > 5 ? this.step / 2 : 1;
date.setMilliseconds(Math.round(date.getMilliseconds() / step) * step);
}
};
/**
* Check if the current value is a major value (for example when the step
* is DAY, a major value is each first day of the MONTH)
* @return {boolean} true if current date is major, else false.
*/
TimeStep.prototype.isMajor = function() {
switch (this.scale) {
case TimeStep.SCALE.MILLISECOND:
return (this.current.getMilliseconds() == 0);
case TimeStep.SCALE.SECOND:
return (this.current.getSeconds() == 0);
case TimeStep.SCALE.MINUTE:
return (this.current.getHours() == 0) && (this.current.getMinutes() == 0);
// Note: this is no bug. Major label is equal for both minute and hour scale
case TimeStep.SCALE.HOUR:
return (this.current.getHours() == 0);
case TimeStep.SCALE.WEEKDAY: // intentional fall through
case TimeStep.SCALE.DAY:
return (this.current.getDate() == 1);
case TimeStep.SCALE.MONTH:
return (this.current.getMonth() == 0);
case TimeStep.SCALE.YEAR:
return false;
default:
return false;
}
};
/**
* Returns formatted text for the minor axislabel, depending on the current
* date and the scale. For example when scale is MINUTE, the current time is
* formatted as "hh:mm".
* @param {Date} [date] custom date. if not provided, current date is taken
*/
TimeStep.prototype.getLabelMinor = function(date) {
if (date == undefined) {
date = this.current;
}
switch (this.scale) {
case TimeStep.SCALE.MILLISECOND: return moment(date).format('SSS');
case TimeStep.SCALE.SECOND: return moment(date).format('s');
case TimeStep.SCALE.MINUTE: return moment(date).format('HH:mm');
case TimeStep.SCALE.HOUR: return moment(date).format('HH:mm');
case TimeStep.SCALE.WEEKDAY: return moment(date).format('ddd D');
case TimeStep.SCALE.DAY: return moment(date).format('D');
case TimeStep.SCALE.MONTH: return moment(date).format('MMM');
case TimeStep.SCALE.YEAR: return moment(date).format('YYYY');
default: return '';
}
};
/**
* Returns formatted text for the major axis label, depending on the current
* date and the scale. For example when scale is MINUTE, the major scale is
* hours, and the hour will be formatted as "hh".
* @param {Date} [date] custom date. if not provided, current date is taken
*/
TimeStep.prototype.getLabelMajor = function(date) {
if (date == undefined) {
date = this.current;
}
//noinspection FallthroughInSwitchStatementJS
switch (this.scale) {
case TimeStep.SCALE.MILLISECOND:return moment(date).format('HH:mm:ss');
case TimeStep.SCALE.SECOND: return moment(date).format('D MMMM HH:mm');
case TimeStep.SCALE.MINUTE:
case TimeStep.SCALE.HOUR: return moment(date).format('ddd D MMMM');
case TimeStep.SCALE.WEEKDAY:
case TimeStep.SCALE.DAY: return moment(date).format('MMMM YYYY');
case TimeStep.SCALE.MONTH: return moment(date).format('YYYY');
case TimeStep.SCALE.YEAR: return '';
default: return '';
}
};
/**
* @constructor Stack
* Stacks items on top of each other.
* @param {ItemSet} parent
* @param {Object} [options]
*/
function Stack (parent, options) {
this.parent = parent;
this.options = options || {};
this.defaultOptions = {
order: function (a, b) {
//return (b.width - a.width) || (a.left - b.left); // TODO: cleanup
// Order: ranges over non-ranges, ranged ordered by width, and
// lastly ordered by start.
if (a instanceof ItemRange) {
if (b instanceof ItemRange) {
var aInt = (a.data.end - a.data.start);
var bInt = (b.data.end - b.data.start);
return (aInt - bInt) || (a.data.start - b.data.start);
}
else {
return -1;
}
}
else {
if (b instanceof ItemRange) {
return 1;
}
else {
return (a.data.start - b.data.start);
}
}
},
margin: {
item: 10
}
};
this.ordered = []; // ordered items
}
/**
* Set options for the stack
* @param {Object} options Available options:
* {ItemSet} parent
* {Number} margin
* {function} order Stacking order
*/
Stack.prototype.setOptions = function setOptions (options) {
util.extend(this.options, options);
// TODO: register on data changes at the connected parent itemset, and update the changed part only and immediately
};
/**
* Stack the items such that they don't overlap. The items will have a minimal
* distance equal to options.margin.item.
*/
Stack.prototype.update = function update() {
this._order();
this._stack();
};
/**
* Order the items. The items are ordered by width first, and by left position
* second.
* If a custom order function has been provided via the options, then this will
* be used.
* @private
*/
Stack.prototype._order = function _order () {
var items = this.parent.items;
if (!items) {
throw new Error('Cannot stack items: parent does not contain items');
}
// TODO: store the sorted items, to have less work later on
var ordered = [];
var index = 0;
// items is a map (no array)
util.forEach(items, function (item) {
if (item.visible) {
ordered[index] = item;
index++;
}
});
//if a customer stack order function exists, use it.
var order = this.options.order || this.defaultOptions.order;
if (!(typeof order === 'function')) {
throw new Error('Option order must be a function');
}
ordered.sort(order);
this.ordered = ordered;
};
/**
* Adjust vertical positions of the events such that they don't overlap each
* other.
* @private
*/
Stack.prototype._stack = function _stack () {
var i,
iMax,
ordered = this.ordered,
options = this.options,
orientation = options.orientation || this.defaultOptions.orientation,
axisOnTop = (orientation == 'top'),
margin;
if (options.margin && options.margin.item !== undefined) {
margin = options.margin.item;
}
else {
margin = this.defaultOptions.margin.item
}
// calculate new, non-overlapping positions
for (i = 0, iMax = ordered.length; i < iMax; i++) {
var item = ordered[i];
var collidingItem = null;
do {
// TODO: optimize checking for overlap. when there is a gap without items,
// you only need to check for items from the next item on, not from zero
collidingItem = this.checkOverlap(ordered, i, 0, i - 1, margin);
if (collidingItem != null) {
// There is a collision. Reposition the event above the colliding element
if (axisOnTop) {
item.top = collidingItem.top + collidingItem.height + margin;
}
else {
item.top = collidingItem.top - item.height - margin;
}
}
} while (collidingItem);
}
};
/**
* Check if the destiny position of given item overlaps with any
* of the other items from index itemStart to itemEnd.
* @param {Array} items Array with items
* @param {int} itemIndex Number of the item to be checked for overlap
* @param {int} itemStart First item to be checked.
* @param {int} itemEnd Last item to be checked.
* @return {Object | null} colliding item, or undefined when no collisions
* @param {Number} margin A minimum required margin.
* If margin is provided, the two items will be
* marked colliding when they overlap or
* when the margin between the two is smaller than
* the requested margin.
*/
Stack.prototype.checkOverlap = function checkOverlap (items, itemIndex,
itemStart, itemEnd, margin) {
var collision = this.collision;
// we loop from end to start, as we suppose that the chance of a
// collision is larger for items at the end, so check these first.
var a = items[itemIndex];
for (var i = itemEnd; i >= itemStart; i--) {
var b = items[i];
if (collision(a, b, margin)) {
if (i != itemIndex) {
return b;
}
}
}
return null;
};
/**
* Test if the two provided items collide
* The items must have parameters left, width, top, and height.
* @param {Component} a The first item
* @param {Component} b The second item
* @param {Number} margin A minimum required margin.
* If margin is provided, the two items will be
* marked colliding when they overlap or
* when the margin between the two is smaller than
* the requested margin.
* @return {boolean} true if a and b collide, else false
*/
Stack.prototype.collision = function collision (a, b, margin) {
return ((a.left - margin) < (b.left + b.getWidth()) &&
(a.left + a.getWidth() + margin) > b.left &&
(a.top - margin) < (b.top + b.height) &&
(a.top + a.height + margin) > b.top);
};
/**
* @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 {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;
if (event == 'move') {
// drag start listener
component.on('dragstart', function (event) {
me._onDragStart(event, component);
});
// drag listener
component.on('drag', function (event) {
me._onDrag(event, component, direction);
});
// drag end listener
component.on('dragend', function (event) {
me._onDragEnd(event, component);
});
}
else if (event == 'zoom') {
// mouse wheel
function mousewheel (event) {
me._onMouseWheel(event, component, direction);
}
component.on('mousewheel', mousewheel);
component.on('DOMMouseScroll', mousewheel); // For FF
// pinch
component.on('touch', function (event) {
me._onTouch();
});
component.on('pinch', function (event) {
me._onPinch(event, component, direction);
});
}
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.convert(start, 'Number') : this.start,
newEnd = (end != null) ? util.convert(end, 'Number') : 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.pinching) 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.pinching) 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.pinching) 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);
};
/**
* On start of a touch gesture, initialize scale to 1
* @private
*/
Range.prototype._onTouch = function () {
touchParams.start = this.start;
touchParams.end = this.end;
touchParams.pinching = false;
touchParams.center = null;
};
/**
* Handle pinch event
* @param {Event} event
* @param {Component} component
* @param {String} direction 'horizontal' or 'vertical'
* @private
*/
Range.prototype._onPinch = function (event, component, direction) {
touchParams.pinching = 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);
};
/**
* @constructor Controller
*
* A Controller controls the reflows and repaints of all visual components
*/
function Controller () {
this.id = util.randomUUID();
this.components = {};
this.repaintTimer = undefined;
this.reflowTimer = undefined;
}
/**
* Add a component to the controller
* @param {Component} component
*/
Controller.prototype.add = function add(component) {
// validate the component
if (component.id == undefined) {
throw new Error('Component has no field id');
}
if (!(component instanceof Component) && !(component instanceof Controller)) {
throw new TypeError('Component must be an instance of ' +
'prototype Component or Controller');
}
// add the component
component.controller = this;
this.components[component.id] = component;
};
/**
* Remove a component from the controller
* @param {Component | String} component
*/
Controller.prototype.remove = function remove(component) {
var id;
for (id in this.components) {
if (this.components.hasOwnProperty(id)) {
if (id == component || this.components[id] == component) {
break;
}
}
}
if (id) {
delete this.components[id];
}
};
/**
* Request a reflow. The controller will schedule a reflow
* @param {Boolean} [force] If true, an immediate reflow is forced. Default
* is false.
*/
Controller.prototype.requestReflow = function requestReflow(force) {
if (force) {
this.reflow();
}
else {
if (!this.reflowTimer) {
var me = this;
this.reflowTimer = setTimeout(function () {
me.reflowTimer = undefined;
me.reflow();
}, 0);
}
}
};
/**
* Request a repaint. The controller will schedule a repaint
* @param {Boolean} [force] If true, an immediate repaint is forced. Default
* is false.
*/
Controller.prototype.requestRepaint = function requestRepaint(force) {
if (force) {
this.repaint();
}
else {
if (!this.repaintTimer) {
var me = this;
this.repaintTimer = setTimeout(function () {
me.repaintTimer = undefined;
me.repaint();
}, 0);
}
}
};
/**
* Repaint all components
*/
Controller.prototype.repaint = function repaint() {
var changed = false;
// cancel any running repaint request
if (this.repaintTimer) {
clearTimeout(this.repaintTimer);
this.repaintTimer = undefined;
}
var done = {};
function repaint(component, id) {
if (!(id in done)) {
// first repaint the components on which this component is dependent
if (component.depends) {
component.depends.forEach(function (dep) {
repaint(dep, dep.id);
});
}
if (component.parent) {
repaint(component.parent, component.parent.id);
}
// repaint the component itself and mark as done
changed = component.repaint() || changed;
done[id] = true;
}
}
util.forEach(this.components, repaint);
// immediately reflow when needed
if (changed) {
this.reflow();
}
// TODO: limit the number of nested reflows/repaints, prevent loop
};
/**
* Reflow all components
*/
Controller.prototype.reflow = function reflow() {
var resized = false;
// cancel any running repaint request
if (this.reflowTimer) {
clearTimeout(this.reflowTimer);
this.reflowTimer = undefined;
}
var done = {};
function reflow(component, id) {
if (!(id in done)) {
// first reflow the components on which this component is dependent
if (component.depends) {
component.depends.forEach(function (dep) {
reflow(dep, dep.id);
});
}
if (component.parent) {
reflow(component.parent, component.parent.id);
}
// reflow the component itself and mark as done
resized = component.reflow() || resized;
done[id] = true;
}
}
util.forEach(this.components, reflow);
// immediately repaint when needed
if (resized) {
this.repaint();
}
// TODO: limit the number of nested reflows/repaints, prevent loop
};
/**
* Prototype for visual components
*/
function Component () {
this.id = null;
this.parent = null;
this.depends = null;
this.controller = null;
this.options = null;
this.frame = null; // main DOM element
this.top = 0;
this.left = 0;
this.width = 0;
this.height = 0;
}
/**
* Set parameters for the frame. Parameters will be merged in current parameter
* set.
* @param {Object} options Available parameters:
* {String | function} [className]
* {EventBus} [eventBus]
* {String | Number | function} [left]
* {String | Number | function} [top]
* {String | Number | function} [width]
* {String | Number | function} [height]
*/
Component.prototype.setOptions = function setOptions(options) {
if (options) {
util.extend(this.options, options);
if (this.controller) {
this.requestRepaint();
this.requestReflow();
}
}
};
/**
* Get an option value by name
* The function will first check this.options object, and else will check
* this.defaultOptions.
* @param {String} name
* @return {*} value
*/
Component.prototype.getOption = function getOption(name) {
var value;
if (this.options) {
value = this.options[name];
}
if (value === undefined && this.defaultOptions) {
value = this.defaultOptions[name];
}
return value;
};
/**
* Get the container element of the component, which can be used by a child to
* add its own widgets. Not all components do have a container for childs, in
* that case null is returned.
* @returns {HTMLElement | null} container
*/
// TODO: get rid of the getContainer and getFrame methods, provide these via the options
Component.prototype.getContainer = function getContainer() {
// should be implemented by the component
return null;
};
/**
* Get the frame element of the component, the outer HTML DOM element.
* @returns {HTMLElement | null} frame
*/
Component.prototype.getFrame = function getFrame() {
return this.frame;
};
/**
* Repaint the component
* @return {Boolean} changed
*/
Component.prototype.repaint = function repaint() {
// should be implemented by the component
return false;
};
/**
* Reflow the component
* @return {Boolean} resized
*/
Component.prototype.reflow = function reflow() {
// should be implemented by the component
return false;
};
/**
* Hide the component from the DOM
* @return {Boolean} changed
*/
Component.prototype.hide = function hide() {
if (this.frame && this.frame.parentNode) {
this.frame.parentNode.removeChild(this.frame);
return true;
}
else {
return false;
}
};
/**
* Show the component in the DOM (when not already visible).
* A repaint will be executed when the component is not visible
* @return {Boolean} changed
*/
Component.prototype.show = function show() {
if (!this.frame || !this.frame.parentNode) {
return this.repaint();
}
else {
return false;
}
};
/**
* Request a repaint. The controller will schedule a repaint
*/
Component.prototype.requestRepaint = function requestRepaint() {
if (this.controller) {
this.controller.requestRepaint();
}
else {
throw new Error('Cannot request a repaint: no controller configured');
// TODO: just do a repaint when no parent is configured?
}
};
/**
* Request a reflow. The controller will schedule a reflow
*/
Component.prototype.requestReflow = function requestReflow() {
if (this.controller) {
this.controller.requestReflow();
}
else {
throw new Error('Cannot request a reflow: no controller configured');
// TODO: just do a reflow when no parent is configured?
}
};
/**
* A panel can contain components
* @param {Component} [parent]
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] Available parameters:
* {String | Number | function} [left]
* {String | Number | function} [top]
* {String | Number | function} [width]
* {String | Number | function} [height]
* {String | function} [className]
* @constructor Panel
* @extends Component
*/
function Panel(parent, depends, options) {
this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.options = options || {};
}
Panel.prototype = new Component();
/**
* Set options. Will extend the current options.
* @param {Object} [options] Available parameters:
* {String | function} [className]
* {String | Number | function} [left]
* {String | Number | function} [top]
* {String | Number | function} [width]
* {String | Number | function} [height]
*/
Panel.prototype.setOptions = Component.prototype.setOptions;
/**
* Get the container element of the panel, which can be used by a child to
* add its own widgets.
* @returns {HTMLElement} container
*/
Panel.prototype.getContainer = function () {
return this.frame;
};
/**
* Repaint the component
* @return {Boolean} changed
*/
Panel.prototype.repaint = function () {
var changed = 0,
update = util.updateProperty,
asSize = util.option.asSize,
options = this.options,
frame = this.frame;
if (!frame) {
frame = document.createElement('div');
frame.className = 'panel';
var className = options.className;
if (className) {
if (typeof className == 'function') {
util.addClassName(frame, String(className()));
}
else {
util.addClassName(frame, String(className));
}
}
this.frame = frame;
changed += 1;
}
if (!frame.parentNode) {
if (!this.parent) {
throw new Error('Cannot repaint panel: no parent attached');
}
var parentContainer = this.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint panel: parent has no container element');
}
parentContainer.appendChild(frame);
changed += 1;
}
changed += update(frame.style, 'top', asSize(options.top, '0px'));
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
changed += update(frame.style, 'height', asSize(options.height, '100%'));
return (changed > 0);
};
/**
* Reflow the component
* @return {Boolean} resized
*/
Panel.prototype.reflow = function () {
var changed = 0,
update = util.updateProperty,
frame = this.frame;
if (frame) {
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
changed += update(this, 'width', frame.offsetWidth);
changed += update(this, 'height', frame.offsetHeight);
}
else {
changed += 1;
}
return (changed > 0);
};
/**
* A root panel can hold components. The root panel must be initialized with
* a DOM element as container.
* @param {HTMLElement} container
* @param {Object} [options] Available parameters: see RootPanel.setOptions.
* @constructor RootPanel
* @extends Panel
*/
function RootPanel(container, options) {
this.id = util.randomUUID();
this.container = container;
this.options = options || {};
this.defaultOptions = {
autoResize: true
};
this.listeners = {}; // event listeners
}
RootPanel.prototype = new Panel();
/**
* Set options. Will extend the current options.
* @param {Object} [options] Available parameters:
* {String | function} [className]
* {String | Number | function} [left]
* {String | Number | function} [top]
* {String | Number | function} [width]
* {String | Number | function} [height]
* {Boolean | function} [autoResize]
*/
RootPanel.prototype.setOptions = Component.prototype.setOptions;
/**
* Repaint the component
* @return {Boolean} changed
*/
RootPanel.prototype.repaint = function () {
var changed = 0,
update = util.updateProperty,
asSize = util.option.asSize,
options = this.options,
frame = this.frame;
if (!frame) {
frame = document.createElement('div');
this.frame = frame;
changed += 1;
}
if (!frame.parentNode) {
if (!this.container) {
throw new Error('Cannot repaint root panel: no container attached');
}
this.container.appendChild(frame);
changed += 1;
}
frame.className = 'vis timeline rootpanel ' + options.orientation;
var className = options.className;
if (className) {
util.addClassName(frame, util.option.asString(className));
}
changed += update(frame.style, 'top', asSize(options.top, '0px'));
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
changed += update(frame.style, 'height', asSize(options.height, '100%'));
this._updateEventEmitters();
this._updateWatch();
return (changed > 0);
};
/**
* Reflow the component
* @return {Boolean} resized
*/
RootPanel.prototype.reflow = function () {
var changed = 0,
update = util.updateProperty,
frame = this.frame;
if (frame) {
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
changed += update(this, 'width', frame.offsetWidth);
changed += update(this, 'height', frame.offsetHeight);
}
else {
changed += 1;
}
return (changed > 0);
};
/**
* Update watching for resize, depending on the current option
* @private
*/
RootPanel.prototype._updateWatch = function () {
var autoResize = this.getOption('autoResize');
if (autoResize) {
this._watch();
}
else {
this._unwatch();
}
};
/**
* Watch for changes in the size of the frame. On resize, the Panel will
* automatically redraw itself.
* @private
*/
RootPanel.prototype._watch = function () {
var me = this;
this._unwatch();
var checkSize = function () {
var autoResize = me.getOption('autoResize');
if (!autoResize) {
// stop watching when the option autoResize is changed to false
me._unwatch();
return;
}
if (me.frame) {
// check whether the frame is resized
if ((me.frame.clientWidth != me.width) ||
(me.frame.clientHeight != me.height)) {
me.requestReflow();
}
}
};
// TODO: automatically cleanup the event listener when the frame is deleted
util.addEventListener(window, 'resize', checkSize);
this.watchTimer = setInterval(checkSize, 1000);
};
/**
* Stop watching for a resize of the frame.
* @private
*/
RootPanel.prototype._unwatch = function () {
if (this.watchTimer) {
clearInterval(this.watchTimer);
this.watchTimer = undefined;
}
// TODO: remove event listener on window.resize
};
/**
* 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.
*/
RootPanel.prototype.on = function (event, callback) {
// register the listener at this component
var arr = this.listeners[event];
if (!arr) {
arr = [];
this.listeners[event] = arr;
}
arr.push(callback);
this._updateEventEmitters();
};
/**
* Update the event listeners for all event emitters
* @private
*/
RootPanel.prototype._updateEventEmitters = function () {
if (this.listeners) {
var me = this;
util.forEach(this.listeners, function (listeners, event) {
if (!me.emitters) {
me.emitters = {};
}
if (!(event in me.emitters)) {
// create event
var frame = me.frame;
if (frame) {
//console.log('Created a listener for event ' + event + ' on component ' + me.id); // TODO: cleanup logging
var callback = function(event) {
listeners.forEach(function (listener) {
// TODO: filter on event target!
listener(event);
});
};
me.emitters[event] = callback;
if (!me.hammer) {
me.hammer = Hammer(frame, {
prevent_default: true
});
}
me.hammer.on(event, callback);
}
}
});
// TODO: be able to delete event listeners
// TODO: be able to move event listeners to a parent when available
}
};
/**
* A horizontal time axis
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] See TimeAxis.setOptions for the available
* options.
* @constructor TimeAxis
* @extends Component
*/
function TimeAxis (parent, depends, options) {
this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.dom = {
majorLines: [],
majorTexts: [],
minorLines: [],
minorTexts: [],
redundant: {
majorLines: [],
majorTexts: [],
minorLines: [],
minorTexts: []
}
};
this.props = {
range: {
start: 0,
end: 0,
minimumStep: 0
},
lineTop: 0
};
this.options = options || {};
this.defaultOptions = {
orientation: 'bottom', // supported: 'top', 'bottom'
// TODO: implement timeaxis orientations 'left' and 'right'
showMinorLabels: true,
showMajorLabels: true
};
this.conversion = null;
this.range = null;
}
TimeAxis.prototype = new Component();
// TODO: comment options
TimeAxis.prototype.setOptions = Component.prototype.setOptions;
/**
* Set a range (start and end)
* @param {Range | Object} range A Range or an object containing start and end.
*/
TimeAxis.prototype.setRange = function (range) {
if (!(range instanceof Range) && (!range || !range.start || !range.end)) {
throw new TypeError('Range must be an instance of Range, ' +
'or an object containing start and end.');
}
this.range = range;
};
/**
* Convert a position on screen (pixels) to a datetime
* @param {int} x Position on the screen in pixels
* @return {Date} time The datetime the corresponds with given position x
*/
TimeAxis.prototype.toTime = function(x) {
var conversion = this.conversion;
return new Date(x / conversion.scale + conversion.offset);
};
/**
* Convert a datetime (Date object) into a position on the screen
* @param {Date} time A date
* @return {int} x The position on the screen in pixels which corresponds
* with the given date.
* @private
*/
TimeAxis.prototype.toScreen = function(time) {
var conversion = this.conversion;
return (time.valueOf() - conversion.offset) * conversion.scale;
};
/**
* Repaint the component
* @return {Boolean} changed
*/
TimeAxis.prototype.repaint = function () {
var changed = 0,
update = util.updateProperty,
asSize = util.option.asSize,
options = this.options,
orientation = this.getOption('orientation'),
props = this.props,
step = this.step;
var frame = this.frame;
if (!frame) {
frame = document.createElement('div');
this.frame = frame;
changed += 1;
}
frame.className = 'axis';
// TODO: custom className?
if (!frame.parentNode) {
if (!this.parent) {
throw new Error('Cannot repaint time axis: no parent attached');
}
var parentContainer = this.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint time axis: parent has no container element');
}
parentContainer.appendChild(frame);
changed += 1;
}
var parent = frame.parentNode;
if (parent) {
var beforeChild = frame.nextSibling;
parent.removeChild(frame); // take frame offline while updating (is almost twice as fast)
var defaultTop = (orientation == 'bottom' && this.props.parentHeight && this.height) ?
(this.props.parentHeight - this.height) + 'px' :
'0px';
changed += update(frame.style, 'top', asSize(options.top, defaultTop));
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
changed += update(frame.style, 'height', asSize(options.height, this.height + 'px'));
// get characters width and height
this._repaintMeasureChars();
if (this.step) {
this._repaintStart();
step.first();
var xFirstMajorLabel = undefined;
var max = 0;
while (step.hasNext() && max < 1000) {
max++;
var cur = step.getCurrent(),
x = this.toScreen(cur),
isMajor = step.isMajor();
// TODO: lines must have a width, such that we can create css backgrounds
if (this.getOption('showMinorLabels')) {
this._repaintMinorText(x, step.getLabelMinor());
}
if (isMajor && this.getOption('showMajorLabels')) {
if (x > 0) {
if (xFirstMajorLabel == undefined) {
xFirstMajorLabel = x;
}
this._repaintMajorText(x, step.getLabelMajor());
}
this._repaintMajorLine(x);
}
else {
this._repaintMinorLine(x);
}
step.next();
}
// create a major label on the left when needed
if (this.getOption('showMajorLabels')) {
var leftTime = this.toTime(0),
leftText = step.getLabelMajor(leftTime),
widthText = leftText.length * (props.majorCharWidth || 10) + 10; // upper bound estimation
if (xFirstMajorLabel == undefined || widthText < xFirstMajorLabel) {
this._repaintMajorText(0, leftText);
}
}
this._repaintEnd();
}
this._repaintLine();
// put frame online again
if (beforeChild) {
parent.insertBefore(frame, beforeChild);
}
else {
parent.appendChild(frame)
}
}
return (changed > 0);
};
/**
* Start a repaint. Move all DOM elements to a redundant list, where they
* can be picked for re-use, or can be cleaned up in the end
* @private
*/
TimeAxis.prototype._repaintStart = function () {
var dom = this.dom,
redundant = dom.redundant;
redundant.majorLines = dom.majorLines;
redundant.majorTexts = dom.majorTexts;
redundant.minorLines = dom.minorLines;
redundant.minorTexts = dom.minorTexts;
dom.majorLines = [];
dom.majorTexts = [];
dom.minorLines = [];
dom.minorTexts = [];
};
/**
* End a repaint. Cleanup leftover DOM elements in the redundant list
* @private
*/
TimeAxis.prototype._repaintEnd = function () {
util.forEach(this.dom.redundant, function (arr) {
while (arr.length) {
var elem = arr.pop();
if (elem && elem.parentNode) {
elem.parentNode.removeChild(elem);
}
}
});
};
/**
* Create a minor label for the axis at position x
* @param {Number} x
* @param {String} text
* @private
*/
TimeAxis.prototype._repaintMinorText = function (x, text) {
// reuse redundant label
var label = this.dom.redundant.minorTexts.shift();
if (!label) {
// create new label
var content = document.createTextNode('');
label = document.createElement('div');
label.appendChild(content);
label.className = 'text minor';
this.frame.appendChild(label);
}
this.dom.minorTexts.push(label);
label.childNodes[0].nodeValue = text;
label.style.left = x + 'px';
label.style.top = this.props.minorLabelTop + 'px';
//label.title = title; // TODO: this is a heavy operation
};
/**
* Create a Major label for the axis at position x
* @param {Number} x
* @param {String} text
* @private
*/
TimeAxis.prototype._repaintMajorText = function (x, text) {
// reuse redundant label
var label = this.dom.redundant.majorTexts.shift();
if (!label) {
// create label
var content = document.createTextNode(text);
label = document.createElement('div');
label.className = 'text major';
label.appendChild(content);
this.frame.appendChild(label);
}
this.dom.majorTexts.push(label);
label.childNodes[0].nodeValue = text;
label.style.top = this.props.majorLabelTop + 'px';
label.style.left = x + 'px';
//label.title = title; // TODO: this is a heavy operation
};
/**
* Create a minor line for the axis at position x
* @param {Number} x
* @private
*/
TimeAxis.prototype._repaintMinorLine = function (x) {
// reuse redundant line
var line = this.dom.redundant.minorLines.shift();
if (!line) {
// create vertical line
line = document.createElement('div');
line.className = 'grid vertical minor';
this.frame.appendChild(line);
}
this.dom.minorLines.push(line);
var props = this.props;
line.style.top = props.minorLineTop + 'px';
line.style.height = props.minorLineHeight + 'px';
line.style.left = (x - props.minorLineWidth / 2) + 'px';
};
/**
* Create a Major line for the axis at position x
* @param {Number} x
* @private
*/
TimeAxis.prototype._repaintMajorLine = function (x) {
// reuse redundant line
var line = this.dom.redundant.majorLines.shift();
if (!line) {
// create vertical line
line = document.createElement('DIV');
line.className = 'grid vertical major';
this.frame.appendChild(line);
}
this.dom.majorLines.push(line);
var props = this.props;
line.style.top = props.majorLineTop + 'px';
line.style.left = (x - props.majorLineWidth / 2) + 'px';
line.style.height = props.majorLineHeight + 'px';
};
/**
* Repaint the horizontal line for the axis
* @private
*/
TimeAxis.prototype._repaintLine = function() {
var line = this.dom.line,
frame = this.frame,
options = this.options;
// line before all axis elements
if (this.getOption('showMinorLabels') || this.getOption('showMajorLabels')) {
if (line) {
// put this line at the end of all childs
frame.removeChild(line);
frame.appendChild(line);
}
else {
// create the axis line
line = document.createElement('div');
line.className = 'grid horizontal major';
frame.appendChild(line);
this.dom.line = line;
}
line.style.top = this.props.lineTop + 'px';
}
else {
if (line && line.parentElement) {
frame.removeChild(line.line);
delete this.dom.line;
}
}
};
/**
* Create characters used to determine the size of text on the axis
* @private
*/
TimeAxis.prototype._repaintMeasureChars = function () {
// calculate the width and height of a single character
// this is used to calculate the step size, and also the positioning of the
// axis
var dom = this.dom,
text;
if (!dom.measureCharMinor) {
text = document.createTextNode('0');
var measureCharMinor = document.createElement('DIV');
measureCharMinor.className = 'text minor measure';
measureCharMinor.appendChild(text);
this.frame.appendChild(measureCharMinor);
dom.measureCharMinor = measureCharMinor;
}
if (!dom.measureCharMajor) {
text = document.createTextNode('0');
var measureCharMajor = document.createElement('DIV');
measureCharMajor.className = 'text major measure';
measureCharMajor.appendChild(text);
this.frame.appendChild(measureCharMajor);
dom.measureCharMajor = measureCharMajor;
}
};
/**
* Reflow the component
* @return {Boolean} resized
*/
TimeAxis.prototype.reflow = function () {
var changed = 0,
update = util.updateProperty,
frame = this.frame,
range = this.range;
if (!range) {
throw new Error('Cannot repaint time axis: no range configured');
}
if (frame) {
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
// calculate size of a character
var props = this.props,
showMinorLabels = this.getOption('showMinorLabels'),
showMajorLabels = this.getOption('showMajorLabels'),
measureCharMinor = this.dom.measureCharMinor,
measureCharMajor = this.dom.measureCharMajor;
if (measureCharMinor) {
props.minorCharHeight = measureCharMinor.clientHeight;
props.minorCharWidth = measureCharMinor.clientWidth;
}
if (measureCharMajor) {
props.majorCharHeight = measureCharMajor.clientHeight;
props.majorCharWidth = measureCharMajor.clientWidth;
}
var parentHeight = frame.parentNode ? frame.parentNode.offsetHeight : 0;
if (parentHeight != props.parentHeight) {
props.parentHeight = parentHeight;
changed += 1;
}
switch (this.getOption('orientation')) {
case 'bottom':
props.minorLabelHeight = showMinorLabels ? props.minorCharHeight : 0;
props.majorLabelHeight = showMajorLabels ? props.majorCharHeight : 0;
props.minorLabelTop = 0;
props.majorLabelTop = props.minorLabelTop + props.minorLabelHeight;
props.minorLineTop = -this.top;
props.minorLineHeight = Math.max(this.top + props.majorLabelHeight, 0);
props.minorLineWidth = 1; // TODO: really calculate width
props.majorLineTop = -this.top;
props.majorLineHeight = Math.max(this.top + props.minorLabelHeight + props.majorLabelHeight, 0);
props.majorLineWidth = 1; // TODO: really calculate width
props.lineTop = 0;
break;
case 'top':
props.minorLabelHeight = showMinorLabels ? props.minorCharHeight : 0;
props.majorLabelHeight = showMajorLabels ? props.majorCharHeight : 0;
props.majorLabelTop = 0;
props.minorLabelTop = props.majorLabelTop + props.majorLabelHeight;
props.minorLineTop = props.minorLabelTop;
props.minorLineHeight = Math.max(parentHeight - props.majorLabelHeight - this.top);
props.minorLineWidth = 1; // TODO: really calculate width
props.majorLineTop = 0;
props.majorLineHeight = Math.max(parentHeight - this.top);
props.majorLineWidth = 1; // TODO: really calculate width
props.lineTop = props.majorLabelHeight + props.minorLabelHeight;
break;
default:
throw new Error('Unkown orientation "' + this.getOption('orientation') + '"');
}
var height = props.minorLabelHeight + props.majorLabelHeight;
changed += update(this, 'width', frame.offsetWidth);
changed += update(this, 'height', height);
// calculate range and step
this._updateConversion();
var start = util.convert(range.start, 'Number'),
end = util.convert(range.end, 'Number'),
minimumStep = this.toTime((props.minorCharWidth || 10) * 5).valueOf()
-this.toTime(0).valueOf();
this.step = new TimeStep(new Date(start), new Date(end), minimumStep);
changed += update(props.range, 'start', start);
changed += update(props.range, 'end', end);
changed += update(props.range, 'minimumStep', minimumStep.valueOf());
}
return (changed > 0);
};
/**
* Calculate the scale and offset to convert a position on screen to the
* corresponding date and vice versa.
* After the method _updateConversion is executed once, the methods toTime
* and toScreen can be used.
* @private
*/
TimeAxis.prototype._updateConversion = function() {
var range = this.range;
if (!range) {
throw new Error('No range configured');
}
if (range.conversion) {
this.conversion = range.conversion(this.width);
}
else {
this.conversion = Range.conversion(range.start, range.end, this.width);
}
};
/**
* A current time bar
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] Available parameters:
* {Boolean} [showCurrentTime]
* @constructor CurrentTime
* @extends Component
*/
function CurrentTime (parent, depends, options) {
this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.options = options || {};
this.defaultOptions = {
showCurrentTime: false
};
}
CurrentTime.prototype = new Component();
CurrentTime.prototype.setOptions = Component.prototype.setOptions;
/**
* Get the container element of the bar, which can be used by a child to
* add its own widgets.
* @returns {HTMLElement} container
*/
CurrentTime.prototype.getContainer = function () {
return this.frame;
};
/**
* Repaint the component
* @return {Boolean} changed
*/
CurrentTime.prototype.repaint = function () {
var bar = this.frame,
parent = this.parent,
parentContainer = parent.parent.getContainer();
if (!parent) {
throw new Error('Cannot repaint bar: no parent attached');
}
if (!parentContainer) {
throw new Error('Cannot repaint bar: parent has no container element');
}
if (!this.getOption('showCurrentTime')) {
if (bar) {
parentContainer.removeChild(bar);
delete this.frame;
}
return;
}
if (!bar) {
bar = document.createElement('div');
bar.className = 'currenttime';
bar.style.position = 'absolute';
bar.style.top = '0px';
bar.style.height = '100%';
parentContainer.appendChild(bar);
this.frame = bar;
}
if (!parent.conversion) {
parent._updateConversion();
}
var now = new Date();
var x = parent.toScreen(now);
bar.style.left = x + 'px';
bar.title = 'Current time: ' + now;
// start a timer to adjust for the new time
if (this.currentTimeTimer !== undefined) {
clearTimeout(this.currentTimeTimer);
delete this.currentTimeTimer;
}
var timeline = this;
var interval = 1 / parent.conversion.scale / 2;
if (interval < 30) {
interval = 30;
}
this.currentTimeTimer = setTimeout(function() {
timeline.repaint();
}, interval);
return false;
};
/**
* A custom time bar
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] Available parameters:
* {Boolean} [showCustomTime]
* @constructor CustomTime
* @extends Component
*/
function CustomTime (parent, depends, options) {
this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.options = options || {};
this.defaultOptions = {
showCustomTime: false
};
this.listeners = [];
this.customTime = new Date();
}
CustomTime.prototype = new Component();
CustomTime.prototype.setOptions = Component.prototype.setOptions;
/**
* Get the container element of the bar, which can be used by a child to
* add its own widgets.
* @returns {HTMLElement} container
*/
CustomTime.prototype.getContainer = function () {
return this.frame;
};
/**
* Repaint the component
* @return {Boolean} changed
*/
CustomTime.prototype.repaint = function () {
var bar = this.frame,
parent = this.parent,
parentContainer = parent.parent.getContainer();
if (!parent) {
throw new Error('Cannot repaint bar: no parent attached');
}
if (!parentContainer) {
throw new Error('Cannot repaint bar: parent has no container element');
}
if (!this.getOption('showCustomTime')) {
if (bar) {
parentContainer.removeChild(bar);
delete this.frame;
}
return;
}
if (!bar) {
bar = document.createElement('div');
bar.className = 'customtime';
bar.style.position = 'absolute';
bar.style.top = '0px';
bar.style.height = '100%';
parentContainer.appendChild(bar);
var drag = document.createElement('div');
drag.style.position = 'relative';
drag.style.top = '0px';
drag.style.left = '-10px';
drag.style.height = '100%';
drag.style.width = '20px';
bar.appendChild(drag);
this.frame = bar;
this.subscribe(this, 'movetime');
}
if (!parent.conversion) {
parent._updateConversion();
}
var x = parent.toScreen(this.customTime);
bar.style.left = x + 'px';
bar.title = 'Time: ' + this.customTime;
return false;
};
/**
* Set custom time.
* @param {Date} time
*/
CustomTime.prototype._setCustomTime = function(time) {
this.customTime = new Date(time.valueOf());
this.repaint();
};
/**
* Retrieve the current custom time.
* @return {Date} customTime
*/
CustomTime.prototype._getCustomTime = function() {
return new Date(this.customTime.valueOf());
};
/**
* Add listeners for mouse and touch events to the component
* @param {Component} component
*/
CustomTime.prototype.subscribe = function (component, event) {
var me = this;
var listener = {
component: component,
event: event,
callback: function (event) {
me._onMouseDown(event, listener);
},
params: {}
};
component.on('mousedown', listener.callback);
me.listeners.push(listener);
};
/**
* 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.
*/
CustomTime.prototype.on = function (event, callback) {
var bar = this.frame;
if (!bar) {
throw new Error('Cannot add event listener: no parent attached');
}
events.addListener(this, event, callback);
util.addEventListener(bar, event, callback);
};
/**
* Start moving horizontally
* @param {Event} event
* @param {Object} listener Listener containing the component and params
* @private
*/
CustomTime.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.moved = false;
params.customTime = this.customTime;
// add event listeners to handle moving the custom time bar
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.stopPropagation(event);
util.preventDefault(event);
};
/**
* Perform moving operating.
* This function activated from within the funcion CustomTime._onMouseDown().
* @param {Event} event
* @param {Object} listener
* @private
*/
CustomTime.prototype._onMouseMove = function (event, listener) {
event = event || window.event;
var params = listener.params;
var parent = this.parent;
// calculate change in mouse position
var mouseX = util.getPageX(event);
if (params.mouseX === undefined) {
params.mouseX = mouseX;
}
var diff = mouseX - params.mouseX;
// if mouse movement is big enough, register it as a "moved" event
if (Math.abs(diff) >= 1) {
params.moved = true;
}
var x = parent.toScreen(params.customTime);
var xnew = x + diff;
var time = parent.toTime(xnew);
this._setCustomTime(time);
// fire a timechange event
events.trigger(this, 'timechange', {customTime: this.customTime});
util.preventDefault(event);
};
/**
* Stop moving operating.
* This function activated from within the function CustomTime._onMouseDown().
* @param {event} event
* @param {Object} listener
* @private
*/
CustomTime.prototype._onMouseUp = function (event, listener) {
event = event || window.event;
var params = listener.params;
// 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;
}
if (params.moved) {
// fire a timechanged event
events.trigger(this, 'timechanged', {customTime: this.customTime});
}
};
/**
* An ItemSet holds a set of items and ranges which can be displayed in a
* range. The width is determined by the parent of the ItemSet, and the height
* is determined by the size of the items.
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] See ItemSet.setOptions for the available
* options.
* @constructor ItemSet
* @extends Panel
*/
// TODO: improve performance by replacing all Array.forEach with a for loop
function ItemSet(parent, depends, options) {
this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
// one options object is shared by this itemset and all its items
this.options = options || {};
this.defaultOptions = {
type: 'box',
align: 'center',
orientation: 'bottom',
margin: {
axis: 20,
item: 10
},
padding: 5
};
this.dom = {};
var me = this;
this.itemsData = null; // DataSet
this.range = null; // Range or Object {start: number, end: number}
this.listeners = {
'add': function (event, params, senderId) {
if (senderId != me.id) {
me._onAdd(params.items);
}
},
'update': function (event, params, senderId) {
if (senderId != me.id) {
me._onUpdate(params.items);
}
},
'remove': function (event, params, senderId) {
if (senderId != me.id) {
me._onRemove(params.items);
}
}
};
this.items = {}; // object with an Item for every data item
this.queue = {}; // queue with id/actions: 'add', 'update', 'delete'
this.stack = new Stack(this, Object.create(this.options));
this.conversion = null;
// TODO: ItemSet should also attach event listeners for rangechange and rangechanged, like timeaxis
}
ItemSet.prototype = new Panel();
// available item types will be registered here
ItemSet.types = {
box: ItemBox,
range: ItemRange,
rangeoverflow: ItemRangeOverflow,
point: ItemPoint
};
/**
* Set options for the ItemSet. Existing options will be extended/overwritten.
* @param {Object} [options] The following options are available:
* {String | function} [className]
* class name for the itemset
* {String} [type]
* Default type for the items. Choose from 'box'
* (default), 'point', or 'range'. The default
* Style can be overwritten by individual items.
* {String} align
* Alignment for the items, only applicable for
* ItemBox. Choose 'center' (default), 'left', or
* 'right'.
* {String} orientation
* Orientation of the item set. Choose 'top' or
* 'bottom' (default).
* {Number} margin.axis
* Margin between the axis and the items in pixels.
* Default is 20.
* {Number} margin.item
* Margin between items in pixels. Default is 10.
* {Number} padding
* Padding of the contents of an item in pixels.
* Must correspond with the items css. Default is 5.
*/
ItemSet.prototype.setOptions = Component.prototype.setOptions;
/**
* Set range (start and end).
* @param {Range | Object} range A Range or an object containing start and end.
*/
ItemSet.prototype.setRange = function setRange(range) {
if (!(range instanceof Range) && (!range || !range.start || !range.end)) {
throw new TypeError('Range must be an instance of Range, ' +
'or an object containing start and end.');
}
this.range = range;
};
/**
* Repaint the component
* @return {Boolean} changed
*/
ItemSet.prototype.repaint = function repaint() {
var changed = 0,
update = util.updateProperty,
asSize = util.option.asSize,
options = this.options,
orientation = this.getOption('orientation'),
defaultOptions = this.defaultOptions,
frame = this.frame;
if (!frame) {
frame = document.createElement('div');
frame.className = 'itemset';
var className = options.className;
if (className) {
util.addClassName(frame, util.option.asString(className));
}
// create background panel
var background = document.createElement('div');
background.className = 'background';
frame.appendChild(background);
this.dom.background = background;
// create foreground panel
var foreground = document.createElement('div');
foreground.className = 'foreground';
frame.appendChild(foreground);
this.dom.foreground = foreground;
// create axis panel
var axis = document.createElement('div');
axis.className = 'itemset-axis';
//frame.appendChild(axis);
this.dom.axis = axis;
this.frame = frame;
changed += 1;
}
if (!this.parent) {
throw new Error('Cannot repaint itemset: no parent attached');
}
var parentContainer = this.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint itemset: parent has no container element');
}
if (!frame.parentNode) {
parentContainer.appendChild(frame);
changed += 1;
}
if (!this.dom.axis.parentNode) {
parentContainer.appendChild(this.dom.axis);
changed += 1;
}
// reposition frame
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'top', asSize(options.top, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
changed += update(frame.style, 'height', asSize(options.height, this.height + 'px'));
// reposition axis
changed += update(this.dom.axis.style, 'left', asSize(options.left, '0px'));
changed += update(this.dom.axis.style, 'width', asSize(options.width, '100%'));
if (orientation == 'bottom') {
changed += update(this.dom.axis.style, 'top', (this.height + this.top) + 'px');
}
else { // orientation == 'top'
changed += update(this.dom.axis.style, 'top', this.top + 'px');
}
this._updateConversion();
var me = this,
queue = this.queue,
itemsData = this.itemsData,
items = this.items,
dataOptions = {
// TODO: cleanup
// fields: [(itemsData && itemsData.fieldId || 'id'), 'start', 'end', 'content', 'type', 'className']
};
// show/hide added/changed/removed items
Object.keys(queue).forEach(function (id) {
//var entry = queue[id];
var action = queue[id];
var item = items[id];
//var item = entry.item;
//noinspection FallthroughInSwitchStatementJS
switch (action) {
case 'add':
case 'update':
var itemData = itemsData && itemsData.get(id, dataOptions);
if (itemData) {
var type = itemData.type ||
(itemData.start && itemData.end && 'range') ||
options.type ||
'box';
var constructor = ItemSet.types[type];
// TODO: how to handle items with invalid data? hide them and give a warning? or throw an error?
if (item) {
// update item
if (!constructor || !(item instanceof constructor)) {
// item type has changed, hide and delete the item
changed += item.hide();
item = null;
}
else {
item.data = itemData; // TODO: create a method item.setData ?
changed++;
}
}
if (!item) {
// create item
if (constructor) {
item = new constructor(me, itemData, options, defaultOptions);
changed++;
}
else {
throw new TypeError('Unknown item type "' + type + '"');
}
}
// force a repaint (not only a reposition)
item.repaint();
items[id] = item;
}
// update queue
delete queue[id];
break;
case 'remove':
if (item) {
// remove DOM of the item
changed += item.hide();
}
// update lists
delete items[id];
delete queue[id];
break;
default:
console.log('Error: unknown action "' + action + '"');
}
});
// reposition all items. Show items only when in the visible area
util.forEach(this.items, function (item) {
if (item.visible) {
changed += item.show();
item.reposition();
}
else {
changed += item.hide();
}
});
return (changed > 0);
};
/**
* Get the foreground container element
* @return {HTMLElement} foreground
*/
ItemSet.prototype.getForeground = function getForeground() {
return this.dom.foreground;
};
/**
* Get the background container element
* @return {HTMLElement} background
*/
ItemSet.prototype.getBackground = function getBackground() {
return this.dom.background;
};
/**
* Get the axis container element
* @return {HTMLElement} axis
*/
ItemSet.prototype.getAxis = function getAxis() {
return this.dom.axis;
};
/**
* Reflow the component
* @return {Boolean} resized
*/
ItemSet.prototype.reflow = function reflow () {
var changed = 0,
options = this.options,
marginAxis = options.margin && options.margin.axis || this.defaultOptions.margin.axis,
marginItem = options.margin && options.margin.item || this.defaultOptions.margin.item,
update = util.updateProperty,
asNumber = util.option.asNumber,
asSize = util.option.asSize,
frame = this.frame;
if (frame) {
this._updateConversion();
util.forEach(this.items, function (item) {
changed += item.reflow();
});
// TODO: stack.update should be triggered via an event, in stack itself
// TODO: only update the stack when there are changed items
this.stack.update();
var maxHeight = asNumber(options.maxHeight);
var fixedHeight = (asSize(options.height) != null);
var height;
if (fixedHeight) {
height = frame.offsetHeight;
}
else {
// height is not specified, determine the height from the height and positioned items
var visibleItems = this.stack.ordered; // TODO: not so nice way to get the filtered items
if (visibleItems.length) {
var min = visibleItems[0].top;
var max = visibleItems[0].top + visibleItems[0].height;
util.forEach(visibleItems, function (item) {
min = Math.min(min, item.top);
max = Math.max(max, (item.top + item.height));
});
height = (max - min) + marginAxis + marginItem;
}
else {
height = marginAxis + marginItem;
}
}
if (maxHeight != null) {
height = Math.min(height, maxHeight);
}
changed += update(this, 'height', height);
// calculate height from items
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
changed += update(this, 'width', frame.offsetWidth);
}
else {
changed += 1;
}
return (changed > 0);
};
/**
* Hide this component from the DOM
* @return {Boolean} changed
*/
ItemSet.prototype.hide = function hide() {
var changed = false;
// remove the DOM
if (this.frame && this.frame.parentNode) {
this.frame.parentNode.removeChild(this.frame);
changed = true;
}
if (this.dom.axis && this.dom.axis.parentNode) {
this.dom.axis.parentNode.removeChild(this.dom.axis);
changed = true;
}
return changed;
};
/**
* Set items
* @param {vis.DataSet | null} items
*/
ItemSet.prototype.setItems = function setItems(items) {
var me = this,
ids,
oldItemsData = this.itemsData;
// replace the dataset
if (!items) {
this.itemsData = null;
}
else if (items instanceof DataSet || items instanceof DataView) {
this.itemsData = items;
}
else {
throw new TypeError('Data must be an instance of DataSet');
}
if (oldItemsData) {
// unsubscribe from old dataset
util.forEach(this.listeners, function (callback, event) {
oldItemsData.unsubscribe(event, callback);
});
// remove all drawn items
ids = oldItemsData.getIds();
this._onRemove(ids);
}
if (this.itemsData) {
// subscribe to new dataset
var id = this.id;
util.forEach(this.listeners, function (callback, event) {
me.itemsData.subscribe(event, callback, id);
});
// draw all new items
ids = this.itemsData.getIds();
this._onAdd(ids);
}
};
/**
* Get the current items items
* @returns {vis.DataSet | null}
*/
ItemSet.prototype.getItems = function getItems() {
return this.itemsData;
};
/**
* Handle updated items
* @param {Number[]} ids
* @private
*/
ItemSet.prototype._onUpdate = function _onUpdate(ids) {
this._toQueue('update', ids);
};
/**
* Handle changed items
* @param {Number[]} ids
* @private
*/
ItemSet.prototype._onAdd = function _onAdd(ids) {
this._toQueue('add', ids);
};
/**
* Handle removed items
* @param {Number[]} ids
* @private
*/
ItemSet.prototype._onRemove = function _onRemove(ids) {
this._toQueue('remove', ids);
};
/**
* Put items in the queue to be added/updated/remove
* @param {String} action can be 'add', 'update', 'remove'
* @param {Number[]} ids
*/
ItemSet.prototype._toQueue = function _toQueue(action, ids) {
var queue = this.queue;
ids.forEach(function (id) {
queue[id] = action;
});
if (this.controller) {
//this.requestReflow();
this.requestRepaint();
}
};
/**
* Calculate the scale and offset to convert a position on screen to the
* corresponding date and vice versa.
* After the method _updateConversion is executed once, the methods toTime
* and toScreen can be used.
* @private
*/
ItemSet.prototype._updateConversion = function _updateConversion() {
var range = this.range;
if (!range) {
throw new Error('No range configured');
}
if (range.conversion) {
this.conversion = range.conversion(this.width);
}
else {
this.conversion = Range.conversion(range.start, range.end, this.width);
}
};
/**
* Convert a position on screen (pixels) to a datetime
* Before this method can be used, the method _updateConversion must be
* executed once.
* @param {int} x Position on the screen in pixels
* @return {Date} time The datetime the corresponds with given position x
*/
ItemSet.prototype.toTime = function toTime(x) {
var conversion = this.conversion;
return new Date(x / conversion.scale + conversion.offset);
};
/**
* Convert a datetime (Date object) into a position on the screen
* Before this method can be used, the method _updateConversion must be
* executed once.
* @param {Date} time A date
* @return {int} x The position on the screen in pixels which corresponds
* with the given date.
*/
ItemSet.prototype.toScreen = function toScreen(time) {
var conversion = this.conversion;
return (time.valueOf() - conversion.offset) * conversion.scale;
};
/**
* @constructor Item
* @param {ItemSet} parent
* @param {Object} data Object containing (optional) parameters type,
* start, end, content, group, className.
* @param {Object} [options] Options to set initial property values
* @param {Object} [defaultOptions] default options
* // TODO: describe available options
*/
function Item (parent, data, options, defaultOptions) {
this.parent = parent;
this.data = data;
this.dom = null;
this.options = options || {};
this.defaultOptions = defaultOptions || {};
this.selected = false;
this.visible = false;
this.top = 0;
this.left = 0;
this.width = 0;
this.height = 0;
}
/**
* Select current item
*/
Item.prototype.select = function select() {
this.selected = true;
};
/**
* Unselect current item
*/
Item.prototype.unselect = function unselect() {
this.selected = false;
};
/**
* Show the Item in the DOM (when not already visible)
* @return {Boolean} changed
*/
Item.prototype.show = function show() {
return false;
};
/**
* Hide the Item from the DOM (when visible)
* @return {Boolean} changed
*/
Item.prototype.hide = function hide() {
return false;
};
/**
* Repaint the item
* @return {Boolean} changed
*/
Item.prototype.repaint = function repaint() {
// should be implemented by the item
return false;
};
/**
* Reflow the item
* @return {Boolean} resized
*/
Item.prototype.reflow = function reflow() {
// should be implemented by the item
return false;
};
/**
* Return the items width
* @return {Integer} width
*/
Item.prototype.getWidth = function getWidth() {
return this.width;
}
/**
* @constructor ItemBox
* @extends Item
* @param {ItemSet} parent
* @param {Object} data Object containing parameters start
* content, className.
* @param {Object} [options] Options to set initial property values
* @param {Object} [defaultOptions] default options
* // TODO: describe available options
*/
function ItemBox (parent, data, options, defaultOptions) {
this.props = {
dot: {
left: 0,
top: 0,
width: 0,
height: 0
},
line: {
top: 0,
left: 0,
width: 0,
height: 0
}
};
Item.call(this, parent, data, options, defaultOptions);
}
ItemBox.prototype = new Item (null, null);
/**
* Select the item
* @override
*/
ItemBox.prototype.select = function select() {
this.selected = true;
// TODO: select and unselect
};
/**
* Unselect the item
* @override
*/
ItemBox.prototype.unselect = function unselect() {
this.selected = false;
// TODO: select and unselect
};
/**
* Repaint the item
* @return {Boolean} changed
*/
ItemBox.prototype.repaint = function repaint() {
// TODO: make an efficient repaint
var changed = false;
var dom = this.dom;
if (!dom) {
this._create();
dom = this.dom;
changed = true;
}
if (dom) {
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
if (!dom.box.parentNode) {
var foreground = this.parent.getForeground();
if (!foreground) {
throw new Error('Cannot repaint time axis: ' +
'parent has no foreground container element');
}
foreground.appendChild(dom.box);
changed = true;
}
if (!dom.line.parentNode) {
var background = this.parent.getBackground();
if (!background) {
throw new Error('Cannot repaint time axis: ' +
'parent has no background container element');
}
background.appendChild(dom.line);
changed = true;
}
if (!dom.dot.parentNode) {
var axis = this.parent.getAxis();
if (!background) {
throw new Error('Cannot repaint time axis: ' +
'parent has no axis container element');
}
axis.appendChild(dom.dot);
changed = true;
}
// update contents
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
}
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
}
else {
throw new Error('Property "content" missing in item ' + this.data.id);
}
changed = true;
}
// update class
var className = (this.data.className? ' ' + this.data.className : '') +
(this.selected ? ' selected' : '');
if (this.className != className) {
this.className = className;
dom.box.className = 'item box' + className;
dom.line.className = 'item line' + className;
dom.dot.className = 'item dot' + className;
changed = true;
}
}
return changed;
};
/**
* Show the item in the DOM (when not already visible). The items DOM will
* be created when needed.
* @return {Boolean} changed
*/
ItemBox.prototype.show = function show() {
if (!this.dom || !this.dom.box.parentNode) {
return this.repaint();
}
else {
return false;
}
};
/**
* Hide the item from the DOM (when visible)
* @return {Boolean} changed
*/
ItemBox.prototype.hide = function hide() {
var changed = false,
dom = this.dom;
if (dom) {
if (dom.box.parentNode) {
dom.box.parentNode.removeChild(dom.box);
changed = true;
}
if (dom.line.parentNode) {
dom.line.parentNode.removeChild(dom.line);
}
if (dom.dot.parentNode) {
dom.dot.parentNode.removeChild(dom.dot);
}
}
return changed;
};
/**
* Reflow the item: calculate its actual size and position from the DOM
* @return {boolean} resized returns true if the axis is resized
* @override
*/
ItemBox.prototype.reflow = function reflow() {
var changed = 0,
update,
dom,
props,
options,
margin,
start,
align,
orientation,
top,
left,
data,
range;
if (this.data.start == undefined) {
throw new Error('Property "start" missing in item ' + this.data.id);
}
data = this.data;
range = this.parent && this.parent.range;
if (data && range) {
// TODO: account for the width of the item
var interval = (range.end - range.start);
this.visible = (data.start > range.start - interval) && (data.start < range.end + interval);
}
else {
this.visible = false;
}
if (this.visible) {
dom = this.dom;
if (dom) {
update = util.updateProperty;
props = this.props;
options = this.options;
start = this.parent.toScreen(this.data.start);
align = options.align || this.defaultOptions.align;
margin = options.margin && options.margin.axis || this.defaultOptions.margin.axis;
orientation = options.orientation || this.defaultOptions.orientation;
changed += update(props.dot, 'height', dom.dot.offsetHeight);
changed += update(props.dot, 'width', dom.dot.offsetWidth);
changed += update(props.line, 'width', dom.line.offsetWidth);
changed += update(props.line, 'height', dom.line.offsetHeight);
changed += update(props.line, 'top', dom.line.offsetTop);
changed += update(this, 'width', dom.box.offsetWidth);
changed += update(this, 'height', dom.box.offsetHeight);
if (align == 'right') {
left = start - this.width;
}
else if (align == 'left') {
left = start;
}
else {
// default or 'center'
left = start - this.width / 2;
}
changed += update(this, 'left', left);
changed += update(props.line, 'left', start - props.line.width / 2);
changed += update(props.dot, 'left', start - props.dot.width / 2);
changed += update(props.dot, 'top', -props.dot.height / 2);
if (orientation == 'top') {
top = margin;
changed += update(this, 'top', top);
}
else {
// default or 'bottom'
var parentHeight = this.parent.height;
top = parentHeight - this.height - margin;
changed += update(this, 'top', top);
}
}
else {
changed += 1;
}
}
return (changed > 0);
};
/**
* Create an items DOM
* @private
*/
ItemBox.prototype._create = function _create() {
var dom = this.dom;
if (!dom) {
this.dom = dom = {};
// create the box
dom.box = document.createElement('DIV');
// className is updated in repaint()
// contents box (inside the background box). used for making margins
dom.content = document.createElement('DIV');
dom.content.className = 'content';
dom.box.appendChild(dom.content);
// line to axis
dom.line = document.createElement('DIV');
dom.line.className = 'line';
// dot on axis
dom.dot = document.createElement('DIV');
dom.dot.className = 'dot';
}
};
/**
* Reposition the item, recalculate its left, top, and width, using the current
* range and size of the items itemset
* @override
*/
ItemBox.prototype.reposition = function reposition() {
var dom = this.dom,
props = this.props,
orientation = this.options.orientation || this.defaultOptions.orientation;
if (dom) {
var box = dom.box,
line = dom.line,
dot = dom.dot;
box.style.left = this.left + 'px';
box.style.top = this.top + 'px';
line.style.left = props.line.left + 'px';
if (orientation == 'top') {
line.style.top = 0 + 'px';
line.style.height = this.top + 'px';
}
else {
// orientation 'bottom'
line.style.top = (this.top + this.height) + 'px';
line.style.height = Math.max(this.parent.height - this.top - this.height +
this.props.dot.height / 2, 0) + 'px';
}
dot.style.left = props.dot.left + 'px';
dot.style.top = props.dot.top + 'px';
}
};
/**
* @constructor ItemPoint
* @extends Item
* @param {ItemSet} parent
* @param {Object} data Object containing parameters start
* content, className.
* @param {Object} [options] Options to set initial property values
* @param {Object} [defaultOptions] default options
* // TODO: describe available options
*/
function ItemPoint (parent, data, options, defaultOptions) {
this.props = {
dot: {
top: 0,
width: 0,
height: 0
},
content: {
height: 0,
marginLeft: 0
}
};
Item.call(this, parent, data, options, defaultOptions);
}
ItemPoint.prototype = new Item (null, null);
/**
* Select the item
* @override
*/
ItemPoint.prototype.select = function select() {
this.selected = true;
// TODO: select and unselect
};
/**
* Unselect the item
* @override
*/
ItemPoint.prototype.unselect = function unselect() {
this.selected = false;
// TODO: select and unselect
};
/**
* Repaint the item
* @return {Boolean} changed
*/
ItemPoint.prototype.repaint = function repaint() {
// TODO: make an efficient repaint
var changed = false;
var dom = this.dom;
if (!dom) {
this._create();
dom = this.dom;
changed = true;
}
if (dom) {
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
var foreground = this.parent.getForeground();
if (!foreground) {
throw new Error('Cannot repaint time axis: ' +
'parent has no foreground container element');
}
if (!dom.point.parentNode) {
foreground.appendChild(dom.point);
foreground.appendChild(dom.point);
changed = true;
}
// update contents
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
}
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
}
else {
throw new Error('Property "content" missing in item ' + this.data.id);
}
changed = true;
}
// update class
var className = (this.data.className? ' ' + this.data.className : '') +
(this.selected ? ' selected' : '');
if (this.className != className) {
this.className = className;
dom.point.className = 'item point' + className;
changed = true;
}
}
return changed;
};
/**
* Show the item in the DOM (when not already visible). The items DOM will
* be created when needed.
* @return {Boolean} changed
*/
ItemPoint.prototype.show = function show() {
if (!this.dom || !this.dom.point.parentNode) {
return this.repaint();
}
else {
return false;
}
};
/**
* Hide the item from the DOM (when visible)
* @return {Boolean} changed
*/
ItemPoint.prototype.hide = function hide() {
var changed = false,
dom = this.dom;
if (dom) {
if (dom.point.parentNode) {
dom.point.parentNode.removeChild(dom.point);
changed = true;
}
}
return changed;
};
/**
* Reflow the item: calculate its actual size from the DOM
* @return {boolean} resized returns true if the axis is resized
* @override
*/
ItemPoint.prototype.reflow = function reflow() {
var changed = 0,
update,
dom,
props,
options,
margin,
orientation,
start,
top,
data,
range;
if (this.data.start == undefined) {
throw new Error('Property "start" missing in item ' + this.data.id);
}
data = this.data;
range = this.parent && this.parent.range;
if (data && range) {
// TODO: account for the width of the item
var interval = (range.end - range.start);
this.visible = (data.start > range.start - interval) && (data.start < range.end);
}
else {
this.visible = false;
}
if (this.visible) {
dom = this.dom;
if (dom) {
update = util.updateProperty;
props = this.props;
options = this.options;
orientation = options.orientation || this.defaultOptions.orientation;
margin = options.margin && options.margin.axis || this.defaultOptions.margin.axis;
start = this.parent.toScreen(this.data.start);
changed += update(this, 'width', dom.point.offsetWidth);
changed += update(this, 'height', dom.point.offsetHeight);
changed += update(props.dot, 'width', dom.dot.offsetWidth);
changed += update(props.dot, 'height', dom.dot.offsetHeight);
changed += update(props.content, 'height', dom.content.offsetHeight);
if (orientation == 'top') {
top = margin;
}
else {
// default or 'bottom'
var parentHeight = this.parent.height;
top = Math.max(parentHeight - this.height - margin, 0);
}
changed += update(this, 'top', top);
changed += update(this, 'left', start - props.dot.width / 2);
changed += update(props.content, 'marginLeft', 1.5 * props.dot.width);
//changed += update(props.content, 'marginRight', 0.5 * props.dot.width); // TODO
changed += update(props.dot, 'top', (this.height - props.dot.height) / 2);
}
else {
changed += 1;
}
}
return (changed > 0);
};
/**
* Create an items DOM
* @private
*/
ItemPoint.prototype._create = function _create() {
var dom = this.dom;
if (!dom) {
this.dom = dom = {};
// background box
dom.point = document.createElement('div');
// className is updated in repaint()
// contents box, right from the dot
dom.content = document.createElement('div');
dom.content.className = 'content';
dom.point.appendChild(dom.content);
// dot at start
dom.dot = document.createElement('div');
dom.dot.className = 'dot';
dom.point.appendChild(dom.dot);
}
};
/**
* Reposition the item, recalculate its left, top, and width, using the current
* range and size of the items itemset
* @override
*/
ItemPoint.prototype.reposition = function reposition() {
var dom = this.dom,
props = this.props;
if (dom) {
dom.point.style.top = this.top + 'px';
dom.point.style.left = this.left + 'px';
dom.content.style.marginLeft = props.content.marginLeft + 'px';
//dom.content.style.marginRight = props.content.marginRight + 'px'; // TODO
dom.dot.style.top = props.dot.top + 'px';
}
};
/**
* @constructor ItemRange
* @extends Item
* @param {ItemSet} parent
* @param {Object} data Object containing parameters start, end
* content, className.
* @param {Object} [options] Options to set initial property values
* @param {Object} [defaultOptions] default options
* // TODO: describe available options
*/
function ItemRange (parent, data, options, defaultOptions) {
this.props = {
content: {
left: 0,
width: 0
}
};
Item.call(this, parent, data, options, defaultOptions);
}
ItemRange.prototype = new Item (null, null);
/**
* Select the item
* @override
*/
ItemRange.prototype.select = function select() {
this.selected = true;
// TODO: select and unselect
};
/**
* Unselect the item
* @override
*/
ItemRange.prototype.unselect = function unselect() {
this.selected = false;
// TODO: select and unselect
};
/**
* Repaint the item
* @return {Boolean} changed
*/
ItemRange.prototype.repaint = function repaint() {
// TODO: make an efficient repaint
var changed = false;
var dom = this.dom;
if (!dom) {
this._create();
dom = this.dom;
changed = true;
}
if (dom) {
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
var foreground = this.parent.getForeground();
if (!foreground) {
throw new Error('Cannot repaint time axis: ' +
'parent has no foreground container element');
}
if (!dom.box.parentNode) {
foreground.appendChild(dom.box);
changed = true;
}
// update content
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
}
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
}
else {
throw new Error('Property "content" missing in item ' + this.data.id);
}
changed = true;
}
// update class
var className = this.data.className ? (' ' + this.data.className) : '';
if (this.className != className) {
this.className = className;
dom.box.className = 'item range' + className;
changed = true;
}
}
return changed;
};
/**
* Show the item in the DOM (when not already visible). The items DOM will
* be created when needed.
* @return {Boolean} changed
*/
ItemRange.prototype.show = function show() {
if (!this.dom || !this.dom.box.parentNode) {
return this.repaint();
}
else {
return false;
}
};
/**
* Hide the item from the DOM (when visible)
* @return {Boolean} changed
*/
ItemRange.prototype.hide = function hide() {
var changed = false,
dom = this.dom;
if (dom) {
if (dom.box.parentNode) {
dom.box.parentNode.removeChild(dom.box);
changed = true;
}
}
return changed;
};
/**
* Reflow the item: calculate its actual size from the DOM
* @return {boolean} resized returns true if the axis is resized
* @override
*/
ItemRange.prototype.reflow = function reflow() {
var changed = 0,
dom,
props,
options,
margin,
padding,
parent,
start,
end,
data,
range,
update,
box,
parentWidth,
contentLeft,
orientation,
top;
if (this.data.start == undefined) {
throw new Error('Property "start" missing in item ' + this.data.id);
}
if (this.data.end == undefined) {
throw new Error('Property "end" missing in item ' + this.data.id);
}
data = this.data;
range = this.parent && this.parent.range;
if (data && range) {
// TODO: account for the width of the item. Take some margin
this.visible = (data.start < range.end) && (data.end > range.start);
}
else {
this.visible = false;
}
if (this.visible) {
dom = this.dom;
if (dom) {
props = this.props;
options = this.options;
parent = this.parent;
start = parent.toScreen(this.data.start);
end = parent.toScreen(this.data.end);
update = util.updateProperty;
box = dom.box;
parentWidth = parent.width;
orientation = options.orientation || this.defaultOptions.orientation;
margin = options.margin && options.margin.axis || this.defaultOptions.margin.axis;
padding = options.padding || this.defaultOptions.padding;
changed += update(props.content, 'width', dom.content.offsetWidth);
changed += update(this, 'height', box.offsetHeight);
// limit the width of the this, as browsers cannot draw very wide divs
if (start < -parentWidth) {
start = -parentWidth;
}
if (end > 2 * parentWidth) {
end = 2 * parentWidth;
}
// when range exceeds left of the window, position the contents at the left of the visible area
if (start < 0) {
contentLeft = Math.min(-start,
(end - start - props.content.width - 2 * padding));
// TODO: remove the need for options.padding. it's terrible.
}
else {
contentLeft = 0;
}
changed += update(props.content, 'left', contentLeft);
if (orientation == 'top') {
top = margin;
changed += update(this, 'top', top);
}
else {
// default or 'bottom'
top = parent.height - this.height - margin;
changed += update(this, 'top', top);
}
changed += update(this, 'left', start);
changed += update(this, 'width', Math.max(end - start, 1)); // TODO: reckon with border width;
}
else {
changed += 1;
}
}
return (changed > 0);
};
/**
* Create an items DOM
* @private
*/
ItemRange.prototype._create = function _create() {
var dom = this.dom;
if (!dom) {
this.dom = dom = {};
// background box
dom.box = document.createElement('div');
// className is updated in repaint()
// contents box
dom.content = document.createElement('div');
dom.content.className = 'content';
dom.box.appendChild(dom.content);
}
};
/**
* Reposition the item, recalculate its left, top, and width, using the current
* range and size of the items itemset
* @override
*/
ItemRange.prototype.reposition = function reposition() {
var dom = this.dom,
props = this.props;
if (dom) {
dom.box.style.top = this.top + 'px';
dom.box.style.left = this.left + 'px';
dom.box.style.width = this.width + 'px';
dom.content.style.left = props.content.left + 'px';
}
};
/**
* @constructor ItemRangeOverflow
* @extends ItemRange
* @param {ItemSet} parent
* @param {Object} data Object containing parameters start, end
* content, className.
* @param {Object} [options] Options to set initial property values
* @param {Object} [defaultOptions] default options
* // TODO: describe available options
*/
function ItemRangeOverflow (parent, data, options, defaultOptions) {
this.props = {
content: {
left: 0,
width: 0
}
};
ItemRange.call(this, parent, data, options, defaultOptions);
}
ItemRangeOverflow.prototype = new ItemRange (null, null);
/**
* Repaint the item
* @return {Boolean} changed
*/
ItemRangeOverflow.prototype.repaint = function repaint() {
// TODO: make an efficient repaint
var changed = false;
var dom = this.dom;
if (!dom) {
this._create();
dom = this.dom;
changed = true;
}
if (dom) {
if (!this.parent) {
throw new Error('Cannot repaint item: no parent attached');
}
var foreground = this.parent.getForeground();
if (!foreground) {
throw new Error('Cannot repaint time axis: ' +
'parent has no foreground container element');
}
if (!dom.box.parentNode) {
foreground.appendChild(dom.box);
changed = true;
}
// update content
if (this.data.content != this.content) {
this.content = this.data.content;
if (this.content instanceof Element) {
dom.content.innerHTML = '';
dom.content.appendChild(this.content);
}
else if (this.data.content != undefined) {
dom.content.innerHTML = this.content;
}
else {
throw new Error('Property "content" missing in item ' + this.data.id);
}
changed = true;
}
// update class
var className = this.data.className ? (' ' + this.data.className) : '';
if (this.className != className) {
this.className = className;
dom.box.className = 'item rangeoverflow' + className;
changed = true;
}
}
return changed;
};
/**
* Return the items width
* @return {Number} width
*/
ItemRangeOverflow.prototype.getWidth = function getWidth() {
if (this.props.content !== undefined && this.width < this.props.content.width)
return this.props.content.width;
else
return this.width;
};
/**
* @constructor Group
* @param {GroupSet} parent
* @param {Number | String} groupId
* @param {Object} [options] Options to set initial property values
* // TODO: describe available options
* @extends Component
*/
function Group (parent, groupId, options) {
this.id = util.randomUUID();
this.parent = parent;
this.groupId = groupId;
this.itemset = null; // ItemSet
this.options = options || {};
this.options.top = 0;
this.props = {
label: {
width: 0,
height: 0
}
};
this.top = 0;
this.left = 0;
this.width = 0;
this.height = 0;
}
Group.prototype = new Component();
// TODO: comment
Group.prototype.setOptions = Component.prototype.setOptions;
/**
* Get the container element of the panel, which can be used by a child to
* add its own widgets.
* @returns {HTMLElement} container
*/
Group.prototype.getContainer = function () {
return this.parent.getContainer();
};
/**
* Set item set for the group. The group will create a view on the itemset,
* filtered by the groups id.
* @param {DataSet | DataView} items
*/
Group.prototype.setItems = function setItems(items) {
if (this.itemset) {
// remove current item set
this.itemset.hide();
this.itemset.setItems();
this.parent.controller.remove(this.itemset);
this.itemset = null;
}
if (items) {
var groupId = this.groupId;
var itemsetOptions = Object.create(this.options);
this.itemset = new ItemSet(this, null, itemsetOptions);
this.itemset.setRange(this.parent.range);
this.view = new DataView(items, {
filter: function (item) {
return item.group == groupId;
}
});
this.itemset.setItems(this.view);
this.parent.controller.add(this.itemset);
}
};
/**
* Repaint the item
* @return {Boolean} changed
*/
Group.prototype.repaint = function repaint() {
return false;
};
/**
* Reflow the item
* @return {Boolean} resized
*/
Group.prototype.reflow = function reflow() {
var changed = 0,
update = util.updateProperty;
changed += update(this, 'top', this.itemset ? this.itemset.top : 0);
changed += update(this, 'height', this.itemset ? this.itemset.height : 0);
// TODO: reckon with the height of the group label
if (this.label) {
var inner = this.label.firstChild;
changed += update(this.props.label, 'width', inner.clientWidth);
changed += update(this.props.label, 'height', inner.clientHeight);
}
else {
changed += update(this.props.label, 'width', 0);
changed += update(this.props.label, 'height', 0);
}
return (changed > 0);
};
/**
* An GroupSet holds a set of groups
* @param {Component} parent
* @param {Component[]} [depends] Components on which this components depends
* (except for the parent)
* @param {Object} [options] See GroupSet.setOptions for the available
* options.
* @constructor GroupSet
* @extends Panel
*/
function GroupSet(parent, depends, options) {
this.id = util.randomUUID();
this.parent = parent;
this.depends = depends;
this.options = options || {};
this.range = null; // Range or Object {start: number, end: number}
this.itemsData = null; // DataSet with items
this.groupsData = null; // DataSet with groups
this.groups = {}; // map with groups
this.dom = {};
this.props = {
labels: {
width: 0
}
};
// TODO: implement right orientation of the labels
// changes in groups are queued key/value map containing id/action
this.queue = {};
var me = this;
this.listeners = {
'add': function (event, params) {
me._onAdd(params.items);
},
'update': function (event, params) {
me._onUpdate(params.items);
},
'remove': function (event, params) {
me._onRemove(params.items);
}
};
}
GroupSet.prototype = new Panel();
/**
* Set options for the GroupSet. Existing options will be extended/overwritten.
* @param {Object} [options] The following options are available:
* {String | function} groupsOrder
* TODO: describe options
*/
GroupSet.prototype.setOptions = Component.prototype.setOptions;
GroupSet.prototype.setRange = function (range) {
// TODO: implement setRange
};
/**
* Set items
* @param {vis.DataSet | null} items
*/
GroupSet.prototype.setItems = function setItems(items) {
this.itemsData = items;
for (var id in this.groups) {
if (this.groups.hasOwnProperty(id)) {
var group = this.groups[id];
group.setItems(items);
}
}
};
/**
* Get items
* @return {vis.DataSet | null} items
*/
GroupSet.prototype.getItems = function getItems() {
return this.itemsData;
};
/**
* Set range (start and end).
* @param {Range | Object} range A Range or an object containing start and end.
*/
GroupSet.prototype.setRange = function setRange(range) {
this.range = range;
};
/**
* Set groups
* @param {vis.DataSet} groups
*/
GroupSet.prototype.setGroups = function setGroups(groups) {
var me = this,
ids;
// unsubscribe from current dataset
if (this.groupsData) {
util.forEach(this.listeners, function (callback, event) {
me.groupsData.unsubscribe(event, callback);
});
// remove all drawn groups
ids = this.groupsData.getIds();
this._onRemove(ids);
}
// replace the dataset
if (!groups) {
this.groupsData = null;
}
else if (groups instanceof DataSet) {
this.groupsData = groups;
}
else {
this.groupsData = new DataSet({
convert: {
start: 'Date',
end: 'Date'
}
});
this.groupsData.add(groups);
}
if (this.groupsData) {
// subscribe to new dataset
var id = this.id;
util.forEach(this.listeners, function (callback, event) {
me.groupsData.subscribe(event, callback, id);
});
// draw all new groups
ids = this.groupsData.getIds();
this._onAdd(ids);
}
};
/**
* Get groups
* @return {vis.DataSet | null} groups
*/
GroupSet.prototype.getGroups = function getGroups() {
return this.groupsData;
};
/**
* Repaint the component
* @return {Boolean} changed
*/
GroupSet.prototype.repaint = function repaint() {
var changed = 0,
i, id, group, label,
update = util.updateProperty,
asSize = util.option.asSize,
asElement = util.option.asElement,
options = this.options,
frame = this.dom.frame,
labels = this.dom.labels,
labelSet = this.dom.labelSet;
// create frame
if (!this.parent) {
throw new Error('Cannot repaint groupset: no parent attached');
}
var parentContainer = this.parent.getContainer();
if (!parentContainer) {
throw new Error('Cannot repaint groupset: parent has no container element');
}
if (!frame) {
frame = document.createElement('div');
frame.className = 'groupset';
this.dom.frame = frame;
var className = options.className;
if (className) {
util.addClassName(frame, util.option.asString(className));
}
changed += 1;
}
if (!frame.parentNode) {
parentContainer.appendChild(frame);
changed += 1;
}
// create labels
var labelContainer = asElement(options.labelContainer);
if (!labelContainer) {
throw new Error('Cannot repaint groupset: option "labelContainer" not defined');
}
if (!labels) {
labels = document.createElement('div');
labels.className = 'labels';
this.dom.labels = labels;
}
if (!labelSet) {
labelSet = document.createElement('div');
labelSet.className = 'label-set';
labels.appendChild(labelSet);
this.dom.labelSet = labelSet;
}
if (!labels.parentNode || labels.parentNode != labelContainer) {
if (labels.parentNode) {
labels.parentNode.removeChild(labels.parentNode);
}
labelContainer.appendChild(labels);
}
// reposition frame
changed += update(frame.style, 'height', asSize(options.height, this.height + 'px'));
changed += update(frame.style, 'top', asSize(options.top, '0px'));
changed += update(frame.style, 'left', asSize(options.left, '0px'));
changed += update(frame.style, 'width', asSize(options.width, '100%'));
// reposition labels
changed += update(labelSet.style, 'top', asSize(options.top, '0px'));
changed += update(labelSet.style, 'height', asSize(options.height, this.height + 'px'));
var me = this,
queue = this.queue,
groups = this.groups,
groupsData = this.groupsData;
// show/hide added/changed/removed groups
var ids = Object.keys(queue);
if (ids.length) {
ids.forEach(function (id) {
var action = queue[id];
var group = groups[id];
//noinspection FallthroughInSwitchStatementJS
switch (action) {
case 'add':
case 'update':
if (!group) {
var groupOptions = Object.create(me.options);
util.extend(groupOptions, {
height: null,
maxHeight: null
});
group = new Group(me, id, groupOptions);
group.setItems(me.itemsData); // attach items data
groups[id] = group;
me.controller.add(group);
}
// TODO: update group data
group.data = groupsData.get(id);
delete queue[id];
break;
case 'remove':
if (group) {
group.setItems(); // detach items data
delete groups[id];
me.controller.remove(group);
}
// update lists
delete queue[id];
break;
default:
console.log('Error: unknown action "' + action + '"');
}
});
// the groupset depends on each of the groups
//this.depends = this.groups; // TODO: gives a circular reference through the parent
// TODO: apply dependencies of the groupset
// update the top positions of the groups in the correct order
var orderedGroups = this.groupsData.getIds({
order: this.options.groupOrder
});
for (i = 0; i < orderedGroups.length; i++) {
(function (group, prevGroup) {
var top = 0;
if (prevGroup) {
top = function () {
// TODO: top must reckon with options.maxHeight
return prevGroup.top + prevGroup.height;
}
}
group.setOptions({
top: top
});
})(groups[orderedGroups[i]], groups[orderedGroups[i - 1]]);
}
// (re)create the labels
while (labelSet.firstChild) {
labelSet.removeChild(labelSet.firstChild);
}
for (i = 0; i < orderedGroups.length; i++) {
id = orderedGroups[i];
label = this._createLabel(id);
labelSet.appendChild(label);
}
changed++;
}
// reposition the labels
// TODO: labels are not displayed correctly when orientation=='top'
// TODO: width of labelPanel is not immediately updated on a change in groups
for (id in groups) {
if (groups.hasOwnProperty(id)) {
group = groups[id];
label = group.label;
if (label) {
label.style.top = group.top + 'px';
label.style.height = group.height + 'px';
}
}
}
return (changed > 0);
};
/**
* Create a label for group with given id
* @param {Number} id
* @return {Element} label
* @private
*/
GroupSet.prototype._createLabel = function(id) {
var group = this.groups[id];
var label = document.createElement('div');
label.className = 'label';
var inner = document.createElement('div');
inner.className = 'inner';
label.appendChild(inner);
var content = group.data && group.data.content;
if (content instanceof Element) {
inner.appendChild(content);
}
else if (content != undefined) {
inner.innerHTML = content;
}
var className = group.data && group.data.className;
if (className) {
util.addClassName(label, className);
}
group.label = label; // TODO: not so nice, parking labels in the group this way!!!
return label;
};
/**
* Get container element
* @return {HTMLElement} container
*/
GroupSet.prototype.getContainer = function getContainer() {
return this.dom.frame;
};
/**
* Get the width of the group labels
* @return {Number} width
*/
GroupSet.prototype.getLabelsWidth = function getContainer() {
return this.props.labels.width;
};
/**
* Reflow the component
* @return {Boolean} resized
*/
GroupSet.prototype.reflow = function reflow() {
var changed = 0,
id, group,
options = this.options,
update = util.updateProperty,
asNumber = util.option.asNumber,
asSize = util.option.asSize,
frame = this.dom.frame;
if (frame) {
var maxHeight = asNumber(options.maxHeight);
var fixedHeight = (asSize(options.height) != null);
var height;
if (fixedHeight) {
height = frame.offsetHeight;
}
else {
// height is not specified, calculate the sum of the height of all groups
height = 0;
for (id in this.groups) {
if (this.groups.hasOwnProperty(id)) {
group = this.groups[id];
height += group.height;
}
}
}
if (maxHeight != null) {
height = Math.min(height, maxHeight);
}
changed += update(this, 'height', height);
changed += update(this, 'top', frame.offsetTop);
changed += update(this, 'left', frame.offsetLeft);
changed += update(this, 'width', frame.offsetWidth);
}
// calculate the maximum width of the labels
var width = 0;
for (id in this.groups) {
if (this.groups.hasOwnProperty(id)) {
group = this.groups[id];
var labelWidth = group.props && group.props.label && group.props.label.width || 0;
width = Math.max(width, labelWidth);
}
}
changed += update(this.props.labels, 'width', width);
return (changed > 0);
};
/**
* Hide the component from the DOM
* @return {Boolean} changed
*/
GroupSet.prototype.hide = function hide() {
if (this.dom.frame && this.dom.frame.parentNode) {
this.dom.frame.parentNode.removeChild(this.dom.frame);
return true;
}
else {
return false;
}
};
/**
* Show the component in the DOM (when not already visible).
* A repaint will be executed when the component is not visible
* @return {Boolean} changed
*/
GroupSet.prototype.show = function show() {
if (!this.dom.frame || !this.dom.frame.parentNode) {
return this.repaint();
}
else {
return false;
}
};
/**
* Handle updated groups
* @param {Number[]} ids
* @private
*/
GroupSet.prototype._onUpdate = function _onUpdate(ids) {
this._toQueue(ids, 'update');
};
/**
* Handle changed groups
* @param {Number[]} ids
* @private
*/
GroupSet.prototype._onAdd = function _onAdd(ids) {
this._toQueue(ids, 'add');
};
/**
* Handle removed groups
* @param {Number[]} ids
* @private
*/
GroupSet.prototype._onRemove = function _onRemove(ids) {
this._toQueue(ids, 'remove');
};
/**
* Put groups in the queue to be added/updated/remove
* @param {Number[]} ids
* @param {String} action can be 'add', 'update', 'remove'
*/
GroupSet.prototype._toQueue = function _toQueue(ids, action) {
var queue = this.queue;
ids.forEach(function (id) {
queue[id] = action;
});
if (this.controller) {
//this.requestReflow();
this.requestRepaint();
}
};
/**
* Create a timeline visualization
* @param {HTMLElement} container
* @param {vis.DataSet | Array | DataTable} [items]
* @param {Object} [options] See Timeline.setOptions for the available options.
* @constructor
*/
function Timeline (container, items, options) {
var me = this;
var now = moment().hours(0).minutes(0).seconds(0).milliseconds(0);
this.options = {
orientation: 'bottom',
min: null,
max: null,
zoomMin: 10, // milliseconds
zoomMax: 1000 * 60 * 60 * 24 * 365 * 10000, // milliseconds
// moveable: true, // TODO: option moveable
// zoomable: true, // TODO: option zoomable
showMinorLabels: true,
showMajorLabels: true,
showCurrentTime: false,
showCustomTime: false,
autoResize: false
};
// controller
this.controller = new Controller();
// root panel
if (!container) {
throw new Error('No container element provided');
}
var rootOptions = Object.create(this.options);
rootOptions.height = function () {
// TODO: change to height
if (me.options.height) {
// fixed height
return me.options.height;
}
else {
// auto height
return (me.timeaxis.height + me.content.height) + 'px';
}
};
this.rootPanel = new RootPanel(container, rootOptions);
this.controller.add(this.rootPanel);
// item panel
var itemOptions = Object.create(this.options);
itemOptions.left = function () {
return me.labelPanel.width;
};
itemOptions.width = function () {
return me.rootPanel.width - me.labelPanel.width;
};
itemOptions.top = null;
itemOptions.height = null;
this.itemPanel = new Panel(this.rootPanel, [], itemOptions);
this.controller.add(this.itemPanel);
// label panel
var labelOptions = Object.create(this.options);
labelOptions.top = null;
labelOptions.left = null;
labelOptions.height = null;
labelOptions.width = function () {
if (me.content && typeof me.content.getLabelsWidth === 'function') {
return me.content.getLabelsWidth();
}
else {
return 0;
}
};
this.labelPanel = new Panel(this.rootPanel, [], labelOptions);
this.controller.add(this.labelPanel);
// range
var rangeOptions = Object.create(this.options);
this.range = new Range(rangeOptions);
this.range.setRange(
now.clone().add('days', -3).valueOf(),
now.clone().add('days', 4).valueOf()
);
// TODO: reckon with options moveable and zoomable
this.range.subscribe(this.rootPanel, 'move', 'horizontal');
this.range.subscribe(this.rootPanel, 'zoom', 'horizontal');
this.range.on('rangechange', function () {
var force = true;
me.controller.requestReflow(force);
});
this.range.on('rangechanged', function () {
var force = true;
me.controller.requestReflow(force);
});
// TODO: put the listeners in setOptions, be able to dynamically change with options moveable and zoomable
// time axis
var timeaxisOptions = Object.create(rootOptions);
timeaxisOptions.range = this.range;
timeaxisOptions.left = null;
timeaxisOptions.top = null;
timeaxisOptions.width = '100%';
timeaxisOptions.height = null;
this.timeaxis = new TimeAxis(this.itemPanel, [], timeaxisOptions);
this.timeaxis.setRange(this.range);
this.controller.add(this.timeaxis);
// current time bar
this.currenttime = new CurrentTime(this.timeaxis, [], rootOptions);
this.controller.add(this.currenttime);
// custom time bar
this.customtime = new CustomTime(this.timeaxis, [], rootOptions);
this.controller.add(this.customtime);
// create groupset
this.setGroups(null);
this.itemsData = null; // DataSet
this.groupsData = null; // DataSet
// apply options
if (options) {
this.setOptions(options);
}
// create itemset and groupset
if (items) {
this.setItems(items);
}
}
/**
* Set options
* @param {Object} options TODO: describe the available options
*/
Timeline.prototype.setOptions = function (options) {
util.extend(this.options, options);
// force update of range
// options.start and options.end can be undefined
//this.range.setRange(options.start, options.end);
this.range.setRange();
this.controller.reflow();
this.controller.repaint();
};
/**
* Set a custom time bar
* @param {Date} time
*/
Timeline.prototype.setCustomTime = function (time) {
this.customtime._setCustomTime(time);
};
/**
* Retrieve the current custom time.
* @return {Date} customTime
*/
Timeline.prototype.getCustomTime = function() {
return new Date(this.customtime.customTime.valueOf());
};
/**
* Set items
* @param {vis.DataSet | Array | DataTable | null} items
*/
Timeline.prototype.setItems = function(items) {
var initialLoad = (this.itemsData == null);
// convert to type DataSet when needed
var newItemSet;
if (!items) {
newItemSet = null;
}
else if (items instanceof DataSet) {
newItemSet = items;
}
if (!(items instanceof DataSet)) {
newItemSet = new DataSet({
convert: {
start: 'Date',
end: 'Date'
}
});
newItemSet.add(items);
}
// set items
this.itemsData = newItemSet;
this.content.setItems(newItemSet);
if (initialLoad && (this.options.start == undefined || this.options.end == undefined)) {
// apply the data range as range
var dataRange = this.getItemRange();
// add 5% space on both sides
var min = dataRange.min;
var max = dataRange.max;
if (min != null && max != null) {
var interval = (max.valueOf() - min.valueOf());
if (interval <= 0) {
// prevent an empty interval
interval = 24 * 60 * 60 * 1000; // 1 day
}
min = new Date(min.valueOf() - interval * 0.05);
max = new Date(max.valueOf() + interval * 0.05);
}
// override specified start and/or end date
if (this.options.start != undefined) {
min = util.convert(this.options.start, 'Date');
}
if (this.options.end != undefined) {
max = util.convert(this.options.end, 'Date');
}
// apply range if there is a min or max available
if (min != null || max != null) {
this.range.setRange(min, max);
}
}
};
/**
* Set groups
* @param {vis.DataSet | Array | DataTable} groups
*/
Timeline.prototype.setGroups = function(groups) {
var me = this;
this.groupsData = groups;
// switch content type between ItemSet or GroupSet when needed
var Type = this.groupsData ? GroupSet : ItemSet;
if (!(this.content instanceof Type)) {
// remove old content set
if (this.content) {
this.content.hide();
if (this.content.setItems) {
this.content.setItems(); // disconnect from items
}
if (this.content.setGroups) {
this.content.setGroups(); // disconnect from groups
}
this.controller.remove(this.content);
}
// create new content set
var options = Object.create(this.options);
util.extend(options, {
top: function () {
if (me.options.orientation == 'top') {
return me.timeaxis.height;
}
else {
return me.itemPanel.height - me.timeaxis.height - me.content.height;
}
},
left: null,
width: '100%',
height: function () {
if (me.options.height) {
// fixed height
return me.itemPanel.height - me.timeaxis.height;
}
else {
// auto height
return null;
}
},
maxHeight: function () {
// TODO: change maxHeight to be a css string like '100%' or '300px'
if (me.options.maxHeight) {
if (!util.isNumber(me.options.maxHeight)) {
throw new TypeError('Number expected for property maxHeight');
}
return me.options.maxHeight - me.timeaxis.height;
}
else {
return null;
}
},
labelContainer: function () {
return me.labelPanel.getContainer();
}
});
this.content = new Type(this.itemPanel, [this.timeaxis], options);
if (this.content.setRange) {
this.content.setRange(this.range);
}
if (this.content.setItems) {
this.content.setItems(this.itemsData);
}
if (this.content.setGroups) {
this.content.setGroups(this.groupsData);
}
this.controller.add(this.content);
}
};
/**
* Get the data range of the item set.
* @returns {{min: Date, max: Date}} range A range with a start and end Date.
* When no minimum is found, min==null
* When no maximum is found, max==null
*/
Timeline.prototype.getItemRange = function getItemRange() {
// calculate min from start filed
var itemsData = this.itemsData,
min = null,
max = null;
if (itemsData) {
// calculate the minimum value of the field 'start'
var minItem = itemsData.min('start');
min = minItem ? minItem.start.valueOf() : null;
// calculate maximum value of fields 'start' and 'end'
var maxStartItem = itemsData.max('start');
if (maxStartItem) {
max = maxStartItem.start.valueOf();
}
var maxEndItem = itemsData.max('end');
if (maxEndItem) {
if (max == null) {
max = maxEndItem.end.valueOf();
}
else {
max = Math.max(max, maxEndItem.end.valueOf());
}
}
}
return {
min: (min != null) ? new Date(min) : null,
max: (max != null) ? new Date(max) : null
};
};
(function(exports) {
/**
* Parse a text source containing data in DOT language into a JSON object.
* The object contains two lists: one with nodes and one with edges.
*
* DOT language reference: http://www.graphviz.org/doc/info/lang.html
*
* @param {String} data Text containing a graph in DOT-notation
* @return {Object} graph An object containing two parameters:
* {Object[]} nodes
* {Object[]} edges
*/
function parseDOT (data) {
dot = data;
return parseGraph();
}
// token types enumeration
var TOKENTYPE = {
NULL : 0,
DELIMITER : 1,
IDENTIFIER: 2,
UNKNOWN : 3
};
// map with all delimiters
var DELIMITERS = {
'{': true,
'}': true,
'[': true,
']': true,
';': true,
'=': true,
',': true,
'->': true,
'--': true
};
var dot = ''; // current dot file
var index = 0; // current index in dot file
var c = ''; // current token character in expr
var token = ''; // current token
var tokenType = TOKENTYPE.NULL; // type of the token
/**
* Get the first character from the dot file.
* The character is stored into the char c. If the end of the dot file is
* reached, the function puts an empty string in c.
*/
function first() {
index = 0;
c = dot.charAt(0);
}
/**
* Get the next character from the dot file.
* The character is stored into the char c. If the end of the dot file is
* reached, the function puts an empty string in c.
*/
function next() {
index++;
c = dot.charAt(index);
}
/**
* Preview the next character from the dot file.
* @return {String} cNext
*/
function nextPreview() {
return dot.charAt(index + 1);
}
/**
* Test whether given character is alphabetic or numeric
* @param {String} c
* @return {Boolean} isAlphaNumeric
*/
var regexAlphaNumeric = /[a-zA-Z_0-9.:#]/;
function isAlphaNumeric(c) {
return regexAlphaNumeric.test(c);
}
/**
* Merge all properties of object b into object b
* @param {Object} a
* @param {Object} b
* @return {Object} a
*/
function merge (a, b) {
if (!a) {
a = {};
}
if (b) {
for (var name in b) {
if (b.hasOwnProperty(name)) {
a[name] = b[name];
}
}
}
return a;
}
/**
* Set a value in an object, where the provided parameter name can be a
* path with nested parameters. For example:
*
* var obj = {a: 2};
* setValue(obj, 'b.c', 3); // obj = {a: 2, b: {c: 3}}
*
* @param {Object} obj
* @param {String} path A parameter name or dot-separated parameter path,
* like "color.highlight.border".
* @param {*} value
*/
function setValue(obj, path, value) {
var keys = path.split('.');
var o = obj;
while (keys.length) {
var key = keys.shift();
if (keys.length) {
// this isn't the end point
if (!o[key]) {
o[key] = {};
}
o = o[key];
}
else {
// this is the end point
o[key] = value;
}
}
}
/**
* Add a node to a graph object. If there is already a node with
* the same id, their attributes will be merged.
* @param {Object} graph
* @param {Object} node
*/
function addNode(graph, node) {
var i, len;
var current = null;
// find root graph (in case of subgraph)
var graphs = [graph]; // list with all graphs from current graph to root graph
var root = graph;
while (root.parent) {
graphs.push(root.parent);
root = root.parent;
}
// find existing node (at root level) by its id
if (root.nodes) {
for (i = 0, len = root.nodes.length; i < len; i++) {
if (node.id === root.nodes[i].id) {
current = root.nodes[i];
break;
}
}
}
if (!current) {
// this is a new node
current = {
id: node.id
};
if (graph.node) {
// clone default attributes
current.attr = merge(current.attr, graph.node);
}
}
// add node to this (sub)graph and all its parent graphs
for (i = graphs.length - 1; i >= 0; i--) {
var g = graphs[i];
if (!g.nodes) {
g.nodes = [];
}
if (g.nodes.indexOf(current) == -1) {
g.nodes.push(current);
}
}
// merge attributes
if (node.attr) {
current.attr = merge(current.attr, node.attr);
}
}
/**
* Add an edge to a graph object
* @param {Object} graph
* @param {Object} edge
*/
function addEdge(graph, edge) {
if (!graph.edges) {
graph.edges = [];
}
graph.edges.push(edge);
if (graph.edge) {
var attr = merge({}, graph.edge); // clone default attributes
edge.attr = merge(attr, edge.attr); // merge attributes
}
}
/**
* Create an edge to a graph object
* @param {Object} graph
* @param {String | Number | Object} from
* @param {String | Number | Object} to
* @param {String} type
* @param {Object | null} attr
* @return {Object} edge
*/
function createEdge(graph, from, to, type, attr) {
var edge = {
from: from,
to: to,
type: type
};
if (graph.edge) {
edge.attr = merge({}, graph.edge); // clone default attributes
}
edge.attr = merge(edge.attr || {}, attr); // merge attributes
return edge;
}
/**
* Get next token in the current dot file.
* The token and token type are available as token and tokenType
*/
function getToken() {
tokenType = TOKENTYPE.NULL;
token = '';
// skip over whitespaces
while (c == ' ' || c == '\t' || c == '\n' || c == '\r') { // space, tab, enter
next();
}
do {
var isComment = false;
// skip comment
if (c == '#') {
// find the previous non-space character
var i = index - 1;
while (dot.charAt(i) == ' ' || dot.charAt(i) == '\t') {
i--;
}
if (dot.charAt(i) == '\n' || dot.charAt(i) == '') {
// the # is at the start of a line, this is indeed a line comment
while (c != '' && c != '\n') {
next();
}
isComment = true;
}
}
if (c == '/' && nextPreview() == '/') {
// skip line comment
while (c != '' && c != '\n') {
next();
}
isComment = true;
}
if (c == '/' && nextPreview() == '*') {
// skip block comment
while (c != '') {
if (c == '*' && nextPreview() == '/') {
// end of block comment found. skip these last two characters
next();
next();
break;
}
else {
next();
}
}
isComment = true;
}
// skip over whitespaces
while (c == ' ' || c == '\t' || c == '\n' || c == '\r') { // space, tab, enter
next();
}
}
while (isComment);
// check for end of dot file
if (c == '') {
// token is still empty
tokenType = TOKENTYPE.DELIMITER;
return;
}
// check for delimiters consisting of 2 characters
var c2 = c + nextPreview();
if (DELIMITERS[c2]) {
tokenType = TOKENTYPE.DELIMITER;
token = c2;
next();
next();
return;
}
// check for delimiters consisting of 1 character
if (DELIMITERS[c]) {
tokenType = TOKENTYPE.DELIMITER;
token = c;
next();
return;
}
// check for an identifier (number or string)
// TODO: more precise parsing of numbers/strings (and the port separator ':')
if (isAlphaNumeric(c) || c == '-') {
token += c;
next();
while (isAlphaNumeric(c)) {
token += c;
next();
}
if (token == 'false') {
token = false; // convert to boolean
}
else if (token == 'true') {
token = true; // convert to boolean
}
else if (!isNaN(Number(token))) {
token = Number(token); // convert to number
}
tokenType = TOKENTYPE.IDENTIFIER;
return;
}
// check for a string enclosed by double quotes
if (c == '"') {
next();
while (c != '' && (c != '"' || (c == '"' && nextPreview() == '"'))) {
token += c;
if (c == '"') { // skip the escape character
next();
}
next();
}
if (c != '"') {
throw newSyntaxError('End of string " expected');
}
next();
tokenType = TOKENTYPE.IDENTIFIER;
return;
}
// something unknown is found, wrong characters, a syntax error
tokenType = TOKENTYPE.UNKNOWN;
while (c != '') {
token += c;
next();
}
throw new SyntaxError('Syntax error in part "' + chop(token, 30) + '"');
}
/**
* Parse a graph.
* @returns {Object} graph
*/
function parseGraph() {
var graph = {};
first();
getToken();
// optional strict keyword
if (token == 'strict') {
graph.strict = true;
getToken();
}
// graph or digraph keyword
if (token == 'graph' || token == 'digraph') {
graph.type = token;
getToken();
}
// optional graph id
if (tokenType == TOKENTYPE.IDENTIFIER) {
graph.id = token;
getToken();
}
// open angle bracket
if (token != '{') {
throw newSyntaxError('Angle bracket { expected');
}
getToken();
// statements
parseStatements(graph);
// close angle bracket
if (token != '}') {
throw newSyntaxError('Angle bracket } expected');
}
getToken();
// end of file
if (token !== '') {
throw newSyntaxError('End of file expected');
}
getToken();
// remove temporary default properties
delete graph.node;
delete graph.edge;
delete graph.graph;
return graph;
}
/**
* Parse a list with statements.
* @param {Object} graph
*/
function parseStatements (graph) {
while (token !== '' && token != '}') {
parseStatement(graph);
if (token == ';') {
getToken();
}
}
}
/**
* Parse a single statement. Can be a an attribute statement, node
* statement, a series of node statements and edge statements, or a
* parameter.
* @param {Object} graph
*/
function parseStatement(graph) {
// parse subgraph
var subgraph = parseSubgraph(graph);
if (subgraph) {
// edge statements
parseEdge(graph, subgraph);
return;
}
// parse an attribute statement
var attr = parseAttributeStatement(graph);
if (attr) {
return;
}
// parse node
if (tokenType != TOKENTYPE.IDENTIFIER) {
throw newSyntaxError('Identifier expected');
}
var id = token; // id can be a string or a number
getToken();
if (token == '=') {
// id statement
getToken();
if (tokenType != TOKENTYPE.IDENTIFIER) {
throw newSyntaxError('Identifier expected');
}
graph[id] = token;
getToken();
// TODO: implement comma separated list with "a_list: ID=ID [','] [a_list] "
}
else {
parseNodeStatement(graph, id);
}
}
/**
* Parse a subgraph
* @param {Object} graph parent graph object
* @return {Object | null} subgraph
*/
function parseSubgraph (graph) {
var subgraph = null;
// optional subgraph keyword
if (token == 'subgraph') {
subgraph = {};
subgraph.type = 'subgraph';
getToken();
// optional graph id
if (tokenType == TOKENTYPE.IDENTIFIER) {
subgraph.id = token;
getToken();
}
}
// open angle bracket
if (token == '{') {
getToken();
if (!subgraph) {
subgraph = {};
}
subgraph.parent = graph;
subgraph.node = graph.node;
subgraph.edge = graph.edge;
subgraph.graph = graph.graph;
// statements
parseStatements(subgraph);
// close angle bracket
if (token != '}') {
throw newSyntaxError('Angle bracket } expected');
}
getToken();
// remove temporary default properties
delete subgraph.node;
delete subgraph.edge;
delete subgraph.graph;
delete subgraph.parent;
// register at the parent graph
if (!graph.subgraphs) {
graph.subgraphs = [];
}
graph.subgraphs.push(subgraph);
}
return subgraph;
}
/**
* parse an attribute statement like "node [shape=circle fontSize=16]".
* Available keywords are 'node', 'edge', 'graph'.
* The previous list with default attributes will be replaced
* @param {Object} graph
* @returns {String | null} keyword Returns the name of the parsed attribute
* (node, edge, graph), or null if nothing
* is parsed.
*/
function parseAttributeStatement (graph) {
// attribute statements
if (token == 'node') {
getToken();
// node attributes
graph.node = parseAttributeList();
return 'node';
}
else if (token == 'edge') {
getToken();
// edge attributes
graph.edge = parseAttributeList();
return 'edge';
}
else if (token == 'graph') {
getToken();
// graph attributes
graph.graph = parseAttributeList();
return 'graph';
}
return null;
}
/**
* parse a node statement
* @param {Object} graph
* @param {String | Number} id
*/
function parseNodeStatement(graph, id) {
// node statement
var node = {
id: id
};
var attr = parseAttributeList();
if (attr) {
node.attr = attr;
}
addNode(graph, node);
// edge statements
parseEdge(graph, id);
}
/**
* Parse an edge or a series of edges
* @param {Object} graph
* @param {String | Number} from Id of the from node
*/
function parseEdge(graph, from) {
while (token == '->' || token == '--') {
var to;
var type = token;
getToken();
var subgraph = parseSubgraph(graph);
if (subgraph) {
to = subgraph;
}
else {
if (tokenType != TOKENTYPE.IDENTIFIER) {
throw newSyntaxError('Identifier or subgraph expected');
}
to = token;
addNode(graph, {
id: to
});
getToken();
}
// parse edge attributes
var attr = parseAttributeList();
// create edge
var edge = createEdge(graph, from, to, type, attr);
addEdge(graph, edge);
from = to;
}
}
/**
* Parse a set with attributes,
* for example [label="1.000", shape=solid]
* @return {Object | null} attr
*/
function parseAttributeList() {
var attr = null;
while (token == '[') {
getToken();
attr = {};
while (token !== '' && token != ']') {
if (tokenType != TOKENTYPE.IDENTIFIER) {
throw newSyntaxError('Attribute name expected');
}
var name = token;
getToken();
if (token != '=') {
throw newSyntaxError('Equal sign = expected');
}
getToken();
if (tokenType != TOKENTYPE.IDENTIFIER) {
throw newSyntaxError('Attribute value expected');
}
var value = token;
setValue(attr, name, value); // name can be a path
getToken();
if (token ==',') {
getToken();
}
}
if (token != ']') {
throw newSyntaxError('Bracket ] expected');
}
getToken();
}
return attr;
}
/**
* Create a syntax error with extra information on current token and index.
* @param {String} message
* @returns {SyntaxError} err
*/
function newSyntaxError(message) {
return new SyntaxError(message + ', got "' + chop(token, 30) + '" (char ' + index + ')');
}
/**
* Chop off text after a maximum length
* @param {String} text
* @param {Number} maxLength
* @returns {String}
*/
function chop (text, maxLength) {
return (text.length <= maxLength) ? text : (text.substr(0, 27) + '...');
}
/**
* Execute a function fn for each pair of elements in two arrays
* @param {Array | *} array1
* @param {Array | *} array2
* @param {function} fn
*/
function forEach2(array1, array2, fn) {
if (array1 instanceof Array) {
array1.forEach(function (elem1) {
if (array2 instanceof Array) {
array2.forEach(function (elem2) {
fn(elem1, elem2);
});
}
else {
fn(elem1, array2);
}
});
}
else {
if (array2 instanceof Array) {
array2.forEach(function (elem2) {
fn(array1, elem2);
});
}
else {
fn(array1, array2);
}
}
}
/**
* Convert a string containing a graph in DOT language into a map containing
* with nodes and edges in the format of graph.
* @param {String} data Text containing a graph in DOT-notation
* @return {Object} graphData
*/
function DOTToGraph (data) {
// parse the DOT file
var dotData = parseDOT(data);
var graphData = {
nodes: [],
edges: [],
options: {}
};
// copy the nodes
if (dotData.nodes) {
dotData.nodes.forEach(function (dotNode) {
var graphNode = {
id: dotNode.id,
label: String(dotNode.label || dotNode.id)
};
merge(graphNode, dotNode.attr);
if (graphNode.image) {
graphNode.shape = 'image';
}
graphData.nodes.push(graphNode);
});
}
// copy the edges
if (dotData.edges) {
/**
* Convert an edge in DOT format to an edge with VisGraph format
* @param {Object} dotEdge
* @returns {Object} graphEdge
*/
function convertEdge(dotEdge) {
var graphEdge = {
from: dotEdge.from,
to: dotEdge.to
};
merge(graphEdge, dotEdge.attr);
graphEdge.style = (dotEdge.type == '->') ? 'arrow' : 'line';
return graphEdge;
}
dotData.edges.forEach(function (dotEdge) {
var from, to;
if (dotEdge.from instanceof Object) {
from = dotEdge.from.nodes;
}
else {
from = {
id: dotEdge.from
}
}
if (dotEdge.to instanceof Object) {
to = dotEdge.to.nodes;
}
else {
to = {
id: dotEdge.to
}
}
if (dotEdge.from instanceof Object && dotEdge.from.edges) {
dotEdge.from.edges.forEach(function (subEdge) {
var graphEdge = convertEdge(subEdge);
graphData.edges.push(graphEdge);
});
}
forEach2(from, to, function (from, to) {
var subEdge = createEdge(graphData, from.id, to.id, dotEdge.type, dotEdge.attr);
var graphEdge = convertEdge(subEdge);
graphData.edges.push(graphEdge);
});
if (dotEdge.to instanceof Object && dotEdge.to.edges) {
dotEdge.to.edges.forEach(function (subEdge) {
var graphEdge = convertEdge(subEdge);
graphData.edges.push(graphEdge);
});
}
});
}
// copy the options
if (dotData.attr) {
graphData.options = dotData.attr;
}
return graphData;
}
// exports
exports.parseDOT = parseDOT;
exports.DOTToGraph = DOTToGraph;
})(typeof util !== 'undefined' ? util : exports);
/**
* Canvas shapes used by the Graph
*/
if (typeof CanvasRenderingContext2D !== 'undefined') {
/**
* Draw a circle shape
*/
CanvasRenderingContext2D.prototype.circle = function(x, y, r) {
this.beginPath();
this.arc(x, y, r, 0, 2*Math.PI, false);
};
/**
* Draw a square shape
* @param {Number} x horizontal center
* @param {Number} y vertical center
* @param {Number} r size, width and height of the square
*/
CanvasRenderingContext2D.prototype.square = function(x, y, r) {
this.beginPath();
this.rect(x - r, y - r, r * 2, r * 2);
};
/**
* Draw a triangle shape
* @param {Number} x horizontal center
* @param {Number} y vertical center
* @param {Number} r radius, half the length of the sides of the triangle
*/
CanvasRenderingContext2D.prototype.triangle = function(x, y, r) {
// http://en.wikipedia.org/wiki/Equilateral_triangle
this.beginPath();
var s = r * 2;
var s2 = s / 2;
var ir = Math.sqrt(3) / 6 * s; // radius of inner circle
var h = Math.sqrt(s * s - s2 * s2); // height
this.moveTo(x, y - (h - ir));
this.lineTo(x + s2, y + ir);
this.lineTo(x - s2, y + ir);
this.lineTo(x, y - (h - ir));
this.closePath();
};
/**
* Draw a triangle shape in downward orientation
* @param {Number} x horizontal center
* @param {Number} y vertical center
* @param {Number} r radius
*/
CanvasRenderingContext2D.prototype.triangleDown = function(x, y, r) {
// http://en.wikipedia.org/wiki/Equilateral_triangle
this.beginPath();
var s = r * 2;
var s2 = s / 2;
var ir = Math.sqrt(3) / 6 * s; // radius of inner circle
var h = Math.sqrt(s * s - s2 * s2); // height
this.moveTo(x, y + (h - ir));
this.lineTo(x + s2, y - ir);
this.lineTo(x - s2, y - ir);
this.lineTo(x, y + (h - ir));
this.closePath();
};
/**
* Draw a star shape, a star with 5 points
* @param {Number} x horizontal center
* @param {Number} y vertical center
* @param {Number} r radius, half the length of the sides of the triangle
*/
CanvasRenderingContext2D.prototype.star = function(x, y, r) {
// http://www.html5canvastutorials.com/labs/html5-canvas-star-spinner/
this.beginPath();
for (var n = 0; n < 10; n++) {
var radius = (n % 2 === 0) ? r * 1.3 : r * 0.5;
this.lineTo(
x + radius * Math.sin(n * 2 * Math.PI / 10),
y - radius * Math.cos(n * 2 * Math.PI / 10)
);
}
this.closePath();
};
/**
* http://stackoverflow.com/questions/1255512/how-to-draw-a-rounded-rectangle-on-html-canvas
*/
CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
var r2d = Math.PI/180;
if( w - ( 2 * r ) < 0 ) { r = ( w / 2 ); } //ensure that the radius isn't too large for x
if( h - ( 2 * r ) < 0 ) { r = ( h / 2 ); } //ensure that the radius isn't too large for y
this.beginPath();
this.moveTo(x+r,y);
this.lineTo(x+w-r,y);
this.arc(x+w-r,y+r,r,r2d*270,r2d*360,false);
this.lineTo(x+w,y+h-r);
this.arc(x+w-r,y+h-r,r,0,r2d*90,false);
this.lineTo(x+r,y+h);
this.arc(x+r,y+h-r,r,r2d*90,r2d*180,false);
this.lineTo(x,y+r);
this.arc(x+r,y+r,r,r2d*180,r2d*270,false);
};
/**
* http://stackoverflow.com/questions/2172798/how-to-draw-an-oval-in-html5-canvas
*/
CanvasRenderingContext2D.prototype.ellipse = function(x, y, w, h) {
var kappa = .5522848,
ox = (w / 2) * kappa, // control point offset horizontal
oy = (h / 2) * kappa, // control point offset vertical
xe = x + w, // x-end
ye = y + h, // y-end
xm = x + w / 2, // x-middle
ym = y + h / 2; // y-middle
this.beginPath();
this.moveTo(x, ym);
this.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y);
this.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym);
this.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye);
this.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym);
};
/**
* http://stackoverflow.com/questions/2172798/how-to-draw-an-oval-in-html5-canvas
*/
CanvasRenderingContext2D.prototype.database = function(x, y, w, h) {
var f = 1/3;
var wEllipse = w;
var hEllipse = h * f;
var kappa = .5522848,
ox = (wEllipse / 2) * kappa, // control point offset horizontal
oy = (hEllipse / 2) * kappa, // control point offset vertical
xe = x + wEllipse, // x-end
ye = y + hEllipse, // y-end
xm = x + wEllipse / 2, // x-middle
ym = y + hEllipse / 2, // y-middle
ymb = y + (h - hEllipse/2), // y-midlle, bottom ellipse
yeb = y + h; // y-end, bottom ellipse
this.beginPath();
this.moveTo(xe, ym);
this.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye);
this.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym);
this.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y);
this.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym);
this.lineTo(xe, ymb);
this.bezierCurveTo(xe, ymb + oy, xm + ox, yeb, xm, yeb);
this.bezierCurveTo(xm - ox, yeb, x, ymb + oy, x, ymb);
this.lineTo(x, ym);
};
/**
* Draw an arrow point (no line)
*/
CanvasRenderingContext2D.prototype.arrow = function(x, y, angle, length) {
// tail
var xt = x - length * Math.cos(angle);
var yt = y - length * Math.sin(angle);
// inner tail
// TODO: allow to customize different shapes
var xi = x - length * 0.9 * Math.cos(angle);
var yi = y - length * 0.9 * Math.sin(angle);
// left
var xl = xt + length / 3 * Math.cos(angle + 0.5 * Math.PI);
var yl = yt + length / 3 * Math.sin(angle + 0.5 * Math.PI);
// right
var xr = xt + length / 3 * Math.cos(angle - 0.5 * Math.PI);
var yr = yt + length / 3 * Math.sin(angle - 0.5 * Math.PI);
this.beginPath();
this.moveTo(x, y);
this.lineTo(xl, yl);
this.lineTo(xi, yi);
this.lineTo(xr, yr);
this.closePath();
};
/**
* Sets up the dashedLine functionality for drawing
* Original code came from http://stackoverflow.com/questions/4576724/dotted-stroke-in-canvas
* @author David Jordan
* @date 2012-08-08
*/
CanvasRenderingContext2D.prototype.dashedLine = function(x,y,x2,y2,dashArray){
if (!dashArray) dashArray=[10,5];
if (dashLength==0) dashLength = 0.001; // Hack for Safari
var dashCount = dashArray.length;
this.moveTo(x, y);
var dx = (x2-x), dy = (y2-y);
var slope = dy/dx;
var distRemaining = Math.sqrt( dx*dx + dy*dy );
var dashIndex=0, draw=true;
while (distRemaining>=0.1){
var dashLength = dashArray[dashIndex++%dashCount];
if (dashLength > distRemaining) dashLength = distRemaining;
var xStep = Math.sqrt( dashLength*dashLength / (1 + slope*slope) );
if (dx<0) xStep = -xStep;
x += xStep;
y += slope*xStep;
this[draw ? 'lineTo' : 'moveTo'](x,y);
distRemaining -= dashLength;
draw = !draw;
}
};
// TODO: add diamond shape
}
/**
* @class Node
* A node. A node can be connected to other nodes via one or multiple edges.
* @param {object} properties An object containing properties for the node. All
* properties are optional, except for the id.
* {number} id Id of the node. Required
* {string} label Text label for the node
* {number} x Horizontal position of the node
* {number} y Vertical position of the node
* {string} shape Node shape, available:
* "database", "circle", "ellipse",
* "box", "image", "text", "dot",
* "star", "triangle", "triangleDown",
* "square"
* {string} image An image url
* {string} title An title text, can be HTML
* {anytype} group A group name or number
* @param {Graph.Images} imagelist A list with images. Only needed
* when the node has an image
* @param {Graph.Groups} grouplist A list with groups. Needed for
* retrieving group properties
* @param {Object} constants An object with default values for
* example for the color
*/
function Node(properties, imagelist, grouplist, constants) {
this.selected = false;
this.edges = []; // all edges connected to this node
this.dynamicEdges = [];
this.reroutedEdges = {};
this.group = constants.nodes.group;
this.fontSize = constants.nodes.fontSize;
this.fontFace = constants.nodes.fontFace;
this.fontColor = constants.nodes.fontColor;
this.color = constants.nodes.color;
// set defaults for the properties
this.id = undefined;
this.shape = constants.nodes.shape;
this.image = constants.nodes.image;
this.x = 0;
this.y = 0;
this.xFixed = false;
this.yFixed = false;
this.radius = constants.nodes.radius;
this.baseRadiusValue = constants.nodes.radius;
this.radiusFixed = false;
this.radiusMin = constants.nodes.radiusMin;
this.radiusMax = constants.nodes.radiusMax;
this.imagelist = imagelist;
this.grouplist = grouplist;
this.setProperties(properties, constants);
// creating the variables for clustering
this.resetCluster();
this.dynamicEdgesLength = 0;
this.clusterSession = 0;
this.clusterSizeWidthFactor = constants.clustering.clusterSizeWidthFactor;
this.clusterSizeHeightFactor = constants.clustering.clusterSizeHeightFactor;
this.clusterSizeRadiusFactor = constants.clustering.clusterSizeRadiusFactor;
// mass, force, velocity
this.mass = 50; // kg (mass is adjusted for the number of connected edges)
this.fx = 0.0; // external force x
this.fy = 0.0; // external force y
this.vx = 0.0; // velocity x
this.vy = 0.0; // velocity y
this.minForce = constants.minForce;
this.damping = 0.9; // damping factor
this.graphScaleInv = 1;
}
/**
* (re)setting the clustering variables and objects
*/
Node.prototype.resetCluster = function() {
// clustering variables
this.formationScale = undefined; // this is used to determine when to open the cluster
this.clusterSize = 1; // this signifies the total amount of nodes in this cluster
this.containedNodes = {};
this.containedEdges = {};
this.clusterSessions = [];
};
/**
* Attach a edge to the node
* @param {Edge} edge
*/
Node.prototype.attachEdge = function(edge) {
if (this.edges.indexOf(edge) == -1) {
this.edges.push(edge);
}
if (this.dynamicEdges.indexOf(edge) == -1) {
this.dynamicEdges.push(edge);
}
this.dynamicEdgesLength = this.dynamicEdges.length;
this._updateMass();
};
/**
* Detach a edge from the node
* @param {Edge} edge
*/
Node.prototype.detachEdge = function(edge) {
var index = this.edges.indexOf(edge);
if (index != -1) {
this.edges.splice(index, 1);
this.dynamicEdges.splice(index, 1);
}
this.dynamicEdgesLength = this.dynamicEdges.length;
this._updateMass();
};
/**
* Update the nodes mass, which is determined by the number of edges connecting
* to it (more edges -> heavier node).
* @private
*/
Node.prototype._updateMass = function() {
this.mass = 50 + 20 * this.edges.length; // kg
};
/**
* Set or overwrite properties for the node
* @param {Object} properties an object with properties
* @param {Object} constants and object with default, global properties
*/
Node.prototype.setProperties = function(properties, constants) {
if (!properties) {
return;
}
// basic properties
if (properties.id !== undefined) {this.id = properties.id;}
if (properties.label !== undefined) {this.label = properties.label;}
if (properties.title !== undefined) {this.title = properties.title;}
if (properties.group !== undefined) {this.group = properties.group;}
if (properties.x !== undefined) {this.x = properties.x;}
if (properties.y !== undefined) {this.y = properties.y;}
if (properties.value !== undefined) {this.value = properties.value;}
if (this.id === undefined) {
throw "Node must have an id";
}
// copy group properties
if (this.group) {
var groupObj = this.grouplist.get(this.group);
for (var prop in groupObj) {
if (groupObj.hasOwnProperty(prop)) {
this[prop] = groupObj[prop];
}
}
}
// individual shape properties
if (properties.shape !== undefined) {this.shape = properties.shape;}
if (properties.image !== undefined) {this.image = properties.image;}
if (properties.radius !== undefined) {this.radius = properties.radius;}
if (properties.color !== undefined) {this.color = Node.parseColor(properties.color);}
if (properties.fontColor !== undefined) {this.fontColor = properties.fontColor;}
if (properties.fontSize !== undefined) {this.fontSize = properties.fontSize;}
if (properties.fontFace !== undefined) {this.fontFace = properties.fontFace;}
if (this.image !== undefined) {
if (this.imagelist) {
this.imageObj = this.imagelist.load(this.image);
}
else {
throw "No imagelist provided";
}
}
this.xFixed = this.xFixed || (properties.x !== undefined);
this.yFixed = this.yFixed || (properties.y !== undefined);
this.radiusFixed = this.radiusFixed || (properties.radius !== undefined);
if (this.shape == 'image') {
this.radiusMin = constants.nodes.widthMin;
this.radiusMax = constants.nodes.widthMax;
}
// choose draw method depending on the shape
switch (this.shape) {
case 'database': this.draw = this._drawDatabase; this.resize = this._resizeDatabase; break;
case 'box': this.draw = this._drawBox; this.resize = this._resizeBox; break;
case 'circle': this.draw = this._drawCircle; this.resize = this._resizeCircle; break;
case 'ellipse': this.draw = this._drawEllipse; this.resize = this._resizeEllipse; break;
// TODO: add diamond shape
case 'image': this.draw = this._drawImage; this.resize = this._resizeImage; break;
case 'text': this.draw = this._drawText; this.resize = this._resizeText; break;
case 'dot': this.draw = this._drawDot; this.resize = this._resizeShape; break;
case 'square': this.draw = this._drawSquare; this.resize = this._resizeShape; break;
case 'triangle': this.draw = this._drawTriangle; this.resize = this._resizeShape; break;
case 'triangleDown': this.draw = this._drawTriangleDown; this.resize = this._resizeShape; break;
case 'star': this.draw = this._drawStar; this.resize = this._resizeShape; break;
default: this.draw = this._drawEllipse; this.resize = this._resizeEllipse; break;
}
// reset the size of the node, this can be changed
this._reset();
};
/**
* Parse a color property into an object with border, background, and
* hightlight colors
* @param {Object | String} color
* @return {Object} colorObject
*/
Node.parseColor = function(color) {
var c;
if (util.isString(color)) {
c = {
border: color,
background: color,
highlight: {
border: color,
background: color
}
};
// TODO: automatically generate a nice highlight color
}
else {
c = {};
c.background = color.background || 'white';
c.border = color.border || c.background;
if (util.isString(color.highlight)) {
c.highlight = {
border: color.highlight,
background: color.highlight
}
}
else {
c.highlight = {};
c.highlight.background = color.highlight && color.highlight.background || c.background;
c.highlight.border = color.highlight && color.highlight.border || c.border;
}
}
return c;
};
/**
* select this node
*/
Node.prototype.select = function() {
this.selected = true;
this._reset();
};
/**
* unselect this node
*/
Node.prototype.unselect = function() {
this.selected = false;
this._reset();
};
/**
* Reset the calculated size of the node, forces it to recalculate its size
*/
Node.prototype.clearSizeCache = function() {
this._reset();
};
/**
* Reset the calculated size of the node, forces it to recalculate its size
* @private
*/
Node.prototype._reset = function() {
this.width = undefined;
this.height = undefined;
};
/**
* get the title of this node.
* @return {string} title The title of the node, or undefined when no title
* has been set.
*/
Node.prototype.getTitle = function() {
return this.title;
};
/**
* Calculate the distance to the border of the Node
* @param {CanvasRenderingContext2D} ctx
* @param {Number} angle Angle in radians
* @returns {number} distance Distance to the border in pixels
*/
Node.prototype.distanceToBorder = function (ctx, angle) {
var borderWidth = 1;
if (!this.width) {
this.resize(ctx);
}
//noinspection FallthroughInSwitchStatementJS
switch (this.shape) {
case 'circle':
case 'dot':
return this.radius + borderWidth;
case 'ellipse':
var a = this.width / 2;
var b = this.height / 2;
var w = (Math.sin(angle) * a);
var h = (Math.cos(angle) * b);
return a * b / Math.sqrt(w * w + h * h);
// TODO: implement distanceToBorder for database
// TODO: implement distanceToBorder for triangle
// TODO: implement distanceToBorder for triangleDown
case 'box':
case 'image':
case 'text':
default:
if (this.width) {
return Math.min(
Math.abs(this.width / 2 / Math.cos(angle)),
Math.abs(this.height / 2 / Math.sin(angle))) + borderWidth;
// TODO: reckon with border radius too in case of box
}
else {
return 0;
}
}
// TODO: implement calculation of distance to border for all shapes
};
/**
* Set forces acting on the node
* @param {number} fx Force in horizontal direction
* @param {number} fy Force in vertical direction
*/
Node.prototype._setForce = function(fx, fy) {
this.fx = fx;
this.fy = fy;
};
/**
* Add forces acting on the node
* @param {number} fx Force in horizontal direction
* @param {number} fy Force in vertical direction
* @private
*/
Node.prototype._addForce = function(fx, fy) {
this.fx += fx;
this.fy += fy;
};
/**
* Perform one discrete step for the node
* @param {number} interval Time interval in seconds
*/
Node.prototype.discreteStep = function(interval) {
if (!this.xFixed) {
var dx = -this.damping * this.vx; // damping force
var ax = (this.fx + dx) / this.mass; // acceleration
this.vx += ax / interval; // velocity
this.x += this.vx / interval; // position
}
if (!this.yFixed) {
var dy = -this.damping * this.vy; // damping force
var ay = (this.fy + dy) / this.mass; // acceleration
this.vy += ay / interval; // velocity
this.y += this.vy / interval; // position
}
};
/**
* Check if this node has a fixed x and y position
* @return {boolean} true if fixed, false if not
*/
Node.prototype.isFixed = function() {
return (this.xFixed && this.yFixed);
};
/**
* Check if this node is moving
* @param {number} vmin the minimum velocity considered as "moving"
* @return {boolean} true if moving, false if it has no velocity
*/
// TODO: replace this method with calculating the kinetic energy
Node.prototype.isMoving = function(vmin) {
return (Math.abs(this.vx) > vmin || Math.abs(this.vy) > vmin ||
(!this.xFixed && Math.abs(this.fx) > this.minForce) ||
(!this.yFixed && Math.abs(this.fy) > this.minForce));
};
/**
* check if this node is selecte
* @return {boolean} selected True if node is selected, else false
*/
Node.prototype.isSelected = function() {
return this.selected;
};
/**
* Retrieve the value of the node. Can be undefined
* @return {Number} value
*/
Node.prototype.getValue = function() {
return this.value;
};
/**
* Calculate the distance from the nodes location to the given location (x,y)
* @param {Number} x
* @param {Number} y
* @return {Number} value
*/
Node.prototype.getDistance = function(x, y) {
var dx = this.x - x,
dy = this.y - y;
return Math.sqrt(dx * dx + dy * dy);
};
/**
* Adjust the value range of the node. The node will adjust it's radius
* based on its value.
* @param {Number} min
* @param {Number} max
*/
Node.prototype.setValueRange = function(min, max) {
if (!this.radiusFixed && this.value !== undefined) {
if (max == min) {
this.radius = (this.radiusMin + this.radiusMax) / 2;
}
else {
var scale = (this.radiusMax - this.radiusMin) / (max - min);
this.radius = (this.value - min) * scale + this.radiusMin;
}
}
};
/**
* Draw this node in the given canvas
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
* @param {CanvasRenderingContext2D} ctx
*/
Node.prototype.draw = function(ctx) {
throw "Draw method not initialized for node";
};
/**
* Recalculate the size of this node in the given canvas
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
* @param {CanvasRenderingContext2D} ctx
*/
Node.prototype.resize = function(ctx) {
throw "Resize method not initialized for node";
};
/**
* Check if this object is overlapping with the provided object
* @param {Object} obj an object with parameters left, top, right, bottom
* @return {boolean} True if location is located on node
*/
Node.prototype.isOverlappingWith = function(obj) {
return (this.left < obj.right &&
this.left + this.width > obj.left &&
this.top < obj.bottom &&
this.top + this.height > obj.top);
};
Node.prototype._resizeImage = function (ctx) {
// TODO: pre calculate the image size
if (!this.width) { // undefined or 0
var width, height;
if (this.value) {
var scale = this.imageObj.height / this.imageObj.width;
width = this.radius || this.imageObj.width;
height = this.radius * scale || this.imageObj.height;
}
else {
width = this.imageObj.width;
height = this.imageObj.height;
}
this.width = width;
this.height = height;
this.width += this.clusterSize * this.clusterSizeWidthFactor;
this.height += this.clusterSize * this.clusterSizeHeightFactor;
this.radius += this.clusterSize * this.clusterSizeRadiusFactor;
}
};
Node.prototype._drawImage = function (ctx) {
this._resizeImage(ctx);
this.left = this.x - this.width / 2;
this.top = this.y - this.height / 2;
var yLabel;
if (this.imageObj) {
// draw the shade
if (this.clusterSize > 1) {
var lineWidth = ((this.clusterSize > 1) ? 10 : 0.0);
lineWidth *= this.graphScaleInv;
lineWidth = Math.min(0.2 * this.width,lineWidth);
ctx.globalAlpha = 0.5;
ctx.drawImage(this.imageObj, this.left - lineWidth, this.top - lineWidth, this.width + 2*lineWidth, this.height + 2*lineWidth);
}
ctx.globalAlpha = 1.0;
ctx.drawImage(this.imageObj, this.left, this.top, this.width, this.height);
yLabel = this.y + this.height / 2;
}
else {
// image still loading... just draw the label for now
yLabel = this.y;
}
this._label(ctx, this.label, this.x, yLabel, undefined, "top");
};
Node.prototype._resizeBox = function (ctx) {
if (!this.width) {
var margin = 5;
var textSize = this.getTextSize(ctx);
this.width = textSize.width + 2 * margin;
this.height = textSize.height + 2 * margin;
this.width += this.clusterSize * 0.5 * this.clusterSizeWidthFactor;
this.height += this.clusterSize * 0.5 * this.clusterSizeHeightFactor;
//this.radius += this.clusterSize * this.clusterSizeRadiusFactor;
}
};
Node.prototype._drawBox = function (ctx) {
this._resizeBox(ctx);
this.left = this.x - this.width / 2;
this.top = this.y - this.height / 2;
var clusterLineWidth = 2.5;
var selectionLineWidth = 2;
ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border;
// draw the outer border
if (this.clusterSize > 1) {
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx.roundRect(this.left-2*ctx.lineWidth, this.top-2*ctx.lineWidth, this.width+4*ctx.lineWidth, this.height+4*ctx.lineWidth, this.radius);
ctx.stroke();
}
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background;
ctx.roundRect(this.left, this.top, this.width, this.height, this.radius);
ctx.fill();
ctx.stroke();
this._label(ctx, this.label, this.x, this.y);
};
Node.prototype._resizeDatabase = function (ctx) {
if (!this.width) {
var margin = 5;
var textSize = this.getTextSize(ctx);
var size = textSize.width + 2 * margin;
this.width = size;
this.height = size;
// scaling used for clustering
this.width += this.clusterSize * this.clusterSizeWidthFactor;
this.height += this.clusterSize * this.clusterSizeHeightFactor;
this.radius += this.clusterSize * this.clusterSizeRadiusFactor;
}
};
Node.prototype._drawDatabase = function (ctx) {
this._resizeDatabase(ctx);
this.left = this.x - this.width / 2;
this.top = this.y - this.height / 2;
var clusterLineWidth = 2.5;
var selectionLineWidth = 2;
ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border;
// draw the outer border
if (this.clusterSize > 1) {
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx.database(this.x - this.width/2 - 2*ctx.lineWidth, this.y - this.height*0.5 - 2*ctx.lineWidth, this.width + 4*ctx.lineWidth, this.height + 4*ctx.lineWidth);
ctx.stroke();
}
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background;
ctx.database(this.x - this.width/2, this.y - this.height*0.5, this.width, this.height);
ctx.fill();
ctx.stroke();
this._label(ctx, this.label, this.x, this.y);
};
Node.prototype._resizeCircle = function (ctx) {
if (!this.width) {
var margin = 5;
var textSize = this.getTextSize(ctx);
var diameter = Math.max(textSize.width, textSize.height) + 2 * margin;
this.radius = diameter / 2;
this.width = diameter;
this.height = diameter;
// scaling used for clustering
this.width += this.clusterSize * this.clusterSizeWidthFactor;
this.height += this.clusterSize * this.clusterSizeHeightFactor;
this.radius += this.clusterSize * 0.5*this.clusterSizeRadiusFactor;
}
};
Node.prototype._drawCircle = function (ctx) {
this._resizeCircle(ctx);
this.left = this.x - this.width / 2;
this.top = this.y - this.height / 2;
var clusterLineWidth = 2.5;
var selectionLineWidth = 2;
ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border;
// draw the outer border
if (this.clusterSize > 1) {
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx.circle(this.x, this.y, this.radius+2*ctx.lineWidth);
ctx.stroke();
}
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background;
ctx.circle(this.x, this.y, this.radius);
ctx.fill();
ctx.stroke();
this._label(ctx, this.label, this.x, this.y);
};
Node.prototype._resizeEllipse = function (ctx) {
if (!this.width) {
var textSize = this.getTextSize(ctx);
this.width = textSize.width * 1.5;
this.height = textSize.height * 2;
if (this.width < this.height) {
this.width = this.height;
}
// scaling used for clustering
this.width += this.clusterSize * this.clusterSizeWidthFactor;
this.height += this.clusterSize * this.clusterSizeHeightFactor;
this.radius += this.clusterSize * this.clusterSizeRadiusFactor;
}
};
Node.prototype._drawEllipse = function (ctx) {
this._resizeEllipse(ctx);
this.left = this.x - this.width / 2;
this.top = this.y - this.height / 2;
var clusterLineWidth = 2.5;
var selectionLineWidth = 2;
ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border;
// draw the outer border
if (this.clusterSize > 1) {
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx.ellipse(this.left-2*ctx.lineWidth, this.top-2*ctx.lineWidth, this.width+4*ctx.lineWidth, this.height+4*ctx.lineWidth);
ctx.stroke();
}
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background;
ctx.ellipse(this.left, this.top, this.width, this.height);
ctx.fill();
ctx.stroke();
this._label(ctx, this.label, this.x, this.y);
};
Node.prototype._drawDot = function (ctx) {
this._drawShape(ctx, 'circle');
};
Node.prototype._drawTriangle = function (ctx) {
this._drawShape(ctx, 'triangle');
};
Node.prototype._drawTriangleDown = function (ctx) {
this._drawShape(ctx, 'triangleDown');
};
Node.prototype._drawSquare = function (ctx) {
this._drawShape(ctx, 'square');
};
Node.prototype._drawStar = function (ctx) {
this._drawShape(ctx, 'star');
};
Node.prototype._resizeShape = function (ctx) {
if (!this.width) {
this.radius = this.baseRadiusValue;
var size = 2 * this.radius;
this.width = size;
this.height = size;
// scaling used for clustering
this.width += this.clusterSize * this.clusterSizeWidthFactor;
this.height += this.clusterSize * this.clusterSizeHeightFactor;
this.radius += this.clusterSize * 0.5 * this.clusterSizeRadiusFactor;
}
};
Node.prototype._drawShape = function (ctx, shape) {
this._resizeShape(ctx);
this.left = this.x - this.width / 2;
this.top = this.y - this.height / 2;
var clusterLineWidth = 2.5;
var selectionLineWidth = 2;
var radiusMultiplier = 2;
// choose draw method depending on the shape
switch (shape) {
case 'dot': radiusMultiplier = 2; break;
case 'square': radiusMultiplier = 2; break;
case 'triangle': radiusMultiplier = 3; break;
case 'triangleDown': radiusMultiplier = 3; break;
case 'star': radiusMultiplier = 4; break;
}
ctx.strokeStyle = this.selected ? this.color.highlight.border : this.color.border;
// draw the outer border
if (this.clusterSize > 1) {
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx[shape](this.x, this.y, this.radius + radiusMultiplier * ctx.lineWidth);
ctx.stroke();
}
ctx.lineWidth = (this.selected ? selectionLineWidth : 1.0) + ((this.clusterSize > 1) ? clusterLineWidth : 0.0);
ctx.lineWidth *= this.graphScaleInv;
ctx.lineWidth = Math.min(0.1 * this.width,ctx.lineWidth);
ctx.fillStyle = this.selected ? this.color.highlight.background : this.color.background;
ctx[shape](this.x, this.y, this.radius);
ctx.fill();
ctx.stroke();
if (this.label) {
this._label(ctx, this.label, this.x, this.y + this.height / 2, undefined, 'top');
}
};
Node.prototype._resizeText = function (ctx) {
if (!this.width) {
var margin = 5;
var textSize = this.getTextSize(ctx);
this.width = textSize.width + 2 * margin;
this.height = textSize.height + 2 * margin;
// scaling used for clustering
this.width += this.clusterSize * this.clusterSizeWidthFactor;
this.height += this.clusterSize * this.clusterSizeHeightFactor;
this.radius += this.clusterSize * this.clusterSizeRadiusFactor;
}
};
Node.prototype._drawText = function (ctx) {
this._resizeText(ctx);
this.left = this.x - this.width / 2;
this.top = this.y - this.height / 2;
this._label(ctx, this.label, this.x, this.y);
};
Node.prototype._label = function (ctx, text, x, y, align, baseline) {
if (text) {
ctx.font = (this.selected ? "bold " : "") + this.fontSize + "px " + this.fontFace;
ctx.fillStyle = this.fontColor || "black";
ctx.textAlign = align || "center";
ctx.textBaseline = baseline || "middle";
var lines = text.split('\n'),
lineCount = lines.length,
fontSize = (this.fontSize + 4),
yLine = y + (1 - lineCount) / 2 * fontSize;
for (var i = 0; i < lineCount; i++) {
ctx.fillText(lines[i], x, yLine);
yLine += fontSize;
}
}
};
Node.prototype.getTextSize = function(ctx) {
if (this.label !== undefined) {
ctx.font = (this.selected ? "bold " : "") + this.fontSize + "px " + this.fontFace;
var lines = this.label.split('\n'),
height = (this.fontSize + 4) * lines.length,
width = 0;
for (var i = 0, iMax = lines.length; i < iMax; i++) {
width = Math.max(width, ctx.measureText(lines[i]).width);
}
return {"width": width, "height": height};
}
else {
return {"width": 0, "height": 0};
}
};
/**
* This allows the zoom level of the graph to influence the rendering
*
* @param scale
*/
Node.prototype.setScale = function(scale) {
this.graphScaleInv = 1.0/scale;
};
/**
* This function updates the damping parameter for clusters, based ont he
*
* @param {Integer} numberOfNodes
*/
Node.prototype.updateDamping = function(numberOfNodes) {
this.damping = 0.8 + 0.1*this.clusterSize * (1 + 2/Math.pow(numberOfNodes,2));
};
/**
* set the velocity at 0. Is called when this node is contained in another during clustering
*/
Node.prototype.clearVelocity = function() {
this.vx = 0;
this.vy = 0;
};
/**
* Basic preservation of (kinectic) energy
*
* @param massBeforeClustering
*/
Node.prototype.updateVelocity = function(massBeforeClustering) {
var energyBefore = this.vx * this.vx * massBeforeClustering;
this.vx = Math.sqrt(energyBefore/this.mass);
energyBefore = this.vy * this.vy * massBeforeClustering;
this.vy = Math.sqrt(energyBefore/this.mass);
};
/**
* @class Edge
*
* A edge connects two nodes
* @param {Object} properties Object with properties. Must contain
* At least properties from and to.
* Available properties: from (number),
* to (number), label (string, color (string),
* width (number), style (string),
* length (number), title (string)
* @param {Graph} graph A graph object, used to find and edge to
* nodes.
* @param {Object} constants An object with default values for
* example for the color
*/
function Edge (properties, graph, constants) {
if (!graph) {
throw "No graph provided";
}
this.graph = graph;
// initialize constants
this.widthMin = constants.edges.widthMin;
this.widthMax = constants.edges.widthMax;
// initialize variables
this.id = undefined;
this.fromId = undefined;
this.toId = undefined;
this.style = constants.edges.style;
this.title = undefined;
this.width = constants.edges.width;
this.value = undefined;
this.length = constants.edges.length;
this.from = null; // a node
this.to = null; // a node
// we use this to be able to reconnect the edge to a cluster if its node is put into a cluster
// by storing the original information we can revert to the original connection when the cluser is opened.
this.originalFromID = [];
this.originalToID = [];
this.connected = false;
// Added to support dashed lines
// David Jordan
// 2012-08-08
this.dash = util.extend({}, constants.edges.dash); // contains properties length, gap, altLength
this.stiffness = undefined; // depends on the length of the edge
this.color = constants.edges.color;
this.widthFixed = false;
this.lengthFixed = false;
this.setProperties(properties, constants);
};
/**
* Set or overwrite properties for the edge
* @param {Object} properties an object with properties
* @param {Object} constants and object with default, global properties
*/
Edge.prototype.setProperties = function(properties, constants) {
if (!properties) {
return;
}
if (properties.from != undefined) {this.fromId = properties.from;}
if (properties.to != undefined) {this.toId = properties.to;}
if (properties.id != undefined) {this.id = properties.id;}
if (properties.style != undefined) {this.style = properties.style;}
if (properties.label != undefined) {this.label = properties.label;}
if (this.label) {
this.fontSize = constants.edges.fontSize;
this.fontFace = constants.edges.fontFace;
this.fontColor = constants.edges.fontColor;
if (properties.fontColor != undefined) {this.fontColor = properties.fontColor;}
if (properties.fontSize != undefined) {this.fontSize = properties.fontSize;}
if (properties.fontFace != undefined) {this.fontFace = properties.fontFace;}
}
if (properties.title != undefined) {this.title = properties.title;}
if (properties.width != undefined) {this.width = properties.width;}
if (properties.value != undefined) {this.value = properties.value;}
if (properties.length != undefined) {this.length = properties.length;}
// Added to support dashed lines
// David Jordan
// 2012-08-08
if (properties.dash) {
if (properties.dash.length != undefined) {this.dash.length = properties.dash.length;}
if (properties.dash.gap != undefined) {this.dash.gap = properties.dash.gap;}
if (properties.dash.altLength != undefined) {this.dash.altLength = properties.dash.altLength;}
}
if (properties.color != undefined) {this.color = properties.color;}
// A node is connected when it has a from and to node.
this.connect();
this.widthFixed = this.widthFixed || (properties.width != undefined);
this.lengthFixed = this.lengthFixed || (properties.length != undefined);
this.stiffness = 1 / this.length;
// set draw method based on style
switch (this.style) {
case 'line': this.draw = this._drawLine; break;
case 'arrow': this.draw = this._drawArrow; break;
case 'arrow-center': this.draw = this._drawArrowCenter; break;
case 'dash-line': this.draw = this._drawDashLine; break;
default: this.draw = this._drawLine; break;
}
};
/**
* Connect an edge to its nodes
*/
Edge.prototype.connect = function () {
this.disconnect();
this.from = this.graph.nodes[this.fromId] || null;
this.to = this.graph.nodes[this.toId] || null;
this.connected = (this.from && this.to);
if (this.connected) {
this.from.attachEdge(this);
this.to.attachEdge(this);
}
else {
if (this.from) {
this.from.detachEdge(this);
}
if (this.to) {
this.to.detachEdge(this);
}
}
};
/**
* Disconnect an edge from its nodes
*/
Edge.prototype.disconnect = function () {
if (this.from) {
this.from.detachEdge(this);
this.from = null;
}
if (this.to) {
this.to.detachEdge(this);
this.to = null;
}
this.connected = false;
};
/**
* get the title of this edge.
* @return {string} title The title of the edge, or undefined when no title
* has been set.
*/
Edge.prototype.getTitle = function() {
return this.title;
};
/**
* Retrieve the value of the edge. Can be undefined
* @return {Number} value
*/
Edge.prototype.getValue = function() {
return this.value;
};
/**
* Adjust the value range of the edge. The edge will adjust it's width
* based on its value.
* @param {Number} min
* @param {Number} max
*/
Edge.prototype.setValueRange = function(min, max) {
if (!this.widthFixed && this.value !== undefined) {
var scale = (this.widthMax - this.widthMin) / (max - min);
this.width = (this.value - min) * scale + this.widthMin;
}
};
/**
* Redraw a edge
* Draw this edge in the given canvas
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
* @param {CanvasRenderingContext2D} ctx
*/
Edge.prototype.draw = function(ctx) {
throw "Method draw not initialized in edge";
};
/**
* Check if this object is overlapping with the provided object
* @param {Object} obj an object with parameters left, top
* @return {boolean} True if location is located on the edge
*/
Edge.prototype.isOverlappingWith = function(obj) {
var distMax = 10;
var xFrom = this.from.x;
var yFrom = this.from.y;
var xTo = this.to.x;
var yTo = this.to.y;
var xObj = obj.left;
var yObj = obj.top;
var dist = Edge._dist(xFrom, yFrom, xTo, yTo, xObj, yObj);
return (dist < distMax);
};
/**
* Redraw a edge as a line
* Draw this edge in the given canvas
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
* @param {CanvasRenderingContext2D} ctx
* @private
*/
Edge.prototype._drawLine = function(ctx) {
// set style
ctx.strokeStyle = this.color;
ctx.lineWidth = this._getLineWidth();
var point;
if (this.from != this.to) {
// draw line
this._line(ctx);
// draw label
if (this.label) {
point = this._pointOnLine(0.5);
this._label(ctx, this.label, point.x, point.y);
}
}
else {
var x, y;
var radius = this.length / 4;
var node = this.from;
if (!node.width) {
node.resize(ctx);
}
if (node.width > node.height) {
x = node.x + node.width / 2;
y = node.y - radius;
}
else {
x = node.x + radius;
y = node.y - node.height / 2;
}
this._circle(ctx, x, y, radius);
point = this._pointOnCircle(x, y, radius, 0.5);
this._label(ctx, this.label, point.x, point.y);
}
};
/**
* Get the line width of the edge. Depends on width and whether one of the
* connected nodes is selected.
* @return {Number} width
* @private
*/
Edge.prototype._getLineWidth = function() {
if (this.from.selected || this.to.selected) {
return Math.min(this.width * 2, this.widthMax)*this.graphScaleInv;
}
else {
return this.width*this.graphScaleInv;
}
};
/**
* Draw a line between two nodes
* @param {CanvasRenderingContext2D} ctx
* @private
*/
Edge.prototype._line = function (ctx) {
// draw a straight line
ctx.beginPath();
ctx.moveTo(this.from.x, this.from.y);
ctx.lineTo(this.to.x, this.to.y);
ctx.stroke();
};
/**
* Draw a line from a node to itself, a circle
* @param {CanvasRenderingContext2D} ctx
* @param {Number} x
* @param {Number} y
* @param {Number} radius
* @private
*/
Edge.prototype._circle = function (ctx, x, y, radius) {
// draw a circle
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI, false);
ctx.stroke();
};
/**
* Draw label with white background and with the middle at (x, y)
* @param {CanvasRenderingContext2D} ctx
* @param {String} text
* @param {Number} x
* @param {Number} y
* @private
*/
Edge.prototype._label = function (ctx, text, x, y) {
if (text) {
// TODO: cache the calculated size
ctx.font = ((this.from.selected || this.to.selected) ? "bold " : "") +
this.fontSize + "px " + this.fontFace;
ctx.fillStyle = 'white';
var width = ctx.measureText(text).width;
var height = this.fontSize;
var left = x - width / 2;
var top = y - height / 2;
ctx.fillRect(left, top, width, height);
// draw text
ctx.fillStyle = this.fontColor || "black";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText(text, left, top);
}
};
/**
* Redraw a edge as a dashed line
* Draw this edge in the given canvas
* @author David Jordan
* @date 2012-08-08
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
* @param {CanvasRenderingContext2D} ctx
* @private
*/
Edge.prototype._drawDashLine = function(ctx) {
// set style
ctx.strokeStyle = this.color;
ctx.lineWidth = this._getLineWidth();
// draw dashed line
ctx.beginPath();
ctx.lineCap = 'round';
if (this.dash.altLength != undefined) //If an alt dash value has been set add to the array this value
{
ctx.dashedLine(this.from.x,this.from.y,this.to.x,this.to.y,
[this.dash.length,this.dash.gap,this.dash.altLength,this.dash.gap]);
}
else if (this.dash.length != undefined && this.dash.gap != undefined) //If a dash and gap value has been set add to the array this value
{
ctx.dashedLine(this.from.x,this.from.y,this.to.x,this.to.y,
[this.dash.length,this.dash.gap]);
}
else //If all else fails draw a line
{
ctx.moveTo(this.from.x, this.from.y);
ctx.lineTo(this.to.x, this.to.y);
}
ctx.stroke();
// draw label
if (this.label) {
var point = this._pointOnLine(0.5);
this._label(ctx, this.label, point.x, point.y);
}
};
/**
* Get a point on a line
* @param {Number} percentage. Value between 0 (line start) and 1 (line end)
* @return {Object} point
* @private
*/
Edge.prototype._pointOnLine = function (percentage) {
return {
x: (1 - percentage) * this.from.x + percentage * this.to.x,
y: (1 - percentage) * this.from.y + percentage * this.to.y
}
};
/**
* Get a point on a circle
* @param {Number} x
* @param {Number} y
* @param {Number} radius
* @param {Number} percentage. Value between 0 (line start) and 1 (line end)
* @return {Object} point
* @private
*/
Edge.prototype._pointOnCircle = function (x, y, radius, percentage) {
var angle = (percentage - 3/8) * 2 * Math.PI;
return {
x: x + radius * Math.cos(angle),
y: y - radius * Math.sin(angle)
}
};
/**
* Redraw a edge as a line with an arrow halfway the line
* Draw this edge in the given canvas
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
* @param {CanvasRenderingContext2D} ctx
* @private
*/
Edge.prototype._drawArrowCenter = function(ctx) {
var point;
// set style
ctx.strokeStyle = this.color;
ctx.fillStyle = this.color;
ctx.lineWidth = this._getLineWidth();
if (this.from != this.to) {
// draw line
this._line(ctx);
// draw an arrow halfway the line
var angle = Math.atan2((this.to.y - this.from.y), (this.to.x - this.from.x));
var length = 10 + 5 * this.width; // TODO: make customizable?
point = this._pointOnLine(0.5);
ctx.arrow(point.x, point.y, angle, length);
ctx.fill();
ctx.stroke();
// draw label
if (this.label) {
point = this._pointOnLine(0.5);
this._label(ctx, this.label, point.x, point.y);
}
}
else {
// draw circle
var x, y;
var radius = this.length / 4;
var node = this.from;
if (!node.width) {
node.resize(ctx);
}
if (node.width > node.height) {
x = node.x + node.width / 2;
y = node.y - radius;
}
else {
x = node.x + radius;
y = node.y - node.height / 2;
}
this._circle(ctx, x, y, radius);
// draw all arrows
var angle = 0.2 * Math.PI;
var length = 10 + 5 * this.width; // TODO: make customizable?
point = this._pointOnCircle(x, y, radius, 0.5);
ctx.arrow(point.x, point.y, angle, length);
ctx.fill();
ctx.stroke();
// draw label
if (this.label) {
point = this._pointOnCircle(x, y, radius, 0.5);
this._label(ctx, this.label, point.x, point.y);
}
}
};
/**
* Redraw a edge as a line with an arrow
* Draw this edge in the given canvas
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
* @param {CanvasRenderingContext2D} ctx
* @private
*/
Edge.prototype._drawArrow = function(ctx) {
// set style
ctx.strokeStyle = this.color;
ctx.fillStyle = this.color;
ctx.lineWidth = this._getLineWidth();
// draw line
var angle, length;
if (this.from != this.to) {
// calculate length and angle of the line
angle = Math.atan2((this.to.y - this.from.y), (this.to.x - this.from.x));
var dx = (this.to.x - this.from.x);
var dy = (this.to.y - this.from.y);
var lEdge = Math.sqrt(dx * dx + dy * dy);
var lFrom = this.from.distanceToBorder(ctx, angle + Math.PI);
var pFrom = (lEdge - lFrom) / lEdge;
var xFrom = (pFrom) * this.from.x + (1 - pFrom) * this.to.x;
var yFrom = (pFrom) * this.from.y + (1 - pFrom) * this.to.y;
var lTo = this.to.distanceToBorder(ctx, angle);
var pTo = (lEdge - lTo) / lEdge;
var xTo = (1 - pTo) * this.from.x + pTo * this.to.x;
var yTo = (1 - pTo) * this.from.y + pTo * this.to.y;
ctx.beginPath();
ctx.moveTo(xFrom, yFrom);
ctx.lineTo(xTo, yTo);
ctx.stroke();
// draw arrow at the end of the line
length = 10 + 5 * this.width; // TODO: make customizable?
ctx.arrow(xTo, yTo, angle, length);
ctx.fill();
ctx.stroke();
// draw label
if (this.label) {
var point = this._pointOnLine(0.5);
this._label(ctx, this.label, point.x, point.y);
}
}
else {
// draw circle
var node = this.from;
var x, y, arrow;
var radius = this.length / 4;
if (!node.width) {
node.resize(ctx);
}
if (node.width > node.height) {
x = node.x + node.width / 2;
y = node.y - radius;
arrow = {
x: x,
y: node.y,
angle: 0.9 * Math.PI
};
}
else {
x = node.x + radius;
y = node.y - node.height / 2;
arrow = {
x: node.x,
y: y,
angle: 0.6 * Math.PI
};
}
ctx.beginPath();
// TODO: do not draw a circle, but an arc
// TODO: similarly, for a line without arrows, draw to the border of the nodes instead of the center
ctx.arc(x, y, radius, 0, 2 * Math.PI, false);
ctx.stroke();
// draw all arrows
length = 10 + 5 * this.width; // TODO: make customizable?
ctx.arrow(arrow.x, arrow.y, arrow.angle, length);
ctx.fill();
ctx.stroke();
// draw label
if (this.label) {
point = this._pointOnCircle(x, y, radius, 0.5);
this._label(ctx, this.label, point.x, point.y);
}
}
};
/**
* Calculate the distance between a point (x3,y3) and a line segment from
* (x1,y1) to (x2,y2).
* http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment
* @param {number} x1
* @param {number} y1
* @param {number} x2
* @param {number} y2
* @param {number} x3
* @param {number} y3
* @private
*/
Edge._dist = function (x1,y1, x2,y2, x3,y3) { // x3,y3 is the point
var px = x2-x1,
py = y2-y1,
something = px*px + py*py,
u = ((x3 - x1) * px + (y3 - y1) * py) / something;
if (u > 1) {
u = 1;
}
else if (u < 0) {
u = 0;
}
var x = x1 + u * px,
y = y1 + u * py,
dx = x - x3,
dy = y - y3;
//# Note: If the actual distance does not matter,
//# if you only want to compare what this function
//# returns to other results of this function, you
//# can just return the squared distance instead
//# (i.e. remove the sqrt) to gain a little performance
return Math.sqrt(dx*dx + dy*dy);
};
/**
* This allows the zoom level of the graph to influence the rendering
*
* @param scale
*/
Edge.prototype.setScale = function(scale) {
this.graphScaleInv = 1.0/scale;
};
/**
* Popup is a class to create a popup window with some text
* @param {Element} container The container object.
* @param {Number} [x]
* @param {Number} [y]
* @param {String} [text]
*/
function Popup(container, x, y, text) {
if (container) {
this.container = container;
}
else {
this.container = document.body;
}
this.x = 0;
this.y = 0;
this.padding = 5;
if (x !== undefined && y !== undefined ) {
this.setPosition(x, y);
}
if (text !== undefined) {
this.setText(text);
}
// create the frame
this.frame = document.createElement("div");
var style = this.frame.style;
style.position = "absolute";
style.visibility = "hidden";
style.border = "1px solid #666";
style.color = "black";
style.padding = this.padding + "px";
style.backgroundColor = "#FFFFC6";
style.borderRadius = "3px";
style.MozBorderRadius = "3px";
style.WebkitBorderRadius = "3px";
style.boxShadow = "3px 3px 10px rgba(128, 128, 128, 0.5)";
style.whiteSpace = "nowrap";
this.container.appendChild(this.frame);
};
/**
* @param {number} x Horizontal position of the popup window
* @param {number} y Vertical position of the popup window
*/
Popup.prototype.setPosition = function(x, y) {
this.x = parseInt(x);
this.y = parseInt(y);
};
/**
* Set the text for the popup window. This can be HTML code
* @param {string} text
*/
Popup.prototype.setText = function(text) {
this.frame.innerHTML = text;
};
/**
* Show the popup window
* @param {boolean} show Optional. Show or hide the window
*/
Popup.prototype.show = function (show) {
if (show === undefined) {
show = true;
}
if (show) {
var height = this.frame.clientHeight;
var width = this.frame.clientWidth;
var maxHeight = this.frame.parentNode.clientHeight;
var maxWidth = this.frame.parentNode.clientWidth;
var top = (this.y - height);
if (top + height + this.padding > maxHeight) {
top = maxHeight - height - this.padding;
}
if (top < this.padding) {
top = this.padding;
}
var left = this.x;
if (left + width + this.padding > maxWidth) {
left = maxWidth - width - this.padding;
}
if (left < this.padding) {
left = this.padding;
}
this.frame.style.left = left + "px";
this.frame.style.top = top + "px";
this.frame.style.visibility = "visible";
}
else {
this.hide();
}
};
/**
* Hide the popup window
*/
Popup.prototype.hide = function () {
this.frame.style.visibility = "hidden";
};
/**
* @class Groups
* This class can store groups and properties specific for groups.
*/
Groups = function () {
this.clear();
this.defaultIndex = 0;
};
/**
* default constants for group colors
*/
Groups.DEFAULT = [
{border: "#2B7CE9", background: "#97C2FC", highlight: {border: "#2B7CE9", background: "#D2E5FF"}}, // blue
{border: "#FFA500", background: "#FFFF00", highlight: {border: "#FFA500", background: "#FFFFA3"}}, // yellow
{border: "#FA0A10", background: "#FB7E81", highlight: {border: "#FA0A10", background: "#FFAFB1"}}, // red
{border: "#41A906", background: "#7BE141", highlight: {border: "#41A906", background: "#A1EC76"}}, // green
{border: "#E129F0", background: "#EB7DF4", highlight: {border: "#E129F0", background: "#F0B3F5"}}, // magenta
{border: "#7C29F0", background: "#AD85E4", highlight: {border: "#7C29F0", background: "#D3BDF0"}}, // purple
{border: "#C37F00", background: "#FFA807", highlight: {border: "#C37F00", background: "#FFCA66"}}, // orange
{border: "#4220FB", background: "#6E6EFD", highlight: {border: "#4220FB", background: "#9B9BFD"}}, // darkblue
{border: "#FD5A77", background: "#FFC0CB", highlight: {border: "#FD5A77", background: "#FFD1D9"}}, // pink
{border: "#4AD63A", background: "#C2FABC", highlight: {border: "#4AD63A", background: "#E6FFE3"}} // mint
];
/**
* Clear all groups
*/
Groups.prototype.clear = function () {
this.groups = {};
this.groups.length = function()
{
var i = 0;
for ( var p in this ) {
if (this.hasOwnProperty(p)) {
i++;
}
}
return i;
}
};
/**
* get group properties of a groupname. If groupname is not found, a new group
* is added.
* @param {*} groupname Can be a number, string, Date, etc.
* @return {Object} group The created group, containing all group properties
*/
Groups.prototype.get = function (groupname) {
var group = this.groups[groupname];
if (group == undefined) {
// create new group
var index = this.defaultIndex % Groups.DEFAULT.length;
this.defaultIndex++;
group = {};
group.color = Groups.DEFAULT[index];
this.groups[groupname] = group;
}
return group;
};
/**
* Add a custom group style
* @param {String} groupname
* @param {Object} style An object containing borderColor,
* backgroundColor, etc.
* @return {Object} group The created group object
*/
Groups.prototype.add = function (groupname, style) {
this.groups[groupname] = style;
if (style.color) {
style.color = Node.parseColor(style.color);
}
return style;
};
/**
* @class Images
* This class loads images and keeps them stored.
*/
Images = function () {
this.images = {};
this.callback = undefined;
};
/**
* Set an onload callback function. This will be called each time an image
* is loaded
* @param {function} callback
*/
Images.prototype.setOnloadCallback = function(callback) {
this.callback = callback;
};
/**
*
* @param {string} url Url of the image
* @return {Image} img The image object
*/
Images.prototype.load = function(url) {
var img = this.images[url];
if (img == undefined) {
// create the image
var images = this;
img = new Image();
this.images[url] = img;
img.onload = function() {
if (images.callback) {
images.callback(this);
}
};
img.src = url;
}
return img;
};
/**
* @constructor Cluster
* Contains the cluster properties for the graph object
*/
function Cluster() {
this.clusterSession = 0;
this.hubThreshold = 5;
}
/**
* This function can be called to open up a specific cluster.
* It will unpack the cluster back one level.
*
* @param node | Node object: cluster to open.
*/
Cluster.prototype.openCluster = function(node) {
var isMovingBeforeClustering = this.moving;
this._expandClusterNode(node,false,true);
// housekeeping
this._updateNodeIndexList();
this._updateDynamicEdges();
this._updateLabels();
// if the simulation was settled, we restart the simulation if a cluster has been formed or expanded
if (this.moving != isMovingBeforeClustering) {
this.start();
}
};
/**
* This function can be called to increase the cluster level. This means that the nodes with only one edge connection will
* be clustered with their connected node. This can be repeated as many times as needed.
* This can be called externally (by a keybind for instance) to reduce the complexity of big datasets.
*/
Cluster.prototype.increaseClusterLevel = function() {
this.updateClusters(-1,false,true);
};
/**
* This function can be called to decrease the cluster level. This means that the nodes with only one edge connection will
* be unpacked if they are a cluster. This can be repeated as many times as needed.
* This can be called externally (by a key-bind for instance) to look into clusters without zooming.
*/
Cluster.prototype.decreaseClusterLevel = function() {
this.updateClusters(1,false,true);
};
/**
* This function clusters on zoom, it can be called with a predefined zoom direction
* If out, check if we can form clusters, if in, check if we can open clusters.
* This function is only called from _zoom()
*
* @param {Int} zoomDirection
* @param {Boolean} recursive | enable or disable recursive calling of the opening of clusters
* @param {Boolean} force | enable or disable forcing
*
* @private
*/
Cluster.prototype.updateClusters = function(zoomDirection,recursive,force) {
var isMovingBeforeClustering = this.moving;
var amountOfNodes = this.nodeIndices.length;
// check if we zoom in or out
if (this.previousScale > this.scale || zoomDirection == -1) { // zoom out
this._formClusters(force);
}
else if (this.previousScale < this.scale || zoomDirection == 1) { // zoom out
this._openClusters(recursive,force);
}
this._updateNodeIndexList();
// if a cluster was NOT formed and the user zoomed out, we try clustering by hubs and update the index again
if (this.nodeIndices.length == amountOfNodes && (this.previousScale > this.scale || zoomDirection == -1)) {
this._aggregateHubs(force);
this._updateNodeIndexList();
}
this.previousScale = this.scale;
// rest of the housekeeping
this._updateDynamicEdges();
this._updateLabels();
// if a cluster was formed, we increase the clusterSession
if (this.nodeIndices.length < amountOfNodes) { // this means a clustering operation has taken place
this.clusterSession += 1;
}
// if the simulation was settled, we restart the simulation if a cluster has been formed or expanded
if (this.moving != isMovingBeforeClustering) {
this.start();
}
};
/**
* this functions starts clustering by hubs
* The minimum hub threshold is set globally
*
* @private
*/
Cluster.prototype._aggregateHubs = function(force) {
this._getHubSize();
this._clusterByHub(force);
};
/**
* This function is fired by keypress. It forces hubs to form.
*
*/
Cluster.prototype.forceAggregateHubs = function() {
var isMovingBeforeClustering = this.moving;
var amountOfNodes = this.nodeIndices.length;
this._aggregateHubs(true);
// housekeeping
this._updateNodeIndexList();
this._updateDynamicEdges();
this._updateLabels();
// if a cluster was formed, we increase the clusterSession
if (this.nodeIndices.length != amountOfNodes) {
this.clusterSession += 1;
}
// if the simulation was settled, we restart the simulation if a cluster has been formed or expanded
if (this.moving != isMovingBeforeClustering) {
this.start();
}
};
/**
* This function loops over all nodes in the nodeIndices list. For each node it checks if it is a cluster and if it
* has to be opened based on the current zoom level.
*
* @private
*/
Cluster.prototype._openClusters = function(recursive,force) {
for (var i = 0; i < this.nodeIndices.length; i++) {
var node = this.nodes[this.nodeIndices[i]];
this._expandClusterNode(node,recursive,force);
}
};
/**
* This function checks if a node has to be opened. This is done by checking the zoom level.
* If the node contains child nodes, this function is recursively called on the child nodes as well.
* This recursive behaviour is optional and can be set by the recursive argument.
*
* @param {Node} parentNode | to check for cluster and expand
* @param {Boolean} recursive | enable or disable recursive calling
* @param {Boolean} force | enable or disable forcing
* @param {Boolean} openAll | This will recursively force all nodes in the parent to be released
* @private
*/
Cluster.prototype._expandClusterNode = function(parentNode, recursive, force, openAll) {
var openedCluster = false;
// first check if node is a cluster
if (parentNode.clusterSize > 1) {
// this means that on a double tap event or a zoom event, the cluster fully unpacks if it is smaller than 20
if (parentNode.clusterSize < 20 && force == false) {
openAll = true;
}
recursive = openAll ? true : recursive;
// if the last child has been added on a smaller scale than current scale (@optimization)
if (parentNode.formationScale < this.scale || force == true) {
// we will check if any of the contained child nodes should be removed from the cluster
for (var containedNodeID in parentNode.containedNodes) {
if (parentNode.containedNodes.hasOwnProperty(containedNodeID)) {
var childNode = parentNode.containedNodes[containedNodeID];
// force expand will expand the largest cluster size clusters. Since we cluster from outside in, we assume that
// the largest cluster is the one that comes from outside
if (force == true) {
if (childNode.clusterSession == parentNode.clusterSessions[parentNode.clusterSessions.length-1]
|| openAll) {
this._expelChildFromParent(parentNode,containedNodeID,recursive,force,openAll);
openedCluster = true;
}
}
else {
if (this._parentNodeInActiveArea(parentNode)) {
this._expelChildFromParent(parentNode,containedNodeID,recursive,force,openAll);
openedCluster = true;
}
}
}
}
}
if (openedCluster == true) {
parentNode.clusterSessions.pop();
}
}
};
/**
* ONLY CALLED FROM _expandClusterNode
*
* This function will expel a child_node from a parent_node. This is to de-cluster the node. This function will remove
* the child node from the parent contained_node object and put it back into the global nodes object.
* The same holds for the edge that was connected to the child node. It is moved back into the global edges object.
*
* @param {Node} parentNode | the parent node
* @param {String} containedNodeID | child_node id as it is contained in the containedNodes object of the parent node
* @param {Boolean} recursive | This will also check if the child needs to be expanded.
* With force and recursive both true, the entire cluster is unpacked
* @param {Boolean} force | This will disregard the zoom level and will expel this child from the parent
* @param {Boolean} openAll | This will recursively force all nodes in the parent to be released
* @private
*/
Cluster.prototype._expelChildFromParent = function(parentNode, containedNodeID, recursive, force, openAll) {
var childNode = parentNode.containedNodes[containedNodeID];
// if child node has been added on smaller scale than current, kick out
if (childNode.formationScale < this.scale || force == true) {
// put the child node back in the global nodes object
this.nodes[containedNodeID] = childNode;
// release the contained edges from this childNode back into the global edges
this._releaseContainedEdges(parentNode,childNode);
// reconnect rerouted edges to the childNode
this._connectEdgeBackToChild(parentNode,childNode);
// validate all edges in dynamicEdges
this._validateEdges(parentNode);
// undo the changes from the clustering operation on the parent node
parentNode.mass -= this.constants.clustering.massTransferCoefficient * childNode.mass;
parentNode.fontSize -= this.constants.clustering.fontSizeMultiplier * childNode.clusterSize;
parentNode.clusterSize -= childNode.clusterSize;
parentNode.dynamicEdgesLength = parentNode.dynamicEdges.length;
// place the child node near the parent, not at the exact same location to avoid chaos in the system
childNode.x = parentNode.x + this.constants.edges.length * 0.2 * (0.5 - Math.random()) * parentNode.clusterSize;
childNode.y = parentNode.y + this.constants.edges.length * 0.2 * (0.5 - Math.random()) * parentNode.clusterSize;
// remove the clusterSession from the child node
childNode.clusterSession = 0;
// remove node from the list
delete parentNode.containedNodes[containedNodeID];
// restart the simulation to reorganise all nodes
this.moving = true;
// recalculate the size of the node on the next time the node is rendered
parentNode.clearSizeCache();
}
// check if a further expansion step is possible if recursivity is enabled
if (recursive == true) {
this._expandClusterNode(childNode,recursive,force,openAll);
}
};
/**
* This function checks if any nodes at the end of their trees have edges below a threshold length
* This function is called only from updateClusters()
* forceLevelCollapse ignores the length of the edge and collapses one level
* This means that a node with only one edge will be clustered with its connected node
*
* @private
* @param {Boolean} force
*/
Cluster.prototype._formClusters = function(force) {
if (force == false) {
this._formClustersByZoom();
}
else {
this._forceClustersByZoom();
}
};
/**
* This function handles the clustering by zooming out, this is based on a minimum edge distance
*
* @private
*/
Cluster.prototype._formClustersByZoom = function() {
var dx,dy,length,
minLength = this.constants.clustering.clusterLength/this.scale;
// check if any edges are shorter than minLength and start the clustering
// the clustering favours the node with the larger mass
for (var edgeID in this.edges) {
if (this.edges.hasOwnProperty(edgeID)) {
var edge = this.edges[edgeID];
if (edge.connected) {
dx = (edge.to.x - edge.from.x);
dy = (edge.to.y - edge.from.y);
length = Math.sqrt(dx * dx + dy * dy);
if (length < minLength) {
// first check which node is larger
var parentNode = edge.from;
var childNode = edge.to;
if (edge.to.mass > edge.from.mass) {
parentNode = edge.to;
childNode = edge.from;
}
if (childNode.dynamicEdgesLength == 1) {
this._addToCluster(parentNode,childNode,false);
}
else if (parentNode.dynamicEdgesLength == 1) {
this._addToCluster(childNode,parentNode,false);
}
}
}
}
}
};
/**
* This function forces the graph to cluster all nodes with only one connecting edge to their
* connected node.
*
* @private
*/
Cluster.prototype._forceClustersByZoom = function() {
for (var nodeID in this.nodes) {
// another node could have absorbed this child.
if (this.nodes.hasOwnProperty(nodeID)) {
var childNode = this.nodes[nodeID];
// the edges can be swallowed by another decrease
if (childNode.dynamicEdgesLength == 1 && childNode.dynamicEdges.length != 0) {
var edge = childNode.dynamicEdges[0];
var parentNode = (edge.toId == childNode.id) ? this.nodes[edge.fromId] : this.nodes[edge.toId];
// group to the largest node
if (parentNode.mass > childNode.mass) {
this._addToCluster(parentNode,childNode,true);
}
else {
this._addToCluster(childNode,parentNode,true);
}
}
}
}
};
/**
*
* @param {Boolean} force
* @private
*/
Cluster.prototype._clusterByHub = function(force) {
var dx,dy,length;
var minLength = this.constants.clustering.clusterLength/this.scale;
var allowCluster = false;
// we loop over all nodes in the list
for (var nodeID in this.nodes) {
// we check if it is still available since it can be used by the clustering in this loop
if (this.nodes.hasOwnProperty(nodeID)) {
var hubNode = this.nodes[nodeID];
// we decide if the node is a hub
if (hubNode.dynamicEdgesLength >= this.hubThreshold) {
// we create a list of edges because the dynamicEdges change over the course of this loop
var edgesIDarray = [];
var amountOfInitialEdges = hubNode.dynamicEdges.length;
for (var j = 0; j < amountOfInitialEdges; j++) {
edgesIDarray.push(hubNode.dynamicEdges[j].id);
}
// if the hub clustering is not forces, we check if one of the edges connected
// to a cluster is small enough based on the constants.clustering.clusterLength
if (force == false) {
allowCluster = false;
for (j = 0; j < amountOfInitialEdges; j++) {
var edge = this.edges[edgesIDarray[j]];
if (edge !== undefined) {
if (edge.connected) {
dx = (edge.to.x - edge.from.x);
dy = (edge.to.y - edge.from.y);
length = Math.sqrt(dx * dx + dy * dy);
if (length < minLength) {
allowCluster = true;
break;
}
}
}
}
}
// start the clustering if allowed
if ((!force && allowCluster) || force) {
// we loop over all edges INITIALLY connected to this hub
for (j = 0; j < amountOfInitialEdges; j++) {
edge = this.edges[edgesIDarray[j]];
// the edge can be clustered by this function in a previous loop
if (edge !== undefined) {
var childNode = this.nodes[(edge.fromId == hubNode.id) ? edge.toId : edge.fromId];
// we do not want hubs to merge with other hubs.
if (childNode.dynamicEdges.length <= this.hubThreshold) {
this._addToCluster(hubNode,childNode,force);
}
}
}
}
}
}
}
};
/**
* This function adds the child node to the parent node, creating a cluster if it is not already.
* This function is called only from updateClusters()
*
* @param {Node} parentNode | this is the node that will house the child node
* @param {Node} childNode | this node will be deleted from the global this.nodes and stored in the parent node
* @param {Boolean} force | true will only update the remainingEdges at the very end of the clustering, ensuring single level collapse
* @private
*/
Cluster.prototype._addToCluster = function(parentNode, childNode, force) {
// join child node in the parent node
parentNode.containedNodes[childNode.id] = childNode;
// manage all the edges connected to the child and parent nodes
for (var i = 0; i < childNode.dynamicEdges.length; i++) {
var edge = childNode.dynamicEdges[i];
if (edge.toId == parentNode.id || edge.fromId == parentNode.id) { // edge connected to parentNode
this._addToContainedEdges(parentNode,childNode,edge);
}
else {
this._connectEdgeToCluster(parentNode,childNode,edge);
}
}
childNode.dynamicEdges = [];
// remove the childNode from the global nodes object
delete this.nodes[childNode.id];
var massBefore = parentNode.mass;
childNode.clusterSession = this.clusterSession;
parentNode.mass += this.constants.clustering.massTransferCoefficient * childNode.mass;
parentNode.clusterSize += childNode.clusterSize;
parentNode.fontSize += this.constants.clustering.fontSizeMultiplier * childNode.clusterSize;
// keep track of the clustersessions so we can open the cluster up as it has been formed.
if (parentNode.clusterSessions[parentNode.clusterSessions.length - 1] != this.clusterSession) {
parentNode.clusterSessions.push(this.clusterSession);
}
// giving the clusters a dynamic formationScale to ensure not all clusters open up when zoomed
if (force == true) {
parentNode.formationScale = Math.pow(1 - (1.0/11.0),this.clusterSession+2);
}
else {
parentNode.formationScale = this.scale; // The latest child has been added on this scale
}
// recalculate the size of the node on the next time the node is rendered
parentNode.clearSizeCache();
// set the pop-out scale for the childnode
parentNode.containedNodes[childNode.id].formationScale = parentNode.formationScale;
// nullify the movement velocity of the child, this is to avoid hectic behaviour
childNode.clearVelocity();
// the mass has altered, preservation of energy dictates the velocity to be updated
parentNode.updateVelocity(massBefore);
// restart the simulation to reorganise all nodes
this.moving = true;
};
/**
* This function will apply the changes made to the remainingEdges during the formation of the clusters.
* This is a seperate function to allow for level-wise collapsing of the node tree.
* It has to be called if a level is collapsed. It is called by _formClusters().
* @private
*/
Cluster.prototype._updateDynamicEdges = function() {
for (var i = 0; i < this.nodeIndices.length; i++) {
var node = this.nodes[this.nodeIndices[i]];
node.dynamicEdgesLength = node.dynamicEdges.length;
// this corrects for multiple edges pointing at the same other node
var correction = 0;
if (node.dynamicEdgesLength > 1) {
for (var j = 0; j < node.dynamicEdgesLength - 1; j++) {
var edgeToId = node.dynamicEdges[j].toId;
var edgeFromId = node.dynamicEdges[j].fromId;
for (var k = j+1; k < node.dynamicEdgesLength; k++) {
if ((node.dynamicEdges[k].toId == edgeToId && node.dynamicEdges[k].fromId == edgeFromId) ||
(node.dynamicEdges[k].fromId == edgeToId && node.dynamicEdges[k].toId == edgeFromId)) {
correction += 1;
}
}
}
}
node.dynamicEdgesLength -= correction;
}
};
/**
* This adds an edge from the childNode to the contained edges of the parent node
*
* @param parentNode | Node object
* @param childNode | Node object
* @param edge | Edge object
* @private
*/
Cluster.prototype._addToContainedEdges = function(parentNode, childNode, edge) {
// create an array object if it does not yet exist for this childNode
if (!(parentNode.containedEdges.hasOwnProperty(childNode.id))) {
parentNode.containedEdges[childNode.id] = []
}
// add this edge to the list
parentNode.containedEdges[childNode.id].push(edge);
// remove the edge from the global edges object
delete this.edges[edge.id];
// remove the edge from the parent object
for (var i = 0; i < parentNode.dynamicEdges.length; i++) {
if (parentNode.dynamicEdges[i].id == edge.id) {
parentNode.dynamicEdges.splice(i,1);
break;
}
}
};
/**
* This function connects an edge that was connected to a child node to the parent node.
* It keeps track of which nodes it has been connected to with the originalID array.
*
* @param parentNode | Node object
* @param childNode | Node object
* @param edge | Edge object
* @private
*/
Cluster.prototype._connectEdgeToCluster = function(parentNode, childNode, edge) {
if (edge.toId == childNode.id) { // edge connected to other node on the "to" side
edge.originalToID.push(childNode.id);
edge.to = parentNode;
edge.toId = parentNode.id;
}
else { // edge connected to other node with the "from" side
edge.originalFromID.push(childNode.id);
edge.from = parentNode;
edge.fromId = parentNode.id;
}
this._addToReroutedEdges(parentNode,childNode,edge);
};
/**
* This adds an edge from the childNode to the rerouted edges of the parent node
*
* @param parentNode | Node object
* @param childNode | Node object
* @param edge | Edge object
* @private
*/
Cluster.prototype._addToReroutedEdges = function(parentNode, childNode, edge) {
// create an array object if it does not yet exist for this childNode
// we store the edge in the rerouted edges so we can restore it when the cluster pops open
if (!(parentNode.reroutedEdges.hasOwnProperty(childNode.id))) {
parentNode.reroutedEdges[childNode.id] = [];
}
parentNode.reroutedEdges[childNode.id].push(edge);
// this edge becomes part of the dynamicEdges of the cluster node
parentNode.dynamicEdges.push(edge);
};
/**
* This function connects an edge that was connected to a cluster node back to the child node.
*
* @param parentNode | Node object
* @param childNode | Node object
* @private
*/
Cluster.prototype._connectEdgeBackToChild = function(parentNode, childNode) {
if (parentNode.reroutedEdges.hasOwnProperty(childNode.id)) {
for (var i = 0; i < parentNode.reroutedEdges[childNode.id].length; i++) {
var edge = parentNode.reroutedEdges[childNode.id][i];
if (edge.originalFromID[edge.originalFromID.length-1] == childNode.id) {
edge.originalFromID.pop();
edge.fromId = childNode.id;
edge.from = childNode;
}
else {
edge.originalToID.pop();
edge.toId = childNode.id;
edge.to = childNode;
}
// append this edge to the list of edges connecting to the childnode
childNode.dynamicEdges.push(edge);
// remove the edge from the parent object
for (var j = 0; j < parentNode.dynamicEdges.length; j++) {
if (parentNode.dynamicEdges[j].id == edge.id) {
parentNode.dynamicEdges.splice(j,1);
break;
}
}
}
// remove the entry from the rerouted edges
delete parentNode.reroutedEdges[childNode.id];
}
};
/**
* When loops are clustered, an edge can be both in the rerouted array and the contained array.
* This function is called last to verify that all edges in dynamicEdges are in fact connected to the
* parentNode
*
* @param parentNode | Node object
* @private
*/
Cluster.prototype._validateEdges = function(parentNode) {
// TODO: check if good idea
for (var i = 0; i < parentNode.dynamicEdges.length; i++) {
var edge = parentNode.dynamicEdges[i];
if (parentNode.id != edge.toId && parentNode.id != edge.fromId) {
parentNode.dynamicEdges.splice(i,1);
}
}
};
/**
* This function released the contained edges back into the global domain and puts them back into the
* dynamic edges of both parent and child.
*
* @param {Node} parentNode |
* @param {Node} childNode |
* @private
*/
Cluster.prototype._releaseContainedEdges = function(parentNode, childNode) {
for (var i = 0; i < parentNode.containedEdges[childNode.id].length; i++) {
var edge = parentNode.containedEdges[childNode.id][i];
// put the edge back in the global edges object
this.edges[edge.id] = edge;
// put the edge back in the dynamic edges of the child and parent
childNode.dynamicEdges.push(edge);
parentNode.dynamicEdges.push(edge);
}
// remove the entry from the contained edges
delete parentNode.containedEdges[childNode.id];
};
// ------------------- UTILITY FUNCTIONS ---------------------------- //
/**
* This updates the node labels for all nodes (for debugging purposes)
* @private
*/
Cluster.prototype._updateLabels = function() {
var nodeID;
// update node labels
for (nodeID in this.nodes) {
if (this.nodes.hasOwnProperty(nodeID)) {
var node = this.nodes[nodeID];
if (node.clusterSize > 1) {
node.label = "[".concat(String(node.clusterSize),"]");
}
}
}
// update node labels
for (nodeID in this.nodes) {
if (this.nodes.hasOwnProperty(nodeID)) {
node = this.nodes[nodeID];
if (node.clusterSize == 1) {
node.label = String(node.id);
}
}
}
/* Debug Override */
for (nodeID in this.nodes) {
if (this.nodes.hasOwnProperty(nodeID)) {
node = this.nodes[nodeID];
node.label = String(node.clusterSize).concat(":",String(node.id));
}
}
};
/**
* This function determines if the cluster we want to decluster is in the active area
* this means around the zoom center
*
* @param {Node} node
* @returns {boolean}
* @private
*/
Cluster.prototype._parentNodeInActiveArea = function(node) {
return (
Math.abs(node.x - this.zoomCenter.x) <= this.constants.clustering.activeAreaBoxSize/this.scale
&&
Math.abs(node.y - this.zoomCenter.y) <= this.constants.clustering.activeAreaBoxSize/this.scale
)
};
/**
* This is an adaptation of the original repositioning function. This is called if the system is clustered initially
* It puts large clusters away from the center and randomizes the order.
*
* @private
*/
Cluster.prototype._repositionNodes = function() {
for (var i = 0; i < this.nodeIndices.length; i++) {
var node = this.nodes[this.nodeIndices[i]];
if (!node.isFixed()) {
var radius = this.constants.edges.length * (1 + 0.6*node.clusterSize);
var angle = 2 * Math.PI * Math.random();
node.x = radius * Math.cos(angle);
node.y = radius * Math.sin(angle);
}
}
};
/**
* We determine how many connections denote an important hub.
* We take the mean + 2*std as the important hub size. (Assuming a normal distribution of data, ~2.2%)
*
* @private
*/
Cluster.prototype._getHubSize = function() {
var average = 0;
var averageSquared = 0;
var hubCounter = 0;
var largestHub = 0;
for (var i = 0; i < this.nodeIndices.length; i++) {
var node = this.nodes[this.nodeIndices[i]];
if (node.dynamicEdgesLength > largestHub) {
largestHub = node.dynamicEdgesLength;
}
average += node.dynamicEdgesLength;
averageSquared += Math.pow(node.dynamicEdgesLength,2);
hubCounter += 1;
}
average = average / hubCounter;
averageSquared = averageSquared / hubCounter;
var variance = averageSquared - Math.pow(average,2);
var standardDeviation = Math.sqrt(variance);
this.hubThreshold = Math.floor(average + 2*standardDeviation);
// always have at least one to cluster
if (this.hubThreshold > largestHub) {
this.hubThreshold = largestHub;
}
// console.log("average",average,"averageSQ",averageSquared,"var",variance,"std",standardDeviation);
// console.log("hubThreshold:",this.hubThreshold);
};
/**
* We get the amount of "extension nodes" or snakes. These are not quickly clustered with the outliers and hubs methods
* with this amount we can cluster specifically on these snakes.
*
* @returns {number}
* @private
*/
Cluster.prototype._getAmountOfSnakes = function() {
var snakes = 0;
for (nodeID in this.nodes) {
if (this.nodes.hasOwnProperty(nodeID)) {
if (this.nodes[nodeID].dynamicEdges.length == 2) {
snakes += 1;
}
}
}
return snakes;
};
/**
* @constructor Graph
* Create a graph visualization, displaying nodes and edges.
*
* @param {Element} container The DOM element in which the Graph will
* be created. Normally a div element.
* @param {Object} data An object containing parameters
* {Array} nodes
* {Array} edges
* @param {Object} options Options
*/
function Graph (container, data, options) {
// create variables and set default values
this.containerElement = container;
this.width = '100%';
this.height = '100%';
this.refreshRate = 50; // milliseconds
this.stabilize = true; // stabilize before displaying the graph
this.selectable = true;
// set constant values
this.constants = {
nodes: {
radiusMin: 5,
radiusMax: 20,
radius: 5,
distance: 100, // px
shape: 'ellipse',
image: undefined,
widthMin: 16, // px
widthMax: 64, // px
fontColor: 'black',
fontSize: 14, // px
//fontFace: verdana,
fontFace: 'arial',
color: {
border: '#2B7CE9',
background: '#97C2FC',
highlight: {
border: '#2B7CE9',
background: '#D2E5FF'
}
},
borderColor: '#2B7CE9',
backgroundColor: '#97C2FC',
highlightColor: '#D2E5FF',
group: undefined
},
edges: {
widthMin: 1,
widthMax: 15,
width: 1,
style: 'line',
color: '#343434',
fontColor: '#343434',
fontSize: 14, // px
fontFace: 'arial',
//distance: 100, //px
length: 100, // px
dash: {
length: 10,
gap: 5,
altLength: undefined
}
},
clustering: { // TODO: naming of variables
maxNumberOfNodes: 100, // for automatic (initial) clustering //
clusterLength: 30, // threshold edge length for clusteringl
fontSizeMultiplier: 3, // how much the cluster font size grows per node (in px)
forceAmplification: 0.7, // amount of clusterSize between two nodes multiply this value (+1) with the repulsion force
distanceAmplification: 0.3, // amount of clusterSize between two nodes multiply this value (+1) with the repulsion force
edgeStrength: 0.01,
edgeGrowth: 11, // amount of clusterSize connected to the edge is multiplied with this and added to edgeLength
clusterSizeWidthFactor: 10,
clusterSizeHeightFactor: 10,
clusterSizeRadiusFactor: 10,
activeAreaBoxSize: 100, // box area around the curser where clusters are popped open
massTransferCoefficient: 1 // parent.mass += massTransferCoefficient * child.mass
},
minForce: 0.05,
minVelocity: 0.02, // px/s
maxIterations: 1000 // maximum number of iteration to stabilize
};
// call the constructor of the cluster object
Cluster.call(this);
var graph = this;
this.freezeSimulation = false;
this.nodeIndices = []; // the node indices list is used to speed up the computation of the repulsion fields
this.tapTimer = 0;
this.pocketUniverse = {};
this.nodes = {}; // object with Node objects
this.edges = {}; // object with Edge objects
this.zoomCenter = {}; // object with x and y elements used for determining the center of the zoom action
this.scale = 1; // defining the global scale variable in the constructor
this.previousScale = this.scale; // this is used to check if the zoom operation is zooming in or out
// TODO: create a counter to keep track on the number of nodes having values
// TODO: create a counter to keep track on the number of nodes currently moving
// TODO: create a counter to keep track on the number of edges having values
this.nodesData = null; // A DataSet or DataView
this.edgesData = null; // A DataSet or DataView
// create event listeners used to subscribe on the DataSets of the nodes and edges
var me = this;
this.nodesListeners = {
'add': function (event, params) {
me._addNodes(params.items);
me.start();
},
'update': function (event, params) {
me._updateNodes(params.items);
me.start();
},
'remove': function (event, params) {
me._removeNodes(params.items);
me.start();
}
};
this.edgesListeners = {
'add': function (event, params) {
me._addEdges(params.items);
me.start();
},
'update': function (event, params) {
me._updateEdges(params.items);
me.start();
},
'remove': function (event, params) {
me._removeEdges(params.items);
me.start();
}
};
this.groups = new Groups(); // object with groups
this.images = new Images(); // object with images
this.images.setOnloadCallback(function () {
graph._redraw();
});
// properties of the data
this.moving = false; // True if any of the nodes have an undefined position
this.selection = [];
this.timer = undefined;
// create a frame and canvas
this._create();
// apply options
this.setOptions(options);
// draw data
this.setData(data); // TODO: option to render (start())
// zoom so all data will fit on the screen
this.zoomToFit();
// cluster if the data set is big
this.clusterToFit(true);
// updates the lables after clustering
this._updateLabels();
// find a stable position or start animating to a stable position
if (this.stabilize) {
this._doStabilize();
}
this.start();
}
/**
* We add the functionality of the cluster object to the graph object
* @type {Cluster.prototype}
*/
Graph.prototype = Object.create(Cluster.prototype);
/**
* This function clusters until the maxNumberOfNodes has been reached
*
* @param {Boolean} reposition
*/
Graph.prototype.clusterToFit = function(reposition) {
var numberOfNodes = this.nodeIndices.length;
var maxNumberOfNodes = this.constants.clustering.maxNumberOfNodes;
var maxLevels = 10;
var level = 0;
// we first cluster the hubs, then we pull in the outliers, repeat
while (numberOfNodes >= maxNumberOfNodes && level < maxLevels) {
if (level % 5 == 0) {
console.log("Aggregating Hubs @ level: ",level,". Threshold:", this.hubThreshold,"clusterSession",this.clusterSession);
this.forceAggregateHubs();
}
else {
console.log("Pulling in Outliers @ level: ",level,"clusterSession",this.clusterSession);
this.increaseClusterLevel();
}
numberOfNodes = this.nodeIndices.length;
level += 1;
}
// after the clustering we reposition the nodes to avoid initial chaos
if (level > 1 && reposition == true) {
this._repositionNodes();
}
};
/**
* This function zooms out to fit all data on screen based on amount of nodes
*/
Graph.prototype.zoomToFit = function() {
var numberOfNodes = this.nodeIndices.length;
var zoomLevel = 105 / (numberOfNodes + 80); // this is obtained from fitting a dataset from 5 points with scale levels that looked good.
if (zoomLevel > 1.0) {
zoomLevel = 1.0;
}
if (!('mousewheelScale' in this.pinch)) {
this.pinch.mousewheelScale = zoomLevel;
}
this._setScale(zoomLevel);
};
/**
* Update the this.nodeIndices with the most recent node index list
* @private
*/
Graph.prototype._updateNodeIndexList = function() {
this.nodeIndices = [];
for (var idx in this.nodes) {
if (this.nodes.hasOwnProperty(idx)) {
this.nodeIndices.push(idx);
}
}
};
/**
* Set nodes and edges, and optionally options as well.
*
* @param {Object} data Object containing parameters:
* {Array | DataSet | DataView} [nodes] Array with nodes
* {Array | DataSet | DataView} [edges] Array with edges
* {String} [dot] String containing data in DOT format
* {Options} [options] Object with options
*/
Graph.prototype.setData = function(data) {
if (data && data.dot && (data.nodes || data.edges)) {
throw new SyntaxError('Data must contain either parameter "dot" or ' +
' parameter pair "nodes" and "edges", but not both.');
}
// set options
this.setOptions(data && data.options);
// set all data
if (data && data.dot) {
// parse DOT file
if(data && data.dot) {
var dotData = vis.util.DOTToGraph(data.dot);
this.setData(dotData);
return;
}
}
else {
this._setNodes(data && data.nodes);
this._setEdges(data && data.edges);
}
// updating the list of node indices
};
/**
* Set options
* @param {Object} options
*/
Graph.prototype.setOptions = function (options) {
if (options) {
// retrieve parameter values
if (options.width !== undefined) {this.width = options.width;}
if (options.height !== undefined) {this.height = options.height;}
if (options.stabilize !== undefined) {this.stabilize = options.stabilize;}
if (options.selectable !== undefined) {this.selectable = options.selectable;}
// TODO: work out these options and document them
if (options.edges) {
for (var prop in options.edges) {
if (options.edges.hasOwnProperty(prop)) {
this.constants.edges[prop] = options.edges[prop];
}
}
if (options.edges.length !== undefined &&
options.nodes && options.nodes.distance === undefined) {
this.constants.edges.length = options.edges.length;
this.constants.nodes.distance = options.edges.length * 1.25;
}
if (!options.edges.fontColor) {
this.constants.edges.fontColor = options.edges.color;
}
// Added to support dashed lines
// David Jordan
// 2012-08-08
if (options.edges.dash) {
if (options.edges.dash.length !== undefined) {
this.constants.edges.dash.length = options.edges.dash.length;
}
if (options.edges.dash.gap !== undefined) {
this.constants.edges.dash.gap = options.edges.dash.gap;
}
if (options.edges.dash.altLength !== undefined) {
this.constants.edges.dash.altLength = options.edges.dash.altLength;
}
}
}
if (options.nodes) {
for (prop in options.nodes) {
if (options.nodes.hasOwnProperty(prop)) {
this.constants.nodes[prop] = options.nodes[prop];
}
}
if (options.nodes.color) {
this.constants.nodes.color = Node.parseColor(options.nodes.color);
}
/*
if (options.nodes.widthMin) this.constants.nodes.radiusMin = options.nodes.widthMin;
if (options.nodes.widthMax) this.constants.nodes.radiusMax = options.nodes.widthMax;
*/
}
if (options.groups) {
for (var groupname in options.groups) {
if (options.groups.hasOwnProperty(groupname)) {
var group = options.groups[groupname];
this.groups.add(groupname, group);
}
}
}
}
this.setSize(this.width, this.height);
this._setTranslation(this.frame.clientWidth / 2, this.frame.clientHeight / 2);
this._setScale(1);
};
/**
* fire an event
* @param {String} event The name of an event, for example 'select'
* @param {Object} params Optional object with event parameters
* @private
*/
Graph.prototype._trigger = function (event, params) {
events.trigger(this, event, params);
};
/**
* Create the main frame for the Graph.
* This function is executed once when a Graph object is created. The frame
* contains a canvas, and this canvas contains all objects like the axis and
* nodes.
* @private
*/
Graph.prototype._create = function () {
// remove all elements from the container element.
while (this.containerElement.hasChildNodes()) {
this.containerElement.removeChild(this.containerElement.firstChild);
}
this.frame = document.createElement('div');
this.frame.className = 'graph-frame';
this.frame.style.position = 'relative';
this.frame.style.overflow = 'hidden';
// create the graph canvas (HTML canvas element)
this.frame.canvas = document.createElement( 'canvas' );
this.frame.canvas.style.position = 'relative';
this.frame.appendChild(this.frame.canvas);
if (!this.frame.canvas.getContext) {
var noCanvas = document.createElement( 'DIV' );
noCanvas.style.color = 'red';
noCanvas.style.fontWeight = 'bold' ;
noCanvas.style.padding = '10px';
noCanvas.innerHTML = 'Error: your browser does not support HTML canvas';
this.frame.canvas.appendChild(noCanvas);
}
var me = this;
this.drag = {};
this.pinch = {};
this.hammer = Hammer(this.frame.canvas, {
prevent_default: true
});
this.hammer.on('tap', me._onTap.bind(me) );
this.hammer.on('hold', me._onHold.bind(me) );
this.hammer.on('pinch', me._onPinch.bind(me) );
this.hammer.on('touch', me._onTouch.bind(me) );
this.hammer.on('dragstart', me._onDragStart.bind(me) );
this.hammer.on('drag', me._onDrag.bind(me) );
this.hammer.on('dragend', me._onDragEnd.bind(me) );
this.hammer.on('mousewheel',me._onMouseWheel.bind(me) );
this.hammer.on('DOMMouseScroll',me._onMouseWheel.bind(me) ); // for FF
this.hammer.on('mousemove', me._onMouseMoveTitle.bind(me) );
this.mouseTrap = mouseTrap;
this.mouseTrap.bind("=",this.decreaseClusterLevel.bind(me));
this.mouseTrap.bind("-",this.increaseClusterLevel.bind(me));
this.mouseTrap.bind("s",this.singleStep.bind(me));
this.mouseTrap.bind("h",this.forceAggregateHubs.bind(me));
this.mouseTrap.bind("f",this.toggleFreeze.bind(me));
// add the frame to the container element
this.containerElement.appendChild(this.frame);
};
/**
*
* @param {{x: Number, y: Number}} pointer
* @return {Number | null} node
* @private
*/
Graph.prototype._getNodeAt = function (pointer) {
var x = this._canvasToX(pointer.x);
var y = this._canvasToY(pointer.y);
var obj = {
left: x,
top: y,
right: x,
bottom: y
};
// if there are overlapping nodes, select the last one, this is the
// one which is drawn on top of the others
var overlappingNodes = this._getNodesOverlappingWith(obj);
return (overlappingNodes.length > 0) ?
overlappingNodes[overlappingNodes.length - 1] : null;
};
/**
* Get the pointer location from a touch location
* @param {{pageX: Number, pageY: Number}} touch
* @return {{x: Number, y: Number}} pointer
* @private
*/
Graph.prototype._getPointer = function (touch) {
return {
x: touch.pageX - vis.util.getAbsoluteLeft(this.frame.canvas),
y: touch.pageY - vis.util.getAbsoluteTop(this.frame.canvas)
};
};
/**
* On start of a touch gesture, store the pointer
* @param event
* @private
*/
Graph.prototype._onTouch = function (event) {
this.drag.pointer = this._getPointer(event.gesture.touches[0]);
this.drag.pinched = false;
this.pinch.scale = this._getScale();
};
/**
* handle drag start event
* @private
*/
Graph.prototype._onDragStart = function () {
var drag = this.drag;
drag.selection = [];
drag.translation = this._getTranslation();
drag.nodeId = this._getNodeAt(drag.pointer);
// note: drag.pointer is set in _onTouch to get the initial touch location
var node = this.nodes[drag.nodeId];
if (node) {
// select the clicked node if not yet selected
if (!node.isSelected()) {
this._selectNodes([drag.nodeId]);
}
// create an array with the selected nodes and their original location and status
var me = this;
this.selection.forEach(function (id) {
var node = me.nodes[id];
if (node) {
var s = {
id: id,
node: node,
// store original x, y, xFixed and yFixed, make the node temporarily Fixed
x: node.x,
y: node.y,
xFixed: node.xFixed,
yFixed: node.yFixed
};
node.xFixed = true;
node.yFixed = true;
drag.selection.push(s);
}
});
}
};
/**
* handle drag event
* @private
*/
Graph.prototype._onDrag = function (event) {
if (this.drag.pinched) {
return;
}
var pointer = this._getPointer(event.gesture.touches[0]);
var me = this,
drag = this.drag,
selection = drag.selection;
if (selection && selection.length) {
// calculate delta's and new location
var deltaX = pointer.x - drag.pointer.x,
deltaY = pointer.y - drag.pointer.y;
// update position of all selected nodes
selection.forEach(function (s) {
var node = s.node;
if (!s.xFixed) {
node.x = me._canvasToX(me._xToCanvas(s.x) + deltaX);
}
if (!s.yFixed) {
node.y = me._canvasToY(me._yToCanvas(s.y) + deltaY);
}
});
// start animation if not yet running
if (!this.moving) {
this.moving = true;
this.start();
}
}
else {
// move the graph
var diffX = pointer.x - this.drag.pointer.x;
var diffY = pointer.y - this.drag.pointer.y;
this._setTranslation(
this.drag.translation.x + diffX,
this.drag.translation.y + diffY);
this._redraw();
this.moved = true;
}
};
/**
* handle drag start event
* @private
*/
Graph.prototype._onDragEnd = function () {
var selection = this.drag.selection;
if (selection) {
selection.forEach(function (s) {
// restore original xFixed and yFixed
s.node.xFixed = s.xFixed;
s.node.yFixed = s.yFixed;
});
}
};
/**
* handle tap/click event: select/unselect a node
* @private
*/
Graph.prototype._onTap = function (event) {
var pointer = this._getPointer(event.gesture.touches[0]);
var nodeId = this._getNodeAt(pointer);
var node = this.nodes[nodeId];
var elapsedTime = new Date().getTime() - this.tapTimer;
this.tapTimer = new Date().getTime();
if (node) {
if (node.isSelected() && elapsedTime < 300) {
this.openCluster(node);
}
// select this node
this._selectNodes([nodeId]);
if (!this.moving) {
this._redraw();
}
}
else {
// remove selection
this._unselectNodes();
this._redraw();
}
};
/**
* handle long tap event: multi select nodes
* @private
*/
Graph.prototype._onHold = function (event) {
var pointer = this._getPointer(event.gesture.touches[0]);
var nodeId = this._getNodeAt(pointer);
var node = this.nodes[nodeId];
if (node) {
if (!node.isSelected()) {
// select this node, keep previous selection
var append = true;
this._selectNodes([nodeId], append);
}
else {
this._unselectNodes([nodeId]);
}
if (!this.moving) {
this._redraw();
}
}
else {
// Do nothing
}
};
/**
* Handle pinch event
* @param event
* @private
*/
Graph.prototype._onPinch = function (event) {
var pointer = this._getPointer(event.gesture.center);
this.drag.pinched = true;
if (!('scale' in this.pinch)) {
this.pinch.scale = 1;
}
// TODO: enable moving while pinching?
var scale = this.pinch.scale * event.gesture.scale;
this._zoom(scale, pointer)
};
/**
* Zoom the graph in or out
* @param {Number} scale a number around 1, and between 0.01 and 10
* @param {{x: Number, y: Number}} pointer
* @return {Number} appliedScale scale is limited within the boundaries
* @private
*/
Graph.prototype._zoom = function(scale, pointer) {
var scaleOld = this._getScale();
if (scale < 0.01) {
scale = 0.01;
}
if (scale > 10) {
scale = 10;
}
// + this.frame.canvas.clientHeight / 2
var translation = this._getTranslation();
var scaleFrac = scale / scaleOld;
var tx = (1 - scaleFrac) * pointer.x + translation.x * scaleFrac;
var ty = (1 - scaleFrac) * pointer.y + translation.y * scaleFrac;
this.zoomCenter = {"x" : this._canvasToX(pointer.x),
"y" : this._canvasToY(pointer.y)};
// this.zoomCenter = {"x" : pointer.x,"y" : pointer.y };
this._setScale(scale);
this._setTranslation(tx, ty);
this.updateClusters(0,false,false);
this._redraw();
//console.log("current zoomscale:",this.scale)
return scale;
};
/**
* Event handler for mouse wheel event, used to zoom the timeline
* See http://adomas.org/javascript-mouse-wheel/
* https://github.com/EightMedia/hammer.js/issues/256
* @param {MouseEvent} event
* @private
*/
Graph.prototype._onMouseWheel = function(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) {
if (!('mousewheelScale' in this.pinch)) {
this.pinch.mousewheelScale = 1;
}
// calculate the new scale
var scale = this.pinch.mousewheelScale;
var zoom = delta / 10;
if (delta < 0) {
zoom = zoom / (1 - zoom);
}
scale *= (1 + zoom);
// calculate the pointer location
var gesture = util.fakeGesture(this, event);
var pointer = this._getPointer(gesture.center);
// apply the new scale
scale = this._zoom(scale, pointer);
// store the new, applied scale
this.pinch.mousewheelScale = scale;
}
// Prevent default actions caused by mouse wheel.
event.preventDefault();
};
/**
* Mouse move handler for checking whether the title moves over a node with a title.
* @param {Event} event
* @private
*/
Graph.prototype._onMouseMoveTitle = function (event) {
var gesture = util.fakeGesture(this, event);
var pointer = this._getPointer(gesture.center);
// check if the previously selected node is still selected
if (this.popupNode) {
this._checkHidePopup(pointer);
}
// start a timeout that will check if the mouse is positioned above
// an element
var me = this;
var checkShow = function() {
me._checkShowPopup(pointer);
};
if (this.popupTimer) {
clearInterval(this.popupTimer); // stop any running timer
}
if (!this.leftButtonDown) {
this.popupTimer = setTimeout(checkShow, 300);
}
};
/**
* Check if there is an element on the given position in the graph
* (a node or edge). If so, and if this element has a title,
* show a popup window with its title.
*
* @param {{x:Number, y:Number}} pointer
* @private
*/
Graph.prototype._checkShowPopup = function (pointer) {
var obj = {
left: this._canvasToX(pointer.x),
top: this._canvasToY(pointer.y),
right: this._canvasToX(pointer.x),
bottom: this._canvasToY(pointer.y)
};
var id;
var lastPopupNode = this.popupNode;
if (this.popupNode == undefined) {
// search the nodes for overlap, select the top one in case of multiple nodes
var nodes = this.nodes;
for (id in nodes) {
if (nodes.hasOwnProperty(id)) {
var node = nodes[id];
if (node.getTitle() != undefined && node.isOverlappingWith(obj)) {
this.popupNode = node;
break;
}
}
}
}
if (this.popupNode == undefined) {
// search the edges for overlap
var edges = this.edges;
for (id in edges) {
if (edges.hasOwnProperty(id)) {
var edge = edges[id];
if (edge.connected && (edge.getTitle() != undefined) &&
edge.isOverlappingWith(obj)) {
this.popupNode = edge;
break;
}
}
}
}
if (this.popupNode) {
// show popup message window
if (this.popupNode != lastPopupNode) {
var me = this;
if (!me.popup) {
me.popup = new Popup(me.frame);
}
// adjust a small offset such that the mouse cursor is located in the
// bottom left location of the popup, and you can easily move over the
// popup area
me.popup.setPosition(pointer.x - 3, pointer.y - 3);
me.popup.setText(me.popupNode.getTitle());
me.popup.show();
}
}
else {
if (this.popup) {
this.popup.hide();
}
}
};
/**
* Check if the popup must be hided, which is the case when the mouse is no
* longer hovering on the object
* @param {{x:Number, y:Number}} pointer
* @private
*/
Graph.prototype._checkHidePopup = function (pointer) {
if (!this.popupNode || !this._getNodeAt(pointer) ) {
this.popupNode = undefined;
if (this.popup) {
this.popup.hide();
}
}
};
/**
* Unselect selected nodes. If no selection array is provided, all nodes
* are unselected
* @param {Object[]} selection Array with selection objects, each selection
* object has a parameter row. Optional
* @param {Boolean} triggerSelect If true (default), the select event
* is triggered when nodes are unselected
* @return {Boolean} changed True if the selection is changed
* @private
*/
Graph.prototype._unselectNodes = function(selection, triggerSelect) {
var changed = false;
var i, iMax, id;
if (selection) {
// remove provided selections
for (i = 0, iMax = selection.length; i < iMax; i++) {
id = selection[i];
this.nodes[id].unselect();
var j = 0;
while (j < this.selection.length) {
if (this.selection[j] == id) {
this.selection.splice(j, 1);
changed = true;
}
else {
j++;
}
}
}
}
else if (this.selection && this.selection.length) {
// remove all selections
for (i = 0, iMax = this.selection.length; i < iMax; i++) {
id = this.selection[i];
this.nodes[id].unselect();
changed = true;
}
this.selection = [];
}
if (changed && (triggerSelect == true || triggerSelect == undefined)) {
// fire the select event
this._trigger('select');
}
return changed;
};
/**
* select all nodes on given location x, y
* @param {Array} selection an array with node ids
* @param {boolean} append If true, the new selection will be appended to the
* current selection (except for duplicate entries)
* @return {Boolean} changed True if the selection is changed
* @private
*/
Graph.prototype._selectNodes = function(selection, append) {
var changed = false;
var i, iMax;
// TODO: the selectNodes method is a little messy, rework this
// check if the current selection equals the desired selection
var selectionAlreadyThere = true;
if (selection.length != this.selection.length) {
selectionAlreadyThere = false;
}
else {
for (i = 0, iMax = Math.min(selection.length, this.selection.length); i < iMax; i++) {
if (selection[i] != this.selection[i]) {
selectionAlreadyThere = false;
break;
}
}
}
if (selectionAlreadyThere) {
return changed;
}
if (append == undefined || append == false) {
// first deselect any selected node
var triggerSelect = false;
changed = this._unselectNodes(undefined, triggerSelect);
}
for (i = 0, iMax = selection.length; i < iMax; i++) {
// add each of the new selections, but only when they are not duplicate
var id = selection[i];
var isDuplicate = (this.selection.indexOf(id) != -1);
if (!isDuplicate) {
this.nodes[id].select();
this.selection.push(id);
changed = true;
}
}
if (changed) {
// fire the select event
this._trigger('select');
}
return changed;
};
/**
* retrieve all nodes overlapping with given object
* @param {Object} obj An object with parameters left, top, right, bottom
* @return {Number[]} An array with id's of the overlapping nodes
* @private
*/
Graph.prototype._getNodesOverlappingWith = function (obj) {
var nodes = this.nodes,
overlappingNodes = [];
for (var id in nodes) {
if (nodes.hasOwnProperty(id)) {
if (nodes[id].isOverlappingWith(obj)) {
overlappingNodes.push(id);
}
}
}
return overlappingNodes;
};
/**
* retrieve the currently selected nodes
* @return {Number[] | String[]} selection An array with the ids of the
* selected nodes.
*/
Graph.prototype.getSelection = function() {
return this.selection.concat([]);
};
/**
* select zero or more nodes
* @param {Number[] | String[]} selection An array with the ids of the
* selected nodes.
*/
Graph.prototype.setSelection = function(selection) {
var i, iMax, id;
if (!selection || (selection.length == undefined))
throw 'Selection must be an array with ids';
// first unselect any selected node
for (i = 0, iMax = this.selection.length; i < iMax; i++) {
id = this.selection[i];
this.nodes[id].unselect();
}
this.selection = [];
for (i = 0, iMax = selection.length; i < iMax; i++) {
id = selection[i];
var node = this.nodes[id];
if (!node) {
throw new RangeError('Node with id "' + id + '" not found');
}
node.select();
this.selection.push(id);
}
this.redraw();
};
/**
* Validate the selection: remove ids of nodes which no longer exist
* @private
*/
Graph.prototype._updateSelection = function () {
var i = 0;
while (i < this.selection.length) {
var id = this.selection[i];
if (!this.nodes[id]) {
this.selection.splice(i, 1);
}
else {
i++;
}
}
};
/**
* Temporary method to test calculating a hub value for the nodes
* @param {number} level Maximum number edges between two nodes in order
* to call them connected. Optional, 1 by default
* @return {Number[]} connectioncount array with the connection count
* for each node
* @private
*/
Graph.prototype._getConnectionCount = function(level) {
if (level == undefined) {
level = 1;
}
// get the nodes connected to given nodes
function getConnectedNodes(nodes) {
var connectedNodes = [];
for (var j = 0, jMax = nodes.length; j < jMax; j++) {
var node = nodes[j];
// find all nodes connected to this node
var edges = node.edges;
for (var i = 0, iMax = edges.length; i < iMax; i++) {
var edge = edges[i];
var other = null;
// check if connected
if (edge.from == node)
other = edge.to;
else if (edge.to == node)
other = edge.from;
// check if the other node is not already in the list with nodes
var k, kMax;
if (other) {
for (k = 0, kMax = nodes.length; k < kMax; k++) {
if (nodes[k] == other) {
other = null;
break;
}
}
}
if (other) {
for (k = 0, kMax = connectedNodes.length; k < kMax; k++) {
if (connectedNodes[k] == other) {
other = null;
break;
}
}
}
if (other)
connectedNodes.push(other);
}
}
return connectedNodes;
}
var connections = [];
var nodes = this.nodes;
for (var id in nodes) {
if (nodes.hasOwnProperty(id)) {
var c = [nodes[id]];
for (var l = 0; l < level; l++) {
c = c.concat(getConnectedNodes(c));
}
connections.push(c);
}
}
var hubs = [];
for (var i = 0, len = connections.length; i < len; i++) {
hubs.push(connections[i].length);
}
return hubs;
};
/**
* Set a new size for the graph
* @param {string} width Width in pixels or percentage (for example '800px'
* or '50%')
* @param {string} height Height in pixels or percentage (for example '400px'
* or '30%')
*/
Graph.prototype.setSize = function(width, height) {
this.frame.style.width = width;
this.frame.style.height = height;
this.frame.canvas.style.width = '100%';
this.frame.canvas.style.height = '100%';
this.frame.canvas.width = this.frame.canvas.clientWidth;
this.frame.canvas.height = this.frame.canvas.clientHeight;
};
/**
* Set a data set with nodes for the graph
* @param {Array | DataSet | DataView} nodes The data containing the nodes.
* @private
*/
Graph.prototype._setNodes = function(nodes) {
var oldNodesData = this.nodesData;
if (nodes instanceof DataSet || nodes instanceof DataView) {
this.nodesData = nodes;
}
else if (nodes instanceof Array) {
this.nodesData = new DataSet();
this.nodesData.add(nodes);
}
else if (!nodes) {
this.nodesData = new DataSet();
}
else {
throw new TypeError('Array or DataSet expected');
}
if (oldNodesData) {
// unsubscribe from old dataset
util.forEach(this.nodesListeners, function (callback, event) {
oldNodesData.unsubscribe(event, callback);
});
}
// remove drawn nodes
this.nodes = {};
if (this.nodesData) {
// subscribe to new dataset
var me = this;
util.forEach(this.nodesListeners, function (callback, event) {
me.nodesData.subscribe(event, callback);
});
// draw all new nodes
var ids = this.nodesData.getIds();
this._addNodes(ids);
}
this._updateSelection();
};
/**
* Add nodes
* @param {Number[] | String[]} ids
* @private
*/
Graph.prototype._addNodes = function(ids) {
var id;
for (var i = 0, len = ids.length; i < len; i++) {
id = ids[i];
var data = this.nodesData.get(id);
var node = new Node(data, this.images, this.groups, this.constants);
this.nodes[id] = node; // note: this may replace an existing node
if (!node.isFixed()) {
// TODO: position new nodes in a smarter way!
var radius = this.constants.edges.length * 2;
var count = ids.length;
var angle = 2 * Math.PI * (i / count);
node.x = radius * Math.cos(angle);
node.y = radius * Math.sin(angle);
// note: no not use node.isMoving() here, as that gives the current
// velocity of the node, which is zero after creation of the node.
this.moving = true;
}
}
this._updateNodeIndexList();
this._reconnectEdges();
this._updateValueRange(this.nodes);
};
/**
* Update existing nodes, or create them when not yet existing
* @param {Number[] | String[]} ids
* @private
*/
Graph.prototype._updateNodes = function(ids) {
var nodes = this.nodes,
nodesData = this.nodesData;
for (var i = 0, len = ids.length; i < len; i++) {
var id = ids[i];
var node = nodes[id];
var data = nodesData.get(id);
if (node) {
// update node
node.setProperties(data, this.constants);
}
else {
// create node
node = new Node(properties, this.images, this.groups, this.constants);
nodes[id] = node;
if (!node.isFixed()) {
this.moving = true;
}
}
}
this._updateNodeIndexList();
this._reconnectEdges();
this._updateValueRange(nodes);
};
/**
* Remove existing nodes. If nodes do not exist, the method will just ignore it.
* @param {Number[] | String[]} ids
* @private
*/
Graph.prototype._removeNodes = function(ids) {
var nodes = this.nodes;
for (var i = 0, len = ids.length; i < len; i++) {
var id = ids[i];
delete nodes[id];
}
this._updateNodeIndexList();
this._reconnectEdges();
this._updateSelection();
this._updateValueRange(nodes);
};
/**
* Load edges by reading the data table
* @param {Array | DataSet | DataView} edges The data containing the edges.
* @private
* @private
*/
Graph.prototype._setEdges = function(edges) {
var oldEdgesData = this.edgesData;
if (edges instanceof DataSet || edges instanceof DataView) {
this.edgesData = edges;
}
else if (edges instanceof Array) {
this.edgesData = new DataSet();
this.edgesData.add(edges);
}
else if (!edges) {
this.edgesData = new DataSet();
}
else {
throw new TypeError('Array or DataSet expected');
}
if (oldEdgesData) {
// unsubscribe from old dataset
util.forEach(this.edgesListeners, function (callback, event) {
oldEdgesData.unsubscribe(event, callback);
});
}
// remove drawn edges
this.edges = {};
if (this.edgesData) {
// subscribe to new dataset
var me = this;
util.forEach(this.edgesListeners, function (callback, event) {
me.edgesData.subscribe(event, callback);
});
// draw all new nodes
var ids = this.edgesData.getIds();
this._addEdges(ids);
}
this._reconnectEdges();
};
/**
* Add edges
* @param {Number[] | String[]} ids
* @private
*/
Graph.prototype._addEdges = function (ids) {
var edges = this.edges,
edgesData = this.edgesData;
for (var i = 0, len = ids.length; i < len; i++) {
var id = ids[i];
var oldEdge = edges[id];
if (oldEdge) {
oldEdge.disconnect();
}
var data = edgesData.get(id, {"showInternalIds" : true});
edges[id] = new Edge(data, this, this.constants);
}
this.moving = true;
this._updateValueRange(edges);
};
/**
* Update existing edges, or create them when not yet existing
* @param {Number[] | String[]} ids
* @private
*/
Graph.prototype._updateEdges = function (ids) {
var edges = this.edges,
edgesData = this.edgesData;
for (var i = 0, len = ids.length; i < len; i++) {
var id = ids[i];
var data = edgesData.get(id);
var edge = edges[id];
if (edge) {
// update edge
edge.disconnect();
edge.setProperties(data, this.constants);
edge.connect();
}
else {
// create edge
edge = new Edge(data, this, this.constants);
this.edges[id] = edge;
}
}
this.moving = true;
this._updateValueRange(edges);
};
/**
* Remove existing edges. Non existing ids will be ignored
* @param {Number[] | String[]} ids
* @private
*/
Graph.prototype._removeEdges = function (ids) {
var edges = this.edges;
for (var i = 0, len = ids.length; i < len; i++) {
var id = ids[i];
var edge = edges[id];
if (edge) {
edge.disconnect();
delete edges[id];
}
}
this.moving = true;
this._updateValueRange(edges);
};
/**
* Reconnect all edges
* @private
*/
Graph.prototype._reconnectEdges = function() {
var id,
nodes = this.nodes,
edges = this.edges;
for (id in nodes) {
if (nodes.hasOwnProperty(id)) {
nodes[id].edges = [];
}
}
for (id in edges) {
if (edges.hasOwnProperty(id)) {
var edge = edges[id];
edge.from = null;
edge.to = null;
edge.connect();
}
}
};
/**
* Update the values of all object in the given array according to the current
* value range of the objects in the array.
* @param {Object} obj An object containing a set of Edges or Nodes
* The objects must have a method getValue() and
* setValueRange(min, max).
* @private
*/
Graph.prototype._updateValueRange = function(obj) {
var id;
// determine the range of the objects
var valueMin = undefined;
var valueMax = undefined;
for (id in obj) {
if (obj.hasOwnProperty(id)) {
var value = obj[id].getValue();
if (value !== undefined) {
valueMin = (valueMin === undefined) ? value : Math.min(value, valueMin);
valueMax = (valueMax === undefined) ? value : Math.max(value, valueMax);
}
}
}
// adjust the range of all objects
if (valueMin !== undefined && valueMax !== undefined) {
for (id in obj) {
if (obj.hasOwnProperty(id)) {
obj[id].setValueRange(valueMin, valueMax);
}
}
}
};
/**
* Redraw the graph with the current data
* chart will be resized too.
*/
Graph.prototype.redraw = function() {
this.setSize(this.width, this.height);
this._redraw();
};
/**
* Redraw the graph with the current data
* @private
*/
Graph.prototype._redraw = function() {
var ctx = this.frame.canvas.getContext('2d');
// clear the canvas
var w = this.frame.canvas.width;
var h = this.frame.canvas.height;
ctx.clearRect(0, 0, w, h);
// set scaling and translation
ctx.save();
ctx.translate(this.translation.x, this.translation.y);
ctx.scale(this.scale, this.scale);
this._drawEdges(ctx);
this._drawNodes(ctx);
// restore original scaling and translation
ctx.restore();
};
/**
* Set the translation of the graph
* @param {Number} offsetX Horizontal offset
* @param {Number} offsetY Vertical offset
* @private
*/
Graph.prototype._setTranslation = function(offsetX, offsetY) {
if (this.translation === undefined) {
this.translation = {
x: 0,
y: 0
};
}
if (offsetX !== undefined) {
this.translation.x = offsetX;
}
if (offsetY !== undefined) {
this.translation.y = offsetY;
}
};
/**
* Get the translation of the graph
* @return {Object} translation An object with parameters x and y, both a number
* @private
*/
Graph.prototype._getTranslation = function() {
return {
x: this.translation.x,
y: this.translation.y
};
};
/**
* Scale the graph
* @param {Number} scale Scaling factor 1.0 is unscaled
* @private
*/
Graph.prototype._setScale = function(scale) {
this.scale = scale;
};
/**
* Get the current scale of the graph
* @return {Number} scale Scaling factor 1.0 is unscaled
* @private
*/
Graph.prototype._getScale = function() {
return this.scale;
};
/**
* Convert a horizontal point on the HTML canvas to the x-value of the model
* @param {number} x
* @returns {number}
* @private
*/
Graph.prototype._canvasToX = function(x) {
return (x - this.translation.x) / this.scale;
};
/**
* Convert an x-value in the model to a horizontal point on the HTML canvas
* @param {number} x
* @returns {number}
* @private
*/
Graph.prototype._xToCanvas = function(x) {
return x * this.scale + this.translation.x;
};
/**
* Convert a vertical point on the HTML canvas to the y-value of the model
* @param {number} y
* @returns {number}
* @private
*/
Graph.prototype._canvasToY = function(y) {
return (y - this.translation.y) / this.scale;
};
/**
* Convert an y-value in the model to a vertical point on the HTML canvas
* @param {number} y
* @returns {number}
* @private
*/
Graph.prototype._yToCanvas = function(y) {
return y * this.scale + this.translation.y ;
};
/**
* Redraw all nodes
* The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
* @param {CanvasRenderingContext2D} ctx
* @private
*/
Graph.prototype._drawNodes = function(ctx) {
// first draw the unselected nodes
var nodes = this.nodes;
var selected = [];
for (var id in nodes) {
if (nodes.hasOwnProperty(id)) {
nodes[id].setScale(this.scale);
if (nodes[id].isSelected()) {
selected.push(id);
}
else {
nodes[id].draw(ctx);
}
}
}
// draw the selected nodes on top
for (var s = 0, sMax = selected.length; s < sMax; s++) {
nodes[selected[s]].draw(ctx);
}
};
/**
* Redraw all edges
* The 2d context of a HTML canvas can be retrieved by canvas.getContext('2d');
* @param {CanvasRenderingContext2D} ctx
* @private
*/
Graph.prototype._drawEdges = function(ctx) {
var edges = this.edges;
for (var id in edges) {
if (edges.hasOwnProperty(id)) {
var edge = edges[id];
edge.setScale(this.scale);
if (edge.connected) {
edges[id].draw(ctx);
}
}
}
};
/**
* Find a stable position for all nodes
* @private
*/
Graph.prototype._doStabilize = function() {
var start = new Date();
// find stable position
var count = 0;
var vmin = this.constants.minVelocity;
var stable = false;
while (!stable && count < this.constants.maxIterations) {
this._calculateForces();
this._discreteStepNodes();
stable = !this._isMoving(vmin);
count++;
}
var end = new Date();
// console.log('Stabilized in ' + (end-start) + ' ms, ' + count + ' iterations' ); // TODO: cleanup
};
/**
* Calculate the external forces acting on the nodes
* Forces are caused by: edges, repulsing forces between nodes, gravity
* @private
*/
Graph.prototype._calculateForces = function() {
if (this.nodeIndices.length == 1) { // stop calculation if there is only one node
this.nodes[this.nodeIndices[0]]._setForce(0,0);
}
else if (this.nodeIndices.length > this.constants.clustering.maxNumberOfNodes * 4) {
console.log(this.nodeIndices.length, this.constants.clustering.maxNumberOfNodes * 4)
this.clusterToFit(false);
this._calculateForces();
}
else {
// create a local edge to the nodes and edges, that is faster
var id, dx, dy, angle, distance, fx, fy,
repulsingForce, springForce, length, edgeLength,
nodes = this.nodes,
edges = this.edges;
// Gravity is required to keep separated groups from floating off
// the forces are reset to zero in this loop by using _setForce instead
// of _addForce
var gravity = 0.08;
for (var i = 0; i < this.nodeIndices.length; i++) {
var node = nodes[this.nodeIndices[i]];
dx = -node.x - this.translation.x + this.frame.canvas.clientWidth*0.5;
dy = -node.y - this.translation.y + this.frame.canvas.clientHeight*0.5;
angle = Math.atan2(dy, dx);
fx = Math.cos(angle) * gravity;
fy = Math.sin(angle) * gravity;
node._setForce(fx, fy);
node.updateDamping(this.nodeIndices.length);
}
this._updateLabels();
// repulsing forces between nodes
var minimumDistance = this.constants.nodes.distance,
steepness = 10; // higher value gives steeper slope of the force around the given minimumDistance
// we loop from i over all but the last entree in the array
// j loops from i+1 to the last. This way we do not double count any of the indices, nor i == j
for (var i = 0; i < this.nodeIndices.length-1; i++) {
var node1 = nodes[this.nodeIndices[i]];
for (var j = i+1; j < this.nodeIndices.length; j++) {
var node2 = nodes[this.nodeIndices[j]];
var clusterSize = (node1.clusterSize + node2.clusterSize - 2);
dx = node2.x - node1.x;
dy = node2.y - node1.y;
distance = Math.sqrt(dx * dx + dy * dy);
// clusters have a larger region of influence
minimumDistance = (clusterSize == 0) ? this.constants.nodes.distance : (this.constants.nodes.distance * (1 + clusterSize * this.constants.clustering.distanceAmplification));
if (distance < 2*minimumDistance) { // at 2.0 * the minimum distance, the force is 0.000045
angle = Math.atan2(dy, dx);
if (distance < 0.5*minimumDistance) { // at 0.5 * the minimum distance, the force is 0.993307
repulsingForce = 1.0;
}
else {
// TODO: correct factor for repulsing force
//repulsingForce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force
//repulsingForce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force
repulsingForce = 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)); // TODO: customize the repulsing force
}
// amplify the repulsion for clusters.
repulsingForce *= (clusterSize == 0) ? 1 : 1 + clusterSize * this.constants.clustering.forceAmplification;
fx = Math.cos(angle) * repulsingForce;
fy = Math.sin(angle) * repulsingForce;
node1._addForce(-fx, -fy);
node2._addForce(fx, fy);
}
}
}
// TODO: re-implement repulsion of edges
for (var n = 0; n < nodes.length; n++) {
for (var l = 0; l < edges.length; l++) {
var lx = edges[l].from.x+(edges[l].to.x - edges[l].from.x)/2,
ly = edges[l].from.y+(edges[l].to.y - edges[l].from.y)/2,
// calculate normally distributed force
dx = nodes[n].x - lx,
dy = nodes[n].y - ly,
distance = Math.sqrt(dx * dx + dy * dy),
angle = Math.atan2(dy, dx),
// TODO: correct factor for repulsing force
//var repulsingforce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force
//repulsingforce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ), // TODO: customize the repulsing force
repulsingforce = 1 / (1 + Math.exp((distance / (minimumDistance / 2) - 1) * steepness)), // TODO: customize the repulsing force
fx = Math.cos(angle) * repulsingforce,
fy = Math.sin(angle) * repulsingforce;
nodes[n]._addForce(fx, fy);
edges[l].from._addForce(-fx/2,-fy/2);
edges[l].to._addForce(-fx/2,-fy/2);
}
}
// forces caused by the edges, modelled as springs
for (id in edges) {
if (edges.hasOwnProperty(id)) {
var edge = edges[id];
if (edge.connected) {
var clusterSize = (edge.to.clusterSize + edge.from.clusterSize - 2);
dx = (edge.to.x - edge.from.x);
dy = (edge.to.y - edge.from.y);
//edgeLength = (edge.from.width + edge.from.height + edge.to.width + edge.to.height)/2 || edge.length; // TODO: dmin
//edgeLength = (edge.from.width + edge.to.width)/2 || edge.length; // TODO: dmin
//edgeLength = 20 + ((edge.from.width + edge.to.width) || 0) / 2;
edgeLength = edge.length;
// this implies that the edges between big clusters are longer
edgeLength += clusterSize * this.constants.clustering.edgeGrowth;
length = Math.sqrt(dx * dx + dy * dy);
angle = Math.atan2(dy, dx);
springForce = edge.stiffness * (edgeLength - length);
// boost strength of cluster springs
springForce *= (clusterSize == 0) ? 1 : 1 + clusterSize * this.constants.clustering.edgeStrength;
fx = Math.cos(angle) * springForce;
fy = Math.sin(angle) * springForce;
edge.from._addForce(-fx, -fy);
edge.to._addForce(fx, fy);
}
}
}
/*
// TODO: re-implement repulsion of edges
// repulsing forces between edges
var minimumDistance = this.constants.edges.distance,
steepness = 10; // higher value gives steeper slope of the force around the given minimumDistance
for (var l = 0; l < edges.length; l++) {
//Keep distance from other edge centers
for (var l2 = l + 1; l2 < this.edges.length; l2++) {
//var dmin = (nodes[n].width + nodes[n].height + nodes[n2].width + nodes[n2].height) / 1 || minimumDistance, // TODO: dmin
//var dmin = (nodes[n].width + nodes[n2].width)/2 || minimumDistance, // TODO: dmin
//dmin = 40 + ((nodes[n].width/2 + nodes[n2].width/2) || 0),
var lx = edges[l].from.x+(edges[l].to.x - edges[l].from.x)/2,
ly = edges[l].from.y+(edges[l].to.y - edges[l].from.y)/2,
l2x = edges[l2].from.x+(edges[l2].to.x - edges[l2].from.x)/2,
l2y = edges[l2].from.y+(edges[l2].to.y - edges[l2].from.y)/2,
// calculate normally distributed force
dx = l2x - lx,
dy = l2y - ly,
distance = Math.sqrt(dx * dx + dy * dy),
angle = Math.atan2(dy, dx),
// TODO: correct factor for repulsing force
//var repulsingforce = 2 * Math.exp(-5 * (distance * distance) / (dmin * dmin) ); // TODO: customize the repulsing force
//repulsingforce = Math.exp(-1 * (distance * distance) / (dmin * dmin) ), // TODO: customize the repulsing force
repulsingforce = 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)), // TODO: customize the repulsing force
fx = Math.cos(angle) * repulsingforce,
fy = Math.sin(angle) * repulsingforce;
edges[l].from._addForce(-fx, -fy);
edges[l].to._addForce(-fx, -fy);
edges[l2].from._addForce(fx, fy);
edges[l2].to._addForce(fx, fy);
}
}
*/
}
};
/**
* Check if any of the nodes is still moving
* @param {number} vmin the minimum velocity considered as 'moving'
* @return {boolean} true if moving, false if non of the nodes is moving
* @private
*/
Graph.prototype._isMoving = function(vmin) {
// TODO: ismoving does not work well: should check the kinetic energy, not its velocity
var nodes = this.nodes;
for (var id in nodes) {
if (nodes.hasOwnProperty(id) && nodes[id].isMoving(vmin)) {
return true;
}
}
return false;
};
/**
* Perform one discrete step for all nodes
* @private
*/
Graph.prototype._discreteStepNodes = function() {
var interval = this.refreshRate / 1000.0; // in seconds
var nodes = this.nodes;
for (var id in nodes) {
if (nodes.hasOwnProperty(id)) {
nodes[id].discreteStep(interval);
}
}
};
/**
* Start animating nodes and edges
*/
Graph.prototype.start = function() {
if (!this.freezeSimulation) {
if (this.moving) {
this._calculateForces();
this._discreteStepNodes();
var vmin = this.constants.minVelocity;
this.moving = this._isMoving(vmin);
}
if (this.moving) {
// start animation. only start timer if it is not already running
if (!this.timer) {
var graph = this;
this.timer = window.setTimeout(function () {
graph.timer = undefined;
// benchmark the calculation
// var start = window.performance.now();
graph.start();
// Optionally call this twice for faster convergence
graph.start();
// var end = window.performance.now();
// var time = end - start;
// console.log('Simulation time: ' + time);
// start = window.performance.now();
graph._redraw();
// end = window.performance.now();
// time = end - start;
// console.log('Drawing time: ' + time);
}, this.refreshRate);
}
}
else {
this._redraw();
}
}
};
Graph.prototype.singleStep = function() {
if (this.moving) {
this._calculateForces();
this._discreteStepNodes();
var vmin = this.constants.minVelocity;
this.moving = this._isMoving(vmin);
this._redraw();
}
};
/**
* Stop animating nodes and edges.
*/
Graph.prototype.stop = function () {
if (this.timer) {
window.clearInterval(this.timer);
this.timer = undefined;
}
};
/**
* Freeze the animation
*/
Graph.prototype.toggleFreeze = function() {
if (this.freezeSimulation == false) {
this.freezeSimulation = true;
}
else {
this.freezeSimulation = false;
this.start();
}
console.log('freezeSimulation',this.freezeSimulation)
}
/**
* vis.js module exports
*/
var vis = {
util: util,
events: events,
Controller: Controller,
DataSet: DataSet,
DataView: DataView,
Range: Range,
Stack: Stack,
TimeStep: TimeStep,
EventBus: EventBus,
components: {
items: {
Item: Item,
ItemBox: ItemBox,
ItemPoint: ItemPoint,
ItemRange: ItemRange
},
Component: Component,
Panel: Panel,
RootPanel: RootPanel,
ItemSet: ItemSet,
TimeAxis: TimeAxis
},
graph: {
Node: Node,
Edge: Edge,
Popup: Popup,
Groups: Groups,
Images: Images
},
Timeline: Timeline,
Graph: Graph
};
/**
* CommonJS module exports
*/
if (typeof exports !== 'undefined') {
exports = vis;
}
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = vis;
}
/**
* AMD module exports
*/
if (typeof(define) === 'function') {
define(function () {
return vis;
});
}
/**
* Window exports
*/
if (typeof window !== 'undefined') {
// attach the module to the window, load as a regular javascript file
window['vis'] = vis;
}
},{"hammerjs":1,"moment":2,"mouseTrap":3}]},{},[4])
(4)
});