/* * 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 { color?: BubbleColorVal; data?: T; key?: string; size: number; tooltip?: React.ReactNode; x: number; y: number; } export interface BubbleChartProps { 'data-testid'?: string; displayXGrid?: boolean; displayXTicks?: boolean; displayYGrid?: boolean; displayYTicks?: boolean; formatXTick: (tick: number) => string; formatYTick: (tick: number) => string; height: number; items: Array>; 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; 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(props: BubbleChartProps) { 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(); const zoomRef = React.useRef>(); 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) => { 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) => { 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 ( 1 ? 0 : y2} /> ); }); return {lines}; }, [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 ( 1 ? 0 : x1} x2={x2 * transform.k} y1={y * transform.k + transform.y} y2={y * transform.k + transform.y} /> ); }); return {lines}; }, [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 {innerText} ) : null; }); return {ticks}; }, [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] ? ( {innerText} ) : null; }); return {ticks}; }, [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 ( ); }); const xTicks = getTicks(xScale, props.formatXTick); const yTicks = getTicks(yScale, props.formatYTick); return ( {renderXGrid(xTicks, xScale, yScale)} {renderYGrid(yTicks, xScale, yScale)} {bubbles} {renderXTicks(xTicks, xScale, yScaleOriginal)} {renderYTicks(yTicks, xScaleOriginal, yScale)} ); }; return (
{zoomLabel} {': '} {zoomLevelLabel} {zoomLevelLabel !== '100%' && ( {zoomResetLabel} )}
{(size) => renderChart(size.width)}
); } interface BubbleProps { color?: BubbleColorVal; data?: T; onClick?: (ref?: T) => void; r: number; scale: number; tooltip?: string | React.ReactNode; x: number; y: number; } function Bubble(props: BubbleProps) { const theme = useTheme(); const { color, data, onClick, r, scale, tooltip, x, y } = props; const handleClick = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); onClick?.(data); }, [data, onClick] ); const circle = ( ); return {circle}; } 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); `;