lib/BaseGauge.js
/*!
* The MIT License (MIT)
*
* Copyright (c) 2016 Mykhailo Stadnyk <[email protected]>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
require('./polyfill');
const SmartCanvas = require('./SmartCanvas');
const Animation = require('./Animation');
const Collection = require('./Collection');
const DomObserver = require('./DomObserver');
const EventEmitter = require('./EventEmitter');
const version = '%VERSION%';
const round = Math.round;
const abs = Math.abs;
let gauges = new Collection();
gauges.version = version;
/**
* Basic abstract BaseGauge class implementing common functionality
* for different type of gauges.
*
* It should not be instantiated directly but must be extended by a final
* gauge implementation.
*
* @abstract
* @example
*
* class MyCoolGauge extends BaseGauge {
*
* // theses methods below MUST be implemented:
*
* constructor(options) {
* // ... do something with options
* super(options);
* // ... implement anything else
* }
*
* draw() {
* // ... some implementation here
* return this;
* }
* }
*/
export default class BaseGauge extends EventEmitter {
/**
* Fired each time gauge is initialized on a page
*
* @event BaseGauge#init
*/
/**
* Fired each time gauge scene is rendered
*
* @event BaseGauge#render
*/
/**
* Fired each time gauge object is destroyed
*
* @event BaseGauge#destroy
*/
/**
* Fired each time before animation is started on the gauge
*
* @event BaseGauge#animationStart
*/
/**
* Fired each time animation scene is complete
*
* @event BaseGauge#animate
* @type {number} percent
* @type {number} value
*/
/**
* Fired each time animation is complete on the gauge
*
* @event BaseGauge#animationEnd
*/
/**
* @event BaseGauge#value
* @type {number} newValue
* @type {number} oldValue
*/
/**
* @constructor
* @abstract
* @param {GenericOptions} options
*/
constructor(options) {
super();
let className = this.constructor.name;
if (className === 'BaseGauge') {
throw new TypeError('Attempt to instantiate abstract class!');
}
gauges.push(this);
if (options.listeners) {
Object.keys(options.listeners).forEach(event => {
let handlers = options.listeners[event] instanceof Array ?
options.listeners[event] : [options.listeners[event]];
handlers.forEach(handler => {
this.on(event, handler);
});
});
}
//noinspection JSUnresolvedVariable
/**
* Gauges version string
*
* @type {string}
*/
this.version = version;
/**
* Gauge type class
*
* @type {BaseGauge} type
*/
this.type = ns[className] || BaseGauge;
/**
* True if gauge has been drawn for the first time, false otherwise.
*
* @type {boolean}
*/
this.initialized = false;
options.minValue = parseFloat(options.minValue);
options.maxValue = parseFloat(options.maxValue);
options.value = parseFloat(options.value) || 0;
if (!options.borders) {
options.borderInnerWidth = options.borderMiddleWidth =
options.borderOuterWidth = 0;
}
if (!options.renderTo) {
throw TypeError('Canvas element was not specified when creating ' +
'the Gauge object!');
}
let canvas = options.renderTo.tagName ?
options.renderTo :
/* istanbul ignore next: to be tested with e2e tests */
document.getElementById(options.renderTo);
if (!(canvas instanceof HTMLCanvasElement)) {
throw TypeError('Given gauge canvas element is invalid!');
}
options.width = parseFloat(options.width) || 0;
options.height = parseFloat(options.height) || 0;
if (!options.width || !options.height) {
if (!options.width) options.width = canvas.parentNode ?
canvas.parentNode.offsetWidth : canvas.offsetWidth;
if (!options.height) options.height = canvas.parentNode ?
canvas.parentNode.offsetHeight : canvas.offsetHeight;
}
/**
* Gauge options
*
* @type {GenericOptions} options
*/
this.options = options || {};
if (this.options.animateOnInit) {
this._value = this.options.value;
this.options.value = this.options.minValue;
}
/**
* @type {SmartCanvas} canvas
*/
this.canvas = new SmartCanvas(canvas, options.width, options.height);
this.canvas.onRedraw = this.draw.bind(this);
/**
* @type {Animation} animation
*/
this.animation = new Animation(
options.animationRule,
options.animationDuration);
}
/**
* Sets new value for this gauge.
* If gauge is animated by configuration it will trigger a proper animation.
* Upsetting a value triggers gauge redraw.
*
* @param {number} value
*/
set value(value) {
value = BaseGauge.ensureValue(value, this.options.minValue);
let fromValue = this.options.value;
if (value === fromValue) {
return ;
}
if (this.options.animation) {
if (this.animation.frame) {
// animation is already in progress -
// forget related old animation value
// @see https://github.com/Mikhus/canvas-gauges/issues/94
this.options.value = this._value;
// if there is no actual value change requested stop it
if (this._value === value) {
this.animation.cancel();
delete this._value;
return ;
}
}
/**
* @type {number}
* @access private
*/
if (this._value === undefined) {
this._value = value;
}
this.emit('animationStart');
this.animation.animate(percent => {
let newValue = fromValue + (value - fromValue) * percent;
this.options.animatedValue &&
this.emit('value', newValue, this.value);
this.options.value = newValue;
this.draw();
this.emit('animate', percent, this.options.value);
}, () => {
if (this._value !== undefined) {
this.emit('value', this._value, this.value);
this.options.value = this._value;
delete this._value;
}
this.draw();
this.emit('animationEnd');
});
}
else {
this.emit('value', value, this.value);
this.options.value = value;
this.draw();
}
}
/**
* Returns current value of the gauge
*
* @return {number}
*/
get value() {
return typeof this._value === 'undefined' ?
this.options.value : this._value;
}
/**
* Updates gauge options
*
* @param {*} options
* @return {BaseGauge}
* @access protected
*/
static configure(options) {
return options;
}
/**
* Updates gauge configuration options at runtime and redraws the gauge
*
* @param {RadialGaugeOptions} options
* @returns {BaseGauge}
*/
update(options) {
Object.assign(this.options, this.type.configure(options || {}));
this.canvas.width = this.options.width;
this.canvas.height = this.options.height;
this.animation.rule = this.options.animationRule;
this.animation.duration = this.options.animationDuration;
this.canvas.redraw();
return this;
}
/**
* Performs destruction of this object properly
*/
destroy() {
let index = gauges.indexOf(this);
/* istanbul ignore else */
if (~index) {
//noinspection JSUnresolvedFunction
gauges.splice(index, 1);
}
this.canvas.destroy();
this.canvas = null;
this.animation.destroy();
this.animation = null;
this.emit('destroy');
}
/**
* Returns gauges version string
*
* @return {string}
*/
static get version() {
return version;
}
/**
* Triggering gauge render on a canvas.
*
* @abstract
* @returns {BaseGauge}
*/
draw() {
if (this.options.animateOnInit && !this.initialized) {
this.value = this._value;
this.initialized = true;
this.emit('init');
}
this.emit('render');
return this;
}
/**
* Inject given gauge object into DOM
*
* @param {string} type
* @param {GenericOptions} options
*/
static initialize(type, options) {
return new DomObserver(options, 'canvas', type);
}
/**
* Initializes gauge from a given HTML element
* (given element should be valid HTML canvas gauge definition)
*
* @param {HTMLElement} element
*/
static fromElement(element) {
let type = DomObserver.toCamelCase(element.getAttribute('data-type'));
let attributes = element.attributes;
let i = 0;
let s = attributes.length;
let options = {};
if (!type) {
return ;
}
if (!/Gauge$/.test(type)) {
type += 'Gauge';
}
for (; i < s; i++) {
options[
DomObserver.toCamelCase(
attributes[i].name.replace(/^data-/, ''),
false)
] = DomObserver.parse(attributes[i].value);
}
new DomObserver(options, element.tagName, type).process(element);
}
/**
* Ensures value is proper number
*
* @param {*} value
* @param {number} min
* @return {number}
*/
static ensureValue(value, min = 0) {
value = parseFloat(value);
if (isNaN(value) || !isFinite(value)) {
value = parseFloat(min) || 0;
}
return value;
}
/**
* Corrects javascript modulus bug
* @param {number} n
* @param {number} m
* @return {number}
*/
static mod(n,m) {
return ((n % m) + m) % m;
}
}
/**
* @ignore
* @typedef {object} ns
*/
/* istanbul ignore if */
if (typeof ns !== 'undefined') {
ns['BaseGauge'] = BaseGauge;
ns['gauges'] = (window.document || {})['gauges'] = gauges;
}
module.exports = BaseGauge;