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