"Fossies" - the Fresh Open Source Software Archive

Member "redmine-4.1.1/lib/redmine/helpers/gantt.rb" (6 Apr 2020, 41682 Bytes) of package /linux/www/redmine-4.1.1.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Ruby source code syntax highlighting (style: standard) with prefixed line numbers and code folding option. Alternatively you can here view or download the uninterpreted source code file. See also the latest Fossies "Diffs" side-by-side code changes report for "gantt.rb": 4.1.0_vs_4.1.1.

    1 # frozen_string_literal: true
    2 
    3 # Redmine - project management software
    4 # Copyright (C) 2006-2019  Jean-Philippe Lang
    5 #
    6 # This program is free software; you can redistribute it and/or
    7 # modify it under the terms of the GNU General Public License
    8 # as published by the Free Software Foundation; either version 2
    9 # of the License, or (at your option) any later version.
   10 #
   11 # This program is distributed in the hope that it will be useful,
   12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
   13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   14 # GNU General Public License for more details.
   15 #
   16 # You should have received a copy of the GNU General Public License
   17 # along with this program; if not, write to the Free Software
   18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
   19 
   20 module Redmine
   21   module Helpers
   22     # Simple class to handle gantt chart data
   23     class Gantt
   24       class MaxLinesLimitReached < StandardError
   25       end
   26 
   27       include ERB::Util
   28       include Redmine::I18n
   29       include Redmine::Utils::DateCalculation
   30 
   31       # Relation types that are rendered
   32       DRAW_TYPES = {
   33         IssueRelation::TYPE_BLOCKS   => { :landscape_margin => 16, :color => '#F34F4F' },
   34         IssueRelation::TYPE_PRECEDES => { :landscape_margin => 20, :color => '#628FEA' }
   35       }.freeze
   36 
   37       UNAVAILABLE_COLUMNS = [:tracker, :id, :subject]
   38 
   39       # Some utility methods for the PDF export
   40       # @private
   41       class PDF
   42         MaxCharactorsForSubject = 45
   43         TotalWidth = 280
   44         LeftPaneWidth = 100
   45 
   46         def self.right_pane_width
   47           TotalWidth - LeftPaneWidth
   48         end
   49       end
   50 
   51       attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months, :truncated, :max_rows
   52       attr_accessor :query
   53       attr_accessor :project
   54       attr_accessor :view
   55 
   56       def initialize(options={})
   57         options = options.dup
   58         if options[:year] && options[:year].to_i >0
   59           @year_from = options[:year].to_i
   60           if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
   61             @month_from = options[:month].to_i
   62           else
   63             @month_from = 1
   64           end
   65         else
   66           @month_from ||= User.current.today.month
   67           @year_from ||= User.current.today.year
   68         end
   69         zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
   70         @zoom = (zoom > 0 && zoom < 5) ? zoom : 2
   71         months = (options[:months] || User.current.pref[:gantt_months]).to_i
   72         @months = (months > 0 && months < Setting.gantt_months_limit.to_i + 1) ? months : 6
   73         # Save gantt parameters as user preference (zoom and months count)
   74         if User.current.logged? &&
   75              (@zoom   != User.current.pref[:gantt_zoom] ||
   76               @months != User.current.pref[:gantt_months])
   77           User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
   78           User.current.preference.save
   79         end
   80         @date_from = Date.civil(@year_from, @month_from, 1)
   81         @date_to = (@date_from >> @months) - 1
   82         @subjects = +''
   83         @lines = +''
   84         @columns ||= {}
   85         @number_of_rows = nil
   86         @truncated = false
   87         if options.has_key?(:max_rows)
   88           @max_rows = options[:max_rows]
   89         else
   90           @max_rows = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i
   91         end
   92       end
   93 
   94       def common_params
   95         { :controller => 'gantts', :action => 'show', :project_id => @project }
   96       end
   97 
   98       def params
   99         common_params.merge({:zoom => zoom, :year => year_from,
  100                              :month => month_from, :months => months})
  101       end
  102 
  103       def params_previous
  104         common_params.merge({:year => (date_from << months).year,
  105                              :month => (date_from << months).month,
  106                              :zoom => zoom, :months => months})
  107       end
  108 
  109       def params_next
  110         common_params.merge({:year => (date_from >> months).year,
  111                              :month => (date_from >> months).month,
  112                              :zoom => zoom, :months => months})
  113       end
  114 
  115       # Returns the number of rows that will be rendered on the Gantt chart
  116       def number_of_rows
  117         return @number_of_rows if @number_of_rows
  118         rows = projects.inject(0) {|total, p| total += number_of_rows_on_project(p)}
  119         rows > @max_rows ? @max_rows : rows
  120       end
  121 
  122       # Returns the number of rows that will be used to list a project on
  123       # the Gantt chart.  This will recurse for each subproject.
  124       def number_of_rows_on_project(project)
  125         return 0 unless projects.include?(project)
  126         count = 1
  127         count += project_issues(project).size
  128         count += project_versions(project).size
  129         count
  130       end
  131 
  132       # Renders the subjects of the Gantt chart, the left side.
  133       def subjects(options={})
  134         render(options.merge(:only => :subjects)) unless @subjects_rendered
  135         @subjects
  136       end
  137 
  138       # Renders the lines of the Gantt chart, the right side
  139       def lines(options={})
  140         render(options.merge(:only => :lines)) unless @lines_rendered
  141         @lines
  142       end
  143 
  144       # Renders the selected column of the Gantt chart, the right side of subjects.
  145       def selected_column_content(options={})
  146         render(options.merge(:only => :selected_columns)) unless @columns.has_key?(options[:column].name)
  147         @columns[options[:column].name]
  148       end
  149 
  150       # Returns issues that will be rendered
  151       def issues
  152         @issues ||= @query.issues(
  153           :include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
  154           :order => ["#{Project.table_name}.lft ASC", "#{Issue.table_name}.id ASC"],
  155           :limit => @max_rows
  156         )
  157       end
  158 
  159       # Returns a hash of the relations between the issues that are present on the gantt
  160       # and that should be displayed, grouped by issue ids.
  161       def relations
  162         return @relations if @relations
  163         if issues.any?
  164           issue_ids = issues.map(&:id)
  165           @relations = IssueRelation.
  166             where(:issue_from_id => issue_ids, :issue_to_id => issue_ids, :relation_type => DRAW_TYPES.keys).
  167             group_by(&:issue_from_id)
  168         else
  169           @relations = {}
  170         end
  171       end
  172 
  173       # Return all the project nodes that will be displayed
  174       def projects
  175         return @projects if @projects
  176         ids = issues.collect(&:project).uniq.collect(&:id)
  177         if ids.any?
  178           # All issues projects and their visible ancestors
  179           @projects = Project.visible.
  180             joins("LEFT JOIN #{Project.table_name} child ON #{Project.table_name}.lft <= child.lft AND #{Project.table_name}.rgt >= child.rgt").
  181             where("child.id IN (?)", ids).
  182             order("#{Project.table_name}.lft ASC").
  183             distinct.
  184             to_a
  185         else
  186           @projects = []
  187         end
  188       end
  189 
  190       # Returns the issues that belong to +project+
  191       def project_issues(project)
  192         @issues_by_project ||= issues.group_by(&:project)
  193         @issues_by_project[project] || []
  194       end
  195 
  196       # Returns the distinct versions of the issues that belong to +project+
  197       def project_versions(project)
  198         project_issues(project).collect(&:fixed_version).compact.uniq
  199       end
  200 
  201       # Returns the issues that belong to +project+ and are assigned to +version+
  202       def version_issues(project, version)
  203         project_issues(project).select {|issue| issue.fixed_version == version}
  204       end
  205 
  206       def render(options={})
  207         options = {:top => 0, :top_increment => 20,
  208                    :indent_increment => 20, :render => :subject,
  209                    :format => :html}.merge(options)
  210         indent = options[:indent] || 4
  211         @subjects = +'' unless options[:only] == :lines || options[:only] == :selected_columns
  212         @lines = +'' unless options[:only] == :subjects || options[:only] == :selected_columns
  213         @columns[options[:column].name] = +'' if options[:only] == :selected_columns && @columns.has_key?(options[:column]) == false
  214         @number_of_rows = 0
  215         begin
  216           Project.project_tree(projects) do |project, level|
  217             options[:indent] = indent + level * options[:indent_increment]
  218             render_project(project, options)
  219           end
  220         rescue MaxLinesLimitReached
  221           @truncated = true
  222         end
  223         @subjects_rendered = true unless options[:only] == :lines || options[:only] == :selected_columns
  224         @lines_rendered = true unless options[:only] == :subjects || options[:only] == :selected_columns
  225         render_end(options)
  226       end
  227 
  228       def render_project(project, options={})
  229         render_object_row(project, options)
  230         increment_indent(options) do
  231           # render issue that are not assigned to a version
  232           issues = project_issues(project).select {|i| i.fixed_version.nil?}
  233           render_issues(issues, options)
  234           # then render project versions and their issues
  235           versions = project_versions(project)
  236           self.class.sort_versions!(versions)
  237           versions.each do |version|
  238             render_version(project, version, options)
  239           end
  240         end
  241       end
  242 
  243       def render_version(project, version, options={})
  244         render_object_row(version, options)
  245         increment_indent(options) do
  246           issues = version_issues(project, version)
  247           render_issues(issues, options)
  248         end
  249       end
  250 
  251       def render_issues(issues, options={})
  252         self.class.sort_issues!(issues)
  253         ancestors = []
  254         issues.each do |issue|
  255           while ancestors.any? && !issue.is_descendant_of?(ancestors.last)
  256             ancestors.pop
  257             decrement_indent(options)
  258           end
  259           render_object_row(issue, options)
  260           unless issue.leaf?
  261             ancestors << issue
  262             increment_indent(options)
  263           end
  264         end
  265         decrement_indent(options, ancestors.size)
  266       end
  267 
  268       def render_object_row(object, options)
  269         class_name = object.class.name.downcase
  270         send("subject_for_#{class_name}", object, options) unless options[:only] == :lines || options[:only] == :selected_columns
  271         send("line_for_#{class_name}", object, options) unless options[:only] == :subjects || options[:only] == :selected_columns
  272         column_content_for_issue(object, options) if options[:only] == :selected_columns && options[:column].present? && object.is_a?(Issue)
  273         options[:top] += options[:top_increment]
  274         @number_of_rows += 1
  275         if @max_rows && @number_of_rows >= @max_rows
  276           raise MaxLinesLimitReached
  277         end
  278       end
  279 
  280       def render_end(options={})
  281         case options[:format]
  282         when :pdf
  283           options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
  284         end
  285       end
  286 
  287       def increment_indent(options, factor=1)
  288         options[:indent] += options[:indent_increment] * factor
  289         if block_given?
  290           yield
  291           decrement_indent(options, factor)
  292         end
  293       end
  294 
  295       def decrement_indent(options, factor=1)
  296         increment_indent(options, -factor)
  297       end
  298 
  299       def subject_for_project(project, options)
  300         subject(project.name, options, project)
  301       end
  302 
  303       def line_for_project(project, options)
  304         # Skip projects that don't have a start_date or due date
  305         if project.is_a?(Project) && project.start_date && project.due_date
  306           label = project.name
  307           line(project.start_date, project.due_date, nil, true, label, options, project)
  308         end
  309       end
  310 
  311       def subject_for_version(version, options)
  312         subject(version.to_s_with_project, options, version)
  313       end
  314 
  315       def line_for_version(version, options)
  316         # Skip versions that don't have a start_date
  317         if version.is_a?(Version) && version.due_date && version.start_date
  318           label = "#{h(version)} #{h(version.visible_fixed_issues.completed_percent.to_f.round)}%"
  319           label = h("#{version.project} -") + label unless @project && @project == version.project
  320           line(version.start_date, version.due_date,  version.visible_fixed_issues.completed_percent, true, label, options, version)
  321         end
  322       end
  323 
  324       def subject_for_issue(issue, options)
  325         subject(issue.subject, options, issue)
  326       end
  327 
  328       def line_for_issue(issue, options)
  329         # Skip issues that don't have a due_before (due_date or version's due_date)
  330         if issue.is_a?(Issue) && issue.due_before
  331           label = issue.status.name.dup
  332           unless issue.disabled_core_fields.include?('done_ratio')
  333             label << " #{issue.done_ratio}%"
  334           end
  335           markers = !issue.leaf?
  336           line(issue.start_date, issue.due_before, issue.done_ratio, markers, label, options, issue)
  337         end
  338       end
  339 
  340       def column_content_for_issue(issue, options)
  341         if options[:format] == :html
  342           data_options = {}
  343           data_options[:collapse_expand] = "issue-#{issue.id}"
  344           style = "position: absolute;top: #{options[:top]}px; font-size: 0.8em;"
  345           content = view.content_tag(:div, view.column_content(options[:column], issue), :style => style, :class => "issue_#{options[:column].name}", :id => "#{options[:column].name}_issue_#{issue.id}", :data => data_options)
  346           @columns[options[:column].name] << content if @columns.has_key?(options[:column].name)
  347           content
  348         end
  349       end
  350 
  351       def subject(label, options, object=nil)
  352         send "#{options[:format]}_subject", options, label, object
  353       end
  354 
  355       def line(start_date, end_date, done_ratio, markers, label, options, object=nil)
  356         options[:zoom] ||= 1
  357         options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
  358         coords = coordinates(start_date, end_date, done_ratio, options[:zoom])
  359         send "#{options[:format]}_task", options, coords, markers, label, object
  360       end
  361 
  362       # Generates a gantt image
  363       # Only defined if MiniMagick is avalaible
  364       def to_image(format='PNG')
  365         date_to = (@date_from >> @months) - 1
  366         show_weeks = @zoom > 1
  367         show_days = @zoom > 2
  368         subject_width = 400
  369         header_height = 18
  370         # width of one day in pixels
  371         zoom = @zoom * 2
  372         g_width = (@date_to - @date_from + 1) * zoom
  373         g_height = 20 * number_of_rows + 30
  374         headers_height = (show_weeks ? 2 * header_height : header_height)
  375         height = g_height + headers_height
  376         # TODO: Remove rmagick_font_path in a later version
  377         Rails.logger.warn('rmagick_font_path option is deprecated. Use minimagick_font_path instead.') \
  378           unless Redmine::Configuration['rmagick_font_path'].nil?
  379         font_path = Redmine::Configuration['minimagick_font_path'].presence || Redmine::Configuration['rmagick_font_path'].presence
  380         img = MiniMagick::Image.create(".#{format}", false)
  381         MiniMagick::Tool::Convert.new do |gc|
  382           gc.size('%dx%d' % [subject_width + g_width + 1, height])
  383           gc.xc('white')
  384           gc.font(font_path) if font_path.present?
  385           # Subjects
  386           gc.stroke('transparent')
  387           subjects(:image => gc, :top => (headers_height + 20), :indent => 4, :format => :image)
  388           # Months headers
  389           month_f = @date_from
  390           left = subject_width
  391           @months.times do
  392             width = ((month_f >> 1) - month_f) * zoom
  393             gc.fill('white')
  394             gc.stroke('grey')
  395             gc.strokewidth(1)
  396             gc.draw('rectangle %d,%d %d,%d' % [
  397               left, 0, left + width, height
  398             ])
  399             gc.fill('black')
  400             gc.stroke('transparent')
  401             gc.strokewidth(1)
  402             gc.draw('text %d,%d %s' % [
  403               left.round + 8, 14, Redmine::Utils::Shell.shell_quote("#{month_f.year}-#{month_f.month}")
  404             ])
  405             left = left + width
  406             month_f = month_f >> 1
  407           end
  408           # Weeks headers
  409           if show_weeks
  410             left = subject_width
  411             height = header_height
  412             if @date_from.cwday == 1
  413               # date_from is monday
  414               week_f = date_from
  415             else
  416               # find next monday after date_from
  417               week_f = @date_from + (7 - @date_from.cwday + 1)
  418               width = (7 - @date_from.cwday + 1) * zoom
  419               gc.fill('white')
  420               gc.stroke('grey')
  421               gc.strokewidth(1)
  422               gc.draw('rectangle %d,%d %d,%d' % [
  423                 left, header_height, left + width, 2 * header_height + g_height - 1
  424               ])
  425               left = left + width
  426             end
  427             while week_f <= date_to
  428               width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
  429               gc.fill('white')
  430               gc.stroke('grey')
  431               gc.strokewidth(1)
  432               gc.draw('rectangle %d,%d %d,%d' % [
  433                 left.round, header_height, left.round + width, 2 * header_height + g_height - 1
  434               ])
  435               gc.fill('black')
  436               gc.stroke('transparent')
  437               gc.strokewidth(1)
  438               gc.draw('text %d,%d %s' % [
  439                 left.round + 2, header_height + 14, Redmine::Utils::Shell.shell_quote(week_f.cweek.to_s)
  440               ])
  441               left = left + width
  442               week_f = week_f + 7
  443             end
  444           end
  445           # Days details (week-end in grey)
  446           if show_days
  447             left = subject_width
  448             height = g_height + header_height - 1
  449             wday = @date_from.cwday
  450             (date_to - @date_from + 1).to_i.times do
  451               width =  zoom
  452               gc.fill(non_working_week_days.include?(wday) ? '#eee' : 'white')
  453               gc.stroke('#ddd')
  454               gc.strokewidth(1)
  455               gc.draw('rectangle %d,%d %d,%d' % [
  456                 left, 2 * header_height, left + width, 2 * header_height + g_height - 1
  457               ])
  458               left = left + width
  459               wday = wday + 1
  460               wday = 1 if wday > 7
  461             end
  462           end
  463           # border
  464           gc.fill('transparent')
  465           gc.stroke('grey')
  466           gc.strokewidth(1)
  467           gc.draw('rectangle %d,%d %d,%d' % [
  468             0, 0, subject_width + g_width, headers_height
  469           ])
  470           gc.stroke('black')
  471           gc.draw('rectangle %d,%d %d,%d' % [
  472             0, 0, subject_width + g_width, g_height + headers_height - 1
  473           ])
  474           # content
  475           top = headers_height + 20
  476           gc.stroke('transparent')
  477           lines(:image => gc, :top => top, :zoom => zoom,
  478                 :subject_width => subject_width, :format => :image)
  479           # today red line
  480           if User.current.today >= @date_from and User.current.today <= date_to
  481             gc.stroke('red')
  482             x = (User.current.today - @date_from + 1) * zoom + subject_width
  483             gc.draw('line %g,%g %g,%g' % [
  484               x, headers_height, x, headers_height + g_height - 1
  485             ])
  486           end
  487           gc << img.path
  488         end
  489         img.to_blob
  490       ensure
  491         img.destroy! if img
  492       end if Object.const_defined?(:MiniMagick)
  493 
  494       def to_pdf
  495         pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language)
  496         pdf.SetTitle("#{l(:label_gantt)} #{project}")
  497         pdf.alias_nb_pages
  498         pdf.footer_date = format_date(User.current.today)
  499         pdf.AddPage("L")
  500         pdf.SetFontStyle('B', 12)
  501         pdf.SetX(15)
  502         pdf.RDMCell(PDF::LeftPaneWidth, 20, project.to_s)
  503         pdf.Ln
  504         pdf.SetFontStyle('B', 9)
  505         subject_width = PDF::LeftPaneWidth
  506         header_height = 5
  507         headers_height = header_height
  508         show_weeks = false
  509         show_days = false
  510         if self.months < 7
  511           show_weeks = true
  512           headers_height = 2 * header_height
  513           if self.months < 3
  514             show_days = true
  515             headers_height = 3 * header_height
  516             if self.months < 2
  517               show_day_num = true
  518               headers_height = 4 * header_height
  519             end
  520           end
  521         end
  522         g_width = PDF.right_pane_width
  523         zoom = (g_width) / (self.date_to - self.date_from + 1)
  524         g_height = 120
  525         t_height = g_height + headers_height
  526         y_start = pdf.GetY
  527         # Months headers
  528         month_f = self.date_from
  529         left = subject_width
  530         height = header_height
  531         self.months.times do
  532           width = ((month_f >> 1) - month_f) * zoom
  533           pdf.SetY(y_start)
  534           pdf.SetX(left)
  535           pdf.RDMCell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
  536           left = left + width
  537           month_f = month_f >> 1
  538         end
  539         # Weeks headers
  540         if show_weeks
  541           left = subject_width
  542           height = header_height
  543           if self.date_from.cwday == 1
  544             # self.date_from is monday
  545             week_f = self.date_from
  546           else
  547             # find next monday after self.date_from
  548             week_f = self.date_from + (7 - self.date_from.cwday + 1)
  549             width = (7 - self.date_from.cwday + 1) * zoom-1
  550             pdf.SetY(y_start + header_height)
  551             pdf.SetX(left)
  552             pdf.RDMCell(width + 1, height, "", "LTR")
  553             left = left + width + 1
  554           end
  555           while week_f <= self.date_to
  556             width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
  557             pdf.SetY(y_start + header_height)
  558             pdf.SetX(left)
  559             pdf.RDMCell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
  560             left = left + width
  561             week_f = week_f + 7
  562           end
  563         end
  564         # Day numbers headers
  565         if show_day_num
  566           left = subject_width
  567           height = header_height
  568           day_num = self.date_from
  569           wday = self.date_from.cwday
  570           pdf.SetFontStyle('B', 7)
  571           (self.date_to - self.date_from + 1).to_i.times do
  572             width = zoom
  573             pdf.SetY(y_start + header_height * 2)
  574             pdf.SetX(left)
  575             pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
  576             pdf.RDMCell(width, height, day_num.day.to_s, "LTR", 0, "C")
  577             left = left + width
  578             day_num = day_num + 1
  579             wday = wday + 1
  580             wday = 1 if wday > 7
  581           end
  582         end
  583         # Days headers
  584         if show_days
  585           left = subject_width
  586           height = header_height
  587           wday = self.date_from.cwday
  588           pdf.SetFontStyle('B', 7)
  589           (self.date_to - self.date_from + 1).to_i.times do
  590             width = zoom
  591             pdf.SetY(y_start + header_height * (show_day_num ? 3 : 2))
  592             pdf.SetX(left)
  593             pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
  594             pdf.RDMCell(width, height, day_name(wday).first, "LTR", 0, "C")
  595             left = left + width
  596             wday = wday + 1
  597             wday = 1 if wday > 7
  598           end
  599         end
  600         pdf.SetY(y_start)
  601         pdf.SetX(15)
  602         pdf.SetTextColor(0)
  603         pdf.RDMCell(subject_width + g_width - 15, headers_height, "", 1)
  604         # Tasks
  605         top = headers_height + y_start
  606         options = {
  607           :top => top,
  608           :zoom => zoom,
  609           :subject_width => subject_width,
  610           :g_width => g_width,
  611           :indent => 0,
  612           :indent_increment => 5,
  613           :top_increment => 5,
  614           :format => :pdf,
  615           :pdf => pdf
  616         }
  617         render(options)
  618         pdf.Output
  619       end
  620 
  621       private
  622 
  623       def coordinates(start_date, end_date, progress, zoom=nil)
  624         zoom ||= @zoom
  625         coords = {}
  626         if start_date && end_date && start_date < self.date_to && end_date > self.date_from
  627           if start_date > self.date_from
  628             coords[:start] = start_date - self.date_from
  629             coords[:bar_start] = start_date - self.date_from
  630           else
  631             coords[:bar_start] = 0
  632           end
  633           if end_date < self.date_to
  634             coords[:end] = end_date - self.date_from + 1
  635             coords[:bar_end] = end_date - self.date_from + 1
  636           else
  637             coords[:bar_end] = self.date_to - self.date_from + 1
  638           end
  639           if progress
  640             progress_date = calc_progress_date(start_date, end_date, progress)
  641             if progress_date > self.date_from && progress_date > start_date
  642               if progress_date < self.date_to
  643                 coords[:bar_progress_end] = progress_date - self.date_from
  644               else
  645                 coords[:bar_progress_end] = self.date_to - self.date_from + 1
  646               end
  647             end
  648             if progress_date <= User.current.today
  649               late_date = [User.current.today, end_date].min + 1
  650               if late_date > self.date_from && late_date > start_date
  651                 if late_date < self.date_to
  652                   coords[:bar_late_end] = late_date - self.date_from
  653                 else
  654                   coords[:bar_late_end] = self.date_to - self.date_from + 1
  655                 end
  656               end
  657             end
  658           end
  659         end
  660         # Transforms dates into pixels witdh
  661         coords.each_key do |key|
  662           coords[key] = (coords[key] * zoom).floor
  663         end
  664         coords
  665       end
  666 
  667       def calc_progress_date(start_date, end_date, progress)
  668         start_date + (end_date - start_date + 1) * (progress / 100.0)
  669       end
  670 
  671       # Singleton class method is public
  672       class << self
  673         def sort_issues!(issues)
  674           issues.sort_by! {|issue| sort_issue_logic(issue)}
  675         end
  676 
  677         def sort_issue_logic(issue)
  678           julian_date = Date.new()
  679           ancesters_start_date = []
  680           current_issue = issue
  681           begin
  682             ancesters_start_date.unshift([current_issue.start_date || julian_date, current_issue.id])
  683             current_issue = current_issue.parent
  684           end while (current_issue)
  685           ancesters_start_date
  686         end
  687 
  688         def sort_versions!(versions)
  689           versions.sort!
  690         end
  691       end
  692 
  693       def pdf_new_page?(options)
  694         if options[:top] > 180
  695           options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
  696           options[:pdf].AddPage("L")
  697           options[:top] = 15
  698           options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
  699         end
  700       end
  701 
  702       def html_subject_content(object)
  703         case object
  704         when Issue
  705           issue = object
  706           css_classes = +''
  707           css_classes << ' issue-overdue' if issue.overdue?
  708           css_classes << ' issue-behind-schedule' if issue.behind_schedule?
  709           css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
  710           css_classes << ' issue-closed' if issue.closed?
  711           if issue.start_date && issue.due_before && issue.done_ratio
  712             progress_date = calc_progress_date(issue.start_date,
  713                                                issue.due_before, issue.done_ratio)
  714             css_classes << ' behind-start-date' if progress_date < self.date_from
  715             css_classes << ' over-end-date' if progress_date > self.date_to
  716           end
  717           s = (+"").html_safe
  718           s << view.assignee_avatar(issue.assigned_to, :size => 13, :class => 'icon-gravatar')
  719           s << view.link_to_issue(issue).html_safe
  720           s << view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]', :value => issue.id, :style => 'display:none;', :class => 'toggle-selection')
  721           view.content_tag(:span, s, :class => css_classes).html_safe
  722         when Version
  723           version = object
  724           html_class = +""
  725           html_class << 'icon icon-package '
  726           html_class << (version.behind_schedule? ? 'version-behind-schedule' : '') << " "
  727           html_class << (version.overdue? ? 'version-overdue' : '')
  728           html_class << ' version-closed' unless version.open?
  729           if version.start_date && version.due_date && version.visible_fixed_issues.completed_percent
  730             progress_date = calc_progress_date(version.start_date,
  731                                                version.due_date, version.visible_fixed_issues.completed_percent)
  732             html_class << ' behind-start-date' if progress_date < self.date_from
  733             html_class << ' over-end-date' if progress_date > self.date_to
  734           end
  735           s = view.link_to_version(version).html_safe
  736           view.content_tag(:span, s, :class => html_class).html_safe
  737         when Project
  738           project = object
  739           html_class = +""
  740           html_class << 'icon icon-projects '
  741           html_class << (project.overdue? ? 'project-overdue' : '')
  742           s = view.link_to_project(project).html_safe
  743           view.content_tag(:span, s, :class => html_class).html_safe
  744         end
  745       end
  746 
  747       def html_subject(params, subject, object)
  748         content = html_subject_content(object) || subject
  749         tag_options = {}
  750         case object
  751         when Issue
  752           tag_options[:id] = "issue-#{object.id}"
  753           tag_options[:class] = "issue-subject hascontextmenu"
  754           tag_options[:title] = object.subject
  755           children = object.children & project_issues(object.project)
  756           has_children = children.present? && (children.collect(&:fixed_version).uniq & [object.fixed_version]).present?
  757         when Version
  758           tag_options[:id] = "version-#{object.id}"
  759           tag_options[:class] = "version-name"
  760           has_children = object.fixed_issues.exists?
  761         when Project
  762           tag_options[:class] = "project-name"
  763           has_children = object.issues.exists? || object.versions.exists?
  764         end
  765         if object
  766           tag_options[:data] = {
  767             :collapse_expand => {
  768               :top_increment => params[:top_increment],
  769               :obj_id => "#{object.class}-#{object.id}".downcase,
  770             },
  771           }
  772         end
  773         if has_children
  774           content = view.content_tag(:span, nil, :class => 'icon icon-expended expander') + content
  775           tag_options[:class] += ' open'
  776         else
  777           if params[:indent]
  778             params = params.dup
  779             params[:indent] += 12
  780           end
  781         end
  782         style = "position: absolute;top:#{params[:top]}px;left:#{params[:indent]}px;"
  783         style += "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width]
  784         tag_options[:style] = style
  785         output = view.content_tag(:div, content, tag_options)
  786         @subjects << output
  787         output
  788       end
  789 
  790       def pdf_subject(params, subject, options={})
  791         pdf_new_page?(params)
  792         params[:pdf].SetY(params[:top])
  793         params[:pdf].SetX(15)
  794         char_limit = PDF::MaxCharactorsForSubject - params[:indent]
  795         params[:pdf].RDMCell(params[:subject_width] - 15, 5,
  796                              (" " * params[:indent]) +
  797                                subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'),
  798                              "LR")
  799         params[:pdf].SetY(params[:top])
  800         params[:pdf].SetX(params[:subject_width])
  801         params[:pdf].RDMCell(params[:g_width], 5, "", "LR")
  802       end
  803 
  804       def image_subject(params, subject, options={})
  805         params[:image].fill('black')
  806         params[:image].stroke('transparent')
  807         params[:image].strokewidth(1)
  808         params[:image].draw('text %d,%d %s' % [
  809           params[:indent], params[:top] + 2, Redmine::Utils::Shell.shell_quote(subject)
  810         ])
  811       end
  812 
  813       def issue_relations(issue)
  814         rels = {}
  815         if relations[issue.id]
  816           relations[issue.id].each do |relation|
  817             (rels[relation.relation_type] ||= []) << relation.issue_to_id
  818           end
  819         end
  820         rels
  821       end
  822 
  823       def html_task(params, coords, markers, label, object)
  824         output = +''
  825         data_options = {}
  826         data_options[:collapse_expand] = "#{object.class}-#{object.id}".downcase if object
  827         css = "task " +
  828           case object
  829           when Project
  830             "project"
  831           when Version
  832             "version"
  833           when Issue
  834             object.leaf? ? 'leaf' : 'parent'
  835           else
  836             ""
  837           end
  838         # Renders the task bar, with progress and late
  839         if coords[:bar_start] && coords[:bar_end]
  840           width = coords[:bar_end] - coords[:bar_start] - 2
  841           style = +""
  842           style << "top:#{params[:top]}px;"
  843           style << "left:#{coords[:bar_start]}px;"
  844           style << "width:#{width}px;"
  845           html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue)
  846           html_id = "task-todo-version-#{object.id}" if object.is_a?(Version)
  847           content_opt = {:style => style,
  848                          :class => "#{css} task_todo",
  849                          :id => html_id,
  850                          :data => {}}
  851           if object.is_a?(Issue)
  852             rels = issue_relations(object)
  853             if rels.present?
  854               content_opt[:data] = {"rels" => rels.to_json}
  855             end
  856           end
  857           content_opt[:data].merge!(data_options)
  858           output << view.content_tag(:div, '&nbsp;'.html_safe, content_opt)
  859           if coords[:bar_late_end]
  860             width = coords[:bar_late_end] - coords[:bar_start] - 2
  861             style = +""
  862             style << "top:#{params[:top]}px;"
  863             style << "left:#{coords[:bar_start]}px;"
  864             style << "width:#{width}px;"
  865             output << view.content_tag(:div, '&nbsp;'.html_safe,
  866                                        :style => style,
  867                                        :class => "#{css} task_late",
  868                                        :data => data_options)
  869           end
  870           if coords[:bar_progress_end]
  871             width = coords[:bar_progress_end] - coords[:bar_start] - 2
  872             style = +""
  873             style << "top:#{params[:top]}px;"
  874             style << "left:#{coords[:bar_start]}px;"
  875             style << "width:#{width}px;"
  876             html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue)
  877             html_id = "task-done-version-#{object.id}" if object.is_a?(Version)
  878             output << view.content_tag(:div, '&nbsp;'.html_safe,
  879                                        :style => style,
  880                                        :class => "#{css} task_done",
  881                                        :id => html_id,
  882                                        :data => data_options)
  883           end
  884         end
  885         # Renders the markers
  886         if markers
  887           if coords[:start]
  888             style = +""
  889             style << "top:#{params[:top]}px;"
  890             style << "left:#{coords[:start]}px;"
  891             style << "width:15px;"
  892             output << view.content_tag(:div, '&nbsp;'.html_safe,
  893                                        :style => style,
  894                                        :class => "#{css} marker starting",
  895                                        :data => data_options)
  896           end
  897           if coords[:end]
  898             style = +""
  899             style << "top:#{params[:top]}px;"
  900             style << "left:#{coords[:end]}px;"
  901             style << "width:15px;"
  902             output << view.content_tag(:div, '&nbsp;'.html_safe,
  903                                        :style => style,
  904                                        :class => "#{css} marker ending",
  905                                        :data => data_options)
  906           end
  907         end
  908         # Renders the label on the right
  909         if label
  910           style = +""
  911           style << "top:#{params[:top]}px;"
  912           style << "left:#{(coords[:bar_end] || 0) + 8}px;"
  913           style << "width:15px;"
  914           output << view.content_tag(:div, label,
  915                                      :style => style,
  916                                      :class => "#{css} label",
  917                                      :data => data_options)
  918         end
  919         # Renders the tooltip
  920         if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end]
  921           s = view.content_tag(:span,
  922                                view.render_issue_tooltip(object).html_safe,
  923                                :class => "tip")
  924           s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]', :value => object.id, :style => 'display:none;', :class => 'toggle-selection')
  925           style = +""
  926           style << "position: absolute;"
  927           style << "top:#{params[:top]}px;"
  928           style << "left:#{coords[:bar_start]}px;"
  929           style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
  930           style << "height:12px;"
  931           output << view.content_tag(:div, s.html_safe,
  932                                      :style => style,
  933                                      :class => "tooltip hascontextmenu",
  934                                      :data => data_options)
  935         end
  936         @lines << output
  937         output
  938       end
  939 
  940       def pdf_task(params, coords, markers, label, object)
  941         cell_height_ratio = params[:pdf].get_cell_height_ratio()
  942         params[:pdf].set_cell_height_ratio(0.1)
  943 
  944         height = 2
  945         height /= 2 if markers
  946         # Renders the task bar, with progress and late
  947         if coords[:bar_start] && coords[:bar_end]
  948           width = [1, coords[:bar_end] - coords[:bar_start]].max
  949           params[:pdf].SetY(params[:top] + 1.5)
  950           params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
  951           params[:pdf].SetFillColor(200, 200, 200)
  952           params[:pdf].RDMCell(width, height, "", 0, 0, "", 1)
  953           if coords[:bar_late_end]
  954             width = [1, coords[:bar_late_end] - coords[:bar_start]].max
  955             params[:pdf].SetY(params[:top] + 1.5)
  956             params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
  957             params[:pdf].SetFillColor(255, 100, 100)
  958             params[:pdf].RDMCell(width, height, "", 0, 0, "", 1)
  959           end
  960           if coords[:bar_progress_end]
  961             width = [1, coords[:bar_progress_end] - coords[:bar_start]].max
  962             params[:pdf].SetY(params[:top] + 1.5)
  963             params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
  964             params[:pdf].SetFillColor(90, 200, 90)
  965             params[:pdf].RDMCell(width, height, "", 0, 0, "", 1)
  966           end
  967         end
  968         # Renders the markers
  969         if markers
  970           if coords[:start]
  971             params[:pdf].SetY(params[:top] + 1)
  972             params[:pdf].SetX(params[:subject_width] + coords[:start] - 1)
  973             params[:pdf].SetFillColor(50, 50, 200)
  974             params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
  975           end
  976           if coords[:end]
  977             params[:pdf].SetY(params[:top] + 1)
  978             params[:pdf].SetX(params[:subject_width] + coords[:end] - 1)
  979             params[:pdf].SetFillColor(50, 50, 200)
  980             params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
  981           end
  982         end
  983         # Renders the label on the right
  984         if label
  985           params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5)
  986           params[:pdf].RDMCell(30, 2, label)
  987         end
  988 
  989         params[:pdf].set_cell_height_ratio(cell_height_ratio)
  990       end
  991 
  992       def image_task(params, coords, markers, label, object)
  993         height = 6
  994         height /= 2 if markers
  995         # Renders the task bar, with progress and late
  996         if coords[:bar_start] && coords[:bar_end]
  997           params[:image].fill('#aaa')
  998           params[:image].draw('rectangle %d,%d %d,%d' % [
  999             params[:subject_width] + coords[:bar_start],
 1000             params[:top],
 1001             params[:subject_width] + coords[:bar_end],
 1002             params[:top] - height
 1003           ])
 1004           if coords[:bar_late_end]
 1005             params[:image].fill('#f66')
 1006             params[:image].draw('rectangle %d,%d %d,%d' % [
 1007               params[:subject_width] + coords[:bar_start],
 1008               params[:top],
 1009               params[:subject_width] + coords[:bar_late_end],
 1010               params[:top] - height
 1011             ])
 1012           end
 1013           if coords[:bar_progress_end]
 1014             params[:image].fill('#00c600')
 1015             params[:image].draw('rectangle %d,%d %d,%d' % [
 1016               params[:subject_width] + coords[:bar_start],
 1017               params[:top],
 1018               params[:subject_width] + coords[:bar_progress_end],
 1019               params[:top] - height
 1020             ])
 1021           end
 1022         end
 1023         # Renders the markers
 1024         if markers
 1025           if coords[:start]
 1026             x = params[:subject_width] + coords[:start]
 1027             y = params[:top] - height / 2
 1028             params[:image].fill('blue')
 1029             params[:image].draw('polygon %d,%d %d,%d %d,%d %d,%d' % [
 1030               x - 4, y,
 1031               x, y - 4,
 1032               x + 4, y,
 1033               x, y + 4
 1034             ])
 1035           end
 1036           if coords[:end]
 1037             x = params[:subject_width] + coords[:end]
 1038             y = params[:top] - height / 2
 1039             params[:image].fill('blue')
 1040             params[:image].draw('polygon %d,%d %d,%d %d,%d %d,%d' % [
 1041               x - 4, y,
 1042               x, y - 4,
 1043               x + 4, y,
 1044               x, y + 4
 1045             ])
 1046           end
 1047         end
 1048         # Renders the label on the right
 1049         if label
 1050           params[:image].fill('black')
 1051           params[:image].draw('text %d,%d %s' % [
 1052             params[:subject_width] + (coords[:bar_end] || 0) + 5, params[:top] + 1, Redmine::Utils::Shell.shell_quote(label)
 1053           ])
 1054         end
 1055       end
 1056     end
 1057   end
 1058 end