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

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088
  1. # frozen_string_literal: true
  2. # Redmine - project management software
  3. # Copyright (C) 2006-2023 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,
  285. version.visible_fixed_issues.completed_percent,
  286. true, label, options, version)
  287. end
  288. end
  289. def subject_for_issue(issue, options)
  290. subject(issue.subject, options, issue)
  291. end
  292. def line_for_issue(issue, options)
  293. # Skip issues that don't have a due_before (due_date or version's due_date)
  294. if issue.is_a?(Issue) && issue.due_before
  295. label = issue.status.name.dup
  296. unless issue.disabled_core_fields.include?('done_ratio')
  297. label << " #{issue.done_ratio}%"
  298. end
  299. markers = !issue.leaf?
  300. line(issue.start_date, issue.due_before, issue.done_ratio, markers, label, options, issue)
  301. end
  302. end
  303. def column_content_for_issue(issue, options)
  304. if options[:format] == :html
  305. data_options = {}
  306. data_options[:collapse_expand] = "issue-#{issue.id}"
  307. data_options[:number_of_rows] = number_of_rows
  308. style = "position: absolute;top: #{options[:top]}px; font-size: 0.8em;"
  309. content =
  310. view.content_tag(
  311. :div, view.column_content(options[:column], issue),
  312. :style => style, :class => "issue_#{options[:column].name}",
  313. :id => "#{options[:column].name}_issue_#{issue.id}",
  314. :data => data_options
  315. )
  316. @columns[options[:column].name] << content if @columns.has_key?(options[:column].name)
  317. content
  318. end
  319. end
  320. def subject(label, options, object=nil)
  321. send "#{options[:format]}_subject", options, label, object
  322. end
  323. def line(start_date, end_date, done_ratio, markers, label, options, object=nil)
  324. options[:zoom] ||= 1
  325. options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
  326. coords = coordinates(start_date, end_date, done_ratio, options[:zoom])
  327. send "#{options[:format]}_task", options, coords, markers, label, object
  328. end
  329. # Generates a gantt image
  330. # Only defined if MiniMagick is avalaible
  331. def to_image(format='PNG')
  332. date_to = (@date_from >> @months) - 1
  333. show_weeks = @zoom > 1
  334. show_days = @zoom > 2
  335. subject_width = 400
  336. header_height = 18
  337. # width of one day in pixels
  338. zoom = @zoom * 2
  339. g_width = (@date_to - @date_from + 1) * zoom
  340. g_height = 20 * number_of_rows + 30
  341. headers_height = (show_weeks ? 2 * header_height : header_height)
  342. height = g_height + headers_height
  343. # TODO: Remove rmagick_font_path in a later version
  344. unless Redmine::Configuration['rmagick_font_path'].nil?
  345. Rails.logger.warn(
  346. 'rmagick_font_path option is deprecated. Use minimagick_font_path instead.'
  347. )
  348. end
  349. font_path =
  350. Redmine::Configuration['minimagick_font_path'].presence ||
  351. Redmine::Configuration['rmagick_font_path'].presence
  352. img = MiniMagick::Image.create(".#{format}", false)
  353. if Redmine::Configuration['imagemagick_convert_command'].present?
  354. MiniMagick.cli_path = File.dirname(Redmine::Configuration['imagemagick_convert_command'])
  355. end
  356. MiniMagick::Tool::Convert.new do |gc|
  357. gc.size('%dx%d' % [subject_width + g_width + 1, height])
  358. gc.xc('white')
  359. gc.font(font_path) if font_path.present?
  360. # Subjects
  361. gc.stroke('transparent')
  362. subjects(:image => gc, :top => (headers_height + 20), :indent => 4, :format => :image)
  363. # Months headers
  364. month_f = @date_from
  365. left = subject_width
  366. @months.times do
  367. width = ((month_f >> 1) - month_f) * zoom
  368. gc.fill('white')
  369. gc.stroke('grey')
  370. gc.strokewidth(1)
  371. gc.draw('rectangle %d,%d %d,%d' % [
  372. left, 0, left + width, height
  373. ])
  374. gc.fill('black')
  375. gc.stroke('transparent')
  376. gc.strokewidth(1)
  377. gc.draw('text %d,%d %s' % [
  378. left.round + 8, 14, magick_text("#{month_f.year}-#{month_f.month}")
  379. ])
  380. left = left + width
  381. month_f = month_f >> 1
  382. end
  383. # Weeks headers
  384. if show_weeks
  385. left = subject_width
  386. height = header_height
  387. if @date_from.cwday == 1
  388. # date_from is monday
  389. week_f = date_from
  390. else
  391. # find next monday after date_from
  392. week_f = @date_from + (7 - @date_from.cwday + 1)
  393. width = (7 - @date_from.cwday + 1) * zoom
  394. gc.fill('white')
  395. gc.stroke('grey')
  396. gc.strokewidth(1)
  397. gc.draw('rectangle %d,%d %d,%d' % [
  398. left, header_height, left + width, 2 * header_height + g_height - 1
  399. ])
  400. left = left + width
  401. end
  402. while week_f <= date_to
  403. width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
  404. gc.fill('white')
  405. gc.stroke('grey')
  406. gc.strokewidth(1)
  407. gc.draw('rectangle %d,%d %d,%d' % [
  408. left.round, header_height, left.round + width, 2 * header_height + g_height - 1
  409. ])
  410. gc.fill('black')
  411. gc.stroke('transparent')
  412. gc.strokewidth(1)
  413. gc.draw('text %d,%d %s' % [
  414. left.round + 2, header_height + 14, magick_text(week_f.cweek.to_s)
  415. ])
  416. left = left + width
  417. week_f = week_f + 7
  418. end
  419. end
  420. # Days details (week-end in grey)
  421. if show_days
  422. left = subject_width
  423. height = g_height + header_height - 1
  424. (@date_from..date_to).each do |g_date|
  425. width = zoom
  426. gc.fill(non_working_week_days.include?(g_date.cwday) ? '#eee' : 'white')
  427. gc.stroke('#ddd')
  428. gc.strokewidth(1)
  429. gc.draw('rectangle %d,%d %d,%d' % [
  430. left, 2 * header_height, left + width, 2 * header_height + g_height - 1
  431. ])
  432. left = left + width
  433. end
  434. end
  435. # border
  436. gc.fill('transparent')
  437. gc.stroke('grey')
  438. gc.strokewidth(1)
  439. gc.draw('rectangle %d,%d %d,%d' % [
  440. 0, 0, subject_width + g_width, headers_height
  441. ])
  442. gc.stroke('black')
  443. gc.draw('rectangle %d,%d %d,%d' % [
  444. 0, 0, subject_width + g_width, g_height + headers_height - 1
  445. ])
  446. # content
  447. top = headers_height + 20
  448. gc.stroke('transparent')
  449. lines(:image => gc, :top => top, :zoom => zoom,
  450. :subject_width => subject_width, :format => :image)
  451. # today red line
  452. if User.current.today >= @date_from and User.current.today <= date_to
  453. gc.stroke('red')
  454. x = (User.current.today - @date_from + 1) * zoom + subject_width
  455. gc.draw('line %g,%g %g,%g' % [
  456. x, headers_height, x, headers_height + g_height - 1
  457. ])
  458. end
  459. gc << img.path
  460. end
  461. img.to_blob
  462. ensure
  463. img.destroy! if img
  464. end if Object.const_defined?(:MiniMagick)
  465. def to_pdf
  466. pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language)
  467. pdf.SetTitle("#{l(:label_gantt)} #{project}")
  468. pdf.alias_nb_pages
  469. pdf.footer_date = format_date(User.current.today)
  470. pdf.AddPage("L")
  471. pdf.SetFontStyle('B', 12)
  472. pdf.SetX(15)
  473. pdf.RDMCell(PDF::LeftPaneWidth, 20, project.to_s)
  474. pdf.Ln
  475. pdf.SetFontStyle('B', 9)
  476. subject_width = PDF::LeftPaneWidth
  477. header_height = 5
  478. headers_height = header_height
  479. show_weeks = false
  480. show_days = false
  481. if self.months < 7
  482. show_weeks = true
  483. headers_height = 2 * header_height
  484. if self.months < 3
  485. show_days = true
  486. headers_height = 3 * header_height
  487. if self.months < 2
  488. show_day_num = true
  489. headers_height = 4 * header_height
  490. end
  491. end
  492. end
  493. g_width = PDF.right_pane_width
  494. zoom = g_width / (self.date_to - self.date_from + 1)
  495. g_height = 120
  496. t_height = g_height + headers_height
  497. y_start = pdf.GetY
  498. # Months headers
  499. month_f = self.date_from
  500. left = subject_width
  501. height = header_height
  502. self.months.times do
  503. width = ((month_f >> 1) - month_f) * zoom
  504. pdf.SetY(y_start)
  505. pdf.SetX(left)
  506. pdf.RDMCell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
  507. left = left + width
  508. month_f = month_f >> 1
  509. end
  510. # Weeks headers
  511. if show_weeks
  512. left = subject_width
  513. height = header_height
  514. if self.date_from.cwday == 1
  515. # self.date_from is monday
  516. week_f = self.date_from
  517. else
  518. # find next monday after self.date_from
  519. week_f = self.date_from + (7 - self.date_from.cwday + 1)
  520. width = (7 - self.date_from.cwday + 1) * zoom-1
  521. pdf.SetY(y_start + header_height)
  522. pdf.SetX(left)
  523. pdf.RDMCell(width + 1, height, "", "LTR")
  524. left = left + width + 1
  525. end
  526. while week_f <= self.date_to
  527. width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
  528. pdf.SetY(y_start + header_height)
  529. pdf.SetX(left)
  530. pdf.RDMCell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
  531. left = left + width
  532. week_f = week_f + 7
  533. end
  534. end
  535. # Day numbers headers
  536. if show_day_num
  537. left = subject_width
  538. height = header_height
  539. day_num = self.date_from
  540. pdf.SetFontStyle('B', 7)
  541. (self.date_from..self.date_to).each do |g_date|
  542. width = zoom
  543. pdf.SetY(y_start + header_height * 2)
  544. pdf.SetX(left)
  545. pdf.SetTextColor(non_working_week_days.include?(g_date.cwday) ? 150 : 0)
  546. pdf.RDMCell(width, height, day_num.day.to_s, "LTR", 0, "C")
  547. left = left + width
  548. day_num = day_num + 1
  549. end
  550. end
  551. # Days headers
  552. if show_days
  553. left = subject_width
  554. height = header_height
  555. pdf.SetFontStyle('B', 7)
  556. (self.date_from..self.date_to).each do |g_date|
  557. width = zoom
  558. pdf.SetY(y_start + header_height * (show_day_num ? 3 : 2))
  559. pdf.SetX(left)
  560. pdf.SetTextColor(non_working_week_days.include?(g_date.cwday) ? 150 : 0)
  561. pdf.RDMCell(width, height, day_name(g_date.cwday).first, "LTR", 0, "C")
  562. left = left + width
  563. end
  564. end
  565. pdf.SetY(y_start)
  566. pdf.SetX(15)
  567. pdf.SetTextColor(0)
  568. pdf.RDMCell(subject_width + g_width - 15, headers_height, "", 1)
  569. # Tasks
  570. top = headers_height + y_start
  571. options = {
  572. :top => top,
  573. :zoom => zoom,
  574. :subject_width => subject_width,
  575. :g_width => g_width,
  576. :indent => 0,
  577. :indent_increment => 5,
  578. :top_increment => 5,
  579. :format => :pdf,
  580. :pdf => pdf
  581. }
  582. render(options)
  583. pdf.Output
  584. end
  585. private
  586. def coordinates(start_date, end_date, progress, zoom=nil)
  587. zoom ||= @zoom
  588. coords = {}
  589. if start_date && end_date && start_date <= self.date_to && end_date >= self.date_from
  590. if start_date >= self.date_from
  591. coords[:start] = start_date - self.date_from
  592. coords[:bar_start] = start_date - self.date_from
  593. else
  594. coords[:bar_start] = 0
  595. end
  596. if end_date <= self.date_to
  597. coords[:end] = end_date - self.date_from + 1
  598. coords[:bar_end] = end_date - self.date_from + 1
  599. else
  600. coords[:bar_end] = self.date_to - self.date_from + 1
  601. end
  602. if progress
  603. progress_date = calc_progress_date(start_date, end_date, progress)
  604. if progress_date > self.date_from && progress_date > start_date
  605. if progress_date < self.date_to
  606. coords[:bar_progress_end] = progress_date - self.date_from
  607. else
  608. coords[:bar_progress_end] = self.date_to - self.date_from + 1
  609. end
  610. end
  611. if progress_date <= User.current.today
  612. late_date = [User.current.today, end_date].min + 1
  613. if late_date > self.date_from && late_date > start_date
  614. if late_date < self.date_to
  615. coords[:bar_late_end] = late_date - self.date_from
  616. else
  617. coords[:bar_late_end] = self.date_to - self.date_from + 1
  618. end
  619. end
  620. end
  621. end
  622. end
  623. # Transforms dates into pixels witdh
  624. coords.each_key do |key|
  625. coords[key] = (coords[key] * zoom).floor
  626. end
  627. coords
  628. end
  629. def calc_progress_date(start_date, end_date, progress)
  630. start_date + (end_date - start_date + 1) * (progress / 100.0)
  631. end
  632. # Singleton class method is public
  633. class << self
  634. def sort_issues!(issues)
  635. issues.sort_by! {|issue| sort_issue_logic(issue)}
  636. end
  637. def sort_issue_logic(issue)
  638. julian_date = Date.new
  639. ancesters_start_date = []
  640. current_issue = issue
  641. begin
  642. ancesters_start_date.unshift([current_issue.start_date || julian_date, current_issue.id])
  643. current_issue = current_issue.parent
  644. end while (current_issue)
  645. ancesters_start_date
  646. end
  647. def sort_versions!(versions)
  648. versions.sort!
  649. end
  650. end
  651. def pdf_new_page?(options)
  652. if options[:top] > 180
  653. options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
  654. options[:pdf].AddPage("L")
  655. options[:top] = 15
  656. options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
  657. end
  658. end
  659. def html_subject_content(object)
  660. case object
  661. when Issue
  662. issue = object
  663. css_classes = +''
  664. css_classes << ' issue-overdue' if issue.overdue?
  665. css_classes << ' issue-behind-schedule' if issue.behind_schedule?
  666. css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
  667. css_classes << ' issue-closed' if issue.closed?
  668. if issue.start_date && issue.due_before && issue.done_ratio
  669. progress_date = calc_progress_date(issue.start_date,
  670. issue.due_before, issue.done_ratio)
  671. css_classes << ' behind-start-date' if progress_date < self.date_from
  672. css_classes << ' over-end-date' if progress_date > self.date_to
  673. end
  674. s = (+"").html_safe
  675. s << view.assignee_avatar(issue.assigned_to, :size => 13, :class => 'icon-gravatar')
  676. s << view.link_to_issue(issue).html_safe
  677. s << view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]',
  678. :value => issue.id, :style => 'display:none;',
  679. :class => 'toggle-selection')
  680. view.content_tag(:span, s, :class => css_classes).html_safe
  681. when Version
  682. version = object
  683. html_class = +""
  684. html_class << 'icon icon-package '
  685. html_class << (version.behind_schedule? ? 'version-behind-schedule' : '') << " "
  686. html_class << (version.overdue? ? 'version-overdue' : '')
  687. html_class << ' version-closed' unless version.open?
  688. if version.start_date && version.due_date && version.visible_fixed_issues.completed_percent
  689. progress_date = calc_progress_date(version.start_date,
  690. version.due_date, version.visible_fixed_issues.completed_percent)
  691. html_class << ' behind-start-date' if progress_date < self.date_from
  692. html_class << ' over-end-date' if progress_date > self.date_to
  693. end
  694. s = view.link_to_version(version).html_safe
  695. view.content_tag(:span, s, :class => html_class).html_safe
  696. when Project
  697. project = object
  698. html_class = +""
  699. html_class << 'icon icon-projects '
  700. html_class << (project.overdue? ? 'project-overdue' : '')
  701. s = view.link_to_project(project).html_safe
  702. view.content_tag(:span, s, :class => html_class).html_safe
  703. end
  704. end
  705. def html_subject(params, subject, object)
  706. content = html_subject_content(object) || subject
  707. tag_options = {}
  708. case object
  709. when Issue
  710. tag_options[:id] = "issue-#{object.id}"
  711. tag_options[:class] = "issue-subject hascontextmenu"
  712. tag_options[:title] = object.subject
  713. children = object.children & project_issues(object.project)
  714. has_children =
  715. children.present? &&
  716. (children.collect(&:fixed_version).uniq & [object.fixed_version]).present?
  717. when Version
  718. tag_options[:id] = "version-#{object.id}"
  719. tag_options[:class] = "version-name"
  720. has_children = object.fixed_issues.exists?
  721. when Project
  722. tag_options[:class] = "project-name"
  723. has_children = object.issues.exists? || object.versions.exists?
  724. end
  725. if object
  726. tag_options[:data] = {
  727. :collapse_expand => {
  728. :top_increment => params[:top_increment],
  729. :obj_id => "#{object.class}-#{object.id}".downcase,
  730. },
  731. :number_of_rows => number_of_rows,
  732. }
  733. end
  734. if has_children
  735. content = view.content_tag(:span, nil, :class => 'icon icon-expended expander') + content
  736. tag_options[:class] += ' open'
  737. else
  738. if params[:indent]
  739. params = params.dup
  740. params[:indent] += 12
  741. end
  742. end
  743. style = "position: absolute;top:#{params[:top]}px;left:#{params[:indent]}px;"
  744. style += "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width]
  745. tag_options[:style] = style
  746. output = view.content_tag(:div, content, tag_options)
  747. @subjects << output
  748. output
  749. end
  750. def pdf_subject(params, subject, options={})
  751. pdf_new_page?(params)
  752. params[:pdf].SetY(params[:top])
  753. params[:pdf].SetX(15)
  754. char_limit = PDF::MaxCharactorsForSubject - params[:indent]
  755. params[:pdf].RDMCell(params[:subject_width] - 15, 5,
  756. (" " * params[:indent]) +
  757. subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'),
  758. "LR")
  759. params[:pdf].SetY(params[:top])
  760. params[:pdf].SetX(params[:subject_width])
  761. params[:pdf].RDMCell(params[:g_width], 5, "", "LR")
  762. end
  763. def image_subject(params, subject, options={})
  764. params[:image].fill('black')
  765. params[:image].stroke('transparent')
  766. params[:image].strokewidth(1)
  767. params[:image].draw('text %d,%d %s' % [
  768. params[:indent], params[:top] + 2, magick_text(subject)
  769. ])
  770. end
  771. def issue_relations(issue)
  772. rels = {}
  773. if relations[issue.id]
  774. relations[issue.id].each do |relation|
  775. (rels[relation.relation_type] ||= []) << relation.issue_to_id
  776. end
  777. end
  778. rels
  779. end
  780. def html_task(params, coords, markers, label, object)
  781. output = +''
  782. data_options = {}
  783. if object
  784. data_options[:collapse_expand] = "#{object.class}-#{object.id}".downcase
  785. data_options[:number_of_rows] = number_of_rows
  786. end
  787. css = "task " +
  788. case object
  789. when Project
  790. "project"
  791. when Version
  792. "version"
  793. when Issue
  794. object.leaf? ? 'leaf' : 'parent'
  795. else
  796. ""
  797. end
  798. # Renders the task bar, with progress and late
  799. if coords[:bar_start] && coords[:bar_end]
  800. width = coords[:bar_end] - coords[:bar_start] - 2
  801. style = +""
  802. style << "top:#{params[:top]}px;"
  803. style << "left:#{coords[:bar_start]}px;"
  804. style << "width:#{width}px;"
  805. html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue)
  806. html_id = "task-todo-version-#{object.id}" if object.is_a?(Version)
  807. content_opt = {:style => style,
  808. :class => "#{css} task_todo",
  809. :id => html_id,
  810. :data => {}}
  811. if object.is_a?(Issue)
  812. rels = issue_relations(object)
  813. if rels.present?
  814. content_opt[:data] = {"rels" => rels.to_json}
  815. end
  816. end
  817. content_opt[:data].merge!(data_options)
  818. output << view.content_tag(:div, '&nbsp;'.html_safe, content_opt)
  819. if coords[:bar_late_end]
  820. width = coords[:bar_late_end] - coords[:bar_start] - 2
  821. style = +""
  822. style << "top:#{params[:top]}px;"
  823. style << "left:#{coords[:bar_start]}px;"
  824. style << "width:#{width}px;"
  825. output << view.content_tag(:div, '&nbsp;'.html_safe,
  826. :style => style,
  827. :class => "#{css} task_late",
  828. :data => data_options)
  829. end
  830. if coords[:bar_progress_end]
  831. width = coords[:bar_progress_end] - coords[:bar_start] - 2
  832. style = +""
  833. style << "top:#{params[:top]}px;"
  834. style << "left:#{coords[:bar_start]}px;"
  835. style << "width:#{width}px;"
  836. html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue)
  837. html_id = "task-done-version-#{object.id}" if object.is_a?(Version)
  838. output << view.content_tag(:div, '&nbsp;'.html_safe,
  839. :style => style,
  840. :class => "#{css} task_done",
  841. :id => html_id,
  842. :data => data_options)
  843. end
  844. end
  845. # Renders the markers
  846. if markers
  847. if coords[:start]
  848. style = +""
  849. style << "top:#{params[:top]}px;"
  850. style << "left:#{coords[:start]}px;"
  851. style << "width:15px;"
  852. output << view.content_tag(:div, '&nbsp;'.html_safe,
  853. :style => style,
  854. :class => "#{css} marker starting",
  855. :data => data_options)
  856. end
  857. if coords[:end]
  858. style = +""
  859. style << "top:#{params[:top]}px;"
  860. style << "left:#{coords[:end]}px;"
  861. style << "width:15px;"
  862. output << view.content_tag(:div, '&nbsp;'.html_safe,
  863. :style => style,
  864. :class => "#{css} marker ending",
  865. :data => data_options)
  866. end
  867. end
  868. # Renders the label on the right
  869. if label
  870. style = +""
  871. style << "top:#{params[:top]}px;"
  872. style << "left:#{(coords[:bar_end] || 0) + 8}px;"
  873. style << "width:15px;"
  874. output << view.content_tag(:div, label,
  875. :style => style,
  876. :class => "#{css} label",
  877. :data => data_options)
  878. end
  879. # Renders the tooltip
  880. if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end]
  881. s = view.content_tag(:span,
  882. view.render_issue_tooltip(object).html_safe,
  883. :class => "tip")
  884. s += view.content_tag(:input, nil, :type => 'checkbox', :name => 'ids[]',
  885. :value => object.id, :style => 'display:none;',
  886. :class => 'toggle-selection')
  887. style = +""
  888. style << "position: absolute;"
  889. style << "top:#{params[:top]}px;"
  890. style << "left:#{coords[:bar_start]}px;"
  891. style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
  892. style << "height:12px;"
  893. output << view.content_tag(:div, s.html_safe,
  894. :style => style,
  895. :class => "tooltip hascontextmenu",
  896. :data => data_options)
  897. end
  898. @lines << output
  899. output
  900. end
  901. def pdf_task(params, coords, markers, label, object)
  902. cell_height_ratio = params[:pdf].get_cell_height_ratio
  903. params[:pdf].set_cell_height_ratio(0.1)
  904. height = 2
  905. height /= 2 if markers
  906. # Renders the task bar, with progress and late
  907. if coords[:bar_start] && coords[:bar_end]
  908. width = [1, coords[:bar_end] - coords[:bar_start]].max
  909. params[:pdf].SetY(params[:top] + 1.5)
  910. params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
  911. params[:pdf].SetFillColor(200, 200, 200)
  912. params[:pdf].RDMCell(width, height, "", 0, 0, "", 1)
  913. if coords[:bar_late_end]
  914. width = [1, coords[:bar_late_end] - coords[:bar_start]].max
  915. params[:pdf].SetY(params[:top] + 1.5)
  916. params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
  917. params[:pdf].SetFillColor(255, 100, 100)
  918. params[:pdf].RDMCell(width, height, "", 0, 0, "", 1)
  919. end
  920. if coords[:bar_progress_end]
  921. width = [1, coords[:bar_progress_end] - coords[:bar_start]].max
  922. params[:pdf].SetY(params[:top] + 1.5)
  923. params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
  924. params[:pdf].SetFillColor(90, 200, 90)
  925. params[:pdf].RDMCell(width, height, "", 0, 0, "", 1)
  926. end
  927. end
  928. # Renders the markers
  929. if markers
  930. if coords[:start]
  931. params[:pdf].SetY(params[:top] + 1)
  932. params[:pdf].SetX(params[:subject_width] + coords[:start] - 1)
  933. params[:pdf].SetFillColor(50, 50, 200)
  934. params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
  935. end
  936. if coords[:end]
  937. params[:pdf].SetY(params[:top] + 1)
  938. params[:pdf].SetX(params[:subject_width] + coords[:end] - 1)
  939. params[:pdf].SetFillColor(50, 50, 200)
  940. params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
  941. end
  942. end
  943. # Renders the label on the right
  944. if label
  945. params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5)
  946. params[:pdf].RDMCell(30, 2, label)
  947. end
  948. params[:pdf].set_cell_height_ratio(cell_height_ratio)
  949. end
  950. def image_task(params, coords, markers, label, object)
  951. height = 6
  952. height /= 2 if markers
  953. # Renders the task bar, with progress and late
  954. if coords[:bar_start] && coords[:bar_end]
  955. params[:image].fill('#aaa')
  956. params[:image].draw('rectangle %d,%d %d,%d' % [
  957. params[:subject_width] + coords[:bar_start],
  958. params[:top],
  959. params[:subject_width] + coords[:bar_end],
  960. params[:top] - height
  961. ])
  962. if coords[:bar_late_end]
  963. params[:image].fill('#f66')
  964. params[:image].draw('rectangle %d,%d %d,%d' % [
  965. params[:subject_width] + coords[:bar_start],
  966. params[:top],
  967. params[:subject_width] + coords[:bar_late_end],
  968. params[:top] - height
  969. ])
  970. end
  971. if coords[:bar_progress_end]
  972. params[:image].fill('#00c600')
  973. params[:image].draw('rectangle %d,%d %d,%d' % [
  974. params[:subject_width] + coords[:bar_start],
  975. params[:top],
  976. params[:subject_width] + coords[:bar_progress_end],
  977. params[:top] - height
  978. ])
  979. end
  980. end
  981. # Renders the markers
  982. if markers
  983. if coords[:start]
  984. x = params[:subject_width] + coords[:start]
  985. y = params[:top] - height / 2
  986. params[:image].fill('blue')
  987. params[:image].draw('polygon %d,%d %d,%d %d,%d %d,%d' % [
  988. x - 4, y,
  989. x, y - 4,
  990. x + 4, y,
  991. x, y + 4
  992. ])
  993. end
  994. if coords[:end]
  995. x = params[:subject_width] + coords[:end]
  996. y = params[:top] - height / 2
  997. params[:image].fill('blue')
  998. params[:image].draw('polygon %d,%d %d,%d %d,%d %d,%d' % [
  999. x - 4, y,
  1000. x, y - 4,
  1001. x + 4, y,
  1002. x, y + 4
  1003. ])
  1004. end
  1005. end
  1006. # Renders the label on the right
  1007. if label
  1008. params[:image].fill('black')
  1009. params[:image].draw('text %d,%d %s' % [
  1010. params[:subject_width] + (coords[:bar_end] || 0) + 5,
  1011. params[:top] + 1,
  1012. magick_text(label)
  1013. ])
  1014. end
  1015. end
  1016. # Escape the passed string as a text argument in a draw rule for
  1017. # mini_magick. Note that the returned string is not shell-safe on its own.
  1018. def magick_text(str)
  1019. "'#{str.to_s.gsub(/['\\]/, '\\\\\0')}'"
  1020. end
  1021. end
  1022. end
  1023. end