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