From ad9ecec15f216c12f7e6456fc9d3cde0007b860f Mon Sep 17 00:00:00 2001 From: Pascal Mugnier Date: Wed, 11 Apr 2018 15:45:43 +0200 Subject: [PATCH] SONAR-9416 Enable zoom on bubble charts (#127) --- server/sonar-web/package.json | 1 + .../main/js/app/styles/components/badges.css | 6 + .../js/app/styles/components/graphics.css | 17 +- .../js/components/charts/BubbleChart.d.ts | 48 -- .../main/js/components/charts/BubbleChart.js | 270 ----------- .../main/js/components/charts/BubbleChart.tsx | 434 ++++++++++++++++++ server/sonar-web/yarn.lock | 9 +- .../resources/org/sonar/l10n/core.properties | 1 + 8 files changed, 466 insertions(+), 320 deletions(-) delete mode 100644 server/sonar-web/src/main/js/components/charts/BubbleChart.d.ts delete mode 100644 server/sonar-web/src/main/js/components/charts/BubbleChart.js create mode 100644 server/sonar-web/src/main/js/components/charts/BubbleChart.tsx diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index eadf694d63a..78e0324d5a3 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -55,6 +55,7 @@ "@types/react-redux": "5.0.12", "@types/react-router": "3.0.13", "@types/react-select": "1.0.59", + "@types/react-virtualized": "9.7.15", "autoprefixer": "7.1.6", "babel-core": "6.26.0", "babel-jest": "22.0.6", diff --git a/server/sonar-web/src/main/js/app/styles/components/badges.css b/server/sonar-web/src/main/js/app/styles/components/badges.css index 602f7513f1a..1dd87fe7d64 100644 --- a/server/sonar-web/src/main/js/app/styles/components/badges.css +++ b/server/sonar-web/src/main/js/app/styles/components/badges.css @@ -144,3 +144,9 @@ a.badge-focus:active { font-size: var(--smallFontSize); font-weight: 400; } + +.outline-badge.active { + color: var(--baseFontColor); + border: 1px solid var(--blue); + background-color: var(--lightBlue); +} diff --git a/server/sonar-web/src/main/js/app/styles/components/graphics.css b/server/sonar-web/src/main/js/app/styles/components/graphics.css index 049dde27ec5..03128aa2a68 100644 --- a/server/sonar-web/src/main/js/app/styles/components/graphics.css +++ b/server/sonar-web/src/main/js/app/styles/components/graphics.css @@ -199,12 +199,20 @@ * Bubble Chart */ +.bubble-chart.is-moving { + cursor: move; +} + +.bubble-chart text { + user-select: none; +} + .bubble-chart-bubble { fill: var(--blue); fill-opacity: 0.2; stroke: var(--blue); cursor: pointer; - transition: all 0.2s ease; + transition: fill-opacity 0.2s ease; } .bubble-chart-bubble:hover { @@ -226,6 +234,13 @@ text-anchor: end; } +.bubble-chart-zoom { + position: absolute; + right: 20px; + top: 20px; + z-index: var(--aboveNormalZIndex); +} + /* * Legends */ diff --git a/server/sonar-web/src/main/js/components/charts/BubbleChart.d.ts b/server/sonar-web/src/main/js/components/charts/BubbleChart.d.ts deleted file mode 100644 index f428115c4b3..00000000000 --- a/server/sonar-web/src/main/js/components/charts/BubbleChart.d.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 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 * as React from 'react'; - -interface Item { - x: number; - y: number; - size: number; - color?: string; - key?: string; - link?: any; - tooltip?: React.ReactNode; -} - -interface Props { - items: Item[]; - sizeRange?: [number, number]; - displayXGrid?: boolean; - displayXTicks?: boolean; - displayYGrid?: boolean; - displayYTicks?: boolean; - height: number; - padding: [number, number, number, number]; - formatXTick: (tick: number) => string; - formatYTick: (tick: number) => string; - onBubbleClick?: (link?: any) => void; - xDomain?: [number, number]; - yDomain?: [number, number]; -} - -export default class BubbleChart extends React.Component {} diff --git a/server/sonar-web/src/main/js/components/charts/BubbleChart.js b/server/sonar-web/src/main/js/components/charts/BubbleChart.js deleted file mode 100644 index 73fcce09b78..00000000000 --- a/server/sonar-web/src/main/js/components/charts/BubbleChart.js +++ /dev/null @@ -1,270 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 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. - */ -// @flow -import React from 'react'; -import { Link } from 'react-router'; -import { min, max } from 'd3-array'; -import { scaleLinear } from 'd3-scale'; -import { sortBy, uniq } from 'lodash'; -import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'; -import Tooltip from '../controls/Tooltip'; - -/*:: -type Scale = { - (number): number, - range: () => [number, number], - ticks: number => Array -}; -*/ - -const TICKS_COUNT = 5; - -export class Bubble extends React.PureComponent { - /*:: props: { - color?: string, - link?: string, - onClick: (?string) => void, - r: number, - tooltip?: string | React$Element<*>, - x: number, - y: number - }; -*/ - - handleClick = () => { - if (this.props.onClick) { - this.props.onClick(this.props.link); - } - }; - - render() { - let circle = ( - - ); - - if (this.props.link && !this.props.onClick) { - circle = {circle}; - } - - return ( - - {circle} - - ); - } -} - -export default class BubbleChart extends React.PureComponent { - /*:: props: {| - items: Array<{| - x: number, - y: number, - size: number, - color?: string, - key?: string, - link?: string, - tooltip?: string | React$Element<*> - |}>, - sizeRange?: [number, number], - displayXGrid: boolean, - displayXTicks: boolean, - displayYGrid: boolean, - displayYTicks: boolean, - height: number, - padding: [number, number, number, number], - formatXTick: number => string, - formatYTick: number => string, - onBubbleClick?: (?string) => void, - xDomain?: [number, number], - yDomain?: [number, number] - |}; -*/ - - static defaultProps = { - sizeRange: [5, 45], - displayXGrid: true, - displayYGrid: true, - displayXTicks: true, - displayYTicks: true, - padding: [10, 10, 10, 10], - formatXTick: d => d, - formatYTick: d => d - }; - - getXRange(xScale /*: Scale */, sizeScale /*: Scale */, availableWidth /*: number */) { - const minX = min(this.props.items, d => xScale(d.x) - sizeScale(d.size)); - const maxX = max(this.props.items, d => xScale(d.x) + sizeScale(d.size)); - 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)); - const maxY = max(this.props.items, d => yScale(d.y) + sizeScale(d.size)); - 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 /*: number => string */) { - const ticks = scale.ticks(TICKS_COUNT).map(tick => format(tick)); - const uniqueTicksCount = uniq(ticks).length; - const ticksCount = uniqueTicksCount < TICKS_COUNT ? uniqueTicksCount - 1 : TICKS_COUNT; - return scale.ticks(ticksCount); - } - - renderXGrid(ticks /*: Array */, xScale /*: Scale */, yScale /*: Scale */) { - if (!this.props.displayXGrid) { - return null; - } - - const lines = ticks.map((tick, index) => { - const x = xScale(tick); - const y1 = yScale.range()[0]; - const y2 = yScale.range()[1]; - return ; - }); - - return {lines}; - } - - renderYGrid(ticks /*: Array */, xScale /*: Scale */, yScale /*: Scale */) { - if (!this.props.displayYGrid) { - return null; - } - - const lines = ticks.map((tick, index) => { - const y = yScale(tick); - const x1 = xScale.range()[0]; - const x2 = xScale.range()[1]; - return ; - }); - - return {lines}; - } - - renderXTicks(xTicks /*: Array */, xScale /*: Scale */, yScale /*: Scale */) { - if (!this.props.displayXTicks) { - return null; - } - - const ticks = xTicks.map((tick, index) => { - const x = xScale(tick); - const y = yScale.range()[0]; - const innerText = this.props.formatXTick(tick); - return ( - - {innerText} - - ); - }); - - return {ticks}; - } - - renderYTicks(yTicks /*: Array */, xScale /*: Scale */, yScale /*: Scale */) { - if (!this.props.displayYTicks) { - return null; - } - - const ticks = yTicks.map((tick, index) => { - const x = xScale.range()[0]; - const y = yScale(tick); - const innerText = this.props.formatYTick(tick); - return ( - - {innerText} - - ); - }); - - return {ticks}; - } - - renderChart(width /*: number */) { - 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)]) - .range([0, availableWidth]) - .nice(); - const yScale = scaleLinear() - .domain(this.props.yDomain || [0, max(this.props.items, d => d.y)]) - .range([availableHeight, 0]) - .nice(); - const sizeScale = scaleLinear() - .domain(this.props.sizeDomain || [0, max(this.props.items, d => d.size)]) - .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.renderXTicks(xTicks, xScale, yScaleOriginal)} - {this.renderYGrid(yTicks, xScale, yScale)} - {this.renderYTicks(yTicks, xScaleOriginal, yScale)} - {bubbles} - - - ); - } - - render() { - return {size => this.renderChart(size.width)}; - } -} diff --git a/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx b/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx new file mode 100644 index 00000000000..cd7033c2413 --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx @@ -0,0 +1,434 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 * as React from 'react'; +import * as classNames from 'classnames'; +import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer'; +import { Link } from 'react-router'; +import { min, max } from 'd3-array'; +import { scaleLinear, ScaleLinear } from 'd3-scale'; +import { sortBy, uniq } from 'lodash'; +import Tooltip from '../controls/Tooltip'; +import { translate } from '../../helpers/l10n'; + +const TICKS_COUNT = 5; + +interface BubbleProps { + color?: string; + link?: string; + onClick?: (link?: string) => void; + r: number; + tooltip?: string | React.ReactNode; + x: number; + y: number; +} + +export class Bubble extends React.PureComponent { + handleClick = (event: React.MouseEvent) => { + if (this.props.onClick) { + event.stopPropagation(); + event.preventDefault(); + this.props.onClick(this.props.link); + } + }; + + render() { + let circle = ( + + ); + + if (this.props.link && !this.props.onClick) { + circle = {circle}; + } + + return ( + + {circle} + + ); + } +} + +interface Item { + color?: string; + key?: string; + link?: any; + 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: Item[]; + onBubbleClick?: (link?: string) => void; + padding: [number, number, number, number]; + sizeDomain?: [number, number]; + sizeRange?: [number, number]; + xDomain?: [number, number]; + yDomain?: [number, number]; +} + +interface State { + isMoving: boolean; + moveOrigin: { x: number; y: number }; + zoom: number; + zoomOrigin: { x: number; y: number }; +} + +type Scale = ScaleLinear; + +export default class BubbleChart extends React.Component { + node: SVGSVGElement | null = null; + + static defaultProps = { + displayXGrid: true, + displayXTicks: true, + displayYGrid: true, + displayYTicks: true, + formatXTick: (d: number) => d, + formatYTick: (d: number) => d, + padding: [10, 10, 10, 10], + sizeRange: [5, 45] + }; + + constructor(props: Props) { + super(props); + this.state = { + isMoving: false, + moveOrigin: { x: 0, y: 0 }, + zoom: 1, + zoomOrigin: { x: 0, y: 0 } + }; + } + + componentDidMount() { + document.addEventListener('mouseup', this.stopMoving); + document.addEventListener('mousemove', this.updateZoomCenter); + } + + componentWillUnmount() { + document.removeEventListener('mouseup', this.stopMoving); + document.removeEventListener('mousemove', this.updateZoomCenter); + } + + startMoving = (event: React.MouseEvent) => { + if (this.node && this.state.zoom > 1) { + const rect = this.node.getBoundingClientRect(); + this.setState({ + isMoving: true, + moveOrigin: { x: event.clientX - rect.left, y: event.clientY - rect.top } + }); + } + }; + + updateZoomCenter = (event: MouseEvent) => { + if (this.node && this.state.isMoving) { + const rect = this.node.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + this.setState(state => ({ + zoomOrigin: { + x: Math.max(-100, state.zoomOrigin.x + (state.moveOrigin.x - x) / state.zoom), + y: Math.max(-100, state.zoomOrigin.y + (state.moveOrigin.y - y) / state.zoom) + }, + moveOrigin: { x, y } + })); + } + }; + + stopMoving = () => { + this.setState({ isMoving: false }); + }; + + onWheel = (event: React.WheelEvent) => { + if (this.node) { + event.stopPropagation(); + event.preventDefault(); + + const rect = this.node.getBoundingClientRect(); + const mouseX = event.clientX - rect.left - this.props.padding[1]; + const mouseY = event.clientY - rect.top - this.props.padding[0]; + + let delta = event.deltaY; + if ((event as any).webkitDirectionInvertedFromDevice) { + delta = -delta; + } + + if (delta < 0) { + this.handleZoomOut(mouseX, mouseY); + } else { + this.handleZoomIn(mouseX, mouseY); + } + } + }; + + handleZoomOut = (x: number, y: number) => { + if (this.state.zoom === 1) { + this.setState(state => ({ + zoom: Math.max(1.0, state.zoom - 0.5), + zoomOrigin: { x, y } + })); + } else { + this.setState(state => ({ + zoom: Math.max(1.0, state.zoom - 0.5) + })); + } + }; + + handleZoomIn = (x: number, y: number) => { + if (this.state.zoom === 1) { + this.setState(state => ({ + zoom: Math.min(10.0, state.zoom + 0.5), + zoomOrigin: { x, y } + })); + } else { + this.setState(state => ({ + zoom: Math.min(10.0, state.zoom + 0.5) + })); + } + }; + + resetZoom = () => { + this.setState({ + zoom: 1, + zoomOrigin: { x: 0, y: 0 } + }); + }; + + 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 ticks = scale.ticks(TICKS_COUNT).map(tick => format(tick)); + const uniqueTicksCount = uniq(ticks).length; + const ticksCount = uniqueTicksCount < TICKS_COUNT ? uniqueTicksCount - 1 : TICKS_COUNT; + return scale.ticks(ticksCount); + } + + getZoomLevelLabel = () => this.state.zoom * 100 + '%'; + + renderXGrid = (ticks: Array, xScale: Scale, yScale: Scale, delta: number) => { + if (!this.props.displayXGrid) { + return null; + } + + const lines = ticks.map((tick, index) => { + const x = xScale(tick); + const y1 = yScale.range()[0]; + const y2 = yScale.range()[1]; + return ( + + ); + }); + + return {lines}; + }; + + renderYGrid = (ticks: Array, xScale: Scale, yScale: Scale, delta: number) => { + if (!this.props.displayYGrid) { + return null; + } + + const lines = ticks.map((tick, index) => { + const y = yScale(tick); + const x1 = xScale.range()[0]; + const x2 = xScale.range()[1]; + return ( + + ); + }); + + return {lines}; + }; + + renderXTicks = (xTicks: Array, xScale: Scale, yScale: Scale, delta: number) => { + if (!this.props.displayXTicks) { + return null; + } + + const ticks = xTicks.map((tick, index) => { + const x = xScale(tick); + const y = yScale.range()[0]; + const innerText = this.props.formatXTick(tick); + return ( + + {innerText} + + ); + }); + + return {ticks}; + }; + + renderYTicks = (yTicks: Array, xScale: Scale, yScale: Scale, delta: number) => { + if (!this.props.displayYTicks) { + return null; + } + + const ticks = yTicks.map((tick, index) => { + const x = xScale.range()[0]; + const y = yScale(tick); + const innerText = this.props.formatYTick(tick); + return ( + + {innerText} + + ); + }); + + return {ticks}; + }; + + renderChart = (width: number) => { + 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 centerXDelta = this.state.zoomOrigin.x * this.state.zoom - this.state.zoomOrigin.x; + const centerYDelta = this.state.zoomOrigin.y * this.state.zoom - this.state.zoomOrigin.y; + + 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.node = node)} + width={width}> + + + {this.renderXGrid(xTicks, xScale, yScale, centerXDelta)} + {this.renderYGrid(yTicks, xScale, yScale, centerYDelta)} + {bubbles} + + {this.renderXTicks(xTicks, xScale, yScaleOriginal, centerXDelta)} + {this.renderYTicks(yTicks, xScaleOriginal, yScale, centerYDelta)} + + + ); + }; + + render() { + return ( +
+
+ + 1 })} + onClick={this.resetZoom} + to="#"> + {this.getZoomLevelLabel()} + + +
+ {size => this.renderChart(size.width)} +
+ ); + } +} diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock index dd0142459a6..ad4e577368c 100644 --- a/server/sonar-web/yarn.lock +++ b/server/sonar-web/yarn.lock @@ -63,7 +63,7 @@ version "6.0.90" resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.90.tgz#0ed74833fa1b73dcdb9409dcb1c97ec0a8b13b02" -"@types/prop-types@15.5.2": +"@types/prop-types@*", "@types/prop-types@15.5.2": version "15.5.2" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.2.tgz#3c6b8dceb2906cc87fe4358e809f9d20c8d59be1" @@ -110,6 +110,13 @@ dependencies: "@types/react" "*" +"@types/react-virtualized@9.7.15": + version "9.7.15" + resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.7.15.tgz#223a30403317e9955d836a3c4f0561087b43617e" + dependencies: + "@types/prop-types" "*" + "@types/react" "*" + "@types/react@*", "@types/react@16.0.29": version "16.0.29" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.0.29.tgz#4eea6a8de9f40ca71d580ae7a9f3b4b77b368de8" diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 97f73111a99..aba9c50be01 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2451,6 +2451,7 @@ component_measures.domain_facets.Complexity.help=How simple or complicated the c component_measures.facet_category.new_code_category=On new code component_measures.facet_category.overall_category=Overall component_measures.facet_category.tests_category=Tests +component_measures.bubble_chart.zoom_level=Current zoom level. Scrool on the chart to zoom or unzoom, click here to reset. #------------------------------------------------------------------------------ # -- 2.39.5