1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import * as vega from 'vega'; 17import * as vegaLite from 'vega-lite'; 18import {getErrorMessage} from '../../base/errors'; 19import {isString, shallowEquals} from '../../base/object_utils'; 20import {SimpleResizeObserver} from '../../base/resize_observer'; 21import {Engine} from '../../trace_processor/engine'; 22import {QueryError} from '../../trace_processor/query_result'; 23import {Spinner} from '../../widgets/spinner'; 24 25function isVegaLite(spec: unknown): boolean { 26 if (typeof spec === 'object') { 27 const schema = (spec as {$schema: unknown})['$schema']; 28 if (schema !== undefined && isString(schema)) { 29 // If the schema is available use that: 30 return schema.includes('vega-lite'); 31 } 32 } 33 // Otherwise assume vega-lite: 34 return true; 35} 36 37// Vega-Lite specific interactions 38// types (https://vega.github.io/vega-lite/docs/selection.html#select) 39export enum VegaLiteSelectionTypes { 40 INTERVAL = 'interval', 41 POINT = 'point', 42} 43 44// Vega-Lite Field Types 45// These are for axis field (data) types 46// https://vega.github.io/vega-lite/docs/type.html 47export type VegaLiteFieldType = 48 | 'quantitative' 49 | 'temporal' 50 | 'ordinal' 51 | 'nominal' 52 | 'geojson'; 53 54// Vega-Lite supported aggregation operations 55// https://vega.github.io/vega-lite/docs/aggregate.html#ops 56export type VegaLiteAggregationOps = 57 | 'count' 58 | 'valid' 59 | 'values' 60 | 'missing' 61 | 'distinct' 62 | 'sum' 63 | 'product' 64 | 'mean' 65 | 'average' 66 | 'variance' 67 | 'variancep' 68 | 'stdev' 69 | 'stdevp' 70 | 'stderr' 71 | 'median' 72 | 'q1' 73 | 'q3' 74 | 'ci0' 75 | 'ci1' 76 | 'min' 77 | 'max' 78 | 'argmin' 79 | 'argmax'; 80 81export type VegaEventType = 82 | 'click' 83 | 'dblclick' 84 | 'dragenter' 85 | 'dragleave' 86 | 'dragover' 87 | 'keydown' 88 | 'keypress' 89 | 'keyup' 90 | 'mousedown' 91 | 'mousemove' 92 | 'mouseout' 93 | 'mouseover' 94 | 'mouseup' 95 | 'mousewheel' 96 | 'touchend' 97 | 'touchmove' 98 | 'touchstart' 99 | 'wheel'; 100 101export interface VegaViewData { 102 // eslint-disable-next-line @typescript-eslint/no-explicit-any 103 [name: string]: any; 104} 105 106interface VegaViewAttrs { 107 spec: string; 108 data: VegaViewData; 109 engine?: Engine; 110 onPointSelection?: (item: vega.Item) => void; 111 onIntervalSelection?: (value: vega.SignalValue) => void; 112} 113 114// VegaWrapper is in exactly one of these states: 115enum Status { 116 // Has not visualisation to render. 117 Empty, 118 // Currently loading the visualisation. 119 Loading, 120 // Failed to load or render the visualisation. The reason is 121 // retrievable via |error|. 122 Error, 123 // Displaying a visualisation: 124 Done, 125} 126 127class EngineLoader implements vega.Loader { 128 private engine?: Engine; 129 private loader: vega.Loader; 130 131 constructor(engine: Engine | undefined) { 132 this.engine = engine; 133 this.loader = vega.loader(); 134 } 135 136 // eslint-disable-next-line @typescript-eslint/no-explicit-any 137 async load(uri: string, _options?: any): Promise<string> { 138 if (this.engine === undefined) { 139 return ''; 140 } 141 try { 142 const result = await this.engine.query(uri); 143 const columns = result.columns(); 144 // eslint-disable-next-line @typescript-eslint/no-explicit-any 145 const rows: any[] = []; 146 for (const it = result.iter({}); it.valid(); it.next()) { 147 // eslint-disable-next-line @typescript-eslint/no-explicit-any 148 const row: any = {}; 149 for (const name of columns) { 150 let value = it.get(name); 151 if (typeof value === 'bigint') { 152 value = Number(value); 153 } 154 row[name] = value; 155 } 156 rows.push(row); 157 } 158 return JSON.stringify(rows); 159 } catch (e) { 160 if (e instanceof QueryError) { 161 console.error(e); 162 return ''; 163 } else { 164 throw e; 165 } 166 } 167 } 168 169 // eslint-disable-next-line @typescript-eslint/no-explicit-any 170 sanitize(uri: string, options: any): Promise<{href: string}> { 171 return this.loader.sanitize(uri, options); 172 } 173 174 // eslint-disable-next-line @typescript-eslint/no-explicit-any 175 http(uri: string, options: any): Promise<string> { 176 return this.loader.http(uri, options); 177 } 178 179 file(filename: string): Promise<string> { 180 return this.loader.file(filename); 181 } 182} 183 184class VegaWrapper { 185 private dom: Element; 186 private _spec?: string; 187 private _data?: VegaViewData; 188 private view?: vega.View; 189 private pending?: Promise<vega.View>; 190 private _status: Status; 191 private _error?: string; 192 private _engine?: Engine; 193 194 private _onPointSelection?: (item: vega.Item) => void; 195 private _onIntervalSelection?: (value: vega.SignalValue) => void; 196 197 constructor(dom: Element) { 198 this.dom = dom; 199 this._status = Status.Empty; 200 } 201 202 get status(): Status { 203 return this._status; 204 } 205 206 get error(): string { 207 return this._error ?? ''; 208 } 209 210 set spec(value: string) { 211 if (this._spec !== value) { 212 this._spec = value; 213 this.updateView(); 214 } 215 } 216 217 set data(value: VegaViewData) { 218 if (this._data === value || shallowEquals(this._data, value)) { 219 return; 220 } 221 this._data = value; 222 this.updateView(); 223 } 224 225 set engine(engine: Engine | undefined) { 226 this._engine = engine; 227 } 228 229 set onPointSelection(cb: ((item: vega.Item) => void) | undefined) { 230 this._onPointSelection = cb; 231 } 232 233 set onIntervalSelection(cb: ((value: vega.SignalValue) => void) | undefined) { 234 this._onIntervalSelection = cb; 235 } 236 237 onResize() { 238 if (this.view) { 239 this.view.resize(); 240 } 241 } 242 243 private updateView() { 244 this._status = Status.Empty; 245 this._error = undefined; 246 247 // We no longer care about inflight renders: 248 if (this.pending) { 249 this.pending = undefined; 250 } 251 252 // Destroy existing view if needed: 253 if (this.view) { 254 this.view.finalize(); 255 this.view = undefined; 256 } 257 258 // If the spec and data are both available then create a new view: 259 if (this._spec !== undefined && this._data !== undefined) { 260 let spec; 261 try { 262 spec = JSON.parse(this._spec); 263 } catch (e) { 264 this.setError(e); 265 return; 266 } 267 268 if (isVegaLite(spec)) { 269 try { 270 spec = vegaLite.compile(spec, {}).spec; 271 } catch (e) { 272 this.setError(e); 273 return; 274 } 275 } 276 277 // Create the runtime and view the bind the host DOM element 278 // and any data. 279 const runtime = vega.parse(spec); 280 this.view = new vega.View(runtime, { 281 loader: new EngineLoader(this._engine), 282 }); 283 this.view.initialize(this.dom); 284 for (const [key, value] of Object.entries(this._data)) { 285 this.view.data(key, value); 286 } 287 288 // Attaching event listeners for Vega-Lite 289 // selection interactions only (interval and point selection) 290 // Both will trigger a pointerup event 291 if (isVegaLite(this._spec)) { 292 this.view.addEventListener('pointerup', (_, item) => { 293 if (item) { 294 if ( 295 this.view?.signal(VegaLiteSelectionTypes.INTERVAL) !== 296 undefined && 297 Object.values(this.view?.signal(VegaLiteSelectionTypes.INTERVAL)) 298 .length > 0 299 ) { 300 this._onIntervalSelection?.( 301 this.view?.signal(VegaLiteSelectionTypes.INTERVAL), 302 ); 303 } else { 304 this._onPointSelection?.(item); 305 } 306 } 307 }); 308 } 309 310 const pending = this.view.runAsync(); 311 pending 312 .then(() => { 313 this.handleComplete(pending); 314 }) 315 .catch((err) => { 316 this.handleError(pending, err); 317 }); 318 this.pending = pending; 319 this._status = Status.Loading; 320 } 321 } 322 323 private handleComplete(pending: Promise<vega.View>) { 324 if (this.pending !== pending) { 325 return; 326 } 327 this._status = Status.Done; 328 this.pending = undefined; 329 m.redraw(); 330 } 331 332 private handleError(pending: Promise<vega.View>, err: unknown) { 333 if (this.pending !== pending) { 334 return; 335 } 336 this.pending = undefined; 337 this.setError(err); 338 } 339 340 private setError(err: unknown) { 341 this._status = Status.Error; 342 this._error = getErrorMessage(err); 343 m.redraw(); 344 } 345 346 [Symbol.dispose]() { 347 this._data = undefined; 348 this._spec = undefined; 349 this.updateView(); 350 } 351} 352 353export class VegaView implements m.ClassComponent<VegaViewAttrs> { 354 private wrapper?: VegaWrapper; 355 private resize?: Disposable; 356 357 oncreate({dom, attrs}: m.CVnodeDOM<VegaViewAttrs>) { 358 const wrapper = new VegaWrapper(dom.firstElementChild!); 359 wrapper.spec = attrs.spec; 360 wrapper.data = attrs.data; 361 wrapper.engine = attrs.engine; 362 363 // Chart interactivity handlers 364 wrapper.onPointSelection = attrs.onPointSelection; 365 wrapper.onIntervalSelection = attrs.onIntervalSelection; 366 367 this.wrapper = wrapper; 368 this.resize = new SimpleResizeObserver(dom, () => { 369 wrapper.onResize(); 370 }); 371 } 372 373 onupdate({attrs}: m.CVnodeDOM<VegaViewAttrs>) { 374 if (this.wrapper) { 375 this.wrapper.spec = attrs.spec; 376 this.wrapper.data = attrs.data; 377 this.wrapper.engine = attrs.engine; 378 } 379 } 380 381 onremove() { 382 if (this.resize) { 383 this.resize[Symbol.dispose](); 384 this.resize = undefined; 385 } 386 if (this.wrapper) { 387 this.wrapper[Symbol.dispose](); 388 this.wrapper = undefined; 389 } 390 } 391 392 view(_: m.Vnode<VegaViewAttrs>) { 393 return m( 394 '.pf-vega-view', 395 m(''), 396 this.wrapper?.status === Status.Loading && 397 m('.pf-vega-view-status', m(Spinner)), 398 this.wrapper?.status === Status.Error && 399 m('.pf-vega-view-status', this.wrapper?.error ?? 'Error'), 400 ); 401 } 402} 403