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