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'; 18 19import {Disposable} from '../base/disposable'; 20import {getErrorMessage} from '../base/errors'; 21import {isString, shallowEquals} from '../base/object_utils'; 22import {SimpleResizeObserver} from '../base/resize_observer'; 23import {Engine} from '../trace_processor/engine'; 24import {QueryError} from '../trace_processor/query_result'; 25import {scheduleFullRedraw} from '../widgets/raf'; 26import {Spinner} from '../widgets/spinner'; 27 28function isVegaLite(spec: unknown): boolean { 29 if (typeof spec === 'object') { 30 const schema = (spec as {$schema: unknown})['$schema']; 31 if (schema !== undefined && isString(schema)) { 32 // If the schema is available use that: 33 return schema.includes('vega-lite'); 34 } 35 } 36 // Otherwise assume vega-lite: 37 return true; 38} 39 40export interface VegaViewData { 41 // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 [name: string]: any; 43} 44 45interface VegaViewAttrs { 46 spec: string; 47 data: VegaViewData; 48 engine?: Engine; 49} 50 51// VegaWrapper is in exactly one of these states: 52enum Status { 53 // Has not visualisation to render. 54 Empty, 55 // Currently loading the visualisation. 56 Loading, 57 // Failed to load or render the visualisation. The reson is 58 // retrievable via |error|. 59 Error, 60 // Displaying a visualisation: 61 Done, 62} 63 64class EngineLoader implements vega.Loader { 65 private engine?: Engine; 66 private loader: vega.Loader; 67 68 constructor(engine: Engine | undefined) { 69 this.engine = engine; 70 this.loader = vega.loader(); 71 } 72 73 // eslint-disable-next-line @typescript-eslint/no-explicit-any 74 async load(uri: string, _options?: any): Promise<string> { 75 if (this.engine === undefined) { 76 return ''; 77 } 78 try { 79 const result = await this.engine.query(uri); 80 const columns = result.columns(); 81 // eslint-disable-next-line @typescript-eslint/no-explicit-any 82 const rows: any[] = []; 83 for (const it = result.iter({}); it.valid(); it.next()) { 84 // eslint-disable-next-line @typescript-eslint/no-explicit-any 85 const row: any = {}; 86 for (const name of columns) { 87 let value = it.get(name); 88 if (typeof value === 'bigint') { 89 value = Number(value); 90 } 91 row[name] = value; 92 } 93 rows.push(row); 94 } 95 return JSON.stringify(rows); 96 } catch (e) { 97 if (e instanceof QueryError) { 98 console.error(e); 99 return ''; 100 } else { 101 throw e; 102 } 103 } 104 } 105 106 // eslint-disable-next-line @typescript-eslint/no-explicit-any 107 sanitize(uri: string, options: any): Promise<{href: string}> { 108 return this.loader.sanitize(uri, options); 109 } 110 111 // eslint-disable-next-line @typescript-eslint/no-explicit-any 112 http(uri: string, options: any): Promise<string> { 113 return this.loader.http(uri, options); 114 } 115 116 file(filename: string): Promise<string> { 117 return this.loader.file(filename); 118 } 119} 120 121class VegaWrapper { 122 private dom: Element; 123 private _spec?: string; 124 private _data?: VegaViewData; 125 private view?: vega.View; 126 private pending?: Promise<vega.View>; 127 private _status: Status; 128 private _error?: string; 129 private _engine?: Engine; 130 131 constructor(dom: Element) { 132 this.dom = dom; 133 this._status = Status.Empty; 134 } 135 136 get status(): Status { 137 return this._status; 138 } 139 140 get error(): string { 141 return this._error ?? ''; 142 } 143 144 set spec(value: string) { 145 if (this._spec !== value) { 146 this._spec = value; 147 this.updateView(); 148 } 149 } 150 151 set data(value: VegaViewData) { 152 if (this._data === value || shallowEquals(this._data, value)) { 153 return; 154 } 155 this._data = value; 156 this.updateView(); 157 } 158 159 set engine(engine: Engine | undefined) { 160 this._engine = engine; 161 } 162 163 onResize() { 164 if (this.view) { 165 this.view.resize(); 166 } 167 } 168 169 private updateView() { 170 this._status = Status.Empty; 171 this._error = undefined; 172 173 // We no longer care about inflight renders: 174 if (this.pending) { 175 this.pending = undefined; 176 } 177 178 // Destroy existing view if needed: 179 if (this.view) { 180 this.view.finalize(); 181 this.view = undefined; 182 } 183 184 // If the spec and data are both available then create a new view: 185 if (this._spec !== undefined && this._data !== undefined) { 186 let spec; 187 try { 188 spec = JSON.parse(this._spec); 189 } catch (e) { 190 this.setError(e); 191 return; 192 } 193 194 if (isVegaLite(spec)) { 195 try { 196 spec = vegaLite.compile(spec, {}).spec; 197 } catch (e) { 198 this.setError(e); 199 return; 200 } 201 } 202 203 // Create the runtime and view the bind the host DOM element 204 // and any data. 205 const runtime = vega.parse(spec); 206 this.view = new vega.View(runtime, { 207 loader: new EngineLoader(this._engine), 208 }); 209 this.view.initialize(this.dom); 210 for (const [key, value] of Object.entries(this._data)) { 211 this.view.data(key, value); 212 } 213 214 const pending = this.view.runAsync(); 215 pending 216 .then(() => { 217 this.handleComplete(pending); 218 }) 219 .catch((err) => { 220 this.handleError(pending, err); 221 }); 222 this.pending = pending; 223 this._status = Status.Loading; 224 } 225 } 226 227 private handleComplete(pending: Promise<vega.View>) { 228 if (this.pending !== pending) { 229 return; 230 } 231 this._status = Status.Done; 232 this.pending = undefined; 233 scheduleFullRedraw(); 234 } 235 236 private handleError(pending: Promise<vega.View>, err: unknown) { 237 if (this.pending !== pending) { 238 return; 239 } 240 this.pending = undefined; 241 this.setError(err); 242 } 243 244 private setError(err: unknown) { 245 this._status = Status.Error; 246 this._error = getErrorMessage(err); 247 scheduleFullRedraw(); 248 } 249 250 dispose() { 251 this._data = undefined; 252 this._spec = undefined; 253 this.updateView(); 254 } 255} 256 257export class VegaView implements m.ClassComponent<VegaViewAttrs> { 258 private wrapper?: VegaWrapper; 259 private resize?: Disposable; 260 261 oncreate({dom, attrs}: m.CVnodeDOM<VegaViewAttrs>) { 262 const wrapper = new VegaWrapper(dom.firstElementChild!); 263 wrapper.spec = attrs.spec; 264 wrapper.data = attrs.data; 265 wrapper.engine = attrs.engine; 266 this.wrapper = wrapper; 267 this.resize = new SimpleResizeObserver(dom, () => { 268 wrapper.onResize(); 269 }); 270 } 271 272 onupdate({attrs}: m.CVnodeDOM<VegaViewAttrs>) { 273 if (this.wrapper) { 274 this.wrapper.spec = attrs.spec; 275 this.wrapper.data = attrs.data; 276 this.wrapper.engine = attrs.engine; 277 } 278 } 279 280 onremove() { 281 if (this.resize) { 282 this.resize.dispose(); 283 this.resize = undefined; 284 } 285 if (this.wrapper) { 286 this.wrapper.dispose(); 287 this.wrapper = undefined; 288 } 289 } 290 291 view(_: m.Vnode<VegaViewAttrs>) { 292 return m( 293 '.pf-vega-view', 294 m(''), 295 this.wrapper?.status === Status.Loading && 296 m('.pf-vega-view-status', m(Spinner)), 297 this.wrapper?.status === Status.Error && 298 m('.pf-vega-view-status', this.wrapper?.error ?? 'Error'), 299 ); 300 } 301} 302