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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  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 { 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 { ButtonSecondary } from '../sonar-aligned/components/buttons';
  33. import { BubbleColorVal } from '../types/charts';
  34. import { Note } from './Text';
  35. import { Tooltip } from './Tooltip';
  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. // @ts-expect-error Type instantiation is excessively deep and possibly infinite.
  123. select(nodeRef.current).call(zoomRef.current);
  124. },
  125. [zoomed],
  126. );
  127. const resetZoom = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
  128. e.stopPropagation();
  129. e.preventDefault();
  130. if (zoomRef.current && nodeRef.current) {
  131. select(nodeRef.current).call(zoomRef.current.transform, zoomIdentity);
  132. }
  133. }, []);
  134. const getXRange = React.useCallback(
  135. (xScale: Scale, sizeScale: Scale, availableWidth: number) => {
  136. const [x1, x2] = xScale.range();
  137. const minX = min(items, (d) => xScale(d.x) - sizeScale(d.size)) ?? 0;
  138. const maxX = max(items, (d) => xScale(d.x) + sizeScale(d.size)) ?? 0;
  139. const dMinX = minX < 0 ? x1 - minX : x1;
  140. const dMaxX = maxX > x2 ? maxX - x2 : 0;
  141. return [dMinX, availableWidth - dMaxX];
  142. },
  143. [items],
  144. );
  145. const getYRange = React.useCallback(
  146. (yScale: Scale, sizeScale: Scale, availableHeight: number) => {
  147. const [y1, y2] = yScale.range();
  148. const minY = min(items, (d) => yScale(d.y) - sizeScale(d.size)) ?? 0;
  149. const maxY = max(items, (d) => yScale(d.y) + sizeScale(d.size)) ?? 0;
  150. const dMinY = minY < 0 ? y2 - minY : y2;
  151. const dMaxY = maxY > y1 ? maxY - y1 : 0;
  152. return [availableHeight - dMaxY, dMinY];
  153. },
  154. [items],
  155. );
  156. const getTicks = React.useCallback(
  157. (scale: Scale, format: (d: number) => string) => {
  158. const zoomAmount = Math.ceil(transform.k);
  159. const ticks = scale.ticks(TICKS_COUNT * zoomAmount).map((tick) => format(tick));
  160. const uniqueTicksCount = uniq(ticks).length;
  161. const ticksCount =
  162. uniqueTicksCount < TICKS_COUNT * zoomAmount
  163. ? uniqueTicksCount - 1
  164. : TICKS_COUNT * zoomAmount;
  165. return scale.ticks(ticksCount);
  166. },
  167. [transform],
  168. );
  169. const renderXGrid = React.useCallback(
  170. (ticks: number[], xScale: Scale, yScale: Scale) => {
  171. if (!displayXGrid) {
  172. return null;
  173. }
  174. const lines = ticks.map((tick, index) => {
  175. const x = xScale(tick);
  176. const [y1, y2] = yScale.range();
  177. return (
  178. <BubbleChartGrid
  179. // eslint-disable-next-line react/no-array-index-key
  180. key={index}
  181. x1={x * transform.k + transform.x}
  182. x2={x * transform.k + transform.x}
  183. y1={y1 * transform.k}
  184. y2={transform.k > 1 ? 0 : y2}
  185. />
  186. );
  187. });
  188. return <g>{lines}</g>;
  189. },
  190. [transform, displayXGrid],
  191. );
  192. const renderYGrid = React.useCallback(
  193. (ticks: number[], xScale: Scale, yScale: Scale) => {
  194. if (!displayYGrid) {
  195. return null;
  196. }
  197. const lines = ticks.map((tick, index) => {
  198. const y = yScale(tick);
  199. const [x1, x2] = xScale.range();
  200. return (
  201. <BubbleChartGrid
  202. // eslint-disable-next-line react/no-array-index-key
  203. key={index}
  204. x1={transform.k > 1 ? 0 : x1}
  205. x2={x2 * transform.k}
  206. y1={y * transform.k + transform.y}
  207. y2={y * transform.k + transform.y}
  208. />
  209. );
  210. });
  211. return <g>{lines}</g>;
  212. },
  213. [displayYGrid, transform],
  214. );
  215. const renderXTicks = React.useCallback(
  216. (xTicks: number[], xScale: Scale, yScale: Scale) => {
  217. if (!displayXTicks) {
  218. return null;
  219. }
  220. const ticks = xTicks.map((tick, index) => {
  221. const x = xScale(tick) * transform.k + transform.x;
  222. const y = yScale.range()[0];
  223. const innerText = formatXTick(tick);
  224. // as we modified the `x` using `transform`, check that it is inside the range again
  225. return x > 0 && x < xScale.range()[1] ? (
  226. // eslint-disable-next-line react/no-array-index-key
  227. <BubbleChartTick dy="1.5em" key={index} style={{ '--align': 'middle' }} x={x} y={y}>
  228. {innerText}
  229. </BubbleChartTick>
  230. ) : null;
  231. });
  232. return <g>{ticks}</g>;
  233. },
  234. [displayXTicks, formatXTick, transform],
  235. );
  236. const renderYTicks = React.useCallback(
  237. (yTicks: number[], xScale: Scale, yScale: Scale) => {
  238. if (!displayYTicks) {
  239. return null;
  240. }
  241. const ticks = yTicks.map((tick, index) => {
  242. const x = xScale.range()[0];
  243. const y = yScale(tick) * transform.k + transform.y;
  244. const innerText = formatYTick(tick);
  245. // as we modified the `y` using `transform`, check that it is inside the range again
  246. return y > 0 && y < yScale.range()[0] ? (
  247. <BubbleChartTick
  248. dx="-0.5em"
  249. dy="0.3em"
  250. // eslint-disable-next-line react/no-array-index-key
  251. key={index}
  252. style={{ '--align': 'end' }}
  253. x={x}
  254. y={y}
  255. >
  256. {innerText}
  257. </BubbleChartTick>
  258. ) : null;
  259. });
  260. return <g>{ticks}</g>;
  261. },
  262. [displayYTicks, formatYTick, transform],
  263. );
  264. const renderChart = (width: number) => {
  265. const availableWidth = width - padding[1] - padding[3];
  266. const availableHeight = height - padding[0] - padding[2];
  267. const xScale = scaleLinear()
  268. .domain(xDomain ?? [0, max(items, (d) => d.x) ?? 0])
  269. .range([0, availableWidth])
  270. .nice();
  271. const yScale = scaleLinear()
  272. .domain(yDomain ?? [0, max(items, (d) => d.y) ?? 0])
  273. .range([availableHeight, 0])
  274. .nice();
  275. const sizeScale = scaleLinear()
  276. .domain(sizeDomain ?? [0, max(items, (d) => d.size) ?? 0])
  277. .range(sizeRange ?? []);
  278. const xScaleOriginal = xScale.copy();
  279. const yScaleOriginal = yScale.copy();
  280. xScale.range(getXRange(xScale, sizeScale, availableWidth));
  281. yScale.range(getYRange(yScale, sizeScale, availableHeight));
  282. const bubbles = sortBy(items, (b) => -b.size).map((item, index) => {
  283. return (
  284. <Bubble
  285. color={item.color}
  286. data={item.data}
  287. key={item.key ?? index}
  288. onClick={props.onBubbleClick}
  289. r={sizeScale(item.size)}
  290. scale={1 / transform.k}
  291. tooltip={item.tooltip}
  292. x={xScale(item.x)}
  293. y={yScale(item.y)}
  294. />
  295. );
  296. });
  297. const xTicks = getTicks(xScale, props.formatXTick);
  298. const yTicks = getTicks(yScale, props.formatYTick);
  299. return (
  300. <svg
  301. className={classNames('bubble-chart')}
  302. data-testid={props['data-testid']}
  303. height={height}
  304. ref={boundNode}
  305. width={width}
  306. >
  307. <defs>
  308. <clipPath id="graph-region">
  309. <rect
  310. // Extend clip by 2 pixels: one for clipRect border, and one for Bubble borders
  311. height={availableHeight + 4}
  312. width={availableWidth + 4}
  313. x={-2}
  314. y={-2}
  315. />
  316. </clipPath>
  317. </defs>
  318. <g transform={`translate(${padding[3]}, ${padding[0]})`}>
  319. <g clipPath="url(#graph-region)">
  320. {renderXGrid(xTicks, xScale, yScale)}
  321. {renderYGrid(yTicks, xScale, yScale)}
  322. <g transform={`translate(${transform.x}, ${transform.y}) scale(${transform.k})`}>
  323. {bubbles}
  324. </g>
  325. </g>
  326. {renderXTicks(xTicks, xScale, yScaleOriginal)}
  327. {renderYTicks(yTicks, xScaleOriginal, yScale)}
  328. </g>
  329. </svg>
  330. );
  331. };
  332. return (
  333. <div>
  334. <div className="sw-flex sw-items-center sw-justify-end sw-h-control sw-mb-4">
  335. <Tooltip overlay={zoomTooltipText}>
  336. <span>
  337. <Note className="sw-body-sm-highlight">{zoomLabel}</Note>
  338. {': '}
  339. {zoomLevelLabel}
  340. </span>
  341. </Tooltip>
  342. {zoomLevelLabel !== '100%' && (
  343. <ButtonSecondary
  344. className="sw-ml-2"
  345. disabled={zoomLevelLabel === '100%'}
  346. onClick={resetZoom}
  347. >
  348. {zoomResetLabel}
  349. </ButtonSecondary>
  350. )}
  351. </div>
  352. <AutoSizer disableHeight>{(size) => renderChart(size.width)}</AutoSizer>
  353. </div>
  354. );
  355. }
  356. interface BubbleProps<T> {
  357. color?: BubbleColorVal;
  358. data?: T;
  359. onClick?: (ref?: T) => void;
  360. r: number;
  361. scale: number;
  362. tooltip?: string | React.ReactNode;
  363. x: number;
  364. y: number;
  365. }
  366. function Bubble<T>(props: BubbleProps<T>) {
  367. const theme = useTheme();
  368. const { color, data, onClick, r, scale, tooltip, x, y } = props;
  369. const handleClick = React.useCallback(
  370. (event: React.MouseEvent<HTMLAnchorElement>) => {
  371. event.stopPropagation();
  372. event.preventDefault();
  373. onClick?.(data);
  374. },
  375. [data, onClick],
  376. );
  377. const circle = (
  378. <a href="#" onClick={handleClick}>
  379. <BubbleStyled
  380. r={r}
  381. style={{
  382. fill: color ? themeColor(`bubble.${color}`)({ theme }) : '',
  383. stroke: color ? themeContrast(`bubble.${color}`)({ theme }) : '',
  384. }}
  385. transform={`translate(${x}, ${y}) scale(${scale})`}
  386. />
  387. </a>
  388. );
  389. return <Tooltip overlay={tooltip}>{circle}</Tooltip>;
  390. }
  391. const BubbleStyled = styled.circle`
  392. ${tw`sw-cursor-pointer`}
  393. transition: fill-opacity 0.2s ease;
  394. fill: ${themeColor('bubbleDefault')};
  395. stroke: ${themeContrast('bubbleDefault')};
  396. &:hover {
  397. fill-opacity: 0.8;
  398. }
  399. `;
  400. const BubbleChartGrid = styled.line`
  401. shape-rendering: crispedges;
  402. stroke: ${themeColor('bubbleChartLine')};
  403. `;
  404. const BubbleChartTick = styled.text`
  405. ${tw`sw-body-sm`}
  406. ${tw`sw-select-none`}
  407. fill: ${themeColor('pageContentLight')};
  408. text-anchor: var(--align);
  409. `;