/**
 * The 'd3-treemap' component uses D3's
 * [TreeMap Layout](https://github.com/d3/d3-3.x-api-reference/blob/master/Treemap-Layout.md)
 * to recursively subdivide area into rectangles, where the area of any node in the tree
 * corresponds to its value.
 *
 *     @example
 *     Ext.create('Ext.panel.Panel', {
 *         renderTo: Ext.getBody(),
 *         title: 'TreeMap Chart',
 *         height: 750,
 *         width: 750,
 *         layout: 'fit',
 *         items: [
 *             {
 *                 xtype: 'd3-treemap',
 *                 tooltip: {
 *                     renderer: function (component, tooltip, record) {
 *                         tooltip.setHtml(record.get('text'));
 *                     }
 *                 },
 *                 nodeValue: function (node) {
 *                     // Associates the rendered size of the box to a value in your data
 *                     return node.data.value;
 *                 },
 *                 store: {
 *                     type: 'tree',
 *                     data: [
 *                         {  text: 'Hulk',
 *                            value : 5,
 *                            children: [
 *                                 { text: 'The Leader', value: 3 },
 *                                 { text: 'Abomination', value: 2 },
 *                                 { text: 'Sandman', value: 1 }
 *                             ]
 *                         },
 *                         {   text: 'Vision',
 *                             value : 4,
 *                             children: [
 *                                 { text: 'Kang', value: 4 },
 *                                 { text: 'Magneto', value: 3 },
 *                                 { text: 'Norman Osborn', value: 2 },
 *                                 { text: 'Anti-Vision', value: 1 }
 *                             ]
 *                         },
 *                         {   text: 'Ghost Rider',
 *                             value : 3,
 *                             children: [
 *                                 { text: 'Mephisto', value: 1 }
 *                             ]
 *                         },
 *                         {   text: 'Loki',
 *                             value : 2,
 *                             children: [
 *                                 { text: 'Captain America', value: 3 },
 *                                 { text: 'Deadpool', value: 4 },
 *                                 { text: 'Odin', value: 5 },
 *                                 { text: 'Scarlet Witch', value: 2 },
 *                                 { text: 'Silver Surfer', value: 1 }
 *                             ]
 *                         },
 *                         {   text: 'Daredevil',
 *                             value : 1,
 *                             children: [
 *                                 { text: 'Purple Man', value: 4 },
 *                                 { text: 'Kingpin', value: 3 },
 *                                 { text: 'Namor', value: 2 },
 *                                 { text: 'Sabretooth', value: 1 }
 *                             ]
 *                         }
 *                     ]
 *                 }
 *             }
 *         ]
 *     });
 *
 */
Ext.define('Ext.d3.hierarchy.TreeMap', {
    extend: 'Ext.d3.hierarchy.Hierarchy',
    xtype: 'd3-treemap',
 
    config: {
        componentCls: 'treemap',
 
        /**
         * @cfg {Boolean} sticky
         * Whether the 'treemap' layout is sticky or not.
         * Once set, cannot be changed.
         * The expectation with treemap.sticky is that you use the same
         * root node as input to the layout but you change the value
         * function to alter how the child nodes are sized.
         * The reason for this constraint is that with a sticky layout,
         * the topology of the tree can't change — you must have the same
         * number of nodes in the same hierarchy. The only thing that
         * changes is the value.
         * [More info.](https://github.com/mbostock/d3/wiki/Treemap-Layout#sticky)
         */
        sticky: false,
 
        sorter: function () {
            // Have to do this for TreeMap because of the following issue:
            // https://sencha.jira.com/browse/EXTJS-21069
            return 0;
        },
 
        /**
         * @cfg {Object} parentTile
         *
         * Parent tile options.
         *
         * **Config Options**
         *
         * - padding {Number} Determines the amount of extra space to reserve between the
         * parent and its children (uniform on all sides).
         * This setting affects the layout of the treemap.
         * _defaults to 4_
         *
         * - label {Object} Parent tile label options.
         *
         *   - offset {Number[]} The offset of the label from the top-left corner of the
         * tile's rect.
         * _defaults to [5, 2]_
         *
         *   - clipSize {Number[]} If the size of a parent node is smaller than this
         * size, its label will be hidden.
         */
        parentTile: {
            padding: 4,
            label: {
                offset: [5, 2],
                clipSize: [110, 40]
            }
        },
 
        /**
         * @cfg {Object} leafTile
         *
         * Leaf tile options.
         *
         * ### Config Options
         *
         * - padding {Number} The amount by which the node's computed width and height
         * will be rendered smaller to make space between nodes. This setting affects the
         * presentation rather than layout.
         * _defaults to 4_
         *
         * - label {Object} Child tile label options
         *
         *   - offset {Number[]} The offset of the label from the top-left corner of the
         * tile's rect.
         * _defaults to [5, 1]_
         */
        leafTile: {
            padding: 0
        },
 
        colorAxis: {
            scale: {
                type: 'category20c'
            },
 
            field: 'name',
 
            processor: function (axis, scale, node, field) {
                // We want child nodes to have the same color as their parent by default,
                // but if we set their 'fill' to 'none', they won't be selectable, so we
                // fill them with an almost transparent white instead.
                return node.isLeaf() ? 'rgba(255,255,255,0.05)' : scale(node.data[field]);
            }
        },
 
        nodeTransform: function (selection) {
            // Because leaf tile padding simply subtracts that amount from leaf
            // nodes' width and height after the layout is done, all nodes have
            // to be translated by half the padding value to remain centered
            // in their parent.
            var leafTile = this.getLeafTile(),
                delta = leafTile.padding / 2;
 
            selection.attr('transform', function (node) {
                return 'translate(' + (node.x + delta) + ',' + (node.y + delta) + ')';
            });
        },
 
        /**
         * @cfg {String} busyLayoutText The text to show when the layout is in progress.
         */
        busyLayoutText: 'Layout in progress...'
    },
 
    applyLayout: function () {
        var me = this,
            sticky = me.getSticky(),
            layout = d3.layout.treemap()
                .size(null)
                .round(false)
                .sticky(sticky);
 
        // Set layout size to 'null', so that 'performLayout' won't run until the size
        // is set. Otherwise, 'performLayout' may run with default size of [1, 1],
        // which will prevent the 'sticky' config from working as intended.
 
        return layout;
    },
 
    setLayoutSize: function (size) {
        this.callParent([size]);
    },
 
    deferredLayoutId: null,
 
    isLayoutBlocked: function (layout) {
        var me = this,
            maskText = me.getBusyLayoutText(),
            blocked = false;
 
        if (layout.size()) {
            if (!me.deferredLayoutId) {
                me.showMask(maskText);
                // Let the mask render...
                // Note: small timeouts are not always enough to render the mask's DOM,
                //       100 seems to work every time everywhere.
                me.deferredLayoutId = setTimeout(me.performLayout.bind(me), 100);
                blocked = true;
            } else {
                clearTimeout(me.deferredLayoutId);
                me.deferredLayoutId = null;
            }
        } else {
            blocked = true;
        }
 
        return blocked;
    },
 
    onAfterRender: function () {
        this.hideMask();
    },
 
    /**
     * @private
     * A map of the {nodeId: Boolean} format,
     * where the Boolean value controls visibility of the label.
     */
    hiddenParentLabels: null,
 
    /**
     * @private
     * A map of the {nodeId: SVGRect} format,
     * where the SVGRect value is the bounding box of the label.
     */
    labelSizes: null,
 
    /**
     * Override superclass method here, because getting bbox of the scene won't always
     * produce the intended result: hidden text that sticks out of its container will
     * still be measured.
     */
    getContentRect: function () {
        var sceneRect = this.getSceneRect(),
            contentRect = this.contentRect || (this.contentRect = {x: 0, y: 0});
 
        // The (x, y) in `contentRect` should be untranslated content position relative
        // to the scene's origin, which is expected to always be (0, 0) for TreeMap.
        // But the (x, y) in `sceneRect` are relative to component's origin:
        // (padding.left, padding.top).
 
        if (sceneRect) {
            contentRect.width = sceneRect.width;
            contentRect.height = sceneRect.height;
        }
 
        return sceneRect && contentRect;
    },
 
 
    onNodeSelect: function (node, el) {
        this.callParent(arguments);
 
        // Remove the fill given by the `colorAxis`, so that
        // the CSS style can be used to specify the color
        // of the selection.
        el.select('rect').style('fill', null);
    },
 
    onNodeDeselect: function (node, el) {
        var me = this,
            colorAxis = me.getColorAxis();
 
        me.callParent(arguments);
 
        // Restore the original color.
        // (see 'onNodeSelect' comments).
        el
            .select('rect')
            .style('fill', function (node) {
                return colorAxis.getColor(node);
            });
    },
 
    renderNodes: function (nodeElements) {
        var me = this,
            layout = me.getLayout(),
            store = me.getStore(),
            root = store && store.getRoot(),
            rootHidden = !me.getRootVisible(),
            parentTile = me.getParentTile(),
            gap = parentTile.padding,
            parentLabel = parentTile.label,
            nodeTransform = me.getNodeTransform(),
            hiddenParentLabels = me.hiddenParentLabels = {},
            labelSizes = me.labelSizes = {};
 
        // To show parent node's label, we need to make space for it during layout by adding
        // extra top padding to the node. But during the layout, the size of the label
        // is not known. We can determine label visibility based on the size of the node,
        // but that alone is not enough, as long nodes can have even longer labels.
        // Clipping labels instead of hiding them is not possible, because overflow is not
        // supported in SVG 1.1, and lack of proper nested SVGs support in IE prevents us
        // from clipping via wrapping labels with 'svg' elements.
 
        // So knowing the size of the label is crucial.
        // Measuring can only be done after a node is rendered, so we do it this way:
 
        // 1) first layout pass with no padding
        // 2) render nodes
        // 3) measure labels
        // 4) second layout pass with padding
        // 5) adjust postion and size of nodes, and label visibility
 
        me.callParent([nodeElements]);
 
        layout.padding(function (node) {
            // This will be called for parent nodes only.
            // Leaf node label visibility is determined in `textVisibilityFn`.
            var size = labelSizes[node.id],
                clipSize = parentLabel.clipSize,
                padding;
 
            if (rootHidden && node.isRoot()) {
                // The root node is always rendered, we hide it by removing the padding,
                // so its children obscure it.
                padding = 0;
                hiddenParentLabels[node.id] = true;
            } else {
                padding = [parentLabel.offset[1] * 2, gap, gap, gap];
                if (size.width < (node.dx - gap * 2) && size.height < (node.dy - gap * 2)
                    && node.dx > clipSize[0] && node.dy > clipSize[1]) {
                    padding[0] += size.height;
                    hiddenParentLabels[node.id] = false;
                } else {
                    hiddenParentLabels[node.id] = true;
                }
            }
 
            return padding;
        });
        me.nodes = layout(root);
        layout.padding(null);
 
        nodeElements = nodeElements.data(me.nodes, me.getNodeKey());
        // 'enter' and 'exit' selections are empty at this point.
 
        nodeElements
            .transition()
            .call(nodeTransform.bind(me));
 
        nodeElements
            .select('rect')
            .call(me.nodeSizeFn.bind(me));
 
        nodeElements
            .select('text')
            .call(me.textVisibilityFn.bind(me))
            .each(function (node) {
                if (node.isLeaf()) {
                    this.setAttribute('x', node.dx / 2);
                    this.setAttribute('y', node.dy / 2);
                } else {
                    this.setAttribute('x', parentLabel.offset[0]);
                    this.setAttribute('y', parentLabel.offset[1]);
                }
            });
    },
 
    getLabelSizeScale: function (domain, range) {
        var me = this,
            scale = me.labelSizeScale;
 
        if (!scale) {
            if (!domain) {
                domain = [8, 27];
            }
            if (!range) {
                range = ['8px', '12px', '18px', '27px'];
            }
            me.labelSizeScale = scale = d3.scale.quantize();
        }
        if (domain && range) {
            scale.domain(domain).range(range);
        }
 
        return scale;
    },
 
    labelSizeFormula: function (element, node, scale) {
        // This can cause a much slower rendering while scaling (e.g. zooming in/out with PanZoom),
        // if too many different font sizes are returned, so we quantize them with 'scale'.
        if (node.isLeaf()) {
            return scale(Math.min(node.dx / 4, node.dy / 2));
        }
        return element.style.fontSize; // Parent font size is set via CSS.
    },
 
    updateNodeValue: function (nodeValue) {
        this.callParent(arguments);
        // The parent method doesn't perform layout automatically, nor should it,
        // as for some hiearchy components merely re-rendering the scene will be
        // sufficient. For treemaps however, a layout is necessary to determine
        // label size and visibility.
        if (!this.isConfiguring) {
            this.performLayout();
        }
    },
 
    addNodes: function (selection) {
        var me = this,
            group = selection.append('g'),
            labelSizes = me.labelSizes,
            colorAxis = me.getColorAxis(),
            nodeText = me.getNodeText(),
            labelSizeScale = me.getLabelSizeScale(),
            labelSizeFormula = me.labelSizeFormula,
            cls = me.defaultCls;
 
        group
            .attr('class', cls.node)
            .call(me.onNodesAdd.bind(me));
 
        group
            .append('rect')
            .style('fill', function (node) {
                return colorAxis.getColor(node);
            });
 
        group
            .append('text')
            .style('font-size', function (node) {
                return labelSizeFormula(this, node, labelSizeScale);
            })
            .each(function (node) {
                var text = nodeText(me, node);
 
                this.textContent = text == null ? '' : text;
                this.setAttribute('class', cls.label);
                Ext.d3.Helpers.fakeDominantBaseline(this, node.isLeaf() ? 'central' : 'text-before-edge');
                labelSizes[node.id] = this.getBBox();
            });
    },
 
    updateNodes: function (selection) {
        var me = this,
            nodeText = me.getNodeText(),
            nodeClass = me.getNodeClass(),
            labelSizeScale = me.getLabelSizeScale(),
            labelSizeFormula = me.labelSizeFormula,
            labelSizes = me.labelSizes;
 
        selection = selection
            .call(nodeClass.bind(me));
 
        selection
            .select('rect')
            .call(me.nodeSizeFn.bind(me));
 
        selection
            .select('text')
            .style('font-size', function (node) {
                return labelSizeFormula(this, node, labelSizeScale);
            })
            .each(function (node) {
                var text = nodeText(me, node);
 
                this.textContent = text == null ? '' : text;
                labelSizes[node.id] = this.getBBox();
            });
    },
 
    /**
     * @private
     */
    nodeSizeFn: function (selection) {
        var leafTile = this.getLeafTile(),
            padding = leafTile.padding;
 
        selection
            .attr('width', function (node) {
                return Math.max(0, node.dx - padding);
            })
            .attr('height', function (node) {
                return Math.max(0, node.dy - padding);
            });
    },
 
    isLabelVisible: function (element, node) {
        var me = this,
            bbox = element.getBBox(),
            width = node.dx,
            height = node.dy,
            isLeaf = node.isLeaf(),
            parentTile = me.getParentTile(),
            hiddenParentLabels = me.hiddenParentLabels,
            parentLabelOffset = parentTile.label.offset,
            result;
 
        if (isLeaf) {
            // At least one pixel gap between the 'text' and 'rect' edges.
            width -= 2;
            height -= 2;
        } else {
            width -= parentLabelOffset[0] * 2;
            height -= parentLabelOffset[1] * 2;
        }
 
        if (isLeaf || !node.isExpanded()) {
            result = bbox.width < width && bbox.height < height;
        } else {
            result = !hiddenParentLabels[node.id];
        }
 
        return result;
    },
 
    /**
     * @private
     */
    textVisibilityFn: function (selection) {
        var me = this;
 
        selection.classed(me.defaultCls.hidden, function (node) {
            return !me.isLabelVisible(this, node);
        });
    },
 
    destroy: function () {
        var me = this,
            colorAxis = me.getColorAxis();
 
        if (me.deferredLayoutId) {
            clearTimeout(me.deferredLayoutId);
        }
        colorAxis.destroy();
 
        me.callParent();
    }
 
});