| /** |
| * @file timeline.js |
| * |
| * @brief |
| * The Timeline is an interactive visualization chart to visualize events in |
| * time, having a start and end date. |
| * You can freely move and zoom in the timeline by dragging |
| * and scrolling in the Timeline. Items are optionally dragable. The time |
| * scale on the axis is adjusted automatically, and supports scales ranging |
| * from milliseconds to years. |
| * |
| * Timeline is part of the CHAP Links library. |
| * |
| * Timeline is tested on Firefox 3.6, Safari 5.0, Chrome 6.0, Opera 10.6, and |
| * Internet Explorer 6+. |
| * |
| * @license |
| * 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. |
| * |
| * Copyright (c) 2011-2013 Almende B.V. |
| * |
| * @author Jos de Jong, <jos@almende.org> |
| * @date 2013-08-20 |
| * @version 2.5.0 |
| */ |
| |
| /* |
| * i18n mods by github user iktuz (https://gist.github.com/iktuz/3749287/) |
| * added to v2.4.1 with da_DK language by @bjarkebech |
| */ |
| |
| /* |
| * TODO |
| * |
| * Add zooming with pinching on Android |
| * |
| * Bug: when an item contains a javascript onclick or a link, this does not work |
| * when the item is not selected (when the item is being selected, |
| * it is redrawn, which cancels any onclick or link action) |
| * Bug: when an item contains an image without size, or a css max-width, it is not sized correctly |
| * Bug: neglect items when they have no valid start/end, instead of throwing an error |
| * Bug: Pinching on ipad does not work very well, sometimes the page will zoom when pinching vertically |
| * Bug: cannot set max width for an item, like div.timeline-event-content {white-space: normal; max-width: 100px;} |
| * Bug on IE in Quirks mode. When you have groups, and delete an item, the groups become invisible |
| */ |
| |
| /** |
| * Declare a unique namespace for CHAP's Common Hybrid Visualisation Library, |
| * "links" |
| */ |
| if (typeof links === 'undefined') { |
| links = {}; |
| // important: do not use var, as "var links = {};" will overwrite |
| // the existing links variable value with undefined in IE8, IE7. |
| } |
| |
| |
| /** |
| * Ensure the variable google exists |
| */ |
| if (typeof google === 'undefined') { |
| google = undefined; |
| // important: do not use var, as "var google = undefined;" will overwrite |
| // the existing google variable value with undefined in IE8, IE7. |
| } |
| |
| |
| |
| // Internet Explorer 8 and older does not support Array.indexOf, |
| // so we define it here in that case |
| // http://soledadpenades.com/2007/05/17/arrayindexof-in-internet-explorer/ |
| if(!Array.prototype.indexOf) { |
| Array.prototype.indexOf = function(obj){ |
| for(var i = 0; i < this.length; i++){ |
| if(this[i] == obj){ |
| return i; |
| } |
| } |
| return -1; |
| } |
| } |
| |
| // Internet Explorer 8 and older does not support Array.forEach, |
| // so we define it here in that case |
| // https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/forEach |
| if (!Array.prototype.forEach) { |
| Array.prototype.forEach = function(fn, scope) { |
| for(var i = 0, len = this.length; i < len; ++i) { |
| fn.call(scope || this, this[i], i, this); |
| } |
| } |
| } |
| |
| |
| /** |
| * @constructor links.Timeline |
| * The timeline is a visualization chart to visualize events in time. |
| * |
| * The timeline is developed in javascript as a Google Visualization Chart. |
| * |
| * @param {Element} container The DOM element in which the Timeline will |
| * be created. Normally a div element. |
| */ |
| links.Timeline = function(container) { |
| if (!container) { |
| // this call was probably only for inheritance, no constructor-code is required |
| return; |
| } |
| |
| // create variables and set default values |
| this.dom = {}; |
| this.conversion = {}; |
| this.eventParams = {}; // stores parameters for mouse events |
| this.groups = []; |
| this.groupIndexes = {}; |
| this.items = []; |
| this.renderQueue = { |
| show: [], // Items made visible but not yet added to DOM |
| hide: [], // Items currently visible but not yet removed from DOM |
| update: [] // Items with changed data but not yet adjusted DOM |
| }; |
| this.renderedItems = []; // Items currently rendered in the DOM |
| this.clusterGenerator = new links.Timeline.ClusterGenerator(this); |
| this.currentClusters = []; |
| this.selection = undefined; // stores index and item which is currently selected |
| |
| this.listeners = {}; // event listener callbacks |
| |
| // Initialize sizes. |
| // Needed for IE (which gives an error when you try to set an undefined |
| // value in a style) |
| this.size = { |
| 'actualHeight': 0, |
| 'axis': { |
| 'characterMajorHeight': 0, |
| 'characterMajorWidth': 0, |
| 'characterMinorHeight': 0, |
| 'characterMinorWidth': 0, |
| 'height': 0, |
| 'labelMajorTop': 0, |
| 'labelMinorTop': 0, |
| 'line': 0, |
| 'lineMajorWidth': 0, |
| 'lineMinorHeight': 0, |
| 'lineMinorTop': 0, |
| 'lineMinorWidth': 0, |
| 'top': 0 |
| }, |
| 'contentHeight': 0, |
| 'contentLeft': 0, |
| 'contentWidth': 0, |
| 'frameHeight': 0, |
| 'frameWidth': 0, |
| 'groupsLeft': 0, |
| 'groupsWidth': 0, |
| 'items': { |
| 'top': 0 |
| } |
| }; |
| |
| this.dom.container = container; |
| |
| this.options = { |
| 'width': "100%", |
| 'height': "auto", |
| 'minHeight': 0, // minimal height in pixels |
| 'autoHeight': true, |
| |
| 'eventMargin': 10, // minimal margin between events |
| 'eventMarginAxis': 20, // minimal margin between events and the axis |
| 'dragAreaWidth': 10, // pixels |
| |
| 'min': undefined, |
| 'max': undefined, |
| 'zoomMin': 10, // milliseconds |
| 'zoomMax': 1000 * 60 * 60 * 24 * 365 * 10000, // milliseconds |
| |
| 'moveable': true, |
| 'zoomable': true, |
| 'selectable': true, |
| 'unselectable': true, |
| 'editable': false, |
| 'snapEvents': true, |
| 'groupChangeable': true, |
| |
| 'showCurrentTime': true, // show a red bar displaying the current time |
| 'showCustomTime': false, // show a blue, draggable bar displaying a custom time |
| 'showMajorLabels': true, |
| 'showMinorLabels': true, |
| 'showNavigation': false, |
| 'showButtonNew': false, |
| 'groupsOnRight': false, |
| 'axisOnTop': false, |
| 'stackEvents': true, |
| 'animate': true, |
| 'animateZoom': true, |
| 'cluster': false, |
| 'style': 'box', |
| 'customStackOrder': false, //a function(a,b) for determining stackorder amongst a group of items. Essentially a comparator, -ve value for "a before b" and vice versa |
| |
| // i18n: Timeline only has built-in English text per default. Include timeline-locales.js to support more localized text. |
| 'locale': 'en', |
| 'MONTHS': new Array("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"), |
| 'MONTHS_SHORT': new Array("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"), |
| 'DAYS': new Array("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"), |
| 'DAYS_SHORT': new Array("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"), |
| 'ZOOM_IN': "Zoom in", |
| 'ZOOM_OUT': "Zoom out", |
| 'MOVE_LEFT': "Move left", |
| 'MOVE_RIGHT': "Move right", |
| 'NEW': "New", |
| 'CREATE_NEW_EVENT': "Create new event" |
| }; |
| |
| this.clientTimeOffset = 0; // difference between client time and the time |
| // set via Timeline.setCurrentTime() |
| var dom = this.dom; |
| |
| // remove all elements from the container element. |
| while (dom.container.hasChildNodes()) { |
| dom.container.removeChild(dom.container.firstChild); |
| } |
| |
| // create a step for drawing the axis |
| this.step = new links.Timeline.StepDate(); |
| |
| // add standard item types |
| this.itemTypes = { |
| box: links.Timeline.ItemBox, |
| range: links.Timeline.ItemRange, |
| dot: links.Timeline.ItemDot |
| }; |
| |
| // initialize data |
| this.data = []; |
| this.firstDraw = true; |
| |
| // date interval must be initialized |
| this.setVisibleChartRange(undefined, undefined, false); |
| |
| // render for the first time |
| this.render(); |
| |
| // fire the ready event |
| var me = this; |
| setTimeout(function () { |
| me.trigger('ready'); |
| }, 0); |
| }; |
| |
| |
| /** |
| * Main drawing logic. This is the function that needs to be called |
| * in the html page, to draw the timeline. |
| * |
| * A data table with the events must be provided, and an options table. |
| * |
| * @param {google.visualization.DataTable} data |
| * The data containing the events for the timeline. |
| * Object DataTable is defined in |
| * google.visualization.DataTable |
| * @param {Object} options A name/value map containing settings for the |
| * timeline. Optional. |
| */ |
| links.Timeline.prototype.draw = function(data, options) { |
| this.setOptions(options); |
| |
| if (this.options.selectable) { |
| links.Timeline.addClassName(this.dom.frame, "timeline-selectable"); |
| } |
| |
| // read the data |
| this.setData(data); |
| |
| // set timer range. this will also redraw the timeline |
| if (options && (options.start || options.end)) { |
| this.setVisibleChartRange(options.start, options.end); |
| } |
| else if (this.firstDraw) { |
| this.setVisibleChartRangeAuto(); |
| } |
| |
| this.firstDraw = false; |
| }; |
| |
| |
| /** |
| * Set options for the timeline. |
| * Timeline must be redrawn afterwards |
| * @param {Object} options A name/value map containing settings for the |
| * timeline. Optional. |
| */ |
| links.Timeline.prototype.setOptions = function(options) { |
| if (options) { |
| // retrieve parameter values |
| for (var i in options) { |
| if (options.hasOwnProperty(i)) { |
| this.options[i] = options[i]; |
| } |
| } |
| |
| // prepare i18n dependent on set locale |
| if (typeof links.locales !== 'undefined' && this.options.locale !== 'en') { |
| var localeOpts = links.locales[this.options.locale]; |
| if(localeOpts) { |
| for (var l in localeOpts) { |
| if (localeOpts.hasOwnProperty(l)) { |
| this.options[l] = localeOpts[l]; |
| } |
| } |
| } |
| } |
| |
| // check for deprecated options |
| if (options.showButtonAdd != undefined) { |
| this.options.showButtonNew = options.showButtonAdd; |
| console.log('WARNING: Option showButtonAdd is deprecated. Use showButtonNew instead'); |
| } |
| if (options.intervalMin != undefined) { |
| this.options.zoomMin = options.intervalMin; |
| console.log('WARNING: Option intervalMin is deprecated. Use zoomMin instead'); |
| } |
| if (options.intervalMax != undefined) { |
| this.options.zoomMax = options.intervalMax; |
| console.log('WARNING: Option intervalMax is deprecated. Use zoomMax instead'); |
| } |
| |
| if (options.scale && options.step) { |
| this.step.setScale(options.scale, options.step); |
| } |
| } |
| |
| // validate options |
| this.options.autoHeight = (this.options.height === "auto"); |
| }; |
| |
| /** |
| * Add new type of items |
| * @param {String} typeName Name of new type |
| * @param {links.Timeline.Item} typeFactory Constructor of items |
| */ |
| links.Timeline.prototype.addItemType = function (typeName, typeFactory) { |
| this.itemTypes[typeName] = typeFactory; |
| }; |
| |
| /** |
| * Retrieve a map with the column indexes of the columns by column name. |
| * For example, the method returns the map |
| * { |
| * start: 0, |
| * end: 1, |
| * content: 2, |
| * group: undefined, |
| * className: undefined |
| * editable: undefined |
| * type: undefined |
| * } |
| * @param {google.visualization.DataTable} dataTable |
| * @type {Object} map |
| */ |
| links.Timeline.mapColumnIds = function (dataTable) { |
| var cols = {}, |
| colCount = dataTable.getNumberOfColumns(), |
| allUndefined = true; |
| |
| // loop over the columns, and map the column id's to the column indexes |
| for (var col = 0; col < colCount; col++) { |
| var id = dataTable.getColumnId(col) || dataTable.getColumnLabel(col); |
| cols[id] = col; |
| if (id == 'start' || id == 'end' || id == 'content' || id == 'group' || |
| id == 'className' || id == 'editable' || id == 'type') { |
| allUndefined = false; |
| } |
| } |
| |
| // if no labels or ids are defined, use the default mapping |
| // for start, end, content, group, className, editable, type |
| if (allUndefined) { |
| cols.start = 0; |
| cols.end = 1; |
| cols.content = 2; |
| if (colCount >= 3) {cols.group = 3} |
| if (colCount >= 4) {cols.className = 4} |
| if (colCount >= 5) {cols.editable = 5} |
| if (colCount >= 6) {cols.type = 6} |
| } |
| |
| return cols; |
| }; |
| |
| /** |
| * Set data for the timeline |
| * @param {google.visualization.DataTable | Array} data |
| */ |
| links.Timeline.prototype.setData = function(data) { |
| // unselect any previously selected item |
| this.unselectItem(); |
| |
| if (!data) { |
| data = []; |
| } |
| |
| // clear all data |
| this.stackCancelAnimation(); |
| this.clearItems(); |
| this.data = data; |
| var items = this.items; |
| this.deleteGroups(); |
| |
| if (google && google.visualization && |
| data instanceof google.visualization.DataTable) { |
| // map the datatable columns |
| var cols = links.Timeline.mapColumnIds(data); |
| |
| // read DataTable |
| for (var row = 0, rows = data.getNumberOfRows(); row < rows; row++) { |
| items.push(this.createItem({ |
| 'start': ((cols.start != undefined) ? data.getValue(row, cols.start) : undefined), |
| 'end': ((cols.end != undefined) ? data.getValue(row, cols.end) : undefined), |
| 'content': ((cols.content != undefined) ? data.getValue(row, cols.content) : undefined), |
| 'group': ((cols.group != undefined) ? data.getValue(row, cols.group) : undefined), |
| 'className': ((cols.className != undefined) ? data.getValue(row, cols.className) : undefined), |
| 'editable': ((cols.editable != undefined) ? data.getValue(row, cols.editable) : undefined), |
| 'type': ((cols.editable != undefined) ? data.getValue(row, cols.type) : undefined) |
| })); |
| } |
| } |
| else if (links.Timeline.isArray(data)) { |
| // read JSON array |
| for (var row = 0, rows = data.length; row < rows; row++) { |
| var itemData = data[row]; |
| var item = this.createItem(itemData); |
| items.push(item); |
| } |
| } |
| else { |
| throw "Unknown data type. DataTable or Array expected."; |
| } |
| |
| // prepare data for clustering, by filtering and sorting by type |
| if (this.options.cluster) { |
| this.clusterGenerator.setData(this.items); |
| } |
| |
| this.render({ |
| animate: false |
| }); |
| }; |
| |
| /** |
| * Return the original data table. |
| * @return {google.visualization.DataTable | Array} data |
| */ |
| links.Timeline.prototype.getData = function () { |
| return this.data; |
| }; |
| |
| |
| /** |
| * Update the original data with changed start, end or group. |
| * |
| * @param {Number} index |
| * @param {Object} values An object containing some of the following parameters: |
| * {Date} start, |
| * {Date} end, |
| * {String} content, |
| * {String} group |
| */ |
| links.Timeline.prototype.updateData = function (index, values) { |
| var data = this.data, |
| prop; |
| |
| if (google && google.visualization && |
| data instanceof google.visualization.DataTable) { |
| // update the original google DataTable |
| var missingRows = (index + 1) - data.getNumberOfRows(); |
| if (missingRows > 0) { |
| data.addRows(missingRows); |
| } |
| |
| // map the column id's by name |
| var cols = links.Timeline.mapColumnIds(data); |
| |
| // merge all fields from the provided data into the current data |
| for (prop in values) { |
| if (values.hasOwnProperty(prop)) { |
| var col = cols[prop]; |
| if (col == undefined) { |
| // create new column |
| var value = values[prop]; |
| var valueType = 'string'; |
| if (typeof(value) == 'number') {valueType = 'number';} |
| else if (typeof(value) == 'boolean') {valueType = 'boolean';} |
| else if (value instanceof Date) {valueType = 'datetime';} |
| col = data.addColumn(valueType, prop); |
| } |
| data.setValue(index, col, values[prop]); |
| |
| // TODO: correctly serialize the start and end Date to the desired type (Date, String, or Number) |
| } |
| } |
| } |
| else if (links.Timeline.isArray(data)) { |
| // update the original JSON table |
| var row = data[index]; |
| if (row == undefined) { |
| row = {}; |
| data[index] = row; |
| } |
| |
| // merge all fields from the provided data into the current data |
| for (prop in values) { |
| if (values.hasOwnProperty(prop)) { |
| row[prop] = values[prop]; |
| |
| // TODO: correctly serialize the start and end Date to the desired type (Date, String, or Number) |
| } |
| } |
| } |
| else { |
| throw "Cannot update data, unknown type of data"; |
| } |
| }; |
| |
| /** |
| * Find the item index from a given HTML element |
| * If no item index is found, undefined is returned |
| * @param {Element} element |
| * @return {Number | undefined} index |
| */ |
| links.Timeline.prototype.getItemIndex = function(element) { |
| var e = element, |
| dom = this.dom, |
| frame = dom.items.frame, |
| items = this.items, |
| index = undefined; |
| |
| // try to find the frame where the items are located in |
| while (e.parentNode && e.parentNode !== frame) { |
| e = e.parentNode; |
| } |
| |
| if (e.parentNode === frame) { |
| // yes! we have found the parent element of all items |
| // retrieve its id from the array with items |
| for (var i = 0, iMax = items.length; i < iMax; i++) { |
| if (items[i].dom === e) { |
| index = i; |
| break; |
| } |
| } |
| } |
| |
| return index; |
| }; |
| |
| /** |
| * Set a new size for the timeline |
| * @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%") |
| */ |
| links.Timeline.prototype.setSize = function(width, height) { |
| if (width) { |
| this.options.width = width; |
| this.dom.frame.style.width = width; |
| } |
| if (height) { |
| this.options.height = height; |
| this.options.autoHeight = (this.options.height === "auto"); |
| if (height !== "auto" ) { |
| this.dom.frame.style.height = height; |
| } |
| } |
| |
| this.render({ |
| animate: false |
| }); |
| }; |
| |
| |
| /** |
| * Set a new value for the visible range int the timeline. |
| * Set start undefined to include everything from the earliest date to end. |
| * Set end undefined to include everything from start to the last date. |
| * Example usage: |
| * myTimeline.setVisibleChartRange(new Date("2010-08-22"), |
| * new Date("2010-09-13")); |
| * @param {Date} start The start date for the timeline. optional |
| * @param {Date} end The end date for the timeline. optional |
| * @param {boolean} redraw Optional. If true (default) the Timeline is |
| * directly redrawn |
| */ |
| links.Timeline.prototype.setVisibleChartRange = function(start, end, redraw) { |
| var range = {}; |
| if (!start || !end) { |
| // retrieve the date range of the items |
| range = this.getDataRange(true); |
| } |
| |
| if (!start) { |
| if (end) { |
| if (range.min && range.min.valueOf() < end.valueOf()) { |
| // start of the data |
| start = range.min; |
| } |
| else { |
| // 7 days before the end |
| start = new Date(end.valueOf()); |
| start.setDate(start.getDate() - 7); |
| } |
| } |
| else { |
| // default of 3 days ago |
| start = new Date(); |
| start.setDate(start.getDate() - 3); |
| } |
| } |
| |
| if (!end) { |
| if (range.max) { |
| // end of the data |
| end = range.max; |
| } |
| else { |
| // 7 days after start |
| end = new Date(start.valueOf()); |
| end.setDate(end.getDate() + 7); |
| } |
| } |
| |
| // prevent start Date <= end Date |
| if (end <= start) { |
| end = new Date(start.valueOf()); |
| end.setDate(end.getDate() + 7); |
| } |
| |
| // limit to the allowed range (don't let this do by applyRange, |
| // because that method will try to maintain the interval (end-start) |
| var min = this.options.min ? this.options.min : undefined; // date |
| if (min != undefined && start.valueOf() < min.valueOf()) { |
| start = new Date(min.valueOf()); // date |
| } |
| var max = this.options.max ? this.options.max : undefined; // date |
| if (max != undefined && end.valueOf() > max.valueOf()) { |
| end = new Date(max.valueOf()); // date |
| } |
| |
| this.applyRange(start, end); |
| |
| if (redraw == undefined || redraw == true) { |
| this.render({ |
| animate: false |
| }); // TODO: optimize, no reflow needed |
| } |
| else { |
| this.recalcConversion(); |
| } |
| }; |
| |
| |
| /** |
| * Change the visible chart range such that all items become visible |
| */ |
| links.Timeline.prototype.setVisibleChartRangeAuto = function() { |
| var range = this.getDataRange(true); |
| this.setVisibleChartRange(range.min, range.max); |
| }; |
| |
| /** |
| * Adjust the visible range such that the current time is located in the center |
| * of the timeline |
| */ |
| links.Timeline.prototype.setVisibleChartRangeNow = function() { |
| var now = new Date(); |
| |
| var diff = (this.end.valueOf() - this.start.valueOf()); |
| |
| var startNew = new Date(now.valueOf() - diff/2); |
| var endNew = new Date(startNew.valueOf() + diff); |
| this.setVisibleChartRange(startNew, endNew); |
| }; |
| |
| |
| /** |
| * Retrieve the current visible range in the timeline. |
| * @return {Object} An object with start and end properties |
| */ |
| links.Timeline.prototype.getVisibleChartRange = function() { |
| return { |
| 'start': new Date(this.start.valueOf()), |
| 'end': new Date(this.end.valueOf()) |
| }; |
| }; |
| |
| /** |
| * Get the date range of the items. |
| * @param {boolean} [withMargin] If true, 5% of whitespace is added to the |
| * left and right of the range. Default is false. |
| * @return {Object} range An object with parameters min and max. |
| * - {Date} min is the lowest start date of the items |
| * - {Date} max is the highest start or end date of the items |
| * If no data is available, the values of min and max |
| * will be undefined |
| */ |
| links.Timeline.prototype.getDataRange = function (withMargin) { |
| var items = this.items, |
| min = undefined, // number |
| max = undefined; // number |
| |
| if (items) { |
| for (var i = 0, iMax = items.length; i < iMax; i++) { |
| var item = items[i], |
| start = item.start != undefined ? item.start.valueOf() : undefined, |
| end = item.end != undefined ? item.end.valueOf() : start; |
| |
| if (start != undefined) { |
| min = (min != undefined) ? Math.min(min.valueOf(), start.valueOf()) : start; |
| } |
| |
| if (end != undefined) { |
| max = (max != undefined) ? Math.max(max.valueOf(), end.valueOf()) : end; |
| } |
| } |
| } |
| |
| if (min && max && withMargin) { |
| // zoom out 5% such that you have a little white space on the left and right |
| var diff = (max - min); |
| min = min - diff * 0.05; |
| max = max + diff * 0.05; |
| } |
| |
| return { |
| 'min': min != undefined ? new Date(min) : undefined, |
| 'max': max != undefined ? new Date(max) : undefined |
| }; |
| }; |
| |
| /** |
| * Re-render (reflow and repaint) all components of the Timeline: frame, axis, |
| * items, ... |
| * @param {Object} [options] Available options: |
| * {boolean} renderTimesLeft Number of times the |
| * render may be repeated |
| * 5 times by default. |
| * {boolean} animate takes options.animate |
| * as default value |
| */ |
| links.Timeline.prototype.render = function(options) { |
| var frameResized = this.reflowFrame(); |
| var axisResized = this.reflowAxis(); |
| var groupsResized = this.reflowGroups(); |
| var itemsResized = this.reflowItems(); |
| var resized = (frameResized || axisResized || groupsResized || itemsResized); |
| |
| // TODO: only stackEvents/filterItems when resized or changed. (gives a bootstrap issue). |
| // if (resized) { |
| var animate = this.options.animate; |
| if (options && options.animate != undefined) { |
| animate = options.animate; |
| } |
| |
| this.recalcConversion(); |
| this.clusterItems(); |
| this.filterItems(); |
| this.stackItems(animate); |
| |
| this.recalcItems(); |
| |
| // TODO: only repaint when resized or when filterItems or stackItems gave a change? |
| var needsReflow = this.repaint(); |
| |
| // re-render once when needed (prevent endless re-render loop) |
| if (needsReflow) { |
| var renderTimesLeft = options ? options.renderTimesLeft : undefined; |
| if (renderTimesLeft == undefined) { |
| renderTimesLeft = 5; |
| } |
| if (renderTimesLeft > 0) { |
| this.render({ |
| 'animate': options ? options.animate: undefined, |
| 'renderTimesLeft': (renderTimesLeft - 1) |
| }); |
| } |
| } |
| }; |
| |
| /** |
| * Repaint all components of the Timeline |
| * @return {boolean} needsReflow Returns true if the DOM is changed such that |
| * a reflow is needed. |
| */ |
| links.Timeline.prototype.repaint = function() { |
| var frameNeedsReflow = this.repaintFrame(); |
| var axisNeedsReflow = this.repaintAxis(); |
| var groupsNeedsReflow = this.repaintGroups(); |
| var itemsNeedsReflow = this.repaintItems(); |
| this.repaintCurrentTime(); |
| this.repaintCustomTime(); |
| |
| return (frameNeedsReflow || axisNeedsReflow || groupsNeedsReflow || itemsNeedsReflow); |
| }; |
| |
| /** |
| * Reflow the timeline frame |
| * @return {boolean} resized Returns true if any of the frame elements |
| * have been resized. |
| */ |
| links.Timeline.prototype.reflowFrame = function() { |
| var dom = this.dom, |
| options = this.options, |
| size = this.size, |
| resized = false; |
| |
| // Note: IE7 has issues with giving frame.clientWidth, therefore I use offsetWidth instead |
| var frameWidth = dom.frame ? dom.frame.offsetWidth : 0, |
| frameHeight = dom.frame ? dom.frame.clientHeight : 0; |
| |
| resized = resized || (size.frameWidth !== frameWidth); |
| resized = resized || (size.frameHeight !== frameHeight); |
| size.frameWidth = frameWidth; |
| size.frameHeight = frameHeight; |
| |
| return resized; |
| }; |
| |
| /** |
| * repaint the Timeline frame |
| * @return {boolean} needsReflow Returns true if the DOM is changed such that |
| * a reflow is needed. |
| */ |
| links.Timeline.prototype.repaintFrame = function() { |
| var needsReflow = false, |
| dom = this.dom, |
| options = this.options, |
| size = this.size; |
| |
| // main frame |
| if (!dom.frame) { |
| dom.frame = document.createElement("DIV"); |
| dom.frame.className = "timeline-frame ui-widget ui-widget-content ui-corner-all"; |
| dom.frame.style.position = "relative"; |
| dom.frame.style.overflow = "hidden"; |
| dom.container.appendChild(dom.frame); |
| needsReflow = true; |
| } |
| |
| var height = options.autoHeight ? |
| (size.actualHeight + "px") : |
| (options.height || "100%"); |
| var width = options.width || "100%"; |
| needsReflow = needsReflow || (dom.frame.style.height != height); |
| needsReflow = needsReflow || (dom.frame.style.width != width); |
| dom.frame.style.height = height; |
| dom.frame.style.width = width; |
| |
| // contents |
| if (!dom.content) { |
| // create content box where the axis and items will be created |
| dom.content = document.createElement("DIV"); |
| dom.content.style.position = "relative"; |
| dom.content.style.overflow = "hidden"; |
| dom.frame.appendChild(dom.content); |
| |
| var timelines = document.createElement("DIV"); |
| timelines.style.position = "absolute"; |
| timelines.style.left = "0px"; |
| timelines.style.top = "0px"; |
| timelines.style.height = "100%"; |
| timelines.style.width = "0px"; |
| dom.content.appendChild(timelines); |
| dom.contentTimelines = timelines; |
| |
| var params = this.eventParams, |
| me = this; |
| if (!params.onMouseDown) { |
| params.onMouseDown = function (event) {me.onMouseDown(event);}; |
| links.Timeline.addEventListener(dom.content, "mousedown", params.onMouseDown); |
| } |
| if (!params.onTouchStart) { |
| params.onTouchStart = function (event) {me.onTouchStart(event);}; |
| links.Timeline.addEventListener(dom.content, "touchstart", params.onTouchStart); |
| } |
| if (!params.onMouseWheel) { |
| params.onMouseWheel = function (event) {me.onMouseWheel(event);}; |
| links.Timeline.addEventListener(dom.content, "mousewheel", params.onMouseWheel); |
| } |
| if (!params.onDblClick) { |
| params.onDblClick = function (event) {me.onDblClick(event);}; |
| links.Timeline.addEventListener(dom.content, "dblclick", params.onDblClick); |
| } |
| |
| needsReflow = true; |
| } |
| dom.content.style.left = size.contentLeft + "px"; |
| dom.content.style.top = "0px"; |
| dom.content.style.width = size.contentWidth + "px"; |
| dom.content.style.height = size.frameHeight + "px"; |
| |
| this.repaintNavigation(); |
| |
| return needsReflow; |
| }; |
| |
| /** |
| * Reflow the timeline axis. Calculate its height, width, positioning, etc... |
| * @return {boolean} resized returns true if the axis is resized |
| */ |
| links.Timeline.prototype.reflowAxis = function() { |
| var resized = false, |
| dom = this.dom, |
| options = this.options, |
| size = this.size, |
| axisDom = dom.axis; |
| |
| var characterMinorWidth = (axisDom && axisDom.characterMinor) ? axisDom.characterMinor.clientWidth : 0, |
| characterMinorHeight = (axisDom && axisDom.characterMinor) ? axisDom.characterMinor.clientHeight : 0, |
| characterMajorWidth = (axisDom && axisDom.characterMajor) ? axisDom.characterMajor.clientWidth : 0, |
| characterMajorHeight = (axisDom && axisDom.characterMajor) ? axisDom.characterMajor.clientHeight : 0, |
| axisHeight = (options.showMinorLabels ? characterMinorHeight : 0) + |
| (options.showMajorLabels ? characterMajorHeight : 0); |
| |
| var axisTop = options.axisOnTop ? 0 : size.frameHeight - axisHeight, |
| axisLine = options.axisOnTop ? axisHeight : axisTop; |
| |
| resized = resized || (size.axis.top !== axisTop); |
| resized = resized || (size.axis.line !== axisLine); |
| resized = resized || (size.axis.height !== axisHeight); |
| size.axis.top = axisTop; |
| size.axis.line = axisLine; |
| size.axis.height = axisHeight; |
| size.axis.labelMajorTop = options.axisOnTop ? 0 : axisLine + |
| (options.showMinorLabels ? characterMinorHeight : 0); |
| size.axis.labelMinorTop = options.axisOnTop ? |
| (options.showMajorLabels ? characterMajorHeight : 0) : |
| axisLine; |
| size.axis.lineMinorTop = options.axisOnTop ? size.axis.labelMinorTop : 0; |
| size.axis.lineMinorHeight = options.showMajorLabels ? |
| size.frameHeight - characterMajorHeight: |
| size.frameHeight; |
| if (axisDom && axisDom.minorLines && axisDom.minorLines.length) { |
| size.axis.lineMinorWidth = axisDom.minorLines[0].offsetWidth; |
| } |
| else { |
| size.axis.lineMinorWidth = 1; |
| } |
| if (axisDom && axisDom.majorLines && axisDom.majorLines.length) { |
| size.axis.lineMajorWidth = axisDom.majorLines[0].offsetWidth; |
| } |
| else { |
| size.axis.lineMajorWidth = 1; |
| } |
| |
| resized = resized || (size.axis.characterMinorWidth !== characterMinorWidth); |
| resized = resized || (size.axis.characterMinorHeight !== characterMinorHeight); |
| resized = resized || (size.axis.characterMajorWidth !== characterMajorWidth); |
| resized = resized || (size.axis.characterMajorHeight !== characterMajorHeight); |
| size.axis.characterMinorWidth = characterMinorWidth; |
| size.axis.characterMinorHeight = characterMinorHeight; |
| size.axis.characterMajorWidth = characterMajorWidth; |
| size.axis.characterMajorHeight = characterMajorHeight; |
| |
| var contentHeight = Math.max(size.frameHeight - axisHeight, 0); |
| size.contentLeft = options.groupsOnRight ? 0 : size.groupsWidth; |
| size.contentWidth = Math.max(size.frameWidth - size.groupsWidth, 0); |
| size.contentHeight = contentHeight; |
| |
| return resized; |
| }; |
| |
| /** |
| * Redraw the timeline axis with minor and major labels |
| * @return {boolean} needsReflow Returns true if the DOM is changed such |
| * that a reflow is needed. |
| */ |
| links.Timeline.prototype.repaintAxis = function() { |
| var needsReflow = false, |
| dom = this.dom, |
| options = this.options, |
| size = this.size, |
| step = this.step; |
| |
| var axis = dom.axis; |
| if (!axis) { |
| axis = {}; |
| dom.axis = axis; |
| } |
| if (!size.axis.properties) { |
| size.axis.properties = {}; |
| } |
| if (!axis.minorTexts) { |
| axis.minorTexts = []; |
| } |
| if (!axis.minorLines) { |
| axis.minorLines = []; |
| } |
| if (!axis.majorTexts) { |
| axis.majorTexts = []; |
| } |
| if (!axis.majorLines) { |
| axis.majorLines = []; |
| } |
| |
| if (!axis.frame) { |
| axis.frame = document.createElement("DIV"); |
| axis.frame.style.position = "absolute"; |
| axis.frame.style.left = "0px"; |
| axis.frame.style.top = "0px"; |
| dom.content.appendChild(axis.frame); |
| } |
| |
| // take axis offline |
| dom.content.removeChild(axis.frame); |
| |
| axis.frame.style.width = (size.contentWidth) + "px"; |
| axis.frame.style.height = (size.axis.height) + "px"; |
| |
| // the drawn axis is more wide than the actual visual part, such that |
| // the axis can be dragged without having to redraw it each time again. |
| var start = this.screenToTime(0); |
| var end = this.screenToTime(size.contentWidth); |
| |
| // calculate minimum step (in milliseconds) based on character size |
| if (size.axis.characterMinorWidth) { |
| this.minimumStep = this.screenToTime(size.axis.characterMinorWidth * 6) - |
| this.screenToTime(0); |
| |
| step.setRange(start, end, this.minimumStep); |
| } |
| |
| var charsNeedsReflow = this.repaintAxisCharacters(); |
| needsReflow = needsReflow || charsNeedsReflow; |
| |
| // The current labels on the axis will be re-used (much better performance), |
| // therefore, the repaintAxis method uses the mechanism with |
| // repaintAxisStartOverwriting, repaintAxisEndOverwriting, and |
| // this.size.axis.properties is used. |
| this.repaintAxisStartOverwriting(); |
| |
| step.start(); |
| var xFirstMajorLabel = undefined; |
| var max = 0; |
| while (!step.end() && max < 1000) { |
| max++; |
| var cur = step.getCurrent(), |
| x = this.timeToScreen(cur), |
| isMajor = step.isMajor(); |
| |
| if (options.showMinorLabels) { |
| this.repaintAxisMinorText(x, step.getLabelMinor(options)); |
| } |
| |
| if (isMajor && options.showMajorLabels) { |
| if (x > 0) { |
| if (xFirstMajorLabel == undefined) { |
| xFirstMajorLabel = x; |
| } |
| this.repaintAxisMajorText(x, step.getLabelMajor(options)); |
| } |
| this.repaintAxisMajorLine(x); |
| } |
| else { |
| this.repaintAxisMinorLine(x); |
| } |
| |
| step.next(); |
| } |
| |
| // create a major label on the left when needed |
| if (options.showMajorLabels) { |
| var leftTime = this.screenToTime(0), |
| leftText = this.step.getLabelMajor(options, leftTime), |
| width = leftText.length * size.axis.characterMajorWidth + 10; // upper bound estimation |
| |
| if (xFirstMajorLabel == undefined || width < xFirstMajorLabel) { |
| this.repaintAxisMajorText(0, leftText, leftTime); |
| } |
| } |
| |
| // cleanup left over labels |
| this.repaintAxisEndOverwriting(); |
| |
| this.repaintAxisHorizontal(); |
| |
| // put axis online |
| dom.content.insertBefore(axis.frame, dom.content.firstChild); |
| |
| return needsReflow; |
| }; |
| |
| /** |
| * Create characters used to determine the size of text on the axis |
| * @return {boolean} needsReflow Returns true if the DOM is changed such that |
| * a reflow is needed. |
| */ |
| links.Timeline.prototype.repaintAxisCharacters = 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 needsReflow = false, |
| dom = this.dom, |
| axis = dom.axis, |
| text; |
| |
| if (!axis.characterMinor) { |
| text = document.createTextNode("0"); |
| var characterMinor = document.createElement("DIV"); |
| characterMinor.className = "timeline-axis-text timeline-axis-text-minor"; |
| characterMinor.appendChild(text); |
| characterMinor.style.position = "absolute"; |
| characterMinor.style.visibility = "hidden"; |
| characterMinor.style.paddingLeft = "0px"; |
| characterMinor.style.paddingRight = "0px"; |
| axis.frame.appendChild(characterMinor); |
| |
| axis.characterMinor = characterMinor; |
| needsReflow = true; |
| } |
| |
| if (!axis.characterMajor) { |
| text = document.createTextNode("0"); |
| var characterMajor = document.createElement("DIV"); |
| characterMajor.className = "timeline-axis-text timeline-axis-text-major"; |
| characterMajor.appendChild(text); |
| characterMajor.style.position = "absolute"; |
| characterMajor.style.visibility = "hidden"; |
| characterMajor.style.paddingLeft = "0px"; |
| characterMajor.style.paddingRight = "0px"; |
| axis.frame.appendChild(characterMajor); |
| |
| axis.characterMajor = characterMajor; |
| needsReflow = true; |
| } |
| |
| return needsReflow; |
| }; |
| |
| /** |
| * Initialize redraw of the axis. All existing labels and lines will be |
| * overwritten and reused. |
| */ |
| links.Timeline.prototype.repaintAxisStartOverwriting = function () { |
| var properties = this.size.axis.properties; |
| |
| properties.minorTextNum = 0; |
| properties.minorLineNum = 0; |
| properties.majorTextNum = 0; |
| properties.majorLineNum = 0; |
| }; |
| |
| /** |
| * End of overwriting HTML DOM elements of the axis. |
| * remaining elements will be removed |
| */ |
| links.Timeline.prototype.repaintAxisEndOverwriting = function () { |
| var dom = this.dom, |
| props = this.size.axis.properties, |
| frame = this.dom.axis.frame, |
| num; |
| |
| // remove leftovers |
| var minorTexts = dom.axis.minorTexts; |
| num = props.minorTextNum; |
| while (minorTexts.length > num) { |
| var minorText = minorTexts[num]; |
| frame.removeChild(minorText); |
| minorTexts.splice(num, 1); |
| } |
| |
| var minorLines = dom.axis.minorLines; |
| num = props.minorLineNum; |
| while (minorLines.length > num) { |
| var minorLine = minorLines[num]; |
| frame.removeChild(minorLine); |
| minorLines.splice(num, 1); |
| } |
| |
| var majorTexts = dom.axis.majorTexts; |
| num = props.majorTextNum; |
| while (majorTexts.length > num) { |
| var majorText = majorTexts[num]; |
| frame.removeChild(majorText); |
| majorTexts.splice(num, 1); |
| } |
| |
| var majorLines = dom.axis.majorLines; |
| num = props.majorLineNum; |
| while (majorLines.length > num) { |
| var majorLine = majorLines[num]; |
| frame.removeChild(majorLine); |
| majorLines.splice(num, 1); |
| } |
| }; |
| |
| /** |
| * Repaint the horizontal line and background of the axis |
| */ |
| links.Timeline.prototype.repaintAxisHorizontal = function() { |
| var axis = this.dom.axis, |
| size = this.size, |
| options = this.options; |
| |
| // line behind all axis elements (possibly having a background color) |
| var hasAxis = (options.showMinorLabels || options.showMajorLabels); |
| if (hasAxis) { |
| if (!axis.backgroundLine) { |
| // create the axis line background (for a background color or so) |
| var backgroundLine = document.createElement("DIV"); |
| backgroundLine.className = "timeline-axis"; |
| backgroundLine.style.position = "absolute"; |
| backgroundLine.style.left = "0px"; |
| backgroundLine.style.width = "100%"; |
| backgroundLine.style.border = "none"; |
| axis.frame.insertBefore(backgroundLine, axis.frame.firstChild); |
| |
| axis.backgroundLine = backgroundLine; |
| } |
| |
| if (axis.backgroundLine) { |
| axis.backgroundLine.style.top = size.axis.top + "px"; |
| axis.backgroundLine.style.height = size.axis.height + "px"; |
| } |
| } |
| else { |
| if (axis.backgroundLine) { |
| axis.frame.removeChild(axis.backgroundLine); |
| delete axis.backgroundLine; |
| } |
| } |
| |
| // line before all axis elements |
| if (hasAxis) { |
| if (axis.line) { |
| // put this line at the end of all childs |
| var line = axis.frame.removeChild(axis.line); |
| axis.frame.appendChild(line); |
| } |
| else { |
| // make the axis line |
| var line = document.createElement("DIV"); |
| line.className = "timeline-axis"; |
| line.style.position = "absolute"; |
| line.style.left = "0px"; |
| line.style.width = "100%"; |
| line.style.height = "0px"; |
| axis.frame.appendChild(line); |
| |
| axis.line = line; |
| } |
| |
| axis.line.style.top = size.axis.line + "px"; |
| } |
| else { |
| if (axis.line && axis.line.parentElement) { |
| axis.frame.removeChild(axis.line); |
| delete axis.line; |
| } |
| } |
| }; |
| |
| /** |
| * Create a minor label for the axis at position x |
| * @param {Number} x |
| * @param {String} text |
| */ |
| links.Timeline.prototype.repaintAxisMinorText = function (x, text) { |
| var size = this.size, |
| dom = this.dom, |
| props = size.axis.properties, |
| frame = dom.axis.frame, |
| minorTexts = dom.axis.minorTexts, |
| index = props.minorTextNum, |
| label; |
| |
| if (index < minorTexts.length) { |
| label = minorTexts[index] |
| } |
| else { |
| // create new label |
| var content = document.createTextNode(""); |
| label = document.createElement("DIV"); |
| label.appendChild(content); |
| label.className = "timeline-axis-text timeline-axis-text-minor"; |
| label.style.position = "absolute"; |
| |
| frame.appendChild(label); |
| |
| minorTexts.push(label); |
| } |
| |
| label.childNodes[0].nodeValue = text; |
| label.style.left = x + "px"; |
| label.style.top = size.axis.labelMinorTop + "px"; |
| //label.title = title; // TODO: this is a heavy operation |
| |
| props.minorTextNum++; |
| }; |
| |
| /** |
| * Create a minor line for the axis at position x |
| * @param {Number} x |
| */ |
| links.Timeline.prototype.repaintAxisMinorLine = function (x) { |
| var axis = this.size.axis, |
| dom = this.dom, |
| props = axis.properties, |
| frame = dom.axis.frame, |
| minorLines = dom.axis.minorLines, |
| index = props.minorLineNum, |
| line; |
| |
| if (index < minorLines.length) { |
| line = minorLines[index]; |
| } |
| else { |
| // create vertical line |
| line = document.createElement("DIV"); |
| line.className = "timeline-axis-grid timeline-axis-grid-minor"; |
| line.style.position = "absolute"; |
| line.style.width = "0px"; |
| |
| frame.appendChild(line); |
| minorLines.push(line); |
| } |
| |
| line.style.top = axis.lineMinorTop + "px"; |
| line.style.height = axis.lineMinorHeight + "px"; |
| line.style.left = (x - axis.lineMinorWidth/2) + "px"; |
| |
| props.minorLineNum++; |
| }; |
| |
| /** |
| * Create a Major label for the axis at position x |
| * @param {Number} x |
| * @param {String} text |
| */ |
| links.Timeline.prototype.repaintAxisMajorText = function (x, text) { |
| var size = this.size, |
| props = size.axis.properties, |
| frame = this.dom.axis.frame, |
| majorTexts = this.dom.axis.majorTexts, |
| index = props.majorTextNum, |
| label; |
| |
| if (index < majorTexts.length) { |
| label = majorTexts[index]; |
| } |
| else { |
| // create label |
| var content = document.createTextNode(text); |
| label = document.createElement("DIV"); |
| label.className = "timeline-axis-text timeline-axis-text-major"; |
| label.appendChild(content); |
| label.style.position = "absolute"; |
| label.style.top = "0px"; |
| |
| frame.appendChild(label); |
| majorTexts.push(label); |
| } |
| |
| label.childNodes[0].nodeValue = text; |
| label.style.top = size.axis.labelMajorTop + "px"; |
| label.style.left = x + "px"; |
| //label.title = title; // TODO: this is a heavy operation |
| |
| props.majorTextNum ++; |
| }; |
| |
| /** |
| * Create a Major line for the axis at position x |
| * @param {Number} x |
| */ |
| links.Timeline.prototype.repaintAxisMajorLine = function (x) { |
| var size = this.size, |
| props = size.axis.properties, |
| axis = this.size.axis, |
| frame = this.dom.axis.frame, |
| majorLines = this.dom.axis.majorLines, |
| index = props.majorLineNum, |
| line; |
| |
| if (index < majorLines.length) { |
| line = majorLines[index]; |
| } |
| else { |
| // create vertical line |
| line = document.createElement("DIV"); |
| line.className = "timeline-axis-grid timeline-axis-grid-major"; |
| line.style.position = "absolute"; |
| line.style.top = "0px"; |
| line.style.width = "0px"; |
| |
| frame.appendChild(line); |
| majorLines.push(line); |
| } |
| |
| line.style.left = (x - axis.lineMajorWidth/2) + "px"; |
| line.style.height = size.frameHeight + "px"; |
| |
| props.majorLineNum ++; |
| }; |
| |
| /** |
| * Reflow all items, retrieve their actual size |
| * @return {boolean} resized returns true if any of the items is resized |
| */ |
| links.Timeline.prototype.reflowItems = function() { |
| var resized = false, |
| i, |
| iMax, |
| group, |
| groups = this.groups, |
| renderedItems = this.renderedItems; |
| |
| if (groups) { // TODO: need to check if labels exists? |
| // loop through all groups to reset the items height |
| groups.forEach(function (group) { |
| group.itemsHeight = 0; |
| }); |
| } |
| |
| // loop through the width and height of all visible items |
| for (i = 0, iMax = renderedItems.length; i < iMax; i++) { |
| var item = renderedItems[i], |
| domItem = item.dom; |
| group = item.group; |
| |
| if (domItem) { |
| // TODO: move updating width and height into item.reflow |
| var width = domItem ? domItem.clientWidth : 0; |
| var height = domItem ? domItem.clientHeight : 0; |
| resized = resized || (item.width != width); |
| resized = resized || (item.height != height); |
| item.width = width; |
| item.height = height; |
| //item.borderWidth = (domItem.offsetWidth - domItem.clientWidth - 2) / 2; // TODO: borderWidth |
| item.reflow(); |
| } |
| |
| if (group) { |
| group.itemsHeight = group.itemsHeight ? |
| Math.max(group.itemsHeight, item.height) : |
| item.height; |
| } |
| } |
| |
| return resized; |
| }; |
| |
| /** |
| * Recalculate item properties: |
| * - the height of each group. |
| * - the actualHeight, from the stacked items or the sum of the group heights |
| * @return {boolean} resized returns true if any of the items properties is |
| * changed |
| */ |
| links.Timeline.prototype.recalcItems = function () { |
| var resized = false, |
| i, |
| iMax, |
| item, |
| finalItem, |
| finalItems, |
| group, |
| groups = this.groups, |
| size = this.size, |
| options = this.options, |
| renderedItems = this.renderedItems; |
| |
| var actualHeight = 0; |
| if (groups.length == 0) { |
| // calculate actual height of the timeline when there are no groups |
| // but stacked items |
| if (options.autoHeight || options.cluster) { |
| var min = 0, |
| max = 0; |
| |
| if (this.stack && this.stack.finalItems) { |
| // adjust the offset of all finalItems when the actualHeight has been changed |
| finalItems = this.stack.finalItems; |
| finalItem = finalItems[0]; |
| if (finalItem && finalItem.top) { |
| min = finalItem.top; |
| max = finalItem.top + finalItem.height; |
| } |
| for (i = 1, iMax = finalItems.length; i < iMax; i++) { |
| finalItem = finalItems[i]; |
| min = Math.min(min, finalItem.top); |
| max = Math.max(max, finalItem.top + finalItem.height); |
| } |
| } |
| else { |
| item = renderedItems[0]; |
| if (item && item.top) { |
| min = item.top; |
| max = item.top + item.height; |
| } |
| for (i = 1, iMax = renderedItems.length; i < iMax; i++) { |
| item = renderedItems[i]; |
| if (item.top) { |
| min = Math.min(min, item.top); |
| max = Math.max(max, (item.top + item.height)); |
| } |
| } |
| } |
| |
| actualHeight = (max - min) + 2 * options.eventMarginAxis + size.axis.height; |
| if (actualHeight < options.minHeight) { |
| actualHeight = options.minHeight; |
| } |
| |
| if (size.actualHeight != actualHeight && options.autoHeight && !options.axisOnTop) { |
| // adjust the offset of all items when the actualHeight has been changed |
| var diff = actualHeight - size.actualHeight; |
| if (this.stack && this.stack.finalItems) { |
| finalItems = this.stack.finalItems; |
| for (i = 0, iMax = finalItems.length; i < iMax; i++) { |
| finalItems[i].top += diff; |
| finalItems[i].item.top += diff; |
| } |
| } |
| else { |
| for (i = 0, iMax = renderedItems.length; i < iMax; i++) { |
| renderedItems[i].top += diff; |
| } |
| } |
| } |
| } |
| } |
| else { |
| // loop through all groups to get the height of each group, and the |
| // total height |
| actualHeight = size.axis.height + 2 * options.eventMarginAxis; |
| for (i = 0, iMax = groups.length; i < iMax; i++) { |
| group = groups[i]; |
| |
| var groupHeight = Math.max(group.labelHeight || 0, group.itemsHeight || 0); |
| resized = resized || (groupHeight != group.height); |
| group.height = groupHeight; |
| |
| actualHeight += groups[i].height + options.eventMargin; |
| } |
| |
| // calculate top positions of the group labels and lines |
| var eventMargin = options.eventMargin, |
| top = options.axisOnTop ? |
| options.eventMarginAxis + eventMargin/2 : |
| size.contentHeight - options.eventMarginAxis + eventMargin/ 2, |
| axisHeight = size.axis.height; |
| |
| for (i = 0, iMax = groups.length; i < iMax; i++) { |
| group = groups[i]; |
| if (options.axisOnTop) { |
| group.top = top + axisHeight; |
| group.labelTop = top + axisHeight + (group.height - group.labelHeight) / 2; |
| group.lineTop = top + axisHeight + group.height + eventMargin/2; |
| top += group.height + eventMargin; |
| } |
| else { |
| top -= group.height + eventMargin; |
| group.top = top; |
| group.labelTop = top + (group.height - group.labelHeight) / 2; |
| group.lineTop = top - eventMargin/2; |
| } |
| } |
| |
| // calculate top position of the visible items |
| for (i = 0, iMax = renderedItems.length; i < iMax; i++) { |
| item = renderedItems[i]; |
| group = item.group; |
| |
| if (group) { |
| item.top = group.top; |
| } |
| } |
| |
| resized = true; |
| } |
| |
| if (actualHeight < options.minHeight) { |
| actualHeight = options.minHeight; |
| } |
| resized = resized || (actualHeight != size.actualHeight); |
| size.actualHeight = actualHeight; |
| |
| return resized; |
| }; |
| |
| /** |
| * This method clears the (internal) array this.items in a safe way: neatly |
| * cleaning up the DOM, and accompanying arrays this.renderedItems and |
| * the created clusters. |
| */ |
| links.Timeline.prototype.clearItems = function() { |
| // add all visible items to the list to be hidden |
| var hideItems = this.renderQueue.hide; |
| this.renderedItems.forEach(function (item) { |
| hideItems.push(item); |
| }); |
| |
| // clear the cluster generator |
| this.clusterGenerator.clear(); |
| |
| // actually clear the items |
| this.items = []; |
| }; |
| |
| /** |
| * Repaint all items |
| * @return {boolean} needsReflow Returns true if the DOM is changed such that |
| * a reflow is needed. |
| */ |
| links.Timeline.prototype.repaintItems = function() { |
| var i, iMax, item, index; |
| |
| var needsReflow = false, |
| dom = this.dom, |
| size = this.size, |
| timeline = this, |
| renderedItems = this.renderedItems; |
| |
| if (!dom.items) { |
| dom.items = {}; |
| } |
| |
| // draw the frame containing the items |
| var frame = dom.items.frame; |
| if (!frame) { |
| frame = document.createElement("DIV"); |
| frame.style.position = "relative"; |
| dom.content.appendChild(frame); |
| dom.items.frame = frame; |
| } |
| |
| frame.style.left = "0px"; |
| frame.style.top = size.items.top + "px"; |
| frame.style.height = "0px"; |
| |
| // Take frame offline (for faster manipulation of the DOM) |
| dom.content.removeChild(frame); |
| |
| // process the render queue with changes |
| var queue = this.renderQueue; |
| var newImageUrls = []; |
| needsReflow = needsReflow || |
| (queue.show.length > 0) || |
| (queue.update.length > 0) || |
| (queue.hide.length > 0); // TODO: reflow needed on hide of items? |
| |
| while (item = queue.show.shift()) { |
| item.showDOM(frame); |
| item.getImageUrls(newImageUrls); |
| renderedItems.push(item); |
| } |
| while (item = queue.update.shift()) { |
| item.updateDOM(frame); |
| item.getImageUrls(newImageUrls); |
| index = this.renderedItems.indexOf(item); |
| if (index == -1) { |
| renderedItems.push(item); |
| } |
| } |
| while (item = queue.hide.shift()) { |
| item.hideDOM(frame); |
| index = this.renderedItems.indexOf(item); |
| if (index != -1) { |
| renderedItems.splice(index, 1); |
| } |
| } |
| |
| // reposition all visible items |
| renderedItems.forEach(function (item) { |
| item.updatePosition(timeline); |
| }); |
| |
| // redraw the delete button and dragareas of the selected item (if any) |
| this.repaintDeleteButton(); |
| this.repaintDragAreas(); |
| |
| // put frame online again |
| dom.content.appendChild(frame); |
| |
| if (newImageUrls.length) { |
| // retrieve all image sources from the items, and set a callback once |
| // all images are retrieved |
| var callback = function () { |
| timeline.render(); |
| }; |
| var sendCallbackWhenAlreadyLoaded = false; |
| links.imageloader.loadAll(newImageUrls, callback, sendCallbackWhenAlreadyLoaded); |
| } |
| |
| return needsReflow; |
| }; |
| |
| /** |
| * Reflow the size of the groups |
| * @return {boolean} resized Returns true if any of the frame elements |
| * have been resized. |
| */ |
| links.Timeline.prototype.reflowGroups = function() { |
| var resized = false, |
| options = this.options, |
| size = this.size, |
| dom = this.dom; |
| |
| // calculate the groups width and height |
| // TODO: only update when data is changed! -> use an updateSeq |
| var groupsWidth = 0; |
| |
| // loop through all groups to get the labels width and height |
| var groups = this.groups; |
| var labels = this.dom.groups ? this.dom.groups.labels : []; |
| for (var i = 0, iMax = groups.length; i < iMax; i++) { |
| var group = groups[i]; |
| var label = labels[i]; |
| group.labelWidth = label ? label.clientWidth : 0; |
| group.labelHeight = label ? label.clientHeight : 0; |
| group.width = group.labelWidth; // TODO: group.width is redundant with labelWidth |
| |
| groupsWidth = Math.max(groupsWidth, group.width); |
| } |
| |
| // limit groupsWidth to the groups width in the options |
| if (options.groupsWidth !== undefined) { |
| groupsWidth = dom.groups.frame ? dom.groups.frame.clientWidth : 0; |
| } |
| |
| // compensate for the border width. TODO: calculate the real border width |
| groupsWidth += 1; |
| |
| var groupsLeft = options.groupsOnRight ? size.frameWidth - groupsWidth : 0; |
| resized = resized || (size.groupsWidth !== groupsWidth); |
| resized = resized || (size.groupsLeft !== groupsLeft); |
| size.groupsWidth = groupsWidth; |
| size.groupsLeft = groupsLeft; |
| |
| return resized; |
| }; |
| |
| /** |
| * Redraw the group labels |
| */ |
| links.Timeline.prototype.repaintGroups = function() { |
| var dom = this.dom, |
| timeline = this, |
| options = this.options, |
| size = this.size, |
| groups = this.groups; |
| |
| if (dom.groups === undefined) { |
| dom.groups = {}; |
| } |
| |
| var labels = dom.groups.labels; |
| if (!labels) { |
| labels = []; |
| dom.groups.labels = labels; |
| } |
| var labelLines = dom.groups.labelLines; |
| if (!labelLines) { |
| labelLines = []; |
| dom.groups.labelLines = labelLines; |
| } |
| var itemLines = dom.groups.itemLines; |
| if (!itemLines) { |
| itemLines = []; |
| dom.groups.itemLines = itemLines; |
| } |
| |
| // create the frame for holding the groups |
| var frame = dom.groups.frame; |
| if (!frame) { |
| frame = document.createElement("DIV"); |
| frame.className = "timeline-groups-axis"; |
| frame.style.position = "absolute"; |
| frame.style.overflow = "hidden"; |
| frame.style.top = "0px"; |
| frame.style.height = "100%"; |
| |
| dom.frame.appendChild(frame); |
| dom.groups.frame = frame; |
| } |
| |
| frame.style.left = size.groupsLeft + "px"; |
| frame.style.width = (options.groupsWidth !== undefined) ? |
| options.groupsWidth : |
| size.groupsWidth + "px"; |
| |
| // hide groups axis when there are no groups |
| if (groups.length == 0) { |
| frame.style.display = 'none'; |
| } |
| else { |
| frame.style.display = ''; |
| } |
| |
| // TODO: only create/update groups when data is changed. |
| |
| // create the items |
| var current = labels.length, |
| needed = groups.length; |
| |
| // overwrite existing group labels |
| for (var i = 0, iMax = Math.min(current, needed); i < iMax; i++) { |
| var group = groups[i]; |
| var label = labels[i]; |
| label.innerHTML = this.getGroupName(group); |
| label.style.display = ''; |
| } |
| |
| // append new items when needed |
| for (var i = current; i < needed; i++) { |
| var group = groups[i]; |
| |
| // create text label |
| var label = document.createElement("DIV"); |
| label.className = "timeline-groups-text"; |
| label.style.position = "absolute"; |
| if (options.groupsWidth === undefined) { |
| label.style.whiteSpace = "nowrap"; |
| } |
| label.innerHTML = this.getGroupName(group); |
| frame.appendChild(label); |
| labels[i] = label; |
| |
| // create the grid line between the group labels |
| var labelLine = document.createElement("DIV"); |
| labelLine.className = "timeline-axis-grid timeline-axis-grid-minor"; |
| labelLine.style.position = "absolute"; |
| labelLine.style.left = "0px"; |
| labelLine.style.width = "100%"; |
| labelLine.style.height = "0px"; |
| labelLine.style.borderTopStyle = "solid"; |
| frame.appendChild(labelLine); |
| labelLines[i] = labelLine; |
| |
| // create the grid line between the items |
| var itemLine = document.createElement("DIV"); |
| itemLine.className = "timeline-axis-grid timeline-axis-grid-minor"; |
| itemLine.style.position = "absolute"; |
| itemLine.style.left = "0px"; |
| itemLine.style.width = "100%"; |
| itemLine.style.height = "0px"; |
| itemLine.style.borderTopStyle = "solid"; |
| dom.content.insertBefore(itemLine, dom.content.firstChild); |
| itemLines[i] = itemLine; |
| } |
| |
| // remove redundant items from the DOM when needed |
| for (var i = needed; i < current; i++) { |
| var label = labels[i], |
| labelLine = labelLines[i], |
| itemLine = itemLines[i]; |
| |
| frame.removeChild(label); |
| frame.removeChild(labelLine); |
| dom.content.removeChild(itemLine); |
| } |
| labels.splice(needed, current - needed); |
| labelLines.splice(needed, current - needed); |
| itemLines.splice(needed, current - needed); |
| |
| links.Timeline.addClassName(frame, options.groupsOnRight ? 'timeline-groups-axis-onright' : 'timeline-groups-axis-onleft'); |
| |
| // position the groups |
| for (var i = 0, iMax = groups.length; i < iMax; i++) { |
| var group = groups[i], |
| label = labels[i], |
| labelLine = labelLines[i], |
| itemLine = itemLines[i]; |
| |
| label.style.top = group.labelTop + "px"; |
| labelLine.style.top = group.lineTop + "px"; |
| itemLine.style.top = group.lineTop + "px"; |
| itemLine.style.width = size.contentWidth + "px"; |
| } |
| |
| if (!dom.groups.background) { |
| // create the axis grid line background |
| var background = document.createElement("DIV"); |
| background.className = "timeline-axis"; |
| background.style.position = "absolute"; |
| background.style.left = "0px"; |
| background.style.width = "100%"; |
| background.style.border = "none"; |
| |
| frame.appendChild(background); |
| dom.groups.background = background; |
| } |
| dom.groups.background.style.top = size.axis.top + 'px'; |
| dom.groups.background.style.height = size.axis.height + 'px'; |
| |
| if (!dom.groups.line) { |
| // create the axis grid line |
| var line = document.createElement("DIV"); |
| line.className = "timeline-axis"; |
| line.style.position = "absolute"; |
| line.style.left = "0px"; |
| line.style.width = "100%"; |
| line.style.height = "0px"; |
| |
| frame.appendChild(line); |
| dom.groups.line = line; |
| } |
| dom.groups.line.style.top = size.axis.line + 'px'; |
| |
| // create a callback when there are images which are not yet loaded |
| // TODO: more efficiently load images in the groups |
| if (dom.groups.frame && groups.length) { |
| var imageUrls = []; |
| links.imageloader.filterImageUrls(dom.groups.frame, imageUrls); |
| if (imageUrls.length) { |
| // retrieve all image sources from the items, and set a callback once |
| // all images are retrieved |
| var callback = function () { |
| timeline.render(); |
| }; |
| var sendCallbackWhenAlreadyLoaded = false; |
| links.imageloader.loadAll(imageUrls, callback, sendCallbackWhenAlreadyLoaded); |
| } |
| } |
| }; |
| |
| |
| /** |
| * Redraw the current time bar |
| */ |
| links.Timeline.prototype.repaintCurrentTime = function() { |
| var options = this.options, |
| dom = this.dom, |
| size = this.size; |
| |
| if (!options.showCurrentTime) { |
| if (dom.currentTime) { |
| dom.contentTimelines.removeChild(dom.currentTime); |
| delete dom.currentTime; |
| } |
| |
| return; |
| } |
| |
| if (!dom.currentTime) { |
| // create the current time bar |
| var currentTime = document.createElement("DIV"); |
| currentTime.className = "timeline-currenttime"; |
| currentTime.style.position = "absolute"; |
| currentTime.style.top = "0px"; |
| currentTime.style.height = "100%"; |
| |
| dom.contentTimelines.appendChild(currentTime); |
| dom.currentTime = currentTime; |
| } |
| |
| var now = new Date(); |
| var nowOffset = new Date(now.valueOf() + this.clientTimeOffset); |
| var x = this.timeToScreen(nowOffset); |
| |
| var visible = (x > -size.contentWidth && x < 2 * size.contentWidth); |
| dom.currentTime.style.display = visible ? '' : 'none'; |
| dom.currentTime.style.left = x + "px"; |
| dom.currentTime.title = "Current time: " + nowOffset; |
| |
| // start a timer to adjust for the new time |
| if (this.currentTimeTimer != undefined) { |
| clearTimeout(this.currentTimeTimer); |
| delete this.currentTimeTimer; |
| } |
| var timeline = this; |
| var onTimeout = function() { |
| timeline.repaintCurrentTime(); |
| }; |
| // the time equal to the width of one pixel, divided by 2 for more smoothness |
| var interval = 1 / this.conversion.factor / 2; |
| if (interval < 30) interval = 30; |
| this.currentTimeTimer = setTimeout(onTimeout, interval); |
| }; |
| |
| /** |
| * Redraw the custom time bar |
| */ |
| links.Timeline.prototype.repaintCustomTime = function() { |
| var options = this.options, |
| dom = this.dom, |
| size = this.size; |
| |
| if (!options.showCustomTime) { |
| if (dom.customTime) { |
| dom.contentTimelines.removeChild(dom.customTime); |
| delete dom.customTime; |
| } |
| |
| return; |
| } |
| |
| if (!dom.customTime) { |
| var customTime = document.createElement("DIV"); |
| customTime.className = "timeline-customtime"; |
| customTime.style.position = "absolute"; |
| customTime.style.top = "0px"; |
| customTime.style.height = "100%"; |
| |
| 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"; |
| customTime.appendChild(drag); |
| |
| dom.contentTimelines.appendChild(customTime); |
| dom.customTime = customTime; |
| |
| // initialize parameter |
| this.customTime = new Date(); |
| } |
| |
| var x = this.timeToScreen(this.customTime), |
| visible = (x > -size.contentWidth && x < 2 * size.contentWidth); |
| dom.customTime.style.display = visible ? '' : 'none'; |
| dom.customTime.style.left = x + "px"; |
| dom.customTime.title = "Time: " + this.customTime; |
| }; |
| |
| |
| /** |
| * Redraw the delete button, on the top right of the currently selected item |
| * if there is no item selected, the button is hidden. |
| */ |
| links.Timeline.prototype.repaintDeleteButton = function () { |
| var timeline = this, |
| dom = this.dom, |
| frame = dom.items.frame; |
| |
| var deleteButton = dom.items.deleteButton; |
| if (!deleteButton) { |
| // create a delete button |
| deleteButton = document.createElement("DIV"); |
| deleteButton.className = "timeline-navigation-delete"; |
| deleteButton.style.position = "absolute"; |
| |
| frame.appendChild(deleteButton); |
| dom.items.deleteButton = deleteButton; |
| } |
| |
| var index = this.selection ? this.selection.index : -1, |
| item = this.selection ? this.items[index] : undefined; |
| if (item && item.rendered && this.isEditable(item)) { |
| var right = item.getRight(this), |
| top = item.top; |
| |
| deleteButton.style.left = right + 'px'; |
| deleteButton.style.top = top + 'px'; |
| deleteButton.style.display = ''; |
| frame.removeChild(deleteButton); |
| frame.appendChild(deleteButton); |
| } |
| else { |
| deleteButton.style.display = 'none'; |
| } |
| }; |
| |
| |
| /** |
| * Redraw the drag areas. When an item (ranges only) is selected, |
| * it gets a drag area on the left and right side, to change its width |
| */ |
| links.Timeline.prototype.repaintDragAreas = function () { |
| var timeline = this, |
| options = this.options, |
| dom = this.dom, |
| frame = this.dom.items.frame; |
| |
| // create left drag area |
| var dragLeft = dom.items.dragLeft; |
| if (!dragLeft) { |
| dragLeft = document.createElement("DIV"); |
| dragLeft.className="timeline-event-range-drag-left"; |
| dragLeft.style.position = "absolute"; |
| |
| frame.appendChild(dragLeft); |
| dom.items.dragLeft = dragLeft; |
| } |
| |
| // create right drag area |
| var dragRight = dom.items.dragRight; |
| if (!dragRight) { |
| dragRight = document.createElement("DIV"); |
| dragRight.className="timeline-event-range-drag-right"; |
| dragRight.style.position = "absolute"; |
| |
| frame.appendChild(dragRight); |
| dom.items.dragRight = dragRight; |
| } |
| |
| // reposition left and right drag area |
| var index = this.selection ? this.selection.index : -1, |
| item = this.selection ? this.items[index] : undefined; |
| if (item && item.rendered && this.isEditable(item) && |
| (item instanceof links.Timeline.ItemRange)) { |
| var left = this.timeToScreen(item.start), |
| right = this.timeToScreen(item.end), |
| top = item.top, |
| height = item.height; |
| |
| dragLeft.style.left = left + 'px'; |
| dragLeft.style.top = top + 'px'; |
| dragLeft.style.width = options.dragAreaWidth + "px"; |
| dragLeft.style.height = height + 'px'; |
| dragLeft.style.display = ''; |
| frame.removeChild(dragLeft); |
| frame.appendChild(dragLeft); |
| |
| dragRight.style.left = (right - options.dragAreaWidth) + 'px'; |
| dragRight.style.top = top + 'px'; |
| dragRight.style.width = options.dragAreaWidth + "px"; |
| dragRight.style.height = height + 'px'; |
| dragRight.style.display = ''; |
| frame.removeChild(dragRight); |
| frame.appendChild(dragRight); |
| } |
| else { |
| dragLeft.style.display = 'none'; |
| dragRight.style.display = 'none'; |
| } |
| }; |
| |
| /** |
| * Create the navigation buttons for zooming and moving |
| */ |
| links.Timeline.prototype.repaintNavigation = function () { |
| var timeline = this, |
| options = this.options, |
| dom = this.dom, |
| frame = dom.frame, |
| navBar = dom.navBar; |
| |
| if (!navBar) { |
| var showButtonNew = options.showButtonNew && options.editable; |
| var showNavigation = options.showNavigation && (options.zoomable || options.moveable); |
| if (showNavigation || showButtonNew) { |
| // create a navigation bar containing the navigation buttons |
| navBar = document.createElement("DIV"); |
| navBar.style.position = "absolute"; |
| navBar.className = "timeline-navigation ui-widget ui-state-highlight ui-corner-all"; |
| if (options.groupsOnRight) { |
| navBar.style.left = '10px'; |
| } |
| else { |
| navBar.style.right = '10px'; |
| } |
| if (options.axisOnTop) { |
| navBar.style.bottom = '10px'; |
| } |
| else { |
| navBar.style.top = '10px'; |
| } |
| dom.navBar = navBar; |
| frame.appendChild(navBar); |
| } |
| |
| if (showButtonNew) { |
| // create a new in button |
| navBar.addButton = document.createElement("DIV"); |
| navBar.addButton.className = "timeline-navigation-new"; |
| navBar.addButton.title = options.CREATE_NEW_EVENT; |
| var addIconSpan = document.createElement("SPAN"); |
| addIconSpan.className = "ui-icon ui-icon-circle-plus"; |
| navBar.addButton.appendChild(addIconSpan); |
| |
| var onAdd = function(event) { |
| links.Timeline.preventDefault(event); |
| links.Timeline.stopPropagation(event); |
| |
| // create a new event at the center of the frame |
| var w = timeline.size.contentWidth; |
| var x = w / 2; |
| var xstart = timeline.screenToTime(x - w / 10); // subtract 10% of timeline width |
| var xend = timeline.screenToTime(x + w / 10); // add 10% of timeline width |
| if (options.snapEvents) { |
| timeline.step.snap(xstart); |
| timeline.step.snap(xend); |
| } |
| |
| var content = options.NEW; |
| var group = timeline.groups.length ? timeline.groups[0].content : undefined; |
| var preventRender = true; |
| timeline.addItem({ |
| 'start': xstart, |
| 'end': xend, |
| 'content': content, |
| 'group': group |
| }, preventRender); |
| var index = (timeline.items.length - 1); |
| timeline.selectItem(index); |
| |
| timeline.applyAdd = true; |
| |
| // fire an add event. |
| // Note that the change can be canceled from within an event listener if |
| // this listener calls the method cancelAdd(). |
| timeline.trigger('add'); |
| |
| if (timeline.applyAdd) { |
| // render and select the item |
| timeline.render({animate: false}); |
| timeline.selectItem(index); |
| } |
| else { |
| // undo an add |
| timeline.deleteItem(index); |
| } |
| }; |
| links.Timeline.addEventListener(navBar.addButton, "mousedown", onAdd); |
| navBar.appendChild(navBar.addButton); |
| } |
| |
| if (showButtonNew && showNavigation) { |
| // create a separator line |
| links.Timeline.addClassName(navBar.addButton, 'timeline-navigation-new-line'); |
| } |
| |
| if (showNavigation) { |
| if (options.zoomable) { |
| // create a zoom in button |
| navBar.zoomInButton = document.createElement("DIV"); |
| navBar.zoomInButton.className = "timeline-navigation-zoom-in"; |
| navBar.zoomInButton.title = this.options.ZOOM_IN; |
| var ziIconSpan = document.createElement("SPAN"); |
| ziIconSpan.className = "ui-icon ui-icon-circle-zoomin"; |
| navBar.zoomInButton.appendChild(ziIconSpan); |
| |
| var onZoomIn = function(event) { |
| links.Timeline.preventDefault(event); |
| links.Timeline.stopPropagation(event); |
| timeline.zoom(0.4); |
| timeline.trigger("rangechange"); |
| timeline.trigger("rangechanged"); |
| }; |
| links.Timeline.addEventListener(navBar.zoomInButton, "mousedown", onZoomIn); |
| navBar.appendChild(navBar.zoomInButton); |
| |
| // create a zoom out button |
| navBar.zoomOutButton = document.createElement("DIV"); |
| navBar.zoomOutButton.className = "timeline-navigation-zoom-out"; |
| navBar.zoomOutButton.title = this.options.ZOOM_OUT; |
| var zoIconSpan = document.createElement("SPAN"); |
| zoIconSpan.className = "ui-icon ui-icon-circle-zoomout"; |
| navBar.zoomOutButton.appendChild(zoIconSpan); |
| |
| var onZoomOut = function(event) { |
| links.Timeline.preventDefault(event); |
| links.Timeline.stopPropagation(event); |
| timeline.zoom(-0.4); |
| timeline.trigger("rangechange"); |
| timeline.trigger("rangechanged"); |
| }; |
| links.Timeline.addEventListener(navBar.zoomOutButton, "mousedown", onZoomOut); |
| navBar.appendChild(navBar.zoomOutButton); |
| } |
| |
| if (options.moveable) { |
| // create a move left button |
| navBar.moveLeftButton = document.createElement("DIV"); |
| navBar.moveLeftButton.className = "timeline-navigation-move-left"; |
| navBar.moveLeftButton.title = this.options.MOVE_LEFT; |
| var mlIconSpan = document.createElement("SPAN"); |
| mlIconSpan.className = "ui-icon ui-icon-circle-arrow-w"; |
| navBar.moveLeftButton.appendChild(mlIconSpan); |
| |
| var onMoveLeft = function(event) { |
| links.Timeline.preventDefault(event); |
| links.Timeline.stopPropagation(event); |
| timeline.move(-0.2); |
| timeline.trigger("rangechange"); |
| timeline.trigger("rangechanged"); |
| }; |
| links.Timeline.addEventListener(navBar.moveLeftButton, "mousedown", onMoveLeft); |
| navBar.appendChild(navBar.moveLeftButton); |
| |
| // create a move right button |
| navBar.moveRightButton = document.createElement("DIV"); |
| navBar.moveRightButton.className = "timeline-navigation-move-right"; |
| navBar.moveRightButton.title = this.options.MOVE_RIGHT; |
| var mrIconSpan = document.createElement("SPAN"); |
| mrIconSpan.className = "ui-icon ui-icon-circle-arrow-e"; |
| navBar.moveRightButton.appendChild(mrIconSpan); |
| |
| var onMoveRight = function(event) { |
| links.Timeline.preventDefault(event); |
| links.Timeline.stopPropagation(event); |
| timeline.move(0.2); |
| timeline.trigger("rangechange"); |
| timeline.trigger("rangechanged"); |
| }; |
| links.Timeline.addEventListener(navBar.moveRightButton, "mousedown", onMoveRight); |
| navBar.appendChild(navBar.moveRightButton); |
| } |
| } |
| } |
| }; |
| |
| |
| /** |
| * Set current time. This function can be used to set the time in the client |
| * timeline equal with the time on a server. |
| * @param {Date} time |
| */ |
| links.Timeline.prototype.setCurrentTime = function(time) { |
| var now = new Date(); |
| this.clientTimeOffset = (time.valueOf() - now.valueOf()); |
| |
| this.repaintCurrentTime(); |
| }; |
| |
| /** |
| * Get current time. The time can have an offset from the real time, when |
| * the current time has been changed via the method setCurrentTime. |
| * @return {Date} time |
| */ |
| links.Timeline.prototype.getCurrentTime = function() { |
| var now = new Date(); |
| return new Date(now.valueOf() + this.clientTimeOffset); |
| }; |
| |
| |
| /** |
| * Set custom time. |
| * The custom time bar can be used to display events in past or future. |
| * @param {Date} time |
| */ |
| links.Timeline.prototype.setCustomTime = function(time) { |
| this.customTime = new Date(time.valueOf()); |
| this.repaintCustomTime(); |
| }; |
| |
| /** |
| * Retrieve the current custom time. |
| * @return {Date} customTime |
| */ |
| links.Timeline.prototype.getCustomTime = function() { |
| return new Date(this.customTime.valueOf()); |
| }; |
| |
| /** |
| * 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 {links.Timeline.StepDate.SCALE} scale |
| * A scale. Choose from SCALE.MILLISECOND, |
| * SCALE.SECOND, SCALE.MINUTE, SCALE.HOUR, |
| * SCALE.WEEKDAY, SCALE.DAY, SCALE.MONTH, |
| * SCALE.YEAR. |
| * @param {int} step A step size, by default 1. Choose for |
| * example 1, 2, 5, or 10. |
| */ |
| links.Timeline.prototype.setScale = function(scale, step) { |
| this.step.setScale(scale, step); |
| this.render(); // TODO: optimize: only reflow/repaint axis |
| }; |
| |
| /** |
| * Enable or disable autoscaling |
| * @param {boolean} enable If true or not defined, autoscaling is enabled. |
| * If false, autoscaling is disabled. |
| */ |
| links.Timeline.prototype.setAutoScale = function(enable) { |
| this.step.setAutoScale(enable); |
| this.render(); // TODO: optimize: only reflow/repaint axis |
| }; |
| |
| /** |
| * Redraw the timeline |
| * Reloads the (linked) data table and redraws the timeline when resized. |
| * See also the method checkResize |
| */ |
| links.Timeline.prototype.redraw = function() { |
| this.setData(this.data); |
| }; |
| |
| |
| /** |
| * Check if the timeline is resized, and if so, redraw the timeline. |
| * Useful when the webpage is resized. |
| */ |
| links.Timeline.prototype.checkResize = function() { |
| // TODO: re-implement the method checkResize, or better, make it redundant as this.render will be smarter |
| this.render(); |
| }; |
| |
| /** |
| * Check whether a given item is editable |
| * @param {links.Timeline.Item} item |
| * @return {boolean} editable |
| */ |
| links.Timeline.prototype.isEditable = function (item) { |
| if (item) { |
| if (item.editable != undefined) { |
| return item.editable; |
| } |
| else { |
| return this.options.editable; |
| } |
| } |
| return false; |
| }; |
| |
| /** |
| * Calculate the factor and offset to convert a position on screen to the |
| * corresponding date and vice versa. |
| * After the method calcConversionFactor is executed once, the methods screenToTime and |
| * timeToScreen can be used. |
| */ |
| links.Timeline.prototype.recalcConversion = function() { |
| this.conversion.offset = this.start.valueOf(); |
| this.conversion.factor = this.size.contentWidth / |
| (this.end.valueOf() - this.start.valueOf()); |
| }; |
| |
| |
| /** |
| * Convert a position on screen (pixels) to a datetime |
| * Before this method can be used, the method calcConversionFactor must be |
| * executed once. |
| * @param {int} x Position on the screen in pixels |
| * @return {Date} time The datetime the corresponds with given position x |
| */ |
| links.Timeline.prototype.screenToTime = function(x) { |
| var conversion = this.conversion; |
| return new Date(x / conversion.factor + conversion.offset); |
| }; |
| |
| /** |
| * Convert a datetime (Date object) into a position on the screen |
| * Before this method can be used, the method calcConversionFactor 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. |
| */ |
| links.Timeline.prototype.timeToScreen = function(time) { |
| var conversion = this.conversion; |
| return (time.valueOf() - conversion.offset) * conversion.factor; |
| }; |
| |
| |
| |
| /** |
| * Event handler for touchstart event on mobile devices |
| */ |
| links.Timeline.prototype.onTouchStart = function(event) { |
| var params = this.eventParams, |
| me = this; |
| |
| if (params.touchDown) { |
| // if already moving, return |
| return; |
| } |
| |
| params.touchDown = true; |
| params.zoomed = false; |
| |
| this.onMouseDown(event); |
| |
| if (!params.onTouchMove) { |
| params.onTouchMove = function (event) {me.onTouchMove(event);}; |
| links.Timeline.addEventListener(document, "touchmove", params.onTouchMove); |
| } |
| if (!params.onTouchEnd) { |
| params.onTouchEnd = function (event) {me.onTouchEnd(event);}; |
| links.Timeline.addEventListener(document, "touchend", params.onTouchEnd); |
| } |
| |
| /* TODO |
| // check for double tap event |
| var delta = 500; // ms |
| var doubleTapStart = (new Date()).valueOf(); |
| var target = links.Timeline.getTarget(event); |
| var doubleTapItem = this.getItemIndex(target); |
| if (params.doubleTapStart && |
| (doubleTapStart - params.doubleTapStart) < delta && |
| doubleTapItem == params.doubleTapItem) { |
| delete params.doubleTapStart; |
| delete params.doubleTapItem; |
| me.onDblClick(event); |
| params.touchDown = false; |
| } |
| params.doubleTapStart = doubleTapStart; |
| params.doubleTapItem = doubleTapItem; |
| */ |
| // store timing for double taps |
| var target = links.Timeline.getTarget(event); |
| var item = this.getItemIndex(target); |
| params.doubleTapStartPrev = params.doubleTapStart; |
| params.doubleTapStart = (new Date()).valueOf(); |
| params.doubleTapItemPrev = params.doubleTapItem; |
| params.doubleTapItem = item; |
| |
| links.Timeline.preventDefault(event); |
| }; |
| |
| /** |
| * Event handler for touchmove event on mobile devices |
| */ |
| links.Timeline.prototype.onTouchMove = function(event) { |
| var params = this.eventParams; |
| |
| if (event.scale && event.scale !== 1) { |
| params.zoomed = true; |
| } |
| |
| if (!params.zoomed) { |
| // move |
| this.onMouseMove(event); |
| } |
| else { |
| if (this.options.zoomable) { |
| // pinch |
| // TODO: pinch only supported on iPhone/iPad. Create something manually for Android? |
| params.zoomed = true; |
| |
| var scale = event.scale, |
| oldWidth = (params.end.valueOf() - params.start.valueOf()), |
| newWidth = oldWidth / scale, |
| diff = newWidth - oldWidth, |
| start = new Date(parseInt(params.start.valueOf() - diff/2)), |
| end = new Date(parseInt(params.end.valueOf() + diff/2)); |
| |
| // TODO: determine zoom-around-date from touch positions? |
| |
| this.setVisibleChartRange(start, end); |
| this.trigger("rangechange"); |
| } |
| } |
| |
| links.Timeline.preventDefault(event); |
| }; |
| |
| /** |
| * Event handler for touchend event on mobile devices |
| */ |
| links.Timeline.prototype.onTouchEnd = function(event) { |
| var params = this.eventParams; |
| var me = this; |
| params.touchDown = false; |
| |
| if (params.zoomed) { |
| this.trigger("rangechanged"); |
| } |
| |
| if (params.onTouchMove) { |
| links.Timeline.removeEventListener(document, "touchmove", params.onTouchMove); |
| delete params.onTouchMove; |
| |
| } |
| if (params.onTouchEnd) { |
| links.Timeline.removeEventListener(document, "touchend", params.onTouchEnd); |
| delete params.onTouchEnd; |
| } |
| |
| this.onMouseUp(event); |
| |
| // check for double tap event |
| var delta = 500; // ms |
| var doubleTapEnd = (new Date()).valueOf(); |
| var target = links.Timeline.getTarget(event); |
| var doubleTapItem = this.getItemIndex(target); |
| if (params.doubleTapStartPrev && |
| (doubleTapEnd - params.doubleTapStartPrev) < delta && |
| params.doubleTapItem == params.doubleTapItemPrev) { |
| params.touchDown = true; |
| me.onDblClick(event); |
| params.touchDown = false; |
| } |
| |
| links.Timeline.preventDefault(event); |
| }; |
| |
| |
| /** |
| * Start a moving operation inside the provided parent element |
| * @param {Event} event The event that occurred (required for |
| * retrieving the mouse position) |
| */ |
| links.Timeline.prototype.onMouseDown = function(event) { |
| event = event || window.event; |
| |
| var params = this.eventParams, |
| options = this.options, |
| dom = this.dom; |
| |
| // only react on left mouse button down |
| var leftButtonDown = event.which ? (event.which == 1) : (event.button == 1); |
| if (!leftButtonDown && !params.touchDown) { |
| return; |
| } |
| |
| // get mouse position |
| params.mouseX = links.Timeline.getPageX(event); |
| params.mouseY = links.Timeline.getPageY(event); |
| params.frameLeft = links.Timeline.getAbsoluteLeft(this.dom.content); |
| params.frameTop = links.Timeline.getAbsoluteTop(this.dom.content); |
| params.previousLeft = 0; |
| params.previousOffset = 0; |
| |
| params.moved = false; |
| params.start = new Date(this.start.valueOf()); |
| params.end = new Date(this.end.valueOf()); |
| |
| params.target = links.Timeline.getTarget(event); |
| var dragLeft = (dom.items && dom.items.dragLeft) ? dom.items.dragLeft : undefined; |
| var dragRight = (dom.items && dom.items.dragRight) ? dom.items.dragRight : undefined; |
| params.itemDragLeft = (params.target === dragLeft); |
| params.itemDragRight = (params.target === dragRight); |
| |
| if (params.itemDragLeft || params.itemDragRight) { |
| params.itemIndex = this.selection ? this.selection.index : undefined; |
| } |
| else { |
| params.itemIndex = this.getItemIndex(params.target); |
| } |
| |
| params.customTime = (params.target === dom.customTime || |
| params.target.parentNode === dom.customTime) ? |
| this.customTime : |
| undefined; |
| |
| params.addItem = (options.editable && event.ctrlKey); |
| if (params.addItem) { |
| // create a new event at the current mouse position |
| var x = params.mouseX - params.frameLeft; |
| var y = params.mouseY - params.frameTop; |
| |
| var xstart = this.screenToTime(x); |
| if (options.snapEvents) { |
| this.step.snap(xstart); |
| } |
| var xend = new Date(xstart.valueOf()); |
| var content = options.NEW; |
| var group = this.getGroupFromHeight(y); |
| this.addItem({ |
| 'start': xstart, |
| 'end': xend, |
| 'content': content, |
| 'group': this.getGroupName(group) |
| }); |
| params.itemIndex = (this.items.length - 1); |
| this.selectItem(params.itemIndex); |
| params.itemDragRight = true; |
| } |
| |
| var item = this.items[params.itemIndex]; |
| var isSelected = this.isSelected(params.itemIndex); |
| params.editItem = isSelected && this.isEditable(item); |
| if (params.editItem) { |
| params.itemStart = item.start; |
| params.itemEnd = item.end; |
| params.itemGroup = item.group; |
| params.itemLeft = item.start ? this.timeToScreen(item.start) : undefined; |
| params.itemRight = item.end ? this.timeToScreen(item.end) : undefined; |
| } |
| else { |
| this.dom.frame.style.cursor = 'move'; |
| } |
| if (!params.touchDown) { |
| // add event listeners to handle moving the contents |
| // we store the function onmousemove and onmouseup in the timeline, so we can |
| // remove the eventlisteners lateron in the function mouseUp() |
| var me = this; |
| if (!params.onMouseMove) { |
| params.onMouseMove = function (event) {me.onMouseMove(event);}; |
| links.Timeline.addEventListener(document, "mousemove", params.onMouseMove); |
| } |
| if (!params.onMouseUp) { |
| params.onMouseUp = function (event) {me.onMouseUp(event);}; |
| links.Timeline.addEventListener(document, "mouseup", params.onMouseUp); |
| } |
| |
| links.Timeline.preventDefault(event); |
| } |
| }; |
| |
| |
| /** |
| * Perform moving operating. |
| * This function activated from within the funcion links.Timeline.onMouseDown(). |
| * @param {Event} event Well, eehh, the event |
| */ |
| links.Timeline.prototype.onMouseMove = function (event) { |
| event = event || window.event; |
| |
| var params = this.eventParams, |
| size = this.size, |
| dom = this.dom, |
| options = this.options; |
| |
| // calculate change in mouse position |
| var mouseX = links.Timeline.getPageX(event); |
| var mouseY = links.Timeline.getPageY(event); |
| |
| if (params.mouseX == undefined) { |
| params.mouseX = mouseX; |
| } |
| if (params.mouseY == undefined) { |
| params.mouseY = mouseY; |
| } |
| |
| var diffX = mouseX - params.mouseX; |
| var diffY = mouseY - params.mouseY; |
| |
| // if mouse movement is big enough, register it as a "moved" event |
| if (Math.abs(diffX) >= 1) { |
| params.moved = true; |
| } |
| |
| if (params.customTime) { |
| var x = this.timeToScreen(params.customTime); |
| var xnew = x + diffX; |
| this.customTime = this.screenToTime(xnew); |
| this.repaintCustomTime(); |
| |
| // fire a timechange event |
| this.trigger('timechange'); |
| } |
| else if (params.editItem) { |
| var item = this.items[params.itemIndex], |
| left, |
| right; |
| |
| if (params.itemDragLeft) { |
| // move the start of the item |
| left = params.itemLeft + diffX; |
| right = params.itemRight; |
| |
| item.start = this.screenToTime(left); |
| if (options.snapEvents) { |
| this.step.snap(item.start); |
| left = this.timeToScreen(item.start); |
| } |
| |
| if (left > right) { |
| left = right; |
| item.start = this.screenToTime(left); |
| } |
| } |
| else if (params.itemDragRight) { |
| // move the end of the item |
| left = params.itemLeft; |
| right = params.itemRight + diffX; |
| |
| item.end = this.screenToTime(right); |
| if (options.snapEvents) { |
| this.step.snap(item.end); |
| right = this.timeToScreen(item.end); |
| } |
| |
| if (right < left) { |
| right = left; |
| item.end = this.screenToTime(right); |
| } |
| } |
| else { |
| // move the item |
| left = params.itemLeft + diffX; |
| item.start = this.screenToTime(left); |
| if (options.snapEvents) { |
| this.step.snap(item.start); |
| left = this.timeToScreen(item.start); |
| } |
| |
| if (item.end) { |
| right = left + (params.itemRight - params.itemLeft); |
| item.end = this.screenToTime(right); |
| } |
| } |
| |
| item.setPosition(left, right); |
| |
| var dragging = params.itemDragLeft || params.itemDragRight; |
| if (this.groups.length && !dragging) { |
| // move item from one group to another when needed |
| var y = mouseY - params.frameTop; |
| var group = this.getGroupFromHeight(y); |
| if (options.groupsChangeable && item.group !== group) { |
| // move item to the other group |
| var index = this.items.indexOf(item); |
| this.changeItem(index, {'group': this.getGroupName(group)}); |
| } |
| else { |
| this.repaintDeleteButton(); |
| this.repaintDragAreas(); |
| } |
| } |
| else { |
| // TODO: does not work well in FF, forces redraw with every mouse move it seems |
| this.render(); // TODO: optimize, only redraw the items? |
| // Note: when animate==true, no redraw is needed here, its done by stackItems animation |
| } |
| } |
| else if (options.moveable) { |
| var interval = (params.end.valueOf() - params.start.valueOf()); |
| var diffMillisecs = Math.round((-diffX) / size.contentWidth * interval); |
| var newStart = new Date(params.start.valueOf() + diffMillisecs); |
| var newEnd = new Date(params.end.valueOf() + diffMillisecs); |
| this.applyRange(newStart, newEnd); |
| // if the applied range is moved due to a fixed min or max, |
| // change the diffMillisecs accordingly |
| var appliedDiff = (this.start.valueOf() - newStart.valueOf()); |
| if (appliedDiff) { |
| diffMillisecs += appliedDiff; |
| } |
| |
| this.recalcConversion(); |
| |
| // move the items by changing the left position of their frame. |
| // this is much faster than repositioning all elements individually via the |
| // repaintFrame() function (which is done once at mouseup) |
| // note that we round diffX to prevent wrong positioning on millisecond scale |
| var previousLeft = params.previousLeft || 0; |
| var currentLeft = parseFloat(dom.items.frame.style.left) || 0; |
| var previousOffset = params.previousOffset || 0; |
| var frameOffset = previousOffset + (currentLeft - previousLeft); |
| var frameLeft = -diffMillisecs / interval * size.contentWidth + frameOffset; |
| |
| dom.items.frame.style.left = (frameLeft) + "px"; |
| |
| // read the left again from DOM (IE8- rounds the value) |
| params.previousOffset = frameOffset; |
| params.previousLeft = parseFloat(dom.items.frame.style.left) || frameLeft; |
| |
| this.repaintCurrentTime(); |
| this.repaintCustomTime(); |
| this.repaintAxis(); |
| |
| // fire a rangechange event |
| this.trigger('rangechange'); |
| } |
| |
| links.Timeline.preventDefault(event); |
| }; |
| |
| |
| /** |
| * Stop moving operating. |
| * This function activated from within the funcion links.Timeline.onMouseDown(). |
| * @param {event} event The event |
| */ |
| links.Timeline.prototype.onMouseUp = function (event) { |
| var params = this.eventParams, |
| options = this.options; |
| |
| event = event || window.event; |
| |
| this.dom.frame.style.cursor = 'auto'; |
| |
| // remove event listeners here, important for Safari |
| if (params.onMouseMove) { |
| links.Timeline.removeEventListener(document, "mousemove", params.onMouseMove); |
| delete params.onMouseMove; |
| } |
| if (params.onMouseUp) { |
| links.Timeline.removeEventListener(document, "mouseup", params.onMouseUp); |
| delete params.onMouseUp; |
| } |
| //links.Timeline.preventDefault(event); |
| |
| if (params.customTime) { |
| // fire a timechanged event |
| this.trigger('timechanged'); |
| } |
| else if (params.editItem) { |
| var item = this.items[params.itemIndex]; |
| |
| if (params.moved || params.addItem) { |
| this.applyChange = true; |
| this.applyAdd = true; |
| |
| this.updateData(params.itemIndex, { |
| 'start': item.start, |
| 'end': item.end |
| }); |
| |
| // fire an add or change event. |
| // Note that the change can be canceled from within an event listener if |
| // this listener calls the method cancelChange(). |
| this.trigger(params.addItem ? 'add' : 'change'); |
| |
| if (params.addItem) { |
| if (this.applyAdd) { |
| this.updateData(params.itemIndex, { |
| 'start': item.start, |
| 'end': item.end, |
| 'content': item.content, |
| 'group': this.getGroupName(item.group) |
| }); |
| } |
| else { |
| // undo an add |
| this.deleteItem(params.itemIndex); |
| } |
| } |
| else { |
| if (this.applyChange) { |
| this.updateData(params.itemIndex, { |
| 'start': item.start, |
| 'end': item.end |
| }); |
| } |
| else { |
| // undo a change |
| delete this.applyChange; |
| delete this.applyAdd; |
| |
| var item = this.items[params.itemIndex], |
| domItem = item.dom; |
| |
| item.start = params.itemStart; |
| item.end = params.itemEnd; |
| item.group = params.itemGroup; |
| // TODO: original group should be restored too |
| item.setPosition(params.itemLeft, params.itemRight); |
| } |
| } |
| |
| // prepare data for clustering, by filtering and sorting by type |
| if (this.options.cluster) { |
| this.clusterGenerator.updateData(); |
| } |
| |
| this.render(); |
| } |
| } |
| else { |
| if (!params.moved && !params.zoomed) { |
| // mouse did not move -> user has selected an item |
| |
| if (params.target === this.dom.items.deleteButton) { |
| // delete item |
| if (this.selection) { |
| this.confirmDeleteItem(this.selection.index); |
| } |
| } |
| else if (options.selectable) { |
| // select/unselect item |
| if (params.itemIndex != undefined) { |
| if (!this.isSelected(params.itemIndex)) { |
| this.selectItem(params.itemIndex); |
| this.trigger('select'); |
| } |
| } |
| else { |
| if (options.unselectable) { |
| this.unselectItem(); |
| this.trigger('select'); |
| } |
| } |
| } |
| } |
| else { |
| // timeline is moved |
| // TODO: optimize: no need to reflow and cluster again? |
| this.render(); |
| |
| if ((params.moved && options.moveable) || (params.zoomed && options.zoomable) ) { |
| // fire a rangechanged event |
| this.trigger('rangechanged'); |
| } |
| } |
| } |
| }; |
| |
| /** |
| * Double click event occurred for an item |
| * @param {Event} event |
| */ |
| links.Timeline.prototype.onDblClick = function (event) { |
| var params = this.eventParams, |
| options = this.options, |
| dom = this.dom, |
| size = this.size; |
| event = event || window.event; |
| |
| if (params.itemIndex != undefined) { |
| var item = this.items[params.itemIndex]; |
| if (item && this.isEditable(item)) { |
| // fire the edit event |
| this.trigger('edit'); |
| } |
| } |
| else { |
| if (options.editable) { |
| // create a new item |
| |
| // get mouse position |
| params.mouseX = links.Timeline.getPageX(event); |
| params.mouseY = links.Timeline.getPageY(event); |
| var x = params.mouseX - links.Timeline.getAbsoluteLeft(dom.content); |
| var y = params.mouseY - links.Timeline.getAbsoluteTop(dom.content); |
| |
| // create a new event at the current mouse position |
| var xstart = this.screenToTime(x); |
| var xend = this.screenToTime(x + size.frameWidth / 10); // add 10% of timeline width |
| if (options.snapEvents) { |
| this.step.snap(xstart); |
| this.step.snap(xend); |
| } |
| |
| var content = options.NEW; |
| var group = this.getGroupFromHeight(y); // (group may be undefined) |
| var preventRender = true; |
| this.addItem({ |
| 'start': xstart, |
| 'end': xend, |
| 'content': content, |
| 'group': this.getGroupName(group) |
| }, preventRender); |
| params.itemIndex = (this.items.length - 1); |
| this.selectItem(params.itemIndex); |
| |
| this.applyAdd = true; |
| |
| // fire an add event. |
| // Note that the change can be canceled from within an event listener if |
| // this listener calls the method cancelAdd(). |
| this.trigger('add'); |
| |
| if (this.applyAdd) { |
| // render and select the item |
| this.render({animate: false}); |
| this.selectItem(params.itemIndex); |
| } |
| else { |
| // undo an add |
| this.deleteItem(params.itemIndex); |
| } |
| } |
| } |
| |
| links.Timeline.preventDefault(event); |
| }; |
| |
| |
| /** |
| * Event handler for mouse wheel event, used to zoom the timeline |
| * Code from http://adomas.org/javascript-mouse-wheel/ |
| * @param {Event} event The event |
| */ |
| links.Timeline.prototype.onMouseWheel = function(event) { |
| if (!this.options.zoomable) |
| return; |
| |
| if (!event) { /* For IE. */ |
| event = window.event; |
| } |
| |
| // retrieve delta |
| var delta = 0; |
| if (event.wheelDelta) { /* IE/Opera. */ |
| delta = event.wheelDelta/120; |
| } else if (event.detail) { /* Mozilla case. */ |
| // In Mozilla, sign of delta is different than in IE. |
| // Also, delta is multiple of 3. |
| delta = -event.detail/3; |
| } |
| |
| // If delta is nonzero, handle it. |
| // Basically, delta is now positive if wheel was scrolled up, |
| // and negative, if wheel was scrolled down. |
| if (delta) { |
| // TODO: on FireFox, the window is not redrawn within repeated scroll-events |
| // -> use a delayed redraw? Make a zoom queue? |
| |
| var timeline = this; |
| var zoom = function () { |
| // perform the zoom action. Delta is normally 1 or -1 |
| var zoomFactor = delta / 5.0; |
| var frameLeft = links.Timeline.getAbsoluteLeft(timeline.dom.content); |
| var mouseX = links.Timeline.getPageX(event); |
| var zoomAroundDate = |
| (mouseX != undefined && frameLeft != undefined) ? |
| timeline.screenToTime(mouseX - frameLeft) : |
| undefined; |
| |
| timeline.zoom(zoomFactor, zoomAroundDate); |
| |
| // fire a rangechange and a rangechanged event |
| timeline.trigger("rangechange"); |
| timeline.trigger("rangechanged"); |
| }; |
| |
| var scroll = function () { |
| // Scroll the timeline |
| timeline.move(delta * -0.2); |
| timeline.trigger("rangechange"); |
| timeline.trigger("rangechanged"); |
| }; |
| |
| if (event.shiftKey) { |
| scroll(); |
| } |
| else { |
| zoom(); |
| } |
| } |
| |
| // Prevent default actions caused by mouse wheel. |
| // That might be ugly, but we handle scrolls somehow |
| // anyway, so don't bother here... |
| links.Timeline.preventDefault(event); |
| }; |
| |
| |
| /** |
| * Zoom the timeline the given zoomfactor in or out. Start and end date will |
| * be adjusted, and the timeline will be redrawn. You can optionally give a |
| * date around which to zoom. |
| * For example, try zoomfactor = 0.1 or -0.1 |
| * @param {Number} zoomFactor Zooming amount. Positive value will zoom in, |
| * negative value will zoom out |
| * @param {Date} zoomAroundDate Date around which will be zoomed. Optional |
| */ |
| links.Timeline.prototype.zoom = function(zoomFactor, zoomAroundDate) { |
| // if zoomAroundDate is not provided, take it half between start Date and end Date |
| if (zoomAroundDate == undefined) { |
| zoomAroundDate = new Date((this.start.valueOf() + this.end.valueOf()) / 2); |
| } |
| |
| // prevent zoom factor larger than 1 or smaller than -1 (larger than 1 will |
| // result in a start>=end ) |
| if (zoomFactor >= 1) { |
| zoomFactor = 0.9; |
| } |
| if (zoomFactor <= -1) { |
| zoomFactor = -0.9; |
| } |
| |
| // adjust a negative factor such that zooming in with 0.1 equals zooming |
| // out with a factor -0.1 |
| if (zoomFactor < 0) { |
| zoomFactor = zoomFactor / (1 + zoomFactor); |
| } |
| |
| // zoom start Date and end Date relative to the zoomAroundDate |
| var startDiff = (this.start.valueOf() - zoomAroundDate); |
| var endDiff = (this.end.valueOf() - zoomAroundDate); |
| |
| // calculate new dates |
| var newStart = new Date(this.start.valueOf() - startDiff * zoomFactor); |
| var newEnd = new Date(this.end.valueOf() - endDiff * zoomFactor); |
| |
| // only zoom in when interval is larger than minimum interval (to prevent |
| // sliding to left/right when having reached the minimum zoom level) |
| var interval = (newEnd.valueOf() - newStart.valueOf()); |
| var zoomMin = Number(this.options.zoomMin) || 10; |
| if (zoomMin < 10) { |
| zoomMin = 10; |
| } |
| if (interval >= zoomMin) { |
| this.applyRange(newStart, newEnd, zoomAroundDate); |
| this.render({ |
| animate: this.options.animate && this.options.animateZoom |
| }); |
| } |
| }; |
| |
| /** |
| * Move the timeline the given movefactor to the left or right. Start and end |
| * date will be adjusted, and the timeline will be redrawn. |
| * For example, try moveFactor = 0.1 or -0.1 |
| * @param {Number} moveFactor Moving amount. Positive value will move right, |
| * negative value will move left |
| */ |
| links.Timeline.prototype.move = function(moveFactor) { |
| // zoom start Date and end Date relative to the zoomAroundDate |
| var diff = (this.end.valueOf() - this.start.valueOf()); |
| |
| // apply new dates |
| var newStart = new Date(this.start.valueOf() + diff * moveFactor); |
| var newEnd = new Date(this.end.valueOf() + diff * moveFactor); |
| this.applyRange(newStart, newEnd); |
| |
| this.render(); // TODO: optimize, no need to reflow, only to recalc conversion and repaint |
| }; |
| |
| /** |
| * Apply a visible range. The range is limited to feasible maximum and minimum |
| * range. |
| * @param {Date} start |
| * @param {Date} end |
| * @param {Date} zoomAroundDate Optional. Date around which will be zoomed. |
| */ |
| links.Timeline.prototype.applyRange = function (start, end, zoomAroundDate) { |
| // calculate new start and end value |
| var startValue = start.valueOf(); // number |
| var endValue = end.valueOf(); // number |
| var interval = (endValue - startValue); |
| |
| // determine maximum and minimum interval |
| var options = this.options; |
| var year = 1000 * 60 * 60 * 24 * 365; |
| var zoomMin = Number(options.zoomMin) || 10; |
| if (zoomMin < 10) { |
| zoomMin = 10; |
| } |
| var zoomMax = Number(options.zoomMax) || 10000 * year; |
| if (zoomMax > 10000 * year) { |
| zoomMax = 10000 * year; |
| } |
| if (zoomMax < zoomMin) { |
| zoomMax = zoomMin; |
| } |
| |
| // determine min and max date value |
| var min = options.min ? options.min.valueOf() : undefined; // number |
| var max = options.max ? options.max.valueOf() : undefined; // number |
| if (min != undefined && max != undefined) { |
| if (min >= max) { |
| // empty range |
| var day = 1000 * 60 * 60 * 24; |
| max = min + day; |
| } |
| if (zoomMax > (max - min)) { |
| zoomMax = (max - min); |
| } |
| if (zoomMin > (max - min)) { |
| zoomMin = (max - min); |
| } |
| } |
| |
| // prevent empty interval |
| if (startValue >= endValue) { |
| endValue += 1000 * 60 * 60 * 24; |
| } |
| |
| // prevent too small scale |
| // TODO: IE has problems with milliseconds |
| if (interval < zoomMin) { |
| var diff = (zoomMin - interval); |
| var f = zoomAroundDate ? (zoomAroundDate.valueOf() - startValue) / interval : 0.5; |
| startValue -= Math.round(diff * f); |
| endValue += Math.round(diff * (1 - f)); |
| } |
| |
| // prevent too large scale |
| if (interval > zoomMax) { |
| var diff = (interval - zoomMax); |
| var f = zoomAroundDate ? (zoomAroundDate.valueOf() - startValue) / interval : 0.5; |
| startValue += Math.round(diff * f); |
| endValue -= Math.round(diff * (1 - f)); |
| } |
| |
| // prevent to small start date |
| if (min != undefined) { |
| var diff = (startValue - min); |
| if (diff < 0) { |
| startValue -= diff; |
| endValue -= diff; |
| } |
| } |
| |
| // prevent to large end date |
| if (max != undefined) { |
| var diff = (max - endValue); |
| if (diff < 0) { |
| startValue += diff; |
| endValue += diff; |
| } |
| } |
| |
| // apply new dates |
| this.start = new Date(startValue); |
| this.end = new Date(endValue); |
| }; |
| |
| /** |
| * Delete an item after a confirmation. |
| * The deletion can be cancelled by executing .cancelDelete() during the |
| * triggered event 'delete'. |
| * @param {int} index Index of the item to be deleted |
| */ |
| links.Timeline.prototype.confirmDeleteItem = function(index) { |
| this.applyDelete = true; |
| |
| // select the event to be deleted |
| if (!this.isSelected(index)) { |
| this.selectItem(index); |
| } |
| |
| // fire a delete event trigger. |
| // Note that the delete event can be canceled from within an event listener if |
| // this listener calls the method cancelChange(). |
| this.trigger('delete'); |
| |
| if (this.applyDelete) { |
| this.deleteItem(index); |
| } |
| |
| delete this.applyDelete; |
| }; |
| |
| /** |
| * Delete an item |
| * @param {int} index Index of the item to be deleted |
| * @param {boolean} [preventRender=false] Do not re-render timeline if true |
| * (optimization for multiple delete) |
| */ |
| links.Timeline.prototype.deleteItem = function(index, preventRender) { |
| if (index >= this.items.length) { |
| throw "Cannot delete row, index out of range"; |
| } |
| |
| if (this.selection) { |
| // adjust the selection |
| if (this.selection.index == index) { |
| // item to be deleted is selected |
| this.unselectItem(); |
| } |
| else if (this.selection.index > index) { |
| // update selection index |
| this.selection.index--; |
| } |
| } |
| |
| // actually delete the item and remove it from the DOM |
| var item = this.items.splice(index, 1)[0]; |
| this.renderQueue.hide.push(item); |
| |
| // delete the row in the original data table |
| if (this.data) { |
| if (google && google.visualization && |
| this.data instanceof google.visualization.DataTable) { |
| this.data.removeRow(index); |
| } |
| else if (links.Timeline.isArray(this.data)) { |
| this.data.splice(index, 1); |
| } |
| else { |
| throw "Cannot delete row from data, unknown data type"; |
| } |
| } |
| |
| // prepare data for clustering, by filtering and sorting by type |
| if (this.options.cluster) { |
| this.clusterGenerator.updateData(); |
| } |
| |
| if (!preventRender) { |
| this.render(); |
| } |
| }; |
| |
| |
| /** |
| * Delete all items |
| */ |
| links.Timeline.prototype.deleteAllItems = function() { |
| this.unselectItem(); |
| |
| // delete the loaded items |
| this.clearItems(); |
| |
| // delete the groups |
| this.deleteGroups(); |
| |
| // empty original data table |
| if (this.data) { |
| if (google && google.visualization && |
| this.data instanceof google.visualization.DataTable) { |
| this.data.removeRows(0, this.data.getNumberOfRows()); |
| } |
| else if (links.Timeline.isArray(this.data)) { |
| this.data.splice(0, this.data.length); |
| } |
| else { |
| throw "Cannot delete row from data, unknown data type"; |
| } |
| } |
| |
| // prepare data for clustering, by filtering and sorting by type |
| if (this.options.cluster) { |
| this.clusterGenerator.updateData(); |
| } |
| |
| this.render(); |
| }; |
| |
| |
| /** |
| * Find the group from a given height in the timeline |
| * @param {Number} height Height in the timeline |
| * @return {Object | undefined} group The group object, or undefined if out |
| * of range |
| */ |
| links.Timeline.prototype.getGroupFromHeight = function(height) { |
| var i, |
| group, |
| groups = this.groups; |
| |
| if (groups.length) { |
| if (this.options.axisOnTop) { |
| for (i = groups.length - 1; i >= 0; i--) { |
| group = groups[i]; |
| if (height > group.top) { |
| return group; |
| } |
| } |
| } |
| else { |
| for (i = 0; i < groups.length; i++) { |
| group = groups[i]; |
| if (height > group.top) { |
| return group; |
| } |
| } |
| } |
| |
| return group; // return the last group |
| } |
| |
| return undefined; |
| }; |
| |
| /** |
| * @constructor links.Timeline.Item |
| * @param {Object} data Object containing parameters start, end |
| * content, group, type, editable. |
| * @param {Object} [options] Options to set initial property values |
| * {Number} top |
| * {Number} left |
| * {Number} width |
| * {Number} height |
| */ |
| links.Timeline.Item = function (data, options) { |
| if (data) { |
| /* TODO: use parseJSONDate as soon as it is tested and working (in two directions) |
| this.start = links.Timeline.parseJSONDate(data.start); |
| this.end = links.Timeline.parseJSONDate(data.end); |
| */ |
| this.start = data.start; |
| this.end = data.end; |
| this.content = data.content; |
| this.className = data.className; |
| this.editable = data.editable; |
| this.group = data.group; |
| this.type = data.type; |
| } |
| this.top = 0; |
| this.left = 0; |
| this.width = 0; |
| this.height = 0; |
| this.lineWidth = 0; |
| this.dotWidth = 0; |
| this.dotHeight = 0; |
| |
| this.rendered = false; // true when the item is draw in the Timeline DOM |
| |
| if (options) { |
| // override the default properties |
| for (var option in options) { |
| if (options.hasOwnProperty(option)) { |
| this[option] = options[option]; |
| } |
| } |
| } |
| |
| }; |
| |
| |
| |
| /** |
| * Reflow the Item: retrieve its actual size from the DOM |
| * @return {boolean} resized returns true if the axis is resized |
| */ |
| links.Timeline.Item.prototype.reflow = function () { |
| // Should be implemented by sub-prototype |
| return false; |
| }; |
| |
| /** |
| * Append all image urls present in the items DOM to the provided array |
| * @param {String[]} imageUrls |
| */ |
| links.Timeline.Item.prototype.getImageUrls = function (imageUrls) { |
| if (this.dom) { |
| links.imageloader.filterImageUrls(this.dom, imageUrls); |
| } |
| }; |
| |
| /** |
| * Select the item |
| */ |
| links.Timeline.Item.prototype.select = function () { |
| // Should be implemented by sub-prototype |
| }; |
| |
| /** |
| * Unselect the item |
| */ |
| links.Timeline.Item.prototype.unselect = function () { |
| // Should be implemented by sub-prototype |
| }; |
| |
| /** |
| * Creates the DOM for the item, depending on its type |
| * @return {Element | undefined} |
| */ |
| links.Timeline.Item.prototype.createDOM = function () { |
| // Should be implemented by sub-prototype |
| }; |
| |
| /** |
| * Append the items DOM to the given HTML container. If items DOM does not yet |
| * exist, it will be created first. |
| * @param {Element} container |
| */ |
| links.Timeline.Item.prototype.showDOM = function (container) { |
| // Should be implemented by sub-prototype |
| }; |
| |
| /** |
| * Remove the items DOM from the current HTML container |
| * @param {Element} container |
| */ |
| links.Timeline.Item.prototype.hideDOM = function (container) { |
| // Should be implemented by sub-prototype |
| }; |
| |
| /** |
| * Update the DOM of the item. This will update the content and the classes |
| * of the item |
| */ |
| links.Timeline.Item.prototype.updateDOM = function () { |
| // Should be implemented by sub-prototype |
| }; |
| |
| /** |
| * Reposition the item, recalculate its left, top, and width, using the current |
| * range of the timeline and the timeline options. |
| * @param {links.Timeline} timeline |
| */ |
| links.Timeline.Item.prototype.updatePosition = function (timeline) { |
| // Should be implemented by sub-prototype |
| }; |
| |
| /** |
| * Check if the item is drawn in the timeline (i.e. the DOM of the item is |
| * attached to the frame. You may also just request the parameter item.rendered |
| * @return {boolean} rendered |
| */ |
| links.Timeline.Item.prototype.isRendered = function () { |
| return this.rendered; |
| }; |
| |
| /** |
| * Check if the item is located in the visible area of the timeline, and |
| * not part of a cluster |
| * @param {Date} start |
| * @param {Date} end |
| * @return {boolean} visible |
| */ |
| links.Timeline.Item.prototype.isVisible = function (start, end) { |
| // Should be implemented by sub-prototype |
| return false; |
| }; |
| |
| /** |
| * Reposition the item |
| * @param {Number} left |
| * @param {Number} right |
| */ |
| links.Timeline.Item.prototype.setPosition = function (left, right) { |
| // Should be implemented by sub-prototype |
| }; |
| |
| /** |
| * Calculate the right position of the item |
| * @param {links.Timeline} timeline |
| * @return {Number} right |
| */ |
| links.Timeline.Item.prototype.getRight = function (timeline) { |
| // Should be implemented by sub-prototype |
| return 0; |
| }; |
| |
| /** |
| * Calculate the width of the item |
| * @param {links.Timeline} timeline |
| * @return {Number} width |
| */ |
| links.Timeline.Item.prototype.getWidth = function (timeline) { |
| // Should be implemented by sub-prototype |
| return this.width || 0; // last rendered width |
| }; |
| |
| |
| /** |
| * @constructor links.Timeline.ItemBox |
| * @extends links.Timeline.Item |
| * @param {Object} data Object containing parameters start, end |
| * content, group, type, className, editable. |
| * @param {Object} [options] Options to set initial property values |
| * {Number} top |
| * {Number} left |
| * {Number} width |
| * {Number} height |
| */ |
| links.Timeline.ItemBox = function (data, options) { |
| links.Timeline.Item.call(this, data, options); |
| }; |
| |
| links.Timeline.ItemBox.prototype = new links.Timeline.Item(); |
| |
| /** |
| * Reflow the Item: retrieve its actual size from the DOM |
| * @return {boolean} resized returns true if the axis is resized |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.reflow = function () { |
| var dom = this.dom, |
| dotHeight = dom.dot.offsetHeight, |
| dotWidth = dom.dot.offsetWidth, |
| lineWidth = dom.line.offsetWidth, |
| resized = ( |
| (this.dotHeight != dotHeight) || |
| (this.dotWidth != dotWidth) || |
| (this.lineWidth != lineWidth) |
| ); |
| |
| this.dotHeight = dotHeight; |
| this.dotWidth = dotWidth; |
| this.lineWidth = lineWidth; |
| |
| return resized; |
| }; |
| |
| /** |
| * Select the item |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.select = function () { |
| var dom = this.dom; |
| links.Timeline.addClassName(dom, 'timeline-event-selected ui-state-active'); |
| links.Timeline.addClassName(dom.line, 'timeline-event-selected ui-state-active'); |
| links.Timeline.addClassName(dom.dot, 'timeline-event-selected ui-state-active'); |
| }; |
| |
| /** |
| * Unselect the item |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.unselect = function () { |
| var dom = this.dom; |
| links.Timeline.removeClassName(dom, 'timeline-event-selected ui-state-active'); |
| links.Timeline.removeClassName(dom.line, 'timeline-event-selected ui-state-active'); |
| links.Timeline.removeClassName(dom.dot, 'timeline-event-selected ui-state-active'); |
| }; |
| |
| /** |
| * Creates the DOM for the item, depending on its type |
| * @return {Element | undefined} |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.createDOM = function () { |
| // background box |
| var divBox = document.createElement("DIV"); |
| divBox.style.position = "absolute"; |
| divBox.style.left = this.left + "px"; |
| divBox.style.top = this.top + "px"; |
| |
| // contents box (inside the background box). used for making margins |
| var divContent = document.createElement("DIV"); |
| divContent.className = "timeline-event-content"; |
| divContent.innerHTML = this.content; |
| divBox.appendChild(divContent); |
| |
| // line to axis |
| var divLine = document.createElement("DIV"); |
| divLine.style.position = "absolute"; |
| divLine.style.width = "0px"; |
| // important: the vertical line is added at the front of the list of elements, |
| // so it will be drawn behind all boxes and ranges |
| divBox.line = divLine; |
| |
| // dot on axis |
| var divDot = document.createElement("DIV"); |
| divDot.style.position = "absolute"; |
| divDot.style.width = "0px"; |
| divDot.style.height = "0px"; |
| divBox.dot = divDot; |
| |
| this.dom = divBox; |
| this.updateDOM(); |
| |
| return divBox; |
| }; |
| |
| /** |
| * Append the items DOM to the given HTML container. If items DOM does not yet |
| * exist, it will be created first. |
| * @param {Element} container |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.showDOM = function (container) { |
| var dom = this.dom; |
| if (!dom) { |
| dom = this.createDOM(); |
| } |
| |
| if (dom.parentNode != container) { |
| if (dom.parentNode) { |
| // container is changed. remove from old container |
| this.hideDOM(); |
| } |
| |
| // append to this container |
| container.appendChild(dom); |
| container.insertBefore(dom.line, container.firstChild); |
| // Note: line must be added in front of the this, |
| // such that it stays below all this |
| container.appendChild(dom.dot); |
| this.rendered = true; |
| } |
| }; |
| |
| /** |
| * Remove the items DOM from the current HTML container, but keep the DOM in |
| * memory |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.hideDOM = function () { |
| var dom = this.dom; |
| if (dom) { |
| if (dom.parentNode) { |
| dom.parentNode.removeChild(dom); |
| } |
| if (dom.line && dom.line.parentNode) { |
| dom.line.parentNode.removeChild(dom.line); |
| } |
| if (dom.dot && dom.dot.parentNode) { |
| dom.dot.parentNode.removeChild(dom.dot); |
| } |
| this.rendered = false; |
| } |
| }; |
| |
| /** |
| * Update the DOM of the item. This will update the content and the classes |
| * of the item |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.updateDOM = function () { |
| var divBox = this.dom; |
| if (divBox) { |
| var divLine = divBox.line; |
| var divDot = divBox.dot; |
| |
| // update contents |
| divBox.firstChild.innerHTML = this.content; |
| |
| // update class |
| divBox.className = "timeline-event timeline-event-box ui-widget ui-state-default"; |
| divLine.className = "timeline-event timeline-event-line ui-widget ui-state-default"; |
| divDot.className = "timeline-event timeline-event-dot ui-widget ui-state-default"; |
| |
| if (this.isCluster) { |
| links.Timeline.addClassName(divBox, 'timeline-event-cluster ui-widget-header'); |
| links.Timeline.addClassName(divLine, 'timeline-event-cluster ui-widget-header'); |
| links.Timeline.addClassName(divDot, 'timeline-event-cluster ui-widget-header'); |
| } |
| |
| // add item specific class name when provided |
| if (this.className) { |
| links.Timeline.addClassName(divBox, this.className); |
| links.Timeline.addClassName(divLine, this.className); |
| links.Timeline.addClassName(divDot, this.className); |
| } |
| |
| // TODO: apply selected className? |
| } |
| }; |
| |
| /** |
| * Reposition the item, recalculate its left, top, and width, using the current |
| * range of the timeline and the timeline options. |
| * @param {links.Timeline} timeline |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.updatePosition = function (timeline) { |
| var dom = this.dom; |
| if (dom) { |
| var left = timeline.timeToScreen(this.start), |
| axisOnTop = timeline.options.axisOnTop, |
| axisTop = timeline.size.axis.top, |
| axisHeight = timeline.size.axis.height, |
| boxAlign = (timeline.options.box && timeline.options.box.align) ? |
| timeline.options.box.align : undefined; |
| |
| dom.style.top = this.top + "px"; |
| if (boxAlign == 'right') { |
| dom.style.left = (left - this.width) + "px"; |
| } |
| else if (boxAlign == 'left') { |
| dom.style.left = (left) + "px"; |
| } |
| else { // default or 'center' |
| dom.style.left = (left - this.width/2) + "px"; |
| } |
| |
| var line = dom.line; |
| var dot = dom.dot; |
| line.style.left = (left - this.lineWidth/2) + "px"; |
| dot.style.left = (left - this.dotWidth/2) + "px"; |
| if (axisOnTop) { |
| line.style.top = axisHeight + "px"; |
| line.style.height = Math.max(this.top - axisHeight, 0) + "px"; |
| dot.style.top = (axisHeight - this.dotHeight/2) + "px"; |
| } |
| else { |
| line.style.top = (this.top + this.height) + "px"; |
| line.style.height = Math.max(axisTop - this.top - this.height, 0) + "px"; |
| dot.style.top = (axisTop - this.dotHeight/2) + "px"; |
| } |
| } |
| }; |
| |
| /** |
| * Check if the item is visible in the timeline, and not part of a cluster |
| * @param {Date} start |
| * @param {Date} end |
| * @return {Boolean} visible |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.isVisible = function (start, end) { |
| if (this.cluster) { |
| return false; |
| } |
| |
| return (this.start > start) && (this.start < end); |
| }; |
| |
| /** |
| * Reposition the item |
| * @param {Number} left |
| * @param {Number} right |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.setPosition = function (left, right) { |
| var dom = this.dom; |
| |
| dom.style.left = (left - this.width / 2) + "px"; |
| dom.line.style.left = (left - this.lineWidth / 2) + "px"; |
| dom.dot.style.left = (left - this.dotWidth / 2) + "px"; |
| |
| if (this.group) { |
| this.top = this.group.top; |
| dom.style.top = this.top + 'px'; |
| } |
| }; |
| |
| /** |
| * Calculate the right position of the item |
| * @param {links.Timeline} timeline |
| * @return {Number} right |
| * @override |
| */ |
| links.Timeline.ItemBox.prototype.getRight = function (timeline) { |
| var boxAlign = (timeline.options.box && timeline.options.box.align) ? |
| timeline.options.box.align : undefined; |
| |
| var left = timeline.timeToScreen(this.start); |
| var right; |
| if (boxAlign == 'right') { |
| right = left; |
| } |
| else if (boxAlign == 'left') { |
| right = (left + this.width); |
| } |
| else { // default or 'center' |
| right = (left + this.width / 2); |
| } |
| |
| return right; |
| }; |
| |
| /** |
| * @constructor links.Timeline.ItemRange |
| * @extends links.Timeline.Item |
| * @param {Object} data Object containing parameters start, end |
| * content, group, type, className, editable. |
| * @param {Object} [options] Options to set initial property values |
| * {Number} top |
| * {Number} left |
| * {Number} width |
| * {Number} height |
| */ |
| links.Timeline.ItemRange = function (data, options) { |
| links.Timeline.Item.call(this, data, options); |
| }; |
| |
| links.Timeline.ItemRange.prototype = new links.Timeline.Item(); |
| |
| /** |
| * Select the item |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.select = function () { |
| var dom = this.dom; |
| links.Timeline.addClassName(dom, 'timeline-event-selected ui-state-active'); |
| }; |
| |
| /** |
| * Unselect the item |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.unselect = function () { |
| var dom = this.dom; |
| links.Timeline.removeClassName(dom, 'timeline-event-selected ui-state-active'); |
| }; |
| |
| /** |
| * Creates the DOM for the item, depending on its type |
| * @return {Element | undefined} |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.createDOM = function () { |
| // background box |
| var divBox = document.createElement("DIV"); |
| divBox.style.position = "absolute"; |
| |
| // contents box |
| var divContent = document.createElement("DIV"); |
| divContent.className = "timeline-event-content"; |
| divBox.appendChild(divContent); |
| |
| this.dom = divBox; |
| this.updateDOM(); |
| |
| return divBox; |
| }; |
| |
| /** |
| * Append the items DOM to the given HTML container. If items DOM does not yet |
| * exist, it will be created first. |
| * @param {Element} container |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.showDOM = function (container) { |
| var dom = this.dom; |
| if (!dom) { |
| dom = this.createDOM(); |
| } |
| |
| if (dom.parentNode != container) { |
| if (dom.parentNode) { |
| // container changed. remove the item from the old container |
| this.hideDOM(); |
| } |
| |
| // append to the new container |
| container.appendChild(dom); |
| this.rendered = true; |
| } |
| }; |
| |
| /** |
| * Remove the items DOM from the current HTML container |
| * The DOM will be kept in memory |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.hideDOM = function () { |
| var dom = this.dom; |
| if (dom) { |
| if (dom.parentNode) { |
| dom.parentNode.removeChild(dom); |
| } |
| this.rendered = false; |
| } |
| }; |
| |
| /** |
| * Update the DOM of the item. This will update the content and the classes |
| * of the item |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.updateDOM = function () { |
| var divBox = this.dom; |
| if (divBox) { |
| // update contents |
| divBox.firstChild.innerHTML = this.content; |
| |
| // update class |
| divBox.className = "timeline-event timeline-event-range ui-widget ui-state-default"; |
| |
| if (this.isCluster) { |
| links.Timeline.addClassName(divBox, 'timeline-event-cluster ui-widget-header'); |
| } |
| |
| // add item specific class name when provided |
| if (this.className) { |
| links.Timeline.addClassName(divBox, this.className); |
| } |
| |
| // TODO: apply selected className? |
| } |
| }; |
| |
| /** |
| * Reposition the item, recalculate its left, top, and width, using the current |
| * range of the timeline and the timeline options. * |
| * @param {links.Timeline} timeline |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.updatePosition = function (timeline) { |
| var dom = this.dom; |
| if (dom) { |
| var contentWidth = timeline.size.contentWidth, |
| left = timeline.timeToScreen(this.start), |
| right = timeline.timeToScreen(this.end); |
| |
| // limit the width of the this, as browsers cannot draw very wide divs |
| if (left < -contentWidth) { |
| left = -contentWidth; |
| } |
| if (right > 2 * contentWidth) { |
| right = 2 * contentWidth; |
| } |
| |
| dom.style.top = this.top + "px"; |
| dom.style.left = left + "px"; |
| //dom.style.width = Math.max(right - left - 2 * this.borderWidth, 1) + "px"; // TODO: borderWidth |
| dom.style.width = Math.max(right - left, 1) + "px"; |
| } |
| }; |
| |
| /** |
| * Check if the item is visible in the timeline, and not part of a cluster |
| * @param {Number} start |
| * @param {Number} end |
| * @return {boolean} visible |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.isVisible = function (start, end) { |
| if (this.cluster) { |
| return false; |
| } |
| |
| return (this.end > start) |
| && (this.start < end); |
| }; |
| |
| /** |
| * Reposition the item |
| * @param {Number} left |
| * @param {Number} right |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.setPosition = function (left, right) { |
| var dom = this.dom; |
| |
| dom.style.left = left + 'px'; |
| dom.style.width = (right - left) + 'px'; |
| |
| if (this.group) { |
| this.top = this.group.top; |
| dom.style.top = this.top + 'px'; |
| } |
| }; |
| |
| /** |
| * Calculate the right position of the item |
| * @param {links.Timeline} timeline |
| * @return {Number} right |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.getRight = function (timeline) { |
| return timeline.timeToScreen(this.end); |
| }; |
| |
| /** |
| * Calculate the width of the item |
| * @param {links.Timeline} timeline |
| * @return {Number} width |
| * @override |
| */ |
| links.Timeline.ItemRange.prototype.getWidth = function (timeline) { |
| return timeline.timeToScreen(this.end) - timeline.timeToScreen(this.start); |
| }; |
| |
| /** |
| * @constructor links.Timeline.ItemDot |
| * @extends links.Timeline.Item |
| * @param {Object} data Object containing parameters start, end |
| * content, group, type, className, editable. |
| * @param {Object} [options] Options to set initial property values |
| * {Number} top |
| * {Number} left |
| * {Number} width |
| * {Number} height |
| */ |
| links.Timeline.ItemDot = function (data, options) { |
| links.Timeline.Item.call(this, data, options); |
| }; |
| |
| links.Timeline.ItemDot.prototype = new links.Timeline.Item(); |
| |
| /** |
| * Reflow the Item: retrieve its actual size from the DOM |
| * @return {boolean} resized returns true if the axis is resized |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.reflow = function () { |
| var dom = this.dom, |
| dotHeight = dom.dot.offsetHeight, |
| dotWidth = dom.dot.offsetWidth, |
| contentHeight = dom.content.offsetHeight, |
| resized = ( |
| (this.dotHeight != dotHeight) || |
| (this.dotWidth != dotWidth) || |
| (this.contentHeight != contentHeight) |
| ); |
| |
| this.dotHeight = dotHeight; |
| this.dotWidth = dotWidth; |
| this.contentHeight = contentHeight; |
| |
| return resized; |
| }; |
| |
| /** |
| * Select the item |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.select = function () { |
| var dom = this.dom; |
| links.Timeline.addClassName(dom, 'timeline-event-selected ui-state-active'); |
| }; |
| |
| /** |
| * Unselect the item |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.unselect = function () { |
| var dom = this.dom; |
| links.Timeline.removeClassName(dom, 'timeline-event-selected ui-state-active'); |
| }; |
| |
| /** |
| * Creates the DOM for the item, depending on its type |
| * @return {Element | undefined} |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.createDOM = function () { |
| // background box |
| var divBox = document.createElement("DIV"); |
| divBox.style.position = "absolute"; |
| |
| // contents box, right from the dot |
| var divContent = document.createElement("DIV"); |
| divContent.className = "timeline-event-content"; |
| divBox.appendChild(divContent); |
| |
| // dot at start |
| var divDot = document.createElement("DIV"); |
| divDot.style.position = "absolute"; |
| divDot.style.width = "0px"; |
| divDot.style.height = "0px"; |
| divBox.appendChild(divDot); |
| |
| divBox.content = divContent; |
| divBox.dot = divDot; |
| |
| this.dom = divBox; |
| this.updateDOM(); |
| |
| return divBox; |
| }; |
| |
| /** |
| * Append the items DOM to the given HTML container. If items DOM does not yet |
| * exist, it will be created first. |
| * @param {Element} container |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.showDOM = function (container) { |
| var dom = this.dom; |
| if (!dom) { |
| dom = this.createDOM(); |
| } |
| |
| if (dom.parentNode != container) { |
| if (dom.parentNode) { |
| // container changed. remove it from old container first |
| this.hideDOM(); |
| } |
| |
| // append to container |
| container.appendChild(dom); |
| this.rendered = true; |
| } |
| }; |
| |
| /** |
| * Remove the items DOM from the current HTML container |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.hideDOM = function () { |
| var dom = this.dom; |
| if (dom) { |
| if (dom.parentNode) { |
| dom.parentNode.removeChild(dom); |
| } |
| this.rendered = false; |
| } |
| }; |
| |
| /** |
| * Update the DOM of the item. This will update the content and the classes |
| * of the item |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.updateDOM = function () { |
| if (this.dom) { |
| var divBox = this.dom; |
| var divDot = divBox.dot; |
| |
| // update contents |
| divBox.firstChild.innerHTML = this.content; |
| |
| // update classes |
| divBox.className = "timeline-event-dot-container"; |
| divDot.className = "timeline-event timeline-event-dot ui-widget ui-state-default"; |
| |
| if (this.isCluster) { |
| links.Timeline.addClassName(divBox, 'timeline-event-cluster ui-widget-header'); |
| links.Timeline.addClassName(divDot, 'timeline-event-cluster ui-widget-header'); |
| } |
| |
| // add item specific class name when provided |
| if (this.className) { |
| links.Timeline.addClassName(divBox, this.className); |
| links.Timeline.addClassName(divDot, this.className); |
| } |
| |
| // TODO: apply selected className? |
| } |
| }; |
| |
| /** |
| * Reposition the item, recalculate its left, top, and width, using the current |
| * range of the timeline and the timeline options. * |
| * @param {links.Timeline} timeline |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.updatePosition = function (timeline) { |
| var dom = this.dom; |
| if (dom) { |
| var left = timeline.timeToScreen(this.start); |
| |
| dom.style.top = this.top + "px"; |
| dom.style.left = (left - this.dotWidth / 2) + "px"; |
| |
| dom.content.style.marginLeft = (1.5 * this.dotWidth) + "px"; |
| //dom.content.style.marginRight = (0.5 * this.dotWidth) + "px"; // TODO |
| dom.dot.style.top = ((this.height - this.dotHeight) / 2) + "px"; |
| } |
| }; |
| |
| /** |
| * Check if the item is visible in the timeline, and not part of a cluster. |
| * @param {Date} start |
| * @param {Date} end |
| * @return {boolean} visible |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.isVisible = function (start, end) { |
| if (this.cluster) { |
| return false; |
| } |
| |
| return (this.start > start) |
| && (this.start < end); |
| }; |
| |
| /** |
| * Reposition the item |
| * @param {Number} left |
| * @param {Number} right |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.setPosition = function (left, right) { |
| var dom = this.dom; |
| |
| dom.style.left = (left - this.dotWidth / 2) + "px"; |
| |
| if (this.group) { |
| this.top = this.group.top; |
| dom.style.top = this.top + 'px'; |
| } |
| }; |
| |
| /** |
| * Calculate the right position of the item |
| * @param {links.Timeline} timeline |
| * @return {Number} right |
| * @override |
| */ |
| links.Timeline.ItemDot.prototype.getRight = function (timeline) { |
| return timeline.timeToScreen(this.start) + this.width; |
| }; |
| |
| /** |
| * Retrieve the properties of an item. |
| * @param {Number} index |
| * @return {Object} properties Object containing item properties:<br> |
| * {Date} start (required), |
| * {Date} end (optional), |
| * {String} content (required), |
| * {String} group (optional), |
| * {String} className (optional) |
| * {boolean} editable (optional) |
| * {String} type (optional) |
| */ |
| links.Timeline.prototype.getItem = function (index) { |
| if (index >= this.items.length) { |
| throw "Cannot get item, index out of range"; |
| } |
| |
| var item = this.items[index]; |
| |
| var properties = {}; |
| properties.start = new Date(item.start.valueOf()); |
| if (item.end) { |
| properties.end = new Date(item.end.valueOf()); |
| } |
| properties.content = item.content; |
| if (item.group) { |
| properties.group = this.getGroupName(item.group); |
| } |
| if ('className' in item) { |
| properties.className = this.getGroupName(item.className); |
| } |
| if (item.hasOwnProperty('editable') && (typeof item.editable != 'undefined')) { |
| properties.editable = item.editable; |
| } |
| if (item.type) { |
| properties.type = item.type; |
| } |
| |
| return properties; |
| }; |
| |
| /** |
| * Add a new item. |
| * @param {Object} itemData Object containing item properties:<br> |
| * {Date} start (required), |
| * {Date} end (optional), |
| * {String} content (required), |
| * {String} group (optional) |
| * {String} className (optional) |
| * {Boolean} editable (optional) |
| * {String} type (optional) |
| * @param {boolean} [preventRender=false] Do not re-render timeline if true |
| */ |
| links.Timeline.prototype.addItem = function (itemData, preventRender) { |
| var itemsData = [ |
| itemData |
| ]; |
| |
| this.addItems(itemsData, preventRender); |
| }; |
| |
| /** |
| * Add new items. |
| * @param {Array} itemsData An array containing Objects. |
| * The objects must have the following parameters: |
| * {Date} start, |
| * {Date} end, |
| * {String} content with text or HTML code, |
| * {String} group (optional) |
| * {String} className (optional) |
| * {String} editable (optional) |
| * {String} type (optional) |
| * @param {boolean} [preventRender=false] Do not re-render timeline if true |
| */ |
| links.Timeline.prototype.addItems = function (itemsData, preventRender) { |
| var timeline = this, |
| items = this.items; |
| |
| // append the items |
| itemsData.forEach(function (itemData) { |
| var index = items.length; |
| items.push(timeline.createItem(itemData)); |
| timeline.updateData(index, itemData); |
| |
| // note: there is no need to add the item to the renderQueue, that |
| // will be done when this.render() is executed and all items are |
| // filtered again. |
| }); |
| |
| // prepare data for clustering, by filtering and sorting by type |
| if (this.options.cluster) { |
| this.clusterGenerator.updateData(); |
| } |
| |
| if (!preventRender) { |
| this.render({ |
| animate: false |
| }); |
| } |
| }; |
| |
| /** |
| * Create an item object, containing all needed parameters |
| * @param {Object} itemData Object containing parameters start, end |
| * content, group. |
| * @return {Object} item |
| */ |
| links.Timeline.prototype.createItem = function(itemData) { |
| var type = itemData.type || (itemData.end ? 'range' : this.options.style); |
| var data = { |
| start: itemData.start, |
| end: itemData.end, |
| content: itemData.content, |
| className: itemData.className, |
| editable: itemData.editable, |
| group: this.getGroup(itemData.group), |
| type: type |
| }; |
| // TODO: optimize this, when creating an item, all data is copied twice... |
| |
| // TODO: is initialTop needed? |
| var initialTop, |
| options = this.options; |
| if (options.axisOnTop) { |
| initialTop = this.size.axis.height + options.eventMarginAxis + options.eventMargin / 2; |
| } |
| else { |
| initialTop = this.size.contentHeight - options.eventMarginAxis - options.eventMargin / 2; |
| } |
| |
| if (type in this.itemTypes) { |
| return new this.itemTypes[type](data, {'top': initialTop}) |
| } |
| |
| console.log('ERROR: Unknown event style "' + type + '"'); |
| return new links.Timeline.Item(data, { |
| 'top': initialTop |
| }); |
| }; |
| |
| /** |
| * Edit an item |
| * @param {Number} index |
| * @param {Object} itemData Object containing item properties:<br> |
| * {Date} start (required), |
| * {Date} end (optional), |
| * {String} content (required), |
| * {String} group (optional) |
| * @param {boolean} [preventRender=false] Do not re-render timeline if true |
| */ |
| links.Timeline.prototype.changeItem = function (index, itemData, preventRender) { |
| var oldItem = this.items[index]; |
| if (!oldItem) { |
| throw "Cannot change item, index out of range"; |
| } |
| |
| // replace item, merge the changes |
| var newItem = this.createItem({ |
| 'start': itemData.hasOwnProperty('start') ? itemData.start : oldItem.start, |
| 'end': itemData.hasOwnProperty('end') ? itemData.end : oldItem.end, |
| 'content': itemData.hasOwnProperty('content') ? itemData.content : oldItem.content, |
| 'group': itemData.hasOwnProperty('group') ? itemData.group : this.getGroupName(oldItem.group), |
| 'className': itemData.hasOwnProperty('className') ? itemData.className : oldItem.className, |
| 'editable': itemData.hasOwnProperty('editable') ? itemData.editable : oldItem.editable, |
| 'type': itemData.hasOwnProperty('type') ? itemData.type : oldItem.type |
| }); |
| this.items[index] = newItem; |
| |
| // append the changes to the render queue |
| this.renderQueue.hide.push(oldItem); |
| this.renderQueue.show.push(newItem); |
| |
| // update the original data table |
| this.updateData(index, itemData); |
| |
| // prepare data for clustering, by filtering and sorting by type |
| if (this.options.cluster) { |
| this.clusterGenerator.updateData(); |
| } |
| |
| if (!preventRender) { |
| // redraw timeline |
| this.render({ |
| animate: false |
| }); |
| |
| if (this.selection && this.selection.index == index) { |
| newItem.select(); |
| } |
| } |
| }; |
| |
| /** |
| * Delete all groups |
| */ |
| links.Timeline.prototype.deleteGroups = function () { |
| this.groups = []; |
| this.groupIndexes = {}; |
| }; |
| |
| |
| /** |
| * Get a group by the group name. When the group does not exist, |
| * it will be created. |
| * @param {String} groupName the name of the group |
| * @return {Object} groupObject |
| */ |
| links.Timeline.prototype.getGroup = function (groupName) { |
| var groups = this.groups, |
| groupIndexes = this.groupIndexes, |
| groupObj = undefined; |
| |
| var groupIndex = groupIndexes[groupName]; |
| if (groupIndex == undefined && groupName != undefined) { // not null or undefined |
| groupObj = { |
| 'content': groupName, |
| 'labelTop': 0, |
| 'lineTop': 0 |
| // note: this object will lateron get addition information, |
| // such as height and width of the group |
| }; |
| groups.push(groupObj); |
| // sort the groups |
| groups = groups.sort(function (a, b) { |
| if (a.content > b.content) { |
| return 1; |
| } |
| if (a.content < b.content) { |
| return -1; |
| } |
| return 0; |
| }); |
| |
| // rebuilt the groupIndexes |
| for (var i = 0, iMax = groups.length; i < iMax; i++) { |
| groupIndexes[groups[i].content] = i; |
| } |
| } |
| else { |
| groupObj = groups[groupIndex]; |
| } |
| |
| return groupObj; |
| }; |
| |
| /** |
| * Get the group name from a group object. |
| * @param {Object} groupObj |
| * @return {String} groupName the name of the group, or undefined when group |
| * was not provided |
| */ |
| links.Timeline.prototype.getGroupName = function (groupObj) { |
| return groupObj ? groupObj.content : undefined; |
| }; |
| |
| /** |
| * Cancel a change item |
| * This method can be called insed an event listener which catches the "change" |
| * event. The changed event position will be undone. |
| */ |
| links.Timeline.prototype.cancelChange = function () { |
| this.applyChange = false; |
| }; |
| |
| /** |
| * Cancel deletion of an item |
| * This method can be called insed an event listener which catches the "delete" |
| * event. Deletion of the event will be undone. |
| */ |
| links.Timeline.prototype.cancelDelete = function () { |
| this.applyDelete = false; |
| }; |
| |
| |
| /** |
| * Cancel creation of a new item |
| * This method can be called insed an event listener which catches the "new" |
| * event. Creation of the new the event will be undone. |
| */ |
| links.Timeline.prototype.cancelAdd = function () { |
| this.applyAdd = false; |
| }; |
| |
| |
| /** |
| * Select an event. The visible chart range will be moved such that the selected |
| * event is placed in the middle. |
| * For example selection = [{row: 5}]; |
| * @param {Array} selection An array with a column row, containing the row |
| * number (the id) of the event to be selected. |
| * @return {boolean} true if selection is succesfully set, else false. |
| */ |
| links.Timeline.prototype.setSelection = function(selection) { |
| if (selection != undefined && selection.length > 0) { |
| if (selection[0].row != undefined) { |
| var index = selection[0].row; |
| if (this.items[index]) { |
| var item = this.items[index]; |
| this.selectItem(index); |
| |
| // move the visible chart range to the selected event. |
| var start = item.start; |
| var end = item.end; |
| var middle; // number |
| if (end != undefined) { |
| middle = (end.valueOf() + start.valueOf()) / 2; |
| } else { |
| middle = start.valueOf(); |
| } |
| var diff = (this.end.valueOf() - this.start.valueOf()), |
| newStart = new Date(middle - diff/2), |
| newEnd = new Date(middle + diff/2); |
| |
| this.setVisibleChartRange(newStart, newEnd); |
| |
| return true; |
| } |
| } |
| } |
| else { |
| // unselect current selection |
| this.unselectItem(); |
| } |
| return false; |
| }; |
| |
| /** |
| * Retrieve the currently selected event |
| * @return {Array} sel An array with a column row, containing the row number |
| * of the selected event. If there is no selection, an |
| * empty array is returned. |
| */ |
| links.Timeline.prototype.getSelection = function() { |
| var sel = []; |
| if (this.selection) { |
| sel.push({"row": this.selection.index}); |
| } |
| return sel; |
| }; |
| |
| |
| /** |
| * Select an item by its index |
| * @param {Number} index |
| */ |
| links.Timeline.prototype.selectItem = function(index) { |
| this.unselectItem(); |
| |
| this.selection = undefined; |
| |
| if (this.items[index] != undefined) { |
| var item = this.items[index], |
| domItem = item.dom; |
| |
| this.selection = { |
| 'index': index |
| }; |
| |
| if (item && item.dom) { |
| // TODO: move adjusting the domItem to the item itself |
| if (this.isEditable(item)) { |
| item.dom.style.cursor = 'move'; |
| } |
| item.select(); |
| } |
| this.repaintDeleteButton(); |
| this.repaintDragAreas(); |
| } |
| }; |
| |
| /** |
| * Check if an item is currently selected |
| * @param {Number} index |
| * @return {boolean} true if row is selected, else false |
| */ |
| links.Timeline.prototype.isSelected = function (index) { |
| return (this.selection && this.selection.index == index); |
| }; |
| |
| /** |
| * Unselect the currently selected event (if any) |
| */ |
| links.Timeline.prototype.unselectItem = function() { |
| if (this.selection) { |
| var item = this.items[this.selection.index]; |
| |
| if (item && item.dom) { |
| var domItem = item.dom; |
| domItem.style.cursor = ''; |
| item.unselect(); |
| } |
| |
| this.selection = undefined; |
| this.repaintDeleteButton(); |
| this.repaintDragAreas(); |
| } |
| }; |
| |
| |
| /** |
| * Stack the items such that they don't overlap. The items will have a minimal |
| * distance equal to options.eventMargin. |
| * @param {boolean | undefined} animate if animate is true, the items are |
| * moved to their new position animated |
| * defaults to false. |
| */ |
| links.Timeline.prototype.stackItems = function(animate) { |
| if (this.groups.length > 0) { |
| // under this conditions we refuse to stack the events |
| // TODO: implement support for stacking items per group |
| return; |
| } |
| |
| if (animate == undefined) { |
| animate = false; |
| } |
| |
| // calculate the order and final stack position of the items |
| var stack = this.stack; |
| if (!stack) { |
| stack = {}; |
| this.stack = stack; |
| } |
| stack.sortedItems = this.stackOrder(this.renderedItems); |
| stack.finalItems = this.stackCalculateFinal(stack.sortedItems); |
| |
| if (animate || stack.timer) { |
| // move animated to the final positions |
| var timeline = this; |
| var step = function () { |
| var arrived = timeline.stackMoveOneStep(stack.sortedItems, |
| stack.finalItems); |
| |
| timeline.repaint(); |
| |
| if (!arrived) { |
| stack.timer = setTimeout(step, 30); |
| } |
| else { |
| delete stack.timer; |
| } |
| }; |
| |
| if (!stack.timer) { |
| stack.timer = setTimeout(step, 30); |
| } |
| } |
| else { |
| // move immediately to the final positions |
| this.stackMoveToFinal(stack.sortedItems, stack.finalItems); |
| } |
| }; |
| |
| /** |
| * Cancel any running animation |
| */ |
| links.Timeline.prototype.stackCancelAnimation = function() { |
| if (this.stack && this.stack.timer) { |
| clearTimeout(this.stack.timer); |
| delete this.stack.timer; |
| } |
| }; |
| |
| |
| /** |
| * Order the items in the array this.items. The default order is determined via: |
| * - Ranges go before boxes and dots. |
| * - The item with the oldest start time goes first |
| * If a custom function has been provided via the stackorder option, then this will be used. |
| * @param {Array} items Array with items |
| * @return {Array} sortedItems Array with sorted items |
| */ |
| links.Timeline.prototype.stackOrder = function(items) { |
| // TODO: store the sorted items, to have less work later on |
| var sortedItems = items.concat([]); |
| |
| //if a customer stack order function exists, use it. |
| var f = this.options.customStackOrder && (typeof this.options.customStackOrder === 'function') ? this.options.customStackOrder : function (a, b) |
| { |
| if ((a instanceof links.Timeline.ItemRange) && |
| !(b instanceof links.Timeline.ItemRange)) { |
| return -1; |
| } |
| |
| if (!(a instanceof links.Timeline.ItemRange) && |
| (b instanceof links.Timeline.ItemRange)) { |
| return 1; |
| } |
| |
| return (a.left - b.left); |
| }; |
| |
| sortedItems.sort(f); |
| |
| return sortedItems; |
| }; |
| |
| /** |
| * Adjust vertical positions of the events such that they don't overlap each |
| * other. |
| * @param {timeline.Item[]} items |
| * @return {Object[]} finalItems |
| */ |
| links.Timeline.prototype.stackCalculateFinal = function(items) { |
| var i, |
| iMax, |
| size = this.size, |
| axisTop = size.axis.top, |
| axisHeight = size.axis.height, |
| options = this.options, |
| axisOnTop = options.axisOnTop, |
| eventMargin = options.eventMargin, |
| eventMarginAxis = options.eventMarginAxis, |
| finalItems = []; |
| |
| // initialize final positions |
| for (i = 0, iMax = items.length; i < iMax; i++) { |
| var item = items[i], |
| top, |
| bottom, |
| height = item.height, |
| width = item.getWidth(this), |
| right = item.getRight(this), |
| left = right - width; |
| |
| if (axisOnTop) { |
| top = axisHeight + eventMarginAxis + eventMargin / 2; |
| } |
| else { |
| top = axisTop - height - eventMarginAxis - eventMargin / 2; |
| } |
| bottom = top + height; |
| |
| finalItems[i] = { |
| 'left': left, |
| 'top': top, |
| 'right': right, |
| 'bottom': bottom, |
| 'height': height, |
| 'item': item |
| }; |
| } |
| |
| if (this.options.stackEvents) { |
| // calculate new, non-overlapping positions |
| //var items = sortedItems; |
| for (i = 0, iMax = finalItems.length; i < iMax; i++) { |
| //for (var i = finalItems.length - 1; i >= 0; i--) { |
| var finalItem = finalItems[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.stackItemsCheckOverlap(finalItems, i, 0, i-1); |
| if (collidingItem != null) { |
| // There is a collision. Reposition the event above the colliding element |
| if (axisOnTop) { |
| finalItem.top = collidingItem.top + collidingItem.height + eventMargin; |
| } |
| else { |
| finalItem.top = collidingItem.top - finalItem.height - eventMargin; |
| } |
| finalItem.bottom = finalItem.top + finalItem.height; |
| } |
| } while (collidingItem); |
| } |
| } |
| |
| return finalItems; |
| }; |
| |
| |
| /** |
| * Move the events one step in the direction of their final positions |
| * @param {Array} currentItems Array with the real items and their current |
| * positions |
| * @param {Array} finalItems Array with objects containing the final |
| * positions of the items |
| * @return {boolean} arrived True if all items have reached their final |
| * location, else false |
| */ |
| links.Timeline.prototype.stackMoveOneStep = function(currentItems, finalItems) { |
| var arrived = true; |
| |
| // apply new positions animated |
| for (i = 0, iMax = finalItems.length; i < iMax; i++) { |
| var finalItem = finalItems[i], |
| item = finalItem.item; |
| |
| var topNow = parseInt(item.top); |
| var topFinal = parseInt(finalItem.top); |
| var diff = (topFinal - topNow); |
| if (diff) { |
| var step = (topFinal == topNow) ? 0 : ((topFinal > topNow) ? 1 : -1); |
| if (Math.abs(diff) > 4) step = diff / 4; |
| var topNew = parseInt(topNow + step); |
| |
| if (topNew != topFinal) { |
| arrived = false; |
| } |
| |
| item.top = topNew; |
| item.bottom = item.top + item.height; |
| } |
| else { |
| item.top = finalItem.top; |
| item.bottom = finalItem.bottom; |
| } |
| |
| item.left = finalItem.left; |
| item.right = finalItem.right; |
| } |
| |
| return arrived; |
| }; |
| |
| |
| |
| /** |
| * Move the events from their current position to the final position |
| * @param {Array} currentItems Array with the real items and their current |
| * positions |
| * @param {Array} finalItems Array with objects containing the final |
| * positions of the items |
| */ |
| links.Timeline.prototype.stackMoveToFinal = function(currentItems, finalItems) { |
| // Put the events directly at there final position |
| for (i = 0, iMax = finalItems.length; i < iMax; i++) { |
| var finalItem = finalItems[i], |
| current = finalItem.item; |
| |
| current.left = finalItem.left; |
| current.top = finalItem.top; |
| current.right = finalItem.right; |
| current.bottom = finalItem.bottom; |
| } |
| }; |
| |
| |
| |
| /** |
| * 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} colliding item, or undefined when no collisions |
| */ |
| links.Timeline.prototype.stackItemsCheckOverlap = function(items, itemIndex, |
| itemStart, itemEnd) { |
| var eventMargin = this.options.eventMargin, |
| 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 item1 = items[itemIndex]; |
| for (var i = itemEnd; i >= itemStart; i--) { |
| var item2 = items[i]; |
| if (collision(item1, item2, eventMargin)) { |
| if (i != itemIndex) { |
| return item2; |
| } |
| } |
| } |
| |
| return undefined; |
| }; |
| |
| /** |
| * Test if the two provided items collide |
| * The items must have parameters left, right, top, and bottom. |
| * @param {Element} item1 The first item |
| * @param {Element} item2 The second item |
| * @param {Number} margin A minimum required margin. Optional. |
| * 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 item1 and item2 collide, else false |
| */ |
| links.Timeline.prototype.collision = function(item1, item2, margin) { |
| // set margin if not specified |
| if (margin == undefined) { |
| margin = 0; |
| } |
| |
| // calculate if there is overlap (collision) |
| return (item1.left - margin < item2.right && |
| item1.right + margin > item2.left && |
| item1.top - margin < item2.bottom && |
| item1.bottom + margin > item2.top); |
| }; |
| |
| |
| /** |
| * fire an event |
| * @param {String} event The name of an event, for example "rangechange" or "edit" |
| */ |
| links.Timeline.prototype.trigger = function (event) { |
| // built up properties |
| var properties = null; |
| switch (event) { |
| case 'rangechange': |
| case 'rangechanged': |
| properties = { |
| 'start': new Date(this.start.valueOf()), |
| 'end': new Date(this.end.valueOf()) |
| }; |
| break; |
| |
| case 'timechange': |
| case 'timechanged': |
| properties = { |
| 'time': new Date(this.customTime.valueOf()) |
| }; |
| break; |
| } |
| |
| // trigger the links event bus |
| links.events.trigger(this, event, properties); |
| |
| // trigger the google event bus |
| if (google && google.visualization) { |
| google.visualization.events.trigger(this, event, properties); |
| } |
| }; |
| |
| |
| /** |
| * Cluster the events |
| */ |
| links.Timeline.prototype.clusterItems = function () { |
| if (!this.options.cluster) { |
| return; |
| } |
| |
| var clusters = this.clusterGenerator.getClusters(this.conversion.factor); |
| if (this.clusters != clusters) { |
| // cluster level changed |
| var queue = this.renderQueue; |
| |
| // remove the old clusters from the scene |
| if (this.clusters) { |
| this.clusters.forEach(function (cluster) { |
| queue.hide.push(cluster); |
| |
| // unlink the items |
| cluster.items.forEach(function (item) { |
| item.cluster = undefined; |
| }); |
| }); |
| } |
| |
| // append the new clusters |
| clusters.forEach(function (cluster) { |
| // don't add to the queue.show here, will be done in .filterItems() |
| |
| // link all items to the cluster |
| cluster.items.forEach(function (item) { |
| item.cluster = cluster; |
| }); |
| }); |
| |
| this.clusters = clusters; |
| } |
| }; |
| |
| /** |
| * Filter the visible events |
| */ |
| links.Timeline.prototype.filterItems = function () { |
| var queue = this.renderQueue, |
| window = (this.end - this.start), |
| start = new Date(this.start.valueOf() - window), |
| end = new Date(this.end.valueOf() + window); |
| |
| function filter (arr) { |
| arr.forEach(function (item) { |
| var rendered = item.rendered; |
| var visible = item.isVisible(start, end); |
| if (rendered != visible) { |
| if (rendered) { |
| queue.hide.push(item); // item is rendered but no longer visible |
| } |
| if (visible && (queue.show.indexOf(item) == -1)) { |
| queue.show.push(item); // item is visible but neither rendered nor queued up to be rendered |
| } |
| } |
| }); |
| } |
| |
| // filter all items and all clusters |
| filter(this.items); |
| if (this.clusters) { |
| filter(this.clusters); |
| } |
| }; |
| |
| /** ------------------------------------------------------------------------ **/ |
| |
| /** |
| * @constructor links.Timeline.ClusterGenerator |
| * Generator which creates clusters of items, based on the visible range in |
| * the Timeline. There is a set of cluster levels which is cached. |
| * @param {links.Timeline} timeline |
| */ |
| links.Timeline.ClusterGenerator = function (timeline) { |
| this.timeline = timeline; |
| this.clear(); |
| }; |
| |
| /** |
| * Clear all cached clusters and data, and initialize all variables |
| */ |
| links.Timeline.ClusterGenerator.prototype.clear = function () { |
| // cache containing created clusters for each cluster level |
| this.items = []; |
| this.groups = {}; |
| this.clearCache(); |
| }; |
| |
| /** |
| * Clear the cached clusters |
| */ |
| links.Timeline.ClusterGenerator.prototype.clearCache = function () { |
| // cache containing created clusters for each cluster level |
| this.cache = {}; |
| this.cacheLevel = -1; |
| this.cache[this.cacheLevel] = []; |
| }; |
| |
| /** |
| * Set the items to be clustered. |
| * This will clear cached clusters. |
| * @param {Item[]} items |
| * @param {Object} [options] Available options: |
| * {boolean} applyOnChangedLevel |
| * If true (default), the changed data is applied |
| * as soon the cluster level changes. If false, |
| * The changed data is applied immediately |
| */ |
| links.Timeline.ClusterGenerator.prototype.setData = function (items, options) { |
| this.items = items || []; |
| this.dataChanged = true; |
| this.applyOnChangedLevel = true; |
| if (options && options.applyOnChangedLevel) { |
| this.applyOnChangedLevel = options.applyOnChangedLevel; |
| } |
| // console.log('clustergenerator setData applyOnChangedLevel=' + this.applyOnChangedLevel); // TODO: cleanup |
| }; |
| |
| /** |
| * Update the current data set: clear cache, and recalculate the clustering for |
| * the current level |
| */ |
| links.Timeline.ClusterGenerator.prototype.updateData = function () { |
| this.dataChanged = true; |
| this.applyOnChangedLevel = false; |
| }; |
| |
| /** |
| * Filter the items per group. |
| * @private |
| */ |
| links.Timeline.ClusterGenerator.prototype.filterData = function () { |
| // filter per group |
| var items = this.items || []; |
| var groups = {}; |
| this.groups = groups; |
| |
| // split the items per group |
| items.forEach(function (item) { |
| // put the item in the correct group |
| var groupName = item.group ? item.group.content : ''; |
| var group = groups[groupName]; |
| if (!group) { |
| group = []; |
| groups[groupName] = group; |
| } |
| group.push(item); |
| |
| // calculate the center of the item |
| if (item.start) { |
| if (item.end) { |
| // range |
| item.center = (item.start.valueOf() + item.end.valueOf()) / 2; |
| } |
| else { |
| // box, dot |
| item.center = item.start.valueOf(); |
| } |
| } |
| }); |
| |
| // sort the items per group |
| for (var groupName in groups) { |
| if (groups.hasOwnProperty(groupName)) { |
| groups[groupName].sort(function (a, b) { |
| return (a.center - b.center); |
| }); |
| } |
| } |
| |
| this.dataChanged = false; |
| }; |
| |
| /** |
| * Cluster the events which are too close together |
| * @param {Number} scale The scale of the current window, |
| * defined as (windowWidth / (endDate - startDate)) |
| * @return {Item[]} clusters |
| */ |
| links.Timeline.ClusterGenerator.prototype.getClusters = function (scale) { |
| var level = -1, |
| granularity = 2, // TODO: what granularity is needed for the cluster levels? |
| timeWindow = 0, // milliseconds |
| maxItems = 5; // TODO: do not hard code maxItems |
| |
| if (scale > 0) { |
| level = Math.round(Math.log(100 / scale) / Math.log(granularity)); |
| timeWindow = Math.pow(granularity, level); |
| |
| // groups must have a larger time window, as the items will not be stacked |
| if (this.timeline.groups && this.timeline.groups.length) { |
| timeWindow *= 4; |
| } |
| } |
| |
| // clear the cache when and re-filter the data when needed. |
| if (this.dataChanged) { |
| var levelChanged = (level != this.cacheLevel); |
| var applyDataNow = this.applyOnChangedLevel ? levelChanged : true; |
| if (applyDataNow) { |
| // TODO: currently drawn clusters should be removed! mark them as invisible? |
| this.clearCache(); |
| this.filterData(); |
| // console.log('clustergenerator: cache cleared...'); // TODO: cleanup |
| } |
| } |
| |
| this.cacheLevel = level; |
| var clusters = this.cache[level]; |
| if (!clusters) { |
| // console.log('clustergenerator: create cluster level ' + level); // TODO: cleanup |
| clusters = []; |
| |
| // TODO: spit this method, it is too large |
| for (var groupName in this.groups) { |
| if (this.groups.hasOwnProperty(groupName)) { |
| var items = this.groups[groupName]; |
| var iMax = items.length; |
| var i = 0; |
| while (i < iMax) { |
| // find all items around current item, within the timeWindow |
| var item = items[i]; |
| var neighbors = 1; // start at 1, to include itself) |
| |
| // loop through items left from the current item |
| var j = i - 1; |
| while (j >= 0 && (item.center - items[j].center) < timeWindow / 2) { |
| if (!items[j].cluster) { |
| neighbors++; |
| } |
| j--; |
| } |
| |
| // loop through items right from the current item |
| var k = i + 1; |
| while (k < items.length && (items[k].center - item.center) < timeWindow / 2) { |
| neighbors++; |
| k++; |
| } |
| |
| // loop through the created clusters |
| var l = clusters.length - 1; |
| while (l >= 0 && (item.center - clusters[l].center) < timeWindow / 2) { |
| if (item.group == clusters[l].group) { |
| neighbors++; |
| } |
| l--; |
| } |
| |
| // aggregate until the number of items is within maxItems |
| if (neighbors > maxItems) { |
| // too busy in this window. |
| var num = neighbors - maxItems + 1; |
| var clusterItems = []; |
| |
| // append the items to the cluster, |
| // and calculate the average start for the cluster |
| var avg = undefined; // number. average of all start dates |
| var min = undefined; // number. minimum of all start dates |
| var max = undefined; // number. maximum of all start and end dates |
| var containsRanges = false; |
| var count = 0; |
| var m = i; |
| while (clusterItems.length < num && m < items.length) { |
| var p = items[m]; |
| var start = p.start.valueOf(); |
| var end = p.end ? p.end.valueOf() : p.start.valueOf(); |
| clusterItems.push(p); |
| if (count) { |
| // calculate new average (use fractions to prevent overflow) |
| avg = (count / (count + 1)) * avg + (1 / (count + 1)) * p.center; |
| } |
| else { |
| avg = p.center; |
| } |
| min = (min != undefined) ? Math.min(min, start) : start; |
| max = (max != undefined) ? Math.max(max, end) : end; |
| containsRanges = containsRanges || (p instanceof links.Timeline.ItemRange); |
| count++; |
| m++; |
| } |
| |
| var cluster; |
| var title = 'Cluster containing ' + count + |
| ' events. Zoom in to see the individual events.'; |
| var content = '<div title="' + title + '">' + count + ' events</div>'; |
| var group = item.group ? item.group.content : undefined; |
| if (containsRanges) { |
| // boxes and/or ranges |
| cluster = this.timeline.createItem({ |
| 'start': new Date(min), |
| 'end': new Date(max), |
| 'content': content, |
| 'group': group |
| }); |
| } |
| else { |
| // boxes only |
| cluster = this.timeline.createItem({ |
| 'start': new Date(avg), |
| 'content': content, |
| 'group': group |
| }); |
| } |
| cluster.isCluster = true; |
| cluster.items = clusterItems; |
| cluster.items.forEach(function (item) { |
| item.cluster = cluster; |
| }); |
| |
| clusters.push(cluster); |
| i += num; |
| } |
| else { |
| delete item.cluster; |
| i += 1; |
| } |
| } |
| } |
| } |
| |
| this.cache[level] = clusters; |
| } |
| |
| return clusters; |
| }; |
| |
| |
| /** ------------------------------------------------------------------------ **/ |
| |
| |
| /** |
| * Event listener (singleton) |
| */ |
| links.events = links.events || { |
| 'listeners': [], |
| |
| /** |
| * Find a single listener by its object |
| * @param {Object} object |
| * @return {Number} index -1 when not found |
| */ |
| 'indexOf': function (object) { |
| var listeners = this.listeners; |
| for (var i = 0, iMax = this.listeners.length; i < iMax; i++) { |
| var listener = listeners[i]; |
| if (listener && listener.object == object) { |
| return i; |
| } |
| } |
| return -1; |
| }, |
| |
| /** |
| * Add an event listener |
| * @param {Object} object |
| * @param {String} event The name of an event, for example 'select' |
| * @param {function} callback The callback method, called when the |
| * event takes place |
| */ |
| 'addListener': function (object, event, callback) { |
| var index = this.indexOf(object); |
| var listener = this.listeners[index]; |
| if (!listener) { |
| listener = { |
| 'object': object, |
| 'events': {} |
| }; |
| this.listeners.push(listener); |
| } |
| |
| var callbacks = listener.events[event]; |
| if (!callbacks) { |
| callbacks = []; |
| listener.events[event] = callbacks; |
| } |
| |
| // add the callback if it does not yet exist |
| if (callbacks.indexOf(callback) == -1) { |
| callbacks.push(callback); |
| } |
| }, |
| |
| /** |
| * Remove an event listener |
| * @param {Object} object |
| * @param {String} event The name of an event, for example 'select' |
| * @param {function} callback The registered callback method |
| */ |
| 'removeListener': function (object, event, callback) { |
| var index = this.indexOf(object); |
| var listener = this.listeners[index]; |
| if (listener) { |
| var callbacks = listener.events[event]; |
| if (callbacks) { |
| var index = callbacks.indexOf(callback); |
| if (index != -1) { |
| callbacks.splice(index, 1); |
| } |
| |
| // remove the array when empty |
| if (callbacks.length == 0) { |
| delete listener.events[event]; |
| } |
| } |
| |
| // count the number of registered events. remove listener when empty |
| var count = 0; |
| var events = listener.events; |
| for (var e in events) { |
| if (events.hasOwnProperty(e)) { |
| count++; |
| } |
| } |
| if (count == 0) { |
| delete this.listeners[index]; |
| } |
| } |
| }, |
| |
| /** |
| * Remove all registered event listeners |
| */ |
| 'removeAllListeners': function () { |
| this.listeners = []; |
| }, |
| |
| /** |
| * Trigger an event. All registered event handlers will be called |
| * @param {Object} object |
| * @param {String} event |
| * @param {Object} properties (optional) |
| */ |
| 'trigger': function (object, event, properties) { |
| var index = this.indexOf(object); |
| var listener = this.listeners[index]; |
| if (listener) { |
| var callbacks = listener.events[event]; |
| if (callbacks) { |
| for (var i = 0, iMax = callbacks.length; i < iMax; i++) { |
| callbacks[i](properties); |
| } |
| } |
| } |
| } |
| }; |
| |
| |
| /** ------------------------------------------------------------------------ **/ |
| |
| /** |
| * @constructor links.Timeline.StepDate |
| * The class StepDate 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 start(). 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 end(). After each step, you can |
| * retrieve the current date via get(). |
| * The class step 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 |
| */ |
| links.Timeline.StepDate = function(start, end, minimumStep) { |
| |
| // variables |
| this.current = new Date(); |
| this._start = new Date(); |
| this._end = new Date(); |
| |
| this.autoScale = true; |
| this.scale = links.Timeline.StepDate.SCALE.DAY; |
| this.step = 1; |
| |
| // initialize the range |
| this.setRange(start, end, minimumStep); |
| }; |
| |
| /// enum scale |
| links.Timeline.StepDate.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 |
| */ |
| links.Timeline.StepDate.prototype.setRange = function(start, end, minimumStep) { |
| if (!(start instanceof Date) || !(end instanceof Date)) { |
| //throw "No legal start or end date in method setRange"; |
| return; |
| } |
| |
| 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 step iterator to the start date. |
| */ |
| links.Timeline.StepDate.prototype.start = 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 |
| */ |
| links.Timeline.StepDate.prototype.roundToMinor = function() { |
| // round to floor |
| // IMPORTANT: we have no breaks in this switch! (this is no bug) |
| //noinspection FallthroughInSwitchStatementJS |
| switch (this.scale) { |
| case links.Timeline.StepDate.SCALE.YEAR: |
| this.current.setFullYear(this.step * Math.floor(this.current.getFullYear() / this.step)); |
| this.current.setMonth(0); |
| case links.Timeline.StepDate.SCALE.MONTH: this.current.setDate(1); |
| case links.Timeline.StepDate.SCALE.DAY: // intentional fall through |
| case links.Timeline.StepDate.SCALE.WEEKDAY: this.current.setHours(0); |
| case links.Timeline.StepDate.SCALE.HOUR: this.current.setMinutes(0); |
| case links.Timeline.StepDate.SCALE.MINUTE: this.current.setSeconds(0); |
| case links.Timeline.StepDate.SCALE.SECOND: this.current.setMilliseconds(0); |
| //case links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.MILLISECOND: this.current.setMilliseconds(this.current.getMilliseconds() - this.current.getMilliseconds() % this.step); break; |
| case links.Timeline.StepDate.SCALE.SECOND: this.current.setSeconds(this.current.getSeconds() - this.current.getSeconds() % this.step); break; |
| case links.Timeline.StepDate.SCALE.MINUTE: this.current.setMinutes(this.current.getMinutes() - this.current.getMinutes() % this.step); break; |
| case links.Timeline.StepDate.SCALE.HOUR: this.current.setHours(this.current.getHours() - this.current.getHours() % this.step); break; |
| case links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through |
| case links.Timeline.StepDate.SCALE.DAY: this.current.setDate((this.current.getDate()-1) - (this.current.getDate()-1) % this.step + 1); break; |
| case links.Timeline.StepDate.SCALE.MONTH: this.current.setMonth(this.current.getMonth() - this.current.getMonth() % this.step); break; |
| case links.Timeline.StepDate.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() - this.current.getFullYear() % this.step); break; |
| default: break; |
| } |
| } |
| }; |
| |
| /** |
| * Check if the end date is reached |
| * @return {boolean} true if the current date has passed the end date |
| */ |
| links.Timeline.StepDate.prototype.end = function () { |
| return (this.current.valueOf() > this._end.valueOf()); |
| }; |
| |
| /** |
| * Do the next step |
| */ |
| links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.MILLISECOND: |
| |
| this.current = new Date(this.current.valueOf() + this.step); break; |
| case links.Timeline.StepDate.SCALE.SECOND: this.current = new Date(this.current.valueOf() + this.step * 1000); break; |
| case links.Timeline.StepDate.SCALE.MINUTE: this.current = new Date(this.current.valueOf() + this.step * 1000 * 60); break; |
| case links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through |
| case links.Timeline.StepDate.SCALE.DAY: this.current.setDate(this.current.getDate() + this.step); break; |
| case links.Timeline.StepDate.SCALE.MONTH: this.current.setMonth(this.current.getMonth() + this.step); break; |
| case links.Timeline.StepDate.SCALE.YEAR: this.current.setFullYear(this.current.getFullYear() + this.step); break; |
| default: break; |
| } |
| } |
| else { |
| switch (this.scale) { |
| case links.Timeline.StepDate.SCALE.MILLISECOND: this.current = new Date(this.current.valueOf() + this.step); break; |
| case links.Timeline.StepDate.SCALE.SECOND: this.current.setSeconds(this.current.getSeconds() + this.step); break; |
| case links.Timeline.StepDate.SCALE.MINUTE: this.current.setMinutes(this.current.getMinutes() + this.step); break; |
| case links.Timeline.StepDate.SCALE.HOUR: this.current.setHours(this.current.getHours() + this.step); break; |
| case links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through |
| case links.Timeline.StepDate.SCALE.DAY: this.current.setDate(this.current.getDate() + this.step); break; |
| case links.Timeline.StepDate.SCALE.MONTH: this.current.setMonth(this.current.getMonth() + this.step); break; |
| case links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.MILLISECOND: if(this.current.getMilliseconds() < this.step) this.current.setMilliseconds(0); break; |
| case links.Timeline.StepDate.SCALE.SECOND: if(this.current.getSeconds() < this.step) this.current.setSeconds(0); break; |
| case links.Timeline.StepDate.SCALE.MINUTE: if(this.current.getMinutes() < this.step) this.current.setMinutes(0); break; |
| case links.Timeline.StepDate.SCALE.HOUR: if(this.current.getHours() < this.step) this.current.setHours(0); break; |
| case links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through |
| case links.Timeline.StepDate.SCALE.DAY: if(this.current.getDate() < this.step+1) this.current.setDate(1); break; |
| case links.Timeline.StepDate.SCALE.MONTH: if(this.current.getMonth() < this.step) this.current.setMonth(0); break; |
| case links.Timeline.StepDate.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 |
| */ |
| links.Timeline.StepDate.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 {links.Timeline.StepDate.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. |
| */ |
| links.Timeline.StepDate.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 |
| */ |
| links.Timeline.StepDate.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 |
| */ |
| links.Timeline.StepDate.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 = links.Timeline.StepDate.SCALE.YEAR; this.step = 1000;} |
| if (stepYear*500 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 500;} |
| if (stepYear*100 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 100;} |
| if (stepYear*50 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 50;} |
| if (stepYear*10 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 10;} |
| if (stepYear*5 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 5;} |
| if (stepYear > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.YEAR; this.step = 1;} |
| if (stepMonth*3 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MONTH; this.step = 3;} |
| if (stepMonth > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MONTH; this.step = 1;} |
| if (stepDay*5 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.DAY; this.step = 5;} |
| if (stepDay*2 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.DAY; this.step = 2;} |
| if (stepDay > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.DAY; this.step = 1;} |
| if (stepDay/2 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.WEEKDAY; this.step = 1;} |
| if (stepHour*4 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.HOUR; this.step = 4;} |
| if (stepHour > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.HOUR; this.step = 1;} |
| if (stepMinute*15 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MINUTE; this.step = 15;} |
| if (stepMinute*10 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MINUTE; this.step = 10;} |
| if (stepMinute*5 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MINUTE; this.step = 5;} |
| if (stepMinute > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MINUTE; this.step = 1;} |
| if (stepSecond*15 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.SECOND; this.step = 15;} |
| if (stepSecond*10 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.SECOND; this.step = 10;} |
| if (stepSecond*5 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.SECOND; this.step = 5;} |
| if (stepSecond > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.SECOND; this.step = 1;} |
| if (stepMillisecond*200 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 200;} |
| if (stepMillisecond*100 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 100;} |
| if (stepMillisecond*50 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 50;} |
| if (stepMillisecond*10 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 10;} |
| if (stepMillisecond*5 > minimumStep) {this.scale = links.Timeline.StepDate.SCALE.MILLISECOND; this.step = 5;} |
| if (stepMillisecond > minimumStep) {this.scale = links.Timeline.StepDate.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 |
| */ |
| links.Timeline.StepDate.prototype.snap = function(date) { |
| if (this.scale == links.Timeline.StepDate.SCALE.YEAR) { |
| var year = date.getFullYear() + Math.round(date.getMonth() / 12); |
| date.setFullYear(Math.round(year / this.step) * this.step); |
| date.setMonth(0); |
| date.setDate(0); |
| date.setHours(0); |
| date.setMinutes(0); |
| date.setSeconds(0); |
| date.setMilliseconds(0); |
| } |
| else if (this.scale == links.Timeline.StepDate.SCALE.MONTH) { |
| if (date.getDate() > 15) { |
| date.setDate(1); |
| date.setMonth(date.getMonth() + 1); |
| // important: first set Date to 1, after that change the month. |
| } |
| else { |
| date.setDate(1); |
| } |
| |
| date.setHours(0); |
| date.setMinutes(0); |
| date.setSeconds(0); |
| date.setMilliseconds(0); |
| } |
| else if (this.scale == links.Timeline.StepDate.SCALE.DAY || |
| this.scale == links.Timeline.StepDate.SCALE.WEEKDAY) { |
| switch (this.step) { |
| case 5: |
| case 2: |
| date.setHours(Math.round(date.getHours() / 24) * 24); break; |
| default: |
| date.setHours(Math.round(date.getHours() / 12) * 12); break; |
| } |
| date.setMinutes(0); |
| date.setSeconds(0); |
| date.setMilliseconds(0); |
| } |
| else if (this.scale == links.Timeline.StepDate.SCALE.HOUR) { |
| switch (this.step) { |
| case 4: |
| date.setMinutes(Math.round(date.getMinutes() / 60) * 60); break; |
| default: |
| date.setMinutes(Math.round(date.getMinutes() / 30) * 30); break; |
| } |
| date.setSeconds(0); |
| date.setMilliseconds(0); |
| } else if (this.scale == links.Timeline.StepDate.SCALE.MINUTE) { |
| switch (this.step) { |
| case 15: |
| case 10: |
| date.setMinutes(Math.round(date.getMinutes() / 5) * 5); |
| date.setSeconds(0); |
| break; |
| case 5: |
| date.setSeconds(Math.round(date.getSeconds() / 60) * 60); break; |
| default: |
| date.setSeconds(Math.round(date.getSeconds() / 30) * 30); break; |
| } |
| date.setMilliseconds(0); |
| } |
| else if (this.scale == links.Timeline.StepDate.SCALE.SECOND) { |
| switch (this.step) { |
| case 15: |
| case 10: |
| date.setSeconds(Math.round(date.getSeconds() / 5) * 5); |
| date.setMilliseconds(0); |
| break; |
| case 5: |
| date.setMilliseconds(Math.round(date.getMilliseconds() / 1000) * 1000); break; |
| default: |
| date.setMilliseconds(Math.round(date.getMilliseconds() / 500) * 500); break; |
| } |
| } |
| else if (this.scale == links.Timeline.StepDate.SCALE.MILLISECOND) { |
| var step = this.step > 5 ? this.step / 2 : 1; |
| date.setMilliseconds(Math.round(date.getMilliseconds() / step) * step); |
| } |
| }; |
| |
| /** |
| * Check if the current step is a major step (for example when the step |
| * is DAY, a major step is each first day of the MONTH) |
| * @return {boolean} true if current date is major, else false. |
| */ |
| links.Timeline.StepDate.prototype.isMajor = function() { |
| switch (this.scale) { |
| case links.Timeline.StepDate.SCALE.MILLISECOND: |
| return (this.current.getMilliseconds() == 0); |
| case links.Timeline.StepDate.SCALE.SECOND: |
| return (this.current.getSeconds() == 0); |
| case links.Timeline.StepDate.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 links.Timeline.StepDate.SCALE.HOUR: |
| return (this.current.getHours() == 0); |
| case links.Timeline.StepDate.SCALE.WEEKDAY: // intentional fall through |
| case links.Timeline.StepDate.SCALE.DAY: |
| return (this.current.getDate() == 1); |
| case links.Timeline.StepDate.SCALE.MONTH: |
| return (this.current.getMonth() == 0); |
| case links.Timeline.StepDate.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 {Object} options |
| * @param {Date} [date] custom date. if not provided, current date is taken |
| */ |
| links.Timeline.StepDate.prototype.getLabelMinor = function(options, date) { |
| if (date == undefined) { |
| date = this.current; |
| } |
| |
| switch (this.scale) { |
| case links.Timeline.StepDate.SCALE.MILLISECOND: return String(date.getMilliseconds()); |
| case links.Timeline.StepDate.SCALE.SECOND: return String(date.getSeconds()); |
| case links.Timeline.StepDate.SCALE.MINUTE: |
| return this.addZeros(date.getHours(), 2) + ":" + this.addZeros(date.getMinutes(), 2); |
| case links.Timeline.StepDate.SCALE.HOUR: |
| return this.addZeros(date.getHours(), 2) + ":" + this.addZeros(date.getMinutes(), 2); |
| case links.Timeline.StepDate.SCALE.WEEKDAY: return options.DAYS_SHORT[date.getDay()] + ' ' + date.getDate(); |
| case links.Timeline.StepDate.SCALE.DAY: return String(date.getDate()); |
| case links.Timeline.StepDate.SCALE.MONTH: return options.MONTHS_SHORT[date.getMonth()]; // month is zero based |
| case links.Timeline.StepDate.SCALE.YEAR: return String(date.getFullYear()); |
| default: return ""; |
| } |
| }; |
| |
| |
| /** |
| * Returns formatted text for the major axislabel, 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 {Object} options |
| * @param {Date} [date] custom date. if not provided, current date is taken |
| */ |
| links.Timeline.StepDate.prototype.getLabelMajor = function(options, date) { |
| if (date == undefined) { |
| date = this.current; |
| } |
| |
| switch (this.scale) { |
| case links.Timeline.StepDate.SCALE.MILLISECOND: |
| return this.addZeros(date.getHours(), 2) + ":" + |
| this.addZeros(date.getMinutes(), 2) + ":" + |
| this.addZeros(date.getSeconds(), 2); |
| case links.Timeline.StepDate.SCALE.SECOND: |
| return date.getDate() + " " + |
| options.MONTHS[date.getMonth()] + " " + |
| this.addZeros(date.getHours(), 2) + ":" + |
| this.addZeros(date.getMinutes(), 2); |
| case links.Timeline.StepDate.SCALE.MINUTE: |
| return options.DAYS[date.getDay()] + " " + |
| date.getDate() + " " + |
| options.MONTHS[date.getMonth()] + " " + |
| date.getFullYear(); |
| case links.Timeline.StepDate.SCALE.HOUR: |
| return options.DAYS[date.getDay()] + " " + |
| date.getDate() + " " + |
| options.MONTHS[date.getMonth()] + " " + |
| date.getFullYear(); |
| case links.Timeline.StepDate.SCALE.WEEKDAY: |
| case links.Timeline.StepDate.SCALE.DAY: |
| return options.MONTHS[date.getMonth()] + " " + |
| date.getFullYear(); |
| case links.Timeline.StepDate.SCALE.MONTH: |
| return String(date.getFullYear()); |
| default: |
| return ""; |
| } |
| }; |
| |
| /** |
| * Add leading zeros to the given value to match the desired length. |
| * For example addZeros(123, 5) returns "00123" |
| * @param {int} value A value |
| * @param {int} len Desired final length |
| * @return {string} value with leading zeros |
| */ |
| links.Timeline.StepDate.prototype.addZeros = function(value, len) { |
| var str = "" + value; |
| while (str.length < len) { |
| str = "0" + str; |
| } |
| return str; |
| }; |
| |
| |
| |
| /** ------------------------------------------------------------------------ **/ |
| |
| /** |
| * Image Loader service. |
| * can be used to get a callback when a certain image is loaded |
| * |
| */ |
| links.imageloader = (function () { |
| var urls = {}; // the loaded urls |
| var callbacks = {}; // the urls currently being loaded. Each key contains |
| // an array with callbacks |
| |
| /** |
| * Check if an image url is loaded |
| * @param {String} url |
| * @return {boolean} loaded True when loaded, false when not loaded |
| * or when being loaded |
| */ |
| function isLoaded (url) { |
| if (urls[url] == true) { |
| return true; |
| } |
| |
| var image = new Image(); |
| image.src = url; |
| if (image.complete) { |
| return true; |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Check if an image url is being loaded |
| * @param {String} url |
| * @return {boolean} loading True when being loaded, false when not loading |
| * or when already loaded |
| */ |
| function isLoading (url) { |
| return (callbacks[url] != undefined); |
| } |
| |
| /** |
| * Load given image url |
| * @param {String} url |
| * @param {function} callback |
| * @param {boolean} sendCallbackWhenAlreadyLoaded optional |
| */ |
| function load (url, callback, sendCallbackWhenAlreadyLoaded) { |
| if (sendCallbackWhenAlreadyLoaded == undefined) { |
| sendCallbackWhenAlreadyLoaded = true; |
| } |
| |
| if (isLoaded(url)) { |
| if (sendCallbackWhenAlreadyLoaded) { |
| callback(url); |
| } |
| return; |
| } |
| |
| if (isLoading(url) && !sendCallbackWhenAlreadyLoaded) { |
| return; |
| } |
| |
| var c = callbacks[url]; |
| if (!c) { |
| var image = new Image(); |
| image.src = url; |
| |
| c = []; |
| callbacks[url] = c; |
| |
| image.onload = function (event) { |
| urls[url] = true; |
| delete callbacks[url]; |
| |
| for (var i = 0; i < c.length; i++) { |
| c[i](url); |
| } |
| } |
| } |
| |
| if (c.indexOf(callback) == -1) { |
| c.push(callback); |
| } |
| } |
| |
| /** |
| * Load a set of images, and send a callback as soon as all images are |
| * loaded |
| * @param {String[]} urls |
| * @param {function } callback |
| * @param {boolean} sendCallbackWhenAlreadyLoaded |
| */ |
| function loadAll (urls, callback, sendCallbackWhenAlreadyLoaded) { |
| // list all urls which are not yet loaded |
| var urlsLeft = []; |
| urls.forEach(function (url) { |
| if (!isLoaded(url)) { |
| urlsLeft.push(url); |
| } |
| }); |
| |
| if (urlsLeft.length) { |
| // there are unloaded images |
| var countLeft = urlsLeft.length; |
| urlsLeft.forEach(function (url) { |
| load(url, function () { |
| countLeft--; |
| if (countLeft == 0) { |
| // done! |
| callback(); |
| } |
| }, sendCallbackWhenAlreadyLoaded); |
| }); |
| } |
| else { |
| // we are already done! |
| if (sendCallbackWhenAlreadyLoaded) { |
| callback(); |
| } |
| } |
| } |
| |
| /** |
| * Recursively retrieve all image urls from the images located inside a given |
| * HTML element |
| * @param {Node} elem |
| * @param {String[]} urls Urls will be added here (no duplicates) |
| */ |
| function filterImageUrls (elem, urls) { |
| var child = elem.firstChild; |
| while (child) { |
| if (child.tagName == 'IMG') { |
| var url = child.src; |
| if (urls.indexOf(url) == -1) { |
| urls.push(url); |
| } |
| } |
| |
| filterImageUrls(child, urls); |
| |
| child = child.nextSibling; |
| } |
| } |
| |
| return { |
| 'isLoaded': isLoaded, |
| 'isLoading': isLoading, |
| 'load': load, |
| 'loadAll': loadAll, |
| 'filterImageUrls': filterImageUrls |
| }; |
| })(); |
| |
| |
| /** ------------------------------------------------------------------------ **/ |
| |
| |
| /** |
| * 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 |
| */ |
| links.Timeline.addEventListener = function (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 |
| */ |
| links.Timeline.removeEventListener = function(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 |
| */ |
| links.Timeline.getTarget = function (event) { |
| // code from http://www.quirksmode.org/js/events_properties.html |
| if (!event) { |
| event = window.event; |
| } |
| |
| var target; |
| |
| if (event.target) { |
| target = event.target; |
| } |
| else if (event.srcElement) { |
| target = event.srcElement; |
| } |
| |
| if (target.nodeType != undefined && target.nodeType == 3) { |
| // defeat Safari bug |
| target = target.parentNode; |
| } |
| |
| return target; |
| }; |
| |
| /** |
| * Stop event propagation |
| */ |
| links.Timeline.stopPropagation = function (event) { |
| if (!event) |
| event = window.event; |
| |
| if (event.stopPropagation) { |
| event.stopPropagation(); // non-IE browsers |
| } |
| else { |
| event.cancelBubble = true; // IE browsers |
| } |
| }; |
| |
| |
| /** |
| * Cancels the event if it is cancelable, without stopping further propagation of the event. |
| */ |
| links.Timeline.preventDefault = function (event) { |
| if (!event) |
| event = window.event; |
| |
| if (event.preventDefault) { |
| event.preventDefault(); // non-IE browsers |
| } |
| else { |
| event.returnValue = false; // IE browsers |
| } |
| }; |
| |
| |
| /** |
| * 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. |
| */ |
| links.Timeline.getAbsoluteLeft = function(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. |
| */ |
| links.Timeline.getAbsoluteTop = function(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 |
| */ |
| links.Timeline.getPageY = function (event) { |
| if (('targetTouches' in event) && event.targetTouches.length) { |
| event = event.targetTouches[0]; |
| } |
| |
| if ('pageY' in event) { |
| return event.pageY; |
| } |
| |
| // calculate pageY from clientY |
| var 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 |
| */ |
| links.Timeline.getPageX = function (event) { |
| if (('targetTouches' in event) && event.targetTouches.length) { |
| event = event.targetTouches[0]; |
| } |
| |
| if ('pageX' in event) { |
| return event.pageX; |
| } |
| |
| // calculate pageX from clientX |
| var 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 ); |
| }; |
| |
| /** |
| * Adds one or more className's to the given elements style |
| * @param {Element} elem |
| * @param {String} className |
| */ |
| links.Timeline.addClassName = function(elem, className) { |
| var classes = elem.className.split(' '); |
| var classesToAdd = className.split(' '); |
| |
| var added = false; |
| for (var i=0; i<classesToAdd.length; i++) { |
| if (classes.indexOf(classesToAdd[i]) == -1) { |
| classes.push(classesToAdd[i]); // add the class to the array |
| added = true; |
| } |
| } |
| |
| if (added) { |
| elem.className = classes.join(' '); |
| } |
| }; |
| |
| /** |
| * Removes one or more className's from the given elements style |
| * @param {Element} elem |
| * @param {String} className |
| */ |
| links.Timeline.removeClassName = function(elem, className) { |
| var classes = elem.className.split(' '); |
| var classesToRemove = className.split(' '); |
| |
| var removed = false; |
| for (var i=0; i<classesToRemove.length; i++) { |
| var index = classes.indexOf(classesToRemove[i]); |
| if (index != -1) { |
| classes.splice(index, 1); // remove the class from the array |
| removed = true; |
| } |
| } |
| |
| if (removed) { |
| elem.className = classes.join(' '); |
| } |
| }; |
| |
| /** |
| * Check if given object is a Javascript Array |
| * @param {*} obj |
| * @return {Boolean} isArray true if the given object is an array |
| */ |
| // See http://stackoverflow.com/questions/2943805/javascript-instanceof-typeof-in-gwt-jsni |
| links.Timeline.isArray = function (obj) { |
| if (obj instanceof Array) { |
| return true; |
| } |
| return (Object.prototype.toString.call(obj) === '[object Array]'); |
| }; |
| |
| /** |
| * parse a JSON date |
| * @param {Date | String | Number} date Date object to be parsed. Can be: |
| * - a Date object like new Date(), |
| * - a long like 1356970529389, |
| * an ISO String like "2012-12-31T16:16:07.213Z", |
| * or a .Net Date string like |
| * "\/Date(1356970529389)\/" |
| * @return {Date} parsedDate |
| */ |
| links.Timeline.parseJSONDate = function (date) { |
| if (date == undefined) { |
| return undefined; |
| } |
| |
| //test for date |
| if (date instanceof Date) { |
| return date; |
| } |
| |
| // test for MS format. |
| // FIXME: will fail on a Number |
| var m = date.match(/\/Date\((-?\d+)([-\+]?\d{2})?(\d{2})?\)\//i); |
| if (m) { |
| var offset = m[2] |
| ? (3600000 * m[2]) // hrs offset |
| + (60000 * m[3] * (m[2] / Math.abs(m[2]))) // mins offset |
| : 0; |
| |
| return new Date( |
| (1 * m[1]) // ticks |
| + offset |
| ); |
| } |
| |
| // failing that, try to parse whatever we've got. |
| return Date.parse(date); |
| }; |