diff --git a/NEWS.md b/NEWS.md index 5b8876b3..1f341125 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,24 @@ +2017-07-17 +========== + +The `stats` database table and associated ACP subpage have been removed in favor +of integration with [Prometheus](https://prometheus.io/). You can enable +Prometheus reporting by copying `conf/example/prometheus.toml` to +`conf/prometheus.toml` and editing it to your liking. I recommend integrating +Prometheus with [Grafana](https://grafana.com/) for dashboarding needs. + +The particular metrics that were saved in the `stats` table are reported by the +following Prometheus metrics: + + * Channel count: `cytube_channels_num_active` gauge. + * User count: `cytube_sockets_num_connected` gauge (labeled by socket.io + transport). + * CPU/Memory: default metrics emitted by the + [`prom-client`](https://github.com/siimon/prom-client) module. + +More Prometheus metrics will be added in the future to make CyTube easier to +monitor :) + 2017-07-15 ========== diff --git a/config.template.yaml b/config.template.yaml index 664a3428..9154819c 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -166,13 +166,6 @@ channel-save-interval: 5 channel-storage: type: 'file' -# Configure statistics tracking -stats: - # Interval (in milliseconds) between data points - default 1h - interval: 3600000 - # Maximum age of a datapoint (ms) before it is deleted - default 24h - max-age: 86400000 - # Configure periodic clearing of old alias data aliases: # Interval (in milliseconds) between subsequent runs of clearing diff --git a/package.json b/package.json index 87e1cc7f..128c7b6d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Calvin Montgomery", "name": "CyTube", "description": "Online media synchronizer and chat", - "version": "3.41.1", + "version": "3.42.0", "repository": { "url": "http://github.com/calzoneman/sync" }, diff --git a/src/acp.js b/src/acp.js index 15a1b6d7..e370ffa3 100644 --- a/src/acp.js +++ b/src/acp.js @@ -257,12 +257,6 @@ function handleForceUnload(user, data) { Logger.eventlog.log("[acp] " + eventUsername(user) + " forced unload of " + name); } -function handleListStats(user) { - db.listStats(function (err, rows) { - user.socket.emit("acp-list-stats", rows); - }); -} - function init(user) { var s = user.socket; s.on("acp-announce", handleAnnounce.bind(this, user)); @@ -276,7 +270,6 @@ function init(user) { s.on("acp-delete-channel", handleDeleteChannel.bind(this, user)); s.on("acp-list-activechannels", handleListActiveChannels.bind(this, user)); s.on("acp-force-unload", handleForceUnload.bind(this, user)); - s.on("acp-list-stats", handleListStats.bind(this, user)); const globalBanDB = db.getGlobalBanDB(); globalBanDB.listGlobalBans().then(bans => { diff --git a/src/bgtask.js b/src/bgtask.js index 99e922f3..5ff28b8a 100644 --- a/src/bgtask.js +++ b/src/bgtask.js @@ -13,26 +13,6 @@ const LOGGER = require('@calzoneman/jsli')('bgtask'); var init = null; -/* Stats */ -function initStats(Server) { - var STAT_INTERVAL = parseInt(Config.get("stats.interval")); - var STAT_EXPIRE = parseInt(Config.get("stats.max-age")); - - setInterval(function () { - var chancount = Server.channels.length; - var usercount = 0; - Server.channels.forEach(function (chan) { - usercount += chan.users.length; - }); - - var mem = process.memoryUsage().rss; - - db.addStatPoint(Date.now(), usercount, chancount, mem, function () { - db.pruneStats(Date.now() - STAT_EXPIRE); - }); - }, STAT_INTERVAL); -} - /* Alias cleanup */ function initAliasCleanup(Server) { var CLEAN_INTERVAL = parseInt(Config.get("aliases.purge-interval")); @@ -91,7 +71,6 @@ module.exports = function (Server) { } init = Server; - initStats(Server); initAliasCleanup(Server); initChannelDumper(Server); initPasswordResetCleanup(Server); diff --git a/src/config.js b/src/config.js index 2cff9b76..92a4ef8b 100644 --- a/src/config.js +++ b/src/config.js @@ -82,10 +82,6 @@ var defaults = { "max-channels-per-user": 5, "max-accounts-per-ip": 5, "guest-login-delay": 60, - stats: { - interval: 3600000, - "max-age": 86400000 - }, aliases: { "purge-interval": 3600000, "max-age": 2592000000 diff --git a/src/database.js b/src/database.js index f5ec5a00..772afe9c 100644 --- a/src/database.js +++ b/src/database.js @@ -360,37 +360,6 @@ module.exports.getIPs = function (name, callback) { /* END REGION */ -/* REGION stats */ - -module.exports.addStatPoint = function (time, ucount, ccount, mem, callback) { - if (typeof callback !== "function") { - callback = blackHole; - } - - var query = "INSERT INTO stats VALUES (?, ?, ?, ?)"; - module.exports.query(query, [time, ucount, ccount, mem], callback); -}; - -module.exports.pruneStats = function (before, callback) { - if (typeof callback !== "function") { - callback = blackHole; - } - - var query = "DELETE FROM stats WHERE time < ?"; - module.exports.query(query, [before], callback); -}; - -module.exports.listStats = function (callback) { - if (typeof callback !== "function") { - return; - } - - var query = "SELECT * FROM stats ORDER BY time ASC"; - module.exports.query(query, callback); -}; - -/* END REGION */ - /* Misc */ module.exports.loadAnnouncement = function () { var query = "SELECT * FROM `meta` WHERE `key`='announcement'"; diff --git a/src/database/tables.js b/src/database/tables.js index e0b98816..9f07387a 100644 --- a/src/database/tables.js +++ b/src/database/tables.js @@ -65,15 +65,6 @@ const TBL_ALIASES = "" + "PRIMARY KEY (`visit_id`), INDEX (`ip`)" + ")"; -const TBL_STATS = "" + - "CREATE TABLE IF NOT EXISTS `stats` (" + - "`time` BIGINT NOT NULL," + - "`usercount` INT NOT NULL," + - "`chancount` INT NOT NULL," + - "`mem` INT NOT NULL," + - "PRIMARY KEY (`time`))" + - "CHARACTER SET utf8"; - const TBL_META = "" + "CREATE TABLE IF NOT EXISTS `meta` (" + "`key` VARCHAR(255) NOT NULL," + @@ -132,7 +123,6 @@ module.exports.init = function (queryfn, cb) { password_reset: TBL_PASSWORD_RESET, user_playlists: TBL_USER_PLAYLISTS, aliases: TBL_ALIASES, - stats: TBL_STATS, meta: TBL_META, channel_data: TBL_CHANNEL_DATA }; diff --git a/src/io/ioserver.js b/src/io/ioserver.js index c93067d1..b8a01752 100644 --- a/src/io/ioserver.js +++ b/src/io/ioserver.js @@ -19,6 +19,7 @@ const verifySession = Promise.promisify(session.verifySession); const getAliases = Promise.promisify(db.getAliases); import { CachingGlobalBanlist } from './globalban'; import proxyaddr from 'proxy-addr'; +import { Counter, Gauge } from 'prom-client'; const LOGGER = require('@calzoneman/jsli')('ioserver'); @@ -187,6 +188,54 @@ function isIPGlobalBanned(ip) { return globalIPBanlist.isIPGlobalBanned(ip); } +const promSocketCount = new Gauge({ + name: 'cytube_sockets_num_connected', + help: 'Gauge of connected socket.io clients', + labelNames: ['transport'] +}); +const promSocketAccept = new Counter({ + name: 'cytube_sockets_accept_count', + help: 'Counter for number of connections accepted. Excludes rejected connections.' +}); +const promSocketDisconnect = new Counter({ + name: 'cytube_sockets_disconnect_count', + help: 'Counter for number of connections disconnected.' +}); +function emitMetrics(sock) { + try { + let transportName = sock.client.conn.transport.name; + promSocketCount.inc({ transport: transportName }); + promSocketAccept.inc(1, new Date()); + + sock.client.conn.on('upgrade', newTransport => { + try { + // Sanity check + if (newTransport !== transportName) { + promSocketCount.dec({ transport: transportName }); + transportName = newTransport.name; + promSocketCount.inc({ transport: transportName }); + } + } catch (error) { + LOGGER.error('Error emitting transport upgrade metrics for socket (ip=%s): %s', + sock._realip, error.stack); + } + }); + + sock.on('disconnect', () => { + try { + promSocketCount.dec({ transport: transportName }); + promSocketDisconnect.inc(1, new Date()); + } catch (error) { + LOGGER.error('Error emitting disconnect metrics for socket (ip=%s): %s', + sock._realip, error.stack); + } + }); + } catch (error) { + LOGGER.error('Error emitting metrics for socket (ip=%s): %s', + sock._realip, error.stack); + } +} + /** * Called after a connection is accepted */ @@ -227,6 +276,8 @@ function handleConnection(sock) { return; } + emitMetrics(sock); + LOGGER.info("Accepted socket from " + ip); counters.add("socket.io:accept", 1); diff --git a/src/prometheus-server.js b/src/prometheus-server.js index f94fca57..b36a88e3 100644 --- a/src/prometheus-server.js +++ b/src/prometheus-server.js @@ -1,10 +1,11 @@ import http from 'http'; -import { register } from 'prom-client'; +import { register, collectDefaultMetrics } from 'prom-client'; import { parse as parseURL } from 'url'; const LOGGER = require('@calzoneman/jsli')('prometheus-server'); let server = null; +let defaultMetricsTimer = null; export function init(prometheusConfig) { if (server !== null) { @@ -13,6 +14,8 @@ export function init(prometheusConfig) { return; } + defaultMetricsTimer = collectDefaultMetrics(); + server = http.createServer((req, res) => { if (req.method !== 'GET' || parseURL(req.url).pathname !== prometheusConfig.getPath()) { @@ -44,4 +47,6 @@ export function init(prometheusConfig) { export function shutdown() { server.close(); server = null; + clearInterval(defaultMetricsTimer); + defaultMetricsTimer = null; } diff --git a/src/server.js b/src/server.js index 17bbbd22..9c8f7fcc 100644 --- a/src/server.js +++ b/src/server.js @@ -51,6 +51,7 @@ import session from './session'; import { LegacyModule } from './legacymodule'; import { PartitionModule } from './partition/partitionmodule'; import * as Switches from './switches'; +import { Gauge } from 'prom-client'; var Server = function () { var self = this; @@ -226,6 +227,10 @@ Server.prototype.isChannelLoaded = function (name) { return false; }; +const promActiveChannels = new Gauge({ + name: 'cytube_channels_num_active', + help: 'Number of channels currently active' +}); Server.prototype.getChannel = function (name) { var cname = name.toLowerCase(); if (this.partitionDecider && @@ -242,6 +247,7 @@ Server.prototype.getChannel = function (name) { } var c = new Channel(name); + promActiveChannels.inc(); c.on("empty", function () { self.unloadChannel(c); }); @@ -310,6 +316,7 @@ Server.prototype.unloadChannel = function (chan, options) { } } chan.dead = true; + promActiveChannels.dec(); }; Server.prototype.packChannelList = function (publicOnly, isAdmin) { diff --git a/src/web/webserver.js b/src/web/webserver.js index 0e46bf92..8e09d498 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -11,7 +11,7 @@ import csrf from './csrf'; import * as HTTPStatus from './httpstatus'; import { CSRFError, HTTPError } from '../errors'; import counters from '../counters'; -import { Summary } from 'prom-client'; +import { Summary, Counter } from 'prom-client'; const LOGGER = require('@calzoneman/jsli')('webserver'); @@ -35,6 +35,11 @@ function initPrometheus(app) { + 'until the "finish" event on the response object.', labelNames: ['method', 'statusCode'] }); + const requests = new Counter({ + name: 'cytube_http_req_count', + help: 'HTTP Request count', + labelNames: ['method', 'statusCode'] + }); app.use((req, res, next) => { const startTime = process.hrtime(); @@ -43,6 +48,7 @@ function initPrometheus(app) { const diff = process.hrtime(startTime); const diffMs = diff[0]*1e3 + diff[1]*1e-6; latency.labels(req.method, res.statusCode).observe(diffMs); + requests.labels(req.method, res.statusCode).inc(1, new Date()); } catch (error) { LOGGER.error('Failed to record HTTP Prometheus metrics: %s', error.stack); } diff --git a/www/js/acp.js b/www/js/acp.js index 318ca849..1d53e38f 100644 --- a/www/js/acp.js +++ b/www/js/acp.js @@ -34,7 +34,6 @@ addMenuItem("#acp-user-lookup", "Users"); addMenuItem("#acp-channel-lookup", "Channels"); addMenuItem("#acp-loaded-channels", "Active Channels"); addMenuItem("#acp-eventlog", "Event Log"); -addMenuItem("#acp-stats", "Stats"); /* Log Viewer */ function readSyslog() { @@ -541,79 +540,6 @@ function filterEventLog() { $("#acp-eventlog-filter").change(filterEventLog); $("#acp-eventlog-refresh").click(readEventlog); -/* Stats */ - -$("a:contains('Stats')").click(function () { - socket.emit("acp-list-stats"); -}); - -socket.on("acp-list-stats", function (rows) { - var labels = []; - var ucounts = []; - var ccounts = []; - var mcounts = []; - var lastdate = ""; - rows.forEach(function (r) { - var d = new Date(parseInt(r.time)); - var t = ""; - if (d.toDateString() !== lastdate) { - lastdate = d.toDateString(); - t = d.getFullYear()+"-"+(d.getMonth()+1)+"-"+d.getDate(); - t += " " + d.toTimeString().split(" ")[0]; - } else { - t = d.toTimeString().split(" ")[0]; - } - - labels.push(t); - ucounts.push(r.usercount); - ccounts.push(r.chancount); - mcounts.push(r.mem / 1048576); - }); - - var userdata = { - labels: labels, - datasets: [ - { - fillColor: "rgba(151, 187, 205, 0.5)", - strokeColor: "rgba(151, 187, 205, 1)", - pointColor: "rgba(151, 187, 205, 1)", - pointStrokeColor: "#fff", - data: ucounts - } - ] - }; - - var channeldata = { - labels: labels, - datasets: [ - { - fillColor: "rgba(151, 187, 205, 0.5)", - strokeColor: "rgba(151, 187, 205, 1)", - pointColor: "rgba(151, 187, 205, 1)", - pointStrokeColor: "#fff", - data: ccounts - } - ] - }; - - var memdata = { - labels: labels, - datasets: [ - { - fillColor: "rgba(151, 187, 205, 0.5)", - strokeColor: "rgba(151, 187, 205, 1)", - pointColor: "rgba(151, 187, 205, 1)", - pointStrokeColor: "#fff", - data: mcounts - } - ] - }; - - new Chart($("#stat_users")[0].getContext("2d")).Line(userdata); - new Chart($("#stat_channels")[0].getContext("2d")).Line(channeldata); - new Chart($("#stat_mem")[0].getContext("2d")).Line(memdata); -}); - /* Initialize keyed table sorts */ $("table").each(function () { var table = $(this); diff --git a/www/js/chart.js b/www/js/chart.js deleted file mode 100644 index ffbe16f3..00000000 --- a/www/js/chart.js +++ /dev/null @@ -1,1426 +0,0 @@ -/*! - * Chart.js - * http://chartjs.org/ - * - * Copyright 2013 Nick Downie - * Released under the MIT license - * https://github.com/nnnick/Chart.js/blob/master/LICENSE.md - */ - -//Define the global Chart Variable as a class. -window.Chart = function(context){ - - var chart = this; - - - //Easing functions adapted from Robert Penner's easing equations - //http://www.robertpenner.com/easing/ - - var animationOptions = { - linear : function (t){ - return t; - }, - easeInQuad: function (t) { - return t*t; - }, - easeOutQuad: function (t) { - return -1 *t*(t-2); - }, - easeInOutQuad: function (t) { - if ((t/=1/2) < 1) return 1/2*t*t; - return -1/2 * ((--t)*(t-2) - 1); - }, - easeInCubic: function (t) { - return t*t*t; - }, - easeOutCubic: function (t) { - return 1*((t=t/1-1)*t*t + 1); - }, - easeInOutCubic: function (t) { - if ((t/=1/2) < 1) return 1/2*t*t*t; - return 1/2*((t-=2)*t*t + 2); - }, - easeInQuart: function (t) { - return t*t*t*t; - }, - easeOutQuart: function (t) { - return -1 * ((t=t/1-1)*t*t*t - 1); - }, - easeInOutQuart: function (t) { - if ((t/=1/2) < 1) return 1/2*t*t*t*t; - return -1/2 * ((t-=2)*t*t*t - 2); - }, - easeInQuint: function (t) { - return 1*(t/=1)*t*t*t*t; - }, - easeOutQuint: function (t) { - return 1*((t=t/1-1)*t*t*t*t + 1); - }, - easeInOutQuint: function (t) { - if ((t/=1/2) < 1) return 1/2*t*t*t*t*t; - return 1/2*((t-=2)*t*t*t*t + 2); - }, - easeInSine: function (t) { - return -1 * Math.cos(t/1 * (Math.PI/2)) + 1; - }, - easeOutSine: function (t) { - return 1 * Math.sin(t/1 * (Math.PI/2)); - }, - easeInOutSine: function (t) { - return -1/2 * (Math.cos(Math.PI*t/1) - 1); - }, - easeInExpo: function (t) { - return (t==0) ? 1 : 1 * Math.pow(2, 10 * (t/1 - 1)); - }, - easeOutExpo: function (t) { - return (t==1) ? 1 : 1 * (-Math.pow(2, -10 * t/1) + 1); - }, - easeInOutExpo: function (t) { - if (t==0) return 0; - if (t==1) return 1; - if ((t/=1/2) < 1) return 1/2 * Math.pow(2, 10 * (t - 1)); - return 1/2 * (-Math.pow(2, -10 * --t) + 2); - }, - easeInCirc: function (t) { - if (t>=1) return t; - return -1 * (Math.sqrt(1 - (t/=1)*t) - 1); - }, - easeOutCirc: function (t) { - return 1 * Math.sqrt(1 - (t=t/1-1)*t); - }, - easeInOutCirc: function (t) { - if ((t/=1/2) < 1) return -1/2 * (Math.sqrt(1 - t*t) - 1); - return 1/2 * (Math.sqrt(1 - (t-=2)*t) + 1); - }, - easeInElastic: function (t) { - var s=1.70158;var p=0;var a=1; - if (t==0) return 0; if ((t/=1)==1) return 1; if (!p) p=1*.3; - if (a < Math.abs(1)) { a=1; var s=p/4; } - else var s = p/(2*Math.PI) * Math.asin (1/a); - return -(a*Math.pow(2,10*(t-=1)) * Math.sin( (t*1-s)*(2*Math.PI)/p )); - }, - easeOutElastic: function (t) { - var s=1.70158;var p=0;var a=1; - if (t==0) return 0; if ((t/=1)==1) return 1; if (!p) p=1*.3; - if (a < Math.abs(1)) { a=1; var s=p/4; } - else var s = p/(2*Math.PI) * Math.asin (1/a); - return a*Math.pow(2,-10*t) * Math.sin( (t*1-s)*(2*Math.PI)/p ) + 1; - }, - easeInOutElastic: function (t) { - var s=1.70158;var p=0;var a=1; - if (t==0) return 0; if ((t/=1/2)==2) return 1; if (!p) p=1*(.3*1.5); - if (a < Math.abs(1)) { a=1; var s=p/4; } - else var s = p/(2*Math.PI) * Math.asin (1/a); - if (t < 1) return -.5*(a*Math.pow(2,10*(t-=1)) * Math.sin( (t*1-s)*(2*Math.PI)/p )); - return a*Math.pow(2,-10*(t-=1)) * Math.sin( (t*1-s)*(2*Math.PI)/p )*.5 + 1; - }, - easeInBack: function (t) { - var s = 1.70158; - return 1*(t/=1)*t*((s+1)*t - s); - }, - easeOutBack: function (t) { - var s = 1.70158; - return 1*((t=t/1-1)*t*((s+1)*t + s) + 1); - }, - easeInOutBack: function (t) { - var s = 1.70158; - if ((t/=1/2) < 1) return 1/2*(t*t*(((s*=(1.525))+1)*t - s)); - return 1/2*((t-=2)*t*(((s*=(1.525))+1)*t + s) + 2); - }, - easeInBounce: function (t) { - return 1 - animationOptions.easeOutBounce (1-t); - }, - easeOutBounce: function (t) { - if ((t/=1) < (1/2.75)) { - return 1*(7.5625*t*t); - } else if (t < (2/2.75)) { - return 1*(7.5625*(t-=(1.5/2.75))*t + .75); - } else if (t < (2.5/2.75)) { - return 1*(7.5625*(t-=(2.25/2.75))*t + .9375); - } else { - return 1*(7.5625*(t-=(2.625/2.75))*t + .984375); - } - }, - easeInOutBounce: function (t) { - if (t < 1/2) return animationOptions.easeInBounce (t*2) * .5; - return animationOptions.easeOutBounce (t*2-1) * .5 + 1*.5; - } - }; - - //Variables global to the chart - var width = context.canvas.width; - var height = context.canvas.height; - - - //High pixel density displays - multiply the size of the canvas height/width by the device pixel ratio, then scale. - if (window.devicePixelRatio) { - context.canvas.style.width = width + "px"; - context.canvas.style.height = height + "px"; - context.canvas.height = height * window.devicePixelRatio; - context.canvas.width = width * window.devicePixelRatio; - context.scale(window.devicePixelRatio, window.devicePixelRatio); - } - - this.PolarArea = function(data,options){ - - chart.PolarArea.defaults = { - scaleOverlay : true, - scaleOverride : false, - scaleSteps : null, - scaleStepWidth : null, - scaleStartValue : null, - scaleShowLine : true, - scaleLineColor : "rgba(0,0,0,.1)", - scaleLineWidth : 1, - scaleShowLabels : true, - scaleLabel : "<%=value%>", - scaleFontFamily : "'Arial'", - scaleFontSize : 12, - scaleFontStyle : "normal", - scaleFontColor : "#666", - scaleShowLabelBackdrop : true, - scaleBackdropColor : "rgba(255,255,255,0.75)", - scaleBackdropPaddingY : 2, - scaleBackdropPaddingX : 2, - segmentShowStroke : true, - segmentStrokeColor : "#fff", - segmentStrokeWidth : 2, - animation : true, - animationSteps : 100, - animationEasing : "easeOutBounce", - animateRotate : true, - animateScale : false, - onAnimationComplete : null - }; - - var config = (options)? mergeChartConfig(chart.PolarArea.defaults,options) : chart.PolarArea.defaults; - - return new PolarArea(data,config,context); - }; - - this.Radar = function(data,options){ - - chart.Radar.defaults = { - scaleOverlay : false, - scaleOverride : false, - scaleSteps : null, - scaleStepWidth : null, - scaleStartValue : null, - scaleShowLine : true, - scaleLineColor : "rgba(0,0,0,.1)", - scaleLineWidth : 1, - scaleShowLabels : false, - scaleLabel : "<%=value%>", - scaleFontFamily : "'Arial'", - scaleFontSize : 12, - scaleFontStyle : "normal", - scaleFontColor : "#666", - scaleShowLabelBackdrop : true, - scaleBackdropColor : "rgba(255,255,255,0.75)", - scaleBackdropPaddingY : 2, - scaleBackdropPaddingX : 2, - angleShowLineOut : true, - angleLineColor : "rgba(0,0,0,.1)", - angleLineWidth : 1, - pointLabelFontFamily : "'Arial'", - pointLabelFontStyle : "normal", - pointLabelFontSize : 12, - pointLabelFontColor : "#666", - pointDot : true, - pointDotRadius : 3, - pointDotStrokeWidth : 1, - datasetStroke : true, - datasetStrokeWidth : 2, - datasetFill : true, - animation : true, - animationSteps : 60, - animationEasing : "easeOutQuart", - onAnimationComplete : null - }; - - var config = (options)? mergeChartConfig(chart.Radar.defaults,options) : chart.Radar.defaults; - - return new Radar(data,config,context); - }; - - this.Pie = function(data,options){ - chart.Pie.defaults = { - segmentShowStroke : true, - segmentStrokeColor : "#fff", - segmentStrokeWidth : 2, - animation : true, - animationSteps : 100, - animationEasing : "easeOutBounce", - animateRotate : true, - animateScale : false, - onAnimationComplete : null - }; - - var config = (options)? mergeChartConfig(chart.Pie.defaults,options) : chart.Pie.defaults; - - return new Pie(data,config,context); - }; - - this.Doughnut = function(data,options){ - - chart.Doughnut.defaults = { - segmentShowStroke : true, - segmentStrokeColor : "#fff", - segmentStrokeWidth : 2, - percentageInnerCutout : 50, - animation : true, - animationSteps : 100, - animationEasing : "easeOutBounce", - animateRotate : true, - animateScale : false, - onAnimationComplete : null - }; - - var config = (options)? mergeChartConfig(chart.Doughnut.defaults,options) : chart.Doughnut.defaults; - - return new Doughnut(data,config,context); - - }; - - this.Line = function(data,options){ - - chart.Line.defaults = { - scaleOverlay : false, - scaleOverride : false, - scaleSteps : null, - scaleStepWidth : null, - scaleStartValue : null, - scaleLineColor : "rgba(0,0,0,.1)", - scaleLineWidth : 1, - scaleShowLabels : true, - scaleLabel : "<%=value%>", - scaleFontFamily : "'Arial'", - scaleFontSize : 12, - scaleFontStyle : "normal", - scaleFontColor : "#666", - scaleShowGridLines : true, - scaleGridLineColor : "rgba(0,0,0,.05)", - scaleGridLineWidth : 1, - bezierCurve : true, - pointDot : true, - pointDotRadius : 4, - pointDotStrokeWidth : 2, - datasetStroke : true, - datasetStrokeWidth : 2, - datasetFill : true, - animation : true, - animationSteps : 60, - animationEasing : "easeOutQuart", - onAnimationComplete : null - }; - var config = (options) ? mergeChartConfig(chart.Line.defaults,options) : chart.Line.defaults; - - return new Line(data,config,context); - } - - this.Bar = function(data,options){ - chart.Bar.defaults = { - scaleOverlay : false, - scaleOverride : false, - scaleSteps : null, - scaleStepWidth : null, - scaleStartValue : null, - scaleLineColor : "rgba(0,0,0,.1)", - scaleLineWidth : 1, - scaleShowLabels : true, - scaleLabel : "<%=value%>", - scaleFontFamily : "'Arial'", - scaleFontSize : 12, - scaleFontStyle : "normal", - scaleFontColor : "#666", - scaleShowGridLines : true, - scaleGridLineColor : "rgba(0,0,0,.05)", - scaleGridLineWidth : 1, - barShowStroke : true, - barStrokeWidth : 2, - barValueSpacing : 5, - barDatasetSpacing : 1, - animation : true, - animationSteps : 60, - animationEasing : "easeOutQuart", - onAnimationComplete : null - }; - var config = (options) ? mergeChartConfig(chart.Bar.defaults,options) : chart.Bar.defaults; - - return new Bar(data,config,context); - } - - var clear = function(c){ - c.clearRect(0, 0, width, height); - }; - - var PolarArea = function(data,config,ctx){ - var maxSize, scaleHop, calculatedScale, labelHeight, scaleHeight, valueBounds, labelTemplateString; - - - calculateDrawingSizes(); - - valueBounds = getValueBounds(); - - labelTemplateString = (config.scaleShowLabels)? config.scaleLabel : null; - - //Check and set the scale - if (!config.scaleOverride){ - - calculatedScale = calculateScale(scaleHeight,valueBounds.maxSteps,valueBounds.minSteps,valueBounds.maxValue,valueBounds.minValue,labelTemplateString); - } - else { - calculatedScale = { - steps : config.scaleSteps, - stepValue : config.scaleStepWidth, - graphMin : config.scaleStartValue, - labels : [] - } - populateLabels(labelTemplateString, calculatedScale.labels,calculatedScale.steps,config.scaleStartValue,config.scaleStepWidth); - } - - scaleHop = maxSize/(calculatedScale.steps); - - //Wrap in an animation loop wrapper - animationLoop(config,drawScale,drawAllSegments,ctx); - - function calculateDrawingSizes(){ - maxSize = (Min([width,height])/2); - //Remove whatever is larger - the font size or line width. - - maxSize -= Max([config.scaleFontSize*0.5,config.scaleLineWidth*0.5]); - - labelHeight = config.scaleFontSize*2; - //If we're drawing the backdrop - add the Y padding to the label height and remove from drawing region. - if (config.scaleShowLabelBackdrop){ - labelHeight += (2 * config.scaleBackdropPaddingY); - maxSize -= config.scaleBackdropPaddingY*1.5; - } - - scaleHeight = maxSize; - //If the label height is less than 5, set it to 5 so we don't have lines on top of each other. - labelHeight = Default(labelHeight,5); - } - function drawScale(){ - for (var i=0; i upperValue) {upperValue = data[i].value;} - if (data[i].value < lowerValue) {lowerValue = data[i].value;} - }; - - var maxSteps = Math.floor((scaleHeight / (labelHeight*0.66))); - var minSteps = Math.floor((scaleHeight / labelHeight*0.5)); - - return { - maxValue : upperValue, - minValue : lowerValue, - maxSteps : maxSteps, - minSteps : minSteps - }; - - - } - } - - var Radar = function (data,config,ctx) { - var maxSize, scaleHop, calculatedScale, labelHeight, scaleHeight, valueBounds, labelTemplateString; - - //If no labels are defined set to an empty array, so referencing length for looping doesn't blow up. - if (!data.labels) data.labels = []; - - calculateDrawingSizes(); - - var valueBounds = getValueBounds(); - - labelTemplateString = (config.scaleShowLabels)? config.scaleLabel : null; - - //Check and set the scale - if (!config.scaleOverride){ - - calculatedScale = calculateScale(scaleHeight,valueBounds.maxSteps,valueBounds.minSteps,valueBounds.maxValue,valueBounds.minValue,labelTemplateString); - } - else { - calculatedScale = { - steps : config.scaleSteps, - stepValue : config.scaleStepWidth, - graphMin : config.scaleStartValue, - labels : [] - } - populateLabels(labelTemplateString, calculatedScale.labels,calculatedScale.steps,config.scaleStartValue,config.scaleStepWidth); - } - - scaleHop = maxSize/(calculatedScale.steps); - - animationLoop(config,drawScale,drawAllDataPoints,ctx); - - //Radar specific functions. - function drawAllDataPoints(animationDecimal){ - var rotationDegree = (2*Math.PI)/data.datasets[0].data.length; - - ctx.save(); - //translate to the centre of the canvas. - ctx.translate(width/2,height/2); - - //We accept multiple data sets for radar charts, so show loop through each set - for (var i=0; i Math.PI){ - ctx.textAlign = "right"; - } - else{ - ctx.textAlign = "left"; - } - - ctx.textBaseline = "middle"; - - ctx.fillText(data.labels[k],opposite,-adjacent); - - } - ctx.restore(); - }; - function calculateDrawingSizes(){ - maxSize = (Min([width,height])/2); - - labelHeight = config.scaleFontSize*2; - - var labelLength = 0; - for (var i=0; ilabelLength) labelLength = textMeasurement; - } - - //Figure out whats the largest - the height of the text or the width of what's there, and minus it from the maximum usable size. - maxSize -= Max([labelLength,((config.pointLabelFontSize/2)*1.5)]); - - maxSize -= config.pointLabelFontSize; - maxSize = CapValue(maxSize, null, 0); - scaleHeight = maxSize; - //If the label height is less than 5, set it to 5 so we don't have lines on top of each other. - labelHeight = Default(labelHeight,5); - }; - function getValueBounds() { - var upperValue = Number.MIN_VALUE; - var lowerValue = Number.MAX_VALUE; - - for (var i=0; i upperValue){upperValue = data.datasets[i].data[j]} - if (data.datasets[i].data[j] < lowerValue){lowerValue = data.datasets[i].data[j]} - } - } - - var maxSteps = Math.floor((scaleHeight / (labelHeight*0.66))); - var minSteps = Math.floor((scaleHeight / labelHeight*0.5)); - - return { - maxValue : upperValue, - minValue : lowerValue, - maxSteps : maxSteps, - minSteps : minSteps - }; - - - } - } - - var Pie = function(data,config,ctx){ - var segmentTotal = 0; - - //In case we have a canvas that is not a square. Minus 5 pixels as padding round the edge. - var pieRadius = Min([height/2,width/2]) - 5; - - for (var i=0; i 0){ - ctx.save(); - ctx.textAlign = "right"; - } - else{ - ctx.textAlign = "center"; - } - ctx.fillStyle = config.scaleFontColor; - for (var i=0; i 0){ - ctx.translate(yAxisPosX + i*valueHop,xAxisPosY + config.scaleFontSize); - ctx.rotate(-(rotateLabels * (Math.PI/180))); - ctx.fillText(data.labels[i], 0,0); - ctx.restore(); - } - - else{ - ctx.fillText(data.labels[i], yAxisPosX + i*valueHop,xAxisPosY + config.scaleFontSize+3); - } - - ctx.beginPath(); - ctx.moveTo(yAxisPosX + i * valueHop, xAxisPosY+3); - - //Check i isnt 0, so we dont go over the Y axis twice. - if(config.scaleShowGridLines && i>0){ - ctx.lineWidth = config.scaleGridLineWidth; - ctx.strokeStyle = config.scaleGridLineColor; - ctx.lineTo(yAxisPosX + i * valueHop, 5); - } - else{ - ctx.lineTo(yAxisPosX + i * valueHop, xAxisPosY+3); - } - ctx.stroke(); - } - - //Y axis - ctx.lineWidth = config.scaleLineWidth; - ctx.strokeStyle = config.scaleLineColor; - ctx.beginPath(); - ctx.moveTo(yAxisPosX,xAxisPosY+5); - ctx.lineTo(yAxisPosX,5); - ctx.stroke(); - - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; - for (var j=0; j longestText)? measuredText : longestText; - } - //Add a little extra padding from the y axis - longestText +=10; - } - xAxisLength = width - longestText - widestXLabel; - valueHop = Math.floor(xAxisLength/(data.labels.length-1)); - - yAxisPosX = width-widestXLabel/2-xAxisLength; - xAxisPosY = scaleHeight + config.scaleFontSize/2; - } - function calculateDrawingSizes(){ - maxSize = height; - - //Need to check the X axis first - measure the length of each text metric, and figure out if we need to rotate by 45 degrees. - ctx.font = config.scaleFontStyle + " " + config.scaleFontSize+"px " + config.scaleFontFamily; - widestXLabel = 1; - for (var i=0; i widestXLabel)? textLength : widestXLabel; - } - if (width/data.labels.length < widestXLabel){ - rotateLabels = 45; - if (width/data.labels.length < Math.cos(rotateLabels) * widestXLabel){ - rotateLabels = 90; - maxSize -= widestXLabel; - } - else{ - maxSize -= Math.sin(rotateLabels) * widestXLabel; - } - } - else{ - maxSize -= config.scaleFontSize; - } - - //Add a little padding between the x line and the text - maxSize -= 5; - - - labelHeight = config.scaleFontSize; - - maxSize -= labelHeight; - //Set 5 pixels greater than the font size to allow for a little padding from the X axis. - - scaleHeight = maxSize; - - //Then get the area above we can safely draw on. - - } - function getValueBounds() { - var upperValue = Number.MIN_VALUE; - var lowerValue = Number.MAX_VALUE; - for (var i=0; i upperValue) { upperValue = data.datasets[i].data[j] }; - if ( data.datasets[i].data[j] < lowerValue) { lowerValue = data.datasets[i].data[j] }; - } - }; - - var maxSteps = Math.floor((scaleHeight / (labelHeight*0.66))); - var minSteps = Math.floor((scaleHeight / labelHeight*0.5)); - - return { - maxValue : upperValue, - minValue : lowerValue, - maxSteps : maxSteps, - minSteps : minSteps - }; - - - } - - - } - - var Bar = function(data,config,ctx){ - var maxSize, scaleHop, calculatedScale, labelHeight, scaleHeight, valueBounds, labelTemplateString, valueHop,widestXLabel, xAxisLength,yAxisPosX,xAxisPosY,barWidth, rotateLabels = 0; - - calculateDrawingSizes(); - - valueBounds = getValueBounds(); - //Check and set the scale - labelTemplateString = (config.scaleShowLabels)? config.scaleLabel : ""; - if (!config.scaleOverride){ - - calculatedScale = calculateScale(scaleHeight,valueBounds.maxSteps,valueBounds.minSteps,valueBounds.maxValue,valueBounds.minValue,labelTemplateString); - } - else { - calculatedScale = { - steps : config.scaleSteps, - stepValue : config.scaleStepWidth, - graphMin : config.scaleStartValue, - labels : [] - } - populateLabels(labelTemplateString, calculatedScale.labels,calculatedScale.steps,config.scaleStartValue,config.scaleStepWidth); - } - - scaleHop = Math.floor(scaleHeight/calculatedScale.steps); - calculateXAxisSize(); - animationLoop(config,drawScale,drawBars,ctx); - - function drawBars(animPc){ - ctx.lineWidth = config.barStrokeWidth; - for (var i=0; i 0){ - ctx.save(); - ctx.textAlign = "right"; - } - else{ - ctx.textAlign = "center"; - } - ctx.fillStyle = config.scaleFontColor; - for (var i=0; i 0){ - ctx.translate(yAxisPosX + i*valueHop,xAxisPosY + config.scaleFontSize); - ctx.rotate(-(rotateLabels * (Math.PI/180))); - ctx.fillText(data.labels[i], 0,0); - ctx.restore(); - } - - else{ - ctx.fillText(data.labels[i], yAxisPosX + i*valueHop + valueHop/2,xAxisPosY + config.scaleFontSize+3); - } - - ctx.beginPath(); - ctx.moveTo(yAxisPosX + (i+1) * valueHop, xAxisPosY+3); - - //Check i isnt 0, so we dont go over the Y axis twice. - ctx.lineWidth = config.scaleGridLineWidth; - ctx.strokeStyle = config.scaleGridLineColor; - ctx.lineTo(yAxisPosX + (i+1) * valueHop, 5); - ctx.stroke(); - } - - //Y axis - ctx.lineWidth = config.scaleLineWidth; - ctx.strokeStyle = config.scaleLineColor; - ctx.beginPath(); - ctx.moveTo(yAxisPosX,xAxisPosY+5); - ctx.lineTo(yAxisPosX,5); - ctx.stroke(); - - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; - for (var j=0; j longestText)? measuredText : longestText; - } - //Add a little extra padding from the y axis - longestText +=10; - } - xAxisLength = width - longestText - widestXLabel; - valueHop = Math.floor(xAxisLength/(data.labels.length)); - - barWidth = (valueHop - config.scaleGridLineWidth*2 - (config.barValueSpacing*2) - (config.barDatasetSpacing*data.datasets.length-1) - ((config.barStrokeWidth/2)*data.datasets.length-1))/data.datasets.length; - - yAxisPosX = width-widestXLabel/2-xAxisLength; - xAxisPosY = scaleHeight + config.scaleFontSize/2; - } - function calculateDrawingSizes(){ - maxSize = height; - - //Need to check the X axis first - measure the length of each text metric, and figure out if we need to rotate by 45 degrees. - ctx.font = config.scaleFontStyle + " " + config.scaleFontSize+"px " + config.scaleFontFamily; - widestXLabel = 1; - for (var i=0; i widestXLabel)? textLength : widestXLabel; - } - if (width/data.labels.length < widestXLabel){ - rotateLabels = 45; - if (width/data.labels.length < Math.cos(rotateLabels) * widestXLabel){ - rotateLabels = 90; - maxSize -= widestXLabel; - } - else{ - maxSize -= Math.sin(rotateLabels) * widestXLabel; - } - } - else{ - maxSize -= config.scaleFontSize; - } - - //Add a little padding between the x line and the text - maxSize -= 5; - - - labelHeight = config.scaleFontSize; - - maxSize -= labelHeight; - //Set 5 pixels greater than the font size to allow for a little padding from the X axis. - - scaleHeight = maxSize; - - //Then get the area above we can safely draw on. - - } - function getValueBounds() { - var upperValue = Number.MIN_VALUE; - var lowerValue = Number.MAX_VALUE; - for (var i=0; i upperValue) { upperValue = data.datasets[i].data[j] }; - if ( data.datasets[i].data[j] < lowerValue) { lowerValue = data.datasets[i].data[j] }; - } - }; - - var maxSteps = Math.floor((scaleHeight / (labelHeight*0.66))); - var minSteps = Math.floor((scaleHeight / labelHeight*0.5)); - - return { - maxValue : upperValue, - minValue : lowerValue, - maxSteps : maxSteps, - minSteps : minSteps - }; - - - } - } - - function calculateOffset(val,calculatedScale,scaleHop){ - var outerValue = calculatedScale.steps * calculatedScale.stepValue; - var adjustedValue = val - calculatedScale.graphMin; - var scalingFactor = CapValue(adjustedValue/outerValue,1,0); - return (scaleHop*calculatedScale.steps) * scalingFactor; - } - - function animationLoop(config,drawScale,drawData,ctx){ - var animFrameAmount = (config.animation)? 1/CapValue(config.animationSteps,Number.MAX_VALUE,1) : 1, - easingFunction = animationOptions[config.animationEasing], - percentAnimComplete =(config.animation)? 0 : 1; - - - - if (typeof drawScale !== "function") drawScale = function(){}; - - requestAnimFrame(animLoop); - - function animateFrame(){ - var easeAdjustedAnimationPercent =(config.animation)? CapValue(easingFunction(percentAnimComplete),null,0) : 1; - clear(ctx); - if(config.scaleOverlay){ - drawData(easeAdjustedAnimationPercent); - drawScale(); - } else { - drawScale(); - drawData(easeAdjustedAnimationPercent); - } - } - function animLoop(){ - //We need to check if the animation is incomplete (less than 1), or complete (1). - percentAnimComplete += animFrameAmount; - animateFrame(); - //Stop the loop continuing forever - if (percentAnimComplete <= 1){ - requestAnimFrame(animLoop); - } - else{ - if (typeof config.onAnimationComplete == "function") config.onAnimationComplete(); - } - - } - - } - - //Declare global functions to be called within this namespace here. - - - // shim layer with setTimeout fallback - var requestAnimFrame = (function(){ - return window.requestAnimationFrame || - window.webkitRequestAnimationFrame || - window.mozRequestAnimationFrame || - window.oRequestAnimationFrame || - window.msRequestAnimationFrame || - function(callback) { - window.setTimeout(callback, 1000 / 60); - }; - })(); - - function calculateScale(drawingHeight,maxSteps,minSteps,maxValue,minValue,labelTemplateString){ - var graphMin,graphMax,graphRange,stepValue,numberOfSteps,valueRange,rangeOrderOfMagnitude,decimalNum; - - valueRange = maxValue - minValue; - - rangeOrderOfMagnitude = calculateOrderOfMagnitude(valueRange); - - graphMin = Math.floor(minValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude); - - graphMax = Math.ceil(maxValue / (1 * Math.pow(10, rangeOrderOfMagnitude))) * Math.pow(10, rangeOrderOfMagnitude); - - graphRange = graphMax - graphMin; - - stepValue = Math.pow(10, rangeOrderOfMagnitude); - - numberOfSteps = Math.round(graphRange / stepValue); - - //Compare number of steps to the max and min for that size graph, and add in half steps if need be. - while(numberOfSteps < minSteps || numberOfSteps > maxSteps) { - if (numberOfSteps < minSteps){ - stepValue /= 2; - numberOfSteps = Math.round(graphRange/stepValue); - } - else{ - stepValue *=2; - numberOfSteps = Math.round(graphRange/stepValue); - } - }; - - var labels = []; - populateLabels(labelTemplateString, labels, numberOfSteps, graphMin, stepValue); - - return { - steps : numberOfSteps, - stepValue : stepValue, - graphMin : graphMin, - labels : labels - - } - - function calculateOrderOfMagnitude(val){ - return Math.floor(Math.log(val) / Math.LN10); - } - - - } - - //Populate an array of all the labels by interpolating the string. - function populateLabels(labelTemplateString, labels, numberOfSteps, graphMin, stepValue) { - if (labelTemplateString) { - //Fix floating point errors by setting to fixed the on the same decimal as the stepValue. - for (var i = 1; i < numberOfSteps + 1; i++) { - labels.push(tmpl(labelTemplateString, {value: (graphMin + (stepValue * i)).toFixed(getDecimalPlaces(stepValue))})); - } - } - } - - //Max value from array - function Max( array ){ - return Math.max.apply( Math, array ); - }; - //Min value from array - function Min( array ){ - return Math.min.apply( Math, array ); - }; - //Default if undefined - function Default(userDeclared,valueIfFalse){ - if(!userDeclared){ - return valueIfFalse; - } else { - return userDeclared; - } - }; - //Is a number function - function isNumber(n) { - return !isNaN(parseFloat(n)) && isFinite(n); - } - //Apply cap a value at a high or low number - function CapValue(valueToCap, maxValue, minValue){ - if(isNumber(maxValue)) { - if( valueToCap > maxValue ) { - return maxValue; - } - } - if(isNumber(minValue)){ - if ( valueToCap < minValue ){ - return minValue; - } - } - return valueToCap; - } - function getDecimalPlaces (num){ - var numberOfDecimalPlaces; - if (num%1!=0){ - return num.toString().split(".")[1].length - } - else{ - return 0; - } - - } - - function mergeChartConfig(defaults,userDefined){ - var returnObj = {}; - for (var attrname in defaults) { returnObj[attrname] = defaults[attrname]; } - for (var attrname in userDefined) { returnObj[attrname] = userDefined[attrname]; } - return returnObj; - } - - //Javascript micro templating by John Resig - source at http://ejohn.org/blog/javascript-micro-templating/ - var cache = {}; - - function tmpl(str, data){ - // Figure out if we're getting a template, or if we need to - // load the template - and be sure to cache the result. - var fn = !/\W/.test(str) ? - cache[str] = cache[str] || - tmpl(document.getElementById(str).innerHTML) : - - // Generate a reusable function that will serve as a template - // generator (and which will be cached). - new Function("obj", - "var p=[],print=function(){p.push.apply(p,arguments);};" + - - // Introduce the data as local variables using with(){} - "with(obj){p.push('" + - - // Convert the template into pure JavaScript - str - .replace(/[\r\t\n]/g, " ") - .split("<%").join("\t") - .replace(/((^|%>)[^\t]*)'/g, "$1\r") - .replace(/\t=(.*?)%>/g, "',$1,'") - .split("\t").join("');") - .split("%>").join("p.push('") - .split("\r").join("\\'") - + "');}return p.join('');"); - - // Provide some basic currying to the user - return data ? fn( data ) : fn; - }; -} - -