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