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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  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().scaleExtent([1, 10]).on('zoom', this.zoomed);
  93. select(this.node).call(this.zoom);
  94. };
  95. zoomed = () => {
  96. const { padding } = this.props;
  97. const { x, y, k } = event.transform as { x: number; y: number; k: number };
  98. this.setState({
  99. transform: {
  100. x: x + padding[3] * (k - 1),
  101. y: y + padding[0] * (k - 1),
  102. k,
  103. },
  104. });
  105. };
  106. resetZoom = (event: React.MouseEvent<Link>) => {
  107. event.stopPropagation();
  108. event.preventDefault();
  109. if (this.zoom && this.node) {
  110. select(this.node).call(this.zoom.transform, zoomIdentity);
  111. }
  112. };
  113. getXRange(xScale: Scale, sizeScale: Scale, availableWidth: number) {
  114. const minX = min(this.props.items, (d) => xScale(d.x) - sizeScale(d.size)) || 0;
  115. const maxX = max(this.props.items, (d) => xScale(d.x) + sizeScale(d.size)) || 0;
  116. const dMinX = minX < 0 ? xScale.range()[0] - minX : xScale.range()[0];
  117. const dMaxX = maxX > xScale.range()[1] ? maxX - xScale.range()[1] : 0;
  118. return [dMinX, availableWidth - dMaxX];
  119. }
  120. getYRange(yScale: Scale, sizeScale: Scale, availableHeight: number) {
  121. const minY = min(this.props.items, (d) => yScale(d.y) - sizeScale(d.size)) || 0;
  122. const maxY = max(this.props.items, (d) => yScale(d.y) + sizeScale(d.size)) || 0;
  123. const dMinY = minY < 0 ? yScale.range()[1] - minY : yScale.range()[1];
  124. const dMaxY = maxY > yScale.range()[0] ? maxY - yScale.range()[0] : 0;
  125. return [availableHeight - dMaxY, dMinY];
  126. }
  127. getTicks(scale: Scale, format: (d: number) => string) {
  128. const zoom = Math.ceil(this.state.transform.k);
  129. const ticks = scale.ticks(TICKS_COUNT * zoom).map((tick) => format(tick));
  130. const uniqueTicksCount = uniq(ticks).length;
  131. const ticksCount =
  132. uniqueTicksCount < TICKS_COUNT * zoom ? uniqueTicksCount - 1 : TICKS_COUNT * zoom;
  133. return scale.ticks(ticksCount);
  134. }
  135. getZoomLevelLabel = () => Math.floor(this.state.transform.k * 100) + '%';
  136. renderXGrid = (ticks: number[], xScale: Scale, yScale: Scale) => {
  137. if (!this.props.displayXGrid) {
  138. return null;
  139. }
  140. const { transform } = this.state;
  141. const lines = ticks.map((tick, index) => {
  142. const x = xScale(tick);
  143. const y1 = yScale.range()[0];
  144. const y2 = yScale.range()[1];
  145. return (
  146. <line
  147. className="bubble-chart-grid"
  148. key={index}
  149. x1={x * transform.k + transform.x}
  150. x2={x * transform.k + transform.x}
  151. y1={y1 * transform.k}
  152. y2={transform.k > 1 ? 0 : y2}
  153. />
  154. );
  155. });
  156. return <g>{lines}</g>;
  157. };
  158. renderYGrid = (ticks: number[], xScale: Scale, yScale: Scale) => {
  159. if (!this.props.displayYGrid) {
  160. return null;
  161. }
  162. const { transform } = this.state;
  163. const lines = ticks.map((tick, index) => {
  164. const y = yScale(tick);
  165. const x1 = xScale.range()[0];
  166. const x2 = xScale.range()[1];
  167. return (
  168. <line
  169. className="bubble-chart-grid"
  170. key={index}
  171. x1={transform.k > 1 ? 0 : x1}
  172. x2={x2 * transform.k}
  173. y1={y * transform.k + transform.y}
  174. y2={y * transform.k + transform.y}
  175. />
  176. );
  177. });
  178. return <g>{lines}</g>;
  179. };
  180. renderXTicks = (xTicks: number[], xScale: Scale, yScale: Scale) => {
  181. if (!this.props.displayXTicks) {
  182. return null;
  183. }
  184. const { transform } = this.state;
  185. const ticks = xTicks.map((tick, index) => {
  186. const x = xScale(tick) * transform.k + transform.x;
  187. const y = yScale.range()[0];
  188. const innerText = this.props.formatXTick(tick);
  189. // as we modified the `x` using `transform`, check that it is inside the range again
  190. return x > 0 && x < xScale.range()[1] ? (
  191. <text className="bubble-chart-tick" dy="1.5em" key={index} x={x} y={y}>
  192. {innerText}
  193. </text>
  194. ) : null;
  195. });
  196. return <g>{ticks}</g>;
  197. };
  198. renderYTicks = (yTicks: number[], xScale: Scale, yScale: Scale) => {
  199. if (!this.props.displayYTicks) {
  200. return null;
  201. }
  202. const { transform } = this.state;
  203. const ticks = yTicks.map((tick, index) => {
  204. const x = xScale.range()[0];
  205. const y = yScale(tick) * transform.k + transform.y;
  206. const innerText = this.props.formatYTick(tick);
  207. // as we modified the `y` using `transform`, check that it is inside the range again
  208. return y > 0 && y < yScale.range()[0] ? (
  209. <text
  210. className="bubble-chart-tick bubble-chart-tick-y"
  211. dx="-0.5em"
  212. dy="0.3em"
  213. key={index}
  214. x={x}
  215. y={y}>
  216. {innerText}
  217. </text>
  218. ) : null;
  219. });
  220. return <g>{ticks}</g>;
  221. };
  222. renderChart = (width: number) => {
  223. const { transform } = this.state;
  224. const availableWidth = width - this.props.padding[1] - this.props.padding[3];
  225. const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2];
  226. const xScale = scaleLinear()
  227. .domain(this.props.xDomain || [0, max(this.props.items, (d) => d.x) || 0])
  228. .range([0, availableWidth])
  229. .nice();
  230. const yScale = scaleLinear()
  231. .domain(this.props.yDomain || [0, max(this.props.items, (d) => d.y) || 0])
  232. .range([availableHeight, 0])
  233. .nice();
  234. const sizeScale = scaleLinear()
  235. .domain(this.props.sizeDomain || [0, max(this.props.items, (d) => d.size) || 0])
  236. .range(this.props.sizeRange || []);
  237. const xScaleOriginal = xScale.copy();
  238. const yScaleOriginal = yScale.copy();
  239. xScale.range(this.getXRange(xScale, sizeScale, availableWidth));
  240. yScale.range(this.getYRange(yScale, sizeScale, availableHeight));
  241. const bubbles = sortBy(this.props.items, (b) => -b.size).map((item, index) => {
  242. return (
  243. <Bubble
  244. color={item.color}
  245. data={item.data}
  246. key={item.key || index}
  247. link={item.link}
  248. onClick={this.props.onBubbleClick}
  249. r={sizeScale(item.size)}
  250. scale={1 / transform.k}
  251. tooltip={item.tooltip}
  252. x={xScale(item.x)}
  253. y={yScale(item.y)}
  254. />
  255. );
  256. });
  257. const xTicks = this.getTicks(xScale, this.props.formatXTick);
  258. const yTicks = this.getTicks(yScale, this.props.formatYTick);
  259. return (
  260. <svg
  261. className={classNames('bubble-chart')}
  262. height={this.props.height}
  263. ref={this.boundNode}
  264. width={width}>
  265. <defs>
  266. <clipPath id="graph-region">
  267. <rect
  268. // Extend clip by 2 pixels: one for clipRect border, and one for Bubble borders
  269. height={availableHeight + 4}
  270. width={availableWidth + 4}
  271. x={-2}
  272. y={-2}
  273. />
  274. </clipPath>
  275. </defs>
  276. <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
  277. <g clipPath="url(#graph-region)">
  278. {this.renderXGrid(xTicks, xScale, yScale)}
  279. {this.renderYGrid(yTicks, xScale, yScale)}
  280. <g transform={`translate(${transform.x}, ${transform.y}) scale(${transform.k})`}>
  281. {bubbles}
  282. </g>
  283. </g>
  284. {this.renderXTicks(xTicks, xScale, yScaleOriginal)}
  285. {this.renderYTicks(yTicks, xScaleOriginal, yScale)}
  286. </g>
  287. </svg>
  288. );
  289. };
  290. render() {
  291. return (
  292. <div>
  293. <div className="bubble-chart-zoom">
  294. <Tooltip overlay={translate('component_measures.bubble_chart.zoom_level')}>
  295. <Link onClick={this.resetZoom} to="#">
  296. {this.getZoomLevelLabel()}
  297. </Link>
  298. </Tooltip>
  299. </div>
  300. <AutoSizer disableHeight={true}>{(size) => this.renderChart(size.width)}</AutoSizer>
  301. </div>
  302. );
  303. }
  304. }
  305. interface BubbleProps<T> {
  306. color?: string;
  307. link?: string | Location;
  308. onClick?: (ref?: T) => void;
  309. data?: T;
  310. r: number;
  311. scale: number;
  312. tooltip?: string | React.ReactNode;
  313. x: number;
  314. y: number;
  315. }
  316. function Bubble<T>(props: BubbleProps<T>) {
  317. const handleClick = (event: React.MouseEvent<SVGCircleElement>) => {
  318. if (props.onClick) {
  319. event.stopPropagation();
  320. event.preventDefault();
  321. props.onClick(props.data);
  322. }
  323. };
  324. let circle = (
  325. <circle
  326. className="bubble-chart-bubble"
  327. onClick={props.onClick ? handleClick : undefined}
  328. r={props.r}
  329. style={{ fill: props.color, stroke: props.color }}
  330. transform={`translate(${props.x}, ${props.y}) scale(${props.scale})`}
  331. />
  332. );
  333. if (props.link && !props.onClick) {
  334. circle = <Link to={props.link}>{circle}</Link>;
  335. }
  336. return (
  337. <Tooltip overlay={props.tooltip || undefined}>
  338. <g>{circle}</g>
  339. </Tooltip>
  340. );
  341. }