github.com/grafana/pyroscope@v1.18.0/public/app/components/TimelineChart/TimelineChartWrapper.tsx (about) 1 /* eslint-disable react/no-access-state-in-setstate */ 2 /* eslint-disable react/no-did-update-set-state */ 3 /* eslint-disable react/destructuring-assignment */ 4 import React, { ReactNode } from 'react'; 5 import Color from 'color'; 6 import type { Group } from '@pyroscope/legacy/models'; 7 import type { Timeline } from '@pyroscope/models/timeline'; 8 import type { TooltipCallbackProps } from '@pyroscope/components/TimelineChart/Tooltip.plugin'; 9 import TooltipWrapper from '@pyroscope/components/TimelineChart/TooltipWrapper'; 10 import type { TooltipWrapperProps } from '@pyroscope/components/TimelineChart/TooltipWrapper'; 11 import TimelineChart from '@pyroscope/components/TimelineChart/TimelineChart'; 12 import { markingsFromSelection } from '@pyroscope/components/TimelineChart/markings'; 13 import { centerTimelineData } from '@pyroscope/components/TimelineChart/centerTimelineData'; 14 import styles from './TimelineChartWrapper.module.css'; 15 16 export interface TimelineGroupData { 17 data: Group; 18 tagName: string; 19 color?: Color; 20 } 21 22 export interface TimelineData { 23 data?: Timeline; 24 color?: string; 25 } 26 27 export interface Selection { 28 from: string; 29 to: string; 30 color: Color; 31 overlayColor: Color; 32 } 33 34 type SingleDataProps = { 35 /** used to display at max 2 time series */ 36 mode: 'singles'; 37 /** timelineA refers to the first (and maybe unique) timeline */ 38 timelineA: TimelineData; 39 /** timelineB refers to the second timeline, useful for comparison view */ 40 timelineB?: TimelineData; 41 }; 42 43 // Used in Tag Explorer 44 type MultipleDataProps = { 45 /** used when displaying multiple time series. original use case is for tag explorer */ 46 mode: 'multiple'; 47 /** timelineGroups refers to group of timelines, useful for explore view */ 48 timelineGroups: TimelineGroupData[]; 49 /** if there is active group, the other groups should "dim" themselves */ 50 activeGroup: string; 51 /** show or hide legend */ 52 showTagsLegend: boolean; 53 /** to set active tagValue using <Legend /> */ 54 handleGroupByTagValueChange: (groupByTagValue: string) => void; 55 }; 56 57 type TimelineDataProps = SingleDataProps | MultipleDataProps; 58 59 type TimelineChartWrapperProps = TimelineDataProps & { 60 /** the id attribute of the element float will use to apply to, it should be globally unique */ 61 id: string; 62 63 ['data-testid']?: string; 64 onSelect: (from: string, until: string) => void; 65 format: 'lines' | 'bars'; 66 67 height?: string; 68 69 /** refers to the highlighted selection */ 70 selection?: { 71 left?: Selection; 72 right?: Selection; 73 }; 74 75 timezone: 'browser' | 'utc'; 76 title?: ReactNode; 77 78 /** whether to show a selection with grabbable handle 79 * ATTENTION: it only works with a single selection */ 80 selectionWithHandler?: boolean; 81 82 /** selection type 'single' => gray selection, 'double' => color selection */ 83 selectionType: 'single' | 'double'; 84 onHoverDisplayTooltip?: React.FC<TooltipCallbackProps>; 85 86 /** The list of timeline IDs (flotjs component) to sync the crosshair with */ 87 syncCrosshairsWith?: string[]; 88 }; 89 90 class TimelineChartWrapper extends React.Component< 91 TimelineChartWrapperProps, 92 // TODO add type 93 ShamefulAny 94 > { 95 // eslint-disable-next-line react/static-property-placement 96 static defaultProps = { 97 format: 'bars', 98 mode: 'singles', 99 timezone: 'browser', 100 height: '100px', 101 }; 102 103 constructor(props: TimelineChartWrapperProps) { 104 super(props); 105 106 let flotOptions = { 107 margin: { 108 top: 0, 109 left: 0, 110 bottom: 0, 111 right: 0, 112 }, 113 selection: { 114 selectionWithHandler: props.selectionWithHandler || false, 115 mode: 'x', 116 // custom selection works for 'single' selection type, 117 // 'double' selection works in old fashion way 118 // we use different props to customize selection appearance 119 selectionType: props.selectionType, 120 overlayColor: 121 props.selectionType === 'double' 122 ? undefined 123 : props?.selection?.right?.overlayColor || 124 props?.selection?.left?.overlayColor, 125 boundaryColor: 126 props.selectionType === 'double' 127 ? undefined 128 : props?.selection?.right?.color || props?.selection?.left?.color, 129 }, 130 crosshair: { 131 mode: 'x', 132 color: '#C3170D', 133 lineWidth: '1', 134 }, 135 grid: { 136 borderWidth: 1, // outside border of the timelines 137 hoverable: true, 138 139 // For the contextMenu plugin to work. From the docs: 140 // > If you set “clickable” to true, the plot will listen for click events 141 // on the plot area and fire a “plotclick” event on the placeholder with 142 // a position and a nearby data item object as parameters. 143 clickable: true, 144 }, 145 syncCrosshairsWith: [], 146 yaxis: { 147 show: false, 148 min: 0, 149 }, 150 points: { 151 show: false, 152 symbol: () => {}, // function that draw points on the chart 153 }, 154 lines: { 155 show: false, 156 }, 157 bars: { 158 show: true, 159 }, 160 xaxis: { 161 mode: 'time', 162 timezone: props.timezone, 163 reserveSpace: false, 164 // according to https://github.com/flot/flot/blob/master/API.md#customizing-the-axes 165 minTickSize: [3, 'second'], 166 }, 167 }; 168 169 flotOptions = (() => { 170 switch (props.format) { 171 case 'lines': { 172 return { 173 ...flotOptions, 174 lines: { 175 show: true, 176 lineWidth: 0.8, 177 }, 178 bars: { 179 show: false, 180 }, 181 }; 182 } 183 184 case 'bars': { 185 return { 186 ...flotOptions, 187 bars: { 188 show: true, 189 }, 190 lines: { 191 show: false, 192 }, 193 }; 194 } 195 default: { 196 throw new Error(`Invalid format: '${props.format}'`); 197 } 198 } 199 })(); 200 201 this.state = { flotOptions }; 202 this.state.flotOptions.grid.markings = this.plotMarkings(); 203 } 204 205 // TODO: this only seems to sync props back into the state, which seems unnecessary 206 componentDidUpdate(prevProps: TimelineChartWrapperProps) { 207 if ( 208 prevProps.selection !== this.props.selection || 209 prevProps.syncCrosshairsWith !== this.props.syncCrosshairsWith 210 ) { 211 const newFlotOptions = this.state.flotOptions; 212 newFlotOptions.grid.markings = this.plotMarkings(); 213 newFlotOptions.syncCrosshairsWith = this.props.syncCrosshairsWith; 214 215 this.setState({ flotOptions: newFlotOptions }); 216 } 217 } 218 219 plotMarkings = () => { 220 const selectionMarkings = markingsFromSelection( 221 this.props.selectionType, 222 this.props.selection?.left, 223 this.props.selection?.right 224 ); 225 226 return [...selectionMarkings]; 227 }; 228 229 setOnHoverDisplayTooltip = ( 230 data: TooltipWrapperProps & TooltipCallbackProps 231 ) => { 232 const tooltipContent = []; 233 234 const TooltipBody: React.FC<TooltipCallbackProps> | undefined = 235 this.props?.onHoverDisplayTooltip; 236 237 if (TooltipBody) { 238 tooltipContent.push( 239 <TooltipBody 240 key="explore-body" 241 values={data.values} 242 timeLabel={data.timeLabel} 243 /> 244 ); 245 } 246 247 if (tooltipContent.length) { 248 return ( 249 <TooltipWrapper 250 align={data.align} 251 pageY={data.pageY} 252 pageX={data.pageX} 253 > 254 {tooltipContent.map((tooltipBody) => tooltipBody)} 255 </TooltipWrapper> 256 ); 257 } 258 259 return null; 260 }; 261 262 renderMultiple = (props: MultipleDataProps) => { 263 const { flotOptions } = this.state; 264 const { timelineGroups, activeGroup } = props; 265 const { timezone } = this.props; 266 267 // TODO: unify with renderSingle 268 const onHoverDisplayTooltip = ( 269 data: TooltipWrapperProps & TooltipCallbackProps 270 ) => this.setOnHoverDisplayTooltip(data); 271 272 const customFlotOptions = { 273 ...flotOptions, 274 onHoverDisplayTooltip, 275 xaxis: { ...flotOptions.xaxis, autoscaleMargin: null, timezone }, 276 wrapperId: this.props.id, 277 }; 278 279 const centeredTimelineGroups = timelineGroups.map( 280 ({ data, color, tagName }) => { 281 return { 282 data: centerTimelineData({ data }), 283 tagName, 284 color: 285 activeGroup && activeGroup !== tagName ? color?.fade(0.75) : color, 286 }; 287 } 288 ); 289 290 return <>{this.timelineChart(centeredTimelineGroups, customFlotOptions)}</>; 291 }; 292 293 renderSingle = (props: SingleDataProps) => { 294 const { flotOptions } = this.state; 295 const { timelineA } = props; 296 let { timelineB } = props; 297 const { timezone, title } = this.props; 298 299 // TODO deep copy 300 timelineB = timelineB ? JSON.parse(JSON.stringify(timelineB)) : undefined; 301 302 // TODO: unify with renderMultiple 303 const onHoverDisplayTooltip = ( 304 data: TooltipWrapperProps & TooltipCallbackProps 305 ) => this.setOnHoverDisplayTooltip(data); 306 307 const customFlotOptions = { 308 ...flotOptions, 309 onHoverDisplayTooltip, 310 wrapperId: this.props.id, 311 xaxis: { 312 ...flotOptions.xaxis, 313 // In case there are few chunks left, then we'd like to add some margins to 314 // both sides making it look more centers 315 autoscaleMargin: 316 timelineA?.data && timelineA.data.samples.length > 3 ? null : 0.005, 317 timezone, 318 }, 319 }; 320 321 // Since this may be overwritten, we always need to set it up correctly 322 if (timelineA && timelineB) { 323 customFlotOptions.bars.show = false; 324 } else { 325 customFlotOptions.bars.show = true; 326 } 327 328 // If they are the same, skew the second one slightly so that they are both visible 329 if (areTimelinesTheSame(timelineA, timelineB)) { 330 // the factor is completely arbitrary, we use a positive number to skew above 331 timelineB = skewTimeline(timelineB, 4); 332 } 333 334 if (isSingleDatapoint(timelineA, timelineB)) { 335 // check if both have a single value 336 // if so, let's use bars 337 // since we can't put a point when there's no data when using points 338 if (timelineB && timelineB.data && timelineB.data.samples.length <= 1) { 339 customFlotOptions.bars.show = true; 340 341 // Also slightly skew to show them side by side 342 timelineB.data.startTime += 0.01; 343 } 344 } 345 346 const data = [ 347 timelineA && 348 timelineA.data && { 349 ...timelineA, 350 data: centerTimelineData(timelineA), 351 }, 352 timelineB && 353 timelineB.data && { ...timelineB, data: centerTimelineData(timelineB) }, 354 ].filter((a) => !!a); 355 356 return ( 357 <> 358 {title} 359 {this.timelineChart(data, customFlotOptions)} 360 </> 361 ); 362 }; 363 364 timelineChart = ( 365 data: Array<{ data: number[][]; color?: string | Color } | undefined>, 366 customFlotOptions: ShamefulAny 367 ) => { 368 return ( 369 <TimelineChart 370 onSelect={this.props.onSelect} 371 className={styles.wrapper} 372 data-testid={this.props['data-testid']} 373 id={this.props.id} 374 options={customFlotOptions} 375 data={data} 376 width="100%" 377 height={this.props.height} 378 /> 379 ); 380 }; 381 382 render = () => { 383 if (this.props.mode === 'multiple') { 384 return this.renderMultiple(this.props); 385 } 386 return this.renderSingle(this.props); 387 }; 388 } 389 390 function isSingleDatapoint(timelineA: TimelineData, timelineB?: TimelineData) { 391 const aIsSingle = timelineA.data && timelineA.data.samples.length <= 1; 392 if (!aIsSingle) { 393 return false; 394 } 395 396 if (timelineB && timelineB.data) { 397 return timelineB.data.samples.length <= 1; 398 } 399 400 return true; 401 } 402 403 function skewTimeline( 404 timeline: TimelineData | undefined, 405 factor: number 406 ): TimelineData | undefined { 407 if (!timeline) { 408 return undefined; 409 } 410 411 // TODO: deep copy 412 const copy = JSON.parse(JSON.stringify(timeline)) as typeof timeline; 413 414 if (copy && copy.data) { 415 let min = copy.data.samples[0]; 416 let max = copy.data.samples[0]; 417 418 for (let i = 0; i < copy.data.samples.length; i += 1) { 419 const b = copy.data.samples[i]; 420 421 if (b < min) { 422 min = b; 423 } 424 if (b > max) { 425 max = b; 426 } 427 } 428 429 const height = 100; // px 430 const skew = (max - min) / height; 431 432 if (copy.data) { 433 copy.data.samples = copy.data.samples.map((a) => { 434 // We don't want to skew negative values, since users are expecting an absent value 435 if (a <= 0) { 436 return 0; 437 } 438 439 // 4 is completely arbitrary, it was eyeballed 440 return a + skew * factor; 441 }); 442 } 443 } 444 445 return copy; 446 } 447 448 function areTimelinesTheSame( 449 timelineA: TimelineData, 450 timelineB?: TimelineData 451 ) { 452 if (!timelineA || !timelineB) { 453 // for all purposes let's consider two empty timelines the same 454 // since we want to transform them 455 return false; 456 } 457 const dataA = timelineA.data; 458 const dataB = timelineB.data; 459 460 if (!dataA || !dataB) { 461 return false; 462 } 463 464 // Find the biggest one 465 const biggest = dataA.samples.length > dataB.samples.length ? dataA : dataB; 466 const smallest = dataA.samples.length < dataB.samples.length ? dataA : dataB; 467 468 const map = new Map(biggest.samples.map((a) => [a, true])); 469 470 return smallest.samples.every((a) => map.has(a)); 471 } 472 473 export default TimelineChartWrapper;