//-- 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. //++ // ╭───────────────────────────────────────────────────────────────╮ // │ _____ _ _ _ │ // │ |_ _(_)_ __ ___ ___| (_)_ __ ___ ___ │ // │ | | | | '_ ` _ \ / _ \ | | '_ \ / _ \/ __| │ // │ | | | | | | | | | __/ | | | | | __/\__ \ │ // │ |_| |_|_| |_| |_|\___|_|_|_| |_|\___||___/ │ // ├───────────────────────────────────────────────────────────────┤ // │ Javascript library that fetches and plots timelines for the │ // │ OpenProject timelines module. │ // ╰───────────────────────────────────────────────────────────────╯ // 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, Timeline:true*/ if (typeof Timeline === "undefined") { Timeline = {}; } // ╭───────────────────────────────────────────────────────────────────╮ // │ Timeline.PlanningElement │ // ╰───────────────────────────────────────────────────────────────────╯ Timeline.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 && label_space !== undefined) { 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 && label_space !== undefined) { 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 if (label_space.w > Timeline.PE_TEXT_AGGREGATED_LABEL_WIDTH_THRESHOLD) { textColor = timeline.getLimunanceFor(color) > Timeline.PE_LUMINANCE_THRESHOLD ? Timeline.PE_DARK_TEXT_COLOR : Timeline.PE_LIGHT_TEXT_COLOR; 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; } };