From 81645d024b9fbc057f0fe0cea9ebf3b43298f681 Mon Sep 17 00:00:00 2001 From: Pascal Mugnier Date: Tue, 29 May 2018 11:03:00 +0200 Subject: [PATCH] SONAR-9416 Fix and improve bubble chart zoom --- server/sonar-web/package.json | 3 + .../main/js/components/charts/BubbleChart.css | 4 - .../main/js/components/charts/BubbleChart.tsx | 239 +++++++----------- .../__snapshots__/BubbleChart-test.tsx.snap | 18 +- server/sonar-web/yarn.lock | 67 ++++- 5 files changed, 168 insertions(+), 163 deletions(-) diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json index 02b243b8aea..241641cf904 100644 --- a/server/sonar-web/package.json +++ b/server/sonar-web/package.json @@ -14,6 +14,7 @@ "d3-scale": "2.0.0", "d3-selection": "1.3.0", "d3-shape": "1.2.0", + "d3-zoom": "1.7.1", "date-fns": "1.29.0", "formik": "0.11.11", "history": "3.3.0", @@ -43,7 +44,9 @@ "@types/d3-array": "1.2.1", "@types/d3-hierarchy": "1.1.1", "@types/d3-scale": "2.0.0", + "@types/d3-selection": "1.3.0", "@types/d3-shape": "1.2.2", + "@types/d3-zoom": "1.7.1", "@types/enzyme": "3.1.10", "@types/jest": "22.2.3", "@types/keymaster": "1.6.28", diff --git a/server/sonar-web/src/main/js/components/charts/BubbleChart.css b/server/sonar-web/src/main/js/components/charts/BubbleChart.css index 9bcecc465eb..2f5c872a69b 100644 --- a/server/sonar-web/src/main/js/components/charts/BubbleChart.css +++ b/server/sonar-web/src/main/js/components/charts/BubbleChart.css @@ -17,10 +17,6 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -.bubble-chart.is-moving { - cursor: move; -} - .bubble-chart text { user-select: none; } diff --git a/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx b/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx index ec134895fd3..f636a57ee14 100644 --- a/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx +++ b/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx @@ -23,6 +23,8 @@ 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 { zoom, zoomIdentity } from 'd3-zoom'; +import { event, select } from 'd3-selection'; import { sortBy, uniq } from 'lodash'; import Tooltip from '../controls/Tooltip'; import { translate } from '../../helpers/l10n'; @@ -35,6 +37,7 @@ interface BubbleProps { link?: string; onClick?: (link?: string) => void; r: number; + scale: number; tooltip?: string | React.ReactNode; x: number; y: number; @@ -56,7 +59,7 @@ export class Bubble extends React.PureComponent { onClick={this.props.onClick ? this.handleClick : undefined} r={this.props.r} style={{ fill: this.props.color, stroke: this.props.color }} - transform={`translate(${this.props.x}, ${this.props.y})`} + transform={`translate(${this.props.x}, ${this.props.y}) scale(${this.props.scale})`} /> ); @@ -100,16 +103,16 @@ interface Props { } interface State { - isMoving: boolean; - moveOrigin: { x: number; y: number }; - zoom: number; - zoomOrigin: { x: number; y: number }; + transform: { x: number; y: number; k: number }; } type Scale = ScaleLinear; export default class BubbleChart extends React.Component { node: SVGSVGElement | null = null; + selection: any = null; + transform: any = null; + zoom: any = null; static defaultProps = { displayXGrid: true, @@ -121,23 +124,33 @@ export default class BubbleChart extends React.Component { constructor(props: Props) { super(props); - this.state = { - isMoving: false, - moveOrigin: { x: 0, y: 0 }, - zoom: 1, - zoomOrigin: { x: 0, y: 0 } - }; + this.state = { transform: { x: 0, y: 0, k: 1 } }; } - componentDidMount() { - document.addEventListener('mouseup', this.stopMoving); - document.addEventListener('mousemove', this.updateZoomCenter); + componentDidUpdate() { + if (this.zoom && this.node) { + const rect = this.node.getBoundingClientRect(); + this.zoom.translateExtent([[0, 0], [rect.width, rect.height]]); + } } - componentWillUnmount() { - document.removeEventListener('mouseup', this.stopMoving); - document.removeEventListener('mousemove', this.updateZoomCenter); - } + boundNode = (node: SVGSVGElement) => { + this.node = node; + this.zoom = zoom() + .scaleExtent([1, 10]) + .on('zoom', this.zoomed); + this.selection = select(this.node).call(this.zoom); + }; + + zoomed = () => { + this.setState({ transform: event.transform }); + }; + + resetZoom = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + select(this.node).call(this.zoom.transform, zoomIdentity); + }; get formatXTick() { return this.props.formatXTick || ((d: number) => String(d)); @@ -151,92 +164,6 @@ export default class BubbleChart extends React.Component { return this.props.padding || [10, 10, 10, 10]; } - 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.padding[1]; - const mouseY = event.clientY - rect.top - this.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 = (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - 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; @@ -254,19 +181,22 @@ export default class BubbleChart extends React.Component { } getTicks(scale: Scale, format: (d: number) => string) { - const ticks = scale.ticks(TICKS_COUNT).map(tick => format(tick)); + const zoom = Math.ceil(this.state.transform.k); + const ticks = scale.ticks(TICKS_COUNT * zoom).map(tick => format(tick)); const uniqueTicksCount = uniq(ticks).length; - const ticksCount = uniqueTicksCount < TICKS_COUNT ? uniqueTicksCount - 1 : TICKS_COUNT; + const ticksCount = + uniqueTicksCount < TICKS_COUNT * zoom ? uniqueTicksCount - 1 : TICKS_COUNT * zoom; return scale.ticks(ticksCount); } - getZoomLevelLabel = () => this.state.zoom * 100 + '%'; + getZoomLevelLabel = () => Math.floor(this.state.transform.k * 100) + '%'; - renderXGrid = (ticks: Array, xScale: Scale, yScale: Scale, delta: number) => { + 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]; @@ -275,10 +205,10 @@ export default class BubbleChart extends React.Component { 1 ? 0 : y2} /> ); }); @@ -286,11 +216,12 @@ export default class BubbleChart extends React.Component { return {lines}; }; - renderYGrid = (ticks: Array, xScale: Scale, yScale: Scale, delta: number) => { + 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]; @@ -299,10 +230,10 @@ export default class BubbleChart extends React.Component { 1 ? 0 : x1} + x2={x2 * transform.k} + y1={y * transform.k + transform.y} + y2={y * transform.k + transform.y} /> ); }); @@ -310,56 +241,54 @@ export default class BubbleChart extends React.Component { return {lines}; }; - renderXTicks = (xTicks: Array, xScale: Scale, yScale: Scale, delta: number) => { + 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); + const x = xScale(tick) * transform.k + transform.x; const y = yScale.range()[0]; const innerText = this.formatXTick(tick); - return ( - + return x > 0 ? ( + {innerText} - ); + ) : null; }); return {ticks}; }; - renderYTicks = (yTicks: Array, xScale: Scale, yScale: Scale, delta: number) => { + 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); + const y = yScale(tick) * transform.k + transform.y; const innerText = this.formatYTick(tick); - return ( + return y > 0 && y < this.props.height - 80 ? ( + y={y}> {innerText} - ); + ) : null; }); return {ticks}; }; renderChart = (width: number) => { + const { transform } = this.state; const availableWidth = width - this.padding[1] - this.padding[3]; const availableHeight = this.props.height - this.padding[0] - this.padding[2]; @@ -381,9 +310,6 @@ export default class BubbleChart extends React.Component { 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 ( { link={item.link} onClick={this.props.onBubbleClick} r={sizeScale(item.size)} + scale={1 / transform.k} tooltip={item.tooltip} - x={xScale(item.x) * this.state.zoom - centerXDelta} - y={yScale(item.y) * this.state.zoom - centerYDelta} + x={xScale(item.x)} + y={yScale(item.y)} /> ); }); @@ -404,23 +331,31 @@ export default class BubbleChart extends React.Component { return ( (this.node = node)} + ref={this.boundNode} 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)} + + {this.renderXGrid(xTicks, xScale, yScale)} + {this.renderYGrid(yTicks, xScale, yScale)} + + {bubbles} + + + {this.renderXTicks(xTicks, xScale, yScaleOriginal)} + {this.renderYTicks(yTicks, xScaleOriginal, yScale)} ); @@ -432,7 +367,7 @@ export default class BubbleChart extends React.Component {
1 })} + className={classNames('outline-badge', { active: this.state.transform.k > 1 })} onClick={this.resetZoom} to="#"> {this.getZoomLevelLabel()} diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.tsx.snap b/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.tsx.snap index fbbd76666bd..89b36a4c5ea 100644 --- a/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.tsx.snap @@ -4,6 +4,7 @@ exports[`should display bubbles 1`] = ` @@ -18,7 +19,7 @@ exports[`should display bubbles 1`] = ` "stroke": undefined, } } - transform="translate(-10, 52.3015873015873)" + transform="translate(-10, 52.3015873015873) scale(1)" /> @@ -29,6 +30,7 @@ exports[`should display bubbles 2`] = ` @@ -43,7 +45,7 @@ exports[`should display bubbles 2`] = ` "stroke": undefined, } } - transform="translate(-75, 33.57142857142857)" + transform="translate(-75, 33.57142857142857) scale(1)" /> @@ -55,6 +57,7 @@ exports[`should render bubble links 1`] = ` key="0" link="foo" r={45} + scale={1} x={-10} y={52.3015873015873} > @@ -78,7 +81,7 @@ exports[`should render bubble links 1`] = ` "stroke": undefined, } } - transform="translate(-10, 52.3015873015873)" + transform="translate(-10, 52.3015873015873) scale(1)" /> @@ -92,6 +95,7 @@ exports[`should render bubble links 2`] = ` key="1" link="bar" r={33.57142857142857} + scale={1} x={-75} y={33.57142857142857} > @@ -115,7 +119,7 @@ exports[`should render bubble links 2`] = ` "stroke": undefined, } } - transform="translate(-75, 33.57142857142857)" + transform="translate(-75, 33.57142857142857) scale(1)" /> @@ -130,6 +134,7 @@ exports[`should render bubbles with click handlers 1`] = ` link="foo" onClick={[MockFunction]} r={45} + scale={1} x={-10} y={52.3015873015873} > @@ -145,7 +150,7 @@ exports[`should render bubbles with click handlers 1`] = ` "stroke": undefined, } } - transform="translate(-10, 52.3015873015873)" + transform="translate(-10, 52.3015873015873) scale(1)" /> @@ -158,6 +163,7 @@ exports[`should render bubbles with click handlers 2`] = ` link="bar" onClick={[MockFunction]} r={33.57142857142857} + scale={1} x={-75} y={33.57142857142857} > @@ -173,7 +179,7 @@ exports[`should render bubbles with click handlers 2`] = ` "stroke": undefined, } } - transform="translate(-75, 33.57142857142857)" + transform="translate(-75, 33.57142857142857) scale(1)" /> diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock index 91927389191..e99a7491fcf 100644 --- a/server/sonar-web/yarn.lock +++ b/server/sonar-web/yarn.lock @@ -32,10 +32,20 @@ version "1.2.1" resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-1.2.1.tgz#e489605208d46a1c9d980d2e5772fa9c75d9ec65" +"@types/d3-color@*": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-1.2.1.tgz#26141c3c554e320edd40726b793570a3ae57397e" + "@types/d3-hierarchy@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-1.1.1.tgz#f1b7f4bab0a9ecfb9c372a303ded97d83db3cbbf" +"@types/d3-interpolate@*": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-1.1.6.tgz#64041b15c9c032c348da1b22baabc59fa4d16136" + dependencies: + "@types/d3-color" "*" + "@types/d3-path@*": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.6.tgz#c1a7d2dc07b295fdd1c84dabe4404df991b48693" @@ -46,6 +56,14 @@ dependencies: "@types/d3-time" "*" +"@types/d3-selection@*": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-1.3.1.tgz#c6227f4e39d429cc429ce3882fd533facc7f014c" + +"@types/d3-selection@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-1.3.0.tgz#acede3d22c18ec085cc401d4fdab9f040e1a73c7" + "@types/d3-shape@1.2.2": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.2.2.tgz#f8dcdff7772a7ae37858bf04abd43848a78e590e" @@ -56,6 +74,13 @@ version "1.0.7" resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-1.0.7.tgz#4266d7c9be15fa81256a88d1d052d61cd8dc572c" +"@types/d3-zoom@1.7.1": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-1.7.1.tgz#0d69be0bf5849cffb66f48e4258c838436b43822" + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + "@types/enzyme@3.1.10": version "3.1.10" resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.1.10.tgz#28108a9864e65699751469551a803a35d2e26160" @@ -2320,6 +2345,21 @@ d3-color@1: version "1.0.3" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.0.3.tgz#bc7643fca8e53a8347e2fbdaffa236796b58509b" +d3-dispatch@1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.3.tgz#46e1491eaa9b58c358fce5be4e8bed626e7871f8" + +d3-drag@1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-1.2.1.tgz#df8dd4c502fb490fc7462046a8ad98a5c479282d" + dependencies: + d3-dispatch "1" + d3-selection "1" + +d3-ease@1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.3.tgz#68bfbc349338a380c44d8acc4fbc3304aa2d8c0e" + d3-format@1: version "1.2.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.2.0.tgz#6b480baa886885d4651dc248a8f4ac9da16db07a" @@ -2349,7 +2389,7 @@ d3-scale@2.0.0: d3-time "1" d3-time-format "2" -d3-selection@1.3.0: +d3-selection@1, d3-selection@1.3.0, d3-selection@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.3.0.tgz#d53772382d3dc4f7507bfb28bcd2d6aed2a0ad6d" @@ -2369,6 +2409,31 @@ d3-time@1: version "1.0.7" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.7.tgz#94caf6edbb7879bb809d0d1f7572bc48482f7270" +d3-timer@1: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.7.tgz#df9650ca587f6c96607ff4e60cc38229e8dd8531" + +d3-transition@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.1.1.tgz#d8ef89c3b848735b060e54a39b32aaebaa421039" + dependencies: + d3-color "1" + d3-dispatch "1" + d3-ease "1" + d3-interpolate "1" + d3-selection "^1.1.0" + d3-timer "1" + +d3-zoom@1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-1.7.1.tgz#02f43b3c3e2db54f364582d7e4a236ccc5506b63" + dependencies: + d3-dispatch "1" + d3-drag "1" + d3-interpolate "1" + d3-selection "1" + d3-transition "1" + damerau-levenshtein@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514" -- 2.39.5