项目文件夹

文件
2026-07-13 13:05:14 +08:00

1356 行
38 KiB
TypeScript

// @ts-ignore
import type { WebHandle, wasm_bindgen } from "./re_viewer";
let get_wasm_bindgen: (() => typeof wasm_bindgen) | null = null;
let _wasm_module: WebAssembly.Module | null = null;
/**
* Feature-detect WebAssembly SIMD (`simd128`).
*
* The viewer .wasm is compiled with `-Ctarget-feature=+simd128`, so a browser
* without SIMD support will fail to instantiate the module with a cryptic
* `CompileError`. We probe up-front and surface a clear error instead.
*
* The probe is a minimal module that uses the `v128.any_true` instruction.
* Supported in: Chrome 91+, Firefox 89+, Safari 16.4+.
*/
function has_wasm_simd(): boolean {
try {
return WebAssembly.validate(new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0,
10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11,
]));
} catch {
return false;
}
}
const UNSUPPORTED_BROWSER_MESSAGE =
"Your browser is too old to run the Rerun Viewer. " +
"The Viewer requires WebAssembly SIMD support, available in " +
"Chrome 91+, Firefox 89+, Safari 16.4+, or any modern Chromium-based browser. " +
"Please update your browser and try again.";
async function fetch_viewer_js(base_url?: string): Promise<(() => typeof wasm_bindgen)> {
// @ts-ignore
return (await import("./re_viewer")).default;
}
async function fetch_viewer_wasm(
base_url?: string,
on_progress?: (received: number, total: number | null) => void,
): Promise<Response> {
//!<INLINE-MARKER-OPEN>
const url = base_url
? new URL("./re_viewer_bg.wasm", base_url)
: new URL("./re_viewer_bg.wasm", import.meta.url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch viewer Wasm: ${response.status} ${response.statusText}`,
);
}
return wrap_fetch_with_progress(response, on_progress);
//!<INLINE-MARKER-CLOSE>
}
/**
* Estimates total uncompressed bytes for progress display.
* This is a rough estimate — do NOT use for truncation detection.
*/
function estimate_total_bytes(response: Response): number | null {
// When served with `rerun-final-length`, use that (set by `re_web_viewer_server`).
const final_length = response.headers.get("rerun-final-length");
if (final_length != null) return parseInt(final_length, 10);
// When gzip-compressed, try the GCS uncompressed-size header.
if (response.headers.get("content-encoding") === "gzip") {
const uncompressed = response.headers.get("x-goog-meta-uncompressed-size");
if (uncompressed != null) return parseInt(uncompressed, 10);
// Fall back to content-length * 3 (good empirical approximation for gzip'd wasm).
const cl = response.headers.get("content-length");
if (cl != null) return parseInt(cl, 10) * 3;
}
// Uncompressed: content-length is the exact size.
const cl = response.headers.get("content-length");
if (cl != null) return parseInt(cl, 10);
return null;
}
/**
* Wraps a fetch response to track download progress.
*/
function wrap_fetch_with_progress(
response: Response,
on_progress?: (received: number, total: number | null) => void,
): Response {
const total_bytes = estimate_total_bytes(response);
if (!response.body) return response;
let received = 0;
const body = response.body;
const tracked = new ReadableStream({
async start(controller) {
const reader = body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
on_progress?.(received, total_bytes);
controller.enqueue(value);
}
controller.close();
},
});
return new Response(tracked, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
function format_mib(bytes: number): string {
return (bytes / (1024 * 1024)).toFixed(1) + " MiB";
}
async function load(
base_url?: string,
on_progress?: (received: number, total: number | null) => void,
): Promise<typeof wasm_bindgen.WebHandle> {
if (!has_wasm_simd()) {
throw new Error(UNSUPPORTED_BROWSER_MESSAGE);
}
// instantiate wbg globals+module for every invocation of `load`,
// but don't load the JS/Wasm source every time
if (!get_wasm_bindgen || !_wasm_module) {
[get_wasm_bindgen, _wasm_module] = await Promise.all([
fetch_viewer_js(base_url),
WebAssembly.compileStreaming(fetch_viewer_wasm(base_url, on_progress)),
]);
}
let bindgen = get_wasm_bindgen();
await bindgen({ module_or_path: _wasm_module });
return class extends bindgen.WebHandle {
free() {
super.free();
// @ts-ignore
bindgen.deinit();
}
};
}
let _minimize_current_fullscreen_viewer: (() => void) | null = null;
function randomId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
export type Panel = "top" | "blueprint" | "selection" | "time";
export type PanelState = "hidden" | "collapsed" | "expanded";
export type Backend = "webgpu" | "webgl";
export type VideoDecoder = "auto" | "prefer_software" | "prefer_hardware";
export interface LoginOptions {
/** URL to redirect to after successful OAuth login (e.g. "/signed-in" or "https://example.com/signed-in"). */
signed_in_url: string;
/** URL to redirect to after logout (e.g. "/signed-out" or "https://example.com/signed-out"). */
signed_out_url: string;
}
// NOTE: When changing these options, consider how it affects the `web-viewer-react` package:
// - Should this option be exposed?
// - Should changing this option result in the viewer being restarted?
export interface WebViewerOptions {
/** Url to the example manifest. Unused if `hide_welcome_screen` is set to `true`. */
manifest_url?: string;
/** The render backend used by the viewer. Either "webgl" or "webgpu". Prefers "webgpu". */
render_backend?: Backend;
/** Video decoder config used by the viewer. Either "auto", "prefer_software" or "prefer_hardware". */
video_decoder?: VideoDecoder;
/** If set to `true`, hides the welcome screen, which contains our examples. Defaults to `false`. */
hide_welcome_screen?: boolean;
/**
* Allow the viewer to handle fullscreen mode.
* This option sets canvas style so is not recommended if you are doing anything custom,
* or are embedding the viewer in an iframe.
*
* Defaults to `false`.
*/
allow_fullscreen?: boolean;
/**
* Enable the history feature of the viewer.
*
* This is only relevant when `hide_welcome_screen` is `false`,
* as it's currently only used to allow going between the welcome screen and examples.
*
* Defaults to `false`.
*/
enable_history?: boolean;
/** The CSS width of the canvas. */
width?: string;
/** The CSS height of the canvas. */
height?: string;
/** The fallback token to use, if any.
*
* The fallback token behaves similarly to the `REDAP_TOKEN` env variable. If set in the
* enclosing notebook environment, it should be used to set the fallback token.
*/
fallback_token?: string;
/**
* The color theme to use.
*
* If not set, the viewer uses the previously persisted theme preference or defaults to "system".
*/
theme?: "dark" | "light" | "system";
/**
* Enable OAuth login in the viewer.
*
* When set, the viewer shows login UI and uses the provided URLs for OAuth redirects.
*
* To use this:
* 1. Host the `signed-in.html` and `signed-out.html` pages alongside your viewer.
* Templates can be found at:
* - https://github.com/rerun-io/rerun/blob/main/crates/viewer/re_web_viewer_server/web_viewer/signed-in.html
* - https://github.com/rerun-io/rerun/blob/main/crates/viewer/re_web_viewer_server/web_viewer/signed-out.html
* 2. Set the URLs to those pages here.
* 3. Contact your Rerun representative to have the redirect URLs
* and origin whitelisted in the OAuth configuration.
*
* When not set (default), login UI is hidden. Token-based auth still works.
*/
login?: LoginOptions;
}
export interface WebViewerOpenOptions {
/**
* Whether Rerun should open an HTTP resource in "Following" mode when streaming.
*
* Defaults to `false`. Ignored for non-HTTP URLs.
*/
follow_if_http?: boolean;
}
// `AppOptions` and `WebViewerOptions` must be compatible
// otherwise we need to restructure how we pass options to the viewer
/**
* The public interface is @see {WebViewerOptions}. This adds a few additional, internal options.
*
* @private
*/
export interface AppOptions extends WebViewerOptions {
/** The url that's used when sharing web viewer urls
*
* If not set, the viewer will use the url of the page it is embedded in.
*/
viewer_base_url?: string;
/** Whether the viewer is running in a notebook. */
notebook?: boolean;
url?: string;
panel_state_overrides?: Partial<{
[K in Panel]: PanelState;
}>;
on_viewer_event?: (event_json: string) => void;
fullscreen?: FullscreenOptions;
}
// Types are based on `crates/viewer/re_viewer/src/event.rs`.
// Important: The event names defined here are `snake_case` versions
// of their `PascalCase` counterparts on the Rust side.
/** An event produced in the Viewer. */
export type ViewerEvent =
| PlayEvent
| PauseEvent
| TimeUpdateEvent
| TimelineChangeEvent
| SelectionChangeEvent
| RecordingOpenEvent;
/**
* Properties available on all {@link ViewerEvent} types.
*/
export type ViewerEventBase = {
application_id: string;
recording_id: string;
partition_id?: string;
}
/**
* Fired when the timeline starts playing.
*/
export type PlayEvent = ViewerEventBase & {
type: "play";
};
/**
* Fired when the timeline stops playing.
*/
export type PauseEvent = ViewerEventBase & {
type: "pause";
}
/**
* Fired when the timepoint changes.
*/
export type TimeUpdateEvent = ViewerEventBase & {
type: "time_update";
time: number;
}
/**
* Fired when a different timeline is selected.
*/
export type TimelineChangeEvent = ViewerEventBase & {
type: "timeline_change";
timeline: string;
time: number;
}
/**
* Fired when the selection changes.
*
* This event is fired each time any part of the event payload changes,
* this includes for example clicking on different parts of the same
* entity in a 2D or 3D view.
*/
export type SelectionChangeEvent = ViewerEventBase & {
type: "selection_change";
items: SelectionChangeItem[];
}
/**
* Fired when a new recording is opened in the Viewer.
*
* For `rrd` file or stream, a recording is considered "open" after
* enough information about the recording, such as its ID and source,
* is received.
*
* Contains some basic information about the origin of the recording.
*/
export type RecordingOpenEvent = ViewerEventBase & {
type: "recording_open";
/**
* Where the recording came from.
*
* The value should be considered unstable, which is why we don't
* list the possible values here.
*/
source: string;
/**
* Version of the SDK used to create this recording.
*
* Uses semver format.
*/
version?: string;
}
// A bit of TypeScript metaprogramming to automatically produce a
// mapping of event names to event payloads given the above type
// definitions.
// Yield the event with type `K`.
type _GetViewerEvent<K> =
Extract<ViewerEvent, { type: K }>;
// `ViewerEvent` is a union of all events, so its `type` field
// is a union of all `type` fields.
type _ViewerEventNames = ViewerEvent["type"];
// For every event, get its payload type.
type ViewerEventMap = {
[K in _ViewerEventNames]: _GetViewerEvent<K>
}
/**
* Selected an entity, or an instance of an entity.
*
* If the entity was selected within a view, then this also
* includes the view's name.
*
* If the entity was selected within a 2D or 3D space view,
* then this also includes the position.
*/
export type EntityItem = {
type: "entity";
entity_path: string;
instance_id?: number;
view_name?: string;
position?: [number, number, number];
};
/** Selected a view. */
export type ViewItem = { type: "view"; view_id: string; view_name: string };
/** Selected a container. */
export type ContainerItem = {
type: "container";
container_id: string;
container_name: string;
};
/** A single item in a selection. */
export type SelectionChangeItem = EntityItem | ViewItem | ContainerItem;
interface FullscreenOptions {
get_state: () => boolean;
on_toggle: () => void;
}
export interface WebViewerEvents extends ViewerEventMap {
fullscreen: boolean;
ready: void;
}
// This abomination is a mapped type with key filtering, and is used to split the events
// into those which take no value in their callback, and those which do.
// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as
export type EventsWithValue = {
[K in keyof WebViewerEvents as WebViewerEvents[K] extends void
? never
: K]: WebViewerEvents[K] extends any[]
? WebViewerEvents[K]
: [WebViewerEvents[K]];
};
export type EventsWithoutValue = {
[K in keyof WebViewerEvents as WebViewerEvents[K] extends void
? K
: never]: WebViewerEvents[K];
};
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Resolve a potentially relative URL against the current origin. */
function resolveAbsoluteUrl(url: string): string {
return new URL(url, window.location.href).toString();
}
/**
* Rerun Web Viewer
*
* ```ts
* const viewer = new WebViewer();
* await viewer.start();
* ```
*
* Data may be provided to the Viewer as:
* - An HTTP file URL, e.g. `viewer.start("https://app.rerun.io/version/0.34.0/examples/dna.rrd")`
* - A Rerun gRPC URL, e.g. `viewer.start("rerun+http://127.0.0.1:9876/proxy")`
* - A stream of log messages, via {@link WebViewer.open_channel}.
*
* Callbacks may be attached for various events using {@link WebViewer.on}:
*
* ```ts
* viewer.on("time_update", (time) => console.log(`current time: {time}`));
* ```
*
* For the full list of available events, see {@link ViewerEvent}.
*/
export class WebViewer {
#id = randomId();
// NOTE: Using the handle requires wrapping all calls to its methods in try/catch.
// On failure, call `this.stop` to prevent a memory leak, then re-throw the error.
#handle: WebHandle | null = null;
#canvas: HTMLCanvasElement | null = null;
#loader: HTMLDivElement | null = null;
#state: "ready" | "starting" | "stopped" = "stopped";
#fullscreen = false;
#allow_fullscreen = false;
constructor() {
injectStyle();
setupGlobalEventListeners();
}
/**
* Start the viewer.
*
* @param rrd URLs to `.rrd` files or gRPC connections to our SDK.
* @param parent The element to attach the canvas onto.
* @param options Web Viewer configuration.
* @param open_options Open options forwarded to the initial {@link WebViewer.open} call.
*/
async start(
rrd: string | string[] | null,
parent: HTMLElement | null,
options: WebViewerOptions | null,
open_options: WebViewerOpenOptions | null = null,
): Promise<void> {
parent ??= document.body;
options ??= {};
options = options ? { ...options } : options;
this.#allow_fullscreen = options.allow_fullscreen || false;
if (this.#state !== "stopped") return;
this.#state = "starting";
this.#clearLoader();
this.#canvas = document.createElement("canvas");
this.#canvas.style.width = options.width ?? "640px";
this.#canvas.style.height = options.height ?? "360px";
parent.append(this.#canvas);
// Show loading progress bar
this.#loader = document.createElement("div");
this.#loader.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; background-color: #1c1c1c; font-family: sans-serif; color: white;">
<div style="margin-bottom: 16px;">Loading Rerun\u2026</div>
<div style="width: 200px;">
<div style="background: #333; border-radius: 4px; height: 6px; overflow: hidden;">
<div class="rerun-progress-bar" style="background: white; height: 100%; width: 0%; transition: width 0.2s;"></div>
</div>
<div class="rerun-progress-text" style="margin-top: 6px; font-size: 12px; color: #999;"></div>
</div>
</div>
`;
this.#loader.style.position = "absolute";
this.#loader.style.inset = "0";
parent.style.position = "relative";
parent.append(this.#loader);
const progress_bar = this.#loader.querySelector(".rerun-progress-bar") as HTMLElement;
const progress_text = this.#loader.querySelector(".rerun-progress-text") as HTMLElement;
const on_progress = (received: number, total: number | null) => {
if (total != null && total > 0) {
const pct = Math.min((received / total) * 100, 100);
progress_bar.style.width = pct.toFixed(1) + "%";
progress_text.textContent = `${Math.round(pct)}%`;
} else {
progress_text.textContent = format_mib(received);
}
};
// This yield appears to be necessary to ensure that the canvas is attached to the DOM
// and visible. Without it we get occasionally get a panic about a failure to find a canvas
// element with the given ID.
await delay(0);
let base_url: string | undefined = (options as any)?.base_url;
if (base_url) {
delete (options as any).base_url;
}
let WebHandle_class: typeof wasm_bindgen.WebHandle;
try {
WebHandle_class = await load(base_url, on_progress);
} catch (e) {
this.#clearLoader();
this.#fail("Failed to load rerun", String(e));
throw e;
}
if (this.#state !== "starting") {
this.#clearLoader();
return;
}
const fullscreen = this.#allow_fullscreen
? {
get_state: () => this.#fullscreen,
on_toggle: () => this.toggle_fullscreen(),
}
: undefined;
const on_viewer_event = (event_json: string) => {
// for notebooks/gradio, we can avoid a whole layer
// of serde by sending over the raw json directly,
// which will be deserialized in Python instead
this.#dispatch_raw_event(event_json);
// for JS users, we dispatch the parsed event
let event: ViewerEvent = JSON.parse(event_json);
this.#dispatch_event(
event.type as any,
event,
);
}
const login = options.login
? {
signed_in_url: resolveAbsoluteUrl(options.login.signed_in_url),
signed_out_url: resolveAbsoluteUrl(options.login.signed_out_url),
}
: undefined;
this.#handle = new WebHandle_class({
...options,
login,
fullscreen,
on_viewer_event,
});
try {
await this.#handle.start(this.#canvas);
} catch (e) {
this.#clearLoader();
this.#fail("Failed to start", String(e));
throw e;
}
if (this.#state !== "starting") {
this.#clearLoader();
return;
}
this.#clearLoader();
this.#state = "ready";
this.#dispatch_event("ready");
if (rrd) {
this.open(rrd, open_options ?? undefined);
}
let self = this;
function check_for_panic() {
if (self.#handle?.has_panicked()) {
self.#fail("Rerun has crashed.", self.#handle?.panic_message());
} else {
let delay_ms = 1000;
setTimeout(check_for_panic, delay_ms);
}
}
check_for_panic();
return;
}
#raw_events: Set<(event_json: string) => void> = new Set();
#dispatch_raw_event(event_json: string) {
for (const callback of this.#raw_events) {
callback(event_json);
}
}
/** Internal interface */
// NOTE: Callbacks passed to this function must NOT invoke any viewer methods!
// The `setTimeout` is omitted to avoid the 1-tick delay, as it is unnecessary,
// because this is only meant to be used for sending events to Jupyter/Gradio.
//
// Do not change this without searching for grepping for usage!
private _on_raw_event(callback: (event: string) => void): () => void {
this.#raw_events.add(callback);
return () => this.#raw_events.delete(callback);
}
#event_map: Map<
keyof WebViewerEvents,
Map<(...args: any[]) => void, { once: boolean }>
> = new Map();
#dispatch_event<E extends keyof EventsWithValue>(
event: E,
...args: EventsWithValue[E]
): void;
#dispatch_event<E extends keyof EventsWithoutValue>(event: E): void;
#dispatch_event(event: any, ...args: any[]): void {
// Dispatch events on next tick.
// This is necessary because we may have been called somewhere deep within the viewer's call stack,
// which means that `app` may be locked. The event will not actually be dispatched until the
// full call stack has returned or the current task has yielded to the event loop. It does not
// guarantee that we will be able to acquire the lock here, but it makes it a lot more likely.
setTimeout(() => {
const callbacks = this.#event_map.get(event);
if (callbacks) {
for (const [callback, { once }] of [...callbacks.entries()]) {
callback(...args);
if (once) callbacks.delete(callback);
}
}
}, 0);
}
/**
* Register an event listener.
*
* Returns a function which removes the listener when called.
*
* See {@link ViewerEvent} for a full list of available events.
*/
on<E extends keyof EventsWithValue>(
event: E,
callback: (...args: EventsWithValue[E]) => void,
): () => void;
on<E extends keyof EventsWithoutValue>(
event: E,
callback: () => void,
): () => void;
on(event: any, callback: any): () => void {
const callbacks = this.#event_map.get(event) ?? new Map();
callbacks.set(callback, { once: false });
this.#event_map.set(event, callbacks);
return () => callbacks.delete(callback);
}
/**
* Register an event listener which runs only once.
*
* Returns a function which removes the listener when called.
*
* See {@link ViewerEvent} for a full list of available events.
*/
once<E extends keyof EventsWithValue>(
event: E,
callback: (value: EventsWithValue[E]) => void,
): () => void;
once<E extends keyof EventsWithoutValue>(
event: E,
callback: () => void,
): () => void;
once(event: any, callback: any): () => void {
const callbacks = this.#event_map.get(event) ?? new Map();
callbacks.set(callback, { once: true });
this.#event_map.set(event, callbacks);
return () => callbacks.delete(callback);
}
/**
* Unregister an event listener.
*
* The event emitter relies on referential equality to store callbacks.
* The `callback` passed in must be the exact same _instance_ of the function passed in to `on` or `once`.
*
* See {@link ViewerEvent} for a full list of available events.
*/
off<E extends keyof EventsWithValue>(
event: E,
callback: (value: EventsWithValue[E]) => void,
): void;
off<E extends keyof EventsWithoutValue>(event: E, callback: () => void): void;
off(event: any, callback: any): void {
const callbacks = this.#event_map.get(event);
if (callbacks) {
callbacks.delete(callback);
} else {
console.warn(
"Attempted to call `WebViewer.off` with an unregistered callback. Are you passing in the same function instance?",
);
}
}
/**
* The underlying canvas element.
*/
get canvas() {
return this.#canvas;
}
/**
* Returns `true` if the viewer is ready to connect to data sources.
*/
get ready() {
return this.#state === "ready";
}
/**
* Open a recording.
*
* The viewer must have been started via {@link WebViewer.start}.
*
* @param rrd URLs to `.rrd` files or gRPC connections to our SDK.
*/
open(rrd: string | string[], options: WebViewerOpenOptions = {}) {
if (!this.#handle) {
throw new Error(`attempted to open \`${rrd}\` in a stopped viewer`);
}
const urls = Array.isArray(rrd) ? rrd : [rrd];
for (const url of urls) {
try {
this.#handle.add_receiver(url, options.follow_if_http);
} catch (e) {
this.#fail("Failed to open recording", String(e));
throw e;
}
}
}
/**
* Close a recording.
*
* The viewer must have been started via {@link WebViewer.start}.
*
* @param rrd URLs to `.rrd` files or gRPC connections to our SDK.
*/
close(rrd: string | string[]) {
if (!this.#handle) {
throw new Error(`attempted to close \`${rrd}\` in a stopped viewer`);
}
const urls = Array.isArray(rrd) ? rrd : [rrd];
for (const url of urls) {
try {
this.#handle.remove_receiver(url);
} catch (e) {
this.#fail("Failed to close recording", String(e));
throw e;
}
}
}
/**
* Stop the viewer, freeing all associated memory.
*
* The same viewer instance may be started multiple times.
*/
stop() {
if (this.#state === "stopped") return;
if (this.#allow_fullscreen && this.#canvas && this.#fullscreen) {
this.#minimize();
}
this.#state = "stopped";
this.#canvas?.remove();
this.#clearLoader();
try {
this.#handle?.destroy();
this.#handle?.free();
} catch (e) {
this.#handle = null;
throw e;
}
this.#canvas = null;
this.#handle = null;
this.#loader = null;
this.#fullscreen = false;
this.#allow_fullscreen = false;
}
#fail(message: string, error_message?: string) {
console.error("WebViewer failure:", message, error_message);
if (this.canvas?.parentElement) {
const parent = this.canvas.parentElement;
parent.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: white; font-family: sans-serif; background-color: #1c1c1c;">
<h1 class="rerun-fail-message"></h1>
<pre class="rerun-fail-error" style="text-align: left; white-space: pre-wrap; word-break: break-word; max-width: 90vw;"></pre>
<button class="rerun-fail-clear-cache">Clear caches and reload</button>
</div>
`;
parent.querySelector(".rerun-fail-message")!.textContent = message;
const errorEl = parent.querySelector(".rerun-fail-error")!;
if (error_message) {
errorEl.textContent = error_message;
} else {
errorEl.remove();
}
parent.querySelector(".rerun-fail-clear-cache")!.addEventListener("click", async () => {
if ("caches" in window) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
window.location.reload();
});
}
this.stop();
}
#clearLoader() {
this.#loader?.remove();
this.#loader = null;
}
/**
* Opens a new channel for sending log messages.
*
* The channel can be used to incrementally push `rrd` chunks into the viewer.
*
* @param channel_name used to identify the channel.
*/
open_channel(channel_name: string = "rerun-io/web-viewer"): LogChannel {
if (!this.#handle) {
throw new Error(
`attempted to open channel \"${channel_name}\" in a stopped web viewer`,
);
}
const id = crypto.randomUUID();
try {
this.#handle.open_channel(id, channel_name);
} catch (e) {
this.#fail("Failed to open channel", String(e));
throw e;
}
const on_send = (/** @type {Uint8Array} */ data: Uint8Array) => {
if (!this.#handle) {
throw new Error(
`attempted to send data through channel \"${channel_name}\" to a stopped web viewer`,
);
}
try {
this.#handle.send_rrd_to_channel(id, data);
} catch (e) {
this.#fail("Failed to send data", String(e));
throw e;
}
};
const on_send_table = (/** @type {Uint8Array} */ data: Uint8Array) => {
if (!this.#handle) {
throw new Error(
`attempted to send data through channel \"${channel_name}\" to a stopped web viewer`,
);
}
try {
this.#handle.send_table_to_channel(id, data);
} catch (e) {
this.#fail("Failed to send table", String(e));
throw e;
}
}
const on_close = () => {
if (!this.#handle) {
throw new Error(
`attempted to send data through channel \"${channel_name}\" to a stopped web viewer`,
);
}
try {
this.#handle.close_channel(id);
} catch (e) {
this.#fail("Failed to close channel", String(e));
throw e;
}
};
const get_state = () => this.#state;
return new LogChannel(on_send, on_send_table, on_close, get_state);
}
/**
* Force a panel to a specific state.
*
* @param panel which panel to configure
* @param state which state to force the panel into
*/
override_panel_state(panel: Panel, state: PanelState | undefined | null) {
if (!this.#handle) {
throw new Error(
`attempted to set ${panel} panel to ${state} in a stopped web viewer`,
);
}
try {
this.#handle.override_panel_state(panel, state);
} catch (e) {
this.#fail("Failed to override panel state", String(e));
throw e;
}
}
/**
* Toggle panel overrides set via `override_panel_state`.
*
* @param value set to a specific value. Toggles the previous value if not provided.
*/
toggle_panel_overrides(value?: boolean | null) {
if (!this.#handle) {
throw new Error(
`attempted to toggle panel overrides in a stopped web viewer`,
);
}
try {
this.#handle.toggle_panel_overrides(value as boolean | undefined);
} catch (e) {
this.#fail("Failed to toggle panel overrides", String(e));
throw e;
}
}
/**
* Get the active recording id.
*/
get_active_recording_id(): string | null {
if (!this.#handle) {
throw new Error(
`attempted to get active recording id in a stopped web viewer`,
);
}
return this.#handle.get_active_recording_id() ?? null;
}
/**
* Set the active recording id.
*
* This is the same as clicking on the recording in the Viewer's left panel.
*/
set_active_recording_id(value: string) {
if (!this.#handle) {
throw new Error(
`attempted to set active recording id to ${value} in a stopped web viewer`,
);
}
this.#handle.set_active_recording_id(value);
}
/**
* Get the play state.
*
* This always returns `false` if the recording can't be found.
*/
get_playing(recording_id: string): boolean {
if (!this.#handle) {
throw new Error(`attempted to get play state in a stopped web viewer`);
}
return this.#handle.get_playing(recording_id) || false;
}
/**
* Set the play state.
*
* This does nothing if the recording can't be found.
*/
set_playing(recording_id: string, value: boolean) {
if (!this.#handle) {
throw new Error(
`attempted to set play state to ${value ? "playing" : "paused"
} in a stopped web viewer`,
);
}
this.#handle.set_playing(recording_id, value);
}
/**
* Get the current time.
*
* The interpretation of time depends on what kind of timeline it is:
*
* - For time timelines, this is the time in nanoseconds.
* - For sequence timelines, this is the sequence number.
*
* This always returns `0` if the recording or timeline can't be found.
*/
get_current_time(recording_id: string, timeline: string): number {
if (!this.#handle) {
throw new Error(`attempted to get current time in a stopped web viewer`);
}
return this.#handle.get_time_for_timeline(recording_id, timeline) || 0;
}
/**
* Set the current time.
*
* Equivalent to clicking on the timeline in the time panel at the specified `time`.
* The interpretation of `time` depends on what kind of timeline it is:
*
* - For time timelines, this is the time in nanoseconds.
* - For sequence timelines, this is the sequence number.
*
* This does nothing if the recording or timeline can't be found.
*/
set_current_time(recording_id: string, timeline: string, time: number) {
if (!this.#handle) {
throw new Error(
`attempted to set current time to ${time} in a stopped web viewer`,
);
}
this.#handle.set_time_for_timeline(recording_id, timeline, time);
}
/**
* Get the active timeline.
*
* This always returns `null` if the recording can't be found.
*/
get_active_timeline(recording_id: string): string | null {
if (!this.#handle) {
throw new Error(
`attempted to get active timeline in a stopped web viewer`,
);
}
return this.#handle.get_active_timeline(recording_id) ?? null;
}
/**
* Set the active timeline.
*
* This does nothing if the recording or timeline can't be found.
*/
set_active_timeline(recording_id: string, timeline: string) {
if (!this.#handle) {
throw new Error(
`attempted to set active timeline to ${timeline} in a stopped web viewer`,
);
}
this.#handle.set_active_timeline(recording_id, timeline);
}
/**
* Get the time range for a timeline.
*
* This always returns `null` if the recording or timeline can't be found.
*/
get_time_range(
recording_id: string,
timeline: string,
): { min: number; max: number } | null {
if (!this.#handle) {
throw new Error(`attempted to get time range in a stopped web viewer`);
}
return this.#handle.get_timeline_time_range(recording_id, timeline);
}
/**
* Toggle fullscreen mode.
*
* This does nothing if `allow_fullscreen` was not set to `true` when starting the viewer.
*
* Fullscreen mode works by updating the underlying `<canvas>` element's `style`:
* - `position` to `fixed`
* - width/height/top/left to cover the entire viewport
*
* When fullscreen mode is toggled off, the style is restored to its previous values.
*
* When fullscreen mode is toggled on, any other instance of the viewer on the page
* which is already in fullscreen mode is toggled off. This means that it doesn't
* have to be tracked manually.
*
* This functionality can also be directly accessed in the viewer:
* - The maximize/minimize top panel button
* - The `Toggle fullscreen` UI command (accessible via the command palette, CTRL+P)
*/
toggle_fullscreen() {
if (!this.#allow_fullscreen) return;
if (!this.#handle || !this.#canvas) {
throw new Error(
`attempted to toggle fullscreen mode in a stopped web viewer`,
);
}
if (this.#fullscreen) {
this.#minimize();
} else {
this.#maximize();
}
}
set_credentials(access_token: string, email: string) {
if (!this.#handle) {
throw new Error(
`attempted to set credentials in a stopped web viewer`,
);
}
this.#handle.set_credentials(access_token, email);
}
#minimize = () => { };
#maximize = () => {
_minimize_current_fullscreen_viewer?.();
const canvas = this.#canvas!;
const rect = canvas.getBoundingClientRect();
const sync_style_to_rect = () => {
canvas.style.left = rect.left + "px";
canvas.style.top = rect.top + "px";
canvas.style.width = rect.width + "px";
canvas.style.height = rect.height + "px";
};
const undo_style = () => canvas.removeAttribute("style");
const transition = (callback: () => void) =>
setTimeout(() => requestAnimationFrame(callback), transition_delay_ms);
canvas.classList.add(classes.fullscreen_base, classes.fullscreen_rect);
sync_style_to_rect();
requestAnimationFrame(() => {
if (!this.#fullscreen) return;
canvas.classList.add(classes.transition);
transition(() => {
if (!this.#fullscreen) return;
undo_style();
document.body.classList.add(classes.hide_scrollbars);
document.documentElement.classList.add(classes.hide_scrollbars);
this.#dispatch_event("fullscreen", true);
});
});
this.#minimize = () => {
document.body.classList.remove(classes.hide_scrollbars);
document.documentElement.classList.remove(classes.hide_scrollbars);
sync_style_to_rect();
canvas.classList.remove(classes.fullscreen_rect);
transition(() => {
if (this.#fullscreen) return;
undo_style();
canvas.classList.remove(classes.fullscreen_base, classes.transition);
});
_minimize_current_fullscreen_viewer = null;
this.#fullscreen = false;
this.#dispatch_event("fullscreen", false);
};
_minimize_current_fullscreen_viewer = () => this.#minimize();
this.#fullscreen = true;
};
}
export class LogChannel {
#on_send;
#on_send_table;
#on_close;
#get_state;
#closed = false;
/**
* @param on_send
* @param on_close
* @param get_state
*/
constructor(
on_send: (data: Uint8Array) => void,
on_send_table: (data: Uint8Array) => void,
on_close: () => void,
get_state: () => "ready" | "starting" | "stopped",
) {
this.#on_send = on_send;
this.#on_send_table = on_send_table;
this.#on_close = on_close;
this.#get_state = get_state;
}
get ready() {
return !this.#closed && this.#get_state() === "ready";
}
/**
* Send an `rrd` containing log messages to the viewer.
*
* Does nothing if `!this.ready`.
*
* @param rrd_bytes Is an rrd file stored in a byte array, received via some other side channel.
*/
send_rrd(rrd_bytes: Uint8Array) {
if (!this.ready) return;
this.#on_send(rrd_bytes);
}
send_table(table_bytes: Uint8Array) {
if (!this.ready) return;
this.#on_send_table(table_bytes)
}
/**
* Close the channel.
*
* Does nothing if `!this.ready`.
*/
close() {
if (!this.ready) return;
this.#on_close();
this.#closed = true;
}
}
const classes = {
hide_scrollbars: "rerun-viewer-hide-scrollbars",
fullscreen_base: "rerun-viewer-fullscreen-base",
fullscreen_rect: "rerun-viewer-fullscreen-rect",
transition: "rerun-viewer-transition",
};
const transition_delay_ms = 100;
const css = `
html.${classes.hide_scrollbars},
body.${classes.hide_scrollbars} {
scrollbar-gutter: auto !important;
overflow: hidden !important;
}
.${classes.fullscreen_base} {
position: fixed;
z-index: 99999;
}
.${classes.transition} {
transition: all ${transition_delay_ms / 1000}s linear;
}
.${classes.fullscreen_rect} {
left: 0;
top: 0;
width: 100%;
height: 100%;
}
`;
function injectStyle() {
const ID = "__rerun_viewer_style";
if (document.getElementById(ID)) {
// already injected
return;
}
const style = document.createElement("style");
style.id = ID;
style.appendChild(document.createTextNode(css));
document.head.appendChild(style);
}
function setupGlobalEventListeners() {
window.addEventListener("keyup", (e) => {
if (e.code === "Escape") {
_minimize_current_fullscreen_viewer?.();
}
});
}