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