/* * SonarQube * Copyright (C) 2009-2022 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 classNames from 'classnames'; import { max, min } from 'd3-array'; import { scaleLinear, ScaleLinear } from 'd3-scale'; import { select } from 'd3-selection'; import { D3ZoomEvent, zoom, ZoomBehavior, zoomIdentity } from 'd3-zoom'; import { sortBy, uniq } from 'lodash'; import * as React from 'react'; import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer'; import { translate } from '../../helpers/l10n'; import Link from '../common/Link'; import Tooltip from '../controls/Tooltip'; import './BubbleChart.css'; const TICKS_COUNT = 5; interface BubbleItem { color: { fill: string; stroke: string; hover?: string }; key?: string; data?: T; size: number; tooltip?: React.ReactNode; x: number; y: number; } interface Props { displayXGrid?: boolean; displayXTicks?: boolean; displayYGrid?: boolean; displayYTicks?: boolean; formatXTick: (tick: number) => string; formatYTick: (tick: number) => string; height: number; items: BubbleItem[]; onBubbleClick?: (ref?: T) => void; padding: [number, number, number, number]; sizeDomain?: [number, number]; sizeRange?: [number, number]; xDomain?: [number, number]; yDomain?: [number, number]; } interface State { transform: { x: number; y: number; k: number }; } type Scale = ScaleLinear; export default class BubbleChart extends React.PureComponent, State> { private node?: Element; private zoom?: ZoomBehavior; static 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] }; constructor(props: Props) { super(props); this.state = { transform: { x: 0, y: 0, k: 1 } }; } componentDidUpdate() { if (this.zoom && this.node) { const rect = this.node.getBoundingClientRect(); this.zoom.translateExtent([ [0, 0], [rect.width, rect.height] ]); } } boundNode = (node: SVGSVGElement) => { this.node = node; this.zoom = zoom() .scaleExtent([1, 10]) .on('zoom', this.zoomed); select(this.node).call(this.zoom as any); }; zoomed = (event: D3ZoomEvent) => { const { padding } = this.props; const { x, y, k } = event.transform; this.setState({ transform: { x: x + padding[3] * (k - 1), y: y + padding[0] * (k - 1), k } }); }; resetZoom = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); if (this.zoom && this.node) { select(this.node).call(this.zoom.transform as any, zoomIdentity); } }; getXRange(xScale: Scale, sizeScale: Scale, availableWidth: number) { const minX = min(this.props.items, d => xScale(d.x) - sizeScale(d.size)) || 0; const maxX = max(this.props.items, d => xScale(d.x) + sizeScale(d.size)) || 0; const dMinX = minX < 0 ? xScale.range()[0] - minX : xScale.range()[0]; const dMaxX = maxX > xScale.range()[1] ? maxX - xScale.range()[1] : 0; return [dMinX, availableWidth - dMaxX]; } getYRange(yScale: Scale, sizeScale: Scale, availableHeight: number) { const minY = min(this.props.items, d => yScale(d.y) - sizeScale(d.size)) || 0; const maxY = max(this.props.items, d => yScale(d.y) + sizeScale(d.size)) || 0; const dMinY = minY < 0 ? yScale.range()[1] - minY : yScale.range()[1]; const dMaxY = maxY > yScale.range()[0] ? maxY - yScale.range()[0] : 0; return [availableHeight - dMaxY, dMinY]; } getTicks(scale: Scale, format: (d: number) => string) { const zoomAmount = Math.ceil(this.state.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); } getZoomLevelLabel = () => Math.floor(this.state.transform.k * 100) + '%'; renderXGrid = (ticks: number[], xScale: Scale, yScale: Scale) => { if (!this.props.displayXGrid) { return null; } const { transform } = this.state; const lines = ticks.map((tick, index) => { const x = xScale(tick); const y1 = yScale.range()[0]; const y2 = yScale.range()[1]; return ( 1 ? 0 : y2} /> ); }); return {lines}; }; renderYGrid = (ticks: number[], xScale: Scale, yScale: Scale) => { if (!this.props.displayYGrid) { return null; } const { transform } = this.state; const lines = ticks.map((tick, index) => { const y = yScale(tick); const x1 = xScale.range()[0]; const x2 = xScale.range()[1]; return ( 1 ? 0 : x1} x2={x2 * transform.k} y1={y * transform.k + transform.y} y2={y * transform.k + transform.y} /> ); }); return {lines}; }; renderXTicks = (xTicks: number[], xScale: Scale, yScale: Scale) => { if (!this.props.displayXTicks) { return null; } const { transform } = this.state; const ticks = xTicks.map((tick, index) => { const x = xScale(tick) * transform.k + transform.x; const y = yScale.range()[0]; const innerText = this.props.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}; }; renderYTicks = (yTicks: number[], xScale: Scale, yScale: Scale) => { if (!this.props.displayYTicks) { return null; } const { transform } = this.state; const ticks = yTicks.map((tick, index) => { const x = xScale.range()[0]; const y = yScale(tick) * transform.k + transform.y; const innerText = this.props.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}; }; renderChart = (width: number) => { const { transform } = this.state; const availableWidth = width - this.props.padding[1] - this.props.padding[3]; const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2]; const xScale = scaleLinear() .domain(this.props.xDomain || [0, max(this.props.items, d => d.x) || 0]) .range([0, availableWidth]) .nice(); const yScale = scaleLinear() .domain(this.props.yDomain || [0, max(this.props.items, d => d.y) || 0]) .range([availableHeight, 0]) .nice(); const sizeScale = scaleLinear() .domain(this.props.sizeDomain || [0, max(this.props.items, d => d.size) || 0]) .range(this.props.sizeRange || []); const xScaleOriginal = xScale.copy(); const yScaleOriginal = yScale.copy(); xScale.range(this.getXRange(xScale, sizeScale, availableWidth)); yScale.range(this.getYRange(yScale, sizeScale, availableHeight)); const bubbles = sortBy(this.props.items, b => -b.size).map((item, index) => { return ( ); }); const xTicks = this.getTicks(xScale, this.props.formatXTick); const yTicks = this.getTicks(yScale, this.props.formatYTick); return ( {this.renderXGrid(xTicks, xScale, yScale)} {this.renderYGrid(yTicks, xScale, yScale)} {bubbles} {this.renderXTicks(xTicks, xScale, yScaleOriginal)} {this.renderYTicks(yTicks, xScaleOriginal, yScale)} ); }; render() { return (
{this.getZoomLevelLabel()}
{size => this.renderChart(size.width)}
); } } interface BubbleProps { color: { fill: string; stroke: string; hover?: string }; onClick?: (ref?: T) => void; data?: T; r: number; scale: number; tooltip?: string | React.ReactNode; x: number; y: number; } function Bubble(props: BubbleProps) { const handleClick = (e: React.MouseEvent) => { if (props.onClick) { e.stopPropagation(); e.preventDefault(); props.onClick(props.data); } }; const circle = ( ); return {circle}; }