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