lineChartWidgetVisualization.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import {useRef} from 'react';
  2. import {useNavigate} from 'react-router-dom';
  3. import {useTheme} from '@emotion/react';
  4. import type {
  5. TooltipFormatterCallback,
  6. TopLevelFormatterParams,
  7. } from 'echarts/types/dist/shared';
  8. import type EChartsReactCore from 'echarts-for-react/lib/core';
  9. import BaseChart from 'sentry/components/charts/baseChart';
  10. import {getFormatter} from 'sentry/components/charts/components/tooltip';
  11. import LineSeries from 'sentry/components/charts/series/lineSeries';
  12. import {useChartZoom} from 'sentry/components/charts/useChartZoom';
  13. import {isChartHovered} from 'sentry/components/charts/utils';
  14. import type {Series} from 'sentry/types/echarts';
  15. import {defined} from 'sentry/utils';
  16. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  17. import useOrganization from 'sentry/utils/useOrganization';
  18. import usePageFilters from 'sentry/utils/usePageFilters';
  19. import {useWidgetSyncContext} from '../../contexts/widgetSyncContext';
  20. import {ReleaseSeries} from '../common/releaseSeries';
  21. import type {Meta, Release, TimeseriesData} from '../common/types';
  22. import {formatChartValue} from './formatChartValue';
  23. import {splitSeriesIntoCompleteAndIncomplete} from './splitSeriesIntoCompleteAndIncomplete';
  24. export interface LineChartWidgetVisualizationProps {
  25. timeseries: TimeseriesData[];
  26. dataCompletenessDelay?: number;
  27. meta?: Meta;
  28. releases?: Release[];
  29. }
  30. export function LineChartWidgetVisualization(props: LineChartWidgetVisualizationProps) {
  31. const chartRef = useRef<EChartsReactCore | null>(null);
  32. const {register: registerWithWidgetSyncContext} = useWidgetSyncContext();
  33. const pageFilters = usePageFilters();
  34. const {start, end, period, utc} = pageFilters.selection.datetime;
  35. const {meta} = props;
  36. const dataCompletenessDelay = props.dataCompletenessDelay ?? 0;
  37. const theme = useTheme();
  38. const organization = useOrganization();
  39. const navigate = useNavigate();
  40. let releaseSeries: Series | undefined = undefined;
  41. if (props.releases) {
  42. const onClick = (release: Release) => {
  43. navigate(
  44. normalizeUrl({
  45. pathname: `/organizations/${
  46. organization.slug
  47. }/releases/${encodeURIComponent(release.version)}/`,
  48. })
  49. );
  50. };
  51. releaseSeries = ReleaseSeries(theme, props.releases, onClick, utc ?? false);
  52. }
  53. const chartZoomProps = useChartZoom({
  54. saveOnZoom: true,
  55. });
  56. let completeSeries: TimeseriesData[] = props.timeseries;
  57. const incompleteSeries: TimeseriesData[] = [];
  58. if (dataCompletenessDelay > 0) {
  59. completeSeries = [];
  60. props.timeseries.forEach(timeserie => {
  61. const [completeSerie, incompleteSerie] = splitSeriesIntoCompleteAndIncomplete(
  62. timeserie,
  63. dataCompletenessDelay
  64. );
  65. if (completeSerie && completeSerie.data.length > 0) {
  66. completeSeries.push(completeSerie);
  67. }
  68. if (incompleteSerie && incompleteSerie.data.length > 0) {
  69. incompleteSeries.push(incompleteSerie);
  70. }
  71. });
  72. }
  73. // TODO: There's a TypeScript indexing error here. This _could_ in theory be
  74. // `undefined`. We need to guard against this in the parent component, and
  75. // show an error.
  76. const firstSeries = props.timeseries[0];
  77. // TODO: Raise error if attempting to plot series of different types or units
  78. const firstSeriesField = firstSeries?.field;
  79. const type = meta?.fields?.[firstSeriesField] ?? 'number';
  80. const unit = meta?.units?.[firstSeriesField] ?? undefined;
  81. const formatter: TooltipFormatterCallback<TopLevelFormatterParams> = (
  82. params,
  83. asyncTicket
  84. ) => {
  85. // Only show the tooltip of the current chart. Otherwise, all tooltips
  86. // in the chart group appear.
  87. if (!isChartHovered(chartRef?.current)) {
  88. return '';
  89. }
  90. let deDupedParams = params;
  91. if (Array.isArray(params)) {
  92. // We split each series into a complete and incomplete series, and they
  93. // have the same name. The two series overlap at one point on the chart,
  94. // to create a continuous line. This code prevents both series from
  95. // showing up on the tooltip
  96. const uniqueSeries = new Set<string>();
  97. deDupedParams = params.filter(param => {
  98. // Filter null values from tooltip
  99. if (param.value[1] === null) {
  100. return false;
  101. }
  102. if (uniqueSeries.has(param.seriesName)) {
  103. return false;
  104. }
  105. uniqueSeries.add(param.seriesName);
  106. return true;
  107. });
  108. }
  109. return getFormatter({
  110. isGroupedByDate: true,
  111. showTimeInTooltip: true,
  112. valueFormatter: value => {
  113. return formatChartValue(value, type, unit);
  114. },
  115. truncate: true,
  116. utc: utc ?? false,
  117. })(deDupedParams, asyncTicket);
  118. };
  119. let visibleSeriesCount = props.timeseries.length;
  120. if (releaseSeries) {
  121. visibleSeriesCount += 1;
  122. }
  123. const showLegend = visibleSeriesCount > 1;
  124. return (
  125. <BaseChart
  126. ref={e => {
  127. chartRef.current = e;
  128. if (e?.getEchartsInstance) {
  129. registerWithWidgetSyncContext(e.getEchartsInstance());
  130. }
  131. }}
  132. autoHeightResize
  133. series={[
  134. ...completeSeries.map(timeserie => {
  135. return LineSeries({
  136. name: timeserie.field,
  137. color: timeserie.color,
  138. animation: false,
  139. data: timeserie.data.map(datum => {
  140. return [datum.timestamp, datum.value];
  141. }),
  142. });
  143. }),
  144. ...incompleteSeries.map(timeserie => {
  145. return LineSeries({
  146. name: timeserie.field,
  147. color: timeserie.color,
  148. animation: false,
  149. data: timeserie.data.map(datum => {
  150. return [datum.timestamp, datum.value];
  151. }),
  152. lineStyle: {
  153. type: 'dotted',
  154. },
  155. silent: true,
  156. });
  157. }),
  158. releaseSeries &&
  159. LineSeries({
  160. ...releaseSeries,
  161. name: releaseSeries.seriesName,
  162. data: [],
  163. }),
  164. ].filter(defined)}
  165. grid={{
  166. left: 0,
  167. top: showLegend ? 25 : 10,
  168. right: 4,
  169. bottom: 0,
  170. containLabel: true,
  171. }}
  172. legend={
  173. showLegend
  174. ? {
  175. top: 0,
  176. left: 0,
  177. }
  178. : undefined
  179. }
  180. tooltip={{
  181. trigger: 'axis',
  182. axisPointer: {
  183. type: 'cross',
  184. },
  185. formatter,
  186. }}
  187. xAxis={{
  188. axisLabel: {
  189. padding: [0, 10, 0, 10],
  190. width: 60,
  191. },
  192. splitNumber: 0,
  193. }}
  194. yAxis={{
  195. axisLabel: {
  196. formatter(value: number) {
  197. return formatChartValue(value, type, unit);
  198. },
  199. },
  200. axisPointer: {
  201. type: 'line',
  202. snap: false,
  203. lineStyle: {
  204. type: 'solid',
  205. width: 0.5,
  206. },
  207. label: {
  208. show: false,
  209. },
  210. },
  211. }}
  212. {...chartZoomProps}
  213. isGroupedByDate
  214. useMultilineDate
  215. start={start ? new Date(start) : undefined}
  216. end={end ? new Date(end) : undefined}
  217. period={period}
  218. utc={utc ?? undefined}
  219. />
  220. );
  221. }