]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9416 Enable zoom on bubble charts (#127)
authorPascal Mugnier <pascal.mugnier@sonarsource.com>
Wed, 11 Apr 2018 13:45:43 +0000 (15:45 +0200)
committerSonarTech <sonartech@sonarsource.com>
Wed, 11 Apr 2018 18:20:48 +0000 (20:20 +0200)
server/sonar-web/package.json
server/sonar-web/src/main/js/app/styles/components/badges.css
server/sonar-web/src/main/js/app/styles/components/graphics.css
server/sonar-web/src/main/js/components/charts/BubbleChart.d.ts [deleted file]
server/sonar-web/src/main/js/components/charts/BubbleChart.js [deleted file]
server/sonar-web/src/main/js/components/charts/BubbleChart.tsx [new file with mode: 0644]
server/sonar-web/yarn.lock
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index eadf694d63a7ba62e0578be35c23cbc5f847725c..78e0324d5a36ad9a41d2ec757cdde5f11a4dcf58 100644 (file)
@@ -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",
index 602f7513f1a0c7364ea5e334654f966aec80b0ac..1dd87fe7d6484ab1d0eebd75574d3ad5115a6b46 100644 (file)
@@ -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);
+}
index 049dde27ec57859c37645df38762b36befba09b9..03128aa2a68161153a80c9a92fd0ed18e25324dd 100644 (file)
  * 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 {
   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 (file)
index f428115..0000000
+++ /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<Props> {}
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 (file)
index 73fcce0..0000000
+++ /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<number>
-};
-*/
-
-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 = (
-      <circle
-        className="bubble-chart-bubble"
-        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})`}
-      />
-    );
-
-    if (this.props.link && !this.props.onClick) {
-      circle = <Link to={this.props.link}>{circle}</Link>;
-    }
-
-    return (
-      <Tooltip overlay={this.props.tooltip || undefined}>
-        <g>{circle}</g>
-      </Tooltip>
-    );
-  }
-}
-
-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<number> */, 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 <line key={index} x1={x} x2={x} y1={y1} y2={y2} className="bubble-chart-grid" />;
-    });
-
-    return <g ref="xGrid">{lines}</g>;
-  }
-
-  renderYGrid(ticks /*: Array<number> */, 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 <line key={index} x1={x1} x2={x2} y1={y} y2={y} className="bubble-chart-grid" />;
-    });
-
-    return <g ref="yGrid">{lines}</g>;
-  }
-
-  renderXTicks(xTicks /*: Array<number> */, 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 (
-        <text key={index} className="bubble-chart-tick" x={x} y={y} dy="1.5em">
-          {innerText}
-        </text>
-      );
-    });
-
-    return <g>{ticks}</g>;
-  }
-
-  renderYTicks(yTicks /*: Array<number> */, 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 (
-        <text
-          key={index}
-          className="bubble-chart-tick bubble-chart-tick-y"
-          x={x}
-          y={y}
-          dx="-0.5em"
-          dy="0.3em">
-          {innerText}
-        </text>
-      );
-    });
-
-    return <g>{ticks}</g>;
-  }
-
-  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 (
-        <Bubble
-          key={item.key || index}
-          link={item.link}
-          tooltip={item.tooltip}
-          x={xScale(item.x)}
-          y={yScale(item.y)}
-          r={sizeScale(item.size)}
-          color={item.color}
-          onClick={this.props.onBubbleClick}
-        />
-      );
-    });
-
-    const xTicks = this.getTicks(xScale, this.props.formatXTick);
-    const yTicks = this.getTicks(yScale, this.props.formatYTick);
-
-    return (
-      <svg className="bubble-chart" width={width} height={this.props.height}>
-        <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
-          {this.renderXGrid(xTicks, xScale, yScale)}
-          {this.renderXTicks(xTicks, xScale, yScaleOriginal)}
-          {this.renderYGrid(yTicks, xScale, yScale)}
-          {this.renderYTicks(yTicks, xScaleOriginal, yScale)}
-          {bubbles}
-        </g>
-      </svg>
-    );
-  }
-
-  render() {
-    return <AutoSizer disableHeight={true}>{size => this.renderChart(size.width)}</AutoSizer>;
-  }
-}
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 (file)
index 0000000..cd7033c
--- /dev/null
@@ -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<BubbleProps> {
+  handleClick = (event: React.MouseEvent<SVGCircleElement>) => {
+    if (this.props.onClick) {
+      event.stopPropagation();
+      event.preventDefault();
+      this.props.onClick(this.props.link);
+    }
+  };
+
+  render() {
+    let circle = (
+      <circle
+        className="bubble-chart-bubble"
+        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})`}
+      />
+    );
+
+    if (this.props.link && !this.props.onClick) {
+      circle = <Link to={this.props.link}>{circle}</Link>;
+    }
+
+    return (
+      <Tooltip overlay={this.props.tooltip || undefined}>
+        <g>{circle}</g>
+      </Tooltip>
+    );
+  }
+}
+
+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<number, number>;
+
+export default class BubbleChart extends React.Component<Props, State> {
+  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<SVGSVGElement>) => {
+    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<SVGSVGElement>) => {
+    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<number>, 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 (
+        <line
+          className="bubble-chart-grid"
+          key={index}
+          x1={x * this.state.zoom - delta}
+          x2={x * this.state.zoom - delta}
+          y1={y1}
+          y2={y2}
+        />
+      );
+    });
+
+    return <g>{lines}</g>;
+  };
+
+  renderYGrid = (ticks: Array<number>, 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 (
+        <line
+          className="bubble-chart-grid"
+          key={index}
+          x1={x1}
+          x2={x2}
+          y1={y * this.state.zoom - delta}
+          y2={y * this.state.zoom - delta}
+        />
+      );
+    });
+
+    return <g>{lines}</g>;
+  };
+
+  renderXTicks = (xTicks: Array<number>, 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 (
+        <text
+          className="bubble-chart-tick"
+          dy="1.5em"
+          key={index}
+          x={x * this.state.zoom - delta}
+          y={y}>
+          {innerText}
+        </text>
+      );
+    });
+
+    return <g>{ticks}</g>;
+  };
+
+  renderYTicks = (yTicks: Array<number>, 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 (
+        <text
+          className="bubble-chart-tick bubble-chart-tick-y"
+          dx="-0.5em"
+          dy="0.3em"
+          key={index}
+          x={x}
+          y={y * this.state.zoom - delta}>
+          {innerText}
+        </text>
+      );
+    });
+
+    return <g>{ticks}</g>;
+  };
+
+  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 (
+        <Bubble
+          color={item.color}
+          key={item.key || index}
+          link={item.link}
+          onClick={this.props.onBubbleClick}
+          r={sizeScale(item.size)}
+          tooltip={item.tooltip}
+          x={xScale(item.x) * this.state.zoom - centerXDelta}
+          y={yScale(item.y) * this.state.zoom - centerYDelta}
+        />
+      );
+    });
+
+    const xTicks = this.getTicks(xScale, this.props.formatXTick);
+    const yTicks = this.getTicks(yScale, this.props.formatYTick);
+
+    return (
+      <svg
+        className={classNames('bubble-chart', { 'is-moving': this.state.isMoving })}
+        height={this.props.height}
+        onMouseDown={this.startMoving}
+        onWheel={this.onWheel}
+        ref={node => (this.node = node)}
+        width={width}>
+        <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
+          <svg
+            height={this.props.height - this.props.padding[0] - this.props.padding[2]}
+            style={{ overflow: 'hidden' }}
+            width={width}>
+            {this.renderXGrid(xTicks, xScale, yScale, centerXDelta)}
+            {this.renderYGrid(yTicks, xScale, yScale, centerYDelta)}
+            {bubbles}
+          </svg>
+          {this.renderXTicks(xTicks, xScale, yScaleOriginal, centerXDelta)}
+          {this.renderYTicks(yTicks, xScaleOriginal, yScale, centerYDelta)}
+        </g>
+      </svg>
+    );
+  };
+
+  render() {
+    return (
+      <div>
+        <div className="bubble-chart-zoom">
+          <Tooltip overlay={translate('component_measures.bubble_chart.zoom_level')}>
+            <Link
+              className={classNames('outline-badge', { active: this.state.zoom > 1 })}
+              onClick={this.resetZoom}
+              to="#">
+              {this.getZoomLevelLabel()}
+            </Link>
+          </Tooltip>
+        </div>
+        <AutoSizer disableHeight={true}>{size => this.renderChart(size.width)}</AutoSizer>
+      </div>
+    );
+  }
+}
index dd0142459a64387d7e7d792a81aecfbd0503b072..ad4e577368c20e57020e524de97fcd215fe137f9 100644 (file)
@@ -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"
 
   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"
index 97f73111a99b9cdeb8c8a744f8ad9df1cb8c9682..aba9c50be010f4469429cbaea62bfdf534a0e1b8 100644 (file)
@@ -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.
 
 #------------------------------------------------------------------------------
 #