perspective_viewer/custom_elements/viewer.rs
1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13#![allow(non_snake_case)]
14
15use std::cell::RefCell;
16use std::rc::Rc;
17
18use futures::channel::oneshot::channel;
19use futures::future::join_all;
20use js_sys::{Array, JsString};
21use perspective_client::config::ViewConfigUpdate;
22use perspective_client::utils::PerspectiveResultExt;
23use perspective_js::utils::global;
24use perspective_js::{JsViewConfig, JsViewWindow, Table, View, apierror};
25use wasm_bindgen::JsCast;
26use wasm_bindgen::prelude::*;
27use wasm_bindgen_derive::try_from_js_option;
28use wasm_bindgen_futures::JsFuture;
29use web_sys::HtmlElement;
30use yew::Callback;
31
32use crate::components::viewer::{PerspectiveViewerMsg, PerspectiveViewerProps};
33use crate::config::*;
34use crate::custom_events::*;
35use crate::js::*;
36use crate::presentation::*;
37use crate::queries::*;
38use crate::root::Root;
39use crate::session::{ResetOptions, TableLoadState};
40use crate::tasks::*;
41use crate::utils::*;
42use crate::workspace::{Panel, PanelId, Workspace};
43use crate::*;
44
45#[wasm_bindgen]
46extern "C" {
47 /// `load()` argument: a [`Client`], a (deprecated) [`Table`], or a
48 /// `Promise` resolving to either. Typed rather than `any` so callers get
49 /// completion; the `Table` forms remain runtime-deprecated.
50 #[wasm_bindgen(typescript_type = "Client | Table | Promise<Client | Table>")]
51 pub type JsClientLoad;
52
53 /// `eject()` argument dict (`{ client?: string }`).
54 #[wasm_bindgen(typescript_type = "ClientOptions")]
55 pub type JsClientOptions;
56
57 /// Panel-selector dict (`{ panel?: string }`) for the active/base
58 /// accessor methods.
59 #[wasm_bindgen(typescript_type = "PanelOptions")]
60 pub type JsPanelOptions;
61
62 /// `download`/`export`/`copy` options dict
63 /// (`{ method?: ExportMethod, panel?: string }`).
64 #[wasm_bindgen(typescript_type = "ExportOptions")]
65 pub type JsExportOptions;
66
67 /// `getTable` options dict (`{ wait?: boolean, panel?: string }`).
68 #[wasm_bindgen(typescript_type = "GetTableOptions")]
69 pub type JsGetTableOptions;
70
71 /// `getClient` options dict (`{ wait?: boolean, panel?: string }`).
72 #[wasm_bindgen(typescript_type = "GetClientOptions")]
73 pub type JsGetClientOptions;
74
75 /// `restoreWorkspace()` argument: a whole-element config update.
76 #[wasm_bindgen(typescript_type = "WorkspaceConfigUpdate")]
77 pub type JsWorkspaceConfigUpdate;
78
79 /// `saveWorkspace()` return: a whole-element config.
80 #[wasm_bindgen(typescript_type = "Promise<WorkspaceConfig>")]
81 pub type JsWorkspaceConfigPromise;
82
83 /// A `Promise<void>` return, used by the `restore` family (whose
84 /// `ApiFuture<()>` would otherwise erase to `Promise<any>`).
85 #[wasm_bindgen(typescript_type = "Promise<void>")]
86 pub type JsVoidPromise;
87
88 /// `save()` return: a single-panel config.
89 #[wasm_bindgen(typescript_type = "Promise<ViewerConfig>")]
90 pub type JsViewerConfigPromise;
91}
92
93#[derive(serde::Deserialize, Default)]
94struct ResizeOptions {
95 dimensions: Option<ResizeDimensions>,
96}
97
98#[derive(serde::Deserialize, Clone, Copy)]
99struct ResizeDimensions {
100 width: f64,
101 height: f64,
102}
103
104/// Leniently deserialize an optional JS options dict into a serde struct,
105/// falling back to `Default` on absence or a malformed argument (matching the
106/// `ResizeOptions` precedent — an options bag is a best-effort convenience,
107/// not a hard-validated payload).
108fn parse_options<T, U>(options: Option<T>) -> U
109where
110 T: Into<JsValue>,
111 U: Default + for<'a> serde::Deserialize<'a>,
112{
113 options
114 .and_then(|o| o.into_serde_ext().ok())
115 .unwrap_or_default()
116}
117
118/// The `<perspective-viewer>` custom element.
119///
120/// # JavaScript Examples
121///
122/// Create a new `<perspective-viewer>`:
123///
124/// ```javascript
125/// const viewer = document.createElement("perspective-viewer");
126/// window.body.appendChild(viewer);
127/// ```
128///
129/// Complete example including loading and restoring the [`Table`]:
130///
131/// ```javascript
132/// import perspective from "@perspective-dev/viewer";
133/// import perspective from "@perspective-dev/client";
134///
135/// const viewer = document.createElement("perspective-viewer");
136/// const worker = await perspective.worker();
137///
138/// await worker.table("x\n1", {name: "table_one"});
139/// await viewer.load(worker);
140/// await viewer.restore({table: "table_one"});
141/// ```
142#[derive(Clone)]
143#[wasm_bindgen]
144pub struct PerspectiveViewerElement {
145 pub(crate) presentation: Presentation,
146 pub(crate) workspace: Workspace,
147 pub(crate) elem: HtmlElement,
148 pub(crate) root: Root<components::viewer::PerspectiveViewer>,
149 resize_handle: Rc<RefCell<Option<ResizeObserverHandle>>>,
150 intersection_handle: Rc<RefCell<Option<AutoPauseHandle>>>,
151 hosted_table_subs: HostedTableSubs,
152 _subscriptions: Rc<[Subscription; 2]>,
153 _custom_event_subs: Rc<Vec<Subscription>>,
154}
155
156impl CustomElementMetadata for PerspectiveViewerElement {
157 const CUSTOM_ELEMENT_NAME: &'static str = "perspective-viewer";
158 const STATICS: &'static [&'static str] =
159 ["registerPlugin", "get_wasm_module", "get_worker_url"].as_slice();
160}
161
162impl PerspectiveViewerElement {
163 fn layout_changed_notify(&self) -> Callback<()> {
164 let root = self.root.clone();
165 Callback::from(move |_: ()| {
166 if let Some(app) = root.borrow().as_ref() {
167 app.send_message(PerspectiveViewerMsg::LayoutChanged);
168 }
169 })
170 }
171
172 fn resolve_panel(&self, name: Option<String>) -> ApiResult<Panel> {
173 let id = name.map(PanelId::from);
174 self.workspace.panel_or_active(id.as_ref()).ok_or_else(|| {
175 format!(
176 "No panel named \"{}\"",
177 id.as_ref().map(PanelId::as_str).unwrap_or_default()
178 )
179 .into()
180 })
181 }
182
183 fn layout_element(&self) -> Option<RegularLayout> {
184 self.elem
185 .shadow_root()?
186 .query_selector(RegularLayout::TAG_NAME)
187 .ok()
188 .flatten()
189 .map(|el| el.unchecked_into())
190 }
191
192 async fn workspace_config(this: Self) -> ApiResult<JsValue> {
193 let mut panels: std::collections::BTreeMap<String, PanelViewerConfig> = Default::default();
194 for id in &this.workspace.panel_ids() {
195 let panel = this.workspace.panel(id).into_apierror()?;
196 let config = panel
197 .renderer
198 .clone()
199 .with_lock(async {
200 get_viewer_config(&panel.session, &panel.renderer, &this.presentation).await
201 })
202 .await?;
203
204 panels.insert(id.as_str().to_owned(), config.panel);
205 }
206
207 let active = this
208 .presentation
209 .is_settings_open()
210 .then(|| this.workspace.active_id())
211 .flatten()
212 .map(|id| id.as_str().to_owned());
213
214 let layout = this
215 .layout_element()
216 .map(|l| l.save().into_serde_ext::<crate::js::Layout>())
217 .transpose()?;
218
219 Ok(JsValue::from_serde_ext(&WorkspaceConfig {
220 version: API_VERSION.to_string(),
221 active,
222 layout,
223 panels,
224 global_filters: this.workspace.global_filters(),
225 masters: this
226 .workspace
227 .masters()
228 .iter()
229 .map(|id| id.as_str().to_owned())
230 .collect(),
231 })?)
232 }
233}
234
235fn eject_client_panels(
236 workspace: &Workspace,
237 root: &Root<crate::components::viewer::PerspectiveViewer>,
238 target: String,
239 ids: Vec<PanelId>,
240) -> ApiFuture<()> {
241 clone!(workspace, root);
242 let effect = workspace.effects().guard();
243 ApiFuture::new_throttled(async move {
244 let _effect = effect;
245 for id in ids {
246 let (completion, receiver) = Completion::new();
247 root.borrow()
248 .as_ref()
249 .into_apierror()?
250 .send_message(PerspectiveViewerMsg::ClosePanel(
251 id.to_string(),
252 Some(completion),
253 ));
254
255 receiver.await.map_err(|_| ApiError::new("Cancelled"))??;
256 }
257
258 workspace.remove_client(&target);
259 Ok(())
260 })
261}
262
263#[rustfmt::skip]
264const DEPRECATED_TABLE_MESSAGE: &str =
265 "`load(table)` is deprecated - use `load(client)` followed by `restore({table: \"name\"})` instead";
266
267#[wasm_bindgen]
268impl PerspectiveViewerElement {
269 #[doc(hidden)]
270 #[wasm_bindgen(constructor)]
271 pub fn new(elem: web_sys::HtmlElement) -> Self {
272 let init = web_sys::ShadowRootInit::new(web_sys::ShadowRootMode::Open);
273 let shadow_root = elem
274 .attach_shadow(&init)
275 .unwrap()
276 .unchecked_into::<web_sys::Element>();
277
278 Self::new_from_shadow(elem, shadow_root)
279 }
280
281 fn new_from_shadow(elem: web_sys::HtmlElement, shadow_root: web_sys::Element) -> Self {
282 // Application State.
283 let presentation = Presentation::new(&elem);
284
285 // Boot with ZERO panels — an unconfigured element is a blank stage. The
286 // first `load`/`restore`/`addPanel` creates the first panel, which
287 // adopts the element's `theme` attribute when set (see
288 // `create_panel_model`'s authored-theme boot).
289 let workspace = Workspace::new();
290 let custom_event_subs = wire_element_events(&elem, &presentation, &workspace);
291
292 // Create Yew App
293 let props = yew::props!(PerspectiveViewerProps {
294 elem: elem.clone(),
295 presentation: presentation.clone(),
296 workspace: workspace.clone(),
297 });
298
299 let state = props.clone();
300 let root = Root::new(shadow_root, props);
301
302 // Create callbacks
303 let eject_sub = presentation.on_eject.add_listener({
304 let root = root.clone();
305 move |_| {
306 clone!(state.workspace, root);
307 ApiFuture::spawn(async move {
308 if let Some(target) = workspace.active_client().map(|c| c.get_name().to_owned())
309 {
310 let ids = workspace.panels_for_client(&target);
311 if ids.len() < workspace.panel_ids().len() {
312 return eject_client_panels(&workspace, &root, target, ids).await;
313 }
314 }
315
316 delete_all(&workspace, &root).await
317 })
318 }
319 });
320
321 let resize_handle = ResizeObserverHandle::new(&elem, &workspace, &presentation, &root);
322 let intersect_handle = AutoPauseHandle::new(&elem, &presentation, &workspace);
323 let (lifecycle_sub, hosted_table_subs) = wire_table_lifecycle(&workspace, &presentation);
324
325 Self {
326 elem,
327 root,
328 presentation,
329 workspace,
330 resize_handle: Rc::new(RefCell::new(Some(resize_handle))),
331 intersection_handle: Rc::new(RefCell::new(Some(intersect_handle))),
332 hosted_table_subs,
333 _subscriptions: Rc::new([eject_sub, lifecycle_sub]),
334 _custom_event_subs: Rc::new(custom_event_subs),
335 }
336 }
337
338 #[doc(hidden)]
339 #[wasm_bindgen(js_name = "connectedCallback")]
340 pub fn connected_callback(&self) -> ApiResult<()> {
341 tracing::debug!("Connected <perspective-viewer>");
342 Ok(())
343 }
344
345 /// Loads a [`Client`], or optionally [`Table`], or optionally a Javascript
346 /// `Promise` which returns a [`Client`] or [`Table`], in this viewer.
347 ///
348 /// Loading a [`Client`] does not render, but subsequent calls to
349 /// [`PerspectiveViewerElement::restore`] will use this [`Client`] to look
350 /// up the proviced `table` name field for the provided
351 /// [`ViewerConfigUpdate`].
352 ///
353 /// Loading a [`Table`] is equivalent to subsequently calling
354 /// [`Self::restore`] with the `table` field set to [`Table::get_name`], and
355 /// will render the UI in its default state when [`Self::load`] resolves.
356 /// If you plan to call [`Self::restore`] anyway, prefer passing a
357 /// [`Client`] argument to [`Self::load`] as it will conserve one render.
358 ///
359 /// When [`PerspectiveViewerElement::load`] resolves, the first frame of the
360 /// UI + visualization is guaranteed to have been drawn. Awaiting the result
361 /// of this method in a `try`/`catch` block will capture any errors
362 /// thrown during the loading process, or from the [`Client`] `Promise`
363 /// itself.
364 ///
365 /// [`PerspectiveViewerElement::load`] may also be called with a [`Table`],
366 /// which is equivalent to:
367 ///
368 /// ```javascript
369 /// await viewer.load(await table.get_client());
370 /// await viewer.restore({name: await table.get_name()})
371 /// ```
372 ///
373 /// If you plan to call [`PerspectiveViewerElement::restore`] immediately
374 /// after [`PerspectiveViewerElement::load`] yourself, as is commonly
375 /// done when loading and configuring a new `<perspective-viewer>`, you
376 /// should use a [`Client`] as an argument and set the `table` field in the
377 /// restore call as
378 ///
379 /// A [`Table`] can be created using the
380 /// [`@perspective-dev/client`](https://www.npmjs.com/package/@perspective-dev/client)
381 /// library from NPM (see [`perspective_js`] documentation for details).
382 ///
383 /// # JavaScript Examples
384 ///
385 /// ```javascript
386 /// import perspective from "@perspective-dev/client";
387 ///
388 /// const worker = await perspective.worker();
389 /// viewer.load(worker);
390 /// ```
391 ///
392 /// ... or
393 ///
394 /// ```javascript
395 /// const table = await worker.table(data, {name: "superstore"});
396 /// viewer.load(table);
397 /// ```
398 ///
399 /// Complete example:
400 ///
401 /// ```javascript
402 /// const viewer = document.createElement("perspective-viewer");
403 /// const worker = await perspective.worker();
404 ///
405 /// await worker.table("x\n1", {name: "table_one"});
406 /// await viewer.load(worker);
407 /// await viewer.restore({table: "table_one", columns: ["x"]});
408 /// ```
409 ///
410 /// ... or, if you don't want to pass your own arguments to `restore`:
411 ///
412 /// ```javascript
413 /// const viewer = document.createElement("perspective-viewer");
414 /// const worker = await perspective.worker();
415 ///
416 /// const table = await worker.table("x\n1", {name: "table_one"});
417 /// await viewer.load(table);
418 /// ```
419 pub fn load(&self, client: JsClientLoad) -> ApiResult<ApiFuture<()>> {
420 let effect = self.workspace.effects().guard();
421 let table: JsValue = client.into();
422 let promise = table
423 .clone()
424 .dyn_into::<js_sys::Promise>()
425 .unwrap_or_else(|_| js_sys::Promise::resolve(&table));
426
427 // Resolve the target panel. On an EMPTY element (zero panels):
428 // - a synchronously-detectable `Client` registers inertly with NO panel (the
429 // common `load(client)` — no phantom panel is left behind);
430 // - otherwise (a resolved `Table`, or a `Promise` whose type isn't yet known)
431 // the first panel is RESERVED synchronously here — a full panel model held
432 // in the workspace's reservation slot, NOT placed — so its ordering position
433 // is fixed at the call site: a `restore()` fired right after an unawaited
434 // `load()` CLAIMS the reservation (placing it) and targets THIS panel, not a
435 // second one. The reservation is likewise placed when the payload proves to
436 // be a `Table` (or the load fails, surfacing its error), and discarded —
437 // only while still unclaimed — for an inert `Client` payload. Placement and
438 // discard are both atomic slot transfers (`Workspace::claim_reserved` /
439 // `Workspace::take_reserved`), so an inert payload disposing a panel a
440 // racing `restore` claimed is unrepresentable.
441 // A pre-existing active panel is used as-is (a `Client` registers
442 // inertly against it, never clearing its table).
443 let (panel, notify) = match self.workspace.active_panel() {
444 Some(panel) => (panel, None),
445 None => {
446 // Empty element — classify the payload synchronously where possible.
447 if let Ok(Some(client)) =
448 try_from_js_option::<perspective_js::Client>(table.clone())
449 {
450 // A resolved `Client` registers SYNCHRONOUSLY (so an unawaited
451 // `load(client)` is visible to a `restore()` fired right after,
452 // which creates the first panel and federates against loaded
453 // clients) and creates NO panel — inert.
454 self.workspace
455 .set_default_client(client.get_client().clone());
456 return Ok(ApiFuture::new(async { Ok(()) }));
457 }
458
459 // A resolved `Table` (or a `Promise` whose type isn't yet known)
460 // adopts the pending reservation (a second `load()` on a
461 // still-empty element), else reserves a fresh panel model.
462 let panel = self.workspace.reserved_panel().unwrap_or_else(|| {
463 create_panel_model(
464 &self.elem,
465 &self.presentation,
466 &self.workspace,
467 None,
468 ViewerConfigUpdate::default(),
469 None,
470 Placement::Reserved,
471 );
472 self.workspace
473 .reserved_panel()
474 .expect("just-reserved panel is present")
475 });
476
477 // Carrying `Some(notify)` marks this load as the reservation's
478 // owner — the only call that may place or discard it below.
479 (panel, Some(self.layout_changed_notify()))
480 },
481 };
482
483 // A `Table` payload targets this panel's engines; a `Client` registers
484 // inertly against it. Selecting the panel here (not at construction)
485 // keeps the registry race safe — by `load()` time real plugins have
486 // registered.
487 let session = panel.session;
488 let renderer = panel.renderer;
489
490 // Open the pending-load window SYNCHRONOUSLY, at the call site — this
491 // is what fixes the ordering. The payload's RESET disposition (a
492 // `Table` resets the view; a `Client` does not) is unknown until the
493 // promise resolves, but the window's POSITION on the config-commit
494 // stream is fixed NOW. A `restore()` a caller fires immediately after
495 // this unawaited `load()` (the React prop-binding pattern, which has
496 // no async ordering guarantees) commits INTO this window's journal and
497 // is replayed over the reset base if the payload proves to be a
498 // `Table` — so a moved-async reset can no longer clobber a later
499 // commit. See `SESSION_CONFIG_COHERENCE_PLAN.md`.
500 let generation = session.begin_pending_load();
501
502 clone!(self.workspace, self.presentation);
503 Ok(ApiFuture::new_throttled(async move {
504 let _effect = effect;
505 renderer.set_throttle(None);
506 let _run_token = session.begin_config_run();
507 let result = {
508 clone!(session, renderer, workspace, notify);
509 renderer
510 .clone()
511 .render_task(|guard| async move {
512 renderer.set_default_theme(presentation.get_default_theme_name().await);
513 renderer.stamp_theme(None);
514 let jstable = JsFuture::from(promise)
515 .await
516 .map_err(|x| apierror!(TableError(x)))?;
517
518 if let Ok(Some(table)) =
519 try_from_js_option::<perspective_js::Table>(jstable.clone())
520 {
521 tracing::warn!("{}", DEPRECATED_TABLE_MESSAGE);
522 let Some(journal) = session.take_pending_load(generation) else {
523 return Ok(None);
524 };
525
526 if let Some(notify) = ¬ify {
527 place_reserved(&workspace, notify);
528 }
529
530 let _plugin = renderer.ensure_plugin_selected()?;
531 let _ = renderer.mount_active_plugin();
532 session
533 .reset(ResetOptions {
534 config: true,
535 expressions: true,
536 stats: true,
537 table: Some(session::TableIntermediateState::Reloaded),
538 })
539 .await
540 .unwrap_or_log();
541
542 let client = table.get_client().await;
543 let inner_client = client.get_client().clone();
544 session.set_client(inner_client.clone());
545 workspace.set_default_client(inner_client);
546 let name = table.get_name().await;
547 tracing::debug!(
548 "Loading {:.0} rows from `Table` {}",
549 table.size().await?,
550 name
551 );
552
553 session.set_table(name).await?;
554 for delta in journal {
555 session.commit_view_config(delta)?;
556 }
557
558 session.commit_table_defaults();
559 let (disposition, _pin) =
560 crate::tasks::bind_snapshot(&guard, &session, &renderer).await?;
561
562 crate::tasks::dispatch_bound(
563 &guard,
564 &renderer,
565 disposition,
566 false,
567 crate::tasks::RunOrigin::Public,
568 )
569 .await?;
570
571 Ok(None)
572 } else if let Ok(Some(client)) = wasm_bindgen_derive::try_from_js_option::<
573 perspective_js::Client,
574 >(jstable)
575 {
576 // INERT: register the client only — never rebind or
577 // reset the active panel (its table is preserved).
578 // Panels bind their client lazily at table-resolution
579 // time (`Workspace::resolve_client_for_table`). The
580 // window is discarded (not replayed): a `Client`
581 // performs no reset, and any racing `restore`'s
582 // commits already applied live (`commit_view_config`).
583 let owned_window = session.take_pending_load(generation).is_some();
584 let discard = if owned_window && notify.is_some() {
585 workspace.take_reserved()
586 } else {
587 None
588 };
589
590 workspace.set_default_client(client.get_client().clone());
591 Ok(discard)
592 } else {
593 session.take_pending_load(generation);
594 Err(ApiError::new("Invalid argument"))
595 }
596 })
597 .await
598 };
599
600 match result {
601 Err(e) => {
602 session.take_pending_load(generation);
603 if let Some(notify) = ¬ify {
604 place_reserved(&workspace, notify);
605 }
606
607 session.set_error(false, e.clone()).await?;
608 Err(e)
609 },
610 Ok(Some(panel)) => eject_panel(panel).await,
611 Ok(None) => Ok(()),
612 }
613 }))
614 }
615
616 /// Delete all internal [`View`]s and all associated state, rendering this
617 /// `<perspective-viewer>` unusable and freeing all associated resources.
618 /// Does not delete any supplied [`Table`] (as this is constructed by the
619 /// callee).
620 ///
621 /// Calling _any_ method on a `<perspective-viewer>` after [`Self::delete`]
622 /// will throw.
623 ///
624 /// <div class="warning">
625 ///
626 /// Allowing a `<perspective-viewer>` to be garbage-collected
627 /// without calling [`PerspectiveViewerElement::delete`] will leak WASM
628 /// memory!
629 ///
630 /// </div>
631 ///
632 /// # JavaScript Examples
633 ///
634 /// ```javascript
635 /// await viewer.delete();
636 /// ```
637 pub fn delete(self) -> ApiFuture<()> {
638 let subs = std::mem::take(&mut *self.hosted_table_subs.borrow_mut());
639 let teardown = delete_all(&self.workspace, &self.root);
640 ApiFuture::new(async move {
641 for (client, id) in subs {
642 let _ = client.remove_hosted_tables_update(id).await;
643 }
644
645 teardown.await
646 })
647 }
648
649 /// Remove a [`Client`] from this `<perspective-viewer>` and dispose every
650 /// panel bound to it (each panel's `View` is deleted and its `Table`
651 /// reference released).
652 ///
653 /// # Arguments
654 ///
655 /// - `options` - An optional `{client?: string}` dict naming the client to
656 /// eject; the active panel's client when omitted.
657 ///
658 /// # JavaScript Examples
659 ///
660 /// ```javascript
661 /// await viewer.eject();
662 /// await viewer.eject({client: "remote"});
663 /// ```
664 pub fn eject(&mut self, options: Option<JsClientOptions>) -> ApiFuture<()> {
665 let ClientOptions { client } = parse_options(options);
666 // Default target: the active panel's client, or — when the active panel
667 // is unbound (`load(Client)` is now inert) — the default client.
668 let Some(target) = client
669 .or_else(|| {
670 self.workspace
671 .active_client()
672 .map(|c| c.get_name().to_owned())
673 })
674 .or_else(|| {
675 self.workspace
676 .default_client()
677 .map(|c| c.get_name().to_owned())
678 })
679 else {
680 return ApiFuture::new_throttled(async move { Ok(()) });
681 };
682
683 let ids = self.workspace.panels_for_client(&target);
684
685 // The target client backs EVERY panel — reset the element to its
686 // pre-`load` state (dropping the client with it), as a `Workspace`
687 // must always keep at least one panel.
688 if !ids.is_empty() && ids.len() == self.workspace.panel_ids().len() {
689 let mut state = Self::new_from_shadow(
690 self.elem.clone(),
691 self.elem.shadow_root().unwrap().unchecked_into(),
692 );
693
694 std::mem::swap(self, &mut state);
695 return ApiFuture::new_throttled(state.delete());
696 }
697
698 eject_client_panels(&self.workspace, &self.root, target, ids)
699 }
700
701 /// Get the underlying [`View`] for this viewer.
702 ///
703 /// Use this method to get promgrammatic access to the [`View`] as currently
704 /// configured by the user, for e.g. serializing as an
705 /// [Apache Arrow](https://arrow.apache.org/) before passing to another
706 /// library.
707 ///
708 /// The [`View`] returned by this method is owned by the
709 /// [`PerspectiveViewerElement`] and may be _invalidated_ by
710 /// [`View::delete`] at any time. Plugins which rely on this [`View`] for
711 /// their [`HTMLPerspectiveViewerPluginElement::draw`] implementations
712 /// should treat this condition as a _cancellation_ by silently aborting on
713 /// "View already deleted" errors from method calls.
714 ///
715 /// # JavaScript Examples
716 ///
717 /// ```javascript
718 /// const view = await viewer.getView();
719 /// ```
720 #[wasm_bindgen]
721 pub fn getView(&self, options: Option<JsPanelOptions>) -> ApiFuture<View> {
722 let PanelOptions { panel: name } = parse_options(options);
723 let this = self.clone();
724 ApiFuture::new(async move {
725 let panel = this.resolve_panel(name)?;
726 Ok(panel.session.get_view().ok_or("No table set")?.into())
727 })
728 }
729
730 /// Get a copy of the [`ViewConfig`] for the current [`View`]. This is
731 /// non-blocking as it does not need to access the plugin (unlike
732 /// [`PerspectiveViewerElement::save`]), and also makes no API calls to the
733 /// server (unlike [`PerspectiveViewerElement::getView`] followed by
734 /// [`View::get_config`])
735 #[wasm_bindgen]
736 pub fn getViewConfig(&self, options: Option<JsPanelOptions>) -> ApiFuture<JsViewConfig> {
737 let PanelOptions { panel: name } = parse_options(options);
738 let this = self.clone();
739 ApiFuture::new(async move {
740 let panel = this.resolve_panel(name)?;
741 let config = if let Some(ctx) = panel.renderer.render_context() {
742 (*ctx.view_config).clone()
743 } else if let Some(rendered) = panel.session.get_rendered_view_config() {
744 (*rendered).clone()
745 } else {
746 panel.session.get_view_config().clone()
747 };
748
749 Ok(JsValue::from_serde_ext(&config)?.unchecked_into())
750 })
751 }
752
753 /// Get the underlying [`Table`] for this viewer (as passed to
754 /// [`PerspectiveViewerElement::load`] or as the `table` field to
755 /// [`PerspectiveViewerElement::restore`]).
756 ///
757 /// # Arguments
758 ///
759 /// - `wait_for_table` - whether to wait for
760 /// [`PerspectiveViewerElement::load`] to be called, or fail immediately
761 /// if [`PerspectiveViewerElement::load`] has not yet been called.
762 ///
763 /// # JavaScript Examples
764 ///
765 /// ```javascript
766 /// const table = await viewer.getTable();
767 /// ```
768 #[wasm_bindgen]
769 pub fn getTable(&self, options: Option<JsGetTableOptions>) -> ApiFuture<Table> {
770 let GetTableOptions {
771 wait: wait_for_table,
772 panel: name,
773 } = parse_options(options);
774 let this = self.clone();
775 ApiFuture::new(async move {
776 let panel = this.resolve_panel(name)?;
777 if !wait_for_table.unwrap_or_default()
778 && let Some(ctx) = panel.renderer.render_context()
779 {
780 return Ok(ctx.table.clone().into());
781 }
782
783 let session = panel.session;
784 match session.get_table() {
785 Some(table) => Ok(table.into()),
786 None if !wait_for_table.unwrap_or_default() => Err("No `Table` set".into()),
787 None => {
788 session.table_loaded.read_next().await?;
789 Ok(session.get_table().ok_or("No `Table` set")?.into())
790 },
791 }
792 })
793 }
794
795 /// Get the underlying [`Client`] for this viewer (as passed to, or
796 /// associated with the [`Table`] passed to,
797 /// [`PerspectiveViewerElement::load`]).
798 ///
799 /// # Arguments
800 ///
801 /// - `wait_for_client` - whether to wait for
802 /// [`PerspectiveViewerElement::load`] to be called, or fail immediately
803 /// if [`PerspectiveViewerElement::load`] has not yet been called.
804 ///
805 /// # JavaScript Examples
806 ///
807 /// ```javascript
808 /// const client = await viewer.getClient();
809 /// ```
810 #[wasm_bindgen]
811 pub fn getClient(
812 &self,
813 options: Option<JsGetClientOptions>,
814 ) -> ApiFuture<perspective_js::Client> {
815 let GetClientOptions {
816 wait: wait_for_client,
817 panel: name,
818 } = parse_options(options);
819 let this = self.clone();
820 ApiFuture::new(async move {
821 let panel = this.resolve_panel(name)?;
822 if !wait_for_client.unwrap_or_default()
823 && let Some(ctx) = panel.renderer.render_context()
824 {
825 return Ok(ctx.client.clone().into());
826 }
827
828 let session = panel.session;
829 match session.get_client() {
830 Some(client) => Ok(client.into()),
831 None if !wait_for_client.unwrap_or_default() => Err("No `Client` set".into()),
832 None => {
833 session.table_loaded.read_next().await?;
834 Ok(session.get_client().ok_or("No `Client` set")?.into())
835 },
836 }
837 })
838 }
839
840 /// Get render statistics. Some fields of the returned stats object are
841 /// relative to the last time [`PerspectiveViewerElement::getRenderStats`]
842 /// was called, ergo calling this method resets these fields.
843 ///
844 /// # JavaScript Examples
845 ///
846 /// ```javascript
847 /// const {virtual_fps, actual_fps} = await viewer.getRenderStats();
848 /// ```
849 #[wasm_bindgen]
850 pub fn getRenderStats(&self, options: Option<JsPanelOptions>) -> ApiResult<JsValue> {
851 let PanelOptions { panel: name } = parse_options(options);
852 let panel = self.resolve_panel(name)?;
853 Ok(JsValue::from_serde_ext(
854 &panel.renderer.render_timer().get_stats(),
855 )?)
856 }
857
858 /// Flush any pending modifications to this `<perspective-viewer>`. Since
859 /// `<perspective-viewer>`'s API is almost entirely `async`, it may take
860 /// some milliseconds before any user-initiated changes to the [`View`]
861 /// affects the rendered element. If you want to make sure all pending
862 /// actions have been rendered, call and await [`Self::flush`].
863 ///
864 /// [`Self::flush`] will resolve immediately if there is no [`Table`] set.
865 ///
866 /// # JavaScript Examples
867 ///
868 /// In this example, [`Self::restore`] is called without `await`, but the
869 /// eventual render which results from this call can still be awaited by
870 /// immediately awaiting [`Self::flush`] instead.
871 ///
872 /// ```javascript
873 /// viewer.restore(config);
874 /// await viewer.flush();
875 /// ```
876 pub fn flush(&self) -> ApiFuture<()> {
877 let workspace = self.workspace.clone();
878 let presentation = self.presentation.clone();
879 ApiFuture::new_throttled(async move {
880 loop {
881 workspace.effects().settle().await;
882 let panels = workspace
883 .reserved_panel()
884 .into_iter()
885 .chain(workspace.panels())
886 .collect::<Vec<_>>();
887
888 let mut fulfilled = false;
889 for panel in &panels {
890 panel.renderer.clone().with_lock(async { Ok(()) }).await?;
891 panel.renderer.clone().with_lock(async { Ok(()) }).await?;
892 panel.session.settle_dispatches().await?;
893 if !global::document().hidden()
894 && presentation.is_visible()
895 && !panel.renderer.is_plugin_activated()?
896 && panel.session.get_error().is_none()
897 && matches!(panel.session.has_table(), Some(TableLoadState::Loaded))
898 {
899 set_panel_paused(&panel.session, &panel.renderer, &presentation, true)
900 .await?;
901 if !panel.renderer.is_plugin_activated()? {
902 just_render(&panel.session, &panel.renderer)?.await?;
903 }
904
905 fulfilled = true;
906 }
907 }
908
909 if !fulfilled && workspace.effects().is_empty() {
910 return Ok(());
911 }
912 }
913 })
914 }
915
916 /// Restore a single panel from a full/partial
917 /// [`perspective_js::JsViewConfig`] (its user-configurable state, including
918 /// the `Table` name) — the active panel, or a specific panel via the
919 /// optional `{panel}` selector.
920 ///
921 /// If `panel` names no existing panel, a NEW panel is created with that id
922 /// and the config restored into it (an upsert), equivalent to
923 /// [`Self::addPanel`] but with a caller-chosen id. As with a created panel,
924 /// the element-level `settings`/`theme` fields are ignored in that case.
925 ///
926 /// On an empty element with a pending [`Self::load`] whose payload is not
927 /// yet classified, the active-target form (no `panel`) instead claims and
928 /// restores into that load's reserved first panel — see [`Self::load`].
929 ///
930 /// This restores a SINGLE panel; a whole-element config (with a `panels`
931 /// map) must be applied via [`Self::restoreWorkspace`] — its `panels` /
932 /// `layout` keys are ignored here.
933 ///
934 /// One of the best ways to use [`Self::restore`] is by first configuring
935 /// a `<perspective-viewer>` as you wish, then using either the `Debug`
936 /// panel or "Copy" -> "config.json" from the toolbar menu to snapshot
937 /// the [`Self::restore`] argument as JSON.
938 ///
939 /// # Arguments
940 ///
941 /// - `update` - The config to restore to, as returned by [`Self::save`] in
942 /// either "json", "string" or "arraybuffer" format.
943 /// - `name` - The panel to target, or `None` for the active panel.
944 ///
945 /// # JavaScript Examples
946 ///
947 /// Loads a default plugin for the table named `"superstore"`:
948 ///
949 /// ```javascript
950 /// await viewer.restore({table: "superstore"});
951 /// ```
952 ///
953 /// Apply a `group_by` to the same `viewer` element, without
954 /// modifying/resetting other fields - you can omit the `table` field,
955 /// this has already been set once and is not modified:
956 ///
957 /// ```javascript
958 /// await viewer.restore({group_by: ["State"]});
959 /// ```
960 pub fn restore(
961 &self,
962 update: JsViewerConfigUpdate,
963 options: Option<JsPanelOptions>,
964 ) -> JsVoidPromise {
965 let PanelOptions { panel: name } = parse_options(options);
966 let effect = self.workspace.effects().guard();
967 let this = self.clone();
968 let fut = ApiFuture::new_throttled(async move {
969 let _effect = effect;
970 let id = name.map(PanelId::from);
971 let update = ViewerConfigUpdate::decode(&update)?;
972 match this.workspace.panel_or_active(id.as_ref()) {
973 // An existing (or the active) panel — update it in place.
974 Some(panel) => {
975 let active = this.workspace.active_id().as_ref() == Some(&panel.id);
976 restore_panel(
977 &panel.session,
978 &panel.renderer,
979 &this.presentation,
980 &this.workspace,
981 Some(&this.root),
982 RestoreMode::Existing { active },
983 update,
984 )
985 .await
986 },
987 // No existing panel matched. The active-target form
988 // (`panel: None`) CLAIMS a pending `load()`'s reserved panel
989 // — placing it and restoring into it — so a `restore` fired
990 // right after an unawaited `load(promise)` configures the
991 // panel that load's payload will bind, per the call-site
992 // ordering contract in [`Self::load`]. Named upserts and
993 // reservation-less elements create a fresh panel instead,
994 // routing through the shared `create_panel`
995 // (`RestoreMode::Fresh`) pipeline so the new panel's id is
996 // the requested `panel`.
997 None => {
998 let notify = this.layout_changed_notify();
999 let claimed = id
1000 .is_none()
1001 .then(|| place_reserved(&this.workspace, ¬ify))
1002 .flatten();
1003 match claimed {
1004 Some(panel) => {
1005 restore_panel(
1006 &panel.session,
1007 &panel.renderer,
1008 &this.presentation,
1009 &this.workspace,
1010 Some(&this.root),
1011 RestoreMode::Existing { active: true },
1012 update,
1013 )
1014 .await
1015 },
1016 None => {
1017 create_panel(
1018 &this.elem,
1019 &this.presentation,
1020 &this.workspace,
1021 ¬ify,
1022 id,
1023 update,
1024 None,
1025 )
1026 .await?;
1027 Ok(())
1028 },
1029 }
1030 },
1031 }
1032 });
1033
1034 js_sys::Promise::from(fut).unchecked_into()
1035 }
1036
1037 /// Restore the ENTIRE element from a whole-element
1038 /// [`WorkspaceConfigUpdate`] (`{version, active?, layout, panels, ...}`) —
1039 /// the multi-panel counterpart of [`Self::restore`]. Every existing panel
1040 /// is replaced by the `panels` entries, and the layout tree + master/detail
1041 /// cross-filter state re-applied. Unlike [`Self::restore`], this never
1042 /// falls back to the single-panel path.
1043 ///
1044 /// # JavaScript Examples
1045 ///
1046 /// ```javascript
1047 /// await viewer.restoreWorkspace(await otherViewer.saveWorkspace());
1048 /// ```
1049 pub fn restoreWorkspace(&self, update: JsWorkspaceConfigUpdate) -> JsVoidPromise {
1050 let update: JsViewerConfigUpdate = update.unchecked_into();
1051 let effect = self.workspace.effects().guard();
1052 let this = self.clone();
1053 let fut = ApiFuture::new(async move {
1054 let _effect = effect;
1055 let (contents, eject_tasks) = sync_update_panels(&this, update)?;
1056 let results = join_all(contents.into_iter().map(|(id, session, renderer, config)| {
1057 let presentation = this.presentation.clone();
1058 let workspace = this.workspace.clone();
1059 async move {
1060 stamp_global_overlay(&workspace, &id, &session);
1061 restore_panel(
1062 &session,
1063 &renderer,
1064 &presentation,
1065 &workspace,
1066 None,
1067 RestoreMode::Fresh,
1068 config,
1069 )
1070 .await?;
1071 if workspace.is_master(&id) {
1072 set_edit_mode(&session, &renderer, "SELECT_ROW_TREE");
1073 }
1074
1075 Ok(())
1076 }
1077 }))
1078 .await;
1079
1080 results.into_iter().collect::<ApiResult<Vec<_>>>()?;
1081 join_all(eject_tasks)
1082 .await
1083 .into_iter()
1084 .collect::<ApiResult<Vec<_>>>()?;
1085
1086 Ok(())
1087 });
1088
1089 js_sys::Promise::from(fut).unchecked_into()
1090 }
1091
1092 /// If this element is in an _errored_ state, this method will clear it and
1093 /// re-render. Calling this method is equivalent to clicking the error reset
1094 /// button in the UI.
1095 pub fn resetError(&self) -> ApiFuture<()> {
1096 let Some(panel) = self.workspace.active_panel() else {
1097 return ApiFuture::new_throttled(async move { Ok(()) });
1098 };
1099
1100 let reset_effect = self.workspace.effects().guard();
1101 let reset_task = panel.session.reset(ResetOptions::default());
1102 ApiFuture::spawn(async move {
1103 let _effect = reset_effect;
1104 reset_task.await
1105 });
1106
1107 let effect = self.workspace.effects().guard();
1108 ApiFuture::new_throttled(async move {
1109 let _effect = effect;
1110 apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?.await?;
1111 Ok(())
1112 })
1113 }
1114
1115 /// Save a single panel's user-configurable state as a [`ViewerConfig`], one
1116 /// which can be restored via [`Self::restore`] — the active panel, or a
1117 /// specific panel via the optional `{panel}` selector.
1118 ///
1119 /// This saves a SINGLE panel; to snapshot the ENTIRE element (every panel +
1120 /// layout + cross-filters) use [`Self::saveWorkspace`].
1121 ///
1122 /// # Arguments
1123 ///
1124 /// - `options` - An optional `{panel?: string}`; the panel to save, or the
1125 /// active panel when omitted.
1126 ///
1127 /// # JavaScript Examples
1128 ///
1129 /// Get the current `group_by` setting:
1130 ///
1131 /// ```javascript
1132 /// const {group_by} = await viewer.save();
1133 /// ```
1134 ///
1135 /// Reset workflow attached to an external button `myResetButton`:
1136 ///
1137 /// ```javascript
1138 /// const token = await viewer.save();
1139 /// myResetButton.addEventListener("click", async () => {
1140 /// await viewer.restore(token);
1141 /// });
1142 /// ```
1143 pub fn save(&self, options: Option<JsPanelOptions>) -> JsViewerConfigPromise {
1144 let PanelOptions { panel: name } = parse_options(options);
1145 let this = self.clone();
1146 let fut = ApiFuture::new(async move {
1147 let panel = this.resolve_panel(name)?;
1148 let viewer_config = panel
1149 .renderer
1150 .clone()
1151 .with_lock(async {
1152 get_viewer_config(&panel.session, &panel.renderer, &this.presentation).await
1153 })
1154 .await?;
1155
1156 viewer_config.encode()
1157 });
1158
1159 js_sys::Promise::from(fut).unchecked_into()
1160 }
1161
1162 /// Save the ENTIRE element to a whole-element [`WorkspaceConfig`]
1163 /// (`{version, active?, layout, panels, ...}`) — the multi-panel
1164 /// counterpart of [`Self::save`]. Unlike [`Self::save`] (which emits a
1165 /// single `ViewerConfig` for one panel), this ALWAYS emits the
1166 /// whole-element format, restorable via [`Self::restoreWorkspace`].
1167 ///
1168 /// # JavaScript Examples
1169 ///
1170 /// ```javascript
1171 /// const token = await viewer.saveWorkspace();
1172 /// await viewer.restoreWorkspace(token);
1173 /// ```
1174 pub fn saveWorkspace(&self) -> JsWorkspaceConfigPromise {
1175 let this = self.clone();
1176 let fut = ApiFuture::new(Self::workspace_config(this));
1177 js_sys::Promise::from(fut).unchecked_into()
1178 }
1179
1180 /// Download this viewer's internal [`View`] data via a browser download
1181 /// event.
1182 ///
1183 /// # Arguments
1184 ///
1185 /// - `method` - The `ExportMethod` to use to render the data to download.
1186 ///
1187 /// # JavaScript Examples
1188 ///
1189 /// ```javascript
1190 /// myDownloadButton.addEventListener("click", async () => {
1191 /// await viewer.download();
1192 /// })
1193 /// ```
1194 pub fn download(&self, options: Option<JsExportOptions>) -> ApiFuture<()> {
1195 let ExportOptions {
1196 method,
1197 panel: name,
1198 } = parse_options(options);
1199 let method = method.map(|m| JsString::from(m.as_str()));
1200 let this = self.clone();
1201 ApiFuture::new_throttled(async move {
1202 let method = if let Some(method) = method
1203 .map(|x| x.unchecked_into())
1204 .map(serde_wasm_bindgen::from_value)
1205 {
1206 method?
1207 } else {
1208 ExportMethod::Csv
1209 };
1210
1211 let panel = this.resolve_panel(name)?;
1212 let blob =
1213 export_method_to_blob(&panel.session, &panel.renderer, &this.presentation, method)
1214 .await?;
1215 let is_chart = panel.renderer.is_chart();
1216 download(
1217 format!("untitled{}", method.as_filename(is_chart)).as_ref(),
1218 &blob,
1219 )
1220 })
1221 }
1222
1223 /// Exports this viewer's internal [`View`] as a JavaSript data, the
1224 /// exact type of which depends on the `method` but defaults to `String`
1225 /// in CSV format.
1226 ///
1227 /// This method is only really useful for the `"plugin"` method, which
1228 /// will use the configured plugin's export (e.g. PNG for
1229 /// `@perspective-dev/viewer-charts`). Otherwise, prefer to call the
1230 /// equivalent method on the underlying [`View`] directly.
1231 ///
1232 /// # Arguments
1233 ///
1234 /// - `method` - The `ExportMethod` to use to render the data to download.
1235 ///
1236 /// # JavaScript Examples
1237 ///
1238 /// ```javascript
1239 /// const data = await viewer.export("plugin");
1240 /// ```
1241 pub fn export(&self, options: Option<JsExportOptions>) -> ApiFuture<JsValue> {
1242 let ExportOptions {
1243 method,
1244 panel: name,
1245 } = parse_options(options);
1246 let method = method.map(|m| JsString::from(m.as_str()));
1247 let this = self.clone();
1248 ApiFuture::new(async move {
1249 let method = if let Some(method) = method
1250 .map(|x| x.unchecked_into())
1251 .map(serde_wasm_bindgen::from_value)
1252 {
1253 method?
1254 } else {
1255 ExportMethod::Csv
1256 };
1257
1258 let panel = this.resolve_panel(name)?;
1259 export_method_to_jsvalue(&panel.session, &panel.renderer, &this.presentation, method)
1260 .await
1261 })
1262 }
1263
1264 /// Copy this viewer's `View` or `Table` data as CSV to the system
1265 /// clipboard.
1266 ///
1267 /// # Arguments
1268 ///
1269 /// - `method` - The `ExportMethod` (serialized as a `String`) to use to
1270 /// render the data to the Clipboard.
1271 ///
1272 /// # JavaScript Examples
1273 ///
1274 /// ```javascript
1275 /// myDownloadButton.addEventListener("click", async () => {
1276 /// await viewer.copy();
1277 /// })
1278 /// ```
1279 pub fn copy(&self, options: Option<JsExportOptions>) -> ApiFuture<()> {
1280 let ExportOptions {
1281 method,
1282 panel: name,
1283 } = parse_options(options);
1284 let method = method.map(|m| JsString::from(m.as_str()));
1285 let this = self.clone();
1286 ApiFuture::new_throttled(async move {
1287 let method = if let Some(method) = method
1288 .map(|x| x.unchecked_into())
1289 .map(serde_wasm_bindgen::from_value)
1290 {
1291 method?
1292 } else {
1293 ExportMethod::Csv
1294 };
1295
1296 let panel = this.resolve_panel(name)?;
1297 let js_task =
1298 export_method_to_blob(&panel.session, &panel.renderer, &this.presentation, method);
1299 copy_to_clipboard(js_task, MimeType::TextPlain).await
1300 })
1301 }
1302
1303 /// Reset a panel's `ViewerConfig` to its data-relative default.
1304 ///
1305 /// Without a `panel`, this is ELEMENT-LEVEL: EVERY panel is reset and the
1306 /// cross-filter overlay cleared (symmetric with whole-element
1307 /// [`Self::save`] / [`Self::restore`]). With `{panel}`, only that panel is
1308 /// reset — the other panels and the overlay are left untouched.
1309 ///
1310 /// # Arguments
1311 ///
1312 /// - `reset_all` - If set, will clear expressions and column settings as
1313 /// well.
1314 /// - `options` - An optional `{panel?: string}`; the panel to reset, or
1315 /// every panel when omitted.
1316 ///
1317 /// # JavaScript Examples
1318 ///
1319 /// ```javascript
1320 /// await viewer.reset(); // every panel
1321 /// await viewer.reset(true, {panel: "p1"}); // just "p1", + expressions
1322 /// ```
1323 pub fn reset(&self, reset_all: Option<bool>, options: Option<JsPanelOptions>) -> ApiFuture<()> {
1324 let PanelOptions { panel: name } = parse_options(options);
1325 let effect = self.workspace.effects().guard();
1326 let this = self.clone();
1327 let all = reset_all.unwrap_or_default();
1328 ApiFuture::new_throttled(async move {
1329 let _effect = effect;
1330 let (completion, receiver) = Completion::new();
1331 {
1332 let root = this.root.borrow();
1333 let app = root.as_ref().ok_or("Already deleted")?;
1334 match name {
1335 // Element-level: reset every panel + the cross-filter overlay.
1336 None => {
1337 tracing::debug!("Resetting config");
1338 app.send_message(PerspectiveViewerMsg::Reset(all, Some(completion)));
1339 },
1340 // A single named panel; errors if the panel doesn't exist.
1341 Some(name) => {
1342 let panel = this.resolve_panel(Some(name))?;
1343 tracing::debug!("Resetting config ({})", panel.id);
1344 app.send_message(PerspectiveViewerMsg::ResetPanel(
1345 Some(panel.id.to_string()),
1346 all,
1347 Some(completion),
1348 ));
1349 },
1350 }
1351 }
1352
1353 receiver.await.map_err(|_| ApiError::new("Cancelled"))?
1354 })
1355 }
1356
1357 /// Recalculate the viewer's dimensions and redraw.
1358 ///
1359 /// Use this method to tell `<perspective-viewer>` its dimensions have
1360 /// changed when auto-size mode has been disabled via [`Self::setAutoSize`].
1361 /// [`Self::resize`] resolves when the resize-initiated redraw of this
1362 /// element has completed.
1363 ///
1364 /// # Arguments
1365 ///
1366 /// - `options` - An optional object with the following fields:
1367 /// - `dimensions` - An optional object `{width, height}` providing
1368 /// explicit size hints (in pixels) for the plugin container. When
1369 /// provided, the plugin element will be temporarily sized to these
1370 /// dimensions during resize, then reset.
1371 ///
1372 /// # JavaScript Examples
1373 ///
1374 /// ```javascript
1375 /// await viewer.resize()
1376 /// await viewer.resize({dimensions: {width: 800, height: 600}})
1377 /// ```
1378 #[wasm_bindgen]
1379 pub fn resize(&self, options: Option<JsValue>) -> ApiFuture<()> {
1380 let opts: ResizeOptions = options
1381 .map(|v| v.into_serde_ext())
1382 .transpose()
1383 .unwrap_or_default()
1384 .unwrap_or_default();
1385
1386 let effect = self.workspace.effects().guard();
1387 let workspace = self.workspace.clone();
1388 ApiFuture::new_throttled(async move {
1389 let _effect = effect;
1390 // With zero panels there is nothing to resize; fan out to whatever
1391 // panels exist otherwise.
1392 let Some(panel) = workspace.active_panel() else {
1393 resize_visible_panels(&workspace).await;
1394 return Ok(());
1395 };
1396
1397 if !panel.renderer.is_plugin_activated()? {
1398 apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?
1399 .await?;
1400 } else if let Some(dims) = opts.dimensions {
1401 panel
1402 .renderer
1403 .resize_with_dimensions(dims.width, dims.height)
1404 .await?;
1405 } else {
1406 resize_visible_panels(&workspace).await;
1407 }
1408
1409 Ok(())
1410 })
1411 }
1412
1413 /// Sets the auto-size behavior of this component.
1414 ///
1415 /// When `true`, this `<perspective-viewer>` will register a
1416 /// `ResizeObserver` on itself and call [`Self::resize`] whenever its own
1417 /// dimensions change. However, when embedded in a larger application
1418 /// context, you may want to call [`Self::resize`] manually to avoid
1419 /// over-rendering; in this case auto-sizing can be disabled via this
1420 /// method. Auto-size behavior is enabled by default.
1421 ///
1422 /// # Arguments
1423 ///
1424 /// - `autosize` - Whether to enable `auto-size` behavior or not.
1425 ///
1426 /// # JavaScript Examples
1427 ///
1428 /// Disable auto-size behavior:
1429 ///
1430 /// ```javascript
1431 /// viewer.setAutoSize(false);
1432 /// ```
1433 #[wasm_bindgen]
1434 pub fn setAutoSize(&self, autosize: bool) {
1435 if autosize {
1436 let handle = Some(ResizeObserverHandle::new(
1437 &self.elem,
1438 &self.workspace,
1439 &self.presentation,
1440 &self.root,
1441 ));
1442 *self.resize_handle.borrow_mut() = handle;
1443 } else {
1444 *self.resize_handle.borrow_mut() = None;
1445 }
1446 }
1447
1448 /// Sets the auto-pause behavior of this component.
1449 ///
1450 /// When `true`, this `<perspective-viewer>` will skip rendering
1451 /// whenever it cannot be seen — tracked via an `IntersectionObserver`
1452 /// on itself (scrolled out of the viewport, `display: none`) combined
1453 /// with the document's page visibility (backgrounded browser tab,
1454 /// minimized window). Auto-pause is enabled by default.
1455 ///
1456 /// # Arguments
1457 ///
1458 /// - `autopause` Whether to enable `auto-pause` behavior or not.
1459 ///
1460 /// # JavaScript Examples
1461 ///
1462 /// Disable auto-size behavior:
1463 ///
1464 /// ```javascript
1465 /// viewer.setAutoPause(false);
1466 /// ```
1467 #[wasm_bindgen]
1468 pub fn setAutoPause(&self, autopause: bool) -> ApiFuture<()> {
1469 if autopause {
1470 let handle = Some(AutoPauseHandle::new(
1471 &self.elem,
1472 &self.presentation,
1473 &self.workspace,
1474 ));
1475
1476 *self.intersection_handle.borrow_mut() = handle;
1477 } else {
1478 *self.intersection_handle.borrow_mut() = None;
1479 let effect = self.workspace.effects().guard();
1480 let workspace = self.workspace.clone();
1481 let presentation = self.presentation.clone();
1482 return ApiFuture::new(async move {
1483 let _effect = effect;
1484 for id in workspace.panel_ids() {
1485 if let Some(panel) = workspace.panel(&id) {
1486 // A failed resume is already surfaced as that
1487 // panel's error state — don't let it abort the
1488 // remaining panels' resumes.
1489 let _ =
1490 set_panel_paused(&panel.session, &panel.renderer, &presentation, true)
1491 .await;
1492 }
1493 }
1494
1495 Ok(())
1496 });
1497 }
1498
1499 ApiFuture::new(async move { Ok(()) })
1500 }
1501
1502 /// Return a [`perspective_js::JsViewWindow`] for the currently selected
1503 /// region of the named panel, or the active panel when `panel` is omitted.
1504 #[wasm_bindgen]
1505 pub fn getSelection(&self, options: Option<JsPanelOptions>) -> ApiResult<Option<JsViewWindow>> {
1506 let PanelOptions { panel: name } = parse_options(options);
1507 let panel = self.resolve_panel(name)?;
1508 Ok(panel.renderer.get_selection().map(|x| x.into()))
1509 }
1510
1511 /// Set the selection [`perspective_js::JsViewWindow`] for the named panel,
1512 /// or the active panel when `panel` is omitted.
1513 #[wasm_bindgen]
1514 pub fn setSelection(
1515 &self,
1516 window: Option<JsViewWindow>,
1517 options: Option<JsPanelOptions>,
1518 ) -> ApiResult<()> {
1519 let PanelOptions { panel: name } = parse_options(options);
1520 let window = window.map(|x| x.into_serde_ext()).transpose()?;
1521 self.resolve_panel(name)?.renderer.set_selection(window);
1522 Ok(())
1523 }
1524
1525 /// Get this viewer's edit port for the named panel's [`Table`] (see
1526 /// [`Table::update`] for details on ports), or the active panel when
1527 /// `panel` is omitted.
1528 #[wasm_bindgen]
1529 pub fn getEditPort(&self, options: Option<JsPanelOptions>) -> ApiResult<f64> {
1530 let PanelOptions { panel: name } = parse_options(options);
1531 let panel = self.resolve_panel(name)?;
1532 let edit_port = if let Some(ctx) = panel.renderer.render_context() {
1533 ctx.edit_port
1534 } else {
1535 panel.session.metadata().get_edit_port()
1536 };
1537
1538 Ok(edit_port.ok_or("No `Table` loaded")?)
1539 }
1540
1541 /// Restyle all plugins from current document.
1542 ///
1543 /// <div class="warning">
1544 ///
1545 /// [`Self::restyleElement`] _must_ be called for many runtime changes to
1546 /// CSS properties to be reflected in an already-rendered
1547 /// `<perspective-viewer>`.
1548 ///
1549 /// </div>
1550 ///
1551 /// # JavaScript Examples
1552 ///
1553 /// ```javascript
1554 /// viewer.style = "--psp--color: red";
1555 /// await viewer.restyleElement();
1556 /// ```
1557 #[wasm_bindgen]
1558 pub fn restyleElement(&self) -> ApiFuture<JsValue> {
1559 clone!(self.workspace, self.presentation);
1560 let effect = workspace.effects().guard();
1561 ApiFuture::new(async move {
1562 let _effect = effect;
1563 let default = presentation.get_default_theme_name().await;
1564 for panel in workspace
1565 .panel_ids()
1566 .into_iter()
1567 .filter_map(|id| workspace.panel(&id))
1568 {
1569 panel.renderer.set_default_theme(default.clone());
1570 panel.renderer.restyle_all().await?;
1571 }
1572
1573 Ok(JsValue::UNDEFINED)
1574 })
1575 }
1576
1577 /// Set the available theme names available in the status bar UI.
1578 ///
1579 /// Calling [`Self::resetThemes`] may cause the current theme to switch,
1580 /// if e.g. the new theme set does not contain the current theme.
1581 ///
1582 /// # JavaScript Examples
1583 ///
1584 /// Restrict `<perspective-viewer>` theme options to _only_ default light
1585 /// and dark themes, regardless of what is auto-detected from the page's
1586 /// CSS:
1587 ///
1588 /// ```javascript
1589 /// viewer.resetThemes(["Pro Light", "Pro Dark"])
1590 /// ```
1591 #[wasm_bindgen]
1592 pub fn resetThemes(&self, themes: Option<Box<[JsValue]>>) -> ApiFuture<JsValue> {
1593 clone!(self.workspace, self.presentation);
1594 let effect = workspace.effects().guard();
1595 ApiFuture::new(async move {
1596 let _effect = effect;
1597 let themes: Option<Vec<String>> = themes
1598 .unwrap_or_default()
1599 .iter()
1600 .map(|x| x.as_string())
1601 .collect();
1602
1603 let theme_name = presentation.get_selected_theme_name().await;
1604 presentation.reset_available_themes(themes).await;
1605 let available = presentation.get_available_themes().await?;
1606 let reset_theme = available
1607 .iter()
1608 .find(|y| theme_name.as_ref() == Some(y))
1609 .cloned();
1610
1611 presentation.set_theme_name(reset_theme.as_deref()).await?;
1612 let new_default = presentation.get_default_theme_name().await;
1613 for panel in workspace
1614 .panel_ids()
1615 .into_iter()
1616 .filter_map(|id| workspace.panel(&id))
1617 {
1618 // Availability applies to PANELS too: a panel pinned to a
1619 // theme outside the new set follows the host — clear the pin
1620 // so it renders (and `save`s) the new registry default, the
1621 // same keep-if-available-else-default rule applied to the
1622 // host selection above.
1623 if panel
1624 .renderer
1625 .theme()
1626 .is_some_and(|t| !available.contains(&t))
1627 {
1628 panel.renderer.set_theme(None);
1629 }
1630
1631 panel.renderer.set_default_theme(new_default.clone());
1632 if panel.renderer.needs_restyle() {
1633 panel.renderer.restyle_all().await?;
1634 }
1635 }
1636
1637 Ok(JsValue::UNDEFINED)
1638 })
1639 }
1640
1641 /// Determines the render throttling behavior. Can be an integer, for
1642 /// millisecond window to throttle render event; or, if `None`, adaptive
1643 /// throttling will be calculated from the measured render time of the
1644 /// last 5 frames.
1645 ///
1646 /// # Arguments
1647 ///
1648 /// - `throttle` - The throttle rate in milliseconds (f64), or `None` for
1649 /// adaptive throttling.
1650 ///
1651 /// # JavaScript Examples
1652 ///
1653 /// Only draws at most 1 frame/sec:
1654 ///
1655 /// ```rust
1656 /// viewer.setThrottle(1000);
1657 /// ```
1658 #[wasm_bindgen]
1659 pub fn setThrottle(&self, val: Option<f64>) {
1660 for panel in self
1661 .workspace
1662 .panel_ids()
1663 .into_iter()
1664 .filter_map(|id| self.workspace.panel(&id))
1665 {
1666 panel.renderer.set_throttle(val);
1667 }
1668 }
1669
1670 /// Toggle (or force) the config panel open/closed.
1671 ///
1672 /// # Arguments
1673 ///
1674 /// - `force` - Force the state of the panel open or closed, or `None` to
1675 /// toggle.
1676 ///
1677 /// # JavaScript Examples
1678 ///
1679 /// ```javascript
1680 /// await viewer.toggleConfig();
1681 /// ```
1682 #[wasm_bindgen]
1683 pub fn toggleConfig(&self, force: Option<bool>) -> ApiFuture<JsValue> {
1684 let effect = self.workspace.effects().guard();
1685 let root = self.root.clone();
1686 ApiFuture::new(async move {
1687 let _effect = effect;
1688 let force = force.map(SettingsUpdate::Update);
1689 let (sender, receiver) = channel::<ApiResult<wasm_bindgen::JsValue>>();
1690 root.borrow().as_ref().into_apierror()?.send_message(
1691 PerspectiveViewerMsg::ToggleSettingsInit(force, Some(sender)),
1692 );
1693
1694 receiver.await.map_err(|_| JsValue::from("Cancelled"))?
1695 })
1696 }
1697
1698 /// Get an `Array` of all of the plugin custom elements registered for this
1699 /// element. This may not include plugins which called
1700 /// [`registerPlugin`] after the host has rendered for the first time.
1701 #[wasm_bindgen]
1702 pub fn getAllPlugins(&self) -> Array {
1703 self.workspace
1704 .active_renderer()
1705 .map(|r| r.get_all_plugins().iter().collect::<Array>())
1706 .unwrap_or_default()
1707 }
1708
1709 /// Gets a plugin Custom Element with the `name` field, or get the active
1710 /// plugin if no `name` is provided.
1711 ///
1712 /// # Arguments
1713 ///
1714 /// - `name` - The `name` property of a perspective plugin Custom Element,
1715 /// or `None` for the active plugin's Custom Element.
1716 #[wasm_bindgen]
1717 pub fn getPlugin(&self, name: Option<String>) -> ApiResult<JsPerspectiveViewerPlugin> {
1718 let renderer = self
1719 .workspace
1720 .active_renderer()
1721 .ok_or_else(|| ApiError::new("No active panel"))?;
1722 match name {
1723 None => renderer.ensure_plugin_selected(),
1724 Some(name) => renderer.get_plugin(&name),
1725 }
1726 }
1727
1728 /// Add a new, independent panel to this viewer's layout, rendering the
1729 /// supplied [`ViewerConfigUpdate`] into it. The panel uses the default
1730 /// [`perspective_client::Client`] (the first passed to [`Self::load`]) to
1731 /// resolve its `table`. Returns the generated panel id.
1732 ///
1733 /// Element-level config fields (`settings`, `theme`) in the argument are
1734 /// ignored — those are shared across the element, not per-panel.
1735 #[wasm_bindgen]
1736 pub fn addPanel(&self, update: JsViewerConfigUpdate) -> ApiFuture<JsValue> {
1737 clone!(self.elem, self.presentation, self.workspace);
1738 let effect = workspace.effects().guard();
1739 let notify = self.layout_changed_notify();
1740 ApiFuture::new(async move {
1741 let _effect = effect;
1742 let update = ViewerConfigUpdate::decode(&update)?;
1743 let id = create_panel(
1744 &elem,
1745 &presentation,
1746 &workspace,
1747 ¬ify,
1748 None,
1749 update,
1750 None,
1751 )
1752 .await?;
1753 Ok(JsValue::from_str(id.as_str()))
1754 })
1755 }
1756
1757 /// Get the ids of all panels in this viewer's layout, in insertion order.
1758 #[wasm_bindgen]
1759 pub fn getPanelNames(&self) -> Array {
1760 self.workspace
1761 .panel_ids()
1762 .iter()
1763 .map(|id| JsValue::from_str(id.as_str()))
1764 .collect()
1765 }
1766
1767 /// The id of the active panel — the one the settings panel and status-bar
1768 /// toolbar target — or `null` when the element has zero panels.
1769 #[wasm_bindgen]
1770 pub fn getActivePanel(&self) -> JsValue {
1771 self.workspace
1772 .active_id()
1773 .map(|id| JsValue::from_str(id.as_str()))
1774 .unwrap_or(JsValue::NULL)
1775 }
1776
1777 /// Make the panel with id `name` the active panel, re-targeting the
1778 /// settings panel and status-bar toolbar (and the root's
1779 /// session/renderer subscriptions) to its engines. Resolves after the
1780 /// activation-chrome redraws on both sides of the switch have completed
1781 /// (invariant I6).
1782 #[wasm_bindgen]
1783 pub fn setActivePanel(&self, name: String) -> ApiFuture<()> {
1784 let effect = self.workspace.effects().guard();
1785 let root = self.root.clone();
1786 ApiFuture::new(async move {
1787 let _effect = effect;
1788 let (completion, receiver) = Completion::new();
1789 root.borrow()
1790 .as_ref()
1791 .into_apierror()?
1792 .send_message(PerspectiveViewerMsg::SetActivePanel(name, Some(completion)));
1793
1794 receiver.await.map_err(|_| ApiError::new("Cancelled"))?
1795 })
1796 }
1797
1798 /// Remove the panel with id `name` from the layout, disposing its engines
1799 /// (its `View` is deleted and its `Table` reference released). The last
1800 /// remaining panel cannot be removed (resolves as a no-op). Resolves
1801 /// after the panel's teardown run completes, carrying any teardown
1802 /// error — previously fire-and-forget and silently dropped (invariant
1803 /// I6). See also [`Self::addPanel`].
1804 #[wasm_bindgen]
1805 pub fn removePanel(&self, name: String) -> ApiFuture<()> {
1806 let effect = self.workspace.effects().guard();
1807 let root = self.root.clone();
1808 ApiFuture::new(async move {
1809 let _effect = effect;
1810 let (completion, receiver) = Completion::new();
1811 root.borrow()
1812 .as_ref()
1813 .into_apierror()?
1814 .send_message(PerspectiveViewerMsg::ClosePanel(name, Some(completion)));
1815
1816 receiver.await.map_err(|_| ApiError::new("Cancelled"))?
1817 })
1818 }
1819
1820 /// Create a new JavaScript Heap reference for this model instance.
1821 #[doc(hidden)]
1822 #[allow(clippy::use_self)]
1823 #[wasm_bindgen]
1824 pub fn __get_model(&self) -> PerspectiveViewerElement {
1825 self.clone()
1826 }
1827
1828 /// Asynchronously opens the column settings for a specific column.
1829 /// When finished, the `<perspective-viewer>` element will emit a
1830 /// "perspective-toggle-column-settings" CustomEvent.
1831 /// The event's details property has two fields: `{open: bool, column_name?:
1832 /// string}`. The CustomEvent is also fired whenever the user toggles the
1833 /// sidebar manually.
1834 #[wasm_bindgen]
1835 pub fn toggleColumnSettings(
1836 &self,
1837 column_name: String,
1838 options: Option<JsPanelOptions>,
1839 ) -> ApiFuture<()> {
1840 let PanelOptions { panel: name } = parse_options(options);
1841 let effect = self.workspace.effects().guard();
1842 let this = self.clone();
1843 ApiFuture::new_throttled(async move {
1844 let _effect = effect;
1845 let panel = this.resolve_panel(name)?;
1846 let was_active = this.workspace.active_id().as_ref() == Some(&panel.id);
1847 let locator = get_column_locator(&panel.session.metadata(), Some(column_name));
1848 if !was_active {
1849 this.root.borrow().as_ref().into_apierror()?.send_message(
1850 PerspectiveViewerMsg::SetActivePanel(panel.id.as_str().to_owned(), None),
1851 );
1852 }
1853
1854 let (sender, receiver) = channel::<()>();
1855 this.root.borrow().as_ref().into_apierror()?.send_message(
1856 PerspectiveViewerMsg::OpenColumnSettings {
1857 locator,
1858 sender: Some(sender),
1859 toggle: was_active,
1860 },
1861 );
1862
1863 receiver.await.map_err(|_| ApiError::from("Cancelled"))
1864 })
1865 }
1866}