import { Scale } from '@visx/visx'; import { bisector } from 'd3-array'; import type { ScaleLinear, ScaleTime } from 'd3-scale'; import numeral from 'numeral'; import { add, addIndex, always, any, cond, equals, filter, find, flatten, gt, head, identity, includes, isEmpty, isNil, isNotNil, keys, last, lt, map, negate, path, pipe, pluck, prop, propEq, reduce, reject, sortBy, split, T, uniq } from 'ramda'; import { margin } from '../../Chart/common'; import type { LineChartData } from '../models'; import type { AxeScale, FormatMetricValueProps, Line, Metric, TimeValue, TimeValueProps, Xscale } from './models'; interface TimeTickWithMetrics { metrics: Array; timeTick: string; } const defaultDsData = { ds_color_line: '#000000', ds_filled: false, ds_invert: false, ds_legend: '', ds_order: '0', ds_stack: '0', ds_stack_key: null, ds_transparency: 80 }; const toTimeTickWithMetrics = ({ metrics, times }: { metrics: Array; times: Array; }): Array => map( (timeTick: string) => ({ metrics, timeTick }), times ); const toTimeTickValue = ( { timeTick, metrics }: TimeTickWithMetrics, timeIndex: number ): TimeValue => { const getMetricsForIndex = (): Omit => { const addMetricForTimeIndex = ( acc: TimeValue, { metric_id, data }: Metric ): TimeValue => ({ ...acc, [metric_id]: data[timeIndex] === undefined ? null : data[timeIndex] }) as TimeValue; return reduce(addMetricForTimeIndex, {} as TimeValue, metrics); }; return { timeTick, ...getMetricsForIndex() }; }; const getTimeSeries = (graphData: LineChartData): Array => { const isGreaterThanLowerLimit = (value: number | null): boolean => { const lowerLimit = path(['global', 'lower-limit'], graphData); if (isNil(lowerLimit)) { return true; } return value !== null && value >= lowerLimit; }; const rejectLowerThanLimit = ({ timeTick, ...metrics }: TimeValue): TimeValue => ({ ...filter(isGreaterThanLowerLimit, metrics), timeTick }); const indexedMap = addIndex(map); return pipe( toTimeTickWithMetrics, indexedMap(toTimeTickValue), map(rejectLowerThanLimit) )(graphData); }; const toLine = ({ ds_data, legend, metric, unit, average_value, minimum_value, maximum_value, metric_id, displayAs }: Metric): Line => { const safeDsData = { ...defaultDsData, ...(ds_data || {}), ds_color_area: ds_data?.ds_color_area ?? ds_data?.ds_color_line ?? defaultDsData.ds_color_line }; return { areaColor: safeDsData.ds_color_area, average_value, color: safeDsData.ds_color_line, display: true, displayAs, filled: safeDsData.ds_filled, highlight: undefined, invert: safeDsData.ds_invert, legend: safeDsData.ds_legend, lineColor: safeDsData.ds_color_line, maximum_value, metric, metric_id, minimum_value, name: legend, stackKey: safeDsData.ds_stack_key || null, stackOrder: equals(safeDsData.ds_stack, '1') || equals(safeDsData.ds_stack, true) ? Number.parseInt(safeDsData.ds_order || '0', 10) : null, transparency: safeDsData.ds_transparency, unit }; }; const getLineData = (graphData: LineChartData): Array => map(toLine, graphData.metrics); const getMin = (values: Array): number => Math.min(...values); const getMax = (values: Array): number => Math.max(...values); const getTime = (timeValue: TimeValue): number => new Date(timeValue.timeTick).valueOf(); const getMetrics = (timeValue: TimeValue): Array => // @ts-expect-error - suppressing pre-existing type mismatch pipe(keys, reject(equals('timeTick')))(timeValue); const getValueForMetric = (timeValue: TimeValue) => (metric_id: number): number => prop(metric_id, timeValue) as number; const getUnits = (lines: Array): Array => // @ts-expect-error - suppressing pre-existing type mismatch pipe(map(prop('unit')), uniq)(lines); interface ValuesForUnitProps { lines: Array; timeSeries: Array; unit: string; } const getMetricValuesForUnit = ({ lines, timeSeries, unit }: ValuesForUnitProps): Array => { const getTimeSeriesValuesForMetric = (metric_id: number): Array => map( (timeValue) => getValueForMetric(timeValue)(metric_id), timeSeries ) as Array; return pipe( filter(propEq(unit, 'unit')) as (line: Array) => Array, map(prop('metric_id')), map(getTimeSeriesValuesForMetric), flatten, reject(isNil) )(lines) as Array; }; const getDates = (timeSeries: Array): Array => { const toTimeTick = ({ timeTick }: TimeValue): string => timeTick; const toDate = (tick: string): Date => new Date(tick); return pipe(map(toTimeTick), map(toDate))(timeSeries); }; interface LineForMetricProps { lines: Array; metric_id: number; } const getLineForMetric = ({ lines, metric_id }: LineForMetricProps): Line | undefined => find(propEq(metric_id, 'metric_id'), lines); interface LinesForMetricsProps { lines: Array; metricIds: Array; } export const getLinesForMetrics = ({ lines, metricIds }: LinesForMetricsProps): Array => filter(({ metric_id }) => metricIds.includes(metric_id), lines); interface LinesTimeSeries { invert?: boolean | string | null; lines: Array; timeSeries: Array; } const getMetricValuesForLines = ({ lines, timeSeries }: LinesTimeSeries): Array => pipe( getUnits, map((unit) => getMetricValuesForUnit({ lines, timeSeries, unit })), flatten )(lines); const getStackedMetricValues = ({ lines, timeSeries }: LinesTimeSeries): Array => { const getTimeSeriesValuesForMetric = (metric_id: number): Array => map( (timeValue) => getValueForMetric(timeValue)(metric_id) || 0, timeSeries ); const metricsValues = pipe( // @ts-expect-error - suppressing pre-existing type mismatch map(prop('metric_id')) as (metric: unknown) => Array, // @ts-expect-error - suppressing pre-existing type mismatch map(getTimeSeriesValuesForMetric) as () => Array> )(lines as Array); if (isEmpty(metricsValues) || isNil(metricsValues)) { return []; } return metricsValues[0].map((_, index): number => reduce( (acc: number, metricValue: Array) => add(metricValue[index], acc), 0, metricsValues ) ); }; const getSortedStackedLines = (lines: Array): Array => pipe( reject(({ stackOrder }: Line): boolean => isNil(stackOrder)) as ( lines: Array ) => Array, sortBy(prop('stackOrder')) )(lines); const getInvertedStackedLines = (lines: Array): Array => pipe( // @ts-expect-error - suppressing pre-existing type mismatch filter(({ invert }: Line): boolean => invert) as ( lines: Array ) => Array, getSortedStackedLines )(lines); const getNotInvertedStackedLines = (lines: Array): Array => pipe( // @ts-expect-error - suppressing pre-existing type mismatch reject(({ invert }: Line): boolean => invert) as ( lines: Array ) => Array, getSortedStackedLines )(lines); interface HasStackedLines { lines: Array; unit: string; } const hasUnitStackedLines = ({ lines, unit }: HasStackedLines): boolean => // @ts-expect-error - suppressing pre-existing type mismatch pipe(getSortedStackedLines, any(propEq(unit, 'unit')))(lines); const getTimeSeriesForLines = ({ lines, timeSeries, invert }: LinesTimeSeries): Array => { const metrics = map(prop('metric_id'), lines); return map( ({ timeTick, ...metricsValue }): TimeValue => ({ ...reduce( (acc, metric_id): Omit => ({ ...acc, [metric_id]: invert && metricsValue[metric_id] && gt(metricsValue[metric_id], 0) ? negate(metricsValue[metric_id]) : metricsValue[metric_id] }), {} as Omit, metrics ), timeTick }), timeSeries ); }; interface GetYScaleProps { invert: string | null; scale?: 'linear' | 'logarithmic'; scaleLogarithmicBase?: number; unit: string; yScalesPerUnit: Record>; } const getYScale = ({ unit, invert, yScalesPerUnit, scale = 'linear', scaleLogarithmicBase }: GetYScaleProps): ScaleLinear => { const yScale = yScalesPerUnit[unit]; return invert ? getScaleType(scale)({ base: scaleLogarithmicBase, domain: yScale.domain().reverse(), range: yScale.range().reverse() }) : yScale; }; const getScaleType = ( scale: 'linear' | 'logarithmic' ): typeof Scale.scaleLinear | typeof Scale.scaleLog => equals(scale, 'logarithmic') ? Scale.scaleLog : Scale.scaleLinear; const hasOnlyZeroesHasValue = (graphValues: Array): boolean => graphValues.every((value) => equals(value, 0) || equals(value, null)); const getSanitizedValues = reject( ( value: | number | boolean | typeof Number.POSITIVE_INFINITY | typeof Number.NEGATIVE_INFINITY ) => equals(value, false) || equals(value, Number.POSITIVE_INFINITY) || equals(value, Number.NEGATIVE_INFINITY) ); interface GetScaleProps { graphValues: Array; height: number; stackedValues: Array; thresholds: Array; isCenteredZero?: boolean; scale?: 'linear' | 'logarithmic'; scaleLogarithmicBase?: number; isHorizontal: boolean; invert?: boolean | string | null; hasDisplayAsBar: boolean; hasLineFilled: boolean; hasStackedLines: boolean; min?: number; max?: number; } const getScale = ({ graphValues, height, stackedValues, thresholds, isCenteredZero, scale, scaleLogarithmicBase, isHorizontal, invert, hasDisplayAsBar, hasLineFilled, hasStackedLines, min, max // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: scale calculation requires multiple branching conditions }: GetScaleProps): ScaleLinear => { const isLogScale = equals(scale, 'logarithmic'); const sanitizedValuesForMinimum = min ? [min] : getSanitizedValues([ invert && graphValues.every(lt(0)) ? negate(getMax(graphValues)) : getMin(graphValues), !isEmpty(stackedValues) && !equals(stackedValues, [0]) && getMin([0, ...stackedValues]), Math.min(...thresholds) ]); const minValue = Math.min( ...(sanitizedValuesForMinimum.filter(isNotNil) as Array) ); const sanitizedValuesForMaximum = max ? [max] : getSanitizedValues([ getMax(graphValues), getMax(stackedValues), // @ts-expect-error - suppressing pre-existing type mismatch hasOnlyZeroesHasValue(graphValues) ? 1 : null, Math.max(...thresholds) ]); // @ts-expect-error - suppressing pre-existing type mismatch const maxValue = Math.max(...sanitizedValuesForMaximum.filter(isNotNil)); const minValueWithMargin = (hasDisplayAsBar && minValue > 0) || (hasLineFilled && Math.max(maxValue, minValue) > minValue && minValue > 0) || (hasStackedLines && minValue > maxValue) ? 0 : minValue - Math.abs(minValue) * 0.05; const maxValueWithMargin = (hasDisplayAsBar && maxValue < 0) || (hasLineFilled && Math.min(maxValue, minValue) < maxValue && maxValue < 0) || (hasStackedLines && minValue > maxValue) ? 0 : maxValue + Math.abs(maxValue) * 0.05; const scaleType = getScaleType(scale ?? 'linear'); const upperRangeValue = minValue === maxValue && maxValue === 0 ? height : 0; const range = [height, upperRangeValue]; if (isCenteredZero) { const greatestValue = Math.max( Math.abs(maxValueWithMargin), Math.abs(minValueWithMargin) ); return scaleType({ base: scaleLogarithmicBase || 2, clamp: Boolean(min || max), domain: [-greatestValue, greatestValue], range: isHorizontal ? range : range.reverse() }); } const domain = [isLogScale ? 0.001 : minValueWithMargin, maxValueWithMargin]; return scaleType({ base: scaleLogarithmicBase || 2, clamp: Boolean(min || max), domain, range: isHorizontal ? range : range.reverse() }); }; const getXScale = ({ dataTime, valueWidth }: Xscale): ScaleTime => { return Scale.scaleTime({ domain: [getMin(dataTime.map(getTime)), getMax(dataTime.map(getTime))], range: [0, valueWidth] }); }; export const getXScaleBand = ({ dataTime, valueWidth }: Xscale): ReturnType> => { return Scale.scaleBand({ domain: dataTime.map(getTime), padding: 0.2, range: [0, valueWidth] }); }; const getYScaleUnit = ({ dataLines, dataTimeSeries, valueGraphHeight, thresholds, thresholdUnit, isCenteredZero, scale, scaleLogarithmicBase, isHorizontal = true, unit, invert, min, max, isBarChart, boundariesUnit, isFilled }: AxeScale & { invert?: boolean | string | null; unit: string; max?: number; min?: number; boundariesUnit?: string; isBarChart?: boolean; isFilled?: boolean; }): ScaleLinear => { const [firstUnit] = getUnits(dataLines); const shouldApplyThresholds = equals(unit, thresholdUnit) || (!thresholdUnit && equals(firstUnit, unit)); const graphValues = getMetricValuesForUnit({ lines: dataLines, timeSeries: dataTimeSeries, unit }); const hasStackedLines = hasUnitStackedLines({ lines: dataLines, unit }); const stackedValues = hasStackedLines ? getStackedMetricValues({ lines: getSortedStackedLines(dataLines).filter( ({ unit: stackedUnit }) => equals(unit, stackedUnit) ), timeSeries: dataTimeSeries }) : []; return getScale({ graphValues, hasDisplayAsBar: isBarChart || dataLines.some( ({ displayAs, unit: lineUnit }) => equals(unit, lineUnit) && equals(displayAs, 'bar') ), hasLineFilled: isNil(isFilled) ? dataLines.some( ({ unit: lineUnit, filled }) => equals(unit, lineUnit) && filled ) : isFilled, hasStackedLines: dataLines.some( ({ unit: lineUnit, stackKey, stackOrder }) => equals(unit, lineUnit) && (stackKey || stackOrder) ), height: valueGraphHeight, invert, isCenteredZero, isHorizontal, max: boundaryToApplyToUnit({ boundariesUnit, boundary: max, unit }), min: boundaryToApplyToUnit({ boundariesUnit, boundary: min, unit }), scale, scaleLogarithmicBase, stackedValues, thresholds: shouldApplyThresholds ? thresholds : [] }); }; const boundaryToApplyToUnit = ({ boundary, boundariesUnit, unit }: { boundary?: number; boundariesUnit?: string; unit: string; }): number | undefined => { if (!boundariesUnit) { return boundary; } return equals(boundariesUnit, unit) ? boundary : undefined; }; const getYScalePerUnit = ({ dataLines, dataTimeSeries, valueGraphHeight, thresholds, thresholdUnit, isCenteredZero, scale, scaleLogarithmicBase, isHorizontal = true, isBarChart, min, max, boundariesUnit, isFilled }: AxeScale & { min?: number; max?: number; isBarChart?: boolean; boundariesUnit?: string; isFilled?: boolean; }): Record> => { const units = getUnits(dataLines); const scalePerUnit = units.reduce((acc, unit) => { return { ...acc, [unit]: getYScaleUnit({ boundariesUnit, dataLines, dataTimeSeries, invert: dataLines.some( ({ unit: lineUnit, invert }) => equals(lineUnit, unit) && invert ), isBarChart, isCenteredZero, isFilled, isHorizontal, max, min, scale, scaleLogarithmicBase, thresholds, thresholdUnit, unit, valueGraphHeight }) }; }, {}); return scalePerUnit; }; const formatTime = ({ value, unit }: { value: number; unit: string; }): string => { return `${numeral(value).format('0.[00]a')} ${unit}`; }; const registerMsUnitToNumeral = (): null => { try { numeral.register('format', 'milliseconds', { format: (value: number) => { return formatTime({ unit: 'ms', value }); }, regexps: { format: /(ms)/, unformat: /(ms)/ }, unformat: () => 0 }); return null; } catch (_) { return null; } }; registerMsUnitToNumeral(); const registerSecondsUnitToNumeral = (): null => { try { numeral.register('format', 'seconds', { format: (value: number) => { return formatTime({ unit: 's', value }); }, regexps: { format: /(s)/, unformat: /(s)/ }, unformat: () => 0 }); return null; } catch (_) { return null; } }; registerSecondsUnitToNumeral(); const getBase1024 = ({ unit, base }: { unit: string; base: number | string; }): boolean => { const base2Units = [ 'B', 'bytes', 'bytespersecond', 'B/s', 'B/sec', 'o', 'octets', 'b/s', 'b' ]; return base2Units.includes(unit) || Number(base) === 1024; }; const formatMetricValue = ({ value, unit, base = 1000 }: FormatMetricValueProps): string | null => { if (isNil(value)) { return null; } const base1024 = getBase1024({ base, unit }); const formatSuffix = cond([ [equals('ms'), always(' ms')], [equals('s'), always(' s')], [T, always(base1024 ? ' ib' : 'a')] ])(unit); const formattedMetricValue = numeral(Math.abs(value)) .format(`0.[00]${formatSuffix}`) .replace(/B/, unit); if (lt(value, 0)) { return `-${formattedMetricValue}`; } return formattedMetricValue; }; const formatMetricValueWithUnit = ({ value, unit, base = 1000, isRaw = false }: FormatMetricValueProps & { isRaw?: boolean }): string | null => { if (isNil(value)) { return null; } if (isRaw) { const unitText = equals('%', unit) ? unit : ` ${unit}`; return `${value}${unitText}`; } if (equals('%', unit)) { return `${numeral(value).format('0.[00]')}%`; } const formattedMetricValue = formatMetricValue({ base, unit, value }); return formattedMetricValue; }; const bisectDate = bisector(identity).center; const getTimeValue = ({ x, xScale, timeSeries, marginLeft = margin.left }: TimeValueProps): TimeValue | null => { if (isNil(x)) { return null; } const date = xScale.invert(x - marginLeft); const index = bisectDate(getDates(timeSeries), date); return timeSeries[index]; }; const getMetricWithLatestData = ( graphData: LineChartData ): Metric | undefined => { const metric = head(graphData.metrics) as Metric; const lastData = last(metric?.data.filter((v) => v) || []); return { ...metric, data: lastData ? [lastData] : [] }; }; interface FormatMetricNameProps { legend: string | null; name: string; } export const formatMetricName = ({ legend, name }: FormatMetricNameProps): string => { const legendName = legend || name; const metricName = includes('#', legendName) ? split('#')(legendName)[1] : legendName; return metricName; }; export const getStackedLinesTimeSeriesPerStackAndUnit = ({ stackedLines, timeSeries, invert }: { stackedLines: Array; timeSeries: Array; invert?: boolean; }): { stackedLinesTimeSeriesPerStackKeyAndUnit: Record< string, { lines: Array; timeSeries: Array } >; stackedKeys: Record; } => { const stackedKeys = stackedLines.reduce( (acc, { unit, stackKey }) => ({ ...acc, [`stacked-${unit || ''}-${stackKey ? stackKey : ''}`]: null }), {} ); const stackedKeysWithOnlyStackKey = Object.keys(stackedKeys).filter( (stackKey: string) => stackKey.split('-')[2] ); const stackedKeysWithOnlyUnit = Object.keys(stackedKeys).filter( (stackKey: string) => !stackKey.split('-')[2] ); const stackedLinesTimeSeriesPerStackKey = stackedKeysWithOnlyStackKey.reduce( (acc, stackedKey: string) => { const [, stackUnit, stackKey] = stackedKey.split('-'); const relatedLines = stackedLines.filter(({ unit, stackKey: key }) => { return stackUnit === (unit || '') && stackKey === key; }); return { ...acc, [stackedKey]: { lines: relatedLines, timeSeries: getTimeSeriesForLines({ invert, lines: relatedLines, timeSeries }) } }; }, {} ); const affectedLinesPerStackKey = flatten( // @ts-expect-error - suppressing pre-existing type mismatch pluck('lines', Object.values(stackedLinesTimeSeriesPerStackKey)) ); const stackedLinesTimeSeriesPerUnit = stackedKeysWithOnlyUnit.reduce( (acc, stackedKey: string) => { const [, stackUnit] = stackedKey.split('-'); const relatedLines = stackedLines.filter( (line) => !affectedLinesPerStackKey.some( (affectedLine) => line.metric_id === affectedLine.metric_id ) && stackUnit === (line.unit || '') ); return { ...acc, [stackedKey]: { lines: relatedLines, timeSeries: getTimeSeriesForLines({ invert, lines: relatedLines, timeSeries }) } }; }, {} ); return { stackedKeys, stackedLinesTimeSeriesPerStackKeyAndUnit: { ...stackedLinesTimeSeriesPerStackKey, ...stackedLinesTimeSeriesPerUnit } }; }; export { getTimeSeries, getLineData, getMin, getMax, getTime, getMetrics, getValueForMetric, getMetricValuesForUnit, getUnits, getDates, getLineForMetric, getMetricValuesForLines, getSortedStackedLines, getTimeSeriesForLines, getStackedMetricValues, getInvertedStackedLines, getNotInvertedStackedLines, hasUnitStackedLines, getYScale, getScale, getXScale, formatMetricValue, getTimeValue, bisectDate, getMetricWithLatestData, formatMetricValueWithUnit, getYScaleUnit, getYScalePerUnit };