// Copyright 2022 The Parca Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import React, {Fragment, useCallback, useId, useMemo, useRef, useState} from 'react'; import * as d3 from 'd3'; import {pointer} from 'd3-selection'; import throttle from 'lodash.throttle'; import {useContextMenu} from 'react-contexify'; import {DateTimeRange, useParcaContext} from '@parca/components'; import {TEST_IDS, testId} from '@parca/test-utils'; import {formatDate, formatForTimespan, getPrecision, valueFormatter} from '@parca/utilities'; import MetricsCircle from '../MetricsCircle'; import MetricsSeries from '../MetricsSeries'; import MetricsContextMenu, { ContextMenuItem, ContextMenuItemOrSubmenu, ContextMenuSubmenu, } from './MetricsContextMenu'; import MetricsInfoPanel from './MetricsInfoPanel'; import MetricsTooltip from './MetricsTooltip'; interface Props { data: Series[]; from: number; to: number; onSampleClick: (closestPoint: SeriesPoint) => void; setTimeRange: (range: DateTimeRange) => void; yAxisLabel: string; yAxisUnit: string; width?: number; height?: number; margin?: number; selectedPoint?: SeriesPoint | null; contextMenuItems?: ContextMenuItemOrSubmenu[]; renderTooltipContent?: (seriesIndex: number, pointIndex: number) => React.ReactNode; } export interface SeriesPoint { seriesIndex: number; pointIndex: number; } export interface HighlightedSeries { seriesIndex: number; pointIndex: number; x: number; y: number; } export interface Series { id: string; // opaque string used to determine line color values: Array<[number, number]>; // [timestamp_ms, value] highlighted?: boolean; } const MetricsGraph = ({ data, from, to, onSampleClick, setTimeRange, yAxisLabel, yAxisUnit, width = 0, height = 0, margin = 0, selectedPoint, contextMenuItems, renderTooltipContent, }: Props): JSX.Element => { const [isInfoPanelOpen, setIsInfoPanelOpen] = useState(false); return (
isInfoPanelOpen && setIsInfoPanelOpen(false)} >
setIsInfoPanelOpen(true)} />
); }; export default MetricsGraph; export type {ContextMenuItemOrSubmenu, ContextMenuItem, ContextMenuSubmenu}; export const parseValue = (value: string): number | null => { const val = parseFloat(value); // "+Inf", "-Inf", "+Inf" will be parsed into NaN by parseFloat(). They // can't be graphed, so show them as gaps (null). return isNaN(val) ? null : val; }; const lineStroke = '1px'; const lineStrokeHover = '2px'; export const RawMetricsGraph = ({ data, from, to, onSampleClick, setTimeRange, yAxisLabel, yAxisUnit, width, height = 50, margin = 0, selectedPoint, contextMenuItems, renderTooltipContent, }: Props): JSX.Element => { const {timezone} = useParcaContext(); const graph = useRef(null); const [dragging, setDragging] = useState(false); const [hovering, setHovering] = useState(false); const [relPos, setRelPos] = useState(-1); const [pos, setPos] = useState([0, 0]); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const metricPointRef = useRef(null); const idForContextMenu = useId(); if (width === undefined || width == null) { width = 0; } const graphWidth = useMemo(() => width - margin * 1.5 - margin / 2, [width, margin]); const graphTransform = useMemo(() => { // Adds 6px padding which aligns the graph on the grid return `translate(6, 0) scale(${(graphWidth - 6) / graphWidth}, 1)`; }, [graphWidth]); const series = data; const extentsY = series.map(function (s) { return d3.extent(s.values, function (d) { return d[1]; // d[1] is the value }); }); const minY = d3.min(extentsY, function (d) { return d[0]; }); const maxY = d3.max(extentsY, function (d) { return d[1]; }); /* Scale */ const xScale = d3.scaleUtc().domain([from, to]).range([0, graphWidth]); const yScale = d3 .scaleLinear() // tslint:disable-next-line .domain([minY, maxY] as Iterable) .range([height - margin, 0]) .nice(); // Create deterministic color mapping based on series IDs const color = useMemo(() => d3.scaleOrdinal(d3.schemeCategory10), []); const l = d3.line<[number, number]>( d => xScale(d[0]), d => yScale(d[1]) ); const closestPoint = useMemo(() => { // Guard against empty series if (series.length === 0) { return null; } const closestPointPerSeries = series.map(function (s) { if (s.values.length === 0) { return { pointIndex: undefined, distance: Infinity, }; } const distances = s.values.map(d => { const x = xScale(d[0]) + margin / 2; // d[0] is timestamp_ms const y = yScale(d[1]) - margin / 3; // d[1] is value // Cartesian distance from the mouse position to the point return Math.sqrt(Math.pow(pos[0] - x, 2) + Math.pow(pos[1] - y, 2)); }); const pointIndex = d3.minIndex(distances); const minDistance = pointIndex != null ? distances[pointIndex] : Infinity; return { pointIndex, distance: minDistance, }; }); const closestSeriesIndex = d3.minIndex(closestPointPerSeries, s => s.distance); if (closestSeriesIndex == null || closestPointPerSeries[closestSeriesIndex] == null) { return null; } const pointIndex = closestPointPerSeries[closestSeriesIndex].pointIndex; if (pointIndex == null) { return null; } return { seriesIndex: closestSeriesIndex, pointIndex, }; }, [pos, series, xScale, yScale, margin]); const highlighted = useMemo(() => { if (series.length === 0 || closestPoint == null) { return null; } const point = series[closestPoint.seriesIndex].values[closestPoint.pointIndex]; return { seriesIndex: closestPoint.seriesIndex, pointIndex: closestPoint.pointIndex, x: xScale(point[0]), y: yScale(point[1]), }; }, [closestPoint, series, xScale, yScale]); const selected = useMemo(() => { if (series.length === 0 || selectedPoint == null) { return null; } const point = series[selectedPoint.seriesIndex].values[selectedPoint.pointIndex]; return { seriesIndex: selectedPoint.seriesIndex, pointIndex: selectedPoint.pointIndex, x: xScale(point[0]), y: yScale(point[1]), }; }, [selectedPoint, series, xScale, yScale]); const onMouseDown = (e: React.MouseEvent): void => { // only left mouse button if (e.button !== 0) { return; } // X/Y coordinate array relative to svg const rel = pointer(e); const xCoordinate = rel[0]; const xCoordinateWithoutMargin = xCoordinate - margin; if (xCoordinateWithoutMargin >= 0) { setRelPos(xCoordinateWithoutMargin); setDragging(true); } e.stopPropagation(); e.preventDefault(); }; const handleClosestPointClick = (): void => { if (closestPoint != null) { onSampleClick(closestPoint); } }; const onMouseUp = (e: React.MouseEvent): void => { setDragging(false); if (relPos === -1) { // MouseDown happened outside of this element. return; } // This is a normal click. We tolerate tiny movements to still be a // click as they can occur when clicking based on user feedback. if (Math.abs(relPos - pos[0]) <= 1) { handleClosestPointClick(); setRelPos(-1); return; } let startPos = relPos; let endPos = pos[0]; if (startPos > endPos) { startPos = pos[0]; endPos = relPos; } const startCorrection = 10; const endCorrection = 30; const firstTime = xScale.invert(startPos - startCorrection).valueOf(); const secondTime = xScale.invert(endPos - endCorrection).valueOf(); setTimeRange(DateTimeRange.fromAbsoluteDates(firstTime, secondTime)); setRelPos(-1); e.stopPropagation(); e.preventDefault(); }; const throttledSetPos = throttle(setPos, 20); const onMouseMove = (e: React.MouseEvent): void => { if (isContextMenuOpen) { return; } // X/Y coordinate array relative to svg const rel = pointer(e); const xCoordinate = rel[0]; const xCoordinateWithoutMargin = xCoordinate - margin; const yCoordinate = rel[1]; const yCoordinateWithoutMargin = yCoordinate - margin; throttledSetPos([xCoordinateWithoutMargin, yCoordinateWithoutMargin]); }; const MENU_ID = `metrics-context-menu-${idForContextMenu}`; const {show} = useContextMenu({ id: MENU_ID, }); const displayMenu = useCallback( (e: React.MouseEvent): void => { show({ event: e, }); }, [show] ); const trackVisibility = (isVisible: boolean): void => { setIsContextMenuOpen(isVisible); }; return ( <> {contextMenuItems != null && ( )} {highlighted != null && hovering && !dragging && pos[0] !== 0 && pos[1] !== 0 && (
setHovering(true)} onMouseLeave={() => setHovering(false)} > {!isContextMenuOpen && ( )}
)}
setHovering(false)} onContextMenu={displayMenu} > {dragging && ( )} {yScale.ticks(5).map((d, i, allTicks) => { let decimals = 2; const intervalBetweenTicks = allTicks[1] - allTicks[0]; if (intervalBetweenTicks < 1) { const precision = getPrecision(intervalBetweenTicks); decimals = precision; } return ( {valueFormatter(d, yAxisUnit, decimals)} ); })} {yAxisLabel} {xScale.ticks(5).map((d, i) => ( {formatDate(d, formatForTimespan(from, to), timezone)} ))} Time {series.map((s, i) => ( ))} {hovering && highlighted != null && ( )} {selected != null && ( )}
); };