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