123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450 |
- /*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
- import { useTheme } from '@emotion/react';
- import styled from '@emotion/styled';
- import classNames from 'classnames';
- import { max, min } from 'd3-array';
- import { ScaleLinear, scaleLinear } from 'd3-scale';
- import { select } from 'd3-selection';
- import { D3ZoomEvent, ZoomBehavior, zoom, zoomIdentity } from 'd3-zoom';
- import { sortBy, uniq } from 'lodash';
- import * as React from 'react';
- import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer';
- import tw from 'twin.macro';
- import { themeColor, themeContrast } from '../helpers';
- import { BubbleColorVal } from '../types/charts';
- import { Note } from './Text';
- import { Tooltip } from './Tooltip';
- import { ButtonSecondary } from './buttons';
-
- const TICKS_COUNT = 5;
-
- interface BubbleItem<T> {
- color?: BubbleColorVal;
- data?: T;
- key?: string;
- size: number;
- tooltip?: React.ReactNode;
- x: number;
- y: number;
- }
-
- export interface BubbleChartProps<T> {
- 'data-testid'?: string;
- displayXGrid?: boolean;
- displayXTicks?: boolean;
- displayYGrid?: boolean;
- displayYTicks?: boolean;
- formatXTick: (tick: number) => string;
- formatYTick: (tick: number) => string;
- height: number;
- items: Array<BubbleItem<T>>;
- onBubbleClick?: (ref?: T) => void;
- padding: [number, number, number, number];
- sizeDomain?: [number, number];
- sizeRange?: [number, number];
- xDomain?: [number, number];
- yDomain?: [number, number];
- zoomLabel?: string;
- zoomResetLabel?: string;
- zoomTooltipText?: string;
- }
-
- type Scale = ScaleLinear<number, number>;
-
- BubbleChart.defaultProps = {
- displayXGrid: true,
- displayXTicks: true,
- displayYGrid: true,
- displayYTicks: true,
- formatXTick: (d: number) => String(d),
- formatYTick: (d: number) => String(d),
- padding: [10, 10, 10, 10],
- sizeRange: [5, 45],
- };
-
- export function BubbleChart<T>(props: BubbleChartProps<T>) {
- const {
- padding,
- height,
- items,
- xDomain,
- yDomain,
- sizeDomain,
- sizeRange,
- zoomResetLabel = 'Reset',
- zoomTooltipText,
- zoomLabel = 'Zoom',
- displayXTicks,
- displayYTicks,
- displayXGrid,
- displayYGrid,
- formatXTick,
- formatYTick,
- } = props;
- const [transform, setTransform] = React.useState({ x: 0, y: 0, k: 1 });
- const nodeRef = React.useRef<SVGSVGElement>();
- const zoomRef = React.useRef<ZoomBehavior<Element, unknown>>();
- const zoomLevelLabel = `${Math.floor(transform.k * 100)}%`;
-
- if (zoomRef.current && nodeRef.current) {
- const rect = nodeRef.current.getBoundingClientRect();
- zoomRef.current.translateExtent([
- [0, 0],
- [rect.width, rect.height],
- ]);
- }
-
- const zoomed = React.useCallback(
- (event: D3ZoomEvent<SVGSVGElement, void>) => {
- const { x, y, k } = event.transform;
- setTransform({
- x: x + padding[3] * (k - 1),
- y: y + padding[0] * (k - 1),
- k,
- });
- },
- [padding],
- );
-
- const boundNode = React.useCallback(
- (node: SVGSVGElement) => {
- nodeRef.current = node;
- zoomRef.current = zoom().scaleExtent([1, 10]).on('zoom', zoomed);
- select(nodeRef.current).call(zoomRef.current);
- },
- [zoomed],
- );
-
- const resetZoom = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
- e.stopPropagation();
- e.preventDefault();
- if (zoomRef.current && nodeRef.current) {
- select(nodeRef.current).call(zoomRef.current.transform, zoomIdentity);
- }
- }, []);
-
- const getXRange = React.useCallback(
- (xScale: Scale, sizeScale: Scale, availableWidth: number) => {
- const [x1, x2] = xScale.range();
- const minX = min(items, (d) => xScale(d.x) - sizeScale(d.size)) ?? 0;
- const maxX = max(items, (d) => xScale(d.x) + sizeScale(d.size)) ?? 0;
- const dMinX = minX < 0 ? x1 - minX : x1;
- const dMaxX = maxX > x2 ? maxX - x2 : 0;
- return [dMinX, availableWidth - dMaxX];
- },
- [items],
- );
-
- const getYRange = React.useCallback(
- (yScale: Scale, sizeScale: Scale, availableHeight: number) => {
- const [y1, y2] = yScale.range();
- const minY = min(items, (d) => yScale(d.y) - sizeScale(d.size)) ?? 0;
- const maxY = max(items, (d) => yScale(d.y) + sizeScale(d.size)) ?? 0;
- const dMinY = minY < 0 ? y2 - minY : y2;
- const dMaxY = maxY > y1 ? maxY - y1 : 0;
- return [availableHeight - dMaxY, dMinY];
- },
- [items],
- );
-
- const getTicks = React.useCallback(
- (scale: Scale, format: (d: number) => string) => {
- const zoomAmount = Math.ceil(transform.k);
- const ticks = scale.ticks(TICKS_COUNT * zoomAmount).map((tick) => format(tick));
- const uniqueTicksCount = uniq(ticks).length;
- const ticksCount =
- uniqueTicksCount < TICKS_COUNT * zoomAmount
- ? uniqueTicksCount - 1
- : TICKS_COUNT * zoomAmount;
- return scale.ticks(ticksCount);
- },
- [transform],
- );
-
- const renderXGrid = React.useCallback(
- (ticks: number[], xScale: Scale, yScale: Scale) => {
- if (!displayXGrid) {
- return null;
- }
-
- const lines = ticks.map((tick, index) => {
- const x = xScale(tick);
- const [y1, y2] = yScale.range();
- return (
- <BubbleChartGrid
- // eslint-disable-next-line react/no-array-index-key
- key={index}
- x1={x * transform.k + transform.x}
- x2={x * transform.k + transform.x}
- y1={y1 * transform.k}
- y2={transform.k > 1 ? 0 : y2}
- />
- );
- });
-
- return <g>{lines}</g>;
- },
- [transform, displayXGrid],
- );
-
- const renderYGrid = React.useCallback(
- (ticks: number[], xScale: Scale, yScale: Scale) => {
- if (!displayYGrid) {
- return null;
- }
-
- const lines = ticks.map((tick, index) => {
- const y = yScale(tick);
- const [x1, x2] = xScale.range();
- return (
- <BubbleChartGrid
- // eslint-disable-next-line react/no-array-index-key
- key={index}
- x1={transform.k > 1 ? 0 : x1}
- x2={x2 * transform.k}
- y1={y * transform.k + transform.y}
- y2={y * transform.k + transform.y}
- />
- );
- });
-
- return <g>{lines}</g>;
- },
- [displayYGrid, transform],
- );
-
- const renderXTicks = React.useCallback(
- (xTicks: number[], xScale: Scale, yScale: Scale) => {
- if (!displayXTicks) {
- return null;
- }
-
- const ticks = xTicks.map((tick, index) => {
- const x = xScale(tick) * transform.k + transform.x;
- const y = yScale.range()[0];
- const innerText = formatXTick(tick);
- // as we modified the `x` using `transform`, check that it is inside the range again
- return x > 0 && x < xScale.range()[1] ? (
- // eslint-disable-next-line react/no-array-index-key
- <BubbleChartTick dy="1.5em" key={index} style={{ '--align': 'middle' }} x={x} y={y}>
- {innerText}
- </BubbleChartTick>
- ) : null;
- });
-
- return <g>{ticks}</g>;
- },
- [displayXTicks, formatXTick, transform],
- );
-
- const renderYTicks = React.useCallback(
- (yTicks: number[], xScale: Scale, yScale: Scale) => {
- if (!displayYTicks) {
- return null;
- }
-
- const ticks = yTicks.map((tick, index) => {
- const x = xScale.range()[0];
- const y = yScale(tick) * transform.k + transform.y;
- const innerText = formatYTick(tick);
- // as we modified the `y` using `transform`, check that it is inside the range again
- return y > 0 && y < yScale.range()[0] ? (
- <BubbleChartTick
- dx="-0.5em"
- dy="0.3em"
- // eslint-disable-next-line react/no-array-index-key
- key={index}
- style={{ '--align': 'end' }}
- x={x}
- y={y}
- >
- {innerText}
- </BubbleChartTick>
- ) : null;
- });
-
- return <g>{ticks}</g>;
- },
- [displayYTicks, formatYTick, transform],
- );
-
- const renderChart = (width: number) => {
- const availableWidth = width - padding[1] - padding[3];
- const availableHeight = height - padding[0] - padding[2];
-
- const xScale = scaleLinear()
- .domain(xDomain ?? [0, max(items, (d) => d.x) ?? 0])
- .range([0, availableWidth])
- .nice();
- const yScale = scaleLinear()
- .domain(yDomain ?? [0, max(items, (d) => d.y) ?? 0])
- .range([availableHeight, 0])
- .nice();
- const sizeScale = scaleLinear()
- .domain(sizeDomain ?? [0, max(items, (d) => d.size) ?? 0])
- .range(sizeRange ?? []);
-
- const xScaleOriginal = xScale.copy();
- const yScaleOriginal = yScale.copy();
-
- xScale.range(getXRange(xScale, sizeScale, availableWidth));
- yScale.range(getYRange(yScale, sizeScale, availableHeight));
-
- const bubbles = sortBy(items, (b) => -b.size).map((item, index) => {
- return (
- <Bubble
- color={item.color}
- data={item.data}
- key={item.key ?? index}
- onClick={props.onBubbleClick}
- r={sizeScale(item.size)}
- scale={1 / transform.k}
- tooltip={item.tooltip}
- x={xScale(item.x)}
- y={yScale(item.y)}
- />
- );
- });
-
- const xTicks = getTicks(xScale, props.formatXTick);
- const yTicks = getTicks(yScale, props.formatYTick);
-
- return (
- <svg
- className={classNames('bubble-chart')}
- data-testid={props['data-testid']}
- height={height}
- ref={boundNode}
- width={width}
- >
- <defs>
- <clipPath id="graph-region">
- <rect
- // Extend clip by 2 pixels: one for clipRect border, and one for Bubble borders
- height={availableHeight + 4}
- width={availableWidth + 4}
- x={-2}
- y={-2}
- />
- </clipPath>
- </defs>
- <g transform={`translate(${padding[3]}, ${padding[0]})`}>
- <g clipPath="url(#graph-region)">
- {renderXGrid(xTicks, xScale, yScale)}
- {renderYGrid(yTicks, xScale, yScale)}
- <g transform={`translate(${transform.x}, ${transform.y}) scale(${transform.k})`}>
- {bubbles}
- </g>
- </g>
- {renderXTicks(xTicks, xScale, yScaleOriginal)}
- {renderYTicks(yTicks, xScaleOriginal, yScale)}
- </g>
- </svg>
- );
- };
-
- return (
- <div>
- <div className="sw-flex sw-items-center sw-justify-end sw-h-control sw-mb-4">
- <Tooltip overlay={zoomTooltipText}>
- <span>
- <Note className="sw-body-sm-highlight">{zoomLabel}</Note>
- {': '}
- {zoomLevelLabel}
- </span>
- </Tooltip>
- {zoomLevelLabel !== '100%' && (
- <ButtonSecondary
- className="sw-ml-2"
- disabled={zoomLevelLabel === '100%'}
- onClick={resetZoom}
- >
- {zoomResetLabel}
- </ButtonSecondary>
- )}
- </div>
- <AutoSizer disableHeight>{(size) => renderChart(size.width)}</AutoSizer>
- </div>
- );
- }
-
- interface BubbleProps<T> {
- color?: BubbleColorVal;
- data?: T;
- onClick?: (ref?: T) => void;
- r: number;
- scale: number;
- tooltip?: string | React.ReactNode;
- x: number;
- y: number;
- }
-
- function Bubble<T>(props: BubbleProps<T>) {
- const theme = useTheme();
- const { color, data, onClick, r, scale, tooltip, x, y } = props;
- const handleClick = React.useCallback(
- (event: React.MouseEvent<HTMLAnchorElement>) => {
- event.stopPropagation();
- event.preventDefault();
- onClick?.(data);
- },
- [data, onClick],
- );
-
- const circle = (
- <a href="#" onClick={handleClick}>
- <BubbleStyled
- r={r}
- style={{
- fill: color ? themeColor(`bubble.${color}`)({ theme }) : '',
- stroke: color ? themeContrast(`bubble.${color}`)({ theme }) : '',
- }}
- transform={`translate(${x}, ${y}) scale(${scale})`}
- />
- </a>
- );
-
- return <Tooltip overlay={tooltip}>{circle}</Tooltip>;
- }
-
- const BubbleStyled = styled.circle`
- ${tw`sw-cursor-pointer`}
-
- transition: fill-opacity 0.2s ease;
- fill: ${themeColor('bubbleDefault')};
- stroke: ${themeContrast('bubbleDefault')};
-
- &:hover {
- fill-opacity: 0.8;
- }
- `;
-
- const BubbleChartGrid = styled.line`
- shape-rendering: crispedges;
- stroke: ${themeColor('bubbleChartLine')};
- `;
-
- const BubbleChartTick = styled.text`
- ${tw`sw-body-sm`}
- ${tw`sw-select-none`}
- fill: ${themeColor('pageContentLight')};
- text-anchor: var(--align);
- `;
|