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 20KB

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