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

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