You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

gantt.rb 41KB

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