├── .env.example ├── .github ├── dependabot.yml └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── Procfile ├── README.md ├── app.js ├── app.json ├── config.js ├── index.js ├── models └── SurveyResponse.js ├── package-lock.json ├── package.json ├── public ├── chart.js ├── index.html ├── index.js ├── landing.html ├── main.css └── webhook-screen-cap.gif ├── routes ├── message.js ├── results.js └── voice.js ├── survey_data.js └── test ├── message.spec.js ├── results.spec.js └── voice.spec.js /.env.example: -------------------------------------------------------------------------------- 1 | # example connection string 2 | 3 | MONGO_URL=mongodb://localhost:27017/test 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: npm-check-updates 10 | versions: 11 | - 11.1.1 12 | - 11.1.10 13 | - 11.1.4 14 | - 11.1.8 15 | - 11.1.9 16 | - 11.2.0 17 | - 11.2.1 18 | - 11.2.2 19 | - 11.3.0 20 | - 11.4.1 21 | - 11.5.1 22 | - 11.5.3 23 | - dependency-name: y18n 24 | versions: 25 | - 4.0.1 26 | - 4.0.2 27 | - dependency-name: xmldom 28 | versions: 29 | - 0.4.0 30 | - dependency-name: lodash 31 | versions: 32 | - 4.17.20 33 | - dependency-name: handlebars 34 | versions: 35 | - 4.7.6 36 | - dependency-name: mocha 37 | versions: 38 | - 8.2.1 39 | - 8.3.0 40 | - 8.3.1 41 | - dependency-name: twilio 42 | versions: 43 | - 3.51.0 44 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | paths-ignore: 10 | - '**.md' 11 | pull_request: 12 | branches: [ master ] 13 | paths-ignore: 14 | - '**.md' 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: [14] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v2 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: 'npm' 31 | - name: Start MongoDB 32 | uses: supercharge/mongodb-github-action@1.6.0 33 | with: 34 | mongodb-version: 5 35 | 36 | - name: Create env file 37 | run: cp .env.example .env 38 | 39 | - name: Install Dependencies 40 | run: npm install 41 | - run: npm run build --if-present 42 | - run: npm test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # Ignore logs 5 | *.log 6 | 7 | # Ignore local env files 8 | .env 9 | .idea 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Twilio Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node index.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Twilio 3 | 4 | 5 | [![Node.js CI](https://github.com/TwilioDevEd/survey-node/actions/workflows/node.js.yml/badge.svg)](https://github.com/TwilioDevEd/survey-node/actions/workflows/node.js.yml) 6 | 7 | # Automated Surveys. Powered by Twilio - Node.js/Express 8 | 9 | Instantly collect structured data from your users with a survey conducted over a voice call or SMS text messages. Learn how to create your own survey in the language and framework of your choice. For a step-by-step tutorial see [twilio docs](https://www.twilio.com/docs/howto/walkthrough/automated-survey/node/express). 10 | 11 | ## Local development 12 | 13 | First you need to install [Node.js](http://nodejs.org/) as well as [MongoDB](https://www.mongodb.org/) 14 | 15 | To run the app locally: 16 | 17 | 1. Clone this repository and `cd` into it 18 | 19 | ```bash 20 | $ git clone git@github.com:TwilioDevEd/survey-node.git 21 | 22 | $ cd survey-node 23 | ``` 24 | 25 | 1. Install dependencies 26 | 27 | ```bash 28 | $ npm install 29 | ``` 30 | 31 | 1. Copy the sample configuration file and edit it to match your configuration 32 | 33 | ```bash 34 | $ cp .env.example .env 35 | ``` 36 | Be sure to set `MONGO_URL`to your local mongo instance uri for example: 37 | `mongodb://localhost:27017/surveys` where `surveys` is the db name. 38 | 39 | 1. Run the application 40 | 41 | ```bash 42 | $ npm start 43 | ``` 44 | Alternatively you might also consider using [nodemon](https://github.com/remy/nodemon) for this. It works just like 45 | the node command but automatically restarts your application when you change any source code files. 46 | 47 | ```bash 48 | $ npm install -g nodemon 49 | $ nodemon index 50 | ``` 51 | 1. Expose your application to the wider internet using [ngrok](http://ngrok.com). This step 52 | is important because the application won't work as expected if you run it through 53 | localhost. 54 | 55 | ```bash 56 | $ npm i -g ngrok 57 | $ ngrok http 3000 58 | ``` 59 | 60 | Once ngrok is running, open up your browser and go to your ngrok URL. It will 61 | look something like this: `http://9a159ccf.ngrok.io` 62 | 63 | You can read [this blog post](https://www.twilio.com/blog/2015/09/6-awesome-reasons-to-use-ngrok-when-testing-webhooks.html) 64 | for more details on how to use ngrok. 65 | 66 | ### Configure Twilio to call your webhooks 67 | 68 | You will also need to configure Twilio to call your application when calls are received 69 | 70 | You will need to provision at least one Twilio number with voice capabilities 71 | so the application's users can take surveys. You can buy a number [using the twilio console.](https://www.twilio.com/user/account/phone-numbers/search). Once you havea number you need to configure your number to work with your application. Open [the number management page](https://www.twilio.com/user/account/phone-numbers/incoming) and open a number's configuration by clicking on it. 72 | 73 | ![Configure Voice](public/webhook-screen-cap.gif) 74 | 75 | 76 | 1. Check it out at [http://localhost:3000](http://localhost:3000) 77 | 78 | ## Meta 79 | 80 | * No warranty expressed or implied. Software is as is. Diggity. 81 | * [MIT License](http://www.opensource.org/licenses/mit-license.html) 82 | * Lovingly crafted by Twilio Developer Education. 83 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var express = require('express'); 3 | var morgan = require('morgan'); 4 | var mongoose = require('mongoose'); 5 | var urlencoded = require('body-parser').urlencoded; 6 | var config = require('./config'); 7 | var voice = require('./routes/voice'); 8 | var message = require('./routes/message'); 9 | var results = require('./routes/results'); 10 | var Promise = require('bluebird'); 11 | 12 | // use node A+ promises 13 | mongoose.Promise = Promise; 14 | 15 | // check for connection string 16 | if (!config.mongoUrl) { 17 | throw new Error('MONGO_URL env variable not set.'); 18 | } 19 | 20 | var isConn; 21 | // initialize MongoDB connection 22 | if (mongoose.connections.length === 0) { 23 | mongoose.connect(config.mongoUrl); 24 | } else { 25 | mongoose.connections.forEach(function(conn) { 26 | if (!conn.host) { 27 | isConn = false; 28 | } 29 | }) 30 | 31 | if (isConn === false) { 32 | mongoose.connect(config.mongoUrl); 33 | } 34 | } 35 | 36 | // Create Express web app with some useful middleware 37 | var app = express(); 38 | app.use(express.static(path.join(__dirname, 'public'))); 39 | app.use(urlencoded({ extended: true })); 40 | app.use(morgan('combined')); 41 | 42 | // Twilio Webhook routes 43 | app.post('/voice', voice.interview); 44 | app.post('/voice/:responseId/transcribe/:questionIndex', voice.transcription); 45 | app.post('/message', message); 46 | 47 | // Ajax route to aggregate response data for the UI 48 | app.get('/results', results); 49 | 50 | module.exports = app; 51 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Twilio: Automated Survey", 3 | "description": "This sample application powers automated surveys via voice calls and SMS.", 4 | "keywords": [ 5 | "twilio", 6 | "node.js", 7 | "mongodb", 8 | "mongoose", 9 | "express", 10 | "surveys" 11 | ], 12 | "website": "https://www.twilio.com/docs/howto/walkthrough/automated-survey/node/express", 13 | "repository": "https://github.com/TwilioDevEd/survey-node", 14 | "logo": "https://s3.amazonaws.com/howtodocs/twilio-logo.png", 15 | "success_url": "/landing.html", 16 | "addons": [ 17 | "mongolab" 18 | ] 19 | } -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | require('dotenv-safe').config(); 2 | 3 | module.exports = { 4 | // HTTP port 5 | port: process.env.PORT || 3000, 6 | 7 | // MongoDB connection string - MONGO_URL is for local dev, 8 | // MONGOLAB_URI is for the MongoLab add-on for Heroku deployment 9 | mongoUrl: process.env.MONGOLAB_URI || process.env.MONGO_URL || process.env.MONGODB_URI 10 | }; 11 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var app = require('./app'); 2 | var config = require('./config'); 3 | var http = require('http'); 4 | 5 | // Create HTTP server and mount Express app 6 | var server = http.createServer(app); 7 | server.listen(config.port, function() { 8 | console.log('Express server started on *:'+config.port); 9 | }); 10 | -------------------------------------------------------------------------------- /models/SurveyResponse.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose'); 2 | // Define survey response model schema 3 | var SurveyResponseSchema = new mongoose.Schema({ 4 | // phone number of participant 5 | phone: String, 6 | 7 | // status of the participant's current survey response 8 | complete: { 9 | type: Boolean, 10 | default: false 11 | }, 12 | 13 | // record of answers 14 | responses: [mongoose.Schema.Types.Mixed] 15 | }); 16 | 17 | // For the given phone number and survey, advance the survey to the next 18 | // question 19 | SurveyResponseSchema.statics.advanceSurvey = function(args, cb) { 20 | var surveyData = args.survey; 21 | var phone = args.phone; 22 | var input = args.input; 23 | var surveyResponse; 24 | 25 | // Find current incomplete survey 26 | SurveyResponse.findOne({ 27 | phone: phone, 28 | complete: false 29 | }, function(err, doc) { 30 | surveyResponse = doc || new SurveyResponse({ 31 | phone: phone 32 | }); 33 | processInput(); 34 | }); 35 | 36 | // fill in any answer to the current question, and determine next question 37 | // to ask 38 | function processInput() { 39 | // If we have input, use it to answer the current question 40 | var responseLength = surveyResponse.responses.length 41 | var currentQuestion = surveyData[responseLength]; 42 | 43 | // if there's a problem with the input, we can re-ask the same question 44 | function reask() { 45 | cb.call(surveyResponse, null, surveyResponse, responseLength); 46 | } 47 | 48 | // If we have no input, ask the current question again 49 | if (input === undefined) return reask(); 50 | 51 | // Otherwise use the input to answer the current question 52 | var questionResponse = {}; 53 | if (currentQuestion.type === 'boolean') { 54 | // Anything other than '1' or 'yes' is a false 55 | var isTrue = input === '1' || input.toLowerCase() === 'yes'; 56 | questionResponse.answer = isTrue; 57 | } else if (currentQuestion.type === 'number') { 58 | // Try and cast to a Number 59 | var num = Number(input); 60 | if (isNaN(num)) { 61 | // don't update the survey response, return the same question 62 | return reask(); 63 | } else { 64 | questionResponse.answer = num; 65 | } 66 | } else if (input.indexOf('http') === 0) { 67 | // input is a recording URL 68 | questionResponse.recordingUrl = input; 69 | } else { 70 | // otherwise store raw value 71 | questionResponse.answer = input; 72 | } 73 | 74 | // Save type from question 75 | questionResponse.type = currentQuestion.type; 76 | surveyResponse.responses.push(questionResponse); 77 | 78 | // If new responses length is the length of survey, mark as done 79 | if (surveyResponse.responses.length === surveyData.length) { 80 | surveyResponse.complete = true; 81 | } 82 | 83 | // Save response 84 | surveyResponse.save(function(err) { 85 | if (err) { 86 | reask(); 87 | } else { 88 | cb.call(surveyResponse, err, surveyResponse, responseLength+1); 89 | } 90 | }); 91 | } 92 | }; 93 | 94 | // Export model 95 | delete mongoose.models.SurveyResponse 96 | delete mongoose.modelSchemas.SurveyResponse 97 | var SurveyResponse = mongoose.model('SurveyResponse', SurveyResponseSchema); 98 | module.exports = SurveyResponse; 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "survey-node", 3 | "version": "1.0.0", 4 | "description": "an automated survey conducted via voice and sms", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index", 8 | "test": "node_modules/.bin/mocha ./**/*.spec.js --exit" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/TwilioDevEd/survey-node" 13 | }, 14 | "keywords": [ 15 | "twilio", 16 | "express", 17 | "survey" 18 | ], 19 | "author": "Kevin Whinnery", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/TwilioDevEd/survey-node/issues" 23 | }, 24 | "homepage": "https://github.com/TwilioDevEd/survey-node", 25 | "dependencies": { 26 | "bluebird": "^3.5.5", 27 | "body-parser": "^1.19.0", 28 | "dotenv-safe": "^8.2.0", 29 | "express": "^4.17.1", 30 | "jade": "^1.11.0", 31 | "mongoose": "5.6.11", 32 | "morgan": "^1.9.1", 33 | "npm-check-updates": "^3.1.21", 34 | "tracer": "^1.0.1", 35 | "twilio": "3.33.4" 36 | }, 37 | "devDependencies": { 38 | "chai": "^4.2.0", 39 | "lodash": "^4.17.15", 40 | "mocha": "^6.2.0", 41 | "supertest": "^4.0.2", 42 | "supertest-promised": "^1.0.0", 43 | "xmldom": "^0.1.27", 44 | "xpath": "0.0.27" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /public/chart.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Chart.js 3 | * http://chartjs.org/ 4 | * Version: 1.0.1 5 | * 6 | * Copyright 2015 Nick Downie 7 | * Released under the MIT license 8 | * https://github.com/nnnick/Chart.js/blob/master/LICENSE.md 9 | */ 10 | 11 | 12 | (function(){ 13 | 14 | "use strict"; 15 | 16 | //Declare root variable - window in the browser, global on the server 17 | var root = this, 18 | previous = root.Chart; 19 | 20 | //Occupy the global variable of Chart, and create a simple base class 21 | var Chart = function(context){ 22 | var chart = this; 23 | this.canvas = context.canvas; 24 | 25 | this.ctx = context; 26 | 27 | //Variables global to the chart 28 | var computeDimension = function(element,dimension) 29 | { 30 | if (element['offset'+dimension]) 31 | { 32 | return element['offset'+dimension]; 33 | } 34 | else 35 | { 36 | return document.defaultView.getComputedStyle(element).getPropertyValue(dimension); 37 | } 38 | } 39 | 40 | var width = this.width = computeDimension(context.canvas,'Width'); 41 | var height = this.height = computeDimension(context.canvas,'Height'); 42 | 43 | // Firefox requires this to work correctly 44 | context.canvas.width = width; 45 | context.canvas.height = height; 46 | 47 | this.aspectRatio = this.width / this.height; 48 | //High pixel density displays - multiply the size of the canvas height/width by the device pixel ratio, then scale. 49 | helpers.retinaScale(this); 50 | 51 | return this; 52 | }; 53 | //Globally expose the defaults to allow for user updating/changing 54 | Chart.defaults = { 55 | global: { 56 | // Boolean - Whether to animate the chart 57 | animation: true, 58 | 59 | // Number - Number of animation steps 60 | animationSteps: 60, 61 | 62 | // String - Animation easing effect 63 | animationEasing: "easeOutQuart", 64 | 65 | // Boolean - If we should show the scale at all 66 | showScale: true, 67 | 68 | // Boolean - If we want to override with a hard coded scale 69 | scaleOverride: false, 70 | 71 | // ** Required if scaleOverride is true ** 72 | // Number - The number of steps in a hard coded scale 73 | scaleSteps: null, 74 | // Number - The value jump in the hard coded scale 75 | scaleStepWidth: null, 76 | // Number - The scale starting value 77 | scaleStartValue: null, 78 | 79 | // String - Colour of the scale line 80 | scaleLineColor: "rgba(0,0,0,.1)", 81 | 82 | // Number - Pixel width of the scale line 83 | scaleLineWidth: 1, 84 | 85 | // Boolean - Whether to show labels on the scale 86 | scaleShowLabels: true, 87 | 88 | // Interpolated JS string - can access value 89 | scaleLabel: "<%=value%>", 90 | 91 | // Boolean - Whether the scale should stick to integers, and not show any floats even if drawing space is there 92 | scaleIntegersOnly: true, 93 | 94 | // Boolean - Whether the scale should start at zero, or an order of magnitude down from the lowest value 95 | scaleBeginAtZero: false, 96 | 97 | // String - Scale label font declaration for the scale label 98 | scaleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", 99 | 100 | // Number - Scale label font size in pixels 101 | scaleFontSize: 12, 102 | 103 | // String - Scale label font weight style 104 | scaleFontStyle: "normal", 105 | 106 | // String - Scale label font colour 107 | scaleFontColor: "#666", 108 | 109 | // Boolean - whether or not the chart should be responsive and resize when the browser does. 110 | responsive: false, 111 | 112 | // Boolean - whether to maintain the starting aspect ratio or not when responsive, if set to false, will take up entire container 113 | maintainAspectRatio: true, 114 | 115 | // Boolean - Determines whether to draw tooltips on the canvas or not - attaches events to touchmove & mousemove 116 | showTooltips: true, 117 | 118 | // Boolean - Determines whether to draw built-in tooltip or call custom tooltip function 119 | customTooltips: false, 120 | 121 | // Array - Array of string names to attach tooltip events 122 | tooltipEvents: ["mousemove", "touchstart", "touchmove", "mouseout"], 123 | 124 | // String - Tooltip background colour 125 | tooltipFillColor: "rgba(0,0,0,0.8)", 126 | 127 | // String - Tooltip label font declaration for the scale label 128 | tooltipFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", 129 | 130 | // Number - Tooltip label font size in pixels 131 | tooltipFontSize: 14, 132 | 133 | // String - Tooltip font weight style 134 | tooltipFontStyle: "normal", 135 | 136 | // String - Tooltip label font colour 137 | tooltipFontColor: "#fff", 138 | 139 | // String - Tooltip title font declaration for the scale label 140 | tooltipTitleFontFamily: "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif", 141 | 142 | // Number - Tooltip title font size in pixels 143 | tooltipTitleFontSize: 14, 144 | 145 | // String - Tooltip title font weight style 146 | tooltipTitleFontStyle: "bold", 147 | 148 | // String - Tooltip title font colour 149 | tooltipTitleFontColor: "#fff", 150 | 151 | // Number - pixel width of padding around tooltip text 152 | tooltipYPadding: 6, 153 | 154 | // Number - pixel width of padding around tooltip text 155 | tooltipXPadding: 6, 156 | 157 | // Number - Size of the caret on the tooltip 158 | tooltipCaretSize: 8, 159 | 160 | // Number - Pixel radius of the tooltip border 161 | tooltipCornerRadius: 6, 162 | 163 | // Number - Pixel offset from point x to tooltip edge 164 | tooltipXOffset: 10, 165 | 166 | // String - Template string for single tooltips 167 | tooltipTemplate: "<%if (label){%><%=label%>: <%}%><%= value %>", 168 | 169 | // String - Template string for single tooltips 170 | multiTooltipTemplate: "<%= value %>", 171 | 172 | // String - Colour behind the legend colour block 173 | multiTooltipKeyBackground: '#fff', 174 | 175 | // Function - Will fire on animation progression. 176 | onAnimationProgress: function(){}, 177 | 178 | // Function - Will fire on animation completion. 179 | onAnimationComplete: function(){} 180 | 181 | } 182 | }; 183 | 184 | //Create a dictionary of chart types, to allow for extension of existing types 185 | Chart.types = {}; 186 | 187 | //Global Chart helpers object for utility methods and classes 188 | var helpers = Chart.helpers = {}; 189 | 190 | //-- Basic js utility methods 191 | var each = helpers.each = function(loopable,callback,self){ 192 | var additionalArgs = Array.prototype.slice.call(arguments, 3); 193 | // Check to see if null or undefined firstly. 194 | if (loopable){ 195 | if (loopable.length === +loopable.length){ 196 | var i; 197 | for (i=0; i= 0; i--) { 269 | var currentItem = arrayToSearch[i]; 270 | if (filterCallback(currentItem)){ 271 | return currentItem; 272 | } 273 | } 274 | }, 275 | inherits = helpers.inherits = function(extensions){ 276 | //Basic javascript inheritance based on the model created in Backbone.js 277 | var parent = this; 278 | var ChartElement = (extensions && extensions.hasOwnProperty("constructor")) ? extensions.constructor : function(){ return parent.apply(this, arguments); }; 279 | 280 | var Surrogate = function(){ this.constructor = ChartElement;}; 281 | Surrogate.prototype = parent.prototype; 282 | ChartElement.prototype = new Surrogate(); 283 | 284 | ChartElement.extend = inherits; 285 | 286 | if (extensions) extend(ChartElement.prototype, extensions); 287 | 288 | ChartElement.__super__ = parent.prototype; 289 | 290 | return ChartElement; 291 | }, 292 | noop = helpers.noop = function(){}, 293 | uid = helpers.uid = (function(){ 294 | var id=0; 295 | return function(){ 296 | return "chart-" + id++; 297 | }; 298 | })(), 299 | warn = helpers.warn = function(str){ 300 | //Method for warning of errors 301 | if (window.console && typeof window.console.warn == "function") console.warn(str); 302 | }, 303 | amd = helpers.amd = (typeof define == 'function' && define.amd), 304 | //-- Math methods 305 | isNumber = helpers.isNumber = function(n){ 306 | return !isNaN(parseFloat(n)) && isFinite(n); 307 | }, 308 | max = helpers.max = function(array){ 309 | return Math.max.apply( Math, array ); 310 | }, 311 | min = helpers.min = function(array){ 312 | return Math.min.apply( Math, array ); 313 | }, 314 | cap = helpers.cap = function(valueToCap,maxValue,minValue){ 315 | if(isNumber(maxValue)) { 316 | if( valueToCap > maxValue ) { 317 | return maxValue; 318 | } 319 | } 320 | else if(isNumber(minValue)){ 321 | if ( valueToCap < minValue ){ 322 | return minValue; 323 | } 324 | } 325 | return valueToCap; 326 | }, 327 | getDecimalPlaces = helpers.getDecimalPlaces = function(num){ 328 | if (num%1!==0 && isNumber(num)){ 329 | return num.toString().split(".")[1].length; 330 | } 331 | else { 332 | return 0; 333 | } 334 | }, 335 | toRadians = helpers.radians = function(degrees){ 336 | return degrees * (Math.PI/180); 337 | }, 338 | // Gets the angle from vertical upright to the point about a centre. 339 | getAngleFromPoint = helpers.getAngleFromPoint = function(centrePoint, anglePoint){ 340 | var distanceFromXCenter = anglePoint.x - centrePoint.x, 341 | distanceFromYCenter = anglePoint.y - centrePoint.y, 342 | radialDistanceFromCenter = Math.sqrt( distanceFromXCenter * distanceFromXCenter + distanceFromYCenter * distanceFromYCenter); 343 | 344 | 345 | var angle = Math.PI * 2 + Math.atan2(distanceFromYCenter, distanceFromXCenter); 346 | 347 | //If the segment is in the top left quadrant, we need to add another rotation to the angle 348 | if (distanceFromXCenter < 0 && distanceFromYCenter < 0){ 349 | angle += Math.PI*2; 350 | } 351 | 352 | return { 353 | angle: angle, 354 | distance: radialDistanceFromCenter 355 | }; 356 | }, 357 | aliasPixel = helpers.aliasPixel = function(pixelWidth){ 358 | return (pixelWidth % 2 === 0) ? 0 : 0.5; 359 | }, 360 | splineCurve = helpers.splineCurve = function(FirstPoint,MiddlePoint,AfterPoint,t){ 361 | //Props to Rob Spencer at scaled innovation for his post on splining between points 362 | //http://scaledinnovation.com/analytics/splines/aboutSplines.html 363 | var d01=Math.sqrt(Math.pow(MiddlePoint.x-FirstPoint.x,2)+Math.pow(MiddlePoint.y-FirstPoint.y,2)), 364 | d12=Math.sqrt(Math.pow(AfterPoint.x-MiddlePoint.x,2)+Math.pow(AfterPoint.y-MiddlePoint.y,2)), 365 | fa=t*d01/(d01+d12),// scaling factor for triangle Ta 366 | fb=t*d12/(d01+d12); 367 | return { 368 | inner : { 369 | x : MiddlePoint.x-fa*(AfterPoint.x-FirstPoint.x), 370 | y : MiddlePoint.y-fa*(AfterPoint.y-FirstPoint.y) 371 | }, 372 | outer : { 373 | x: MiddlePoint.x+fb*(AfterPoint.x-FirstPoint.x), 374 | y : MiddlePoint.y+fb*(AfterPoint.y-FirstPoint.y) 375 | } 376 | }; 377 | }, 378 | calculateOrderOfMagnitude = helpers.calculateOrderOfMagnitude = function(val){ 379 | return Math.floor(Math.log(val) / Math.LN10); 380 | }, 381 | calculateScaleRange = helpers.calculateScaleRange = function(valuesArray, drawingSize, textSize, startFromZero, integersOnly){ 382 | 383 | //Set a minimum step of two - a point at the top of the graph, and a point at the base 384 | var minSteps = 2, 385 | maxSteps = Math.floor(drawingSize/(textSize * 1.5)), 386 | skipFitting = (minSteps >= maxSteps); 387 | 388 | var maxValue = max(valuesArray), 389 | minValue = min(valuesArray); 390 | 391 | // We need some degree of seperation here to calculate the scales if all the values are the same 392 | // Adding/minusing 0.5 will give us a range of 1. 393 | if (maxValue === minValue){ 394 | maxValue += 0.5; 395 | // So we don't end up with a graph with a negative start value if we've said always start from zero 396 | if (minValue >= 0.5 && !startFromZero){ 397 | minValue -= 0.5; 398 | } 399 | else{ 400 | // Make up a whole number above the values 401 | maxValue += 0.5; 402 | } 403 | } 404 | 405 | var valueRange = Math.abs(maxValue - minValue), 406 | rangeOrderOfMagnitude = calculateOrderOfMagnitude(valueRange), 407 | graphMax = Math.ceil(maxValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude), 408 | graphMin = (startFromZero) ? 0 : Math.floor(minValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude), 409 | graphRange = graphMax - graphMin, 410 | stepValue = Math.pow(10, rangeOrderOfMagnitude), 411 | numberOfSteps = Math.round(graphRange / stepValue); 412 | 413 | //If we have more space on the graph we'll use it to give more definition to the data 414 | while((numberOfSteps > maxSteps || (numberOfSteps * 2) < maxSteps) && !skipFitting) { 415 | if(numberOfSteps > maxSteps){ 416 | stepValue *=2; 417 | numberOfSteps = Math.round(graphRange/stepValue); 418 | // Don't ever deal with a decimal number of steps - cancel fitting and just use the minimum number of steps. 419 | if (numberOfSteps % 1 !== 0){ 420 | skipFitting = true; 421 | } 422 | } 423 | //We can fit in double the amount of scale points on the scale 424 | else{ 425 | //If user has declared ints only, and the step value isn't a decimal 426 | if (integersOnly && rangeOrderOfMagnitude >= 0){ 427 | //If the user has said integers only, we need to check that making the scale more granular wouldn't make it a float 428 | if(stepValue/2 % 1 === 0){ 429 | stepValue /=2; 430 | numberOfSteps = Math.round(graphRange/stepValue); 431 | } 432 | //If it would make it a float break out of the loop 433 | else{ 434 | break; 435 | } 436 | } 437 | //If the scale doesn't have to be an int, make the scale more granular anyway. 438 | else{ 439 | stepValue /=2; 440 | numberOfSteps = Math.round(graphRange/stepValue); 441 | } 442 | 443 | } 444 | } 445 | 446 | if (skipFitting){ 447 | numberOfSteps = minSteps; 448 | stepValue = graphRange / numberOfSteps; 449 | } 450 | 451 | return { 452 | steps : numberOfSteps, 453 | stepValue : stepValue, 454 | min : graphMin, 455 | max : graphMin + (numberOfSteps * stepValue) 456 | }; 457 | 458 | }, 459 | /* jshint ignore:start */ 460 | // Blows up jshint errors based on the new Function constructor 461 | //Templating methods 462 | //Javascript micro templating by John Resig - source at http://ejohn.org/blog/javascript-micro-templating/ 463 | template = helpers.template = function(templateString, valuesObject){ 464 | 465 | // If templateString is function rather than string-template - call the function for valuesObject 466 | 467 | if(templateString instanceof Function){ 468 | return templateString(valuesObject); 469 | } 470 | 471 | var cache = {}; 472 | function tmpl(str, data){ 473 | // Figure out if we're getting a template, or if we need to 474 | // load the template - and be sure to cache the result. 475 | var fn = !/\W/.test(str) ? 476 | cache[str] = cache[str] : 477 | 478 | // Generate a reusable function that will serve as a template 479 | // generator (and which will be cached). 480 | new Function("obj", 481 | "var p=[],print=function(){p.push.apply(p,arguments);};" + 482 | 483 | // Introduce the data as local variables using with(){} 484 | "with(obj){p.push('" + 485 | 486 | // Convert the template into pure JavaScript 487 | str 488 | .replace(/[\r\t\n]/g, " ") 489 | .split("<%").join("\t") 490 | .replace(/((^|%>)[^\t]*)'/g, "$1\r") 491 | .replace(/\t=(.*?)%>/g, "',$1,'") 492 | .split("\t").join("');") 493 | .split("%>").join("p.push('") 494 | .split("\r").join("\\'") + 495 | "');}return p.join('');" 496 | ); 497 | 498 | // Provide some basic currying to the user 499 | return data ? fn( data ) : fn; 500 | } 501 | return tmpl(templateString,valuesObject); 502 | }, 503 | /* jshint ignore:end */ 504 | generateLabels = helpers.generateLabels = function(templateString,numberOfSteps,graphMin,stepValue){ 505 | var labelsArray = new Array(numberOfSteps); 506 | if (labelTemplateString){ 507 | each(labelsArray,function(val,index){ 508 | labelsArray[index] = template(templateString,{value: (graphMin + (stepValue*(index+1)))}); 509 | }); 510 | } 511 | return labelsArray; 512 | }, 513 | //--Animation methods 514 | //Easing functions adapted from Robert Penner's easing equations 515 | //http://www.robertpenner.com/easing/ 516 | easingEffects = helpers.easingEffects = { 517 | linear: function (t) { 518 | return t; 519 | }, 520 | easeInQuad: function (t) { 521 | return t * t; 522 | }, 523 | easeOutQuad: function (t) { 524 | return -1 * t * (t - 2); 525 | }, 526 | easeInOutQuad: function (t) { 527 | if ((t /= 1 / 2) < 1) return 1 / 2 * t * t; 528 | return -1 / 2 * ((--t) * (t - 2) - 1); 529 | }, 530 | easeInCubic: function (t) { 531 | return t * t * t; 532 | }, 533 | easeOutCubic: function (t) { 534 | return 1 * ((t = t / 1 - 1) * t * t + 1); 535 | }, 536 | easeInOutCubic: function (t) { 537 | if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t; 538 | return 1 / 2 * ((t -= 2) * t * t + 2); 539 | }, 540 | easeInQuart: function (t) { 541 | return t * t * t * t; 542 | }, 543 | easeOutQuart: function (t) { 544 | return -1 * ((t = t / 1 - 1) * t * t * t - 1); 545 | }, 546 | easeInOutQuart: function (t) { 547 | if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t * t; 548 | return -1 / 2 * ((t -= 2) * t * t * t - 2); 549 | }, 550 | easeInQuint: function (t) { 551 | return 1 * (t /= 1) * t * t * t * t; 552 | }, 553 | easeOutQuint: function (t) { 554 | return 1 * ((t = t / 1 - 1) * t * t * t * t + 1); 555 | }, 556 | easeInOutQuint: function (t) { 557 | if ((t /= 1 / 2) < 1) return 1 / 2 * t * t * t * t * t; 558 | return 1 / 2 * ((t -= 2) * t * t * t * t + 2); 559 | }, 560 | easeInSine: function (t) { 561 | return -1 * Math.cos(t / 1 * (Math.PI / 2)) + 1; 562 | }, 563 | easeOutSine: function (t) { 564 | return 1 * Math.sin(t / 1 * (Math.PI / 2)); 565 | }, 566 | easeInOutSine: function (t) { 567 | return -1 / 2 * (Math.cos(Math.PI * t / 1) - 1); 568 | }, 569 | easeInExpo: function (t) { 570 | return (t === 0) ? 1 : 1 * Math.pow(2, 10 * (t / 1 - 1)); 571 | }, 572 | easeOutExpo: function (t) { 573 | return (t === 1) ? 1 : 1 * (-Math.pow(2, -10 * t / 1) + 1); 574 | }, 575 | easeInOutExpo: function (t) { 576 | if (t === 0) return 0; 577 | if (t === 1) return 1; 578 | if ((t /= 1 / 2) < 1) return 1 / 2 * Math.pow(2, 10 * (t - 1)); 579 | return 1 / 2 * (-Math.pow(2, -10 * --t) + 2); 580 | }, 581 | easeInCirc: function (t) { 582 | if (t >= 1) return t; 583 | return -1 * (Math.sqrt(1 - (t /= 1) * t) - 1); 584 | }, 585 | easeOutCirc: function (t) { 586 | return 1 * Math.sqrt(1 - (t = t / 1 - 1) * t); 587 | }, 588 | easeInOutCirc: function (t) { 589 | if ((t /= 1 / 2) < 1) return -1 / 2 * (Math.sqrt(1 - t * t) - 1); 590 | return 1 / 2 * (Math.sqrt(1 - (t -= 2) * t) + 1); 591 | }, 592 | easeInElastic: function (t) { 593 | var s = 1.70158; 594 | var p = 0; 595 | var a = 1; 596 | if (t === 0) return 0; 597 | if ((t /= 1) == 1) return 1; 598 | if (!p) p = 1 * 0.3; 599 | if (a < Math.abs(1)) { 600 | a = 1; 601 | s = p / 4; 602 | } else s = p / (2 * Math.PI) * Math.asin(1 / a); 603 | return -(a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p)); 604 | }, 605 | easeOutElastic: function (t) { 606 | var s = 1.70158; 607 | var p = 0; 608 | var a = 1; 609 | if (t === 0) return 0; 610 | if ((t /= 1) == 1) return 1; 611 | if (!p) p = 1 * 0.3; 612 | if (a < Math.abs(1)) { 613 | a = 1; 614 | s = p / 4; 615 | } else s = p / (2 * Math.PI) * Math.asin(1 / a); 616 | return a * Math.pow(2, -10 * t) * Math.sin((t * 1 - s) * (2 * Math.PI) / p) + 1; 617 | }, 618 | easeInOutElastic: function (t) { 619 | var s = 1.70158; 620 | var p = 0; 621 | var a = 1; 622 | if (t === 0) return 0; 623 | if ((t /= 1 / 2) == 2) return 1; 624 | if (!p) p = 1 * (0.3 * 1.5); 625 | if (a < Math.abs(1)) { 626 | a = 1; 627 | s = p / 4; 628 | } else s = p / (2 * Math.PI) * Math.asin(1 / a); 629 | if (t < 1) return -0.5 * (a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p)); 630 | return a * Math.pow(2, -10 * (t -= 1)) * Math.sin((t * 1 - s) * (2 * Math.PI) / p) * 0.5 + 1; 631 | }, 632 | easeInBack: function (t) { 633 | var s = 1.70158; 634 | return 1 * (t /= 1) * t * ((s + 1) * t - s); 635 | }, 636 | easeOutBack: function (t) { 637 | var s = 1.70158; 638 | return 1 * ((t = t / 1 - 1) * t * ((s + 1) * t + s) + 1); 639 | }, 640 | easeInOutBack: function (t) { 641 | var s = 1.70158; 642 | if ((t /= 1 / 2) < 1) return 1 / 2 * (t * t * (((s *= (1.525)) + 1) * t - s)); 643 | return 1 / 2 * ((t -= 2) * t * (((s *= (1.525)) + 1) * t + s) + 2); 644 | }, 645 | easeInBounce: function (t) { 646 | return 1 - easingEffects.easeOutBounce(1 - t); 647 | }, 648 | easeOutBounce: function (t) { 649 | if ((t /= 1) < (1 / 2.75)) { 650 | return 1 * (7.5625 * t * t); 651 | } else if (t < (2 / 2.75)) { 652 | return 1 * (7.5625 * (t -= (1.5 / 2.75)) * t + 0.75); 653 | } else if (t < (2.5 / 2.75)) { 654 | return 1 * (7.5625 * (t -= (2.25 / 2.75)) * t + 0.9375); 655 | } else { 656 | return 1 * (7.5625 * (t -= (2.625 / 2.75)) * t + 0.984375); 657 | } 658 | }, 659 | easeInOutBounce: function (t) { 660 | if (t < 1 / 2) return easingEffects.easeInBounce(t * 2) * 0.5; 661 | return easingEffects.easeOutBounce(t * 2 - 1) * 0.5 + 1 * 0.5; 662 | } 663 | }, 664 | //Request animation polyfill - http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/ 665 | requestAnimFrame = helpers.requestAnimFrame = (function(){ 666 | return window.requestAnimationFrame || 667 | window.webkitRequestAnimationFrame || 668 | window.mozRequestAnimationFrame || 669 | window.oRequestAnimationFrame || 670 | window.msRequestAnimationFrame || 671 | function(callback) { 672 | return window.setTimeout(callback, 1000 / 60); 673 | }; 674 | })(), 675 | cancelAnimFrame = helpers.cancelAnimFrame = (function(){ 676 | return window.cancelAnimationFrame || 677 | window.webkitCancelAnimationFrame || 678 | window.mozCancelAnimationFrame || 679 | window.oCancelAnimationFrame || 680 | window.msCancelAnimationFrame || 681 | function(callback) { 682 | return window.clearTimeout(callback, 1000 / 60); 683 | }; 684 | })(), 685 | animationLoop = helpers.animationLoop = function(callback,totalSteps,easingString,onProgress,onComplete,chartInstance){ 686 | 687 | var currentStep = 0, 688 | easingFunction = easingEffects[easingString] || easingEffects.linear; 689 | 690 | var animationFrame = function(){ 691 | currentStep++; 692 | var stepDecimal = currentStep/totalSteps; 693 | var easeDecimal = easingFunction(stepDecimal); 694 | 695 | callback.call(chartInstance,easeDecimal,stepDecimal, currentStep); 696 | onProgress.call(chartInstance,easeDecimal,stepDecimal); 697 | if (currentStep < totalSteps){ 698 | chartInstance.animationFrame = requestAnimFrame(animationFrame); 699 | } else{ 700 | onComplete.apply(chartInstance); 701 | } 702 | }; 703 | requestAnimFrame(animationFrame); 704 | }, 705 | //-- DOM methods 706 | getRelativePosition = helpers.getRelativePosition = function(evt){ 707 | var mouseX, mouseY; 708 | var e = evt.originalEvent || evt, 709 | canvas = evt.currentTarget || evt.srcElement, 710 | boundingRect = canvas.getBoundingClientRect(); 711 | 712 | if (e.touches){ 713 | mouseX = e.touches[0].clientX - boundingRect.left; 714 | mouseY = e.touches[0].clientY - boundingRect.top; 715 | 716 | } 717 | else{ 718 | mouseX = e.clientX - boundingRect.left; 719 | mouseY = e.clientY - boundingRect.top; 720 | } 721 | 722 | return { 723 | x : mouseX, 724 | y : mouseY 725 | }; 726 | 727 | }, 728 | addEvent = helpers.addEvent = function(node,eventType,method){ 729 | if (node.addEventListener){ 730 | node.addEventListener(eventType,method); 731 | } else if (node.attachEvent){ 732 | node.attachEvent("on"+eventType, method); 733 | } else { 734 | node["on"+eventType] = method; 735 | } 736 | }, 737 | removeEvent = helpers.removeEvent = function(node, eventType, handler){ 738 | if (node.removeEventListener){ 739 | node.removeEventListener(eventType, handler, false); 740 | } else if (node.detachEvent){ 741 | node.detachEvent("on"+eventType,handler); 742 | } else{ 743 | node["on" + eventType] = noop; 744 | } 745 | }, 746 | bindEvents = helpers.bindEvents = function(chartInstance, arrayOfEvents, handler){ 747 | // Create the events object if it's not already present 748 | if (!chartInstance.events) chartInstance.events = {}; 749 | 750 | each(arrayOfEvents,function(eventName){ 751 | chartInstance.events[eventName] = function(){ 752 | handler.apply(chartInstance, arguments); 753 | }; 754 | addEvent(chartInstance.chart.canvas,eventName,chartInstance.events[eventName]); 755 | }); 756 | }, 757 | unbindEvents = helpers.unbindEvents = function (chartInstance, arrayOfEvents) { 758 | each(arrayOfEvents, function(handler,eventName){ 759 | removeEvent(chartInstance.chart.canvas, eventName, handler); 760 | }); 761 | }, 762 | getMaximumWidth = helpers.getMaximumWidth = function(domNode){ 763 | var container = domNode.parentNode; 764 | // TODO = check cross browser stuff with this. 765 | return container.clientWidth; 766 | }, 767 | getMaximumHeight = helpers.getMaximumHeight = function(domNode){ 768 | var container = domNode.parentNode; 769 | // TODO = check cross browser stuff with this. 770 | return container.clientHeight; 771 | }, 772 | getMaximumSize = helpers.getMaximumSize = helpers.getMaximumWidth, // legacy support 773 | retinaScale = helpers.retinaScale = function(chart){ 774 | var ctx = chart.ctx, 775 | width = chart.canvas.width, 776 | height = chart.canvas.height; 777 | 778 | if (window.devicePixelRatio) { 779 | ctx.canvas.style.width = width + "px"; 780 | ctx.canvas.style.height = height + "px"; 781 | ctx.canvas.height = height * window.devicePixelRatio; 782 | ctx.canvas.width = width * window.devicePixelRatio; 783 | ctx.scale(window.devicePixelRatio, window.devicePixelRatio); 784 | } 785 | }, 786 | //-- Canvas methods 787 | clear = helpers.clear = function(chart){ 788 | chart.ctx.clearRect(0,0,chart.width,chart.height); 789 | }, 790 | fontString = helpers.fontString = function(pixelSize,fontStyle,fontFamily){ 791 | return fontStyle + " " + pixelSize+"px " + fontFamily; 792 | }, 793 | longestText = helpers.longestText = function(ctx,font,arrayOfStrings){ 794 | ctx.font = font; 795 | var longest = 0; 796 | each(arrayOfStrings,function(string){ 797 | var textWidth = ctx.measureText(string).width; 798 | longest = (textWidth > longest) ? textWidth : longest; 799 | }); 800 | return longest; 801 | }, 802 | drawRoundedRectangle = helpers.drawRoundedRectangle = function(ctx,x,y,width,height,radius){ 803 | ctx.beginPath(); 804 | ctx.moveTo(x + radius, y); 805 | ctx.lineTo(x + width - radius, y); 806 | ctx.quadraticCurveTo(x + width, y, x + width, y + radius); 807 | ctx.lineTo(x + width, y + height - radius); 808 | ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); 809 | ctx.lineTo(x + radius, y + height); 810 | ctx.quadraticCurveTo(x, y + height, x, y + height - radius); 811 | ctx.lineTo(x, y + radius); 812 | ctx.quadraticCurveTo(x, y, x + radius, y); 813 | ctx.closePath(); 814 | }; 815 | 816 | 817 | //Store a reference to each instance - allowing us to globally resize chart instances on window resize. 818 | //Destroy method on the chart will remove the instance of the chart from this reference. 819 | Chart.instances = {}; 820 | 821 | Chart.Type = function(data,options,chart){ 822 | this.options = options; 823 | this.chart = chart; 824 | this.id = uid(); 825 | //Add the chart instance to the global namespace 826 | Chart.instances[this.id] = this; 827 | 828 | // Initialize is always called when a chart type is created 829 | // By default it is a no op, but it should be extended 830 | if (options.responsive){ 831 | this.resize(); 832 | } 833 | this.initialize.call(this,data); 834 | }; 835 | 836 | //Core methods that'll be a part of every chart type 837 | extend(Chart.Type.prototype,{ 838 | initialize : function(){return this;}, 839 | clear : function(){ 840 | clear(this.chart); 841 | return this; 842 | }, 843 | stop : function(){ 844 | // Stops any current animation loop occuring 845 | helpers.cancelAnimFrame.call(root, this.animationFrame); 846 | return this; 847 | }, 848 | resize : function(callback){ 849 | this.stop(); 850 | var canvas = this.chart.canvas, 851 | newWidth = getMaximumWidth(this.chart.canvas), 852 | newHeight = this.options.maintainAspectRatio ? newWidth / this.chart.aspectRatio : getMaximumHeight(this.chart.canvas); 853 | 854 | canvas.width = this.chart.width = newWidth; 855 | canvas.height = this.chart.height = newHeight; 856 | 857 | retinaScale(this.chart); 858 | 859 | if (typeof callback === "function"){ 860 | callback.apply(this, Array.prototype.slice.call(arguments, 1)); 861 | } 862 | return this; 863 | }, 864 | reflow : noop, 865 | render : function(reflow){ 866 | if (reflow){ 867 | this.reflow(); 868 | } 869 | if (this.options.animation && !reflow){ 870 | helpers.animationLoop( 871 | this.draw, 872 | this.options.animationSteps, 873 | this.options.animationEasing, 874 | this.options.onAnimationProgress, 875 | this.options.onAnimationComplete, 876 | this 877 | ); 878 | } 879 | else{ 880 | this.draw(); 881 | this.options.onAnimationComplete.call(this); 882 | } 883 | return this; 884 | }, 885 | generateLegend : function(){ 886 | return template(this.options.legendTemplate,this); 887 | }, 888 | destroy : function(){ 889 | this.clear(); 890 | unbindEvents(this, this.events); 891 | var canvas = this.chart.canvas; 892 | 893 | // Reset canvas height/width attributes starts a fresh with the canvas context 894 | canvas.width = this.chart.width; 895 | canvas.height = this.chart.height; 896 | 897 | // < IE9 doesn't support removeProperty 898 | if (canvas.style.removeProperty) { 899 | canvas.style.removeProperty('width'); 900 | canvas.style.removeProperty('height'); 901 | } else { 902 | canvas.style.removeAttribute('width'); 903 | canvas.style.removeAttribute('height'); 904 | } 905 | 906 | delete Chart.instances[this.id]; 907 | }, 908 | showTooltip : function(ChartElements, forceRedraw){ 909 | // Only redraw the chart if we've actually changed what we're hovering on. 910 | if (typeof this.activeElements === 'undefined') this.activeElements = []; 911 | 912 | var isChanged = (function(Elements){ 913 | var changed = false; 914 | 915 | if (Elements.length !== this.activeElements.length){ 916 | changed = true; 917 | return changed; 918 | } 919 | 920 | each(Elements, function(element, index){ 921 | if (element !== this.activeElements[index]){ 922 | changed = true; 923 | } 924 | }, this); 925 | return changed; 926 | }).call(this, ChartElements); 927 | 928 | if (!isChanged && !forceRedraw){ 929 | return; 930 | } 931 | else{ 932 | this.activeElements = ChartElements; 933 | } 934 | this.draw(); 935 | if(this.options.customTooltips){ 936 | this.options.customTooltips(false); 937 | } 938 | if (ChartElements.length > 0){ 939 | // If we have multiple datasets, show a MultiTooltip for all of the data points at that index 940 | if (this.datasets && this.datasets.length > 1) { 941 | var dataArray, 942 | dataIndex; 943 | 944 | for (var i = this.datasets.length - 1; i >= 0; i--) { 945 | dataArray = this.datasets[i].points || this.datasets[i].bars || this.datasets[i].segments; 946 | dataIndex = indexOf(dataArray, ChartElements[0]); 947 | if (dataIndex !== -1){ 948 | break; 949 | } 950 | } 951 | var tooltipLabels = [], 952 | tooltipColors = [], 953 | medianPosition = (function(index) { 954 | 955 | // Get all the points at that particular index 956 | var Elements = [], 957 | dataCollection, 958 | xPositions = [], 959 | yPositions = [], 960 | xMax, 961 | yMax, 962 | xMin, 963 | yMin; 964 | helpers.each(this.datasets, function(dataset){ 965 | dataCollection = dataset.points || dataset.bars || dataset.segments; 966 | if (dataCollection[dataIndex] && dataCollection[dataIndex].hasValue()){ 967 | Elements.push(dataCollection[dataIndex]); 968 | } 969 | }); 970 | 971 | helpers.each(Elements, function(element) { 972 | xPositions.push(element.x); 973 | yPositions.push(element.y); 974 | 975 | 976 | //Include any colour information about the element 977 | tooltipLabels.push(helpers.template(this.options.multiTooltipTemplate, element)); 978 | tooltipColors.push({ 979 | fill: element._saved.fillColor || element.fillColor, 980 | stroke: element._saved.strokeColor || element.strokeColor 981 | }); 982 | 983 | }, this); 984 | 985 | yMin = min(yPositions); 986 | yMax = max(yPositions); 987 | 988 | xMin = min(xPositions); 989 | xMax = max(xPositions); 990 | 991 | return { 992 | x: (xMin > this.chart.width/2) ? xMin : xMax, 993 | y: (yMin + yMax)/2 994 | }; 995 | }).call(this, dataIndex); 996 | 997 | new Chart.MultiTooltip({ 998 | x: medianPosition.x, 999 | y: medianPosition.y, 1000 | xPadding: this.options.tooltipXPadding, 1001 | yPadding: this.options.tooltipYPadding, 1002 | xOffset: this.options.tooltipXOffset, 1003 | fillColor: this.options.tooltipFillColor, 1004 | textColor: this.options.tooltipFontColor, 1005 | fontFamily: this.options.tooltipFontFamily, 1006 | fontStyle: this.options.tooltipFontStyle, 1007 | fontSize: this.options.tooltipFontSize, 1008 | titleTextColor: this.options.tooltipTitleFontColor, 1009 | titleFontFamily: this.options.tooltipTitleFontFamily, 1010 | titleFontStyle: this.options.tooltipTitleFontStyle, 1011 | titleFontSize: this.options.tooltipTitleFontSize, 1012 | cornerRadius: this.options.tooltipCornerRadius, 1013 | labels: tooltipLabels, 1014 | legendColors: tooltipColors, 1015 | legendColorBackground : this.options.multiTooltipKeyBackground, 1016 | title: ChartElements[0].label, 1017 | chart: this.chart, 1018 | ctx: this.chart.ctx, 1019 | custom: this.options.customTooltips 1020 | }).draw(); 1021 | 1022 | } else { 1023 | each(ChartElements, function(Element) { 1024 | var tooltipPosition = Element.tooltipPosition(); 1025 | new Chart.Tooltip({ 1026 | x: Math.round(tooltipPosition.x), 1027 | y: Math.round(tooltipPosition.y), 1028 | xPadding: this.options.tooltipXPadding, 1029 | yPadding: this.options.tooltipYPadding, 1030 | fillColor: this.options.tooltipFillColor, 1031 | textColor: this.options.tooltipFontColor, 1032 | fontFamily: this.options.tooltipFontFamily, 1033 | fontStyle: this.options.tooltipFontStyle, 1034 | fontSize: this.options.tooltipFontSize, 1035 | caretHeight: this.options.tooltipCaretSize, 1036 | cornerRadius: this.options.tooltipCornerRadius, 1037 | text: template(this.options.tooltipTemplate, Element), 1038 | chart: this.chart, 1039 | custom: this.options.customTooltips 1040 | }).draw(); 1041 | }, this); 1042 | } 1043 | } 1044 | return this; 1045 | }, 1046 | toBase64Image : function(){ 1047 | return this.chart.canvas.toDataURL.apply(this.chart.canvas, arguments); 1048 | } 1049 | }); 1050 | 1051 | Chart.Type.extend = function(extensions){ 1052 | 1053 | var parent = this; 1054 | 1055 | var ChartType = function(){ 1056 | return parent.apply(this,arguments); 1057 | }; 1058 | 1059 | //Copy the prototype object of the this class 1060 | ChartType.prototype = clone(parent.prototype); 1061 | //Now overwrite some of the properties in the base class with the new extensions 1062 | extend(ChartType.prototype, extensions); 1063 | 1064 | ChartType.extend = Chart.Type.extend; 1065 | 1066 | if (extensions.name || parent.prototype.name){ 1067 | 1068 | var chartName = extensions.name || parent.prototype.name; 1069 | //Assign any potential default values of the new chart type 1070 | 1071 | //If none are defined, we'll use a clone of the chart type this is being extended from. 1072 | //I.e. if we extend a line chart, we'll use the defaults from the line chart if our new chart 1073 | //doesn't define some defaults of their own. 1074 | 1075 | var baseDefaults = (Chart.defaults[parent.prototype.name]) ? clone(Chart.defaults[parent.prototype.name]) : {}; 1076 | 1077 | Chart.defaults[chartName] = extend(baseDefaults,extensions.defaults); 1078 | 1079 | Chart.types[chartName] = ChartType; 1080 | 1081 | //Register this new chart type in the Chart prototype 1082 | Chart.prototype[chartName] = function(data,options){ 1083 | var config = merge(Chart.defaults.global, Chart.defaults[chartName], options || {}); 1084 | return new ChartType(data,config,this); 1085 | }; 1086 | } else{ 1087 | warn("Name not provided for this chart, so it hasn't been registered"); 1088 | } 1089 | return parent; 1090 | }; 1091 | 1092 | Chart.Element = function(configuration){ 1093 | extend(this,configuration); 1094 | this.initialize.apply(this,arguments); 1095 | this.save(); 1096 | }; 1097 | extend(Chart.Element.prototype,{ 1098 | initialize : function(){}, 1099 | restore : function(props){ 1100 | if (!props){ 1101 | extend(this,this._saved); 1102 | } else { 1103 | each(props,function(key){ 1104 | this[key] = this._saved[key]; 1105 | },this); 1106 | } 1107 | return this; 1108 | }, 1109 | save : function(){ 1110 | this._saved = clone(this); 1111 | delete this._saved._saved; 1112 | return this; 1113 | }, 1114 | update : function(newProps){ 1115 | each(newProps,function(value,key){ 1116 | this._saved[key] = this[key]; 1117 | this[key] = value; 1118 | },this); 1119 | return this; 1120 | }, 1121 | transition : function(props,ease){ 1122 | each(props,function(value,key){ 1123 | this[key] = ((value - this._saved[key]) * ease) + this._saved[key]; 1124 | },this); 1125 | return this; 1126 | }, 1127 | tooltipPosition : function(){ 1128 | return { 1129 | x : this.x, 1130 | y : this.y 1131 | }; 1132 | }, 1133 | hasValue: function(){ 1134 | return isNumber(this.value); 1135 | } 1136 | }); 1137 | 1138 | Chart.Element.extend = inherits; 1139 | 1140 | 1141 | Chart.Point = Chart.Element.extend({ 1142 | display: true, 1143 | inRange: function(chartX,chartY){ 1144 | var hitDetectionRange = this.hitDetectionRadius + this.radius; 1145 | return ((Math.pow(chartX-this.x, 2)+Math.pow(chartY-this.y, 2)) < Math.pow(hitDetectionRange,2)); 1146 | }, 1147 | draw : function(){ 1148 | if (this.display){ 1149 | var ctx = this.ctx; 1150 | ctx.beginPath(); 1151 | 1152 | ctx.arc(this.x, this.y, this.radius, 0, Math.PI*2); 1153 | ctx.closePath(); 1154 | 1155 | ctx.strokeStyle = this.strokeColor; 1156 | ctx.lineWidth = this.strokeWidth; 1157 | 1158 | ctx.fillStyle = this.fillColor; 1159 | 1160 | ctx.fill(); 1161 | ctx.stroke(); 1162 | } 1163 | 1164 | 1165 | //Quick debug for bezier curve splining 1166 | //Highlights control points and the line between them. 1167 | //Handy for dev - stripped in the min version. 1168 | 1169 | // ctx.save(); 1170 | // ctx.fillStyle = "black"; 1171 | // ctx.strokeStyle = "black" 1172 | // ctx.beginPath(); 1173 | // ctx.arc(this.controlPoints.inner.x,this.controlPoints.inner.y, 2, 0, Math.PI*2); 1174 | // ctx.fill(); 1175 | 1176 | // ctx.beginPath(); 1177 | // ctx.arc(this.controlPoints.outer.x,this.controlPoints.outer.y, 2, 0, Math.PI*2); 1178 | // ctx.fill(); 1179 | 1180 | // ctx.moveTo(this.controlPoints.inner.x,this.controlPoints.inner.y); 1181 | // ctx.lineTo(this.x, this.y); 1182 | // ctx.lineTo(this.controlPoints.outer.x,this.controlPoints.outer.y); 1183 | // ctx.stroke(); 1184 | 1185 | // ctx.restore(); 1186 | 1187 | 1188 | 1189 | } 1190 | }); 1191 | 1192 | Chart.Arc = Chart.Element.extend({ 1193 | inRange : function(chartX,chartY){ 1194 | 1195 | var pointRelativePosition = helpers.getAngleFromPoint(this, { 1196 | x: chartX, 1197 | y: chartY 1198 | }); 1199 | 1200 | //Check if within the range of the open/close angle 1201 | var betweenAngles = (pointRelativePosition.angle >= this.startAngle && pointRelativePosition.angle <= this.endAngle), 1202 | withinRadius = (pointRelativePosition.distance >= this.innerRadius && pointRelativePosition.distance <= this.outerRadius); 1203 | 1204 | return (betweenAngles && withinRadius); 1205 | //Ensure within the outside of the arc centre, but inside arc outer 1206 | }, 1207 | tooltipPosition : function(){ 1208 | var centreAngle = this.startAngle + ((this.endAngle - this.startAngle) / 2), 1209 | rangeFromCentre = (this.outerRadius - this.innerRadius) / 2 + this.innerRadius; 1210 | return { 1211 | x : this.x + (Math.cos(centreAngle) * rangeFromCentre), 1212 | y : this.y + (Math.sin(centreAngle) * rangeFromCentre) 1213 | }; 1214 | }, 1215 | draw : function(animationPercent){ 1216 | 1217 | var easingDecimal = animationPercent || 1; 1218 | 1219 | var ctx = this.ctx; 1220 | 1221 | ctx.beginPath(); 1222 | 1223 | ctx.arc(this.x, this.y, this.outerRadius, this.startAngle, this.endAngle); 1224 | 1225 | ctx.arc(this.x, this.y, this.innerRadius, this.endAngle, this.startAngle, true); 1226 | 1227 | ctx.closePath(); 1228 | ctx.strokeStyle = this.strokeColor; 1229 | ctx.lineWidth = this.strokeWidth; 1230 | 1231 | ctx.fillStyle = this.fillColor; 1232 | 1233 | ctx.fill(); 1234 | ctx.lineJoin = 'bevel'; 1235 | 1236 | if (this.showStroke){ 1237 | ctx.stroke(); 1238 | } 1239 | } 1240 | }); 1241 | 1242 | Chart.Rectangle = Chart.Element.extend({ 1243 | draw : function(){ 1244 | var ctx = this.ctx, 1245 | halfWidth = this.width/2, 1246 | leftX = this.x - halfWidth, 1247 | rightX = this.x + halfWidth, 1248 | top = this.base - (this.base - this.y), 1249 | halfStroke = this.strokeWidth / 2; 1250 | 1251 | // Canvas doesn't allow us to stroke inside the width so we can 1252 | // adjust the sizes to fit if we're setting a stroke on the line 1253 | if (this.showStroke){ 1254 | leftX += halfStroke; 1255 | rightX -= halfStroke; 1256 | top += halfStroke; 1257 | } 1258 | 1259 | ctx.beginPath(); 1260 | 1261 | ctx.fillStyle = this.fillColor; 1262 | ctx.strokeStyle = this.strokeColor; 1263 | ctx.lineWidth = this.strokeWidth; 1264 | 1265 | // It'd be nice to keep this class totally generic to any rectangle 1266 | // and simply specify which border to miss out. 1267 | ctx.moveTo(leftX, this.base); 1268 | ctx.lineTo(leftX, top); 1269 | ctx.lineTo(rightX, top); 1270 | ctx.lineTo(rightX, this.base); 1271 | ctx.fill(); 1272 | if (this.showStroke){ 1273 | ctx.stroke(); 1274 | } 1275 | }, 1276 | height : function(){ 1277 | return this.base - this.y; 1278 | }, 1279 | inRange : function(chartX,chartY){ 1280 | return (chartX >= this.x - this.width/2 && chartX <= this.x + this.width/2) && (chartY >= this.y && chartY <= this.base); 1281 | } 1282 | }); 1283 | 1284 | Chart.Tooltip = Chart.Element.extend({ 1285 | draw : function(){ 1286 | 1287 | var ctx = this.chart.ctx; 1288 | 1289 | ctx.font = fontString(this.fontSize,this.fontStyle,this.fontFamily); 1290 | 1291 | this.xAlign = "center"; 1292 | this.yAlign = "above"; 1293 | 1294 | //Distance between the actual element.y position and the start of the tooltip caret 1295 | var caretPadding = this.caretPadding = 2; 1296 | 1297 | var tooltipWidth = ctx.measureText(this.text).width + 2*this.xPadding, 1298 | tooltipRectHeight = this.fontSize + 2*this.yPadding, 1299 | tooltipHeight = tooltipRectHeight + this.caretHeight + caretPadding; 1300 | 1301 | if (this.x + tooltipWidth/2 >this.chart.width){ 1302 | this.xAlign = "left"; 1303 | } else if (this.x - tooltipWidth/2 < 0){ 1304 | this.xAlign = "right"; 1305 | } 1306 | 1307 | if (this.y - tooltipHeight < 0){ 1308 | this.yAlign = "below"; 1309 | } 1310 | 1311 | 1312 | var tooltipX = this.x - tooltipWidth/2, 1313 | tooltipY = this.y - tooltipHeight; 1314 | 1315 | ctx.fillStyle = this.fillColor; 1316 | 1317 | // Custom Tooltips 1318 | if(this.custom){ 1319 | this.custom(this); 1320 | } 1321 | else{ 1322 | switch(this.yAlign) 1323 | { 1324 | case "above": 1325 | //Draw a caret above the x/y 1326 | ctx.beginPath(); 1327 | ctx.moveTo(this.x,this.y - caretPadding); 1328 | ctx.lineTo(this.x + this.caretHeight, this.y - (caretPadding + this.caretHeight)); 1329 | ctx.lineTo(this.x - this.caretHeight, this.y - (caretPadding + this.caretHeight)); 1330 | ctx.closePath(); 1331 | ctx.fill(); 1332 | break; 1333 | case "below": 1334 | tooltipY = this.y + caretPadding + this.caretHeight; 1335 | //Draw a caret below the x/y 1336 | ctx.beginPath(); 1337 | ctx.moveTo(this.x, this.y + caretPadding); 1338 | ctx.lineTo(this.x + this.caretHeight, this.y + caretPadding + this.caretHeight); 1339 | ctx.lineTo(this.x - this.caretHeight, this.y + caretPadding + this.caretHeight); 1340 | ctx.closePath(); 1341 | ctx.fill(); 1342 | break; 1343 | } 1344 | 1345 | switch(this.xAlign) 1346 | { 1347 | case "left": 1348 | tooltipX = this.x - tooltipWidth + (this.cornerRadius + this.caretHeight); 1349 | break; 1350 | case "right": 1351 | tooltipX = this.x - (this.cornerRadius + this.caretHeight); 1352 | break; 1353 | } 1354 | 1355 | drawRoundedRectangle(ctx,tooltipX,tooltipY,tooltipWidth,tooltipRectHeight,this.cornerRadius); 1356 | 1357 | ctx.fill(); 1358 | 1359 | ctx.fillStyle = this.textColor; 1360 | ctx.textAlign = "center"; 1361 | ctx.textBaseline = "middle"; 1362 | ctx.fillText(this.text, tooltipX + tooltipWidth/2, tooltipY + tooltipRectHeight/2); 1363 | } 1364 | } 1365 | }); 1366 | 1367 | Chart.MultiTooltip = Chart.Element.extend({ 1368 | initialize : function(){ 1369 | this.font = fontString(this.fontSize,this.fontStyle,this.fontFamily); 1370 | 1371 | this.titleFont = fontString(this.titleFontSize,this.titleFontStyle,this.titleFontFamily); 1372 | 1373 | this.height = (this.labels.length * this.fontSize) + ((this.labels.length-1) * (this.fontSize/2)) + (this.yPadding*2) + this.titleFontSize *1.5; 1374 | 1375 | this.ctx.font = this.titleFont; 1376 | 1377 | var titleWidth = this.ctx.measureText(this.title).width, 1378 | //Label has a legend square as well so account for this. 1379 | labelWidth = longestText(this.ctx,this.font,this.labels) + this.fontSize + 3, 1380 | longestTextWidth = max([labelWidth,titleWidth]); 1381 | 1382 | this.width = longestTextWidth + (this.xPadding*2); 1383 | 1384 | 1385 | var halfHeight = this.height/2; 1386 | 1387 | //Check to ensure the height will fit on the canvas 1388 | //The three is to buffer form the very 1389 | if (this.y - halfHeight < 0 ){ 1390 | this.y = halfHeight; 1391 | } else if (this.y + halfHeight > this.chart.height){ 1392 | this.y = this.chart.height - halfHeight; 1393 | } 1394 | 1395 | //Decide whether to align left or right based on position on canvas 1396 | if (this.x > this.chart.width/2){ 1397 | this.x -= this.xOffset + this.width; 1398 | } else { 1399 | this.x += this.xOffset; 1400 | } 1401 | 1402 | 1403 | }, 1404 | getLineHeight : function(index){ 1405 | var baseLineHeight = this.y - (this.height/2) + this.yPadding, 1406 | afterTitleIndex = index-1; 1407 | 1408 | //If the index is zero, we're getting the title 1409 | if (index === 0){ 1410 | return baseLineHeight + this.titleFontSize/2; 1411 | } else{ 1412 | return baseLineHeight + ((this.fontSize*1.5*afterTitleIndex) + this.fontSize/2) + this.titleFontSize * 1.5; 1413 | } 1414 | 1415 | }, 1416 | draw : function(){ 1417 | // Custom Tooltips 1418 | if(this.custom){ 1419 | this.custom(this); 1420 | } 1421 | else{ 1422 | drawRoundedRectangle(this.ctx,this.x,this.y - this.height/2,this.width,this.height,this.cornerRadius); 1423 | var ctx = this.ctx; 1424 | ctx.fillStyle = this.fillColor; 1425 | ctx.fill(); 1426 | ctx.closePath(); 1427 | 1428 | ctx.textAlign = "left"; 1429 | ctx.textBaseline = "middle"; 1430 | ctx.fillStyle = this.titleTextColor; 1431 | ctx.font = this.titleFont; 1432 | 1433 | ctx.fillText(this.title,this.x + this.xPadding, this.getLineHeight(0)); 1434 | 1435 | ctx.font = this.font; 1436 | helpers.each(this.labels,function(label,index){ 1437 | ctx.fillStyle = this.textColor; 1438 | ctx.fillText(label,this.x + this.xPadding + this.fontSize + 3, this.getLineHeight(index + 1)); 1439 | 1440 | //A bit gnarly, but clearing this rectangle breaks when using explorercanvas (clears whole canvas) 1441 | //ctx.clearRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize); 1442 | //Instead we'll make a white filled block to put the legendColour palette over. 1443 | 1444 | ctx.fillStyle = this.legendColorBackground; 1445 | ctx.fillRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize); 1446 | 1447 | ctx.fillStyle = this.legendColors[index].fill; 1448 | ctx.fillRect(this.x + this.xPadding, this.getLineHeight(index + 1) - this.fontSize/2, this.fontSize, this.fontSize); 1449 | 1450 | 1451 | },this); 1452 | } 1453 | } 1454 | }); 1455 | 1456 | Chart.Scale = Chart.Element.extend({ 1457 | initialize : function(){ 1458 | this.fit(); 1459 | }, 1460 | buildYLabels : function(){ 1461 | this.yLabels = []; 1462 | 1463 | var stepDecimalPlaces = getDecimalPlaces(this.stepValue); 1464 | 1465 | for (var i=0; i<=this.steps; i++){ 1466 | this.yLabels.push(template(this.templateString,{value:(this.min + (i * this.stepValue)).toFixed(stepDecimalPlaces)})); 1467 | } 1468 | this.yLabelWidth = (this.display && this.showLabels) ? longestText(this.ctx,this.font,this.yLabels) : 0; 1469 | }, 1470 | addXLabel : function(label){ 1471 | this.xLabels.push(label); 1472 | this.valuesCount++; 1473 | this.fit(); 1474 | }, 1475 | removeXLabel : function(){ 1476 | this.xLabels.shift(); 1477 | this.valuesCount--; 1478 | this.fit(); 1479 | }, 1480 | // Fitting loop to rotate x Labels and figure out what fits there, and also calculate how many Y steps to use 1481 | fit: function(){ 1482 | // First we need the width of the yLabels, assuming the xLabels aren't rotated 1483 | 1484 | // To do that we need the base line at the top and base of the chart, assuming there is no x label rotation 1485 | this.startPoint = (this.display) ? this.fontSize : 0; 1486 | this.endPoint = (this.display) ? this.height - (this.fontSize * 1.5) - 5 : this.height; // -5 to pad labels 1487 | 1488 | // Apply padding settings to the start and end point. 1489 | this.startPoint += this.padding; 1490 | this.endPoint -= this.padding; 1491 | 1492 | // Cache the starting height, so can determine if we need to recalculate the scale yAxis 1493 | var cachedHeight = this.endPoint - this.startPoint, 1494 | cachedYLabelWidth; 1495 | 1496 | // Build the current yLabels so we have an idea of what size they'll be to start 1497 | /* 1498 | * This sets what is returned from calculateScaleRange as static properties of this class: 1499 | * 1500 | this.steps; 1501 | this.stepValue; 1502 | this.min; 1503 | this.max; 1504 | * 1505 | */ 1506 | this.calculateYRange(cachedHeight); 1507 | 1508 | // With these properties set we can now build the array of yLabels 1509 | // and also the width of the largest yLabel 1510 | this.buildYLabels(); 1511 | 1512 | this.calculateXLabelRotation(); 1513 | 1514 | while((cachedHeight > this.endPoint - this.startPoint)){ 1515 | cachedHeight = this.endPoint - this.startPoint; 1516 | cachedYLabelWidth = this.yLabelWidth; 1517 | 1518 | this.calculateYRange(cachedHeight); 1519 | this.buildYLabels(); 1520 | 1521 | // Only go through the xLabel loop again if the yLabel width has changed 1522 | if (cachedYLabelWidth < this.yLabelWidth){ 1523 | this.calculateXLabelRotation(); 1524 | } 1525 | } 1526 | 1527 | }, 1528 | calculateXLabelRotation : function(){ 1529 | //Get the width of each grid by calculating the difference 1530 | //between x offsets between 0 and 1. 1531 | 1532 | this.ctx.font = this.font; 1533 | 1534 | var firstWidth = this.ctx.measureText(this.xLabels[0]).width, 1535 | lastWidth = this.ctx.measureText(this.xLabels[this.xLabels.length - 1]).width, 1536 | firstRotated, 1537 | lastRotated; 1538 | 1539 | 1540 | this.xScalePaddingRight = lastWidth/2 + 3; 1541 | this.xScalePaddingLeft = (firstWidth/2 > this.yLabelWidth + 10) ? firstWidth/2 : this.yLabelWidth + 10; 1542 | 1543 | this.xLabelRotation = 0; 1544 | if (this.display){ 1545 | var originalLabelWidth = longestText(this.ctx,this.font,this.xLabels), 1546 | cosRotation, 1547 | firstRotatedWidth; 1548 | this.xLabelWidth = originalLabelWidth; 1549 | //Allow 3 pixels x2 padding either side for label readability 1550 | var xGridWidth = Math.floor(this.calculateX(1) - this.calculateX(0)) - 6; 1551 | 1552 | //Max label rotate should be 90 - also act as a loop counter 1553 | while ((this.xLabelWidth > xGridWidth && this.xLabelRotation === 0) || (this.xLabelWidth > xGridWidth && this.xLabelRotation <= 90 && this.xLabelRotation > 0)){ 1554 | cosRotation = Math.cos(toRadians(this.xLabelRotation)); 1555 | 1556 | firstRotated = cosRotation * firstWidth; 1557 | lastRotated = cosRotation * lastWidth; 1558 | 1559 | // We're right aligning the text now. 1560 | if (firstRotated + this.fontSize / 2 > this.yLabelWidth + 8){ 1561 | this.xScalePaddingLeft = firstRotated + this.fontSize / 2; 1562 | } 1563 | this.xScalePaddingRight = this.fontSize/2; 1564 | 1565 | 1566 | this.xLabelRotation++; 1567 | this.xLabelWidth = cosRotation * originalLabelWidth; 1568 | 1569 | } 1570 | if (this.xLabelRotation > 0){ 1571 | this.endPoint -= Math.sin(toRadians(this.xLabelRotation))*originalLabelWidth + 3; 1572 | } 1573 | } 1574 | else{ 1575 | this.xLabelWidth = 0; 1576 | this.xScalePaddingRight = this.padding; 1577 | this.xScalePaddingLeft = this.padding; 1578 | } 1579 | 1580 | }, 1581 | // Needs to be overidden in each Chart type 1582 | // Otherwise we need to pass all the data into the scale class 1583 | calculateYRange: noop, 1584 | drawingArea: function(){ 1585 | return this.startPoint - this.endPoint; 1586 | }, 1587 | calculateY : function(value){ 1588 | var scalingFactor = this.drawingArea() / (this.min - this.max); 1589 | return this.endPoint - (scalingFactor * (value - this.min)); 1590 | }, 1591 | calculateX : function(index){ 1592 | var isRotated = (this.xLabelRotation > 0), 1593 | // innerWidth = (this.offsetGridLines) ? this.width - offsetLeft - this.padding : this.width - (offsetLeft + halfLabelWidth * 2) - this.padding, 1594 | innerWidth = this.width - (this.xScalePaddingLeft + this.xScalePaddingRight), 1595 | valueWidth = innerWidth/(this.valuesCount - ((this.offsetGridLines) ? 0 : 1)), 1596 | valueOffset = (valueWidth * index) + this.xScalePaddingLeft; 1597 | 1598 | if (this.offsetGridLines){ 1599 | valueOffset += (valueWidth/2); 1600 | } 1601 | 1602 | return Math.round(valueOffset); 1603 | }, 1604 | update : function(newProps){ 1605 | helpers.extend(this, newProps); 1606 | this.fit(); 1607 | }, 1608 | draw : function(){ 1609 | var ctx = this.ctx, 1610 | yLabelGap = (this.endPoint - this.startPoint) / this.steps, 1611 | xStart = Math.round(this.xScalePaddingLeft); 1612 | if (this.display){ 1613 | ctx.fillStyle = this.textColor; 1614 | ctx.font = this.font; 1615 | each(this.yLabels,function(labelString,index){ 1616 | var yLabelCenter = this.endPoint - (yLabelGap * index), 1617 | linePositionY = Math.round(yLabelCenter), 1618 | drawHorizontalLine = this.showHorizontalLines; 1619 | 1620 | ctx.textAlign = "right"; 1621 | ctx.textBaseline = "middle"; 1622 | if (this.showLabels){ 1623 | ctx.fillText(labelString,xStart - 10,yLabelCenter); 1624 | } 1625 | 1626 | // This is X axis, so draw it 1627 | if (index === 0 && !drawHorizontalLine){ 1628 | drawHorizontalLine = true; 1629 | } 1630 | 1631 | if (drawHorizontalLine){ 1632 | ctx.beginPath(); 1633 | } 1634 | 1635 | if (index > 0){ 1636 | // This is a grid line in the centre, so drop that 1637 | ctx.lineWidth = this.gridLineWidth; 1638 | ctx.strokeStyle = this.gridLineColor; 1639 | } else { 1640 | // This is the first line on the scale 1641 | ctx.lineWidth = this.lineWidth; 1642 | ctx.strokeStyle = this.lineColor; 1643 | } 1644 | 1645 | linePositionY += helpers.aliasPixel(ctx.lineWidth); 1646 | 1647 | if(drawHorizontalLine){ 1648 | ctx.moveTo(xStart, linePositionY); 1649 | ctx.lineTo(this.width, linePositionY); 1650 | ctx.stroke(); 1651 | ctx.closePath(); 1652 | } 1653 | 1654 | ctx.lineWidth = this.lineWidth; 1655 | ctx.strokeStyle = this.lineColor; 1656 | ctx.beginPath(); 1657 | ctx.moveTo(xStart - 5, linePositionY); 1658 | ctx.lineTo(xStart, linePositionY); 1659 | ctx.stroke(); 1660 | ctx.closePath(); 1661 | 1662 | },this); 1663 | 1664 | each(this.xLabels,function(label,index){ 1665 | var xPos = this.calculateX(index) + aliasPixel(this.lineWidth), 1666 | // Check to see if line/bar here and decide where to place the line 1667 | linePos = this.calculateX(index - (this.offsetGridLines ? 0.5 : 0)) + aliasPixel(this.lineWidth), 1668 | isRotated = (this.xLabelRotation > 0), 1669 | drawVerticalLine = this.showVerticalLines; 1670 | 1671 | // This is Y axis, so draw it 1672 | if (index === 0 && !drawVerticalLine){ 1673 | drawVerticalLine = true; 1674 | } 1675 | 1676 | if (drawVerticalLine){ 1677 | ctx.beginPath(); 1678 | } 1679 | 1680 | if (index > 0){ 1681 | // This is a grid line in the centre, so drop that 1682 | ctx.lineWidth = this.gridLineWidth; 1683 | ctx.strokeStyle = this.gridLineColor; 1684 | } else { 1685 | // This is the first line on the scale 1686 | ctx.lineWidth = this.lineWidth; 1687 | ctx.strokeStyle = this.lineColor; 1688 | } 1689 | 1690 | if (drawVerticalLine){ 1691 | ctx.moveTo(linePos,this.endPoint); 1692 | ctx.lineTo(linePos,this.startPoint - 3); 1693 | ctx.stroke(); 1694 | ctx.closePath(); 1695 | } 1696 | 1697 | 1698 | ctx.lineWidth = this.lineWidth; 1699 | ctx.strokeStyle = this.lineColor; 1700 | 1701 | 1702 | // Small lines at the bottom of the base grid line 1703 | ctx.beginPath(); 1704 | ctx.moveTo(linePos,this.endPoint); 1705 | ctx.lineTo(linePos,this.endPoint + 5); 1706 | ctx.stroke(); 1707 | ctx.closePath(); 1708 | 1709 | ctx.save(); 1710 | ctx.translate(xPos,(isRotated) ? this.endPoint + 12 : this.endPoint + 8); 1711 | ctx.rotate(toRadians(this.xLabelRotation)*-1); 1712 | ctx.font = this.font; 1713 | ctx.textAlign = (isRotated) ? "right" : "center"; 1714 | ctx.textBaseline = (isRotated) ? "middle" : "top"; 1715 | ctx.fillText(label, 0, 0); 1716 | ctx.restore(); 1717 | },this); 1718 | 1719 | } 1720 | } 1721 | 1722 | }); 1723 | 1724 | Chart.RadialScale = Chart.Element.extend({ 1725 | initialize: function(){ 1726 | this.size = min([this.height, this.width]); 1727 | this.drawingArea = (this.display) ? (this.size/2) - (this.fontSize/2 + this.backdropPaddingY) : (this.size/2); 1728 | }, 1729 | calculateCenterOffset: function(value){ 1730 | // Take into account half font size + the yPadding of the top value 1731 | var scalingFactor = this.drawingArea / (this.max - this.min); 1732 | 1733 | return (value - this.min) * scalingFactor; 1734 | }, 1735 | update : function(){ 1736 | if (!this.lineArc){ 1737 | this.setScaleSize(); 1738 | } else { 1739 | this.drawingArea = (this.display) ? (this.size/2) - (this.fontSize/2 + this.backdropPaddingY) : (this.size/2); 1740 | } 1741 | this.buildYLabels(); 1742 | }, 1743 | buildYLabels: function(){ 1744 | this.yLabels = []; 1745 | 1746 | var stepDecimalPlaces = getDecimalPlaces(this.stepValue); 1747 | 1748 | for (var i=0; i<=this.steps; i++){ 1749 | this.yLabels.push(template(this.templateString,{value:(this.min + (i * this.stepValue)).toFixed(stepDecimalPlaces)})); 1750 | } 1751 | }, 1752 | getCircumference : function(){ 1753 | return ((Math.PI*2) / this.valuesCount); 1754 | }, 1755 | setScaleSize: function(){ 1756 | /* 1757 | * Right, this is really confusing and there is a lot of maths going on here 1758 | * The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9 1759 | * 1760 | * Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif 1761 | * 1762 | * Solution: 1763 | * 1764 | * We assume the radius of the polygon is half the size of the canvas at first 1765 | * at each index we check if the text overlaps. 1766 | * 1767 | * Where it does, we store that angle and that index. 1768 | * 1769 | * After finding the largest index and angle we calculate how much we need to remove 1770 | * from the shape radius to move the point inwards by that x. 1771 | * 1772 | * We average the left and right distances to get the maximum shape radius that can fit in the box 1773 | * along with labels. 1774 | * 1775 | * Once we have that, we can find the centre point for the chart, by taking the x text protrusion 1776 | * on each side, removing that from the size, halving it and adding the left x protrusion width. 1777 | * 1778 | * This will mean we have a shape fitted to the canvas, as large as it can be with the labels 1779 | * and position it in the most space efficient manner 1780 | * 1781 | * https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif 1782 | */ 1783 | 1784 | 1785 | // Get maximum radius of the polygon. Either half the height (minus the text width) or half the width. 1786 | // Use this to calculate the offset + change. - Make sure L/R protrusion is at least 0 to stop issues with centre points 1787 | var largestPossibleRadius = min([(this.height/2 - this.pointLabelFontSize - 5), this.width/2]), 1788 | pointPosition, 1789 | i, 1790 | textWidth, 1791 | halfTextWidth, 1792 | furthestRight = this.width, 1793 | furthestRightIndex, 1794 | furthestRightAngle, 1795 | furthestLeft = 0, 1796 | furthestLeftIndex, 1797 | furthestLeftAngle, 1798 | xProtrusionLeft, 1799 | xProtrusionRight, 1800 | radiusReductionRight, 1801 | radiusReductionLeft, 1802 | maxWidthRadius; 1803 | this.ctx.font = fontString(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily); 1804 | for (i=0;i furthestRight) { 1814 | furthestRight = pointPosition.x + halfTextWidth; 1815 | furthestRightIndex = i; 1816 | } 1817 | if (pointPosition.x - halfTextWidth < furthestLeft) { 1818 | furthestLeft = pointPosition.x - halfTextWidth; 1819 | furthestLeftIndex = i; 1820 | } 1821 | } 1822 | else if (i < this.valuesCount/2) { 1823 | // Less than half the values means we'll left align the text 1824 | if (pointPosition.x + textWidth > furthestRight) { 1825 | furthestRight = pointPosition.x + textWidth; 1826 | furthestRightIndex = i; 1827 | } 1828 | } 1829 | else if (i > this.valuesCount/2){ 1830 | // More than half the values means we'll right align the text 1831 | if (pointPosition.x - textWidth < furthestLeft) { 1832 | furthestLeft = pointPosition.x - textWidth; 1833 | furthestLeftIndex = i; 1834 | } 1835 | } 1836 | } 1837 | 1838 | xProtrusionLeft = furthestLeft; 1839 | 1840 | xProtrusionRight = Math.ceil(furthestRight - this.width); 1841 | 1842 | furthestRightAngle = this.getIndexAngle(furthestRightIndex); 1843 | 1844 | furthestLeftAngle = this.getIndexAngle(furthestLeftIndex); 1845 | 1846 | radiusReductionRight = xProtrusionRight / Math.sin(furthestRightAngle + Math.PI/2); 1847 | 1848 | radiusReductionLeft = xProtrusionLeft / Math.sin(furthestLeftAngle + Math.PI/2); 1849 | 1850 | // Ensure we actually need to reduce the size of the chart 1851 | radiusReductionRight = (isNumber(radiusReductionRight)) ? radiusReductionRight : 0; 1852 | radiusReductionLeft = (isNumber(radiusReductionLeft)) ? radiusReductionLeft : 0; 1853 | 1854 | this.drawingArea = largestPossibleRadius - (radiusReductionLeft + radiusReductionRight)/2; 1855 | 1856 | //this.drawingArea = min([maxWidthRadius, (this.height - (2 * (this.pointLabelFontSize + 5)))/2]) 1857 | this.setCenterPoint(radiusReductionLeft, radiusReductionRight); 1858 | 1859 | }, 1860 | setCenterPoint: function(leftMovement, rightMovement){ 1861 | 1862 | var maxRight = this.width - rightMovement - this.drawingArea, 1863 | maxLeft = leftMovement + this.drawingArea; 1864 | 1865 | this.xCenter = (maxLeft + maxRight)/2; 1866 | // Always vertically in the centre as the text height doesn't change 1867 | this.yCenter = (this.height/2); 1868 | }, 1869 | 1870 | getIndexAngle : function(index){ 1871 | var angleMultiplier = (Math.PI * 2) / this.valuesCount; 1872 | // Start from the top instead of right, so remove a quarter of the circle 1873 | 1874 | return index * angleMultiplier - (Math.PI/2); 1875 | }, 1876 | getPointPosition : function(index, distanceFromCenter){ 1877 | var thisAngle = this.getIndexAngle(index); 1878 | return { 1879 | x : (Math.cos(thisAngle) * distanceFromCenter) + this.xCenter, 1880 | y : (Math.sin(thisAngle) * distanceFromCenter) + this.yCenter 1881 | }; 1882 | }, 1883 | draw: function(){ 1884 | if (this.display){ 1885 | var ctx = this.ctx; 1886 | each(this.yLabels, function(label, index){ 1887 | // Don't draw a centre value 1888 | if (index > 0){ 1889 | var yCenterOffset = index * (this.drawingArea/this.steps), 1890 | yHeight = this.yCenter - yCenterOffset, 1891 | pointPosition; 1892 | 1893 | // Draw circular lines around the scale 1894 | if (this.lineWidth > 0){ 1895 | ctx.strokeStyle = this.lineColor; 1896 | ctx.lineWidth = this.lineWidth; 1897 | 1898 | if(this.lineArc){ 1899 | ctx.beginPath(); 1900 | ctx.arc(this.xCenter, this.yCenter, yCenterOffset, 0, Math.PI*2); 1901 | ctx.closePath(); 1902 | ctx.stroke(); 1903 | } else{ 1904 | ctx.beginPath(); 1905 | for (var i=0;i= 0; i--) { 1942 | if (this.angleLineWidth > 0){ 1943 | var outerPosition = this.getPointPosition(i, this.calculateCenterOffset(this.max)); 1944 | ctx.beginPath(); 1945 | ctx.moveTo(this.xCenter, this.yCenter); 1946 | ctx.lineTo(outerPosition.x, outerPosition.y); 1947 | ctx.stroke(); 1948 | ctx.closePath(); 1949 | } 1950 | // Extra 3px out for some label spacing 1951 | var pointLabelPosition = this.getPointPosition(i, this.calculateCenterOffset(this.max) + 5); 1952 | ctx.font = fontString(this.pointLabelFontSize,this.pointLabelFontStyle,this.pointLabelFontFamily); 1953 | ctx.fillStyle = this.pointLabelFontColor; 1954 | 1955 | var labelsCount = this.labels.length, 1956 | halfLabelsCount = this.labels.length/2, 1957 | quarterLabelsCount = halfLabelsCount/2, 1958 | upperHalf = (i < quarterLabelsCount || i > labelsCount - quarterLabelsCount), 1959 | exactQuarter = (i === quarterLabelsCount || i === labelsCount - quarterLabelsCount); 1960 | if (i === 0){ 1961 | ctx.textAlign = 'center'; 1962 | } else if(i === halfLabelsCount){ 1963 | ctx.textAlign = 'center'; 1964 | } else if (i < halfLabelsCount){ 1965 | ctx.textAlign = 'left'; 1966 | } else { 1967 | ctx.textAlign = 'right'; 1968 | } 1969 | 1970 | // Set the correct text baseline based on outer positioning 1971 | if (exactQuarter){ 1972 | ctx.textBaseline = 'middle'; 1973 | } else if (upperHalf){ 1974 | ctx.textBaseline = 'bottom'; 1975 | } else { 1976 | ctx.textBaseline = 'top'; 1977 | } 1978 | 1979 | ctx.fillText(this.labels[i], pointLabelPosition.x, pointLabelPosition.y); 1980 | } 1981 | } 1982 | } 1983 | } 1984 | }); 1985 | 1986 | // Attach global event to resize each chart instance when the browser resizes 1987 | helpers.addEvent(window, "resize", (function(){ 1988 | // Basic debounce of resize function so it doesn't hurt performance when resizing browser. 1989 | var timeout; 1990 | return function(){ 1991 | clearTimeout(timeout); 1992 | timeout = setTimeout(function(){ 1993 | each(Chart.instances,function(instance){ 1994 | // If the responsive flag is set in the chart instance config 1995 | // Cascade the resize event down to the chart. 1996 | if (instance.options.responsive){ 1997 | instance.resize(instance.render, true); 1998 | } 1999 | }); 2000 | }, 50); 2001 | }; 2002 | })()); 2003 | 2004 | 2005 | if (amd) { 2006 | define(function(){ 2007 | return Chart; 2008 | }); 2009 | } else if (typeof module === 'object' && module.exports) { 2010 | module.exports = Chart; 2011 | } 2012 | 2013 | root.Chart = Chart; 2014 | 2015 | Chart.noConflict = function(){ 2016 | root.Chart = previous; 2017 | return Chart; 2018 | }; 2019 | 2020 | }).call(this); 2021 | 2022 | (function(){ 2023 | "use strict"; 2024 | 2025 | var root = this, 2026 | Chart = root.Chart, 2027 | helpers = Chart.helpers; 2028 | 2029 | 2030 | var defaultConfig = { 2031 | //Boolean - Whether the scale should start at zero, or an order of magnitude down from the lowest value 2032 | scaleBeginAtZero : true, 2033 | 2034 | //Boolean - Whether grid lines are shown across the chart 2035 | scaleShowGridLines : true, 2036 | 2037 | //String - Colour of the grid lines 2038 | scaleGridLineColor : "rgba(0,0,0,.05)", 2039 | 2040 | //Number - Width of the grid lines 2041 | scaleGridLineWidth : 1, 2042 | 2043 | //Boolean - Whether to show horizontal lines (except X axis) 2044 | scaleShowHorizontalLines: true, 2045 | 2046 | //Boolean - Whether to show vertical lines (except Y axis) 2047 | scaleShowVerticalLines: true, 2048 | 2049 | //Boolean - If there is a stroke on each bar 2050 | barShowStroke : true, 2051 | 2052 | //Number - Pixel width of the bar stroke 2053 | barStrokeWidth : 2, 2054 | 2055 | //Number - Spacing between each of the X value sets 2056 | barValueSpacing : 5, 2057 | 2058 | //Number - Spacing between data sets within X values 2059 | barDatasetSpacing : 1, 2060 | 2061 | //String - A legend template 2062 | legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
" 2063 | 2064 | }; 2065 | 2066 | 2067 | Chart.Type.extend({ 2068 | name: "Bar", 2069 | defaults : defaultConfig, 2070 | initialize: function(data){ 2071 | 2072 | //Expose options as a scope variable here so we can access it in the ScaleClass 2073 | var options = this.options; 2074 | 2075 | this.ScaleClass = Chart.Scale.extend({ 2076 | offsetGridLines : true, 2077 | calculateBarX : function(datasetCount, datasetIndex, barIndex){ 2078 | //Reusable method for calculating the xPosition of a given bar based on datasetIndex & width of the bar 2079 | var xWidth = this.calculateBaseWidth(), 2080 | xAbsolute = this.calculateX(barIndex) - (xWidth/2), 2081 | barWidth = this.calculateBarWidth(datasetCount); 2082 | 2083 | return xAbsolute + (barWidth * datasetIndex) + (datasetIndex * options.barDatasetSpacing) + barWidth/2; 2084 | }, 2085 | calculateBaseWidth : function(){ 2086 | return (this.calculateX(1) - this.calculateX(0)) - (2*options.barValueSpacing); 2087 | }, 2088 | calculateBarWidth : function(datasetCount){ 2089 | //The padding between datasets is to the right of each bar, providing that there are more than 1 dataset 2090 | var baseWidth = this.calculateBaseWidth() - ((datasetCount - 1) * options.barDatasetSpacing); 2091 | 2092 | return (baseWidth / datasetCount); 2093 | } 2094 | }); 2095 | 2096 | this.datasets = []; 2097 | 2098 | //Set up tooltip events on the chart 2099 | if (this.options.showTooltips){ 2100 | helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ 2101 | var activeBars = (evt.type !== 'mouseout') ? this.getBarsAtEvent(evt) : []; 2102 | 2103 | this.eachBars(function(bar){ 2104 | bar.restore(['fillColor', 'strokeColor']); 2105 | }); 2106 | helpers.each(activeBars, function(activeBar){ 2107 | activeBar.fillColor = activeBar.highlightFill; 2108 | activeBar.strokeColor = activeBar.highlightStroke; 2109 | }); 2110 | this.showTooltip(activeBars); 2111 | }); 2112 | } 2113 | 2114 | //Declare the extension of the default point, to cater for the options passed in to the constructor 2115 | this.BarClass = Chart.Rectangle.extend({ 2116 | strokeWidth : this.options.barStrokeWidth, 2117 | showStroke : this.options.barShowStroke, 2118 | ctx : this.chart.ctx 2119 | }); 2120 | 2121 | //Iterate through each of the datasets, and build this into a property of the chart 2122 | helpers.each(data.datasets,function(dataset,datasetIndex){ 2123 | 2124 | var datasetObject = { 2125 | label : dataset.label || null, 2126 | fillColor : dataset.fillColor, 2127 | strokeColor : dataset.strokeColor, 2128 | bars : [] 2129 | }; 2130 | 2131 | this.datasets.push(datasetObject); 2132 | 2133 | helpers.each(dataset.data,function(dataPoint,index){ 2134 | //Add a new point for each piece of data, passing any required data to draw. 2135 | datasetObject.bars.push(new this.BarClass({ 2136 | value : dataPoint, 2137 | label : data.labels[index], 2138 | datasetLabel: dataset.label, 2139 | strokeColor : dataset.strokeColor, 2140 | fillColor : dataset.fillColor, 2141 | highlightFill : dataset.highlightFill || dataset.fillColor, 2142 | highlightStroke : dataset.highlightStroke || dataset.strokeColor 2143 | })); 2144 | },this); 2145 | 2146 | },this); 2147 | 2148 | this.buildScale(data.labels); 2149 | 2150 | this.BarClass.prototype.base = this.scale.endPoint; 2151 | 2152 | this.eachBars(function(bar, index, datasetIndex){ 2153 | helpers.extend(bar, { 2154 | width : this.scale.calculateBarWidth(this.datasets.length), 2155 | x: this.scale.calculateBarX(this.datasets.length, datasetIndex, index), 2156 | y: this.scale.endPoint 2157 | }); 2158 | bar.save(); 2159 | }, this); 2160 | 2161 | this.render(); 2162 | }, 2163 | update : function(){ 2164 | this.scale.update(); 2165 | // Reset any highlight colours before updating. 2166 | helpers.each(this.activeElements, function(activeElement){ 2167 | activeElement.restore(['fillColor', 'strokeColor']); 2168 | }); 2169 | 2170 | this.eachBars(function(bar){ 2171 | bar.save(); 2172 | }); 2173 | this.render(); 2174 | }, 2175 | eachBars : function(callback){ 2176 | helpers.each(this.datasets,function(dataset, datasetIndex){ 2177 | helpers.each(dataset.bars, callback, this, datasetIndex); 2178 | },this); 2179 | }, 2180 | getBarsAtEvent : function(e){ 2181 | var barsArray = [], 2182 | eventPosition = helpers.getRelativePosition(e), 2183 | datasetIterator = function(dataset){ 2184 | barsArray.push(dataset.bars[barIndex]); 2185 | }, 2186 | barIndex; 2187 | 2188 | for (var datasetIndex = 0; datasetIndex < this.datasets.length; datasetIndex++) { 2189 | for (barIndex = 0; barIndex < this.datasets[datasetIndex].bars.length; barIndex++) { 2190 | if (this.datasets[datasetIndex].bars[barIndex].inRange(eventPosition.x,eventPosition.y)){ 2191 | helpers.each(this.datasets, datasetIterator); 2192 | return barsArray; 2193 | } 2194 | } 2195 | } 2196 | 2197 | return barsArray; 2198 | }, 2199 | buildScale : function(labels){ 2200 | var self = this; 2201 | 2202 | var dataTotal = function(){ 2203 | var values = []; 2204 | self.eachBars(function(bar){ 2205 | values.push(bar.value); 2206 | }); 2207 | return values; 2208 | }; 2209 | 2210 | var scaleOptions = { 2211 | templateString : this.options.scaleLabel, 2212 | height : this.chart.height, 2213 | width : this.chart.width, 2214 | ctx : this.chart.ctx, 2215 | textColor : this.options.scaleFontColor, 2216 | fontSize : this.options.scaleFontSize, 2217 | fontStyle : this.options.scaleFontStyle, 2218 | fontFamily : this.options.scaleFontFamily, 2219 | valuesCount : labels.length, 2220 | beginAtZero : this.options.scaleBeginAtZero, 2221 | integersOnly : this.options.scaleIntegersOnly, 2222 | calculateYRange: function(currentHeight){ 2223 | var updatedRanges = helpers.calculateScaleRange( 2224 | dataTotal(), 2225 | currentHeight, 2226 | this.fontSize, 2227 | this.beginAtZero, 2228 | this.integersOnly 2229 | ); 2230 | helpers.extend(this, updatedRanges); 2231 | }, 2232 | xLabels : labels, 2233 | font : helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, this.options.scaleFontFamily), 2234 | lineWidth : this.options.scaleLineWidth, 2235 | lineColor : this.options.scaleLineColor, 2236 | showHorizontalLines : this.options.scaleShowHorizontalLines, 2237 | showVerticalLines : this.options.scaleShowVerticalLines, 2238 | gridLineWidth : (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0, 2239 | gridLineColor : (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)", 2240 | padding : (this.options.showScale) ? 0 : (this.options.barShowStroke) ? this.options.barStrokeWidth : 0, 2241 | showLabels : this.options.scaleShowLabels, 2242 | display : this.options.showScale 2243 | }; 2244 | 2245 | if (this.options.scaleOverride){ 2246 | helpers.extend(scaleOptions, { 2247 | calculateYRange: helpers.noop, 2248 | steps: this.options.scaleSteps, 2249 | stepValue: this.options.scaleStepWidth, 2250 | min: this.options.scaleStartValue, 2251 | max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) 2252 | }); 2253 | } 2254 | 2255 | this.scale = new this.ScaleClass(scaleOptions); 2256 | }, 2257 | addData : function(valuesArray,label){ 2258 | //Map the values array for each of the datasets 2259 | helpers.each(valuesArray,function(value,datasetIndex){ 2260 | //Add a new point for each piece of data, passing any required data to draw. 2261 | this.datasets[datasetIndex].bars.push(new this.BarClass({ 2262 | value : value, 2263 | label : label, 2264 | x: this.scale.calculateBarX(this.datasets.length, datasetIndex, this.scale.valuesCount+1), 2265 | y: this.scale.endPoint, 2266 | width : this.scale.calculateBarWidth(this.datasets.length), 2267 | base : this.scale.endPoint, 2268 | strokeColor : this.datasets[datasetIndex].strokeColor, 2269 | fillColor : this.datasets[datasetIndex].fillColor 2270 | })); 2271 | },this); 2272 | 2273 | this.scale.addXLabel(label); 2274 | //Then re-render the chart. 2275 | this.update(); 2276 | }, 2277 | removeData : function(){ 2278 | this.scale.removeXLabel(); 2279 | //Then re-render the chart. 2280 | helpers.each(this.datasets,function(dataset){ 2281 | dataset.bars.shift(); 2282 | },this); 2283 | this.update(); 2284 | }, 2285 | reflow : function(){ 2286 | helpers.extend(this.BarClass.prototype,{ 2287 | y: this.scale.endPoint, 2288 | base : this.scale.endPoint 2289 | }); 2290 | var newScaleProps = helpers.extend({ 2291 | height : this.chart.height, 2292 | width : this.chart.width 2293 | }); 2294 | this.scale.update(newScaleProps); 2295 | }, 2296 | draw : function(ease){ 2297 | var easingDecimal = ease || 1; 2298 | this.clear(); 2299 | 2300 | var ctx = this.chart.ctx; 2301 | 2302 | this.scale.draw(easingDecimal); 2303 | 2304 | //Draw all the bars for each dataset 2305 | helpers.each(this.datasets,function(dataset,datasetIndex){ 2306 | helpers.each(dataset.bars,function(bar,index){ 2307 | if (bar.hasValue()){ 2308 | bar.base = this.scale.endPoint; 2309 | //Transition then draw 2310 | bar.transition({ 2311 | x : this.scale.calculateBarX(this.datasets.length, datasetIndex, index), 2312 | y : this.scale.calculateY(bar.value), 2313 | width : this.scale.calculateBarWidth(this.datasets.length) 2314 | }, easingDecimal).draw(); 2315 | } 2316 | },this); 2317 | 2318 | },this); 2319 | } 2320 | }); 2321 | 2322 | 2323 | }).call(this); 2324 | 2325 | (function(){ 2326 | "use strict"; 2327 | 2328 | var root = this, 2329 | Chart = root.Chart, 2330 | //Cache a local reference to Chart.helpers 2331 | helpers = Chart.helpers; 2332 | 2333 | var defaultConfig = { 2334 | //Boolean - Whether we should show a stroke on each segment 2335 | segmentShowStroke : true, 2336 | 2337 | //String - The colour of each segment stroke 2338 | segmentStrokeColor : "#fff", 2339 | 2340 | //Number - The width of each segment stroke 2341 | segmentStrokeWidth : 2, 2342 | 2343 | //The percentage of the chart that we cut out of the middle. 2344 | percentageInnerCutout : 50, 2345 | 2346 | //Number - Amount of animation steps 2347 | animationSteps : 100, 2348 | 2349 | //String - Animation easing effect 2350 | animationEasing : "easeOutBounce", 2351 | 2352 | //Boolean - Whether we animate the rotation of the Doughnut 2353 | animateRotate : true, 2354 | 2355 | //Boolean - Whether we animate scaling the Doughnut from the centre 2356 | animateScale : false, 2357 | 2358 | //String - A legend template 2359 | legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(segments[i].label){%><%=segments[i].label%><%}%>
  • <%}%>
" 2360 | 2361 | }; 2362 | 2363 | 2364 | Chart.Type.extend({ 2365 | //Passing in a name registers this chart in the Chart namespace 2366 | name: "Doughnut", 2367 | //Providing a defaults will also register the deafults in the chart namespace 2368 | defaults : defaultConfig, 2369 | //Initialize is fired when the chart is initialized - Data is passed in as a parameter 2370 | //Config is automatically merged by the core of Chart.js, and is available at this.options 2371 | initialize: function(data){ 2372 | 2373 | //Declare segments as a static property to prevent inheriting across the Chart type prototype 2374 | this.segments = []; 2375 | this.outerRadius = (helpers.min([this.chart.width,this.chart.height]) - this.options.segmentStrokeWidth/2)/2; 2376 | 2377 | this.SegmentArc = Chart.Arc.extend({ 2378 | ctx : this.chart.ctx, 2379 | x : this.chart.width/2, 2380 | y : this.chart.height/2 2381 | }); 2382 | 2383 | //Set up tooltip events on the chart 2384 | if (this.options.showTooltips){ 2385 | helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ 2386 | var activeSegments = (evt.type !== 'mouseout') ? this.getSegmentsAtEvent(evt) : []; 2387 | 2388 | helpers.each(this.segments,function(segment){ 2389 | segment.restore(["fillColor"]); 2390 | }); 2391 | helpers.each(activeSegments,function(activeSegment){ 2392 | activeSegment.fillColor = activeSegment.highlightColor; 2393 | }); 2394 | this.showTooltip(activeSegments); 2395 | }); 2396 | } 2397 | this.calculateTotal(data); 2398 | 2399 | helpers.each(data,function(datapoint, index){ 2400 | this.addData(datapoint, index, true); 2401 | },this); 2402 | 2403 | this.render(); 2404 | }, 2405 | getSegmentsAtEvent : function(e){ 2406 | var segmentsArray = []; 2407 | 2408 | var location = helpers.getRelativePosition(e); 2409 | 2410 | helpers.each(this.segments,function(segment){ 2411 | if (segment.inRange(location.x,location.y)) segmentsArray.push(segment); 2412 | },this); 2413 | return segmentsArray; 2414 | }, 2415 | addData : function(segment, atIndex, silent){ 2416 | var index = atIndex || this.segments.length; 2417 | this.segments.splice(index, 0, new this.SegmentArc({ 2418 | value : segment.value, 2419 | outerRadius : (this.options.animateScale) ? 0 : this.outerRadius, 2420 | innerRadius : (this.options.animateScale) ? 0 : (this.outerRadius/100) * this.options.percentageInnerCutout, 2421 | fillColor : segment.color, 2422 | highlightColor : segment.highlight || segment.color, 2423 | showStroke : this.options.segmentShowStroke, 2424 | strokeWidth : this.options.segmentStrokeWidth, 2425 | strokeColor : this.options.segmentStrokeColor, 2426 | startAngle : Math.PI * 1.5, 2427 | circumference : (this.options.animateRotate) ? 0 : this.calculateCircumference(segment.value), 2428 | label : segment.label 2429 | })); 2430 | if (!silent){ 2431 | this.reflow(); 2432 | this.update(); 2433 | } 2434 | }, 2435 | calculateCircumference : function(value){ 2436 | return (Math.PI*2)*(value / this.total); 2437 | }, 2438 | calculateTotal : function(data){ 2439 | this.total = 0; 2440 | helpers.each(data,function(segment){ 2441 | this.total += segment.value; 2442 | },this); 2443 | }, 2444 | update : function(){ 2445 | this.calculateTotal(this.segments); 2446 | 2447 | // Reset any highlight colours before updating. 2448 | helpers.each(this.activeElements, function(activeElement){ 2449 | activeElement.restore(['fillColor']); 2450 | }); 2451 | 2452 | helpers.each(this.segments,function(segment){ 2453 | segment.save(); 2454 | }); 2455 | this.render(); 2456 | }, 2457 | 2458 | removeData: function(atIndex){ 2459 | var indexToDelete = (helpers.isNumber(atIndex)) ? atIndex : this.segments.length-1; 2460 | this.segments.splice(indexToDelete, 1); 2461 | this.reflow(); 2462 | this.update(); 2463 | }, 2464 | 2465 | reflow : function(){ 2466 | helpers.extend(this.SegmentArc.prototype,{ 2467 | x : this.chart.width/2, 2468 | y : this.chart.height/2 2469 | }); 2470 | this.outerRadius = (helpers.min([this.chart.width,this.chart.height]) - this.options.segmentStrokeWidth/2)/2; 2471 | helpers.each(this.segments, function(segment){ 2472 | segment.update({ 2473 | outerRadius : this.outerRadius, 2474 | innerRadius : (this.outerRadius/100) * this.options.percentageInnerCutout 2475 | }); 2476 | }, this); 2477 | }, 2478 | draw : function(easeDecimal){ 2479 | var animDecimal = (easeDecimal) ? easeDecimal : 1; 2480 | this.clear(); 2481 | helpers.each(this.segments,function(segment,index){ 2482 | segment.transition({ 2483 | circumference : this.calculateCircumference(segment.value), 2484 | outerRadius : this.outerRadius, 2485 | innerRadius : (this.outerRadius/100) * this.options.percentageInnerCutout 2486 | },animDecimal); 2487 | 2488 | segment.endAngle = segment.startAngle + segment.circumference; 2489 | 2490 | segment.draw(); 2491 | if (index === 0){ 2492 | segment.startAngle = Math.PI * 1.5; 2493 | } 2494 | //Check to see if it's the last segment, if not get the next and update the start angle 2495 | if (index < this.segments.length-1){ 2496 | this.segments[index+1].startAngle = segment.endAngle; 2497 | } 2498 | },this); 2499 | 2500 | } 2501 | }); 2502 | 2503 | Chart.types.Doughnut.extend({ 2504 | name : "Pie", 2505 | defaults : helpers.merge(defaultConfig,{percentageInnerCutout : 0}) 2506 | }); 2507 | 2508 | }).call(this); 2509 | (function(){ 2510 | "use strict"; 2511 | 2512 | var root = this, 2513 | Chart = root.Chart, 2514 | helpers = Chart.helpers; 2515 | 2516 | var defaultConfig = { 2517 | 2518 | ///Boolean - Whether grid lines are shown across the chart 2519 | scaleShowGridLines : true, 2520 | 2521 | //String - Colour of the grid lines 2522 | scaleGridLineColor : "rgba(0,0,0,.05)", 2523 | 2524 | //Number - Width of the grid lines 2525 | scaleGridLineWidth : 1, 2526 | 2527 | //Boolean - Whether to show horizontal lines (except X axis) 2528 | scaleShowHorizontalLines: true, 2529 | 2530 | //Boolean - Whether to show vertical lines (except Y axis) 2531 | scaleShowVerticalLines: true, 2532 | 2533 | //Boolean - Whether the line is curved between points 2534 | bezierCurve : true, 2535 | 2536 | //Number - Tension of the bezier curve between points 2537 | bezierCurveTension : 0.4, 2538 | 2539 | //Boolean - Whether to show a dot for each point 2540 | pointDot : true, 2541 | 2542 | //Number - Radius of each point dot in pixels 2543 | pointDotRadius : 4, 2544 | 2545 | //Number - Pixel width of point dot stroke 2546 | pointDotStrokeWidth : 1, 2547 | 2548 | //Number - amount extra to add to the radius to cater for hit detection outside the drawn point 2549 | pointHitDetectionRadius : 20, 2550 | 2551 | //Boolean - Whether to show a stroke for datasets 2552 | datasetStroke : true, 2553 | 2554 | //Number - Pixel width of dataset stroke 2555 | datasetStrokeWidth : 2, 2556 | 2557 | //Boolean - Whether to fill the dataset with a colour 2558 | datasetFill : true, 2559 | 2560 | //String - A legend template 2561 | legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
" 2562 | 2563 | }; 2564 | 2565 | 2566 | Chart.Type.extend({ 2567 | name: "Line", 2568 | defaults : defaultConfig, 2569 | initialize: function(data){ 2570 | //Declare the extension of the default point, to cater for the options passed in to the constructor 2571 | this.PointClass = Chart.Point.extend({ 2572 | strokeWidth : this.options.pointDotStrokeWidth, 2573 | radius : this.options.pointDotRadius, 2574 | display: this.options.pointDot, 2575 | hitDetectionRadius : this.options.pointHitDetectionRadius, 2576 | ctx : this.chart.ctx, 2577 | inRange : function(mouseX){ 2578 | return (Math.pow(mouseX-this.x, 2) < Math.pow(this.radius + this.hitDetectionRadius,2)); 2579 | } 2580 | }); 2581 | 2582 | this.datasets = []; 2583 | 2584 | //Set up tooltip events on the chart 2585 | if (this.options.showTooltips){ 2586 | helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ 2587 | var activePoints = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : []; 2588 | this.eachPoints(function(point){ 2589 | point.restore(['fillColor', 'strokeColor']); 2590 | }); 2591 | helpers.each(activePoints, function(activePoint){ 2592 | activePoint.fillColor = activePoint.highlightFill; 2593 | activePoint.strokeColor = activePoint.highlightStroke; 2594 | }); 2595 | this.showTooltip(activePoints); 2596 | }); 2597 | } 2598 | 2599 | //Iterate through each of the datasets, and build this into a property of the chart 2600 | helpers.each(data.datasets,function(dataset){ 2601 | 2602 | var datasetObject = { 2603 | label : dataset.label || null, 2604 | fillColor : dataset.fillColor, 2605 | strokeColor : dataset.strokeColor, 2606 | pointColor : dataset.pointColor, 2607 | pointStrokeColor : dataset.pointStrokeColor, 2608 | points : [] 2609 | }; 2610 | 2611 | this.datasets.push(datasetObject); 2612 | 2613 | 2614 | helpers.each(dataset.data,function(dataPoint,index){ 2615 | //Add a new point for each piece of data, passing any required data to draw. 2616 | datasetObject.points.push(new this.PointClass({ 2617 | value : dataPoint, 2618 | label : data.labels[index], 2619 | datasetLabel: dataset.label, 2620 | strokeColor : dataset.pointStrokeColor, 2621 | fillColor : dataset.pointColor, 2622 | highlightFill : dataset.pointHighlightFill || dataset.pointColor, 2623 | highlightStroke : dataset.pointHighlightStroke || dataset.pointStrokeColor 2624 | })); 2625 | },this); 2626 | 2627 | this.buildScale(data.labels); 2628 | 2629 | 2630 | this.eachPoints(function(point, index){ 2631 | helpers.extend(point, { 2632 | x: this.scale.calculateX(index), 2633 | y: this.scale.endPoint 2634 | }); 2635 | point.save(); 2636 | }, this); 2637 | 2638 | },this); 2639 | 2640 | 2641 | this.render(); 2642 | }, 2643 | update : function(){ 2644 | this.scale.update(); 2645 | // Reset any highlight colours before updating. 2646 | helpers.each(this.activeElements, function(activeElement){ 2647 | activeElement.restore(['fillColor', 'strokeColor']); 2648 | }); 2649 | this.eachPoints(function(point){ 2650 | point.save(); 2651 | }); 2652 | this.render(); 2653 | }, 2654 | eachPoints : function(callback){ 2655 | helpers.each(this.datasets,function(dataset){ 2656 | helpers.each(dataset.points,callback,this); 2657 | },this); 2658 | }, 2659 | getPointsAtEvent : function(e){ 2660 | var pointsArray = [], 2661 | eventPosition = helpers.getRelativePosition(e); 2662 | helpers.each(this.datasets,function(dataset){ 2663 | helpers.each(dataset.points,function(point){ 2664 | if (point.inRange(eventPosition.x,eventPosition.y)) pointsArray.push(point); 2665 | }); 2666 | },this); 2667 | return pointsArray; 2668 | }, 2669 | buildScale : function(labels){ 2670 | var self = this; 2671 | 2672 | var dataTotal = function(){ 2673 | var values = []; 2674 | self.eachPoints(function(point){ 2675 | values.push(point.value); 2676 | }); 2677 | 2678 | return values; 2679 | }; 2680 | 2681 | var scaleOptions = { 2682 | templateString : this.options.scaleLabel, 2683 | height : this.chart.height, 2684 | width : this.chart.width, 2685 | ctx : this.chart.ctx, 2686 | textColor : this.options.scaleFontColor, 2687 | fontSize : this.options.scaleFontSize, 2688 | fontStyle : this.options.scaleFontStyle, 2689 | fontFamily : this.options.scaleFontFamily, 2690 | valuesCount : labels.length, 2691 | beginAtZero : this.options.scaleBeginAtZero, 2692 | integersOnly : this.options.scaleIntegersOnly, 2693 | calculateYRange : function(currentHeight){ 2694 | var updatedRanges = helpers.calculateScaleRange( 2695 | dataTotal(), 2696 | currentHeight, 2697 | this.fontSize, 2698 | this.beginAtZero, 2699 | this.integersOnly 2700 | ); 2701 | helpers.extend(this, updatedRanges); 2702 | }, 2703 | xLabels : labels, 2704 | font : helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, this.options.scaleFontFamily), 2705 | lineWidth : this.options.scaleLineWidth, 2706 | lineColor : this.options.scaleLineColor, 2707 | showHorizontalLines : this.options.scaleShowHorizontalLines, 2708 | showVerticalLines : this.options.scaleShowVerticalLines, 2709 | gridLineWidth : (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0, 2710 | gridLineColor : (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)", 2711 | padding: (this.options.showScale) ? 0 : this.options.pointDotRadius + this.options.pointDotStrokeWidth, 2712 | showLabels : this.options.scaleShowLabels, 2713 | display : this.options.showScale 2714 | }; 2715 | 2716 | if (this.options.scaleOverride){ 2717 | helpers.extend(scaleOptions, { 2718 | calculateYRange: helpers.noop, 2719 | steps: this.options.scaleSteps, 2720 | stepValue: this.options.scaleStepWidth, 2721 | min: this.options.scaleStartValue, 2722 | max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) 2723 | }); 2724 | } 2725 | 2726 | 2727 | this.scale = new Chart.Scale(scaleOptions); 2728 | }, 2729 | addData : function(valuesArray,label){ 2730 | //Map the values array for each of the datasets 2731 | 2732 | helpers.each(valuesArray,function(value,datasetIndex){ 2733 | //Add a new point for each piece of data, passing any required data to draw. 2734 | this.datasets[datasetIndex].points.push(new this.PointClass({ 2735 | value : value, 2736 | label : label, 2737 | x: this.scale.calculateX(this.scale.valuesCount+1), 2738 | y: this.scale.endPoint, 2739 | strokeColor : this.datasets[datasetIndex].pointStrokeColor, 2740 | fillColor : this.datasets[datasetIndex].pointColor 2741 | })); 2742 | },this); 2743 | 2744 | this.scale.addXLabel(label); 2745 | //Then re-render the chart. 2746 | this.update(); 2747 | }, 2748 | removeData : function(){ 2749 | this.scale.removeXLabel(); 2750 | //Then re-render the chart. 2751 | helpers.each(this.datasets,function(dataset){ 2752 | dataset.points.shift(); 2753 | },this); 2754 | this.update(); 2755 | }, 2756 | reflow : function(){ 2757 | var newScaleProps = helpers.extend({ 2758 | height : this.chart.height, 2759 | width : this.chart.width 2760 | }); 2761 | this.scale.update(newScaleProps); 2762 | }, 2763 | draw : function(ease){ 2764 | var easingDecimal = ease || 1; 2765 | this.clear(); 2766 | 2767 | var ctx = this.chart.ctx; 2768 | 2769 | // Some helper methods for getting the next/prev points 2770 | var hasValue = function(item){ 2771 | return item.value !== null; 2772 | }, 2773 | nextPoint = function(point, collection, index){ 2774 | return helpers.findNextWhere(collection, hasValue, index) || point; 2775 | }, 2776 | previousPoint = function(point, collection, index){ 2777 | return helpers.findPreviousWhere(collection, hasValue, index) || point; 2778 | }; 2779 | 2780 | this.scale.draw(easingDecimal); 2781 | 2782 | 2783 | helpers.each(this.datasets,function(dataset){ 2784 | var pointsWithValues = helpers.where(dataset.points, hasValue); 2785 | 2786 | //Transition each point first so that the line and point drawing isn't out of sync 2787 | //We can use this extra loop to calculate the control points of this dataset also in this loop 2788 | 2789 | helpers.each(dataset.points, function(point, index){ 2790 | if (point.hasValue()){ 2791 | point.transition({ 2792 | y : this.scale.calculateY(point.value), 2793 | x : this.scale.calculateX(index) 2794 | }, easingDecimal); 2795 | } 2796 | },this); 2797 | 2798 | 2799 | // Control points need to be calculated in a seperate loop, because we need to know the current x/y of the point 2800 | // This would cause issues when there is no animation, because the y of the next point would be 0, so beziers would be skewed 2801 | if (this.options.bezierCurve){ 2802 | helpers.each(pointsWithValues, function(point, index){ 2803 | var tension = (index > 0 && index < pointsWithValues.length - 1) ? this.options.bezierCurveTension : 0; 2804 | point.controlPoints = helpers.splineCurve( 2805 | previousPoint(point, pointsWithValues, index), 2806 | point, 2807 | nextPoint(point, pointsWithValues, index), 2808 | tension 2809 | ); 2810 | 2811 | // Prevent the bezier going outside of the bounds of the graph 2812 | 2813 | // Cap puter bezier handles to the upper/lower scale bounds 2814 | if (point.controlPoints.outer.y > this.scale.endPoint){ 2815 | point.controlPoints.outer.y = this.scale.endPoint; 2816 | } 2817 | else if (point.controlPoints.outer.y < this.scale.startPoint){ 2818 | point.controlPoints.outer.y = this.scale.startPoint; 2819 | } 2820 | 2821 | // Cap inner bezier handles to the upper/lower scale bounds 2822 | if (point.controlPoints.inner.y > this.scale.endPoint){ 2823 | point.controlPoints.inner.y = this.scale.endPoint; 2824 | } 2825 | else if (point.controlPoints.inner.y < this.scale.startPoint){ 2826 | point.controlPoints.inner.y = this.scale.startPoint; 2827 | } 2828 | },this); 2829 | } 2830 | 2831 | 2832 | //Draw the line between all the points 2833 | ctx.lineWidth = this.options.datasetStrokeWidth; 2834 | ctx.strokeStyle = dataset.strokeColor; 2835 | ctx.beginPath(); 2836 | 2837 | helpers.each(pointsWithValues, function(point, index){ 2838 | if (index === 0){ 2839 | ctx.moveTo(point.x, point.y); 2840 | } 2841 | else{ 2842 | if(this.options.bezierCurve){ 2843 | var previous = previousPoint(point, pointsWithValues, index); 2844 | 2845 | ctx.bezierCurveTo( 2846 | previous.controlPoints.outer.x, 2847 | previous.controlPoints.outer.y, 2848 | point.controlPoints.inner.x, 2849 | point.controlPoints.inner.y, 2850 | point.x, 2851 | point.y 2852 | ); 2853 | } 2854 | else{ 2855 | ctx.lineTo(point.x,point.y); 2856 | } 2857 | } 2858 | }, this); 2859 | 2860 | ctx.stroke(); 2861 | 2862 | if (this.options.datasetFill && pointsWithValues.length > 0){ 2863 | //Round off the line by going to the base of the chart, back to the start, then fill. 2864 | ctx.lineTo(pointsWithValues[pointsWithValues.length - 1].x, this.scale.endPoint); 2865 | ctx.lineTo(pointsWithValues[0].x, this.scale.endPoint); 2866 | ctx.fillStyle = dataset.fillColor; 2867 | ctx.closePath(); 2868 | ctx.fill(); 2869 | } 2870 | 2871 | //Now draw the points over the line 2872 | //A little inefficient double looping, but better than the line 2873 | //lagging behind the point positions 2874 | helpers.each(pointsWithValues,function(point){ 2875 | point.draw(); 2876 | }); 2877 | },this); 2878 | } 2879 | }); 2880 | 2881 | 2882 | }).call(this); 2883 | 2884 | (function(){ 2885 | "use strict"; 2886 | 2887 | var root = this, 2888 | Chart = root.Chart, 2889 | //Cache a local reference to Chart.helpers 2890 | helpers = Chart.helpers; 2891 | 2892 | var defaultConfig = { 2893 | //Boolean - Show a backdrop to the scale label 2894 | scaleShowLabelBackdrop : true, 2895 | 2896 | //String - The colour of the label backdrop 2897 | scaleBackdropColor : "rgba(255,255,255,0.75)", 2898 | 2899 | // Boolean - Whether the scale should begin at zero 2900 | scaleBeginAtZero : true, 2901 | 2902 | //Number - The backdrop padding above & below the label in pixels 2903 | scaleBackdropPaddingY : 2, 2904 | 2905 | //Number - The backdrop padding to the side of the label in pixels 2906 | scaleBackdropPaddingX : 2, 2907 | 2908 | //Boolean - Show line for each value in the scale 2909 | scaleShowLine : true, 2910 | 2911 | //Boolean - Stroke a line around each segment in the chart 2912 | segmentShowStroke : true, 2913 | 2914 | //String - The colour of the stroke on each segement. 2915 | segmentStrokeColor : "#fff", 2916 | 2917 | //Number - The width of the stroke value in pixels 2918 | segmentStrokeWidth : 2, 2919 | 2920 | //Number - Amount of animation steps 2921 | animationSteps : 100, 2922 | 2923 | //String - Animation easing effect. 2924 | animationEasing : "easeOutBounce", 2925 | 2926 | //Boolean - Whether to animate the rotation of the chart 2927 | animateRotate : true, 2928 | 2929 | //Boolean - Whether to animate scaling the chart from the centre 2930 | animateScale : false, 2931 | 2932 | //String - A legend template 2933 | legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(segments[i].label){%><%=segments[i].label%><%}%>
  • <%}%>
" 2934 | }; 2935 | 2936 | 2937 | Chart.Type.extend({ 2938 | //Passing in a name registers this chart in the Chart namespace 2939 | name: "PolarArea", 2940 | //Providing a defaults will also register the deafults in the chart namespace 2941 | defaults : defaultConfig, 2942 | //Initialize is fired when the chart is initialized - Data is passed in as a parameter 2943 | //Config is automatically merged by the core of Chart.js, and is available at this.options 2944 | initialize: function(data){ 2945 | this.segments = []; 2946 | //Declare segment class as a chart instance specific class, so it can share props for this instance 2947 | this.SegmentArc = Chart.Arc.extend({ 2948 | showStroke : this.options.segmentShowStroke, 2949 | strokeWidth : this.options.segmentStrokeWidth, 2950 | strokeColor : this.options.segmentStrokeColor, 2951 | ctx : this.chart.ctx, 2952 | innerRadius : 0, 2953 | x : this.chart.width/2, 2954 | y : this.chart.height/2 2955 | }); 2956 | this.scale = new Chart.RadialScale({ 2957 | display: this.options.showScale, 2958 | fontStyle: this.options.scaleFontStyle, 2959 | fontSize: this.options.scaleFontSize, 2960 | fontFamily: this.options.scaleFontFamily, 2961 | fontColor: this.options.scaleFontColor, 2962 | showLabels: this.options.scaleShowLabels, 2963 | showLabelBackdrop: this.options.scaleShowLabelBackdrop, 2964 | backdropColor: this.options.scaleBackdropColor, 2965 | backdropPaddingY : this.options.scaleBackdropPaddingY, 2966 | backdropPaddingX: this.options.scaleBackdropPaddingX, 2967 | lineWidth: (this.options.scaleShowLine) ? this.options.scaleLineWidth : 0, 2968 | lineColor: this.options.scaleLineColor, 2969 | lineArc: true, 2970 | width: this.chart.width, 2971 | height: this.chart.height, 2972 | xCenter: this.chart.width/2, 2973 | yCenter: this.chart.height/2, 2974 | ctx : this.chart.ctx, 2975 | templateString: this.options.scaleLabel, 2976 | valuesCount: data.length 2977 | }); 2978 | 2979 | this.updateScaleRange(data); 2980 | 2981 | this.scale.update(); 2982 | 2983 | helpers.each(data,function(segment,index){ 2984 | this.addData(segment,index,true); 2985 | },this); 2986 | 2987 | //Set up tooltip events on the chart 2988 | if (this.options.showTooltips){ 2989 | helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ 2990 | var activeSegments = (evt.type !== 'mouseout') ? this.getSegmentsAtEvent(evt) : []; 2991 | helpers.each(this.segments,function(segment){ 2992 | segment.restore(["fillColor"]); 2993 | }); 2994 | helpers.each(activeSegments,function(activeSegment){ 2995 | activeSegment.fillColor = activeSegment.highlightColor; 2996 | }); 2997 | this.showTooltip(activeSegments); 2998 | }); 2999 | } 3000 | 3001 | this.render(); 3002 | }, 3003 | getSegmentsAtEvent : function(e){ 3004 | var segmentsArray = []; 3005 | 3006 | var location = helpers.getRelativePosition(e); 3007 | 3008 | helpers.each(this.segments,function(segment){ 3009 | if (segment.inRange(location.x,location.y)) segmentsArray.push(segment); 3010 | },this); 3011 | return segmentsArray; 3012 | }, 3013 | addData : function(segment, atIndex, silent){ 3014 | var index = atIndex || this.segments.length; 3015 | 3016 | this.segments.splice(index, 0, new this.SegmentArc({ 3017 | fillColor: segment.color, 3018 | highlightColor: segment.highlight || segment.color, 3019 | label: segment.label, 3020 | value: segment.value, 3021 | outerRadius: (this.options.animateScale) ? 0 : this.scale.calculateCenterOffset(segment.value), 3022 | circumference: (this.options.animateRotate) ? 0 : this.scale.getCircumference(), 3023 | startAngle: Math.PI * 1.5 3024 | })); 3025 | if (!silent){ 3026 | this.reflow(); 3027 | this.update(); 3028 | } 3029 | }, 3030 | removeData: function(atIndex){ 3031 | var indexToDelete = (helpers.isNumber(atIndex)) ? atIndex : this.segments.length-1; 3032 | this.segments.splice(indexToDelete, 1); 3033 | this.reflow(); 3034 | this.update(); 3035 | }, 3036 | calculateTotal: function(data){ 3037 | this.total = 0; 3038 | helpers.each(data,function(segment){ 3039 | this.total += segment.value; 3040 | },this); 3041 | this.scale.valuesCount = this.segments.length; 3042 | }, 3043 | updateScaleRange: function(datapoints){ 3044 | var valuesArray = []; 3045 | helpers.each(datapoints,function(segment){ 3046 | valuesArray.push(segment.value); 3047 | }); 3048 | 3049 | var scaleSizes = (this.options.scaleOverride) ? 3050 | { 3051 | steps: this.options.scaleSteps, 3052 | stepValue: this.options.scaleStepWidth, 3053 | min: this.options.scaleStartValue, 3054 | max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) 3055 | } : 3056 | helpers.calculateScaleRange( 3057 | valuesArray, 3058 | helpers.min([this.chart.width, this.chart.height])/2, 3059 | this.options.scaleFontSize, 3060 | this.options.scaleBeginAtZero, 3061 | this.options.scaleIntegersOnly 3062 | ); 3063 | 3064 | helpers.extend( 3065 | this.scale, 3066 | scaleSizes, 3067 | { 3068 | size: helpers.min([this.chart.width, this.chart.height]), 3069 | xCenter: this.chart.width/2, 3070 | yCenter: this.chart.height/2 3071 | } 3072 | ); 3073 | 3074 | }, 3075 | update : function(){ 3076 | this.calculateTotal(this.segments); 3077 | 3078 | helpers.each(this.segments,function(segment){ 3079 | segment.save(); 3080 | }); 3081 | this.render(); 3082 | }, 3083 | reflow : function(){ 3084 | helpers.extend(this.SegmentArc.prototype,{ 3085 | x : this.chart.width/2, 3086 | y : this.chart.height/2 3087 | }); 3088 | this.updateScaleRange(this.segments); 3089 | this.scale.update(); 3090 | 3091 | helpers.extend(this.scale,{ 3092 | xCenter: this.chart.width/2, 3093 | yCenter: this.chart.height/2 3094 | }); 3095 | 3096 | helpers.each(this.segments, function(segment){ 3097 | segment.update({ 3098 | outerRadius : this.scale.calculateCenterOffset(segment.value) 3099 | }); 3100 | }, this); 3101 | 3102 | }, 3103 | draw : function(ease){ 3104 | var easingDecimal = ease || 1; 3105 | //Clear & draw the canvas 3106 | this.clear(); 3107 | helpers.each(this.segments,function(segment, index){ 3108 | segment.transition({ 3109 | circumference : this.scale.getCircumference(), 3110 | outerRadius : this.scale.calculateCenterOffset(segment.value) 3111 | },easingDecimal); 3112 | 3113 | segment.endAngle = segment.startAngle + segment.circumference; 3114 | 3115 | // If we've removed the first segment we need to set the first one to 3116 | // start at the top. 3117 | if (index === 0){ 3118 | segment.startAngle = Math.PI * 1.5; 3119 | } 3120 | 3121 | //Check to see if it's the last segment, if not get the next and update the start angle 3122 | if (index < this.segments.length - 1){ 3123 | this.segments[index+1].startAngle = segment.endAngle; 3124 | } 3125 | segment.draw(); 3126 | }, this); 3127 | this.scale.draw(); 3128 | } 3129 | }); 3130 | 3131 | }).call(this); 3132 | (function(){ 3133 | "use strict"; 3134 | 3135 | var root = this, 3136 | Chart = root.Chart, 3137 | helpers = Chart.helpers; 3138 | 3139 | 3140 | 3141 | Chart.Type.extend({ 3142 | name: "Radar", 3143 | defaults:{ 3144 | //Boolean - Whether to show lines for each scale point 3145 | scaleShowLine : true, 3146 | 3147 | //Boolean - Whether we show the angle lines out of the radar 3148 | angleShowLineOut : true, 3149 | 3150 | //Boolean - Whether to show labels on the scale 3151 | scaleShowLabels : false, 3152 | 3153 | // Boolean - Whether the scale should begin at zero 3154 | scaleBeginAtZero : true, 3155 | 3156 | //String - Colour of the angle line 3157 | angleLineColor : "rgba(0,0,0,.1)", 3158 | 3159 | //Number - Pixel width of the angle line 3160 | angleLineWidth : 1, 3161 | 3162 | //String - Point label font declaration 3163 | pointLabelFontFamily : "'Arial'", 3164 | 3165 | //String - Point label font weight 3166 | pointLabelFontStyle : "normal", 3167 | 3168 | //Number - Point label font size in pixels 3169 | pointLabelFontSize : 10, 3170 | 3171 | //String - Point label font colour 3172 | pointLabelFontColor : "#666", 3173 | 3174 | //Boolean - Whether to show a dot for each point 3175 | pointDot : true, 3176 | 3177 | //Number - Radius of each point dot in pixels 3178 | pointDotRadius : 3, 3179 | 3180 | //Number - Pixel width of point dot stroke 3181 | pointDotStrokeWidth : 1, 3182 | 3183 | //Number - amount extra to add to the radius to cater for hit detection outside the drawn point 3184 | pointHitDetectionRadius : 20, 3185 | 3186 | //Boolean - Whether to show a stroke for datasets 3187 | datasetStroke : true, 3188 | 3189 | //Number - Pixel width of dataset stroke 3190 | datasetStrokeWidth : 2, 3191 | 3192 | //Boolean - Whether to fill the dataset with a colour 3193 | datasetFill : true, 3194 | 3195 | //String - A legend template 3196 | legendTemplate : "
    -legend\"><% for (var i=0; i
  • \"><%if(datasets[i].label){%><%=datasets[i].label%><%}%>
  • <%}%>
" 3197 | 3198 | }, 3199 | 3200 | initialize: function(data){ 3201 | this.PointClass = Chart.Point.extend({ 3202 | strokeWidth : this.options.pointDotStrokeWidth, 3203 | radius : this.options.pointDotRadius, 3204 | display: this.options.pointDot, 3205 | hitDetectionRadius : this.options.pointHitDetectionRadius, 3206 | ctx : this.chart.ctx 3207 | }); 3208 | 3209 | this.datasets = []; 3210 | 3211 | this.buildScale(data); 3212 | 3213 | //Set up tooltip events on the chart 3214 | if (this.options.showTooltips){ 3215 | helpers.bindEvents(this, this.options.tooltipEvents, function(evt){ 3216 | var activePointsCollection = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : []; 3217 | 3218 | this.eachPoints(function(point){ 3219 | point.restore(['fillColor', 'strokeColor']); 3220 | }); 3221 | helpers.each(activePointsCollection, function(activePoint){ 3222 | activePoint.fillColor = activePoint.highlightFill; 3223 | activePoint.strokeColor = activePoint.highlightStroke; 3224 | }); 3225 | 3226 | this.showTooltip(activePointsCollection); 3227 | }); 3228 | } 3229 | 3230 | //Iterate through each of the datasets, and build this into a property of the chart 3231 | helpers.each(data.datasets,function(dataset){ 3232 | 3233 | var datasetObject = { 3234 | label: dataset.label || null, 3235 | fillColor : dataset.fillColor, 3236 | strokeColor : dataset.strokeColor, 3237 | pointColor : dataset.pointColor, 3238 | pointStrokeColor : dataset.pointStrokeColor, 3239 | points : [] 3240 | }; 3241 | 3242 | this.datasets.push(datasetObject); 3243 | 3244 | helpers.each(dataset.data,function(dataPoint,index){ 3245 | //Add a new point for each piece of data, passing any required data to draw. 3246 | var pointPosition; 3247 | if (!this.scale.animation){ 3248 | pointPosition = this.scale.getPointPosition(index, this.scale.calculateCenterOffset(dataPoint)); 3249 | } 3250 | datasetObject.points.push(new this.PointClass({ 3251 | value : dataPoint, 3252 | label : data.labels[index], 3253 | datasetLabel: dataset.label, 3254 | x: (this.options.animation) ? this.scale.xCenter : pointPosition.x, 3255 | y: (this.options.animation) ? this.scale.yCenter : pointPosition.y, 3256 | strokeColor : dataset.pointStrokeColor, 3257 | fillColor : dataset.pointColor, 3258 | highlightFill : dataset.pointHighlightFill || dataset.pointColor, 3259 | highlightStroke : dataset.pointHighlightStroke || dataset.pointStrokeColor 3260 | })); 3261 | },this); 3262 | 3263 | },this); 3264 | 3265 | this.render(); 3266 | }, 3267 | eachPoints : function(callback){ 3268 | helpers.each(this.datasets,function(dataset){ 3269 | helpers.each(dataset.points,callback,this); 3270 | },this); 3271 | }, 3272 | 3273 | getPointsAtEvent : function(evt){ 3274 | var mousePosition = helpers.getRelativePosition(evt), 3275 | fromCenter = helpers.getAngleFromPoint({ 3276 | x: this.scale.xCenter, 3277 | y: this.scale.yCenter 3278 | }, mousePosition); 3279 | 3280 | var anglePerIndex = (Math.PI * 2) /this.scale.valuesCount, 3281 | pointIndex = Math.round((fromCenter.angle - Math.PI * 1.5) / anglePerIndex), 3282 | activePointsCollection = []; 3283 | 3284 | // If we're at the top, make the pointIndex 0 to get the first of the array. 3285 | if (pointIndex >= this.scale.valuesCount || pointIndex < 0){ 3286 | pointIndex = 0; 3287 | } 3288 | 3289 | if (fromCenter.distance <= this.scale.drawingArea){ 3290 | helpers.each(this.datasets, function(dataset){ 3291 | activePointsCollection.push(dataset.points[pointIndex]); 3292 | }); 3293 | } 3294 | 3295 | return activePointsCollection; 3296 | }, 3297 | 3298 | buildScale : function(data){ 3299 | this.scale = new Chart.RadialScale({ 3300 | display: this.options.showScale, 3301 | fontStyle: this.options.scaleFontStyle, 3302 | fontSize: this.options.scaleFontSize, 3303 | fontFamily: this.options.scaleFontFamily, 3304 | fontColor: this.options.scaleFontColor, 3305 | showLabels: this.options.scaleShowLabels, 3306 | showLabelBackdrop: this.options.scaleShowLabelBackdrop, 3307 | backdropColor: this.options.scaleBackdropColor, 3308 | backdropPaddingY : this.options.scaleBackdropPaddingY, 3309 | backdropPaddingX: this.options.scaleBackdropPaddingX, 3310 | lineWidth: (this.options.scaleShowLine) ? this.options.scaleLineWidth : 0, 3311 | lineColor: this.options.scaleLineColor, 3312 | angleLineColor : this.options.angleLineColor, 3313 | angleLineWidth : (this.options.angleShowLineOut) ? this.options.angleLineWidth : 0, 3314 | // Point labels at the edge of each line 3315 | pointLabelFontColor : this.options.pointLabelFontColor, 3316 | pointLabelFontSize : this.options.pointLabelFontSize, 3317 | pointLabelFontFamily : this.options.pointLabelFontFamily, 3318 | pointLabelFontStyle : this.options.pointLabelFontStyle, 3319 | height : this.chart.height, 3320 | width: this.chart.width, 3321 | xCenter: this.chart.width/2, 3322 | yCenter: this.chart.height/2, 3323 | ctx : this.chart.ctx, 3324 | templateString: this.options.scaleLabel, 3325 | labels: data.labels, 3326 | valuesCount: data.datasets[0].data.length 3327 | }); 3328 | 3329 | this.scale.setScaleSize(); 3330 | this.updateScaleRange(data.datasets); 3331 | this.scale.buildYLabels(); 3332 | }, 3333 | updateScaleRange: function(datasets){ 3334 | var valuesArray = (function(){ 3335 | var totalDataArray = []; 3336 | helpers.each(datasets,function(dataset){ 3337 | if (dataset.data){ 3338 | totalDataArray = totalDataArray.concat(dataset.data); 3339 | } 3340 | else { 3341 | helpers.each(dataset.points, function(point){ 3342 | totalDataArray.push(point.value); 3343 | }); 3344 | } 3345 | }); 3346 | return totalDataArray; 3347 | })(); 3348 | 3349 | 3350 | var scaleSizes = (this.options.scaleOverride) ? 3351 | { 3352 | steps: this.options.scaleSteps, 3353 | stepValue: this.options.scaleStepWidth, 3354 | min: this.options.scaleStartValue, 3355 | max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth) 3356 | } : 3357 | helpers.calculateScaleRange( 3358 | valuesArray, 3359 | helpers.min([this.chart.width, this.chart.height])/2, 3360 | this.options.scaleFontSize, 3361 | this.options.scaleBeginAtZero, 3362 | this.options.scaleIntegersOnly 3363 | ); 3364 | 3365 | helpers.extend( 3366 | this.scale, 3367 | scaleSizes 3368 | ); 3369 | 3370 | }, 3371 | addData : function(valuesArray,label){ 3372 | //Map the values array for each of the datasets 3373 | this.scale.valuesCount++; 3374 | helpers.each(valuesArray,function(value,datasetIndex){ 3375 | var pointPosition = this.scale.getPointPosition(this.scale.valuesCount, this.scale.calculateCenterOffset(value)); 3376 | this.datasets[datasetIndex].points.push(new this.PointClass({ 3377 | value : value, 3378 | label : label, 3379 | x: pointPosition.x, 3380 | y: pointPosition.y, 3381 | strokeColor : this.datasets[datasetIndex].pointStrokeColor, 3382 | fillColor : this.datasets[datasetIndex].pointColor 3383 | })); 3384 | },this); 3385 | 3386 | this.scale.labels.push(label); 3387 | 3388 | this.reflow(); 3389 | 3390 | this.update(); 3391 | }, 3392 | removeData : function(){ 3393 | this.scale.valuesCount--; 3394 | this.scale.labels.shift(); 3395 | helpers.each(this.datasets,function(dataset){ 3396 | dataset.points.shift(); 3397 | },this); 3398 | this.reflow(); 3399 | this.update(); 3400 | }, 3401 | update : function(){ 3402 | this.eachPoints(function(point){ 3403 | point.save(); 3404 | }); 3405 | this.reflow(); 3406 | this.render(); 3407 | }, 3408 | reflow: function(){ 3409 | helpers.extend(this.scale, { 3410 | width : this.chart.width, 3411 | height: this.chart.height, 3412 | size : helpers.min([this.chart.width, this.chart.height]), 3413 | xCenter: this.chart.width/2, 3414 | yCenter: this.chart.height/2 3415 | }); 3416 | this.updateScaleRange(this.datasets); 3417 | this.scale.setScaleSize(); 3418 | this.scale.buildYLabels(); 3419 | }, 3420 | draw : function(ease){ 3421 | var easeDecimal = ease || 1, 3422 | ctx = this.chart.ctx; 3423 | this.clear(); 3424 | this.scale.draw(); 3425 | 3426 | helpers.each(this.datasets,function(dataset){ 3427 | 3428 | //Transition each point first so that the line and point drawing isn't out of sync 3429 | helpers.each(dataset.points,function(point,index){ 3430 | if (point.hasValue()){ 3431 | point.transition(this.scale.getPointPosition(index, this.scale.calculateCenterOffset(point.value)), easeDecimal); 3432 | } 3433 | },this); 3434 | 3435 | 3436 | 3437 | //Draw the line between all the points 3438 | ctx.lineWidth = this.options.datasetStrokeWidth; 3439 | ctx.strokeStyle = dataset.strokeColor; 3440 | ctx.beginPath(); 3441 | helpers.each(dataset.points,function(point,index){ 3442 | if (index === 0){ 3443 | ctx.moveTo(point.x,point.y); 3444 | } 3445 | else{ 3446 | ctx.lineTo(point.x,point.y); 3447 | } 3448 | },this); 3449 | ctx.closePath(); 3450 | ctx.stroke(); 3451 | 3452 | ctx.fillStyle = dataset.fillColor; 3453 | ctx.fill(); 3454 | 3455 | //Now draw the points over the line 3456 | //A little inefficient double looping, but better than the line 3457 | //lagging behind the point positions 3458 | helpers.each(dataset.points,function(point){ 3459 | if (point.hasValue()){ 3460 | point.draw(); 3461 | } 3462 | }); 3463 | 3464 | },this); 3465 | 3466 | } 3467 | 3468 | }); 3469 | 3470 | 3471 | 3472 | 3473 | 3474 | }).call(this); -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Automated Surveys 5 | 7 | 9 | 11 | 13 | 14 | 15 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |

Automated Survey Results

28 |

29 | Want to display the results of your awesome Twilio survey? Here's 30 | how you might do that. 31 |

32 | 33 |

Showing Total Responses

34 |
35 | 36 |
37 |
38 |

"Please tell us your age."

39 | 40 |
41 |
42 |

"Have you ever jump-kicked a lemur?"

43 | 44 |
45 |
46 | 47 |
48 |

"Who is your favorite Ninja Turtle and why?"

49 | 50 | 51 | 52 | 53 | 54 |
Response Text / TranscriptRecording
55 |
56 | 57 |
58 | 59 |
60 | Made with by your pals 61 | @twilio 62 |
63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /public/index.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | 3 | // Chart ages 4 | function ages(results) { 5 | // Collect age results 6 | var data = {}; 7 | for (var i = 0, l = results.length; i'; 53 | if (response.recordingUrl) { 54 | tpl += ''; 57 | } else { 58 | tpl += 'N/A'; 59 | } 60 | tpl += ''; 61 | return tpl; 62 | } 63 | 64 | // add text responses to a table 65 | function freeText(results) { 66 | var $responses = $('#turtleResponses'); 67 | var content = ''; 68 | for (var i = 0, l = results.length; i 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Automate Survey Tutorial 10 | 19 | 20 | 87 | 88 | 89 |
90 | 119 | 120 | 121 | 125 | 126 | 127 | 139 | 140 | 141 | 142 | 143 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /public/main.css: -------------------------------------------------------------------------------- 1 | #main, footer { 2 | text-align: center; 3 | } 4 | 5 | footer { 6 | margin:40px 0; 7 | } 8 | 9 | table { 10 | margin:10px auto; 11 | } 12 | 13 | td { 14 | padding:5px; 15 | text-align: left; 16 | } -------------------------------------------------------------------------------- /public/webhook-screen-cap.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TwilioDevEd/survey-node/c16e944cd6d689a7ecd4eb0f5a1120cea3be0671/public/webhook-screen-cap.gif -------------------------------------------------------------------------------- /routes/message.js: -------------------------------------------------------------------------------- 1 | var MessagingResponse = require('twilio').twiml.MessagingResponse; 2 | var SurveyResponse = require('../models/SurveyResponse'); 3 | var survey = require('../survey_data'); 4 | var logger = require('tracer').colorConsole(); 5 | 6 | // Handle SMS submissions 7 | module.exports = function(request, response) { 8 | var phone = request.body.From; 9 | var input = request.body.Body; 10 | 11 | // respond with message TwiML content 12 | function respond(message) { 13 | var twiml = new MessagingResponse(); 14 | twiml.message(message); 15 | logger.debug(twiml.toString()); 16 | response.type('text/xml'); 17 | response.send(twiml.toString()); 18 | } 19 | 20 | // Check if there are any responses for the current number in an incomplete 21 | // survey response 22 | SurveyResponse.findOne({ 23 | phone: phone, 24 | complete: false 25 | }, function(err, doc) { 26 | if (!doc) { 27 | var newSurvey = new SurveyResponse({ 28 | phone: phone 29 | }); 30 | newSurvey.save(function(err, doc) { 31 | // Skip the input and just ask the first question 32 | handleNextQuestion(err, doc, 0); 33 | }); 34 | } else { 35 | // After the first message, start processing input 36 | SurveyResponse.advanceSurvey({ 37 | phone: phone, 38 | input: input, 39 | survey: survey 40 | }, handleNextQuestion); 41 | } 42 | }); 43 | 44 | // Ask the next question based on the current index 45 | function handleNextQuestion(err, surveyResponse, questionIndex) { 46 | var question = survey[questionIndex]; 47 | var responseMessage = ''; 48 | 49 | if (err || !surveyResponse) { 50 | return respond('Terribly sorry, but an error has occurred. ' 51 | + 'Please retry your message.'); 52 | } 53 | 54 | // If question is null, we're done! 55 | if (!question) { 56 | return respond('Thank you for taking this survey. Goodbye!'); 57 | } 58 | 59 | // Add a greeting if this is the first question 60 | if (questionIndex === 0) { 61 | responseMessage += 'Thank you for taking our survey! '; 62 | } 63 | 64 | // Add question text 65 | responseMessage += question.text; 66 | 67 | // Add question instructions for special types 68 | if (question.type === 'boolean') { 69 | responseMessage += ' Type "yes" or "no".'; 70 | } 71 | 72 | // reply with message 73 | respond(responseMessage); 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /routes/results.js: -------------------------------------------------------------------------------- 1 | var SurveyResponse = require('../models/SurveyResponse'); 2 | var survey = require('../survey_data'); 3 | 4 | // Grab all the latest survey data for display in a quick and dirty UI 5 | module.exports = function(request, response) { 6 | SurveyResponse.find({ 7 | complete: true 8 | }).limit(100).exec(function(err, docs) { 9 | if (err) { 10 | response.status(500).send(err); 11 | } else { 12 | response.send({ 13 | survey: survey, 14 | results: docs 15 | }); 16 | } 17 | }); 18 | }; -------------------------------------------------------------------------------- /routes/voice.js: -------------------------------------------------------------------------------- 1 | var VoiceResponse = require('twilio').twiml.VoiceResponse; 2 | var SurveyResponse = require('../models/SurveyResponse'); 3 | var survey = require('../survey_data'); 4 | 5 | // Main interview loop 6 | exports.interview = function(request, response) { 7 | var phone = request.body.From; 8 | var input = request.body.RecordingUrl || request.body.Digits; 9 | var twiml = new VoiceResponse(); 10 | 11 | // helper to append a new "Say" verb with Polly.Amy voice 12 | function say(text) { 13 | twiml.say({ voice: 'Polly.Amy'}, text); 14 | } 15 | 16 | // respond with the current TwiML content 17 | function respond() { 18 | response.type('text/xml'); 19 | response.send(twiml.toString()); 20 | } 21 | 22 | // Find an in-progess survey if one exists, otherwise create one 23 | SurveyResponse.advanceSurvey({ 24 | phone: phone, 25 | input: input, 26 | survey: survey 27 | }, function(err, surveyResponse, questionIndex) { 28 | var question = survey[questionIndex]; 29 | 30 | if (err || !surveyResponse) { 31 | say('Terribly sorry, but an error has occurred. Goodbye.'); 32 | return respond(); 33 | } 34 | 35 | // If question is null, we're done! 36 | if (!question) { 37 | say('Thank you for taking this survey. Goodbye!'); 38 | return respond(); 39 | } 40 | 41 | // Add a greeting if this is the first question 42 | if (questionIndex === 0) { 43 | say('Thank you for taking our survey. Please listen carefully ' 44 | + 'to the following questions.'); 45 | } 46 | 47 | // Otherwise, ask the next question 48 | say(question.text); 49 | 50 | // Depending on the type of question, we either need to get input via 51 | // DTMF tones or recorded speech 52 | if (question.type === 'text') { 53 | say('Please record your response after the beep. ' 54 | + 'Press any key to finish.'); 55 | twiml.record({ 56 | transcribe: true, 57 | transcribeCallback: '/voice/' + surveyResponse._id 58 | + '/transcribe/' + questionIndex, 59 | maxLength: 60 60 | }); 61 | } else if (question.type === 'boolean') { 62 | say('Press one for "yes", and any other key for "no".'); 63 | twiml.gather({ 64 | timeout: 10, 65 | numDigits: 1 66 | }); 67 | } else { 68 | // Only other supported type is number 69 | say('Enter the number using the number keys on your telephone.' 70 | + ' Press star to finish.'); 71 | twiml.gather({ 72 | timeout: 10, 73 | finishOnKey: '*' 74 | }); 75 | } 76 | 77 | // render TwiML response 78 | respond(); 79 | }); 80 | }; 81 | 82 | // Transcripton callback - called by Twilio with transcript of recording 83 | // Will update survey response outside the interview call flow 84 | exports.transcription = function(request, response) { 85 | var responseId = request.params.responseId; 86 | var questionIndex = request.params.questionIndex; 87 | var transcript = request.body.TranscriptionText; 88 | 89 | SurveyResponse.findById(responseId, function(err, surveyResponse) { 90 | if (err || !surveyResponse || 91 | !surveyResponse.responses[questionIndex]) 92 | return response.status(500).end(); 93 | 94 | // Update appropriate answer field 95 | surveyResponse.responses[questionIndex].answer = transcript; 96 | surveyResponse.markModified('responses'); 97 | surveyResponse.save(function(err, doc) { 98 | return response.status(err ? 500 : 200).end(); 99 | }); 100 | }); 101 | }; 102 | -------------------------------------------------------------------------------- /survey_data.js: -------------------------------------------------------------------------------- 1 | // Hard coded survey questions 2 | module.exports = [ 3 | { 4 | text: 'Please tell us your age.', 5 | type: 'number' 6 | }, 7 | { 8 | text: 'Have you ever jump-kicked a lemur?', 9 | type: 'boolean' 10 | }, 11 | { 12 | text: 'Who is your favorite Teenage Mutant Ninja Turtle and why?', 13 | type: 'text' 14 | } 15 | ]; -------------------------------------------------------------------------------- /test/message.spec.js: -------------------------------------------------------------------------------- 1 | var Promise = require("bluebird"); 2 | var expect = require("chai").expect; 3 | var supertest = require("supertest-promised"); 4 | var find = require("lodash/find"); 5 | var app = require("../app"); 6 | var SurveyResponse = require("../models/SurveyResponse"); 7 | var agent = supertest(app); 8 | var xpath = require('xpath'); 9 | var dom = require('xmldom').DOMParser; 10 | 11 | describe("POST /message", function() { 12 | beforeEach(function() { 13 | return SurveyResponse.remove({}) 14 | .then(() => console.log("### delete all")) 15 | }); 16 | 17 | it("returns a greeting and a question on first survey attempt", function() { 18 | return agent.post("/message") 19 | .type("form") 20 | .send({ 21 | From: "+15555555555", 22 | Body: "Message body", 23 | }) 24 | .expect("Content-Type", /text\/xml/) 25 | .expect(200) 26 | .expect(function(res) { 27 | var doc = new dom().parseFromString(res.text); 28 | var MessageTxt = xpath.select("/Response/Message/text()", doc)[0].data; 29 | 30 | expect(MessageTxt).to 31 | .contain("Thank you for taking our survey! Please tell us your age."); 32 | }); 33 | }); 34 | 35 | it("returns a goodbye after the final step in the survey.", function() { 36 | function step1() { 37 | return agent.post("/message") 38 | .type("form") 39 | .send({ 40 | From: "+15555555555", 41 | Body: "Message body", 42 | }) 43 | .expect("Content-Type", /text\/xml/) 44 | .expect(200) 45 | .end(); 46 | } 47 | 48 | function step2() { 49 | return agent.post("/message") 50 | .type("form") 51 | .send({ 52 | From: "+15555555555", 53 | Body: "33", 54 | }) 55 | .expect("Content-Type", /text\/xml/) 56 | .expect(200) 57 | .expect(function(res) { 58 | var doc = new dom().parseFromString(res.text); 59 | var MessageTxt = xpath.select("/Response/Message/text()", doc)[0].data; 60 | 61 | expect(MessageTxt).to 62 | .contain("Have you ever jump-kicked a lemur? Type \"yes\" or \"no\"."); 63 | 64 | return; 65 | }) 66 | .end(); 67 | }; 68 | 69 | function step3() { 70 | return agent.post("/message") 71 | .type("form") 72 | .send({ 73 | From: "+15555555555", 74 | Body: "yes", 75 | }) 76 | .expect("Content-Type", /text\/xml/) 77 | .expect(200) 78 | .expect(function(res) { 79 | var doc = new dom().parseFromString(res.text); 80 | var MessageTxt = xpath.select("/Response/Message/text()", doc)[0].data; 81 | 82 | expect(MessageTxt).to 83 | .contain("Who is your favorite Teenage Mutant Ninja Turtle and why?"); 84 | return; 85 | }) 86 | .end(); 87 | }; 88 | 89 | function step4() { 90 | return agent.post("/message") 91 | .type("form") 92 | .send({ 93 | From: "+15555555555", 94 | Body: "rafael", 95 | }) 96 | .expect("Content-Type", /text\/xml/) 97 | .expect(200) 98 | .expect(function(res) { 99 | var doc = new dom().parseFromString(res.text); 100 | var MessageTxt = xpath.select("/Response/Message/text()", doc)[0].data; 101 | 102 | expect(MessageTxt).to 103 | .contain("Thank you for taking this survey. Goodbye!"); 104 | return; 105 | }) 106 | .end(); 107 | }; 108 | 109 | var steps = [step1, step2, step3, step4]; 110 | 111 | return Promise.each(steps, function(step) { 112 | return step(); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /test/results.spec.js: -------------------------------------------------------------------------------- 1 | var Promise = require("bluebird"); 2 | var expect = require("chai").expect; 3 | var supertest = require("supertest-promised"); 4 | var find = require("lodash/find"); 5 | var app = require("../app"); 6 | var SurveyResponse = require("../models/SurveyResponse"); 7 | var agent = supertest(app); 8 | var xpath = require('xpath'); 9 | var dom = require('xmldom').DOMParser; 10 | 11 | describe("GET /message", function() { 12 | beforeEach(function() { 13 | function clearDb() { 14 | return SurveyResponse.remove({}) 15 | } 16 | 17 | function step1() { 18 | return agent.post("/message") 19 | .type("form") 20 | .send({ 21 | From: "+15555555555", 22 | Body: "Message body", 23 | }) 24 | .expect("Content-Type", /text\/xml/) 25 | .expect(200) 26 | .end(); 27 | } 28 | 29 | function step2() { 30 | return agent.post("/message") 31 | .type("form") 32 | .send({ 33 | From: "+15555555555", 34 | Body: "33", 35 | }) 36 | .expect("Content-Type", /text\/xml/) 37 | .expect(200) 38 | .expect(function(res) { 39 | var doc = new dom().parseFromString(res.text); 40 | var MessageTxt = xpath.select("/Response/Message/text()", doc)[0].data; 41 | 42 | expect(MessageTxt).to 43 | .contain("Have you ever jump-kicked a lemur? Type \"yes\" or \"no\"."); 44 | return; 45 | }) 46 | .end(); 47 | }; 48 | 49 | function step3() { 50 | return agent.post("/message") 51 | .type("form") 52 | .send({ 53 | From: "+15555555555", 54 | Body: "yes", 55 | }) 56 | .expect("Content-Type", /text\/xml/) 57 | .expect(200) 58 | .expect(function(res) { 59 | var doc = new dom().parseFromString(res.text); 60 | var MessageTxt = xpath.select("/Response/Message/text()", doc)[0].data; 61 | 62 | expect(MessageTxt).to 63 | .contain("Who is your favorite Teenage Mutant Ninja Turtle and why?"); 64 | return; 65 | }) 66 | .end(); 67 | }; 68 | 69 | function step4() { 70 | return agent.post("/message") 71 | .type("form") 72 | .send({ 73 | From: "+15555555555", 74 | Body: "rafael", 75 | }) 76 | .expect("Content-Type", /text\/xml/) 77 | .expect(200) 78 | .expect(function(res) { 79 | var doc = new dom().parseFromString(res.text); 80 | var MessageTxt = xpath.select("/Response/Message/text()", doc)[0].data; 81 | 82 | expect(MessageTxt).to 83 | .contain("Thank you for taking this survey. Goodbye!"); 84 | return; 85 | }) 86 | .end(); 87 | }; 88 | 89 | var steps = [clearDb, step1, step2, step3, step4]; 90 | 91 | return Promise.each(steps, function(step) { 92 | return step(); 93 | }); 94 | }); 95 | 96 | it("Returns a response with list of questions and answers.", function() { 97 | var response = { 98 | "survey":[ 99 | {"text":"Please tell us your age.","type":"number"}, 100 | {"text":"Have you ever jump-kicked a lemur?","type":"boolean"}, 101 | {"text":"Who is your favorite Teenage Mutant Ninja Turtle and why?","type":"text"}, 102 | ], 103 | "results":[ 104 | { 105 | "phone":"+15555555555", 106 | "__v":3, 107 | "responses":[ 108 | {"type":"number","answer":33}, 109 | {"type":"boolean","answer":true}, 110 | {"type":"text","answer":"rafael"}, 111 | ], 112 | "complete":true, 113 | } 114 | ] 115 | }; 116 | 117 | return agent.get("/results") 118 | .expect("Content-Type", /application\/json/) 119 | .expect(200) 120 | .expect(function(res) { 121 | var body = res.body; 122 | 123 | body.results.forEach(function(result) { 124 | delete result._id; 125 | }); 126 | 127 | expect(body).to.deep.equal(response); 128 | }) 129 | .end() 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /test/voice.spec.js: -------------------------------------------------------------------------------- 1 | var Promise = require("bluebird"); 2 | var expect = require("chai").expect; 3 | var supertest = require("supertest-promised"); 4 | var find = require("lodash/find"); 5 | var app = require("../app"); 6 | var SurveyResponse = require("../models/SurveyResponse"); 7 | var agent = supertest(app); 8 | 9 | describe("GET /voice", function() { 10 | beforeEach(function() { 11 | return SurveyResponse.remove({}); 12 | }); 13 | 14 | it("returns a TwiML response with correct message.", function() { 15 | var requestBody = { 16 | "Called":"+17070000000", 17 | "ToState":"CA", 18 | "CallerCountry":"US", 19 | "Direction":"inbound", 20 | "CallerState":"TX", 21 | "ToZip":"94595", 22 | "CallSid":"CAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 23 | "To":"+17070000000", 24 | "CallerZip":"", 25 | "ToCountry":"US", 26 | "ApiVersion":"2010-04-01", 27 | "CalledZip":"94595", 28 | "CalledCity":"WALNUT CREEK", 29 | "CallStatus":"ringing", "From":"+17370000000", "AccountSid":"AC4XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 30 | "CalledCountry":"US", 31 | "CallerCity":"", 32 | "Caller":"+17370000000", 33 | "FromCountry":"US", 34 | "ToCity":"WALNUT CREEK", 35 | "FromCity":"", 36 | "CalledState":"CA", 37 | "FromZip":"", 38 | "FromState":"TX" 39 | }; 40 | 41 | return agent.post("/voice") 42 | .send(requestBody) 43 | .expect("Content-Type", /text\/xml/) 44 | .expect(200) 45 | .expect(function(res) { 46 | expect(res.text).to.contain("Thank you for taking our survey. Please listen carefully to the following questions.") 47 | }) 48 | .end() 49 | }); 50 | }); 51 | --------------------------------------------------------------------------------