xref: /aosp_15_r20/external/perfetto/ui/src/components/widgets/vega_view.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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