/* * 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 ; };