/**

 * This is an experimental Highcharts module that draws long data series on a canvas

 * in order to increase performance of the initial load time and tooltip responsiveness.

 *

 * Compatible with HTML5 canvas compatible browsers (not IE < 9).

 *

 * Author: Torstein Honsi

 *

 * 

 * Development plan

 * - Column range.

 * - Heatmap.

 * - Treemap.

 * - Check how it works with Highstock and data grouping.

 * - Check inverted charts.

 * - Check reversed axes.

 * - Chart callback should be async after last series is drawn. (But not necessarily, we don't do

     that with initial series animation).

 * - Cache full-size image so we don't have to redraw on hide/show and zoom up. But k-d-tree still

 *   needs to be built.

 * - Test IE9 and IE10.

 * - Stacking is not perhaps not correct since it doesn't use the translation given in 

 *   the translate method. If this gets to complicated, a possible way out would be to 

 *   have a simplified renderCanvas method that simply draws the areaPath on a canvas.

 *

 * If this module is taken in as part of the core

 * - All the loading logic should be merged with core. Update styles in the core.

 * - Most of the method wraps should probably be added directly in parent methods.

 *

 * Notes for boost mode

 * - Area lines are not drawn

 * - Point markers are not drawn

 * - Zones and negativeColor don't work

 * - Columns are always one pixel wide. Don't set the threshold too low.

 *

 * Optimizing tips for users

 * - For scatter plots, use a marker.radius of 1 or less. It results in a rectangle being drawn, which is 

 *   considerably faster than a circle.

 * - Set extremes (min, max) explicitly on the axes in order for Highcharts to avoid computing extremes.

 * - Set enableMouseTracking to false on the series to improve total rendering time.

 * - The default threshold is set based on one series. If you have multiple, dense series, the combined

 *   number of points drawn gets higher, and you may want to set the threshold lower in order to 

 *   use optimizations.

 */

/*global document, Highcharts, HighchartsAdapter, setTimeout */

(function (H, HA) {



    'use strict';



    var noop = function () { return undefined; },

        Color = H.Color,

        Series = H.Series,

        seriesTypes = H.seriesTypes,

        each = H.each,

        extend = H.extend,

        addEvent = HA.addEvent,

        fireEvent = HA.fireEvent,

        merge = H.merge,

        pick = H.pick,

        wrap = H.wrap,

        plotOptions = H.getOptions().plotOptions,

        CHUNK_SIZE = 50000;



    function eachAsync(arr, fn, callback, chunkSize, i) {

        i = i || 0;

        chunkSize = chunkSize || CHUNK_SIZE;

        each(arr.slice(i, i + chunkSize), fn);

        if (i + chunkSize < arr.length) {

            setTimeout(function () {

                eachAsync(arr, fn, callback, chunkSize, i + chunkSize);

            });

        } else if (callback) {

            callback();

        }

    }



    // Set default options

    each(['area', 'arearange', 'column', 'line', 'scatter'], function (type) {

        if (plotOptions[type]) {

            plotOptions[type].boostThreshold = 5000;

        }

    });



    /**

     * Override a bunch of methods the same way. If the number of points is below the threshold,

     * run the original method. If not, check for a canvas version or do nothing.

     */

    each(['translate', 'generatePoints', 'drawTracker', 'drawPoints', 'render'], function (method) {

        function branch(proceed) {

            var letItPass = this.options.stacking && (method === 'translate' || method === 'generatePoints');

            if ((this.processedXData || this.options.data).length < (this.options.boostThreshold || Number.MAX_VALUE) ||

                    letItPass) {



                // Clear image

                if (method === 'render' && this.image) {

                    this.image.attr({ href: '' });

                    this.animate = null; // We're zooming in, don't run animation

                }



                proceed.call(this);



            // If a canvas version of the method exists, like renderCanvas(), run

            } else if (this[method + 'Canvas']) {



                this[method + 'Canvas']();

            }

        }

        wrap(Series.prototype, method, branch);



        // A special case for some types - its translate method is already wrapped

        if (method === 'translate') {

            if (seriesTypes.column) {

                wrap(seriesTypes.column.prototype, method, branch);

            }

            if (seriesTypes.arearange) {

                wrap(seriesTypes.arearange.prototype, method, branch);

            }

        }

    });



    /**

     * Do not compute extremes when min and max are set.

     * If we use this in the core, we can add the hook to hasExtremes to the methods directly.

     */

    wrap(Series.prototype, 'getExtremes', function (proceed) {

        if (!this.hasExtremes()) {

            proceed.apply(this, Array.prototype.slice.call(arguments, 1));

        }

    });

    wrap(Series.prototype, 'setData', function (proceed) {

        if (!this.hasExtremes(true)) {

            proceed.apply(this, Array.prototype.slice.call(arguments, 1));

        }

    });

    wrap(Series.prototype, 'processData', function (proceed) {

        if (!this.hasExtremes(true)) {

            proceed.apply(this, Array.prototype.slice.call(arguments, 1));

        }

    });





    H.extend(Series.prototype, {

        pointRange: 0,



        hasExtremes: function (checkX) {

            var options = this.options,

                data = options.data,

                xAxis = this.xAxis && this.xAxis.options,

                yAxis = this.yAxis && this.yAxis.options;

            return data.length > (options.boostThreshold || Number.MAX_VALUE) && typeof yAxis.min === 'number' && typeof yAxis.max === 'number' &&

                (!checkX || (typeof xAxis.min === 'number' && typeof xAxis.max === 'number'));

        },



        /**

         * If implemented in the core, parts of this can probably be shared with other similar

         * methods in Highcharts.

         */

        destroyGraphics: function () {

            var series = this,

                points = this.points,

                point,

                i;



            for (i = 0; i < points.length; i = i + 1) {

                point = points[i];

                if (point && point.graphic) {

                    point.graphic = point.graphic.destroy();

                }

            }



            each(['graph', 'area'], function (prop) {

                if (series[prop]) {

                    series[prop] = series[prop].destroy();

                }

            });

        },



        /**

         * Create a hidden canvas to draw the graph on. The contents is later copied over 

         * to an SVG image element.

         */

        getContext: function () {

            var width = this.chart.plotWidth,

                height = this.chart.plotHeight;



            if (!this.canvas) {

                this.canvas = document.createElement('canvas');

                this.image = this.chart.renderer.image('', 0, 0, width, height).add(this.group);

                this.ctx = this.canvas.getContext('2d');

            } else {

                this.ctx.clearRect(0, 0, width, height);

            }



            this.canvas.setAttribute('width', width);

            this.canvas.setAttribute('height', height);

            this.image.attr({

                width: width,

                height: height

            });



            return this.ctx;

        },



        /** 

         * Draw the canvas image inside an SVG image

         */

        canvasToSVG: function () {

            this.image.attr({ href: this.canvas.toDataURL('image/png') });

        },



        cvsLineTo: function (ctx, clientX, plotY) {

            ctx.lineTo(clientX, plotY);

        },



        renderCanvas: function () {

            var series = this,

                options = series.options,

                chart = series.chart,

                xAxis = this.xAxis,

                yAxis = this.yAxis,

                ctx,

                i,

                c = 0,

                xData = series.processedXData,

                yData = series.processedYData,

                rawData = options.data,

                xExtremes = xAxis.getExtremes(),

                xMin = xExtremes.min,

                xMax = xExtremes.max,

                yExtremes = yAxis.getExtremes(),

                yMin = yExtremes.min,

                yMax = yExtremes.max,

                pointTaken = {},

                lastClientX,

                sampling = !!series.sampling,

                points,

                r = options.marker && options.marker.radius,

                cvsDrawPoint = this.cvsDrawPoint,

                cvsLineTo = options.lineWidth ? this.cvsLineTo : false,

                cvsMarker = r <= 1 ? this.cvsMarkerSquare : this.cvsMarkerCircle,

                enableMouseTracking = options.enableMouseTracking !== false,

                lastPoint,

                threshold = options.threshold,

                yBottom = yAxis.getThreshold(threshold),

                hasThreshold = typeof threshold === 'number',

                translatedThreshold = yBottom,

                doFill = this.fill,

                isRange = series.pointArrayMap && series.pointArrayMap.join(',') === 'low,high',

                isStacked = !!options.stacking,

                cropStart = series.cropStart || 0,

                loadingOptions = chart.options.loading,

                requireSorting = series.requireSorting,

                wasNull,

                connectNulls = options.connectNulls,

                useRaw = !xData,

                minVal,

                maxVal,

                minI,

                maxI,

                fillColor = series.fillOpacity ?

                        new Color(series.color).setOpacity(pick(options.fillOpacity, 0.75)).get() :

                        series.color,

                stroke = function () {

                    if (doFill) {

                        ctx.fillStyle = fillColor;

                        ctx.fill();

                    } else {

                        ctx.strokeStyle = series.color;

                        ctx.lineWidth = options.lineWidth;

                        ctx.stroke();

                    }

                },

                drawPoint = function (clientX, plotY, yBottom) {

                    if (c === 0) {

                        ctx.beginPath();

                    }



                    if (wasNull) {

                        ctx.moveTo(clientX, plotY);

                    } else {

                        if (cvsDrawPoint) {

                            cvsDrawPoint(ctx, clientX, plotY, yBottom, lastPoint);

                        } else if (cvsLineTo) {

                            cvsLineTo(ctx, clientX, plotY);

                        } else if (cvsMarker) {

                            cvsMarker(ctx, clientX, plotY, r);

                        }

                    }



                    // We need to stroke the line for every 1000 pixels. It will crash the browser

                    // memory use if we stroke too infrequently.

                    c = c + 1;

                    if (c === 1000) {

                        stroke();

                        c = 0;

                    }



                    // Area charts need to keep track of the last point

                    lastPoint = {

                        clientX: clientX,

                        plotY: plotY,

                        yBottom: yBottom

                    };

                },



                addKDPoint = function (clientX, plotY, i) {



                    // The k-d tree requires series points. Reduce the amount of points, since the time to build the 

                    // tree increases exponentially.

                    if (enableMouseTracking && !pointTaken[clientX + ',' + plotY]) {

                        points.push({

                            clientX: clientX,

                            plotX: clientX,

                            plotY: plotY,

                            i: cropStart + i

                        });

                        pointTaken[clientX + ',' + plotY] = true;

                    }

                };



            // If we are zooming out from SVG mode, destroy the graphics

            if (this.points) {

                this.destroyGraphics();

            }



            // The group

            series.plotGroup(

                'group',

                'series',

                series.visible ? 'visible' : 'hidden',

                options.zIndex,

                chart.seriesGroup

            );



            series.getAttribs();

            series.markerGroup = series.group;

            addEvent(series, 'destroy', function () {

                series.markerGroup = null;

            });



            points = this.points = [];

            ctx = this.getContext();

            series.buildKDTree = noop; // Do not start building while drawing 



            // Display a loading indicator

            if (rawData.length > 99999) {

                chart.options.loading = merge(loadingOptions, {

                    labelStyle: {

                        backgroundColor: 'rgba(255,255,255,0.75)',

                        padding: '1em',

                        borderRadius: '0.5em'

                    },

                    style: {

                        backgroundColor: 'none',

                        opacity: 1

                    }

                });

                chart.showLoading('Drawing...');

                chart.options.loading = loadingOptions; // reset

                if (chart.loadingShown === true) {

                    chart.loadingShown = 1;

                } else {

                    chart.loadingShown = chart.loadingShown + 1;

                }

            }



            // Loop over the points

            i = 0;

            eachAsync(isStacked ? series.data : (xData || rawData), function (d) {



                var x,

                    y,

                    clientX,

                    plotY,

                    isNull,

                    low,

                    isYInside = true;



                if (useRaw) {

                    x = d[0];

                    y = d[1];

                } else {

                    x = d;

                    y = yData[i];

                }



                // Resolve low and high for range series

                if (isRange) {

                    if (useRaw) {

                        y = d.slice(1, 3);

                    }

                    low = y[0];

                    y = y[1];

                } else if (isStacked) {

                    x = d.x;

                    y = d.stackY;

                    low = y - d.y;

                }



                isNull = y === null;



                // Optimize for scatter zooming

                if (!requireSorting) {

                    isYInside = y >= yMin && y <= yMax;

                }



                if (!isNull && x >= xMin && x <= xMax && isYInside) {



                    clientX = Math.round(xAxis.toPixels(x, true));



                    if (sampling) {

                        if (minI === undefined || clientX === lastClientX) {

                            if (!isRange) {

                                low = y;

                            }

                            if (maxI === undefined || y > maxVal) {

                                maxVal = y;

                                maxI = i;

                            }

                            if (minI === undefined || low < minVal) {

                                minVal = low;

                                minI = i;

                            }



                        }

                        if (clientX !== lastClientX) { // Add points and reset

                            if (minI !== undefined) { // then maxI is also a number

                                plotY = yAxis.toPixels(maxVal, true);

                                yBottom = yAxis.toPixels(minVal, true);

                                drawPoint(

                                    clientX,

                                    hasThreshold ? Math.min(plotY, translatedThreshold) : plotY,

                                    hasThreshold ? Math.max(yBottom, translatedThreshold) : yBottom

                                );

                                addKDPoint(clientX, plotY, maxI);

                                if (yBottom !== plotY) {

                                    addKDPoint(clientX, yBottom, minI);

                                }

                            }





                            minI = maxI = undefined;

                            lastClientX = clientX;

                        }

                    } else {

                        plotY = Math.round(yAxis.toPixels(y, true));

                        drawPoint(clientX, plotY, yBottom);

                        addKDPoint(clientX, plotY, i);

                    }

                }

                wasNull = isNull && !connectNulls;



                i = i + 1;



                if (i % CHUNK_SIZE === 0) {

                    series.canvasToSVG();

                }



            }, function () {



                var loadingDiv = chart.loadingDiv,

                    loadingShown = +chart.loadingShown;



                stroke();

                series.canvasToSVG();



                fireEvent(series, 'renderedCanvas');



                // Do not use chart.hideLoading, as it runs JS animation and will be blocked by buildKDTree.

                // CSS animation looks good, but then it must be deleted in timeout. If we add the module to core,

                // change hideLoading so we can skip this block.

                if (loadingShown === 1) {

                    extend(loadingDiv.style, {

                        transition: 'opacity 250ms',

                        opacity: 0

                    });



                    chart.loadingShown = false;

                    setTimeout(function () {

                        if (loadingDiv.parentNode) { // In exporting it is falsy

                            loadingDiv.parentNode.removeChild(loadingDiv);

                        }

                        chart.loadingDiv = chart.loadingSpan = null;

                    }, 250);

                }

                if (loadingShown) {

                    chart.loadingShown = loadingShown - 1;

                }



                // Pass tests in Pointer. 

                // TODO: Replace this with a single property, and replace when zooming in

                // below boostThreshold.

                series.directTouch = false;

                series.options.stickyTracking = true;



                delete series.buildKDTree; // Go back to prototype, ready to build

                series.buildKDTree();



             // Don't do async on export, the exportChart, getSVGForExport and getSVG methods are not chained for it.

            }, chart.renderer.forExport ? Number.MAX_VALUE : undefined);

        }

    });



    seriesTypes.scatter.prototype.cvsMarkerCircle = function (ctx, clientX, plotY, r) {

        ctx.moveTo(clientX, plotY);

        ctx.arc(clientX, plotY, r, 0, 2 * Math.PI, false);

    };



    // Rect is twice as fast as arc, should be used for small markers

    seriesTypes.scatter.prototype.cvsMarkerSquare = function (ctx, clientX, plotY, r) {

        ctx.moveTo(clientX, plotY);

        ctx.rect(clientX - r, plotY - r, r * 2, r * 2);

    };

    seriesTypes.scatter.prototype.fill = true;



    extend(seriesTypes.area.prototype, {

        cvsDrawPoint: function (ctx, clientX, plotY, yBottom, lastPoint) {

            if (lastPoint && clientX !== lastPoint.clientX) {

                ctx.moveTo(lastPoint.clientX, lastPoint.yBottom);

                ctx.lineTo(lastPoint.clientX, lastPoint.plotY);

                ctx.lineTo(clientX, plotY);

                ctx.lineTo(clientX, yBottom);

            }

        },

        fill: true,

        fillOpacity: true,

        sampling: true

    });



    extend(seriesTypes.column.prototype, {

        cvsDrawPoint: function (ctx, clientX, plotY, yBottom) {

            ctx.rect(clientX - 1, plotY, 1, yBottom - plotY);

        },

        fill: true,

        sampling: true

    });



    /**

     * Return a point instance from the k-d-tree

     */

    wrap(Series.prototype, 'searchPoint', function (proceed) {

        var point = proceed.apply(this, [].slice.call(arguments, 1)),

            ret = point;



        if (point && !(point instanceof this.pointClass)) {

            ret = (new this.pointClass()).init(this, this.options.data[point.i]);

            ret.dist = point.dist;

            ret.category = ret.x;

            ret.plotX = point.plotX;

            ret.plotY = point.plotY;

        }

        return ret;

    });

}(Highcharts, HighchartsAdapter));

