Add metric visualisations

Allow plugins to add vega visualisations for specific TraceProcessor
metrics. These visualisations appear on the metrics page next to the
given metrics.

Change-Id: I6762613444d65c3e136ee0f172254cb6e39063ba
diff --git a/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256 b/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
index dbe8f50..cb72f34 100644
--- a/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
+++ b/test/data/ui-screenshots/ui-routing_navigate_navigate_back_and_forward.png.sha256
@@ -1 +1 @@
-1e9e74a4d2d5d3d8b600d4eb6d0fa3c3174c7fe99238637533f7cbed7bd89877
\ No newline at end of file
+660213bbb3da8bd84385b3e22809f4c8d3b6eae57fcdf95cd4fd336deb3cc1a5
\ No newline at end of file
diff --git a/ui/src/assets/metrics_page.scss b/ui/src/assets/metrics_page.scss
index 1487e34..b49b772 100644
--- a/ui/src/assets/metrics_page.scss
+++ b/ui/src/assets/metrics_page.scss
@@ -14,26 +14,22 @@
 
 @import "widgets/theme";
 
+.metrics-page-picker {
+  display: flex;
+}
+
+.metrics-page-picker > * {
+  margin-right: 1rem;
+}
+
 .metrics-page {
   padding: 30px;
   font-family: "Roboto", sans-serif;
-  overflow-y: auto;
+  overflow-y: scroll;
+  overflow-x: hidden;
 
-  .metric-run-button {
-    background-color: #262f3c;
-    color: #fff;
-    border-radius: 4px;
-    padding: 5px 10px;
-    font-weight: bold;
-    font-family: "Roboto";
-  }
-
-  select {
-    margin: 10px;
-    font-family: "Roboto";
-    font-size: 1em;
-    border: 1px solid black;
-    background-color: #eee;
+  & > * {
+    margin-bottom: 1rem;
   }
 
   pre {
diff --git a/ui/src/assets/widgets/vega_view.scss b/ui/src/assets/widgets/vega_view.scss
index d0d31ed..df20e99 100644
--- a/ui/src/assets/widgets/vega_view.scss
+++ b/ui/src/assets/widgets/vega_view.scss
@@ -15,19 +15,10 @@
 @import "theme";
 
 .pf-vega-view {
-  display: grid;
-  align-items: center;
-  justify-items: center;
-  grid-template-rows: 1fr;
-  grid-template-columns: 1fr;
   border-radius: $pf-border-radius;
   border: solid 1px $pf-colour-thin-border;
   color: $pf-minimal-foreground;
-}
-
-.pf-vega-view > * {
-  grid-column-start: 1;
-  grid-row-start: 1;
+  position: relative;
 }
 
 .pf-vega-view-status {
@@ -36,4 +27,6 @@
   height: 100%;
   width: 100%;
   padding: 1rem;
+  position: absolute;
+  top: 0;
 }
diff --git a/ui/src/base/object_utils.ts b/ui/src/base/object_utils.ts
index 38ece5c..d8cdd38 100644
--- a/ui/src/base/object_utils.ts
+++ b/ui/src/base/object_utils.ts
@@ -29,3 +29,23 @@
   }
   return o;
 }
+
+export function shallowEquals(a: any, b: any) {
+  if (a === b) {
+    return true;
+  }
+  if (a === undefined || b === undefined) {
+    return false;
+  }
+  for (const key of Object.keys(a)) {
+    if (a[key] !== b[key]) {
+      return false;
+    }
+  }
+  for (const key of Object.keys(b)) {
+    if (a[key] !== b[key]) {
+      return false;
+    }
+  }
+  return true;
+}
diff --git a/ui/src/base/object_utils_unittest.ts b/ui/src/base/object_utils_unittest.ts
index b28ebb3..4668c4c 100644
--- a/ui/src/base/object_utils_unittest.ts
+++ b/ui/src/base/object_utils_unittest.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {lookupPath} from './object_utils';
+import {lookupPath, shallowEquals} from './object_utils';
 
 test('lookupPath', () => {
   const nested = {baz: 'qux'};
@@ -29,3 +29,21 @@
   expect(lookupPath(value, [])).toBe(value);
   expect(lookupPath(value, ['baz'])).toBe(nested);
 });
+
+test('shallowEquals', () => {
+  const one = 1;
+  const foo = 'Foo!';
+  const nestedFoo = {
+    foo,
+  };
+  const nestedFooDupe = {
+    foo,
+  };
+
+  expect(shallowEquals({}, {})).toBe(true);
+  expect(shallowEquals({one}, {})).toBe(false);
+  expect(shallowEquals({}, {one})).toBe(false);
+  expect(shallowEquals({one}, {one})).toBe(true);
+  expect(shallowEquals({nestedFoo}, {nestedFoo})).toBe(true);
+  expect(shallowEquals({nestedFoo}, {nestedFooDupe})).toBe(false);
+});
diff --git a/ui/src/base/result.ts b/ui/src/base/result.ts
new file mode 100644
index 0000000..653eb48
--- /dev/null
+++ b/ui/src/base/result.ts
@@ -0,0 +1,65 @@
+// 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.
+
+export enum ResultStatus {
+  PENDING = 'pending',
+  SUCCESS = 'success',
+  ERROR = 'error',
+}
+
+export interface PendingResult {
+  status: ResultStatus.PENDING;
+}
+
+export interface ErrorResult {
+  status: ResultStatus.ERROR;
+  error: string;
+}
+
+export interface SuccessResult<T> {
+  status: ResultStatus.SUCCESS;
+  data: T;
+}
+
+export type Result<T> = PendingResult|ErrorResult|SuccessResult<T>;
+
+export function isError<T>(result: Result<T>): result is ErrorResult {
+  return result.status === ResultStatus.ERROR;
+}
+
+export function isPending<T>(result: Result<T>): result is PendingResult {
+  return result.status === ResultStatus.PENDING;
+}
+
+export function isSuccess<T>(result: Result<T>): result is SuccessResult<T> {
+  return result.status === ResultStatus.SUCCESS;
+}
+
+export function pending(): PendingResult {
+  return {status: ResultStatus.PENDING};
+}
+
+export function error(message: string): ErrorResult {
+  return {
+    status: ResultStatus.ERROR,
+    error: message,
+  };
+}
+
+export function success<T>(data: T): SuccessResult<T> {
+  return {
+    status: ResultStatus.SUCCESS,
+    data,
+  };
+}
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 6dfbac2..48ef058 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -1008,34 +1008,6 @@
     state.lastRecordingError = undefined;
   },
 
-  requestSelectedMetric(state: StateDraft, _: {}): void {
-    if (!state.metrics.availableMetrics) throw Error('No metrics available');
-    if (state.metrics.selectedIndex === undefined) {
-      throw Error('No metric selected');
-    }
-    state.metrics.requestedMetric =
-        state.metrics.availableMetrics[state.metrics.selectedIndex];
-  },
-
-  resetMetricRequest(state: StateDraft, args: {name: string}): void {
-    if (state.metrics.requestedMetric !== args.name) return;
-    state.metrics.requestedMetric = undefined;
-  },
-
-  setAvailableMetrics(state: StateDraft, args: {availableMetrics: string[]}):
-      void {
-        state.metrics.availableMetrics = args.availableMetrics;
-        if (args.availableMetrics.length > 0) state.metrics.selectedIndex = 0;
-      },
-
-  setMetricSelectedIndex(state: StateDraft, args: {index: number}): void {
-    if (!state.metrics.availableMetrics ||
-        args.index >= state.metrics.availableMetrics.length) {
-      throw Error('metric selection out of bounds');
-    }
-    state.metrics.selectedIndex = args.index;
-  },
-
   togglePerfDebug(state: StateDraft, _: {}): void {
     state.perfDebug = !state.perfDebug;
   },
diff --git a/ui/src/common/empty_state.ts b/ui/src/common/empty_state.ts
index 6ba8b6a..c6d4248 100644
--- a/ui/src/common/empty_state.ts
+++ b/ui/src/common/empty_state.ts
@@ -102,7 +102,6 @@
     scrollingTracks: [],
     areas: {},
     queries: {},
-    metrics: {},
     permalink: {},
     notes: {},
     visualisedArgs: [],
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index d295e49..46db5e1 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -22,6 +22,7 @@
 import {
   Command,
   EngineProxy,
+  MetricVisualisation,
   PluginContext,
   PluginInfo,
   Store,
@@ -209,6 +210,17 @@
       }
     });
   }
+
+  metricVisualisations(): MetricVisualisation[] {
+    return Array.from(this.contexts.values()).flatMap((ctx) => {
+      const tracePlugin = ctx.tracePlugin;
+      if (tracePlugin && tracePlugin.metricVisualisations) {
+        return tracePlugin.metricVisualisations();
+      } else {
+        return [];
+      }
+    });
+  }
 }
 
 // TODO(hjd): Sort out the story for global singletons like these:
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index cc20223..69cbb9a 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -115,7 +115,8 @@
 // 33. Add plugins state.
 // 34. Add additional pendingDeeplink fields (query, pid).
 // 35. Add force to OmniboxState
-export const STATE_VERSION = 35;
+// 36. Remove metrics
+export const STATE_VERSION = 36;
 
 export const SCROLLING_TRACK_GROUP = 'ScrollingTracks';
 
@@ -428,12 +429,6 @@
   sorting?: Sorting;
 }
 
-export interface MetricsState {
-  availableMetrics?: string[];  // Undefined until list is loaded.
-  selectedIndex?: number;
-  requestedMetric?: string;  // Unset after metric request is handled.
-}
-
 // Auxiliary metadata needed to parse the query result, as well as to render it
 // correctly. Generated together with the text of query and passed without the
 // change to the query response.
@@ -566,7 +561,6 @@
   debugTrackId?: string;
   lastTrackReloadRequest?: number;
   queries: ObjectById<QueryConfig>;
-  metrics: MetricsState;
   permalink: PermalinkConfig;
   notes: ObjectById<Note|AreaNote>;
   status: Status;
diff --git a/ui/src/controller/metrics_controller.ts b/ui/src/controller/metrics_controller.ts
deleted file mode 100644
index 42b61ac..0000000
--- a/ui/src/controller/metrics_controller.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2020 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 {Actions} from '../common/actions';
-import {Engine} from '../common/engine';
-import {QueryError} from '../common/query_result';
-import {globals} from '../frontend/globals';
-import {publishMetricResult} from '../frontend/publish';
-
-import {Controller} from './controller';
-
-export class MetricsController extends Controller<'main'> {
-  private engine: Engine;
-  private currentlyRunningMetric?: string;
-
-  constructor(args: {engine: Engine}) {
-    super('main');
-    this.engine = args.engine;
-    this.run();
-  }
-
-  private async computeMetric(name: string) {
-    if (name === this.currentlyRunningMetric) return;
-    this.currentlyRunningMetric = name;
-    try {
-      const metricResult = await this.engine.computeMetric([name], 'prototext');
-      const resultString =
-          metricResult instanceof Uint8Array ? '' : metricResult;
-      publishMetricResult({
-        name,
-        resultString,
-      });
-    } catch (e) {
-      if (e instanceof QueryError) {
-        // Reroute error to be displated differently when metric is run through
-        // metric page.
-        publishMetricResult({name, error: e.message});
-      } else {
-        throw e;
-      }
-    }
-    globals.dispatch(Actions.resetMetricRequest({name}));
-    this.currentlyRunningMetric = undefined;
-  }
-
-  run() {
-    const {requestedMetric} = globals.state.metrics;
-    if (!requestedMetric) return;
-    this.computeMetric(requestedMetric);
-  }
-}
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index fb6405c..981f85a 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -110,7 +110,6 @@
 import {FtraceController} from './ftrace_controller';
 import {LoadingManager} from './loading_manager';
 import {LogsController} from './logs_controller';
-import {MetricsController} from './metrics_controller';
 import {
   PIVOT_TABLE_REDUX_FLAG,
   PivotTableController,
@@ -338,8 +337,7 @@
             Child('ftrace', FtraceController, {engine, app: globals}));
 
         childControllers.push(
-          Child('traceError', TraceErrorController, {engine}));
-        childControllers.push(Child('metrics', MetricsController, {engine}));
+            Child('traceError', TraceErrorController, {engine}));
 
         return childControllers;
 
@@ -854,7 +852,6 @@
     for (const it = metricsResult.iter({name: STR}); it.valid(); it.next()) {
       availableMetrics.push(it.name);
     }
-    globals.dispatch(Actions.setAvailableMetrics({availableMetrics}));
 
     const availableMetricsSet = new Set<string>(availableMetrics);
     for (const [flag, metric] of FLAGGED_METRICS) {
diff --git a/ui/src/frontend/metrics_page.ts b/ui/src/frontend/metrics_page.ts
index 9692028..233927d 100644
--- a/ui/src/frontend/metrics_page.ts
+++ b/ui/src/frontend/metrics_page.ts
@@ -14,71 +14,274 @@
 
 import m from 'mithril';
 
-import {Actions} from '../common/actions';
+import {
+  error,
+  isError,
+  isPending,
+  pending,
+  Result,
+  success,
+} from '../base/result';
+import {EngineProxy} from '../common/engine';
+import {pluginManager, PluginManager} from '../common/plugins';
+import {STR} from '../common/query_result';
+import {raf} from '../core/raf_scheduler';
+import {MetricVisualisation} from '../public';
+
 import {globals} from './globals';
 import {createPage} from './pages';
-import {Button} from './widgets/button';
+import {Select} from './widgets/select';
+import {Spinner} from './widgets/spinner';
+import {VegaView} from './widgets/vega_view';
 
-function getCurrSelectedMetric() {
-  const {availableMetrics, selectedIndex} = globals.state.metrics;
-  if (!availableMetrics) return undefined;
-  if (selectedIndex === undefined) return undefined;
-  return availableMetrics[selectedIndex];
+type Format = 'json'|'prototext'|'proto';
+const FORMATS: Format[] = ['json', 'prototext', 'proto'];
+
+function getEngine(): EngineProxy|undefined {
+  const engineId = globals.getCurrentEngine()?.id;
+  if (engineId === undefined) {
+    return undefined;
+  }
+  const engine = globals.engines.get(engineId)?.getProxy('MetricsPage');
+  return engine;
 }
 
-class MetricResult implements m.ClassComponent {
-  view() {
-    const metricResult = globals.metricResult;
-    if (metricResult === undefined) return undefined;
-    const currSelection = getCurrSelectedMetric();
-    if (!(metricResult && metricResult.name === currSelection)) {
-      return undefined;
-    }
-    if (metricResult.error !== undefined) {
-      return m('pre.metric-error', metricResult.error);
-    }
-    if (metricResult.resultString !== undefined) {
-      return m('pre', metricResult.resultString);
-    }
-    return undefined;
+async function getMetrics(engine: EngineProxy): Promise<string[]> {
+  const metrics: string[] = [];
+  const metricsResult = await engine.query('select name from trace_metrics');
+  for (const it = metricsResult.iter({name: STR}); it.valid(); it.next()) {
+    metrics.push(it.name);
+  }
+  return metrics;
+}
+
+async function getMetric(
+    engine: EngineProxy, metric: string, format: Format): Promise<string> {
+  const result = await engine.computeMetric([metric], format);
+  if (result instanceof Uint8Array) {
+    return `Uint8Array<len=${result.length}>`;
+  } else {
+    return result;
   }
 }
 
-class MetricPicker implements m.ClassComponent {
-  view() {
-    const {availableMetrics, selectedIndex} = globals.state.metrics;
-    if (availableMetrics === undefined) return 'Loading metrics...';
-    if (availableMetrics.length === 0) return 'No metrics available';
-    if (selectedIndex === undefined) {
-      throw Error('Should not happen when avaibleMetrics is non-empty');
+class MetricsController {
+  engine: EngineProxy;
+  plugins: PluginManager;
+  private _metrics: string[];
+  private _selected?: string;
+  private _result: Result<string>;
+  private _format: Format;
+  private _json: any;
+
+  constructor(plugins: PluginManager, engine: EngineProxy) {
+    this.plugins = plugins;
+    this.engine = engine;
+    this._metrics = [];
+    this._result = success('');
+    this._json = {};
+    this._format = 'json';
+    getMetrics(this.engine).then((metrics) => {
+      this._metrics = metrics;
+    });
+  }
+
+  get metrics(): string[] {
+    return this._metrics;
+  }
+
+  get visualisations(): MetricVisualisation[] {
+    return this.plugins.metricVisualisations().filter(
+        (v) => v.metric === this.selected);
+  }
+
+  set selected(metric: string|undefined) {
+    if (this._selected === metric) {
+      return;
+    }
+    this._selected = metric;
+    this.update();
+  }
+
+  get selected(): string|undefined {
+    return this._selected;
+  }
+
+  set format(format: Format) {
+    if (this._format === format) {
+      return;
+    }
+    this._format = format;
+    this.update();
+  }
+
+  get format(): Format {
+    return this._format;
+  }
+
+  get result(): Result<string> {
+    return this._result;
+  }
+
+  get resultAsJson(): any {
+    console.log(this._json);
+    return this._json;
+  }
+
+  private update() {
+    const selected = this._selected;
+    const format = this._format;
+    if (selected === undefined) {
+      this._result = success('');
+      this._json = {};
+    } else {
+      this._result = pending();
+      this._json = {};
+      getMetric(this.engine, selected, format)
+          .then((result) => {
+            if (this._selected === selected && this._format === format) {
+              this._result = success(result);
+              if (format === 'json') {
+                this._json = JSON.parse(result);
+              }
+            }
+          })
+          .catch((e) => {
+            if (this._selected === selected && this._format === format) {
+              this._result = error(e);
+              this._json = {};
+            }
+          })
+          .finally(() => {
+            raf.scheduleFullRedraw();
+          });
+    }
+    raf.scheduleFullRedraw();
+  }
+}
+
+interface MetricResultAttrs {
+  result: Result<string>;
+}
+
+class MetricResultView implements m.ClassComponent<MetricResultAttrs> {
+  view({attrs}: m.CVnode<MetricResultAttrs>) {
+    const result = attrs.result;
+    if (isPending(result)) {
+      return m(Spinner);
     }
 
-    return m('div', [
-      'Select a metric:',
-      m('select',
-        {
-          selectedIndex: globals.state.metrics.selectedIndex,
-          onchange: (e: InputEvent) => {
-            globals.dispatch(Actions.setMetricSelectedIndex(
-                {index: (e.target as HTMLSelectElement).selectedIndex}));
+    if (isError(result)) {
+      return m('pre.metric-error', result.error);
+    }
+
+    return m('pre', result.data);
+  }
+}
+
+interface MetricPickerAttrs {
+  controller: MetricsController;
+}
+
+class MetricPicker implements m.ClassComponent<MetricPickerAttrs> {
+  view({attrs}: m.CVnode<MetricPickerAttrs>) {
+    const {controller} = attrs;
+    return m(
+        '.metrics-page-picker',
+        m(Select,
+          {
+            value: controller.selected,
+            oninput: (e: Event) => {
+              if (!e.target) return;
+              controller.selected = (e.target as HTMLSelectElement).value;
+            },
           },
-        },
-        availableMetrics.map(
-            (metric) => m('option', {value: metric, key: metric}, metric))),
-      m(Button, {
-        onclick: () => globals.dispatch(Actions.requestSelectedMetric({})),
-        label: 'Run',
+          controller.metrics.map(
+              (metric) =>
+                  m('option',
+                    {
+                      value: metric,
+                      key: metric,
+                    },
+                    metric))),
+        m(
+            Select,
+            {
+              oninput: (e: Event) => {
+                if (!e.target) return;
+                controller.format =
+                    (e.target as HTMLSelectElement).value as Format;
+              },
+            },
+            FORMATS.map((f) => {
+              return m('option', {
+                selected: controller.format === f,
+                key: f,
+                value: f,
+                label: f,
+              });
+            }),
+            ),
+    );
+  }
+}
+
+interface MetricVizViewAttrs {
+  visualisation: MetricVisualisation;
+  data: any;
+}
+
+class MetricVizView implements m.ClassComponent<MetricVizViewAttrs> {
+  view({attrs}: m.CVnode<MetricVizViewAttrs>) {
+    return m(
+        '',
+        m(VegaView, {
+          spec: attrs.visualisation.spec,
+          data: {
+            metric: attrs.data,
+          },
+        }),
+    );
+  }
+};
+
+class MetricPageContents implements m.ClassComponent {
+  controller?: MetricsController;
+
+  oncreate() {
+    const engine = getEngine();
+    if (engine !== undefined) {
+      this.controller = new MetricsController(pluginManager, engine);
+    }
+  }
+
+  view() {
+    const controller = this.controller;
+    if (controller === undefined) {
+      return m('');
+    }
+
+    const json = controller.resultAsJson;
+
+    return [
+      m(MetricPicker, {
+        controller,
       }),
-    ]);
+      (controller.format === 'json') &&
+          controller.visualisations.map((visualisation) => {
+            let data = json;
+            for (const p of visualisation.path) {
+              data = data[p] ?? [];
+            }
+            return m(MetricVizView, {visualisation, data});
+          }),
+      m(MetricResultView, {result: controller.result}),
+    ];
   }
 }
 
 export const MetricsPage = createPage({
   view() {
-    return m(
-        '.metrics-page',
-        m(MetricPicker),
-        m(MetricResult),
-    );
+    return m('.metrics-page', m(MetricPageContents));
   },
 });
diff --git a/ui/src/frontend/widgets/vega_view.ts b/ui/src/frontend/widgets/vega_view.ts
index 145ca4c..6c69203 100644
--- a/ui/src/frontend/widgets/vega_view.ts
+++ b/ui/src/frontend/widgets/vega_view.ts
@@ -16,6 +16,9 @@
 import * as vega from 'vega';
 import * as vegaLite from 'vega-lite';
 
+import {Disposable} from '../../base/disposable';
+import {shallowEquals} from '../../base/object_utils';
+import {SimpleResizeObserver} from '../../base/resize_observer';
 import {getErrorMessage} from '../../common/errors';
 import {raf} from '../../core/raf_scheduler';
 
@@ -86,9 +89,16 @@
   }
 
   set data(value: VegaViewData) {
-    if (this._data !== value) {
-      this._data = value;
-      this.updateView();
+    if (this._data === value || shallowEquals(this._data, value)) {
+      return;
+    }
+    this._data = value;
+    this.updateView();
+  }
+
+  onResize() {
+    if (this.view) {
+      this.view.resize();
     }
   }
 
@@ -180,12 +190,16 @@
 
 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;
     this.wrapper = wrapper;
+    this.resize = new SimpleResizeObserver(dom, () => {
+      wrapper.onResize();
+    });
   }
 
   onupdate({attrs}: m.CVnodeDOM<VegaViewAttrs>) {
@@ -196,6 +210,10 @@
   }
 
   onremove() {
+    if (this.resize) {
+      this.resize.dispose();
+      this.resize = undefined;
+    }
     if (this.wrapper) {
       this.wrapper.dispose();
       this.wrapper = undefined;
diff --git a/ui/src/plugins/dev.perfetto.AndroidBinderViz/index.ts b/ui/src/plugins/dev.perfetto.AndroidBinderViz/index.ts
new file mode 100644
index 0000000..8f01d35
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.AndroidBinderViz/index.ts
@@ -0,0 +1,69 @@
+// 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 {
+  EngineProxy,
+  MetricVisualisation,
+  PluginContext,
+  Store,
+  TracePlugin,
+} from '../../public';
+
+const SPEC = `
+{
+  "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+  "width": "container",
+  "height": 300,
+  "description": ".",
+  "data": {
+    "name": "metric"
+  },
+  "mark": "bar",
+  "encoding": {
+    "x": {"field": "client_process", "type": "nominal"},
+    "y": {"field": "client_dur", "aggregate": "max"}
+  }
+}
+`;
+
+interface State {}
+
+class AndroidBinderVizPlugin implements TracePlugin {
+  static migrate(_initialState: unknown): State {
+    return {};
+  }
+
+  constructor(_store: Store<State>, _engine: EngineProxy) {
+    // No-op
+  }
+
+  dispose(): void {
+    // No-op
+  }
+
+  metricVisualisations(): MetricVisualisation[] {
+    return [{
+      metric: 'android_binder',
+      spec: SPEC,
+      path: ['android_binder', 'unaggregated_txn_breakdown'],
+    }];
+  }
+}
+
+export const plugin = {
+  pluginId: 'dev.perfetto.AndroidBinderVizPlugin',
+  activate(ctx: PluginContext) {
+    ctx.registerTracePluginFactory(AndroidBinderVizPlugin);
+  },
+};
diff --git a/ui/src/public/index.ts b/ui/src/public/index.ts
index 92450b0..086628a 100644
--- a/ui/src/public/index.ts
+++ b/ui/src/public/index.ts
@@ -51,6 +51,39 @@
   callback: (...args: any[]) => void;
 }
 
+export interface MetricVisualisation {
+  // The name of the metric e.g. 'android_camera'
+  metric: string;
+
+  // A vega or vega-lite visualisation spec.
+  // The data from the metric under path will be exposed as a
+  // datasource named "metric" in Vega(-Lite)
+  spec: string;
+
+  // A path index into the metric.
+  // For example if the metric returns the folowing protobuf:
+  // {
+  //   foo {
+  //     bar {
+  //       baz: { name: "a" }
+  //       baz: { name: "b" }
+  //       baz: { name: "c" }
+  //     }
+  //   }
+  // }
+  // That becomes the following json:
+  // { "foo": { "bar": { "baz": [
+  //  {"name": "a"},
+  //  {"name": "b"},
+  //  {"name": "c"},
+  // ]}}}
+  // And given path = ["foo", "bar", "baz"]
+  // We extract:
+  // [ {"name": "a"}, {"name": "b"}, {"name": "c"} ]
+  // And pass that to the vega(-lite) visualisation.
+  path: string[];
+}
+
 // All trace plugins must implement this interface.
 export interface TracePlugin extends Disposable {
   commands?: () => Command[];
@@ -59,6 +92,10 @@
   // potential tracks. Zero or more of the provided tracks may be
   // instantiated depending on the users choices.
   tracks?: () => Promise<TrackInfo[]>;
+
+  // Metric visualisations. These extend the metrics page with
+  // visualisations for specific metrics.
+  metricVisualisations?: () => MetricVisualisation[];
 }
 
 // This interface defines what a plugin factory should look like.