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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
  21. import styled from '@emotion/styled';
  22. import classNames from 'classnames';
  23. import { max, min } from 'd3-array';
  24. import { ScaleLinear, scaleLinear } from 'd3-scale';
  25. import { select } from 'd3-selection';
  26. import { D3ZoomEvent, ZoomBehavior, zoom, zoomIdentity } from 'd3-zoom';
  27. import { sortBy, uniq } from 'lodash';
  28. import * as React from 'react';
  29. import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer';
  30. import tw from 'twin.macro';
  31. import { themeColor, themeContrast } from '../helpers';
  32. import { BubbleColorVal } from '../types/charts';
  33. import { Note } from './Text';
  34. import { Tooltip } from './Tooltip';
  35. import { ButtonSecondary } from './buttons';
  36. const TICKS_COUNT = 5;
  37. interface BubbleItem<T> {
  38. color?: BubbleColorVal;
  39. data?: T;
  40. key?: string;
  41. size: number;
  42. tooltip?: React.ReactNode;
  43. x: number;
  44. y: number;
  45. }
  46. export interface BubbleChartProps<T> {
  47. 'data-testid'?: string;
  48. displayXGrid?: boolean;
  49. displayXTicks?: boolean;
  50. displayYGrid?: boolean;
  51. displayYTicks?: boolean;
  52. formatXTick: (tick: number) => string;
  53. formatYTick: (tick: number) => string;
  54. height: number;
  55. items: Array<BubbleItem<T>>;
  56. onBubbleClick?: (ref?: T) => void;
  57. padding: [number, number, number, number];
  58. sizeDomain?: [number, number];
  59. sizeRange?: [number, number];
  60. xDomain?: [number, number];
  61. yDomain?: [number, number];
  62. zoomLabel?: string;
  63. zoomResetLabel?: string;
  64. zoomTooltipText?: string;
  65. }
  66. type Scale = ScaleLinear<number, number>;
  67. BubbleChart.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. export function BubbleChart<T>(props: BubbleChartProps<T>) {
  78. const {
  79. padding,
  80. height,
  81. items,
  82. xDomain,
  83. yDomain,
  84. sizeDomain,
  85. sizeRange,
  86. zoomResetLabel = 'Reset',
  87. zoomTooltipText,
  88. zoomLabel = 'Zoom',
  89. displayXTicks,
  90. displayYTicks,
  91. displayXGrid,
  92. displayYGrid,
  93. formatXTick,
  94. formatYTick,
  95. } = props;
  96. const [transform, setTransform] = React.useState({ x: 0, y: 0, k: 1 });
  97. const nodeRef = React.useRef<SVGSVGElement>();
  98. const zoomRef = React.useRef<ZoomBehavior<Element, unknown>>();
  99. const zoomLevelLabel = `${Math.floor(transform.k * 100)}%`;
  100. if (zoomRef.current && nodeRef.current) {
  101. const rect = nodeRef.current.getBoundingClientRect();
  102. zoomRef.current.translateExtent([
  103. [0, 0],
  104. [rect.width, rect.height],
  105. ]);
  106. }
  107. const zoomed = React.useCallback(
  108. (event: D3ZoomEvent<SVGSVGElement, void>) => {
  109. const { x, y, k } = event.transform;
  110. setTransform({
  111. x: x + padding[3] * (k - 1),
  112. y: y + padding[0] * (k - 1),
  113. k,
  114. });
  115. },
  116. [padding],
  117. );
  118. const boundNode = React.useCallback(
  119. (node: SVGSVGElement) => {
  120. nodeRef.current = node;
  121. zoomRef.current = zoom().scaleExtent([1, 10]).on('zoom', zoomed);
  122. select(nodeRef.current).call(zoomRef.current);
  123. },
  124. [zoomed],
  125. );
  126. const resetZoom = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
  127. e.stopPropagation();
  128. e.preventDefault();
  129. if (zoomRef.current && nodeRef.current) {
  130. select(nodeRef.current).call(zoomRef.current.transform, zoomIdentity);
  131. }
  132. }, []);
  133. const getXRange = React.useCallback(
  134. (xScale: Scale, sizeScale: Scale, availableWidth: number) => {
  135. const [x1, x2] = xScale.range();
  136. const minX = min(items, (d) => xScale(d.x) - sizeScale(d.size)) ?? 0;
  137. const maxX = max(items, (d) => xScale(d.x) + sizeScale(d.size)) ?? 0;
  138. const dMinX = minX < 0 ? x1 - minX : x1;
  139. const dMaxX = maxX > x2 ? maxX - x2 : 0;
  140. return [dMinX, availableWidth - dMaxX];
  141. },
  142. [items],
  143. );
  144. const getYRange = React.useCallback(
  145. (yScale: Scale, sizeScale: Scale, availableHeight: number) => {
  146. const [y1, y2] = yScale.range();
  147. const minY = min(items, (d) => yScale(d.y) - sizeScale(d.size)) ?? 0;
  148. const maxY = max(items, (d) => yScale(d.y) + sizeScale(d.size)) ?? 0;
  149. const dMinY = minY < 0 ? y2 - minY : y2;
  150. const dMaxY = maxY > y1 ? maxY - y1 : 0;
  151. return [availableHeight - dMaxY, dMinY];
  152. },
  153. [items],
  154. );
  155. const getTicks = React.useCallback(
  156. (scale: Scale, format: (d: number) => string) => {
  157. const zoomAmount = Math.ceil(transform.k);
  158. const ticks = scale.ticks(TICKS_COUNT * zoomAmount).map((tick) => format(tick));
  159. const uniqueTicksCount = uniq(ticks).length;
  160. const ticksCount =
  161. uniqueTicksCount < TICKS_COUNT * zoomAmount
  162. ? uniqueTicksCount - 1
  163. : TICKS_COUNT * zoomAmount;
  164. return scale.ticks(ticksCount);
  165. },
  166. [transform],
  167. );
  168. const renderXGrid = React.useCallback(
  169. (ticks: number[], xScale: Scale, yScale: Scale) => {
  170. if (!displayXGrid) {
  171. return null;
  172. }
  173. const lines = ticks.map((tick, index) => {
  174. const x = xScale(tick);
  175. const [y1, y2] = yScale.range();
  176. return (
  177. <BubbleChartGrid
  178. // eslint-disable-next-line react/no-array-index-key
  179. key={index}
  180. x1={x * transform.k + transform.x}
  181. x2={x * transform.k + transform.x}
  182. y1={y1 * transform.k}
  183. y2={transform.k > 1 ? 0 : y2}
  184. />
  185. );
  186. });
  187. return <g>{lines}</g>;
  188. },
  189. [transform, displayXGrid],
  190. );
  191. const renderYGrid = React.useCallback(
  192. (ticks: number[], xScale: Scale, yScale: Scale) => {
  193. if (!displayYGrid) {
  194. return null;
  195. }
  196. const lines = ticks.map((tick, index) => {
  197. const y = yScale(tick);
  198. const [x1, x2] = xScale.range();
  199. return (
  200. <BubbleChartGrid
  201. // eslint-disable-next-line react/no-array-index-key
  202. key={index}
  203. x1={transform.k > 1 ? 0 : x1}
  204. x2={x2 * transform.k}
  205. y1={y * transform.k + transform.y}
  206. y2={y * transform.k + transform.y}
  207. />
  208. );
  209. });
  210. return <g>{lines}</g>;
  211. },
  212. [displayYGrid, transform],
  213. );
  214. const renderXTicks = React.useCallback(
  215. (xTicks: number[], xScale: Scale, yScale: Scale) => {
  216. if (!displayXTicks) {
  217. return null;
  218. }
  219. const ticks = xTicks.map((tick, index) => {
  220. const x = xScale(tick) * transform.k + transform.x;
  221. const y = yScale.range()[0];
  222. const innerText = formatXTick(tick);
  223. // as we modified the `x` using `transform`, check that it is inside the range again
  224. return x > 0 && x < xScale.range()[1] ? (
  225. // eslint-disable-next-line react/no-array-index-key
  226. <BubbleChartTick dy="1.5em" key={index} style={{ '--align': 'middle' }} x={x} y={y}>
  227. {innerText}
  228. </BubbleChartTick>
  229. ) : null;
  230. });
  231. return <g>{ticks}</g>;
  232. },
  233. [displayXTicks, formatXTick, transform],
  234. );
  235. const renderYTicks = React.useCallback(
  236. (yTicks: number[], xScale: Scale, yScale: Scale) => {
  237. if (!displayYTicks) {
  238. return null;
  239. }
  240. const ticks = yTicks.map((tick, index) => {
  241. const x = xScale.range()[0];
  242. const y = yScale(tick) * transform.k + transform.y;
  243. const innerText = formatYTick(tick);
  244. // as we modified the `y` using `transform`, check that it is inside the range again
  245. return y > 0 && y < yScale.range()[0] ? (
  246. <BubbleChartTick
  247. dx="-0.5em"
  248. dy="0.3em"
  249. // eslint-disable-next-line react/no-array-index-key
  250. key={index}
  251. style={{ '--align': 'end' }}
  252. x={x}
  253. y={y}
  254. >
  255. {innerText}
  256. </BubbleChartTick>
  257. ) : null;
  258. });
  259. return <g>{ticks}</g>;
  260. },
  261. [displayYTicks, formatYTick, transform],
  262. );
  263. const renderChart = (width: number) => {
  264. const availableWidth = width - padding[1] - padding[3];
  265. const availableHeight = height - padding[0] - padding[2];
  266. const xScale = scaleLinear()
  267. .domain(xDomain ?? [0, max(items, (d) => d.x) ?? 0])
  268. .range([0, availableWidth])
  269. .nice();
  270. const yScale = scaleLinear()
  271. .domain(yDomain ?? [0, max(items, (d) => d.y) ?? 0])
  272. .range([availableHeight, 0])
  273. .nice();
  274. const sizeScale = scaleLinear()
  275. .domain(sizeDomain ?? [0, max(items, (d) => d.size) ?? 0])
  276. .range(sizeRange ?? []);
  277. const xScaleOriginal = xScale.copy();
  278. const yScaleOriginal = yScale.copy();
  279. xScale.range(getXRange(xScale, sizeScale, availableWidth));
  280. yScale.range(getYRange(yScale, sizeScale, availableHeight));
  281. const bubbles = sortBy(items, (b) => -b.size).map((item, index) => {
  282. return (
  283. <Bubble
  284. color={item.color}
  285. data={item.data}
  286. key={item.key ?? index}
  287. onClick={props.onBubbleClick}
  288. r={sizeScale(item.size)}
  289. scale={1 / transform.k}
  290. tooltip={item.tooltip}
  291. x={xScale(item.x)}
  292. y={yScale(item.y)}
  293. />
  294. );
  295. });
  296. const xTicks = getTicks(xScale, props.formatXTick);
  297. const yTicks = getTicks(yScale, props.formatYTick);
  298. return (
  299. <svg
  300. className={classNames('bubble-chart')}
  301. data-testid={props['data-testid']}
  302. height={height}
  303. ref={boundNode}
  304. width={width}
  305. >
  306. <defs>
  307. <clipPath id="graph-region">
  308. <rect
  309. // Extend clip by 2 pixels: one for clipRect border, and one for Bubble borders
  310. height={availableHeight + 4}
  311. width={availableWidth + 4}
  312. x={-2}
  313. y={-2}
  314. />
  315. </clipPath>
  316. </defs>
  317. <g transform={`translate(${padding[3]}, ${padding[0]})`}>
  318. <g clipPath="url(#graph-region)">
  319. {renderXGrid(xTicks, xScale, yScale)}
  320. {renderYGrid(yTicks, xScale, yScale)}
  321. <g transform={`translate(${transform.x}, ${transform.y}) scale(${transform.k})`}>
  322. {bubbles}
  323. </g>
  324. </g>
  325. {renderXTicks(xTicks, xScale, yScaleOriginal)}
  326. {renderYTicks(yTicks, xScaleOriginal, yScale)}
  327. </g>
  328. </svg>
  329. );
  330. };
  331. return (
  332. <div>
  333. <div className="sw-flex sw-items-center sw-justify-end sw-h-control sw-mb-4">
  334. <Tooltip overlay={zoomTooltipText}>
  335. <span>
  336. <Note className="sw-body-sm-highlight">{zoomLabel}</Note>
  337. {': '}
  338. {zoomLevelLabel}
  339. </span>
  340. </Tooltip>
  341. {zoomLevelLabel !== '100%' && (
  342. <ButtonSecondary
  343. className="sw-ml-2"
  344. disabled={zoomLevelLabel === '100%'}
  345. onClick={resetZoom}
  346. >
  347. {zoomResetLabel}
  348. </ButtonSecondary>
  349. )}
  350. </div>
  351. <AutoSizer disableHeight>{(size) => renderChart(size.width)}</AutoSizer>
  352. </div>
  353. );
  354. }
  355. interface BubbleProps<T> {
  356. color?: BubbleColorVal;
  357. data?: T;
  358. onClick?: (ref?: T) => void;
  359. r: number;
  360. scale: number;
  361. tooltip?: string | React.ReactNode;
  362. x: number;
  363. y: number;
  364. }
  365. function Bubble<T>(props: BubbleProps<T>) {
  366. const theme = useTheme();
  367. const { color, data, onClick, r, scale, tooltip, x, y } = props;
  368. const handleClick = React.useCallback(
  369. (event: React.MouseEvent<HTMLAnchorElement>) => {
  370. event.stopPropagation();
  371. event.preventDefault();
  372. onClick?.(data);
  373. },
  374. [data, onClick],
  375. );
  376. const circle = (
  377. <a href="#" onClick={handleClick}>
  378. <BubbleStyled
  379. r={r}
  380. style={{
  381. fill: color ? themeColor(`bubble.${color}`)({ theme }) : '',
  382. stroke: color ? themeContrast(`bubble.${color}`)({ theme }) : '',
  383. }}
  384. transform={`translate(${x}, ${y}) scale(${scale})`}
  385. />
  386. </a>
  387. );
  388. return <Tooltip overlay={tooltip}>{circle}</Tooltip>;
  389. }
  390. const BubbleStyled = styled.circle`
  391. ${tw`sw-cursor-pointer`}
  392. transition: fill-opacity 0.2s ease;
  393. fill: ${themeColor('bubbleDefault')};
  394. stroke: ${themeContrast('bubbleDefault')};
  395. &:hover {
  396. fill-opacity: 0.8;
  397. }
  398. `;
  399. const BubbleChartGrid = styled.line`
  400. shape-rendering: crispedges;
  401. stroke: ${themeColor('bubbleChartLine')};
  402. `;
  403. const BubbleChartTick = styled.text`
  404. ${tw`sw-body-sm`}
  405. ${tw`sw-select-none`}
  406. fill: ${themeColor('pageContentLight')};
  407. text-anchor: var(--align);
  408. `;