From 7feb62d1317819a82df8dcbc71969d9c1d51bdc7 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Gr=C3=A9goire=20Aubert?= Date: Wed, 21 Jun 2017 15:53:31 +0200 Subject: [PATCH] SONAR-9402 Add basic zooming capabilities to the project history graphs --- .../projectActivity/components/GraphsZoom.js | 81 ++++ .../components/ProjectActivityGraphs.js | 71 ++-- .../components/StaticGraphs.js | 12 +- .../src/main/js/apps/projectActivity/utils.js | 3 + .../js/components/charts/AdvancedTimeline.js | 50 ++- .../main/js/components/charts/ZoomTimeLine.js | 370 ++++++++++++++++++ .../src/main/less/components/graphics.less | 44 +++ 7 files changed, 586 insertions(+), 45 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/projectActivity/components/GraphsZoom.js create mode 100644 server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js 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 new file mode 100644 index 00000000000..3dea9f1a188 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/GraphsZoom.js @@ -0,0 +1,81 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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 { some, throttle } 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 = { + graphEndDate: ?Date, + graphStartDate: ?Date, + leakPeriodDate: Date, + loading: boolean, + metricsType: string, + series: Array, + showAreas?: boolean, + updateGraphZoom: (from: ?Date, to: ?Date) => void, + updateQuery: RawQuery => 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()) { + return null; + } + + return ( +
+ + {({ width }) => ( + + )} + +
+ ); + } +} 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 5680eca4306..db1eb841dac 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 @@ -20,8 +20,14 @@ // @flow import React from 'react'; import ProjectActivityGraphsHeader from './ProjectActivityGraphsHeader'; +import GraphsZoom from './GraphsZoom'; import StaticGraphs from './StaticGraphs'; -import { GRAPHS_METRICS, generateCoveredLinesMetric, historyQueryChanged } from '../utils'; +import { + GRAPHS_METRICS, + datesQueryChanged, + generateCoveredLinesMetric, + historyQueryChanged +} from '../utils'; import { translate } from '../../../helpers/l10n'; import type { RawQuery } from '../../../helpers/query'; import type { Analysis, MeasureHistory, Query } from '../types'; @@ -39,7 +45,8 @@ type Props = { }; type State = { - filteredSeries: Array, + graphStartDate: ?Date, + graphEndDate: ?Date, series: Array }; @@ -51,7 +58,8 @@ export default class ProjectActivityGraphs extends React.PureComponent { super(props); const series = this.getSeries(props.measuresHistory); this.state = { - filteredSeries: this.filterSeries(series, props.query), + graphStartDate: props.query.from || null, + graphEndDate: props.query.to || null, series }; } @@ -62,10 +70,13 @@ export default class ProjectActivityGraphs extends React.PureComponent { historyQueryChanged(this.props.query, nextProps.query) ) { const series = this.getSeries(nextProps.measuresHistory); - this.setState({ - filteredSeries: this.filterSeries(series, nextProps.query), - series - }); + this.setState({ series }); + } + if ( + nextProps.query !== this.props.query && + datesQueryChanged(this.props.query, nextProps.query) + ) { + this.setState({ graphStartDate: nextProps.query.from, graphEndDate: nextProps.query.to }); } } @@ -89,35 +100,37 @@ export default class ProjectActivityGraphs extends React.PureComponent { }; }); - filterSeries = (series: Array, query: Query): Array => { - if (!query.from && !query.to) { - return series; - } - return series.map(serie => ({ - ...serie, - data: serie.data.filter(p => { - const isAfterFrom = !query.from || p.x >= query.from; - const isBeforeTo = !query.to || p.x <= query.to; - return isAfterFrom && isBeforeTo; - }) - })); - }; + updateGraphZoom = (graphStartDate: ?Date, graphEndDate: ?Date) => + this.setState({ graphStartDate, graphEndDate }); render() { - const { graph, category } = this.props.query; + const { leakPeriodDate, loading, metricsType, query } = this.props; + const { series } = this.state; return (
- + +
); 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 086c9a6e1d1..e14f5f097fb 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,11 +32,13 @@ import type { Serie } from '../../../components/charts/AdvancedTimeline'; type Props = { analyses: Array, eventFilter: string, - filteredSeries: Array, + graphStartDate: ?Date, leakPeriodDate: Date, loading: boolean, metricsType: string, - series: Array + series: Array, + showAreas?: boolean, + graphEndDate: ?Date }; export default class StaticGraphs extends React.PureComponent { @@ -95,7 +97,7 @@ export default class StaticGraphs extends React.PureComponent { ); } - const { filteredSeries, series } = this.props; + const { series } = this.props; return (
@@ -103,6 +105,7 @@ export default class StaticGraphs extends React.PureComponent { {({ height, width }) => ( )} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/utils.js b/server/sonar-web/src/main/js/apps/projectActivity/utils.js index 257300e503d..ae651fd891f 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/utils.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/utils.js @@ -77,6 +77,9 @@ export const activityQueryChanged = (prevQuery: Query, nextQuery: Query): boolea export const historyQueryChanged = (prevQuery: Query, nextQuery: Query): boolean => prevQuery.graph !== nextQuery.graph; +export const datesQueryChanged = (prevQuery: Query, nextQuery: Query): boolean => + prevQuery.from !== nextQuery.from || prevQuery.to !== nextQuery.to; + export const generateCoveredLinesMetric = ( uncoveredLines: MeasureHistory, measuresHistory: Array, 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 bc71efecf69..7beb5d31afc 100644 --- a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js +++ b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js @@ -20,18 +20,19 @@ // @flow import React from 'react'; import classNames from 'classnames'; -import { flatten } from 'lodash'; +import { flatten, sortBy } from 'lodash'; import { extent, max } from 'd3-array'; import { scaleLinear, scalePoint, scaleTime } from 'd3-scale'; import { line as d3Line, area, curveBasis } from 'd3-shape'; type Event = { className?: string, name: string, date: Date }; -type Point = { x: Date, y: number | string }; +export type Point = { x: Date, y: number | string }; export type Serie = { name: string, data: Array, style: string }; type Scale = Function; type Props = { basisCurve?: boolean, + endDate: ?Date, events?: Array, eventSize?: number, formatYTick: number => string, @@ -42,7 +43,8 @@ type Props = { padding: Array, series: Array, showAreas?: boolean, - showEventMarkers?: boolean + showEventMarkers?: boolean, + startDate: ?Date }; export default class AdvancedTimeline extends React.PureComponent { @@ -50,7 +52,7 @@ export default class AdvancedTimeline extends React.PureComponent { static defaultProps = { eventSize: 8, - padding: [25, 25, 30, 70] + padding: [10, 10, 30, 60] }; getRatingScale = (availableHeight: number) => @@ -69,8 +71,12 @@ export default class AdvancedTimeline extends React.PureComponent { } }; - getXScale = (availableWidth: number, flatData: Array) => - scaleTime().domain(extent(flatData, d => d.x)).range([0, availableWidth]).clamp(true); + getXScale = (availableWidth: number, flatData: Array) => { + 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); + }; getScales = () => { const availableWidth = this.props.width - this.props.padding[1] - this.props.padding[3]; @@ -131,7 +137,7 @@ export default class AdvancedTimeline extends React.PureComponent { const nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1]; const x = (xScale(tick) + xScale(nextTick)) / 2; return ( - + {format(tick)} ); @@ -144,13 +150,18 @@ export default class AdvancedTimeline extends React.PureComponent { if (!this.props.leakPeriodDate) { return null; } - const yScaleRange = yScale.range(); + const yRange = yScale.range(); + const xRange = xScale.range(); + const leakWidth = xRange[xRange.length - 1] - xScale(this.props.leakPeriodDate); + if (leakWidth < 0) { + return null; + } return ( ); @@ -222,14 +233,29 @@ export default class AdvancedTimeline extends React.PureComponent { ); }; + renderClipPath = (xScale: Scale, yScale: Scale) => { + return ( + + + + + + ); + }; + render() { if (!this.props.width || !this.props.height) { return
; } const { xScale, yScale } = this.getScales(); + const isZoomed = this.props.startDate || this.props.endDate; return ( - + + {this.renderClipPath(xScale, yScale)} {this.renderLeak(xScale, yScale)} {this.renderHorizontalGrid(xScale, yScale)} diff --git a/server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js b/server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js new file mode 100644 index 00000000000..85b11114e07 --- /dev/null +++ b/server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js @@ -0,0 +1,370 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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 classNames from 'classnames'; +import { flatten, sortBy } from 'lodash'; +import { extent, max, min } 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'; +import type { DraggableData } from 'react-draggable'; +import type { Point, Serie } from './AdvancedTimeline'; + +type Scale = Function; + +type Props = { + basisCurve?: boolean, + endDate: ?Date, + height: number, + width: number, + leakPeriodDate: Date, + padding: Array, + series: Array, + showAreas?: boolean, + showXTicks?: boolean, + startDate: ?Date, + updateZoom: (start: ?Date, endDate: ?Date) => void, + updateZoomFast: (start: ?Date, endDate: ?Date) => void +}; + +type State = { + newZoomStart: ?number +}; + +export default class ZoomTimeLine extends React.PureComponent { + props: Props; + static defaultProps = { + padding: [0, 0, 18, 0], + showXTicks: true + }; + + state: State = { + newZoomStart: null + }; + + getRatingScale = (availableHeight: number) => + scalePoint().domain([5, 4, 3, 2, 1]).range([availableHeight, 0]); + + getLevelScale = (availableHeight: number) => + scalePoint().domain(['ERROR', 'WARN', 'OK']).range([availableHeight, 0]); + + getYScale = (availableHeight: number, flatData: Array) => { + if (this.props.metricType === 'RATING') { + return this.getRatingScale(availableHeight); + } else if (this.props.metricType === 'LEVEL') { + return this.getLevelScale(availableHeight); + } else { + return scaleLinear().range([availableHeight, 0]).domain([0, max(flatData, d => d.y)]).nice(); + } + }; + + getXScale = (availableWidth: number, flatData: Array) => + scaleTime().domain(extent(flatData, d => d.x)).range([0, availableWidth]).clamp(true); + + getScales = () => { + const availableWidth = this.props.width - this.props.padding[1] - this.props.padding[3]; + 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), + yScale: this.getYScale(availableHeight, flatData) + }; + }; + + getEventMarker = (size: number) => { + const half = size / 2; + return `M${half} 0 L${size} ${half} L ${half} ${size} L0 ${half} L${half} 0 L${size} ${half}`; + }; + + handleSelectionDrag = ( + xScale: Scale, + updateFunc: (xScale: Scale, xArray: Array) => void, + checkDelta?: boolean + ) => (e: Event, data: DraggableData) => { + if (!checkDelta || data.deltaX) { + updateFunc(xScale, [data.x, data.node.getBoundingClientRect().width + data.x]); + } + }; + + handleSelectionHandleDrag = ( + xScale: Scale, + fixedX: number, + updateFunc: (xScale: Scale, xArray: Array) => void, + handleDirection: string, + checkDelta?: boolean + ) => (e: Event, data: DraggableData) => { + if (!checkDelta || data.deltaX) { + updateFunc(xScale, handleDirection === 'right' ? [fixedX, data.x] : [data.x, fixedX]); + } + }; + + handleNewZoomDragStart = (e: Event, data: DraggableData) => + this.setState({ newZoomStart: data.x - data.node.getBoundingClientRect().left }); + + handleNewZoomDrag = (xScale: Scale) => (e: Event, data: DraggableData) => { + const { newZoomStart } = this.state; + if (newZoomStart != null && data.deltaX) { + this.handleFastZoomUpdate(xScale, [ + newZoomStart, + data.x - data.node.getBoundingClientRect().left + ]); + } + }; + + handleNewZoomDragEnd = (xScale: Scale, xDim: Array) => ( + e: Event, + data: DraggableData + ) => { + const { newZoomStart } = this.state; + if (newZoomStart != null) { + const x = data.x - data.node.getBoundingClientRect().left; + this.handleZoomUpdate(xScale, newZoomStart === x ? xDim : [newZoomStart, x]); + this.setState({ newZoomStart: null }); + } + }; + + handleZoomUpdate = (xScale: Scale, xArray: Array) => { + 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; + if (this.props.startDate !== startDate || this.props.endDate !== endDate) { + this.props.updateZoom(startDate, endDate); + } + }; + + handleFastZoomUpdate = (xScale: Scale, xArray: Array) => { + 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 ( + + ); + }; + + renderTicks = (xScale: Scale, yScale: Scale) => { + const format = xScale.tickFormat(7); + const ticks = xScale.ticks(7); + const y = yScale.range()[0]; + return ( + + {ticks.slice(0, -1).map((tick, index) => { + const nextTick = index + 1 < ticks.length ? ticks[index + 1] : xScale.domain()[1]; + const x = (xScale(tick) + xScale(nextTick)) / 2; + return ( + + {format(tick)} + + ); + })} + + ); + }; + + renderLeak = (xScale: Scale, yScale: Scale) => { + if (!this.props.leakPeriodDate) { + return null; + } + const yRange = yScale.range(); + return ( + + ); + }; + + renderLines = (xScale: Scale, yScale: Scale) => { + const lineGenerator = d3Line() + .defined(d => d.y || d.y === 0) + .x(d => xScale(d.x)) + .y(d => yScale(d.y)); + if (this.props.basisCurve) { + lineGenerator.curve(curveBasis); + } + return ( + + {this.props.series.map((serie, idx) => ( + + ))} + + ); + }; + + renderAreas = (xScale: Scale, yScale: Scale) => { + const areaGenerator = area() + .defined(d => d.y || d.y === 0) + .x(d => xScale(d.x)) + .y1(d => yScale(d.y)) + .y0(yScale(0)); + if (this.props.basisCurve) { + areaGenerator.curve(curveBasis); + } + return ( + + {this.props.series.map((serie, idx) => ( + + ))} + + ); + }; + + renderZoomHandle = ( + opts: { + xScale: Scale, + xPos: number, + fixedPos: number, + yDim: Array, + xDim: Array, + direction: string + } + ) => ( + + + + ); + + renderZoom = (xScale: Scale, yScale: Scale) => { + const xRange = xScale.range(); + const yRange = yScale.range(); + const xDim = [xRange[0], xRange[xRange.length - 1]]; + const yDim = [yRange[0], yRange[yRange.length - 1]]; + 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 showZoomArea = this.state.newZoomStart == null || this.state.newZoomStart === startX; + return ( + + + + + {showZoomArea && + + + } + {showZoomArea && + this.renderZoomHandle({ + xScale, + xPos: startX, + fixedPos: endX, + xDim, + yDim, + direction: 'left' + })} + {showZoomArea && + this.renderZoomHandle({ + xScale, + xPos: endX, + fixedPos: startX, + xDim, + yDim, + direction: 'right' + })} + + ); + }; + + render() { + if (!this.props.width || !this.props.height) { + return
; + } + + const { xScale, yScale } = this.getScales(); + return ( + + + {this.renderLeak(xScale, yScale)} + {this.renderBaseLine(xScale, yScale)} + {this.props.showXTicks && this.renderTicks(xScale, yScale)} + {this.props.showAreas && this.renderAreas(xScale, yScale)} + {this.renderLines(xScale, yScale)} + {this.renderZoom(xScale, yScale)} + + + ); + } +} diff --git a/server/sonar-web/src/main/less/components/graphics.less b/server/sonar-web/src/main/less/components/graphics.less index c65a50abb5d..7aef4947c40 100644 --- a/server/sonar-web/src/main/less/components/graphics.less +++ b/server/sonar-web/src/main/less/components/graphics.less @@ -263,3 +263,47 @@ .histogram-value { text-anchor: start; } + +/* + * Charts zooming + */ + +.chart-zoomed { + .line-chart-area { + clip-path: url(#chart-clip); + } + + .line-chart-path { + clip-path: url(#chart-clip); + } +} + +.chart-zoom-tick { + fill: @secondFontColor; + font-size: 10px; + text-anchor: middle; +} + +.chart-zoom { + + .zoom-overlay{ + fill: none; + stroke: none; + cursor: crosshair;; + pointer-events: all; + } + + .zoom-selection { + fill: @secondFontColor; + fill-opacity: 0.2; + stroke: @secondFontColor; + shape-rendering: crispEdges; + cursor: move; + } + + .zoom-selection-handle { + cursor: ew-resize; + fill-opacity: 0; + stroke: none; + } +} -- 2.39.5