Home Reference Source

js/BoxedTree.js

import d3 from './CustomD3';
import {TextBox as d3PlusTextBox} from 'd3plus-text';
import BaseTree from './BaseTree';
import BoxedNodeSettings from './BoxedNodeSettings';

class BoxedTree extends BaseTree{
    /** 
     * @param {object} options The options object.
     * @param {bodyDisplayTextAccessorCallBack} options.getBodyDisplayText Determines how to obtain the body text to display for a node corresponding to a data item.
     * @param {titleDisplayTextAccessorCallBack} options.getTitleDisplayText Determines how to obtain the title text to display for a node corresponding to a data item.
    */
    constructor(options) {
        super(options);
        var mergedOptions = {
            ...BaseTree.defaults,
            ...BoxedTree.defaults,
            ...options
        };

        this._getBodyDisplayText = mergedOptions.getBodyDisplayText;
        this._getTitleDisplayText = mergedOptions.getTitleDisplayText;
        this.nodeSettings = new BoxedNodeSettings(this, mergedOptions.nodeSettings);
    }

    /** @inheritdoc */
    initialize() {
        super.initialize();

        // Create the svg, and set its dimensions
        this.getSvg().classed('boxed-tree', true);
        return this;
    }

    /** @inheritdoc */
    _nodeEnter(nodeEnter, nodes) {
        var self = this;
        // Declare box dimensions
        var nodeBodyBoxWidth = self.nodeSettings.getBodyBoxWidth();
        var nodeBodyBoxHeight = self.nodeSettings.getBodyBoxHeight();
        var nodeBodyBoxPadding = self.nodeSettings.getBodyBoxPadding();

        var nodeTitleBoxWidth = self.nodeSettings.getTitleBoxWidth();
        var nodeTitleBoxHeight = self.nodeSettings.getTitleBoxHeight();
        var nodeTitleBoxPadding = self.nodeSettings.getTitleBoxPadding();

        /* Add Body Rectangle and Text for Node */
        var bodyGroups = nodeEnter.append("g")
            .classed("body-group", true);

        bodyGroups.append("rect")
            .classed("body-box", true)
            .attr("width", 0.000001)
            .attr("height", 0.000001);

        bodyGroups.each(function(data, index, arr) {
            var element = this;
            var selection = d3.select(element);
            var singledOutData = [];
            singledOutData.push(data);

            var recalculatedPaddingTop = nodeBodyBoxPadding.top;
            if (self.getTitleDisplayText.call(self, data))
            {
                recalculatedPaddingTop += nodeTitleBoxHeight / 2;
            }

            // D3Plus Textbox with resizing capability
            var d3PlusBodyTextBox = new d3PlusTextBox()
                .select(element) // Sets the D3Plus code to append to the specified DOM element.
                .data(singledOutData)
                .text((data, index, arr) => {
                    return self.getBodyDisplayText.call(self, data);
                })
                .textAnchor("middle")
                .verticalAlign("middle")
                .fontSize(13) // in pixels
                .x(nodeBodyBoxPadding.left)
                .y(recalculatedPaddingTop - nodeBodyBoxHeight / 2)
                .width(nodeBodyBoxWidth - nodeBodyBoxPadding.left - nodeBodyBoxPadding.right)
                .height(nodeBodyBoxHeight - recalculatedPaddingTop - nodeBodyBoxPadding.bottom)
                .ellipsis((text, line) => {
                    // If text was cut-off, add tooltip
                    selection.append("title")
                        .text(self.getBodyDisplayText(data));
                    return ((text.replace(/\.|,$/g, "")) + "...");
                })
                .render();
        });

        /* Add Title Rectangle and Text for Node */
        var titleGroups = nodeEnter.append("g")
            .classed("title-group", true)
            .attr("transform", "translate(" + -nodeTitleBoxWidth / 3 + ", " + (-nodeTitleBoxHeight / 2 - nodeBodyBoxHeight / 2) + ")");

        titleGroups.each(function(data, index, arr) {
            if (!self.getTitleDisplayText.call(self, data))
                return;
            var element = this;
            var selection = d3.select(element);
            var singledOutData = [];
            singledOutData.push(data);

            selection.append("rect")
                .classed("title-box", true)
                .attr("width", nodeTitleBoxWidth)
                .attr("height", nodeTitleBoxHeight);

            // D3Plus Textbox with resizing capability
            var d3PlusTitleTextBox = new d3PlusTextBox()
                .select(element) // Sets the D3Plus code to append to the DOM element.
                .data(singledOutData)
                .text((data, index, arr) => {
                    return self.getTitleDisplayText.call(self, data);
                })
                .textAnchor("middle")
                .verticalAlign("middle")
                .x(nodeTitleBoxPadding.left)
                .y(nodeTitleBoxPadding.top)
                .fontWeight(700)
                .fontMin(6)
                .fontMax(16)
                .fontResize(true) // Resizes the text to fit the content
                .width(nodeTitleBoxWidth - nodeTitleBoxPadding.left - nodeTitleBoxPadding.right)
                .height(nodeTitleBoxHeight - nodeTitleBoxPadding.top - nodeTitleBoxPadding.bottom)
                .render();
        });
        return self;
    }

    /** @inheritdoc */
    _nodeUpdate(nodeUpdate, nodeUpdateTransition, nodes) {
        // Transition to the proper position for the node

        // Translating while inverting X/Y to
        // make tree direction from left to right,
        // instead of the typical top-to-down tree
        if (this.getOrientation().toLowerCase() === 'toptobottom')
        {
            nodeUpdateTransition.attr("transform", (data, index, arr) => "translate(" + data.x + "," + data.y + ")");
        }
        else
        {
            nodeUpdateTransition.attr("transform", (data, index, arr) => "translate(" + data.y + "," + data.x + ")");
        }

        var nodeBodyBoxWidth = this.nodeSettings.getBodyBoxWidth();
        var nodeBodyBoxHeight = this.nodeSettings.getBodyBoxHeight();

        // Update the node attributes and style
        nodeUpdate.select(".node .body-group .body-box")
            .attr("y", -(nodeBodyBoxHeight / 2))
            .attr("width", nodeBodyBoxWidth)
            .attr("height", nodeBodyBoxHeight);

        nodeUpdate.select(".d3plus-textBox")
            .style("fill-opacity", 1);
        return this;
    }

    /** @inheritdoc */
    _nodeExit(nodeExit, nodeExitTransition, nodes) {
        var nodeBodyBoxWidth = this.nodeSettings.getBodyBoxWidth();
        var nodeBodyBoxHeight = this.nodeSettings.getBodyBoxHeight();

        nodeExitTransition.attr("transform", (data, index, arr) => {
                var highestCollapsingParent = data.parent;
                while (highestCollapsingParent.parent && !highestCollapsingParent.parent.children) {
                    highestCollapsingParent = highestCollapsingParent.parent;
                }

                if (this.getOrientation().toLowerCase() === 'toptobottom')
                {
                    return "translate(" + (highestCollapsingParent.x + nodeBodyBoxWidth / 2) + "," + (highestCollapsingParent.y + nodeBodyBoxHeight) + ")";
                }
                else
                {
                    // Translating while inverting X/Y to
                    // make tree direction from left to right,
                    // instead of the typical top-to-down tree
                    return "translate(" + (highestCollapsingParent.y + nodeBodyBoxWidth) + "," + (highestCollapsingParent.x + nodeBodyBoxHeight / 2) + ")";
                }
            })
            .remove();

        // On exit animate out
        nodeExitTransition.select(".node .body-group rect")
            .attr("width", 0.000001)
            .attr("height", 0.000001);

        nodeExitTransition.select(".node .body-group .d3plus-textBox")
            .style("fill-opacity", 0.000001)
            .attr("transform", (data, index, arr) => "translate(0," + (-nodeBodyBoxHeight / 2) + ")")
            .selectAll("text")
                .style("font-size", 0)
                .attr("y", "0px")
                .attr("x", "0px");

        nodeExitTransition.select(".node .title-group")
            .attr("transform", "translate(0, " + (-nodeBodyBoxHeight / 2) + ")");

        nodeExitTransition.select(".node .title-group rect")
            .attr("width", 0.000001)
            .attr("height", 0.000001);

        nodeExitTransition.select(".node .title-group .d3plus-textBox")
            .style("fill-opacity", 0.000001)
            .attr("transform", "translate(0,0)")
            .selectAll("text")
                .style("font-size", 0)
                .attr("y", "0px")
                .attr("x", "0px");

        // On exit reduce the opacity of text labels
        nodeExitTransition.select(".d3plus-textBox")
            .style("fill-opacity", 0.000001);
        return this;
    }

    /** @inheritdoc */
    _getNodeSize() {
        if (this.getOrientation().toLowerCase() === 'toptobottom')
        {
            return [
                this.nodeSettings.getBodyBoxWidth() + this.nodeSettings.getHorizontalSpacing(),
                this.nodeSettings.getBodyBoxHeight() + this.nodeSettings.getVerticalSpacing()
            ];
        }
        else
        {
            return [
                this.nodeSettings.getBodyBoxHeight() + this.nodeSettings.getVerticalSpacing(),
                this.nodeSettings.getBodyBoxWidth() + this.nodeSettings.getHorizontalSpacing()
            ];
        }
    }

    /** @inheritdoc */
    _linkEnter(source, linkEnter, links, linkPathGenerator)	{
        linkEnter.attr("d", (data, index, arr) => {
            var sourceCoordinate = {
                x: source.x0,
                y: source.y0
            };

            var coordinatesObject = {
                source: sourceCoordinate,
                target: sourceCoordinate
            };
            return linkPathGenerator(coordinatesObject);
        });
        return this;
    }

    /** @inheritdoc */
    _linkUpdate(source, linkUpdate, linkUpdateTransition, links, linkPathGenerator) {
        linkUpdateTransition.attr("d", (data, index, arr) => {
            var sourceCoordinate = data;
            var targetCoordinate = data.parent;

            var coordinatesObject = {
                source: sourceCoordinate,
                target: targetCoordinate
            };

            return linkPathGenerator(coordinatesObject);
        });
        return this;
    }

    /** @inheritdoc */
    _linkExit(source, linkExit, linkExitTransition, links, linkPathGenerator) {
        linkExitTransition.attr("d", (data, index, arr) => {
            var highestCollapsingParent = data.parent;
            while (highestCollapsingParent.parent && !highestCollapsingParent.parent.children) {
                highestCollapsingParent = highestCollapsingParent.parent;
            }
            
            var sourceCoordinate = null;
            if (this.getOrientation().toLowerCase() === 'toptobottom')
            {
                var nodeBodyBoxHeight = this.nodeSettings.getBodyBoxHeight();
                sourceCoordinate = {
                    x: highestCollapsingParent.x,
                    y: highestCollapsingParent.y + nodeBodyBoxHeight
                };
            }
            else
            {
                var nodeBodyBoxWidth = this.nodeSettings.getBodyBoxWidth();
                sourceCoordinate = {
                    x: highestCollapsingParent.x,
                    y: highestCollapsingParent.y + nodeBodyBoxWidth
                };
            }

            var targetCoordinate = {
                x: highestCollapsingParent.x,
                y: highestCollapsingParent.y
            };

            var coordinatesObject = {
                source: sourceCoordinate,
                target: targetCoordinate
            };

            return linkPathGenerator(coordinatesObject);
        });
        return this;
    }

    /** @inheritdoc */
    _getLinkPathGenerator() {
        // Declare box dimensions
        var nodeBodyBoxWidth = this.nodeSettings.getBodyBoxWidth();
        var nodeBodyBoxHeight = this.nodeSettings.getBodyBoxHeight();

        // We specify arrow functions that returns
        // an array specifying how to get the
        // the x/y cordinates from the object,
        // in the format of [x, y], the default
        // format for the link generator to
        // generate the path
        if (this.getOrientation().toLowerCase() === 'toptobottom')
        {
            return d3.linkVertical()
                .source((data) => [data.source.x + nodeBodyBoxWidth / 2, data.source.y - nodeBodyBoxHeight / 2])
                .target((data) => [data.target.x + nodeBodyBoxWidth / 2, data.target.y + nodeBodyBoxHeight / 2]);
        }
        else
        {
            return d3.linkHorizontal()
                // Inverts the X/Y coordinates to draw links for a
                // tree starting from left to right,
                // instead of the typical top-to-down tree
                .source((data) => [data.source.y, data.source.x])
                .target((data) => [data.target.y + nodeBodyBoxWidth, data.target.x]);
        }
    }

    /** @inheritdoc */
    validateSettings() {
        super.validateSettings();
        if (!this._getBodyDisplayText)
            throw "Need to define the getBodyDisplayText function as part of the options";
        return this;
    }

    /**
     * Sets the body display text accessor,
     * used to get the body display text
     * for the nodes.
     * 
     * @param {bodyDisplayTextAccessorCallBack} newBodyDisplayTextAccessor 
     */
    setBodyDisplayTextAccessor(newBodyDisplayTextAccessor) {
        this._getBodyDisplayText = newBodyDisplayTextAccessor;
        return this;
    }

    /**
     * Gets the body display text for a given data item.
     * 
     * @param {object} nodeDataItem The data item to get the body display text from.
     * @returns {string} The body display text to render for the node.
     */
    getBodyDisplayText(nodeDataItem) {
        // Note that data in this context refers to D3 Tree data, not the original item data
        // To Access the original item data, use the ".data" property
        return this._getBodyDisplayText(nodeDataItem.data);
    }

    /**
     * Sets the title display text accessor,
     * used to get the title display text
     * for the nodes.
     * 
     * @param {titleDisplayTextAccessorCallBack} newTitleDisplayTextAccessor 
     */
    setTitleDisplayTextAccessor(newTitleDisplayTextAccessor) {
        this._getTitleDisplayText = newTitleDisplayTextAccessor;
        return this;
    }

    /**
     * Gets the title display text for a given data item.
     * 
     * @param {object} nodeDataItem The D3 node data item to get the title display text from.
     * @returns {string} The title display text to render for the node.
     */
    getTitleDisplayText(nodeDataItem) {
        // Note that data in this context refers to D3 Tree data, not the original item data
        // To Access the original item data, use the ".data" property
        return this._getTitleDisplayText(nodeDataItem.data);
    }

    /** @inheritdoc */
    centerNode(nodeDataItem) {
        var nodeBodyBoxWidth = this.nodeSettings.getBodyBoxWidth();
        var nodeBodyBoxHeight = this.nodeSettings.getBodyBoxHeight();
        if (this.getOrientation().toLowerCase() === 'toptobottom')
        {
            nodeDataItem.x0 = nodeDataItem.x0;
            nodeDataItem.y0 = nodeDataItem.y0 + nodeBodyBoxHeight / 2;
        }
        else
        {
            nodeDataItem.y0 = nodeDataItem.y0 + nodeBodyBoxWidth / 2;
            nodeDataItem.x0 = nodeDataItem.x0;
        }
        return super.centerNode(nodeDataItem);
    }

    /**
     * Determines how to obtain the body text
     * to display for a node corresponding
     * to a data item.
     * 
     * @callback bodyDisplayTextAccessorCallBack
     * @param {object} data The data item to get the body display text from.
     * @returns {string} The body display text to render for the node.
     */

    /**
     * Determines how to obtain the title text
     * to display for a node corresponding
     * to a data item.
     * 
     * @callback titleDisplayTextAccessorCallBack
     * @param {object} data The data item to get the title display text from.
     * @returns {string} The title display text to render for the node.
     */
}

BoxedTree.defaults = {
    getBodyDisplayText: null,
    getTitleDisplayText: (dataItem) => {
        return null;
    }
}

export default BoxedTree;