• 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';
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