/**
 * Displays a value within the given interval as a gauge. For example:
 *
 *     @example
 *     Ext.create({
 *         xtype: 'panel',
 *         renderTo: document.body,
 *         width: 200,
 *         height: 200,
 *         layout: 'fit',
 *         items: {
 *             xtype: 'gauge',
 *             padding: 20,
 *             value: 55,
 *             minValue: 40,
 *             maxValue: 80
 *         }
 *     });
 *
 * It's also possible to use gauges to create loading indicators:
 *
 *     @example
 *     Ext.create({
 *         xtype: 'panel',
 *         renderTo: document.body,
 *         width: 200,
 *         height: 200,
 *         layout: 'fit',
 *         items: {
 *             xtype: 'gauge',
 *             padding: 20,
 *             trackStart: 0,
 *             trackLength: 360,
 *             value: 20,
 *             valueStyle: {
 *                 round: true
 *             },
 *             textTpl: 'Loading...',
 *             animation: {
 *                 easing: 'linear',
 *                 duration: 100000
 *             }
 *         }
 *     }).items.first().setAngleOffset(360 * 100);
 * 
 * Gauges can contain needles as well.
 * 
 *      @example
 *      Ext.create({
 *         xtype: 'panel',
 *         renderTo: document.body,
 *         width: 200,
 *         height: 200,
 *         layout: 'fit',
 *         items: {
 *             xtype: 'gauge',
 *             padding: 20,
 *             value: 55,
 *             minValue: 40,
 *             maxValue: 80,
 *             needle: 'wedge'
 *         }
 *     });
 * 
 */
Ext.define('Ext.ux.gauge.Gauge', {
    alternateClassName: 'Ext.ux.Gauge',
    extend: 'Ext.Gadget',
    xtype: 'gauge',
 
    requires: [
        'Ext.ux.gauge.needle.Abstract',
        'Ext.util.Region'
    ],
 
    config: {
        /**
         * @cfg {Number/String} padding
         * Gauge sector padding in pixels or percent of width/height, whichever is smaller.
         */
        padding: 10,
 
        /**
         * @cfg {Number} trackStart
         * The angle in the [0, 360) interval at which the gauge's track sector starts.
         * E.g. 0 for 3 o-clock, 90 for 6 o-clock, 180 for 9 o-clock, 270 for noon.
         */
        trackStart: 135,
 
        /**
         * @cfg {Number} trackLength
         * The angle in the (0, 360] interval to add to the {@link #trackStart} angle
         * to determine the angle at which the track ends.
         */
        trackLength: 270,
 
        /**
         * @cfg {Number} angleOffset
         * The angle at which the {@link #minValue} starts in case of a circular gauge.
         */
        angleOffset: 0,
 
        /**
         * @cfg {Number} minValue
         * The minimum value that the gauge can represent.
         */
        minValue: 0,
 
        /**
         * @cfg {Number} maxValue
         * The maximum value that the gauge can represent.
         */
        maxValue: 100,
 
        /**
         * @cfg {Number} value
         * The current value of the gauge.
         */
        value: 50,
 
        /**
         * @cfg {Ext.ux.gauge.needle.Abstract} needle
         * A config object for the needle to be used by the gauge.
         * The needle will track the current {@link #value}.
         * The default needle type is 'diamond', so if a config like
         *
         *     needle: {
         *         outerRadius: '100%'
         *     }
         *
         * is used, the app/view still has to require
         * the `Ext.ux.gauge.needle.Diamond` class.
         * If a type is specified explicitly
         *
         *     needle: {
         *         type: 'arrow'
         *     }
         *
         * it's straightforward which class should be required.
         */
        needle: null,
 
        needleDefaults: {
            cached: true,
            $value: {
                type: 'diamond'
            }
        },
 
        /**
         * @cfg {Boolean} [clockwise=true]
         * `true` - {@link #cfg!value} increments in a clockwise fashion
         * `false` - {@link #cfg!value} increments in an anticlockwise fashion
         */
        clockwise: true,
 
        /**
         * @cfg {Ext.XTemplate} textTpl
         * The template for the text in the center of the gauge.
         * The available data values are:
         * - `value` - The {@link #cfg!value} of the gauge.
         * - `percent` - The value as a percentage between 0 and 100.
         * - `minValue` - The value of the {@link #cfg!minValue} config.
         * - `maxValue` - The value of the {@link #cfg!maxValue} config.
         * - `delta` - The delta between the {@link #cfg!minValue} and {@link #cfg!maxValue}.
         */
        textTpl: ['<tpl>{value:number("0.00")}%</tpl>'],
 
        /**
         * @cfg {String} [textAlign='c-c']
         * If the gauge has a donut hole, the text will be centered inside it.
         * Otherwise, the text will be centered in the middle of the gauge's
         * bounding box. This config allows to alter the position of the text
         * in the latter case. See the docs for the `align` option to the
         * {@link Ext.util.Region#alignTo} method for possible ways of alignment
         * of the text to the guage's bounding box.
         */
        textAlign: 'c-c',
 
        /**
         * @cfg {Object} textOffset
         * This config can be used to displace the {@link #textTpl text} from its default
         * position in the center of the gauge by providing values for horizontal and
         * vertical displacement.
         * @cfg {Number} textOffset.dx Horizontal displacement.
         * @cfg {Number} textOffset.dy Vertical displacement.
         */
        textOffset: {
            dx: 0,
            dy: 0
        },
 
        /**
         * @cfg {Object} trackStyle
         * Track sector styles.
         * @cfg {String/Object[]} trackStyle.fill Track sector fill color. Defaults to CSS value.
         * It's also possible to have a linear gradient fill that starts at the top-left corner
         * of the gauge and ends at its bottom-right corner, by providing an array of color stop
         * objects. For example:
         *
         *     trackStyle: {
         *         fill: [{
         *             offset: 0,
         *             color: 'green',
         *             opacity: 0.8
         *         }, {
         *             offset: 1,
         *             color: 'gold'
         *         }]
         *     }
         *
         * @cfg {Number} trackStyle.fillOpacity Track sector fill opacity. Defaults to CSS value.
         * @cfg {String} trackStyle.stroke Track sector stroke color. Defaults to CSS value.
         * @cfg {Number} trackStyle.strokeOpacity Track sector stroke opacity.
         * Defaults to CSS value.
         * @cfg {Number} trackStyle.strokeWidth Track sector stroke width. Defaults to CSS value.
         * @cfg {Number/String} [trackStyle.outerRadius='100%'] The outer radius of the track
         * sector.
         * For example:
         *
         *     outerRadius: '90%',      // 90% of the maximum radius
         *     outerRadius: 100,        // radius of 100 pixels
         *     outerRadius: '70% + 5',  // 70% of the maximum radius plus 5 pixels
         *     outerRadius: '80% - 10', // 80% of the maximum radius minus 10 pixels
         *
         * @cfg {Number/String} [trackStyle.innerRadius='50%'] The inner radius of the track sector.
         * See the `trackStyle.outerRadius` config documentation for more information.
         * @cfg {Boolean} [trackStyle.round=false] Whether to round the track sector edges or not.
         */
        trackStyle: {
            outerRadius: '100%',
            innerRadius: '100% - 20',
            round: false
        },
 
        /**
         * @cfg {Object} valueStyle
         * Value sector styles.
         * @cfg {String/Object[]} valueStyle.fill Value sector fill color. Defaults to CSS value.
         * See the `trackStyle.fill` config documentation for more information.
         * @cfg {Number} valueStyle.fillOpacity Value sector fill opacity. Defaults to CSS value.
         * @cfg {String} valueStyle.stroke Value sector stroke color. Defaults to CSS value.
         * @cfg {Number} valueStyle.strokeOpacity Value sector stroke opacity. Defaults to
         * CSS value.
         * @cfg {Number} valueStyle.strokeWidth Value sector stroke width. Defaults to CSS value.
         * @cfg {Number/String} [valueStyle.outerRadius='100% - 4'] The outer radius of the value
         * sector.
         * See the `trackStyle.outerRadius` config documentation for more information.
         * @cfg {Number/String} [valueStyle.innerRadius='50% + 4'] The inner radius of the value
         * sector.
         * See the `trackStyle.outerRadius` config documentation for more information.
         * @cfg {Boolean} [valueStyle.round=false] Whether to round the value sector edges or not.
         */
        valueStyle: {
            outerRadius: '100% - 2',
            innerRadius: '100% - 18',
            round: false
        },
 
        /**
         * @cfg {Object/Boolean} [animation=true]
         * The animation applied to the gauge on changes to the {@link #value}
         * and the {@link #angleOffset} configs. Defaults to 1 second animation
         * with the  'out' easing.
         * @cfg {Number} animation.duration The duraction of the animation.
         * @cfg {String} animation.easing The easing function to use for the animation.
         * Possible values are:
         * - `linear` - no easing, no acceleration
         * - `in` - accelerating from zero velocity
         * - `out` - (default) decelerating to zero velocity
         * - `inOut` - acceleration until halfway, then deceleration
         */
        animation: true
    },
 
    baseCls: Ext.baseCSSPrefix + 'gauge',
 
    template: [{
        reference: 'bodyElement',
        children: [{
            reference: 'textElement',
            cls: Ext.baseCSSPrefix + 'gauge-text'
        }]
    }],
 
    defaultBindProperty: 'value',
 
    pathAttributes: {
        // The properties in the `trackStyle` and `valueStyle` configs
        // that are path attributes.
        fill: true,
        fillOpacity: true,
        stroke: true,
        strokeOpacity: true,
        strokeWidth: true
    },
 
    easings: {
        linear: Ext.identityFn,
        // cubic easings
        'in': function(t) {
            return t * t * t;
        },
        out: function(t) {
            return (--t) * t * t + 1;
        },
        inOut: function(t) {
            return t < 0.5 ? 4 * t * t * t : (- 1) * (2 * t - 2) * (2 * t - 2) + 1;
        }
    },
 
    resizeDelay: 0,   // in milliseconds
    resizeTimerId: 0,
    size: null,       // cached size
    svgNS: 'http://www.w3.org/2000/svg',
    svg: null,        // SVG document
    defs: null,       // the `defs` section of the SVG document
    trackArc: null,
    valueArc: null,
    trackGradient: null,
    valueGradient: null,
    fx: null,         // either the `value` or the `angleOffset` animation
    fxValue: 0,       // the actual value rendered/animated
    fxAngleOffset: 0,
 
    constructor: function(config) {
        var me = this;
 
        me.fitSectorInRectCache = {
            startAngle: null,
            lengthAngle: null,
            minX: null,
            maxX: null,
            minY: null,
            maxY: null
        };
 
        me.interpolator = me.createInterpolator();
        me.callParent([config]);
 
        me.el.on('resize', 'onElementResize', me);
    },
 
    doDestroy: function() {
        var me = this;
 
        Ext.undefer(me.resizeTimerId);
        me.el.un('resize', 'onElementResize', me);
        me.stopAnimation();
        me.setNeedle(null);
        me.trackGradient = Ext.destroy(me.trackGradient);
        me.valueGradient = Ext.destroy(me.valueGradient);
        me.defs = Ext.destroy(me.defs);
        me.svg = Ext.destroy(me.svg);
 
        me.callParent();
    },
 
    // <if classic>
    afterComponentLayout: function(width, height, oldWidth, oldHeight) {
        this.callParent([width, height, oldWidth, oldHeight]);
 
        if (Ext.isIE9) {
            this.handleResize();
        }
    },
    // </if>
 
    onElementResize: function(element, size) {
        this.handleResize(size);
    },
 
    handleResize: function(size, instantly) {
        var me = this,
            el = me.element;
 
        if (!(el && (size = size || el.getSize()) && size.width && size.height)) {
            return;
        }
 
        me.resizeTimerId = Ext.undefer(me.resizeTimerId);
 
        if (!instantly && me.resizeDelay) {
            me.resizeTimerId = Ext.defer(me.handleResize, me.resizeDelay, me, [size, true]);
 
            return;
        }
 
        me.size = size;
        me.resizeHandler(size);
    },
 
    updateMinValue: function(minValue) {
        var me = this;
 
        me.interpolator.setDomain(minValue, me.getMaxValue());
 
        if (!me.isConfiguring) {
            me.render();
        }
    },
 
    updateMaxValue: function(maxValue) {
        var me = this;
 
        me.interpolator.setDomain(me.getMinValue(), maxValue);
 
        if (!me.isConfiguring) {
            me.render();
        }
    },
 
    updateAngleOffset: function(angleOffset, oldAngleOffset) {
        var me = this,
            animation = me.getAnimation();
 
        me.fxAngleOffset = angleOffset;
 
        if (me.isConfiguring) {
            return;
        }
 
        if (animation.duration) {
            me.animate(
                oldAngleOffset, angleOffset,
                animation.duration, me.easings[animation.easing],
                function(angleOffset) {
                    me.fxAngleOffset = angleOffset;
                    me.render();
                }
            );
        }
        else {
            me.render();
        }
    },
 
    //<debug>
    applyTrackStart: function(trackStart) {
        if (trackStart < 0 || trackStart >= 360) {
            Ext.raise("'trackStart' should be within [0, 360).");
        }
 
        return trackStart;
    },
 
    applyTrackLength: function(trackLength) {
        if (trackLength <= 0 || trackLength > 360) {
            Ext.raise("'trackLength' should be within (0, 360].");
        }
 
        return trackLength;
    },
    //</debug>
 
    updateTrackStart: function(trackStart) {
        var me = this;
 
        if (!me.isConfiguring) {
            me.render();
        }
    },
 
    updateTrackLength: function(trackLength) {
        var me = this;
 
        me.interpolator.setRange(0, trackLength);
 
        if (!me.isConfiguring) {
            me.render();
        }
    },
 
    applyPadding: function(padding) {
        var ratio;
 
        if (typeof padding === 'string') {
            ratio = parseFloat(padding) / 100;
 
            return function(x) {
                return x * ratio;
            };
        }
 
        return function() {
            return padding;
        };
    },
 
    updatePadding: function() {
        if (!this.isConfiguring) {
            this.render();
        }
    },
 
    applyValue: function(value) {
        var minValue = this.getMinValue(),
            maxValue = this.getMaxValue();
 
        return Math.min(Math.max(value, minValue), maxValue);
    },
 
    updateValue: function(value, oldValue) {
        var me = this,
            animation = me.getAnimation();
 
        me.fxValue = value;
 
        if (me.isConfiguring) {
            return;
        }
 
        me.writeText();
 
        if (animation.duration) {
            me.animate(
                oldValue, value,
                animation.duration, me.easings[animation.easing],
                function(value) {
                    me.fxValue = value;
                    me.render();
                }
            );
        }
        else {
            me.render();
        }
    },
 
    applyTextTpl: function(textTpl) {
        if (textTpl && !textTpl.isTemplate) {
            textTpl = new Ext.XTemplate(textTpl);
        }
 
        return textTpl;
    },
 
    applyTextOffset: function(offset) {
        offset = offset || {};
        offset.dx = offset.dx || 0;
        offset.dy = offset.dy || 0;
 
        return offset;
    },
 
    updateTextTpl: function() {
        this.writeText();
 
        if (!this.isConfiguring) {
            this.centerText(); // text will be centered on first size
        }
    },
 
    writeText: function(options) {
        var me = this,
            value = me.getValue(),
            minValue = me.getMinValue(),
            maxValue = me.getMaxValue(),
            delta = maxValue - minValue,
            textTpl = me.getTextTpl();
 
        textTpl.overwrite(me.textElement, {
            value: value,
            percent: (value - minValue) / delta * 100,
            minValue: minValue,
            maxValue: maxValue,
            delta: delta
        });
    },
 
    centerText: function(cx, cy, sectorRegion, innerRadius, outerRadius) {
        var textElement = this.textElement,
            textAlign = this.getTextAlign(),
            alignedRegion, textBox;
 
        if (Ext.Number.isEqual(innerRadius, 0, 0.1) ||
            sectorRegion.isOutOfBound({ x: cx, y: cy })) {
 
            alignedRegion = textElement.getRegion().alignTo({
                align: textAlign, // align text region's center to sector region's center
                target: sectorRegion
            });
 
            textElement.setLeft(alignedRegion.left);
            textElement.setTop(alignedRegion.top);
        }
        else {
            textBox = textElement.getBox();
            textElement.setLeft(cx - textBox.width / 2);
            textElement.setTop(cy - textBox.height / 2);
        }
    },
 
    camelCaseRe: /([a-z])([A-Z])/g,
 
    /**
     * @private
     */
    camelToHyphen: function(name) {
        return name.replace(this.camelCaseRe, '$1-$2').toLowerCase();
    },
 
    applyTrackStyle: function(trackStyle) {
        var me = this,
            trackGradient;
 
        trackStyle.innerRadius = me.getRadiusFn(trackStyle.innerRadius);
        trackStyle.outerRadius = me.getRadiusFn(trackStyle.outerRadius);
 
        if (Ext.isArray(trackStyle.fill)) {
            trackGradient = me.getTrackGradient();
            me.setGradientStops(trackGradient, trackStyle.fill);
            trackStyle.fill = 'url(#' + trackGradient.dom.getAttribute('id') + ')';
        }
 
        return trackStyle;
    },
 
    updateTrackStyle: function(trackStyle) {
        var me = this,
            trackArc = Ext.fly(me.getTrackArc()),
            name;
 
        for (name in trackStyle) {
            if (name in me.pathAttributes) {
                trackArc.setStyle(me.camelToHyphen(name), trackStyle[name]);
            }
            else {
                trackArc.setStyle(name, trackStyle[name]);
            }
        }
    },
 
    applyValueStyle: function(valueStyle) {
        var me = this,
            valueGradient;
 
        valueStyle.innerRadius = me.getRadiusFn(valueStyle.innerRadius);
        valueStyle.outerRadius = me.getRadiusFn(valueStyle.outerRadius);
 
        if (Ext.isArray(valueStyle.fill)) {
            valueGradient = me.getValueGradient();
            me.setGradientStops(valueGradient, valueStyle.fill);
            valueStyle.fill = 'url(#' + valueGradient.dom.getAttribute('id') + ')';
        }
 
        return valueStyle;
    },
 
    updateValueStyle: function(valueStyle) {
        var me = this,
            valueArc = Ext.fly(me.getValueArc()),
            name;
 
        for (name in valueStyle) {
            if (name in me.pathAttributes) {
                valueArc.setStyle(me.camelToHyphen(name), valueStyle[name]);
            }
            else {
                valueArc.setStyle(name, valueStyle[name]);
            }
        }
    },
 
    /**
     * @private
     */
    getRadiusFn: function(radius) {
        var result, pos, ratio,
            increment = 0;
 
        if (Ext.isNumber(radius)) {
            result = function() {
                return radius;
            };
        }
        else if (Ext.isString(radius)) {
            radius = radius.replace(/ /g, '');
            ratio = parseFloat(radius) / 100;
            pos = radius.search('%'); // E.g. '100% - 4'
 
            if (pos < radius.length - 1) {
                increment = parseFloat(radius.substr(pos + 1));
            }
 
            result = function(radius) {
                return radius * ratio + increment;
            };
 
            result.ratio = ratio;
        }
 
        return result;
    },
 
    getSvg: function() {
        var me = this,
            svg = me.svg;
 
        if (!svg) {
            svg = me.svg = Ext.get(document.createElementNS(me.svgNS, 'svg'));
            me.bodyElement.append(svg);
        }
 
        return svg;
    },
 
    getTrackArc: function() {
        var me = this,
            trackArc = me.trackArc;
 
        if (!trackArc) {
            trackArc = me.trackArc = document.createElementNS(me.svgNS, 'path');
            me.getSvg().append(trackArc, true);
            // Note: Ext.dom.Element.addCls doesn't work on SVG elements,
            // as it simply assigns a class string to el.dom.className,
            // which in case of SVG is no simple string:
            // SVGAnimatedString {baseVal: "x-gauge-track", animVal: "x-gauge-track"}
            trackArc.setAttribute('class', Ext.baseCSSPrefix + 'gauge-track');
        }
 
        return trackArc;
    },
 
    getValueArc: function() {
        var me = this,
            valueArc = me.valueArc;
 
        me.getTrackArc(); // make sure the track arc is created first for proper draw order
 
        if (!valueArc) {
            valueArc = me.valueArc = document.createElementNS(me.svgNS, 'path');
            me.getSvg().append(valueArc, true);
            valueArc.setAttribute('class', Ext.baseCSSPrefix + 'gauge-value');
        }
 
        return valueArc;
    },
 
    applyNeedle: function(needle, oldNeedle) {
        // Make sure the track and value elements have been already created,
        // so that the needle element renders on top.
        this.getValueArc();
 
        return Ext.Factory.gaugeNeedle.update(oldNeedle, needle,
                                              this, 'createNeedle', 'needleDefaults');
    },
 
    createNeedle: function(config) {
        return Ext.apply({
            gauge: this
        }, config);
    },
 
    getDefs: function() {
        var me = this,
            defs = me.defs;
 
        if (!defs) {
            defs = me.defs = Ext.get(document.createElementNS(me.svgNS, 'defs'));
            me.getSvg().appendChild(defs);
        }
 
        return defs;
    },
 
    /**
     * @private
     */
    setGradientSize: function(gradient, x1, y1, x2, y2) {
        gradient.setAttribute('x1', x1);
        gradient.setAttribute('y1', y1);
        gradient.setAttribute('x2', x2);
        gradient.setAttribute('y2', y2);
    },
 
    /**
     * @private
     */
    resizeGradients: function(size) {
        var me = this,
            trackGradient = me.getTrackGradient(),
            valueGradient = me.getValueGradient(),
            x1 = 0,
            y1 = size.height / 2,
            x2 = size.width,
            y2 = size.height / 2;
 
        me.setGradientSize(trackGradient.dom, x1, y1, x2, y2);
        me.setGradientSize(valueGradient.dom, x1, y1, x2, y2);
    },
 
    /**
     * @private
     */
    setGradientStops: function(gradient, stops) {
        var ln = stops.length,
            i, stopCfg, stopEl;
 
        while (gradient.firstChild) {
            gradient.removeChild(gradient.firstChild);
        }
 
        for (= 0; i < ln; i++) {
            stopCfg = stops[i];
            stopEl = document.createElementNS(this.svgNS, 'stop');
            gradient.appendChild(stopEl);
            stopEl.setAttribute('offset', stopCfg.offset);
            stopEl.setAttribute('stop-color', stopCfg.color);
 
            if ('opacity' in stopCfg) {
                stopEl.setAttribute('stop-opacity', stopCfg.opacity);
            }
        }
    },
 
    getTrackGradient: function() {
        var me = this,
            trackGradient = me.trackGradient;
 
        if (!trackGradient) {
            trackGradient = me.trackGradient =
                Ext.get(document.createElementNS(me.svgNS, 'linearGradient'));
 
            // Using absolute values for x1, y1, x2, y2 attributes.
            trackGradient.dom.setAttribute('gradientUnits', 'userSpaceOnUse');
            me.getDefs().appendChild(trackGradient);
            Ext.get(trackGradient); // assign unique ID
        }
 
        return trackGradient;
    },
 
    getValueGradient: function() {
        var me = this,
            valueGradient = me.valueGradient;
 
        if (!valueGradient) {
            valueGradient = me.valueGradient =
                Ext.get(document.createElementNS(me.svgNS, 'linearGradient'));
 
            // Using absolute values for x1, y1, x2, y2 attributes.
            valueGradient.dom.setAttribute('gradientUnits', 'userSpaceOnUse');
            me.getDefs().appendChild(valueGradient);
            Ext.get(valueGradient); // assign unique ID
        }
 
        return valueGradient;
    },
 
    getArcPoint: function(centerX, centerY, radius, degrees) {
        var radians = degrees / 180 * Math.PI;
 
        return [
            centerX + radius * Math.cos(radians),
            centerY + radius * Math.sin(radians)
        ];
    },
 
    isCircle: function(startAngle, endAngle) {
        return Ext.Number.isEqual(Math.abs(endAngle - startAngle), 360, 0.001);
    },
 
    getArcPath: function(centerX, centerY, innerRadius, outerRadius, startAngle, endAngle, round) {
        var me = this,
            isCircle = me.isCircle(startAngle, endAngle),
            // It's not possible to draw a circle using arcs.
            endAngle = endAngle - 0.01, // eslint-disable-line no-redeclare
            innerStartPoint = me.getArcPoint(centerX, centerY, innerRadius, startAngle),
            innerEndPoint = me.getArcPoint(centerX, centerY, innerRadius, endAngle),
            outerStartPoint = me.getArcPoint(centerX, centerY, outerRadius, startAngle),
            outerEndPoint = me.getArcPoint(centerX, centerY, outerRadius, endAngle),
            large = endAngle - startAngle <= 180 ? 0 : 1,
            path = [
                'M', innerStartPoint[0], innerStartPoint[1],
                'A', innerRadius, innerRadius, 0, large, 1, innerEndPoint[0], innerEndPoint[1]
            ],
            capRadius = (outerRadius - innerRadius) / 2;
 
        if (isCircle) {
            path.push('M', outerEndPoint[0], outerEndPoint[1]);
        }
        else {
            if (round) {
                path.push('A', capRadius, capRadius, 0, 0, 0, outerEndPoint[0], outerEndPoint[1]);
            }
            else {
                path.push('L', outerEndPoint[0], outerEndPoint[1]);
            }
        }
 
        path.push('A', outerRadius, outerRadius, 0, large, 0, outerStartPoint[0],
                  outerStartPoint[1]);
 
        if (round && !isCircle) {
            path.push('A', capRadius, capRadius, 0, 0, 0, innerStartPoint[0], innerStartPoint[1]);
        }
 
        path.push('Z');
 
        return path.join(' ');
    },
 
    resizeHandler: function(size) {
        var me = this,
            svg = me.getSvg();
 
        svg.setSize(size);
        me.resizeGradients(size);
        me.render();
    },
 
    /**
     * @private
     * Creates a linear interpolator function that itself has a few methods:
     * - `setDomain(from, to)`
     * - `setRange(from, to)`
     * - `getDomain` - returns the domain as a [from, to] array
     * - `getRange` - returns the range as a [from, to] array
     * @param {Boolean} [rangeCheck=false] 
     * Whether to allow out of bounds values for domain and range.
     * @return {Function} The interpolator function:
     * `interpolator(domainValue, isInvert)`.
     * If the `isInvert` parameter is `true`, the start of domain will correspond
     * to the end of range. This is useful, for example, when you want to render
     * increasing domain values counter-clockwise instead of clockwise.
     */
    createInterpolator: function(rangeCheck) {
        var domainStart = 0,
            domainDelta = 1,
            rangeStart = 0,
            rangeEnd = 1,
 
            interpolator = function(x, invert) {
                var t = 0;
 
                if (domainDelta) {
                    t = (- domainStart) / domainDelta;
 
                    if (rangeCheck) {
                        t = Math.max(0, t);
                        t = Math.min(1, t);
                    }
 
                    if (invert) {
                        t = 1 - t;
                    }
                }
 
                return (1 - t) * rangeStart + t * rangeEnd;
            };
 
        interpolator.setDomain = function(a, b) {
            domainStart = a;
            domainDelta = b - a;
 
            return this;
        };
 
        interpolator.setRange = function(a, b) {
            rangeStart = a;
            rangeEnd = b;
 
            return this;
        };
 
        interpolator.getDomain = function() {
            return [domainStart, domainStart + domainDelta];
        };
 
        interpolator.getRange = function() {
            return [rangeStart, rangeEnd];
        };
 
        return interpolator;
    },
 
    applyAnimation: function(animation) {
        if (true === animation) {
            animation = {};
        }
        else if (false === animation) {
            animation = {
                duration: 0
            };
        }
 
        if (!('duration' in animation)) {
            animation.duration = 1000;
        }
 
        if (!(animation.easing in this.easings)) {
            animation.easing = 'out';
        }
 
        return animation;
    },
 
    updateAnimation: function() {
        this.stopAnimation();
    },
 
    /**
     * @private
     * @param {Number} from 
     * @param {Number} to 
     * @param {Number} duration 
     * @param {Function} easing 
     * @param {Function} fn Function to execute on every frame of animation.
     * The function takes a single parameter - the value in the [from, to]
     * range, interpolated based on current time and easing function.
     * With certain easings, the value may overshoot the range slighly.
     * @param {Object} scope 
     */
    animate: function(from, to, duration, easing, fn, scope) {
        var me = this,
            start = Ext.now(),
            interpolator = me.createInterpolator().setRange(from, to);
 
        function frame() {
            var now = Ext.AnimationQueue.frameStartTime,
                t = Math.min(now - start, duration) / duration,
                value = interpolator(easing(t));
 
            if (scope) {
                if (typeof fn === 'string') {
                    scope[fn].call(scope, value);
                }
                else {
                    fn.call(scope, value);
                }
            }
            else {
                fn(value);
            }
 
            if (>= 1) {
                Ext.AnimationQueue.stop(frame, scope);
                me.fx = null;
            }
        }
 
        me.stopAnimation();
        Ext.AnimationQueue.start(frame, scope);
        me.fx = {
            frame: frame,
            scope: scope
        };
    },
 
    /**
     * Stops the current {@link #value} or {@link #angleOffset} animation.
     */
    stopAnimation: function() {
        var me = this;
 
        if (me.fx) {
            Ext.AnimationQueue.stop(me.fx.frame, me.fx.scope);
            me.fx = null;
        }
    },
 
    unitCircleExtrema: {
        0: [1, 0],
        90: [0, 1],
        180: [-1, 0],
        270: [0, -1],
        360: [1, 0],
        450: [0, 1],
        540: [-1, 0],
        630: [0, -1]
    },
 
    /**
     * @private
     */
    getUnitSectorExtrema: function(startAngle, lengthAngle) {
        var extrema = this.unitCircleExtrema,
            points = [],
            angle;
 
        for (angle in extrema) {
            if (angle > startAngle && angle < startAngle + lengthAngle) {
                points.push(extrema[angle]);
            }
        }
 
        return points;
    },
 
    /**
     * @private
     * Given a rect with a known width and height, find the maximum radius of the donut
     * sector that can fit into it, as well as the center point of such a sector.
     * The end and start angles of the sector are also known, as well as the relationship
     * between the inner and outer radii.
     */
    fitSectorInRect: function(width, height, startAngle, lengthAngle, ratio) {
        if (Ext.Number.isEqual(lengthAngle, 360, 0.001)) {
            return {
                cx: width / 2,
                cy: height / 2,
                radius: Math.min(width, height) / 2,
                region: new Ext.util.Region(0, width, height, 0)
            };
        }
 
        // eslint-disable-next-line vars-on-top
        var me = this,
            points, xx, yy, minX, maxX, minY, maxY,
            cache = me.fitSectorInRectCache,
            sameAngles = cache.startAngle === startAngle && cache.lengthAngle === lengthAngle;
 
        if (sameAngles) {
            minX = cache.minX;
            maxX = cache.maxX;
            minY = cache.minY;
            maxY = cache.maxY;
        }
        else {
            points = me.getUnitSectorExtrema(startAngle, lengthAngle).concat([
                // start angle outer radius point
                me.getArcPoint(0, 0, 1, startAngle),
 
                // start angle inner radius point
                me.getArcPoint(0, 0, ratio, startAngle),
 
                // end angle outer radius point
                me.getArcPoint(0, 0, 1, startAngle + lengthAngle),
 
                // end angle inner radius point
                me.getArcPoint(0, 0, ratio, startAngle + lengthAngle)
            ]);
 
            xx = points.map(function(point) {
                return point[0];
            });
 
            yy = points.map(function(point) {
                return point[1];
            });
 
            // The bounding box of a unit sector with the given properties.
            minX = Math.min.apply(null, xx);
            maxX = Math.max.apply(null, xx);
            minY = Math.min.apply(null, yy);
            maxY = Math.max.apply(null, yy);
 
            cache.startAngle = startAngle;
            cache.lengthAngle = lengthAngle;
            cache.minX = minX;
            cache.maxX = maxX;
            cache.minY = minY;
            cache.maxY = maxY;
        }
 
        // eslint-disable-next-line vars-on-top, one-var
        var sectorWidth = maxX - minX,
            sectorHeight = maxY - minY,
            scaleX = width / sectorWidth,
            scaleY = height / sectorHeight,
            scale = Math.min(scaleX, scaleY),
            // Region constructor takes: top, right, bottom, left.
            sectorRegion = new Ext.util.Region(minY * scale, maxX * scale, maxY * scale,
                                               minX * scale),
            rectRegion = new Ext.util.Region(0, width, height, 0),
            alignedRegion = sectorRegion.alignTo({
                align: 'c-c', // align sector region's center to rect region's center
                target: rectRegion
            }),
            dx = alignedRegion.left - minX * scale,
            dy = alignedRegion.top - minY * scale;
 
        return {
            cx: dx,
            cy: dy,
            radius: scale,
            region: alignedRegion
        };
    },
 
    /**
     * @private
     */
    fitSectorInPaddedRect: function(width, height, padding, startAngle, lengthAngle, ratio) {
        var result = this.fitSectorInRect(
            width - padding * 2,
            height - padding * 2,
            startAngle, lengthAngle, ratio
        );
 
        result.cx += padding;
        result.cy += padding;
        result.region.translateBy(padding, padding);
 
        return result;
    },
 
    /**
     * @private
     */
    normalizeAngle: function(angle) {
        return (angle % 360 + 360) % 360;
    },
 
    render: function() {
        if (!this.size) {
            return;
        }
 
        // eslint-disable-next-line vars-on-top
        var me = this,
            textOffset = me.getTextOffset(),
            trackArc = me.getTrackArc(),
            valueArc = me.getValueArc(),
            needle = me.getNeedle(),
            clockwise = me.getClockwise(),
            value = me.fxValue,
            angleOffset = me.fxAngleOffset,
            trackLength = me.getTrackLength(),
            width = me.size.width,
            height = me.size.height,
            paddingFn = me.getPadding(),
            padding = paddingFn(Math.min(width, height)),
 
            // in the range of [0, 360)
            trackStart = me.normalizeAngle(me.getTrackStart() + angleOffset),
 
            // in the range of (0, 720)
            trackEnd = trackStart + trackLength,
            valueLength = me.interpolator(value),
            trackStyle = me.getTrackStyle(),
            valueStyle = me.getValueStyle(),
            sector = me.fitSectorInPaddedRect(
                width, height, padding, trackStart, trackLength, trackStyle.innerRadius.ratio
            ),
            cx = sector.cx,
            cy = sector.cy,
            radius = sector.radius,
            trackInnerRadius = Math.max(0, trackStyle.innerRadius(radius)),
            trackOuterRadius = Math.max(0, trackStyle.outerRadius(radius)),
            valueInnerRadius = Math.max(0, valueStyle.innerRadius(radius)),
            valueOuterRadius = Math.max(0, valueStyle.outerRadius(radius)),
            trackPath = me.getArcPath(
                cx, cy, trackInnerRadius, trackOuterRadius, trackStart, trackEnd, trackStyle.round
            ),
            valuePath = me.getArcPath(
                cx, cy, valueInnerRadius, valueOuterRadius,
                clockwise ? trackStart : trackEnd - valueLength,
                clockwise ? trackStart + valueLength : trackEnd,
                valueStyle.round
            );
 
        me.centerText(
            cx + textOffset.dx, cy + textOffset.dy,
            sector.region, trackInnerRadius, trackOuterRadius
        );
 
        trackArc.setAttribute('d', trackPath);
        valueArc.setAttribute('d', valuePath);
 
        if (needle) {
            needle.setRadius(radius);
            needle.setTransform(cx, cy, -90 + trackStart + valueLength);
        }
 
        me.fireEvent('render', me);
    }
});