diff --git a/addons/project/data/project_data.xml b/addons/project/data/project_data.xml index 9057d9ef..d92991fc 100644 --- a/addons/project/data/project_data.xml +++ b/addons/project/data/project_data.xml @@ -18,6 +18,16 @@ project.task + + + Tasks + project.task + TSK + 3 + + + + @@ -127,7 +137,7 @@ 1 - 0 + l Welcome to Flectra @@ -139,7 +149,7 @@ 2 - 0 + l Try to play with the search bar. Use the filters @@ -150,7 +160,7 @@ 3 - 0 + l Try to drag a task wherever your want @@ -161,7 +171,7 @@ 4 - 0 + l Guess what happens if you set this task as favorite? @@ -171,7 +181,7 @@ - 0 + l Use the chatter to collaborate with your members @@ -197,7 +207,7 @@ 3 - 0 + l Use tags to organize your tasks @@ -210,7 +220,7 @@ 4 - 0 + l 3 @@ -222,7 +232,7 @@ 5 - 0 + l Set this task as 'Ready for next stage' to proceed further in the process @@ -232,7 +242,7 @@ - 0 + l Do you want to learn more? Try our implementation guide! @@ -265,7 +275,7 @@ - 0 + l Finished with this stage? Archive it ! @@ -275,7 +285,7 @@ - 0 + l You want to add a stage? Add a new column ! @@ -285,7 +295,7 @@ - 0 + l You can set a deadline on a task @@ -295,7 +305,7 @@ - 0 + l Send a message with a picture as attachment, and see what happens! diff --git a/addons/project/data/project_demo.xml b/addons/project/data/project_demo.xml index 3e23d19c..7933c421 100644 --- a/addons/project/data/project_demo.xml +++ b/addons/project/data/project_demo.xml @@ -124,7 +124,7 @@ - 0 + l Prepare Requirements Document @@ -134,7 +134,7 @@ - 0 + l Make SRS @@ -143,7 +143,7 @@ - 0 + l Budget Planning @@ -154,7 +154,7 @@ - 0 + l Develop module for Sale Management Use the account_budget module @@ -168,11 +168,11 @@ - 1 + m Develop module for Warehouse done - 0 + l - 1 + m Integrate Modules @@ -191,7 +191,7 @@ - 1 + m Internal testing + Software Install @@ -200,10 +200,10 @@ - 1 + m New portal system - 0 + l @@ -212,7 +212,7 @@ - 1 + m Document history management @@ -221,7 +221,7 @@ - 1 + m Social network integration blocked @@ -231,7 +231,7 @@ - 1 + m User interface improvements - 1 + m Improve+clean code and functionality @@ -255,7 +255,7 @@ - 1 + m Design Use Cases @@ -265,7 +265,7 @@ - 1 + m Dataflow Design @@ -274,7 +274,7 @@ - 1 + m User Interface design @@ -288,7 +288,7 @@ - 1 + m Set target for all deparments @@ -298,7 +298,7 @@ - 1 + m Integration of core components @@ -308,7 +308,7 @@ - 1 + m Deploy and review on live system @@ -350,7 +350,7 @@ 12.0 - 0 + l Customer analysis + Architecture 7 @@ -371,7 +371,7 @@ 8.0 - 1 + m Modifications asked by the customer = date_start)', 'Error! project start-date must be lower than project end-date.') @@ -361,7 +365,7 @@ class Task(models.Model): _name = "project.task" _description = "Task" _date_name = "date_start" - _inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin'] + _inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin', 'ir.branch.company.mixin'] _mail_post_access = 'read' _order = "priority desc, sequence, date_start, name, id" @@ -386,6 +390,12 @@ class Task(models.Model): stage_ids = stages._search(search_domain, order=order, access_rights_uid=SUPERUSER_ID) return stages.browse(stage_ids) + @api.one + @api.depends('stage_id') + def calculate_actual_end_date(self): + if self.stage_id.name == 'Done': + self.actual_end_date = date.today() + active = fields.Boolean(default=True) name = fields.Char(string='Task Title', track_visibility='always', required=True, index=True) description = fields.Html(string='Description') @@ -464,6 +474,39 @@ class Task(models.Model): working_days_open = fields.Float(compute='_compute_elapsed', string='Working days to assign', store=True, group_operator="avg") working_days_close = fields.Float(compute='_compute_elapsed', string='Working days to close', store=True, group_operator="avg") + task_seq = fields.Char( + string="Reference", track_visibility='onchange', + default=lambda self: self.env['ir.sequence'].next_by_code( + 'project.task') or '/') + priority = fields.Selection([ + ('l', 'Low'), ('m', 'Medium'), ('h', 'High'), ], + string="Priority", default='l') + start_date = fields.Date(string="Start Date", track_visibility='onchange') + end_date = fields.Date(string="End Date", track_visibility='onchange') + actual_end_date = fields.Date( + compute='calculate_actual_end_date', string="Actual End Date", + store=True, track_visibility='onchange' + ) + + @api.onchange('priority', 'project_id') + def task_deadline(self): + if self.project_id and self.priority: + days = 0 + if self.priority == "l": + days = int(self.project_id.low) + elif self.priority == "m": + days = int(self.project_id.medium) + else: + days = int(self.project_id.high) + + self.update({ + 'date_deadline': date.today() + timedelta(days) + }) + else: + self.update({ + 'date_deadline': date.today() + }) + def _compute_attachment_ids(self): for task in self: attachment_ids = self.env['ir.attachment'].search([('res_id', '=', task.id), ('res_model', '=', 'project.task')]).ids diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index f6741061..89bc736b 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -136,10 +136,17 @@ + + + + + + + @@ -427,6 +434,10 @@ + + + + @@ -434,6 +445,9 @@ + + + @@ -906,4 +920,85 @@ parent="menu_project_report" sequence="10"/> + + + project.task.kanban + project.task + + + + +
+
+
+ + + +
+
+
+
+ + +
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ + + + project.task.search + project.task + + + + + + + + + + + + + + + + + + + + + Priority Tasks + ir.actions.act_window + project.task + kanban,form,tree + + + {'search_default_priority': 1} + + + + + diff --git a/addons/project_scrum/__init__.py b/addons/project_scrum/__init__.py new file mode 100755 index 00000000..439f7fd9 --- /dev/null +++ b/addons/project_scrum/__init__.py @@ -0,0 +1,4 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from . import models +from . import controllers diff --git a/addons/project_scrum/__manifest__.py b/addons/project_scrum/__manifest__.py new file mode 100755 index 00000000..7fded5fd --- /dev/null +++ b/addons/project_scrum/__manifest__.py @@ -0,0 +1,45 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Project Scrum', + 'version': '1.0', + 'category': 'Project', + 'author': 'Flectra', + 'website': 'https://flectrahq.com', + 'sequence': 40, + 'summary': 'A module for Scrum implementation', + 'depends': [ + 'project', 'resource' + ], + 'data': [ + 'security/ir.model.access.csv', + 'security/scrum_security.xml', + 'views/other_views.xml', + 'views/project_sprint_views.xml', + 'views/project_story_views.xml', + 'views/project_team_views.xml', + 'views/release_planning_views.xml', + 'views/cron_view.xml', + 'views/assets.xml', + 'views/retrospective_method_views.xml', + 'views/retrospective_views.xml', + 'reports/project_scrum_report.xml', + 'reports/release_planning_template.xml', + 'reports/project_sprint_template.xml', + 'data/sprint_sequence.xml', + 'data/project_sprint_data.xml', + ], + 'demo': [ + 'demo/project_team_demo.xml', + 'demo/project_sprint_demo.xml', + 'demo/project_story_demo.xml', + 'demo/project_release_planning_demo.xml', + 'demo/retrospective_demo.xml', + 'demo/project_task_demo.xml', + ], + 'qweb': [ + 'static/src/xml/scrum_dashboard.xml', + ], + 'installable': True, + 'application': True, +} diff --git a/addons/project_scrum/controllers/__init__.py b/addons/project_scrum/controllers/__init__.py new file mode 100755 index 00000000..28943b4e --- /dev/null +++ b/addons/project_scrum/controllers/__init__.py @@ -0,0 +1,3 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from . import backend \ No newline at end of file diff --git a/addons/project_scrum/controllers/backend.py b/addons/project_scrum/controllers/backend.py new file mode 100755 index 00000000..88f701a1 --- /dev/null +++ b/addons/project_scrum/controllers/backend.py @@ -0,0 +1,105 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from flectra import http +from flectra.http import request +from datetime import datetime + + +class Sprint(http.Controller): + + @http.route('/project_scrum/get_sprints_data', type='json', auth='user') + def fetch_sprints_data(self): + res = {} + + total_sprints = request.env['project.sprint'].search_count([]) + total_tasks = request.env['project.task'].search_count([]) + total_stories = request.env['project.story'].search_count([]) + total_projects = request.env['project.project'].search_count([]) + sprint_ids = request.env['project.sprint'].search_read( + [], ['id', 'name']) + + res.update({ + 'total_sprints': total_sprints, + 'total_tasks': total_tasks, + 'total_stories': total_stories, + 'total_projects': total_projects, + 'sprint_ids': sprint_ids, + }) + + return res + + @http.route('/project_scrum/get_sprint_data_for_chart', type='json', + auth='user') + def fetch_data_for_chart(self): + data = [] + sprint_ids = request.env['project.sprint'].search([]) + + for sprint in sprint_ids: + start = datetime.strptime(sprint.start_date, '%Y-%m-%d').date() + end = datetime.strptime(sprint.end_date, '%Y-%m-%d').date() + diff = (end - start).days + + data.append({ + 'sprint_seq': sprint.sprint_seq or '/', + 'velocity': sprint.estimated_velocity or 0, + 'no_of_days': diff, + }) + + return data + + @http.route('/project_scrum/get_sprint_data', type='json', auth='user') + def fetch_sprint_data(self, sprint_id): + data = [] + + if sprint_id: + sprint_obj = request.env['project.sprint'].browse( + int(sprint_id)) + sprint_velocity = sprint_obj.estimated_velocity + task_ids = request.env['project.task'].search([ + ('sprint_id', '=', int(sprint_id)) + ]) + + for task in task_ids: + per = (float(task.velocity) * 100) / float(sprint_velocity) + data.append({ + 'velocity': task.velocity, + 'task_seq': task.task_seq or '/', + 'per': round(per, 2), + 'user': task.user_id.name or '', + 'id': task.id, + }) + return data + + @http.route('/project_scrum/get_sprints_task_data', type='json', + auth='user') + def fetch_sprints_task_data(self, sprint_id): + res = {} + + if sprint_id: + domain = [('sprint_id', '=', int(sprint_id))] + + total_tasks = request.env['project.task'].search_count(domain) + total_stories = request.env['project.story'].search_count(domain) + + res.update({ + 'total_tasks': total_tasks, + 'total_stories': total_stories, + }) + + return res + + @http.route('/project_scrum/get_line_chart_data', type='json', + auth='user') + def fetch_line_chart_data(self): + data = [] + sprint_ids = request.env['project.sprint'].search([]) + for sprint in sprint_ids: + per = float( + (sprint.actual_velocity * 100) / sprint.estimated_velocity + if sprint.estimated_velocity > 0.0 else 0.0) + data.append({ + 'sprint_seq': sprint.sprint_seq or '/', + 'per': per, + 'velocity': sprint.estimated_velocity or 0.0, + }) + return data diff --git a/addons/project_scrum/data/project_sprint_data.xml b/addons/project_scrum/data/project_sprint_data.xml new file mode 100755 index 00000000..4e6d85e5 --- /dev/null +++ b/addons/project_scrum/data/project_sprint_data.xml @@ -0,0 +1,88 @@ + + + + + + + Sprint Draft + project.sprint + + Sprint Draft + + + + Sprint In Progress + project.sprint + + Sprint In Progress + + + + Sprint Pending + project.sprint + + Sprint Pending + + + + Sprint Done + project.sprint + + Sprint Done + + + + Sprint Cancel + project.sprint + + Sprint Cancel + + + + + + Sprint Draft + 10 + project.team + + + team_id + + + + Sprint In Progress + 10 + project.team + + + team_id + + + + Sprint Pending + 10 + project.team + + + team_id + + + + Sprint Done + 10 + project.team + + + team_id + + + + Sprint Cancel + 10 + project.team + + + team_id + + + \ No newline at end of file diff --git a/addons/project_scrum/data/sprint_sequence.xml b/addons/project_scrum/data/sprint_sequence.xml new file mode 100755 index 00000000..a3e4f0ca --- /dev/null +++ b/addons/project_scrum/data/sprint_sequence.xml @@ -0,0 +1,13 @@ + + + + + + Sprints + project.sprint + SP + 3 + + + + diff --git a/addons/project_scrum/demo/project_release_planning_demo.xml b/addons/project_scrum/demo/project_release_planning_demo.xml new file mode 100755 index 00000000..08acfdbd --- /dev/null +++ b/addons/project_scrum/demo/project_release_planning_demo.xml @@ -0,0 +1,42 @@ + + + + + + Release of Development + + + + + + + Release of Design & Documentation + + + + + + + Release Access Rights Allocation + + + + + + + Release Mail Configuration & Improved Dashboard + + + + + + + Scrum Updates + + + + + + \ No newline at end of file diff --git a/addons/project_scrum/demo/project_sprint_demo.xml b/addons/project_scrum/demo/project_sprint_demo.xml new file mode 100755 index 00000000..2c9e8f89 --- /dev/null +++ b/addons/project_scrum/demo/project_sprint_demo.xml @@ -0,0 +1,235 @@ + + + + + + 8 + 5 + + + + 7 + 5 + + + + + Manager + M + + + + Developer + D + + + + Tester + T + + + + + + Module Development + draft + + + + Develop Module with Documentation + + 11 + am + + 8.00 + hours + 1.00 + + + + + Access Rights & Testing + in_progress + + + + Access Rights Allocation + + 15 + pm + + 7.00 + hours + 1.00 + + + + + Implement Mailing Functionality + cancel + + + + Mailing and Workflow + + 15 + pm + + 7.00 + hours + 1.00 + + + + + Dashboard Improvements + in_progress + + + + Exclusive Dashboard + + 10 + am + + 7.00 + hours + 1.00 + + + + + Project Integration + pending + + + + Integrating Project + + 12 + pm + + 7.00 + hours + 1.00 + + + + + + 100 + 10.00 + + + + + + + 80 + 8.00 + + + + + + + 40 + 4.00 + + + + + + + 95 + 1.00 + + + + + + + 90 + 1.00 + + + + + + + 90 + 1.00 + + + + + + + 80 + 2.00 + + + + + + + 80 + 1.00 + + + + + + + 90 + 1.00 + + + + + + + 90 + 1.00 + + + + + + + 100 + 1.00 + + + + + + + 80 + 2.00 + + + + + + + 90 + 2.00 + + + + + + + 90 + 1.00 + + + + \ No newline at end of file diff --git a/addons/project_scrum/demo/project_story_demo.xml b/addons/project_scrum/demo/project_story_demo.xml new file mode 100755 index 00000000..fd7e792f --- /dev/null +++ b/addons/project_scrum/demo/project_story_demo.xml @@ -0,0 +1,65 @@ + + + + + + High + HIGH + + + + Medium + MEDIUM + + + + Low + LOW + + + + + Descriptive Story + Desc + + + + Brief Story + Brief + + + + + Development + + + + + Begin development... + + + + Designing + + + + + Begin Designing... + + + + Testing Workflow + + + + + Workflow testing... + + + \ No newline at end of file diff --git a/addons/project_scrum/demo/project_task_demo.xml b/addons/project_scrum/demo/project_task_demo.xml new file mode 100755 index 00000000..8fb3c2c9 --- /dev/null +++ b/addons/project_scrum/demo/project_task_demo.xml @@ -0,0 +1,250 @@ + + + + + + Creating Objects + + m + + 1 + + + + + + 30 + + Creating necessary objects... + 11 + 1 + + + + Creating Views + + h + + 1 + + + + + + 20 + + Creating view for objects... + 12 + 1 + + + + + Access Rights + + l + + 1 + + + + + + 15 + + Necessary rights for different objects... + 13 + 1 + + + + + Creating Mail Templates + + m + + 1 + + + + + + 15 + + Create different mail templates... + 13 + 1 + + + + + Mailing Functionality + + m + + 1 + + + + + + 20 + + Send Mails.... + 13 + 1 + + + + Rights division for different modules + + m + + 1 + + + + + + 25 + + Rights division... + 13 + 1 + + + + Rights allocation + + m + + 1 + + + + + + 30 + + Rights Allocation... + 13 + 1 + + + + Testing with different Users + + m + + 1 + + + + + + 25 + + Testing with different users to ensure users have got the necessary access... + 13 + 1 + + + + + Dashboard Design + + m + + 1 + + + + + + 35 + + Design for dashboard... + 13 + 1 + + + + View Rendering + + m + + 1 + + + + + + 20 + + Render views... + 13 + 1 + + + + + + Enhancements + + m + + 1 + + + + + + 25 + + Improvements for dashboard.... + 13 + 1 + + + + + Objects Integration + + m + + 1 + + + + + + 25 + + Integrate objects.... + 13 + 1 + + + + View Enhancements + + m + + 1 + + + + + + 20 + + View Enhancements... + 13 + 1 + + + + + \ No newline at end of file diff --git a/addons/project_scrum/demo/project_team_demo.xml b/addons/project_scrum/demo/project_team_demo.xml new file mode 100755 index 00000000..5bf45d25 --- /dev/null +++ b/addons/project_scrum/demo/project_team_demo.xml @@ -0,0 +1,75 @@ + + + + + + + Chris Gardner + chris@demo.com + + + + Oskar Schindler + oskar@demo.com + + + + Andy Dufresne + andy@demo.com + + + + + + Project Scrum + + + + employees + 1 + 10 + 1 + + + + + + Project Module Team + + + + + Technical, Functional + Description for the Project team... + + + + Sales Team + + + + + Functional + Description for the Sales team... + + + + Sales and Purchase Team + + + + + Technical, Functional + Description for the team... + + + diff --git a/addons/project_scrum/demo/retrospective_demo.xml b/addons/project_scrum/demo/retrospective_demo.xml new file mode 100755 index 00000000..02e1240c --- /dev/null +++ b/addons/project_scrum/demo/retrospective_demo.xml @@ -0,0 +1,60 @@ + + + + + + Went Well + Whatever we developed or improved, it went well + + + Needs Improvement + We have found some some tasks or ideas, which needs improvements + + + Stop + Now Stop Working of worthless task and go through new ideas we found. + + + + + Sprint Module Retrospective + + + + + + Scrum Documentation Retrospective + + + + + + Access Right Retrospective + + + + + + + + Tasks have been done well. + + + + + Add new description as per new added modules. + + + + + Implement New Access Rights according to the new modules added + + + + \ No newline at end of file diff --git a/addons/project_scrum/models/__init__.py b/addons/project_scrum/models/__init__.py new file mode 100755 index 00000000..161e1a7a --- /dev/null +++ b/addons/project_scrum/models/__init__.py @@ -0,0 +1,8 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from . import project_sprint +from . import project_story +from . import project_team +from . import release_planning +from . import retrospective_method +from . import retrospective \ No newline at end of file diff --git a/addons/project_scrum/models/project_sprint.py b/addons/project_scrum/models/project_sprint.py new file mode 100755 index 00000000..c9855d5c --- /dev/null +++ b/addons/project_scrum/models/project_sprint.py @@ -0,0 +1,670 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from flectra import models, fields, api, _ +from flectra.exceptions import ValidationError +from datetime import datetime, timedelta + + +class ProjectSprint(models.Model): + _name = "project.sprint" + _inherit = ['ir.branch.company.mixin', 'mail.thread'] + _description = "Sprint of the Project" + _rec_name = 'sprint_seq' + + sprint_seq = fields.Char( + string="Reference", readonly=True) + name = fields.Char("Sprint Name", required=True, + track_visibility="onchange") + goal_of_sprint = fields.Char("Goal of Sprint", track_visibility="onchange") + + meeting_date = fields.Datetime("Planning Meeting Date", required=True, + track_visibility="onchange") + hour = fields.Float(string="Hour", track_visibility="onchange") + time_zone = fields.Selection([ + ('am', 'AM'), + ('pm', 'PM'), + ], track_visibility="onchange") + estimated_velocity = fields.Integer( + compute="calculate_estimated_velocity", string="Estimated Velocity", + store=True, track_visibility="onchange") + actual_velocity = fields.Integer( + compute="calculate_actual_velocity", string="Actual Velocity", + store=True, track_visibility="onchange") + sprint_planning_line = fields.One2many( + 'sprint.planning.line', 'sprint_id', string="Sprint Planning Lines") + project_id = fields.Many2one('project.project', string="Project", + track_visibility="onchange") + start_date = fields.Date(string="Start Date", track_visibility="onchange") + end_date = fields.Date(string="End Date", track_visibility="onchange") + working_days = fields.Integer( + compute="calculate_business_days", string="Business Days", + store=True, track_visibility="onchange") + productivity_hours = fields.Float(string="Productivity Hours", + track_visibility="onchange") + productivity_per = fields.Float( + compute="calculate_productivity_per", string="Productivity (%)", + store=True, track_visibility="onchange") + holiday_type = fields.Selection( + [('hours', 'Hours'), ('days', 'Days')], + string="Holiday (Hours / Days)", default='hours', + track_visibility="onchange") + holiday_count = fields.Float(string="Holiday Count", + track_visibility="onchange") + holiday_days = fields.Float( + compute="calculate_holiday_days", string="Holiday Days", store=True, + track_visibility="onchange") + state = fields.Selection([ + ('draft', 'Draft'), + ('in_progress', 'In Progress'), + ('pending', 'Pending'), + ('done', 'Done'), + ('cancel', 'Cancel')], string="State", default='draft', + track_visibility="onchange") + team_id = fields.Many2one('project.team', string="Team", + track_visibility="onchange", required=True) + task_line = fields.One2many('project.task', 'sprint_id', string='Tasks') + color = fields.Integer('Color Index') + + @api.depends('start_date', 'end_date') + def days_calculate(self): + if self.start_date and self.end_date: + diff = datetime.strptime( + self.end_date, '%Y-%m-%d').date() - datetime.strptime( + self.start_date, '%Y-%m-%d').date() + self.duration = diff.days + + duration = fields.Integer( + "Duration (In Days)", compute='days_calculate', store=True, + track_visibility="onchange") + + @api.multi + def _get_task_count(self): + for record in self: + record.number_of_tasks = record.env['project.task'].search_count([ + ('sprint_id', '=', record.id)]) + + number_of_tasks = fields.Integer( + string="# of tasks", compute="_get_task_count") + + @api.multi + def _get_story_count(self): + for record in self: + count = record.env['project.story'].search_count([ + ('sprint_id', '=', record.id)]) + record.number_of_stories = count + + number_of_stories = fields.Integer( + string="# of stories", compute="_get_story_count") + + @api.multi + def _get_retrospective_count(self): + for record in self: + count = record.env['retrospective'].search_count([ + ('sprint_id', '=', record.id)]) + record.number_of_retrospectives = count + + number_of_retrospectives = fields.Integer( + string="# of Retrospectives", compute="_get_retrospective_count") + + @api.multi + @api.depends('task_line', 'task_line.stage_id', 'task_line.sprint_id', + 'estimated_velocity', 'start_date', 'end_date', 'project_id', + 'team_id') + def calculate_tasks_json(self): + data = [] + for record in self: + task_ids = self.env['project.task'].search([ + ('sprint_id', '=', record.id)]) + velocity = record.estimated_velocity or 1.0 + for task in task_ids: + data.append({ + 'task': task.task_seq or '/', + 'velocity': task.velocity or 0, + 'per': round(((float(task.velocity) * 100) / float( + velocity)), 2) + }) + record.tasks_json = data + + tasks_json = fields.Char( + string="Tasks", compute="calculate_tasks_json", store=True) + + @api.multi + def get_data(self): + task_dict_list = [] + for record in self: + task_pool = self.env['project.task'].search([ + ('sprint_id', '=', record.id)]) + for task in task_pool: + task_dict = { + 'reference': task.task_seq, + 'name': task.name, + 'velocity': task.velocity, + 'start_date': task.start_date, + 'end_date': task.end_date, + 'actual_end_date': task.actual_end_date, + 'assigned_to': task.user_id.name, + 'state': task.stage_id.name, + } + task_dict_list.append(task_dict) + return task_dict_list + + @api.multi + def set_state_open(self): + self.state = 'in_progress' + + @api.multi + def set_state_cancel(self): + self.state = 'cancel' + + @api.multi + def set_state_pending(self): + self.state = 'pending' + + @api.multi + def redirect_to_view(self, model, caption): + return { + 'name': (_(caption)), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'tree,form', + 'res_model': model, + 'domain': [('sprint_id', '=', self.id)], + 'context': { + 'default_sprint_id': self.id, + 'default_project_id': self.project_id.id + } + } + + @api.multi + def action_view_tasks(self): + return self.redirect_to_view("project.task", "Tasks") + + @api.multi + def action_view_stories(self): + return self.redirect_to_view("project.story", "Stories") + + @api.multi + def action_view_release_planning(self): + return self.redirect_to_view("release.planning", "Release Planning") + + @api.multi + def action_view_retrospective(self): + return self.redirect_to_view("retrospective", "Retrospective") + + @api.constrains('start_date', 'end_date') + def check_dates(self): + if self.start_date and self.end_date and ( + self.start_date > self.end_date): + raise ValidationError( + "Start Date can not be greater than End date, Dude!") + + @api.onchange('holiday_type') + def onchange_holiday_type(self): + self.holiday_count = 0.0 + self.holiday_days = 0.0 + + @api.multi + @api.depends('project_id', 'project_id.no_of_days', 'start_date', + 'end_date') + def calculate_business_days(self): + for record in self: + if record.start_date and record.end_date: + days_dict = { + 0: (1, 2, 3, 4, 5, 6, 7), + 1: (2, 3, 4, 5, 6, 7), + 2: (3, 4, 5, 6, 7), + 3: (4, 5, 6, 7), + 4: (5, 6, 7), + 5: (6, 7), + 6: (7,), + 7: (), + } + start = datetime.strptime(record.start_date, "%Y-%m-%d").date() + end = datetime.strptime(record.end_date, "%Y-%m-%d").date() + delta = timedelta(days=1) + days = 0 + + if record.project_id and end > start: + working_days = record.project_id.no_of_days + non_working_days = days_dict[working_days] + + while end != start: + end -= delta + if end.isoweekday() not in non_working_days: + days += 1 + + record.working_days = days + + @api.multi + @api.depends('project_id', 'project_id.no_of_hours', 'productivity_hours') + def calculate_productivity_per(self): + for record in self: + project_id = record.project_id + no_of_hours = project_id.no_of_hours if project_id else 0 + prod_hours = record.productivity_hours + if project_id and no_of_hours > 0 and prod_hours: + record.productivity_per = (prod_hours / no_of_hours) * 100 + + @api.multi + @api.depends('project_id', 'project_id.no_of_hours', 'holiday_count') + def calculate_holiday_days(self): + for record in self: + if record.holiday_type == 'days' and record.project_id: + hours = record.holiday_count * record.project_id.no_of_hours + record.holiday_days = hours + + @api.multi + @api.depends('project_id', 'task_line', 'task_line.velocity') + def calculate_estimated_velocity(self): + for record in self: + task_ids = record.env['project.task'].search([ + ('sprint_id', '=', record.id) + ]) + total_velocity = sum([ + task.velocity for task in task_ids if task.velocity]) + record.estimated_velocity = total_velocity + + @api.multi + @api.depends('project_id', 'end_date', 'task_line', 'task_line.velocity', + 'task_line.stage_id') + def calculate_actual_velocity(self): + for record in self: + task_ids = record.env['project.task'].search([ + ('sprint_id', '=', record.id), + ('actual_end_date', '<=', record.end_date), + ]) + total_velocity = sum([ + task.velocity for task in task_ids if task.velocity]) + record.actual_velocity = total_velocity + + @api.onchange('duration') + def onchange_start_date(self): + if self.start_date: + end_date = datetime.strptime( + self.start_date, '%Y-%m-%d') + timedelta(days=self.duration) + self.end_date = end_date + + @api.onchange('project_id') + def onchange_project(self): + if self.project_id and self.project_id.branch_id: + self.branch_id = self.project_id.branch_id + + @api.multi + def check_sprint_state(self): + next_call = datetime.today() + for record in self.search([('state', '!=', 'done')]): + if record.end_date: + end_date = datetime.strptime( + record.end_date, '%Y-%m-%d').date() + if end_date < next_call: + record.state = 'done' + + @api.constrains('sprint_planning_line') + def check_users_in_planning_line(self): + user_list = [] + for user in self.sprint_planning_line: + if user.user_id.id not in user_list: + user_list.append(user.user_id.id) + else: + raise ValidationError( + "You can't add the same user twice in Sprint Planning!") + + @api.model + def create(self, vals): + vals['sprint_seq'] = self.env[ + 'ir.sequence'].next_by_code('project.sprint') + + res = super(ProjectSprint, self).create(vals) + partner_list = [] + + mail_channel_id = self.env['mail.channel'].sudo().search([ + ('name', '=', 'Project Sprint') + ]) + if mail_channel_id: + mail_channel_ids = self.env['mail.followers'].sudo().search([ + ('channel_id', '=', mail_channel_id.id), + ('res_model', '=', res._name), + ('res_id', '=', res.id), + ]) + if not mail_channel_ids: + self.env['mail.followers'].sudo().create({ + 'channel_id': mail_channel_id.id, + 'res_model': res._name, + 'res_id': res.id, + }) + + if 'team_id' in vals: + team_id = self.env['project.team'].browse(vals['team_id']) + partner_list += [member.partner_id.id + for member in team_id.member_ids] + + for follower in partner_list: + if follower: + mail_follower_ids = self.env['mail.followers'].sudo().search([ + ('res_id', '=', res.id), + ('partner_id', '=', follower), + ('res_model', '=', res._name), + ]) + if not mail_follower_ids: + self.env['mail.followers'].sudo().create({ + 'res_id': res.id, + 'res_model': res._name, + 'partner_id': follower, + 'team_id': team_id.id, + }) + return res + + @api.multi + def write(self, vals): + res = super(ProjectSprint, self).write(vals) + + if 'team_id' in vals: + team_id = self.env['project.team'].browse(vals['team_id']) + else: + team_id = self.team_id + + delete_team_id = self.env['mail.followers'].sudo().search([ + ('team_id', '!=', team_id.id), + ('res_id', '=', self.id), + ]) + delete_team_id.unlink() + + partner_list = [member.partner_id.id for member in team_id.member_ids] + for follower in partner_list: + if follower: + mail_follower_ids = self.env['mail.followers'].sudo().search([ + ('res_id', '=', self.id), + ('partner_id', '=', follower), + ('res_model', '=', self._name), + ]) + if not mail_follower_ids: + self.env['mail.followers'].sudo().create({ + 'res_id': self.id, + 'res_model': self._name, + 'partner_id': follower, + 'team_id': team_id.id, + }) + return res + + @api.multi + def _track_subtype(self, init_values): + self.ensure_one() + if 'state' in init_values and self.state == 'draft': + return 'project_scrum.state_sprint_draft' + elif 'state' in init_values and self.state == 'in_progress': + return 'project_scrum.state_sprint_in_progress' + elif 'state' in init_values and self.state == 'pending': + return 'project_scrum.state_sprint_pending' + elif 'state' in init_values and self.state == 'done': + return 'project_scrum.state_sprint_done' + elif 'state' in init_values and self.state == 'cancel': + return 'project_scrum.state_sprint_cancel' + return super(ProjectSprint, self)._track_subtype(init_values) + + +class Project(models.Model): + _inherit = "project.project" + + @api.multi + def _sprint_count(self): + for project in self: + count = project.env['project.sprint'].search_count([ + ('project_id', '=', project.id) + ]) + project.sprint_count = count + + sprint_count = fields.Integer( + compute="_sprint_count", string="No. of sprints related to") + + @api.multi + def show_sprints(self): + self.ensure_one() + return { + 'name': ("Sprints"), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'tree,form', + 'res_model': 'project.sprint', + 'domain': [('project_id', '=', self.id)] + } + + no_of_hours = fields.Integer( + related="resource_calendar_id.no_of_hours", + string="Working Hour(s) per Day", store=True) + no_of_days = fields.Integer( + related="resource_calendar_id.no_of_days", + string="Working Day(s) per Week", store=True) + task_type_ids = fields.Many2many( + 'project.task.type', 'project_task_type_rel', 'project_id', 'type_id', + string='Project(s)') + + +class SprintPlanningLine(models.Model): + _name = "sprint.planning.line" + + @api.multi + @api.depends('available_per', 'sprint_id.project_id', + 'sprint_id.project_id.no_of_hours', + 'sprint_id', 'sprint_id.working_days') + def calculate_hours(self): + for record in self: + project_id = record.sprint_id.project_id + no_of_hours = project_id and project_id.no_of_hours or 0.0 + calc = ( + record.available_per * + record.sprint_id.working_days * no_of_hours) / 100 + record.productivity_hours = float(calc) + + @api.multi + @api.depends('sprint_id', 'available_per', 'sprint_id.working_days', + 'sprint_id.productivity_hours') + def calculate_sprint_hours(self): + for record in self: + hours = (record.available_per * record.sprint_id.working_days * + record.sprint_id.productivity_hours) / 100 + record.sprint_hours = hours + + @api.multi + @api.depends('sprint_id', 'sprint_id.holiday_count', + 'sprint_id.holiday_days', 'sprint_hours', 'off_hours') + def calculate_total_hours(self): + for record in self: + if record.sprint_id.holiday_type == 'hours': + hours = ( + record.sprint_hours - record.sprint_id.holiday_count - + record.off_hours) + else: + hours = ( + record.sprint_hours - record.sprint_id.holiday_days - + record.off_hours) + record.total_hours = hours + + sprint_id = fields.Many2one('project.sprint', string="Sprint") + user_id = fields.Many2one('res.users', string="User") + role_id = fields.Many2one( + related="user_id.role_id", string="Role", store=True) + available_per = fields.Integer(string="Available (%)") + productivity_hours = fields.Float( + compute="calculate_hours", string="Productivity Hour(s)", store=True) + sprint_hours = fields.Float( + compute="calculate_sprint_hours", string="Sprint Hour(s)", store=True) + off_hours = fields.Float(string="Off Hour(s)") + total_hours = fields.Float( + compute="calculate_total_hours", string="Total Hour(s)", store=True) + + +class UserRole(models.Model): + _name = "user.role" + + name = fields.Char(string="Role") + code = fields.Char(string="Code") + + +class ResUsers(models.Model): + _inherit = "res.users" + + role_id = fields.Many2one("user.role", string="User Role") + + +class ProjectTask(models.Model): + _inherit = "project.task" + + sprint_id = fields.Many2one( + 'project.sprint', string="Sprint", track_visibility="onchange") + velocity = fields.Integer(string="Velocity", track_visibility="onchange") + story_id = fields.Many2one( + 'project.story', string="Story", track_visibility="onchange") + release_planning_id = fields.Many2one( + "release.planning", string="Release Planning", + track_visibility="onchange") + + @api.onchange('story_id') + def onchange_story(self): + if self.story_id: + self.description = self.story_id.description + + @api.constrains('start_date', 'end_date') + def check_dates(self): + if self.sprint_id: + start_date = self.sprint_id.start_date + end_date = self.sprint_id.end_date + + if self.start_date and start_date and ( + self.start_date < start_date): + raise ValidationError( + "Start date is not valid according to the Sprint.") + + if self.end_date and end_date and (self.end_date > end_date): + raise ValidationError( + "End date is not valid according to the Sprint.") + + @api.model + def create(self, vals): + res = super(ProjectTask, self).create(vals) + + partner_list = [ + member.partner_id.id + for member in res.sprint_id.team_id.member_ids + ] + + for follower in partner_list: + if follower: + mail_follower_ids = self.env['mail.followers'].sudo().search([ + ('res_id', '=', res.id), + ('partner_id', '=', follower), + ('res_model', '=', self._name), + ]) + if not mail_follower_ids: + self.env['mail.followers'].sudo().create({ + 'res_id': res.id, + 'res_model': self._name, + 'partner_id': follower, + 'team_id': res.sprint_id.team_id.id, + }) + return res + + @api.multi + def write(self, vals): + if self.task_seq == '/': + vals['task_seq'] = self.env['ir.sequence'].next_by_code( + 'project.task') + res = super(ProjectTask, self).write(vals) + + data = [] + for record in self: + task_ids = self.search([ + ('sprint_id', '=', record.sprint_id.id)]) + for task in task_ids: + data.append({ + 'task': task.task_seq or '/', + 'velocity': task.velocity or 0, + 'per': round(((float(task.velocity) * 100) / float( + record.sprint_id.estimated_velocity)), 2) + if record.sprint_id.estimated_velocity > 0 else 0 + }) + + record.sprint_id.write({'tasks_json': data}) + + if 'sprint_id' in vals: + sprint_id = self.env['project.sprint'].browse(vals['sprint_id']) + team_id = sprint_id.team_id + else: + team_id = self.sprint_id.team_id + + delete_team_id = self.env['mail.followers'].sudo().search([ + ('team_id', '!=', team_id.id), + ('res_id', '=', self.id), + ]) + delete_team_id.unlink() + + partner_list = [member.partner_id.id for member in team_id.member_ids] + for follower in partner_list: + if follower: + mail_follower_ids = self.env['mail.followers'].sudo().search([ + ('res_id', '=', self.id), + ('partner_id', '=', follower), + ('res_model', '=', self._name), + ]) + if not mail_follower_ids: + self.env['mail.followers'].sudo().create({ + 'res_id': self.id, + 'res_model': self._name, + 'partner_id': follower, + 'team_id': team_id.id, + }) + return res + + +class MailFollowers(models.Model): + _inherit = "mail.followers" + + team_id = fields.Many2one("project.team", string="Project Team") + + +class ResourceCalendar(models.Model): + _inherit = "resource.calendar" + + no_of_hours = fields.Integer(string="No. of Hour(s) a Day", default=8) + no_of_days = fields.Integer(string="No. of Day(s) a Week", default=5) + + @api.multi + @api.constrains('no_of_hours', 'no_of_days', 'attendance_ids') + def _check_days_hours(self): + week_days = { + 0: 'Monday', + 1: 'Tuesday', + 2: 'Wednesday', + 3: 'Thursday', + 4: 'Friday', + 5: 'Saturday', + 6: 'Sunday' + } + + for resource in self: + res = {} + current_hours = resource.no_of_hours + current_days = resource.no_of_days + + if current_days > 7 or current_days < 1: + raise ValidationError(_( + "No. of Days should be in between 1 - 7.")) + + for attendance in resource.attendance_ids: + day = attendance.dayofweek + diff = abs(attendance.hour_from - attendance.hour_to) + if day not in res: + res[day] = diff + else: + res[day] += diff + + if current_days and len(res) > current_days: + raise ValidationError(_( + "You can not Add Working Hour(s) for more than %s\ + different days.") % current_days) + + for day, hours in sorted(list(res.items())): + if current_hours and hours > current_hours: + raise ValidationError(_( + "Invalid hours for %s!\n\nWorking hours per day should\ + not be greater than %s.") % ( + week_days[int(day)], current_hours)) diff --git a/addons/project_scrum/models/project_story.py b/addons/project_scrum/models/project_story.py new file mode 100755 index 00000000..35edc4c4 --- /dev/null +++ b/addons/project_scrum/models/project_story.py @@ -0,0 +1,161 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from flectra import fields, models, api, _ + + +class ProjectStory(models.Model): + _name = "project.story" + _inherit = ['ir.branch.company.mixin', 'mail.thread'] + _description = "Project Story" + + name = fields.Char("Title", required=True, track_visibility="onchange") + priority_id = fields.Many2one('story.priority', string="Priority", + track_visibility="onchange") + story_type_id = fields.Many2one('story.type', string="Type", + track_visibility="onchange") + tags = fields.Char("Tags", track_visibility="onchange") + description = fields.Text("Description", track_visibility="onchange") + owner_id = fields.Many2one( + 'res.users', string="Owner", default=lambda self: self.env.user.id, + track_visibility='onchange') + sprint_id = fields.Many2one('project.sprint', string="Sprint", + track_visibility="onchange") + estimated_velocity = fields.Integer( + compute="calculate_estimated_velocity", string="Estimated Velocity", + store=True, track_visibility="onchange") + actual_velocity = fields.Integer( + compute="calculate_actual_velocity", string="Actual Velocity", + store=True, track_visibility="onchange") + state = fields.Selection([ + ('draft', 'Draft'), + ('in_progress', 'In Progress'), + ('cancel', 'Cancel'), + ('done', 'Done'), + ], string="states", track_visibility="onchange", default="draft") + + @api.multi + def set_state_active(self): + self.state = "in_progress" + + @api.multi + def set_state_cancel(self): + self.state = "cancel" + + @api.multi + def set_state_done(self): + self.state = "done" + + @api.multi + def action_view_tasks(self): + return { + 'name': (_('Tasks')), + 'type': 'ir.actions.act_window', + 'view_type': 'form', + 'view_mode': 'tree,form', + 'res_model': 'project.task', + 'domain': [('sprint_id', '=', self.sprint_id.id)], + 'context': { + 'default_sprint_id': self.sprint_id.id, + 'default_project_id': self.sprint_id.project_id.id, + } + } + + @api.multi + @api.depends('sprint_id') + def calculate_estimated_velocity(self): + for story in self: + task_ids = story.env['project.task'].search([ + ('sprint_id', '=', story.sprint_id.id) + ]) + total_velocity = sum([ + task.velocity for task in task_ids if task.velocity]) + story.estimated_velocity = total_velocity + + @api.multi + @api.depends('sprint_id', 'sprint_id.end_date') + def calculate_actual_velocity(self): + for story in self: + task_ids = story.env['project.task'].search([ + ('sprint_id', '=', story.sprint_id.id), + ('actual_end_date', '<=', story.sprint_id.end_date) + ]) + total_velocity = sum([ + task.velocity for task in task_ids if task.velocity]) + story.actual_velocity = total_velocity + + @api.onchange('sprint_id') + def onchange_project(self): + if self.sprint_id and self.sprint_id.project_id.branch_id: + self.branch_id = self.sprint_id.project_id.branch_id + + @api.model + def create(self, vals): + res = super(ProjectStory, self).create(vals) + + partner_list = [ + member.partner_id.id for member in res.sprint_id.team_id.member_ids + ] + + for follower in partner_list: + if follower: + mail_followers_ids = self.env['mail.followers'].sudo().search([ + ('res_id', '=', res.id), + ('partner_id', '=', follower), + ('res_model', '=', self._name), + ]) + + if not mail_followers_ids: + self.env['mail.followers'].sudo().create({ + 'res_id': res.id, + 'res_model': self._name, + 'partner_id': follower, + 'team_id': res.sprint_id.team_id.id, + }) + return res + + @api.multi + def write(self, vals): + res = super(ProjectStory, self).write(vals) + delete_team_id = self.env['mail.followers'].sudo().search([ + ('team_id', '!=', self.sprint_id.team_id.id), + ('res_id', '=', self.id), + ]) + + delete_team_id.unlink() + + partner_list = [ + member.partner_id.id for member in + self.sprint_id.team_id.member_ids + ] + + for follower in partner_list: + if follower: + mail_follower_ids = self.env['mail.followers'].sudo().search([ + ('res_id', '=', self.id), + ('partner_id', '=', follower), + ('res_model', '=', self._name), + ]) + if not mail_follower_ids: + self.env['mail.followers'].sudo().create({ + 'res_id': self.id, + 'res_model': self._name, + 'partner_id': follower, + 'team_id': self.sprint_id.team_id.id, + }) + return res + + +class StoryPriority(models.Model): + _name = "story.priority" + _inherit = 'mail.thread' + + name = fields.Char("Name", track_visibility="onchange") + code = fields.Char("Code", track_visibility="onchange") + + +class StoryType(models.Model): + _name = "story.type" + _inherit = 'mail.thread' + + name = fields.Char("Name", track_visibility="onchange") + code = fields.Char("Code", track_visibility="onchange") diff --git a/addons/project_scrum/models/project_team.py b/addons/project_scrum/models/project_team.py new file mode 100755 index 00000000..9e37c1b5 --- /dev/null +++ b/addons/project_scrum/models/project_team.py @@ -0,0 +1,23 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from flectra import models, fields, api + + +class ProjectTeam(models.Model): + _name = "project.team" + _inherit = ['ir.branch.company.mixin', 'mail.thread'] + _description = "Project Team" + + name = fields.Char("Team Name", required=True, track_visibility="onchange") + strength = fields.Text("Team Strength", track_visibility="onchange") + member_ids = fields.Many2many("res.users", string="Members") + master_id = fields.Many2one( + "res.users", string="Scrum Master", track_visibility="onchange") + description = fields.Html() + project_id = fields.Many2one( + 'project.project', string="Project", track_visibility="onchange") + + @api.onchange('project_id') + def onchange_project(self): + if self.project_id and self.project_id.branch_id: + self.branch_id = self.project_id.branch_id diff --git a/addons/project_scrum/models/release_planning.py b/addons/project_scrum/models/release_planning.py new file mode 100755 index 00000000..2822fa19 --- /dev/null +++ b/addons/project_scrum/models/release_planning.py @@ -0,0 +1,29 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from flectra import models, fields, api + + +class ReleasePlanning(models.Model): + _name = "release.planning" + _inherit = ['ir.branch.company.mixin', 'mail.thread'] + + name = fields.Char(string="Planning Name", track_visibility="onchange") + release_date = fields.Datetime( + string="Release Date", track_visibility="onchange") + sprint_id = fields.Many2one( + 'project.sprint', string="Sprint", track_visibility="onchange") + priority = fields.Selection([ + ('low', 'Low'), + ('medium', 'Medium'), + ('high', 'High')], string="Priority", default='low', + track_visibility="onchange") + velocity = fields.Integer( + related="sprint_id.estimated_velocity", string="Sprint Velocity", + track_visibility="onchange", store=True) + task_id = fields.One2many( + "project.task", "release_planning_id", string="Task", readonly=True) + + @api.onchange('sprint_id') + def onchange_project(self): + if self.sprint_id and self.sprint_id.project_id.branch_id: + self.branch_id = self.sprint_id.project_id.branch_id diff --git a/addons/project_scrum/models/retrospective.py b/addons/project_scrum/models/retrospective.py new file mode 100755 index 00000000..12c05c8d --- /dev/null +++ b/addons/project_scrum/models/retrospective.py @@ -0,0 +1,42 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from flectra import models, fields, api + + +class Retrospective(models.Model): + _name = "retrospective" + _inherit = ['ir.branch.company.mixin', 'mail.thread'] + + name = fields.Char(string="Retrospective Name", required=True, + track_visibility='onchange') + retrospective_method_id = fields.Many2one( + "retrospective.method", string="Retrospective Method", + track_visibility='onchange') + scrum_master = fields.Many2one("res.users", string="Scrum Master", + track_visibility='onchange') + sprint_id = fields.Many2one( + "project.sprint", string="Sprint", track_visibility='onchange') + retrospective_line_ids = fields.One2many( + "retrospective.lines", "retrospective_id", + string="Retrospective Lines", track_visibility='onchange') + start_date = fields.Datetime(string="Start Date") + end_date = fields.Datetime(string="End Date") + + @api.onchange('sprint_id') + def on_sprint_id_change(self): + self.scrum_master = self.sprint_id.team_id.master_id.id + + @api.onchange('sprint_id') + def onchange_project(self): + if self.sprint_id and self.sprint_id.project_id.branch_id: + self.branch_id = self.sprint_id.project_id.branch_id + + +class RetrospectiveLines(models.Model): + _name = "retrospective.lines" + + user_id = fields.Many2one("res.users", string="User", readonly=True, + required=True, + default=lambda self: self.env.user) + message = fields.Text(string="Message") + retrospective_id = fields.Many2one("retrospective", string="Retrospective") diff --git a/addons/project_scrum/models/retrospective_method.py b/addons/project_scrum/models/retrospective_method.py new file mode 100755 index 00000000..83333f25 --- /dev/null +++ b/addons/project_scrum/models/retrospective_method.py @@ -0,0 +1,15 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from flectra import models, fields + + +class RetrospectiveMethod(models.Model): + _name = "retrospective.method" + _inherit = ['ir.branch.company.mixin', 'mail.thread'] + _description = "Retrospective Implementation for Project Scrum" + + name = fields.Char( + string="Method Name", required=True, track_visibility='onchange') + + description = fields.Text( + string="Description", track_visibility='onchange') diff --git a/addons/project_scrum/reports/project_scrum_report.xml b/addons/project_scrum/reports/project_scrum_report.xml new file mode 100755 index 00000000..1c89ee9f --- /dev/null +++ b/addons/project_scrum/reports/project_scrum_report.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/addons/project_scrum/reports/project_sprint_template.xml b/addons/project_scrum/reports/project_sprint_template.xml new file mode 100755 index 00000000..db80ea99 --- /dev/null +++ b/addons/project_scrum/reports/project_sprint_template.xml @@ -0,0 +1,134 @@ + + + + + + \ No newline at end of file diff --git a/addons/project_scrum/reports/release_planning_template.xml b/addons/project_scrum/reports/release_planning_template.xml new file mode 100755 index 00000000..c1bc7f2d --- /dev/null +++ b/addons/project_scrum/reports/release_planning_template.xml @@ -0,0 +1,100 @@ + + + + + + \ No newline at end of file diff --git a/addons/project_scrum/security/ir.model.access.csv b/addons/project_scrum/security/ir.model.access.csv new file mode 100755 index 00000000..ee1a4b13 --- /dev/null +++ b/addons/project_scrum/security/ir.model.access.csv @@ -0,0 +1,36 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_project_sprint_user,access_project_sprint_user,model_project_sprint,project.group_project_user,1,1,0,0 +access_project_sprint_manager,access_project_sprint_manager,model_project_sprint,project.group_project_manager,1,1,1,1 +,,,,,,, +access_project_story_user,access_project_story_user,model_project_story,project.group_project_user,1,1,0,0 +access_project_story_manager,access_project_story_manager,model_project_story,project.group_project_manager,1,1,1,1 +,,,,,,, +access_project_team_user,access_project_team_user,model_project_team,project.group_project_user,1,1,0,0 +access_project_team_manager,access_project_team_manager,model_project_team,project.group_project_manager,1,1,1,1 +,,,,,,, +access_role_user,access_role_user,model_user_role,project.group_project_user,1,0,0,0 +access_role_manager,access_role_manager,model_user_role,project.group_project_manager,1,1,1,1 +,,,,,,, +access_sprint_planning_line_user,access_sprint_planning_line_user,model_sprint_planning_line,project.group_project_user,1,0,0,0 +access_sprint_planning_line_manager,access_sprint_planning_line_manager,model_sprint_planning_line,project.group_project_manager,1,1,1,1 +,,,,,,, +access_story_priority_user,access_story_priority_user,model_story_priority,project.group_project_user,1,0,0,0 +access_story_priority_manager,access_story_priority_manager,model_story_priority,project.group_project_manager,1,1,1,1 +,,,,,,, +access_story_type_user,access_story_priority_user,model_story_type,project.group_project_user,1,0,0,0 +access_story_type_manager,access_story_priority_manager,model_story_type,project.group_project_manager,1,1,1,1 +,,,,,,, +access_release_planning_user,access_release_planning_user,model_release_planning,project.group_project_user,1,0,0,0 +access_release_planning_manager,access_release_planning_manager,model_release_planning,project.group_project_manager,1,1,1,1 +,,,,,,, +access_retrospective_user,access_retrospective_user,model_retrospective,project.group_project_user,1,1,0,0 +access_retrospective_manager,access_retrospective_manager,model_retrospective,project.group_project_manager,1,1,1,1 +,,,,,,, +access_retrospective_method_user,access_retrospective_method_user,model_retrospective_method,project.group_project_user,1,0,0,0 +access_retrospective_method_manager,access_retrospective_method_manager,model_retrospective_method,project.group_project_manager,1,1,1,1 +,,,,,,, +access_retrospective_lines_user,access_retrospective_lines_user,model_retrospective_lines,project.group_project_user,1,1,1,1 +access_retrospective_lines_manager,access_retrospective_lines_manager,model_retrospective_lines,project.group_project_manager,1,1,1,1 +,,,,,,, +access_project_team_manager_res_users,access_project_team_manager_res_users,base.model_res_users,project.group_project_manager,1,1,0,0 +access_project_team_user_res_users,access_project_team_user_res_users,base.model_res_users,project.group_project_user,1,1,0,0 diff --git a/addons/project_scrum/security/scrum_security.xml b/addons/project_scrum/security/scrum_security.xml new file mode 100755 index 00000000..dbb6d33c --- /dev/null +++ b/addons/project_scrum/security/scrum_security.xml @@ -0,0 +1,13 @@ + + + + + Sprint: multi-company & branch + + + ['|','|', ('company_id', '=', False), ('company_id', 'child_of', [user.company_id.id]), ('company_id', 'in', user.company_ids.ids), '|', '|', ('branch_id', '=', False), + ('branch_id', '=', user.default_branch_id.id), ('branch_id', 'in', user.branch_ids.ids)] + + + + \ No newline at end of file diff --git a/addons/project_scrum/static/description/dashboard.png b/addons/project_scrum/static/description/dashboard.png new file mode 100644 index 00000000..c2f98adc Binary files /dev/null and b/addons/project_scrum/static/description/dashboard.png differ diff --git a/addons/project_scrum/static/description/icon.png b/addons/project_scrum/static/description/icon.png new file mode 100644 index 00000000..9ae5985b Binary files /dev/null and b/addons/project_scrum/static/description/icon.png differ diff --git a/addons/project_scrum/static/description/index.html b/addons/project_scrum/static/description/index.html new file mode 100644 index 00000000..db984dd4 --- /dev/null +++ b/addons/project_scrum/static/description/index.html @@ -0,0 +1,105 @@ +
+
+
+

Project Scrum

+

Attractive Dashboard

+
+
+ This should be the picture of Dashboard +
+
+

+ You can see the burndown chart of sprint as well as tasks + related to particular sprint. User can also use smart buttons for + traversing Projects, Sprints, Tasks and Stories

+
+
+
+ +
+
+
+

Sprint

+
+
+

A scrum sprint is a regular, repeatable work cycle in scrum + methodology during which work is completed and made ready for review. Scrum sprints are + basic units of development in the scrum methodology. Generally sprints are 1-2 week(s) long...

+
+
+
+ Sprint +
+
+
+
+ +
+
+
+

Release Planning

+
+
+
+ Release Planning +
+
+
+

+ A very high-level plan for multiple Sprints (e.g. three to twelve iteration) is created during the Release planning. It is a guideline that reflects expectations about which features will be implemented and when they are completed. It also serves as a base to monitor progress within the project. +

+
+
+ +
+ +
+
+
+

Story

+
+
+

+ User Stories are managed in the Product Backlog. The User Stories are ordered according to priority. The most prioritized user stories are refined to granular level, while the least priority user stories are kept at a lesser detail level. For every sprint, the most prioritized and hence more granulated user stories are taken into the sprint backlog. If a user story is to be added to the product backlog, its priority is first determined, and it is placed according to its place as per the priority. +

+
+
+
+ Story +
+
+
+
+ +
+
+
+

Team

+
+
+
+ Team +
+
+
+ Within the Scrum Framework all work delivered to the customer is done by dedicated Scrum Teams. A Scrum Team is a collection of individuals working together to deliver the requested and committed product increments. +
+
+
+ +
+
+
+

Retrospective

+
+
+ The sprint retrospective is a meeting facilitated by the ScrumMaster at which the team discusses the just-concluded sprint and determines what could be changed that might make the next sprint more productive. The sprint review looks at what the team is building, whereas the retrospective looks at how they are building it. +
+
+
+ Retrospective +
+
+
+
diff --git a/addons/project_scrum/static/description/release_planning.png b/addons/project_scrum/static/description/release_planning.png new file mode 100644 index 00000000..425f561a Binary files /dev/null and b/addons/project_scrum/static/description/release_planning.png differ diff --git a/addons/project_scrum/static/description/retro.png b/addons/project_scrum/static/description/retro.png new file mode 100644 index 00000000..88bda509 Binary files /dev/null and b/addons/project_scrum/static/description/retro.png differ diff --git a/addons/project_scrum/static/description/retro_method.png b/addons/project_scrum/static/description/retro_method.png new file mode 100644 index 00000000..d67befe1 Binary files /dev/null and b/addons/project_scrum/static/description/retro_method.png differ diff --git a/addons/project_scrum/static/description/sprint.png b/addons/project_scrum/static/description/sprint.png new file mode 100644 index 00000000..416c4267 Binary files /dev/null and b/addons/project_scrum/static/description/sprint.png differ diff --git a/addons/project_scrum/static/description/story.png b/addons/project_scrum/static/description/story.png new file mode 100644 index 00000000..bf2b666e Binary files /dev/null and b/addons/project_scrum/static/description/story.png differ diff --git a/addons/project_scrum/static/description/team.png b/addons/project_scrum/static/description/team.png new file mode 100644 index 00000000..62262835 Binary files /dev/null and b/addons/project_scrum/static/description/team.png differ diff --git a/addons/project_scrum/static/src/fonts/sourcesanspro/METADATA.pb b/addons/project_scrum/static/src/fonts/sourcesanspro/METADATA.pb new file mode 100755 index 00000000..603b9560 --- /dev/null +++ b/addons/project_scrum/static/src/fonts/sourcesanspro/METADATA.pb @@ -0,0 +1,117 @@ +name: "Source Sans Pro" +designer: "Paul D. Hunt" +license: "OFL" +category: "SANS_SERIF" +date_added: "2012-07-31" +fonts { + name: "Source Sans Pro" + style: "normal" + weight: 200 + filename: "SourceSansPro-ExtraLight.ttf" + post_script_name: "SourceSansPro-ExtraLight" + full_name: "Source Sans Pro ExtraLight" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "italic" + weight: 200 + filename: "SourceSansPro-ExtraLightItalic.ttf" + post_script_name: "SourceSansPro-ExtraLightIt" + full_name: "Source Sans Pro ExtraLight Italic" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "normal" + weight: 300 + filename: "SourceSansPro-Light.ttf" + post_script_name: "SourceSansPro-Light" + full_name: "Source Sans Pro Light" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "italic" + weight: 300 + filename: "SourceSansPro-LightItalic.ttf" + post_script_name: "SourceSansPro-LightIt" + full_name: "Source Sans Pro Light Italic" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "normal" + weight: 400 + filename: "SourceSansPro-Regular.ttf" + post_script_name: "SourceSansPro-Regular" + full_name: "Source Sans Pro" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "italic" + weight: 400 + filename: "SourceSansPro-Italic.ttf" + post_script_name: "SourceSansPro-It" + full_name: "Source Sans Pro Italic" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "normal" + weight: 600 + filename: "SourceSansPro-Semibold.ttf" + post_script_name: "SourceSansPro-Semibold" + full_name: "Source Sans Pro Semibold" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "italic" + weight: 600 + filename: "SourceSansPro-SemiboldItalic.ttf" + post_script_name: "SourceSansPro-SemiboldIt" + full_name: "Source Sans Pro Semibold Italic" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "normal" + weight: 700 + filename: "SourceSansPro-Bold.ttf" + post_script_name: "SourceSansPro-Bold" + full_name: "Source Sans Pro Bold" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "italic" + weight: 700 + filename: "SourceSansPro-BoldItalic.ttf" + post_script_name: "SourceSansPro-BoldIt" + full_name: "Source Sans Pro Bold Italic" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "normal" + weight: 900 + filename: "SourceSansPro-Black.ttf" + post_script_name: "SourceSansPro-Black" + full_name: "Source Sans Pro Black" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +fonts { + name: "Source Sans Pro" + style: "italic" + weight: 900 + filename: "SourceSansPro-BlackItalic.ttf" + post_script_name: "SourceSansPro-BlackIt" + full_name: "Source Sans Pro Black Italic" + copyright: "Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries." +} +subsets: "menu" +subsets: "latin" +subsets: "latin-ext" +subsets: "vietnamese" diff --git a/addons/project_scrum/static/src/fonts/sourcesanspro/SourceSansPro-Regular.ttf b/addons/project_scrum/static/src/fonts/sourcesanspro/SourceSansPro-Regular.ttf new file mode 100755 index 00000000..91e9ea57 Binary files /dev/null and b/addons/project_scrum/static/src/fonts/sourcesanspro/SourceSansPro-Regular.ttf differ diff --git a/addons/project_scrum/static/src/js/Chart.js b/addons/project_scrum/static/src/js/Chart.js new file mode 100755 index 00000000..35808da3 --- /dev/null +++ b/addons/project_scrum/static/src/js/Chart.js @@ -0,0 +1,3739 @@ +/*! + * Chart.js + * http://chartjs.org/ + * Version: 1.1.1 + * + * Copyright 2015 Nick Downie + * Released under the MIT license + * https://github.com/nnnick/Chart.js/blob/master/LICENSE.md + */ + + +(function(){ + + "use strict"; + + //Declare root variable - window in the browser, global on the server + var root = this, + previous = root.Chart; + + //Occupy the global variable of Chart, and create a simple base class + var Chart = function(context){ + var chart = this; + this.canvas = context.canvas; + + this.ctx = context; + + //Variables global to the chart + var computeDimension = function(element,dimension) + { + if (element['offset'+dimension]) + { + return element['offset'+dimension]; + } + else + { + return document.defaultView.getComputedStyle(element).getPropertyValue(dimension); + } + }; + + var width = this.width = computeDimension(context.canvas,'Width') || context.canvas.width; + var height = this.height = computeDimension(context.canvas,'Height') || context.canvas.height; + + this.aspectRatio = this.width / this.height; + //High pixel density displays - multiply the size of the canvas height/width by the device pixel ratio, then scale. + helpers.retinaScale(this); + + return this; + }; + //Globally expose the defaults to allow for user updating/changing + Chart.defaults = { + global: { + // Boolean - Whether to animate the chart + animation: true, + + // Number - Number of animation steps + animationSteps: 60, + + // String - Animation easing effect + animationEasing: "easeOutQuart", + + // Boolean - If we should show the scale at all + showScale: true, + + // Boolean - If we want to override with a hard coded scale + scaleOverride: false, + + // ** Required if scaleOverride is true ** + // Number - The number of steps in a hard coded scale + scaleSteps: null, + // Number - The value jump in the hard coded scale + scaleStepWidth: null, + // Number - The scale starting value + scaleStartValue: null, + + // String - Colour of the scale line + scaleLineColor: "rgba(0,0,0,.1)", + + // Number - Pixel width of the scale line + scaleLineWidth: 1, + + // Boolean - Whether to show labels on the scale + scaleShowLabels: true, + + // Interpolated JS string - can access value + scaleLabel: "<%=value%>", + + // Boolean - Whether the scale should stick to integers, and not show any floats even if drawing space is there + scaleIntegersOnly: true, + + // Boolean - Whether the scale should start at zero, or an order of magnitude down from the lowest value + scaleBeginAtZero: false, + + // String - Scale label font declaration for the scale label + scaleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", + + // Number - Scale label font size in pixels + scaleFontSize: 12, + + // String - Scale label font weight style + scaleFontStyle: "normal", + + // String - Scale label font colour + scaleFontColor: "#666", + + // Boolean - whether or not the chart should be responsive and resize when the browser does. + responsive: false, + + // Boolean - whether to maintain the starting aspect ratio or not when responsive, if set to false, will take up entire container + maintainAspectRatio: true, + + // Boolean - Determines whether to draw tooltips on the canvas or not - attaches events to touchmove & mousemove + showTooltips: true, + + // Boolean - Determines whether to draw built-in tooltip or call custom tooltip function + customTooltips: false, + + // Array - Array of string names to attach tooltip events + tooltipEvents: ["mousemove", "touchstart", "touchmove", "mouseout"], + + // String - Tooltip background colour + tooltipFillColor: "rgba(0,0,0,0.8)", + + // String - Tooltip label font declaration for the scale label + tooltipFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", + + // Number - Tooltip label font size in pixels + tooltipFontSize: 14, + + // String - Tooltip font weight style + tooltipFontStyle: "normal", + + // String - Tooltip label font colour + tooltipFontColor: "#fff", + + // String - Tooltip title font declaration for the scale label + tooltipTitleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", + + // Number - Tooltip title font size in pixels + tooltipTitleFontSize: 14, + + // String - Tooltip title font weight style + tooltipTitleFontStyle: "bold", + + // String - Tooltip title font colour + tooltipTitleFontColor: "#fff", + + // String - Tooltip title template + tooltipTitleTemplate: "<%= label%>", + + // Number - pixel width of padding around tooltip text + tooltipYPadding: 6, + + // Number - pixel width of padding around tooltip text + tooltipXPadding: 6, + + // Number - Size of the caret on the tooltip + tooltipCaretSize: 8, + + // Number - Pixel radius of the tooltip border + tooltipCornerRadius: 6, + + // Number - Pixel offset from point x to tooltip edge + tooltipXOffset: 10, + + // String - Template string for single tooltips + tooltipTemplate: "<%if (label){%><%=label%>: <%}%><%= value %>", + + // String - Template string for single tooltips + multiTooltipTemplate: "<%= datasetLabel %>: <%= value %>", + + // String - Colour behind the legend colour block + multiTooltipKeyBackground: '#fff', + + // Array - A list of colors to use as the defaults + segmentColorDefault: ["#A6CEE3", "#1F78B4", "#B2DF8A", "#33A02C", "#FB9A99", "#E31A1C", "#FDBF6F", "#FF7F00", "#CAB2D6", "#6A3D9A", "#B4B482", "#B15928" ], + + // Array - A list of highlight colors to use as the defaults + segmentHighlightColorDefaults: [ "#CEF6FF", "#47A0DC", "#DAFFB2", "#5BC854", "#FFC2C1", "#FF4244", "#FFE797", "#FFA728", "#F2DAFE", "#9265C2", "#DCDCAA", "#D98150" ], + + // Function - Will fire on animation progression. + onAnimationProgress: function(){}, + + // Function - Will fire on animation completion. + onAnimationComplete: function(){} + + } + }; + + //Create a dictionary of chart types, to allow for extension of existing types + Chart.types = {}; + + //Global Chart helpers object for utility methods and classes + var helpers = Chart.helpers = {}; + + //-- Basic js utility methods + var each = helpers.each = function(loopable,callback,self){ + var additionalArgs = Array.prototype.slice.call(arguments, 3); + // Check to see if null or undefined firstly. + if (loopable){ + if (loopable.length === +loopable.length){ + var i; + for (i=0; i= 0; i--) { + var currentItem = arrayToSearch[i]; + if (filterCallback(currentItem)){ + return currentItem; + } + } + }, + inherits = helpers.inherits = function(extensions){ + //Basic javascript inheritance based on the model created in Backbone.js + var parent = this; + var ChartElement = (extensions && extensions.hasOwnProperty("constructor")) ? extensions.constructor : function(){ return parent.apply(this, arguments); }; + + var Surrogate = function(){ this.constructor = ChartElement;}; + Surrogate.prototype = parent.prototype; + ChartElement.prototype = new Surrogate(); + + ChartElement.extend = inherits; + + if (extensions) extend(ChartElement.prototype, extensions); + + ChartElement.__super__ = parent.prototype; + + return ChartElement; + }, + noop = helpers.noop = function(){}, + uid = helpers.uid = (function(){ + var id=0; + return function(){ + return "chart-" + id++; + }; + })(), + warn = helpers.warn = function(str){ + //Method for warning of errors + if (window.console && typeof window.console.warn === "function") console.warn(str); + }, + amd = helpers.amd = (typeof define === 'function' && define.amd), + //-- Math methods + isNumber = helpers.isNumber = function(n){ + return !isNaN(parseFloat(n)) && isFinite(n); + }, + max = helpers.max = function(array){ + return Math.max.apply( Math, array ); + }, + min = helpers.min = function(array){ + return Math.min.apply( Math, array ); + }, + cap = helpers.cap = function(valueToCap,maxValue,minValue){ + if(isNumber(maxValue)) { + if( valueToCap > maxValue ) { + return maxValue; + } + } + else if(isNumber(minValue)){ + if ( valueToCap < minValue ){ + return minValue; + } + } + return valueToCap; + }, + getDecimalPlaces = helpers.getDecimalPlaces = function(num){ + if (num%1!==0 && isNumber(num)){ + var s = num.toString(); + if(s.indexOf("e-") < 0){ + // no exponent, e.g. 0.01 + return s.split(".")[1].length; + } + else if(s.indexOf(".") < 0) { + // no decimal point, e.g. 1e-9 + return parseInt(s.split("e-")[1]); + } + else { + // exponent and decimal point, e.g. 1.23e-9 + var parts = s.split(".")[1].split("e-"); + return parts[0].length + parseInt(parts[1]); + } + } + else { + return 0; + } + }, + toRadians = helpers.radians = function(degrees){ + return degrees * (Math.PI/180); + }, + // Gets the angle from vertical upright to the point about a centre. + getAngleFromPoint = helpers.getAngleFromPoint = function(centrePoint, anglePoint){ + var distanceFromXCenter = anglePoint.x - centrePoint.x, + distanceFromYCenter = anglePoint.y - centrePoint.y, + radialDistanceFromCenter = Math.sqrt( distanceFromXCenter * distanceFromXCenter + distanceFromYCenter * distanceFromYCenter); + + + var angle = Math.PI * 2 + Math.atan2(distanceFromYCenter, distanceFromXCenter); + + //If the segment is in the top left quadrant, we need to add another rotation to the angle + if (distanceFromXCenter < 0 && distanceFromYCenter < 0){ + angle += Math.PI*2; + } + + return { + angle: angle, + distance: radialDistanceFromCenter + }; + }, + aliasPixel = helpers.aliasPixel = function(pixelWidth){ + return (pixelWidth % 2 === 0) ? 0 : 0.5; + }, + splineCurve = helpers.splineCurve = function(FirstPoint,MiddlePoint,AfterPoint,t){ + //Props to Rob Spencer at scaled innovation for his post on splining between points + //http://scaledinnovation.com/analytics/splines/aboutSplines.html + var d01=Math.sqrt(Math.pow(MiddlePoint.x-FirstPoint.x,2)+Math.pow(MiddlePoint.y-FirstPoint.y,2)), + d12=Math.sqrt(Math.pow(AfterPoint.x-MiddlePoint.x,2)+Math.pow(AfterPoint.y-MiddlePoint.y,2)), + fa=t*d01/(d01+d12),// scaling factor for triangle Ta + fb=t*d12/(d01+d12); + return { + inner : { + x : MiddlePoint.x-fa*(AfterPoint.x-FirstPoint.x), + y : MiddlePoint.y-fa*(AfterPoint.y-FirstPoint.y) + }, + outer : { + x: MiddlePoint.x+fb*(AfterPoint.x-FirstPoint.x), + y : MiddlePoint.y+fb*(AfterPoint.y-FirstPoint.y) + } + }; + }, + calculateOrderOfMagnitude = helpers.calculateOrderOfMagnitude = function(val){ + return Math.floor(Math.log(val) / Math.LN10); + }, + calculateScaleRange = helpers.calculateScaleRange = function(valuesArray, drawingSize, textSize, startFromZero, integersOnly){ + + //Set a minimum step of two - a point at the top of the graph, and a point at the base + var minSteps = 2, + maxSteps = Math.floor(drawingSize/(textSize * 1.5)), + skipFitting = (minSteps >= maxSteps); + + // Filter out null values since these would min() to zero + var values = []; + each(valuesArray, function( v ){ + v == null || values.push( v ); + }); + var minValue = min(values), + maxValue = max(values); + + // We need some degree of separation here to calculate the scales if all the values are the same + // Adding/minusing 0.5 will give us a range of 1. + if (maxValue === minValue){ + maxValue += 0.5; + // So we don't end up with a graph with a negative start value if we've said always start from zero + if (minValue >= 0.5 && !startFromZero){ + minValue -= 0.5; + } + else{ + // Make up a whole number above the values + maxValue += 0.5; + } + } + + var valueRange = Math.abs(maxValue - minValue), + rangeOrderOfMagnitude = calculateOrderOfMagnitude(valueRange), + graphMax = Math.ceil(maxValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude), + graphMin = (startFromZero) ? 0 : Math.floor(minValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude), + graphRange = graphMax - graphMin, + stepValue = Math.pow(10, rangeOrderOfMagnitude), + numberOfSteps = Math.round(graphRange / stepValue); + + //If we have more space on the graph we'll use it to give more definition to the data + while((numberOfSteps > maxSteps || (numberOfSteps * 2) < maxSteps) && !skipFitting) { + if(numberOfSteps > maxSteps){ + stepValue *=2; + numberOfSteps = Math.round(graphRange/stepValue); + // Don't ever deal with a decimal number of steps - cancel fitting and just use the minimum number of steps. + if (numberOfSteps % 1 !== 0){ + skipFitting = true; + } + } + //We can fit in double the amount of scale points on the scale + else{ + //If user has declared ints only, and the step value isn't a decimal + if (integersOnly && rangeOrderOfMagnitude >= 0){ + //If the user has said integers only, we need to check that making the scale more granular wouldn't make it a float + if(stepValue/2 % 1 === 0){ + stepValue /=2; + numberOfSteps = Math.round(graphRange/stepValue); + } + //If it would make it a float break out of the loop + else{ + break; + } + } + //If the scale doesn't have to be an int, make the scale more granular anyway. + else{ + stepValue /=2; + numberOfSteps = Math.round(graphRange/stepValue); + } + + } + } + + if (skipFitting){ + numberOfSteps = minSteps; + stepValue = graphRange / numberOfSteps; + } + + return { + steps : numberOfSteps, + stepValue : stepValue, + min : graphMin, + max : graphMin + (numberOfSteps * stepValue) + }; + + }, + /* jshint ignore:start */ + // Blows up jshint errors based on the new Function constructor + //Templating methods + //Javascript micro templating by John Resig - source at http://ejohn.org/blog/javascript-micro-templating/ + template = helpers.template = function(templateString, valuesObject){ + + // If templateString is function rather than string-template - call the function for valuesObject + + if(templateString instanceof Function){ + return templateString(valuesObject); + } + + var cache = {}; + function tmpl(str, data){ + // Figure out if we're getting a template, or if we need to + // load the template - and be sure to cache the result. + var fn = !/\W/.test(str) ? + cache[str] = cache[str] : + + // Generate a reusable function that will serve as a template + // generator (and which will be cached). + new Function("obj", + "var p=[],print=function(){p.push.apply(p,arguments);};" + + + // Introduce the data as local variables using with(){} + "with(obj){p.push('" + + + // Convert the template into pure JavaScript + str + .replace(/[\r\t\n]/g, " ") + .split("<%").join("\t") + .replace(/((^|%>)[^\t]*)'/g, "$1\r") + .replace(/\t=(.*?)%>/g, "',$1,'") + .split("\t").join("');") + .split("%>").join("p.push('") + .split("\r").join("\\'") + + "');}return p.join('');" + ); + + // Provide some basic currying to the user + return data ? fn( data ) : fn; + } + return tmpl(templateString,valuesObject); + }, + /* jshint ignore:end */ + generateLabels = helpers.generateLabels = function(templateString,numberOfSteps,graphMin,stepValue){ + var labelsArray = new Array(numberOfSteps); + if (templateString){ + each(labelsArray,function(val,index){ + labelsArray[index] = template(templateString,{value: (graphMin + (stepValue*(index+1)))}); + }); + } + return labelsArray; + }, + //--Animation methods + //Easing functions adapted from Robert Penner's easing equations + //http://www.robertpenner.com/easing/ + easingEffects = helpers.easingEffects = { + linear: function (t) { + return t; + }, + easeInQuad: function (t) { + return t * t; + }, + easeOutQuad: function (t) { + return -1 * t * (t - 2); + }, + easeInOutQuad: function (t) { + if ((t /= 1 / 2) < 1){ + return 1 / 2 * t * t; + } + return -1 / 2 * ((--t) * (t - 2) - 1); + }, + easeInCubic: function (t) { + return t * t * t; + }, + easeOutCubic: function (t) { + return 1 * ((t = t / 1 - 1) * t * t + 1); + }, + easeInOutCubic: function (t) { + if ((t /= 1 / 2) < 1){ + return 1 / 2 * t * t * t; + } + return 1 / 2 * ((t -= 2) * t * t + 2); + }, + easeInQuart: function (t) { + return t * t * t * t; + }, + easeOutQuart: function (t) { + return -1 * ((t = t / 1 - 1) * t * t * t - 1); + }, + easeInOutQuart: function (t) { + if ((t /= 1 / 2) < 1){ + return 1 / 2 * t * t * t * t; + } + return -1 / 2 * ((t -= 2) * t * t * t - 2); + }, + easeInQuint: function (t) { + return 1 * (t /= 1) * t * t * t * t; + }, + easeOutQuint: function (t) { + return 1 * ((t = t / 1 - 1) * t * t * t * t + 1); + }, + easeInOutQuint: function (t) { + if ((t /= 1 / 2) < 1){ + return 1 / 2 * t * t * t * t * t; + } + return 1 / 2 * ((t -= 2) * t * t * t * t + 2); + }, + easeInSine: function (t) { + return -1 * Math.cos(t / 1 * (Math.PI / 2)) + 1; + }, + easeOutSine: function (t) { + return 1 * Math.sin(t / 1 * (Math.PI / 2)); + }, + easeInOutSine: function (t) { + return -1 / 2 * (Math.cos(Math.PI * t / 1) - 1); + }, + easeInExpo: function (t) { + return (t === 0) ? 1 : 1 * Math.pow(2, 10 * (t / 1 - 1)); + }, + easeOutExpo: function (t) { + return (t === 1) ? 1 : 1 * (-Math.pow(2, -10 * t / 1) + 1); + }, + easeInOutExpo: function (t) { + if (t === 0){ + return 0; + } + if (t === 1){ + return 1; + } + if ((t /= 1 / 2) < 1){ + return 1 / 2 * Math.pow(2, 10 * (t - 1)); + } + return 1 / 2 * (-Math.pow(2, -10 * --t) + 2); + }, + easeInCirc: function (t) { + if (t >= 1){ + return t; + } + return -1 * (Math.sqrt(1 - (t /= 1) * t) - 1); + }, + easeOutCirc: function (t) { + return 1 * Math.sqrt(1 - (t = t / 1 - 1) * t); + }, + easeInOutCirc: function (t) { + if ((t /= 1 / 2) < 1){ + return -1 / 2 * (Math.sqrt(1 - t * t) - 1); + } + return 1 / 2 * (Math.sqrt(1 - (t -= 2) * t) + 1); + }, + easeInElastic: function (t) { + var s = 1.70158; + var p = 0; + var a = 1; + if (t === 0){ + return 0; + } + if ((t /= 1) == 1){ + return 1; + } + if (!p){ + p = 1 * 0.3; + } + if (a < Math.abs(1)) { + a = 1; + s = p / 4; + } else{ + s = p / (2 * Math.PI) * Math.asin(1 / a); + } + return -(a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p)); + }, + easeOutElastic: function (t) { + var s = 1.70158; + var p = 0; + var a = 1; + if (t === 0){ + return 0; + } + if ((t /= 1) == 1){ + return 1; + } + if (!p){ + p = 1 * 0.3; + } + if (a < Math.abs(1)) { + a = 1; + s = p / 4; + } else{ + s = p / (2 * Math.PI) * Math.asin(1 / a); + } + return a * Math.pow(2, -10 * t) * Math.sin((t * 1 - s) * (2 * Math.PI) / p) + 1; + }, + easeInOutElastic: function (t) { + var s = 1.70158; + var p = 0; + var a = 1; + if (t === 0){ + return 0; + } + if ((t /= 1 / 2) == 2){ + return 1; + } + if (!p){ + p = 1 * (0.3 * 1.5); + } + if (a < Math.abs(1)) { + a = 1; + s = p / 4; + } else { + s = p / (2 * Math.PI) * Math.asin(1 / a); + } + if (t < 1){ + return -0.5 * (a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p));} + return a * Math.pow(2, -10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p) * 0.5 + 1; + }, + easeInBack: function (t) { + var s = 1.70158; + return 1 * (t /= 1) * t * ((s + 1) * t - s); + }, + easeOutBack: function (t) { + var s = 1.70158; + return 1 * ((t = t / 1 - 1) * t * ((s + 1) * t + s) + 1); + }, + easeInOutBack: function (t) { + var s = 1.70158; + if ((t /= 1 / 2) < 1){ + return 1 / 2 * (t * t * (((s *= (1.525)) + 1) * t - s)); + } + return 1 / 2 * ((t -= 2) * t * (((s *= (1.525)) + 1) * t + s) + 2); + }, + easeInBounce: function (t) { + return 1 - easingEffects.easeOutBounce(1 - t); + }, + easeOutBounce: function (t) { + if ((t /= 1) < (1 / 2.75)) { + return 1 * (7.5625 * t * t); + } else if (t < (2 / 2.75)) { + return 1 * (7.5625 * (t -= (1.5 / 2.75)) * t + 0.75); + } else if (t < (2.5 / 2.75)) { + return 1 * (7.5625 * (t -= (2.25 / 2.75)) * t + 0.9375); + } else { + return 1 * (7.5625 * (t -= (2.625 / 2.75)) * t + 0.984375); + } + }, + easeInOutBounce: function (t) { + if (t < 1 / 2){ + return easingEffects.easeInBounce(t * 2) * 0.5; + } + return easingEffects.easeOutBounce(t * 2 - 1) * 0.5 + 1 * 0.5; + } + }, + //Request animation polyfill - http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/ + requestAnimFrame = helpers.requestAnimFrame = (function(){ + return window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function(callback) { + return window.setTimeout(callback, 1000 / 60); + }; + })(), + cancelAnimFrame = helpers.cancelAnimFrame = (function(){ + return window.cancelAnimationFrame || + window.webkitCancelAnimationFrame || + window.mozCancelAnimationFrame || + window.oCancelAnimationFrame || + window.msCancelAnimationFrame || + function(callback) { + return window.clearTimeout(callback, 1000 / 60); + }; + })(), + animationLoop = helpers.animationLoop = function(callback,totalSteps,easingString,onProgress,onComplete,chartInstance){ + + var currentStep = 0, + easingFunction = easingEffects[easingString] || easingEffects.linear; + + var animationFrame = function(){ + currentStep++; + var stepDecimal = currentStep/totalSteps; + var easeDecimal = easingFunction(stepDecimal); + + callback.call(chartInstance,easeDecimal,stepDecimal, currentStep); + onProgress.call(chartInstance,easeDecimal,stepDecimal); + if (currentStep < totalSteps){ + chartInstance.animationFrame = requestAnimFrame(animationFrame); + } else{ + onComplete.apply(chartInstance); + } + }; + requestAnimFrame(animationFrame); + }, + //-- DOM methods + getRelativePosition = helpers.getRelativePosition = function(evt){ + var mouseX, mouseY; + var e = evt.originalEvent || evt, + canvas = evt.currentTarget || evt.srcElement, + boundingRect = canvas.getBoundingClientRect(); + + if (e.touches){ + mouseX = e.touches[0].clientX - boundingRect.left; + mouseY = e.touches[0].clientY - boundingRect.top; + + } + else{ + mouseX = e.clientX - boundingRect.left; + mouseY = e.clientY - boundingRect.top; + } + + return { + x : mouseX, + y : mouseY + }; + + }, + addEvent = helpers.addEvent = function(node,eventType,method){ + if (node.addEventListener){ + node.addEventListener(eventType,method); + } else if (node.attachEvent){ + node.attachEvent("on"+eventType, method); + } else { + node["on"+eventType] = method; + } + }, + removeEvent = helpers.removeEvent = function(node, eventType, handler){ + if (node.removeEventListener){ + node.removeEventListener(eventType, handler, false); + } else if (node.detachEvent){ + node.detachEvent("on"+eventType,handler); + } else{ + node["on" + eventType] = noop; + } + }, + bindEvents = helpers.bindEvents = function(chartInstance, arrayOfEvents, handler){ + // Create the events object if it's not already present + if (!chartInstance.events) chartInstance.events = {}; + + each(arrayOfEvents,function(eventName){ + chartInstance.events[eventName] = function(){ + handler.apply(chartInstance, arguments); + }; + addEvent(chartInstance.chart.canvas,eventName,chartInstance.events[eventName]); + }); + }, + unbindEvents = helpers.unbindEvents = function (chartInstance, arrayOfEvents) { + each(arrayOfEvents, function(handler,eventName){ + removeEvent(chartInstance.chart.canvas, eventName, handler); + }); + }, + getMaximumWidth = helpers.getMaximumWidth = function(domNode){ + var container = domNode.parentNode, + padding = parseInt(getStyle(container, 'padding-left')) + parseInt(getStyle(container, 'padding-right')); + // TODO = check cross browser stuff with this. + return container ? container.clientWidth - padding : 0; + }, + getMaximumHeight = helpers.getMaximumHeight = function(domNode){ + var container = domNode.parentNode, + padding = parseInt(getStyle(container, 'padding-bottom')) + parseInt(getStyle(container, 'padding-top')); + // TODO = check cross browser stuff with this. + return container ? container.clientHeight - padding : 0; + }, + getStyle = helpers.getStyle = function (el, property) { + return el.currentStyle ? + el.currentStyle[property] : + document.defaultView.getComputedStyle(el, null).getPropertyValue(property); + }, + getMaximumSize = helpers.getMaximumSize = helpers.getMaximumWidth, // legacy support + retinaScale = helpers.retinaScale = function(chart){ + var ctx = chart.ctx, + width = chart.canvas.width, + height = chart.canvas.height; + + if (window.devicePixelRatio) { + ctx.canvas.style.width = width + "px"; + ctx.canvas.style.height = height + "px"; + ctx.canvas.height = height * window.devicePixelRatio; + ctx.canvas.width = width * window.devicePixelRatio; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + } + }, + //-- Canvas methods + clear = helpers.clear = function(chart){ + chart.ctx.clearRect(0,0,chart.width,chart.height); + }, + fontString = helpers.fontString = function(pixelSize,fontStyle,fontFamily){ + return fontStyle + " " + pixelSize+"px " + fontFamily; + }, + longestText = helpers.longestText = function(ctx,font,arrayOfStrings){ + ctx.font = font; + var longest = 0; + each(arrayOfStrings,function(string){ + var textWidth = ctx.measureText(string).width; + longest = (textWidth > longest) ? textWidth : longest; + }); + return longest; + }, + drawRoundedRectangle = helpers.drawRoundedRectangle = function(ctx,x,y,width,height,radius){ + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + }; + + + //Store a reference to each instance - allowing us to globally resize chart instances on window resize. + //Destroy method on the chart will remove the instance of the chart from this reference. + Chart.instances = {}; + + Chart.Type = function(data,options,chart){ + this.options = options; + this.chart = chart; + this.id = uid(); + //Add the chart instance to the global namespace + Chart.instances[this.id] = this; + + // Initialize is always called when a chart type is created + // By default it is a no op, but it should be extended + if (options.responsive){ + this.resize(); + } + this.initialize.call(this,data); + }; + + //Core methods that'll be a part of every chart type + extend(Chart.Type.prototype,{ + initialize : function(){return this;}, + clear : function(){ + clear(this.chart); + return this; + }, + stop : function(){ + // Stops any current animation loop occuring + Chart.animationService.cancelAnimation(this); + return this; + }, + resize : function(callback){ + this.stop(); + var canvas = this.chart.canvas, + newWidth = getMaximumWidth(this.chart.canvas), + newHeight = this.options.maintainAspectRatio ? newWidth / this.chart.aspectRatio : getMaximumHeight(this.chart.canvas); + + canvas.width = this.chart.width = newWidth; + canvas.height = this.chart.height = newHeight; + + retinaScale(this.chart); + + if (typeof callback === "function"){ + callback.apply(this, Array.prototype.slice.call(arguments, 1)); + } + return this; + }, + reflow : noop, + render : function(reflow){ + if (reflow){ + this.reflow(); + } + + if (this.options.animation && !reflow){ + var animation = new Chart.Animation(); + animation.numSteps = this.options.animationSteps; + animation.easing = this.options.animationEasing; + + // render function + animation.render = function(chartInstance, animationObject) { + var easingFunction = helpers.easingEffects[animationObject.easing]; + var stepDecimal = animationObject.currentStep / animationObject.numSteps; + var easeDecimal = easingFunction(stepDecimal); + + chartInstance.draw(easeDecimal, stepDecimal, animationObject.currentStep); + }; + + // user events + animation.onAnimationProgress = this.options.onAnimationProgress; + animation.onAnimationComplete = this.options.onAnimationComplete; + + Chart.animationService.addAnimation(this, animation); + } + else{ + this.draw(); + this.options.onAnimationComplete.call(this); + } + return this; + }, + generateLegend : function(){ + return helpers.template(this.options.legendTemplate, this); + }, + destroy : function(){ + this.stop(); + this.clear(); + unbindEvents(this, this.events); + var canvas = this.chart.canvas; + + // Reset canvas height/width attributes starts a fresh with the canvas context + canvas.width = this.chart.width; + canvas.height = this.chart.height; + + // < IE9 doesn't support removeProperty + if (canvas.style.removeProperty) { + canvas.style.removeProperty('width'); + canvas.style.removeProperty('height'); + } else { + canvas.style.removeAttribute('width'); + canvas.style.removeAttribute('height'); + } + + delete Chart.instances[this.id]; + }, + showTooltip : function(ChartElements, forceRedraw){ + // Only redraw the chart if we've actually changed what we're hovering on. + if (typeof this.activeElements === 'undefined') this.activeElements = []; + + var isChanged = (function(Elements){ + var changed = false; + + if (Elements.length !== this.activeElements.length){ + changed = true; + return changed; + } + + each(Elements, function(element, index){ + if (element !== this.activeElements[index]){ + changed = true; + } + }, this); + return changed; + }).call(this, ChartElements); + + if (!isChanged && !forceRedraw){ + return; + } + else{ + this.activeElements = ChartElements; + } + this.draw(); + if(this.options.customTooltips){ + this.options.customTooltips(false); + } + if (ChartElements.length > 0){ + // If we have multiple datasets, show a MultiTooltip for all of the data points at that index + if (this.datasets && this.datasets.length > 1) { + var dataArray, + dataIndex; + + for (var i = this.datasets.length - 1; i >= 0; i--) { + dataArray = this.datasets[i].points || this.datasets[i].bars || this.datasets[i].segments; + dataIndex = indexOf(dataArray, ChartElements[0]); + if (dataIndex !== -1){ + break; + } + } + var tooltipLabels = [], + tooltipColors = [], + medianPosition = (function(index) { + + // Get all the points at that particular index + var Elements = [], + dataCollection, + xPositions = [], + yPositions = [], + xMax, + yMax, + xMin, + yMin; + helpers.each(this.datasets, function(dataset){ + dataCollection = dataset.points || dataset.bars || dataset.segments; + if (dataCollection[dataIndex] && dataCollection[dataIndex].hasValue()){ + Elements.push(dataCollection[dataIndex]); + } + }); + + helpers.each(Elements, function(element) { + xPositions.push(element.x); + yPositions.push(element.y); + + + //Include any colour information about the element + tooltipLabels.push(helpers.template(this.options.multiTooltipTemplate, element)); + tooltipColors.push({ + fill: element._saved.fillColor || element.fillColor, + stroke: element._saved.strokeColor || element.strokeColor + }); + + }, this); + + yMin = min(yPositions); + yMax = max(yPositions); + + xMin = min(xPositions); + xMax = max(xPositions); + + return { + x: (xMin > this.chart.width/2) ? xMin : xMax, + y: (yMin + yMax)/2 + }; + }).call(this, dataIndex); + + new Chart.MultiTooltip({ + x: medianPosition.x, + y: medianPosition.y, + xPadding: this.options.tooltipXPadding, + yPadding: this.options.tooltipYPadding, + xOffset: this.options.tooltipXOffset, + fillColor: this.options.tooltipFillColor, + textColor: this.options.tooltipFontColor, + fontFamily: this.options.tooltipFontFamily, + fontStyle: this.options.tooltipFontStyle, + fontSize: this.options.tooltipFontSize, + titleTextColor: this.options.tooltipTitleFontColor, + titleFontFamily: this.options.tooltipTitleFontFamily, + titleFontStyle: this.options.tooltipTitleFontStyle, + titleFontSize: this.options.tooltipTitleFontSize, + cornerRadius: this.options.tooltipCornerRadius, + labels: tooltipLabels, + legendColors: tooltipColors, + legendColorBackground : this.options.multiTooltipKeyBackground, + title: template(this.options.tooltipTitleTemplate,ChartElements[0]), + chart: this.chart, + ctx: this.chart.ctx, + custom: this.options.customTooltips + }).draw(); + + } else { + each(ChartElements, function(Element) { + var tooltipPosition = Element.tooltipPosition(); + new Chart.Tooltip({ + x: Math.round(tooltipPosition.x), + y: Math.round(tooltipPosition.y), + xPadding: this.options.tooltipXPadding, + yPadding: this.options.tooltipYPadding, + fillColor: this.options.tooltipFillColor, + textColor: this.options.tooltipFontColor, + fontFamily: this.options.tooltipFontFamily, + fontStyle: this.options.tooltipFontStyle, + fontSize: this.options.tooltipFontSize, + caretHeight: this.options.tooltipCaretSize, + cornerRadius: this.options.tooltipCornerRadius, + text: template(this.options.tooltipTemplate, Element), + chart: this.chart, + custom: this.options.customTooltips + }).draw(); + }, this); + } + } + return this; + }, + toBase64Image : function(){ + return this.chart.canvas.toDataURL.apply(this.chart.canvas, arguments); + } + }); + + Chart.Type.extend = function(extensions){ + + var parent = this; + + var ChartType = function(){ + return parent.apply(this,arguments); + }; + + //Copy the prototype object of the this class + ChartType.prototype = clone(parent.prototype); + //Now overwrite some of the properties in the base class with the new extensions + extend(ChartType.prototype, extensions); + + ChartType.extend = Chart.Type.extend; + + if (extensions.name || parent.prototype.name){ + + var chartName = extensions.name || parent.prototype.name; + //Assign any potential default values of the new chart type + + //If none are defined, we'll use a clone of the chart type this is being extended from. + //I.e. if we extend a line chart, we'll use the defaults from the line chart if our new chart + //doesn't define some defaults of their own. + + var baseDefaults = (Chart.defaults[parent.prototype.name]) ? clone(Chart.defaults[parent.prototype.name]) : {}; + + Chart.defaults[chartName] = extend(baseDefaults,extensions.defaults); + + Chart.types[chartName] = ChartType; + + //Register this new chart type in the Chart prototype + Chart.prototype[chartName] = function(data,options){ + var config = merge(Chart.defaults.global, Chart.defaults[chartName], options || {}); + return new ChartType(data,config,this); + }; + } else{ + warn("Name not provided for this chart, so it hasn't been registered"); + } + return parent; + }; + + Chart.Element = function(configuration){ + extend(this,configuration); + this.initialize.apply(this,arguments); + this.save(); + }; + extend(Chart.Element.prototype,{ + initialize : function(){}, + restore : function(props){ + if (!props){ + extend(this,this._saved); + } else { + each(props,function(key){ + this[key] = this._saved[key]; + },this); + } + return this; + }, + save : function(){ + this._saved = clone(this); + delete this._saved._saved; + return this; + }, + update : function(newProps){ + each(newProps,function(value,key){ + this._saved[key] = this[key]; + this[key] = value; + },this); + return this; + }, + transition : function(props,ease){ + each(props,function(value,key){ + this[key] = ((value - this._saved[key]) * ease) + this._saved[key]; + },this); + return this; + }, + tooltipPosition : function(){ + return { + x : this.x, + y : this.y + }; + }, + hasValue: function(){ + return isNumber(this.value); + } + }); + + Chart.Element.extend = inherits; + + + Chart.Point = Chart.Element.extend({ + display: true, + inRange: function(chartX,chartY){ + var hitDetectionRange = this.hitDetectionRadius + this.radius; + return ((Math.pow(chartX-this.x, 2)+Math.pow(chartY-this.y, 2)) < Math.pow(hitDetectionRange,2)); + }, + draw : function(){ + if (this.display){ + var ctx = this.ctx; + ctx.beginPath(); + + ctx.arc(this.x, this.y, this.radius, 0, Math.PI*2); + ctx.closePath(); + + ctx.strokeStyle = this.strokeColor; + ctx.lineWidth = this.strokeWidth; + + ctx.fillStyle = this.fillColor; + + ctx.fill(); + ctx.stroke(); + } + + + //Quick debug for bezier curve splining + //Highlights control points and the line between them. + //Handy for dev - stripped in the min version. + + // ctx.save(); + // ctx.fillStyle = "black"; + // ctx.strokeStyle = "black" + // ctx.beginPath(); + // ctx.arc(this.controlPoints.inner.x,this.controlPoints.inner.y, 2, 0, Math.PI*2); + // ctx.fill(); + + // ctx.beginPath(); + // ctx.arc(this.controlPoints.outer.x,this.controlPoints.outer.y, 2, 0, Math.PI*2); + // ctx.fill(); + + // ctx.moveTo(this.controlPoints.inner.x,this.controlPoints.inner.y); + // ctx.lineTo(this.x, this.y); + // ctx.lineTo(this.controlPoints.outer.x,this.controlPoints.outer.y); + // ctx.stroke(); + + // ctx.restore(); + + + + } + }); + + Chart.Arc = Chart.Element.extend({ + inRange : function(chartX,chartY){ + + var pointRelativePosition = helpers.getAngleFromPoint(this, { + x: chartX, + y: chartY + }); + + // Normalize all angles to 0 - 2*PI (0 - 360°) + var pointRelativeAngle = pointRelativePosition.angle % (Math.PI * 2), + startAngle = (Math.PI * 2 + this.startAngle) % (Math.PI * 2), + endAngle = (Math.PI * 2 + this.endAngle) % (Math.PI * 2) || 360; + + // Calculate wether the pointRelativeAngle is between the start and the end angle + var betweenAngles = (endAngle < startAngle) ? + pointRelativeAngle <= endAngle || pointRelativeAngle >= startAngle: + pointRelativeAngle >= startAngle && pointRelativeAngle <= endAngle; + + //Check if within the range of the open/close angle + var withinRadius = (pointRelativePosition.distance >= this.innerRadius && pointRelativePosition.distance <= this.outerRadius); + + return (betweenAngles && withinRadius); + //Ensure within the outside of the arc centre, but inside arc outer + }, + tooltipPosition : function(){ + var centreAngle = this.startAngle + ((this.endAngle - this.startAngle) / 2), + rangeFromCentre = (this.outerRadius - this.innerRadius) / 2 + this.innerRadius; + return { + x : this.x + (Math.cos(centreAngle) * rangeFromCentre), + y : this.y + (Math.sin(centreAngle) * rangeFromCentre) + }; + }, + draw : function(animationPercent){ + + var easingDecimal = animationPercent || 1; + + var ctx = this.ctx; + + ctx.beginPath(); + + ctx.arc(this.x, this.y, this.outerRadius < 0 ? 0 : this.outerRadius, this.startAngle, this.endAngle); + + ctx.arc(this.x, this.y, this.innerRadius < 0 ? 0 : this.innerRadius, this.endAngle, this.startAngle, true); + + ctx.closePath(); + ctx.strokeStyle = this.strokeColor; + ctx.lineWidth = this.strokeWidth; + + ctx.fillStyle = this.fillColor; + + ctx.fill(); + ctx.lineJoin = 'bevel'; + + if (this.showStroke){ + ctx.stroke(); + } + } + }); + + Chart.Rectangle = Chart.Element.extend({ + draw : function(){ + var ctx = this.ctx, + halfWidth = this.width/2, + leftX = this.x - halfWidth, + rightX = this.x + halfWidth, + top = this.base - (this.base - this.y), + halfStroke = this.strokeWidth / 2; + + // Canvas doesn't allow us to stroke inside the width so we can + // adjust the sizes to fit if we're setting a stroke on the line + if (this.showStroke){ + leftX += halfStroke; + rightX -= halfStroke; + top += halfStroke; + } + + ctx.beginPath(); + + ctx.fillStyle = this.fillColor; + ctx.strokeStyle = this.strokeColor; + ctx.lineWidth = this.strokeWidth; + + // It'd be nice to keep this class totally generic to any rectangle + // and simply specify which border to miss out. + ctx.moveTo(leftX, this.base); + ctx.lineTo(leftX, top); + ctx.lineTo(rightX, top); + ctx.lineTo(rightX, this.base); + ctx.fill(); + if (this.showStroke){ + ctx.stroke(); + } + }, + height : function(){ + return this.base - this.y; + }, + inRange : function(chartX,chartY){ + return (chartX >= this.x - this.width/2 && chartX <= this.x + this.width/2) && (chartY >= this.y && chartY <= this.base); + } + }); + + Chart.Animation = Chart.Element.extend({ + currentStep: null, // the current animation step + numSteps: 60, // default number of steps + easing: "", // the easing to use for this animation + render: null, // render function used by the animation service + + onAnimationProgress: null, // user specified callback to fire on each step of the animation + onAnimationComplete: null, // user specified callback to fire when the animation finishes + }); + + Chart.Tooltip = Chart.Element.extend({ + draw : function(){ + + var ctx = this.chart.ctx; + + ctx.font = fontString(this.fontSize,this.fontStyle,this.fontFamily); + + this.xAlign = "center"; + this.yAlign = "above"; + + //Distance between the actual element.y position and the start of the tooltip caret + var caretPadding = this.caretPadding = 2; + + var tooltipWidth = ctx.measureText(this.text).width + 2*this.xPadding, + tooltipRectHeight = this.fontSize + 2*this.yPadding, + tooltipHeight = tooltipRectHeight + this.caretHeight + caretPadding; + + if (this.x + tooltipWidth/2 >this.chart.width){ + this.xAlign = "left"; + } else if (this.x - tooltipWidth/2 < 0){ + this.xAlign = "right"; + } + + if (this.y - tooltipHeight < 0){ + this.yAlign = "below"; + } + + + var tooltipX = this.x - tooltipWidth/2, + tooltipY = this.y - tooltipHeight; + + ctx.fillStyle = this.fillColor; + + // Custom Tooltips + if(this.custom){ + this.custom(this); + } + else{ + switch(this.yAlign) + { + case "above": + //Draw a caret above the x/y + ctx.beginPath(); + ctx.moveTo(this.x,this.y - caretPadding); + ctx.lineTo(this.x + this.caretHeight, this.y - (caretPadding + this.caretHeight)); + ctx.lineTo(this.x - this.caretHeight, this.y - (caretPadding + this.caretHeight)); + ctx.closePath(); + ctx.fill(); + break; + case "below": + tooltipY = this.y + caretPadding + this.caretHeight; + //Draw a caret below the x/y + ctx.beginPath(); + ctx.moveTo(this.x, this.y + caretPadding); + ctx.lineTo(this.x + this.caretHeight, this.y + caretPadding + this.caretHeight); + ctx.lineTo(this.x - this.caretHeight, this.y + caretPadding + this.caretHeight); + ctx.closePath(); + ctx.fill(); + break; + } + + switch(this.xAlign) + { + case "left": + tooltipX = this.x - tooltipWidth + (this.cornerRadius + this.caretHeight); + break; + case "right": + tooltipX = this.x - (this.cornerRadius + this.caretHeight); + break; + } + + drawRoundedRectangle(ctx,tooltipX,tooltipY,tooltipWidth,tooltipRectHeight,this.cornerRadius); + + ctx.fill(); + + ctx.fillStyle = this.textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(this.text, tooltipX + tooltipWidth/2, tooltipY + tooltipRectHeight/2); + } + } + }); + + Chart.MultiTooltip = Chart.Element.extend({ + initialize : function(){ + this.font = fontString(this.fontSize,this.fontStyle,this.fontFamily); + + this.titleFont = fontString(this.titleFontSize,this.titleFontStyle,this.titleFontFamily); + + this.titleHeight = this.title ? this.titleFontSize * 1.5 : 0; + this.height = (this.labels.length * this.fontSize) + ((this.labels.length-1) * (this.fontSize/2)) + (this.yPadding*2) + this.titleHeight; + + this.ctx.font = this.titleFont; + + var titleWidth = this.ctx.measureText(this.title).width, + //Label has a legend square as well so account for this. + labelWidth = longestText(this.ctx,this.font,this.labels) + this.fontSize + 3, + longestTextWidth = max([labelWidth,titleWidth]); + + this.width = longestTextWidth + (this.xPadding*2); + + + var halfHeight = this.height/2; + + //Check to ensure the height will fit on the canvas + if (this.y - halfHeight < 0 ){ + this.y = halfHeight; + } else if (this.y + halfHeight > this.chart.height){ + this.y = this.chart.height - halfHeight; + } + + //Decide whether to align left or right based on position on canvas + if (this.x > this.chart.width/2){ + this.x -= this.xOffset + this.width; + } else { + this.x += this.xOffset; + } + + + }, + getLineHeight : function(index){ + var baseLineHeight = this.y - (this.height/2) + this.yPadding, + afterTitleIndex = index-1; + + //If the index is zero, we're getting the title + if (index === 0){ + return baseLineHeight + this.titleHeight / 3; + } else{ + return baseLineHeight + ((this.fontSize * 1.5 * afterTitleIndex) + this.fontSize / 2) + this.titleHeight; + } + + }, + draw : function(){ + // Custom Tooltips + if(this.custom){ + this.custom(this); + } + else{ + drawRoundedRectangle(this.ctx,this.x,this.y - this.height/2,this.width,this.height,this.cornerRadius); + var ctx = this.ctx; + ctx.fillStyle = this.fillColor; + ctx.fill(); + ctx.closePath(); + + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillStyle = this.titleTextColor; + ctx.font = this.titleFont; + + ctx.fillText(this.title,this.x + this.xPadding, this.getLineHeight(0)); + + ctx.font = this.font; + helpers.each(this.labels,function(label,index){ + ctx.fillStyle = this.textColor; + ctx.fillText(label,this.x + this.xPadding + this.fontSize + 3, this.getLineHeight(index + 1)); + + //A bit gnarly, but clearing this rectangle breaks when using explorercanvas (clears whole canvas) + //ctx.clearRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize); + //Instead we'll make a white filled block to put the legendColour palette over. + + ctx.fillStyle = this.legendColorBackground; + ctx.fillRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize); + + ctx.fillStyle = this.legendColors[index].fill; + ctx.fillRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize); + + + },this); + } + } + }); + + Chart.Scale = Chart.Element.extend({ + initialize : function(){ + this.fit(); + }, + buildYLabels : function(){ + this.yLabels = []; + + var stepDecimalPlaces = getDecimalPlaces(this.stepValue); + + for (var i=0; i<=this.steps; i++){ + this.yLabels.push(template(this.templateString,{value:(this.min + (i * this.stepValue)).toFixed(stepDecimalPlaces)})); + } + this.yLabelWidth = (this.display && this.showLabels) ? longestText(this.ctx,this.font,this.yLabels) + 10 : 0; + }, + addXLabel : function(label){ + this.xLabels.push(label); + this.valuesCount++; + this.fit(); + }, + removeXLabel : function(){ + this.xLabels.shift(); + this.valuesCount--; + this.fit(); + }, + // Fitting loop to rotate x Labels and figure out what fits there, and also calculate how many Y steps to use + fit: function(){ + // First we need the width of the yLabels, assuming the xLabels aren't rotated + + // To do that we need the base line at the top and base of the chart, assuming there is no x label rotation + this.startPoint = (this.display) ? this.fontSize : 0; + this.endPoint = (this.display) ? this.height - (this.fontSize * 1.5) - 5 : this.height; // -5 to pad labels + + // Apply padding settings to the start and end point. + this.startPoint += this.padding; + this.endPoint -= this.padding; + + // Cache the starting endpoint, excluding the space for x labels + var cachedEndPoint = this.endPoint; + + // Cache the starting height, so can determine if we need to recalculate the scale yAxis + var cachedHeight = this.endPoint - this.startPoint, + cachedYLabelWidth; + + // Build the current yLabels so we have an idea of what size they'll be to start + /* + * This sets what is returned from calculateScaleRange as static properties of this class: + * + this.steps; + this.stepValue; + this.min; + this.max; + * + */ + this.calculateYRange(cachedHeight); + + // With these properties set we can now build the array of yLabels + // and also the width of the largest yLabel + this.buildYLabels(); + + this.calculateXLabelRotation(); + + while((cachedHeight > this.endPoint - this.startPoint)){ + cachedHeight = this.endPoint - this.startPoint; + cachedYLabelWidth = this.yLabelWidth; + + this.calculateYRange(cachedHeight); + this.buildYLabels(); + + // Only go through the xLabel loop again if the yLabel width has changed + if (cachedYLabelWidth < this.yLabelWidth){ + this.endPoint = cachedEndPoint; + this.calculateXLabelRotation(); + } + } + + }, + calculateXLabelRotation : function(){ + //Get the width of each grid by calculating the difference + //between x offsets between 0 and 1. + + this.ctx.font = this.font; + + var firstWidth = this.ctx.measureText(this.xLabels[0]).width, + lastWidth = this.ctx.measureText(this.xLabels[this.xLabels.length - 1]).width, + firstRotated, + lastRotated; + + + this.xScalePaddingRight = lastWidth/2 + 3; + this.xScalePaddingLeft = (firstWidth/2 > this.yLabelWidth) ? firstWidth/2 : this.yLabelWidth; + + this.xLabelRotation = 0; + if (this.display){ + var originalLabelWidth = longestText(this.ctx,this.font,this.xLabels), + cosRotation, + firstRotatedWidth; + this.xLabelWidth = originalLabelWidth; + //Allow 3 pixels x2 padding either side for label readability + var xGridWidth = Math.floor(this.calculateX(1) - this.calculateX(0)) - 6; + + //Max label rotate should be 90 - also act as a loop counter + while ((this.xLabelWidth > xGridWidth && this.xLabelRotation === 0) || (this.xLabelWidth > xGridWidth && this.xLabelRotation <= 90 && this.xLabelRotation > 0)){ + cosRotation = Math.cos(toRadians(this.xLabelRotation)); + + firstRotated = cosRotation * firstWidth; + lastRotated = cosRotation * lastWidth; + + // We're right aligning the text now. + if (firstRotated + this.fontSize / 2 > this.yLabelWidth){ + this.xScalePaddingLeft = firstRotated + this.fontSize / 2; + } + this.xScalePaddingRight = this.fontSize/2; + + + this.xLabelRotation++; + this.xLabelWidth = cosRotation * originalLabelWidth; + + } + if (this.xLabelRotation > 0){ + this.endPoint -= Math.sin(toRadians(this.xLabelRotation))*originalLabelWidth + 3; + } + } + else{ + this.xLabelWidth = 0; + this.xScalePaddingRight = this.padding; + this.xScalePaddingLeft = this.padding; + } + + }, + // Needs to be overidden in each Chart type + // Otherwise we need to pass all the data into the scale class + calculateYRange: noop, + drawingArea: function(){ + return this.startPoint - this.endPoint; + }, + calculateY : function(value){ + var scalingFactor = this.drawingArea() / (this.min - this.max); + return this.endPoint - (scalingFactor * (value - this.min)); + }, + calculateX : function(index){ + var isRotated = (this.xLabelRotation > 0), + // innerWidth = (this.offsetGridLines) ? this.width - offsetLeft - this.padding : this.width - (offsetLeft + halfLabelWidth * 2) - this.padding, + innerWidth = this.width - (this.xScalePaddingLeft + this.xScalePaddingRight), + valueWidth = innerWidth/Math.max((this.valuesCount - ((this.offsetGridLines) ? 0 : 1)), 1), + valueOffset = (valueWidth * index) + this.xScalePaddingLeft; + + if (this.offsetGridLines){ + valueOffset += (valueWidth/2); + } + + return Math.round(valueOffset); + }, + update : function(newProps){ + helpers.extend(this, newProps); + this.fit(); + }, + draw : function(){ + var ctx = this.ctx, + yLabelGap = (this.endPoint - this.startPoint) / this.steps, + xStart = Math.round(this.xScalePaddingLeft); + if (this.display){ + ctx.fillStyle = this.textColor; + ctx.font = this.font; + each(this.yLabels,function(labelString,index){ + var yLabelCenter = this.endPoint - (yLabelGap * index), + linePositionY = Math.round(yLabelCenter), + drawHorizontalLine = this.showHorizontalLines; + + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + if (this.showLabels){ + ctx.fillText(labelString,xStart - 10,yLabelCenter); + } + + // This is X axis, so draw it + if (index === 0 && !drawHorizontalLine){ + drawHorizontalLine = true; + } + + if (drawHorizontalLine){ + ctx.beginPath(); + } + + if (index > 0){ + // This is a grid line in the centre, so drop that + ctx.lineWidth = this.gridLineWidth; + ctx.strokeStyle = this.gridLineColor; + } else { + // This is the first line on the scale + ctx.lineWidth = this.lineWidth; + ctx.strokeStyle = this.lineColor; + } + + linePositionY += helpers.aliasPixel(ctx.lineWidth); + + if(drawHorizontalLine){ + ctx.moveTo(xStart, linePositionY); + ctx.lineTo(this.width, linePositionY); + ctx.stroke(); + ctx.closePath(); + } + + ctx.lineWidth = this.lineWidth; + ctx.strokeStyle = this.lineColor; + ctx.beginPath(); + ctx.moveTo(xStart - 5, linePositionY); + ctx.lineTo(xStart, linePositionY); + ctx.stroke(); + ctx.closePath(); + + },this); + + each(this.xLabels,function(label,index){ + var xPos = this.calculateX(index) + aliasPixel(this.lineWidth), + // Check to see if line/bar here and decide where to place the line + linePos = this.calculateX(index - (this.offsetGridLines ? 0.5 : 0)) + aliasPixel(this.lineWidth), + isRotated = (this.xLabelRotation > 0), + drawVerticalLine = this.showVerticalLines; + + // This is Y axis, so draw it + if (index === 0 && !drawVerticalLine){ + drawVerticalLine = true; + } + + if (drawVerticalLine){ + ctx.beginPath(); + } + + if (index > 0){ + // This is a grid line in the centre, so drop that + ctx.lineWidth = this.gridLineWidth; + ctx.strokeStyle = this.gridLineColor; + } else { + // This is the first line on the scale + ctx.lineWidth = this.lineWidth; + ctx.strokeStyle = this.lineColor; + } + + if (drawVerticalLine){ + ctx.moveTo(linePos,this.endPoint); + ctx.lineTo(linePos,this.startPoint - 3); + ctx.stroke(); + ctx.closePath(); + } + + + ctx.lineWidth = this.lineWidth; + ctx.strokeStyle = this.lineColor; + + + // Small lines at the bottom of the base grid line + ctx.beginPath(); + ctx.moveTo(linePos,this.endPoint); + ctx.lineTo(linePos,this.endPoint + 5); + ctx.stroke(); + ctx.closePath(); + + ctx.save(); + ctx.translate(xPos,(isRotated) ? this.endPoint + 12 : this.endPoint + 8); + ctx.rotate(toRadians(this.xLabelRotation)*-1); + ctx.font = this.font; + ctx.textAlign = (isRotated) ? "right" : "center"; + ctx.textBaseline = (isRotated) ? "middle" : "top"; + ctx.fillText(label, 0, 0); + ctx.restore(); + },this); + + } + } + + }); + + Chart.RadialScale = Chart.Element.extend({ + initialize: function(){ + this.size = min([this.height, this.width]); + this.drawingArea = (this.display) ? (this.size/2) - (this.fontSize/2 + this.backdropPaddingY) : (this.size/2); + }, + calculateCenterOffset: function(value){ + // Take into account half font size + the yPadding of the top value + var scalingFactor = this.drawingArea / (this.max - this.min); + + return (value - this.min) * scalingFactor; + }, + update : function(){ + if (!this.lineArc){ + this.setScaleSize(); + } else { + this.drawingArea = (this.display) ? (this.size/2) - (this.fontSize/2 + this.backdropPaddingY) : (this.size/2); + } + this.buildYLabels(); + }, + buildYLabels: function(){ + this.yLabels = []; + + var stepDecimalPlaces = getDecimalPlaces(this.stepValue); + + for (var i=0; i<=this.steps; i++){ + this.yLabels.push(template(this.templateString,{value:(this.min + (i * this.stepValue)).toFixed(stepDecimalPlaces)})); + } + }, + getCircumference : function(){ + return ((Math.PI*2) / this.valuesCount); + }, + setScaleSize: function(){ + /* + * Right, this is really confusing and there is a lot of maths going on here + * The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9 + * + * Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif + * + * Solution: + * + * We assume the radius of the polygon is half the size of the canvas at first + * at each index we check if the text overlaps. + * + * Where it does, we store that angle and that index. + * + * After finding the largest index and angle we calculate how much we need to remove + * from the shape radius to move the point inwards by that x. + * + * We average the left and right distances to get the maximum shape radius that can fit in the box + * along with labels. + * + * Once we have that, we can find the centre point for the chart, by taking the x text protrusion + * on each side, removing that from the size, halving it and adding the left x protrusion width. + * + * This will mean we have a shape fitted to the canvas, as large as it can be with the labels + * and position it in the most space efficient manner + * + * https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif + */ + + + // Get maximum radius of the polygon. Either half the height (minus the text width) or half the width. + // Use this to calculate the offset + change. - Make sure L/R protrusion is at least 0 to stop issues with centre points + var largestPossibleRadius = min([(this.height/2 - this.pointLabelFontSize - 5), this.width/2]), + pointPosition, + i, + textWidth, + halfTextWidth, + furthestRight = this.width, + furthestRightIndex, + furthestRightAngle, + furthestLeft = 0, + furthestLeftIndex, + furthestLeftAngle, + xProtrusionLeft, + xProtrusionRight, + radiusReductionRight, + radiusReductionLeft, + maxWidthRadius; + this.ctx.font = fontString(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily); + for (i=0;i furthestRight) { + furthestRight = pointPosition.x + halfTextWidth; + furthestRightIndex = i; + } + if (pointPosition.x - halfTextWidth < furthestLeft) { + furthestLeft = pointPosition.x - halfTextWidth; + furthestLeftIndex = i; + } + } + else if (i < this.valuesCount/2) { + // Less than half the values means we'll left align the text + if (pointPosition.x + textWidth > furthestRight) { + furthestRight = pointPosition.x + textWidth; + furthestRightIndex = i; + } + } + else if (i > this.valuesCount/2){ + // More than half the values means we'll right align the text + if (pointPosition.x - textWidth < furthestLeft) { + furthestLeft = pointPosition.x - textWidth; + furthestLeftIndex = i; + } + } + } + + xProtrusionLeft = furthestLeft; + + xProtrusionRight = Math.ceil(furthestRight - this.width); + + furthestRightAngle = this.getIndexAngle(furthestRightIndex); + + furthestLeftAngle = this.getIndexAngle(furthestLeftIndex); + + radiusReductionRight = xProtrusionRight / Math.sin(furthestRightAngle + Math.PI/2); + + radiusReductionLeft = xProtrusionLeft / Math.sin(furthestLeftAngle + Math.PI/2); + + // Ensure we actually need to reduce the size of the chart + radiusReductionRight = (isNumber(radiusReductionRight)) ? radiusReductionRight : 0; + radiusReductionLeft = (isNumber(radiusReductionLeft)) ? radiusReductionLeft : 0; + + this.drawingArea = largestPossibleRadius - (radiusReductionLeft + radiusReductionRight)/2; + + //this.drawingArea = min([maxWidthRadius, (this.height - (2 * (this.pointLabelFontSize + 5)))/2]) + this.setCenterPoint(radiusReductionLeft, radiusReductionRight); + + }, + setCenterPoint: function(leftMovement, rightMovement){ + + var maxRight = this.width - rightMovement - this.drawingArea, + maxLeft = leftMovement + this.drawingArea; + + this.xCenter = (maxLeft + maxRight)/2; + // Always vertically in the centre as the text height doesn't change + this.yCenter = (this.height/2); + }, + + getIndexAngle : function(index){ + var angleMultiplier = (Math.PI * 2) / this.valuesCount; + // Start from the top instead of right, so remove a quarter of the circle + + return index * angleMultiplier - (Math.PI/2); + }, + getPointPosition : function(index, distanceFromCenter){ + var thisAngle = this.getIndexAngle(index); + return { + x : (Math.cos(thisAngle) * distanceFromCenter) + this.xCenter, + y : (Math.sin(thisAngle) * distanceFromCenter) + this.yCenter + }; + }, + draw: function(){ + if (this.display){ + var ctx = this.ctx; + each(this.yLabels, function(label, index){ + // Don't draw a centre value + if (index > 0){ + var yCenterOffset = index * (this.drawingArea/this.steps), + yHeight = this.yCenter - yCenterOffset, + pointPosition; + + // Draw circular lines around the scale + if (this.lineWidth > 0){ + ctx.strokeStyle = this.lineColor; + ctx.lineWidth = this.lineWidth; + + if(this.lineArc){ + ctx.beginPath(); + ctx.arc(this.xCenter, this.yCenter, yCenterOffset, 0, Math.PI*2); + ctx.closePath(); + ctx.stroke(); + } else{ + ctx.beginPath(); + for (var i=0;i= 0; i--) { + var centerOffset = null, outerPosition = null; + + if (this.angleLineWidth > 0 && (i % this.angleLineInterval === 0)){ + centerOffset = this.calculateCenterOffset(this.max); + outerPosition = this.getPointPosition(i, centerOffset); + ctx.beginPath(); + ctx.moveTo(this.xCenter, this.yCenter); + ctx.lineTo(outerPosition.x, outerPosition.y); + ctx.stroke(); + ctx.closePath(); + } + + if (this.backgroundColors && this.backgroundColors.length == this.valuesCount) { + if (centerOffset == null) + centerOffset = this.calculateCenterOffset(this.max); + + if (outerPosition == null) + outerPosition = this.getPointPosition(i, centerOffset); + + var previousOuterPosition = this.getPointPosition(i === 0 ? this.valuesCount - 1 : i - 1, centerOffset); + var nextOuterPosition = this.getPointPosition(i === this.valuesCount - 1 ? 0 : i + 1, centerOffset); + + var previousOuterHalfway = { x: (previousOuterPosition.x + outerPosition.x) / 2, y: (previousOuterPosition.y + outerPosition.y) / 2 }; + var nextOuterHalfway = { x: (outerPosition.x + nextOuterPosition.x) / 2, y: (outerPosition.y + nextOuterPosition.y) / 2 }; + + ctx.beginPath(); + ctx.moveTo(this.xCenter, this.yCenter); + ctx.lineTo(previousOuterHalfway.x, previousOuterHalfway.y); + ctx.lineTo(outerPosition.x, outerPosition.y); + ctx.lineTo(nextOuterHalfway.x, nextOuterHalfway.y); + ctx.fillStyle = this.backgroundColors[i]; + ctx.fill(); + ctx.closePath(); + } + // Extra 3px out for some label spacing + var pointLabelPosition = this.getPointPosition(i, this.calculateCenterOffset(this.max) + 5); + ctx.font = fontString(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily); + ctx.fillStyle = this.pointLabelFontColor; + + var labelsCount = this.labels.length, + halfLabelsCount = this.labels.length/2, + quarterLabelsCount = halfLabelsCount/2, + upperHalf = (i < quarterLabelsCount || i > labelsCount - quarterLabelsCount), + exactQuarter = (i === quarterLabelsCount || i === labelsCount - quarterLabelsCount); + if (i === 0){ + ctx.textAlign = 'center'; + } else if(i === halfLabelsCount){ + ctx.textAlign = 'center'; + } else if (i < halfLabelsCount){ + ctx.textAlign = 'left'; + } else { + ctx.textAlign = 'right'; + } + + // Set the correct text baseline based on outer positioning + if (exactQuarter){ + ctx.textBaseline = 'middle'; + } else if (upperHalf){ + ctx.textBaseline = 'bottom'; + } else { + ctx.textBaseline = 'top'; + } + + ctx.fillText(this.labels[i], pointLabelPosition.x, pointLabelPosition.y); + } + } + } + } + }); + + Chart.animationService = { + frameDuration: 17, + animations: [], + dropFrames: 0, + addAnimation: function(chartInstance, animationObject) { + for (var index = 0; index < this.animations.length; ++ index){ + if (this.animations[index].chartInstance === chartInstance){ + // replacing an in progress animation + this.animations[index].animationObject = animationObject; + return; + } + } + + this.animations.push({ + chartInstance: chartInstance, + animationObject: animationObject + }); + + // If there are no animations queued, manually kickstart a digest, for lack of a better word + if (this.animations.length == 1) { + helpers.requestAnimFrame.call(window, this.digestWrapper); + } + }, + // Cancel the animation for a given chart instance + cancelAnimation: function(chartInstance) { + var index = helpers.findNextWhere(this.animations, function(animationWrapper) { + return animationWrapper.chartInstance === chartInstance; + }); + + if (index) + { + this.animations.splice(index, 1); + } + }, + // calls startDigest with the proper context + digestWrapper: function() { + Chart.animationService.startDigest.call(Chart.animationService); + }, + startDigest: function() { + + var startTime = Date.now(); + var framesToDrop = 0; + + if(this.dropFrames > 1){ + framesToDrop = Math.floor(this.dropFrames); + this.dropFrames -= framesToDrop; + } + + for (var i = 0; i < this.animations.length; i++) { + + if (this.animations[i].animationObject.currentStep === null){ + this.animations[i].animationObject.currentStep = 0; + } + + this.animations[i].animationObject.currentStep += 1 + framesToDrop; + if(this.animations[i].animationObject.currentStep > this.animations[i].animationObject.numSteps){ + this.animations[i].animationObject.currentStep = this.animations[i].animationObject.numSteps; + } + + this.animations[i].animationObject.render(this.animations[i].chartInstance, this.animations[i].animationObject); + + // Check if executed the last frame. + if (this.animations[i].animationObject.currentStep == this.animations[i].animationObject.numSteps){ + // Call onAnimationComplete + this.animations[i].animationObject.onAnimationComplete.call(this.animations[i].chartInstance); + // Remove the animation. + this.animations.splice(i, 1); + // Keep the index in place to offset the splice + i--; + } + } + + var endTime = Date.now(); + var delay = endTime - startTime - this.frameDuration; + var frameDelay = delay / this.frameDuration; + + if(frameDelay > 1){ + this.dropFrames += frameDelay; + } + + // Do we have more stuff to animate? + if (this.animations.length > 0){ + helpers.requestAnimFrame.call(window, this.digestWrapper); + } + } + }; + + // Attach global event to resize each chart instance when the browser resizes + helpers.addEvent(window, "resize", (function(){ + // Basic debounce of resize function so it doesn't hurt performance when resizing browser. + var timeout; + return function(){ + clearTimeout(timeout); + timeout = setTimeout(function(){ + each(Chart.instances,function(instance){ + // If the responsive flag is set in the chart instance config + // Cascade the resize event down to the chart. + if (instance.options.responsive){ + instance.resize(instance.render, true); + } + }); + }, 50); + }; + })()); + + + if (amd) { + define('Chart', [], function(){ + return Chart; + }); + } else if (typeof module === 'object' && module.exports) { + module.exports = Chart; + } + + root.Chart = Chart; + + Chart.noConflict = function(){ + root.Chart = previous; + return Chart; + }; + +}).call(this); + +(function(){ + "use strict"; + + var root = this, + Chart = root.Chart, + helpers = Chart.helpers; + + + var defaultConfig = { + //Boolean - Whether the scale should start at zero, or an order of magnitude down from the lowest value + scaleBeginAtZero : true, + + //Boolean - Whether grid lines are shown across the chart + scaleShowGridLines : true, + + //String - Colour of the grid lines + scaleGridLineColor : "rgba(0,0,0,.05)", + + //Number - Width of the grid lines + scaleGridLineWidth : 1, + + //Boolean - Whether to show horizontal lines (except X axis) + scaleShowHorizontalLines: true, + + //Boolean - Whether to show vertical lines (except Y axis) + scaleShowVerticalLines: true, + + //Boolean - If there is a stroke on each bar + barShowStroke : true, + + //Number - Pixel width of the bar stroke + barStrokeWidth : 2, + + //Number - Spacing between each of the X value sets + barValueSpacing : 5, + + //Number - Spacing between data sets within X values + barDatasetSpacing : 1, + + //String - A legend template + legendTemplate : "
    -legend\"><% for (var i=0; i
  • -legend-icon\" style=\"background-color:<%=datasets[i].fillColor%>\">-legend-text\"><%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
" + + }; + + + Chart.Type.extend({ + name: "Bar", + defaults : defaultConfig, + initialize: function(data){ + + //Expose options as a scope variable here so we can access it in the ScaleClass + var options = this.options; + + this.ScaleClass = Chart.Scale.extend({ + offsetGridLines : true, + calculateBarX : function(datasetCount, datasetIndex, barIndex){ + //Reusable method for calculating the xPosition of a given bar based on datasetIndex & width of the bar + var xWidth = this.calculateBaseWidth(), + xAbsolute = this.calculateX(barIndex) - (xWidth/2), + barWidth = this.calculateBarWidth(datasetCount); + + return xAbsolute + (barWidth * datasetIndex) + (datasetIndex * options.barDatasetSpacing) + barWidth/2; + }, + calculateBaseWidth : function(){ + return (this.calculateX(1) - this.calculateX(0)) - (2*options.barValueSpacing); + }, + calculateBarWidth : function(datasetCount){ + //The padding between datasets is to the right of each bar, providing that there are more than 1 dataset + var baseWidth = this.calculateBaseWidth() - ((datasetCount - 1) * options.barDatasetSpacing); + + return (baseWidth / datasetCount); + } + }); + + this.datasets = []; + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activeBars = (evt.type !== 'mouseout') ? this.getBarsAtEvent(evt) : []; + + this.eachBars(function(bar){ + bar.restore(['fillColor', 'strokeColor']); + }); + helpers.each(activeBars, function(activeBar){ + if (activeBar) { + activeBar.fillColor = activeBar.highlightFill; + activeBar.strokeColor = activeBar.highlightStroke; + } + }); + this.showTooltip(activeBars); + }); + } + + //Declare the extension of the default point, to cater for the options passed in to the constructor + this.BarClass = Chart.Rectangle.extend({ + strokeWidth : this.options.barStrokeWidth, + showStroke : this.options.barShowStroke, + ctx : this.chart.ctx + }); + + //Iterate through each of the datasets, and build this into a property of the chart + helpers.each(data.datasets,function(dataset,datasetIndex){ + + var datasetObject = { + label : dataset.label || null, + fillColor : dataset.fillColor, + strokeColor : dataset.strokeColor, + bars : [] + }; + + this.datasets.push(datasetObject); + + helpers.each(dataset.data,function(dataPoint,index){ + //Add a new point for each piece of data, passing any required data to draw. + datasetObject.bars.push(new this.BarClass({ + value : dataPoint, + label : data.labels[index], + datasetLabel: dataset.label, + strokeColor : (typeof dataset.strokeColor == 'object') ? dataset.strokeColor[index] : dataset.strokeColor, + fillColor : (typeof dataset.fillColor == 'object') ? dataset.fillColor[index] : dataset.fillColor, + highlightFill : (dataset.highlightFill) ? (typeof dataset.highlightFill == 'object') ? dataset.highlightFill[index] : dataset.highlightFill : (typeof dataset.fillColor == 'object') ? dataset.fillColor[index] : dataset.fillColor, + highlightStroke : (dataset.highlightStroke) ? (typeof dataset.highlightStroke == 'object') ? dataset.highlightStroke[index] : dataset.highlightStroke : (typeof dataset.strokeColor == 'object') ? dataset.strokeColor[index] : dataset.strokeColor + })); + },this); + + },this); + + this.buildScale(data.labels); + + this.BarClass.prototype.base = this.scale.endPoint; + + this.eachBars(function(bar, index, datasetIndex){ + helpers.extend(bar, { + width : this.scale.calculateBarWidth(this.datasets.length), + x: this.scale.calculateBarX(this.datasets.length, datasetIndex, index), + y: this.scale.endPoint + }); + bar.save(); + }, this); + + this.render(); + }, + update : function(){ + this.scale.update(); + // Reset any highlight colours before updating. + helpers.each(this.activeElements, function(activeElement){ + activeElement.restore(['fillColor', 'strokeColor']); + }); + + this.eachBars(function(bar){ + bar.save(); + }); + this.render(); + }, + eachBars : function(callback){ + helpers.each(this.datasets,function(dataset, datasetIndex){ + helpers.each(dataset.bars, callback, this, datasetIndex); + },this); + }, + getBarsAtEvent : function(e){ + var barsArray = [], + eventPosition = helpers.getRelativePosition(e), + datasetIterator = function(dataset){ + barsArray.push(dataset.bars[barIndex]); + }, + barIndex; + + for (var datasetIndex = 0; datasetIndex < this.datasets.length; datasetIndex++) { + for (barIndex = 0; barIndex < this.datasets[datasetIndex].bars.length; barIndex++) { + if (this.datasets[datasetIndex].bars[barIndex].inRange(eventPosition.x,eventPosition.y)){ + helpers.each(this.datasets, datasetIterator); + return barsArray; + } + } + } + + return barsArray; + }, + buildScale : function(labels){ + var self = this; + + var dataTotal = function(){ + var values = []; + self.eachBars(function(bar){ + values.push(bar.value); + }); + return values; + }; + + var scaleOptions = { + templateString : this.options.scaleLabel, + height : this.chart.height, + width : this.chart.width, + ctx : this.chart.ctx, + textColor : this.options.scaleFontColor, + fontSize : this.options.scaleFontSize, + fontStyle : this.options.scaleFontStyle, + fontFamily : this.options.scaleFontFamily, + valuesCount : labels.length, + beginAtZero : this.options.scaleBeginAtZero, + integersOnly : this.options.scaleIntegersOnly, + calculateYRange: function(currentHeight){ + var updatedRanges = helpers.calculateScaleRange( + dataTotal(), + currentHeight, + this.fontSize, + this.beginAtZero, + this.integersOnly + ); + helpers.extend(this, updatedRanges); + }, + xLabels : labels, + font : helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, this.options.scaleFontFamily), + lineWidth : this.options.scaleLineWidth, + lineColor : this.options.scaleLineColor, + showHorizontalLines : this.options.scaleShowHorizontalLines, + showVerticalLines : this.options.scaleShowVerticalLines, + gridLineWidth : (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0, + gridLineColor : (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)", + padding : (this.options.showScale) ? 0 : (this.options.barShowStroke) ? this.options.barStrokeWidth : 0, + showLabels : this.options.scaleShowLabels, + display : this.options.showScale + }; + + if (this.options.scaleOverride){ + helpers.extend(scaleOptions, { + calculateYRange: helpers.noop, + steps: this.options.scaleSteps, + stepValue: this.options.scaleStepWidth, + min: this.options.scaleStartValue, + max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) + }); + } + + this.scale = new this.ScaleClass(scaleOptions); + }, + addData : function(valuesArray,label){ + //Map the values array for each of the datasets + helpers.each(valuesArray,function(value,datasetIndex){ + //Add a new point for each piece of data, passing any required data to draw. + this.datasets[datasetIndex].bars.push(new this.BarClass({ + value : value, + label : label, + datasetLabel: this.datasets[datasetIndex].label, + x: this.scale.calculateBarX(this.datasets.length, datasetIndex, this.scale.valuesCount+1), + y: this.scale.endPoint, + width : this.scale.calculateBarWidth(this.datasets.length), + base : this.scale.endPoint, + strokeColor : this.datasets[datasetIndex].strokeColor, + fillColor : this.datasets[datasetIndex].fillColor + })); + },this); + + this.scale.addXLabel(label); + //Then re-render the chart. + this.update(); + }, + removeData : function(){ + this.scale.removeXLabel(); + //Then re-render the chart. + helpers.each(this.datasets,function(dataset){ + dataset.bars.shift(); + },this); + this.update(); + }, + reflow : function(){ + helpers.extend(this.BarClass.prototype,{ + y: this.scale.endPoint, + base : this.scale.endPoint + }); + var newScaleProps = helpers.extend({ + height : this.chart.height, + width : this.chart.width + }); + this.scale.update(newScaleProps); + }, + draw : function(ease){ + var easingDecimal = ease || 1; + this.clear(); + + var ctx = this.chart.ctx; + + this.scale.draw(easingDecimal); + + //Draw all the bars for each dataset + helpers.each(this.datasets,function(dataset,datasetIndex){ + helpers.each(dataset.bars,function(bar,index){ + if (bar.hasValue()){ + bar.base = this.scale.endPoint; + //Transition then draw + bar.transition({ + x : this.scale.calculateBarX(this.datasets.length, datasetIndex, index), + y : this.scale.calculateY(bar.value), + width : this.scale.calculateBarWidth(this.datasets.length) + }, easingDecimal).draw(); + } + },this); + + },this); + } + }); + + +}).call(this); + +(function(){ + "use strict"; + + var root = this, + Chart = root.Chart, + //Cache a local reference to Chart.helpers + helpers = Chart.helpers; + + var defaultConfig = { + //Boolean - Whether we should show a stroke on each segment + segmentShowStroke : true, + + //String - The colour of each segment stroke + segmentStrokeColor : "#fff", + + //Number - The width of each segment stroke + segmentStrokeWidth : 2, + + //The percentage of the chart that we cut out of the middle. + percentageInnerCutout : 50, + + //Number - Amount of animation steps + animationSteps : 100, + + //String - Animation easing effect + animationEasing : "easeOutBounce", + + //Boolean - Whether we animate the rotation of the Doughnut + animateRotate : true, + + //Boolean - Whether we animate scaling the Doughnut from the centre + animateScale : false, + + //String - A legend template + legendTemplate : "
    -legend\"><% for (var i=0; i
  • -legend-icon\" style=\"background-color:<%=segments[i].fillColor%>\">-legend-text\"><%if(segments[i].label){%><%=segments[i].label%><%}%>
  • <%}%>
" + + }; + + Chart.Type.extend({ + //Passing in a name registers this chart in the Chart namespace + name: "Doughnut", + //Providing a defaults will also register the defaults in the chart namespace + defaults : defaultConfig, + //Initialize is fired when the chart is initialized - Data is passed in as a parameter + //Config is automatically merged by the core of Chart.js, and is available at this.options + initialize: function(data){ + + //Declare segments as a static property to prevent inheriting across the Chart type prototype + this.segments = []; + this.outerRadius = (helpers.min([this.chart.width,this.chart.height]) - this.options.segmentStrokeWidth/2)/2; + + this.SegmentArc = Chart.Arc.extend({ + ctx : this.chart.ctx, + x : this.chart.width/2, + y : this.chart.height/2 + }); + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activeSegments = (evt.type !== 'mouseout') ? this.getSegmentsAtEvent(evt) : []; + + helpers.each(this.segments,function(segment){ + segment.restore(["fillColor"]); + }); + helpers.each(activeSegments,function(activeSegment){ + activeSegment.fillColor = activeSegment.highlightColor; + }); + this.showTooltip(activeSegments); + }); + } + this.calculateTotal(data); + + helpers.each(data,function(datapoint, index){ + if (!datapoint.color) { + datapoint.color = 'hsl(' + (360 * index / data.length) + ', 100%, 50%)'; + } + this.addData(datapoint, index, true); + },this); + + this.render(); + }, + getSegmentsAtEvent : function(e){ + var segmentsArray = []; + + var location = helpers.getRelativePosition(e); + + helpers.each(this.segments,function(segment){ + if (segment.inRange(location.x,location.y)) segmentsArray.push(segment); + },this); + return segmentsArray; + }, + addData : function(segment, atIndex, silent){ + var index = atIndex !== undefined ? atIndex : this.segments.length; + if ( typeof(segment.color) === "undefined" ) { + segment.color = Chart.defaults.global.segmentColorDefault[index % Chart.defaults.global.segmentColorDefault.length]; + segment.highlight = Chart.defaults.global.segmentHighlightColorDefaults[index % Chart.defaults.global.segmentHighlightColorDefaults.length]; + } + this.segments.splice(index, 0, new this.SegmentArc({ + value : segment.value, + outerRadius : (this.options.animateScale) ? 0 : this.outerRadius, + innerRadius : (this.options.animateScale) ? 0 : (this.outerRadius/100) * this.options.percentageInnerCutout, + fillColor : segment.color, + highlightColor : segment.highlight || segment.color, + showStroke : this.options.segmentShowStroke, + strokeWidth : this.options.segmentStrokeWidth, + strokeColor : this.options.segmentStrokeColor, + startAngle : Math.PI * 1.5, + circumference : (this.options.animateRotate) ? 0 : this.calculateCircumference(segment.value), + label : segment.label + })); + if (!silent){ + this.reflow(); + this.update(); + } + }, + calculateCircumference : function(value) { + if ( this.total > 0 ) { + return (Math.PI*2)*(value / this.total); + } else { + return 0; + } + }, + calculateTotal : function(data){ + this.total = 0; + helpers.each(data,function(segment){ + this.total += Math.abs(segment.value); + },this); + }, + update : function(){ + this.calculateTotal(this.segments); + + // Reset any highlight colours before updating. + helpers.each(this.activeElements, function(activeElement){ + activeElement.restore(['fillColor']); + }); + + helpers.each(this.segments,function(segment){ + segment.save(); + }); + this.render(); + }, + + removeData: function(atIndex){ + var indexToDelete = (helpers.isNumber(atIndex)) ? atIndex : this.segments.length-1; + this.segments.splice(indexToDelete, 1); + this.reflow(); + this.update(); + }, + + reflow : function(){ + helpers.extend(this.SegmentArc.prototype,{ + x : this.chart.width/2, + y : this.chart.height/2 + }); + this.outerRadius = (helpers.min([this.chart.width,this.chart.height]) - this.options.segmentStrokeWidth/2)/2; + helpers.each(this.segments, function(segment){ + segment.update({ + outerRadius : this.outerRadius, + innerRadius : (this.outerRadius/100) * this.options.percentageInnerCutout + }); + }, this); + }, + draw : function(easeDecimal){ + var animDecimal = (easeDecimal) ? easeDecimal : 1; + this.clear(); + helpers.each(this.segments,function(segment,index){ + segment.transition({ + circumference : this.calculateCircumference(segment.value), + outerRadius : this.outerRadius, + innerRadius : (this.outerRadius/100) * this.options.percentageInnerCutout + },animDecimal); + + segment.endAngle = segment.startAngle + segment.circumference; + + segment.draw(); + if (index === 0){ + segment.startAngle = Math.PI * 1.5; + } + //Check to see if it's the last segment, if not get the next and update the start angle + if (index < this.segments.length-1){ + this.segments[index+1].startAngle = segment.endAngle; + } + },this); + + } + }); + + Chart.types.Doughnut.extend({ + name : "Pie", + defaults : helpers.merge(defaultConfig,{percentageInnerCutout : 0}) + }); + +}).call(this); + +(function(){ + "use strict"; + + var root = this, + Chart = root.Chart, + helpers = Chart.helpers; + + var defaultConfig = { + + ///Boolean - Whether grid lines are shown across the chart + scaleShowGridLines : true, + + //String - Colour of the grid lines + scaleGridLineColor : "rgba(0,0,0,.05)", + + //Number - Width of the grid lines + scaleGridLineWidth : 1, + + //Boolean - Whether to show horizontal lines (except X axis) + scaleShowHorizontalLines: true, + + //Boolean - Whether to show vertical lines (except Y axis) + scaleShowVerticalLines: true, + + //Boolean - Whether the line is curved between points + bezierCurve : true, + + //Number - Tension of the bezier curve between points + bezierCurveTension : 0.4, + + //Boolean - Whether to show a dot for each point + pointDot : true, + + //Number - Radius of each point dot in pixels + pointDotRadius : 4, + + //Number - Pixel width of point dot stroke + pointDotStrokeWidth : 1, + + //Number - amount extra to add to the radius to cater for hit detection outside the drawn point + pointHitDetectionRadius : 20, + + //Boolean - Whether to show a stroke for datasets + datasetStroke : true, + + //Number - Pixel width of dataset stroke + datasetStrokeWidth : 2, + + //Boolean - Whether to fill the dataset with a colour + datasetFill : true, + + //String - A legend template + legendTemplate : "
    -legend\"><% for (var i=0; i
  • -legend-icon\" style=\"background-color:<%=datasets[i].strokeColor%>\">-legend-text\"><%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
", + + //Boolean - Whether to horizontally center the label and point dot inside the grid + offsetGridLines : false + + }; + + + Chart.Type.extend({ + name: "Line", + defaults : defaultConfig, + initialize: function(data){ + //Declare the extension of the default point, to cater for the options passed in to the constructor + this.PointClass = Chart.Point.extend({ + offsetGridLines : this.options.offsetGridLines, + strokeWidth : this.options.pointDotStrokeWidth, + radius : this.options.pointDotRadius, + display: this.options.pointDot, + hitDetectionRadius : this.options.pointHitDetectionRadius, + ctx : this.chart.ctx, + inRange : function(mouseX){ + return (Math.pow(mouseX-this.x, 2) < Math.pow(this.radius + this.hitDetectionRadius,2)); + } + }); + + this.datasets = []; + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activePoints = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : []; + this.eachPoints(function(point){ + point.restore(['fillColor', 'strokeColor']); + }); + helpers.each(activePoints, function(activePoint){ + activePoint.fillColor = activePoint.highlightFill; + activePoint.strokeColor = activePoint.highlightStroke; + }); + this.showTooltip(activePoints); + }); + } + + //Iterate through each of the datasets, and build this into a property of the chart + helpers.each(data.datasets,function(dataset){ + + var datasetObject = { + label : dataset.label || null, + fillColor : dataset.fillColor, + strokeColor : dataset.strokeColor, + pointColor : dataset.pointColor, + pointStrokeColor : dataset.pointStrokeColor, + points : [] + }; + + this.datasets.push(datasetObject); + + + helpers.each(dataset.data,function(dataPoint,index){ + //Add a new point for each piece of data, passing any required data to draw. + datasetObject.points.push(new this.PointClass({ + value : dataPoint, + label : data.labels[index], + datasetLabel: dataset.label, + strokeColor : dataset.pointStrokeColor, + fillColor : dataset.pointColor, + highlightFill : dataset.pointHighlightFill || dataset.pointColor, + highlightStroke : dataset.pointHighlightStroke || dataset.pointStrokeColor + })); + },this); + + this.buildScale(data.labels); + + + this.eachPoints(function(point, index){ + helpers.extend(point, { + x: this.scale.calculateX(index), + y: this.scale.endPoint + }); + point.save(); + }, this); + + },this); + + + this.render(); + }, + update : function(){ + this.scale.update(); + // Reset any highlight colours before updating. + helpers.each(this.activeElements, function(activeElement){ + activeElement.restore(['fillColor', 'strokeColor']); + }); + this.eachPoints(function(point){ + point.save(); + }); + this.render(); + }, + eachPoints : function(callback){ + helpers.each(this.datasets,function(dataset){ + helpers.each(dataset.points,callback,this); + },this); + }, + getPointsAtEvent : function(e){ + var pointsArray = [], + eventPosition = helpers.getRelativePosition(e); + helpers.each(this.datasets,function(dataset){ + helpers.each(dataset.points,function(point){ + if (point.inRange(eventPosition.x,eventPosition.y)) pointsArray.push(point); + }); + },this); + return pointsArray; + }, + buildScale : function(labels){ + var self = this; + + var dataTotal = function(){ + var values = []; + self.eachPoints(function(point){ + values.push(point.value); + }); + + return values; + }; + + var scaleOptions = { + templateString : this.options.scaleLabel, + height : this.chart.height, + width : this.chart.width, + ctx : this.chart.ctx, + textColor : this.options.scaleFontColor, + offsetGridLines : this.options.offsetGridLines, + fontSize : this.options.scaleFontSize, + fontStyle : this.options.scaleFontStyle, + fontFamily : this.options.scaleFontFamily, + valuesCount : labels.length, + beginAtZero : this.options.scaleBeginAtZero, + integersOnly : this.options.scaleIntegersOnly, + calculateYRange : function(currentHeight){ + var updatedRanges = helpers.calculateScaleRange( + dataTotal(), + currentHeight, + this.fontSize, + this.beginAtZero, + this.integersOnly + ); + helpers.extend(this, updatedRanges); + }, + xLabels : labels, + font : helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, this.options.scaleFontFamily), + lineWidth : this.options.scaleLineWidth, + lineColor : this.options.scaleLineColor, + showHorizontalLines : this.options.scaleShowHorizontalLines, + showVerticalLines : this.options.scaleShowVerticalLines, + gridLineWidth : (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0, + gridLineColor : (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)", + padding: (this.options.showScale) ? 0 : this.options.pointDotRadius + this.options.pointDotStrokeWidth, + showLabels : this.options.scaleShowLabels, + display : this.options.showScale + }; + + if (this.options.scaleOverride){ + helpers.extend(scaleOptions, { + calculateYRange: helpers.noop, + steps: this.options.scaleSteps, + stepValue: this.options.scaleStepWidth, + min: this.options.scaleStartValue, + max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) + }); + } + + + this.scale = new Chart.Scale(scaleOptions); + }, + addData : function(valuesArray,label){ + //Map the values array for each of the datasets + + helpers.each(valuesArray,function(value,datasetIndex){ + //Add a new point for each piece of data, passing any required data to draw. + this.datasets[datasetIndex].points.push(new this.PointClass({ + value : value, + label : label, + datasetLabel: this.datasets[datasetIndex].label, + x: this.scale.calculateX(this.scale.valuesCount+1), + y: this.scale.endPoint, + strokeColor : this.datasets[datasetIndex].pointStrokeColor, + fillColor : this.datasets[datasetIndex].pointColor + })); + },this); + + this.scale.addXLabel(label); + //Then re-render the chart. + this.update(); + }, + removeData : function(){ + this.scale.removeXLabel(); + //Then re-render the chart. + helpers.each(this.datasets,function(dataset){ + dataset.points.shift(); + },this); + this.update(); + }, + reflow : function(){ + var newScaleProps = helpers.extend({ + height : this.chart.height, + width : this.chart.width + }); + this.scale.update(newScaleProps); + }, + draw : function(ease){ + var easingDecimal = ease || 1; + this.clear(); + + var ctx = this.chart.ctx; + + // Some helper methods for getting the next/prev points + var hasValue = function(item){ + return item.value !== null; + }, + nextPoint = function(point, collection, index){ + return helpers.findNextWhere(collection, hasValue, index) || point; + }, + previousPoint = function(point, collection, index){ + return helpers.findPreviousWhere(collection, hasValue, index) || point; + }; + + if (!this.scale) return; + this.scale.draw(easingDecimal); + + + helpers.each(this.datasets,function(dataset){ + var pointsWithValues = helpers.where(dataset.points, hasValue); + + //Transition each point first so that the line and point drawing isn't out of sync + //We can use this extra loop to calculate the control points of this dataset also in this loop + + helpers.each(dataset.points, function(point, index){ + if (point.hasValue()){ + point.transition({ + y : this.scale.calculateY(point.value), + x : this.scale.calculateX(index) + }, easingDecimal); + } + },this); + + + // Control points need to be calculated in a separate loop, because we need to know the current x/y of the point + // This would cause issues when there is no animation, because the y of the next point would be 0, so beziers would be skewed + if (this.options.bezierCurve){ + helpers.each(pointsWithValues, function(point, index){ + var tension = (index > 0 && index < pointsWithValues.length - 1) ? this.options.bezierCurveTension : 0; + point.controlPoints = helpers.splineCurve( + previousPoint(point, pointsWithValues, index), + point, + nextPoint(point, pointsWithValues, index), + tension + ); + + // Prevent the bezier going outside of the bounds of the graph + + // Cap puter bezier handles to the upper/lower scale bounds + if (point.controlPoints.outer.y > this.scale.endPoint){ + point.controlPoints.outer.y = this.scale.endPoint; + } + else if (point.controlPoints.outer.y < this.scale.startPoint){ + point.controlPoints.outer.y = this.scale.startPoint; + } + + // Cap inner bezier handles to the upper/lower scale bounds + if (point.controlPoints.inner.y > this.scale.endPoint){ + point.controlPoints.inner.y = this.scale.endPoint; + } + else if (point.controlPoints.inner.y < this.scale.startPoint){ + point.controlPoints.inner.y = this.scale.startPoint; + } + },this); + } + + + //Draw the line between all the points + ctx.lineWidth = this.options.datasetStrokeWidth; + ctx.strokeStyle = dataset.strokeColor; + ctx.beginPath(); + + helpers.each(pointsWithValues, function(point, index){ + if (index === 0){ + ctx.moveTo(point.x, point.y); + } + else{ + if(this.options.bezierCurve){ + var previous = previousPoint(point, pointsWithValues, index); + + ctx.bezierCurveTo( + previous.controlPoints.outer.x, + previous.controlPoints.outer.y, + point.controlPoints.inner.x, + point.controlPoints.inner.y, + point.x, + point.y + ); + } + else{ + ctx.lineTo(point.x,point.y); + } + } + }, this); + + if (this.options.datasetStroke) { + ctx.stroke(); + } + + if (this.options.datasetFill && pointsWithValues.length > 0){ + //Round off the line by going to the base of the chart, back to the start, then fill. + ctx.lineTo(pointsWithValues[pointsWithValues.length - 1].x, this.scale.endPoint); + ctx.lineTo(pointsWithValues[0].x, this.scale.endPoint); + ctx.fillStyle = dataset.fillColor; + ctx.closePath(); + ctx.fill(); + } + + //Now draw the points over the line + //A little inefficient double looping, but better than the line + //lagging behind the point positions + helpers.each(pointsWithValues,function(point){ + point.draw(); + }); + },this); + } + }); + + +}).call(this); + +(function(){ + "use strict"; + + var root = this, + Chart = root.Chart, + //Cache a local reference to Chart.helpers + helpers = Chart.helpers; + + var defaultConfig = { + //Boolean - Show a backdrop to the scale label + scaleShowLabelBackdrop : true, + + //String - The colour of the label backdrop + scaleBackdropColor : "rgba(255,255,255,0.75)", + + // Boolean - Whether the scale should begin at zero + scaleBeginAtZero : true, + + //Number - The backdrop padding above & below the label in pixels + scaleBackdropPaddingY : 2, + + //Number - The backdrop padding to the side of the label in pixels + scaleBackdropPaddingX : 2, + + //Boolean - Show line for each value in the scale + scaleShowLine : true, + + //Boolean - Stroke a line around each segment in the chart + segmentShowStroke : true, + + //String - The colour of the stroke on each segment. + segmentStrokeColor : "#fff", + + //Number - The width of the stroke value in pixels + segmentStrokeWidth : 2, + + //Number - Amount of animation steps + animationSteps : 100, + + //String - Animation easing effect. + animationEasing : "easeOutBounce", + + //Boolean - Whether to animate the rotation of the chart + animateRotate : true, + + //Boolean - Whether to animate scaling the chart from the centre + animateScale : false, + + //String - A legend template + legendTemplate : "
    -legend\"><% for (var i=0; i
  • -legend-icon\" style=\"background-color:<%=segments[i].fillColor%>\">-legend-text\"><%if(segments[i].label){%><%=segments[i].label%><%}%>
  • <%}%>
" + }; + + + Chart.Type.extend({ + //Passing in a name registers this chart in the Chart namespace + name: "PolarArea", + //Providing a defaults will also register the defaults in the chart namespace + defaults : defaultConfig, + //Initialize is fired when the chart is initialized - Data is passed in as a parameter + //Config is automatically merged by the core of Chart.js, and is available at this.options + initialize: function(data){ + this.segments = []; + //Declare segment class as a chart instance specific class, so it can share props for this instance + this.SegmentArc = Chart.Arc.extend({ + showStroke : this.options.segmentShowStroke, + strokeWidth : this.options.segmentStrokeWidth, + strokeColor : this.options.segmentStrokeColor, + ctx : this.chart.ctx, + innerRadius : 0, + x : this.chart.width/2, + y : this.chart.height/2 + }); + this.scale = new Chart.RadialScale({ + display: this.options.showScale, + fontStyle: this.options.scaleFontStyle, + fontSize: this.options.scaleFontSize, + fontFamily: this.options.scaleFontFamily, + fontColor: this.options.scaleFontColor, + showLabels: this.options.scaleShowLabels, + showLabelBackdrop: this.options.scaleShowLabelBackdrop, + backdropColor: this.options.scaleBackdropColor, + backdropPaddingY : this.options.scaleBackdropPaddingY, + backdropPaddingX: this.options.scaleBackdropPaddingX, + lineWidth: (this.options.scaleShowLine) ? this.options.scaleLineWidth : 0, + lineColor: this.options.scaleLineColor, + lineArc: true, + width: this.chart.width, + height: this.chart.height, + xCenter: this.chart.width/2, + yCenter: this.chart.height/2, + ctx : this.chart.ctx, + templateString: this.options.scaleLabel, + valuesCount: data.length + }); + + this.updateScaleRange(data); + + this.scale.update(); + + helpers.each(data,function(segment,index){ + this.addData(segment,index,true); + },this); + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activeSegments = (evt.type !== 'mouseout') ? this.getSegmentsAtEvent(evt) : []; + helpers.each(this.segments,function(segment){ + segment.restore(["fillColor"]); + }); + helpers.each(activeSegments,function(activeSegment){ + activeSegment.fillColor = activeSegment.highlightColor; + }); + this.showTooltip(activeSegments); + }); + } + + this.render(); + }, + getSegmentsAtEvent : function(e){ + var segmentsArray = []; + + var location = helpers.getRelativePosition(e); + + helpers.each(this.segments,function(segment){ + if (segment.inRange(location.x,location.y)) segmentsArray.push(segment); + },this); + return segmentsArray; + }, + addData : function(segment, atIndex, silent){ + var index = atIndex || this.segments.length; + + this.segments.splice(index, 0, new this.SegmentArc({ + fillColor: segment.color, + highlightColor: segment.highlight || segment.color, + label: segment.label, + value: segment.value, + outerRadius: (this.options.animateScale) ? 0 : this.scale.calculateCenterOffset(segment.value), + circumference: (this.options.animateRotate) ? 0 : this.scale.getCircumference(), + startAngle: Math.PI * 1.5 + })); + if (!silent){ + this.reflow(); + this.update(); + } + }, + removeData: function(atIndex){ + var indexToDelete = (helpers.isNumber(atIndex)) ? atIndex : this.segments.length-1; + this.segments.splice(indexToDelete, 1); + this.reflow(); + this.update(); + }, + calculateTotal: function(data){ + this.total = 0; + helpers.each(data,function(segment){ + this.total += segment.value; + },this); + this.scale.valuesCount = this.segments.length; + }, + updateScaleRange: function(datapoints){ + var valuesArray = []; + helpers.each(datapoints,function(segment){ + valuesArray.push(segment.value); + }); + + var scaleSizes = (this.options.scaleOverride) ? + { + steps: this.options.scaleSteps, + stepValue: this.options.scaleStepWidth, + min: this.options.scaleStartValue, + max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) + } : + helpers.calculateScaleRange( + valuesArray, + helpers.min([this.chart.width, this.chart.height])/2, + this.options.scaleFontSize, + this.options.scaleBeginAtZero, + this.options.scaleIntegersOnly + ); + + helpers.extend( + this.scale, + scaleSizes, + { + size: helpers.min([this.chart.width, this.chart.height]), + xCenter: this.chart.width/2, + yCenter: this.chart.height/2 + } + ); + + }, + update : function(){ + this.calculateTotal(this.segments); + + helpers.each(this.segments,function(segment){ + segment.save(); + }); + + this.reflow(); + this.render(); + }, + reflow : function(){ + helpers.extend(this.SegmentArc.prototype,{ + x : this.chart.width/2, + y : this.chart.height/2 + }); + this.updateScaleRange(this.segments); + this.scale.update(); + + helpers.extend(this.scale,{ + xCenter: this.chart.width/2, + yCenter: this.chart.height/2 + }); + + helpers.each(this.segments, function(segment){ + segment.update({ + outerRadius : this.scale.calculateCenterOffset(segment.value) + }); + }, this); + + }, + draw : function(ease){ + var easingDecimal = ease || 1; + //Clear & draw the canvas + this.clear(); + helpers.each(this.segments,function(segment, index){ + segment.transition({ + circumference : this.scale.getCircumference(), + outerRadius : this.scale.calculateCenterOffset(segment.value) + },easingDecimal); + + segment.endAngle = segment.startAngle + segment.circumference; + + // If we've removed the first segment we need to set the first one to + // start at the top. + if (index === 0){ + segment.startAngle = Math.PI * 1.5; + } + + //Check to see if it's the last segment, if not get the next and update the start angle + if (index < this.segments.length - 1){ + this.segments[index+1].startAngle = segment.endAngle; + } + segment.draw(); + }, this); + this.scale.draw(); + } + }); + +}).call(this); + +(function(){ + "use strict"; + + var root = this, + Chart = root.Chart, + helpers = Chart.helpers; + + + + Chart.Type.extend({ + name: "Radar", + defaults:{ + //Boolean - Whether to show lines for each scale point + scaleShowLine : true, + + //Boolean - Whether we show the angle lines out of the radar + angleShowLineOut : true, + + //Boolean - Whether to show labels on the scale + scaleShowLabels : false, + + // Boolean - Whether the scale should begin at zero + scaleBeginAtZero : true, + + //String - Colour of the angle line + angleLineColor : "rgba(0,0,0,.1)", + + //Number - Pixel width of the angle line + angleLineWidth : 1, + + //Number - Interval at which to draw angle lines ("every Nth point") + angleLineInterval: 1, + + //String - Point label font declaration + pointLabelFontFamily : "'Arial'", + + //String - Point label font weight + pointLabelFontStyle : "normal", + + //Number - Point label font size in pixels + pointLabelFontSize : 10, + + //String - Point label font colour + pointLabelFontColor : "#666", + + //Boolean - Whether to show a dot for each point + pointDot : true, + + //Number - Radius of each point dot in pixels + pointDotRadius : 3, + + //Number - Pixel width of point dot stroke + pointDotStrokeWidth : 1, + + //Number - amount extra to add to the radius to cater for hit detection outside the drawn point + pointHitDetectionRadius : 20, + + //Boolean - Whether to show a stroke for datasets + datasetStroke : true, + + //Number - Pixel width of dataset stroke + datasetStrokeWidth : 2, + + //Boolean - Whether to fill the dataset with a colour + datasetFill : true, + + //String - A legend template + legendTemplate : "
    -legend\"><% for (var i=0; i
  • -legend-icon\" style=\"background-color:<%=datasets[i].strokeColor%>\">-legend-text\"><%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
" + + }, + + initialize: function(data){ + this.PointClass = Chart.Point.extend({ + strokeWidth : this.options.pointDotStrokeWidth, + radius : this.options.pointDotRadius, + display: this.options.pointDot, + hitDetectionRadius : this.options.pointHitDetectionRadius, + ctx : this.chart.ctx + }); + + this.datasets = []; + + this.buildScale(data); + + //Set up tooltip events on the chart + if (this.options.showTooltips){ + helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ + var activePointsCollection = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : []; + + this.eachPoints(function(point){ + point.restore(['fillColor', 'strokeColor']); + }); + helpers.each(activePointsCollection, function(activePoint){ + activePoint.fillColor = activePoint.highlightFill; + activePoint.strokeColor = activePoint.highlightStroke; + }); + + this.showTooltip(activePointsCollection); + }); + } + + //Iterate through each of the datasets, and build this into a property of the chart + helpers.each(data.datasets,function(dataset){ + + var datasetObject = { + label: dataset.label || null, + fillColor : dataset.fillColor, + strokeColor : dataset.strokeColor, + pointColor : dataset.pointColor, + pointStrokeColor : dataset.pointStrokeColor, + points : [] + }; + + this.datasets.push(datasetObject); + + helpers.each(dataset.data,function(dataPoint,index){ + //Add a new point for each piece of data, passing any required data to draw. + var pointPosition; + if (!this.scale.animation){ + pointPosition = this.scale.getPointPosition(index, this.scale.calculateCenterOffset(dataPoint)); + } + datasetObject.points.push(new this.PointClass({ + value : dataPoint, + label : data.labels[index], + datasetLabel: dataset.label, + x: (this.options.animation) ? this.scale.xCenter : pointPosition.x, + y: (this.options.animation) ? this.scale.yCenter : pointPosition.y, + strokeColor : dataset.pointStrokeColor, + fillColor : dataset.pointColor, + highlightFill : dataset.pointHighlightFill || dataset.pointColor, + highlightStroke : dataset.pointHighlightStroke || dataset.pointStrokeColor + })); + },this); + + },this); + + this.render(); + }, + eachPoints : function(callback){ + helpers.each(this.datasets,function(dataset){ + helpers.each(dataset.points,callback,this); + },this); + }, + + getPointsAtEvent : function(evt){ + var mousePosition = helpers.getRelativePosition(evt), + fromCenter = helpers.getAngleFromPoint({ + x: this.scale.xCenter, + y: this.scale.yCenter + }, mousePosition); + + var anglePerIndex = (Math.PI * 2) /this.scale.valuesCount, + pointIndex = Math.round((fromCenter.angle - Math.PI * 1.5) / anglePerIndex), + activePointsCollection = []; + + // If we're at the top, make the pointIndex 0 to get the first of the array. + if (pointIndex >= this.scale.valuesCount || pointIndex < 0){ + pointIndex = 0; + } + + if (fromCenter.distance <= this.scale.drawingArea){ + helpers.each(this.datasets, function(dataset){ + activePointsCollection.push(dataset.points[pointIndex]); + }); + } + + return activePointsCollection; + }, + + buildScale : function(data){ + this.scale = new Chart.RadialScale({ + display: this.options.showScale, + fontStyle: this.options.scaleFontStyle, + fontSize: this.options.scaleFontSize, + fontFamily: this.options.scaleFontFamily, + fontColor: this.options.scaleFontColor, + showLabels: this.options.scaleShowLabels, + showLabelBackdrop: this.options.scaleShowLabelBackdrop, + backdropColor: this.options.scaleBackdropColor, + backgroundColors: this.options.scaleBackgroundColors, + backdropPaddingY : this.options.scaleBackdropPaddingY, + backdropPaddingX: this.options.scaleBackdropPaddingX, + lineWidth: (this.options.scaleShowLine) ? this.options.scaleLineWidth : 0, + lineColor: this.options.scaleLineColor, + angleLineColor : this.options.angleLineColor, + angleLineWidth : (this.options.angleShowLineOut) ? this.options.angleLineWidth : 0, + angleLineInterval: (this.options.angleLineInterval) ? this.options.angleLineInterval : 1, + // Point labels at the edge of each line + pointLabelFontColor : this.options.pointLabelFontColor, + pointLabelFontSize : this.options.pointLabelFontSize, + pointLabelFontFamily : this.options.pointLabelFontFamily, + pointLabelFontStyle : this.options.pointLabelFontStyle, + height : this.chart.height, + width: this.chart.width, + xCenter: this.chart.width/2, + yCenter: this.chart.height/2, + ctx : this.chart.ctx, + templateString: this.options.scaleLabel, + labels: data.labels, + valuesCount: data.datasets[0].data.length + }); + + this.scale.setScaleSize(); + this.updateScaleRange(data.datasets); + this.scale.buildYLabels(); + }, + updateScaleRange: function(datasets){ + var valuesArray = (function(){ + var totalDataArray = []; + helpers.each(datasets,function(dataset){ + if (dataset.data){ + totalDataArray = totalDataArray.concat(dataset.data); + } + else { + helpers.each(dataset.points, function(point){ + totalDataArray.push(point.value); + }); + } + }); + return totalDataArray; + })(); + + + var scaleSizes = (this.options.scaleOverride) ? + { + steps: this.options.scaleSteps, + stepValue: this.options.scaleStepWidth, + min: this.options.scaleStartValue, + max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) + } : + helpers.calculateScaleRange( + valuesArray, + helpers.min([this.chart.width, this.chart.height])/2, + this.options.scaleFontSize, + this.options.scaleBeginAtZero, + this.options.scaleIntegersOnly + ); + + helpers.extend( + this.scale, + scaleSizes + ); + + }, + addData : function(valuesArray,label){ + //Map the values array for each of the datasets + this.scale.valuesCount++; + helpers.each(valuesArray,function(value,datasetIndex){ + var pointPosition = this.scale.getPointPosition(this.scale.valuesCount, this.scale.calculateCenterOffset(value)); + this.datasets[datasetIndex].points.push(new this.PointClass({ + value : value, + label : label, + datasetLabel: this.datasets[datasetIndex].label, + x: pointPosition.x, + y: pointPosition.y, + strokeColor : this.datasets[datasetIndex].pointStrokeColor, + fillColor : this.datasets[datasetIndex].pointColor + })); + },this); + + this.scale.labels.push(label); + + this.reflow(); + + this.update(); + }, + removeData : function(){ + this.scale.valuesCount--; + this.scale.labels.shift(); + helpers.each(this.datasets,function(dataset){ + dataset.points.shift(); + },this); + this.reflow(); + this.update(); + }, + update : function(){ + this.eachPoints(function(point){ + point.save(); + }); + this.reflow(); + this.render(); + }, + reflow: function(){ + helpers.extend(this.scale, { + width : this.chart.width, + height: this.chart.height, + size : helpers.min([this.chart.width, this.chart.height]), + xCenter: this.chart.width/2, + yCenter: this.chart.height/2 + }); + this.updateScaleRange(this.datasets); + this.scale.setScaleSize(); + this.scale.buildYLabels(); + }, + draw : function(ease){ + var easeDecimal = ease || 1, + ctx = this.chart.ctx; + this.clear(); + this.scale.draw(); + + helpers.each(this.datasets,function(dataset){ + + //Transition each point first so that the line and point drawing isn't out of sync + helpers.each(dataset.points,function(point,index){ + if (point.hasValue()){ + point.transition(this.scale.getPointPosition(index, this.scale.calculateCenterOffset(point.value)), easeDecimal); + } + },this); + + + + //Draw the line between all the points + ctx.lineWidth = this.options.datasetStrokeWidth; + ctx.strokeStyle = dataset.strokeColor; + ctx.beginPath(); + helpers.each(dataset.points,function(point,index){ + if (index === 0){ + ctx.moveTo(point.x,point.y); + } + else{ + ctx.lineTo(point.x,point.y); + } + },this); + ctx.closePath(); + ctx.stroke(); + + ctx.fillStyle = dataset.fillColor; + if(this.options.datasetFill){ + ctx.fill(); + } + //Now draw the points over the line + //A little inefficient double looping, but better than the line + //lagging behind the point positions + helpers.each(dataset.points,function(point){ + if (point.hasValue()){ + point.draw(); + } + }); + + },this); + + } + + }); + + + + + +}).call(this); + + +var scrum_cnt = 0; \ No newline at end of file diff --git a/addons/project_scrum/static/src/js/scrum_dashboard.js b/addons/project_scrum/static/src/js/scrum_dashboard.js new file mode 100755 index 00000000..c16d5c5f --- /dev/null +++ b/addons/project_scrum/static/src/js/scrum_dashboard.js @@ -0,0 +1,321 @@ +flectra.define('project_scrum.dashboard', function(require) { + "use strict"; + + var core = require('web.core'); + var field_utils = require('web.field_utils'); + var KanbanView = require('web.KanbanView'); + var KanbanModel = require('web.KanbanModel'); + var KanbanRenderer = require('web.KanbanRenderer'); + var KanbanController = require('web.KanbanController'); + var data = require('web.data'); + var view_registry = require('web.view_registry'); + var QWeb = core.qweb; + + var _t = core._t; + var _lt = core._lt; + + var ScrumDashboardRenderer = KanbanRenderer.extend({ + events: _.extend({}, KanbanRenderer.prototype.events, { + 'click .total-sprints': 'on_sprints_click', + 'click .total-tasks': 'on_tasks_click', + 'click .total-stories': 'on_stories_click', + 'click .total-projects': 'on_projects_click', + }), + + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Notifies the controller that the target has changed. + * + * @private + * @param {string} target_name the name of the changed target + * @param {string} value the new value + */ + _notifyTargetChange: function (target_name, value) { + this.trigger_up('dashboard_edit_target', { + target_name: target_name, + target_value: value, + }); + }, + + fetch_data: function() { + // Overwrite this function with useful data + return $.when(); + }, + /** + * @override + * @private + * @returns {Deferred} + */ + _render: function() { + var super_render = this._super; + var self = this; + self._rpc({ + route: '/project_scrum/get_sprints_data', + params: {},}).done(function + (result) { + if (result) { + $('#total_projects').html("

" + result.total_projects + "

"); + $('#total_sprints').html("

" + result.total_sprints + "

"); + $('#total_stories').html("

" + result.total_stories + "

"); + $('#total_tasks').html("

" + result.total_tasks + "

"); + } + }); + self.render_line_chart(); + return this.fetch_data().then(function(result) { + var scrum_dashboard_view = QWeb.render('ScrumDashboard'); + super_render.call(self); + $(scrum_dashboard_view).prependTo(self.$el); + }); + }, + + load_sprint_burndown_chart: function(result) { + var sprint_labels = [], + sprint_velocities = [], + sprint_rates = []; + for (var key in result) { + if (result[key]) { + sprint_labels.push(result[key].sprint_seq); + sprint_velocities.push(result[key].velocity); + sprint_rates.push(result[key].per); + } + } + try { + var sprint_burndown_chart = $("#scrum_chart")[0].getContext("2d"); + sprint_burndown_chart.canvas.height = 55; + + // This will get the first returned node in the jQuery collection. + var sprint_chart = new Chart(sprint_burndown_chart); + + var sprint_chart_data = { + labels: sprint_labels, + datasets: [{ + label: "Success (%)", + fillColor: "rgb(243, 156, 18)", + strokeColor: "rgb(243, 156, 18)", + pointColor: "rgb(243, 156, 18)", + pointStrokeColor: "#c1c7d1", + pointHighlightFill: "#fff", + pointHighlightStroke: "rgb(243, 156, 18)", + data: sprint_velocities + }, + { + label: "Estimated Velocity", + fillColor: "rgba(60,141,188,0.9)", + strokeColor: "rgba(60,141,188,0.8)", + pointColor: "#3b8bba", + pointStrokeColor: "rgba(60,141,188,1)", + pointHighlightFill: "#fff", + pointHighlightStroke: "rgba(60,141,188,1)", + data: sprint_rates + }, + ] + }; + + var sprint_chart_options = { + //Boolean - If we should show the scale at all + showScale: true, + //Boolean - Whether grid lines are shown across the chart + scaleShowGridLines: false, + //String - Colour of the grid lines + scaleGridLineColor: "rgba(0,0,0,.05)", + //Number - Width of the grid lines + scaleGridLineWidth: 1, + //Boolean - Whether to show horizontal lines (except X axis) + scaleShowHorizontalLines: true, + //Boolean - Whether to show vertical lines (except Y axis) + scaleShowVerticalLines: true, + //Boolean - Whether the line is curved between points + bezierCurve: true, + //Number - Tension of the bezier curve between points + bezierCurveTension: 0.3, + //Boolean - Whether to show a dot for each point + pointDot: false, + //Number - Radius of each point dot in pixels + pointDotRadius: 4, + //Number - Pixel width of point dot stroke + pointDotStrokeWidth: 1, + //Number - amount extra to add to the radius to cater for hit detection outside the drawn point + pointHitDetectionRadius: 20, + //Boolean - Whether to show a stroke for datasets + datasetStroke: true, + //Number - Pixel width of dataset stroke + datasetStrokeWidth: 2, + //Boolean - Whether to fill the dataset with a color + datasetFill: true, + //String - A legend template + legendTemplate: "
    -legend\"><% for (var i=0; i
  • \"><%=datasets[i].label%>
  • <%}%>
", + //Boolean - whether to maintain the starting aspect ratio or not when responsive, if set to false, will take up entire container + maintainAspectRatio: true, + //Boolean - whether to make the chart responsive to window resizing + responsive: true + }; + + //Create the line chart + sprint_chart.Line(sprint_chart_data, sprint_chart_options); + } catch (e) { + console.log("Something went wrong...", e); + } + }, + + render_line_chart: function() { + var self = this; + self._rpc({ + route: '/project_scrum/get_line_chart_data', + params: {},}).done(function(result) { + if (result) { + self.load_sprint_burndown_chart(result); // load sprint burndown chart + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + on_sprints_click: function(event) { + var self = this, + context = {}; + context.search_default_group_by_project = 1; + return self._rpc({ + route: "/web/action/load", + params: { + action_id: "project_scrum.action_project_sprint" + },}).done(function(result) { + if (result) { + result.views = [ + [false, 'list'], + [false, 'form'] + ]; + result.context = context; + return self.do_action(result); + }; + }) + }, + + on_tasks_click: function(event) { + var self = this, + context = {}; + context.search_default_group_by_sprint = 1; + return self._rpc({ + route:"/web/action/load", + params: { + action_id: "project.action_view_task" + },}).done(function(result) { + if (result) { + result.views = [ + [false, 'list'], + [false, 'form'] + ]; + result.context = context; + return self.do_action(result); + }; + }) + }, + + on_stories_click: function() { + var self = this, + context = {}; + context.search_default_group_by_sprint = 1; + return self._rpc({route:"/web/action/load", + params: { + action_id: "project_scrum.action_project_story_sprint" + },}).done(function(result) { + if (result) { + result.views = [ + [false, 'list'], + [false, 'form'] + ]; + result.context = context; + return self.do_action(result); + }; + }) + }, + + on_projects_click: function() { + var self = this, + context = {}; + context.search_default_Manager = 1; + return self._rpc({ + route: "/web/action/load", + params:{ + action_id: "project.open_view_project_all_config" + }, + }).done(function(result) { + if (result) { + result.views = [ + [false, 'list'], + [false, 'form'] + ]; + result.context = context; + return self.do_action(result); + }; + }) + }, + }); + + var ScrumDashboardModel = KanbanModel.extend({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + init: function () { + this.dashboardValues = {}; + this._super.apply(this, arguments); + }, + + /** + * @override + */ + get: function (localID) { + var result = this._super.apply(this, arguments); + return result; + }, + + + /** + * @Å“verride + * @returns {Deferred} + */ + load: function () { + return this._super.apply(this, arguments); + }, + /** + * @Å“verride + * @returns {Deferred} + */ + reload: function () { + return this._super.apply(this, arguments); + }, + }); + + var ScrumDashboardController = KanbanController.extend({ + + }); + + var MyMainDashboard = KanbanView.extend({ + config: _.extend({}, KanbanView.prototype.config, { + Model: ScrumDashboardModel, + Renderer: ScrumDashboardRenderer, + Controller: ScrumDashboardController, + }), + display_name: _lt('Dashboard'), + icon: 'fa-dashboard', + searchview_hidden: true, + }); + + view_registry.add('scrum_dashboard', MyMainDashboard); + + return { + Model: ScrumDashboardModel, + Renderer: ScrumDashboardRenderer, + Controller: ScrumDashboardController, + }; +}); + \ No newline at end of file diff --git a/addons/project_scrum/static/src/less/dashboard.less b/addons/project_scrum/static/src/less/dashboard.less new file mode 100755 index 00000000..320fc5c1 --- /dev/null +++ b/addons/project_scrum/static/src/less/dashboard.less @@ -0,0 +1,191 @@ +@font-face { + font-family: customFont; + src: url('../fonts/sourcesanspro/SourceSansPro-Regular.ttf'); +} + +.custom-dashboard-margin { + margin: 2px; + width: 25%; + background-color: #99a3a5; +} + +.o_form_view.o_form_editable .o_address_format .o_address_city { + width: 31%; +} + +canvas#sprint_piechart { + margin-top: 50px; +} + +.bg-sprint { + background-color: #00A65A !important; + color: #4C4C4C !important; +} + +.bg-task { + background-color: #E3695A !important; + color: #4C4C4C !important; +} + +.bg-story { + background-color: #F39C12 !important; + color: #4C4C4C !important; +} + +.bg-projects { + background-color: #00C0EF !important; + color: #4C4C4C !important; +} + + +#myTable > thead { + background-color: darkgray; + font-size: 16px; +} + +#myTable > td { + font-size: 13px; +} + +.myFont { + font-family: customFont; +} + +.kanban-color { + cursor: pointer; + background-color: #7c7bad; +} + +div.velocity-div { + font-size: 15px; + &:before { + content: ''; + width: 21px; + height: 21px; + border: 1px solid #e1e1e1; + position: absolute; + left: 41px; + } +} + +.list-color { + content: ''; + width: 15px; + height: 15px; + border: 1px solid #e1e1e1; + position: absolute; + left: 75px; +} + +#chart_names { + li { + padding-bottom: 10px; + } +} + +.blue:before { + background-color: #4E8EAC; +} + +.orange:before { + background-color: #F39C12; +} + +.big { + font-size: 18px; + font-weight: bolder; +} + +.panel-blue { + border-color: #337ab7; +} + +.panel-green { + border-color: #5cb85c; +} + +.panel-orange { + border-color: #f0ad4e; +} + +.panel-red { + border-color: #d9534f; +} + +.panel { + margin-bottom: 20px; + background-color: #fff; + border: 1px solid transparent; + -webkit-box-shadow: 0 1px 1px rgba(0,0,0,.05); + box-shadow: 0 1px 1px rgba(0,0,0,.05); + cursor: pointer; +} + +.panel-heading { + padding: 10px 15px; + border-bottom: 1px solid transparent; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border-radius: 5px; +} + +.panel-blue > .panel-heading { + color: #fff; + background-color: #337ab7; + border-color: #337ab7; +} + +.panel-green > .panel-heading { + color: #fff; + background-color: #5cb85c; + border-color: #5cb85c; +} + +.panel-orange > .panel-heading { + color: #fff; + background-color: #f0ad4e; + border-color: #f0ad4e; +} + +.panel-red > .panel-heading { + color: #fff; + background-color: #d9534f; + border-color: #d9534f; +} + +.custom-bg { + background-color: #fff; + width:100%; +} + +.panel-heading:hover { + color: #ebebeb; +} + +#sprint_id { + margin-left: 25px; + width: 92%; +} + +.o_kanban_view .o_kanban_record.oe_kanban_global_click, .o_kanban_view .o_kanban_record.oe_kanban_global_click_edit { + cursor: pointer; + font-size: 15px; +} + +.bold { + font-weight: bold; +} + +/*.o_view_manager_content { + margin-top: 130px; +}*/ + +.sprint_kanban { + position: relative; + min-width: 49% !important; + margin: 5px; +} + +.o_kanban_view.o_kanban_ungrouped .sprint_kanban.o_kanban_record{ + margin : 4px !important; +} diff --git a/addons/project_scrum/static/src/xml/scrum_dashboard.xml b/addons/project_scrum/static/src/xml/scrum_dashboard.xml new file mode 100755 index 00000000..2c4f3b21 --- /dev/null +++ b/addons/project_scrum/static/src/xml/scrum_dashboard.xml @@ -0,0 +1,96 @@ + + diff --git a/addons/project_scrum/tests/__init__.py b/addons/project_scrum/tests/__init__.py new file mode 100755 index 00000000..4ae6d49f --- /dev/null +++ b/addons/project_scrum/tests/__init__.py @@ -0,0 +1,8 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from .test_scrum_common import TestScrumCommon +from .test_scrum_team import TestScrumTeam +from .test_scrum_sprint import TestScrumSprint +from .test_scrum_task import TestScrumTask +from .test_scrum_release_planning import TestScrumReleasePlanning +from .test_scrum_story import TestScrumStory \ No newline at end of file diff --git a/addons/project_scrum/tests/test_scrum_common.py b/addons/project_scrum/tests/test_scrum_common.py new file mode 100644 index 00000000..a2c5b76e --- /dev/null +++ b/addons/project_scrum/tests/test_scrum_common.py @@ -0,0 +1,79 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from flectra.tests import common + + +class TestScrumCommon(common.SavepointCase): + def setUp(self): + super(TestScrumCommon, self).setUp() + + self.teams = [ + self.env.ref('project_scrum.project_team_1'), + self.env.ref('project_scrum.project_team_2'), + self.env.ref('project_scrum.project_team_3') + ] + + self.user_roles = [ + self.env.ref('project_scrum.user_role_1'), + self.env.ref('project_scrum.user_role_2'), + self.env.ref('project_scrum.user_role_3') + ] + + self.sprints = [ + self.env.ref('project_scrum.project_sprint_1'), + self.env.ref('project_scrum.project_sprint_2'), + self.env.ref('project_scrum.project_sprint_3'), + self.env.ref('project_scrum.project_sprint_4'), + self.env.ref('project_scrum.project_sprint_5') + ] + + self.project_tasks = [ + self.env.ref('project_scrum.project_task_1'), + self.env.ref('project_scrum.project_task_2'), + self.env.ref('project_scrum.project_task_3'), + self.env.ref('project_scrum.project_task_4'), + self.env.ref('project_scrum.project_task_6'), + self.env.ref('project_scrum.project_task_7'), + self.env.ref('project_scrum.project_task_8'), + self.env.ref('project_scrum.project_task_9'), + self.env.ref('project_scrum.project_task_10'), + self.env.ref('project_scrum.project_task_11'), + self.env.ref('project_scrum.project_task_12'), + self.env.ref('project_scrum.project_task_13'), + self.env.ref('project_scrum.project_task_14') + ] + + self.users = [ + self.env.ref('project_scrum.user_1'), + self.env.ref('project_scrum.user_2'), + self.env.ref('project_scrum.user_3') + ] + + self.release_plans = [ + self.env.ref('project_scrum.release_planning_1'), + self.env.ref('project_scrum.release_planning_2'), + self.env.ref('project_scrum.release_planning_3'), + self.env.ref('project_scrum.release_planning_4'), + self.env.ref('project_scrum.release_planning_5') + ] + + self.story_priorities = [ + self.env.ref('project_scrum.story_priority_1'), + self.env.ref('project_scrum.story_priority_2'), + self.env.ref('project_scrum.story_priority_3') + ] + + self.story_types = [ + self.env.ref('project_scrum.story_type_1'), + self.env.ref('project_scrum.story_type_2') + ] + + self.stories = [ + self.env.ref('project_scrum.project_story_1'), + self.env.ref('project_scrum.project_story_2'), + self.env.ref('project_scrum.project_story_3') + ] + + self.project = self.env.ref('project_scrum.demo_project_1') + + self.sprint_planning_line = self.env['sprint.planning.line'] diff --git a/addons/project_scrum/tests/test_scrum_release_planning.py b/addons/project_scrum/tests/test_scrum_release_planning.py new file mode 100644 index 00000000..e22b870a --- /dev/null +++ b/addons/project_scrum/tests/test_scrum_release_planning.py @@ -0,0 +1,23 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from logging import info + +from .test_scrum_common import TestScrumCommon + + +class TestScrumReleasePlanning(TestScrumCommon): + def setUp(self): + super(TestScrumReleasePlanning, self).setUp() + + def test_scrum_release_planning(self): + if not self.release_plans: + raise AssertionError( + 'Error in data. Please check for release planning.') + info('Details of release planning:') + for plan in self.release_plans: + info('Release Plan : %s' % plan.name) + info(' Release Date : %s' % plan.release_date) + info(' Sprint : %s' % plan.sprint_id.name) + info(' Branch : %s' % plan.branch_id.name) + info(' Priority : %s' % plan.priority) + info(' Sprint Velocity : %s' % plan.velocity) diff --git a/addons/project_scrum/tests/test_scrum_sprint.py b/addons/project_scrum/tests/test_scrum_sprint.py new file mode 100644 index 00000000..13e96b88 --- /dev/null +++ b/addons/project_scrum/tests/test_scrum_sprint.py @@ -0,0 +1,63 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from logging import info + +from .test_scrum_common import TestScrumCommon + + +class TestScrumSprint(TestScrumCommon): + def setUp(self): + super(TestScrumSprint, self).setUp() + + def test_scrum_sprint(self): + # self.check_user_roles(self.user_roles) + self.check_sprint(self.sprints) + + def check_user_roles(self, roles): + if not roles: + raise AssertionError('Error in data. Please check user role data.') + info('Details of Roles :') + info('Code : name') + for role in roles: + info(' %s : %s' % (role.code, role.name)) + + def check_sprint(self, sprints): + if not sprints: + raise AssertionError('Error in data. Please check sprint data.') + info('Details of Sprints :') + for sprint in sprints: + info('Sprint : %s' % sprint.name) + info(' Sequence : %s' % sprint.sprint_seq) + info(' Project : %s' % sprint.project_id.name) + info(' Team : %s' % sprint.team_id.name) + info(' Date : %s - %s' % (sprint.start_date, sprint.end_date)) + info(' Planning Meeting Date : %s' % sprint.meeting_date) + info(' Goal of Sprint : %s' % sprint.goal_of_sprint) + info(' Branch : %s' % sprint.branch_id.name) + info(' Daily Scrum Time : %f' % sprint.hour) + info(' Duration (In Days): %d' % sprint.duration) + info(' Estimated Velocity : %d' % sprint.estimated_velocity) + info(' Actual Velocity : %d' % sprint.actual_velocity) + info(' Business Days : %d' % sprint.working_days) + info(' Holiday (Hours / Days) : %s' % sprint.holiday_type) + info(' Holiday Count : %f' % sprint.holiday_count) + info(' Productivity Hours : %s' % sprint.productivity_hours) + info(' Productivity : %f' % sprint.productivity_per) + + lines = self.sprint_planning_line.search( + [('sprint_id', '=', sprint.id)]) + if not lines: + raise AssertionError( + 'Error in data. Please check spring planning lines ' + 'for sprint %s' % sprint.name) + + info(' Spring Planning :') + for line in lines: + info(' User : %s' % line.user_id.name) + info(' Role : %s' % line.role_id.name) + info(' Available : %s' % line.available_per) + info(' Productivity Hour : ' + '%s' % line.productivity_hours) + info(' Sprint hour : %s' % line.sprint_hours) + info(' Off Hour : %s' % line.off_hours) + info(' Total Hour: %s' % line.total_hours) diff --git a/addons/project_scrum/tests/test_scrum_story.py b/addons/project_scrum/tests/test_scrum_story.py new file mode 100644 index 00000000..64d80d67 --- /dev/null +++ b/addons/project_scrum/tests/test_scrum_story.py @@ -0,0 +1,57 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from logging import info + +from .test_scrum_common import TestScrumCommon + + +class TestScrumStory(TestScrumCommon): + def setUp(self): + super(TestScrumStory, self).setUp() + + def test_scrum_story(self): + if not self.story_priorities: + raise AssertionError( + 'Error in data. Please check for story priorities.') + self.check_priority(self.story_priorities) + + if not self.story_types: + raise AssertionError( + 'Error in data. Please check for story types.') + self.check_type(self.story_types) + + if not self.stories: + raise AssertionError( + 'Error in data. Please check for story data.') + self.check_story(self.stories) + + def check_priority(self, priorities): + info('Details of Priorities :') + info(' Code : Name') + for priority in priorities: + info(' %s : %s' % (priority.code, priority.name)) + + def check_type(self, types): + info('Details of Story Types :') + info(' Code : Name') + for type in types: + info(' %s : %s' % (type.code, type.name)) + + def check_story(self, stories): + info('Details of Stories:') + for story in stories: + if not story.name: + raise AssertionError( + 'Error in data. Please check for story name.') + info('Story : %s' % story.name) + if not story.sprint_id: + raise AssertionError( + 'Error in data. Please check for story sprint value.') + info(' Sprint : %s' % story.sprint_id.name) + info(' Priority : %s' % story.priority_id.name) + info(' Owner : %s' % story.owner_id.name) + info(' Branch : %s' % story.branch_id.name) + info(' Type : %s' % story.story_type_id.name) + info(' Actual Velocity : %d' % story.actual_velocity) + info(' Estimated Velocity : %s' % story.estimated_velocity ) + info(' Description : %s' % story.description) diff --git a/addons/project_scrum/tests/test_scrum_task.py b/addons/project_scrum/tests/test_scrum_task.py new file mode 100644 index 00000000..18f35a23 --- /dev/null +++ b/addons/project_scrum/tests/test_scrum_task.py @@ -0,0 +1,43 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from logging import info + +from .test_scrum_common import TestScrumCommon + + +class TestScrumTask(TestScrumCommon): + def setUp(self): + super(TestScrumTask, self).setUp() + + def test_scrum_task(self): + if not self.project_tasks: + raise AssertionError('Error in data. Please Check Project Tasks.') + info('Details of tasks:') + for task in self.project_tasks: + if not task.name: + raise AssertionError( + 'Error in data. Please Check Project Tasks Name.') + info('Details of : %s' % task.name) + if not task.project_id: + raise AssertionError( + 'Error in data. Please Check Project Tasks Project.') + info(' Project : %s' % task.project_id.name) + if not task.sprint_id: + raise AssertionError( + 'Error in data. Please Check Project Tasks Sprint.') + info(' Sprint : %s' % task.sprint_id.name) + info(' Assigned to : %s' % task.user_id.name) + info(' Company : %s' % task.company_id.name) + if not task.start_date and task.end_date: + raise AssertionError( + 'Error in data. Please Check Project Tasks Date.') + info(' Date : %s - %s' % (task.start_date, task.end_date)) + info(' Actual End Date : %s' % task.actual_end_date) + info(' Deadline : %s' % task.date_deadline) + info(' Reference : %s' % task.task_seq) + info(' Branch : %s' % task.branch_id.name) + info(' Story : %s' % task.story_id.name) + info(' Velocity : %d' % task.velocity) + info(' Release Planning : %s' % task.release_planning_id.name) + info(' Priority : %s' % task.priority) + info(' Description : %s' % task.description) diff --git a/addons/project_scrum/tests/test_scrum_team.py b/addons/project_scrum/tests/test_scrum_team.py new file mode 100644 index 00000000..230d6c5c --- /dev/null +++ b/addons/project_scrum/tests/test_scrum_team.py @@ -0,0 +1,31 @@ +# Part of Flectra. See LICENSE file for full copyright and licensing details. + +from logging import info + +from .test_scrum_common import TestScrumCommon + + +class TestScrumTeam(TestScrumCommon): + def setUp(self): + super(TestScrumTeam, self).setUp() + + def test_scrum_team(self): + if not self.teams: + raise AssertionError( + 'Error in data. Please check for Team in Scrum.') + info('Details of Teams of Scrum.....') + for team in self.teams: + if not (team.name and team.project_id): + raise AssertionError( + 'Error in data. Please check for Team in Scrum.') + + info('Team Name : %s' % team.name) + info(' Description : %s' % team.description) + info(' Strength : %s' % team.strength) + info(' Project : %s' % team.project_id.name) + info(' Scrum Master : %s' % team.master_id.name) + if team.member_ids: + info(' Members : %d' % len(team.member_ids)) + for member in team.member_ids: + info(' %s' % member.name) + info(' Branch : %s' % team.branch_id.name) diff --git a/addons/project_scrum/views/assets.xml b/addons/project_scrum/views/assets.xml new file mode 100755 index 00000000..67ca0e63 --- /dev/null +++ b/addons/project_scrum/views/assets.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/addons/project_scrum/views/cron_view.xml b/addons/project_scrum/views/cron_view.xml new file mode 100755 index 00000000..eb212b00 --- /dev/null +++ b/addons/project_scrum/views/cron_view.xml @@ -0,0 +1,13 @@ + + + + Sprint State + 1 + days + -1 + + code + model.check_sprint_state() + + + \ No newline at end of file diff --git a/addons/project_scrum/views/other_views.xml b/addons/project_scrum/views/other_views.xml new file mode 100755 index 00000000..149999f5 --- /dev/null +++ b/addons/project_scrum/views/other_views.xml @@ -0,0 +1,162 @@ + + + + + Project inherited form + project.project + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Users inherited form + res.users + + + + + + + + + + + Tasks inherited form + project.task + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tasks inherited search + project.task + + + + + + + + + + + + + + Tasks inherited tree + project.task + + + + + + + + + + + + + + + Tasks + project.task + kanban,tree,form + { + 'search_default_sprint_id': [active_id], + 'default_sprint_id': active_id, + } + + + + + + + Resource Calendar + resource.calendar + + + + + + + + + + + + \ No newline at end of file diff --git a/addons/project_scrum/views/project_sprint_views.xml b/addons/project_scrum/views/project_sprint_views.xml new file mode 100755 index 00000000..3384ef5b --- /dev/null +++ b/addons/project_scrum/views/project_sprint_views.xml @@ -0,0 +1,404 @@ + + + + project.sprint + project.sprint + +
+
+
+ +
+
+
+

+ +

+
+
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + + project.sprint.tree + project.sprint + + + + + + + + + + + + + + + + project.sprint.search + project.sprint + + + + + + + + + + + + + + + + + + project.sprint.kanban + project.sprint + + + + + + + + + +
+
+
+
+
+ + + + + + +
+
+
+ +
+
+
+
+ +
+
+
+
    +
+
+
+
+
+ +
+
+
+
+ +
+
+
+ Edit +
+
+
+
    +
+
+
+ +
+
+
+
+
+
+ + + + project.sprint.calendar + project.sprint + + + + + + + + + + + Sprint + project.sprint + form + kanban,form,tree,calendar + + + + + + + + + + + + user.role + user.role + + + + + + + + + + user.role + user.role + +
+ + + + + + +
+
+
+ + + Roles + user.role + form + form,tree + + + + + + + mail.followers + mail.followers + + + + + + + + +
\ No newline at end of file diff --git a/addons/project_scrum/views/project_story_views.xml b/addons/project_scrum/views/project_story_views.xml new file mode 100755 index 00000000..3eefe0e6 --- /dev/null +++ b/addons/project_scrum/views/project_story_views.xml @@ -0,0 +1,187 @@ + + + + project.story + project.story + +
+
+
+ +
+
+
+

+ +

+
+ + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + + project.story + project.story + + + + + + + + + + + + + + project.story + project.story + + + + + + + + + + + + + + + Project Story + project.story + form + form,tree + + + + + + + + + + story.priority + story.priority + + + + + + + + + + story.priority + story.priority + +
+ + + + + + +
+ + +
+
+
+
+ + + Story Priorities + story.priority + form + form,tree + + + + + + + + + story.type + story.type + + + + + + + + + + story.type + story.type + +
+ + + + + + +
+ + +
+
+
+
+ + + Story Types + story.type + form + form,tree + + + + + +
\ No newline at end of file diff --git a/addons/project_scrum/views/project_team_views.xml b/addons/project_scrum/views/project_team_views.xml new file mode 100755 index 00000000..922db188 --- /dev/null +++ b/addons/project_scrum/views/project_team_views.xml @@ -0,0 +1,61 @@ + + + + project.team + project.team + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + +
+
+ + +
+
+
+
+ + + project.team + project.team + + + + + + + + + + + + Project Team + project.team + form + form,tree + + + + + +
\ No newline at end of file diff --git a/addons/project_scrum/views/release_planning_views.xml b/addons/project_scrum/views/release_planning_views.xml new file mode 100755 index 00000000..07bda89d --- /dev/null +++ b/addons/project_scrum/views/release_planning_views.xml @@ -0,0 +1,73 @@ + + + + release.planning.form + release.planning + +
+ + + + + + + + + + + + + + + +
+ + +
+
+
+
+ + + release.planning.tree + release.planning + + + + + + + + + + + + + + release.planning.calendar + release.planning + + + + + + + + + + + Release Planning + release.planning + form + tree,form,calendar + + + + + +
\ No newline at end of file diff --git a/addons/project_scrum/views/retrospective_method_views.xml b/addons/project_scrum/views/retrospective_method_views.xml new file mode 100755 index 00000000..323c762a --- /dev/null +++ b/addons/project_scrum/views/retrospective_method_views.xml @@ -0,0 +1,56 @@ + + + + Retrospective Method + retrospective.method + +
+ + + + + + + + + + + + +
+ + +
+
+
+
+ + + Retrospective Method + retrospective.method + + + + + + + + + Retrospective Method + retrospective.method + form + form,tree + + + + + + + +
\ No newline at end of file diff --git a/addons/project_scrum/views/retrospective_views.xml b/addons/project_scrum/views/retrospective_views.xml new file mode 100755 index 00000000..03bd2041 --- /dev/null +++ b/addons/project_scrum/views/retrospective_views.xml @@ -0,0 +1,77 @@ + + + + Retrospective + retrospective + +
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ + + Retrospective + retrospective + + + + + + + + + + + + + + Retrospective + retrospective + form + form,tree + + + + + Retrospective Line + retrospective.lines + + + + + + + + + +
\ No newline at end of file