1import React from 'react'; 2import uPlot, { AlignedData } from 'uplot'; 3import { Themeable2 } from '../../types'; 4import { findMidPointYPosition, pluginLog } from '../uPlot/utils'; 5import { 6 DataFrame, 7 DataHoverClearEvent, 8 DataHoverEvent, 9 Field, 10 FieldMatcherID, 11 fieldMatchers, 12 LegacyGraphHoverEvent, 13 TimeRange, 14 TimeZone, 15} from '@grafana/data'; 16import { preparePlotFrame as defaultPreparePlotFrame } from './utils'; 17import { VizLegendOptions } from '@grafana/schema'; 18import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext'; 19import { Subscription } from 'rxjs'; 20import { throttleTime } from 'rxjs/operators'; 21import { GraphNGLegendEvent, XYFieldMatchers } from './types'; 22import { Renderers, UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; 23import { VizLayout } from '../VizLayout/VizLayout'; 24import { UPlotChart } from '../uPlot/Plot'; 25import { ScaleProps } from '../uPlot/config/UPlotScaleBuilder'; 26import { AxisProps } from '../uPlot/config/UPlotAxisBuilder'; 27 28/** 29 * @internal -- not a public API 30 */ 31export const FIXED_UNIT = '__fixed'; 32 33/** 34 * @internal -- not a public API 35 */ 36export type PropDiffFn<T extends any = any> = (prev: T, next: T) => boolean; 37 38export interface GraphNGProps extends Themeable2 { 39 frames: DataFrame[]; 40 structureRev?: number; // a number that will change when the frames[] structure changes 41 width: number; 42 height: number; 43 timeRange: TimeRange; 44 timeZone: TimeZone; 45 legend: VizLegendOptions; 46 fields?: XYFieldMatchers; // default will assume timeseries data 47 renderers?: Renderers; 48 tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps; 49 tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps; 50 onLegendClick?: (event: GraphNGLegendEvent) => void; 51 children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode; 52 prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder; 53 propsToDiff?: Array<string | PropDiffFn>; 54 preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame; 55 renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null; 56 57 /** 58 * needed for propsToDiff to re-init the plot & config 59 * this is a generic approach to plot re-init, without having to specify which panel-level options 60 * should cause invalidation. we can drop this in favor of something like panelOptionsRev that gets passed in 61 * similar to structureRev. then we can drop propsToDiff entirely. 62 */ 63 options?: Record<string, any>; 64} 65 66function sameProps(prevProps: any, nextProps: any, propsToDiff: Array<string | PropDiffFn> = []) { 67 for (const propName of propsToDiff) { 68 if (typeof propName === 'function') { 69 if (!propName(prevProps, nextProps)) { 70 return false; 71 } 72 } else if (nextProps[propName] !== prevProps[propName]) { 73 return false; 74 } 75 } 76 77 return true; 78} 79 80/** 81 * @internal -- not a public API 82 */ 83export interface GraphNGState { 84 alignedFrame: DataFrame; 85 alignedData: AlignedData; 86 config?: UPlotConfigBuilder; 87} 88 89/** 90 * "Time as X" core component, expects ascending x 91 */ 92export class GraphNG extends React.Component<GraphNGProps, GraphNGState> { 93 static contextType = PanelContextRoot; 94 panelContext: PanelContext = {} as PanelContext; 95 private plotInstance: React.RefObject<uPlot>; 96 97 private subscription = new Subscription(); 98 99 constructor(props: GraphNGProps) { 100 super(props); 101 this.state = this.prepState(props); 102 this.plotInstance = React.createRef(); 103 } 104 105 getTimeRange = () => this.props.timeRange; 106 107 prepState(props: GraphNGProps, withConfig = true) { 108 let state: GraphNGState = null as any; 109 110 const { frames, fields, preparePlotFrame } = props; 111 112 const preparePlotFrameFn = preparePlotFrame || defaultPreparePlotFrame; 113 114 const alignedFrame = preparePlotFrameFn( 115 frames, 116 fields || { 117 x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), 118 y: fieldMatchers.get(FieldMatcherID.numeric).get({}), 119 } 120 ); 121 pluginLog('GraphNG', false, 'data aligned', alignedFrame); 122 123 if (alignedFrame) { 124 let config = this.state?.config; 125 126 if (withConfig) { 127 config = props.prepConfig(alignedFrame, this.props.frames, this.getTimeRange); 128 pluginLog('GraphNG', false, 'config prepared', config); 129 } 130 131 state = { 132 alignedFrame, 133 alignedData: config!.prepData!([alignedFrame]) as AlignedData, 134 config, 135 }; 136 137 pluginLog('GraphNG', false, 'data prepared', state.alignedData); 138 } 139 140 return state; 141 } 142 143 handleCursorUpdate(evt: DataHoverEvent | LegacyGraphHoverEvent) { 144 const time = evt.payload?.point?.time; 145 const u = this.plotInstance.current; 146 if (u && time) { 147 // Try finding left position on time axis 148 const left = u.valToPos(time, 'x'); 149 let top; 150 if (left) { 151 // find midpoint between points at current idx 152 top = findMidPointYPosition(u, u.posToIdx(left)); 153 } 154 155 if (!top || !left) { 156 return; 157 } 158 159 u.setCursor({ 160 left, 161 top, 162 }); 163 } 164 } 165 166 componentDidMount() { 167 this.panelContext = this.context as PanelContext; 168 const { eventBus } = this.panelContext; 169 170 this.subscription.add( 171 eventBus 172 .getStream(DataHoverEvent) 173 .pipe(throttleTime(50)) 174 .subscribe({ 175 next: (evt) => { 176 if (eventBus === evt.origin) { 177 return; 178 } 179 this.handleCursorUpdate(evt); 180 }, 181 }) 182 ); 183 184 // Legacy events (from flot graph) 185 this.subscription.add( 186 eventBus 187 .getStream(LegacyGraphHoverEvent) 188 .pipe(throttleTime(50)) 189 .subscribe({ 190 next: (evt) => this.handleCursorUpdate(evt), 191 }) 192 ); 193 194 this.subscription.add( 195 eventBus 196 .getStream(DataHoverClearEvent) 197 .pipe(throttleTime(50)) 198 .subscribe({ 199 next: () => { 200 const u = this.plotInstance?.current; 201 202 if (u) { 203 u.setCursor({ 204 left: -10, 205 top: -10, 206 }); 207 } 208 }, 209 }) 210 ); 211 } 212 213 componentDidUpdate(prevProps: GraphNGProps) { 214 const { frames, structureRev, timeZone, propsToDiff } = this.props; 215 216 const propsChanged = !sameProps(prevProps, this.props, propsToDiff); 217 218 if (frames !== prevProps.frames || propsChanged) { 219 let newState = this.prepState(this.props, false); 220 221 if (newState) { 222 const shouldReconfig = 223 this.state.config === undefined || 224 timeZone !== prevProps.timeZone || 225 structureRev !== prevProps.structureRev || 226 !structureRev || 227 propsChanged; 228 229 if (shouldReconfig) { 230 newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange); 231 newState.alignedData = newState.config.prepData!([newState.alignedFrame]) as AlignedData; 232 pluginLog('GraphNG', false, 'config recreated', newState.config); 233 } 234 } 235 236 newState && this.setState(newState); 237 } 238 } 239 240 componentWillUnmount() { 241 this.subscription.unsubscribe(); 242 } 243 244 render() { 245 const { width, height, children, timeRange, renderLegend } = this.props; 246 const { config, alignedFrame, alignedData } = this.state; 247 248 if (!config) { 249 return null; 250 } 251 252 return ( 253 <VizLayout width={width} height={height} legend={renderLegend(config)}> 254 {(vizWidth: number, vizHeight: number) => ( 255 <UPlotChart 256 config={config} 257 data={alignedData} 258 width={vizWidth} 259 height={vizHeight} 260 timeRange={timeRange} 261 plotRef={(u) => ((this.plotInstance as React.MutableRefObject<uPlot>).current = u)} 262 > 263 {children ? children(config, alignedFrame) : null} 264 </UPlotChart> 265 )} 266 </VizLayout> 267 ); 268 } 269} 270