Mercurial > hg > soundsoftware-site
comparison lib/redmine/helpers/gantt.rb @ 22:40f7cfd4df19
* Update to SVN trunk rev 4173
author | Chris Cannam <chris.cannam@soundsoftware.ac.uk> |
---|---|
date | Fri, 24 Sep 2010 14:06:04 +0100 |
parents | cca12e1c1fd4 |
children | 94944d00e43c |
comparison
equal
deleted
inserted
replaced
14:1d32c0a0efbf | 22:40f7cfd4df19 |
---|---|
17 | 17 |
18 module Redmine | 18 module Redmine |
19 module Helpers | 19 module Helpers |
20 # Simple class to handle gantt chart data | 20 # Simple class to handle gantt chart data |
21 class Gantt | 21 class Gantt |
22 attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months, :events | 22 include ERB::Util |
23 | 23 include Redmine::I18n |
24 | |
25 # :nodoc: | |
26 # Some utility methods for the PDF export | |
27 class PDF | |
28 MaxCharactorsForSubject = 45 | |
29 TotalWidth = 280 | |
30 LeftPaneWidth = 100 | |
31 | |
32 def self.right_pane_width | |
33 TotalWidth - LeftPaneWidth | |
34 end | |
35 end | |
36 | |
37 attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months | |
38 attr_accessor :query | |
39 attr_accessor :project | |
40 attr_accessor :view | |
41 | |
24 def initialize(options={}) | 42 def initialize(options={}) |
25 options = options.dup | 43 options = options.dup |
26 @events = [] | |
27 | 44 |
28 if options[:year] && options[:year].to_i >0 | 45 if options[:year] && options[:year].to_i >0 |
29 @year_from = options[:year].to_i | 46 @year_from = options[:year].to_i |
30 if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12 | 47 if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12 |
31 @month_from = options[:month].to_i | 48 @month_from = options[:month].to_i |
49 end | 66 end |
50 | 67 |
51 @date_from = Date.civil(@year_from, @month_from, 1) | 68 @date_from = Date.civil(@year_from, @month_from, 1) |
52 @date_to = (@date_from >> @months) - 1 | 69 @date_to = (@date_from >> @months) - 1 |
53 end | 70 end |
54 | 71 |
55 | 72 def common_params |
56 def events=(e) | 73 { :controller => 'gantts', :action => 'show', :project_id => @project } |
57 @events = e | |
58 # Adds all ancestors | |
59 root_ids = e.select {|i| i.is_a?(Issue) && i.parent_id? }.collect(&:root_id).uniq | |
60 if root_ids.any? | |
61 # Retrieves all nodes | |
62 parents = Issue.find_all_by_root_id(root_ids, :conditions => ["rgt - lft > 1"]) | |
63 # Only add ancestors | |
64 @events += parents.select {|p| @events.detect {|i| i.is_a?(Issue) && p.is_ancestor_of?(i)}} | |
65 end | |
66 @events.uniq! | |
67 # Sort issues by hierarchy and start dates | |
68 @events.sort! {|x,y| | |
69 if x.is_a?(Issue) && y.is_a?(Issue) | |
70 gantt_issue_compare(x, y, @events) | |
71 else | |
72 gantt_start_compare(x, y) | |
73 end | |
74 } | |
75 # Removes issues that have no start or end date | |
76 @events.reject! {|i| i.is_a?(Issue) && (i.start_date.nil? || i.due_before.nil?) } | |
77 @events | |
78 end | 74 end |
79 | 75 |
80 def params | 76 def params |
81 { :zoom => zoom, :year => year_from, :month => month_from, :months => months } | 77 common_params.merge({ :zoom => zoom, :year => year_from, :month => month_from, :months => months }) |
82 end | 78 end |
83 | 79 |
84 def params_previous | 80 def params_previous |
85 { :year => (date_from << months).year, :month => (date_from << months).month, :zoom => zoom, :months => months } | 81 common_params.merge({:year => (date_from << months).year, :month => (date_from << months).month, :zoom => zoom, :months => months }) |
86 end | 82 end |
87 | 83 |
88 def params_next | 84 def params_next |
89 { :year => (date_from >> months).year, :month => (date_from >> months).month, :zoom => zoom, :months => months } | 85 common_params.merge({:year => (date_from >> months).year, :month => (date_from >> months).month, :zoom => zoom, :months => months }) |
90 end | 86 end |
91 | 87 |
88 ### Extracted from the HTML view/helpers | |
89 # Returns the number of rows that will be rendered on the Gantt chart | |
90 def number_of_rows | |
91 if @project | |
92 return number_of_rows_on_project(@project) | |
93 else | |
94 Project.roots.inject(0) do |total, project| | |
95 total += number_of_rows_on_project(project) | |
96 end | |
97 end | |
98 end | |
99 | |
100 # Returns the number of rows that will be used to list a project on | |
101 # the Gantt chart. This will recurse for each subproject. | |
102 def number_of_rows_on_project(project) | |
103 # Remove the project requirement for Versions because it will | |
104 # restrict issues to only be on the current project. This | |
105 # ends up missing issues which are assigned to shared versions. | |
106 @query.project = nil if @query.project | |
107 | |
108 # One Root project | |
109 count = 1 | |
110 # Issues without a Version | |
111 count += project.issues.for_gantt.without_version.with_query(@query).count | |
112 | |
113 # Versions | |
114 count += project.versions.count | |
115 | |
116 # Issues on the Versions | |
117 project.versions.each do |version| | |
118 count += version.fixed_issues.for_gantt.with_query(@query).count | |
119 end | |
120 | |
121 # Subprojects | |
122 project.children.each do |subproject| | |
123 count += number_of_rows_on_project(subproject) | |
124 end | |
125 | |
126 count | |
127 end | |
128 | |
129 # Renders the subjects of the Gantt chart, the left side. | |
130 def subjects(options={}) | |
131 options = {:indent => 4, :render => :subject, :format => :html}.merge(options) | |
132 | |
133 output = '' | |
134 if @project | |
135 output << render_project(@project, options) | |
136 else | |
137 Project.roots.each do |project| | |
138 output << render_project(project, options) | |
139 end | |
140 end | |
141 | |
142 output | |
143 end | |
144 | |
145 # Renders the lines of the Gantt chart, the right side | |
146 def lines(options={}) | |
147 options = {:indent => 4, :render => :line, :format => :html}.merge(options) | |
148 output = '' | |
149 | |
150 if @project | |
151 output << render_project(@project, options) | |
152 else | |
153 Project.roots.each do |project| | |
154 output << render_project(project, options) | |
155 end | |
156 end | |
157 | |
158 output | |
159 end | |
160 | |
161 def render_project(project, options={}) | |
162 options[:top] = 0 unless options.key? :top | |
163 options[:indent_increment] = 20 unless options.key? :indent_increment | |
164 options[:top_increment] = 20 unless options.key? :top_increment | |
165 | |
166 output = '' | |
167 # Project Header | |
168 project_header = if options[:render] == :subject | |
169 subject_for_project(project, options) | |
170 else | |
171 # :line | |
172 line_for_project(project, options) | |
173 end | |
174 output << project_header if options[:format] == :html | |
175 | |
176 options[:top] += options[:top_increment] | |
177 options[:indent] += options[:indent_increment] | |
178 | |
179 # Second, Issues without a version | |
180 issues = project.issues.for_gantt.without_version.with_query(@query) | |
181 if issues | |
182 issue_rendering = render_issues(issues, options) | |
183 output << issue_rendering if options[:format] == :html | |
184 end | |
185 | |
186 # Third, Versions | |
187 project.versions.sort.each do |version| | |
188 version_rendering = render_version(version, options) | |
189 output << version_rendering if options[:format] == :html | |
190 end | |
191 | |
192 # Fourth, subprojects | |
193 project.children.each do |project| | |
194 subproject_rendering = render_project(project, options) | |
195 output << subproject_rendering if options[:format] == :html | |
196 end | |
197 | |
198 # Remove indent to hit the next sibling | |
199 options[:indent] -= options[:indent_increment] | |
200 | |
201 output | |
202 end | |
203 | |
204 def render_issues(issues, options={}) | |
205 output = '' | |
206 issues.each do |i| | |
207 issue_rendering = if options[:render] == :subject | |
208 subject_for_issue(i, options) | |
209 else | |
210 # :line | |
211 line_for_issue(i, options) | |
212 end | |
213 output << issue_rendering if options[:format] == :html | |
214 options[:top] += options[:top_increment] | |
215 end | |
216 output | |
217 end | |
218 | |
219 def render_version(version, options={}) | |
220 output = '' | |
221 # Version header | |
222 version_rendering = if options[:render] == :subject | |
223 subject_for_version(version, options) | |
224 else | |
225 # :line | |
226 line_for_version(version, options) | |
227 end | |
228 | |
229 output << version_rendering if options[:format] == :html | |
230 | |
231 options[:top] += options[:top_increment] | |
232 | |
233 # Remove the project requirement for Versions because it will | |
234 # restrict issues to only be on the current project. This | |
235 # ends up missing issues which are assigned to shared versions. | |
236 @query.project = nil if @query.project | |
237 | |
238 issues = version.fixed_issues.for_gantt.with_query(@query) | |
239 if issues | |
240 # Indent issues | |
241 options[:indent] += options[:indent_increment] | |
242 output << render_issues(issues, options) | |
243 options[:indent] -= options[:indent_increment] | |
244 end | |
245 | |
246 output | |
247 end | |
248 | |
249 def subject_for_project(project, options) | |
250 case options[:format] | |
251 when :html | |
252 output = '' | |
253 | |
254 output << "<div class='project-name' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small> " | |
255 if project.is_a? Project | |
256 output << "<span class='icon icon-projects #{project.overdue? ? 'project-overdue' : ''}'>" | |
257 output << view.link_to_project(project) | |
258 output << '</span>' | |
259 else | |
260 ActiveRecord::Base.logger.debug "Gantt#subject_for_project was not given a project" | |
261 '' | |
262 end | |
263 output << "</small></div>" | |
264 | |
265 output | |
266 when :image | |
267 | |
268 options[:image].fill('black') | |
269 options[:image].stroke('transparent') | |
270 options[:image].stroke_width(1) | |
271 options[:image].text(options[:indent], options[:top] + 2, project.name) | |
272 when :pdf | |
273 options[:pdf].SetY(options[:top]) | |
274 options[:pdf].SetX(15) | |
275 | |
276 char_limit = PDF::MaxCharactorsForSubject - options[:indent] | |
277 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{project.name}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR") | |
278 | |
279 options[:pdf].SetY(options[:top]) | |
280 options[:pdf].SetX(options[:subject_width]) | |
281 options[:pdf].Cell(options[:g_width], 5, "", "LR") | |
282 end | |
283 end | |
284 | |
285 def line_for_project(project, options) | |
286 # Skip versions that don't have a start_date | |
287 if project.is_a?(Project) && project.start_date | |
288 options[:zoom] ||= 1 | |
289 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom] | |
290 | |
291 | |
292 case options[:format] | |
293 when :html | |
294 output = '' | |
295 i_left = ((project.start_date - self.date_from)*options[:zoom]).floor | |
296 | |
297 start_date = project.start_date | |
298 start_date ||= self.date_from | |
299 start_left = ((start_date - self.date_from)*options[:zoom]).floor | |
300 | |
301 i_end_date = ((project.due_date <= self.date_to) ? project.due_date : self.date_to ) | |
302 i_done_date = start_date + ((project.due_date - start_date+1)* project.completed_percent(:include_subprojects => true)/100).floor | |
303 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date ) | |
304 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date ) | |
305 | |
306 i_late_date = [i_end_date, Date.today].min if start_date < Date.today | |
307 i_end = ((i_end_date - self.date_from) * options[:zoom]).floor | |
308 | |
309 i_width = (i_end - i_left + 1).floor - 2 # total width of the issue (- 2 for left and right borders) | |
310 d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2 # done width | |
311 l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width | |
312 | |
313 # Bar graphic | |
314 | |
315 # Make sure that negative i_left and i_width don't | |
316 # overflow the subject | |
317 if i_end > 0 && i_left <= options[:g_width] | |
318 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task project_todo'> </div>" | |
319 end | |
320 | |
321 if l_width > 0 && i_left <= options[:g_width] | |
322 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task project_late'> </div>" | |
323 end | |
324 if d_width > 0 && i_left <= options[:g_width] | |
325 output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task project_done'> </div>" | |
326 end | |
327 | |
328 | |
329 # Starting diamond | |
330 if start_left <= options[:g_width] && start_left > 0 | |
331 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task project-line starting'> </div>" | |
332 output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;' class='task label'>" | |
333 output << "</div>" | |
334 end | |
335 | |
336 # Ending diamond | |
337 # Don't show items too far ahead | |
338 if i_end <= options[:g_width] && i_end > 0 | |
339 output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task project-line ending'> </div>" | |
340 end | |
341 | |
342 # DIsplay the Project name and % | |
343 if i_end <= options[:g_width] | |
344 # Display the status even if it's floated off to the left | |
345 status_px = i_end + 12 # 12px for the diamond | |
346 status_px = 0 if status_px <= 0 | |
347 | |
348 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label project-name'>" | |
349 output << "<strong>#{h project } #{h project.completed_percent(:include_subprojects => true).to_i.to_s}%</strong>" | |
350 output << "</div>" | |
351 end | |
352 | |
353 output | |
354 when :image | |
355 options[:image].stroke('transparent') | |
356 i_left = options[:subject_width] + ((project.due_date - self.date_from)*options[:zoom]).floor | |
357 | |
358 # Make sure negative i_left doesn't overflow the subject | |
359 if i_left > options[:subject_width] | |
360 options[:image].fill('blue') | |
361 options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6) | |
362 options[:image].fill('black') | |
363 options[:image].text(i_left + 11, options[:top] + 1, project.name) | |
364 end | |
365 when :pdf | |
366 options[:pdf].SetY(options[:top]+1.5) | |
367 i_left = ((project.due_date - @date_from)*options[:zoom]) | |
368 | |
369 # Make sure negative i_left doesn't overflow the subject | |
370 if i_left > 0 | |
371 options[:pdf].SetX(options[:subject_width] + i_left) | |
372 options[:pdf].SetFillColor(50,50,200) | |
373 options[:pdf].Cell(2, 2, "", 0, 0, "", 1) | |
374 | |
375 options[:pdf].SetY(options[:top]+1.5) | |
376 options[:pdf].SetX(options[:subject_width] + i_left + 3) | |
377 options[:pdf].Cell(30, 2, "#{project.name}") | |
378 end | |
379 end | |
380 else | |
381 ActiveRecord::Base.logger.debug "Gantt#line_for_project was not given a project with a start_date" | |
382 '' | |
383 end | |
384 end | |
385 | |
386 def subject_for_version(version, options) | |
387 case options[:format] | |
388 when :html | |
389 output = '' | |
390 output << "<div class='version-name' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small> " | |
391 if version.is_a? Version | |
392 output << "<span class='icon icon-package #{version.behind_schedule? ? 'version-behind-schedule' : ''} #{version.overdue? ? 'version-overdue' : ''}'>" | |
393 output << view.link_to_version(version) | |
394 output << '</span>' | |
395 else | |
396 ActiveRecord::Base.logger.debug "Gantt#subject_for_version was not given a version" | |
397 '' | |
398 end | |
399 output << "</small></div>" | |
400 | |
401 output | |
402 when :image | |
403 options[:image].fill('black') | |
404 options[:image].stroke('transparent') | |
405 options[:image].stroke_width(1) | |
406 options[:image].text(options[:indent], options[:top] + 2, version.to_s_with_project) | |
407 when :pdf | |
408 options[:pdf].SetY(options[:top]) | |
409 options[:pdf].SetX(15) | |
410 | |
411 char_limit = PDF::MaxCharactorsForSubject - options[:indent] | |
412 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{version.to_s_with_project}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR") | |
413 | |
414 options[:pdf].SetY(options[:top]) | |
415 options[:pdf].SetX(options[:subject_width]) | |
416 options[:pdf].Cell(options[:g_width], 5, "", "LR") | |
417 end | |
418 end | |
419 | |
420 def line_for_version(version, options) | |
421 # Skip versions that don't have a start_date | |
422 if version.is_a?(Version) && version.start_date | |
423 options[:zoom] ||= 1 | |
424 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom] | |
425 | |
426 case options[:format] | |
427 when :html | |
428 output = '' | |
429 i_left = ((version.start_date - self.date_from)*options[:zoom]).floor | |
430 # TODO: or version.fixed_issues.collect(&:start_date).min | |
431 start_date = version.fixed_issues.minimum('start_date') if version.fixed_issues.present? | |
432 start_date ||= self.date_from | |
433 start_left = ((start_date - self.date_from)*options[:zoom]).floor | |
434 | |
435 i_end_date = ((version.due_date <= self.date_to) ? version.due_date : self.date_to ) | |
436 i_done_date = start_date + ((version.due_date - start_date+1)* version.completed_pourcent/100).floor | |
437 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date ) | |
438 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date ) | |
439 | |
440 i_late_date = [i_end_date, Date.today].min if start_date < Date.today | |
441 | |
442 i_width = (i_left - start_left + 1).floor - 2 # total width of the issue (- 2 for left and right borders) | |
443 d_width = ((i_done_date - start_date)*options[:zoom]).floor - 2 # done width | |
444 l_width = i_late_date ? ((i_late_date - start_date+1)*options[:zoom]).floor - 2 : 0 # delay width | |
445 | |
446 i_end = ((i_end_date - self.date_from) * options[:zoom]).floor # Ending pixel | |
447 | |
448 # Bar graphic | |
449 | |
450 # Make sure that negative i_left and i_width don't | |
451 # overflow the subject | |
452 if i_width > 0 && i_left <= options[:g_width] | |
453 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ i_width }px;' class='task milestone_todo'> </div>" | |
454 end | |
455 if l_width > 0 && i_left <= options[:g_width] | |
456 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ l_width }px;' class='task milestone_late'> </div>" | |
457 end | |
458 if d_width > 0 && i_left <= options[:g_width] | |
459 output<< "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:#{ d_width }px;' class='task milestone_done'> </div>" | |
460 end | |
461 | |
462 | |
463 # Starting diamond | |
464 if start_left <= options[:g_width] && start_left > 0 | |
465 output << "<div style='top:#{ options[:top] }px;left:#{ start_left }px;width:15px;' class='task milestone starting'> </div>" | |
466 output << "<div style='top:#{ options[:top] }px;left:#{ start_left + 12 }px;background:#fff;' class='task'>" | |
467 output << "</div>" | |
468 end | |
469 | |
470 # Ending diamond | |
471 # Don't show items too far ahead | |
472 if i_left <= options[:g_width] && i_end > 0 | |
473 output << "<div style='top:#{ options[:top] }px;left:#{ i_end }px;width:15px;' class='task milestone ending'> </div>" | |
474 end | |
475 | |
476 # Display the Version name and % | |
477 if i_end <= options[:g_width] | |
478 # Display the status even if it's floated off to the left | |
479 status_px = i_end + 12 # 12px for the diamond | |
480 status_px = 0 if status_px <= 0 | |
481 | |
482 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='task label version-name'>" | |
483 output << h("#{version.project} -") unless @project && @project == version.project | |
484 output << "<strong>#{h version } #{h version.completed_pourcent.to_i.to_s}%</strong>" | |
485 output << "</div>" | |
486 end | |
487 | |
488 output | |
489 when :image | |
490 options[:image].stroke('transparent') | |
491 i_left = options[:subject_width] + ((version.start_date - @date_from)*options[:zoom]).floor | |
492 | |
493 # Make sure negative i_left doesn't overflow the subject | |
494 if i_left > options[:subject_width] | |
495 options[:image].fill('green') | |
496 options[:image].rectangle(i_left, options[:top], i_left + 6, options[:top] - 6) | |
497 options[:image].fill('black') | |
498 options[:image].text(i_left + 11, options[:top] + 1, version.name) | |
499 end | |
500 when :pdf | |
501 options[:pdf].SetY(options[:top]+1.5) | |
502 i_left = ((version.start_date - @date_from)*options[:zoom]) | |
503 | |
504 # Make sure negative i_left doesn't overflow the subject | |
505 if i_left > 0 | |
506 options[:pdf].SetX(options[:subject_width] + i_left) | |
507 options[:pdf].SetFillColor(50,200,50) | |
508 options[:pdf].Cell(2, 2, "", 0, 0, "", 1) | |
509 | |
510 options[:pdf].SetY(options[:top]+1.5) | |
511 options[:pdf].SetX(options[:subject_width] + i_left + 3) | |
512 options[:pdf].Cell(30, 2, "#{version.name}") | |
513 end | |
514 end | |
515 else | |
516 ActiveRecord::Base.logger.debug "Gantt#line_for_version was not given a version with a start_date" | |
517 '' | |
518 end | |
519 end | |
520 | |
521 def subject_for_issue(issue, options) | |
522 case options[:format] | |
523 when :html | |
524 output = '' | |
525 output << "<div class='tooltip'>" | |
526 output << "<div class='issue-subject' style='position: absolute;line-height:1.2em;height:16px;top:#{options[:top]}px;left:#{options[:indent]}px;overflow:hidden;'><small> " | |
527 if issue.is_a? Issue | |
528 css_classes = [] | |
529 css_classes << 'issue-overdue' if issue.overdue? | |
530 css_classes << 'issue-behind-schedule' if issue.behind_schedule? | |
531 css_classes << 'icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to | |
532 | |
533 if issue.assigned_to.present? | |
534 assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name | |
535 output << view.avatar(issue.assigned_to, :class => 'gravatar icon-gravatar', :size => 10, :title => assigned_string) | |
536 end | |
537 output << "<span class='#{css_classes.join(' ')}'>" | |
538 output << view.link_to_issue(issue) | |
539 output << ":" | |
540 output << h(issue.subject) | |
541 output << '</span>' | |
542 else | |
543 ActiveRecord::Base.logger.debug "Gantt#subject_for_issue was not given an issue" | |
544 '' | |
545 end | |
546 output << "</small></div>" | |
547 | |
548 # Tooltip | |
549 if issue.is_a? Issue | |
550 output << "<span class='tip' style='position: absolute;top:#{ options[:top].to_i + 16 }px;left:#{ options[:indent].to_i + 20 }px;'>" | |
551 output << view.render_issue_tooltip(issue) | |
552 output << "</span>" | |
553 end | |
554 | |
555 output << "</div>" | |
556 output | |
557 when :image | |
558 options[:image].fill('black') | |
559 options[:image].stroke('transparent') | |
560 options[:image].stroke_width(1) | |
561 options[:image].text(options[:indent], options[:top] + 2, issue.subject) | |
562 when :pdf | |
563 options[:pdf].SetY(options[:top]) | |
564 options[:pdf].SetX(15) | |
565 | |
566 char_limit = PDF::MaxCharactorsForSubject - options[:indent] | |
567 options[:pdf].Cell(options[:subject_width]-15, 5, (" " * options[:indent]) +"#{issue.tracker} #{issue.id}: #{issue.subject}".sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR") | |
568 | |
569 options[:pdf].SetY(options[:top]) | |
570 options[:pdf].SetX(options[:subject_width]) | |
571 options[:pdf].Cell(options[:g_width], 5, "", "LR") | |
572 end | |
573 end | |
574 | |
575 def line_for_issue(issue, options) | |
576 # Skip issues that don't have a due_before (due_date or version's due_date) | |
577 if issue.is_a?(Issue) && issue.due_before | |
578 case options[:format] | |
579 when :html | |
580 output = '' | |
581 # Handle nil start_dates, rare but can happen. | |
582 i_start_date = if issue.start_date && issue.start_date >= self.date_from | |
583 issue.start_date | |
584 else | |
585 self.date_from | |
586 end | |
587 | |
588 i_end_date = ((issue.due_before && issue.due_before <= self.date_to) ? issue.due_before : self.date_to ) | |
589 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor | |
590 i_done_date = (i_done_date <= self.date_from ? self.date_from : i_done_date ) | |
591 i_done_date = (i_done_date >= self.date_to ? self.date_to : i_done_date ) | |
592 | |
593 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today | |
594 | |
595 i_left = ((i_start_date - self.date_from)*options[:zoom]).floor | |
596 i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor - 2 # total width of the issue (- 2 for left and right borders) | |
597 d_width = ((i_done_date - i_start_date)*options[:zoom]).floor - 2 # done width | |
598 l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor - 2 : 0 # delay width | |
599 css = "task " + (issue.leaf? ? 'leaf' : 'parent') | |
600 | |
601 # Make sure that negative i_left and i_width don't | |
602 # overflow the subject | |
603 if i_width > 0 | |
604 output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;' class='#{css} task_todo'> </div>" | |
605 end | |
606 if l_width > 0 | |
607 output << "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ l_width }px;' class='#{css} task_late'> </div>" | |
608 end | |
609 if d_width > 0 | |
610 output<< "<div style='top:#{ options[:top] }px;left:#{ i_left }px;width:#{ d_width }px;' class='#{css} task_done'> </div>" | |
611 end | |
612 | |
613 # Display the status even if it's floated off to the left | |
614 status_px = i_left + i_width + 5 | |
615 status_px = 5 if status_px <= 0 | |
616 | |
617 output << "<div style='top:#{ options[:top] }px;left:#{ status_px }px;' class='#{css} label issue-name'>" | |
618 output << issue.status.name | |
619 output << ' ' | |
620 output << (issue.done_ratio).to_i.to_s | |
621 output << "%" | |
622 output << "</div>" | |
623 | |
624 output << "<div class='tooltip' style='position: absolute;top:#{ options[:top] }px;left:#{ i_left }px;width:#{ i_width }px;height:12px;'>" | |
625 output << '<span class="tip">' | |
626 output << view.render_issue_tooltip(issue) | |
627 output << "</span></div>" | |
628 output | |
629 | |
630 when :image | |
631 # Handle nil start_dates, rare but can happen. | |
632 i_start_date = if issue.start_date && issue.start_date >= @date_from | |
633 issue.start_date | |
634 else | |
635 @date_from | |
636 end | |
637 | |
638 i_end_date = (issue.due_before <= date_to ? issue.due_before : date_to ) | |
639 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor | |
640 i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date ) | |
641 i_done_date = (i_done_date >= date_to ? date_to : i_done_date ) | |
642 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today | |
643 | |
644 i_left = options[:subject_width] + ((i_start_date - @date_from)*options[:zoom]).floor | |
645 i_width = ((i_end_date - i_start_date + 1)*options[:zoom]).floor # total width of the issue | |
646 d_width = ((i_done_date - i_start_date)*options[:zoom]).floor # done width | |
647 l_width = i_late_date ? ((i_late_date - i_start_date+1)*options[:zoom]).floor : 0 # delay width | |
648 | |
649 | |
650 # Make sure that negative i_left and i_width don't | |
651 # overflow the subject | |
652 if i_width > 0 | |
653 options[:image].fill('grey') | |
654 options[:image].rectangle(i_left, options[:top], i_left + i_width, options[:top] - 6) | |
655 options[:image].fill('red') | |
656 options[:image].rectangle(i_left, options[:top], i_left + l_width, options[:top] - 6) if l_width > 0 | |
657 options[:image].fill('blue') | |
658 options[:image].rectangle(i_left, options[:top], i_left + d_width, options[:top] - 6) if d_width > 0 | |
659 end | |
660 | |
661 # Show the status and % done next to the subject if it overflows | |
662 options[:image].fill('black') | |
663 if i_width > 0 | |
664 options[:image].text(i_left + i_width + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%") | |
665 else | |
666 options[:image].text(options[:subject_width] + 5,options[:top] + 1, "#{issue.status.name} #{issue.done_ratio}%") | |
667 end | |
668 | |
669 when :pdf | |
670 options[:pdf].SetY(options[:top]+1.5) | |
671 # Handle nil start_dates, rare but can happen. | |
672 i_start_date = if issue.start_date && issue.start_date >= @date_from | |
673 issue.start_date | |
674 else | |
675 @date_from | |
676 end | |
677 | |
678 i_end_date = (issue.due_before <= @date_to ? issue.due_before : @date_to ) | |
679 | |
680 i_done_date = i_start_date + ((issue.due_before - i_start_date+1)*issue.done_ratio/100).floor | |
681 i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date ) | |
682 i_done_date = (i_done_date >= @date_to ? @date_to : i_done_date ) | |
683 | |
684 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today | |
685 | |
686 i_left = ((i_start_date - @date_from)*options[:zoom]) | |
687 i_width = ((i_end_date - i_start_date + 1)*options[:zoom]) | |
688 d_width = ((i_done_date - i_start_date)*options[:zoom]) | |
689 l_width = ((i_late_date - i_start_date+1)*options[:zoom]) if i_late_date | |
690 l_width ||= 0 | |
691 | |
692 # Make sure that negative i_left and i_width don't | |
693 # overflow the subject | |
694 if i_width > 0 | |
695 options[:pdf].SetX(options[:subject_width] + i_left) | |
696 options[:pdf].SetFillColor(200,200,200) | |
697 options[:pdf].Cell(i_width, 2, "", 0, 0, "", 1) | |
698 end | |
699 | |
700 if l_width > 0 | |
701 options[:pdf].SetY(options[:top]+1.5) | |
702 options[:pdf].SetX(options[:subject_width] + i_left) | |
703 options[:pdf].SetFillColor(255,100,100) | |
704 options[:pdf].Cell(l_width, 2, "", 0, 0, "", 1) | |
705 end | |
706 if d_width > 0 | |
707 options[:pdf].SetY(options[:top]+1.5) | |
708 options[:pdf].SetX(options[:subject_width] + i_left) | |
709 options[:pdf].SetFillColor(100,100,255) | |
710 options[:pdf].Cell(d_width, 2, "", 0, 0, "", 1) | |
711 end | |
712 | |
713 options[:pdf].SetY(options[:top]+1.5) | |
714 | |
715 # Make sure that negative i_left and i_width don't | |
716 # overflow the subject | |
717 if (i_left + i_width) >= 0 | |
718 options[:pdf].SetX(options[:subject_width] + i_left + i_width) | |
719 else | |
720 options[:pdf].SetX(options[:subject_width]) | |
721 end | |
722 options[:pdf].Cell(30, 2, "#{issue.status} #{issue.done_ratio}%") | |
723 end | |
724 else | |
725 ActiveRecord::Base.logger.debug "GanttHelper#line_for_issue was not given an issue with a due_before" | |
726 '' | |
727 end | |
728 end | |
729 | |
92 # Generates a gantt image | 730 # Generates a gantt image |
93 # Only defined if RMagick is avalaible | 731 # Only defined if RMagick is avalaible |
94 def to_image(project, format='PNG') | 732 def to_image(format='PNG') |
95 date_to = (@date_from >> @months)-1 | 733 date_to = (@date_from >> @months)-1 |
96 show_weeks = @zoom > 1 | 734 show_weeks = @zoom > 1 |
97 show_days = @zoom > 2 | 735 show_days = @zoom > 2 |
98 | 736 |
99 subject_width = 400 | 737 subject_width = 400 |
100 header_heigth = 18 | 738 header_heigth = 18 |
101 # width of one day in pixels | 739 # width of one day in pixels |
102 zoom = @zoom*2 | 740 zoom = @zoom*2 |
103 g_width = (@date_to - @date_from + 1)*zoom | 741 g_width = (@date_to - @date_from + 1)*zoom |
104 g_height = 20 * events.length + 20 | 742 g_height = 20 * number_of_rows + 30 |
105 headers_heigth = (show_weeks ? 2*header_heigth : header_heigth) | 743 headers_heigth = (show_weeks ? 2*header_heigth : header_heigth) |
106 height = g_height + headers_heigth | 744 height = g_height + headers_heigth |
107 | 745 |
108 imgl = Magick::ImageList.new | 746 imgl = Magick::ImageList.new |
109 imgl.new_image(subject_width+g_width+1, height) | 747 imgl.new_image(subject_width+g_width+1, height) |
110 gc = Magick::Draw.new | 748 gc = Magick::Draw.new |
111 | 749 |
112 # Subjects | 750 # Subjects |
113 top = headers_heigth + 20 | 751 subjects(:image => gc, :top => (headers_heigth + 20), :indent => 4, :format => :image) |
114 gc.fill('black') | |
115 gc.stroke('transparent') | |
116 gc.stroke_width(1) | |
117 events.each do |i| | |
118 text = "" | |
119 if i.is_a? Issue | |
120 text = "#{i.tracker} #{i.id}: #{i.subject}" | |
121 else | |
122 text = i.name | |
123 end | |
124 text = "#{i.project} - #{text}" unless project && project == i.project | |
125 gc.text(4, top + 2, text) | |
126 top = top + 20 | |
127 end | |
128 | 752 |
129 # Months headers | 753 # Months headers |
130 month_f = @date_from | 754 month_f = @date_from |
131 left = subject_width | 755 left = subject_width |
132 @months.times do | 756 @months.times do |
200 gc.stroke('black') | 824 gc.stroke('black') |
201 gc.rectangle(0, 0, subject_width+g_width, g_height+ headers_heigth-1) | 825 gc.rectangle(0, 0, subject_width+g_width, g_height+ headers_heigth-1) |
202 | 826 |
203 # content | 827 # content |
204 top = headers_heigth + 20 | 828 top = headers_heigth + 20 |
205 gc.stroke('transparent') | 829 |
206 events.each do |i| | 830 lines(:image => gc, :top => top, :zoom => zoom, :subject_width => subject_width, :format => :image) |
207 if i.is_a?(Issue) | |
208 i_start_date = (i.start_date >= @date_from ? i.start_date : @date_from ) | |
209 i_end_date = (i.due_before <= date_to ? i.due_before : date_to ) | |
210 i_done_date = i.start_date + ((i.due_before - i.start_date+1)*i.done_ratio/100).floor | |
211 i_done_date = (i_done_date <= @date_from ? @date_from : i_done_date ) | |
212 i_done_date = (i_done_date >= date_to ? date_to : i_done_date ) | |
213 i_late_date = [i_end_date, Date.today].min if i_start_date < Date.today | |
214 | |
215 i_left = subject_width + ((i_start_date - @date_from)*zoom).floor | |
216 i_width = ((i_end_date - i_start_date + 1)*zoom).floor # total width of the issue | |
217 d_width = ((i_done_date - i_start_date)*zoom).floor # done width | |
218 l_width = i_late_date ? ((i_late_date - i_start_date+1)*zoom).floor : 0 # delay width | |
219 | |
220 gc.fill('grey') | |
221 gc.rectangle(i_left, top, i_left + i_width, top - 6) | |
222 gc.fill('red') | |
223 gc.rectangle(i_left, top, i_left + l_width, top - 6) if l_width > 0 | |
224 gc.fill('blue') | |
225 gc.rectangle(i_left, top, i_left + d_width, top - 6) if d_width > 0 | |
226 gc.fill('black') | |
227 gc.text(i_left + i_width + 5,top + 1, "#{i.status.name} #{i.done_ratio}%") | |
228 else | |
229 i_left = subject_width + ((i.start_date - @date_from)*zoom).floor | |
230 gc.fill('green') | |
231 gc.rectangle(i_left, top, i_left + 6, top - 6) | |
232 gc.fill('black') | |
233 gc.text(i_left + 11, top + 1, i.name) | |
234 end | |
235 top = top + 20 | |
236 end | |
237 | 831 |
238 # today red line | 832 # today red line |
239 if Date.today >= @date_from and Date.today <= date_to | 833 if Date.today >= @date_from and Date.today <= date_to |
240 gc.stroke('red') | 834 gc.stroke('red') |
241 x = (Date.today-@date_from+1)*zoom + subject_width | 835 x = (Date.today-@date_from+1)*zoom + subject_width |
244 | 838 |
245 gc.draw(imgl) | 839 gc.draw(imgl) |
246 imgl.format = format | 840 imgl.format = format |
247 imgl.to_blob | 841 imgl.to_blob |
248 end if Object.const_defined?(:Magick) | 842 end if Object.const_defined?(:Magick) |
843 | |
844 def to_pdf | |
845 pdf = ::Redmine::Export::PDF::IFPDF.new(current_language) | |
846 pdf.SetTitle("#{l(:label_gantt)} #{project}") | |
847 pdf.AliasNbPages | |
848 pdf.footer_date = format_date(Date.today) | |
849 pdf.AddPage("L") | |
850 pdf.SetFontStyle('B',12) | |
851 pdf.SetX(15) | |
852 pdf.Cell(PDF::LeftPaneWidth, 20, project.to_s) | |
853 pdf.Ln | |
854 pdf.SetFontStyle('B',9) | |
855 | |
856 subject_width = PDF::LeftPaneWidth | |
857 header_heigth = 5 | |
858 | |
859 headers_heigth = header_heigth | |
860 show_weeks = false | |
861 show_days = false | |
862 | |
863 if self.months < 7 | |
864 show_weeks = true | |
865 headers_heigth = 2*header_heigth | |
866 if self.months < 3 | |
867 show_days = true | |
868 headers_heigth = 3*header_heigth | |
869 end | |
870 end | |
871 | |
872 g_width = PDF.right_pane_width | |
873 zoom = (g_width) / (self.date_to - self.date_from + 1) | |
874 g_height = 120 | |
875 t_height = g_height + headers_heigth | |
876 | |
877 y_start = pdf.GetY | |
878 | |
879 # Months headers | |
880 month_f = self.date_from | |
881 left = subject_width | |
882 height = header_heigth | |
883 self.months.times do | |
884 width = ((month_f >> 1) - month_f) * zoom | |
885 pdf.SetY(y_start) | |
886 pdf.SetX(left) | |
887 pdf.Cell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C") | |
888 left = left + width | |
889 month_f = month_f >> 1 | |
890 end | |
891 | |
892 # Weeks headers | |
893 if show_weeks | |
894 left = subject_width | |
895 height = header_heigth | |
896 if self.date_from.cwday == 1 | |
897 # self.date_from is monday | |
898 week_f = self.date_from | |
899 else | |
900 # find next monday after self.date_from | |
901 week_f = self.date_from + (7 - self.date_from.cwday + 1) | |
902 width = (7 - self.date_from.cwday + 1) * zoom-1 | |
903 pdf.SetY(y_start + header_heigth) | |
904 pdf.SetX(left) | |
905 pdf.Cell(width + 1, height, "", "LTR") | |
906 left = left + width+1 | |
907 end | |
908 while week_f <= self.date_to | |
909 width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom | |
910 pdf.SetY(y_start + header_heigth) | |
911 pdf.SetX(left) | |
912 pdf.Cell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C") | |
913 left = left + width | |
914 week_f = week_f+7 | |
915 end | |
916 end | |
917 | |
918 # Days headers | |
919 if show_days | |
920 left = subject_width | |
921 height = header_heigth | |
922 wday = self.date_from.cwday | |
923 pdf.SetFontStyle('B',7) | |
924 (self.date_to - self.date_from + 1).to_i.times do | |
925 width = zoom | |
926 pdf.SetY(y_start + 2 * header_heigth) | |
927 pdf.SetX(left) | |
928 pdf.Cell(width, height, day_name(wday).first, "LTR", 0, "C") | |
929 left = left + width | |
930 wday = wday + 1 | |
931 wday = 1 if wday > 7 | |
932 end | |
933 end | |
934 | |
935 pdf.SetY(y_start) | |
936 pdf.SetX(15) | |
937 pdf.Cell(subject_width+g_width-15, headers_heigth, "", 1) | |
938 | |
939 # Tasks | |
940 top = headers_heigth + y_start | |
941 pdf_subjects_and_lines(pdf, { | |
942 :top => top, | |
943 :zoom => zoom, | |
944 :subject_width => subject_width, | |
945 :g_width => g_width | |
946 }) | |
947 | |
948 | |
949 pdf.Line(15, top, subject_width+g_width, top) | |
950 pdf.Output | |
951 | |
952 | |
953 end | |
249 | 954 |
250 private | 955 private |
251 | 956 |
252 def gantt_issue_compare(x, y, issues) | 957 # Renders both the subjects and lines of the Gantt chart for the |
253 if x.parent_id == y.parent_id | 958 # PDF format |
254 gantt_start_compare(x, y) | 959 def pdf_subjects_and_lines(pdf, options = {}) |
255 elsif x.is_ancestor_of?(y) | 960 subject_options = {:indent => 0, :indent_increment => 5, :top_increment => 3, :render => :subject, :format => :pdf, :pdf => pdf}.merge(options) |
256 -1 | 961 line_options = {:indent => 0, :indent_increment => 5, :top_increment => 3, :render => :line, :format => :pdf, :pdf => pdf}.merge(options) |
257 elsif y.is_ancestor_of?(x) | 962 |
258 1 | 963 if @project |
964 render_project(@project, subject_options) | |
965 render_project(@project, line_options) | |
259 else | 966 else |
260 ax = issues.select {|i| i.is_a?(Issue) && i.is_ancestor_of?(x) && !i.is_ancestor_of?(y) }.sort_by(&:lft).first | 967 Project.roots.each do |project| |
261 ay = issues.select {|i| i.is_a?(Issue) && i.is_ancestor_of?(y) && !i.is_ancestor_of?(x) }.sort_by(&:lft).first | 968 render_project(project, subject_options) |
262 if ax.nil? && ay.nil? | 969 render_project(project, line_options) |
263 gantt_start_compare(x, y) | 970 end |
264 else | 971 end |
265 gantt_issue_compare(ax || x, ay || y, issues) | 972 end |
266 end | 973 |
267 end | |
268 end | |
269 | |
270 def gantt_start_compare(x, y) | |
271 if x.start_date.nil? | |
272 -1 | |
273 elsif y.start_date.nil? | |
274 1 | |
275 else | |
276 x.start_date <=> y.start_date | |
277 end | |
278 end | |
279 end | 974 end |
280 end | 975 end |
281 end | 976 end |