controls/Timeline/helpers/creationHelpers.js

"use strict";
import * as d3 from "d3";
import { Shape } from "../../../core";
import { getDefaultSVGProps } from "../../../core/Shape";
import {
    buildAxisLabel,
    getAxisLabelHeight,
    getAxisTickFormat,
    getRotationForAxis,
    getXAxisHeight,
    getYAxisHeight,
    prepareXAxis
} from "../../../helpers/axis";
import constants, { SHAPES } from "../../../helpers/constants";
import {
    legendClickHandler,
    legendHoverHandler,
    loadLegendItem,
    isLegendSelected
} from "../../../helpers/legend";
import { getSVGObject } from "../../../helpers/shapeSVG";
import styles from "../../../helpers/styles";
import utils from "../../../helpers/utils";
import {
    attachEventHandlers,
    d3RemoveElement,
    detachEventHandlers,
    getColorForTarget,
    getShapeForTarget
} from "../../Graph/helpers/helpers";
import { transformPoint } from "./translateHelpers";

/**
 * @typedef TimelineContent
 */

const DEFAULT_HEIGHT = constants.TIMELINE_HEIGHT;

/**
 * Calculates the height for graph
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @returns {number} Height for the axes
 */
const determineHeight = (config) =>
    DEFAULT_HEIGHT + config.padding.top - config.padding.bottom;
/**
 * Calculates axes label sizes, specifically:
 *  X Axis Label: Height
 *
 *  @private
 *  @param {object} config - config object derived from input JSON
 *  @returns {undefined} - returns nothing
 */
const calculateAxesLabelSize = (config) => {
    config.axisLabelHeights = {};
    config.axisLabelHeights.x = 0;
    if (config.showLabel) {
        if (config.axis.x.label) {
            config.axisLabelHeights.x = getAxisLabelHeight(config.axis.x.label);
        }
    }
};
/**
 * Calculates axes sizes, specifically:
 *  X Axis: Height
 *  Padding is provided enough to accommodate around 15 characters.
 *  Beyond which we would need to apply truncation (ellipsis)
 *
 *  @private
 *  @param {object} config - config object derived from input JSON
 *  @returns {undefined} - returns nothing
 */
const calculateAxesSize = (config) => {
    config.axisSizes = {};
    config.axisSizes.x = getXAxisHeight(config);
};
/**
 * X Axis's starting position within the canvas
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @returns {number} Position for the axis
 */
const getXAxisXPosition = (config) => config.padding.left;
/**
 * X Axis's position vertically relative to the canvas
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @returns {number} Position for the axis
 */
const getXAxisYPosition = (config) =>
    config.padding.top * 1.5 + config.padding.bottom;

/**
 * X Axis's width that will hold equally spaced ticks
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @returns {number} X Axis width
 */
const getXAxisWidth = (config) =>
    config.canvasWidth - config.padding.left - config.padding.right;
/**
 * X Axis label's starting position below the graph
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @returns {number} Position for the label
 */
const getXAxisLabelXPosition = (config) =>
    getXAxisXPosition(config) + getXAxisWidth(config) / 2;
/**
 * X Axis label's position vertically below the graph
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @returns {number} Position for the label
 */
const getXAxisLabelYPosition = (config) =>
    getXAxisYPosition(config) +
    config.axisLabelHeights.x * 2 +
    config.padding.bottom * 4;
/**
 * Prepares X,Y and Y2 Axes according to their scale and available container width and height
 *
 * @private
 * @param {object} axis - Axis scaled according to input parameters
 * @param {object} scale - d3 scale taking into account the input parameters
 * @param {object} config - config object derived from input JSON
 * @returns {object} - Scaled axes object
 */
const getAxesScale = (axis, scale, config) => {
    axis.x = prepareXAxis(
        scale.x,
        config.axis.x.ticks.values,
        getXAxisWidth(config),
        getAxisTickFormat(
            config.d3Locale,
            config.axis.x.ticks.format,
            config.axis.x.type
        )
    ).tickSize(constants.DEFAULT_TIMELINE_TICK_LENGTH);
    return axis;
};
/**
 * Creates and sets the d3 scale for the Graph. Once the scale is created
 * we can create the axes. To create a d3 scale, we need domain and range.
 * To create an axis we need scale, orientation and tick values, if needed
 *
 * The scale function uses d3.linear.nice which rounds the values in the axes.
 * i.e. [0.20147987687960267, 0.996679553296417] will get translated to [0.2, 1]
 *
 * The scale function uses d3.linear.clamp which "clamps" the scale so that any
 * input provided will clamp between the domain.
 * i.e. Before, If you have domain 0 to 20 (input lower and upper bounds) and range 0 to 100 (Width in px).
 * When input 20 is provided then the scale returns the px positioning as 200, which would put the point outside the graph.
 * Instead we clamp it within the graph as an upper bound using clamp. Now, it will return 100px.
 *
 * @private
 * @param {object} scale - d3 scale taking into account the input parameters
 * @param {object} config - config object derived from input JSON
 * @returns {undefined} - returns nothing
 */
const scaleGraph = (scale, config) => {
    scale.x = d3
        .scaleTime()
        .domain(config.axis.x.domain)
        .range([0, getXAxisWidth(config)])
        .clamp(config.settingsDictionary.shouldClamp);
    if (config.axis.x.rangeRounding) {
        scale.x.nice();
    }
};
/**
 * Added defs element for the canvas. This currently holds the clip paths for the entire chart.
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @param {d3.Selection} canvasSVG - d3 selection node of canvas svg
 * @returns {object} d3 svg path
 */
const createDefs = (config, canvasSVG) =>
    canvasSVG
        .append("defs")
        .append("clipPath")
        .attr("id", config.clipPathId)
        .append("rect")
        .attr(constants.X_AXIS, getXAxisXPosition(config))
        .attr(constants.Y_AXIS, getXAxisYPosition(config) / 2)
        .attr("width", getXAxisWidth(config))
        .attr("height", getYAxisHeight(config));
/**
 * Create the d3 Axis - X and append into the canvas.
 *
 * @private
 * @param {object} axis - Axis scaled according to input parameters
 * @param {object} scale - d3 scale taking into account the input parameters
 * @param {object} config - config object derived from input JSON
 * @param {d3.selection} canvasSVG - d3 selection node of canvas svg
 * @returns {undefined} - returns nothing
 */
const createAxes = (axis, scale, config, canvasSVG) => {
    getAxesScale(axis, scale, config);
    canvasSVG
        .append("g")
        .classed(styles.axis, true)
        .classed(styles.axisX, true)
        .attr("aria-hidden", false)
        .attr(
            "transform",
            `translate(${getXAxisXPosition(config)}, ${getXAxisYPosition(
                config
            )})`
        )
        .call(axis.x);
};
/**
 * Create the d3 Labels - X and append into the canvas.
 * Only if showLabel is enabled. X Axis is 0 deg rotated
 *
 * @private
 * @todo Label overflow formatting, adding ellipsis?
 * @param {object} config - config object derived from input JSON
 * @param {d3.selection} canvasSVG - d3 selection node of canvas svg
 * @returns {undefined} - returns nothing
 */
const createLabel = (config, canvasSVG) => {
    if (config.showLabel) {
        if (config.axis.x.label) {
            const labelPath = canvasSVG
                .append("g")
                .classed(styles.axisLabelX, true)
                .attr(
                    "transform",
                    `translate(${getXAxisLabelXPosition(
                        config
                    )},${getXAxisLabelYPosition(
                        config
                    )}) rotate(${getRotationForAxis(constants.X_AXIS)})`
                );
            buildAxisLabel(labelPath, utils.sanitize(config.axis.x.label));
        }
    }
};
/**
 * Creates a container for timeline graph
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @param {d3.selection} canvasSVG - d3 selection node of canvas svg
 * @returns {object} d3 svg path
 */
const createTimelineContent = (config, canvasSVG) =>
    canvasSVG
        .append("g")
        .classed(styles.timelineGraphContent, true)
        .attr("clip-path", `url(#${config.clipPathId})`);
/**
 * Creates a group for each timeline content loaded
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @param {d3.selection} canvasSVG - d3 selection node of canvas svg
 * @param {object} contentConfig - content config object
 * @returns {object} d3 svg path
 */
const createTimelineContentGroup = (config, canvasSVG, contentConfig) =>
    canvasSVG
        .append("g")
        .classed(styles.timelineContentGroup, true)
        .attr("transform", `translate(${getXAxisXPosition(config)},0)`)
        .attr("aria-labelledby", contentConfig.label.display)
        .attr("aria-describedby", contentConfig.key);
/**
 * Toggles the selection of a dateline indicator, executes on click of a data point.
 *
 * @private
 * @param {object} target - DOM element of the data point clicked
 * @returns {Array} d3 html element of the selected point
 */
const toggleDataPointSelection = (target) => {
    const selectedPointNode = d3
        .select(target.parentNode)
        .select(`.${styles.dataPointSelection}`);
    selectedPointNode.attr(
        "aria-hidden",
        !(selectedPointNode.attr("aria-hidden") === "true")
    );
    return selectedPointNode;
};
/**
 * Handler for the data point on click. If the content property is present for the data point
 * then the callback is executed other wise it is NOP.
 * If the callback is present, the selected data point is toggled and the element is passed as an argument to the
 * consumer in the callback, to execute once the popup is closed.
 *  Callback arguments:
 *      Post close callback function
 *      value [x and y data point values]
 *      Selected data point target [d3 target]
 *  On close of popup, call -> the provided callback
 *
 * @private
 * @param {object} value - data point object
 * @param {number} index - data point index for the set
 * @param {object} target - DOM object of the clicked point
 * @returns {undefined} - returns nothing
 */
const dataPointActionHandler = (value, index, target) => {
    if (utils.isEmpty(value.onClick)) {
        return;
    }
    toggleDataPointSelection(target).call((selectedTarget) =>
        value.onClick(
            () => {
                selectedTarget.attr("aria-hidden", true);
            },
            value.key,
            index,
            value,
            selectedTarget
        )
    );
};
/**
 * Draws the points with options opted in the input JSON by the consumer.
 *  Render the point with appropriate color, shape, x and y co-ordinates, label etc.
 *
 * @private
 * @param {object} pathProperties - holds SVG path element, data point object, data point index
 * @param {object} dataPointProperties - holds object scale, config for aria-hidden attribute,
 *    fill style, shape style, SVG class name
 * @param {object} graphProperties - holds d3 scale for Graph, Graph config derived from input
 *    JSON, and class attribute name
 * @returns {undefined} - returns nothing
 */
const renderPointPath = (
    pathProperties,
    dataPointProperties,
    graphProperties
) => {
    const { path, value, index } = pathProperties;
    const {
        objectScale,
        hiddenConfig,
        fillStyle,
        shapeStyle,
        pointStyle
    } = dataPointProperties;
    const { scale, config, cls } = graphProperties;
    path.append(() =>
        new Shape(getSVGObject(shapeStyle, objectScale)).getShapeElement(
            getDefaultSVGProps({
                svgClassNames: `${pointStyle} ${cls}`,
                svgStyles: `fill: ${fillStyle};`,
                transformFn: transformPoint(scale, config)(value),
                onClickFn() {
                    dataPointActionHandler(value, index, this);
                },
                a11yAttributes: {
                    "aria-describedby": value.key,
                    "aria-hidden": hiddenConfig,
                    "aria-disabled": !utils.isFunction(value.onClick)
                }
            })
        )
    );
};
/**
 * Draws the points with options opted in the input JSON by the consumer for each data set.
 *  Render the point with appropriate color, shape, x and y co-ordinates, label etc.
 *  On click content callback function is called.
 *
 * @private
 * @param {object} scale - d3 scale for Graph
 * @param {object} config - Graph config object derived from input JSON
 * @param {d3.selection} canvasSVG - d3 html element of the canvas
 * @returns {undefined} - returns nothing
 */
const createPoints = (scale, config, canvasSVG) => {
    const renderDataPointPath = (path, value, index) => {
        let cls;
        const objectScale = constants.DEFAULT_TIMELINE_SCALE;
        const hiddenConfig =
            document
                .querySelector(
                    `.${styles.legendItem}[aria-describedby="${value.key}"]`
                )
                ?.getAttribute("aria-current") === "false";
        const fillStyle = getColorForTarget(value);
        const shapeStyle = getShapeForTarget(value);
        const pointStyle = styles.point;

        const pathProperties = { path, value, index };
        const dataPointProperties = {
            objectScale,
            hiddenConfig,
            fillStyle,
            shapeStyle,
            pointStyle
        };
        const graphProperties = { scale, config, cls };
        renderPointPath(pathProperties, dataPointProperties, graphProperties);
    };
    const renderSelectionPath = (path, value, index) => {
        let fillStyle, pointStyle;
        const objectScale = constants.DEFAULT_TIMELINE_PLOT_SELECTION_SCALE;
        const hiddenConfig = true;
        const shapeStyle = SHAPES.CIRCLE;
        const cls = styles.dataPointSelection;

        const pathProperties = { path, value, index };
        const dataPointProperties = {
            objectScale,
            hiddenConfig,
            fillStyle,
            shapeStyle,
            pointStyle
        };
        const graphProperties = { scale, config, cls };
        renderPointPath(pathProperties, dataPointProperties, graphProperties);
    };
    const renderCriticalityPath = (path, value, index, cls, config) => {
        let fillStyle;
        const objectScale = constants.DEFAULT_TIMELINE_SCALE;
        const hiddenConfig =
            document
                .querySelector(
                    `.${styles.legendItem}[aria-describedby="${value.key}"]`
                )
                ?.getAttribute("aria-current") === "false";
        const shapeStyle = getShapeForTarget(value);
        const pointStyle = styles.point;

        const pathProperties = { path, value, index };
        const dataPointProperties = {
            objectScale,
            hiddenConfig,
            fillStyle,
            shapeStyle,
            pointStyle
        };
        const graphProperties = { scale, config, cls };
        renderPointPath(pathProperties, dataPointProperties, graphProperties);
    };
    canvasSVG
        .append("g")
        .classed(styles.pointGroup, true)
        .each(function (d, i) {
            const dataPointSVG = d3.select(this);
            renderSelectionPath(dataPointSVG, d, i);
            if (d.isCritical) {
                renderCriticalityPath(
                    dataPointSVG,
                    d,
                    i,
                    styles.criticalityTimelineOuterPoint,
                    config
                );
                renderCriticalityPath(
                    dataPointSVG,
                    d,
                    i,
                    styles.criticalityTimelineInnerPoint,
                    config
                );
            }
            renderDataPointPath(dataPointSVG, d, i);
        });
};
/**
 * A callback that will be sent to Graph class so that when graph is
 * created the Graph API will execute this callback function and the legend
 * items are loaded.
 *
 * @private
 * @param {object} config - Graph config object derived from input JSON
 * @param {object} eventHandlers - Object containing click and hover event handlers for legend item
 * @param {object} dataTarget - Data points object
 * @param {object} legendSVG - d3 element that will be need to render the legend
 * items into.
 * @returns {undefined} - returns nothing
 */
const prepareLegendItems = (config, eventHandlers, dataTarget, legendSVG) => {
    if (dataTarget.label && dataTarget.label.display && legendSVG) {
        loadLegendItem(legendSVG, dataTarget, config, eventHandlers);
    }
};
/**
 * Handler for Request animation frame, executes on resize.
 *  * Order of execution
 *      * Redraws the content
 *      * Shows/hides the regions
 *
 * @private
 * @param {object} graphContext - Graph instance
 * @param {TimelineContent} control - TimelineContent instance
 * @returns {function()} callback function handler for RAF
 */
const onAnimationHandler = (graphContext, control) => () => {
    // control.redraw(graphContext);
};
/**
 * Click handler for legend item. Removes the line from graph when clicked and calls redraw
 *
 * @private
 * @param {object} graphContext - Graph instance
 * @param {TimelineContent} control - TimelineContent instance
 * @param {object} config - Graph config object derived from input JSON
 * @param {d3.selection} canvasSVG - d3 selection node of canvas svg
 * @returns {Function} - returns callback function that handles click action on legend item
 */
const clickHandler = (graphContext, control, config, canvasSVG) => (
    element,
    item
) => {
    const updateShownTarget = (shownTargets, item) => {
        const index = shownTargets.indexOf(item.key);
        if (index > -1) {
            shownTargets.splice(index, 1);
        } else {
            shownTargets.push(item.key);
        }
    };
    legendClickHandler(element);
    updateShownTarget(config.shownTargets, item);
    canvasSVG
        .selectAll(`.${styles.point}[aria-describedby="${item.key}"]`)
        .attr("aria-hidden", isLegendSelected(d3.select(element)));
    window.requestAnimationFrame(onAnimationHandler(graphContext, control));
};
/**
 * Hover handler for legend item. Highlights current line and blurs the rest of the targets in Graph
 * if present.
 *
 * @private
 * @param {Array} graphTargets - List of all the items in the Graph
 * @param {d3.selection} canvasSVG - d3 selection node of canvas svg
 * @returns {Function} - returns callback function that handles hover action on legend item
 */
const hoverHandler = (graphTargets, canvasSVG) => (item, state) => {
    const additionalHoverHandler = (
        shownTargets,
        canvasSVG,
        currentKey,
        hoverState,
        k
    ) => {
        canvasSVG
            .selectAll(`.${styles.point}[aria-describedby="${k}"]`)
            .classed(styles.blur, state === constants.HOVER_EVENT.MOUSE_ENTER);
    };
    legendHoverHandler(graphTargets, canvasSVG, item.key, state, [
        additionalHoverHandler
    ]);
};
/**
 * CLear the graph data points and lines currently rendered
 *
 * @private
 * @param {d3.selection} canvasSVG - d3 selection node of canvas svg
 * @param {object} dataTarget - Data points object
 * @returns {object} - d3 select object
 */
const clear = (canvasSVG, dataTarget) =>
    d3RemoveElement(canvasSVG, `g[aria-describedby="${dataTarget.key}"]`);

export {
    calculateAxesLabelSize,
    getXAxisWidth,
    getXAxisXPosition,
    getXAxisYPosition,
    getXAxisLabelXPosition,
    getXAxisLabelYPosition,
    getAxesScale,
    calculateAxesSize,
    createAxes,
    createDefs,
    createLabel,
    createTimelineContent,
    createTimelineContentGroup,
    createPoints,
    hoverHandler,
    clickHandler,
    prepareLegendItems,
    scaleGraph,
    determineHeight,
    getShapeForTarget,
    getColorForTarget,
    attachEventHandlers,
    detachEventHandlers,
    clear
};