aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>2017-06-21 15:53:31 +0200
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>2017-07-04 14:15:34 +0200
commit7feb62d1317819a82df8dcbc71969d9c1d51bdc7 (patch)
tree2a0937f508e28fc242958661a67ef89c03e2b75d /server
parentcc5e586bcc63ddcb678659d44196cbda482141ed (diff)
downloadsonarqube-7feb62d1317819a82df8dcbc71969d9c1d51bdc7.tar.gz
sonarqube-7feb62d1317819a82df8dcbc71969d9c1d51bdc7.zip
SONAR-9402 Add basic zooming capabilities to the project history graphs
Diffstat (limited to 'server')
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/GraphsZoom.js81
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js71
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js12
-rw-r--r--server/sonar-web/src/main/js/apps/projectActivity/utils.js3
-rw-r--r--server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js50
-rw-r--r--server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js370
-rw-r--r--server/sonar-web/src/main/less/components/graphics.less44
7 files changed, 586 insertions, 45 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
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<Serie>,
+ 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 (
+ <div className="project-activity-graph-zoom">
+ <AutoSizer disableHeight={true}>
+ {({ width }) => (
+ <ZoomTimeLine
+ endDate={this.props.graphEndDate}
+ height={64}
+ width={width}
+ interpolate="linear"
+ leakPeriodDate={this.props.leakPeriodDate}
+ metricType={this.props.metricsType}
+ padding={[0, 10, 18, 60]}
+ series={this.props.series}
+ showAreas={this.props.showAreas}
+ startDate={this.props.graphStartDate}
+ updateZoom={this.updateDateRange}
+ updateZoomFast={this.props.updateGraphZoom}
+ />
+ )}
+ </AutoSizer>
+ </div>
+ );
+ }
+}
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<Serie>,
+ graphStartDate: ?Date,
+ graphEndDate: ?Date,
series: Array<Serie>
};
@@ -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<Serie>, query: Query): Array<Serie> => {
- 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 (
<div className="project-activity-layout-page-main-inner boxed-group boxed-group-inner">
- <ProjectActivityGraphsHeader graph={graph} updateQuery={this.props.updateQuery} />
+ <ProjectActivityGraphsHeader graph={query.graph} updateQuery={this.props.updateQuery} />
<StaticGraphs
analyses={this.props.analyses}
- eventFilter={category}
- filteredSeries={this.state.filteredSeries}
- leakPeriodDate={this.props.leakPeriodDate}
- loading={this.props.loading}
- metricsType={this.props.metricsType}
+ eventFilter={query.category}
+ graphEndDate={this.state.graphEndDate}
+ graphStartDate={this.state.graphStartDate}
+ leakPeriodDate={leakPeriodDate}
+ loading={loading}
+ metricsType={metricsType}
project={this.props.project}
- series={this.state.series}
- showAreas={['coverage', 'duplications'].includes(graph)}
+ series={series}
+ showAreas={['coverage', 'duplications'].includes(query.graph)}
+ />
+ <GraphsZoom
+ graphEndDate={this.state.graphEndDate}
+ graphStartDate={this.state.graphStartDate}
+ leakPeriodDate={leakPeriodDate}
+ loading={loading}
+ metricsType={metricsType}
+ 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 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<Analysis>,
eventFilter: string,
- filteredSeries: Array<Serie>,
+ graphStartDate: ?Date,
leakPeriodDate: Date,
loading: boolean,
metricsType: string,
- series: Array<Serie>
+ series: Array<Serie>,
+ 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 (
<div className="project-activity-graph-container">
<StaticGraphsLegend series={series} />
@@ -103,6 +105,7 @@ export default class StaticGraphs extends React.PureComponent {
<AutoSizer>
{({ height, width }) => (
<AdvancedTimeline
+ endDate={this.props.graphEndDate}
events={this.getEvents()}
height={height}
interpolate="linear"
@@ -110,8 +113,9 @@ export default class StaticGraphs extends React.PureComponent {
formatYTick={this.formatYTick}
leakPeriodDate={this.props.leakPeriodDate}
metricType={this.props.metricsType}
- series={filteredSeries}
+ series={series}
showAreas={this.props.showAreas}
+ startDate={this.props.graphStartDate}
width={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<MeasureHistory>,
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<Point>, style: string };
type Scale = Function;
type Props = {
basisCurve?: boolean,
+ endDate: ?Date,
events?: Array<Event>,
eventSize?: number,
formatYTick: number => string,
@@ -42,7 +43,8 @@ type Props = {
padding: Array<number>,
series: Array<Serie>,
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<Point>) =>
- scaleTime().domain(extent(flatData, d => d.x)).range([0, availableWidth]).clamp(true);
+ getXScale = (availableWidth: number, flatData: Array<Point>) => {
+ 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 (
- <text key={index} className="line-chart-tick" x={x} y={y} dy="2em">
+ <text key={index} className="line-chart-tick" x={x} y={y} dy="1.5em">
{format(tick)}
</text>
);
@@ -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 (
<rect
x={xScale(this.props.leakPeriodDate)}
- y={yScaleRange[yScaleRange.length - 1]}
- width={xScale.range()[1] - xScale(this.props.leakPeriodDate)}
- height={yScaleRange[0] - yScaleRange[yScaleRange.length - 1]}
+ y={yRange[yRange.length - 1]}
+ width={leakWidth}
+ height={yRange[0] - yRange[yRange.length - 1]}
fill="#fbf3d5"
/>
);
@@ -222,14 +233,29 @@ export default class AdvancedTimeline extends React.PureComponent {
);
};
+ renderClipPath = (xScale: Scale, yScale: Scale) => {
+ return (
+ <defs>
+ <clipPath id="chart-clip">
+ <rect width={xScale.range()[1]} height={yScale.range()[0] + 10} />
+ </clipPath>
+ </defs>
+ );
+ };
+
render() {
if (!this.props.width || !this.props.height) {
return <div />;
}
const { xScale, yScale } = this.getScales();
+ const isZoomed = this.props.startDate || this.props.endDate;
return (
- <svg className="line-chart" width={this.props.width} height={this.props.height}>
+ <svg
+ className={classNames('line-chart', { 'chart-zoomed': isZoomed })}
+ width={this.props.width}
+ height={this.props.height}>
+ {this.renderClipPath(xScale, yScale)}
<g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
{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<number>,
+ series: Array<Serie>,
+ 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<Point>) => {
+ 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<Point>) =>
+ 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<number>) => 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<number>) => 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<number>) => (
+ 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<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;
+ 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
+ className="line-chart-grid"
+ x1={xScale.range()[0]}
+ x2={xScale.range()[1]}
+ y1={yScale.range()[0]}
+ y2={yScale.range()[0]}
+ />
+ );
+ };
+
+ renderTicks = (xScale: Scale, yScale: Scale) => {
+ const format = xScale.tickFormat(7);
+ const ticks = xScale.ticks(7);
+ const y = yScale.range()[0];
+ return (
+ <g>
+ {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 (
+ <text key={index} className="chart-zoom-tick" x={x} y={y} dy="1.3em">
+ {format(tick)}
+ </text>
+ );
+ })}
+ </g>
+ );
+ };
+
+ renderLeak = (xScale: Scale, yScale: Scale) => {
+ if (!this.props.leakPeriodDate) {
+ return null;
+ }
+ const yRange = yScale.range();
+ return (
+ <rect
+ x={xScale(this.props.leakPeriodDate)}
+ y={yRange[yRange.length - 1]}
+ width={xScale.range()[1] - xScale(this.props.leakPeriodDate)}
+ height={yRange[0] - yRange[yRange.length - 1]}
+ fill="#fbf3d5"
+ />
+ );
+ };
+
+ 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 (
+ <g>
+ {this.props.series.map((serie, idx) => (
+ <path
+ key={`${idx}-${serie.name}`}
+ className={classNames('line-chart-path', 'line-chart-path-' + serie.style)}
+ d={lineGenerator(serie.data)}
+ />
+ ))}
+ </g>
+ );
+ };
+
+ 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 (
+ <g>
+ {this.props.series.map((serie, idx) => (
+ <path
+ key={`${idx}-${serie.name}`}
+ className={classNames('line-chart-area', 'line-chart-area-' + serie.style)}
+ d={areaGenerator(serie.data)}
+ />
+ ))}
+ </g>
+ );
+ };
+
+ renderZoomHandle = (
+ opts: {
+ xScale: Scale,
+ xPos: number,
+ fixedPos: number,
+ yDim: Array<number>,
+ xDim: Array<number>,
+ direction: string
+ }
+ ) => (
+ <Draggable
+ axis="x"
+ bounds={{ left: opts.xDim[0], right: opts.xDim[1] }}
+ position={{ x: opts.xPos, y: 0 }}
+ onDrag={this.handleSelectionHandleDrag(
+ opts.xScale,
+ opts.fixedPos,
+ this.handleFastZoomUpdate,
+ opts.direction,
+ true
+ )}
+ onStop={this.handleSelectionHandleDrag(
+ opts.xScale,
+ opts.fixedPos,
+ this.handleZoomUpdate,
+ opts.direction
+ )}>
+ <rect
+ className="zoom-selection-handle"
+ x={-3}
+ y={opts.yDim[1]}
+ height={opts.yDim[0] - opts.yDim[1]}
+ width={6}
+ />
+ </Draggable>
+ );
+
+ 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 (
+ <g className="chart-zoom">
+ <DraggableCore
+ onStart={this.handleNewZoomDragStart}
+ onDrag={this.handleNewZoomDrag(xScale)}
+ onStop={this.handleNewZoomDragEnd(xScale, xDim)}>
+ <rect
+ className="zoom-overlay"
+ x={xDim[0]}
+ y={yDim[1]}
+ height={yDim[0] - yDim[1]}
+ width={xDim[1] - xDim[0]}
+ />
+ </DraggableCore>
+ {showZoomArea &&
+ <Draggable
+ axis="x"
+ bounds={{ left: xDim[0], right: xDim[1] - xArray[1] + xArray[0] }}
+ position={{ x: xArray[0], y: 0 }}
+ onDrag={this.handleSelectionDrag(xScale, this.handleFastZoomUpdate, true)}
+ onStop={this.handleSelectionDrag(xScale, this.handleZoomUpdate)}>
+ <rect
+ className="zoom-selection"
+ x={0}
+ y={yDim[1]}
+ height={yDim[0] - yDim[1]}
+ width={xArray[1] - xArray[0]}
+ />
+ </Draggable>}
+ {showZoomArea &&
+ this.renderZoomHandle({
+ xScale,
+ xPos: startX,
+ fixedPos: endX,
+ xDim,
+ yDim,
+ direction: 'left'
+ })}
+ {showZoomArea &&
+ this.renderZoomHandle({
+ xScale,
+ xPos: endX,
+ fixedPos: startX,
+ xDim,
+ yDim,
+ direction: 'right'
+ })}
+ </g>
+ );
+ };
+
+ render() {
+ if (!this.props.width || !this.props.height) {
+ return <div />;
+ }
+
+ const { xScale, yScale } = this.getScales();
+ return (
+ <svg className="line-chart " width={this.props.width} height={this.props.height}>
+ <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}>
+ {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)}
+ </g>
+ </svg>
+ );
+ }
+}
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;
+ }
+}