Skip to main content

perspective_viewer/custom_elements/
viewer.rs

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