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