// 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 {useEffect, useMemo, useRef, useState} from 'react'; import {Icon} from '@iconify/react'; import cx from 'classnames'; import * as d3 from 'd3'; import {NumberDuo} from '../../../utils'; export interface DataPoint { timestamp: number; value: number; sampleCount?: number; } interface DragState { stripIndex: number; startX: number; currentX: number; } interface Props { width: number; height: number; marginLeft?: number; marginRight?: number; marginTop?: number; marginBottom?: number; fill?: string; data: DataPoint[]; selectionBounds?: NumberDuo | undefined; setSelectionBounds: (newBounds: NumberDuo | undefined) => void; stepMs: number; onDragStart?: (startX: number) => void; dragState?: DragState; isAnyDragActive?: boolean; timeBounds?: NumberDuo; } const DraggingWindow = ({ dragStart, currentX, }: { dragStart: number | undefined; currentX: number | undefined; }): JSX.Element | null => { const start = useMemo(() => Math.min(dragStart ?? 0, currentX ?? 0), [dragStart, currentX]); const width = useMemo(() => Math.abs((dragStart ?? 0) - (currentX ?? 0)), [dragStart, currentX]); if (dragStart === undefined || currentX === undefined) { return null; } return (
); }; const ZoomWindow = ({ zoomWindow, onZoomWindowChange, setIsHoveringDragHandle, }: { zoomWindow?: NumberDuo; width: number; onZoomWindowChange: (newWindow: NumberDuo) => void; setIsHoveringDragHandle: (arg: boolean) => void; }): JSX.Element | null => { const windowStartHandleRef = useRef(null); const windowEndHandleRef = useRef(null); const [zoomWindowState, setZoomWindowState] = useState(zoomWindow); const [dragginStart, setDraggingStart] = useState(false); const [draggingEnd, setDraggingEnd] = useState(false); useEffect(() => { if ( zoomWindow === undefined || zoomWindowState === undefined || zoomWindow[0] !== zoomWindowState[0] || zoomWindow[1] !== zoomWindowState[1] ) { setZoomWindowState(zoomWindow); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [zoomWindow]); if (zoomWindowState === undefined) { return null; } const beforeWidth = zoomWindowState[0]; const afterStart = zoomWindowState[1]; return (
{ if (dragginStart) { const [x] = d3.pointer(e); if (x >= afterStart - 10) { return; } const newStart = Math.min(x, afterStart); const newEnd = Math.max(x, afterStart); setZoomWindowState([newStart, newEnd]); } if (draggingEnd) { const [x] = d3.pointer(e); if (x <= beforeWidth + 10) { return; } const newStart = Math.min(x, beforeWidth); const newEnd = Math.max(x, beforeWidth); setZoomWindowState([newStart, newEnd]); } }} onMouseLeave={() => { setDraggingStart(false); setDraggingEnd(false); }} onMouseUp={() => { if (dragginStart) { setDraggingStart(false); } if (draggingEnd) { setDraggingEnd(false); } if (zoomWindowState[0] === zoomWindow?.[0] && zoomWindowState[1] === zoomWindow?.[1]) { return; } onZoomWindowChange(zoomWindowState); setZoomWindowState(undefined); }} >
{ setDraggingStart(true); e.stopPropagation(); e.preventDefault(); }} ref={windowStartHandleRef} onMouseEnter={() => { setIsHoveringDragHandle(true); }} onMouseLeave={() => { setIsHoveringDragHandle(false); }} >
{ setDraggingEnd(true); e.stopPropagation(); e.preventDefault(); }} ref={windowEndHandleRef} onMouseEnter={() => { setIsHoveringDragHandle(true); }} onMouseLeave={() => { setIsHoveringDragHandle(false); }} >
); }; export const SamplesGraph = ({ data, height, width, marginLeft = 0, marginRight = 0, marginBottom = 0, marginTop = 0, fill = 'gray', selectionBounds, setSelectionBounds, stepMs, onDragStart, dragState, isAnyDragActive = false, timeBounds, }: Props): JSX.Element => { const [mousePosition, setMousePosition] = useState(undefined); const [isHoveringDragHandle, setIsHoveringDragHandle] = useState(false); // use the bounds from props if provided, else compute from data const xDomain = timeBounds ?? (d3.extent(data, d => d.timestamp) as NumberDuo); const x = d3.scaleUtc(xDomain, [marginLeft, width - marginRight]); // Calculate sample count range for opacity scaling const sampleCounts = data.map(d => Number(d.sampleCount ?? 1)); const maxSampleCount = Math.max(...sampleCounts); const minSampleCount = Math.min(...sampleCounts); // Create opacity scale: more samples = higher opacity const opacityScale = d3 .scaleLinear() .domain([minSampleCount, maxSampleCount]) .range([0.5, 1.0]) .clamp(true); const zoomWindow: NumberDuo | undefined = useMemo(() => { if (selectionBounds === undefined) { return undefined; } return [x(selectionBounds[0]), x(selectionBounds[1])]; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectionBounds]); const setSelectionBoundsWithScaling = ([startPx, endPx]: NumberDuo): void => { setSelectionBounds([x.invert(startPx).getTime(), x.invert(endPx).getTime()]); }; return (
{ // Only track hover position when no drag is active anywhere if (isAnyDragActive) return; const [xPos, yPos] = d3.pointer(e); if ( xPos >= marginLeft && xPos <= width - marginRight && yPos >= marginTop && yPos <= height - marginBottom ) { setMousePosition([xPos, yPos]); } else { setMousePosition(undefined); } }} onMouseLeave={() => { // Only clear hover position, drag is managed by parent setMousePosition(undefined); }} onMouseDown={e => { // only left mouse button if (e.button !== 0) { return; } // X/Y coordinate array relative to element const rel = d3.pointer(e); const xCoordinate = rel[0]; if (xCoordinate >= 0 && onDragStart !== undefined) { onDragStart(xCoordinate); } e.stopPropagation(); e.preventDefault(); }} > {/* onHover guide, only visible when hovering and not dragging and not having an active zoom window */}
{/* drag guide, only visible when dragging */} {/* zoom window */} {/* Background for the full strip area */} {data.map((d, i) => { const xPosition = x(d.timestamp); // Use stepMs for bucket width const rectWidth = x(d.timestamp + stepMs) - xPosition; // Calculate opacity based on sample count const opacity = opacityScale(Number(d.sampleCount ?? 1)); return ( ); })}
); };