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