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.

ZoomTimeLine.tsx 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  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 { extent, max } from 'd3-array';
  22. import { ScaleTime, scaleLinear, scalePoint, scaleTime } from 'd3-scale';
  23. import { area, curveBasis, line as d3Line } from 'd3-shape';
  24. import { CSSColor, DraggableIcon, themeColor } from 'design-system';
  25. import { flatten, sortBy, throttle } from 'lodash';
  26. import * as React from 'react';
  27. import Draggable, { DraggableBounds, DraggableCore, DraggableData } from 'react-draggable';
  28. import { MetricType } from '../../types/metrics';
  29. import { Chart } from '../../types/types';
  30. import { LINE_CHART_DASHES } from '../activity-graph/utils';
  31. export interface Props {
  32. basisCurve?: boolean;
  33. endDate?: Date;
  34. height: number;
  35. leakPeriodDate?: Date;
  36. metricType: string;
  37. padding?: number[];
  38. series: Chart.Serie[];
  39. showAreas?: boolean;
  40. startDate?: Date;
  41. updateZoom: (start?: Date, endDate?: Date) => void;
  42. width: number;
  43. }
  44. export type PropsWithDefaults = Props & typeof ZoomTimeLine.defaultProps;
  45. interface State {
  46. overlayLeftPos?: number;
  47. newZoomStart?: number;
  48. }
  49. type XScale = ScaleTime<number, number>;
  50. export class ZoomTimeLine extends React.PureComponent<Props, State> {
  51. static defaultProps = {
  52. padding: [0, 0, 18, 0],
  53. };
  54. constructor(props: PropsWithDefaults) {
  55. super(props);
  56. this.state = {};
  57. this.handleZoomUpdate = throttle(this.handleZoomUpdate, 40);
  58. }
  59. getRatingScale = (availableHeight: number) => {
  60. return scalePoint<number>().domain([5, 4, 3, 2, 1]).range([availableHeight, 0]);
  61. };
  62. getLevelScale = (availableHeight: number) => {
  63. return scalePoint().domain(['ERROR', 'WARN', 'OK']).range([availableHeight, 0]);
  64. };
  65. getYScale = (availableHeight: number, flatData: Chart.Point[]) => {
  66. if (this.props.metricType === MetricType.Rating) {
  67. return this.getRatingScale(availableHeight);
  68. } else if (this.props.metricType === MetricType.Level) {
  69. return this.getLevelScale(availableHeight);
  70. }
  71. return scaleLinear()
  72. .range([availableHeight, 0])
  73. .domain([0, max(flatData, (d) => Number(d.y || 0)) as number])
  74. .nice();
  75. };
  76. getXScale = (availableWidth: number, flatData: Chart.Point[]): XScale => {
  77. return scaleTime()
  78. .domain(extent(flatData, (d) => d.x) as [Date, Date])
  79. .range([0, availableWidth])
  80. .clamp(true);
  81. };
  82. getScales = () => {
  83. const { padding } = this.props as PropsWithDefaults;
  84. const availableWidth = this.props.width - padding[1] - padding[3];
  85. const availableHeight = this.props.height - padding[0] - padding[2];
  86. const flatData = flatten(this.props.series.map((serie) => serie.data));
  87. return {
  88. xScale: this.getXScale(availableWidth, flatData),
  89. yScale: this.getYScale(availableHeight, flatData),
  90. };
  91. };
  92. handleDoubleClick = (xScale: XScale, xDim: number[]) => () => {
  93. this.handleZoomUpdate(xScale, xDim);
  94. };
  95. handleSelectionDrag =
  96. (xScale: XScale, width: number, xDim: number[], checkDelta = false) =>
  97. (_: MouseEvent, data: DraggableData) => {
  98. if (!checkDelta || data.deltaX) {
  99. const x = Math.max(xDim[0], Math.min(data.x, xDim[1] - width));
  100. this.handleZoomUpdate(xScale, [x, width + x]);
  101. }
  102. };
  103. handleSelectionHandleDrag =
  104. (xScale: XScale, fixedX: number, xDim: number[], handleDirection: string, checkDelta = false) =>
  105. (_: MouseEvent, data: DraggableData) => {
  106. if (!checkDelta || data.deltaX) {
  107. const x = Math.max(xDim[0], Math.min(data.x, xDim[1]));
  108. this.handleZoomUpdate(xScale, handleDirection === 'right' ? [fixedX, x] : [x, fixedX]);
  109. }
  110. };
  111. handleNewZoomDragStart = (xDim: number[]) => (_: MouseEvent, data: DraggableData) => {
  112. const overlayLeftPos = data.node.getBoundingClientRect().left;
  113. this.setState({
  114. overlayLeftPos,
  115. newZoomStart: Math.round(Math.max(xDim[0], Math.min(data.x - overlayLeftPos, xDim[1]))),
  116. });
  117. };
  118. handleNewZoomDrag = (xScale: XScale, xDim: number[]) => (_: MouseEvent, data: DraggableData) => {
  119. const { newZoomStart, overlayLeftPos } = this.state;
  120. if (newZoomStart != null && overlayLeftPos != null && data.deltaX) {
  121. this.handleZoomUpdate(
  122. xScale,
  123. sortBy([newZoomStart, Math.max(xDim[0], Math.min(data.x - overlayLeftPos, xDim[1]))]),
  124. );
  125. }
  126. };
  127. handleNewZoomDragEnd =
  128. (xScale: XScale, xDim: number[]) => (_: MouseEvent, data: DraggableData) => {
  129. const { newZoomStart, overlayLeftPos } = this.state;
  130. if (newZoomStart !== undefined && overlayLeftPos !== undefined) {
  131. const x = Math.round(Math.max(xDim[0], Math.min(data.x - overlayLeftPos, xDim[1])));
  132. this.handleZoomUpdate(xScale, newZoomStart === x ? xDim : sortBy([newZoomStart, x]));
  133. this.setState({ newZoomStart: undefined, overlayLeftPos: undefined });
  134. }
  135. };
  136. handleZoomUpdate = (xScale: XScale, xArray: number[]) => {
  137. const xRange = xScale.range();
  138. const startDate =
  139. xArray[0] > xRange[0] && xArray[0] < xRange[xRange.length - 1]
  140. ? xScale.invert(xArray[0])
  141. : undefined;
  142. const endDate =
  143. xArray[1] > xRange[0] && xArray[1] < xRange[xRange.length - 1]
  144. ? xScale.invert(xArray[1])
  145. : undefined;
  146. this.props.updateZoom(startDate, endDate);
  147. };
  148. renderBaseLine = (xScale: XScale, yScale: { range: () => number[] }) => {
  149. return (
  150. <StyledBaseLine
  151. x1={xScale.range()[0]}
  152. x2={xScale.range()[1]}
  153. y1={yScale.range()[0]}
  154. y2={yScale.range()[0]}
  155. />
  156. );
  157. };
  158. renderNewCode = (xScale: XScale, yScale: { range: () => number[] }) => {
  159. const { leakPeriodDate } = this.props;
  160. if (!leakPeriodDate) {
  161. return null;
  162. }
  163. const yRange = yScale.range();
  164. return (
  165. <StyledNewCodeLegend
  166. height={yRange[0] - yRange[yRange.length - 1]}
  167. width={xScale.range()[1] - xScale(leakPeriodDate)}
  168. x={xScale(leakPeriodDate)}
  169. y={yRange[yRange.length - 1]}
  170. />
  171. );
  172. };
  173. renderLines = (xScale: XScale, yScale: (y: string | number | undefined) => number) => {
  174. const { series } = this.props;
  175. const lineGenerator = d3Line<Chart.Point>()
  176. .defined((d) => Boolean(d.y || d.y === 0))
  177. .x((d) => xScale(d.x))
  178. .y((d) => yScale(d.y));
  179. if (this.props.basisCurve) {
  180. lineGenerator.curve(curveBasis);
  181. }
  182. return (
  183. <g>
  184. {series.map((serie, idx) => (
  185. <StyledPath index={idx} d={lineGenerator(serie.data) ?? undefined} key={serie.name} />
  186. ))}
  187. </g>
  188. );
  189. };
  190. renderAreas = (xScale: XScale, yScale: (y: string | number | undefined) => number) => {
  191. const areaGenerator = area<Chart.Point>()
  192. .defined((d) => Boolean(d.y || d.y === 0))
  193. .x((d) => xScale(d.x))
  194. .y1((d) => yScale(d.y))
  195. .y0(yScale(0));
  196. if (this.props.basisCurve) {
  197. areaGenerator.curve(curveBasis);
  198. }
  199. return (
  200. <g>
  201. {this.props.series.map((serie, idx) => (
  202. <StyledArea index={idx} d={areaGenerator(serie.data) ?? undefined} key={serie.name} />
  203. ))}
  204. </g>
  205. );
  206. };
  207. renderZoomHandle = (options: {
  208. xScale: XScale;
  209. xPos: number;
  210. fixedPos: number;
  211. yDim: number[];
  212. xDim: number[];
  213. direction: string;
  214. }) => (
  215. <Draggable
  216. axis="x"
  217. bounds={{ left: options.xDim[0], right: options.xDim[1] } as DraggableBounds}
  218. onDrag={this.handleSelectionHandleDrag(
  219. options.xScale,
  220. options.fixedPos,
  221. options.xDim,
  222. options.direction,
  223. true,
  224. )}
  225. onStop={this.handleSelectionHandleDrag(
  226. options.xScale,
  227. options.fixedPos,
  228. options.xDim,
  229. options.direction,
  230. )}
  231. position={{ x: options.xPos, y: 0 }}
  232. >
  233. <g>
  234. <ZoomHighlightHandle
  235. height={options.yDim[0] - options.yDim[1] + 1}
  236. width={2}
  237. x={options.direction === 'right' ? 0 : -2}
  238. y={options.yDim[1]}
  239. />
  240. <DraggableIcon
  241. fill="graphZoomHandleColor"
  242. x={options.direction === 'right' ? -7 : -9}
  243. y={16}
  244. />
  245. </g>
  246. </Draggable>
  247. );
  248. renderZoom = (xScale: XScale, yScale: { range: () => number[] }) => {
  249. const xRange = xScale.range();
  250. const yRange = yScale.range();
  251. const xDim = [xRange[0], xRange[xRange.length - 1]];
  252. const yDim = [yRange[0], yRange[yRange.length - 1]];
  253. const startX = Math.round(this.props.startDate ? xScale(this.props.startDate) : xDim[0]);
  254. const endX = Math.round(this.props.endDate ? xScale(this.props.endDate) : xDim[1]);
  255. const xArray = sortBy([startX, endX]);
  256. const zoomBoxWidth = xArray[1] - xArray[0];
  257. const showZoomArea =
  258. this.state.newZoomStart == null ||
  259. this.state.newZoomStart === startX ||
  260. this.state.newZoomStart === endX;
  261. return (
  262. <g>
  263. <DraggableCore
  264. onDrag={this.handleNewZoomDrag(xScale, xDim)}
  265. onStart={this.handleNewZoomDragStart(xDim)}
  266. onStop={this.handleNewZoomDragEnd(xScale, xDim)}
  267. >
  268. <ZoomOverlay
  269. height={yDim[0] - yDim[1]}
  270. width={xDim[1] - xDim[0]}
  271. x={xDim[0]}
  272. y={yDim[1]}
  273. />
  274. </DraggableCore>
  275. {showZoomArea && (
  276. <Draggable
  277. axis="x"
  278. bounds={{ left: xDim[0], right: Math.floor(xDim[1] - zoomBoxWidth) } as DraggableBounds}
  279. onDrag={this.handleSelectionDrag(xScale, zoomBoxWidth, xDim, true)}
  280. onStop={this.handleSelectionDrag(xScale, zoomBoxWidth, xDim)}
  281. position={{ x: xArray[0], y: 0 }}
  282. >
  283. <ZoomHighlight
  284. height={yDim[0] - yDim[1] + 1}
  285. onDoubleClick={this.handleDoubleClick(xScale, xDim)}
  286. width={zoomBoxWidth}
  287. x={0}
  288. y={yDim[1]}
  289. />
  290. </Draggable>
  291. )}
  292. {showZoomArea &&
  293. this.renderZoomHandle({
  294. xScale,
  295. xPos: startX,
  296. fixedPos: endX,
  297. xDim,
  298. yDim,
  299. direction: 'left',
  300. })}
  301. {showZoomArea &&
  302. this.renderZoomHandle({
  303. xScale,
  304. xPos: endX,
  305. fixedPos: startX,
  306. xDim,
  307. yDim,
  308. direction: 'right',
  309. })}
  310. </g>
  311. );
  312. };
  313. render() {
  314. const { padding } = this.props as PropsWithDefaults;
  315. if (!this.props.width || !this.props.height) {
  316. return <div />;
  317. }
  318. const { xScale, yScale } = this.getScales();
  319. return (
  320. <svg height={this.props.height} width={this.props.width}>
  321. <g transform={`translate(${padding[3]}, ${padding[0] + 2})`}>
  322. {this.renderNewCode(xScale, yScale as Parameters<typeof this.renderNewCode>[1])}
  323. {this.renderBaseLine(xScale, yScale as Parameters<typeof this.renderBaseLine>[1])}
  324. {this.props.showAreas &&
  325. this.renderAreas(xScale, yScale as Parameters<typeof this.renderAreas>[1])}
  326. {this.renderLines(xScale, yScale as Parameters<typeof this.renderLines>[1])}
  327. {this.renderZoom(xScale, yScale as Parameters<typeof this.renderZoom>[1])}
  328. </g>
  329. </svg>
  330. );
  331. }
  332. }
  333. const ZoomHighlight = styled.rect`
  334. cursor: move;
  335. fill: ${themeColor('graphZoomBackgroundColor')};
  336. stroke: ${themeColor('graphZoomBorderColor')};
  337. fill-opacity: 0.2;
  338. shape-rendering: crispEdges;
  339. `;
  340. const ZoomHighlightHandle = styled.rect`
  341. cursor: ew-resize;
  342. fill-opacity: 1;
  343. fill: ${themeColor('graphZoomHandleColor')};
  344. stroke: none;
  345. `;
  346. const ZoomOverlay = styled.rect`
  347. cursor: crosshair;
  348. pointer-events: all;
  349. fill: none;
  350. stroke: none;
  351. `;
  352. const AREA_OPACITY = 0.15;
  353. const StyledArea = styled.path<{ index: number }>`
  354. clip-path: url(#chart-clip);
  355. fill: ${({ index }) => themeColor(`graphLineColor.${index}` as CSSColor, AREA_OPACITY)};
  356. stroke-width: 0;
  357. `;
  358. const StyledPath = styled.path<{ index: number }>`
  359. clip-path: url(#chart-clip);
  360. fill: none;
  361. stroke: ${({ index }) => themeColor(`graphLineColor.${index}` as CSSColor)};
  362. stroke-dasharray: ${({ index }) => LINE_CHART_DASHES[index]};
  363. stroke-width: 2px;
  364. `;
  365. const StyledNewCodeLegend = styled.rect`
  366. fill: ${themeColor('newCodeLegend')};
  367. `;
  368. const StyledBaseLine = styled('line')`
  369. shape-rendering: crispedges;
  370. stroke: ${themeColor('graphGridColor')};
  371. `;