diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-06-22 11:22:24 +0200 |
---|---|---|
committer | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-07-04 14:15:34 +0200 |
commit | 35b4bc1e0319f991f9bb72c681c6be7f7991b478 (patch) | |
tree | 139e7f78caab331629e7a697c6d1dd046a35e241 | |
parent | 7feb62d1317819a82df8dcbc71969d9c1d51bdc7 (diff) | |
download | sonarqube-35b4bc1e0319f991f9bb72c681c6be7f7991b478.tar.gz sonarqube-35b4bc1e0319f991f9bb72c681c6be7f7991b478.zip |
SONAR-9402 Add wheel zoom to the project history graphs
8 files changed, 167 insertions, 105 deletions
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsZoom.js b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsZoom.js index 3dea9f1a188..73e9411b862 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsZoom.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsZoom.js @@ -19,10 +19,9 @@ */ // @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> diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js index db1eb841dac..004ef709197 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js @@ -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> ); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js index e14f5f097fb..7428bdd3282 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js @@ -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> diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityGraphs-test.js b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityGraphs-test.js index 3434f552fae..2b27845d57c 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityGraphs-test.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityGraphs-test.js @@ -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} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap index 0fa696ab435..67899574868 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap @@ -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", + }, + ], +} +`; diff --git a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js index 7beb5d31afc..fa495a1ae8c 100644 --- a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js +++ b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js @@ -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> diff --git a/server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js b/server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js index 85b11114e07..34a11d878cf 100644 --- a/server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js +++ b/server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js @@ -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 && diff --git a/server/sonar-web/src/main/less/components/graphics.less b/server/sonar-web/src/main/less/components/graphics.less index 7aef4947c40..916fea38a74 100644 --- a/server/sonar-web/src/main/less/components/graphics.less +++ b/server/sonar-web/src/main/less/components/graphics.less @@ -284,6 +284,12 @@ text-anchor: middle; } +.chart-wheel-zoom-overlay { + fill: none; + stroke: none; + pointer-events: all; +} + .chart-zoom { .zoom-overlay{ |