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 futures::future::join_all;
20use js_sys::{Array, JsString};
21use perspective_client::config::ViewConfigUpdate;
22use perspective_js::utils::global;
23use perspective_js::{JsViewConfig, JsViewWindow, Table, View, apierror};
24use wasm_bindgen::JsCast;
25use wasm_bindgen::prelude::*;
26use wasm_bindgen_derive::try_from_js_option;
27use wasm_bindgen_futures::JsFuture;
28use web_sys::HtmlElement;
29use yew::Callback;
30
31#[cfg(feature = "llm-agent")]
32use crate::agent::AgentRuntime;
33use crate::components::viewer::{PerspectiveViewerMsg, PerspectiveViewerProps};
34use crate::config::*;
35use crate::custom_events::*;
36use crate::js::*;
37use crate::presentation::*;
38use crate::queries::*;
39use crate::root::Root;
40use crate::session::{BindPlan, Disposal, MissingTable, OpKind, ResetOptions, TableLoadState};
41use crate::tasks::*;
42use crate::utils::*;
43use crate::workspace::{Panel, PanelId, Workspace};
44use crate::*;
45
46#[wasm_bindgen]
47extern "C" {
48 /// `load()` argument: a [`Client`], a (deprecated) [`Table`], or a
49 /// `Promise` resolving to either. Typed rather than `any` so callers get
50 /// completion; the `Table` forms remain runtime-deprecated.
51 #[wasm_bindgen(typescript_type = "Client | Table | Promise<Client | Table>")]
52 pub type JsClientLoad;
53
54 /// `eject()` argument dict (`{ client?: string }`).
55 #[wasm_bindgen(typescript_type = "ClientOptions")]
56 pub type JsClientOptions;
57
58 /// Panel-selector dict (`{ panel?: string }`) for the active/base
59 /// accessor methods.
60 #[wasm_bindgen(typescript_type = "PanelOptions")]
61 pub type JsPanelOptions;
62
63 /// The `restore()` options dict.
64 #[wasm_bindgen(typescript_type = "RestoreOptions")]
65 pub type JsRestoreOptions;
66
67 /// The `restoreWorkspace()` options dict.
68 #[wasm_bindgen(typescript_type = "RestoreWorkspaceOptions")]
69 pub type JsRestoreWorkspaceOptions;
70
71 /// The `addPanel()` options dict.
72 #[wasm_bindgen(typescript_type = "AddPanelOptions")]
73 pub type JsAddPanelOptions;
74
75 /// `addPanel()` argument: a new panel's initial config — `table`
76 /// REQUIRED.
77 #[wasm_bindgen(typescript_type = "ViewerConfigInitial")]
78 pub type JsViewerConfigInitial;
79
80 /// `download`/`export`/`copy` options dict
81 /// (`{ method?: ExportMethod, panel?: string }`).
82 #[wasm_bindgen(typescript_type = "ExportOptions")]
83 pub type JsExportOptions;
84
85 /// `getTable` options dict (`{ wait?: boolean, panel?: string }`).
86 #[wasm_bindgen(typescript_type = "GetTableOptions")]
87 pub type JsGetTableOptions;
88
89 /// `getClient` options dict (`{ wait?: boolean, panel?: string }`).
90 #[wasm_bindgen(typescript_type = "GetClientOptions")]
91 pub type JsGetClientOptions;
92
93 /// `getView` options dict (`{ mode?: GetViewMode, panel?: string }`).
94 #[wasm_bindgen(typescript_type = "GetViewOptions")]
95 pub type JsGetViewOptions;
96
97 /// `saveWorkspace()` options dict (`{ full_palette?: boolean }`).
98 #[wasm_bindgen(typescript_type = "SaveWorkspaceOptions")]
99 pub type JsSaveWorkspaceOptions;
100
101 /// `saveWorkspace()` return: a workspace config.
102 #[wasm_bindgen(typescript_type = "Promise<WorkspaceConfig>")]
103 pub type JsWorkspaceConfigPromise;
104
105 /// A `Promise<void>` return, used by the `restore` family (whose
106 /// `ApiFuture<()>` would otherwise erase to `Promise<any>`).
107 #[wasm_bindgen(typescript_type = "Promise<void>")]
108 pub type JsVoidPromise;
109
110 /// `save()` return: a single-panel config.
111 #[wasm_bindgen(typescript_type = "Promise<ViewerConfig>")]
112 pub type JsViewerConfigPromise;
113}
114
115#[derive(serde::Deserialize, Default)]
116struct ResizeOptions {
117 dimensions: Option<ResizeDimensions>,
118}
119
120#[derive(serde::Deserialize, Clone, Copy)]
121struct ResizeDimensions {
122 width: f64,
123 height: f64,
124}
125
126/// Leniently deserialize an optional JS options dict into a serde struct,
127/// falling back to `Default` on absence or a malformed argument (matching the
128/// `ResizeOptions` precedent — an options bag is a best-effort convenience,
129/// not a hard-validated payload).
130fn parse_options<T, U>(options: Option<T>) -> ApiResult<U>
131where
132 T: Into<JsValue>,
133 U: Default + for<'a> serde::Deserialize<'a>,
134{
135 match options {
136 None => Ok(U::default()),
137 Some(x) => Ok(x.into_serde_ext()?),
138 }
139}
140
141/// The `<perspective-viewer>` custom element.
142///
143/// # JavaScript Examples
144///
145/// Create a new `<perspective-viewer>`:
146///
147/// ```javascript
148/// const viewer = document.createElement("perspective-viewer");
149/// window.body.appendChild(viewer);
150/// ```
151///
152/// Complete example including loading and restoring the [`Table`]:
153///
154/// ```javascript
155/// import perspective from "@perspective-dev/viewer";
156/// import perspective from "@perspective-dev/client";
157///
158/// const viewer = document.createElement("perspective-viewer");
159/// const worker = await perspective.worker();
160///
161/// await worker.table("x\n1", {name: "table_one"});
162/// await viewer.load(worker);
163/// await viewer.restore({table: "table_one"});
164/// ```
165#[derive(Clone)]
166#[wasm_bindgen]
167pub struct PerspectiveViewerElement {
168 pub(crate) presentation: Presentation,
169 pub(crate) workspace: Workspace,
170 pub(crate) elem: HtmlElement,
171 pub(crate) root: Root<components::viewer::PerspectiveViewer>,
172 resize_handle: Rc<RefCell<Option<ResizeObserverHandle>>>,
173 intersection_handle: Rc<RefCell<Option<AutoPauseHandle>>>,
174 hosted_table_subs: HostedTableSubs,
175 _subscriptions: Rc<[Subscription; 2]>,
176 _custom_event_subs: Rc<Vec<Subscription>>,
177}
178
179impl CustomElementMetadata for PerspectiveViewerElement {
180 const CUSTOM_ELEMENT_NAME: &'static str = "perspective-viewer";
181 const STATICS: &'static [&'static str] =
182 ["registerPlugin", "get_wasm_module", "get_worker_url"].as_slice();
183}
184
185impl PerspectiveViewerElement {
186 fn layout_changed_notify(&self) -> Callback<()> {
187 let root = self.root.clone();
188 Callback::from(move |_: ()| {
189 if let Some(app) = root.borrow().as_ref() {
190 app.send_message(PerspectiveViewerMsg::LayoutChanged);
191 }
192 })
193 }
194
195 fn resolve_panel(&self, name: Option<String>) -> ApiResult<Panel> {
196 let id = name.map(PanelId::from);
197 self.workspace.panel_or_active(id.as_ref()).ok_or_else(|| {
198 format!(
199 "No panel named \"{}\"",
200 id.as_ref().map(PanelId::as_str).unwrap_or_default()
201 )
202 .into()
203 })
204 }
205
206 /// Re-stamp the global-filter overlay on every panel not in `fresh` and
207 /// re-render those whose overlay changed.
208 async fn rerender_retained_overlays(&self, fresh: &[PanelId]) -> ApiResult<()> {
209 let retained = self
210 .workspace
211 .panels()
212 .into_iter()
213 .filter(|panel| !fresh.contains(&panel.id))
214 .collect::<Vec<_>>();
215
216 join_all(
217 retained
218 .iter()
219 .map(|panel| broadcast_overlay(&self.workspace, panel)),
220 )
221 .await
222 .into_iter()
223 .collect::<ApiResult<Vec<_>>>()?;
224
225 Ok(())
226 }
227
228 fn layout_element(&self) -> Option<RegularLayout> {
229 self.elem
230 .shadow_root()?
231 .query_selector(RegularLayout::TAG_NAME)
232 .ok()
233 .flatten()
234 .map(|el| el.unchecked_into())
235 }
236
237 async fn workspace_config(this: Self, full_palette: bool) -> ApiResult<JsValue> {
238 this.workspace.effects().settle().await;
239 let mut panels: std::collections::BTreeMap<String, PanelViewerConfig<String, String>> =
240 Default::default();
241 for id in &this.workspace.panel_ids() {
242 let panel = this.workspace.panel(id).into_apierror()?;
243 let config = panel
244 .renderer
245 .clone()
246 .with_lock(async {
247 get_viewer_config(&panel.session, &panel.renderer, &this.presentation).await
248 })
249 .await?;
250
251 panels.insert(id.as_str().to_owned(), config.panel);
252 }
253
254 let mut palette = palette_set(&this.workspace, &this.presentation);
255 let mut referenced = std::collections::BTreeSet::new();
256 for (id, item) in styles_in_use(&this.workspace) {
257 let Some(name) = palette_name_for(&palette, item.kind, &item.literal) else {
258 continue;
259 };
260
261 if let Some(entry) = panels
262 .get_mut(id.as_str())
263 .and_then(|panel| panel.columns_config.get_mut(&item.column))
264 {
265 entry.insert(item.key, serde_json::Value::String(format_var_ref(&name)));
266 referenced.insert(name);
267 }
268 }
269
270 if !full_palette {
271 palette.retain(|name, _| referenced.contains(name));
272 }
273
274 let active = this
275 .presentation
276 .is_settings_open()
277 .then(|| this.workspace.active_id())
278 .flatten()
279 .map(|id| id.as_str().to_owned());
280
281 let layout = this
282 .layout_element()
283 .map(|l| l.save().into_serde_ext::<crate::js::Layout>())
284 .transpose()?;
285
286 Ok(JsValue::from_serde_ext(&WorkspaceConfig {
287 version: API_VERSION.to_string(),
288 active,
289 layout,
290 panels,
291 derived_tables: this.workspace.derived_configs(),
292 global_filters: this.workspace.global_filters(),
293 masters: this
294 .workspace
295 .masters()
296 .iter()
297 .map(|id| id.as_str().to_owned())
298 .collect(),
299 palette,
300 })?)
301 }
302}
303
304fn eject_client_panels(
305 workspace: &Workspace,
306 root: &Root<crate::components::viewer::PerspectiveViewer>,
307 target: String,
308 ids: Vec<PanelId>,
309) -> ApiFuture<()> {
310 clone!(workspace, root);
311 let effect = workspace.effects().guard();
312 ApiFuture::new_throttled(async move {
313 let _effect = effect;
314 for id in ids {
315 let (completion, receiver) = Completion::new();
316 root.borrow()
317 .as_ref()
318 .into_apierror()?
319 .send_message(PerspectiveViewerMsg::ClosePanel(
320 id.to_string(),
321 Some(completion),
322 ));
323
324 receiver.await.map_err(|_| ApiError::new("Cancelled"))??;
325 }
326
327 workspace.remove_client(&target);
328 Ok(())
329 })
330}
331
332#[rustfmt::skip]
333const DEPRECATED_TABLE_MESSAGE: &str =
334 "`load(table)` is deprecated - use `load(client)` followed by `restore({table: \"name\"})` instead";
335
336#[wasm_bindgen]
337impl PerspectiveViewerElement {
338 #[doc(hidden)]
339 #[wasm_bindgen(constructor)]
340 pub fn new(elem: web_sys::HtmlElement) -> Self {
341 let init = web_sys::ShadowRootInit::new(web_sys::ShadowRootMode::Open);
342 let shadow_root = elem
343 .attach_shadow(&init)
344 .unwrap()
345 .unchecked_into::<web_sys::Element>();
346
347 Self::new_from_shadow(elem, shadow_root)
348 }
349
350 fn new_from_shadow(elem: web_sys::HtmlElement, shadow_root: web_sys::Element) -> Self {
351 // Application State.
352 let presentation = Presentation::new(&elem);
353
354 // Boot with ZERO panels — an unconfigured element is a blank stage. The
355 // first `load`/`restore`/`addPanel` creates the first panel, which
356 // adopts the element's `theme` attribute when set (see
357 // `create_panel_model`'s authored-theme boot).
358 let workspace = Workspace::new();
359 let custom_event_subs = wire_element_events(&elem, &presentation, &workspace);
360
361 // Create Yew App
362 let props = yew::props!(PerspectiveViewerProps {
363 elem: elem.clone(),
364 presentation: presentation.clone(),
365 workspace: workspace.clone(),
366 });
367
368 let state = props.clone();
369 let root = Root::new(shadow_root, props);
370
371 // Create callbacks
372 let eject_sub = presentation.on_eject.add_listener({
373 let root = root.clone();
374 move |_| {
375 clone!(state.workspace, root);
376 ApiFuture::spawn(async move {
377 if let Some(target) = workspace.active_client().map(|c| c.get_name().to_owned())
378 {
379 let ids = workspace.panels_for_client(&target);
380 if ids.len() < workspace.panel_ids().len() {
381 return eject_client_panels(&workspace, &root, target, ids).await;
382 }
383 }
384
385 delete_all(&workspace, &root).await
386 })
387 }
388 });
389
390 let resize_handle = ResizeObserverHandle::new(&elem, &workspace, &presentation, &root);
391 let intersect_handle = AutoPauseHandle::new(&elem, &presentation, &workspace);
392 let (lifecycle_sub, hosted_table_subs) = wire_table_lifecycle(&workspace, &presentation);
393
394 Self {
395 elem,
396 root,
397 presentation,
398 workspace,
399 resize_handle: Rc::new(RefCell::new(Some(resize_handle))),
400 intersection_handle: Rc::new(RefCell::new(Some(intersect_handle))),
401 hosted_table_subs,
402 _subscriptions: Rc::new([eject_sub, lifecycle_sub]),
403 _custom_event_subs: Rc::new(custom_event_subs),
404 }
405 }
406
407 #[doc(hidden)]
408 #[wasm_bindgen(js_name = "connectedCallback")]
409 pub fn connected_callback(&self) -> ApiResult<()> {
410 tracing::debug!("Connected <perspective-viewer>");
411 Ok(())
412 }
413
414 /// Loads a [`Client`], or optionally [`Table`], or optionally a Javascript
415 /// `Promise` which returns a [`Client`] or [`Table`], in this viewer.
416 ///
417 /// Loading a [`Client`] does not render, but subsequent calls to
418 /// [`PerspectiveViewerElement::restore`] will use this [`Client`] to look
419 /// up the proviced `table` name field for the provided
420 /// [`ViewerConfigUpdate`].
421 ///
422 /// Loading a [`Table`] is equivalent to subsequently calling
423 /// [`Self::restore`] with the `table` field set to [`Table::get_name`], and
424 /// will render the UI in its default state when [`Self::load`] resolves.
425 /// If you plan to call [`Self::restore`] anyway, prefer passing a
426 /// [`Client`] argument to [`Self::load`] as it will conserve one render.
427 ///
428 /// When [`PerspectiveViewerElement::load`] resolves, the first frame of the
429 /// UI + visualization is guaranteed to have been drawn. Awaiting the result
430 /// of this method in a `try`/`catch` block will capture any errors
431 /// thrown during the loading process, or from the [`Client`] `Promise`
432 /// itself.
433 ///
434 /// [`PerspectiveViewerElement::load`] may also be called with a [`Table`],
435 /// which is equivalent to:
436 ///
437 /// ```javascript
438 /// await viewer.load(await table.get_client());
439 /// await viewer.restore({name: await table.get_name()})
440 /// ```
441 ///
442 /// If you plan to call [`PerspectiveViewerElement::restore`] immediately
443 /// after [`PerspectiveViewerElement::load`] yourself, as is commonly
444 /// done when loading and configuring a new `<perspective-viewer>`, you
445 /// should use a [`Client`] as an argument and set the `table` field in the
446 /// restore call as
447 ///
448 /// A [`Table`] can be created using the
449 /// [`@perspective-dev/client`](https://www.npmjs.com/package/@perspective-dev/client)
450 /// library from NPM (see [`perspective_js`] documentation for details).
451 ///
452 /// # JavaScript Examples
453 ///
454 /// ```javascript
455 /// import perspective from "@perspective-dev/client";
456 ///
457 /// const worker = await perspective.worker();
458 /// viewer.load(worker);
459 /// ```
460 ///
461 /// ... or
462 ///
463 /// ```javascript
464 /// const table = await worker.table(data, {name: "superstore"});
465 /// viewer.load(table);
466 /// ```
467 ///
468 /// Complete example:
469 ///
470 /// ```javascript
471 /// const viewer = document.createElement("perspective-viewer");
472 /// const worker = await perspective.worker();
473 ///
474 /// await worker.table("x\n1", {name: "table_one"});
475 /// await viewer.load(worker);
476 /// await viewer.restore({table: "table_one", columns: ["x"]});
477 /// ```
478 ///
479 /// ... or, if you don't want to pass your own arguments to `restore`:
480 ///
481 /// ```javascript
482 /// const viewer = document.createElement("perspective-viewer");
483 /// const worker = await perspective.worker();
484 ///
485 /// const table = await worker.table("x\n1", {name: "table_one"});
486 /// await viewer.load(table);
487 /// ```
488 pub fn load(&self, client: JsClientLoad) -> ApiResult<ApiFuture<()>> {
489 let effect = self.workspace.effects().guard();
490 let table: JsValue = client.into();
491 let promise = table
492 .clone()
493 .dyn_into::<js_sys::Promise>()
494 .unwrap_or_else(|_| js_sys::Promise::resolve(&table));
495
496 let (panel, notify) = match self.workspace.active_panel() {
497 Some(panel) => (panel, None),
498 None => {
499 if let Ok(Some(client)) =
500 try_from_js_option::<perspective_js::Client>(table.clone())
501 {
502 self.workspace
503 .set_default_client(client.get_client().clone());
504 return Ok(ApiFuture::new(async { Ok(()) }));
505 }
506
507 let panel = self.workspace.reserved_panel().unwrap_or_else(|| {
508 create_panel_model(
509 &self.elem,
510 &self.presentation,
511 &self.workspace,
512 None,
513 ViewerConfigUpdate::default(),
514 None,
515 Placement::Reserved,
516 );
517 self.workspace
518 .reserved_panel()
519 .expect("just-reserved panel is present")
520 });
521
522 (panel, Some(self.layout_changed_notify()))
523 },
524 };
525
526 let session = panel.session;
527 let renderer = panel.renderer;
528 clone!(self.workspace, self.presentation);
529 enum Discard {
530 Unclaimed(Panel),
531 Evicted(Panel),
532 }
533
534 let table_known = matches!(
535 try_from_js_option::<perspective_js::Table>(table.clone()),
536 Ok(Some(_))
537 );
538
539 let ticket = session.submit(OpKind::Load { table_known }, {
540 clone!(session, renderer);
541 move |load| {
542 Box::pin(async move {
543 let _effect = effect;
544 renderer.set_throttle(None);
545 let result = {
546 clone!(session, renderer, workspace, notify, load);
547 renderer
548 .clone()
549 .render_task(|guard| async move {
550 renderer.stamp_theme(None);
551 let jstable = JsFuture::from(promise)
552 .await
553 .map_err(|x| apierror!(TableError(x)))?;
554
555 if let Ok(Some(table)) =
556 try_from_js_option::<perspective_js::Table>(jstable.clone())
557 {
558 tracing::warn!("{}", DEPRECATED_TABLE_MESSAGE);
559 if load.is_superseded() {
560 return Ok(None);
561 }
562
563 if let Some(notify) = ¬ify {
564 place_reserved(&workspace, notify, true);
565 }
566
567 let client = table.get_client().await;
568 let inner_client = client.get_client().clone();
569 workspace.set_default_client(inner_client.clone());
570 let name = table.get_name().await;
571 tracing::debug!(
572 "Loading {:.0} rows from `Table` {}",
573 table.size().await?,
574 name
575 );
576
577 let plan = match session::probe_table(
578 &inner_client,
579 &name,
580 MissingTable::Pend,
581 )
582 .await?
583 {
584 Some(table) => BindPlan::Bind {
585 client: inner_client,
586 table: Box::new(table),
587 reset: true,
588 },
589 None => BindPlan::Pend {
590 client: inner_client,
591 name,
592 reset: true,
593 },
594 };
595
596 let prepared = crate::tasks::prepare(
597 &session,
598 &renderer,
599 &presentation,
600 false,
601 plan,
602 renderer
603 .slot_name()
604 .map(|id| overlay_for(&workspace, &PanelId::from(id))),
605 ViewerConfigUpdate::default(),
606 )
607 .await?;
608
609 crate::tasks::commit_and_render_locked(
610 guard, &session, &renderer, prepared,
611 )
612 .await?;
613
614 Ok(None)
615 } else if let Ok(Some(client)) = wasm_bindgen_derive::try_from_js_option::<
616 perspective_js::Client,
617 >(jstable)
618 {
619 // INERT: register the client only — never rebind or
620 // reset the active panel (its table is preserved).
621 // Panels bind their client lazily at table-resolution
622 // time (`Workspace::resolve_client_for_table`).
623 let owned_window = !load.is_superseded();
624 let discard = if owned_window && notify.is_some() {
625 match workspace.take_reserved() {
626 Some(panel) => Some(Discard::Unclaimed(panel)),
627 // The one-shot claim read: a table-less
628 // `restore` claimed the reservation, and no
629 // table has bound nor is pending — evict the
630 // panel `CREATE_REQUIRES_TABLE` forbids.
631 None if workspace
632 .resolve_claim()
633 .is_some_and(|has_table| !has_table)
634 && session.get_table().is_none()
635 && session.pending_table().is_none() =>
636 {
637 let evicted = renderer
638 .slot_name()
639 .map(PanelId::from)
640 .and_then(|id| workspace.remove_panel(&id));
641
642 if evicted.is_some()
643 && let Some(notify) = ¬ify
644 {
645 notify.emit(());
646 }
647
648 evicted.map(Discard::Evicted)
649 },
650 None => None,
651 }
652 } else {
653 None
654 };
655
656 workspace.set_default_client(client.get_client().clone());
657 Ok(discard)
658 } else {
659 Err(ApiError::new("Invalid argument"))
660 }
661 })
662 .await
663 };
664
665 match result {
666 Err(e) => {
667 if let Some(notify) = ¬ify {
668 place_reserved(&workspace, notify, true);
669 }
670
671 session.set_error(false, e.clone()).await?;
672 Err(e)
673 },
674 Ok(Some(Discard::Unclaimed(panel))) => {
675 eject_panel(panel, Disposal::Reject).await?;
676 Ok(None)
677 },
678 Ok(Some(Discard::Evicted(panel))) => {
679 eject_panel(panel, Disposal::Resolve).await?;
680 Err(ApiError::new(CREATE_REQUIRES_TABLE))
681 },
682 Ok(None) => Ok(None),
683 }
684 })
685 }
686 });
687
688 Ok(ApiFuture::new_throttled(ticket.settle()))
689 }
690
691 /// Delete all internal [`View`]s and all associated state, rendering this
692 /// `<perspective-viewer>` unusable and freeing all associated resources.
693 /// Does not delete any supplied [`Table`] (as this is constructed by the
694 /// callee).
695 ///
696 /// Calling _any_ method on a `<perspective-viewer>` after [`Self::delete`]
697 /// will throw.
698 ///
699 /// <div class="warning">
700 ///
701 /// Allowing a `<perspective-viewer>` to be garbage-collected
702 /// without calling [`PerspectiveViewerElement::delete`] will leak WASM
703 /// memory!
704 ///
705 /// </div>
706 ///
707 /// # JavaScript Examples
708 ///
709 /// ```javascript
710 /// await viewer.delete();
711 /// ```
712 pub fn delete(self) -> ApiFuture<()> {
713 let subs = std::mem::take(&mut *self.hosted_table_subs.borrow_mut());
714 let teardown = delete_all(&self.workspace, &self.root);
715 ApiFuture::new(async move {
716 for (client, id) in subs {
717 let _ = client.remove_hosted_tables_update(id).await;
718 }
719
720 teardown.await
721 })
722 }
723
724 /// Remove a [`Client`] from this `<perspective-viewer>` and dispose every
725 /// panel bound to it (each panel's `View` is deleted and its `Table`
726 /// reference released).
727 ///
728 /// # Arguments
729 ///
730 /// - `options` - An optional `{client?: string}` dict naming the client to
731 /// eject; the active panel's client when omitted.
732 ///
733 /// # JavaScript Examples
734 ///
735 /// ```javascript
736 /// await viewer.eject();
737 /// await viewer.eject({client: "remote"});
738 /// ```
739 pub fn eject(&mut self, options: Option<JsClientOptions>) -> ApiFuture<()> {
740 let ClientOptions { client } = match parse_options(options) {
741 Ok(x) => x,
742 Err(x) => return ApiFuture::new_err(x),
743 };
744
745 // Default target: the active panel's client, or — when the active panel
746 // is unbound (`load(Client)` is now inert) — the default client.
747 let Some(target) = client
748 .or_else(|| {
749 self.workspace
750 .active_client()
751 .map(|c| c.get_name().to_owned())
752 })
753 .or_else(|| {
754 self.workspace
755 .default_client()
756 .map(|c| c.get_name().to_owned())
757 })
758 else {
759 return ApiFuture::new_throttled(async move { Ok(()) });
760 };
761
762 let ids = self.workspace.panels_for_client(&target);
763
764 // The target client backs EVERY panel — reset the element to its
765 // pre-`load` state (dropping the client with it), as a `Workspace`
766 // must always keep at least one panel.
767 if !ids.is_empty() && ids.len() == self.workspace.panel_ids().len() {
768 let mut state = Self::new_from_shadow(
769 self.elem.clone(),
770 self.elem.shadow_root().unwrap().unchecked_into(),
771 );
772
773 std::mem::swap(self, &mut state);
774 return ApiFuture::new_throttled(state.delete());
775 }
776
777 eject_client_panels(&self.workspace, &self.root, target, ids)
778 }
779
780 /// Get a [`View`] for a panel of this viewer, as currently configured by
781 /// the user.
782 ///
783 /// # Arguments
784 ///
785 /// - `mode` - which [`View`] to return:
786 /// - `"live"` (default) - the panel's own [`View`], which the viewer
787 /// replaces on every config change and deletes when auto-paused or
788 /// [`PerspectiveViewerElement::delete`]d, so calls on it may fail with
789 /// "View already deleted", it must never be deleted by the caller, and
790 /// it rejects with `No View for panel "<name>"` while none is bound
791 /// (auto-paused or not yet rendered).
792 /// - `"clone"` - a new [`View`] built from the panel's effective config
793 /// (global filter included), independent of the render lifecycle, which
794 /// the caller owns and must [`View::delete`].
795 /// - `"auto"` - `"live"` when the panel has a bound [`View`], else
796 /// `"clone"`.
797 /// - `panel` - the target panel, or the active panel when omitted.
798 ///
799 /// Plugins which rely on the live [`View`] for their
800 /// [`HTMLPerspectiveViewerPluginElement::draw`] implementations should
801 /// treat "View already deleted" as a _cancellation_ by silently aborting.
802 ///
803 /// # JavaScript Examples
804 ///
805 /// ```javascript
806 /// const view = await viewer.getView();
807 /// ```
808 ///
809 /// Export every panel without borrowing the viewer's own [`View`]:
810 ///
811 /// ```javascript
812 /// for (const panel of viewer.getPanelNames()) {
813 /// const view = await viewer.getView({ panel, mode: "clone" });
814 /// const arrow = await view.to_arrow();
815 /// await view.delete();
816 /// }
817 /// ```
818 #[wasm_bindgen]
819 pub fn getView(&self, options: Option<JsGetViewOptions>) -> ApiFuture<View> {
820 let this = self.clone();
821 ApiFuture::new(async move {
822 let GetViewOptions { mode, panel: name } = parse_options(options)?;
823 let panel = this.resolve_panel(name)?;
824 match (mode.unwrap_or_default(), panel.session.get_view()) {
825 (GetViewMode::Live | GetViewMode::Auto, Some(view)) => Ok(view.into()),
826 (GetViewMode::Live, None) => {
827 Err(format!("No View for panel \"{}\"", panel.id.as_str()).into())
828 },
829 (GetViewMode::Clone, _) | (GetViewMode::Auto, None) => {
830 Ok(panel.session.create_detached_view().await?.into())
831 },
832 }
833 })
834 }
835
836 /// Get a copy of the [`ViewConfig`] for the current [`View`]. This is
837 /// non-blocking as it does not need to access the plugin (unlike
838 /// [`PerspectiveViewerElement::save`]), and also makes no API calls to the
839 /// server (unlike [`PerspectiveViewerElement::getView`] followed by
840 /// [`View::get_config`])
841 #[wasm_bindgen]
842 pub fn getViewConfig(&self, options: Option<JsPanelOptions>) -> ApiFuture<JsViewConfig> {
843 let this = self.clone();
844 ApiFuture::new(async move {
845 let PanelOptions { panel: name } = parse_options(options)?;
846 let panel = this.resolve_panel(name)?;
847 let config = if let Some(ctx) = panel.renderer.render_context() {
848 (*ctx.view_config).clone()
849 } else if let Some(rendered) = panel.session.get_rendered_view_config() {
850 (*rendered).clone()
851 } else {
852 panel.session.get_view_config().clone()
853 };
854
855 Ok(JsValue::from_serde_ext(&config)?.unchecked_into())
856 })
857 }
858
859 /// Get the underlying [`Table`] for this viewer (as passed to
860 /// [`PerspectiveViewerElement::load`] or as the `table` field to
861 /// [`PerspectiveViewerElement::restore`]).
862 ///
863 /// # Arguments
864 ///
865 /// - `wait_for_table` - whether to wait for
866 /// [`PerspectiveViewerElement::load`] to be called, or fail immediately
867 /// if [`PerspectiveViewerElement::load`] has not yet been called.
868 ///
869 /// # JavaScript Examples
870 ///
871 /// ```javascript
872 /// const table = await viewer.getTable();
873 /// ```
874 #[wasm_bindgen]
875 pub fn getTable(&self, options: Option<JsGetTableOptions>) -> ApiFuture<Table> {
876 let this = self.clone();
877 ApiFuture::new(async move {
878 let GetTableOptions {
879 wait: wait_for_table,
880 panel: name,
881 } = parse_options(options)?;
882 let panel = this.resolve_panel(name)?;
883 if !wait_for_table.unwrap_or_default()
884 && let Some(ctx) = panel.renderer.render_context()
885 {
886 return Ok(ctx.table.clone().into());
887 }
888
889 let session = panel.session;
890 match session.get_table() {
891 Some(table) => Ok(table.into()),
892 None if !wait_for_table.unwrap_or_default() => Err("No `Table` set".into()),
893 None => {
894 session.table_loaded.read_next().await?;
895 Ok(session.get_table().ok_or("No `Table` set")?.into())
896 },
897 }
898 })
899 }
900
901 /// Get the underlying [`Client`] for this viewer (as passed to, or
902 /// associated with the [`Table`] passed to,
903 /// [`PerspectiveViewerElement::load`]).
904 ///
905 /// # Arguments
906 ///
907 /// - `wait_for_client` - whether to wait for
908 /// [`PerspectiveViewerElement::load`] to be called, or fail immediately
909 /// if [`PerspectiveViewerElement::load`] has not yet been called.
910 ///
911 /// # JavaScript Examples
912 ///
913 /// ```javascript
914 /// const client = await viewer.getClient();
915 /// ```
916 #[wasm_bindgen]
917 pub fn getClient(
918 &self,
919 options: Option<JsGetClientOptions>,
920 ) -> ApiFuture<perspective_js::Client> {
921 let this = self.clone();
922 ApiFuture::new(async move {
923 let GetClientOptions {
924 wait: wait_for_client,
925 panel: name,
926 } = parse_options(options)?;
927 let panel = this.resolve_panel(name)?;
928 if !wait_for_client.unwrap_or_default()
929 && let Some(ctx) = panel.renderer.render_context()
930 {
931 return Ok(ctx.client.clone().into());
932 }
933
934 let session = panel.session;
935 match session.get_client() {
936 Some(client) => Ok(client.into()),
937 None if !wait_for_client.unwrap_or_default() => Err("No `Client` set".into()),
938 None => {
939 session.table_loaded.read_next().await?;
940 Ok(session.get_client().ok_or("No `Client` set")?.into())
941 },
942 }
943 })
944 }
945
946 /// Get render statistics. Some fields of the returned stats object are
947 /// relative to the last time [`PerspectiveViewerElement::getRenderStats`]
948 /// was called, ergo calling this method resets these fields.
949 ///
950 /// # JavaScript Examples
951 ///
952 /// ```javascript
953 /// const {virtual_fps, actual_fps} = await viewer.getRenderStats();
954 /// ```
955 #[wasm_bindgen]
956 pub fn getRenderStats(&self, options: Option<JsPanelOptions>) -> ApiResult<JsValue> {
957 let PanelOptions { panel: name } = parse_options(options)?;
958 let panel = self.resolve_panel(name)?;
959 Ok(JsValue::from_serde_ext(
960 &panel.renderer.render_timer().get_stats(),
961 )?)
962 }
963
964 /// Flush any pending modifications to this `<perspective-viewer>`. Since
965 /// `<perspective-viewer>`'s API is almost entirely `async`, it may take
966 /// some milliseconds before any user-initiated changes to the [`View`]
967 /// affects the rendered element. If you want to make sure all pending
968 /// actions have been rendered, call and await [`Self::flush`].
969 ///
970 /// [`Self::flush`] will resolve immediately if there is no [`Table`] set.
971 ///
972 /// # JavaScript Examples
973 ///
974 /// In this example, [`Self::restore`] is called without `await`, but the
975 /// eventual render which results from this call can still be awaited by
976 /// immediately awaiting [`Self::flush`] instead.
977 ///
978 /// ```javascript
979 /// viewer.restore(config);
980 /// await viewer.flush();
981 /// ```
982 pub fn flush(&self) -> ApiFuture<()> {
983 let workspace = self.workspace.clone();
984 let presentation = self.presentation.clone();
985 ApiFuture::new_throttled(async move {
986 loop {
987 workspace.effects().settle().await;
988 let panels = workspace
989 .reserved_panel()
990 .into_iter()
991 .chain(workspace.panels())
992 .collect::<Vec<_>>();
993
994 let mut fulfilled = false;
995 for panel in &panels {
996 panel.session.settle_ops().await;
997 panel.renderer.clone().with_lock(async { Ok(()) }).await?;
998 panel.renderer.clone().with_lock(async { Ok(()) }).await?;
999 panel.session.settle_dispatches().await?;
1000 if !global::document().hidden()
1001 && presentation.is_visible()
1002 && !panel.renderer.is_plugin_activated()?
1003 && panel.session.get_error().is_none()
1004 && panel.renderer.failure().is_none()
1005 && matches!(panel.session.has_table(), Some(TableLoadState::Loaded))
1006 {
1007 set_panel_paused(&panel.session, &panel.renderer, &presentation, true)
1008 .await?;
1009 if !panel.renderer.is_plugin_activated()? {
1010 just_render(&panel.session, &panel.renderer)?.await?;
1011 }
1012
1013 fulfilled = true;
1014 }
1015 }
1016
1017 if !fulfilled && workspace.effects().is_empty() {
1018 return Ok(());
1019 }
1020 }
1021 })
1022 }
1023
1024 /// Restore a single panel from a full/partial
1025 /// [`perspective_js::JsViewConfig`] (its user-configurable state, including
1026 /// the `Table` name) — the active panel, or a specific panel via the
1027 /// optional `{panel}` selector.
1028 ///
1029 /// If `panel` names no existing panel, a NEW panel is created with that id
1030 /// and the config restored into it (an upsert). Creation REQUIRES a
1031 /// `table` — the same rule [`Self::addPanel`] enforces in its argument
1032 /// type — and a would-create call without one REJECTS before any state
1033 /// (including `settings`) is applied: with no panel to target and no
1034 /// `table`, the patch has no data arrival path. In particular, on an
1035 /// element with zero panels every `restore` must carry a `table`.
1036 ///
1037 /// On an empty element with a pending [`Self::load`] whose payload is not
1038 /// yet classified, the active-target form (no `panel`) instead claims and
1039 /// restores into that load's reserved first panel — see [`Self::load`].
1040 ///
1041 /// This restores a SINGLE panel; a workspace config (with a `panels`
1042 /// map) must be applied via [`Self::restoreWorkspace`] — its `panels` /
1043 /// `layout` keys are ignored here.
1044 ///
1045 /// One of the best ways to use [`Self::restore`] is by first configuring
1046 /// a `<perspective-viewer>` as you wish, then using either the `Debug`
1047 /// panel or "Copy" -> "config.json" from the toolbar menu to snapshot
1048 /// the [`Self::restore`] argument as JSON.
1049 ///
1050 /// # Arguments
1051 ///
1052 /// - `update` - The config to restore to, as returned by [`Self::save`] in
1053 /// either "json", "string" or "arraybuffer" format.
1054 /// - `options.panel` - The panel to target, or the active panel when
1055 /// omitted.
1056 /// - `options.suppress_errors` - when `true`, a config that was applied and
1057 /// then failed to render only rejects the returned `Promise`, without
1058 /// raising the panel's visible error state.
1059 /// - `options.wait_for_table` - when `true`, a `table` no loaded client
1060 /// hosts yet leaves the panel empty and pending until the table is
1061 /// created, instead of the default error.
1062 ///
1063 /// # JavaScript Examples
1064 ///
1065 /// Loads a default plugin for the table named `"superstore"`:
1066 ///
1067 /// ```javascript
1068 /// await viewer.restore({table: "superstore"});
1069 /// ```
1070 ///
1071 /// Apply a `group_by` to the same `viewer` element, without
1072 /// modifying/resetting other fields - you can omit the `table` field,
1073 /// this has already been set once and is not modified:
1074 ///
1075 /// ```javascript
1076 /// await viewer.restore({group_by: ["State"]});
1077 /// ```
1078 pub fn restore(
1079 &self,
1080 update: JsViewerConfigUpdate,
1081 options: Option<JsRestoreOptions>,
1082 ) -> JsVoidPromise {
1083 let effect = self.workspace.effects().guard();
1084 let this = self.clone();
1085 let fut = ApiFuture::new_throttled(async move {
1086 let RestoreOptions {
1087 panel: name,
1088 suppress_errors,
1089 wait_for_table,
1090 } = parse_options(options)?;
1091
1092 let errors = if suppress_errors.unwrap_or_default() {
1093 RestoreErrors::Suppress
1094 } else {
1095 RestoreErrors::Publish
1096 };
1097
1098 let missing = MissingTable::from_wait(wait_for_table);
1099
1100 let _effect = effect;
1101 let id = name.map(PanelId::from);
1102 let mut update = ViewerConfigUpdate::decode(&update)?;
1103 let settings = std::mem::replace(&mut update.settings, OptionalUpdate::Missing);
1104 enum Target {
1105 Existing { panel: Panel, active: bool },
1106 Claimed(Panel),
1107 Create(Box<ViewerConfigInitial>),
1108 }
1109
1110 crate::tasks::resolve_table_ref(&this.workspace, &mut update, missing).await?;
1111 let notify = this.layout_changed_notify();
1112 let target = match this.workspace.panel_or_active(id.as_ref()) {
1113 // An existing (or the active) panel — update it in place.
1114 Some(panel) => {
1115 let active = this.workspace.active_id().as_ref() == Some(&panel.id);
1116 Target::Existing { panel, active }
1117 },
1118 None => {
1119 let has_table = matches!(&update.table, OptionalUpdate::Update(_));
1120 match id
1121 .is_none()
1122 .then(|| place_reserved(&this.workspace, ¬ify, has_table))
1123 .flatten()
1124 {
1125 Some(panel) => Target::Claimed(panel),
1126 None => Target::Create(Box::new(ViewerConfigInitial::try_from(
1127 std::mem::take(&mut update),
1128 )?)),
1129 }
1130 },
1131 };
1132
1133 let settings_toggle: Option<futures::future::LocalBoxFuture<'static, ApiResult<()>>> =
1134 (!matches!(settings, OptionalUpdate::Missing)).then(|| {
1135 let this = this.clone();
1136 Box::pin(async move {
1137 let (sender, receiver) = channel::<ApiResult<JsValue>>();
1138 this.root.borrow().as_ref().into_apierror()?.send_message(
1139 PerspectiveViewerMsg::ToggleSettingsInit(
1140 Some(settings),
1141 false,
1142 Some(sender),
1143 ),
1144 );
1145
1146 receiver.await.map_err(|_| ApiError::new("Cancelled"))??;
1147 Ok(())
1148 })
1149 as futures::future::LocalBoxFuture<'static, ApiResult<()>>
1150 });
1151
1152 match target {
1153 Target::Existing { panel, active } => {
1154 restore_panel(
1155 &panel.session,
1156 &panel.renderer,
1157 &this.presentation,
1158 &this.workspace,
1159 RestoreMode::Existing { active },
1160 update,
1161 errors,
1162 missing,
1163 settings_toggle,
1164 )
1165 .await
1166 },
1167 Target::Claimed(panel) => {
1168 restore_panel(
1169 &panel.session,
1170 &panel.renderer,
1171 &this.presentation,
1172 &this.workspace,
1173 RestoreMode::Existing { active: true },
1174 update,
1175 errors,
1176 missing,
1177 settings_toggle,
1178 )
1179 .await
1180 },
1181 Target::Create(config) => {
1182 if let Some(settings_toggle) = settings_toggle {
1183 settings_toggle.await?;
1184 }
1185
1186 create_panel(
1187 &this.elem,
1188 &this.presentation,
1189 &this.workspace,
1190 ¬ify,
1191 id,
1192 *config,
1193 None,
1194 missing,
1195 )
1196 .await?;
1197 Ok(())
1198 },
1199 }
1200 });
1201
1202 js_sys::Promise::from(fut).unchecked_into()
1203 }
1204
1205 /// Apply a [`WorkspaceConfigUpdate`] to the element — the multi-panel
1206 /// counterpart of [`Self::restore`] and like it a field-wise update, where
1207 /// an absent field is unchanged, `null` resets and a value replaces.
1208 ///
1209 /// # Arguments
1210 ///
1211 /// - `update` - The workspace config update: a [`Self::saveWorkspace`]
1212 /// token replaces every panel (`panels` is the whole set), while a token
1213 /// without `panels` keeps them and `layout`, `active` and `masters` name
1214 /// them by their existing ids.
1215 /// - `options.wait_for_table` - when `true`, a panel whose `table` no
1216 /// loaded client hosts yet is created empty and pending until the table
1217 /// is created, instead of the default error.
1218 ///
1219 /// # JavaScript Examples
1220 ///
1221 /// ```javascript
1222 /// await viewer.restoreWorkspace(await otherViewer.saveWorkspace());
1223 /// ```
1224 ///
1225 /// Cross-filter the current panels without re-creating them:
1226 ///
1227 /// ```javascript
1228 /// await viewer.restoreWorkspace({ global_filters: [["Region", "==", "West"]] });
1229 /// ```
1230 pub fn restoreWorkspace(
1231 &self,
1232 update: JsWorkspaceConfigUpdate,
1233 options: Option<JsRestoreWorkspaceOptions>,
1234 ) -> JsVoidPromise {
1235 let effect = self.workspace.effects().guard();
1236 let this = self.clone();
1237 let fut = ApiFuture::new(async move {
1238 let _effect = effect;
1239 let RestoreWorkspaceOptions { wait_for_table } = parse_options(options)?;
1240 let missing = MissingTable::from_wait(wait_for_table);
1241 let (contents, eject_tasks, derived_tables) = sync_update_panels(&this, update)?;
1242
1243 // The outgoing panels' ejects must settle BEFORE the derived
1244 // pass: a stale derivation is undeletable (and so cannot be
1245 // redefined or pruned) while an ejecting panel still holds a
1246 // `View` on it. Their errors are surfaced LAST, after the
1247 // replacement set is in place.
1248 let ejected = join_all(eject_tasks)
1249 .await
1250 .into_iter()
1251 .collect::<ApiResult<Vec<_>>>();
1252
1253 if let Some(derived_tables) = derived_tables.clone() {
1254 crate::tasks::apply_derived_tables(&this.workspace, derived_tables).await?;
1255 }
1256
1257 let fresh = contents
1258 .iter()
1259 .map(|(id, ..)| id.clone())
1260 .collect::<Vec<_>>();
1261 let results = join_all(contents.into_iter().map(|(id, session, renderer, config)| {
1262 let presentation = this.presentation.clone();
1263 let workspace = this.workspace.clone();
1264 let notify = this.layout_changed_notify();
1265 async move {
1266 let result = restore_panel(
1267 &session,
1268 &renderer,
1269 &presentation,
1270 &workspace,
1271 RestoreMode::Fresh,
1272 config,
1273 crate::tasks::RestoreErrors::Publish,
1274 missing,
1275 None,
1276 )
1277 .await;
1278
1279 if discard_rejected(&workspace, &id, &session, &result).await? {
1280 notify.emit(());
1281 }
1282
1283 result?;
1284 if workspace.is_master(&id) {
1285 set_edit_mode(&session, &renderer, "SELECT_ROW_TREE");
1286 }
1287
1288 Ok(())
1289 }
1290 }))
1291 .await;
1292
1293 results.into_iter().collect::<ApiResult<Vec<_>>>()?;
1294 this.rerender_retained_overlays(&fresh).await?;
1295 if let Some(derived_tables) = derived_tables {
1296 let keep = derived_tables.keys().cloned().collect();
1297 crate::tasks::prune_derived(&this.workspace, &keep).await?;
1298 }
1299
1300 ejected?;
1301 Ok(())
1302 });
1303
1304 js_sys::Promise::from(fut).unchecked_into()
1305 }
1306
1307 /// If this element is in an _errored_ state, this method will clear it and
1308 /// re-render. Calling this method is equivalent to clicking the error reset
1309 /// button in the UI.
1310 pub fn resetError(&self) -> ApiFuture<()> {
1311 let Some(panel) = self.workspace.active_panel() else {
1312 return ApiFuture::new_throttled(async move { Ok(()) });
1313 };
1314
1315 let reset_effect = self.workspace.effects().guard();
1316 let reset = panel.session.submit(OpKind::Restore { update: None }, {
1317 let session = panel.session.clone();
1318 move |_ctx| {
1319 Box::pin(async move {
1320 session.reset(ResetOptions::default()).await?;
1321 Ok(None)
1322 })
1323 }
1324 });
1325
1326 ApiFuture::spawn(async move {
1327 let _effect = reset_effect;
1328 reset.settle().await
1329 });
1330
1331 let effect = self.workspace.effects().guard();
1332 ApiFuture::new_throttled(async move {
1333 let _effect = effect;
1334 apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?.await?;
1335 Ok(())
1336 })
1337 }
1338
1339 /// Save a single panel's user-configurable state as a [`ViewerConfig`], one
1340 /// which can be restored via [`Self::restore`] — the active panel, or a
1341 /// specific panel via the optional `{panel}` selector.
1342 ///
1343 /// This saves a SINGLE panel; to snapshot the ENTIRE element (every panel +
1344 /// layout + cross-filters) use [`Self::saveWorkspace`].
1345 ///
1346 /// # Arguments
1347 ///
1348 /// - `options` - An optional `{panel?: string}`; the panel to save, or the
1349 /// active panel when omitted.
1350 ///
1351 /// # JavaScript Examples
1352 ///
1353 /// Get the current `group_by` setting:
1354 ///
1355 /// ```javascript
1356 /// const {group_by} = await viewer.save();
1357 /// ```
1358 ///
1359 /// Reset workflow attached to an external button `myResetButton`:
1360 ///
1361 /// ```javascript
1362 /// const token = await viewer.save();
1363 /// myResetButton.addEventListener("click", async () => {
1364 /// await viewer.restore(token);
1365 /// });
1366 /// ```
1367 pub fn save(&self, options: Option<JsPanelOptions>) -> JsViewerConfigPromise {
1368 let this = self.clone();
1369 let fut = ApiFuture::new(async move {
1370 let PanelOptions { panel: name } = parse_options(options)?;
1371 this.workspace.effects().settle().await;
1372 let panel = this.resolve_panel(name)?;
1373 let viewer_config = panel
1374 .renderer
1375 .clone()
1376 .with_lock(async {
1377 get_viewer_config(&panel.session, &panel.renderer, &this.presentation).await
1378 })
1379 .await?;
1380
1381 viewer_config
1382 .map_table(|name| this.workspace.inline_table_ref(&name))
1383 .encode()
1384 });
1385
1386 js_sys::Promise::from(fut).unchecked_into()
1387 }
1388
1389 /// Save the ENTIRE element to a [`WorkspaceConfig`]
1390 /// (`{version, active?, layout, panels, palette?, ...}`) — the
1391 /// multi-panel counterpart of [`Self::save`]. Unlike [`Self::save`]
1392 /// (which emits a single `ViewerConfig` for one panel), this ALWAYS
1393 /// emits the workspace format, restorable via
1394 /// [`Self::restoreWorkspace`].
1395 ///
1396 /// # JavaScript Examples
1397 ///
1398 /// ```javascript
1399 /// const token = await viewer.saveWorkspace();
1400 /// await viewer.restoreWorkspace(token);
1401 /// ```
1402 pub fn saveWorkspace(
1403 &self,
1404 options: Option<JsSaveWorkspaceOptions>,
1405 ) -> JsWorkspaceConfigPromise {
1406 let this = self.clone();
1407 let fut = ApiFuture::new(async move {
1408 let SaveWorkspaceOptions { full_palette } = parse_options(options)?;
1409 Self::workspace_config(this, full_palette.unwrap_or(false)).await
1410 });
1411
1412 js_sys::Promise::from(fut).unchecked_into()
1413 }
1414
1415 /// Download this viewer's internal [`View`] data via a browser download
1416 /// event.
1417 ///
1418 /// # Arguments
1419 ///
1420 /// - `method` - The `ExportMethod` to use to render the data to download.
1421 ///
1422 /// # JavaScript Examples
1423 ///
1424 /// ```javascript
1425 /// myDownloadButton.addEventListener("click", async () => {
1426 /// await viewer.download();
1427 /// })
1428 /// ```
1429 pub fn download(&self, options: Option<JsExportOptions>) -> ApiFuture<()> {
1430 let this = self.clone();
1431 ApiFuture::new_throttled(async move {
1432 let ExportOptions {
1433 method,
1434 panel: name,
1435 } = parse_options(options)?;
1436 let method = method.map(|m| JsString::from(m.as_str()));
1437 let method = if let Some(method) = method
1438 .map(|x| x.unchecked_into())
1439 .map(serde_wasm_bindgen::from_value)
1440 {
1441 method?
1442 } else {
1443 ExportMethod::Csv
1444 };
1445
1446 let panel = this.resolve_panel(name)?;
1447 let blob = export_method_to_blob(
1448 &panel.session,
1449 &panel.renderer,
1450 &this.presentation,
1451 &this.workspace,
1452 method,
1453 )
1454 .await?;
1455 let is_chart = panel.renderer.is_chart();
1456 download(
1457 format!("untitled{}", method.as_filename(is_chart)).as_ref(),
1458 &blob,
1459 )
1460 })
1461 }
1462
1463 /// Exports this viewer's internal [`View`] as a JavaSript data, the
1464 /// exact type of which depends on the `method` but defaults to `String`
1465 /// in CSV format.
1466 ///
1467 /// This method is only really useful for the `"plugin"` method, which
1468 /// will use the configured plugin's export (e.g. PNG for
1469 /// `@perspective-dev/viewer-charts`). Otherwise, prefer to call the
1470 /// equivalent method on the underlying [`View`] directly.
1471 ///
1472 /// # Arguments
1473 ///
1474 /// - `method` - The `ExportMethod` to use to render the data to download.
1475 ///
1476 /// # JavaScript Examples
1477 ///
1478 /// ```javascript
1479 /// const data = await viewer.export("plugin");
1480 /// ```
1481 pub fn export(&self, options: Option<JsExportOptions>) -> ApiFuture<JsValue> {
1482 let this = self.clone();
1483 ApiFuture::new(async move {
1484 let ExportOptions {
1485 method,
1486 panel: name,
1487 } = parse_options(options)?;
1488
1489 let method = method.map(|m| JsString::from(m.as_str()));
1490 let method = if let Some(method) = method
1491 .map(|x| x.unchecked_into())
1492 .map(serde_wasm_bindgen::from_value)
1493 {
1494 method?
1495 } else {
1496 ExportMethod::Csv
1497 };
1498
1499 let panel = this.resolve_panel(name)?;
1500 export_method_to_jsvalue(
1501 &panel.session,
1502 &panel.renderer,
1503 &this.presentation,
1504 &this.workspace,
1505 method,
1506 )
1507 .await
1508 })
1509 }
1510
1511 /// Copy this viewer's `View` or `Table` data as CSV to the system
1512 /// clipboard.
1513 ///
1514 /// # Arguments
1515 ///
1516 /// - `method` - The `ExportMethod` (serialized as a `String`) to use to
1517 /// render the data to the Clipboard.
1518 ///
1519 /// # JavaScript Examples
1520 ///
1521 /// ```javascript
1522 /// myDownloadButton.addEventListener("click", async () => {
1523 /// await viewer.copy();
1524 /// })
1525 /// ```
1526 pub fn copy(&self, options: Option<JsExportOptions>) -> ApiFuture<()> {
1527 let this = self.clone();
1528 ApiFuture::new_throttled(async move {
1529 let ExportOptions {
1530 method,
1531 panel: name,
1532 } = parse_options(options)?;
1533
1534 let method = method.map(|m| JsString::from(m.as_str()));
1535 let method = if let Some(method) = method
1536 .map(|x| x.unchecked_into())
1537 .map(serde_wasm_bindgen::from_value)
1538 {
1539 method?
1540 } else {
1541 ExportMethod::Csv
1542 };
1543
1544 let panel = this.resolve_panel(name)?;
1545 let js_task = export_method_to_blob(
1546 &panel.session,
1547 &panel.renderer,
1548 &this.presentation,
1549 &this.workspace,
1550 method,
1551 );
1552 copy_to_clipboard(js_task, MimeType::TextPlain).await
1553 })
1554 }
1555
1556 /// Reset a panel's `ViewerConfig` to its data-relative default.
1557 ///
1558 /// Without a `panel`, this is ELEMENT-LEVEL: EVERY panel is reset and the
1559 /// cross-filter overlay cleared (symmetric with
1560 /// [`Self::saveWorkspace`] / [`Self::restoreWorkspace`]). With `{panel}`,
1561 /// only that panel is reset — the other panels and the overlay are left
1562 /// untouched.
1563 ///
1564 /// # Arguments
1565 ///
1566 /// - `reset_all` - If set, will clear expressions and column settings as
1567 /// well, and tear down the element's ENTIRE derived-table state: every
1568 /// panel bound to a derived `Table` is released to its root table, and
1569 /// every derivation (pending ones included) is deleted.
1570 /// - `options` - An optional `{panel?: string}`; the panel to reset, or
1571 /// every panel when omitted.
1572 ///
1573 /// # JavaScript Examples
1574 ///
1575 /// ```javascript
1576 /// await viewer.reset(); // every panel
1577 /// await viewer.reset(true, {panel: "p1"}); // just "p1", + expressions
1578 /// ```
1579 pub fn reset(&self, reset_all: Option<bool>, options: Option<JsPanelOptions>) -> ApiFuture<()> {
1580 let effect = self.workspace.effects().guard();
1581 let this = self.clone();
1582 let all = reset_all.unwrap_or_default();
1583 ApiFuture::new_throttled(async move {
1584 let PanelOptions { panel: name } = parse_options(options)?;
1585 let _effect = effect;
1586 let (completion, receiver) = Completion::new();
1587 {
1588 let root = this.root.borrow();
1589 let app = root.as_ref().ok_or("Already deleted")?;
1590 match name {
1591 // Element-level: reset every panel + the cross-filter overlay.
1592 None => {
1593 tracing::debug!("Resetting config");
1594 app.send_message(PerspectiveViewerMsg::Reset(all, Some(completion)));
1595 },
1596 // A single named panel; errors if the panel doesn't exist.
1597 Some(name) => {
1598 let panel = this.resolve_panel(Some(name))?;
1599 tracing::debug!("Resetting config ({})", panel.id);
1600 app.send_message(PerspectiveViewerMsg::ResetPanel(
1601 Some(panel.id.to_string()),
1602 all,
1603 Some(completion),
1604 ));
1605 },
1606 }
1607 }
1608
1609 receiver.await.map_err(|_| ApiError::new("Cancelled"))?
1610 })
1611 }
1612
1613 /// Recalculate the viewer's dimensions and redraw.
1614 ///
1615 /// Use this method to tell `<perspective-viewer>` its dimensions have
1616 /// changed when auto-size mode has been disabled via [`Self::setAutoSize`].
1617 /// [`Self::resize`] resolves when the resize-initiated redraw of this
1618 /// element has completed.
1619 ///
1620 /// # Arguments
1621 ///
1622 /// - `options` - An optional object with the following fields:
1623 /// - `dimensions` - An optional object `{width, height}` providing
1624 /// explicit size hints (in pixels) for the plugin container. When
1625 /// provided, the plugin element will be temporarily sized to these
1626 /// dimensions during resize, then reset.
1627 ///
1628 /// # JavaScript Examples
1629 ///
1630 /// ```javascript
1631 /// await viewer.resize()
1632 /// await viewer.resize({dimensions: {width: 800, height: 600}})
1633 /// ```
1634 #[wasm_bindgen]
1635 pub fn resize(&self, options: Option<JsValue>) -> ApiFuture<()> {
1636 let opts: ResizeOptions = options
1637 .map(|v| v.into_serde_ext())
1638 .transpose()
1639 .unwrap_or_default()
1640 .unwrap_or_default();
1641
1642 let effect = self.workspace.effects().guard();
1643 let workspace = self.workspace.clone();
1644 ApiFuture::new_throttled(async move {
1645 let _effect = effect;
1646 // With zero panels there is nothing to resize; fan out to whatever
1647 // panels exist otherwise.
1648 let Some(panel) = workspace.active_panel() else {
1649 resize_visible_panels(&workspace).await;
1650 return Ok(());
1651 };
1652
1653 if !panel.renderer.is_plugin_activated()? {
1654 apply_and_render(&panel.session, &panel.renderer, ViewConfigUpdate::default())?
1655 .await?;
1656 } else if let Some(dims) = opts.dimensions {
1657 panel
1658 .renderer
1659 .resize_with_dimensions(dims.width, dims.height)
1660 .await?;
1661 } else {
1662 resize_visible_panels(&workspace).await;
1663 }
1664
1665 Ok(())
1666 })
1667 }
1668
1669 /// Sets the auto-size behavior of this component.
1670 ///
1671 /// When `true`, this `<perspective-viewer>` will register a
1672 /// `ResizeObserver` on itself and call [`Self::resize`] whenever its own
1673 /// dimensions change. However, when embedded in a larger application
1674 /// context, you may want to call [`Self::resize`] manually to avoid
1675 /// over-rendering; in this case auto-sizing can be disabled via this
1676 /// method. Auto-size behavior is enabled by default.
1677 ///
1678 /// # Arguments
1679 ///
1680 /// - `autosize` - Whether to enable `auto-size` behavior or not.
1681 ///
1682 /// # JavaScript Examples
1683 ///
1684 /// Disable auto-size behavior:
1685 ///
1686 /// ```javascript
1687 /// viewer.setAutoSize(false);
1688 /// ```
1689 #[wasm_bindgen]
1690 pub fn setAutoSize(&self, autosize: bool) {
1691 if autosize {
1692 let handle = Some(ResizeObserverHandle::new(
1693 &self.elem,
1694 &self.workspace,
1695 &self.presentation,
1696 &self.root,
1697 ));
1698 *self.resize_handle.borrow_mut() = handle;
1699 } else {
1700 *self.resize_handle.borrow_mut() = None;
1701 }
1702 }
1703
1704 /// Sets the auto-pause behavior of this component.
1705 ///
1706 /// When `true`, this `<perspective-viewer>` will skip rendering
1707 /// whenever it cannot be seen — tracked via an `IntersectionObserver`
1708 /// on itself (scrolled out of the viewport, `display: none`) combined
1709 /// with the document's page visibility (backgrounded browser tab,
1710 /// minimized window). Auto-pause is enabled by default.
1711 ///
1712 /// # Arguments
1713 ///
1714 /// - `autopause` Whether to enable `auto-pause` behavior or not.
1715 ///
1716 /// # JavaScript Examples
1717 ///
1718 /// Disable auto-size behavior:
1719 ///
1720 /// ```javascript
1721 /// viewer.setAutoPause(false);
1722 /// ```
1723 #[wasm_bindgen]
1724 pub fn setAutoPause(&self, autopause: bool) -> ApiFuture<()> {
1725 if autopause {
1726 let handle = Some(AutoPauseHandle::new(
1727 &self.elem,
1728 &self.presentation,
1729 &self.workspace,
1730 ));
1731
1732 *self.intersection_handle.borrow_mut() = handle;
1733 } else {
1734 *self.intersection_handle.borrow_mut() = None;
1735 let effect = self.workspace.effects().guard();
1736 let workspace = self.workspace.clone();
1737 let presentation = self.presentation.clone();
1738 return ApiFuture::new(async move {
1739 let _effect = effect;
1740 for id in workspace.panel_ids() {
1741 if let Some(panel) = workspace.panel(&id) {
1742 // A failed resume is already surfaced as that
1743 // panel's error state — don't let it abort the
1744 // remaining panels' resumes.
1745 let _ =
1746 set_panel_paused(&panel.session, &panel.renderer, &presentation, true)
1747 .await;
1748 }
1749 }
1750
1751 Ok(())
1752 });
1753 }
1754
1755 ApiFuture::new(async move { Ok(()) })
1756 }
1757
1758 /// Return a [`perspective_js::JsViewWindow`] for the currently selected
1759 /// region of the named panel, or the active panel when `panel` is omitted.
1760 #[wasm_bindgen]
1761 pub fn getSelection(&self, options: Option<JsPanelOptions>) -> ApiResult<Option<JsViewWindow>> {
1762 let PanelOptions { panel: name } = parse_options(options)?;
1763 let panel = self.resolve_panel(name)?;
1764 Ok(panel.renderer.get_selection().map(|x| x.into()))
1765 }
1766
1767 /// Set the selection [`perspective_js::JsViewWindow`] for the named panel,
1768 /// or the active panel when `panel` is omitted.
1769 #[wasm_bindgen]
1770 pub fn setSelection(
1771 &self,
1772 window: Option<JsViewWindow>,
1773 options: Option<JsPanelOptions>,
1774 ) -> ApiResult<()> {
1775 let PanelOptions { panel: name } = parse_options(options)?;
1776 let window = window.map(|x| x.into_serde_ext()).transpose()?;
1777 self.resolve_panel(name)?.renderer.set_selection(window);
1778 Ok(())
1779 }
1780
1781 /// Get this viewer's edit port for the named panel's [`Table`] (see
1782 /// [`Table::update`] for details on ports), or the active panel when
1783 /// `panel` is omitted.
1784 #[wasm_bindgen]
1785 pub fn getEditPort(&self, options: Option<JsPanelOptions>) -> ApiResult<f64> {
1786 let PanelOptions { panel: name } = parse_options(options)?;
1787 let panel = self.resolve_panel(name)?;
1788 let edit_port = if let Some(ctx) = panel.renderer.render_context() {
1789 ctx.edit_port
1790 } else {
1791 panel.session.metadata().get_edit_port()
1792 };
1793
1794 Ok(edit_port.ok_or("No `Table` loaded")?)
1795 }
1796
1797 /// Restyle all plugins from current document.
1798 ///
1799 /// <div class="warning">
1800 ///
1801 /// [`Self::restyleElement`] _must_ be called for many runtime changes to
1802 /// CSS properties to be reflected in an already-rendered
1803 /// `<perspective-viewer>`.
1804 ///
1805 /// </div>
1806 ///
1807 /// # JavaScript Examples
1808 ///
1809 /// ```javascript
1810 /// viewer.style = "--psp--color: red";
1811 /// await viewer.restyleElement();
1812 /// ```
1813 #[wasm_bindgen]
1814 pub fn restyleElement(&self) -> ApiFuture<JsValue> {
1815 clone!(self.workspace);
1816 let effect = workspace.effects().guard();
1817 ApiFuture::new(async move {
1818 let _effect = effect;
1819 for panel in workspace
1820 .panel_ids()
1821 .into_iter()
1822 .filter_map(|id| workspace.panel(&id))
1823 {
1824 panel.renderer.restyle_all().await?;
1825 }
1826
1827 Ok(JsValue::UNDEFINED)
1828 })
1829 }
1830
1831 #[wasm_bindgen]
1832 pub fn getThemes(&self) -> ApiFuture<JsValue> {
1833 clone!(self.presentation);
1834 ApiFuture::new(async move {
1835 let x = presentation
1836 .get_available_themes()
1837 .await?
1838 .iter()
1839 .cloned()
1840 .collect::<Vec<_>>();
1841
1842 Ok(JsValue::from(x))
1843 })
1844 }
1845
1846 /// Set the available theme names available in the status bar UI.
1847 ///
1848 /// Calling [`Self::resetThemes`] may cause the current theme to switch,
1849 /// if e.g. the new theme set does not contain the current theme.
1850 ///
1851 /// # JavaScript Examples
1852 ///
1853 /// Restrict `<perspective-viewer>` theme options to _only_ default light
1854 /// and dark themes, regardless of what is auto-detected from the page's
1855 /// CSS:
1856 ///
1857 /// ```javascript
1858 /// viewer.resetThemes(["Pro Light", "Pro Dark"])
1859 /// ```
1860 #[wasm_bindgen]
1861 pub fn resetThemes(&self, themes: Option<Box<[JsValue]>>) -> ApiFuture<JsValue> {
1862 clone!(self.workspace, self.presentation);
1863 let effect = workspace.effects().guard();
1864 ApiFuture::new(async move {
1865 let _effect = effect;
1866 // `None` (re-parse the document) must survive the conversion —
1867 // mapping BEFORE defaulting is what keeps that branch reachable
1868 // from JavaScript at all.
1869 let themes: Option<Vec<String>> = match themes {
1870 None => None,
1871 Some(themes) => themes.iter().map(|x| x.as_string()).collect(),
1872 };
1873
1874 let previous = presentation.active_theme_name_sync();
1875 let active = presentation.reset_themes(themes).await?;
1876 let available = presentation.get_available_themes().await?;
1877 for panel in workspace
1878 .panel_ids()
1879 .into_iter()
1880 .filter_map(|id| workspace.panel(&id))
1881 {
1882 let theme = panel.renderer.theme();
1883
1884 // A panel follows the host only when it was TRACKING it (it
1885 // holds the host's previous theme, which is how every panel
1886 // born without an explicit one starts — and what keeps the
1887 // active panel and the host in agreement), or when its own
1888 // theme has left the registry. A panel explicitly set to a
1889 // different, still-available theme is untouched: re-ordering
1890 // alone must repaint nothing.
1891 let stale = theme.as_ref().is_none_or(|x| !available.contains(x));
1892 if (stale || theme == previous) && theme != active {
1893 submit_theme(&panel.session, &panel.renderer, active.clone());
1894 if panel.renderer.needs_restyle() {
1895 panel.renderer.restyle_all().await?;
1896 }
1897 }
1898 }
1899
1900 presentation.publish_theme_config().await?;
1901 Ok(JsValue::UNDEFINED)
1902 })
1903 }
1904
1905 /// Determines the render throttling behavior. Can be an integer, for
1906 /// millisecond window to throttle render event; or, if `None`, adaptive
1907 /// throttling will be calculated from the measured render time of the
1908 /// last 5 frames.
1909 ///
1910 /// # Arguments
1911 ///
1912 /// - `throttle` - The throttle rate in milliseconds (f64), or `None` for
1913 /// adaptive throttling.
1914 ///
1915 /// # JavaScript Examples
1916 ///
1917 /// Only draws at most 1 frame/sec:
1918 ///
1919 /// ```rust
1920 /// viewer.setThrottle(1000);
1921 /// ```
1922 #[wasm_bindgen]
1923 pub fn setThrottle(&self, val: Option<f64>) {
1924 for panel in self
1925 .workspace
1926 .panel_ids()
1927 .into_iter()
1928 .filter_map(|id| self.workspace.panel(&id))
1929 {
1930 panel.renderer.set_throttle(val);
1931 }
1932 }
1933
1934 /// Toggle (or force) the config panel open/closed.
1935 ///
1936 /// # Arguments
1937 ///
1938 /// - `force` - Force the state of the panel open or closed, or `None` to
1939 /// toggle.
1940 ///
1941 /// # JavaScript Examples
1942 ///
1943 /// ```javascript
1944 /// await viewer.toggleConfig();
1945 /// ```
1946 #[wasm_bindgen]
1947 pub fn toggleConfig(&self, force: Option<bool>) -> ApiFuture<JsValue> {
1948 let effect = self.workspace.effects().guard();
1949 let root = self.root.clone();
1950 ApiFuture::new(async move {
1951 let _effect = effect;
1952 let force = force.map(SettingsUpdate::Update);
1953 let (sender, receiver) = channel::<ApiResult<wasm_bindgen::JsValue>>();
1954 root.borrow().as_ref().into_apierror()?.send_message(
1955 PerspectiveViewerMsg::ToggleSettingsInit(force, true, Some(sender)),
1956 );
1957
1958 receiver.await.map_err(|_| JsValue::from("Cancelled"))?
1959 })
1960 }
1961
1962 /// Get an `Array` of all of the plugin custom elements registered for this
1963 /// element. This may not include plugins which called
1964 /// [`registerPlugin`] after the host has rendered for the first time.
1965 #[wasm_bindgen]
1966 pub fn getAllPlugins(&self) -> Array {
1967 self.workspace
1968 .active_renderer()
1969 .map(|r| r.get_all_plugins().iter().collect::<Array>())
1970 .unwrap_or_default()
1971 }
1972
1973 /// Gets a plugin Custom Element with the `name` field, or get the active
1974 /// plugin if no `name` is provided.
1975 ///
1976 /// # Arguments
1977 ///
1978 /// - `name` - The `name` property of a perspective plugin Custom Element,
1979 /// or `None` for the active plugin's Custom Element.
1980 #[wasm_bindgen]
1981 pub fn getPlugin(&self, name: Option<String>) -> ApiResult<JsPerspectiveViewerPlugin> {
1982 let renderer = self
1983 .workspace
1984 .active_renderer()
1985 .ok_or_else(|| ApiError::new("No active panel"))?;
1986 match name {
1987 None => renderer.ensure_plugin_selected(),
1988 Some(name) => renderer.get_plugin(&name),
1989 }
1990 }
1991
1992 /// Add a new, independent panel to this viewer's layout, rendering the
1993 /// supplied [`ViewerConfigInitial`] into it. Unlike [`Self::restore`]'s
1994 /// update-shaped argument, a new panel has no prior state, so `table`
1995 /// is REQUIRED — a table-less call rejects before the layout is
1996 /// touched. The panel uses the default [`perspective_client::Client`]
1997 /// (the first passed to [`Self::load`]) to resolve its `table`. Returns
1998 /// the generated panel id.
1999 ///
2000 /// The element-level `settings` field does not exist on the argument
2001 /// type (it is shared across the element, not per-panel).
2002 ///
2003 /// # Arguments
2004 ///
2005 /// - `config` - The new panel's initial config, whose `table` is required.
2006 /// - `options.wait_for_table` - when `true`, a `table` no loaded client
2007 /// hosts yet creates the panel empty and pending until the table is
2008 /// created, instead of the default rejection.
2009 #[wasm_bindgen]
2010 pub fn addPanel(
2011 &self,
2012 config: JsViewerConfigInitial,
2013 options: Option<JsAddPanelOptions>,
2014 ) -> ApiFuture<JsValue> {
2015 clone!(self.elem, self.presentation, self.workspace);
2016 let effect = workspace.effects().guard();
2017 let notify = self.layout_changed_notify();
2018 ApiFuture::new(async move {
2019 let _effect = effect;
2020 let AddPanelOptions { wait_for_table } = parse_options(options)?;
2021 let config = ViewerConfigInitial::decode(&config)?;
2022 let id = create_panel(
2023 &elem,
2024 &presentation,
2025 &workspace,
2026 ¬ify,
2027 None,
2028 config,
2029 None,
2030 MissingTable::from_wait(wait_for_table),
2031 )
2032 .await?;
2033 Ok(JsValue::from_str(id.as_str()))
2034 })
2035 }
2036
2037 /// Get the ids of all panels in this viewer's layout, in insertion order.
2038 #[wasm_bindgen]
2039 pub fn getPanelNames(&self) -> Array {
2040 self.workspace
2041 .panel_ids()
2042 .iter()
2043 .map(|id| JsValue::from_str(id.as_str()))
2044 .collect()
2045 }
2046
2047 /// The id of the active panel — the one the settings panel and status-bar
2048 /// toolbar target — or `null` when the element has zero panels.
2049 #[wasm_bindgen]
2050 pub fn getActivePanel(&self) -> JsValue {
2051 self.workspace
2052 .active_id()
2053 .map(|id| JsValue::from_str(id.as_str()))
2054 .unwrap_or(JsValue::NULL)
2055 }
2056
2057 /// Make the panel with id `name` the active panel, re-targeting the
2058 /// settings panel and status-bar toolbar (and the root's
2059 /// session/renderer subscriptions) to its engines. Resolves after the
2060 /// activation-chrome redraws on both sides of the switch have completed
2061 /// (invariant I6).
2062 #[wasm_bindgen]
2063 pub fn setActivePanel(&self, name: String) -> ApiFuture<()> {
2064 let effect = self.workspace.effects().guard();
2065 let root = self.root.clone();
2066 ApiFuture::new(async move {
2067 let _effect = effect;
2068 let (completion, receiver) = Completion::new();
2069 root.borrow()
2070 .as_ref()
2071 .into_apierror()?
2072 .send_message(PerspectiveViewerMsg::SetActivePanel(name, Some(completion)));
2073
2074 receiver.await.map_err(|_| ApiError::new("Cancelled"))?
2075 })
2076 }
2077
2078 /// Remove the panel with id `name` from the layout, disposing its engines
2079 /// (its `View` is deleted and its `Table` reference released). The last
2080 /// remaining panel cannot be removed (resolves as a no-op). Resolves
2081 /// after the panel's teardown run completes, carrying any teardown
2082 /// error — previously fire-and-forget and silently dropped (invariant
2083 /// I6). See also [`Self::addPanel`].
2084 #[wasm_bindgen]
2085 pub fn removePanel(&self, name: String) -> ApiFuture<()> {
2086 let effect = self.workspace.effects().guard();
2087 let root = self.root.clone();
2088 ApiFuture::new(async move {
2089 let _effect = effect;
2090 let (completion, receiver) = Completion::new();
2091 root.borrow()
2092 .as_ref()
2093 .into_apierror()?
2094 .send_message(PerspectiveViewerMsg::ClosePanel(name, Some(completion)));
2095
2096 receiver.await.map_err(|_| ApiError::new("Cancelled"))?
2097 })
2098 }
2099
2100 /// Create a new JavaScript Heap reference for this model instance.
2101 #[doc(hidden)]
2102 #[allow(clippy::use_self)]
2103 #[wasm_bindgen]
2104 pub fn __get_model(&self) -> PerspectiveViewerElement {
2105 self.clone()
2106 }
2107
2108 /// Asynchronously opens the column settings for a specific column.
2109 /// When finished, the `<perspective-viewer>` element will emit a
2110 /// "perspective-toggle-column-settings" CustomEvent.
2111 /// The event's details property has two fields: `{open: bool, column_name?:
2112 /// string}`. The CustomEvent is also fired whenever the user toggles the
2113 /// sidebar manually.
2114 #[wasm_bindgen]
2115 pub fn toggleColumnSettings(
2116 &self,
2117 column_name: String,
2118 options: Option<JsPanelOptions>,
2119 ) -> ApiFuture<()> {
2120 let effect = self.workspace.effects().guard();
2121 let this = self.clone();
2122 ApiFuture::new_throttled(async move {
2123 let PanelOptions { panel: name } = parse_options(options)?;
2124 let _effect = effect;
2125 let panel = this.resolve_panel(name)?;
2126 let was_active = this.workspace.active_id().as_ref() == Some(&panel.id);
2127 let target = {
2128 let config = panel.session.get_view_config();
2129 let metadata = panel.session.metadata();
2130 classify_column(&column_name, &config, &metadata)
2131 .map(|_| crate::presentation::ColumnSettingsTarget::Column(column_name))
2132 };
2133 if !was_active {
2134 this.root.borrow().as_ref().into_apierror()?.send_message(
2135 PerspectiveViewerMsg::SetActivePanel(panel.id.as_str().to_owned(), None),
2136 );
2137 }
2138
2139 let (sender, receiver) = channel::<()>();
2140 this.root.borrow().as_ref().into_apierror()?.send_message(
2141 PerspectiveViewerMsg::OpenColumnSettings {
2142 target,
2143 sender: Some(sender),
2144 toggle: was_active,
2145 },
2146 );
2147
2148 receiver.await.map_err(|_| ApiError::from("Cancelled"))
2149 })
2150 }
2151}
2152
2153#[cfg(feature = "llm-agent")]
2154#[wasm_bindgen]
2155impl PerspectiveViewerElement {
2156 /// Configure the embedded LLM agent (see `prompt()`), replacing any prior
2157 /// configuration and conversation.
2158 ///
2159 /// The agent core is provider-agnostic: one OpenAI-chat-completions
2160 /// protocol over primitive connection fields. Exactly one of
2161 /// `config.url` or `config.engine` is required; the `providers` presets
2162 /// exported by this package are plain spreadable collections of these
2163 /// fields (`{...providers.anthropic, apiKey}`).
2164 ///
2165 /// - `config.url` - a full chat-completions endpoint URL (any
2166 /// OpenAI-compatible service: Anthropic/Gemini compatibility endpoints,
2167 /// LM Studio, Ollama, OpenRouter, a proxy...).
2168 /// - `config.engine` - an in-page engine object with an OpenAI-compatible
2169 /// `chat.completions.create(request)` method (e.g. WebLLM's `MLCEngine`);
2170 /// mutually exclusive with `url`.
2171 /// - `config.headers` - extra request headers, sent verbatim.
2172 /// - `config.apiKey` - sugar for the `Authorization: Bearer` header.
2173 /// - `config.model` - model id sent with each request; local servers and
2174 /// engines generally answer with whatever model is loaded.
2175 /// - `config.name` - cosmetic label for the chat badge (presets set this).
2176 /// - `config.systemPrompt` - extra system-prompt context appended to the
2177 /// agent's built-in instructions.
2178 /// - `config.maxTurns` - max model turns (tool-call rounds + the final
2179 /// answer) per `prompt()` call. Defaults to 16.
2180 /// - `config.docs` - the agent metadata bundle, which supplies the
2181 /// `search_docs` corpus and the rich tool parameter schemas: the packaged
2182 /// `dist/docs/perspective-docs.json` asset as a parsed object (`import
2183 /// docs from "…json" with { type: "json" }`), a `fetch()` `Response`, an
2184 /// `ArrayBuffer`, a JSON string, or a `Promise` of any of those — and/or
2185 /// an inline array of `{title?, text}` entries for host data definitions.
2186 /// Omitted, `search_docs` searches an empty corpus and the parameter
2187 /// schemas degrade to permissive objects.
2188 /// - `config.systemRole` - where the preamble (plus `systemPrompt`) is
2189 /// placed: `"system"` (default) or `"user"`. Some engines refuse a system
2190 /// message alongside `tools` because they substitute their own — WebLLM's
2191 /// Hermes function calling throws `CustomSystemPromptError` on ANY system
2192 /// message — and those need `"user"`, which folds the same text into the
2193 /// opening user turn.
2194 /// - `config.entitlements` - access grants limiting which tools the agent
2195 /// is offered (and may call): any of `"read_view"`, `"configure_view"`,
2196 /// `"manage_layout"`, `"read_docs"`, `"read_data"`. Omitted, all but
2197 /// `"read_data"` are granted; `["read_view", "read_docs"]` yields a
2198 /// read-only agent.
2199 ///
2200 /// # JavaScript Examples
2201 ///
2202 /// ```javascript
2203 /// import { providers } from "@perspective-dev/viewer";
2204 ///
2205 /// viewer.agentConfig({
2206 /// ...providers.anthropic,
2207 /// apiKey: "sk-ant-...",
2208 /// docs: fetch(
2209 /// "node_modules/@perspective-dev/viewer/dist/docs/perspective-docs.json",
2210 /// ),
2211 /// });
2212 /// ```
2213 #[wasm_bindgen(js_name = "agentConfig")]
2214 pub fn agent_config(&self, config: JsValue) -> ApiResult<()> {
2215 let runtime = AgentRuntime::new(&config, self.clone())?;
2216 self.presentation.agent.configure(runtime);
2217 Ok(())
2218 }
2219
2220 /// Run one conversational turn of the embedded LLM agent (configured via
2221 /// `agentConfig()`), resolving with the agent's final text response after
2222 /// any tool calls have been applied to this element. Turns share a
2223 /// conversation history (and the chat sidebar's transcript) until
2224 /// `agentReset()`; a call made while a turn is already running rejects.
2225 /// Tool activity is emitted as `perspective-agent-tool` CustomEvents on
2226 /// this element.
2227 ///
2228 /// # JavaScript Examples
2229 ///
2230 /// ```javascript
2231 /// await viewer.agentPrompt("Show me sales by region as a bar chart");
2232 /// ```
2233 #[wasm_bindgen(js_name = "agentPrompt")]
2234 pub fn agent_prompt(&self, prompt: String) -> js_sys::Promise {
2235 let presentation = self.presentation.clone();
2236 let fut = ApiFuture::new(async move {
2237 Ok(JsValue::from(presentation.agent.run_prompt(prompt).await?))
2238 });
2239
2240 js_sys::Promise::from(fut)
2241 }
2242
2243 /// Clear the agent's conversation (history and chat transcript), keeping
2244 /// its configuration. Cancels any in-flight turn.
2245 #[wasm_bindgen(js_name = "agentReset")]
2246 pub fn agent_reset(&self) -> js_sys::Promise {
2247 let presentation = self.presentation.clone();
2248 let fut = ApiFuture::new(async move {
2249 presentation.agent.reset().await;
2250 Ok(JsValue::UNDEFINED)
2251 });
2252
2253 js_sys::Promise::from(fut)
2254 }
2255}