123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- 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 {
- EChartClickHandler,
- EChartDataZoomHandler,
- EChartEventHandler,
- EChartHighlightHandler,
- EChartMouseOutHandler,
- EChartMouseOverHandler,
- ReactEchartsRef,
- Series,
- } from 'sentry/types/echarts';
- import {
- axisLabelFormatter,
- getDurationUnit,
- tooltipFormatter,
- } from 'sentry/utils/discover/charts';
- import {
- aggregateOutputType,
- AggregationOutputType,
- RateUnits,
- } from 'sentry/utils/discover/fields';
- import usePageFilters from 'sentry/utils/usePageFilters';
- import useRouter from 'sentry/utils/useRouter';
- import {SpanMetricsField} from 'sentry/views/starfish/types';
- const STARFISH_CHART_GROUP = 'starfish_chart_group';
- export const STARFISH_FIELDS: Record<string, {outputType: AggregationOutputType}> = {
- [SpanMetricsField.SPAN_DURATION]: {
- outputType: 'duration',
- },
- [SpanMetricsField.SPAN_SELF_TIME]: {
- outputType: 'duration',
- },
- // local is only used with `time_spent_percentage` function
- local: {
- outputType: 'duration',
- },
- };
- type Props = {
- data: Series[];
- loading: boolean;
- utc: boolean;
- aggregateOutputFormat?: AggregationOutputType;
- chartColors?: string[];
- chartGroup?: string;
- dataMax?: number;
- definedAxisTicks?: number;
- disableXAxis?: boolean;
- durationUnit?: number;
- errored?: boolean;
- forwardedRef?: RefObject<ReactEchartsRef>;
- grid?: AreaChartProps['grid'];
- height?: number;
- hideYAxisSplitLine?: boolean;
- isBarChart?: boolean;
- isLineChart?: boolean;
- legendFormatter?: (name: string) => string;
- log?: boolean;
- onClick?: EChartClickHandler;
- onDataZoom?: EChartDataZoomHandler;
- onHighlight?: EChartHighlightHandler;
- onLegendSelectChanged?: EChartEventHandler<{
- name: string;
- selected: Record<string, boolean>;
- type: 'legendselectchanged';
- }>;
- onMouseOut?: EChartMouseOutHandler;
- onMouseOver?: EChartMouseOverHandler;
- previousData?: Series[];
- rateUnit?: RateUnits;
- 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,
- utc,
- loading,
- height,
- grid,
- disableXAxis,
- definedAxisTicks,
- durationUnit,
- rateUnit,
- chartColors,
- isBarChart,
- isLineChart,
- stacked,
- log,
- hideYAxisSplitLine,
- showLegend,
- scatterPlot,
- throughput,
- aggregateOutputFormat,
- onClick,
- onMouseOver,
- onMouseOut,
- onHighlight,
- forwardedRef,
- chartGroup,
- tooltipFormatterOptions = {},
- errored,
- onLegendSelectChanged,
- onDataZoom,
- legendFormatter,
- }: Props) {
- const router = useRouter();
- const theme = useTheme();
- const pageFilters = usePageFilters();
- const {start, end, period} = pageFilters.selection.datetime;
- const defaultRef = useRef<ReactEchartsRef>(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),
- rateUnit
- );
- },
- },
- splitLine: hideYAxisSplitLine ? {show: false} : undefined,
- },
- ...additionalAxis,
- ];
- const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
- 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,
- ...(legendFormatter ? {formatter: legendFormatter} : {}),
- }
- : 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<AreaChartProps, 'series'>;
- const series: Series[] = data.map((values, _) => ({
- ...values,
- yAxisIndex: 0,
- xAxisIndex: 0,
- }));
- // Trims off the last data point because it's incomplete
- const trimmedSeries =
- period && !start && !end
- ? series.map(serie => {
- return {
- ...serie,
- data: serie.data.slice(0, -1),
- };
- })
- : series;
- const xAxis: XAXisOption = disableXAxis
- ? {
- show: false,
- axisLabel: {show: true, margin: 0},
- axisLine: {show: false},
- }
- : {
- min: moment(trimmedSeries[0]?.data[0]?.name).unix() * 1000,
- max:
- moment(trimmedSeries[0]?.data[trimmedSeries[0].data.length - 1]?.name).unix() *
- 1000,
- };
- function getChart() {
- if (errored) {
- return (
- <ErrorPanel>
- <IconWarning color="gray300" size="lg" />
- </ErrorPanel>
- );
- }
- return (
- <ChartZoom
- router={router}
- saveOnZoom
- period={period}
- start={start}
- end={end}
- utc={utc}
- onDataZoom={onDataZoom}
- >
- {zoomRenderProps => {
- if (isLineChart) {
- return (
- <BaseChart
- {...zoomRenderProps}
- ref={chartRef}
- height={height}
- previousPeriod={previousData}
- additionalSeries={transformedThroughput}
- xAxis={xAxis}
- yAxes={areaChartProps.yAxes}
- tooltip={areaChartProps.tooltip}
- colors={colors}
- grid={grid}
- legend={showLegend ? {top: 0, right: 10} : undefined}
- onClick={onClick}
- onMouseOut={onMouseOut}
- onMouseOver={onMouseOver}
- onHighlight={onHighlight}
- series={[
- ...trimmedSeries.map(({seriesName, data: seriesData, ...options}) =>
- 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 (
- <BarChart
- height={height}
- series={trimmedSeries}
- xAxis={xAxis}
- additionalSeries={transformedThroughput}
- yAxes={areaChartProps.yAxes}
- tooltip={areaChartProps.tooltip}
- colors={colors}
- grid={grid}
- legend={showLegend ? {top: 0, right: 10} : undefined}
- onClick={onClick}
- />
- );
- }
- return (
- <AreaChart
- forwardedRef={chartRef}
- height={height}
- {...zoomRenderProps}
- series={trimmedSeries}
- previousPeriod={previousData}
- additionalSeries={transformedThroughput}
- xAxis={xAxis}
- stacked={stacked}
- onClick={onClick}
- {...areaChartProps}
- onLegendSelectChanged={onLegendSelectChanged}
- />
- );
- }}
- </ChartZoom>
- );
- }
- return (
- <TransitionChart
- loading={loading}
- reloading={loading}
- height={height ? `${height}px` : undefined}
- >
- <LoadingScreen loading={loading} />
- {getChart()}
- </TransitionChart>
- );
- }
- export default Chart;
- export function useSynchronizeCharts(deps: boolean[] = []) {
- const [synchronized, setSynchronized] = useState<boolean>(false);
- useEffect(() => {
- if (deps.every(Boolean)) {
- echarts.connect(STARFISH_CHART_GROUP);
- setSynchronized(true);
- }
- }, [deps, synchronized]);
- }
- const StyledTransparentLoadingMask = styled(props => (
- <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
- ))`
- display: flex;
- justify-content: center;
- align-items: center;
- `;
- function LoadingScreen({loading}: {loading: boolean}) {
- if (!loading) {
- return null;
- }
- return (
- <StyledTransparentLoadingMask visible={loading}>
- <LoadingIndicator mini />
- </StyledTransparentLoadingMask>
- );
- }
|