/**
 * The 'd3-sunburst' component visualizes tree nodes as donut sectors,
 * with the root circle in the center. The angle and area of each sector corresponds
 * to its {@link Ext.d3.hierarchy.Hierarchy#nodeValue value}. By default
 * the same value is returned for each node, meaning that siblings will span equal
 * angles and occupy equal area.
 *
 *     @example
 *     Ext.create('Ext.panel.Panel', {
 *         renderTo: Ext.getBody(),
 *         title: 'Sunburst Chart',
 *         height: 750,
 *         width: 750,
 *         layout: 'fit',
 *         items: [
 *             {
 *                 xtype: 'd3-sunburst',
 *                 padding: 20,
 *                 tooltip: {
 *                     renderer: function(component, tooltip, node) {
 *                         tooltip.setHtml(node.data.get('text'));
 *                     }
 *                 },
 *                 store: {
 *                     type: 'tree',
 *                     data: [
 *                         {
 *                             text: "Oscorp",
 *                             children: [
 *                                 {text: 'Norman Osborn'},
 *                                 {text: 'Harry Osborn'},
 *                                 {text: 'Arthur Stacy'}
 *                             ]
 *                         },
 *                         {
 *                             text: "SHIELD",
 *                             children: [
 *                                 {text: 'Nick Fury'},
 *                                 {text: 'Maria Hill'},
 *                                 {text: 'Tony Stark'}
 *                             ]
 *                         },
 *                         {
 *                             text: "Illuminati",
 *                             children: [
 *                                 {text: 'Namor'},
 *                                 {text: 'Tony Stark'},
 *                                 {text: 'Reed Richards'},
 *                                 {text: 'Black Bolt'},
 *                                 {text: 'Stephen Strange'},
 *                                 {text: 'Charles Xavier'}
 *                             ]
 *                         }
 *                     ]
 *                 }
 *             }
 *         ]
 *     });
 *
 */
Ext.define('Ext.d3.hierarchy.partition.Sunburst', {
    extend: 'Ext.d3.hierarchy.partition.Partition',
    xtype: 'd3-sunburst',
 
    config: {
        componentCls: 'sunburst',
 
        /**
         * The padding of a node's text inside its container.
         * @cfg {Array} textPadding
         */
        textPadding: [5, '0.35em'],
 
        /**
         * The radius of the dot in the center of the sunburst that represents the parent node
         * of the currently visible node hierarchy and allows to zoom one level up by clicking
         * or tapping it.
         * @cfg {Number} [zoomParentDotRadius=30]
         */
        zoomParentDotRadius: 30,
 
        transitions: {
            zoom: {
                duration: 1000,
                ease: 'cubicInOut'
            }
        }
    },
 
    setupScene: function(scene) {
        this.callParent([scene]);
        this.setupScales();
        this.setupArcGenerator();
    },
 
    scaleDefaults: {
        x: {
            domain: [0, 1],
            range: [0, 2 * Math.PI]
        },
        y: {
            domain: [0, 1]
        }
    },
 
    setupScales: function() {
        var defaults = this.scaleDefaults;
 
        // Node's x0 & x1 properties will represent the angle.
        // Node's y0 & y1 properties will represent the area
        // divided by π (since circle area = πr², we can treat
        // the area as if it's been already divided by π and
        // remove it from the right side of the equation as well,
        // the relationship is still preserved, and we can simply
        // take a square root of the area to get the radius).
        this.xScale = d3.scaleLinear()
            .domain(defaults.x.domain.slice())
            .range(defaults.x.range.slice());
 
        this.yScale = d3.scaleSqrt()
            .domain(defaults.y.domain.slice());
    },
 
    /**
     * [Arc generator](https://github.com/mbostock/d3/wiki/SVG-Shapes#arc)
     * for sunburst slices.
     * @private
     * @property {Function} arc
     */
    arc: null,
 
    defaultCls: {
        center: Ext.baseCSSPrefix + 'd3-center'
    },
 
    setupArcGenerator: function() {
        var me = this,
            x = me.xScale,
            y = me.yScale;
 
        // Takes a node of a partition layout and returns an arc (in SVG path syntax)
        // that represents it.
        me.arc = d3.arc()
            .startAngle(function(node) {
                return Math.max(0, Math.min(2 * Math.PI, x(node.x0)));
            })
            .endAngle(function(node) {
                return Math.max(0, Math.min(2 * Math.PI, x(node.x1)));
            })
            .innerRadius(function(node) {
                return Math.max(0, y(node.y0));
            })
            .outerRadius(function(node) {
                return Math.max(0, y(node.y1));
            });
    },
 
    /**
     * @protected
     * Override parent method to neither set the layout size,
     * nor perform layout on scene resize.
     * The default layout size of 1x1 is used at all times.
     * Only the output range of scales changes.
     */
    onSceneResize: function(scene, rect) {
        var me = this,
            nodesGroup = me.nodesGroup,
            centerX = 0.5 * rect.width,
            centerY = 0.5 * rect.height,
            radius = Math.min(centerX, centerY);
 
        nodesGroup.attr('transform', 'translate(' + centerX + ',' + centerY + ')');
 
        me.setRadius(radius);
    },
 
    radius: null,
    minRadius: 1,
 
    setRadius: function(radius) {
        var me = this;
 
        radius = Math.max(me.minRadius, radius);
        me.radius = radius;
        me.yScale.range([0, radius]);
 
        me.renderScene();
    },
 
    /**
     * Zooms in the `node`, so that the sunburst only shows the node itself and its children.
     * To zoom in instantly, even when the {@link #transitions} `zoom` config is not `false`,
     * set the second argument to `true`.
     * @param {Ext.data.TreeModel} record 
     * @param {Boolean} [instantly] 
     */
    zoomInNode: function(record, instantly) {
        var me = this,
            scene = me.getScene(),
            parentRadius = me.getZoomParentDotRadius(),
            radius = me.radius,
            xScale = me.xScale,
            yScale = me.yScale,
            arc = me.arc,
            transition, node, nodes;
 
        if (me.hasFirstLayout && me.hasFirstRender && me.size && record && record.isNode) {
            node = me.nodeFromRecord(record);
        }
 
        if (!node) {
            return;
        }
 
        transition = me.createTransition(instantly ? 'none' : 'zoom', scene);
 
        nodes = transition.tween('scale', function() {
            // Default xScale and yScale domain is [0, 1].
            // Default xScale range is [0, 2π].
            // By reducing the xScale's domain to the span of selected slice,
            // we make it occupy the whole pie angle.
            // Similarly, by reducing the yScale's domain to an interval
            // past slice's radius, we make that slice and its children
            // occupy the whole pie radius.
            // By making the yScale's range start with a non-zero value,
            // we make a hole in the current subtree that now occupies
            // the whole pie. Inside that hole the parent node (that now
            // falls out of yScale's range) is going to be visible
            // and available for selection to go one level up.
            var xDomain = d3.interpolate(xScale.domain(), [node.x0, node.x1]),
                yDomain = d3.interpolate(yScale.domain(), [node.y0, 1]),
                yRange = d3.interpolate(yScale.range(), [node.y0 ? parentRadius : 0, radius]);
 
            return function(t) {
                xScale.domain(xDomain(t));
                yScale.domain(yDomain(t)).range(yRange(t));
            };
        })
        .selectAll('.' + me.defaultCls.node);
 
        nodes.selectAll('path')
            .attrTween('d', function(node) {
                return function(t) {
                    // Layout stays exactly the same, but scales
                    // change slightly on every frame.
                    return arc(node);
                };
            });
 
        nodes.selectAll('text')
            .call(me.positionTextFn.bind(me))
            .call(me.textVisibilityFn.bind(me));
 
        if (instantly) {
            me.xScale.domain([node.x0, node.x1]);
            me.yScale.domain([node.y0, 1]).range([node.y0 ? parentRadius : 0, radius]);
        }
    },
 
    onNodeSelect: function(node, el) {
        var me = this,
            transitionCfg = me.getTransitions().select,
            to = transitionCfg.targetScale,
            from = transitionCfg.sourceScale,
            transition = me.createTransition('select');
 
        me.callParent(arguments);
 
        // Bring selected element to front to avoid the stroke being clipped by adjacent elements.
        // Remove the fill, so that CSS can be used to specify the color of the selection.
        el.raise().select('path').style('fill', null);
 
        if (transition.duration()) {
            el.transition(transition)
              .attr('transform', 'scale(' + to + ',' + to + ')')
              .transition(transition)
              .attr('transform', 'scale(' + from + ',' + from + ')');
        }
    },
 
    onNodeDeselect: function(node, el) {
        var me = this,
            colorAxis = me.getColorAxis();
 
        me.callParent(arguments);
 
        // Restore the original color.
        // (see 'onNodeSelect' comments).
        el
            .select('path')
            .style('fill', function(node) {
                return colorAxis.getColor(node);
            });
    },
 
    /**
     * @private
     * Checks if a bounding box (e.g. of a text) fits inside a slice.
     * The bounding box is assumed to be centered in the middle of the slice
     * angularly, with the width of the box in the direction of the radius,
     * and left edge 'px' pixels from inner radius (r1).
     * @param {Object} bbox 
     * @param {Number} bbox.width 
     * @param {Number} bbox.height 
     * @param {Number} a1 Start angle in the [0, 2 * Math.PI] interval.
     * @param {Number} a2 End angle in the [0, 2 * Math.PI] interval.
     * @param {Number} r1 Inner radius.
     * @param {Number} r2 Outer radius.
     * @param {Number} px X-padding.
     * @param {Number} py Y-padding.
     * @returns {Boolean} 
     */
    isBBoxInSlice: function(bbox, a1, a2, r1, r2, px, py) {
        var a = Math.abs(a2 - a1),
            width = Math.abs(r2 - r1) - px * 2,
            // for very big angles text is never too tall,
            // so there must be some other limit
            height = a < Math.PI
                ? 2 * (r1 + px) * Math.tan(0.5 * a) - py * 2
                : 0.5 * r2,
            isWider = bbox.width > width,
            isTaller = bbox.height > height;
 
        return !(isWider || isTaller);
    },
 
    /**
     * @private
     */
    textVisibilityFn: function(selection) {
        var me = this,
            x = me.xScale,
            y = me.yScale,
            invisibleCls = me.defaultCls.invisible,
            textPadding = me.getTextPadding(),
            px = parseFloat(textPadding[0]),
            py = parseFloat(textPadding[1]),
            isTransition = selection instanceof d3.transition;
 
        function isInvisible(el, node) {
            //<debug>
            if (me.isDestroyed) {
                Ext.log.warn("Component is destroyed, shouldn't have executed this.");
            }
            //</debug>
 
            // eslint-disable-next-line vars-on-top
            var bbox = el._bbox || el.getBBox(), // SVG 'text' element
                a1 = x(node.x0),
                a2 = x(node.x1),
                r1 = y(node.y0),
                r2 = y(node.y1),
                isBBoxInSlice = me.isBBoxInSlice(bbox, a1, a2, r1, r2, px, py),
                xDomain = x.domain(),
                yDomain = y.domain(),
                isOutOfX = xDomain[0] > node.x0 || xDomain[1] < node.x1,
                isOutOfY = yDomain[0] > node.y0 || yDomain[1] < node.y1;
 
            return !isBBoxInSlice || isOutOfX || isOutOfY;
        }
 
        if (isTransition) {
            selection.tween('class.invisible', function(node) {
                var el = this;
 
                return function() {
                    d3.select(el).classed(invisibleCls, isInvisible(el, node));
                };
            });
        }
        else {
            selection.classed(invisibleCls, function(node) {
                return isInvisible(this, node);
            });
        }
    },
 
    /**
     * @private
     * @param {d3.selection} selection 'text' elements.
     */
    positionTextFn: function(selection) {
        var me = this,
            x = me.xScale,
            y = me.yScale,
            halfPi = Math.PI / 2,
            degreesPerRadian = 180 / Math.PI,
            isTween = selection instanceof d3.transition,
            method = isTween ? 'attrTween' : 'attr',
            xFn, transformFn;
 
        function getX(node) {
            return y(node.y0);
        }
 
        function getTransform(node) {
            return node === me.root
                ? ''
                : ('rotate(' + (x((node.x0 + node.x1) / 2) - halfPi) * degreesPerRadian + ')');
        }
 
        if (isTween) {
            // Interpolator factory evaluated for every element on transition start.
            xFn = function(node) {
                // Interpolator, invoked for every frame of transition and is given time t in [0, 1]
                return function() {
                    // We don't use the time here, as the running transition will be changing
                    // the domain of the scale used by the `getX` function, so each frame the result
                    // will be different even if `node.y` doesn't change. See the `zoomInNode`
                    // method for more details.
                    return getX(node);
                };
            };
 
            transformFn = function(node) {
                return function() {
                    return getTransform(node);
                };
            };
        }
        else {
            xFn = function(node) {
                return getX(node);
            };
 
            transformFn = function(node) {
                return getTransform(node);
            };
        }
 
        selection[method]('x', xFn)[method]('transform', transformFn);
    },
 
    addNodes: function(selection) {
        var me = this,
            textPadding = me.getTextPadding();
 
        selection
            .append('path')
            .attr('d', me.arc);
 
        selection
            .append('text')
            .attr('class', me.defaultCls.label)
            .each(function(node) {
                if (node !== me.root) {
                    this.setAttribute('dx', textPadding[0]);
                    this.setAttribute('dy', textPadding[1]);
                }
            });
    },
 
    measureLabels: function(selection) {
        var isTransition = selection instanceof d3.transition;
 
        if (!isTransition) {
            selection.each(function() {
                // Cache the bounding box on the element itself each time data updates
                // to use later during a transition.
                this._bbox = this.getBBox();
            });
        }
    },
 
    updateNodes: function(update, enter) {
        var me = this,
            colorAxis = me.getColorAxis(),
            nodeText = me.getNodeText(),
            selectionCfg = me.getSelection(),
            selection = update.merge(enter);
 
        selection
            .select('path')
            .attr('d', me.arc)
            .style('fill', function(node) {
                // Don't set the color of selected element to allow for CSS styling.
                return node.data === selectionCfg ? null : colorAxis.getColor(node);
            });
 
        selection
            .select('text')
            .text(function(node) {
                return nodeText(me, node);
            })
            .call(me.measureLabels.bind(me))
            .call(me.positionTextFn.bind(me))
            .call(me.textVisibilityFn.bind(me));
 
        me.nodesGroup.select('.' + me.defaultCls.selected).raise();
    },
 
    updateColorAxis: function(colorAxis) {
        var me = this;
 
        if (!me.isConfiguring) {
            me.getRenderedNodes()
                .select('path')
                .style('fill', function(node) {
                    return colorAxis.getColor(node);
                });
        }
    }
 
});