From 9a253fad349bd12b3f3c4bf32bea3a4e1db07344 Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Mon, 6 Feb 2017 19:50:38 -0600 Subject: [PATCH 01/33] add in support for overrides by course dept and number --- category_settings.php | 25 ++- category_settings_form.php | 15 ++ classes/input_handler.php | 226 ++++++++++++++++++++++++++-- classes/url_generator.php | 7 +- classes/utility.php | 55 ++++++- db/upgrade.php | 58 +++++++ delete_coursematch_setting_form.php | 49 ++++++ lang/en/local_cas_help_links.php | 4 + renderer.php | 13 ++ version.php | 4 +- 10 files changed, 432 insertions(+), 24 deletions(-) create mode 100644 db/upgrade.php create mode 100644 delete_coursematch_setting_form.php diff --git a/category_settings.php b/category_settings.php index 3949d1c..4ebb269 100644 --- a/category_settings.php +++ b/category_settings.php @@ -26,9 +26,9 @@ $context = context_system::instance(); -global $PAGE, $USER, $CFG; +global $PAGE, $CFG; -$PAGE->set_url($CFG->wwwroot . '/local/cas_help_links/user_settings.php'); +$PAGE->set_url($CFG->wwwroot . '/local/cas_help_links/category_settings.php'); $PAGE->set_context($context); require_login(); @@ -42,8 +42,20 @@ $submit_success = false; if ($data = data_submitted() and confirm_sesskey()) { + try { - $submit_success = \local_cas_help_links_input_handler::handle_category_settings_input($data); + // @TODO - is there a better way of determining which form was submitted? + + // if category settings page was submitted + if (property_exists($data, '_qf__cas_cat_form')) { + $submit_success = \local_cas_help_links_input_handler::handle_category_settings_input($data); + } + + // if coursematch delete form was submitted + else if (property_exists($data, '_qf__cas_delete_coursematch_form')) { + $submit_success = \local_cas_help_links_input_handler::handle_coursematch_deletion_input($data); + } + } catch (\Exception $e) { $submit_success = false; } @@ -60,6 +72,8 @@ // get all data $categorySettingsData = \local_cas_help_links_utility::get_all_category_settings(); +$coursematchSettingsData = \local_cas_help_links_utility::get_all_coursematch_settings(); + // PAGE RENDERING STUFF $PAGE->set_context($context); $PAGE->requires->jquery(); @@ -78,4 +92,9 @@ echo $OUTPUT->notification(get_string('submit_success', 'local_cas_help_links'), 'notifysuccess'); } echo $output->cas_category_links($categorySettingsData); + +foreach ($coursematchSettingsData as $coursematch) { + echo $output->cas_delete_coursematch($coursematch); +} + echo $output->footer(); diff --git a/category_settings_form.php b/category_settings_form.php index 60f3b8b..aef6d7e 100644 --- a/category_settings_form.php +++ b/category_settings_form.php @@ -43,6 +43,7 @@ function definition() { 'class' => 'url-input' ); $catheader = get_string('category_header', 'local_cas_help_links'); + $coursematchheader = get_string('course_match_header', 'local_cas_help_links'); $hidecatlinks = get_string('hide_category_links', 'local_cas_help_links'); $categories = $this->_customdata['categorySettingsData']; @@ -64,6 +65,20 @@ function definition() { $mform->setDefault($category['link_input_name'], $category['link_url']); $mform->setType($category['link_input_name'], PARAM_TEXT); } + + $mform->addElement('header', 'course_match_preferences', $coursematchheader); + + $mform->addElement('text', 'coursematch_dept', get_string('coursematch_dept', 'local_cas_help_links'), []); + $mform->setDefault('coursematch_dept', ''); + $mform->setType('coursematch_dept', PARAM_TEXT); + + $mform->addElement('text', 'coursematch_number', get_string('coursematch_number', 'local_cas_help_links'), []); + $mform->setDefault('coursematch_number', ''); + $mform->setType('coursematch_number', PARAM_TEXT); + + $mform->addElement('text', 'coursematch_link', get_string('coursematch_link', 'local_cas_help_links'), $lattributes); + $mform->setDefault('coursematch_link', ''); + $mform->setType('coursematch_link', PARAM_TEXT); $this->add_action_buttons(); } diff --git a/classes/input_handler.php b/classes/input_handler.php index f3122a3..886826d 100644 --- a/classes/input_handler.php +++ b/classes/input_handler.php @@ -29,12 +29,29 @@ public static function handle_user_settings_input($post_data, $user_id) { } /** - * Accepts a given array of posted category link setting data and persists appropriately + * Accepts a given array of posted category link setting AND course match setting data and persists appropriately * * @param array $post_data * @return boolean */ public static function handle_category_settings_input($post_data) + { + // first, handle category link inputs + self::handle_category_settings_link_input($post_data); + + // second, handle "coursematch" inputs + self::handle_category_settings_coursematch_input($post_data); + + return true; + } + + /** + * Accepts a given array of posted category link setting data and persists appropriately + * + * @param array $post_data + * @return void + */ + private static function handle_category_settings_link_input($post_data) { $link_objects = self::get_link_input_objects($post_data); @@ -51,8 +68,20 @@ public static function handle_category_settings_input($post_data) self::insert_link_record($link); } } + } - return true; + /** + * Accepts a given array of posted category setting "coursematch" data and persists appropriately + * + * @param array $post_data + * @return void + */ + private static function handle_category_settings_coursematch_input($post_data) + { + $coursematch_object = self::get_coursematch_input_object($post_data); + + if (self::coursematch_should_be_persisted($coursematch_object, true)) + self::insert_coursematch_record($coursematch_object); } /** @@ -79,6 +108,16 @@ private static function get_link_input_objects($post_data, $user_id = 0) return $link_input_objects; } + private static function get_coursematch_input_object($post_data) + { + $coursematch_input_arrays = self::get_coursematch_input_arrays($post_data); + + // combine and convert coursematch input arrays to a single object + $coursematch_input_object = self::objectify_coursematch_inputs($coursematch_input_arrays); + + return $coursematch_input_object; + } + /** * Reports whether or not the given link object should be persisted * @@ -95,6 +134,20 @@ private static function link_should_be_persisted($link, $check_for_duplicate_rec return ($link->display && ! $link->link) ? false : true; } + /** + * Reports whether or not the given coursematch object should be persisted + * + * @param object $coursematch + * @return bool + */ + private static function coursematch_should_be_persisted($coursematch) + { + if (self::identical_coursematch_exists($coursematch)) + return false; + + return ( ! $coursematch->dept || ! $coursematch->number || ! $coursematch->link) ? false : true; + } + /** * Reports whether or not identical link record(s) exist for this link object * @@ -128,6 +181,27 @@ private static function identical_link_exists($link) return ! $existing_records ? false : true; } + /** + * Reports whether or not an identical coursematch record exists for this coursematch object + * + * @param object $coursematch coursematch record to be persisted + * @return boolean + */ + private static function identical_coursematch_exists($coursematch) + { + global $DB; + + $params = [ + 'type' => 'coursematch', + 'dept' => $coursematch->dept, + 'number' => $coursematch->number, + ]; + + $existing_records = $DB->get_records(self::get_link_table_name(), $params); + + return ! $existing_records ? false : true; + } + /** * Update the given link record * @@ -160,6 +234,19 @@ private static function insert_link_record($link_record) $DB->insert_record(self::get_link_table_name(), $link_record); } + /** + * Insert the given coursematch record + * + * @param object $coursematch_record + * @return void + */ + private static function insert_coursematch_record($coursematch_record) + { + global $DB; + + $DB->insert_record(self::get_link_table_name(), $coursematch_record); + } + /** * Returns the name of the 'help links' table * @@ -193,14 +280,14 @@ private static function assign_user_to_link_objects($link_objects, $user_id) /** * Returns an array of combined, formatted link objects from the given array of individual inputs * - * @param array $link_inputs + * @param array $input_arrays * @return array */ - private static function objectify_link_inputs($link_inputs) + private static function objectify_link_inputs($input_arrays) { $output = []; - foreach ($link_inputs as $input) { + foreach ($input_arrays as $input) { $input = self::sanitize_link_input($input); // if this input has not been added to output yet @@ -209,13 +296,39 @@ private static function objectify_link_inputs($link_inputs) // otherwise, this link exists in output and needs missing field (display/link) to be updated } else { - $output[$input['id']] = self::update_link_object($output[$input['id']], $input); + $output[$input['id']] = self::update_object($output[$input['id']], $input); } } return $output; } + /** + * Returns a formatted coursematch object from the given array of individual inputs + * + * @param array $input_arrays + * @return array + */ + private static function objectify_coursematch_inputs($input_arrays) + { + $output = []; + + foreach ($input_arrays as $input) { + $input = self::sanitize_link_input($input); + + // if this input has not been added to output yet + if ( ! array_key_exists($input['id'], $output)) { + $output[$input['id']] = self::transform_coursematch_input_to_object($input); + + // otherwise, this link exists in output and needs missing field (dept/number/link) to be updated + } else { + $output[$input['id']] = self::update_object($output[$input['id']], $input); + } + } + + return current($output); + } + /** * Checks whether the given input array contains link information, * if so, asserts that the url contains an appropriate prefix @@ -258,17 +371,17 @@ private static function format_url($url) } /** - * Returns a link object with the given input property updated + * Returns an object with the given input property updated * - * @param object $link_object + * @param object $object * @param array $input * @return object */ - private static function update_link_object($link_object, $input) + private static function update_object($object, $input) { - $link_object->$input['field'] = $input['input_value']; + $object->$input['field'] = $input['input_value']; - return $link_object; + return $object; } /** @@ -291,6 +404,23 @@ private static function transform_link_input_to_object($input) return $link_object; } + /** + * Returns a formatted coursematch object from the given input array + * + * @param array $input + * @return object + */ + private static function transform_coursematch_input_to_object($input) + { + $coursematch_object = new stdClass(); + $coursematch_object->type = 'coursematch'; + $coursematch_object->dept = $input['field'] == 'dept' ? strtoupper($input['input_value']) : ''; + $coursematch_object->number = $input['field'] == 'number' ? $input['input_value'] : ''; + $coursematch_object->link = $input['field'] == 'link' ? $input['input_value'] : ''; + + return $coursematch_object; + } + /** * Returns an array of all formatted link input data * @@ -304,7 +434,7 @@ private static function get_link_input_arrays($post_data) foreach ((array) $post_data as $name => $value) { $decodedInput = self::decode_input_name($name); - if ( ! $decodedInput['is_link_input']) + if ( ! array_key_exists('is_link_input', $decodedInput) || ! $decodedInput['is_link_input']) continue; $decodedInput['input_name'] = $name; @@ -321,6 +451,31 @@ private static function get_link_input_arrays($post_data) return $output; } + /** + * Returns an array of all formatted link input data + * + * @param array $post_data + * @return array + */ + private static function get_coursematch_input_arrays($post_data) + { + $output = []; + + foreach ((array) $post_data as $name => $value) { + $decodedInput = self::decode_input_name($name); + + if ( ! array_key_exists('is_coursematch_input', $decodedInput) || ! $decodedInput['is_coursematch_input']) + continue; + + $decodedInput['input_name'] = $name; + $decodedInput['input_value'] = $value; + + $output[$name] = $decodedInput; + } + + return $output; + } + /** * Returns an encoded input name string for the given attributes * @@ -350,10 +505,16 @@ public static function decode_input_name($name) return self::decode_link_input_name($name); break; + + case 'coursematch': + return self::decode_coursematch_input_name($name); + + break; default: return [ - 'is_link_input' => false + 'is_link_input' => false, + 'is_coursematch_input' => false ]; break; @@ -382,4 +543,43 @@ public static function decode_link_input_name($name) 'field' => (string) $exploded[4], ]; } + + /** + * Returns an array of data representing given link input name + * + * @param string $name + * @return array + */ + public static function decode_coursematch_input_name($name) + { + $exploded = explode('_', $name); + + $inputId = substr($name, 0, strrpos($name,'_')); + + return [ + 'id' => $inputId, + 'is_coursematch_input' => true, + 'field' => (string) $exploded[1], + ]; + } + + /** + * Accepts a given array of posted coursematch deletion data and handles appropriately + * + * @param array $post_data + * @return boolean + */ + public static function handle_coursematch_deletion_input($post_data) + { + // @TODO - authorization here? + + global $DB; + + $DB->delete_records(self::get_link_table_name(), [ + 'type' => 'coursematch', + 'id' => $post_data->id + ]); + + return true; + } } diff --git a/classes/url_generator.php b/classes/url_generator.php index 6c504da..403a0d9 100644 --- a/classes/url_generator.php +++ b/classes/url_generator.php @@ -23,7 +23,7 @@ public static function getUrlArrayForCourse($course, $editLinkForInstructor = fa return self::getEmptyHelpUrlArray(); } else { // otherwise return link pref data - return self::getDisplayHelpUrlArray($course_id, $category_id, $primary_instructor_user_id); + return self::getDisplayHelpUrlArray($course_id, $category_id, $primary_instructor_user_id, $course->fullname); } } @@ -115,12 +115,13 @@ private static function getCategoryEditHelpUrl() * @param int $course_id * @param int $category_id * @param int $primary_instructor_user_id + * @param string $course_full_name * @return array */ - private static function getDisplayHelpUrlArray($course_id, $category_id, $primary_instructor_user_id) + private static function getDisplayHelpUrlArray($course_id, $category_id, $primary_instructor_user_id, $course_full_name) { // get appropriate pref from db - if ( ! $selectedPref = \local_cas_help_links_utility::getSelectedPref($course_id, $category_id, $primary_instructor_user_id)) { + if ( ! $selectedPref = \local_cas_help_links_utility::getSelectedPref($course_id, $category_id, $primary_instructor_user_id, $course_full_name)) { // if no pref can be resolved, return default settings using system config $urlArray = self::getDefaultHelpUrlArray(); } else { diff --git a/classes/utility.php b/classes/utility.php index 47d82f0..23f83af 100644 --- a/classes/utility.php +++ b/classes/utility.php @@ -61,6 +61,20 @@ public static function get_all_category_settings() return $transformedCategoryData; } + /** + * Returns an array of all existing "coursematch" settings data + * + * @return array + */ + public static function get_all_coursematch_settings() + { + global $DB; + + $results = $DB->get_records('local_cas_help_links', ['type' => 'coursematch']); + + return $results; + } + /** * Fetches the given primary's current course data * @@ -436,15 +450,19 @@ public static function getAuthUserId() { * @param int $course_id * @param int $category_id * @param int $primary_instructor_user_id + * @param string $course_full_name * @return mixed array|bool */ - public static function getSelectedPref($course_id, $category_id, $primary_instructor_user_id) + public static function getSelectedPref($course_id, $category_id, $primary_instructor_user_id, $course_full_name) { // pull all of the preference data relative to the course, category, user $prefs = self::getRelatedPrefData($course_id, $category_id, $primary_instructor_user_id); $selectedPref = false; + $coursematch_dept = self::get_coursematch_dept_from_name($course_full_name); + $coursematch_number = self::get_coursematch_number_from_name($course_full_name); + // first, keep only prefs with this primary associated if ($primaryUserPrefs = array_where($prefs, function ($key, $pref) use ($primary_instructor_user_id) { return $pref->user_id == $primary_instructor_user_id; @@ -497,6 +515,11 @@ public static function getSelectedPref($course_id, $category_id, $primary_instru }); } } + // otherwise, attempt to find a "coursematch" + } else if ($selectedPref = array_where($prefs, function ($key, $pref) use ($coursematch_dept, $coursematch_number) { + return $pref->type == 'coursematch' && $pref->dept == $coursematch_dept && $pref->number == $coursematch_number; + })) { + // otherwise, keep only this category's prefs } else if ($categoryPrefs = array_where($prefs, function ($key, $pref) use ($category_id) { return $pref->type == 'category' && $pref->category_id == $category_id && $pref->user_id == 0; @@ -570,8 +593,8 @@ private static function buildPrefsWhereClause($course_id, $category_id, $primary return $carry; }); - // remove the final "or" from the where clause - $whereClause = substr($whereClause, 0, -4); + // include all 'coursematch' prefs + $whereClause .= "(links.type = 'coursematch')"; return $whereClause; } @@ -598,6 +621,32 @@ private static function get_course_end_time() return time(); } + /** + * Returns a "department number" string given a moodle course full name + * + * @param string $course_fullname ex: '2017 Spring MUS 1751 for teacher...' + * @return string + */ + private static function get_coursematch_dept_from_name($course_fullname) + { + $exploded = explode(' ', $course_fullname); + + return $exploded[2]; + } + + /** + * Returns a "course number" string given a moodle course full name + * + * @param string $course_fullname ex: '2017 Spring MUS 1751 for teacher...' + * @return string + */ + private static function get_coursematch_number_from_name($course_fullname) + { + $exploded = explode(' ', $course_fullname); + + return $exploded[3]; + } + } /** diff --git a/db/upgrade.php b/db/upgrade.php new file mode 100644 index 0000000..084ea3b --- /dev/null +++ b/db/upgrade.php @@ -0,0 +1,58 @@ +. + +/** + * @package local_cas_help_links + * @copyright 2016, Louisiana State University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +function xmldb_local_cas_help_links_upgrade($oldversion) { + global $DB, $CFG; + + $dbman = $DB->get_manager(); + + if ($oldversion < 2017020200) { + $table = new xmldb_table('local_cas_help_links'); + + // Define field "dept" to be added to local_cas_help_links + $field = new xmldb_field('dept', XMLDB_TYPE_CHAR, '10'); + + // Conditionally launch add field "dept" + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field "number" to be added to local_cas_help_links + $field = new xmldb_field('number', XMLDB_TYPE_CHAR, '10'); + + // Conditionally launch add field "number" + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + } + + if ($oldversion < 2017020202) { + $table = new xmldb_table('local_cas_help_links'); + + // Increase the length of the "type" field to 11 so as to accommodate the new "coursematch" type + $field = new xmldb_field('type', XMLDB_TYPE_CHAR, '11', null, XMLDB_NOTNULL); + + $dbman->change_field_precision($table, $field); + } + + return true; +} diff --git a/delete_coursematch_setting_form.php b/delete_coursematch_setting_form.php new file mode 100644 index 0000000..2476815 --- /dev/null +++ b/delete_coursematch_setting_form.php @@ -0,0 +1,49 @@ +. + +/** + * Form for local cas_help_links + * + * @package local_cas_help_links + * @copyright 2016, William C. Mazilly, Robert Russo + * @copyright 2016, Louisiana State University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +if (!defined('MOODLE_INTERNAL')) { + die('Direct access to this script is forbidden.'); /// It must be included from a Moodle page +} + +require_once($CFG->libdir.'/formslib.php'); + +class cas_delete_coursematch_form extends moodleform { + + function definition() { + global $CFG, $DB, $OUTPUT; + $mform = $this->_form; + + $mform->addElement('hidden', 'sesskey', sesskey()); + $mform->setType('id', PARAM_INT); + + $mform->addElement('hidden', 'id', $this->_customdata['coursematch_id']); + + $mform->addElement('html', '

' . $this->_customdata['coursematch_dept'] . ' ' . $this->_customdata['coursematch_number'] . '  (' . $this->_customdata['coursematch_link'] . ')

'); + + $mform->addElement('submit', 'submitdeletcoursematch', 'Delete', ['class' => 'pull-left']); + } +} diff --git a/lang/en/local_cas_help_links.php b/lang/en/local_cas_help_links.php index eefa80d..97997a4 100644 --- a/lang/en/local_cas_help_links.php +++ b/lang/en/local_cas_help_links.php @@ -42,10 +42,14 @@ $string['hide_course_link'] = 'Hide course link: '; $string['pcategory_header'] = 'Category Personal Preferences'; $string['category_header'] = 'Category Defaults'; +$string['course_match_header'] = 'Course Match Defaults'; $string['hide_category_links'] = 'Hide category links: '; $string['user_header'] = 'User Personal Preferences'; $string['hide_user_links'] = 'Hide all my links: '; $string['my_default_link'] = 'My default link'; +$string['coursematch_dept'] = 'Course Department'; +$string['coursematch_number'] = 'Course Number'; +$string['coursematch_link'] = 'URL'; $string['submit_success'] = 'You preferences have been successfully updated.'; $string['submit_error'] = 'Please make sure your links are in the proper URL format'; diff --git a/renderer.php b/renderer.php index 6435cf9..fc4a466 100644 --- a/renderer.php +++ b/renderer.php @@ -29,6 +29,7 @@ require_once($CFG->libdir.'/formslib.php'); require_once($CFG->dirroot.'/local/cas_help_links/user_settings_form.php'); require_once($CFG->dirroot.'/local/cas_help_links/category_settings_form.php'); +require_once($CFG->dirroot.'/local/cas_help_links/delete_coursematch_setting_form.php'); class local_cas_help_links_renderer extends plugin_renderer_base { @@ -48,4 +49,16 @@ public function cas_category_links($categorySettingsData) { return $out; } + public function cas_delete_coursematch($coursematch) { + $mform = new cas_delete_coursematch_form(null, [ + 'coursematch_id' => $coursematch->id, + 'coursematch_dept' => $coursematch->dept, + 'coursematch_number' => $coursematch->number, + 'coursematch_link' => $coursematch->link, + ]); + + $out = $mform->display(); + return $out; + } + } diff --git a/version.php b/version.php index 8b83dd2..c69784d 100644 --- a/version.php +++ b/version.php @@ -22,9 +22,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2017020101; +$plugin->version = 2017020202; $plugin->requires = 2014051200; $plugin->cron = 0; $plugin->component = 'local_cas_help_links'; $plugin->maturity = MATURITY_STABLE; -$plugin->release = 'v0.5'; +$plugin->release = 'v0.6'; From a6ab188b74b3c3ba67d2575d7b6b6886156a5158 Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Tue, 7 Feb 2017 15:33:22 -0600 Subject: [PATCH 02/33] add in support for logging link-clicking activity --- README.md | 14 +++------ classes/button_renderer.php | 58 ++++++++++++++++++++++++++++++++++++ classes/logger.php | 34 +++++++++++++++++++++ db/upgrade.php | 14 +++++++++ interstitial.php | 59 +++++++++++++++++++++++++++++++++++++ version.php | 4 +-- 6 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 classes/button_renderer.php create mode 100644 classes/logger.php create mode 100644 interstitial.php diff --git a/README.md b/README.md index b1b2695..b6d04b8 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,14 @@ Currently, the front-facing method of the URL generator class will accept a cour ``` // Add CAS links -if (class_exists('local_cas_help_links_url_generator')) { - $help_url_array = \local_cas_help_links_url_generator::getUrlArrayForCourse($course); - if ($help_url_array['display']) { - $html .= '' . $help_url_array['label'] . ''; - } +if (class_exists('local_cas_help_links_button_renderer')) { + $html .= \local_cas_help_links_button_renderer::get_html_for_course($course, ['class' => 'btn cas_help']); } ``` AND in the welcome area ``` // Add CAS links -if (class_exists('local_cas_help_links_url_generator')) { - $help_url_array = \local_cas_help_links_url_generator::getUrlForUser($user_id); - if ($help_url_array['display']) { - $output .= '' . $help_url_array['label'] . ''; - } +if (class_exists('local_cas_help_links_button_renderer')) { + $output .= \local_cas_help_links_button_renderer::get_html_for_user_id($user_id, ['class' => 'btn cas_edit_help']); } ``` diff --git a/classes/button_renderer.php b/classes/button_renderer.php new file mode 100644 index 0000000..7921824 --- /dev/null +++ b/classes/button_renderer.php @@ -0,0 +1,58 @@ + $help_url_array['url'], + 'l' => $help_url_array['link_id'], + ]); + + return $help_url_array['display'] ? '' . $help_url_array['label'] . '' : ''; + } + +} diff --git a/classes/logger.php b/classes/logger.php new file mode 100644 index 0000000..0878a36 --- /dev/null +++ b/classes/logger.php @@ -0,0 +1,34 @@ +user_id = $user_id; + $log_record->link_id = $link_id; + $log_record->time_clicked = time(); + + $DB->insert_record(self::get_log_table_name(), $log_record); + } + + /** + * Returns the name of the 'help links log' table + * + * @return string + */ + private static function get_log_table_name() + { + return 'local_cas_help_links_log'; + } + +} diff --git a/db/upgrade.php b/db/upgrade.php index 084ea3b..b66a7ea 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -54,5 +54,19 @@ function xmldb_local_cas_help_links_upgrade($oldversion) { $dbman->change_field_precision($table, $field); } + if ($oldversion < 2017020701) { + $table = new xmldb_table('local_cas_help_links_log'); + + if ( ! $dbman->table_exists($table)) { + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('link_id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null); + $table->add_field('user_id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null); + $table->add_field('time_clicked', XMLDB_TYPE_INTEGER, '12', null, XMLDB_NOTNULL, null, null); + $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); // @TODO - index here? + + $dbman->create_table($table); + } + } + return true; } diff --git a/interstitial.php b/interstitial.php new file mode 100644 index 0000000..4e08e50 --- /dev/null +++ b/interstitial.php @@ -0,0 +1,59 @@ +. + +/** + * @package local_cas_help_links + * @copyright 2016, Louisiana State University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// defined('MOODLE_INTERNAL') || die(); + +require_once('../../config.php'); + +$redirect_url = required_param('u', PARAM_URL); +$link_id = required_param('l', PARAM_INT); + +$context = context_system::instance(); + +global $PAGE, $CFG, $USER; + +$PAGE->set_url($CFG->wwwroot . '/local/cas_help_links/interstitial.php'); +$PAGE->set_context($context); + +require_login(); + +////////////////////////////////////////////////////////// +/// +/// HANDLE REDIRECT +/// +////////////////////////////////////////////////////////// + +// if a URL was provided +if ($redirect_url) { + // verify link record? + + // loggen the linken here + \local_cas_help_links_logger::log_link_click($USER->id, $link_id); + + // redirect to the appropriate url + header('Location: ' . $redirect_url); + die; +} + +// otherwise, default to redirecting back from whence they came! +header('Location: ' . $_SERVER['HTTP_REFERER']); +die; diff --git a/version.php b/version.php index c69784d..13a090c 100644 --- a/version.php +++ b/version.php @@ -22,9 +22,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2017020202; +$plugin->version = 2017020701; $plugin->requires = 2014051200; $plugin->cron = 0; $plugin->component = 'local_cas_help_links'; $plugin->maturity = MATURITY_STABLE; -$plugin->release = 'v0.6'; +$plugin->release = 'v0.7'; From 89851ecd740a3c33c83828e8217ee986b80e1a76 Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Tue, 7 Feb 2017 15:39:31 -0600 Subject: [PATCH 03/33] render edit setting link correctly --- classes/button_renderer.php | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/classes/button_renderer.php b/classes/button_renderer.php index 7921824..3c45304 100644 --- a/classes/button_renderer.php +++ b/classes/button_renderer.php @@ -16,7 +16,14 @@ public static function get_html_for_course($course, $attributes = []) if ( ! $help_url_array['display']) return ''; - return self::render_button_from_url_array($help_url_array, $attributes); + $class = array_key_exists('class', $attributes) ? $attributes['class'] : ''; + + $interstitial_url = new moodle_url('/local/cas_help_links/interstitial.php', [ + 'u' => $help_url_array['url'], + 'l' => $help_url_array['link_id'], + ]); + + return $help_url_array['display'] ? '' . $help_url_array['label'] . '' : ''; } /** @@ -33,26 +40,9 @@ public static function get_html_for_user_id($user_id, $attributes = []) if ( ! $help_url_array['display']) return ''; - return self::render_button_from_url_array($help_url_array, $attributes); - } - - /** - * Returns an appropriately generated HTML link for a given "help array" and optional attributes - * - * @param array $help_url_array - * @param array $attributes an array of attributes to be applied to the link (optional) - * @return string - */ - private static function render_button_from_url_array($help_url_array, $attributes = []) - { $class = array_key_exists('class', $attributes) ? $attributes['class'] : ''; - $interstitial_url = new moodle_url('/local/cas_help_links/interstitial.php', [ - 'u' => $help_url_array['url'], - 'l' => $help_url_array['link_id'], - ]); - - return $help_url_array['display'] ? '' . $help_url_array['label'] . '' : ''; + return $help_url_array['display'] ? '' . $help_url_array['label'] . '' : ''; } } From a62601f957c39b46f8e559799d4e66ffdcd6332e Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Mon, 13 Feb 2017 08:32:04 -0600 Subject: [PATCH 04/33] add upgrade schema to install file --- db/install.xml | 18 +++++++++++++++++- db/upgrade.php | 8 ++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/db/install.xml b/db/install.xml index 1761052..33898dd 100644 --- a/db/install.xml +++ b/db/install.xml @@ -7,12 +7,14 @@ - + + + @@ -21,5 +23,19 @@
+ + + + + + + + + + + + + +
\ No newline at end of file diff --git a/db/upgrade.php b/db/upgrade.php index b66a7ea..6621e71 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -58,10 +58,10 @@ function xmldb_local_cas_help_links_upgrade($oldversion) { $table = new xmldb_table('local_cas_help_links_log'); if ( ! $dbman->table_exists($table)) { - $table->add_field('id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); - $table->add_field('link_id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null); - $table->add_field('user_id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null); - $table->add_field('time_clicked', XMLDB_TYPE_INTEGER, '12', null, XMLDB_NOTNULL, null, null); + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, XMLDB_SEQUENCE); + $table->add_field('link_id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL); + $table->add_field('user_id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL); + $table->add_field('time_clicked', XMLDB_TYPE_INTEGER, '12', XMLDB_UNSIGNED, XMLDB_NOTNULL); $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); // @TODO - index here? $dbman->create_table($table); From 3c229aa59d308d7edc0b2c8ae8c61f5309b7b119 Mon Sep 17 00:00:00 2001 From: Robert Russo Date: Tue, 14 Feb 2017 16:02:04 -0600 Subject: [PATCH 05/33] Add courseid to logging and base SQL for stats --- classes/button_renderer.php | 3 +- classes/logger.php | 3 +- classes/url_generator.php | 11 ++++--- classes/utility.php | 63 +++++++++++++++++++++++++++++++++++++ db/install.xml | 17 +++++++++- db/upgrade.php | 10 ++++++ interstitial.php | 3 +- version.php | 2 +- 8 files changed, 103 insertions(+), 9 deletions(-) diff --git a/classes/button_renderer.php b/classes/button_renderer.php index 3c45304..13babc2 100644 --- a/classes/button_renderer.php +++ b/classes/button_renderer.php @@ -20,7 +20,8 @@ public static function get_html_for_course($course, $attributes = []) $interstitial_url = new moodle_url('/local/cas_help_links/interstitial.php', [ 'u' => $help_url_array['url'], - 'l' => $help_url_array['link_id'], + 'c' => $help_url_array['course_id'], + 'l' => $help_url_array['link_id'] ]); return $help_url_array['display'] ? '' . $help_url_array['label'] . '' : ''; diff --git a/classes/logger.php b/classes/logger.php index 0878a36..bdd2713 100644 --- a/classes/logger.php +++ b/classes/logger.php @@ -9,12 +9,13 @@ class local_cas_help_links_logger { * @param int $link_id 'help links' table record id * @return boid */ - public static function log_link_click($user_id, $link_id = 0) + public static function log_link_click($user_id, $course_id, $link_id = 0) { global $DB; $log_record = new stdClass; $log_record->user_id = $user_id; + $log_record->course_id = $course_id; $log_record->link_id = $link_id; $log_record->time_clicked = time(); diff --git a/classes/url_generator.php b/classes/url_generator.php index 403a0d9..e88dc04 100644 --- a/classes/url_generator.php +++ b/classes/url_generator.php @@ -123,7 +123,7 @@ private static function getDisplayHelpUrlArray($course_id, $category_id, $primar // get appropriate pref from db if ( ! $selectedPref = \local_cas_help_links_utility::getSelectedPref($course_id, $category_id, $primary_instructor_user_id, $course_full_name)) { // if no pref can be resolved, return default settings using system config - $urlArray = self::getDefaultHelpUrlArray(); + $urlArray = self::getDefaultHelpUrlArray($course_id); } else { // otherwise, convert the selected pref result to a single object $selectedPref = reset($selectedPref); // @WATCH - should be no multiple results confusion here @@ -132,7 +132,8 @@ private static function getDisplayHelpUrlArray($course_id, $category_id, $primar 'display' => $selectedPref->display, 'url' => $selectedPref->link, 'label' => get_string('help_button_label', 'local_cas_help_links'), - 'link_id' => $selectedPref->id, + 'course_id' => $course_id, + 'link_id' => $selectedPref->id ]; } @@ -144,13 +145,14 @@ private static function getDisplayHelpUrlArray($course_id, $category_id, $primar * * @return array */ - private static function getDefaultHelpUrlArray() + private static function getDefaultHelpUrlArray($course_id) { return [ 'display' => \local_cas_help_links_utility::isPluginEnabled(), 'url' => get_config('local_cas_help_links', 'default_help_link'), 'label' => get_string('help_button_label', 'local_cas_help_links'), - 'link_id' => '' + 'course_id' => $course_id, + 'link_id' => '0' ]; } @@ -165,6 +167,7 @@ private static function getEmptyHelpUrlArray() 'display' => false, 'url' => '', 'label' => '', + 'course_id' => 0, 'link_id' => 0 ]; } diff --git a/classes/utility.php b/classes/utility.php index 23f83af..53c00fd 100644 --- a/classes/utility.php +++ b/classes/utility.php @@ -75,6 +75,69 @@ public static function get_all_coursematch_settings() return $results; } + + /** + * Fetches the usage for the system + * + * @return array + */ + private static function get_usage_data() + { + global $DB; + + $result = $DB->get_records_sql('SELECT Department, Course_Number, Full_Name_of_User, Link_Type, External_URL, Time_Clicked FROM ( + SELECT + llog.id AS uniqer, + uec.department AS Department, + uec.cou_number AS Course_Number, + CONCAT(u.firstname, " ", u.lastname) AS Full_Name_of_User, + link.type AS Link_Type, + link.link AS External_URL, + FROM_UNIXTIME(llog.time_clicked) AS Time_Clicked + FROM {course} c + INNER JOIN {enrol_ues_sections} sec ON sec.idnumber = c.idnumber + INNER JOIN {enrol_ues_courses} uec ON uec.id = sec.courseid + INNER JOIN {local_cas_help_links_log} llog ON c.id = llog.course_id + INNER JOIN {user} u ON u.id = llog.user_id + INNER JOIN {local_cas_help_links} link ON link.id = llog.link_id + WHERE c.idnumber <> "" AND c.idnumber IS NOT NULL AND link.user_id = 0 + UNION ALL + SELECT + llog.id AS uniqer, + uec.department AS Department, + uec.cou_number AS Course_Number, + CONCAT(u.firstname, " ", u.lastname) AS Full_Name_of_User, + "Site" AS Link_Type, + NULL AS External_URL, + FROM_UNIXTIME(llog.time_clicked) AS Time_Clicked + FROM {course} c + INNER JOIN {enrol_ues_sections} sec ON sec.idnumber = c.idnumber + INNER JOIN {enrol_ues_courses} uec ON uec.id = sec.courseid + INNER JOIN {local_cas_help_links_log} llog ON c.id = llog.course_id + INNER JOIN {user} u ON u.id = llog.user_id + LEFT JOIN {local_cas_help_links} link ON link.id = llog.link_id + WHERE c.idnumber <> "" AND c.idnumber IS NOT NULL AND link.id IS NULL + UNION ALL + SELECT + llog.id AS uniqer, + uec.department AS Department, + uec.cou_number AS Course_Number, + CONCAT(u.firstname, " ", u.lastname) AS Full_Name_of_User, + IF(link.user_id>0,"User Category", IFNULL(link.type, "Site")) AS Link_Type, + link.link AS External_URL, + FROM_UNIXTIME(llog.time_clicked) AS Time_Clicked + FROM {course} c + INNER JOIN {enrol_ues_sections} sec ON sec.idnumber = c.idnumber + INNER JOIN {enrol_ues_courses} uec ON uec.id = sec.courseid + INNER JOIN {local_cas_help_links_log} llog ON c.id = llog.course_id + INNER JOIN {user} u ON u.id = llog.user_id + INNER JOIN {local_cas_help_links} link ON link.id = llog.link_id + WHERE c.idnumber <> "" AND c.idnumber IS NOT NULL AND link.user_id > 0) t + GROUP BY uniqer + '); + return $result; + } + /** * Fetches the given primary's current course data * diff --git a/db/install.xml b/db/install.xml index 1761052..9e10457 100644 --- a/db/install.xml +++ b/db/install.xml @@ -21,5 +21,20 @@ + + + + + + + + + + + + + + +
- \ No newline at end of file + diff --git a/db/upgrade.php b/db/upgrade.php index b66a7ea..f6b4d42 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -61,6 +61,7 @@ function xmldb_local_cas_help_links_upgrade($oldversion) { $table->add_field('id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); $table->add_field('link_id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null); $table->add_field('user_id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null); + $table->add_field('course_id', XMLDB_TYPE_INTEGER, '11', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, '0'); $table->add_field('time_clicked', XMLDB_TYPE_INTEGER, '12', null, XMLDB_NOTNULL, null, null); $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id')); // @TODO - index here? @@ -68,5 +69,14 @@ function xmldb_local_cas_help_links_upgrade($oldversion) { } } + if ($oldversion < 2017021400) { + $table = new xmldb_table('local_cas_help_links_log'); + + if ($dbman->table_exists($table)) { + $field = new xmldb_field('course_id', XMLDB_TYPE_INTEGER, '11', null, XMLDB_NOTNULL, null, '0'); + $dbman->add_field($table, $field); + } + } + return true; } diff --git a/interstitial.php b/interstitial.php index 4e08e50..1607c10 100644 --- a/interstitial.php +++ b/interstitial.php @@ -25,6 +25,7 @@ require_once('../../config.php'); $redirect_url = required_param('u', PARAM_URL); +$course_id = required_param('c', PARAM_INT); $link_id = required_param('l', PARAM_INT); $context = context_system::instance(); @@ -47,7 +48,7 @@ // verify link record? // loggen the linken here - \local_cas_help_links_logger::log_link_click($USER->id, $link_id); + \local_cas_help_links_logger::log_link_click($USER->id, $course_id, $link_id); // redirect to the appropriate url header('Location: ' . $redirect_url); diff --git a/version.php b/version.php index 13a090c..46f7257 100644 --- a/version.php +++ b/version.php @@ -22,7 +22,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2017020701; +$plugin->version = 2017021400; $plugin->requires = 2014051200; $plugin->cron = 0; $plugin->component = 'local_cas_help_links'; From d3541ff733aa2e4db65b965600355c74227dd89a Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Wed, 15 Feb 2017 08:47:41 -0600 Subject: [PATCH 06/33] add seeding support --- classes/db_seeder.php | 362 ++ cli/seed_db.php | 81 + resources/sample_urls.php | 10005 ++++++++++++++++++++++++++++++++++++ version.php | 4 +- 4 files changed, 10450 insertions(+), 2 deletions(-) create mode 100644 classes/db_seeder.php create mode 100644 cli/seed_db.php create mode 100644 resources/sample_urls.php diff --git a/classes/db_seeder.php b/classes/db_seeder.php new file mode 100644 index 0000000..cb4caa4 --- /dev/null +++ b/classes/db_seeder.php @@ -0,0 +1,362 @@ +db = $DB; + $this->urls = include('../resources/sample_urls.php'); + $this->studentUsers = null; + $this->studentUserCount = null; + $this->links = null; + $this->linkCount = null; + } + + /** + * Deletes all cas help link records + * + * @return void + */ + public function clearLinks() + { + $this->db->delete_records('local_cas_help_links'); + } + + /** + * Deletes all cas help link log records + * + * @return void + */ + public function clearLogs() + { + $this->db->delete_records('local_cas_help_links_log'); + } + + /** + * Inserts sample "category" cas help links into the DB + * + * @return bool + */ + public function seedCategoryLinks() + { + $amountAdded = 0; + + // get all course category ids + foreach ($this->getCategories() as $category) + { + $categoryId = (int) $category->id; + + // 80% chance a category will have a link + if (byChance(80)) { + $this->insertLink('category', $categoryId); + + $amountAdded++; + } + } + + return $amountAdded; + } + + /** + * Inserts sample "course" cas help links into the DB + * + * @return bool + */ + public function seedCourseLinks() + { + $amountAdded = 0; + + // get all course ids + foreach (get_courses() as $course) + { + $courseId = (int) $course->id; + + // 40% chance a course will have a link + if (byChance(40)) { + $this->insertLink('course', $courseId); + + $amountAdded++; + } + } + + return $amountAdded; + } + + /** + * Inserts sample "user" cas help links into the DB + * + * @return bool + */ + public function seedUserLinks() + { + $amountAdded = 0; + + // get all course ids + foreach ($this->getInstructorUsers() as $user) + { + $userId = (int) $user->id; + + // 20% chance a course will have a link + if (byChance(20)) { + $this->insertLink('user', $userId); + + $amountAdded++; + } + } + + return $amountAdded; + } + + /** + * Inserts logging activity given a range of months into the DB + * + * @param string $monthRangeString ex: 2016-4,2017-2 + * @return bool + */ + public function seedLog($monthRangeString) + { + $success = false; + $tickDate = $this->startDate = $this->getDateFromString('start', $monthRangeString); + $this->endDate = $this->getDateFromString('end', $monthRangeString); + $this->hoursLeft = $this->getHoursLeft(); + + // iterate through each hour in the range + while ($this->hoursLeft > 0) { + // calculate clicks this hour (between 0 - 100) + $clicksThisHour = mt_rand(0, 100); + + foreach (range(1, $clicksThisHour) as $click) { + // get a random user id + $userId = $this->getRandomUserId(); + + // get a random link id + $linkId = $this->getRandomLinkId(); + + // @TODO - randomize the specific time portion of timestamp + $this->insertLogRecord($userId, $linkId, $tickDate->getTimestamp()); + } + + // add an hour to the current timestamp + $tickDate->add(new DateInterval('PT1H')); + + $this->hoursLeft--; + $success = true; + } + + return $success; + } + + /** + * Returns a random student user id, + * also sets available student user list and count if not already set + * + * @return int + */ + private function getRandomUserId() + { + if (is_null($this->studentUsers)) { + $this->studentUsers = array_values($this->getStudentUsers()); + $this->studentUserCount = count($this->studentUsers); + } + + $user = $this->studentUsers[mt_rand(0, $this->studentUserCount - 1)]; + + return (int) $user->id; + } + + /** + * Returns a random cas help link id, + * also sets available link list and count if not already set + * + * @return int + */ + private function getRandomLinkId() + { + if (is_null($this->links)) { + $this->links = array_values($this->getLinks()); + $this->linkCount = count($this->links); + } + + $link = $this->links[mt_rand(0, $this->linkCount - 1)]; + + return (int) $link->id; + } + + /** + * Inserts a generated link record of the given type and id + * + * @param string $type category|course|user + * @param int $id + * @return int + */ + private function insertLink($type, $id) + { + $identifier = $type . '_id'; + + $link = new stdClass(); + $link->type = $type; + $link->$identifier = $id; + $link->display = (int) byChance(85); + $link->link = $this->getRandomUrl(); + + $id = $this->db->insert_record('local_cas_help_links', $link); + + return $id; + } + + /** + * Inserts a log record for the given parameters + * + * @param int $userId + * @param int $linkId + * @param int $timestamp + * @return int + */ + private function insertLogRecord($userId, $linkId, $timestamp) + { + $logRecord = new stdClass(); + $logRecord->user_id = $userId; + $logRecord->link_id = $linkId; + $logRecord->time_clicked = $timestamp; + + $id = $this->db->insert_record('local_cas_help_links_log', $logRecord); + + return $id; + } + + /** + * Returns an array of objects containing category ids + * + * @return array + */ + private function getCategories() + { + $catIds = $this->db->get_records_sql('SELECT id FROM {course_categories} WHERE id != 1'); + + return $catIds; + } + + /** + * Returns an array of objects containing primary instructor user ids + * + * @return array + */ + private function getInstructorUsers() + { + $result = $this->db->get_records_sql('SELECT DISTINCT u.id FROM {enrol_ues_teachers} t + INNER JOIN {user} u ON u.id = t.userid + INNER JOIN {enrol_ues_sections} sec ON sec.id = t.sectionid + INNER JOIN {enrol_ues_courses} cou ON cou.id = sec.courseid + WHERE sec.idnumber IS NOT NULL + AND sec.idnumber <> "" + AND cou.cou_number < "5000" + AND t.primary_flag = "1" + AND t.status = "enrolled"'); + + return $result; + } + + /** + * Returns an array of objects containing student user ids + * + * @return array + */ + private function getStudentUsers() + { + $result = $this->db->get_records_sql('SELECT DISTINCT u.id FROM {enrol_ues_students} s + INNER JOIN {user} u ON u.id = s.userid + INNER JOIN {enrol_ues_sections} sec ON sec.id = s.sectionid + WHERE sec.idnumber IS NOT NULL + AND sec.idnumber <> "" + AND s.status = "enrolled"'); + + return $result; + } + + /** + * Returns all cas help link records + * + * @return array + */ + private function getLinks() + { + $result = $this->db->get_records('local_cas_help_links'); + + return $result; + } + + /** + * Returns the difference in hours between the start and end date + * + * @return int + */ + private function getHoursLeft() + { + $interval = $this->startDate->diff($this->endDate); + + return (int) $interval->format('%a') * 24; + } + + /** + * Returns a specific datetime for the start or end of a given "month range string" + * + * @param string $date start(default)|end + * @param string $rangeString ex: 2016-4,2017-2 + * @return DateTime + */ + private function getDateFromString($date = 'start', $rangeString) + { + list($start, $end) = explode(',', $rangeString); + + $day = $date == 'end' ? '28' : '1'; // @TODO - calculate real last day of month + + $time = $date == 'end' ? '11:59:59' : '00:00:00'; + + $datetime = DateTime::createFromFormat('Y-n-j G:i:s', $$date . '-' . $day . ' ' . $time); + + return $datetime; + } + + /** + * Returns a random URL + * + * @return string + */ + private function getRandomUrl() + { + $key = mt_rand(0, 9999); + + return $this->urls[$key]; + } + +} + +/** + * Helper function for determining true/false based on a given chance of being true + * + * @param int $pct (ex: 40 = 40%) + * @return bool + */ +function byChance($pct) +{ + return mt_rand(1, 100) <= $pct; +} \ No newline at end of file diff --git a/cli/seed_db.php b/cli/seed_db.php new file mode 100644 index 0000000..32b301c --- /dev/null +++ b/cli/seed_db.php @@ -0,0 +1,81 @@ +libdir.'/clilib.php'); + +// Get cli options. +list($options, $unrecognized) = cli_get_params( + array( + 'links' => false, + 'range' => '', // ex: 2017-1,2017-12 + 'help' => false + ), + array( + 'h' => 'help', + 'r' => 'range', + ) +); + +if ($options['help']) +{ + $help = "\nOptions:\n-h, --help Print out this help\n-r, --range The month range for activity generation (default: '2017-1,2017-12')\n\nExample:\n\$sudo -u www-data /usr/bin/php local/cas_help_links/seed_db.php --range=2016-1,2017-2\n\n"; + + echo $help; + die; +} + +// create a new seeder for this date range +$seeder = new \local_cas_help_links_db_seeder(); + +// create link records if necessary +if ( ! empty($options['links'])) +{ + // clear all link records + $seeder->clearLinks(); + + // first, seed category links + if ($amountAdded = $seeder->seedCategoryLinks()) { + echo $amountAdded . " category links added!\n"; + } else { + cli_error("Could not create category links.\n"); + die; + } + + // second, seed course links + if ($amountAdded = $seeder->seedCourseLinks()) { + echo $amountAdded . " course links added!\n"; + } else { + cli_error("Could not create course links.\n"); + die; + } + + // third, seed user (instructor) links + if ($amountAdded = $seeder->seedUserLinks()) { + echo $amountAdded . " user links added!\n"; + } else { + cli_error("Could not create user links.\n"); + die; + } +} + +// if we have valid range input +if ( ! empty($options['range'])) +{ + // clear all log records + $seeder->clearLogs(); + + // attempt to generate log activity for the given range + if ($seeder->seedLog($options['range'])) { + echo "Click activity added!\n"; + } else { + cli_error("Could not create log activity.\n"); + die; + } +} + +die; \ No newline at end of file diff --git a/resources/sample_urls.php b/resources/sample_urls.php new file mode 100644 index 0000000..562665b --- /dev/null +++ b/resources/sample_urls.php @@ -0,0 +1,10005 @@ +version = 2017020701; +$plugin->version = 2017021300; $plugin->requires = 2014051200; $plugin->cron = 0; $plugin->component = 'local_cas_help_links'; $plugin->maturity = MATURITY_STABLE; -$plugin->release = 'v0.7'; +$plugin->release = 'v0.8'; From a043476005975bf7a70f9172b728cb55d9b3b744 Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Wed, 15 Feb 2017 12:48:28 -0600 Subject: [PATCH 07/33] add course ids to log activity --- classes/db_seeder.php | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/classes/db_seeder.php b/classes/db_seeder.php index cb4caa4..700850e 100644 --- a/classes/db_seeder.php +++ b/classes/db_seeder.php @@ -12,6 +12,10 @@ class local_cas_help_links_db_seeder { public $urls; + public $courses; + + public $courseCount; + public $studentUsers; public $studentUserCount; @@ -148,11 +152,14 @@ public function seedLog($monthRangeString) // get a random user id $userId = $this->getRandomUserId(); + // get a random course id + $courseId = $this->getRandomCourseId(); + // get a random link id $linkId = $this->getRandomLinkId(); // @TODO - randomize the specific time portion of timestamp - $this->insertLogRecord($userId, $linkId, $tickDate->getTimestamp()); + $this->insertLogRecord($userId, $linkId, $courseId, $tickDate->getTimestamp()); } // add an hour to the current timestamp @@ -165,6 +172,25 @@ public function seedLog($monthRangeString) return $success; } + /** + * Returns a random course id, + * also sets available course list and count if not already set + * + * @return int + */ + private function getRandomCourseId() + { + if (is_null($this->courses)) { + // @TODO - make sure we're getting a real, active course + $this->courses = array_values(get_courses()); + $this->courseCount = count($this->courses); + } + + $course = $this->courses[mt_rand(0, $this->courseCount - 1)]; + + return (int) $course->id; + } + /** * Returns a random student user id, * also sets available student user list and count if not already set @@ -228,14 +254,16 @@ private function insertLink($type, $id) * * @param int $userId * @param int $linkId + * @param int $courseId * @param int $timestamp * @return int */ - private function insertLogRecord($userId, $linkId, $timestamp) + private function insertLogRecord($userId, $linkId, $courseId, $timestamp) { $logRecord = new stdClass(); $logRecord->user_id = $userId; $logRecord->link_id = $linkId; + $logRecord->course_id = $courseId; $logRecord->time_clicked = $timestamp; $id = $this->db->insert_record('local_cas_help_links_log', $logRecord); From 7aead4295cfc7199b99385779f2fac80b90dc420 Mon Sep 17 00:00:00 2001 From: Robert Russo Date: Wed, 15 Feb 2017 12:52:37 -0600 Subject: [PATCH 08/33] Installer fix --- db/install.xml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/db/install.xml b/db/install.xml index e43270f..e234f86 100644 --- a/db/install.xml +++ b/db/install.xml @@ -7,14 +7,12 @@ - + - - @@ -23,13 +21,12 @@
- - + - + From ae8e26a7c1a40816d28222f237ff41b7eedb8385 Mon Sep 17 00:00:00 2001 From: Robert Russo Date: Wed, 15 Feb 2017 12:53:42 -0600 Subject: [PATCH 09/33] Bump version --- version.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.php b/version.php index 68020b3..83c0b8a 100644 --- a/version.php +++ b/version.php @@ -22,9 +22,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2017021400; +$plugin->version = 2017021500; $plugin->requires = 2014051200; $plugin->cron = 0; $plugin->component = 'local_cas_help_links'; $plugin->maturity = MATURITY_STABLE; -$plugin->release = 'v0.8'; +$plugin->release = 'v0.9'; From 7072d6fc8213ab2aaba3a07354382482cefe010e Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Wed, 15 Feb 2017 14:53:41 -0600 Subject: [PATCH 10/33] schema changes in log table --- db/install.xml | 8 ++++---- db/upgrade.php | 28 ++++++++++++++++++++++++++++ version.php | 4 ++-- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/db/install.xml b/db/install.xml index e43270f..0e94ae8 100644 --- a/db/install.xml +++ b/db/install.xml @@ -27,16 +27,16 @@
- + + - + + - -
diff --git a/db/upgrade.php b/db/upgrade.php index f6b4d42..14f4db5 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -78,5 +78,33 @@ function xmldb_local_cas_help_links_upgrade($oldversion) { } } + if ($oldversion < 2017021500) { + $table = new xmldb_table('local_cas_help_links_log'); + + if ($dbman->table_exists($table)) { + + // drop link_id from log table + $key = new xmldb_key('link_id_key', XMLDB_KEY_FOREIGN); + $dbman->drop_key($table, $key); + $field = new xmldb_field('link_id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null); + $dbman->drop_field($table, $field); + + // drop course_id from log table + $key = new xmldb_key('course_id_key', XMLDB_KEY_FOREIGN); + $dbman->drop_key($table, $key); + $field = new xmldb_field('course_id', XMLDB_TYPE_INTEGER, '11', null, XMLDB_NOTNULL, null, '0'); + $dbman->drop_field($table, $field); + + $field = new xmldb_field('link_type', XMLDB_TYPE_CHAR, '11', null, XMLDB_NOTNULL, null); + $dbman->add_field($table, $field); + $field = new xmldb_field('link_url', XMLDB_TYPE_CHAR, '512', null, XMLDB_NOTNULL, null); + $dbman->add_field($table, $field); + $field = new xmldb_field('course_dept', XMLDB_TYPE_CHAR, '10', null, XMLDB_NOTNULL, null); + $dbman->add_field($table, $field); + $field = new xmldb_field('course_number', XMLDB_TYPE_CHAR, '10', null, XMLDB_NOTNULL, null); + $dbman->add_field($table, $field); + } + } + return true; } diff --git a/version.php b/version.php index 68020b3..83c0b8a 100644 --- a/version.php +++ b/version.php @@ -22,9 +22,9 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2017021400; +$plugin->version = 2017021500; $plugin->requires = 2014051200; $plugin->cron = 0; $plugin->component = 'local_cas_help_links'; $plugin->maturity = MATURITY_STABLE; -$plugin->release = 'v0.8'; +$plugin->release = 'v0.9'; From 9e734ed10cb45ee24767b1e69d293b86b598dc78 Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Thu, 16 Feb 2017 10:21:19 -0600 Subject: [PATCH 11/33] update log insertion to accomodate new schema --- classes/db_seeder.php | 68 ++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/classes/db_seeder.php b/classes/db_seeder.php index 700850e..6eb682c 100644 --- a/classes/db_seeder.php +++ b/classes/db_seeder.php @@ -12,14 +12,14 @@ class local_cas_help_links_db_seeder { public $urls; - public $courses; - - public $courseCount; - public $studentUsers; public $studentUserCount; + public $uesCourses; + + public $uesCourseCount; + public $links; public $linkCount; @@ -31,6 +31,8 @@ public function __construct() { $this->urls = include('../resources/sample_urls.php'); $this->studentUsers = null; $this->studentUserCount = null; + $this->uesCourses = null; + $this->uesCourseCount = null; $this->links = null; $this->linkCount = null; } @@ -152,14 +154,14 @@ public function seedLog($monthRangeString) // get a random user id $userId = $this->getRandomUserId(); - // get a random course id - $courseId = $this->getRandomCourseId(); + // get a random UES course + $uesCourse = $this->getRandomUesCourse(); - // get a random link id - $linkId = $this->getRandomLinkId(); + // get a random link + $link = $this->getRandomLink(); // @TODO - randomize the specific time portion of timestamp - $this->insertLogRecord($userId, $linkId, $courseId, $tickDate->getTimestamp()); + $this->insertLogRecord($userId, $link, $uesCourse, $tickDate->getTimestamp()); } // add an hour to the current timestamp @@ -173,22 +175,22 @@ public function seedLog($monthRangeString) } /** - * Returns a random course id, + * Returns a random UES course, * also sets available course list and count if not already set * - * @return int + * @return object */ - private function getRandomCourseId() + private function getRandomUesCourse() { - if (is_null($this->courses)) { + if (is_null($this->uesCourses)) { // @TODO - make sure we're getting a real, active course - $this->courses = array_values(get_courses()); - $this->courseCount = count($this->courses); + $this->uesCourses = array_values($this->getUesCourses()); + $this->uesCourseCount = count($this->uesCourses); } - $course = $this->courses[mt_rand(0, $this->courseCount - 1)]; + $course = $this->uesCourses[mt_rand(0, $this->uesCourseCount - 1)]; - return (int) $course->id; + return $course; } /** @@ -210,12 +212,12 @@ private function getRandomUserId() } /** - * Returns a random cas help link id, + * Returns a random cas help link, * also sets available link list and count if not already set * - * @return int + * @return object */ - private function getRandomLinkId() + private function getRandomLink() { if (is_null($this->links)) { $this->links = array_values($this->getLinks()); @@ -224,7 +226,7 @@ private function getRandomLinkId() $link = $this->links[mt_rand(0, $this->linkCount - 1)]; - return (int) $link->id; + return $link; } /** @@ -253,18 +255,20 @@ private function insertLink($type, $id) * Inserts a log record for the given parameters * * @param int $userId - * @param int $linkId - * @param int $courseId + * @param object $link + * @param object $uesCourse * @param int $timestamp * @return int */ - private function insertLogRecord($userId, $linkId, $courseId, $timestamp) + private function insertLogRecord($userId, $link, $uesCourse, $timestamp) { $logRecord = new stdClass(); $logRecord->user_id = $userId; - $logRecord->link_id = $linkId; - $logRecord->course_id = $courseId; $logRecord->time_clicked = $timestamp; + $logRecord->link_type = $link->type; + $logRecord->link_url = $link->link; + $logRecord->course_dept = $uesCourse->department; + $logRecord->course_number = $uesCourse->cou_number; $id = $this->db->insert_record('local_cas_help_links_log', $logRecord); @@ -320,6 +324,18 @@ private function getStudentUsers() return $result; } + /** + * Returns an array of objects containing ues courses + * + * @return array + */ + private function getUesCourses() + { + $result = $this->db->get_records('enrol_ues_courses'); + + return $result; + } + /** * Returns all cas help link records * From d94e3b7f06f18f98245666890b43ec2f8a9abba7 Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Thu, 16 Feb 2017 11:41:55 -0600 Subject: [PATCH 12/33] update logging method to log the appropriate data --- classes/logger.php | 45 +++++++++++++++++++++++++++++++++++++++++++-- classes/utility.php | 40 ++++++++++++++++++++++++++++++++++++++++ interstitial.php | 2 -- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/classes/logger.php b/classes/logger.php index bdd2713..05ee9ee 100644 --- a/classes/logger.php +++ b/classes/logger.php @@ -6,18 +6,59 @@ class local_cas_help_links_logger { * Persists a log record for a "link clicked" activity * * @param int $user_id moodle user id + * @param int $course_id moodle course id * @param int $link_id 'help links' table record id * @return boid */ public static function log_link_click($user_id, $course_id, $link_id = 0) { + // if there is no explicit link id passed, this was a general help click + if ( ! $link_id) { + $linkType = 'site'; + $linkUrl = get_config('local_cas_help_links', 'default_help_link'); + + // otherwise, attempt to fetch this link record + } else { + // if there is a link record, log the appropriate info + if ($link = \local_cas_help_links_utility::get_link($link_id)) { + $linkType = $link->type; + $linkUrl = $link->link; + + // otherwise, there is a problem (link should exist here) + } else { + // bail out @TODO - log this error internally? + return; + } + } + + // attempt to fetch this ues course's data + if ($uesCourseData = \local_cas_help_links_utility::get_ues_course_data($course_id)) { + // if no result, fallback to empty strings so as not to distrupt the redirect + if (empty($uesCourseData)) { + $courseDept = ''; + $courseNumber = ''; + + // otherwise, log the appropriate info + } else { + $courseDept = $uesCourseData->department; + $courseNumber = $uesCourseData->cou_number; + } + + // otherwise, something has gone wrong + } else { + $courseDept = ''; + $courseNumber = ''; + } + global $DB; $log_record = new stdClass; $log_record->user_id = $user_id; - $log_record->course_id = $course_id; - $log_record->link_id = $link_id; $log_record->time_clicked = time(); + $log_record->link_type = $linkType; + $log_record->link_url = $linkUrl; + $log_record->course_dept = $courseDept; + $log_record->course_number = $courseNumber; $DB->insert_record(self::get_log_table_name(), $log_record); } diff --git a/classes/utility.php b/classes/utility.php index 53c00fd..7dd0b5f 100644 --- a/classes/utility.php +++ b/classes/utility.php @@ -445,6 +445,21 @@ private static function get_links($type, $user_id = 0) return $result; } + /** + * Fetches a cas_help_link object + * + * @param int $link_id + * @return object + */ + public static function get_link($link_id) + { + global $DB; + + $result = $DB->get_record('local_cas_help_links', ['id' => $link_id]); + + return $result; + } + /** * Returns whether or not this plugin is enabled based off plugin config * @@ -496,6 +511,31 @@ public static function getPrimaryInstructorId($idnumber) return ! is_null($userId) ? (int) $userId : 0; } + /** + * Fetches select data from a UES course record given a moodle course id + * + * @param int $course_id + * @return array + */ + public static function get_ues_course_data($course_id) + { + global $DB; + + // @TODO: make cou_number variable + $result = $DB->get_record_sql('SELECT DISTINCT uesc.department, uesc.cou_number FROM {enrol_ues_courses} uesc + INNER JOIN {enrol_ues_sections} sec ON sec.courseid = uesc.id + INNER JOIN {enrol_ues_semesters} sem ON sem.id = sec.semesterid + INNER JOIN {course} c ON c.idnumber = sec.idnumber + WHERE sec.idnumber IS NOT NULL + AND sec.idnumber <> "" + AND uesc.cou_number < "5000" + AND sem.classes_start < ' . self::get_course_start_time() . ' + AND sem.grades_due > ' . self::get_course_end_time() . ' + AND c.id = ?', array($course_id)); + + return $result; + } + /** * Returns the currently authenticated user id * diff --git a/interstitial.php b/interstitial.php index 1607c10..a56ca2e 100644 --- a/interstitial.php +++ b/interstitial.php @@ -45,8 +45,6 @@ // if a URL was provided if ($redirect_url) { - // verify link record? - // loggen the linken here \local_cas_help_links_logger::log_link_click($USER->id, $course_id, $link_id); From fe1948ebe283099d98bc28166c8c4f51008cc48a Mon Sep 17 00:00:00 2001 From: Chad Mazilly Date: Tue, 21 Feb 2017 13:17:14 -0600 Subject: [PATCH 13/33] add in usage chart page --- Gruntfile.js | 14 + amd/src/Chart.js | 12269 +++++++++++++++++++++++++ amd/src/Chart.min.js | 14 + amd/src/semesterUsageChart.js | 30 + analytics.php | 70 + category_settings.php | 6 +- category_settings_form.php | 10 +- classes/db_seeder.php | 2 +- classes/logger.php | 177 + classes/utility.php | 80 +- {resources => files}/sample_urls.php | 0 lang/en/local_cas_help_links.php | 8 +- package.json | 4 + renderer.php | 13 + 14 files changed, 12624 insertions(+), 73 deletions(-) create mode 100644 amd/src/Chart.js create mode 100644 amd/src/Chart.min.js create mode 100644 amd/src/semesterUsageChart.js create mode 100644 analytics.php rename {resources => files}/sample_urls.php (100%) diff --git a/Gruntfile.js b/Gruntfile.js index 8b458cf..2bcbe59 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -10,6 +10,7 @@ module.exports = function (grunt) { grunt.loadNpmTasks("grunt-contrib-sass"); grunt.loadNpmTasks("grunt-contrib-watch"); grunt.loadNpmTasks("grunt-contrib-clean"); + grunt.loadNpmTasks("grunt-contrib-copy"); grunt.initConfig({ sass: { @@ -19,6 +20,19 @@ module.exports = function (grunt) { } } }, + copy: { + main: { + files: [ + { + expand: true, + src: ['node_modules/chart.js/dist/Chart.js', 'node_modules/chart.js/dist/Chart.min.js'], + dest: 'amd/src/', + flatten: true, + filter: 'isFile' + }, + ], + }, + }, watch: { files: '**/*.sass', tasks: ['sass'] diff --git a/amd/src/Chart.js b/amd/src/Chart.js new file mode 100644 index 0000000..56e06df --- /dev/null +++ b/amd/src/Chart.js @@ -0,0 +1,12269 @@ +/*! + * Chart.js + * http://chartjs.org/ + * Version: 2.5.0 + * + * Copyright 2017 Nick Downie + * Released under the MIT license + * https://github.com/chartjs/Chart.js/blob/master/LICENSE.md + */ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Chart = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o lum2) { + return (lum1 + 0.05) / (lum2 + 0.05); + } + return (lum2 + 0.05) / (lum1 + 0.05); + }, + + level: function (color2) { + var contrastRatio = this.contrast(color2); + if (contrastRatio >= 7.1) { + return 'AAA'; + } + + return (contrastRatio >= 4.5) ? 'AA' : ''; + }, + + dark: function () { + // YIQ equation from http://24ways.org/2010/calculating-color-contrast + var rgb = this.values.rgb; + var yiq = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000; + return yiq < 128; + }, + + light: function () { + return !this.dark(); + }, + + negate: function () { + var rgb = []; + for (var i = 0; i < 3; i++) { + rgb[i] = 255 - this.values.rgb[i]; + } + this.setValues('rgb', rgb); + return this; + }, + + lighten: function (ratio) { + var hsl = this.values.hsl; + hsl[2] += hsl[2] * ratio; + this.setValues('hsl', hsl); + return this; + }, + + darken: function (ratio) { + var hsl = this.values.hsl; + hsl[2] -= hsl[2] * ratio; + this.setValues('hsl', hsl); + return this; + }, + + saturate: function (ratio) { + var hsl = this.values.hsl; + hsl[1] += hsl[1] * ratio; + this.setValues('hsl', hsl); + return this; + }, + + desaturate: function (ratio) { + var hsl = this.values.hsl; + hsl[1] -= hsl[1] * ratio; + this.setValues('hsl', hsl); + return this; + }, + + whiten: function (ratio) { + var hwb = this.values.hwb; + hwb[1] += hwb[1] * ratio; + this.setValues('hwb', hwb); + return this; + }, + + blacken: function (ratio) { + var hwb = this.values.hwb; + hwb[2] += hwb[2] * ratio; + this.setValues('hwb', hwb); + return this; + }, + + greyscale: function () { + var rgb = this.values.rgb; + // http://en.wikipedia.org/wiki/Grayscale#Converting_color_to_grayscale + var val = rgb[0] * 0.3 + rgb[1] * 0.59 + rgb[2] * 0.11; + this.setValues('rgb', [val, val, val]); + return this; + }, + + clearer: function (ratio) { + var alpha = this.values.alpha; + this.setValues('alpha', alpha - (alpha * ratio)); + return this; + }, + + opaquer: function (ratio) { + var alpha = this.values.alpha; + this.setValues('alpha', alpha + (alpha * ratio)); + return this; + }, + + rotate: function (degrees) { + var hsl = this.values.hsl; + var hue = (hsl[0] + degrees) % 360; + hsl[0] = hue < 0 ? 360 + hue : hue; + this.setValues('hsl', hsl); + return this; + }, + + /** + * Ported from sass implementation in C + * https://github.com/sass/libsass/blob/0e6b4a2850092356aa3ece07c6b249f0221caced/functions.cpp#L209 + */ + mix: function (mixinColor, weight) { + var color1 = this; + var color2 = mixinColor; + var p = weight === undefined ? 0.5 : weight; + + var w = 2 * p - 1; + var a = color1.alpha() - color2.alpha(); + + var w1 = (((w * a === -1) ? w : (w + a) / (1 + w * a)) + 1) / 2.0; + var w2 = 1 - w1; + + return this + .rgb( + w1 * color1.red() + w2 * color2.red(), + w1 * color1.green() + w2 * color2.green(), + w1 * color1.blue() + w2 * color2.blue() + ) + .alpha(color1.alpha() * p + color2.alpha() * (1 - p)); + }, + + toJSON: function () { + return this.rgb(); + }, + + clone: function () { + // NOTE(SB): using node-clone creates a dependency to Buffer when using browserify, + // making the final build way to big to embed in Chart.js. So let's do it manually, + // assuming that values to clone are 1 dimension arrays containing only numbers, + // except 'alpha' which is a number. + var result = new Color(); + var source = this.values; + var target = result.values; + var value, type; + + for (var prop in source) { + if (source.hasOwnProperty(prop)) { + value = source[prop]; + type = ({}).toString.call(value); + if (type === '[object Array]') { + target[prop] = value.slice(0); + } else if (type === '[object Number]') { + target[prop] = value; + } else { + console.error('unexpected color value:', value); + } + } + } + + return result; + } +}; + +Color.prototype.spaces = { + rgb: ['red', 'green', 'blue'], + hsl: ['hue', 'saturation', 'lightness'], + hsv: ['hue', 'saturation', 'value'], + hwb: ['hue', 'whiteness', 'blackness'], + cmyk: ['cyan', 'magenta', 'yellow', 'black'] +}; + +Color.prototype.maxes = { + rgb: [255, 255, 255], + hsl: [360, 100, 100], + hsv: [360, 100, 100], + hwb: [360, 100, 100], + cmyk: [100, 100, 100, 100] +}; + +Color.prototype.getValues = function (space) { + var values = this.values; + var vals = {}; + + for (var i = 0; i < space.length; i++) { + vals[space.charAt(i)] = values[space][i]; + } + + if (values.alpha !== 1) { + vals.a = values.alpha; + } + + // {r: 255, g: 255, b: 255, a: 0.4} + return vals; +}; + +Color.prototype.setValues = function (space, vals) { + var values = this.values; + var spaces = this.spaces; + var maxes = this.maxes; + var alpha = 1; + var i; + + if (space === 'alpha') { + alpha = vals; + } else if (vals.length) { + // [10, 10, 10] + values[space] = vals.slice(0, space.length); + alpha = vals[space.length]; + } else if (vals[space.charAt(0)] !== undefined) { + // {r: 10, g: 10, b: 10} + for (i = 0; i < space.length; i++) { + values[space][i] = vals[space.charAt(i)]; + } + + alpha = vals.a; + } else if (vals[spaces[space][0]] !== undefined) { + // {red: 10, green: 10, blue: 10} + var chans = spaces[space]; + + for (i = 0; i < space.length; i++) { + values[space][i] = vals[chans[i]]; + } + + alpha = vals.alpha; + } + + values.alpha = Math.max(0, Math.min(1, (alpha === undefined ? values.alpha : alpha))); + + if (space === 'alpha') { + return false; + } + + var capped; + + // cap values of the space prior converting all values + for (i = 0; i < space.length; i++) { + capped = Math.max(0, Math.min(maxes[space][i], values[space][i])); + values[space][i] = Math.round(capped); + } + + // convert to all the other color spaces + for (var sname in spaces) { + if (sname !== space) { + values[sname] = convert[space][sname](values[space]); + } + } + + return true; +}; + +Color.prototype.setSpace = function (space, args) { + var vals = args[0]; + + if (vals === undefined) { + // color.rgb() + return this.getValues(space); + } + + // color.rgb(10, 10, 10) + if (typeof vals === 'number') { + vals = Array.prototype.slice.call(args); + } + + this.setValues(space, vals); + return this; +}; + +Color.prototype.setChannel = function (space, index, val) { + var svalues = this.values[space]; + if (val === undefined) { + // color.red() + return svalues[index]; + } else if (val === svalues[index]) { + // color.red(color.red()) + return this; + } + + // color.red(100) + svalues[index] = val; + this.setValues(space, svalues); + + return this; +}; + +if (typeof window !== 'undefined') { + window.Color = Color; +} + +module.exports = Color; + +},{"2":2,"5":5}],4:[function(require,module,exports){ +/* MIT license */ + +module.exports = { + rgb2hsl: rgb2hsl, + rgb2hsv: rgb2hsv, + rgb2hwb: rgb2hwb, + rgb2cmyk: rgb2cmyk, + rgb2keyword: rgb2keyword, + rgb2xyz: rgb2xyz, + rgb2lab: rgb2lab, + rgb2lch: rgb2lch, + + hsl2rgb: hsl2rgb, + hsl2hsv: hsl2hsv, + hsl2hwb: hsl2hwb, + hsl2cmyk: hsl2cmyk, + hsl2keyword: hsl2keyword, + + hsv2rgb: hsv2rgb, + hsv2hsl: hsv2hsl, + hsv2hwb: hsv2hwb, + hsv2cmyk: hsv2cmyk, + hsv2keyword: hsv2keyword, + + hwb2rgb: hwb2rgb, + hwb2hsl: hwb2hsl, + hwb2hsv: hwb2hsv, + hwb2cmyk: hwb2cmyk, + hwb2keyword: hwb2keyword, + + cmyk2rgb: cmyk2rgb, + cmyk2hsl: cmyk2hsl, + cmyk2hsv: cmyk2hsv, + cmyk2hwb: cmyk2hwb, + cmyk2keyword: cmyk2keyword, + + keyword2rgb: keyword2rgb, + keyword2hsl: keyword2hsl, + keyword2hsv: keyword2hsv, + keyword2hwb: keyword2hwb, + keyword2cmyk: keyword2cmyk, + keyword2lab: keyword2lab, + keyword2xyz: keyword2xyz, + + xyz2rgb: xyz2rgb, + xyz2lab: xyz2lab, + xyz2lch: xyz2lch, + + lab2xyz: lab2xyz, + lab2rgb: lab2rgb, + lab2lch: lab2lch, + + lch2lab: lch2lab, + lch2xyz: lch2xyz, + lch2rgb: lch2rgb +} + + +function rgb2hsl(rgb) { + var r = rgb[0]/255, + g = rgb[1]/255, + b = rgb[2]/255, + min = Math.min(r, g, b), + max = Math.max(r, g, b), + delta = max - min, + h, s, l; + + if (max == min) + h = 0; + else if (r == max) + h = (g - b) / delta; + else if (g == max) + h = 2 + (b - r) / delta; + else if (b == max) + h = 4 + (r - g)/ delta; + + h = Math.min(h * 60, 360); + + if (h < 0) + h += 360; + + l = (min + max) / 2; + + if (max == min) + s = 0; + else if (l <= 0.5) + s = delta / (max + min); + else + s = delta / (2 - max - min); + + return [h, s * 100, l * 100]; +} + +function rgb2hsv(rgb) { + var r = rgb[0], + g = rgb[1], + b = rgb[2], + min = Math.min(r, g, b), + max = Math.max(r, g, b), + delta = max - min, + h, s, v; + + if (max == 0) + s = 0; + else + s = (delta/max * 1000)/10; + + if (max == min) + h = 0; + else if (r == max) + h = (g - b) / delta; + else if (g == max) + h = 2 + (b - r) / delta; + else if (b == max) + h = 4 + (r - g) / delta; + + h = Math.min(h * 60, 360); + + if (h < 0) + h += 360; + + v = ((max / 255) * 1000) / 10; + + return [h, s, v]; +} + +function rgb2hwb(rgb) { + var r = rgb[0], + g = rgb[1], + b = rgb[2], + h = rgb2hsl(rgb)[0], + w = 1/255 * Math.min(r, Math.min(g, b)), + b = 1 - 1/255 * Math.max(r, Math.max(g, b)); + + return [h, w * 100, b * 100]; +} + +function rgb2cmyk(rgb) { + var r = rgb[0] / 255, + g = rgb[1] / 255, + b = rgb[2] / 255, + c, m, y, k; + + k = Math.min(1 - r, 1 - g, 1 - b); + c = (1 - r - k) / (1 - k) || 0; + m = (1 - g - k) / (1 - k) || 0; + y = (1 - b - k) / (1 - k) || 0; + return [c * 100, m * 100, y * 100, k * 100]; +} + +function rgb2keyword(rgb) { + return reverseKeywords[JSON.stringify(rgb)]; +} + +function rgb2xyz(rgb) { + var r = rgb[0] / 255, + g = rgb[1] / 255, + b = rgb[2] / 255; + + // assume sRGB + r = r > 0.04045 ? Math.pow(((r + 0.055) / 1.055), 2.4) : (r / 12.92); + g = g > 0.04045 ? Math.pow(((g + 0.055) / 1.055), 2.4) : (g / 12.92); + b = b > 0.04045 ? Math.pow(((b + 0.055) / 1.055), 2.4) : (b / 12.92); + + var x = (r * 0.4124) + (g * 0.3576) + (b * 0.1805); + var y = (r * 0.2126) + (g * 0.7152) + (b * 0.0722); + var z = (r * 0.0193) + (g * 0.1192) + (b * 0.9505); + + return [x * 100, y *100, z * 100]; +} + +function rgb2lab(rgb) { + var xyz = rgb2xyz(rgb), + x = xyz[0], + y = xyz[1], + z = xyz[2], + l, a, b; + + x /= 95.047; + y /= 100; + z /= 108.883; + + x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x) + (16 / 116); + y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y) + (16 / 116); + z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z) + (16 / 116); + + l = (116 * y) - 16; + a = 500 * (x - y); + b = 200 * (y - z); + + return [l, a, b]; +} + +function rgb2lch(args) { + return lab2lch(rgb2lab(args)); +} + +function hsl2rgb(hsl) { + var h = hsl[0] / 360, + s = hsl[1] / 100, + l = hsl[2] / 100, + t1, t2, t3, rgb, val; + + if (s == 0) { + val = l * 255; + return [val, val, val]; + } + + if (l < 0.5) + t2 = l * (1 + s); + else + t2 = l + s - l * s; + t1 = 2 * l - t2; + + rgb = [0, 0, 0]; + for (var i = 0; i < 3; i++) { + t3 = h + 1 / 3 * - (i - 1); + t3 < 0 && t3++; + t3 > 1 && t3--; + + if (6 * t3 < 1) + val = t1 + (t2 - t1) * 6 * t3; + else if (2 * t3 < 1) + val = t2; + else if (3 * t3 < 2) + val = t1 + (t2 - t1) * (2 / 3 - t3) * 6; + else + val = t1; + + rgb[i] = val * 255; + } + + return rgb; +} + +function hsl2hsv(hsl) { + var h = hsl[0], + s = hsl[1] / 100, + l = hsl[2] / 100, + sv, v; + + if(l === 0) { + // no need to do calc on black + // also avoids divide by 0 error + return [0, 0, 0]; + } + + l *= 2; + s *= (l <= 1) ? l : 2 - l; + v = (l + s) / 2; + sv = (2 * s) / (l + s); + return [h, sv * 100, v * 100]; +} + +function hsl2hwb(args) { + return rgb2hwb(hsl2rgb(args)); +} + +function hsl2cmyk(args) { + return rgb2cmyk(hsl2rgb(args)); +} + +function hsl2keyword(args) { + return rgb2keyword(hsl2rgb(args)); +} + + +function hsv2rgb(hsv) { + var h = hsv[0] / 60, + s = hsv[1] / 100, + v = hsv[2] / 100, + hi = Math.floor(h) % 6; + + var f = h - Math.floor(h), + p = 255 * v * (1 - s), + q = 255 * v * (1 - (s * f)), + t = 255 * v * (1 - (s * (1 - f))), + v = 255 * v; + + switch(hi) { + case 0: + return [v, t, p]; + case 1: + return [q, v, p]; + case 2: + return [p, v, t]; + case 3: + return [p, q, v]; + case 4: + return [t, p, v]; + case 5: + return [v, p, q]; + } +} + +function hsv2hsl(hsv) { + var h = hsv[0], + s = hsv[1] / 100, + v = hsv[2] / 100, + sl, l; + + l = (2 - s) * v; + sl = s * v; + sl /= (l <= 1) ? l : 2 - l; + sl = sl || 0; + l /= 2; + return [h, sl * 100, l * 100]; +} + +function hsv2hwb(args) { + return rgb2hwb(hsv2rgb(args)) +} + +function hsv2cmyk(args) { + return rgb2cmyk(hsv2rgb(args)); +} + +function hsv2keyword(args) { + return rgb2keyword(hsv2rgb(args)); +} + +// http://dev.w3.org/csswg/css-color/#hwb-to-rgb +function hwb2rgb(hwb) { + var h = hwb[0] / 360, + wh = hwb[1] / 100, + bl = hwb[2] / 100, + ratio = wh + bl, + i, v, f, n; + + // wh + bl cant be > 1 + if (ratio > 1) { + wh /= ratio; + bl /= ratio; + } + + i = Math.floor(6 * h); + v = 1 - bl; + f = 6 * h - i; + if ((i & 0x01) != 0) { + f = 1 - f; + } + n = wh + f * (v - wh); // linear interpolation + + switch (i) { + default: + case 6: + case 0: r = v; g = n; b = wh; break; + case 1: r = n; g = v; b = wh; break; + case 2: r = wh; g = v; b = n; break; + case 3: r = wh; g = n; b = v; break; + case 4: r = n; g = wh; b = v; break; + case 5: r = v; g = wh; b = n; break; + } + + return [r * 255, g * 255, b * 255]; +} + +function hwb2hsl(args) { + return rgb2hsl(hwb2rgb(args)); +} + +function hwb2hsv(args) { + return rgb2hsv(hwb2rgb(args)); +} + +function hwb2cmyk(args) { + return rgb2cmyk(hwb2rgb(args)); +} + +function hwb2keyword(args) { + return rgb2keyword(hwb2rgb(args)); +} + +function cmyk2rgb(cmyk) { + var c = cmyk[0] / 100, + m = cmyk[1] / 100, + y = cmyk[2] / 100, + k = cmyk[3] / 100, + r, g, b; + + r = 1 - Math.min(1, c * (1 - k) + k); + g = 1 - Math.min(1, m * (1 - k) + k); + b = 1 - Math.min(1, y * (1 - k) + k); + return [r * 255, g * 255, b * 255]; +} + +function cmyk2hsl(args) { + return rgb2hsl(cmyk2rgb(args)); +} + +function cmyk2hsv(args) { + return rgb2hsv(cmyk2rgb(args)); +} + +function cmyk2hwb(args) { + return rgb2hwb(cmyk2rgb(args)); +} + +function cmyk2keyword(args) { + return rgb2keyword(cmyk2rgb(args)); +} + + +function xyz2rgb(xyz) { + var x = xyz[0] / 100, + y = xyz[1] / 100, + z = xyz[2] / 100, + r, g, b; + + r = (x * 3.2406) + (y * -1.5372) + (z * -0.4986); + g = (x * -0.9689) + (y * 1.8758) + (z * 0.0415); + b = (x * 0.0557) + (y * -0.2040) + (z * 1.0570); + + // assume sRGB + r = r > 0.0031308 ? ((1.055 * Math.pow(r, 1.0 / 2.4)) - 0.055) + : r = (r * 12.92); + + g = g > 0.0031308 ? ((1.055 * Math.pow(g, 1.0 / 2.4)) - 0.055) + : g = (g * 12.92); + + b = b > 0.0031308 ? ((1.055 * Math.pow(b, 1.0 / 2.4)) - 0.055) + : b = (b * 12.92); + + r = Math.min(Math.max(0, r), 1); + g = Math.min(Math.max(0, g), 1); + b = Math.min(Math.max(0, b), 1); + + return [r * 255, g * 255, b * 255]; +} + +function xyz2lab(xyz) { + var x = xyz[0], + y = xyz[1], + z = xyz[2], + l, a, b; + + x /= 95.047; + y /= 100; + z /= 108.883; + + x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x) + (16 / 116); + y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y) + (16 / 116); + z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z) + (16 / 116); + + l = (116 * y) - 16; + a = 500 * (x - y); + b = 200 * (y - z); + + return [l, a, b]; +} + +function xyz2lch(args) { + return lab2lch(xyz2lab(args)); +} + +function lab2xyz(lab) { + var l = lab[0], + a = lab[1], + b = lab[2], + x, y, z, y2; + + if (l <= 8) { + y = (l * 100) / 903.3; + y2 = (7.787 * (y / 100)) + (16 / 116); + } else { + y = 100 * Math.pow((l + 16) / 116, 3); + y2 = Math.pow(y / 100, 1/3); + } + + x = x / 95.047 <= 0.008856 ? x = (95.047 * ((a / 500) + y2 - (16 / 116))) / 7.787 : 95.047 * Math.pow((a / 500) + y2, 3); + + z = z / 108.883 <= 0.008859 ? z = (108.883 * (y2 - (b / 200) - (16 / 116))) / 7.787 : 108.883 * Math.pow(y2 - (b / 200), 3); + + return [x, y, z]; +} + +function lab2lch(lab) { + var l = lab[0], + a = lab[1], + b = lab[2], + hr, h, c; + + hr = Math.atan2(b, a); + h = hr * 360 / 2 / Math.PI; + if (h < 0) { + h += 360; + } + c = Math.sqrt(a * a + b * b); + return [l, c, h]; +} + +function lab2rgb(args) { + return xyz2rgb(lab2xyz(args)); +} + +function lch2lab(lch) { + var l = lch[0], + c = lch[1], + h = lch[2], + a, b, hr; + + hr = h / 360 * 2 * Math.PI; + a = c * Math.cos(hr); + b = c * Math.sin(hr); + return [l, a, b]; +} + +function lch2xyz(args) { + return lab2xyz(lch2lab(args)); +} + +function lch2rgb(args) { + return lab2rgb(lch2lab(args)); +} + +function keyword2rgb(keyword) { + return cssKeywords[keyword]; +} + +function keyword2hsl(args) { + return rgb2hsl(keyword2rgb(args)); +} + +function keyword2hsv(args) { + return rgb2hsv(keyword2rgb(args)); +} + +function keyword2hwb(args) { + return rgb2hwb(keyword2rgb(args)); +} + +function keyword2cmyk(args) { + return rgb2cmyk(keyword2rgb(args)); +} + +function keyword2lab(args) { + return rgb2lab(keyword2rgb(args)); +} + +function keyword2xyz(args) { + return rgb2xyz(keyword2rgb(args)); +} + +var cssKeywords = { + aliceblue: [240,248,255], + antiquewhite: [250,235,215], + aqua: [0,255,255], + aquamarine: [127,255,212], + azure: [240,255,255], + beige: [245,245,220], + bisque: [255,228,196], + black: [0,0,0], + blanchedalmond: [255,235,205], + blue: [0,0,255], + blueviolet: [138,43,226], + brown: [165,42,42], + burlywood: [222,184,135], + cadetblue: [95,158,160], + chartreuse: [127,255,0], + chocolate: [210,105,30], + coral: [255,127,80], + cornflowerblue: [100,149,237], + cornsilk: [255,248,220], + crimson: [220,20,60], + cyan: [0,255,255], + darkblue: [0,0,139], + darkcyan: [0,139,139], + darkgoldenrod: [184,134,11], + darkgray: [169,169,169], + darkgreen: [0,100,0], + darkgrey: [169,169,169], + darkkhaki: [189,183,107], + darkmagenta: [139,0,139], + darkolivegreen: [85,107,47], + darkorange: [255,140,0], + darkorchid: [153,50,204], + darkred: [139,0,0], + darksalmon: [233,150,122], + darkseagreen: [143,188,143], + darkslateblue: [72,61,139], + darkslategray: [47,79,79], + darkslategrey: [47,79,79], + darkturquoise: [0,206,209], + darkviolet: [148,0,211], + deeppink: [255,20,147], + deepskyblue: [0,191,255], + dimgray: [105,105,105], + dimgrey: [105,105,105], + dodgerblue: [30,144,255], + firebrick: [178,34,34], + floralwhite: [255,250,240], + forestgreen: [34,139,34], + fuchsia: [255,0,255], + gainsboro: [220,220,220], + ghostwhite: [248,248,255], + gold: [255,215,0], + goldenrod: [218,165,32], + gray: [128,128,128], + green: [0,128,0], + greenyellow: [173,255,47], + grey: [128,128,128], + honeydew: [240,255,240], + hotpink: [255,105,180], + indianred: [205,92,92], + indigo: [75,0,130], + ivory: [255,255,240], + khaki: [240,230,140], + lavender: [230,230,250], + lavenderblush: [255,240,245], + lawngreen: [124,252,0], + lemonchiffon: [255,250,205], + lightblue: [173,216,230], + lightcoral: [240,128,128], + lightcyan: [224,255,255], + lightgoldenrodyellow: [250,250,210], + lightgray: [211,211,211], + lightgreen: [144,238,144], + lightgrey: [211,211,211], + lightpink: [255,182,193], + lightsalmon: [255,160,122], + lightseagreen: [32,178,170], + lightskyblue: [135,206,250], + lightslategray: [119,136,153], + lightslategrey: [119,136,153], + lightsteelblue: [176,196,222], + lightyellow: [255,255,224], + lime: [0,255,0], + limegreen: [50,205,50], + linen: [250,240,230], + magenta: [255,0,255], + maroon: [128,0,0], + mediumaquamarine: [102,205,170], + mediumblue: [0,0,205], + mediumorchid: [186,85,211], + mediumpurple: [147,112,219], + mediumseagreen: [60,179,113], + mediumslateblue: [123,104,238], + mediumspringgreen: [0,250,154], + mediumturquoise: [72,209,204], + mediumvioletred: [199,21,133], + midnightblue: [25,25,112], + mintcream: [245,255,250], + mistyrose: [255,228,225], + moccasin: [255,228,181], + navajowhite: [255,222,173], + navy: [0,0,128], + oldlace: [253,245,230], + olive: [128,128,0], + olivedrab: [107,142,35], + orange: [255,165,0], + orangered: [255,69,0], + orchid: [218,112,214], + palegoldenrod: [238,232,170], + palegreen: [152,251,152], + paleturquoise: [175,238,238], + palevioletred: [219,112,147], + papayawhip: [255,239,213], + peachpuff: [255,218,185], + peru: [205,133,63], + pink: [255,192,203], + plum: [221,160,221], + powderblue: [176,224,230], + purple: [128,0,128], + rebeccapurple: [102, 51, 153], + red: [255,0,0], + rosybrown: [188,143,143], + royalblue: [65,105,225], + saddlebrown: [139,69,19], + salmon: [250,128,114], + sandybrown: [244,164,96], + seagreen: [46,139,87], + seashell: [255,245,238], + sienna: [160,82,45], + silver: [192,192,192], + skyblue: [135,206,235], + slateblue: [106,90,205], + slategray: [112,128,144], + slategrey: [112,128,144], + snow: [255,250,250], + springgreen: [0,255,127], + steelblue: [70,130,180], + tan: [210,180,140], + teal: [0,128,128], + thistle: [216,191,216], + tomato: [255,99,71], + turquoise: [64,224,208], + violet: [238,130,238], + wheat: [245,222,179], + white: [255,255,255], + whitesmoke: [245,245,245], + yellow: [255,255,0], + yellowgreen: [154,205,50] +}; + +var reverseKeywords = {}; +for (var key in cssKeywords) { + reverseKeywords[JSON.stringify(cssKeywords[key])] = key; +} + +},{}],5:[function(require,module,exports){ +var conversions = require(4); + +var convert = function() { + return new Converter(); +} + +for (var func in conversions) { + // export Raw versions + convert[func + "Raw"] = (function(func) { + // accept array or plain args + return function(arg) { + if (typeof arg == "number") + arg = Array.prototype.slice.call(arguments); + return conversions[func](arg); + } + })(func); + + var pair = /(\w+)2(\w+)/.exec(func), + from = pair[1], + to = pair[2]; + + // export rgb2hsl and ["rgb"]["hsl"] + convert[from] = convert[from] || {}; + + convert[from][to] = convert[func] = (function(func) { + return function(arg) { + if (typeof arg == "number") + arg = Array.prototype.slice.call(arguments); + + var val = conversions[func](arg); + if (typeof val == "string" || val === undefined) + return val; // keyword + + for (var i = 0; i < val.length; i++) + val[i] = Math.round(val[i]); + return val; + } + })(func); +} + + +/* Converter does lazy conversion and caching */ +var Converter = function() { + this.convs = {}; +}; + +/* Either get the values for a space or + set the values for a space, depending on args */ +Converter.prototype.routeSpace = function(space, args) { + var values = args[0]; + if (values === undefined) { + // color.rgb() + return this.getValues(space); + } + // color.rgb(10, 10, 10) + if (typeof values == "number") { + values = Array.prototype.slice.call(args); + } + + return this.setValues(space, values); +}; + +/* Set the values for a space, invalidating cache */ +Converter.prototype.setValues = function(space, values) { + this.space = space; + this.convs = {}; + this.convs[space] = values; + return this; +}; + +/* Get the values for a space. If there's already + a conversion for the space, fetch it, otherwise + compute it */ +Converter.prototype.getValues = function(space) { + var vals = this.convs[space]; + if (!vals) { + var fspace = this.space, + from = this.convs[fspace]; + vals = convert[fspace][space](from); + + this.convs[space] = vals; + } + return vals; +}; + +["rgb", "hsl", "hsv", "cmyk", "keyword"].forEach(function(space) { + Converter.prototype[space] = function(vals) { + return this.routeSpace(space, arguments); + } +}); + +module.exports = convert; +},{"4":4}],6:[function(require,module,exports){ +module.exports = { + "aliceblue": [240, 248, 255], + "antiquewhite": [250, 235, 215], + "aqua": [0, 255, 255], + "aquamarine": [127, 255, 212], + "azure": [240, 255, 255], + "beige": [245, 245, 220], + "bisque": [255, 228, 196], + "black": [0, 0, 0], + "blanchedalmond": [255, 235, 205], + "blue": [0, 0, 255], + "blueviolet": [138, 43, 226], + "brown": [165, 42, 42], + "burlywood": [222, 184, 135], + "cadetblue": [95, 158, 160], + "chartreuse": [127, 255, 0], + "chocolate": [210, 105, 30], + "coral": [255, 127, 80], + "cornflowerblue": [100, 149, 237], + "cornsilk": [255, 248, 220], + "crimson": [220, 20, 60], + "cyan": [0, 255, 255], + "darkblue": [0, 0, 139], + "darkcyan": [0, 139, 139], + "darkgoldenrod": [184, 134, 11], + "darkgray": [169, 169, 169], + "darkgreen": [0, 100, 0], + "darkgrey": [169, 169, 169], + "darkkhaki": [189, 183, 107], + "darkmagenta": [139, 0, 139], + "darkolivegreen": [85, 107, 47], + "darkorange": [255, 140, 0], + "darkorchid": [153, 50, 204], + "darkred": [139, 0, 0], + "darksalmon": [233, 150, 122], + "darkseagreen": [143, 188, 143], + "darkslateblue": [72, 61, 139], + "darkslategray": [47, 79, 79], + "darkslategrey": [47, 79, 79], + "darkturquoise": [0, 206, 209], + "darkviolet": [148, 0, 211], + "deeppink": [255, 20, 147], + "deepskyblue": [0, 191, 255], + "dimgray": [105, 105, 105], + "dimgrey": [105, 105, 105], + "dodgerblue": [30, 144, 255], + "firebrick": [178, 34, 34], + "floralwhite": [255, 250, 240], + "forestgreen": [34, 139, 34], + "fuchsia": [255, 0, 255], + "gainsboro": [220, 220, 220], + "ghostwhite": [248, 248, 255], + "gold": [255, 215, 0], + "goldenrod": [218, 165, 32], + "gray": [128, 128, 128], + "green": [0, 128, 0], + "greenyellow": [173, 255, 47], + "grey": [128, 128, 128], + "honeydew": [240, 255, 240], + "hotpink": [255, 105, 180], + "indianred": [205, 92, 92], + "indigo": [75, 0, 130], + "ivory": [255, 255, 240], + "khaki": [240, 230, 140], + "lavender": [230, 230, 250], + "lavenderblush": [255, 240, 245], + "lawngreen": [124, 252, 0], + "lemonchiffon": [255, 250, 205], + "lightblue": [173, 216, 230], + "lightcoral": [240, 128, 128], + "lightcyan": [224, 255, 255], + "lightgoldenrodyellow": [250, 250, 210], + "lightgray": [211, 211, 211], + "lightgreen": [144, 238, 144], + "lightgrey": [211, 211, 211], + "lightpink": [255, 182, 193], + "lightsalmon": [255, 160, 122], + "lightseagreen": [32, 178, 170], + "lightskyblue": [135, 206, 250], + "lightslategray": [119, 136, 153], + "lightslategrey": [119, 136, 153], + "lightsteelblue": [176, 196, 222], + "lightyellow": [255, 255, 224], + "lime": [0, 255, 0], + "limegreen": [50, 205, 50], + "linen": [250, 240, 230], + "magenta": [255, 0, 255], + "maroon": [128, 0, 0], + "mediumaquamarine": [102, 205, 170], + "mediumblue": [0, 0, 205], + "mediumorchid": [186, 85, 211], + "mediumpurple": [147, 112, 219], + "mediumseagreen": [60, 179, 113], + "mediumslateblue": [123, 104, 238], + "mediumspringgreen": [0, 250, 154], + "mediumturquoise": [72, 209, 204], + "mediumvioletred": [199, 21, 133], + "midnightblue": [25, 25, 112], + "mintcream": [245, 255, 250], + "mistyrose": [255, 228, 225], + "moccasin": [255, 228, 181], + "navajowhite": [255, 222, 173], + "navy": [0, 0, 128], + "oldlace": [253, 245, 230], + "olive": [128, 128, 0], + "olivedrab": [107, 142, 35], + "orange": [255, 165, 0], + "orangered": [255, 69, 0], + "orchid": [218, 112, 214], + "palegoldenrod": [238, 232, 170], + "palegreen": [152, 251, 152], + "paleturquoise": [175, 238, 238], + "palevioletred": [219, 112, 147], + "papayawhip": [255, 239, 213], + "peachpuff": [255, 218, 185], + "peru": [205, 133, 63], + "pink": [255, 192, 203], + "plum": [221, 160, 221], + "powderblue": [176, 224, 230], + "purple": [128, 0, 128], + "rebeccapurple": [102, 51, 153], + "red": [255, 0, 0], + "rosybrown": [188, 143, 143], + "royalblue": [65, 105, 225], + "saddlebrown": [139, 69, 19], + "salmon": [250, 128, 114], + "sandybrown": [244, 164, 96], + "seagreen": [46, 139, 87], + "seashell": [255, 245, 238], + "sienna": [160, 82, 45], + "silver": [192, 192, 192], + "skyblue": [135, 206, 235], + "slateblue": [106, 90, 205], + "slategray": [112, 128, 144], + "slategrey": [112, 128, 144], + "snow": [255, 250, 250], + "springgreen": [0, 255, 127], + "steelblue": [70, 130, 180], + "tan": [210, 180, 140], + "teal": [0, 128, 128], + "thistle": [216, 191, 216], + "tomato": [255, 99, 71], + "turquoise": [64, 224, 208], + "violet": [238, 130, 238], + "wheat": [245, 222, 179], + "white": [255, 255, 255], + "whitesmoke": [245, 245, 245], + "yellow": [255, 255, 0], + "yellowgreen": [154, 205, 50] +}; +},{}],7:[function(require,module,exports){ +/** + * @namespace Chart + */ +var Chart = require(28)(); + +require(26)(Chart); +require(42)(Chart); +require(22)(Chart); +require(31)(Chart); +require(25)(Chart); +require(21)(Chart); +require(23)(Chart); +require(24)(Chart); +require(29)(Chart); +require(33)(Chart); +require(34)(Chart); +require(32)(Chart); +require(35)(Chart); +require(30)(Chart); +require(27)(Chart); +require(36)(Chart); + +require(37)(Chart); +require(38)(Chart); +require(39)(Chart); +require(40)(Chart); + +require(45)(Chart); +require(43)(Chart); +require(44)(Chart); +require(46)(Chart); +require(47)(Chart); +require(48)(Chart); + +// Controllers must be loaded after elements +// See Chart.core.datasetController.dataElementType +require(15)(Chart); +require(16)(Chart); +require(17)(Chart); +require(18)(Chart); +require(19)(Chart); +require(20)(Chart); + +require(8)(Chart); +require(9)(Chart); +require(10)(Chart); +require(11)(Chart); +require(12)(Chart); +require(13)(Chart); +require(14)(Chart); + +window.Chart = module.exports = Chart; + +},{"10":10,"11":11,"12":12,"13":13,"14":14,"15":15,"16":16,"17":17,"18":18,"19":19,"20":20,"21":21,"22":22,"23":23,"24":24,"25":25,"26":26,"27":27,"28":28,"29":29,"30":30,"31":31,"32":32,"33":33,"34":34,"35":35,"36":36,"37":37,"38":38,"39":39,"40":40,"42":42,"43":43,"44":44,"45":45,"46":46,"47":47,"48":48,"8":8,"9":9}],8:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + Chart.Bar = function(context, config) { + config.type = 'bar'; + + return new Chart(context, config); + }; + +}; + +},{}],9:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + Chart.Bubble = function(context, config) { + config.type = 'bubble'; + return new Chart(context, config); + }; + +}; + +},{}],10:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + Chart.Doughnut = function(context, config) { + config.type = 'doughnut'; + + return new Chart(context, config); + }; + +}; + +},{}],11:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + Chart.Line = function(context, config) { + config.type = 'line'; + + return new Chart(context, config); + }; + +}; + +},{}],12:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + Chart.PolarArea = function(context, config) { + config.type = 'polarArea'; + + return new Chart(context, config); + }; + +}; + +},{}],13:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + Chart.Radar = function(context, config) { + config.type = 'radar'; + + return new Chart(context, config); + }; + +}; + +},{}],14:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + var defaultConfig = { + hover: { + mode: 'single' + }, + + scales: { + xAxes: [{ + type: 'linear', // scatter should not use a category axis + position: 'bottom', + id: 'x-axis-1' // need an ID so datasets can reference the scale + }], + yAxes: [{ + type: 'linear', + position: 'left', + id: 'y-axis-1' + }] + }, + + tooltips: { + callbacks: { + title: function() { + // Title doesn't make sense for scatter since we format the data as a point + return ''; + }, + label: function(tooltipItem) { + return '(' + tooltipItem.xLabel + ', ' + tooltipItem.yLabel + ')'; + } + } + } + }; + + // Register the default config for this type + Chart.defaults.scatter = defaultConfig; + + // Scatter charts use line controllers + Chart.controllers.scatter = Chart.controllers.line; + + Chart.Scatter = function(context, config) { + config.type = 'scatter'; + return new Chart(context, config); + }; + +}; + +},{}],15:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + var helpers = Chart.helpers; + + Chart.defaults.bar = { + hover: { + mode: 'label' + }, + + scales: { + xAxes: [{ + type: 'category', + + // Specific to Bar Controller + categoryPercentage: 0.8, + barPercentage: 0.9, + + // grid line settings + gridLines: { + offsetGridLines: true + } + }], + yAxes: [{ + type: 'linear' + }] + } + }; + + Chart.controllers.bar = Chart.DatasetController.extend({ + + dataElementType: Chart.elements.Rectangle, + + initialize: function(chart, datasetIndex) { + Chart.DatasetController.prototype.initialize.call(this, chart, datasetIndex); + + var me = this; + var meta = me.getMeta(); + var dataset = me.getDataset(); + + meta.stack = dataset.stack; + // Use this to indicate that this is a bar dataset. + meta.bar = true; + }, + + // Correctly calculate the bar width accounting for stacks and the fact that not all bars are visible + getStackCount: function() { + var me = this; + var meta = me.getMeta(); + var yScale = me.getScaleForId(meta.yAxisID); + + var stacks = []; + helpers.each(me.chart.data.datasets, function(dataset, datasetIndex) { + var dsMeta = me.chart.getDatasetMeta(datasetIndex); + if (dsMeta.bar && me.chart.isDatasetVisible(datasetIndex) && + (yScale.options.stacked === false || + (yScale.options.stacked === true && stacks.indexOf(dsMeta.stack) === -1) || + (yScale.options.stacked === undefined && (dsMeta.stack === undefined || stacks.indexOf(dsMeta.stack) === -1)))) { + stacks.push(dsMeta.stack); + } + }, me); + + return stacks.length; + }, + + update: function(reset) { + var me = this; + helpers.each(me.getMeta().data, function(rectangle, index) { + me.updateElement(rectangle, index, reset); + }, me); + }, + + updateElement: function(rectangle, index, reset) { + var me = this; + var meta = me.getMeta(); + var xScale = me.getScaleForId(meta.xAxisID); + var yScale = me.getScaleForId(meta.yAxisID); + var scaleBase = yScale.getBasePixel(); + var rectangleElementOptions = me.chart.options.elements.rectangle; + var custom = rectangle.custom || {}; + var dataset = me.getDataset(); + + rectangle._xScale = xScale; + rectangle._yScale = yScale; + rectangle._datasetIndex = me.index; + rectangle._index = index; + + var ruler = me.getRuler(index); // The index argument for compatible + rectangle._model = { + x: me.calculateBarX(index, me.index, ruler), + y: reset ? scaleBase : me.calculateBarY(index, me.index), + + // Tooltip + label: me.chart.data.labels[index], + datasetLabel: dataset.label, + + // Appearance + horizontal: false, + base: reset ? scaleBase : me.calculateBarBase(me.index, index), + width: me.calculateBarWidth(ruler), + backgroundColor: custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(dataset.backgroundColor, index, rectangleElementOptions.backgroundColor), + borderSkipped: custom.borderSkipped ? custom.borderSkipped : rectangleElementOptions.borderSkipped, + borderColor: custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor), + borderWidth: custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth) + }; + + rectangle.pivot(); + }, + + calculateBarBase: function(datasetIndex, index) { + var me = this; + var meta = me.getMeta(); + var yScale = me.getScaleForId(meta.yAxisID); + var base = yScale.getBaseValue(); + var original = base; + + if ((yScale.options.stacked === true) || + (yScale.options.stacked === undefined && meta.stack !== undefined)) { + var chart = me.chart; + var datasets = chart.data.datasets; + var value = Number(datasets[datasetIndex].data[index]); + + for (var i = 0; i < datasetIndex; i++) { + var currentDs = datasets[i]; + var currentDsMeta = chart.getDatasetMeta(i); + if (currentDsMeta.bar && currentDsMeta.yAxisID === yScale.id && chart.isDatasetVisible(i) && + meta.stack === currentDsMeta.stack) { + var currentVal = Number(currentDs.data[index]); + base += value < 0 ? Math.min(currentVal, original) : Math.max(currentVal, original); + } + } + + return yScale.getPixelForValue(base); + } + + return yScale.getBasePixel(); + }, + + getRuler: function() { + var me = this; + var meta = me.getMeta(); + var xScale = me.getScaleForId(meta.xAxisID); + var stackCount = me.getStackCount(); + + var tickWidth = xScale.width / xScale.ticks.length; + var categoryWidth = tickWidth * xScale.options.categoryPercentage; + var categorySpacing = (tickWidth - (tickWidth * xScale.options.categoryPercentage)) / 2; + var fullBarWidth = categoryWidth / stackCount; + + var barWidth = fullBarWidth * xScale.options.barPercentage; + var barSpacing = fullBarWidth - (fullBarWidth * xScale.options.barPercentage); + + return { + stackCount: stackCount, + tickWidth: tickWidth, + categoryWidth: categoryWidth, + categorySpacing: categorySpacing, + fullBarWidth: fullBarWidth, + barWidth: barWidth, + barSpacing: barSpacing + }; + }, + + calculateBarWidth: function(ruler) { + var me = this; + var meta = me.getMeta(); + var xScale = me.getScaleForId(meta.xAxisID); + if (xScale.options.barThickness) { + return xScale.options.barThickness; + } + return ruler.barWidth; + }, + + // Get stack index from the given dataset index accounting for stacks and the fact that not all bars are visible + getStackIndex: function(datasetIndex) { + var me = this; + var meta = me.chart.getDatasetMeta(datasetIndex); + var yScale = me.getScaleForId(meta.yAxisID); + var dsMeta, j; + var stacks = [meta.stack]; + + for (j = 0; j < datasetIndex; ++j) { + dsMeta = this.chart.getDatasetMeta(j); + if (dsMeta.bar && this.chart.isDatasetVisible(j) && + (yScale.options.stacked === false || + (yScale.options.stacked === true && stacks.indexOf(dsMeta.stack) === -1) || + (yScale.options.stacked === undefined && (dsMeta.stack === undefined || stacks.indexOf(dsMeta.stack) === -1)))) { + stacks.push(dsMeta.stack); + } + } + + return stacks.length - 1; + }, + + calculateBarX: function(index, datasetIndex, ruler) { + var me = this; + var meta = me.getMeta(); + var xScale = me.getScaleForId(meta.xAxisID); + var stackIndex = me.getStackIndex(datasetIndex); + var leftTick = xScale.getPixelForValue(null, index, datasetIndex, me.chart.isCombo); + leftTick -= me.chart.isCombo ? (ruler.tickWidth / 2) : 0; + + return leftTick + + (ruler.barWidth / 2) + + ruler.categorySpacing + + (ruler.barWidth * stackIndex) + + (ruler.barSpacing / 2) + + (ruler.barSpacing * stackIndex); + }, + + calculateBarY: function(index, datasetIndex) { + var me = this; + var meta = me.getMeta(); + var yScale = me.getScaleForId(meta.yAxisID); + var value = Number(me.getDataset().data[index]); + + if (yScale.options.stacked || + (yScale.options.stacked === undefined && meta.stack !== undefined)) { + var base = yScale.getBaseValue(); + var sumPos = base, + sumNeg = base; + + for (var i = 0; i < datasetIndex; i++) { + var ds = me.chart.data.datasets[i]; + var dsMeta = me.chart.getDatasetMeta(i); + if (dsMeta.bar && dsMeta.yAxisID === yScale.id && me.chart.isDatasetVisible(i) && + meta.stack === dsMeta.stack) { + var stackedVal = Number(ds.data[index]); + if (stackedVal < 0) { + sumNeg += stackedVal || 0; + } else { + sumPos += stackedVal || 0; + } + } + } + + if (value < 0) { + return yScale.getPixelForValue(sumNeg + value); + } + return yScale.getPixelForValue(sumPos + value); + } + + return yScale.getPixelForValue(value); + }, + + draw: function(ease) { + var me = this; + var easingDecimal = ease || 1; + var metaData = me.getMeta().data; + var dataset = me.getDataset(); + var i, len; + + Chart.canvasHelpers.clipArea(me.chart.chart.ctx, me.chart.chartArea); + for (i = 0, len = metaData.length; i < len; ++i) { + var d = dataset.data[i]; + if (d !== null && d !== undefined && !isNaN(d)) { + metaData[i].transition(easingDecimal).draw(); + } + } + Chart.canvasHelpers.unclipArea(me.chart.chart.ctx); + }, + + setHoverStyle: function(rectangle) { + var dataset = this.chart.data.datasets[rectangle._datasetIndex]; + var index = rectangle._index; + + var custom = rectangle.custom || {}; + var model = rectangle._model; + model.backgroundColor = custom.hoverBackgroundColor ? custom.hoverBackgroundColor : helpers.getValueAtIndexOrDefault(dataset.hoverBackgroundColor, index, helpers.getHoverColor(model.backgroundColor)); + model.borderColor = custom.hoverBorderColor ? custom.hoverBorderColor : helpers.getValueAtIndexOrDefault(dataset.hoverBorderColor, index, helpers.getHoverColor(model.borderColor)); + model.borderWidth = custom.hoverBorderWidth ? custom.hoverBorderWidth : helpers.getValueAtIndexOrDefault(dataset.hoverBorderWidth, index, model.borderWidth); + }, + + removeHoverStyle: function(rectangle) { + var dataset = this.chart.data.datasets[rectangle._datasetIndex]; + var index = rectangle._index; + var custom = rectangle.custom || {}; + var model = rectangle._model; + var rectangleElementOptions = this.chart.options.elements.rectangle; + + model.backgroundColor = custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(dataset.backgroundColor, index, rectangleElementOptions.backgroundColor); + model.borderColor = custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor); + model.borderWidth = custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth); + } + + }); + + + // including horizontalBar in the bar file, instead of a file of its own + // it extends bar (like pie extends doughnut) + Chart.defaults.horizontalBar = { + hover: { + mode: 'label' + }, + + scales: { + xAxes: [{ + type: 'linear', + position: 'bottom' + }], + yAxes: [{ + position: 'left', + type: 'category', + + // Specific to Horizontal Bar Controller + categoryPercentage: 0.8, + barPercentage: 0.9, + + // grid line settings + gridLines: { + offsetGridLines: true + } + }] + }, + elements: { + rectangle: { + borderSkipped: 'left' + } + }, + tooltips: { + callbacks: { + title: function(tooltipItems, data) { + // Pick first xLabel for now + var title = ''; + + if (tooltipItems.length > 0) { + if (tooltipItems[0].yLabel) { + title = tooltipItems[0].yLabel; + } else if (data.labels.length > 0 && tooltipItems[0].index < data.labels.length) { + title = data.labels[tooltipItems[0].index]; + } + } + + return title; + }, + label: function(tooltipItem, data) { + var datasetLabel = data.datasets[tooltipItem.datasetIndex].label || ''; + return datasetLabel + ': ' + tooltipItem.xLabel; + } + } + } + }; + + Chart.controllers.horizontalBar = Chart.controllers.bar.extend({ + + // Correctly calculate the bar width accounting for stacks and the fact that not all bars are visible + getStackCount: function() { + var me = this; + var meta = me.getMeta(); + var xScale = me.getScaleForId(meta.xAxisID); + + var stacks = []; + helpers.each(me.chart.data.datasets, function(dataset, datasetIndex) { + var dsMeta = me.chart.getDatasetMeta(datasetIndex); + if (dsMeta.bar && me.chart.isDatasetVisible(datasetIndex) && + (xScale.options.stacked === false || + (xScale.options.stacked === true && stacks.indexOf(dsMeta.stack) === -1) || + (xScale.options.stacked === undefined && (dsMeta.stack === undefined || stacks.indexOf(dsMeta.stack) === -1)))) { + stacks.push(dsMeta.stack); + } + }, me); + + return stacks.length; + }, + + updateElement: function(rectangle, index, reset) { + var me = this; + var meta = me.getMeta(); + var xScale = me.getScaleForId(meta.xAxisID); + var yScale = me.getScaleForId(meta.yAxisID); + var scaleBase = xScale.getBasePixel(); + var custom = rectangle.custom || {}; + var dataset = me.getDataset(); + var rectangleElementOptions = me.chart.options.elements.rectangle; + + rectangle._xScale = xScale; + rectangle._yScale = yScale; + rectangle._datasetIndex = me.index; + rectangle._index = index; + + var ruler = me.getRuler(index); // The index argument for compatible + rectangle._model = { + x: reset ? scaleBase : me.calculateBarX(index, me.index), + y: me.calculateBarY(index, me.index, ruler), + + // Tooltip + label: me.chart.data.labels[index], + datasetLabel: dataset.label, + + // Appearance + horizontal: true, + base: reset ? scaleBase : me.calculateBarBase(me.index, index), + height: me.calculateBarHeight(ruler), + backgroundColor: custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(dataset.backgroundColor, index, rectangleElementOptions.backgroundColor), + borderSkipped: custom.borderSkipped ? custom.borderSkipped : rectangleElementOptions.borderSkipped, + borderColor: custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.borderColor, index, rectangleElementOptions.borderColor), + borderWidth: custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.borderWidth, index, rectangleElementOptions.borderWidth) + }; + + rectangle.pivot(); + }, + + calculateBarBase: function(datasetIndex, index) { + var me = this; + var meta = me.getMeta(); + var xScale = me.getScaleForId(meta.xAxisID); + var base = xScale.getBaseValue(); + var originalBase = base; + + if (xScale.options.stacked || + (xScale.options.stacked === undefined && meta.stack !== undefined)) { + var chart = me.chart; + var datasets = chart.data.datasets; + var value = Number(datasets[datasetIndex].data[index]); + + for (var i = 0; i < datasetIndex; i++) { + var currentDs = datasets[i]; + var currentDsMeta = chart.getDatasetMeta(i); + if (currentDsMeta.bar && currentDsMeta.xAxisID === xScale.id && chart.isDatasetVisible(i) && + meta.stack === currentDsMeta.stack) { + var currentVal = Number(currentDs.data[index]); + base += value < 0 ? Math.min(currentVal, originalBase) : Math.max(currentVal, originalBase); + } + } + + return xScale.getPixelForValue(base); + } + + return xScale.getBasePixel(); + }, + + getRuler: function() { + var me = this; + var meta = me.getMeta(); + var yScale = me.getScaleForId(meta.yAxisID); + var stackCount = me.getStackCount(); + + var tickHeight = yScale.height / yScale.ticks.length; + var categoryHeight = tickHeight * yScale.options.categoryPercentage; + var categorySpacing = (tickHeight - (tickHeight * yScale.options.categoryPercentage)) / 2; + var fullBarHeight = categoryHeight / stackCount; + + var barHeight = fullBarHeight * yScale.options.barPercentage; + var barSpacing = fullBarHeight - (fullBarHeight * yScale.options.barPercentage); + + return { + stackCount: stackCount, + tickHeight: tickHeight, + categoryHeight: categoryHeight, + categorySpacing: categorySpacing, + fullBarHeight: fullBarHeight, + barHeight: barHeight, + barSpacing: barSpacing + }; + }, + + calculateBarHeight: function(ruler) { + var me = this; + var meta = me.getMeta(); + var yScale = me.getScaleForId(meta.yAxisID); + if (yScale.options.barThickness) { + return yScale.options.barThickness; + } + return ruler.barHeight; + }, + + // Get stack index from the given dataset index accounting for stacks and the fact that not all bars are visible + getStackIndex: function(datasetIndex) { + var me = this; + var meta = me.chart.getDatasetMeta(datasetIndex); + var xScale = me.getScaleForId(meta.xAxisID); + var dsMeta, j; + var stacks = [meta.stack]; + + for (j = 0; j < datasetIndex; ++j) { + dsMeta = this.chart.getDatasetMeta(j); + if (dsMeta.bar && this.chart.isDatasetVisible(j) && + (xScale.options.stacked === false || + (xScale.options.stacked === true && stacks.indexOf(dsMeta.stack) === -1) || + (xScale.options.stacked === undefined && (dsMeta.stack === undefined || stacks.indexOf(dsMeta.stack) === -1)))) { + stacks.push(dsMeta.stack); + } + } + + return stacks.length - 1; + }, + + calculateBarX: function(index, datasetIndex) { + var me = this; + var meta = me.getMeta(); + var xScale = me.getScaleForId(meta.xAxisID); + var value = Number(me.getDataset().data[index]); + + if (xScale.options.stacked || + (xScale.options.stacked === undefined && meta.stack !== undefined)) { + var base = xScale.getBaseValue(); + var sumPos = base, + sumNeg = base; + + for (var i = 0; i < datasetIndex; i++) { + var ds = me.chart.data.datasets[i]; + var dsMeta = me.chart.getDatasetMeta(i); + if (dsMeta.bar && dsMeta.xAxisID === xScale.id && me.chart.isDatasetVisible(i) && + meta.stack === dsMeta.stack) { + var stackedVal = Number(ds.data[index]); + if (stackedVal < 0) { + sumNeg += stackedVal || 0; + } else { + sumPos += stackedVal || 0; + } + } + } + + if (value < 0) { + return xScale.getPixelForValue(sumNeg + value); + } + return xScale.getPixelForValue(sumPos + value); + } + + return xScale.getPixelForValue(value); + }, + + calculateBarY: function(index, datasetIndex, ruler) { + var me = this; + var meta = me.getMeta(); + var yScale = me.getScaleForId(meta.yAxisID); + var stackIndex = me.getStackIndex(datasetIndex); + var topTick = yScale.getPixelForValue(null, index, datasetIndex, me.chart.isCombo); + topTick -= me.chart.isCombo ? (ruler.tickHeight / 2) : 0; + + return topTick + + (ruler.barHeight / 2) + + ruler.categorySpacing + + (ruler.barHeight * stackIndex) + + (ruler.barSpacing / 2) + + (ruler.barSpacing * stackIndex); + } + }); +}; + +},{}],16:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + var helpers = Chart.helpers; + + Chart.defaults.bubble = { + hover: { + mode: 'single' + }, + + scales: { + xAxes: [{ + type: 'linear', // bubble should probably use a linear scale by default + position: 'bottom', + id: 'x-axis-0' // need an ID so datasets can reference the scale + }], + yAxes: [{ + type: 'linear', + position: 'left', + id: 'y-axis-0' + }] + }, + + tooltips: { + callbacks: { + title: function() { + // Title doesn't make sense for scatter since we format the data as a point + return ''; + }, + label: function(tooltipItem, data) { + var datasetLabel = data.datasets[tooltipItem.datasetIndex].label || ''; + var dataPoint = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index]; + return datasetLabel + ': (' + tooltipItem.xLabel + ', ' + tooltipItem.yLabel + ', ' + dataPoint.r + ')'; + } + } + } + }; + + Chart.controllers.bubble = Chart.DatasetController.extend({ + + dataElementType: Chart.elements.Point, + + update: function(reset) { + var me = this; + var meta = me.getMeta(); + var points = meta.data; + + // Update Points + helpers.each(points, function(point, index) { + me.updateElement(point, index, reset); + }); + }, + + updateElement: function(point, index, reset) { + var me = this; + var meta = me.getMeta(); + var xScale = me.getScaleForId(meta.xAxisID); + var yScale = me.getScaleForId(meta.yAxisID); + + var custom = point.custom || {}; + var dataset = me.getDataset(); + var data = dataset.data[index]; + var pointElementOptions = me.chart.options.elements.point; + var dsIndex = me.index; + + helpers.extend(point, { + // Utility + _xScale: xScale, + _yScale: yScale, + _datasetIndex: dsIndex, + _index: index, + + // Desired view properties + _model: { + x: reset ? xScale.getPixelForDecimal(0.5) : xScale.getPixelForValue(typeof data === 'object' ? data : NaN, index, dsIndex, me.chart.isCombo), + y: reset ? yScale.getBasePixel() : yScale.getPixelForValue(data, index, dsIndex), + // Appearance + radius: reset ? 0 : custom.radius ? custom.radius : me.getRadius(data), + + // Tooltip + hitRadius: custom.hitRadius ? custom.hitRadius : helpers.getValueAtIndexOrDefault(dataset.hitRadius, index, pointElementOptions.hitRadius) + } + }); + + // Trick to reset the styles of the point + Chart.DatasetController.prototype.removeHoverStyle.call(me, point, pointElementOptions); + + var model = point._model; + model.skip = custom.skip ? custom.skip : (isNaN(model.x) || isNaN(model.y)); + + point.pivot(); + }, + + getRadius: function(value) { + return value.r || this.chart.options.elements.point.radius; + }, + + setHoverStyle: function(point) { + var me = this; + Chart.DatasetController.prototype.setHoverStyle.call(me, point); + + // Radius + var dataset = me.chart.data.datasets[point._datasetIndex]; + var index = point._index; + var custom = point.custom || {}; + var model = point._model; + model.radius = custom.hoverRadius ? custom.hoverRadius : (helpers.getValueAtIndexOrDefault(dataset.hoverRadius, index, me.chart.options.elements.point.hoverRadius)) + me.getRadius(dataset.data[index]); + }, + + removeHoverStyle: function(point) { + var me = this; + Chart.DatasetController.prototype.removeHoverStyle.call(me, point, me.chart.options.elements.point); + + var dataVal = me.chart.data.datasets[point._datasetIndex].data[point._index]; + var custom = point.custom || {}; + var model = point._model; + + model.radius = custom.radius ? custom.radius : me.getRadius(dataVal); + } + }); +}; + +},{}],17:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + var helpers = Chart.helpers, + defaults = Chart.defaults; + + defaults.doughnut = { + animation: { + // Boolean - Whether we animate the rotation of the Doughnut + animateRotate: true, + // Boolean - Whether we animate scaling the Doughnut from the centre + animateScale: false + }, + aspectRatio: 1, + hover: { + mode: 'single' + }, + legendCallback: function(chart) { + var text = []; + text.push('
    '); + + var data = chart.data; + var datasets = data.datasets; + var labels = data.labels; + + if (datasets.length) { + for (var i = 0; i < datasets[0].data.length; ++i) { + text.push('
  • '); + if (labels[i]) { + text.push(labels[i]); + } + text.push('
  • '); + } + } + + text.push('
'); + return text.join(''); + }, + legend: { + labels: { + generateLabels: function(chart) { + var data = chart.data; + if (data.labels.length && data.datasets.length) { + return data.labels.map(function(label, i) { + var meta = chart.getDatasetMeta(0); + var ds = data.datasets[0]; + var arc = meta.data[i]; + var custom = arc && arc.custom || {}; + var getValueAtIndexOrDefault = helpers.getValueAtIndexOrDefault; + var arcOpts = chart.options.elements.arc; + var fill = custom.backgroundColor ? custom.backgroundColor : getValueAtIndexOrDefault(ds.backgroundColor, i, arcOpts.backgroundColor); + var stroke = custom.borderColor ? custom.borderColor : getValueAtIndexOrDefault(ds.borderColor, i, arcOpts.borderColor); + var bw = custom.borderWidth ? custom.borderWidth : getValueAtIndexOrDefault(ds.borderWidth, i, arcOpts.borderWidth); + + return { + text: label, + fillStyle: fill, + strokeStyle: stroke, + lineWidth: bw, + hidden: isNaN(ds.data[i]) || meta.data[i].hidden, + + // Extra data used for toggling the correct item + index: i + }; + }); + } + return []; + } + }, + + onClick: function(e, legendItem) { + var index = legendItem.index; + var chart = this.chart; + var i, ilen, meta; + + for (i = 0, ilen = (chart.data.datasets || []).length; i < ilen; ++i) { + meta = chart.getDatasetMeta(i); + // toggle visibility of index if exists + if (meta.data[index]) { + meta.data[index].hidden = !meta.data[index].hidden; + } + } + + chart.update(); + } + }, + + // The percentage of the chart that we cut out of the middle. + cutoutPercentage: 50, + + // The rotation of the chart, where the first data arc begins. + rotation: Math.PI * -0.5, + + // The total circumference of the chart. + circumference: Math.PI * 2.0, + + // Need to override these to give a nice default + tooltips: { + callbacks: { + title: function() { + return ''; + }, + label: function(tooltipItem, data) { + var dataLabel = data.labels[tooltipItem.index]; + var value = ': ' + data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index]; + + if (helpers.isArray(dataLabel)) { + // show value on first line of multiline label + // need to clone because we are changing the value + dataLabel = dataLabel.slice(); + dataLabel[0] += value; + } else { + dataLabel += value; + } + + return dataLabel; + } + } + } + }; + + defaults.pie = helpers.clone(defaults.doughnut); + helpers.extend(defaults.pie, { + cutoutPercentage: 0 + }); + + + Chart.controllers.doughnut = Chart.controllers.pie = Chart.DatasetController.extend({ + + dataElementType: Chart.elements.Arc, + + linkScales: helpers.noop, + + // Get index of the dataset in relation to the visible datasets. This allows determining the inner and outer radius correctly + getRingIndex: function(datasetIndex) { + var ringIndex = 0; + + for (var j = 0; j < datasetIndex; ++j) { + if (this.chart.isDatasetVisible(j)) { + ++ringIndex; + } + } + + return ringIndex; + }, + + update: function(reset) { + var me = this; + var chart = me.chart, + chartArea = chart.chartArea, + opts = chart.options, + arcOpts = opts.elements.arc, + availableWidth = chartArea.right - chartArea.left - arcOpts.borderWidth, + availableHeight = chartArea.bottom - chartArea.top - arcOpts.borderWidth, + minSize = Math.min(availableWidth, availableHeight), + offset = { + x: 0, + y: 0 + }, + meta = me.getMeta(), + cutoutPercentage = opts.cutoutPercentage, + circumference = opts.circumference; + + // If the chart's circumference isn't a full circle, calculate minSize as a ratio of the width/height of the arc + if (circumference < Math.PI * 2.0) { + var startAngle = opts.rotation % (Math.PI * 2.0); + startAngle += Math.PI * 2.0 * (startAngle >= Math.PI ? -1 : startAngle < -Math.PI ? 1 : 0); + var endAngle = startAngle + circumference; + var start = {x: Math.cos(startAngle), y: Math.sin(startAngle)}; + var end = {x: Math.cos(endAngle), y: Math.sin(endAngle)}; + var contains0 = (startAngle <= 0 && 0 <= endAngle) || (startAngle <= Math.PI * 2.0 && Math.PI * 2.0 <= endAngle); + var contains90 = (startAngle <= Math.PI * 0.5 && Math.PI * 0.5 <= endAngle) || (startAngle <= Math.PI * 2.5 && Math.PI * 2.5 <= endAngle); + var contains180 = (startAngle <= -Math.PI && -Math.PI <= endAngle) || (startAngle <= Math.PI && Math.PI <= endAngle); + var contains270 = (startAngle <= -Math.PI * 0.5 && -Math.PI * 0.5 <= endAngle) || (startAngle <= Math.PI * 1.5 && Math.PI * 1.5 <= endAngle); + var cutout = cutoutPercentage / 100.0; + var min = {x: contains180 ? -1 : Math.min(start.x * (start.x < 0 ? 1 : cutout), end.x * (end.x < 0 ? 1 : cutout)), y: contains270 ? -1 : Math.min(start.y * (start.y < 0 ? 1 : cutout), end.y * (end.y < 0 ? 1 : cutout))}; + var max = {x: contains0 ? 1 : Math.max(start.x * (start.x > 0 ? 1 : cutout), end.x * (end.x > 0 ? 1 : cutout)), y: contains90 ? 1 : Math.max(start.y * (start.y > 0 ? 1 : cutout), end.y * (end.y > 0 ? 1 : cutout))}; + var size = {width: (max.x - min.x) * 0.5, height: (max.y - min.y) * 0.5}; + minSize = Math.min(availableWidth / size.width, availableHeight / size.height); + offset = {x: (max.x + min.x) * -0.5, y: (max.y + min.y) * -0.5}; + } + + chart.borderWidth = me.getMaxBorderWidth(meta.data); + chart.outerRadius = Math.max((minSize - chart.borderWidth) / 2, 0); + chart.innerRadius = Math.max(cutoutPercentage ? (chart.outerRadius / 100) * (cutoutPercentage) : 0, 0); + chart.radiusLength = (chart.outerRadius - chart.innerRadius) / chart.getVisibleDatasetCount(); + chart.offsetX = offset.x * chart.outerRadius; + chart.offsetY = offset.y * chart.outerRadius; + + meta.total = me.calculateTotal(); + + me.outerRadius = chart.outerRadius - (chart.radiusLength * me.getRingIndex(me.index)); + me.innerRadius = Math.max(me.outerRadius - chart.radiusLength, 0); + + helpers.each(meta.data, function(arc, index) { + me.updateElement(arc, index, reset); + }); + }, + + updateElement: function(arc, index, reset) { + var me = this; + var chart = me.chart, + chartArea = chart.chartArea, + opts = chart.options, + animationOpts = opts.animation, + centerX = (chartArea.left + chartArea.right) / 2, + centerY = (chartArea.top + chartArea.bottom) / 2, + startAngle = opts.rotation, // non reset case handled later + endAngle = opts.rotation, // non reset case handled later + dataset = me.getDataset(), + circumference = reset && animationOpts.animateRotate ? 0 : arc.hidden ? 0 : me.calculateCircumference(dataset.data[index]) * (opts.circumference / (2.0 * Math.PI)), + innerRadius = reset && animationOpts.animateScale ? 0 : me.innerRadius, + outerRadius = reset && animationOpts.animateScale ? 0 : me.outerRadius, + valueAtIndexOrDefault = helpers.getValueAtIndexOrDefault; + + helpers.extend(arc, { + // Utility + _datasetIndex: me.index, + _index: index, + + // Desired view properties + _model: { + x: centerX + chart.offsetX, + y: centerY + chart.offsetY, + startAngle: startAngle, + endAngle: endAngle, + circumference: circumference, + outerRadius: outerRadius, + innerRadius: innerRadius, + label: valueAtIndexOrDefault(dataset.label, index, chart.data.labels[index]) + } + }); + + var model = arc._model; + // Resets the visual styles + this.removeHoverStyle(arc); + + // Set correct angles if not resetting + if (!reset || !animationOpts.animateRotate) { + if (index === 0) { + model.startAngle = opts.rotation; + } else { + model.startAngle = me.getMeta().data[index - 1]._model.endAngle; + } + + model.endAngle = model.startAngle + model.circumference; + } + + arc.pivot(); + }, + + removeHoverStyle: function(arc) { + Chart.DatasetController.prototype.removeHoverStyle.call(this, arc, this.chart.options.elements.arc); + }, + + calculateTotal: function() { + var dataset = this.getDataset(); + var meta = this.getMeta(); + var total = 0; + var value; + + helpers.each(meta.data, function(element, index) { + value = dataset.data[index]; + if (!isNaN(value) && !element.hidden) { + total += Math.abs(value); + } + }); + + /* if (total === 0) { + total = NaN; + }*/ + + return total; + }, + + calculateCircumference: function(value) { + var total = this.getMeta().total; + if (total > 0 && !isNaN(value)) { + return (Math.PI * 2.0) * (value / total); + } + return 0; + }, + + // gets the max border or hover width to properly scale pie charts + getMaxBorderWidth: function(elements) { + var max = 0, + index = this.index, + length = elements.length, + borderWidth, + hoverWidth; + + for (var i = 0; i < length; i++) { + borderWidth = elements[i]._model ? elements[i]._model.borderWidth : 0; + hoverWidth = elements[i]._chart ? elements[i]._chart.config.data.datasets[index].hoverBorderWidth : 0; + + max = borderWidth > max ? borderWidth : max; + max = hoverWidth > max ? hoverWidth : max; + } + return max; + } + }); +}; + +},{}],18:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + var helpers = Chart.helpers; + + Chart.defaults.line = { + showLines: true, + spanGaps: false, + + hover: { + mode: 'label' + }, + + scales: { + xAxes: [{ + type: 'category', + id: 'x-axis-0' + }], + yAxes: [{ + type: 'linear', + id: 'y-axis-0' + }] + } + }; + + function lineEnabled(dataset, options) { + return helpers.getValueOrDefault(dataset.showLine, options.showLines); + } + + Chart.controllers.line = Chart.DatasetController.extend({ + + datasetElementType: Chart.elements.Line, + + dataElementType: Chart.elements.Point, + + update: function(reset) { + var me = this; + var meta = me.getMeta(); + var line = meta.dataset; + var points = meta.data || []; + var options = me.chart.options; + var lineElementOptions = options.elements.line; + var scale = me.getScaleForId(meta.yAxisID); + var i, ilen, custom; + var dataset = me.getDataset(); + var showLine = lineEnabled(dataset, options); + + // Update Line + if (showLine) { + custom = line.custom || {}; + + // Compatibility: If the properties are defined with only the old name, use those values + if ((dataset.tension !== undefined) && (dataset.lineTension === undefined)) { + dataset.lineTension = dataset.tension; + } + + // Utility + line._scale = scale; + line._datasetIndex = me.index; + // Data + line._children = points; + // Model + line._model = { + // Appearance + // The default behavior of lines is to break at null values, according + // to https://github.com/chartjs/Chart.js/issues/2435#issuecomment-216718158 + // This option gives lines the ability to span gaps + spanGaps: dataset.spanGaps ? dataset.spanGaps : options.spanGaps, + tension: custom.tension ? custom.tension : helpers.getValueOrDefault(dataset.lineTension, lineElementOptions.tension), + backgroundColor: custom.backgroundColor ? custom.backgroundColor : (dataset.backgroundColor || lineElementOptions.backgroundColor), + borderWidth: custom.borderWidth ? custom.borderWidth : (dataset.borderWidth || lineElementOptions.borderWidth), + borderColor: custom.borderColor ? custom.borderColor : (dataset.borderColor || lineElementOptions.borderColor), + borderCapStyle: custom.borderCapStyle ? custom.borderCapStyle : (dataset.borderCapStyle || lineElementOptions.borderCapStyle), + borderDash: custom.borderDash ? custom.borderDash : (dataset.borderDash || lineElementOptions.borderDash), + borderDashOffset: custom.borderDashOffset ? custom.borderDashOffset : (dataset.borderDashOffset || lineElementOptions.borderDashOffset), + borderJoinStyle: custom.borderJoinStyle ? custom.borderJoinStyle : (dataset.borderJoinStyle || lineElementOptions.borderJoinStyle), + fill: custom.fill ? custom.fill : (dataset.fill !== undefined ? dataset.fill : lineElementOptions.fill), + steppedLine: custom.steppedLine ? custom.steppedLine : helpers.getValueOrDefault(dataset.steppedLine, lineElementOptions.stepped), + cubicInterpolationMode: custom.cubicInterpolationMode ? custom.cubicInterpolationMode : helpers.getValueOrDefault(dataset.cubicInterpolationMode, lineElementOptions.cubicInterpolationMode), + // Scale + scaleTop: scale.top, + scaleBottom: scale.bottom, + scaleZero: scale.getBasePixel() + }; + + line.pivot(); + } + + // Update Points + for (i=0, ilen=points.length; i'); + + var data = chart.data; + var datasets = data.datasets; + var labels = data.labels; + + if (datasets.length) { + for (var i = 0; i < datasets[0].data.length; ++i) { + text.push('
  • '); + if (labels[i]) { + text.push(labels[i]); + } + text.push('
  • '); + } + } + + text.push(''); + return text.join(''); + }, + legend: { + labels: { + generateLabels: function(chart) { + var data = chart.data; + if (data.labels.length && data.datasets.length) { + return data.labels.map(function(label, i) { + var meta = chart.getDatasetMeta(0); + var ds = data.datasets[0]; + var arc = meta.data[i]; + var custom = arc.custom || {}; + var getValueAtIndexOrDefault = helpers.getValueAtIndexOrDefault; + var arcOpts = chart.options.elements.arc; + var fill = custom.backgroundColor ? custom.backgroundColor : getValueAtIndexOrDefault(ds.backgroundColor, i, arcOpts.backgroundColor); + var stroke = custom.borderColor ? custom.borderColor : getValueAtIndexOrDefault(ds.borderColor, i, arcOpts.borderColor); + var bw = custom.borderWidth ? custom.borderWidth : getValueAtIndexOrDefault(ds.borderWidth, i, arcOpts.borderWidth); + + return { + text: label, + fillStyle: fill, + strokeStyle: stroke, + lineWidth: bw, + hidden: isNaN(ds.data[i]) || meta.data[i].hidden, + + // Extra data used for toggling the correct item + index: i + }; + }); + } + return []; + } + }, + + onClick: function(e, legendItem) { + var index = legendItem.index; + var chart = this.chart; + var i, ilen, meta; + + for (i = 0, ilen = (chart.data.datasets || []).length; i < ilen; ++i) { + meta = chart.getDatasetMeta(i); + meta.data[index].hidden = !meta.data[index].hidden; + } + + chart.update(); + } + }, + + // Need to override these to give a nice default + tooltips: { + callbacks: { + title: function() { + return ''; + }, + label: function(tooltipItem, data) { + return data.labels[tooltipItem.index] + ': ' + tooltipItem.yLabel; + } + } + } + }; + + Chart.controllers.polarArea = Chart.DatasetController.extend({ + + dataElementType: Chart.elements.Arc, + + linkScales: helpers.noop, + + update: function(reset) { + var me = this; + var chart = me.chart; + var chartArea = chart.chartArea; + var meta = me.getMeta(); + var opts = chart.options; + var arcOpts = opts.elements.arc; + var minSize = Math.min(chartArea.right - chartArea.left, chartArea.bottom - chartArea.top); + chart.outerRadius = Math.max((minSize - arcOpts.borderWidth / 2) / 2, 0); + chart.innerRadius = Math.max(opts.cutoutPercentage ? (chart.outerRadius / 100) * (opts.cutoutPercentage) : 1, 0); + chart.radiusLength = (chart.outerRadius - chart.innerRadius) / chart.getVisibleDatasetCount(); + + me.outerRadius = chart.outerRadius - (chart.radiusLength * me.index); + me.innerRadius = me.outerRadius - chart.radiusLength; + + meta.count = me.countVisibleElements(); + + helpers.each(meta.data, function(arc, index) { + me.updateElement(arc, index, reset); + }); + }, + + updateElement: function(arc, index, reset) { + var me = this; + var chart = me.chart; + var dataset = me.getDataset(); + var opts = chart.options; + var animationOpts = opts.animation; + var scale = chart.scale; + var getValueAtIndexOrDefault = helpers.getValueAtIndexOrDefault; + var labels = chart.data.labels; + + var circumference = me.calculateCircumference(dataset.data[index]); + var centerX = scale.xCenter; + var centerY = scale.yCenter; + + // If there is NaN data before us, we need to calculate the starting angle correctly. + // We could be way more efficient here, but its unlikely that the polar area chart will have a lot of data + var visibleCount = 0; + var meta = me.getMeta(); + for (var i = 0; i < index; ++i) { + if (!isNaN(dataset.data[i]) && !meta.data[i].hidden) { + ++visibleCount; + } + } + + // var negHalfPI = -0.5 * Math.PI; + var datasetStartAngle = opts.startAngle; + var distance = arc.hidden ? 0 : scale.getDistanceFromCenterForValue(dataset.data[index]); + var startAngle = datasetStartAngle + (circumference * visibleCount); + var endAngle = startAngle + (arc.hidden ? 0 : circumference); + + var resetRadius = animationOpts.animateScale ? 0 : scale.getDistanceFromCenterForValue(dataset.data[index]); + + helpers.extend(arc, { + // Utility + _datasetIndex: me.index, + _index: index, + _scale: scale, + + // Desired view properties + _model: { + x: centerX, + y: centerY, + innerRadius: 0, + outerRadius: reset ? resetRadius : distance, + startAngle: reset && animationOpts.animateRotate ? datasetStartAngle : startAngle, + endAngle: reset && animationOpts.animateRotate ? datasetStartAngle : endAngle, + label: getValueAtIndexOrDefault(labels, index, labels[index]) + } + }); + + // Apply border and fill style + me.removeHoverStyle(arc); + + arc.pivot(); + }, + + removeHoverStyle: function(arc) { + Chart.DatasetController.prototype.removeHoverStyle.call(this, arc, this.chart.options.elements.arc); + }, + + countVisibleElements: function() { + var dataset = this.getDataset(); + var meta = this.getMeta(); + var count = 0; + + helpers.each(meta.data, function(element, index) { + if (!isNaN(dataset.data[index]) && !element.hidden) { + count++; + } + }); + + return count; + }, + + calculateCircumference: function(value) { + var count = this.getMeta().count; + if (count > 0 && !isNaN(value)) { + return (2 * Math.PI) / count; + } + return 0; + } + }); +}; + +},{}],20:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + var helpers = Chart.helpers; + + Chart.defaults.radar = { + aspectRatio: 1, + scale: { + type: 'radialLinear' + }, + elements: { + line: { + tension: 0 // no bezier in radar + } + } + }; + + Chart.controllers.radar = Chart.DatasetController.extend({ + + datasetElementType: Chart.elements.Line, + + dataElementType: Chart.elements.Point, + + linkScales: helpers.noop, + + update: function(reset) { + var me = this; + var meta = me.getMeta(); + var line = meta.dataset; + var points = meta.data; + var custom = line.custom || {}; + var dataset = me.getDataset(); + var lineElementOptions = me.chart.options.elements.line; + var scale = me.chart.scale; + + // Compatibility: If the properties are defined with only the old name, use those values + if ((dataset.tension !== undefined) && (dataset.lineTension === undefined)) { + dataset.lineTension = dataset.tension; + } + + helpers.extend(meta.dataset, { + // Utility + _datasetIndex: me.index, + // Data + _children: points, + _loop: true, + // Model + _model: { + // Appearance + tension: custom.tension ? custom.tension : helpers.getValueOrDefault(dataset.lineTension, lineElementOptions.tension), + backgroundColor: custom.backgroundColor ? custom.backgroundColor : (dataset.backgroundColor || lineElementOptions.backgroundColor), + borderWidth: custom.borderWidth ? custom.borderWidth : (dataset.borderWidth || lineElementOptions.borderWidth), + borderColor: custom.borderColor ? custom.borderColor : (dataset.borderColor || lineElementOptions.borderColor), + fill: custom.fill ? custom.fill : (dataset.fill !== undefined ? dataset.fill : lineElementOptions.fill), + borderCapStyle: custom.borderCapStyle ? custom.borderCapStyle : (dataset.borderCapStyle || lineElementOptions.borderCapStyle), + borderDash: custom.borderDash ? custom.borderDash : (dataset.borderDash || lineElementOptions.borderDash), + borderDashOffset: custom.borderDashOffset ? custom.borderDashOffset : (dataset.borderDashOffset || lineElementOptions.borderDashOffset), + borderJoinStyle: custom.borderJoinStyle ? custom.borderJoinStyle : (dataset.borderJoinStyle || lineElementOptions.borderJoinStyle), + + // Scale + scaleTop: scale.top, + scaleBottom: scale.bottom, + scaleZero: scale.getBasePosition() + } + }); + + meta.dataset.pivot(); + + // Update Points + helpers.each(points, function(point, index) { + me.updateElement(point, index, reset); + }, me); + + // Update bezier control points + me.updateBezierControlPoints(); + }, + updateElement: function(point, index, reset) { + var me = this; + var custom = point.custom || {}; + var dataset = me.getDataset(); + var scale = me.chart.scale; + var pointElementOptions = me.chart.options.elements.point; + var pointPosition = scale.getPointPositionForValue(index, dataset.data[index]); + + helpers.extend(point, { + // Utility + _datasetIndex: me.index, + _index: index, + _scale: scale, + + // Desired view properties + _model: { + x: reset ? scale.xCenter : pointPosition.x, // value not used in dataset scale, but we want a consistent API between scales + y: reset ? scale.yCenter : pointPosition.y, + + // Appearance + tension: custom.tension ? custom.tension : helpers.getValueOrDefault(dataset.lineTension, me.chart.options.elements.line.tension), + radius: custom.radius ? custom.radius : helpers.getValueAtIndexOrDefault(dataset.pointRadius, index, pointElementOptions.radius), + backgroundColor: custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(dataset.pointBackgroundColor, index, pointElementOptions.backgroundColor), + borderColor: custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.pointBorderColor, index, pointElementOptions.borderColor), + borderWidth: custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.pointBorderWidth, index, pointElementOptions.borderWidth), + pointStyle: custom.pointStyle ? custom.pointStyle : helpers.getValueAtIndexOrDefault(dataset.pointStyle, index, pointElementOptions.pointStyle), + + // Tooltip + hitRadius: custom.hitRadius ? custom.hitRadius : helpers.getValueAtIndexOrDefault(dataset.hitRadius, index, pointElementOptions.hitRadius) + } + }); + + point._model.skip = custom.skip ? custom.skip : (isNaN(point._model.x) || isNaN(point._model.y)); + }, + updateBezierControlPoints: function() { + var chartArea = this.chart.chartArea; + var meta = this.getMeta(); + + helpers.each(meta.data, function(point, index) { + var model = point._model; + var controlPoints = helpers.splineCurve( + helpers.previousItem(meta.data, index, true)._model, + model, + helpers.nextItem(meta.data, index, true)._model, + model.tension + ); + + // Prevent the bezier going outside of the bounds of the graph + model.controlPointPreviousX = Math.max(Math.min(controlPoints.previous.x, chartArea.right), chartArea.left); + model.controlPointPreviousY = Math.max(Math.min(controlPoints.previous.y, chartArea.bottom), chartArea.top); + + model.controlPointNextX = Math.max(Math.min(controlPoints.next.x, chartArea.right), chartArea.left); + model.controlPointNextY = Math.max(Math.min(controlPoints.next.y, chartArea.bottom), chartArea.top); + + // Now pivot the point for animation + point.pivot(); + }); + }, + + draw: function(ease) { + var meta = this.getMeta(); + var easingDecimal = ease || 1; + + // Transition Point Locations + helpers.each(meta.data, function(point) { + point.transition(easingDecimal); + }); + + // Transition and Draw the line + meta.dataset.transition(easingDecimal).draw(); + + // Draw the points + helpers.each(meta.data, function(point) { + point.draw(); + }); + }, + + setHoverStyle: function(point) { + // Point + var dataset = this.chart.data.datasets[point._datasetIndex]; + var custom = point.custom || {}; + var index = point._index; + var model = point._model; + + model.radius = custom.hoverRadius ? custom.hoverRadius : helpers.getValueAtIndexOrDefault(dataset.pointHoverRadius, index, this.chart.options.elements.point.hoverRadius); + model.backgroundColor = custom.hoverBackgroundColor ? custom.hoverBackgroundColor : helpers.getValueAtIndexOrDefault(dataset.pointHoverBackgroundColor, index, helpers.getHoverColor(model.backgroundColor)); + model.borderColor = custom.hoverBorderColor ? custom.hoverBorderColor : helpers.getValueAtIndexOrDefault(dataset.pointHoverBorderColor, index, helpers.getHoverColor(model.borderColor)); + model.borderWidth = custom.hoverBorderWidth ? custom.hoverBorderWidth : helpers.getValueAtIndexOrDefault(dataset.pointHoverBorderWidth, index, model.borderWidth); + }, + + removeHoverStyle: function(point) { + var dataset = this.chart.data.datasets[point._datasetIndex]; + var custom = point.custom || {}; + var index = point._index; + var model = point._model; + var pointElementOptions = this.chart.options.elements.point; + + model.radius = custom.radius ? custom.radius : helpers.getValueAtIndexOrDefault(dataset.radius, index, pointElementOptions.radius); + model.backgroundColor = custom.backgroundColor ? custom.backgroundColor : helpers.getValueAtIndexOrDefault(dataset.pointBackgroundColor, index, pointElementOptions.backgroundColor); + model.borderColor = custom.borderColor ? custom.borderColor : helpers.getValueAtIndexOrDefault(dataset.pointBorderColor, index, pointElementOptions.borderColor); + model.borderWidth = custom.borderWidth ? custom.borderWidth : helpers.getValueAtIndexOrDefault(dataset.pointBorderWidth, index, pointElementOptions.borderWidth); + } + }); +}; + +},{}],21:[function(require,module,exports){ +/* global window: false */ +'use strict'; + +module.exports = function(Chart) { + + var helpers = Chart.helpers; + + Chart.defaults.global.animation = { + duration: 1000, + easing: 'easeOutQuart', + onProgress: helpers.noop, + onComplete: helpers.noop + }; + + 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.animationService = { + frameDuration: 17, + animations: [], + dropFrames: 0, + request: null, + + /** + * @function Chart.animationService.addAnimation + * @param chartInstance {ChartController} the chart to animate + * @param animationObject {IAnimation} the animation that we will animate + * @param duration {Number} length of animation in ms + * @param lazy {Boolean} if true, the chart is not marked as animating to enable more responsive interactions + */ + addAnimation: function(chartInstance, animationObject, duration, lazy) { + var me = this; + + if (!lazy) { + chartInstance.animating = true; + } + + for (var index = 0; index < me.animations.length; ++index) { + if (me.animations[index].chartInstance === chartInstance) { + // replacing an in progress animation + me.animations[index].animationObject = animationObject; + return; + } + } + + me.animations.push({ + chartInstance: chartInstance, + animationObject: animationObject + }); + + // If there are no animations queued, manually kickstart a digest, for lack of a better word + if (me.animations.length === 1) { + me.requestAnimationFrame(); + } + }, + // Cancel the animation for a given chart instance + cancelAnimation: function(chartInstance) { + var index = helpers.findIndex(this.animations, function(animationWrapper) { + return animationWrapper.chartInstance === chartInstance; + }); + + if (index !== -1) { + this.animations.splice(index, 1); + chartInstance.animating = false; + } + }, + requestAnimationFrame: function() { + var me = this; + if (me.request === null) { + // Skip animation frame requests until the active one is executed. + // This can happen when processing mouse events, e.g. 'mousemove' + // and 'mouseout' events will trigger multiple renders. + me.request = helpers.requestAnimFrame.call(window, function() { + me.request = null; + me.startDigest(); + }); + } + }, + startDigest: function() { + var me = this; + + var startTime = Date.now(); + var framesToDrop = 0; + + if (me.dropFrames > 1) { + framesToDrop = Math.floor(me.dropFrames); + me.dropFrames = me.dropFrames % 1; + } + + var i = 0; + while (i < me.animations.length) { + if (me.animations[i].animationObject.currentStep === null) { + me.animations[i].animationObject.currentStep = 0; + } + + me.animations[i].animationObject.currentStep += 1 + framesToDrop; + + if (me.animations[i].animationObject.currentStep > me.animations[i].animationObject.numSteps) { + me.animations[i].animationObject.currentStep = me.animations[i].animationObject.numSteps; + } + + me.animations[i].animationObject.render(me.animations[i].chartInstance, me.animations[i].animationObject); + if (me.animations[i].animationObject.onAnimationProgress && me.animations[i].animationObject.onAnimationProgress.call) { + me.animations[i].animationObject.onAnimationProgress.call(me.animations[i].chartInstance, me.animations[i]); + } + + if (me.animations[i].animationObject.currentStep === me.animations[i].animationObject.numSteps) { + if (me.animations[i].animationObject.onAnimationComplete && me.animations[i].animationObject.onAnimationComplete.call) { + me.animations[i].animationObject.onAnimationComplete.call(me.animations[i].chartInstance, me.animations[i]); + } + + // executed the last frame. Remove the animation. + me.animations[i].chartInstance.animating = false; + + me.animations.splice(i, 1); + } else { + ++i; + } + } + + var endTime = Date.now(); + var dropFrames = (endTime - startTime) / me.frameDuration; + + me.dropFrames += dropFrames; + + // Do we have more stuff to animate? + if (me.animations.length > 0) { + me.requestAnimationFrame(); + } + } + }; +}; + +},{}],22:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + // Global Chart canvas helpers object for drawing items to canvas + var helpers = Chart.canvasHelpers = {}; + + helpers.drawPoint = function(ctx, pointStyle, radius, x, y) { + var type, edgeLength, xOffset, yOffset, height, size; + + if (typeof pointStyle === 'object') { + type = pointStyle.toString(); + if (type === '[object HTMLImageElement]' || type === '[object HTMLCanvasElement]') { + ctx.drawImage(pointStyle, x - pointStyle.width / 2, y - pointStyle.height / 2); + return; + } + } + + if (isNaN(radius) || radius <= 0) { + return; + } + + switch (pointStyle) { + // Default includes circle + default: + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.closePath(); + ctx.fill(); + break; + case 'triangle': + ctx.beginPath(); + edgeLength = 3 * radius / Math.sqrt(3); + height = edgeLength * Math.sqrt(3) / 2; + ctx.moveTo(x - edgeLength / 2, y + height / 3); + ctx.lineTo(x + edgeLength / 2, y + height / 3); + ctx.lineTo(x, y - 2 * height / 3); + ctx.closePath(); + ctx.fill(); + break; + case 'rect': + size = 1 / Math.SQRT2 * radius; + ctx.beginPath(); + ctx.fillRect(x - size, y - size, 2 * size, 2 * size); + ctx.strokeRect(x - size, y - size, 2 * size, 2 * size); + break; + case 'rectRounded': + var offset = radius / Math.SQRT2; + var leftX = x - offset; + var topY = y - offset; + var sideSize = Math.SQRT2 * radius; + Chart.helpers.drawRoundedRectangle(ctx, leftX, topY, sideSize, sideSize, radius / 2); + ctx.fill(); + break; + case 'rectRot': + size = 1 / Math.SQRT2 * radius; + ctx.beginPath(); + ctx.moveTo(x - size, y); + ctx.lineTo(x, y + size); + ctx.lineTo(x + size, y); + ctx.lineTo(x, y - size); + ctx.closePath(); + ctx.fill(); + break; + case 'cross': + ctx.beginPath(); + ctx.moveTo(x, y + radius); + ctx.lineTo(x, y - radius); + ctx.moveTo(x - radius, y); + ctx.lineTo(x + radius, y); + ctx.closePath(); + break; + case 'crossRot': + ctx.beginPath(); + xOffset = Math.cos(Math.PI / 4) * radius; + yOffset = Math.sin(Math.PI / 4) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.moveTo(x - xOffset, y + yOffset); + ctx.lineTo(x + xOffset, y - yOffset); + ctx.closePath(); + break; + case 'star': + ctx.beginPath(); + ctx.moveTo(x, y + radius); + ctx.lineTo(x, y - radius); + ctx.moveTo(x - radius, y); + ctx.lineTo(x + radius, y); + xOffset = Math.cos(Math.PI / 4) * radius; + yOffset = Math.sin(Math.PI / 4) * radius; + ctx.moveTo(x - xOffset, y - yOffset); + ctx.lineTo(x + xOffset, y + yOffset); + ctx.moveTo(x - xOffset, y + yOffset); + ctx.lineTo(x + xOffset, y - yOffset); + ctx.closePath(); + break; + case 'line': + ctx.beginPath(); + ctx.moveTo(x - radius, y); + ctx.lineTo(x + radius, y); + ctx.closePath(); + break; + case 'dash': + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + radius, y); + ctx.closePath(); + break; + } + + ctx.stroke(); + }; + + helpers.clipArea = function(ctx, clipArea) { + ctx.save(); + ctx.beginPath(); + ctx.rect(clipArea.left, clipArea.top, clipArea.right - clipArea.left, clipArea.bottom - clipArea.top); + ctx.clip(); + }; + + helpers.unclipArea = function(ctx) { + ctx.restore(); + }; + +}; + +},{}],23:[function(require,module,exports){ +'use strict'; + +module.exports = function(Chart) { + + var helpers = Chart.helpers; + var plugins = Chart.plugins; + var platform = Chart.platform; + + // Create a dictionary of chart types, to allow for extension of existing types + Chart.types = {}; + + // 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 = {}; + + // Controllers available for dataset visualization eg. bar, line, slice, etc. + Chart.controllers = {}; + + /** + * Initializes the given config with global and chart default values. + */ + function initConfig(config) { + config = config || {}; + + // Do NOT use configMerge() for the data object because this method merges arrays + // and so would change references to labels and datasets, preventing data updates. + var data = config.data = config.data || {}; + data.datasets = data.datasets || []; + data.labels = data.labels || []; + + config.options = helpers.configMerge( + Chart.defaults.global, + Chart.defaults[config.type], + config.options || {}); + + return config; + } + + /** + * Updates the config of the chart + * @param chart {Chart.Controller} chart to update the options for + */ + function updateConfig(chart) { + var newOptions = chart.options; + + // Update Scale(s) with options + if (newOptions.scale) { + chart.scale.options = newOptions.scale; + } else if (newOptions.scales) { + newOptions.scales.xAxes.concat(newOptions.scales.yAxes).forEach(function(scaleOptions) { + chart.scales[scaleOptions.id].options = scaleOptions; + }); + } + + // Tooltip + chart.tooltip._options = newOptions.tooltips; + } + + /** + * @class Chart.Controller + * The main controller of a chart. + */ + Chart.Controller = function(item, config, instance) { + var me = this; + + config = initConfig(config); + + var context = platform.acquireContext(item, config); + var canvas = context && context.canvas; + var height = canvas && canvas.height; + var width = canvas && canvas.width; + + instance.ctx = context; + instance.canvas = canvas; + instance.config = config; + instance.width = width; + instance.height = height; + instance.aspectRatio = height? width / height : null; + + me.id = helpers.uid(); + me.chart = instance; + me.config = config; + me.options = config.options; + me._bufferedRender = false; + + // Add the chart instance to the global namespace + Chart.instances[me.id] = me; + + Object.defineProperty(me, 'data', { + get: function() { + return me.config.data; + } + }); + + if (!context || !canvas) { + // The given item is not a compatible context2d element, let's return before finalizing + // the chart initialization but after setting basic chart / controller properties that + // can help to figure out that the chart is not valid (e.g chart.canvas !== null); + // https://github.com/chartjs/Chart.js/issues/2807 + console.error("Failed to create chart: can't acquire context from the given item"); + return me; + } + + me.initialize(); + me.update(); + + return me; + }; + + helpers.extend(Chart.Controller.prototype, /** @lends Chart.Controller.prototype */ { + initialize: function() { + var me = this; + + // Before init plugin notification + plugins.notify(me, 'beforeInit'); + + helpers.retinaScale(me.chart); + + me.bindEvents(); + + if (me.options.responsive) { + // Initial resize before chart draws (must be silent to preserve initial animations). + me.resize(true); + } + + // Make sure scales have IDs and are built before we build any controllers. + me.ensureScalesHaveIDs(); + me.buildScales(); + me.initToolTip(); + + // After init plugin notification + plugins.notify(me, 'afterInit'); + + return me; + }, + + clear: function() { + helpers.clear(this.chart); + return this; + }, + + stop: function() { + // Stops any current animation loop occurring + Chart.animationService.cancelAnimation(this); + return this; + }, + + resize: function(silent) { + var me = this; + var chart = me.chart; + var options = me.options; + var canvas = chart.canvas; + var aspectRatio = (options.maintainAspectRatio && chart.aspectRatio) || null; + + // the canvas render width and height will be casted to integers so make sure that + // the canvas display style uses the same integer values to avoid blurring effect. + var newWidth = Math.floor(helpers.getMaximumWidth(canvas)); + var newHeight = Math.floor(aspectRatio? newWidth / aspectRatio : helpers.getMaximumHeight(canvas)); + + if (chart.width === newWidth && chart.height === newHeight) { + return; + } + + canvas.width = chart.width = newWidth; + canvas.height = chart.height = newHeight; + canvas.style.width = newWidth + 'px'; + canvas.style.height = newHeight + 'px'; + + helpers.retinaScale(chart); + + if (!silent) { + // Notify any plugins about the resize + var newSize = {width: newWidth, height: newHeight}; + plugins.notify(me, 'resize', [newSize]); + + // Notify of resize + if (me.options.onResize) { + me.options.onResize(me, newSize); + } + + me.stop(); + me.update(me.options.responsiveAnimationDuration); + } + }, + + ensureScalesHaveIDs: function() { + var options = this.options; + var scalesOptions = options.scales || {}; + var scaleOptions = options.scale; + + helpers.each(scalesOptions.xAxes, function(xAxisOptions, index) { + xAxisOptions.id = xAxisOptions.id || ('x-axis-' + index); + }); + + helpers.each(scalesOptions.yAxes, function(yAxisOptions, index) { + yAxisOptions.id = yAxisOptions.id || ('y-axis-' + index); + }); + + if (scaleOptions) { + scaleOptions.id = scaleOptions.id || 'scale'; + } + }, + + /** + * Builds a map of scale ID to scale object for future lookup. + */ + buildScales: function() { + var me = this; + var options = me.options; + var scales = me.scales = {}; + var items = []; + + if (options.scales) { + items = items.concat( + (options.scales.xAxes || []).map(function(xAxisOptions) { + return {options: xAxisOptions, dtype: 'category'}; + }), + (options.scales.yAxes || []).map(function(yAxisOptions) { + return {options: yAxisOptions, dtype: 'linear'}; + }) + ); + } + + if (options.scale) { + items.push({options: options.scale, dtype: 'radialLinear', isDefault: true}); + } + + helpers.each(items, function(item) { + var scaleOptions = item.options; + var scaleType = helpers.getValueOrDefault(scaleOptions.type, item.dtype); + var scaleClass = Chart.scaleService.getScaleConstructor(scaleType); + if (!scaleClass) { + return; + } + + var scale = new scaleClass({ + id: scaleOptions.id, + options: scaleOptions, + ctx: me.chart.ctx, + chart: me + }); + + scales[scale.id] = scale; + + // TODO(SB): I think we should be able to remove this custom case (options.scale) + // and consider it as a regular scale part of the "scales"" map only! This would + // make the logic easier and remove some useless? custom code. + if (item.isDefault) { + me.scale = scale; + } + }); + + Chart.scaleService.addScalesToLayout(this); + }, + + buildOrUpdateControllers: function() { + var me = this; + var types = []; + var newControllers = []; + + helpers.each(me.data.datasets, function(dataset, datasetIndex) { + var meta = me.getDatasetMeta(datasetIndex); + if (!meta.type) { + meta.type = dataset.type || me.config.type; + } + + types.push(meta.type); + + if (meta.controller) { + meta.controller.updateIndex(datasetIndex); + } else { + meta.controller = new Chart.controllers[meta.type](me, datasetIndex); + newControllers.push(meta.controller); + } + }, me); + + if (types.length > 1) { + for (var i = 1; i < types.length; i++) { + if (types[i] !== types[i - 1]) { + me.isCombo = true; + break; + } + } + } + + return newControllers; + }, + + /** + * Reset the elements of all datasets + * @private + */ + resetElements: function() { + var me = this; + helpers.each(me.data.datasets, function(dataset, datasetIndex) { + me.getDatasetMeta(datasetIndex).controller.reset(); + }, me); + }, + + /** + * Resets the chart back to it's state before the initial animation + */ + reset: function() { + this.resetElements(); + this.tooltip.initialize(); + }, + + update: function(animationDuration, lazy) { + var me = this; + + updateConfig(me); + + if (plugins.notify(me, 'beforeUpdate') === false) { + return; + } + + // In case the entire data object changed + me.tooltip._data = me.data; + + // Make sure dataset controllers are updated and new controllers are reset + var newControllers = me.buildOrUpdateControllers(); + + // Make sure all dataset controllers have correct meta data counts + helpers.each(me.data.datasets, function(dataset, datasetIndex) { + me.getDatasetMeta(datasetIndex).controller.buildOrUpdateElements(); + }, me); + + me.updateLayout(); + + // Can only reset the new controllers after the scales have been updated + helpers.each(newControllers, function(controller) { + controller.reset(); + }); + + me.updateDatasets(); + + // Do this before render so that any plugins that need final scale updates can use it + plugins.notify(me, 'afterUpdate'); + + if (me._bufferedRender) { + me._bufferedRequest = { + lazy: lazy, + duration: animationDuration + }; + } else { + me.render(animationDuration, lazy); + } + }, + + /** + * Updates the chart layout unless a plugin returns `false` to the `beforeLayout` + * hook, in which case, plugins will not be called on `afterLayout`. + * @private + */ + updateLayout: function() { + var me = this; + + if (plugins.notify(me, 'beforeLayout') === false) { + return; + } + + Chart.layoutService.update(this, this.chart.width, this.chart.height); + + /** + * Provided for backward compatibility, use `afterLayout` instead. + * @method IPlugin#afterScaleUpdate + * @deprecated since version 2.5.0 + * @todo remove at version 3 + */ + plugins.notify(me, 'afterScaleUpdate'); + plugins.notify(me, 'afterLayout'); + }, + + /** + * Updates all datasets unless a plugin returns `false` to the `beforeDatasetsUpdate` + * hook, in which case, plugins will not be called on `afterDatasetsUpdate`. + * @private + */ + updateDatasets: function() { + var me = this; + + if (plugins.notify(me, 'beforeDatasetsUpdate') === false) { + return; + } + + for (var i = 0, ilen = me.data.datasets.length; i < ilen; ++i) { + me.getDatasetMeta(i).controller.update(); + } + + plugins.notify(me, 'afterDatasetsUpdate'); + }, + + render: function(duration, lazy) { + var me = this; + + if (plugins.notify(me, 'beforeRender') === false) { + return; + } + + var animationOptions = me.options.animation; + var onComplete = function() { + plugins.notify(me, 'afterRender'); + var callback = animationOptions && animationOptions.onComplete; + if (callback && callback.call) { + callback.call(me); + } + }; + + if (animationOptions && ((typeof duration !== 'undefined' && duration !== 0) || (typeof duration === 'undefined' && animationOptions.duration !== 0))) { + var animation = new Chart.Animation(); + animation.numSteps = (duration || animationOptions.duration) / 16.66; // 60 fps + animation.easing = animationOptions.easing; + + // 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 = animationOptions.onProgress; + animation.onAnimationComplete = onComplete; + + Chart.animationService.addAnimation(me, animation, duration, lazy); + } else { + me.draw(); + onComplete(); + } + + return me; + }, + + draw: function(easingValue) { + var me = this; + + me.clear(); + + if (easingValue === undefined || easingValue === null) { + easingValue = 1; + } + + if (plugins.notify(me, 'beforeDraw', [easingValue]) === false) { + return; + } + + // Draw all the scales + helpers.each(me.boxes, function(box) { + box.draw(me.chartArea); + }, me); + + if (me.scale) { + me.scale.draw(); + } + + me.drawDatasets(easingValue); + + // Finally draw the tooltip + me.tooltip.transition(easingValue).draw(); + + plugins.notify(me, 'afterDraw', [easingValue]); + }, + + /** + * Draws all datasets unless a plugin returns `false` to the `beforeDatasetsDraw` + * hook, in which case, plugins will not be called on `afterDatasetsDraw`. + * @private + */ + drawDatasets: function(easingValue) { + var me = this; + + if (plugins.notify(me, 'beforeDatasetsDraw', [easingValue]) === false) { + return; + } + + // Draw each dataset via its respective controller (reversed to support proper line stacking) + helpers.each(me.data.datasets, function(dataset, datasetIndex) { + if (me.isDatasetVisible(datasetIndex)) { + me.getDatasetMeta(datasetIndex).controller.draw(easingValue); + } + }, me, true); + + plugins.notify(me, 'afterDatasetsDraw', [easingValue]); + }, + + // Get the single element that was clicked on + // @return : An object containing the dataset index and element index of the matching element. Also contains the rectangle that was draw + getElementAtEvent: function(e) { + return Chart.Interaction.modes.single(this, e); + }, + + getElementsAtEvent: function(e) { + return Chart.Interaction.modes.label(this, e, {intersect: true}); + }, + + getElementsAtXAxis: function(e) { + return Chart.Interaction.modes['x-axis'](this, e, {intersect: true}); + }, + + getElementsAtEventForMode: function(e, mode, options) { + var method = Chart.Interaction.modes[mode]; + if (typeof method === 'function') { + return method(this, e, options); + } + + return []; + }, + + getDatasetAtEvent: function(e) { + return Chart.Interaction.modes.dataset(this, e, {intersect: true}); + }, + + getDatasetMeta: function(datasetIndex) { + var me = this; + var dataset = me.data.datasets[datasetIndex]; + if (!dataset._meta) { + dataset._meta = {}; + } + + var meta = dataset._meta[me.id]; + if (!meta) { + meta = dataset._meta[me.id] = { + type: null, + data: [], + dataset: null, + controller: null, + hidden: null, // See isDatasetVisible() comment + xAxisID: null, + yAxisID: null + }; + } + + return meta; + }, + + getVisibleDatasetCount: function() { + var count = 0; + for (var i = 0, ilen = this.data.datasets.length; i