helpers/legend.js

/**
 * @module legend
 * @alias module:legend
 */

"use strict";
import * as d3 from "d3";
import {
    d3RemoveElement,
    getColorForTarget,
    getShapeForTarget
} from "../controls/Graph/helpers/helpers";
import { Shape } from "../core";
import { getDefaultSVGProps } from "../core/Shape";
import constants from "../helpers/constants";
import errors from "../helpers/errors";
import styles from "../helpers/styles";
import utils from "../helpers/utils";
import { getDefaultValue } from "../core/BaseConfig";
import { getStrokeDashArray } from "../core/BaseConfig/helper";

/**
 * Validates legend label
 *
 * @private
 * @throws {module:errors.THROW_MSG_LEGEND_LABEL_NOT_PROVIDED}
 * @throws {module:errors.THROW_MSG_LEGEND_LABEL_FORMAT_NOT_PROVIDED}
 * @param {object} label - label object
 * @returns {undefined} returns nothing
 */
const validateLegendLabel = (label) => {
    if (!label) {
        throw new Error(errors.THROW_MSG_LEGEND_LABEL_NOT_PROVIDED);
    }
    if (utils.isDefined(label.format) && !utils.isFunction(label.format)) {
        throw new Error(errors.THROW_MSG_LEGEND_LABEL_FORMAT_NOT_PROVIDED);
    }
};
/**
 * Returns the sanitized legend item display string
 *
 * @private
 * @param {string} text - legend display string
 * @returns {string} Sanitized text
 */
const getText = (text) => utils.sanitize(text);
/**
 * Hide legend when legend item has no data and showElement is set to false
 *
 * @private
 * @param {object} input item object processed from the input JSON
 * @returns {string} returns "none" if legend is to be hidden otherwise returns empty string
 */
const legendDisplayStyle = (input) =>
    input.legendOptions &&
    input.legendOptions.showElement === false &&
    utils.isEmptyArray(input.values)
        ? "none"
        : "";
/**
 * Loads the legend items. The values are taken from the Labels property of the input JSON
 * The click and the hover events are only registered when there are datapoints matching the
 * unique ids or have the isDisabled flag turned off.
 *
 * @param {object} legendSVG - d3 element path of the legend from the parent control
 * @param {object} t - input item object processed from the input JSON
 * @param {object} config - Graph config object derived from input JSON
 * @param {object} eventHandlers - Callback function object executed when legend item is clicked or hovered.
 * Contains click and hover handlers as object property
 * @returns {object} returns the d3 element path for the legend
 */
const loadLegendItem = (legendSVG, t, config, eventHandlers) => {
    if (!utils.isFunction(eventHandlers.clickHandler)) {
        throw new Error(
            "Invalid Argument: eventHandlers needs a clickHandler callback function."
        );
    }
    if (!utils.isFunction(eventHandlers.hoverHandler)) {
        throw new Error(
            "Invalid Argument: eventHandlers needs a hoverHandler callback function."
        );
    }
    validateLegendLabel(t.label);
    const text = getText(t.label.display);
    const index = config.shownTargets.indexOf(t.key);
    const shouldForceDisableLegendItem =
        !!t.label.isDisabled || utils.isEmptyArray(t.values);
    const itemPath = legendSVG
        .append("li")
        .classed(styles.legendItem, true)
        .attr("aria-current", shouldForceDisableLegendItem || index > -1)
        .attr("aria-disabled", shouldForceDisableLegendItem)
        .style("display", legendDisplayStyle(t))
        .attr("role", "listitem")
        .attr("aria-labelledby", text)
        .attr("aria-describedby", t.key)
        .style("margin", config.legendPadding.hasCustomLegendPadding && 0)
        .style(
            "padding",
            `${config.legendPadding.top}px ${config.legendPadding.right}px ${config.legendPadding.bottom}px ${config.legendPadding.left}px`
        );
    if (!shouldForceDisableLegendItem && index > -1) {
        itemPath
            .on("click", function () {
                return eventHandlers.clickHandler(this, t);
            })
            .on("mouseenter", () =>
                eventHandlers.hoverHandler(t, constants.HOVER_EVENT.MOUSE_ENTER)
            )
            .on("mouseleave", () =>
                eventHandlers.hoverHandler(t, constants.HOVER_EVENT.MOUSE_EXIT)
            );
    }
    const buttonPath = itemPath
        .append("button")
        .classed(styles.legendItemBtn, true)
        .attr("title", text)
        .attr("tabindex", shouldForceDisableLegendItem ? -1 : 0)
        .append("span")
        .attr("class", styles.legendItemSpan);

    processLegendOptions(buttonPath, t);

    itemPath
        .append("label")
        .classed(styles.legendItemText, true)
        .attr("tabindex", -1)
        .text(text);
    return legendSVG;
};

/**
 * Creates legend button content based on legend options
 *
 * @private
 * @param {object} buttonPath - d3 svg object
 * @param {object} input - input item object processed from the input JSON
 */
const processLegendOptions = (buttonPath, input) => {
    if (input.legendOptions) {
        // Create a legend icon only if the showShape is true
        if (input.showShapes || input.showShapes === undefined) {
            if (input.legendOptions.showShape) {
                createLegendIcon(buttonPath, input);
            }
        }
        if (input.legendOptions.showLine) {
            createLegendLine(buttonPath, input);
        }
    } else {
        createLegendIcon(buttonPath, input);
    }
};

/**
 * Creates an icon in the legend button
 *
 * @private
 * @param {object} buttonPath - d3 svg object
 * @param {object} input - input item object processed from the input JSON
 * @returns {object} returns the d3 element path for the legend
 */
const createLegendIcon = (buttonPath, input) =>
    buttonPath.append(() =>
        new Shape(getShapeForTarget(input)).getShapeElement(
            getDefaultSVGProps({
                svgClassNames: styles.legendItemIcon,
                svgStyles: `fill: ${getColorForTarget(input)};`
            }),
            true
        )
    );

/**
 * Creates a line in the legend button
 *
 * @private
 * @param {object} buttonPath - d3 svg object
 * @param {object} t - input item object processed from the input JSON
 */
const createLegendLine = (buttonPath, t) => {
    const { legendOptions } = t;
    const svg = buttonPath
        .append("svg")
        .classed(
            legendOptions.showShape
                ? styles.legendItemLineWithIcon
                : styles.legendItemLine,
            true
        );
    svg.append("line") // creating white line
        .attr("x2", constants.DEFAULT_LEGEND_LINE_WIDTH)
        .classed(styles.legendItemWhiteLine, true);
    svg.append("line")
        .attr("x1", 1)
        .attr(
            "x2",
            legendOptions.showShape
                ? constants.DEFAULT_LEGEND_LINE_WIDTH_WITH_SYMBOL - 1
                : constants.DEFAULT_LEGEND_LINE_WIDTH - 1
        )
        .attr("y1", constants.LEGEND_LINE_POSITION)
        .attr("y2", constants.LEGEND_LINE_POSITION)
        .attr(
            "style",
            `stroke: ${getColorForTarget(t)};
            stroke-dasharray: ${legendOptions.style.strokeDashArray};
            stroke-width: 1px;`
        );
};
/**
 * Removes the legend item from legend SVG in the graph
 *
 * @param {object} legendSVG - d3 svg object
 * @param {object} dataTarget - Data points object
 * @returns {object} - d3 svg object
 */
const removeLegendItem = (legendSVG, dataTarget) =>
    d3RemoveElement(legendSVG, `li[aria-describedby="${dataTarget.key}"]`);
/**
 * Creates the legend item list and appends into the container. The container consists of
 * the canvas which houses the graph itself, and the legend <ul> which contains the list of data points labels and
 * their respective shapes.
 * Only if showLegend is enabled.
 *
 * @param {object} config - Graph config object derived from input JSON
 * @param {object} container - d3 Container svg
 * @returns {object} - d3 svg object
 */
const createLegend = (config, container) =>
    container
        .append("ul")
        .classed(styles.legend, true)
        .attr("role", "list")
        .style("flex-direction", config.bindLegendTo && "column");
/**
 * Returns a boolean after checking the attribute `aria-current`.
 *
 * @param {HTMLElement} target - d3 svg object
 * @returns {boolean} - returns boolean
 */
const isLegendSelected = (target) => target.attr("aria-current") !== "true";
/**
 * Handler that will need to be called when a legend item is clicked along
 * with any other operations that will be need to taken care of by the parent
 * control.
 *
 * @param {HTMLElement} element - d3 element of the legend item clicked
 * @returns {object} - d3 svg object
 */
const legendClickHandler = (element) => {
    const target = d3.select(element);
    return target.attr("aria-current", isLegendSelected(target));
};
/**
 * Hover handler for legend items.
 *
 * @param {Array} shownTargets - Targets/data sets that are currently displayed in graph
 * @param {d3.selection} canvasSVG - d3 selection node of canvas svg
 * @param {string} key - Data points set unique key
 * @param {string} hoverState - state of mouse hover => enter or leave
 * @param {Array} [additionalHandlers] - Additional set of handlers that consumers can execute on
 * top of the base hover handler
 * @returns {undefined} - returns nothing
 */
const legendHoverHandler = (
    shownTargets,
    canvasSVG,
    key,
    hoverState,
    additionalHandlers = []
) => {
    // Blur everything except the item hovered
    shownTargets
        .filter((target) => target !== key)
        .forEach((k) => {
            // All Points
            canvasSVG
                .selectAll(`svg[aria-describedby="${k}"]`)
                .classed(
                    styles.blur,
                    hoverState === constants.HOVER_EVENT.MOUSE_ENTER
                );
            // All Lines
            canvasSVG
                .selectAll(`path[aria-describedby="${k}"]`)
                .classed(
                    styles.blur,
                    hoverState === constants.HOVER_EVENT.MOUSE_ENTER
                );
            canvasSVG
                .selectAll(`.${styles.pairedLine}`)
                .classed(
                    styles.blur,
                    hoverState === constants.HOVER_EVENT.MOUSE_ENTER
                );
            canvasSVG
                .selectAll(`rect[aria-describedby="${k}"]`)
                .classed(
                    styles.blur,
                    hoverState === constants.HOVER_EVENT.MOUSE_ENTER
                );
            canvasSVG
                .selectAll(
                    `.${styles.barGoalLine}[aria-describedby="region_${k}"]`
                )
                .attr(
                    "aria-hidden",
                    hoverState === constants.HOVER_EVENT.MOUSE_ENTER
                );
            canvasSVG
                .selectAll(`[aria-describedby="text_label_${k}"]`)
                .classed(
                    styles.blur,
                    hoverState === constants.HOVER_EVENT.MOUSE_ENTER
                );
            if (utils.notEmpty(additionalHandlers)) {
                additionalHandlers.forEach((fn) =>
                    fn(shownTargets, canvasSVG, key, hoverState, k)
                );
            }
        });
};
/**
 * Constructs a legend text based on the display string, value.
 * If formatter function is provided by the consumer then that function will be called.
 *
 * @private
 * @param {string} display - legend item display string
 * @param {number} value - pie slice value
 * @param {Function} format - formatter callback function provided by the consumer
 * @returns {string} - A string that will be used in the legend item
 */
const getPieLegendText = (display, value, format) => {
    if (format) {
        return format(display, value);
    }
    return `${getText(display)}: ${value}`;
};
/**
 * Pie chart legend items are non-clickable and they react only to hover or click
 * performed on any of a slice in pie chart itself or hovered over a legend item.
 *
 * @param {object} legendSVG - d3 element path of the legend from the parent control
 * @param {object} dataTarget - input item object processed from the input JSON
 * @param {Function} hoverHandler - Callback function to be called when hovered over the legend item
 * @param {object} config - Graph config object derived from input JSON
 * @returns {undefined} returns nothing
 */
const loadPieLegendItem = (legendSVG, dataTarget, { hoverHandler }, config) => {
    validateLegendLabel(dataTarget.label);
    const text = getPieLegendText(
        dataTarget.label.display,
        dataTarget.value,
        dataTarget.label.format
    );
    const itemPath = legendSVG
        .append("li")
        .classed(styles.pieLegendItem, true)
        .attr("role", "listitem")
        .attr("tabindex", 0)
        .attr("aria-labelledby", text)
        .attr("aria-describedby", dataTarget.key)
        .style("margin", config.legendPadding.hasCustomLegendPadding && 0)
        .style(
            "padding",
            `${config.legendPadding.top}px ${config.legendPadding.right}px ${config.legendPadding.bottom}px ${config.legendPadding.left}px`
        );
    itemPath.append(() =>
        new Shape(getShapeForTarget(dataTarget)).getShapeElement(
            getDefaultSVGProps({
                svgClassNames: styles.pieLegendItemIcon,
                svgStyles: `fill: ${getColorForTarget(dataTarget)};`
            }),
            true
        )
    );
    itemPath.append("label").classed(styles.legendItemText, true).text(text);
    itemPath
        .on("mouseenter", () =>
            hoverHandler(dataTarget, constants.HOVER_EVENT.MOUSE_ENTER)
        )
        .on("mouseleave", () =>
            hoverHandler(dataTarget, constants.HOVER_EVENT.MOUSE_EXIT)
        );
};
/**
 * Validate and return the legendOptions property
 *
 * @param {object} graphConfig - config object of Graph API
 * @param {object} dataTarget - Data points object
 * @returns {object} legendOptions - legendOptions for the legend
 */
const getDefaultLegendOptions = (graphConfig, dataTarget) => {
    const legendOptions = getDefaultValue(dataTarget.legendOptions, {
        showShape: true,
        showLine: dataTarget.showShapes === false,
        showElement: true
    });
    legendOptions.showShape =
        dataTarget.showShapes !== false
            ? getDefaultValue(legendOptions.showShape, true)
            : false;
    legendOptions.showLine = getDefaultValue(legendOptions.showLine, false);
    legendOptions.showElement = getDefaultValue(
        legendOptions.showElement,
        true
    );
    legendOptions.style = getDefaultValue(legendOptions.style, {});

    if (legendOptions.style.strokeDashArray) {
        legendOptions.style = {
            strokeDashArray: getStrokeDashArray(legendOptions.style)
        };
    } else {
        if (dataTarget.showShapes === false) {
            legendOptions.style.strokeDashArray =
                dataTarget.style.strokeDashArray;
        } else {
            legendOptions.style = {
                strokeDashArray: getStrokeDashArray(legendOptions.style)
            };
        }
    }

    return legendOptions;
};
/**
 * Helper function to set the right legend padding values based on input JSON.
 *
 * @param {object} config - config which needs to be updated
 * @param {object} inputLegendPadding - input legend padding provided via input JSON.
 * @returns {object} - padding for Legend
 */
const getLegendPadding = (config, inputLegendPadding) => {
    // If legendPadding is provided, update the config object with the provided values, else update it with the default constants
    if (utils.isDefined(config.legendPadding)) {
        return {
            top: getDefaultValue(
                inputLegendPadding.top,
                constants.LEGEND_PADDING.top
            ),
            bottom: getDefaultValue(
                inputLegendPadding.bottom,
                constants.LEGEND_PADDING.bottom
            ),
            right: getDefaultValue(
                inputLegendPadding.right,
                constants.LEGEND_PADDING.right
            ),
            left: getDefaultValue(
                inputLegendPadding.left,
                constants.LEGEND_PADDING.left
            ),
            hasCustomLegendPadding: true
        };
    } else {
        return {
            top: constants.LEGEND_PADDING.top,
            bottom: constants.LEGEND_PADDING.bottom,
            right: constants.LEGEND_PADDING.right,
            left: constants.LEGEND_PADDING.left,
            hasCustomLegendPadding: false
        };
    }
};

/**
 * Updates the legend during reflow.
 *
 * @private
 * @param {object} legendSVG - d3 element path of the legend from the parent control
 * @param {object} legend - input item object processed from the input JSON
 * @param {object} graph - Graph object derived from input JSON
 * @param {object} eventHandlers - Callback function object executed when legend item is clicked or hovered.
 * Contains click and hover handlers as object property
 * @returns {object} returns the d3 element path for the legend
 */
const reflowLegend = (legendSVG, legend, graph, eventHandlers) => {
    const index = graph.config.shownTargets.indexOf(legend.key);
    const shouldForceDisableLegendItem =
        !!legend.label.isDisabled || utils.isEmptyArray(legend.values);
    const itemPath = legendSVG
        .select(`li[aria-describedby="${legend.key}"]`)
        .attr("aria-current", shouldForceDisableLegendItem || index > -1)
        .attr("aria-disabled", shouldForceDisableLegendItem)
        .style("display", legendDisplayStyle(legend));

    if (!shouldForceDisableLegendItem) {
        // set the click and hover handler, when legend have values
        itemPath
            .on("click", function () {
                return eventHandlers.clickHandler(this, legend);
            })
            .on("mouseenter", () =>
                eventHandlers.hoverHandler(
                    legend,
                    constants.HOVER_EVENT.MOUSE_ENTER
                )
            )
            .on("mouseleave", () =>
                eventHandlers.hoverHandler(
                    legend,
                    constants.HOVER_EVENT.MOUSE_EXIT
                )
            );
    } else {
        // set the click and hover handler to null, when legend doesnot have any value
        itemPath
            .on("click", () => null)
            .on("mouseenter", () => null)
            .on("mouseleave", () => null);
    }
    return legendSVG;
};

/**
 * @enum {Function}
 */
export {
    createLegend,
    loadLegendItem,
    loadPieLegendItem,
    removeLegendItem,
    legendClickHandler,
    legendHoverHandler,
    isLegendSelected,
    getDefaultLegendOptions,
    getLegendPadding,
    reflowLegend
};