blob: 45589ae580f65a885b2144673bc1e06bc54c164f [file] [log] [blame]
// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import * as vega from 'vega';
import * as vegaLite from 'vega-lite';
import {getErrorMessage} from '../../base/errors';
import {isString, shallowEquals} from '../../base/object_utils';
import {SimpleResizeObserver} from '../../base/resize_observer';
import {Engine} from '../../trace_processor/engine';
import {QueryError} from '../../trace_processor/query_result';
import {Spinner} from '../../widgets/spinner';
function isVegaLite(spec: unknown): boolean {
if (typeof spec === 'object') {
const schema = (spec as {$schema: unknown})['$schema'];
if (schema !== undefined && isString(schema)) {
// If the schema is available use that:
return schema.includes('vega-lite');
}
}
// Otherwise assume vega-lite:
return true;
}
// Vega-Lite specific interactions
// types (https://vega.github.io/vega-lite/docs/selection.html#select)
export enum VegaLiteSelectionTypes {
INTERVAL = 'interval',
POINT = 'point',
}
// Vega-Lite Field Types
// These are for axis field (data) types
// https://vega.github.io/vega-lite/docs/type.html
export type VegaLiteFieldType =
| 'quantitative'
| 'temporal'
| 'ordinal'
| 'nominal'
| 'geojson';
// Vega-Lite supported aggregation operations
// https://vega.github.io/vega-lite/docs/aggregate.html#ops
export type VegaLiteAggregationOps =
| 'count'
| 'valid'
| 'values'
| 'missing'
| 'distinct'
| 'sum'
| 'product'
| 'mean'
| 'average'
| 'variance'
| 'variancep'
| 'stdev'
| 'stdevp'
| 'stderr'
| 'median'
| 'q1'
| 'q3'
| 'ci0'
| 'ci1'
| 'min'
| 'max'
| 'argmin'
| 'argmax';
export type VegaEventType =
| 'click'
| 'dblclick'
| 'dragenter'
| 'dragleave'
| 'dragover'
| 'keydown'
| 'keypress'
| 'keyup'
| 'mousedown'
| 'mousemove'
| 'mouseout'
| 'mouseover'
| 'mouseup'
| 'mousewheel'
| 'touchend'
| 'touchmove'
| 'touchstart'
| 'wheel';
export interface VegaViewData {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[name: string]: any;
}
type VegaSignalListenerHandler = (args: {
view: vega.View;
name: string;
value: vega.SignalValue;
}) => void;
type VegaEventListenerHandler = (args: {
view: vega.View;
event: vega.ScenegraphEvent;
item?: vega.Item | null;
}) => void;
interface VegaViewAttrs {
spec: string;
data: VegaViewData;
engine?: Engine;
signalHandlers?: {
readonly name: string;
readonly handler: VegaSignalListenerHandler;
}[];
eventHandlers?: {
readonly name: string;
readonly handler: VegaEventListenerHandler;
}[];
onViewDestroyed?: () => void;
}
// VegaWrapper is in exactly one of these states:
enum Status {
// Has not visualisation to render.
Empty,
// Currently loading the visualisation.
Loading,
// Failed to load or render the visualisation. The reason is
// retrievable via |error|.
Error,
// Displaying a visualisation:
Done,
}
class EngineLoader implements vega.Loader {
private engine?: Engine;
private loader: vega.Loader;
constructor(engine: Engine | undefined) {
this.engine = engine;
this.loader = vega.loader();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async load(uri: string, _options?: any): Promise<string> {
if (this.engine === undefined) {
return '';
}
try {
const result = await this.engine.query(uri);
const columns = result.columns();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows: any[] = [];
for (const it = result.iter({}); it.valid(); it.next()) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const row: any = {};
for (const name of columns) {
let value = it.get(name);
if (typeof value === 'bigint') {
value = Number(value);
}
row[name] = value;
}
rows.push(row);
}
return JSON.stringify(rows);
} catch (e) {
if (e instanceof QueryError) {
console.error(e);
return '';
} else {
throw e;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sanitize(uri: string, options: any): Promise<{href: string}> {
return this.loader.sanitize(uri, options);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
http(uri: string, options: any): Promise<string> {
return this.loader.http(uri, options);
}
file(filename: string): Promise<string> {
return this.loader.file(filename);
}
}
class VegaWrapper {
private dom: Element;
private _spec?: string;
private _data?: VegaViewData;
private view?: vega.View;
private pending?: Promise<vega.View>;
private _status: Status;
private _error?: string;
private _engine?: Engine;
private _signalHandlers?: {
readonly name: string;
readonly handler: vega.SignalListenerHandler;
}[];
private _eventHandlers?: {
readonly name: string;
readonly handler: vega.EventListenerHandler;
}[];
private _onViewDestroyed?: () => void;
constructor(dom: Element) {
this.dom = dom;
this._status = Status.Empty;
}
get status(): Status {
return this._status;
}
get error(): string {
return this._error ?? '';
}
set spec(value: string) {
if (this._spec !== value) {
this._spec = value;
this.updateView();
}
}
set data(value: VegaViewData) {
if (this._data === value || shallowEquals(this._data, value)) {
return;
}
this._data = value;
this.updateView();
}
set engine(engine: Engine | undefined) {
this._engine = engine;
}
set signalHandlers(
handlers: {
readonly name: string;
readonly handler: VegaSignalListenerHandler;
}[],
) {
for (const {name, handler} of this._signalHandlers ?? []) {
this.view?.removeSignalListener(name, handler);
}
this._signalHandlers = handlers.map(({name, handler}) => {
return {
name,
handler: (name2, value) =>
handler({view: this.view!, name: name2, value}),
};
});
for (const {name, handler} of this._signalHandlers) {
this.view?.addSignalListener(name, handler);
}
}
set eventHandlers(
handlers: {
readonly name: string;
readonly handler: VegaEventListenerHandler;
}[],
) {
for (const {name, handler} of this._eventHandlers ?? []) {
this.view?.removeEventListener(name, handler);
}
this._eventHandlers = handlers.map(({name, handler}) => {
return {
name,
handler: (event, item) => handler({view: this.view!, event, item}),
};
});
for (const {name, handler} of this._eventHandlers) {
this.view?.addEventListener(name, handler);
}
}
set onViewDestroyed(handler: (() => void) | undefined) {
this._onViewDestroyed = handler;
}
onResize() {
if (this.view) {
this.view.resize();
}
}
private updateView() {
this._status = Status.Empty;
this._error = undefined;
// We no longer care about inflight renders:
if (this.pending) {
this.pending = undefined;
}
// Destroy existing view if needed:
if (this.view) {
this._onViewDestroyed?.();
this.view.finalize();
this.view = undefined;
}
// If the spec and data are both available then create a new view:
if (this._spec !== undefined && this._data !== undefined) {
let spec;
try {
spec = JSON.parse(this._spec);
} catch (e) {
this.setError(e);
return;
}
if (isVegaLite(spec)) {
try {
spec = vegaLite.compile(spec, {}).spec;
} catch (e) {
this.setError(e);
return;
}
}
// Create the runtime and view the bind the host DOM element
// and any data.
const runtime = vega.parse(spec);
this.view = new vega.View(runtime, {
loader: new EngineLoader(this._engine),
});
this.view.hover();
this.view.initialize(this.dom);
for (const [key, value] of Object.entries(this._data)) {
this.view.data(key, value);
}
if (isVegaLite(this._spec)) {
for (const {name, handler} of this._signalHandlers ?? []) {
this.view.addSignalListener(name, handler);
}
for (const {name, handler} of this._eventHandlers ?? []) {
this.view.addEventListener(name, handler);
}
}
const pending = this.view.runAsync();
pending
.then(() => {
this.handleComplete(pending);
})
.catch((err) => {
this.handleError(pending, err);
});
this.pending = pending;
this._status = Status.Loading;
}
}
private handleComplete(pending: Promise<vega.View>) {
if (this.pending !== pending) {
return;
}
this._status = Status.Done;
this.pending = undefined;
m.redraw();
}
private handleError(pending: Promise<vega.View>, err: unknown) {
if (this.pending !== pending) {
return;
}
this.pending = undefined;
this.setError(err);
}
private setError(err: unknown) {
this._status = Status.Error;
this._error = getErrorMessage(err);
m.redraw();
}
[Symbol.dispose]() {
this._data = undefined;
this._spec = undefined;
this.updateView();
}
}
export class VegaView implements m.ClassComponent<VegaViewAttrs> {
private wrapper?: VegaWrapper;
private resize?: Disposable;
oncreate({dom, attrs}: m.CVnodeDOM<VegaViewAttrs>) {
const wrapper = new VegaWrapper(dom.firstElementChild!);
wrapper.spec = attrs.spec;
wrapper.data = attrs.data;
wrapper.engine = attrs.engine;
wrapper.signalHandlers = attrs.signalHandlers ?? [];
wrapper.eventHandlers = attrs.eventHandlers ?? [];
wrapper.onViewDestroyed = attrs.onViewDestroyed;
this.wrapper = wrapper;
this.resize = new SimpleResizeObserver(dom, () => {
wrapper.onResize();
});
}
onupdate({attrs}: m.CVnodeDOM<VegaViewAttrs>) {
if (this.wrapper) {
this.wrapper.spec = attrs.spec;
this.wrapper.data = attrs.data;
this.wrapper.engine = attrs.engine;
this.wrapper.signalHandlers = attrs.signalHandlers ?? [];
this.wrapper.eventHandlers = attrs.eventHandlers ?? [];
this.wrapper.onViewDestroyed = attrs.onViewDestroyed;
}
}
onremove() {
if (this.resize) {
this.resize[Symbol.dispose]();
this.resize = undefined;
}
if (this.wrapper) {
this.wrapper[Symbol.dispose]();
this.wrapper = undefined;
}
}
view(_: m.Vnode<VegaViewAttrs>) {
return m(
'.pf-vega-view',
m(''),
this.wrapper?.status === Status.Loading &&
m('.pf-vega-view-status', m(Spinner)),
this.wrapper?.status === Status.Error &&
m('.pf-vega-view-status', this.wrapper?.error ?? 'Error'),
);
}
}