1
|
# Redmine - project management software
|
2
|
# Copyright (C) 2006-2011 Jean-Philippe Lang
|
3
|
#
|
4
|
# This program is free software; you can redistribute it and/or
|
5
|
# modify it under the terms of the GNU General Public License
|
6
|
# as published by the Free Software Foundation; either version 2
|
7
|
# of the License, or (at your option) any later version.
|
8
|
#
|
9
|
# This program is distributed in the hope that it will be useful,
|
10
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
11
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
12
|
# GNU General Public License for more details.
|
13
|
#
|
14
|
# You should have received a copy of the GNU General Public License
|
15
|
# along with this program; if not, write to the Free Software
|
16
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
17
|
|
18
|
module Redmine
|
19
|
module Helpers
|
20
|
# Simple class to handle gantt chart data
|
21
|
class Gantt
|
22
|
include ERB::Util
|
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, :truncated, :max_rows
|
38
|
attr_accessor :query
|
39
|
attr_accessor :project
|
40
|
attr_accessor :view
|
41
|
|
42
|
def initialize(options={})
|
43
|
options = options.dup
|
44
|
|
45
|
if options[:year] && options[:year].to_i >0
|
46
|
@year_from = options[:year].to_i
|
47
|
if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
|
48
|
@month_from = options[:month].to_i
|
49
|
else
|
50
|
@month_from = 1
|
51
|
end
|
52
|
else
|
53
|
@month_from ||= Date.today.month
|
54
|
@year_from ||= Date.today.year
|
55
|
end
|
56
|
|
57
|
zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
|
58
|
@zoom = (zoom > 0 && zoom < 5) ? zoom : 2
|
59
|
months = (options[:months] || User.current.pref[:gantt_months]).to_i
|
60
|
@months = (months > 0 && months < 25) ? months : 6
|
61
|
|
62
|
# Save gantt parameters as user preference (zoom and months count)
|
63
|
if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] || @months != User.current.pref[:gantt_months]))
|
64
|
User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
|
65
|
User.current.preference.save
|
66
|
end
|
67
|
|
68
|
@date_from = Date.civil(@year_from, @month_from, 1)
|
69
|
@date_to = (@date_from >> @months) - 1
|
70
|
|
71
|
@subjects = ''
|
72
|
@lines = ''
|
73
|
@number_of_rows = nil
|
74
|
|
75
|
@issue_ancestors = []
|
76
|
|
77
|
@truncated = false
|
78
|
if options.has_key?(:max_rows)
|
79
|
@max_rows = options[:max_rows]
|
80
|
else
|
81
|
@max_rows = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i
|
82
|
end
|
83
|
end
|
84
|
|
85
|
def common_params
|
86
|
{ :controller => 'gantts', :action => 'show', :project_id => @project }
|
87
|
end
|
88
|
|
89
|
def params
|
90
|
common_params.merge({ :zoom => zoom, :year => year_from, :month => month_from, :months => months })
|
91
|
end
|
92
|
|
93
|
def params_previous
|
94
|
common_params.merge({:year => (date_from << months).year, :month => (date_from << months).month, :zoom => zoom, :months => months })
|
95
|
end
|
96
|
|
97
|
def params_next
|
98
|
common_params.merge({:year => (date_from >> months).year, :month => (date_from >> months).month, :zoom => zoom, :months => months })
|
99
|
end
|
100
|
|
101
|
# Returns the number of rows that will be rendered on the Gantt chart
|
102
|
def number_of_rows
|
103
|
return @number_of_rows if @number_of_rows
|
104
|
|
105
|
rows = projects.inject(0) {|total, p| total += number_of_rows_on_project(p)}
|
106
|
rows > @max_rows ? @max_rows : rows
|
107
|
end
|
108
|
|
109
|
# Returns the number of rows that will be used to list a project on
|
110
|
# the Gantt chart. This will recurse for each subproject.
|
111
|
def number_of_rows_on_project(project)
|
112
|
return 0 unless projects.include?(project)
|
113
|
|
114
|
count = 1
|
115
|
count += project_issues(project).size
|
116
|
count += project_versions(project).size
|
117
|
count
|
118
|
end
|
119
|
|
120
|
# Renders the subjects of the Gantt chart, the left side.
|
121
|
def subjects(options={})
|
122
|
render(options.merge(:only => :subjects)) unless @subjects_rendered
|
123
|
@subjects
|
124
|
end
|
125
|
|
126
|
# Renders the lines of the Gantt chart, the right side
|
127
|
def lines(options={})
|
128
|
render(options.merge(:only => :lines)) unless @lines_rendered
|
129
|
@lines
|
130
|
end
|
131
|
|
132
|
# Returns issues that will be rendered
|
133
|
def issues
|
134
|
@issues ||= @query.issues(
|
135
|
:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
|
136
|
:order => "#{Project.table_name}.lft ASC, #{Issue.table_name}.id ASC",
|
137
|
:limit => @max_rows
|
138
|
)
|
139
|
end
|
140
|
|
141
|
# Return all the project nodes that will be displayed
|
142
|
def projects
|
143
|
return @projects if @projects
|
144
|
|
145
|
ids = issues.collect(&:project).uniq.collect(&:id)
|
146
|
if ids.any?
|
147
|
# All issues projects and their visible ancestors
|
148
|
@projects = Project.visible.all(
|
149
|
:joins => "LEFT JOIN #{Project.table_name} child ON #{Project.table_name}.lft <= child.lft AND #{Project.table_name}.rgt >= child.rgt",
|
150
|
:conditions => ["child.id IN (?)", ids],
|
151
|
:order => "#{Project.table_name}.lft ASC"
|
152
|
).uniq
|
153
|
else
|
154
|
@projects = []
|
155
|
end
|
156
|
end
|
157
|
|
158
|
# Returns the issues that belong to +project+
|
159
|
def project_issues(project)
|
160
|
@issues_by_project ||= issues.group_by(&:project)
|
161
|
@issues_by_project[project] || []
|
162
|
end
|
163
|
|
164
|
# Returns the distinct versions of the issues that belong to +project+
|
165
|
def project_versions(project)
|
166
|
project_issues(project).collect(&:fixed_version).compact.uniq
|
167
|
end
|
168
|
|
169
|
# Returns the issues that belong to +project+ and are assigned to +version+
|
170
|
def version_issues(project, version)
|
171
|
project_issues(project).select {|issue| issue.fixed_version == version}
|
172
|
end
|
173
|
|
174
|
def render(options={})
|
175
|
options = {:top => 0, :top_increment => 20, :indent_increment => 20, :render => :subject, :format => :html}.merge(options)
|
176
|
indent = options[:indent] || 4
|
177
|
|
178
|
@subjects = '' unless options[:only] == :lines
|
179
|
@lines = '' unless options[:only] == :subjects
|
180
|
@number_of_rows = 0
|
181
|
|
182
|
Project.project_tree(projects) do |project, level|
|
183
|
options[:indent] = indent + level * options[:indent_increment]
|
184
|
render_project(project, options)
|
185
|
break if abort?
|
186
|
end
|
187
|
|
188
|
@subjects_rendered = true unless options[:only] == :lines
|
189
|
@lines_rendered = true unless options[:only] == :subjects
|
190
|
|
191
|
render_end(options)
|
192
|
end
|
193
|
|
194
|
def render_project(project, options={})
|
195
|
subject_for_project(project, options) unless options[:only] == :lines
|
196
|
line_for_project(project, options) unless options[:only] == :subjects
|
197
|
|
198
|
options[:top] += options[:top_increment]
|
199
|
options[:indent] += options[:indent_increment]
|
200
|
@number_of_rows += 1
|
201
|
return if abort?
|
202
|
|
203
|
issues = project_issues(project).select {|i| i.fixed_version.nil?}
|
204
|
sort_issues!(issues)
|
205
|
if issues
|
206
|
render_issues(issues, options)
|
207
|
return if abort?
|
208
|
end
|
209
|
|
210
|
versions = project_versions(project)
|
211
|
versions.each do |version|
|
212
|
render_version(project, version, options)
|
213
|
end
|
214
|
|
215
|
# Remove indent to hit the next sibling
|
216
|
options[:indent] -= options[:indent_increment]
|
217
|
end
|
218
|
|
219
|
def render_issues(issues, options={})
|
220
|
@issue_ancestors = []
|
221
|
|
222
|
issues.each do |i|
|
223
|
subject_for_issue(i, options) unless options[:only] == :lines
|
224
|
line_for_issue(i, options) unless options[:only] == :subjects
|
225
|
|
226
|
options[:top] += options[:top_increment]
|
227
|
@number_of_rows += 1
|
228
|
break if abort?
|
229
|
end
|
230
|
|
231
|
options[:indent] -= (options[:indent_increment] * @issue_ancestors.size)
|
232
|
end
|
233
|
|
234
|
def render_version(project, version, options={})
|
235
|
# Version header
|
236
|
subject_for_version(version, options) unless options[:only] == :lines
|
237
|
line_for_version(version, options) unless options[:only] == :subjects
|
238
|
|
239
|
options[:top] += options[:top_increment]
|
240
|
@number_of_rows += 1
|
241
|
return if abort?
|
242
|
|
243
|
issues = version_issues(project, version)
|
244
|
if issues
|
245
|
sort_issues!(issues)
|
246
|
# Indent issues
|
247
|
options[:indent] += options[:indent_increment]
|
248
|
render_issues(issues, options)
|
249
|
options[:indent] -= options[:indent_increment]
|
250
|
end
|
251
|
end
|
252
|
|
253
|
def render_end(options={})
|
254
|
case options[:format]
|
255
|
when :pdf
|
256
|
options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
|
257
|
end
|
258
|
end
|
259
|
|
260
|
def subject_for_project(project, options)
|
261
|
case options[:format]
|
262
|
when :html
|
263
|
subject = "<span class='icon icon-projects #{project.overdue? ? 'project-overdue' : ''}'>"
|
264
|
subject << view.link_to_project(project)
|
265
|
subject << '</span>'
|
266
|
html_subject(options, subject, :css => "project-name")
|
267
|
when :image
|
268
|
image_subject(options, project.name)
|
269
|
when :pdf
|
270
|
pdf_new_page?(options)
|
271
|
pdf_subject(options, project.name)
|
272
|
end
|
273
|
end
|
274
|
|
275
|
def line_for_project(project, options)
|
276
|
# Skip versions that don't have a start_date or due date
|
277
|
if project.is_a?(Project) && project.start_date && project.due_date
|
278
|
options[:zoom] ||= 1
|
279
|
options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
|
280
|
|
281
|
coords = coordinates(project.start_date, project.due_date, nil, options[:zoom])
|
282
|
label = h(project)
|
283
|
|
284
|
case options[:format]
|
285
|
when :html
|
286
|
html_task(options, coords, :css => "project task", :label => label, :markers => true)
|
287
|
when :image
|
288
|
image_task(options, coords, :label => label, :markers => true, :height => 3)
|
289
|
when :pdf
|
290
|
pdf_task(options, coords, :label => label, :markers => true, :height => 0.8)
|
291
|
end
|
292
|
else
|
293
|
ActiveRecord::Base.logger.debug "Gantt#line_for_project was not given a project with a start_date"
|
294
|
''
|
295
|
end
|
296
|
end
|
297
|
|
298
|
def subject_for_version(version, options)
|
299
|
case options[:format]
|
300
|
when :html
|
301
|
subject = "<span class='icon icon-package #{version.behind_schedule? ? 'version-behind-schedule' : ''} #{version.overdue? ? 'version-overdue' : ''}'>"
|
302
|
subject << view.link_to_version(version)
|
303
|
subject << '</span>'
|
304
|
html_subject(options, subject, :css => "version-name")
|
305
|
when :image
|
306
|
image_subject(options, version.to_s_with_project)
|
307
|
when :pdf
|
308
|
pdf_new_page?(options)
|
309
|
pdf_subject(options, version.to_s_with_project)
|
310
|
end
|
311
|
end
|
312
|
|
313
|
def line_for_version(version, options)
|
314
|
# Skip versions that don't have a start_date
|
315
|
if version.is_a?(Version) && version.start_date && version.due_date
|
316
|
options[:zoom] ||= 1
|
317
|
options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
|
318
|
|
319
|
coords = coordinates(version.start_date, version.due_date, version.completed_pourcent, options[:zoom])
|
320
|
label = "#{h version } #{h version.completed_pourcent.to_i.to_s}%"
|
321
|
label = h("#{version.project} -") + label unless @project && @project == version.project
|
322
|
|
323
|
case options[:format]
|
324
|
when :html
|
325
|
html_task(options, coords, :css => "version task", :label => label, :markers => true)
|
326
|
when :image
|
327
|
image_task(options, coords, :label => label, :markers => true, :height => 3)
|
328
|
when :pdf
|
329
|
pdf_task(options, coords, :label => label, :markers => true, :height => 0.8)
|
330
|
end
|
331
|
else
|
332
|
ActiveRecord::Base.logger.debug "Gantt#line_for_version was not given a version with a start_date"
|
333
|
''
|
334
|
end
|
335
|
end
|
336
|
|
337
|
def subject_for_issue(issue, options)
|
338
|
while @issue_ancestors.any? && !issue.is_descendant_of?(@issue_ancestors.last)
|
339
|
@issue_ancestors.pop
|
340
|
options[:indent] -= options[:indent_increment]
|
341
|
end
|
342
|
|
343
|
output = case options[:format]
|
344
|
when :html
|
345
|
css_classes = ''
|
346
|
css_classes << ' issue-overdue' if issue.overdue?
|
347
|
css_classes << ' issue-behind-schedule' if issue.behind_schedule?
|
348
|
css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
|
349
|
|
350
|
subject = "<span class='#{css_classes}'>"
|
351
|
if issue.assigned_to.present?
|
352
|
assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
|
353
|
subject << view.avatar(issue.assigned_to, :class => 'gravatar icon-gravatar', :size => 10, :title => assigned_string).to_s
|
354
|
end
|
355
|
subject << view.link_to_issue(issue)
|
356
|
subject << '</span>'
|
357
|
html_subject(options, subject, :css => "issue-subject", :title => issue.subject) + "\n"
|
358
|
when :image
|
359
|
image_subject(options, issue.subject)
|
360
|
when :pdf
|
361
|
pdf_new_page?(options)
|
362
|
pdf_subject(options, issue.subject)
|
363
|
end
|
364
|
|
365
|
unless issue.leaf?
|
366
|
@issue_ancestors << issue
|
367
|
options[:indent] += options[:indent_increment]
|
368
|
end
|
369
|
|
370
|
output
|
371
|
end
|
372
|
|
373
|
def line_for_issue(issue, options)
|
374
|
# Skip issues that don't have a due_before (due_date or version's due_date)
|
375
|
if issue.is_a?(Issue) && issue.due_before
|
376
|
coords = coordinates(issue.start_date, issue.due_before, issue.done_ratio, options[:zoom])
|
377
|
label = "#{ issue.status.name } #{ issue.done_ratio }%"
|
378
|
|
379
|
case options[:format]
|
380
|
when :html
|
381
|
html_task(options, coords, :css => "task " + (issue.leaf? ? 'leaf' : 'parent'), :label => label, :issue => issue, :markers => !issue.leaf?)
|
382
|
when :image
|
383
|
image_task(options, coords, :label => label)
|
384
|
when :pdf
|
385
|
pdf_task(options, coords, :label => label)
|
386
|
end
|
387
|
else
|
388
|
ActiveRecord::Base.logger.debug "GanttHelper#line_for_issue was not given an issue with a due_before"
|
389
|
''
|
390
|
end
|
391
|
end
|
392
|
|
393
|
# Generates a gantt image
|
394
|
# Only defined if RMagick is avalaible
|
395
|
def to_image(format='PNG')
|
396
|
date_to = (@date_from >> @months)-1
|
397
|
show_weeks = @zoom > 1
|
398
|
show_days = @zoom > 2
|
399
|
|
400
|
subject_width = 400
|
401
|
header_heigth = 18
|
402
|
# width of one day in pixels
|
403
|
zoom = @zoom*2
|
404
|
g_width = (@date_to - @date_from + 1)*zoom
|
405
|
g_height = 20 * number_of_rows + 30
|
406
|
headers_heigth = (show_weeks ? 2*header_heigth : header_heigth)
|
407
|
height = g_height + headers_heigth
|
408
|
|
409
|
imgl = Magick::ImageList.new
|
410
|
imgl.new_image(subject_width+g_width+1, height)
|
411
|
gc = Magick::Draw.new
|
412
|
|
413
|
# Subjects
|
414
|
gc.stroke('transparent')
|
415
|
subjects(:image => gc, :top => (headers_heigth + 20), :indent => 4, :format => :image)
|
416
|
|
417
|
# Months headers
|
418
|
month_f = @date_from
|
419
|
left = subject_width
|
420
|
@months.times do
|
421
|
width = ((month_f >> 1) - month_f) * zoom
|
422
|
gc.fill('white')
|
423
|
gc.stroke('grey')
|
424
|
gc.stroke_width(1)
|
425
|
gc.rectangle(left, 0, left + width, height)
|
426
|
gc.fill('black')
|
427
|
gc.stroke('transparent')
|
428
|
gc.stroke_width(1)
|
429
|
gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
|
430
|
left = left + width
|
431
|
month_f = month_f >> 1
|
432
|
end
|
433
|
|
434
|
# Weeks headers
|
435
|
if show_weeks
|
436
|
left = subject_width
|
437
|
height = header_heigth
|
438
|
if @date_from.cwday == 1
|
439
|
# date_from is monday
|
440
|
week_f = date_from
|
441
|
else
|
442
|
# find next monday after date_from
|
443
|
week_f = @date_from + (7 - @date_from.cwday + 1)
|
444
|
width = (7 - @date_from.cwday + 1) * zoom
|
445
|
gc.fill('white')
|
446
|
gc.stroke('grey')
|
447
|
gc.stroke_width(1)
|
448
|
gc.rectangle(left, header_heigth, left + width, 2*header_heigth + g_height-1)
|
449
|
left = left + width
|
450
|
end
|
451
|
while week_f <= date_to
|
452
|
width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
|
453
|
gc.fill('white')
|
454
|
gc.stroke('grey')
|
455
|
gc.stroke_width(1)
|
456
|
gc.rectangle(left.round, header_heigth, left.round + width, 2*header_heigth + g_height-1)
|
457
|
gc.fill('black')
|
458
|
gc.stroke('transparent')
|
459
|
gc.stroke_width(1)
|
460
|
gc.text(left.round + 2, header_heigth + 14, week_f.cweek.to_s)
|
461
|
left = left + width
|
462
|
week_f = week_f+7
|
463
|
end
|
464
|
end
|
465
|
|
466
|
# Days details (week-end in grey)
|
467
|
if show_days
|
468
|
left = subject_width
|
469
|
height = g_height + header_heigth - 1
|
470
|
wday = @date_from.cwday
|
471
|
(date_to - @date_from + 1).to_i.times do
|
472
|
width = zoom
|
473
|
gc.fill(wday == 6 || wday == 7 ? '#eee' : 'white')
|
474
|
gc.stroke('#ddd')
|
475
|
gc.stroke_width(1)
|
476
|
gc.rectangle(left, 2*header_heigth, left + width, 2*header_heigth + g_height-1)
|
477
|
left = left + width
|
478
|
wday = wday + 1
|
479
|
wday = 1 if wday > 7
|
480
|
end
|
481
|
end
|
482
|
|
483
|
# border
|
484
|
gc.fill('transparent')
|
485
|
gc.stroke('grey')
|
486
|
gc.stroke_width(1)
|
487
|
gc.rectangle(0, 0, subject_width+g_width, headers_heigth)
|
488
|
gc.stroke('black')
|
489
|
gc.rectangle(0, 0, subject_width+g_width, g_height+ headers_heigth-1)
|
490
|
|
491
|
# content
|
492
|
top = headers_heigth + 20
|
493
|
|
494
|
gc.stroke('transparent')
|
495
|
lines(:image => gc, :top => top, :zoom => zoom, :subject_width => subject_width, :format => :image)
|
496
|
|
497
|
# today red line
|
498
|
if Date.today >= @date_from and Date.today <= date_to
|
499
|
gc.stroke('red')
|
500
|
x = (Date.today-@date_from+1)*zoom + subject_width
|
501
|
gc.line(x, headers_heigth, x, headers_heigth + g_height-1)
|
502
|
end
|
503
|
|
504
|
gc.draw(imgl)
|
505
|
imgl.format = format
|
506
|
imgl.to_blob
|
507
|
end if Object.const_defined?(:Magick)
|
508
|
|
509
|
def to_pdf
|
510
|
if l(:general_pdf_encoding).upcase == 'UTF-8'
|
511
|
pdf = ::Redmine::Export::PDF::IFPDF.new(current_language)
|
512
|
else
|
513
|
pdf = ::Redmine::Export::PDF::IFPDF.new(current_language)
|
514
|
end
|
515
|
pdf.SetTitle("#{l(:label_gantt)} #{project}")
|
516
|
pdf.alias_nb_pages
|
517
|
pdf.footer_date = format_date(Date.today)
|
518
|
pdf.AddPage("L")
|
519
|
pdf.SetFontStyle('B',12)
|
520
|
pdf.SetX(15)
|
521
|
pdf.UTF8Cell(PDF::LeftPaneWidth, 20, project.to_s)
|
522
|
pdf.Ln
|
523
|
pdf.SetFontStyle('B',9)
|
524
|
|
525
|
subject_width = PDF::LeftPaneWidth
|
526
|
header_heigth = 5
|
527
|
|
528
|
headers_heigth = header_heigth
|
529
|
show_weeks = false
|
530
|
show_days = false
|
531
|
|
532
|
if self.months < 7
|
533
|
show_weeks = true
|
534
|
headers_heigth = 2*header_heigth
|
535
|
if self.months < 3
|
536
|
show_days = true
|
537
|
headers_heigth = 3*header_heigth
|
538
|
end
|
539
|
end
|
540
|
|
541
|
g_width = PDF.right_pane_width
|
542
|
zoom = (g_width) / (self.date_to - self.date_from + 1)
|
543
|
g_height = 120
|
544
|
t_height = g_height + headers_heigth
|
545
|
|
546
|
y_start = pdf.GetY
|
547
|
|
548
|
# Months headers
|
549
|
month_f = self.date_from
|
550
|
left = subject_width
|
551
|
height = header_heigth
|
552
|
self.months.times do
|
553
|
width = ((month_f >> 1) - month_f) * zoom
|
554
|
pdf.SetY(y_start)
|
555
|
pdf.SetX(left)
|
556
|
pdf.UTF8Cell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
|
557
|
left = left + width
|
558
|
month_f = month_f >> 1
|
559
|
end
|
560
|
|
561
|
# Weeks headers
|
562
|
if show_weeks
|
563
|
left = subject_width
|
564
|
height = header_heigth
|
565
|
if self.date_from.cwday == 1
|
566
|
# self.date_from is monday
|
567
|
week_f = self.date_from
|
568
|
else
|
569
|
# find next monday after self.date_from
|
570
|
week_f = self.date_from + (7 - self.date_from.cwday + 1)
|
571
|
width = (7 - self.date_from.cwday + 1) * zoom-1
|
572
|
pdf.SetY(y_start + header_heigth)
|
573
|
pdf.SetX(left)
|
574
|
pdf.UTF8Cell(width + 1, height, "", "LTR")
|
575
|
left = left + width+1
|
576
|
end
|
577
|
while week_f <= self.date_to
|
578
|
width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
|
579
|
pdf.SetY(y_start + header_heigth)
|
580
|
pdf.SetX(left)
|
581
|
pdf.UTF8Cell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
|
582
|
left = left + width
|
583
|
week_f = week_f+7
|
584
|
end
|
585
|
end
|
586
|
|
587
|
# Days headers
|
588
|
if show_days
|
589
|
left = subject_width
|
590
|
height = header_heigth
|
591
|
wday = self.date_from.cwday
|
592
|
pdf.SetFontStyle('B',7)
|
593
|
(self.date_to - self.date_from + 1).to_i.times do
|
594
|
width = zoom
|
595
|
pdf.SetY(y_start + 2 * header_heigth)
|
596
|
pdf.SetX(left)
|
597
|
pdf.UTF8Cell(width, height, day_name(wday).first, "LTR", 0, "C")
|
598
|
left = left + width
|
599
|
wday = wday + 1
|
600
|
wday = 1 if wday > 7
|
601
|
end
|
602
|
end
|
603
|
|
604
|
pdf.SetY(y_start)
|
605
|
pdf.SetX(15)
|
606
|
pdf.UTF8Cell(subject_width+g_width-15, headers_heigth, "", 1)
|
607
|
|
608
|
# Tasks
|
609
|
top = headers_heigth + y_start
|
610
|
options = {
|
611
|
:top => top,
|
612
|
:zoom => zoom,
|
613
|
:subject_width => subject_width,
|
614
|
:g_width => g_width,
|
615
|
:indent => 0,
|
616
|
:indent_increment => 5,
|
617
|
:top_increment => 5,
|
618
|
:format => :pdf,
|
619
|
:pdf => pdf
|
620
|
}
|
621
|
render(options)
|
622
|
pdf.Output
|
623
|
end
|
624
|
|
625
|
private
|
626
|
|
627
|
def coordinates(start_date, end_date, progress, zoom=nil)
|
628
|
zoom ||= @zoom
|
629
|
|
630
|
coords = {}
|
631
|
if start_date && end_date && start_date < self.date_to && end_date > self.date_from
|
632
|
if start_date > self.date_from
|
633
|
coords[:start] = start_date - self.date_from
|
634
|
coords[:bar_start] = start_date - self.date_from
|
635
|
else
|
636
|
coords[:bar_start] = 0
|
637
|
end
|
638
|
if end_date < self.date_to
|
639
|
coords[:end] = end_date - self.date_from
|
640
|
coords[:bar_end] = end_date - self.date_from + 1
|
641
|
else
|
642
|
coords[:bar_end] = self.date_to - self.date_from + 1
|
643
|
end
|
644
|
|
645
|
if progress
|
646
|
progress_date = start_date + (end_date - start_date) * (progress / 100.0)
|
647
|
if progress_date > self.date_from && progress_date > start_date
|
648
|
if progress_date < self.date_to
|
649
|
coords[:bar_progress_end] = progress_date - self.date_from + 1
|
650
|
else
|
651
|
coords[:bar_progress_end] = self.date_to - self.date_from + 1
|
652
|
end
|
653
|
end
|
654
|
|
655
|
if progress_date < Date.today
|
656
|
late_date = [Date.today, end_date].min
|
657
|
if late_date > self.date_from && late_date > start_date
|
658
|
if late_date < self.date_to
|
659
|
coords[:bar_late_end] = late_date - self.date_from + 1
|
660
|
else
|
661
|
coords[:bar_late_end] = self.date_to - self.date_from + 1
|
662
|
end
|
663
|
end
|
664
|
end
|
665
|
end
|
666
|
end
|
667
|
|
668
|
# Transforms dates into pixels witdh
|
669
|
coords.keys.each do |key|
|
670
|
coords[key] = (coords[key] * zoom).floor
|
671
|
end
|
672
|
coords
|
673
|
end
|
674
|
|
675
|
# Sorts a collection of issues by start_date, due_date, id for gantt rendering
|
676
|
def sort_issues!(issues)
|
677
|
issues.sort! { |a, b| gantt_issue_compare(a, b, issues) }
|
678
|
end
|
679
|
|
680
|
# TODO: top level issues should be sorted by start date
|
681
|
def gantt_issue_compare(x, y, issues)
|
682
|
if x.root_id == y.root_id
|
683
|
x.lft <=> y.lft
|
684
|
else
|
685
|
x.root_id <=> y.root_id
|
686
|
end
|
687
|
end
|
688
|
|
689
|
def current_limit
|
690
|
if @max_rows
|
691
|
@max_rows - @number_of_rows
|
692
|
else
|
693
|
nil
|
694
|
end
|
695
|
end
|
696
|
|
697
|
def abort?
|
698
|
if @max_rows && @number_of_rows >= @max_rows
|
699
|
@truncated = true
|
700
|
end
|
701
|
end
|
702
|
|
703
|
def pdf_new_page?(options)
|
704
|
if options[:top] > 180
|
705
|
options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
|
706
|
options[:pdf].AddPage("L")
|
707
|
options[:top] = 15
|
708
|
options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
|
709
|
end
|
710
|
end
|
711
|
|
712
|
def html_subject(params, subject, options={})
|
713
|
style = "position: absolute;top:#{params[:top]}px;left:#{params[:indent]}px;"
|
714
|
style << "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width]
|
715
|
|
716
|
output = view.content_tag 'div', subject, :class => options[:css], :style => style, :title => options[:title]
|
717
|
@subjects << output
|
718
|
output
|
719
|
end
|
720
|
|
721
|
def pdf_subject(params, subject, options={})
|
722
|
params[:pdf].SetY(params[:top])
|
723
|
params[:pdf].SetX(15)
|
724
|
|
725
|
char_limit = PDF::MaxCharactorsForSubject - params[:indent]
|
726
|
params[:pdf].UTF8Cell(params[:subject_width]-15, 5, (" " * params[:indent]) + subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR")
|
727
|
|
728
|
params[:pdf].SetY(params[:top])
|
729
|
params[:pdf].SetX(params[:subject_width])
|
730
|
params[:pdf].UTF8Cell(params[:g_width], 5, "", "LR")
|
731
|
end
|
732
|
|
733
|
def image_subject(params, subject, options={})
|
734
|
params[:image].fill('black')
|
735
|
params[:image].stroke('transparent')
|
736
|
params[:image].stroke_width(1)
|
737
|
params[:image].text(params[:indent], params[:top] + 2, subject)
|
738
|
end
|
739
|
|
740
|
def html_task(params, coords, options={})
|
741
|
output = ''
|
742
|
# Renders the task bar, with progress and late
|
743
|
if coords[:bar_start] && coords[:bar_end]
|
744
|
output << "<div style='top:#{ params[:top] }px;left:#{ coords[:bar_start] }px;width:#{ coords[:bar_end] - coords[:bar_start] - 2}px;' class='#{options[:css]} task_todo'> </div>"
|
745
|
|
746
|
if coords[:bar_late_end]
|
747
|
output << "<div style='top:#{ params[:top] }px;left:#{ coords[:bar_start] }px;width:#{ coords[:bar_late_end] - coords[:bar_start] - 2}px;' class='#{options[:css]} task_late'> </div>"
|
748
|
end
|
749
|
if coords[:bar_progress_end]
|
750
|
output << "<div style='top:#{ params[:top] }px;left:#{ coords[:bar_start] }px;width:#{ coords[:bar_progress_end] - coords[:bar_start] - 2}px;' class='#{options[:css]} task_done'> </div>"
|
751
|
end
|
752
|
end
|
753
|
# Renders the markers
|
754
|
if options[:markers]
|
755
|
if coords[:start]
|
756
|
output << "<div style='top:#{ params[:top] }px;left:#{ coords[:start] }px;width:15px;' class='#{options[:css]} marker starting'> </div>"
|
757
|
end
|
758
|
if coords[:end]
|
759
|
output << "<div style='top:#{ params[:top] }px;left:#{ coords[:end] + params[:zoom] }px;width:15px;' class='#{options[:css]} marker ending'> </div>"
|
760
|
end
|
761
|
end
|
762
|
# Renders the label on the right
|
763
|
if options[:label]
|
764
|
output << "<div style='top:#{ params[:top] }px;left:#{ (coords[:bar_end] || 0) + 8 }px;' class='#{options[:css]} label'>"
|
765
|
output << options[:label]
|
766
|
output << "</div>"
|
767
|
end
|
768
|
# Renders the tooltip
|
769
|
if options[:issue] && coords[:bar_start] && coords[:bar_end]
|
770
|
output << "<div class='tooltip' style='position: absolute;top:#{ params[:top] }px;left:#{ coords[:bar_start] }px;width:#{ coords[:bar_end] - coords[:bar_start] }px;height:12px;'>"
|
771
|
output << '<span class="tip">'
|
772
|
output << view.render_issue_tooltip(options[:issue])
|
773
|
output << "</span></div>"
|
774
|
end
|
775
|
@lines << output
|
776
|
output
|
777
|
end
|
778
|
|
779
|
def pdf_task(params, coords, options={})
|
780
|
height = options[:height] || 2
|
781
|
|
782
|
# Renders the task bar, with progress and late
|
783
|
if coords[:bar_start] && coords[:bar_end]
|
784
|
params[:pdf].SetY(params[:top]+1.5)
|
785
|
params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
|
786
|
params[:pdf].SetFillColor(200,200,200)
|
787
|
params[:pdf].UTF8Cell(coords[:bar_end] - coords[:bar_start], height, "", 0, 0, "", 1)
|
788
|
|
789
|
if coords[:bar_late_end]
|
790
|
params[:pdf].SetY(params[:top]+1.5)
|
791
|
params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
|
792
|
params[:pdf].SetFillColor(255,100,100)
|
793
|
params[:pdf].UTF8Cell(coords[:bar_late_end] - coords[:bar_start], height, "", 0, 0, "", 1)
|
794
|
end
|
795
|
if coords[:bar_progress_end]
|
796
|
params[:pdf].SetY(params[:top]+1.5)
|
797
|
params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
|
798
|
params[:pdf].SetFillColor(90,200,90)
|
799
|
params[:pdf].UTF8Cell(coords[:bar_progress_end] - coords[:bar_start], height, "", 0, 0, "", 1)
|
800
|
end
|
801
|
end
|
802
|
# Renders the markers
|
803
|
if options[:markers]
|
804
|
if coords[:start]
|
805
|
params[:pdf].SetY(params[:top] + 1)
|
806
|
params[:pdf].SetX(params[:subject_width] + coords[:start] - 1)
|
807
|
params[:pdf].SetFillColor(50,50,200)
|
808
|
params[:pdf].UTF8Cell(2, 2, "", 0, 0, "", 1)
|
809
|
end
|
810
|
if coords[:end]
|
811
|
params[:pdf].SetY(params[:top] + 1)
|
812
|
params[:pdf].SetX(params[:subject_width] + coords[:end] - 1)
|
813
|
params[:pdf].SetFillColor(50,50,200)
|
814
|
params[:pdf].UTF8Cell(2, 2, "", 0, 0, "", 1)
|
815
|
end
|
816
|
end
|
817
|
# Renders the label on the right
|
818
|
if options[:label]
|
819
|
params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5)
|
820
|
params[:pdf].UTF8Cell(30, 2, options[:label])
|
821
|
end
|
822
|
end
|
823
|
|
824
|
def image_task(params, coords, options={})
|
825
|
height = options[:height] || 6
|
826
|
|
827
|
# Renders the task bar, with progress and late
|
828
|
if coords[:bar_start] && coords[:bar_end]
|
829
|
params[:image].fill('#aaa')
|
830
|
params[:image].rectangle(params[:subject_width] + coords[:bar_start], params[:top], params[:subject_width] + coords[:bar_end], params[:top] - height)
|
831
|
|
832
|
if coords[:bar_late_end]
|
833
|
params[:image].fill('#f66')
|
834
|
params[:image].rectangle(params[:subject_width] + coords[:bar_start], params[:top], params[:subject_width] + coords[:bar_late_end], params[:top] - height)
|
835
|
end
|
836
|
if coords[:bar_progress_end]
|
837
|
params[:image].fill('#00c600')
|
838
|
params[:image].rectangle(params[:subject_width] + coords[:bar_start], params[:top], params[:subject_width] + coords[:bar_progress_end], params[:top] - height)
|
839
|
end
|
840
|
end
|
841
|
# Renders the markers
|
842
|
if options[:markers]
|
843
|
if coords[:start]
|
844
|
x = params[:subject_width] + coords[:start]
|
845
|
y = params[:top] - height / 2
|
846
|
params[:image].fill('blue')
|
847
|
params[:image].polygon(x-4, y, x, y-4, x+4, y, x, y+4)
|
848
|
end
|
849
|
if coords[:end]
|
850
|
x = params[:subject_width] + coords[:end] + params[:zoom]
|
851
|
y = params[:top] - height / 2
|
852
|
params[:image].fill('blue')
|
853
|
params[:image].polygon(x-4, y, x, y-4, x+4, y, x, y+4)
|
854
|
end
|
855
|
end
|
856
|
# Renders the label on the right
|
857
|
if options[:label]
|
858
|
params[:image].fill('black')
|
859
|
params[:image].text(params[:subject_width] + (coords[:bar_end] || 0) + 5,params[:top] + 1, options[:label])
|
860
|
end
|
861
|
end
|
862
|
end
|
863
|
end
|
864
|
end
|