/** * vis.js * https://github.com/almende/vis * * A dynamic, browser-based visualization library. * * @version 0.6.1 * @date 2014-03-06 * * @license * Copyright (C) 2011-2014 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>> 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; }; /** * 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 var gesture = Hammer.event.collectEventData(this, eventType, event); // for hammer.js 1.0.6 //var touches = Hammer.event.getTouchList(event, eventType); // var gesture = Hammer.event.collectEventData(this, eventType, touches, event); // on IE in standards mode, no touches are recognized by hammer.js, // resulting in NaN values for center.pageX and center.pageY if (isNaN(gesture.center.pageX)) { gesture.center.pageX = event.pageX; } if (isNaN(gesture.center.pageY)) { gesture.center.pageY = event.pageY; } return gesture; }; 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; }; util.GiveDec = function GiveDec(Hex) { if(Hex == "A") Value = 10; else if(Hex == "B") Value = 11; else if(Hex == "C") Value = 12; else if(Hex == "D") Value = 13; else if(Hex == "E") Value = 14; else if(Hex == "F") Value = 15; else Value = eval(Hex) return Value; } util.GiveHex = function GiveHex(Dec) { if(Dec == 10) Value = "A"; else if(Dec == 11) Value = "B"; else if(Dec == 12) Value = "C"; else if(Dec == 13) Value = "D"; else if(Dec == 14) Value = "E"; else if(Dec == 15) Value = "F"; else Value = "" + Dec; return Value; } /** * http://www.yellowpipe.com/yis/tools/hex-to-rgb/color-converter.php * * @param {String} hex * @returns {{r: *, g: *, b: *}} */ util.hexToRGB = function hexToRGB(hex) { hex = hex.replace("#","").toUpperCase(); var a = util.GiveDec(hex.substring(0, 1)); var b = util.GiveDec(hex.substring(1, 2)); var c = util.GiveDec(hex.substring(2, 3)); var d = util.GiveDec(hex.substring(3, 4)); var e = util.GiveDec(hex.substring(4, 5)); var f = util.GiveDec(hex.substring(5, 6)); var r = (a * 16) + b; var g = (c * 16) + d; var b = (e * 16) + f; return {r:r,g:g,b:b}; }; util.RGBToHex = function RGBToHex(red,green,blue) { var a = util.GiveHex(Math.floor(red / 16)); var b = util.GiveHex(red % 16); var c = util.GiveHex(Math.floor(green / 16)); var d = util.GiveHex(green % 16); var e = util.GiveHex(Math.floor(blue / 16)); var f = util.GiveHex(blue % 16); var hex = a + b + c + d + e + f; return "#" + hex; }; /** * http://www.javascripter.net/faq/rgb2hsv.htm * * @param red * @param green * @param blue * @returns {*} * @constructor */ util.RGBToHSV = function RGBToHSV (red,green,blue) { red=red/255; green=green/255; blue=blue/255; var minRGB = Math.min(red,Math.min(green,blue)); var maxRGB = Math.max(red,Math.max(green,blue)); // Black-gray-white if (minRGB == maxRGB) { return {h:0,s:0,v:minRGB}; } // Colors other than black-gray-white: var d = (red==minRGB) ? green-blue : ((blue==minRGB) ? red-green : blue-red); var h = (red==minRGB) ? 3 : ((blue==minRGB) ? 1 : 5); var hue = 60*(h - d/(maxRGB - minRGB))/360; var saturation = (maxRGB - minRGB)/maxRGB; var value = maxRGB; return {h:hue,s:saturation,v:value}; }; /** * https://gist.github.com/mjijackson/5311256 * @param hue * @param saturation * @param value * @returns {{r: number, g: number, b: number}} * @constructor */ util.HSVToRGB = function HSVToRGB(h, s, v) { var r, g, b; var i = Math.floor(h * 6); var f = h * 6 - i; var p = v * (1 - s); var q = v * (1 - f * s); var t = v * (1 - (1 - f) * s); switch (i % 6) { case 0: r = v, g = t, b = p; break; case 1: r = q, g = v, b = p; break; case 2: r = p, g = v, b = t; break; case 3: r = p, g = q, b = v; break; case 4: r = t, g = p, b = v; break; case 5: r = v, g = p, b = q; break; } return {r:Math.floor(r * 255), g:Math.floor(g * 255), b:Math.floor(b * 255) }; }; util.HSVToHex = function HSVToHex(h,s,v) { var rgb = util.HSVToRGB(h,s,v); return util.RGBToHex(rgb.r,rgb.g,rgb.b); } util.hexToHSV = function hexToHSV(hex) { var rgb = util.hexToRGB(hex); return util.RGBToHSV(rgb.r,rgb.g,rgb.b); } util.isValidHex = function isValidHex(hex) { var isOk = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(hex); return isOk; } /** * 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.} [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.} [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.} [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.} [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.on) { this.data.on('*', 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.} [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.on = DataSet.prototype.on; DataView.prototype.off = DataSet.prototype.off; DataView.prototype._trigger = DataSet.prototype._trigger; // TODO: make these functions deprecated (replaced with `on` and `off` since version 0.5) DataView.prototype.subscribe = DataView.prototype.on; DataView.prototype.unsubscribe = DataView.prototype.off; /** * @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. * @return {Date} snappedDate */ TimeStep.prototype.snap = function(date) { var clone = new Date(date.valueOf()); if (this.scale == TimeStep.SCALE.YEAR) { var year = clone.getFullYear() + Math.round(clone.getMonth() / 12); clone.setFullYear(Math.round(year / this.step) * this.step); clone.setMonth(0); clone.setDate(0); clone.setHours(0); clone.setMinutes(0); clone.setSeconds(0); clone.setMilliseconds(0); } else if (this.scale == TimeStep.SCALE.MONTH) { if (clone.getDate() > 15) { clone.setDate(1); clone.setMonth(clone.getMonth() + 1); // important: first set Date to 1, after that change the month. } else { clone.setDate(1); } clone.setHours(0); clone.setMinutes(0); clone.setSeconds(0); clone.setMilliseconds(0); } else if (this.scale == TimeStep.SCALE.DAY || this.scale == TimeStep.SCALE.WEEKDAY) { //noinspection FallthroughInSwitchStatementJS switch (this.step) { case 5: case 2: clone.setHours(Math.round(clone.getHours() / 24) * 24); break; default: clone.setHours(Math.round(clone.getHours() / 12) * 12); break; } clone.setMinutes(0); clone.setSeconds(0); clone.setMilliseconds(0); } else if (this.scale == TimeStep.SCALE.HOUR) { switch (this.step) { case 4: clone.setMinutes(Math.round(clone.getMinutes() / 60) * 60); break; default: clone.setMinutes(Math.round(clone.getMinutes() / 30) * 30); break; } clone.setSeconds(0); clone.setMilliseconds(0); } else if (this.scale == TimeStep.SCALE.MINUTE) { //noinspection FallthroughInSwitchStatementJS switch (this.step) { case 15: case 10: clone.setMinutes(Math.round(clone.getMinutes() / 5) * 5); clone.setSeconds(0); break; case 5: clone.setSeconds(Math.round(clone.getSeconds() / 60) * 60); break; default: clone.setSeconds(Math.round(clone.getSeconds() / 30) * 30); break; } clone.setMilliseconds(0); } else if (this.scale == TimeStep.SCALE.SECOND) { //noinspection FallthroughInSwitchStatementJS switch (this.step) { case 15: case 10: clone.setSeconds(Math.round(clone.getSeconds() / 5) * 5); clone.setMilliseconds(0); break; case 5: clone.setMilliseconds(Math.round(clone.getMilliseconds() / 1000) * 1000); break; default: clone.setMilliseconds(Math.round(clone.getMilliseconds() / 500) * 500); break; } } else if (this.scale == TimeStep.SCALE.MILLISECOND) { var step = this.step > 5 ? this.step / 2 : 1; clone.setMilliseconds(Math.round(clone.getMilliseconds() / step) * step); } return clone; }; /** * 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} itemset * @param {Object} [options] */ function Stack (itemset, options) { this.itemset = itemset; 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} itemset * {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 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. 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.itemset.items; if (!items) { throw new Error('Cannot stack items: ItemSet 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.width) && (a.left + a.width + 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); } // extend the Range prototype with an event emitter mixin Emitter(Range.prototype); /** * Set options for the range controller * @param {Object} options Available options: * {Number} min Minimum value for start * {Number} max Maximum value for end * {Number} zoomMin Set a minimum value for * (end - start). * {Number} zoomMax Set a maximum value for * (end - start). */ Range.prototype.setOptions = function (options) { util.extend(this.options, options); // re-apply range with new limitations if (this.start !== null && this.end !== null) { this.setRange(this.start, this.end); } }; /** * Test whether direction has a valid value * @param {String} direction 'horizontal' or 'vertical' */ function validateDirection (direction) { if (direction != 'horizontal' && direction != 'vertical') { throw new TypeError('Unknown direction "' + direction + '". ' + 'Choose "horizontal" or "vertical".'); } } /** * Add listeners for mouse and touch events to the component * @param {Controller} controller * @param {Component} component Should be a rootpanel * @param {String} event Available events: 'move', 'zoom' * @param {String} direction Available directions: 'horizontal', 'vertical' */ Range.prototype.subscribe = function (controller, component, event, direction) { var me = this; if (event == 'move') { // drag start listener controller.on('dragstart', function (event) { me._onDragStart(event, component); }); // drag listener controller.on('drag', function (event) { me._onDrag(event, component, direction); }); // drag end listener controller.on('dragend', function (event) { me._onDragEnd(event, component); }); // ignore dragging when holding controller.on('hold', function (event) { me._onHold(); }); } else if (event == 'zoom') { // mouse wheel function mousewheel (event) { me._onMouseWheel(event, component, direction); } controller.on('mousewheel', mousewheel); controller.on('DOMMouseScroll', mousewheel); // For FF // pinch controller.on('touch', function (event) { me._onTouch(event); }); controller.on('pinch', function (event) { me._onPinch(event, component, direction); }); } else { throw new TypeError('Unknown event "' + event + '". ' + 'Choose "move" or "zoom".'); } }; /** * 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) { var params = { start: this.start, end: this.end }; this.emit('rangechange', params); this.emit('rangechanged', params); } }; /** * Set a new start and end range. This method is the same as setRange, but * does not trigger a range change and range changed event, and it returns * true when the range is changed * @param {Number} [start] * @param {Number} [end] * @return {Boolean} changed * @private */ Range.prototype._applyRange = function(start, end) { var newStart = (start != null) ? util.convert(start, 'Date').valueOf() : this.start, newEnd = (end != null) ? util.convert(end, 'Date').valueOf() : this.end, max = (this.options.max != null) ? util.convert(this.options.max, 'Date').valueOf() : null, min = (this.options.min != null) ? util.convert(this.options.min, 'Date').valueOf() : null, diff; // check for valid number if (isNaN(newStart) || newStart === null) { throw new Error('Invalid start "' + start + '"'); } if (isNaN(newEnd) || newEnd === null) { throw new Error('Invalid end "' + end + '"'); } // prevent start < end if (newEnd < newStart) { newEnd = newStart; } // prevent start < min if (min !== null) { if (newStart < min) { diff = (min - newStart); newStart += diff; newEnd += diff; // prevent end > max if (max != null) { if (newEnd > max) { newEnd = max; } } } } // prevent end > max if (max !== null) { if (newEnd > max) { diff = (newEnd - max); newStart -= diff; newEnd -= diff; // prevent start < min if (min != null) { if (newStart < min) { newStart = min; } } } } // prevent (end-start) < zoomMin if (this.options.zoomMin !== null) { var zoomMin = parseFloat(this.options.zoomMin); if (zoomMin < 0) { zoomMin = 0; } if ((newEnd - newStart) < zoomMin) { if ((this.end - this.start) === zoomMin) { // ignore this action, we are already zoomed to the minimum newStart = this.start; newEnd = this.end; } else { // zoom to the minimum diff = (zoomMin - (newEnd - newStart)); newStart -= diff / 2; newEnd += diff / 2; } } } // prevent (end-start) > zoomMax if (this.options.zoomMax !== null) { var zoomMax = parseFloat(this.options.zoomMax); if (zoomMax < 0) { zoomMax = 0; } if ((newEnd - newStart) > zoomMax) { if ((this.end - this.start) === zoomMax) { // ignore this action, we are already zoomed to the maximum newStart = this.start; newEnd = this.end; } else { // zoom to the maximum diff = ((newEnd - newStart) - zoomMax); newStart += diff / 2; newEnd -= diff / 2; } } } var changed = (this.start != newStart || this.end != newEnd); this.start = newStart; this.end = newEnd; return changed; }; /** * Retrieve the current range. * @return {Object} An object with start and end properties */ Range.prototype.getRange = function() { return { start: this.start, end: this.end }; }; /** * Calculate the conversion offset and scale for current range, based on * the provided width * @param {Number} width * @returns {{offset: number, scale: number}} conversion */ Range.prototype.conversion = function (width) { return Range.conversion(this.start, this.end, width); }; /** * Static method to calculate the conversion offset and scale for a range, * based on the provided start, end, and width * @param {Number} start * @param {Number} end * @param {Number} width * @returns {{offset: number, scale: number}} conversion */ Range.conversion = function (start, end, width) { if (width != 0 && (end - start != 0)) { return { offset: start, scale: width / (end - start) } } else { return { offset: 0, scale: 1 }; } }; // global (private) object to store drag params var touchParams = {}; /** * Start dragging horizontally or vertically * @param {Event} event * @param {Object} component * @private */ Range.prototype._onDragStart = function(event, component) { // refuse to drag when we where pinching to prevent the timeline make a jump // when releasing the fingers in opposite order from the touch screen if (touchParams.ignore) return; // TODO: reckon with option movable 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); // TODO: reckon with option movable // refuse to drag when we where pinching to prevent the timeline make a jump // when releasing the fingers in opposite order from the touch screen if (touchParams.ignore) return; var delta = (direction == 'horizontal') ? event.gesture.deltaX : event.gesture.deltaY, interval = (touchParams.end - touchParams.start), width = (direction == 'horizontal') ? component.width : component.height, diffRange = -delta / width * interval; this._applyRange(touchParams.start + diffRange, touchParams.end + diffRange); this.emit('rangechange', { start: this.start, end: this.end }); }; /** * Stop dragging operating. * @param {event} event * @param {Component} component * @private */ Range.prototype._onDragEnd = function (event, component) { // refuse to drag when we where pinching to prevent the timeline make a jump // when releasing the fingers in opposite order from the touch screen if (touchParams.ignore) return; // TODO: reckon with option movable if (component.frame) { component.frame.style.cursor = 'auto'; } // fire a rangechanged event this.emit('rangechanged', { start: this.start, end: this.end }); }; /** * 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); // TODO: reckon with option zoomable // 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.center, 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) event.preventDefault(); }; /** * Start of a touch gesture * @private */ Range.prototype._onTouch = function (event) { touchParams.start = this.start; touchParams.end = this.end; touchParams.ignore = false; touchParams.center = null; // don't move the range when dragging a selected event // TODO: it's not so neat to have to know about the state of the ItemSet var item = ItemSet.itemFromTarget(event); if (item && item.selected && this.options.editable) { touchParams.ignore = true; } }; /** * On start of a hold gesture * @private */ Range.prototype._onHold = function () { touchParams.ignore = true; }; /** * Handle pinch event * @param {Event} event * @param {Component} component * @param {String} direction 'horizontal' or 'vertical' * @private */ Range.prototype._onPinch = function (event, component, direction) { touchParams.ignore = true; // TODO: reckon with option zoomable 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 components, * and is used as an event bus for all components. */ function Controller () { var me = this; this.id = util.randomUUID(); this.components = {}; /** * Listen for a 'request-reflow' event. The controller will schedule a reflow * @param {Boolean} [force] If true, an immediate reflow is forced. Default * is false. */ var reflowTimer = null; this.on('request-reflow', function requestReflow(force) { if (force) { me.reflow(); } else { if (!reflowTimer) { reflowTimer = setTimeout(function () { reflowTimer = null; 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. */ var repaintTimer = null; this.on('request-repaint', function requestRepaint(force) { if (force) { me.repaint(); } else { if (!repaintTimer) { repaintTimer = setTimeout(function () { repaintTimer = null; me.repaint(); }, 0); } } }); } // Extend controller with Emitter mixin Emitter(Controller.prototype); /** * 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.setController(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) { // unregister the controller (gives the component the ability to unregister // event listeners and clean up other stuff) this.components[id].setController(null); delete this.components[id]; } }; /** * 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); this.emit('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); this.emit('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] * {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; }; /** * Set controller for this component, or remove current controller by passing * null as parameter value. * @param {Controller | null} controller */ Component.prototype.setController = function setController (controller) { this.controller = controller || null; }; /** * Get controller of this component * @return {Controller} controller */ Component.prototype.getController = function getController () { return this.controller; }; /** * 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.emit('request-repaint'); } 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.emit('request-reflow'); } 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 = 'vpanel'; 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; // create functions to be used as DOM event listeners var me = this; this.hammer = null; // create listeners for all interesting events, these events will be emitted // via the controller var events = [ 'touch', 'pinch', 'tap', 'doubletap', 'hold', 'dragstart', 'drag', 'dragend', 'mousewheel', 'DOMMouseScroll' // DOMMouseScroll is for Firefox ]; this.listeners = {}; events.forEach(function (event) { me.listeners[event] = function () { var args = [event].concat(Array.prototype.slice.call(arguments, 0)); me.controller.emit.apply(me.controller, args); }; }); this.options = options || {}; this.defaultOptions = { autoResize: true }; } 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; this._registerListeners(); 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 + (options.editable ? ' editable' : ''); 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._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 }; /** * Set controller for this component, or remove current controller by passing * null as parameter value. * @param {Controller | null} controller */ RootPanel.prototype.setController = function setController (controller) { this.controller = controller || null; if (this.controller) { this._registerListeners(); } else { this._unregisterListeners(); } }; /** * Register event emitters emitted by the rootpanel * @private */ RootPanel.prototype._registerListeners = function () { if (this.frame && this.controller && !this.hammer) { this.hammer = Hammer(this.frame, { prevent_default: true }); for (var event in this.listeners) { if (this.listeners.hasOwnProperty(event)) { this.hammer.on(event, this.listeners[event]); } } } }; /** * Unregister event emitters from the rootpanel * @private */ RootPanel.prototype._unregisterListeners = function () { if (this.hammer) { for (var event in this.listeners) { if (this.listeners.hasOwnProperty(event)) { this.hammer.off(event, this.listeners[event]); } } this.hammer = null; } }; /** * 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); } }; /** * 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. * @return {Date} snappedDate */ TimeAxis.prototype.snap = function snap (date) { return this.step.snap(date); }; /** * 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 false; } 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.customTime = new Date(); this.eventParams = {}; // stores state parameters while dragging the bar } CustomTime.prototype = new Component(); Emitter(CustomTime.prototype); 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; if (!parent) { throw new Error('Cannot repaint bar: no parent attached'); } var parentContainer = parent.parent.getContainer(); 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 false; } 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; // attach event listeners this.hammer = Hammer(bar, { prevent_default: true }); this.hammer.on('dragstart', this._onDragStart.bind(this)); this.hammer.on('drag', this._onDrag.bind(this)); this.hammer.on('dragend', this._onDragEnd.bind(this)); } 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()); }; /** * Start moving horizontally * @param {Event} event * @private */ CustomTime.prototype._onDragStart = function(event) { this.eventParams.customTime = this.customTime; event.stopPropagation(); event.preventDefault(); }; /** * Perform moving operating. * @param {Event} event * @private */ CustomTime.prototype._onDrag = function (event) { var deltaX = event.gesture.deltaX, x = this.parent.toScreen(this.eventParams.customTime) + deltaX, time = this.parent.toTime(x); this.setCustomTime(time); // fire a timechange event if (this.controller) { this.controller.emit('timechange', { time: this.customTime }) } event.stopPropagation(); event.preventDefault(); }; /** * Stop moving operating. * @param {event} event * @private */ CustomTime.prototype._onDragEnd = function (event) { // fire a timechanged event if (this.controller) { this.controller.emit('timechanged', { time: this.customTime }) } event.stopPropagation(); event.preventDefault(); }; /** * 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; // event listeners this.eventListeners = { dragstart: this._onDragStart.bind(this), drag: this._onDrag.bind(this), dragend: this._onDragEnd.bind(this) }; // 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} // data change listeners 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.selection = []; // list with the ids of all selected nodes this.queue = {}; // queue with id/actions: 'add', 'update', 'delete' this.stack = new Stack(this, Object.create(this.options)); this.conversion = null; this.touchParams = {}; // stores properties while dragging // 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. * {Function} snap * Function to let items snap to nice dates when * dragging items. */ ItemSet.prototype.setOptions = Component.prototype.setOptions; /** * Set controller for this component * @param {Controller | null} controller */ ItemSet.prototype.setController = function setController (controller) { var event; // unregister old event listeners if (this.controller) { for (event in this.eventListeners) { if (this.eventListeners.hasOwnProperty(event)) { this.controller.off(event, this.eventListeners[event]); } } } this.controller = controller || null; // register new event listeners if (this.controller) { for (event in this.eventListeners) { if (this.eventListeners.hasOwnProperty(event)) { this.controller.on(event, this.eventListeners[event]); } } } }; // attach event listeners for dragging items to the controller (function (me) { var _controller = null; var _onDragStart = null; var _onDrag = null; var _onDragEnd = null; Object.defineProperty(me, 'controller', { get: function () { return _controller; }, set: function (controller) { } }); }) (this); /** * 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; }; /** * Set selected items by their id. Replaces the current selection * Unknown id's are silently ignored. * @param {Array} [ids] An array with zero or more id's of the items to be * selected. If ids is an empty array, all items will be * unselected. */ ItemSet.prototype.setSelection = function setSelection(ids) { var i, ii, id, item, selection; if (ids) { if (!Array.isArray(ids)) { throw new TypeError('Array expected'); } // unselect currently selected items for (i = 0, ii = this.selection.length; i < ii; i++) { id = this.selection[i]; item = this.items[id]; if (item) item.unselect(); } // select items this.selection = []; for (i = 0, ii = ids.length; i < ii; i++) { id = ids[i]; item = this.items[id]; if (item) { this.selection.push(id); item.select(); } } if (this.controller) { this.requestRepaint(); } } }; /** * Get the selected items by their id * @return {Array} ids The ids of the selected items */ ItemSet.prototype.getSelection = function getSelection() { return this.selection.concat([]); }; /** * Deselect a selected item * @param {String | Number} id * @private */ ItemSet.prototype._deselect = function _deselect(id) { var selection = this.selection; for (var i = 0, ii = selection.length; i < ii; i++) { if (selection[i] == id) { // non-strict comparison! selection.splice(i, 1); break; } } }; /** * 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'; frame['timeline-itemset'] = this; 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 for (var id in queue) { if (queue.hasOwnProperty(id)) { var entry = queue[id], item = items[id], action = entry.action; //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); item.id = entry.id; // we take entry.id, as id itself is stringified 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 the item from the set selected items if (item.selected) { me._deselect(id); } // 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 && 'axis' in options.margin) ? options.margin.axis : this.defaultOptions.margin.axis, marginItem = (options.margin && 'item' in 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.on(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; }; /** * Remove an item by its id * @param {String | Number} id */ ItemSet.prototype.removeItem = function removeItem (id) { var item = this.itemsData.get(id), dataset = this._myDataSet(); if (item) { // confirm deletion this.options.onRemove(item, function (item) { if (item) { dataset.remove(item); } }); } }; /** * 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] = { id: id, action: 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; }; /** * Start dragging the selected events * @param {Event} event * @private */ ItemSet.prototype._onDragStart = function (event) { if (!this.options.editable) { return; } var item = ItemSet.itemFromTarget(event), me = this; if (item && item.selected) { var dragLeftItem = event.target.dragLeftItem; var dragRightItem = event.target.dragRightItem; if (dragLeftItem) { this.touchParams.itemProps = [{ item: dragLeftItem, start: item.data.start.valueOf() }]; } else if (dragRightItem) { this.touchParams.itemProps = [{ item: dragRightItem, end: item.data.end.valueOf() }]; } else { this.touchParams.itemProps = this.getSelection().map(function (id) { var item = me.items[id]; var props = { item: item }; if ('start' in item.data) { props.start = item.data.start.valueOf() } if ('end' in item.data) { props.end = item.data.end.valueOf() } return props; }); } event.stopPropagation(); } }; /** * Drag selected items * @param {Event} event * @private */ ItemSet.prototype._onDrag = function (event) { if (this.touchParams.itemProps) { var snap = this.options.snap || null, deltaX = event.gesture.deltaX, offset = deltaX / this.conversion.scale; // move this.touchParams.itemProps.forEach(function (props) { if ('start' in props) { var start = new Date(props.start + offset); props.item.data.start = snap ? snap(start) : start; } if ('end' in props) { var end = new Date(props.end + offset); props.item.data.end = snap ? snap(end) : end; } }); // TODO: implement onMoving handler // TODO: implement dragging from one group to another this.requestReflow(); event.stopPropagation(); } }; /** * End of dragging selected items * @param {Event} event * @private */ ItemSet.prototype._onDragEnd = function (event) { if (this.touchParams.itemProps) { // prepare a change set for the changed items var changes = [], me = this, dataset = this._myDataSet(), type; this.touchParams.itemProps.forEach(function (props) { var id = props.item.id, item = me.itemsData.get(id); var changed = false; if ('start' in props.item.data) { changed = (props.start != props.item.data.start.valueOf()); item.start = util.convert(props.item.data.start, dataset.convert['start']); } if ('end' in props.item.data) { changed = changed || (props.end != props.item.data.end.valueOf()); item.end = util.convert(props.item.data.end, dataset.convert['end']); } // only apply changes when start or end is actually changed if (changed) { me.options.onMove(item, function (item) { if (item) { // apply changes changes.push(item); } else { // restore original values if ('start' in props) props.item.data.start = props.start; if ('end' in props) props.item.data.end = props.end; me.requestReflow(); } }); } }); this.touchParams.itemProps = null; // apply the changes to the data (if there are changes) if (changes.length) { dataset.update(changes); } event.stopPropagation(); } }; /** * Find an item from an event target: * searches for the attribute 'timeline-item' in the event target's element tree * @param {Event} event * @return {Item | null} item */ ItemSet.itemFromTarget = function itemFromTarget (event) { var target = event.target; while (target) { if (target.hasOwnProperty('timeline-item')) { return target['timeline-item']; } target = target.parentNode; } return null; }; /** * Find the ItemSet from an event target: * searches for the attribute 'timeline-itemset' in the event target's element tree * @param {Event} event * @return {ItemSet | null} item */ ItemSet.itemSetFromTarget = function itemSetFromTarget (event) { var target = event.target; while (target) { if (target.hasOwnProperty('timeline-itemset')) { return target['timeline-itemset']; } target = target.parentNode; } return null; }; /** * Find the DataSet to which this ItemSet is connected * @returns {null | DataSet} dataset * @private */ ItemSet.prototype._myDataSet = function _myDataSet() { // find the root DataSet var dataset = this.itemsData; while (dataset instanceof DataView) { dataset = dataset.data; } return dataset; }; /** * @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; this.offset = 0; } /** * Select current item */ Item.prototype.select = function select() { this.selected = true; if (this.visible) this.repaint(); }; /** * Unselect current item */ Item.prototype.unselect = function unselect() { this.selected = false; if (this.visible) this.repaint(); }; /** * 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; }; /** * Give the item a display offset in pixels * @param {Number} offset Offset on screen in pixels */ Item.prototype.setOffset = function setOffset(offset) { this.offset = offset; }; /** * Repaint a delete button on the top right of the item when the item is selected * @param {HTMLElement} anchor * @private */ Item.prototype._repaintDeleteButton = function (anchor) { if (this.selected && this.options.editable && !this.dom.deleteButton) { // create and show button var parent = this.parent; var id = this.id; var deleteButton = document.createElement('div'); deleteButton.className = 'delete'; deleteButton.title = 'Delete this item'; Hammer(deleteButton, { preventDefault: true }).on('tap', function (event) { parent.removeItem(id); event.stopPropagation(); }); anchor.appendChild(deleteButton); this.dom.deleteButton = deleteButton; } else if (!this.selected && this.dom.deleteButton) { // remove button if (this.dom.deleteButton.parentNode) { this.dom.deleteButton.parentNode.removeChild(this.dom.deleteButton); } this.dom.deleteButton = null; } }; /** * @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); /** * 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; } this._repaintDeleteButton(dom.box); // 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) + this.offset; 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'; // attach this item as attribute dom.box['timeline-item'] = this; } }; /** * 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); /** * 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; } this._repaintDeleteButton(dom.point); // 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) + this.offset; 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); // attach this item as attribute dom.point['timeline-item'] = this; } }; /** * 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); /** * 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; } this._repaintDeleteButton(dom.box); this._repaintDragLeft(); this._repaintDragRight(); // update class var className = (this.data.className ? (' ' + this.data.className) : '') + (this.selected ? ' selected' : ''); 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) + this.offset; end = parent.toScreen(this.data.end) + this.offset; 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); // attach this item as attribute dom.box['timeline-item'] = this; } }; /** * 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'; } }; /** * Repaint a drag area on the left side of the range when the range is selected * @private */ ItemRange.prototype._repaintDragLeft = function () { if (this.selected && this.options.editable && !this.dom.dragLeft) { // create and show drag area var dragLeft = document.createElement('div'); dragLeft.className = 'drag-left'; dragLeft.dragLeftItem = this; // TODO: this should be redundant? Hammer(dragLeft, { preventDefault: true }).on('drag', function () { //console.log('drag left') }); this.dom.box.appendChild(dragLeft); this.dom.dragLeft = dragLeft; } else if (!this.selected && this.dom.dragLeft) { // delete drag area if (this.dom.dragLeft.parentNode) { this.dom.dragLeft.parentNode.removeChild(this.dom.dragLeft); } this.dom.dragLeft = null; } }; /** * Repaint a drag area on the right side of the range when the range is selected * @private */ ItemRange.prototype._repaintDragRight = function () { if (this.selected && this.options.editable && !this.dom.dragRight) { // create and show drag area var dragRight = document.createElement('div'); dragRight.className = 'drag-right'; dragRight.dragRightItem = this; // TODO: this should be redundant? Hammer(dragRight, { preventDefault: true }).on('drag', function () { //console.log('drag right') }); this.dom.box.appendChild(dragRight); this.dom.dragRight = dragRight; } else if (!this.selected && this.dom.dragRight) { // delete drag area if (this.dom.dragRight.parentNode) { this.dom.dragRight.parentNode.removeChild(this.dom.dragRight); } this.dom.dragRight = null; } }; /** * @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 } }; // define a private property _width, which is the with of the range box // adhering to the ranges start and end date. The property width has a // getter which returns the max of border width and content width this._width = 0; Object.defineProperty(this, 'width', { get: function () { return (this.props.content && this._width < this.props.content.width) ? this.props.content.width : this._width; }, set: function (width) { this._width = width; } }); 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.id); } changed = true; } this._repaintDeleteButton(dom.box); this._repaintDragLeft(); this._repaintDragRight(); // update class var className = (this.data.className? ' ' + this.data.className : '') + (this.selected ? ' selected' : ''); if (this.className != className) { this.className = className; dom.box.className = 'item rangeoverflow' + className; changed = true; } } return changed; }; /** * Reposition the item, recalculate its left, top, and width, using the current * range and size of the items itemset * @override */ ItemRangeOverflow.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 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); } }; /** * Set selected items by their id. Replaces the current selection. * Unknown id's are silently ignored. * @param {Array} [ids] An array with zero or more id's of the items to be * selected. If ids is an empty array, all items will be * unselected. */ Group.prototype.setSelection = function setSelection(ids) { if (this.itemset) this.itemset.setSelection(ids); }; /** * Get the selected items by their id * @return {Array} ids The ids of the selected items */ Group.prototype.getSelection = function getSelection() { return this.itemset ? this.itemset.getSelection() : []; }; /** * 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.on(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; }; /** * Set selected items by their id. Replaces the current selection. * Unknown id's are silently ignored. * @param {Array} [ids] An array with zero or more id's of the items to be * selected. If ids is an empty array, all items will be * unselected. */ GroupSet.prototype.setSelection = function setSelection(ids) { var selection = [], groups = this.groups; // iterate over each of the groups for (var id in groups) { if (groups.hasOwnProperty(id)) { var group = groups[id]; group.setSelection(ids); } } return selection; }; /** * Get the selected items by their id * @return {Array} ids The ids of the selected items */ GroupSet.prototype.getSelection = function getSelection() { var selection = [], groups = this.groups; // iterate over each of the groups for (var id in groups) { if (groups.hasOwnProperty(id)) { var group = groups[id]; selection = selection.concat(group.getSelection()); } } return selection; }; /** * 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'; frame['timeline-groupset'] = this; 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 = 'vlabel'; 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(); } }; /** * Find the Group from an event target: * searches for the attribute 'timeline-groupset' in the event target's element * tree, then finds the right group in this groupset * @param {Event} event * @return {Group | null} group */ GroupSet.groupFromTarget = function groupFromTarget (event) { var groupset, target = event.target; while (target) { if (target.hasOwnProperty('timeline-groupset')) { groupset = target['timeline-groupset']; break; } target = target.parentNode; } if (groupset) { for (var groupId in groupset.groups) { if (groupset.groups.hasOwnProperty(groupId)) { var group = groupset.groups[groupId]; if (group.itemset && ItemSet.itemSetFromTarget(event) == group.itemset) { return group; } } } } return null; }; /** * Create a timeline visualization * @param {HTMLElement} container * @param {vis.DataSet | Array | google.visualization.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', autoResize: true, editable: false, selectable: true, snap: null, // will be specified after timeaxis is created 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, onAdd: function (item, callback) { callback(item); }, onUpdate: function (item, callback) { callback(item); }, onMove: function (item, callback) { callback(item); }, onRemove: function (item, callback) { callback(item); } }; // 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); // single select (or unselect) when tapping an item this.controller.on('tap', this._onSelectItem.bind(this)); // multi select when holding mouse/touch, or on ctrl+click this.controller.on('hold', this._onMultiSelectItem.bind(this)); // add item on doubletap this.controller.on('doubletap', this._onAddItem.bind(this)); // 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() ); this.range.subscribe(this.controller, this.rootPanel, 'move', 'horizontal'); this.range.subscribe(this.controller, this.rootPanel, 'zoom', 'horizontal'); this.range.on('rangechange', function (properties) { var force = true; me.controller.emit('rangechange', properties); me.controller.emit('request-reflow', force); }); this.range.on('rangechanged', function (properties) { var force = true; me.controller.emit('rangechanged', properties); me.controller.emit('request-reflow', force); }); // 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); this.options.snap = this.timeaxis.snap.bind(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); } } /** * Add an event listener to the timeline * @param {String} event Available events: select, rangechange, rangechanged, * timechange, timechanged * @param {function} callback */ Timeline.prototype.on = function on (event, callback) { this.controller.on(event, callback); }; /** * Add an event listener from the timeline * @param {String} event * @param {function} callback */ Timeline.prototype.off = function off (event, callback) { this.controller.off(event, callback); }; /** * Set options * @param {Object} options TODO: describe the available options */ Timeline.prototype.setOptions = function (options) { util.extend(this.options, options); // force update of range (apply new min/max etc.) // both start and end are optional this.range.setRange(options.start, options.end); if ('editable' in options || 'selectable' in options) { if (this.options.selectable) { // force update of selection this.setSelection(this.getSelection()); } else { // remove selection this.setSelection([]); } } // validate the callback functions var validateCallback = (function (fn) { if (!(this.options[fn] instanceof Function) || this.options[fn].length != 2) { throw new Error('option ' + fn + ' must be a function ' + fn + '(item, callback)'); } }).bind(this); ['onAdd', 'onUpdate', 'onRemove', 'onMove'].forEach(validateCallback); this.controller.reflow(); this.controller.repaint(); }; /** * Set a custom time bar * @param {Date} time */ Timeline.prototype.setCustomTime = function (time) { if (!this.customtime) { throw new Error('Cannot get custom time: Custom time bar is not enabled'); } this.customtime.setCustomTime(time); }; /** * Retrieve the current custom time. * @return {Date} customTime */ Timeline.prototype.getCustomTime = function() { if (!this.customtime) { throw new Error('Cannot get custom time: Custom time bar is not enabled'); } return this.customtime.getCustomTime(); }; /** * Set items * @param {vis.DataSet | Array | google.visualization.DataTable | null} items */ Timeline.prototype.setItems = function(items) { var initialLoad = (this.itemsData == null); // convert to type DataSet when needed var newDataSet; if (!items) { newDataSet = null; } else if (items instanceof DataSet) { newDataSet = items; } if (!(items instanceof DataSet)) { newDataSet = new DataSet({ convert: { start: 'Date', end: 'Date' } }); newDataSet.add(items); } // set items this.itemsData = newDataSet; this.content.setItems(newDataSet); 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 start = dataRange.min; var end = dataRange.max; if (start != null && end != null) { var interval = (end.valueOf() - start.valueOf()); if (interval <= 0) { // prevent an empty interval interval = 24 * 60 * 60 * 1000; // 1 day } start = new Date(start.valueOf() - interval * 0.05); end = new Date(end.valueOf() + interval * 0.05); } // override specified start and/or end date if (this.options.start != undefined) { start = util.convert(this.options.start, 'Date'); } if (this.options.end != undefined) { end = util.convert(this.options.end, 'Date'); } // apply range if there is a min or max available if (start != null || end != null) { this.range.setRange(start, end); } } }; /** * Set groups * @param {vis.DataSet | Array | google.visualization.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 }; }; /** * Set selected items by their id. Replaces the current selection * Unknown id's are silently ignored. * @param {Array} [ids] An array with zero or more id's of the items to be * selected. If ids is an empty array, all items will be * unselected. */ Timeline.prototype.setSelection = function setSelection (ids) { if (this.content) this.content.setSelection(ids); }; /** * Get the selected items by their id * @return {Array} ids The ids of the selected items */ Timeline.prototype.getSelection = function getSelection() { return this.content ? this.content.getSelection() : []; }; /** * Set the visible window. Both parameters are optional, you can change only * start or only end. * @param {Date | Number | String} [start] Start date of visible window * @param {Date | Number | String} [end] End date of visible window */ Timeline.prototype.setWindow = function setWindow(start, end) { this.range.setRange(start, end); }; /** * Get the visible window * @return {{start: Date, end: Date}} Visible range */ Timeline.prototype.getWindow = function setWindow() { var range = this.range.getRange(); return { start: new Date(range.start), end: new Date(range.end) }; }; /** * Handle selecting/deselecting an item when tapping it * @param {Event} event * @private */ // TODO: move this function to ItemSet Timeline.prototype._onSelectItem = function (event) { if (!this.options.selectable) return; var ctrlKey = event.gesture.srcEvent && event.gesture.srcEvent.ctrlKey; var shiftKey = event.gesture.srcEvent && event.gesture.srcEvent.shiftKey; if (ctrlKey || shiftKey) { this._onMultiSelectItem(event); return; } var item = ItemSet.itemFromTarget(event); var selection = item ? [item.id] : []; this.setSelection(selection); this.controller.emit('select', { items: this.getSelection() }); event.stopPropagation(); }; /** * Handle creation and updates of an item on double tap * @param event * @private */ Timeline.prototype._onAddItem = function (event) { if (!this.options.selectable) return; if (!this.options.editable) return; var me = this, item = ItemSet.itemFromTarget(event); if (item) { // update item // execute async handler to update the item (or cancel it) var itemData = me.itemsData.get(item.id); // get a clone of the data from the dataset this.options.onUpdate(itemData, function (itemData) { if (itemData) { me.itemsData.update(itemData); } }); } else { // add item var xAbs = vis.util.getAbsoluteLeft(this.rootPanel.frame); var x = event.gesture.center.pageX - xAbs; var newItem = { start: this.timeaxis.snap(this._toTime(x)), content: 'new item' }; var id = util.randomUUID(); newItem[this.itemsData.fieldId] = id; var group = GroupSet.groupFromTarget(event); if (group) { newItem.group = group.groupId; } // execute async handler to customize (or cancel) adding an item this.options.onAdd(newItem, function (item) { if (item) { me.itemsData.add(newItem); // select the created item after it is repainted me.controller.once('repaint', function () { me.setSelection([id]); me.controller.emit('select', { items: me.getSelection() }); }.bind(me)); } }); } }; /** * Handle selecting/deselecting multiple items when holding an item * @param {Event} event * @private */ // TODO: move this function to ItemSet Timeline.prototype._onMultiSelectItem = function (event) { if (!this.options.selectable) return; var selection, item = ItemSet.itemFromTarget(event); if (item) { // multi select items selection = this.getSelection(); // current selection var index = selection.indexOf(item.id); if (index == -1) { // item is not yet selected -> select it selection.push(item.id); } else { // item is already selected -> deselect it selection.splice(index, 1); } this.setSelection(selection); this.controller.emit('select', { items: this.getSelection() }); event.stopPropagation(); } }; /** * 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 * @private */ Timeline.prototype._toTime = function _toTime(x) { var conversion = this.range.conversion(this.content.width); 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 */ Timeline.prototype._toScreen = function _toScreen(time) { var conversion = this.range.conversion(this.content.width); return (time.valueOf() - conversion.offset) * conversion.scale; }; (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.fontDrawThreshold = 3; 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.horizontalAlignLeft = true; // these are for the navigation controls this.verticalAlignTop = true; // these are for the navigation controls this.radius = constants.nodes.radius; this.baseRadiusValue = constants.nodes.radius; this.radiusFixed = false; this.radiusMin = constants.nodes.radiusMin; this.radiusMax = constants.nodes.radiusMax; this.level = -1; this.imagelist = imagelist; this.grouplist = grouplist; // physics properties 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 = constants.physics.damping; this.mass = 1; // kg this.setProperties(properties, constants); // creating the variables for clustering this.resetCluster(); this.dynamicEdgesLength = 0; this.clusterSession = 0; this.clusterSizeWidthFactor = constants.clustering.nodeScaling.width; this.clusterSizeHeightFactor = constants.clustering.nodeScaling.height; this.clusterSizeRadiusFactor = constants.clustering.nodeScaling.radius; this.maxNodeSizeIncrements = constants.clustering.maxNodeSizeIncrements; this.growthIndicator = 0; // variables to tell the node about the graph. this.graphScaleInv = 1; this.graphScale = 1; this.canvasTopLeft = {"x": -300, "y": -300}; this.canvasBottomRight = {"x": 300, "y": 300}; this.parentEdgeId = null; } /** * (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; }; /** * 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; }; /** * 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; } this.originalLabel = undefined; // basic properties if (properties.id !== undefined) {this.id = properties.id;} if (properties.label !== undefined) {this.label = properties.label; this.originalLabel = 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 (properties.level !== undefined) {this.level = properties.level;} // physics if (properties.internalMultiplier !== undefined) {this.internalMultiplier = properties.internalMultiplier;} if (properties.damping !== undefined) {this.dampingBase = properties.damping;} if (properties.mass !== undefined) {this.mass = properties.mass;} // navigation controls properties if (properties.horizontalAlignLeft !== undefined) {this.horizontalAlignLeft = properties.horizontalAlignLeft;} if (properties.verticalAlignTop !== undefined) {this.verticalAlignTop = properties.verticalAlignTop;} if (properties.triggerFunction !== undefined) {this.triggerFunction = properties.triggerFunction;} 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 && !properties.allowedToMove); this.yFixed = this.yFixed || (properties.y !== undefined && !properties.allowedToMove); 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)) { if (util.isValidHex(color)) { var hsv = util.hexToHSV(color); var lighterColorHSV = {h:hsv.h,s:hsv.s * 0.45,v:Math.min(1,hsv.v * 1.05)}; var darkerColorHSV = {h:hsv.h,s:Math.min(1,hsv.v * 1.25),v:hsv.v*0.6}; var darkerColorHex = util.HSVToHex(darkerColorHSV.h ,darkerColorHSV.h ,darkerColorHSV.v); var lighterColorHex = util.HSVToHex(lighterColorHSV.h,lighterColorHSV.s,lighterColorHSV.v); c = { background: color, border:darkerColorHex, highlight: { background:lighterColorHex, border:darkerColorHex } }; } else { c = { background:color, border:color, highlight: { background:color, border: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); } 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 } }; /** * Perform one discrete step for the node * @param {number} interval Time interval in seconds */ Node.prototype.discreteStepLimited = function(interval, maxVelocity) { 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.vx = (Math.abs(this.vx) > maxVelocity) ? ((this.vx > 0) ? maxVelocity : -maxVelocity) : this.vx; 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.vy = (Math.abs(this.vy) > maxVelocity) ? ((this.vy > 0) ? maxVelocity : -maxVelocity) : this.vy; 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); }; /** * 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; } } this.baseRadiusValue = this.radius; }; /** * 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 || !this.height) { // undefined or 0 var width, height; if (this.value) { this.radius = this.baseRadiusValue; var scale = this.imageObj.height / this.imageObj.width; if (scale !== undefined) { width = this.radius || this.imageObj.width; height = this.radius * scale || this.imageObj.height; } else { width = 0; height = 0; } } else { width = this.imageObj.width; height = this.imageObj.height; } this.width = width; this.height = height; this.growthIndicator = 0; if (this.width > 0 && this.height > 0) { this.width += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeWidthFactor; this.height += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeHeightFactor; this.radius += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeRadiusFactor; this.growthIndicator = this.width - width; } } }; 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.width != 0 ) { // 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); } // draw the image 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 += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * 0.5 * this.clusterSizeWidthFactor; this.height += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * 0.5 * this.clusterSizeHeightFactor; this.growthIndicator = this.width - (textSize.width + 2 * margin); // this.radius += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * 0.5 * 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 += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeWidthFactor; this.height += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeHeightFactor; this.radius += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeRadiusFactor; this.growthIndicator = this.width - size; } }; 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 += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * 0.5 * this.clusterSizeWidthFactor; // this.height += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * 0.5 * this.clusterSizeHeightFactor; this.radius += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * 0.5 * this.clusterSizeRadiusFactor; this.growthIndicator = this.radius - 0.5*diameter; } }; 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; } var defaultSize = this.width; // scaling used for clustering this.width += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeWidthFactor; this.height += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeHeightFactor; this.radius += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeRadiusFactor; this.growthIndicator = this.width - defaultSize; } }; 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 += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeWidthFactor; this.height += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeHeightFactor; this.radius += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * 0.5 * this.clusterSizeRadiusFactor; this.growthIndicator = this.width - size; } }; 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 += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeWidthFactor; this.height += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeHeightFactor; this.radius += Math.min(this.clusterSize - 1, this.maxNodeSizeIncrements) * this.clusterSizeRadiusFactor; this.growthIndicator = this.width - (textSize.width + 2 * margin); } }; 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 && this.fontSize * this.graphScale > this.fontDrawThreshold) { 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 is used to determine if a node is visible at all. this is used to determine when it needs to be drawn. * there is a safety margin of 0.3 * width; * * @returns {boolean} */ Node.prototype.inArea = function() { if (this.width !== undefined) { return (this.x + this.width*this.graphScaleInv >= this.canvasTopLeft.x && this.x - this.width*this.graphScaleInv < this.canvasBottomRight.x && this.y + this.height*this.graphScaleInv >= this.canvasTopLeft.y && this.y - this.height*this.graphScaleInv < this.canvasBottomRight.y); } else { return true; } }; /** * checks if the core of the node is in the display area, this is used for opening clusters around zoom * @returns {boolean} */ Node.prototype.inView = function() { return (this.x >= this.canvasTopLeft.x && this.x < this.canvasBottomRight.x && this.y >= this.canvasTopLeft.y && this.y < this.canvasBottomRight.y); }; /** * This allows the zoom level of the graph to influence the rendering * We store the inverted scale and the coordinates of the top left, and bottom right points of the canvas * * @param scale * @param canvasTopLeft * @param canvasBottomRight */ Node.prototype.setScaleAndPos = function(scale,canvasTopLeft,canvasBottomRight) { this.graphScaleInv = 1.0/scale; this.graphScale = scale; this.canvasTopLeft = canvasTopLeft; this.canvasBottomRight = canvasBottomRight; }; /** * 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.graphScale = scale; }; /** * 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 = (this.vx < 0) ? -Math.sqrt(energyBefore/this.mass) : Math.sqrt(energyBefore/this.mass); this.vx = Math.sqrt(energyBefore/this.mass); energyBefore = this.vy * this.vy * massBeforeClustering; //this.vy = (this.vy < 0) ? -Math.sqrt(energyBefore/this.mass) : Math.sqrt(energyBefore/this.mass); 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.physics.springLength; this.customLength = false; this.selected = false; this.smooth = constants.smoothCurves; this.from = null; // a node this.to = null; // a node this.via = null; // a temp 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.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; this.customLength = true;} // 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); // 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 = this._getDistanceToEdge(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(); if (this.from != this.to) { // draw line this._line(ctx); // draw label var point; if (this.label) { if (this.smooth == true) { var midpointX = 0.5*(0.5*(this.from.x + this.via.x) + 0.5*(this.to.x + this.via.x)); var midpointY = 0.5*(0.5*(this.from.y + this.via.y) + 0.5*(this.to.y + this.via.y)); point = {x:midpointX, y:midpointY}; } else { 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.selected == true) { 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); if (this.smooth == true) { ctx.quadraticCurveTo(this.via.x,this.via.y,this.to.x, this.to.y); } else { 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(); // only firefox and chrome support this method, else we use the legacy one. if (ctx.mozDash !== undefined || ctx.setLineDash !== undefined) { ctx.beginPath(); ctx.moveTo(this.from.x, this.from.y); // configure the dash pattern var pattern = [0]; if (this.dash.length !== undefined && this.dash.gap !== undefined) { pattern = [this.dash.length,this.dash.gap]; } else { pattern = [5,5]; } // set dash settings for chrome or firefox if (typeof ctx.setLineDash !== 'undefined') { //Chrome ctx.setLineDash(pattern); ctx.lineDashOffset = 0; } else { //Firefox ctx.mozDash = pattern; ctx.mozDashOffset = 0; } // draw the line if (this.smooth == true) { ctx.quadraticCurveTo(this.via.x,this.via.y,this.to.x, this.to.y); } else { ctx.lineTo(this.to.x, this.to.y); } ctx.stroke(); // restore the dash settings. if (typeof ctx.setLineDash !== 'undefined') { //Chrome ctx.setLineDash([0]); ctx.lineDashOffset = 0; } else { //Firefox ctx.mozDash = [0]; ctx.mozDashOffset = 0; } } else { // unsupporting smooth lines // 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; if (this.smooth == true) { var midpointX = 0.5*(0.5*(this.from.x + this.via.x) + 0.5*(this.to.x + this.via.x)); var midpointY = 0.5*(0.5*(this.from.y + this.via.y) + 0.5*(this.to.y + this.via.y)); point = {x:midpointX, y:midpointY}; } else { 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); 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? // draw an arrow halfway the line if (this.smooth == true) { var midpointX = 0.5*(0.5*(this.from.x + this.via.x) + 0.5*(this.to.x + this.via.x)); var midpointY = 0.5*(0.5*(this.from.y + this.via.y) + 0.5*(this.to.y + this.via.y)); point = {x:midpointX, y:midpointY}; } else { point = this._pointOnLine(0.5); } ctx.arrow(point.x, point.y, angle, length); ctx.fill(); ctx.stroke(); // draw label if (this.label) { this._label(ctx, this.label, point.x, point.y); } } else { // draw circle var x, y; var radius = 0.25 * Math.max(100,this.length); var node = this.from; if (!node.width) { node.resize(ctx); } if (node.width > node.height) { x = node.x + node.width * 0.5; y = node.y - radius; } else { x = node.x + radius; y = node.y - node.height * 0.5; } 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(); var angle, length; //draw a line if (this.from != this.to) { 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 edgeSegmentLength = Math.sqrt(dx * dx + dy * dy); var fromBorderDist = this.from.distanceToBorder(ctx, angle + Math.PI); var fromBorderPoint = (edgeSegmentLength - fromBorderDist) / edgeSegmentLength; var xFrom = (fromBorderPoint) * this.from.x + (1 - fromBorderPoint) * this.to.x; var yFrom = (fromBorderPoint) * this.from.y + (1 - fromBorderPoint) * this.to.y; if (this.smooth == true) { angle = Math.atan2((this.to.y - this.via.y), (this.to.x - this.via.x)); dx = (this.to.x - this.via.x); dy = (this.to.y - this.via.y); edgeSegmentLength = Math.sqrt(dx * dx + dy * dy); } var toBorderDist = this.to.distanceToBorder(ctx, angle); var toBorderPoint = (edgeSegmentLength - toBorderDist) / edgeSegmentLength; var xTo,yTo; if (this.smooth == true) { xTo = (1 - toBorderPoint) * this.via.x + toBorderPoint * this.to.x; yTo = (1 - toBorderPoint) * this.via.y + toBorderPoint * this.to.y; } else { xTo = (1 - toBorderPoint) * this.from.x + toBorderPoint * this.to.x; yTo = (1 - toBorderPoint) * this.from.y + toBorderPoint * this.to.y; } ctx.beginPath(); ctx.moveTo(xFrom,yFrom); if (this.smooth == true) { ctx.quadraticCurveTo(this.via.x,this.via.y,xTo, yTo); } else { ctx.lineTo(xTo, yTo); } ctx.stroke(); // draw arrow at the end of the line length = 10 + 5 * this.width; ctx.arrow(xTo, yTo, angle, length); ctx.fill(); ctx.stroke(); // draw label if (this.label) { var point; if (this.smooth == true) { var midpointX = 0.5*(0.5*(this.from.x + this.via.x) + 0.5*(this.to.x + this.via.x)); var midpointY = 0.5*(0.5*(this.from.y + this.via.y) + 0.5*(this.to.y + this.via.y)); point = {x:midpointX, y:midpointY}; } else { 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 = 0.25 * Math.max(100,this.length); if (!node.width) { node.resize(ctx); } if (node.width > node.height) { x = node.x + node.width * 0.5; 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 * 0.5; arrow = { x: node.x, y: y, angle: 0.6 * Math.PI }; } ctx.beginPath(); // 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.prototype._getDistanceToEdge = function (x1,y1, x2,y2, x3,y3) { // x3,y3 is the point if (this.smooth == true) { var minDistance = 1e9; var i,t,x,y,dx,dy; for (i = 0; i < 10; i++) { t = 0.1*i; x = Math.pow(1-t,2)*x1 + (2*t*(1 - t))*this.via.x + Math.pow(t,2)*x2; y = Math.pow(1-t,2)*y1 + (2*t*(1 - t))*this.via.y + Math.pow(t,2)*y2; dx = Math.abs(x3-x); dy = Math.abs(y3-y); minDistance = Math.min(minDistance,Math.sqrt(dx*dx + dy*dy)); } return minDistance } else { 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; }; Edge.prototype.select = function() { this.selected = true; }; Edge.prototype.unselect = function() { this.selected = false; }; Edge.prototype.positionBezierNode = function() { if (this.via !== null) { this.via.x = 0.5 * (this.from.x + this.to.x); this.via.y = 0.5 * (this.from.y + this.to.y); } }; /** * 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; }; /** * Created by Alex on 2/6/14. */ var physicsMixin = { /** * Toggling barnes Hut calculation on and off. * * @private */ _toggleBarnesHut : function() { this.constants.physics.barnesHut.enabled = !this.constants.physics.barnesHut.enabled; this._loadSelectedForceSolver(); this.moving = true; this.start(); }, /** * This loads the node force solver based on the barnes hut or repulsion algorithm * * @private */ _loadSelectedForceSolver : function() { // this overloads the this._calculateNodeForces if (this.constants.physics.barnesHut.enabled == true) { this._clearMixin(repulsionMixin); this._clearMixin(hierarchalRepulsionMixin); this.constants.physics.centralGravity = this.constants.physics.barnesHut.centralGravity; this.constants.physics.springLength = this.constants.physics.barnesHut.springLength; this.constants.physics.springConstant = this.constants.physics.barnesHut.springConstant; this.constants.physics.damping = this.constants.physics.barnesHut.damping; this._loadMixin(barnesHutMixin); } else if (this.constants.physics.hierarchicalRepulsion.enabled == true) { this._clearMixin(barnesHutMixin); this._clearMixin(repulsionMixin); this.constants.physics.centralGravity = this.constants.physics.hierarchicalRepulsion.centralGravity; this.constants.physics.springLength = this.constants.physics.hierarchicalRepulsion.springLength; this.constants.physics.springConstant = this.constants.physics.hierarchicalRepulsion.springConstant; this.constants.physics.damping = this.constants.physics.hierarchicalRepulsion.damping; this._loadMixin(hierarchalRepulsionMixin); } else { this._clearMixin(barnesHutMixin); this._clearMixin(hierarchalRepulsionMixin); this.barnesHutTree = undefined; this.constants.physics.centralGravity = this.constants.physics.repulsion.centralGravity; this.constants.physics.springLength = this.constants.physics.repulsion.springLength; this.constants.physics.springConstant = this.constants.physics.repulsion.springConstant; this.constants.physics.damping = this.constants.physics.repulsion.damping; this._loadMixin(repulsionMixin); } }, /** * Before calculating the forces, we check if we need to cluster to keep up performance and we check * if there is more than one node. If it is just one node, we dont calculate anything. * * @private */ _initializeForceCalculation : function() { // stop calculation if there is only one node if (this.nodeIndices.length == 1) { this.nodes[this.nodeIndices[0]]._setForce(0,0); } else { // if there are too many nodes on screen, we cluster without repositioning if (this.nodeIndices.length > this.constants.clustering.clusterThreshold && this.constants.clustering.enabled == true) { this.clusterToFit(this.constants.clustering.reduceToNodes, false); } // we now start the force calculation this._calculateForces(); } }, /** * Calculate the external forces acting on the nodes * Forces are caused by: edges, repulsing forces between nodes, gravity * @private */ _calculateForces : function() { // 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 this._calculateGravitationalForces(); this._calculateNodeForces(); if (this.constants.smoothCurves == true) { this._calculateSpringForcesWithSupport(); } else { this._calculateSpringForces(); } }, /** * Smooth curves are created by adding invisible nodes in the center of the edges. These nodes are also * handled in the calculateForces function. We then use a quadratic curve with the center node as control. * This function joins the datanodes and invisible (called support) nodes into one object. * We do this so we do not contaminate this.nodes with the support nodes. * * @private */ _updateCalculationNodes : function() { if (this.constants.smoothCurves == true) { this.calculationNodes = {}; this.calculationNodeIndices = []; for (var nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { this.calculationNodes[nodeId] = this.nodes[nodeId]; } } var supportNodes = this.sectors['support']['nodes']; for (var supportNodeId in supportNodes) { if (supportNodes.hasOwnProperty(supportNodeId)) { if (this.edges.hasOwnProperty(supportNodes[supportNodeId].parentEdgeId)) { this.calculationNodes[supportNodeId] = supportNodes[supportNodeId]; } else { supportNodes[supportNodeId]._setForce(0,0); } } } for (var idx in this.calculationNodes) { if (this.calculationNodes.hasOwnProperty(idx)) { this.calculationNodeIndices.push(idx); } } } else { this.calculationNodes = this.nodes; this.calculationNodeIndices = this.nodeIndices; } }, /** * this function applies the central gravity effect to keep groups from floating off * * @private */ _calculateGravitationalForces : function() { var dx, dy, distance, node, i; var nodes = this.calculationNodes; var gravity = this.constants.physics.centralGravity; var gravityForce = 0; for (i = 0; i < this.calculationNodeIndices.length; i++) { node = nodes[this.calculationNodeIndices[i]]; node.damping = this.constants.physics.damping; // possibly add function to alter damping properties of clusters. // gravity does not apply when we are in a pocket sector if (this._sector() == "default" && gravity != 0) { dx = -node.x; dy = -node.y; distance = Math.sqrt(dx*dx + dy*dy); gravityForce = gravity / distance; node.fx = dx * gravityForce; node.fy = dy * gravityForce; } else { node.fx = 0; node.fy = 0; } } }, /** * this function calculates the effects of the springs in the case of unsmooth curves. * * @private */ _calculateSpringForces : function() { var edgeLength, edge, edgeId; var dx, dy, fx, fy, springForce, length; var edges = this.edges; // forces caused by the edges, modelled as springs for (edgeId in edges) { if (edges.hasOwnProperty(edgeId)) { edge = edges[edgeId]; if (edge.connected) { // only calculate forces if nodes are in the same sector if (this.nodes.hasOwnProperty(edge.toId) && this.nodes.hasOwnProperty(edge.fromId)) { edgeLength = edge.customLength ? edge.length : this.constants.physics.springLength; // this implies that the edges between big clusters are longer edgeLength += (edge.to.clusterSize + edge.from.clusterSize - 2) * this.constants.clustering.edgeGrowth; dx = (edge.from.x - edge.to.x); dy = (edge.from.y - edge.to.y); length = Math.sqrt(dx * dx + dy * dy); if (length == 0) { length = 0.01; } springForce = this.constants.physics.springConstant * (edgeLength - length) / length; fx = dx * springForce; fy = dy * springForce; edge.from.fx += fx; edge.from.fy += fy; edge.to.fx -= fx; edge.to.fy -= fy; } } } } }, /** * This function calculates the springforces on the nodes, accounting for the support nodes. * * @private */ _calculateSpringForcesWithSupport : function() { var edgeLength, edge, edgeId, combinedClusterSize; var edges = this.edges; // forces caused by the edges, modelled as springs for (edgeId in edges) { if (edges.hasOwnProperty(edgeId)) { edge = edges[edgeId]; if (edge.connected) { // only calculate forces if nodes are in the same sector if (this.nodes.hasOwnProperty(edge.toId) && this.nodes.hasOwnProperty(edge.fromId)) { if (edge.via != null) { var node1 = edge.to; var node2 = edge.via; var node3 = edge.from; edgeLength = edge.customLength ? edge.length : this.constants.physics.springLength; combinedClusterSize = node1.clusterSize + node3.clusterSize - 2; // this implies that the edges between big clusters are longer edgeLength += combinedClusterSize * this.constants.clustering.edgeGrowth; this._calculateSpringForce(node1,node2,0.5*edgeLength); this._calculateSpringForce(node2,node3,0.5*edgeLength); } } } } } }, /** * This is the code actually performing the calculation for the function above. It is split out to avoid repetition. * * @param node1 * @param node2 * @param edgeLength * @private */ _calculateSpringForce : function(node1,node2,edgeLength) { var dx, dy, fx, fy, springForce, length; dx = (node1.x - node2.x); dy = (node1.y - node2.y); length = Math.sqrt(dx * dx + dy * dy); springForce = this.constants.physics.springConstant * (edgeLength - length) / length; if (length == 0) { length = 0.01; } fx = dx * springForce; fy = dy * springForce; node1.fx += fx; node1.fy += fy; node2.fx -= fx; node2.fy -= fy; }, /** * Load the HTML for the physics config and bind it * @private */ _loadPhysicsConfiguration : function() { if (this.physicsConfiguration === undefined) { var hierarchicalLayoutDirections = ["LR","RL","UD","DU"]; this.physicsConfiguration = document.createElement('div'); this.physicsConfiguration.className = "PhysicsConfiguration"; this.physicsConfiguration.innerHTML = '' + '' + '' + '' + ''+ '' + ''+ '
Simulation Mode:
Barnes HutRepulsionHierarchical
' + ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ '' this.containerElement.parentElement.insertBefore(this.physicsConfiguration,this.containerElement); var rangeElement; rangeElement = document.getElementById('graph_BH_gc'); rangeElement.onchange = showValueOfRange.bind(this,'graph_BH_gc',-1,"physics_barnesHut_gravitationalConstant"); rangeElement = document.getElementById('graph_BH_cg'); rangeElement.onchange = showValueOfRange.bind(this,'graph_BH_cg',1,"physics_centralGravity"); rangeElement = document.getElementById('graph_BH_sc'); rangeElement.onchange = showValueOfRange.bind(this,'graph_BH_sc',1,"physics_springConstant"); rangeElement = document.getElementById('graph_BH_sl'); rangeElement.onchange = showValueOfRange.bind(this,'graph_BH_sl',1,"physics_springLength"); rangeElement = document.getElementById('graph_BH_damp'); rangeElement.onchange = showValueOfRange.bind(this,'graph_BH_damp',1,"physics_damping"); rangeElement = document.getElementById('graph_R_nd'); rangeElement.onchange = showValueOfRange.bind(this,'graph_R_nd',1,"physics_repulsion_nodeDistance"); rangeElement = document.getElementById('graph_R_cg'); rangeElement.onchange = showValueOfRange.bind(this,'graph_R_cg',1,"physics_centralGravity"); rangeElement = document.getElementById('graph_R_sc'); rangeElement.onchange = showValueOfRange.bind(this,'graph_R_sc',1,"physics_springConstant"); rangeElement = document.getElementById('graph_R_sl'); rangeElement.onchange = showValueOfRange.bind(this,'graph_R_sl',1,"physics_springLength"); rangeElement = document.getElementById('graph_R_damp'); rangeElement.onchange = showValueOfRange.bind(this,'graph_R_damp',1,"physics_damping"); rangeElement = document.getElementById('graph_H_nd'); rangeElement.onchange = showValueOfRange.bind(this,'graph_H_nd',1,"physics_hierarchicalRepulsion_nodeDistance"); rangeElement = document.getElementById('graph_H_cg'); rangeElement.onchange = showValueOfRange.bind(this,'graph_H_cg',1,"physics_centralGravity"); rangeElement = document.getElementById('graph_H_sc'); rangeElement.onchange = showValueOfRange.bind(this,'graph_H_sc',1,"physics_springConstant"); rangeElement = document.getElementById('graph_H_sl'); rangeElement.onchange = showValueOfRange.bind(this,'graph_H_sl',1,"physics_springLength"); rangeElement = document.getElementById('graph_H_damp'); rangeElement.onchange = showValueOfRange.bind(this,'graph_H_damp',1,"physics_damping"); rangeElement = document.getElementById('graph_H_direction'); rangeElement.onchange = showValueOfRange.bind(this,'graph_H_direction',hierarchicalLayoutDirections,"hierarchicalLayout_direction"); rangeElement = document.getElementById('graph_H_levsep'); rangeElement.onchange = showValueOfRange.bind(this,'graph_H_levsep',1,"hierarchicalLayout_levelSeparation"); rangeElement = document.getElementById('graph_H_nspac'); rangeElement.onchange = showValueOfRange.bind(this,'graph_H_nspac',1,"hierarchicalLayout_nodeSpacing"); var radioButton1 = document.getElementById("graph_physicsMethod1"); var radioButton2 = document.getElementById("graph_physicsMethod2"); var radioButton3 = document.getElementById("graph_physicsMethod3"); radioButton2.checked = true; if (this.constants.physics.barnesHut.enabled) { radioButton1.checked = true; } if (this.constants.hierarchicalLayout.enabled) { radioButton3.checked = true; } switchConfigurations.apply(this); radioButton1.onchange = switchConfigurations.bind(this); radioButton2.onchange = switchConfigurations.bind(this); radioButton3.onchange = switchConfigurations.bind(this); } }, _overWriteGraphConstants : function(constantsVariableName, value) { var nameArray = constantsVariableName.split("_"); if (nameArray.length == 1) { this.constants[nameArray[0]] = value; } else if (nameArray.length == 2) { this.constants[nameArray[0]][nameArray[1]] = value; } else if (nameArray.length == 3) { this.constants[nameArray[0]][nameArray[1]][nameArray[2]] = value; } } } function switchConfigurations () { var ids = ["graph_BH_table","graph_R_table","graph_H_table"] var radioButton = document.querySelector('input[name="graph_physicsMethod"]:checked').value; var tableId = "graph_" + radioButton + "_table"; var table = document.getElementById(tableId); table.style.display = "block"; for (var i = 0; i < ids.length; i++) { if (ids[i] != tableId) { table = document.getElementById(ids[i]); table.style.display = "none"; } } this._restoreNodes(); if (radioButton == "R") { this.constants.hierarchicalLayout.enabled = false; this.constants.physics.hierarchicalRepulsion.enabeled = false; this.constants.physics.barnesHut.enabled = false; } else if (radioButton == "H") { this.constants.hierarchicalLayout.enabled = true; this.constants.physics.hierarchicalRepulsion.enabeled = true; this.constants.physics.barnesHut.enabled = false; this._setupHierarchicalLayout(); } else { this.constants.hierarchicalLayout.enabled = false; this.constants.physics.hierarchicalRepulsion.enabeled = false; this.constants.physics.barnesHut.enabled = true; } this._loadSelectedForceSolver(); this.moving = true; this.start(); } function showValueOfRange (id,map,constantsVariableName) { var valueId = id + "_value"; var rangeValue = document.getElementById(id).value; if (constantsVariableName == "hierarchicalLayout_direction" || constantsVariableName == "hierarchicalLayout_levelSeparation" || constantsVariableName == "hierarchicalLayout_nodeSpacing") { this._setupHierarchicalLayout(); } if (map instanceof Array) { document.getElementById(valueId).value = map[parseInt(rangeValue)]; this._overWriteGraphConstants(constantsVariableName,map[parseInt(rangeValue)]); } else { document.getElementById(valueId).value = map * parseFloat(rangeValue); this._overWriteGraphConstants(constantsVariableName,map * parseFloat(rangeValue)); } this.moving = true; this.start(); }; /** * Created by Alex on 2/10/14. */ var hierarchalRepulsionMixin = { /** * Calculate the forces the nodes apply on eachother based on a repulsion field. * This field is linearly approximated. * * @private */ _calculateNodeForces : function() { var dx, dy, distance, fx, fy, combinedClusterSize, repulsingForce, node1, node2, i, j; var nodes = this.calculationNodes; var nodeIndices = this.calculationNodeIndices; // approximation constants var b = 5; var a_base = 0.5*-b; // repulsing forces between nodes var nodeDistance = this.constants.physics.hierarchicalRepulsion.nodeDistance; var minimumDistance = nodeDistance; // 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 (i = 0; i < nodeIndices.length-1; i++) { node1 = nodes[nodeIndices[i]]; for (j = i+1; j < nodeIndices.length; j++) { node2 = nodes[nodeIndices[j]]; dx = node2.x - node1.x; dy = node2.y - node1.y; distance = Math.sqrt(dx * dx + dy * dy); var a = a_base / minimumDistance; if (distance < 2*minimumDistance) { repulsingForce = a * distance + b; // linear approx of 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)) // normalize force with if (distance == 0) { distance = 0.01; } else { repulsingForce = repulsingForce/distance; } fx = dx * repulsingForce; fy = dy * repulsingForce; node1.fx -= fx; node1.fy -= fy; node2.fx += fx; node2.fy += fy; } } } } } /** * Created by Alex on 2/10/14. */ var barnesHutMixin = { /** * This function calculates the forces the nodes apply on eachother based on a gravitational model. * The Barnes Hut method is used to speed up this N-body simulation. * * @private */ _calculateNodeForces : function() { var node; var nodes = this.calculationNodes; var nodeIndices = this.calculationNodeIndices; var nodeCount = nodeIndices.length; this._formBarnesHutTree(nodes,nodeIndices); var barnesHutTree = this.barnesHutTree; // place the nodes one by one recursively for (var i = 0; i < nodeCount; i++) { node = nodes[nodeIndices[i]]; // starting with root is irrelevant, it never passes the BarnesHut condition this._getForceContribution(barnesHutTree.root.children.NW,node); this._getForceContribution(barnesHutTree.root.children.NE,node); this._getForceContribution(barnesHutTree.root.children.SW,node); this._getForceContribution(barnesHutTree.root.children.SE,node); } }, /** * This function traverses the barnesHutTree. It checks when it can approximate distant nodes with their center of mass. * If a region contains a single node, we check if it is not itself, then we apply the force. * * @param parentBranch * @param node * @private */ _getForceContribution : function(parentBranch,node) { // we get no force contribution from an empty region if (parentBranch.childrenCount > 0) { var dx,dy,distance; // get the distance from the center of mass to the node. dx = parentBranch.centerOfMass.x - node.x; dy = parentBranch.centerOfMass.y - node.y; distance = Math.sqrt(dx * dx + dy * dy); // BarnesHut condition // original condition : s/d < theta = passed === d/s > 1/theta = passed // calcSize = 1/s --> d * 1/s > 1/theta = passed if (distance * parentBranch.calcSize > this.constants.physics.barnesHut.theta) { // duplicate code to reduce function calls to speed up program if (distance == 0) { distance = 0.1*Math.random(); dx = distance; } var gravityForce = this.constants.physics.barnesHut.gravitationalConstant * parentBranch.mass * node.mass / (distance * distance * distance); var fx = dx * gravityForce; var fy = dy * gravityForce; node.fx += fx; node.fy += fy; } else { // Did not pass the condition, go into children if available if (parentBranch.childrenCount == 4) { this._getForceContribution(parentBranch.children.NW,node); this._getForceContribution(parentBranch.children.NE,node); this._getForceContribution(parentBranch.children.SW,node); this._getForceContribution(parentBranch.children.SE,node); } else { // parentBranch must have only one node, if it was empty we wouldnt be here if (parentBranch.children.data.id != node.id) { // if it is not self // duplicate code to reduce function calls to speed up program if (distance == 0) { distance = 0.5*Math.random(); dx = distance; } var gravityForce = this.constants.physics.barnesHut.gravitationalConstant * parentBranch.mass * node.mass / (distance * distance * distance); var fx = dx * gravityForce; var fy = dy * gravityForce; node.fx += fx; node.fy += fy; } } } } }, /** * This function constructs the barnesHut tree recursively. It creates the root, splits it and starts placing the nodes. * * @param nodes * @param nodeIndices * @private */ _formBarnesHutTree : function(nodes,nodeIndices) { var node; var nodeCount = nodeIndices.length; var minX = Number.MAX_VALUE, minY = Number.MAX_VALUE, maxX =-Number.MAX_VALUE, maxY =-Number.MAX_VALUE; // get the range of the nodes for (var i = 0; i < nodeCount; i++) { var x = nodes[nodeIndices[i]].x; var y = nodes[nodeIndices[i]].y; if (x < minX) { minX = x; } if (x > maxX) { maxX = x; } if (y < minY) { minY = y; } if (y > maxY) { maxY = y; } } // make the range a square var sizeDiff = Math.abs(maxX - minX) - Math.abs(maxY - minY); // difference between X and Y if (sizeDiff > 0) {minY -= 0.5 * sizeDiff; maxY += 0.5 * sizeDiff;} // xSize > ySize else {minX += 0.5 * sizeDiff; maxX -= 0.5 * sizeDiff;} // xSize < ySize var minimumTreeSize = 1e-5; var rootSize = Math.max(minimumTreeSize,Math.abs(maxX - minX)); var halfRootSize = 0.5 * rootSize; var centerX = 0.5 * (minX + maxX), centerY = 0.5 * (minY + maxY); // construct the barnesHutTree var barnesHutTree = {root:{ centerOfMass:{x:0,y:0}, // Center of Mass mass:0, range: {minX:centerX-halfRootSize,maxX:centerX+halfRootSize, minY:centerY-halfRootSize,maxY:centerY+halfRootSize}, size: rootSize, calcSize: 1 / rootSize, children: {data:null}, maxWidth: 0, level: 0, childrenCount: 4 }}; this._splitBranch(barnesHutTree.root); // place the nodes one by one recursively for (i = 0; i < nodeCount; i++) { node = nodes[nodeIndices[i]]; this._placeInTree(barnesHutTree.root,node); } // make global this.barnesHutTree = barnesHutTree }, _updateBranchMass : function(parentBranch, node) { var totalMass = parentBranch.mass + node.mass; var totalMassInv = 1/totalMass; parentBranch.centerOfMass.x = parentBranch.centerOfMass.x * parentBranch.mass + node.x * node.mass; parentBranch.centerOfMass.x *= totalMassInv; parentBranch.centerOfMass.y = parentBranch.centerOfMass.y * parentBranch.mass + node.y * node.mass; parentBranch.centerOfMass.y *= totalMassInv; parentBranch.mass = totalMass; var biggestSize = Math.max(Math.max(node.height,node.radius),node.width); parentBranch.maxWidth = (parentBranch.maxWidth < biggestSize) ? biggestSize : parentBranch.maxWidth; }, _placeInTree : function(parentBranch,node,skipMassUpdate) { if (skipMassUpdate != true || skipMassUpdate === undefined) { // update the mass of the branch. this._updateBranchMass(parentBranch,node); } if (parentBranch.children.NW.range.maxX > node.x) { // in NW or SW if (parentBranch.children.NW.range.maxY > node.y) { // in NW this._placeInRegion(parentBranch,node,"NW"); } else { // in SW this._placeInRegion(parentBranch,node,"SW"); } } else { // in NE or SE if (parentBranch.children.NW.range.maxY > node.y) { // in NE this._placeInRegion(parentBranch,node,"NE"); } else { // in SE this._placeInRegion(parentBranch,node,"SE"); } } }, _placeInRegion : function(parentBranch,node,region) { switch (parentBranch.children[region].childrenCount) { case 0: // place node here parentBranch.children[region].children.data = node; parentBranch.children[region].childrenCount = 1; this._updateBranchMass(parentBranch.children[region],node); break; case 1: // convert into children // if there are two nodes exactly overlapping (on init, on opening of cluster etc.) // we move one node a pixel and we do not put it in the tree. if (parentBranch.children[region].children.data.x == node.x && parentBranch.children[region].children.data.y == node.y) { node.x += Math.random(); node.y += Math.random(); this._placeInTree(parentBranch,node, true); } else { this._splitBranch(parentBranch.children[region]); this._placeInTree(parentBranch.children[region],node); } break; case 4: // place in branch this._placeInTree(parentBranch.children[region],node); break; } }, /** * this function splits a branch into 4 sub branches. If the branch contained a node, we place it in the subbranch * after the split is complete. * * @param parentBranch * @private */ _splitBranch : function(parentBranch) { // if the branch is filled with a node, replace the node in the new subset. var containedNode = null; if (parentBranch.childrenCount == 1) { containedNode = parentBranch.children.data; parentBranch.mass = 0; parentBranch.centerOfMass.x = 0; parentBranch.centerOfMass.y = 0; } parentBranch.childrenCount = 4; parentBranch.children.data = null; this._insertRegion(parentBranch,"NW"); this._insertRegion(parentBranch,"NE"); this._insertRegion(parentBranch,"SW"); this._insertRegion(parentBranch,"SE"); if (containedNode != null) { this._placeInTree(parentBranch,containedNode); } }, /** * This function subdivides the region into four new segments. * Specifically, this inserts a single new segment. * It fills the children section of the parentBranch * * @param parentBranch * @param region * @param parentRange * @private */ _insertRegion : function(parentBranch, region) { var minX,maxX,minY,maxY; var childSize = 0.5 * parentBranch.size; switch (region) { case "NW": minX = parentBranch.range.minX; maxX = parentBranch.range.minX + childSize; minY = parentBranch.range.minY; maxY = parentBranch.range.minY + childSize; break; case "NE": minX = parentBranch.range.minX + childSize; maxX = parentBranch.range.maxX; minY = parentBranch.range.minY; maxY = parentBranch.range.minY + childSize; break; case "SW": minX = parentBranch.range.minX; maxX = parentBranch.range.minX + childSize; minY = parentBranch.range.minY + childSize; maxY = parentBranch.range.maxY; break; case "SE": minX = parentBranch.range.minX + childSize; maxX = parentBranch.range.maxX; minY = parentBranch.range.minY + childSize; maxY = parentBranch.range.maxY; break; } parentBranch.children[region] = { centerOfMass:{x:0,y:0}, mass:0, range:{minX:minX,maxX:maxX,minY:minY,maxY:maxY}, size: 0.5 * parentBranch.size, calcSize: 2 * parentBranch.calcSize, children: {data:null}, maxWidth: 0, level: parentBranch.level+1, childrenCount: 0 }; }, /** * This function is for debugging purposed, it draws the tree. * * @param ctx * @param color * @private */ _drawTree : function(ctx,color) { if (this.barnesHutTree !== undefined) { ctx.lineWidth = 1; this._drawBranch(this.barnesHutTree.root,ctx,color); } }, /** * This function is for debugging purposes. It draws the branches recursively. * * @param branch * @param ctx * @param color * @private */ _drawBranch : function(branch,ctx,color) { if (color === undefined) { color = "#FF0000"; } if (branch.childrenCount == 4) { this._drawBranch(branch.children.NW,ctx); this._drawBranch(branch.children.NE,ctx); this._drawBranch(branch.children.SE,ctx); this._drawBranch(branch.children.SW,ctx); } ctx.strokeStyle = color; ctx.beginPath(); ctx.moveTo(branch.range.minX,branch.range.minY); ctx.lineTo(branch.range.maxX,branch.range.minY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(branch.range.maxX,branch.range.minY); ctx.lineTo(branch.range.maxX,branch.range.maxY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(branch.range.maxX,branch.range.maxY); ctx.lineTo(branch.range.minX,branch.range.maxY); ctx.stroke(); ctx.beginPath(); ctx.moveTo(branch.range.minX,branch.range.maxY); ctx.lineTo(branch.range.minX,branch.range.minY); ctx.stroke(); /* if (branch.mass > 0) { ctx.circle(branch.centerOfMass.x, branch.centerOfMass.y, 3*branch.mass); ctx.stroke(); } */ } }; /** * Created by Alex on 2/10/14. */ var repulsionMixin = { /** * Calculate the forces the nodes apply on eachother based on a repulsion field. * This field is linearly approximated. * * @private */ _calculateNodeForces : function() { var dx, dy, angle, distance, fx, fy, combinedClusterSize, repulsingForce, node1, node2, i, j; var nodes = this.calculationNodes; var nodeIndices = this.calculationNodeIndices; // approximation constants var a_base = -2/3; var b = 4/3; // repulsing forces between nodes var nodeDistance = this.constants.physics.repulsion.nodeDistance; var minimumDistance = nodeDistance; // 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 (i = 0; i < nodeIndices.length-1; i++) { node1 = nodes[nodeIndices[i]]; for (j = i+1; j < nodeIndices.length; j++) { node2 = nodes[nodeIndices[j]]; combinedClusterSize = node1.clusterSize + node2.clusterSize - 2; dx = node2.x - node1.x; dy = node2.y - node1.y; distance = Math.sqrt(dx * dx + dy * dy); minimumDistance = (combinedClusterSize == 0) ? nodeDistance : (nodeDistance * (1 + combinedClusterSize * this.constants.clustering.distanceAmplification)); var a = a_base / minimumDistance; if (distance < 2*minimumDistance) { if (distance < 0.5*minimumDistance) { repulsingForce = 1.0; } else { repulsingForce = a * distance + b; // linear approx of 1 / (1 + Math.exp((distance / minimumDistance - 1) * steepness)) } // amplify the repulsion for clusters. repulsingForce *= (combinedClusterSize == 0) ? 1 : 1 + combinedClusterSize * this.constants.clustering.forceAmplification; repulsingForce = repulsingForce/distance; fx = dx * repulsingForce; fy = dy * repulsingForce; node1.fx -= fx; node1.fy -= fy; node2.fx += fx; node2.fy += fy; } } } } } var HierarchicalLayoutMixin = { /** * This is the main function to layout the nodes in a hierarchical way. * It checks if the node details are supplied correctly * * @private */ _setupHierarchicalLayout : function() { if (this.constants.hierarchicalLayout.enabled == true) { if (this.constants.hierarchicalLayout.direction == "RL" || this.constants.hierarchicalLayout.direction == "DU") { this.constants.hierarchicalLayout.levelSeparation *= -1; } // get the size of the largest hubs and check if the user has defined a level for a node. var hubsize = 0; var node, nodeId; var definedLevel = false; var undefinedLevel = false; for (nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { node = this.nodes[nodeId]; if (node.level != -1) { definedLevel = true; } else { undefinedLevel = true; } if (hubsize < node.edges.length) { hubsize = node.edges.length; } } } // if the user defined some levels but not all, alert and run without hierarchical layout if (undefinedLevel == true && definedLevel == true) { alert("To use the hierarchical layout, nodes require either no predefined levels or levels have to be defined for all nodes.") this.zoomExtent(true,this.constants.clustering.enabled); if (!this.constants.clustering.enabled) { this.start(); } } else { // setup the system to use hierarchical method. this._changeConstants(); // define levels if undefined by the users. Based on hubsize if (undefinedLevel == true) { this._determineLevels(hubsize); } // check the distribution of the nodes per level. var distribution = this._getDistribution(); // place the nodes on the canvas. This also stablilizes the system. this._placeNodesByHierarchy(distribution); // start the simulation. this.start(); } } }, /** * This function places the nodes on the canvas based on the hierarchial distribution. * * @param {Object} distribution | obtained by the function this._getDistribution() * @private */ _placeNodesByHierarchy : function(distribution) { var nodeId, node; // start placing all the level 0 nodes first. Then recursively position their branches. for (nodeId in distribution[0].nodes) { if (distribution[0].nodes.hasOwnProperty(nodeId)) { node = distribution[0].nodes[nodeId]; if (this.constants.hierarchicalLayout.direction == "UD" || this.constants.hierarchicalLayout.direction == "DU") { if (node.xFixed) { node.x = distribution[0].minPos; node.xFixed = false; distribution[0].minPos += distribution[0].nodeSpacing; } } else { if (node.yFixed) { node.y = distribution[0].minPos; node.yFixed = false; distribution[0].minPos += distribution[0].nodeSpacing; } } this._placeBranchNodes(node.edges,node.id,distribution,node.level); } } // stabilize the system after positioning. This function calls zoomExtent. this._doStabilize(); }, /** * This function get the distribution of levels based on hubsize * * @returns {Object} * @private */ _getDistribution : function() { var distribution = {}; var nodeId, node; // we fix Y because the hierarchy is vertical, we fix X so we do not give a node an x position for a second time. // the fix of X is removed after the x value has been set. for (nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { node = this.nodes[nodeId]; node.xFixed = true; node.yFixed = true; if (this.constants.hierarchicalLayout.direction == "UD" || this.constants.hierarchicalLayout.direction == "DU") { node.y = this.constants.hierarchicalLayout.levelSeparation*node.level; } else { node.x = this.constants.hierarchicalLayout.levelSeparation*node.level; } if (!distribution.hasOwnProperty(node.level)) { distribution[node.level] = {amount: 0, nodes: {}, minPos:0, nodeSpacing:0}; } distribution[node.level].amount += 1; distribution[node.level].nodes[node.id] = node; } } // determine the largest amount of nodes of all levels var maxCount = 0; for (var level in distribution) { if (distribution.hasOwnProperty(level)) { if (maxCount < distribution[level].amount) { maxCount = distribution[level].amount; } } } // set the initial position and spacing of each nodes accordingly for (var level in distribution) { if (distribution.hasOwnProperty(level)) { distribution[level].nodeSpacing = (maxCount + 1) * this.constants.hierarchicalLayout.nodeSpacing; distribution[level].nodeSpacing /= (distribution[level].amount + 1); distribution[level].minPos = distribution[level].nodeSpacing - (0.5 * (distribution[level].amount + 1) * distribution[level].nodeSpacing); } } return distribution; }, /** * this function allocates nodes in levels based on the recursive branching from the largest hubs. * * @param hubsize * @private */ _determineLevels : function(hubsize) { var nodeId, node; // determine hubs for (nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { node = this.nodes[nodeId]; if (node.edges.length == hubsize) { node.level = 0; } } } // branch from hubs for (nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { node = this.nodes[nodeId]; if (node.level == 0) { this._setLevel(1,node.edges,node.id); } } } }, /** * Since hierarchical layout does not support: * - smooth curves (based on the physics), * - clustering (based on dynamic node counts) * * We disable both features so there will be no problems. * * @private */ _changeConstants : function() { this.constants.clustering.enabled = false; this.constants.physics.barnesHut.enabled = false; this.constants.physics.hierarchicalRepulsion.enabled = true; this._loadSelectedForceSolver(); this.constants.smoothCurves = false; this._configureSmoothCurves(); }, /** * This is a recursively called function to enumerate the branches from the largest hubs and place the nodes * on a X position that ensures there will be no overlap. * * @param edges * @param parentId * @param distribution * @param parentLevel * @private */ _placeBranchNodes : function(edges, parentId, distribution, parentLevel) { for (var i = 0; i < edges.length; i++) { var childNode = null; if (edges[i].toId == parentId) { childNode = edges[i].from; } else { childNode = edges[i].to; } // if a node is conneceted to another node on the same level (or higher (means lower level))!, this is not handled here. var nodeMoved = false; if (this.constants.hierarchicalLayout.direction == "UD" || this.constants.hierarchicalLayout.direction == "DU") { if (childNode.xFixed && childNode.level > parentLevel) { childNode.xFixed = false; childNode.x = distribution[childNode.level].minPos; nodeMoved = true; } } else { if (childNode.yFixed && childNode.level > parentLevel) { childNode.yFixed = false; childNode.y = distribution[childNode.level].minPos; nodeMoved = true; } } if (nodeMoved == true) { distribution[childNode.level].minPos += distribution[childNode.level].nodeSpacing; if (childNode.edges.length > 1) { this._placeBranchNodes(childNode.edges,childNode.id,distribution,childNode.level); } } } }, /** * this function is called recursively to enumerate the barnches of the largest hubs and give each node a level. * * @param level * @param edges * @param parentId * @private */ _setLevel : function(level, edges, parentId) { for (var i = 0; i < edges.length; i++) { var childNode = null; if (edges[i].toId == parentId) { childNode = edges[i].from; } else { childNode = edges[i].to; } if (childNode.level == -1 || childNode.level > level) { childNode.level = level; if (edges.length > 1) { this._setLevel(level+1, childNode.edges, childNode.id); } } } }, /** * Unfix nodes * * @private */ _restoreNodes : function() { for (nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { this.nodes[nodeId].xFixed = false; this.nodes[nodeId].yFixed = false; } } } }; /** * Created by Alex on 2/4/14. */ var manipulationMixin = { /** * clears the toolbar div element of children * * @private */ _clearManipulatorBar : function() { while (this.manipulationDiv.hasChildNodes()) { this.manipulationDiv.removeChild(this.manipulationDiv.firstChild); } }, /** * Manipulation UI temporarily overloads certain functions to extend or replace them. To be able to restore * these functions to their original functionality, we saved them in this.cachedFunctions. * This function restores these functions to their original function. * * @private */ _restoreOverloadedFunctions : function() { for (var functionName in this.cachedFunctions) { if (this.cachedFunctions.hasOwnProperty(functionName)) { this[functionName] = this.cachedFunctions[functionName]; } } }, /** * Enable or disable edit-mode. * * @private */ _toggleEditMode : function() { this.editMode = !this.editMode; var toolbar = document.getElementById("graph-manipulationDiv"); var closeDiv = document.getElementById("graph-manipulation-closeDiv"); var editModeDiv = document.getElementById("graph-manipulation-editMode"); if (this.editMode == true) { toolbar.style.display="block"; closeDiv.style.display="block"; editModeDiv.style.display="none"; closeDiv.onclick = this._toggleEditMode.bind(this); } else { toolbar.style.display="none"; closeDiv.style.display="none"; editModeDiv.style.display="block"; closeDiv.onclick = null; } this._createManipulatorBar() }, /** * main function, creates the main toolbar. Removes functions bound to the select event. Binds all the buttons of the toolbar. * * @private */ _createManipulatorBar : function() { // remove bound functions this.off('select', this.boundFunction); // restore overloaded functions this._restoreOverloadedFunctions(); // resume calculation this.freezeSimulation = false; // reset global variables this.blockConnectingEdgeSelection = false; this.forceAppendSelection = false if (this.editMode == true) { while (this.manipulationDiv.hasChildNodes()) { this.manipulationDiv.removeChild(this.manipulationDiv.firstChild); } // add the icons to the manipulator div this.manipulationDiv.innerHTML = "" + "" + "Add Node" + "
" + "" + "Add Link"; if (this._getSelectedNodeCount() == 1 && this.triggerFunctions.edit) { this.manipulationDiv.innerHTML += "" + "
" + "" + "Edit Node"; } if (this._selectionIsEmpty() == false) { this.manipulationDiv.innerHTML += "" + "
" + "" + "Delete selected"; } // bind the icons var addNodeButton = document.getElementById("graph-manipulate-addNode"); addNodeButton.onclick = this._createAddNodeToolbar.bind(this); var addEdgeButton = document.getElementById("graph-manipulate-connectNode"); addEdgeButton.onclick = this._createAddEdgeToolbar.bind(this); if (this._getSelectedNodeCount() == 1 && this.triggerFunctions.edit) { var editButton = document.getElementById("graph-manipulate-editNode"); editButton.onclick = this._editNode.bind(this); } if (this._selectionIsEmpty() == false) { var deleteButton = document.getElementById("graph-manipulate-delete"); deleteButton.onclick = this._deleteSelected.bind(this); } var closeDiv = document.getElementById("graph-manipulation-closeDiv"); closeDiv.onclick = this._toggleEditMode.bind(this); this.boundFunction = this._createManipulatorBar.bind(this); this.on('select', this.boundFunction); } else { this.editModeDiv.innerHTML = "" + "" + "Edit" var editModeButton = document.getElementById("graph-manipulate-editModeButton"); editModeButton.onclick = this._toggleEditMode.bind(this); } }, /** * Create the toolbar for adding Nodes * * @private */ _createAddNodeToolbar : function() { // clear the toolbar this._clearManipulatorBar(); this.off('select', this.boundFunction); // create the toolbar contents this.manipulationDiv.innerHTML = "" + "" + "Back" + "
" + "" + "Click in an empty space to place a new node"; // bind the icon var backButton = document.getElementById("graph-manipulate-back"); backButton.onclick = this._createManipulatorBar.bind(this); // we use the boundFunction so we can reference it when we unbind it from the "select" event. this.boundFunction = this._addNode.bind(this); this.on('select', this.boundFunction); }, /** * create the toolbar to connect nodes * * @private */ _createAddEdgeToolbar : function() { // clear the toolbar this._clearManipulatorBar(); this._unselectAll(true); this.freezeSimulation = true; this.off('select', this.boundFunction); this._unselectAll(); this.forceAppendSelection = false; this.blockConnectingEdgeSelection = true; this.manipulationDiv.innerHTML = "" + "" + "Back" + "
" + "" + "Click on a node and drag the edge to another node to connect them."; // bind the icon var backButton = document.getElementById("graph-manipulate-back"); backButton.onclick = this._createManipulatorBar.bind(this); // we use the boundFunction so we can reference it when we unbind it from the "select" event. this.boundFunction = this._handleConnect.bind(this); this.on('select', this.boundFunction); // temporarily overload functions this.cachedFunctions["_handleTouch"] = this._handleTouch; this.cachedFunctions["_handleOnRelease"] = this._handleOnRelease; this._handleTouch = this._handleConnect; this._handleOnRelease = this._finishConnect; // redraw to show the unselect this._redraw(); }, /** * the function bound to the selection event. It checks if you want to connect a cluster and changes the description * to walk the user through the process. * * @private */ _handleConnect : function(pointer) { if (this._getSelectedNodeCount() == 0) { var node = this._getNodeAt(pointer); if (node != null) { if (node.clusterSize > 1) { alert("Cannot create edges to a cluster.") } else { this._selectObject(node,false); // create a node the temporary line can look at this.sectors['support']['nodes']['targetNode'] = new Node({id:'targetNode'},{},{},this.constants); this.sectors['support']['nodes']['targetNode'].x = node.x; this.sectors['support']['nodes']['targetNode'].y = node.y; this.sectors['support']['nodes']['targetViaNode'] = new Node({id:'targetViaNode'},{},{},this.constants); this.sectors['support']['nodes']['targetViaNode'].x = node.x; this.sectors['support']['nodes']['targetViaNode'].y = node.y; this.sectors['support']['nodes']['targetViaNode'].parentEdgeId = "connectionEdge"; // create a temporary edge this.edges['connectionEdge'] = new Edge({id:"connectionEdge",from:node.id,to:this.sectors['support']['nodes']['targetNode'].id}, this, this.constants); this.edges['connectionEdge'].from = node; this.edges['connectionEdge'].connected = true; this.edges['connectionEdge'].smooth = true; this.edges['connectionEdge'].selected = true; this.edges['connectionEdge'].to = this.sectors['support']['nodes']['targetNode']; this.edges['connectionEdge'].via = this.sectors['support']['nodes']['targetViaNode']; this.cachedFunctions["_handleOnDrag"] = this._handleOnDrag; this._handleOnDrag = function(event) { var pointer = this._getPointer(event.gesture.center); this.sectors['support']['nodes']['targetNode'].x = this._canvasToX(pointer.x); this.sectors['support']['nodes']['targetNode'].y = this._canvasToY(pointer.y); this.sectors['support']['nodes']['targetViaNode'].x = 0.5 * (this._canvasToX(pointer.x) + this.edges['connectionEdge'].from.x); this.sectors['support']['nodes']['targetViaNode'].y = this._canvasToY(pointer.y); }; this.moving = true; this.start(); } } } }, _finishConnect : function(pointer) { if (this._getSelectedNodeCount() == 1) { // restore the drag function this._handleOnDrag = this.cachedFunctions["_handleOnDrag"]; delete this.cachedFunctions["_handleOnDrag"]; // remember the edge id var connectFromId = this.edges['connectionEdge'].fromId; // remove the temporary nodes and edge delete this.edges['connectionEdge'] delete this.sectors['support']['nodes']['targetNode']; delete this.sectors['support']['nodes']['targetViaNode']; var node = this._getNodeAt(pointer); if (node != null) { if (node.clusterSize > 1) { alert("Cannot create edges to a cluster.") } else { this._createEdge(connectFromId,node.id); this._createManipulatorBar(); } } this._unselectAll(); } }, /** * Adds a node on the specified location * * @param {Object} pointer */ _addNode : function() { if (this._selectionIsEmpty() && this.editMode == true) { var positionObject = this._pointerToPositionObject(this.pointerPosition); var defaultData = {id:util.randomUUID(),x:positionObject.left,y:positionObject.top,label:"new",allowedToMove:true}; if (this.triggerFunctions.add) { if (this.triggerFunctions.add.length == 2) { var me = this; this.triggerFunctions.add(defaultData, function(finalizedData) { me.createNodeOnClick = true; me.nodesData.add(finalizedData); me.createNodeOnClick = false; me._createManipulatorBar(); me.moving = true; me.start(); }); } else { alert("The function for add does not support two arguments (data,callback)."); this._createManipulatorBar(); this.moving = true; this.start(); } } else { this.createNodeOnClick = true; this.nodesData.add(defaultData); this.createNodeOnClick = false; this._createManipulatorBar(); this.moving = true; this.start(); } } }, /** * connect two nodes with a new edge. * * @private */ _createEdge : function(sourceNodeId,targetNodeId) { if (this.editMode == true) { var defaultData = {from:sourceNodeId, to:targetNodeId}; if (this.triggerFunctions.connect) { if (this.triggerFunctions.connect.length == 2) { var me = this; this.triggerFunctions.connect(defaultData, function(finalizedData) { me.edgesData.add(finalizedData) me.moving = true; me.start(); }); } else { alert("The function for connect does not support two arguments (data,callback)."); this.moving = true; this.start(); } } else { this.edgesData.add(defaultData) this.moving = true; this.start(); } } }, /** * Create the toolbar to edit the selected node. The label and the color can be changed. Other colors are derived from the chosen color. * * @private */ _editNode : function() { if (this.triggerFunctions.edit && this.editMode == true) { var node = this._getSelectedNode(); var data = {id:node.id, label: node.label, group: node.group, shape: node.shape, color: { background:node.color.background, border:node.color.border, highlight: { background:node.color.highlight.background, border:node.color.highlight.border } }}; if (this.triggerFunctions.edit.length == 2) { var me = this; this.triggerFunctions.edit(data, function (finalizedData) { me.nodesData.update(finalizedData); me._createManipulatorBar(); me.moving = true; me.start(); }); } else { alert("The function for edit does not support two arguments (data, callback).") } } else { alert("No edit function has been bound to this button.") } }, /** * delete everything in the selection * * @private */ _deleteSelected : function() { if (!this._selectionIsEmpty() && this.editMode == true) { if (!this._clusterInSelection()) { var selectedNodes = this.getSelectedNodes(); var selectedEdges = this.getSelectedEdges(); if (this.triggerFunctions.delete) { var me = this; var data = {nodes: selectedNodes, edges: selectedEdges}; if (this.triggerFunctions.delete.length = 2) { this.triggerFunctions.delete(data, function (finalizedData) { me.edgesData.remove(finalizedData.edges); me.nodesData.remove(finalizedData.nodes); this._unselectAll(); me.moving = true; me.start(); }); } else { alert("The function for edit does not support two arguments (data, callback).") } } else { this.edgesData.remove(selectedEdges); this.nodesData.remove(selectedNodes); this._unselectAll(); this.moving = true; this.start(); } } else { alert("Clusters cannot be deleted."); } } } }; /** * Creation of the SectorMixin var. * * This contains all the functions the Graph object can use to employ the sector system. * The sector system is always used by Graph, though the benefits only apply to the use of clustering. * If clustering is not used, there is no overhead except for a duplicate object with references to nodes and edges. * * Alex de Mulder * 21-01-2013 */ var SectorMixin = { /** * This function is only called by the setData function of the Graph object. * This loads the global references into the active sector. This initializes the sector. * * @private */ _putDataInSector : function() { this.sectors["active"][this._sector()].nodes = this.nodes; this.sectors["active"][this._sector()].edges = this.edges; this.sectors["active"][this._sector()].nodeIndices = this.nodeIndices; }, /** * /** * This function sets the global references to nodes, edges and nodeIndices back to * those of the supplied (active) sector. If a type is defined, do the specific type * * @param {String} sectorId * @param {String} [sectorType] | "active" or "frozen" * @private */ _switchToSector : function(sectorId, sectorType) { if (sectorType === undefined || sectorType == "active") { this._switchToActiveSector(sectorId); } else { this._switchToFrozenSector(sectorId); } }, /** * This function sets the global references to nodes, edges and nodeIndices back to * those of the supplied active sector. * * @param sectorId * @private */ _switchToActiveSector : function(sectorId) { this.nodeIndices = this.sectors["active"][sectorId]["nodeIndices"]; this.nodes = this.sectors["active"][sectorId]["nodes"]; this.edges = this.sectors["active"][sectorId]["edges"]; }, /** * This function sets the global references to nodes, edges and nodeIndices back to * those of the supplied active sector. * * @param sectorId * @private */ _switchToSupportSector : function() { this.nodeIndices = this.sectors["support"]["nodeIndices"]; this.nodes = this.sectors["support"]["nodes"]; this.edges = this.sectors["support"]["edges"]; }, /** * This function sets the global references to nodes, edges and nodeIndices back to * those of the supplied frozen sector. * * @param sectorId * @private */ _switchToFrozenSector : function(sectorId) { this.nodeIndices = this.sectors["frozen"][sectorId]["nodeIndices"]; this.nodes = this.sectors["frozen"][sectorId]["nodes"]; this.edges = this.sectors["frozen"][sectorId]["edges"]; }, /** * This function sets the global references to nodes, edges and nodeIndices back to * those of the currently active sector. * * @private */ _loadLatestSector : function() { this._switchToSector(this._sector()); }, /** * This function returns the currently active sector Id * * @returns {String} * @private */ _sector : function() { return this.activeSector[this.activeSector.length-1]; }, /** * This function returns the previously active sector Id * * @returns {String} * @private */ _previousSector : function() { if (this.activeSector.length > 1) { return this.activeSector[this.activeSector.length-2]; } else { throw new TypeError('there are not enough sectors in the this.activeSector array.'); } }, /** * We add the active sector at the end of the this.activeSector array * This ensures it is the currently active sector returned by _sector() and it reaches the top * of the activeSector stack. When we reverse our steps we move from the end to the beginning of this stack. * * @param newId * @private */ _setActiveSector : function(newId) { this.activeSector.push(newId); }, /** * We remove the currently active sector id from the active sector stack. This happens when * we reactivate the previously active sector * * @private */ _forgetLastSector : function() { this.activeSector.pop(); }, /** * This function creates a new active sector with the supplied newId. This newId * is the expanding node id. * * @param {String} newId | Id of the new active sector * @private */ _createNewSector : function(newId) { // create the new sector this.sectors["active"][newId] = {"nodes":{}, "edges":{}, "nodeIndices":[], "formationScale": this.scale, "drawingNode": undefined}; // create the new sector render node. This gives visual feedback that you are in a new sector. this.sectors["active"][newId]['drawingNode'] = new Node( {id:newId, color: { background: "#eaefef", border: "495c5e" } },{},{},this.constants); this.sectors["active"][newId]['drawingNode'].clusterSize = 2; }, /** * This function removes the currently active sector. This is called when we create a new * active sector. * * @param {String} sectorId | Id of the active sector that will be removed * @private */ _deleteActiveSector : function(sectorId) { delete this.sectors["active"][sectorId]; }, /** * This function removes the currently active sector. This is called when we reactivate * the previously active sector. * * @param {String} sectorId | Id of the active sector that will be removed * @private */ _deleteFrozenSector : function(sectorId) { delete this.sectors["frozen"][sectorId]; }, /** * Freezing an active sector means moving it from the "active" object to the "frozen" object. * We copy the references, then delete the active entree. * * @param sectorId * @private */ _freezeSector : function(sectorId) { // we move the set references from the active to the frozen stack. this.sectors["frozen"][sectorId] = this.sectors["active"][sectorId]; // we have moved the sector data into the frozen set, we now remove it from the active set this._deleteActiveSector(sectorId); }, /** * This is the reverse operation of _freezeSector. Activating means moving the sector from the "frozen" * object to the "active" object. * * @param sectorId * @private */ _activateSector : function(sectorId) { // we move the set references from the frozen to the active stack. this.sectors["active"][sectorId] = this.sectors["frozen"][sectorId]; // we have moved the sector data into the active set, we now remove it from the frozen stack this._deleteFrozenSector(sectorId); }, /** * This function merges the data from the currently active sector with a frozen sector. This is used * in the process of reverting back to the previously active sector. * The data that is placed in the frozen (the previously active) sector is the node that has been removed from it * upon the creation of a new active sector. * * @param sectorId * @private */ _mergeThisWithFrozen : function(sectorId) { // copy all nodes for (var nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { this.sectors["frozen"][sectorId]["nodes"][nodeId] = this.nodes[nodeId]; } } // copy all edges (if not fully clustered, else there are no edges) for (var edgeId in this.edges) { if (this.edges.hasOwnProperty(edgeId)) { this.sectors["frozen"][sectorId]["edges"][edgeId] = this.edges[edgeId]; } } // merge the nodeIndices for (var i = 0; i < this.nodeIndices.length; i++) { this.sectors["frozen"][sectorId]["nodeIndices"].push(this.nodeIndices[i]); } }, /** * This clusters the sector to one cluster. It was a single cluster before this process started so * we revert to that state. The clusterToFit function with a maximum size of 1 node does this. * * @private */ _collapseThisToSingleCluster : function() { this.clusterToFit(1,false); }, /** * We create a new active sector from the node that we want to open. * * @param node * @private */ _addSector : function(node) { // this is the currently active sector var sector = this._sector(); // // this should allow me to select nodes from a frozen set. // if (this.sectors['active'][sector]["nodes"].hasOwnProperty(node.id)) { // console.log("the node is part of the active sector"); // } // else { // console.log("I dont know what the fuck happened!!"); // } // when we switch to a new sector, we remove the node that will be expanded from the current nodes list. delete this.nodes[node.id]; var unqiueIdentifier = util.randomUUID(); // we fully freeze the currently active sector this._freezeSector(sector); // we create a new active sector. This sector has the Id of the node to ensure uniqueness this._createNewSector(unqiueIdentifier); // we add the active sector to the sectors array to be able to revert these steps later on this._setActiveSector(unqiueIdentifier); // we redirect the global references to the new sector's references. this._sector() now returns unqiueIdentifier this._switchToSector(this._sector()); // finally we add the node we removed from our previous active sector to the new active sector this.nodes[node.id] = node; }, /** * We close the sector that is currently open and revert back to the one before. * If the active sector is the "default" sector, nothing happens. * * @private */ _collapseSector : function() { // the currently active sector var sector = this._sector(); // we cannot collapse the default sector if (sector != "default") { if ((this.nodeIndices.length == 1) || (this.sectors["active"][sector]["drawingNode"].width*this.scale < this.constants.clustering.screenSizeThreshold * this.frame.canvas.clientWidth) || (this.sectors["active"][sector]["drawingNode"].height*this.scale < this.constants.clustering.screenSizeThreshold * this.frame.canvas.clientHeight)) { var previousSector = this._previousSector(); // we collapse the sector back to a single cluster this._collapseThisToSingleCluster(); // we move the remaining nodes, edges and nodeIndices to the previous sector. // This previous sector is the one we will reactivate this._mergeThisWithFrozen(previousSector); // the previously active (frozen) sector now has all the data from the currently active sector. // we can now delete the active sector. this._deleteActiveSector(sector); // we activate the previously active (and currently frozen) sector. this._activateSector(previousSector); // we load the references from the newly active sector into the global references this._switchToSector(previousSector); // we forget the previously active sector because we reverted to the one before this._forgetLastSector(); // finally, we update the node index list. this._updateNodeIndexList(); // we refresh the list with calulation nodes and calculation node indices. this._updateCalculationNodes(); } } }, /** * This runs a function in all active sectors. This is used in _redraw() and the _initializeForceCalculation(). * * @param {String} runFunction | This is the NAME of a function we want to call in all active sectors * | we dont pass the function itself because then the "this" is the window object * | instead of the Graph object * @param {*} [argument] | Optional: arguments to pass to the runFunction * @private */ _doInAllActiveSectors : function(runFunction,argument) { if (argument === undefined) { for (var sector in this.sectors["active"]) { if (this.sectors["active"].hasOwnProperty(sector)) { // switch the global references to those of this sector this._switchToActiveSector(sector); this[runFunction](); } } } else { for (var sector in this.sectors["active"]) { if (this.sectors["active"].hasOwnProperty(sector)) { // switch the global references to those of this sector this._switchToActiveSector(sector); var args = Array.prototype.splice.call(arguments, 1); if (args.length > 1) { this[runFunction](args[0],args[1]); } else { this[runFunction](argument); } } } } // we revert the global references back to our active sector this._loadLatestSector(); }, /** * This runs a function in all active sectors. This is used in _redraw() and the _initializeForceCalculation(). * * @param {String} runFunction | This is the NAME of a function we want to call in all active sectors * | we dont pass the function itself because then the "this" is the window object * | instead of the Graph object * @param {*} [argument] | Optional: arguments to pass to the runFunction * @private */ _doInSupportSector : function(runFunction,argument) { if (argument === undefined) { this._switchToSupportSector(); this[runFunction](); } else { this._switchToSupportSector(); var args = Array.prototype.splice.call(arguments, 1); if (args.length > 1) { this[runFunction](args[0],args[1]); } else { this[runFunction](argument); } } // we revert the global references back to our active sector this._loadLatestSector(); }, /** * This runs a function in all frozen sectors. This is used in the _redraw(). * * @param {String} runFunction | This is the NAME of a function we want to call in all active sectors * | we don't pass the function itself because then the "this" is the window object * | instead of the Graph object * @param {*} [argument] | Optional: arguments to pass to the runFunction * @private */ _doInAllFrozenSectors : function(runFunction,argument) { if (argument === undefined) { for (var sector in this.sectors["frozen"]) { if (this.sectors["frozen"].hasOwnProperty(sector)) { // switch the global references to those of this sector this._switchToFrozenSector(sector); this[runFunction](); } } } else { for (var sector in this.sectors["frozen"]) { if (this.sectors["frozen"].hasOwnProperty(sector)) { // switch the global references to those of this sector this._switchToFrozenSector(sector); var args = Array.prototype.splice.call(arguments, 1); if (args.length > 1) { this[runFunction](args[0],args[1]); } else { this[runFunction](argument); } } } } this._loadLatestSector(); }, /** * This runs a function in all sectors. This is used in the _redraw(). * * @param {String} runFunction | This is the NAME of a function we want to call in all active sectors * | we don't pass the function itself because then the "this" is the window object * | instead of the Graph object * @param {*} [argument] | Optional: arguments to pass to the runFunction * @private */ _doInAllSectors : function(runFunction,argument) { var args = Array.prototype.splice.call(arguments, 1); if (argument === undefined) { this._doInAllActiveSectors(runFunction); this._doInAllFrozenSectors(runFunction); } else { if (args.length > 1) { this._doInAllActiveSectors(runFunction,args[0],args[1]); this._doInAllFrozenSectors(runFunction,args[0],args[1]); } else { this._doInAllActiveSectors(runFunction,argument); this._doInAllFrozenSectors(runFunction,argument); } } }, /** * This clears the nodeIndices list. We cannot use this.nodeIndices = [] because we would break the link with the * active sector. Thus we clear the nodeIndices in the active sector, then reconnect the this.nodeIndices to it. * * @private */ _clearNodeIndexList : function() { var sector = this._sector(); this.sectors["active"][sector]["nodeIndices"] = []; this.nodeIndices = this.sectors["active"][sector]["nodeIndices"]; }, /** * Draw the encompassing sector node * * @param ctx * @param sectorType * @private */ _drawSectorNodes : function(ctx,sectorType) { var minY = 1e9, maxY = -1e9, minX = 1e9, maxX = -1e9, node; for (var sector in this.sectors[sectorType]) { if (this.sectors[sectorType].hasOwnProperty(sector)) { if (this.sectors[sectorType][sector]["drawingNode"] !== undefined) { this._switchToSector(sector,sectorType); minY = 1e9; maxY = -1e9; minX = 1e9; maxX = -1e9; for (var nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { node = this.nodes[nodeId]; node.resize(ctx); if (minX > node.x - 0.5 * node.width) {minX = node.x - 0.5 * node.width;} if (maxX < node.x + 0.5 * node.width) {maxX = node.x + 0.5 * node.width;} if (minY > node.y - 0.5 * node.height) {minY = node.y - 0.5 * node.height;} if (maxY < node.y + 0.5 * node.height) {maxY = node.y + 0.5 * node.height;} } } node = this.sectors[sectorType][sector]["drawingNode"]; node.x = 0.5 * (maxX + minX); node.y = 0.5 * (maxY + minY); node.width = 2 * (node.x - minX); node.height = 2 * (node.y - minY); node.radius = Math.sqrt(Math.pow(0.5*node.width,2) + Math.pow(0.5*node.height,2)); node.setScale(this.scale); node._drawCircle(ctx); } } } }, _drawAllSectorNodes : function(ctx) { this._drawSectorNodes(ctx,"frozen"); this._drawSectorNodes(ctx,"active"); this._loadLatestSector(); } }; /** * Creation of the ClusterMixin var. * * This contains all the functions the Graph object can use to employ clustering * * Alex de Mulder * 21-01-2013 */ var ClusterMixin = { /** * This is only called in the constructor of the graph object * */ startWithClustering : function() { // cluster if the data set is big this.clusterToFit(this.constants.clustering.initialMaxNodes, true); // updates the lables after clustering this.updateLabels(); // this is called here because if clusterin is disabled, the start and stabilize are called in // the setData function. if (this.stabilize) { this._doStabilize(); } this.start(); }, /** * This function clusters until the initialMaxNodes has been reached * * @param {Number} maxNumberOfNodes * @param {Boolean} reposition */ clusterToFit : function(maxNumberOfNodes, reposition) { var numberOfNodes = this.nodeIndices.length; var maxLevels = 50; var level = 0; // we first cluster the hubs, then we pull in the outliers, repeat while (numberOfNodes > maxNumberOfNodes && level < maxLevels) { if (level % 3 == 0) { this.forceAggregateHubs(true); this.normalizeClusterLevels(); } else { this.increaseClusterLevel(); // this also includes a cluster normalization } numberOfNodes = this.nodeIndices.length; level += 1; } // after the clustering we reposition the nodes to reduce the initial chaos if (level > 0 && reposition == true) { this.repositionNodes(); } this._updateCalculationNodes(); }, /** * This function can be called to open up a specific cluster. It is only called by * It will unpack the cluster back one level. * * @param node | Node object: cluster to open. */ openCluster : function(node) { var isMovingBeforeClustering = this.moving; if (node.clusterSize > this.constants.clustering.sectorThreshold && this._nodeInActiveArea(node) && !(this._sector() == "default" && this.nodeIndices.length == 1)) { // this loads a new sector, loads the nodes and edges and nodeIndices of it. this._addSector(node); var level = 0; // we decluster until we reach a decent number of nodes while ((this.nodeIndices.length < this.constants.clustering.initialMaxNodes) && (level < 10)) { this.decreaseClusterLevel(); level += 1; } } else { this._expandClusterNode(node,false,true); // update the index list, dynamic edges and labels this._updateNodeIndexList(); this._updateDynamicEdges(); this._updateCalculationNodes(); 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 calls the updateClustes with default arguments */ updateClustersDefault : function() { if (this.constants.clustering.enabled == true) { this.updateClusters(0,false,false); } }, /** * 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. */ 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. */ decreaseClusterLevel : function() { this.updateClusters(1,false,true); }, /** * This is the main clustering function. It clusters and declusters on zoom or forced * 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 {Number} zoomDirection | -1 / 0 / +1 for zoomOut / determineByZoom / zoomIn * @param {Boolean} recursive | enabled or disable recursive calling of the opening of clusters * @param {Boolean} force | enabled or disable forcing * */ updateClusters : function(zoomDirection,recursive,force,doNotStart) { var isMovingBeforeClustering = this.moving; var amountOfNodes = this.nodeIndices.length; // on zoom out collapse the sector if the scale is at the level the sector was made if (this.previousScale > this.scale && zoomDirection == 0) { this._collapseSector(); } // check if we zoom in or out if (this.previousScale > this.scale || zoomDirection == -1) { // zoom out // forming clusters when forced pulls outliers in. When not forced, the edge length of the // outer nodes determines if it is being clustered this._formClusters(force); } else if (this.previousScale < this.scale || zoomDirection == 1) { // zoom in if (force == true) { // _openClusters checks for each node if the formationScale of the cluster is smaller than // the current scale and if so, declusters. When forced, all clusters are reduced by one step this._openClusters(recursive,force); } else { // if a cluster takes up a set percentage of the active window this._openClustersBySize(); } } this._updateNodeIndexList(); // if a cluster was NOT formed and the user zoomed out, we try clustering by hubs if (this.nodeIndices.length == amountOfNodes && (this.previousScale > this.scale || zoomDirection == -1)) { this._aggregateHubs(force); this._updateNodeIndexList(); } // we now reduce chains. if (this.previousScale > this.scale || zoomDirection == -1) { // zoom out this.handleChains(); this._updateNodeIndexList(); } this.previousScale = this.scale; // rest of the update the index list, dynamic edges and labels 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 clusters have been made, we normalize the cluster level this.normalizeClusterLevels(); } if (doNotStart == false || doNotStart === undefined) { // if the simulation was settled, we restart the simulation if a cluster has been formed or expanded if (this.moving != isMovingBeforeClustering) { this.start(); } } this._updateCalculationNodes(); }, /** * This function handles the chains. It is called on every updateClusters(). */ handleChains : function() { // after clustering we check how many chains there are var chainPercentage = this._getChainFraction(); if (chainPercentage > this.constants.clustering.chainThreshold) { this._reduceAmountOfChains(1 - this.constants.clustering.chainThreshold / chainPercentage) } }, /** * this functions starts clustering by hubs * The minimum hub threshold is set globally * * @private */ _aggregateHubs : function(force) { this._getHubSize(); this._formClustersByHub(force,false); }, /** * This function is fired by keypress. It forces hubs to form. * */ forceAggregateHubs : function(doNotStart) { var isMovingBeforeClustering = this.moving; var amountOfNodes = this.nodeIndices.length; this._aggregateHubs(true); // update the index list, dynamic edges and labels this._updateNodeIndexList(); this._updateDynamicEdges(); this.updateLabels(); // if a cluster was formed, we increase the clusterSession if (this.nodeIndices.length != amountOfNodes) { this.clusterSession += 1; } if (doNotStart == false || doNotStart === undefined) { // if the simulation was settled, we restart the simulation if a cluster has been formed or expanded if (this.moving != isMovingBeforeClustering) { this.start(); } } }, /** * If a cluster takes up more than a set percentage of the screen, open the cluster * * @private */ _openClustersBySize : function() { for (var nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { var node = this.nodes[nodeId]; if (node.inView() == true) { if ((node.width*this.scale > this.constants.clustering.screenSizeThreshold * this.frame.canvas.clientWidth) || (node.height*this.scale > this.constants.clustering.screenSizeThreshold * this.frame.canvas.clientHeight)) { this.openCluster(node); } } } } }, /** * 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 */ _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._updateCalculationNodes(); } }, /** * 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 | enabled or disable recursive calling * @param {Boolean} force | enabled or disable forcing * @param {Boolean} [openAll] | This will recursively force all nodes in the parent to be released * @private */ _expandClusterNode : function(parentNode, recursive, force, openAll) { // 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 < this.constants.clustering.sectorThreshold) { openAll = true; } recursive = openAll ? true : recursive; // if the last child has been added on a smaller scale than current scale decluster 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); } } else { if (this._nodeInActiveArea(parentNode)) { this._expelChildFromParent(parentNode,containedNodeId,recursive,force,openAll); } } } } } } }, /** * 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 */ _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) { // unselect all selected items this._unselectAll(); // 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 -= childNode.mass; parentNode.clusterSize -= childNode.clusterSize; parentNode.fontSize = Math.min(this.constants.clustering.maxFontSize, this.constants.nodes.fontSize + this.constants.clustering.fontSizeMultiplier*parentNode.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 + parentNode.growthIndicator * (0.5 - Math.random()); childNode.y = parentNode.y + parentNode.growthIndicator * (0.5 - Math.random()); // remove node from the list delete parentNode.containedNodes[containedNodeId]; // check if there are other childs with this clusterSession in the parent. var othersPresent = false; for (var childNodeId in parentNode.containedNodes) { if (parentNode.containedNodes.hasOwnProperty(childNodeId)) { if (parentNode.containedNodes[childNodeId].clusterSession == childNode.clusterSession) { othersPresent = true; break; } } } // if there are no others, remove the cluster session from the list if (othersPresent == false) { parentNode.clusterSessions.pop(); } this._repositionBezierNodes(childNode); // this._repositionBezierNodes(parentNode); // remove the clusterSession from the child node childNode.clusterSession = 0; // recalculate the size of the node on the next time the node is rendered parentNode.clearSizeCache(); // restart the simulation to reorganise all nodes this.moving = true; } // check if a further expansion step is possible if recursivity is enabled if (recursive == true) { this._expandClusterNode(childNode,recursive,force,openAll); } }, /** * position the bezier nodes at the center of the edges * * @param node * @private */ _repositionBezierNodes : function(node) { for (var i = 0; i < node.dynamicEdges.length; i++) { node.dynamicEdges[i].positionBezierNode(); } }, /** * 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 */ _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 */ _formClustersByZoom : function() { var dx,dy,length, minLength = this.constants.clustering.clusterEdgeThreshold/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) { if (edge.toId != edge.fromId) { 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 */ _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 (childNode.id != parentNode.id) { if (parentNode.mass > childNode.mass) { this._addToCluster(parentNode,childNode,true); } else { this._addToCluster(childNode,parentNode,true); } } } } } }, /** * To keep the nodes of roughly equal size we normalize the cluster levels. * This function clusters a node to its smallest connected neighbour. * * @param node * @private */ _clusterToSmallestNeighbour : function(node) { var smallestNeighbour = -1; var smallestNeighbourNode = null; for (var i = 0; i < node.dynamicEdges.length; i++) { if (node.dynamicEdges[i] !== undefined) { var neighbour = null; if (node.dynamicEdges[i].fromId != node.id) { neighbour = node.dynamicEdges[i].from; } else if (node.dynamicEdges[i].toId != node.id) { neighbour = node.dynamicEdges[i].to; } if (neighbour != null && smallestNeighbour > neighbour.clusterSessions.length) { smallestNeighbour = neighbour.clusterSessions.length; smallestNeighbourNode = neighbour; } } } if (neighbour != null && this.nodes[neighbour.id] !== undefined) { this._addToCluster(neighbour, node, true); } }, /** * This function forms clusters from hubs, it loops over all nodes * * @param {Boolean} force | Disregard zoom level * @param {Boolean} onlyEqual | This only clusters a hub with a specific number of edges * @private */ _formClustersByHub : function(force, onlyEqual) { // 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)) { this._formClusterFromHub(this.nodes[nodeId],force,onlyEqual); } } }, /** * This function forms a cluster from a specific preselected hub node * * @param {Node} hubNode | the node we will cluster as a hub * @param {Boolean} force | Disregard zoom level * @param {Boolean} onlyEqual | This only clusters a hub with a specific number of edges * @param {Number} [absorptionSizeOffset] | * @private */ _formClusterFromHub : function(hubNode, force, onlyEqual, absorptionSizeOffset) { if (absorptionSizeOffset === undefined) { absorptionSizeOffset = 0; } // we decide if the node is a hub if ((hubNode.dynamicEdgesLength >= this.hubThreshold && onlyEqual == false) || (hubNode.dynamicEdgesLength == this.hubThreshold && onlyEqual == true)) { // initialize variables var dx,dy,length; var minLength = this.constants.clustering.clusterEdgeThreshold/this.scale; var allowCluster = false; // 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.clusterEdgeThreshold if (force == false) { allowCluster = false; for (j = 0; j < amountOfInitialEdges; j++) { var edge = this.edges[edgesIdarray[j]]; if (edge !== undefined) { if (edge.connected) { if (edge.toId != edge.fromId) { 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 nor do we want to cluster itself. if ((childNode.dynamicEdges.length <= (this.hubThreshold + absorptionSizeOffset)) && (childNode.id != hubNode.id)) { this._addToCluster(hubNode,childNode,force); } } } } } }, /** * This function adds the child node to the parent node, creating a cluster if it is not already. * * @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 */ _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); } } // a contained node has no dynamic edges. childNode.dynamicEdges = []; // remove circular edges from clusters this._containCircularEdgesFromNode(parentNode,childNode); // remove the childNode from the global nodes object delete this.nodes[childNode.id]; // update the properties of the child and parent var massBefore = parentNode.mass; childNode.clusterSession = this.clusterSession; parentNode.mass += childNode.mass; parentNode.clusterSize += childNode.clusterSize; parentNode.fontSize = Math.min(this.constants.clustering.maxFontSize, this.constants.nodes.fontSize + this.constants.clustering.fontSizeMultiplier*parentNode.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); } // forced clusters only open from screen size and double tap if (force == true) { // parentNode.formationScale = Math.pow(1 - (1.0/11.0),this.clusterSession+3); parentNode.formationScale = 0; } 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 barnesHutTree. * It has to be called if a level is collapsed. It is called by _formClusters(). * @private */ _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 */ _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 {Node} parentNode | Node object * @param {Node} childNode | Node object * @param {Edge} edge | Edge object * @private */ _connectEdgeToCluster : function(parentNode, childNode, edge) { // handle circular edges if (edge.toId == edge.fromId) { this._addToContainedEdges(parentNode, childNode, edge); } else { 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); } }, /** * If a node is connected to itself, a circular edge is drawn. When clustering we want to contain * these edges inside of the cluster. * * @param parentNode * @param childNode * @private */ _containCircularEdgesFromNode : function(parentNode, childNode) { // manage all the edges connected to the child and parent nodes for (var i = 0; i < parentNode.dynamicEdges.length; i++) { var edge = parentNode.dynamicEdges[i]; // handle circular edges if (edge.toId == edge.fromId) { this._addToContainedEdges(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 */ _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 */ _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 */ _validateEdges : function(parentNode) { 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 */ _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) */ 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) { if (node.originalLabel !== undefined) { node.label = node.originalLabel; } else { 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.level); // } // } }, /** * We want to keep the cluster level distribution rather small. This means we do not want unclustered nodes * if the rest of the nodes are already a few cluster levels in. * To fix this we use this function. It determines the min and max cluster level and sends nodes that have not * clustered enough to the clusterToSmallestNeighbours function. */ normalizeClusterLevels : function() { var maxLevel = 0; var minLevel = 1e9; var clusterLevel = 0; // we loop over all nodes in the list for (var nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { clusterLevel = this.nodes[nodeId].clusterSessions.length; if (maxLevel < clusterLevel) {maxLevel = clusterLevel;} if (minLevel > clusterLevel) {minLevel = clusterLevel;} } } if (maxLevel - minLevel > this.constants.clustering.clusterLevelDifference) { var amountOfNodes = this.nodeIndices.length; var targetLevel = maxLevel - this.constants.clustering.clusterLevelDifference; // we loop over all nodes in the list for (var nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { if (this.nodes[nodeId].clusterSessions.length < targetLevel) { this._clusterToSmallestNeighbour(this.nodes[nodeId]); } } } this._updateNodeIndexList(); this._updateDynamicEdges(); // if a cluster was formed, we increase the clusterSession if (this.nodeIndices.length != amountOfNodes) { this.clusterSession += 1; } } }, /** * 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 */ _nodeInActiveArea : function(node) { return ( Math.abs(node.x - this.areaCenter.x) <= this.constants.clustering.activeAreaBoxSize/this.scale && Math.abs(node.y - this.areaCenter.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. * */ repositionNodes : function() { for (var i = 0; i < this.nodeIndices.length; i++) { var node = this.nodes[this.nodeIndices[i]]; if ((node.xFixed == false || node.yFixed == false) && this.createNodeOnClick != true) { var radius = this.constants.physics.springLength * Math.min(100,node.mass); var angle = 2 * Math.PI * Math.random(); if (node.xFixed == false) {node.x = radius * Math.cos(angle);} if (node.yFixed == false) {node.y = radius * Math.sin(angle);} this._repositionBezierNodes(node); } } }, /** * 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 */ _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 reduce the amount of "extension nodes" or chains. These are not quickly clustered with the outliers and hubs methods * with this amount we can cluster specifically on these chains. * * @param {Number} fraction | between 0 and 1, the percentage of chains to reduce * @private */ _reduceAmountOfChains : function(fraction) { this.hubThreshold = 2; var reduceAmount = Math.floor(this.nodeIndices.length * fraction); for (var nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { if (this.nodes[nodeId].dynamicEdgesLength == 2 && this.nodes[nodeId].dynamicEdges.length >= 2) { if (reduceAmount > 0) { this._formClusterFromHub(this.nodes[nodeId],true,true,1); reduceAmount -= 1; } } } } }, /** * We get the amount of "extension nodes" or chains. These are not quickly clustered with the outliers and hubs methods * with this amount we can cluster specifically on these chains. * * @private */ _getChainFraction : function() { var chains = 0; var total = 0; for (var nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { if (this.nodes[nodeId].dynamicEdgesLength == 2 && this.nodes[nodeId].dynamicEdges.length >= 2) { chains += 1; } total += 1; } } return chains/total; } }; var SelectionMixin = { /** * This function can be called from the _doInAllSectors function * * @param object * @param overlappingNodes * @private */ _getNodesOverlappingWith : function(object, overlappingNodes) { var nodes = this.nodes; for (var nodeId in nodes) { if (nodes.hasOwnProperty(nodeId)) { if (nodes[nodeId].isOverlappingWith(object)) { overlappingNodes.push(nodeId); } } } }, /** * retrieve all nodes overlapping with given object * @param {Object} object An object with parameters left, top, right, bottom * @return {Number[]} An array with id's of the overlapping nodes * @private */ _getAllNodesOverlappingWith : function (object) { var overlappingNodes = []; this._doInAllActiveSectors("_getNodesOverlappingWith",object,overlappingNodes); return overlappingNodes; }, /** * Return a position object in canvasspace from a single point in screenspace * * @param pointer * @returns {{left: number, top: number, right: number, bottom: number}} * @private */ _pointerToPositionObject : function(pointer) { var x = this._canvasToX(pointer.x); var y = this._canvasToY(pointer.y); return {left: x, top: y, right: x, bottom: y}; }, /** * Get the top node at the a specific point (like a click) * * @param {{x: Number, y: Number}} pointer * @return {Node | null} node * @private */ _getNodeAt : function (pointer) { // we first check if this is an navigation controls element var positionObject = this._pointerToPositionObject(pointer); var overlappingNodes = this._getAllNodesOverlappingWith(positionObject); // if there are overlapping nodes, select the last one, this is the // one which is drawn on top of the others if (overlappingNodes.length > 0) { return this.nodes[overlappingNodes[overlappingNodes.length - 1]]; } else { return null; } }, /** * retrieve all edges overlapping with given object, selector is around center * @param {Object} object An object with parameters left, top, right, bottom * @return {Number[]} An array with id's of the overlapping nodes * @private */ _getEdgesOverlappingWith : function (object, overlappingEdges) { var edges = this.edges; for (var edgeId in edges) { if (edges.hasOwnProperty(edgeId)) { if (edges[edgeId].isOverlappingWith(object)) { overlappingEdges.push(edgeId); } } } }, /** * retrieve all nodes overlapping with given object * @param {Object} object An object with parameters left, top, right, bottom * @return {Number[]} An array with id's of the overlapping nodes * @private */ _getAllEdgesOverlappingWith : function (object) { var overlappingEdges = []; this._doInAllActiveSectors("_getEdgesOverlappingWith",object,overlappingEdges); return overlappingEdges; }, /** * Place holder. To implement change the _getNodeAt to a _getObjectAt. Have the _getObjectAt call * _getNodeAt and _getEdgesAt, then priortize the selection to user preferences. * * @param pointer * @returns {null} * @private */ _getEdgeAt : function(pointer) { var positionObject = this._pointerToPositionObject(pointer); var overlappingEdges = this._getAllEdgesOverlappingWith(positionObject); if (overlappingEdges.length > 0) { return this.edges[overlappingEdges[overlappingEdges.length - 1]]; } else { return null; } }, /** * Add object to the selection array. * * @param obj * @private */ _addToSelection : function(obj) { this.selectionObj[obj.id] = obj; }, /** * Remove a single option from selection. * * @param {Object} obj * @private */ _removeFromSelection : function(obj) { delete this.selectionObj[obj.id]; }, /** * Unselect all. The selectionObj is useful for this. * * @param {Boolean} [doNotTrigger] | ignore trigger * @private */ _unselectAll : function(doNotTrigger) { if (doNotTrigger === undefined) { doNotTrigger = false; } for (var objectId in this.selectionObj) { if (this.selectionObj.hasOwnProperty(objectId)) { this.selectionObj[objectId].unselect(); } } this.selectionObj = {}; if (doNotTrigger == false) { this.emit('select', this.getSelection()); } }, /** * Unselect all clusters. The selectionObj is useful for this. * * @param {Boolean} [doNotTrigger] | ignore trigger * @private */ _unselectClusters : function(doNotTrigger) { if (doNotTrigger === undefined) { doNotTrigger = false; } for (var objectId in this.selectionObj) { if (this.selectionObj.hasOwnProperty(objectId)) { if (this.selectionObj[objectId] instanceof Node) { if (this.selectionObj[objectId].clusterSize > 1) { this.selectionObj[objectId].unselect(); this._removeFromSelection(this.selectionObj[objectId]); } } } } if (doNotTrigger == false) { this.emit('select', this.getSelection()); } }, /** * return the number of selected nodes * * @returns {number} * @private */ _getSelectedNodeCount : function() { var count = 0; for (var objectId in this.selectionObj) { if (this.selectionObj.hasOwnProperty(objectId)) { if (this.selectionObj[objectId] instanceof Node) { count += 1; } } } return count; }, /** * return the number of selected nodes * * @returns {number} * @private */ _getSelectedNode : function() { for (var objectId in this.selectionObj) { if (this.selectionObj.hasOwnProperty(objectId)) { if (this.selectionObj[objectId] instanceof Node) { return this.selectionObj[objectId]; } } } return null; }, /** * return the number of selected edges * * @returns {number} * @private */ _getSelectedEdgeCount : function() { var count = 0; for (var objectId in this.selectionObj) { if (this.selectionObj.hasOwnProperty(objectId)) { if (this.selectionObj[objectId] instanceof Edge) { count += 1; } } } return count; }, /** * return the number of selected objects. * * @returns {number} * @private */ _getSelectedObjectCount : function() { var count = 0; for (var objectId in this.selectionObj) { if (this.selectionObj.hasOwnProperty(objectId)) { count += 1; } } return count; }, /** * Check if anything is selected * * @returns {boolean} * @private */ _selectionIsEmpty : function() { for(var objectId in this.selectionObj) { if(this.selectionObj.hasOwnProperty(objectId)) { return false; } } return true; }, /** * check if one of the selected nodes is a cluster. * * @returns {boolean} * @private */ _clusterInSelection : function() { for(var objectId in this.selectionObj) { if(this.selectionObj.hasOwnProperty(objectId)) { if (this.selectionObj[objectId] instanceof Node) { if (this.selectionObj[objectId].clusterSize > 1) { return true; } } } } return false; }, /** * select the edges connected to the node that is being selected * * @param {Node} node * @private */ _selectConnectedEdges : function(node) { for (var i = 0; i < node.dynamicEdges.length; i++) { var edge = node.dynamicEdges[i]; edge.select(); this._addToSelection(edge); } }, /** * unselect the edges connected to the node that is being selected * * @param {Node} node * @private */ _unselectConnectedEdges : function(node) { for (var i = 0; i < node.dynamicEdges.length; i++) { var edge = node.dynamicEdges[i]; edge.unselect(); this._removeFromSelection(edge); } }, /** * This is called when someone clicks on a node. either select or deselect it. * If there is an existing selection and we don't want to append to it, clear the existing selection * * @param {Node || Edge} object * @param {Boolean} append * @param {Boolean} [doNotTrigger] | ignore trigger * @private */ _selectObject : function(object, append, doNotTrigger) { if (doNotTrigger === undefined) { doNotTrigger = false; } if (this._selectionIsEmpty() == false && append == false && this.forceAppendSelection == false) { this._unselectAll(true); } if (object.selected == false) { object.select(); this._addToSelection(object); if (object instanceof Node && this.blockConnectingEdgeSelection == false) { this._selectConnectedEdges(object); } } else { object.unselect(); this._removeFromSelection(object); } if (doNotTrigger == false) { this.emit('select', this.getSelection()); } }, /** * handles the selection part of the touch, only for navigation controls elements; * Touch is triggered before tap, also before hold. Hold triggers after a while. * This is the most responsive solution * * @param {Object} pointer * @private */ _handleTouch : function(pointer) { }, /** * handles the selection part of the tap; * * @param {Object} pointer * @private */ _handleTap : function(pointer) { var node = this._getNodeAt(pointer); if (node != null) { this._selectObject(node,false); } else { var edge = this._getEdgeAt(pointer); if (edge != null) { this._selectObject(edge,false); } else { this._unselectAll(); } } this.emit("click", this.getSelection()); this._redraw(); }, /** * handles the selection part of the double tap and opens a cluster if needed * * @param {Object} pointer * @private */ _handleDoubleTap : function(pointer) { var node = this._getNodeAt(pointer); if (node != null && node !== undefined) { // we reset the areaCenter here so the opening of the node will occur this.areaCenter = {"x" : this._canvasToX(pointer.x), "y" : this._canvasToY(pointer.y)}; this.openCluster(node); } this.emit("doubleClick", this.getSelection()); }, /** * Handle the onHold selection part * * @param pointer * @private */ _handleOnHold : function(pointer) { var node = this._getNodeAt(pointer); if (node != null) { this._selectObject(node,true); } else { var edge = this._getEdgeAt(pointer); if (edge != null) { this._selectObject(edge,true); } } this._redraw(); }, /** * handle the onRelease event. These functions are here for the navigation controls module. * * @private */ _handleOnRelease : function(pointer) { }, /** * * retrieve the currently selected objects * @return {Number[] | String[]} selection An array with the ids of the * selected nodes. */ getSelection : function() { var nodeIds = this.getSelectedNodes(); var edgeIds = this.getSelectedEdges(); return {nodes:nodeIds, edges:edgeIds}; }, /** * * retrieve the currently selected nodes * @return {String} selection An array with the ids of the * selected nodes. */ getSelectedNodes : function() { var idArray = []; for(var objectId in this.selectionObj) { if(this.selectionObj.hasOwnProperty(objectId)) { if (this.selectionObj[objectId] instanceof Node) { idArray.push(objectId); } } } return idArray }, /** * * retrieve the currently selected edges * @return {Array} selection An array with the ids of the * selected nodes. */ getSelectedEdges : function() { var idArray = []; for(var objectId in this.selectionObj) { if(this.selectionObj.hasOwnProperty(objectId)) { if (this.selectionObj[objectId] instanceof Edge) { idArray.push(objectId); } } } return idArray }, /** * select zero or more nodes * @param {Number[] | String[]} selection An array with the ids of the * selected nodes. */ 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 this._unselectAll(true); 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'); } this._selectObject(node,true,true); } this.redraw(); }, /** * Validate the selection: remove ids of nodes which no longer exist * @private */ _updateSelection : function () { for(var objectId in this.selectionObj) { if(this.selectionObj.hasOwnProperty(objectId)) { if (this.selectionObj[objectId] instanceof Node) { if (!this.nodes.hasOwnProperty(objectId)) { delete this.selectionObj[objectId]; } } else { // assuming only edges and nodes are selected if (!this.edges.hasOwnProperty(objectId)) { delete this.selectionObj[objectId]; } } } } } }; /** * Created by Alex on 1/22/14. */ var NavigationMixin = { _cleanNavigation : function() { // clean up previosu navigation items var wrapper = document.getElementById('graph-navigation_wrapper'); if (wrapper != null) { this.containerElement.removeChild(wrapper); } document.onmouseup = null; }, /** * Creation of the navigation controls nodes. They are drawn over the rest of the nodes and are not affected by scale and translation * they have a triggerFunction which is called on click. If the position of the navigation controls is dependent * on this.frame.canvas.clientWidth or this.frame.canvas.clientHeight, we flag horizontalAlignLeft and verticalAlignTop false. * This means that the location will be corrected by the _relocateNavigation function on a size change of the canvas. * * @private */ _loadNavigationElements : function() { this._cleanNavigation(); this.navigationDivs = {}; var navigationDivs = ['up','down','left','right','zoomIn','zoomOut','zoomExtends']; var navigationDivActions = ['_moveUp','_moveDown','_moveLeft','_moveRight','_zoomIn','_zoomOut','zoomExtent']; this.navigationDivs['wrapper'] = document.createElement('div'); this.navigationDivs['wrapper'].id = "graph-navigation_wrapper"; this.containerElement.insertBefore(this.navigationDivs['wrapper'],this.frame); for (var i = 0; i < navigationDivs.length; i++) { this.navigationDivs[navigationDivs[i]] = document.createElement('div'); this.navigationDivs[navigationDivs[i]].id = "graph-navigation_" + navigationDivs[i]; this.navigationDivs[navigationDivs[i]].className = "graph-navigation " + navigationDivs[i]; this.navigationDivs['wrapper'].appendChild(this.navigationDivs[navigationDivs[i]]); this.navigationDivs[navigationDivs[i]].onmousedown = this[navigationDivActions[i]].bind(this); } document.onmouseup = this._stopMovement.bind(this); }, /** * this stops all movement induced by the navigation buttons * * @private */ _stopMovement : function() { this._xStopMoving(); this._yStopMoving(); this._stopZoom(); }, /** * stops the actions performed by page up and down etc. * * @param event * @private */ _preventDefault : function(event) { if (event !== undefined) { if (event.preventDefault) { event.preventDefault(); } else { event.returnValue = false; } } }, /** * move the screen up * By using the increments, instead of adding a fixed number to the translation, we keep fluent and * instant movement. The onKeypress event triggers immediately, then pauses, then triggers frequently * To avoid this behaviour, we do the translation in the start loop. * * @private */ _moveUp : function(event) { console.log("here") this.yIncrement = this.constants.keyboard.speed.y; this.start(); // if there is no node movement, the calculation wont be done this._preventDefault(event); }, /** * move the screen down * @private */ _moveDown : function(event) { this.yIncrement = -this.constants.keyboard.speed.y; this.start(); // if there is no node movement, the calculation wont be done this._preventDefault(event); }, /** * move the screen left * @private */ _moveLeft : function(event) { this.xIncrement = this.constants.keyboard.speed.x; this.start(); // if there is no node movement, the calculation wont be done this._preventDefault(event); }, /** * move the screen right * @private */ _moveRight : function(event) { this.xIncrement = -this.constants.keyboard.speed.y; this.start(); // if there is no node movement, the calculation wont be done this._preventDefault(event); }, /** * Zoom in, using the same method as the movement. * @private */ _zoomIn : function(event) { this.zoomIncrement = this.constants.keyboard.speed.zoom; this.start(); // if there is no node movement, the calculation wont be done this._preventDefault(event); }, /** * Zoom out * @private */ _zoomOut : function() { this.zoomIncrement = -this.constants.keyboard.speed.zoom; this.start(); // if there is no node movement, the calculation wont be done this._preventDefault(event); }, /** * Stop zooming and unhighlight the zoom controls * @private */ _stopZoom : function() { this.zoomIncrement = 0; }, /** * Stop moving in the Y direction and unHighlight the up and down * @private */ _yStopMoving : function() { this.yIncrement = 0; }, /** * Stop moving in the X direction and unHighlight left and right. * @private */ _xStopMoving : function() { this.xIncrement = 0; } }; /** * Created by Alex on 2/10/14. */ var graphMixinLoaders = { /** * Load a mixin into the graph object * * @param {Object} sourceVariable | this object has to contain functions. * @private */ _loadMixin : function(sourceVariable) { for (var mixinFunction in sourceVariable) { if (sourceVariable.hasOwnProperty(mixinFunction)) { Graph.prototype[mixinFunction] = sourceVariable[mixinFunction]; } } }, /** * removes a mixin from the graph object. * * @param {Object} sourceVariable | this object has to contain functions. * @private */ _clearMixin : function(sourceVariable) { for (var mixinFunction in sourceVariable) { if (sourceVariable.hasOwnProperty(mixinFunction)) { Graph.prototype[mixinFunction] = undefined; } } }, /** * Mixin the physics system and initialize the parameters required. * * @private */ _loadPhysicsSystem : function() { this._loadMixin(physicsMixin); this._loadSelectedForceSolver(); if (this.constants.configurePhysics == true) { this._loadPhysicsConfiguration(); } }, /** * Mixin the cluster system and initialize the parameters required. * * @private */ _loadClusterSystem : function() { this.clusterSession = 0; this.hubThreshold = 5; this._loadMixin(ClusterMixin); }, /** * Mixin the sector system and initialize the parameters required * * @private */ _loadSectorSystem : function() { this.sectors = { }, this.activeSector = ["default"]; this.sectors["active"] = { }, this.sectors["active"]["default"] = {"nodes":{}, "edges":{}, "nodeIndices":[], "formationScale": 1.0, "drawingNode": undefined }; this.sectors["frozen"] = {}, this.sectors["support"] = {"nodes":{}, "edges":{}, "nodeIndices":[], "formationScale": 1.0, "drawingNode": undefined }; this.nodeIndices = this.sectors["active"]["default"]["nodeIndices"]; // the node indices list is used to speed up the computation of the repulsion fields this._loadMixin(SectorMixin); }, /** * Mixin the selection system and initialize the parameters required * * @private */ _loadSelectionSystem : function() { this.selectionObj = { }; this._loadMixin(SelectionMixin); }, /** * Mixin the navigationUI (User Interface) system and initialize the parameters required * * @private */ _loadManipulationSystem : function() { // reset global variables -- these are used by the selection of nodes and edges. this.blockConnectingEdgeSelection = false; this.forceAppendSelection = false if (this.constants.dataManipulation.enabled == true) { // load the manipulator HTML elements. All styling done in css. if (this.manipulationDiv === undefined) { this.manipulationDiv = document.createElement('div'); this.manipulationDiv.className = 'graph-manipulationDiv'; this.manipulationDiv.id = 'graph-manipulationDiv'; if (this.editMode == true) { this.manipulationDiv.style.display = "block"; } else { this.manipulationDiv.style.display = "none"; } this.containerElement.insertBefore(this.manipulationDiv, this.frame); } if (this.editModeDiv === undefined) { this.editModeDiv = document.createElement('div'); this.editModeDiv.className = 'graph-manipulation-editMode'; this.editModeDiv.id = 'graph-manipulation-editMode'; if (this.editMode == true) { this.editModeDiv.style.display = "none"; } else { this.editModeDiv.style.display = "block"; } this.containerElement.insertBefore(this.editModeDiv, this.frame); } if (this.closeDiv === undefined) { this.closeDiv = document.createElement('div'); this.closeDiv.className = 'graph-manipulation-closeDiv'; this.closeDiv.id = 'graph-manipulation-closeDiv'; this.closeDiv.style.display = this.manipulationDiv.style.display; this.containerElement.insertBefore(this.closeDiv, this.frame); } // load the manipulation functions this._loadMixin(manipulationMixin); // create the manipulator toolbar this._createManipulatorBar(); } else { if (this.manipulationDiv !== undefined) { // removes all the bindings and overloads this._createManipulatorBar(); // remove the manipulation divs this.containerElement.removeChild(this.manipulationDiv); this.containerElement.removeChild(this.editModeDiv); this.containerElement.removeChild(this.closeDiv); this.manipulationDiv = undefined; this.editModeDiv = undefined; this.closeDiv = undefined; // remove the mixin functions this._clearMixin(manipulationMixin); } } }, /** * Mixin the navigation (User Interface) system and initialize the parameters required * * @private */ _loadNavigationControls : function() { this._loadMixin(NavigationMixin); // the clean function removes the button divs, this is done to remove the bindings. this._cleanNavigation(); if (this.constants.navigation.enabled == true) { this._loadNavigationElements(); } }, /** * Mixin the hierarchical layout system. * * @private */ _loadHierarchySystem : function() { this._loadMixin(HierarchicalLayoutMixin); } } /** * @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) { this._initializeMixinLoaders(); // create variables and set default values this.containerElement = container; this.width = '100%'; this.height = '100%'; // render and calculation settings this.renderRefreshRate = 60; // hz (fps) this.renderTimestep = 1000 / this.renderRefreshRate; // ms -- saves calculation later on this.renderTime = 0.5 * this.renderTimestep; // measured time it takes to render a frame this.maxRenderSteps = 3; // max amount of physics ticks per render step. this.stabilize = true; // stabilize before displaying the graph this.selectable = true; // these functions are triggered when the dataset is edited this.triggerFunctions = {add:null,edit:null,connect:null,delete:null}; // set constant values this.constants = { nodes: { radiusMin: 5, radiusMax: 20, radius: 5, shape: 'ellipse', image: undefined, widthMin: 16, // px widthMax: 64, // px fixed: false, fontColor: 'black', fontSize: 14, // px fontFace: 'verdana', level: -1, 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: '#848484', fontColor: '#343434', fontSize: 14, // px fontFace: 'arial', dash: { length: 10, gap: 5, altLength: undefined } }, configurePhysics:false, physics: { barnesHut: { enabled: true, theta: 1 / 0.6, // inverted to save time during calculation gravitationalConstant: -2000, centralGravity: 0.3, springLength: 100, springConstant: 0.05, damping: 0.09 }, repulsion: { centralGravity: 0.1, springLength: 200, springConstant: 0.05, nodeDistance: 100, damping: 0.09 }, hierarchicalRepulsion: { enabled: false, centralGravity: 0.0, springLength: 100, springConstant: 0.01, nodeDistance: 60, damping: 0.09 }, damping: null, centralGravity: null, springLength: null, springConstant: null }, clustering: { // Per Node in Cluster = PNiC enabled: false, // (Boolean) | global on/off switch for clustering. initialMaxNodes: 100, // (# nodes) | if the initial amount of nodes is larger than this, we cluster until the total number is less than this threshold. clusterThreshold:500, // (# nodes) | during calculate forces, we check if the total number of nodes is larger than this. If it is, cluster until reduced to reduceToNodes reduceToNodes:300, // (# nodes) | during calculate forces, we check if the total number of nodes is larger than clusterThreshold. If it is, cluster until reduced to this chainThreshold: 0.4, // (% of all drawn nodes)| maximum percentage of allowed chainnodes (long strings of connected nodes) within all nodes. (lower means less chains). clusterEdgeThreshold: 20, // (px) | edge length threshold. if smaller, this node is clustered. sectorThreshold: 100, // (# nodes in cluster) | cluster size threshold. If larger, expanding in own sector. screenSizeThreshold: 0.2, // (% of canvas) | relative size threshold. If the width or height of a clusternode takes up this much of the screen, decluster node. fontSizeMultiplier: 4.0, // (px PNiC) | how much the cluster font size grows per node in cluster (in px). maxFontSize: 1000, forceAmplification: 0.1, // (multiplier PNiC) | factor of increase fo the repulsion force of a cluster (per node in cluster). distanceAmplification: 0.1, // (multiplier PNiC) | factor how much the repulsion distance of a cluster increases (per node in cluster). edgeGrowth: 20, // (px PNiC) | amount of clusterSize connected to the edge is multiplied with this and added to edgeLength. nodeScaling: {width: 1, // (px PNiC) | growth of the width per node in cluster. height: 1, // (px PNiC) | growth of the height per node in cluster. radius: 1}, // (px PNiC) | growth of the radius per node in cluster. maxNodeSizeIncrements: 600, // (# increments) | max growth of the width per node in cluster. activeAreaBoxSize: 80, // (px) | box area around the curser where clusters are popped open. clusterLevelDifference: 2 }, navigation: { enabled: false }, keyboard: { enabled: false, speed: {x: 10, y: 10, zoom: 0.02} }, dataManipulation: { enabled: false, initiallyVisible: false }, hierarchicalLayout: { enabled:false, levelSeparation: 150, nodeSpacing: 100, direction: "UD" // UD, DU, LR, RL }, smoothCurves: true, maxVelocity: 10, minVelocity: 0.1, // px/s maxIterations: 1000 // maximum number of iteration to stabilize }; this.editMode = this.constants.dataManipulation.initiallyVisible; // Node variables var graph = this; this.groups = new Groups(); // object with groups this.images = new Images(); // object with images this.images.setOnloadCallback(function () { graph._redraw(); }); // keyboard navigation variables this.xIncrement = 0; this.yIncrement = 0; this.zoomIncrement = 0; // loading all the mixins: // load the force calculation functions, grouped under the physics system. this._loadPhysicsSystem(); // create a frame and canvas this._create(); // load the sector system. (mandatory, fully integrated with Graph) this._loadSectorSystem(); // load the cluster system. (mandatory, even when not using the cluster system, there are function calls to it) this._loadClusterSystem(); // load the selection system. (mandatory, required by Graph) this._loadSelectionSystem(); // load the selection system. (mandatory, required by Graph) this._loadHierarchySystem(); // apply options this.setOptions(options); // other vars this.freezeSimulation = false;// freeze the simulation this.cachedFunctions = {}; // containers for nodes and edges this.calculationNodes = {}; this.calculationNodeIndices = []; this.nodeIndices = []; // array with all the indices of the nodes. Used to speed up forces calculation this.nodes = {}; // object with Node objects this.edges = {}; // object with Edge objects // position and scale variables and objects this.canvasTopLeft = {"x": 0,"y": 0}; // coordinates of the top left of the canvas. they will be set during _redraw. this.canvasBottomRight = {"x": 0,"y": 0}; // coordinates of the bottom right of the canvas. they will be set during _redraw this.pointerPosition = {"x": 0,"y": 0}; // coordinates of the bottom right of the canvas. they will be set during _redraw this.areaCenter = {}; // 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 // datasets or dataviews 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 this.nodesListeners = { 'add': function (event, params) { graph._addNodes(params.items); graph.start(); }, 'update': function (event, params) { graph._updateNodes(params.items); graph.start(); }, 'remove': function (event, params) { graph._removeNodes(params.items); graph.start(); } }; this.edgesListeners = { 'add': function (event, params) { graph._addEdges(params.items); graph.start(); }, 'update': function (event, params) { graph._updateEdges(params.items); graph.start(); }, 'remove': function (event, params) { graph._removeEdges(params.items); graph.start(); } }; // properties for the animation this.moving = true; this.timer = undefined; // Scheduling function. Is definded in this.start(); // load data (the disable start variable will be the same as the enabled clustering) this.setData(data,this.constants.clustering.enabled || this.constants.hierarchicalLayout.enabled); // hierarchical layout if (this.constants.hierarchicalLayout.enabled == true) { this._setupHierarchicalLayout(); } else { // zoom so all data will fit on the screen, if clustering is enabled, we do not want start to be called here. if (this.stabilize == false) { this.zoomExtent(true,this.constants.clustering.enabled); } } // if clustering is disabled, the simulation will have started in the setData function if (this.constants.clustering.enabled) { this.startWithClustering(); } } // Extend Graph with an Emitter mixin Emitter(Graph.prototype); /** * Get the script path where the vis.js library is located * * @returns {string | null} path Path or null when not found. Path does not * end with a slash. * @private */ Graph.prototype._getScriptPath = function() { var scripts = document.getElementsByTagName( 'script' ); // find script named vis.js or vis.min.js for (var i = 0; i < scripts.length; i++) { var src = scripts[i].src; var match = src && /\/?vis(.min)?\.js$/.exec(src); if (match) { // return path without the script name return src.substring(0, src.length - match[0].length); } } return null; }; /** * Find the center position of the graph * @private */ Graph.prototype._getRange = function() { var minY = 1e9, maxY = -1e9, minX = 1e9, maxX = -1e9, node; for (var nodeId in this.nodes) { if (this.nodes.hasOwnProperty(nodeId)) { node = this.nodes[nodeId]; if (minX > (node.x)) {minX = node.x;} if (maxX < (node.x)) {maxX = node.x;} if (minY > (node.y)) {minY = node.y;} if (maxY < (node.y)) {maxY = node.y;} } } return {minX: minX, maxX: maxX, minY: minY, maxY: maxY}; }; /** * @param {object} range = {minX: minX, maxX: maxX, minY: minY, maxY: maxY}; * @returns {{x: number, y: number}} * @private */ Graph.prototype._findCenter = function(range) { return {x: (0.5 * (range.maxX + range.minX)), y: (0.5 * (range.maxY + range.minY))}; }; /** * center the graph * * @param {object} range = {minX: minX, maxX: maxX, minY: minY, maxY: maxY}; */ Graph.prototype._centerGraph = function(range) { var center = this._findCenter(range); center.x *= this.scale; center.y *= this.scale; center.x -= 0.5 * this.frame.canvas.clientWidth; center.y -= 0.5 * this.frame.canvas.clientHeight; this._setTranslation(-center.x,-center.y); // set at 0,0 }; /** * This function zooms out to fit all data on screen based on amount of nodes * * @param {Boolean} [initialZoom] | zoom based on fitted formula or range, true = fitted, default = false; */ Graph.prototype.zoomExtent = function(initialZoom, disableStart) { if (initialZoom === undefined) { initialZoom = false; } if (disableStart === undefined) { disableStart = false; } var range = this._getRange(); var zoomLevel; if (initialZoom == true) { var numberOfNodes = this.nodeIndices.length; if (this.constants.smoothCurves == true) { if (this.constants.clustering.enabled == true && numberOfNodes >= this.constants.clustering.initialMaxNodes) { zoomLevel = 49.07548 / (numberOfNodes + 142.05338) + 9.1444e-04; // this is obtained from fitting a dataset from 5 points with scale levels that looked good. } else { zoomLevel = 12.662 / (numberOfNodes + 7.4147) + 0.0964822; // this is obtained from fitting a dataset from 5 points with scale levels that looked good. } } else { if (this.constants.clustering.enabled == true && numberOfNodes >= this.constants.clustering.initialMaxNodes) { zoomLevel = 77.5271985 / (numberOfNodes + 187.266146) + 4.76710517e-05; // this is obtained from fitting a dataset from 5 points with scale levels that looked good. } else { zoomLevel = 30.5062972 / (numberOfNodes + 19.93597763) + 0.08413486; // this is obtained from fitting a dataset from 5 points with scale levels that looked good. } } // correct for larger canvasses. var factor = Math.min(this.frame.canvas.clientWidth / 600, this.frame.canvas.clientHeight / 600); zoomLevel *= factor; } else { var xDistance = (Math.abs(range.minX) + Math.abs(range.maxX)) * 1.1; var yDistance = (Math.abs(range.minY) + Math.abs(range.maxY)) * 1.1; var xZoomLevel = this.frame.canvas.clientWidth / xDistance; var yZoomLevel = this.frame.canvas.clientHeight / yDistance; zoomLevel = (xZoomLevel <= yZoomLevel) ? xZoomLevel : yZoomLevel; } if (zoomLevel > 1.0) { zoomLevel = 1.0; } this._setScale(zoomLevel); this._centerGraph(range); if (disableStart == false) { this.moving = true; this.start(); } }; /** * Update the this.nodeIndices with the most recent node index list * @private */ Graph.prototype._updateNodeIndexList = function() { this._clearNodeIndexList(); 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 * @param {Boolean} [disableStart] | optional: disable the calling of the start function. */ Graph.prototype.setData = function(data, disableStart) { if (disableStart === undefined) { disableStart = false; } 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); } this._putDataInSector(); if (!disableStart) { // find a stable position or start animating to a stable position if (this.stabilize) { this._doStabilize(); } this.start(); } }; /** * Set options * @param {Object} options */ Graph.prototype.setOptions = function (options) { if (options) { var prop; // 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;} if (options.smoothCurves !== undefined) {this.constants.smoothCurves = options.smoothCurves;} if (options.configurePhysics !== undefined){this.constants.configurePhysics = options.configurePhysics;} if (options.onAdd) { this.triggerFunctions.add = options.onAdd; } if (options.onEdit) { this.triggerFunctions.edit = options.onEdit; } if (options.onConnect) { this.triggerFunctions.connect = options.onConnect; } if (options.onDelete) { this.triggerFunctions.delete = options.onDelete; } if (options.physics) { if (options.physics.barnesHut) { this.constants.physics.barnesHut.enabled = true; for (prop in options.physics.barnesHut) { if (options.physics.barnesHut.hasOwnProperty(prop)) { this.constants.physics.barnesHut[prop] = options.physics.barnesHut[prop]; } } } if (options.physics.repulsion) { this.constants.physics.barnesHut.enabled = false; for (prop in options.physics.repulsion) { if (options.physics.repulsion.hasOwnProperty(prop)) { this.constants.physics.repulsion[prop] = options.physics.repulsion[prop]; } } } } if (options.hierarchicalLayout) { this.constants.hierarchicalLayout.enabled = true; for (prop in options.hierarchicalLayout) { if (options.hierarchicalLayout.hasOwnProperty(prop)) { this.constants.hierarchicalLayout[prop] = options.hierarchicalLayout[prop]; } } } else if (options.hierarchicalLayout !== undefined) { this.constants.hierarchicalLayout.enabled = false; } if (options.clustering) { this.constants.clustering.enabled = true; for (prop in options.clustering) { if (options.clustering.hasOwnProperty(prop)) { this.constants.clustering[prop] = options.clustering[prop]; } } } else if (options.clustering !== undefined) { this.constants.clustering.enabled = false; } if (options.navigation) { this.constants.navigation.enabled = true; for (prop in options.navigation) { if (options.navigation.hasOwnProperty(prop)) { this.constants.navigation[prop] = options.navigation[prop]; } } } else if (options.navigation !== undefined) { this.constants.navigation.enabled = false; } if (options.keyboard) { this.constants.keyboard.enabled = true; for (prop in options.keyboard) { if (options.keyboard.hasOwnProperty(prop)) { this.constants.keyboard[prop] = options.keyboard[prop]; } } } else if (options.keyboard !== undefined) { this.constants.keyboard.enabled = false; } if (options.dataManipulation) { this.constants.dataManipulation.enabled = true; for (prop in options.dataManipulation) { if (options.dataManipulation.hasOwnProperty(prop)) { this.constants.dataManipulation[prop] = options.dataManipulation[prop]; } } } else if (options.dataManipulation !== undefined) { this.constants.dataManipulation.enabled = false; } // TODO: work out these options and document them if (options.edges) { for (prop in options.edges) { if (options.edges.hasOwnProperty(prop)) { this.constants.edges[prop] = options.edges[prop]; } } 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); } } } } // (Re)loading the mixins that can be enabled or disabled in the options. // load the force calculation functions, grouped under the physics system. this._loadPhysicsSystem(); // load the navigation system. this._loadNavigationControls(); // load the data manipulation system this._loadManipulationSystem(); // configure the smooth curves this._configureSmoothCurves(); // bind keys. If disabled, this will not do anything; this._createKeyBinds(); this.setSize(this.width, this.height); this._setTranslation(this.frame.clientWidth / 2, this.frame.clientHeight / 2); this._setScale(1); this._redraw(); }; /** * 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'; this.frame.style.zIndex = "1"; // 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('doubletap', me._onDoubleTap.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('release', me._onRelease.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) ); // add the frame to the container element this.containerElement.appendChild(this.frame); }; /** * Binding the keys for keyboard navigation. These functions are defined in the NavigationMixin * @private */ Graph.prototype._createKeyBinds = function() { var me = this; this.mousetrap = mousetrap; this.mousetrap.reset(); if (this.constants.keyboard.enabled == true) { this.mousetrap.bind("up", this._moveUp.bind(me) , "keydown"); this.mousetrap.bind("up", this._yStopMoving.bind(me), "keyup"); this.mousetrap.bind("down", this._moveDown.bind(me) , "keydown"); this.mousetrap.bind("down", this._yStopMoving.bind(me), "keyup"); this.mousetrap.bind("left", this._moveLeft.bind(me) , "keydown"); this.mousetrap.bind("left", this._xStopMoving.bind(me), "keyup"); this.mousetrap.bind("right",this._moveRight.bind(me), "keydown"); this.mousetrap.bind("right",this._xStopMoving.bind(me), "keyup"); this.mousetrap.bind("=", this._zoomIn.bind(me), "keydown"); this.mousetrap.bind("=", this._stopZoom.bind(me), "keyup"); this.mousetrap.bind("-", this._zoomOut.bind(me), "keydown"); this.mousetrap.bind("-", this._stopZoom.bind(me), "keyup"); this.mousetrap.bind("[", this._zoomIn.bind(me), "keydown"); this.mousetrap.bind("[", this._stopZoom.bind(me), "keyup"); this.mousetrap.bind("]", this._zoomOut.bind(me), "keydown"); this.mousetrap.bind("]", this._stopZoom.bind(me), "keyup"); this.mousetrap.bind("pageup",this._zoomIn.bind(me), "keydown"); this.mousetrap.bind("pageup",this._stopZoom.bind(me), "keyup"); this.mousetrap.bind("pagedown",this._zoomOut.bind(me),"keydown"); this.mousetrap.bind("pagedown",this._stopZoom.bind(me), "keyup"); } if (this.constants.dataManipulation.enabled == true) { this.mousetrap.bind("escape",this._createManipulatorBar.bind(me)); this.mousetrap.bind("del",this._deleteSelected.bind(me)); } }; /** * 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.center); this.drag.pinched = false; this.pinch.scale = this._getScale(); this._handleTouch(this.drag.pointer); }; /** * handle drag start event * @private */ Graph.prototype._onDragStart = function () { this._handleDragStart(); }; /** * This function is called by _onDragStart. * It is separated out because we can then overload it for the datamanipulation system. * * @private */ Graph.prototype._handleDragStart = function() { var drag = this.drag; var node = this._getNodeAt(drag.pointer); // note: drag.pointer is set in _onTouch to get the initial touch location drag.dragging = true; drag.selection = []; drag.translation = this._getTranslation(); drag.nodeId = null; if (node != null) { drag.nodeId = node.id; // select the clicked node if not yet selected if (!node.isSelected()) { this._selectObject(node,false); } // create an array with the selected nodes and their original location and status for (var objectId in this.selectionObj) { if (this.selectionObj.hasOwnProperty(objectId)) { var object = this.selectionObj[objectId]; if (object instanceof Node) { var s = { id: object.id, node: object, // store original x, y, xFixed and yFixed, make the node temporarily Fixed x: object.x, y: object.y, xFixed: object.xFixed, yFixed: object.yFixed }; object.xFixed = true; object.yFixed = true; drag.selection.push(s); } } } } }; /** * handle drag event * @private */ Graph.prototype._onDrag = function (event) { this._handleOnDrag(event) }; /** * This function is called by _onDrag. * It is separated out because we can then overload it for the datamanipulation system. * * @private */ Graph.prototype._handleOnDrag = function(event) { if (this.drag.pinched) { return; } var pointer = this._getPointer(event.gesture.center); 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 _animationStep 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 () { this.drag.dragging = false; 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.center); this.pointerPosition = pointer; this._handleTap(pointer); }; /** * handle doubletap event * @private */ Graph.prototype._onDoubleTap = function (event) { var pointer = this._getPointer(event.gesture.center); this._handleDoubleTap(pointer); }; /** * handle long tap event: multi select nodes * @private */ Graph.prototype._onHold = function (event) { var pointer = this._getPointer(event.gesture.center); this.pointerPosition = pointer; this._handleOnHold(pointer); }; /** * handle the release of the screen * * @private */ Graph.prototype._onRelease = function (event) { var pointer = this._getPointer(event.gesture.center); this._handleOnRelease(pointer); }; /** * 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: enabled 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 Position on screen * @return {Number} appliedScale scale is limited within the boundaries * @private */ Graph.prototype._zoom = function(scale, pointer) { var scaleOld = this._getScale(); if (scale < 0.00001) { scale = 0.00001; } 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.areaCenter = {"x" : this._canvasToX(pointer.x), "y" : this._canvasToY(pointer.y)}; this._setScale(scale); this._setTranslation(tx, ty); this.updateClustersDefault(); this._redraw(); 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) { // calculate the new scale var scale = this._getScale(); 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 this._zoom(scale, pointer); } // 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 calculationTimer } if (!this.drag.dragging) { 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(); } } }; /** * 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; if (this.manipulationDiv !== undefined) { this.manipulationDiv.style.width = this.frame.canvas.clientWidth; } this.emit('resize', {width:this.frame.canvas.width,height:this.frame.canvas.height}); }; /** * 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.off(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.on(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.xFixed == false || node.yFixed == false) && this.createNodeOnClick != true) { var radius = 10 * 0.1*ids.length; var angle = 2 * Math.PI * Math.random(); if (node.xFixed == false) {node.x = radius * Math.cos(angle);} if (node.yFixed == false) {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._updateCalculationNodes(); this._reconnectEdges(); this._updateValueRange(this.nodes); this.updateLabels(); }; /** * 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.off(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.on(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); this._createBezierNodes(); this._updateCalculationNodes(); }; /** * 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._createBezierNodes(); 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) { if (edge.via != null) { delete this.sectors['support']['nodes'][edge.via.id]; } edge.disconnect(); delete edges[id]; } } this.moving = true; this._updateValueRange(edges); this._updateCalculationNodes(); }; /** * 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.canvasTopLeft = { "x": this._canvasToX(0), "y": this._canvasToY(0) }; this.canvasBottomRight = { "x": this._canvasToX(this.frame.canvas.clientWidth), "y": this._canvasToY(this.frame.canvas.clientHeight) }; this._doInAllSectors("_drawAllSectorNodes",ctx); this._doInAllSectors("_drawEdges",ctx); this._doInAllSectors("_drawNodes",ctx,false); // this._doInSupportSector("_drawNodes",ctx,true); // this._drawTree(ctx,"#F00F0F"); // 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 * @param {Boolean} [alwaysShow] * @private */ Graph.prototype._drawNodes = function(ctx,alwaysShow) { if (alwaysShow === undefined) { alwaysShow = false; } // first draw the unselected nodes var nodes = this.nodes; var selected = []; for (var id in nodes) { if (nodes.hasOwnProperty(id)) { nodes[id].setScaleAndPos(this.scale,this.canvasTopLeft,this.canvasBottomRight); if (nodes[id].isSelected()) { selected.push(id); } else { if (nodes[id].inArea() || alwaysShow) { nodes[id].draw(ctx); } } } } // draw the selected nodes on top for (var s = 0, sMax = selected.length; s < sMax; s++) { if (nodes[selected[s]].inArea() || alwaysShow) { 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() { // find stable position var count = 0; while (this.moving && count < this.constants.maxIterations) { this._physicsTick(); count++; } this.zoomExtent(false,true); }; /** * 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) { 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 = 0.65; var nodes = this.nodes; var nodeId; if (this.constants.maxVelocity > 0) { for (nodeId in nodes) { if (nodes.hasOwnProperty(nodeId)) { nodes[nodeId].discreteStepLimited(interval, this.constants.maxVelocity); } } } else { for (nodeId in nodes) { if (nodes.hasOwnProperty(nodeId)) { nodes[nodeId].discreteStep(interval); } } } var vminCorrected = this.constants.minVelocity / Math.max(this.scale,0.05); if (vminCorrected > 0.5*this.constants.maxVelocity) { this.moving = true; } else { this.moving = this._isMoving(vminCorrected); } }; Graph.prototype._physicsTick = function() { if (!this.freezeSimulation) { if (this.moving) { this._doInAllActiveSectors("_initializeForceCalculation"); if (this.constants.smoothCurves) { this._doInSupportSector("_discreteStepNodes"); } this._doInAllActiveSectors("_discreteStepNodes"); this._findCenter(this._getRange()) } } }; /** * This function runs one step of the animation. It calls an x amount of physics ticks and one render tick. * It reschedules itself at the beginning of the function * * @private */ Graph.prototype._animationStep = function() { // reset the timer so a new scheduled animation step can be set this.timer = undefined; // handle the keyboad movement this._handleNavigation(); // this schedules a new animation step this.start(); // start the physics simulation var calculationTime = Date.now(); var maxSteps = 1; this._physicsTick(); var timeRequired = Date.now() - calculationTime; while (timeRequired < (this.renderTimestep - this.renderTime) && maxSteps < this.maxRenderSteps) { this._physicsTick(); timeRequired = Date.now() - calculationTime; maxSteps++; } // start the rendering process var renderTime = Date.now(); this._redraw(); this.renderTime = Date.now() - renderTime; }; if (typeof window !== 'undefined') { window.requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame; } /** * Schedule a animation step with the refreshrate interval. * * @poram {Boolean} runCalculationStep */ Graph.prototype.start = function() { if (this.moving || this.xIncrement != 0 || this.yIncrement != 0 || this.zoomIncrement != 0) { if (!this.timer) { this.timer = window.requestAnimationFrame(this._animationStep.bind(this), this.renderTimestep); // wait this.renderTimeStep milliseconds and perform the animation step function } } else { this._redraw(); } }; /** * Move the graph according to the keyboard presses. * * @private */ Graph.prototype._handleNavigation = function() { if (this.xIncrement != 0 || this.yIncrement != 0) { var translation = this._getTranslation(); this._setTranslation(translation.x+this.xIncrement, translation.y+this.yIncrement); } if (this.zoomIncrement != 0) { var center = { x: this.frame.canvas.clientWidth / 2, y: this.frame.canvas.clientHeight / 2 }; this._zoom(this.scale*(1 + this.zoomIncrement), center); } }; /** * Freeze the _animationStep */ Graph.prototype.toggleFreeze = function() { if (this.freezeSimulation == false) { this.freezeSimulation = true; } else { this.freezeSimulation = false; this.start(); } }; Graph.prototype._configureSmoothCurves = function(disableStart) { if (disableStart === undefined) { disableStart = true; } if (this.constants.smoothCurves == true) { this._createBezierNodes(); } else { // delete the support nodes this.sectors['support']['nodes'] = {}; for (var edgeId in this.edges) { if (this.edges.hasOwnProperty(edgeId)) { this.edges[edgeId].smooth = false; this.edges[edgeId].via = null; } } } this._updateCalculationNodes(); if (!disableStart) { this.moving = true; this.start(); } }; Graph.prototype._createBezierNodes = function() { if (this.constants.smoothCurves == true) { for (var edgeId in this.edges) { if (this.edges.hasOwnProperty(edgeId)) { var edge = this.edges[edgeId]; if (edge.via == null) { edge.smooth = true; var nodeId = "edgeId:".concat(edge.id); this.sectors['support']['nodes'][nodeId] = new Node( {id:nodeId, mass:1, shape:'circle', internalMultiplier:1 },{},{},this.constants); edge.via = this.sectors['support']['nodes'][nodeId]; edge.via.parentEdgeId = edge.id; edge.positionBezierNode(); } } } } }; Graph.prototype._initializeMixinLoaders = function () { for (var mixinFunction in graphMixinLoaders) { if (graphMixinLoaders.hasOwnProperty(mixinFunction)) { Graph.prototype[mixinFunction] = graphMixinLoaders[mixinFunction]; } } }; /** * vis.js module exports */ var vis = { util: util, Controller: Controller, DataSet: DataSet, DataView: DataView, Range: Range, Stack: Stack, TimeStep: TimeStep, 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; } },{"emitter-component":2,"hammerjs":3,"moment":4,"mousetrap":5}],2:[function(require,module,exports){ /** * Expose `Emitter`. */ module.exports = Emitter; /** * Initialize a new `Emitter`. * * @api public */ function Emitter(obj) { if (obj) return mixin(obj); }; /** * Mixin the emitter properties. * * @param {Object} obj * @return {Object} * @api private */ function mixin(obj) { for (var key in Emitter.prototype) { obj[key] = Emitter.prototype[key]; } return obj; } /** * Listen on the given `event` with `fn`. * * @param {String} event * @param {Function} fn * @return {Emitter} * @api public */ Emitter.prototype.on = Emitter.prototype.addEventListener = function(event, fn){ this._callbacks = this._callbacks || {}; (this._callbacks[event] = this._callbacks[event] || []) .push(fn); return this; }; /** * Adds an `event` listener that will be invoked a single * time then automatically removed. * * @param {String} event * @param {Function} fn * @return {Emitter} * @api public */ Emitter.prototype.once = function(event, fn){ var self = this; this._callbacks = this._callbacks || {}; function on() { self.off(event, on); fn.apply(this, arguments); } on.fn = fn; this.on(event, on); return this; }; /** * Remove the given callback for `event` or all * registered callbacks. * * @param {String} event * @param {Function} fn * @return {Emitter} * @api public */ Emitter.prototype.off = Emitter.prototype.removeListener = Emitter.prototype.removeAllListeners = Emitter.prototype.removeEventListener = function(event, fn){ this._callbacks = this._callbacks || {}; // all if (0 == arguments.length) { this._callbacks = {}; return this; } // specific event var callbacks = this._callbacks[event]; if (!callbacks) return this; // remove all handlers if (1 == arguments.length) { delete this._callbacks[event]; return this; } // remove specific handler var cb; for (var i = 0; i < callbacks.length; i++) { cb = callbacks[i]; if (cb === fn || cb.fn === fn) { callbacks.splice(i, 1); break; } } return this; }; /** * Emit `event` with the given args. * * @param {String} event * @param {Mixed} ... * @return {Emitter} */ Emitter.prototype.emit = function(event){ this._callbacks = this._callbacks || {}; var args = [].slice.call(arguments, 1) , callbacks = this._callbacks[event]; if (callbacks) { callbacks = callbacks.slice(0); for (var i = 0, len = callbacks.length; i < len; ++i) { callbacks[i].apply(this, args); } } return this; }; /** * Return array of callbacks for `event`. * * @param {String} event * @return {Array} * @api public */ Emitter.prototype.listeners = function(event){ this._callbacks = this._callbacks || {}; return this._callbacks[event] || []; }; /** * Check if this emitter has `event` handlers. * * @param {String} event * @return {Boolean} * @api public */ Emitter.prototype.hasListeners = function(event){ return !! this.listeners(event).length; }; },{}],3:[function(require,module,exports){ /*! Hammer.JS - v1.0.5 - 2013-04-07 * http://eightmedia.github.com/hammer.js * * Copyright (c) 2013 Jorik Tangelder ; * 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 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= 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 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); },{}],4:[function(require,module,exports){ //! moment.js //! version : 2.5.1 //! authors : Tim Wood, Iskren Chernev, Moment.js contributors //! license : MIT //! momentjs.com (function (undefined) { /************************************ Constants ************************************/ var moment, VERSION = "2.5.1", 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 = {}, // moment internal properties momentProperties = { _isAMomentObject: null, _i : null, _f : null, _l : null, _strict : null, _isUTC : null, _offset : null, // optional. Combine with _isUTC _pf : null, _lang : null // optional }, // 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 parseTokenSignedNumber = /[+-]?\d+/, // -inf - inf // 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{6}|\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 = [ ['YYYYYY-MM-DD', /[+-]\d{6}-\d{2}-\d{2}/], ['YYYY-MM-DD', /\d{4}-\d{2}-\d{2}/], ['GGGG-[W]WW-E', /\d{4}-W\d{2}-\d/], ['GGGG-[W]WW', /\d{4}-W\d{2}/], ['YYYY-DDD', /\d{4}-\d{3}/] ], // 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 leftZeroFill(this.weekYear(), 4); }, ggggg : function () { return leftZeroFill(this.weekYear(), 5); }, GG : function () { return leftZeroFill(this.isoWeekYear() % 100, 2); }, GGGG : function () { return leftZeroFill(this.isoWeekYear(), 4); }, 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 defaultParsingFlags() { // We need to deep clone this object, and es5 standard is not very // helpful. return { empty : false, unusedTokens : [], unusedInput : [], overflow : -2, charsLeftOver : 0, nullInput : false, invalidMonth : null, invalidFormat : false, userInvalidated : false, iso: false }; } 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 cloneMoment(m) { var result = {}, i; for (i in m) { if (m.hasOwnProperty(i) && momentProperties.hasOwnProperty(i)) { result[i] = m[i]; } } return result; } 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 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 'Y': case 'G': case 'g': return parseTokenSignedNumber; 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': if (strict) { return parseTokenThreeDigits; } /* falls through */ case 'DDD': return 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 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); tempConfig._pf = defaultParsingFlags(); 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, l, string = config._i, match = isoRegex.exec(string); if (match) { config._pf.iso = true; for (i = 0, l = isoDates.length; i < l; i++) { if (isoDates[i][1].exec(string)) { // match[5] should be "T" or undefined config._f = isoDates[i][0] + (match[6] || " "); break; } } for (i = 0, l = isoTimes.length; i < l; 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) { var d = makeUTCDate(year, 0, 1).getUTCDay(), daysToAdd, dayOfYear; weekday = weekday != null ? weekday : firstDayOfWeek; daysToAdd = firstDayOfWeek - d + (d > firstDayOfWeekOfYear ? 7 : 0) - (d < firstDayOfWeek ? 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 (input === null) { return moment.invalid({nullInput: true}); } if (typeof input === 'string') { config._i = input = getLangDefinition().preparse(input); } if (moment.isMoment(input)) { config = cloneMoment(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) { var c; if (typeof(lang) === "boolean") { strict = lang; lang = undefined; } // object construction must be done this way. // https://github.com/moment/moment/issues/1423 c = {}; c._isAMomentObject = true; c._i = input; c._f = format; c._l = lang; c._strict = strict; c._isUTC = false; c._pf = defaultParsingFlags(); return makeMoment(c); }; // creating with utc moment.utc = function (input, format, lang, strict) { var c; if (typeof(lang) === "boolean") { strict = lang; lang = undefined; } // object construction must be done this way. // https://github.com/moment/moment/issues/1423 c = {}; c._isAMomentObject = true; c._useUTC = true; c._isUTC = true; c._l = lang; c._i = input; c._f = format; c._strict = strict; c._pf = defaultParsingFlags(); return makeMoment(c).utc(); }; // 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 || (obj != null && obj.hasOwnProperty('_isAMomentObject')); }; // 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); },{}],5:[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; },{}]},{},[1]) (1) });