github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/packages/pyroscope-flamegraph/src/FlameGraph/FlameGraphComponent/index.tsx (about) 1 /* eslint-disable no-unused-expressions, import/no-extraneous-dependencies */ 2 import React, { useCallback, useRef } from 'react'; 3 import clsx from 'clsx'; 4 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 5 import { faRedo } from '@fortawesome/free-solid-svg-icons/faRedo'; 6 import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy'; 7 import { faHighlighter } from '@fortawesome/free-solid-svg-icons/faHighlighter'; 8 import { faCompressAlt } from '@fortawesome/free-solid-svg-icons/faCompressAlt'; 9 import { MenuItem } from '@webapp/ui/Menu'; 10 import useResizeObserver from '@react-hook/resize-observer'; 11 import { Maybe } from 'true-myth'; 12 import debounce from 'lodash.debounce'; 13 import { Flamebearer } from '@pyroscope/models/src'; 14 import styles from './canvas.module.css'; 15 import Flamegraph from './Flamegraph'; 16 import Highlight from './Highlight'; 17 import ContextMenuHighlight from './ContextMenuHighlight'; 18 import FlamegraphTooltip from '../../Tooltip/FlamegraphTooltip'; 19 import ContextMenu from './ContextMenu'; 20 import LogoLink from './LogoLink'; 21 import { SandwichIcon, HeadFirstIcon, TailFirstIcon } from '../../Icons'; 22 import { PX_PER_LEVEL } from './constants'; 23 import Header from './Header'; 24 import { FlamegraphPalette } from './colorPalette'; 25 import type { ViewTypes } from './viewTypes'; 26 import { FitModes, HeadMode, TailMode } from '../../fitMode/fitMode'; 27 import indexStyles from './styles.module.scss'; 28 29 interface FlamegraphProps { 30 flamebearer: Flamebearer; 31 focusedNode: ConstructorParameters<typeof Flamegraph>[2]; 32 fitMode: ConstructorParameters<typeof Flamegraph>[3]; 33 updateFitMode: (f: FitModes) => void; 34 highlightQuery: ConstructorParameters<typeof Flamegraph>[4]; 35 zoom: ConstructorParameters<typeof Flamegraph>[5]; 36 showCredit: boolean; 37 selectedItem: Maybe<string>; 38 39 onZoom: (bar: Maybe<{ i: number; j: number }>) => void; 40 onFocusOnNode: (i: number, j: number) => void; 41 setActiveItem: (item: { name: string }) => void; 42 updateView?: (v: ViewTypes) => void; 43 44 onReset: () => void; 45 isDirty: () => boolean; 46 47 ['data-testid']?: string; 48 palette: FlamegraphPalette; 49 setPalette: (p: FlamegraphPalette) => void; 50 toolbarVisible?: boolean; 51 headerVisible?: boolean; 52 disableClick?: boolean; 53 showSingleLevel?: boolean; 54 } 55 56 export default function FlameGraphComponent(props: FlamegraphProps) { 57 const canvasRef = React.useRef<HTMLCanvasElement>(null); 58 const flamegraph = useRef<Flamegraph>(); 59 60 const [rightClickedNode, setRightClickedNode] = React.useState< 61 Maybe<{ top: number; left: number; width: number }> 62 >(Maybe.nothing()); 63 64 const { 65 flamebearer, 66 focusedNode, 67 fitMode, 68 updateFitMode, 69 highlightQuery, 70 zoom, 71 toolbarVisible, 72 headerVisible = true, 73 disableClick = false, 74 showSingleLevel = false, 75 showCredit, 76 setActiveItem, 77 selectedItem, 78 updateView, 79 } = props; 80 81 const { onZoom, onReset, isDirty, onFocusOnNode } = props; 82 const { 'data-testid': dataTestId } = props; 83 const { palette, setPalette } = props; 84 85 // debounce rendering canvas 86 // used for situations like resizing 87 // triggered by eg collapsing the sidebar 88 const debouncedRenderCanvas = useCallback( 89 debounce(() => { 90 renderCanvas(); 91 }, 50), 92 [] 93 ); 94 95 // rerender whenever the canvas size changes 96 // eg window resize, or simply changing the view 97 // to display the flamegraph isolated from the table 98 useResizeObserver(canvasRef, () => { 99 if (flamegraph) { 100 debouncedRenderCanvas(); 101 } 102 }); 103 104 const onClick = (e: React.MouseEvent<HTMLCanvasElement>) => { 105 const opt = getFlamegraph().xyToBar( 106 e.nativeEvent.offsetX, 107 e.nativeEvent.offsetY 108 ); 109 110 opt.match({ 111 // clicked on an invalid node 112 Nothing: () => {}, 113 Just: (bar) => { 114 zoom.match({ 115 // there's no existing zoom 116 // so just zoom on the clicked node 117 Nothing: () => { 118 onZoom(opt); 119 }, 120 121 // it's already zoomed 122 Just: (z) => { 123 // TODO there mya be stale props here... 124 // we are clicking on the same node that's zoomed 125 if (bar.i === z.i && bar.j === z.j) { 126 // undo that zoom 127 onZoom(Maybe.nothing()); 128 } else { 129 onZoom(opt); 130 } 131 }, 132 }); 133 }, 134 }); 135 }; 136 137 const xyToHighlightData = (x: number, y: number) => { 138 const opt = getFlamegraph().xyToBar(x, y); 139 140 return opt.map((bar) => { 141 return { 142 left: getCanvas().offsetLeft + bar.x, 143 top: getCanvas().offsetTop + bar.y, 144 width: bar.width, 145 }; 146 }); 147 }; 148 149 const xyToTooltipData = (x: number, y: number) => { 150 return getFlamegraph().xyToBar(x, y); 151 }; 152 153 const onContextMenuClose = () => { 154 setRightClickedNode(Maybe.nothing()); 155 }; 156 157 const onContextMenuOpen = (x: number, y: number) => { 158 setRightClickedNode(xyToHighlightData(x, y)); 159 }; 160 161 // Context Menu stuff 162 const xyToContextMenuItems = useCallback( 163 (x: number, y: number) => { 164 const dirty = isDirty(); 165 const bar = getFlamegraph().xyToBar(x, y); 166 const barName = bar.isJust ? bar.value.name : ''; 167 168 const CollapseItem = () => { 169 const hoveredOnValidNode = bar.mapOrElse( 170 () => false, 171 () => true 172 ); 173 174 const onClick = bar.mapOrElse( 175 () => () => {}, 176 (f) => onFocusOnNode.bind(null, f.i, f.j) 177 ); 178 179 return ( 180 <MenuItem 181 key="focus" 182 disabled={!hoveredOnValidNode} 183 onClick={onClick} 184 > 185 <FontAwesomeIcon icon={faCompressAlt} /> 186 Collapse nodes above 187 </MenuItem> 188 ); 189 }; 190 191 const CopyItem = () => { 192 const onClick = () => { 193 if (!navigator.clipboard) return; 194 195 navigator.clipboard.writeText(barName); 196 }; 197 198 return ( 199 <MenuItem key="copy" onClick={onClick}> 200 <FontAwesomeIcon icon={faCopy} /> 201 Copy function name 202 </MenuItem> 203 ); 204 }; 205 206 const HighlightSimilarNodesItem = () => { 207 const onClick = () => { 208 setActiveItem({ name: barName }); 209 }; 210 211 const actionName = 212 selectedItem.isJust && selectedItem.value === barName 213 ? 'Clear highlight' 214 : 'Highlight similar nodes'; 215 216 return ( 217 <MenuItem key="highlight-similar-nodes" onClick={onClick}> 218 <FontAwesomeIcon icon={faHighlighter} /> 219 {actionName} 220 </MenuItem> 221 ); 222 }; 223 224 const OpenInSandwichViewItem = () => { 225 if (!updateView) { 226 return null; 227 } 228 229 const handleClick = () => { 230 if (updateView) { 231 updateView('sandwich'); 232 setActiveItem({ name: barName }); 233 } 234 }; 235 236 return ( 237 <MenuItem 238 key="open-in-sandwich-view" 239 className={indexStyles.sandwichItem} 240 onClick={handleClick} 241 > 242 <SandwichIcon fill="black" /> 243 Open in sandwich view 244 </MenuItem> 245 ); 246 }; 247 248 const FitModeItem = () => { 249 const isHeadFirst = fitMode === HeadMode; 250 251 const handleClick = () => { 252 const newValues = isHeadFirst ? TailMode : HeadMode; 253 updateFitMode(newValues); 254 }; 255 256 return ( 257 <MenuItem 258 className={indexStyles.fitModeItem} 259 key="fit-mode" 260 onClick={handleClick} 261 > 262 {isHeadFirst ? <TailFirstIcon /> : <HeadFirstIcon />} 263 Show text {isHeadFirst ? 'tail first' : 'head first'} 264 </MenuItem> 265 ); 266 }; 267 268 return [ 269 <MenuItem key="reset" disabled={!dirty} onClick={onReset}> 270 <FontAwesomeIcon icon={faRedo} /> 271 Reset View 272 </MenuItem>, 273 CollapseItem(), 274 CopyItem(), 275 HighlightSimilarNodesItem(), 276 OpenInSandwichViewItem(), 277 FitModeItem(), 278 ].filter(Boolean) as JSX.Element[]; 279 }, 280 [flamegraph, selectedItem, fitMode] 281 ); 282 283 const constructCanvas = () => { 284 if (canvasRef.current) { 285 const f = new Flamegraph( 286 flamebearer, 287 canvasRef.current, 288 focusedNode, 289 fitMode, 290 highlightQuery, 291 zoom, 292 palette 293 ); 294 295 flamegraph.current = f; 296 } 297 }; 298 299 React.useEffect(() => { 300 constructCanvas(); 301 renderCanvas(); 302 }, [palette]); 303 304 React.useEffect(() => { 305 constructCanvas(); 306 renderCanvas(); 307 }, [ 308 canvasRef.current, 309 flamebearer, 310 focusedNode, 311 fitMode, 312 highlightQuery, 313 zoom, 314 ]); 315 316 const renderCanvas = () => { 317 canvasRef?.current?.setAttribute('data-state', 'rendering'); 318 flamegraph?.current?.render(); 319 canvasRef?.current?.setAttribute('data-state', 'rendered'); 320 }; 321 322 const dataUnavailable = 323 !flamebearer || (flamebearer && flamebearer.names.length <= 1); 324 325 const getCanvas = () => { 326 if (!canvasRef.current) { 327 throw new Error('Missing canvas'); 328 } 329 return canvasRef.current; 330 }; 331 332 const getFlamegraph = () => { 333 if (!flamegraph.current) { 334 throw new Error('Missing canvas'); 335 } 336 return flamegraph.current; 337 }; 338 339 return ( 340 <div 341 data-testid="flamegraph-view" 342 className={clsx(indexStyles.flamegraphPane, { 343 'vertical-orientation': flamebearer.format === 'double', 344 })} 345 > 346 {headerVisible && ( 347 <Header 348 format={flamebearer.format} 349 units={flamebearer.units} 350 palette={palette} 351 setPalette={setPalette} 352 toolbarVisible={toolbarVisible} 353 /> 354 )} 355 <div 356 data-testid={dataTestId} 357 style={{ 358 opacity: dataUnavailable && !showSingleLevel ? 0 : 1, 359 }} 360 > 361 <canvas 362 height="0" 363 data-testid="flamegraph-canvas" 364 data-highlightquery={highlightQuery} 365 className={clsx('flamegraph-canvas', styles.canvas)} 366 ref={canvasRef} 367 onClick={!disableClick ? onClick : undefined} 368 /> 369 </div> 370 {showCredit ? <LogoLink /> : ''} 371 {flamegraph && canvasRef && ( 372 <Highlight 373 barHeight={PX_PER_LEVEL} 374 canvasRef={canvasRef} 375 zoom={zoom} 376 xyToHighlightData={xyToHighlightData} 377 /> 378 )} 379 {flamegraph && ( 380 <ContextMenuHighlight 381 barHeight={PX_PER_LEVEL} 382 node={rightClickedNode} 383 /> 384 )} 385 {flamegraph && ( 386 <FlamegraphTooltip 387 format={flamebearer.format} 388 canvasRef={canvasRef} 389 xyToData={xyToTooltipData as ShamefulAny} 390 numTicks={flamebearer.numTicks} 391 sampleRate={flamebearer.sampleRate} 392 leftTicks={ 393 flamebearer.format === 'double' ? flamebearer.leftTicks : 0 394 } 395 rightTicks={ 396 flamebearer.format === 'double' ? flamebearer.rightTicks : 0 397 } 398 units={flamebearer.units} 399 palette={palette} 400 /> 401 )} 402 403 {!disableClick && flamegraph && canvasRef && ( 404 <ContextMenu 405 canvasRef={canvasRef} 406 xyToMenuItems={xyToContextMenuItems} 407 onClose={onContextMenuClose} 408 onOpen={onContextMenuOpen} 409 /> 410 )} 411 </div> 412 ); 413 }