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