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