leighquince/Chart.js

View on GitHub
src/Chart.Line.js

Summary

Maintainability
F
1 mo
Test Coverage
(function() {
    "use strict";

    var root = this,
        Chart = root.Chart,
        helpers = Chart.helpers;

    var defaultConfig = {
        //Function - Whether the current x-axis label should be filtered out, takes in current label and 
        //index, return true to filter out the label return false to keep the label
        labelsFilter: function(label, index) {
            return false;
        },

        ///Boolean - Whether grid lines are shown across the chart
        scaleShowGridLines: true,

        //String - Colour of the grid lines
        scaleGridLineColor: "rgba(0,0,0,.05)",

        //Number - Width of the grid lines
        scaleGridLineWidth: 1,

        //Boolean - Whether to show horizontal lines (except X axis)
        scaleShowHorizontalLines: true,

        //Boolean - Whether to show vertical lines (except Y axis)
        scaleShowVerticalLines: true,

        //Boolean - Whether the line is curved between points
        bezierCurve: true,

        //Number - Tension of the bezier curve between points
        bezierCurveTension: 0.4,

        //Boolean - Whether to show a dot for each point
        pointDot: true,

        //Number - Radius of each point dot in pixels
        pointDotRadius: 4,

        //Number - Pixel width of point dot stroke
        pointDotStrokeWidth: 1,

        //Number - amount extra to add to the radius to cater for hit detection outside the drawn point
        pointHitDetectionRadius: 20,

        //Boolean - Whether to show a stroke for datasets
        datasetStroke: true,

        //Number - Pixel width of dataset stroke
        datasetStrokeWidth: 2,

        //Boolean - Whether to fill the dataset with a colour
        datasetFill: true,

        //Boolean - Whetther to try and fill sparse datasets to keep one consecutive line
        populateSparseData: false,

        //Number - length of labels being displayed on graph, 0 represents full length
        labelLength: 0,

        //String - A legend template
        legendTemplate: "<ul class=\"<%=name.toLowerCase()%>-legend\"><% for (var i=0; i<datasets.length; i++){%><li><span style=\"background-color:<%=datasets[i].strokeColor%>\"></span><%if(datasets[i].label){%><%=datasets[i].label%><%}%></li><%}%></ul>",

        //Array - specific yAxis details
        yAxes: [],

        //Boolean - set default yAxis on the left of chart
        scalePositionLeft: true,
    };


    Chart.Type.extend({
        name: "Line",
        defaults: defaultConfig,
        initialize: function(data) {
            //Declare the extension of the default point, to cater for the options passed in to the constructor
            this.PointClass = Chart.Point.extend({
                strokeWidth: this.options.pointDotStrokeWidth,
                radius: this.options.pointDotRadius,
                display: this.options.pointDot,
                hitDetectionRadius: this.options.pointHitDetectionRadius,
                ctx: this.chart.ctx,
                inRange: function(mouseX) {
                    return (Math.pow(mouseX - this.x, 2) < Math.pow(this.radius + this.hitDetectionRadius, 2));
                }
            });

            this.datasets = [];
            this.yAxes = data.yAxes;
            //Set up tooltip events on the chart
            if (this.options.showTooltips) {
                helpers.bindEvents(this, this.options.tooltipEvents, function(evt) {
                    var activePoints = (evt.type !== 'mouseout') ? this.getPointsAtEvent(evt) : [];
                    this.eachPoints(function(point) {
                        point.restore(['fillColor', 'strokeColor']);
                    });
                    helpers.each(activePoints, function(activePoint) {
                        activePoint.fillColor = activePoint.highlightFill;
                        activePoint.strokeColor = activePoint.highlightStroke;
                    });
                    this.showTooltip(activePoints);
                });
            }
            var sparseDatasetValues = [];
            if (this.options.populateSparseData) {
                //go through array
                //find null blocks
                //find values at end and start of null blocks
                //take first away from second
                //dived by number of nulls in block + 1
                //use this number to assign values to nulls adding it to the values
                //


                for (var i = 0; i < data.datasets.length; i++) {
                    var startNullBlockIndex = null,
                        endNullBlockIndex = null;
                    for (var j = 0; j < data.datasets[i].data.length; j++) {
                        var dataPointValue = data.datasets[i].data[j];

                        if (dataPointValue === null && startNullBlockIndex !== null) {
                            endNullBlockIndex = j - 1;
                        } else if (dataPointValue === null && startNullBlockIndex === null) {

                        }
                    }
                }
            }

            //Iterate through each of the datasets, and build this into a property of the chart
            helpers.each(data.datasets, function(dataset) {
                var datasetObject = {
                    label: dataset.label || null,
                    fillColor: dataset.fillColor,
                    strokeColor: dataset.strokeColor,
                    pointColor: dataset.pointColor,
                    pointStrokeColor: dataset.pointStrokeColor,
                    showTooltip: dataset.showTooltip,
                    points: [],
                    yAxesGroup: dataset.yAxesGroup,
                    values: dataset.data
                };

                this.datasets.push(datasetObject);


                helpers.each(dataset.data, function(dataPoint, index) {
                    //Add a new point for each piece of data, passing any required data to draw.
                    datasetObject.points.push(new this.PointClass({

                        //if datapoint is null add a flag to ignore this point
                        ignore: dataPoint === null,
                        showTooltip: dataset.showTooltip === undefined ? true : dataset.showTooltip,
                        value: dataPoint === null ? 0 : dataPoint,
                        label: data.labels[index],
                        datasetLabel: dataset.label,
                        strokeColor: dataset.pointStrokeColor,
                        fillColor: dataset.pointColor,
                        highlightFill: dataset.pointHighlightFill || dataset.pointColor,
                        highlightStroke: dataset.pointHighlightStroke || dataset.pointStrokeColor,
                        yAxesGroup: dataset.yAxesGroup,
                    }));
                }, this);
            }, this);

            this.buildScale(data.labels);

            if (this.scale.min < 0) {
                var basePercetage = (-1 * parseFloat(this.scale.min) /
                    (this.scale.max - this.scale.min) * 1.00);
                var totalHeight = (this.scale.endPoint - this.scale.startPoint);
                var originFromEnd = basePercetage * totalHeight;
                var base = this.scale.endPoint - originFromEnd + this.options.scaleGridLineWidth;


                this.PointClass.prototype.base = base;
            } else {
                this.PointClass.prototype.base = this.scale.endPoint;
            }
            this.eachPoints(function(point, index) {
                helpers.extend(point, {
                    x: this.scale.calculateX(index),
                    y: this.scale.endPoint
                });
                point.save();
            }, this);




            this.render();
        },
        update: function() {
            this.scale.update();
            // Reset any highlight colours before updating.
            helpers.each(this.activeElements, function(activeElement) {
                activeElement.restore(['fillColor', 'strokeColor']);
            });
            this.eachPoints(function(point) {
                point.save();
            });
            this.render();
        },
        eachPoints: function(callback) {
            helpers.each(this.datasets, function(dataset) {
                helpers.each(dataset.points, callback, this);
            }, this);
        },
        getPointsAtEvent: function(e) {
            var pointsArray = [],
                eventPosition = helpers.getRelativePosition(e);
            helpers.each(this.datasets, function(dataset) {
                helpers.each(dataset.points, function(point) {
                    if (point.inRange(eventPosition.x, eventPosition.y) && point.showTooltip && !point.ignore) pointsArray.push(point);
                });
            }, this);
            return pointsArray;
        },
        buildScale: function(labels) {
            var self = this;

            var dataTotal = function() {
                var values = [];
                self.eachPoints(function(point) {
                    if (point.value !== null) {
                        values.push(point.value);
                    }
                });

                return values;
            };
            var scaleOptions = {
                labelLength: this.options.labelLength,
                templateString: this.options.scaleLabel,
                height: this.chart.height,
                width: this.chart.width,
                ctx: this.chart.ctx,
                labelsFilter: this.options.labelsFilter,
                textColor: this.options.scaleFontColor,
                fontSize: this.options.scaleFontSize,
                fontStyle: this.options.scaleFontStyle,
                fontFamily: this.options.scaleFontFamily,
                valuesCount: labels.length,
                beginAtZero: this.options.scaleBeginAtZero,
                integersOnly: this.options.scaleIntegersOnly,
                xLabels: labels,
                font: helpers.fontString(this.options.scaleFontSize, this.options.scaleFontStyle, this.options.scaleFontFamily),
                lineWidth: this.options.scaleLineWidth,
                lineColor: this.options.scaleLineColor,
                showHorizontalLines: this.options.scaleShowHorizontalLines,
                showVerticalLines: this.options.scaleShowVerticalLines,
                gridLineWidth: (this.options.scaleShowGridLines) ? this.options.scaleGridLineWidth : 0,
                gridLineColor: (this.options.scaleShowGridLines) ? this.options.scaleGridLineColor : "rgba(0,0,0,0)",
                padding: (this.options.showScale) ? 0 : this.options.pointDotRadius + this.options.pointDotStrokeWidth,
                showLabels: this.options.scaleShowLabels,
                display: this.options.showScale,
                yAxes: this.yAxes,
                positionLeft: this.options.scalePositionLeft,
                datasets: this.datasets,
            };
            if (this.options.scaleOverride) {
                helpers.extend(scaleOptions, {
                    scaleOverride: this.options.scaleOverride,
                    calculateYRange: helpers.noop,
                    steps: this.options.scaleSteps,
                    stepValue: this.options.scaleStepWidth,
                    min: this.options.scaleStartValue,
                    max: this.options.scaleStartValue + (this.options.scaleSteps * this.options.scaleStepWidth)
                });
            }


            this.scale = new Chart.Scale(scaleOptions);
        },
        addData: function(valuesArray, label) {
            //Map the values array for each of the datasets
            helpers.each(valuesArray, function(value, datasetIndex) {
                //Add a new point for each piece of data, passing any required data to draw.
                this.datasets[datasetIndex].points.push(new this.PointClass({
                    value: value,
                    label: label,
                    x: this.scale.calculateX(this.scale.valuesCount + 1),
                    y: this.scale.base,
                    strokeColor: this.datasets[datasetIndex].pointStrokeColor,
                    fillColor: this.datasets[datasetIndex].pointColor,
                    yAxesGroup: this.datasets[datasetIndex].yAxesGroup
                }));
            }, this);

            this.scale.addXLabel(label);
            //Then re-render the chart.
            this.update();
        },
        removeData: function() {
            this.scale.removeXLabel();
            //Then re-render the chart.
            helpers.each(this.datasets, function(dataset) {
                dataset.points.shift();
            }, this);
            this.update();
        },
        reflow: function() {
            var newScaleProps = helpers.extend({
                height: this.chart.height,
                width: this.chart.width
            });
            this.scale.update(newScaleProps);
        },

        //extracted from draw() so it can be used to draw any line datasets
        drawDatasets: function(datasets, easingDecimal) {
            var ctx = this.chart.ctx;

            // Some helper methods for getting the next/prev points
            var hasValue = function(item) {
                    return item.value !== null;
                },
                nextPoint = function(point, collection, index) {
                    return helpers.findNextWhere(collection, hasValue, index) || point;
                },
                previousPoint = function(point, collection, index) {
                    return helpers.findPreviousWhere(collection, hasValue, index) || point;
                };

            this.scale.draw(easingDecimal);


            helpers.each(this.datasets, function(dataset) {
                //Transition each point first so that the line and point drawing isn't out of sync
                //We can use this extra loop to calculate the control points of this dataset also in this loop

                helpers.each(dataset.points, function(point, index) {
                    point.transition({
                        y: this.scale.calculateY(point),
                        x: this.scale.calculateX(index)
                    }, easingDecimal);

                }, this);


                // Control points need to be calculated in a seperate loop, because we need to know the current x/y of the point
                // This would cause issues when there is no animation, because the y of the next point would be 0, so beziers would be skewed
                if (this.options.bezierCurve) {
                    helpers.each(dataset.points, function(point, index) {

                        //If we're at the start or end, we don't have a previous/next point
                        //By setting the tension to 0 here, the curve will transition to straight at the end
                        var nextPoint, previousPoint, thispoint;
                        if (index === 0) {
                            nextPoint = this.getNextDataPoint(dataset, index) || point;
                            point.controlPoints = helpers.splineCurve(point, point, nextPoint, 0);
                        } else if (index === dataset.points.length - 1) {
                            previousPoint = this.getLastDataPoint(dataset, index) || point;
                            point.controlPoints = helpers.splineCurve(previousPoint, point, point, 0);
                        } else {
                            previousPoint = this.getLastDataPoint(dataset, index) || point;
                            nextPoint = this.getNextDataPoint(dataset, index) || point;
                            thispoint = this.getThisPoint(dataset, index) || point;
                            point.controlPoints = helpers.splineCurve(previousPoint, thispoint, nextPoint, this.options.bezierCurveTension);
                        }
                    }, this);
                }


                //Draw the line between all the points
                ctx.lineWidth = this.options.datasetStrokeWidth;
                ctx.strokeStyle = dataset.strokeColor;


                var penDown = false;
                var start = null;
                var started = false;

                helpers.each(dataset.points, function(point, index) {
                    if (this.scale.getAxisMin(point) < 0) {
                        point.base = this.scale.getAxisBase(point);
                    } else {
                        point.base = this.scale.endPoint;
                    }
                    /**
                     * no longer draw if the last point was ignore (as we don;t have anything to draw from)
                     * or if this point is ignore
                     * or if it's the first
                     */
                    if ((!point.ignore || (this.options.populateSparseData && started)) && !penDown) {
                        ctx.beginPath();
                        penDown = true;
                        start = point;
                        started = true;
                    }
                    if (index > 0 && (!dataset.points[index - 1].ignore || this.options.populateSparseData) && (!point.ignore || this.options.populateSparseData)) {
                        if (dataset.points[index].ignore) {

                        } else if (this.options.bezierCurve) {
                            var lastDataPoint = this.getLastDataPoint(dataset, index);
                            if (lastDataPoint) {

                                ctx.bezierCurveTo(
                                    lastDataPoint.controlPoints.outer.x,
                                    lastDataPoint.controlPoints.outer.y,
                                    point.controlPoints.inner.x,
                                    point.controlPoints.inner.y,
                                    point.x,
                                    point.y
                                );
                            } else {
                                ctx.moveTo(point.x, point.y);
                            }
                        } else {
                            ctx.lineTo(point.x, point.y);
                        }

                    } else if (index === 0 || (dataset.points[index - 1].ignore && !this.options.populateSparseData)) {
                        ctx.moveTo(point.x, point.y);
                    }

                    if (((dataset.points.length > index + 1 && (dataset.points[index + 1].ignore && !this.options.populateSparseData)) ||
                            dataset.points.length == index + 1) && (!point.ignore || this.options.populateSparseData)) {
                        ctx.stroke();

                        if (dataset.points.length == index + 1 && point.ignore) {
                            point = this.getLastDataPoint(dataset, index);
                        }
                        if (this.options.datasetFill) {
                            ctx.lineTo(point.x, point.base);
                            ctx.lineTo(start.x, point.base);
                            ctx.fillStyle = dataset.fillColor;
                            ctx.closePath();
                            if (point.x != start.x) {
                                ctx.fill();
                            }
                        }



                        penDown = false;
                    }

                }, this);


                //Now draw the points over the line
                //A little inefficient double looping, but better than the line
                //lagging behind the point positions
                helpers.each(dataset.points, function(point) {
                    /**
                     * don't draw the dot if we are ignoring
                     */
                    if (!point.ignore)
                        point.draw();
                });

            }, this);

        },

        getLastDataPoint: function(dataset, index) {
            var lastPointWithData = null;
            if (this.options.populateSparseData) {
                for (var i = index - 1; i >= 0; i--) {
                    if (!dataset.points[i].ignore) {
                        lastPointWithData = dataset.points[i];
                        break;
                    }
                }
            } else {
                index--;
                if (index >= 0 && !dataset.points[index].ignore) {
                    lastPointWithData = dataset.points[index];
                }
            }
            return lastPointWithData;
        },

        getNextDataPoint: function(dataset, index) {
            var nextDataPoint = null;
            if (this.options.populateSparseData) {
                for (var i = index + 1; i < dataset.points.length; i++) {
                    if (!dataset.points[i].ignore) {
                        nextDataPoint = dataset.points[i];
                        break;
                    }
                }


            } else {
                index++;
                if (index < dataset.points.length && !dataset.points[index].ignore) {
                    nextDataPoint = dataset.points[index];
                }
            }
            return nextDataPoint;
        },

        getThisPoint: function(dataset, index) {
            var thisDataPoint = null;
            if (dataset.points[index].ignore) {
                var groupLength, pointInGroup, startValue, endValue, startIndex, endIndex;
                for (var i = index + 1; i < dataset.points.length; i++) {
                    if (!dataset.points[i].ignore && i + 1 <= dataset.points.length) {
                        endIndex = i;
                        endValue = dataset.points[i + 1];
                    }
                }
            }


        },
        draw: function(ease) {
            var easingDecimal = ease || 1;
            this.clear();

            this.scale.draw(easingDecimal);
            this.drawDatasets(this.datasets, easingDecimal);
        }
    });


}).call(this);