]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9402 Add wheel zoom to the project history graphs
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Thu, 22 Jun 2017 09:22:24 +0000 (11:22 +0200)
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>
Tue, 4 Jul 2017 12:15:34 +0000 (14:15 +0200)
server/sonar-web/src/main/js/apps/projectActivity/components/GraphsZoom.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js
server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityGraphs-test.js
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap
server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js
server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js
server/sonar-web/src/main/less/components/graphics.less

index 3dea9f1a1886c0dc9c3b7cf48ef47b55a7dfb3d9..73e9411b862878c2c786c11ca8941785bf1bf665 100644 (file)
  */
 // @flow
 import React from 'react';
-import { some, throttle } from 'lodash';
+import { some } from 'lodash';
 import { AutoSizer } from 'react-virtualized';
 import ZoomTimeLine from '../../../components/charts/ZoomTimeLine';
-import type { RawQuery } from '../../../helpers/query';
 import type { Serie } from '../../../components/charts/AdvancedTimeline';
 
 type Props = {
@@ -33,22 +32,14 @@ type Props = {
   metricsType: string,
   series: Array<Serie>,
   showAreas?: boolean,
-  updateGraphZoom: (from: ?Date, to: ?Date) => void,
-  updateQuery: RawQuery => void
+  updateGraphZoom: (from: ?Date, to: ?Date) => void
 };
 
 export default class GraphsZoom extends React.PureComponent {
   props: Props;
 
-  constructor(props: Props) {
-    super(props);
-    this.updateDateRange = throttle(this.updateDateRange, 100);
-  }
-
   hasHistoryData = () => some(this.props.series, serie => serie.data && serie.data.length > 2);
 
-  updateDateRange = (from: ?Date, to: ?Date) => this.props.updateQuery({ from, to });
-
   render() {
     const { loading } = this.props;
     if (loading || !this.hasHistoryData()) {
@@ -70,8 +61,7 @@ export default class GraphsZoom extends React.PureComponent {
               series={this.props.series}
               showAreas={this.props.showAreas}
               startDate={this.props.graphStartDate}
-              updateZoom={this.updateDateRange}
-              updateZoomFast={this.props.updateGraphZoom}
+              updateZoom={this.props.updateGraphZoom}
             />
           )}
         </AutoSizer>
index db1eb841daccd5ff0893bb3b85b8bcb5a30376cc..004ef7091974cfe26a74901874fdafef74b58b8d 100644 (file)
@@ -19,6 +19,7 @@
  */
 // @flow
 import React from 'react';
+import { debounce, sortBy } from 'lodash';
 import ProjectActivityGraphsHeader from './ProjectActivityGraphsHeader';
 import GraphsZoom from './GraphsZoom';
 import StaticGraphs from './StaticGraphs';
@@ -62,6 +63,7 @@ export default class ProjectActivityGraphs extends React.PureComponent {
       graphEndDate: props.query.to || null,
       series
     };
+    this.updateQueryDateRange = debounce(this.updateQueryDateRange, 250);
   }
 
   componentWillReceiveProps(nextProps: Props) {
@@ -100,8 +102,27 @@ export default class ProjectActivityGraphs extends React.PureComponent {
       };
     });
 
-  updateGraphZoom = (graphStartDate: ?Date, graphEndDate: ?Date) =>
+  updateGraphZoom = (graphStartDate: ?Date, graphEndDate: ?Date) => {
+    if (graphEndDate != null && graphStartDate != null) {
+      const msDiff = Math.abs(graphEndDate.valueOf() - graphStartDate.valueOf());
+      // 12 hours minimum between the two dates
+      if (msDiff < 1000 * 60 * 60 * 12) {
+        return;
+      }
+    }
+
     this.setState({ graphStartDate, graphEndDate });
+    this.updateQueryDateRange([graphStartDate, graphEndDate]);
+  };
+
+  updateQueryDateRange = (dates: Array<?Date>) => {
+    if (dates[0] == null || dates[1] == null) {
+      this.props.updateQuery({ from: dates[0], to: dates[1] });
+    } else {
+      const sortedDates = sortBy(dates);
+      this.props.updateQuery({ from: sortedDates[0], to: sortedDates[1] });
+    }
+  };
 
   render() {
     const { leakPeriodDate, loading, metricsType, query } = this.props;
@@ -120,6 +141,7 @@ export default class ProjectActivityGraphs extends React.PureComponent {
           project={this.props.project}
           series={series}
           showAreas={['coverage', 'duplications'].includes(query.graph)}
+          updateGraphZoom={this.updateGraphZoom}
         />
         <GraphsZoom
           graphEndDate={this.state.graphEndDate}
@@ -130,7 +152,6 @@ export default class ProjectActivityGraphs extends React.PureComponent {
           series={series}
           showAreas={['coverage', 'duplications'].includes(query.graph)}
           updateGraphZoom={this.updateGraphZoom}
-          updateQuery={this.props.updateQuery}
         />
       </div>
     );
index e14f5f097fb5fa1d8c858357a21dfab375408753..7428bdd328299f18f33d3e67494d819ac4a65f2f 100644 (file)
@@ -32,13 +32,14 @@ import type { Serie } from '../../../components/charts/AdvancedTimeline';
 type Props = {
   analyses: Array<Analysis>,
   eventFilter: string,
+  graphEndDate: ?Date,
   graphStartDate: ?Date,
   leakPeriodDate: Date,
   loading: boolean,
   metricsType: string,
   series: Array<Serie>,
   showAreas?: boolean,
-  graphEndDate: ?Date
+  updateGraphZoom: (from: ?Date, to: ?Date) => void
 };
 
 export default class StaticGraphs extends React.PureComponent {
@@ -108,6 +109,7 @@ export default class StaticGraphs extends React.PureComponent {
                 endDate={this.props.graphEndDate}
                 events={this.getEvents()}
                 height={height}
+                width={width}
                 interpolate="linear"
                 formatValue={this.formatValue}
                 formatYTick={this.formatYTick}
@@ -116,7 +118,7 @@ export default class StaticGraphs extends React.PureComponent {
                 series={series}
                 showAreas={this.props.showAreas}
                 startDate={this.props.graphStartDate}
-                width={width}
+                updateZoom={this.props.updateGraphZoom}
               />
             )}
           </AutoSizer>
index 3434f552faee55486d8a67c7a09e974afac1f67a..2b27845d57cfd267c11bd790a5efda575859fddd 100644 (file)
@@ -80,7 +80,7 @@ it('should render correctly the graph and legends', () => {
   expect(shallow(<ProjectActivityGraphs {...DEFAULT_PROPS} />)).toMatchSnapshot();
 });
 
-it('should render correctly filter history on dates', () => {
+it('should render correctly with filter history on dates', () => {
   const wrapper = shallow(
     <ProjectActivityGraphs
       {...DEFAULT_PROPS}
index 0fa696ab435f3a7e073db2c8ae458c181d08c266..67899574868e231fe2b93d7509e7611b857d3d46 100644 (file)
@@ -1,39 +1,5 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`should render correctly filter history on dates 1`] = `
-Object {
-  "filteredSeries": Array [
-    Object {
-      "data": Array [],
-      "name": "code_smells",
-      "style": 1,
-      "translatedName": "metric.code_smells.name",
-    },
-  ],
-  "series": Array [
-    Object {
-      "data": Array [
-        Object {
-          "x": 2016-10-26T10:17:29.000Z,
-          "y": 2286,
-        },
-        Object {
-          "x": 2016-10-27T10:21:15.000Z,
-          "y": 1749,
-        },
-        Object {
-          "x": 2016-10-27T14:33:50.000Z,
-          "y": 500,
-        },
-      ],
-      "name": "code_smells",
-      "style": 1,
-      "translatedName": "metric.code_smells.name",
-    },
-  ],
-}
-`;
-
 exports[`should render correctly the graph and legends 1`] = `
 <div
   className="project-activity-layout-page-main-inner boxed-group boxed-group-inner"
@@ -80,7 +46,13 @@ exports[`should render correctly the graph and legends 1`] = `
       ]
     }
     eventFilter=""
-    filteredSeries={
+    graphEndDate={null}
+    graphStartDate={null}
+    leakPeriodDate="2017-05-16T13:50:02+0200"
+    loading={false}
+    metricsType="INT"
+    project="org.sonarsource.sonarqube:sonarqube"
+    series={
       Array [
         Object {
           "data": Array [
@@ -103,10 +75,15 @@ exports[`should render correctly the graph and legends 1`] = `
         },
       ]
     }
+    showAreas={false}
+    updateGraphZoom={[Function]}
+  />
+  <GraphsZoom
+    graphEndDate={null}
+    graphStartDate={null}
     leakPeriodDate="2017-05-16T13:50:02+0200"
     loading={false}
     metricsType="INT"
-    project="org.sonarsource.sonarqube:sonarqube"
     series={
       Array [
         Object {
@@ -131,6 +108,35 @@ exports[`should render correctly the graph and legends 1`] = `
       ]
     }
     showAreas={false}
+    updateGraphZoom={[Function]}
   />
 </div>
 `;
+
+exports[`should render correctly with filter history on dates 1`] = `
+Object {
+  "graphEndDate": null,
+  "graphStartDate": "2016-10-27T12:21:15+0200",
+  "series": Array [
+    Object {
+      "data": Array [
+        Object {
+          "x": 2016-10-26T10:17:29.000Z,
+          "y": 2286,
+        },
+        Object {
+          "x": 2016-10-27T10:21:15.000Z,
+          "y": 1749,
+        },
+        Object {
+          "x": 2016-10-27T14:33:50.000Z,
+          "y": 500,
+        },
+      ],
+      "name": "code_smells",
+      "style": 1,
+      "translatedName": "metric.code_smells.name",
+    },
+  ],
+}
+`;
index 7beb5d31afc9fc0e6b9d1c71947f67f5ed4c4109..fa495a1ae8cf89b263e14e44ce413aff32d88848 100644 (file)
@@ -44,7 +44,9 @@ type Props = {
   series: Array<Serie>,
   showAreas?: boolean,
   showEventMarkers?: boolean,
-  startDate: ?Date
+  startDate: ?Date,
+  updateZoom: (start: ?Date, endDate: ?Date) => void,
+  zoomSpeed: number
 };
 
 export default class AdvancedTimeline extends React.PureComponent {
@@ -52,7 +54,8 @@ export default class AdvancedTimeline extends React.PureComponent {
 
   static defaultProps = {
     eventSize: 8,
-    padding: [10, 10, 30, 60]
+    padding: [10, 10, 30, 60],
+    zoomSpeed: 1
   };
 
   getRatingScale = (availableHeight: number) =>
@@ -75,7 +78,11 @@ export default class AdvancedTimeline extends React.PureComponent {
     const dateRange = extent(flatData, d => d.x);
     const start = this.props.startDate ? this.props.startDate : dateRange[0];
     const end = this.props.endDate ? this.props.endDate : dateRange[1];
-    return scaleTime().domain(sortBy([start, end])).range([0, availableWidth]).clamp(false);
+    const xScale = scaleTime().domain(sortBy([start, end])).range([0, availableWidth]).clamp(false);
+    return {
+      xScale,
+      maxXRange: dateRange.map(xScale)
+    };
   };
 
   getScales = () => {
@@ -83,7 +90,7 @@ export default class AdvancedTimeline extends React.PureComponent {
     const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2];
     const flatData = flatten(this.props.series.map((serie: Serie) => serie.data));
     return {
-      xScale: this.getXScale(availableWidth, flatData),
+      ...this.getXScale(availableWidth, flatData),
       yScale: this.getYScale(availableHeight, flatData)
     };
   };
@@ -93,6 +100,21 @@ export default class AdvancedTimeline extends React.PureComponent {
     return `M${half} 0 L${size} ${half} L ${half} ${size} L0 ${half} L${half} 0 L${size} ${half}`;
   };
 
+  handleWheel = (xScale: Scale, maxXRange: Array<number>) => (
+    evt: WheelEvent & { target: HTMLElement }
+  ) => {
+    evt.preventDefault();
+    const parentBbox = evt.target.getBoundingClientRect();
+    const mouseXPos = (evt.clientX - parentBbox.left) / parentBbox.width;
+    const xRange = xScale.range();
+    const speed = evt.deltaMode ? 25 / evt.deltaMode * this.props.zoomSpeed : this.props.zoomSpeed;
+    const leftPos = xRange[0] - Math.round(speed * evt.deltaY * mouseXPos);
+    const rightPos = xRange[1] + Math.round(speed * evt.deltaY * (1 - mouseXPos));
+    const startDate = leftPos > maxXRange[0] ? xScale.invert(leftPos) : null;
+    const endDate = rightPos < maxXRange[1] ? xScale.invert(rightPos) : null;
+    this.props.updateZoom(startDate, endDate);
+  };
+
   renderHorizontalGrid = (xScale: Scale, yScale: Scale) => {
     const hasTicks = typeof yScale.ticks === 'function';
     const ticks = hasTicks ? yScale.ticks(4) : yScale.domain();
@@ -243,12 +265,23 @@ export default class AdvancedTimeline extends React.PureComponent {
     );
   };
 
+  renderZoomOverlay = (xScale: Scale, yScale: Scale, maxXRange: Array<number>) => {
+    return (
+      <rect
+        className="chart-wheel-zoom-overlay"
+        width={xScale.range()[1]}
+        height={yScale.range()[0]}
+        onWheel={this.handleWheel(xScale, maxXRange)}
+      />
+    );
+  };
+
   render() {
     if (!this.props.width || !this.props.height) {
       return <div />;
     }
 
-    const { xScale, yScale } = this.getScales();
+    const { maxXRange, xScale, yScale } = this.getScales();
     const isZoomed = this.props.startDate || this.props.endDate;
     return (
       <svg
@@ -262,6 +295,7 @@ export default class AdvancedTimeline extends React.PureComponent {
           {this.renderTicks(xScale, yScale)}
           {this.props.showAreas && this.renderAreas(xScale, yScale)}
           {this.renderLines(xScale, yScale)}
+          {this.renderZoomOverlay(xScale, yScale, maxXRange)}
           {this.props.showEventMarkers && this.renderEvents(xScale, yScale)}
         </g>
       </svg>
index 85b11114e0714b0f0be937fabf86ad9f57927327..34a11d878cf7ca28e6b20e3b0a8b08eb9acaabe2 100644 (file)
@@ -21,7 +21,7 @@
 import React from 'react';
 import classNames from 'classnames';
 import { flatten, sortBy } from 'lodash';
-import { extent, max, min } from 'd3-array';
+import { extent, max } from 'd3-array';
 import { scaleLinear, scalePoint, scaleTime } from 'd3-scale';
 import { line as d3Line, area, curveBasis } from 'd3-shape';
 import Draggable, { DraggableCore } from 'react-draggable';
@@ -41,8 +41,7 @@ type Props = {
   showAreas?: boolean,
   showXTicks?: boolean,
   startDate: ?Date,
-  updateZoom: (start: ?Date, endDate: ?Date) => void,
-  updateZoomFast: (start: ?Date, endDate: ?Date) => void
+  updateZoom: (start: ?Date, endDate: ?Date) => void
 };
 
 type State = {
@@ -96,35 +95,42 @@ export default class ZoomTimeLine extends React.PureComponent {
 
   handleSelectionDrag = (
     xScale: Scale,
-    updateFunc: (xScale: Scale, xArray: Array<number>) => void,
+    width: number,
+    xDim: Array<number>,
     checkDelta?: boolean
   ) => (e: Event, data: DraggableData) => {
     if (!checkDelta || data.deltaX) {
-      updateFunc(xScale, [data.x, data.node.getBoundingClientRect().width + data.x]);
+      const x = Math.max(xDim[0], Math.min(data.x, xDim[1] - width));
+      this.handleZoomUpdate(xScale, [x, width + x]);
     }
   };
 
   handleSelectionHandleDrag = (
     xScale: Scale,
     fixedX: number,
-    updateFunc: (xScale: Scale, xArray: Array<number>) => void,
+    xDim: Array<number>,
     handleDirection: string,
     checkDelta?: boolean
   ) => (e: Event, data: DraggableData) => {
     if (!checkDelta || data.deltaX) {
-      updateFunc(xScale, handleDirection === 'right' ? [fixedX, data.x] : [data.x, fixedX]);
+      const x = Math.max(xDim[0], Math.min(data.x, xDim[1]));
+      this.handleZoomUpdate(xScale, handleDirection === 'right' ? [fixedX, x] : [x, fixedX]);
     }
   };
 
-  handleNewZoomDragStart = (e: Event, data: DraggableData) =>
-    this.setState({ newZoomStart: data.x - data.node.getBoundingClientRect().left });
+  handleNewZoomDragStart = (xDim: Array<number>) => (e: Event, data: DraggableData) =>
+    this.setState({
+      newZoomStart: Math.round(
+        Math.max(xDim[0], Math.min(data.x - data.node.getBoundingClientRect().left, xDim[1]))
+      )
+    });
 
-  handleNewZoomDrag = (xScale: Scale) => (e: Event, data: DraggableData) => {
+  handleNewZoomDrag = (xScale: Scale, xDim: Array<number>) => (e: Event, data: DraggableData) => {
     const { newZoomStart } = this.state;
     if (newZoomStart != null && data.deltaX) {
-      this.handleFastZoomUpdate(xScale, [
+      this.handleZoomUpdate(xScale, [
         newZoomStart,
-        data.x - data.node.getBoundingClientRect().left
+        Math.max(xDim[0], Math.min(data.x - data.node.getBoundingClientRect().left, xDim[1]))
       ]);
     }
   };
@@ -135,7 +141,10 @@ export default class ZoomTimeLine extends React.PureComponent {
   ) => {
     const { newZoomStart } = this.state;
     if (newZoomStart != null) {
-      const x = data.x - data.node.getBoundingClientRect().left;
+      const x = Math.max(
+        xDim[0],
+        Math.min(data.x - data.node.getBoundingClientRect().left, xDim[1])
+      );
       this.handleZoomUpdate(xScale, newZoomStart === x ? xDim : [newZoomStart, x]);
       this.setState({ newZoomStart: null });
     }
@@ -143,24 +152,17 @@ export default class ZoomTimeLine extends React.PureComponent {
 
   handleZoomUpdate = (xScale: Scale, xArray: Array<number>) => {
     const xRange = xScale.range();
-    const xStart = min(xArray);
-    const xEnd = max(xArray);
-    const startDate = xStart > xRange[0] ? xScale.invert(xStart) : null;
-    const endDate = xEnd < xRange[xRange.length - 1] ? xScale.invert(xEnd) : null;
+    const startDate = xArray[0] > xRange[0] && xArray[0] < xRange[xRange.length - 1]
+      ? xScale.invert(xArray[0])
+      : null;
+    const endDate = xArray[1] > xRange[0] && xArray[1] < xRange[xRange.length - 1]
+      ? xScale.invert(xArray[1])
+      : null;
     if (this.props.startDate !== startDate || this.props.endDate !== endDate) {
       this.props.updateZoom(startDate, endDate);
     }
   };
 
-  handleFastZoomUpdate = (xScale: Scale, xArray: Array<number>) => {
-    const xRange = xScale.range();
-    const startDate = xArray[0] > xRange[0] ? xScale.invert(xArray[0]) : null;
-    const endDate = xArray[1] < xRange[xRange.length - 1] ? xScale.invert(xArray[1]) : null;
-    if (this.props.startDate !== startDate || this.props.endDate !== endDate) {
-      this.props.updateZoomFast(startDate, endDate);
-    }
-  };
-
   renderBaseLine = (xScale: Scale, yScale: Scale) => {
     return (
       <line
@@ -252,7 +254,7 @@ export default class ZoomTimeLine extends React.PureComponent {
   };
 
   renderZoomHandle = (
-    opts: {
+    options: {
       xScale: Scale,
       xPos: number,
       fixedPos: number,
@@ -263,26 +265,26 @@ export default class ZoomTimeLine extends React.PureComponent {
   ) => (
     <Draggable
       axis="x"
-      bounds={{ left: opts.xDim[0], right: opts.xDim[1] }}
-      position={{ x: opts.xPos, y: 0 }}
+      bounds={{ left: options.xDim[0], right: options.xDim[1] }}
+      position={{ x: options.xPos, y: 0 }}
       onDrag={this.handleSelectionHandleDrag(
-        opts.xScale,
-        opts.fixedPos,
-        this.handleFastZoomUpdate,
-        opts.direction,
+        options.xScale,
+        options.fixedPos,
+        options.xDim,
+        options.direction,
         true
       )}
       onStop={this.handleSelectionHandleDrag(
-        opts.xScale,
-        opts.fixedPos,
-        this.handleZoomUpdate,
-        opts.direction
+        options.xScale,
+        options.fixedPos,
+        options.xDim,
+        options.direction
       )}>
       <rect
         className="zoom-selection-handle"
         x={-3}
-        y={opts.yDim[1]}
-        height={opts.yDim[0] - opts.yDim[1]}
+        y={options.yDim[1]}
+        height={options.yDim[0] - options.yDim[1]}
         width={6}
       />
     </Draggable>
@@ -296,12 +298,13 @@ export default class ZoomTimeLine extends React.PureComponent {
     const startX = Math.round(this.props.startDate ? xScale(this.props.startDate) : xDim[0]);
     const endX = Math.round(this.props.endDate ? xScale(this.props.endDate) : xDim[1]);
     const xArray = sortBy([startX, endX]);
+    const zoomBoxWidth = xArray[1] - xArray[0];
     const showZoomArea = this.state.newZoomStart == null || this.state.newZoomStart === startX;
     return (
       <g className="chart-zoom">
         <DraggableCore
-          onStart={this.handleNewZoomDragStart}
-          onDrag={this.handleNewZoomDrag(xScale)}
+          onStart={this.handleNewZoomDragStart(xDim)}
+          onDrag={this.handleNewZoomDrag(xScale, xDim)}
           onStop={this.handleNewZoomDragEnd(xScale, xDim)}>
           <rect
             className="zoom-overlay"
@@ -314,16 +317,16 @@ export default class ZoomTimeLine extends React.PureComponent {
         {showZoomArea &&
           <Draggable
             axis="x"
-            bounds={{ left: xDim[0], right: xDim[1] - xArray[1] + xArray[0] }}
+            bounds={{ left: xDim[0], right: Math.floor(xDim[1] - zoomBoxWidth) }}
             position={{ x: xArray[0], y: 0 }}
-            onDrag={this.handleSelectionDrag(xScale, this.handleFastZoomUpdate, true)}
-            onStop={this.handleSelectionDrag(xScale, this.handleZoomUpdate)}>
+            onDrag={this.handleSelectionDrag(xScale, zoomBoxWidth, xDim, true)}
+            onStop={this.handleSelectionDrag(xScale, zoomBoxWidth, xDim)}>
             <rect
               className="zoom-selection"
               x={0}
-              y={yDim[1]}
+              y={yDim[1] + 1}
               height={yDim[0] - yDim[1]}
-              width={xArray[1] - xArray[0]}
+              width={zoomBoxWidth}
             />
           </Draggable>}
         {showZoomArea &&
index 7aef4947c4073b81aab41fc6bee7fab036244f57..916fea38a744ffda9038b5d43a6570712715492e 100644 (file)
   text-anchor: middle;
 }
 
+.chart-wheel-zoom-overlay {
+  fill: none;
+  stroke: none;
+  pointer-events: all;
+}
+
 .chart-zoom {
 
   .zoom-overlay{