controls/Timeline/Timeline.js

"use strict";
import * as d3 from "d3";
import Construct from "../../core/Construct";
import { getYAxisHeight, updateXAxisDomain } from "../../helpers/axis";
import constants from "../../helpers/constants";
import errors from "../../helpers/errors";
import { createLegend, reflowLegend } from "../../helpers/legend";
import styles from "../../helpers/styles";
import { d3RemoveElement } from "../Graph/helpers/helpers";
import { getElementBoxSizingParameters } from "../../helpers/paddingUtils";
import {
    attachEventHandlers,
    calculateAxesLabelSize,
    calculateAxesSize,
    createAxes,
    createDefs,
    createLabel,
    createTimelineContent,
    detachEventHandlers,
    determineHeight,
    scaleGraph,
    clickHandler,
    hoverHandler
} from "./helpers/creationHelpers";
import {
    translateTimelineGraph,
    translateAxes
} from "./helpers/translateHelpers";
import TimelineConfig, { processInput } from "./TimelineConfig";
import TimelineContent from "./TimelineContent";

/**
 * @typedef {object} Timeline
 * @typedef {object} TimelineConfig
 */
const BASE_CANVAS_WIDTH_PADDING = constants.BASE_CANVAS_WIDTH_PADDING;
/**
 * Sets the canvas width
 *
 * @private
 * @param {HTMLElement} container - d3 HTML element object which forms the chart container
 * @param {object} config - config object derived from input JSON
 * @returns {undefined} - returns nothing
 */
const setCanvasWidth = (container, config) => {
    config.canvasWidth =
        parseInt(container.style("width"), 10) -
        getElementBoxSizingParameters(container);
};
/**
 * Sets the canvas width. Canvas rests within a container.
 *
 * @private
 * @param {object} config - config object derived from input JSON
 * @returns {undefined} - returns nothing
 */
const setCanvasHeight = (config) => {
    config.canvasHeight =
        getYAxisHeight(config) +
        (config.padding.bottom * 2 + config.padding.top) * 2;
};
/**
 * Data point sets can be loaded using this function.
 * Load function validates, clones and stores the input onto a config object.
 *
 * @private
 * @throws {module:errors.THROW_MSG_NO_AXES_DATA_LOADED}
 * @param {object} inputJSON - Input JSON provided by the consumer
 * @returns {object} config object containing consumer data
 */
const loadInput = (inputJSON) =>
    new TimelineConfig()
        .setInput(inputJSON)
        .validateInput()
        .clone()
        .getConfig();
/**
 * Executes the before init process checklist, needs to be called by parent control.
 *  Binds the chart id provided in the input JSON to graph container.
 *
 * @private
 * @param {Timeline} control - Timeline instance
 * @returns {Timeline} Timeline instance
 */
const beforeInit = (control) => {
    control.graphContainer = d3.select(control.config.bindTo);
    control.config.height = determineHeight(control.config);
    return control;
};
/**
 * Initializes the necessary Timeline constructor objects
 *
 * @private
 * @param {Timeline} control - Timeline instance
 * @returns {Timeline} Timeline instance
 */
const initConfig = (control) => {
    control.graphContainer = null;
    control.config = {
        axis: {
            x: {}
        },
        shownTargets: {},
        pan: {}
    };
    control.axis = {};
    control.scale = {};
    control.svg = null;
    control.legendSVG = null;
    control.content = [];
    control.contentConfig = [];
    control.resizeHandler = null;
    return control;
};
/**
 * Executes the init process checklist, needs to be called by parent control.
 * Needs to be called post calling beforeInit
 *  Sets the canvas width within the graph container
 *  Determines the height for canvas
 *  Calculates Axes width and height
 *  Calculates Axes label width and height, positioning
 *  Creates and sets the d3 scale for the Graph
 *
 * @private
 * @param {Timeline} control - Timeline instance
 * @returns {Timeline} Timeline instance
 */
const init = (control) => {
    setCanvasWidth(control.graphContainer, control.config);
    calculateAxesSize(control.config);
    calculateAxesLabelSize(control.config);
    setCanvasHeight(control.config);
    scaleGraph(control.scale, control.config);
    return control;
};

/**
 * Timeline graph is a graph with no Y Axis. This is primarily used to denote an action
 * at a given point of time. It also accepts/supplies a content property which can be used to
 * display non-numeric values which typically cannot be represented using traditional graphs like
 * Line or Paired Result.
 *
 * * Axes - X axis only
 * * X Axis ticks
 * * Legend
 * * X Axis label
 * * Data points
 *
 * Lifecycle functions include:
 *  * BeforeInit
 *  * Init
 *  * Render
 *  * AfterInit
 *
 * @module Timeline
 * @class Timeline
 */
class Timeline extends Construct {
    /**
     * @class
     * @param {TimelineConfig} input - Input JSON instance created using TimelineConfig
     */
    constructor(input) {
        super();
        initConfig(this);
        this.generate(input);
    }

    /**
     * Draw function that is called by the parent control. This draws the x-axis, legend and
     * X Axis label for the chart construct.
     *
     * @description Since we don't have the concept of z-index in visualization,
     * the order of rendering should be following:
     *  * SVG container
     *  * X-Axis
     *  * X Axis Label
     *  * Legend
     *  * Data [In our case we have load and unload]
     * @param {object} input - Input JSON
     * @returns {HTMLElement} d3 selection node of svg.
     */
    generate(input) {
        this.config = loadInput(input);
        processInput(input, this.config);
        beforeInit(this);
        init(this);
        const containerSVG = d3
            .select(this.config.bindTo)
            .append("div")
            .classed(styles.container, true)
            .style("padding-top", this.config.removeContainerPadding && 0)
            .style("padding-bottom", this.config.removeContainerPadding && 0);
        this.svg = containerSVG
            .insert("svg", ":first-child")
            .classed(styles.canvas, true)
            .attr("role", "img")
            .attr("height", this.config.canvasHeight)
            .attr(
                "width",
                this.config.padding.hasCustomPadding
                    ? this.config.canvasWidth
                    : this.config.canvasWidth - BASE_CANVAS_WIDTH_PADDING
            );
        createDefs(this.config, this.svg);
        createAxes(this.axis, this.scale, this.config, this.svg);
        const ticks = document.getElementsByClassName("tick");
        const firstTick = ticks[0].getBoundingClientRect().width;
        const lastTick = ticks[ticks.length - 1].getBoundingClientRect().width;
        const xAxis = document
            .getElementsByClassName("carbon-axis carbon-axis-x")[0]
            .getBoundingClientRect().width;
        if (xAxis + firstTick / 2 + lastTick / 2 > this.config.canvasWidth) {
            d3RemoveElement(this.graphContainer, "defs");
            d3RemoveElement(this.graphContainer, `.${styles.axisX}`);
            this.config.padding.left = firstTick / 2;
            this.config.padding.right = lastTick / 2;
            createDefs(this.config, this.svg);
            createAxes(this.axis, this.scale, this.config, this.svg);
        }
        createTimelineContent(this.config, this.svg);
        createLabel(this.config, this.svg);
        if (this.config.showLegend) {
            this.legendSVG = createLegend(
                this.config,
                this.config.bindLegendTo
                    ? d3.select(this.config.bindLegendTo)
                    : containerSVG
            );
        }
        attachEventHandlers(this);
        return this.svg;
    }

    /**
     * Resizes the graph canvas. Uses the clipPath def.
     * It scales the graph on resize, and translates the graph elements:
     *  X-Axis
     *  Label
     *
     *  @returns {Timeline} - Timeline instance
     */
    resize() {
        setCanvasWidth(this.graphContainer, this.config);
        scaleGraph(this.scale, this.config);
        translateTimelineGraph(this);
        this.contentConfig.forEach((control) => control.resize(this));
        return this;
    }

    /**
     * Loads the content onto the graph.
     * The content serves as a 1to1 relationship. For rendering
     * multiple data sets respective number of content needs to be provided.
     *
     * @param {object} input - Timeline content
     * @returns {Timeline} - Timeline instance
     */
    loadContent(input) {
        const _content = new TimelineContent(input);
        _content.load(this);
        this.content.push(input.key);
        this.contentConfig.push(_content);
        this.resize();
        return this;
    }

    /**
     * Unloads the content from the graph.
     * The content serves as a 1to1 relationship. For rendering
     * multiple data sets respective number of content needs to be provided.
     *
     * @param {object} input - Timeline content to be removed
     * @returns {Timeline} - Timeline instance
     */
    unloadContent(input) {
        const index = this.content.indexOf(input.key);
        if (index < 0) {
            throw new Error(errors.THROW_MSG_INVALID_OBJECT_PROVIDED);
        }
        this.contentConfig[index].unload(this);
        this.content.splice(index, 1);
        this.contentConfig.splice(index, 1);
        this.resize();
        return this;
    }

    /**
     * Updates the graph axisData and content.
     *
     *  @returns {Timeline} - Timeline instance
     * @param {Array} graphData - Input array that holds updated values and key
     */
    reflow(graphData) {
        const eventHandlers = {
            clickHandler: clickHandler(this, this, this.config, this.svg),
            hoverHandler: hoverHandler(this.config.shownTargets, this.svg)
        };
        updateXAxisDomain(this.config);
        scaleGraph(this.scale, this.config);
        translateAxes(this.axis, this.scale, this.config, this.svg);
        let position;
        if (
            graphData &&
            graphData.values &&
            this.content.includes(graphData.key)
        ) {
            this.contentConfig.forEach((config, index) => {
                if (config.config.key === graphData.key) position = index;
            });
            this.contentConfig[position].reflow(this, graphData);
        }
        reflowLegend(
            this.legendSVG,
            this.contentConfig[0].config,
            this,
            eventHandlers
        );
        this.resize();
        return this;
    }

    /**
     * Destroys the graph: Container and canvas.
     *
     * @returns {Timeline} - Timeline instance
     */
    destroy() {
        detachEventHandlers(this);
        d3RemoveElement(this.graphContainer, `.${styles.canvas}`);
        d3RemoveElement(this.graphContainer, `.${styles.container}`);
        initConfig(this);
        return this;
    }
}

export default Timeline;