/* * SonarQube * Copyright (C) 2009-2021 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. */ import classNames from 'classnames'; import { extent, max } from 'd3-array'; import { scaleLinear, scalePoint, scaleTime, ScaleTime } from 'd3-scale'; import { area, curveBasis, line as d3Line } from 'd3-shape'; import { flatten, sortBy, throttle } from 'lodash'; import * as React from 'react'; import Draggable, { DraggableBounds, DraggableCore, DraggableData } from 'react-draggable'; import { colors } from '../../app/theme'; import './LineChart.css'; import './ZoomTimeLine.css'; export interface Props { basisCurve?: boolean; endDate?: Date; height: number; leakPeriodDate?: Date; metricType: string; padding: number[]; series: T.Chart.Serie[]; showAreas?: boolean; showXTicks: boolean; startDate?: Date; updateZoom: (start?: Date, endDate?: Date) => void; width: number; } interface State { overlayLeftPos?: number; newZoomStart?: number; } type XScale = ScaleTime; // It should be `ScaleLinear | ScalePoint | ScalePoint`, but in order // to make it work, we need to write a lot of type guards :-(. This introduces a lot of unnecessary code, // not to mention overhead at runtime. The simplest is just to cast to any, and rely on D3's internals // to make it work. type YScale = any; export default class ZoomTimeLine extends React.PureComponent { static defaultProps = { padding: [0, 0, 18, 0], showXTicks: true }; constructor(props: Props) { super(props); this.state = {}; this.handleZoomUpdate = throttle(this.handleZoomUpdate, 40); } getRatingScale = (availableHeight: number) => { return scalePoint() .domain([5, 4, 3, 2, 1]) .range([availableHeight, 0]); }; getLevelScale = (availableHeight: number) => { return scalePoint() .domain(['ERROR', 'WARN', 'OK']) .range([availableHeight, 0]); }; getYScale = (availableHeight: number, flatData: T.Chart.Point[]): YScale => { if (this.props.metricType === 'RATING') { return this.getRatingScale(availableHeight); } else if (this.props.metricType === 'LEVEL') { return this.getLevelScale(availableHeight); } return scaleLinear() .range([availableHeight, 0]) .domain([0, max(flatData, d => Number(d.y || 0)) as number]) .nice(); }; getXScale = (availableWidth: number, flatData: T.Chart.Point[]): XScale => { return scaleTime() .domain(extent(flatData, d => d.x) as [Date, Date]) .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.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}`; }; handleDoubleClick = (xScale: XScale, xDim: number[]) => () => { this.handleZoomUpdate(xScale, xDim); }; handleSelectionDrag = (xScale: XScale, width: number, xDim: number[], checkDelta = false) => ( _: MouseEvent, data: DraggableData ) => { if (!checkDelta || data.deltaX) { const x = Math.max(xDim[0], Math.min(data.x, xDim[1] - width)); this.handleZoomUpdate(xScale, [x, width + x]); } }; handleSelectionHandleDrag = ( xScale: XScale, fixedX: number, xDim: number[], handleDirection: string, checkDelta = false ) => (_: MouseEvent, data: DraggableData) => { if (!checkDelta || data.deltaX) { const x = Math.max(xDim[0], Math.min(data.x, xDim[1])); this.handleZoomUpdate(xScale, handleDirection === 'right' ? [fixedX, x] : [x, fixedX]); } }; handleNewZoomDragStart = (xDim: number[]) => (_: MouseEvent, data: DraggableData) => { const overlayLeftPos = data.node.getBoundingClientRect().left; this.setState({ overlayLeftPos, newZoomStart: Math.round(Math.max(xDim[0], Math.min(data.x - overlayLeftPos, xDim[1]))) }); }; handleNewZoomDrag = (xScale: XScale, xDim: number[]) => (_: MouseEvent, data: DraggableData) => { const { newZoomStart, overlayLeftPos } = this.state; if (newZoomStart != null && overlayLeftPos != null && data.deltaX) { this.handleZoomUpdate( xScale, sortBy([newZoomStart, Math.max(xDim[0], Math.min(data.x - overlayLeftPos, xDim[1]))]) ); } }; handleNewZoomDragEnd = (xScale: XScale, xDim: number[]) => ( _: MouseEvent, data: DraggableData ) => { const { newZoomStart, overlayLeftPos } = this.state; if (newZoomStart !== undefined && overlayLeftPos !== undefined) { const x = Math.round(Math.max(xDim[0], Math.min(data.x - overlayLeftPos, xDim[1]))); this.handleZoomUpdate(xScale, newZoomStart === x ? xDim : sortBy([newZoomStart, x])); this.setState({ newZoomStart: undefined, overlayLeftPos: undefined }); } }; handleZoomUpdate = (xScale: XScale, xArray: number[]) => { const xRange = xScale.range(); const startDate = xArray[0] > xRange[0] && xArray[0] < xRange[xRange.length - 1] ? xScale.invert(xArray[0]) : undefined; const endDate = xArray[1] > xRange[0] && xArray[1] < xRange[xRange.length - 1] ? xScale.invert(xArray[1]) : undefined; this.props.updateZoom(startDate, endDate); }; renderBaseLine = (xScale: XScale, yScale: YScale) => { return ( ); }; renderTicks = (xScale: XScale, yScale: YScale) => { 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 ( // eslint-disable-next-line react/no-array-index-key {format(tick)} ); })} ); }; renderLeak = (xScale: XScale, yScale: YScale) => { const { leakPeriodDate } = this.props; if (!leakPeriodDate) { return null; } const yRange = yScale.range(); return ( ); }; renderLines = (xScale: XScale, yScale: YScale) => { const lineGenerator = d3Line() .defined(d => Boolean(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: XScale, yScale: YScale) => { const areaGenerator = area() .defined(d => Boolean(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 = (options: { xScale: XScale; xPos: number; fixedPos: number; yDim: number[]; xDim: number[]; direction: string; }) => ( ); renderZoom = (xScale: XScale, yScale: YScale) => { 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 zoomBoxWidth = xArray[1] - xArray[0]; const showZoomArea = this.state.newZoomStart == null || this.state.newZoomStart === startX || this.state.newZoomStart === endX; 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)} ); } }