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.

BubbleChart.tsx 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  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 { max, min } from 'd3-array';
  22. import { scaleLinear, ScaleLinear } from 'd3-scale';
  23. import { event, select } from 'd3-selection';
  24. import { zoom, ZoomBehavior, zoomIdentity } from 'd3-zoom';
  25. import { sortBy, uniq } from 'lodash';
  26. import * as React from 'react';
  27. import { Link } from 'react-router';
  28. import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer';
  29. import { translate } from '../../helpers/l10n';
  30. import { Location } from '../../helpers/urls';
  31. import Tooltip from '../controls/Tooltip';
  32. import './BubbleChart.css';
  33. const TICKS_COUNT = 5;
  34. interface BubbleItem<T> {
  35. color?: string;
  36. key?: string;
  37. link?: string | Location;
  38. data?: T;
  39. size: number;
  40. tooltip?: React.ReactNode;
  41. x: number;
  42. y: number;
  43. }
  44. interface Props<T> {
  45. displayXGrid?: boolean;
  46. displayXTicks?: boolean;
  47. displayYGrid?: boolean;
  48. displayYTicks?: boolean;
  49. formatXTick: (tick: number) => string;
  50. formatYTick: (tick: number) => string;
  51. height: number;
  52. items: BubbleItem<T>[];
  53. onBubbleClick?: (ref?: T) => void;
  54. padding: [number, number, number, number];
  55. sizeDomain?: [number, number];
  56. sizeRange?: [number, number];
  57. xDomain?: [number, number];
  58. yDomain?: [number, number];
  59. }
  60. interface State {
  61. transform: { x: number; y: number; k: number };
  62. }
  63. type Scale = ScaleLinear<number, number>;
  64. export default class BubbleChart<T> extends React.PureComponent<Props<T>, State> {
  65. private node?: Element;
  66. private zoom?: ZoomBehavior<Element, unknown>;
  67. static defaultProps = {
  68. displayXGrid: true,
  69. displayXTicks: true,
  70. displayYGrid: true,
  71. displayYTicks: true,
  72. formatXTick: (d: number) => String(d),
  73. formatYTick: (d: number) => String(d),
  74. padding: [10, 10, 10, 10],
  75. sizeRange: [5, 45]
  76. };
  77. constructor(props: Props<T>) {
  78. super(props);
  79. this.state = { transform: { x: 0, y: 0, k: 1 } };
  80. }
  81. componentDidUpdate() {
  82. if (this.zoom && this.node) {
  83. const rect = this.node.getBoundingClientRect();
  84. this.zoom.translateExtent([
  85. [0, 0],
  86. [rect.width, rect.height]
  87. ]);
  88. }
  89. }
  90. boundNode = (node: SVGSVGElement) => {
  91. this.node = node;
  92. this.zoom = zoom()
  93. .scaleExtent([1, 10])
  94. .on('zoom', this.zoomed);
  95. select(this.node).call(this.zoom as any);
  96. };
  97. zoomed = () => {
  98. const { padding } = this.props;
  99. const { x, y, k } = event.transform as { x: number; y: number; k: number };
  100. this.setState({
  101. transform: {
  102. x: x + padding[3] * (k - 1),
  103. y: y + padding[0] * (k - 1),
  104. k
  105. }
  106. });
  107. };
  108. resetZoom = (e: React.MouseEvent<Link>) => {
  109. e.stopPropagation();
  110. e.preventDefault();
  111. if (this.zoom && this.node) {
  112. select(this.node).call(this.zoom.transform as any, zoomIdentity);
  113. }
  114. };
  115. getXRange(xScale: Scale, sizeScale: Scale, availableWidth: number) {
  116. const minX = min(this.props.items, d => xScale(d.x) - sizeScale(d.size)) || 0;
  117. const maxX = max(this.props.items, d => xScale(d.x) + sizeScale(d.size)) || 0;
  118. const dMinX = minX < 0 ? xScale.range()[0] - minX : xScale.range()[0];
  119. const dMaxX = maxX > xScale.range()[1] ? maxX - xScale.range()[1] : 0;
  120. return [dMinX, availableWidth - dMaxX];
  121. }
  122. getYRange(yScale: Scale, sizeScale: Scale, availableHeight: number) {
  123. const minY = min(this.props.items, d => yScale(d.y) - sizeScale(d.size)) || 0;
  124. const maxY = max(this.props.items, d => yScale(d.y) + sizeScale(d.size)) || 0;
  125. const dMinY = minY < 0 ? yScale.range()[1] - minY : yScale.range()[1];
  126. const dMaxY = maxY > yScale.range()[0] ? maxY - yScale.range()[0] : 0;
  127. return [availableHeight - dMaxY, dMinY];
  128. }
  129. getTicks(scale: Scale, format: (d: number) => string) {
  130. const zoomAmount = Math.ceil(this.state.transform.k);
  131. const ticks = scale.ticks(TICKS_COUNT * zoomAmount).map(tick => format(tick));
  132. const uniqueTicksCount = uniq(ticks).length;
  133. const ticksCount =
  134. uniqueTicksCount < TICKS_COUNT * zoomAmount ? uniqueTicksCount - 1 : TICKS_COUNT * zoomAmount;
  135. return scale.ticks(ticksCount);
  136. }
  137. getZoomLevelLabel = () => Math.floor(this.state.transform.k * 100) + '%';
  138. renderXGrid = (ticks: number[], xScale: Scale, yScale: Scale) => {
  139. if (!this.props.displayXGrid) {
  140. return null;
  141. }
  142. const { transform } = this.state;
  143. const lines = ticks.map((tick, index) => {
  144. const x = xScale(tick);
  145. const y1 = yScale.range()[0];
  146. const y2 = yScale.range()[1];
  147. return (
  148. <line
  149. className="bubble-chart-grid"
  150. // eslint-disable-next-line react/no-array-index-key
  151. key={index}
  152. x1={x * transform.k + transform.x}
  153. x2={x * transform.k + transform.x}
  154. y1={y1 * transform.k}
  155. y2={transform.k > 1 ? 0 : y2}
  156. />
  157. );
  158. });
  159. return <g>{lines}</g>;
  160. };
  161. renderYGrid = (ticks: number[], xScale: Scale, yScale: Scale) => {
  162. if (!this.props.displayYGrid) {
  163. return null;
  164. }
  165. const { transform } = this.state;
  166. const lines = ticks.map((tick, index) => {
  167. const y = yScale(tick);
  168. const x1 = xScale.range()[0];
  169. const x2 = xScale.range()[1];
  170. return (
  171. <line
  172. className="bubble-chart-grid"
  173. // eslint-disable-next-line react/no-array-index-key
  174. key={index}
  175. x1={transform.k > 1 ? 0 : x1}
  176. x2={x2 * transform.k}
  177. y1={y * transform.k + transform.y}
  178. y2={y * transform.k + transform.y}
  179. />
  180. );
  181. });
  182. return <g>{lines}</g>;
  183. };
  184. renderXTicks = (xTicks: number[], xScale: Scale, yScale: Scale) => {
  185. if (!this.props.displayXTicks) {
  186. return null;
  187. }
  188. const { transform } = this.state;
  189. const ticks = xTicks.map((tick, index) => {
  190. const x = xScale(tick) * transform.k + transform.x;
  191. const y = yScale.range()[0];
  192. const innerText = this.props.formatXTick(tick);
  193. // as we modified the `x` using `transform`, check that it is inside the range again
  194. return x > 0 && x < xScale.range()[1] ? (
  195. // eslint-disable-next-line react/no-array-index-key
  196. <text className="bubble-chart-tick" dy="1.5em" key={index} x={x} y={y}>
  197. {innerText}
  198. </text>
  199. ) : null;
  200. });
  201. return <g>{ticks}</g>;
  202. };
  203. renderYTicks = (yTicks: number[], xScale: Scale, yScale: Scale) => {
  204. if (!this.props.displayYTicks) {
  205. return null;
  206. }
  207. const { transform } = this.state;
  208. const ticks = yTicks.map((tick, index) => {
  209. const x = xScale.range()[0];
  210. const y = yScale(tick) * transform.k + transform.y;
  211. const innerText = this.props.formatYTick(tick);
  212. // as we modified the `y` using `transform`, check that it is inside the range again
  213. return y > 0 && y < yScale.range()[0] ? (
  214. <text
  215. className="bubble-chart-tick bubble-chart-tick-y"
  216. dx="-0.5em"
  217. dy="0.3em"
  218. // eslint-disable-next-line react/no-array-index-key
  219. key={index}
  220. x={x}
  221. y={y}>
  222. {innerText}
  223. </text>
  224. ) : null;
  225. });
  226. return <g>{ticks}</g>;
  227. };
  228. renderChart = (width: number) => {
  229. const { transform } = this.state;
  230. const availableWidth = width - this.props.padding[1] - this.props.padding[3];
  231. const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2];
  232. const xScale = scaleLinear()
  233. .domain(this.props.xDomain || [0, max(this.props.items, d => d.x) || 0])
  234. .range([0, availableWidth])
  235. .nice();
  236. const yScale = scaleLinear()
  237. .domain(this.props.yDomain || [0, max(this.props.items, d => d.y) || 0])
  238. .range([availableHeight, 0])
  239. .nice();
  240. const sizeScale = scaleLinear()
  241. .domain(this.props.sizeDomain || [0, max(this.props.items, d => d.size) || 0])
  242. .range(this.props.sizeRange || []);
  243. const xScaleOriginal = xScale.copy();
  244. const yScaleOriginal = yScale.copy();
  245. xScale.range(this.getXRange(xScale, sizeScale, availableWidth));
  246. yScale.range(this.getYRange(yScale, sizeScale, availableHeight));
  247. const bubbles = sortBy(this.props.items, b => -b.size).map((item, index) => {
  248. return (
  249. <Bubble
  250. color={item.color}
  251. data={item.data}
  252. key={item.key || index}
  253. link={item.link}
  254. onClick={this.props.onBubbleClick}
  255. r={sizeScale(item.size)}
  256. scale={1 / transform.k}
  257. tooltip={item.tooltip}
  258. x={xScale(item.x)}
  259. y={yScale(item.y)}
  260. />
  261. );
  262. });
  263. const xTicks = this.getTicks(xScale, this.props.formatXTick);
  264. const yTicks = this.getTicks(yScale, this.props.formatYTick);
  265. return (
  266. <svg
  267. className={classNames('bubble-chart')}
  268. height={this.props.height}
  269. ref={this.boundNode}
  270. width={width}>
  271. <defs>
  272. <clipPath id="graph-region">
  273. <rect
  274. // Extend clip by 2 pixels: one for clipRect border, and one for Bubble borders
  275. height={availableHeight + 4}
  276. width={availableWidth + 4}
  277. x={-2}
  278. y={-2}
  279. />
  280. </clipPath>
  281. </defs>
  282. <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
  283. <g clipPath="url(#graph-region)">
  284. {this.renderXGrid(xTicks, xScale, yScale)}
  285. {this.renderYGrid(yTicks, xScale, yScale)}
  286. <g transform={`translate(${transform.x}, ${transform.y}) scale(${transform.k})`}>
  287. {bubbles}
  288. </g>
  289. </g>
  290. {this.renderXTicks(xTicks, xScale, yScaleOriginal)}
  291. {this.renderYTicks(yTicks, xScaleOriginal, yScale)}
  292. </g>
  293. </svg>
  294. );
  295. };
  296. render() {
  297. return (
  298. <div>
  299. <div className="bubble-chart-zoom">
  300. <Tooltip overlay={translate('component_measures.bubble_chart.zoom_level')}>
  301. <Link onClick={this.resetZoom} to="#">
  302. {this.getZoomLevelLabel()}
  303. </Link>
  304. </Tooltip>
  305. </div>
  306. <AutoSizer disableHeight={true}>{size => this.renderChart(size.width)}</AutoSizer>
  307. </div>
  308. );
  309. }
  310. }
  311. interface BubbleProps<T> {
  312. color?: string;
  313. link?: string | Location;
  314. onClick?: (ref?: T) => void;
  315. data?: T;
  316. r: number;
  317. scale: number;
  318. tooltip?: string | React.ReactNode;
  319. x: number;
  320. y: number;
  321. }
  322. function Bubble<T>(props: BubbleProps<T>) {
  323. const handleClick = (e: React.MouseEvent<SVGCircleElement>) => {
  324. if (props.onClick) {
  325. e.stopPropagation();
  326. e.preventDefault();
  327. props.onClick(props.data);
  328. }
  329. };
  330. let circle = (
  331. <circle
  332. className="bubble-chart-bubble"
  333. onClick={props.onClick ? handleClick : undefined}
  334. r={props.r}
  335. style={{ fill: props.color, stroke: props.color }}
  336. transform={`translate(${props.x}, ${props.y}) scale(${props.scale})`}
  337. />
  338. );
  339. if (props.link && !props.onClick) {
  340. circle = <Link to={props.link}>{circle}</Link>;
  341. }
  342. return (
  343. <Tooltip overlay={props.tooltip || undefined}>
  344. <g>{circle}</g>
  345. </Tooltip>
  346. );
  347. }