github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/webapp/javascript/components/Heatmap/index.tsx (about) 1 import React, { useState, useRef, useMemo, useEffect, RefObject } from 'react'; 2 import useResizeObserver from '@react-hook/resize-observer'; 3 import Color from 'color'; 4 import cl from 'classnames'; 5 import { interpolateViridis } from 'd3-scale-chromatic'; 6 7 import { getFormatter } from '@pyroscope/flamegraph/src/format/format'; 8 import type { Heatmap as HeatmapType } from '@webapp/services/render'; 9 import { 10 SelectedAreaCoordsType, 11 useHeatmapSelection, 12 } from './useHeatmapSelection.hook'; 13 import HeatmapTooltip from './HeatmapTooltip'; 14 import { HEATMAP_HEIGHT, HEATMAP_COLORS } from './constants'; 15 import { getTicks } from './utils'; 16 17 // eslint-disable-next-line css-modules/no-unused-class 18 import styles from './Heatmap.module.scss'; 19 20 interface HeatmapProps { 21 heatmap: HeatmapType; 22 onSelection: ( 23 minV: number, 24 maxV: number, 25 startT: number, 26 endT: number 27 ) => void; 28 sampleRate: number; 29 timezone: string; 30 } 31 32 export function Heatmap({ 33 heatmap, 34 onSelection, 35 sampleRate, 36 timezone, 37 }: HeatmapProps) { 38 const canvasRef = useRef<HTMLCanvasElement>(null); 39 const heatmapRef = useRef<HTMLDivElement>(null); 40 const resizedSelectedAreaRef = useRef<HTMLDivElement>(null); 41 const [heatmapW, setHeatmapW] = useState(0); 42 43 const { selectedCoordinates, selectedAreaToHeatmapRatio, resetSelection } = 44 useHeatmapSelection({ 45 canvasRef, 46 resizedSelectedAreaRef, 47 heatmapW, 48 heatmap, 49 onSelection, 50 }); 51 52 useEffect(() => { 53 if (heatmapRef.current) { 54 const { width } = heatmapRef.current.getBoundingClientRect(); 55 setHeatmapW(width); 56 } 57 }, []); 58 59 useResizeObserver(heatmapRef.current, (entry: ResizeObserverEntry) => { 60 if (canvasRef.current) { 61 // Firefox implements `contentBoxSize` as a single content rect, rather than an array 62 const contentBoxSize = Array.isArray(entry.contentBoxSize) 63 ? entry.contentBoxSize[0] 64 : entry.contentBoxSize; 65 66 canvasRef.current.width = contentBoxSize.inlineSize; 67 setHeatmapW(contentBoxSize.inlineSize); 68 } 69 }); 70 71 const getColor = useMemo( 72 () => 73 (x: number): string => { 74 if (x === 0) { 75 return Color.rgb(22, 22, 22).toString(); 76 } 77 78 // from 0 to 1 79 const colorIndex = (x - heatmap.minDepth) / heatmap.maxDepth; 80 81 return interpolateViridis(colorIndex); 82 }, 83 [heatmap] 84 ); 85 86 const getLegendLabel = (index: number): string => { 87 switch (index) { 88 case 0: 89 return heatmap.maxDepth.toString(); 90 case 3: 91 return Math.round( 92 (heatmap.maxDepth - heatmap.minDepth) / 2 + heatmap.minDepth 93 ).toString(); 94 case 6: 95 return heatmap.minDepth.toString(); 96 default: 97 return ''; 98 } 99 }; 100 101 const heatmapGrid = (() => 102 heatmap.values.map((column, colIndex) => ( 103 // eslint-disable-next-line react/no-array-index-key 104 <g role="row" key={colIndex}> 105 {column.map((itemsCount: number, rowIndex: number, itemsCountArr) => ( 106 <rect 107 role="gridcell" 108 data-items={itemsCount} 109 fill={getColor(itemsCount)} 110 // eslint-disable-next-line react/no-array-index-key 111 key={rowIndex} 112 x={colIndex * (heatmapW / heatmap.timeBuckets)} 113 y={ 114 (itemsCountArr.length - 1 - rowIndex) * 115 (HEATMAP_HEIGHT / heatmap.valueBuckets) 116 } 117 width={heatmapW / heatmap.timeBuckets} 118 height={HEATMAP_HEIGHT / heatmap.valueBuckets} 119 /> 120 ))} 121 </g> 122 )))(); 123 124 return ( 125 <div 126 ref={heatmapRef} 127 className={styles.heatmapContainer} 128 data-testid="heatmap-container" 129 > 130 <Axis 131 axis="y" 132 sampleRate={sampleRate} 133 min={heatmap.minValue} 134 max={heatmap.maxValue} 135 ticksCount={5} 136 /> 137 <ResizedSelectedArea 138 resizedSelectedAreaRef={resizedSelectedAreaRef} 139 start={selectedCoordinates.start || { x: 0, y: 0 }} 140 end={selectedCoordinates.end || { x: 0, y: 0 }} 141 containerW={heatmapW} 142 resizeRatio={selectedAreaToHeatmapRatio} 143 handleClick={resetSelection} 144 /> 145 <svg role="img" className={styles.heatmapSvg} height={HEATMAP_HEIGHT}> 146 {heatmapGrid} 147 <foreignObject 148 className={styles.selectionContainer} 149 height={HEATMAP_HEIGHT} 150 > 151 <canvas 152 data-testid="selection-canvas" 153 id="selectionCanvas" 154 ref={canvasRef} 155 height={HEATMAP_HEIGHT} 156 /> 157 </foreignObject> 158 </svg> 159 <Axis 160 axis="x" 161 min={heatmap.startTime} 162 max={heatmap.endTime} 163 ticksCount={7} 164 timezone={timezone} 165 /> 166 <div className={styles.legend} data-testid="color-scale"> 167 <span className={styles.units}>Count</span> 168 {HEATMAP_COLORS.map((color, index) => ( 169 <div key={color.toString()} className={styles.colorLabelContainer}> 170 {index % 3 === 0 && ( 171 <span role="textbox" className={styles.label}> 172 {getLegendLabel(index)} 173 </span> 174 )} 175 <div 176 className={styles.color} 177 style={{ 178 backgroundColor: color.toString(), 179 }} 180 /> 181 </div> 182 ))} 183 </div> 184 <HeatmapTooltip 185 dataSourceElRef={canvasRef} 186 heatmapW={heatmapW} 187 heatmap={heatmap} 188 timezone={timezone} 189 sampleRate={sampleRate} 190 /> 191 </div> 192 ); 193 } 194 195 interface ResizedSelectedArea { 196 resizedSelectedAreaRef: RefObject<HTMLDivElement>; 197 containerW: number; 198 start: SelectedAreaCoordsType; 199 end: SelectedAreaCoordsType; 200 resizeRatio: number; 201 handleClick: () => void; 202 } 203 204 function ResizedSelectedArea({ 205 resizedSelectedAreaRef, 206 containerW, 207 start, 208 end, 209 resizeRatio, 210 handleClick, 211 }: ResizedSelectedArea) { 212 const top = start.y > end.y ? end.y : start.y; 213 const originalLeftOffset = start.x > end.x ? end.x : start.x; 214 215 const w = Math.abs(containerW / resizeRatio); 216 const h = Math.abs(end.y - start.y); 217 const left = Math.abs((originalLeftOffset * w) / (end.x - start.x || 1)); 218 219 return ( 220 <> 221 {h ? ( 222 <div 223 style={{ 224 position: 'absolute', 225 width: w, 226 height: h, 227 top, 228 left, 229 border: `1px solid ${Color.rgb(255, 149, 5).toString()}`, 230 }} 231 /> 232 ) : null} 233 <div 234 ref={resizedSelectedAreaRef} 235 data-testid="selection-resizable-canvas" 236 onClick={handleClick} 237 className={styles.selectedAreaBlock} 238 id="selectionArea" 239 style={{ 240 width: w, 241 height: h, 242 top, 243 left, 244 mixBlendMode: 'overlay', 245 backgroundColor: Color.rgb(255, 149, 5).toString(), 246 }} 247 /> 248 </> 249 ); 250 } 251 252 interface AxisProps { 253 axis: 'x' | 'y'; 254 min: number; 255 max: number; 256 ticksCount: number; 257 timezone?: string; 258 sampleRate?: number; 259 } 260 261 function Axis({ axis, max, min, ticksCount, timezone, sampleRate }: AxisProps) { 262 const yAxisformatter = sampleRate && getFormatter(max, sampleRate, 'samples'); 263 let ticks: string[]; 264 265 ticks = getTicks( 266 min, 267 max, 268 { timezone, formatter: yAxisformatter, ticksCount }, 269 sampleRate 270 ); 271 272 // There's not enough data to construct the Y axis 273 if (axis === 'y' && min === 0 && max === 0) { 274 ticks = ['0']; 275 } 276 277 return ( 278 <div 279 data-testid={`${axis}-axis`} 280 className={styles[`${axis}Axis`]} 281 style={axis === 'y' ? { height: HEATMAP_HEIGHT } : {}} 282 > 283 {yAxisformatter ? ( 284 <div className={styles.axisUnits}>{yAxisformatter.suffix}s</div> 285 ) : null} 286 <div className={styles.tickValues}> 287 {ticks.map((tick) => ( 288 <div 289 role="textbox" 290 className={cl(styles.tickValue, styles[`${axis}TickValue`])} 291 key={tick} 292 > 293 <span>{tick}</span> 294 </div> 295 ))} 296 </div> 297 <div className={styles.ticksContainer}> 298 {ticks.map((tick) => ( 299 <div className={styles.tick} key={tick} /> 300 ))} 301 </div> 302 </div> 303 ); 304 }