/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/
import { find, isEmpty, uniqBy } from 'lodash';
import Plotly from 'plotly.js-dist';
import React, { useMemo } from 'react';
import { COLOR_BLACK, COLOR_WHITE } from '../../../../../common/constants/colors';
import {
METRICS_GRID_SPACE_BETWEEN_X_AXIS,
METRICS_GRID_SPACE_BETWEEN_Y_AXIS,
DEFAULT_METRICS_CHART_PARAMETERS,
METRICS_AXIS_MARGIN,
METRICS_ANNOTATION,
METRICS_REDUCE_VALUE_SIZE_PERCENTAGE,
METRICS_REDUCE_TITLE_SIZE_PERCENTAGE,
METRICS_REDUCE_SERIES_UNIT_SIZE_PERCENTAGE,
METRICS_SERIES_UNIT_SUBSTRING_LENGTH,
GROUPBY,
AGGREGATIONS,
} from '../../../../../common/constants/explorer';
import {
DEFAULT_CHART_STYLES,
FILLOPACITY_DIV_FACTOR,
} from '../../../../../common/constants/shared';
import {
ConfigListEntry,
IVisualizationContainerProps,
} from '../../../../../common/types/explorer';
import { uiSettingsService } from '../../../../../common/utils';
import { ThresholdUnitType } from '../../../event_analytics/explorer/visualizations/config_panel/config_panes/config_controls/config_thresholds';
import { EmptyPlaceholder } from '../../../event_analytics/explorer/visualizations/shared_components/empty_placeholder';
import {
getPropName,
getRoundOf,
getTooltipHoverInfo,
hexToRgb,
} from '../../../event_analytics/utils/utils';
import { Plt } from '../../plotly/plot';
const {
DefaultOrientation,
DefaultTextMode,
DefaultChartType,
BaseThreshold,
DefaultTextColor,
} = DEFAULT_METRICS_CHART_PARAMETERS;
interface CreateAnnotationType {
index: number;
label: string;
value: number | string;
valueColor: string;
}
export const Metrics = ({ visualizations, layout, config }: any) => {
const {
data: {
rawVizData: {
data: queriedVizData,
metadata: { fields },
},
userConfigs: {
dataConfig: {
span = {},
[GROUPBY]: xaxis = [],
[AGGREGATIONS]: series = [],
chartStyles = {},
panelOptions = {},
tooltipOptions = {},
thresholds = [],
},
layoutConfig = {},
},
},
vis: { charttype, titlesize, valuesize, textmode, orientation, precisionvalue },
}: IVisualizationContainerProps = visualizations;
// data config parametrs
const timestampField = find(fields, (field) => field.type === 'timestamp');
/**
* determine x axis
*/
let xaxes: ConfigListEntry[];
if (span && span.time_field && timestampField) {
xaxes = [timestampField, ...xaxis];
} else {
xaxes = xaxis;
}
const seriesLength = series.length;
const chartType = chartStyles.chartType || charttype;
if (
isEmpty(queriedVizData) ||
(chartType === DefaultChartType && xaxes.length === 0) ||
seriesLength === 0
)
return ;
// thresholds
const appliedThresholds = thresholds.length ? thresholds : [BaseThreshold];
const sortedThresholds = uniqBy(
[...appliedThresholds].sort((a: ThresholdUnitType, b: ThresholdUnitType) => a.value - b.value),
'value'
);
// style panel parameters
let titleSize =
chartStyles.titleSize ||
titlesize - titlesize * seriesLength * METRICS_REDUCE_TITLE_SIZE_PERCENTAGE;
const valueSize =
chartStyles.valueSize ||
valuesize - valuesize * seriesLength * METRICS_REDUCE_VALUE_SIZE_PERCENTAGE;
const selectedOrientation = chartStyles.orientation || orientation;
const chartOrientation =
selectedOrientation === DefaultOrientation || selectedOrientation === 'v'
? DefaultOrientation
: 'h';
const selectedTextMode = chartStyles.textMode || textmode;
let textMode =
selectedTextMode === DefaultTextMode || selectedTextMode === 'values+names'
? DefaultTextMode
: selectedTextMode;
const precisionValue = chartStyles.precisionValue || precisionvalue;
const seriesUnits =
chartStyles.seriesUnits?.substring(0, METRICS_SERIES_UNIT_SUBSTRING_LENGTH) || '';
const seriesUnitsSize = valueSize - valueSize * METRICS_REDUCE_SERIES_UNIT_SIZE_PERCENTAGE;
const isDarkMode = uiSettingsService.get('theme:darkMode');
const textColor = chartStyles.textColor?.childColor || DefaultTextColor;
if (chartType === 'text' && chartStyles.textMode === undefined) {
textMode = 'names';
titleSize = titlesize;
}
// margin from left of grid cell for label/value
const ANNOTATION_MARGIN_LEFT = seriesLength > 1 ? 0.01 : 0;
let autoChartLayout: object = {
annotations: [],
};
const xaxesData = xaxes.reduce((prev, cur) => {
if (queriedVizData[cur.name]) {
if (prev.length === 0) return queriedVizData[cur.name].flat();
return prev.map(
(item: string | number, index: number) => `${item},
${queriedVizData[cur.name][index]}`
);
}
}, []);
const createValueText = (value: string | number) =>
`${value}${
seriesUnits ? ` ${seriesUnits}` : ''
}`;
const calculateTextCooridinate = (seriesCount: number, index: number) => {
// calculating center of each subplot based on orienation vertical(single column) or horizontal(single row)
// splitting whole plot area with series length and find center of each individual subplot w.r.t index of series
if (seriesCount === 1) {
return 0.5;
} else if (index === 0) {
return 1 / seriesCount / 2;
}
return (index + 1) / seriesCount - 1 / seriesCount / 2;
};
const createAnnotationsAutoModeHorizontal = ({
label,
value,
index,
valueColor,
}: CreateAnnotationType) => {
const yCordinate = index > 0 ? (index + 1) / seriesLength : 1 / seriesLength;
return textMode === DefaultTextMode
? [
{
...METRICS_ANNOTATION,
x: ANNOTATION_MARGIN_LEFT,
y: yCordinate,
xanchor: 'left',
yanchor: 'top',
text: label,
font: {
size: titleSize,
color: isDarkMode ? COLOR_WHITE : COLOR_BLACK,
family: 'Roboto',
},
type: 'name',
seriesValue: value,
},
{
...METRICS_ANNOTATION,
x: 1,
y: yCordinate,
xanchor: 'right',
yanchor: 'top',
text: createValueText(value),
font: {
size: valueSize,
color: valueColor,
family: 'Roboto',
},
type: 'value',
seriesValue: value,
},
]
: [
{
...METRICS_ANNOTATION,
x: 0.5,
y: calculateTextCooridinate(seriesLength, index),
xanchor: 'center',
yanchor: 'bottom',
text: textMode === 'values' ? createValueText(value) : label,
font: {
size: textMode === 'values' ? valueSize : titleSize,
color: textMode === 'names' ? (isDarkMode ? COLOR_WHITE : COLOR_BLACK) : valueColor,
family: 'Roboto',
},
type: textMode === 'names' ? 'name' : 'value',
seriesValue: value,
},
];
};
const createAnnotationAutoModeVertical = ({
label,
value,
index,
valueColor,
}: CreateAnnotationType) => {
const xCoordinate = index / seriesLength + ANNOTATION_MARGIN_LEFT;
return textMode === DefaultTextMode
? [
{
...METRICS_ANNOTATION,
xanchor: 'left',
yanchor: 'bottom',
text: label,
font: {
size: titleSize,
color: isDarkMode ? COLOR_WHITE : COLOR_BLACK,
family: 'Roboto',
},
x: xCoordinate,
y: 1,
seriesValue: value,
type: 'name',
},
{
...METRICS_ANNOTATION,
xanchor: 'left',
yanchor: 'top',
text: createValueText(value),
font: {
size: valueSize,
color: valueColor,
family: 'Roboto',
},
x: xCoordinate,
y: 1,
type: 'value',
seriesValue: value,
},
]
: [
{
...METRICS_ANNOTATION,
x: calculateTextCooridinate(seriesLength, index),
xanchor: 'center',
y: 0.95,
yanchor: 'bottom',
text: textMode === 'values' ? createValueText(value) : label,
font: {
size: textMode === 'values' ? valueSize : titleSize,
color: textMode === 'names' ? (isDarkMode ? COLOR_WHITE : COLOR_BLACK) : valueColor,
family: 'Roboto',
},
type: textMode === 'names' ? 'name' : 'value',
seriesValue: value,
},
];
};
// extend y axis range to increase height of subplot w.r.t series data
const extendYaxisRange = (seriesLabel: string) => {
const sortedData = queriedVizData[seriesLabel]
.slice()
.sort((curr: number, next: number) => next - curr);
return isNaN(sortedData[0]) ? 100 : sortedData[0] + sortedData[0] / 2;
};
const getSeriesValue = (label: string) =>
typeof queriedVizData[label][queriedVizData[label].length - 1] === 'number'
? getRoundOf(
queriedVizData[label][queriedVizData[label].length - 1],
Math.abs(precisionValue)
)
: 0;
const generateLineTraces = () => {
return series.map((seriesItem: ConfigListEntry, seriesIndex: number) => {
const seriesLabel = getPropName(seriesItem);
const isLabelExisted = queriedVizData[seriesLabel] ? true : false;
const annotationOption = {
label: seriesLabel,
value: isLabelExisted ? getSeriesValue(seriesLabel) : 0,
index: seriesIndex,
valueColor: '',
};
const layoutAxisIndex = seriesIndex > 0 ? seriesIndex + 1 : '';
autoChartLayout = {
...autoChartLayout,
annotations: autoChartLayout.annotations.concat(
chartOrientation === DefaultOrientation || seriesLength === 1
? createAnnotationAutoModeVertical(annotationOption)
: createAnnotationsAutoModeHorizontal(annotationOption)
),
[`xaxis${layoutAxisIndex}`]: {
visible: false,
showgrid: false,
anchor: `y${layoutAxisIndex}`,
layoutFor: seriesLabel,
},
[`yaxis${layoutAxisIndex}`]: {
visible: false,
showgrid: false,
anchor: `x${layoutAxisIndex}`,
range: isLabelExisted ? [0, extendYaxisRange(seriesLabel)] : [0, 100],
layoutFor: seriesLabel,
},
};
return {
x: xaxesData,
y: queriedVizData[seriesLabel],
seriesValue: isLabelExisted ? getSeriesValue(seriesLabel) : 0,
fill: 'tozeroy',
mode: 'lines',
type: 'scatter',
fillcolor: '',
line: {
color: '',
},
name: seriesLabel,
...(seriesIndex > 0 && {
xaxis: `x${seriesIndex + 1}`,
yaxis: `y${seriesIndex + 1}`,
}),
hoverinfo: getTooltipHoverInfo({
tooltipMode: tooltipOptions.tooltipMode,
tooltipText: tooltipOptions.tooltipText,
}),
};
});
};
const createAnnotationTextModeVertical = ({
label,
value,
index,
valueColor,
}: CreateAnnotationType) => {
return textMode === DefaultTextMode
? [
{
...METRICS_ANNOTATION,
xanchor: 'left',
yanchor: seriesLength === 1 ? 'center' : 'bottom',
text: label,
font: {
size: titleSize,
color: textColor,
family: 'Roboto',
},
x:
seriesLength === 1
? 0 + ANNOTATION_MARGIN_LEFT
: index / seriesLength + ANNOTATION_MARGIN_LEFT,
y: 0.5,
seriesValue: value,
type: 'name',
},
{
...METRICS_ANNOTATION,
xanchor: seriesLength === 1 ? 'right' : 'left',
yanchor: seriesLength === 1 ? 'center' : 'top',
text: createValueText(value),
font: {
size: valueSize,
color: textColor,
family: 'Roboto',
},
x:
seriesLength === 1
? 1 - ANNOTATION_MARGIN_LEFT
: index / seriesLength + ANNOTATION_MARGIN_LEFT,
y: 0.5,
type: 'value',
seriesValue: value,
},
]
: [
{
...METRICS_ANNOTATION,
x: calculateTextCooridinate(seriesLength, index),
xanchor: 'center',
y: 0.5,
yanchor: 'center',
text: textMode === 'values' ? createValueText(value) : label,
font: {
size: textMode === 'values' ? valueSize : titleSize,
color: textColor,
family: 'Roboto',
},
type: textMode === 'names' ? 'name' : 'value',
seriesValue: value,
},
];
};
const createAnnotationTextModeHorizontal = ({
label,
value,
index,
valueColor,
}: CreateAnnotationType) => {
return textMode === DefaultTextMode
? [
{
...METRICS_ANNOTATION,
xanchor: 'left',
yanchor: 'center',
text: label,
font: {
size: titleSize,
color: COLOR_WHITE,
family: 'Roboto',
},
x: 0 + ANNOTATION_MARGIN_LEFT,
y: calculateTextCooridinate(seriesLength, index),
seriesValue: value,
type: 'name',
},
{
...METRICS_ANNOTATION,
xanchor: 'right',
yanchor: 'center',
text: createValueText(value),
font: {
size: valueSize,
color: COLOR_WHITE,
family: 'Roboto',
},
x: 1 - ANNOTATION_MARGIN_LEFT,
y: calculateTextCooridinate(seriesLength, index),
type: 'value',
seriesValue: value,
},
]
: [
{
...METRICS_ANNOTATION,
xanchor: 'center',
yanchor: 'center',
x: 0.5,
y: calculateTextCooridinate(seriesLength, index),
text: textMode === 'values' ? createValueText(value) : label,
font: {
size: textMode === 'values' ? valueSize : titleSize,
color: COLOR_WHITE,
family: 'Roboto',
},
type: textMode === 'names' ? 'name' : 'value',
seriesValue: value,
},
];
};
const generateRectShapes = () => {
const shape = {
type: 'rect',
xsizemode: 'scaled',
layer: 'below',
yref: 'paper',
xref: 'paper',
line: {
color: '',
width: 3,
},
fillcolor: '',
};
const shapes: any = [];
series.forEach((seriesItem: ConfigListEntry, seriesIndex: number) => {
const seriesLabel = getPropName(seriesItem);
const isLabelExisted = queriedVizData[seriesLabel] ? true : false;
const seriesValue = isLabelExisted ? getSeriesValue(seriesLabel) : 0;
const axisIndex = seriesIndex > 0 ? seriesIndex + 1 : '';
const annotation = {
label: seriesLabel,
value: seriesValue,
index: seriesIndex,
valueColor: '',
};
autoChartLayout = {
...autoChartLayout,
annotations: autoChartLayout.annotations.concat(
orientation === DefaultOrientation || seriesLength === 1
? createAnnotationTextModeVertical({
...annotation,
})
: createAnnotationTextModeHorizontal({
...annotation,
})
),
[`yaxis${axisIndex}`]: {
visible: false,
showgrid: false,
anchor: `x${axisIndex}`,
},
[`xaxis${axisIndex}`]: {
visible: false,
showgrid: false,
anchor: `y${axisIndex}`,
},
};
const nonSimilarAxis = orientation === DefaultOrientation ? 'x' : 'y';
const similarAxis = orientation === DefaultOrientation ? 'y' : 'x';
// for first metric
if (seriesIndex === 0) {
shapes.push({
...shape,
[`${nonSimilarAxis}0`]: 0,
[`${nonSimilarAxis}1`]: 1 / seriesLength,
[`${similarAxis}0`]: 0,
[`${similarAxis}1`]: 1,
seriesValue,
});
} else {
shapes.push({
...shape,
[`${nonSimilarAxis}0`]:
shapes[shapes.length - 1][`${nonSimilarAxis}1`] + METRICS_GRID_SPACE_BETWEEN_X_AXIS,
[`${nonSimilarAxis}1`]:
shapes[shapes.length - 1][`${nonSimilarAxis}1`] + 1 / seriesLength,
[`${similarAxis}0`]: 0,
[`${similarAxis}1`]: 1,
seriesValue,
});
}
});
return shapes;
};
const [statsData, statsLayout]: Plotly.Data[] = useMemo(() => {
let calculatedStatsData: Plotly.Data[] = [];
let sortedStatsData: Plotly.Data[] = [];
let sortedShapesData = [];
if (chartType === DefaultChartType) {
calculatedStatsData = generateLineTraces();
sortedStatsData = calculatedStatsData
.map((stat, statIndex) => ({ ...stat, oldIndex: statIndex }))
.sort((statCurrent, statNext) => statCurrent.seriesValue - statNext.seriesValue);
} else {
const shapes = generateRectShapes();
autoChartLayout = {
...autoChartLayout,
shapes,
};
sortedShapesData = shapes
.map((shape: object, shapeIndex: number) => ({ ...shape, oldIndex: shapeIndex }))
.sort((current: object, next: object) => current.seriesValue - next.seriesValue);
}
if (sortedThresholds.length) {
// threshold ranges with min, max values
let thresholdRanges: number[][] = [];
const maxValue =
chartType === DefaultChartType
? sortedStatsData[sortedStatsData.length - 1].seriesValue
: sortedShapesData[sortedShapesData.length - 1].seriesValue;
thresholdRanges = sortedThresholds.map((thresh, index) => [
thresh.value,
index === sortedThresholds.length - 1 ? maxValue : sortedThresholds[index + 1].value,
]);
if (chartType === DefaultChartType) {
if (thresholdRanges.length) {
// change color for line traces
for (let statIndex = 0; statIndex < sortedStatsData.length; statIndex++) {
for (let threshIndex = 0; threshIndex < thresholdRanges.length; threshIndex++) {
if (
Number(sortedStatsData[statIndex].seriesValue) >=
Number(thresholdRanges[threshIndex][0]) &&
Number(sortedStatsData[statIndex].seriesValue) <=
Number(thresholdRanges[threshIndex][1])
) {
calculatedStatsData[sortedStatsData[statIndex].oldIndex].fillcolor = hexToRgb(
sortedThresholds[threshIndex].color,
DEFAULT_CHART_STYLES.FillOpacity / FILLOPACITY_DIV_FACTOR
);
calculatedStatsData[sortedStatsData[statIndex].oldIndex].line.color =
sortedThresholds[threshIndex].color;
}
}
}
// change color of text annotations
for (
let annotationIndex = 0;
annotationIndex < autoChartLayout.annotations.length;
annotationIndex++
) {
const isSeriesValueText = autoChartLayout.annotations[annotationIndex].type === 'value';
const seriesValue = Number(autoChartLayout.annotations[annotationIndex].seriesValue);
for (let threshIndex = 0; threshIndex < thresholdRanges.length; threshIndex++) {
if (
isSeriesValueText &&
seriesValue >= Number(thresholdRanges[threshIndex][0]) &&
seriesValue <= Number(thresholdRanges[threshIndex][1])
) {
autoChartLayout.annotations[annotationIndex].font.color =
sortedThresholds[threshIndex].color;
}
}
}
}
} else {
// change color of shapes
for (let shapeIndex = 0; shapeIndex < sortedShapesData.length; shapeIndex++) {
for (let threshIndex = 0; threshIndex < thresholdRanges.length; threshIndex++) {
const seriesValue = Number(sortedShapesData[shapeIndex].seriesValue);
if (
seriesValue >= Number(thresholdRanges[threshIndex][0]) &&
seriesValue <= Number(thresholdRanges[threshIndex][1])
) {
const color = sortedThresholds[threshIndex].color;
autoChartLayout.shapes[sortedShapesData[shapeIndex].oldIndex].fillcolor = color;
autoChartLayout.shapes[sortedShapesData[shapeIndex].oldIndex].line = {
...autoChartLayout.shapes[sortedShapesData[shapeIndex].oldIndex].line,
color,
};
}
}
}
}
}
return [chartType === DefaultChartType ? calculatedStatsData : [], autoChartLayout];
}, [
xaxes,
series,
fields,
appliedThresholds,
chartOrientation,
titleSize,
valueSize,
textMode,
seriesUnits,
queriedVizData,
precisionValue,
]);
const mergedLayout = useMemo(() => {
return {
...layout,
...(layoutConfig.layout && layoutConfig.layout),
showlegend: false,
margin:
chartType === DefaultChartType
? METRICS_AXIS_MARGIN
: panelOptions.title || layoutConfig.layout?.title
? METRICS_AXIS_MARGIN
: { ...METRICS_AXIS_MARGIN, t: 0 },
...statsLayout,
grid: {
...(chartOrientation === DefaultOrientation
? {
rows: 1,
columns: seriesLength,
xgap: METRICS_GRID_SPACE_BETWEEN_X_AXIS,
}
: {
rows: seriesLength,
columns: 1,
ygap: METRICS_GRID_SPACE_BETWEEN_Y_AXIS,
}),
pattern: 'independent',
roworder: 'bottom to top',
},
title: panelOptions?.title || layoutConfig.layout?.title || '',
};
}, [
chartType,
layout,
layoutConfig.layout,
panelOptions?.title,
orientation,
seriesLength,
statsLayout,
]);
const mergedConfigs = {
...config,
...(layoutConfig.config && layoutConfig.config),
};
return ;
};