You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
7005 lines
226 KiB
7005 lines
226 KiB
/**
|
|
* @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-2015 Almende B.V.
|
|
*
|
|
* @author Jos de Jong, <jos@almende.org>
|
|
* @date 2015-03-04
|
|
* @version 2.9.1
|
|
*/
|
|
|
|
/*
|
|
* 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.
|
|
* @param {Object} options A name/value map containing settings for the
|
|
* timeline. Optional.
|
|
*/
|
|
links.Timeline = function(container, options) {
|
|
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;
|
|
|
|
//
|
|
// Let's set the default options first
|
|
//
|
|
this.options = {
|
|
'width': "100%",
|
|
'height': "auto",
|
|
'minHeight': 0, // minimal height in pixels
|
|
'groupMinHeight': 0,
|
|
'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,
|
|
'groupsChangeable': true,
|
|
'timeChangeable': 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,
|
|
'groupsOrder' : true,
|
|
'axisOnTop': false,
|
|
'stackEvents': true,
|
|
'animate': true,
|
|
'animateZoom': true,
|
|
'cluster': false,
|
|
'clusterMaxItems': 5,
|
|
'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': ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
|
|
'MONTHS_SHORT': ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
|
'DAYS': ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
|
|
'DAYS_SHORT': ["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"
|
|
};
|
|
|
|
//
|
|
// Now we can set the givenproperties
|
|
//
|
|
this.setOptions(options);
|
|
|
|
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,
|
|
floatingRange: links.Timeline.ItemFloatingRange,
|
|
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. The use of options here
|
|
* is deprecated. Pass timeline options in the
|
|
* constructor or use setOptions()
|
|
*/
|
|
links.Timeline.prototype.draw = function(data, options) {
|
|
if (options) {
|
|
console.log("WARNING: Passing options in draw() is deprecated. Pass options to the constructur or use setOptions() instead!");
|
|
this.setOptions(options);
|
|
}
|
|
|
|
if (this.options.selectable) {
|
|
links.Timeline.addClassName(this.dom.frame, "timeline-selectable");
|
|
}
|
|
|
|
// read the data
|
|
this.setData(data);
|
|
|
|
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");
|
|
};
|
|
|
|
/**
|
|
* Get options for the timeline.
|
|
*
|
|
* @return the options object
|
|
*/
|
|
links.Timeline.prototype.getOptions = function() {
|
|
return this.options;
|
|
};
|
|
|
|
/**
|
|
* 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.type != 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;
|
|
};
|
|
|
|
|
|
/**
|
|
* Find the cluster index from a given HTML element
|
|
* If no cluster index is found, undefined is returned
|
|
* @param {Element} element
|
|
* @return {Number | undefined} index
|
|
*/
|
|
links.Timeline.prototype.getClusterIndex = function(element) {
|
|
var e = element,
|
|
dom = this.dom,
|
|
frame = dom.items.frame,
|
|
clusters = this.clusters,
|
|
index = undefined;
|
|
|
|
if (this.clusters) {
|
|
// try to find the frame where the clusters 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 clusters
|
|
// retrieve its id from the array with clusters
|
|
for (var i = 0, iMax = clusters.length; i < iMax; i++) {
|
|
if (clusters[i].dom === e) {
|
|
index = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return index;
|
|
};
|
|
|
|
/**
|
|
* Find all elements within the start and end range
|
|
* If no element is found, returns an empty array
|
|
* @param start time
|
|
* @param end time
|
|
* @return Array itemsInRange
|
|
*/
|
|
links.Timeline.prototype.getVisibleItems = function (start, end) {
|
|
var items = this.items;
|
|
var itemsInRange = [];
|
|
|
|
if (items) {
|
|
for (var i = 0, iMax = items.length; i < iMax; i++) {
|
|
var item = items[i];
|
|
if (item.end) {
|
|
// Time range object // NH use getLeft and getRight here
|
|
if (start <= item.start && item.end <= end) {
|
|
itemsInRange.push({"row": i});
|
|
}
|
|
} else {
|
|
// Point object
|
|
if (start <= item.start && item.start <= end) {
|
|
itemsInRange.push({"row": i});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// var sel = [];
|
|
// if (this.selection) {
|
|
// sel.push({"row": this.selection.index});
|
|
// }
|
|
// return sel;
|
|
|
|
return itemsInRange;
|
|
};
|
|
|
|
|
|
/**
|
|
* 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.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.className = "timeline-content";
|
|
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 = group.labelHeight || 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 = Math.max(this.options.groupMinHeight,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];
|
|
|
|
//
|
|
// TODO: Do we want to apply a max height? how ?
|
|
//
|
|
var groupHeight = group.itemsHeight;
|
|
resized = resized || (groupHeight != group.height);
|
|
group.height = Math.max(groupHeight, options.groupMinHeight);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 && 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 !== undefined) ? this.selection.index : -1,
|
|
item = (this.selection && this.selection.index !== undefined) ? 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 !== undefined) ? this.selection.index : -1,
|
|
item = (this.selection && this.selection.index !== undefined) ? this.items[index] : undefined;
|
|
if (item && item.rendered && this.isEditable(item) &&
|
|
(item instanceof links.Timeline.ItemRange || item instanceof links.Timeline.ItemFloatingRange)) {
|
|
var left = item.getLeft(this), // NH change to getLeft
|
|
right = item.getRight(this), // NH change to getRight
|
|
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);
|
|
if (options.snapEvents) {
|
|
timeline.step.snap(xstart);
|
|
}
|
|
|
|
var content = options.NEW;
|
|
var group = timeline.groups.length ? timeline.groups[0].content : undefined;
|
|
var preventRender = true;
|
|
timeline.addItem({
|
|
'start': xstart,
|
|
'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) ? this.selection.index : undefined;
|
|
delete params.clusterIndex;
|
|
}
|
|
else {
|
|
params.itemIndex = this.getItemIndex(params.target);
|
|
params.clusterIndex = this.getClusterIndex(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);
|
|
delete params.clusterIndex;
|
|
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.getLeft(this); // NH Use item.getLeft here
|
|
params.itemRight = item.getRight(this); // NH Use item.getRight here
|
|
}
|
|
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 && options.timeChangeable) {
|
|
// 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);
|
|
}
|
|
this.trigger('change');
|
|
}
|
|
else if (params.itemDragRight && options.timeChangeable) {
|
|
// 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);
|
|
}
|
|
this.trigger('change');
|
|
}
|
|
else if (options.timeChangeable) {
|
|
// 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);
|
|
}
|
|
this.trigger('change');
|
|
}
|
|
|
|
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 changed 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' : 'changed');
|
|
|
|
//retrieve item data again to include changes made to it in the triggered event handlers
|
|
item = this.items[params.itemIndex];
|
|
|
|
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);
|
|
|
|
this.updateData(params.itemIndex, {
|
|
'start': params.itemStart,
|
|
'end': params.itemEnd
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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.selection.index !== undefined) {
|
|
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(params.clusterIndex != undefined) {
|
|
this.selectCluster(params.clusterIndex);
|
|
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);
|
|
if (options.snapEvents) {
|
|
this.step.snap(xstart);
|
|
}
|
|
|
|
var content = options.NEW;
|
|
var group = this.getGroupFromHeight(y); // (group may be undefined)
|
|
var preventRender = true;
|
|
this.addItem({
|
|
'start': xstart,
|
|
'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 && this.selection.index !== undefined) {
|
|
// 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 left position of the item
|
|
* @param {links.Timeline} timeline
|
|
* @return {Number} left
|
|
*/
|
|
links.Timeline.Item.prototype.getLeft = function (timeline) {
|
|
// Should be implemented by sub-prototype
|
|
return 0;
|
|
};
|
|
|
|
/**
|
|
* 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 left position of the item
|
|
* @param {links.Timeline} timeline
|
|
* @return {Number} left
|
|
* @override
|
|
*/
|
|
links.Timeline.ItemBox.prototype.getLeft = function (timeline) {
|
|
var boxAlign = (timeline.options.box && timeline.options.box.align) ?
|
|
timeline.options.box.align : undefined;
|
|
|
|
var left = timeline.timeToScreen(this.start);
|
|
if (boxAlign == 'right') {
|
|
left = left - width;
|
|
}
|
|
else { // default or 'center'
|
|
left = (left - this.width / 2);
|
|
}
|
|
|
|
return left;
|
|
};
|
|
|
|
/**
|
|
* 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 left position of the item
|
|
* @param {links.Timeline} timeline
|
|
* @return {Number} left
|
|
* @override
|
|
*/
|
|
links.Timeline.ItemRange.prototype.getLeft = function (timeline) {
|
|
return timeline.timeToScreen(this.start);
|
|
};
|
|
|
|
/**
|
|
* 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.ItemFloatingRange
|
|
* @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.ItemFloatingRange = function (data, options) {
|
|
links.Timeline.Item.call(this, data, options);
|
|
};
|
|
|
|
links.Timeline.ItemFloatingRange.prototype = new links.Timeline.Item();
|
|
|
|
/**
|
|
* Select the item
|
|
* @override
|
|
*/
|
|
links.Timeline.ItemFloatingRange.prototype.select = function () {
|
|
var dom = this.dom;
|
|
links.Timeline.addClassName(dom, 'timeline-event-selected ui-state-active');
|
|
};
|
|
|
|
/**
|
|
* Unselect the item
|
|
* @override
|
|
*/
|
|
links.Timeline.ItemFloatingRange.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.ItemFloatingRange.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.ItemFloatingRange.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.ItemFloatingRange.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.ItemFloatingRange.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.ItemFloatingRange.prototype.updatePosition = function (timeline) {
|
|
var dom = this.dom;
|
|
if (dom) {
|
|
var contentWidth = timeline.size.contentWidth,
|
|
left = this.getLeft(timeline), // NH use getLeft
|
|
right = this.getRight(timeline); // NH use getRight;
|
|
|
|
// 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.ItemFloatingRange.prototype.isVisible = function (start, end) {
|
|
if (this.cluster) {
|
|
return false;
|
|
}
|
|
|
|
// NH check for no end value
|
|
if (this.end && this.start) {
|
|
return (this.end > start)
|
|
&& (this.start < end);
|
|
} else if (this.start) {
|
|
return (this.start < end);
|
|
} else if (this.end) {
|
|
return (this.end > start);
|
|
} else {return true;}
|
|
};
|
|
|
|
/**
|
|
* Reposition the item
|
|
* @param {Number} left
|
|
* @param {Number} right
|
|
* @override
|
|
*/
|
|
links.Timeline.ItemFloatingRange.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 left position of the item
|
|
* @param {links.Timeline} timeline
|
|
* @return {Number} left
|
|
* @override
|
|
*/
|
|
links.Timeline.ItemFloatingRange.prototype.getLeft = function (timeline) {
|
|
// NH check for no start value
|
|
if (this.start) {
|
|
return timeline.timeToScreen(this.start);
|
|
} else {
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Calculate the right position of the item
|
|
* @param {links.Timeline} timeline
|
|
* @return {Number} right
|
|
* @override
|
|
*/
|
|
links.Timeline.ItemFloatingRange.prototype.getRight = function (timeline) {
|
|
// NH check for no end value
|
|
if (this.end) {
|
|
return timeline.timeToScreen(this.end);
|
|
} else {
|
|
return timeline.size.contentWidth;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Calculate the width of the item
|
|
* @param {links.Timeline} timeline
|
|
* @return {Number} width
|
|
* @override
|
|
*/
|
|
links.Timeline.ItemFloatingRange.prototype.getWidth = function (timeline) {
|
|
return this.getRight(timeline) - this.getLeft(timeline);
|
|
};
|
|
|
|
/**
|
|
* @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 left position of the item
|
|
* @param {links.Timeline} timeline
|
|
* @return {Number} left
|
|
* @override
|
|
*/
|
|
links.Timeline.ItemDot.prototype.getLeft = function (timeline) {
|
|
return timeline.timeToScreen(this.start);
|
|
};
|
|
|
|
/**
|
|
* 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} 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)
|
|
*/
|
|
links.Timeline.prototype.getItem = function (index) {
|
|
if (index >= this.items.length) {
|
|
throw "Cannot get item, index out of range";
|
|
}
|
|
|
|
// take the original data as start, includes foreign fields
|
|
var data = this.data,
|
|
itemData;
|
|
if (google && google.visualization &&
|
|
data instanceof google.visualization.DataTable) {
|
|
// map the datatable columns
|
|
var cols = links.Timeline.mapColumnIds(data);
|
|
|
|
itemData = {};
|
|
for (var col in cols) {
|
|
if (cols.hasOwnProperty(col)) {
|
|
itemData[col] = this.data.getValue(index, cols[col]);
|
|
}
|
|
}
|
|
}
|
|
else if (links.Timeline.isArray(this.data)) {
|
|
// read JSON array
|
|
itemData = links.Timeline.clone(this.data[index]);
|
|
}
|
|
else {
|
|
throw "Unknown data type. DataTable or Array expected.";
|
|
}
|
|
|
|
// override the data with current settings of the item (should be the same)
|
|
var item = this.items[index];
|
|
|
|
itemData.start = new Date(item.start.valueOf());
|
|
if (item.end) {
|
|
itemData.end = new Date(item.end.valueOf());
|
|
}
|
|
itemData.content = item.content;
|
|
if (item.group) {
|
|
itemData.group = this.getGroupName(item.group);
|
|
}
|
|
if (item.className) {
|
|
itemData.className = item.className;
|
|
}
|
|
if (typeof item.editable !== 'undefined') {
|
|
itemData.editable = item.editable;
|
|
}
|
|
if (item.type) {
|
|
itemData.type = item.type;
|
|
}
|
|
|
|
return itemData;
|
|
};
|
|
|
|
|
|
/**
|
|
* Retrieve the properties of a cluster.
|
|
* @param {Number} index
|
|
* @return {Object} clusterdata Object containing cluster properties:<br>
|
|
* {Date} start (required),
|
|
* {String} type (optional)
|
|
* {Array} array with item data as is in getItem()
|
|
*/
|
|
links.Timeline.prototype.getCluster = function (index) {
|
|
if (index >= this.clusters.length) {
|
|
throw "Cannot get cluster, index out of range";
|
|
}
|
|
|
|
var clusterData = {},
|
|
cluster = this.clusters[index],
|
|
clusterItems = cluster.items;
|
|
|
|
clusterData.start = new Date(cluster.start.valueOf());
|
|
if (cluster.type) {
|
|
clusterData.type = cluster.type;
|
|
}
|
|
|
|
// push cluster item data
|
|
clusterData.items = [];
|
|
for(var i = 0; i < clusterItems.length; i++){
|
|
for(var j = 0; j < this.items.length; j++){
|
|
// TODO could be nicer to be able to have the item index into the cluster
|
|
if(this.items[j] == clusterItems[i])
|
|
{
|
|
clusterData.items.push(this.getItem(j));
|
|
break;
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
return clusterData;
|
|
};
|
|
|
|
/**
|
|
* 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 = links.Timeline.clone(itemData);
|
|
data.type = type;
|
|
data.group = this.getGroup(itemData.group);
|
|
// 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 type "' + 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
|
|
if (this.options.groupsOrder == true) {
|
|
groups = groups.sort(function (a, b) {
|
|
if (a.content > b.content) {
|
|
return 1;
|
|
}
|
|
if (a.content < b.content) {
|
|
return -1;
|
|
}
|
|
return 0;
|
|
});
|
|
} else if (typeof(this.options.groupsOrder) == "function") {
|
|
groups = groups.sort(this.options.groupsOrder)
|
|
}
|
|
|
|
// 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) {
|
|
if(this.selection.index !== undefined)
|
|
{
|
|
sel.push({"row": this.selection.index});
|
|
} else {
|
|
sel.push({"cluster": this.selection.cluster});
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Select an cluster by its index
|
|
* @param {Number} index
|
|
*/
|
|
links.Timeline.prototype.selectCluster = function(index) {
|
|
this.unselectItem();
|
|
|
|
this.selection = undefined;
|
|
|
|
if (this.clusters[index] != undefined) {
|
|
this.selection = {
|
|
'cluster': index
|
|
};
|
|
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 && this.selection.index !== undefined) {
|
|
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 (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;
|
|
}
|
|
};
|
|
|
|
links.Timeline.prototype.getItemsByGroup = function(items) {
|
|
var itemsByGroup = {};
|
|
for (var i = 0; i < items.length; ++i) {
|
|
var item = items[i];
|
|
var group = "undefined";
|
|
|
|
if (item.group) {
|
|
if (item.group.content) {
|
|
group = item.group.content;
|
|
} else {
|
|
group = item.group;
|
|
}
|
|
}
|
|
|
|
if (!itemsByGroup[group]) {
|
|
itemsByGroup[group] = [];
|
|
}
|
|
|
|
itemsByGroup[group].push(item);
|
|
}
|
|
|
|
return itemsByGroup;
|
|
};
|
|
|
|
/**
|
|
* 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 || a instanceof links.Timeline.ItemFloatingRange) &&
|
|
!(b instanceof links.Timeline.ItemRange || b instanceof links.Timeline.ItemFloatingRange)) {
|
|
return -1;
|
|
}
|
|
|
|
if (!(a instanceof links.Timeline.ItemRange || a instanceof links.Timeline.ItemFloatingRange) &&
|
|
(b instanceof links.Timeline.ItemRange || b instanceof links.Timeline.ItemFloatingRange)) {
|
|
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 size = this.size,
|
|
options = this.options,
|
|
axisOnTop = options.axisOnTop,
|
|
eventMargin = options.eventMargin,
|
|
eventMarginAxis = options.eventMarginAxis,
|
|
groupBase = (axisOnTop)
|
|
? size.axis.height + eventMarginAxis + eventMargin/2
|
|
: size.contentHeight - eventMarginAxis - eventMargin/2,
|
|
groupedItems, groupFinalItems, finalItems = [];
|
|
|
|
groupedItems = this.getItemsByGroup(items);
|
|
|
|
//
|
|
// groupedItems contains all items by group, plus it may contain an
|
|
// additional "undefined" group which contains all items with no group. We
|
|
// first process the grouped items, and then the ungrouped
|
|
//
|
|
for (j = 0; j<this.groups.length; ++j) {
|
|
var group = this.groups[j];
|
|
|
|
if (!groupedItems[group.content]) {
|
|
if (axisOnTop) {
|
|
groupBase += options.groupMinHeight + eventMargin;
|
|
} else {
|
|
groupBase -= (options.groupMinHeight + eventMargin);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// initialize final positions and fill finalItems
|
|
groupFinalItems = this.finalItemsPosition(groupedItems[group.content], groupBase, group);
|
|
groupFinalItems.forEach(function(item) {
|
|
finalItems.push(item);
|
|
});
|
|
|
|
if (axisOnTop) {
|
|
groupBase += group.itemsHeight + eventMargin;
|
|
} else {
|
|
groupBase -= (group.itemsHeight + eventMargin);
|
|
}
|
|
}
|
|
|
|
//
|
|
// Ungrouped items' turn now!
|
|
//
|
|
if (groupedItems["undefined"]) {
|
|
// initialize final positions and fill finalItems
|
|
groupFinalItems = this.finalItemsPosition(groupedItems["undefined"], groupBase);
|
|
groupFinalItems.forEach(function(item) {
|
|
finalItems.push(item);
|
|
});
|
|
}
|
|
|
|
return finalItems;
|
|
};
|
|
|
|
links.Timeline.prototype.finalItemsPosition = function(items, groupBase, group) {
|
|
var i,
|
|
iMax,
|
|
options = this.options,
|
|
axisOnTop = options.axisOnTop,
|
|
eventMargin = options.eventMargin,
|
|
groupFinalItems;
|
|
|
|
// initialize final positions and fill finalItems
|
|
groupFinalItems = this.initialItemsPosition(items, groupBase);
|
|
|
|
// calculate new, non-overlapping positions
|
|
for (i = 0, iMax = groupFinalItems.length; i < iMax; i++) {
|
|
var finalItem = groupFinalItems[i];
|
|
var collidingItem = null;
|
|
|
|
if (this.options.stackEvents) {
|
|
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(groupFinalItems, 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);
|
|
}
|
|
|
|
if (group) {
|
|
if (axisOnTop) {
|
|
group.itemsHeight = (group.itemsHeight)
|
|
? Math.max(group.itemsHeight, finalItem.bottom - groupBase)
|
|
: finalItem.height + eventMargin;
|
|
} else {
|
|
group.itemsHeight = (group.itemsHeight)
|
|
? Math.max(group.itemsHeight, groupBase - finalItem.top)
|
|
: finalItem.height + eventMargin;
|
|
}
|
|
}
|
|
}
|
|
|
|
return groupFinalItems;
|
|
};
|
|
|
|
links.Timeline.prototype.initialItemsPosition = function(items, groupBase) {
|
|
var options = this.options,
|
|
axisOnTop = options.axisOnTop,
|
|
finalItems = [];
|
|
|
|
for (var 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;
|
|
|
|
top = (axisOnTop) ? groupBase
|
|
: groupBase - height;
|
|
|
|
bottom = top + height;
|
|
|
|
finalItems.push({
|
|
'left': left,
|
|
'top': top,
|
|
'right': right,
|
|
'bottom': bottom,
|
|
'height': height,
|
|
'item': item
|
|
});
|
|
}
|
|
|
|
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 (var 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 (var 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, this.options.clusterMaxItems);
|
|
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, maxItems) {
|
|
var level = -1,
|
|
granularity = 2, // TODO: what granularity is needed for the cluster levels?
|
|
timeWindow = 0; // milliseconds
|
|
|
|
if (scale > 0) {
|
|
level = Math.round(Math.log(100 / scale) / Math.log(granularity));
|
|
timeWindow = Math.pow(granularity, level);
|
|
}
|
|
|
|
// 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 || p instanceof links.Timeline.ItemFloatingRange);
|
|
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]');
|
|
};
|
|
|
|
/**
|
|
* Shallow clone an object
|
|
* @param {Object} object
|
|
* @return {Object} clone
|
|
*/
|
|
links.Timeline.clone = function (object) {
|
|
var clone = {};
|
|
for (var prop in object) {
|
|
if (object.hasOwnProperty(prop)) {
|
|
clone[prop] = object[prop];
|
|
}
|
|
}
|
|
return clone;
|
|
};
|
|
|
|
/**
|
|
* 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);
|
|
};
|