• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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