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