├── .rspec ├── spec ├── rcov.opts ├── ci_config │ └── database.ci.yml ├── spec.opts ├── sanity_spec.rb ├── ci.sh ├── controllers │ ├── issues_controller_spec.rb │ └── gantts_controller_spec.rb ├── helpers.rb ├── spec_helper.rb ├── models │ ├── project_patch_spec.rb │ └── issue_patch_spec.rb ├── factories.rb └── lib │ └── calendar_spec.rb ├── .rvmrc ├── lang └── en.yml ├── Gemfile.lock ├── screenshots ├── gantt_html_zoom1-year.png ├── gantt_html_zoom3-week.png ├── gantt_html_zoom4-day.png └── gantt_html_zoom2-month.png ├── ISSUE_TEMPLATE.md ├── app └── views │ ├── issues │ └── _show_estimated_duration.html.erb │ ├── settings │ └── _better_gantt_chart_settings.html.erb │ └── gantts │ └── show.html.erb ├── .travis.yml ├── lib ├── redmine_better_gantt_chart │ ├── redmine_better_gantt_chart.rb │ ├── hooks │ │ └── view_issues_show_details_bottom_hook.rb │ ├── issue_patch.rb │ ├── project_patch.rb │ ├── gantts_controller_patch.rb │ ├── patches.rb │ ├── active_record │ │ ├── callback_extensions_for_rails3.rb │ │ └── callback_extensions_for_rails2.rb │ ├── issues_helper_patch.rb │ ├── calendar.rb │ └── issue_dependency_patch.rb └── redmine │ └── helpers │ └── better_gantt.rb ├── assets └── javascripts │ ├── compile_coffee.bat │ ├── raphael.arrow.coffee │ ├── raphael.arrow.js │ └── raphael-min.js ├── Gemfile ├── Rakefile ├── config └── locales │ ├── en.yml │ ├── fr.yml │ ├── pt-BR.yml │ ├── de.yml │ └── bg.yml ├── init.rb └── README.rdoc /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /spec/rcov.opts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rvmrc: -------------------------------------------------------------------------------- 1 | rvm use 1.9.3 2 | -------------------------------------------------------------------------------- /lang/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here 2 | my_label: "My label" 3 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | specs: 3 | 4 | PLATFORMS 5 | ruby 6 | 7 | DEPENDENCIES 8 | -------------------------------------------------------------------------------- /spec/ci_config/database.ci.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: db/test.sqlite3 4 | -------------------------------------------------------------------------------- /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --colour 2 | --format 3 | nested 4 | --loadby 5 | mtime 6 | --reverse 7 | --backtrace 8 | -------------------------------------------------------------------------------- /screenshots/gantt_html_zoom1-year.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kulesa/redmine_better_gantt_chart/HEAD/screenshots/gantt_html_zoom1-year.png -------------------------------------------------------------------------------- /screenshots/gantt_html_zoom3-week.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kulesa/redmine_better_gantt_chart/HEAD/screenshots/gantt_html_zoom3-week.png -------------------------------------------------------------------------------- /screenshots/gantt_html_zoom4-day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kulesa/redmine_better_gantt_chart/HEAD/screenshots/gantt_html_zoom4-day.png -------------------------------------------------------------------------------- /screenshots/gantt_html_zoom2-month.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kulesa/redmine_better_gantt_chart/HEAD/screenshots/gantt_html_zoom2-month.png -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Hello! 2 | ### This is to let you know that this project is unmaintained. 3 | ### If you'd like to adopt this repo, please contact me! 4 | -------------------------------------------------------------------------------- /spec/sanity_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../spec_helper', __FILE__) 2 | 3 | describe Class do 4 | it "should be a class of Class" do 5 | Class.class.should eql(Class) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/issues/_show_estimated_duration.html.erb: -------------------------------------------------------------------------------- 1 | 2 | <%= h l(:field_duration)%>: 3 | <%= issue.duration if issue.duration > 0 %> 4 | 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.8.7 3 | - 1.9.2 4 | env: 5 | - DB=sqlite 6 | before_script: "./spec/ci.sh" 7 | script: "sh -c 'cd spec/dummy_redmine/vendor/plugins/redmine_better_gantt_chart && bundle exec rake spec'" 8 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/redmine_better_gantt_chart.rb: -------------------------------------------------------------------------------- 1 | module RedmineBetterGanttChart 2 | def self.work_on_weekends? 3 | Setting['plugin_redmine_better_gantt_chart']['work_on_weekends'] 4 | end 5 | 6 | def self.smart_sorting? 7 | Setting['plugin_redmine_better_gantt_chart']['smart_sorting'] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/hooks/view_issues_show_details_bottom_hook.rb: -------------------------------------------------------------------------------- 1 | module RedmineBetterGanttChart 2 | module Hooks 3 | class ViewIssuesShowDetailsBottomHook < Redmine::Hook::ViewListener 4 | include Redmine::I18n 5 | 6 | render_on(:view_issues_show_details_bottom, 7 | :partial => 'issues/show_estimated_duration', 8 | :layout => false) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/issue_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineBetterGanttChart 2 | module IssuePatch 3 | def self.included(base) # :nodoc: 4 | base.send(:include, InstanceMethods) 5 | end 6 | 7 | module InstanceMethods 8 | # Defines whether this issue is marked as external from the current project 9 | def external=(flag) 10 | @external = flag 11 | end 12 | 13 | # Returns whether this issue is marked as external from the current project 14 | def external? 15 | !!@external 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/ci.sh: -------------------------------------------------------------------------------- 1 | echo "Setting up dummy Redmine" 2 | git clone https://github.com/edavis10/redmine.git spec/dummy_redmine 3 | echo "Checking out branch 1.3" 4 | cd spec/dummy_redmine && git checkout origin/1.3-stable 5 | echo "Copying database.yml" 6 | cp ../ci_config/database.ci.yml config/database.yml 7 | echo "Cloning the plugin to dummy Redmine plugins folder" 8 | git clone ../.. vendor/plugins/redmine_better_gantt_chart 9 | echo "Migrating database" 10 | RAILS_ENV=test bundle exec rake db:create db:migrate db:migrate_plugins 11 | cd vendor/plugins/redmine_better_gantt_chart && bundle install 12 | -------------------------------------------------------------------------------- /assets/javascripts/compile_coffee.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | set script=raphael.arrow 3 | 4 | cmd /c "coffee -v" 5 | echo. 6 | if not (%errorlevel%) == (0) ( 7 | cls 8 | color 0e 9 | echo. &echo. &echo. 10 | echo ==================================================================== 11 | echo This script requires installation of the CoffeeScript compiler. 12 | echo Refer to coffeescript.org for details. 13 | echo ==================================================================== 14 | echo. &echo. &echo. 15 | ) else ( 16 | echo Watching %script%.coffee for changes... 17 | coffee -l -w -c %script% 18 | ) 19 | pause 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | ## ----------------------------------- 2 | ## Uncomment content of this Gemfile 3 | ## only if you need to run plugin specs. 4 | ## ----------------------------------- 5 | 6 | #redmine_root = ENV["REDMINE_ROOT"] || File.dirname(__FILE__) + "/../../" 7 | #eval File.read File.expand_path(redmine_root + 'Gemfile', __FILE__) 8 | 9 | #group :test do 10 | #gem 'rspec', "~> 2.14.0" 11 | #gem 'rspec-rails', "~> 2.14.0" 12 | #gem 'factory_girl', "~> 2.3.2" 13 | #gem 'database_cleaner' 14 | #gem 'mysql2' 15 | #gem 'pry' 16 | #end 17 | 18 | gem 'activerecord-deprecated_finders', require: 'active_record/deprecated_finders' 19 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | 2 | Dir[File.expand_path(File.dirname(__FILE__)) + "/lib/tasks/**/*.rake"].sort.each { |ext| load ext } 3 | 4 | begin 5 | require 'spec' 6 | rescue LoadError 7 | require 'rubygems' 8 | require 'spec' 9 | end 10 | 11 | begin 12 | require 'spec/rake/spectask' 13 | rescue LoadError 14 | puts <<-EOS 15 | To use rspec for testing you must install rspec gem: 16 | gem install rspec 17 | EOS 18 | exit(0) 19 | end 20 | 21 | desc "Run the specs under spec/models" 22 | Spec::Rake::SpecTask.new do |t| 23 | t.spec_opts = ['--options', "spec/spec.opts"] 24 | t.spec_files = FileList['spec/**/*_spec.rb'] 25 | end 26 | -------------------------------------------------------------------------------- /app/views/settings/_better_gantt_chart_settings.html.erb: -------------------------------------------------------------------------------- 1 |

2 | 3 | <%= check_box_tag('settings[work_on_weekends]', @settings['work_on_weekends'], @settings['work_on_weekends']) %> 4 |

5 |

6 | <%= h l(:label_enabled) %>: <%= h l(:description_enabled) %> 7 |
<%= h l(:label_disabled) %>: <%= h l(:description_disabled) %> 8 |

9 |

10 |
11 | 12 | <%= check_box_tag('settings[smart_sorting]', @settings['smart_sorting'], @settings['smart_sorting']) %> 13 |

14 | -------------------------------------------------------------------------------- /spec/controllers/issues_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../spec_helper', __FILE__) 2 | 3 | describe IssuesController do 4 | include Redmine::I18n 5 | render_views 6 | 7 | before(:each) do 8 | @current_user = Factory :user 9 | User.stub(:current).and_return(@current_user) 10 | @current_user.stub(:allowed_to?).and_return(true) 11 | @current_user.stub(:pref).and_return(Factory(:user_preference, :user_id => @current_user.id)) 12 | @current_user.stub(:mail) 13 | end 14 | 15 | let!(:issue) { Factory(:issue) } 16 | 17 | it "should show estimated duration in issue details" do 18 | get :show, :id => issue.id 19 | response.should have_text(/th class=.duration./) 20 | end 21 | end 22 | 23 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | en: 3 | field_duration: "Duration" 4 | work_on_weekends_label: "Work on weekends" 5 | smart_sorting_label: "Smart sorting" 6 | task_moved_journal_entry: "due to changes in a related task: #%{id}" 7 | label_enabled: "Enabled" 8 | label_disabled: "Disabled" 9 | description_enabled: "a week has 7 working days. Issues can start and finish on weekends when automatically rescheduled. Issue duration includes weekends." 10 | description_disabled: "a week has 5 working days. If start or due date falls on a weekend when the issue is rescheduled automatically, it is moved to the next Monday. Note, that with any value of the setting start and due dates can always be set to a weekend manually." 11 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/project_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineBetterGanttChart 2 | module ProjectPatch 3 | def self.included(base) # :nodoc: 4 | base.send(:include, InstanceMethods) 5 | end 6 | 7 | module InstanceMethods 8 | # Returns the project's related issues belonging to othe projects 9 | def cross_project_related_issues 10 | related_issues = [] 11 | self.issues.each do |issue| 12 | issue.relations.each do |relation| 13 | related_issue = relation.other_issue(issue) 14 | related_issue.external = true 15 | related_issues << related_issue unless related_issue.project == issue.project 16 | end 17 | end 18 | related_issues 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /config/locales/fr.yml: -------------------------------------------------------------------------------- 1 | # French strings go here for Rails i18n 2 | fr: 3 | field_duration: "Durée" 4 | work_on_weekends_label: "Travail le week-end" 5 | smart_sorting_label: "Classement intelligent" 6 | task_moved_journal_entry: "en raison d'un changement dans une tâche liée: #%{id}" 7 | label_enabled: "Activé" 8 | label_disabled: "Désactivé" 9 | description_enabled: "une semaine a 7 jours ouvrés. Les demandes peuvent commencer et finir les weekends lors des réordonnancements automatiques. Les durées des demandes incluent les weekends." 10 | description_disabled: "une semaine a 5 jours ouvrés. Si la date de début ou la date de fin tombe un weekend lors d'un réordonnancement automatique, elle est déplacée au lundi suivant. La date de début et la date de fin peuvent toujours être manuellement définies le weekend." 11 | -------------------------------------------------------------------------------- /config/locales/pt-BR.yml: -------------------------------------------------------------------------------- 1 | # English strings go here for Rails i18n 2 | pt-BR: 3 | field_duration: "Duração" 4 | work_on_weekends_label: "Trabalho em fins de semana" 5 | smart_sorting_label: "Ordenação inteligente" 6 | task_moved_journal_entry: "devido a mudanças em uma tarefa relacionada: #%{id}" 7 | label_enabled: "Ligado" 8 | label_disabled: "Desligado" 9 | description_enabled: "uma semana tem 7 dias uteis. Casos podem iniciar e terminar em fins de semana quando reagendados automaticamente. Duração dos casos inclui fins de semana." 10 | description_disabled: "uma semana tem 5 dias uteis. Se um caso começar ou terminar em um fim de semana devicoa reagentamento automático, será movido para a próxima segunda-feira. Note que, com qualquer configuração, um caso pode ser definido para iniciar ou terminar em fins de semana manualmente." 11 | -------------------------------------------------------------------------------- /config/locales/de.yml: -------------------------------------------------------------------------------- 1 | # German strings go here for Rails i18n 2 | de: 3 | field_duration: "Bearbeitungsdauer" 4 | work_on_weekends_label: "Arbeit an Wochenenden" 5 | smart_sorting_label: "Smart sorting" 6 | task_moved_journal_entry: "Grund: Änderung an zugehörigem Ticket: #%{id}" 7 | label_enabled: "Aktiviert" 8 | label_disabled: "Deaktiviert" 9 | description_enabled: "Eine Woche hat 7 Arbeitstage. Bei automatischen Terminänderungen können Beginn und Abgabedatum auf ein Wochenende fallen. Die Bearbeitungsdauer schließt Wochendenden ein." 10 | description_disabled: "Eine Woche hat 5 Arbeitstage. Fällt Beginn oder Abgabedatum bei automatischen Terminänderungen auf ein Wochenende wird es auf den folgenden Montag verschoben. Hinweis: Unabhängig von dieser Einstellung kann Beginn und Abgabedatum immer manuell auf ein Wochende gesetzt werden." 11 | -------------------------------------------------------------------------------- /config/locales/bg.yml: -------------------------------------------------------------------------------- 1 | # Bulgarian strings go here for Rails i18n, translated by Ivan Cenov, i_cenov@botevgrad.com 2 | bg: 3 | field_duration: "Продължителност" 4 | work_on_weekends_label: "Работа в почивните дни" 5 | smart_sorting_label: "Интелигентно сортиране" 6 | task_moved_journal_entry: "поради промени в свързана задача #%{id}" 7 | label_enabled: "Включено" 8 | label_disabled: "Изключено" 9 | description_enabled: "Седмицата съдържа 7 работни дни. Задачите могат да стартират и спират в събота и неделя, когато се изпълнява автоматично преместване на датите. Продължителността на задачите включва събота и неделя." 10 | description_disabled: "Седмицата съдържа 5 работни дни. Ако началната или крайната дата попадне в почивните дни при автоматично преместване, тя се премества в следващия понеделник. Независимо от това е възможно ръчно установяване на дата в почивен ден." 11 | -------------------------------------------------------------------------------- /spec/helpers.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | def create_related_issues(relation_type, from_issue = Factory(:issue), to_issue = Factory(:issue)) 3 | from_issue.relations << IssueRelation.create!(:issue_from => from_issue, :issue_to => to_issue, :relation_type => relation_type) 4 | from_issue.save! 5 | [from_issue, to_issue].map(&:reload) 6 | end 7 | 8 | def relate_issues(from_issue, to_issue, relation_type = 'precedes') 9 | from_issue.relations << IssueRelation.create!(:issue_from => from_issue, :issue_to => to_issue, :relation_type => relation_type) 10 | from_issue.save! 11 | [from_issue, to_issue].map(&:reload) 12 | end 13 | 14 | def work_on_weekends(wow) 15 | RedmineBetterGanttChart.stub(:work_on_weekends?).and_return(wow) 16 | end 17 | 18 | # Useful for debugging - draws ASCII gantt chart 19 | def draw_tasks(start, *tasks) 20 | tasks.each { |t| t.reload } 21 | tasks.each { |task| draw_from_date(start, task) } 22 | end 23 | 24 | def draw_from_date(date_from, issue) 25 | start_date = issue.start_date 26 | due_date = issue.due_date || issue.start_date 27 | puts " "*(start_date - date_from) + "#"*(due_date - start_date) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to ~/spec when you run 'ruby script/generate rspec' 2 | # from the project root directory. 3 | ENV["RAILS_ENV"] = "test" 4 | require 'active_record' 5 | require 'active_record/connection_adapters/abstract_adapter' 6 | require 'active_record/connection_adapters/abstract_mysql_adapter' 7 | 8 | # Allows loading of an environment config based on the environment 9 | redmine_root = ENV["REDMINE_ROOT"] || File.dirname(__FILE__) + "/../../.." 10 | require File.expand_path(redmine_root + "/config/environment", __FILE__) 11 | require 'rspec/rails' 12 | require 'factory_girl' 13 | 14 | require File.expand_path(File.dirname(__FILE__) + '/factories.rb') 15 | require File.expand_path(File.dirname(__FILE__) + '/helpers') 16 | 17 | RSpec.configure do |config| 18 | config.use_transactional_fixtures = false 19 | require 'database_cleaner' 20 | DatabaseCleaner.strategy = :truncation 21 | config.include Helpers 22 | config.treat_symbols_as_metadata_keys_with_true_values = true 23 | config.run_all_when_everything_filtered = true 24 | config.filter_run_including :focus => true 25 | 26 | config.before(:suite) do 27 | DatabaseCleaner.clean 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/gantts_controller_patch.rb: -------------------------------------------------------------------------------- 1 | # Patching GanttsController to use our own gantt helper 2 | module RedmineBetterGanttChart 3 | module GanttsControllerPatch 4 | def self.included(base) 5 | base.send(:include, InstanceMethods) 6 | 7 | base.class_eval do 8 | alias_method_chain :show, :custom_helper 9 | end 10 | end 11 | 12 | module InstanceMethods 13 | def show_with_custom_helper 14 | @gantt = Redmine::Helpers::BetterGantt.new(params) 15 | @gantt.project = @project 16 | retrieve_query 17 | @query.group_by = nil 18 | @gantt.query = @query if @query.valid? 19 | 20 | basename = (@project ? "#{@project.identifier}-" : '') + 'gantt' 21 | 22 | respond_to do |format| 23 | format.html { render :action => "show", :layout => !request.xhr? } 24 | format.png { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image') 25 | format.pdf { send_data(@gantt.to_pdf, :type => 'application/pdf', :filename => "#{basename}.pdf") } 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/patches.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'issue' 2 | require_dependency 'project' 3 | 4 | unless IssuesHelper.included_modules.include? RedmineBetterGanttChart::IssuesHelperPatch 5 | IssuesHelper.send(:include, RedmineBetterGanttChart::IssuesHelperPatch) 6 | end 7 | 8 | unless Issue.included_modules.include? RedmineBetterGanttChart::IssueDependencyPatch 9 | Issue.send(:include, RedmineBetterGanttChart::IssueDependencyPatch) 10 | end 11 | 12 | unless Issue.included_modules.include? RedmineBetterGanttChart::IssuePatch 13 | Issue.send(:include, RedmineBetterGanttChart::IssuePatch) 14 | end 15 | 16 | unless Project.included_modules.include? RedmineBetterGanttChart::ProjectPatch 17 | Project.send(:include, RedmineBetterGanttChart::ProjectPatch) 18 | end 19 | 20 | unless GanttsController.included_modules.include? RedmineBetterGanttChart::GanttsControllerPatch 21 | GanttsController.send(:include, RedmineBetterGanttChart::GanttsControllerPatch) 22 | end 23 | 24 | require_dependency 'redmine_better_gantt_chart/redmine_better_gantt_chart' 25 | require_dependency 'redmine_better_gantt_chart/calendar' 26 | require_dependency 'redmine_better_gantt_chart/hooks/view_issues_show_details_bottom_hook' 27 | -------------------------------------------------------------------------------- /spec/models/project_patch_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../spec_helper', __FILE__) 2 | 3 | describe 'Project Dependency Patch' do 4 | 5 | before(:all) do 6 | Setting.cross_project_issue_relations = '1' 7 | 8 | # Issue 1 of Project 1 precedes issue 2 of the same project 9 | # Issue 1 precedes an issue of Project 2 10 | # Issue 2 precedes an issue of Project 3 11 | @first_project_issue1, @first_project_issue2 = create_related_issues("precedes") 12 | @project1 = @first_project_issue1.project 13 | @project2, @project3 = Factory(:project), Factory(:project) 14 | @second_project_issue = Factory(:issue, :project => @project2) 15 | @third_project_issue = Factory(:issue, :project => @project3) 16 | create_related_issues("precedes", @first_project_issue1, @second_project_issue) 17 | create_related_issues("precedes", @first_project_issue2, @third_project_issue) 18 | end 19 | 20 | it 'should find cross project related issues' do 21 | @project1.cross_project_related_issues.count.should eql(2) 22 | @project1.cross_project_related_issues.should include(@second_project_issue, @third_project_issue) 23 | end 24 | 25 | it 'should find cross project related issues of other projects' do 26 | @project2.cross_project_related_issues.count.should eql(1) 27 | @project2.cross_project_related_issues.should include(@first_project_issue1) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine' 2 | require 'dispatcher' unless Rails::VERSION::MAJOR >= 3 3 | 4 | if Rails::VERSION::MAJOR >= 3 5 | ActionDispatch::Callbacks.to_prepare do 6 | require_dependency 'redmine_better_gantt_chart/patches' 7 | 8 | unless ActiveRecord::Base.included_modules.include?(RedmineBetterGanttChart::ActiveRecord::CallbackExtensionsForRails3) 9 | ActiveRecord::Base.send(:include, RedmineBetterGanttChart::ActiveRecord::CallbackExtensionsForRails3) 10 | end 11 | end 12 | else 13 | Dispatcher.to_prepare :redmine_better_gantt_chart do 14 | unless ActiveRecord::Base.included_modules.include?(RedmineBetterGanttChart::ActiveRecord::CallbackExtensionsForRails2) 15 | ActiveRecord::Base.send(:include, RedmineBetterGanttChart::ActiveRecord::CallbackExtensionsForRails2) 16 | end 17 | require_dependency 'redmine_better_gantt_chart/patches' 18 | end 19 | end 20 | 21 | Redmine::Plugin.register :redmine_better_gantt_chart do 22 | name 'Redmine Better Gantt Chart plugin' 23 | author 'Alexey Kuleshov' 24 | description 'This plugin improves Redmine Gantt Chart' 25 | version '0.9.0' 26 | url 'https://github.com/kulesa/redmine_better_gantt_chart' 27 | author_url 'http://github.com/kulesa' 28 | 29 | requires_redmine :version_or_higher => '1.1.0' 30 | 31 | settings(:default => { 32 | 'work_on_weekends' => true, 33 | 'smart_sorting' => true 34 | }, :partial => "better_gantt_chart_settings") 35 | end 36 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/active_record/callback_extensions_for_rails3.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/proxy_object' 2 | 3 | module RedmineBetterGanttChart 4 | module ActiveRecord 5 | class WithoutCallbacks < ActiveSupport::ProxyObject 6 | def initialize(target, types) 7 | @target = target 8 | @types = types 9 | end 10 | 11 | def respond_to?(method, include_private = false) 12 | @target.respond_to?(method, include_private) 13 | end 14 | 15 | def method_missing(method, *args, &block) 16 | @target.skip_callbacks(*@types) do 17 | @target.send(method, *args, &block) 18 | end 19 | end 20 | end 21 | 22 | module CallbackExtensionsForRails3 23 | extend ActiveSupport::Concern 24 | 25 | module ClassMethods 26 | def without_callbacks(*types) 27 | RedmineBetterGanttChart::ActiveRecord::WithoutCallbacks.new(self, types) 28 | end 29 | 30 | def skip_callbacks(*types) 31 | types = [:save, :create, :update, :destroy, :touch] if types.empty? 32 | deactivate_callbacks(types) 33 | yield.tap do 34 | activate_callbacks(types) 35 | end 36 | end 37 | 38 | def deactivate_callbacks(types) 39 | types.each do |type| 40 | name = :"_run_#{type}_callbacks" 41 | alias_method(:"_deactivated_#{name}", name) 42 | define_method(name) { |&block| block.call } 43 | end 44 | end 45 | 46 | def activate_callbacks(types) 47 | types.each do |type| 48 | name = :"_run_#{type}_callbacks" 49 | alias_method(name, :"_deactivated_#{name}") 50 | undef_method(:"_deactivated_#{name}") 51 | end 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/active_record/callback_extensions_for_rails2.rb: -------------------------------------------------------------------------------- 1 | module RedmineBetterGanttChart 2 | module ActiveRecord 3 | module CallbackExtensionsForRails2 4 | 5 | def self.included(base) #:nodoc: 6 | base.extend ClassMethods 7 | 8 | base.class_eval do 9 | alias_method_chain :callback, :switch 10 | class_inheritable_accessor :disabled_callbacks 11 | self.disabled_callbacks = [] # set default to empty array 12 | end 13 | end 14 | 15 | # overloaded callback method with hook to disable callbacks 16 | def callback_with_switch(method) 17 | self.disabled_callbacks ||= [] # FIXME: this is a hack required because we don't inherit the default [] from AR::Base properly?! 18 | if self.disabled_callbacks.include?( method.to_s ) # disable hook 19 | return true 20 | else 21 | callback_without_switch(method) 22 | end 23 | end 24 | 25 | module ClassMethods 26 | def with_callbacks_disabled(*callbacks, &block) 27 | self.disabled_callbacks = [*callbacks.map(&:to_s)] 28 | yield 29 | self.disabled_callbacks = [] # old_value 30 | end 31 | 32 | alias_method :with_callback_disabled, :with_callbacks_disabled 33 | 34 | def with_all_callbacks_disabled &block 35 | all_callbacks = %w[ 36 | before_create 37 | before_validation 38 | before_validation_on_create 39 | before_validation_on_update 40 | before_save 41 | before_update 42 | before_destroy 43 | after_create 44 | after_validation 45 | after_validation_on_create 46 | after_validation_on_update 47 | after_save 48 | after_update 49 | after_destroy 50 | ] 51 | with_callbacks_disabled *all_callbacks, &block 52 | end 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/factories.rb: -------------------------------------------------------------------------------- 1 | # This should prevent creation of new instance when I want to use the only existing 2 | saved_single_instances = {} 3 | #Find or create the model instance 4 | single_instances = lambda do |factory_key| 5 | begin 6 | saved_single_instances[factory_key].reload 7 | rescue NoMethodError, ActiveRecord::RecordNotFound 8 | #was never created (is nil) or was cleared from db 9 | saved_single_instances[factory_key] = Factory.create(factory_key) #recreate 10 | end 11 | 12 | return saved_single_instances[factory_key] 13 | end 14 | 15 | def test_time 16 | @test_time ||= Time.zone.now.to_date 17 | end 18 | 19 | FactoryGirl.define do 20 | factory :user_preference do 21 | time_zone '' 22 | hide_mail false 23 | others {{:gantt_months=>6, :comments_sorting=>"asc", :gantt_zoom=>3, :no_self_notified=>true}} 24 | end 25 | 26 | factory :user do 27 | sequence(:firstname) {|n| "John#{n}"} 28 | lastname 'Kilmer' 29 | sequence(:login) {|n| "john_#{n}"} 30 | sequence(:mail) {|n| "john#{n}@local.com"} 31 | after_create do |u| 32 | Factory(:user_preference, :user => u) 33 | end 34 | end 35 | 36 | factory :admin, :parent => :user do 37 | admin true 38 | end 39 | 40 | factory :project do 41 | sequence(:name) {|n| "project#{n}"} 42 | identifier {|u| u.name } 43 | is_public true 44 | end 45 | 46 | factory :tracker do 47 | sequence(:name) { |n| "Feature #{n}" } 48 | sequence(:position) {|n| n} 49 | end 50 | 51 | factory :bug, :parent => :tracker do 52 | name 'Bug' 53 | end 54 | 55 | factory :main_project, :parent => :project do 56 | name 'The Main Project' 57 | identifier 'supaproject' 58 | after_create do |p| 59 | p.trackers << single_instances[:bug]; p.save! 60 | end 61 | end 62 | 63 | factory :issue_priority do 64 | sequence(:name) {|n| "Issue#{n}"} 65 | end 66 | 67 | factory :issue_status do 68 | sequence(:name) {|n| "status#{n}"} 69 | is_closed false 70 | is_default false 71 | sequence(:position) {|n| n} 72 | end 73 | 74 | factory :issue do 75 | sequence(:subject) {|n| "Issue_no_#{n}"} 76 | description {|u| u.subject} 77 | project { single_instances[:main_project] } 78 | tracker { single_instances[:bug] } 79 | priority { Factory(:issue_priority) } 80 | status { Factory(:issue_status) } 81 | author { Factory(:user) } 82 | start_date test_time 83 | due_date { |u| u.start_date + 3.days} 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/issues_helper_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineBetterGanttChart 2 | module IssuesHelperPatch 3 | 4 | # Renders an extended HTML/CSS tooltip: includes information on related tasks 5 | # 6 | # To use, a trigger div is needed. This is a div with the class of "tooltip" 7 | # that contains this method wrapped in a span with the class of "tip" 8 | # 9 | #
<%= link_to_issue(issue) %> 10 | # <%= render_issue_tooltip(issue) %> 11 | #
12 | # 13 | def render_extended_issue_tooltip(issue) 14 | @cached_label_status ||= l(:field_status) 15 | @cached_label_start_date ||= l(:field_start_date) 16 | @cached_label_due_date ||= l(:field_due_date) 17 | @cached_label_assigned_to ||= l(:field_assigned_to) 18 | @cached_label_priority ||= l(:field_priority) 19 | @cached_label_project ||= l(:field_project) 20 | @cached_label_follows ||= l(:label_follows) 21 | @cached_label_precedes ||= l(:label_precedes) 22 | @cached_label_delay ||= l(:field_delay) 23 | 24 | content = link_to_issue(issue) + ( 25 | "

" + 26 | "#{@cached_label_project}: #{link_to_project(issue.project)}
" + 27 | "#{@cached_label_status}: #{issue.status.name}
" + 28 | "#{@cached_label_start_date}: #{format_date(issue.start_date)}
" + 29 | "#{@cached_label_due_date}: #{format_date(issue.due_date)}
" + 30 | "#{@cached_label_assigned_to}: #{issue.assigned_to}
" + 31 | "#{@cached_label_priority}: #{issue.priority.name}" 32 | ).html_safe 33 | 34 | display_limit = 4 # That's max for a sane tooltip 35 | 36 | relation_from_link = lambda do |rel| 37 | "#{l(rel.label_for(issue))}: #{link_to_issue(rel.issue_from)}" 38 | end 39 | 40 | relation_to_link = lambda do |rel| 41 | "#{l(rel.label_for(issue))}: #{link_to_issue(rel.issue_to)}" 42 | end 43 | 44 | unless issue.relations_to.empty? 45 | content += ("
" + issue.relations_to.first(display_limit).map(&relation_from_link).join('
')).html_safe 46 | display_limit -= issue.relations_to.count 47 | end 48 | 49 | unless issue.relations_from.empty? 50 | new_limit = display_limit < 0 ? 0 : display_limit 51 | content += ("
" + issue.relations_from.first(new_limit).map(&relation_to_link).join('
')).html_safe 52 | display_limit -= issue.relations_from.count 53 | end 54 | 55 | content += "
There are #{0-display_limit} more relations" if display_limit < 0 56 | content 57 | end 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/calendar.rb: -------------------------------------------------------------------------------- 1 | module RedmineBetterGanttChart 2 | module Calendar 3 | def self.workdays_between(date_from, date_to) 4 | date_from, date_to = date_to, date_from if date_to < date_from 5 | number_of_days_in(date_from, date_to) { |d| is_a_working_day?(d) } 6 | end 7 | 8 | def self.weekends_between(date_from, date_to) 9 | date_from, date_to = date_to, date_from if date_to < date_from 10 | number_of_days_in(date_from, date_to) { |d| !is_a_working_day?(d) } 11 | end 12 | 13 | def self.next_working_day(date) 14 | the_same_day_or(date) { next_day_of_week(1, date) } 15 | end 16 | 17 | def self.previous_working_day(date) 18 | the_same_day_or(date) { previous_day_of_week(5, date) } 19 | end 20 | 21 | def self.workdays_from_date(date, shift) 22 | end_date = date + shift.days 23 | 24 | if RedmineBetterGanttChart.work_on_weekends? 25 | end_date 26 | else 27 | start_date = date 28 | if shift > 0 29 | while true 30 | break if (range = weekends_between(start_date, end_date).days) == 0 31 | start_date = end_date 32 | start_date += 1.days if start_date.wday == 6 || start_date.wday == 0 33 | end_date = end_date + range 34 | end 35 | next_working_day(end_date) 36 | else 37 | while true 38 | break if (weekend_diff = weekends_between(start_date, end_date)) == 0 39 | start_date = end_date 40 | start_date -= 1.days if start_date.wday == 6 || start_date.wday == 0 41 | end_date = end_date - weekend_diff.days 42 | end 43 | previous_working_day(end_date) 44 | end 45 | end 46 | end 47 | 48 | def self.workdays_before_date(date, shift) 49 | workdays_from_date(date, -shift) 50 | end 51 | 52 | # Returns nearest requested day of week, for example 53 | # next_day_of_week(5) will return next Friday 54 | # next_day_of_week(5, Date.today + 7) will return Friday of the next week 55 | def self.next_day_of_week(wday, from = Date.today) 56 | from + (wday + 7 - from.wday) % 7 57 | end 58 | 59 | def self.previous_day_of_week(wday, from = Date.today) 60 | from - (from.wday - wday + 7) % 7 61 | end 62 | 63 | def self.working_days 64 | RedmineBetterGanttChart.work_on_weekends? ? 0..6 : 1..5 65 | end 66 | 67 | def self.is_a_working_day?(date) 68 | working_days.include?(date.wday) 69 | end 70 | 71 | def self.number_of_days_in(date_from, date_to, &block) 72 | (date_from..date_to).select do |date| 73 | yield date 74 | end.size 75 | end 76 | 77 | def self.the_same_day_or(date) 78 | if RedmineBetterGanttChart.work_on_weekends? || is_a_working_day?(date) 79 | date 80 | else 81 | yield 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /assets/javascripts/raphael.arrow.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | This plugin draws arrows on Redmine gantt chart. 3 | ### 4 | 5 | # Draws an arrow 6 | # Original here: http://taitems.tumblr.com/post/549973287/drawing-arrows-in-raphaeljs 7 | Raphael.fn.ganttArrow = (coords, relationType = "follows") -> 8 | # Strokes for relation types 9 | relationDash = 10 | "follows": "" 11 | "duplicated": "- " 12 | "blocked": "-" 13 | "relates": "." 14 | 15 | # Shorthand functions for formatting SVG commands 16 | cmd = (cmd, a...) -> cmd.concat(" ", a.join(" "), " ") 17 | M = (x, y) -> cmd("M", x, y) 18 | m = (x, y) -> cmd("m", x, y) 19 | L1 = (x1, y1) -> cmd("L", x1, y1) 20 | l2 = (x1, y1, x2, y2) -> cmd("l", x1, y1, x2, y2) 21 | 22 | line = (x1, y1, x2, y2) -> 23 | M(x1, y1) + L1(x2, y2) 24 | 25 | triangle = (cx, cy, r) -> 26 | r *= 1.5 27 | "".concat(M(cx,cy), m(0,-1*r*.58), l2(r*.5, r*.87, -r, 0), " z") 28 | 29 | [x1, y1, x6, y6] = coords 30 | x1 += 3 31 | 32 | arrow = @set() 33 | 34 | deltaX = 7 35 | deltaY = 8 36 | 37 | [x2, y2] = [x1 + deltaX - 3, y1] 38 | [x5, y5] = [x6 - deltaX, y6] 39 | 40 | if y1 < y6 41 | [x3, y3] = [x2, y6 - deltaY] 42 | else 43 | [x3, y3] = [x2, y6 + deltaY] 44 | 45 | if x1 + deltaX + 7 < x6 46 | [x4, y4] = [x3, y5] 47 | else 48 | [x4, y4] = [x5, y3] 49 | 50 | arrow.push @path(line(x1, y1, x2, y2)) 51 | arrow.push @path(line(x2, y2, x3, y3)) 52 | arrow.push @path(line(x3, y3, x4, y4)) 53 | arrow.push @path(line(x4, y4, x5, y5)) 54 | arrow.push @path(line(x5, y6, x6, y6)) 55 | arrowhead = arrow.push(@path(triangle(x6 + deltaX - 5, y6 + 1, 5)).rotate(90)) 56 | arrow.toFront() 57 | arrow.attr({fill: "#444", stroke: "#222", "stroke-dasharray": relationDash[relationType]}) 58 | ### 59 | Draws connection arrows over the gantt chart 60 | ### 61 | window.redrawGanttArrows = () -> 62 | paper = Raphael("gantt_lines", "100%", "100%") # check out 'gantt_lines' div, margin-right: -2048px FTW! 63 | paper.clear 64 | window.paper = paper 65 | paper.canvas.style.position = "absolute" 66 | paper.canvas.style.zIndex = "24" 67 | 68 | # Relation attributes 69 | relationAttrs = ["follows", "blocked", "duplicated", "relates"] 70 | 71 | # Calculates arrow coordinates 72 | calculateAnchors = (from, to) -> 73 | [fromOffsetX, fromOffsetY] = [from.position().left, from.position().top] 74 | [toOffsetX, toOffsetY] = [to.position().left, to.position().top] 75 | if to.hasClass('parent') 76 | typeOffsetX = 10 77 | else 78 | typeOffsetX = 6 79 | anchors = [fromOffsetX + from.width() - 1, fromOffsetY + from.height()/2, toOffsetX - typeOffsetX, toOffsetY + to.height()/2] 80 | anchors 81 | 82 | # Draw arrows for all tasks, which have dependencies 83 | $('div.task_todo').each (element) -> 84 | element = this 85 | for relationAttribute in relationAttrs 86 | if (related = element.getAttribute(relationAttribute)) 87 | for id in related.split(',') 88 | if (item = $('#'+id)) 89 | from = item 90 | to = $('#'+element.id) 91 | if from.position()? and to.position()? 92 | paper.ganttArrow calculateAnchors(from, to), relationAttribute 93 | -------------------------------------------------------------------------------- /spec/controllers/gantts_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../spec_helper', __FILE__) 2 | 3 | describe GanttsController, '#show' do 4 | include Redmine::I18n 5 | render_views 6 | 7 | before(:all) do 8 | # Precedes - Follows 9 | @preceding_issue, @following_issue = create_related_issues("precedes") 10 | 11 | # Blocks - Blocked 12 | @blocks_issue, @blocked_issue = create_related_issues("blocks") 13 | 14 | # Duplicates - Duplicated 15 | @duplicates_issue, @duplicated_issue = create_related_issues("duplicates") 16 | 17 | # Relates 18 | @one_issue, @other_issue = create_related_issues("relates") 19 | end 20 | 21 | before(:each) do 22 | @current_user = mock_model(User, :admin? => true, 23 | :logged? => true, 24 | :language => :en, 25 | :active? => true, 26 | :memberships => [], 27 | :anonymous? => false, 28 | :name => "A Test >User", 29 | :projects => Project, 30 | :groups => [], 31 | :css_classes => 'user_active') 32 | User.stub(:current).and_return(@current_user) 33 | @current_user.stub(:allowed_to?).and_return(true) 34 | @current_user.stub(:pref).and_return(Factory(:user_preference, :user_id => @current_user.id)) 35 | end 36 | 37 | it 'should be successful' do 38 | get :show 39 | response.status.should == 200 40 | end 41 | 42 | it 'should have custom javascripts included' do 43 | get :show 44 | response.body.should have_text(/raphael.min.js/) 45 | response.body.should have_text(/raphael.arrow.js/) 46 | end 47 | 48 | it 'should insert issue ids and follow tags' do 49 | get :show 50 | response.body.should have_text(/div id='#{@preceding_issue.id}'/) 51 | response.body.should have_text(/div id='#{@following_issue.id}' follows='#{@preceding_issue.id}'/) 52 | end 53 | 54 | it 'should insert blocked tags' do 55 | get :show 56 | response.body.should have_text(/div id='#{@blocks_issue.id}'/) 57 | response.body.should have_text(/div id='#{@blocked_issue.id}' blocked='#{@blocks_issue.id}'/) 58 | end 59 | 60 | it 'should insert duplicated tags' do 61 | get :show 62 | response.body.should have_text(/div id='#{@duplicates_issue.id}'/) 63 | response.body.should have_text(/div id='#{@duplicated_issue.id}' duplicated='#{@duplicates_issue.id}'/) 64 | end 65 | 66 | it 'should insert relates tags' do 67 | get :show 68 | response.body.should have_text(/div id='#{@one_issue.id}'/) 69 | response.body.should have_text(/div id='#{@other_issue.id}' relates='#{@one_issue.id}'/) 70 | end 71 | 72 | it 'should insert an array of ids to a tag' do 73 | @blocks_issue.relations << IssueRelation.create!(:issue_from => @blocks_issue, :issue_to => @duplicated_issue, :relation_type => "duplicates", :delay => 0) 74 | @blocks_issue.save! 75 | 76 | get :show 77 | response.body.should have_text(/duplicated='#{@duplicates_issue.id},#{@blocks_issue.id}'/) 78 | end 79 | 80 | it 'should mix different relation types' do 81 | @blocks_issue.relations << IssueRelation.create!(:issue_from => @blocks_issue, :issue_to => @duplicated_issue, :relation_type => "duplicates", :delay => 0) 82 | @blocks_issue.save! 83 | @one_issue.relations << IssueRelation.create!(:issue_from => @one_issue, :issue_to => @duplicated_issue, :relation_type => "relates") 84 | @one_issue.save! 85 | 86 | get :show 87 | response.body.should have_text(/duplicated='#{@duplicates_issue.id},#{@blocks_issue.id}' relates='#{@one_issue.id}'|relates='#{@one_issue.id}' duplicated='#{@duplicates_issue.id},#{@blocks_issue.id}'/) 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Redmine Better Gantt Chart Plugin 2 | 3 | {}[http://travis-ci.org/kulesa/redmine_better_gantt_chart] 4 | 5 | The plugin improves functionality of Redmine Gantt Chart. 6 | 7 | == Features 8 | 9 | - Issues on Gantt chart connected with arrows. Handy! 10 | - Relations info added to issue tooltip. 11 | - *NEW*: smart sorting of issues on the chart. Now issues are sorted just like you'd expect them to. 12 | - *NEW*: rescheduling takes into account weekends, if this setting is enabled 13 | - Fixed rescheduling of related tasks if due date of dependent task changed to an earlier date. 14 | - Fixed sorting of issues on the chart as per http://www.redmine.org/issues/7335 15 | - Added validation: start date of child issue cannot be less than start date of the parent, if parent depends on other tasks 16 | - Fast rescheduling of related issues. Now you can have hundreds of related issues with many levels of hierarchy, and expect they'll be rescheduled just almost as fast as if you were in MS Project. And it will not cause 'stack level too deep' error (not kidding)! 17 | 18 | == Compatibility 19 | 20 | Tested with Redmine versions: 1.1.0, 1.1.1, 1.1.2, 1.1.3, 1.2, 1.3, 1.4, 2.2, 2.3, 2.4 21 | 22 | == Installation and Setup 23 | 24 | 1. Go to releases page: https://github.com/kulesa/redmine_better_gantt_chart/releases 25 | 2. For Redmine 2.x above download {version 0.9.0}[https://github.com/kulesa/redmine_better_gantt_chart/releases/download/v.0.9.0/redmine_better_gantt_chart_0.9.0.zip] or above. For Redmine 1.x download {version 0.6.1}[https://github.com/kulesa/redmine_better_gantt_chart/archive/v0.6.1.zip]. 26 | 3. Go to your #{RAILS_ROOT}/vendor/plugins directory 27 | 4. Unzip downloaded archive and rename extracted folder to '*redmine_better_gantt_chart*' (if extracted folder has other name). 28 | 5. Restart Redmine 29 | 30 | You should now see the plugin in *Administration* -> *Plugins*. The plugin does not require any database migrations. 31 | 32 | Connection arrows are rendered in SVG via {Raphael.js}[http://raphaeljs.com/] library. This should work in any modern or not so modern browser, including IE6. 33 | 34 | == Usage 35 | 36 | === Relations 37 | 38 | 1. Open an existing issue in Redmine 39 | 2. Click *Add* in *Related* *Issues* section, select type of relation 'Follows' or 'Precedes' and enter # of the related issue. 40 | 3. Save changes. 41 | 4. Go to *Gantt* tab. 42 | 43 | === Smart sorting 44 | 45 | By default issues on the chart sorted uses improved algorythm. However you can switch back to default Redmine sorting by disabling 'Smart sorting' plugin setting. 46 | 47 | === Rescheduling 48 | 49 | Changing due date of an issue causes rescheduling of related issues. By default, any day of week is a working day, and new start and due dates can fall on weekends as well as on other days. To change this, 50 | go to plugin settings and disable *Work on weekends* checkbox. This will turn on support of working days. So far only normal weekends supported; you need manually change dates falling on holidays. 51 | 52 | New setting *Work on weekends* introduced in v.0.6. 53 | 54 | == Problems and Limitations 55 | 56 | - Currently only '*follows*' and '*precedes*' relations are used to calculate schedule of dependend issues. Other relation types are rendered on the chart, but they *do* *not* *change* schedule of related issues. 57 | - Only 'finish-to-start' dependency type is available. 58 | - One issue can precede many issues but, can be preceeded with only one issue. 59 | 60 | == Helping out 61 | 62 | If you notice any problems, please report them to the GitHub issue tracker {here}[https://github.com/kulesa/redmine_better_gantt_chart/issues]. Feel free to contact me via GitHub or Twitter 63 | or whatever with any other questions or feature requests. To submit changes fork the project and send a pull request. 64 | 65 | === Running specs 66 | 67 | To run specs, 68 | 69 | - go to the plugin folder, uncomment content of Gemfile, run `bundle` 70 | - run `bundle exec rspec`. 71 | 72 | == Contributors 73 | 74 | Thanks to Jeremy Subtil ({BigMadWolf}[https://github.com/BigMadWolf]) for contributing a patch for displaying connections between cross-project related issues. 75 | 76 | == License 77 | 78 | Better Gantt Chart is released under MIT license. 79 | -------------------------------------------------------------------------------- /assets/javascripts/raphael.arrow.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | /* 3 | This plugin draws arrows on Redmine gantt chart. 4 | */ var __slice = Array.prototype.slice; 5 | Raphael.fn.ganttArrow = function(coords, relationType) { 6 | var L1, M, arrow, arrowhead, cmd, deltaX, deltaY, l2, line, m, relationDash, triangle, x1, x2, x3, x4, x5, x6, y1, y2, y3, y4, y5, y6, _ref, _ref2, _ref3, _ref4, _ref5, _ref6; 7 | if (relationType == null) { 8 | relationType = "follows"; 9 | } 10 | relationDash = { 11 | "follows": "", 12 | "duplicated": "- ", 13 | "blocked": "-", 14 | "relates": "." 15 | }; 16 | cmd = function() { 17 | var a, cmd; 18 | cmd = arguments[0], a = 2 <= arguments.length ? __slice.call(arguments, 1) : []; 19 | return cmd.concat(" ", a.join(" "), " "); 20 | }; 21 | M = function(x, y) { 22 | return cmd("M", x, y); 23 | }; 24 | m = function(x, y) { 25 | return cmd("m", x, y); 26 | }; 27 | L1 = function(x1, y1) { 28 | return cmd("L", x1, y1); 29 | }; 30 | l2 = function(x1, y1, x2, y2) { 31 | return cmd("l", x1, y1, x2, y2); 32 | }; 33 | line = function(x1, y1, x2, y2) { 34 | return M(x1, y1) + L1(x2, y2); 35 | }; 36 | triangle = function(cx, cy, r) { 37 | r *= 1.5; 38 | return "".concat(M(cx, cy), m(0, -1 * r * .58), l2(r * .5, r * .87, -r, 0), " z"); 39 | }; 40 | x1 = coords[0], y1 = coords[1], x6 = coords[2], y6 = coords[3]; 41 | x1 += 3; 42 | arrow = this.set(); 43 | deltaX = 7; 44 | deltaY = 8; 45 | _ref = [x1 + deltaX - 3, y1], x2 = _ref[0], y2 = _ref[1]; 46 | _ref2 = [x6 - deltaX, y6], x5 = _ref2[0], y5 = _ref2[1]; 47 | if (y1 < y6) { 48 | _ref3 = [x2, y6 - deltaY], x3 = _ref3[0], y3 = _ref3[1]; 49 | } else { 50 | _ref4 = [x2, y6 + deltaY], x3 = _ref4[0], y3 = _ref4[1]; 51 | } 52 | if (x1 + deltaX + 7 < x6) { 53 | _ref5 = [x3, y5], x4 = _ref5[0], y4 = _ref5[1]; 54 | } else { 55 | _ref6 = [x5, y3], x4 = _ref6[0], y4 = _ref6[1]; 56 | } 57 | arrow.push(this.path(line(x1, y1, x2, y2))); 58 | arrow.push(this.path(line(x2, y2, x3, y3))); 59 | arrow.push(this.path(line(x3, y3, x4, y4))); 60 | arrow.push(this.path(line(x4, y4, x5, y5))); 61 | arrow.push(this.path(line(x5, y6, x6, y6))); 62 | arrowhead = arrow.push(this.path(triangle(x6 + deltaX - 5, y6 + 1, 5)).rotate(90)); 63 | arrow.toFront(); 64 | return arrow.attr({ 65 | fill: "#444", 66 | stroke: "#222", 67 | "stroke-dasharray": relationDash[relationType] 68 | }); 69 | }; 70 | /* 71 | Draws connection arrows over the gantt chart 72 | */ 73 | window.redrawGanttArrows = function() { 74 | var calculateAnchors, paper, relationAttrs; 75 | paper = Raphael("gantt_lines", "100%", "100%"); 76 | paper.clear; 77 | window.paper = paper; 78 | paper.canvas.style.position = "absolute"; 79 | paper.canvas.style.zIndex = "24"; 80 | relationAttrs = ["follows", "blocked", "duplicated", "relates"]; 81 | calculateAnchors = function(from, to) { 82 | var anchors, fromOffsetX, fromOffsetY, toOffsetX, toOffsetY, typeOffsetX, _ref, _ref2; 83 | _ref = [from.position().left, from.position().top], fromOffsetX = _ref[0], fromOffsetY = _ref[1]; 84 | _ref2 = [to.position().left, to.position().top], toOffsetX = _ref2[0], toOffsetY = _ref2[1]; 85 | if (to.hasClass('parent')) { 86 | typeOffsetX = 10; 87 | } else { 88 | typeOffsetX = 6; 89 | } 90 | anchors = [fromOffsetX + from.width() - 1, fromOffsetY + from.height() / 2, toOffsetX - typeOffsetX, toOffsetY + to.height() / 2]; 91 | return anchors; 92 | }; 93 | return $('div.task_todo').each(function(element) { 94 | var from, id, item, related, relationAttribute, to, _i, _len, _results; 95 | element = this; 96 | _results = []; 97 | for (_i = 0, _len = relationAttrs.length; _i < _len; _i++) { 98 | relationAttribute = relationAttrs[_i]; 99 | _results.push((function() { 100 | var _i, _len, _ref, _results; 101 | if ((related = element.getAttribute(relationAttribute))) { 102 | _ref = related.split(','); 103 | _results = []; 104 | for (_i = 0, _len = _ref.length; _i < _len; _i++) { 105 | id = _ref[_i]; 106 | _results.push((item = $('#' + id)) ? (from = item, to = $('#' + element.id), (from.position() != null) && (to.position() != null) ? paper.ganttArrow(calculateAnchors(from, to), relationAttribute) : void 0) : void 0); 107 | } 108 | return _results; 109 | } 110 | })()); 111 | } 112 | return _results; 113 | }); 114 | }; 115 | }).call(this); 116 | -------------------------------------------------------------------------------- /spec/lib/calendar_spec.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require File.expand_path('spec/helpers') 3 | require File.expand_path('../../../lib/redmine_better_gantt_chart/redmine_better_gantt_chart', __FILE__) 4 | require File.expand_path('../../../lib/redmine_better_gantt_chart/calendar', __FILE__) 5 | 6 | describe RedmineBetterGanttChart::Calendar do 7 | include Helpers 8 | 9 | before(:each) do 10 | work_on_weekends true 11 | end 12 | 13 | let!(:thursday) { subject.next_day_of_week(4) } 14 | let!(:friday) { subject.next_day_of_week(5) } 15 | let!(:saturday) { subject.next_day_of_week(6) } 16 | 17 | it "should tell next requested day of week" do 18 | yesterday_date_of_week = Date.yesterday.wday 19 | subject.next_day_of_week(yesterday_date_of_week).should == Date.today + 6.days 20 | end 21 | 22 | it "should tell previous requested day of week" do 23 | tomorrow_day_of_week = Date.tomorrow.wday 24 | subject.previous_day_of_week(tomorrow_day_of_week).should == Date.today - 6.days 25 | end 26 | 27 | it "should have 8 working days between today and 1 week later if work on weekends enabled" do 28 | subject.workdays_between(Date.today, Date.today + 1.week).should == 8 29 | end 30 | 31 | it "should have 4 working days between today and 1 week later if work on weekends disabled" do 32 | work_on_weekends false 33 | subject.workdays_between(Date.today, Date.today + 1.week).should == 6 34 | end 35 | 36 | it "should have 1 working days between the same start and end dates" do 37 | subject.workdays_between(friday, friday).should == 1 38 | end 39 | 40 | it "should have 6 working days between saturday and next_monday if work on weekends disabled" do 41 | work_on_weekends false 42 | subject.workdays_between(saturday, saturday + 9).should == 6 43 | end 44 | 45 | it "should calculate the difference if date to is earlier than date from" do 46 | subject.workdays_between(friday, thursday).should == 47 | subject.workdays_between(thursday, friday).should 48 | end 49 | 50 | it "should tell next working day is friday if today is friday" do 51 | subject.next_working_day(friday).should == friday 52 | end 53 | 54 | it "should tell next working day is saturday if today is saturday and work on weekends is enabled" do 55 | subject.next_working_day(saturday).should == saturday 56 | end 57 | 58 | it "should tell next working day is monday if today is saturday and work on weekends is disabled" do 59 | work_on_weekends false 60 | subject.next_working_day(saturday).should == saturday + 2.days 61 | end 62 | 63 | it "should tell previous working day is friday if today is friday" do 64 | subject.previous_working_day(friday).should == friday 65 | end 66 | 67 | it "should tell previous working day is saturday if today is saturday and work on weekends is enabled" do 68 | subject.previous_working_day(saturday).should == saturday 69 | end 70 | 71 | it "should tell previous working day is friday if today is sunday and work on weekends is disabled" do 72 | work_on_weekends false 73 | subject.previous_working_day(saturday + 1.day).should == friday 74 | end 75 | 76 | it "should let calculate finish date based on duration and start date with work on weekends enabled" do 77 | subject.workdays_from_date(Date.today, 7).should == Date.today + 1.week 78 | end 79 | 80 | it "should let calculate finish date based on duration and start date wihout work on weekends" do 81 | work_on_weekends false 82 | subject.workdays_from_date(Date.today, 5).should == Date.today + 1.week 83 | end 84 | 85 | it "should calculate start date based on duration and finish date" do 86 | work_on_weekends false 87 | previous_friday = friday - 7 88 | previous_monday = friday - 4 89 | subject.workdays_from_date(friday, -4).should == previous_monday 90 | subject.workdays_from_date(friday, -6).should == previous_friday 91 | end 92 | 93 | it "should calculate workdays before date" do 94 | subject.should_receive(:workdays_from_date).with(Date.today, -5) 95 | subject.workdays_before_date(Date.today, 5) 96 | end 97 | 98 | it "should say that 6 working days from saturday is monday if work on weekends disabled" do 99 | work_on_weekends false 100 | next_monday = saturday + 9.days 101 | subject.workdays_from_date(saturday, 6).should == next_monday 102 | end 103 | 104 | it "should say that 6 working from sunday is monday if work on weekends disabled" do 105 | work_on_weekends false 106 | sunday = saturday + 1.day 107 | next_monday = sunday + 8.days 108 | subject.workdays_from_date(sunday, 6).should == next_monday 109 | end 110 | 111 | it "should return the same day if duration is 0" do 112 | work_on_weekends false 113 | subject.workdays_from_date(friday, 0).should == friday 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/redmine_better_gantt_chart/issue_dependency_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineBetterGanttChart 2 | module IssueDependencyPatch 3 | def self.included(base) # :nodoc: 4 | base.send(:include, InstanceMethods) 5 | 6 | base.class_eval do 7 | alias_method_chain :reschedule_following_issues, :fast_update 8 | alias_method_chain :reschedule_on!, :earlier_date 9 | alias_method_chain :soonest_start, :dependent_parent_validation 10 | alias_method_chain :duration, :work_days 11 | end 12 | end 13 | 14 | module InstanceMethods 15 | 16 | def create_journal_entry 17 | create_journal 18 | end 19 | 20 | # Redefined to work without recursion on AR objects 21 | def reschedule_following_issues_with_fast_update 22 | if start_date_changed? || due_date_changed? 23 | cache_and_apply_changes do 24 | reschedule_dependent_issue 25 | end 26 | end 27 | end 28 | 29 | def cache_and_apply_changes(&block) 30 | @changes = {} # a hash of changes to be applied later, will contain values like this: { issue_id => {:start_date => ..., :end_date => ...}} 31 | @parents = {} # a hash of children for any affected parent issue 32 | 33 | yield 34 | 35 | reschedule_parents 36 | ordered_changes = prepare_and_sort_changes_list(@changes) 37 | 38 | Issue.skip_callbacks do 39 | transaction do 40 | ordered_changes.each do |the_changes| 41 | issue_id, changes = the_changes[1] 42 | apply_issue_changes(issue_id, changes) 43 | end 44 | end 45 | end 46 | end 47 | 48 | def prepare_and_sort_changes_list(changes_list) 49 | ordered_changes = [] 50 | changes_list.each do |c| 51 | ordered_changes << [c[1][:due_date] || c[1][:start_date], c] 52 | end 53 | ordered_changes.sort! 54 | end 55 | 56 | def apply_issue_changes(issue_id, changes) 57 | issue = Issue.find(issue_id) 58 | changes.each_pair do |key, value| 59 | changes.delete(key) if issue.send(key) == value.to_date 60 | end 61 | unless changes.empty? 62 | issue.init_journal(User.current, I18n.t('task_moved_journal_entry', id: self.id ) ) 63 | issue.update_attributes(changes) 64 | issue.create_journal_entry 65 | end 66 | end 67 | 68 | def reschedule_dependent_issue(issue = self, options = {}) #start_date_to = nil, due_date_to = nil 69 | cache_change(issue, options) 70 | process_child_issues(issue) if !issue.leaf? 71 | process_following_issues(issue) 72 | update_parent_start_and_due(issue) if issue.parent_id 73 | end 74 | 75 | def process_child_issues(issue) 76 | childs_with_nil_start_dates = [] 77 | 78 | issue.leaves.each do |leaf| 79 | start_date = cached_value(issue, :start_date) 80 | child_start_date = cached_value(leaf, :start_date) 81 | 82 | cache_change(issue, :start_date => child_start_date) if start_date.nil? 83 | 84 | if child_start_date.nil? or 85 | (start_date > child_start_date) or 86 | (start_date < child_start_date and issue.start_date == leaf.start_date) 87 | reschedule_dependent_issue(leaf, :start_date => start_date) 88 | end 89 | end 90 | end 91 | 92 | def process_following_issues(issue) 93 | issue.relations_from.each do |relation| 94 | if is_a_link_with_following_issue?(relation) && due_date = cached_value(issue, :due_date) 95 | new_start_date = RedmineBetterGanttChart::Calendar.workdays_from_date(due_date, relation.delay) + 1.day 96 | new_start_date = RedmineBetterGanttChart::Calendar.next_working_day(new_start_date) 97 | reschedule_dependent_issue(relation.issue_to, :start_date => new_start_date) 98 | end 99 | end 100 | end 101 | 102 | def is_a_link_with_following_issue?(relation) 103 | relation.issue_to && relation.relation_type == IssueRelation::TYPE_PRECEDES 104 | end 105 | 106 | def reschedule_parents 107 | @parents.dup.each_pair do |parent_id, children| 108 | parent_min_start = min_parent_start(parent_id) 109 | parent_max_start = max_parent_due(parent_id) 110 | cache_change(parent_id, :start_date => parent_min_start, 111 | :due_date => parent_max_start) 112 | 113 | children.each do |child| # If parent's start is changing, change start_date of any childs that have empty start_date 114 | if cached_value(child, :start_date).nil? 115 | cache_change(child, :start_date => parent_min_start, :parent => true) 116 | end 117 | end 118 | 119 | process_following_issues(Issue.find(parent_id)) 120 | end 121 | end 122 | 123 | # Caches changes to be applied later. If no attributes to change given, just caches current values. 124 | # Use :parent => true to just change one date without changing the other. If :parent is not specified, 125 | # change of one of the issue dates will cause change of the other. 126 | # 127 | # If no options is provided existing, issue cache is initialized. 128 | def cache_change(issue, options = {}) 129 | if issue.is_a?(Integer) 130 | issue_id = issue 131 | issue = Issue.find(issue_id) unless options[:start_date] && options[:due_date] # optimization for the case when issue is not required 132 | else 133 | issue_id = issue.id 134 | end 135 | 136 | @changes[issue_id] ||= {} 137 | new_dates = {} 138 | 139 | if options.empty? || (options[:start_date] && options[:due_date]) 140 | # Both or none dates changed 141 | [:start_date, :due_date].each do |attr| 142 | new_dates[attr] = options[attr] || @changes[issue_id][attr] || issue.send(attr) 143 | end 144 | else 145 | # One of the dates changed - change another accordingly 146 | changed_attr = options[:start_date] && :start_date || :due_date 147 | other_attr = if changed_attr == :start_date then :due_date else :start_date end 148 | 149 | new_dates[changed_attr] = options[changed_attr] 150 | if issue.send(other_attr) 151 | if options[:parent] 152 | new_dates[other_attr] = issue.send(other_attr) 153 | else 154 | new_dates[other_attr] = RedmineBetterGanttChart::Calendar.workdays_from_date(new_dates[changed_attr], RedmineBetterGanttChart::Calendar.workdays_between(issue.send(changed_attr), issue.send(other_attr)).to_i - 1) end 155 | end 156 | end 157 | 158 | [:start_date, :due_date].each do |attr| 159 | @changes[issue_id][attr] = new_dates[attr].to_date if new_dates[attr] 160 | end 161 | end 162 | 163 | # Returns cached value or caches it if it hasn't been cached yet 164 | def cached_value(issue, attr) 165 | issue_id = issue.is_a?(Integer) ? issue : issue.id 166 | cache_change(issue_id) unless @changes[issue_id] 167 | @changes[issue_id][attr] || @changes[issue_id][:start_date] 168 | end 169 | 170 | # Each time we update cache of a child issue, need to update cache of the parent issue 171 | # by setting start_date to min(parent.all_children) and due_date to max(parent.all_children). 172 | # Apparently, to do so, first we need to add to cache all child issues of the parent, even if 173 | # they are not affected by rescheduling. 174 | def update_parent_start_and_due(issue) 175 | current_parent_id = issue.parent_id 176 | unless @parents[current_parent_id] 177 | # This parent is touched for the first time, let's cache it's children 178 | @parents[current_parent_id] = [issue.id] # at least the current issue is a child - even if it is not saved yet (is is possible?) 179 | issue.parent.children.each do |child| 180 | cache_change(child) unless @changes[child] 181 | @parents[current_parent_id] << child.id 182 | end 183 | end 184 | end 185 | 186 | def min_parent_start(current_parent_id) 187 | @parents[current_parent_id].uniq.inject(Date.new(5000)) do |min, child_id| # Someone needs to update this before 01/01/5000 188 | min = min < (current_child_start = cached_value(child_id, :start_date)) ? min : current_child_start rescue min 189 | end 190 | end 191 | 192 | def max_parent_due(current_parent_id) 193 | @parents[current_parent_id].uniq.inject(Date.new) do |max, child_id| 194 | max = max > (current_child_due = cached_value(child_id, :due_date)) ? max : current_child_due rescue max 195 | end 196 | end 197 | 198 | # Returns the time scheduled for this issue in working days. 199 | # 200 | def duration_with_work_days 201 | if self.start_date && self.due_date 202 | RedmineBetterGanttChart::Calendar.workdays_between(self.start_date, self.due_date) 203 | else 204 | 0 205 | end 206 | end 207 | 208 | # Changes behaviour of reschedule_on method 209 | def reschedule_on_with_earlier_date!(date) 210 | return if date.nil? 211 | 212 | if start_date.blank? || start_date != date 213 | if due_date.present? 214 | self.due_date = RedmineBetterGanttChart::Calendar.workdays_from_date(date, duration - 1) 215 | end 216 | self.start_date = date 217 | save 218 | end 219 | end 220 | 221 | # Modifies validation of soonest start date for a new task: 222 | # if parent task has dependency, start date cannot be earlier than start date of the parent. 223 | def soonest_start_with_dependent_parent_validation 224 | @soonest_start ||= ( 225 | relations_to.collect{|relation| relation.successor_soonest_start} + 226 | ancestors.collect(&:soonest_start) + 227 | [parent_start_constraint] 228 | ).compact.max 229 | end 230 | 231 | # Returns [soonest_start_date] if parent task has dependency contstraints 232 | # or [nil] otherwise 233 | def parent_start_constraint 234 | if parent_issue_id && @parent_issue 235 | @parent_issue.soonest_start 236 | else 237 | nil 238 | end 239 | end 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /spec/models/issue_patch_spec.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../spec_helper', __FILE__) 2 | 3 | describe 'Improved issue dependencies management' do 4 | context 'with work on weekends enabled' do 5 | before(:all) do 6 | work_on_weekends true 7 | end 8 | 9 | before(:each) do 10 | @first_issue, @second_issue = create_related_issues("precedes") 11 | end 12 | 13 | it "issue duration is in calendar days" do 14 | issue = Factory(:issue, :start_date => Date.today, :due_date => Date.today + 1.week) 15 | issue.duration.should == 8 16 | end 17 | 18 | it 'changes date of the dependent task when due date of the first task is moved forward' do 19 | lambda { 20 | @first_issue.due_date = @first_issue.due_date + 2.days 21 | @first_issue.save! 22 | @second_issue.reload 23 | }.should change(@second_issue, :start_date).to(@first_issue.due_date + 3.days) 24 | end 25 | 26 | it 'changes date of the dependent task when due date of the first task is moved back' do 27 | lambda { 28 | @first_issue.due_date = @first_issue.due_date - 2.days 29 | @first_issue.save! 30 | @second_issue.reload 31 | }.should change(@second_issue, :start_date).to(@first_issue.due_date - 1.day) 32 | end 33 | 34 | it 'doesn\'t allow set start date earlier than parent.soonest_start' do 35 | child_issue = Factory.build(:issue) 36 | child_issue.parent_issue_id = @second_issue.id 37 | expect { 38 | child_issue.start_date = @second_issue.start_date - 1 39 | child_issue.save! 40 | }.to raise_error(ActiveRecord::RecordInvalid) 41 | end 42 | 43 | it "doesn't fail when removing an only child issue from the parent" do 44 | parent_issue, next_issue = create_related_issues("precedes") 45 | child_issue = Factory(:issue) 46 | child_issue.parent_issue_id = parent_issue.id 47 | child_issue.save! 48 | parent_issue.reload 49 | 50 | expect { 51 | child_issue.parent_issue_id = nil 52 | child_issue.save! 53 | }.not_to raise_error() 54 | end 55 | 56 | it "doesn't fail when assigning start_date to a child issue when parent's start_date is empty and siblings' start_dates are empty" do 57 | parent_issue = Factory(:issue, :start_date => nil, :due_date => nil) 58 | child_issue1 = Factory(:issue, :start_date => nil, :due_date => nil) 59 | child_issue2 = Factory(:issue, :start_date => nil, :due_date => nil) 60 | child_issue1.parent_issue_id = parent_issue.id 61 | child_issue2.parent_issue_id = parent_issue.id 62 | child_issue1.save! 63 | child_issue2.save! 64 | parent_issue.reload 65 | 66 | expect { 67 | child_issue1.start_date = Date.today 68 | child_issue1.save! 69 | }.not_to raise_error() 70 | end 71 | 72 | it "doesn't fail when start_date of an issue deep in hierarchy is changed from empty" do 73 | parent_issue = Factory(:issue, :start_date => nil, :due_date => nil) 74 | child_issue1 = Factory(:issue, :start_date => nil, :due_date => nil) 75 | child_issue2 = Factory(:issue, :start_date => nil, :due_date => nil) 76 | child_issue1.parent_issue_id = parent_issue.id 77 | child_issue2.parent_issue_id = parent_issue.id 78 | child_issue1.save! 79 | child_issue2.save! 80 | 81 | child_issue1_1 = Factory(:issue, :start_date => nil, :due_date => nil) 82 | child_issue1_2 = Factory(:issue, :start_date => nil, :due_date => nil) 83 | child_issue1_1.parent_issue_id = child_issue1.id 84 | child_issue1_2.parent_issue_id = child_issue1.id 85 | child_issue1_1.save! 86 | child_issue1_2.save! 87 | 88 | child_issue2_1 = Factory(:issue, :start_date => nil, :due_date => nil) 89 | child_issue2_2 = Factory(:issue, :start_date => nil, :due_date => nil) 90 | child_issue2_1.parent_issue_id = child_issue2.id 91 | child_issue2_2.parent_issue_id = child_issue2.id 92 | child_issue2_1.save! 93 | child_issue2_2.save! 94 | 95 | expect { 96 | child_issue2_2.start_date = Date.today 97 | child_issue2_2.save! 98 | }.not_to raise_error() 99 | end 100 | 101 | it "doesn't fail when an issue without start or due date becomes a parent issue" do 102 | parent_issue = Factory(:issue, :start_date => nil, :due_date => nil) 103 | child_issue = Factory(:issue, :due_date => nil) 104 | 105 | expect { 106 | child_issue.parent_issue_id = parent_issue.id 107 | child_issue.save! 108 | }.not_to raise_error() 109 | end 110 | 111 | describe 'handles long dependency chains' do 112 | before do 113 | @start_issue = Factory(:issue) 114 | @current_issue = @start_issue 115 | # Change X.times to a really big number to stress test rescheduling of a really long chain of dependent issues :) 116 | 3.times do 117 | previous_issue, @current_issue = create_related_issues("precedes", @current_issue) 118 | end 119 | end 120 | 121 | it 'and reschedules tens of related issues when due date of the first issue is moved back' do 122 | si = Issue.find(@start_issue.id + 1) 123 | lambda { 124 | @start_issue.due_date = @start_issue.due_date - 2.days 125 | @start_issue.save! 126 | @current_issue.reload 127 | }.should change(@current_issue, :start_date).to(@current_issue.start_date - 2.days) 128 | end 129 | 130 | it 'and reschedules tens of related issues when due date of the first task is moved forth' do 131 | lambda { 132 | @start_issue.due_date = @start_issue.due_date + 2.days 133 | @start_issue.save! 134 | @current_issue.reload 135 | }.should change(@current_issue, :start_date).to(@current_issue.start_date + 2.days) 136 | end 137 | 138 | it 'and doesn\'t allow create circular dependencies' do 139 | expect { 140 | create_related_issues("precedes", @current_issue, @start_issue) 141 | }.to raise_error(ActiveRecord::RecordInvalid) 142 | end 143 | end 144 | 145 | describe 'allows fast rescheduling of dependent issues' do 146 | before do 147 | # Testing on the following dependency chain: 148 | # @initial -> @related -> @parent [ @child1 -> @child2] 149 | @initial, @related = create_related_issues("precedes") 150 | @related, @parent = create_related_issues("precedes", @related) 151 | @child1 = Factory.build(:issue, :start_date => @parent.start_date) 152 | @child2 = Factory.build(:issue, :start_date => @parent.start_date) 153 | @child1.parent_issue_id = @parent.id 154 | @child1.save! 155 | @child2.parent_issue_id = @parent.id 156 | @child2.save! 157 | 158 | create_related_issues("precedes", @child1, @child2) 159 | end 160 | 161 | it "should change start date of the last dependend child issue when due date of the first issue moved FORWARD" do 162 | lambda { 163 | @initial.due_date = @initial.due_date + 2.days 164 | @initial.save! 165 | @child2.reload 166 | }.should change(@child2, :start_date).to(@child2.start_date + 2.days) 167 | end 168 | 169 | it "should change start date of the last dependend child issue when due date of the first issue moved BACK" do 170 | lambda { 171 | @initial.due_date = @initial.due_date - 2.days 172 | @initial.save! 173 | @child2.reload 174 | }.should change(@child2, :start_date).to(@child2.start_date - 2.days) 175 | end 176 | 177 | it "should not fail when due_date of one of rescheduled issues is nil" do 178 | initial, child = create_related_issues("precedes") 179 | parent = Factory(:issue, :due_date => nil) 180 | other_child = Factory(:issue, :due_date => nil) 181 | child.parent_issue_id = parent.id 182 | child.save! 183 | other_child.parent_issue_id = parent.id 184 | other_child.save! 185 | parent.reload 186 | other_child.reload 187 | child.destroy 188 | end 189 | 190 | it "should reschedule start date of parent task of a dependend child task" do 191 | parent_a = Factory(:issue) 192 | child_a = Factory.build(:issue, :start_date => parent_a.start_date) 193 | child_b = Factory.build(:issue, :start_date => parent_a.start_date) 194 | child_a.parent_issue_id = parent_a.id 195 | child_b.parent_issue_id = parent_a.id 196 | child_a.save! 197 | child_b.save! 198 | 199 | child_a, child_b = create_related_issues("precedes", child_a, child_b) 200 | 201 | parent_b = Factory(:issue) 202 | child_c = Factory.build(:issue, :start_date => parent_b.start_date) 203 | child_d = Factory.build(:issue, :start_date => parent_b.start_date) 204 | child_c.parent_issue_id = parent_b.id 205 | child_d.parent_issue_id = parent_b.id 206 | child_c.save! 207 | child_d.save! 208 | 209 | child_b, child_c = create_related_issues("precedes", child_b, child_c) 210 | child_c, child_d = create_related_issues("precedes", child_c, child_d) 211 | 212 | parent_b.reload 213 | 214 | parent_start = parent_b.start_date 215 | parent_due = parent_b.due_date 216 | 217 | child_a.due_date = child_a.due_date - 2.days 218 | child_a.save! 219 | parent_b.reload 220 | 221 | parent_b.start_date.should == parent_start - 2.days 222 | parent_b.due_date.should == parent_due - 2.days 223 | end 224 | 225 | it "should reshedule a following task of a parent task, when the parent task itself being rescheduled after changes in it's child task" do 226 | parent = Factory(:issue) 227 | child = Factory(:issue, :start_date => Date.today, 228 | :due_date => Date.today + 1, 229 | :parent_issue_id => parent.id) 230 | follower = Factory(:issue, :start_date => Date.today, :due_date => Date.today + 1) 231 | relate_issues parent, follower 232 | 233 | child.update_attributes(:due_date => Date.today + 7) 234 | parent.reload 235 | follower.reload 236 | 237 | follower.start_date.should == parent.due_date + 1 238 | end 239 | end 240 | end 241 | 242 | context "when work on weekends is disabled" do 243 | before(:all) do 244 | work_on_weekends false 245 | end 246 | 247 | it "issue duration is in working days" do 248 | issue = Factory(:issue, :start_date => Date.today, :due_date => Date.today + 1.week) 249 | issue.duration.should == 6 250 | end 251 | 252 | it "should reschedule after with earlier date" do 253 | monday = RedmineBetterGanttChart::Calendar.next_day_of_week(1) 254 | @first_issue = Factory(:issue, :start_date => monday, :due_date => monday + 1) 255 | @second_issue = Factory(:issue, :start_date => monday, :due_date => monday + 1) 256 | 257 | lambda { 258 | relate_issues(@first_issue, @second_issue) 259 | }.should change(@second_issue, :due_date).to(monday + 3) 260 | 261 | end 262 | 263 | it "lets create a relation between issues without due dates" do 264 | pop = Factory(:issue) 265 | parent_issue = Factory(:issue, :start_date => Date.today, :due_date => nil) 266 | issue1 = Factory(:issue, :start_date => Date.today, :due_date => nil) 267 | issue1.update_attributes!(:parent_issue_id => parent_issue.id) 268 | issue2 = Factory(:issue, :start_date => Date.today, :due_date => nil) 269 | issue2.update_attributes!(:parent_issue_id => parent_issue.id) 270 | relate_issues(issue2, issue1, "follows") 271 | 272 | issue1.reload 273 | issue1.relations.count.should == 1 274 | end 275 | 276 | end 277 | end 278 | -------------------------------------------------------------------------------- /app/views/gantts/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= javascript_include_tag "raphael-min.js", :plugin => "redmine_better_gantt_chart" %> 2 | <%= javascript_include_tag "raphael.arrow.js", :plugin => "redmine_better_gantt_chart" %> 3 | <% @gantt.view = self %> 4 |

<%= @query.new_record? ? l(:label_gantt) : h(@query.name) %>

5 | 6 | <%= form_tag({:controller => 'gantts', :action => 'show', 7 | :project_id => @project, :month => params[:month], 8 | :year => params[:year], :months => params[:months]}, 9 | :method => :get, :id => 'query_form') do %> 10 | <%= hidden_field_tag 'set_filter', '1' %> 11 |
"> 12 | <%= l(:label_filter_plural) %> 13 |
"> 14 | <%= render :partial => 'queries/filters', :locals => {:query => @query} %> 15 |
16 |
17 | 18 |

19 | <%= gantt_zoom_link(@gantt, :in) %> 20 | <%= gantt_zoom_link(@gantt, :out) %> 21 |

22 | 23 |

24 | <%= text_field_tag 'months', @gantt.months, :size => 2 %> 25 | <%= l(:label_months_from) %> 26 | <%= select_month(@gantt.month_from, :prefix => "month", :discard_type => true) %> 27 | <%= select_year(@gantt.year_from, :prefix => "year", :discard_type => true) %> 28 | <%= hidden_field_tag 'zoom', @gantt.zoom %> 29 | 30 | <%= link_to_function l(:button_apply), '$("#query_form").submit()', 31 | :class => 'icon icon-checked' %> 32 | <%= link_to l(:button_clear), { :project_id => @project, :set_filter => 1 }, 33 | :class => 'icon icon-reload' %> 34 |

35 | <% end %> 36 | 37 | <%= error_messages_for 'query' %> 38 | <% if @query.valid? %> 39 | <% 40 | zoom = 1 41 | @gantt.zoom.times { zoom = zoom * 2 } 42 | 43 | subject_width = 330 44 | header_heigth = 18 45 | line_height = 18 46 | 47 | show_years = true 48 | show_weeks = false 49 | show_days = false 50 | show_day_numbers = false 51 | 52 | headers_height = 2 * header_heigth 53 | if @gantt.zoom > 1 54 | show_years = false 55 | show_weeks = true 56 | if @gantt.zoom > 2 57 | show_days = true 58 | headers_height = (3 * header_heigth).to_i 59 | if @gantt.zoom > 3 60 | show_day_numbers = true 61 | headers_height = (3.25 * header_heigth).to_i 62 | end 63 | end 64 | end 65 | 66 | # Width of the entire chart 67 | g_width = ((@gantt.work_days_in(@gantt.date_to, @gantt.date_from) + 1) * zoom).to_i 68 | @gantt.render(:top => headers_height + 8, 69 | :zoom => zoom, 70 | :g_width => g_width, 71 | :subject_width => subject_width) 72 | g_height = [(line_height * (@gantt.number_of_rows + 2)) + 0, 208 ].max 73 | t_height = g_height + headers_height; 74 | s_height = 24; 75 | %> 76 | 77 | <% if @gantt.truncated %> 78 |

<%= l(:notice_gantt_chart_truncated, :max => @gantt.max_rows) %>

79 | <% end %> 80 | 81 | 82 | 83 | 113 | 114 | 305 | 306 |
84 | <% 85 | style = "" 86 | style += "position:relative;" 87 | style += "height: #{t_height + s_height}px;" 88 | style += "width: #{subject_width + 1}px;" 89 | %> 90 | <%= content_tag(:div, :style => style) do %> 91 | <% 92 | style = "" 93 | style += "right:-2px;" 94 | style += "width: #{subject_width}px;" 95 | style += "height: #{headers_height}px;" 96 | style += 'background: #eee;' 97 | %> 98 | <%= content_tag(:div, "", :style => style, :class => "gantt_hdr") %> 99 | <% 100 | style = "" 101 | style += "right:-2px;" 102 | style += "width: #{subject_width}px;" 103 | style += "height: #{t_height}px;" 104 | style += 'border-left: 1px solid #c0c0c0;' 105 | style += 'overflow: hidden;' 106 | %> 107 | <%= content_tag(:div, "", :style => style, :class => "gantt_hdr") %> 108 | <%= content_tag(:div, :class => "gantt_subjects") do %> 109 | <%= @gantt.subjects.html_safe %> 110 | <% end %> 111 | <% end %> 112 | 115 |
116 | <% 117 | style = "" 118 | style += "width: #{g_width - 1}px;" 119 | style += "height: #{headers_height}px;" 120 | style += 'background: #eee;' 121 | %> 122 | <%= content_tag(:div, ' '.html_safe, :style => style, :class => "gantt_hdr") %> 123 | 124 | <% ###### Today red line (excluded from cache) ###### %> 125 | <% if Date.today >= @gantt.date_from and Date.today <= @gantt.date_to %> 126 | <% 127 | width = zoom 128 | today_left = (((@gantt.work_days_in(Date.today, @gantt.date_from) + 0) * zoom).floor() - 1).to_i 129 | style = "" 130 | style += "position: absolute;" 131 | style += "height: #{headers_height + g_height}px;" 132 | style += "top: 0px;" 133 | style += "left: #{today_left}px;" 134 | style += "width: #{width}px;" 135 | #style += "border-left: 1px dashed red;" 136 | style += 'background:#ffe3e3;' 137 | %> 138 | <%= content_tag(:div, ' '.html_safe, :style => style) %> 139 | <% end %> 140 | 141 | <% ###### Years headers ###### %> 142 | <% if show_years %> 143 | <% 144 | left = 0 145 | height = (show_weeks ? header_heigth : header_heigth + g_height) 146 | years = 1 147 | months_remaining = @gantt.months - (12 - @gantt.date_from.month) - 1 148 | years += months_remaining <= 0 ? 0 : (months_remaining / 12).to_i + 1 149 | %> 150 | <% years.times do |year| %> 151 | <% 152 | year = year + @gantt.date_from.year.to_i 153 | if year == @gantt.date_from.year 154 | work_days = @gantt.work_days_in(Date.new(year + 1), @gantt.date_from) 155 | months_remaining -= 12 - (@gantt.date_from.month).to_i - 1 156 | elsif months_remaining < 12 157 | work_days = @gantt.work_days_in(Date.new(year, months_remaining+1, 1), Date.new(year)) 158 | months_remaining = 0 159 | else 160 | work_days = @gantt.work_days_in(Date.new(year + 1), Date.new(year)) 161 | months_remaining -= 12 162 | end 163 | width = (work_days * zoom - 1).to_i 164 | style = "" 165 | style += "left: #{left}px;" 166 | style += "width: #{width}px;" 167 | style += "height: #{height}px;" 168 | %> 169 | <%= content_tag(:div, :style => style, :class => "gantt_hdr") do %> 170 | <%= link_to h("#{year}"), 171 | @gantt.params.merge(:year => year), 172 | :title => "#{year}" %> 173 | <% end %> 174 | <% 175 | left = left + width + 1 176 | %> 177 | <% end %> 178 | <% end %> 179 | 180 | <% ###### Months headers ###### %> 181 | <% 182 | month_f = @gantt.date_from 183 | left = 0 184 | height = (show_weeks ? header_heigth : header_heigth + g_height) 185 | %> 186 | <% @gantt.months.times do %> 187 | <% 188 | width = (@gantt.work_days_in(month_f >> 1, month_f) * zoom - 1).to_i 189 | style = "" 190 | style += "top: #{header_heigth+1}px;" if show_years 191 | style += "left: #{left}px;" 192 | style += "width: #{width}px;" 193 | style += "height: #{height}px;" 194 | %> 195 | <%= content_tag(:div, :style => style, :class => "gantt_hdr") do %> 196 | <% 197 | month_title = month_f.strftime("%b") 198 | month_title += " #{month_f.year}" unless show_years 199 | %> 200 | <%= link_to h("#{month_title}"), 201 | @gantt.params.merge(:year => month_f.year, :month => month_f.month), 202 | :title => "#{month_name(month_f.month)} #{month_f.year}" %> 203 | <% end %> 204 | <% 205 | left = left + width + 1 206 | month_f = month_f >> 1 207 | %> 208 | <% end %> 209 | 210 | <% ###### Weeks headers ###### %> 211 | <% if show_weeks %> 212 | <% 213 | work_days_in_week = @gantt.work_on_weekends ? 7 : 5 214 | left = 0 215 | height = (show_days ? header_heigth - 1 : header_heigth - 1 + g_height) 216 | %> 217 | <% if @gantt.date_from.cwday == 1 %> 218 | <% 219 | # @date_from is monday 220 | week_f = @gantt.date_from 221 | %> 222 | <% else %> 223 | <% 224 | # find next monday after @date_from 225 | week_f = @gantt.date_from + (7 - @gantt.date_from.cwday + 1) 226 | width = (work_days_in_week - @gantt.date_from.cwday + 1) * zoom - 1 227 | style = "" 228 | style += "left: #{left}px;" 229 | style += "top: #{header_heigth+1}px;" 230 | style += "width: #{width}px;" 231 | style += "height: #{height}px;" 232 | %> 233 | <%= content_tag(:div, ' '.html_safe, 234 | :style => style, :class => "gantt_hdr") %> 235 | <% left = left + width + 1 %> 236 | <% end %> 237 | <% while week_f <= @gantt.date_to %> 238 | <% 239 | width = ((week_f + work_days_in_week - 1 <= @gantt.date_to) ? 240 | work_days_in_week * zoom - 1 : 241 | (@gantt.date_to - week_f + 1) * zoom - 1).to_i 242 | style = "" 243 | style += "left: #{left}px;" 244 | style += "top: #{header_heigth+1}px;" 245 | style += "width: #{width}px;" 246 | style += "height: #{height}px;" 247 | %> 248 | <%= content_tag(:div, :style => style, :class => "gantt_hdr") do %> 249 | <%= content_tag(:small) do %> 250 | <%= week_f.cweek if width >= 16 %> 251 | <% end %> 252 | <% end %> 253 | <% 254 | left = left + width + 1 255 | week_f = week_f + 7 256 | %> 257 | <% end %> 258 | <% end %> 259 | 260 | <% ###### Days headers ####### %> 261 | <% if show_days %> 262 | <% 263 | left = 0 264 | height = g_height + header_heigth - 1 265 | wday = @gantt.date_from.cwday 266 | %> 267 | <% (@gantt.date_from).upto(@gantt.date_to) do |day| %> 268 | <% if (day.cwday <= work_days_in_week) %> 269 | <% 270 | width = zoom - 1 271 | day_abbr = [:"zh", :"zh-TW"].include?(current_language) ? day_name(wday)[6,3] : day_name(wday).first # correct abbreviation of day of week for Chinese language 272 | style = "" 273 | style += "left: #{left}px;" 274 | style += "top: #{(header_heigth*2)+1}px;" 275 | style += "width: #{width}px;" 276 | style += "height: #{height}px;" 277 | style += "text-align:center;" 278 | style += "font-size:0.7em;" 279 | style += 'background:#f1f1f1;' if (@gantt.work_on_weekends && day.cwday > 5) 280 | style += 'border-left: 1px solid #c6c6c6;' if (!@gantt.work_on_weekends && day.cwday == 1) 281 | %> 282 | <%= content_tag(:div, :style => style, :class => "gantt_hdr") do %> 283 | <%= day_letter(wday) %> 284 | <% if show_day_numbers %> 285 |
286 | <%= (day).mday %> 287 | <% end %> 288 | <% end %> 289 | <% end %> 290 | <% 291 | if day.cwday <= work_days_in_week 292 | left = left + width+1 293 | wday = wday + 1 294 | else 295 | wday = 1 296 | end 297 | %> 298 | <% end %> 299 | <% end %> 300 | 301 | <%= @gantt.lines.html_safe %> 302 | 303 |
304 |
307 | 308 | 309 | 310 | 314 | 318 | 319 |
311 | <%= link_to_content_update("\xc2\xab " + l(:label_previous), 312 | params.merge(@gantt.params_previous)) %> 313 | 315 | <%= link_to_content_update(l(:label_next) + " \xc2\xbb", 316 | params.merge(@gantt.params_next)) %> 317 |
320 | 321 | <% other_formats_links do |f| %> 322 | <%= f.link_to 'PDF', :url => params.merge(@gantt.params) %> 323 | <%= f.link_to('PNG', :url => params.merge(@gantt.params)) if @gantt.respond_to?('to_image') %> 324 | <% end %> 325 | <% end # query.valid? %> 326 | 327 | <% content_for :sidebar do %> 328 | <%= render :partial => 'issues/sidebar' %> 329 | <% end %> 330 | 331 | <% html_title(l(:label_gantt)) -%> 332 | <%= javascript_tag("window.redrawGanttArrows()") -%> 333 | -------------------------------------------------------------------------------- /lib/redmine/helpers/better_gantt.rb: -------------------------------------------------------------------------------- 1 | # Redmine - project management software 2 | # Copyright (C) 2006-2011 Jean-Philippe Lang 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 2 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 17 | 18 | module Redmine 19 | module Helpers 20 | # Simple class to handle gantt chart data 21 | class BetterGantt 22 | include ERB::Util 23 | include Redmine::I18n 24 | 25 | # :nodoc: 26 | # Some utility methods for the PDF export 27 | class PDF 28 | MaxCharactorsForSubject = 45 29 | TotalWidth = 280 30 | LeftPaneWidth = 100 31 | 32 | def self.right_pane_width 33 | TotalWidth - LeftPaneWidth 34 | end 35 | end 36 | 37 | attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months, 38 | :truncated, :max_rows, :work_on_weekends 39 | attr_accessor :query 40 | attr_accessor :project 41 | attr_accessor :view 42 | 43 | def initialize(options={}) 44 | options = options.dup 45 | 46 | if options[:year] && options[:year].to_i >0 47 | @year_from = options[:year].to_i 48 | if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12 49 | @month_from = options[:month].to_i 50 | else 51 | @month_from = 1 52 | end 53 | else 54 | @month_from ||= Date.today.month 55 | @year_from ||= Date.today.year 56 | end 57 | 58 | zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i 59 | @zoom = (zoom > 0 && zoom < 5) ? zoom : 2 60 | months = (options[:months] || User.current.pref[:gantt_months]).to_i 61 | @months = (months > 0 && months < 25) ? months : 6 62 | @work_on_weekends = RedmineBetterGanttChart.work_on_weekends? 63 | work_on_weekends = @work_on_weekends 64 | 65 | # Save gantt parameters as user preference (zoom and months count) 66 | if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] || @months != User.current.pref[:gantt_months])) 67 | User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months 68 | User.current.preference.save 69 | end 70 | 71 | @date_from = Date.civil(@year_from, @month_from, 1) 72 | @date_to = (@date_from >> @months) - 1 73 | 74 | @subjects = '' 75 | @lines = '' 76 | @number_of_rows = nil 77 | 78 | @issue_ancestors = [] 79 | 80 | @truncated = false 81 | if options.has_key?(:max_rows) 82 | @max_rows = options[:max_rows] 83 | else 84 | @max_rows = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i 85 | end 86 | end 87 | 88 | def common_params 89 | { :controller => 'gantts', :action => 'show', :project_id => @project } 90 | end 91 | 92 | def params 93 | common_params.merge({ :zoom => zoom, :year => year_from, 94 | :month => month_from, :months => months, 95 | :work_on_weekends => work_on_weekends }) 96 | end 97 | 98 | def params_previous 99 | common_params.merge({:year => (date_from << months).year, 100 | :month => (date_from << months).month, 101 | :zoom => zoom, :months => months, 102 | :work_on_weekends => work_on_weekends }) 103 | end 104 | 105 | def params_next 106 | common_params.merge({:year => (date_from >> months).year, 107 | :month => (date_from >> months).month, 108 | :zoom => zoom, :months => months, 109 | :work_on_weekends => work_on_weekends }) 110 | end 111 | 112 | # Returns the number of rows that will be rendered on the Gantt chart 113 | def number_of_rows 114 | return @number_of_rows if @number_of_rows 115 | 116 | rows = projects.inject(0) {|total, p| total += number_of_rows_on_project(p)} 117 | rows > @max_rows ? @max_rows : rows 118 | end 119 | 120 | # Returns the number of rows that will be used to list a project on 121 | # the Gantt chart. This will recurse for each subproject. 122 | # Adds cross-project related issues to counting 123 | def number_of_rows_on_project(project) 124 | return 0 unless projects.include?(project) 125 | 126 | count = 1 127 | count += project_issues(project).size 128 | count += project_versions(project).size 129 | count 130 | end 131 | 132 | # Renders the subjects of the Gantt chart, the left side. 133 | def subjects(options={}) 134 | render(options.merge(:only => :subjects)) unless @subjects_rendered 135 | @subjects 136 | end 137 | 138 | # Renders the lines of the Gantt chart, the right side 139 | def lines(options={}) 140 | render(options.merge(:only => :lines)) unless @lines_rendered 141 | @lines 142 | end 143 | 144 | # Returns issues that will be rendered 145 | def issues 146 | @issues ||= @query.issues( 147 | :include => [:assigned_to, :tracker, :priority, :category, :fixed_version], 148 | :order => "#{Project.table_name}.lft ASC, #{Issue.table_name}.id ASC", 149 | :limit => @max_rows 150 | ) 151 | end 152 | 153 | # Return all the project nodes that will be displayed 154 | def projects 155 | return @projects if @projects 156 | 157 | ids = issues.collect(&:project).uniq.collect(&:id) 158 | if ids.any? 159 | # All issues projects and their visible ancestors 160 | @projects = Project.visible.all( 161 | :joins => "LEFT JOIN #{Project.table_name} child ON #{Project.table_name}.lft <= child.lft AND #{Project.table_name}.rgt >= child.rgt", 162 | :conditions => ["child.id IN (?)", ids], 163 | :order => "#{Project.table_name}.lft ASC" 164 | ).uniq 165 | else 166 | @projects = [] 167 | end 168 | end 169 | 170 | # Returns the issues that belong to +project+ 171 | def project_issues(project) 172 | @issues_by_project ||= issues.group_by(&:project) 173 | @issues_by_project[project] || [] 174 | end 175 | 176 | # Returns the distinct versions of the issues that belong to +project+ 177 | def project_versions(project) 178 | project_issues(project).collect(&:fixed_version).compact.uniq 179 | end 180 | 181 | # Returns the issues that belong to +project+ and are assigned to +version+ 182 | def version_issues(project, version) 183 | project_issues(project).select {|issue| issue.fixed_version == version} 184 | end 185 | 186 | def render(options={}) 187 | options = {:top => 0, :top_increment => 18, :indent_increment => 18, :render => :subject, :format => :html}.merge(options) 188 | indent = options[:indent] || 4 189 | 190 | @subjects = '' unless options[:only] == :lines 191 | @lines = '' unless options[:only] == :subjects 192 | @number_of_rows = 0 193 | 194 | Project.project_tree(projects) do |project, level| 195 | options[:indent] = indent + level * options[:indent_increment] 196 | render_project(project, options) 197 | break if abort? 198 | end 199 | 200 | @subjects_rendered = true unless options[:only] == :lines 201 | @lines_rendered = true unless options[:only] == :subjects 202 | 203 | render_end(options) 204 | end 205 | 206 | def render_project(project, options={}) 207 | subject_for_project(project, options) unless options[:only] == :lines 208 | line_for_project(project, options) unless options[:only] == :subjects 209 | 210 | options[:top] += options[:top_increment] 211 | options[:indent] += options[:indent_increment] 212 | @number_of_rows += 1 213 | return if abort? 214 | 215 | issues = project_issues(project).select {|i| i.fixed_version.nil?} 216 | sort_issues!(issues) 217 | if issues 218 | render_issues(issues, options) 219 | return if abort? 220 | end 221 | 222 | versions = project_versions(project) 223 | versions.sort.each do |version| 224 | render_version(project, version, options) 225 | end 226 | 227 | # Remove indent to hit the next sibling 228 | options[:indent] -= options[:indent_increment] 229 | end 230 | 231 | def render_issues(issues, options={}) 232 | @issue_ancestors = [] 233 | 234 | issues.each do |i| 235 | subject_for_issue(i, options) unless options[:only] == :lines 236 | line_for_issue(i, options) unless options[:only] == :subjects 237 | 238 | options[:top] += options[:top_increment] 239 | @number_of_rows += 1 240 | break if abort? 241 | end 242 | 243 | options[:indent] -= (options[:indent_increment] * @issue_ancestors.size) 244 | end 245 | 246 | def render_version(project, version, options={}) 247 | # Version header 248 | subject_for_version(version, options) unless options[:only] == :lines 249 | line_for_version(version, options) unless options[:only] == :subjects 250 | 251 | options[:top] += options[:top_increment] 252 | @number_of_rows += 1 253 | return if abort? 254 | 255 | issues = version_issues(project, version) 256 | if issues 257 | sort_issues!(issues) 258 | # Indent issues 259 | options[:indent] += options[:indent_increment] 260 | render_issues(issues, options) 261 | options[:indent] -= options[:indent_increment] 262 | end 263 | end 264 | 265 | def render_end(options={}) 266 | case options[:format] 267 | when :pdf 268 | options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top]) 269 | end 270 | end 271 | 272 | def subject_for_project(project, options) 273 | case options[:format] 274 | when :html 275 | subject = "" 276 | subject << view.link_to_project(project) 277 | subject << '' 278 | html_subject(options, subject, :css => "project-name") 279 | when :image 280 | image_subject(options, project.name) 281 | when :pdf 282 | pdf_new_page?(options) 283 | pdf_subject(options, project.name) 284 | end 285 | end 286 | 287 | def line_for_project(project, options) 288 | # Skip versions that don't have a start_date or due date 289 | if project.is_a?(Project) && project.start_date && project.due_date 290 | options[:zoom] ||= 1 291 | options[:g_width] ||= (work_days_in(self.date_to, self.date_from) + 1) * options[:zoom] 292 | 293 | coords = coordinates(project.start_date, project.due_date, nil, options[:zoom]) 294 | label = h(project) 295 | 296 | case options[:format] 297 | when :html 298 | html_task(options, coords, :css => "project task", :label => label, :markers => true) 299 | when :image 300 | image_task(options, coords, :label => label, :markers => true, :height => 3) 301 | when :pdf 302 | pdf_task(options, coords, :label => label, :markers => true, :height => 0.8) 303 | end 304 | else 305 | ActiveRecord::Base.logger.debug "Gantt#line_for_project was not given a project with a start_date" 306 | '' 307 | end 308 | end 309 | 310 | def subject_for_version(version, options) 311 | case options[:format] 312 | when :html 313 | subject = "" 314 | subject << view.link_to_version(version) 315 | subject << '' 316 | html_subject(options, subject, :css => "version-name") 317 | when :image 318 | image_subject(options, version.to_s_with_project) 319 | when :pdf 320 | pdf_new_page?(options) 321 | pdf_subject(options, version.to_s_with_project) 322 | end 323 | end 324 | 325 | def line_for_version(version, options) 326 | # Skip versions that don't have a start_date 327 | if version.is_a?(Version) && version.start_date && version.due_date 328 | options[:zoom] ||= 1 329 | options[:g_width] ||= (work_days_in(self.date_to, self.date_from) + 1) * options[:zoom] 330 | 331 | coords = coordinates(version.start_date, version.due_date, version.completed_percent, options[:zoom]) 332 | label = "#{h version } #{h version.completed_percent.to_i.to_s}%" 333 | label = h("#{version.project} -") + label unless @project && @project == version.project 334 | 335 | case options[:format] 336 | when :html 337 | html_task(options, coords, :css => "version task", :label => label, :markers => true) 338 | when :image 339 | image_task(options, coords, :label => label, :markers => true, :height => 3) 340 | when :pdf 341 | pdf_task(options, coords, :label => label, :markers => true, :height => 0.8) 342 | end 343 | else 344 | ActiveRecord::Base.logger.debug "Gantt#line_for_version was not given a version with a start_date" 345 | '' 346 | end 347 | end 348 | 349 | # Prefixes cross-project related issues with their project name 350 | def subject_for_issue(issue, options) 351 | while @issue_ancestors.any? && !issue.is_descendant_of?(@issue_ancestors.last) 352 | @issue_ancestors.pop 353 | options[:indent] -= options[:indent_increment] 354 | end 355 | 356 | output = case options[:format] 357 | when :html 358 | css_classes = '' 359 | css_classes << ' issue-overdue' if issue.overdue? 360 | css_classes << ' issue-behind-schedule' if issue.behind_schedule? 361 | css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to 362 | 363 | subject = "" 364 | if issue.assigned_to.present? 365 | assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name 366 | subject << view.avatar(issue.assigned_to, :class => 'gravatar icon-gravatar', :size => 10, :title => assigned_string).to_s 367 | end 368 | subject << "(" + view.link_to_project(issue.project) + ") " if issue.external? 369 | subject << view.link_to_issue(issue) 370 | subject << '' 371 | html_subject(options, subject, :css => "issue-subject", :title => issue.subject, :external => issue.external?) + "\n" 372 | when :image 373 | image_subject(options, issue.subject) 374 | when :pdf 375 | pdf_new_page?(options) 376 | pdf_subject(options, issue.subject) 377 | end 378 | 379 | unless issue.leaf? 380 | @issue_ancestors << issue 381 | options[:indent] += options[:indent_increment] 382 | end 383 | 384 | output 385 | end 386 | 387 | def line_for_issue(issue, options) 388 | return unless issue.is_a?(Issue) 389 | if issue.due_before 390 | coords = coordinates(issue.start_date, issue.due_before, issue.done_ratio, options[:zoom]) 391 | label = "#{ issue.status.name }" 392 | label += " #{ issue.done_ratio }%" if issue.done_ratio > 0 && issue.done_ratio < 100 393 | else 394 | coords = coordinates(issue.start_date, issue.start_date, 0, options[:zoom]) 395 | label = "#{ issue.status.name }" 396 | end 397 | 398 | case options[:format] 399 | when :html 400 | html_task(options, coords, :css => "task " + (issue.leaf? ? 'leaf' : 'parent'), :label => label, :issue => issue, :markers => !issue.leaf?) 401 | when :image 402 | image_task(options, coords, :label => label) 403 | when :pdf 404 | pdf_task(options, coords, :label => label) 405 | end 406 | end 407 | 408 | # Generates a gantt image 409 | # Only defined if RMagick is avalaible 410 | def to_image(format='PNG') 411 | date_to = (@date_from >> @months)-1 412 | show_weeks = @zoom > 1 413 | show_days = @zoom > 2 414 | 415 | subject_width = 400 416 | header_height = 18 417 | line_height = 18 418 | # width of one day in pixels 419 | zoom = @zoom*2 420 | g_width = (work_days_in(@date_to, @date_from) + 1)*zoom 421 | g_height = (line_height * (number_of_rows + 2)) + 0 422 | headers_height = (show_weeks ? 2*header_height : header_height) 423 | height = g_height + headers_height 424 | 425 | imgl = Magick::ImageList.new 426 | imgl.new_image(subject_width+g_width+1, height) 427 | gc = Magick::Draw.new 428 | 429 | # Subjects 430 | gc.stroke('transparent') 431 | subjects(:image => gc, :top => (headers_height + line_height), :indent => 4, :format => :image) 432 | 433 | # Months headers 434 | month_f = @date_from 435 | left = subject_width 436 | @months.times do 437 | width = ((month_f >> 1) - month_f) * zoom 438 | gc.fill('white') 439 | gc.stroke('grey') 440 | gc.stroke_width(1) 441 | gc.rectangle(left, 0, left + width, height) 442 | gc.fill('black') 443 | gc.stroke('transparent') 444 | gc.stroke_width(1) 445 | gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}") 446 | left = left + width 447 | month_f = month_f >> 1 448 | end 449 | 450 | # Weeks headers 451 | if show_weeks 452 | left = subject_width 453 | height = header_height 454 | if @date_from.cwday == 1 455 | # date_from is monday 456 | week_f = date_from 457 | else 458 | # find next monday after date_from 459 | week_f = @date_from + (7 - @date_from.cwday + 1) 460 | width = (7 - @date_from.cwday + 1) * zoom 461 | gc.fill('white') 462 | gc.stroke('grey') 463 | gc.stroke_width(1) 464 | gc.rectangle(left, header_height, left + width, 2*header_height + g_height-1) 465 | left = left + width 466 | end 467 | while week_f <= date_to 468 | width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom 469 | gc.fill('white') 470 | gc.stroke('grey') 471 | gc.stroke_width(1) 472 | gc.rectangle(left.round, header_height, left.round + width, 2*header_height + g_height-1) 473 | gc.fill('black') 474 | gc.stroke('transparent') 475 | gc.stroke_width(1) 476 | gc.text(left.round + 2, header_height + 14, week_f.cweek.to_s) 477 | left = left + width 478 | week_f = week_f+7 479 | end 480 | end 481 | 482 | # Days details (week-end in grey) 483 | if show_days 484 | left = subject_width 485 | height = g_height + header_height - 1 486 | wday = @date_from.cwday 487 | (date_to - @date_from + 1).to_i.times do 488 | width = zoom 489 | gc.fill(wday == 6 || wday == 7 ? '#eee' : 'white') 490 | gc.stroke('#ddd') 491 | gc.stroke_width(1) 492 | gc.rectangle(left, 2*header_height, left + width, 2*header_height + g_height-1) 493 | left = left + width 494 | wday = wday + 1 495 | wday = 1 if wday > 7 496 | end 497 | end 498 | 499 | # border 500 | gc.fill('transparent') 501 | gc.stroke('grey') 502 | gc.stroke_width(1) 503 | gc.rectangle(0, 0, subject_width+g_width, headers_height) 504 | gc.stroke('black') 505 | gc.rectangle(0, 0, subject_width+g_width, g_height+ headers_height-1) 506 | 507 | # content 508 | top = headers_height + 20 509 | 510 | gc.stroke('transparent') 511 | lines(:image => gc, :top => top, :zoom => zoom, :subject_width => subject_width, :format => :image) 512 | 513 | # today red line 514 | if Date.today >= @date_from and Date.today <= date_to 515 | gc.stroke('red') 516 | x = (Date.today-@date_from+1)*zoom + subject_width 517 | gc.line(x, headers_height, x, headers_height + g_height-1) 518 | end 519 | 520 | gc.draw(imgl) 521 | imgl.format = format 522 | imgl.to_blob 523 | end if Object.const_defined?(:Magick) 524 | 525 | def to_pdf 526 | 527 | begin 528 | pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language) 529 | rescue NameError 530 | # Compatibility with 1.1.3 531 | unless ::Redmine::Export::PDF::IFPDF.respond_to?(:alias_nb_pages) 532 | # Compatibility with 1.1.2 533 | # TODO: get rid of this dirty hack 534 | ::Redmine::Export::PDF::IFPDF.class_eval "alias :alias_nb_pages :AliasNbPages; alias :RDMCell :Cell" 535 | end 536 | 537 | pdf = ::Redmine::Export::PDF::IFPDF.new(current_language) 538 | end 539 | 540 | pdf.SetTitle("#{l(:label_gantt)} #{project}") 541 | pdf.alias_nb_pages 542 | pdf.footer_date = format_date(Date.today) 543 | pdf.AddPage("L") 544 | pdf.SetFontStyle('B',12) 545 | pdf.SetX(15) 546 | pdf.RDMCell(PDF::LeftPaneWidth, 20, project.to_s) 547 | pdf.Ln 548 | pdf.SetFontStyle('B',9) 549 | 550 | subject_width = PDF::LeftPaneWidth 551 | header_height = 5 552 | 553 | headers_height = header_height 554 | show_weeks = false 555 | show_days = false 556 | 557 | if self.months < 7 558 | show_weeks = true 559 | headers_height = 2*header_height 560 | if self.months < 3 561 | show_days = true 562 | headers_height = 3*header_height 563 | end 564 | end 565 | 566 | g_width = PDF.right_pane_width 567 | zoom = (g_width) / (self.date_to - self.date_from + 1) 568 | g_height = 120 569 | t_height = g_height + headers_height 570 | 571 | y_start = pdf.GetY 572 | 573 | # Months headers 574 | month_f = self.date_from 575 | left = subject_width 576 | height = header_height 577 | self.months.times do 578 | width = ((month_f >> 1) - month_f) * zoom 579 | pdf.SetY(y_start) 580 | pdf.SetX(left) 581 | pdf.RDMCell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C") 582 | left = left + width 583 | month_f = month_f >> 1 584 | end 585 | 586 | # Weeks headers 587 | if show_weeks 588 | left = subject_width 589 | height = header_height 590 | if self.date_from.cwday == 1 591 | # self.date_from is monday 592 | week_f = self.date_from 593 | else 594 | # find next monday after self.date_from 595 | week_f = self.date_from + (7 - self.date_from.cwday + 1) 596 | width = (7 - self.date_from.cwday + 1) * zoom-1 597 | pdf.SetY(y_start + header_height) 598 | pdf.SetX(left) 599 | pdf.RDMCell(width + 1, height, "", "LTR") 600 | left = left + width+1 601 | end 602 | while week_f <= self.date_to 603 | width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom 604 | pdf.SetY(y_start + header_height) 605 | pdf.SetX(left) 606 | pdf.RDMCell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C") 607 | left = left + width 608 | week_f = week_f+7 609 | end 610 | end 611 | 612 | # Days headers 613 | if show_days 614 | left = subject_width 615 | height = header_height 616 | wday = self.date_from.cwday 617 | pdf.SetFontStyle('B',7) 618 | (self.date_to - self.date_from + 1).to_i.times do 619 | width = zoom 620 | pdf.SetY(y_start + 2 * header_height) 621 | pdf.SetX(left) 622 | pdf.RDMCell(width, height, day_name(wday).first, "LTR", 0, "C") 623 | left = left + width 624 | wday = wday + 1 625 | wday = 1 if wday > 7 626 | end 627 | end 628 | 629 | pdf.SetY(y_start) 630 | pdf.SetX(15) 631 | pdf.RDMCell(subject_width+g_width-15, headers_height, "", 1) 632 | 633 | # Tasks 634 | top = headers_height + y_start 635 | options = { 636 | :top => top, 637 | :zoom => zoom, 638 | :subject_width => subject_width, 639 | :g_width => g_width, 640 | :indent => 0, 641 | :indent_increment => 5, 642 | :top_increment => 5, 643 | :format => :pdf, 644 | :pdf => pdf 645 | } 646 | render(options) 647 | pdf.Output 648 | end 649 | 650 | # Get the number of work days between two dates. This does not include the 651 | # end date, e.g. the result when the start_date equals the end_date is 0. 652 | # This assumes that the work week is Monday-Friday. 653 | # TODO: It's probably a bit odd to have date_to as the first parameter here. 654 | # I just kept the same order as the original date subtraction code. 655 | def work_days_in(date_to, date_from) 656 | if !@work_on_weekends 657 | date_to = ensure_workday(date_to) 658 | date_from = ensure_workday(date_from) 659 | end 660 | days_in = date_to - date_from 661 | if @work_on_weekends 662 | return days_in 663 | end 664 | weekends_in = (days_in / 7).floor 665 | weekends_in += 1 if date_to.cwday < date_from.cwday 666 | work_days = days_in - (weekends_in * 2) 667 | work_days 668 | end 669 | 670 | private 671 | 672 | def coordinates(start_date, end_date, progress, zoom=nil) 673 | zoom ||= @zoom 674 | 675 | coords = {} 676 | if start_date && end_date && start_date < self.date_to && end_date > self.date_from 677 | if start_date > self.date_from 678 | coords[:start] = work_days_in(start_date, self.date_from) 679 | coords[:bar_start] = work_days_in(start_date, self.date_from) 680 | else 681 | coords[:bar_start] = 0 682 | end 683 | if end_date < self.date_to 684 | coords[:end] = work_days_in(end_date, self.date_from) 685 | coords[:bar_end] = work_days_in(end_date, self.date_from) + 1 686 | else 687 | coords[:bar_end] = work_days_in(self.date_to, self.date_from) + 1 688 | end 689 | 690 | if progress 691 | workdays = work_days_in(end_date, start_date) + 1 692 | progress_days = workdays * (progress / 100.0) 693 | progress_date = date_for_workdays(start_date, progress_days) 694 | if progress_date > self.date_from && progress_date > start_date 695 | if progress_date < self.date_to 696 | coords[:bar_progress_end] = work_days_in(progress_date, self.date_from) 697 | else 698 | coords[:bar_progress_end] = work_days_in(self.date_to, self.date_from) + 1 699 | end 700 | end 701 | 702 | if progress_date < Date.today 703 | late_date = [Date.today, end_date].min 704 | if late_date > self.date_from && late_date > start_date 705 | if late_date < self.date_to 706 | coords[:bar_late_end] = work_days_in(late_date, self.date_from) + 1 707 | else 708 | coords[:bar_late_end] = work_days_in(self.date_to, self.date_from) + 1 709 | end 710 | end 711 | end 712 | end 713 | end 714 | 715 | # Transforms dates into pixels witdh 716 | coords.keys.each do |key| 717 | coords[key] = (coords[key] * zoom).floor 718 | end 719 | coords 720 | end 721 | 722 | # Get the end date for a given start date and duration of workdays. 723 | # If the number of workdays is 0, the result is equal to the start date. 724 | # If the number of workdays is 1, the result is equal to the next workday 725 | # that follows the start date. 726 | def date_for_workdays(date_from, workdays) 727 | if @work_on_weekends 728 | return date_from + workdays 729 | end 730 | days_in = date_from + workdays 731 | workdays_in_week = @work_on_weekends ? 7 : 5 732 | weekends = (workdays / workdays_in_week).floor 733 | date_to = date_from + workdays + (weekends * 2) # candidate result (might end on a weekend) 734 | weekends += 1 if date_to.cwday >= 6 735 | weekends += 1 if date_to.cwday < date_from.cwday 736 | date_to = date_from + workdays + (weekends * 2) # final result 737 | date_to 738 | end 739 | 740 | # Ensure that the date falls on a workday. If the given date falls on a 741 | # weekend, it is moved to the following Monday. 742 | def ensure_workday(date) 743 | if !@work_on_weekends 744 | date += 8 - date.cwday if date.cwday >= 6 745 | end 746 | date 747 | end 748 | 749 | # Sorts a collection of issues by start_date, due_date, id for gantt rendering 750 | def sort_issues!(issues) 751 | issues.sort! { |a, b| gantt_issue_compare(a, b, issues) } 752 | end 753 | 754 | def gantt_issue_compare(x, y, issues = nil) 755 | get_compare_params(x) <=> get_compare_params(y) 756 | end 757 | 758 | def get_compare_params(issue) 759 | if RedmineBetterGanttChart.smart_sorting? 760 | # Smart sorting: issues sorted first by start date of their parent issue, then by id of parent issue, then by start date 761 | 762 | start_date = issue.start_date || Date.new() 763 | 764 | if issue.leaf? && issue.parent.present? 765 | identifying_id = issue.parent_id || issue.id 766 | identifying_start = issue.parent.start_date || start_date 767 | root_start = issue.root.start_date || start_date 768 | else 769 | identifying_id = issue.id 770 | identifying_start = start_date 771 | root_start = start_date 772 | end 773 | 774 | [root_start, issue.root_id, identifying_start, start_date, issue.lft] 775 | else 776 | # Default Redmine sorting 777 | [issue.root_id, issue.lft] 778 | end 779 | end 780 | 781 | def current_limit 782 | if @max_rows 783 | @max_rows - @number_of_rows 784 | else 785 | nil 786 | end 787 | end 788 | 789 | def abort? 790 | if @max_rows && @number_of_rows >= @max_rows 791 | @truncated = true 792 | end 793 | end 794 | 795 | def pdf_new_page?(options) 796 | if options[:top] > 180 797 | options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top]) 798 | options[:pdf].AddPage("L") 799 | options[:top] = 15 800 | options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1) 801 | end 802 | end 803 | 804 | # Renders subjects of cross-project related issues in italic 805 | def html_subject(params, subject, options={}) 806 | style = "position: absolute;top:#{params[:top]}px;left:#{params[:indent]}px;" 807 | style << "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width] 808 | style << "font-style:italic;" if options[:external] 809 | 810 | output = view.content_tag 'div', subject.html_safe, :class => options[:css], :style => style, :title => options[:title] 811 | @subjects << output 812 | output 813 | end 814 | 815 | def pdf_subject(params, subject, options={}) 816 | params[:pdf].SetY(params[:top]) 817 | params[:pdf].SetX(15) 818 | 819 | char_limit = PDF::MaxCharactorsForSubject - params[:indent] 820 | params[:pdf].RDMCell(params[:subject_width]-15, 5, (" " * params[:indent]) + subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'), "LR") 821 | 822 | params[:pdf].SetY(params[:top]) 823 | params[:pdf].SetX(params[:subject_width]) 824 | params[:pdf].RDMCell(params[:g_width], 5, "", "LR") 825 | end 826 | 827 | def image_subject(params, subject, options={}) 828 | params[:image].fill('black') 829 | params[:image].stroke('transparent') 830 | params[:image].stroke_width(1) 831 | params[:image].text(params[:indent], params[:top] + 2, subject) 832 | end 833 | 834 | def html_task(params, coords, options={}) 835 | top = (params[:top] + 4).to_s 836 | text_top = (params[:top] + 1).to_s 837 | output = '' 838 | # Renders the task bar, with progress and late 839 | 840 | if options[:issue] 841 | issue = options[:issue] 842 | issue_id = "#{issue.id}" 843 | relations = {} 844 | issue.relations_to.each do |relation| 845 | relation_type = relation.relation_type_for(relation.issue_to) 846 | (relations[relation_type] ||= []) << relation.issue_from_id 847 | end 848 | issue_relations = relations.inject("") {|str,rel| str << " #{rel[0]}='#{rel[1].join(',')}'" } 849 | end 850 | 851 | if coords[:bar_start] && coords[:bar_end] 852 | if options[:issue] 853 | output << "
 
" 854 | else 855 | output << "
 
" 856 | end 857 | 858 | if coords[:bar_late_end] 859 | output << "
 
" 860 | end 861 | if coords[:bar_progress_end] 862 | output << "
 
" 863 | end 864 | end 865 | 866 | # Renders the markers 867 | if options[:markers] 868 | if coords[:start] 869 | output << "
 
" 870 | end 871 | if coords[:end] 872 | output << "
 
" 873 | end 874 | end 875 | # Renders the label on the right 876 | if options[:label] 877 | output << "
" 878 | output << options[:label] 879 | output << "
" 880 | end 881 | # Renders the tooltip 882 | if options[:issue] && coords[:bar_start] && coords[:bar_end] 883 | output << "
" 884 | output << '' 885 | output << view.render_extended_issue_tooltip(options[:issue]) 886 | output << "
" 887 | end 888 | @lines << output 889 | output 890 | end 891 | 892 | def pdf_task(params, coords, options={}) 893 | height = options[:height] || 2 894 | 895 | # Renders the task bar, with progress and late 896 | if coords[:bar_start] && coords[:bar_end] 897 | params[:pdf].SetY(params[:top]+1.5) 898 | params[:pdf].SetX(params[:subject_width] + coords[:bar_start]) 899 | params[:pdf].SetFillColor(200,200,200) 900 | params[:pdf].RDMCell(coords[:bar_end] - coords[:bar_start], height, "", 0, 0, "", 1) 901 | 902 | if coords[:bar_late_end] 903 | params[:pdf].SetY(params[:top]+1.5) 904 | params[:pdf].SetX(params[:subject_width] + coords[:bar_start]) 905 | params[:pdf].SetFillColor(255,100,100) 906 | params[:pdf].RDMCell(coords[:bar_late_end] - coords[:bar_start], height, "", 0, 0, "", 1) 907 | end 908 | if coords[:bar_progress_end] 909 | params[:pdf].SetY(params[:top]+1.5) 910 | params[:pdf].SetX(params[:subject_width] + coords[:bar_start]) 911 | params[:pdf].SetFillColor(90,200,90) 912 | params[:pdf].RDMCell(coords[:bar_progress_end] - coords[:bar_start], height, "", 0, 0, "", 1) 913 | end 914 | end 915 | # Renders the markers 916 | if options[:markers] 917 | if coords[:start] 918 | params[:pdf].SetY(params[:top] + 1) 919 | params[:pdf].SetX(params[:subject_width] + coords[:start] - 1) 920 | params[:pdf].SetFillColor(50,50,200) 921 | params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1) 922 | end 923 | if coords[:end] 924 | params[:pdf].SetY(params[:top] + 1) 925 | params[:pdf].SetX(params[:subject_width] + coords[:end] - 1) 926 | params[:pdf].SetFillColor(50,50,200) 927 | params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1) 928 | end 929 | end 930 | # Renders the label on the right 931 | if options[:label] 932 | params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5) 933 | params[:pdf].RDMCell(30, 2, options[:label]) 934 | end 935 | end 936 | 937 | def image_task(params, coords, options={}) 938 | height = options[:height] || 6 939 | 940 | # Renders the task bar, with progress and late 941 | if coords[:bar_start] && coords[:bar_end] 942 | params[:image].fill('#aaa') 943 | params[:image].rectangle(params[:subject_width] + coords[:bar_start], params[:top], params[:subject_width] + coords[:bar_end], params[:top] - height) 944 | 945 | if coords[:bar_late_end] 946 | params[:image].fill('#f66') 947 | params[:image].rectangle(params[:subject_width] + coords[:bar_start], params[:top], params[:subject_width] + coords[:bar_late_end], params[:top] - height) 948 | end 949 | if coords[:bar_progress_end] 950 | params[:image].fill('#00c600') 951 | params[:image].rectangle(params[:subject_width] + coords[:bar_start], params[:top], params[:subject_width] + coords[:bar_progress_end], params[:top] - height) 952 | end 953 | end 954 | # Renders the markers 955 | if options[:markers] 956 | if coords[:start] 957 | x = params[:subject_width] + coords[:start] 958 | y = params[:top] - height / 2 959 | params[:image].fill('blue') 960 | params[:image].polygon(x-4, y, x, y-4, x+4, y, x, y+4) 961 | end 962 | if coords[:end] 963 | x = params[:subject_width] + coords[:end] + params[:zoom] 964 | y = params[:top] - height / 2 965 | params[:image].fill('blue') 966 | params[:image].polygon(x-4, y, x, y-4, x+4, y, x, y+4) 967 | end 968 | end 969 | # Renders the label on the right 970 | if options[:label] 971 | params[:image].fill('black') 972 | params[:image].text(params[:subject_width] + (coords[:bar_end] || 0) + 5,params[:top] + 1, options[:label]) 973 | end 974 | end 975 | end 976 | end 977 | end 978 | -------------------------------------------------------------------------------- /assets/javascripts/raphael-min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Raphael 1.5.2 - JavaScript Vector Library 3 | * 4 | * Copyright (c) 2010 Dmitry Baranovskiy (http://raphaeljs.com) 5 | * Licensed under the MIT (http://raphaeljs.com/license.html) license. 6 | */ 7 | (function(){function a(){if(a.is(arguments[0],G)){var b=arguments[0],d=bV[m](a,b.splice(0,3+a.is(b[0],E))),e=d.set();for(var g=0,h=b[w];g";bg=bf.firstChild;bg.style.behavior="url(#default#VML)";if(!(bg&&typeof bg.adj=="object"))return a.type=null;bf=null}a.svg=!(a.vml=a.type=="VML");j[e]=a[e];k=j[e];a._id=0;a._oid=0;a.fn={};a.is=function(a,b){b=x.call(b);if(b=="finite")return!O[f](+a);return b=="null"&&a===null||b==typeof a||b=="object"&&a===Object(a)||b=="array"&&Array.isArray&&Array.isArray(a)||J.call(a).slice(8,-1).toLowerCase()==b};a.angle=function(b,c,d,e,f,g){{if(f==null){var h=b-d,i=c-e;if(!h&&!i)return 0;return((h<0)*180+y.atan(-i/-h)*180/D+360)%360}return a.angle(b,c,f,g)-a.angle(d,e,f,g)}};a.rad=function(a){return a%360*D/180};a.deg=function(a){return a*180/D%360};a.snapTo=function(b,c,d){d=a.is(d,"finite")?d:10;if(a.is(b,G)){var e=b.length;while(e--)if(B(b[e]-c)<=d)return b[e]}else{b=+b;var f=c%b;if(fb-d)return c-f+b}return c};function bh(){var a=[],b=0;for(;b<32;b++)a[b]=(~(~(y.random()*16)))[H](16);a[12]=4;a[16]=(a[16]&3|8)[H](16);return"r-"+a[v]("")}a.setWindow=function(a){h=a;g=h.document};var bi=function(b){if(a.vml){var c=/^\s+|\s+$/g,d;try{var e=new ActiveXObject("htmlfile");e.write("");e.close();d=e.body}catch(a){d=createPopup().document.body}var f=d.createTextRange();bi=bm(function(a){try{d.style.color=r(a)[Y](c,p);var b=f.queryCommandValue("ForeColor");b=(b&255)<<16|b&65280|(b&16711680)>>>16;return"#"+("000000"+b[H](16)).slice(-6)}catch(a){return"none"}})}else{var h=g.createElement("i");h.title="Raphaël Colour Picker";h.style.display="none";g.body[l](h);bi=bm(function(a){h.style.color=a;return g.defaultView.getComputedStyle(h,p).getPropertyValue("color")})}return bi(b)},bj=function(){return"hsb("+[this.h,this.s,this.b]+")"},bk=function(){return"hsl("+[this.h,this.s,this.l]+")"},bl=function(){return this.hex};a.hsb2rgb=function(b,c,d,e){if(a.is(b,"object")&&"h"in b&&"s"in b&&"b"in b){d=b.b;c=b.s;b=b.h;e=b.o}return a.hsl2rgb(b,c,d/2,e)};a.hsl2rgb=function(b,c,d,e){if(a.is(b,"object")&&"h"in b&&"s"in b&&"l"in b){d=b.l;c=b.s;b=b.h}if(b>1||c>1||d>1){b/=360;c/=100;d/=100}var f={},g=["r","g","b"],h,i,j,k,l,m;if(c){d<0.5?h=d*(1+c):h=d+c-d*c;i=2*d-h;for(var n=0;n<3;n++){j=b+1/3*-(n-1);j<0&&j++;j>1&&j--;j*6<1?f[g[n]]=i+(h-i)*6*j:j*2<1?f[g[n]]=h:j*3<2?f[g[n]]=i+(h-i)*(2/3-j)*6:f[g[n]]=i}}else f={r:d,g:d,b:d};f.r*=255;f.g*=255;f.b*=255;f.hex="#"+(16777216|f.b|f.g<<8|f.r<<16).toString(16).slice(1);a.is(e,"finite")&&(f.opacity=e);f.toString=bl;return f};a.rgb2hsb=function(b,c,d){if(c==null&&a.is(b,"object")&&"r"in b&&"g"in b&&"b"in b){d=b.b;c=b.g;b=b.r}if(c==null&&a.is(b,F)){var e=a.getRGB(b);b=e.r;c=e.g;d=e.b}if(b>1||c>1||d>1){b/=255;c/=255;d/=255}var f=z(b,c,d),g=A(b,c,d),h,i,j=f;{if(g==f)return{h:0,s:0,b:f,toString:bj};var k=f-g;i=k/f;b==f?h=(c-d)/k:c==f?h=2+(d-b)/k:h=4+(b-c)/k;h/=6;h<0&&h++;h>1&&h--}return{h:h,s:i,b:j,toString:bj}};a.rgb2hsl=function(b,c,d){if(c==null&&a.is(b,"object")&&"r"in b&&"g"in b&&"b"in b){d=b.b;c=b.g;b=b.r}if(c==null&&a.is(b,F)){var e=a.getRGB(b);b=e.r;c=e.g;d=e.b}if(b>1||c>1||d>1){b/=255;c/=255;d/=255}var f=z(b,c,d),g=A(b,c,d),h,i,j=(f+g)/2,k;if(g==f)k={h:0,s:0,l:j};else{var l=f-g;i=j<0.5?l/(f+g):l/(2-f-g);b==f?h=(c-d)/l:c==f?h=2+(d-b)/l:h=4+(b-c)/l;h/=6;h<0&&h++;h>1&&h--;k={h:h,s:i,l:j}}k.toString=bk;return k};a._path2string=function(){return this.join(",")[Y](ba,"$1")};function bm(a,b,c){function d(){var g=Array[e].slice.call(arguments,0),h=g[v]("►"),i=d.cache=d.cache||{},j=d.count=d.count||[];if(i[f](h))return c?c(i[h]):i[h];j[w]>=1000&&delete i[j.shift()];j[L](h);i[h]=a[m](b,g);return c?c(i[h]):i[h]}return d}a.getRGB=bm(function(b){if(!b||!(!((b=r(b)).indexOf("-")+1)))return{r:-1,g:-1,b:-1,hex:"none",error:1};if(b=="none")return{r:-1,g:-1,b:-1,hex:"none"};!(_[f](b.toLowerCase().substring(0,2))||b.charAt()=="#")&&(b=bi(b));var c,d,e,g,h,i,j,k=b.match(N);if(k){if(k[2]){g=T(k[2].substring(5),16);e=T(k[2].substring(3,5),16);d=T(k[2].substring(1,3),16)}if(k[3]){g=T((i=k[3].charAt(3))+i,16);e=T((i=k[3].charAt(2))+i,16);d=T((i=k[3].charAt(1))+i,16)}if(k[4]){j=k[4][s]($);d=S(j[0]);j[0].slice(-1)=="%"&&(d*=2.55);e=S(j[1]);j[1].slice(-1)=="%"&&(e*=2.55);g=S(j[2]);j[2].slice(-1)=="%"&&(g*=2.55);k[1].toLowerCase().slice(0,4)=="rgba"&&(h=S(j[3]));j[3]&&j[3].slice(-1)=="%"&&(h/=100)}if(k[5]){j=k[5][s]($);d=S(j[0]);j[0].slice(-1)=="%"&&(d*=2.55);e=S(j[1]);j[1].slice(-1)=="%"&&(e*=2.55);g=S(j[2]);j[2].slice(-1)=="%"&&(g*=2.55);(j[0].slice(-3)=="deg"||j[0].slice(-1)=="°")&&(d/=360);k[1].toLowerCase().slice(0,4)=="hsba"&&(h=S(j[3]));j[3]&&j[3].slice(-1)=="%"&&(h/=100);return a.hsb2rgb(d,e,g,h)}if(k[6]){j=k[6][s]($);d=S(j[0]);j[0].slice(-1)=="%"&&(d*=2.55);e=S(j[1]);j[1].slice(-1)=="%"&&(e*=2.55);g=S(j[2]);j[2].slice(-1)=="%"&&(g*=2.55);(j[0].slice(-3)=="deg"||j[0].slice(-1)=="°")&&(d/=360);k[1].toLowerCase().slice(0,4)=="hsla"&&(h=S(j[3]));j[3]&&j[3].slice(-1)=="%"&&(h/=100);return a.hsl2rgb(d,e,g,h)}k={r:d,g:e,b:g};k.hex="#"+(16777216|g|e<<8|d<<16).toString(16).slice(1);a.is(h,"finite")&&(k.opacity=h);return k}return{r:-1,g:-1,b:-1,hex:"none",error:1}},a);a.getColor=function(a){var b=this.getColor.start=this.getColor.start||{h:0,s:1,b:a||0.75},c=this.hsb2rgb(b.h,b.s,b.b);b.h+=0.075;if(b.h>1){b.h=0;b.s-=0.2;b.s<=0&&(this.getColor.start={h:0,s:1,b:b.b})}return c.hex};a.getColor.reset=function(){delete this.start};a.parsePathString=bm(function(b){if(!b)return null;var c={a:7,c:6,h:1,l:2,m:2,q:4,s:4,t:2,v:1,z:0},d=[];a.is(b,G)&&a.is(b[0],G)&&(d=bo(b));d[w]||r(b)[Y](bb,function(a,b,e){var f=[],g=x.call(b);e[Y](bc,function(a,b){b&&f[L](+b)});if(g=="m"&&f[w]>2){d[L]([b][n](f.splice(0,2)));g="l";b=b=="m"?"l":"L"}while(f[w]>=c[g]){d[L]([b][n](f.splice(0,c[g])));if(!c[g])break}});d[H]=a._path2string;return d});a.findDotsAtSegment=function(a,b,c,d,e,f,g,h,i){var j=1-i,k=C(j,3)*a+C(j,2)*3*i*c+j*3*i*i*e+C(i,3)*g,l=C(j,3)*b+C(j,2)*3*i*d+j*3*i*i*f+C(i,3)*h,m=a+2*i*(c-a)+i*i*(e-2*c+a),n=b+2*i*(d-b)+i*i*(f-2*d+b),o=c+2*i*(e-c)+i*i*(g-2*e+c),p=d+2*i*(f-d)+i*i*(h-2*f+d),q=(1-i)*a+i*c,r=(1-i)*b+i*d,s=(1-i)*e+i*g,t=(1-i)*f+i*h,u=90-y.atan((m-o)/(n-p))*180/D;(m>o||n1){x=y.sqrt(x);c=x*c;d=x*d}var z=c*c,A=d*d,C=(f==g?-1:1)*y.sqrt(B((z*A-z*u*u-A*t*t)/(z*u*u+A*t*t))),E=C*c*u/d+(a+h)/2,F=C*-d*t/c+(b+i)/2,G=y.asin(((b-F)/d).toFixed(9)),H=y.asin(((i-F)/d).toFixed(9));G=aH&&(G=G-D*2);!g&&H>G&&(H=H-D*2)}var I=H-G;if(B(I)>k){var J=H,K=h,L=i;H=G+k*(g&&H>G?1:-1);h=E+c*y.cos(H);i=F+d*y.sin(H);m=bt(h,i,c,d,e,0,g,K,L,[H,J,E,F])}I=H-G;var M=y.cos(G),N=y.sin(G),O=y.cos(H),P=y.sin(H),Q=y.tan(I/4),R=4/3*c*Q,S=4/3*d*Q,T=[a,b],U=[a+R*N,b-S*M],V=[h+R*P,i-S*O],W=[h,i];U[0]=2*T[0]-U[0];U[1]=2*T[1]-U[1];{if(j)return[U,V,W][n](m);m=[U,V,W][n](m)[v]()[s](",");var X=[];for(var Y=0,Z=m[w];Y"1e12"&&(l=0.5);B(n)>"1e12"&&(n=0.5);if(l>0&&l<1){q=bu(a,b,c,d,e,f,g,h,l);p[L](q.x);o[L](q.y)}if(n>0&&n<1){q=bu(a,b,c,d,e,f,g,h,n);p[L](q.x);o[L](q.y)}i=f-2*d+b-(h-2*f+d);j=2*(d-b)-2*(f-d);k=b-d;l=(-j+y.sqrt(j*j-4*i*k))/2/i;n=(-j-y.sqrt(j*j-4*i*k))/2/i;B(l)>"1e12"&&(l=0.5);B(n)>"1e12"&&(n=0.5);if(l>0&&l<1){q=bu(a,b,c,d,e,f,g,h,l);p[L](q.x);o[L](q.y)}if(n>0&&n<1){q=bu(a,b,c,d,e,f,g,h,n);p[L](q.x);o[L](q.y)}return{min:{x:A[m](0,p),y:A[m](0,o)},max:{x:z[m](0,p),y:z[m](0,o)}}}),bw=bm(function(a,b){var c=bq(a),d=b&&bq(b),e={x:0,y:0,bx:0,by:0,X:0,Y:0,qx:null,qy:null},f={x:0,y:0,bx:0,by:0,X:0,Y:0,qx:null,qy:null},g=function(a,b){var c,d;if(!a)return["C",b.x,b.y,b.x,b.y,b.x,b.y];!(a[0]in{T:1,Q:1})&&(b.qx=b.qy=null);switch(a[0]){case"M":b.X=a[1];b.Y=a[2];break;case"A":a=["C"][n](bt[m](0,[b.x,b.y][n](a.slice(1))));break;case"S":c=b.x+(b.x-(b.bx||b.x));d=b.y+(b.y-(b.by||b.y));a=["C",c,d][n](a.slice(1));break;case"T":b.qx=b.x+(b.x-(b.qx||b.x));b.qy=b.y+(b.y-(b.qy||b.y));a=["C"][n](bs(b.x,b.y,b.qx,b.qy,a[1],a[2]));break;case"Q":b.qx=a[1];b.qy=a[2];a=["C"][n](bs(b.x,b.y,a[1],a[2],a[3],a[4]));break;case"L":a=["C"][n](br(b.x,b.y,a[1],a[2]));break;case"H":a=["C"][n](br(b.x,b.y,a[1],b.y));break;case"V":a=["C"][n](br(b.x,b.y,b.x,a[1]));break;case"Z":a=["C"][n](br(b.x,b.y,b.X,b.Y));break}return a},h=function(a,b){if(a[b][w]>7){a[b].shift();var e=a[b];while(e[w])a.splice(b++,0,["C"][n](e.splice(0,6)));a.splice(b,1);k=z(c[w],d&&d[w]||0)}},i=function(a,b,e,f,g){if(a&&b&&a[g][0]=="M"&&b[g][0]!="M"){b.splice(g,0,["M",f.x,f.y]);e.bx=0;e.by=0;e.x=a[g][1];e.y=a[g][2];k=z(c[w],d&&d[w]||0)}};for(var j=0,k=z(c[w],d&&d[w]||0);j0.5)*2-1;C(e-0.5,2)+C(f-0.5,2)>0.25&&(f=y.sqrt(0.25-C(e-0.5,2))*g+0.5)&&f!=0.5&&(f=f.toFixed(5)-0.00001*g)}return p});b=b[s](/\s*\-\s*/);if(d=="linear"){var i=b.shift();i=-S(i);if(isNaN(i))return null;var j=[0,0,y.cos(i*D/180),y.sin(i*D/180)],k=1/(z(B(j[2]),B(j[3]))||1);j[2]*=k;j[3]*=k;if(j[2]<0){j[0]=-j[2];j[2]=0}if(j[3]<0){j[1]=-j[3];j[3]=0}}var m=bx(b);if(!m)return null;var n=a.getAttribute(I);n=n.match(/^url\(#(.*)\)$/);n&&c.defs.removeChild(g.getElementById(n[1]));var o=bG(d+"Gradient");o.id=bh();bG(o,d=="radial"?{fx:e,fy:f}:{x1:j[0],y1:j[1],x2:j[2],y2:j[3]});c.defs[l](o);for(var q=0,t=m[w];q1?G.opacity/100:G.opacity});case"stroke":G=a.getRGB(o);h[R](n,G.hex);n=="stroke"&&G[f]("opacity")&&bG(h,{"stroke-opacity":G.opacity>1?G.opacity/100:G.opacity});break;case"gradient":(({circle:1,ellipse:1})[f](c.type)||r(o).charAt()!="r")&&bI(h,o,c.paper);break;case"opacity":i.gradient&&!i[f]("stroke-opacity")&&bG(h,{"stroke-opacity":o>1?o/100:o});case"fill-opacity":if(i.gradient){var H=g.getElementById(h.getAttribute(I)[Y](/^url\(#|\)$/g,p));if(H){var J=H.getElementsByTagName("stop");J[J[w]-1][R]("stop-opacity",o)}break}default:n=="font-size"&&(o=T(o,10)+"px");var K=n[Y](/(\-.)/g,function(a){return V.call(a.substring(1))});h.style[K]=o;h[R](n,o);break}}}bM(c,d);m?c.rotate(m.join(q)):S(j)&&c.rotate(j,true)},bL=1.2,bM=function(b,c){if(b.type!="text"||!(c[f]("text")||c[f]("font")||c[f]("font-size")||c[f]("x")||c[f]("y")))return;var d=b.attrs,e=b.node,h=e.firstChild?T(g.defaultView.getComputedStyle(e.firstChild,p).getPropertyValue("font-size"),10):10;if(c[f]("text")){d.text=c.text;while(e.firstChild)e.removeChild(e.firstChild);var i=r(c.text)[s]("\n");for(var j=0,k=i[w];jb.height&&(b.height=e.y+e.height-b.y);e.x+e.width-b.x>b.width&&(b.width=e.x+e.width-b.x)}}a&&this.hide();return b};bN[e].attr=function(b,c){if(this.removed)return this;if(b==null){var d={};for(var e in this.attrs)this.attrs[f](e)&&(d[e]=this.attrs[e]);this._.rt.deg&&(d.rotation=this.rotate());(this._.sx!=1||this._.sy!=1)&&(d.scale=this.scale());d.gradient&&d.fill=="none"&&(d.fill=d.gradient)&&delete d.gradient;return d}if(c==null&&a.is(b,F)){if(b=="translation")return cz.call(this);if(b=="rotation")return this.rotate();if(b=="scale")return this.scale();if(b==I&&this.attrs.fill=="none"&&this.attrs.gradient)return this.attrs.gradient;return this.attrs[b]}if(c==null&&a.is(b,G)){var g={};for(var h=0,i=b.length;h"));m.W=h.w=m.paper.span.offsetWidth;m.H=h.h=m.paper.span.offsetHeight;m.X=h.x;m.Y=h.y+Q(m.H/2);switch(h["text-anchor"]){case"start":m.node.style["v-text-align"]="left";m.bbx=Q(m.W/2);break;case"end":m.node.style["v-text-align"]="right";m.bbx=-Q(m.W/2);break;default:m.node.style["v-text-align"]="center";break}}};bI=function(a,b){a.attrs=a.attrs||{};var c=a.attrs,d,e="linear",f=".5 .5";a.attrs.gradient=b;b=r(b)[Y](bd,function(a,b,c){e="radial";if(b&&c){b=S(b);c=S(c);C(b-0.5,2)+C(c-0.5,2)>0.25&&(c=y.sqrt(0.25-C(b-0.5,2))*((c>0.5)*2-1)+0.5);f=b+q+c}return p});b=b[s](/\s*\-\s*/);if(e=="linear"){var g=b.shift();g=-S(g);if(isNaN(g))return null}var h=bx(b);if(!h)return null;a=a.shape||a.node;d=a.getElementsByTagName(I)[0]||cd(I);!d.parentNode&&a.appendChild(d);if(h[w]){d.on=true;d.method="none";d.color=h[0].color;d.color2=h[h[w]-1].color;var i=[];for(var j=0,k=h[w];j")}}catch(a){cd=function(a){return g.createElement("<"+a+" xmlns=\"urn:schemas-microsoft.com:vml\" class=\"rvml\">")}}bV=function(){var b=by[m](0,arguments),c=b.container,d=b.height,e,f=b.width,h=b.x,i=b.y;if(!c)throw new Error("VML container not found.");var k=new j,n=k.canvas=g.createElement("div"),o=n.style;h=h||0;i=i||0;f=f||512;d=d||342;f==+f&&(f+="px");d==+d&&(d+="px");k.width=1000;k.height=1000;k.coordsize=b_*1000+q+b_*1000;k.coordorigin="0 0";k.span=g.createElement("span");k.span.style.cssText="position:absolute;left:-9999em;top:-9999em;padding:0;margin:0;line-height:1;display:inline;";n[l](k.span);o.cssText=a.format("top:0;left:0;width:{0};height:{1};display:inline-block;position:relative;clip:rect(0 {0} {1} 0);overflow:hidden",f,d);if(c==1){g.body[l](n);o.left=h+"px";o.top=i+"px";o.position="absolute"}else c.firstChild?c.insertBefore(n,c.firstChild):c[l](n);bz.call(k,k,a.fn);return k};k.clear=function(){this.canvas.innerHTML=p;this.span=g.createElement("span");this.span.style.cssText="position:absolute;left:-9999em;top:-9999em;padding:0;margin:0;line-height:1;display:inline;";this.canvas[l](this.span);this.bottom=this.top=null};k.remove=function(){this.canvas.parentNode.removeChild(this.canvas);for(var a in this)this[a]=bF(a);return true}}var ce=navigator.userAgent.match(/Version\\x2f(.*?)\s/);navigator.vendor=="Apple Computer, Inc."&&(ce&&ce[1]<4||navigator.platform.slice(0,2)=="iP")?k.safari=function(){var a=this.rect(-99,-99,this.width+99,this.height+99).attr({stroke:"none"});h.setTimeout(function(){a.remove()})}:k.safari=function(){};var cf=function(){this.returnValue=false},cg=function(){return this.originalEvent.preventDefault()},ch=function(){this.cancelBubble=true},ci=function(){return this.originalEvent.stopPropagation()},cj=(function(){{if(g.addEventListener)return function(a,b,c,d){var e=o&&u[b]?u[b]:b,g=function(e){if(o&&u[f](b))for(var g=0,h=e.targetTouches&&e.targetTouches.length;g1&&(a=Array[e].splice.call(arguments,0,arguments[w]));return new cC(a)};k.setSize=bU;k.top=k.bottom=null;k.raphael=a;function co(){return this.x+q+this.y}bO.resetScale=function(){if(this.removed)return this;this._.sx=1;this._.sy=1;this.attrs.scale="1 1"};bO.scale=function(a,b,c,d){if(this.removed)return this;if(a==null&&b==null)return{x:this._.sx,y:this._.sy,toString:co};b=b||a;!(+b)&&(b=a);var e,f,g,h,i=this.attrs;if(a!=0){var j=this.getBBox(),k=j.x+j.width/2,l=j.y+j.height/2,m=B(a/this._.sx),o=B(b/this._.sy);c=+c||c==0?c:k;d=+d||d==0?d:l;var r=this._.sx>0,s=this._.sy>0,t=~(~(a/B(a))),u=~(~(b/B(b))),x=m*t,y=o*u,z=this.node.style,A=c+B(k-c)*x*(k>c==r?1:-1),C=d+B(l-d)*y*(l>d==s?1:-1),D=a*t>b*u?o:m;switch(this.type){case"rect":case"image":var E=i.width*m,F=i.height*o;this.attr({height:F,r:i.r*D,width:E,x:A-E/2,y:C-F/2});break;case"circle":case"ellipse":this.attr({rx:i.rx*m,ry:i.ry*o,r:i.r*D,cx:A,cy:C});break;case"text":this.attr({x:A,y:C});break;case"path":var G=bp(i.path),H=true,I=r?x:m,J=s?y:o;for(var K=0,L=G[w];Kr)p=n.data[r*l];else{p=a.findDotsAtSegment(b,c,d,e,f,g,h,i,r/l);n.data[r]=p}r&&(k+=C(C(o.x-p.x,2)+C(o.y-p.y,2),0.5));if(j!=null&&k>=j)return p;o=p}if(j==null)return k},cr=function(b,c){return function(d,e,f){d=bw(d);var g,h,i,j,k="",l={},m,n=0;for(var o=0,p=d.length;oe){if(c&&!l.start){m=cq(g,h,i[1],i[2],i[3],i[4],i[5],i[6],e-n);k+=["C",m.start.x,m.start.y,m.m.x,m.m.y,m.x,m.y];if(f)return k;l.start=k;k=["M",m.x,m.y+"C",m.n.x,m.n.y,m.end.x,m.end.y,i[5],i[6]][v]();n+=j;g=+i[5];h=+i[6];continue}if(!b&&!c){m=cq(g,h,i[1],i[2],i[3],i[4],i[5],i[6],e-n);return{x:m.x,y:m.y,alpha:m.alpha}}}n+=j;g=+i[5];h=+i[6]}k+=i}l.end=k;m=b?n:c?l:a.findDotsAtSegment(g,h,i[1],i[2],i[3],i[4],i[5],i[6],1);m.alpha&&(m={x:m.x,y:m.y,alpha:m.alpha});return m}},cs=cr(1),ct=cr(),cu=cr(0,1);bO.getTotalLength=function(){if(this.type!="path")return;if(this.node.getTotalLength)return this.node.getTotalLength();return cs(this.attrs.path)};bO.getPointAtLength=function(a){if(this.type!="path")return;return ct(this.attrs.path,a)};bO.getSubpath=function(a,b){if(this.type!="path")return;if(B(this.getTotalLength()-b)<"1e-6")return cu(this.attrs.path,a).end;var c=cu(this.attrs.path,b,1);return a?cu(c,a).end:c};a.easing_formulas={linear:function(a){return a},"<":function(a){return C(a,3)},">":function(a){return C(a-1,3)+1},"<>":function(a){a=a*2;if(a<1)return C(a,3)/2;a-=2;return(C(a,3)+2)/2},backIn:function(a){var b=1.70158;return a*a*((b+1)*a-b)},backOut:function(a){a=a-1;var b=1.70158;return a*a*((b+1)*a+b)+1},elastic:function(a){if(a==0||a==1)return a;var b=0.3,c=b/4;return C(2,-10*a)*y.sin((a-c)*(2*D)/b)+1},bounce:function(a){var b=7.5625,c=2.75,d;if(a<1/c)d=b*a*a;else if(a<2/c){a-=1.5/c;d=b*a*a+0.75}else if(a<2.5/c){a-=2.25/c;d=b*a*a+0.9375}else{a-=2.625/c;d=b*a*a+0.984375}return d}};var cv=[],cw=function(){var b=+(new Date);for(var c=0;cd)return d;while(cf?c=e:d=e;e=(d-c)/2+c}return e}return n(a,1/(200*f))}bO.onAnimation=function(a){this._run=a||0;return this};bO.animate=function(c,d,e,g){var h=this;h.timeouts=h.timeouts||[];if(a.is(e,"function")||!e)g=e||null;if(h.removed){g&&g.call(h);return h}var i={},j={},k=false,l={};for(var m in c)if(c[f](m)){if(X[f](m)||h.paper.customAttributes[f](m)){k=true;i[m]=h.attr(m);i[m]==null&&(i[m]=W[m]);j[m]=c[m];switch(X[m]){case"along":var n=cs(c[m]),o=ct(c[m],n*!(!c.back)),p=h.getBBox();l[m]=n/d;l.tx=p.x;l.ty=p.y;l.sx=o.x;l.sy=o.y;j.rot=c.rot;j.back=c.back;j.len=n;c.rot&&(l.r=S(h.rotate())||0);break;case E:l[m]=(j[m]-i[m])/d;break;case"colour":i[m]=a.getRGB(i[m]);var q=a.getRGB(j[m]);l[m]={r:(q.r-i[m].r)/d,g:(q.g-i[m].g)/d,b:(q.b-i[m].b)/d};break;case"path":var t=bw(i[m],j[m]);i[m]=t[0];var u=t[1];l[m]=[];for(var v=0,x=i[m][w];v