kanbanworkflowstimelinescrumrubyroadmapproject-planningproject-managementopenprojectangularissue-trackerifcgantt-chartganttbug-trackerboardsbcf
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.
5173 lines
170 KiB
5173 lines
170 KiB
//-- copyright
|
|
// OpenProject is a project management system.
|
|
// Copyright (C) 2012-2013 the OpenProject Foundation (OPF)
|
|
//
|
|
// This program is free software; you can redistribute it and/or
|
|
// modify it under the terms of the GNU General Public License version 3.
|
|
//
|
|
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
|
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
|
// Copyright (C) 2010-2013 the ChiliProject Team
|
|
//
|
|
// This program is free software; you can redistribute it and/or
|
|
// modify it under the terms of the GNU General Public License
|
|
// as published by the Free Software Foundation; either version 2
|
|
// of the License, or (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program; if not, write to the Free Software
|
|
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
|
//
|
|
// See doc/COPYRIGHT.rdoc for more details.
|
|
//++
|
|
|
|
// ╭───────────────────────────────────────────────────────────────╮
|
|
// │ Timeines Plugin Javascript Library. │
|
|
// ├───────────────────────────────────────────────────────────────┤
|
|
// │ Javascript Library that fetches and plots timelines for the │
|
|
// │ accompanying ChiliProject Plugin. Martin Czuchra, Finn GmbH │
|
|
// │ 2011 │
|
|
// ╰───────────────────────────────────────────────────────────────╯
|
|
|
|
// stricter than default
|
|
/*jshint undef:true,
|
|
eqeqeq:true,
|
|
forin:true,
|
|
immed:true,
|
|
latedef:true,
|
|
trailing: true
|
|
*/
|
|
|
|
// looser than default
|
|
/*jshint eqnull:true */
|
|
|
|
// environment and other global vars
|
|
/*jshint browser:true, devel:true*/
|
|
/*global jQuery:false, Raphael:false, Timeline:true*/
|
|
|
|
Timeline = {
|
|
|
|
LOAD_ERROR_TIMEOUT: 60000,
|
|
DISPLAY_ERROR_DELAY: 2000,
|
|
PROJECT_ID_BLOCK_SIZE: 100,
|
|
USER_ATTRIBUTES: {
|
|
PROJECT: ["responsible_id"],
|
|
PLANNING_ELEMENT: ["responsible_id", "assigned_to_id"]
|
|
},
|
|
|
|
defaults: {
|
|
artificial_load_delay: 0, // no delay
|
|
columns: [],
|
|
exclude_own_planning_elements: false,
|
|
exclude_reporters: false,
|
|
api_prefix: '/api/v2',
|
|
hide_other_group: false,
|
|
hide_tree_root: false,
|
|
i18n: {}, // undefined would be bad.
|
|
initial_outline_expansion: 0, // aggregations only
|
|
project_prefix: '/projects',
|
|
planning_element_prefix: '',
|
|
ui_root: jQuery('#timeline'),
|
|
url_prefix: '' // empty prefix so it is not undefined.
|
|
},
|
|
|
|
ajax_defaults: {
|
|
cache: false,
|
|
context: this,
|
|
dataType: 'json'
|
|
},
|
|
|
|
instances: [],
|
|
get: function(n) {
|
|
if (typeof n !== "number") {
|
|
n = 0;
|
|
}
|
|
return this.instances[n];
|
|
},
|
|
isInstance: function(n) {
|
|
return (n === undefined) ?
|
|
Timeline.instances.indexOf(this) :
|
|
this === Timeline.get(n);
|
|
},
|
|
isGrouping: function() {
|
|
if ((this.options.grouping_one_enabled === 'yes' &&
|
|
this.options.grouping_one_selection !== undefined) ||
|
|
(this.options.grouping_two_enabled === 'yes' &&
|
|
this.options.grouping_two_selection !== undefined)) {
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
},
|
|
isComparing: function() {
|
|
return ((this.options.comparison !== undefined) &&
|
|
(this.options.comparison !== 'none'));
|
|
},
|
|
comparisonCurrentTime: function() {
|
|
var value;
|
|
if (!this.isComparing()) {
|
|
return undefined;
|
|
}
|
|
if (this.options.comparison === 'historical') {
|
|
value = this.options.compare_to_historical_two;
|
|
}
|
|
else {
|
|
// default is no (undefined) current time, which corresponds to today.
|
|
return undefined;
|
|
}
|
|
return +Date.parse(value) / 1000;
|
|
},
|
|
calculateTimeFilter: function () {
|
|
if (!this.frameSet) {
|
|
if (this.options.planning_element_time === "absolute") {
|
|
this.frameStart = Date.parse(this.options.planning_element_time_absolute_one);
|
|
this.frameEnd = Date.parse(this.options.planning_element_time_absolute_two);
|
|
} else if (this.options.planning_element_time === "relative") {
|
|
var startR = parseInt(this.options.planning_element_time_relative_one, 10);
|
|
var endR = parseInt(this.options.planning_element_time_relative_two, 10);
|
|
if (!isNaN(startR)) {
|
|
this.frameStart = Date.now();
|
|
|
|
switch (this.options.planning_element_time_relative_one_unit[0]) {
|
|
case "0":
|
|
this.frameStart.add(-1 * startR).days();
|
|
break;
|
|
case "1":
|
|
this.frameStart.add(-1 * startR).weeks();
|
|
break;
|
|
case "2":
|
|
this.frameStart.add(-1 * startR).months();
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!isNaN(endR)) {
|
|
this.frameEnd = Date.now();
|
|
|
|
switch (this.options.planning_element_time_relative_two_unit[0]) {
|
|
case "0":
|
|
this.frameEnd.add(endR).days();
|
|
break;
|
|
case "1":
|
|
this.frameEnd.add(endR).weeks();
|
|
break;
|
|
case "2":
|
|
this.frameEnd.add(endR).months();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.frameSet = true;
|
|
}
|
|
},
|
|
inTimeFilter: function (start, end) {
|
|
this.calculateTimeFilter();
|
|
|
|
if (!start && !end) {
|
|
return false;
|
|
}
|
|
|
|
if (!start) {
|
|
start = end;
|
|
}
|
|
|
|
if (!end) {
|
|
end = start;
|
|
}
|
|
|
|
if (this.frameStart) {
|
|
if (start < this.frameStart && end < this.frameStart) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (this.frameEnd) {
|
|
if (start > this.frameEnd && end > this.frameEnd) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
},
|
|
verticalPlanningElementIds: function() {
|
|
|
|
return this.options.vertical_planning_elements ?
|
|
jQuery.map(
|
|
this.options.vertical_planning_elements.split(/\,/),
|
|
function(a) {
|
|
try {
|
|
return parseInt(a.match(/\s*\*?(\d*)\s*/)[1], 10);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
}
|
|
) : [];
|
|
},
|
|
comparisonTarget: function() {
|
|
var result, value, unit;
|
|
if (!this.isComparing()) {
|
|
return undefined;
|
|
}
|
|
switch (this.options.comparison) {
|
|
case 'relative':
|
|
result = new Date();
|
|
value = Timeline.pnum(this.options.compare_to_relative);
|
|
unit = Timeline.pnum(this.options.compare_to_relative_unit[0]);
|
|
switch (unit) {
|
|
case 0:
|
|
return Math.floor(result.add(-value).days() / 1000);
|
|
case 1:
|
|
return Math.floor(result.add(-value).weeks() / 1000);
|
|
case 2:
|
|
return Math.floor(result.add(-value).months() / 1000);
|
|
default:
|
|
return this.die(this.i18n('timelines.errors.report_comparison'));
|
|
}
|
|
break; // to please jslint
|
|
case 'absolute':
|
|
value = this.options.compare_to_absolute;
|
|
break;
|
|
case 'historical':
|
|
value = this.options.compare_to_historical_one;
|
|
break;
|
|
default:
|
|
return this.die(this.i18n('timelines.errors.report_comparison'));
|
|
}
|
|
return +Date.parse(value)/1000;
|
|
},
|
|
create: function() {
|
|
var timeline = Object.create(Timeline);
|
|
|
|
// some private fields.
|
|
timeline.listeners = [];
|
|
timeline.data = {};
|
|
|
|
Timeline.instances.push(timeline);
|
|
return timeline;
|
|
},
|
|
|
|
startup: function(options) {
|
|
var timeline = this, timelineLoader;
|
|
|
|
if(this === Timeline) {
|
|
timeline = Timeline.create();
|
|
return timeline.startup(options);
|
|
}
|
|
|
|
// configuration
|
|
|
|
if (!options) {
|
|
throw new Error('No configuration options given');
|
|
}
|
|
options = jQuery.extend({}, this.defaults, options);
|
|
this.options = options;
|
|
|
|
// we're hiding the root if there is a grouping.
|
|
this.options.hide_tree_root = this.isGrouping();
|
|
|
|
if (this.options.username) {
|
|
this.ajax_defaults.username = this.options.username;
|
|
}
|
|
if (this.options.password) {
|
|
this.ajax_defaults.password = this.options.password;
|
|
}
|
|
if (this.options.api_key) {
|
|
this.ajax_defaults.headers = {
|
|
'X-ChiliProject-API-Key': this.options.api_key,
|
|
'X-OpenProject-API-Key': this.options.api_key,
|
|
'X-Redmine-API-Key': this.options.api_key
|
|
};
|
|
}
|
|
|
|
// setup UI.
|
|
|
|
this.uiRoot = this.options.ui_root;
|
|
this.setupUI();
|
|
|
|
try {
|
|
|
|
// prerequisites (3rd party libs)
|
|
this.checkPrerequisites();
|
|
this.modalHelper = modalHelperInstance;
|
|
this.modalHelper.setupTimeline(
|
|
this,
|
|
{
|
|
api_prefix : this.options.api_prefix,
|
|
url_prefix : this.options.url_prefix,
|
|
project_prefix : this.options.project_prefix
|
|
}
|
|
);
|
|
|
|
jQuery(this.modalHelper).on("closed", function () {
|
|
timeline.reload();
|
|
});
|
|
|
|
timelineLoader = this.provideTimelineLoader();
|
|
|
|
jQuery(timelineLoader).on('complete', jQuery.proxy(function(e, data) {
|
|
jQuery.extend(this, data);
|
|
|
|
jQuery(this).trigger('dataLoaded');
|
|
this.defer(jQuery.proxy(this, 'onLoadComplete'),
|
|
this.options.artificial_load_delay);
|
|
}, this));
|
|
|
|
this.safetyHook = window.setTimeout(function() {
|
|
timeline.die(timeline.i18n('timelines.errors.report_timeout'));
|
|
}, Timeline.LOAD_ERROR_TIMEOUT);
|
|
|
|
timelineLoader.load();
|
|
|
|
return this;
|
|
|
|
} catch (e) {
|
|
this.die(e);
|
|
}
|
|
},
|
|
reload: function() {
|
|
delete this.lefthandTree;
|
|
|
|
var timelineLoader = this.provideTimelineLoader();
|
|
|
|
jQuery(timelineLoader).on('complete', jQuery.proxy(function (e, data) {
|
|
|
|
jQuery.extend(this, data);
|
|
jQuery(this).trigger('dataReLoaded');
|
|
|
|
if (this.isGrouping() && this.options.grouping_two_enabled) {
|
|
this.secondLevelGroupingAdjustments();
|
|
}
|
|
|
|
this.adjustForPlanningElements();
|
|
|
|
this.rebuildAll();
|
|
|
|
}, this));
|
|
|
|
timelineLoader.load();
|
|
},
|
|
provideTimelineLoader: function() {
|
|
return new Timeline.TimelineLoader(
|
|
this,
|
|
{
|
|
api_prefix : this.options.api_prefix,
|
|
url_prefix : this.options.url_prefix,
|
|
project_prefix : this.options.project_prefix,
|
|
planning_element_prefix : this.options.planning_element_prefix,
|
|
project_id : this.options.project_id,
|
|
project_types : this.options.project_types,
|
|
project_statuses : this.options.project_status,
|
|
project_responsibles : this.options.project_responsibles,
|
|
project_parents : this.options.parents,
|
|
planning_element_types : this.options.planning_element_types,
|
|
planning_element_responsibles : this.options.planning_element_responsibles,
|
|
planning_element_status : this.options.planning_element_status,
|
|
grouping_one : (this.options.grouping_one_enabled ? this.options.grouping_one_selection : undefined),
|
|
grouping_two : (this.options.grouping_two_enabled ? this.options.grouping_two_selection : undefined),
|
|
ajax_defaults : this.ajax_defaults,
|
|
current_time : this.comparisonCurrentTime(),
|
|
target_time : this.comparisonTarget(),
|
|
include_planning_elements : this.verticalPlanningElementIds()
|
|
}
|
|
);
|
|
},
|
|
defer: function(action, delay) {
|
|
var timeline = this;
|
|
var result;
|
|
if (delay === undefined) {
|
|
delay = 0;
|
|
}
|
|
result = window.setTimeout(function() {
|
|
try {
|
|
action.call();
|
|
} catch(e) {
|
|
timeline.die(e);
|
|
}
|
|
}, 0);
|
|
return result;
|
|
},
|
|
die: function(error, classes) {
|
|
var message = (typeof error === 'string') ? error :
|
|
this.i18n('timelines.errors.report_epicfail'); // + '<br>' + error.message;
|
|
classes = classes || 'flash error';
|
|
|
|
this.warn(message, classes);
|
|
|
|
// assume this won't happen anymore.
|
|
this.onLoadComplete = function() {};
|
|
|
|
if (console && console.log) {
|
|
console.log(error.stack);
|
|
}
|
|
|
|
throw error;
|
|
},
|
|
warn: function(message, classes) {
|
|
var root = this.getUiRoot();
|
|
|
|
window.setTimeout(function() {
|
|
|
|
// generate and display the error message.
|
|
var warning = jQuery('<div class="' + classes + '">' + message + '</div>');
|
|
root.empty().append(warning);
|
|
|
|
}, Timeline.DISPLAY_ERROR_DELAY);
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Loading │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
FilterQueryStringBuilder: (function() {
|
|
|
|
/**
|
|
* FilterQueryStringBuilder
|
|
*
|
|
* Simple serializer of query strings that satisfies OpenProject's filter
|
|
* API. Transforms hashes of desired filterings into the proper query strings.
|
|
*
|
|
* Examples:
|
|
*
|
|
* fqsb = (new FilterQueryStringBuilder({
|
|
* 'type_id': [4, 5]
|
|
* })).build(
|
|
* '/api/v2/projects/sample_project/planning_elements.json'
|
|
* );
|
|
*
|
|
* => /api/v2/projects/sample_project/planning_elements.json?f[]=type_id&op[type_id]==&v[type_id][]=4&v[type_id][]=5
|
|
*
|
|
* fqsb = (new FilterQueryStringBuilder())
|
|
* .filter({ 'type_id': [4, 5] })
|
|
* .append({ 'at_time': 1380795754 })
|
|
* .build( '/api/v2/projects/sample_project/planning_elements.json' );
|
|
*
|
|
* => /api/v2/projects/sample_project/planning_elements.json?f[]=type_id&op[type_id]==&v[type_id][]=4&v[type_id][]=5&at_time=1380795754
|
|
*/
|
|
var FilterQueryStringBuilder = function (filterHash) {
|
|
this.filterHash = filterHash || {};
|
|
this.paramsHash = {};
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.filter = function(filters) {
|
|
this.filterHash = jQuery.extend({}, this.filterHash, filters);
|
|
return this;
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.append = function(addition) {
|
|
this.paramsHash = jQuery.extend({}, this.paramsHash, addition);
|
|
return this;
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.buildMetaDataForKey = function(key) {
|
|
this.queryStringParts.push({name: 'f[]', value: key},
|
|
{name: 'op[' + key + ']', value: '='});
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.prepareFilterDataForKeyAndValue = function(key, value) {
|
|
this.queryStringParts.push({name: 'v[' + key + '][]', value: value});
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.prepareAdditionalQueryData = function(key, value) {
|
|
this.queryStringParts.push({name: key, value: value});
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.prepareFilterDataForKeyAndArrayOfValues = function(key, value) {
|
|
jQuery.each(value, jQuery.proxy( function(i, e) {
|
|
this.prepareFilterDataForKeyAndValue(key, e);
|
|
}, this));
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.buildFilterDataForValue = function(key, value) {
|
|
if (value instanceof Array) {
|
|
this.prepareFilterDataForKeyAndArrayOfValues(key, value);
|
|
} else {
|
|
this.prepareFilterDataForKeyAndValue(key, value);
|
|
}
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.registerKeyAndValue = function(key, value) {
|
|
this.buildMetaDataForKey(key);
|
|
this.buildFilterDataForValue(key, value);
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.prepareQueryStringParts = function() {
|
|
this.queryStringParts = [];
|
|
jQuery.each(this.filterHash, jQuery.proxy(this.registerKeyAndValue, this));
|
|
jQuery.each(this.paramsHash, jQuery.proxy(this.prepareAdditionalQueryData, this));
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.buildQueryStringFromQueryStringParts = function(url) {
|
|
return jQuery.map(this.queryStringParts, function(e, i) {
|
|
return e.name + "=" + encodeURIComponent(e.value);
|
|
}).join('&');
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.buildUrlFromQueryStringParts = function(url) {
|
|
var resultUrl = url;
|
|
resultUrl += "?";
|
|
resultUrl += this.buildQueryStringFromQueryStringParts();
|
|
return resultUrl;
|
|
};
|
|
|
|
FilterQueryStringBuilder.prototype.build = function(url) {
|
|
this.prepareQueryStringParts();
|
|
return this.buildUrlFromQueryStringParts(url);
|
|
};
|
|
|
|
return FilterQueryStringBuilder;
|
|
})(),
|
|
|
|
TimelineLoader : (function () {
|
|
|
|
/**
|
|
* QueueingLoader
|
|
*
|
|
* Simple wrapper around jQuery.ajax, which introduces two step loading
|
|
* of remote data.
|
|
*
|
|
* Step 1: register URL and meta information using `register`
|
|
* Step 2: load all registered elements using `load`
|
|
*
|
|
* To notify consumers about AJAX responses, the QueueingLoader uses
|
|
* the jQuery custom event system. Whenever a request is complete,
|
|
* success or error events will be triggered. When the queue is empty,
|
|
* an 'empty' event will be triggered. Use the callback functions like
|
|
* the following ones, to react on these events:
|
|
*
|
|
* jQuery(myLoader).on("success", function (e, args) {
|
|
* console.log("'success' triggered for", this);
|
|
* console.log("identifier used in register:", args.identifier);
|
|
* console.log("context provided in register:", args.context);
|
|
* console.log("data returned by the server:", args.data);
|
|
* });
|
|
*
|
|
* jQuery(myLoader).on("error", function (e, args) {
|
|
* console.log("'error' triggered for ", this);
|
|
* console.log("identifier used in register:", args.identifier);
|
|
* console.log("context provided in register:", args.context);
|
|
* console.log("textStatus provided by jqXHR:", args.textStatus);
|
|
* });
|
|
*
|
|
* jQuery(myLoader).on("empty", function (e) {
|
|
* console.log("'empty' triggered for ", this);
|
|
* });
|
|
*
|
|
*/
|
|
var QueueingLoader = function (ajaxDefaults) {
|
|
this.ajaxDefaults = ajaxDefaults;
|
|
this.registered = {};
|
|
this.loading = {};
|
|
};
|
|
|
|
/**
|
|
* Enqueue new elements to load
|
|
*
|
|
* identifier should be a string and uniq. If identifier is used twice, then
|
|
* bad things will happen.
|
|
*
|
|
* options should be the options passed to jQuery.ajax. Often it is enough,
|
|
* to provide a URL.
|
|
*
|
|
* context additional information which will be passed to the callbacks. Use
|
|
* this to store some state.
|
|
*/
|
|
QueueingLoader.prototype.register = function (identifier, options, context) {
|
|
this.registered[identifier] = {
|
|
options : options,
|
|
context : context || {}
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Trigger loading of all registered elements
|
|
*/
|
|
QueueingLoader.prototype.load = function () {
|
|
var identifier, element;
|
|
|
|
for (identifier in this.registered) {
|
|
if (this.registered.hasOwnProperty(identifier)) {
|
|
element = this.registered[identifier];
|
|
delete this.registered[identifier];
|
|
|
|
this.loadElement(identifier, element);
|
|
}
|
|
}
|
|
};
|
|
|
|
QueueingLoader.prototype.getRegisteredIdentifiers = function () {
|
|
return jQuery.map(this.registered, function (e, i) { return i; });
|
|
};
|
|
|
|
QueueingLoader.prototype.getLoadingIdentifiers = function () {
|
|
return jQuery.map(this.loading, function (e, i) { return i; });
|
|
};
|
|
|
|
// Methods below are not meant to be public
|
|
QueueingLoader.prototype.loadElement = function (identifier, element) {
|
|
|
|
element.options = jQuery.extend(
|
|
{},
|
|
this.ajaxDefaults,
|
|
element.options,
|
|
{
|
|
success : function(data, textStatus, jqXHR) {
|
|
delete this.loading[identifier];
|
|
|
|
jQuery(this).trigger('success', {identifier : identifier,
|
|
context : element.context,
|
|
data : data});
|
|
},
|
|
|
|
error : function (jqXHR, textStatus, errorThrown) {
|
|
delete this.loading[identifier];
|
|
|
|
jQuery(this).trigger('error', {identifier : identifier,
|
|
context : element.context,
|
|
textStatus : textStatus});
|
|
},
|
|
|
|
complete : this.onComplete,
|
|
|
|
context : this
|
|
}
|
|
);
|
|
|
|
this.loading[identifier] = element;
|
|
|
|
jQuery.ajax(element.options);
|
|
};
|
|
|
|
QueueingLoader.prototype.onComplete = function () {
|
|
// count remainders
|
|
var remaining = 0;
|
|
for (var key in this.loading) {
|
|
if (this.loading.hasOwnProperty(key)) {
|
|
remaining++;
|
|
}
|
|
}
|
|
|
|
// if nothing remains, notify about empty queue
|
|
if (remaining === 0) {
|
|
jQuery(this).trigger('empty');
|
|
}
|
|
};
|
|
|
|
var DataEnhancer = function (timeline) {
|
|
this.timeline = timeline;
|
|
|
|
this.options = {
|
|
projectId : timeline.options.project_id
|
|
};
|
|
|
|
this.die = function () {
|
|
timeline.die.apply(timeline, arguments);
|
|
};
|
|
};
|
|
|
|
DataEnhancer.getBasicType = function (identifier) {
|
|
var i, basicTypes = this.BasicTypes();
|
|
|
|
for (i = 0; i < basicTypes.length; i++) {
|
|
if (basicTypes[i].identifier === identifier) {
|
|
return basicTypes[i];
|
|
}
|
|
}
|
|
};
|
|
|
|
DataEnhancer.BasicTypes = function () {
|
|
return [
|
|
Timeline.Color,
|
|
Timeline.Status,
|
|
Timeline.PlanningElementType,
|
|
Timeline.HistoricalPlanningElement,
|
|
Timeline.PlanningElement,
|
|
Timeline.ProjectType,
|
|
Timeline.Project,
|
|
Timeline.ProjectAssociation,
|
|
Timeline.Reporting,
|
|
Timeline.User
|
|
];
|
|
};
|
|
|
|
DataEnhancer.prototype.createObjects = function (data, identifier) {
|
|
var type = DataEnhancer.getBasicType(identifier);
|
|
|
|
var i, e, id, map = {};
|
|
|
|
if (data instanceof Array) {
|
|
for (i = 0; i < data.length; i++) {
|
|
e = data[i];
|
|
e.timeline = this.timeline;
|
|
id = e.id;
|
|
map[id] = jQuery.extend(Object.create(type), e);
|
|
}
|
|
}
|
|
else {
|
|
console.warn("Expected instance of Array, but got something else.", data, identifier);
|
|
}
|
|
|
|
return map;
|
|
};
|
|
|
|
DataEnhancer.prototype.enhance = function (data) {
|
|
try {
|
|
|
|
this.data = data;
|
|
|
|
this.createEmptyElementMaps();
|
|
this.assignMainProject();
|
|
|
|
this.augmentReportingsWithProjectObjects();
|
|
this.augmentProjectsWithProjectTypesAndAssociations();
|
|
|
|
this.augmentPlanningElementsWithHistoricalData();
|
|
this.augmentPlanningElementsWithAllKindsOfStuff();
|
|
this.augmentPlanningElementsWithVerticalityData();
|
|
|
|
return this.data;
|
|
|
|
} catch(e) {
|
|
this.die(e);
|
|
}
|
|
};
|
|
|
|
DataEnhancer.prototype.createEmptyElementMaps = function () {
|
|
if (!this.data.hasOwnProperty(Timeline.ProjectAssociation.identifier)) {
|
|
this.data[Timeline.ProjectAssociation.identifier] = {};
|
|
}
|
|
};
|
|
|
|
DataEnhancer.prototype.getElementMap = function (type) {
|
|
return this.data[type.identifier];
|
|
};
|
|
|
|
DataEnhancer.prototype.setElementMap = function (type, map) {
|
|
if (map === undefined) {
|
|
delete this.data[type.identifier];
|
|
}
|
|
else {
|
|
this.data[type.identifier] = map;
|
|
}
|
|
return map;
|
|
};
|
|
|
|
DataEnhancer.prototype.getElements = function (type) {
|
|
var map = this.getElementMap(type) || {},
|
|
result = [];
|
|
|
|
for (var key in map) {
|
|
if (map.hasOwnProperty(key)) {
|
|
result.push(map[key]);
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
DataEnhancer.prototype.getElement = function (type, id) {
|
|
return this.data[type.identifier][id];
|
|
};
|
|
|
|
DataEnhancer.prototype.setElement = function (type, id, element) {
|
|
this.data[type.identifier][id] = element;
|
|
return this.data[type.identifier][id];
|
|
};
|
|
|
|
DataEnhancer.prototype.getProject = function () {
|
|
return this.data.project;
|
|
};
|
|
|
|
DataEnhancer.prototype.setProject = function (project) {
|
|
this.data.project = project;
|
|
return project;
|
|
};
|
|
|
|
|
|
DataEnhancer.prototype.assignMainProject = function () {
|
|
if (this.getProject() !== undefined) {
|
|
return;
|
|
}
|
|
|
|
var dataEnhancer = this;
|
|
|
|
// looking for main project in timeline.projects array and storing it as
|
|
// primary project in timeline.project
|
|
jQuery.each(this.getElements(Timeline.Project), function (i, e) {
|
|
if (e.identifier === dataEnhancer.options.projectId ||
|
|
e.id === dataEnhancer.options.projectId) {
|
|
|
|
dataEnhancer.setProject(e);
|
|
}
|
|
});
|
|
|
|
if (dataEnhancer.getProject() === undefined) {
|
|
dataEnhancer.die(new Error("Could not find main project. " +
|
|
"The current user is probably not allowed to view timelines in here."));
|
|
}
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentReportingsWithProjectObjects = function () {
|
|
var dataEnhancer = this;
|
|
|
|
jQuery.each(dataEnhancer.getElements(Timeline.Reporting), function (i, reporting) {
|
|
// TODO this somehow didn't make the change to reporting_to_project_id and project_id.
|
|
var project = dataEnhancer.getElement(Timeline.Project, reporting.reporting_to_project.id);
|
|
var reporter = dataEnhancer.getElement(Timeline.Project, reporting.project.id);
|
|
|
|
// there might not be a project, which due to insufficient rights
|
|
// and the fact that some user with more rights originally created
|
|
// the report, is not available here.
|
|
if (!project || !reporter) {
|
|
// TODO some flag indicating that something is wrong/missing.
|
|
return;
|
|
}
|
|
|
|
reporting.reporting_to_project = project;
|
|
reporting.project = reporter;
|
|
|
|
reporter.via_reporting = reporting;
|
|
|
|
// project ← reporting → project
|
|
if (project.reporters === undefined) {
|
|
project.reporters = [];
|
|
}
|
|
if (jQuery.inArray(reporter, project.reporters) === -1) {
|
|
project.reporters.push(reporter);
|
|
}
|
|
});
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentElementAttributesWithUser = function (e, attributes) {
|
|
if (this.data[Timeline.User.identifier]) {
|
|
var k, curAttr;
|
|
for (k = 0; k < attributes.length; k += 1) {
|
|
curAttr = attributes[k];
|
|
if (e[curAttr]) {
|
|
e[curAttr.replace(/_id$/, "")] = this.getElement(Timeline.User,
|
|
e[curAttr]);
|
|
}
|
|
|
|
delete e[curAttr];
|
|
}
|
|
}
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentProjectElementWithUser = function (p) {
|
|
this.augmentElementAttributesWithUser(p, Timeline.USER_ATTRIBUTES.PROJECT);
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentProjectsWithProjectTypesAndAssociations = function () {
|
|
var dataEnhancer = this;
|
|
|
|
jQuery.each(dataEnhancer.getElements(Timeline.Project), function (i, e) {
|
|
|
|
dataEnhancer.augmentProjectElementWithUser(e);
|
|
|
|
// project_type ← project
|
|
if (e.project_type_id !== undefined) {
|
|
var project_type = dataEnhancer.getElement(Timeline.ProjectType, e.project_type_id);
|
|
|
|
if (project_type) {
|
|
e.project_type = project_type;
|
|
}
|
|
}
|
|
|
|
// project ← association → project
|
|
|
|
var associations = e[Timeline.ProjectAssociation.identifier];
|
|
var j, a, other;
|
|
|
|
if (associations instanceof Array) {
|
|
for (j = 0; j < associations.length; j++) {
|
|
a = associations[j];
|
|
a.timeline = dataEnhancer.timeline;
|
|
a.origin = e;
|
|
|
|
other = dataEnhancer.getElement(Timeline.Project, a.to_project_id);
|
|
if (other) {
|
|
a.project = other;
|
|
dataEnhancer.setElement(
|
|
Timeline.ProjectAssociation,
|
|
a.id,
|
|
jQuery.extend(Object.create(Timeline.ProjectAssociation), a));
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// project → parent
|
|
if (e.parent_id) {
|
|
e.parent = dataEnhancer.getElement(Timeline.Project, e.parent_id);
|
|
}
|
|
delete e.parent_id;
|
|
});
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentPlanningElementsWithHistoricalData = function () {
|
|
var dataEnhancer = this;
|
|
|
|
jQuery.each(dataEnhancer.getElements(Timeline.HistoricalPlanningElement), function (i, e) {
|
|
var pe = dataEnhancer.getElement(Timeline.PlanningElement, e.id);
|
|
|
|
if (pe === undefined) {
|
|
|
|
// The planning element is in the historical data, but not in
|
|
// the current set of planning elements, i.e. it was deleted
|
|
// in the compared timeframe. We therefore import the deleted
|
|
// element into the planning elements array and set the
|
|
// is_deleted flag.
|
|
e = jQuery.extend(Object.create(Timeline.PlanningElement), e);
|
|
e.is_deleted = true;
|
|
dataEnhancer.setElement(Timeline.PlanningElement, e.id, e);
|
|
pe = e;
|
|
}
|
|
|
|
pe.historical_element = jQuery.extend(Object.create(Timeline.PlanningElement), e);
|
|
});
|
|
|
|
dataEnhancer.setElementMap(Timeline.HistoricalPlanningElement, undefined);
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentPlanningElementWithStatus = function (pe) {
|
|
// planning_element → planning_element_type
|
|
if (pe.status_id) {
|
|
pe.status = this.getElement(Timeline.Status,
|
|
pe.status_id);
|
|
}
|
|
delete pe.status_id;
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentPlanningElementWithUser = function (pe) {
|
|
this.augmentElementAttributesWithUser(pe, Timeline.USER_ATTRIBUTES.PLANNING_ELEMENT);
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentPlanningElementWithType = function (pe) {
|
|
// planning_element → planning_element_type
|
|
if (pe.type_id) {
|
|
pe.planning_element_type = this.getElement(Timeline.PlanningElementType,
|
|
pe.type_id);
|
|
}
|
|
delete pe.type_id;
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentPlanningElementWithProject = function (pe) {
|
|
var project = this.getElement(Timeline.Project, pe.project_id);
|
|
|
|
// there might not be such a project, due to insufficient rights
|
|
// and the fact that some user with more rights originally created
|
|
// the report.
|
|
if (!project) {
|
|
// TODO some flag indicating that something is wrong/missing.
|
|
return;
|
|
}
|
|
|
|
// planning_element → project
|
|
pe.project = project;
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentPlanningElementWithParent = function (pe) {
|
|
if (pe.parent_id) {
|
|
var parent = this.getElement(Timeline.PlanningElement, pe.parent_id);
|
|
|
|
if (parent !== undefined) {
|
|
|
|
// planning_element ↔ planning_element
|
|
if (parent.planning_elements === undefined) {
|
|
parent.planning_elements = [];
|
|
}
|
|
parent.planning_elements.push(pe);
|
|
pe.parent = parent;
|
|
}
|
|
|
|
} else {
|
|
var project = pe.project;
|
|
if (project) {
|
|
// planning_element ← project
|
|
if (project.planning_elements === undefined) {
|
|
project.planning_elements = [];
|
|
}
|
|
project.planning_elements.push(pe);
|
|
}
|
|
}
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentPlanningElementsWithAllKindsOfStuff = function () {
|
|
var dataEnhancer = this;
|
|
|
|
jQuery.each(dataEnhancer.getElements(Timeline.PlanningElement), function (i, e) {
|
|
dataEnhancer.augmentPlanningElementWithStatus(e);
|
|
dataEnhancer.augmentPlanningElementWithType(e);
|
|
dataEnhancer.augmentPlanningElementWithProject(e);
|
|
dataEnhancer.augmentPlanningElementWithParent(e);
|
|
dataEnhancer.augmentPlanningElementWithUser(e);
|
|
if (e.historical_element) {
|
|
dataEnhancer.augmentPlanningElementWithStatus(e.historical_element);
|
|
dataEnhancer.augmentPlanningElementWithType(e.historical_element);
|
|
}
|
|
});
|
|
};
|
|
|
|
DataEnhancer.prototype.augmentPlanningElementsWithVerticalityData = function () {
|
|
var dataEnhancer = this;
|
|
|
|
jQuery.each(dataEnhancer.getElements(Timeline.PlanningElement), function (i, e) {
|
|
var pe = dataEnhancer.getElement(Timeline.PlanningElement, e.id);
|
|
var pet = pe.getPlanningElementType();
|
|
|
|
pe.vertical = this.timeline.verticalPlanningElementIds().indexOf(pe.id) !== -1;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* TimelineLoader
|
|
*
|
|
* Loads all data, that is relevant for the current timeline instance.
|
|
*
|
|
* timeline: Timeline instance
|
|
* options: Configuration Hash
|
|
*
|
|
* The timeline parameter is used to augment the loaded data with pointers
|
|
* to their coressponding timeline. No assumptions about methods or
|
|
* attributes are made.
|
|
*
|
|
* The following list describes the required options
|
|
*
|
|
* url_prefix : timeline.options.url_prefix,
|
|
* project_prefix : timeline.options.project_prefix,
|
|
* project_id : timeline.options.project_id,
|
|
*
|
|
* ajax_defaults : timeline.ajax_defaults
|
|
*
|
|
* current_time : this.comparisonCurrentTime(),
|
|
* target_time : this.comparisonTarget()
|
|
*
|
|
* Use `load` to trigger loading of data.
|
|
* Use events to get notified about completion
|
|
*
|
|
* jQuery(timelineLoader).on("complete", function (e, data) {
|
|
* console.log("'complete' triggered for", this);
|
|
* console.log("Loaded data is:", data);
|
|
* }
|
|
*/
|
|
var TimelineLoader = function (timeline, options) {
|
|
this.options = options;
|
|
this.data = {};
|
|
this.loader = new QueueingLoader(options.ajax_defaults);
|
|
this.dataEnhancer = new DataEnhancer(timeline);
|
|
|
|
this.globalPrefix = options.url_prefix + options.api_prefix;
|
|
|
|
jQuery(this.loader).on('success', jQuery.proxy(this, 'onLoadSuccess'))
|
|
.on('error', jQuery.proxy(this, 'onLoadError'))
|
|
.on('empty', jQuery.proxy(this, 'onLoadComplete'));
|
|
};
|
|
|
|
|
|
TimelineLoader.prototype.load = function () {
|
|
this.registerProjectReportings();
|
|
this.registerGlobalElements();
|
|
|
|
this.loader.load();
|
|
};
|
|
|
|
TimelineLoader.prototype.onLoadSuccess = function (e, args) {
|
|
var storeIn = args.context.storeIn || args.identifier,
|
|
readFrom = args.context.readFrom || storeIn;
|
|
|
|
this.storeData(args.data[readFrom], storeIn);
|
|
this.checkDependencies(args.identifier);
|
|
};
|
|
|
|
TimelineLoader.prototype.onLoadError = function (e, args) {
|
|
var storeIn = args.context.storeIn || args.identifier;
|
|
|
|
console.warn("Error during loading", arguments);
|
|
|
|
this.storeData([], storeIn);
|
|
|
|
this.checkDependencies(args.identifier);
|
|
};
|
|
|
|
TimelineLoader.prototype.onLoadComplete = function (e) {
|
|
jQuery(this).trigger('complete', this.dataEnhancer.enhance(this.data));
|
|
};
|
|
|
|
TimelineLoader.prototype.registerProjectReportings = function () {
|
|
var projectPrefix = this.options.url_prefix +
|
|
this.options.api_prefix +
|
|
this.options.project_prefix +
|
|
"/" +
|
|
this.options.project_id;
|
|
|
|
var url = projectPrefix + '/reportings.json?only=via_target';
|
|
|
|
if (this.options.project_types) {
|
|
url += '&project_types=' + this.options.project_types.join();
|
|
}
|
|
|
|
if (this.options.project_statuses) {
|
|
url += '&project_statuses=' + this.options.project_statuses.join();
|
|
}
|
|
|
|
if (this.options.project_responsibles) {
|
|
url += '&project_responsibles=' + this.options.project_responsibles.join();
|
|
}
|
|
|
|
if (this.options.project_parents) {
|
|
url += '&project_parents=' + this.options.project_parents.join();
|
|
}
|
|
|
|
if (this.options.grouping_one) {
|
|
url += '&grouping_one=' + this.options.grouping_one.join();
|
|
}
|
|
|
|
if (this.options.grouping_two) {
|
|
url += '&grouping_two=' + this.options.grouping_two.join();
|
|
}
|
|
|
|
this.loader.register(Timeline.Reporting.identifier,
|
|
{ url : url });
|
|
};
|
|
|
|
TimelineLoader.prototype.registerGlobalElements = function () {
|
|
|
|
this.loader.register(
|
|
Timeline.Status.identifier,
|
|
{ url : this.globalPrefix + '/statuses.json' });
|
|
this.loader.register(
|
|
Timeline.PlanningElementType.identifier,
|
|
{ url : this.globalPrefix + '/planning_element_types.json' });
|
|
this.loader.register(
|
|
Timeline.Color.identifier,
|
|
{ url : this.globalPrefix + '/colors.json' });
|
|
this.loader.register(
|
|
Timeline.ProjectType.identifier,
|
|
{ url : this.globalPrefix + '/project_types.json' });
|
|
};
|
|
|
|
TimelineLoader.prototype.registerProjects = function (ids) {
|
|
|
|
this.inChunks(ids, function (project_ids_of_packet, i) {
|
|
|
|
this.loader.register(
|
|
Timeline.Project.identifier + '_' + i,
|
|
{ url : this.globalPrefix +
|
|
'/projects.json?ids=' +
|
|
project_ids_of_packet.join(',')},
|
|
{ storeIn : Timeline.Project.identifier }
|
|
);
|
|
});
|
|
};
|
|
|
|
TimelineLoader.prototype.registerUsers = function (ids) {
|
|
|
|
this.inChunks(ids, function (user_ids_of_packet, i) {
|
|
|
|
this.loader.register(
|
|
Timeline.User.identifier + '_' + i,
|
|
{ url : this.globalPrefix +
|
|
'/users.json?ids=' +
|
|
user_ids_of_packet.join(',')},
|
|
{ storeIn : Timeline.User.identifier }
|
|
);
|
|
});
|
|
};
|
|
|
|
TimelineLoader.prototype.provideServerSideFilterHashTypes = function (hash) {
|
|
if (this.options.planning_element_types !== undefined) {
|
|
hash.type_id = this.options.planning_element_types;
|
|
}
|
|
};
|
|
|
|
TimelineLoader.prototype.provideServerSideFilterHashStatus = function (hash) {
|
|
if (this.options.planning_element_status !== undefined) {
|
|
hash.status_id = this.options.planning_element_status;
|
|
}
|
|
};
|
|
|
|
TimelineLoader.prototype.provideServerSideFilterHashResponsibles = function (hash) {
|
|
if (this.options.planning_element_responsibles !== undefined) {
|
|
hash.responsible_id = this.options.planning_element_responsibles;
|
|
}
|
|
};
|
|
|
|
TimelineLoader.prototype.provideServerSideFilterHash = function() {
|
|
var result = {};
|
|
this.provideServerSideFilterHashTypes(result);
|
|
this.provideServerSideFilterHashResponsibles(result);
|
|
this.provideServerSideFilterHashStatus(result);
|
|
return result;
|
|
};
|
|
|
|
TimelineLoader.prototype.registerPlanningElements = function (ids) {
|
|
|
|
this.inChunks(ids, function (projectIdsOfPacket, i) {
|
|
var projectPrefix = this.options.url_prefix +
|
|
this.options.api_prefix +
|
|
this.options.project_prefix +
|
|
"/" +
|
|
projectIdsOfPacket.join(',');
|
|
|
|
var qsb = new Timeline.FilterQueryStringBuilder(
|
|
this.provideServerSideFilterHash());
|
|
|
|
// load current planning elements.
|
|
this.loader.register(
|
|
Timeline.PlanningElement.identifier + '_' + i,
|
|
{ url : qsb.build(projectPrefix + '/planning_elements.json') },
|
|
{ storeIn: Timeline.PlanningElement.identifier }
|
|
);
|
|
|
|
// load historical planning elements.
|
|
if (this.options.target_time) {
|
|
this.loader.register(
|
|
Timeline.HistoricalPlanningElement.identifier + '_' + i,
|
|
{ url : qsb.append({ at_time: this.options.target_time })
|
|
.build(projectPrefix + '/planning_elements.json') },
|
|
{ storeIn: Timeline.HistoricalPlanningElement.identifier,
|
|
readFrom: Timeline.PlanningElement.identifier }
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
|
|
TimelineLoader.prototype.registerPlanningElementsByID = function (ids) {
|
|
|
|
this.inChunks(ids, function (planningElementIdsOfPacket, i) {
|
|
var projectPrefix = this.options.url_prefix +
|
|
this.options.api_prefix;
|
|
|
|
// load current planning elements.
|
|
this.loader.register(
|
|
Timeline.PlanningElement.identifier + '_IDS_' + i,
|
|
{ url : projectPrefix +
|
|
'/planning_elements.json?ids=' +
|
|
planningElementIdsOfPacket.join(',')},
|
|
{ storeIn: Timeline.PlanningElement.identifier }
|
|
);
|
|
|
|
// load historical planning elements.
|
|
// TODO: load historical PEs here!
|
|
if (this.options.target_time) {
|
|
this.loader.register(
|
|
Timeline.HistoricalPlanningElement.identifier + '_IDS_' + i,
|
|
{ url : projectPrefix +
|
|
'/planning_elements.json?ids=' +
|
|
planningElementIdsOfPacket.join(',') },
|
|
{ storeIn: Timeline.HistoricalPlanningElement.identifier,
|
|
readFrom: Timeline.PlanningElement.identifier }
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
TimelineLoader.prototype.inChunks = function (elements, iter) {
|
|
var i, current_elements;
|
|
|
|
i = 0;
|
|
elements = elements.clone();
|
|
|
|
while (elements.length > 0) {
|
|
i++;
|
|
|
|
current_elements = elements.splice(0, Timeline.PROJECT_ID_BLOCK_SIZE);
|
|
|
|
iter.call(this, current_elements, i);
|
|
}
|
|
};
|
|
|
|
TimelineLoader.prototype.storeData = function (data, identifier) {
|
|
if (!jQuery.isArray(data)) {
|
|
this.die("Expected an instance of Array. Got something else. This " +
|
|
"should never happen.", data, identifier);
|
|
}
|
|
|
|
this.data[identifier] = this.data[identifier] || {};
|
|
jQuery.extend(
|
|
this.data[identifier],
|
|
this.dataEnhancer.createObjects(data, identifier));
|
|
};
|
|
|
|
TimelineLoader.prototype.getCurrentlyLoadingTypes = function (unique) {
|
|
var currentlyLoadingTypes = [], m = {};
|
|
|
|
jQuery.each(this.loader.getLoadingIdentifiers(), function (i, e) {
|
|
currentlyLoadingTypes.push(e.replace(/_\d+$/, ''));
|
|
});
|
|
|
|
if (unique) {
|
|
jQuery.each(currentlyLoadingTypes, function (i, e) { m[e] = e; });
|
|
currentlyLoadingTypes = [];
|
|
jQuery.each(m, function (i, e) { currentlyLoadingTypes.push(e); });
|
|
}
|
|
|
|
return currentlyLoadingTypes;
|
|
};
|
|
|
|
TimelineLoader.prototype.doneLoading = function (param) {
|
|
if (typeof param !== 'string') {
|
|
param = param.identifier;
|
|
}
|
|
if (param === Timeline.Project.identifier ||
|
|
param === Timeline.PlanningElement.identifier) {
|
|
|
|
return jQuery.inArray(param, this.getCurrentlyLoadingTypes()) === -1;
|
|
}
|
|
else {
|
|
return this.data[param] !== undefined;
|
|
}
|
|
};
|
|
|
|
TimelineLoader.prototype.getRemainingPlanningElements = function () {
|
|
var i,
|
|
necessaryIDs = [],
|
|
vp = this.options.include_planning_elements;
|
|
|
|
for (i = 0; i < vp.length; i += 1) {
|
|
if (typeof this.data.planning_elements[vp[i]] === "undefined") {
|
|
necessaryIDs.push(vp[i]);
|
|
}
|
|
}
|
|
|
|
return necessaryIDs;
|
|
};
|
|
|
|
function addUserIDsFromElementAttributes(results, attributes, element) {
|
|
var k, userid;
|
|
for (k = 0; k < attributes.length; k += 1) {
|
|
userid = element[attributes[k]];
|
|
if (userid && results.indexOf(userid) === -1) {
|
|
results.push(userid);
|
|
}
|
|
}
|
|
}
|
|
|
|
function addUserIDsForElementsByAttribute(results, attributes, elements) {
|
|
var i, keys = Object.keys(elements), current;
|
|
for (i = 0; i < keys.length; i += 1) {
|
|
current = elements[keys[i]];
|
|
|
|
addUserIDsFromElementAttributes(results, attributes, current);
|
|
}
|
|
}
|
|
|
|
TimelineLoader.prototype.getUsersToLoad = function () {
|
|
var results = [];
|
|
|
|
addUserIDsForElementsByAttribute(results, Timeline.USER_ATTRIBUTES.PLANNING_ELEMENT, this.data.planning_elements);
|
|
addUserIDsForElementsByAttribute(results, Timeline.USER_ATTRIBUTES.PROJECT, this.data.projects);
|
|
|
|
return results;
|
|
};
|
|
|
|
TimelineLoader.prototype.getRelevantProjectIdsBasedOnReportings = function () {
|
|
var i,
|
|
relevantProjectIds = [this.options.project_id];
|
|
|
|
if (this.doneLoading(Timeline.Reporting)) {
|
|
for (i in this.data.reportings) {
|
|
if (this.data.reportings.hasOwnProperty(i)) {
|
|
relevantProjectIds.push(this.data.reportings[i].getProjectId());
|
|
}
|
|
}
|
|
|
|
this.getRelevantProjectIdsBasedOnReportings = function () {
|
|
return relevantProjectIds;
|
|
};
|
|
}
|
|
else {
|
|
console.warn("Getting relevant project ids before reportings are " +
|
|
"loaded. This might be a bug.");
|
|
}
|
|
|
|
return relevantProjectIds;
|
|
};
|
|
|
|
TimelineLoader.prototype.getProject = function (idOrIdentifier) {
|
|
var i, ps = this.data.projects;
|
|
|
|
if (typeof idOrIdentifier === 'string') {
|
|
for (i in ps) {
|
|
if (ps.hasOwnProperty(i) && ps[i].identifier === idOrIdentifier) {
|
|
return ps[i];
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
return this.data.projects[idOrIdentifier];
|
|
}
|
|
};
|
|
|
|
TimelineLoader.prototype.getRelevantProjectIdsBasedOnProjects = function () {
|
|
var relevantProjectIds = this.getRelevantProjectIdsBasedOnReportings(),
|
|
timelineLoader = this;
|
|
|
|
if (this.doneLoading(Timeline.Project)) {
|
|
relevantProjectIds = jQuery.grep(relevantProjectIds, function (e, i) {
|
|
return timelineLoader.getProject(e) && timelineLoader.getProject(e).filteredOut();
|
|
}, true);
|
|
|
|
this.getRelevantProjectIdsBasedOnProjects = function () {
|
|
return relevantProjectIds;
|
|
};
|
|
}
|
|
else {
|
|
console.warn("Getting relevant project ids before projects are " +
|
|
"loaded. This might be a bug.");
|
|
}
|
|
|
|
return relevantProjectIds;
|
|
};
|
|
|
|
TimelineLoader.prototype.shouldLoadReportings = function (lastLoaded) {
|
|
return lastLoaded === Timeline.Reporting.identifier;
|
|
};
|
|
|
|
TimelineLoader.prototype.shouldLoadPlanningElements = function (lastLoaded) {
|
|
|
|
if (this.doneLoading(Timeline.Project) &&
|
|
this.doneLoading(Timeline.Reporting) &&
|
|
this.doneLoading(Timeline.ProjectType)) {
|
|
|
|
this.shouldLoadPlanningElements = function () { return false; };
|
|
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
TimelineLoader.prototype.shouldLoadUsers = function (lastLoaded) {
|
|
if (this.doneLoading(Timeline.Project) &&
|
|
this.doneLoading(Timeline.Reporting) &&
|
|
this.doneLoading(Timeline.ProjectType) &&
|
|
this.doneLoading(Timeline.PlanningElement)) {
|
|
|
|
// this will not work for pes from another project (like vertical pes)!
|
|
// but as we do not display users for this data currently,
|
|
// we will not add them yet
|
|
this.shouldLoadUsers = function () { return false; };
|
|
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
TimelineLoader.prototype.shouldLoadRemainingPlanningElements = function (lastLoaded) {
|
|
|
|
if (this.doneLoading(Timeline.Project) &&
|
|
this.doneLoading(Timeline.Reporting) &&
|
|
this.doneLoading(Timeline.ProjectType) &&
|
|
this.doneLoading(Timeline.PlanningElement)) {
|
|
|
|
this.shouldLoadRemainingPlanningElements = function () { return false; };
|
|
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
TimelineLoader.prototype.checkDependencies = function (identifier) {
|
|
if (this.shouldLoadReportings(identifier)) {
|
|
this.registerProjects(this.getRelevantProjectIdsBasedOnReportings());
|
|
} else if (this.shouldLoadPlanningElements(identifier)) {
|
|
this.data = this.dataEnhancer.enhance(this.data);
|
|
|
|
this.registerPlanningElements(this.getRelevantProjectIdsBasedOnProjects());
|
|
} else {
|
|
if (this.shouldLoadRemainingPlanningElements(identifier)) {
|
|
this.registerPlanningElementsByID(this.getRemainingPlanningElements());
|
|
}
|
|
|
|
if (this.shouldLoadUsers(identifier)) {
|
|
this.registerUsers(this.getUsersToLoad());
|
|
}
|
|
}
|
|
|
|
this.loader.load();
|
|
};
|
|
|
|
TimelineLoader.prototype.complete = function (data) {
|
|
// This function is just a placeholder to let you know, that you should
|
|
// probably register an event handler on 'complete'. The handler should
|
|
// have the following signature:
|
|
//
|
|
// function (e, data) {}
|
|
return data;
|
|
};
|
|
|
|
return TimelineLoader;
|
|
})(),
|
|
|
|
checkPrerequisites: function() {
|
|
if (jQuery === undefined) {
|
|
throw new Error('jQuery seems to be missing (jQuery is undefined)');
|
|
} else if (jQuery().slider === undefined) {
|
|
throw new Error('jQuery UI seems to be missing (jQuery().slider is undefined)');
|
|
} else if ((1).month === undefined) {
|
|
throw new Error('date.js seems to be missing ((1).month is undefined)');
|
|
} else if (Raphael === undefined) {
|
|
throw new Error('Raphael seems to be missing (Raphael is undefined)');
|
|
}
|
|
return true;
|
|
},
|
|
|
|
onLoadComplete: function() {
|
|
// everything here should be wrapped in try/catch, to never
|
|
var tree;
|
|
try {
|
|
window.clearTimeout(this.safetyHook);
|
|
|
|
if (this.isGrouping() && this.options.grouping_two_enabled) {
|
|
this.secondLevelGroupingAdjustments();
|
|
}
|
|
|
|
tree = this.getLefthandTree();
|
|
if (tree.containsPlanningElements() || tree.containsProjects()) {
|
|
this.adjustForPlanningElements();
|
|
this.completeUI();
|
|
} else {
|
|
this.warn(this.i18n('label_no_data'), 'warning');
|
|
}
|
|
} catch (e) {
|
|
this.die(e);
|
|
}
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Data Store │
|
|
// ├───────────────────────────────────────────────────────────────────┤
|
|
// │ Model Prototypes: │
|
|
// │ Timeline.PlanningElement │
|
|
// │ Timeline.ProjectType │
|
|
// │ Timeline.Project │
|
|
// │ Timeline.Color │
|
|
// │ Timeline.Reporting │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
secondLevelGroupingAdjustments : function () {
|
|
var grouping = jQuery.map(this.options.grouping_two_selection || [], Timeline.pnum);
|
|
var root = this.getProject();
|
|
var associations = Timeline.ProjectAssociation.all(this);
|
|
var listToRemove = [];
|
|
|
|
// for all projects on the first level
|
|
jQuery.each(root.getReporters(), function (i, e) {
|
|
|
|
// find all projects that are associated
|
|
jQuery.each(associations, function (j, a) {
|
|
|
|
if (a.involves(e)) {
|
|
var other = a.getOther(e);
|
|
if (typeof other.getProjectType === "function") {
|
|
var pt = other.getProjectType();
|
|
var type = pt !== null ? pt.id : -1;
|
|
var relevant = false;
|
|
|
|
jQuery.each(grouping, function(k, l) {
|
|
if (l === type) {
|
|
relevant = true;
|
|
}
|
|
});
|
|
|
|
if (relevant) {
|
|
|
|
// add the other project as a simulated reporter to the current one.
|
|
e.addReporter(other);
|
|
other.hasSecondLevelGroupingAdjustment = true;
|
|
// remove the project from the root level of the report.
|
|
listToRemove.push(other);
|
|
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// remove all children of root that we couldn't remove while still iterating.
|
|
jQuery.each(listToRemove, function(i, e) {
|
|
root.removeReporter(e);
|
|
});
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Timeline.ProjectAssociation │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
ProjectAssociation: {
|
|
identifier: 'project_associations',
|
|
all: function(timeline) {
|
|
// collect all project associations.
|
|
var r = timeline.project_associations;
|
|
var result = [];
|
|
for (var key in r) {
|
|
if (r.hasOwnProperty(key)) {
|
|
result.push(r[key]);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
getOrigin: function() {
|
|
return this.origin;
|
|
},
|
|
getTarget: function() {
|
|
return this.project;
|
|
},
|
|
getOther: function(project) {
|
|
var origin = this.getOrigin();
|
|
var target = this.getTarget();
|
|
if (project.id === origin.id) {
|
|
return target;
|
|
} else if (project.id === target.id) {
|
|
return origin;
|
|
}
|
|
return null;
|
|
},
|
|
getInvolvedProjects: function() {
|
|
return [this.getOrigin(), this.getTarget()];
|
|
},
|
|
involves: function(project) {
|
|
var inv = this.getInvolvedProjects();
|
|
|
|
return (
|
|
project !== undefined &&
|
|
inv[0] !== undefined &&
|
|
inv[1] !== undefined &&
|
|
(project.id === inv[0].id || project.id === inv[1].id)
|
|
);
|
|
}
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Timeline.Reporting │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
Reporting: {
|
|
identifier: 'reportings',
|
|
all: function(timeline) {
|
|
// collect all reportings.
|
|
var r = timeline.reportings;
|
|
var result = [];
|
|
for (var key in r) {
|
|
if (r.hasOwnProperty(key)) {
|
|
result.push(r[key]);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
getProject: function() {
|
|
return (this.project !== undefined) ? this.project : null;
|
|
},
|
|
getProjectId: function () {
|
|
return this.project.id;
|
|
},
|
|
getReportingToProject : function () {
|
|
return (this.reporting_to_project !== undefined) ? this.reporting_to_project : null;
|
|
},
|
|
getReportingToProjectId : function () {
|
|
return this.reporting_to_project.id;
|
|
},
|
|
getStatus: function() {
|
|
return (this.reported_project_status !== undefined) ? this.reported_project_status : null;
|
|
}
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Timeline.ProjectType │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
ProjectType: {
|
|
identifier: 'project_types',
|
|
all: function(timeline) {
|
|
// collect all project types
|
|
var r = timeline.project_types;
|
|
var result = [];
|
|
for (var key in r) {
|
|
if (r.hasOwnProperty(key)) {
|
|
result.push(r[key]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Timeline.Color │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
Color: {
|
|
identifier: 'colors',
|
|
all: function(timeline) {
|
|
// collect all colors
|
|
var r = timeline.colors;
|
|
var result = [];
|
|
for (var key in r) {
|
|
if (r.hasOwnProperty(key)) {
|
|
result.push(r[key]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Timeline.Status │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
Status: {
|
|
identifier: 'statuses',
|
|
all: function(timeline) {
|
|
// collect all reportings.
|
|
var r = timeline.statuses;
|
|
var result = [];
|
|
for (var key in r) {
|
|
if (r.hasOwnProperty(key)) {
|
|
result.push(r[key]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Timeline.PlanningElementType │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
PlanningElementType: {
|
|
identifier: 'planning_element_types',
|
|
all: function(timeline) {
|
|
// collect all reportings.
|
|
var r = timeline.planning_element_types;
|
|
var result = [];
|
|
for (var key in r) {
|
|
if (r.hasOwnProperty(key)) {
|
|
result.push(r[key]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Timeline.Project │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
User: {
|
|
is: function(t) {
|
|
return Timeline.User.identifier === t.identifier;
|
|
},
|
|
identifier: 'users'
|
|
},
|
|
|
|
Project: {
|
|
is: function(t) {
|
|
return Timeline.Project.identifier === t.identifier;
|
|
},
|
|
identifier: 'projects',
|
|
hide: function () {
|
|
var hidden = this.hiddenForEmpty() ||
|
|
this.hiddenForTimeFrame();
|
|
|
|
this.hide = function () { return hidden; };
|
|
|
|
return hidden;
|
|
},
|
|
hiddenForEmpty: function () {
|
|
if (this.timeline.options.exclude_empty !== 'yes') {
|
|
return false;
|
|
}
|
|
|
|
var hidden = true;
|
|
// iterates over projects for second level grouping adjustments.
|
|
// TODO simply hiding parents might be sufficient.
|
|
jQuery.each(this.getPlanningElements(), function (i, child) {
|
|
if (!child.filteredOut()) {
|
|
hidden = false;
|
|
}
|
|
});
|
|
|
|
return hidden;
|
|
},
|
|
hiddenForTimeFrame: function () {
|
|
var types = this.timeline.options.planning_element_time_types;
|
|
if (!types || types.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
var hidden = true;
|
|
|
|
// we need to look at every element
|
|
jQuery.each(this.getPlanningElements(), function (i, child) {
|
|
// if hidden is already false, do not calculate
|
|
// otherwise, we show this project current element is a planning element (redundant?)
|
|
// and it is inside our timeframe
|
|
// and it has got the planning element type we want
|
|
if (hidden &&
|
|
child.is(Timeline.PlanningElement) &&
|
|
child.inTimeFrame() &&
|
|
Timeline.idInArray(types, child.getPlanningElementType())) {
|
|
hidden = false;
|
|
}
|
|
});
|
|
|
|
return hidden;
|
|
},
|
|
filteredOut: function() {
|
|
var filtered = this.filteredOutForExclusionOfOwnPlanningElements() ||
|
|
this.filteredOutForExclusionOfReporters() ||
|
|
this.filteredOutForTypes() ||
|
|
this.filteredOutForStatus() ||
|
|
this.filteredOutForSubproject() ||
|
|
this.filteredOutForResponsibles();
|
|
|
|
this.filteredOut = function () { return filtered; };
|
|
|
|
return filtered;
|
|
},
|
|
filteredOutForExclusionOfOwnPlanningElements: function () {
|
|
return (this.timeline.options.exclude_own_planning_elements === 'yes' &&
|
|
(this.id === this.timeline.options.project_id ||
|
|
this.identifier === this.timeline.options.project_id));
|
|
},
|
|
filteredOutForExclusionOfReporters: function() {
|
|
return (this.timeline.options.exclude_reporters === 'yes' &&
|
|
!(this.id === this.timeline.options.project_id ||
|
|
this.identifier === this.timeline.options.project_id));
|
|
},
|
|
filteredOutForSubproject: function() {
|
|
var i, j, p;
|
|
var allowedParents = this.timeline.options.parents;
|
|
|
|
if ((allowedParents === undefined) ||
|
|
(allowedParents.length === 0)) {
|
|
|
|
// if there is no filter, do not filter out anything.
|
|
return false;
|
|
|
|
} else {
|
|
|
|
// for every id selected in the filter
|
|
for (i = 0; i < allowedParents.length; i++) {
|
|
|
|
j = Timeline.pnum(allowedParents[i]);
|
|
|
|
if (j === -1) {
|
|
// if the current selection is the (none) selection, we only need to
|
|
// check the project's immediate parent. If it has none, it should
|
|
// be shown, thus *not* filtered.
|
|
if (this.parent === undefined) {
|
|
return false;
|
|
}
|
|
}
|
|
else {
|
|
// if this project or one of it's ancestors has an id that is
|
|
// equal to the current selection, it should be shown, thus *not*
|
|
// filtered.
|
|
p = this;
|
|
while (p !== undefined) {
|
|
if (p.id === j) {
|
|
return false;
|
|
} else {
|
|
p = p.parent;
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// everything that was not decided to be *not* filtered until now should
|
|
// be filtered.
|
|
return true;
|
|
}
|
|
},
|
|
filteredOutForResponsibles: function() {
|
|
return Timeline.filterOutBasedOnArray(
|
|
this.timeline.options.project_responsibles,
|
|
this.getResponsible()
|
|
);
|
|
},
|
|
filteredOutForStatus: function() {
|
|
return Timeline.filterOutBasedOnArray(
|
|
this.timeline.options.project_status,
|
|
this.getProjectStatus()
|
|
);
|
|
},
|
|
filteredOutForTypes: function() {
|
|
return Timeline.filterOutBasedOnArray(this.timeline.options.project_types,
|
|
this.getProjectType());
|
|
},
|
|
getPlanningElementType: function() {
|
|
return undefined;
|
|
},
|
|
getPlanningElements: function() {
|
|
if (!this.planning_elements) {
|
|
return [];
|
|
}
|
|
if (!this.sorted_pes) {
|
|
this.sort('planning_elements');
|
|
this.sorted_pes = true;
|
|
}
|
|
return this.planning_elements;
|
|
},
|
|
getFirstLevelGroupingData: function() {
|
|
return this.timeline.getGroupForProject(this);
|
|
},
|
|
getFirstLevelGrouping: function() {
|
|
return this.timeline.getGroupForProject(this).number;
|
|
},
|
|
getFirstLevelGroupingName: function() {
|
|
return this.timeline.getGroupForProject(this).name;
|
|
},
|
|
sort: function(field) {
|
|
var timeline = this.timeline;
|
|
this[field] = this[field].sort(function(a, b) {
|
|
|
|
// order by inverse grouping, date, name, id
|
|
var dc = 0, nc = 0;
|
|
var as = a.start(), bs = b.start();
|
|
var ag, bg;
|
|
if (a.is(Timeline.Project) && b.is(Timeline.Project)) {
|
|
var dataAGrouping = a.getFirstLevelGroupingData();
|
|
var dataBGrouping = b.getFirstLevelGroupingData();
|
|
|
|
// order first level grouping.
|
|
if (parseInt(dataAGrouping.id, 10) !== parseInt(dataBGrouping.id, 10)) {
|
|
/** other is always at bottom */
|
|
if (parseInt(dataAGrouping.id, 10) === 0) {
|
|
return 1;
|
|
} else if (parseInt(dataBGrouping.id, 10) === 0) {
|
|
return -1;
|
|
}
|
|
|
|
if (parseInt(timeline.options.grouping_one_sort, 10) === 1) {
|
|
ag = dataAGrouping.number;
|
|
bg = dataBGrouping.number;
|
|
} else {
|
|
ag = dataAGrouping.p.name;
|
|
bg = dataBGrouping.p.name;
|
|
}
|
|
|
|
if (ag > bg) {
|
|
return 1;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
if (!as && typeof a.end === "function") {
|
|
as = a.end();
|
|
}
|
|
if (!bs && typeof b.end === "function") {
|
|
bs = b.end();
|
|
}
|
|
|
|
if (as) {
|
|
if (bs) {
|
|
dc = as.compareTo(bs);
|
|
} else {
|
|
dc = 1;
|
|
}
|
|
} else if (bs) {
|
|
dc = -1;
|
|
}
|
|
|
|
var identifier_methods = [a, b].map(function(e) { return e.hasOwnProperty("subject") ? "subject" : "name"; });
|
|
|
|
if (!a.identifierLower) {
|
|
a.identifierLower = a[identifier_methods[0]].toLowerCase();
|
|
}
|
|
|
|
if (!b.identifierLower) {
|
|
b.identifierLower = b[identifier_methods[1]].toLowerCase();
|
|
}
|
|
|
|
if (a.identifierLower < b.identifierLower) {
|
|
nc = -1;
|
|
}
|
|
if (a.identifierLower > b.identifierLower) {
|
|
nc = +1;
|
|
}
|
|
|
|
if (a.hasSecondLevelGroupingAdjustment && b.hasSecondLevelGroupingAdjustment) {
|
|
if (parseInt(timeline.options.grouping_two_sort, 10) === 1) {
|
|
if (dc !== 0) {
|
|
return dc;
|
|
}
|
|
|
|
if (nc !== 0) {
|
|
return nc;
|
|
}
|
|
} else if (parseInt(timeline.options.grouping_two_sort, 10) === 2) {
|
|
if (nc !== 0) {
|
|
return nc;
|
|
}
|
|
|
|
if (dc !== 0) {
|
|
return dc;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (parseInt(timeline.options.project_sort, 10) === 1 && a.is(Timeline.Project) && b.is(Timeline.Project)) {
|
|
if (nc !== 0) {
|
|
return nc;
|
|
}
|
|
|
|
if (dc !== 0) {
|
|
return dc;
|
|
}
|
|
} else {
|
|
if (dc !== 0) {
|
|
return dc;
|
|
}
|
|
|
|
if (nc !== 0) {
|
|
return nc;
|
|
}
|
|
}
|
|
|
|
if (a.id > b.id) {
|
|
return +1;
|
|
} else if (a.id < b.id) {
|
|
return -1;
|
|
}
|
|
return 0;
|
|
});
|
|
return this;
|
|
},
|
|
start: function() {
|
|
var i, current, pes = this.getPlanningElements();
|
|
for (i = 0; i < pes.length; i += 1) {
|
|
current = pes[i];
|
|
if (current.start()) {
|
|
return current.start();
|
|
} else if (current.end()) {
|
|
return current.end();
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
},
|
|
getAttribute: function (val) {
|
|
if (typeof this[val] === "function") {
|
|
return this[val]();
|
|
}
|
|
|
|
return this[val];
|
|
},
|
|
does_historical_differ: function () {
|
|
return false;
|
|
},
|
|
getReporters: function() {
|
|
if (!this.reporters) {
|
|
return [];
|
|
}
|
|
if (!this.sorted_reps) {
|
|
this.sort('reporters');
|
|
this.sorted_reps = true;
|
|
}
|
|
return this.reporters;
|
|
},
|
|
addReporter: function(rep) {
|
|
var reps = this.getReporters();
|
|
if (jQuery.inArray(rep, reps) === -1) {
|
|
reps.push(rep);
|
|
this.reporters = reps;
|
|
this.sorted_reps = false;
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
removeReporter: function(rep) {
|
|
// this fails silently, when reporter to be removed is not in the list reporters.
|
|
var new_reporters = jQuery.grep(this.getReporters(), function(e, i) {
|
|
return e.id !== rep.id;
|
|
});
|
|
this.reporters = new_reporters;
|
|
// we are not resetting sorted_reps, since removal does not affect sortation.
|
|
},
|
|
getProjectStatus: function() {
|
|
return this.via_reporting !== undefined ? this.via_reporting.getStatus() : null;
|
|
},
|
|
getTypeName: function () {
|
|
var pt = this.getProjectType();
|
|
if (pt) {
|
|
return pt.name;
|
|
}
|
|
},
|
|
getStatusName: function () {
|
|
var status = this.getProjectStatus();
|
|
if (status) {
|
|
return status.name;
|
|
}
|
|
},
|
|
getProjectType: function() {
|
|
return (this.project_type !== undefined) ? this.project_type : null;
|
|
},
|
|
getResponsible: function() {
|
|
return (this.responsible !== undefined) ? this.responsible : null;
|
|
},
|
|
getResponsibleName: function() {
|
|
if (this.responsible && this.responsible.name) {
|
|
return this.responsible.name;
|
|
}
|
|
},
|
|
getAssignedName: function () {
|
|
return;
|
|
},
|
|
getSubElements: function() {
|
|
var result = [];
|
|
|
|
jQuery.each(this.getPlanningElements(), function(i, e) {
|
|
// filtering of planning elements now happens in iterateWithChildren
|
|
result.push(e);
|
|
});
|
|
|
|
jQuery.each(this.getReporters(), function(i, e) {
|
|
// filtering of projects still happens here.
|
|
if (!e.filteredOut()) {
|
|
result.push(e);
|
|
}
|
|
});
|
|
|
|
return result;
|
|
},
|
|
getParent: function() {
|
|
var parent;
|
|
if(!this.parent) return null;
|
|
parent = this.timeline.getProject(this.parent.id);
|
|
|
|
this.parent = parent;
|
|
this.getParent = function() { return this.parent; };
|
|
|
|
return this.getParent();
|
|
},
|
|
getUrl: function() {
|
|
var options = this.timeline.options;
|
|
var url = options.url_prefix;
|
|
|
|
url += options.project_prefix;
|
|
url += "/";
|
|
url += this.identifier;
|
|
url += "/timelines";
|
|
|
|
return url;
|
|
},
|
|
render: function(node) {
|
|
if (node.isExpanded()) {
|
|
return false;
|
|
}
|
|
|
|
var timeline = this.timeline;
|
|
var scale = timeline.getScale();
|
|
var beginning = timeline.getBeginning();
|
|
var milestones, others;
|
|
|
|
// draw all planning elements that should be seen in an
|
|
// aggregation. limited to one level.
|
|
|
|
var pes = this.getPlanningElements();
|
|
|
|
var dummy_node = {
|
|
getDOMElement: function() {
|
|
return node.getDOMElement();
|
|
},
|
|
isExpanded: function() {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
var is_milestone = function(e, i) {
|
|
var pet = e.getPlanningElementType();
|
|
return pet && pet.is_milestone;
|
|
};
|
|
|
|
var render = function(i, e) {
|
|
var node = jQuery.extend({}, dummy_node, {
|
|
getData: function() { return e; }
|
|
});
|
|
e.render(node, true, label_spaces[i]);
|
|
e.renderForeground(node, true, label_spaces[i]);
|
|
};
|
|
|
|
var visible_in_aggregation = function(e, i) {
|
|
var pet = e.getPlanningElementType();
|
|
return !e.filteredOut() && pet && pet.in_aggregation;
|
|
};
|
|
|
|
// The label_spaces object will contain available spaces per
|
|
// planning element. There may be many.
|
|
var label_spaces = {};
|
|
|
|
// divide into milestones and others.
|
|
milestones = jQuery.grep(pes, is_milestone);
|
|
others = jQuery.grep(pes, is_milestone, true);
|
|
|
|
// join others with milestones, and remove all that should be filtered.
|
|
pes = jQuery.grep(others.concat(milestones), visible_in_aggregation);
|
|
|
|
// Outer loop to calculate best label space for each planning
|
|
// element. Here, we initialize possible spaces by registering the
|
|
// whole element as the single space for a label.
|
|
jQuery.each(pes, function(i, e) {
|
|
var b = e.getHorizontalBounds(scale, beginning);
|
|
label_spaces[i] = [b];
|
|
|
|
// Now, for every other element (that is above the one we're
|
|
// traversing), shorten the available spaces or splice them.
|
|
jQuery.each(pes, function(j, f) {
|
|
var k, cb = f.getHorizontalBounds(scale, beginning, is_milestone(f));
|
|
|
|
// do not shorten if I am looking at myself.
|
|
if (e === f) {
|
|
return;
|
|
}
|
|
|
|
// do not shorten if current element is not a milestone and
|
|
// begins before me.
|
|
if (!is_milestone(f) && cb.x < b.x) {
|
|
return;
|
|
}
|
|
|
|
// do not shorten if both are milestones and current begins before me.
|
|
if (is_milestone(f) && is_milestone(e) && cb.x < b.x) {
|
|
return;
|
|
}
|
|
|
|
// do not shorten if I am a milestone and current element is not.
|
|
if (is_milestone(e) && !is_milestone(f)) {
|
|
return;
|
|
}
|
|
|
|
if (!e.hasBothDates() || !f.hasBothDates()) {
|
|
return;
|
|
}
|
|
|
|
// iterate over actual spaces left for shortening or splicing.
|
|
var spaces = label_spaces[i];
|
|
for (k = 0; k < spaces.length; k++) {
|
|
var space = spaces[k];
|
|
// b eeeeeeee
|
|
//cb fffffffffff
|
|
|
|
// current element lies after me,
|
|
var rightSideOverlap = cb.x > space.x &&
|
|
// but I do end after its start.
|
|
cb.x < space.end();
|
|
// b eeeeeeeeeee
|
|
//cb ffffffffffff
|
|
|
|
// current element lies before me
|
|
var leftSideOverlap = cb.end() < space.end() &&
|
|
// but I start before current elements end.
|
|
cb.end() > space.x;
|
|
|
|
if ((cb.x < space.x && cb.end() > space.end()) &&
|
|
(label_spaces[i].length > 0)) {
|
|
if (label_spaces[i].length === 1) {
|
|
label_spaces[i][0].w = 0;
|
|
} else {
|
|
label_spaces[i].splice(k, 1);
|
|
}
|
|
}
|
|
|
|
// fffffffeeeeeeeeeeeeffffffffff
|
|
if (rightSideOverlap && leftSideOverlap) {
|
|
|
|
// if current planning element is completely enclosed
|
|
// in the current space, split the space into two, and
|
|
// reiterate. splitting happens by splicing the array at
|
|
// position i.
|
|
label_spaces[i].splice(k, 1,
|
|
{'x': space.x,
|
|
'w': cb.x - space.x, end: space.end},
|
|
{'x': cb.end(),
|
|
'w': space.end() - cb.end(), end: space.end});
|
|
|
|
|
|
} else if (rightSideOverlap) {
|
|
|
|
// if current planning element (f) starts before the one
|
|
// current space ends, adjust the width of the space.
|
|
space.w = Math.min(space.end(), cb.x) - space.x;
|
|
|
|
} else if (leftSideOverlap) {
|
|
|
|
// if current planning element (f) ends after the current
|
|
// space starts, adjust the beginning of the space.
|
|
// we also need to modify the width because we want to
|
|
// keep the end at the same position.
|
|
var oldStart = space.x;
|
|
space.x = Math.max(space.x, cb.end());
|
|
space.w -= (space.x - oldStart);
|
|
}
|
|
}
|
|
});
|
|
|
|
// after all possible label spaces for the given element are
|
|
// evaluated, select the widest.
|
|
b = label_spaces[i].shift();
|
|
jQuery.each(label_spaces[i], function(i, e) {
|
|
if (e.w > b.w) {
|
|
b = e;
|
|
}
|
|
});
|
|
label_spaces[i] = b;
|
|
});
|
|
|
|
// jQuery.each(others, render);
|
|
// jQuery.each(milestones, render);
|
|
jQuery.each(pes, render);
|
|
},
|
|
getElements: function() {
|
|
return [];
|
|
},
|
|
all: function(timeline) {
|
|
// collect all planning elements
|
|
var r = timeline.projects;
|
|
var result = [];
|
|
for (var key in r) {
|
|
if (r.hasOwnProperty(key)) {
|
|
result.push(r[key]);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Timeline.HistoricalPlanningElement │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
HistoricalPlanningElement: {
|
|
identifier: 'historical_planning_elements'
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Timeline.PlanningElement │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
PlanningElement: {
|
|
is: function(t) {
|
|
return Timeline.PlanningElement.identifier === t.identifier;
|
|
},
|
|
identifier: 'planning_elements',
|
|
hide: function () {
|
|
return false;
|
|
},
|
|
filteredOut: function() {
|
|
var filtered = this.filteredOutForProjectFilter();
|
|
this.filteredOut = function() { return filtered; };
|
|
return filtered;
|
|
},
|
|
inTimeFrame: function () {
|
|
return this.timeline.inTimeFilter(this.start(), this.end());
|
|
},
|
|
filteredOutForProjectFilter: function() {
|
|
return this.project.filteredOut();
|
|
},
|
|
all: function(timeline) {
|
|
// collect all planning elements
|
|
var r = timeline.planning_elements;
|
|
var result = [];
|
|
for (var key in r) {
|
|
if (r.hasOwnProperty(key)) {
|
|
result.push(r[key]);
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
getProject: function() {
|
|
return (this.project !== undefined) ? this.project : null;
|
|
},
|
|
getPlanningElementType: function() {
|
|
return (this.planning_element_type !== undefined) ?
|
|
this.planning_element_type : null;
|
|
},
|
|
getResponsible: function() {
|
|
return (this.responsible !== undefined) ? this.responsible : null;
|
|
},
|
|
getResponsibleName: function() {
|
|
if (this.responsible && this.responsible.name) {
|
|
return this.responsible.name;
|
|
}
|
|
},
|
|
getAssignedName: function () {
|
|
if (this.assigned_to && this.assigned_to.name) {
|
|
return this.assigned_to.name;
|
|
}
|
|
},
|
|
getParent: function() {
|
|
return (this.parent !== undefined) ? this.parent : null;
|
|
},
|
|
getChildren: function() {
|
|
if (!this.planning_elements) {
|
|
return [];
|
|
}
|
|
if (!this.sorted) {
|
|
this.sort('planning_elements');
|
|
this.sorted = true;
|
|
}
|
|
return this.planning_elements;
|
|
},
|
|
hasChildren: function() {
|
|
return this.getChildren().length > 0;
|
|
},
|
|
getTypeName: function () {
|
|
var pet = this.getPlanningElementType();
|
|
if (pet) {
|
|
return pet.name;
|
|
}
|
|
},
|
|
getStatusName: function () {
|
|
if (this.status) {
|
|
return this.status.name;
|
|
}
|
|
},
|
|
getProjectName: function () {
|
|
if (this.project) {
|
|
return this.project.name;
|
|
}
|
|
},
|
|
sort: function(field) {
|
|
this[field] = this[field].sort(function(a, b) {
|
|
// order by date, name
|
|
var dc = 0;
|
|
var as = a.start(), bs = b.start();
|
|
if (as) {
|
|
if (bs) {
|
|
dc = as.compareTo(bs);
|
|
} else {
|
|
dc = 1;
|
|
}
|
|
} else if (bs) {
|
|
dc = -1;
|
|
}
|
|
if (dc !== 0) {
|
|
return dc;
|
|
}
|
|
if (a.name < b.name) {
|
|
return -1;
|
|
}
|
|
if (a.name > b.name) {
|
|
return +1;
|
|
}
|
|
return 0;
|
|
});
|
|
return this;
|
|
},
|
|
start: function() {
|
|
var pet = this.getPlanningElementType();
|
|
//if we have got a milestone w/o a start date but with an end date, just set them the same.
|
|
if (this.start_date === undefined && this.due_date !== undefined && pet && pet.is_milestone) {
|
|
this.start_date = this.due_date;
|
|
}
|
|
if (this.start_date_object === undefined && this.start_date !== undefined) {
|
|
this.start_date_object = Date.parse(this.start_date);
|
|
}
|
|
return this.start_date_object;
|
|
},
|
|
end: function() {
|
|
var pet = this.getPlanningElementType();
|
|
//if we have got a milestone w/o a start date but with an end date, just set them the same.
|
|
if (this.due_date === undefined && this.start_date !== undefined && pet && pet.is_milestone) {
|
|
this.due_date = this.start_date;
|
|
}
|
|
if (this.due_date_object=== undefined && this.due_date !== undefined) {
|
|
this.due_date_object = Date.parse(this.due_date);
|
|
}
|
|
return this.due_date_object;
|
|
},
|
|
getAttribute: function (val) {
|
|
if (typeof this[val] === "function") {
|
|
return this[val]();
|
|
}
|
|
|
|
return this[val];
|
|
},
|
|
does_historical_differ: function (val) {
|
|
if (!this.has_historical()) {
|
|
return false;
|
|
}
|
|
|
|
return this.historical().getAttribute(val) !== this.getAttribute(val);
|
|
},
|
|
has_historical: function () {
|
|
return this.historical_element !== undefined;
|
|
},
|
|
historical: function () {
|
|
return this.historical_element || Object.create(Timeline.PlanningElement);
|
|
},
|
|
alternate_start: function() {
|
|
return this.historical().start();
|
|
},
|
|
alternate_end: function() {
|
|
return this.historical().end();
|
|
},
|
|
getSubElements: function() {
|
|
return this.getChildren();
|
|
},
|
|
hasAlternateDates: function() {
|
|
return (this.does_historical_differ("start_date") ||
|
|
this.does_historical_differ("end_date") ||
|
|
this.is_deleted);
|
|
},
|
|
isDeleted: function() {
|
|
return true && this.is_deleted;
|
|
},
|
|
isNewlyAdded: function() {
|
|
return (this.timeline.isComparing() &&
|
|
!this.has_historical());
|
|
},
|
|
getAlternateHorizontalBounds: function(scale, absolute_beginning, milestone) {
|
|
return this.getHorizontalBoundsForDates(
|
|
scale,
|
|
absolute_beginning,
|
|
this.alternate_start(),
|
|
this.alternate_end(),
|
|
milestone
|
|
);
|
|
},
|
|
getHorizontalBounds: function(scale, absolute_beginning, milestone) {
|
|
return this.getHorizontalBoundsForDates(
|
|
scale,
|
|
absolute_beginning,
|
|
this.start(),
|
|
this.end(),
|
|
milestone
|
|
);
|
|
},
|
|
hasStartDate: function () {
|
|
if (this.start()) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
hasEndDate: function () {
|
|
if (this.end()) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
hasBothDates: function () {
|
|
if (this.start() && this.end()) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
hasOneDate: function () {
|
|
if (this.start() || this.end()) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
getHorizontalBoundsForDates: function(scale, absolute_beginning, start, end, milestone) {
|
|
var timeline = this.timeline;
|
|
|
|
if (!start && !end) {
|
|
return {
|
|
'x': 0,
|
|
'w': 0,
|
|
'end': function () {
|
|
return this.x + this.w;
|
|
}
|
|
};
|
|
} else if (!end) {
|
|
end = start.clone().addDays(2);
|
|
} else if (!start) {
|
|
start = end.clone().addDays(-2);
|
|
}
|
|
|
|
// calculate graphical representation. the +1 makes sense when
|
|
// considering equal start and end date.
|
|
var x = timeline.getDaysBetween(absolute_beginning, start) * scale.day;
|
|
var w = (timeline.getDaysBetween(start, end) + 1) * scale.day;
|
|
|
|
if (milestone === true) {
|
|
//we are in the middle of the diamond so we have to move half the size to the left
|
|
x -= ((scale.height - 1) / 2);
|
|
//and add the diamonds corners to the width.
|
|
w += scale.height - 1;
|
|
}
|
|
|
|
return {
|
|
'x': x,
|
|
'w': w,
|
|
'end': function () {
|
|
return this.x + this.w;
|
|
}
|
|
};
|
|
},
|
|
getUrl: function() {
|
|
var options = this.timeline.options;
|
|
var url = options.url_prefix;
|
|
|
|
url += "/work_packages/";
|
|
url += this.id;
|
|
|
|
return url;
|
|
},
|
|
getColor: function () {
|
|
// if there is a color for this planning element type, use it.
|
|
// use it also for planning elements w/ children. if there are
|
|
// children but no planning element type, use the default color
|
|
// for planning element parents. if there is no planning element
|
|
// type and there are no children, use a default color.
|
|
var pet = this.getPlanningElementType();
|
|
var color;
|
|
|
|
if (pet && pet.color) {
|
|
color = pet.color.hexcode;
|
|
} else if (this.hasChildren()) {
|
|
color = Timeline.DEFAULT_PARENT_COLOR;
|
|
} else {
|
|
color = Timeline.DEFAULT_COLOR;
|
|
}
|
|
|
|
if (!this.hasBothDates()) {
|
|
if (this.hasStartDate()) {
|
|
color = "180-#ffffff-" + color;
|
|
} else {
|
|
color = "180-" + color + "-#ffffff";
|
|
}
|
|
}
|
|
|
|
return color;
|
|
},
|
|
render: function(node, in_aggregation, label_space) {
|
|
var timeline = this.timeline;
|
|
var paper = timeline.getPaper();
|
|
var scale = timeline.getScale();
|
|
var beginning = timeline.getBeginning();
|
|
var elements = [];
|
|
var pet = this.getPlanningElementType();
|
|
var self = this;
|
|
var color, text, x, y, textColor;
|
|
var bounds = this.getHorizontalBounds(scale, beginning);
|
|
var left = bounds.x;
|
|
var width = bounds.w;
|
|
var alternate_bounds = this.getAlternateHorizontalBounds(scale, beginning);
|
|
var alternate_left = alternate_bounds.x;
|
|
var alternate_width = alternate_bounds.w;
|
|
var hover_left = left;
|
|
var hover_width = width;
|
|
var element = node.getDOMElement();
|
|
var captionElements = [];
|
|
var label;
|
|
var deleted = true && this.is_deleted;
|
|
var comparison_offset = deleted ? 0 : Timeline.DEFAULT_COMPARISON_OFFSET;
|
|
var strokeColor = Timeline.DEFAULT_STROKE_COLOR;
|
|
var historical = this.historical();
|
|
|
|
var has_both_dates = this.hasBothDates();
|
|
var has_one_date = this.hasOneDate();
|
|
var has_start_date = this.hasStartDate();
|
|
|
|
if (in_aggregation) {
|
|
hover_left = label_space.x + Timeline.HOVER_THRESHOLD;
|
|
hover_width = label_space.w - 2 * Timeline.HOVER_THRESHOLD;
|
|
}
|
|
|
|
if (in_aggregation && !has_both_dates) {
|
|
return;
|
|
}
|
|
|
|
var has_alternative = this.hasAlternateDates();
|
|
var could_have_been_milestone = (this.alternate_start === this.alternate_end);
|
|
|
|
var height, top;
|
|
|
|
if (historical.hasOneDate()) {
|
|
// ╭─────────────────────────────────────────────────────────╮
|
|
// │ Rendering of historical data. Use default planning │
|
|
// │ element appearance, only use milestones when the │
|
|
// │ element is currently a milestone and the historical │
|
|
// │ data has equal start and end dates. │
|
|
// ╰─────────────────────────────────────────────────────────╯
|
|
color = this.historical().getColor();
|
|
|
|
if (!historical.hasBothDates()) {
|
|
strokeColor = 'none';
|
|
}
|
|
|
|
//TODO: fix for work units w/o start/end date
|
|
if (!in_aggregation && has_alternative) {
|
|
if (pet && pet.is_milestone && could_have_been_milestone) {
|
|
|
|
height = scale.height - 1; //6px makes the element a little smaller.
|
|
top = (timeline.getRelativeVerticalOffset(element) + timeline.getRelativeVerticalBottomOffset(element)) / 2 - height / 2;
|
|
|
|
paper.path(
|
|
timeline.psub('M#{x} #{y}h#{w}l#{d} #{d}l-#{d} #{d}H#{x}l-#{d} -#{d}l#{d} -#{d}Z', {
|
|
x: alternate_left + scale.day / 2,
|
|
y: top - comparison_offset,
|
|
w: alternate_width - scale.day,
|
|
d: height / 2 // diamond corner width.
|
|
})
|
|
).attr({
|
|
'fill': color, // Timeline.DEFAULT_FILL_COLOR_IN_COMPARISONS,
|
|
'opacity': 0.33,
|
|
'stroke': Timeline.DEFAULT_STROKE_COLOR_IN_COMPARISONS,
|
|
'stroke-dasharray': Timeline.DEFAULT_STROKE_DASHARRAY_IN_COMPARISONS
|
|
});
|
|
|
|
} else {
|
|
|
|
height = scale.height - 6; //6px makes the element a little smaller.
|
|
top = (timeline.getRelativeVerticalOffset(element) + timeline.getRelativeVerticalBottomOffset(element)) / 2 - height / 2;
|
|
|
|
paper.rect(
|
|
alternate_left,
|
|
top - comparison_offset, // 8px margin-top
|
|
alternate_width,
|
|
height, // 8px margin-bottom
|
|
4 // round corners
|
|
).attr({
|
|
'fill': color, // Timeline.DEFAULT_FILL_COLOR_IN_COMPARISONS,
|
|
'opacity': 0.33,
|
|
'stroke': Timeline.DEFAULT_STROKE_COLOR_IN_COMPARISONS,
|
|
'stroke-dasharray': Timeline.DEFAULT_STROKE_DASHARRAY_IN_COMPARISONS
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// only render planning elements that have
|
|
// either a start or an end date.
|
|
if (has_one_date) {
|
|
color = this.getColor();
|
|
|
|
if (!has_both_dates) {
|
|
strokeColor = 'none';
|
|
}
|
|
|
|
// ╭─────────────────────────────────────────────────────────╮
|
|
// │ Rendering of actual elements, as milestones, with teeth │
|
|
// │ and the generic, dafault planning element w/ round │
|
|
// │ edges. │
|
|
// ╰─────────────────────────────────────────────────────────╯
|
|
|
|
// in_aggregation defines whether the planning element should be
|
|
// renderd as a generic planning element regardless of children.
|
|
|
|
if (!deleted && pet && pet.is_milestone) {
|
|
|
|
} else if (!deleted && !in_aggregation && this.hasChildren() && node.isExpanded()) {
|
|
|
|
// with teeth (has children).
|
|
|
|
paper.path(
|
|
timeline.psub('M#{x} #{y}m#{d} #{d}l-#{d} #{d}l-#{d} -#{d}V#{y}H#{x}h#{w}h#{d}v#{d}l-#{d} #{d}l-#{d} -#{d}z' + /* outer path */
|
|
'm0 0v-#{d}m#{w} 0m-#{d} 0m-#{d} 0v#{d}' /* inner vertical lines */, {
|
|
x: left,
|
|
y: timeline.getRelativeVerticalOffset(element) + 8,
|
|
d: scale.height + 2 - 16,
|
|
w: width
|
|
})
|
|
).attr({
|
|
'fill': color,
|
|
'stroke': strokeColor
|
|
});
|
|
} else if (!deleted) {
|
|
|
|
// generic.
|
|
|
|
height = scale.height - 6; //6px makes the element a little smaller.
|
|
top = (timeline.getRelativeVerticalOffset(element) + timeline.getRelativeVerticalBottomOffset(element)) / 2 - height / 2;
|
|
|
|
paper.rect(
|
|
left,
|
|
top,
|
|
width,
|
|
height,
|
|
4 // round corners
|
|
).attr({
|
|
'fill': color,
|
|
'stroke': strokeColor
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
renderForeground: function (node, in_aggregation, label_space) {
|
|
var timeline = this.timeline;
|
|
var paper = timeline.getPaper();
|
|
var scale = timeline.getScale();
|
|
var beginning = timeline.getBeginning();
|
|
var elements = [];
|
|
var pet = this.getPlanningElementType();
|
|
var self = this;
|
|
var color, text, x, y, textColor;
|
|
var bounds = this.getHorizontalBounds(scale, beginning);
|
|
var left = bounds.x;
|
|
var width = bounds.w;
|
|
var alternate_bounds = this.getAlternateHorizontalBounds(scale, beginning);
|
|
var alternate_left = alternate_bounds.x;
|
|
var alternate_width = alternate_bounds.w;
|
|
var hover_left = left;
|
|
var hover_width = width;
|
|
var element = node.getDOMElement();
|
|
var captionElements = [];
|
|
var label, textWidth;
|
|
var deleted = true && this.is_deleted;
|
|
var comparison_offset = deleted ? 0 : Timeline.DEFAULT_COMPARISON_OFFSET;
|
|
|
|
var has_both_dates = this.hasBothDates();
|
|
var has_one_date = this.hasOneDate();
|
|
var has_start_date = this.hasStartDate();
|
|
|
|
if (in_aggregation) {
|
|
hover_left = label_space.x + Timeline.HOVER_THRESHOLD;
|
|
hover_width = label_space.w - 2 * Timeline.HOVER_THRESHOLD;
|
|
}
|
|
|
|
var has_alternative = this.hasAlternateDates();
|
|
var could_have_been_milestone = (this.alternate_start === this.alternate_end);
|
|
|
|
var height, top;
|
|
|
|
// if there is a color for this planning element type, use it.
|
|
// use it also for planning elements w/ children. if there are
|
|
// children but no planning element type, use the default color
|
|
// for planning element parents. if there is no planning element
|
|
// type and there are no children, use a default color.
|
|
|
|
if (pet && pet.color) {
|
|
color = pet.color.hexcode;
|
|
} else if (this.hasChildren()) {
|
|
color = Timeline.DEFAULT_PARENT_COLOR;
|
|
} else {
|
|
color = Timeline.DEFAULT_COLOR;
|
|
}
|
|
|
|
if (!deleted && pet && pet.is_milestone) {
|
|
|
|
// milestones.
|
|
height = scale.height - 1; //6px makes the element a little smaller.
|
|
top = (timeline.getRelativeVerticalOffset(element) + timeline.getRelativeVerticalBottomOffset(element)) / 2 - height / 2;
|
|
|
|
paper.path(
|
|
timeline.psub('M#{x} #{y}h#{w}l#{d} #{d}l-#{d} #{d}H#{x}l-#{d} -#{d}l#{d} -#{d}Z', {
|
|
x: left + scale.day / 2,
|
|
y: top,
|
|
w: width - scale.day,
|
|
d: height / 2 // diamond corner width.
|
|
})
|
|
).attr({
|
|
'fill': color,
|
|
'stroke': Timeline.DEFAULT_STROKE_COLOR
|
|
});
|
|
|
|
}
|
|
|
|
// ╭─────────────────────────────────────────────────────────╮
|
|
// │ Labels for rendered elements, either in aggregartion │
|
|
// │ or out of aggregation, inside of elements or outside. │
|
|
// ╰─────────────────────────────────────────────────────────╯
|
|
|
|
height = scale.height - 6; //6px makes the element a little smaller.
|
|
top = (timeline.getRelativeVerticalOffset(element) + timeline.getRelativeVerticalBottomOffset(element)) / 2 - height / 2;
|
|
|
|
y = top + 11;
|
|
|
|
if (has_one_date) {
|
|
if (!in_aggregation) {
|
|
|
|
// text rendering in planning elements outside of aggregations
|
|
label = timeline.paper.text(0, -5, this.subject);
|
|
label.attr({
|
|
'font-size': 12
|
|
});
|
|
|
|
textWidth = label.getBBox().width;
|
|
|
|
// if this is an expanded planning element w/ children, or if
|
|
// the text would not fit:
|
|
if (this.hasChildren() && node.isExpanded() ||
|
|
textWidth > width - Timeline.PE_TEXT_INSIDE_PADDING) {
|
|
|
|
// place a white rect below the label.
|
|
captionElements.push(
|
|
timeline.paper.rect(
|
|
-3,
|
|
-12,
|
|
textWidth + 6,
|
|
15,
|
|
4.5
|
|
).attr({
|
|
'fill': '#ffffff',
|
|
'opacity': 0.5,
|
|
'stroke': 'none'
|
|
}));
|
|
|
|
// text outside planning element
|
|
x = left + width + Timeline.PE_TEXT_OUTSIDE_PADDING;
|
|
textColor = Timeline.PE_DEFAULT_TEXT_COLOR;
|
|
|
|
if (this.hasChildren()) {
|
|
x += Timeline.PE_TEXT_ADDITIONAL_OUTSIDE_PADDING_WHEN_EXPANDED_WITH_CHILDREN;
|
|
}
|
|
|
|
if (pet && pet.is_milestone) {
|
|
x += Timeline.PE_TEXT_ADDITIONAL_OUTSIDE_PADDING_WHEN_MILESTONE;
|
|
}
|
|
|
|
} else if (!has_both_dates) {
|
|
// text inside planning element
|
|
if (has_start_date) {
|
|
x = left + 4; // left of the WU
|
|
} else {
|
|
x = left + width - // right of the WU
|
|
textWidth - // text width
|
|
4; // small border from the right
|
|
}
|
|
|
|
textColor = timeline.getLimunanceFor(color) > Timeline.PE_LUMINANCE_THRESHOLD ?
|
|
Timeline.PE_DARK_TEXT_COLOR : Timeline.PE_LIGHT_TEXT_COLOR;
|
|
} else {
|
|
|
|
// text inside planning element
|
|
x = left + width * 0.5 + // center of the planning element
|
|
textWidth * (-0.5); // half of text width
|
|
|
|
textColor = timeline.getLimunanceFor(color) > Timeline.PE_LUMINANCE_THRESHOLD ?
|
|
Timeline.PE_DARK_TEXT_COLOR : Timeline.PE_LIGHT_TEXT_COLOR;
|
|
}
|
|
|
|
label.attr({
|
|
'fill': textColor,
|
|
'text-anchor': "start",
|
|
'stroke': 'none'
|
|
});
|
|
|
|
if (captionElements[0]) {
|
|
label.insertAfter(captionElements[0]);
|
|
}
|
|
|
|
captionElements.push(label);
|
|
|
|
jQuery.each(captionElements, function(i, e) {
|
|
e.translate(x, y);
|
|
});
|
|
|
|
} else {
|
|
|
|
|
|
textColor = timeline.getLimunanceFor(color) > Timeline.PE_LUMINANCE_THRESHOLD ?
|
|
Timeline.PE_DARK_TEXT_COLOR : Timeline.PE_LIGHT_TEXT_COLOR;
|
|
|
|
var text = this.subject;
|
|
label = timeline.paper.text(0, 0, text);
|
|
label.attr({
|
|
'font-size': 12,
|
|
'fill': textColor,
|
|
'stroke': 'none'
|
|
});
|
|
|
|
x = label_space.x + label_space.w/2;
|
|
y -= 4;
|
|
|
|
while (text.length > 0 && label.getBBox().width > label_space.w) {
|
|
text = text.slice(0, -1);
|
|
label.attr({ 'text': text });
|
|
}
|
|
|
|
label.translate(x, y);
|
|
}
|
|
}
|
|
|
|
// ╭─────────────────────────────────────────────────────────╮
|
|
// │ Defining hover areas that will produce tooltips when │
|
|
// │ mouse is over them. This is last to include text drawn │
|
|
// │ over planning elements. │
|
|
// ╰─────────────────────────────────────────────────────────╯
|
|
|
|
height = scale.height - 6; //6px makes the element a little smaller.
|
|
top = (timeline.getRelativeVerticalOffset(element) + timeline.getRelativeVerticalBottomOffset(element)) / 2 - height / 2;
|
|
|
|
elements.push(paper.rect(
|
|
hover_left - Timeline.HOVER_THRESHOLD,
|
|
top - Timeline.HOVER_THRESHOLD, // 8px margin-top
|
|
hover_width + 2 * Timeline.HOVER_THRESHOLD,
|
|
height + 2 * Timeline.HOVER_THRESHOLD, // 8px margin-bottom
|
|
4 // round corners
|
|
).attr({
|
|
'fill': '#ffffff',
|
|
'opacity': 0
|
|
}));
|
|
|
|
jQuery.each(elements, function(i, e) {
|
|
timeline.addHoverHandler(node, e);
|
|
//self.addElement(e);
|
|
});
|
|
},
|
|
renderVertical: function(node) {
|
|
var timeline = this.timeline;
|
|
var paper = timeline.getPaper();
|
|
var scale = timeline.getScale();
|
|
var beginning = timeline.getBeginning();
|
|
|
|
var pet = this.getPlanningElementType();
|
|
var self = this;
|
|
var color;
|
|
var bounds = this.getHorizontalBounds(scale, beginning);
|
|
|
|
var deleted = true && this.is_deleted;
|
|
|
|
var left = bounds.x;
|
|
var width = bounds.w;
|
|
|
|
var element = node.getDOMElement();
|
|
|
|
var has_both_dates = this.hasBothDates();
|
|
var has_one_date = this.hasOneDate();
|
|
var has_start_date = this.hasStartDate();
|
|
|
|
var hoverElement;
|
|
|
|
color = this.getColor();
|
|
|
|
if (has_one_date) {
|
|
if (!deleted && pet && pet.is_milestone) {
|
|
timeline.paper.path(
|
|
timeline.psub("M#{left} #{top}L#{left} #{height}", {
|
|
'left': left + scale.day / 2,
|
|
'top': timeline.decoHeight(),
|
|
'height': timeline.getMeasuredHeight()
|
|
})
|
|
).attr({
|
|
'stroke': color,
|
|
'stroke-width': 2,
|
|
'stroke-dasharray': '- '
|
|
});
|
|
|
|
hoverElement = paper.rect(
|
|
left + scale.day / 2 - 2 * Timeline.HOVER_THRESHOLD,
|
|
timeline.decoHeight(), // 8px margin-top
|
|
4 * Timeline.HOVER_THRESHOLD,
|
|
timeline.getMeasuredHeight() // 8px margin-bottom
|
|
).attr({
|
|
'fill': '#ffffff',
|
|
'opacity': 0
|
|
});
|
|
|
|
timeline.addHoverHandler(node, hoverElement);
|
|
} else if (!deleted) {
|
|
paper.rect(
|
|
left,
|
|
timeline.decoHeight(),
|
|
width,
|
|
timeline.getMeasuredHeight()
|
|
).attr({
|
|
'fill': color,
|
|
'stroke': Timeline.DEFAULT_STROKE_COLOR,
|
|
'opacity': 0.2
|
|
});
|
|
|
|
hoverElement = paper.rect(
|
|
left - Timeline.HOVER_THRESHOLD,
|
|
timeline.decoHeight(), // 8px margin-top
|
|
width + 2 * Timeline.HOVER_THRESHOLD,
|
|
timeline.getMeasuredHeight() // 8px margin-bottom
|
|
).attr({
|
|
'fill': '#ffffff',
|
|
'opacity': 0
|
|
});
|
|
|
|
timeline.addHoverHandler(node, hoverElement);
|
|
//self.addElement(hoverElement);
|
|
}
|
|
}
|
|
},
|
|
addElement: function(e) {
|
|
if (!this.elements) {
|
|
this.elements = [];
|
|
}
|
|
this.elements.push(e);
|
|
return this;
|
|
},
|
|
getElements: function() {
|
|
if (!this.elements) {
|
|
this.elements = [];
|
|
}
|
|
return this.elements;
|
|
}
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ Defaults and random accessors │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
// defines how many levels are expanded when a tree is created, zero
|
|
// corresponds to the root being collapsed.
|
|
firstDateSeen: null,
|
|
lastDateSeen: null,
|
|
|
|
getBeginning: function() {
|
|
return (Date.parse(this.options.timeframe_start) ||
|
|
(this.firstDateSeen && this.firstDateSeen.clone() ||
|
|
new Date()).last().monday());
|
|
},
|
|
getEnd: function() {
|
|
return (Date.parse(this.options.timeframe_end) ||
|
|
(this.lastDateSeen && this.lastDateSeen.clone() ||
|
|
new Date()).addWeeks(1).next().sunday());
|
|
},
|
|
getDaysBetween: function(a, b) {
|
|
// some meat around date calculations that will be floored out again
|
|
// later. this hopefully takes care of floating point imprecisions
|
|
// and possible leap seconds, as we're only interested in days.
|
|
var da = a - 1000 * 60 * 60 * 4;
|
|
var db = b - 1000 * 60 * 60 * (-4);
|
|
return Math.floor((db - da) / (1000 * 60 * 60 * 24));
|
|
},
|
|
includeDate: function(date) {
|
|
if (date) {
|
|
if (this.firstDateSeen == null ||
|
|
date.compareTo(this.firstDateSeen) < 0) {
|
|
this.firstDateSeen = date;
|
|
} else if (this.lastDateSeen == null ||
|
|
date.compareTo(this.lastDateSeen) > 0) {
|
|
this.lastDateSeen = date;
|
|
}
|
|
}
|
|
},
|
|
adjustForPlanningElements: function() {
|
|
var timeline = this;
|
|
var tree = this.getLefthandTree();
|
|
|
|
// nullify potential previous dates seen. this is relevant when
|
|
// adjusting after the addition of a planning element via modal.
|
|
|
|
timeline.firstDateSeen = null;
|
|
timeline.lastDateSeen = null;
|
|
|
|
tree.iterateWithChildren(function(node) {
|
|
var data = node.getData();
|
|
if (data.is(Timeline.PlanningElement)) {
|
|
timeline.includeDate(data.start());
|
|
timeline.includeDate(data.end());
|
|
}
|
|
}, {
|
|
traverseCollapsed: true
|
|
});
|
|
|
|
},
|
|
getReportings: function() {
|
|
return Timeline.Reporting.all(this);
|
|
},
|
|
getReporting: function(id) {
|
|
return this.reportings[id];
|
|
},
|
|
getProjects: function() {
|
|
return Timeline.Project.all(this);
|
|
},
|
|
getProject: function(id) {
|
|
if (id === undefined) {
|
|
return this.project;
|
|
}
|
|
else return this.projects[id];
|
|
},
|
|
getGroupForProject: function(p) {
|
|
var i, j = 0, projects, key, group;
|
|
var groups = this.getFirstLevelGroups();
|
|
for (j = 0; j < groups.length; j += 1) {
|
|
projects = groups[j].projects;
|
|
group = this.getProject(groups[j].id);
|
|
|
|
for (i = 0; i < projects.length; i++) {
|
|
if (p.id === projects[i].id) {
|
|
return {
|
|
'id': group.id,
|
|
'p': group,
|
|
'number': j + 1,
|
|
'name': group.name
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
'number': 0,
|
|
'id': 0,
|
|
'name': this.i18n('timelines.filter.grouping_other')
|
|
};
|
|
},
|
|
|
|
firstLevelGroups: undefined,
|
|
getFirstLevelGroups: function() {
|
|
if (this.firstLevelGroups !== undefined) {
|
|
return this.firstLevelGroups;
|
|
}
|
|
var i, selection = this.options.grouping_one_selection;
|
|
var p, groups = [], children;
|
|
if (this.isGrouping()) {
|
|
for ( i = 0; i < selection.length; i++ ) {
|
|
p = this.getProject(selection[i]);
|
|
if (p === undefined) {
|
|
// projects may have subprojects that the current user knows
|
|
// about, but which cannot be/ were not fetched in advance due
|
|
// to lack of rights.
|
|
continue;
|
|
}
|
|
children = this.getSubprojectsOf([selection[i]]);
|
|
if (children.length !== 0) {
|
|
groups.push({
|
|
projects: children,
|
|
id: selection[i]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
this.firstLevelGroups = groups;
|
|
return groups;
|
|
},
|
|
getNumberOfGroups: function() {
|
|
var result = this.options.hide_other_group? 0: 1;
|
|
var groups = this.getFirstLevelGroups();
|
|
return result + groups.length;
|
|
},
|
|
getSubprojectsOf: function(parents) {
|
|
var projects = this.getProjects();
|
|
var result = [];
|
|
var timeline = this;
|
|
|
|
// if parents is not an array, turn it into one with length 1, so
|
|
// the following each does not fail.
|
|
if (!(parents instanceof Array)) {
|
|
parents = [parents];
|
|
}
|
|
|
|
var ancestorIsIn = function(project, ancestors) {
|
|
|
|
var parent = project.getParent();
|
|
var r = false;
|
|
if (parent !== null) {
|
|
jQuery.each(ancestors, function(i, p) {
|
|
|
|
// make sure this is a number. when coming from the options
|
|
// array, it might actually be an array of strings.
|
|
if (typeof p === 'string') {
|
|
p = timeline.pnum(p);
|
|
}
|
|
|
|
if (parent && p === parent.id) {
|
|
r = true;
|
|
}
|
|
});
|
|
|
|
// check rest of project tree. this might break when a project
|
|
// in between is not visible to the current user.
|
|
if (parent) {
|
|
r = r || ancestorIsIn(parent, ancestors);
|
|
}
|
|
}
|
|
return r;
|
|
};
|
|
|
|
jQuery.each(projects, function(i, e) {
|
|
if (ancestorIsIn(e, parents)) {
|
|
result.push(e);
|
|
}
|
|
});
|
|
|
|
return result;
|
|
},
|
|
getProjectTypes: function() {
|
|
return Timeline.ProjectType.all(this);
|
|
},
|
|
getProjectType: function(id) {
|
|
return this.project_types[id];
|
|
},
|
|
getPlanningElementTypes: function() {
|
|
return Timeline.PlanningElementType.all(this);
|
|
},
|
|
getPlanningElementType: function(id) {
|
|
return this.planning_element_types[id];
|
|
},
|
|
getPlanningElements: function() {
|
|
return Timeline.PlanningElement.all(this);
|
|
},
|
|
getPlanningElement: function(id) {
|
|
return this.planning_elements[id];
|
|
},
|
|
getColors: function() {
|
|
return Timeline.Color.all(this);
|
|
},
|
|
getProjectAssociations: function() {
|
|
return Timeline.ProjectAssociation.all(this);
|
|
},
|
|
|
|
TreeNode: {
|
|
|
|
payload: undefined,
|
|
parentNode: undefined,
|
|
childNodes: undefined,
|
|
expanded: false,
|
|
|
|
totalCount: 0,
|
|
projectCount: 0,
|
|
|
|
getData: function() {
|
|
return this.payload;
|
|
},
|
|
setData: function(data) {
|
|
this.payload = data;
|
|
return this;
|
|
},
|
|
appendChild: function(node) {
|
|
if (!this.childNodes) {
|
|
this.childNodes = [node];
|
|
} else {
|
|
this.childNodes.push(node);
|
|
}
|
|
node.parentNode = this;
|
|
return node.parentNode;
|
|
},
|
|
removeChild: function(node) {
|
|
var result;
|
|
jQuery.each(this.childNodes, function(i, e) {
|
|
if (node === e) {
|
|
result = node;
|
|
}
|
|
});
|
|
return result;
|
|
},
|
|
hasChildren: function() {
|
|
return this.childNodes && this.childNodes.length > 0;
|
|
},
|
|
children: function() {
|
|
return this.childNodes;
|
|
},
|
|
root: function() {
|
|
if (this.parentNode) {
|
|
return this.parentNode.root();
|
|
} else {
|
|
return this;
|
|
}
|
|
},
|
|
isExpanded: function() {
|
|
return this.expanded;
|
|
},
|
|
setExpand: function(state) {
|
|
this.expanded = state;
|
|
return this.expanded;
|
|
},
|
|
expand: function() {
|
|
return this.setExpand(true);
|
|
},
|
|
collapse: function() {
|
|
return this.setExpand(false);
|
|
},
|
|
toggle: function() {
|
|
return this.setExpand(!this.expanded);
|
|
},
|
|
setExpandedAll: function(state) {
|
|
if (!this.hasChildren()) {
|
|
return;
|
|
}
|
|
this.setExpand(state);
|
|
jQuery.each(this.children(), function(i, e) {
|
|
e.setExpandedAll(state);
|
|
});
|
|
},
|
|
expandAll: function() {
|
|
return this.setExpandedAll(true);
|
|
},
|
|
collapseAll: function() {
|
|
return this.setExpandedAll(false);
|
|
},
|
|
setDOMElement: function(element) {
|
|
this.dom_element = element;
|
|
},
|
|
getDOMElement: function() {
|
|
return this.dom_element;
|
|
},
|
|
iterateWithChildren: function(callback, options) {
|
|
var root = this.root();
|
|
var self = this;
|
|
var timeline;
|
|
var filtered_out, hidden;
|
|
var children = this.children();
|
|
var has_children = children !== undefined;
|
|
|
|
// there might not be any payload, due to insufficient rights and
|
|
// the fact that some user with more rights originally created the
|
|
// report.
|
|
if (root.payload === undefined) {
|
|
// FLAG raise some flag indicating that something is
|
|
// wrong/missing.
|
|
return this;
|
|
}
|
|
|
|
timeline = root.payload.timeline;
|
|
hidden = this.payload.hide();
|
|
filtered_out = this.payload.filteredOut();
|
|
options = options || {indent: 0, index: 0, projects: 0};
|
|
|
|
// ╭─────────────────────────────────────────────────────────╮
|
|
// │ The hide_other_group flag is an option that cuases │
|
|
// │ iteration to stop when the "other" group, i.e., │
|
|
// │ everything that is not otherwise grouped, is reached. │
|
|
// │ This effectively hides that group. │
|
|
// ╰─────────────────────────────────────────────────────────╯
|
|
// the "other" group is reached when we are dealing with a
|
|
// grouping timeline, the current payload is a project, not root,
|
|
// but on level 0, and the first level grouping is 0.
|
|
|
|
if (timeline.options.hide_other_group &&
|
|
timeline.isGrouping() &&
|
|
this.payload.is(Timeline.Project) &&
|
|
this !== root &&
|
|
options.indent === 0 &&
|
|
this.payload.getFirstLevelGrouping() === 0) {
|
|
|
|
return;
|
|
}
|
|
|
|
if (this === root) {
|
|
options = jQuery.extend({}, {indent: 0, index: 0, projects: 0, traverseCollapsed: false}, options);
|
|
}
|
|
|
|
if (this === root && timeline.options.hide_tree_root === true) {
|
|
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ There used to be a requirement that disabled planning │
|
|
// │ elements in root when root should be hidden. That │
|
|
// │ requirement was inverted and it is now desired to │
|
|
// │ show all such planning elements on the root level of │
|
|
// │ the tree. │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
|
|
if (has_children) {
|
|
jQuery.each(children, function(i, e) {
|
|
e.iterateWithChildren(callback, options);
|
|
});
|
|
}
|
|
|
|
} else {
|
|
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ There is a requirement that states that filter status │
|
|
// │ should no longer be inherited. The callback therefore │
|
|
// │ is only invoked when payload is not filtered out. The │
|
|
// │ same is true for incrementing the projects and index │
|
|
// │ count. │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
|
|
if (!filtered_out && !hidden) {
|
|
|
|
if (callback) {
|
|
callback.call(this, this, options.indent, options.index);
|
|
}
|
|
|
|
if (this.payload.is(Timeline.Project)) {
|
|
options.projects++;
|
|
}
|
|
|
|
options.index++;
|
|
}
|
|
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ There is a requirement that states that if the │
|
|
// │ current node is closed, children that are projects │
|
|
// │ should be displayed anyway, and only children that │
|
|
// │ are planning elements should be removed from the │
|
|
// │ view. Beware, this only works as long as there are no │
|
|
// │ projects that are children of planning elements. │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
|
|
// if there are children, loop over them, independently of
|
|
// current node expansion state.
|
|
if (has_children) {
|
|
options.indent++;
|
|
|
|
jQuery.each(children, function(i, child) {
|
|
|
|
// ╭───────────────────────────────────────────────────╮
|
|
// │ Now, if the node, the children of which we │
|
|
// │ are looping over, was expanded, iterate │
|
|
// │ over its children, recursively. Do the same │
|
|
// │ if the iteration was configured with the │
|
|
// │ traverseCollapsed flag. Last but not least, if │
|
|
// │ the current child is a project, iterate over it │
|
|
// │ only if indentation is not too deep. │
|
|
// ╰───────────────────────────────────────────────────╯
|
|
|
|
if (options.traverseCollapsed ||
|
|
self.isExpanded() ||
|
|
child.payload.is(Timeline.Project)) {
|
|
|
|
//do we wan to inherit the hidden status from projects to planning elements?
|
|
if (!hidden || child.payload.is(Timeline.Project)) {
|
|
if (!(options.indent > 1 && child.payload.is(Timeline.Project))) {
|
|
child.iterateWithChildren(callback, options);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
options.indent--;
|
|
}
|
|
}
|
|
|
|
if (this === root) {
|
|
this.totalCount = options.index;
|
|
this.projectCount = options.projects;
|
|
}
|
|
return this;
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────╮
|
|
// │ The following methods are supposed to be called │
|
|
// │ from the root level of the tree, but do │
|
|
// │ gracefully retrieve the root if called from │
|
|
// │ anywhere else. │
|
|
// ╰───────────────────────────────────────────────────╯
|
|
expandTo: function(level) {
|
|
var root = this.root();
|
|
var i = 0, expandables = [root];
|
|
var expand = function (i,e) { return e.expand(); };
|
|
var children;
|
|
var j, c;
|
|
|
|
if (level === undefined) {
|
|
// "To infinity ... and beyond!" - Buzz Lightyear.
|
|
level = Infinity;
|
|
}
|
|
|
|
// collapse all, and expand only as much as is enabled by default.
|
|
root.collapseAll();
|
|
|
|
while (i++ < level && expandables.length > 0) {
|
|
|
|
jQuery.each(expandables, expand);
|
|
children = [];
|
|
for (j = 0; j < expandables.length; j++) {
|
|
c = expandables[j].children();
|
|
if (c) {
|
|
children = children.concat(c);
|
|
}
|
|
}
|
|
expandables = children;
|
|
}
|
|
|
|
return level;
|
|
},
|
|
numberOfProjects: function() {
|
|
return this.getRootProperty('projectCount');
|
|
},
|
|
numberOfPlanningElements: function() {
|
|
return this.getRootProperty('totalCount') -
|
|
this.getRootProperty('projectCount');
|
|
},
|
|
height: function() {
|
|
return this.getRootProperty('totalCount');
|
|
},
|
|
getRootProperty: function(property) {
|
|
var root = this.root();
|
|
this.iterateWithChildren();
|
|
return root[property];
|
|
},
|
|
containsProjects: function() {
|
|
return this.numberOfProjects() !== 0;
|
|
},
|
|
containsPlanningElements: function() {
|
|
return this.numberOfPlanningElements() !== 0;
|
|
}
|
|
},
|
|
|
|
getLefthandTree: function() {
|
|
|
|
if (!this.lefthandTree) {
|
|
|
|
// as long as there are no stored filters or aggregates, we only use
|
|
// the projects as roots.
|
|
var project = this.getProject();
|
|
var tree = Object.create(Timeline.TreeNode);
|
|
var parent_stack = [];
|
|
|
|
tree.setData(project);
|
|
|
|
// there might not be any payload, due to insufficient rights and
|
|
// the fact that some user with more rights originally created the
|
|
// report.
|
|
if (!project) {
|
|
// FLAG raise some flag indicating that something is
|
|
// wrong/missing.
|
|
return tree;
|
|
}
|
|
|
|
var count = 1;
|
|
// for the given node, appends the given planning_elements as children,
|
|
// recursively. every node will have the planning_element as data.
|
|
var treeConstructor = function(node, elements) {
|
|
count += 1;
|
|
|
|
var MAXIMUMPROJECTCOUNT = 12000;
|
|
if (count > MAXIMUMPROJECTCOUNT) {
|
|
throw I18n.t('js.timelines.tooManyProjects', {count: MAXIMUMPROJECTCOUNT});
|
|
}
|
|
|
|
jQuery.each(elements, function(i, e) {
|
|
parent_stack.push(node.payload);
|
|
for (var j = 0; j < parent_stack.length; j++) {
|
|
if (parent_stack[j] === e) {
|
|
parent_stack.pop();
|
|
return; // no more recursion!
|
|
}
|
|
}
|
|
var newNode = Object.create(Timeline.TreeNode);
|
|
newNode.setData(e);
|
|
node.appendChild(newNode);
|
|
treeConstructor(newNode, newNode.getData().getSubElements());
|
|
parent_stack.pop();
|
|
});
|
|
return node;
|
|
};
|
|
|
|
this.lefthandTree = treeConstructor(tree, project.getSubElements());
|
|
|
|
this.lefthandTree.expandTo(this.options.initial_outline_expansion);
|
|
}
|
|
|
|
return this.lefthandTree;
|
|
},
|
|
|
|
// ╭───────────────────────────────────────────────────────────────────╮
|
|
// │ UI and Plotting │
|
|
// ╰───────────────────────────────────────────────────────────────────╯
|
|
|
|
DEFAULT_COLOR: '#999999',
|
|
DEFAULT_FILL_COLOR_IN_COMPARISONS: 'none',
|
|
DEFAULT_LANE_COLOR: '#000000',
|
|
DEFAULT_LANE_WIDTH: 1,
|
|
DEFAULT_PARENT_COLOR: '#666666',
|
|
DEFAULT_STROKE_COLOR: '#000000',
|
|
DEFAULT_STROKE_COLOR_IN_COMPARISONS: '#000000',
|
|
DEFAULT_STROKE_DASHARRAY_IN_COMPARISONS: '', // other examples: '-', '- ', '-- '
|
|
DEFAULT_COMPARISON_OFFSET: 5,
|
|
|
|
DAY_WIDTH: 16,
|
|
|
|
MIN_CHART_WIDTH: 200,
|
|
RENDER_BUCKET_SIZE: 32,
|
|
ORIGINAL_BORDER_WIDTH_CORRECTION: 3,
|
|
BORDER_WIDTH_CORRECTION: 3,
|
|
HOVER_THRESHOLD: 3,
|
|
|
|
GROUP_BAR_INDENT: -10,
|
|
|
|
PE_DARK_TEXT_COLOR: '#000000', // color on light planning element.
|
|
PE_DEFAULT_TEXT_COLOR: '#000000', // color on timelines background.
|
|
PE_HEIGHT: 20,
|
|
PE_LIGHT_TEXT_COLOR: '#ffffff', // color on dark planning element.
|
|
PE_LUMINANCE_THRESHOLD: 0.5, // threshold above which dark text is rendered.
|
|
PE_TEXT_ADDITIONAL_OUTSIDE_PADDING_WHEN_MILESTONE: 6,
|
|
PE_TEXT_ADDITIONAL_OUTSIDE_PADDING_WHEN_EXPANDED_WITH_CHILDREN: 6,
|
|
PE_TEXT_INSIDE_PADDING: 8, // 4px padding on both sides of the planning element towards an inside labelelement towards an inside label.
|
|
PE_TEXT_OUTSIDE_PADDING: 6, // space between planning element and text to its right.
|
|
|
|
USE_MODALS: true,
|
|
|
|
scale: 1,
|
|
zoomIndex: 0,
|
|
|
|
// OUTLINE_LEVELS define possible OUTLINE_CONFIGURATIONS.
|
|
OUTLINE_LEVELS: ['aggregation', 'level1', 'level2', 'level3', 'level4', 'level5', 'all'],
|
|
OUTLINE_CONFIGURATIONS: {
|
|
aggregation: { name: 'timelines.outlines.aggregation', level: 0 },
|
|
level1: { name: 'timelines.outlines.level1', level: 1 },
|
|
level2: { name: 'timelines.outlines.level2', level: 2 },
|
|
level3: { name: 'timelines.outlines.level3', level: 3 },
|
|
level4: { name: 'timelines.outlines.level4', level: 4 },
|
|
level5: { name: 'timelines.outlines.level5', level: 5 },
|
|
all: { name: 'timelines.outlines.all', level: Infinity }
|
|
},
|
|
|
|
// ZOOM_SCALES define possible ZOOM_CONFIGURATIONS.
|
|
ZOOM_SCALES: ['yearly', 'quarterly', 'monthly', 'weekly', 'daily'],
|
|
ZOOM_CONFIGURATIONS: {
|
|
daily: {name: 'timelines.zoom.days', scale: 1.40, config: ['months', 'weeks', 'actualDays', 'weekDays']},
|
|
weekly: {name: 'timelines.zoom.weeks', scale: 0.89, config: ['months', 'weeks', 'weekDays']},
|
|
monthly: {name: 'timelines.zoom.months', scale: 0.53, config: ['years', 'months', 'weeks']},
|
|
quarterly: {name: 'timelines.zoom.quarters', scale: 0.21, config: ['year-quarters', 'months', 'weeks']},
|
|
yearly: {name: 'timelines.zoom.years', scale: 0.10, config: ['years', 'quarters', 'months']}
|
|
},
|
|
getScale: function() {
|
|
var day = this.DAY_WIDTH * this.scale;
|
|
var week = day * 7;
|
|
var height = Timeline.PE_HEIGHT;
|
|
|
|
return {
|
|
height: height,
|
|
week: week,
|
|
day: day
|
|
};
|
|
},
|
|
setScale: function(scale) {
|
|
// returns width for specified scale
|
|
if (!scale) {
|
|
scale = this.scale;
|
|
} else {
|
|
this.scale = scale;
|
|
}
|
|
var days = this.getDaysBetween(
|
|
this.getBeginning(),
|
|
this.getEnd()
|
|
);
|
|
return days * this.DAY_WIDTH * scale;
|
|
},
|
|
getWidth: function() {
|
|
|
|
// width is the wider of the currently visible chart dimensions
|
|
// (adjusted_width) and the minimum space the timeline needs.
|
|
return Math.max(this.adjusted_width, this.setScale() + 200);
|
|
},
|
|
resetWidth: function() {
|
|
delete this.adjusted_width;
|
|
},
|
|
adjustWidth: function(width) {
|
|
// adjusts for the currently visible chart dimensions.
|
|
var old_adjusted_width = this.adjusted_width;
|
|
|
|
this.adjusted_width = this.adjusted_width === undefined?
|
|
width: Math.max(old_adjusted_width, Math.max(width, this.adjusted_width));
|
|
|
|
if (old_adjusted_width < this.adjusted_width) {
|
|
this.rebuildAll();
|
|
}
|
|
|
|
return this.adjusted_width;
|
|
},
|
|
getHeight: function() {
|
|
return this.getMeasuredHeight() - this.getMeasuredScrollbarHeight();
|
|
},
|
|
scaleToFit: function(width) {
|
|
var scale = width / (this.DAY_WIDTH * this.getDaysBetween(
|
|
this.getBeginning(),
|
|
this.getEnd()
|
|
));
|
|
this.setScale(scale);
|
|
return scale;
|
|
},
|
|
getColorParts: function(color) {
|
|
return jQuery.map(color.match(/[0-9a-fA-F]{2}/g), function(e, i) {
|
|
return parseInt(e, 16);
|
|
});
|
|
},
|
|
getLimunanceFor: function(color) {
|
|
var parts = this.getColorParts(color);
|
|
var result = (0.299 * parts[0] + 0.587 * parts[1] + 0.114 * parts[2]) / 256;
|
|
return result;
|
|
},
|
|
|
|
expandTo: function(index) {
|
|
var level;
|
|
index = Math.max(index, 0);
|
|
level = Timeline.OUTLINE_CONFIGURATIONS[Timeline.OUTLINE_LEVELS[index]].level;
|
|
if (this.options.hide_tree_root) {
|
|
level++;
|
|
}
|
|
level = this.getLefthandTree().expandTo(level);
|
|
this.expansionIndex = index;
|
|
this.rebuildAll();
|
|
},
|
|
|
|
zoom: function(index) {
|
|
if (index === undefined) {
|
|
index = this.zoomIndex;
|
|
}
|
|
index = Math.max(Math.min(this.ZOOM_SCALES.length - 1, index), 0);
|
|
this.zoomIndex = index;
|
|
var scale = Timeline.ZOOM_CONFIGURATIONS[Timeline.ZOOM_SCALES[index]].scale;
|
|
this.setScale(scale);
|
|
this.resetWidth();
|
|
this.triggerResize();
|
|
this.rebuildAll();
|
|
},
|
|
zoomIn: function() {
|
|
this.zoom(this.zoomIndex + 1);
|
|
},
|
|
zoomOut: function() {
|
|
this.zoom(this.zoomIndex - 1);
|
|
},
|
|
getSwimlaneStyles: function() {
|
|
return [{
|
|
textColor: '#000000',
|
|
laneColor: '#e7e7e7'
|
|
}, {
|
|
textColor: '#000000',
|
|
laneColor: '#797979'
|
|
}, {
|
|
// laneWidth: 1.5,
|
|
textColor: '#000000',
|
|
laneColor: '#424242'
|
|
}, {
|
|
// laneWidth: 2,
|
|
textColor: '#000000',
|
|
laneColor: '#000000'
|
|
}];
|
|
},
|
|
getSwimlaneConfiguration: function() {
|
|
return {
|
|
'actualDays': {
|
|
// actual days
|
|
delimiter: this.getBeginning().moveToFirstDayOfMonth().moveToDayOfWeek(Date.CultureInfo.firstDayOfWeek, -1),
|
|
caption: function() { return this.delimiter.toString('d'); },
|
|
next: function() { return this.delimiter.addDays(1); },
|
|
overrides: ['weekDays']
|
|
},
|
|
'weekDays': {
|
|
// weekdays
|
|
delimiter: this.getBeginning().moveToFirstDayOfMonth().moveToDayOfWeek(Date.CultureInfo.firstDayOfWeek, -1),
|
|
caption: function() { return this.delimiter.toString('ddd')[0]; },
|
|
next: function() { return this.delimiter.addDays(1); },
|
|
overrides: ['actualDays']
|
|
},
|
|
'weeks': {
|
|
// weeks
|
|
delimiter: this.getBeginning().moveToFirstDayOfMonth().moveToDayOfWeek(Date.CultureInfo.firstDayOfWeek, -1),
|
|
caption: function() { return this.delimiter.getWeekOfYear(); },
|
|
next: function() { return this.delimiter.addWeeks(1); },
|
|
overrides: ['weekDays', 'actualDays']
|
|
},
|
|
'months': {
|
|
// months
|
|
delimiter: this.getBeginning().moveToFirstDayOfMonth(),
|
|
caption: function() { return Date.CultureInfo.abbreviatedMonthNames[this.delimiter.getMonth()]; },
|
|
next: function() { return this.delimiter.addMonths(1); },
|
|
overrides: ['actualDays', 'weekDays', 'quarters']
|
|
},
|
|
'year-quarters': {
|
|
// quarters
|
|
delimiter: this.getBeginning().moveToMonth(0, -1).moveToFirstDayOfMonth(),
|
|
caption: function() { return Date.CultureInfo.abbreviatedQuarterNames[this.delimiter.getQuarter()] + " " + this.delimiter.toString('yyyy'); },
|
|
next: function() { return this.delimiter.addQuarters(1); },
|
|
overrides: ['actualDays', 'weekDays', 'months', 'quarters']
|
|
},
|
|
'quarters': {
|
|
// quarters
|
|
delimiter: this.getBeginning().moveToMonth(0, -1).moveToFirstDayOfMonth(),
|
|
caption: function() { return Date.CultureInfo.abbreviatedQuarterNames[this.delimiter.getQuarter()]; },
|
|
next: function() { return this.delimiter.addQuarters(1); },
|
|
overrides: ['actualDays', 'weekDays', 'months']
|
|
},
|
|
'years': {
|
|
// years
|
|
delimiter: this.getBeginning().moveToMonth(0, -1).moveToFirstDayOfMonth(),
|
|
caption: function() { return this.delimiter.toString('yyyy'); },
|
|
next: function() { return this.delimiter.addYears(1); },
|
|
overrides: ['actualDays', 'weekDays', 'months', 'quarters']
|
|
}};
|
|
},
|
|
getAvailableRows: function() {
|
|
function renderHistoricalKind(data, val, diff) {
|
|
if (!data.does_historical_differ(val)) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof diff === "function") {
|
|
return diff(data[val], data.historical()[val]);
|
|
}
|
|
|
|
return "changed";
|
|
}
|
|
|
|
function historicalHtml(data, val) {
|
|
var kind = renderHistoricalKind(data, val);
|
|
|
|
return renderHistoricalHtml(data, val, kind);
|
|
}
|
|
|
|
function renderHistoricalHtml(data, val, kind) {
|
|
var result = "", theVal;
|
|
|
|
if (data.does_historical_differ(val)) {
|
|
theVal = data.historical().getAttribute(val);
|
|
|
|
if (!theVal) {
|
|
theVal = timeline.i18n('timelines.empty');
|
|
}
|
|
|
|
result += '<span class="tl-historical">';
|
|
result += timeline.escape(theVal);
|
|
result += '<a href="javascript://" title="%t" class="%c"/>'
|
|
.replace(/%t/, timeline.i18n('timelines.change'))
|
|
.replace(/%c/, 'icon tl-icon-' + kind);
|
|
result += '</span><br/>';
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function historicalKindDate(oldVal, newVal) {
|
|
if (oldVal && newVal) {
|
|
return (newVal < oldVal ? 'postponed' : 'preponed');
|
|
}
|
|
|
|
return "changed";
|
|
}
|
|
|
|
var map = {
|
|
"type": "getTypeName",
|
|
"status": "getStatusName",
|
|
"responsible": "getResponsibleName",
|
|
"assigned_to": "getAssignedName",
|
|
"project": "getProjectName"
|
|
};
|
|
|
|
var timeline = this;
|
|
return {
|
|
all: ['due_date', 'type', 'status', 'responsible', 'start_date'],
|
|
general: function (data, val) {
|
|
if (!map[val]) {
|
|
return;
|
|
}
|
|
|
|
val = map[val];
|
|
|
|
var result = "";
|
|
var theVal = data.getAttribute(val);
|
|
|
|
result += historicalHtml(data, val);
|
|
return jQuery(result + '<span class="tl-column">' + timeline.escape(theVal) + '</span>');
|
|
},
|
|
start_date: function(data) {
|
|
var kind = renderHistoricalKind(data, "start_date", historicalKindDate),
|
|
result = '';
|
|
|
|
result += renderHistoricalHtml(data, "start_date", kind);
|
|
|
|
if (data.start_date !== undefined) {
|
|
result += '<span class="tl-column tl-current tl-' + kind + '">' + timeline.escape(data.start_date) + '</span>';
|
|
}
|
|
return jQuery(result);
|
|
},
|
|
due_date: function(data) {
|
|
var kind = renderHistoricalKind(data, "due_date", historicalKindDate),
|
|
result = '';
|
|
|
|
result += renderHistoricalHtml(data, "due_date", kind);
|
|
|
|
if (data.due_date !== undefined) {
|
|
result += '<span class="tl-column tl-current tl-' + kind + '">' +
|
|
timeline.escape(data.due_date) + '</span>';
|
|
}
|
|
return jQuery(result);
|
|
}
|
|
};
|
|
},
|
|
getUiRoot: function() {
|
|
return this.uiRoot;
|
|
},
|
|
getEventHandlerSuffix: function() {
|
|
if (this.event_handler_suffix === undefined) {
|
|
this.event_handler_suffix = this.getUiRoot().attr('id');
|
|
}
|
|
return this.event_handler_suffix;
|
|
},
|
|
getTooltip: function() {
|
|
var tooltip = this.getUiRoot().find('.tl-tooltip');
|
|
|
|
return tooltip;
|
|
},
|
|
getChart: function() {
|
|
return this.getUiRoot().find('.tl-chart');
|
|
},
|
|
|
|
i18n: function(key) {
|
|
var value = this.options.i18n[key];
|
|
var message;
|
|
if (value === undefined) {
|
|
message = 'translation missing: ' + key;
|
|
if (console && console.log) {
|
|
console.log(message);
|
|
}
|
|
return message;
|
|
} else {
|
|
return value;
|
|
}
|
|
},
|
|
|
|
setupUI: function() {
|
|
|
|
this.setupToolbar();
|
|
this.setupChart();
|
|
},
|
|
setupToolbar: function() {
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ Builds the following dom and adds it to root: │
|
|
// │ │
|
|
// │ <div class="tl-toolbar"> ... </div> │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
var toolbar = jQuery('<div class="tl-toolbar"></div>');
|
|
var timeline = this;
|
|
var i, c, containers = [
|
|
0,
|
|
1,
|
|
0, 100, 0, 0, // zooming
|
|
1,
|
|
0, 0 // outline
|
|
];
|
|
var icon = '<a href="javascript://" title="%t" class="%c"/>';
|
|
|
|
for (i = 0; i < containers.length; i++) {
|
|
c = jQuery('<div class="tl-toolbar-container"></div>');
|
|
if (containers[i] !== 0) {
|
|
c.css({
|
|
'width': containers[i] + 'px',
|
|
'height': '20px'
|
|
});
|
|
}
|
|
containers[i] = c;
|
|
toolbar.append(c);
|
|
}
|
|
this.getUiRoot().append(toolbar);
|
|
|
|
var currentContainer = 0;
|
|
|
|
if (Timeline.USE_MODALS) {
|
|
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ Add element │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
|
|
containers[currentContainer++].append(
|
|
jQuery(icon
|
|
.replace(/%t/, timeline.i18n('timelines.new_work_package'))
|
|
.replace(/%c/, 'icon icon-add')
|
|
).click(function(e) {
|
|
e.stopPropagation();
|
|
timeline.addPlanningElement();
|
|
return false;
|
|
}));
|
|
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ Spacer │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
|
|
containers[currentContainer++].css({
|
|
'background-color': '#000000'
|
|
});
|
|
|
|
} else {
|
|
currentContainer += 2;
|
|
}
|
|
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ Zooming │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
|
|
// drop-down
|
|
var form = jQuery('<form></form>');
|
|
var zooms = jQuery('<select name="zooms"></select>');
|
|
for (i = 0; i < Timeline.ZOOM_SCALES.length; i++) {
|
|
zooms.append(jQuery(
|
|
'<option>' +
|
|
timeline.i18n(Timeline.ZOOM_CONFIGURATIONS[Timeline.ZOOM_SCALES[i]].name) +
|
|
'</option>'));
|
|
}
|
|
form.append(zooms);
|
|
containers[currentContainer + 3].append(form);
|
|
|
|
// slider
|
|
var slider = jQuery('<div></div>').slider({
|
|
min: 1,
|
|
max: Timeline.ZOOM_SCALES.length,
|
|
range: 'min',
|
|
value: zooms[0].selectedIndex + 1,
|
|
slide: function(event, ui) {
|
|
zooms[0].selectedIndex = ui.value - 1;
|
|
},
|
|
change: function(event, ui) {
|
|
zooms[0].selectedIndex = ui.value - 1;
|
|
timeline.zoom(ui.value - 1);
|
|
}
|
|
}).css({
|
|
// top right bottom left
|
|
'margin': '4px 6px 3px'
|
|
});
|
|
containers[currentContainer + 1].append(slider);
|
|
zooms.change(function() {
|
|
slider.slider('value', this.selectedIndex + 1);
|
|
});
|
|
|
|
// zoom out
|
|
containers[currentContainer].append(
|
|
jQuery(icon
|
|
.replace(/%t/, timeline.i18n('timelines.zoom.out'))
|
|
.replace(/%c/, 'icon tl-icon-zoomout')
|
|
).click(function() {
|
|
slider.slider('value', slider.slider('value') - 1);
|
|
}));
|
|
|
|
// zoom in
|
|
containers[currentContainer + 2].append(
|
|
jQuery(icon
|
|
.replace(/%t/, timeline.i18n('timelines.zoom.in'))
|
|
.replace(/%c/, 'icon tl-icon-zoomin')
|
|
).click(function() {
|
|
slider.slider('value', slider.slider('value') + 1);
|
|
}));
|
|
|
|
currentContainer += 4;
|
|
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ Spacer │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
|
|
containers[currentContainer++].css({
|
|
'background-color': '#000000'
|
|
});
|
|
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ Outline │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
|
|
// drop-down
|
|
// TODO this is very similar to the way the zoom dropdown is
|
|
// assembled. Refactor to avoid code duplication!
|
|
form = jQuery('<form></form>');
|
|
var outlines = jQuery('<select name="outlines"></select>');
|
|
for (i = 0; i < Timeline.OUTLINE_LEVELS.length; i++) {
|
|
outlines.append(jQuery(
|
|
'<option>' +
|
|
timeline.i18n(Timeline.OUTLINE_CONFIGURATIONS[Timeline.OUTLINE_LEVELS[i]].name) +
|
|
'</option>'));
|
|
}
|
|
form.append(outlines);
|
|
containers[currentContainer + 1].append(form);
|
|
|
|
outlines.change(function() {
|
|
timeline.expandTo(this.selectedIndex);
|
|
});
|
|
|
|
// perform outline action again (icon mostly a divider from zooms)
|
|
containers[currentContainer].append(
|
|
jQuery(icon
|
|
.replace(/%t/, timeline.i18n('timelines.outline'))
|
|
.replace(/%c/, 'icon tl-icon-outline')
|
|
).click(function() {
|
|
timeline.expandTo(outlines[0].selectedIndex);
|
|
}));
|
|
|
|
currentContainer += 2;
|
|
|
|
this.updateToolbar = function() {
|
|
slider.slider('value', timeline.zoomIndex + 1);
|
|
outlines[0].selectedIndex = timeline.expansionIndex;
|
|
};
|
|
},
|
|
setupChart: function() {
|
|
|
|
// ╭───────────────────────────────────────────────────────╮
|
|
// │ Builds the following dom and adds it to root: │
|
|
// │ │
|
|
// │ <div class="timeline tl-under-construction"> │
|
|
// │ <div class="tl-left"> │
|
|
// │ <div class="tl-left-top tl-decoration"></div> │
|
|
// │ <div class="tl-left-main"></div> │
|
|
// │ </div> │
|
|
// │ <div class="tl-right"> │
|
|
// │ <div class="tl-right-top tl-decoration"></div> │
|
|
// │ <div class="tl-right-main"></div> (optional) │
|
|
// │ </div> │
|
|
// │ <div class="tl-scrollcontainer"> │
|
|
// │ <!--div class="tl-decoration"></div--> │
|
|
// │ <div class="tl-chart"></div> │
|
|
// │ </div> │
|
|
// │ <div class="tl-tooltip fade above in"> │
|
|
// │ <div class="tl-tooltip-inner"></div> │
|
|
// │ <div class="tl-tooltip-arrow"></div> │
|
|
// │ </div> │
|
|
// │ </div> │
|
|
// ╰───────────────────────────────────────────────────────╯
|
|
|
|
var timeline = jQuery('<div class="timeline tl-under-construction"></div>');
|
|
|
|
var tlLeft = jQuery('<div class="tl-left"></div>')
|
|
.append(jQuery('<div class="tl-left-top tl-decoration"></div>'))
|
|
.append(jQuery('<div class="tl-left-main"></div>'));
|
|
|
|
var tlRight = jQuery('<div class="tl-right"></div>')
|
|
.append(jQuery('<div class="tl-right-top tl-decoration"></div>'))
|
|
.append(jQuery('<div class="tl-right-main"></div>'));
|
|
|
|
var paper = jQuery('<div class="tl-chart"></div>');
|
|
|
|
var tlScrollContainer = jQuery('<div class="tl-scrollcontainer"></div>')
|
|
//.append(jQuery('<div class="tl-decoration"></div>'))
|
|
.append(paper);
|
|
|
|
var tlTooltip = jQuery('<div class="tl-tooltip fade above in"></div>')
|
|
.append('<div class="tl-tooltip-inner"></div>')
|
|
.append('<div class="tl-tooltip-arrow"></div>');
|
|
|
|
timeline
|
|
.append(tlLeft)
|
|
.append(tlRight)
|
|
.append(tlScrollContainer)
|
|
.append(tlTooltip);
|
|
|
|
this.getUiRoot().append(timeline);
|
|
|
|
// store the paper element for later use.
|
|
this.paperElement = paper[0];
|
|
},
|
|
|
|
completeUI: function() {
|
|
var timeline = this;
|
|
|
|
// construct tree on left-hand-side.
|
|
this.rebuildTree();
|
|
|
|
// lift the curtain, paper otherwise doesn't show w/ VML.
|
|
jQuery('.timeline').removeClass('tl-under-construction');
|
|
this.paper = new Raphael(this.paperElement, 640, 480);
|
|
|
|
// perform some zooming. if there is a zoom level stored with the
|
|
// report, zoom to it. otherwise, zoom out. this also constructs
|
|
// timeline graph.
|
|
if (this.options.zoom_factor &&
|
|
this.options.zoom_factor.length === 1) {
|
|
|
|
this.zoom(
|
|
this.pnum(this.options.zoom_factor[0])
|
|
);
|
|
|
|
} else {
|
|
this.zoomOut();
|
|
}
|
|
|
|
// perform initial outline expansion.
|
|
if (this.options.initial_outline_expansion &&
|
|
this.options.initial_outline_expansion.length === 1) {
|
|
|
|
this.expandTo(
|
|
this.pnum(this.options.initial_outline_expansion[0])
|
|
);
|
|
}
|
|
|
|
// zooming and initial outline expansion have consequences in the
|
|
// select inputs in the toolbar.
|
|
this.updateToolbar();
|
|
|
|
this.getChart().scroll(function() {
|
|
timeline.adjustTooltip();
|
|
});
|
|
|
|
jQuery(window).scroll(function() {
|
|
timeline.adjustTooltip();
|
|
});
|
|
},
|
|
|
|
getMeasuredHeight: function() {
|
|
return this.getUiRoot().find('.tl-left-main').height();
|
|
},
|
|
getMeasuredScrollbarHeight: function() {
|
|
var p, div, h, hh;
|
|
|
|
// this method is built on the assumption that the width of a
|
|
// vertical scrollbar is equal o the height of a horizontal one. if
|
|
// that symmetry is broken, this method will need to be repaired.
|
|
|
|
if (this.scrollbar_height !== undefined) {
|
|
return this.scrollbar_height;
|
|
}
|
|
|
|
p = jQuery('<p/>').css({
|
|
'width': "100%",
|
|
'height': "200px"
|
|
});
|
|
|
|
div = jQuery('<div/>').css({
|
|
'position': "absolute",
|
|
'top': "0",
|
|
'left': "0",
|
|
'visibility': "hidden",
|
|
'width': "200px",
|
|
'height': "150px",
|
|
'overflow': "hidden"
|
|
});
|
|
|
|
div.append(p);
|
|
jQuery('body').append(div);
|
|
h = p[0].offsetWidth;
|
|
div.css({'overflow': 'scroll'});
|
|
hh = p[0].offsetWidth;
|
|
if (h === hh) {
|
|
hh = div[0].clientWidth;
|
|
}
|
|
div.remove();
|
|
|
|
this.scrollbar_height = (h - hh);
|
|
return this.scrollbar_height;
|
|
},
|
|
|
|
escape: function(string) {
|
|
return jQuery('<div/>').text(string).html();
|
|
},
|
|
psub: function(string, map) {
|
|
return string.replace(/#\{(.+?)\}/g, function(m, p, o, s) { return map[p]; });
|
|
},
|
|
pnum: function(string) {
|
|
return parseInt(string.replace(/[^\d\-]/g, ''), 10);
|
|
},
|
|
/**
|
|
* Filter helper for multi select filters based on IDs.
|
|
*
|
|
* Assumption is that array is an array of strings while object is a object
|
|
* with an id field which contains a number
|
|
*/
|
|
filterOutBasedOnArray: function (array, object) {
|
|
return !Timeline.idInArray(array, object);
|
|
},
|
|
idInArray: function (array, object) {
|
|
// when object is not set, check if the (none) a.k.a. -1 option is selected
|
|
var id = object ? object.id + '' : '-1';
|
|
|
|
if (jQuery.isArray(array) && array.length > 0) {
|
|
return jQuery.inArray(id, array) !== -1;
|
|
}
|
|
else {
|
|
// if there is no array, we just accept.
|
|
return true;
|
|
}
|
|
},
|
|
|
|
rebuildAll: function() {
|
|
var timeline = this;
|
|
var root = timeline.getUiRoot();
|
|
|
|
delete this.table_offset;
|
|
|
|
window.clearTimeout(this.rebuildTimeout);
|
|
this.rebuildTimeout = timeline.defer(function() {
|
|
timeline.rebuildTree();
|
|
|
|
// The minimum width of the whole timeline should be the actual
|
|
// width of the table added to the minimum chart width. That way,
|
|
// the floats never break.
|
|
|
|
if (timeline.options.hide_chart == null) {
|
|
root.find('.timeline').css({
|
|
'min-width': root.find('.tl-left-main').width() +
|
|
Timeline.MIN_CHART_WIDTH
|
|
});
|
|
}
|
|
|
|
if (timeline.options.hide_chart !== 'yes') {
|
|
timeline.rebuildGraph();
|
|
} else {
|
|
var chart = timeline.getUiRoot().find('.tl-chart');
|
|
chart.css({ display: 'none'});
|
|
}
|
|
timeline.adjustScrollingForChangedContent();
|
|
});
|
|
},
|
|
|
|
adjustScrollingForChangedContent: function() {
|
|
var current_height = Math.max(jQuery("body").height(), jQuery("#content").height());
|
|
if(current_height < jQuery(window).scrollTop()) {
|
|
jQuery(window).scrollTop(current_height - jQuery(window).height());
|
|
}
|
|
},
|
|
|
|
rebuildTree: function() {
|
|
var where = this.getUiRoot().find('.tl-left-main');
|
|
var tree = this.getLefthandTree();
|
|
var table = jQuery('<table class="tl-main-table"></table>');
|
|
var body = jQuery('<tbody></tbody>');
|
|
var head = jQuery('<thead></thead>');
|
|
var row, cell, link, span, text;
|
|
var timeline = this;
|
|
var rows = this.getAvailableRows();
|
|
var first = true; // for the first row
|
|
var previousGroup = -1;
|
|
var headerHeight = this.decoHeight();
|
|
|
|
// head
|
|
table.append(head);
|
|
row = jQuery('<tr></tr>');
|
|
|
|
// there is always a name.
|
|
cell = jQuery('<th class="tl-first-column"/>');
|
|
cell.append(timeline.i18n('timelines.filter.column.name'));
|
|
|
|
// only compensate for the chart decorations if we're actualy
|
|
// showing one.
|
|
if (timeline.options.hide_chart == null) {
|
|
cell.css({'height': headerHeight + 'px'});
|
|
}
|
|
row.append(cell);
|
|
|
|
// everything else.
|
|
var header = function(key) {
|
|
var th = jQuery('<th></th>');
|
|
th.append(timeline.i18n('timelines.filter.column.' + key));
|
|
return th;
|
|
};
|
|
jQuery.each(timeline.options.columns, function(i, e) {
|
|
row.append(header(e));
|
|
});
|
|
head.append(row);
|
|
|
|
// body
|
|
table.append(body);
|
|
|
|
row = jQuery('<tr></tr>');
|
|
|
|
tree.iterateWithChildren(function(node, indent) {
|
|
var data = node.getData();
|
|
var group;
|
|
|
|
// create a new cell with the name for the current level.
|
|
row = jQuery('<tr></tr>');
|
|
cell = jQuery(
|
|
'<td class="tl-first-column"></td>'
|
|
);
|
|
row.append(cell);
|
|
|
|
var contentWrapper = jQuery('<span class="tl-word-ellipsis"></span>');
|
|
|
|
cell.addClass('tl-indent-' + indent);
|
|
|
|
// check for start of a new group.
|
|
if (timeline.isGrouping() && data.is(Timeline.Project)) {
|
|
if (indent === 0) {
|
|
group = data.getFirstLevelGrouping();
|
|
if (previousGroup !== group) {
|
|
|
|
body.append(jQuery(
|
|
'<tr><td class="tl-grouping" colspan="' +
|
|
(timeline.options.columns.length + 1) + '"><span class="tl-word-ellipsis">' +
|
|
timeline.escape(data.getFirstLevelGroupingName()) +
|
|
'</span></td></tr>'));
|
|
|
|
previousGroup = group;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (node.hasChildren()) {
|
|
cell.addClass(node.isExpanded() ? 'tl-expanded' : 'tl-collapsed');
|
|
|
|
link = jQuery('<a href="javascript:;"/>');
|
|
link.click({'node': node, 'timeline': timeline}, function(event) {
|
|
event.data.node.toggle();
|
|
event.data.timeline.rebuildAll();
|
|
});
|
|
link.append(node.isExpanded() ? '-' : '+');
|
|
cell.append(link);
|
|
}
|
|
|
|
cell.append(contentWrapper);
|
|
|
|
text = timeline.escape(data.subject || data.name);
|
|
if (data.getUrl instanceof Function) {
|
|
text = jQuery('<a href="' + data.getUrl() + '" class="tl-discreet-link" data-modal/>').append(text).attr("title", text);
|
|
}
|
|
|
|
if (data.is(Timeline.Project)) {
|
|
text.addClass('tl-project');
|
|
}
|
|
|
|
span = jQuery('<span/>').append(text);
|
|
contentWrapper.append(span);
|
|
|
|
// the node will later need to know where on the screen the
|
|
// corresponding table cell is, i.e. for computing the vertical
|
|
// index for planning elements inside the chart.
|
|
node.setDOMElement(cell);
|
|
|
|
var added = data.is(Timeline.PlanningElement) && data.isNewlyAdded();
|
|
var change_detected = added || data.is(Timeline.PlanningElement) && data.hasAlternateDates();
|
|
var deleted = data.is(Timeline.PlanningElement) && data.isDeleted();
|
|
|
|
// everything else
|
|
jQuery.each(timeline.options.columns, function(i, e) {
|
|
var cell = jQuery('<td></td>');
|
|
if (typeof rows[e] === "function") {
|
|
cell.append(rows[e].call(data, data));
|
|
} else {
|
|
cell.append(rows.general.call(data, data, e));
|
|
}
|
|
row.append(cell);
|
|
});
|
|
body.append(row);
|
|
|
|
if (data.is(Timeline.Project)) {
|
|
row.addClass('tl-project-row');
|
|
}
|
|
|
|
if (change_detected) {
|
|
span.prepend(
|
|
|
|
// the empty span is for a rendering bug in chrome. the anchor
|
|
// would not be displayed as inline, unless there is a change
|
|
// in the css after the rendering (nop changes suffice) or
|
|
// there is some prepended content. this span provides for
|
|
// exactly that.
|
|
|
|
jQuery('<span/><a href="javascript://" title="%t" class="%c"/>'
|
|
.replace(/%t/, timeline.i18n('timelines.change'))
|
|
.replace(/%c/, added? 'icon tl-icon-added' : deleted? 'icon tl-icon-deleted' : 'icon tl-icon-changed')
|
|
));
|
|
}
|
|
|
|
// attribute a special class to the first row. this is for
|
|
// additional indentation, however only when we are not in a
|
|
// grouping.
|
|
|
|
if (first) {
|
|
first = false;
|
|
if (!timeline.isGrouping()) {
|
|
row.addClass('tl-first-row');
|
|
}
|
|
}
|
|
});
|
|
|
|
// attribute a special class to the last row
|
|
if (row !== undefined) {
|
|
row.addClass('tl-last-row');
|
|
row.find('td').append(timeline.scrollbarBox());
|
|
}
|
|
|
|
where.empty().append(table);
|
|
|
|
var change = [];
|
|
|
|
var maxWidth = jQuery("#content").width() * 0.25;
|
|
table.find(".tl-word-ellipsis").each(function (i, e) {
|
|
e = jQuery(e);
|
|
|
|
var indent = e.offset().left - e.parent().offset().left;
|
|
|
|
if (e.width() > maxWidth - indent) {
|
|
change.push({e: e, w: maxWidth - indent});
|
|
}
|
|
});
|
|
|
|
var i;
|
|
for (i = 0; i < change.length; i += 1) {
|
|
change[i].e.css("width", change[i].w);
|
|
}
|
|
},
|
|
scrollbarBox: function() {
|
|
var scrollbar_height = this.getMeasuredScrollbarHeight();
|
|
return jQuery('<div class="tl-invisible"/>').css({
|
|
'height': scrollbar_height,
|
|
'width': scrollbar_height
|
|
});
|
|
},
|
|
decoHeight: function() {
|
|
var config = Timeline.ZOOM_SCALES[this.zoomIndex];
|
|
var lanes = Timeline.ZOOM_CONFIGURATIONS[config].config.length;
|
|
return 12 * lanes; // -1 is for coordinates starting at 0.
|
|
},
|
|
getPaper: function() {
|
|
return this.paper;
|
|
},
|
|
rebuildGraph: function() {
|
|
var timeline = this;
|
|
var tree = timeline.getLefthandTree();
|
|
var chart = timeline.getUiRoot().find('.tl-chart');
|
|
|
|
chart.css({'display': 'none'});
|
|
|
|
var width = timeline.getWidth();
|
|
var height = timeline.getHeight();
|
|
|
|
// clear and resize
|
|
timeline.paper.clear();
|
|
timeline.paper.setSize(width, height);
|
|
|
|
timeline.defer(function() {
|
|
// rebuild content
|
|
timeline.rebuildBackground(tree, width, height);
|
|
chart.css({'display': 'block'});
|
|
timeline.rebuildForeground(tree);
|
|
});
|
|
},
|
|
finishGraph: function() {
|
|
var root = this.getUiRoot();
|
|
var info = jQuery('<span class="tl-hidden-info tl-finished"></span>');
|
|
|
|
// this will be called asynchronously and finishes up the graph
|
|
// building process.
|
|
this.setupEventHandlers();
|
|
|
|
root.append(info);
|
|
},
|
|
rebuildBackground: function(tree, width, height) {
|
|
var beginning = this.getBeginning();
|
|
var scale = this.getScale();
|
|
var end = this.getEnd();
|
|
var deco = this.decoHeight();
|
|
|
|
deco--;
|
|
|
|
this.paper.rect(0, deco, width, height).attr({
|
|
'fill': '#fff',
|
|
'stroke': '#fff', //
|
|
'stroke-opacity': 0,
|
|
'stroke-width': 0
|
|
});
|
|
|
|
// horizontal bar.
|
|
this.paper.path(
|
|
this.psub('M0 #{y}H#{w}', {
|
|
y: deco + 0.5, // the vertical line otherwise overlaps.
|
|
w: width
|
|
})
|
|
);
|
|
|
|
// *** beginning decorations ***
|
|
|
|
var lastDivider, caption, captionElement, bbox, dividerPath;
|
|
var padding = 2;
|
|
|
|
lastDivider = 0;
|
|
|
|
var swimlanes = this.getSwimlaneConfiguration();
|
|
var styles = this.getSwimlaneStyles();
|
|
var config = Timeline.ZOOM_SCALES[this.zoomIndex];
|
|
|
|
var key, i, left, first, timeline = this;
|
|
var m, x, y;
|
|
|
|
var currentStyle = 0, lastOverrideGroup;
|
|
|
|
for (i = Timeline.ZOOM_CONFIGURATIONS[config].config.length - 1; i >= 0; i--) {
|
|
key = Timeline.ZOOM_CONFIGURATIONS[config].config[i];
|
|
if (swimlanes.hasOwnProperty(key)) {
|
|
|
|
// if the current swimlane has more overrides, we assume a
|
|
// change in quality of the seperation and switch styles to a
|
|
// more solid one. lastOverrideGroup is set to the length of the
|
|
// override-array of the current swimlane.
|
|
|
|
if (swimlanes[key].overrides.length > lastOverrideGroup) {
|
|
currentStyle++;
|
|
}
|
|
lastOverrideGroup = swimlanes[key].overrides.length;
|
|
|
|
lastDivider = 0;
|
|
dividerPath = '';
|
|
first = true;
|
|
while (lastDivider < width || swimlanes[key].delimiter.compareTo(end) <= 0) {
|
|
|
|
caption = swimlanes[key].caption() || '';
|
|
if (caption.length === undefined) {
|
|
caption = caption.toString(); // caption needs to be a string.
|
|
}
|
|
swimlanes[key].next();
|
|
left = timeline.getDaysBetween(beginning, swimlanes[key].delimiter) * scale.day;
|
|
bbox = {height: 8};
|
|
|
|
captionElement = timeline.paper.text(0, 0, caption);
|
|
captionElement.attr({
|
|
'font-size': 10
|
|
});
|
|
|
|
x = (lastDivider + (left - lastDivider) / 2) - (jQuery(captionElement.node).width() / 16);
|
|
y = (deco - padding);
|
|
|
|
captionElement
|
|
.translate(x, y)
|
|
.attr({
|
|
'fill': styles[currentStyle].textColor || timeline.DEFAULT_COLOR,
|
|
'stroke': 'none'
|
|
});
|
|
|
|
lastDivider = left;
|
|
dividerPath += timeline.psub('M#{x} #{y}v#{b} M#{x} #{d}v#{h}', {
|
|
x: left,
|
|
y: deco - bbox.height - 2 * padding,
|
|
h: height,
|
|
b: bbox.height + 2 * padding,
|
|
d: timeline.decoHeight() + 1
|
|
});
|
|
}
|
|
|
|
timeline.paper.path(dividerPath).attr({
|
|
'stroke': styles[currentStyle].laneColor || timeline.DEFAULT_LANE_COLOR,
|
|
'stroke-width': styles[currentStyle].laneWidth || timeline.DEFAULT_LANE_WIDTH,
|
|
'stroke-linecap': 'butt' // the vertical line otherwise overlaps.
|
|
});
|
|
|
|
// altered deco ceiling for next decorations.
|
|
deco -= bbox.height + 2 * padding;
|
|
|
|
// horizontal bar.
|
|
timeline.paper.path(
|
|
timeline.psub('M0 #{y}H#{w}', {
|
|
y: deco + 0.5, // the vertical line otherwise overlaps.
|
|
w: width
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
this.frameLine();
|
|
this.nowLine();
|
|
},
|
|
getRelativeVerticalOffset: function(offset) {
|
|
var result;
|
|
if (this.table_offset === undefined) {
|
|
this.table_offset = this.getUiRoot().find('.tl-left-main table').position().top;
|
|
}
|
|
if (offset !== undefined) {
|
|
result = offset.position().top - this.table_offset;
|
|
return result;
|
|
}
|
|
return this.table_offset;
|
|
},
|
|
getRelativeVerticalBottomOffset: function(offset) {
|
|
var result;
|
|
result = this.getRelativeVerticalOffset(offset);
|
|
if (offset.find("div").length === 1) {
|
|
result -= jQuery(offset.find("div")[0]).height();
|
|
}
|
|
if (offset !== undefined)
|
|
result += offset.outerHeight();
|
|
return result;
|
|
},
|
|
rebuildForeground: function(tree) {
|
|
var timeline = this;
|
|
var previousGrouping = -1;
|
|
var grouping;
|
|
var width = timeline.getWidth();
|
|
var previousNode;
|
|
var render_bucket = [];
|
|
var pre_render_bucket = [];
|
|
var post_render_bucket = [];
|
|
var text_render_bucket = [];
|
|
|
|
// iterate over all planning elements and find vertical ones to draw.
|
|
jQuery.each(timeline.verticalPlanningElementIds(), function (i, e) {
|
|
var pl = timeline.getPlanningElement(e);
|
|
|
|
// the planning element should have been loaded already. however,
|
|
// it might not have been, or it might not even exist. in that
|
|
// case, we simply ignore it.
|
|
if (pl === undefined) {
|
|
return;
|
|
}
|
|
|
|
var pet = pl.getPlanningElementType();
|
|
|
|
var node = Object.create(Timeline.TreeNode);
|
|
node.setData(pl);
|
|
|
|
if (pl.vertical) {
|
|
if (pet && pet.is_milestone) {
|
|
post_render_bucket.push(function () {
|
|
pl.renderVertical(node);
|
|
});
|
|
} else {
|
|
pre_render_bucket.push(function () {
|
|
pl.renderVertical(node);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
tree.iterateWithChildren(function(node, indent, index) {
|
|
var currentElement = node.getDOMElement();
|
|
var currentOffset = timeline.getRelativeVerticalOffset(currentElement);
|
|
var previousElement, previousEnd, groupHeight;
|
|
var groupingChanged = false;
|
|
var pl = node.getData();
|
|
|
|
// if the grouping changed, put a grey box here.
|
|
|
|
if (timeline.isGrouping() && indent === 0 && pl.is(Timeline.Project)) {
|
|
grouping = pl.getFirstLevelGrouping();
|
|
if (previousGrouping !== grouping) {
|
|
|
|
groupingChanged = true;
|
|
|
|
// previousEnd is the vertical position at which a previous
|
|
// element ended. It is calculated by adding the previous
|
|
// element's vertical offset to it's height.
|
|
|
|
if (previousNode !== undefined) {
|
|
previousElement = previousNode.getDOMElement();
|
|
previousEnd = timeline.getRelativeVerticalOffset(previousElement) +
|
|
previousElement.outerHeight();
|
|
} else {
|
|
|
|
previousEnd = timeline.decoHeight();
|
|
}
|
|
|
|
// groupHeight is the height gap between the vertical position
|
|
// at which the current element begins (currentOffset) and the
|
|
// position the previous element ended (previousEnd).
|
|
|
|
groupHeight = currentOffset - previousEnd;
|
|
|
|
// draw grey box.
|
|
|
|
timeline.paper.rect(
|
|
Timeline.GROUP_BAR_INDENT,
|
|
previousEnd,
|
|
width - 2 * Timeline.GROUP_BAR_INDENT,
|
|
groupHeight
|
|
).attr({
|
|
'fill': '#bbb',
|
|
'fill-opacity': 0.5,
|
|
'stroke-width': 1,
|
|
'stroke-opacity': 1,
|
|
'stroke': Timeline.DEFAULT_STROKE_COLOR
|
|
});
|
|
|
|
previousGrouping = grouping;
|
|
}
|
|
|
|
}
|
|
|
|
// if there is a new project, draw a black line.
|
|
|
|
if (pl.is(Timeline.Project)) {
|
|
|
|
if (!groupingChanged) {
|
|
|
|
// draw lines between projects
|
|
timeline.paper.path(
|
|
timeline.psub('M0 #{y}h#{w}', {
|
|
y: currentOffset,
|
|
w: width
|
|
})
|
|
).attr({
|
|
'stroke-width': 1,
|
|
'stroke': Timeline.DEFAULT_STROKE_COLOR
|
|
});
|
|
|
|
}
|
|
|
|
} else if (pl.is(Timeline.PlanningElement)) {
|
|
|
|
}
|
|
|
|
previousNode = node;
|
|
|
|
if (pl.is(Timeline.PlanningElement)) {
|
|
text_render_bucket.push(function () {
|
|
pl.renderForeground(node);
|
|
});
|
|
}
|
|
|
|
render_bucket.push(function() {
|
|
pl.render(node);
|
|
});
|
|
});
|
|
|
|
var render_next_bucket = function() {
|
|
if (jQuery.each(pre_render_bucket.splice(0, Timeline.RENDER_BUCKET_SIZE), function(i, e) {
|
|
e.call();
|
|
}).length !== 0) {
|
|
timeline.defer(render_next_bucket);
|
|
} else if (jQuery.each(render_bucket.splice(0, Timeline.RENDER_BUCKET_SIZE), function(i, e) {
|
|
e.call();
|
|
}).length !== 0) {
|
|
timeline.defer(render_next_bucket);
|
|
} else if (jQuery.each(post_render_bucket.splice(0, Timeline.RENDER_BUCKET_SIZE), function(i, e) {
|
|
e.call();
|
|
}).length !== 0) {
|
|
timeline.defer(render_next_bucket);
|
|
} else if (jQuery.each(text_render_bucket.splice(0, Timeline.RENDER_BUCKET_SIZE), function(i, e) {
|
|
e.call();
|
|
}).length !== 0) {
|
|
timeline.defer(render_next_bucket);
|
|
} else {
|
|
timeline.finishGraph();
|
|
}
|
|
};
|
|
|
|
render_next_bucket();
|
|
},
|
|
|
|
frameLine: function () {
|
|
var timeline = this;
|
|
var scale = timeline.getScale();
|
|
var beginning = timeline.getBeginning();
|
|
var decoHeight = timeline.decoHeight();
|
|
var linePosition;
|
|
|
|
this.calculateTimeFilter();
|
|
|
|
if (this.frameStart) {
|
|
linePosition = (timeline.getDaysBetween(beginning, this.frameStart)) * scale.day;
|
|
|
|
timeline.paper.path(
|
|
timeline.psub("M#{position} #{top}L#{position} #{height}", {
|
|
'position': linePosition,
|
|
'top': decoHeight,
|
|
'height': this.getHeight()
|
|
})
|
|
).attr({
|
|
'stroke': 'blue',
|
|
'stroke-dasharray': '- '
|
|
});
|
|
}
|
|
|
|
if (this.frameEnd) {
|
|
linePosition = ((timeline.getDaysBetween(beginning, this.frameEnd) + 1) * scale.day);
|
|
|
|
timeline.paper.path(
|
|
timeline.psub("M#{position} #{top}L#{position} #{height}", {
|
|
'position': linePosition,
|
|
'top': decoHeight,
|
|
'height': this.getHeight()
|
|
})
|
|
).attr({
|
|
'stroke': 'blue',
|
|
'stroke-dasharray': '- '
|
|
});
|
|
}
|
|
},
|
|
|
|
nowLine: function () {
|
|
var timeline = this;
|
|
var scale = timeline.getScale();
|
|
var beginning = timeline.getBeginning();
|
|
var ms_in_a_day = 86400000; // 24 * 60 * 60 * 1000
|
|
|
|
var todayPosition = (timeline.getDaysBetween(beginning, Date.today())) * scale.day;
|
|
todayPosition += (Date.now() - Date.today()) / ms_in_a_day * scale.day;
|
|
|
|
var decoHeight = timeline.decoHeight();
|
|
|
|
var currentTimeElement = timeline.paper.path(
|
|
timeline.psub("M#{today} #{top}L#{today} #{height}", {
|
|
'today': todayPosition,
|
|
'top': decoHeight,
|
|
'height': this.getHeight()
|
|
})
|
|
).attr({
|
|
'stroke': 'red',
|
|
'stroke-dasharray': '- '
|
|
});
|
|
|
|
var setDateTime = 5 * 60 * 1000;
|
|
|
|
var setDate = function () {
|
|
var newTodayPosition = (timeline.getDaysBetween(beginning, Date.today())) * scale.day;
|
|
newTodayPosition += (Date.now() - Date.today()) / ms_in_a_day * scale.day;
|
|
|
|
if (Math.abs(newTodayPosition - todayPosition) > 0.1) {
|
|
currentTimeElement.transform(
|
|
timeline.psub("t#{trans},0", {
|
|
'trans': newTodayPosition - todayPosition
|
|
})
|
|
);
|
|
}
|
|
|
|
if (scale.day === timeline.getScale().day) {
|
|
window.setTimeout(setDate, setDateTime);
|
|
}
|
|
};
|
|
|
|
window.setTimeout(setDate, setDateTime);
|
|
},
|
|
|
|
adjustTooltip: function(renderable, element) {
|
|
renderable = renderable || this.currentNode;
|
|
element = element || this.currentElement;
|
|
if (!renderable) {
|
|
return;
|
|
}
|
|
|
|
var chart = this.getChart();
|
|
var offset = chart.position();
|
|
var tooltip = this.getTooltip();
|
|
var bbox = element.getBBox();
|
|
var content = tooltip.find('.tl-tooltip-inner');
|
|
var arrow = tooltip.find('.tl-tooltip-arrow');
|
|
var arrowOffset = this.pnum(arrow.css('left'));
|
|
var padding = (tooltip.outerWidth() - tooltip.width()) / 2;
|
|
var duration = tooltip.css('display') !== 'none' ? 0 : 0;
|
|
var info = "";
|
|
var r = renderable.getResponsible();
|
|
|
|
// construct tooltip content information.
|
|
|
|
info += "<b>";
|
|
info += this.escape(renderable.subject);
|
|
info += "</b>";
|
|
if (renderable.is(Timeline.PlanningElement)) {
|
|
info += " (#" + renderable.id + ")";
|
|
}
|
|
info += "<br/>";
|
|
info += this.escape(renderable.start_date);
|
|
if (renderable.due_date !== renderable.start_date) {
|
|
// only have a second date if it is different.
|
|
info += " – " + this.escape(renderable.due_date);
|
|
}
|
|
info += "<br/>";
|
|
if (r && r.name) { // if there is a responsible, show the name.
|
|
info += r.name;
|
|
}
|
|
|
|
content.html(info);
|
|
|
|
// calculate position of tooltip
|
|
var left = offset.left;
|
|
left -= chart.scrollLeft();
|
|
left += bbox.x;
|
|
if (renderable.start_date && renderable.due_date) {
|
|
left += bbox.width / 2;
|
|
} else if (renderable.due_date) {
|
|
left += bbox.width - Timeline.HOVER_THRESHOLD;
|
|
} else {
|
|
left += Timeline.HOVER_THRESHOLD;
|
|
}
|
|
left -= arrowOffset;
|
|
|
|
var min_left = this.getUiRoot().find('.tl-left').position().left;
|
|
min_left += this.getUiRoot().find('.tl-left').width();
|
|
min_left -= arrowOffset;
|
|
|
|
var max_left = this.getUiRoot().find('.tl-right').position().left;
|
|
max_left -= tooltip.outerWidth();
|
|
max_left -= padding;
|
|
max_left += arrowOffset;
|
|
|
|
left = Math.max(min_left, Math.min(max_left, left));
|
|
|
|
var margin = offset.left;
|
|
margin -= chart.scrollLeft();
|
|
margin += (bbox.x);
|
|
if (renderable.start_date && renderable.due_date) {
|
|
margin += bbox.width / 2;
|
|
} else if (renderable.due_date) {
|
|
margin += bbox.width - Timeline.HOVER_THRESHOLD;
|
|
} else {
|
|
margin += Timeline.HOVER_THRESHOLD;
|
|
}
|
|
margin -= left;
|
|
margin -= arrowOffset;
|
|
|
|
var max_margin = tooltip.width();
|
|
max_margin -= padding;
|
|
max_margin -= arrowOffset;
|
|
|
|
margin = Math.min(max_margin, Math.max(margin, 0));
|
|
margin -= padding;
|
|
|
|
var top = offset.top;
|
|
top += bbox.y;
|
|
top -= tooltip.outerHeight();
|
|
top--; // random offset.
|
|
|
|
if (top < jQuery(window).scrollTop() - 80) {
|
|
top = jQuery(window).scrollTop() - 80;
|
|
}
|
|
|
|
this.currentNode = renderable;
|
|
this.currentElement = element;
|
|
tooltip.clearQueue();
|
|
arrow.clearQueue();
|
|
|
|
tooltip.animate({left: left, top: top}, duration, 'swing');
|
|
arrow.animate({'margin-left': margin}, duration, 'swing');
|
|
},
|
|
|
|
setupEventHandlers: function() {
|
|
var tree = this.getLefthandTree();
|
|
this.setupResizeHandlers();
|
|
//this.setupHoverHandlers(tree);
|
|
},
|
|
setupResizeHandlers: function() {
|
|
var timeline = this, timeout;
|
|
var handler_name = 'resize.' + timeline.getEventHandlerSuffix();
|
|
|
|
jQuery(window).unbind(handler_name);
|
|
jQuery(window).bind(handler_name, function() {
|
|
|
|
window.clearTimeout(timeout);
|
|
timeout = window.setTimeout(function() {
|
|
timeline.triggerResize();
|
|
}, 1087); // http://dilbert.com/strips/comic/2008-05-08/
|
|
});
|
|
},
|
|
triggerResize: function() {
|
|
var root = this.getUiRoot();
|
|
var width = root.width() - root.find('.tl-left-main').width() -
|
|
Timeline.BORDER_WIDTH_CORRECTION;
|
|
this.adjustWidth(width);
|
|
},
|
|
addHoverHandler: function(node, e) {
|
|
var tooltip = this.getTooltip();
|
|
var timeline = this;
|
|
|
|
e.unhover();
|
|
e.click(function(e) {
|
|
if (Timeline.USE_MODALS) {
|
|
var payload = node.getData();
|
|
timeline.modalHelper.createModal(payload.getUrl());
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
e.attr({'cursor': 'pointer'});
|
|
e.hover(
|
|
function() {
|
|
timeline.adjustTooltip(node.getData(), e);
|
|
tooltip.show();
|
|
},
|
|
function() {
|
|
delete tooltip.currentNode;
|
|
delete tooltip.currentElement;
|
|
tooltip.hide();
|
|
},
|
|
node, node
|
|
);
|
|
},
|
|
|
|
addPlanningElement: function() {
|
|
var projects = this.projects;
|
|
var project, projectID, possibleProjects = [];
|
|
|
|
for (project in projects) {
|
|
if (projects.hasOwnProperty(project)) {
|
|
possibleProjects.push(projects[project]);
|
|
}
|
|
}
|
|
|
|
projectID = possibleProjects[0].identifier;
|
|
|
|
if (typeof projectID !== "undefined") {
|
|
this.modalHelper.create(projectID);
|
|
}
|
|
}
|
|
};
|
|
|
|
// This polyfill covers the main use case which is creating a new object
|
|
// for which the prototype has been chosen but doesn't take the second
|
|
// argument into account:
|
|
// https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Object/create
|
|
if (!Object.create) {
|
|
Object.create = function(o) {
|
|
if (arguments.length > 1) {
|
|
throw new Error(
|
|
'Object.create implementation only accepts the first parameter.'
|
|
);
|
|
}
|
|
function F() {}
|
|
F.prototype = o;
|
|
return new F();
|
|
};
|
|
}
|
|
|