You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

AdvancedTimeline.tsx 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2024 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import styled from '@emotion/styled';
  21. import classNames from 'classnames';
  22. import { bisector, extent, max } from 'd3-array';
  23. import {
  24. NumberValue,
  25. ScaleLinear,
  26. ScalePoint,
  27. ScaleTime,
  28. scaleLinear,
  29. scalePoint,
  30. scaleTime,
  31. } from 'd3-scale';
  32. import { area, curveBasis, line as d3Line } from 'd3-shape';
  33. import { CSSColor, ThemeProp, themeColor, withTheme } from 'design-system';
  34. import { flatten, isEqual, sortBy, throttle, uniq } from 'lodash';
  35. import * as React from 'react';
  36. import { isDefined } from '../../helpers/types';
  37. import { MetricType } from '../../types/metrics';
  38. import { Chart } from '../../types/types';
  39. import { LINE_CHART_DASHES } from '../activity-graph/utils';
  40. import './AdvancedTimeline.css';
  41. import './LineChart.css';
  42. export interface PropsWithoutTheme {
  43. graphDescription?: string;
  44. basisCurve?: boolean;
  45. endDate?: Date;
  46. disableZoom?: boolean;
  47. formatYTick?: (tick: number | string) => string;
  48. hideGrid?: boolean;
  49. hideXAxis?: boolean;
  50. height: number;
  51. width: number;
  52. leakPeriodDate?: Date;
  53. // used to avoid same y ticks labels
  54. maxYTicksCount?: number;
  55. metricType: string;
  56. padding?: number[];
  57. selectedDate?: Date;
  58. series: Chart.Serie[];
  59. showAreas?: boolean;
  60. startDate?: Date;
  61. updateSelectedDate?: (selectedDate?: Date) => void;
  62. updateTooltip?: (selectedDate?: Date, tooltipXPos?: number, tooltipIdx?: number) => void;
  63. updateZoom?: (start?: Date, endDate?: Date) => void;
  64. zoomSpeed?: number;
  65. }
  66. export type Props = PropsWithoutTheme & ThemeProp;
  67. type PropsWithDefaults = Props & typeof AdvancedTimelineClass.defaultProps;
  68. type XScale = ScaleTime<number, number>;
  69. type YScale = ScaleLinear<number, number> | ScalePoint<number | string>;
  70. type YPoint = (number | string) & NumberValue;
  71. const X_LABEL_OFFSET = 15;
  72. interface State {
  73. maxXRange: number[];
  74. mouseOver?: boolean;
  75. selectedDate?: Date;
  76. selectedDateXPos?: number;
  77. selectedDateIdx?: number;
  78. yScale: YScale;
  79. xScale: XScale;
  80. }
  81. export class AdvancedTimelineClass extends React.PureComponent<Props, State> {
  82. static defaultProps = {
  83. padding: [26, 10, 50, 50],
  84. };
  85. constructor(props: PropsWithDefaults) {
  86. super(props);
  87. const scales = this.getScales(props);
  88. const selectedDatePos = this.getSelectedDatePos(scales.xScale, props.selectedDate);
  89. this.state = { ...scales, ...selectedDatePos };
  90. this.updateTooltipPos = throttle(this.updateTooltipPos, 40);
  91. this.handleZoomUpdate = throttle(this.handleZoomUpdate, 40);
  92. }
  93. componentDidUpdate(prevProps: PropsWithDefaults) {
  94. let scales;
  95. let selectedDatePos;
  96. if (
  97. this.props.metricType !== prevProps.metricType ||
  98. this.props.startDate !== prevProps.startDate ||
  99. this.props.endDate !== prevProps.endDate ||
  100. this.props.width !== prevProps.width ||
  101. this.props.padding !== prevProps.padding ||
  102. this.props.height !== prevProps.height ||
  103. this.props.series !== prevProps.series
  104. ) {
  105. scales = this.getScales(this.props as PropsWithDefaults);
  106. this.setState({ ...scales });
  107. if (this.state.selectedDate != null) {
  108. selectedDatePos = this.getSelectedDatePos(scales.xScale, this.state.selectedDate);
  109. }
  110. }
  111. if (!isEqual(this.props.selectedDate, prevProps.selectedDate)) {
  112. const xScale = scales ? scales.xScale : this.state.xScale;
  113. selectedDatePos = this.getSelectedDatePos(xScale, this.props.selectedDate);
  114. }
  115. if (selectedDatePos) {
  116. this.setState({ ...selectedDatePos });
  117. if (this.props.updateTooltip) {
  118. this.props.updateTooltip(
  119. selectedDatePos.selectedDate,
  120. selectedDatePos.selectedDateXPos,
  121. selectedDatePos.selectedDateIdx,
  122. );
  123. }
  124. }
  125. }
  126. getRatingScale = (availableHeight: number) => {
  127. return scalePoint<number>().domain([5, 4, 3, 2, 1]).range([availableHeight, 0]);
  128. };
  129. getLevelScale = (availableHeight: number) => {
  130. return scalePoint().domain(['ERROR', 'WARN', 'OK']).range([availableHeight, 0]);
  131. };
  132. getYScale = (
  133. props: PropsWithDefaults,
  134. availableHeight: number,
  135. flatData: Chart.Point[],
  136. ): YScale => {
  137. if (props.metricType === MetricType.Rating) {
  138. return this.getRatingScale(availableHeight);
  139. } else if (props.metricType === MetricType.Level) {
  140. return this.getLevelScale(availableHeight);
  141. }
  142. return scaleLinear()
  143. .range([availableHeight, 0])
  144. .domain([0, max(flatData, (d) => Number(d.y || 0)) || 1])
  145. .nice();
  146. };
  147. isYScaleLinear(yScale: YScale): yScale is ScaleLinear<number, number> {
  148. return 'ticks' in yScale;
  149. }
  150. getXScale = (
  151. { startDate, endDate }: PropsWithDefaults,
  152. availableWidth: number,
  153. flatData: Chart.Point[],
  154. ) => {
  155. const dateRange = extent(flatData, (d) => d.x) as [Date, Date];
  156. const start = startDate && startDate > dateRange[0] ? startDate : dateRange[0];
  157. const end = endDate && endDate < dateRange[1] ? endDate : dateRange[1];
  158. const xScale: ScaleTime<number, number> = scaleTime()
  159. .domain(sortBy([start, end]))
  160. .range([0, availableWidth])
  161. .clamp(false);
  162. return {
  163. xScale,
  164. maxXRange: dateRange.map(xScale),
  165. };
  166. };
  167. getScales = (props: PropsWithDefaults) => {
  168. const availableWidth = props.width - props.padding[1] - props.padding[3];
  169. const availableHeight = props.height - props.padding[0] - props.padding[2];
  170. const flatData = flatten(props.series.map((serie) => serie.data));
  171. return {
  172. ...this.getXScale(props, availableWidth, flatData),
  173. yScale: this.getYScale(props, availableHeight, flatData),
  174. };
  175. };
  176. getSelectedDatePos = (xScale: XScale, selectedDate?: Date) => {
  177. const firstSerie = this.props.series[0];
  178. if (selectedDate && firstSerie) {
  179. const idx = firstSerie.data.findIndex((p) => p.x.valueOf() === selectedDate.valueOf());
  180. const xRange = sortBy(xScale.range());
  181. const xPos = xScale(selectedDate);
  182. if (idx >= 0 && xPos >= xRange[0] && xPos <= xRange[1]) {
  183. return {
  184. selectedDate,
  185. selectedDateXPos: xScale(selectedDate),
  186. selectedDateIdx: idx,
  187. };
  188. }
  189. }
  190. return { selectedDate: undefined, selectedDateXPos: undefined, selectedDateIdx: undefined };
  191. };
  192. handleWheel = (event: React.WheelEvent<SVGElement>) => {
  193. const { zoomSpeed = 1 } = this.props;
  194. const { maxXRange, xScale } = this.state;
  195. const parentBbox = event.currentTarget.getBoundingClientRect();
  196. const mouseXPos = (event.pageX - parentBbox.left) / parentBbox.width;
  197. const xRange = xScale.range();
  198. const speed = (event.deltaMode as number | undefined)
  199. ? (25 / event.deltaMode) * zoomSpeed
  200. : zoomSpeed;
  201. const leftPos = xRange[0] - Math.round(speed * event.deltaY * mouseXPos);
  202. const rightPos = xRange[1] + Math.round(speed * event.deltaY * (1 - mouseXPos));
  203. const startDate = leftPos > maxXRange[0] ? xScale.invert(leftPos) : undefined;
  204. const endDate = rightPos < maxXRange[1] ? xScale.invert(rightPos) : undefined;
  205. this.handleZoomUpdate(startDate, endDate);
  206. };
  207. handleZoomUpdate = (startDate?: Date, endDate?: Date) => {
  208. if (this.props.updateZoom) {
  209. this.props.updateZoom(startDate, endDate);
  210. }
  211. };
  212. handleMouseMove = (event: React.MouseEvent<SVGElement>) => {
  213. const parentBbox = event.currentTarget.getBoundingClientRect();
  214. this.updateTooltipPos(event.pageX - parentBbox.left);
  215. };
  216. handleMouseEnter = () => {
  217. this.setState({ mouseOver: true });
  218. };
  219. handleMouseOut = () => {
  220. const { updateTooltip } = this.props;
  221. if (updateTooltip) {
  222. this.setState({
  223. mouseOver: false,
  224. selectedDate: undefined,
  225. selectedDateXPos: undefined,
  226. selectedDateIdx: undefined,
  227. });
  228. updateTooltip(undefined, undefined, undefined);
  229. }
  230. };
  231. handleClick = () => {
  232. const { updateSelectedDate } = this.props;
  233. if (updateSelectedDate) {
  234. updateSelectedDate(this.state.selectedDate || undefined);
  235. }
  236. };
  237. updateTooltipPos = (xPos: number) => {
  238. this.setState((state) => {
  239. const firstSerie = this.props.series[0];
  240. if (state.mouseOver && firstSerie) {
  241. const { updateTooltip } = this.props;
  242. const date = state.xScale.invert(xPos);
  243. const bisectX = bisector<Chart.Point, Date>((d) => d.x).right;
  244. let idx = bisectX(firstSerie.data, date);
  245. if (idx >= 0) {
  246. const previousPoint = firstSerie.data[idx - 1];
  247. const nextPoint = firstSerie.data[idx];
  248. if (
  249. !nextPoint ||
  250. (previousPoint &&
  251. date.valueOf() - previousPoint.x.valueOf() <= nextPoint.x.valueOf() - date.valueOf())
  252. ) {
  253. idx--;
  254. }
  255. const selectedDate = firstSerie.data[idx].x;
  256. const xPos = state.xScale(selectedDate);
  257. if (updateTooltip) {
  258. updateTooltip(selectedDate, xPos, idx);
  259. }
  260. return { selectedDate, selectedDateXPos: xPos, selectedDateIdx: idx };
  261. }
  262. }
  263. return null;
  264. });
  265. };
  266. renderHorizontalGrid = () => {
  267. const { formatYTick, maxYTicksCount = 4 } = this.props;
  268. const { xScale, yScale } = this.state;
  269. const hasTicks = this.isYScaleLinear(yScale);
  270. let ticks: Array<string | number> = hasTicks ? yScale.ticks(maxYTicksCount) : yScale.domain();
  271. if (!ticks.length) {
  272. ticks.push(yScale.domain()[1]);
  273. }
  274. // if there are duplicated ticks, that means 4 ticks are too much for this data
  275. // so let's just use the domain values (min and max)
  276. if (formatYTick) {
  277. const formattedTicks = ticks.map((tick) => formatYTick(tick));
  278. if (ticks.length > uniq(formattedTicks).length) {
  279. ticks = yScale.domain();
  280. }
  281. }
  282. return (
  283. <g>
  284. {ticks.map((tick) => {
  285. const y = yScale(tick as YPoint);
  286. return (
  287. <g key={tick}>
  288. {formatYTick != null && (
  289. <text
  290. className="line-chart-tick line-chart-tick-x sw-body-sm"
  291. dx="-1em"
  292. dy="0.3em"
  293. textAnchor="end"
  294. x={xScale.range()[0]}
  295. y={y}
  296. >
  297. {formatYTick(tick)}
  298. </text>
  299. )}
  300. <line
  301. className="line-chart-grid"
  302. x1={xScale.range()[0]}
  303. x2={xScale.range()[1]}
  304. y1={y}
  305. y2={y}
  306. />
  307. </g>
  308. );
  309. })}
  310. </g>
  311. );
  312. };
  313. renderXAxisTicks = () => {
  314. const { xScale, yScale } = this.state;
  315. const format = xScale.tickFormat(7);
  316. const ticks = xScale.ticks(7);
  317. const y = yScale.range()[0];
  318. return (
  319. <g transform="translate(0, 20)">
  320. {ticks.slice(0, -1).map((tick, index) => {
  321. const x = xScale(tick);
  322. return (
  323. <text
  324. className="line-chart-tick sw-body-sm"
  325. // eslint-disable-next-line react/no-array-index-key
  326. key={index}
  327. textAnchor="end"
  328. transform={`rotate(-35, ${x + X_LABEL_OFFSET}, ${y})`}
  329. x={x + X_LABEL_OFFSET}
  330. y={y}
  331. >
  332. {format(tick)}
  333. </text>
  334. );
  335. })}
  336. </g>
  337. );
  338. };
  339. renderLeak = () => {
  340. const { leakPeriodDate, theme } = this.props;
  341. if (!leakPeriodDate) {
  342. return null;
  343. }
  344. const { xScale, yScale } = this.state;
  345. const yRange = yScale.range();
  346. const xRange = xScale.range();
  347. // truncate leak to start of chart to prevent weird visual artifacts when too far left
  348. // (occurs when leak starts a long time before first analysis)
  349. const leakStart = Math.max(xScale(leakPeriodDate), xRange[0]);
  350. const leakWidth = xRange[xRange.length - 1] - leakStart;
  351. if (leakWidth < 1) {
  352. return null;
  353. }
  354. return (
  355. <rect
  356. className="leak-chart-rect"
  357. fill={themeColor('newCodeLegend')({ theme })}
  358. height={yRange[0] - yRange[yRange.length - 1]}
  359. width={leakWidth}
  360. x={leakStart}
  361. y={yRange[yRange.length - 1]}
  362. />
  363. );
  364. };
  365. renderLines = () => {
  366. const { series, theme } = this.props;
  367. const { xScale, yScale } = this.state;
  368. const lineGenerator = d3Line<Chart.Point>()
  369. .defined((d) => Boolean(d.y || d.y === 0))
  370. .x((d) => xScale(d.x))
  371. .y((d) => yScale(d.y as YPoint) as number);
  372. if (this.props.basisCurve) {
  373. lineGenerator.curve(curveBasis);
  374. }
  375. return (
  376. <g>
  377. {series.map((serie, idx) => (
  378. <path
  379. className={classNames('line-chart-path', `line-chart-path-${idx}`)}
  380. d={lineGenerator(serie.data) ?? undefined}
  381. key={serie.name}
  382. stroke={themeColor(`graphLineColor.${idx}` as Parameters<typeof themeColor>[0])({
  383. theme,
  384. })}
  385. strokeDasharray={LINE_CHART_DASHES[idx]}
  386. />
  387. ))}
  388. </g>
  389. );
  390. };
  391. renderDots = () => {
  392. const { series, theme } = this.props;
  393. const { xScale, yScale } = this.state;
  394. return (
  395. <g>
  396. {series
  397. .map((serie, serieIdx) =>
  398. serie.data
  399. .map((point, idx) => {
  400. const pointNotDefined = !point.y && point.y !== 0;
  401. const hasPointBefore =
  402. serie.data[idx - 1] && (serie.data[idx - 1].y || serie.data[idx - 1].y === 0);
  403. const hasPointAfter =
  404. serie.data[idx + 1] && (serie.data[idx + 1].y || serie.data[idx + 1].y === 0);
  405. if (pointNotDefined || hasPointBefore || hasPointAfter) {
  406. return undefined;
  407. }
  408. return (
  409. <circle
  410. cx={xScale(point.x)}
  411. cy={yScale(point.y as YPoint)}
  412. fill={themeColor(
  413. `graphLineColor.${serieIdx}` as Parameters<typeof themeColor>[0],
  414. )({
  415. theme,
  416. })}
  417. key={`${serie.name}${point.x}${point.y}`}
  418. r="2"
  419. stroke="white"
  420. strokeWidth={1}
  421. />
  422. );
  423. })
  424. .filter(isDefined),
  425. )
  426. .filter((dots) => dots.length > 0)}
  427. </g>
  428. );
  429. };
  430. renderAreas = () => {
  431. const { series, basisCurve } = this.props;
  432. const { xScale, yScale } = this.state;
  433. const areaGenerator = area<Chart.Point>()
  434. .defined((d) => Boolean(d.y || d.y === 0))
  435. .x((d) => xScale(d.x))
  436. .y1((d) => yScale(d.y as YPoint) as number)
  437. .y0(yScale(0) as number);
  438. if (basisCurve) {
  439. areaGenerator.curve(curveBasis);
  440. }
  441. return (
  442. <g>
  443. {series.map((serie, idx) => (
  444. <StyledArea d={areaGenerator(serie.data) ?? undefined} index={idx} key={serie.name} />
  445. ))}
  446. </g>
  447. );
  448. };
  449. renderSelectedDate = () => {
  450. const { series, theme } = this.props;
  451. const { selectedDateIdx, selectedDateXPos, yScale } = this.state;
  452. const firstSerie = series[0];
  453. if (selectedDateIdx == null || selectedDateXPos == null || !firstSerie) {
  454. return null;
  455. }
  456. return (
  457. <g>
  458. <line
  459. className="line-tooltip"
  460. x1={selectedDateXPos}
  461. x2={selectedDateXPos}
  462. y1={yScale.range()[0]}
  463. y2={yScale.range()[1]}
  464. />
  465. {series.map((serie, idx) => {
  466. const point = serie.data[selectedDateIdx];
  467. if (!point || (!point.y && point.y !== 0)) {
  468. return null;
  469. }
  470. return (
  471. <circle
  472. cx={selectedDateXPos}
  473. cy={yScale(point.y as YPoint)}
  474. fill={themeColor(`graphLineColor.${idx}` as Parameters<typeof themeColor>[0])({
  475. theme,
  476. })}
  477. key={serie.name}
  478. r="4"
  479. stroke="white"
  480. strokeWidth={1}
  481. />
  482. );
  483. })}
  484. </g>
  485. );
  486. };
  487. renderClipPath = () => {
  488. const { yScale, xScale } = this.state;
  489. return (
  490. <defs>
  491. <clipPath id="chart-clip">
  492. <rect
  493. height={yScale.range()[0] + 10}
  494. transform="translate(0,-5)"
  495. width={xScale.range()[1]}
  496. />
  497. </clipPath>
  498. </defs>
  499. );
  500. };
  501. renderMouseEventsOverlay = (zoomEnabled: boolean) => {
  502. const { yScale, xScale } = this.state;
  503. const mouseEvents: Partial<React.SVGProps<SVGRectElement>> = {};
  504. if (zoomEnabled) {
  505. mouseEvents.onWheel = this.handleWheel;
  506. }
  507. if (this.props.updateTooltip) {
  508. mouseEvents.onMouseEnter = this.handleMouseEnter;
  509. mouseEvents.onMouseMove = this.handleMouseMove;
  510. mouseEvents.onMouseOut = this.handleMouseOut;
  511. }
  512. if (this.props.updateSelectedDate) {
  513. mouseEvents.onClick = this.handleClick;
  514. }
  515. return (
  516. <rect
  517. className="chart-mouse-events-overlay"
  518. height={yScale.range()[0]}
  519. width={xScale.range()[1]}
  520. {...mouseEvents}
  521. />
  522. );
  523. };
  524. render() {
  525. const {
  526. width,
  527. height,
  528. padding,
  529. disableZoom,
  530. startDate,
  531. endDate,
  532. leakPeriodDate,
  533. hideGrid,
  534. hideXAxis,
  535. showAreas,
  536. graphDescription,
  537. } = this.props as PropsWithDefaults;
  538. if (!width || !height) {
  539. return <div />;
  540. }
  541. const zoomEnabled = !disableZoom && this.props.updateZoom != null;
  542. const isZoomed = Boolean(startDate ?? endDate);
  543. return (
  544. <svg
  545. aria-label={graphDescription}
  546. className={classNames('line-chart', { 'chart-zoomed': isZoomed })}
  547. height={height}
  548. width={width}
  549. >
  550. {zoomEnabled && this.renderClipPath()}
  551. <g transform={`translate(${padding[3]}, ${padding[0]})`}>
  552. {leakPeriodDate != null && this.renderLeak()}
  553. {!hideGrid && this.renderHorizontalGrid()}
  554. {!hideXAxis && this.renderXAxisTicks()}
  555. {showAreas && this.renderAreas()}
  556. {this.renderLines()}
  557. {this.renderDots()}
  558. {this.renderSelectedDate()}
  559. {this.renderMouseEventsOverlay(zoomEnabled)}
  560. </g>
  561. </svg>
  562. );
  563. }
  564. }
  565. const AREA_OPACITY = 0.15;
  566. const StyledArea = styled.path<{ index: number }>`
  567. clip-path: url(#chart-clip);
  568. fill: ${({ index }) => themeColor(`graphLineColor.${index}` as CSSColor, AREA_OPACITY)};
  569. `;
  570. export const AdvancedTimeline = withTheme<PropsWithoutTheme>(AdvancedTimelineClass);