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