]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9402 Add basic zooming capabilities to the project history graphs
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Wed, 21 Jun 2017 13:53:31 +0000 (15:53 +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 [new file with mode: 0644]
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/utils.js
server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js
server/sonar-web/src/main/js/components/charts/ZoomTimeLine.js [new file with mode: 0644]
server/sonar-web/src/main/less/components/graphics.less

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 (file)
index 0000000..3dea9f1
--- /dev/null
@@ -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>
+    );
+  }
+}
index 5680eca4306bf7752dab21dd56c20d7eaa9fc952..db1eb841daccd5ff0893bb3b85b8bcb5a30376cc 100644 (file)
 // @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>
     );
index 086c9a6e1d169a433991076f0c07e7263e295b80..e14f5f097fb5fa1d8c858357a21dfab375408753 100644 (file)
@@ -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}
               />
             )}
index 257300e503ddf6acd0730d978312fd47f9798065..ae651fd891f927274c3dc840806c371f088f0317 100644 (file)
@@ -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>,
index bc71efecf69c4fa3cfbab8b94cfc54b54dfe7d78..7beb5d31afc9fc0e6b9d1c71947f67f5ed4c4109 100644 (file)
 // @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 (file)
index 0000000..85b1111
--- /dev/null
@@ -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>
+    );
+  }
+}
index c65a50abb5d0fe9ed08d67b90ad9395c621a0e3e..7aef4947c4073b81aab41fc6bee7fab036244f57 100644 (file)
 .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;
+  }
+}