"use strict";
import * as d3 from "d3";
import { parseTypedValue } from "../../../core/BaseConfig";
import {
calculateVerticalPadding,
getXAxisXPosition,
isValidAxisType
} from "../../../helpers/axis";
import constants from "../../../helpers/constants";
import errors from "../../../helpers/errors";
import {
legendClickHandler,
legendHoverHandler,
loadLegendItem,
isLegendSelected
} from "../../../helpers/legend";
import {
processRegions,
regionLegendHoverHandler
} from "../../../helpers/region";
import styles from "../../../helpers/styles";
import utils from "../../../helpers/utils";
import { d3RemoveElement } from "../../Graph/helpers/helpers";
import { generateColor, bubbleScale } from "./colorGradient";
import BubbleConfig from "../BubbleConfig";
/**
* @typedef Bubble
*/
/**
* Transforms the point in the bubble graph on resize
*
* @private
* @param {object} scale - d3 scale for Graph
* @returns {Function} - translate function for d3 transform
*/
const transformPoint = (scale) => (value) => {
const getX = (val) => scale.x(val.x);
const getY = (val) => scale[val.yAxis](val.y);
return `translate(${getX(value)},${getY(value)})`;
};
/**
* Transforms points for a data point set in the bubble graph on resize
*
* @private
* @param {object} scale - d3 scale for Graph
* @param {d3.selection} canvasSVG - d3 selection node of canvas svg
* @param {string} cls - selector for the data point translation
* @param {object} config - config object derived from input JSON
* @returns {object} d3 select object
*/
const translatePoints = (scale, canvasSVG, cls, config) =>
canvasSVG
.selectAll(`.${styles.bubbleGraphContent} .${cls}`)
.each(function (d) {
const pointSVG = d3.select(this);
pointSVG
.transition()
.call(
constants.d3Transition(config.settingsDictionary.transition)
)
.attr("transform", () => transformPoint(scale)(d));
});
/**
* Toggles the selection of a data point, 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) => {
blurActionHandler(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);
removeBubbleBlur();
},
value.key,
index,
value,
selectedTarget
)
);
};
/**
* Called on resize, translates the data point values.
* This includes:
* Points
* Selected point indicators
*
* @private
* @param {object} scale - d3 scale for Graph
* @param {d3.selection} canvasSVG - d3 selection node of canvas svg
* @param {object} config - config object derived from input JSON
* @returns {undefined} - returns nothing
*/
const translateBubbleGraph = (scale, canvasSVG, config) => {
translatePoints(scale, canvasSVG, styles.point, config);
translatePoints(scale, canvasSVG, styles.dataPointSelection, config);
};
/**
* Draws the bubble graph on the canvas element. This calls the Graph API to render the following first
* Grid
* Axes
* Legend
* Labels
* Once these items are rendered, we will parse through the data points and render the bubbles
*
* @private
* @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
* @param {Array} dataTarget - Data points
* @returns {undefined} - returns nothing
*/
const draw = (scale, config, canvasSVG, dataTarget) => {
const BubbleSVG = canvasSVG
.append("g")
.classed(styles.bubbleGraphContent, true)
.attr("clip-path", `url(#${config.clipPathId})`)
.attr("aria-hidden", config.shownTargets.indexOf(dataTarget.key) < 0)
.attr("aria-describedby", dataTarget.key);
const currentPointsPath = BubbleSVG.selectAll(
`.${styles.currentPointsGroup}`
).data([dataTarget]);
currentPointsPath
.enter()
.append("g")
.classed(styles.currentPointsGroup, true)
.attr(
"transform",
`translate(${getXAxisXPosition(config)},${calculateVerticalPadding(
config
)})`
);
currentPointsPath
.exit()
.transition()
.call(constants.d3Transition(config.settingsDictionary.transition))
.remove();
const bubblePoint = BubbleSVG.select(`.${styles.currentPointsGroup}`)
.selectAll(`.${styles.point}`)
.data(getDataPointValues(dataTarget).filter((d) => d.y !== null));
drawBubbles(scale, config, bubblePoint.enter(), dataTarget);
bubblePoint
.exit()
.transition()
.call(constants.d3Transition(config.settingsDictionary.transition))
.remove();
};
/**
* Processes the input JSON and adds the shapes, colors, labels etc. to each data points so that we
* can use them when rendering the data point.
*
* @private
* @param {object} graphConfig - config object of Graph API
* @param {object} dataTarget - Data points object
* @returns {object} dataTarget - Updated data target object
*/
const processDataPoints = (graphConfig, dataTarget) => {
const type = graphConfig.axis.x.type;
const getXDataValues = (x) => {
if (!isValidAxisType(x, type)) {
throw new Error(errors.THROW_MSG_INVALID_FORMAT_TYPE);
}
return parseTypedValue(x, type);
};
graphConfig.shownTargets.push(dataTarget.key);
dataTarget.internalValuesSubset = dataTarget.values.map((value) => ({
onClick: dataTarget.onClick,
isCritical: value.isCritical || false,
x: getXDataValues(value.x),
y: value.y,
weight: value.weight,
color: dataTarget.color || constants.DEFAULT_COLOR,
label: dataTarget.label || {},
yAxis: dataTarget.yAxis || constants.Y_AXIS,
key: dataTarget.key
}));
return dataTarget;
};
/**
* Returns the internal values subset which is the array that was created from the input JSON.
* This array has information for each data point w.r.t shape, colors and on click callback along with
* x and y co-ordinates.
*
* @private
* @param {object} target - Object containing the subsets
* @returns {Array} List of data point subsets
*/
const getDataPointValues = (target) => target.internalValuesSubset;
/**
* Enforces blur state for all the bubbles that is not the one clicked on.
* This is provided regardless of whether onClick is present or not.
*
* @private
* @param {object} target - target node of bubble which is clicked
* @returns {undefined} - returns nothing
*/
const enforceBubbleBlur = (target) => {
d3.select(target.viewportElement)
.selectAll(`.${styles.point}`)
.select("circle")
.attr("fill-opacity", constants.DEFAULT_BUBBLE_BLUR_OPACITY)
.attr("stroke-opacity", constants.DEFAULT_BUBBLE_BLUR_STROKE_OPACITY);
d3.select(target)
.select("circle")
.attr("fill-opacity", constants.DEFAULT_BUBBLE_OPACITY)
.attr("stroke-opacity", constants.DEFAULT_BUBBLE_STROKE_OPACITY);
};
/**
* Removes the carbon-bubbleBlur style from all the bubbles to unblur all the bubbles in the bubble graph.
*
* @private
* @returns {object} - d3 Selection object
*/
const removeBubbleBlur = () =>
d3
.selectAll(`.${styles.point}`)
.attr("aria-selected", false)
.select("circle")
.attr("fill-opacity", constants.DEFAULT_BUBBLE_OPACITY)
.attr("stroke-opacity", constants.DEFAULT_BUBBLE_STROKE_OPACITY);
/**
* Handler for the bubble that is clicked on. It blurs all other bubble in the bubble graph except one which is selected.
*
* @private
* @param {HTMLElement} target - Target element bubble clicked on
* @returns {undefined} - returns nothing
*/
const blurActionHandler = (target) => {
d3.select(target).attr("aria-selected", true);
enforceBubbleBlur(target);
};
/**
* Draws the points with options opted in the input JSON by the consumer for each data set.
* Render the point with appropriate color, 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 {Array} pointGroupPath - d3 html element of the points group
* @param {object} dataTarget - data for the bubble graph
* @returns {undefined} - returns nothing
*/
const drawBubbles = (scale, config, pointGroupPath, dataTarget) => {
const renderDataPoint = (path, value, index) => {
const bubblePoint = path
.append("g")
.classed(styles.point, true)
.attr("aria-disabled", !utils.isFunction(value.onClick))
.attr("transform", transformPoint(scale)(value))
.attr("aria-describedby", `${value.key}`)
.attr("aria-selected", false)
.attr(
"aria-hidden",
document
.querySelector(
`.${styles.legendItem}[aria-describedby="${value.key}"]`
)
?.getAttribute("aria-current") === "false"
)
.on("click", function () {
dataPointActionHandler(value, index, this);
});
bubblePoint
.append("circle")
.attr("aria-describedby", value.key)
.attr("r", (d) => decideRadius(dataTarget, d))
.attr("fill", (d) => decideColor(dataTarget, d))
.attr("fill-opacity", constants.DEFAULT_BUBBLE_OPACITY)
.attr("stroke", (d) => decideColor(dataTarget, d));
};
const renderSelectionPath = (path, value, index) => {
path.append("g")
.classed(styles.dataPointSelection, true)
.attr("transform", transformPoint(scale)(value))
.attr("aria-disabled", utils.isDefined(value.onClick))
.attr("aria-hidden", true)
.attr("aria-describedby", value.key)
.on("click", function () {
dataPointActionHandler(value, index, this);
})
.append("circle")
.attr(
"r",
(d) =>
decideRadius(dataTarget, d) +
constants.DEFAULT_BUBBLE_SELECTOR_RADIUS
);
};
pointGroupPath
.append("g")
.classed(styles.pointGroup, true)
.each(function (d, i) {
const dataPointSVG = d3.select(this);
renderSelectionPath(dataPointSVG, d, i);
renderDataPoint(dataPointSVG, d, i);
});
};
/**
* Checks if the weight object is defined with min and max values.
*
* @private
* @param {object} dataTarget - data for the bubble graph
* @returns {boolean} - returns true if weight is defined and inside weight min and max is also defined else false.
*/
const areWeightsDefined = (dataTarget) =>
utils.isDefined(dataTarget.weight)
? utils.isDefined(dataTarget.weight.min) &&
utils.isDefined(dataTarget.weight.max)
: false;
/**
* Checks if hue is defined in the input JSON to get color gradient.
*
* @private
* @param {object} hue - hue is object defining the color range.
* @returns {boolean} - returns true if hue is defined else false.
*/
const isHueDefined = (hue) => utils.isDefined(hue);
/**
* Decides the radius for each bubble
*
* @private
* @param {object} dataTarget - data for the bubble graph
* @param {number} value - data point whose radius has to be decided
* @returns {number} - returns the radius of the bubble
*/
const decideRadius = (dataTarget, value) => {
if (
areWeightsDefined(dataTarget) &&
utils.isUndefined(dataTarget.weight.maxRadius)
) {
return bubbleScale(dataTarget)(value.weight);
} else if (utils.isUndefined(dataTarget.weight)) {
return constants.DEFAULT_BUBBLE_RADIUS_MAX;
} else {
return dataTarget.weight.maxRadius;
}
};
/**
* Decides the color for each bubble
*
* @private
* @param {object} dataTarget - data for the bubble graph
* @param {number} value - data point whose color has to be decided
* @returns {string} - returns color string for each bubble
*/
const decideColor = (dataTarget, value) => {
if (isHueDefined(dataTarget.hue) && areWeightsDefined(dataTarget)) {
return generateColor(dataTarget)(bubbleScale(dataTarget)(value.weight));
} else if (
isHueDefined(dataTarget.hue) &&
areWeightsDefined(dataTarget) === false
) {
return generateColor(dataTarget)(value.y);
} else {
return dataTarget.color;
}
};
/**
* Handler for Request animation frame, executes on resize shows/hides the regions.
*
* @private
* @param {object} graphContext - Graph instance
* @param {object} config - Graph config object derived from input JSON
* @param {d3.selection} canvasSVG - d3 selection node of canvas svg
* @returns {function()} callback function handler for RAF
*/
const onAnimationHandler = (graphContext, config, canvasSVG) => () => {
processRegions(graphContext, config, canvasSVG);
};
/**
* Click handler for legend item. Removes the bubble from the graph
*
* @private
* @param {object} graphContext - Graph instance
* @param {Bubble} control - Bubble 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);
const isSelected = isLegendSelected(d3.select(element));
updateShownTarget(config.shownTargets, item);
canvasSVG
.selectAll(
`.${styles.dataPointSelection}[aria-describedby="${item.key}"]`
)
.attr("aria-hidden", true);
canvasSVG
.selectAll(`.${styles.point}[aria-describedby="${item.key}"]`)
.attr("aria-hidden", isSelected);
window.requestAnimationFrame(
onAnimationHandler(graphContext, config, canvasSVG)
);
};
/**
* Hover handler for legend item. Highlights current bubble 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
]);
canvasSVG
.selectAll(`.${styles.point}[aria-describedby="${item.key}"]`)
.classed(styles.highlight, state === constants.HOVER_EVENT.MOUSE_ENTER);
// Highlight region(s) of the item hovered on, only if the content is currently displayed
regionLegendHoverHandler(graphTargets, canvasSVG, item.key, state);
};
/**
* 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);
}
};
/**
* Clear the graph data points 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}"]`);
/**
* Calculates the min and max values for Y Axis or Y2 Axis.
* First we filter out values that are `null`, this is a result of
* datapoint being part of being in a non-contiguous series and then we
* get the min and max values for the Y or Y2 axis domain.
*
* @private
* @param {Array} values - Datapoint values
* @param {string} axis - y or y2
* @returns {object} - Contains min and max values for the data points for Y and Y2 axis
*/
const calculateValuesRange = (values, axis = constants.Y_AXIS) => {
const yAxisValuesList = values.filter((i) => i.y !== null).map((i) => i.y);
return {
[axis]: {
min: Math.min(...yAxisValuesList),
max: Math.max(...yAxisValuesList)
}
};
};
/**
* Data point sets can be loaded using this function.
* Load function validates, clones and stores the input onto a config object.
*
* @private
* @param {object} inputJSON - Input JSON provided by the consumer
* @returns {object} BubbleConfig config object containing consumer data
*/
const loadInput = (inputJSON) =>
new BubbleConfig().setInput(inputJSON).validateInput().clone().getConfig();
export {
areWeightsDefined,
calculateValuesRange,
clear,
clickHandler,
dataPointActionHandler,
draw,
drawBubbles,
decideRadius,
getDataPointValues,
hoverHandler,
isHueDefined,
loadInput,
prepareLegendItems,
processDataPoints,
translateBubbleGraph,
transformPoint,
translatePoints,
toggleDataPointSelection
};