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}