Skip to main content
Extend Avala’s viewer with custom visualization panels using the TypeScript Panel SDK. Build domain-specific visualizations, connect them to MCAP topics, and share them with your organization.
The Panel SDK is in preview. APIs described on this page may change.

Getting Started

Install the Panel SDK and scaffold a new panel project:
npm install @avala-ai/panel-sdk
npx avala-panel init my-custom-panel
cd my-custom-panel
npm run dev
This creates a panel project with the following structure:
my-custom-panel/
  src/
    index.ts          # Panel entry point
    types.ts          # Message type definitions
  package.json        # Dependencies and build scripts
  avala-panel.json    # Panel metadata (name, description, icon)
  tsconfig.json       # TypeScript configuration
The npm run dev command starts a local development server that hot-reloads your panel inside the Avala viewer. Open any recording in the viewer and your panel appears in the panel selector.

Panel Lifecycle

The Panel SDK uses a lifecycle-based architecture. You export an object that implements lifecycle hooks, and the viewer calls them at the appropriate times.
HookCalled WhenUse Case
onInit(context)Panel first rendersSet up canvas, WebGL context, initial state
onMessage(context, topic, message)New message arrives from a subscribed topicUpdate visualization with new data
onSeek(context, timestamp)User scrubs the timeline or playback jumpsJump visualization to a specific timestamp
onResize(context, width, height)Panel container dimensions changeAdjust canvas size, reflow layout
onSettingsChange(context, settings)User changes a panel settingUpdate colors, filters, display options
onDestroy(context)Panel removed from the layoutClean up resources, cancel animations

Lifecycle Order

When a panel is first added to the layout:
  1. onInit — set up your DOM elements and state
  2. onSettingsChange — called immediately with the initial settings
  3. onSeek — called with the current timeline position
  4. onMessage — called for each buffered message at the current timestamp
During playback, onMessage is called for every incoming message in chronological order. When the user scrubs the timeline, onSeek is called first, followed by onMessage for messages at the new timestamp.

Data Access

Subscribe to MCAP topics and receive typed messages through the onMessage hook.

Basic Example

import { PanelExtension, MessageEvent } from "@avala-ai/panel-sdk";

export const panel: PanelExtension = {
  id: "custom-heatmap",
  name: "Heatmap Panel",
  description: "Visualize spatial density data as a heatmap",

  topics: [{ topic: "/sensor/occupancy", schemaName: "OccupancyGrid" }],

  settings: {
    colorMap: {
      type: "select",
      options: ["viridis", "plasma", "inferno"],
      default: "viridis",
    },
    opacity: { type: "number", min: 0, max: 1, default: 0.8 },
  },

  onInit(context) {
    const canvas = document.createElement("canvas");
    context.panelElement.appendChild(canvas);
    context.state.canvas = canvas;
    context.state.ctx = canvas.getContext("2d");
  },

  onMessage(context, topic, message: MessageEvent) {
    const grid = message.data as OccupancyGrid;
    renderHeatmap(context.state.ctx, grid, context.settings.colorMap);
  },

  onSeek(context, timestamp) {
    // Clear the canvas and wait for messages at the new timestamp
    context.state.ctx.clearRect(
      0,
      0,
      context.state.canvas.width,
      context.state.canvas.height
    );
  },

  onResize(context, width, height) {
    context.state.canvas.width = width;
    context.state.canvas.height = height;
  },

  onDestroy(context) {
    context.state.canvas.remove();
  },
};

Subscribing to Multiple Topics

You can subscribe to multiple topics by listing them in the topics array. Each call to onMessage includes the topic name so you can route messages accordingly.
export const panel: PanelExtension = {
  id: "multi-topic-panel",
  name: "Multi-Topic Panel",
  topics: [
    { topic: "/robot/pose", schemaName: "geometry_msgs/PoseStamped" },
    { topic: "/robot/goal", schemaName: "geometry_msgs/PoseStamped" },
    { topic: "/robot/path", schemaName: "nav_msgs/Path" },
  ],

  onMessage(context, topic, message) {
    switch (topic) {
      case "/robot/pose":
        updateRobotPosition(context, message.data);
        break;
      case "/robot/goal":
        updateGoalMarker(context, message.data);
        break;
      case "/robot/path":
        updatePlannedPath(context, message.data);
        break;
    }
  },
};

Dynamic Topic Subscription

Use context.subscribe() to add topic subscriptions at runtime, for example in response to a settings change:
onSettingsChange(context, settings) {
  if (settings.additionalTopic) {
    context.subscribe([{ topic: settings.additionalTopic }]);
  }
}

Context API

The PanelContext object is passed to every lifecycle hook and provides access to the panel DOM, settings, state, and viewer controls.
PropertyTypeDescription
panelElementHTMLElementRoot DOM element for the panel. Append your visualization here.
settingsRecord<string, any>Current panel settings as configured by the user.
stateRecord<string, any>Mutable state object persisted across lifecycle calls within a session.
currentTimenumberCurrent playback timestamp in nanoseconds since the recording start.
subscribe(topics)(topics: TopicSubscription[]) => voidSubscribe to additional topics at runtime.
seekTo(timestamp)(timestamp: number) => voidProgrammatically seek the viewer timeline to a specific timestamp.
recordingRecordingInfoRecording metadata including duration, topic list, and schema definitions.

RecordingInfo

PropertyTypeDescription
durationnumberTotal recording duration in nanoseconds
startTimenumberRecording start timestamp in nanoseconds
endTimenumberRecording end timestamp in nanoseconds
topicsTopicInfo[]List of all topics in the recording
schemasSchemaInfo[]List of all message schemas

TopicInfo

PropertyTypeDescription
namestringTopic name (e.g., /camera/image)
schemaNamestringSchema name (e.g., sensor_msgs/Image)
messageCountnumberTotal number of messages on this topic
The state object is not persisted across browser sessions. Use it for transient state like canvas references, animation frame IDs, and computed caches. For persistent configuration, use settings.

Panel Settings

Define configurable settings that appear in the panel’s settings UI. Each setting type renders an appropriate input control.
Setting TypeDescriptionExample Use
selectDropdown with predefined optionsColor map, rendering mode
numberNumeric input with optional min/max/stepOpacity, threshold, point size
booleanToggle switchShow/hide grid, enable smoothing
textFree-form text inputCustom title, regex filter
colorColor picker with hex outputOverlay color, background color
topicTopic selector dropdown populated from the recordingDynamic topic binding

Defining Settings

settings: {
  colorMap: {
    type: "select",
    label: "Color Map",
    options: ["viridis", "plasma", "inferno", "magma"],
    default: "viridis",
  },
  pointSize: {
    type: "number",
    label: "Point Size",
    min: 1,
    max: 20,
    step: 1,
    default: 4,
  },
  showGrid: {
    type: "boolean",
    label: "Show Grid",
    default: true,
  },
  title: {
    type: "text",
    label: "Panel Title",
    default: "My Panel",
    placeholder: "Enter a title",
  },
  overlayColor: {
    type: "color",
    label: "Overlay Color",
    default: "#ff0000",
  },
  dataTopic: {
    type: "topic",
    label: "Data Source",
    schemaFilter: "sensor_msgs/*",
  },
}
Settings are validated by the SDK before being passed to onSettingsChange. Invalid values are rejected and the previous valid value is preserved.

Building and Publishing

Build

Compile your panel into a distributable bundle:
npm run build
This produces an optimized JavaScript bundle in the dist/ directory along with a manifest file that the viewer uses to load the panel.

Package

Create a .avala-panel archive for distribution:
npx avala-panel pack
The archive contains the compiled bundle, manifest, and any static assets referenced by your panel.

Publish

Publish the panel to your organization so all team members can use it:
npx avala-panel publish --org my-org
After publishing, the panel appears in the panel selector for all users in your organization. Users can add it to any multi-window layout.

Versioning

Panels follow semantic versioning. The version is read from package.json. When you publish a new version, users who have the panel in their layout are prompted to update.
Version BumpWhen to Use
Patch (1.0.1)Bug fixes, no API changes
Minor (1.1.0)New features, backward-compatible
Major (2.0.0)Breaking changes to settings schema or behavior

Example: Trajectory Panel

A complete example that plots 2D robot trajectories from navigation topics.
import { PanelExtension, MessageEvent } from "@avala-ai/panel-sdk";

interface Pose {
  position: { x: number; y: number; z: number };
  orientation: { x: number; y: number; z: number; w: number };
}

interface PoseStamped {
  header: { stamp: { sec: number; nsec: number } };
  pose: Pose;
}

export const panel: PanelExtension = {
  id: "trajectory-2d",
  name: "Trajectory Panel",
  description: "Plot 2D robot paths from pose topics",

  topics: [
    { topic: "/robot/pose", schemaName: "geometry_msgs/PoseStamped" },
  ],

  settings: {
    lineColor: { type: "color", label: "Path Color", default: "#00ff88" },
    lineWidth: { type: "number", label: "Line Width", min: 1, max: 8, default: 2 },
    showGrid: { type: "boolean", label: "Show Grid", default: true },
    tailLength: {
      type: "number",
      label: "Trail Length (messages)",
      min: 10,
      max: 10000,
      default: 1000,
    },
  },

  onInit(context) {
    const canvas = document.createElement("canvas");
    canvas.style.width = "100%";
    canvas.style.height = "100%";
    context.panelElement.appendChild(canvas);
    context.state.canvas = canvas;
    context.state.ctx = canvas.getContext("2d");
    context.state.poses = [];
  },

  onMessage(context, topic, message: MessageEvent) {
    const msg = message.data as PoseStamped;
    context.state.poses.push({
      x: msg.pose.position.x,
      y: msg.pose.position.y,
    });

    // Trim to tail length
    const maxLen = context.settings.tailLength;
    if (context.state.poses.length > maxLen) {
      context.state.poses = context.state.poses.slice(-maxLen);
    }

    drawTrajectory(context);
  },

  onSeek(context, timestamp) {
    // Clear accumulated poses on seek -- they will be
    // re-delivered by the viewer for the new time range
    context.state.poses = [];
  },

  onResize(context, width, height) {
    context.state.canvas.width = width;
    context.state.canvas.height = height;
    drawTrajectory(context);
  },

  onSettingsChange(context, settings) {
    drawTrajectory(context);
  },

  onDestroy(context) {
    context.state.canvas.remove();
  },
};

function drawTrajectory(context: any) {
  const { ctx, canvas, poses } = context.state;
  const { lineColor, lineWidth, showGrid } = context.settings;

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Draw grid
  if (showGrid) {
    ctx.strokeStyle = "#333333";
    ctx.lineWidth = 0.5;
    for (let x = 0; x < canvas.width; x += 50) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, canvas.height);
      ctx.stroke();
    }
    for (let y = 0; y < canvas.height; y += 50) {
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(canvas.width, y);
      ctx.stroke();
    }
  }

  if (poses.length < 2) return;

  // Compute bounds for auto-scaling
  const xs = poses.map((p: any) => p.x);
  const ys = poses.map((p: any) => p.y);
  const minX = Math.min(...xs), maxX = Math.max(...xs);
  const minY = Math.min(...ys), maxY = Math.max(...ys);
  const rangeX = maxX - minX || 1;
  const rangeY = maxY - minY || 1;
  const padding = 40;

  const scaleX = (canvas.width - 2 * padding) / rangeX;
  const scaleY = (canvas.height - 2 * padding) / rangeY;
  const scale = Math.min(scaleX, scaleY);

  const toScreen = (p: any) => ({
    x: padding + (p.x - minX) * scale,
    y: canvas.height - padding - (p.y - minY) * scale,
  });

  // Draw path
  ctx.strokeStyle = lineColor;
  ctx.lineWidth = lineWidth;
  ctx.beginPath();
  const start = toScreen(poses[0]);
  ctx.moveTo(start.x, start.y);
  for (let i = 1; i < poses.length; i++) {
    const pt = toScreen(poses[i]);
    ctx.lineTo(pt.x, pt.y);
  }
  ctx.stroke();

  // Draw current position marker
  const current = toScreen(poses[poses.length - 1]);
  ctx.fillStyle = lineColor;
  ctx.beginPath();
  ctx.arc(current.x, current.y, 6, 0, Math.PI * 2);
  ctx.fill();
}

Marketplace

Discover and install panels built by the Avala community. Browse the marketplace from the panel selector in the viewer, or visit the marketplace page in Mission Control.

Categories

CategoryExamples
RoboticsTrajectory visualization, joint state viewer, TF tree
AutomotiveCAN bus decoder, lane detection overlay, traffic sign viewer
IndustrialPLC status monitor, conveyor throughput chart, alarm timeline
ResearchDistribution plots, latent space viewer, reward curve tracker

Installing a Marketplace Panel

  1. Open the panel selector in the viewer.
  2. Navigate to the Marketplace tab.
  3. Find the panel you want and click Install.
  4. The panel is added to your organization and available immediately.
Marketplace panels are versioned independently. You can pin a specific version to avoid unexpected changes, or enable auto-updates to receive bug fixes automatically.

Next Steps