;(function (w, undefined) { 'use strict'; /** * Create a new element. * * @param {String} name Element type name. * * @return {Element} */ function newEl(name) { return document.createElement(name); } /** * Apply theme CSS properties to element. * * @param {Element} element DOM element. * @param {Object} theme Theme object. * * @return {Element} */ function applyTheme(element, theme) { for (var name in theme) { try { element.style[name] = theme[name]; } catch (e) {} } return element; } /** * Return type of the value. * * @param {Mixed} value * * @return {String} */ function type(value) { if (value == null) { return String(value); } if (typeof value === 'object' || typeof value === 'function') { return (value instanceof w.NodeList && 'nodelist') || (value instanceof w.HTMLCollection && 'htmlcollection') || Object.prototype.toString.call(value).match(/\s([a-z]+)/i)[1].toLowerCase(); } return typeof value; } /** * Check whether the value is in an array. * * @param {Mixed} value * @param {Array} array * * @return {Integer} Array index or -1 when not found. */ function inArray(value, array) { if (type(array) !== 'array') { return -1; } if (array.indexOf) { return array.indexOf(value); } for (var i = 0, l = array.length; i < l; i++) { if (array[i] === value) { return i; } } return -1; } /** * Poor man's deep object extend. * * Example: * extend({}, defaults, options); * * @return {Void} */ function extend() { var args = arguments; for (var key in args[1]) { if (args[1].hasOwnProperty(key)) { switch (type(args[1][key])) { case 'object': if (type(args[0][key]) === 'object') { extend(args[0][key], args[1][key]); } else { args[0][key] = extend({}, args[1][key]); } break; case 'array': args[0][key] = args[1][key].slice(0); break; default: args[0][key] = args[1][key]; } } } return args.length > 2 ? extend.apply(null, [args[0]].concat(Array.prototype.slice.call(args, 2))) : args[0]; } /** * Convert HSL color to HEX string. * * @param {Array} hsl Array with [hue, saturation, lightness]. * * @return {Array} Array with [red, green, blue]. */ function hslToHex(h, s, l) { var r, g, b; var v, min, sv, sextant, fract, vsf; if (l <= 0.5) { v = l * (1 + s); } else { v = l + s - l * s; } if (v === 0) { return '#000'; } else { min = 2 * l - v; sv = (v - min) / v; h = 6 * h; sextant = Math.floor(h); fract = h - sextant; vsf = v * sv * fract; if (sextant === 0 || sextant === 6) { r = v; g = min + vsf; b = min; } else if (sextant === 1) { r = v - vsf; g = v; b = min; } else if (sextant === 2) { r = min; g = v; b = min + vsf; } else if (sextant === 3) { r = min; g = v - vsf; b = v; } else if (sextant === 4) { r = min + vsf; g = min; b = v; } else { r = v; g = min; b = v - vsf; } return '#' + componentToHex(r) + componentToHex(g) + componentToHex(b); } } /** * Helper function for hslToHex. */ function componentToHex(c) { c = Math.round(c * 255).toString(16); return c.length === 1 ? '0' + c : c; } /** * Manage element event listeners. * * @param {Node} element * @param {Event} eventName * @param {Function} handler * @param {Bool} remove * * @return {Void} */ function listener(element, eventName, handler, remove) { if (element.addEventListener) { element[remove ? 'removeEventListener' : 'addEventListener'](eventName, handler, false); } else if (element.attachEvent) { element[remove ? 'detachEvent' : 'attachEvent']('on' + eventName, handler); } } // Preferred timing funtion var getTime; (function () { var perf = w.performance; if (perf && (perf.now || perf.webkitNow)) { var perfNow = perf.now ? 'now' : 'webkitNow'; getTime = perf[perfNow].bind(perf); } else { getTime = function () { return +new Date(); }; } }()); // Local WindowAnimationTiming interface polyfill var cAF = w.cancelAnimationFrame || w.cancelRequestAnimationFrame; var rAF = w.requestAnimationFrame; (function () { var vendors = ['moz', 'webkit', 'o']; var lastTime = 0; // For a more accurate WindowAnimationTiming interface implementation, ditch the native // requestAnimationFrame when cancelAnimationFrame is not present (older versions of Firefox) for (var i = 0, l = vendors.length; i < l && !cAF; ++i) { cAF = w[vendors[i]+'CancelAnimationFrame'] || w[vendors[i]+'CancelRequestAnimationFrame']; rAF = cAF && w[vendors[i]+'RequestAnimationFrame']; } if (!cAF) { rAF = function (callback) { var currTime = getTime(); var timeToCall = Math.max(0, 16 - (currTime - lastTime)); lastTime = currTime + timeToCall; return w.setTimeout(function () { callback(currTime + timeToCall); }, timeToCall); }; cAF = function (id) { clearTimeout(id); }; } }()); // Property name for assigning element text content var textProp = type(document.createElement('div').textContent) === 'string' ? 'textContent' : 'innerText'; /** * FPSMeter class. * * @param {Element} anchor Element to append the meter to. Default is document.body. * @param {Object} options Object with options. */ function FPSMeter(anchor, options) { // Optional arguments if (type(anchor) === 'object' && anchor.nodeType === undefined) { options = anchor; anchor = document.body; } if (!anchor) { anchor = document.body; } // Private properties var self = this; var o = extend({}, FPSMeter.defaults, options || {}); var el = {}; var cols = []; var theme, heatmaps; var heatDepth = 100; var heating = []; var thisFrameTime = 0; var frameTime = o.threshold; var frameStart = 0; var lastLoop = getTime() - frameTime; var time; var fpsHistory = []; var durationHistory = []; var frameID, renderID; var showFps = o.show === 'fps'; var graphHeight, count, i, j; // Exposed properties self.options = o; self.fps = 0; self.duration = 0; self.isPaused = 0; /** * Tick start for measuring the actual rendering duration. * * @return {Void} */ self.tickStart = function () { frameStart = getTime(); }; /** * FPS tick. * * @return {Void} */ self.tick = function () { time = getTime(); thisFrameTime = time - lastLoop; frameTime += (thisFrameTime - frameTime) / o.smoothing; self.fps = 1000 / frameTime; self.duration = frameStart < lastLoop ? frameTime : time - frameStart; lastLoop = time; }; /** * Pause display rendering. * * @return {Object} FPSMeter instance. */ self.pause = function () { if (frameID) { self.isPaused = 1; clearTimeout(frameID); cAF(frameID); cAF(renderID); frameID = renderID = 0; } return self; }; /** * Resume display rendering. * * @return {Object} FPSMeter instance. */ self.resume = function () { if (!frameID) { self.isPaused = 0; requestRender(); } return self; }; /** * Update options. * * @param {String} name Option name. * @param {Mixed} value New value. * * @return {Object} FPSMeter instance. */ self.set = function (name, value) { o[name] = value; showFps = o.show === 'fps'; // Rebuild or reposition elements when specific option has been updated if (inArray(name, rebuilders) !== -1) { createMeter(); } if (inArray(name, repositioners) !== -1) { positionMeter(); } return self; }; /** * Change meter into rendering duration mode. * * @return {Object} FPSMeter instance. */ self.showDuration = function () { self.set('show', 'ms'); return self; }; /** * Change meter into FPS mode. * * @return {Object} FPSMeter instance. */ self.showFps = function () { self.set('show', 'fps'); return self; }; /** * Toggles between show: 'fps' and show: 'duration'. * * @return {Object} FPSMeter instance. */ self.toggle = function () { self.set('show', showFps ? 'ms' : 'fps'); return self; }; /** * Hide the FPSMeter. Also pauses the rendering. * * @return {Object} FPSMeter instance. */ self.hide = function () { self.pause(); el.container.style.display = 'none'; return self; }; /** * Show the FPSMeter. Also resumes the rendering. * * @return {Object} FPSMeter instance. */ self.show = function () { self.resume(); el.container.style.display = 'block'; return self; }; /** * Check the current FPS and save it in history. * * @return {Void} */ function historyTick() { for (i = o.history; i--;) { fpsHistory[i] = i === 0 ? self.fps : fpsHistory[i-1]; durationHistory[i] = i === 0 ? self.duration : durationHistory[i-1]; } } /** * Returns heat hex color based on values passed. * * @param {Integer} heatmap * @param {Integer} value * @param {Integer} min * @param {Integer} max * * @return {Integer} */ function getHeat(heatmap, value, min, max) { return heatmaps[0|heatmap][Math.round(Math.min((value - min) / (max - min) * heatDepth, heatDepth))]; } /** * Update counter number and legend. * * @return {Void} */ function updateCounter() { // Update legend only when changed if (el.legend.fps !== showFps) { el.legend.fps = showFps; el.legend[textProp] = showFps ? 'FPS' : 'ms'; } // Update counter with a nicely formated & readable number count = showFps ? self.fps : self.duration; el.count[textProp] = count > 999 ? '999+' : count.toFixed(count > 99 ? 0 : o.decimals); } /** * Render current FPS state. * * @return {Void} */ function render() { time = getTime(); // If renderer stopped reporting, do a simulated drop to 0 fps if (lastLoop < time - o.threshold) { self.fps -= self.fps / Math.max(1, o.smoothing * 60 / o.interval); self.duration = 1000 / self.fps; } historyTick(); updateCounter(); // Apply heat to elements if (o.heat) { if (heating.length) { for (i = heating.length; i--;) { heating[i].el.style[theme[heating[i].name].heatOn] = showFps ? getHeat(theme[heating[i].name].heatmap, self.fps, 0, o.maxFps) : getHeat(theme[heating[i].name].heatmap, self.duration, o.threshold, 0); } } if (el.graph && theme.column.heatOn) { for (i = cols.length; i--;) { cols[i].style[theme.column.heatOn] = showFps ? getHeat(theme.column.heatmap, fpsHistory[i], 0, o.maxFps) : getHeat(theme.column.heatmap, durationHistory[i], o.threshold, 0); } } } // Update graph columns height if (el.graph) { for (j = 0; j < o.history; j++) { cols[j].style.height = (showFps ? (fpsHistory[j] ? Math.round(graphHeight / o.maxFps * Math.min(fpsHistory[j], o.maxFps)) : 0) : (durationHistory[j] ? Math.round(graphHeight / o.threshold * Math.min(durationHistory[j], o.threshold)) : 0) ) + 'px'; } } } /** * Request rendering loop. * * @return {Int} Animation frame index. */ function requestRender() { if (o.interval < 20) { frameID = rAF(requestRender); render(); } else { frameID = setTimeout(requestRender, o.interval); renderID = rAF(render); } } /** * Meter events handler. * * @return {Void} */ function eventHandler(event) { event = event || window.event; if (event.preventDefault) { event.preventDefault(); event.stopPropagation(); } else { event.returnValue = false; event.cancelBubble = true; } self.toggle(); } /** * Destroys the current FPSMeter instance. * * @return {Void} */ self.destroy = function () { // Stop rendering self.pause(); // Remove elements removeMeter(); // Stop listening self.tick = self.tickStart = function () {}; }; /** * Remove meter element. * * @return {Void} */ function removeMeter() { // Unbind listeners if (o.toggleOn) { listener(el.container, o.toggleOn, eventHandler, 1); } // Detach element anchor.removeChild(el.container); } /** * Sets the theme, and generates heatmaps when needed. */ function setTheme() { theme = FPSMeter.theme[o.theme]; // Generate heatmaps heatmaps = theme.compiledHeatmaps || []; if (!heatmaps.length && theme.heatmaps.length) { for (j = 0; j < theme.heatmaps.length; j++) { heatmaps[j] = []; for (i = 0; i <= heatDepth; i++) { heatmaps[j][i] = hslToHex(0.33 / heatDepth * i, theme.heatmaps[j].saturation, theme.heatmaps[j].lightness); } } theme.compiledHeatmaps = heatmaps; } } /** * Creates and attaches the meter element. * * @return {Void} */ function createMeter() { // Remove old meter if present if (el.container) { removeMeter(); } // Set theme setTheme(); // Create elements el.container = applyTheme(newEl('div'), theme.container); el.count = el.container.appendChild(applyTheme(newEl('div'), theme.count)); el.legend = el.container.appendChild(applyTheme(newEl('div'), theme.legend)); el.graph = o.graph ? el.container.appendChild(applyTheme(newEl('div'), theme.graph)) : 0; // Add elements to heating array heating.length = 0; for (var key in el) { if (el[key] && theme[key].heatOn) { heating.push({ name: key, el: el[key] }); } } // Graph cols.length = 0; if (el.graph) { // Create graph el.graph.style.width = (o.history * theme.column.width + (o.history - 1) * theme.column.spacing) + 'px'; // Add columns for (i = 0; i < o.history; i++) { cols[i] = el.graph.appendChild(applyTheme(newEl('div'), theme.column)); cols[i].style.position = 'absolute'; cols[i].style.bottom = 0; cols[i].style.right = (i * theme.column.width + i * theme.column.spacing) + 'px'; cols[i].style.width = theme.column.width + 'px'; cols[i].style.height = '0px'; } } // Set the initial state positionMeter(); updateCounter(); // Append container to anchor anchor.appendChild(el.container); // Retrieve graph height after it was appended to DOM if (el.graph) { graphHeight = el.graph.clientHeight; } // Add event listeners if (o.toggleOn) { if (o.toggleOn === 'click') { el.container.style.cursor = 'pointer'; } listener(el.container, o.toggleOn, eventHandler); } } /** * Positions the meter based on options. * * @return {Void} */ function positionMeter() { applyTheme(el.container, o); } /** * Construct. */ (function () { // Create meter element createMeter(); // Start rendering requestRender(); }()); } // Expose the extend function FPSMeter.extend = extend; // Expose the FPSMeter class window.FPSMeter = FPSMeter; // Default options FPSMeter.defaults = { interval: 100, // Update interval in milliseconds. smoothing: 10, // Spike smoothing strength. 1 means no smoothing. show: 'fps', // Whether to show 'fps', or 'ms' = frame duration in milliseconds. toggleOn: 'click', // Toggle between show 'fps' and 'ms' on this event. decimals: 1, // Number of decimals in FPS number. 1 = 59.9, 2 = 59.94, ... maxFps: 60, // Max expected FPS value. threshold: 100, // Minimal tick reporting interval in milliseconds. // Meter position position: 'absolute', // Meter position. zIndex: 10, // Meter Z index. left: '5px', // Meter left offset. top: '5px', // Meter top offset. right: 'auto', // Meter right offset. bottom: 'auto', // Meter bottom offset. margin: '0 0 0 0', // Meter margin. Helps with centering the counter when left: 50%; // Theme theme: 'dark', // Meter theme. Build in: 'dark', 'light', 'transparent', 'colorful'. heat: 0, // Allow themes to use coloring by FPS heat. 0 FPS = red, maxFps = green. // Graph graph: 0, // Whether to show history graph. history: 20 // How many history states to show in a graph. }; // Option names that trigger FPSMeter rebuild or reposition when modified var rebuilders = [ 'toggleOn', 'theme', 'heat', 'graph', 'history' ]; var repositioners = [ 'position', 'zIndex', 'left', 'top', 'right', 'bottom', 'margin' ]; }(window));