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