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 js_sys::{Array, JsString};
20use perspective_client::config::ViewConfigUpdate;
21use perspective_client::utils::PerspectiveResultExt;
22use perspective_js::{JsViewConfig, JsViewWindow, Table, View, apierror};
23use wasm_bindgen::JsCast;
24use wasm_bindgen::prelude::*;
25use wasm_bindgen_derive::try_from_js_option;
26use wasm_bindgen_futures::JsFuture;
27use web_sys::HtmlElement;
28
29use crate::components::viewer::{PerspectiveViewerMsg, PerspectiveViewerProps};
30use crate::config::*;
31use crate::custom_events::*;
32use crate::js::*;
33use crate::presentation::*;
34use crate::queries::*;
35use crate::renderer::*;
36use crate::root::Root;
37use crate::session::{ResetOptions, Session, TableLoadState};
38use crate::tasks::*;
39use crate::utils::*;
40use crate::*;
41
42#[wasm_bindgen]
43extern "C" {
44    #[wasm_bindgen(typescript_type = "Promise<ViewerConfig>")]
45    pub type JsViewerConfigPromise;
46
47    #[wasm_bindgen(typescript_type = "ViewerConfigUpdate")]
48    pub type JsViewerConfigUpdate;
49}
50
51#[derive(serde::Deserialize, Default)]
52struct ResizeOptions {
53    dimensions: Option<ResizeDimensions>,
54}
55
56#[derive(serde::Deserialize, Clone, Copy)]
57struct ResizeDimensions {
58    width: f64,
59    height: f64,
60}
61
62/// The `<perspective-viewer>` custom element.
63///
64/// # JavaScript Examples
65///
66/// Create a new `<perspective-viewer>`:
67///
68/// ```javascript
69/// const viewer = document.createElement("perspective-viewer");
70/// window.body.appendChild(viewer);
71/// ```
72///
73/// Complete example including loading and restoring the [`Table`]:
74///
75/// ```javascript
76/// import perspective from "@perspective-dev/viewer";
77/// import perspective from "@perspective-dev/client";
78///
79/// const viewer = document.createElement("perspective-viewer");
80/// const worker = await perspective.worker();
81///
82/// await worker.table("x\n1", {name: "table_one"});
83/// await viewer.load(worker);
84/// await viewer.restore({table: "table_one"});
85/// ```
86#[derive(Clone)]
87#[wasm_bindgen]
88pub struct PerspectiveViewerElement {
89    elem: HtmlElement,
90    root: Root<components::viewer::PerspectiveViewer>,
91    resize_handle: Rc<RefCell<Option<ResizeObserverHandle>>>,
92    intersection_handle: Rc<RefCell<Option<IntersectionObserverHandle>>>,
93    pub(crate) session: Session,
94    pub(crate) renderer: Renderer,
95    pub(crate) presentation: Presentation,
96    _subscriptions: Rc<[Subscription; 2]>,
97    _custom_event_subs: Rc<Vec<Subscription>>,
98}
99
100impl CustomElementMetadata for PerspectiveViewerElement {
101    const CUSTOM_ELEMENT_NAME: &'static str = "perspective-viewer";
102    const STATICS: &'static [&'static str] =
103        ["registerPlugin", "get_wasm_module", "get_worker_url"].as_slice();
104}
105
106#[wasm_bindgen]
107impl PerspectiveViewerElement {
108    #[doc(hidden)]
109    #[wasm_bindgen(constructor)]
110    pub fn new(elem: web_sys::HtmlElement) -> Self {
111        let init = web_sys::ShadowRootInit::new(web_sys::ShadowRootMode::Open);
112        let shadow_root = elem
113            .attach_shadow(&init)
114            .unwrap()
115            .unchecked_into::<web_sys::Element>();
116
117        Self::new_from_shadow(elem, shadow_root)
118    }
119
120    fn new_from_shadow(elem: web_sys::HtmlElement, shadow_root: web_sys::Element) -> Self {
121        // Application State
122        let session = Session::new();
123        let renderer = Renderer::new(&elem);
124        let presentation = Presentation::new(&elem);
125        let custom_event_subs = wire_custom_events(&elem, &session, &renderer, &presentation);
126
127        // Create Yew App
128        let props = yew::props!(PerspectiveViewerProps {
129            elem: elem.clone(),
130            session: session.clone(),
131            renderer: renderer.clone(),
132            presentation: presentation.clone(),
133        });
134
135        let state = props.clone();
136        let root = Root::new(shadow_root, props);
137
138        // Create callbacks
139        let update_sub = session.table_updated.add_listener({
140            clone!(renderer, session);
141            move |_| {
142                clone!(renderer, session);
143                ApiFuture::spawn(async move {
144                    renderer
145                        .update(session.get_view())
146                        .await
147                        .ignore_view_delete()
148                        .map(|_| ())
149                })
150            }
151        });
152
153        let eject_sub = presentation.on_eject.add_listener({
154            let root = root.clone();
155            move |_| ApiFuture::spawn(delete_all(&state.session, &state.renderer, &root))
156        });
157
158        let resize_handle =
159            ResizeObserverHandle::new(&elem, &renderer, &session, &presentation, &root);
160
161        let intersect_handle =
162            IntersectionObserverHandle::new(&elem, &presentation, &session, &renderer);
163
164        Self {
165            elem,
166            root,
167            session,
168            renderer,
169            presentation,
170            resize_handle: Rc::new(RefCell::new(Some(resize_handle))),
171            intersection_handle: Rc::new(RefCell::new(Some(intersect_handle))),
172            _subscriptions: Rc::new([update_sub, eject_sub]),
173            _custom_event_subs: Rc::new(custom_event_subs),
174        }
175    }
176
177    #[doc(hidden)]
178    #[wasm_bindgen(js_name = "connectedCallback")]
179    pub fn connected_callback(&self) -> ApiResult<()> {
180        tracing::debug!("Connected <perspective-viewer>");
181        Ok(())
182    }
183
184    /// Loads a [`Client`], or optionally [`Table`], or optionally a Javascript
185    /// `Promise` which returns a [`Client`] or [`Table`], in this viewer.
186    ///
187    /// Loading a [`Client`] does not render, but subsequent calls to
188    /// [`PerspectiveViewerElement::restore`] will use this [`Client`] to look
189    /// up the proviced `table` name field for the provided
190    /// [`ViewerConfigUpdate`].
191    ///
192    /// Loading a [`Table`] is equivalent to subsequently calling
193    /// [`Self::restore`] with the `table` field set to [`Table::get_name`], and
194    /// will render the UI in its default state when [`Self::load`] resolves.
195    /// If you plan to call [`Self::restore`] anyway, prefer passing a
196    /// [`Client`] argument to [`Self::load`] as it will conserve one render.
197    ///
198    /// When [`PerspectiveViewerElement::load`] resolves, the first frame of the
199    /// UI + visualization is guaranteed to have been drawn. Awaiting the result
200    /// of this method in a `try`/`catch` block will capture any errors
201    /// thrown during the loading process, or from the [`Client`] `Promise`
202    /// itself.
203    ///
204    /// [`PerspectiveViewerElement::load`] may also be called with a [`Table`],
205    /// which is equivalent to:
206    ///
207    /// ```javascript
208    /// await viewer.load(await table.get_client());
209    /// await viewer.restore({name: await table.get_name()})
210    /// ```
211    ///
212    /// If you plan to call [`PerspectiveViewerElement::restore`] immediately
213    /// after [`PerspectiveViewerElement::load`] yourself, as is commonly
214    /// done when loading and configuring a new `<perspective-viewer>`, you
215    /// should use a [`Client`] as an argument and set the `table` field in the
216    /// restore call as
217    ///
218    /// A [`Table`] can be created using the
219    /// [`@perspective-dev/client`](https://www.npmjs.com/package/@perspective-dev/client)
220    /// library from NPM (see [`perspective_js`] documentation for details).
221    ///
222    /// # JavaScript Examples
223    ///
224    /// ```javascript
225    /// import perspective from "@perspective-dev/client";
226    ///
227    /// const worker = await perspective.worker();
228    /// viewer.load(worker);
229    /// ```
230    ///
231    /// ... or
232    ///
233    /// ```javascript
234    /// const table = await worker.table(data, {name: "superstore"});
235    /// viewer.load(table);
236    /// ```
237    ///
238    /// Complete example:
239    ///
240    /// ```javascript
241    /// const viewer = document.createElement("perspective-viewer");
242    /// const worker = await perspective.worker();
243    ///
244    /// await worker.table("x\n1", {name: "table_one"});
245    /// await viewer.load(worker);
246    /// await viewer.restore({table: "table_one", columns: ["x"]});
247    /// ```
248    ///
249    /// ... or, if you don't want to pass your own arguments to `restore`:
250    ///
251    /// ```javascript
252    /// const viewer = document.createElement("perspective-viewer");
253    /// const worker = await perspective.worker();
254    ///
255    /// const table = await worker.table("x\n1", {name: "table_one"});
256    /// await viewer.load(table);
257    /// ```
258    pub fn load(&self, table: JsValue) -> ApiResult<ApiFuture<()>> {
259        let promise = table
260            .clone()
261            .dyn_into::<js_sys::Promise>()
262            .unwrap_or_else(|_| js_sys::Promise::resolve(&table));
263
264        let _plugin = self.renderer.get_active_plugin()?;
265        let reset_task = self.session.reset(ResetOptions {
266            config: true,
267            expressions: true,
268            stats: true,
269            table: Some(session::TableIntermediateState::Reloaded),
270        });
271
272        clone!(self.renderer, self.session);
273        Ok(ApiFuture::new_throttled(async move {
274            let task = async {
275                // Ignore this error, which is blown away by the table anyway.
276                let _ = reset_task.await;
277                let jstable = JsFuture::from(promise)
278                    .await
279                    .map_err(|x| apierror!(TableError(x)))?;
280
281                if let Ok(Some(table)) =
282                    try_from_js_option::<perspective_js::Table>(jstable.clone())
283                {
284                    let client = table.get_client().await;
285                    session.set_client(client.get_client().clone());
286                    let name = table.get_name().await;
287                    tracing::debug!(
288                        "Loading {:.0} rows from `Table` {}",
289                        table.size().await?,
290                        name
291                    );
292
293                    if session.set_table(name).await? {
294                        session.validate().await?.create_view().await?;
295                    }
296
297                    Ok(session.get_view())
298                } else if let Ok(Some(client)) =
299                    wasm_bindgen_derive::try_from_js_option::<perspective_js::Client>(jstable)
300                {
301                    session.set_client(client.get_client().clone());
302                    Ok(session.get_view())
303                } else {
304                    Err(ApiError::new("Invalid argument"))
305                }
306            };
307
308            renderer.set_throttle(None);
309            let result = renderer.draw(task).await;
310            if let Err(e) = &result {
311                session.set_error(false, e.clone()).await?;
312            }
313
314            result
315        }))
316    }
317
318    /// Delete the internal [`View`] and all associated state, rendering this
319    /// `<perspective-viewer>` unusable and freeing all associated resources.
320    /// Does not delete the supplied [`Table`] (as this is constructed by the
321    /// callee).
322    ///
323    /// Calling _any_ method on a `<perspective-viewer>` after [`Self::delete`]
324    /// will throw.
325    ///
326    /// <div class="warning">
327    ///
328    /// Allowing a `<perspective-viewer>` to be garbage-collected
329    /// without calling [`PerspectiveViewerElement::delete`] will leak WASM
330    /// memory!
331    ///
332    /// </div>
333    ///
334    /// # JavaScript Examples
335    ///
336    /// ```javascript
337    /// await viewer.delete();
338    /// ```
339    pub fn delete(self) -> ApiFuture<()> {
340        delete_all(&self.session, &self.renderer, &self.root)
341    }
342
343    /// Restart this `<perspective-viewer>` to its initial state, before
344    /// `load()`.
345    ///
346    /// Use `Self::restart` if you plan to call `Self::load` on this viewer
347    /// again, or alternatively `Self::delete` if this viewer is no longer
348    /// needed.
349    pub fn eject(&mut self) -> ApiFuture<()> {
350        if matches!(self.session.has_table(), Some(TableLoadState::Loaded)) {
351            let mut state = Self::new_from_shadow(
352                self.elem.clone(),
353                self.elem.shadow_root().unwrap().unchecked_into(),
354            );
355
356            std::mem::swap(self, &mut state);
357            ApiFuture::new_throttled(state.delete())
358        } else {
359            ApiFuture::new_throttled(async move { Ok(()) })
360        }
361    }
362
363    /// Get the underlying [`View`] for this viewer.
364    ///
365    /// Use this method to get promgrammatic access to the [`View`] as currently
366    /// configured by the user, for e.g. serializing as an
367    /// [Apache Arrow](https://arrow.apache.org/) before passing to another
368    /// library.
369    ///
370    /// The [`View`] returned by this method is owned by the
371    /// [`PerspectiveViewerElement`] and may be _invalidated_ by
372    /// [`View::delete`] at any time. Plugins which rely on this [`View`] for
373    /// their [`HTMLPerspectiveViewerPluginElement::draw`] implementations
374    /// should treat this condition as a _cancellation_ by silently aborting on
375    /// "View already deleted" errors from method calls.
376    ///
377    /// # JavaScript Examples
378    ///
379    /// ```javascript
380    /// const view = await viewer.getView();
381    /// ```
382    #[wasm_bindgen]
383    pub fn getView(&self) -> ApiFuture<View> {
384        let session = self.session.clone();
385        ApiFuture::new(async move { Ok(session.get_view().ok_or("No table set")?.into()) })
386    }
387
388    /// Get a copy of the [`ViewConfig`] for the current [`View`]. This is
389    /// non-blocking as it does not need to access the plugin (unlike
390    /// [`PerspectiveViewerElement::save`]), and also makes no API calls to the
391    /// server (unlike [`PerspectiveViewerElement::getView`] followed by
392    /// [`View::get_config`])
393    ///
394    /// Returns the [`ViewConfig`] the currently-bound `View` was constructed
395    /// from, so the value is consistent with what the active plugin is
396    /// rendering even if a queued [`Self::restore`]/`update_and_render` has
397    /// already mutated the live config in anticipation of the next draw.
398    /// Falls back to the live session config when no `View` has yet been
399    /// created (e.g., after `load` but before the first render).
400    #[wasm_bindgen]
401    pub fn getViewConfig(&self) -> ApiFuture<JsViewConfig> {
402        let session = self.session.clone();
403        ApiFuture::new(async move {
404            let config = if let Some(rendered) = session.get_rendered_view_config() {
405                (*rendered).clone()
406            } else {
407                session.get_view_config().clone()
408            };
409
410            Ok(JsValue::from_serde_ext(&config)?.unchecked_into())
411        })
412    }
413
414    /// Get the underlying [`Table`] for this viewer (as passed to
415    /// [`PerspectiveViewerElement::load`] or as the `table` field to
416    /// [`PerspectiveViewerElement::restore`]).
417    ///
418    /// # Arguments
419    ///
420    /// - `wait_for_table` - whether to wait for
421    ///   [`PerspectiveViewerElement::load`] to be called, or fail immediately
422    ///   if [`PerspectiveViewerElement::load`] has not yet been called.
423    ///
424    /// # JavaScript Examples
425    ///
426    /// ```javascript
427    /// const table = await viewer.getTable();
428    /// ```
429    #[wasm_bindgen]
430    pub fn getTable(&self, wait_for_table: Option<bool>) -> ApiFuture<Table> {
431        let session = self.session.clone();
432        ApiFuture::new(async move {
433            match session.get_table() {
434                Some(table) => Ok(table.into()),
435                None if !wait_for_table.unwrap_or_default() => Err("No `Table` set".into()),
436                None => {
437                    session.table_loaded.read_next().await?;
438                    Ok(session.get_table().ok_or("No `Table` set")?.into())
439                },
440            }
441        })
442    }
443
444    /// Get the underlying [`Client`] for this viewer (as passed to, or
445    /// associated with the [`Table`] passed to,
446    /// [`PerspectiveViewerElement::load`]).
447    ///
448    /// # Arguments
449    ///
450    /// - `wait_for_client` - whether to wait for
451    ///   [`PerspectiveViewerElement::load`] to be called, or fail immediately
452    ///   if [`PerspectiveViewerElement::load`] has not yet been called.
453    ///
454    /// # JavaScript Examples
455    ///
456    /// ```javascript
457    /// const client = await viewer.getClient();
458    /// ```
459    #[wasm_bindgen]
460    pub fn getClient(&self, wait_for_client: Option<bool>) -> ApiFuture<perspective_js::Client> {
461        let session = self.session.clone();
462        ApiFuture::new(async move {
463            match session.get_client() {
464                Some(client) => Ok(client.into()),
465                None if !wait_for_client.unwrap_or_default() => Err("No `Client` set".into()),
466                None => {
467                    session.table_loaded.read_next().await?;
468                    Ok(session.get_client().ok_or("No `Client` set")?.into())
469                },
470            }
471        })
472    }
473
474    /// Get render statistics. Some fields of the returned stats object are
475    /// relative to the last time [`PerspectiveViewerElement::getRenderStats`]
476    /// was called, ergo calling this method resets these fields.
477    ///
478    /// # JavaScript Examples
479    ///
480    /// ```javascript
481    /// const {virtual_fps, actual_fps} = await viewer.getRenderStats();
482    /// ```
483    #[wasm_bindgen]
484    pub fn getRenderStats(&self) -> ApiResult<JsValue> {
485        Ok(JsValue::from_serde_ext(
486            &self.renderer.render_timer().get_stats(),
487        )?)
488    }
489
490    /// Flush any pending modifications to this `<perspective-viewer>`.  Since
491    /// `<perspective-viewer>`'s API is almost entirely `async`, it may take
492    /// some milliseconds before any user-initiated changes to the [`View`]
493    /// affects the rendered element.  If you want to make sure all pending
494    /// actions have been rendered, call and await [`Self::flush`].
495    ///
496    /// [`Self::flush`] will resolve immediately if there is no [`Table`] set.
497    ///
498    /// # JavaScript Examples
499    ///
500    /// In this example, [`Self::restore`] is called without `await`, but the
501    /// eventual render which results from this call can still be awaited by
502    /// immediately awaiting [`Self::flush`] instead.
503    ///
504    /// ```javascript
505    /// viewer.restore(config);
506    /// await viewer.flush();
507    /// ```
508    pub fn flush(&self) -> ApiFuture<()> {
509        clone!(self.renderer);
510        ApiFuture::new_throttled(async move {
511            // We must let two AFs pass to guarantee listeners to the DOM state
512            // have themselves triggered, or else `request_animation_frame`
513            // may finish before a `ResizeObserver` triggered before is
514            // notifiedd.
515            //
516            // https://github.com/w3c/csswg-drafts/issues/9560
517            // https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering
518            request_animation_frame().await;
519            request_animation_frame().await;
520            renderer.clone().with_lock(async { Ok(()) }).await?;
521            renderer.with_lock(async { Ok(()) }).await
522        })
523    }
524
525    /// Restores this element from a full/partial
526    /// [`perspective_js::JsViewConfig`] (this element's user-configurable
527    /// state, including the `Table` name).
528    ///
529    /// One of the best ways to use [`Self::restore`] is by first configuring
530    /// a `<perspective-viewer>` as you wish, then using either the `Debug`
531    /// panel or "Copy" -> "config.json" from the toolbar menu to snapshot
532    /// the [`Self::restore`] argument as JSON.
533    ///
534    /// # Arguments
535    ///
536    /// - `update` - The config to restore to, as returned by [`Self::save`] in
537    ///   either "json", "string" or "arraybuffer" format.
538    ///
539    /// # JavaScript Examples
540    ///
541    /// Loads a default plugin for the table named `"superstore"`:
542    ///
543    /// ```javascript
544    /// await viewer.restore({table: "superstore"});
545    /// ```
546    ///
547    /// Apply a `group_by` to the same `viewer` element, without
548    /// modifying/resetting other fields - you can omit the `table` field,
549    /// this has already been set once and is not modified:
550    ///
551    /// ```javascript
552    /// await viewer.restore({group_by: ["State"]});
553    /// ```
554    pub fn restore(&self, update: JsViewerConfigUpdate) -> ApiFuture<()> {
555        let this = self.clone();
556        ApiFuture::new_throttled(async move {
557            let decoded_update = ViewerConfigUpdate::decode(&update)?;
558            tracing::info!("Restoring {}", decoded_update);
559            let root = this.root.clone();
560            let settings = decoded_update.settings.clone();
561            let (sender, receiver) = channel::<()>();
562            root.borrow().as_ref().into_apierror()?.send_message(
563                PerspectiveViewerMsg::ToggleSettingsComplete(settings, sender),
564            );
565
566            let task = if let OptionalUpdate::Update(_) = &decoded_update.table {
567                Some(this.session.reset(ResetOptions {
568                    config: true,
569                    expressions: true,
570                    stats: true,
571                    ..ResetOptions::default()
572                }))
573            } else {
574                None
575            };
576
577            let result = restore_and_render(
578                &this.session,
579                &this.renderer,
580                &this.presentation,
581                decoded_update.clone(),
582                {
583                    clone!(this, decoded_update.table);
584                    async move {
585                        if let OptionalUpdate::Update(name) = table {
586                            if let Some(task) = task {
587                                task.await?;
588                            }
589
590                            this.session.set_table(name).await?;
591                            this.session
592                                .update_column_defaults(&this.renderer.metadata());
593                        };
594
595                        // Something abnormal in the DOM happened, e.g. the
596                        // element was disconnected while rendering.
597                        receiver.await.unwrap_or_log();
598                        Ok(())
599                    }
600                },
601            )
602            .await;
603
604            if let Err(e) = &result {
605                this.session.set_error(false, e.clone()).await?;
606            }
607            result
608        })
609    }
610
611    /// If this element is in an _errored_ state, this method will clear it and
612    /// re-render. Calling this method is equivalent to clicking the error reset
613    /// button in the UI.
614    pub fn resetError(&self) -> ApiFuture<()> {
615        ApiFuture::spawn(self.session.reset(ResetOptions::default()));
616        let this = self.clone();
617        ApiFuture::new_throttled(async move {
618            update_and_render(&this.session, &this.renderer, ViewConfigUpdate::default())?.await?;
619            Ok(())
620        })
621    }
622
623    /// Save this element's user-configurable state to a serialized state
624    /// object, one which can be restored via the [`Self::restore`] method.
625    ///
626    /// # JavaScript Examples
627    ///
628    /// Get the current `group_by` setting:
629    ///
630    /// ```javascript
631    /// const {group_by} = await viewer.restore();
632    /// ```
633    ///
634    /// Reset workflow attached to an external button `myResetButton`:
635    ///
636    /// ```javascript
637    /// const token = await viewer.save();
638    /// myResetButton.addEventListener("clien", async () => {
639    ///     await viewer.restore(token);
640    /// });
641    /// ```
642    pub fn save(&self) -> JsViewerConfigPromise {
643        let this = self.clone();
644        let fut = ApiFuture::new(async move {
645            let viewer_config = this
646                .renderer
647                .clone()
648                .with_lock(async {
649                    get_viewer_config(&this.session, &this.renderer, &this.presentation).await
650                })
651                .await?;
652
653            viewer_config.encode()
654        });
655
656        js_sys::Promise::from(fut).unchecked_into()
657    }
658
659    /// Download this viewer's internal [`View`] data via a browser download
660    /// event.
661    ///
662    /// # Arguments
663    ///
664    /// - `method` - The `ExportMethod` to use to render the data to download.
665    ///
666    /// # JavaScript Examples
667    ///
668    /// ```javascript
669    /// myDownloadButton.addEventListener("click", async () => {
670    ///     await viewer.download();
671    /// })
672    /// ```
673    pub fn download(&self, method: Option<JsString>) -> ApiFuture<()> {
674        let this = self.clone();
675        ApiFuture::new_throttled(async move {
676            let method = if let Some(method) = method
677                .map(|x| x.unchecked_into())
678                .map(serde_wasm_bindgen::from_value)
679            {
680                method?
681            } else {
682                ExportMethod::Csv
683            };
684
685            let blob =
686                export_method_to_blob(&this.session, &this.renderer, &this.presentation, method)
687                    .await?;
688            let is_chart = this.renderer.is_chart();
689            download(
690                format!("untitled{}", method.as_filename(is_chart)).as_ref(),
691                &blob,
692            )
693        })
694    }
695
696    /// Exports this viewer's internal [`View`] as a JavaSript data, the
697    /// exact type of which depends on the `method` but defaults to `String`
698    /// in CSV format.
699    ///
700    /// This method is only really useful for the `"plugin"` method, which
701    /// will use the configured plugin's export (e.g. PNG for
702    /// `@perspective-dev/viewer-charts`). Otherwise, prefer to call the
703    /// equivalent method on the underlying [`View`] directly.
704    ///
705    /// # Arguments
706    ///
707    /// - `method` - The `ExportMethod` to use to render the data to download.
708    ///
709    /// # JavaScript Examples
710    ///
711    /// ```javascript
712    /// const data = await viewer.export("plugin");
713    /// ```
714    pub fn export(&self, method: Option<JsString>) -> ApiFuture<JsValue> {
715        let this = self.clone();
716        ApiFuture::new(async move {
717            let method = if let Some(method) = method
718                .map(|x| x.unchecked_into())
719                .map(serde_wasm_bindgen::from_value)
720            {
721                method?
722            } else {
723                ExportMethod::Csv
724            };
725
726            export_method_to_jsvalue(&this.session, &this.renderer, &this.presentation, method)
727                .await
728        })
729    }
730
731    /// Copy this viewer's `View` or `Table` data as CSV to the system
732    /// clipboard.
733    ///
734    /// # Arguments
735    ///
736    /// - `method` - The `ExportMethod` (serialized as a `String`) to use to
737    ///   render the data to the Clipboard.
738    ///
739    /// # JavaScript Examples
740    ///
741    /// ```javascript
742    /// myDownloadButton.addEventListener("click", async () => {
743    ///     await viewer.copy();
744    /// })
745    /// ```
746    pub fn copy(&self, method: Option<JsString>) -> ApiFuture<()> {
747        let this = self.clone();
748        ApiFuture::new_throttled(async move {
749            let method = if let Some(method) = method
750                .map(|x| x.unchecked_into())
751                .map(serde_wasm_bindgen::from_value)
752            {
753                method?
754            } else {
755                ExportMethod::Csv
756            };
757
758            let js_task =
759                export_method_to_blob(&this.session, &this.renderer, &this.presentation, method);
760            copy_to_clipboard(js_task, MimeType::TextPlain).await
761        })
762    }
763
764    /// Reset the viewer's `ViewerConfig` to the default.
765    ///
766    /// # Arguments
767    ///
768    /// - `reset_all` - If set, will clear expressions and column settings as
769    ///   well.
770    ///
771    /// # JavaScript Examples
772    ///
773    /// ```javascript
774    /// await viewer.reset();
775    /// ```
776    pub fn reset(&self, reset_all: Option<bool>) -> ApiFuture<()> {
777        tracing::debug!("Resetting config");
778        let root = self.root.clone();
779        let all = reset_all.unwrap_or_default();
780        ApiFuture::new_throttled(async move {
781            let (sender, receiver) = channel::<()>();
782            root.borrow()
783                .as_ref()
784                .ok_or("Already deleted")?
785                .send_message(PerspectiveViewerMsg::Reset(all, Some(sender)));
786
787            Ok(receiver.await?)
788        })
789    }
790
791    /// Recalculate the viewer's dimensions and redraw.
792    ///
793    /// Use this method to tell `<perspective-viewer>` its dimensions have
794    /// changed when auto-size mode has been disabled via [`Self::setAutoSize`].
795    /// [`Self::resize`] resolves when the resize-initiated redraw of this
796    /// element has completed.
797    ///
798    /// # Arguments
799    ///
800    /// - `options` - An optional object with the following fields:
801    ///   - `dimensions` - An optional object `{width, height}` providing
802    ///     explicit size hints (in pixels) for the plugin container. When
803    ///     provided, the plugin element will be temporarily sized to these
804    ///     dimensions during resize, then reset.
805    ///
806    /// # JavaScript Examples
807    ///
808    /// ```javascript
809    /// await viewer.resize()
810    /// await viewer.resize({dimensions: {width: 800, height: 600}})
811    /// ```
812    #[wasm_bindgen]
813    pub fn resize(&self, options: Option<JsValue>) -> ApiFuture<()> {
814        let opts: ResizeOptions = options
815            .map(|v| v.into_serde_ext())
816            .transpose()
817            .unwrap_or_default()
818            .unwrap_or_default();
819
820        let state = self.clone();
821        ApiFuture::new_throttled(async move {
822            if !state.renderer.is_plugin_activated()? {
823                update_and_render(&state.session, &state.renderer, ViewConfigUpdate::default())?
824                    .await?;
825            } else if let Some(dims) = opts.dimensions {
826                state
827                    .renderer
828                    .resize_with_dimensions(dims.width, dims.height)
829                    .await?;
830            } else {
831                state.renderer.resize().await?;
832            }
833
834            Ok(())
835        })
836    }
837
838    /// Sets the auto-size behavior of this component.
839    ///
840    /// When `true`, this `<perspective-viewer>` will register a
841    /// `ResizeObserver` on itself and call [`Self::resize`] whenever its own
842    /// dimensions change. However, when embedded in a larger application
843    /// context, you may want to call [`Self::resize`] manually to avoid
844    /// over-rendering; in this case auto-sizing can be disabled via this
845    /// method. Auto-size behavior is enabled by default.
846    ///
847    /// # Arguments
848    ///
849    /// - `autosize` - Whether to enable `auto-size` behavior or not.
850    ///
851    /// # JavaScript Examples
852    ///
853    /// Disable auto-size behavior:
854    ///
855    /// ```javascript
856    /// viewer.setAutoSize(false);
857    /// ```
858    #[wasm_bindgen]
859    pub fn setAutoSize(&self, autosize: bool) {
860        if autosize {
861            let handle = Some(ResizeObserverHandle::new(
862                &self.elem,
863                &self.renderer,
864                &self.session,
865                &self.presentation,
866                &self.root,
867            ));
868            *self.resize_handle.borrow_mut() = handle;
869        } else {
870            *self.resize_handle.borrow_mut() = None;
871        }
872    }
873
874    /// Sets the auto-pause behavior of this component.
875    ///
876    /// When `true`, this `<perspective-viewer>` will register an
877    /// `IntersectionObserver` on itself and subsequently skip rendering
878    /// whenever its viewport visibility changes. Auto-pause is enabled by
879    /// default.
880    ///
881    /// # Arguments
882    ///
883    /// - `autopause` Whether to enable `auto-pause` behavior or not.
884    ///
885    /// # JavaScript Examples
886    ///
887    /// Disable auto-size behavior:
888    ///
889    /// ```javascript
890    /// viewer.setAutoPause(false);
891    /// ```
892    #[wasm_bindgen]
893    pub fn setAutoPause(&self, autopause: bool) -> ApiFuture<()> {
894        if autopause {
895            let handle = Some(IntersectionObserverHandle::new(
896                &self.elem,
897                &self.presentation,
898                &self.session,
899                &self.renderer,
900            ));
901
902            *self.intersection_handle.borrow_mut() = handle;
903        } else {
904            *self.intersection_handle.borrow_mut() = None;
905            if self.session.set_pause(false) {
906                return ApiFuture::new(restore_and_render(
907                    &self.session,
908                    &self.renderer,
909                    &self.presentation,
910                    ViewerConfigUpdate::default(),
911                    async move { Ok(()) },
912                ));
913            }
914        }
915
916        ApiFuture::new(async move { Ok(()) })
917    }
918
919    /// Return a [`perspective_js::JsViewWindow`] for the currently selected
920    /// region.
921    #[wasm_bindgen]
922    pub fn getSelection(&self) -> Option<JsViewWindow> {
923        self.renderer.get_selection().map(|x| x.into())
924    }
925
926    /// Set the selection [`perspective_js::JsViewWindow`] for this element.
927    #[wasm_bindgen]
928    pub fn setSelection(&self, window: Option<JsViewWindow>) -> ApiResult<()> {
929        let window = window.map(|x| x.into_serde_ext()).transpose()?;
930        self.renderer.set_selection(window);
931        Ok(())
932    }
933
934    /// Get this viewer's edit port for the currently loaded [`Table`] (see
935    /// [`Table::update`] for details on ports).
936    #[wasm_bindgen]
937    pub fn getEditPort(&self) -> Result<f64, JsValue> {
938        self.session
939            .metadata()
940            .get_edit_port()
941            .ok_or_else(|| "No `Table` loaded".into())
942    }
943
944    /// Restyle all plugins from current document.
945    ///
946    /// <div class="warning">
947    ///
948    /// [`Self::restyleElement`] _must_ be called for many runtime changes to
949    /// CSS properties to be reflected in an already-rendered
950    /// `<perspective-viewer>`.
951    ///
952    /// </div>
953    ///
954    /// # JavaScript Examples
955    ///
956    /// ```javascript
957    /// viewer.style = "--psp--color: red";
958    /// await viewer.restyleElement();
959    /// ```
960    #[wasm_bindgen]
961    pub fn restyleElement(&self) -> ApiFuture<JsValue> {
962        clone!(self.renderer, self.session);
963        ApiFuture::new(async move {
964            let view = session.get_view().into_apierror()?;
965            renderer.restyle_all(&view).await
966        })
967    }
968
969    /// Set the available theme names available in the status bar UI.
970    ///
971    /// Calling [`Self::resetThemes`] may cause the current theme to switch,
972    /// if e.g. the new theme set does not contain the current theme.
973    ///
974    /// # JavaScript Examples
975    ///
976    /// Restrict `<perspective-viewer>` theme options to _only_ default light
977    /// and dark themes, regardless of what is auto-detected from the page's
978    /// CSS:
979    ///
980    /// ```javascript
981    /// viewer.resetThemes(["Pro Light", "Pro Dark"])
982    /// ```
983    #[wasm_bindgen]
984    pub fn resetThemes(&self, themes: Option<Box<[JsValue]>>) -> ApiFuture<JsValue> {
985        clone!(self.renderer, self.session, self.presentation);
986        ApiFuture::new(async move {
987            let themes: Option<Vec<String>> = themes
988                .unwrap_or_default()
989                .iter()
990                .map(|x| x.as_string())
991                .collect();
992
993            let theme_name = presentation.get_selected_theme_name().await;
994            let mut changed = presentation.reset_available_themes(themes).await;
995            let reset_theme = presentation
996                .get_available_themes()
997                .await?
998                .iter()
999                .find(|y| theme_name.as_ref() == Some(y))
1000                .cloned();
1001
1002            changed = presentation.set_theme_name(reset_theme.as_deref()).await? || changed;
1003            if changed && let Some(view) = session.get_view() {
1004                return renderer.restyle_all(&view).await;
1005            }
1006
1007            Ok(JsValue::UNDEFINED)
1008        })
1009    }
1010
1011    /// Determines the render throttling behavior. Can be an integer, for
1012    /// millisecond window to throttle render event; or, if `None`, adaptive
1013    /// throttling will be calculated from the measured render time of the
1014    /// last 5 frames.
1015    ///
1016    /// # Arguments
1017    ///
1018    /// - `throttle` - The throttle rate in milliseconds (f64), or `None` for
1019    ///   adaptive throttling.
1020    ///
1021    /// # JavaScript Examples
1022    ///
1023    /// Only draws at most 1 frame/sec:
1024    ///
1025    /// ```rust
1026    /// viewer.setThrottle(1000);
1027    /// ```
1028    #[wasm_bindgen]
1029    pub fn setThrottle(&self, val: Option<f64>) {
1030        self.renderer.set_throttle(val);
1031    }
1032
1033    /// Toggle (or force) the config panel open/closed.
1034    ///
1035    /// # Arguments
1036    ///
1037    /// - `force` - Force the state of the panel open or closed, or `None` to
1038    ///   toggle.
1039    ///
1040    /// # JavaScript Examples
1041    ///
1042    /// ```javascript
1043    /// await viewer.toggleConfig();
1044    /// ```
1045    #[wasm_bindgen]
1046    pub fn toggleConfig(&self, force: Option<bool>) -> ApiFuture<JsValue> {
1047        let root = self.root.clone();
1048        ApiFuture::new(async move {
1049            let force = force.map(SettingsUpdate::Update);
1050            let (sender, receiver) = channel::<ApiResult<wasm_bindgen::JsValue>>();
1051            root.borrow().as_ref().into_apierror()?.send_message(
1052                PerspectiveViewerMsg::ToggleSettingsInit(force, Some(sender)),
1053            );
1054
1055            receiver.await.map_err(|_| JsValue::from("Cancelled"))?
1056        })
1057    }
1058
1059    /// Get an `Array` of all of the plugin custom elements registered for this
1060    /// element. This may not include plugins which called
1061    /// [`registerPlugin`] after the host has rendered for the first time.
1062    #[wasm_bindgen]
1063    pub fn getAllPlugins(&self) -> Array {
1064        self.renderer.get_all_plugins().iter().collect::<Array>()
1065    }
1066
1067    /// Gets a plugin Custom Element with the `name` field, or get the active
1068    /// plugin if no `name` is provided.
1069    ///
1070    /// # Arguments
1071    ///
1072    /// - `name` - The `name` property of a perspective plugin Custom Element,
1073    ///   or `None` for the active plugin's Custom Element.
1074    #[wasm_bindgen]
1075    pub fn getPlugin(&self, name: Option<String>) -> ApiResult<JsPerspectiveViewerPlugin> {
1076        match name {
1077            None => self.renderer.get_active_plugin(),
1078            Some(name) => self.renderer.get_plugin(&name),
1079        }
1080    }
1081
1082    /// Create a new JavaScript Heap reference for this model instance.
1083    #[doc(hidden)]
1084    #[allow(clippy::use_self)]
1085    #[wasm_bindgen]
1086    pub fn __get_model(&self) -> PerspectiveViewerElement {
1087        self.clone()
1088    }
1089
1090    /// Asynchronously opens the column settings for a specific column.
1091    /// When finished, the `<perspective-viewer>` element will emit a
1092    /// "perspective-toggle-column-settings" CustomEvent.
1093    /// The event's details property has two fields: `{open: bool, column_name?:
1094    /// string}`. The CustomEvent is also fired whenever the user toggles the
1095    /// sidebar manually.
1096    #[wasm_bindgen]
1097    pub fn toggleColumnSettings(&self, column_name: String) -> ApiFuture<()> {
1098        clone!(self.session, self.root);
1099        ApiFuture::new_throttled(async move {
1100            let locator = get_column_locator(&session.metadata(), Some(column_name));
1101            let (sender, receiver) = channel::<()>();
1102            root.borrow().as_ref().into_apierror()?.send_message(
1103                PerspectiveViewerMsg::OpenColumnSettings {
1104                    locator,
1105                    sender: Some(sender),
1106                    toggle: true,
1107                },
1108            );
1109
1110            receiver.await.map_err(|_| ApiError::from("Cancelled"))
1111        })
1112    }
1113
1114    /// Force open the settings for a particular column. Pass `null` to close
1115    /// the column settings panel. See [`Self::toggleColumnSettings`] for more.
1116    #[wasm_bindgen]
1117    pub fn openColumnSettings(
1118        &self,
1119        column_name: Option<String>,
1120        toggle: Option<bool>,
1121    ) -> ApiFuture<()> {
1122        let locator = get_column_locator(&self.session.metadata(), column_name);
1123        clone!(self.root);
1124        ApiFuture::new_throttled(async move {
1125            let (sender, receiver) = channel::<()>();
1126            root.borrow().as_ref().into_apierror()?.send_message(
1127                PerspectiveViewerMsg::OpenColumnSettings {
1128                    locator,
1129                    sender: Some(sender),
1130                    toggle: toggle.unwrap_or_default(),
1131                },
1132            );
1133
1134            receiver.await.map_err(|_| ApiError::from("Cancelled"))
1135        })
1136    }
1137}