Source: muze-tooltip/src/tooltip.js

import {
    mergeRecursive,
    getQualifiedClassName,
    getUniqueId,
    selectElement,
    setStyles,
    makeElement
} from 'muze-utils';
import { ARROW_BOTTOM, ARROW_LEFT, ARROW_RIGHT, TOOLTIP_LEFT, TOOLTIP_RIGHT, TOOLTIP_BOTTOM, TOOLTIP_TOP,
    INITIAL_STYLE } from './constants';
import { defaultConfig } from './default-config';
import { getArrowPos, placeArrow, reorderContainers } from './helper';
import './styles.scss';
import Content from './content';

/**
 * This component is responsible for creating a tooltip element. It appends the tooltip
 * in the body element.
 * @class Tooltip
 */
export default class Tooltip {
    /**
     * Initializes the tooltip with the container element and configuration
     * @param {HTMLElement} container container where the tooltip will be mounted.
     * @param {string} className Class name of the tooltip.
     */
    constructor (htmlContainer, svgContainer) {
        let connectorContainer = svgContainer;
        this._id = getUniqueId();
        this._config = {};
        this.config({});
        const tooltipConf = this._config;
        const classPrefix = tooltipConf.classPrefix;
        const contentClass = tooltipConf.content.parentClassName;
        const container = makeElement(htmlContainer, 'div', [1], `${classPrefix}-tooltip-container`);
        this._container = container;
        this._tooltipContainer = container.append('div').style('position', 'absolute');
        this._contentContainer = this._tooltipContainer.append('div').attr('class', `${classPrefix}-${contentClass}`);
        this._tooltipBackground = this._tooltipContainer.append('div').style('position', 'relative');
        this._tooltipArrow = this._tooltipContainer.append('div');

        if (!svgContainer) {
            connectorContainer = htmlContainer.append('svg').style('pointer-events', 'none');
        }
        this._contents = {};
        this._tooltipConnectorContainer = selectElement(connectorContainer)
            .append('g')
            .attr('class', `${tooltipConf.classPrefix}-${tooltipConf.connectorClassName}`);
        const id = this._id;
        const defClassName = tooltipConf.defClassName;
        const qualifiedClassName = getQualifiedClassName(defClassName, id, tooltipConf.classPrefix);

        setStyles(this._tooltipArrow, INITIAL_STYLE);
        setStyles(this._tooltipBackground, INITIAL_STYLE);
        this.addClass(qualifiedClassName.join(' '));
        this.addClass(tooltipConf.className);
        this.hide();
    }

    /**
     * Sets the configuration of tooltip.
     * @param {Object} config Configuration of tooltip
     * @return {Tooltip} Instance of tooltip
     */
    config (...config) {
        if (config.length > 0) {
            const defConf = mergeRecursive({}, this.constructor.defaultConfig());
            this._config = mergeRecursive(defConf, config[0]);
            return this;
        }
        return this._config;
    }

    /**
     * Returns the default configuration of tooltip
     * @return {Object} Configuration of tooltip.
     */
    static defaultConfig () {
        return defaultConfig;
    }
    /**
     * Sets the class name of tooltip
     * @param {string} className tooltip class name
     * @return {Tooltip} Instance of tooltip.
     */
    addClass (className) {
        this._tooltipContainer.classed(className, true);
        return this;
    }

    context (...ctx) {
        if (ctx.length) {
            this._context = ctx[0];
            return this;
        }
        return this._context;
    }

    content (name, data, contentConfig = {}) {
        const config = this.config();
        const { classPrefix } = config;
        const contentClass = config.content.className;
        const formatter = config.formatter;
        const className = contentConfig.className || `${classPrefix}-${contentClass}-${name}`;
        const content = this._contents[name] = this._contents[name] || new Content();
        const container = makeElement(this._contentContainer, 'div', [contentConfig.order], className);
        container.attr('class', `${classPrefix}-${contentClass} ${className}`);
        reorderContainers(this._contentContainer, `.${classPrefix}-${contentClass}`);
        const contentConf = config.content;
        contentConfig.classPrefix = this._config.classPrefix;
        content.config(contentConf);

        if (data === null) {
            content.clear();
            container.remove();
            delete this._contents[name];
        } else {
            content.update({
                model: data,
                formatter: contentConfig.formatter || formatter
            });
            content.context(this._context);
            content.render(container);
        }

        if (!Object.keys(this._contents).length) {
            this.hide();
        }
        return this;
    }

    getContents () {
        return Object.values(this._contents);
    }

    /**
     * Positions the tooltip at the given x and y position.
     * @param {number} x x position
     * @param {number} y y position
     * @return {Tooltip} Instance of tooltip.
     */
    position (x, y, conf = {}) {
        if (!Object.keys(this._contents).length) {
            this.hide();
            return this;
        }
        this.show();
        const target = this._target;
        const repositionArrow = conf.repositionArrow;

        if (target && repositionArrow) {
            const node = this._tooltipContainer.node();
            const config = this._config;
            const arrowDisabled = config.arrow.disabled;
            const arrowWidth = arrowDisabled ? 0 : config.arrow.size;
            const arrowOrient = this._arrowOrientation;
            const outsidePlot = arrowOrient === ARROW_LEFT || arrowOrient === ARROW_RIGHT ?
                (y + node.offsetHeight - arrowWidth) < target.y || y > (target.y + target.height) :
                (x + node.offsetWidth - arrowWidth) < target.x || x > (target.x + target.width);

            if (!arrowDisabled) {
                if (outsidePlot) {
                    let path;
                    this._tooltipArrow.style('display', 'none');
                    this._tooltipBackground.style('display', 'none');
                    this._tooltipConnectorContainer.style('display', 'block');
                    const connector = this._tooltipConnectorContainer.selectAll('path').data([1]);
                    const enter = connector.enter().append('path');
                    if (arrowOrient === ARROW_LEFT) {
                        path = `M ${x} ${y + node.offsetHeight / 2} L ${target.x + target.width}`
                            + ` ${target.y + target.height / 2}`;
                    } else if (arrowOrient === ARROW_RIGHT) {
                        path = `M ${x + node.offsetWidth} ${y + node.offsetHeight / 2}`
                                + ` L ${target.x} ${target.y + target.height / 2}`;
                    } else if (arrowOrient === ARROW_BOTTOM) {
                        path = `M ${x + node.offsetWidth / 2} ${y + node.offsetHeight}`
                            + ` L ${target.x + target.width / 2} ${target.y}`;
                    }
                    enter.merge(connector).attr('d', path).style('display', 'block');
                } else {
                    const arrowPos = getArrowPos(arrowOrient, target, {
                        x,
                        y,
                        boxHeight: node.offsetHeight,
                        boxWidth: node.offsetWidth
                    }, this._config);

                    placeArrow(this, this._arrowOrientation, arrowPos);
                    this._tooltipConnectorContainer.style('display', 'none');
                }
            }
        }

        const offset = this._offset || {
            x: 0,
            y: 0
        };
        this._tooltipContainer.style('left', `${offset.x + x}px`).style('top',
            `${offset.y + y}px`);

        return this;
    }

    /**
     * Positions the tooltip relative to a rectangular box. It takes care of tooltip overflowing the
     * boundaries.
     * @param {Object} dim Dimensions of the plot.
     */
    positionRelativeTo (dim, tooltipConf = {}) {
        let obj;
        let orientation = tooltipConf.orientation;
        this.show();
        if (!dim) {
            this.hide();
            return this;
        }

        const extent = this._extent;
        const node = this._tooltipContainer.node();

        this._tooltipContainer.style('top', '0px')
                        .style('left', '0px');
        const offsetWidth = node.offsetWidth + 2;
        const offsetHeight = node.offsetHeight + 2;
        const config = this._config;
        const offset = this._offset;
        const arrowDisabled = config.arrow.disabled;
        const arrowSize = arrowDisabled ? 0 : config.arrow.size;
        const draw = tooltipConf.draw !== undefined ? tooltipConf.draw : true;
        const topSpace = dim.y;
        // When there is no space in right
        const dimX = dim.x + dim.width + offset.x;
        const rightSpace = extent.width - dimX;
        const leftSpace = dim.x + offset.x - extent.x;
        const positionTop = topSpace > (offsetHeight + arrowSize);
        const positionRight = rightSpace >= offsetWidth + arrowSize;
        const positionLeft = leftSpace >= offsetWidth + arrowSize;

        const positionHorizontal = () => {
            let position;
            let x = dim.x + dim.width;
            let y = dim.y;

            if (positionRight) {
                position = TOOLTIP_LEFT;
                x += arrowSize;
            } else if (positionLeft) {
                x = dim.x - offsetWidth;
                position = TOOLTIP_RIGHT;
                x -= arrowSize;
            } else {
                position = 'left';
                x += arrowSize;
            }
            if (dim.height < offsetHeight) {
                y = Math.max(0, dim.y + dim.height / 2 - offsetHeight / 2);
            }

            const arrowPos = getArrowPos(position, dim, {
                x,
                y,
                boxHeight: offsetHeight,
                boxWidth: offsetWidth
            }, this._config);

            return {
                position,
                arrowPos,
                x,
                y
            };
        };

        const positionVertical = () => {
            let position;
            let y;
            // Position tooltip at the center of plot
            let x = dim.x - offsetWidth / 2 + dim.width / 2;

            // Overflows to the right
            if ((extent.width - (dim.x + offset.x)) < offsetWidth) {
                x = extent.width - offsetWidth - offset.x;
            } else if ((x + offset.x) < extent.x) { // Overflows to the left
                x = extent.x;
            }

            if (positionTop) {
                y = dim.y - offsetHeight - arrowSize;
                position = TOOLTIP_BOTTOM;
            } else {
                y = dim.y + dim.height + arrowSize;
                position = TOOLTIP_TOP;
            }

            const arrowPos = getArrowPos(position, dim, {
                x,
                y,
                boxHeight: offsetHeight,
                boxWidth: offsetWidth
            }, this._config);

            return {
                position,
                arrowPos,
                x,
                y
            };
        };

        this._target = dim;
        if (!orientation) {
            if (positionTop) {
                orientation = 'vertical';
            } else if (positionRight || positionLeft) {
                orientation = 'horizontal';
            } else {
                orientation = 'vertical';
            }
        }

        if (orientation === 'horizontal') {
            obj = positionHorizontal();
        } else if (orientation === 'vertical') {
            obj = positionVertical();
        }

        this._position = {
            x: obj.x,
            y: obj.y
        };

        this._arrowPos = obj.arrowPos;
        if (!arrowDisabled) {
            placeArrow(this, obj.position, obj.arrowPos);
        } else {
            this._tooltipArrow.style('display', 'none');
            this._tooltipBackground.style('display', 'none');
        }
        this._arrowOrientation = obj.position;
        draw && this.position(obj.x, obj.y);
        return this;
    }

    /**
     * Hides the tooltip element.
     * @return {Tooltip} Instance of tooltip.
     */
    hide () {
        this._tooltipContainer.style('display', 'none');
        this._tooltipConnectorContainer.style('display', 'none');
        return this;
    }

    /**
     * Shows the tooltip element.
     * @return {Tooltip} Instance of tooltip.
     */
    show () {
        this._tooltipContainer.style('display', 'block');
        return this;
    }

    extent (extent) {
        this._extent = extent;
        return this;
    }

    offset (offset) {
        this._offset = offset;
        return this;
    }

    remove () {
        this._tooltipContainer.remove();
        this._tooltipBackground.remove();
        this._tooltipConnectorContainer.remove();
        return this;
    }
}