Hybrid View

  1. #1
    Ext JS Premium Member troseberry's Avatar
    Join Date
    Feb 2010
    Location
    Dayton, OH
    Posts
    276
    Vote Rating
    9
    troseberry will become famous soon enough

      1  

    Default Threshold Lines and KPI Ranges for Cartesian and Radar Charts

    Threshold Lines and KPI Ranges for Cartesian and Radar Charts


    ***** UPDATE ******
    I have updated the code to include the capability to add the threshold lines and ranges to the radar chart. I also implemented the suggested config to allow for dash array lines

    *********************
    I have put together a little plugin that you can add to a chart with cartesian type of series that will allow you to include threshold lines and range coloring.

    I have attached a zip of the extension and an example that you can just extract into a folder in the examples directory of the library.

    You can define lines and ranges together or just individually.
    Plugin:
    Code:
    /**
     * @class Ext.ux.chart.ThresholdLineRange
     * @extends Ext.AbstractPlugin
     * Plugin to add ability of displaying Threshold Lines and Color Ranges on Cartesian and Radar style charts
     * 
     * Example usage:
     * plugins: [Ext.create('Ext.ux.chart.ThresholdLineRange', {
     *  lines: [{
     *   position: 'left',
     *   color: '#000',
     *   value: 30,
     *   width: 3,
     *   label: {
     *    text: 'Goal',
     *    showValue: false
     *   }
     *  }, {
     *   position: 'right',
     *   value: 70,
     *   color: '#ff0000',
     *   width: 3,
     *   label: {
     *    text: 'Threshold 2'
     *   }
     *  }],
     *  ranges: [{
     *   opacity: 0.1,
     *   from: 0,
     *   to: 70,
     *   color: '#FF0000'
     *  }, {
     *   opacity: 0.1,
     *   from: 70,
     *   to: 90,
     *   color: '#FFFF00'
     *  }, {
     *   opacity: 0.1,
     *   from: 90,
     *   to: 100,
     *   color: '#00FF00'
     *  }]
     * })]
     *
     * @ptype thresholdlinerange
     */
    Ext.define('Ext.ux.chart.ThresholdLineRange', {
        /* Begin Definitions */
        extend: 'Ext.AbstractPlugin',
        requires: ['Ext.chart.Chart', 'Ext.chart.series.Cartesian'],
        /* End Definitions */
        alias: 'plugin.thresholdlinerange',
        /**
         * @cfg {Array} lines
         * An array containing threshold line object configurations that will be added to the chart utilizing the first cartesian series boundary information
         *
         * Configurable properties are as follow
         * - 'position' - the axis position that the line will start from (string)
         * - `color` - an rgb or hex color string for the background color of the line. defaults to '#000'
         * - `value` - the axis scale value that the line will appear at (number)
         * - `width` - the width of the stroke (integer). defaults to `1`
         * - `opacity` - the opacity of the line and the fill color (decimal). defaults to `1`
         * - `label` - a config object for defining the parameters for display of the line's label. If no config is defined then system will create a blank label
         *  Configurable properties are as follow
         *    - `text` - the value to be displayed as the label of the threshold line (string)
         *   - `color` - an rgb or hex color string for the background color of the line. defaults to '#000'
         *   - `font` - the font config to use for the text that is displayed. defaults to `11px Helvetica, sans-serif`
         *   - `showValue` - true or false value to display the value of the threshold line inline with the label text (boolean). defaults to `true`
         * 
         * Example usage:
         * lines: [{
         *  position: 'left',
         *  color: '#ff0000',
         *  value: 25,
         *  width: 3,
         *  label: {
         *   text: 'Threshold 1',
         *   color: '#000',
         *   font: "11px Helvetica, sans-serif",
         *   showValue: true
         *  }
         * }, {
         *  position: 'left',
         *  value: 50,
         *  width: 3
         * }]
         */
        lines: [],
        /**
         * @cfg {Array} ranges
         * An array containing object configurations that will allow you to add color filled regions to the background of the chart based on the first cartesian series boundary information
         *
         * Configurable properties are as follow
         * - `opacity` - the opacity of the line and the fill color (decimal)
         * - `from` - the value to start the colored region fill (number)
         * - `to` - the value to end the colored region fill(number)
         * - `color` - an rgb or hex color string for the background color of the region.
         * - `lineWidth` - the width of the line stroke that will be used around the range
         * Example usage:
         * ranges: [{
         *  opacity: 0.1,
         *  from: 0,
         *  to: 70,
         *  color: '#FF0000'
         * }, {
         *  opacity: 0.1,
         *  from: 70,
         *  to: 90,
         *  color: '#FFFF00'
         * }, {
         *  opacity: 0.1,
         *  from: 90,
         *  to: 100,
         *  color: '#00FF00'
         * }]
         */
        ranges: [],
        init: function (chart) {
            var me = this,
                seriesCollection = chart.series,
                firstSeries = seriesCollection.first();
            // Check to see if the series is already instantiated
            if (firstSeries instanceof Ext.chart.series.Series) {
                me.setupSeriesListener(firstSeries);
            } else {
                /*
        Because the series is still in raw config format the only way to gain reference to the series 'afterrender' event is to add
        a listener to replace event of the Ext.util.MixedCollection object that holds all the series configs. When the chart finally
        instantiates the series it will replace the existing key of the Ext.util.MixedCollection with the new class object. Then we 
        can add a 'afterrender' listener to that new Ext.chart.series.Series inherited object.
       */
                seriesCollection.on({
                    // We set this to only run once because we only need to calculate the region boundaries once in order to add line and region to chart surface
                    single: true,
                    replace: me.onSeriesReplace,
                    scope: me
                });
            }
        },
        // @private called when a new series is instantiated into Chart's series collection
        onSeriesReplace: function (key, old, newObj) {
            this.setupSeriesListener(newObj);
        },
        // @private called to setup afterrender event listener on series instance
        setupSeriesListener: function (series) {
            if (series instanceof Ext.chart.series.Cartesian) {
                // Add listener to the series so that we may add our configured plugin to the chart
                series.on({
                    // Because the 'afterrender' event of the series doesnt pass any parameters we need to bind the series to our listener
                    afterrender: Ext.bind(this.onSeriesAfterRender, this, [series], false),
                    scope: this
                });
            }
        },
        /**
         * @private
         * Event handler to calculate the appropriate chart boundaries and initiate the drawing of the Threshold Lines and Ranges
         */
        onSeriesAfterRender: function (series) {
            var me = this,
                chart = series.chart,
                surface = chart.surface,
                lines = me.lines,
                ranges = me.ranges,
                path, bounds, line, range, i, ln;
            // Set scope properties for easier access
            me.chart = chart;
            me.surface = surface;
            // Check to see how series calculates bounds.  If series contains 'getBounds()' method then its inherited from Bar type series. If not then its a Line type series
            bounds = series.getBounds ? series.getBounds() : me.getLineSeriesBounds(series);
            if (lines) {
                ln = lines.length;
                i = 0;
                // Setup surface groups to hold the new Lines and Labels
                me.thresholdLineGroup = surface.getGroup('thresholdlines');
                me.thresholdLabelGroup = surface.getGroup('thresholdlabels');
                for (i; i < ln; i++) {
                    line = lines[i];
                    // Calculate the actual Path for the new line
                    path = me.calculateLinePath(bounds, line);
                    // Draw the line
                    me.drawThresholdLine(path, line, i);
                }
            }
            if (ranges) {
                ln = ranges.length;
                i = 0;
                // Setup sruface groups to hold new Ranges
                me.rangeGroup = surface.getGroup('rangegroup');
                for (i; i < ln; i++) {
                    range = ranges[i];
                    // Calculate the actual Path for the new Range
                    path = me.calculateRangePath(series.axis, bounds, range);
                    // Draw the Range
                    me.drawRanges(path, range, i);
                }
            }
        },
        /**
         * Calculates the series boundaries for Line type of series
         * @param {Ext.chart.series.Series} series The instantiated series object that will be used to calcuate the boundaries from.
         *
         * Most of the code here is derivitive of the 'drawSeries()' method of the Ext.chart.series.Line class
         * 
         * @return {Object} return An object containing all the needed boundary and scale information of the Ext.chart.series.Series
         */
        getLineSeriesBounds: function (series) {
            var me = this,
                // Get the basic series dimension box
                bbox = series.bbox,
                storeCount = me.chart.getChartStore().getCount(),
                chartAxes = me.chart.axes,
                // Find out the axes that are bound to the chart
                boundAxes = series.getAxesForXAndYFields(),
                boundXAxis = boundAxes.xAxis,
                boundYAxis = boundAxes.yAxis,
                axis, ends, minX, minY, maxX, maxY;
            // Get the min and max of the X axis
            if (axis = chartAxes.get(boundXAxis)) {
                ends = axis.applyData();
                minX = ends.from;
                maxX = ends.to;
            }
            // Get the min and max of the Y axis
            if (axis = chartAxes.get(boundYAxis)) {
                ends = axis.applyData();
                minY = ends.from;
                maxY = ends.to;
            }
            // Calculate the scale of the X axis
            if (isNaN(minX)) {
                minX = 0;
                xScale = bbox.width / ((storeCount - 1) || 1);
            } else {
                xScale = bbox.width / ((maxX - minX) || (storeCount - 1) || 1);
            }
            // Calculate the scale of the Y axis
            if (isNaN(minY)) {
                minY = 0;
                yScale = bbox.height / ((storeCount - 1) || 1);
            } else {
                yScale = bbox.height / ((maxY - minY) || (storeCount - 1) || 1);
            }
            return {
                bbox: bbox,
                minX: minX,
                minY: minY,
                xScale: xScale,
                yScale: yScale
            }
        },
        /**
         * @private
         * Calculates the SVG path string for the Threshold Line
         * @param {Object} bounds An object that contains all the boundary information for the series
         * @param {Object} line Object contains the user config of the line to be drawn
         * 
         * @return {Object} return An object containing the path string and the start x and y coordinates
         */
        calculateLinePath: function (bounds, line) {
            var position = line.position,
                value = line.value,
                bbox = bounds.bbox,
                // Check to see if bounds are setup as individual Line series x and y scales or if one scale is defined typically by Bar series types
                xScale = bounds.xScale || bounds.scale || 1,
                yScale = bounds.yScale || bounds.scale || 1,
                // Check to see if min values are setup in the bounds. If not then default them to start at 0
                minX = bounds.minX || 0,
                minY = bounds.minY || 0,
                boxX = bbox.x,
                boxY = bbox.y,
                height = bbox.height,
                width = bbox.width,
                path = [],
                x, y;
            // Calculate the path string based on bounds and scale info
            if (position == 'left') {
                x = boxX + (0 - minX) * xScale;
                y = boxY + height - (value - minY) * yScale;
                path = path.concat(["M", x, y, "l", width, 0]);
            } else if (position == 'right') {
                x = boxX + width + (0 - minX) * xScale;
                y = boxY + height - (value - minY) * yScale;
                path = path.concat(["M", x, y, "l", -width, 0]);
            } else if (position == 'bottom') {
                y = boxY + height + (0 - minY) * yScale;
                x = boxX + (value - minX) * xScale;
                path = path.concat(["M", x, y, "l", 0, -height]);
            } else if (position == 'top') {
                y = boxY + (0 - minY) * yScale;
                x = boxX + (value - minX) * xScale;
                path = path.concat(["M", x, y, "l", 0, height]);
            }
            return {
                path: path,
                x: x,
                y: y
            }
        },
        /**
         * @private
         * Calculates the SVG path string for the Range
         * @param {String} position The position of the series's bound axis value
         * @param {Object} bounds An object that contains all the boundary information for the series
         * @param {Object} line Object contains the user config of the line to be drawn
         * 
         * @return {Object} return An object containing the path string
         */
        calculateRangePath: function (position, bounds, range) {
            var bbox = bounds.bbox,
                // Check to see if bounds are setup as individual Line series x and y scales or if one scale is defined typically by Bar series types
                xScale = bounds.xScale || bounds.scale || 1,
                yScale = bounds.yScale || bounds.scale || 1,
                // Check to see if min values are setup in the bounds. If not then default them to start at 0
                minX = bounds.minX || 0,
                minY = bounds.minY || 0,
                boxX = bbox.x,
                boxY = bbox.y,
                height = bbox.height,
                width = bbox.width,
                path = [],
                to = range.to,
                from = range.from,
                toPoint = [],
                fromPoint = [],
                lineWidth = (range.lineWidth ? range.lineWidth : 1) / 2,
                x, y;
            // Calculate the path string based on bounds and scale info
            if (position == 'left') {
                toPoint[0] = boxX + (0 - minX) * xScale;
                toPoint[1] = boxY + height - (to - minY) * yScale;
                fromPoint[0] = boxX + (0 - minX) * xScale;
                fromPoint[1] = boxY + height - (from - minY) * yScale;
                path.push("M", fromPoint[0] + 1 + lineWidth, fromPoint[1] + 0.5 - lineWidth, "L", fromPoint[0] + 1 + width - lineWidth, fromPoint[1] + 0.5 - lineWidth, "L", toPoint[0] + 1 + width - lineWidth, toPoint[1] + 0.5 + lineWidth, "L", toPoint[0] + 1 + lineWidth, toPoint[1] + 0.5 + lineWidth, "Z");
            } else if (position == 'right') {
                toPoint[0] = boxX + width + (0 - minX) * xScale;
                toPoint[1] = boxY + height - (to - minY) * yScale;
                fromPoint[0] = boxX + width + (0 - minX) * xScale;
                fromPoint[1] = boxY + height - (from - minY) * yScale;
                path.push("M", fromPoint[0] - lineWidth, fromPoint[1] + 0.5 - lineWidth, "L", fromPoint[0] - width + lineWidth, fromPoint[1] + 0.5 - lineWidth, "L", toPoint[0] - width + lineWidth, toPoint[1] + 0.5 + lineWidth, "L", toPoint[0] - lineWidth, toPoint[1] + 0.5 + lineWidth, "Z");
            } else if (position == 'top') {
                toPoint[0] = boxX + (to - minX) * xScale;
                toPoint[1] = boxY + (0 - minY) * yScale;
                fromPoint[0] = boxX + (from - minX) * xScale;
                fromPoint[1] = boxY + (0 - minY) * yScale;
                path.push("M", fromPoint[0] + 0.5 + lineWidth, fromPoint[1] + 1 + lineWidth, "L", fromPoint[0] + 0.5 + lineWidth, fromPoint[1] + 1 + width - lineWidth, "L", toPoint[0] + 0.5 - lineWidth, toPoint[1] + 1 + width - lineWidth, "L", toPoint[0] + 0.5 - lineWidth, toPoint[1] + 1 + lineWidth, "Z");
            } else {
                toPoint[0] = boxX + (to - minX) * xScale;
                toPoint[1] = boxY + height + (0 - minY) * yScale;
                fromPoint[0] = boxX + (from - minX) * xScale;
                fromPoint[1] = boxY + height + (0 - minY) * yScale;
                path.push("M", fromPoint[0] + 0.5 + lineWidth, fromPoint[1] - lineWidth, "L", fromPoint[0] + 0.5 + lineWidth, fromPoint[1] - width + lineWidth, "L", toPoint[0] + 0.5 - lineWidth, toPoint[1] - width + lineWidth, "L", toPoint[0] + 0.5 - lineWidth, toPoint[1] - lineWidth, "Z");
            }
            return path;
        },
        /**
         * @private
         * Draws the ThresholdLine Sprite and intiaties draw of Label
         * @param {Object} path An object containing the path string and the start x and y coordinates
         * @param {Object} line The object that contains the user config of the line to be drawn
         * @param {Number} index The position in the lines array
         */
        drawThresholdLine: function (path, line, index) {
            var me = this,
                // Get reference to the Sprite Group that will contain all the Lines
                lineGroup = me.thresholdLineGroup,
                // Get reference to the line if already exists
                thresholdLine = lineGroup.getAt(index);
            if (!thresholdLine) {
                thresholdLine = me.surface.add({
                    value: line.value,
                    type: 'path',
                    group: lineGroup,
                    path: path.path,
                    opacity: line.opacity || 1,
                    "stroke-width": line.width || 1,
                    stroke: line.color || '#000'
                });
            }
            // Show the new line and apply the new calculated path if line already exists
            thresholdLine.setAttributes({
                hidden: false,
                path: path.path
            }, true);
            // Initiate the drawing of the Label that goes with this line
            me.getOrCreateLabel(path, line, index);
        },
        /**
         * @private
         * Creates the Label sprite at default location
         * @param {path} path An object containing the path string and the start x and y coordinates of the Line
         * @param {Object} line The object that contains the user config of the line to be drawn
         * @param {Number} index The position in the lines array
         * 
         * @return {Object} return An object containing the Label sprite and its default dimensions
         */
        getOrCreateLabel: function (path, line, index) {
            var me = this,
                // Get reference to the Sprite group that holds all Labels
                labelGroup = me.thresholdLabelGroup,
                // Get reference to the Label if it already eixts
                textLabel = labelGroup.getAt(index),
                position = line.position,
                value = line.value,
                labelCfg = line.label || {},
                // Default Label config
                config = {
                    text: '',
                    color: '#000',
                    font: "11px Helvetica, sans-serif",
                    showValue: true
                },
       pad = 5,
       x = path.x,
       y = path.y,
       width, height;
            if (!textLabel) {
                // If Line position originates from Top or Bottom then rotate the label sprite accordingly to display the text to read top->bottom or bottom->top
                if (position == 'top' || position == 'bottom') {
                    Ext.apply(config, {
                        rotate: {
                            degrees: position == 'top' ? 90 : 270
                        }
                    });
                }
                // Apply user configs with default label configs
                Ext.apply(config, labelCfg);
                // If users specified to display value of Line inline with label then update text config to show
                if (config.showValue) {
                    Ext.apply(config, {
                        text: config.text + ' (' + value + ')'
                    });
                }
                // Add the label to the surface at default coordinates. We do this so that we can get reference to how big the new Label is with the display text applied so we
                // can properly calculate offsets later
                textLabel = me.surface.add(Ext.apply({
                    group: labelGroup,
                    type: 'text',
                    x: 0,
                    y: 0
                }, config));
                me.surface.renderItem(textLabel);
            }
            textLabel._bbox = textLabel.getBBox();
      
      // Calculate new dimensions and coordinates based off of generated Label and Line Position
      width = textLabel._bbox.width;
      height = textLabel._bbox.height;
      if (position == 'top') {
       x = x - pad - (height / 2) + width;
                y = y + pad + (height / 2);
      } else if (position == 'bottom') {
       y = y - pad - (height / 2);
                x = x - (width / 2) - (height / 2);
      } else if (position == 'left') {
       x = x + pad
                y = y - (height / 2);
      } else {
       x = x - width - pad
        y = y - (height / 2);
      }
      // Show the new Label and apply the new calculated coordinates if label already exists
      textLabel.setAttributes({
                hidden: false,
                x: x,
                y: y
            }, true);
        },
        /**
         * @private
         * Draws the Range Sprite
         * @param {Object} path An object containing the path string and the start x and y coordinates
         * @param {Object} range The object that contains the user config of the range to be drawn
         * @param {Number} index The position in the lines array
         */
        drawRanges: function (path, range, index) {
            var me = this,
                // Get reference to the Sprite Group that will contain all the Ranges
                rangeGroup = me.rangeGroup,
                // Get reference to the range if already exists
                rangeSprite = rangeGroup.getAt(index);
            if (!rangeSprite) {
                rangeSprite = me.surface.add({
                    type: 'path',
                    group: rangeGroup,
                    path: path,
                    opacity: range.opacity || 0.1,
                    zIndex: -1,
                    fill: range.color
                });
            }
            // Show the new range and apply the new calculated path if range already exists
            rangeSprite.setAttributes({
                hidden: false,
                path: path
            }, true);
        }
    });

    Example Usage:
    Code:
    plugins: [Ext.create('Ext.ux.chart.ThresholdLineRange', {
        lines: [{
         position: 'left',
         color: '#000',
         value: 30,
         width: 3,
         label: {
          text: 'Goal',
          showValue: false
         }
        }, {
         position: 'right',
         value: 70,
         color: '#ff0000',
         width: 3,
         label: {
          text: 'Threshold 2'
         }
        }],
        ranges: [{
         opacity: 0.1,
         from: 0,
         to: 70,
         color: '#FF0000'
        }, {
         opacity: 0.1,
         from: 70,
         to: 90,
         color: '#FFFF00'
        }, {
         opacity: 0.1,
         from: 90,
         to: 100,
         color: '#00FF00'
        }]
       })]
    Screenshot.jpgThresholdLineRange.zip
    Last edited by troseberry; 21 Dec 2012 at 11:49 AM. Reason: Update to add Threshold Line and Range config to Radar Series

  2. #2
    Sencha Premium Member ajaxvador's Avatar
    Join Date
    Nov 2007
    Location
    PARIS, FRANCE
    Posts
    206
    Vote Rating
    0
    ajaxvador is on a distinguished road

      0  

    Default


    +1
    Vador

  3. #3
    Sencha Premium Member tempvalue's Avatar
    Join Date
    Apr 2012
    Location
    istanbul
    Posts
    22
    Vote Rating
    0
    tempvalue is on a distinguished road

      0  

    Default


    Hi,
    First of all, thanks for this awesome plugin. I just needed such a limit line and now the chart looks like a real KPI Gauge. Btw i found a bug but it may also related with my Ext.JS version which is 4.0.7

    -When panel is resized, chart is also resized but line isn't resized as expected.

  4. #4
    Ext JS Premium Member troseberry's Avatar
    Join Date
    Feb 2010
    Location
    Dayton, OH
    Posts
    276
    Vote Rating
    9
    troseberry will become famous soon enough

      0  

    Default


    I developed it with version 4.1.1 so I will have to go back and check it out inside 4.0.7 to see if there are any issues. The label value can be controlled by setting the label config
    Code:
    label: {
        text: 'My Label',
        showValue: false
    }
    the label orientation should be driven off of the position of the line. If its always vertical then the line would be coming from the bottom or top. If thats not how you want it can you post an example of what its doing so that I can troubleshoot.

  5. #5
    Sencha User
    Join Date
    Jan 2008
    Location
    Istanbul
    Posts
    4
    Vote Rating
    1
    rexmont is on a distinguished road

      0  

    Default A small addition...

    A small addition...


    Hi troseberry,

    Thanks for the plugin. It is a useful one.

    I did make a small addition to your plugin though; draw threshold line as a solid one or a dashed one by supplying a dash array.

    So in your plugin code, line 374, I've changed this code:

    Code:
    opacity: line.opacity || 1,
    "stroke-width": line.width || 1,
    stroke: line.color || '#000'
    to this:

    Code:
    opacity: line.opacity || 1,
    "stroke-width": line.width || 1,
    stroke: line.color || '#000',
    'stroke-dasharray': line.dash || '4, 0'
    And in my code:

    Code:
    plugins: [Ext.create('Ext.ux.chart.ThresholdLineRange', {
    	lines: [
    	{
    		position: 'left',
    		value: 0,
    		color: '#ff0000',
    		width: 1,
    		label: {
    			text: ''
    		},
    		dash: '4,4'
    	}],
    	ranges: [
            .
            .
    As a side note, dash array is two-element array. the first element denotes solid line in pixels and the second element is for empty line in pixels.

    Code:
    4, 4
    - - - - - - - - -
    Code:
    4, 8
    -  -  -  -  -  -  -
    Code:
    2, 8
    -    -    -    -    -
    Code:
    4, 0
    ______________
    etc.

    Thanks.

    Uygar

  6. #6
    Ext JS Premium Member troseberry's Avatar
    Join Date
    Feb 2010
    Location
    Dayton, OH
    Posts
    276
    Vote Rating
    9
    troseberry will become famous soon enough

      0  

    Default


    I have updated the code to include the capability to provide threshold lines and ranges to radar series charts. Also added the dashed array config