/* * SonarQube * Copyright (C) 2009-2024 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 styled from '@emotion/styled'; import { extent, max } from 'd3-array'; import { ScaleTime, scaleLinear, scalePoint, scaleTime } from 'd3-scale'; import { area, curveBasis, line as d3Line } from 'd3-shape'; import { CSSColor, DraggableIcon, themeColor } from 'design-system'; import { flatten, sortBy, throttle } from 'lodash'; import * as React from 'react'; import Draggable, { DraggableBounds, DraggableCore, DraggableData } from 'react-draggable'; import { MetricType } from '../../types/metrics'; import { Chart } from '../../types/types'; import { LINE_CHART_DASHES } from '../activity-graph/utils'; export interface Props { basisCurve?: boolean; endDate?: Date; height: number; leakPeriodDate?: Date; metricType: string; padding?: number[]; series: Chart.Serie[]; showAreas?: boolean; startDate?: Date; updateZoom: (start?: Date, endDate?: Date) => void; width: number; } export type PropsWithDefaults = Props & typeof ZoomTimeLine.defaultProps; interface State { overlayLeftPos?: number; newZoomStart?: number; } type XScale = ScaleTime; export class ZoomTimeLine extends React.PureComponent { static defaultProps = { padding: [0, 0, 18, 0], }; constructor(props: PropsWithDefaults) { 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: Chart.Point[]) => { if (this.props.metricType === MetricType.Rating) { return this.getRatingScale(availableHeight); } else if (this.props.metricType === 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: Chart.Point[]): XScale => { return scaleTime() .domain(extent(flatData, (d) => d.x) as [Date, Date]) .range([0, availableWidth]) .clamp(true); }; getScales = () => { const { padding } = this.props as PropsWithDefaults; const availableWidth = this.props.width - padding[1] - padding[3]; const availableHeight = this.props.height - padding[0] - padding[2]; const flatData = flatten(this.props.series.map((serie) => serie.data)); return { xScale: this.getXScale(availableWidth, flatData), yScale: this.getYScale(availableHeight, flatData), }; }; 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: { range: () => number[] }) => { return ( ); }; renderNewCode = (xScale: XScale, yScale: { range: () => number[] }) => { const { leakPeriodDate } = this.props; if (!leakPeriodDate) { return null; } const yRange = yScale.range(); return ( ); }; renderLines = (xScale: XScale, yScale: (y: string | number | undefined) => number) => { const { series } = this.props; 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 ( {series.map((serie, idx) => ( ))} ); }; renderAreas = (xScale: XScale, yScale: (y: string | number | undefined) => number) => { 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: { range: () => number[] }) => { 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() { const { padding } = this.props as PropsWithDefaults; if (!this.props.width || !this.props.height) { return
; } const { xScale, yScale } = this.getScales(); return ( {this.renderNewCode(xScale, yScale as Parameters[1])} {this.renderBaseLine(xScale, yScale as Parameters[1])} {this.props.showAreas && this.renderAreas(xScale, yScale as Parameters[1])} {this.renderLines(xScale, yScale as Parameters[1])} {this.renderZoom(xScale, yScale as Parameters[1])} ); } } const ZoomHighlight = styled.rect` cursor: move; fill: ${themeColor('graphZoomBackgroundColor')}; stroke: ${themeColor('graphZoomBorderColor')}; fill-opacity: 0.2; shape-rendering: crispEdges; `; const ZoomHighlightHandle = styled.rect` cursor: ew-resize; fill-opacity: 1; fill: ${themeColor('graphZoomHandleColor')}; stroke: none; `; const ZoomOverlay = styled.rect` cursor: crosshair; pointer-events: all; fill: none; stroke: none; `; const AREA_OPACITY = 0.15; const StyledArea = styled.path<{ index: number }>` clip-path: url(#chart-clip); fill: ${({ index }) => themeColor(`graphLineColor.${index}` as CSSColor, AREA_OPACITY)}; stroke-width: 0; `; const StyledPath = styled.path<{ index: number }>` clip-path: url(#chart-clip); fill: none; stroke: ${({ index }) => themeColor(`graphLineColor.${index}` as CSSColor)}; stroke-dasharray: ${({ index }) => LINE_CHART_DASHES[index]}; stroke-width: 2px; `; const StyledNewCodeLegend = styled.rect` fill: ${themeColor('newCodeLegend')}; `; const StyledBaseLine = styled('line')` shape-rendering: crispedges; stroke: ${themeColor('graphGridColor')}; `;