import {RefObject, useEffect, useRef, useState} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {LineSeriesOption} from 'echarts'; import * as echarts from 'echarts/core'; import { TooltipFormatterCallback, TopLevelFormatterParams, XAXisOption, YAXisOption, } from 'echarts/types/dist/shared'; import max from 'lodash/max'; import min from 'lodash/min'; import moment from 'moment'; import {AreaChart, AreaChartProps} from 'sentry/components/charts/areaChart'; import {BarChart} from 'sentry/components/charts/barChart'; import BaseChart from 'sentry/components/charts/baseChart'; import ChartZoom from 'sentry/components/charts/chartZoom'; import { FormatterOptions, getFormatter, } from 'sentry/components/charts/components/tooltip'; import ErrorPanel from 'sentry/components/charts/errorPanel'; import LineSeries from 'sentry/components/charts/series/lineSeries'; import ScatterSeries from 'sentry/components/charts/series/scatterSeries'; import TransitionChart from 'sentry/components/charts/transitionChart'; import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {IconWarning} from 'sentry/icons'; import {DateString} from 'sentry/types'; import { EChartClickHandler, EChartHighlightHandler, EChartMouseOutHandler, EChartMouseOverHandler, ReactEchartsRef, Series, } from 'sentry/types/echarts'; import { axisLabelFormatter, getDurationUnit, tooltipFormatter, } from 'sentry/utils/discover/charts'; import {aggregateOutputType, AggregationOutputType} from 'sentry/utils/discover/fields'; import {DAY, HOUR} from 'sentry/utils/formatters'; import usePageFilters from 'sentry/utils/usePageFilters'; import useRouter from 'sentry/utils/useRouter'; import {SpanMetricsFields} from 'sentry/views/starfish/types'; import {getDateFilters} from 'sentry/views/starfish/utils/getDateFilters'; const STARFISH_CHART_GROUP = 'starfish_chart_group'; export const STARFISH_FIELDS: Record = { [SpanMetricsFields.SPAN_DURATION]: { outputType: 'duration', }, [SpanMetricsFields.SPAN_SELF_TIME]: { outputType: 'duration', }, // local is only used with `time_spent_percentage` function local: { outputType: 'duration', }, }; type Props = { data: Series[]; end: DateString; loading: boolean; start: DateString; statsPeriod: string | null | undefined; utc: boolean; aggregateOutputFormat?: AggregationOutputType; chartColors?: string[]; chartGroup?: string; dataMax?: number; definedAxisTicks?: number; disableXAxis?: boolean; durationUnit?: number; errored?: boolean; forwardedRef?: RefObject; grid?: AreaChartProps['grid']; height?: number; hideYAxisSplitLine?: boolean; isBarChart?: boolean; isLineChart?: boolean; log?: boolean; onClick?: EChartClickHandler; onHighlight?: EChartHighlightHandler; onMouseOut?: EChartMouseOutHandler; onMouseOver?: EChartMouseOverHandler; previousData?: Series[]; scatterPlot?: Series[]; showLegend?: boolean; stacked?: boolean; throughput?: {count: number; interval: string}[]; tooltipFormatterOptions?: FormatterOptions; }; function computeMax(data: Series[]) { const valuesDict = data.map(value => value.data.map(point => point.value)); return max(valuesDict.map(max)) as number; } // adapted from https://stackoverflow.com/questions/11397239/rounding-up-for-a-graph-maximum export function computeAxisMax(data: Series[], stacked?: boolean) { // assumes min is 0 let maxValue = 0; if (data.length > 1 && stacked) { for (let i = 0; i < data.length; i++) { maxValue += max(data[i].data.map(point => point.value)) as number; } } else { maxValue = computeMax(data); } if (maxValue <= 1) { return 1; } const power = Math.log10(maxValue); const magnitude = min([max([10 ** (power - Math.floor(power)), 0]), 10]) as number; let scale: number; if (magnitude <= 2.5) { scale = 0.2; } else if (magnitude <= 5) { scale = 0.5; } else if (magnitude <= 7.5) { scale = 1.0; } else { scale = 2.0; } const step = 10 ** Math.floor(power) * scale; return Math.ceil(Math.ceil(maxValue / step) * step); } function Chart({ data, dataMax, previousData, statsPeriod, start, end, utc, loading, height, grid, disableXAxis, definedAxisTicks, durationUnit, chartColors, isBarChart, isLineChart, stacked, log, hideYAxisSplitLine, showLegend, scatterPlot, throughput, aggregateOutputFormat, onClick, onMouseOver, onMouseOut, onHighlight, forwardedRef, chartGroup, tooltipFormatterOptions = {}, errored, }: Props) { const router = useRouter(); const theme = useTheme(); const pageFilter = usePageFilters(); const {startTime, endTime} = getDateFilters(pageFilter.selection); const defaultRef = useRef(null); const chartRef = forwardedRef || defaultRef; const echartsInstance = chartRef?.current?.getEchartsInstance(); if (echartsInstance && !echartsInstance.group) { echartsInstance.group = chartGroup ?? STARFISH_CHART_GROUP; } const colors = chartColors ?? theme.charts.getColorPalette(4); const durationOnly = aggregateOutputFormat === 'duration' || data.every(value => aggregateOutputType(value.seriesName) === 'duration'); const percentOnly = aggregateOutputFormat === 'percentage' || data.every(value => aggregateOutputType(value.seriesName) === 'percentage'); if (!dataMax) { dataMax = durationOnly ? computeAxisMax( [...data, ...(scatterPlot?.[0]?.data?.length ? scatterPlot : [])], stacked ) : percentOnly ? computeMax([...data, ...(scatterPlot?.[0]?.data?.length ? scatterPlot : [])]) : undefined; // Fix an issue where max == 1 for duration charts would look funky cause we round if (dataMax === 1 && durationOnly) { dataMax += 1; } } let transformedThroughput: LineSeriesOption[] | undefined = undefined; const additionalAxis: YAXisOption[] = []; if (throughput && throughput.length > 1) { transformedThroughput = [ LineSeries({ name: 'Throughput', data: throughput.map(({interval, count}) => [interval, count]), yAxisIndex: 1, lineStyle: {type: 'dashed', width: 1, opacity: 0.5}, animation: false, animationThreshold: 1, animationDuration: 0, }), ]; additionalAxis.push({ minInterval: durationUnit ?? getDurationUnit(data), splitNumber: definedAxisTicks, max: dataMax, type: 'value', axisLabel: { color: theme.chartLabel, formatter(value: number) { return axisLabelFormatter(value, 'number', true); }, }, splitLine: hideYAxisSplitLine ? {show: false} : undefined, }); } const yAxes = [ { minInterval: durationUnit ?? getDurationUnit(data), splitNumber: definedAxisTicks, max: dataMax, type: log ? 'log' : 'value', axisLabel: { color: theme.chartLabel, formatter(value: number) { return axisLabelFormatter( value, aggregateOutputFormat ?? aggregateOutputType(data[0].seriesName), undefined, durationUnit ?? getDurationUnit(data) ); }, }, splitLine: hideYAxisSplitLine ? {show: false} : undefined, }, ...additionalAxis, ]; const formatter: TooltipFormatterCallback = ( params, asyncTicket ) => { // Kinda jank. Get hovered dom elements and check if any of them are the chart const hoveredEchartElement = Array.from(document.querySelectorAll(':hover')).find( element => { return element.classList.contains('echarts-for-react'); } ); if (hoveredEchartElement === chartRef?.current?.ele) { // Return undefined to use default formatter return getFormatter({ isGroupedByDate: true, showTimeInTooltip: true, utc, valueFormatter: (value, seriesName) => { return tooltipFormatter( value, aggregateOutputFormat ?? aggregateOutputType(seriesName) ); }, ...tooltipFormatterOptions, })(params, asyncTicket); } // Return empty string, ie no tooltip return ''; }; const areaChartProps = { seriesOptions: { showSymbol: false, }, grid, yAxes, utc, legend: showLegend ? { top: 0, right: 10, } : undefined, isGroupedByDate: true, showTimeInTooltip: true, tooltip: { formatter, trigger: 'axis', axisPointer: { type: 'cross', label: {show: false}, }, valueFormatter: (value, seriesName) => { return tooltipFormatter( value, aggregateOutputFormat ?? aggregateOutputType(data && data.length ? data[0].seriesName : seriesName) ); }, nameFormatter(value: string) { return value === 'epm()' ? 'tpm()' : value; }, }, } as Omit; const series: Series[] = data.map((values, _) => ({ ...values, yAxisIndex: 0, xAxisIndex: 0, })); const xAxisInterval = getXAxisInterval(startTime, endTime); const xAxis: XAXisOption = disableXAxis ? { show: false, axisLabel: {show: true, margin: 0}, axisLine: {show: false}, } : { type: 'time', maxInterval: xAxisInterval, axisLabel: { formatter: function (value: number) { if (endTime.diff(startTime, 'days') > 30) { return moment(value).format('MMMM DD'); } if (startTime.isSame(endTime, 'day')) { return moment(value).format('HH:mm'); } return moment(value).format('MMMM DD HH:mm'); }, }, }; function getChart() { if (errored) { return ( ); } return ( {zoomRenderProps => { if (isLineChart) { return ( LineSeries({ ...options, name: seriesName, data: seriesData?.map(({value, name}) => [name, value]), animation: false, animationThreshold: 1, animationDuration: 0, }) ), ...(scatterPlot ?? []).map( ({seriesName, data: seriesData, ...options}) => ScatterSeries({ ...options, name: seriesName, data: seriesData?.map(({value, name}) => [name, value]), animation: false, }) ), ]} /> ); } if (isBarChart) { return ( ); } return ( ); }} ); } return ( {getChart()} ); } export default Chart; export function useSynchronizeCharts(deps: boolean[] = []) { const [synchronized, setSynchronized] = useState(false); useEffect(() => { if (deps.every(Boolean)) { echarts.connect(STARFISH_CHART_GROUP); setSynchronized(true); } }, [deps, synchronized]); } const getXAxisInterval = (startTime: moment.Moment, endTime: moment.Moment) => { const dateRange = endTime.diff(startTime); if (dateRange >= 30 * DAY) { return 7 * DAY; } if (dateRange >= 3 * DAY) { return DAY; } if (dateRange >= 1 * DAY) { return 12 * HOUR; } return HOUR; }; const StyledTransparentLoadingMask = styled(props => ( ))` display: flex; justify-content: center; align-items: center; `; function LoadingScreen({loading}: {loading: boolean}) { if (!loading) { return null; } return ( ); }