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

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