123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651 |
- import type {RefObject} from 'react';
- import {createContext, useContext, useEffect, useMemo, useReducer, useRef} from 'react';
- import {useTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import type {LineSeriesOption} from 'echarts';
- import * as echarts from 'echarts/core';
- import type {
- MarkLineOption,
- TooltipFormatterCallback,
- TopLevelFormatterParams,
- XAXisOption,
- YAXisOption,
- } from 'echarts/types/dist/shared';
- import max from 'lodash/max';
- import min from 'lodash/min';
- import type {AreaChartProps} from 'sentry/components/charts/areaChart';
- import {AreaChart} from 'sentry/components/charts/areaChart';
- import {BarChart} from 'sentry/components/charts/barChart';
- import BaseChart from 'sentry/components/charts/baseChart';
- import ChartZoom, {type ZoomRenderProps} from 'sentry/components/charts/chartZoom';
- import type {FormatterOptions} from 'sentry/components/charts/components/tooltip';
- import {getFormatter} from 'sentry/components/charts/components/tooltip';
- import ErrorPanel from 'sentry/components/charts/errorPanel';
- import ReleaseSeries from 'sentry/components/charts/releaseSeries';
- 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 {isChartHovered} from 'sentry/components/charts/utils';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import {
- createIngestionSeries,
- getIngestionDelayBucketCount,
- } from 'sentry/components/metrics/chart/chart';
- import type {Series as MetricSeries} from 'sentry/components/metrics/chart/types';
- import {IconWarning} from 'sentry/icons';
- import type {
- EChartClickHandler,
- EChartDataZoomHandler,
- EChartEventHandler,
- EChartHighlightHandler,
- EChartMouseOutHandler,
- EChartMouseOverHandler,
- ReactEchartsRef,
- Series,
- } from 'sentry/types/echarts';
- import {
- axisLabelFormatter,
- getDurationUnit,
- tooltipFormatter,
- } from 'sentry/utils/discover/charts';
- import type {AggregationOutputType, RateUnit} from 'sentry/utils/discover/fields';
- import {aggregateOutputType} from 'sentry/utils/discover/fields';
- import {MetricDisplayType} from 'sentry/utils/metrics/types';
- import usePageFilters from 'sentry/utils/usePageFilters';
- const STARFISH_CHART_GROUP = 'starfish_chart_group';
- export enum ChartType {
- BAR = 0,
- LINE = 1,
- AREA = 2,
- }
- export interface ChartRenderingProps {
- height: number;
- isFullscreen: boolean;
- }
- export const ChartRenderingContext = createContext<ChartRenderingProps | null>(null);
- type Props = {
- data: Series[];
- loading: boolean;
- type: ChartType;
- aggregateOutputFormat?: AggregationOutputType;
- chartColors?: string[];
- chartGroup?: string;
- dataMax?: number;
- definedAxisTicks?: number;
- disableXAxis?: boolean;
- durationUnit?: number;
- error?: Error | null;
- forwardedRef?: RefObject<ReactEchartsRef>;
- grid?: AreaChartProps['grid'];
- height?: number;
- hideYAxis?: boolean;
- hideYAxisSplitLine?: boolean;
- legendFormatter?: (name: string) => string;
- log?: boolean;
- markLine?: MarkLineOption;
- onClick?: EChartClickHandler;
- onDataZoom?: EChartDataZoomHandler;
- onHighlight?: EChartHighlightHandler;
- onLegendSelectChanged?: EChartEventHandler<{
- name: string;
- selected: Record<string, boolean>;
- type: 'legendselectchanged';
- }>;
- onMouseOut?: EChartMouseOutHandler;
- onMouseOver?: EChartMouseOverHandler;
- previousData?: Series[];
- rateUnit?: RateUnit;
- scatterPlot?: Series[];
- showLegend?: boolean;
- stacked?: boolean;
- throughput?: {count: number; interval: string}[];
- tooltipFormatterOptions?: FormatterOptions;
- };
- function Chart({
- data,
- dataMax,
- previousData,
- loading,
- height: chartHeight,
- grid,
- disableXAxis,
- definedAxisTicks,
- durationUnit,
- rateUnit,
- chartColors,
- type,
- stacked,
- log,
- hideYAxisSplitLine,
- showLegend,
- scatterPlot,
- throughput,
- aggregateOutputFormat,
- onClick,
- onMouseOver,
- onMouseOut,
- onHighlight,
- forwardedRef,
- chartGroup,
- tooltipFormatterOptions = {},
- error,
- onLegendSelectChanged,
- onDataZoom,
- /**
- * Setting a default formatter for some reason causes `>` to
- * render correctly instead of rendering as `>` in the legend.
- */
- legendFormatter = name => name,
- }: Props) {
- const theme = useTheme();
- const pageFilters = usePageFilters();
- const {start, end, period, utc} = pageFilters.selection.datetime;
- const {projects, environments} = pageFilters.selection;
- const renderingContext = useContext(ChartRenderingContext);
- const height = renderingContext?.height ?? chartHeight;
- const isLegendVisible = renderingContext?.isFullscreen ?? showLegend;
- 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,
- });
- }
- let series: Series[] = data.map((values, index) => ({
- ...values,
- yAxisIndex: 0,
- xAxisIndex: 0,
- id: values.seriesName,
- color: colors[index],
- }));
- let incompleteSeries: Series[] = [];
- const bucketSize =
- new Date(series[0]?.data[1]?.name).getTime() -
- new Date(series[0]?.data[0]?.name).getTime();
- const lastBucketTimestamp = new Date(
- series[0]?.data?.[series[0]?.data?.length - 1]?.name
- ).getTime();
- const ingestionBuckets = useMemo(() => {
- if (isNaN(bucketSize) || isNaN(lastBucketTimestamp)) {
- return 1;
- }
- return getIngestionDelayBucketCount(bucketSize, lastBucketTimestamp);
- }, [bucketSize, lastBucketTimestamp]);
- // TODO: Support area and bar charts
- if (type === ChartType.LINE || type === ChartType.AREA) {
- const metricChartType =
- type === ChartType.AREA ? MetricDisplayType.AREA : MetricDisplayType.LINE;
- const seriesToShow = series.map(serie =>
- createIngestionSeries(serie as MetricSeries, ingestionBuckets, metricChartType)
- );
- [series, incompleteSeries] = seriesToShow.reduce(
- (acc, serie, index) => {
- const [trimmed, incomplete] = acc;
- const {markLine: _, ...incompleteSerie} = serie[1] ?? {};
- return [
- [...trimmed, {...serie[0], color: colors[index]}],
- [
- ...incomplete,
- ...(Object.keys(incompleteSerie).length > 0 ? [incompleteSerie] : []),
- ],
- ];
- },
- [[], []] as [MetricSeries[], MetricSeries[]]
- );
- }
- 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 xAxis: XAXisOption = disableXAxis
- ? {
- show: false,
- axisLabel: {show: true, margin: 0},
- axisLine: {show: false},
- }
- : {};
- const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
- params,
- asyncTicket
- ) => {
- // Only show the tooltip if the current chart is hovered
- // as chart groups trigger the tooltip for all charts in the group when one is hoverered
- if (!isChartHovered(chartRef?.current)) {
- return '';
- }
- let deDupedParams = params;
- if (Array.isArray(params)) {
- const uniqueSeries = new Set<string>();
- deDupedParams = params.filter(param => {
- // Filter null values from tooltip
- if (param.value[1] === null) {
- return false;
- }
- if (uniqueSeries.has(param.seriesName)) {
- return false;
- }
- uniqueSeries.add(param.seriesName);
- return true;
- });
- }
- // Return undefined to use default formatter
- return getFormatter({
- isGroupedByDate: true,
- showTimeInTooltip: true,
- utc: utc ?? false,
- valueFormatter: (value, seriesName) => {
- return tooltipFormatter(
- value,
- aggregateOutputFormat ?? aggregateOutputType(seriesName)
- );
- },
- ...tooltipFormatterOptions,
- })(deDupedParams, asyncTicket);
- };
- const areaChartProps = {
- seriesOptions: {
- showSymbol: false,
- },
- grid,
- yAxes,
- utc,
- legend: isLegendVisible ? {top: 0, right: 10, 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?.length ? data[0].seriesName : seriesName)
- );
- },
- nameFormatter(value: string) {
- return value === 'epm()' ? 'tpm()' : value;
- },
- },
- } as Omit<AreaChartProps, 'series'>;
- function getChartWithSeries(
- zoomRenderProps: ZoomRenderProps,
- releaseSeries?: Series[]
- ) {
- if (error) {
- return (
- <ErrorPanel height={`${height}px`} data-test-id="chart-error-panel">
- <IconWarning color="gray300" size="lg" />
- </ErrorPanel>
- );
- }
- if (type === ChartType.LINE) {
- return (
- <BaseChart
- {...zoomRenderProps}
- ref={chartRef}
- height={height}
- previousPeriod={previousData}
- additionalSeries={transformedThroughput}
- xAxis={xAxis}
- yAxes={areaChartProps.yAxes}
- tooltip={areaChartProps.tooltip}
- colors={colors}
- grid={grid}
- legend={
- isLegendVisible ? {top: 0, right: 10, formatter: legendFormatter} : undefined
- }
- onClick={onClick}
- onMouseOut={onMouseOut}
- onMouseOver={onMouseOver}
- onHighlight={onHighlight}
- series={[
- ...series.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,
- })
- ),
- ...incompleteSeries.map(({seriesName, data: seriesData, ...options}) =>
- LineSeries({
- ...options,
- name: seriesName,
- data: seriesData?.map(({value, name}) => [name, value]),
- animation: false,
- animationThreshold: 1,
- animationDuration: 0,
- })
- ),
- ...(releaseSeries ?? []).map(({seriesName, data: seriesData, ...options}) =>
- LineSeries({
- ...options,
- name: seriesName,
- data: seriesData?.map(({value, name}) => [name, value]),
- animation: false,
- animationThreshold: 1,
- animationDuration: 0,
- })
- ),
- ]}
- />
- );
- }
- if (type === ChartType.BAR) {
- return (
- <BarChart
- height={height}
- series={series}
- xAxis={{
- type: 'category',
- axisTick: {show: true},
- truncate: Infinity, // Show axis labels
- axisLabel: {
- interval: 0, // Show _all_ axis labels
- },
- }}
- yAxis={{
- minInterval: durationUnit ?? getDurationUnit(data),
- splitNumber: definedAxisTicks,
- max: dataMax,
- axisLabel: {
- color: theme.chartLabel,
- formatter(value: number) {
- return axisLabelFormatter(
- value,
- aggregateOutputFormat ?? aggregateOutputType(data[0].seriesName),
- undefined,
- durationUnit ?? getDurationUnit(data),
- rateUnit
- );
- },
- },
- }}
- tooltip={{
- valueFormatter: (value, seriesName) => {
- return tooltipFormatter(
- value,
- aggregateOutputFormat ??
- aggregateOutputType(data?.length ? data[0].seriesName : seriesName)
- );
- },
- }}
- colors={colors}
- grid={grid}
- legend={
- isLegendVisible ? {top: 0, right: 10, formatter: legendFormatter} : undefined
- }
- onClick={onClick}
- />
- );
- }
- return (
- <AreaChart
- forwardedRef={chartRef}
- height={height}
- {...zoomRenderProps}
- series={[...series, ...incompleteSeries, ...(releaseSeries ?? [])]}
- previousPeriod={previousData}
- additionalSeries={transformedThroughput}
- xAxis={xAxis}
- stacked={stacked}
- colors={colors}
- onClick={onClick}
- {...areaChartProps}
- onLegendSelectChanged={onLegendSelectChanged}
- />
- );
- }
- function getChart() {
- if (error) {
- return (
- <ErrorPanel height={`${height}px`} data-test-id="chart-error-panel">
- <IconWarning color="gray300" size="lg" />
- </ErrorPanel>
- );
- }
- // add top-padding to the chart in full screen so that the legend
- // and graph do not overlap
- if (renderingContext?.isFullscreen) {
- grid = {...grid, top: '20px'};
- }
- // overlay additional series data such as releases and issues on top of the original insights chart
- return (
- <ChartZoom
- saveOnZoom
- period={period}
- start={start}
- end={end}
- utc={utc}
- onDataZoom={onDataZoom}
- >
- {zoomRenderProps =>
- renderingContext?.isFullscreen ? (
- <ReleaseSeries
- start={start}
- end={end}
- queryExtra={undefined}
- period={period}
- utc={utc}
- projects={projects}
- environments={environments}
- >
- {({releaseSeries}) => {
- return getChartWithSeries(zoomRenderProps, releaseSeries);
- }}
- </ReleaseSeries>
- ) : (
- getChartWithSeries(zoomRenderProps)
- )
- }
- </ChartZoom>
- );
- }
- return (
- <TransitionChart
- loading={loading}
- reloading={loading}
- height={height ? `${height}px` : undefined}
- >
- <LoadingScreen loading={loading} />
- {getChart()}
- </TransitionChart>
- );
- }
- export default Chart;
- 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);
- }
- export function useSynchronizeCharts(
- charts: number,
- ready: boolean,
- group: string = STARFISH_CHART_GROUP
- ) {
- // Tries to connect all the charts under the same group so the cursor is shared.
- const [, forceUpdate] = useReducer(x => x + 1, 0);
- useEffect(() => {
- if (charts && ready) {
- echarts?.connect?.(group);
- // need to force a re-render otherwise only the currently visible charts
- // in the group will end up connected
- forceUpdate();
- }
- }, [
- charts, // this re-connects when new charts are added/removed
- ready, // this waits until the chart data has loaded before attempting to connect
- group,
- ]);
- }
- const StyledTransparentLoadingMask = styled(props => (
- <TransparentLoadingMask {...props} maskBackgroundColor="transparent" />
- ))`
- display: flex;
- justify-content: center;
- align-items: center;
- `;
- export function LoadingScreen({loading}: {loading: boolean}) {
- if (!loading) {
- return null;
- }
- return (
- <StyledTransparentLoadingMask visible={loading}>
- <LoadingIndicator mini />
- </StyledTransparentLoadingMask>
- );
- }
|