Skip to main content

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) = &notify {
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) = &notify {
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, &notify))
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                                &notify,
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                &notify,
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}